Compare commits
6 Commits
1798e9c575
...
v0.10.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 23872dd742 | |||
| 7ffbf5f6ca | |||
| d0dc30fcc9 | |||
| b38f7b4dc3 | |||
| 0512995931 | |||
| 049a5feadb |
@@ -28,10 +28,6 @@ steps:
|
|||||||
|
|
||||||
build_binary linux amd64 ""
|
build_binary linux amd64 ""
|
||||||
build_binary linux arm64 ""
|
build_binary linux arm64 ""
|
||||||
build_binary darwin amd64 ""
|
|
||||||
build_binary darwin arm64 ""
|
|
||||||
build_binary windows amd64 ".exe"
|
|
||||||
build_binary windows arm64 ".exe"
|
|
||||||
|
|
||||||
- name: publish-release
|
- name: publish-release
|
||||||
image: woodpeckerci/plugin-release
|
image: woodpeckerci/plugin-release
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Integration references:
|
|||||||
- `serve` requires an effective `prompt_dir` and `profile_dir` (from flags or config).
|
- `serve` requires an effective `prompt_dir` and `profile_dir` (from flags or config).
|
||||||
- Positional arguments are rejected.
|
- Positional arguments are rejected.
|
||||||
- Prompt cache control is configured in prompt YAML (`messages[].cache_control`), not with CLI flags.
|
- Prompt cache control is configured in prompt YAML (`messages[].cache_control`), not with CLI flags.
|
||||||
|
- Provider-specific `reasoning_effort` and `extra_params` are configured in profile YAML or HTTP model overrides, not with CLI flags.
|
||||||
|
|
||||||
## Flag Reference
|
## Flag Reference
|
||||||
|
|
||||||
@@ -57,6 +58,11 @@ Integration references:
|
|||||||
- `--top-p <float>`: runtime top-p override.
|
- `--top-p <float>`: runtime top-p override.
|
||||||
- `--timeout <duration>`: runtime timeout override (Go duration syntax, for example `30s`, `2m`).
|
- `--timeout <duration>`: runtime timeout override (Go duration syntax, for example `30s`, `2m`).
|
||||||
|
|
||||||
|
Numeric runtime override flags are presence-aware:
|
||||||
|
|
||||||
|
- omitted numeric flags preserve the selected profile/default value
|
||||||
|
- explicit zero values override the selected profile/default value (`--temperature 0`, `--max-tokens 0`, `--top-p 0`, `--timeout 0s`)
|
||||||
|
|
||||||
### `scriptorium render`
|
### `scriptorium render`
|
||||||
|
|
||||||
- Supports the same flags as `run`, except:
|
- Supports the same flags as `run`, except:
|
||||||
|
|||||||
@@ -189,6 +189,11 @@ top_p: 1.0
|
|||||||
timeout_seconds: 90
|
timeout_seconds: 90
|
||||||
api_key_env: SCRIPTORIUM_API_KEY
|
api_key_env: SCRIPTORIUM_API_KEY
|
||||||
service_tier: priority
|
service_tier: priority
|
||||||
|
reasoning_effort: medium
|
||||||
|
extra_params:
|
||||||
|
provider_route: primary
|
||||||
|
provider_options:
|
||||||
|
retry_budget: 2
|
||||||
```
|
```
|
||||||
|
|
||||||
Field reference:
|
Field reference:
|
||||||
@@ -201,9 +206,9 @@ Field reference:
|
|||||||
- `top_p` (optional): range `0..1`
|
- `top_p` (optional): range `0..1`
|
||||||
- `timeout_seconds` (optional): `>= 0`
|
- `timeout_seconds` (optional): `>= 0`
|
||||||
- `service_tier` (optional): provider-specific request tier such as OpenRouter `flex` or `priority`
|
- `service_tier` (optional): provider-specific request tier such as OpenRouter `flex` or `priority`
|
||||||
- `reasoning_effort` (optional)
|
- `reasoning_effort` (optional): serialized as top-level `reasoning_effort` in outbound chat-completions requests
|
||||||
- `api_key_env` (optional)
|
- `api_key_env` (optional)
|
||||||
- `extra_params` (optional map of strings)
|
- `extra_params` (optional map): JSON-compatible provider-specific parameters. Values may be strings, numbers, booleans, objects, or arrays.
|
||||||
|
|
||||||
Profile rules:
|
Profile rules:
|
||||||
|
|
||||||
@@ -211,13 +216,14 @@ Profile rules:
|
|||||||
- Raw `api_key` is rejected; use `api_key_env`.
|
- Raw `api_key` is rejected; use `api_key_env`.
|
||||||
- If `api_key_env` is set, that environment variable must be set when preparing/running.
|
- If `api_key_env` is set, that environment variable must be set when preparing/running.
|
||||||
- Duplicate profile IDs are invalid. If multiple files declare the requested profile ID, Scriptorium fails instead of choosing one.
|
- Duplicate profile IDs are invalid. If multiple files declare the requested profile ID, Scriptorium fails instead of choosing one.
|
||||||
|
- `extra_params` keys must not be empty and must not collide with reserved outbound request fields: `model`, `session_id`, `messages`, `temperature`, `max_tokens`, `top_p`, `service_tier`, `reasoning_effort`, or `response_format`.
|
||||||
|
|
||||||
Current outbound request behavior:
|
Current outbound request behavior:
|
||||||
|
|
||||||
- The OpenAI-compatible client currently serializes: `model`, optional `session_id`, `messages`, `temperature`, `max_tokens`, `top_p`, `service_tier`, and optional `response_format` for `json_schema` prompts.
|
- The OpenAI-compatible client currently serializes: `model`, optional `session_id`, `messages`, `temperature`, `max_tokens`, `top_p`, `service_tier`, `reasoning_effort`, optional `response_format` for `json_schema` prompts, and `extra_params`.
|
||||||
|
- `extra_params` are flattened into provider-specific top-level JSON request fields. They are not wrapped in an `extra_params` object on the outbound provider request.
|
||||||
- Messages without `cache_control` serialize with string `content`.
|
- Messages without `cache_control` serialize with string `content`.
|
||||||
- Messages with `cache_control` serialize as a single text content-block array containing `cache_control`.
|
- Messages with `cache_control` serialize as a single text content-block array containing `cache_control`.
|
||||||
- `reasoning_effort` and `extra_params` are parsed and carried in effective settings, but are not currently serialized into outbound chat-completions requests.
|
|
||||||
|
|
||||||
## Schema Behavior
|
## Schema Behavior
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ Copyable request example file:
|
|||||||
"reasoning_effort": "medium",
|
"reasoning_effort": "medium",
|
||||||
"api_key_env": "SCRIPTORIUM_API_KEY",
|
"api_key_env": "SCRIPTORIUM_API_KEY",
|
||||||
"extra_params": {
|
"extra_params": {
|
||||||
"route": "primary"
|
"route": "primary",
|
||||||
|
"provider_options": {
|
||||||
|
"retry_budget": 2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include_raw_output": false
|
"include_raw_output": false
|
||||||
@@ -67,6 +70,14 @@ Input reference types currently supported by runtime artifact loading:
|
|||||||
- `file`
|
- `file`
|
||||||
- `inline`
|
- `inline`
|
||||||
|
|
||||||
|
Model override notes:
|
||||||
|
|
||||||
|
- Numeric model override fields distinguish omitted values from explicit zero values. For example, omitting `temperature` preserves the selected profile/default value, while `"temperature": 0` explicitly sets the effective temperature to zero.
|
||||||
|
- `extra_params` accepts JSON-compatible values: strings, numbers, booleans, objects, and arrays.
|
||||||
|
- `extra_params` are passed through effective model metadata and flattened into top-level provider request fields by the OpenAI-compatible client.
|
||||||
|
- `extra_params` keys must not be empty and must not collide with reserved outbound fields: `model`, `session_id`, `messages`, `temperature`, `max_tokens`, `top_p`, `service_tier`, `reasoning_effort`, or `response_format`.
|
||||||
|
- Raw API-key values are not accepted. Use `api_key_env` to name an environment variable.
|
||||||
|
|
||||||
## Strict JSON Rules
|
## Strict JSON Rules
|
||||||
|
|
||||||
Request decoding uses strict JSON field checks:
|
Request decoding uses strict JSON field checks:
|
||||||
@@ -119,7 +130,10 @@ Response shape:
|
|||||||
"reasoning_effort": "medium",
|
"reasoning_effort": "medium",
|
||||||
"api_key_env": "SCRIPTORIUM_API_KEY",
|
"api_key_env": "SCRIPTORIUM_API_KEY",
|
||||||
"extra_params": {
|
"extra_params": {
|
||||||
"route": "primary"
|
"route": "primary",
|
||||||
|
"provider_options": {
|
||||||
|
"retry_budget": 2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"input_hashes": {
|
"input_hashes": {
|
||||||
|
|||||||
@@ -28,14 +28,52 @@ Serialized JSON fields:
|
|||||||
- `model` (required after fallback resolution)
|
- `model` (required after fallback resolution)
|
||||||
- `session_id` (only when the rendered prompt includes a non-empty session ID)
|
- `session_id` (only when the rendered prompt includes a non-empty session ID)
|
||||||
- `messages` (rendered prompt messages)
|
- `messages` (rendered prompt messages)
|
||||||
- `temperature` (only when non-zero)
|
- `temperature` (when non-zero, or when explicitly overridden to zero)
|
||||||
- `max_tokens` (only when non-zero)
|
- `max_tokens` (when non-zero, or when explicitly overridden to zero)
|
||||||
- `top_p` (only when non-zero)
|
- `top_p` (when non-zero, or when explicitly overridden to zero)
|
||||||
- `service_tier` (only when non-empty)
|
- `service_tier` (only when non-empty)
|
||||||
|
- `reasoning_effort` (only when non-empty)
|
||||||
- `response_format` (only when structured output is provided)
|
- `response_format` (only when structured output is provided)
|
||||||
|
- profile/request `extra_params` as additional provider-specific top-level fields
|
||||||
|
|
||||||
`service_tier` is provider-specific. OpenRouter currently documents request values such as `flex` and `priority`; Scriptorium forwards any non-empty configured value and lets the backend validate support.
|
`service_tier` is provider-specific. OpenRouter currently documents request values such as `flex` and `priority`; Scriptorium forwards any non-empty configured value and lets the backend validate support.
|
||||||
|
|
||||||
|
`reasoning_effort` is provider-specific. Scriptorium forwards any non-empty configured value as top-level `reasoning_effort` and lets the backend validate support.
|
||||||
|
|
||||||
|
`extra_params` are flattened into the outbound JSON object. They are not wrapped in an `extra_params` object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "gpt-4o-mini",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "rendered text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"provider_route": "primary",
|
||||||
|
"provider_options": {
|
||||||
|
"retry_budget": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`extra_params` values must be JSON-compatible. Supported value shapes include strings, numbers, booleans, objects, and arrays.
|
||||||
|
|
||||||
|
Reserved `extra_params` keys are rejected before the HTTP request is made:
|
||||||
|
|
||||||
|
- `model`
|
||||||
|
- `session_id`
|
||||||
|
- `messages`
|
||||||
|
- `temperature`
|
||||||
|
- `max_tokens`
|
||||||
|
- `top_p`
|
||||||
|
- `service_tier`
|
||||||
|
- `reasoning_effort`
|
||||||
|
- `response_format`
|
||||||
|
|
||||||
|
Empty `extra_params` keys and values that cannot be encoded as JSON are also rejected before the HTTP request is made.
|
||||||
|
|
||||||
`session_id` is rendered from prompt YAML using request variables and serialized as a top-level JSON request field. Scriptorium does not send an `x-session-id` header. Empty rendered session IDs are omitted, and values longer than 256 characters are rejected before the HTTP request.
|
`session_id` is rendered from prompt YAML using request variables and serialized as a top-level JSON request field. Scriptorium does not send an `x-session-id` header. Empty rendered session IDs are omitted, and values longer than 256 characters are rejected before the HTTP request.
|
||||||
|
|
||||||
Messages without prompt cache control serialize with string `content`:
|
Messages without prompt cache control serialize with string `content`:
|
||||||
@@ -104,6 +142,7 @@ Base timeout comes from client configuration.
|
|||||||
Per-request override:
|
Per-request override:
|
||||||
|
|
||||||
- if `Target.TimeoutSeconds > 0`, use that value for request timeout
|
- if `Target.TimeoutSeconds > 0`, use that value for request timeout
|
||||||
|
- if `Target.TimeoutSeconds == 0` and the value came from an explicit request override, disable the HTTP client timeout
|
||||||
- if `Target.TimeoutSeconds < 0`, request is rejected (`ErrInvalidRequest`)
|
- if `Target.TimeoutSeconds < 0`, request is rejected (`ErrInvalidRequest`)
|
||||||
|
|
||||||
## Response Expectations
|
## Response Expectations
|
||||||
@@ -138,12 +177,7 @@ Malformed responses return `ErrMalformedResponse`.
|
|||||||
|
|
||||||
## Unsupported Or Non-Serialized Fields
|
## Unsupported Or Non-Serialized Fields
|
||||||
|
|
||||||
The following fields may exist in profile/effective settings but are not currently serialized into outbound chat-completions payloads:
|
The client does not serialize top-level `cache_control`.
|
||||||
|
|
||||||
- `reasoning_effort`
|
|
||||||
- `extra_params`
|
|
||||||
|
|
||||||
The client also does not serialize top-level `cache_control`.
|
|
||||||
|
|
||||||
No built-in retries, tool-calls, or multi-request payload modes are implemented in this client.
|
No built-in retries, tool-calls, or multi-request payload modes are implemented in this client.
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ Primary app settings consumed by adapters:
|
|||||||
Execution profile/request settings used through runner:
|
Execution profile/request settings used through runner:
|
||||||
|
|
||||||
- `endpoint`, `model`, `temperature`, `max_tokens`, `top_p`, `timeout_seconds`, `service_tier`, `api_key_env`, `reasoning_effort`, `extra_params`
|
- `endpoint`, `model`, `temperature`, `max_tokens`, `top_p`, `timeout_seconds`, `service_tier`, `api_key_env`, `reasoning_effort`, `extra_params`
|
||||||
|
- CLI and HTTP request adapters preserve caller intent for numeric runtime overrides. Omitted values remain absent; explicit zero values are mapped as explicit overrides.
|
||||||
|
- HTTP `extra_params` accepts JSON-compatible values and maps them to domain request overrides without provider-specific adapter logic.
|
||||||
|
|
||||||
## External Dependencies
|
## External Dependencies
|
||||||
|
|
||||||
@@ -97,6 +99,10 @@ LLM adapter:
|
|||||||
- endpoint appends `/chat/completions`.
|
- endpoint appends `/chat/completions`.
|
||||||
- rendered messages without cache control serialize with string `content`.
|
- rendered messages without cache control serialize with string `content`.
|
||||||
- rendered messages with cache control serialize as one text content block with `cache_control`.
|
- rendered messages with cache control serialize as one text content block with `cache_control`.
|
||||||
|
- non-empty `reasoning_effort` serializes as a top-level provider request field.
|
||||||
|
- `extra_params` flatten into provider-specific top-level JSON request fields.
|
||||||
|
- reserved `extra_params` keys are rejected before the provider call: `model`, `session_id`, `messages`, `temperature`, `max_tokens`, `top_p`, `service_tier`, `reasoning_effort`, and `response_format`.
|
||||||
|
- empty `extra_params` keys and values that cannot be JSON-encoded are rejected before the provider call.
|
||||||
- compatible cache usage response fields are parsed into domain token usage.
|
- compatible cache usage response fields are parsed into domain token usage.
|
||||||
- non-2xx responses map to request failure errors.
|
- non-2xx responses map to request failure errors.
|
||||||
- malformed responses (including missing/empty first choice content) are errors.
|
- malformed responses (including missing/empty first choice content) are errors.
|
||||||
@@ -146,5 +152,5 @@ Behavior highlights:
|
|||||||
- Adapter packages do not own runner decision logic.
|
- Adapter packages do not own runner decision logic.
|
||||||
- External request/response strictness is part of contract stability.
|
- External request/response strictness is part of contract stability.
|
||||||
- Prepared-render output never includes resolved API key values.
|
- Prepared-render output never includes resolved API key values.
|
||||||
- Outbound OpenAI-compatible request includes only currently serialized fields (`model`, optional `session_id`, `messages`, optional `temperature`, `max_tokens`, `top_p`, optional `service_tier`, optional `response_format`).
|
- Outbound OpenAI-compatible request includes currently serialized first-class fields (`model`, optional `session_id`, `messages`, optional `temperature`, `max_tokens`, `top_p`, optional `service_tier`, optional `reasoning_effort`, optional `response_format`) plus validated `extra_params` flattened as provider-specific top-level fields.
|
||||||
- Outbound cache control is message-level only; no top-level cache-control field is serialized.
|
- Outbound cache control is message-level only; no top-level cache-control field is serialized.
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ Validation content failures are not run errors:
|
|||||||
- built-in execution defaults
|
- built-in execution defaults
|
||||||
- selected profile values
|
- selected profile values
|
||||||
- request overrides
|
- request overrides
|
||||||
|
- request numeric overrides are presence-aware, so omitted values preserve the current effective value and explicit zero values override it
|
||||||
6. verify required `api_key_env` environment variable:
|
6. verify required `api_key_env` environment variable:
|
||||||
- missing/empty env value returns an invalid request with `ErrAPIKeyEnvMissing`
|
- missing/empty env value returns an invalid request with `ErrAPIKeyEnvMissing`
|
||||||
- only the environment-variable name is retained; secret value is never returned
|
- only the environment-variable name is retained; secret value is never returned
|
||||||
@@ -115,6 +116,14 @@ Validation content failures are not run errors:
|
|||||||
|
|
||||||
`Prepare` does not call the LLM.
|
`Prepare` does not call the LLM.
|
||||||
|
|
||||||
|
Runtime target notes:
|
||||||
|
|
||||||
|
- Profile `extra_params` and request `extra_params` carry JSON-compatible values through prepared output, run metadata, and `domain.GenerateRequest.Target`.
|
||||||
|
- The OpenAI-compatible client serializes non-empty `reasoning_effort` as a top-level provider request field.
|
||||||
|
- The OpenAI-compatible client flattens `extra_params` into provider-specific top-level JSON request fields.
|
||||||
|
- Empty `extra_params` keys, reserved outbound field names, and values that cannot be JSON-encoded fail before the provider request.
|
||||||
|
- Resolved API-key values are never stored in `PreparedRun`, `RunResult`, logs, or HTTP responses.
|
||||||
|
|
||||||
## Run Flow
|
## Run Flow
|
||||||
|
|
||||||
`Run` performs:
|
`Run` performs:
|
||||||
|
|||||||
@@ -513,18 +513,25 @@ func buildRunRequestFromConfig(cfg *runConfig) (domain.RunRequest, error) {
|
|||||||
inputs[name] = domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: path}
|
inputs[name] = domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: path}
|
||||||
}
|
}
|
||||||
|
|
||||||
var modelOverride *domain.ExecutionTarget
|
var modelOverride *domain.ExecutionTargetOverride
|
||||||
if cfg.llmBaseURLSet || cfg.modelSet || cfg.temperatureSet || cfg.maxTokensSet || cfg.topPSet || cfg.apiKeyEnvSet || cfg.timeoutSet {
|
if cfg.llmBaseURLSet || cfg.modelSet || cfg.temperatureSet || cfg.maxTokensSet || cfg.topPSet || cfg.apiKeyEnvSet || cfg.timeoutSet {
|
||||||
modelOverride = &domain.ExecutionTarget{
|
modelOverride = &domain.ExecutionTargetOverride{
|
||||||
Endpoint: cfg.llmBaseURL,
|
Endpoint: cfg.llmBaseURL,
|
||||||
Model: cfg.model,
|
Model: cfg.model,
|
||||||
Temperature: cfg.temperature,
|
APIKeyEnv: cfg.apiKeyEnv,
|
||||||
MaxTokens: cfg.maxTokens,
|
}
|
||||||
TopP: cfg.topP,
|
if cfg.temperatureSet {
|
||||||
APIKeyEnv: cfg.apiKeyEnv,
|
modelOverride.Temperature = &cfg.temperature
|
||||||
|
}
|
||||||
|
if cfg.maxTokensSet {
|
||||||
|
modelOverride.MaxTokens = &cfg.maxTokens
|
||||||
|
}
|
||||||
|
if cfg.topPSet {
|
||||||
|
modelOverride.TopP = &cfg.topP
|
||||||
}
|
}
|
||||||
if cfg.timeoutSet {
|
if cfg.timeoutSet {
|
||||||
modelOverride.TimeoutSeconds = int(cfg.timeout.Seconds())
|
timeoutSeconds := int(cfg.timeout.Seconds())
|
||||||
|
modelOverride.TimeoutSeconds = &timeoutSeconds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -747,6 +747,35 @@ func TestRenderCommandDefaultFormatTextIncludesPreparedDetailsAndNoSecrets(t *te
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderCommandExplicitZeroTemperatureReachesEffectiveSettings(t *testing.T) {
|
||||||
|
lib := newCLITestLibrary(t)
|
||||||
|
inputPath := lib.writeInputFile(t, "transcript.md", "hello transcript")
|
||||||
|
|
||||||
|
writePromptFile(t, lib.promptDir, "prompt.render", "local-default")
|
||||||
|
profile := `id: local-default
|
||||||
|
endpoint: http://127.0.0.1:1/v1
|
||||||
|
model: profile-model
|
||||||
|
temperature: 0.7
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(lib.profileDir, "local-default.yaml"), []byte(profile), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write profile fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, stdout, stderr := runCLICommand(t, renderCommand, []string{
|
||||||
|
"--prompt-dir", lib.promptDir,
|
||||||
|
"--profile-dir", lib.profileDir,
|
||||||
|
"--prompt", "prompt.render",
|
||||||
|
"--input", "transcript=" + inputPath,
|
||||||
|
"--temperature", "0",
|
||||||
|
})
|
||||||
|
if code != ExitOK {
|
||||||
|
t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout, "\n temperature: 0\n") {
|
||||||
|
t.Fatalf("expected explicit zero temperature in effective settings, got:\n%s", stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRenderCommandSucceedsWithPromptAndProfileDirsFromConfig(t *testing.T) {
|
func TestRenderCommandSucceedsWithPromptAndProfileDirsFromConfig(t *testing.T) {
|
||||||
lib := newCLITestLibrary(t)
|
lib := newCLITestLibrary(t)
|
||||||
inputPath := lib.writeInputFile(t, "transcript.md", "hello transcript")
|
inputPath := lib.writeInputFile(t, "transcript.md", "hello transcript")
|
||||||
|
|||||||
@@ -21,16 +21,16 @@ type inputRefDTO struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type modelOverrideRequestDTO struct {
|
type modelOverrideRequestDTO struct {
|
||||||
Endpoint string `json:"endpoint,omitempty"`
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
Temperature float64 `json:"temperature,omitempty"`
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
MaxTokens int `json:"max_tokens,omitempty"`
|
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||||
TopP float64 `json:"top_p,omitempty"`
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
|
||||||
ServiceTier string `json:"service_tier,omitempty"`
|
ServiceTier string `json:"service_tier,omitempty"`
|
||||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||||
APIKeyEnv string `json:"api_key_env,omitempty"`
|
APIKeyEnv string `json:"api_key_env,omitempty"`
|
||||||
ExtraParams map[string]string `json:"extra_params,omitempty"`
|
ExtraParams map[string]any `json:"extra_params,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type runResponseDTO struct {
|
type runResponseDTO struct {
|
||||||
@@ -70,16 +70,16 @@ type metadataDTO struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type modelParamsDTO struct {
|
type modelParamsDTO struct {
|
||||||
Endpoint string `json:"endpoint"`
|
Endpoint string `json:"endpoint"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Temperature float64 `json:"temperature"`
|
Temperature float64 `json:"temperature"`
|
||||||
MaxTokens int `json:"max_tokens"`
|
MaxTokens int `json:"max_tokens"`
|
||||||
TopP float64 `json:"top_p"`
|
TopP float64 `json:"top_p"`
|
||||||
TimeoutSeconds int `json:"timeout_seconds"`
|
TimeoutSeconds int `json:"timeout_seconds"`
|
||||||
ServiceTier string `json:"service_tier,omitempty"`
|
ServiceTier string `json:"service_tier,omitempty"`
|
||||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||||
APIKeyEnv string `json:"api_key_env,omitempty"`
|
APIKeyEnv string `json:"api_key_env,omitempty"`
|
||||||
ExtraParams map[string]string `json:"extra_params,omitempty"`
|
ExtraParams map[string]any `json:"extra_params,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type tokenUsageDTO struct {
|
type tokenUsageDTO struct {
|
||||||
|
|||||||
@@ -61,9 +61,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var model *domain.ExecutionTarget
|
var model *domain.ExecutionTargetOverride
|
||||||
if req.Model != nil {
|
if req.Model != nil {
|
||||||
model = executionTargetFromModelOverrideDTO(req.Model)
|
model = executionTargetOverrideFromModelOverrideDTO(req.Model)
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := h.runner.Run(r.Context(), domain.RunRequest{
|
res, err := h.runner.Run(r.Context(), domain.RunRequest{
|
||||||
@@ -123,11 +123,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func executionTargetFromModelOverrideDTO(dto *modelOverrideRequestDTO) *domain.ExecutionTarget {
|
func executionTargetOverrideFromModelOverrideDTO(dto *modelOverrideRequestDTO) *domain.ExecutionTargetOverride {
|
||||||
if dto == nil {
|
if dto == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &domain.ExecutionTarget{
|
return &domain.ExecutionTargetOverride{
|
||||||
Endpoint: dto.Endpoint,
|
Endpoint: dto.Endpoint,
|
||||||
Model: dto.Model,
|
Model: dto.Model,
|
||||||
Temperature: dto.Temperature,
|
Temperature: dto.Temperature,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/scriptorium/internal/llm"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/usecase"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/usecase"
|
||||||
@@ -32,6 +33,34 @@ func (f *fakeRunner) Run(ctx context.Context, req domain.RunRequest) (*domain.Ru
|
|||||||
return f.result, nil
|
return f.result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type handlerPromptRepo struct {
|
||||||
|
def *domain.PromptDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r handlerPromptRepo) GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error) {
|
||||||
|
return r.def, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type handlerProfileRepo struct {
|
||||||
|
profile *domain.ExecutionProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r handlerProfileRepo) GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error) {
|
||||||
|
return r.profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type handlerArtifactReader struct{}
|
||||||
|
|
||||||
|
func (handlerArtifactReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||||
|
return &domain.Artifact{Name: "input", Body: []byte("input"), Hash: "hash"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type handlerRenderer struct{}
|
||||||
|
|
||||||
|
func (handlerRenderer) Render(ctx context.Context, definition *domain.PromptDefinition, inputs map[string]*domain.Artifact, vars map[string]string) (*domain.RenderedPrompt, error) {
|
||||||
|
return &domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
|
func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
|
||||||
start := time.Now().UTC()
|
start := time.Now().UTC()
|
||||||
end := start.Add(2 * time.Second)
|
end := start.Add(2 * time.Second)
|
||||||
@@ -147,7 +176,7 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
|
|||||||
if r.last.Execution == nil || r.last.Execution.Model != "gpt-x" {
|
if r.last.Execution == nil || r.last.Execution.Model != "gpt-x" {
|
||||||
t.Fatalf("expected model override, got %#v", r.last.Execution)
|
t.Fatalf("expected model override, got %#v", r.last.Execution)
|
||||||
}
|
}
|
||||||
if r.last.Execution.TimeoutSeconds != 120 {
|
if r.last.Execution.TimeoutSeconds == nil || *r.last.Execution.TimeoutSeconds != 120 {
|
||||||
t.Fatalf("expected timeout_seconds override 120, got %#v", r.last.Execution)
|
t.Fatalf("expected timeout_seconds override 120, got %#v", r.last.Execution)
|
||||||
}
|
}
|
||||||
if r.last.Execution.ServiceTier != "flex" {
|
if r.last.Execution.ServiceTier != "flex" {
|
||||||
@@ -228,20 +257,136 @@ func TestHandlerModelOverrideMapsAllSupportedExecutionFields(t *testing.T) {
|
|||||||
got := r.last.Execution
|
got := r.last.Execution
|
||||||
if got.Endpoint != "http://override/v1" ||
|
if got.Endpoint != "http://override/v1" ||
|
||||||
got.Model != "override-model" ||
|
got.Model != "override-model" ||
|
||||||
got.Temperature != 0.6 ||
|
|
||||||
got.MaxTokens != 250 ||
|
|
||||||
got.TopP != 0.85 ||
|
|
||||||
got.TimeoutSeconds != 33 ||
|
|
||||||
got.ServiceTier != "flex" ||
|
got.ServiceTier != "flex" ||
|
||||||
got.ReasoningEffort != "medium" ||
|
got.ReasoningEffort != "medium" ||
|
||||||
got.APIKeyEnv != "SCRIPTORIUM_API_KEY" {
|
got.APIKeyEnv != "SCRIPTORIUM_API_KEY" {
|
||||||
t.Fatalf("unexpected mapped execution target: %+v", got)
|
t.Fatalf("unexpected mapped execution target: %+v", got)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(got.ExtraParams, map[string]string{"provider_option": "on"}) {
|
if got.Temperature == nil || *got.Temperature != 0.6 {
|
||||||
|
t.Fatalf("unexpected mapped temperature: %#v", got.Temperature)
|
||||||
|
}
|
||||||
|
if got.MaxTokens == nil || *got.MaxTokens != 250 {
|
||||||
|
t.Fatalf("unexpected mapped max_tokens: %#v", got.MaxTokens)
|
||||||
|
}
|
||||||
|
if got.TopP == nil || *got.TopP != 0.85 {
|
||||||
|
t.Fatalf("unexpected mapped top_p: %#v", got.TopP)
|
||||||
|
}
|
||||||
|
if got.TimeoutSeconds == nil || *got.TimeoutSeconds != 33 {
|
||||||
|
t.Fatalf("unexpected mapped timeout_seconds: %#v", got.TimeoutSeconds)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got.ExtraParams, map[string]any{"provider_option": "on"}) {
|
||||||
t.Fatalf("unexpected mapped extra_params: %#v", got.ExtraParams)
|
t.Fatalf("unexpected mapped extra_params: %#v", got.ExtraParams)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlerModelOverrideAcceptsJSONCompatibleExtraParams(t *testing.T) {
|
||||||
|
r := &fakeRunner{result: &domain.RunResult{
|
||||||
|
Artifact: domain.Artifact{Body: []byte("ok")},
|
||||||
|
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
||||||
|
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
||||||
|
}}
|
||||||
|
h := NewHandler(r)
|
||||||
|
|
||||||
|
reqBody := `{
|
||||||
|
"prompt_id": "prompt-1",
|
||||||
|
"inputs": {"transcript": {"type": "file", "uri": "./t.md"}},
|
||||||
|
"model": {
|
||||||
|
"extra_params": {
|
||||||
|
"string_value": "enabled",
|
||||||
|
"number_value": 42,
|
||||||
|
"boolean_value": true,
|
||||||
|
"object_value": {"nested": "value", "count": 2},
|
||||||
|
"array_value": ["first", 3, false]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(reqBody))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if r.last.Execution == nil {
|
||||||
|
t.Fatal("expected execution override in run request")
|
||||||
|
}
|
||||||
|
want := map[string]any{
|
||||||
|
"string_value": "enabled",
|
||||||
|
"number_value": float64(42),
|
||||||
|
"boolean_value": true,
|
||||||
|
"object_value": map[string]any{"nested": "value", "count": float64(2)},
|
||||||
|
"array_value": []any{"first", float64(3), false},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(r.last.Execution.ExtraParams, want) {
|
||||||
|
t.Fatalf("unexpected mapped extra_params:\ngot=%#v\nwant=%#v", r.last.Execution.ExtraParams, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerModelOverrideExplicitZeroTemperatureMapsAsPresent(t *testing.T) {
|
||||||
|
r := &fakeRunner{result: &domain.RunResult{
|
||||||
|
Artifact: domain.Artifact{Body: []byte("ok")},
|
||||||
|
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
||||||
|
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0},
|
||||||
|
}}
|
||||||
|
h := NewHandler(r)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
||||||
|
"prompt_id": "prompt-1",
|
||||||
|
"inputs": {"transcript": {"type": "file", "uri": "./t.md"}},
|
||||||
|
"model": {"temperature": 0}
|
||||||
|
}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if r.last.Execution == nil || r.last.Execution.Temperature == nil {
|
||||||
|
t.Fatalf("expected temperature override to be present, got %#v", r.last.Execution)
|
||||||
|
}
|
||||||
|
if *r.last.Execution.Temperature != 0 {
|
||||||
|
t.Fatalf("expected zero temperature override, got %v", *r.last.Execution.Temperature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerModelOverrideOmittedTemperatureMapsAsAbsent(t *testing.T) {
|
||||||
|
r := &fakeRunner{result: &domain.RunResult{
|
||||||
|
Artifact: domain.Artifact{Body: []byte("ok")},
|
||||||
|
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
||||||
|
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0.7},
|
||||||
|
}}
|
||||||
|
h := NewHandler(r)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
||||||
|
"prompt_id": "prompt-1",
|
||||||
|
"inputs": {"transcript": {"type": "file", "uri": "./t.md"}},
|
||||||
|
"model": {"model": "override-model"}
|
||||||
|
}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if r.last.Execution == nil {
|
||||||
|
t.Fatal("expected model override")
|
||||||
|
}
|
||||||
|
if r.last.Execution.Temperature != nil {
|
||||||
|
t.Fatalf("expected omitted temperature to remain absent, got %#v", r.last.Execution.Temperature)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("invalid JSON response: %v", err)
|
||||||
|
}
|
||||||
|
metadata := resp["metadata"].(map[string]any)
|
||||||
|
params := metadata["model_params"].(map[string]any)
|
||||||
|
if params["temperature"] != 0.7 {
|
||||||
|
t.Fatalf("expected effective profile/default temperature in response, got %#v", params["temperature"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandlerResponseMetadataModelParamsIncludesAllSupportedFields(t *testing.T) {
|
func TestHandlerResponseMetadataModelParamsIncludesAllSupportedFields(t *testing.T) {
|
||||||
r := &fakeRunner{result: &domain.RunResult{
|
r := &fakeRunner{result: &domain.RunResult{
|
||||||
Artifact: domain.Artifact{
|
Artifact: domain.Artifact{
|
||||||
@@ -262,8 +407,10 @@ func TestHandlerResponseMetadataModelParamsIncludesAllSupportedFields(t *testing
|
|||||||
ServiceTier: "priority",
|
ServiceTier: "priority",
|
||||||
ReasoningEffort: "high",
|
ReasoningEffort: "high",
|
||||||
APIKeyEnv: "SCRIPTORIUM_API_KEY",
|
APIKeyEnv: "SCRIPTORIUM_API_KEY",
|
||||||
ExtraParams: map[string]string{
|
ExtraParams: map[string]any{
|
||||||
"provider_option": "on",
|
"provider_option": "on",
|
||||||
|
"number_value": 42,
|
||||||
|
"object_value": map[string]any{"nested": "value"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -318,6 +465,13 @@ func TestHandlerResponseMetadataModelParamsIncludesAllSupportedFields(t *testing
|
|||||||
if extraParams["provider_option"] != "on" {
|
if extraParams["provider_option"] != "on" {
|
||||||
t.Fatalf("unexpected extra_params.provider_option: %#v", extraParams["provider_option"])
|
t.Fatalf("unexpected extra_params.provider_option: %#v", extraParams["provider_option"])
|
||||||
}
|
}
|
||||||
|
if extraParams["number_value"] != float64(42) {
|
||||||
|
t.Fatalf("unexpected extra_params.number_value: %#v", extraParams["number_value"])
|
||||||
|
}
|
||||||
|
objectValue, ok := extraParams["object_value"].(map[string]any)
|
||||||
|
if !ok || objectValue["nested"] != "value" {
|
||||||
|
t.Fatalf("unexpected extra_params.object_value: %#v", extraParams["object_value"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerInvalidJSON(t *testing.T) {
|
func TestHandlerInvalidJSON(t *testing.T) {
|
||||||
@@ -352,6 +506,54 @@ func TestHandlerMissingPromptID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlerReservedExtraParamsThroughRunnerMapsToInvalidRequest(t *testing.T) {
|
||||||
|
llmClient, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runner := usecase.NewRunner(
|
||||||
|
handlerPromptRepo{def: &domain.PromptDefinition{
|
||||||
|
ID: "p",
|
||||||
|
Version: "1",
|
||||||
|
DefaultProfile: "exec",
|
||||||
|
Templates: []domain.PromptMessageTemplate{{Role: "user", Content: "hi"}},
|
||||||
|
OutputFormat: domain.FormatText,
|
||||||
|
Validation: domain.OutputContract{Format: domain.FormatText, ValidationMode: domain.ValidationNone},
|
||||||
|
}},
|
||||||
|
handlerProfileRepo{profile: &domain.ExecutionProfile{
|
||||||
|
ID: "exec",
|
||||||
|
Endpoint: "http://example.invalid/v1",
|
||||||
|
Model: "model",
|
||||||
|
}},
|
||||||
|
handlerArtifactReader{},
|
||||||
|
handlerRenderer{},
|
||||||
|
llmClient,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
h := NewHandler(runner)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
||||||
|
"prompt_id":"p",
|
||||||
|
"inputs":{"x":{"type":"file","uri":"a"}},
|
||||||
|
"model":{"extra_params":{"model":"collision"}}
|
||||||
|
}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("invalid JSON response: %v", err)
|
||||||
|
}
|
||||||
|
errBody := resp["error"].(map[string]any)
|
||||||
|
if errBody["code"] != "invalid_request" {
|
||||||
|
t.Fatalf("expected invalid_request code, got %#v", errBody["code"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandlerUsecaseErrorMapping(t *testing.T) {
|
func TestHandlerUsecaseErrorMapping(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ type RunRequest struct {
|
|||||||
ProfileID string
|
ProfileID string
|
||||||
Inputs map[string]ArtifactRef
|
Inputs map[string]ArtifactRef
|
||||||
Vars map[string]string
|
Vars map[string]string
|
||||||
Execution *ExecutionTarget
|
Execution *ExecutionTargetOverride
|
||||||
Validation *OutputContract
|
Validation *OutputContract
|
||||||
Metadata map[string]string
|
Metadata map[string]string
|
||||||
}
|
}
|
||||||
@@ -95,20 +95,21 @@ 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 `json:"prompt_id"`
|
||||||
PromptVersion string `json:"prompt_version,omitempty"`
|
PromptVersion string `json:"prompt_version,omitempty"`
|
||||||
PromptHash string `json:"prompt_hash,omitempty"`
|
PromptHash string `json:"prompt_hash,omitempty"`
|
||||||
SelectedProfileID string `json:"selected_profile_id"`
|
SelectedProfileID string `json:"selected_profile_id"`
|
||||||
EffectiveModelParams ExecutionTarget `json:"effective_model_params"`
|
EffectiveModelParams ExecutionTarget `json:"effective_model_params"`
|
||||||
OutputContract OutputContract `json:"output_contract"`
|
TargetPresence ExecutionTargetPresence `json:"-"`
|
||||||
StructuredOutput *StructuredOutputSpec `json:"structured_output,omitempty"`
|
OutputContract OutputContract `json:"output_contract"`
|
||||||
InputHashes map[string]string `json:"input_hashes,omitempty"`
|
StructuredOutput *StructuredOutputSpec `json:"structured_output,omitempty"`
|
||||||
SessionID string `json:"session_id,omitempty"`
|
InputHashes map[string]string `json:"input_hashes,omitempty"`
|
||||||
RenderedPromptHash string `json:"rendered_prompt_hash"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
Messages []RenderedMessage `json:"messages"`
|
RenderedPromptHash string `json:"rendered_prompt_hash"`
|
||||||
StartTime time.Time `json:"start_time,omitempty"`
|
Messages []RenderedMessage `json:"messages"`
|
||||||
EndTime time.Time `json:"end_time,omitempty"`
|
StartTime time.Time `json:"start_time,omitempty"`
|
||||||
DurationMS int64 `json:"duration_ms,omitempty"`
|
EndTime time.Time `json:"end_time,omitempty"`
|
||||||
|
DurationMS int64 `json:"duration_ms,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtifactRef represents a reference to an input artifact.
|
// ArtifactRef represents a reference to an input artifact.
|
||||||
@@ -159,31 +160,54 @@ type PromptMessageTemplate struct {
|
|||||||
|
|
||||||
// ExecutionProfile describes how and where to execute a model.
|
// ExecutionProfile describes how and where to execute a model.
|
||||||
type ExecutionProfile struct {
|
type ExecutionProfile struct {
|
||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
Endpoint string `yaml:"endpoint"`
|
Endpoint string `yaml:"endpoint"`
|
||||||
Model string `yaml:"model"`
|
Model string `yaml:"model"`
|
||||||
Temperature float64 `yaml:"temperature"`
|
Temperature float64 `yaml:"temperature"`
|
||||||
MaxTokens int `yaml:"max_tokens"`
|
MaxTokens int `yaml:"max_tokens"`
|
||||||
TopP float64 `yaml:"top_p"`
|
TopP float64 `yaml:"top_p"`
|
||||||
TimeoutSeconds int `yaml:"timeout_seconds"`
|
TimeoutSeconds int `yaml:"timeout_seconds"`
|
||||||
ServiceTier string `yaml:"service_tier"`
|
ServiceTier string `yaml:"service_tier"`
|
||||||
ReasoningEffort string `yaml:"reasoning_effort"`
|
ReasoningEffort string `yaml:"reasoning_effort"`
|
||||||
APIKeyEnv string `yaml:"api_key_env"`
|
APIKeyEnv string `yaml:"api_key_env"`
|
||||||
ExtraParams map[string]string `yaml:"extra_params"`
|
ExtraParams map[string]any `yaml:"extra_params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutionTargetOverride represents per-request runtime setting overrides.
|
||||||
|
type ExecutionTargetOverride struct {
|
||||||
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
|
||||||
|
ServiceTier string `json:"service_tier,omitempty"`
|
||||||
|
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||||
|
APIKeyEnv string `json:"api_key_env,omitempty"`
|
||||||
|
ExtraParams map[string]any `json:"extra_params,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutionTargetPresence tracks which effective runtime fields came from an
|
||||||
|
// explicit request override even when the resolved value is a zero value.
|
||||||
|
type ExecutionTargetPresence struct {
|
||||||
|
Temperature bool
|
||||||
|
MaxTokens bool
|
||||||
|
TopP bool
|
||||||
|
TimeoutSeconds bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecutionTarget represents effective model runtime settings for a run.
|
// ExecutionTarget represents effective model runtime settings for a run.
|
||||||
type ExecutionTarget struct {
|
type ExecutionTarget struct {
|
||||||
Endpoint string `yaml:"endpoint" json:"endpoint"`
|
Endpoint string `yaml:"endpoint" json:"endpoint"`
|
||||||
Model string `yaml:"model" json:"model"`
|
Model string `yaml:"model" json:"model"`
|
||||||
Temperature float64 `yaml:"temperature" json:"temperature"`
|
Temperature float64 `yaml:"temperature" json:"temperature"`
|
||||||
MaxTokens int `yaml:"max_tokens" json:"max_tokens"`
|
MaxTokens int `yaml:"max_tokens" json:"max_tokens"`
|
||||||
TopP float64 `yaml:"top_p" json:"top_p"`
|
TopP float64 `yaml:"top_p" json:"top_p"`
|
||||||
TimeoutSeconds int `yaml:"timeout_seconds" json:"timeout_seconds"`
|
TimeoutSeconds int `yaml:"timeout_seconds" json:"timeout_seconds"`
|
||||||
ServiceTier string `yaml:"service_tier" json:"service_tier"`
|
ServiceTier string `yaml:"service_tier" json:"service_tier"`
|
||||||
ReasoningEffort string `yaml:"reasoning_effort" json:"reasoning_effort"`
|
ReasoningEffort string `yaml:"reasoning_effort" json:"reasoning_effort"`
|
||||||
APIKeyEnv string `yaml:"api_key_env" json:"api_key_env"`
|
APIKeyEnv string `yaml:"api_key_env" json:"api_key_env"`
|
||||||
ExtraParams map[string]string `yaml:"extra_params" json:"extra_params"`
|
ExtraParams map[string]any `yaml:"extra_params" json:"extra_params"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OutputContract defines the requirements for the output artifact.
|
// OutputContract defines the requirements for the output artifact.
|
||||||
@@ -211,6 +235,7 @@ type RenderedMessage struct {
|
|||||||
type GenerateRequest struct {
|
type GenerateRequest struct {
|
||||||
Prompt RenderedPrompt
|
Prompt RenderedPrompt
|
||||||
Target ExecutionTarget
|
Target ExecutionTarget
|
||||||
|
TargetPresence ExecutionTargetPresence
|
||||||
StructuredOutput *StructuredOutputSpec
|
StructuredOutput *StructuredOutputSpec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,11 @@ func (textPreparedRunFormatter) Format(prepared *domain.PreparedRun) ([]byte, er
|
|||||||
}
|
}
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
fmt.Fprintf(&b, " %s: %s\n", k, target.ExtraParams[k])
|
renderedValue, err := formatExtraParamTextValue(target.ExtraParams[k])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to format extra_params.%s: %w", k, err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " %s: %s\n", k, renderedValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,3 +179,15 @@ func (textPreparedRunFormatter) Format(prepared *domain.PreparedRun) ([]byte, er
|
|||||||
|
|
||||||
return b.Bytes(), nil
|
return b.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatExtraParamTextValue(value any) (string, error) {
|
||||||
|
if s, ok := value.(string); ok {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,6 +49,36 @@ func TestTextFormatterIncludesPreparedRunDetails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTextFormatterRendersExtraParamsDeterministically(t *testing.T) {
|
||||||
|
prepared := samplePreparedRun()
|
||||||
|
prepared.EffectiveModelParams.ExtraParams = map[string]any{
|
||||||
|
"z_string": "enabled",
|
||||||
|
"b_number": 42,
|
||||||
|
"a_object": map[string]any{
|
||||||
|
"nested": "value",
|
||||||
|
"count": 2,
|
||||||
|
},
|
||||||
|
"c_array": []any{"first", 3, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := FormatPreparedRun(prepared, PreparedRunFormatText)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
s := string(out)
|
||||||
|
|
||||||
|
want := strings.Join([]string{
|
||||||
|
" extra_params:",
|
||||||
|
" a_object: {\"count\":2,\"nested\":\"value\"}",
|
||||||
|
" b_number: 42",
|
||||||
|
" c_array: [\"first\",3,false]",
|
||||||
|
" z_string: enabled",
|
||||||
|
}, "\n")
|
||||||
|
if !strings.Contains(s, want) {
|
||||||
|
t.Fatalf("expected deterministic extra_params block %q, got:\n%s", want, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTextFormatterDoesNotIncludeResolvedAPIKeyValue(t *testing.T) {
|
func TestTextFormatterDoesNotIncludeResolvedAPIKeyValue(t *testing.T) {
|
||||||
const secret = "super-secret-api-key"
|
const secret = "super-secret-api-key"
|
||||||
t.Setenv("SCRIPTORIUM_API_KEY", secret)
|
t.Setenv("SCRIPTORIUM_API_KEY", secret)
|
||||||
@@ -130,6 +160,12 @@ func TestTextFormatterOmitsEmptyCacheControlTTL(t *testing.T) {
|
|||||||
func TestJSONFormatterEmitsValidJSONAndIncludesPreparedRunFields(t *testing.T) {
|
func TestJSONFormatterEmitsValidJSONAndIncludesPreparedRunFields(t *testing.T) {
|
||||||
prepared := samplePreparedRun()
|
prepared := samplePreparedRun()
|
||||||
prepared.SessionID = "session-123"
|
prepared.SessionID = "session-123"
|
||||||
|
prepared.EffectiveModelParams.ExtraParams = map[string]any{
|
||||||
|
"number": 42,
|
||||||
|
"nested": map[string]any{
|
||||||
|
"enabled": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
out, err := FormatPreparedRun(prepared, PreparedRunFormatJSON)
|
out, err := FormatPreparedRun(prepared, PreparedRunFormatJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -156,9 +192,21 @@ func TestJSONFormatterEmitsValidJSONAndIncludesPreparedRunFields(t *testing.T) {
|
|||||||
if decoded["session_id"] != "session-123" {
|
if decoded["session_id"] != "session-123" {
|
||||||
t.Fatalf("expected session_id in json output, got %#v", decoded["session_id"])
|
t.Fatalf("expected session_id in json output, got %#v", decoded["session_id"])
|
||||||
}
|
}
|
||||||
if _, ok := decoded["effective_model_params"]; !ok {
|
modelParams, ok := decoded["effective_model_params"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
t.Fatalf("expected effective_model_params in json output, got %#v", decoded)
|
t.Fatalf("expected effective_model_params in json output, got %#v", decoded)
|
||||||
}
|
}
|
||||||
|
extraParams, ok := modelParams["extra_params"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected extra_params in json output, got %#v", modelParams["extra_params"])
|
||||||
|
}
|
||||||
|
if extraParams["number"] != float64(42) {
|
||||||
|
t.Fatalf("unexpected numeric extra param in json output: %#v", extraParams["number"])
|
||||||
|
}
|
||||||
|
nested, ok := extraParams["nested"].(map[string]any)
|
||||||
|
if !ok || nested["enabled"] != true {
|
||||||
|
t.Fatalf("unexpected nested extra param in json output: %#v", extraParams["nested"])
|
||||||
|
}
|
||||||
if _, ok := decoded["input_hashes"]; !ok {
|
if _, ok := decoded["input_hashes"]; !ok {
|
||||||
t.Fatalf("expected input_hashes in json output, got %#v", decoded)
|
t.Fatalf("expected input_hashes in json output, got %#v", decoded)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,12 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera
|
|||||||
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, err := json.Marshal(wireReq)
|
wirePayload, err := openAIChatRequestPayload(wireReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(wirePayload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: failed to encode request: %v", ErrRequestFailed, err)
|
return nil, fmt.Errorf("%w: failed to encode request: %v", ErrRequestFailed, err)
|
||||||
}
|
}
|
||||||
@@ -111,6 +116,8 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera
|
|||||||
effectiveTimeout := c.timeout
|
effectiveTimeout := c.timeout
|
||||||
if req.Target.TimeoutSeconds > 0 {
|
if req.Target.TimeoutSeconds > 0 {
|
||||||
effectiveTimeout = time.Duration(req.Target.TimeoutSeconds) * time.Second
|
effectiveTimeout = time.Duration(req.Target.TimeoutSeconds) * time.Second
|
||||||
|
} else if req.TargetPresence.TimeoutSeconds {
|
||||||
|
effectiveTimeout = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
httpClient := c.httpClient
|
httpClient := c.httpClient
|
||||||
@@ -182,18 +189,24 @@ func openAIChatRequestFromGenerateRequest(req domain.GenerateRequest, defaultMod
|
|||||||
wireReq.Messages = append(wireReq.Messages, openAIChatRequestMessageFromRenderedMessage(msg))
|
wireReq.Messages = append(wireReq.Messages, openAIChatRequestMessageFromRenderedMessage(msg))
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Target.Temperature != 0 {
|
if req.Target.Temperature != 0 || req.TargetPresence.Temperature {
|
||||||
wireReq.Temperature = &req.Target.Temperature
|
wireReq.Temperature = &req.Target.Temperature
|
||||||
}
|
}
|
||||||
if req.Target.MaxTokens != 0 {
|
if req.Target.MaxTokens != 0 || req.TargetPresence.MaxTokens {
|
||||||
wireReq.MaxTokens = &req.Target.MaxTokens
|
wireReq.MaxTokens = &req.Target.MaxTokens
|
||||||
}
|
}
|
||||||
if req.Target.TopP != 0 {
|
if req.Target.TopP != 0 || req.TargetPresence.TopP {
|
||||||
wireReq.TopP = &req.Target.TopP
|
wireReq.TopP = &req.Target.TopP
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.Target.ServiceTier) != "" {
|
if strings.TrimSpace(req.Target.ServiceTier) != "" {
|
||||||
wireReq.ServiceTier = req.Target.ServiceTier
|
wireReq.ServiceTier = req.Target.ServiceTier
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(req.Target.ReasoningEffort) != "" {
|
||||||
|
wireReq.ReasoningEffort = req.Target.ReasoningEffort
|
||||||
|
}
|
||||||
|
if len(req.Target.ExtraParams) > 0 {
|
||||||
|
wireReq.ExtraParams = req.Target.ExtraParams
|
||||||
|
}
|
||||||
if req.StructuredOutput != nil {
|
if req.StructuredOutput != nil {
|
||||||
responseFormat, err := toOpenAIResponseFormat(req.StructuredOutput)
|
responseFormat, err := toOpenAIResponseFormat(req.StructuredOutput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -206,14 +219,71 @@ func openAIChatRequestFromGenerateRequest(req domain.GenerateRequest, defaultMod
|
|||||||
}
|
}
|
||||||
|
|
||||||
type openAIChatRequest struct {
|
type openAIChatRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
SessionID string `json:"session_id,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
Messages []openAIChatRequestMessage `json:"messages"`
|
Messages []openAIChatRequestMessage `json:"messages"`
|
||||||
Temperature *float64 `json:"temperature,omitempty"`
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
MaxTokens *int `json:"max_tokens,omitempty"`
|
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||||
TopP *float64 `json:"top_p,omitempty"`
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
ServiceTier string `json:"service_tier,omitempty"`
|
ServiceTier string `json:"service_tier,omitempty"`
|
||||||
ResponseFormat *openAIResponseFormat `json:"response_format,omitempty"`
|
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||||
|
ResponseFormat *openAIResponseFormat `json:"response_format,omitempty"`
|
||||||
|
ExtraParams map[string]any `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAIChatRequestPayload(req openAIChatRequest) (map[string]any, error) {
|
||||||
|
out := map[string]any{
|
||||||
|
"model": req.Model,
|
||||||
|
"messages": req.Messages,
|
||||||
|
}
|
||||||
|
if req.SessionID != "" {
|
||||||
|
out["session_id"] = req.SessionID
|
||||||
|
}
|
||||||
|
if req.Temperature != nil {
|
||||||
|
out["temperature"] = *req.Temperature
|
||||||
|
}
|
||||||
|
if req.MaxTokens != nil {
|
||||||
|
out["max_tokens"] = *req.MaxTokens
|
||||||
|
}
|
||||||
|
if req.TopP != nil {
|
||||||
|
out["top_p"] = *req.TopP
|
||||||
|
}
|
||||||
|
if req.ServiceTier != "" {
|
||||||
|
out["service_tier"] = req.ServiceTier
|
||||||
|
}
|
||||||
|
if req.ReasoningEffort != "" {
|
||||||
|
out["reasoning_effort"] = req.ReasoningEffort
|
||||||
|
}
|
||||||
|
if req.ResponseFormat != nil {
|
||||||
|
out["response_format"] = req.ResponseFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range req.ExtraParams {
|
||||||
|
if key == "" {
|
||||||
|
return nil, errors.New("extra_params key must not be empty")
|
||||||
|
}
|
||||||
|
if _, reserved := reservedOpenAIChatRequestFields[key]; reserved {
|
||||||
|
return nil, fmt.Errorf("extra_params key %q collides with reserved request field", key)
|
||||||
|
}
|
||||||
|
if _, err := json.Marshal(value); err != nil {
|
||||||
|
return nil, fmt.Errorf("extra_params.%s must be JSON-serializable: %w", key, err)
|
||||||
|
}
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var reservedOpenAIChatRequestFields = map[string]struct{}{
|
||||||
|
"model": {},
|
||||||
|
"session_id": {},
|
||||||
|
"messages": {},
|
||||||
|
"temperature": {},
|
||||||
|
"max_tokens": {},
|
||||||
|
"top_p": {},
|
||||||
|
"service_tier": {},
|
||||||
|
"reasoning_effort": {},
|
||||||
|
"response_format": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
type openAIChatRequestMessage struct {
|
type openAIChatRequestMessage struct {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -417,7 +418,7 @@ func TestOpenAICompatibleClientOmitsResponseFormatWhenNoStructuredOutput(t *test
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenAICompatibleClientOmitsReasoningEffortAndExtraParams(t *testing.T) {
|
func TestOpenAICompatibleClientSerializesReasoningEffortAndExtraParams(t *testing.T) {
|
||||||
var observedBody map[string]any
|
var observedBody map[string]any
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
@@ -438,19 +439,257 @@ func TestOpenAICompatibleClientOmitsReasoningEffortAndExtraParams(t *testing.T)
|
|||||||
Target: domain.ExecutionTarget{
|
Target: domain.ExecutionTarget{
|
||||||
Model: "model",
|
Model: "model",
|
||||||
ReasoningEffort: "high",
|
ReasoningEffort: "high",
|
||||||
ExtraParams: map[string]string{
|
ExtraParams: map[string]any{
|
||||||
"provider_option": "on",
|
"string_value": "on",
|
||||||
|
"number_value": 42,
|
||||||
|
"boolean_value": true,
|
||||||
|
"object_value": map[string]any{"nested": "value", "count": 2},
|
||||||
|
"array_value": []any{"first", 3, false},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
|
if observedBody["reasoning_effort"] != "high" {
|
||||||
|
t.Fatalf("expected reasoning_effort high, got %#v", observedBody["reasoning_effort"])
|
||||||
|
}
|
||||||
|
if observedBody["string_value"] != "on" {
|
||||||
|
t.Fatalf("unexpected string extra param: %#v", observedBody["string_value"])
|
||||||
|
}
|
||||||
|
if observedBody["number_value"] != float64(42) {
|
||||||
|
t.Fatalf("unexpected number extra param: %#v", observedBody["number_value"])
|
||||||
|
}
|
||||||
|
if observedBody["boolean_value"] != true {
|
||||||
|
t.Fatalf("unexpected boolean extra param: %#v", observedBody["boolean_value"])
|
||||||
|
}
|
||||||
|
objectValue, ok := observedBody["object_value"].(map[string]any)
|
||||||
|
if !ok || objectValue["nested"] != "value" || objectValue["count"] != float64(2) {
|
||||||
|
t.Fatalf("unexpected object extra param: %#v", observedBody["object_value"])
|
||||||
|
}
|
||||||
|
if _, exists := observedBody["extra_params"]; exists {
|
||||||
|
t.Fatalf("expected extra_params wrapper omitted, got %#v", observedBody["extra_params"])
|
||||||
|
}
|
||||||
|
arrayValue, ok := observedBody["array_value"].([]any)
|
||||||
|
if !ok || len(arrayValue) != 3 || arrayValue[0] != "first" || arrayValue[1] != float64(3) || arrayValue[2] != false {
|
||||||
|
t.Fatalf("unexpected array extra param: %#v", observedBody["array_value"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompatibleClientOmitsReasoningEffortWhenUnset(t *testing.T) {
|
||||||
|
var observedBody map[string]any
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&observedBody); err != nil {
|
||||||
|
t.Fatalf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||||
|
Target: domain.ExecutionTarget{Model: "model"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
if _, exists := observedBody["reasoning_effort"]; exists {
|
if _, exists := observedBody["reasoning_effort"]; exists {
|
||||||
t.Fatalf("expected reasoning_effort omitted, got %#v", observedBody["reasoning_effort"])
|
t.Fatalf("expected reasoning_effort omitted, got %#v", observedBody["reasoning_effort"])
|
||||||
}
|
}
|
||||||
if _, exists := observedBody["extra_params"]; exists {
|
if _, exists := observedBody["extra_params"]; exists {
|
||||||
t.Fatalf("expected extra_params omitted, got %#v", observedBody["extra_params"])
|
t.Fatalf("expected extra_params wrapper omitted, got %#v", observedBody["extra_params"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompatibleClientSerializesExplicitZeroNumericOverrides(t *testing.T) {
|
||||||
|
var observedBody map[string]any
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&observedBody); err != nil {
|
||||||
|
t.Fatalf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||||
|
Target: domain.ExecutionTarget{Model: "model"},
|
||||||
|
TargetPresence: domain.ExecutionTargetPresence{
|
||||||
|
Temperature: true,
|
||||||
|
MaxTokens: true,
|
||||||
|
TopP: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if observedBody["temperature"] != float64(0) {
|
||||||
|
t.Fatalf("expected explicit zero temperature, got %#v", observedBody["temperature"])
|
||||||
|
}
|
||||||
|
if observedBody["max_tokens"] != float64(0) {
|
||||||
|
t.Fatalf("expected explicit zero max_tokens, got %#v", observedBody["max_tokens"])
|
||||||
|
}
|
||||||
|
if observedBody["top_p"] != float64(0) {
|
||||||
|
t.Fatalf("expected explicit zero top_p, got %#v", observedBody["top_p"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompatibleClientOmitsImplicitZeroNumericFields(t *testing.T) {
|
||||||
|
var observedBody map[string]any
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&observedBody); err != nil {
|
||||||
|
t.Fatalf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||||
|
Target: domain.ExecutionTarget{Model: "model"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
for _, field := range []string{"temperature", "max_tokens", "top_p"} {
|
||||||
|
if _, exists := observedBody[field]; exists {
|
||||||
|
t.Fatalf("expected implicit zero field %q to be omitted, got body %#v", field, observedBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompatibleClientExplicitZeroTimeoutDisablesClientTimeout(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||||
|
BaseURL: ts.URL + "/v1",
|
||||||
|
Timeout: time.Nanosecond,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||||
|
Target: domain.ExecutionTarget{Model: "model", TimeoutSeconds: 0},
|
||||||
|
TargetPresence: domain.ExecutionTargetPresence{TimeoutSeconds: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected explicit zero timeout to disable client timeout, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompatibleClientOmittedTimeoutUsesClientTimeout(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||||
|
BaseURL: ts.URL + "/v1",
|
||||||
|
Timeout: time.Nanosecond,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||||
|
Target: domain.ExecutionTarget{Model: "model", TimeoutSeconds: 0},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected omitted timeout to use client timeout")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrRequestFailed) {
|
||||||
|
t.Fatalf("expected ErrRequestFailed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompatibleClientRejectsInvalidExtraParamsBeforeProviderCall(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
extraParams map[string]any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "empty key", extraParams: map[string]any{"": "empty"}, want: "key must not be empty"},
|
||||||
|
{name: "unserializable value", extraParams: map[string]any{"bad": math.Inf(1)}, want: "JSON-serializable"},
|
||||||
|
}
|
||||||
|
for _, key := range []string{
|
||||||
|
"model",
|
||||||
|
"session_id",
|
||||||
|
"messages",
|
||||||
|
"temperature",
|
||||||
|
"max_tokens",
|
||||||
|
"top_p",
|
||||||
|
"service_tier",
|
||||||
|
"reasoning_effort",
|
||||||
|
"response_format",
|
||||||
|
} {
|
||||||
|
tests = append(tests, struct {
|
||||||
|
name string
|
||||||
|
extraParams map[string]any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
name: "reserved key " + key,
|
||||||
|
extraParams: map[string]any{key: "collision"},
|
||||||
|
want: "reserved request field",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||||
|
Target: domain.ExecutionTarget{Model: "model", ExtraParams: tc.extraParams},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected invalid request error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrInvalidRequest) {
|
||||||
|
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Fatalf("expected error to contain %q, got %v", tc.want, err)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("provider should not be called for invalid extra_params")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package profile
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -85,6 +86,63 @@ temperature: 0.1
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("valid profile with JSON-compatible extra params", func(t *testing.T) {
|
||||||
|
writeProfileTestFile(t, filepath.Join(tmpDir, "json-extra-params.yaml"), `
|
||||||
|
id: json-extra-params
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: nested-model
|
||||||
|
extra_params:
|
||||||
|
string_value: enabled
|
||||||
|
number_value: 42
|
||||||
|
boolean_value: true
|
||||||
|
object_value:
|
||||||
|
nested: value
|
||||||
|
count: 2
|
||||||
|
array_value:
|
||||||
|
- first
|
||||||
|
- 3
|
||||||
|
- false
|
||||||
|
`)
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(ctx, "json-extra-params")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got map[string]any
|
||||||
|
encoded, err := json.Marshal(p.ExtraParams)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected extra_params to marshal as JSON, got %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(encoded, &got); err != nil {
|
||||||
|
t.Fatalf("expected extra_params JSON to decode, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got["string_value"] != "enabled" {
|
||||||
|
t.Fatalf("unexpected string extra param: %#v", got["string_value"])
|
||||||
|
}
|
||||||
|
if got["number_value"] != float64(42) {
|
||||||
|
t.Fatalf("unexpected number extra param: %#v", got["number_value"])
|
||||||
|
}
|
||||||
|
if got["boolean_value"] != true {
|
||||||
|
t.Fatalf("unexpected boolean extra param: %#v", got["boolean_value"])
|
||||||
|
}
|
||||||
|
objectValue, ok := got["object_value"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected object extra param, got %#v", got["object_value"])
|
||||||
|
}
|
||||||
|
if objectValue["nested"] != "value" || objectValue["count"] != float64(2) {
|
||||||
|
t.Fatalf("unexpected object extra param: %#v", objectValue)
|
||||||
|
}
|
||||||
|
arrayValue, ok := got["array_value"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected array extra param, got %#v", got["array_value"])
|
||||||
|
}
|
||||||
|
if len(arrayValue) != 3 || arrayValue[0] != "first" || arrayValue[1] != float64(3) || arrayValue[2] != false {
|
||||||
|
t.Fatalf("unexpected array extra param: %#v", arrayValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("duplicate profile IDs fail as ambiguous", func(t *testing.T) {
|
t.Run("duplicate profile IDs fail as ambiguous", func(t *testing.T) {
|
||||||
writeProfileTestFile(t, filepath.Join(tmpDir, "duplicate-profile-a.yaml"), `
|
writeProfileTestFile(t, filepath.Join(tmpDir, "duplicate-profile-a.yaml"), `
|
||||||
id: duplicate-profile
|
id: duplicate-profile
|
||||||
|
|||||||
@@ -92,9 +92,13 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes
|
|||||||
genResp, err := r.llm.Generate(ctx, domain.GenerateRequest{
|
genResp, err := r.llm.Generate(ctx, domain.GenerateRequest{
|
||||||
Prompt: domain.RenderedPrompt{SessionID: prepared.SessionID, Messages: prepared.Messages},
|
Prompt: domain.RenderedPrompt{SessionID: prepared.SessionID, Messages: prepared.Messages},
|
||||||
Target: prepared.EffectiveModelParams,
|
Target: prepared.EffectiveModelParams,
|
||||||
|
TargetPresence: prepared.TargetPresence,
|
||||||
StructuredOutput: prepared.StructuredOutput,
|
StructuredOutput: prepared.StructuredOutput,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, llm.ErrInvalidRequest) {
|
||||||
|
return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err)
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("%w: %w", ErrLLMGenerate, err)
|
return nil, fmt.Errorf("%w: %w", ErrLLMGenerate, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +191,10 @@ func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.Pr
|
|||||||
return nil, fmt.Errorf("%w: %w", ErrProfileLoad, err)
|
return nil, fmt.Errorf("%w: %w", ErrProfileLoad, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
effectiveModel := resolveExecutionTarget(execProfile, req.Execution)
|
effectiveModel, targetPresence, err := resolveExecutionTarget(execProfile, req.Execution)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err)
|
||||||
|
}
|
||||||
if strings.TrimSpace(effectiveModel.Endpoint) == "" {
|
if strings.TrimSpace(effectiveModel.Endpoint) == "" {
|
||||||
return nil, fmt.Errorf("%w: execution endpoint is required", ErrInvalidRequest)
|
return nil, fmt.Errorf("%w: execution endpoint is required", ErrInvalidRequest)
|
||||||
}
|
}
|
||||||
@@ -230,6 +237,7 @@ func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.Pr
|
|||||||
PromptHash: promptDefinitionHash,
|
PromptHash: promptDefinitionHash,
|
||||||
SelectedProfileID: selectedProfileID,
|
SelectedProfileID: selectedProfileID,
|
||||||
EffectiveModelParams: effectiveModel,
|
EffectiveModelParams: effectiveModel,
|
||||||
|
TargetPresence: targetPresence,
|
||||||
OutputContract: effectiveContract,
|
OutputContract: effectiveContract,
|
||||||
StructuredOutput: structuredOutput,
|
StructuredOutput: structuredOutput,
|
||||||
InputHashes: inputHashes,
|
InputHashes: inputHashes,
|
||||||
@@ -355,22 +363,75 @@ func mergeExecutionTarget(base domain.ExecutionTarget, override domain.Execution
|
|||||||
out.APIKeyEnv = override.APIKeyEnv
|
out.APIKeyEnv = override.APIKeyEnv
|
||||||
}
|
}
|
||||||
if len(override.ExtraParams) > 0 {
|
if len(override.ExtraParams) > 0 {
|
||||||
cp := make(map[string]string, len(override.ExtraParams))
|
out.ExtraParams = copyExtraParams(override.ExtraParams)
|
||||||
for k, v := range override.ExtraParams {
|
|
||||||
cp[k] = v
|
|
||||||
}
|
|
||||||
out.ExtraParams = cp
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveExecutionTarget(profileValue *domain.ExecutionProfile, override *domain.ExecutionTarget) domain.ExecutionTarget {
|
func mergeExecutionTargetOverride(base domain.ExecutionTarget, override domain.ExecutionTargetOverride) (domain.ExecutionTarget, domain.ExecutionTargetPresence, error) {
|
||||||
|
out := base
|
||||||
|
var presence domain.ExecutionTargetPresence
|
||||||
|
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{}, domain.ExecutionTargetPresence{}, errors.New("temperature must be between 0 and 2")
|
||||||
|
}
|
||||||
|
out.Temperature = *override.Temperature
|
||||||
|
presence.Temperature = true
|
||||||
|
}
|
||||||
|
if override.MaxTokens != nil {
|
||||||
|
if *override.MaxTokens < 0 {
|
||||||
|
return domain.ExecutionTarget{}, domain.ExecutionTargetPresence{}, errors.New("max_tokens must be greater than or equal to 0")
|
||||||
|
}
|
||||||
|
out.MaxTokens = *override.MaxTokens
|
||||||
|
presence.MaxTokens = true
|
||||||
|
}
|
||||||
|
if override.TopP != nil {
|
||||||
|
if *override.TopP < 0 || *override.TopP > 1 {
|
||||||
|
return domain.ExecutionTarget{}, domain.ExecutionTargetPresence{}, errors.New("top_p must be between 0 and 1")
|
||||||
|
}
|
||||||
|
out.TopP = *override.TopP
|
||||||
|
presence.TopP = true
|
||||||
|
}
|
||||||
|
if override.TimeoutSeconds != nil {
|
||||||
|
if *override.TimeoutSeconds < 0 {
|
||||||
|
return domain.ExecutionTarget{}, domain.ExecutionTargetPresence{}, errors.New("timeout_seconds must be greater than or equal to 0")
|
||||||
|
}
|
||||||
|
out.TimeoutSeconds = *override.TimeoutSeconds
|
||||||
|
presence.TimeoutSeconds = true
|
||||||
|
}
|
||||||
|
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, presence, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveExecutionTarget(profileValue *domain.ExecutionProfile, override *domain.ExecutionTargetOverride) (domain.ExecutionTarget, domain.ExecutionTargetPresence, error) {
|
||||||
out := defaults.ExecutionTargetDefault()
|
out := defaults.ExecutionTargetDefault()
|
||||||
out = mergeExecutionTarget(out, executionProfileToTarget(profileValue))
|
out = mergeExecutionTarget(out, executionProfileToTarget(profileValue))
|
||||||
|
var presence domain.ExecutionTargetPresence
|
||||||
if override != nil {
|
if override != nil {
|
||||||
out = mergeExecutionTarget(out, *override)
|
var err error
|
||||||
|
out, presence, err = mergeExecutionTargetOverride(out, *override)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ExecutionTarget{}, domain.ExecutionTargetPresence{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out, presence, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateAPIKeyEnv(apiKeyEnv string) error {
|
func validateAPIKeyEnv(apiKeyEnv string) error {
|
||||||
@@ -388,13 +449,6 @@ func executionProfileToTarget(p *domain.ExecutionProfile) domain.ExecutionTarget
|
|||||||
if p == nil {
|
if p == nil {
|
||||||
return domain.ExecutionTarget{}
|
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{
|
return domain.ExecutionTarget{
|
||||||
Endpoint: p.Endpoint,
|
Endpoint: p.Endpoint,
|
||||||
Model: p.Model,
|
Model: p.Model,
|
||||||
@@ -405,10 +459,21 @@ func executionProfileToTarget(p *domain.ExecutionProfile) domain.ExecutionTarget
|
|||||||
ServiceTier: p.ServiceTier,
|
ServiceTier: p.ServiceTier,
|
||||||
ReasoningEffort: p.ReasoningEffort,
|
ReasoningEffort: p.ReasoningEffort,
|
||||||
APIKeyEnv: p.APIKeyEnv,
|
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 {
|
func resolveOutputContract(def *domain.PromptDefinition, override *domain.OutputContract) domain.OutputContract {
|
||||||
contract := def.Validation
|
contract := def.Validation
|
||||||
if contract.Format == "" {
|
if contract.Format == "" {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/scriptorium/internal/llm"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/prompt"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/prompt"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
|
||||||
@@ -172,7 +173,7 @@ func TestRunnerPrepareWithExplicitProfileSelection(t *testing.T) {
|
|||||||
"transcript": {Type: domain.ArtifactRefFile, URI: "a://t"},
|
"transcript": {Type: domain.ArtifactRefFile, URI: "a://t"},
|
||||||
"glossary": {Type: domain.ArtifactRefFile, URI: "a://g"},
|
"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 {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
@@ -272,11 +273,11 @@ func TestRunnerPrepareRuntimeOverrideBeatsSelectedProfileValue(t *testing.T) {
|
|||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
ProfileID: "exec",
|
ProfileID: "exec",
|
||||||
Inputs: singleInputRef(),
|
Inputs: singleInputRef(),
|
||||||
Execution: &domain.ExecutionTarget{
|
Execution: &domain.ExecutionTargetOverride{
|
||||||
Endpoint: "http://override/v1",
|
Endpoint: "http://override/v1",
|
||||||
Model: "override-model",
|
Model: "override-model",
|
||||||
Temperature: 0.7,
|
Temperature: float64Ptr(0.7),
|
||||||
TimeoutSeconds: 30,
|
TimeoutSeconds: intPtr(30),
|
||||||
ServiceTier: "flex",
|
ServiceTier: "flex",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -294,6 +295,143 @@ 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
|
||||||
|
wantPresence domain.ExecutionTargetPresence
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
wantPresence: domain.ExecutionTargetPresence{Temperature: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit zero max tokens",
|
||||||
|
override: &domain.ExecutionTargetOverride{MaxTokens: intPtr(0)},
|
||||||
|
wantTemperature: 0.7,
|
||||||
|
wantMaxTokens: 0,
|
||||||
|
wantTopP: 0.8,
|
||||||
|
wantTimeoutSecs: 45,
|
||||||
|
wantPresence: domain.ExecutionTargetPresence{MaxTokens: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit zero top p",
|
||||||
|
override: &domain.ExecutionTargetOverride{TopP: float64Ptr(0)},
|
||||||
|
wantTemperature: 0.7,
|
||||||
|
wantMaxTokens: 321,
|
||||||
|
wantTopP: 0,
|
||||||
|
wantTimeoutSecs: 45,
|
||||||
|
wantPresence: domain.ExecutionTargetPresence{TopP: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit zero timeout",
|
||||||
|
override: &domain.ExecutionTargetOverride{TimeoutSeconds: intPtr(0)},
|
||||||
|
wantTemperature: 0.7,
|
||||||
|
wantMaxTokens: 321,
|
||||||
|
wantTopP: 0.8,
|
||||||
|
wantTimeoutSecs: 0,
|
||||||
|
wantPresence: domain.ExecutionTargetPresence{TimeoutSeconds: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if prepared.TargetPresence != tc.wantPresence {
|
||||||
|
t.Fatalf("unexpected target presence: got %+v want %+v", prepared.TargetPresence, tc.wantPresence)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestRunnerPrepareSelectedProfileBeatsBuiltInDefault(t *testing.T) {
|
||||||
promptRepo := &fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)}
|
promptRepo := &fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)}
|
||||||
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||||
@@ -693,7 +831,7 @@ func TestRunnerRunSuccessful(t *testing.T) {
|
|||||||
"transcript": {Type: domain.ArtifactRefFile, URI: "a://t"},
|
"transcript": {Type: domain.ArtifactRefFile, URI: "a://t"},
|
||||||
"glossary": {Type: domain.ArtifactRefFile, URI: "a://g"},
|
"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 {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
@@ -728,11 +866,50 @@ func TestRunnerRunSuccessful(t *testing.T) {
|
|||||||
if llmClient.lastReq.Target.TimeoutSeconds != 90 {
|
if llmClient.lastReq.Target.TimeoutSeconds != 90 {
|
||||||
t.Fatalf("expected timeout propagation, got %d", llmClient.lastReq.Target.TimeoutSeconds)
|
t.Fatalf("expected timeout propagation, got %d", llmClient.lastReq.Target.TimeoutSeconds)
|
||||||
}
|
}
|
||||||
|
if !llmClient.lastReq.TargetPresence.Temperature || !llmClient.lastReq.TargetPresence.TimeoutSeconds {
|
||||||
|
t.Fatalf("expected numeric override presence to be sent to llm, got %+v", llmClient.lastReq.TargetPresence)
|
||||||
|
}
|
||||||
if llmClient.lastReq.Prompt.SessionID != "session-123" {
|
if llmClient.lastReq.Prompt.SessionID != "session-123" {
|
||||||
t.Fatalf("expected session id to be sent to llm, got %q", llmClient.lastReq.Prompt.SessionID)
|
t.Fatalf("expected session id to be sent to llm, got %q", llmClient.lastReq.Prompt.SessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunnerRunPassesExtraParamsToGenerateRequestTarget(t *testing.T) {
|
||||||
|
extraParams := map[string]any{
|
||||||
|
"string_value": "enabled",
|
||||||
|
"number_value": 42,
|
||||||
|
"boolean_value": true,
|
||||||
|
"object_value": map[string]any{"nested": "value"},
|
||||||
|
"array_value": []any{"first", 3, false},
|
||||||
|
}
|
||||||
|
promptRepo := &fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)}
|
||||||
|
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||||
|
"exec": {
|
||||||
|
ID: "exec",
|
||||||
|
Endpoint: "http://profile/v1",
|
||||||
|
Model: "profile-model",
|
||||||
|
ExtraParams: extraParams,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}
|
||||||
|
runner := NewRunner(promptRepo, execRepo, defaultArtifactReader(), defaultRenderer(), llmClient, nil)
|
||||||
|
|
||||||
|
res, 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 !reflect.DeepEqual(res.EffectiveModelParams.ExtraParams, extraParams) {
|
||||||
|
t.Fatalf("expected run result extra_params to match profile values, got %#v", res.EffectiveModelParams.ExtraParams)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(llmClient.lastReq.Target.ExtraParams, extraParams) {
|
||||||
|
t.Fatalf("expected generate request extra_params to match profile values, got %#v", llmClient.lastReq.Target.ExtraParams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunnerRunAndPrepareResolveSameProfileAndEffectiveSettings(t *testing.T) {
|
func TestRunnerRunAndPrepareResolveSameProfileAndEffectiveSettings(t *testing.T) {
|
||||||
promptRepo := &fakePromptRepo{def: promptDef(domain.FormatMarkdown, domain.ValidationBasic, 0)}
|
promptRepo := &fakePromptRepo{def: promptDef(domain.FormatMarkdown, domain.ValidationBasic, 0)}
|
||||||
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}
|
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}
|
||||||
@@ -749,7 +926,7 @@ func TestRunnerRunAndPrepareResolveSameProfileAndEffectiveSettings(t *testing.T)
|
|||||||
Inputs: map[string]domain.ArtifactRef{
|
Inputs: map[string]domain.ArtifactRef{
|
||||||
"transcript": {Type: domain.ArtifactRefFile, URI: "a://t"},
|
"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)
|
prepared, err := runner.Prepare(context.Background(), req)
|
||||||
@@ -877,11 +1054,11 @@ func TestRunnerRunExplicitRuntimeOverrideBeatsSelectedProfileValue(t *testing.T)
|
|||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
ProfileID: "exec",
|
ProfileID: "exec",
|
||||||
Inputs: singleInputRef(),
|
Inputs: singleInputRef(),
|
||||||
Execution: &domain.ExecutionTarget{
|
Execution: &domain.ExecutionTargetOverride{
|
||||||
Endpoint: "http://override/v1",
|
Endpoint: "http://override/v1",
|
||||||
Model: "override-model",
|
Model: "override-model",
|
||||||
Temperature: 0.7,
|
Temperature: float64Ptr(0.7),
|
||||||
TimeoutSeconds: 30,
|
TimeoutSeconds: intPtr(30),
|
||||||
ServiceTier: "flex",
|
ServiceTier: "flex",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1016,7 +1193,7 @@ func TestRunnerRunRuntimeAPIKeyEnvOverrideWorks(t *testing.T) {
|
|||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
ProfileID: "exec",
|
ProfileID: "exec",
|
||||||
Inputs: singleInputRef(),
|
Inputs: singleInputRef(),
|
||||||
Execution: &domain.ExecutionTarget{APIKeyEnv: envName},
|
Execution: &domain.ExecutionTargetOverride{APIKeyEnv: envName},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
@@ -1041,7 +1218,7 @@ func TestRunnerRunRuntimeAPIKeyEnvOverrideBeatsProfile(t *testing.T) {
|
|||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
ProfileID: "exec",
|
ProfileID: "exec",
|
||||||
Inputs: singleInputRef(),
|
Inputs: singleInputRef(),
|
||||||
Execution: &domain.ExecutionTarget{APIKeyEnv: runtimeEnv},
|
Execution: &domain.ExecutionTargetOverride{APIKeyEnv: runtimeEnv},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
@@ -1140,6 +1317,28 @@ func TestRunnerRunLLMFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunnerRunLLMInvalidRequestMapsToUsecaseInvalidRequest(t *testing.T) {
|
||||||
|
runner := NewRunner(
|
||||||
|
&fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)},
|
||||||
|
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}},
|
||||||
|
defaultArtifactReader(),
|
||||||
|
defaultRenderer(),
|
||||||
|
&fakeLLM{err: llm.ErrInvalidRequest},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
|
PromptID: "p",
|
||||||
|
ProfileID: "exec",
|
||||||
|
Inputs: singleInputRef(),
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrInvalidRequest) {
|
||||||
|
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrLLMGenerate) {
|
||||||
|
t.Fatalf("did not expect ErrLLMGenerate, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunnerRunValidationStillWorks(t *testing.T) {
|
func TestRunnerRunValidationStillWorks(t *testing.T) {
|
||||||
validator := &fakeValidator{result: domain.ValidationResult{Status: domain.ValidationFailed, Mode: domain.ValidationBasic, Errors: []string{"bad"}, IsValid: false}}
|
validator := &fakeValidator{result: domain.ValidationResult{Status: domain.ValidationFailed, Mode: domain.ValidationBasic, Errors: []string{"bad"}, IsValid: false}}
|
||||||
runner := NewRunner(
|
runner := NewRunner(
|
||||||
@@ -1182,7 +1381,7 @@ func TestRunnerRunStructuredRepairRemainsBoundedAndUsesEffectiveModelSettings(t
|
|||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
ProfileID: "exec",
|
ProfileID: "exec",
|
||||||
Inputs: singleInputRef(),
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
@@ -1269,7 +1468,7 @@ func TestExecutionProfileToTargetPopulatesAllFieldsAndCopiesExtraParams(t *testi
|
|||||||
ServiceTier: "priority",
|
ServiceTier: "priority",
|
||||||
ReasoningEffort: "medium",
|
ReasoningEffort: "medium",
|
||||||
APIKeyEnv: "SCRIPTORIUM_API_KEY",
|
APIKeyEnv: "SCRIPTORIUM_API_KEY",
|
||||||
ExtraParams: map[string]string{
|
ExtraParams: map[string]any{
|
||||||
"provider_option": "on",
|
"provider_option": "on",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1308,12 +1507,18 @@ func TestResolveExecutionTargetProfileValuesPopulateAllSupportedFields(t *testin
|
|||||||
ServiceTier: "priority",
|
ServiceTier: "priority",
|
||||||
ReasoningEffort: "low",
|
ReasoningEffort: "low",
|
||||||
APIKeyEnv: "PROFILE_KEY",
|
APIKeyEnv: "PROFILE_KEY",
|
||||||
ExtraParams: map[string]string{
|
ExtraParams: map[string]any{
|
||||||
"profile_option": "enabled",
|
"profile_option": "enabled",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
target := resolveExecutionTarget(profileValue, nil)
|
target, presence, err := resolveExecutionTarget(profileValue, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if presence != (domain.ExecutionTargetPresence{}) {
|
||||||
|
t.Fatalf("expected no request override presence, got %+v", presence)
|
||||||
|
}
|
||||||
if target.Endpoint != profileValue.Endpoint ||
|
if target.Endpoint != profileValue.Endpoint ||
|
||||||
target.Model != profileValue.Model ||
|
target.Model != profileValue.Model ||
|
||||||
target.Temperature != profileValue.Temperature ||
|
target.Temperature != profileValue.Temperature ||
|
||||||
@@ -1342,32 +1547,38 @@ func TestResolveExecutionTargetRuntimeOverridesBeatProfileForAllOverrideableFiel
|
|||||||
ServiceTier: "priority",
|
ServiceTier: "priority",
|
||||||
ReasoningEffort: "medium",
|
ReasoningEffort: "medium",
|
||||||
APIKeyEnv: "PROFILE_KEY",
|
APIKeyEnv: "PROFILE_KEY",
|
||||||
ExtraParams: map[string]string{
|
ExtraParams: map[string]any{
|
||||||
"profile_only": "yes",
|
"profile_only": "yes",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
override := &domain.ExecutionTarget{
|
override := &domain.ExecutionTargetOverride{
|
||||||
Endpoint: "http://override/v1",
|
Endpoint: "http://override/v1",
|
||||||
Model: "override-model",
|
Model: "override-model",
|
||||||
Temperature: 0.9,
|
Temperature: float64Ptr(0.9),
|
||||||
MaxTokens: 111,
|
MaxTokens: intPtr(111),
|
||||||
TopP: 0.5,
|
TopP: float64Ptr(0.5),
|
||||||
TimeoutSeconds: 30,
|
TimeoutSeconds: intPtr(30),
|
||||||
ServiceTier: "flex",
|
ServiceTier: "flex",
|
||||||
ReasoningEffort: "high",
|
ReasoningEffort: "high",
|
||||||
APIKeyEnv: "RUNTIME_KEY",
|
APIKeyEnv: "RUNTIME_KEY",
|
||||||
ExtraParams: map[string]string{
|
ExtraParams: map[string]any{
|
||||||
"runtime_only": "yes",
|
"runtime_only": "yes",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
target := resolveExecutionTarget(profileValue, override)
|
target, presence, err := resolveExecutionTarget(profileValue, override)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if presence != (domain.ExecutionTargetPresence{Temperature: true, MaxTokens: true, TopP: true, TimeoutSeconds: true}) {
|
||||||
|
t.Fatalf("unexpected override presence: %+v", presence)
|
||||||
|
}
|
||||||
if target.Endpoint != override.Endpoint ||
|
if target.Endpoint != override.Endpoint ||
|
||||||
target.Model != override.Model ||
|
target.Model != override.Model ||
|
||||||
target.Temperature != override.Temperature ||
|
target.Temperature != *override.Temperature ||
|
||||||
target.MaxTokens != override.MaxTokens ||
|
target.MaxTokens != *override.MaxTokens ||
|
||||||
target.TopP != override.TopP ||
|
target.TopP != *override.TopP ||
|
||||||
target.TimeoutSeconds != override.TimeoutSeconds ||
|
target.TimeoutSeconds != *override.TimeoutSeconds ||
|
||||||
target.ServiceTier != override.ServiceTier ||
|
target.ServiceTier != override.ServiceTier ||
|
||||||
target.ReasoningEffort != override.ReasoningEffort ||
|
target.ReasoningEffort != override.ReasoningEffort ||
|
||||||
target.APIKeyEnv != override.APIKeyEnv {
|
target.APIKeyEnv != override.APIKeyEnv {
|
||||||
@@ -1411,12 +1622,12 @@ func TestMergeExecutionTargetEmptyStringOverridesDoNotErase(t *testing.T) {
|
|||||||
|
|
||||||
func TestMergeExecutionTargetEmptyExtraParamsDoesNotErase(t *testing.T) {
|
func TestMergeExecutionTargetEmptyExtraParamsDoesNotErase(t *testing.T) {
|
||||||
base := domain.ExecutionTarget{
|
base := domain.ExecutionTarget{
|
||||||
ExtraParams: map[string]string{
|
ExtraParams: map[string]any{
|
||||||
"keep": "value",
|
"keep": "value",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
override := domain.ExecutionTarget{
|
override := domain.ExecutionTarget{
|
||||||
ExtraParams: map[string]string{},
|
ExtraParams: map[string]any{},
|
||||||
}
|
}
|
||||||
|
|
||||||
merged := mergeExecutionTarget(base, override)
|
merged := mergeExecutionTarget(base, override)
|
||||||
@@ -1492,6 +1703,14 @@ func singleInputRef() map[string]domain.ArtifactRef {
|
|||||||
return map[string]domain.ArtifactRef{"transcript": {Type: domain.ArtifactRefFile, URI: "a://ok"}}
|
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 {
|
func newMinimalRunner(promptRepo *fakePromptRepo, execRepo *fakeExecutionProfileRepo) *Runner {
|
||||||
return NewRunner(
|
return NewRunner(
|
||||||
promptRepo,
|
promptRepo,
|
||||||
|
|||||||
Reference in New Issue
Block a user