38 Commits

Author SHA1 Message Date
90b76ddad3 Update copyright statement
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-07-04 22:21:52 -05:00
d5b3d1e061 Remove completed documentation roadmaps 2026-07-05 03:16:11 +00:00
41083de46a Align internal documentation with architecture 2026-07-05 03:13:50 +00:00
07ac7e54c5 Expand consumer integration documentation 2026-07-05 03:09:32 +00:00
879cb021b2 Clarify HTTP operations documentation 2026-07-05 03:06:39 +00:00
574f88bd6a Refresh primary documentation references 2026-07-05 03:02:11 +00:00
d5d7a222a4 Establish canonical documentation links 2026-07-05 02:56:57 +00:00
aabd89aea7 Add a documentation update roadmap 2026-07-04 21:34:03 -05:00
9189cbfc22 Align serve usage flags 2026-07-05 00:24:17 +00:00
872c166ed7 Clarify artifact root symlink behavior 2026-07-05 00:22:54 +00:00
6742def4d3 Redact provider error bodies 2026-07-05 00:20:26 +00:00
1b39f82117 Defer profile extra params validation 2026-07-05 00:19:09 +00:00
f7d821067f Add HTTP size limits 2026-07-05 00:17:07 +00:00
a16f66cbc7 Enforce fs source containment 2026-07-05 00:10:47 +00:00
39485d87f6 Add an implementation plan to reflect follow-up findings from the audit 2026-07-04 19:04:33 -05:00
61e5b0fe58 Document cleanup verification details 2026-07-04 23:41:38 +00:00
f3c21c7d9f Remove stale domain run metadata 2026-07-04 23:39:40 +00:00
bc5f5d3731 Share validator mode handling 2026-07-04 23:38:45 +00:00
93a76f1d36 Share YAML catalog helpers 2026-07-04 23:37:07 +00:00
5c882f26a9 Restrict HTTP file artifact inputs 2026-07-04 23:34:44 +00:00
0d45ac6e3c Audit the internal package API and add a staged improvement roadmap 2026-07-04 18:26:25 -05:00
f7ad756fc3 Document public extra params validation 2026-07-04 23:22:13 +00:00
4fe11b1b2b Clarify public profile and source docs 2026-07-04 23:21:02 +00:00
296f9b1817 Make public options opaque 2026-07-04 23:19:17 +00:00
7a8516b0c6 Redact direct API keys in request formatting 2026-07-04 23:17:44 +00:00
2df2f530b3 Validate public JSON-like inputs 2026-07-04 23:15:50 +00:00
8b25ca72e5 Stop mutating supplied HTTP clients 2026-07-04 23:11:26 +00:00
fa02791fe9 Split prompt and profile load errors 2026-07-04 23:09:23 +00:00
32767b4eb4 Audit the public package API and add a staged improvement roadmap 2026-07-04 18:05:20 -05:00
e1e5351c5d Clean up completed roadmap docs 2026-07-04 17:24:02 -05:00
d60ef66f53 Implement a library profile API and built-in profile docs 2026-07-04 17:23:34 -05:00
4669b73d38 Update OpenAI-compatible auth docs 2026-07-04 17:04:25 +00:00
6f91603168 Add public asset source options 2026-07-04 17:02:09 +00:00
3ad247039b Add request API key support 2026-07-04 16:55:01 +00:00
32e2433628 Add built-in profile repository wiring 2026-07-04 16:48:18 +00:00
712c6b92b8 Add profile repository foundations 2026-07-04 16:41:28 +00:00
89cafcefec Add a roadmap, implementation plan, and built-in profile defaults for a production-ready public library package 2026-07-04 11:38:14 -05:00
1d7fac0a47 Implement fixes to the initial library facade 2026-07-04 11:32:47 -05:00
79 changed files with 6560 additions and 1649 deletions

View File

@@ -1,4 +1,4 @@
Copyright (c) 2026 eric.
Copyright (c) 2026 Eric Rakestraw.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

View File

@@ -1,8 +1,12 @@
# scriptorium
Scriptorium is a config-driven prompt execution engine.
Scriptorium is a narrow prompt-execution application for rendering prompt
requests, running them against OpenAI-compatible chat-completions endpoints, and
serving the same run workflow over HTTP.
It separates prompt definitions (what to generate) from execution profiles (how to call an OpenAI-compatible model endpoint), then runs or renders a prepared request from named input artifacts.
It keeps prompt definitions, execution profiles, schemas, and input artifacts as
separate files so prompts can be reviewed and reused without baking model
runtime settings into application code.
## Quickstart
@@ -23,16 +27,19 @@ This command renders the prepared prompt and effective runtime settings without
- [CLI reference](docs/cli.md)
- [Configuration reference](docs/config.md)
- [HTTP API reference](docs/api.md)
- [Operations guide](docs/operations.md)
- [Troubleshooting](docs/troubleshooting.md)
- [Consumer integration overview](docs/consumers/api.md)
- [Go library package](docs/consumers/pkg-scriptorium.md)
- [HTTP API integration](docs/integrations/http-api.md)
- [Subprocess integration](docs/integrations/subprocess.md)
- [OpenAI-compatible chat integration](docs/integrations/openai-compatible-chat.md)
- [Narratio subprocess integration](docs/integrations/narratio.md)
- [Architecture policy](docs/policy/architecture.md)
## Examples
- `examples/config.yml`
- `examples/config.full.yml`
- `examples/render-markdown-summary.sh`
- `examples/http-run.json`
- `examples/go-library/prepare`

View File

@@ -1,18 +1,27 @@
package scriptorium
import "gitea.maximumdirect.net/eric/scriptorium/internal/domain"
import (
"reflect"
func toDomainRunRequest(req RunRequest) domain.RunRequest {
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
execution, err := toDomainExecutionTargetOverride(req.Execution)
if err != nil {
return domain.RunRequest{}, err
}
return domain.RunRequest{
PromptID: req.PromptID,
PromptVersion: req.PromptVersion,
ProfileID: req.ProfileID,
APIKey: req.APIKey,
Inputs: toDomainArtifactRefMap(req.Inputs),
Vars: copyStringMap(req.Vars),
Execution: toDomainExecutionTargetOverride(req.Execution),
Execution: execution,
Validation: toDomainOutputContractPtr(req.Validation),
Metadata: copyStringMap(req.Metadata),
}
}, nil
}
func fromDomainPreparedRun(prepared *domain.PreparedRun) *PreparedRun {
@@ -68,6 +77,7 @@ func fromDomainGenerateRequest(req domain.GenerateRequest) GenerateRequest {
Target: fromDomainExecutionTarget(req.Target),
TargetPresence: fromDomainExecutionTargetPresence(req.TargetPresence),
StructuredOutput: fromDomainStructuredOutputSpec(req.StructuredOutput),
APIKey: req.Target.APIKey,
}
}
@@ -118,9 +128,13 @@ func fromDomainArtifact(artifact domain.Artifact) Artifact {
}
}
func toDomainExecutionTargetOverride(override *ExecutionTargetOverride) *domain.ExecutionTargetOverride {
func toDomainExecutionTargetOverride(override *ExecutionTargetOverride) (*domain.ExecutionTargetOverride, error) {
if override == nil {
return nil
return nil, nil
}
extraParams, err := copyPublicJSONMap(override.ExtraParams)
if err != nil {
return nil, err
}
return &domain.ExecutionTargetOverride{
Endpoint: override.Endpoint,
@@ -132,8 +146,8 @@ func toDomainExecutionTargetOverride(override *ExecutionTargetOverride) *domain.
ServiceTier: override.ServiceTier,
ReasoningEffort: override.ReasoningEffort,
APIKeyEnv: override.APIKeyEnv,
ExtraParams: copyAnyMap(override.ExtraParams),
}
ExtraParams: extraParams,
}, nil
}
func fromDomainExecutionTarget(target domain.ExecutionTarget) ExecutionTarget {
@@ -282,6 +296,9 @@ func copyAnyMap(src map[string]any) map[string]any {
}
func copyAny(value any) any {
if value == nil {
return nil
}
switch v := value.(type) {
case map[string]any:
return copyAnyMap(v)
@@ -295,6 +312,60 @@ func copyAny(value any) any {
return copyStringSlice(v)
case []byte:
return copyBytes(v)
default:
return copyReflectValue(reflect.ValueOf(value)).Interface()
}
}
func copyReflectValue(value reflect.Value) reflect.Value {
if !value.IsValid() {
return value
}
switch value.Kind() {
case reflect.Interface:
if value.IsNil() {
return reflect.Zero(value.Type())
}
copied := copyReflectValue(value.Elem())
if copied.IsValid() && copied.Type().AssignableTo(value.Type()) {
return copied
}
out := reflect.New(value.Type()).Elem()
out.Set(copied)
return out
case reflect.Pointer:
if value.IsNil() {
return reflect.Zero(value.Type())
}
out := reflect.New(value.Type().Elem())
out.Elem().Set(copyReflectValue(value.Elem()))
return out
case reflect.Map:
if value.IsNil() {
return reflect.Zero(value.Type())
}
out := reflect.MakeMapWithSize(value.Type(), value.Len())
iter := value.MapRange()
for iter.Next() {
out.SetMapIndex(copyReflectValue(iter.Key()), copyReflectValue(iter.Value()))
}
return out
case reflect.Slice:
if value.IsNil() {
return reflect.Zero(value.Type())
}
out := reflect.MakeSlice(value.Type(), value.Len(), value.Cap())
for i := 0; i < value.Len(); i++ {
out.Index(i).Set(copyReflectValue(value.Index(i)))
}
return out
case reflect.Array:
out := reflect.New(value.Type()).Elem()
for i := 0; i < value.Len(); i++ {
out.Index(i).Set(copyReflectValue(value.Index(i)))
}
return out
default:
return value
}

297
docs/api.md Normal file
View File

@@ -0,0 +1,297 @@
# HTTP API Reference
This is the canonical public HTTP contract for Scriptorium.
Implemented route:
- `POST /v1/runs`
For CLI behavior, see [CLI reference](cli.md). For config and prompt/profile
file formats, see [Configuration reference](config.md).
The maintained request-shape example is `examples/http-run.json`. It requires a
running `serve` process with an artifact root that can read the referenced
files, plus a reachable model endpoint for full execution.
## Base URL And Deployment
`scriptorium serve` listens on `server.addr` or `serve --addr`. The default is
`:8080`.
The route path is always:
```text
/v1/runs
```
The HTTP adapter has no built-in authentication or authorization. Deploy it
behind trusted network and authentication controls.
## Media Types
- Request body: JSON object.
- Response body: JSON object.
- Response `Content-Type`: `application/json`.
Requests are decoded as JSON regardless of the request `Content-Type` header.
There are no shared query parameters.
## Request Limits
HTTP limits are configured through `server.*` config fields or `serve` flags:
- `server.max_request_bytes`: encoded JSON request body limit, including inline input bodies.
- `server.max_artifact_bytes`: file artifact limit for HTTP `file` input references.
- `server.max_response_bytes`: encoded JSON response limit, including artifact body and optional raw output.
Each limit defaults to `16777216` bytes. `0` disables that limit.
## `POST /v1/runs`
Runs one prompt request and returns the generated artifact, validation result,
and metadata.
### Request Body
```json
{
"prompt_id": "generic.markdown_summary",
"profile_id": "local-fast",
"prompt_version": "1.0.0",
"inputs": {
"transcript": {
"type": "file",
"uri": "./examples/fixtures/transcript.md"
},
"glossary": {
"type": "inline",
"body": "party:\n - Rin"
}
},
"vars": {
"session_date": "2026-05-04"
},
"model": {
"endpoint": "http://localhost:8000/v1",
"model": "gpt-4o-mini",
"temperature": 0,
"max_tokens": 800,
"top_p": 1,
"timeout_seconds": 120,
"service_tier": "priority",
"reasoning_effort": "medium",
"api_key_env": "SCRIPTORIUM_API_KEY",
"extra_params": {
"provider_option": "enabled"
}
},
"include_raw_output": false
}
```
Request fields:
| Field | Required | Description |
| --- | --- | --- |
| `prompt_id` | yes | Prompt ID. Must not be blank. |
| `prompt_version` | no | Prompt version filter. |
| `profile_id` | no | Execution profile ID. If omitted, the prompt must define `default_profile`. |
| `inputs` | yes | Object mapping prompt input names to input references. Must contain at least one entry. |
| `vars` | no | Object mapping template variable names to string values. |
| `model` | no | Runtime model override object. |
| `include_raw_output` | no | When `true`, include `raw_model_output` in the response. |
Input reference fields:
| Field | Required | Description |
| --- | --- | --- |
| `type` | yes | `file` or `inline`. |
| `uri` | for `file` | File URI/path. |
| `body` | for `inline` | Inline artifact body. |
HTTP `file` references require `server.artifact_root` or `serve
--artifact-root`. Relative file URIs resolve against that root. Absolute file
URIs are accepted only when lexically inside the root. Relative traversal and
absolute paths outside the root return `400 artifact_not_allowed`.
The containment check is lexical and does not resolve symlinks. Symlinks inside
the artifact root are followed by the operating system, including symlinks that
point outside the root. Keep the artifact root narrow and not writable by
untrusted users.
Model override fields:
| Field | Description |
| --- | --- |
| `endpoint` | Runtime endpoint override. |
| `model` | Runtime model override. |
| `temperature` | Number in range `0..2`. Explicit `0` is an override. |
| `max_tokens` | Integer greater than or equal to `0`. Explicit `0` is an override. |
| `top_p` | Number in range `0..1`. Explicit `0` is an override. |
| `timeout_seconds` | Integer greater than or equal to `0`. Explicit `0` disables the outbound client timeout. |
| `service_tier` | Provider-specific request tier. |
| `reasoning_effort` | Provider-specific reasoning setting. |
| `api_key_env` | Name of an environment variable containing the API key. |
| `extra_params` | JSON-compatible provider-specific top-level request fields. |
Raw API-key values are not accepted in HTTP payloads. A field such as
`api_key` is rejected as unknown JSON.
`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`.
### Strict JSON Rules
Request decoding is strict:
- malformed JSON returns `400 invalid_json`
- unknown request fields return `400 invalid_json`
- unknown `inputs` item fields return `400 invalid_json`
- unknown `model` fields return `400 invalid_json`
- trailing JSON tokens after the request object return `400 invalid_json`
- request bodies above the configured limit return `413 request_too_large`
### Success Response
Status: `200 OK`
```json
{
"artifact": {
"name": "output",
"content_type": "text/markdown",
"body": "Generated content",
"size": 17,
"hash": "..."
},
"validation": {
"status": "passed",
"mode": "basic",
"repair_attempts": 0,
"is_valid": true
},
"metadata": {
"run_id": "...",
"prompt_id": "generic.markdown_summary",
"prompt_version": "1.0.0",
"prompt_hash": "...",
"rendered_prompt_hash": "...",
"selected_profile_id": "local-fast",
"model_name": "gpt-4o-mini",
"endpoint": "http://localhost:8000/v1",
"model_params": {
"endpoint": "http://localhost:8000/v1",
"model": "gpt-4o-mini",
"temperature": 0.2,
"max_tokens": 500,
"top_p": 1,
"timeout_seconds": 90
},
"input_hashes": {
"transcript": "..."
},
"usage": {
"prompt_tokens": 11,
"completion_tokens": 22,
"total_tokens": 33,
"cached_tokens": 0,
"cache_write_tokens": 0
},
"start_time": "2026-05-04T12:00:00Z",
"end_time": "2026-05-04T12:00:01Z",
"duration_ms": 1000,
"validation_mode": "basic",
"validation_status": "passed",
"repair_attempts_used": 0
}
}
```
Response fields:
- `artifact`: generated output artifact.
- `validation`: validation result for the generated artifact.
- `metadata`: run and effective runtime metadata.
- `raw_model_output`: omitted unless `include_raw_output` is `true`.
`artifact.uri` is omitted when empty. `validation.errors` and
`validation.schema_path` are omitted when empty. `model_params.service_tier`,
`model_params.reasoning_effort`, `model_params.api_key_env`, and
`model_params.extra_params` are omitted when empty.
`metadata.usage.cached_tokens` and `metadata.usage.cache_write_tokens` are
always present as numbers. They are `0` when the provider omits compatible cache
usage fields or reports no cache activity.
### Validation Failure Response
Generated-content validation failures still return `200 OK`.
```json
{
"validation": {
"status": "failed",
"mode": "json",
"errors": ["invalid JSON: ..."],
"repair_attempts": 0,
"is_valid": false
}
}
```
The response still includes `artifact` and `metadata`.
## Error Responses
Error body shape:
```json
{
"error": {
"code": "invalid_request",
"message": "prompt_id is required"
}
}
```
Current status/code mapping:
| Status | Code | Meaning |
| --- | --- | --- |
| `400` | `invalid_json` | Malformed JSON, unknown JSON field, or trailing JSON token. |
| `400` | `invalid_request` | Missing/invalid request fields or invalid runtime overrides. |
| `400` | `profile_required` | No `profile_id` and prompt has no `default_profile`. |
| `400` | `prompt_load_failed` | Prompt definition YAML/contract failed to load. |
| `400` | `profile_load_failed` | Profile YAML/contract failed to load, including raw `api_key`. |
| `400` | `artifact_not_allowed` | HTTP file refs are disabled or requested path is outside artifact root. |
| `400` | `artifact_read_failed` | Input artifact could not be read or input ref was unsupported/invalid. |
| `400` | `prompt_render_failed` | Prompt template rendering failed. |
| `400` | `api_key_env_missing` | Selected `api_key_env` variable is unset or empty. |
| `404` | `not_found` | Route path is unknown. |
| `404` | `prompt_not_found` | Prompt ID/version was not found. |
| `404` | `profile_not_found` | Profile ID was not found. |
| `405` | `method_not_allowed` | Method is not `POST` on `/v1/runs`. |
| `413` | `request_too_large` | Encoded JSON request body exceeds configured request limit. |
| `413` | `artifact_too_large` | HTTP file input artifact exceeds configured artifact limit. |
| `413` | `response_too_large` | Encoded JSON response exceeds configured response limit. |
| `500` | `validation_runtime_failed` | Validator runtime/schema loading failed. |
| `500` | `internal_error` | Unclassified server error. |
| `502` | `llm_failed` | Outbound model request failed. |
HTTP error messages are intentionally concise and do not include sensitive
internal causes.
## Retry And Idempotency
Scriptorium does not provide idempotency keys, pagination, caching headers, or
rate limiting.
Clients may retry transport failures or `5xx` responses when their surrounding
workflow can tolerate another model call. A retry can generate different output
and incur another provider request.
## Example File
- `examples/http-run.json`

View File

@@ -10,119 +10,195 @@ go run ./cmd/scriptorium render \
--input glossary=./examples/fixtures/glossary.yml
```
`render` prepares and formats the prompt without calling an LLM.
`render` prepares the prompt, loads input artifacts, resolves the execution
profile, and prints the prepared request without calling an LLM.
## Command Overview
- `scriptorium run`: prepare prompt, call the configured LLM, write generated output, print a run summary.
- `scriptorium render`: prepare prompt only; write prepared-run output as `text` or `json`.
- `scriptorium serve`: start the HTTP server.
- `scriptorium run`: prepare a prompt, call the configured LLM, write generated output, and print a run summary.
- `scriptorium render`: prepare a prompt only; write prepared-run output as `text` or `json`.
- `scriptorium serve`: start the HTTP server for `POST /v1/runs`.
Integration references:
Canonical related references:
- [HTTP contract](integrations/http-api.md)
- [Narratio subprocess contract](integrations/narratio.md)
- [Configuration reference](config.md)
- [HTTP API reference](api.md)
- [Subprocess integration](integrations/subprocess.md)
## Common Argument Rules
## Common Rules
- `--config` is supported by `run`, `render`, and `serve`.
- `run` and `render` require:
- `--prompt`
- at least one `--input`
- 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.
- 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.
- `run` and `render` require `--prompt`, at least one `--input`, and an effective `prompt_dir`.
- `serve` requires an effective `prompt_dir`.
- `profile_dir` is optional. Without it, only built-in profiles are available.
- If `profile_dir` is set, custom profiles override built-in profiles with the same ID.
- Prompt cache control, `session_id`, structured output, and provider-specific profile fields are configured in YAML, not with CLI flags.
Config precedence is:
1. built-in defaults
2. config file values
3. CLI flags
## Flag Reference
### `scriptorium run`
- `--config <path>`: app config file path.
```bash
scriptorium run [flags]
```
Required through flags or config:
- `--prompt-dir <dir>`: prompt definition directory.
- `--profile-dir <dir>`: profile definition directory.
Required as flags:
- `--prompt <id>`: prompt ID to execute.
- `--input name=path`: input file mapping. Repeat or use comma-separated mappings.
Optional flags:
- `--config <path>`: application config file.
- `--profile-dir <dir>`: custom profile definition directory.
- `--schema-dir <dir>`: schema base directory for `json_schema` validation.
- `--prompt <id>`: prompt ID to execute. Required.
- `--prompt-id <id>`: deprecated alias for `--prompt`.
- `--profile <id>`: explicit profile override.
- `--profile-id <id>`: deprecated alias for `--profile`.
- `--input name=path`: input mapping (repeatable, comma-separated accepted).
- `--var name=value`: template variable mapping (repeatable, comma-separated accepted).
- `--out <path>`: write artifact body to file instead of stdout.
- `--profile <id>`: execution profile override. If omitted, the prompt `default_profile` is used.
- `--var name=value`: template variable mapping. Repeat or use comma-separated mappings.
- `--out <path>`: write generated artifact body to a file instead of stdout.
- `--llm-base-url <url>`: runtime endpoint override.
- `--model <name>`: runtime model override.
- `--api-key-env <name>`: runtime API key environment-variable name override.
- `--api-key-env <name>`: runtime API-key environment variable name override.
- `--temperature <float>`: runtime temperature override.
- `--max-tokens <int>`: runtime max tokens 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 using Go duration syntax, such as `30s` or `2m`.
Numeric runtime override flags are presence-aware:
Deprecated aliases:
- 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`)
- `--prompt-id <id>`: alias for `--prompt`.
- `--profile-id <id>`: alias for `--profile`.
Runtime override notes:
- Omitted numeric override flags preserve the selected profile/default value.
- Explicit zero values override the selected profile/default value.
- `--timeout 0s` disables the outbound HTTP client timeout for that request.
- There is no raw API-key flag; use `--api-key-env`.
### `scriptorium render`
- Supports the same flags as `run`, except:
- no `--schema-dir` flag.
- Adds:
- `--format text|json`: prepared-run output format.
```bash
scriptorium render [flags]
```
Required through flags or config:
- `--prompt-dir <dir>`: prompt definition directory.
Required as flags:
- `--prompt <id>`: prompt ID to render.
- `--input name=path`: input file mapping. Repeat or use comma-separated mappings.
Optional flags:
- `--config <path>`: application config file.
- `--prompt-dir <dir>`: prompt definition directory.
- `--profile-dir <dir>`: custom profile definition directory.
- `--profile <id>`: execution profile override.
- `--var name=value`: template variable mapping. Repeat or use comma-separated mappings.
- `--out <path>`: write prepared-run output to a file instead of stdout.
- `--llm-base-url <url>`: runtime endpoint override for the prepared request.
- `--model <name>`: runtime model override for the prepared request.
- `--api-key-env <name>`: runtime API-key environment variable name override.
- `--temperature <float>`: runtime temperature override.
- `--max-tokens <int>`: runtime max tokens override.
- `--top-p <float>`: runtime top-p override.
- `--timeout <duration>`: runtime timeout override using Go duration syntax.
- `--format text|json`: prepared-run output format. Defaults to config `defaults.render_format`, then `text`.
Deprecated aliases:
- `--prompt-id <id>`: alias for `--prompt`.
- `--profile-id <id>`: alias for `--profile`.
Notes:
- `render` still resolves profile and runtime settings.
- `render` still validates that `api_key_env` exists if the selected profile or overrides require it.
- `render` resolves profiles, loads schemas for `json_schema` prompts, and validates `api_key_env`.
- `render` does not accept `--schema-dir`; use config `schema_dir` for render-time schema lookup.
- `render` does not call the LLM.
### `scriptorium serve`
- `--config <path>`: app config file path.
```bash
scriptorium serve [flags]
```
Required through flags or config:
- `--prompt-dir <dir>`: prompt definition directory.
Optional flags:
- `--config <path>`: application config file.
- `--addr <listen-address>`: HTTP listen address.
- `--prompt-dir <dir>`: prompt definition directory.
- `--profile-dir <dir>`: profile definition directory.
- `--profile-dir <dir>`: custom profile definition directory.
- `--schema-dir <dir>`: schema base directory for `json_schema` validation.
- `--artifact-root <dir>`: base directory for HTTP `file` input references.
- `--max-request-bytes <n>`: maximum HTTP request body bytes; `0` disables the limit.
- `--max-artifact-bytes <n>`: maximum HTTP file artifact bytes; `0` disables the limit.
- `--max-response-bytes <n>`: maximum encoded HTTP response body bytes; `0` disables the limit.
Notes:
- `serve` does not accept runtime model override flags such as `--model` or `--llm-base-url`.
- HTTP request fields and error codes are documented in the [HTTP API reference](api.md).
- HTTP `file` input references are rejected unless an artifact root is configured.
- HTTP size-limit flags affect only `serve`.
## Input And Variable Syntax
- `--input name=path` maps prompt input names to local file paths.
- `--var name=value` maps template variable names to values.
- If a prompt defines `session_id: "{{ .session_id }}"`, pass the OpenRouter sticky-routing value with `--var session_id=<value>`.
- `--var name=value` maps prompt template variables to string values.
- Both flags can be repeated.
- Both flags also support comma-separated batches, for example:
- `--input transcript=./t.md,glossary=./g.yml`
- `--var session_id=42,session_date=2026-05-04`
- Both flags also accept comma-separated mappings, such as `--input transcript=./t.md,glossary=./g.yml`.
- Values may contain `=` after the first separator, such as `--var note=a=b=c`.
- Empty names and empty values are rejected.
CLI `run` and `render` convert every `--input` mapping to a `file` artifact
reference. HTTP also supports `inline` input references; see [HTTP API
reference](api.md).
## Output Behavior
`run`:
- Writes generated artifact content to stdout by default.
- Writes generated artifact content to `--out` when provided.
- Prints run summary metadata to stderr on success.
- Appends `cached_tokens=<n> cache_write_tokens=<n>` to the summary only when the provider reports non-zero cache usage.
- Prints a success summary to stderr.
- Prints errors to stderr on failure.
`render`:
- Writes prepared-run output to stdout by default.
- Writes prepared-run output to `--out` when provided.
- Does not print a success summary line.
- Does not print a success summary.
`serve`:
- Logs startup and server errors to stderr.
## Exit Codes
- `0`: success.
- `1`: runtime/parse/config/load/render/generation/output-write error.
- `2`: `run` completed, output was generated, but validation status is `failed`.
When `run` exits `2`, output may already be written to stdout or `--out`.
- `1`: parse, config, load, render, generation, output-write, or runtime error.
- `2`: `run` completed and wrote output, but validation status is `failed`.
## Common Workflows
Render prompt inputs and template variables as JSON:
Render prompt inputs and variables as JSON:
```bash
go run ./cmd/scriptorium render \
@@ -134,7 +210,7 @@ go run ./cmd/scriptorium render \
--format json
```
Run a prompt with profile override and file output:
Run a prompt with an explicit profile and file output:
```bash
go run ./cmd/scriptorium run \
@@ -146,12 +222,12 @@ go run ./cmd/scriptorium run \
--out ./summary.md
```
Start the HTTP server with explicit config:
Start the HTTP server with example config:
```bash
go run ./cmd/scriptorium serve --config ./examples/config.yml
```
Copyable example script:
Copyable maintained script:
- `examples/render-markdown-summary.sh`

View File

@@ -2,31 +2,32 @@
## Config Discovery And Precedence
Application settings are loaded in this order:
Application settings are resolved in this order:
1. Built-in defaults
1. built-in defaults
2. `config.yml` values
3. CLI overrides
When `--config` is not provided, Scriptorium searches for config files in this order:
When `--config` is omitted, Scriptorium searches:
1. `/usr/local/etc/scriptorium/config.yml`
2. `/etc/scriptorium/config.yml`
If neither file exists, Scriptorium continues with built-in defaults.
If neither file exists, Scriptorium uses built-in defaults. When
`--config <path>` is provided, that file must exist and decode successfully.
When `--config <path>` is provided, that file is required.
## Minimal App Config
## Minimal Working Config
```yaml
prompt_dir: ./examples/prompts
profile_dir: ./examples/profiles
```
This is enough to use `run` and `render` when prompt/profile files are valid.
This is enough for `run` and `render` when selected prompts use built-in
profiles. Set `profile_dir` when prompts or requests use custom profiles.
## Production-Oriented App Config
The maintained repository example is `examples/config.yml`.
## Production-Oriented Config
```yaml
prompt_dir: /opt/scriptorium/prompts
@@ -35,37 +36,57 @@ schema_dir: /opt/scriptorium/schemas
server:
addr: 127.0.0.1:8080
artifact_root: /var/lib/scriptorium/artifacts
max_request_bytes: 16777216
max_artifact_bytes: 16777216
max_response_bytes: 16777216
defaults:
render_format: text
```
## App Config File (`config.yml`)
The maintained full example is `examples/config.full.yml`.
## App Config Reference
Top-level fields:
- `prompt_dir` (optional): default prompt definition directory.
- `profile_dir` (optional): default profile definition directory.
- `schema_dir` (optional): base directory for schema files used by `json_schema` validation.
- `server.addr` (optional): default listen address for `serve`.
- `defaults.render_format` (optional): default `render` output format (`text` or `json`).
| Field | Default | Description |
| --- | --- | --- |
| `prompt_dir` | unset | Directory containing prompt definition YAML files. Required effectively by `run`, `render`, and `serve`. |
| `profile_dir` | unset | Directory containing custom profile YAML files. Built-in profiles remain available when unset. |
| `schema_dir` | `.` | Base directory for relative JSON Schema paths. |
| `server` | `{}` | HTTP service settings used by `serve`. |
| `defaults` | `{}` | Adapter defaults. |
Built-in defaults:
`server` fields:
- `schema_dir`: `.`
- `server.addr`: `:8080`
- `defaults.render_format`: `text`
| Field | Default | Description |
| --- | --- | --- |
| `server.addr` | `:8080` | Listen address for `serve`. |
| `server.artifact_root` | unset | Base directory for HTTP `file` input references. Without it, HTTP file refs are rejected. |
| `server.max_request_bytes` | `16777216` | Maximum encoded HTTP request body bytes. `0` disables the limit. |
| `server.max_artifact_bytes` | `16777216` | Maximum HTTP file artifact bytes. `0` disables the limit. |
| `server.max_response_bytes` | `16777216` | Maximum encoded HTTP response bytes. `0` disables the limit. |
Validation behavior:
`defaults` fields:
- Config decoding is strict; unknown YAML fields are rejected.
- Raw API key fields are not supported in `config.yml`.
| Field | Default | Description |
| --- | --- | --- |
| `defaults.render_format` | `text` | Default `render` output format: `text` or `json`. |
Config rules:
- YAML decoding is strict; unknown fields are rejected.
- HTTP size limits must be greater than or equal to `0`.
- Empty string config values are ignored.
- Raw API key fields are not supported in app config.
## Prompt Definition Files
Prompt definitions are YAML files anywhere under `prompt_dir`, including nested subdirectories.
Subdirectories are organizational only. Callers still select prompts by the YAML `id`, not by file path. For example, `prompts/dnd/recap.yaml` may still declare `id: dnd.recap`, and callers use `--prompt dnd.recap`.
Prompt definitions are YAML files anywhere under `prompt_dir`. Nested
directories are organizational; callers select prompts by YAML `id`, not file
path.
Example:
@@ -98,84 +119,76 @@ output:
repair_attempts: 0
```
Field reference:
Prompt fields:
- `id` (required): prompt identifier.
- `version` (required): prompt version.
- `default_profile` (optional): profile ID used when request does not provide `profile_id`.
- `description` (optional): prompt description.
- `session_id` (optional): Go-template string for OpenRouter sticky-routing `session_id`; rendered from request vars.
- `inputs` (optional list): expected named inputs.
- `messages` (required list): prompt message templates.
- `output` (required object): output contract.
| Field | Required | Description |
| --- | --- | --- |
| `id` | yes | Prompt identifier used by `--prompt` and HTTP `prompt_id`. |
| `version` | yes | Prompt version. |
| `default_profile` | no | Profile ID used when a request does not provide a profile. |
| `description` | no | Human-readable description. |
| `session_id` | no | Go-template string rendered from request vars and forwarded as provider `session_id` when non-empty. |
| `inputs` | no | Named input declarations. |
| `messages` | yes | Chat message templates. |
| `output` | yes | Output format and validation contract. |
`inputs[]` fields:
- `name` (required)
- `required` (optional, boolean)
- `required` (optional boolean)
- `content_type` (optional metadata)
- `description` (optional)
`messages[]` fields:
- `role` (required)
- `content` or `content_file` (exactly one is required)
- `cache_control` (optional object): provider prompt-cache metadata for this message
- exactly one of `content` or `content_file`
- `cache_control` (optional)
Message rules:
- `content_file` resolves relative to the prompt YAML file location.
- Repeated roles are allowed.
- `content_file` is resolved relative to the prompt YAML file location.
- Nested prompt files keep the same relative `content_file` behavior; `./recap.user.md` next to `dnd/recap.yaml` resolves from `dnd/`.
- Prompt decoding is strict; unknown YAML fields are rejected.
- Duplicate prompt IDs are invalid. If multiple files declare the requested prompt ID, Scriptorium fails instead of choosing one.
- Prompt YAML decoding is strict.
- Duplicate input names are invalid.
- Duplicate prompt IDs are invalid for a requested ID/version.
`messages[].cache_control` fields:
- `type` (required when `cache_control` is present): currently only `ephemeral`.
- `ttl` (optional): currently only `1h`; omitted from outbound requests when unset.
| Field | Required | Supported values |
| --- | --- | --- |
| `type` | yes | `ephemeral` |
| `ttl` | no | `1h` |
Example cache-controlled message:
`session_id` behavior:
```yaml
messages:
- role: system
content_file: ./stable_context.md
cache_control:
type: ephemeral
ttl: 1h
- role: user
content: |
{{input "transcript"}}
```
Use cache control on stable reusable prompt content. Dynamic per-run inputs before the cache-controlled message change the provider cache key.
Example prompt-level session ID:
```yaml
session_id: "{{ .session_id }}"
```
When configured, `session_id` is rendered with the same variable context as messages. The rendered value is trimmed, omitted when empty, and rejected if longer than 256 characters. CLI callers pass the value through `--var session_id=<value>`; HTTP callers pass it through `"vars": {"session_id": "<value>"}`.
- Rendered with the same variable context as message templates.
- Trimmed and omitted when empty.
- Rejected when longer than 256 Unicode code points.
- CLI callers pass variables with `--var`; HTTP callers use `vars`.
`output` fields:
- `format` (required): `text`, `markdown`, or `json`.
- `validation_mode` (required): `none`, `basic`, `json`, or `json_schema`.
- `schema_path` (required when `validation_mode: json_schema`).
- `repair_attempts` (required): integer `>= 0`.
| Field | Required | Supported values |
| --- | --- | --- |
| `format` | yes | `text`, `markdown`, `json` |
| `validation_mode` | yes | `none`, `basic`, `json`, `json_schema` |
| `schema_path` | only for `json_schema` | Relative to `schema_dir` unless absolute. |
| `repair_attempts` | yes | Integer greater than or equal to `0`. |
Repair behavior boundary:
Repair boundary:
- `repair_attempts` is part of the prompt contract.
- CLI and HTTP currently construct the runner without a repairer, so normal `run`/`serve` execution does not perform output repair attempts.
- The current CLI and HTTP wiring constructs the runner without a repairer, so normal `run` and `serve` execution does not perform repair attempts.
## Profile Definition Files
Execution profiles are YAML files anywhere under `profile_dir`, including nested subdirectories.
Execution profiles are YAML files anywhere under `profile_dir`. Nested
directories are organizational; callers select profiles by YAML `id`, not file
path.
Subdirectories are organizational only. Callers still select profiles by the YAML `id`, not by file path. For example, `profiles/local/local-quality.yaml` may still declare `id: local-quality`, and callers use `--profile local-quality`.
Scriptorium also ships built-in profiles. Custom profiles override built-ins
with the same ID.
Example:
@@ -192,79 +205,120 @@ service_tier: priority
reasoning_effort: medium
extra_params:
provider_route: primary
provider_options:
retry_budget: 2
```
Field reference:
Profile fields:
- `id` (required)
- `endpoint` (required)
- `model` (required)
- `temperature` (optional): range `0..2`
- `max_tokens` (optional): `>= 0`
- `top_p` (optional): range `0..1`
- `timeout_seconds` (optional): `>= 0`
- `service_tier` (optional): provider-specific request tier such as OpenRouter `flex` or `priority`
- `reasoning_effort` (optional): serialized as top-level `reasoning_effort` in outbound chat-completions requests
- `api_key_env` (optional)
- `extra_params` (optional map): JSON-compatible provider-specific parameters. Values may be strings, numbers, booleans, objects, or arrays.
| Field | Required | Description |
| --- | --- | --- |
| `id` | yes | Profile identifier. |
| `endpoint` | yes | OpenAI-compatible base URL including `/v1`. |
| `model` | yes | Provider model name. |
| `temperature` | no | Range `0..2`. |
| `max_tokens` | no | Integer greater than or equal to `0`. |
| `top_p` | no | Range `0..1`. |
| `timeout_seconds` | no | Integer greater than or equal to `0`. |
| `service_tier` | no | Provider-specific request tier. |
| `reasoning_effort` | no | Provider-specific reasoning setting. |
| `api_key_env` | no | Environment variable name containing the API key. |
| `extra_params` | no | JSON-compatible provider-specific top-level request fields. |
Execution defaults before profile/request overrides:
| Field | Default |
| --- | --- |
| `temperature` | `0.0` |
| `max_tokens` | `0` |
| `top_p` | `1.0` |
| `timeout_seconds` | `600` |
Profile rules:
- Profile decoding is strict; unknown YAML fields are rejected.
- Profile YAML decoding is strict.
- Duplicate custom profile IDs are invalid.
- Matching custom and built-in IDs are valid override behavior.
- Raw `api_key` is rejected; use `api_key_env`.
- 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.
- `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`.
- If `api_key_env` is set, the named environment variable must be set before `run`, `render`, or HTTP execution can prepare the request.
- Profile numeric fields merge by non-zero value. Request overrides are presence-aware, so explicit zero values are supported through CLI flags or HTTP model overrides.
- `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`.
Current outbound request behavior:
Built-in profile catalog:
- 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 with `cache_control` serialize as a single text content-block array containing `cache_control`.
| Provider | ID | Model | API key env |
| --- | --- | --- | --- |
| aion-labs | `aion-2` | `aion-labs/aion-2.0` | `OPENROUTER_API_KEY` |
| anthropic | `claude-fable-latest` | `~anthropic/claude-fable-latest` | `OPENROUTER_API_KEY` |
| anthropic | `claude-haiku-latest` | `~anthropic/claude-haiku-latest` | `OPENROUTER_API_KEY` |
| anthropic | `claude-opus-latest` | `~anthropic/claude-opus-latest` | `OPENROUTER_API_KEY` |
| anthropic | `claude-sonnet-latest` | `~anthropic/claude-sonnet-latest` | `OPENROUTER_API_KEY` |
| deepseek | `deepseek-3-2` | `deepseek/deepseek-v3.2` | `OPENROUTER_API_KEY` |
| deepseek | `deepseek-4-pro` | `deepseek/deepseek-v4-pro` | `OPENROUTER_API_KEY` |
| google | `gemini-2-flash` | `google/gemini-2.5-flash` | `OPENROUTER_API_KEY` |
| google | `gemini-2-flash-lite` | `google/gemini-2.5-flash-lite` | `OPENROUTER_API_KEY` |
| google | `gemini-2-pro` | `google/gemini-2.5-pro` | `OPENROUTER_API_KEY` |
| google | `gemini-3-flash-lite` | `google/gemini-3.1-flash-lite` | `OPENROUTER_API_KEY` |
| google | `gemini-flash-latest` | `~google/gemini-flash-latest` | `OPENROUTER_API_KEY` |
| google | `gemini-pro-latest` | `~google/gemini-pro-latest` | `OPENROUTER_API_KEY` |
| google | `gemma-4-31b` | `google/gemma-4-31b-it:exacto` | `OPENROUTER_API_KEY` |
| minimax | `minimax-m2` | `minimax/minimax-m2.5` | `OPENROUTER_API_KEY` |
| minimax | `minimax-m3` | `minimax/minimax-m3` | `OPENROUTER_API_KEY` |
| mistral | `mistral-large-2512` | `mistralai/mistral-large-2512` | `OPENROUTER_API_KEY` |
| mistral | `mistral-medium-3-5` | `mistralai/mistral-medium-3-5` | `OPENROUTER_API_KEY` |
| mistral | `mistral-small-3` | `mistralai/mistral-small-3.2-24b-instruct` | `OPENROUTER_API_KEY` |
| mistral | `mistral-small-4` | `mistralai/mistral-small-2603` | `OPENROUTER_API_KEY` |
| nvidia | `nemotron-3-ultra` | `nvidia/nemotron-3-ultra-550b-a55b` | `OPENROUTER_API_KEY` |
| openai | `gpt-5-mini` | `openai/gpt-5.4-mini` | `OPENROUTER_API_KEY` |
| openai | `gpt-5-nano` | `openai/gpt-5.4-nano` | `OPENROUTER_API_KEY` |
## Schema Behavior
Schemas are JSON files, typically in `schema_dir`.
Schemas are JSON files, typically under `schema_dir`.
Rules:
- `output.validation_mode: json_schema` requires `output.schema_path`.
- Relative `schema_path` values resolve from `schema_dir`, including explicit nested paths such as `dnd/structured_events.schema.json`.
- Relative `schema_path` values resolve from `schema_dir`.
- Absolute `schema_path` values are used directly.
- Scriptorium does not recursively search schemas by basename; nested schemas must be referenced by their relative path.
- Missing or invalid schema documents cause runtime validation errors.
- Invalid generated JSON causes validation status `failed` (not a runtime error).
- Nested schemas must be referenced by relative path; schemas are not searched recursively by basename.
- Missing or invalid schema documents are runtime validation errors.
- Invalid generated JSON produces validation status `failed`, not a runtime error.
Supported artifact reference types for request inputs are `file` and `inline`.
## Artifact References
Supported request input artifact reference types are:
- `file`
- `inline`
CLI `run` and `render` create `file` references from `--input name=path`.
HTTP `file` references require `server.artifact_root` or `serve
--artifact-root`. Relative file URIs resolve under that root. Absolute paths
and relative traversal outside the root are rejected by lexical checks. Symlinks
inside the root are followed by the operating system, including symlinks that
point outside the root.
HTTP `inline` references do not require an artifact root.
## Secrets Handling
- Keep secret values in environment variables.
- Store only environment-variable names in profile `api_key_env`.
- Do not put raw API keys in config, prompts, profiles, CLI flags, or HTTP request bodies.
- Store only environment-variable names in `api_key_env`.
- Do not put raw API keys in config, prompts, profiles, CLI arguments, examples, or HTTP request bodies.
## Maintained Examples
- App config: `examples/config.yml`
- Minimal app config: `examples/config.yml`
- Full app config: `examples/config.full.yml`
- Prompt examples: `examples/prompts/`
- Profile examples: `examples/profiles/`
- Custom profile examples: `examples/profiles/`
- Schema examples: `examples/schemas/`
- Input fixtures: `examples/fixtures/`
- Render example script: `examples/render-markdown-summary.sh`
- HTTP request example: `examples/http-run.json`
Example organizational layout:
```text
examples/prompts/dnd/recap.yaml
examples/profiles/local/local-quality.yaml
examples/schemas/dnd/structured_events.schema.json
```
- Render script: `examples/render-markdown-summary.sh`
- HTTP request-shape example: `examples/http-run.json`
## Integration References
- [Inbound HTTP contract](integrations/http-api.md)
- [CLI reference](cli.md)
- [HTTP API reference](api.md)
- [Outbound OpenAI-compatible contract](integrations/openai-compatible-chat.md)

View File

@@ -1,13 +1,122 @@
# Consumer API Overview
# Consumer Integration Overview
Scriptorium can be used by consumers through three implemented surfaces:
This guide is for applications that call Scriptorium from another codebase.
- CLI commands, documented in [CLI reference](../cli.md).
- HTTP `POST /v1/runs`, documented in [HTTP API integration](../integrations/http-api.md).
- Go package `gitea.maximumdirect.net/eric/scriptorium`, documented in [pkg-scriptorium](pkg-scriptorium.md).
Scriptorium exposes three integration surfaces:
The Go package is the typed in-process API. It prepares prompts, runs prompts, accepts file or inline artifacts, supports per-request execution overrides, and exposes stable public errors for `errors.Is`.
| Surface | Use when |
| --- | --- |
| Go package | The consumer is Go, needs typed requests/results, or wants injected LLM clients for tests. |
| CLI subprocess | The consumer wants process isolation or is not written in Go. |
| HTTP API | The consumer needs a service boundary or remote access to `POST /v1/runs`. |
Use the Go package when the caller is a Go program that wants typed requests/results, context cancellation, repeated calls without subprocess overhead, or fake LLM injection for tests. Use the CLI or HTTP surfaces when process isolation, language neutrality, or an HTTP boundary is preferred.
Canonical references:
Raw API key values are not accepted in public payloads and are not returned in prepared or run results. Execution profiles may reference an environment variable name through `api_key_env`.
- Go package: [Package scriptorium](pkg-scriptorium.md)
- CLI subprocess: [Subprocess integration](../integrations/subprocess.md)
- HTTP: [HTTP API reference](../api.md)
- File formats: [Configuration reference](../config.md)
## Required Deployment Inputs
Every integration needs operators to provide:
- prompt definitions;
- profile definitions or built-in profile IDs;
- schema files when prompts use `json_schema`;
- input artifacts or inline input bodies;
- API-key environment variables or direct per-request keys where supported.
Raw API keys do not belong in config, prompt files, profile YAML, CLI
arguments, or HTTP request bodies.
## Recommended Workflow
Use the Go package when:
- the consumer is a Go application;
- the application needs `context.Context` cancellation;
- repeated calls should avoid subprocess startup;
- tests need a fake LLM client;
- direct per-request `RunRequest.APIKey` is required.
Use the CLI subprocess when:
- the consumer is not Go;
- process isolation is useful;
- stdout/stderr separation and exit codes are enough;
- the consumer already manages local files and environment variables.
Use HTTP when:
- Scriptorium should run as a service;
- multiple clients need a shared prompt/profile deployment;
- clients can reach a trusted, protected HTTP boundary.
## Minimal Go Example
```go
engine, err := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
ProfileDir: "./examples/profiles",
SchemaDir: "./examples/schemas",
})
if err != nil {
return err
}
prepared, err := engine.Prepare(ctx, scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.File("./examples/fixtures/transcript.md"),
"glossary": scriptorium.File("./examples/fixtures/glossary.yml"),
},
})
if err != nil {
return err
}
_ = prepared.Messages
```
Run the maintained package example:
```bash
go run ./examples/go-library/prepare
```
## Subprocess Workflow
Invoke `scriptorium render` for preflight and `scriptorium run` for generation.
Capture stdout and stderr separately. Treat exit code `2` from `run` as a
completed generation with failed validation.
See [Subprocess integration](../integrations/subprocess.md) for the stable
invocation contract.
## HTTP Workflow
Run `scriptorium serve` behind trusted controls and send JSON requests to
`POST /v1/runs`.
Do not duplicate endpoint schemas in consumers. Use the [HTTP API
reference](../api.md) as the authoritative contract.
## Consumer Responsibilities
Consumers are responsible for:
- selecting prompt/profile IDs as deployment configuration;
- supplying all required inputs and vars;
- protecting generated artifacts and rendered prompts as sensitive data;
- deciding whether to keep output when validation fails;
- implementing retries only when another model call is acceptable.
Scriptorium does not persist run state. Retrying a failed or timed-out request
can produce different output and can incur another provider request.
## Status Behavior
- Go package methods return typed results or errors that support `errors.Is`.
- CLI `run` exits `2` when generation succeeds but validation fails.
- HTTP returns `200 OK` for generated-content validation failures and exposes the failed status in the response body.
- Runtime validation failures are errors.

View File

@@ -6,7 +6,22 @@ Import path:
import "gitea.maximumdirect.net/eric/scriptorium"
```
The root package is a public facade over Scriptorium's prompt execution use case. It keeps `internal/*` packages private while exposing typed construction, preparation, execution, inputs, results, and errors.
The root package is the public Go facade for Scriptorium's prompt prepare/run
workflow. It exposes typed requests, results, source options, injected LLM
clients, and stable public errors while keeping `internal/*` packages private.
## Intended Use Cases
Use the package when a Go application needs:
- in-process prompt preparation or execution;
- typed request/result structs;
- direct `context.Context` cancellation;
- injected/fake LLM clients for tests;
- direct per-request `RunRequest.APIKey`.
Use [Subprocess integration](../integrations/subprocess.md) or the [HTTP API](../api.md)
when a process or service boundary is preferred.
## Construct An Engine
@@ -21,11 +36,95 @@ if err != nil {
}
```
`PromptDir` and `ProfileDir` are required. `SchemaDir` defaults to the built-in schema directory. `Timeout` and `HTTPClient` configure the default OpenAI-compatible client used by `Run` when no custom LLM client is supplied.
`Config` fields:
## Prepare A Prompt
| Field | Description |
| --- | --- |
| `PromptDir` | Prompt definition directory. Required unless `WithPromptFS` or `WithPromptFile` is used. |
| `ProfileDir` | Optional custom profile directory overlaid above built-in profiles. |
| `SchemaDir` | Schema directory. Defaults to `.` when empty. |
| `Timeout` | Default timeout for the built-in OpenAI-compatible client. |
| `HTTPClient` | Optional HTTP client for the built-in OpenAI-compatible client. |
`Prepare` resolves the prompt definition, profile, inputs, variables, output contract, structured-output metadata, and rendered messages without calling an LLM.
`NewEngine` accepts `nil` options and ignores them. Invalid construction wraps
`ErrInvalidConfig`.
## Source Options
Directory fields are the compatibility path. Explicit source options override
the matching directory field.
Prompt sources:
- `WithPromptFS(fsys, root)`
- `WithPromptFile(path)`
Profile sources:
- `WithProfileFS(fsys, root)`
- `WithProfileFile(path)`
- `WithProfiles(profiles...)`
Schema sources:
- `WithSchemaFS(fsys, root)`
- `WithSchemaFile(path)`
LLM source:
- `WithLLMClient(client)`
Source behavior:
- Prompt and profile YAML use the same strict rules as directory loading.
- Prompt `content_file` values resolve relative to the prompt file.
- `fs.FS` roots are containment boundaries for prompt content files and schema paths.
- File options expose the selected file by its base name.
- Profile source precedence is in-memory profiles, then explicit profile file/FS/directory source, then built-ins.
- `WithLLMClient(nil)` returns `ErrInvalidConfig`.
## In-Memory Profiles
Use `WithProfiles` when the application already has typed model settings:
```go
profile := scriptorium.OpenAICompatibleProfile(scriptorium.OpenAICompatibleProfileConfig{
ID: "app.default",
Endpoint: "https://openrouter.ai/api/v1",
Model: "mistralai/mistral-small-3.2-24b-instruct",
APIKeyRequired: true,
})
engine, err := scriptorium.NewEngine(cfg, scriptorium.WithProfiles(profile))
```
`Profile` and `OpenAICompatibleProfileConfig` include:
- `ID`
- `Endpoint`
- `Model`
- `Temperature`
- `MaxTokens`
- `TopP`
- `TimeoutSeconds`
- `ServiceTier`
- `ReasoningEffort`
- `APIKeyRequired`
- `ExtraParams`
`WithProfiles` rejects duplicate IDs in one call. In-memory profiles do not
store raw keys. When `APIKeyRequired` is true, pass the secret on each request
with `RunRequest.APIKey`.
`ExtraParams` must be JSON-compatible: strings, booleans, finite numbers,
objects with string keys, arrays/slices, and nil. Unsupported values, non-string
map keys, non-finite floats, and cycles return `ErrInvalidConfig` for profiles
or `ErrInvalidRequest` for request overrides.
## Prepare Workflow
`Prepare` resolves prompt/profile/input/schema state and renders messages
without calling an LLM.
```go
prepared, err := engine.Prepare(ctx, scriptorium.RunRequest{
@@ -38,22 +137,24 @@ prepared, err := engine.Prepare(ctx, scriptorium.RunRequest{
if err != nil {
return err
}
_ = prepared.Messages
_ = prepared.EffectiveModelParams
```
Input helpers:
`PreparedRun` includes prompt ID/version/hash, selected profile, effective
model params, output contract, structured-output metadata, input hashes,
rendered prompt hash, rendered messages, and timing fields. It does not include
raw API-key values, model output, validation results, or internal target
presence metadata.
- `scriptorium.File(path)` loads an input artifact from a file.
- `scriptorium.Inline(body)` passes inline input content.
- `scriptorium.InlineWithURI(uri, body)` passes inline content with URI metadata.
## Run Workflow
## Run A Prompt
`Run` prepares the prompt, calls the configured LLM client, builds the output artifact, and validates the output.
`Run` calls `Prepare`, invokes the configured LLM client, builds the output
artifact, and validates the output.
```go
result, err := engine.Run(ctx, scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
APIKey: apiKey,
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.File("./examples/fixtures/transcript.md"),
"glossary": scriptorium.File("./examples/fixtures/glossary.yml"),
@@ -65,9 +166,25 @@ if err != nil {
_ = result.Artifact
```
`RunResult` includes the run ID, output artifact, raw output, validation result, prompt/profile/model metadata, effective model parameters, input hashes, token/cache usage, and timing fields. Validation content failures return a successful `RunResult` with failed validation status. Runtime validation errors return `ErrValidation`.
`RunResult` includes run ID, output artifact, raw output, validation result,
prompt/profile/model metadata, effective model params, input hashes, usage, and
timing fields.
## Inject An LLM Client
Generated-content validation failures return a successful `RunResult` with
`Validation.Status == ValidationFailed`. Runtime/schema validation errors
return an error that matches `ErrValidation`.
## Inputs
Input helpers:
- `File(path)`: file-backed artifact reference.
- `Inline(body)`: inline artifact body.
- `InlineWithURI(uri, body)`: inline artifact body with URI metadata.
Input map keys must match the prompt's expected input names.
## Injected LLM Clients
Use `WithLLMClient` for tests or custom model integrations:
@@ -84,11 +201,34 @@ func (fakeLLM) Generate(ctx context.Context, req scriptorium.GenerateRequest) (*
engine, err := scriptorium.NewEngine(cfg, scriptorium.WithLLMClient(fakeLLM{}))
```
The injected client receives the rendered prompt, effective execution target, target presence metadata for explicit numeric overrides, and structured-output spec. `WithLLMClient(nil)` returns `ErrInvalidConfig`.
Injected clients receive:
## Request Overrides
- rendered prompt;
- effective execution target;
- numeric target presence metadata;
- structured-output spec when applicable;
- direct request API key when provided.
`RunRequest.Execution` accepts per-request overrides. Numeric override fields are pointers so explicit zero values are preserved:
Custom clients should not log raw prompts or API keys by default.
## Overrides And API Keys
`RunRequest` fields:
| Field | Description |
| --- | --- |
| `PromptID` | Prompt ID. |
| `PromptVersion` | Optional prompt version filter. |
| `ProfileID` | Optional profile override. |
| `APIKey` | Direct per-request API key. |
| `Inputs` | Input artifact references. |
| `Vars` | Template variables. |
| `Execution` | Per-request model overrides. |
| `Validation` | Per-request output contract override. |
| `Metadata` | Request metadata reserved for callers. |
`RunRequest.Execution` uses pointer fields for numeric values so explicit zero
overrides are preserved:
```go
zero := 0
@@ -97,9 +237,19 @@ req.Execution = &scriptorium.ExecutionTargetOverride{
}
```
Direct `RunRequest.APIKey` takes precedence over profile `api_key_env` for the
default OpenAI-compatible client. It is request-scoped, uses `json:"-"`, and is
not included in `PreparedRun` or `RunResult` JSON. Normal Go string formatting
of `RunRequest` and `GenerateRequest` reports only whether a direct key is set.
Raw API keys do not belong in profile YAML, in-memory profiles, or app config.
Avoid reflection-based debug dumps of request structs because exported fields
remain visible to tools that bypass `String` and `GoString`.
## Errors
Public methods wrap context while preserving stable sentinel checks with `errors.Is`:
Public methods wrap context while preserving stable sentinel checks with
`errors.Is`:
- `ErrInvalidConfig`
- `ErrInvalidRequest`
@@ -122,8 +272,13 @@ if errors.Is(err, scriptorium.ErrPromptNotFound) {
## Examples
Run the prepare-only example from the repository root:
Run the maintained prepare-only example from the repository root:
```bash
go run ./examples/go-library/prepare
```
See also:
- [Configuration reference](../config.md)
- [Consumer integration overview](api.md)

View File

@@ -1,210 +0,0 @@
# HTTP API Integration
## Scope
This document defines the implemented inbound HTTP contract for Scriptorium.
Current scope is only:
- `POST /v1/runs`
For CLI behavior, see the [CLI reference](../cli.md).
## Endpoint
- Method: `POST`
- Path: `/v1/runs`
- Content type: JSON request/response
Route behavior:
- unknown path: `404 not_found`
- unsupported method on `/v1/runs`: `405 method_not_allowed`
Copyable request example file:
- `examples/http-run.json`
## Request Body
```json
{
"prompt_id": "generic.structured_events",
"profile_id": "local-quality",
"prompt_version": "1.0.0",
"inputs": {
"transcript": {"type": "file", "uri": "./examples/fixtures/transcript.md"},
"glossary": {"type": "inline", "body": "party:\n - Rin"}
},
"vars": {
"session_date": "2026-05-04"
},
"model": {
"endpoint": "http://localhost:8000/v1",
"model": "gpt-4o-mini",
"temperature": 0.0,
"max_tokens": 800,
"top_p": 1.0,
"timeout_seconds": 120,
"service_tier": "priority",
"reasoning_effort": "medium",
"api_key_env": "SCRIPTORIUM_API_KEY",
"extra_params": {
"route": "primary",
"provider_options": {
"retry_budget": 2
}
}
},
"include_raw_output": false
}
```
Required fields:
- `prompt_id`
- `inputs` (must contain at least one named input)
Input reference types currently supported by runtime artifact loading:
- `file`
- `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
Request decoding uses strict JSON field checks:
- unknown request fields are rejected with `400 invalid_json`
- unknown `model` fields are rejected with `400 invalid_json`
- raw API-key payload fields such as `api_key` are rejected as unknown fields
## Success Response
Status: `200 OK`
Response shape:
```json
{
"artifact": {
"name": "output",
"content_type": "application/json",
"body": "{\"summary\":\"...\"}",
"uri": "",
"size": 123,
"hash": "..."
},
"validation": {
"status": "passed",
"mode": "json_schema",
"errors": [],
"schema_path": "structured_events.schema.json",
"repair_attempts": 0,
"is_valid": true
},
"metadata": {
"run_id": "...",
"prompt_id": "generic.structured_events",
"prompt_version": "1.0.0",
"prompt_hash": "...",
"rendered_prompt_hash": "...",
"selected_profile_id": "local-quality",
"model_name": "gpt-4o-mini",
"endpoint": "http://localhost:8000/v1",
"model_params": {
"endpoint": "http://localhost:8000/v1",
"model": "gpt-4o-mini",
"temperature": 0,
"max_tokens": 800,
"top_p": 1,
"timeout_seconds": 120,
"service_tier": "priority",
"reasoning_effort": "medium",
"api_key_env": "SCRIPTORIUM_API_KEY",
"extra_params": {
"route": "primary",
"provider_options": {
"retry_budget": 2
}
}
},
"input_hashes": {
"transcript": "..."
},
"usage": {
"prompt_tokens": 11,
"completion_tokens": 22,
"total_tokens": 33,
"cached_tokens": 0,
"cache_write_tokens": 0
},
"start_time": "2026-05-04T12:00:00Z",
"end_time": "2026-05-04T12:00:01Z",
"duration_ms": 1000,
"validation_mode": "json_schema",
"validation_status": "passed",
"repair_attempts_used": 0
}
}
```
`raw_model_output` is omitted by default.
`metadata.usage.cached_tokens` and `metadata.usage.cache_write_tokens` are always present as numbers. They are `0` when the provider omits compatible cache usage fields or reports no cache activity.
To include it, send:
- `"include_raw_output": true`
## Validation Failure Behavior
Validation content failures do not map to HTTP error status.
Behavior:
- status remains `200 OK`
- `validation.status` is `failed`
- validation errors are returned in `validation.errors`
## Error Responses
Error body shape:
```json
{
"error": {
"code": "invalid_request",
"message": "prompt_id is required"
}
}
```
Current error mapping (non-exhaustive):
- `400 invalid_json`: malformed JSON or unknown JSON fields
- `400 invalid_request`: missing/invalid request fields
- `400 profile_required`: no explicit `profile_id` and prompt has no `default_profile`
- `400 prompt_load_failed`: prompt definition invalid/unloadable
- `400 profile_load_failed`: profile invalid/unloadable
- `400 artifact_read_failed`: input artifact loading failed
- `400 prompt_render_failed`: template render failed
- `400 api_key_env_missing`: named API-key environment variable is missing
- `404 prompt_not_found`
- `404 profile_not_found`
- `502 llm_failed`: outbound model request failed
- `500 validation_runtime_failed`: validator runtime/schema-load failure
- `500 internal_error`
## Security And Deployment Note
The HTTP adapter has no built-in authentication or authorization.
Deploy behind trusted controls (for example authenticated gateway/reverse proxy and network boundaries).

View File

@@ -1,114 +0,0 @@
# Narratio Subprocess Integration
## Purpose
This document defines the supported subprocess contract for Narratio invoking Scriptorium through the public CLI.
This is a CLI contract, not an internal Go package integration.
## Supported Commands
Narratio should invoke:
- `scriptorium run`
- `scriptorium render`
Use `run` for generation.
Use `render` for preflight/debug output without LLM execution.
## Recommended Invocation Shapes
Run:
```bash
scriptorium run \
--prompt <prompt_id> \
--input transcript=<path> \
--out <artifact_path>
```
Render:
```bash
scriptorium render \
--prompt <prompt_id> \
--input transcript=<path> \
--format json
```
Narratio may add:
- `--config <path>`
- `--profile <profile_id>`
- repeatable `--input name=path`
- repeatable `--var name=value`
- runtime overrides when explicitly needed (`--model`, `--llm-base-url`, `--timeout`, etc.)
## Config And Directory Behavior
Narratio can rely on resolved app config or pass explicit paths.
- default config search order:
1. `/usr/local/etc/scriptorium/config.yml`
2. `/etc/scriptorium/config.yml`
- explicit `--config` requires file existence and valid syntax
- CLI flags override config values
## Profile Selection
Profile selection follows runner behavior:
1. explicit `--profile`
2. prompt `default_profile`
3. error if neither is available
Narratio should treat prompt/profile IDs as deployment configuration, not hardcoded logic.
## Input And Variable Contract
- Inputs use repeated `--input name=path`.
- Input names must match prompt definition input names.
- Variables use repeated `--var name=value` for small metadata values.
- Prefer file inputs for large content.
## Environment Contract
- Pass through required API-key environment variables referenced by `api_key_env`.
- Never pass raw API keys via CLI arguments.
- Keep subprocess environment scoped to required variables.
## Output And Error Handling
`run`:
- stdout: artifact body unless `--out` is used
- `--out`: writes artifact to file
- stderr: success summary and errors
`render`:
- stdout: prepared-run output unless `--out` is used
- stderr: errors
Narratio should capture stdout and stderr separately.
## Exit Status Contract
- `0`: success
- `1`: parse/config/load/render/generation/IO/runtime error
- `2`: run completed but validation failed
A `run` exit code `2` can still produce output (stdout or `--out`).
## Security Notes
- Treat generated artifacts and stderr logs as potentially sensitive.
- Avoid logging full rendered prompts by default in production contexts.
- Use controlled output paths and access controls for persisted artifacts.
## Canonical References
- CLI behavior: [CLI reference](../cli.md)
- Config behavior: [Configuration reference](../config.md)
- Operations and failure handling: [Operations guide](../operations.md), [Troubleshooting](../troubleshooting.md)

View File

@@ -122,7 +122,12 @@ Structured output is currently `json_schema` only, serialized as:
## Authentication Header
If `Target.APIKeyEnv` is set:
If `Target.APIKey` is set:
- set `Authorization: Bearer <value>`
- do not read `Target.APIKeyEnv`
If `Target.APIKey` is empty and `Target.APIKeyEnv` is set:
- resolve environment variable value at request time
- set `Authorization: Bearer <value>`
@@ -131,7 +136,7 @@ If the environment variable is unset/empty:
- request fails before HTTP call (`ErrInvalidRequest`)
If `Target.APIKeyEnv` is empty:
If both `Target.APIKey` and `Target.APIKeyEnv` are empty:
- no `Authorization` header is sent
@@ -172,7 +177,7 @@ Malformed responses return `ErrMalformedResponse`.
## Error Handling
- network/request-construction failures: `ErrRequestFailed`
- non-2xx HTTP status: `ErrUnexpectedStatus` (includes status code and trimmed response body snippet)
- non-2xx HTTP status: `ErrUnexpectedStatus` (includes status code; provider response bodies are not included)
- malformed response shape/content: `ErrMalformedResponse`
## Unsupported Or Non-Serialized Fields

View File

@@ -0,0 +1,130 @@
# Subprocess Integration
This document defines the supported subprocess contract for downstream
applications invoking Scriptorium through the public CLI.
This is a CLI contract. Go callers that want an in-process typed API should use
the [package guide](../consumers/pkg-scriptorium.md).
## Supported Commands
Downstream applications should invoke:
- `scriptorium render` for preflight/debug output without LLM execution.
- `scriptorium run` for generation.
`scriptorium serve` is an HTTP service command, not the recommended subprocess
contract for per-request execution.
## Recommended Invocation Shapes
Render:
```bash
scriptorium render \
--config <config_path> \
--prompt <prompt_id> \
--input transcript=<path> \
--format json
```
Run:
```bash
scriptorium run \
--config <config_path> \
--prompt <prompt_id> \
--input transcript=<path> \
--out <artifact_path>
```
Callers may add:
- `--profile <profile_id>`
- repeatable `--input name=path`
- repeatable `--var name=value`
- runtime overrides when explicitly needed, such as `--model`, `--llm-base-url`, `--api-key-env`, and `--timeout`
Do not pass raw API keys as command arguments.
## Config And Directory Behavior
Callers can rely on resolved app config or pass explicit paths.
Default config search order:
1. `/usr/local/etc/scriptorium/config.yml`
2. `/etc/scriptorium/config.yml`
Rules:
- Explicit `--config` requires file existence and valid syntax.
- CLI flags override config values.
- `run` and `render` require an effective `prompt_dir`.
- `profile_dir` is optional because built-in profiles are available.
## Profile Selection
Profile selection follows runner behavior:
1. explicit `--profile`
2. prompt `default_profile`
3. error if neither is available
Treat prompt and profile IDs as deployment configuration, not hardcoded business
logic.
## Input And Variable Contract
- Inputs use repeated `--input name=path`.
- Input names must match prompt definition input names.
- Variables use repeated `--var name=value`.
- Both flags also accept comma-separated mappings.
- Prefer file inputs for large content.
CLI inputs are file references. HTTP-only `inline` references are documented in
the [HTTP API reference](../api.md).
## Environment Contract
- Pass through required API-key environment variables referenced by `api_key_env`.
- Keep subprocess environments scoped to required variables.
- Use `--api-key-env` only to name an environment variable.
- Never pass raw API keys via argv.
## Stdout And Stderr
`run`:
- stdout: generated artifact body unless `--out` is used.
- stderr: success summary and errors.
`render`:
- stdout: prepared-run output unless `--out` is used.
- stderr: errors.
Capture stdout and stderr separately. Do not parse stderr as a stable data
format beyond exit status handling.
## Exit Status Contract
- `0`: success.
- `1`: parse, config, load, render, generation, IO, or runtime error.
- `2`: `run` completed and output was written, but validation failed.
A `run` exit code `2` can still produce output on stdout or at `--out`.
Consumers must decide whether to keep or discard that output.
## Security Notes
- Treat generated artifacts, rendered prompts, stdout, and stderr as potentially sensitive.
- Use controlled output paths and access controls for persisted artifacts.
- Avoid logging full rendered prompts or generated artifacts by default.
## Canonical References
- CLI behavior: [CLI reference](../cli.md)
- Config and file formats: [Configuration reference](../config.md)
- Operations: [Operations guide](../operations.md)
- Troubleshooting: [Troubleshooting](../troubleshooting.md)

View File

@@ -1,133 +1,81 @@
# Adapter And Repository Internals
# Adapter Internals
## Purpose
This document describes implemented adapter/repository boundaries and their current behavior.
Adapters translate external interfaces into domain requests and translate domain results back out. They wire dependencies, apply app config, and own IO concerns, but they do not make runner decisions.
Source-loading behavior belongs in `docs/internal/sources.md`. User-facing CLI, HTTP, and package contracts belong in `docs/cli.md`, `docs/api.md`, and `docs/consumers/pkg-scriptorium.md`.
## Adapter Map
- `internal/adapter/cli`: CLI command parsing, app wiring, stdout/stderr handling, exit codes.
- `internal/adapter/http`: HTTP request/response mapping for `POST /v1/runs`.
- root package `scriptorium`: public Go library facade for preparing and running prompt requests.
- `internal/promptdef`: filesystem prompt-definition repository.
- `internal/profile`: filesystem execution-profile repository.
- `internal/artifact`: input artifact reader.
- `internal/prompt`: Go-template renderer.
- `internal/llm`: OpenAI-compatible LLM client implementation.
- `internal/validate`: output validator.
- `internal/format`: prepared-run formatters for `render` output.
- `cmd/scriptorium`: process entrypoint.
- `internal/adapter/cli`: command parsing, config handoff, runner construction, stdout/stderr, exit codes.
- `internal/adapter/http`: `POST /v1/runs` request/response mapping and HTTP error/status mapping.
- root package `scriptorium`: public Go facade over internal runner types and dependencies.
Supporting implementation packages used during adapter wiring:
- `internal/config`
- `internal/defaults`
- `internal/format`
- `internal/llm`
- `internal/prompt`
## Inputs And Outputs
CLI adapter:
- Input: process args, filesystem config/assets, environment.
- Output: exit code, stdout artifact/prepared output, stderr summaries/errors.
- `run` summaries include cache usage counters only when either parsed cache counter is non-zero.
- Input: process args, optional config file, filesystem sources, environment variables.
- Output: process exit code, stdout artifact/prepared output, stderr summaries and errors.
HTTP adapter:
- Input: JSON request body (`runRequestDTO`).
- Output: JSON success/error body with mapped status codes.
- Success metadata includes token usage plus cache usage counters.
- Input: HTTP request method/path/headers/body for `POST /v1/runs`.
- Output: JSON success or error body with mapped status code.
Public library facade:
Public Go facade:
- Input: typed `scriptorium.RunRequest` values.
- Input: typed `scriptorium.Config`, `Option`, and `RunRequest` values.
- Output: typed `PreparedRun` and `RunResult` values plus public sentinel errors.
- Custom LLM behavior is injected with `WithLLMClient`; otherwise the default OpenAI-compatible client is used.
- Public types are facade types converted at the package boundary; internal domain types remain internal.
Filesystem repositories:
- Input: prompt/profile YAML files under configured directories.
- Output: normalized domain definitions/profiles or typed errors.
Artifact reader:
- Input: `domain.ArtifactRef`.
- Output: loaded `domain.Artifact`.
LLM adapter:
- Input: `domain.GenerateRequest`.
- Output: `domain.GenerateResponse`.
Validator:
- Input: artifact body + output contract.
- Output: validation result or runtime validation error.
## Boundaries
- Adapters convert external representations to domain requests and back.
- Use-case decisions remain in `internal/usecase`.
- External dependency details stay scoped to adapter packages.
- Adapters convert external shapes to `domain.RunRequest` and back.
- Runner orchestration remains in `internal/usecase`.
- Prompt/profile/schema/artifact source rules remain in repository, validator, and artifact packages.
- LLM provider request serialization remains in `internal/llm`.
- Public package types are facade types; internal domain types do not leak across the package boundary.
## Config Fields Used
Primary app settings consumed by adapters:
Adapter app settings:
- `prompt_dir`
- `profile_dir`
- `schema_dir`
- `server.addr`
- `server.artifact_root`
- `server.max_request_bytes`
- `server.max_artifact_bytes`
- `server.max_response_bytes`
- `defaults.render_format`
Execution profile/request settings used through runner:
Execution request/profile settings passed through the runner:
- `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.
- `endpoint`
- `model`
- `temperature`
- `max_tokens`
- `top_p`
- `timeout_seconds`
- `service_tier`
- `api_key_env`
- `reasoning_effort`
- `extra_params`
## External Dependencies
CLI and HTTP preserve numeric override presence so omitted values and explicit zero values remain distinct.
- YAML decoding: `gopkg.in/yaml.v3` (strict known-fields mode in config/prompt/profile loaders).
- JSON Schema validation: `github.com/santhosh-tekuri/jsonschema/v6`.
- HTTP client/server: Go standard library.
## Failure Behavior
Strict decoding and input checks:
- config/prompt/profile loaders reject unknown YAML fields.
- prompt/profile repositories scan nested subdirectories recursively.
- prompt/profile lookup uses YAML `id` values; subdirectory paths are organizational only.
- duplicate prompt/profile IDs are invalid and fail instead of using first-match behavior.
- HTTP DTO decoder rejects unknown JSON fields.
- raw API key payload fields are rejected by strict decoding in profile/http paths.
Artifact refs:
- Supported reference types: `inline`, `file`.
- Unsupported types return `ErrUnsupportedRefType`.
LLM adapter:
- endpoint appends `/chat/completions`.
- rendered messages without cache control serialize with string `content`.
- 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.
- non-2xx responses map to request failure errors.
- malformed responses (including missing/empty first choice content) are errors.
Validator:
- `basic`, `json`, `json_schema` content failures return `ValidationFailed` results.
- schema load/compile/path failures are runtime errors.
- schema lookup uses explicit `schema_path` values relative to `schema_dir`; it does not recursively search by basename.
HTTP error mapping:
- maps domain/use-case errors to stable HTTP code + error code/message.
- distinguishes missing profile selection and missing `api_key_env` variable using stable use-case sentinel errors.
- avoids returning internal wrapped-cause details in response payload.
## CLI Adapter Semantics
## CLI Adapter
Implemented commands:
@@ -135,30 +83,73 @@ Implemented commands:
- `render`
- `serve`
Behavior highlights:
Behavior:
- `run` exit `2` indicates validation failed after generation.
- `render` does not call the LLM.
- `serve` exposes HTTP handler only; no built-in auth.
- `render` supports `--format text|json`; `render` does not expose `--schema-dir`.
- deprecated aliases `--prompt-id` and `--profile-id` are still accepted.
- `run` constructs a runner with direct filesystem artifact reading and calls `Runner.Run`.
- `render` constructs a runner and calls `Runner.Prepare`; it does not call the LLM.
- `serve` constructs a restricted artifact reader and HTTP handler, then starts an unauthenticated HTTP server.
- `run` exits `2` when generation succeeds but validation fails.
- parse, runtime, and output-write errors exit `1`.
- deprecated `--prompt-id` and `--profile-id` aliases are accepted.
## Tests To Inspect Before Changing
## HTTP Adapter
Behavior:
- Accepts only `POST /v1/runs`.
- Decodes JSON strictly and rejects unknown fields and trailing JSON tokens.
- Rejects empty `prompt_id` and empty `inputs` before calling the runner.
- Does not accept raw API key values in the request body.
- Returns validation failures as `200` responses with failed validation details.
- Maps request-body, artifact, and encoded-response size failures to `413`.
- Maps domain and repository errors to stable error codes without returning wrapped internal cause text.
The HTTP adapter has no built-in authentication or authorization. Deployment controls must be provided outside the process.
## Public Go Facade
Behavior:
- `NewEngine` wires the same default runner components as CLI/HTTP unless options override them.
- Prompt, profile, and schema sources may come from directories, single files, or `fs.FS` roots.
- `WithProfiles` adds in-memory profiles ahead of file-backed and built-in profiles.
- `WithLLMClient` injects custom model behavior.
- `RunRequest.APIKey` is request-scoped and direct; it is used only for generation and is stripped from public results.
- internal errors are mapped to public sentinels in `errors.go`.
## Failure Behavior
Adapters should:
- keep external error payloads concise and stable.
- avoid leaking raw secret values.
- use sentinels and typed errors for mapping.
- preserve strict external input decoding.
- keep validation content failures distinct from runtime errors.
CLI writes human-readable summaries to stderr. HTTP writes JSON error envelopes. The public Go facade returns typed errors.
## State And Manifests
Adapters do not add durable run state.
- No adapter writes run manifests.
- No adapter implements checkpoint, skip, or resume behavior.
- CLI output files are caller-selected artifacts, not internal state.
## Tests To Inspect
- `internal/adapter/cli/run_test.go`
- `internal/adapter/http/handler_test.go`
- `internal/promptdef/repository_test.go`
- `internal/profile/repository_test.go`
- `internal/artifact/reader_test.go`
- `internal/prompt/renderer_test.go`
- `internal/llm/openai_compatible_client_test.go`
- `internal/validate/standard_validator_test.go`
- `engine_test.go`
- `internal/format/prepared_run_test.go`
- `internal/llm/openai_compatible_client_test.go`
## Architectural Invariants
- Adapter packages do not own runner decision logic.
- External request/response strictness is part of contract stability.
- Prepared-render output never includes resolved API key values.
- 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.
- Adapter packages stay thin and translation-focused.
- App config is resolved before dependency construction.
- External input strictness is part of contract stability.
- CLI and HTTP construct runners without a repairer.
- HTTP endpoint details remain canonical in `docs/api.md`.
- Public Go package details remain canonical in `docs/consumers/pkg-scriptorium.md`.

View File

@@ -2,29 +2,32 @@
## Purpose
`internal/usecase.Runner` is the core use case orchestrator for prompt preparation and execution.
`internal/usecase.Runner` is the core prompt-execution orchestrator. It prepares prompt requests, calls the configured LLM client for `Run`, validates generated output, and returns domain results.
It owns request validation, prompt/profile resolution, runtime-parameter merge, artifact loading, prompt rendering, structured-output setup, LLM invocation, output validation, and result metadata.
Transport parsing, DTOs, CLI output, HTTP status mapping, and public package type conversion belong outside the runner.
## Inputs And Outputs
Primary input type:
Primary inputs:
- `domain.RunRequest`
- repositories/readers/renderers/validators injected at construction
- `context.Context` for cancellation
Primary output types:
Primary outputs:
- `domain.PreparedRun` from `Prepare`
- `domain.RunResult` from `Run`
- wrapped sentinel errors for adapter mapping
LLM boundary types:
- `domain.GenerateRequest`
- `domain.GenerateResponse`
## Boundaries
## Dependencies
`Runner` coordinates the following interfaces:
`Runner` depends on package interfaces instead of concrete adapter types:
- `promptdef.Repository`
- `profile.Repository`
@@ -34,133 +37,110 @@ LLM boundary types:
- `validate.Validator`
- optional `usecase.OutputRepairer`
Transport concerns (CLI flags, HTTP DTO parsing, status-code mapping) stay outside runner.
The CLI, HTTP adapter, and public Go package construct these dependencies and pass them in.
## Config Fields Used
## Config Fields
`Runner` does not read app config files directly.
`Runner` does not read app config files. Effective behavior is determined by injected dependencies and the `domain.RunRequest`.
It receives fully constructed repositories/readers/validators from adapters. Effective behavior depends on adapter wiring, including:
Adapter wiring commonly reflects these app config fields:
- prompt/profile directories
- schema base directory
- selected profile/runtime overrides in request
- `prompt_dir`
- `profile_dir`
- `schema_dir`
- `server.artifact_root`
- HTTP request/artifact/response size limits
## External Adapters Used
`Runner` works with adapter implementations via interfaces. Current wiring from CLI/HTTP uses:
- filesystem prompt/profile repositories
- composite artifact reader
- Go-template prompt renderer
- OpenAI-compatible LLM client
- standard validator
## State And Resume Behavior
`Runner` is stateless across requests.
- No durable run-state storage.
- No built-in resume/skip checkpoints.
- Each `Run`/`Prepare` executes from request inputs and current repositories.
## Failure Behavior
Primary runner error classes:
- `ErrInvalidRequest`: invalid run request envelope.
- `ErrProfileRequired`: specific invalid-request reason when neither request `profile_id` nor prompt `default_profile` is available.
- `ErrAPIKeyEnvMissing`: specific invalid-request reason when `api_key_env` is set but the named environment variable is unset/empty.
- `ErrProfileLoad`: prompt/profile repository load failures.
- `ErrArtifactLoad`: artifact read failures.
- `ErrPromptRender`: template render failures.
- `ErrLLMGenerate`: outbound model request failures.
- `ErrValidation`: validation runtime failures (including structured-output schema load/compile failures).
Reason sentinel behavior:
- `ErrProfileRequired` and `ErrAPIKeyEnvMissing` are wrapped with `ErrInvalidRequest`.
- Adapters can use `errors.Is` for stable reason mapping without matching runner prose.
Validation content failures are not run errors:
- `Run` can succeed with `Validation.Status == failed`.
- CLI maps this to exit code `2`.
- HTTP returns `200` with failed validation details.
Runtime model settings are resolved from the selected profile plus request overrides.
## Prepare Flow
`Prepare` performs:
`Prepare`:
1. validate request basics (prompt ID present).
2. load prompt definition by ID/version.
3. select profile ID:
- explicit request profile ID
- prompt `default_profile`
- otherwise return an invalid request with `ErrProfileRequired`
4. load execution profile.
5. merge effective runtime target:
- built-in execution defaults
- selected profile values
- 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:
- missing/empty env value returns an invalid request with `ErrAPIKeyEnvMissing`
- only the environment-variable name is retained; secret value is never returned
7. resolve output contract and structured-output schema payload when `json_schema` mode is active.
8. read input artifacts.
9. render prompt messages, including any normalized message cache-control metadata.
10. compute prompt/input/render hashes and return `PreparedRun`.
1. requires a non-empty prompt ID.
2. loads the prompt definition and computes its hash.
3. selects the profile from request `profile_id`, then prompt `default_profile`.
4. loads the selected execution profile.
5. merges built-in execution defaults, profile values, and request overrides.
6. applies request-scoped direct API key values for public Go callers.
7. validates endpoint, model, and credential requirements.
8. resolves the output contract and JSON Schema document when required.
9. reads input artifacts.
10. renders prompt messages and hashes the rendered prompt.
11. returns a prepared run without calling the LLM.
`rendered_prompt_hash` includes cache-control metadata when present because it affects the outbound provider request. Prompts without cache control keep the role/content hash behavior.
`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.
Numeric request overrides are presence-aware: omitted values preserve the current effective value, while explicit zero values are real overrides.
## Run Flow
`Run` performs:
`Run`:
1. generate run ID.
2. call `Prepare`.
3. call LLM with prepared messages/effective target/structured-output spec.
4. build output artifact content type from output format.
5. validate output.
6. optionally attempt bounded repair when repairer is injected and contract allows it.
7. return `RunResult` with artifact, raw output, validation, hashes, profile/model metadata, token/cache usage, and timestamps.
1. creates a run ID and start timestamp.
2. calls `Prepare`.
3. calls the injected LLM client with rendered messages, effective target, target presence, and structured-output settings.
4. builds the output artifact.
5. validates the output.
6. optionally attempts bounded repair when a repairer is injected and the contract permits repair.
7. returns the run result with artifact, raw output, validation, hashes, selected profile/model metadata, usage, and timing.
## Repair Hook Boundary
`Run` must reuse `Prepare`; prepare logic should not be duplicated elsewhere.
Repair attempts occur only when all are true:
## Validation And Repair
- repairer is injected
- `repair_attempts > 0`
Validation content failures are returned as successful run results with `Validation.Status == failed`. They are not runtime errors.
Validation runtime failures, such as schema load or compile errors, return `ErrValidation`.
Repair attempts occur only when all conditions are true:
- a repairer is injected
- `repair_attempts` is greater than zero
- validation status is `failed`
- validation mode is `json` or `json_schema`
Current production wiring boundary:
CLI and HTTP wiring call `usecase.NewRunner(...)`, which does not inject a repairer. Normal CLI and HTTP execution therefore does not repair invalid output.
- CLI and HTTP adapters call `usecase.NewRunner(...)` (no repairer argument).
- Therefore normal CLI/HTTP execution does not perform repair attempts today.
## Failure Behavior
## Tests To Inspect Before Changing
Stable runner sentinels include:
- `ErrInvalidRequest`
- `ErrProfileRequired`
- `ErrAPIKeyEnvMissing`
- `ErrAPIKeyRequired`
- `ErrPromptLoad`
- `ErrProfileLoad`
- `ErrArtifactLoad`
- `ErrPromptRender`
- `ErrLLMGenerate`
- `ErrValidation`
Adapters should use `errors.Is` against sentinels and lower-level repository errors instead of matching message text.
Secret values must not appear in prepared output, run results, logs, HTTP responses, or serialized public package results. The effective API-key environment-variable name may appear.
## State And Manifests
The runner is stateless across requests.
- No durable run store.
- No manifest files.
- No checkpoint, skip, or resume behavior.
- Recovery is a new request after correcting inputs, config, or environment.
## Tests To Inspect
- `internal/usecase/runner_test.go`
- `internal/usecase/integration_test.go`
- `engine_test.go`
- `internal/adapter/cli/run_test.go`
- `internal/adapter/http/handler_test.go`
## Architectural Invariants
- `Run` reuses `Prepare`; prepare logic is not duplicated.
- Effective API-key environment-variable name may appear; resolved secret value must not.
- Structured-output schema document must load before LLM call for `json_schema` mode.
- Use-case decisions stay in `internal/usecase`.
- `Run` reuses `Prepare`.
- Prompt/profile/artifact/schema loading remains behind injected boundaries.
- Validation content failures are result state; validation runtime failures are errors.
- Repair loops are bounded by `repair_attempts` and repairer presence.
- Runner stays transport-agnostic.
- Resolved secret values are never serialized or emitted.

157
docs/internal/sources.md Normal file
View File

@@ -0,0 +1,157 @@
# Source Internals
## Purpose
This document covers implemented prompt, profile, schema, artifact, and catalog source behavior. It is for developers changing loaders or source wiring.
Full user-facing YAML and config reference material belongs in `docs/config.md`.
## Prompt Definition Sources
`internal/promptdef` provides directory-backed and `fs.FS` repositories.
Behavior:
- recursively scans `.yaml` and `.yml` files.
- decodes YAML with known-fields checking.
- looks up prompts by YAML `id`, not by path.
- optionally filters by prompt `version`.
- rejects duplicate matching prompt IDs.
- requires `id`, `version`, and at least one message.
- requires each message to set exactly one of `content` or `content_file`.
- resolves filesystem `content_file` values relative to the prompt YAML file.
- resolves `fs.FS` `content_file` values inside the configured source root.
- permits prompt subdirectories only as organization; they are not part of prompt identity.
For `fs.FS` roots, absolute paths and relative traversal outside the source root are rejected by catalog path helpers.
## Profile Sources
`internal/profile` provides directory-backed, `fs.FS`, and overlay repositories. `internal/profile/builtin` embeds built-in profile YAML assets and exposes them through the same repository interface.
Behavior:
- recursively scans `.yaml` and `.yml` files.
- decodes YAML with known-fields checking.
- looks up profiles by YAML `id`, not by path.
- rejects duplicate IDs inside the same source.
- rejects raw `api_key` fields in YAML; file-backed profiles must use `api_key_env`.
- validates required `endpoint` and `model` values.
- validates numeric profile ranges.
Overlay behavior:
- custom profiles are primary.
- built-in profiles are fallback.
- fallback occurs only after a primary `ErrProfileNotFound`.
- primary validation, YAML, duplicate, and raw-key errors are returned directly.
- duplicate IDs across custom and built-in sources are allowed because the custom profile overrides the built-in one.
The public Go facade can add in-memory profiles ahead of file-backed and built-in profiles.
## Schema Sources
`internal/validate` provides:
- `StandardValidator` for filesystem paths.
- `FSValidator` for `fs.FS` roots and single-file public schema sources.
Behavior:
- `json_schema` validation requires a non-empty `schema_path`.
- filesystem schema paths resolve relative to `schema_dir` unless absolute.
- directory-backed schema lookup uses the explicit `schema_path`; it does not search recursively by basename.
- `fs.FS` schema paths must remain inside the configured source root.
- single-file schema sources match by the configured file base name.
- schema documents are loaded before the LLM call for structured output.
- JSON parse failures are validation content failures.
- schema access, decode, registration, and compile failures are runtime validation errors.
## Artifact Sources
`internal/artifact` supports two input artifact reference types:
- `inline`
- `file`
Inline behavior:
- requires a non-empty body.
- produces text/plain artifacts.
- hashes the body bytes.
Direct file behavior:
- used by CLI `run`, CLI `render`, and the public Go facade.
- requires a non-empty URI.
- reads from the process filesystem without HTTP artifact-root restrictions.
- infers content type from file extension, defaulting to text/plain.
Restricted file behavior:
- used by HTTP `serve`.
- allows inline artifacts even when no artifact root is configured.
- denies file artifacts when no artifact root is configured.
- resolves relative file URIs against `server.artifact_root`.
- accepts absolute file URIs only when they pass containment checks.
- applies `server.max_artifact_bytes` when configured.
Restricted containment is lexical. It cleans paths and checks the relative path against the configured root; it does not resolve symlinks. Symlinks inside the root are followed by the operating system, including symlinks that target files outside the root.
## Catalog Helpers
`internal/filecatalog` centralizes shared source helpers:
- recursive YAML discovery for filesystem and `fs.FS` roots.
- deterministic sorting.
- `.yaml` and `.yml` filtering.
- display paths for diagnostics.
- YAML file stems.
- `fs.FS` root cleaning and containment checks.
Repository code should use these helpers instead of reimplementing path traversal and containment rules.
## Failure Behavior
Common source failures:
- missing prompt/profile/schema/artifact files.
- invalid YAML or JSON.
- unknown YAML fields.
- duplicate prompt or profile IDs.
- prompt/profile validation errors.
- raw API key fields in profile YAML.
- unsupported artifact reference type.
- missing inline body or file URI.
- artifact outside HTTP root.
- artifact exceeding HTTP size limit.
- schema load or compile failure.
Prompt/profile repository lookup errors are mapped by adapters separately from runtime runner errors. Validation content failures remain result state; source and schema runtime failures return errors.
## State And Manifests
Source packages do not persist run state.
- No manifests are read or written.
- No source package implements skip or resume behavior.
- Source reads reflect the current filesystem or `fs.FS` state for each request.
## Tests To Inspect
- `internal/promptdef/repository_test.go`
- `internal/profile/repository_test.go`
- `internal/profile/builtin/repository_test.go`
- `internal/artifact/reader_test.go`
- `internal/validate/standard_validator_test.go`
- `internal/usecase/integration_test.go`
- `engine_test.go`
## Architectural Invariants
- Prompt/profile identity comes from YAML `id`.
- External YAML decoding remains strict.
- File-backed profile YAML never accepts raw API key values.
- Built-in profiles are fallback, not a replacement for custom source validation.
- HTTP file artifacts remain rooted by lexical containment.
- Schema runtime failures remain errors, while JSON/schema content mismatches remain validation results.

View File

@@ -2,124 +2,163 @@
## Scope
This document covers day-to-day operation of the CLI and HTTP service for currently implemented behavior.
For command syntax, see [CLI reference](cli.md). For file formats and defaults, see [Configuration reference](config.md).
This guide covers operating the implemented CLI commands and HTTP service. It
does not replace the [CLI reference](cli.md), [Configuration reference](config.md),
or [HTTP API reference](api.md).
## Operational Model
Scriptorium executes one request at a time per CLI invocation or HTTP request.
Scriptorium executes one prompt request per CLI invocation or HTTP request.
Important boundaries:
- No durable run state is stored.
- No built-in resume, checkpoint, archive, or backup workflow exists.
- Recovery is rerun-based: fix inputs/config, then rerun.
- No manifest, archive, checkpoint, or built-in backup workflow is written.
- No built-in resume behavior exists.
- Recovery is rerun-based: correct inputs, config, or environment, then run again.
## Filesystem Layout And Config
## Filesystem Layout
Scriptorium depends on:
Operational deployments usually provide:
- prompt definition files (`prompt_dir`)
- execution profile files (`profile_dir`)
- optional JSON schemas (`schema_dir`)
- `prompt_dir`: prompt definition YAML files and adjacent `content_file` templates.
- `profile_dir`: optional custom profile YAML files.
- `schema_dir`: optional JSON Schema files.
- `server.artifact_root`: optional HTTP file-input root for `serve`.
Config discovery order when `--config` is omitted:
1. `/usr/local/etc/scriptorium/config.yml`
2. `/etc/scriptorium/config.yml`
If neither exists, built-in defaults are used. If `--config <path>` is provided, that file must exist and parse successfully.
Built-in defaults relevant to operations:
- `schema_dir: .`
- `server.addr: :8080`
- `defaults.render_format: text`
Keep these directories readable by the Scriptorium process. Keep
`server.artifact_root` narrow and not writable by untrusted users.
## Normal CLI Workflow
Use `render` first when you need to verify prompt resolution and runtime settings without calling a model.
Use `render` before `run` when changing prompt/profile/input wiring:
Use `run` for generation.
```bash
go run ./cmd/scriptorium render \
--config ./examples/config.yml \
--prompt generic.markdown_summary \
--input transcript=./examples/fixtures/transcript.md \
--input glossary=./examples/fixtures/glossary.yml \
--format json
```
Typical sequence:
Use `run` for generation after preflight:
1. Confirm prompt/profile directories resolve through config or flags.
2. Confirm required input files exist and map to prompt input names.
3. Confirm required API-key environment variables are set.
4. Confirm the selected profile's model endpoint is reachable from the process environment.
5. Run `render` for preflight when changing prompt/profile/input wiring.
6. Run `run` for actual generation.
```bash
go run ./cmd/scriptorium run \
--config ./examples/config.yml \
--prompt generic.markdown_summary \
--input transcript=./examples/fixtures/transcript.md \
--input glossary=./examples/fixtures/glossary.yml \
--out ./summary.md
```
## Secrets Handling
Before production runs, confirm:
Raw API keys are not accepted in config files, profile files as `api_key`, CLI flags, or HTTP request bodies.
Operational pattern:
- Set environment variables that hold secret values.
- Set profile `api_key_env` (or runtime override `api_key_env`) to the environment variable name.
- Keep process environments scoped to only required variables.
- the effective config path is the intended one;
- prompt/profile/schema directories are readable;
- input file paths exist and match prompt input names;
- required API-key environment variables are set;
- the selected model endpoint is reachable from the process environment.
## HTTP Service Operation
Start service with:
Start the service with:
```bash
go run ./cmd/scriptorium serve --config ./examples/config.yml
```
Current inbound API behavior:
The implemented HTTP route is `POST /v1/runs`; request and response fields are
defined in the [HTTP API reference](api.md).
- Route: `POST /v1/runs`
- JSON request parsing rejects unknown fields.
- Validation content failures still return `200 OK` with `validation.status: "failed"`.
The maintained HTTP request-shape example is `examples/http-run.json`.
Security caveat:
HTTP service notes:
- Unknown JSON fields are rejected.
- `inline` input references work without an artifact root.
- `file` input references require `server.artifact_root` or `serve --artifact-root`.
- Request bodies, HTTP file input artifacts, and encoded JSON responses are size-limited.
- Validation content failures return `200 OK` with `validation.status: "failed"`.
Security boundary:
- `serve` has no built-in authentication or authorization.
- Deploy only behind trusted controls (private network boundary, authenticated reverse proxy, API gateway, or equivalent).
- Put it behind trusted controls such as a private network, authenticated reverse proxy, or API gateway.
- Do not expose an artifact root containing unrelated sensitive files.
- Symlinks inside the artifact root are followed by the operating system.
## Secrets Handling
Raw API keys are not accepted in app config, profiles, CLI flags, or HTTP
request bodies.
Use this pattern:
1. Set an environment variable containing the secret value.
2. Store only the variable name in profile `api_key_env` or request override `api_key_env`.
3. Scope the process environment to the minimum required variables.
## Output, Logs, And Exit Codes
`run` command:
`run`:
- Generated artifact body goes to stdout by default.
- `--out` writes generated artifact to a file.
- Summary metadata line is written to stderr on success.
- Exit code `2` means generation completed but validation failed.
- stdout: generated artifact body unless `--out` is used.
- stderr: summary on success, errors on failure.
- exit `2`: generation completed and output was written, but validation failed.
`render` command:
`render`:
- Prepared-run output goes to stdout by default.
- `--out` writes prepared-run output to a file.
- Exit code is `0` on success and `1` on failure.
- stdout: prepared-run output unless `--out` is used.
- stderr: errors.
- exit `0` on success, `1` on failure.
`serve` command:
`serve`:
- Startup and server errors are written to stderr.
- stderr: startup and server errors.
- HTTP response body: JSON success or error envelope.
## Validation Behavior In Operations
## Validation Behavior
Validation modes (`none`, `basic`, `json`, `json_schema`) are defined by prompt output contract.
Prompt `output.validation_mode` controls validation:
Operational interpretation:
- `none`: skipped.
- `basic`: output body must not be empty.
- `json`: output body must parse as JSON.
- `json_schema`: output body must parse as JSON and satisfy the configured schema.
- Validation runtime errors are hard failures (`run` exit `1`; HTTP error response).
- Validation content failures are soft failures (`run` exit `2`; HTTP `200` with failed status).
Runtime/schema failures are hard failures (`run` exit `1`, HTTP error).
Generated-content validation failures are soft failures (`run` exit `2`, HTTP
`200 OK` with failed validation status).
A failed validation run can still produce output. Decide whether to keep or discard that output in your surrounding workflow.
## Size Limits
## Safe Recovery Steps
Defaults are documented in [Configuration reference](config.md). Operationally:
For failed runs or requests:
- Keep default HTTP limits unless larger payloads are measured and expected.
- Prefer `inline` HTTP inputs for small payloads.
- Prefer `file` HTTP inputs for larger local artifacts under a controlled artifact root.
- Increase `server.max_response_bytes` when generated artifacts or requested raw output are expected to be large.
- Use `0` only when another trusted layer enforces size limits.
1. Capture stderr output or HTTP error code/message.
2. Confirm config path and directory settings.
3. Verify prompt/profile IDs and input mappings.
4. Verify API-key environment-variable presence when required.
5. Reproduce with `render --format json` when prompt/profile/input resolution is uncertain.
## Maintained Examples
- `examples/config.yml`
- `examples/config.full.yml`
- `examples/render-markdown-summary.sh`
- `examples/http-run.json`
## Safe Recovery
For failed CLI commands or HTTP requests:
1. Capture stderr or the HTTP error `code` and `message`.
2. Confirm config path and effective directory settings.
3. Verify prompt ID, profile ID, schema path, and input mappings.
4. Verify required API-key environment variables.
5. Reproduce with `render --format json` when pre-LLM resolution is uncertain.
6. Rerun after correction.
Because Scriptorium does not persist run state, rerun is the canonical recovery path.
Because Scriptorium does not persist run state, rerun is the supported recovery
path.

View File

@@ -11,6 +11,7 @@ Scriptorium is a narrow prompt-execution application with three entry paths:
- CLI `run`
- CLI `render`
- HTTP `POST /v1/runs` through `serve`
- public Go package `gitea.maximumdirect.net/eric/scriptorium`
Domain behavior is centralized in `internal/usecase` and `internal/domain`.
@@ -26,6 +27,7 @@ Domain behavior is centralized in `internal/usecase` and `internal/domain`.
Current package map:
- root package `scriptorium`: public Go facade over engine construction, source options, request/result types, and error mapping.
- `cmd/scriptorium`: process entrypoint.
- `internal/adapter/cli`: command parsing, app wiring for CLI commands, output behavior.
- `internal/adapter/http`: HTTP DTO mapping and error/status mapping.
@@ -34,7 +36,9 @@ Current package map:
- `internal/domain`: core request/result and contract types.
- `internal/usecase`: `Runner` prepare/run orchestration and repair-hook boundary.
- `internal/promptdef`: filesystem prompt-definition repository.
- `internal/profile`: filesystem execution-profile repository.
- `internal/profile`: filesystem, `fs.FS`, and overlay execution-profile repositories.
- `internal/profile/builtin`: embedded built-in execution profiles.
- `internal/filecatalog`: shared YAML discovery and `fs.FS` source helpers.
- `internal/artifact`: artifact reference readers.
- `internal/prompt`: template renderer.
- `internal/llm`: provider-neutral LLM client interface and OpenAI-compatible implementation.
@@ -45,6 +49,7 @@ Detailed component behavior is documented in:
- `docs/internal/runner.md`
- `docs/internal/adapters.md`
- `docs/internal/sources.md`
## Configuration And Precedence
@@ -69,9 +74,10 @@ Scriptorium has no durable run-state store.
Current external contracts:
- inbound HTTP contract: `POST /v1/runs`
- inbound HTTP contract: `POST /v1/runs`, documented canonically in `docs/api.md`
- outbound model contract: OpenAI-compatible chat completions subset
- subprocess contract for integrators: CLI `run`/`render`
- public Go package contract: `docs/consumers/pkg-scriptorium.md`
Integration docs belong under `docs/integrations/`.

View File

@@ -4,6 +4,7 @@ This document defines contributor workflow for Scriptorium.
## Repository Layout
- root package `scriptorium`: public Go facade, options, types, and error mapping.
- `cmd/scriptorium`: application entrypoint.
- `internal/domain`: core contracts.
- `internal/usecase`: runner orchestration.
@@ -13,6 +14,8 @@ This document defines contributor workflow for Scriptorium.
- `internal/defaults`: default constants.
- `internal/promptdef`: prompt-definition repository.
- `internal/profile`: execution-profile repository.
- `internal/profile/builtin`: embedded built-in execution profiles.
- `internal/filecatalog`: shared source discovery and path helpers.
- `internal/artifact`: artifact readers.
- `internal/prompt`: prompt rendering.
- `internal/llm`: LLM client interface and OpenAI-compatible implementation.
@@ -38,7 +41,9 @@ go test ./...
Targeted test runs commonly used during changes:
```bash
go test .
go test ./internal/adapter/cli ./internal/adapter/http ./internal/usecase
go test ./internal/...
```
## Coding Conventions
@@ -82,7 +87,8 @@ go test ./internal/adapter/cli ./internal/adapter/http ./internal/usecase
3. Keep business decisions in `internal/usecase`.
4. Add focused adapter tests for mapping, parse, and error behavior.
5. Document the new/changed boundary in `docs/internal/adapters.md`.
6. If external contract changes, update `docs/integrations/` in the same change.
6. If source-loading behavior changes, update `docs/internal/sources.md`.
7. If an external contract changes, update the canonical public or integration doc in the same change.
## How To Update Prompt/Profile/Schema Assets
@@ -99,5 +105,6 @@ When behavior changes:
2. Keep non-roadmap docs limited to implemented behavior.
3. Update links after file moves/renames.
4. Re-run relevant tests and smoke commands.
5. For internal boundary docs, check references with `rg "docs/internal|internal/sources" docs/policy docs/internal`.
Docs work is complete only when code/tests/examples/docs agree.

View File

@@ -1,321 +0,0 @@
# Library API Implementation Plan
This plan implements the target state in `docs/roadmap/library.md`.
Audience: LLM coding agents implementing the feature in order. Follow `docs/policy/architecture.md`, `docs/policy/development.md`, and `docs/policy/documentation.md` before changing code.
## Constraints
- Add a public root package named `scriptorium`; keep existing `internal/*` packages internal.
- Define public facade types and convert to/from internal domain types. Do not alias internal domain types as the public API.
- Do not rewire CLI or HTTP through the public facade in this implementation.
- Preserve current CLI, HTTP, prompt/profile loading, validation, secret-handling, and outbound LLM behavior.
- Do not add dependencies.
- Keep each stage passing `go test ./...` before moving to the next stage.
## Stage 1: Public Types, Engine Construction, And Prepare
Goal: make prompt preparation usable from an imported root package without calling an LLM.
### Public Package
Create Go files at the module root using:
```go
package scriptorium
```
Expose:
```go
type Engine struct { /* unexported fields */ }
type Config struct {
PromptDir string
ProfileDir string
SchemaDir string
Timeout time.Duration
HTTPClient *http.Client
}
type Option func(*engineOptions) error
func NewEngine(cfg Config, opts ...Option) (*Engine, error)
func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, error)
```
Construction rules:
- `PromptDir` and `ProfileDir` are required.
- `SchemaDir` defaults to the same built-in default used by app config.
- `Timeout`, when non-zero, configures the default OpenAI-compatible client timeout.
- `HTTPClient`, when non-nil, is used by the default OpenAI-compatible client.
- `NewEngine` wires the same internal components used by CLI/HTTP: filesystem prompt/profile repositories, composite artifact reader, Go template renderer, standard validator, and OpenAI-compatible LLM client.
- Return public `ErrInvalidConfig` for invalid engine configuration.
### Public Types
Define public facade types with exported fields:
- `RunRequest`
- `PreparedRun`
- `ArtifactRef`
- `Artifact`
- `ExecutionTarget`
- `ExecutionTargetOverride`
- `ExecutionTargetPresence`
- `OutputContract`
- `ValidationResult`
- `TokenUsage`
- `RenderedMessage`
- `CacheControl`
- `StructuredOutputSpec`
Use the same enum string values as internal domain types for formats, validation modes, validation statuses, artifact ref types, cache-control type, and structured-output type.
Required request shape:
```go
type RunRequest struct {
PromptID string
PromptVersion string
ProfileID string
Inputs map[string]ArtifactRef
Vars map[string]string
Execution *ExecutionTargetOverride
Validation *OutputContract
Metadata map[string]string
}
```
`ExecutionTargetOverride` must preserve numeric override presence using pointer fields:
```go
Temperature *float64
MaxTokens *int
TopP *float64
TimeoutSeconds *int
```
`PreparedRun` should include the same user-observable fields as internal `domain.PreparedRun`, but should not expose internal-only target presence metadata.
### Input Helpers
Expose:
```go
func File(path string) ArtifactRef
func Inline(body string) ArtifactRef
func InlineWithURI(uri string, body string) ArtifactRef
```
Mapping:
- `File(path)` maps to artifact type `file` with `URI: path`.
- `Inline(body)` maps to artifact type `inline` with `Body: body`.
- `InlineWithURI(uri, body)` maps to artifact type `inline` with both fields set.
### Conversion Layer
Implement unexported conversion helpers in the public package:
- public run request to internal `domain.RunRequest`
- internal `domain.PreparedRun` to public `PreparedRun`
- internal artifacts/messages/contracts/validation/usage/structured-output to public equivalents
- public execution override to internal `domain.ExecutionTargetOverride`
Conversions must deep-copy maps and slices that cross the public/internal boundary.
### Tests
Add root package tests.
Required tests:
- `NewEngine` rejects missing `PromptDir`.
- `NewEngine` rejects missing `ProfileDir`.
- `Prepare` works with `examples/config.yml` directories when passed directly through `Config`.
- `Prepare` works with `File` input refs.
- `Prepare` works with `Inline` input refs.
- `Prepare` output does not expose raw API-key values or internal target presence metadata in JSON.
- Explicit zero execution overrides survive into prepared effective settings.
### Verification
Run:
```bash
go test ./...
```
## Stage 2: Run, LLM Injection, And Public Errors
Goal: make full execution usable and testable without real provider credentials.
### Public Run Method
Expose:
```go
func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error)
```
`RunResult` should expose:
- run ID
- artifact
- raw output
- validation result
- prompt/profile/model metadata
- effective model params
- input hashes
- token/cache usage
- start/end/duration timing
Do not expose raw API-key values.
### Public LLM Injection
Expose:
```go
type LLMClient interface {
Generate(context.Context, GenerateRequest) (*GenerateResponse, error)
}
func WithLLMClient(client LLMClient) Option
```
Public `GenerateRequest` must include:
- rendered prompt
- effective execution target
- execution target presence
- structured-output spec
Public `GenerateResponse` must include:
- content
- token usage
Implementation rule:
- `WithLLMClient` wraps the public client in an unexported adapter that satisfies `internal/llm.Client`.
- The adapter converts internal generate requests to public generate requests and converts public generate responses back to internal responses.
- A nil client passed to `WithLLMClient` returns `ErrInvalidConfig`.
Default behavior:
- If no custom LLM client is supplied, `NewEngine` uses `internal/llm.NewOpenAICompatibleClient`.
- `Config.Timeout` and `Config.HTTPClient` apply only to the default OpenAI-compatible client.
### Public Errors
Define public sentinel errors:
- `ErrInvalidConfig`
- `ErrInvalidRequest`
- `ErrPromptNotFound`
- `ErrProfileNotFound`
- `ErrPromptLoad`
- `ErrProfileLoad`
- `ErrArtifactLoad`
- `ErrPromptRender`
- `ErrLLMGenerate`
- `ErrValidation`
Public methods must map internal errors to public sentinels while preserving wrapped context. Callers must be able to use `errors.Is`.
Mapping rules:
- missing/invalid public engine config -> `ErrInvalidConfig`
- internal `usecase.ErrInvalidRequest` -> `ErrInvalidRequest`
- internal prompt not found -> `ErrPromptNotFound`
- internal profile not found -> `ErrProfileNotFound`
- internal prompt load errors -> `ErrPromptLoad`
- internal profile load errors -> `ErrProfileLoad`
- internal artifact load errors -> `ErrArtifactLoad`
- internal prompt render errors -> `ErrPromptRender`
- internal LLM generate errors -> `ErrLLMGenerate`
- internal validation runtime errors -> `ErrValidation`
Do not expose internal sentinel values as public API.
### Tests
Required tests:
- `Run` succeeds with `WithLLMClient` fake and returns typed artifact, raw output, validation, metadata, and usage.
- `Run` passes rendered prompt, effective execution target, and target presence to the injected LLM client.
- `Run` validation failure returns a successful result with failed validation, not an error.
- public errors support `errors.Is` for invalid request, prompt not found, profile not found, artifact load, render failure, LLM failure, and validation runtime failure.
- nil `WithLLMClient(nil)` returns `ErrInvalidConfig`.
- default OpenAI-compatible client can still be constructed without real provider credentials.
### Verification
Run:
```bash
go test ./...
```
## Stage 3: Public Documentation And Consumer Examples
Goal: document implemented library behavior in canonical public-consumer docs.
### Docs
After Stages 1 and 2 are implemented, update:
- `README.md`: add a short link to library usage without turning the README into a manual.
- `docs/internal/adapters.md`: list the public library facade as an implemented adapter surface.
- `docs/consumers/api.md`: describe the public consumer API at a high level.
- `docs/consumers/pkg-scriptorium.md`: document the root package usage, types, errors, and examples.
Create `docs/consumers/` if it does not exist.
Do not document unimplemented future library features outside `docs/roadmap/`.
### Examples
Add copyable library examples only if they can be tested without real credentials.
Recommended example:
- `examples/go-library/prepare/main.go` or equivalent prepare-only example using `examples/` prompt/profile/fixture assets.
If adding a run example, it must use an injected fake LLM client and must not require provider credentials.
### Tests
Required tests:
- doc/example smoke coverage for any added Go example using `go test` or `go test ./...`.
- existing CLI/HTTP tests continue to pass unchanged.
### Verification
Run:
```bash
go test ./...
go run ./cmd/scriptorium render \
--config ./examples/config.yml \
--prompt generic.markdown_summary \
--input transcript=./examples/fixtures/transcript.md \
--input glossary=./examples/fixtures/glossary.yml \
--format json
```
## Final Checks
Before considering the feature complete:
1. Confirm the root package can be imported as `gitea.maximumdirect.net/eric/scriptorium`.
2. Confirm public package tests do not require real provider credentials.
3. Confirm `go test ./...` passes.
4. Confirm the render smoke command passes.
5. Confirm non-roadmap docs describe only implemented behavior.
6. Confirm no public result or rendered/prepared output exposes raw API-key values.
7. Confirm `git diff` does not include unrelated CLI/HTTP behavior changes.

View File

@@ -1,134 +0,0 @@
# Library API Roadmap
This roadmap defines the target behavior for making Scriptorium usable as an imported Go library while retaining the current standalone CLI and HTTP application behavior.
The implementation plan for this feature lives in `docs/roadmap/implementation.md`.
## Motivation
Scriptorium is currently optimized for subprocess use by other applications. That contract remains useful because it is language-neutral, operationally simple, and process-isolated.
For Go callers, an imported library should provide:
- typed requests and results instead of stdout/stderr parsing;
- direct `context.Context` cancellation;
- lower overhead for repeated calls;
- easier test integration through injected clients or fixtures;
- direct access to prepared-run data without process management;
- fewer integration points where secrets or output metadata can be mishandled.
The library is an additional adapter surface, not a replacement for the CLI or HTTP API.
## Target State
Scriptorium should expose a small public Go API suitable for common embedding use cases:
- construct an engine from app-level settings such as prompt, profile, and schema directories;
- prepare a prompt request without calling an LLM;
- run a prompt request and receive a typed result;
- pass file and inline artifacts;
- apply profile selection, runtime overrides, vars, validation behavior, cache-control behavior, and structured-output behavior consistently with CLI/HTTP;
- inject a custom LLM client or HTTP client where needed;
- preserve existing CLI and HTTP behavior by continuing to route all entry paths through the same use-case layer.
The public library API should be stable, narrow, and intentionally higher-level than the current `internal/*` package layout.
## Public Package Policy
The public package should be the module root:
```go
import "gitea.maximumdirect.net/eric/scriptorium"
```
Recommended usage shape:
```go
engine, err := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./prompts",
ProfileDir: "./profiles",
SchemaDir: "./schemas",
})
if err != nil {
return err
}
prepared, err := engine.Prepare(ctx, scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.File("./transcript.md"),
},
})
result, err := engine.Run(ctx, scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.File("./transcript.md"),
},
})
```
## Policy Decisions
### Public Package Scope
Expose a narrow root facade package and keep existing `internal/*` packages internal.
Reasoning:
This gives callers the workflow they need without freezing the internal architecture as public API. It also preserves the current package-boundary policy and keeps future refactoring possible.
### Public Type Strategy
Define public facade types and map them to internal domain types.
Reasoning:
Public types can be designed around caller needs and long-term stability. Internal types can continue to evolve with implementation details such as adapter metadata, validation internals, and provider-specific behavior.
### CLI And HTTP Reuse
Keep CLI and HTTP on current internal wiring for the initial library release. Consider migrating them to the public facade only after the facade proves stable.
Reasoning:
This minimizes risk to the existing subprocess and HTTP contracts while adding the new API. It also avoids forcing the first public facade to satisfy every adapter edge case immediately.
### Error Surface
Expose public sentinel errors or typed error categories and map internal errors to them while preserving wrapped context.
Reasoning:
Library callers need stable, idiomatic error checks. Mapping internal errors avoids exposing internal package paths as public compatibility promises.
## Scope
In scope:
- Public facade package for library consumers.
- Public request, result, prepared-run, artifact reference, execution override, validation, and config types.
- Public constructors for common file and inline input references.
- Public engine methods for `Prepare` and `Run`.
- Optional dependency injection for LLM behavior and HTTP behavior.
- Stable error behavior suitable for `errors.Is` and `errors.As`.
- Tests proving public API behavior matches CLI/use-case behavior.
- Documentation and examples for library usage after implementation.
Out of scope for the first library release:
- Making every `internal/*` package public.
- Replacing or rewiring the CLI or HTTP adapters.
- Adding a durable run store or workflow engine.
- Adding broad provider-specific SDK surfaces.
- Adding non-Go language bindings.
- Adding global mutable configuration.
## Acceptance Criteria
- A Go caller can import the root module and run a prompt without invoking a subprocess.
- A Go caller can prepare a prompt without invoking an LLM.
- Public library behavior matches current CLI/HTTP use-case semantics for prompt/profile loading, artifact reading, rendering, validation, and model invocation.
- Existing CLI and HTTP behavior remains unchanged.
- Library tests use injected/fake LLM behavior and do not require real provider credentials.
- Public documentation is concise and limited to implemented behavior once code exists.

View File

@@ -1,19 +1,25 @@
# Troubleshooting
This guide lists recurring implemented failure modes and safe fixes.
This guide lists common implemented failure modes and safe fixes.
For command syntax, see [CLI reference](cli.md). For configuration and file formats, see [Configuration reference](config.md). For operational behavior, see [Operations guide](operations.md).
Canonical references:
## Missing Or Invalid Config File
- [CLI reference](cli.md)
- [Configuration reference](config.md)
- [HTTP API reference](api.md)
- [Operations guide](operations.md)
## Missing Or Invalid Config
Symptom:
- CLI errors such as `application config error: config file not found` or `invalid config YAML`.
- CLI error includes `application config error`, `config file not found`, `invalid config YAML`, or `invalid config`.
Likely cause:
- `--config` points to a missing file.
- Config YAML has syntax errors or unknown fields.
- YAML syntax is invalid.
- Config contains unknown fields or negative HTTP size limits.
Diagnostic step:
@@ -23,40 +29,34 @@ go run ./cmd/scriptorium render --config /path/to/config.yml --prompt generic.ma
Safe fix:
- Correct file path.
- Remove unknown fields.
- Correct the config path.
- Fix YAML syntax.
- Keep secrets out of config.
- Remove unknown fields.
- Keep raw secrets out of config.
Relevant links:
Relevant links: [Configuration reference](config.md), [CLI reference](cli.md)
- [Configuration reference](config.md)
- [CLI reference](cli.md)
## Missing Prompt/Profile Directory Settings
## Missing Prompt Directory
Symptom:
- CLI parse errors saying prompt directory or profile directory is required.
- CLI parse error says the prompt directory is required.
Likely cause:
- Neither CLI flags nor config provide effective `prompt_dir` / `profile_dir`.
- Neither config nor CLI flags provide an effective `prompt_dir`.
Diagnostic step:
- Run the failing command with explicit `--prompt-dir` and `--profile-dir` once to verify.
- Re-run once with explicit `--prompt-dir`.
Safe fix:
- Set `prompt_dir` and `profile_dir` in config, or always pass both flags.
- Set `prompt_dir` in config or pass `--prompt-dir`.
Relevant links:
Relevant links: [Configuration reference](config.md), [CLI reference](cli.md)
- [Configuration reference](config.md)
- [CLI reference](cli.md)
## Unknown Or Unsupported Flags
## Unknown Flags
Symptom:
@@ -64,33 +64,33 @@ Symptom:
Likely cause:
- Typo or command mismatch (for example, `serve` with runtime model override flags).
- Typo.
- Flag is valid for another command.
- `serve` was given runtime model override flags.
Diagnostic step:
- Compare command against the command-specific flag list.
- Compare the command with the command-specific flag list.
Safe fix:
- Remove unsupported flags.
- Use `run`/`render` for runtime model overrides.
- Use `run` or `render` for runtime model overrides.
Relevant links:
Relevant links: [CLI reference](cli.md)
- [CLI reference](cli.md)
## Prompt Definition Load Failures
## Prompt Load Failures
Symptom:
- CLI run/render error from prompt loading.
- HTTP `404 prompt_not_found` or `400 prompt_load_failed`.
- CLI run/render fails during prompt loading.
- HTTP returns `404 prompt_not_found` or `400 prompt_load_failed`.
Likely cause:
- Prompt ID not found.
- Invalid prompt YAML.
- Invalid prompt contract (for example bad validation mode, message content/content_file rule violation, missing schema path for `json_schema`).
- Prompt ID/version does not exist.
- Prompt YAML is invalid or has unknown fields.
- Prompt contract is invalid, such as missing messages, invalid output mode, bad `content_file`, or missing `schema_path` for `json_schema`.
Diagnostic step:
@@ -100,28 +100,25 @@ go run ./cmd/scriptorium render --config ./examples/config.yml --prompt <prompt-
Safe fix:
- Correct prompt ID.
- Fix prompt YAML and contract fields.
- Ensure referenced `content_file` paths exist.
- Correct prompt ID/version.
- Fix prompt YAML and referenced `content_file` paths.
- Fix output contract fields.
Relevant links:
Relevant links: [Configuration reference](config.md), [CLI reference](cli.md)
- [Configuration reference](config.md)
- [CLI reference](cli.md)
## Profile Definition Load Failures
## Profile Load Failures
Symptom:
- CLI run/render error from profile loading.
- HTTP `404 profile_not_found` or `400 profile_load_failed`.
- CLI run/render fails during profile loading.
- HTTP returns `404 profile_not_found`, `400 profile_load_failed`, or `400 profile_required`.
Likely cause:
- Profile ID missing/not found.
- Invalid profile YAML.
- Invalid profile values.
- Raw `api_key` field present (rejected).
- Profile ID does not exist.
- Request omitted profile and prompt has no `default_profile`.
- Profile YAML is invalid or has unknown fields.
- Profile contains raw `api_key`.
Diagnostic step:
@@ -131,78 +128,52 @@ go run ./cmd/scriptorium render --config ./examples/config.yml --prompt generic.
Safe fix:
- Correct profile ID.
- Correct profile ID or prompt `default_profile`.
- Fix profile YAML and value ranges.
- Replace `api_key` with `api_key_env`.
- Replace raw `api_key` with `api_key_env`.
Relevant links:
Relevant links: [Configuration reference](config.md), [CLI reference](cli.md)
- [Configuration reference](config.md)
- [CLI reference](cli.md)
## Input Artifact Read Failures
## Input Artifact Failures
Symptom:
- CLI run/render error reading input artifacts.
- HTTP `400 artifact_read_failed`.
- CLI run/render fails while reading inputs.
- HTTP returns `400 artifact_read_failed`, `400 artifact_not_allowed`, or `413 artifact_too_large`.
Likely cause:
- File path in input mapping does not exist or is unreadable.
- Unsupported artifact reference type in HTTP request.
- Input file path is missing or unreadable.
- HTTP input type is unsupported or missing required fields.
- HTTP file refs are disabled because no artifact root is configured.
- HTTP file path is lexically outside the artifact root.
- HTTP file input exceeds `server.max_artifact_bytes`.
Diagnostic step:
- Verify every mapped file path exists and is readable by the process.
- For HTTP, verify each input uses supported `type` values.
- Verify each input path exists and is readable by the process.
- For HTTP, verify input refs use `file` or `inline`.
- For HTTP file refs, verify the artifact root and compare file size to `server.max_artifact_bytes`.
Safe fix:
- Correct file paths and permissions.
- Use supported input types (`file`, `inline`).
- Correct paths and permissions.
- Configure a narrow artifact root for HTTP file refs.
- Use relative paths under the artifact root or switch to `inline`.
- Increase `server.max_artifact_bytes` only for expected larger inputs.
Relevant links:
- [CLI reference](cli.md)
- [Configuration reference](config.md)
## Prompt Template Render Failures
Symptom:
- CLI run/render error from prompt rendering.
- HTTP `400 prompt_render_failed`.
Likely cause:
- Template references missing input names.
- Template syntax or data reference issues.
Diagnostic step:
- Run `render --format json` with the same prompt, inputs, vars, and profile selection.
Safe fix:
- Align template `{{input "name"}}` references with actual input mappings.
- Fix template syntax and variable names.
Relevant links:
- [CLI reference](cli.md)
- [Configuration reference](config.md)
Relevant links: [HTTP API reference](api.md), [Configuration reference](config.md)
## Missing API-Key Environment Variable
Symptom:
- CLI run/render invalid request error about missing API-key environment variable.
- HTTP `400 api_key_env_missing`.
- CLI render/run fails with an API-key environment error.
- HTTP returns `400 api_key_env_missing`.
Likely cause:
- Selected profile or override sets `api_key_env`, but that environment variable is unset/empty.
- Selected profile or runtime override sets `api_key_env`, but the environment variable is unset or empty.
Diagnostic step:
@@ -212,85 +183,68 @@ printenv SCRIPTORIUM_API_KEY
Safe fix:
- Set the required environment variable before invoking CLI/service.
- Or use a profile that does not require API key auth for the target endpoint.
- Set the required environment variable before starting the CLI command or HTTP service.
- Or use a profile that does not require provider API-key auth.
Relevant links:
Relevant links: [Configuration reference](config.md), [Operations guide](operations.md)
- [Configuration reference](config.md)
- [Operations guide](operations.md)
## Prompt Template Render Failures
Symptom:
- CLI render/run fails during prompt rendering.
- HTTP returns `400 prompt_render_failed`.
Likely cause:
- Template references an input that was not supplied.
- Template syntax or variable reference is invalid.
Diagnostic step:
- Run `render --format json` with the same prompt, inputs, vars, and profile.
Safe fix:
- Align `{{input "name"}}` references with request input names.
- Fix template syntax and variable names.
Relevant links: [Configuration reference](config.md), [CLI reference](cli.md)
## LLM Request Failures
Symptom:
- CLI `run` fails with LLM generation errors.
- CLI `run` fails during generation.
- HTTP returns `502 llm_failed`.
Likely cause:
- Endpoint unreachable.
- Non-2xx response from provider.
- Timeout.
- Malformed provider response.
- Endpoint is unreachable.
- Provider returns non-2xx.
- Request times out.
- Provider response is malformed.
Diagnostic step:
- Confirm endpoint URL and model in selected profile/overrides.
- Retry with `render` first to confirm pre-LLM preparation works.
- Check provider/network logs for non-2xx responses and timeouts.
- Run `render` first to confirm pre-LLM preparation works.
- Check selected endpoint/model in prepared output.
- Check network/provider logs for timeout or non-2xx details.
Safe fix:
- Correct endpoint/model settings.
- Adjust timeout if needed.
- Resolve provider-side or network issues.
- Correct endpoint/model/profile settings.
- Adjust timeout when appropriate.
- Resolve provider or network issue.
Relevant links:
Relevant links: [Operations guide](operations.md), [Configuration reference](config.md)
- [CLI reference](cli.md)
- [Configuration reference](config.md)
- [Operations guide](operations.md)
## Prompt Cache Misses Or No Cache Usage
## Validation Failed
Symptom:
- CLI run summary omits `cached_tokens` / `cache_write_tokens`.
- HTTP `metadata.usage.cached_tokens` and `metadata.usage.cache_write_tokens` are both `0`.
- Provider cost or latency does not improve after repeated similar runs.
Likely cause:
- The selected prompt has no `messages[].cache_control`.
- Dynamic per-run input appears before the cache-controlled message and changes the provider cache key.
- The provider does not support the serialized cache-control shape for the selected model.
- The provider imposes minimum token thresholds or cache-breakpoint limits.
Diagnostic step:
- Run `render --format json` and verify the intended rendered message includes `cache_control`.
- Confirm stable reusable context appears before the cache-controlled message, with dynamic input after it.
- Check provider docs/logs for model support, minimum token thresholds, and breakpoint limits.
Safe fix:
- Move stable reusable context before the cache-controlled message.
- Move highly dynamic input after the cache breakpoint.
- Keep `cache_control.type: ephemeral` and, when using `ttl`, set `ttl: 1h`.
- Use CLI cache counters or HTTP cache usage fields to verify cache reads/writes after rerunning.
Relevant links:
- [Configuration reference](config.md)
- [OpenAI-compatible chat integration](integrations/openai-compatible-chat.md)
## Validation Status Failed (`run` Exit 2 Or HTTP 200 With Failed Status)
Symptom:
- CLI exits with code `2`.
- HTTP returns `200`, but `validation.status` is `failed`.
- CLI `run` exits `2`.
- HTTP returns `200 OK` with `validation.status` set to `failed`.
Likely cause:
@@ -298,18 +252,15 @@ Likely cause:
Diagnostic step:
- Inspect validation mode and validation errors in CLI summary/HTTP response.
- Inspect validation errors in CLI stderr or the HTTP response.
Safe fix:
- Refine prompt constraints.
- Tighten schema or adjust model/profile settings.
- Refine prompt instructions.
- Adjust schema or model/profile settings.
- Rerun after correction.
Relevant links:
- [Configuration reference](config.md)
- [Operations guide](operations.md)
Relevant links: [Operations guide](operations.md), [HTTP API reference](api.md)
## Validation Runtime Failure
@@ -320,48 +271,91 @@ Symptom:
Likely cause:
- `json_schema` schema file missing/inaccessible.
- Invalid schema JSON document.
- `json_schema` schema file is missing or unreadable.
- Schema JSON is invalid.
Diagnostic step:
- Verify `schema_dir` and `output.schema_path` resolution.
- Check schema file readability and valid JSON syntax.
- Verify `schema_dir` and prompt `output.schema_path`.
- Check schema file readability and JSON syntax.
Safe fix:
- Correct schema path.
- Fix schema JSON content.
- Correct schema path or permissions.
- Fix schema JSON.
- Rerun.
Relevant links:
Relevant links: [Configuration reference](config.md), [Operations guide](operations.md)
- [Configuration reference](config.md)
- [Operations guide](operations.md)
## HTTP Request Parsing/Contract Errors
## HTTP JSON Or Request Contract Errors
Symptom:
- HTTP `400 invalid_json` or `400 invalid_request`.
- HTTP returns `400 invalid_json` or `400 invalid_request`.
Likely cause:
- Malformed JSON body.
- Unknown JSON fields.
- Missing required `prompt_id` or `inputs`.
- JSON body is malformed.
- Request has unknown fields or trailing JSON tokens.
- Required `prompt_id` or `inputs` is missing.
- Runtime override values are out of range.
- `extra_params` collides with reserved outbound fields.
Diagnostic step:
- Revalidate request JSON.
- Confirm required request fields are present.
- Revalidate request JSON and compare fields with the API reference.
Safe fix:
- Send valid JSON with only supported fields.
- Ensure `prompt_id` and at least one input mapping are included.
- Send one JSON object with only supported fields.
- Include `prompt_id` and at least one input.
- Use valid model override ranges.
- Remove reserved `extra_params` keys.
Relevant links:
Relevant links: [HTTP API reference](api.md)
- [Operations guide](operations.md)
- [CLI reference](cli.md)
## HTTP Size Limit Errors
Symptom:
- HTTP returns `413 request_too_large`, `413 artifact_too_large`, or `413 response_too_large`.
Likely cause:
- JSON request body exceeds `server.max_request_bytes`.
- HTTP file input exceeds `server.max_artifact_bytes`.
- Encoded JSON response exceeds `server.max_response_bytes`.
Diagnostic step:
- Compare request, file input, and expected response sizes with configured limits.
Safe fix:
- Use smaller inline inputs or switch to file inputs under the artifact root.
- Reduce generated output size.
- Omit `include_raw_output`.
- Increase limits only when the deployment expects larger payloads.
Relevant links: [HTTP API reference](api.md), [Operations guide](operations.md)
## HTTP Route Or Method Errors
Symptom:
- HTTP returns `404 not_found` or `405 method_not_allowed`.
Likely cause:
- Path is not `/v1/runs`.
- Method on `/v1/runs` is not `POST`.
Diagnostic step:
- Check the request URL and method.
Safe fix:
- Send `POST /v1/runs`.
Relevant links: [HTTP API reference](api.md)

217
engine.go
View File

@@ -4,7 +4,10 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@@ -12,6 +15,7 @@ import (
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
"gitea.maximumdirect.net/eric/scriptorium/internal/llm"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile/builtin"
"gitea.maximumdirect.net/eric/scriptorium/internal/prompt"
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
"gitea.maximumdirect.net/eric/scriptorium/internal/usecase"
@@ -48,46 +52,189 @@ type Config struct {
}
// Option customizes engine construction.
type Option func(*engineOptions) error
type Option interface {
apply(*engineOptions) error
}
type optionFunc func(*engineOptions) error
func (f optionFunc) apply(options *engineOptions) error {
return f(options)
}
type engineOptions struct {
llmClient llm.Client
llmClient llm.Client
promptDefs promptdef.Repository
profiles profile.Repository
memoryProfiles profile.Repository
validator validate.Validator
promptSource bool
profileSource bool
memorySource bool
validatorSource bool
}
// WithLLMClient injects a custom LLM client for execution.
func WithLLMClient(client LLMClient) Option {
return func(options *engineOptions) error {
return optionFunc(func(options *engineOptions) error {
if client == nil {
return ErrInvalidConfig
}
options.llmClient = publicLLMClientAdapter{client: client}
return nil
}
})
}
// WithPromptFS loads prompt definitions from fsys under root.
//
// The source uses the same strict prompt YAML rules as configured prompt
// directories, and prompt content_file paths resolve within this source.
func WithPromptFS(fsys fs.FS, root string) Option {
return optionFunc(func(options *engineOptions) error {
if fsys == nil {
return ErrInvalidConfig
}
if strings.TrimSpace(root) == "" {
return ErrInvalidConfig
}
options.promptDefs = promptdef.NewFSRepository(fsys, root)
options.promptSource = true
return nil
})
}
// WithPromptFile loads prompt definitions from the single prompt file at path.
//
// Relative prompt content_file paths resolve from the file's directory.
func WithPromptFile(path string) Option {
return optionFunc(func(options *engineOptions) error {
fsys, root, err := fileSource(path)
if err != nil {
return err
}
options.promptDefs = promptdef.NewFSRepository(fsys, root)
options.promptSource = true
return nil
})
}
// WithProfileFS loads execution profiles from fsys under root.
//
// Profiles from this source overlay built-in profiles. Profile YAML must use
// api_key_env for environment-based credentials; raw API keys are rejected.
func WithProfileFS(fsys fs.FS, root string) Option {
return optionFunc(func(options *engineOptions) error {
if fsys == nil {
return ErrInvalidConfig
}
if strings.TrimSpace(root) == "" {
return ErrInvalidConfig
}
options.profiles = profile.NewFSRepository(fsys, root)
options.profileSource = true
return nil
})
}
// WithProfileFile loads execution profiles from the single profile file at path.
//
// The profile overlays built-in profiles. Profile YAML must use api_key_env for
// environment-based credentials; raw API keys are rejected.
func WithProfileFile(path string) Option {
return optionFunc(func(options *engineOptions) error {
fsys, root, err := fileSource(path)
if err != nil {
return err
}
options.profiles = profile.NewFSRepository(fsys, root)
options.profileSource = true
return nil
})
}
// WithProfiles configures in-memory profiles that take precedence over
// configured profile files and built-in profiles.
func WithProfiles(profiles ...Profile) Option {
return optionFunc(func(options *engineOptions) error {
repo, err := newMemoryProfileRepository(profiles)
if err != nil {
return err
}
options.memoryProfiles = repo
options.memorySource = true
return nil
})
}
// WithSchemaFS loads JSON Schema documents from fsys under root.
//
// Prompt schema_path values resolve within this source when schema validation
// or structured output is requested.
func WithSchemaFS(fsys fs.FS, root string) Option {
return optionFunc(func(options *engineOptions) error {
if fsys == nil {
return ErrInvalidConfig
}
if strings.TrimSpace(root) == "" {
return ErrInvalidConfig
}
options.validator = validate.NewFSValidator(fsys, root)
options.validatorSource = true
return nil
})
}
// WithSchemaFile loads JSON Schema documents from the single schema file at path.
//
// Prompt schema_path values refer to the file's base name.
func WithSchemaFile(path string) Option {
return optionFunc(func(options *engineOptions) error {
fsys, root, err := fileSource(path)
if err != nil {
return err
}
options.validator = validate.NewFSValidator(fsys, root)
options.validatorSource = true
return nil
})
}
// NewEngine constructs an Engine using the same default internal components as
// the CLI and HTTP adapters.
func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
if strings.TrimSpace(cfg.PromptDir) == "" {
return nil, fmt.Errorf("%w: prompt directory is required", ErrInvalidConfig)
}
if strings.TrimSpace(cfg.ProfileDir) == "" {
return nil, fmt.Errorf("%w: profile directory is required", ErrInvalidConfig)
}
var options engineOptions
for _, opt := range opts {
if opt == nil {
continue
}
if err := opt(&options); err != nil {
if err := opt.apply(&options); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
}
}
schemaDir := cfg.SchemaDir
if strings.TrimSpace(schemaDir) == "" {
schemaDir = defaults.SchemaDirDefault
promptDefs := options.promptDefs
if !options.promptSource {
if strings.TrimSpace(cfg.PromptDir) == "" {
return nil, fmt.Errorf("%w: prompt directory is required", ErrInvalidConfig)
}
promptDefs = promptdef.NewFilesystemRepository(cfg.PromptDir)
}
profiles := builtin.NewRepositoryWithDirectory(cfg.ProfileDir)
if options.profileSource {
profiles = builtin.NewRepositoryWithPrimary(options.profiles)
}
if options.memorySource {
profiles = profile.NewOverlayRepository(options.memoryProfiles, profiles)
}
validator := options.validator
if !options.validatorSource {
schemaDir := cfg.SchemaDir
if strings.TrimSpace(schemaDir) == "" {
schemaDir = defaults.SchemaDirDefault
}
validator = validate.NewStandardValidator(schemaDir)
}
llmClient := options.llmClient
@@ -104,23 +251,48 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
return &Engine{
runner: usecase.NewRunner(
promptdef.NewFilesystemRepository(cfg.PromptDir),
profile.NewFilesystemRepository(cfg.ProfileDir),
promptDefs,
profiles,
artifactadapter.NewCompositeReader(),
prompt.NewGoRenderer(),
llmClient,
validate.NewStandardValidator(schemaDir),
validator,
),
}, nil
}
func fileSource(name string) (fs.FS, string, error) {
cleanName := strings.TrimSpace(name)
if cleanName == "" {
return nil, "", ErrInvalidConfig
}
dir := filepath.Dir(cleanName)
base := filepath.Base(cleanName)
if base == "." || base == string(filepath.Separator) || strings.TrimSpace(base) == "" {
return nil, "", ErrInvalidConfig
}
info, err := os.Stat(cleanName)
if err != nil {
return nil, "", fmt.Errorf("%w: failed to access source file %q: %v", ErrInvalidConfig, cleanName, err)
}
if info.IsDir() {
return nil, "", fmt.Errorf("%w: source path %q must be a file", ErrInvalidConfig, cleanName)
}
return os.DirFS(dir), filepath.ToSlash(base), nil
}
// Prepare resolves a prompt request without calling an LLM.
func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, error) {
if e == nil || e.runner == nil {
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
}
prepared, err := e.runner.Prepare(ctx, toDomainRunRequest(req))
domainReq, err := toDomainRunRequest(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
}
prepared, err := e.runner.Prepare(ctx, domainReq)
if err != nil {
return nil, mapPublicError(err)
}
@@ -133,7 +305,12 @@ func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
}
result, err := e.runner.Run(ctx, toDomainRunRequest(req))
domainReq, err := toDomainRunRequest(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
}
result, err := e.runner.Run(ctx, domainReq)
if err != nil {
return nil, mapPublicError(err)
}

File diff suppressed because it is too large Load Diff

View File

@@ -49,9 +49,13 @@ func publicErrorFor(err error) error {
return ErrPromptNotFound
case errors.Is(err, profile.ErrProfileNotFound):
return ErrProfileNotFound
case errors.Is(err, usecase.ErrPromptLoad):
return ErrPromptLoad
case errors.Is(err, usecase.ErrProfileLoad):
return ErrProfileLoad
case errors.Is(err, promptdef.ErrInvalidYAML), errors.Is(err, promptdef.ErrInvalidPromptDefinition):
return ErrPromptLoad
case errors.Is(err, profile.ErrInvalidYAML), errors.Is(err, profile.ErrInvalidProfile):
case isProfileLoadCause(err):
return ErrProfileLoad
case errors.Is(err, usecase.ErrArtifactLoad):
return ErrArtifactLoad
@@ -63,9 +67,13 @@ func publicErrorFor(err error) error {
return ErrValidation
case errors.Is(err, usecase.ErrInvalidRequest):
return ErrInvalidRequest
case errors.Is(err, usecase.ErrProfileLoad):
return ErrPromptLoad
default:
return nil
}
}
func isProfileLoadCause(err error) bool {
return errors.Is(err, profile.ErrInvalidYAML) ||
errors.Is(err, profile.ErrInvalidProfile) ||
errors.Is(err, profile.ErrRawAPIKeyNotAllowed)
}

13
examples/config.full.yml Normal file
View File

@@ -0,0 +1,13 @@
prompt_dir: ./examples/prompts
profile_dir: ./examples/profiles
schema_dir: ./examples/schemas
server:
addr: 127.0.0.1:8080
artifact_root: .
max_request_bytes: 16777216
max_artifact_bytes: 16777216
max_response_bytes: 16777216
defaults:
render_format: text

View File

@@ -4,6 +4,7 @@ schema_dir: ./examples/schemas
server:
addr: :8080
artifact_root: .
defaults:
render_format: text

51
formatting.go Normal file
View File

@@ -0,0 +1,51 @@
package scriptorium
import "fmt"
// String returns a concise request summary without exposing direct API keys.
func (r RunRequest) String() string {
return r.redactedString()
}
// GoString returns a concise request summary without exposing direct API keys.
func (r RunRequest) GoString() string {
return r.redactedString()
}
func (r RunRequest) redactedString() string {
return fmt.Sprintf(
"scriptorium.RunRequest{PromptID:%q PromptVersion:%q ProfileID:%q APIKeySet:%t Inputs:%d Vars:%d ExecutionSet:%t ValidationSet:%t Metadata:%d}",
r.PromptID,
r.PromptVersion,
r.ProfileID,
r.APIKey != "",
len(r.Inputs),
len(r.Vars),
r.Execution != nil,
r.Validation != nil,
len(r.Metadata),
)
}
// String returns a concise request summary without exposing direct API keys or
// rendered prompt content.
func (r GenerateRequest) String() string {
return r.redactedString()
}
// GoString returns a concise request summary without exposing direct API keys or
// rendered prompt content.
func (r GenerateRequest) GoString() string {
return r.redactedString()
}
func (r GenerateRequest) redactedString() string {
return fmt.Sprintf(
"scriptorium.GenerateRequest{Messages:%d Model:%q APIKeySet:%t StructuredOutputSet:%t ExtraParams:%d}",
len(r.Prompt.Messages),
r.Target.Model,
r.APIKey != "",
r.StructuredOutput != nil,
len(r.Target.ExtraParams),
)
}

View File

@@ -19,7 +19,7 @@ import (
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
"gitea.maximumdirect.net/eric/scriptorium/internal/llm"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile/builtin"
"gitea.maximumdirect.net/eric/scriptorium/internal/prompt"
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
"gitea.maximumdirect.net/eric/scriptorium/internal/usecase"
@@ -33,8 +33,7 @@ const (
)
const (
errPromptDirRequired = "prompt directory is required; provide --prompt-dir or config.yml prompt_dir"
errProfileDirRequired = "profile directory is required; provide --profile-dir or config.yml profile_dir"
errPromptDirRequired = "prompt directory is required; provide --prompt-dir or config.yml prompt_dir"
)
type runConfig struct {
@@ -75,10 +74,14 @@ type renderConfig struct {
type serveConfig struct {
configPath string
addr string
promptDir string
profileDir string
schemaDir string
addr string
promptDir string
profileDir string
schemaDir string
artifactRoot string
maxRequestBytes int64
maxArtifactBytes int64
maxResponseBytes int64
}
type commonCommandSettings struct {
@@ -86,6 +89,10 @@ type commonCommandSettings struct {
profileDir string
schemaDir string
serverAddr string
artifactRoot string
maxRequestBytes int64
maxArtifactBytes int64
maxResponseBytes int64
defaultRenderFormat renderformat.PreparedRunOutputFormat
}
@@ -203,9 +210,18 @@ func serveCommand(args []string, stderr io.Writer) int {
return ExitRuntimeError
}
runner := newRunner(cfg.promptDir, cfg.profileDir, cfg.schemaDir, llmClient)
artifactReader, err := artifactadapter.NewRestrictedCompositeReaderWithLimit(cfg.artifactRoot, cfg.maxArtifactBytes)
if err != nil {
fmt.Fprintf(stderr, "artifact root error: %v\n", err)
return ExitRuntimeError
}
h := httpadapter.NewHandler(runner)
runner := newRunnerWithArtifactReader(cfg.promptDir, cfg.profileDir, cfg.schemaDir, llmClient, artifactReader)
h := httpadapter.NewHandlerWithOptions(runner, httpadapter.HandlerOptions{
MaxRequestBytes: cfg.maxRequestBytes,
MaxResponseBytes: cfg.maxResponseBytes,
})
srv := &http.Server{
Addr: cfg.addr,
Handler: h,
@@ -282,6 +298,10 @@ func parseServeArgs(args []string) (*serveConfig, error) {
fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definition YAML files")
fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profile YAML files")
fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas")
fs.StringVar(&cfg.artifactRoot, "artifact-root", "", "base directory for HTTP file input artifacts")
fs.Int64Var(&cfg.maxRequestBytes, "max-request-bytes", 0, "maximum HTTP request body bytes; 0 disables the limit")
fs.Int64Var(&cfg.maxArtifactBytes, "max-artifact-bytes", 0, "maximum HTTP file artifact bytes; 0 disables the limit")
fs.Int64Var(&cfg.maxResponseBytes, "max-response-bytes", 0, "maximum HTTP response body bytes; 0 disables the limit")
if err := fs.Parse(args); err != nil {
return nil, err
@@ -291,10 +311,14 @@ func parseServeArgs(args []string) (*serveConfig, error) {
}
settings, err := resolveCommonSettings(fs, cfg.configPath, appconfig.CLIOverrides{
PromptDir: cfg.promptDirIfSet(fs),
ProfileDir: cfg.profileDirIfSet(fs),
SchemaDir: cfg.schemaDirIfSet(fs),
ServerAddr: cfg.addrIfSet(fs),
PromptDir: cfg.promptDirIfSet(fs),
ProfileDir: cfg.profileDirIfSet(fs),
SchemaDir: cfg.schemaDirIfSet(fs),
ServerAddr: cfg.addrIfSet(fs),
ArtifactRoot: cfg.artifactRootIfSet(fs),
MaxRequestBytes: cfg.maxRequestBytesIfSet(fs),
MaxArtifactBytes: cfg.maxArtifactBytesIfSet(fs),
MaxResponseBytes: cfg.maxResponseBytesIfSet(fs),
})
if err != nil {
return nil, err
@@ -304,14 +328,23 @@ func parseServeArgs(args []string) (*serveConfig, error) {
cfg.profileDir = settings.profileDir
cfg.schemaDir = settings.schemaDir
cfg.addr = settings.serverAddr
cfg.artifactRoot = settings.artifactRoot
cfg.maxRequestBytes = settings.maxRequestBytes
cfg.maxArtifactBytes = settings.maxArtifactBytes
cfg.maxResponseBytes = settings.maxResponseBytes
if err := validateRequiredLibraryDirs(cfg.promptDir, cfg.profileDir); err != nil {
if err := validateRequiredLibraryDirs(cfg.promptDir); err != nil {
return nil, err
}
cfg.promptDir = filepath.Clean(cfg.promptDir)
cfg.profileDir = filepath.Clean(cfg.profileDir)
if strings.TrimSpace(cfg.profileDir) != "" {
cfg.profileDir = filepath.Clean(cfg.profileDir)
}
cfg.schemaDir = filepath.Clean(cfg.schemaDir)
if strings.TrimSpace(cfg.artifactRoot) != "" {
cfg.artifactRoot = filepath.Clean(cfg.artifactRoot)
}
return cfg, nil
}
@@ -353,7 +386,7 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error {
cfg.schemaDir = settings.schemaDir
cfg.defaultRenderFormat = settings.defaultRenderFormat
if err := validateRequiredLibraryDirs(cfg.promptDir, cfg.profileDir); err != nil {
if err := validateRequiredLibraryDirs(cfg.promptDir); err != nil {
return err
}
if strings.TrimSpace(cfg.promptID) == "" {
@@ -363,7 +396,9 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error {
return errors.New("at least one --input is required")
}
cfg.promptDir = filepath.Clean(cfg.promptDir)
cfg.profileDir = filepath.Clean(cfg.profileDir)
if strings.TrimSpace(cfg.profileDir) != "" {
cfg.profileDir = filepath.Clean(cfg.profileDir)
}
if cfg.outputPath != "" {
cfg.outputPath = filepath.Clean(cfg.outputPath)
}
@@ -426,6 +461,34 @@ func (c *serveConfig) addrIfSet(fs *flag.FlagSet) string {
return ""
}
func (c *serveConfig) artifactRootIfSet(fs *flag.FlagSet) string {
if flagWasSet(fs, "artifact-root") {
return c.artifactRoot
}
return ""
}
func (c *serveConfig) maxRequestBytesIfSet(fs *flag.FlagSet) *int64 {
if flagWasSet(fs, "max-request-bytes") {
return &c.maxRequestBytes
}
return nil
}
func (c *serveConfig) maxArtifactBytesIfSet(fs *flag.FlagSet) *int64 {
if flagWasSet(fs, "max-artifact-bytes") {
return &c.maxArtifactBytes
}
return nil
}
func (c *serveConfig) maxResponseBytesIfSet(fs *flag.FlagSet) *int64 {
if flagWasSet(fs, "max-response-bytes") {
return &c.maxResponseBytes
}
return nil
}
func registerConfigPathFlag(fs *flag.FlagSet, target *string) {
fs.StringVar(
target,
@@ -463,25 +526,33 @@ func resolveCommonSettings(fs *flag.FlagSet, configPath string, overrides appcon
profileDir: settings.ProfileDir,
schemaDir: settings.SchemaDir,
serverAddr: settings.ServerAddr,
artifactRoot: settings.ArtifactRoot,
maxRequestBytes: settings.MaxRequestBytes,
maxArtifactBytes: settings.MaxArtifactBytes,
maxResponseBytes: settings.MaxResponseBytes,
defaultRenderFormat: settings.DefaultRenderFormat,
}, nil
}
func validateRequiredLibraryDirs(promptDir, profileDir string) error {
func validateRequiredLibraryDirs(promptDir string) error {
if strings.TrimSpace(promptDir) == "" {
return errors.New(errPromptDirRequired)
}
if strings.TrimSpace(profileDir) == "" {
return errors.New(errProfileDirRequired)
}
return nil
}
func newRunner(promptDir, profileDir, schemaDir string, llmClient llm.Client) *usecase.Runner {
return newRunnerWithArtifactReader(promptDir, profileDir, schemaDir, llmClient, artifactadapter.NewCompositeReader())
}
func newRunnerWithArtifactReader(promptDir, profileDir, schemaDir string, llmClient llm.Client, artifactReader artifactadapter.Reader) *usecase.Runner {
if artifactReader == nil {
artifactReader = artifactadapter.NewCompositeReader()
}
return usecase.NewRunner(
promptdef.NewFilesystemRepository(promptDir),
profile.NewFilesystemRepository(profileDir),
artifactadapter.NewCompositeReader(),
builtin.NewRepositoryWithDirectory(profileDir),
artifactReader,
prompt.NewGoRenderer(),
llmClient,
validate.NewStandardValidator(schemaDir),
@@ -637,5 +708,5 @@ func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage: scriptorium <run|render|serve> ...")
fmt.Fprintln(w, " run: scriptorium run [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID --input name=path [--input ...] [--profile ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--var k=v] [--out path] [--timeout 10m]")
fmt.Fprintln(w, " render: scriptorium render [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID --input name=path [--input ...] [--profile ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--var k=v] [--format text|json] [--out path] [--timeout 10m]")
fmt.Fprintf(w, " serve: scriptorium serve [--config PATH] [--addr %s] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR]\n", defaults.HTTPAddrDefault)
fmt.Fprintf(w, " serve: scriptorium serve [--config PATH] [--addr %s] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] [--artifact-root DIR] [--max-request-bytes N] [--max-artifact-bytes N] [--max-response-bytes N]\n", defaults.HTTPAddrDefault)
}

View File

@@ -74,12 +74,12 @@ func TestParseRunArgsRequiredFlags(t *testing.T) {
t.Fatalf("expected clear prompt-dir guidance, got %v", err)
}
_, err = parseRunArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--prompt", "p", "--input", "a=b"})
if err == nil {
t.Fatal("expected missing --profile-dir error")
cfg, err := parseRunArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--prompt", "p", "--input", "a=b"})
if err != nil {
t.Fatalf("expected missing --profile-dir to be accepted, got %v", err)
}
if !strings.Contains(err.Error(), "profile directory is required") {
t.Fatalf("expected clear profile-dir guidance, got %v", err)
if cfg.profileDir != "" {
t.Fatalf("expected empty profile dir for built-ins, got %q", cfg.profileDir)
}
_, err = parseRunArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--input", "a=b"})
@@ -161,17 +161,12 @@ func TestParseServeArgsRequiredFlags(t *testing.T) {
t.Fatalf("expected clear prompt-dir guidance, got %v", err)
}
_, err = parseServeArgs([]string{"--config", configPath, "--prompt-dir", "./prompts"})
if err == nil {
t.Fatal("expected missing --profile-dir error")
}
if !strings.Contains(err.Error(), "profile directory is required") {
t.Fatalf("expected clear profile-dir guidance, got %v", err)
}
cfg, err := parseServeArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--profile-dir", "./profiles"})
cfg, err := parseServeArgs([]string{"--config", configPath, "--prompt-dir", "./prompts"})
if err != nil {
t.Fatalf("expected valid serve args, got %v", err)
t.Fatalf("expected missing --profile-dir to be accepted, got %v", err)
}
if cfg.profileDir != "" {
t.Fatalf("expected empty profile dir for built-ins, got %q", cfg.profileDir)
}
if cfg.addr != defaults.HTTPAddrDefault {
t.Fatalf("expected default addr %s, got %q", defaults.HTTPAddrDefault, cfg.addr)
@@ -197,6 +192,26 @@ func TestParseServeArgsRejectsRuntimeOverrideFlags(t *testing.T) {
}
}
func TestUsageIncludesServeFileAndSizeLimitFlags(t *testing.T) {
var stderr bytes.Buffer
code := Run(nil, io.Discard, &stderr)
if code != ExitRuntimeError {
t.Fatalf("expected usage path to return runtime error, got %d", code)
}
usage := stderr.String()
for _, want := range []string{
"--artifact-root",
"--max-request-bytes",
"--max-artifact-bytes",
"--max-response-bytes",
} {
if !strings.Contains(usage, want) {
t.Fatalf("expected usage to include %q, got %q", want, usage)
}
}
}
func TestParseRunArgsTimeout(t *testing.T) {
cfg, err := parseRunArgs([]string{
"--prompt-dir", "./prompts",
@@ -440,11 +455,19 @@ profile_dir: ./from-config/profiles
schema_dir: ./from-config/schemas
server:
addr: 127.0.0.1:9000
artifact_root: ./from-config/artifacts
max_request_bytes: 1024
max_artifact_bytes: 2048
max_response_bytes: 4096
`)
cfg, err := parseServeArgs([]string{
"--config", configPath,
"--addr", ":7777",
"--artifact-root", "./from-cli/artifacts",
"--max-request-bytes", "0",
"--max-artifact-bytes", "8192",
"--max-response-bytes", "16384",
})
if err != nil {
t.Fatalf("expected valid args, got %v", err)
@@ -462,6 +485,18 @@ server:
if cfg.addr != ":7777" {
t.Fatalf("expected CLI addr override, got %q", cfg.addr)
}
if cfg.artifactRoot != filepath.Clean("./from-cli/artifacts") {
t.Fatalf("expected CLI artifact root override, got %q", cfg.artifactRoot)
}
if cfg.maxRequestBytes != 0 {
t.Fatalf("expected CLI max request bytes override, got %d", cfg.maxRequestBytes)
}
if cfg.maxArtifactBytes != 8192 {
t.Fatalf("expected CLI max artifact bytes override, got %d", cfg.maxArtifactBytes)
}
if cfg.maxResponseBytes != 16384 {
t.Fatalf("expected CLI max response bytes override, got %d", cfg.maxResponseBytes)
}
}
func TestParseServeArgsWithConfigProvidesRequiredDirectoriesAndAddr(t *testing.T) {
@@ -471,6 +506,10 @@ profile_dir: ./from-config/profiles
schema_dir: ./from-config/schemas
server:
addr: 127.0.0.1:9000
artifact_root: ./from-config/artifacts
max_request_bytes: 1024
max_artifact_bytes: 2048
max_response_bytes: 4096
`)
cfg, err := parseServeArgs([]string{
@@ -492,6 +531,75 @@ server:
if cfg.addr != "127.0.0.1:9000" {
t.Fatalf("expected addr from config, got %q", cfg.addr)
}
if cfg.artifactRoot != filepath.Clean("./from-config/artifacts") {
t.Fatalf("expected artifact root from config, got %q", cfg.artifactRoot)
}
if cfg.maxRequestBytes != 1024 {
t.Fatalf("expected max request bytes from config, got %d", cfg.maxRequestBytes)
}
if cfg.maxArtifactBytes != 2048 {
t.Fatalf("expected max artifact bytes from config, got %d", cfg.maxArtifactBytes)
}
if cfg.maxResponseBytes != 4096 {
t.Fatalf("expected max response bytes from config, got %d", cfg.maxResponseBytes)
}
}
func TestParseServeArgsRejectsNegativeSizeLimits(t *testing.T) {
tests := []struct {
name string
flag string
}{
{name: "request", flag: "--max-request-bytes"},
{name: "artifact", flag: "--max-artifact-bytes"},
{name: "response", flag: "--max-response-bytes"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := parseServeArgs([]string{
"--prompt-dir", "./prompts",
tc.flag, "-1",
})
if err == nil {
t.Fatal("expected negative size limit error")
}
})
}
}
func TestRunAndRenderRejectServeSizeLimitFlags(t *testing.T) {
for _, tc := range []struct {
name string
parse func([]string) error
}{
{
name: "run",
parse: func(args []string) error {
_, err := parseRunArgs(args)
return err
},
},
{
name: "render",
parse: func(args []string) error {
_, err := parseRenderArgs(args)
return err
},
},
} {
t.Run(tc.name, func(t *testing.T) {
err := tc.parse([]string{
"--prompt-dir", "./prompts",
"--prompt", "p",
"--input", "a=b",
"--max-request-bytes", "1024",
})
if err == nil {
t.Fatal("expected unsupported flag error")
}
})
}
}
func TestRunAndRenderBuildEquivalentRuntimeOverrideRequestsForSharedFlags(t *testing.T) {
@@ -565,21 +673,21 @@ profile_dir: ./profiles
}
}
func TestParseRunArgsFailsClearlyWhenNoEffectiveProfileDir(t *testing.T) {
func TestParseRunArgsAcceptsMissingEffectiveProfileDir(t *testing.T) {
configPath := writeAppConfigFile(t, `
prompt_dir: ./prompts
`)
_, err := parseRunArgs([]string{
cfg, err := parseRunArgs([]string{
"--config", configPath,
"--prompt", "p",
"--input", "a=b",
})
if err == nil {
t.Fatal("expected missing profile_dir error")
if err != nil {
t.Fatalf("expected missing profile_dir to be accepted, got %v", err)
}
if !strings.Contains(err.Error(), "profile directory is required") || !strings.Contains(err.Error(), "config.yml profile_dir") {
t.Fatalf("expected clear profile_dir guidance, got %v", err)
if cfg.profileDir != "" {
t.Fatalf("expected empty profile dir for built-ins, got %q", cfg.profileDir)
}
}
@@ -601,21 +709,21 @@ profile_dir: ./profiles
}
}
func TestParseRenderArgsFailsClearlyWhenNoEffectiveProfileDir(t *testing.T) {
func TestParseRenderArgsAcceptsMissingEffectiveProfileDir(t *testing.T) {
configPath := writeAppConfigFile(t, `
prompt_dir: ./prompts
`)
_, err := parseRenderArgs([]string{
cfg, err := parseRenderArgs([]string{
"--config", configPath,
"--prompt", "p",
"--input", "a=b",
})
if err == nil {
t.Fatal("expected missing profile_dir error")
if err != nil {
t.Fatalf("expected missing profile_dir to be accepted, got %v", err)
}
if !strings.Contains(err.Error(), "profile directory is required") || !strings.Contains(err.Error(), "config.yml profile_dir") {
t.Fatalf("expected clear profile_dir guidance, got %v", err)
if cfg.profileDir != "" {
t.Fatalf("expected empty profile dir for built-ins, got %q", cfg.profileDir)
}
}
@@ -929,6 +1037,29 @@ func TestRenderCommandPromptDefaultProfileWorksThroughCLIPath(t *testing.T) {
}
}
func TestRenderCommandUsesBuiltInProfileWithoutProfileDir(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "test-key")
lib := newCLITestLibrary(t)
inputPath := lib.writeInputFile(t, "transcript.md", "hello")
writePromptFile(t, lib.promptDir, "prompt.builtin", "mistral-small-3")
code, stdout, stderr := runCLICommand(t, renderCommand, []string{
"--prompt-dir", lib.promptDir,
"--prompt", "prompt.builtin",
"--input", "transcript=" + inputPath,
})
if code != ExitOK {
t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr)
}
if !strings.Contains(stdout, "selected_profile_id: mistral-small-3") {
t.Fatalf("expected built-in selected profile, got %q", stdout)
}
if !strings.Contains(stdout, "model: mistralai/mistral-small-3.2-24b-instruct") {
t.Fatalf("expected built-in model, got %q", stdout)
}
}
func TestRenderCommandExplicitProfileOverridesPromptDefault(t *testing.T) {
lib := newCLITestLibrary(t)
inputPath := lib.writeInputFile(t, "transcript.md", "hello")

View File

@@ -4,9 +4,12 @@ import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
@@ -18,11 +21,24 @@ type Runner interface {
}
type Handler struct {
runner Runner
runner Runner
options HandlerOptions
}
type HandlerOptions struct {
MaxRequestBytes int64
MaxResponseBytes int64
}
func NewHandler(runner Runner) *Handler {
return &Handler{runner: runner}
return NewHandlerWithOptions(runner, HandlerOptions{
MaxRequestBytes: defaults.HTTPMaxRequestBytesDefault,
MaxResponseBytes: defaults.HTTPMaxResponseBytesDefault,
})
}
func NewHandlerWithOptions(runner Runner, options HandlerOptions) *Handler {
return &Handler{runner: runner, options: options}
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -36,9 +52,26 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
var req runRequestDTO
dec := json.NewDecoder(r.Body)
body := r.Body
if h.options.MaxRequestBytes > 0 {
body = http.MaxBytesReader(w, r.Body, h.options.MaxRequestBytes)
}
dec := json.NewDecoder(body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
if isRequestTooLarge(err) {
writeError(w, http.StatusRequestEntityTooLarge, "request_too_large", "request body is too large")
return
}
writeError(w, http.StatusBadRequest, "invalid_json", "invalid JSON request body")
return
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
if isRequestTooLarge(err) {
writeError(w, http.StatusRequestEntityTooLarge, "request_too_large", "request body is too large")
return
}
writeError(w, http.StatusBadRequest, "invalid_json", "invalid JSON request body")
return
}
@@ -120,7 +153,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
raw := res.RawOutput
resp.RawModelOutput = &raw
}
writeJSON(w, http.StatusOK, resp)
writeLimitedJSON(w, http.StatusOK, resp, h.options.MaxResponseBytes)
}
func executionTargetOverrideFromModelOverrideDTO(dto *modelOverrideRequestDTO) *domain.ExecutionTargetOverride {
@@ -175,7 +208,7 @@ func mapRunError(err error) (int, string, string) {
return http.StatusNotFound, "profile_not_found", "execution profile not found"
case errors.Is(err, promptdef.ErrInvalidYAML), errors.Is(err, promptdef.ErrInvalidPromptDefinition):
return http.StatusBadRequest, "prompt_load_failed", "failed to load prompt definition"
case errors.Is(err, profile.ErrInvalidYAML), errors.Is(err, profile.ErrInvalidProfile):
case errors.Is(err, profile.ErrInvalidYAML), errors.Is(err, profile.ErrInvalidProfile), errors.Is(err, profile.ErrRawAPIKeyNotAllowed):
return http.StatusBadRequest, "profile_load_failed", "failed to load execution profile"
case errors.Is(err, usecase.ErrProfileRequired):
return http.StatusBadRequest, "profile_required", "profile_id is required when prompt default_profile is not set"
@@ -183,8 +216,14 @@ func mapRunError(err error) (int, string, string) {
return http.StatusBadRequest, "api_key_env_missing", "api_key_env is set but the environment variable is missing"
case errors.Is(err, usecase.ErrInvalidRequest):
return http.StatusBadRequest, "invalid_request", "invalid run request"
case errors.Is(err, usecase.ErrProfileLoad):
case errors.Is(err, usecase.ErrPromptLoad):
return http.StatusBadRequest, "prompt_load_failed", "failed to load prompt definition"
case errors.Is(err, usecase.ErrProfileLoad):
return http.StatusBadRequest, "profile_load_failed", "failed to load execution profile"
case errors.Is(err, artifact.ErrFileNotAllowed), errors.Is(err, artifact.ErrFileOutsideRoot):
return http.StatusBadRequest, "artifact_not_allowed", "file input artifact is not allowed"
case errors.Is(err, artifact.ErrFileTooLarge):
return http.StatusRequestEntityTooLarge, "artifact_too_large", "file input artifact is too large"
case errors.Is(err, usecase.ErrArtifactLoad):
return http.StatusBadRequest, "artifact_read_failed", "failed to read input artifact"
case errors.Is(err, usecase.ErrPromptRender):
@@ -199,9 +238,23 @@ func mapRunError(err error) (int, string, string) {
}
func writeJSON(w http.ResponseWriter, status int, v any) {
writeLimitedJSON(w, status, v, 0)
}
func writeLimitedJSON(w http.ResponseWriter, status int, v any, maxBytes int64) {
data, err := json.Marshal(v)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "internal server error")
return
}
data = append(data, '\n')
if maxBytes > 0 && int64(len(data)) > maxBytes {
writeError(w, http.StatusRequestEntityTooLarge, "response_too_large", "response body is too large")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
_, _ = w.Write(data)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
@@ -212,3 +265,8 @@ func writeError(w http.ResponseWriter, status int, code, message string) {
},
})
}
func isRequestTooLarge(err error) bool {
var maxBytesErr *http.MaxBytesError
return errors.As(err, &maxBytesErr)
}

View File

@@ -7,11 +7,14 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"gitea.maximumdirect.net/eric/scriptorium/internal/llm"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
@@ -61,6 +64,12 @@ func (handlerRenderer) Render(ctx context.Context, definition *domain.PromptDefi
return &domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}}, nil
}
type handlerLLMClient struct{}
func (handlerLLMClient) Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
return &domain.GenerateResponse{Content: "ok"}, nil
}
func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
start := time.Now().UTC()
end := start.Add(2 * time.Second)
@@ -184,6 +193,105 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
}
}
func TestHandlerInlineRefsWorkWithoutArtifactRoot(t *testing.T) {
h := newArtifactRootHandler(t, "")
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
"prompt_id":"p",
"inputs":{"x":{"type":"inline","body":"inline body"}}
}`))
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())
}
}
func TestHandlerFileRefsWithoutArtifactRootAreRejected(t *testing.T) {
h := newArtifactRootHandler(t, "")
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
"prompt_id":"p",
"inputs":{"x":{"type":"file","uri":"input.txt"}}
}`))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
assertHTTPErrorCode(t, w, http.StatusBadRequest, "artifact_not_allowed")
}
func TestHandlerFileRefsUnderArtifactRootWork(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "input.txt"), []byte("allowed"), 0o644); err != nil {
t.Fatal(err)
}
h := newArtifactRootHandler(t, root)
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
"prompt_id":"p",
"inputs":{"x":{"type":"file","uri":"input.txt"}}
}`))
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())
}
}
func TestHandlerFileRefsAboveArtifactLimitAreRejected(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "large.txt"), []byte("123456"), 0o644); err != nil {
t.Fatal(err)
}
h := newArtifactRootHandlerWithLimit(t, root, 5)
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
"prompt_id":"p",
"inputs":{"x":{"type":"file","uri":"large.txt"}}
}`))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
assertHTTPErrorCode(t, w, http.StatusRequestEntityTooLarge, "artifact_too_large")
}
func TestHandlerFileRefsOutsideArtifactRootAreRejected(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("denied"), 0o644); err != nil {
t.Fatal(err)
}
h := newArtifactRootHandler(t, root)
tests := []struct {
name string
uri string
}{
{name: "relative traversal", uri: filepath.Join("..", filepath.Base(outside), "secret.txt")},
{name: "absolute outside root", uri: filepath.Join(outside, "secret.txt")},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body := fmt.Sprintf(`{
"prompt_id":"p",
"inputs":{"x":{"type":"file","uri":%q}}
}`, tc.uri)
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(body))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
assertHTTPErrorCode(t, w, http.StatusBadRequest, "artifact_not_allowed")
})
}
}
func TestHandlerPostRunsSuccessUsingPromptDefaultProfile(t *testing.T) {
r := &fakeRunner{result: &domain.RunResult{
Artifact: domain.Artifact{Body: []byte("ok")},
@@ -486,6 +594,69 @@ func TestHandlerInvalidJSON(t *testing.T) {
}
}
func TestHandlerRejectsTrailingJSON(t *testing.T) {
h := NewHandler(&fakeRunner{})
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}} {}`))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
assertHTTPErrorCode(t, w, http.StatusBadRequest, "invalid_json")
}
func TestHandlerRequestTooLarge(t *testing.T) {
h := NewHandlerWithOptions(&fakeRunner{}, HandlerOptions{MaxRequestBytes: 12})
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
assertHTTPErrorCode(t, w, http.StatusRequestEntityTooLarge, "request_too_large")
}
func TestHandlerMalformedJSONBelowLimitStillBadRequest(t *testing.T) {
h := NewHandlerWithOptions(&fakeRunner{}, HandlerOptions{MaxRequestBytes: 1024})
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString("{"))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
assertHTTPErrorCode(t, w, http.StatusBadRequest, "invalid_json")
}
func TestHandlerResponseTooLarge(t *testing.T) {
h := NewHandlerWithOptions(&fakeRunner{result: &domain.RunResult{
Artifact: domain.Artifact{Body: []byte(strings.Repeat("x", 128))},
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
}}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 64})
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
assertHTTPErrorCode(t, w, http.StatusRequestEntityTooLarge, "response_too_large")
}
func TestHandlerRawOutputDoesNotBypassResponseLimit(t *testing.T) {
h := NewHandlerWithOptions(&fakeRunner{result: &domain.RunResult{
Artifact: domain.Artifact{Body: []byte("ok")},
RawOutput: strings.Repeat("raw", 80),
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
}}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 128})
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
"prompt_id":"p",
"inputs":{"x":{"type":"file","uri":"a"}},
"include_raw_output":true
}`))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
assertHTTPErrorCode(t, w, http.StatusRequestEntityTooLarge, "response_too_large")
}
func TestHandlerMissingPromptID(t *testing.T) {
h := NewHandler(&fakeRunner{})
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"inputs":{"x":{"type":"file","uri":"a"}}}`))
@@ -563,11 +734,13 @@ func TestHandlerUsecaseErrorMapping(t *testing.T) {
message string
avoidCause string
}{
{name: "prompt not found", err: wrap(usecase.ErrProfileLoad, promptdef.ErrPromptDefinitionNotFound), status: http.StatusNotFound, code: "prompt_not_found", message: "prompt definition not found"},
{name: "prompt load invalid", err: wrap(usecase.ErrProfileLoad, promptdef.ErrInvalidPromptDefinition), status: http.StatusBadRequest, code: "prompt_load_failed", message: "failed to load prompt definition"},
{name: "prompt not found", err: wrap(usecase.ErrPromptLoad, promptdef.ErrPromptDefinitionNotFound), status: http.StatusNotFound, code: "prompt_not_found", message: "prompt definition not found"},
{name: "prompt load invalid", err: wrap(usecase.ErrPromptLoad, promptdef.ErrInvalidPromptDefinition), status: http.StatusBadRequest, code: "prompt_load_failed", message: "failed to load prompt definition"},
{name: "prompt load generic", err: wrap(usecase.ErrPromptLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "prompt_load_failed", message: "failed to load prompt definition", avoidCause: "read failed"},
{name: "missing profile/default", err: wrap(usecase.ErrInvalidRequest, usecase.ErrProfileRequired), status: http.StatusBadRequest, code: "profile_required", message: "profile_id is required when prompt default_profile is not set"},
{name: "profile not found", err: wrap(usecase.ErrProfileLoad, profile.ErrProfileNotFound), status: http.StatusNotFound, code: "profile_not_found", message: "execution profile not found"},
{name: "profile invalid", err: wrap(usecase.ErrProfileLoad, profile.ErrInvalidProfile), status: http.StatusBadRequest, code: "profile_load_failed", message: "failed to load execution profile"},
{name: "profile load generic", err: wrap(usecase.ErrProfileLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "profile_load_failed", message: "failed to load execution profile", avoidCause: "read failed"},
{name: "api key env missing", err: wrap(usecase.ErrInvalidRequest, usecase.ErrAPIKeyEnvMissing), status: http.StatusBadRequest, code: "api_key_env_missing", message: "api_key_env is set but the environment variable is missing"},
{name: "artifact", err: wrap(usecase.ErrArtifactLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "artifact_read_failed", message: "failed to read input artifact", avoidCause: "read failed"},
{name: "prompt render", err: wrap(usecase.ErrPromptRender, fmt.Errorf("render failed")), status: http.StatusBadRequest, code: "prompt_render_failed", message: "failed to render prompt", avoidCause: "render failed"},
@@ -674,3 +847,54 @@ func TestHandlerValidationFailureStillSuccessAndRawOutputOptIn(t *testing.T) {
func wrap(stage error, cause error) error {
return fmt.Errorf("%w: %w", stage, cause)
}
func newArtifactRootHandler(t *testing.T, root string) *Handler {
t.Helper()
return newArtifactRootHandlerWithLimit(t, root, 0)
}
func newArtifactRootHandlerWithLimit(t *testing.T, root string, maxArtifactBytes int64) *Handler {
t.Helper()
reader, err := artifact.NewRestrictedCompositeReaderWithLimit(root, maxArtifactBytes)
if err != nil {
t.Fatalf("expected restricted artifact reader: %v", 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",
}},
reader,
handlerRenderer{},
handlerLLMClient{},
nil,
)
return NewHandler(runner)
}
func assertHTTPErrorCode(t *testing.T, w *httptest.ResponseRecorder, status int, code string) {
t.Helper()
if w.Code != status {
t.Fatalf("expected %d, got %d body=%s", status, 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"] != code {
t.Fatalf("expected code %q, got %#v", code, errBody["code"])
}
}

View File

@@ -7,15 +7,20 @@ import (
"fmt"
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"io"
"mime"
"os"
"path/filepath"
"strings"
)
var (
ErrUnsupportedRefType = errors.New("unsupported artifact reference type")
ErrMissingInlineBody = errors.New("missing body for inline artifact")
ErrMissingFilePath = errors.New("missing file path for file artifact")
ErrFileNotAllowed = errors.New("file artifact references are not allowed")
ErrFileOutsideRoot = errors.New("file artifact path is outside artifact root")
ErrFileTooLarge = errors.New("file artifact exceeds size limit")
)
// Reader resolves artifact references into actual artifacts.
@@ -26,7 +31,7 @@ type Reader interface {
// CompositeReader routes artifact resolution based on the reference type.
type CompositeReader struct {
inlineReader *inlineReader
fileReader *fileReader
fileReader Reader
}
func NewCompositeReader() Reader {
@@ -36,6 +41,21 @@ func NewCompositeReader() Reader {
}
}
func NewRestrictedCompositeReader(root string) (Reader, error) {
return NewRestrictedCompositeReaderWithLimit(root, 0)
}
func NewRestrictedCompositeReaderWithLimit(root string, maxBytes int64) (Reader, error) {
fileReader, err := newRestrictedFileReader(root, maxBytes)
if err != nil {
return nil, err
}
return &CompositeReader{
inlineReader: &inlineReader{},
fileReader: fileReader,
}, nil
}
func (c *CompositeReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
select {
case <-ctx.Done():
@@ -89,21 +109,133 @@ func (r *fileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.
return nil, ErrMissingFilePath
}
data, err := os.ReadFile(ref.URI)
if err != nil {
return nil, fmt.Errorf("failed to read file %s: %w", ref.URI, err)
return readFileArtifact(ref.URI)
}
type deniedFileReader struct{}
func (r deniedFileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
contentType := mime.TypeByExtension(filepath.Ext(ref.URI))
if ref.URI == "" {
return nil, ErrMissingFilePath
}
return nil, ErrFileNotAllowed
}
type restrictedFileReader struct {
root string
maxBytes int64
}
func newRestrictedFileReader(root string, maxBytes int64) (Reader, error) {
if maxBytes < 0 {
return nil, fmt.Errorf("artifact size limit must be greater than or equal to 0")
}
cleanRoot := strings.TrimSpace(root)
if cleanRoot == "" {
return deniedFileReader{}, nil
}
absRoot, err := filepath.Abs(filepath.Clean(cleanRoot))
if err != nil {
return nil, fmt.Errorf("resolve artifact root: %w", err)
}
return &restrictedFileReader{root: absRoot, maxBytes: maxBytes}, nil
}
func (r *restrictedFileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if ref.URI == "" {
return nil, ErrMissingFilePath
}
path, err := r.resolveLexicalPath(ref.URI)
if err != nil {
return nil, err
}
return readFileArtifactWithLimit(path, r.maxBytes)
}
// resolveLexicalPath checks cleaned path containment without resolving symlinks.
func (r *restrictedFileReader) resolveLexicalPath(rawPath string) (string, error) {
cleanPath := filepath.Clean(strings.TrimSpace(rawPath))
var candidate string
if filepath.IsAbs(cleanPath) {
candidate = cleanPath
} else {
candidate = filepath.Join(r.root, cleanPath)
}
absCandidate, err := filepath.Abs(candidate)
if err != nil {
return "", fmt.Errorf("resolve artifact path: %w", err)
}
absCandidate = filepath.Clean(absCandidate)
rel, err := filepath.Rel(r.root, absCandidate)
if err != nil {
return "", fmt.Errorf("compare artifact path to root: %w", err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
return "", ErrFileOutsideRoot
}
return absCandidate, nil
}
func readFileArtifact(path string) (*domain.Artifact, error) {
return readFileArtifactWithLimit(path, 0)
}
func readFileArtifactWithLimit(path string, maxBytes int64) (*domain.Artifact, error) {
if maxBytes < 0 {
return nil, fmt.Errorf("file size limit must be greater than or equal to 0")
}
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("failed to stat file %s: %w", path, err)
}
if maxBytes > 0 && info.Size() > maxBytes {
return nil, ErrFileTooLarge
}
var reader io.Reader = file
if maxBytes > 0 {
reader = io.LimitReader(file, maxBytes+1)
}
data, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
}
if maxBytes > 0 && int64(len(data)) > maxBytes {
return nil, ErrFileTooLarge
}
contentType := mime.TypeByExtension(filepath.Ext(path))
if contentType == "" {
contentType = defaults.ContentTypeTextPlain
}
return &domain.Artifact{
Name: filepath.Base(ref.URI),
Name: filepath.Base(path),
ContentType: contentType,
Body: data,
URI: ref.URI,
URI: path,
Size: int64(len(data)),
Hash: fmt.Sprintf("%x", sha256.Sum256(data)),
}, nil

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
@@ -56,6 +57,157 @@ func TestCompositeReader_Read(t *testing.T) {
})
}
func TestRestrictedCompositeReader(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "input.txt"), []byte("allowed"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "nested"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("denied"), 0o644); err != nil {
t.Fatal(err)
}
reader, err := NewRestrictedCompositeReader(root)
if err != nil {
t.Fatalf("expected restricted reader construction, got %v", err)
}
t.Run("accepts relative contained path", func(t *testing.T) {
art, err := reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: "nested/../input.txt"})
if err != nil {
t.Fatalf("expected contained relative path to succeed, got %v", err)
}
if string(art.Body) != "allowed" {
t.Fatalf("unexpected artifact body: %q", string(art.Body))
}
})
t.Run("accepts absolute contained path", func(t *testing.T) {
art, err := reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: filepath.Join(root, "input.txt")})
if err != nil {
t.Fatalf("expected contained absolute path to succeed, got %v", err)
}
if art.Name != "input.txt" {
t.Fatalf("unexpected artifact name: %q", art.Name)
}
})
t.Run("rejects relative traversal outside root", func(t *testing.T) {
_, err := reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: filepath.Join("..", filepath.Base(outside), "secret.txt")})
if !errors.Is(err, ErrFileOutsideRoot) {
t.Fatalf("expected ErrFileOutsideRoot, got %v", err)
}
})
t.Run("rejects absolute path outside root", func(t *testing.T) {
_, err := reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: filepath.Join(outside, "secret.txt")})
if !errors.Is(err, ErrFileOutsideRoot) {
t.Fatalf("expected ErrFileOutsideRoot, got %v", err)
}
})
}
func TestRestrictedCompositeReaderFollowsSymlinkInsideRoot(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
outside := t.TempDir()
target := filepath.Join(outside, "linked.txt")
if err := os.WriteFile(target, []byte("linked outside root"), 0o644); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "linked.txt")
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlink creation unavailable: %v", err)
}
reader, err := NewRestrictedCompositeReader(root)
if err != nil {
t.Fatalf("expected restricted reader construction, got %v", err)
}
art, err := reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: "linked.txt"})
if err != nil {
t.Fatalf("expected symlink inside root to be followed, got %v", err)
}
if string(art.Body) != "linked outside root" {
t.Fatalf("unexpected artifact body: %q", string(art.Body))
}
}
func TestRestrictedCompositeReaderWithoutRootDeniesFileRefs(t *testing.T) {
reader, err := NewRestrictedCompositeReader("")
if err != nil {
t.Fatalf("expected restricted reader construction, got %v", err)
}
art, err := reader.Read(context.Background(), domain.ArtifactRef{Type: domain.ArtifactRefInline, Body: "inline"})
if err != nil {
t.Fatalf("expected inline ref to work without artifact root, got %v", err)
}
if string(art.Body) != "inline" {
t.Fatalf("unexpected inline body: %q", string(art.Body))
}
_, err = reader.Read(context.Background(), domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: "input.txt"})
if !errors.Is(err, ErrFileNotAllowed) {
t.Fatalf("expected ErrFileNotAllowed, got %v", err)
}
}
func TestRestrictedCompositeReaderFileSizeLimit(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "exact.txt"), []byte("12345"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "large.txt"), []byte("123456"), 0o644); err != nil {
t.Fatal(err)
}
reader, err := NewRestrictedCompositeReaderWithLimit(root, 5)
if err != nil {
t.Fatalf("expected restricted reader construction, got %v", err)
}
art, err := reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: "exact.txt"})
if err != nil {
t.Fatalf("expected file at limit to succeed, got %v", err)
}
if string(art.Body) != "12345" {
t.Fatalf("unexpected artifact body: %q", string(art.Body))
}
_, err = reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: "large.txt"})
if !errors.Is(err, ErrFileTooLarge) {
t.Fatalf("expected ErrFileTooLarge, got %v", err)
}
}
func TestRestrictedCompositeReaderFileSizeLimitZeroDisablesLimit(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "large.txt"), []byte("123456"), 0o644); err != nil {
t.Fatal(err)
}
reader, err := NewRestrictedCompositeReaderWithLimit(root, 0)
if err != nil {
t.Fatalf("expected restricted reader construction, got %v", err)
}
art, err := reader.Read(context.Background(), domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: "large.txt"})
if err != nil {
t.Fatalf("expected unlimited reader to succeed, got %v", err)
}
if string(art.Body) != "123456" {
t.Fatalf("unexpected artifact body: %q", string(art.Body))
}
}
func TestFileReader_Read(t *testing.T) {
content := []byte("test file content")
tmpFile, err := os.CreateTemp("", "artifact_test_*.txt")

View File

@@ -40,7 +40,11 @@ type Config struct {
}
type ServerConfig struct {
Addr string `yaml:"addr"`
Addr string `yaml:"addr"`
ArtifactRoot string `yaml:"artifact_root"`
MaxRequestBytes *int64 `yaml:"max_request_bytes"`
MaxArtifactBytes *int64 `yaml:"max_artifact_bytes"`
MaxResponseBytes *int64 `yaml:"max_response_bytes"`
}
type DefaultsConfig struct {
@@ -53,16 +57,24 @@ type AppSettings struct {
ProfileDir string
SchemaDir string
ServerAddr string
ArtifactRoot string
MaxRequestBytes int64
MaxArtifactBytes int64
MaxResponseBytes int64
DefaultRenderFormat renderformat.PreparedRunOutputFormat
}
// CLIOverrides can be applied after config load to enforce precedence.
type CLIOverrides struct {
PromptDir string
ProfileDir string
SchemaDir string
ServerAddr string
RenderFormat string
PromptDir string
ProfileDir string
SchemaDir string
ServerAddr string
ArtifactRoot string
MaxRequestBytes *int64
MaxArtifactBytes *int64
MaxResponseBytes *int64
RenderFormat string
}
// BuiltInDefaults returns compile-time application defaults.
@@ -70,6 +82,9 @@ func BuiltInDefaults() AppSettings {
return AppSettings{
SchemaDir: defaults.SchemaDirDefault,
ServerAddr: defaults.HTTPAddrDefault,
MaxRequestBytes: defaults.HTTPMaxRequestBytesDefault,
MaxArtifactBytes: defaults.HTTPMaxArtifactBytesDefault,
MaxResponseBytes: defaults.HTTPMaxResponseBytesDefault,
DefaultRenderFormat: renderformat.DefaultPreparedRunOutputFormat,
}
}
@@ -142,6 +157,27 @@ func ApplyCLIOverrides(base AppSettings, overrides CLIOverrides) (AppSettings, e
if v := strings.TrimSpace(overrides.ServerAddr); v != "" {
out.ServerAddr = v
}
if v := strings.TrimSpace(overrides.ArtifactRoot); v != "" {
out.ArtifactRoot = filepath.Clean(v)
}
if overrides.MaxRequestBytes != nil {
if *overrides.MaxRequestBytes < 0 {
return AppSettings{}, fmt.Errorf("%w: server.max_request_bytes must be greater than or equal to 0", ErrInvalidConfig)
}
out.MaxRequestBytes = *overrides.MaxRequestBytes
}
if overrides.MaxArtifactBytes != nil {
if *overrides.MaxArtifactBytes < 0 {
return AppSettings{}, fmt.Errorf("%w: server.max_artifact_bytes must be greater than or equal to 0", ErrInvalidConfig)
}
out.MaxArtifactBytes = *overrides.MaxArtifactBytes
}
if overrides.MaxResponseBytes != nil {
if *overrides.MaxResponseBytes < 0 {
return AppSettings{}, fmt.Errorf("%w: server.max_response_bytes must be greater than or equal to 0", ErrInvalidConfig)
}
out.MaxResponseBytes = *overrides.MaxResponseBytes
}
if rawFormat := strings.TrimSpace(overrides.RenderFormat); rawFormat != "" {
parsed, err := renderformat.ParsePreparedRunOutputFormat(rawFormat)
if err != nil {
@@ -181,6 +217,27 @@ func applyConfig(base AppSettings, cfg Config) (AppSettings, error) {
if v := strings.TrimSpace(cfg.Server.Addr); v != "" {
out.ServerAddr = v
}
if v := strings.TrimSpace(cfg.Server.ArtifactRoot); v != "" {
out.ArtifactRoot = filepath.Clean(v)
}
if cfg.Server.MaxRequestBytes != nil {
if *cfg.Server.MaxRequestBytes < 0 {
return AppSettings{}, fmt.Errorf("%w: server.max_request_bytes must be greater than or equal to 0", ErrInvalidConfig)
}
out.MaxRequestBytes = *cfg.Server.MaxRequestBytes
}
if cfg.Server.MaxArtifactBytes != nil {
if *cfg.Server.MaxArtifactBytes < 0 {
return AppSettings{}, fmt.Errorf("%w: server.max_artifact_bytes must be greater than or equal to 0", ErrInvalidConfig)
}
out.MaxArtifactBytes = *cfg.Server.MaxArtifactBytes
}
if cfg.Server.MaxResponseBytes != nil {
if *cfg.Server.MaxResponseBytes < 0 {
return AppSettings{}, fmt.Errorf("%w: server.max_response_bytes must be greater than or equal to 0", ErrInvalidConfig)
}
out.MaxResponseBytes = *cfg.Server.MaxResponseBytes
}
if rawFormat := strings.TrimSpace(cfg.Defaults.RenderFormat); rawFormat != "" {
parsed, err := renderformat.ParsePreparedRunOutputFormat(rawFormat)
if err != nil {

View File

@@ -6,6 +6,7 @@ import (
"path/filepath"
"testing"
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
)
@@ -24,6 +25,20 @@ func TestLoadConfigMissingImplicitPathUsesBuiltInDefaults(t *testing.T) {
}
}
func TestBuiltInDefaultsIncludeHTTPSizeLimits(t *testing.T) {
got := BuiltInDefaults()
if got.MaxRequestBytes != defaults.HTTPMaxRequestBytesDefault {
t.Fatalf("unexpected max request bytes: %d", got.MaxRequestBytes)
}
if got.MaxArtifactBytes != defaults.HTTPMaxArtifactBytesDefault {
t.Fatalf("unexpected max artifact bytes: %d", got.MaxArtifactBytes)
}
if got.MaxResponseBytes != defaults.HTTPMaxResponseBytesDefault {
t.Fatalf("unexpected max response bytes: %d", got.MaxResponseBytes)
}
}
func TestLoadConfigMissingExplicitPathReturnsError(t *testing.T) {
tmp := t.TempDir()
missing := filepath.Join(tmp, "missing.yml")
@@ -92,6 +107,10 @@ profile_dir: ./profiles
schema_dir: ./schemas
server:
addr: 127.0.0.1:9090
artifact_root: ./artifacts
max_request_bytes: 1024
max_artifact_bytes: 2048
max_response_bytes: 4096
defaults:
render_format: json
`)
@@ -113,11 +132,61 @@ defaults:
if got.ServerAddr != "127.0.0.1:9090" {
t.Fatalf("unexpected server.addr: %q", got.ServerAddr)
}
if got.ArtifactRoot != filepath.Clean("./artifacts") {
t.Fatalf("unexpected server.artifact_root: %q", got.ArtifactRoot)
}
if got.MaxRequestBytes != 1024 {
t.Fatalf("unexpected server.max_request_bytes: %d", got.MaxRequestBytes)
}
if got.MaxArtifactBytes != 2048 {
t.Fatalf("unexpected server.max_artifact_bytes: %d", got.MaxArtifactBytes)
}
if got.MaxResponseBytes != 4096 {
t.Fatalf("unexpected server.max_response_bytes: %d", got.MaxResponseBytes)
}
if got.DefaultRenderFormat != renderformat.PreparedRunFormatJSON {
t.Fatalf("unexpected defaults.render_format: %q", got.DefaultRenderFormat)
}
}
func TestLoadConfigAcceptsZeroHTTPSizeLimits(t *testing.T) {
path := writeConfigFile(t, "config.yml", `
server:
max_request_bytes: 0
max_artifact_bytes: 0
max_response_bytes: 0
`)
got, err := LoadConfig(path, true)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got.MaxRequestBytes != 0 || got.MaxArtifactBytes != 0 || got.MaxResponseBytes != 0 {
t.Fatalf("expected zero limits to be preserved, got request=%d artifact=%d response=%d", got.MaxRequestBytes, got.MaxArtifactBytes, got.MaxResponseBytes)
}
}
func TestLoadConfigRejectsNegativeHTTPSizeLimits(t *testing.T) {
tests := []struct {
name string
body string
}{
{name: "request", body: "server:\n max_request_bytes: -1\n"},
{name: "artifact", body: "server:\n max_artifact_bytes: -1\n"},
{name: "response", body: "server:\n max_response_bytes: -1\n"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
path := writeConfigFile(t, "config.yml", tc.body)
_, err := LoadConfig(path, true)
if !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("expected ErrInvalidConfig, got %v", err)
}
})
}
}
func TestLoadConfigEmptyFileResolvesToBuiltInDefaults(t *testing.T) {
path := writeConfigFile(t, "config.yml", "")
@@ -185,15 +254,26 @@ func TestApplyCLIOverridesAppliesPrecedence(t *testing.T) {
ProfileDir: "/from/config/profiles",
SchemaDir: "/from/config/schemas",
ServerAddr: ":1234",
ArtifactRoot: "/from/config/artifacts",
MaxRequestBytes: 111,
MaxArtifactBytes: 222,
MaxResponseBytes: 333,
DefaultRenderFormat: renderformat.PreparedRunFormatJSON,
}
maxRequestBytes := int64(0)
maxArtifactBytes := int64(444)
maxResponseBytes := int64(555)
got, err := ApplyCLIOverrides(base, CLIOverrides{
PromptDir: "./prompts-cli",
ProfileDir: "./profiles-cli",
SchemaDir: "./schemas-cli",
ServerAddr: ":8081",
RenderFormat: "text",
PromptDir: "./prompts-cli",
ProfileDir: "./profiles-cli",
SchemaDir: "./schemas-cli",
ServerAddr: ":8081",
ArtifactRoot: "./artifacts-cli",
MaxRequestBytes: &maxRequestBytes,
MaxArtifactBytes: &maxArtifactBytes,
MaxResponseBytes: &maxResponseBytes,
RenderFormat: "text",
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
@@ -211,11 +291,45 @@ func TestApplyCLIOverridesAppliesPrecedence(t *testing.T) {
if got.ServerAddr != ":8081" {
t.Fatalf("unexpected server addr: %q", got.ServerAddr)
}
if got.ArtifactRoot != filepath.Clean("./artifacts-cli") {
t.Fatalf("unexpected artifact root: %q", got.ArtifactRoot)
}
if got.MaxRequestBytes != 0 {
t.Fatalf("unexpected max request bytes: %d", got.MaxRequestBytes)
}
if got.MaxArtifactBytes != 444 {
t.Fatalf("unexpected max artifact bytes: %d", got.MaxArtifactBytes)
}
if got.MaxResponseBytes != 555 {
t.Fatalf("unexpected max response bytes: %d", got.MaxResponseBytes)
}
if got.DefaultRenderFormat != renderformat.PreparedRunFormatText {
t.Fatalf("unexpected render format: %q", got.DefaultRenderFormat)
}
}
func TestApplyCLIOverridesRejectsNegativeHTTPSizeLimits(t *testing.T) {
negative := int64(-1)
tests := []struct {
name string
overrides CLIOverrides
}{
{name: "request", overrides: CLIOverrides{MaxRequestBytes: &negative}},
{name: "artifact", overrides: CLIOverrides{MaxArtifactBytes: &negative}},
{name: "response", overrides: CLIOverrides{MaxResponseBytes: &negative}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := ApplyCLIOverrides(BuiltInDefaults(), tc.overrides)
if !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("expected ErrInvalidConfig, got %v", err)
}
})
}
}
func TestApplyCLIOverridesInvalidRenderFormatReturnsError(t *testing.T) {
_, err := ApplyCLIOverrides(BuiltInDefaults(), CLIOverrides{RenderFormat: "yaml"})
if err == nil {

View File

@@ -7,13 +7,16 @@ import (
)
const (
HTTPAddrDefault = ":8080"
SchemaDirDefault = "."
OutputArtifactName = "output"
ContentTypeTextPlain = "text/plain"
ContentTypeTextMarkdown = "text/markdown"
ContentTypeApplicationJSON = "application/json"
OpenAIChatCompletionsPath = "/chat/completions"
HTTPAddrDefault = ":8080"
SchemaDirDefault = "."
OutputArtifactName = "output"
ContentTypeTextPlain = "text/plain"
ContentTypeTextMarkdown = "text/markdown"
ContentTypeApplicationJSON = "application/json"
OpenAIChatCompletionsPath = "/chat/completions"
HTTPMaxRequestBytesDefault = 16 * 1024 * 1024
HTTPMaxArtifactBytesDefault = 16 * 1024 * 1024
HTTPMaxResponseBytesDefault = 16 * 1024 * 1024
ExecutionDefaultTemperature = 0.0
ExecutionDefaultMaxTokens = 0

View File

@@ -63,6 +63,7 @@ type RunRequest struct {
PromptID string
PromptVersion string
ProfileID string
APIKey string `json:"-" yaml:"-"`
Inputs map[string]ArtifactRef
Vars map[string]string
Execution *ExecutionTargetOverride
@@ -89,7 +90,6 @@ type RunResult struct {
StartTime time.Time
EndTime time.Time
Duration time.Duration
Error error
}
// PreparedRun contains pre-LLM execution state from the prepare/render phase.
@@ -170,6 +170,7 @@ type ExecutionProfile struct {
ServiceTier string `yaml:"service_tier"`
ReasoningEffort string `yaml:"reasoning_effort"`
APIKeyEnv string `yaml:"api_key_env"`
APIKeyRequired bool `yaml:"-" json:"-"`
ExtraParams map[string]any `yaml:"extra_params"`
}
@@ -207,6 +208,8 @@ type ExecutionTarget struct {
ServiceTier string `yaml:"service_tier" json:"service_tier"`
ReasoningEffort string `yaml:"reasoning_effort" json:"reasoning_effort"`
APIKeyEnv string `yaml:"api_key_env" json:"api_key_env"`
APIKey string `yaml:"-" json:"-"`
APIKeyRequired bool `yaml:"-" json:"-"`
ExtraParams map[string]any `yaml:"extra_params" json:"extra_params"`
}
@@ -283,23 +286,3 @@ type ValidationResult struct {
RepairAttempts int
IsValid bool
}
// RunMetadata contains auditing information for a run.
type RunMetadata struct {
RunID string
PromptID string
PromptVersion string
PromptHash string
RenderedPromptHash string
SelectedProfileID string
InputHashes map[string]string
ModelEndpoint string
ModelName string
Params ExecutionTarget
Timestamp time.Time
Duration time.Duration
Usage TokenUsage
ValidationMode ValidationMode
ValidationStatus ValidationStatus
RepairAttempts int
}

View File

@@ -20,6 +20,7 @@ func TestPreparedRunJSONDoesNotIncludeSecretValues(t *testing.T) {
Endpoint: "http://llm/v1",
Model: "gpt-test",
APIKeyEnv: envName,
APIKey: secret,
},
InputHashes: map[string]string{"transcript": "hash-1"},
RenderedPromptHash: "rendered-hash",

View File

@@ -2,7 +2,10 @@ package filecatalog
import (
"context"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
@@ -23,7 +26,7 @@ func FindYAMLFiles(ctx context.Context, root string) ([]string, error) {
if d.IsDir() {
return nil
}
if !isYAMLFile(d.Name()) {
if !IsYAMLFile(d.Name()) {
return nil
}
files = append(files, path)
@@ -33,15 +36,100 @@ func FindYAMLFiles(ctx context.Context, root string) ([]string, error) {
return files, err
}
// FindFSYAMLFiles returns sorted paths for .yaml and .yml files under root in fsys.
func FindFSYAMLFiles(ctx context.Context, fsys fs.FS, root string) ([]string, error) {
cleanRoot := CleanFSRoot(root)
var files []string
err := fs.WalkDir(fsys, cleanRoot, func(name string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if d.IsDir() {
return nil
}
if !IsYAMLFile(d.Name()) {
return nil
}
files = append(files, name)
return nil
})
sort.Strings(files)
return files, err
}
// RelativePath computes a clean relative path from root to path.
func RelativePath(root string, path string) string {
rel, err := filepath.Rel(root, path)
func RelativePath(root string, filePath string) string {
rel, err := filepath.Rel(root, filePath)
if err != nil {
return filepath.Clean(path)
return filepath.Clean(filePath)
}
return filepath.Clean(rel)
}
// CleanFSRoot normalizes a root path for use with fs.FS.
func CleanFSRoot(root string) string {
root = strings.TrimSpace(root)
if root == "" || root == "." {
return "."
}
return path.Clean(root)
}
// DisplayPath returns name relative to root for messages about fs.FS paths.
func DisplayPath(root string, name string) string {
cleanRoot := CleanFSRoot(root)
cleanName := path.Clean(name)
if cleanRoot == "." {
return cleanName
}
prefix := strings.TrimSuffix(cleanRoot, "/") + "/"
if strings.HasPrefix(cleanName, prefix) {
return strings.TrimPrefix(cleanName, prefix)
}
return cleanName
}
// ResolveFSPath resolves userPath from baseDir and keeps it inside root.
func ResolveFSPath(root string, baseDir string, userPath string) (string, string, error) {
cleanRoot := CleanFSRoot(root)
cleanBase := path.Clean(strings.TrimSpace(baseDir))
if cleanBase == "" {
cleanBase = cleanRoot
}
if !containsFSPath(cleanRoot, cleanBase) {
return "", "", fmt.Errorf("base path %q is outside source root %q", cleanBase, cleanRoot)
}
cleanUserPath := strings.TrimSpace(userPath)
if cleanUserPath == "" {
return "", "", fmt.Errorf("path is required")
}
cleanUserPath = path.Clean(cleanUserPath)
if path.IsAbs(cleanUserPath) {
return "", "", fmt.Errorf("path %q must be relative", userPath)
}
resolved := path.Clean(path.Join(cleanBase, cleanUserPath))
if !containsFSPath(cleanRoot, resolved) {
return "", "", fmt.Errorf("path %q escapes source root %q", userPath, cleanRoot)
}
return resolved, DisplayPath(cleanRoot, resolved), nil
}
func containsFSPath(root string, name string) bool {
root = CleanFSRoot(root)
name = path.Clean(name)
if root == "." {
return name == "." || (name != ".." && !strings.HasPrefix(name, "../"))
}
return name == root || strings.HasPrefix(name, strings.TrimSuffix(root, "/")+"/")
}
// Stem strips .yaml or .yml from a file name.
func Stem(name string) string {
name = strings.TrimSuffix(name, ".yaml")
@@ -49,6 +137,6 @@ func Stem(name string) string {
return name
}
func isYAMLFile(name string) bool {
func IsYAMLFile(name string) bool {
return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml")
}

View File

@@ -6,7 +6,9 @@ import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"testing/fstest"
)
func TestFindYAMLFilesNestedSortedAndFiltered(t *testing.T) {
@@ -43,6 +45,42 @@ func TestFindYAMLFilesHonorsContextCancellation(t *testing.T) {
}
}
func TestFindFSYAMLFilesNestedSortedAndFiltered(t *testing.T) {
fsys := fstest.MapFS{
"prompts/z/prompt.yml": &fstest.MapFile{Data: []byte("id: z")},
"prompts/a/profile.yaml": &fstest.MapFile{Data: []byte("id: a")},
"prompts/a/ignore.txt": &fstest.MapFile{Data: []byte("not yaml")},
"prompts/b/ignore.yaml.bak": &fstest.MapFile{Data: []byte("not yaml")},
"other/ignored.yaml": &fstest.MapFile{Data: []byte("id: ignored")},
}
got, err := FindFSYAMLFiles(context.Background(), fsys, " prompts ")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
want := []string{
"prompts/a/profile.yaml",
"prompts/z/prompt.yml",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected sorted YAML files %v, got %v", want, got)
}
}
func TestFindFSYAMLFilesHonorsContextCancellation(t *testing.T) {
fsys := fstest.MapFS{
"one.yaml": &fstest.MapFile{Data: []byte("id: one")},
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := FindFSYAMLFiles(ctx, fsys, ".")
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", err)
}
}
func TestRelativePathNested(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "nested", "profiles", "local.yaml")
@@ -53,6 +91,133 @@ func TestRelativePathNested(t *testing.T) {
}
}
func TestCleanFSRoot(t *testing.T) {
tests := []struct {
name string
root string
want string
}{
{name: "empty", root: "", want: "."},
{name: "dot", root: ".", want: "."},
{name: "trimmed", root: " prompts/../profiles ", want: "profiles"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := CleanFSRoot(tc.root); got != tc.want {
t.Fatalf("expected %q, got %q", tc.want, got)
}
})
}
}
func TestDisplayPath(t *testing.T) {
tests := []struct {
name string
root string
path string
want string
}{
{name: "root dot", root: ".", path: "profiles/local.yaml", want: "profiles/local.yaml"},
{name: "nested root", root: "profiles", path: "profiles/local.yaml", want: "local.yaml"},
{name: "outside root", root: "profiles", path: "other/local.yaml", want: "other/local.yaml"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := DisplayPath(tc.root, tc.path); got != tc.want {
t.Fatalf("expected %q, got %q", tc.want, got)
}
})
}
}
func TestResolveFSPath(t *testing.T) {
tests := []struct {
name string
root string
baseDir string
userPath string
wantPath string
wantDisplay string
wantErr string
}{
{
name: "sibling inside root",
root: "prompts",
baseDir: "prompts/nested",
userPath: "./messages/user.tmpl",
wantPath: "prompts/nested/messages/user.tmpl",
wantDisplay: "nested/messages/user.tmpl",
},
{
name: "parent inside root",
root: "prompts",
baseDir: "prompts/nested",
userPath: "../shared/user.tmpl",
wantPath: "prompts/shared/user.tmpl",
wantDisplay: "shared/user.tmpl",
},
{
name: "escape rejected",
root: "prompts",
baseDir: "prompts/nested",
userPath: "../../outside.tmpl",
wantErr: "escapes source root",
},
{
name: "absolute path rejected",
root: "prompts",
baseDir: "prompts/nested",
userPath: "/outside.tmpl",
wantErr: "must be relative",
},
{
name: "empty path rejected",
root: "prompts",
baseDir: "prompts/nested",
userPath: " ",
wantErr: "path is required",
},
{
name: "dot root allows normal relative path",
root: ".",
baseDir: ".",
userPath: "schemas/events.schema.json",
wantPath: "schemas/events.schema.json",
wantDisplay: "schemas/events.schema.json",
},
{
name: "dot root rejects parent escape",
root: ".",
baseDir: ".",
userPath: "../outside.tmpl",
wantErr: "escapes source root",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gotPath, gotDisplay, err := ResolveFSPath(tc.root, tc.baseDir, tc.userPath)
if tc.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q", tc.wantErr)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error to contain %q, got %v", tc.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if gotPath != tc.wantPath || gotDisplay != tc.wantDisplay {
t.Fatalf("expected path/display %q/%q, got %q/%q", tc.wantPath, tc.wantDisplay, gotPath, gotDisplay)
}
})
}
}
func TestStemStripsYAMLExtensions(t *testing.T) {
tests := []struct {
name string
@@ -73,6 +238,27 @@ func TestStemStripsYAMLExtensions(t *testing.T) {
}
}
func TestIsYAMLFile(t *testing.T) {
tests := []struct {
name string
in string
want bool
}{
{name: "yaml", in: "prompt.yaml", want: true},
{name: "yml", in: "profile.yml", want: true},
{name: "backup", in: "profile.yaml.bak", want: false},
{name: "uppercase", in: "profile.YAML", want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := IsYAMLFile(tc.in); got != tc.want {
t.Fatalf("expected %v, got %v", tc.want, got)
}
})
}
}
func mustWriteFile(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {

View File

@@ -92,6 +92,20 @@ func TestTextFormatterDoesNotIncludeResolvedAPIKeyValue(t *testing.T) {
}
}
func TestTextFormatterDoesNotIncludeDirectAPIKeyValue(t *testing.T) {
const directKey = "direct-format-key"
prepared := samplePreparedRun()
prepared.EffectiveModelParams.APIKey = directKey
out, err := FormatPreparedRun(prepared, PreparedRunFormatText)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if strings.Contains(string(out), directKey) {
t.Fatalf("text output should not include direct api key value: %s", out)
}
}
func TestTextFormatterIncludesMessageCacheControlBeforeContent(t *testing.T) {
prepared := samplePreparedRun()
prepared.Messages = []domain.RenderedMessage{
@@ -269,6 +283,20 @@ func TestJSONFormatterDoesNotIncludeResolvedAPIKeyValue(t *testing.T) {
}
}
func TestJSONFormatterDoesNotIncludeDirectAPIKeyValue(t *testing.T) {
const directKey = "direct-format-key"
prepared := samplePreparedRun()
prepared.EffectiveModelParams.APIKey = directKey
out, err := FormatPreparedRun(prepared, PreparedRunFormatJSON)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if strings.Contains(string(out), directKey) {
t.Fatalf("json output should not include direct api key value: %s", out)
}
}
func TestParsePreparedRunOutputFormatRecognizesSupportedNames(t *testing.T) {
tests := []struct {
name string

View File

@@ -55,10 +55,11 @@ func NewOpenAICompatibleClient(cfg OpenAICompatibleConfig) (*OpenAICompatibleCli
var client *http.Client
if cfg.HTTPClient != nil {
client = cfg.HTTPClient
if client.Timeout == 0 {
client.Timeout = timeout
cloned := *cfg.HTTPClient
if cloned.Timeout == 0 {
cloned.Timeout = timeout
}
client = &cloned
} else {
client = &http.Client{Timeout: timeout}
}
@@ -105,7 +106,9 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera
return nil, fmt.Errorf("%w: failed to create request: %v", ErrRequestFailed, err)
}
httpReq.Header.Set("Content-Type", "application/json")
if envName := strings.TrimSpace(req.Target.APIKeyEnv); envName != "" {
if apiKey := strings.TrimSpace(req.Target.APIKey); apiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
} else if envName := strings.TrimSpace(req.Target.APIKeyEnv); envName != "" {
apiKey := strings.TrimSpace(os.Getenv(envName))
if apiKey == "" {
return nil, fmt.Errorf("%w: api key environment variable %q is not set", ErrInvalidRequest, envName)
@@ -136,8 +139,8 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera
defer httpResp.Body.Close()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(httpResp.Body, 4096))
return nil, fmt.Errorf("%w: status=%d body=%q", ErrUnexpectedStatus, httpResp.StatusCode, strings.TrimSpace(string(body)))
_, _ = io.Copy(io.Discard, io.LimitReader(httpResp.Body, 4096))
return nil, fmt.Errorf("%w: status=%d", ErrUnexpectedStatus, httpResp.StatusCode)
}
var wireResp openAIChatResponse

View File

@@ -14,6 +14,64 @@ import (
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
func TestNewOpenAICompatibleClientDoesNotMutateSuppliedZeroTimeoutClient(t *testing.T) {
transport := http.DefaultTransport
supplied := &http.Client{Transport: transport}
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
HTTPClient: supplied,
})
if err != nil {
t.Fatalf("unexpected constructor error: %v", err)
}
if supplied.Timeout != 0 {
t.Fatalf("expected supplied client timeout to remain zero, got %v", supplied.Timeout)
}
if client.httpClient == supplied {
t.Fatal("expected constructed client to use a cloned HTTP client")
}
if client.httpClient.Timeout != client.timeout {
t.Fatalf("expected cloned client timeout %v, got %v", client.timeout, client.httpClient.Timeout)
}
if client.httpClient.Timeout <= 0 {
t.Fatalf("expected constructed client to use a positive default timeout, got %v", client.httpClient.Timeout)
}
if client.httpClient.Transport != transport {
t.Fatal("expected cloned client to preserve the supplied transport")
}
}
func TestNewOpenAICompatibleClientDoesNotMutateSuppliedNonzeroTimeoutClient(t *testing.T) {
transport := http.DefaultTransport
suppliedTimeout := 37 * time.Second
supplied := &http.Client{
Timeout: suppliedTimeout,
Transport: transport,
}
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
Timeout: 2 * time.Second,
HTTPClient: supplied,
})
if err != nil {
t.Fatalf("unexpected constructor error: %v", err)
}
if supplied.Timeout != suppliedTimeout {
t.Fatalf("expected supplied client timeout to remain %v, got %v", suppliedTimeout, supplied.Timeout)
}
if client.httpClient == supplied {
t.Fatal("expected constructed client to use a cloned HTTP client")
}
if client.httpClient.Timeout != suppliedTimeout {
t.Fatalf("expected cloned client timeout %v, got %v", suppliedTimeout, client.httpClient.Timeout)
}
if client.httpClient.Transport != transport {
t.Fatal("expected cloned client to preserve the supplied transport")
}
}
func TestOpenAICompatibleClientGenerateSuccess(t *testing.T) {
type observedRequest struct {
Authorization string
@@ -148,6 +206,38 @@ func TestOpenAICompatibleClientGenerateSuccess(t *testing.T) {
}
}
func TestOpenAICompatibleClientDirectAPIKeyPreferredOverEnv(t *testing.T) {
const directKey = "direct-llm-key"
t.Setenv("SCRIPTORIUM_TEST_API_KEY", "env-key")
var gotAuth string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
_, _ = 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",
APIKeyEnv: "SCRIPTORIUM_TEST_API_KEY",
APIKey: directKey,
},
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if gotAuth != "Bearer "+directKey {
t.Fatalf("unexpected Authorization header: %q", gotAuth)
}
}
func TestOpenAICompatibleClientSerializesCacheControlledMessageAsContentBlock(t *testing.T) {
var observedBody map[string]any
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -813,9 +903,10 @@ func TestOpenAICompatibleClientEndpointOverride(t *testing.T) {
}
func TestOpenAICompatibleClientNon2xxError(t *testing.T) {
const sensitiveBody = `provider-secret-fragment request_payload_details`
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"bad request payload"}`))
_, _ = w.Write([]byte(`{"error":"` + sensitiveBody + `"}`))
}))
defer ts.Close()
@@ -833,8 +924,11 @@ func TestOpenAICompatibleClientNon2xxError(t *testing.T) {
if !errors.Is(err, ErrUnexpectedStatus) {
t.Fatalf("expected ErrUnexpectedStatus, got %v", err)
}
if !strings.Contains(err.Error(), "400") || !strings.Contains(err.Error(), "bad request payload") {
t.Fatalf("expected status/body details, got %v", err)
if !strings.Contains(err.Error(), "status=400") {
t.Fatalf("expected status detail, got %v", err)
}
if strings.Contains(err.Error(), sensitiveBody) {
t.Fatalf("expected provider response body to be redacted, got %v", err)
}
}

View File

@@ -0,0 +1,9 @@
id: aion-2
endpoint: https://openrouter.ai/api/v1
model: aion-labs/aion-2.0
temperature: 0.72
reasoning_effort: high
top_p: 0.95
timeout_seconds: 180
api_key_env: OPENROUTER_API_KEY
service_tier: flex

View File

@@ -0,0 +1,7 @@
id: claude-fable-latest
endpoint: https://openrouter.ai/api/v1
model: "~anthropic/claude-fable-latest"
reasoning_effort: high
timeout_seconds: 600
api_key_env: OPENROUTER_API_KEY
service_tier: flex

View File

@@ -0,0 +1,7 @@
id: claude-haiku-latest
endpoint: https://openrouter.ai/api/v1
model: "~anthropic/claude-haiku-latest"
reasoning_effort: medium
timeout_seconds: 240
api_key_env: OPENROUTER_API_KEY
service_tier: flex

View File

@@ -0,0 +1,7 @@
id: claude-opus-latest
endpoint: https://openrouter.ai/api/v1
model: "~anthropic/claude-opus-latest"
reasoning_effort: high
timeout_seconds: 240
api_key_env: OPENROUTER_API_KEY
service_tier: flex

View File

@@ -0,0 +1,7 @@
id: claude-sonnet-latest
endpoint: https://openrouter.ai/api/v1
model: "~anthropic/claude-sonnet-latest"
reasoning_effort: high
timeout_seconds: 240
api_key_env: OPENROUTER_API_KEY
service_tier: flex

View File

@@ -0,0 +1,7 @@
id: deepseek-3-2
endpoint: https://openrouter.ai/api/v1
model: deepseek/deepseek-v3.2
reasoning_effort: high
timeout_seconds: 180
api_key_env: OPENROUTER_API_KEY
service_tier: flex

View File

@@ -0,0 +1,7 @@
id: deepseek-4-pro
endpoint: https://openrouter.ai/api/v1
model: deepseek/deepseek-v4-pro
reasoning_effort: high
timeout_seconds: 180
api_key_env: OPENROUTER_API_KEY
service_tier: flex

View File

@@ -0,0 +1,9 @@
id: gemini-2-flash-lite
endpoint: https://openrouter.ai/api/v1
model: "google/gemini-2.5-flash-lite"
#temperature: 0.15
reasoning_effort: high
#top_p: 0.98
timeout_seconds: 240
api_key_env: OPENROUTER_API_KEY
service_tier: flex

View File

@@ -0,0 +1,9 @@
id: gemini-2-flash
endpoint: https://openrouter.ai/api/v1
model: "google/gemini-2.5-flash"
#temperature: 0.15
reasoning_effort: high
#top_p: 0.98
timeout_seconds: 240
api_key_env: OPENROUTER_API_KEY
service_tier: flex

View File

@@ -0,0 +1,9 @@
id: gemini-2-pro
endpoint: https://openrouter.ai/api/v1
model: "google/gemini-2.5-pro"
#temperature: 0.15
reasoning_effort: high
#top_p: 0.98
timeout_seconds: 240
api_key_env: OPENROUTER_API_KEY
service_tier: flex

View File

@@ -0,0 +1,9 @@
id: gemini-3-flash-lite
endpoint: https://openrouter.ai/api/v1
model: "google/gemini-3.1-flash-lite"
#temperature: 0.15
reasoning_effort: high
#top_p: 0.98
timeout_seconds: 240
api_key_env: OPENROUTER_API_KEY
service_tier: flex

View File

@@ -0,0 +1,9 @@
id: gemini-flash-latest
endpoint: https://openrouter.ai/api/v1
model: "~google/gemini-flash-latest"
#temperature: 0.15
reasoning_effort: high
#top_p: 0.98
timeout_seconds: 240
api_key_env: OPENROUTER_API_KEY
service_tier: flex

View File

@@ -0,0 +1,9 @@
id: gemini-pro-latest
endpoint: https://openrouter.ai/api/v1
model: "~google/gemini-pro-latest"
#temperature: 0.15
reasoning_effort: high
#top_p: 0.98
timeout_seconds: 240
api_key_env: OPENROUTER_API_KEY
service_tier: flex

View File

@@ -0,0 +1,9 @@
id: gemma-4-31b
endpoint: https://openrouter.ai/api/v1
model: google/gemma-4-31b-it:exacto
temperature: 0.15
reasoning_effort: high
top_p: 0.98
timeout_seconds: 240
api_key_env: OPENROUTER_API_KEY
service_tier: flex

View File

@@ -0,0 +1,9 @@
id: minimax-m2
endpoint: https://openrouter.ai/api/v1
model: minimax/minimax-m2.5
temperature: 0.5
reasoning_effort: high
top_p: 0.95
timeout_seconds: 180
api_key_env: OPENROUTER_API_KEY
service_tier: flex

View File

@@ -0,0 +1,9 @@
id: minimax-m3
endpoint: https://openrouter.ai/api/v1
model: minimax/minimax-m3
#temperature: 0.5
reasoning_effort: high
#top_p: 0.95
timeout_seconds: 180
api_key_env: OPENROUTER_API_KEY
service_tier: flex

View File

@@ -0,0 +1,7 @@
id: mistral-large-2512
endpoint: https://openrouter.ai/api/v1
model: mistralai/mistral-large-2512
temperature: 0.15
top_p: 0.98
timeout_seconds: 180
api_key_env: OPENROUTER_API_KEY

View File

@@ -0,0 +1,8 @@
id: mistral-medium-3-5
endpoint: https://openrouter.ai/api/v1
model: mistralai/mistral-medium-3-5
temperature: 0.15
reasoning_effort: high
top_p: 0.98
timeout_seconds: 180
api_key_env: OPENROUTER_API_KEY

View File

@@ -0,0 +1,7 @@
id: mistral-small-3
endpoint: https://openrouter.ai/api/v1
model: mistralai/mistral-small-3.2-24b-instruct
temperature: 0.05
top_p: 1.0
timeout_seconds: 180
api_key_env: OPENROUTER_API_KEY

View File

@@ -0,0 +1,8 @@
id: mistral-small-4
endpoint: https://openrouter.ai/api/v1
model: mistralai/mistral-small-2603
temperature: 0.1
reasoning_effort: high
top_p: 0.98
timeout_seconds: 180
api_key_env: OPENROUTER_API_KEY

View File

@@ -0,0 +1,7 @@
id: nemotron-3-ultra
endpoint: https://openrouter.ai/api/v1
model: nvidia/nemotron-3-ultra-550b-a55b
reasoning_effort: high
timeout_seconds: 180
api_key_env: OPENROUTER_API_KEY
service_tier: flex

View File

@@ -0,0 +1,7 @@
id: gpt-5-mini
endpoint: https://openrouter.ai/api/v1
model: "openai/gpt-5.4-mini"
reasoning_effort: high
timeout_seconds: 240
api_key_env: OPENROUTER_API_KEY
service_tier: flex

View File

@@ -0,0 +1,7 @@
id: gpt-5-nano
endpoint: https://openrouter.ai/api/v1
model: "openai/gpt-5.4-nano"
reasoning_effort: high
timeout_seconds: 240
api_key_env: OPENROUTER_API_KEY
service_tier: flex

View File

@@ -0,0 +1,31 @@
package builtin
import (
"embed"
"strings"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
)
const assetRoot = "assets"
//go:embed assets/**/*.yml
var assets embed.FS
func NewRepository() profile.Repository {
return profile.NewFSRepository(assets, assetRoot)
}
func NewRepositoryWithPrimary(primary profile.Repository) profile.Repository {
if primary == nil {
return NewRepository()
}
return profile.NewOverlayRepository(primary, NewRepository())
}
func NewRepositoryWithDirectory(dir string) profile.Repository {
if strings.TrimSpace(dir) == "" {
return NewRepository()
}
return NewRepositoryWithPrimary(profile.NewFilesystemRepository(dir))
}

View File

@@ -0,0 +1,127 @@
package builtin
import (
"context"
"errors"
"io/fs"
"strings"
"testing"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
"gopkg.in/yaml.v3"
)
func TestBuiltInProfilesValidateThroughRepository(t *testing.T) {
repo := NewRepository()
ids := loadBuiltInProfileIDs(t)
if len(ids) == 0 {
t.Fatal("expected built-in profiles")
}
for id := range ids {
t.Run(id, func(t *testing.T) {
p, err := repo.GetProfile(context.Background(), id)
if err != nil {
t.Fatalf("expected built-in profile %q to load, got %v", id, err)
}
if p.ID != id {
t.Fatalf("expected profile id %q, got %q", id, p.ID)
}
})
}
}
func TestBuiltInProfilesDoNotContainDuplicateIDsOrRawAPIKeys(t *testing.T) {
loadBuiltInProfileIDs(t)
}
func loadBuiltInProfileIDs(t *testing.T) map[string]string {
t.Helper()
ids := map[string]string{}
err := fs.WalkDir(assets, assetRoot, func(name string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(name, ".yml") {
return nil
}
data, err := assets.ReadFile(name)
if err != nil {
t.Fatalf("failed to read built-in profile %s: %v", name, err)
}
var raw map[string]any
if err := yaml.Unmarshal(data, &raw); err != nil {
t.Fatalf("failed to decode built-in profile %s: %v", name, err)
}
if _, ok := raw["api_key"]; ok {
t.Fatalf("built-in profile %s contains raw api_key", name)
}
id, ok := raw["id"].(string)
if !ok || strings.TrimSpace(id) == "" {
t.Fatalf("built-in profile %s has missing id", name)
}
if previous, ok := ids[id]; ok {
t.Fatalf("duplicate built-in profile id %q in %s and %s", id, previous, name)
}
ids[id] = name
return nil
})
if err != nil {
t.Fatalf("failed to walk built-in profiles: %v", err)
}
return ids
}
func TestRepositoryWithPrimaryUsesPrimaryBeforeBuiltIns(t *testing.T) {
repo := NewRepositoryWithPrimary(staticProfileRepo{
profiles: map[string]string{"mistral-small-3": "custom-model"},
})
p, err := repo.GetProfile(context.Background(), "mistral-small-3")
if err != nil {
t.Fatalf("expected profile to load, got %v", err)
}
if p.Model != "custom-model" {
t.Fatalf("expected primary profile to override built-in, got %+v", p)
}
}
func TestRepositoryWithPrimaryFallsBackToBuiltIns(t *testing.T) {
repo := NewRepositoryWithPrimary(staticProfileRepo{})
p, err := repo.GetProfile(context.Background(), "mistral-small-3")
if err != nil {
t.Fatalf("expected built-in profile to load, got %v", err)
}
if p.ID != "mistral-small-3" {
t.Fatalf("unexpected profile: %+v", p)
}
}
func TestRepositoryWithPrimaryDoesNotFallBackAfterPrimaryError(t *testing.T) {
repo := NewRepositoryWithPrimary(staticProfileRepo{err: profile.ErrInvalidProfile})
_, err := repo.GetProfile(context.Background(), "mistral-small-3")
if !errors.Is(err, profile.ErrInvalidProfile) {
t.Fatalf("expected primary error, got %v", err)
}
}
type staticProfileRepo struct {
profiles map[string]string
err error
}
func (r staticProfileRepo) GetProfile(_ context.Context, id string) (*domain.ExecutionProfile, error) {
if r.err != nil {
return nil, r.err
}
if model, ok := r.profiles[id]; ok {
return &domain.ExecutionProfile{ID: id, Endpoint: "http://primary/v1", Model: model}, nil
}
return nil, profile.ErrProfileNotFound
}

View File

@@ -5,8 +5,9 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"path"
"strings"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
@@ -30,11 +31,56 @@ func NewFilesystemRepository(dir string) Repository {
}
func (r *filesystemRepository) GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error) {
return loadProfile(ctx, os.DirFS(r.dir), ".", id)
}
type fsRepository struct {
fsys fs.FS
root string
}
func NewFSRepository(fsys fs.FS, root string) Repository {
return &fsRepository{fsys: fsys, root: root}
}
func (r *fsRepository) GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error) {
return loadProfile(ctx, r.fsys, r.root, id)
}
type overlayRepository struct {
primary Repository
fallback Repository
}
func NewOverlayRepository(primary, fallback Repository) Repository {
return &overlayRepository{primary: primary, fallback: fallback}
}
func (r *overlayRepository) GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error) {
if r.primary != nil {
prof, err := r.primary.GetProfile(ctx, id)
if err == nil {
return prof, nil
}
if !errors.Is(err, ErrProfileNotFound) {
return nil, err
}
}
if r.fallback == nil {
return nil, ErrProfileNotFound
}
return r.fallback.GetProfile(ctx, id)
}
func loadProfile(ctx context.Context, fsys fs.FS, root string, id string) (*domain.ExecutionProfile, error) {
if strings.TrimSpace(id) == "" {
return nil, fmt.Errorf("%w: profile id is required", ErrInvalidProfile)
}
if fsys == nil {
return nil, fmt.Errorf("failed to read profile directory: filesystem is nil")
}
files, err := filecatalog.FindYAMLFiles(ctx, r.dir)
files, err := filecatalog.FindFSYAMLFiles(ctx, fsys, root)
if err != nil {
return nil, fmt.Errorf("failed to read profile directory: %w", err)
}
@@ -47,9 +93,9 @@ func (r *filesystemRepository) GetProfile(ctx context.Context, id string) (*doma
default:
}
relPath := filecatalog.RelativePath(r.dir, fullPath)
fileMatch := filecatalog.Stem(filepath.Base(fullPath)) == id
data, err := os.ReadFile(fullPath)
relPath := filecatalog.DisplayPath(root, fullPath)
fileMatch := filecatalog.Stem(path.Base(fullPath)) == id
data, err := fs.ReadFile(fsys, fullPath)
if err != nil {
return nil, fmt.Errorf("failed to read profile file %s: %w", relPath, err)
}

View File

@@ -8,6 +8,9 @@ import (
"path/filepath"
"strings"
"testing"
"testing/fstest"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
func TestFilesystemRepository_GetProfile(t *testing.T) {
@@ -261,3 +264,216 @@ func writeProfileTestFile(t *testing.T, path string, content string) {
t.Fatalf("failed to write profile test file %q: %v", path, err)
}
}
func TestFSRepository(t *testing.T) {
ctx := context.Background()
t.Run("loads valid profiles from nested directories", func(t *testing.T) {
repo := NewFSRepository(fstest.MapFS{
"profiles/provider/nested.yaml": profileMapFile(`
id: nested-profile
endpoint: http://localhost:8000/v1
model: nested-model
temperature: 0.1
`),
}, "profiles")
p, err := repo.GetProfile(ctx, "nested-profile")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if p.ID != "nested-profile" || p.Model != "nested-model" {
t.Fatalf("unexpected profile: %+v", p)
}
})
t.Run("rejects unknown YAML fields", func(t *testing.T) {
repo := NewFSRepository(fstest.MapFS{
"profiles/unknown.yaml": profileMapFile(`
id: unknown-profile
endpoint: http://localhost:8000/v1
model: model
unknown: value
`),
}, "profiles")
_, err := repo.GetProfile(ctx, "unknown-profile")
if !errors.Is(err, ErrInvalidYAML) {
t.Fatalf("expected ErrInvalidYAML, got %v", err)
}
})
t.Run("rejects raw api_key in selected profile", func(t *testing.T) {
repo := NewFSRepository(fstest.MapFS{
"profiles/raw.yaml": profileMapFile(`
id: raw-profile
endpoint: http://localhost:8000/v1
model: model
api_key: secret
`),
}, "profiles")
_, err := repo.GetProfile(ctx, "raw-profile")
if !errors.Is(err, ErrRawAPIKeyNotAllowed) {
t.Fatalf("expected ErrRawAPIKeyNotAllowed, got %v", err)
}
})
t.Run("ignores raw api_key in non-selected profiles", func(t *testing.T) {
repo := NewFSRepository(fstest.MapFS{
"profiles/raw.yaml": profileMapFile(`
id: raw-profile
endpoint: http://localhost:8000/v1
model: model
api_key: secret
`),
"profiles/valid.yaml": profileMapFile(`
id: valid-profile
endpoint: http://localhost:8000/v1
model: model
`),
}, "profiles")
p, err := repo.GetProfile(ctx, "valid-profile")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if p.ID != "valid-profile" {
t.Fatalf("unexpected profile: %+v", p)
}
})
t.Run("rejects duplicate IDs within one source", func(t *testing.T) {
repo := NewFSRepository(fstest.MapFS{
"profiles/a.yaml": profileMapFile(`
id: duplicate-profile
endpoint: http://localhost:8000/v1
model: first
`),
"profiles/nested/b.yaml": profileMapFile(`
id: duplicate-profile
endpoint: http://localhost:8000/v1
model: second
`),
}, "profiles")
_, err := repo.GetProfile(ctx, "duplicate-profile")
if !errors.Is(err, ErrInvalidProfile) {
t.Fatalf("expected ErrInvalidProfile, got %v", err)
}
for _, want := range []string{"duplicate execution profile id", "a.yaml", "nested/b.yaml"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("expected error to contain %q, got %v", want, err)
}
}
})
}
func TestOverlayRepository(t *testing.T) {
ctx := context.Background()
primaryProfile := &domain.ExecutionProfile{ID: "shared", Endpoint: "http://primary", Model: "primary"}
fallbackProfile := &domain.ExecutionProfile{ID: "shared", Endpoint: "http://fallback", Model: "fallback"}
t.Run("returns primary matches before fallback matches", func(t *testing.T) {
repo := NewOverlayRepository(
staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": primaryProfile}},
staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": fallbackProfile}},
)
p, err := repo.GetProfile(ctx, "shared")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if p.Model != "primary" {
t.Fatalf("expected primary profile, got %+v", p)
}
})
t.Run("falls back on primary not found", func(t *testing.T) {
repo := NewOverlayRepository(
staticProfileRepo{},
staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": fallbackProfile}},
)
p, err := repo.GetProfile(ctx, "shared")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if p.Model != "fallback" {
t.Fatalf("expected fallback profile, got %+v", p)
}
})
t.Run("does not fall back after primary load errors", func(t *testing.T) {
for _, tc := range []struct {
name string
err error
}{
{name: "invalid yaml", err: ErrInvalidYAML},
{name: "invalid profile", err: ErrInvalidProfile},
{name: "raw api key", err: ErrRawAPIKeyNotAllowed},
} {
t.Run(tc.name, func(t *testing.T) {
repo := NewOverlayRepository(
staticProfileRepo{err: tc.err},
staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": fallbackProfile}},
)
_, err := repo.GetProfile(ctx, "shared")
if !errors.Is(err, tc.err) {
t.Fatalf("expected %v, got %v", tc.err, err)
}
})
}
})
t.Run("returns not found when both sources miss", func(t *testing.T) {
repo := NewOverlayRepository(staticProfileRepo{}, staticProfileRepo{})
_, err := repo.GetProfile(ctx, "missing")
if !errors.Is(err, ErrProfileNotFound) {
t.Fatalf("expected ErrProfileNotFound, got %v", err)
}
})
t.Run("nil primary uses fallback", func(t *testing.T) {
repo := NewOverlayRepository(nil, staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": fallbackProfile}})
p, err := repo.GetProfile(ctx, "shared")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if p.Model != "fallback" {
t.Fatalf("expected fallback profile, got %+v", p)
}
})
t.Run("nil fallback returns not found after primary miss", func(t *testing.T) {
repo := NewOverlayRepository(staticProfileRepo{}, nil)
_, err := repo.GetProfile(ctx, "missing")
if !errors.Is(err, ErrProfileNotFound) {
t.Fatalf("expected ErrProfileNotFound, got %v", err)
}
})
}
func profileMapFile(content string) *fstest.MapFile {
return &fstest.MapFile{Data: []byte(strings.TrimLeft(content, "\n"))}
}
type staticProfileRepo struct {
profiles map[string]*domain.ExecutionProfile
err error
}
func (r staticProfileRepo) GetProfile(_ context.Context, id string) (*domain.ExecutionProfile, error) {
if r.err != nil {
return nil, r.err
}
if p, ok := r.profiles[id]; ok {
cp := *p
return &cp, nil
}
return nil, ErrProfileNotFound
}

View File

@@ -5,7 +5,9 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
@@ -24,6 +26,11 @@ type filesystemRepository struct {
dir string
}
type fsRepository struct {
fsys fs.FS
root string
}
type promptDefinitionFile struct {
ID string `yaml:"id"`
Version string `yaml:"version"`
@@ -65,6 +72,10 @@ func NewFilesystemRepository(dir string) Repository {
return &filesystemRepository{dir: dir}
}
func NewFSRepository(fsys fs.FS, root string) Repository {
return &fsRepository{fsys: fsys, root: root}
}
func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error) {
if strings.TrimSpace(id) == "" {
return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidPromptDefinition)
@@ -132,6 +143,10 @@ func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id strin
return nil, ErrPromptDefinitionNotFound
}
func (r *fsRepository) GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error) {
return loadPromptDefinition(ctx, r.fsys, r.root, id, version)
}
type promptDefinitionMatch struct {
def *domain.PromptDefinition
path string
@@ -166,7 +181,152 @@ func promptDefinitionFileHasID(path string, id string) bool {
return strings.TrimSpace(raw.ID) == id
}
func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, id string, version string) (*domain.PromptDefinition, error) {
if strings.TrimSpace(id) == "" {
return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidPromptDefinition)
}
if fsys == nil {
return nil, fmt.Errorf("failed to read prompt definition directory: filesystem is nil")
}
files, err := filecatalog.FindFSYAMLFiles(ctx, fsys, root)
if err != nil {
return nil, fmt.Errorf("failed to read prompt definition directory: %w", err)
}
cleanRoot := filecatalog.CleanFSRoot(root)
rootInfo, err := fs.Stat(fsys, cleanRoot)
if err != nil {
return nil, fmt.Errorf("failed to read prompt definition directory: %w", err)
}
var matches []promptDefinitionMatch
for _, fullPath := range files {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
relPath := filecatalog.DisplayPath(root, fullPath)
fileMatch := filecatalog.Stem(path.Base(fullPath)) == id
data, err := fs.ReadFile(fsys, fullPath)
if err != nil {
if fileMatch {
return nil, fmt.Errorf("%w: %s: failed to read prompt definition file: %v", ErrInvalidYAML, relPath, err)
}
continue
}
raw, err := decodePromptDefinition(data)
if err != nil {
if fileMatch || promptDefinitionDataHasID(data, id) {
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, relPath, err)
}
continue
}
def, err := normalizePromptDefinitionFromFS(raw, fsys, root, fullPath, rootInfo.IsDir())
if err != nil {
if fileMatch || strings.TrimSpace(raw.ID) == id {
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, relPath, err)
}
continue
}
if def.ID != id {
continue
}
if version != "" && def.Version != version {
continue
}
matches = append(matches, promptDefinitionMatch{
def: def,
path: relPath,
})
}
if len(matches) > 1 {
paths := make([]string, 0, len(matches))
for _, match := range matches {
paths = append(paths, match.path)
}
if version != "" {
return nil, fmt.Errorf("%w: duplicate prompt definition id %q version %q found in: %s", ErrInvalidPromptDefinition, id, version, strings.Join(paths, ", "))
}
return nil, fmt.Errorf("%w: duplicate prompt definition id %q found in: %s", ErrInvalidPromptDefinition, id, strings.Join(paths, ", "))
}
if len(matches) == 1 {
return matches[0].def, nil
}
return nil, ErrPromptDefinitionNotFound
}
func decodePromptDefinition(data []byte) (*promptDefinitionFile, error) {
var raw promptDefinitionFile
decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(true)
if err := decoder.Decode(&raw); err != nil {
return nil, err
}
return &raw, nil
}
func promptDefinitionDataHasID(data []byte, id string) bool {
var raw struct {
ID string `yaml:"id"`
}
if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&raw); err != nil {
return false
}
return strings.TrimSpace(raw.ID) == id
}
func normalizePromptDefinition(raw *promptDefinitionFile, sourcePath string) (*domain.PromptDefinition, error) {
promptDir := filepath.Dir(sourcePath)
return normalizePromptDefinitionWithContent(raw, func(contentFile string) (string, string, error) {
resolvedPath := strings.TrimSpace(contentFile)
if !filepath.IsAbs(resolvedPath) {
resolvedPath = filepath.Join(promptDir, resolvedPath)
}
resolvedPath = filepath.Clean(resolvedPath)
body, err := os.ReadFile(resolvedPath)
if err != nil {
return "", "", err
}
return string(body), resolvedPath, nil
})
}
func normalizePromptDefinitionFromFS(raw *promptDefinitionFile, fsys fs.FS, root string, sourcePath string, rootIsDir bool) (*domain.PromptDefinition, error) {
promptDir := path.Dir(sourcePath)
return normalizePromptDefinitionWithContent(raw, func(contentFile string) (string, string, error) {
var resolvedPath string
if rootIsDir {
var err error
resolvedPath, _, err = filecatalog.ResolveFSPath(root, promptDir, contentFile)
if err != nil {
return "", "", err
}
} else {
resolvedPath = strings.TrimSpace(contentFile)
if !path.IsAbs(resolvedPath) {
resolvedPath = path.Join(promptDir, resolvedPath)
}
resolvedPath = strings.TrimPrefix(path.Clean(resolvedPath), "/")
}
body, err := fs.ReadFile(fsys, resolvedPath)
if err != nil {
return "", "", err
}
return string(body), resolvedPath, nil
})
}
func normalizePromptDefinitionWithContent(raw *promptDefinitionFile, readContentFile func(string) (string, string, error)) (*domain.PromptDefinition, error) {
if raw == nil {
return nil, errors.New("prompt definition is nil")
}
@@ -206,7 +366,6 @@ func normalizePromptDefinition(raw *promptDefinitionFile, sourcePath string) (*d
}
templates := make([]domain.PromptMessageTemplate, 0, len(raw.Messages))
promptDir := filepath.Dir(sourcePath)
for i, msg := range raw.Messages {
role := strings.TrimSpace(msg.Role)
if role == "" {
@@ -227,17 +386,11 @@ func normalizePromptDefinition(raw *promptDefinitionFile, sourcePath string) (*d
templateContent := msg.Content
resolvedContentFile := ""
if hasContentFile {
resolvedPath := strings.TrimSpace(msg.ContentFile)
if !filepath.IsAbs(resolvedPath) {
resolvedPath = filepath.Join(promptDir, resolvedPath)
}
resolvedPath = filepath.Clean(resolvedPath)
body, err := os.ReadFile(resolvedPath)
body, resolvedPath, err := readContentFile(msg.ContentFile)
if err != nil {
return nil, fmt.Errorf("prompt %q message %d (%s): failed to read content_file %q: %w", id, i, role, msg.ContentFile, err)
}
templateContent = string(body)
templateContent = body
resolvedContentFile = resolvedPath
}

View File

@@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
"testing"
"testing/fstest"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
@@ -324,6 +325,160 @@ output:
})
}
func TestFSRepositoryGetPromptDefinition(t *testing.T) {
repo := NewFSRepository(fstest.MapFS{
"prompts/nested/prompt.yaml": &fstest.MapFile{Data: []byte(`
id: fs-prompt
version: "1.0.0"
inputs:
- name: transcript
required: true
messages:
- role: user
content_file: ./messages/user.tmpl
output:
format: markdown
validation_mode: basic
repair_attempts: 0
`)},
"prompts/nested/messages/user.tmpl": &fstest.MapFile{Data: []byte(`Summarize {{input "transcript"}}.`)},
}, "prompts")
got, err := repo.GetPromptDefinition(context.Background(), "fs-prompt", "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got.ID != "fs-prompt" {
t.Fatalf("unexpected prompt id: %q", got.ID)
}
if len(got.Templates) != 1 || !strings.Contains(got.Templates[0].Content, `{{input "transcript"}}`) {
t.Fatalf("expected content_file body to be loaded, got %+v", got.Templates)
}
if got.Templates[0].ContentFile != "prompts/nested/messages/user.tmpl" {
t.Fatalf("unexpected content file path: %q", got.Templates[0].ContentFile)
}
}
func TestFSRepositoryContentFileContainment(t *testing.T) {
t.Run("nested prompt can reference file inside root", func(t *testing.T) {
repo := NewFSRepository(fstest.MapFS{
"prompts/nested/prompt.yaml": &fstest.MapFile{Data: []byte(`
id: fs-contained-prompt
version: "1.0.0"
messages:
- role: user
content_file: ../shared/user.tmpl
output:
format: markdown
validation_mode: basic
repair_attempts: 0
`)},
"prompts/shared/user.tmpl": &fstest.MapFile{Data: []byte(`Inside root.`)},
}, "prompts")
got, err := repo.GetPromptDefinition(context.Background(), "fs-contained-prompt", "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(got.Templates) != 1 || got.Templates[0].Content != "Inside root." {
t.Fatalf("expected contained content file, got %+v", got.Templates)
}
})
tests := []struct {
name string
contentFile string
wantErr string
}{
{name: "parent escape rejected", contentFile: "../outside.tmpl", wantErr: "escapes source root"},
{name: "absolute path rejected", contentFile: "/outside.tmpl", wantErr: "must be relative"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
repo := NewFSRepository(fstest.MapFS{
"prompts/prompt.yaml": &fstest.MapFile{Data: []byte(`
id: fs-escaped-prompt
version: "1.0.0"
messages:
- role: user
content_file: ` + tc.contentFile + `
output:
format: markdown
validation_mode: basic
repair_attempts: 0
`)},
"outside.tmpl": &fstest.MapFile{Data: []byte(`Outside root.`)},
}, "prompts")
_, err := repo.GetPromptDefinition(context.Background(), "fs-escaped-prompt", "")
if !errors.Is(err, ErrInvalidPromptDefinition) {
t.Fatalf("expected ErrInvalidPromptDefinition, got %v", err)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error to contain %q, got %v", tc.wantErr, err)
}
})
}
}
func TestFSRepositoryRejectsDuplicatePromptIDs(t *testing.T) {
repo := NewFSRepository(fstest.MapFS{
"one.yaml": &fstest.MapFile{Data: []byte(`
id: duplicate-fs-prompt
version: "1.0.0"
messages:
- role: user
content: First.
output:
format: text
validation_mode: none
repair_attempts: 0
`)},
"nested/two.yaml": &fstest.MapFile{Data: []byte(`
id: duplicate-fs-prompt
version: "1.0.0"
messages:
- role: user
content: Second.
output:
format: text
validation_mode: none
repair_attempts: 0
`)},
}, ".")
_, err := repo.GetPromptDefinition(context.Background(), "duplicate-fs-prompt", "")
if !errors.Is(err, ErrInvalidPromptDefinition) {
t.Fatalf("expected ErrInvalidPromptDefinition, got %v", err)
}
if !strings.Contains(err.Error(), "one.yaml") || !strings.Contains(err.Error(), "nested/two.yaml") {
t.Fatalf("expected duplicate paths in error, got %v", err)
}
}
func TestFSRepositoryRejectsUnknownYAMLFields(t *testing.T) {
repo := NewFSRepository(fstest.MapFS{
"not_named_like_id.yaml": &fstest.MapFile{Data: []byte(`
id: strict-fs-prompt
version: "1.0.0"
unknown: true
messages:
- role: user
content: Invalid.
output:
format: text
validation_mode: none
repair_attempts: 0
`)},
}, ".")
_, err := repo.GetPromptDefinition(context.Background(), "strict-fs-prompt", "")
if !errors.Is(err, ErrInvalidYAML) {
t.Fatalf("expected ErrInvalidYAML, got %v", err)
}
}
func assertCacheControl(t *testing.T, got *domain.CacheControl, wantType domain.CacheControlType, wantTTL string) {
t.Helper()
if got == nil {

View File

@@ -27,7 +27,9 @@ var (
ErrInvalidRequest = errors.New("invalid run request")
ErrProfileRequired = errors.New("profile selection is required")
ErrAPIKeyEnvMissing = errors.New("api_key_env points to an unset environment variable")
ErrProfileLoad = errors.New("failed to load prompt definition")
ErrAPIKeyRequired = errors.New("api key is required")
ErrPromptLoad = errors.New("failed to load prompt definition")
ErrProfileLoad = errors.New("failed to load execution profile")
ErrArtifactLoad = errors.New("failed to load artifact")
ErrPromptRender = errors.New("failed to render prompt")
ErrLLMGenerate = errors.New("failed to generate output")
@@ -171,11 +173,11 @@ func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.Pr
def, err := r.promptDefs.GetPromptDefinition(ctx, req.PromptID, req.PromptVersion)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrProfileLoad, err)
return nil, fmt.Errorf("%w: %w", ErrPromptLoad, err)
}
promptDefinitionHash, err := hashPromptDefinition(def)
if err != nil {
return nil, fmt.Errorf("%w: failed to hash prompt definition: %v", ErrProfileLoad, err)
return nil, fmt.Errorf("%w: failed to hash prompt definition: %v", ErrPromptLoad, err)
}
selectedProfileID := strings.TrimSpace(req.ProfileID)
@@ -195,13 +197,14 @@ func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.Pr
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err)
}
effectiveModel.APIKey = req.APIKey
if strings.TrimSpace(effectiveModel.Endpoint) == "" {
return nil, fmt.Errorf("%w: execution endpoint is required", ErrInvalidRequest)
}
if strings.TrimSpace(effectiveModel.Model) == "" {
return nil, fmt.Errorf("%w: execution model is required", ErrInvalidRequest)
}
if err := validateAPIKeyEnv(effectiveModel.APIKeyEnv); err != nil {
if err := validateAPIKey(effectiveModel.APIKeyEnv, effectiveModel.APIKey, effectiveModel.APIKeyRequired); err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err)
}
@@ -362,6 +365,9 @@ func mergeExecutionTarget(base domain.ExecutionTarget, override domain.Execution
if strings.TrimSpace(override.APIKeyEnv) != "" {
out.APIKeyEnv = override.APIKeyEnv
}
if override.APIKeyRequired {
out.APIKeyRequired = true
}
if len(override.ExtraParams) > 0 {
out.ExtraParams = copyExtraParams(override.ExtraParams)
}
@@ -434,9 +440,15 @@ func resolveExecutionTarget(profileValue *domain.ExecutionProfile, override *dom
return out, presence, nil
}
func validateAPIKeyEnv(apiKeyEnv string) error {
func validateAPIKey(apiKeyEnv string, apiKey string, apiKeyRequired bool) error {
if strings.TrimSpace(apiKey) != "" {
return nil
}
envName := strings.TrimSpace(apiKeyEnv)
if envName == "" {
if apiKeyRequired {
return ErrAPIKeyRequired
}
return nil
}
if strings.TrimSpace(os.Getenv(envName)) == "" {
@@ -459,6 +471,7 @@ func executionProfileToTarget(p *domain.ExecutionProfile) domain.ExecutionTarget
ServiceTier: p.ServiceTier,
ReasoningEffort: p.ReasoningEffort,
APIKeyEnv: p.APIKeyEnv,
APIKeyRequired: p.APIKeyRequired,
ExtraParams: copyExtraParams(p.ExtraParams),
}
}

View File

@@ -253,6 +253,17 @@ func TestRunnerPrepareSelectedProfileDoesNotExistFails(t *testing.T) {
}
}
func TestRunnerPreparePromptLoadFailure(t *testing.T) {
runner := NewRunner(&fakePromptRepo{err: errors.New("boom")}, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}, defaultArtifactReader(), defaultRenderer(), &fakeLLM{}, nil)
_, err := runner.Prepare(context.Background(), domain.RunRequest{PromptID: "p"})
if !errors.Is(err, ErrPromptLoad) {
t.Fatalf("expected ErrPromptLoad, got %v", err)
}
if errors.Is(err, ErrProfileLoad) {
t.Fatalf("did not expect ErrProfileLoad, got %v", err)
}
}
func TestRunnerPrepareRuntimeOverrideBeatsSelectedProfileValue(t *testing.T) {
promptRepo := &fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)}
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
@@ -1179,6 +1190,75 @@ func TestRunnerRunAPIKeyEnvMissingEnvironmentValueFailsClearly(t *testing.T) {
}
}
func TestRunnerRunDirectAPIKeyBypassesMissingEnvAndReachesLLM(t *testing.T) {
const directKey = "direct-runner-key"
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", APIKeyEnv: "SCRIPTORIUM_MISSING_KEY"},
}}
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}
runner := NewRunner(promptRepo, execRepo, defaultArtifactReader(), defaultRenderer(), llmClient, nil)
_, err := runner.Run(context.Background(), domain.RunRequest{
PromptID: "p",
ProfileID: "exec",
APIKey: directKey,
Inputs: singleInputRef(),
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if llmClient.lastReq.Target.APIKey != directKey {
t.Fatalf("expected direct API key to reach LLM request")
}
if llmClient.lastReq.Target.APIKeyEnv != "SCRIPTORIUM_MISSING_KEY" {
t.Fatalf("expected api_key_env name to remain on target, got %q", llmClient.lastReq.Target.APIKeyEnv)
}
}
func TestRunnerPrepareAPIKeyRequiredFailsWithoutDirectKey(t *testing.T) {
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", APIKeyRequired: true},
}}
runner := NewRunner(promptRepo, execRepo, defaultArtifactReader(), defaultRenderer(), &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}, nil)
_, err := runner.Prepare(context.Background(), domain.RunRequest{
PromptID: "p",
ProfileID: "exec",
Inputs: singleInputRef(),
})
if !errors.Is(err, ErrAPIKeyRequired) {
t.Fatalf("expected ErrAPIKeyRequired, got %v", err)
}
}
func TestRunnerRunAPIKeyRequiredSucceedsWithDirectKey(t *testing.T) {
const directKey = "direct-required-key"
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", APIKeyRequired: true},
}}
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}
runner := NewRunner(promptRepo, execRepo, defaultArtifactReader(), defaultRenderer(), llmClient, nil)
_, err := runner.Run(context.Background(), domain.RunRequest{
PromptID: "p",
ProfileID: "exec",
APIKey: directKey,
Inputs: singleInputRef(),
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if llmClient.lastReq.Target.APIKey != directKey {
t.Fatalf("expected direct API key to reach LLM request")
}
if !llmClient.lastReq.Target.APIKeyRequired {
t.Fatalf("expected APIKeyRequired to be carried to target")
}
}
func TestRunnerRunRuntimeAPIKeyEnvOverrideWorks(t *testing.T) {
const envName = "SCRIPTORIUM_RUNTIME_API_KEY"
t.Setenv(envName, "runtime-secret")
@@ -1254,8 +1334,11 @@ func TestRunnerRunAPIKeyValueNotPresentInMetadata(t *testing.T) {
func TestRunnerRunPromptLoadFailure(t *testing.T) {
runner := NewRunner(&fakePromptRepo{err: errors.New("boom")}, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}, defaultArtifactReader(), defaultRenderer(), &fakeLLM{}, nil)
_, err := runner.Run(context.Background(), domain.RunRequest{PromptID: "p"})
if !errors.Is(err, ErrProfileLoad) {
t.Fatalf("expected ErrProfileLoad, got %v", err)
if !errors.Is(err, ErrPromptLoad) {
t.Fatalf("expected ErrPromptLoad, got %v", err)
}
if errors.Is(err, ErrProfileLoad) {
t.Fatalf("did not expect ErrProfileLoad, got %v", err)
}
}
@@ -1468,6 +1551,7 @@ func TestExecutionProfileToTargetPopulatesAllFieldsAndCopiesExtraParams(t *testi
ServiceTier: "priority",
ReasoningEffort: "medium",
APIKeyEnv: "SCRIPTORIUM_API_KEY",
APIKeyRequired: true,
ExtraParams: map[string]any{
"provider_option": "on",
},
@@ -1482,7 +1566,8 @@ func TestExecutionProfileToTargetPopulatesAllFieldsAndCopiesExtraParams(t *testi
target.TimeoutSeconds != src.TimeoutSeconds ||
target.ServiceTier != src.ServiceTier ||
target.ReasoningEffort != src.ReasoningEffort ||
target.APIKeyEnv != src.APIKeyEnv {
target.APIKeyEnv != src.APIKeyEnv ||
target.APIKeyRequired != src.APIKeyRequired {
t.Fatalf("expected all profile fields to populate target, got %+v", target)
}
if !reflect.DeepEqual(target.ExtraParams, src.ExtraParams) {
@@ -1507,6 +1592,7 @@ func TestResolveExecutionTargetProfileValuesPopulateAllSupportedFields(t *testin
ServiceTier: "priority",
ReasoningEffort: "low",
APIKeyEnv: "PROFILE_KEY",
APIKeyRequired: true,
ExtraParams: map[string]any{
"profile_option": "enabled",
},
@@ -1527,7 +1613,8 @@ func TestResolveExecutionTargetProfileValuesPopulateAllSupportedFields(t *testin
target.TimeoutSeconds != profileValue.TimeoutSeconds ||
target.ServiceTier != profileValue.ServiceTier ||
target.ReasoningEffort != profileValue.ReasoningEffort ||
target.APIKeyEnv != profileValue.APIKeyEnv {
target.APIKeyEnv != profileValue.APIKeyEnv ||
target.APIKeyRequired != profileValue.APIKeyRequired {
t.Fatalf("expected profile values to populate target, got %+v", target)
}
if !reflect.DeepEqual(target.ExtraParams, profileValue.ExtraParams) {

View File

@@ -5,11 +5,14 @@ import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"gitea.maximumdirect.net/eric/scriptorium/internal/filecatalog"
"github.com/santhosh-tekuri/jsonschema/v6"
)
@@ -18,11 +21,30 @@ type StandardValidator struct {
schemaBaseDir string
}
type FSValidator struct {
fsys fs.FS
root string
}
func NewStandardValidator(schemaBaseDir string) Validator {
return &StandardValidator{schemaBaseDir: schemaBaseDir}
}
func NewFSValidator(fsys fs.FS, root string) Validator {
return &FSValidator{fsys: fsys, root: root}
}
func (v *StandardValidator) Validate(ctx context.Context, artifact *domain.Artifact, contract domain.OutputContract) (domain.ValidationResult, error) {
return validateArtifact(ctx, artifact, contract, v.validateJSONSchema)
}
func (v *FSValidator) Validate(ctx context.Context, artifact *domain.Artifact, contract domain.OutputContract) (domain.ValidationResult, error) {
return validateArtifact(ctx, artifact, contract, v.validateJSONSchema)
}
type schemaValidatorFunc func(instance any, schemaPath string) ([]string, error)
func validateArtifact(ctx context.Context, artifact *domain.Artifact, contract domain.OutputContract, validateSchema schemaValidatorFunc) (domain.ValidationResult, error) {
select {
case <-ctx.Done():
return domain.ValidationResult{}, ctx.Err()
@@ -74,21 +96,14 @@ func (v *StandardValidator) Validate(ctx context.Context, artifact *domain.Artif
return res, nil
}
schemaPath, err := v.resolveSchemaPath(contract.SchemaPath)
validationErrors, err := validateSchema(instance, contract.SchemaPath)
if err != nil {
return domain.ValidationResult{}, err
}
compiler := jsonschema.NewCompiler()
schema, err := compiler.Compile(schemaPath)
if err != nil {
return domain.ValidationResult{}, fmt.Errorf("failed to compile JSON schema %q: %w", schemaPath, err)
}
if err := schema.Validate(instance); err != nil {
if len(validationErrors) > 0 {
res.Status = domain.ValidationFailed
res.IsValid = false
res.Errors = []string{fmt.Sprintf("json schema validation failed: %v", err)}
res.Errors = validationErrors
return res, nil
}
@@ -100,6 +115,46 @@ func (v *StandardValidator) Validate(ctx context.Context, artifact *domain.Artif
}
}
func (v *StandardValidator) validateJSONSchema(instance any, schemaPath string) ([]string, error) {
resolvedSchemaPath, err := v.resolveSchemaPath(schemaPath)
if err != nil {
return nil, err
}
compiler := jsonschema.NewCompiler()
schema, err := compiler.Compile(resolvedSchemaPath)
if err != nil {
return nil, fmt.Errorf("failed to compile JSON schema %q: %w", resolvedSchemaPath, err)
}
if err := schema.Validate(instance); err != nil {
return []string{fmt.Sprintf("json schema validation failed: %v", err)}, nil
}
return nil, nil
}
func (v *FSValidator) validateJSONSchema(instance any, schemaPath string) ([]string, error) {
schemaName, schemaDoc, err := v.loadSchemaDocument(schemaPath)
if err != nil {
return nil, err
}
resourceURL := fsSchemaResourceURL(schemaName)
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource(resourceURL, schemaDoc); err != nil {
return nil, fmt.Errorf("failed to register JSON schema %q: %w", schemaName, err)
}
schema, err := compiler.Compile(resourceURL)
if err != nil {
return nil, fmt.Errorf("failed to compile JSON schema %q: %w", schemaName, err)
}
if err := schema.Validate(instance); err != nil {
return []string{fmt.Sprintf("json schema validation failed: %v", err)}, nil
}
return nil, nil
}
func parseJSON(body []byte) (any, error) {
var v any
if err := json.Unmarshal(body, &v); err != nil {
@@ -132,6 +187,20 @@ func (v *StandardValidator) LoadSchemaDocument(ctx context.Context, schemaPath s
return doc, nil
}
func (v *FSValidator) LoadSchemaDocument(ctx context.Context, schemaPath string) (any, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
_, doc, err := v.loadSchemaDocument(schemaPath)
if err != nil {
return nil, err
}
return doc, nil
}
func (v *StandardValidator) resolveSchemaPath(schemaPath string) (string, error) {
if strings.TrimSpace(schemaPath) == "" {
return "", errors.New("schema path is required for json_schema validation")
@@ -149,3 +218,75 @@ func (v *StandardValidator) resolveSchemaPath(schemaPath string) (string, error)
return resolved, nil
}
func (v *FSValidator) loadSchemaDocument(schemaPath string) (string, any, error) {
resolved, err := v.resolveSchemaPath(schemaPath)
if err != nil {
return "", nil, err
}
raw, err := fs.ReadFile(v.fsys, resolved)
if err != nil {
return "", nil, fmt.Errorf("failed to read schema file %q: %w", resolved, err)
}
var doc any
if err := json.Unmarshal(raw, &doc); err != nil {
return "", nil, fmt.Errorf("failed to decode JSON schema %q: %w", resolved, err)
}
return resolved, doc, nil
}
func (v *FSValidator) resolveSchemaPath(schemaPath string) (string, error) {
if strings.TrimSpace(schemaPath) == "" {
return "", errors.New("schema path is required for json_schema validation")
}
if v.fsys == nil {
return "", errors.New("schema filesystem is nil")
}
cleanRoot := filecatalog.CleanFSRoot(v.root)
rootInfo, err := fs.Stat(v.fsys, cleanRoot)
if err != nil {
return "", fmt.Errorf("failed to access schema source %q: %w", cleanRoot, err)
}
var resolved string
if rootInfo.IsDir() {
resolvedPath, _, err := filecatalog.ResolveFSPath(cleanRoot, cleanRoot, schemaPath)
if err != nil {
return "", err
}
resolved = resolvedPath
} else {
cleanSchemaPath, err := cleanSchemaFSPath(schemaPath)
if err != nil {
return "", err
}
if cleanSchemaPath != path.Base(cleanRoot) {
return "", fmt.Errorf("schema path %q does not match schema file %q", cleanSchemaPath, path.Base(cleanRoot))
}
resolved = cleanRoot
}
if _, err := fs.Stat(v.fsys, resolved); err != nil {
return "", fmt.Errorf("failed to access schema file %q: %w", resolved, err)
}
return resolved, nil
}
func cleanSchemaFSPath(schemaPath string) (string, error) {
cleaned := strings.TrimSpace(schemaPath)
if cleaned == "" {
return "", errors.New("schema path is required for json_schema validation")
}
cleaned = path.Clean(cleaned)
if path.IsAbs(cleaned) {
return "", fmt.Errorf("schema path %q must be relative", schemaPath)
}
return cleaned, nil
}
func fsSchemaResourceURL(schemaName string) string {
return "scriptorium-schema:///" + strings.TrimPrefix(path.Clean(schemaName), "/")
}

View File

@@ -4,7 +4,9 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
@@ -250,3 +252,132 @@ func TestStandardValidatorLoadSchemaDocumentInvalidJSON(t *testing.T) {
t.Fatal("expected decode error")
}
}
func TestFSValidatorJSONSchemaSuccess(t *testing.T) {
v := NewFSValidator(fstest.MapFS{
"schemas/events.schema.json": &fstest.MapFile{Data: []byte(`{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["events"],
"properties": {
"events": {"type": "array"}
}
}`)},
}, "schemas")
res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"events":[]}`)}, domain.OutputContract{
ValidationMode: domain.ValidationJSONSchema,
SchemaPath: "events.schema.json",
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if res.Status != domain.ValidationPassed || !res.IsValid {
t.Fatalf("expected passed/valid, got status=%q valid=%v", res.Status, res.IsValid)
}
}
func TestFSValidatorJSONSchemaPathContainment(t *testing.T) {
t.Run("nested schema inside root succeeds", func(t *testing.T) {
v := NewFSValidator(fstest.MapFS{
"schemas/nested/events.schema.json": &fstest.MapFile{Data: []byte(`{
"type": "object",
"required": ["events"],
"properties": {
"events": {"type": "array"}
}
}`)},
}, "schemas")
res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"events":[]}`)}, domain.OutputContract{
ValidationMode: domain.ValidationJSONSchema,
SchemaPath: "nested/events.schema.json",
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if res.Status != domain.ValidationPassed || !res.IsValid {
t.Fatalf("expected passed/valid, got status=%q valid=%v", res.Status, res.IsValid)
}
})
tests := []struct {
name string
schemaPath string
wantErr string
}{
{name: "parent escape rejected", schemaPath: "../outside.schema.json", wantErr: "escapes source root"},
{name: "absolute path rejected", schemaPath: "/outside.schema.json", wantErr: "must be relative"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
v := NewFSValidator(fstest.MapFS{
"schemas/events.schema.json": &fstest.MapFile{Data: []byte(`{"type":"object"}`)},
"outside.schema.json": &fstest.MapFile{Data: []byte(`{"type":"object"}`)},
"schemas/outside.schema.json": &fstest.MapFile{Data: []byte(`{"type":"object"}`)},
}, "schemas")
_, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"events":[]}`)}, domain.OutputContract{
ValidationMode: domain.ValidationJSONSchema,
SchemaPath: tc.schemaPath,
})
if err == nil {
t.Fatal("expected schema path error")
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error to contain %q, got %v", tc.wantErr, err)
}
})
}
}
func TestFSValidatorSingleSchemaFileUsesBaseName(t *testing.T) {
v := NewFSValidator(fstest.MapFS{
"events.schema.json": &fstest.MapFile{Data: []byte(`{
"type": "object",
"required": ["events"],
"properties": {
"events": {"type": "array"}
}
}`)},
}, "events.schema.json")
res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"events":[]}`)}, domain.OutputContract{
ValidationMode: domain.ValidationJSONSchema,
SchemaPath: "events.schema.json",
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if res.Status != domain.ValidationPassed || !res.IsValid {
t.Fatalf("expected passed/valid, got status=%q valid=%v", res.Status, res.IsValid)
}
_, err = v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"events":[]}`)}, domain.OutputContract{
ValidationMode: domain.ValidationJSONSchema,
SchemaPath: "other.schema.json",
})
if err == nil {
t.Fatal("expected schema path mismatch error")
}
}
func TestFSValidatorLoadSchemaDocument(t *testing.T) {
v := NewFSValidator(fstest.MapFS{
"schemas/schema.json": &fstest.MapFile{Data: []byte(`{"type":"object"}`)},
}, "schemas")
loader, ok := v.(SchemaDocumentLoader)
if !ok {
t.Fatal("fs validator must implement SchemaDocumentLoader")
}
doc, err := loader.LoadSchemaDocument(context.Background(), "schema.json")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
obj, ok := doc.(map[string]any)
if !ok || obj["type"] != "object" {
t.Fatalf("unexpected schema document: %#v", doc)
}
}

218
json_copy.go Normal file
View File

@@ -0,0 +1,218 @@
package scriptorium
import (
"encoding/json"
"fmt"
"math"
"reflect"
"strconv"
)
const maxSafeJSONInteger = 1<<53 - 1
type jsonVisit struct {
typ reflect.Type
ptr uintptr
}
func copyPublicJSONMap(src map[string]any) (map[string]any, error) {
if src == nil {
return nil, nil
}
copied, err := copyPublicJSONValue(reflect.ValueOf(src), "extra_params", make(map[jsonVisit]struct{}))
if err != nil {
return nil, err
}
out, ok := copied.(map[string]any)
if !ok {
return nil, fmt.Errorf("extra_params: expected object")
}
return out, nil
}
func copyPublicJSONValue(value reflect.Value, path string, seen map[jsonVisit]struct{}) (any, error) {
if !value.IsValid() {
return nil, nil
}
if value.Kind() == reflect.Interface {
if value.IsNil() {
return nil, nil
}
return copyPublicJSONValue(value.Elem(), path, seen)
}
if !value.CanInterface() {
return nil, fmt.Errorf("%s: value cannot be copied", path)
}
if number, ok := value.Interface().(json.Number); ok {
f, err := strconv.ParseFloat(number.String(), 64)
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
return nil, fmt.Errorf("%s: invalid JSON number", path)
}
return number, nil
}
switch value.Kind() {
case reflect.Bool, reflect.String:
return value.Interface(), nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
if value.Int() < -maxSafeJSONInteger || value.Int() > maxSafeJSONInteger {
return nil, fmt.Errorf("%s: integer is outside the JSON-safe range", path)
}
return value.Interface(), nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
if value.Uint() > maxSafeJSONInteger {
return nil, fmt.Errorf("%s: integer is outside the JSON-safe range", path)
}
return value.Interface(), nil
case reflect.Float32, reflect.Float64:
f := value.Convert(reflect.TypeOf(float64(0))).Float()
if math.IsNaN(f) || math.IsInf(f, 0) {
return nil, fmt.Errorf("%s: floating-point value must be finite", path)
}
return value.Interface(), nil
case reflect.Pointer:
if value.IsNil() {
return nil, nil
}
visit := jsonVisit{typ: value.Type(), ptr: value.Pointer()}
if _, ok := seen[visit]; ok {
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
}
seen[visit] = struct{}{}
defer delete(seen, visit)
return copyPublicJSONValue(value.Elem(), path, seen)
case reflect.Map:
return copyPublicJSONMapValue(value, path, seen)
case reflect.Slice:
if value.IsNil() {
return nil, nil
}
return copyPublicJSONSequenceValue(value, path, seen)
case reflect.Array:
return copyPublicJSONSequenceValue(value, path, seen)
default:
return nil, fmt.Errorf("%s: unsupported JSON value type %s", path, value.Type())
}
}
func copyPublicJSONMapValue(value reflect.Value, path string, seen map[jsonVisit]struct{}) (any, error) {
if value.IsNil() {
return nil, nil
}
if value.Type().Key().Kind() != reflect.String {
return nil, fmt.Errorf("%s: map key type %s is not supported", path, value.Type().Key())
}
visit := jsonVisit{typ: value.Type(), ptr: value.Pointer()}
if _, ok := seen[visit]; ok {
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
}
seen[visit] = struct{}{}
defer delete(seen, visit)
type entry struct {
key reflect.Value
name string
value any
}
entries := make([]entry, 0, value.Len())
preserveType := true
elemType := value.Type().Elem()
iter := value.MapRange()
for iter.Next() {
key := iter.Key()
name := key.String()
copied, err := copyPublicJSONValue(iter.Value(), path+"."+name, seen)
if err != nil {
return nil, err
}
entries = append(entries, entry{key: key, name: name, value: copied})
if copied == nil {
if !canAssignNil(elemType) {
preserveType = false
}
continue
}
if !reflect.TypeOf(copied).AssignableTo(elemType) {
preserveType = false
}
}
if preserveType {
out := reflect.MakeMapWithSize(value.Type(), len(entries))
for _, entry := range entries {
if entry.value == nil {
out.SetMapIndex(entry.key, reflect.Zero(elemType))
continue
}
out.SetMapIndex(entry.key, reflect.ValueOf(entry.value))
}
return out.Interface(), nil
}
out := make(map[string]any, len(entries))
for _, entry := range entries {
out[entry.name] = entry.value
}
return out, nil
}
func copyPublicJSONSequenceValue(value reflect.Value, path string, seen map[jsonVisit]struct{}) (any, error) {
var visit jsonVisit
if value.Kind() == reflect.Slice {
visit = jsonVisit{typ: value.Type(), ptr: value.Pointer()}
if _, ok := seen[visit]; ok {
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
}
seen[visit] = struct{}{}
defer delete(seen, visit)
}
values := make([]any, value.Len())
preserveType := true
elemType := value.Type().Elem()
for i := 0; i < value.Len(); i++ {
copied, err := copyPublicJSONValue(value.Index(i), fmt.Sprintf("%s[%d]", path, i), seen)
if err != nil {
return nil, err
}
values[i] = copied
if copied == nil {
if !canAssignNil(elemType) {
preserveType = false
}
continue
}
if !reflect.TypeOf(copied).AssignableTo(elemType) {
preserveType = false
}
}
if preserveType {
out := reflect.New(value.Type()).Elem()
if value.Kind() == reflect.Slice {
out = reflect.MakeSlice(value.Type(), value.Len(), value.Len())
}
for i, copied := range values {
if copied == nil {
out.Index(i).Set(reflect.Zero(elemType))
continue
}
out.Index(i).Set(reflect.ValueOf(copied))
}
return out.Interface(), nil
}
out := make([]any, len(values))
copy(out, values)
return out, nil
}
func canAssignNil(typ reflect.Type) bool {
switch typ.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return true
default:
return false
}
}

124
profiles.go Normal file
View File

@@ -0,0 +1,124 @@
package scriptorium
import (
"context"
"errors"
"fmt"
"strings"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
)
// OpenAICompatibleProfile returns an ordinary in-memory Profile for an
// OpenAI-compatible chat-completions endpoint.
//
// It does not register global state, maintain a model catalog, or resolve
// credentials. If APIKeyRequired is true, callers satisfy it with
// RunRequest.APIKey. Raw API keys do not belong in profiles.
func OpenAICompatibleProfile(cfg OpenAICompatibleProfileConfig) Profile {
return Profile{
ID: cfg.ID,
Endpoint: cfg.Endpoint,
Model: cfg.Model,
Temperature: cfg.Temperature,
MaxTokens: cfg.MaxTokens,
TopP: cfg.TopP,
TimeoutSeconds: cfg.TimeoutSeconds,
ServiceTier: cfg.ServiceTier,
ReasoningEffort: cfg.ReasoningEffort,
APIKeyRequired: cfg.APIKeyRequired,
ExtraParams: copyShallowAnyMap(cfg.ExtraParams),
}
}
func copyShallowAnyMap(src map[string]any) map[string]any {
if src == nil {
return nil
}
out := make(map[string]any, len(src))
for k, v := range src {
out[k] = v
}
return out
}
type memoryProfileRepository struct {
profiles map[string]domain.ExecutionProfile
}
func newMemoryProfileRepository(profiles []Profile) (*memoryProfileRepository, error) {
repo := &memoryProfileRepository{profiles: make(map[string]domain.ExecutionProfile, len(profiles))}
for _, publicProfile := range profiles {
prof, err := toDomainProfile(publicProfile)
if err != nil {
return nil, err
}
if _, exists := repo.profiles[prof.ID]; exists {
return nil, fmt.Errorf("duplicate profile id %q", prof.ID)
}
repo.profiles[prof.ID] = prof
}
return repo, nil
}
func (r *memoryProfileRepository) GetProfile(_ context.Context, id string) (*domain.ExecutionProfile, error) {
if r == nil {
return nil, profile.ErrProfileNotFound
}
prof, ok := r.profiles[id]
if !ok {
return nil, profile.ErrProfileNotFound
}
prof.ExtraParams = copyAnyMap(prof.ExtraParams)
return &prof, nil
}
func toDomainProfile(publicProfile Profile) (domain.ExecutionProfile, error) {
extraParams, err := copyPublicJSONMap(publicProfile.ExtraParams)
if err != nil {
return domain.ExecutionProfile{}, err
}
prof := domain.ExecutionProfile{
ID: strings.TrimSpace(publicProfile.ID),
Endpoint: publicProfile.Endpoint,
Model: publicProfile.Model,
Temperature: publicProfile.Temperature,
MaxTokens: publicProfile.MaxTokens,
TopP: publicProfile.TopP,
TimeoutSeconds: publicProfile.TimeoutSeconds,
ServiceTier: publicProfile.ServiceTier,
ReasoningEffort: publicProfile.ReasoningEffort,
APIKeyRequired: publicProfile.APIKeyRequired,
ExtraParams: extraParams,
}
if err := validatePublicProfile(prof); err != nil {
return domain.ExecutionProfile{}, err
}
return prof, nil
}
func validatePublicProfile(prof domain.ExecutionProfile) error {
if strings.TrimSpace(prof.ID) == "" {
return errors.New("id is required")
}
if strings.TrimSpace(prof.Endpoint) == "" {
return errors.New("endpoint is required")
}
if strings.TrimSpace(prof.Model) == "" {
return errors.New("model is required")
}
if prof.Temperature < 0 || prof.Temperature > 2 {
return errors.New("temperature must be between 0 and 2")
}
if prof.MaxTokens < 0 {
return errors.New("max_tokens must be greater than or equal to 0")
}
if prof.TopP < 0 || prof.TopP > 1 {
return errors.New("top_p must be between 0 and 1")
}
if prof.TimeoutSeconds < 0 {
return errors.New("timeout_seconds must be greater than or equal to 0")
}
return nil
}

View File

@@ -60,6 +60,7 @@ type RunRequest struct {
PromptID string
PromptVersion string
ProfileID string
APIKey string `json:"-"`
Inputs map[string]ArtifactRef
Vars map[string]string
Execution *ExecutionTargetOverride
@@ -153,6 +154,46 @@ type ExecutionTargetOverride struct {
ExtraParams map[string]any
}
// Profile is an in-memory execution profile for library consumers.
//
// It is equivalent to a loaded profile file after validation. Raw API keys do
// not belong in profiles; use APIKeyRequired to require callers to provide
// RunRequest.APIKey for each request, or use profile YAML api_key_env with file
// and FS profile sources.
type Profile struct {
ID string
Endpoint string
Model string
Temperature float64
MaxTokens int
TopP float64
TimeoutSeconds int
ServiceTier string
ReasoningEffort string
APIKeyRequired bool
ExtraParams map[string]any
}
// OpenAICompatibleProfileConfig configures an OpenAI-compatible in-memory
// profile.
//
// It contains ordinary profile fields for OpenAI-compatible chat-completions
// endpoints. APIKeyRequired is satisfied by RunRequest.APIKey. Raw API keys do
// not belong in this config.
type OpenAICompatibleProfileConfig struct {
ID string
Endpoint string
Model string
APIKeyRequired bool
Temperature float64
MaxTokens int
TopP float64
TimeoutSeconds int
ServiceTier string
ReasoningEffort string
ExtraParams map[string]any
}
// ExecutionTargetPresence tracks which numeric runtime settings were explicit
// request overrides.
type ExecutionTargetPresence struct {
@@ -232,6 +273,7 @@ type GenerateRequest struct {
Target ExecutionTarget `json:"target"`
TargetPresence ExecutionTargetPresence `json:"target_presence"`
StructuredOutput *StructuredOutputSpec `json:"structured_output,omitempty"`
APIKey string `json:"-"`
}
// GenerateResponse is returned by an injected LLM client.