Make request execution overrides presence-aware
This commit is contained in:
@@ -187,7 +187,10 @@ func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.Pr
|
||||
return nil, fmt.Errorf("%w: %w", ErrProfileLoad, err)
|
||||
}
|
||||
|
||||
effectiveModel := resolveExecutionTarget(execProfile, req.Execution)
|
||||
effectiveModel, err := resolveExecutionTarget(execProfile, req.Execution)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err)
|
||||
}
|
||||
if strings.TrimSpace(effectiveModel.Endpoint) == "" {
|
||||
return nil, fmt.Errorf("%w: execution endpoint is required", ErrInvalidRequest)
|
||||
}
|
||||
@@ -355,22 +358,69 @@ func mergeExecutionTarget(base domain.ExecutionTarget, override domain.Execution
|
||||
out.APIKeyEnv = override.APIKeyEnv
|
||||
}
|
||||
if len(override.ExtraParams) > 0 {
|
||||
cp := make(map[string]string, len(override.ExtraParams))
|
||||
for k, v := range override.ExtraParams {
|
||||
cp[k] = v
|
||||
}
|
||||
out.ExtraParams = cp
|
||||
out.ExtraParams = copyExtraParams(override.ExtraParams)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveExecutionTarget(profileValue *domain.ExecutionProfile, override *domain.ExecutionTarget) domain.ExecutionTarget {
|
||||
func mergeExecutionTargetOverride(base domain.ExecutionTarget, override domain.ExecutionTargetOverride) (domain.ExecutionTarget, error) {
|
||||
out := base
|
||||
if override.Endpoint != "" {
|
||||
out.Endpoint = override.Endpoint
|
||||
}
|
||||
if override.Model != "" {
|
||||
out.Model = override.Model
|
||||
}
|
||||
if override.Temperature != nil {
|
||||
if *override.Temperature < 0 || *override.Temperature > 2 {
|
||||
return domain.ExecutionTarget{}, errors.New("temperature must be between 0 and 2")
|
||||
}
|
||||
out.Temperature = *override.Temperature
|
||||
}
|
||||
if override.MaxTokens != nil {
|
||||
if *override.MaxTokens < 0 {
|
||||
return domain.ExecutionTarget{}, errors.New("max_tokens must be greater than or equal to 0")
|
||||
}
|
||||
out.MaxTokens = *override.MaxTokens
|
||||
}
|
||||
if override.TopP != nil {
|
||||
if *override.TopP < 0 || *override.TopP > 1 {
|
||||
return domain.ExecutionTarget{}, errors.New("top_p must be between 0 and 1")
|
||||
}
|
||||
out.TopP = *override.TopP
|
||||
}
|
||||
if override.TimeoutSeconds != nil {
|
||||
if *override.TimeoutSeconds < 0 {
|
||||
return domain.ExecutionTarget{}, errors.New("timeout_seconds must be greater than or equal to 0")
|
||||
}
|
||||
out.TimeoutSeconds = *override.TimeoutSeconds
|
||||
}
|
||||
if strings.TrimSpace(override.ServiceTier) != "" {
|
||||
out.ServiceTier = override.ServiceTier
|
||||
}
|
||||
if strings.TrimSpace(override.ReasoningEffort) != "" {
|
||||
out.ReasoningEffort = override.ReasoningEffort
|
||||
}
|
||||
if strings.TrimSpace(override.APIKeyEnv) != "" {
|
||||
out.APIKeyEnv = override.APIKeyEnv
|
||||
}
|
||||
if len(override.ExtraParams) > 0 {
|
||||
out.ExtraParams = copyExtraParams(override.ExtraParams)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resolveExecutionTarget(profileValue *domain.ExecutionProfile, override *domain.ExecutionTargetOverride) (domain.ExecutionTarget, error) {
|
||||
out := defaults.ExecutionTargetDefault()
|
||||
out = mergeExecutionTarget(out, executionProfileToTarget(profileValue))
|
||||
if override != nil {
|
||||
out = mergeExecutionTarget(out, *override)
|
||||
var err error
|
||||
out, err = mergeExecutionTargetOverride(out, *override)
|
||||
if err != nil {
|
||||
return domain.ExecutionTarget{}, err
|
||||
}
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateAPIKeyEnv(apiKeyEnv string) error {
|
||||
@@ -388,13 +438,6 @@ func executionProfileToTarget(p *domain.ExecutionProfile) domain.ExecutionTarget
|
||||
if p == nil {
|
||||
return domain.ExecutionTarget{}
|
||||
}
|
||||
cp := map[string]string(nil)
|
||||
if len(p.ExtraParams) > 0 {
|
||||
cp = make(map[string]string, len(p.ExtraParams))
|
||||
for k, v := range p.ExtraParams {
|
||||
cp[k] = v
|
||||
}
|
||||
}
|
||||
return domain.ExecutionTarget{
|
||||
Endpoint: p.Endpoint,
|
||||
Model: p.Model,
|
||||
@@ -405,10 +448,21 @@ func executionProfileToTarget(p *domain.ExecutionProfile) domain.ExecutionTarget
|
||||
ServiceTier: p.ServiceTier,
|
||||
ReasoningEffort: p.ReasoningEffort,
|
||||
APIKeyEnv: p.APIKeyEnv,
|
||||
ExtraParams: cp,
|
||||
ExtraParams: copyExtraParams(p.ExtraParams),
|
||||
}
|
||||
}
|
||||
|
||||
func copyExtraParams(src map[string]any) map[string]any {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
cp := make(map[string]any, len(src))
|
||||
for k, v := range src {
|
||||
cp[k] = v
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
func resolveOutputContract(def *domain.PromptDefinition, override *domain.OutputContract) domain.OutputContract {
|
||||
contract := def.Validation
|
||||
if contract.Format == "" {
|
||||
|
||||
@@ -172,7 +172,7 @@ func TestRunnerPrepareWithExplicitProfileSelection(t *testing.T) {
|
||||
"transcript": {Type: domain.ArtifactRefFile, URI: "a://t"},
|
||||
"glossary": {Type: domain.ArtifactRefFile, URI: "a://g"},
|
||||
},
|
||||
Execution: &domain.ExecutionTarget{Endpoint: "http://override/v1", Model: "m", Temperature: 0.3, TimeoutSeconds: 90},
|
||||
Execution: &domain.ExecutionTargetOverride{Endpoint: "http://override/v1", Model: "m", Temperature: float64Ptr(0.3), TimeoutSeconds: intPtr(90)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
@@ -272,11 +272,11 @@ func TestRunnerPrepareRuntimeOverrideBeatsSelectedProfileValue(t *testing.T) {
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
Inputs: singleInputRef(),
|
||||
Execution: &domain.ExecutionTarget{
|
||||
Execution: &domain.ExecutionTargetOverride{
|
||||
Endpoint: "http://override/v1",
|
||||
Model: "override-model",
|
||||
Temperature: 0.7,
|
||||
TimeoutSeconds: 30,
|
||||
Temperature: float64Ptr(0.7),
|
||||
TimeoutSeconds: intPtr(30),
|
||||
ServiceTier: "flex",
|
||||
},
|
||||
})
|
||||
@@ -294,6 +294,135 @@ func TestRunnerPrepareRuntimeOverrideBeatsSelectedProfileValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerPrepareRequestNumericOverridePresence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
override *domain.ExecutionTargetOverride
|
||||
wantTemperature float64
|
||||
wantMaxTokens int
|
||||
wantTopP float64
|
||||
wantTimeoutSecs int
|
||||
}{
|
||||
{
|
||||
name: "omitted preserves profile values",
|
||||
override: &domain.ExecutionTargetOverride{},
|
||||
wantTemperature: 0.7,
|
||||
wantMaxTokens: 321,
|
||||
wantTopP: 0.8,
|
||||
wantTimeoutSecs: 45,
|
||||
},
|
||||
{
|
||||
name: "explicit zero temperature",
|
||||
override: &domain.ExecutionTargetOverride{Temperature: float64Ptr(0)},
|
||||
wantTemperature: 0,
|
||||
wantMaxTokens: 321,
|
||||
wantTopP: 0.8,
|
||||
wantTimeoutSecs: 45,
|
||||
},
|
||||
{
|
||||
name: "explicit zero max tokens",
|
||||
override: &domain.ExecutionTargetOverride{MaxTokens: intPtr(0)},
|
||||
wantTemperature: 0.7,
|
||||
wantMaxTokens: 0,
|
||||
wantTopP: 0.8,
|
||||
wantTimeoutSecs: 45,
|
||||
},
|
||||
{
|
||||
name: "explicit zero top p",
|
||||
override: &domain.ExecutionTargetOverride{TopP: float64Ptr(0)},
|
||||
wantTemperature: 0.7,
|
||||
wantMaxTokens: 321,
|
||||
wantTopP: 0,
|
||||
wantTimeoutSecs: 45,
|
||||
},
|
||||
{
|
||||
name: "explicit zero timeout",
|
||||
override: &domain.ExecutionTargetOverride{TimeoutSeconds: intPtr(0)},
|
||||
wantTemperature: 0.7,
|
||||
wantMaxTokens: 321,
|
||||
wantTopP: 0.8,
|
||||
wantTimeoutSecs: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runner := NewRunner(
|
||||
&fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)},
|
||||
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||
"exec": {
|
||||
ID: "exec",
|
||||
Endpoint: "http://profile/v1",
|
||||
Model: "profile-model",
|
||||
Temperature: 0.7,
|
||||
MaxTokens: 321,
|
||||
TopP: 0.8,
|
||||
TimeoutSeconds: 45,
|
||||
},
|
||||
}},
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
&fakeLLM{forbid: true},
|
||||
nil,
|
||||
)
|
||||
|
||||
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
Inputs: singleInputRef(),
|
||||
Execution: tc.override,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
got := prepared.EffectiveModelParams
|
||||
if got.Temperature != tc.wantTemperature ||
|
||||
got.MaxTokens != tc.wantMaxTokens ||
|
||||
got.TopP != tc.wantTopP ||
|
||||
got.TimeoutSeconds != tc.wantTimeoutSecs {
|
||||
t.Fatalf("unexpected effective numeric settings: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerPrepareInvalidRequestNumericOverridesFail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
override *domain.ExecutionTargetOverride
|
||||
}{
|
||||
{name: "temperature below range", override: &domain.ExecutionTargetOverride{Temperature: float64Ptr(-0.1)}},
|
||||
{name: "temperature above range", override: &domain.ExecutionTargetOverride{Temperature: float64Ptr(2.1)}},
|
||||
{name: "max tokens below range", override: &domain.ExecutionTargetOverride{MaxTokens: intPtr(-1)}},
|
||||
{name: "top p below range", override: &domain.ExecutionTargetOverride{TopP: float64Ptr(-0.1)}},
|
||||
{name: "top p above range", override: &domain.ExecutionTargetOverride{TopP: float64Ptr(1.1)}},
|
||||
{name: "timeout below range", override: &domain.ExecutionTargetOverride{TimeoutSeconds: intPtr(-1)}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runner := NewRunner(
|
||||
&fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)},
|
||||
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}},
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
&fakeLLM{forbid: true},
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
Inputs: singleInputRef(),
|
||||
Execution: tc.override,
|
||||
})
|
||||
if !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerPrepareSelectedProfileBeatsBuiltInDefault(t *testing.T) {
|
||||
promptRepo := &fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)}
|
||||
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||
@@ -693,7 +822,7 @@ func TestRunnerRunSuccessful(t *testing.T) {
|
||||
"transcript": {Type: domain.ArtifactRefFile, URI: "a://t"},
|
||||
"glossary": {Type: domain.ArtifactRefFile, URI: "a://g"},
|
||||
},
|
||||
Execution: &domain.ExecutionTarget{Endpoint: "http://override/v1", Model: "m", Temperature: 0.3, TimeoutSeconds: 90},
|
||||
Execution: &domain.ExecutionTargetOverride{Endpoint: "http://override/v1", Model: "m", Temperature: float64Ptr(0.3), TimeoutSeconds: intPtr(90)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
@@ -749,7 +878,7 @@ func TestRunnerRunAndPrepareResolveSameProfileAndEffectiveSettings(t *testing.T)
|
||||
Inputs: map[string]domain.ArtifactRef{
|
||||
"transcript": {Type: domain.ArtifactRefFile, URI: "a://t"},
|
||||
},
|
||||
Execution: &domain.ExecutionTarget{Endpoint: "http://override/v1", Model: "m", Temperature: 0.3, TimeoutSeconds: 90},
|
||||
Execution: &domain.ExecutionTargetOverride{Endpoint: "http://override/v1", Model: "m", Temperature: float64Ptr(0.3), TimeoutSeconds: intPtr(90)},
|
||||
}
|
||||
|
||||
prepared, err := runner.Prepare(context.Background(), req)
|
||||
@@ -877,11 +1006,11 @@ func TestRunnerRunExplicitRuntimeOverrideBeatsSelectedProfileValue(t *testing.T)
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
Inputs: singleInputRef(),
|
||||
Execution: &domain.ExecutionTarget{
|
||||
Execution: &domain.ExecutionTargetOverride{
|
||||
Endpoint: "http://override/v1",
|
||||
Model: "override-model",
|
||||
Temperature: 0.7,
|
||||
TimeoutSeconds: 30,
|
||||
Temperature: float64Ptr(0.7),
|
||||
TimeoutSeconds: intPtr(30),
|
||||
ServiceTier: "flex",
|
||||
},
|
||||
})
|
||||
@@ -1016,7 +1145,7 @@ func TestRunnerRunRuntimeAPIKeyEnvOverrideWorks(t *testing.T) {
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
Inputs: singleInputRef(),
|
||||
Execution: &domain.ExecutionTarget{APIKeyEnv: envName},
|
||||
Execution: &domain.ExecutionTargetOverride{APIKeyEnv: envName},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
@@ -1041,7 +1170,7 @@ func TestRunnerRunRuntimeAPIKeyEnvOverrideBeatsProfile(t *testing.T) {
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
Inputs: singleInputRef(),
|
||||
Execution: &domain.ExecutionTarget{APIKeyEnv: runtimeEnv},
|
||||
Execution: &domain.ExecutionTargetOverride{APIKeyEnv: runtimeEnv},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
@@ -1182,7 +1311,7 @@ func TestRunnerRunStructuredRepairRemainsBoundedAndUsesEffectiveModelSettings(t
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
Inputs: singleInputRef(),
|
||||
Execution: &domain.ExecutionTarget{Endpoint: "http://override/v1", Model: "override-model", TimeoutSeconds: 22},
|
||||
Execution: &domain.ExecutionTargetOverride{Endpoint: "http://override/v1", Model: "override-model", TimeoutSeconds: intPtr(22)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
@@ -1269,7 +1398,7 @@ func TestExecutionProfileToTargetPopulatesAllFieldsAndCopiesExtraParams(t *testi
|
||||
ServiceTier: "priority",
|
||||
ReasoningEffort: "medium",
|
||||
APIKeyEnv: "SCRIPTORIUM_API_KEY",
|
||||
ExtraParams: map[string]string{
|
||||
ExtraParams: map[string]any{
|
||||
"provider_option": "on",
|
||||
},
|
||||
}
|
||||
@@ -1308,12 +1437,15 @@ func TestResolveExecutionTargetProfileValuesPopulateAllSupportedFields(t *testin
|
||||
ServiceTier: "priority",
|
||||
ReasoningEffort: "low",
|
||||
APIKeyEnv: "PROFILE_KEY",
|
||||
ExtraParams: map[string]string{
|
||||
ExtraParams: map[string]any{
|
||||
"profile_option": "enabled",
|
||||
},
|
||||
}
|
||||
|
||||
target := resolveExecutionTarget(profileValue, nil)
|
||||
target, err := resolveExecutionTarget(profileValue, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if target.Endpoint != profileValue.Endpoint ||
|
||||
target.Model != profileValue.Model ||
|
||||
target.Temperature != profileValue.Temperature ||
|
||||
@@ -1342,32 +1474,35 @@ func TestResolveExecutionTargetRuntimeOverridesBeatProfileForAllOverrideableFiel
|
||||
ServiceTier: "priority",
|
||||
ReasoningEffort: "medium",
|
||||
APIKeyEnv: "PROFILE_KEY",
|
||||
ExtraParams: map[string]string{
|
||||
ExtraParams: map[string]any{
|
||||
"profile_only": "yes",
|
||||
},
|
||||
}
|
||||
override := &domain.ExecutionTarget{
|
||||
override := &domain.ExecutionTargetOverride{
|
||||
Endpoint: "http://override/v1",
|
||||
Model: "override-model",
|
||||
Temperature: 0.9,
|
||||
MaxTokens: 111,
|
||||
TopP: 0.5,
|
||||
TimeoutSeconds: 30,
|
||||
Temperature: float64Ptr(0.9),
|
||||
MaxTokens: intPtr(111),
|
||||
TopP: float64Ptr(0.5),
|
||||
TimeoutSeconds: intPtr(30),
|
||||
ServiceTier: "flex",
|
||||
ReasoningEffort: "high",
|
||||
APIKeyEnv: "RUNTIME_KEY",
|
||||
ExtraParams: map[string]string{
|
||||
ExtraParams: map[string]any{
|
||||
"runtime_only": "yes",
|
||||
},
|
||||
}
|
||||
|
||||
target := resolveExecutionTarget(profileValue, override)
|
||||
target, err := resolveExecutionTarget(profileValue, override)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if target.Endpoint != override.Endpoint ||
|
||||
target.Model != override.Model ||
|
||||
target.Temperature != override.Temperature ||
|
||||
target.MaxTokens != override.MaxTokens ||
|
||||
target.TopP != override.TopP ||
|
||||
target.TimeoutSeconds != override.TimeoutSeconds ||
|
||||
target.Temperature != *override.Temperature ||
|
||||
target.MaxTokens != *override.MaxTokens ||
|
||||
target.TopP != *override.TopP ||
|
||||
target.TimeoutSeconds != *override.TimeoutSeconds ||
|
||||
target.ServiceTier != override.ServiceTier ||
|
||||
target.ReasoningEffort != override.ReasoningEffort ||
|
||||
target.APIKeyEnv != override.APIKeyEnv {
|
||||
@@ -1411,12 +1546,12 @@ func TestMergeExecutionTargetEmptyStringOverridesDoNotErase(t *testing.T) {
|
||||
|
||||
func TestMergeExecutionTargetEmptyExtraParamsDoesNotErase(t *testing.T) {
|
||||
base := domain.ExecutionTarget{
|
||||
ExtraParams: map[string]string{
|
||||
ExtraParams: map[string]any{
|
||||
"keep": "value",
|
||||
},
|
||||
}
|
||||
override := domain.ExecutionTarget{
|
||||
ExtraParams: map[string]string{},
|
||||
ExtraParams: map[string]any{},
|
||||
}
|
||||
|
||||
merged := mergeExecutionTarget(base, override)
|
||||
@@ -1492,6 +1627,14 @@ func singleInputRef() map[string]domain.ArtifactRef {
|
||||
return map[string]domain.ArtifactRef{"transcript": {Type: domain.ArtifactRefFile, URI: "a://ok"}}
|
||||
}
|
||||
|
||||
func float64Ptr(v float64) *float64 {
|
||||
return &v
|
||||
}
|
||||
|
||||
func intPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
func newMinimalRunner(promptRepo *fakePromptRepo, execRepo *fakeExecutionProfileRepo) *Runner {
|
||||
return NewRunner(
|
||||
promptRepo,
|
||||
|
||||
Reference in New Issue
Block a user