Consolidate public JSON serialization
This commit is contained in:
@@ -97,22 +97,22 @@ type RunResult struct {
|
|||||||
// PreparedRun contains pre-LLM execution state from the prepare/render phase.
|
// PreparedRun contains pre-LLM execution state from the prepare/render phase.
|
||||||
// It must never include resolved API key values, model output, or validation data.
|
// It must never include resolved API key values, model output, or validation data.
|
||||||
type PreparedRun struct {
|
type PreparedRun struct {
|
||||||
PromptID string `json:"prompt_id"`
|
PromptID string
|
||||||
PromptVersion string `json:"prompt_version,omitempty"`
|
PromptVersion string
|
||||||
PromptHash string `json:"prompt_hash,omitempty"`
|
PromptHash string
|
||||||
SelectedProfileID string `json:"selected_profile_id"`
|
SelectedProfileID string
|
||||||
SelectedBackendID string `json:"selected_backend_id,omitempty"`
|
SelectedBackendID string
|
||||||
EffectiveModelParams ExecutionTarget `json:"effective_model_params"`
|
EffectiveModelParams ExecutionTarget
|
||||||
TargetPresence ExecutionTargetPresence `json:"-"`
|
TargetPresence ExecutionTargetPresence
|
||||||
OutputContract OutputContract `json:"output_contract"`
|
OutputContract OutputContract
|
||||||
StructuredOutput *StructuredOutputSpec `json:"structured_output,omitempty"`
|
StructuredOutput *StructuredOutputSpec
|
||||||
InputHashes map[string]string `json:"input_hashes,omitempty"`
|
InputHashes map[string]string
|
||||||
SessionID string `json:"session_id,omitempty"`
|
SessionID string
|
||||||
RenderedPromptHash string `json:"rendered_prompt_hash"`
|
RenderedPromptHash string
|
||||||
Messages []RenderedMessage `json:"messages"`
|
Messages []RenderedMessage
|
||||||
StartTime time.Time `json:"start_time,omitempty"`
|
StartTime time.Time
|
||||||
EndTime time.Time `json:"end_time,omitempty"`
|
EndTime time.Time
|
||||||
DurationMS int64 `json:"duration_ms,omitempty"`
|
DurationMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtifactRef represents a reference to an input artifact.
|
// ArtifactRef represents a reference to an input artifact.
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPreparedRunJSONDoesNotIncludeSecretValues(t *testing.T) {
|
|
||||||
const envName = "PROMPTKIT_TEST_API_KEY"
|
|
||||||
const secret = "super-secret-value"
|
|
||||||
t.Setenv(envName, secret)
|
|
||||||
|
|
||||||
prepared := PreparedRun{
|
|
||||||
PromptID: "prompt.id",
|
|
||||||
PromptVersion: "v1",
|
|
||||||
PromptHash: "prompt-hash",
|
|
||||||
SelectedProfileID: "local-fast",
|
|
||||||
EffectiveModelParams: ExecutionTarget{
|
|
||||||
Endpoint: "http://llm/v1",
|
|
||||||
Model: "gpt-test",
|
|
||||||
APIKeyEnv: envName,
|
|
||||||
APIKey: secret,
|
|
||||||
},
|
|
||||||
InputHashes: map[string]string{"transcript": "hash-1"},
|
|
||||||
RenderedPromptHash: "rendered-hash",
|
|
||||||
Messages: []RenderedMessage{
|
|
||||||
{Role: "system", Content: "You are helpful."},
|
|
||||||
{Role: "user", Content: "Summarize this."},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := json.Marshal(prepared)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := string(b)
|
|
||||||
if strings.Contains(out, secret) {
|
|
||||||
t.Fatalf("prepared run JSON unexpectedly contains secret value: %s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, `"api_key_env":"`+envName+`"`) {
|
|
||||||
t.Fatalf("prepared run JSON should include api_key_env name: %s", out)
|
|
||||||
}
|
|
||||||
|
|
||||||
var top map[string]any
|
|
||||||
if err := json.Unmarshal(b, &top); err != nil {
|
|
||||||
t.Fatalf("unmarshal failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, forbidden := range []string{"raw_output", "validation", "artifact"} {
|
|
||||||
if _, ok := top[forbidden]; ok {
|
|
||||||
t.Fatalf("prepared run JSON should not include %q", forbidden)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPreparedRunJSONIncludesMessageCacheControlOnlyWhenPresent(t *testing.T) {
|
|
||||||
prepared := PreparedRun{
|
|
||||||
PromptID: "prompt.id",
|
|
||||||
SelectedProfileID: "local-fast",
|
|
||||||
EffectiveModelParams: ExecutionTarget{
|
|
||||||
Endpoint: "http://llm/v1",
|
|
||||||
Model: "gpt-test",
|
|
||||||
},
|
|
||||||
RenderedPromptHash: "rendered-hash",
|
|
||||||
Messages: []RenderedMessage{
|
|
||||||
{
|
|
||||||
Role: "system",
|
|
||||||
Content: "You are helpful.",
|
|
||||||
CacheControl: &CacheControl{
|
|
||||||
Type: CacheControlEphemeral,
|
|
||||||
TTL: "1h",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{Role: "user", Content: "Summarize this."},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := json.Marshal(prepared)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded struct {
|
|
||||||
Messages []map[string]any `json:"messages"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(b, &decoded); err != nil {
|
|
||||||
t.Fatalf("unmarshal failed: %v", err)
|
|
||||||
}
|
|
||||||
if len(decoded.Messages) != 2 {
|
|
||||||
t.Fatalf("expected 2 messages, got %d", len(decoded.Messages))
|
|
||||||
}
|
|
||||||
|
|
||||||
cacheControl, ok := decoded.Messages[0]["cache_control"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected cache_control on first message, got %#v", decoded.Messages[0])
|
|
||||||
}
|
|
||||||
if cacheControl["type"] != string(CacheControlEphemeral) || cacheControl["ttl"] != "1h" {
|
|
||||||
t.Fatalf("unexpected cache_control payload: %#v", cacheControl)
|
|
||||||
}
|
|
||||||
if _, ok := decoded.Messages[1]["cache_control"]; ok {
|
|
||||||
t.Fatalf("expected second message to omit cache_control, got %#v", decoded.Messages[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPreparedRunJSONIncludesSessionIDOnlyWhenPresent(t *testing.T) {
|
|
||||||
prepared := PreparedRun{
|
|
||||||
PromptID: "prompt.id",
|
|
||||||
SelectedProfileID: "local-fast",
|
|
||||||
EffectiveModelParams: ExecutionTarget{
|
|
||||||
Endpoint: "http://llm/v1",
|
|
||||||
Model: "gpt-test",
|
|
||||||
},
|
|
||||||
SessionID: "session-123",
|
|
||||||
RenderedPromptHash: "rendered-hash",
|
|
||||||
Messages: []RenderedMessage{{Role: "user", Content: "Summarize this."}},
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := json.Marshal(prepared)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded map[string]any
|
|
||||||
if err := json.Unmarshal(b, &decoded); err != nil {
|
|
||||||
t.Fatalf("unmarshal failed: %v", err)
|
|
||||||
}
|
|
||||||
if decoded["session_id"] != "session-123" {
|
|
||||||
t.Fatalf("expected session_id in prepared run JSON, got %#v", decoded["session_id"])
|
|
||||||
}
|
|
||||||
|
|
||||||
prepared.SessionID = ""
|
|
||||||
b, err = json.Marshal(prepared)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal failed: %v", err)
|
|
||||||
}
|
|
||||||
if strings.Contains(string(b), "session_id") {
|
|
||||||
t.Fatalf("expected empty session_id to be omitted, got %s", b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
142
json.go
142
json.go
@@ -2,9 +2,16 @@ package promptkit
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
minDurationMilliseconds = int64(time.Duration(math.MinInt64) / time.Millisecond)
|
||||||
|
maxDurationMilliseconds = int64(time.Duration(math.MaxInt64) / time.Millisecond)
|
||||||
|
)
|
||||||
|
|
||||||
// MarshalJSON implements json.Marshaler for PreparedRun. It uses RFC 3339
|
// MarshalJSON implements json.Marshaler for PreparedRun. It uses RFC 3339
|
||||||
// timestamps, integer duration_ms, and omits zero timing values.
|
// timestamps, integer duration_ms, and omits zero timing values.
|
||||||
func (r PreparedRun) MarshalJSON() ([]byte, error) {
|
func (r PreparedRun) MarshalJSON() ([]byte, error) {
|
||||||
@@ -21,38 +28,11 @@ func (r PreparedRun) MarshalJSON() ([]byte, error) {
|
|||||||
durationMS = &r.DurationMS
|
durationMS = &r.DurationMS
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.Marshal(struct {
|
return json.Marshal(preparedRunJSON{
|
||||||
PromptID string `json:"prompt_id"`
|
preparedRunJSONFields: preparedRunJSONFields(r),
|
||||||
PromptVersion string `json:"prompt_version,omitempty"`
|
StartTime: startTime,
|
||||||
PromptHash string `json:"prompt_hash,omitempty"`
|
EndTime: endTime,
|
||||||
SelectedProfileID string `json:"selected_profile_id"`
|
DurationMS: durationMS,
|
||||||
SelectedBackendID string `json:"selected_backend_id,omitempty"`
|
|
||||||
EffectiveModelParams ExecutionTarget `json:"effective_model_params"`
|
|
||||||
OutputContract OutputContract `json:"output_contract"`
|
|
||||||
StructuredOutput *StructuredOutputSpec `json:"structured_output,omitempty"`
|
|
||||||
InputHashes map[string]string `json:"input_hashes,omitempty"`
|
|
||||||
SessionID string `json:"session_id,omitempty"`
|
|
||||||
RenderedPromptHash string `json:"rendered_prompt_hash"`
|
|
||||||
Messages []RenderedMessage `json:"messages"`
|
|
||||||
StartTime *time.Time `json:"start_time,omitempty"`
|
|
||||||
EndTime *time.Time `json:"end_time,omitempty"`
|
|
||||||
DurationMS *int64 `json:"duration_ms,omitempty"`
|
|
||||||
}{
|
|
||||||
PromptID: r.PromptID,
|
|
||||||
PromptVersion: r.PromptVersion,
|
|
||||||
PromptHash: r.PromptHash,
|
|
||||||
SelectedProfileID: r.SelectedProfileID,
|
|
||||||
SelectedBackendID: r.SelectedBackendID,
|
|
||||||
EffectiveModelParams: r.EffectiveModelParams,
|
|
||||||
OutputContract: r.OutputContract,
|
|
||||||
StructuredOutput: r.StructuredOutput,
|
|
||||||
InputHashes: r.InputHashes,
|
|
||||||
SessionID: r.SessionID,
|
|
||||||
RenderedPromptHash: r.RenderedPromptHash,
|
|
||||||
Messages: r.Messages,
|
|
||||||
StartTime: startTime,
|
|
||||||
EndTime: endTime,
|
|
||||||
DurationMS: durationMS,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,89 +54,57 @@ func (r RunResult) MarshalJSON() ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return json.Marshal(runResultJSON{
|
return json.Marshal(runResultJSON{
|
||||||
RunID: r.RunID,
|
runResultJSONFields: runResultJSONFields(r),
|
||||||
Artifact: r.Artifact,
|
StartTime: startTime,
|
||||||
RawOutput: r.RawOutput,
|
EndTime: endTime,
|
||||||
Validation: r.Validation,
|
DurationMS: durationMS,
|
||||||
PromptID: r.PromptID,
|
|
||||||
PromptVersion: r.PromptVersion,
|
|
||||||
PromptHash: r.PromptHash,
|
|
||||||
SessionID: r.SessionID,
|
|
||||||
RenderedPromptHash: r.RenderedPromptHash,
|
|
||||||
SelectedProfileID: r.SelectedProfileID,
|
|
||||||
SelectedBackendID: r.SelectedBackendID,
|
|
||||||
ModelName: r.ModelName,
|
|
||||||
Endpoint: r.Endpoint,
|
|
||||||
EffectiveModelParams: r.EffectiveModelParams,
|
|
||||||
InputHashes: r.InputHashes,
|
|
||||||
Usage: r.Usage,
|
|
||||||
StartTime: startTime,
|
|
||||||
EndTime: endTime,
|
|
||||||
DurationMS: durationMS,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnmarshalJSON implements json.Unmarshaler for RunResult. It decodes
|
// UnmarshalJSON implements json.Unmarshaler for RunResult. It decodes
|
||||||
// duration_ms into Duration with millisecond precision.
|
// duration_ms into Duration with millisecond precision. A duration_ms outside
|
||||||
|
// the range representable by time.Duration returns an error without changing
|
||||||
|
// the receiver.
|
||||||
func (r *RunResult) UnmarshalJSON(data []byte) error {
|
func (r *RunResult) UnmarshalJSON(data []byte) error {
|
||||||
var wire runResultJSON
|
var wire runResultJSON
|
||||||
if err := json.Unmarshal(data, &wire); err != nil {
|
if err := json.Unmarshal(data, &wire); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
*r = RunResult{
|
result := RunResult(wire.runResultJSONFields)
|
||||||
RunID: wire.RunID,
|
if wire.DurationMS != nil {
|
||||||
Artifact: wire.Artifact,
|
if *wire.DurationMS < minDurationMilliseconds || *wire.DurationMS > maxDurationMilliseconds {
|
||||||
RawOutput: wire.RawOutput,
|
return fmt.Errorf(
|
||||||
Validation: wire.Validation,
|
"decode RunResult duration_ms: %d cannot be represented as time.Duration",
|
||||||
PromptID: wire.PromptID,
|
*wire.DurationMS,
|
||||||
PromptVersion: wire.PromptVersion,
|
)
|
||||||
PromptHash: wire.PromptHash,
|
}
|
||||||
SessionID: wire.SessionID,
|
result.Duration = time.Duration(*wire.DurationMS) * time.Millisecond
|
||||||
RenderedPromptHash: wire.RenderedPromptHash,
|
|
||||||
SelectedProfileID: wire.SelectedProfileID,
|
|
||||||
SelectedBackendID: wire.SelectedBackendID,
|
|
||||||
ModelName: wire.ModelName,
|
|
||||||
Endpoint: wire.Endpoint,
|
|
||||||
EffectiveModelParams: wire.EffectiveModelParams,
|
|
||||||
InputHashes: wire.InputHashes,
|
|
||||||
Usage: wire.Usage,
|
|
||||||
Duration: time.Duration(valueOrZero(wire.DurationMS)) * time.Millisecond,
|
|
||||||
}
|
}
|
||||||
if wire.StartTime != nil {
|
if wire.StartTime != nil {
|
||||||
r.StartTime = *wire.StartTime
|
result.StartTime = *wire.StartTime
|
||||||
}
|
}
|
||||||
if wire.EndTime != nil {
|
if wire.EndTime != nil {
|
||||||
r.EndTime = *wire.EndTime
|
result.EndTime = *wire.EndTime
|
||||||
}
|
}
|
||||||
|
*r = result
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type runResultJSON struct {
|
type preparedRunJSONFields PreparedRun
|
||||||
RunID string `json:"run_id"`
|
|
||||||
Artifact Artifact `json:"artifact"`
|
type preparedRunJSON struct {
|
||||||
RawOutput string `json:"raw_output"`
|
preparedRunJSONFields
|
||||||
Validation ValidationResult `json:"validation"`
|
StartTime *time.Time `json:"start_time,omitempty"`
|
||||||
PromptID string `json:"prompt_id"`
|
EndTime *time.Time `json:"end_time,omitempty"`
|
||||||
PromptVersion string `json:"prompt_version,omitempty"`
|
DurationMS *int64 `json:"duration_ms,omitempty"`
|
||||||
PromptHash string `json:"prompt_hash,omitempty"`
|
|
||||||
SessionID string `json:"session_id,omitempty"`
|
|
||||||
RenderedPromptHash string `json:"rendered_prompt_hash"`
|
|
||||||
SelectedProfileID string `json:"selected_profile_id"`
|
|
||||||
SelectedBackendID string `json:"selected_backend_id,omitempty"`
|
|
||||||
ModelName string `json:"model_name"`
|
|
||||||
Endpoint string `json:"endpoint"`
|
|
||||||
EffectiveModelParams ExecutionTarget `json:"effective_model_params"`
|
|
||||||
InputHashes map[string]string `json:"input_hashes,omitempty"`
|
|
||||||
Usage TokenUsage `json:"usage"`
|
|
||||||
StartTime *time.Time `json:"start_time,omitempty"`
|
|
||||||
EndTime *time.Time `json:"end_time,omitempty"`
|
|
||||||
DurationMS *int64 `json:"duration_ms,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func valueOrZero(value *int64) int64 {
|
type runResultJSONFields RunResult
|
||||||
if value == nil {
|
|
||||||
return 0
|
type runResultJSON struct {
|
||||||
}
|
runResultJSONFields
|
||||||
return *value
|
StartTime *time.Time `json:"start_time,omitempty"`
|
||||||
|
EndTime *time.Time `json:"end_time,omitempty"`
|
||||||
|
DurationMS *int64 `json:"duration_ms,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
328
json_contract_test.go
Normal file
328
json_contract_test.go
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPreparedRunJSONContractRoundTripsAllFields(t *testing.T) {
|
||||||
|
value := fullyPopulatedPreparedRun()
|
||||||
|
payload, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal PreparedRun: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
object := decodeJSONObject(t, payload)
|
||||||
|
assertJSONFields(t, object,
|
||||||
|
"prompt_id",
|
||||||
|
"prompt_version",
|
||||||
|
"prompt_hash",
|
||||||
|
"selected_profile_id",
|
||||||
|
"selected_backend_id",
|
||||||
|
"effective_model_params",
|
||||||
|
"output_contract",
|
||||||
|
"structured_output",
|
||||||
|
"input_hashes",
|
||||||
|
"session_id",
|
||||||
|
"rendered_prompt_hash",
|
||||||
|
"messages",
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"duration_ms",
|
||||||
|
)
|
||||||
|
|
||||||
|
var messages []map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(object["messages"], &messages); err != nil {
|
||||||
|
t.Fatalf("decode PreparedRun messages: %v", err)
|
||||||
|
}
|
||||||
|
if len(messages) != 2 {
|
||||||
|
t.Fatalf("message count = %d, want 2", len(messages))
|
||||||
|
}
|
||||||
|
if _, ok := messages[0]["cache_control"]; !ok {
|
||||||
|
t.Fatalf("first message omitted cache_control: %s", object["messages"])
|
||||||
|
}
|
||||||
|
if _, ok := messages[1]["cache_control"]; ok {
|
||||||
|
t.Fatalf("second message included empty cache_control: %s", object["messages"])
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded promptkit.PreparedRun
|
||||||
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||||
|
t.Fatalf("unmarshal PreparedRun: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(decoded, value) {
|
||||||
|
t.Fatalf("PreparedRun did not round trip:\ngot %#v\nwant %#v", decoded, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
zeroPayload, err := json.Marshal(promptkit.PreparedRun{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal zero PreparedRun: %v", err)
|
||||||
|
}
|
||||||
|
zeroObject := decodeJSONObject(t, zeroPayload)
|
||||||
|
for _, field := range []string{
|
||||||
|
"prompt_version",
|
||||||
|
"prompt_hash",
|
||||||
|
"selected_backend_id",
|
||||||
|
"structured_output",
|
||||||
|
"input_hashes",
|
||||||
|
"session_id",
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"duration_ms",
|
||||||
|
} {
|
||||||
|
if _, ok := zeroObject[field]; ok {
|
||||||
|
t.Fatalf("zero PreparedRun included %q: %s", field, zeroPayload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunResultJSONContractRoundTripsAllFields(t *testing.T) {
|
||||||
|
value := fullyPopulatedRunResult()
|
||||||
|
payload, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal RunResult: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
object := decodeJSONObject(t, payload)
|
||||||
|
assertJSONFields(t, object,
|
||||||
|
"run_id",
|
||||||
|
"artifact",
|
||||||
|
"raw_output",
|
||||||
|
"validation",
|
||||||
|
"prompt_id",
|
||||||
|
"prompt_version",
|
||||||
|
"prompt_hash",
|
||||||
|
"session_id",
|
||||||
|
"rendered_prompt_hash",
|
||||||
|
"selected_profile_id",
|
||||||
|
"selected_backend_id",
|
||||||
|
"model_name",
|
||||||
|
"endpoint",
|
||||||
|
"effective_model_params",
|
||||||
|
"input_hashes",
|
||||||
|
"usage",
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"duration_ms",
|
||||||
|
)
|
||||||
|
if _, ok := object["duration"]; ok {
|
||||||
|
t.Fatalf("RunResult included nanosecond duration field: %s", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded promptkit.RunResult
|
||||||
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||||
|
t.Fatalf("unmarshal RunResult: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(decoded, value) {
|
||||||
|
t.Fatalf("RunResult did not round trip:\ngot %#v\nwant %#v", decoded, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
zeroPayload, err := json.Marshal(promptkit.RunResult{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal zero RunResult: %v", err)
|
||||||
|
}
|
||||||
|
zeroObject := decodeJSONObject(t, zeroPayload)
|
||||||
|
for _, field := range []string{
|
||||||
|
"prompt_version",
|
||||||
|
"prompt_hash",
|
||||||
|
"session_id",
|
||||||
|
"selected_backend_id",
|
||||||
|
"input_hashes",
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"duration_ms",
|
||||||
|
"duration",
|
||||||
|
} {
|
||||||
|
if _, ok := zeroObject[field]; ok {
|
||||||
|
t.Fatalf("zero RunResult included %q: %s", field, zeroPayload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunResultJSONDurationMillisecondBoundaries(t *testing.T) {
|
||||||
|
maxMilliseconds := int64(time.Duration(math.MaxInt64) / time.Millisecond)
|
||||||
|
minMilliseconds := int64(time.Duration(math.MinInt64) / time.Millisecond)
|
||||||
|
|
||||||
|
for _, milliseconds := range []int64{minMilliseconds, maxMilliseconds} {
|
||||||
|
t.Run(strconv.FormatInt(milliseconds, 10), func(t *testing.T) {
|
||||||
|
var decoded promptkit.RunResult
|
||||||
|
if err := json.Unmarshal(durationPayload(milliseconds), &decoded); err != nil {
|
||||||
|
t.Fatalf("decode representable duration_ms: %v", err)
|
||||||
|
}
|
||||||
|
want := time.Duration(milliseconds) * time.Millisecond
|
||||||
|
if decoded.Duration != want {
|
||||||
|
t.Fatalf("Duration = %v, want %v", decoded.Duration, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, milliseconds := range []int64{
|
||||||
|
minMilliseconds - 1,
|
||||||
|
maxMilliseconds + 1,
|
||||||
|
math.MinInt64,
|
||||||
|
math.MaxInt64,
|
||||||
|
} {
|
||||||
|
t.Run(strconv.FormatInt(milliseconds, 10), func(t *testing.T) {
|
||||||
|
original := fullyPopulatedRunResult()
|
||||||
|
decoded := original
|
||||||
|
err := json.Unmarshal(durationPayload(milliseconds), &decoded)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "duration_ms") || !strings.Contains(err.Error(), "time.Duration") {
|
||||||
|
t.Fatalf("overflow error = %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(decoded, original) {
|
||||||
|
t.Fatalf("failed decode partially updated receiver:\ngot %#v\nwant %#v", decoded, original)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fullyPopulatedPreparedRun() promptkit.PreparedRun {
|
||||||
|
start := time.Date(2026, time.August, 11, 12, 13, 14, 150_000_000, time.UTC)
|
||||||
|
return promptkit.PreparedRun{
|
||||||
|
PromptID: "prompt.prepared",
|
||||||
|
PromptVersion: "2.1.0",
|
||||||
|
PromptHash: "prompt-hash",
|
||||||
|
SelectedProfileID: "profile-prepared",
|
||||||
|
SelectedBackendID: "backend-prepared",
|
||||||
|
EffectiveModelParams: jsonContractExecutionTarget(),
|
||||||
|
OutputContract: promptkit.OutputContract{
|
||||||
|
Format: promptkit.FormatJSON,
|
||||||
|
ValidationMode: promptkit.ValidationJSONSchema,
|
||||||
|
SchemaPath: "schemas/prepared.json",
|
||||||
|
RepairAttempts: 2,
|
||||||
|
},
|
||||||
|
StructuredOutput: &promptkit.StructuredOutputSpec{
|
||||||
|
Type: promptkit.StructuredOutputJSONSchema,
|
||||||
|
JSONSchema: &promptkit.StructuredOutputJSONSpec{
|
||||||
|
Name: "prepared_schema",
|
||||||
|
Strict: true,
|
||||||
|
Schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"required": []any{"value"},
|
||||||
|
"properties": map[string]any{
|
||||||
|
"value": map[string]any{"minimum": float64(1)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputHashes: map[string]string{"first": "hash-1", "second": "hash-2"},
|
||||||
|
SessionID: "session-prepared",
|
||||||
|
RenderedPromptHash: "rendered-hash",
|
||||||
|
Messages: []promptkit.RenderedMessage{
|
||||||
|
{
|
||||||
|
Role: "system",
|
||||||
|
Content: "System message",
|
||||||
|
CacheControl: &promptkit.CacheControl{
|
||||||
|
Type: promptkit.CacheControlEphemeral,
|
||||||
|
TTL: "1h",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{Role: "user", Content: "User message"},
|
||||||
|
},
|
||||||
|
StartTime: start,
|
||||||
|
EndTime: start.Add(1501 * time.Millisecond),
|
||||||
|
DurationMS: 1501,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fullyPopulatedRunResult() promptkit.RunResult {
|
||||||
|
start := time.Date(2026, time.August, 11, 15, 16, 17, 250_000_000, time.UTC)
|
||||||
|
return promptkit.RunResult{
|
||||||
|
RunID: "run-id",
|
||||||
|
Artifact: promptkit.Artifact{
|
||||||
|
Name: "result.json",
|
||||||
|
ContentType: "application/json",
|
||||||
|
Body: []byte(`{"value":2}`),
|
||||||
|
URI: "memory://result.json",
|
||||||
|
Size: 11,
|
||||||
|
Hash: "artifact-hash",
|
||||||
|
},
|
||||||
|
RawOutput: `{"value":2}`,
|
||||||
|
Validation: promptkit.ValidationResult{
|
||||||
|
Status: promptkit.ValidationFailed,
|
||||||
|
Mode: promptkit.ValidationJSONSchema,
|
||||||
|
Errors: []string{"first error", "second error"},
|
||||||
|
SchemaPath: "schemas/result.json",
|
||||||
|
RepairAttempts: 2,
|
||||||
|
IsValid: false,
|
||||||
|
},
|
||||||
|
PromptID: "prompt.result",
|
||||||
|
PromptVersion: "3.2.1",
|
||||||
|
PromptHash: "result-prompt-hash",
|
||||||
|
SessionID: "session-result",
|
||||||
|
RenderedPromptHash: "result-rendered-hash",
|
||||||
|
SelectedProfileID: "profile-result",
|
||||||
|
SelectedBackendID: "backend-result",
|
||||||
|
ModelName: "model-result",
|
||||||
|
Endpoint: "https://result.example/v1",
|
||||||
|
EffectiveModelParams: jsonContractExecutionTarget(),
|
||||||
|
InputHashes: map[string]string{"input": "input-hash"},
|
||||||
|
Usage: promptkit.TokenUsage{
|
||||||
|
PromptTokens: 101,
|
||||||
|
CompletionTokens: 202,
|
||||||
|
TotalTokens: 303,
|
||||||
|
CachedTokens: 44,
|
||||||
|
CacheWriteTokens: 55,
|
||||||
|
},
|
||||||
|
StartTime: start,
|
||||||
|
EndTime: start.Add(1750 * time.Millisecond),
|
||||||
|
Duration: 1750 * time.Millisecond,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonContractExecutionTarget() promptkit.ExecutionTarget {
|
||||||
|
return promptkit.ExecutionTarget{
|
||||||
|
BackendID: "backend-target",
|
||||||
|
Endpoint: "https://target.example/v1",
|
||||||
|
Model: "model-target",
|
||||||
|
Temperature: 0.75,
|
||||||
|
MaxTokens: 321,
|
||||||
|
TopP: 0.875,
|
||||||
|
TimeoutSeconds: 43,
|
||||||
|
ServiceTier: "priority",
|
||||||
|
ReasoningEffort: "high",
|
||||||
|
APIKeyEnv: "PROMPTKIT_JSON_CONTRACT_KEY",
|
||||||
|
ExtraParams: map[string]any{
|
||||||
|
"enabled": true,
|
||||||
|
"weight": float64(1.25),
|
||||||
|
"nested": map[string]any{"name": "value"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func durationPayload(milliseconds int64) []byte {
|
||||||
|
return []byte(`{"duration_ms":` + strconv.FormatInt(milliseconds, 10) + `}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSONObject(t *testing.T, payload []byte) map[string]json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
var object map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(payload, &object); err != nil {
|
||||||
|
t.Fatalf("decode JSON object: %v", err)
|
||||||
|
}
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertJSONFields(t *testing.T, object map[string]json.RawMessage, fields ...string) {
|
||||||
|
t.Helper()
|
||||||
|
want := make(map[string]struct{}, len(fields))
|
||||||
|
for _, field := range fields {
|
||||||
|
want[field] = struct{}{}
|
||||||
|
}
|
||||||
|
for field := range object {
|
||||||
|
if _, ok := want[field]; !ok {
|
||||||
|
t.Errorf("unexpected JSON field %q", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for field := range want {
|
||||||
|
if _, ok := object[field]; !ok {
|
||||||
|
t.Errorf("missing JSON field %q", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"gitea.maximumdirect.net/eric/promptkit"
|
||||||
)
|
)
|
||||||
@@ -391,18 +390,6 @@ output:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPreparedRunJSONOmitsZeroTimingValues(t *testing.T) {
|
|
||||||
payload, err := json.Marshal(promptkit.PreparedRun{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal prepared run: %v", err)
|
|
||||||
}
|
|
||||||
for _, field := range []string{"start_time", "end_time", "duration_ms"} {
|
|
||||||
if strings.Contains(string(payload), `"`+field+`"`) {
|
|
||||||
t.Fatalf("expected zero %s to be omitted, got %s", field, payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBackendIdentityJSONNamesAndOmission(t *testing.T) {
|
func TestBackendIdentityJSONNamesAndOmission(t *testing.T) {
|
||||||
t.Run("execution target round trip", func(t *testing.T) {
|
t.Run("execution target round trip", func(t *testing.T) {
|
||||||
value := promptkit.ExecutionTarget{BackendID: promptkit.BackendOpenRouter}
|
value := promptkit.ExecutionTarget{BackendID: promptkit.BackendOpenRouter}
|
||||||
@@ -819,92 +806,6 @@ func TestBackendExtraParamsAreDeeplyCopiedAtConstructionAndLookup(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPreparedRunJSONTimingRoundTrips(t *testing.T) {
|
|
||||||
start := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
|
|
||||||
prepared := promptkit.PreparedRun{
|
|
||||||
PromptID: "prompt",
|
|
||||||
StartTime: start,
|
|
||||||
EndTime: start.Add(1250 * time.Millisecond),
|
|
||||||
DurationMS: 1250,
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := json.Marshal(prepared)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal prepared run: %v", err)
|
|
||||||
}
|
|
||||||
var decoded promptkit.PreparedRun
|
|
||||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
|
||||||
t.Fatalf("unmarshal prepared run: %v", err)
|
|
||||||
}
|
|
||||||
if decoded.DurationMS != prepared.DurationMS ||
|
|
||||||
!decoded.StartTime.Equal(prepared.StartTime) ||
|
|
||||||
!decoded.EndTime.Equal(prepared.EndTime) {
|
|
||||||
t.Fatalf("timing values did not round trip: got %#v, want %#v", decoded, prepared)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunResultJSONUsesMillisecondsAndRoundTrips(t *testing.T) {
|
|
||||||
start := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
|
|
||||||
result := promptkit.RunResult{
|
|
||||||
RunID: "opaque-run-id",
|
|
||||||
Artifact: promptkit.Artifact{Name: "output", ContentType: "text/plain", Body: []byte("ok")},
|
|
||||||
SessionID: "session-123",
|
|
||||||
StartTime: start,
|
|
||||||
EndTime: start.Add(1500 * time.Millisecond),
|
|
||||||
Duration: 1500 * time.Millisecond,
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := json.Marshal(result)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal run result: %v", err)
|
|
||||||
}
|
|
||||||
var object map[string]any
|
|
||||||
if err := json.Unmarshal(payload, &object); err != nil {
|
|
||||||
t.Fatalf("decode run result JSON: %v", err)
|
|
||||||
}
|
|
||||||
if got := object["duration_ms"]; got != float64(1500) {
|
|
||||||
t.Fatalf("expected duration_ms=1500, got %#v in %s", got, payload)
|
|
||||||
}
|
|
||||||
if _, exists := object["duration"]; exists {
|
|
||||||
t.Fatalf("unexpected nanosecond duration field in %s", payload)
|
|
||||||
}
|
|
||||||
if got := object["session_id"]; got != result.SessionID {
|
|
||||||
t.Fatalf("expected session_id=%q, got %#v in %s", result.SessionID, got, payload)
|
|
||||||
}
|
|
||||||
artifact, ok := object["artifact"].(map[string]any)
|
|
||||||
if !ok || artifact["content_type"] != "text/plain" {
|
|
||||||
t.Fatalf("expected stable artifact JSON fields, got %#v", object["artifact"])
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded promptkit.RunResult
|
|
||||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
|
||||||
t.Fatalf("unmarshal run result: %v", err)
|
|
||||||
}
|
|
||||||
if decoded.SessionID != result.SessionID ||
|
|
||||||
decoded.Duration != result.Duration ||
|
|
||||||
!decoded.StartTime.Equal(result.StartTime) ||
|
|
||||||
!decoded.EndTime.Equal(result.EndTime) {
|
|
||||||
t.Fatalf("timing values did not round trip: got %#v, want %#v", decoded, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err = json.Marshal(promptkit.RunResult{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal zero run result: %v", err)
|
|
||||||
}
|
|
||||||
for _, field := range []string{"session_id", "start_time", "end_time", "duration_ms"} {
|
|
||||||
if strings.Contains(string(payload), `"`+field+`"`) {
|
|
||||||
t.Fatalf("expected zero %s to be omitted, got %s", field, payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var decodedEmpty promptkit.RunResult
|
|
||||||
if err := json.Unmarshal(payload, &decodedEmpty); err != nil {
|
|
||||||
t.Fatalf("unmarshal run result without session_id: %v", err)
|
|
||||||
}
|
|
||||||
if decodedEmpty.SessionID != "" {
|
|
||||||
t.Fatalf("expected absent session_id to decode empty, got %q", decodedEmpty.SessionID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngineValidationIsSinglePass(t *testing.T) {
|
func TestEngineValidationIsSinglePass(t *testing.T) {
|
||||||
client := &fakeLLMClient{
|
client := &fakeLLMClient{
|
||||||
response: &promptkit.GenerateResponse{Content: "not-json"},
|
response: &promptkit.GenerateResponse{Content: "not-json"},
|
||||||
|
|||||||
Reference in New Issue
Block a user