Preserve repair settings and cumulative usage
This commit is contained in:
@@ -56,7 +56,10 @@ reserve another bounded slot.
|
|||||||
|
|
||||||
`NewClient` wraps the engine's selected internal model client after public
|
`NewClient` wraps the engine's selected internal model client after public
|
||||||
client adaptation or built-in client construction. Initial generation and the
|
client adaptation or built-in client construction. Initial generation and the
|
||||||
default repairer receive the same wrapper.
|
default repairer receive the same wrapper. Their requests retain the same
|
||||||
|
effective backend, credential, numeric-presence metadata, and structured-output
|
||||||
|
settings, so scheduling does not change provider omission semantics between
|
||||||
|
calls.
|
||||||
|
|
||||||
For each `Generate` call, the wrapper selects a pool from the request's
|
For each `Generate` call, the wrapper selects a pool from the request's
|
||||||
effective backend ID. An unlimited call passes directly to the next client. A
|
effective backend ID. An unlimited call passes directly to the next client. A
|
||||||
@@ -71,7 +74,9 @@ other backend IDs.
|
|||||||
|
|
||||||
The wrapper passes generation requests, responses, and collaborator errors
|
The wrapper passes generation requests, responses, and collaborator errors
|
||||||
through unchanged. It owns scheduling only; the concrete model client remains
|
through unchanged. It owns scheduling only; the concrete model client remains
|
||||||
responsible for provider transport behavior.
|
responsible for provider transport behavior. The runner, rather than the
|
||||||
|
capacity layer, sums all five usage fields from the initial response and every
|
||||||
|
completed repair response into the successful run result.
|
||||||
|
|
||||||
## Cancellation And Release
|
## Cancellation And Release
|
||||||
|
|
||||||
|
|||||||
@@ -144,20 +144,27 @@ each actual generation call.
|
|||||||
|
|
||||||
When an internal repairer is present, a JSON or JSON Schema content failure can
|
When an internal repairer is present, a JSON or JSON Schema content failure can
|
||||||
trigger bounded repair attempts. Repair receives the effective execution
|
trigger bounded repair attempts. Repair receives the effective execution
|
||||||
target and session ID, validation errors, prior output, and structured-output
|
target, explicit numeric-presence bits, credential, backend identity, session
|
||||||
specification. The default repairer uses the same wrapped client as initial
|
ID, validation errors, prior output, and structured-output specification. One
|
||||||
generation, so each repair reacquires the selected backend's active permit
|
request constructor supplies those common fields to initial and repair
|
||||||
while remaining inside its original admission lease. Repair never performs a
|
generation while their rendered prompts remain intentionally distinct. The
|
||||||
second bounded admission, and repaired outputs use the operation's existing
|
default repairer uses the same wrapped client as initial generation, so each
|
||||||
validation plan. This capability remains internal and is not a public option.
|
repair reacquires the selected backend's active permit while remaining inside
|
||||||
|
its original admission lease. Repair never performs a second bounded
|
||||||
|
admission, and repaired outputs use the operation's existing validation plan.
|
||||||
|
This capability remains internal and is not a public option.
|
||||||
|
|
||||||
A successful result includes the output artifact and raw output, validation
|
A successful result includes the output artifact and raw output, validation
|
||||||
state, effective session ID, prompt and rendered-prompt hashes, selected
|
state, effective session ID, prompt and rendered-prompt hashes, selected
|
||||||
profile and backend, effective settings, input hashes, token usage, a generated
|
profile and backend, effective settings, input hashes, token usage, a generated
|
||||||
run identifier, and UTC timing. The same effective session reaches initial
|
run identifier, and UTC timing. The same effective session reaches initial
|
||||||
generation and any repair attempt through the rendered prompt. The same
|
generation and any repair attempt through the rendered prompt. The same
|
||||||
effective target, including backend identity, reaches generation and any
|
effective target and presence metadata, including backend identity and direct
|
||||||
repair attempt.
|
credential during execution, reaches generation and every repair attempt.
|
||||||
|
Result usage is the field-wise sum of all five usage values from the initial
|
||||||
|
response and every completed repair response. Final raw output, artifact, and
|
||||||
|
validation state still come from the last candidate. A repair error returns no
|
||||||
|
partial run result or partial usage.
|
||||||
|
|
||||||
## Failure Categories
|
## Failure Categories
|
||||||
|
|
||||||
@@ -193,8 +200,9 @@ The [runner tests](../../internal/usecase/runner_test.go) own preparation order,
|
|||||||
selection and override precedence, the two-phase boundary, early admission,
|
selection and override precedence, the two-phase boundary, early admission,
|
||||||
lease lifetime and release, direct-session resolution, schema-before-generation
|
lease lifetime and release, direct-session resolution, schema-before-generation
|
||||||
behavior, hashing, generation and validation outcomes, backend propagation,
|
behavior, hashing, generation and validation outcomes, backend propagation,
|
||||||
bounded repair, shared initial/repair capacity, credentials and redaction,
|
bounded repair progression, initial/repair request parity, cumulative usage,
|
||||||
error categories, artifact metadata, usage, and timing. The
|
shared initial/repair capacity, credentials and redaction, error categories,
|
||||||
|
artifact metadata, and timing. The
|
||||||
[capacity subsystem document](capacity.md) identifies the focused pool,
|
[capacity subsystem document](capacity.md) identifies the focused pool,
|
||||||
waiter, and wrapped-client tests.
|
waiter, and wrapped-client tests.
|
||||||
|
|
||||||
|
|||||||
19
internal/usecase/generation_request.go
Normal file
19
internal/usecase/generation_request.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
|
||||||
|
func newGenerationRequest(
|
||||||
|
prompt domain.RenderedPrompt,
|
||||||
|
sessionID string,
|
||||||
|
target domain.ExecutionTarget,
|
||||||
|
targetPresence domain.ExecutionTargetPresence,
|
||||||
|
structuredOutput *domain.StructuredOutputSpec,
|
||||||
|
) domain.GenerateRequest {
|
||||||
|
prompt.SessionID = sessionID
|
||||||
|
return domain.GenerateRequest{
|
||||||
|
Prompt: prompt,
|
||||||
|
Target: target,
|
||||||
|
TargetPresence: targetPresence,
|
||||||
|
StructuredOutput: structuredOutput,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -317,7 +317,13 @@ func TestRunnerRunPreparedUsesFrozenValidationForInitialAndRepairOutputs(t *test
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
repairer := &fakeRepairer{
|
repairer := &fakeRepairer{
|
||||||
responses: []*domain.GenerateResponse{{Content: `{"repaired":true}`}},
|
responses: []*domain.GenerateResponse{{
|
||||||
|
Content: `{"repaired":true}`,
|
||||||
|
Usage: domain.TokenUsage{
|
||||||
|
PromptTokens: 2, CompletionTokens: 3, TotalTokens: 5,
|
||||||
|
CachedTokens: 7, CacheWriteTokens: 11,
|
||||||
|
},
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
admitter := &fakeRunAdmitter{}
|
admitter := &fakeRunAdmitter{}
|
||||||
reader := defaultArtifactReader()
|
reader := defaultArtifactReader()
|
||||||
@@ -328,7 +334,13 @@ func TestRunnerRunPreparedUsesFrozenValidationForInitialAndRepairOutputs(t *test
|
|||||||
nil,
|
nil,
|
||||||
reader,
|
reader,
|
||||||
renderer,
|
renderer,
|
||||||
&fakeLLM{resp: &domain.GenerateResponse{Content: `{"broken":true}`}},
|
&fakeLLM{resp: &domain.GenerateResponse{
|
||||||
|
Content: `{"broken":true}`,
|
||||||
|
Usage: domain.TokenUsage{
|
||||||
|
PromptTokens: 13, CompletionTokens: 17, TotalTokens: 19,
|
||||||
|
CachedTokens: 23, CacheWriteTokens: 29,
|
||||||
|
},
|
||||||
|
}},
|
||||||
validator,
|
validator,
|
||||||
repairer,
|
repairer,
|
||||||
admitter,
|
admitter,
|
||||||
@@ -355,6 +367,13 @@ func TestRunnerRunPreparedUsesFrozenValidationForInitialAndRepairOutputs(t *test
|
|||||||
if result.Validation.Status != domain.ValidationPassed || result.Validation.RepairAttempts != 1 {
|
if result.Validation.Status != domain.ValidationPassed || result.Validation.RepairAttempts != 1 {
|
||||||
t.Fatalf("unexpected repaired validation result: %+v", result.Validation)
|
t.Fatalf("unexpected repaired validation result: %+v", result.Validation)
|
||||||
}
|
}
|
||||||
|
wantUsage := domain.TokenUsage{
|
||||||
|
PromptTokens: 15, CompletionTokens: 20, TotalTokens: 24,
|
||||||
|
CachedTokens: 30, CacheWriteTokens: 40,
|
||||||
|
}
|
||||||
|
if result.Usage != wantUsage {
|
||||||
|
t.Fatalf("prepared cumulative usage = %+v, want %+v", result.Usage, wantUsage)
|
||||||
|
}
|
||||||
if admitter.releaseCalls != 1 {
|
if admitter.releaseCalls != 1 {
|
||||||
t.Fatalf("admission releases=%d, want 1", admitter.releaseCalls)
|
t.Fatalf("admission releases=%d, want 1", admitter.releaseCalls)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type RepairRequest struct {
|
|||||||
ValidationErrors []string
|
ValidationErrors []string
|
||||||
SessionID string
|
SessionID string
|
||||||
Target domain.ExecutionTarget
|
Target domain.ExecutionTarget
|
||||||
|
TargetPresence domain.ExecutionTargetPresence
|
||||||
StructuredOutput *domain.StructuredOutputSpec
|
StructuredOutput *domain.StructuredOutputSpec
|
||||||
Attempt int
|
Attempt int
|
||||||
MaxAttempts int
|
MaxAttempts int
|
||||||
@@ -44,7 +45,6 @@ func (r *defaultOutputRepairer) Repair(ctx context.Context, req RepairRequest) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
prompt := domain.RenderedPrompt{
|
prompt := domain.RenderedPrompt{
|
||||||
SessionID: req.SessionID,
|
|
||||||
Messages: []domain.RenderedMessage{
|
Messages: []domain.RenderedMessage{
|
||||||
{
|
{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
@@ -64,11 +64,13 @@ func (r *defaultOutputRepairer) Repair(ctx context.Context, req RepairRequest) (
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := r.llm.Generate(ctx, domain.GenerateRequest{
|
resp, err := r.llm.Generate(ctx, newGenerationRequest(
|
||||||
Prompt: prompt,
|
prompt,
|
||||||
Target: req.Target,
|
req.SessionID,
|
||||||
StructuredOutput: req.StructuredOutput,
|
req.Target,
|
||||||
})
|
req.TargetPresence,
|
||||||
|
req.StructuredOutput,
|
||||||
|
))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,18 +175,20 @@ func (r *Runner) executePreparedRun(
|
|||||||
) (*domain.RunResult, error) {
|
) (*domain.RunResult, error) {
|
||||||
executionTarget := prepared.EffectiveModelParams
|
executionTarget := prepared.EffectiveModelParams
|
||||||
executionTarget.APIKey = directAPIKey
|
executionTarget.APIKey = directAPIKey
|
||||||
genResp, err := r.llm.Generate(ctx, domain.GenerateRequest{
|
genResp, err := r.llm.Generate(ctx, newGenerationRequest(
|
||||||
Prompt: domain.RenderedPrompt{SessionID: prepared.SessionID, Messages: prepared.Messages},
|
domain.RenderedPrompt{Messages: prepared.Messages},
|
||||||
Target: executionTarget,
|
prepared.SessionID,
|
||||||
TargetPresence: prepared.TargetPresence,
|
executionTarget,
|
||||||
StructuredOutput: prepared.StructuredOutput,
|
prepared.TargetPresence,
|
||||||
})
|
prepared.StructuredOutput,
|
||||||
|
))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, llm.ErrInvalidRequest) {
|
if errors.Is(err, llm.ErrInvalidRequest) {
|
||||||
return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err)
|
return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("%w: %w", ErrLLMGenerate, err)
|
return nil, fmt.Errorf("%w: %w", ErrLLMGenerate, err)
|
||||||
}
|
}
|
||||||
|
usage := genResp.Usage
|
||||||
|
|
||||||
outputArtifact := buildOutputArtifact(genResp.Content, prepared.OutputContract.Format)
|
outputArtifact := buildOutputArtifact(genResp.Content, prepared.OutputContract.Format)
|
||||||
validationResult, err := validateArtifact(ctx, &outputArtifact, 0)
|
validationResult, err := validateArtifact(ctx, &outputArtifact, 0)
|
||||||
@@ -204,6 +206,7 @@ func (r *Runner) executePreparedRun(
|
|||||||
ValidationErrors: validationResult.Errors,
|
ValidationErrors: validationResult.Errors,
|
||||||
SessionID: prepared.SessionID,
|
SessionID: prepared.SessionID,
|
||||||
Target: executionTarget,
|
Target: executionTarget,
|
||||||
|
TargetPresence: prepared.TargetPresence,
|
||||||
StructuredOutput: prepared.StructuredOutput,
|
StructuredOutput: prepared.StructuredOutput,
|
||||||
Attempt: attemptsUsed,
|
Attempt: attemptsUsed,
|
||||||
MaxAttempts: prepared.OutputContract.RepairAttempts,
|
MaxAttempts: prepared.OutputContract.RepairAttempts,
|
||||||
@@ -217,6 +220,7 @@ func (r *Runner) executePreparedRun(
|
|||||||
}
|
}
|
||||||
|
|
||||||
genResp = repairResp
|
genResp = repairResp
|
||||||
|
usage = addTokenUsage(usage, repairResp.Usage)
|
||||||
outputArtifact = buildOutputArtifact(genResp.Content, prepared.OutputContract.Format)
|
outputArtifact = buildOutputArtifact(genResp.Content, prepared.OutputContract.Format)
|
||||||
|
|
||||||
validationResult, err = validateArtifact(ctx, &outputArtifact, attemptsUsed)
|
validationResult, err = validateArtifact(ctx, &outputArtifact, attemptsUsed)
|
||||||
@@ -245,13 +249,23 @@ func (r *Runner) executePreparedRun(
|
|||||||
Endpoint: prepared.EffectiveModelParams.Endpoint,
|
Endpoint: prepared.EffectiveModelParams.Endpoint,
|
||||||
EffectiveModelParams: executionTarget,
|
EffectiveModelParams: executionTarget,
|
||||||
InputHashes: prepared.InputHashes,
|
InputHashes: prepared.InputHashes,
|
||||||
Usage: genResp.Usage,
|
Usage: usage,
|
||||||
StartTime: start,
|
StartTime: start,
|
||||||
EndTime: end,
|
EndTime: end,
|
||||||
Duration: end.Sub(start),
|
Duration: end.Sub(start),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addTokenUsage(total, next domain.TokenUsage) domain.TokenUsage {
|
||||||
|
return domain.TokenUsage{
|
||||||
|
PromptTokens: total.PromptTokens + next.PromptTokens,
|
||||||
|
CompletionTokens: total.CompletionTokens + next.CompletionTokens,
|
||||||
|
TotalTokens: total.TotalTokens + next.TotalTokens,
|
||||||
|
CachedTokens: total.CachedTokens + next.CachedTokens,
|
||||||
|
CacheWriteTokens: total.CacheWriteTokens + next.CacheWriteTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.PreparedRun, error) {
|
func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.PreparedRun, error) {
|
||||||
state, err := r.resolvePreparation(ctx, req, time.Now().UTC())
|
state, err := r.resolvePreparation(ctx, req, time.Now().UTC())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -228,6 +228,30 @@ type fakeRepairer struct {
|
|||||||
reqs []RepairRequest
|
reqs []RepairRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type sequenceLLM struct {
|
||||||
|
responses []*domain.GenerateResponse
|
||||||
|
requests []domain.GenerateRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *sequenceLLM) Generate(_ context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
||||||
|
c.requests = append(c.requests, req)
|
||||||
|
index := len(c.requests) - 1
|
||||||
|
if index >= len(c.responses) {
|
||||||
|
return nil, errors.New("no generation response configured")
|
||||||
|
}
|
||||||
|
return c.responses[index], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingRepairer struct {
|
||||||
|
next OutputRepairer
|
||||||
|
reqs []RepairRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingRepairer) Repair(ctx context.Context, req RepairRequest) (*domain.GenerateResponse, error) {
|
||||||
|
r.reqs = append(r.reqs, req)
|
||||||
|
return r.next.Repair(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
type fakeRunAdmitter struct {
|
type fakeRunAdmitter struct {
|
||||||
backendIDs []string
|
backendIDs []string
|
||||||
err error
|
err error
|
||||||
@@ -2070,50 +2094,255 @@ func TestRunnerRunValidationStillWorks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunnerRunStructuredRepairRemainsBoundedAndUsesEffectiveModelSettings(t *testing.T) {
|
func TestRunnerRepairStateMachine(t *testing.T) {
|
||||||
repairer := &fakeRepairer{responses: []*domain.GenerateResponse{{Content: `{"broken":`}, {Content: `{"still":`}}}
|
failed := func(mode domain.ValidationMode, diagnostic string) domain.ValidationResult {
|
||||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: `{"initial":`}}
|
return domain.ValidationResult{
|
||||||
|
Status: domain.ValidationFailed,
|
||||||
|
Mode: mode,
|
||||||
|
Errors: []string{diagnostic},
|
||||||
|
IsValid: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
passed := func(mode domain.ValidationMode) domain.ValidationResult {
|
||||||
|
return domain.ValidationResult{
|
||||||
|
Status: domain.ValidationPassed,
|
||||||
|
Mode: mode,
|
||||||
|
IsValid: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
responses := func(count int) []*domain.GenerateResponse {
|
||||||
|
values := make([]*domain.GenerateResponse, count)
|
||||||
|
for index := range values {
|
||||||
|
unit := index + 1
|
||||||
|
values[index] = &domain.GenerateResponse{
|
||||||
|
Content: fmt.Sprintf(`{"candidate":%d}`, index),
|
||||||
|
Usage: domain.TokenUsage{
|
||||||
|
PromptTokens: unit,
|
||||||
|
CompletionTokens: unit * 10,
|
||||||
|
TotalTokens: unit * 100,
|
||||||
|
CachedTokens: unit * 1000,
|
||||||
|
CacheWriteTokens: unit * 10000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
zeroOverrides := &domain.ExecutionTargetOverride{
|
||||||
|
Temperature: float64Ptr(0),
|
||||||
|
MaxTokens: intPtr(0),
|
||||||
|
TopP: float64Ptr(0),
|
||||||
|
TimeoutSeconds: intPtr(0),
|
||||||
|
}
|
||||||
|
allPresent := domain.ExecutionTargetPresence{
|
||||||
|
Temperature: true,
|
||||||
|
MaxTokens: true,
|
||||||
|
TopP: true,
|
||||||
|
TimeoutSeconds: true,
|
||||||
|
}
|
||||||
|
|
||||||
runner := NewRunnerWithRepairer(
|
tests := []struct {
|
||||||
&fakePromptRepo{def: promptDef(domain.FormatJSON, domain.ValidationJSON, 1)},
|
name string
|
||||||
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
mode domain.ValidationMode
|
||||||
"exec": {ID: "exec", BackendID: "custom", Model: "profile-model", TimeoutSeconds: 55},
|
budget int
|
||||||
}}, fakeBackendResolver{backends: map[string]domain.Backend{
|
validationResults []domain.ValidationResult
|
||||||
"custom": {ID: "custom", Endpoint: "http://backend/v1"},
|
responses []*domain.GenerateResponse
|
||||||
}},
|
execution *domain.ExecutionTargetOverride
|
||||||
|
wantPresence domain.ExecutionTargetPresence
|
||||||
|
wantRepairs int
|
||||||
|
wantStatus domain.ValidationStatus
|
||||||
|
structured bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "initial success does not repair",
|
||||||
|
mode: domain.ValidationJSON,
|
||||||
|
budget: 3,
|
||||||
|
validationResults: []domain.ValidationResult{passed(domain.ValidationJSON)},
|
||||||
|
responses: responses(1),
|
||||||
|
wantStatus: domain.ValidationPassed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "basic failure is ineligible despite budget",
|
||||||
|
mode: domain.ValidationBasic,
|
||||||
|
budget: 3,
|
||||||
|
validationResults: []domain.ValidationResult{failed(domain.ValidationBasic, "empty output")},
|
||||||
|
responses: responses(1),
|
||||||
|
wantStatus: domain.ValidationFailed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inherited numeric values remain absent",
|
||||||
|
mode: domain.ValidationJSON,
|
||||||
|
budget: 1,
|
||||||
|
validationResults: []domain.ValidationResult{
|
||||||
|
failed(domain.ValidationJSON, "initial syntax"),
|
||||||
|
passed(domain.ValidationJSON),
|
||||||
|
},
|
||||||
|
responses: responses(2),
|
||||||
|
wantRepairs: 1,
|
||||||
|
wantStatus: domain.ValidationPassed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit numeric zeros remain present",
|
||||||
|
mode: domain.ValidationJSONSchema,
|
||||||
|
budget: 1,
|
||||||
|
execution: zeroOverrides,
|
||||||
|
structured: true,
|
||||||
|
validationResults: []domain.ValidationResult{
|
||||||
|
failed(domain.ValidationJSONSchema, "initial schema mismatch"),
|
||||||
|
passed(domain.ValidationJSONSchema),
|
||||||
|
},
|
||||||
|
responses: responses(2),
|
||||||
|
wantPresence: allPresent,
|
||||||
|
wantRepairs: 1,
|
||||||
|
wantStatus: domain.ValidationPassed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "successful repair stops below larger budget",
|
||||||
|
mode: domain.ValidationJSON,
|
||||||
|
budget: 4,
|
||||||
|
validationResults: []domain.ValidationResult{
|
||||||
|
failed(domain.ValidationJSON, "candidate zero"),
|
||||||
|
failed(domain.ValidationJSON, "candidate one"),
|
||||||
|
passed(domain.ValidationJSON),
|
||||||
|
},
|
||||||
|
responses: responses(3),
|
||||||
|
wantRepairs: 2,
|
||||||
|
wantStatus: domain.ValidationPassed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed repairs exhaust exact larger budget",
|
||||||
|
mode: domain.ValidationJSON,
|
||||||
|
budget: 3,
|
||||||
|
validationResults: []domain.ValidationResult{
|
||||||
|
failed(domain.ValidationJSON, "candidate zero"),
|
||||||
|
failed(domain.ValidationJSON, "candidate one"),
|
||||||
|
failed(domain.ValidationJSON, "candidate two"),
|
||||||
|
failed(domain.ValidationJSON, "candidate three"),
|
||||||
|
},
|
||||||
|
responses: responses(4),
|
||||||
|
wantRepairs: 3,
|
||||||
|
wantStatus: domain.ValidationFailed,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
defaultArtifactReader(),
|
for _, tc := range tests {
|
||||||
defaultRenderer(),
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
llmClient,
|
definition := promptDef(domain.FormatJSON, tc.mode, tc.budget)
|
||||||
validate.NewStandardValidator("."),
|
plan := &recordingPreparedValidation{results: tc.validationResults}
|
||||||
repairer, nil)
|
if tc.structured {
|
||||||
|
definition.Validation.SchemaPath = "schema.json"
|
||||||
|
plan.schemaDocument = map[string]any{"type": "object"}
|
||||||
|
}
|
||||||
|
validator := &recordingValidationPreparer{plan: plan}
|
||||||
|
client := &sequenceLLM{responses: tc.responses}
|
||||||
|
repairer := &recordingRepairer{next: NewDefaultOutputRepairer(client)}
|
||||||
|
runner := NewRunnerWithRepairer(
|
||||||
|
&fakePromptRepo{def: definition},
|
||||||
|
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||||
|
"exec": {ID: "exec", BackendID: "custom", Model: "profile-model"},
|
||||||
|
}},
|
||||||
|
fakeBackendResolver{backends: map[string]domain.Backend{
|
||||||
|
"custom": {ID: "custom", Endpoint: "http://backend.example/v1"},
|
||||||
|
}},
|
||||||
|
defaultArtifactReader(),
|
||||||
|
defaultRenderer(),
|
||||||
|
client,
|
||||||
|
validator,
|
||||||
|
repairer,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
res, err := runner.Run(context.Background(), domain.RunRequest{
|
result, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
ProfileID: "exec",
|
ProfileID: "exec",
|
||||||
Inputs: singleInputRef(),
|
SessionID: " repair-session ",
|
||||||
Execution: &domain.ExecutionTargetOverride{Endpoint: "http://override/v1", Model: "override-model", TimeoutSeconds: intPtr(22)},
|
APIKey: "direct-secret",
|
||||||
})
|
Inputs: singleInputRef(),
|
||||||
if err != nil {
|
Execution: tc.execution,
|
||||||
t.Fatalf("expected no error, got %v", err)
|
})
|
||||||
}
|
if err != nil {
|
||||||
if repairer.calls != 1 || res.Validation.RepairAttempts != 1 {
|
t.Fatalf("run: %v", err)
|
||||||
t.Fatalf("expected one bounded repair, calls=%d attempts=%d", repairer.calls, res.Validation.RepairAttempts)
|
}
|
||||||
}
|
if len(client.requests) != tc.wantRepairs+1 || len(repairer.reqs) != tc.wantRepairs {
|
||||||
if len(repairer.reqs) != 1 {
|
t.Fatalf(
|
||||||
t.Fatalf("expected one repair request, got %d", len(repairer.reqs))
|
"generation/repair calls = (%d, %d), want (%d, %d)",
|
||||||
}
|
len(client.requests), len(repairer.reqs), tc.wantRepairs+1, tc.wantRepairs,
|
||||||
if repairer.reqs[0].Target.Endpoint != "http://override/v1" || repairer.reqs[0].Target.Model != "override-model" {
|
)
|
||||||
t.Fatalf("expected repair to use effective target, got %+v", repairer.reqs[0].Target)
|
}
|
||||||
}
|
if len(plan.artifacts) != tc.wantRepairs+1 {
|
||||||
if repairer.reqs[0].Target.TimeoutSeconds != 22 {
|
t.Fatalf("validation calls = %d, want %d", len(plan.artifacts), tc.wantRepairs+1)
|
||||||
t.Fatalf("expected repair to use effective timeout, got %d", repairer.reqs[0].Target.TimeoutSeconds)
|
}
|
||||||
}
|
|
||||||
if llmClient.lastReq.Target.BackendID != "custom" ||
|
initialRequest := client.requests[0]
|
||||||
repairer.reqs[0].Target.BackendID != "custom" ||
|
if initialRequest.TargetPresence != tc.wantPresence {
|
||||||
res.SelectedBackendID != "custom" {
|
t.Fatalf("initial target presence = %+v, want %+v", initialRequest.TargetPresence, tc.wantPresence)
|
||||||
t.Fatalf("expected backend identity in generation, repair, and result: generate=%q repair=%q result=%q",
|
}
|
||||||
llmClient.lastReq.Target.BackendID, repairer.reqs[0].Target.BackendID, res.SelectedBackendID)
|
if initialRequest.Target.APIKey != "direct-secret" || initialRequest.Target.BackendID != "custom" ||
|
||||||
|
initialRequest.Prompt.SessionID != "repair-session" {
|
||||||
|
t.Fatalf("initial common request fields = %+v", initialRequest)
|
||||||
|
}
|
||||||
|
if initialRequest.Target.Endpoint != "http://backend.example/v1" ||
|
||||||
|
initialRequest.Target.Model != "profile-model" ||
|
||||||
|
initialRequest.Target.Temperature != 0 || initialRequest.Target.MaxTokens != 0 || initialRequest.Target.TopP != 0 {
|
||||||
|
t.Fatalf("initial effective target = %+v", initialRequest.Target)
|
||||||
|
}
|
||||||
|
if tc.structured != (initialRequest.StructuredOutput != nil) {
|
||||||
|
t.Fatalf("initial structured output = %+v, want present %v", initialRequest.StructuredOutput, tc.structured)
|
||||||
|
}
|
||||||
|
if tc.structured && (initialRequest.StructuredOutput.JSONSchema == nil ||
|
||||||
|
initialRequest.StructuredOutput.JSONSchema.Name != "p_1") {
|
||||||
|
t.Fatalf("initial JSON Schema metadata = %+v, want derived schema name p_1", initialRequest.StructuredOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, req := range repairer.reqs {
|
||||||
|
if req.Attempt != index+1 || req.MaxAttempts != tc.budget || req.Mode != tc.mode {
|
||||||
|
t.Fatalf("repair request %d progression = %+v", index, req)
|
||||||
|
}
|
||||||
|
if req.PreviousOutput != tc.responses[index].Content ||
|
||||||
|
!reflect.DeepEqual(req.ValidationErrors, tc.validationResults[index].Errors) {
|
||||||
|
t.Fatalf("repair request %d prior state = %+v", index, req)
|
||||||
|
}
|
||||||
|
if req.TargetPresence != tc.wantPresence || !reflect.DeepEqual(req.Target, initialRequest.Target) ||
|
||||||
|
req.SessionID != initialRequest.Prompt.SessionID ||
|
||||||
|
!reflect.DeepEqual(req.StructuredOutput, initialRequest.StructuredOutput) {
|
||||||
|
t.Fatalf("repair request %d common fields drifted: %+v", index, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
generated := client.requests[index+1]
|
||||||
|
if generated.TargetPresence != initialRequest.TargetPresence ||
|
||||||
|
!reflect.DeepEqual(generated.Target, initialRequest.Target) ||
|
||||||
|
generated.Prompt.SessionID != initialRequest.Prompt.SessionID ||
|
||||||
|
!reflect.DeepEqual(generated.StructuredOutput, initialRequest.StructuredOutput) {
|
||||||
|
t.Fatalf("repair generation request %d common fields drifted: %+v", index, generated)
|
||||||
|
}
|
||||||
|
if reflect.DeepEqual(generated.Prompt.Messages, initialRequest.Prompt.Messages) {
|
||||||
|
t.Fatalf("repair generation request %d reused the initial prompt", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastResponse := tc.responses[tc.wantRepairs]
|
||||||
|
if result.RawOutput != lastResponse.Content || string(result.Artifact.Body) != lastResponse.Content {
|
||||||
|
t.Fatalf("final output = (%q, %q), want %q", result.RawOutput, result.Artifact.Body, lastResponse.Content)
|
||||||
|
}
|
||||||
|
if result.Validation.Status != tc.wantStatus || result.Validation.RepairAttempts != tc.wantRepairs {
|
||||||
|
t.Fatalf("final validation = %+v, want status %q and %d repairs", result.Validation, tc.wantStatus, tc.wantRepairs)
|
||||||
|
}
|
||||||
|
if result.SelectedBackendID != "custom" || result.SessionID != "repair-session" ||
|
||||||
|
result.EffectiveModelParams.APIKey != "" {
|
||||||
|
t.Fatalf("result execution metadata = %+v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wantUsage domain.TokenUsage
|
||||||
|
for _, response := range tc.responses[:tc.wantRepairs+1] {
|
||||||
|
wantUsage.PromptTokens += response.Usage.PromptTokens
|
||||||
|
wantUsage.CompletionTokens += response.Usage.CompletionTokens
|
||||||
|
wantUsage.TotalTokens += response.Usage.TotalTokens
|
||||||
|
wantUsage.CachedTokens += response.Usage.CachedTokens
|
||||||
|
wantUsage.CacheWriteTokens += response.Usage.CacheWriteTokens
|
||||||
|
}
|
||||||
|
if result.Usage != wantUsage {
|
||||||
|
t.Fatalf("cumulative usage = %+v, want %+v", result.Usage, wantUsage)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2208,99 +2437,6 @@ func TestRunnerSchedulesInitialAndRepairGenerationThroughOneBackendPool(t *testi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunnerRunRepairCarriesEffectiveSessionID(t *testing.T) {
|
|
||||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: `{"broken":`}}
|
|
||||||
runner := NewRunnerWithRepairer(
|
|
||||||
&fakePromptRepo{def: promptDef(domain.FormatJSON, domain.ValidationJSON, 1)},
|
|
||||||
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
|
||||||
"exec": {ID: "exec", Endpoint: "http://example.test/v1", Model: "model"},
|
|
||||||
}},
|
|
||||||
nil,
|
|
||||||
defaultArtifactReader(),
|
|
||||||
defaultRenderer(),
|
|
||||||
llmClient,
|
|
||||||
validate.NewStandardValidator("."),
|
|
||||||
NewDefaultOutputRepairer(llmClient), nil)
|
|
||||||
|
|
||||||
result, err := runner.Run(context.Background(), domain.RunRequest{
|
|
||||||
PromptID: "p",
|
|
||||||
ProfileID: "exec",
|
|
||||||
SessionID: " repair-session ",
|
|
||||||
Inputs: singleInputRef(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if llmClient.calls != 2 {
|
|
||||||
t.Fatalf("expected initial generation and one repair, got %d calls", llmClient.calls)
|
|
||||||
}
|
|
||||||
if llmClient.lastReq.Prompt.SessionID != "repair-session" {
|
|
||||||
t.Fatalf("expected repair generation to retain effective session, got %q", llmClient.lastReq.Prompt.SessionID)
|
|
||||||
}
|
|
||||||
if result.SessionID != "repair-session" {
|
|
||||||
t.Fatalf("expected result to retain effective session, got %q", result.SessionID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunnerRunJSONSchemaRepairCarriesStructuredOutputSpec(t *testing.T) {
|
|
||||||
def := promptDef(domain.FormatJSON, domain.ValidationJSONSchema, 1)
|
|
||||||
def.Validation.SchemaPath = "events.schema.json"
|
|
||||||
|
|
||||||
validator := &fakeValidator{
|
|
||||||
result: domain.ValidationResult{
|
|
||||||
Status: domain.ValidationFailed,
|
|
||||||
Mode: domain.ValidationJSONSchema,
|
|
||||||
Errors: []string{"schema mismatch"},
|
|
||||||
IsValid: false,
|
|
||||||
},
|
|
||||||
schemaDoc: map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"events": map[string]any{"type": "array"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
repairer := &fakeRepairer{
|
|
||||||
responses: []*domain.GenerateResponse{
|
|
||||||
{Content: `{"events":[]}`},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: `{"events":[1]}`}}
|
|
||||||
runner := NewRunnerWithRepairer(
|
|
||||||
&fakePromptRepo{def: def},
|
|
||||||
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}, nil,
|
|
||||||
|
|
||||||
defaultArtifactReader(),
|
|
||||||
defaultRenderer(),
|
|
||||||
llmClient,
|
|
||||||
validator,
|
|
||||||
repairer, nil)
|
|
||||||
|
|
||||||
_, err := runner.Run(context.Background(), domain.RunRequest{
|
|
||||||
PromptID: "p",
|
|
||||||
ProfileID: "exec",
|
|
||||||
Inputs: singleInputRef(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if llmClient.lastReq.StructuredOutput == nil || llmClient.lastReq.StructuredOutput.JSONSchema == nil {
|
|
||||||
t.Fatalf("expected initial llm request to include structured output, got %+v", llmClient.lastReq.StructuredOutput)
|
|
||||||
}
|
|
||||||
if len(repairer.reqs) != 1 {
|
|
||||||
t.Fatalf("expected one repair request, got %d", len(repairer.reqs))
|
|
||||||
}
|
|
||||||
if repairer.reqs[0].StructuredOutput == nil || repairer.reqs[0].StructuredOutput.JSONSchema == nil {
|
|
||||||
t.Fatalf("expected repair request structured output, got %+v", repairer.reqs[0].StructuredOutput)
|
|
||||||
}
|
|
||||||
if repairer.reqs[0].StructuredOutput.JSONSchema.Name != "p_1" {
|
|
||||||
t.Fatalf("expected derived schema name p_1, got %q", repairer.reqs[0].StructuredOutput.JSONSchema.Name)
|
|
||||||
}
|
|
||||||
if validator.schemaLoads != 1 || validator.validateCalls != 2 {
|
|
||||||
t.Fatalf("schema preparation/validation calls = (%d, %d), want (1, 2)", validator.schemaLoads, validator.validateCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecutionProfileToTargetPopulatesAllFieldsAndCopiesExtraParams(t *testing.T) {
|
func TestExecutionProfileToTargetPopulatesAllFieldsAndCopiesExtraParams(t *testing.T) {
|
||||||
src := &domain.ExecutionProfile{
|
src := &domain.ExecutionProfile{
|
||||||
ID: "exec",
|
ID: "exec",
|
||||||
|
|||||||
Reference in New Issue
Block a user