39 Commits

Author SHA1 Message Date
40f8a1c628 Identify v0.12.0 as the first published application-only release 2026-07-28 15:59:23 -05:00
27d7ad5057 Retire the completed migration roadmaps 2026-07-28 20:04:24 +00:00
1f08a1a94c Record completion of the Promptkit migration 2026-07-28 20:03:11 +00:00
2f42bdde39 Publish Promptkit migration guidance and release notes
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-07-28 19:43:57 +00:00
9a00f30c7b Establish the Scriptorium release procedure 2026-07-28 19:38:11 +00:00
f71d2bbb73 Add an implementation plan and roadmap for Step 9 of the migration plan 2026-07-28 14:25:56 -05:00
6f64947e42 Record the completed Promptkit application cutover 2026-07-28 14:28:26 +00:00
7bb4cf35b9 Document Scriptorium as a Promptkit application 2026-07-28 14:21:19 +00:00
fb0b21c51d Enforce the application dependency boundary 2026-07-28 14:12:36 +00:00
5a00ca81a2 Remove the duplicated prompt framework 2026-07-28 14:07:59 +00:00
e13610481d Adopt Promptkit at application boundaries 2026-07-28 14:04:29 +00:00
309fe9b7ea Add an implementation plan and roadmap for Step 7 of the migration plan 2026-07-28 08:58:35 -05:00
adfd08ffe2 Record Promptkit extraction and publication 2026-07-28 05:01:36 +00:00
c7263ab2a8 Add an implementation plan and roadmap for Step 6 of the migration plan 2026-07-27 23:06:23 -05:00
532c31c09a Complete step 5 of the migration plan and clean up the implemented roadmap 2026-07-27 22:51:11 -05:00
68cd90c657 Complete Promptkit repository foundation 2026-07-28 02:25:20 +00:00
416438d80d Record Promptkit validation and release decision 2026-07-28 02:05:57 +00:00
9298d8ae73 Add an implementation plan and roadmap for Step 5 of the migration plan 2026-07-27 21:01:37 -05:00
4c7278febc Remove the completed step 4 migration roadmap 2026-07-27 20:40:23 -05:00
096208532e Record public facade boundary validation 2026-07-28 01:28:56 +00:00
50bd19b9d1 Strengthen public framework dependency guard 2026-07-28 01:24:31 +00:00
2fc7204bd5 Make HTTP artifact MIME test deterministic 2026-07-28 01:22:31 +00:00
c0d4ea0d4e Preserve public error categories from collaborators 2026-07-28 01:20:41 +00:00
4d7e1327ad Revise the implementation plan to address remaining step 4 gaps 2026-07-27 20:18:02 -05:00
3c33b52b15 Complete the public facade adapter boundary 2026-07-28 00:57:53 +00:00
280916bf4a Move HTTP serving to the public engine 2026-07-28 00:52:24 +00:00
033bc93d3c Use the public engine for CLI run and render 2026-07-28 00:45:59 +00:00
45c2644b9d Add HTTP restricted artifact reader 2026-07-28 00:40:25 +00:00
a74c03bd9b Add public artifact reader support 2026-07-28 00:35:54 +00:00
ad115a2259 Add an implementation plan and roadmap for Step 4 of the migration plan 2026-07-27 19:30:36 -05:00
3074b3165a Complete step 3 of the migration plan and clean up the implemented roadmap 2026-07-27 19:05:28 -05:00
8703793b0c Complete framework characterization baseline 2026-07-27 22:13:14 +00:00
4cb4943a57 Characterize public engine execution behavior 2026-07-27 22:10:54 +00:00
c6c747e94d Move framework tests to contract fixtures 2026-07-27 22:04:08 +00:00
2bbf13e739 Add framework contract test corpus 2026-07-27 21:59:53 +00:00
5edb24a9c1 Add an implementation plan and roadmap for Step 3 of the migration plan 2026-07-27 16:57:08 -05:00
99d5e96316 Accept the Promptkit split architecture 2026-07-26 18:46:42 -05:00
144d840fbe Remove completed documentation roadmap 2026-07-26 18:33:52 -05:00
ed0c9f6370 Implement layered timeout enforcement 2026-07-26 18:31:06 -05:00
129 changed files with 2131 additions and 14044 deletions

3
.gitignore vendored
View File

@@ -56,6 +56,8 @@ mono_crash.*
[Dd]ebugPublic/ [Dd]ebugPublic/
[Rr]elease/ [Rr]elease/
[Rr]eleases/ [Rr]eleases/
!docs/releases/
!docs/releases/*.md
x64/ x64/
x86/ x86/
[Ww][Ii][Nn]32/ [Ww][Ii][Nn]32/
@@ -433,4 +435,3 @@ FodyWeavers.xsd
# JetBrains Rider # JetBrains Rider
*.sln.iml *.sln.iml

View File

@@ -11,9 +11,16 @@ steps:
version="$CI_COMMIT_TAG" version="$CI_COMMIT_TAG"
dist="dist" dist="dist"
pkg="gitea.maximumdirect.net/eric/scriptorium/cmd/scriptorium" pkg="gitea.maximumdirect.net/eric/scriptorium/cmd/scriptorium"
notes="docs/releases/$version.md"
if [ ! -f "$notes" ]; then
printf 'release notes not found: %s\n' "$notes" >&2
exit 1
fi
rm -rf "$dist" rm -rf "$dist"
mkdir -p "$dist" mkdir -p "$dist"
cp "$notes" "$dist/RELEASE_NOTES.md"
build_binary() { build_binary() {
goos="$1" goos="$1"
@@ -22,7 +29,7 @@ steps:
output="$dist/scriptorium-$version-$goos-$goarch$suffix" output="$dist/scriptorium-$version-$goos-$goarch$suffix"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \ CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
go build -trimpath -ldflags "-s -w -X gitea.maximumdirect.net/eric/scriptorium/internal/buildinfo.Version=$version" \ go build -trimpath -ldflags "-s -w" \
-o "$output" "$pkg" -o "$output" "$pkg"
} }
@@ -38,6 +45,7 @@ steps:
from_secret: GITEA_RELEASE_TOKEN from_secret: GITEA_RELEASE_TOKEN
files: files:
- dist/scriptorium-* - dist/scriptorium-*
note: dist/RELEASE_NOTES.md
checksum: sha256 checksum: sha256
checksum-file: SHA256SUMS checksum-file: SHA256SUMS
checksum-flatten: true checksum-flatten: true

View File

@@ -1,12 +1,15 @@
# scriptorium # Scriptorium
Scriptorium is a narrow prompt-execution application for rendering prompt Scriptorium is a prompt-execution application with a command-line interface and
requests, running them against OpenAI-compatible chat-completions endpoints, and an HTTP service. It prepares prompt requests, runs them against
serving the same run workflow over HTTP. OpenAI-compatible model endpoints, and returns generated output with validation
metadata.
It keeps prompt definitions, execution profiles, schemas, and input artifacts as The application uses
separate files so prompts can be reviewed and reused without baking model [Promptkit v0.1.0](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/)
runtime settings into application code. for prompt, profile, schema, preparation, generation, and validation behavior.
Scriptorium owns executable configuration, CLI and HTTP mapping, process
behavior, output presentation, and HTTP artifact-containment policy.
## Quickstart ## Quickstart
@@ -21,8 +24,9 @@ go run ./cmd/scriptorium render \
--format json --format json
``` ```
This command renders the prepared prompt and effective runtime settings without calling an LLM. This renders the prepared prompt and effective runtime settings without calling
For complete invocation and output behavior, see the [CLI reference](docs/cli.md). a model. For complete invocation and output behavior, see the
[CLI reference](docs/cli.md).
## Documentation ## Documentation
@@ -31,15 +35,17 @@ For complete invocation and output behavior, see the [CLI reference](docs/cli.md
- [HTTP API reference](docs/api.md) - [HTTP API reference](docs/api.md)
- [Operations guide](docs/operations.md) - [Operations guide](docs/operations.md)
- [Consumer integration overview](docs/consumers/api.md) - [Consumer integration overview](docs/consumers/api.md)
- [Go library package](docs/consumers/pkg-scriptorium.md) - [Migration from the former Go package](docs/consumers/migrating-to-promptkit.md)
- [Subprocess integration](docs/integrations/subprocess.md) - [Subprocess integration](docs/integrations/subprocess.md)
- [OpenAI-compatible chat integration](docs/integrations/openai-compatible-chat.md)
- [Architecture policy](docs/policy/architecture.md) - [Architecture policy](docs/policy/architecture.md)
- [Promptkit framework formats](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/formats.md)
- [Promptkit Go consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/consumers/pkg-promptkit.md)
## Examples ## Examples
- [Minimal configuration](examples/config.yml) and [complete configuration](examples/config.full.yml) - [Minimal configuration](examples/config.yml) and
- [Prompt definitions](examples/prompts/), [execution profiles](examples/profiles/), [schemas](examples/schemas/), and [synthetic input fixtures](examples/fixtures/) [complete configuration](examples/config.full.yml)
- [Prompt definitions](examples/prompts/), [execution profiles](examples/profiles/),
[schemas](examples/schemas/), and [synthetic input fixtures](examples/fixtures/)
- [Render script](examples/render-markdown-summary.sh) - [Render script](examples/render-markdown-summary.sh)
- [HTTP request](examples/http-run.json) - [HTTP request](examples/http-run.json)
- [Go library example](examples/go-library/prepare/main.go)

View File

@@ -1,406 +0,0 @@
package scriptorium
import (
"reflect"
"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: execution,
Validation: toDomainOutputContractPtr(req.Validation),
Metadata: copyStringMap(req.Metadata),
}, nil
}
func fromDomainPreparedRun(prepared *domain.PreparedRun) *PreparedRun {
if prepared == nil {
return nil
}
return &PreparedRun{
PromptID: prepared.PromptID,
PromptVersion: prepared.PromptVersion,
PromptHash: prepared.PromptHash,
SelectedProfileID: prepared.SelectedProfileID,
EffectiveModelParams: fromDomainExecutionTarget(prepared.EffectiveModelParams),
OutputContract: fromDomainOutputContract(prepared.OutputContract),
StructuredOutput: fromDomainStructuredOutputSpec(prepared.StructuredOutput),
InputHashes: copyStringMap(prepared.InputHashes),
SessionID: prepared.SessionID,
RenderedPromptHash: prepared.RenderedPromptHash,
Messages: fromDomainRenderedMessages(prepared.Messages),
StartTime: prepared.StartTime,
EndTime: prepared.EndTime,
DurationMS: prepared.DurationMS,
}
}
func fromDomainRunResult(result *domain.RunResult) *RunResult {
if result == nil {
return nil
}
return &RunResult{
RunID: result.RunID,
Artifact: fromDomainArtifact(result.Artifact),
RawOutput: result.RawOutput,
Validation: fromDomainValidationResult(result.Validation),
PromptID: result.PromptID,
PromptVersion: result.PromptVersion,
PromptHash: result.PromptHash,
RenderedPromptHash: result.RenderedPromptHash,
SelectedProfileID: result.SelectedProfileID,
ModelName: result.ModelName,
Endpoint: result.Endpoint,
EffectiveModelParams: fromDomainExecutionTarget(result.EffectiveModelParams),
InputHashes: copyStringMap(result.InputHashes),
Usage: fromDomainTokenUsage(result.Usage),
StartTime: result.StartTime,
EndTime: result.EndTime,
Duration: result.Duration,
}
}
func fromDomainGenerateRequest(req domain.GenerateRequest) GenerateRequest {
return GenerateRequest{
Prompt: fromDomainRenderedPrompt(req.Prompt),
Target: fromDomainExecutionTarget(req.Target),
TargetPresence: fromDomainExecutionTargetPresence(req.TargetPresence),
StructuredOutput: fromDomainStructuredOutputSpec(req.StructuredOutput),
APIKey: req.Target.APIKey,
}
}
func toDomainGenerateResponse(resp *GenerateResponse) *domain.GenerateResponse {
if resp == nil {
return nil
}
return &domain.GenerateResponse{
Content: resp.Content,
Usage: toDomainTokenUsage(resp.Usage),
}
}
func fromDomainRenderedPrompt(prompt domain.RenderedPrompt) RenderedPrompt {
return RenderedPrompt{
SessionID: prompt.SessionID,
Messages: fromDomainRenderedMessages(prompt.Messages),
}
}
func toDomainArtifactRefMap(src map[string]ArtifactRef) map[string]domain.ArtifactRef {
if src == nil {
return nil
}
out := make(map[string]domain.ArtifactRef, len(src))
for k, v := range src {
out[k] = toDomainArtifactRef(v)
}
return out
}
func toDomainArtifactRef(ref ArtifactRef) domain.ArtifactRef {
return domain.ArtifactRef{
Type: domain.ArtifactRefType(ref.Type),
URI: ref.URI,
Body: ref.Body,
}
}
func fromDomainArtifact(artifact domain.Artifact) Artifact {
return Artifact{
Name: artifact.Name,
ContentType: artifact.ContentType,
Body: copyBytes(artifact.Body),
URI: artifact.URI,
Size: artifact.Size,
Hash: artifact.Hash,
}
}
func toDomainExecutionTargetOverride(override *ExecutionTargetOverride) (*domain.ExecutionTargetOverride, error) {
if override == nil {
return nil, nil
}
extraParams, err := copyPublicJSONMap(override.ExtraParams)
if err != nil {
return nil, err
}
return &domain.ExecutionTargetOverride{
Endpoint: override.Endpoint,
Model: override.Model,
Temperature: copyFloat64Ptr(override.Temperature),
MaxTokens: copyIntPtr(override.MaxTokens),
TopP: copyFloat64Ptr(override.TopP),
TimeoutSeconds: copyIntPtr(override.TimeoutSeconds),
ServiceTier: override.ServiceTier,
ReasoningEffort: override.ReasoningEffort,
APIKeyEnv: override.APIKeyEnv,
ExtraParams: extraParams,
}, nil
}
func fromDomainExecutionTarget(target domain.ExecutionTarget) ExecutionTarget {
return ExecutionTarget{
Endpoint: target.Endpoint,
Model: target.Model,
Temperature: target.Temperature,
MaxTokens: target.MaxTokens,
TopP: target.TopP,
TimeoutSeconds: target.TimeoutSeconds,
ServiceTier: target.ServiceTier,
ReasoningEffort: target.ReasoningEffort,
APIKeyEnv: target.APIKeyEnv,
ExtraParams: copyAnyMap(target.ExtraParams),
}
}
func fromDomainExecutionTargetPresence(presence domain.ExecutionTargetPresence) ExecutionTargetPresence {
return ExecutionTargetPresence{
Temperature: presence.Temperature,
MaxTokens: presence.MaxTokens,
TopP: presence.TopP,
TimeoutSeconds: presence.TimeoutSeconds,
}
}
func toDomainOutputContractPtr(contract *OutputContract) *domain.OutputContract {
if contract == nil {
return nil
}
out := toDomainOutputContract(*contract)
return &out
}
func toDomainOutputContract(contract OutputContract) domain.OutputContract {
return domain.OutputContract{
Format: domain.OutputFormat(contract.Format),
ValidationMode: domain.ValidationMode(contract.ValidationMode),
SchemaPath: contract.SchemaPath,
RepairAttempts: contract.RepairAttempts,
}
}
func fromDomainOutputContract(contract domain.OutputContract) OutputContract {
return OutputContract{
Format: OutputFormat(contract.Format),
ValidationMode: ValidationMode(contract.ValidationMode),
SchemaPath: contract.SchemaPath,
RepairAttempts: contract.RepairAttempts,
}
}
func fromDomainValidationResult(result domain.ValidationResult) ValidationResult {
return ValidationResult{
Status: ValidationStatus(result.Status),
Mode: ValidationMode(result.Mode),
Errors: copyStringSlice(result.Errors),
SchemaPath: result.SchemaPath,
RepairAttempts: result.RepairAttempts,
IsValid: result.IsValid,
}
}
func fromDomainTokenUsage(usage domain.TokenUsage) TokenUsage {
return TokenUsage{
PromptTokens: usage.PromptTokens,
CompletionTokens: usage.CompletionTokens,
TotalTokens: usage.TotalTokens,
CachedTokens: usage.CachedTokens,
CacheWriteTokens: usage.CacheWriteTokens,
}
}
func toDomainTokenUsage(usage TokenUsage) domain.TokenUsage {
return domain.TokenUsage{
PromptTokens: usage.PromptTokens,
CompletionTokens: usage.CompletionTokens,
TotalTokens: usage.TotalTokens,
CachedTokens: usage.CachedTokens,
CacheWriteTokens: usage.CacheWriteTokens,
}
}
func fromDomainRenderedMessages(messages []domain.RenderedMessage) []RenderedMessage {
if messages == nil {
return nil
}
out := make([]RenderedMessage, len(messages))
for i, msg := range messages {
out[i] = RenderedMessage{
Role: msg.Role,
Content: msg.Content,
CacheControl: fromDomainCacheControl(msg.CacheControl),
}
}
return out
}
func fromDomainCacheControl(cacheControl *domain.CacheControl) *CacheControl {
if cacheControl == nil {
return nil
}
return &CacheControl{
Type: CacheControlType(cacheControl.Type),
TTL: cacheControl.TTL,
}
}
func fromDomainStructuredOutputSpec(spec *domain.StructuredOutputSpec) *StructuredOutputSpec {
if spec == nil {
return nil
}
out := &StructuredOutputSpec{
Type: StructuredOutputType(spec.Type),
}
if spec.JSONSchema != nil {
out.JSONSchema = &StructuredOutputJSONSpec{
Name: spec.JSONSchema.Name,
Strict: spec.JSONSchema.Strict,
Schema: copyAny(spec.JSONSchema.Schema),
}
}
return out
}
func copyStringMap(src map[string]string) map[string]string {
if src == nil {
return nil
}
out := make(map[string]string, len(src))
for k, v := range src {
out[k] = v
}
return out
}
func copyAnyMap(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] = copyAny(v)
}
return out
}
func copyAny(value any) any {
if value == nil {
return nil
}
switch v := value.(type) {
case map[string]any:
return copyAnyMap(v)
case []any:
out := make([]any, len(v))
for i, item := range v {
out[i] = copyAny(item)
}
return out
case []string:
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
}
}
func copyStringSlice(src []string) []string {
if src == nil {
return nil
}
out := make([]string, len(src))
copy(out, src)
return out
}
func copyBytes(src []byte) []byte {
if src == nil {
return nil
}
out := make([]byte, len(src))
copy(out, src)
return out
}
func copyFloat64Ptr(src *float64) *float64 {
if src == nil {
return nil
}
v := *src
return &v
}
func copyIntPtr(src *int) *int {
if src == nil {
return nil
}
v := *src
return &v
}

View File

@@ -44,6 +44,5 @@ reviewable alongside the implementation change that requires them.
- Changes to behavior must update the canonical owner in the same change. - Changes to behavior must update the canonical owner in the same change.
- Cross-cutting documentation links to the owner instead of copying its - Cross-cutting documentation links to the owner instead of copying its
details. details.
- Documentation restructuring follows the implementation sequence in - Documentation restructuring followed a dedicated implementation roadmap;
[`docs/roadmap/documentation.md`](../roadmap/documentation.md); the roadmap, repository history, not this ADR, records its completion.
not this ADR, records completion status.

View File

@@ -0,0 +1,288 @@
# ADR 0002: Split Promptkit From Scriptorium
## Status
Accepted
## Date
2026-07-26
## Context
Scriptorium currently combines two products in one Go module:
- a reusable prompt-execution framework with a public Go facade; and
- a runnable application with CLI and HTTP interfaces.
Downstream Go projects increasingly import the framework directly and do not
use the executable interfaces. Keeping both products in one module couples
framework releases, dependencies, documentation, and public API evolution to
application-specific transport concerns.
Promptkit will become the framework project, and Scriptorium will become a slim
application that consumes it. This ADR records that end-state boundary. It does
not assert that the split has been implemented; until then, the current
repository structure and contracts remain authoritative.
## Decision
### Projects And Module Paths
Create a repository named `promptkit` alongside Scriptorium:
| Project | Repository and Go module path | Root Go package |
| --- | --- | --- |
| Promptkit | `gitea.maximumdirect.net/eric/promptkit` | `promptkit` |
| Scriptorium | `gitea.maximumdirect.net/eric/scriptorium` | No reusable root facade after migration |
Promptkit will expose its supported public API from the module root. Its
implementation packages will remain under `internal/` unless a real consumer
extension point requires a public type or interface.
Scriptorium will import only Promptkit's supported public packages. It will not
import Promptkit implementation packages or reproduce Promptkit orchestration.
### Product Responsibilities
Promptkit owns application-neutral framework behavior:
- the engine and its `Prepare` and `Run` workflow;
- public request, result, profile, option, extension, and error APIs;
- prompt-definition loading and rendering;
- profile loading, overlays, and the embedded built-in profile registry;
- schema loading and output validation;
- provider-neutral model-client boundaries and the OpenAI-compatible client;
- artifact types, artifact-reader injection, and general-purpose inline and
caller-selected file readers;
- execution-setting resolution and framework defaults; and
- framework-level secret redaction and error classification.
Scriptorium owns executable and transport behavior:
- the `scriptorium` process and its `run`, `render`, and `serve` commands;
- CLI parsing, streams, output files, formatting, exit codes, and process
cancellation behavior;
- application-configuration discovery and CLI-over-configuration precedence;
- HTTP routing, strict request decoding, DTO mapping, response encoding,
status codes, and transport limits;
- HTTP artifact-root containment and deployment policy;
- server construction, server defaults, and process logging; and
- executable release artifacts.
The dependency direction is:
```text
Scriptorium CLI and HTTP adapters
|
v
Promptkit public API
|
v
injected sources, readers, and model clients
```
### Current Package Disposition
Implementation may reorganize files during extraction, but each current package
has this target owner:
| Current package or file group | Target owner | Disposition |
| --- | --- | --- |
| Root `scriptorium` facade files and tests | Promptkit | Move and rename the public package to `promptkit`; Scriptorium retains no compatibility facade. |
| `internal/domain`, `internal/usecase` | Promptkit | Move as internal engine implementation. |
| `internal/promptdef`, `internal/prompt` | Promptkit | Move as internal prompt loading and rendering. |
| `internal/profile`, `internal/profile/builtin` | Promptkit | Move with embedded built-in assets and registry tests. |
| `internal/filecatalog` | Promptkit | Move as source-loading support. |
| `internal/validate` | Promptkit | Move as schema and output-validation implementation. |
| `internal/llm` | Promptkit | Move with the OpenAI-compatible integration. |
| `internal/artifact` | Split | Move general inline/file reading to Promptkit; keep rooted, denied, and byte-limited HTTP file reading in Scriptorium behind a Promptkit reader interface. |
| `internal/defaults` | Split | Move framework, execution, output-artifact, content-type, and model-client defaults to Promptkit; keep CLI, HTTP, and server defaults in Scriptorium. |
| `internal/adapter/cli`, `internal/adapter/http` | Scriptorium | Keep and refactor to use Promptkit's public API. |
| `internal/config` | Scriptorium | Keep application settings, discovery, validation, and CLI precedence. |
| `internal/format` | Scriptorium | Keep prepared-run presentation, rewritten against Promptkit public values. |
| `cmd/scriptorium` | Scriptorium | Keep as the process entrypoint. |
Tests move with the behavior they protect. Cross-boundary tests will live with
the consuming side: Promptkit protects framework contracts, while Scriptorium
protects adapter mapping, HTTP containment, and executable behavior.
### Public Boundary
Promptkit's initial facade will preserve the useful shape of the current
Scriptorium Go API where that reduces extraction risk. It will expose only the
capabilities required by Promptkit consumers and by Scriptorium:
- engine construction, preparation, and execution;
- public request, result, profile, and error values;
- prompt, profile, schema, artifact-reader, validator, and model-client source
or injection options that have demonstrated consumers; and
- enough stable error identity for Scriptorium to map CLI and HTTP outcomes.
Promptkit will not export its domain package, runner implementation,
repositories, adapter DTOs, or general internal constructors merely to
simplify the move.
Scriptorium's CLI and HTTP adapters will depend on a small consumer-facing
`Prepare`/`Run` interface where test substitution is needed. That interface
belongs at the consuming boundary rather than forcing adapter concepts into
Promptkit.
### Artifact Reading And HTTP Containment
Promptkit will define the artifact-reader extension point used during
preparation. Its ordinary file reader may read a path deliberately supplied by
an in-process or CLI caller and does not claim to be a deployment sandbox.
Scriptorium will implement the HTTP-specific reader that:
- denies file references when no artifact root is configured;
- applies the configured artifact byte limit;
- enforces Scriptorium's documented lexical root-containment rule; and
- maps reader failures to Scriptorium HTTP error responses.
Scriptorium will inject that reader through Promptkit's public construction
boundary. Promptkit will not know about HTTP roots, status codes, request DTOs,
or deployment policy.
### Configuration And Default Ownership
Configuration ownership follows the behavior configured, not the current file
location:
| Configuration category | Owner |
| --- | --- |
| Application configuration discovery, configuration-file precedence, `prompt_dir`, `profile_dir`, and `schema_dir` | Scriptorium |
| CLI flags and their mapping to application settings or request overrides | Scriptorium |
| `server.*`, render-output settings, HTTP byte limits, and server defaults | Scriptorium |
| Prompt-definition, profile, and output-contract file formats | Promptkit |
| Prompt/profile source selection, overlays, schema behavior, and built-in profiles | Promptkit |
| Execution settings, presence-aware request overrides, and execution defaults | Promptkit |
| Built-in OpenAI-compatible client settings, timeout behavior, and provider wire mapping | Promptkit |
| HTTP request and response fields, including their mapping to framework values | Scriptorium |
Scriptorium will translate its application settings and external request
values into Promptkit construction options and requests. When an omitted
Scriptorium setting means “use the framework default,” Scriptorium will omit
the override rather than copy Promptkit's numeric default.
### Compatibility And Versioning
This migration is intentionally breaking:
- new Go consumers will import `gitea.maximumdirect.net/eric/promptkit`;
- Scriptorium will not provide aliases, forwarding wrappers, or deprecated
compatibility packages for its former Go facade;
- existing consumers may remain pinned to the final framework-bearing
Scriptorium tag until migrated; and
- intermediate migration phases need not preserve source compatibility, but
each merged phase must be internally buildable and tested.
Promptkit's first release will be `v0.1.0`. During the migration, incompatible
Promptkit changes may advance its minor version until a stable `v1` contract is
declared. The first slim Scriptorium release will advance the Scriptorium minor
version beyond the final framework-bearing release. Normal semantic-versioning
rules apply independently to both projects after the migration.
Promptkit must be tagged before Scriptorium or another consumer publishes a
release that depends on it. Release branches must use tagged module
dependencies, not local replacements or unpublished revisions.
### Local Development And Cross-Repository Coordination
For coordinated local work, place both repositories in a temporary Go
workspace or use an uncommitted module replacement. `go.work`,
`go.work.sum`, and local filesystem `replace` directives must not be committed
to release branches.
Cross-repository changes follow this order:
1. land and tag the required Promptkit capability;
2. update Scriptorium and other consumers to that tag;
3. run each repository's own CI and smoke checks; and
4. release consumers only after the Promptkit tag is available.
Migration coordination must confirm out-of-band repository creation, Promptkit
tags, and downstream migrations before dependent work proceeds.
Cross-repository changes are coordinated, not treated as atomic commits.
### Documentation And Maintained Assets
Each repository will maintain its own README, contributor guide, architecture,
documentation, testing, release, and operations material appropriate to that
project. Cross-project documents will link to the canonical owner rather than
copy its contract.
Existing documentation and maintained assets have these target owners:
| Current material | Target owner |
| --- | --- |
| Current README and executable quickstart | Scriptorium; Promptkit creates its own framework orientation |
| Public Go package and Go-consumer guidance | Promptkit |
| Prompt, profile, schema, execution-setting, and framework credential reference | Promptkit |
| OpenAI-compatible integration contract and framework internal documents | Promptkit |
| CLI, HTTP API, subprocess, and Scriptorium operations contracts | Scriptorium |
| Consumer interface overview | Scriptorium, revised to route Go consumers to Promptkit |
| Application-configuration discovery, server settings, and adapter internals | Scriptorium |
| Current internal overview and source documentation | Split into repository-local overviews; Promptkit owns framework sources and Scriptorium owns HTTP containment |
| This ADR and cross-project migration records | Scriptorium |
| `examples/go-library` | Promptkit |
| `examples/config*.yml`, `examples/render-markdown-summary.sh`, and `examples/http-run.json` | Scriptorium |
| Example prompts, profiles, schemas, and synthetic fixtures used by the executable examples | Scriptorium |
| Embedded built-in profile assets | Promptkit |
| Scriptorium release workflow and executable packaging | Scriptorium |
| Repository-level license, ignore rules, agent guidance, and development policies | Each repository maintains its own applicable copy |
Promptkit will create or retain its own minimal framework examples and test
fixtures rather than making either repository's tests depend on the other's
working tree. Scriptorium's framework-format documentation will become a short
version-appropriate link to Promptkit, while its maintained executable examples
remain self-contained.
## Alternatives Considered
- Keep the current combined repository and improve package naming. This avoids
migration work but retains release and ownership coupling between the
framework and executable.
- Add Promptkit as a wrapper around the Scriptorium public package. This gives
consumers a new import path but leaves framework ownership and dependency
direction inverted.
- Extract Promptkit while retaining a Scriptorium compatibility facade. This
reduces immediate consumer changes but creates a second public API surface
and prolongs duplicate maintenance.
- Move all artifact reading into Promptkit. This would place HTTP containment,
byte limits, and deployment policy in the application-neutral framework.
- Keep Promptkit and Scriptorium as separate modules in one repository. This
separates imports but not repository permissions, release workflows,
issue ownership, or independent project evolution.
## Rationale
A separate Promptkit project makes the reusable framework the direct owner of
the API that downstream Go projects already consume. Keeping Scriptorium as a
public-API consumer exercises the same boundary as other consumers and prevents
its adapters from relying on framework internals.
The selected split keeps transport and deployment policy close to the
Scriptorium interfaces that expose it, while allowing Promptkit to remain
useful to in-process consumers with different IO and security requirements.
Explicit package, configuration, documentation, and asset ownership reduces
ambiguity during extraction and after release.
## Consequences
- All Go consumers of the framework must change their import path.
- Promptkit and Scriptorium gain independent issue, release, CI, policy, and
documentation lifecycles.
- Scriptorium becomes a real downstream integration test of Promptkit's public
facade.
- Framework changes that affect Scriptorium require tagged, ordered
cross-repository coordination.
- Some current packages, especially artifact reading and defaults, must be
separated by responsibility rather than moved intact.
- Scriptorium's current configuration and documentation references must be
split between application and framework owners.
- Maintainers must inventory and migrate downstream consumers explicitly; no
compatibility facade will hide incomplete migration.
- Until the split is implemented, the current repository structure and
contracts remain authoritative.

View File

@@ -0,0 +1,67 @@
# ADR 0003: Use Maintainer-Run Validation and Tag-Only Releases for Promptkit
## Status
Accepted
## Date
2026-07-28
## Context
[ADR 0002](0002-split-promptkit-from-scriptorium.md) established Promptkit as
an independent Go library with its own repository, version history, validation,
and release coordination. It anticipated independent hosted CI for Promptkit
alongside Scriptorium's existing executable build and CI policy.
Promptkit is presently a single-maintainer library. It does not produce a
runnable command, so executable packaging and binary-release automation do not
apply. Its validation and release model should be explicit before repository
guidance relies on it.
## Decision
Promptkit will use maintainer-run validation rather than hosted CI at this
stage. From a clean checkout, the maintainer will run the repository-documented
test, vet, build, formatting, documentation-link, and repository-hygiene checks
before changes are accepted and before a release tag is published.
Promptkit releases consist of source commits and semantic Go module tags. The
project does not release runnable binaries or maintain binary-packaging
automation.
Scriptorium's executable build, hosted CI, and binary-release policies are
unaffected. The repository boundary, independent version history, release
ordering, and other migration decisions accepted by ADR 0002 remain in force.
Where ADR 0002 anticipated independent hosted CI for Promptkit, this later ADR
controls Promptkit validation.
## Alternatives Considered
- Add hosted Promptkit CI now. This would provide automated remote enforcement,
but its setup and maintenance are not proportionate to the present
single-maintainer library and do not replace the maintainer's release
responsibility.
- Require local Git hooks. Hooks can provide fast feedback, but they are
machine-local, can be bypassed, and are not a durable substitute for the
documented clean-checkout validation procedure.
## Rationale
A documented maintainer-run procedure provides a clear acceptance and release
gate with little operational overhead for the project's current contribution
pattern. If maintenance load or contributor patterns change, a later ADR may
introduce hosted CI without changing Promptkit's library or tag-based release
model.
## Consequences
- Promptkit repository guidance must define the complete local validation
procedure and the checks required before accepting or tagging a change.
- Release evidence is the maintainer's successful clean-checkout validation,
not a hosted CI result.
- Promptkit releases contain source and semantic Go module tags only.
- A future move to hosted CI requires a later architectural decision.
- Scriptorium continues to validate, build, package, and release its executable
under its own policies.

View File

@@ -66,9 +66,12 @@ untrusted writers.
The optional `model` object accepts `endpoint`, `model`, `temperature`, The optional `model` object accepts `endpoint`, `model`, `temperature`,
`max_tokens`, `top_p`, `timeout_seconds`, `service_tier`, `max_tokens`, `top_p`, `timeout_seconds`, `service_tier`,
`reasoning_effort`, `api_key_env`, and `extra_params`. Numeric ranges and `reasoning_effort`, `api_key_env`, and `extra_params`. Numeric ranges and
credential supply are defined by the [configuration reference](config.md). framework credential semantics are defined by the
[Promptkit format reference](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/formats.md).
Explicit zero values for the numeric fields are overrides; zero Explicit zero values for the numeric fields are overrides; zero
`timeout_seconds` disables the outbound client timeout. `timeout_seconds` disables the per-generation deadline only, retaining the
request context and configured transport cap. The timeout layers are defined in
the [Promptkit outbound integration contract](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/integrations/openai-compatible-chat.md#timeout-and-cancellation).
Raw API-key values are not accepted. `api_key` and any other unknown model Raw API-key values are not accepted. `api_key` and any other unknown model
field cause `400 invalid_json`. field cause `400 invalid_json`.

View File

@@ -1,7 +1,7 @@
# CLI Reference # CLI Reference
This is the canonical contract for invoking Scriptorium. Configuration discovery, This is the canonical contract for invoking Scriptorium. Configuration discovery,
precedence, directories, profiles, and schemas are defined in the precedence, application source locations, and server settings are defined in the
[configuration reference](config.md). The [HTTP API reference](api.md) owns [configuration reference](config.md). The [HTTP API reference](api.md) owns
service request and response behavior. service request and response behavior.
@@ -64,10 +64,12 @@ Deprecated aliases: `--prompt-id` for `--prompt`, and `--profile-id` for
`--profile`. `--profile`.
Omitted numeric runtime flags preserve the selected effective value; explicit Omitted numeric runtime flags preserve the selected effective value; explicit
zero values override it. `--timeout 0s` disables the outbound HTTP-client zero values override it. `--timeout 0s` disables the per-generation deadline
timeout. CLI durations are converted to whole seconds by truncation toward only; the caller context and configured transport cap remain active. CLI
zero, so any duration whose absolute value is below one second becomes an durations are converted to whole seconds by truncation toward zero, so any
explicit zero-second override. duration whose absolute value is below one second becomes an explicit
zero-second override. The timeout layers are defined in the
[Promptkit outbound integration contract](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/integrations/openai-compatible-chat.md#timeout-and-cancellation).
There is no raw API-key flag. Use `--api-key-env`. There is no raw API-key flag. Use `--api-key-env`.

View File

@@ -1,15 +1,17 @@
# Configuration Reference # Configuration Reference
This is the canonical reference for Scriptorium application settings and the This is the canonical reference for Scriptorium application settings. Prompt,
prompt, profile, and schema files those settings select. For command syntax, profile, schema, execution-setting, built-in profile, and framework credential
see the [CLI reference](cli.md); for HTTP request shapes, limits, and outcomes, semantics are defined by the
see the [HTTP API reference](api.md). [Promptkit v0.1.0 format reference](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/formats.md).
For command syntax, see the [CLI reference](cli.md); for HTTP request shapes and
outcomes, see the [HTTP API reference](api.md).
## Discovery And Precedence ## Discovery And Precedence
Application settings are resolved in this order: Application settings are resolved in this order:
1. built-in defaults; 1. built-in Scriptorium defaults;
2. a configuration file; then 2. a configuration file; then
3. CLI overrides. 3. CLI overrides.
@@ -19,7 +21,7 @@ If neither exists, it uses built-in defaults. An explicit `--config` path must
exist and decode successfully. exist and decode successfully.
The maintained [minimal configuration](../examples/config.yml) and The maintained [minimal configuration](../examples/config.yml) and
[full configuration](../examples/config.full.yml) are copyable examples. [complete configuration](../examples/config.full.yml) are copyable examples.
## Application Configuration File ## Application Configuration File
@@ -28,142 +30,57 @@ do not override a prior value. Raw API-key fields are not accepted.
| Field | Default | Meaning | | Field | Default | Meaning |
| --- | --- | --- | | --- | --- | --- |
| `prompt_dir` | unset | Directory containing prompt-definition YAML. `run`, `render`, and `serve` require an effective value. | | `prompt_dir` | unset | Promptkit prompt-definition source directory. `run`, `render`, and `serve` require an effective value. |
| `profile_dir` | unset | Directory containing custom profile YAML. Built-in profiles remain available. | | `profile_dir` | unset | Optional custom Promptkit profile source directory overlaid on Promptkit built-ins. |
| `schema_dir` | `.` | Base directory for relative JSON Schema paths. | | `schema_dir` | `.` | Promptkit schema source directory for relative schema paths. |
| `server.addr` | `:8080` | Address used by `serve`. | | `server.addr` | `:8080` | Address used by `serve`. |
| `server.artifact_root` | unset | Root that enables HTTP `file` input references. | | `server.artifact_root` | unset | Root that enables HTTP `file` input references. |
| `server.max_request_bytes` | `16777216` | Maximum encoded HTTP request body bytes; `0` disables the limit. | | `server.max_request_bytes` | `16777216` | Maximum encoded HTTP request-body bytes; `0` disables the limit. |
| `server.max_artifact_bytes` | `16777216` | Maximum HTTP file-input artifact bytes; `0` disables the limit. | | `server.max_artifact_bytes` | `16777216` | Maximum HTTP file-input artifact bytes; `0` disables the limit. |
| `server.max_response_bytes` | `16777216` | Maximum encoded HTTP response bytes; `0` disables the limit. | | `server.max_response_bytes` | `16777216` | Maximum encoded HTTP response bytes; `0` disables the limit. |
| `defaults.render_format` | `text` | Default `render` output format: `text` or `json`. | | `defaults.render_format` | `text` | Default prepared-run output format: `text` or `json`. |
The three size fields must be zero or greater. The HTTP contract defines how The size fields must be zero or greater. The [HTTP API](api.md) defines how
each limit is enforced and reported. `server.artifact_root` configures the each limit is enforced and reported. `server.artifact_root` configures an HTTP
deployment boundary; see the [HTTP API reference](api.md) for request-path and deployment boundary; see [operations](operations.md) for deployment handling.
containment behavior, and [operations](operations.md) for deployment handling.
## Prompt Definition Files ## Framework Source Mapping
Prompt definitions are strict YAML files anywhere below `prompt_dir`. A prompt Scriptorium passes `prompt_dir`, `profile_dir`, and `schema_dir` to Promptkit
is selected by its YAML `id`, not by file path; nested directories are only for when constructing its engine. Scriptorium does not redefine or independently
organization. See [maintained prompt examples](../examples/prompts/). parse those framework file formats.
| Field | Required | Meaning | - Prompt selection, versions, message templates, inputs, output contracts, and
| --- | --- | --- | session IDs are Promptkit contracts.
| `id` | yes | Prompt identifier. | - Profile fields, numeric ranges, execution defaults, overlay precedence,
| `version` | yes | Prompt version. | built-in profiles, and credential rules are Promptkit contracts.
| `default_profile` | no | Profile used when a request omits a profile ID. | - Schema path behavior and generated-content validation are Promptkit
| `description` | no | Human-readable description. | contracts.
| `session_id` | no | Go-template string rendered from request variables and sent to a compatible provider when non-empty. |
| `inputs` | no | Declared input metadata. |
| `messages` | yes | Chat-message templates. |
| `output` | yes | Output format and validation contract. |
### Inputs And Messages See the
[tagged Promptkit format reference](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/formats.md)
for all of those definitions. The files under
[`examples/prompts`](../examples/prompts/),
[`examples/profiles`](../examples/profiles/), and
[`examples/schemas`](../examples/schemas/) are maintained Scriptorium
application inputs using that tagged format.
Each `inputs` item has a required `name` and optional `required`, ## Credentials And Outbound Behavior
`content_type`, and `description` fields. Input names must be unique.
Each message has a required `role`, exactly one of `content` or `content_file`, Scriptorium maps `--api-key-env` and HTTP `model.api_key_env` into Promptkit
and optional `cache_control`. A `content_file` path is relative to the prompt request overrides. Keep secret values in environment variables and store only
file. `cache_control.type` must be `ephemeral`; its optional `ttl` is `1h`. their names in configuration or framework source files. Do not place raw keys
in configuration, prompts, profiles, CLI arguments, examples, or HTTP
payloads.
`session_id` uses the same template variables as messages. Empty rendered Promptkit's
values are omitted. A rendered value may contain at most 256 Unicode code [OpenAI-compatible integration contract](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/integrations/openai-compatible-chat.md)
points. defines outbound authentication, provider request mapping, transport limits,
and timeout layering.
### Output Contract
| Field | Required | Values or behavior |
| --- | --- | --- |
| `format` | yes | `text`, `markdown`, or `json`. |
| `validation_mode` | yes | `none`, `basic`, `json`, or `json_schema`. |
| `schema_path` | for `json_schema` | Schema path, relative to `schema_dir` unless absolute. |
| `repair_attempts` | no | Integer greater than or equal to `0`; omitted means `0`. |
## Profile Definition Files
Profiles are strict YAML files anywhere below `profile_dir`. A profile is
selected by YAML `id`; nested directories are organizational. See the
[maintained profile examples](../examples/profiles/).
| Field | Required | Meaning |
| --- | --- | --- |
| `id` | yes | Profile identifier. |
| `endpoint` | yes | OpenAI-compatible base URL, including its API version path when needed. |
| `model` | yes | Provider model name. |
| `temperature` | no | Number from `0` through `2`. |
| `max_tokens` | no | Integer zero or greater. |
| `top_p` | no | Number from `0` through `1`. |
| `timeout_seconds` | no | Integer zero or greater. |
| `service_tier` | no | Non-empty provider-specific request tier. |
| `reasoning_effort` | no | Non-empty provider-specific reasoning setting. |
| `api_key_env` | no | Environment-variable name containing the API key. |
| `extra_params` | no | JSON-compatible provider-specific outbound request fields. |
Execution defaults before profile and request overrides are `temperature: 0`,
`max_tokens: 0`, `top_p: 1`, and `timeout_seconds: 600`. Profile numeric values
merge by non-zero value. Request overrides preserve presence, so an explicit
zero can override a profile value.
Custom profiles take precedence over built-ins with the same ID. Invalid custom
profiles are errors; they do not fall back to a built-in profile. Raw `api_key`
is rejected. Use `api_key_env`, or the public Go package's request-scoped key
mechanism described in the [package contract](consumers/pkg-scriptorium.md).
`extra_params` keys must be non-empty and cannot be `model`, `session_id`,
`messages`, `temperature`, `max_tokens`, `top_p`, `service_tier`,
`reasoning_effort`, or `response_format`.
### Built-In Profile Catalog
Each embedded profile uses `OPENROUTER_API_KEY`.
| Provider | ID | Model |
| --- | --- | --- |
| aion-labs | `aion-2` | `aion-labs/aion-2.0` |
| anthropic | `claude-fable-latest` | `~anthropic/claude-fable-latest` |
| anthropic | `claude-haiku-latest` | `~anthropic/claude-haiku-latest` |
| anthropic | `claude-opus-latest` | `~anthropic/claude-opus-latest` |
| anthropic | `claude-sonnet-latest` | `~anthropic/claude-sonnet-latest` |
| deepseek | `deepseek-3-2` | `deepseek/deepseek-v3.2` |
| deepseek | `deepseek-4-flash` | `deepseek/deepseek-v4-flash` |
| deepseek | `deepseek-4-pro` | `deepseek/deepseek-v4-pro` |
| google | `gemini-2-flash` | `google/gemini-2.5-flash` |
| google | `gemini-2-flash-lite` | `google/gemini-2.5-flash-lite` |
| google | `gemini-2-pro` | `google/gemini-2.5-pro` |
| google | `gemini-3-flash-lite` | `google/gemini-3.1-flash-lite` |
| google | `gemini-flash-latest` | `~google/gemini-flash-latest` |
| google | `gemini-pro-latest` | `~google/gemini-pro-latest` |
| google | `gemma-4-31b` | `google/gemma-4-31b-it:exacto` |
| minimax | `minimax-m2` | `minimax/minimax-m2.5` |
| minimax | `minimax-m3` | `minimax/minimax-m3` |
| mistral | `mistral-large-2512` | `mistralai/mistral-large-2512` |
| mistral | `mistral-medium-3-5` | `mistralai/mistral-medium-3-5` |
| mistral | `mistral-small-3` | `mistralai/mistral-small-3.2-24b-instruct` |
| mistral | `mistral-small-4` | `mistralai/mistral-small-2603` |
| nvidia | `nemotron-3-ultra` | `nvidia/nemotron-3-ultra-550b-a55b` |
| openai | `gpt-5-mini` | `openai/gpt-5.4-mini` |
| openai | `gpt-5-nano` | `openai/gpt-5.4-nano` |
## Schemas
Schemas are JSON files, normally below `schema_dir`. `json_schema` output
requires a `schema_path`. Relative paths resolve from `schema_dir`; absolute
paths are used directly. Referenced nested schemas use relative paths and are
not discovered by basename. An unreadable or invalid schema is a runtime
validation error; generated content that fails JSON or schema validation is a
validation result.
## Credentials
Keep secrets in environment variables. Store only an environment-variable name
in `api_key_env`; do not place raw keys in configuration, prompt or profile
files, CLI arguments, examples, or HTTP payloads.
## Related References ## Related References
- [CLI reference](cli.md) - [CLI reference](cli.md)
- [HTTP API reference](api.md) - [HTTP API reference](api.md)
- [OpenAI-compatible outbound contract](integrations/openai-compatible-chat.md) - [Operations guide](operations.md)
- [Promptkit framework formats](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/formats.md)

View File

@@ -1,45 +1,23 @@
# Consumer Integration Overview # Consumer Integration Overview
This guide helps applications choose a Scriptorium interface and understand Scriptorium exposes executable interfaces. Choose between a local subprocess
their responsibilities. The linked contracts own interface syntax and wire and the HTTP service according to the boundary your application needs.
semantics.
| Interface | Use when | | Interface | Use when |
| --- | --- | | --- | --- |
| Go package | The consumer is Go and needs typed requests, results, or an injected LLM client. | | CLI subprocess | The consumer needs a synchronous local process boundary or prepared output. |
| CLI subprocess | The consumer needs process isolation or is not written in Go. |
| HTTP API | The consumer needs a service boundary or remote access. | | HTTP API | The consumer needs a service boundary or remote access. |
- Go package: [package contract](pkg-scriptorium.md)
- CLI subprocess: [subprocess integration](../integrations/subprocess.md) - CLI subprocess: [subprocess integration](../integrations/subprocess.md)
- HTTP service: [HTTP API reference](../api.md) - HTTP service: [HTTP API reference](../api.md)
- Prompt, profile, schema, and credential configuration: [configuration reference](../config.md) - Application configuration: [configuration reference](../config.md)
## Minimal Go Use Go applications that need an in-process prompt framework should import
Promptkit directly. The tagged
```go [Promptkit Go consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/consumers/pkg-promptkit.md)
engine, err := scriptorium.NewEngine(scriptorium.Config{ owns that interface; Scriptorium does not provide a Go library package.
PromptDir: "./examples/prompts", Consumers arriving from the former Scriptorium Go API should follow the
ProfileDir: "./examples/profiles", [migration guide](migrating-to-promptkit.md).
})
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"),
},
})
if err != nil {
return err
}
_ = prepared
```
For a maintained program, see
[`examples/go-library/prepare`](../../examples/go-library/prepare).
## Consumer Responsibilities ## Consumer Responsibilities
@@ -47,13 +25,14 @@ Consumers are responsible for:
- selecting and deploying prompt, profile, and schema assets; - selecting and deploying prompt, profile, and schema assets;
- supplying required inputs and template variables; - supplying required inputs and template variables;
- supplying credentials through the applicable interface; - supplying credentials through the chosen interface;
- protecting rendered prompts and generated artifacts as potentially sensitive; - protecting rendered prompts and generated artifacts as potentially
sensitive;
- deciding whether validation-failed output is usable; and - deciding whether validation-failed output is usable; and
- retrying only when another model call is acceptable. - retrying only when another model call is acceptable.
Scriptorium does not persist run state. A retry can produce different output and Scriptorium does not persist run state. A retry can produce different output
can incur another provider request. CLI exit behavior belongs to the and can incur another provider request. CLI exits belong to the
[CLI reference](../cli.md); HTTP status behavior belongs to the [CLI reference](../cli.md), HTTP status behavior belongs to the
[HTTP API reference](../api.md); package errors and results belong to the [HTTP API reference](../api.md), and framework semantics belong to
[package contract](pkg-scriptorium.md). [Promptkit v0.1.0](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/formats.md).

View File

@@ -0,0 +1,109 @@
# Migrate From Scriptorium To Promptkit
## Supported Migration Boundary
Scriptorium `v0.11.1` at
`gitea.maximumdirect.net/eric/scriptorium` is the final release that provides
the former in-process Go framework. Promptkit `v0.1.0` at
`gitea.maximumdirect.net/eric/promptkit` is the destination for that framework
API. Scriptorium `v0.12.0` and later provide the CLI and HTTP application only.
There is no Scriptorium compatibility facade, alias package, forwarding
package, or deprecated wrapper. A consumer that cannot migrate may remain
pinned to Scriptorium `v0.11.1`, but that framework-bearing line does not
provide the slim application release.
## Update A Go Consumer
Start from a clean consumer checkout and review the pending diff before
committing it. Add the published Promptkit module:
```sh
go get gitea.maximumdirect.net/eric/promptkit@v0.1.0
```
For an ordinary consumer that imports the former root package under its
default name, replace the exact import and package qualifier, then format the
changed Go files:
```sh
git grep -l \
'"gitea.maximumdirect.net/eric/scriptorium"' \
-- '*.go' |
while IFS= read -r go_file
do
perl -pi -e \
's{"gitea.maximumdirect.net/eric/scriptorium"}{"gitea.maximumdirect.net/eric/promptkit"}g; s{\bscriptorium\.}{promptkit.}g' \
"$go_file"
gofmt -w "$go_file"
done
```
Inspect the resulting diff. Consumers that used an import alias should retain
or deliberately rename that alias instead of applying the qualifier
replacement mechanically.
Remove the now-unused Scriptorium requirement through module tidiness and run
the consumer's complete tests:
```sh
go mod tidy
go test ./...
```
Confirm that `go.mod` selects Promptkit `v0.1.0` and that no Go file imports
the former Scriptorium package:
```sh
test "$(
go list -m -f '{{.Path}}@{{.Version}}' \
gitea.maximumdirect.net/eric/promptkit
)" = 'gitea.maximumdirect.net/eric/promptkit@v0.1.0'
if git grep -n \
'gitea.maximumdirect.net/eric/scriptorium' \
-- '*.go'
then
printf '%s\n' 'a former Scriptorium Go import remains' >&2
exit 1
fi
```
## Compatibility And Additions
Promptkit preserves the established engine, request, result, profile,
source-option, model-client, artifact, validation-value, and public-error
shapes where practical. Exact declarations and current behavior belong to the
tagged [Promptkit consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/consumers/pkg-promptkit.md)
and Go source.
Promptkit also includes migration-relevant public contracts that were not in
Scriptorium `v0.11.1`:
- [`WithArtifactReader`](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/engine.go#L96-L105)
and the
[`ArtifactReader` declaration](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/types.go#L129-L135)
provide the artifact-reading extension described by the tagged
[extension-interface guide](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/consumers/pkg-promptkit.md#extension-interfaces).
- [`ErrProfileRequired` and `ErrAPIKeyEnvMissing`](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/engine.go#L28-L40)
provide the specific identities described by the tagged
[error guide](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/consumers/pkg-promptkit.md#errors).
Use those tagged owners for exact signatures, wrapping guarantees, and
extension behavior.
## Verify Consumer Behavior
Source compatibility is only the first check. Exercise the behavior the
consumer actually relies upon, especially:
- prompt, profile, and schema source selection;
- direct and environment-based credentials;
- caller, generation, and transport timeout layering;
- output validation and validation-failure handling;
- injected model-client and artifact-reader extensions; and
- every `errors.Is` branch used for recovery or classification.
Also verify any serialized values, redaction expectations, filesystem policy,
and provider integration behavior that crosses the consumer's own boundary.
Promptkit owns the in-process framework contract; Scriptorium owns only its
executable CLI and HTTP application interfaces.

View File

@@ -1,182 +0,0 @@
# Package `scriptorium`
Import path:
```go
import "gitea.maximumdirect.net/eric/scriptorium"
```
This is the canonical public Go contract for in-process prompt preparation and
execution. Prompt, profile, and schema file formats are defined in the
[configuration reference](../config.md).
## Engine Construction
`NewEngine(Config, ...Option)` constructs an engine. `Config` has these
fields:
| Field | Meaning |
| --- | --- |
| `PromptDir` | Prompt-definition directory, required unless a prompt source option is supplied. |
| `ProfileDir` | Optional custom profile directory over built-ins. |
| `SchemaDir` | Schema directory; empty uses `.`. |
| `Timeout` | Base timeout for the built-in OpenAI-compatible client when `HTTPClient` is absent or has a zero timeout. A non-positive value uses the internal default. |
| `HTTPClient` | Optional HTTP client for that built-in client. It is cloned; a non-zero `Timeout` on it takes precedence over `Config.Timeout` as the base timeout. |
Nil options are ignored. Invalid construction, including
`WithLLMClient(nil)`, returns an error matching `ErrInvalidConfig`.
An effective positive `timeout_seconds` replaces the base timeout. An explicit
request override of zero disables the HTTP-client timeout. The timeout is
otherwise inherited from the supplied client, `Config.Timeout`, or the internal
default in that order.
Source options replace their matching directory source:
- prompts: `WithPromptFS(fsys, root)`, `WithPromptFile(path)`;
- profiles: `WithProfileFS(fsys, root)`, `WithProfileFile(path)`, and
`WithProfiles(profiles...)`;
- schemas: `WithSchemaFS(fsys, root)`, `WithSchemaFile(path)`; and
- LLM client: `WithLLMClient(client)`.
`fs.FS` prompt-content and schema paths stay inside their configured roots.
Single-file prompt and profile sources are selected by their YAML `id`, not
their file names. `WithPromptFile` resolves relative `content_file` paths from
the prompt file's directory. `WithSchemaFile` exposes its schema by the schema
file's base name. In-memory profiles take precedence over an explicit or
directory-backed profile source, which in turn takes precedence over built-ins.
File and filesystem sources use the format and credential rules in the
[configuration reference](../config.md).
## Prepare And Run
`Prepare(ctx, request)` resolves the prompt, profile, input artifacts,
validation contract, and rendered messages without calling an LLM.
`Run(ctx, request)` performs that preparation, calls the configured client,
and validates generated content.
```go
engine, err := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
ProfileDir: "./examples/profiles",
})
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"),
},
})
if err != nil {
return err
}
_ = prepared.Messages
```
The maintained package example is
[`examples/go-library/prepare`](../../examples/go-library/prepare).
`PreparedRun` exposes prompt, selected-profile, effective-model, output
contract, structured-output, input-hash, rendered-message, and timing
information. It does not include a resolved API key, model output, validation
result, or target-presence metadata.
`RunResult` adds run ID, artifact, raw output, validation, model metadata,
usage, and duration. Generated-content validation failures return a result with
`Validation.Status == ValidationFailed`; schema or validator runtime failures
return an error matching `ErrValidation`.
## Public Values
`ArtifactRef` has `Type`, `URI`, and `Body`; `Artifact` has `Name`,
`ContentType`, `Body`, `URI`, `Size`, and `Hash`. `ExecutionTarget` exposes the
effective endpoint, model, numeric settings, credential-environment name,
service tier, reasoning effort, and extra parameters. `ValidationResult`
contains status, mode, errors, schema path, repair attempts, and validity.
The exported constants define these serialized values:
- artifact types: `inline` and `file`;
- output formats: `text`, `markdown`, and `json`;
- validation modes: `none`, `basic`, `json`, and `json_schema`; and
- validation statuses: `passed`, `failed`, and `skipped`.
`TokenUsage` reports prompt, completion, total, cached, and cache-write token
counts. `RenderedPrompt`, `RenderedMessage`, `CacheControl`, and
`StructuredOutputSpec` are the public shapes used by injected LLM clients.
## Requests, Inputs, And Overrides
`RunRequest` fields are `PromptID`, `PromptVersion`, `ProfileID`,
`APIKey`, `Inputs`, `Vars`, `Execution`, `Validation`, and
`Metadata`.
Input helpers are:
- `File(path)` for a file-backed artifact;
- `Inline(body)` for inline content; and
- `InlineWithURI(uri, body)` for inline content with URI metadata.
Required declared inputs must be supplied. Template rendering must also resolve
every input name the prompt actually references. Extra entries in `Inputs`
are not rejected solely because they are undeclared.
`ExecutionTargetOverride` supplies endpoint, model, credential-environment,
service-tier, reasoning-effort, and extra-parameter overrides. Its numeric
fields (`Temperature`, `MaxTokens`, `TopP`, and `TimeoutSeconds`) are
pointers so explicit zero values are preserved. `OutputContract` supplies
`Format`, `ValidationMode`, `SchemaPath`, and `RepairAttempts`.
`ExtraParams` accepts JSON-compatible values: strings, booleans, finite
numbers, objects with string keys, arrays or slices, and nil. Unsupported
values, non-string map keys, non-finite floats, and cycles return
`ErrInvalidConfig` for profiles or `ErrInvalidRequest` for request
overrides.
## Profiles And Credentials
`OpenAICompatibleProfile(OpenAICompatibleProfileConfig)` creates an
in-memory `Profile`. Its public fields are `ID`, `Endpoint`, `Model`,
`Temperature`, `MaxTokens`, `TopP`, `TimeoutSeconds`, `ServiceTier`,
`ReasoningEffort`, `APIKeyRequired`, and `ExtraParams`.
`WithProfiles` rejects duplicate IDs in one call.
A direct `RunRequest.APIKey` is request-scoped and takes precedence over
`api_key_env` for the built-in client. It is excluded from JSON output and
from `PreparedRun` and `RunResult`. The package's `String` and
`GoString` methods report only whether a direct key is set. Do not use
reflection-based dumps of request structs, which can bypass that redaction.
## Injected LLM Clients
`LLMClient` implements:
```go
Generate(context.Context, GenerateRequest) (*GenerateResponse, error)
```
Injected clients receive the rendered prompt, effective execution target, numeric
target-presence metadata, optional structured-output specification, and direct
request API key. `GenerateResponse` returns content and `TokenUsage`.
Custom clients should avoid logging raw prompts or credentials.
## Errors
Public methods preserve these sentinel checks through `errors.Is`:
- `ErrInvalidConfig`
- `ErrInvalidRequest`
- `ErrPromptNotFound`
- `ErrProfileNotFound`
- `ErrPromptLoad`
- `ErrProfileLoad`
- `ErrArtifactLoad`
- `ErrPromptRender`
- `ErrLLMGenerate`
- `ErrValidation`
For interface selection and operational responsibilities, see the
[consumer integration overview](api.md).

View File

@@ -1,63 +1,56 @@
# Development # Development
This is the contributor entry point for Scriptorium. Use the task-specific This is the contributor entry point for Scriptorium. Scriptorium is an
reading guide below before making changes. Canonical architecture, contracts, application that consumes the public
component behavior, and policies remain in their owning documents. [Promptkit v0.1.0 package](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/consumers/pkg-promptkit.md);
framework implementation work belongs in Promptkit.
## Initial Orientation ## Initial Orientation
Before starting work: Before starting work:
1. inspect the working tree and preserve unrelated changes; 1. inspect the working tree and preserve unrelated changes;
2. read the architecture policy for code or design work; 2. read the [architecture policy](policy/architecture.md);
3. read the policy, contract, and internal documents listed for the task; 3. follow the task-specific contracts and internal documents below; and
4. inspect the relevant implementation and tests before deciding how to change 4. inspect the relevant implementation and tests before changing them.
them.
Start with: Also read the [documentation policy](policy/documentation.md) before changing
documentation and the [testing policy](policy/testing.md) before changing
- [Architecture policy](policy/architecture.md) for system boundaries, tests.
invariants, and non-goals;
- [Internal component overview](internal/overview.md) for the current package
and component map;
- [Documentation policy](policy/documentation.md) before changing
documentation;
- [Testing policy](policy/testing.md) before adding, rewriting, or deleting
tests.
## Task-Specific Reading Guide ## Task-Specific Reading Guide
| Task | Read before changing | | Task | Read before changing |
| --- | --- | | --- | --- |
| Repository orientation or component responsibility | [Internal component overview](internal/overview.md) and [architecture policy](policy/architecture.md) | | Repository orientation or component responsibility | [Internal component overview](internal/overview.md) and [architecture policy](policy/architecture.md) |
| Public Go package or engine behavior | [Go package consumer contract](consumers/pkg-scriptorium.md), [internal component overview](internal/overview.md), [runner internals](internal/runner.md), [adapter internals](internal/adapters.md), and [source internals](internal/sources.md) | | CLI commands, flags, output, or exit behavior | [CLI contract](cli.md) and [adapter internals](internal/adapters.md) |
| CLI commands, flags, output, or exit behavior | [CLI contract](cli.md), [internal component overview](internal/overview.md), and [adapter internals](internal/adapters.md) | | HTTP routes, DTOs, limits, status mapping, or artifact policy | [HTTP API contract](api.md), [adapter internals](internal/adapters.md), and [source internals](internal/sources.md) |
| HTTP routes, DTOs, limits, or status mapping | [HTTP API contract](api.md), [internal component overview](internal/overview.md), [adapter internals](internal/adapters.md), and [source internals](internal/sources.md) | | Application configuration or precedence | [Configuration contract](config.md), [adapter internals](internal/adapters.md), and [source internals](internal/sources.md) |
| Application configuration | [Configuration contract](config.md), [internal component overview](internal/overview.md), [adapter internals](internal/adapters.md), and [source internals](internal/sources.md) | | Prepared-run presentation | [CLI contract](cli.md), [adapter internals](internal/adapters.md), and `internal/format` |
| Prompt, profile, schema, or artifact loading | [Configuration contract](config.md), [internal component overview](internal/overview.md), and [source internals](internal/sources.md) | | Prompt, profile, schema, generation, or validation semantics | [Promptkit framework formats](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/formats.md) and the [Promptkit consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/consumers/pkg-promptkit.md) |
| Runner orchestration, rendering, validation, or repair | [Runner internals](internal/runner.md) and [source internals](internal/sources.md) | | OpenAI-compatible outbound behavior or timeout layering | [Promptkit integration contract](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/integrations/openai-compatible-chat.md) |
| OpenAI-compatible request or response behavior | [OpenAI-compatible integration](integrations/openai-compatible-chat.md), [LLM internals](internal/llm.md), [runner internals](internal/runner.md), and [adapter internals](internal/adapters.md) |
| Subprocess behavior | [Subprocess integration](integrations/subprocess.md) and [CLI contract](cli.md) | | Subprocess behavior | [Subprocess integration](integrations/subprocess.md) and [CLI contract](cli.md) |
| Runtime operation or recovery | [Operations](operations.md) | | Runtime operation or recovery | [Operations](operations.md) |
| Examples or copyable assets | The owning contract for the demonstrated behavior and the related files under `examples/` | | Release packaging or publication | The [release procedure](release.md), [hosted release workflow](../.woodpecker/release.yml), and [architecture policy](policy/architecture.md) |
| Architecture decisions or future work | The [documentation policy](policy/documentation.md), relevant accepted ADRs such as [ADR 0001](adr/0001-adopt-canonical-documentation-ownership.md), and relevant roadmap documents under `roadmap/` | | Examples or copyable assets | The owning Scriptorium contract, the relevant [Promptkit format contract](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/formats.md), and the related files under `examples/` |
| Architecture decisions or future work | The [documentation policy](policy/documentation.md), relevant accepted ADRs, and relevant roadmap documents |
For cross-cutting changes, follow every applicable row. Internal component Cross-project changes land and release in Promptkit before Scriptorium adopts
documents own detailed subsystem change recipes. the tagged version. Do not commit a Go workspace, local replacement, vendored
Promptkit source, or an import of a Promptkit `internal` package.
## Baseline Validation ## Baseline Validation
Use focused checks while iterating, then run validation proportionate to the For code changes, run:
change and the risks described by the testing policy.
The repository-level baseline for code changes is:
```bash ```bash
go test ./... go test ./...
go test -race ./...
go vet ./... go vet ./...
go build ./cmd/scriptorium go build ./cmd/scriptorium
``` ```
Documentation-only work does not require the full Go suite unless it changes Check formatting with `gofmt`, run `git diff --check`, and validate affected
commands, examples, generated output, or another behavior that the suite examples and documentation links. Documentation-only work does not require
validates. Always check changed links, paths, examples, and canonical ownership. unrelated new tests, but commands and examples changed by documentation must be
run.

View File

@@ -1,111 +0,0 @@
# OpenAI-Compatible Chat Integration
This is the outbound wire contract for Scriptorium's OpenAI-compatible
chat-completions client.
## Endpoint And Method
Scriptorium uses the request endpoint override when present; otherwise it uses
the configured client base URL. It removes a trailing slash and sends
`POST /chat/completions`.
For example, `http://localhost:8000/v1` becomes
`http://localhost:8000/v1/chat/completions`.
## Request Payload
The payload always contains `model` and rendered `messages`. It additionally
contains these fields when applicable:
| Field | Inclusion |
| --- | --- |
| `session_id` | Non-empty rendered prompt session ID. |
| `temperature` | Non-zero effective value or an explicit zero override. |
| `max_tokens` | Non-zero effective value or an explicit zero override. |
| `top_p` | Non-zero effective value or an explicit zero override. |
| `service_tier` | Any non-empty configured value. |
| `reasoning_effort` | Any non-empty configured value. |
| `response_format` | Structured output is requested. |
| provider-specific fields | Flattened from `extra_params`. |
`service_tier` and `reasoning_effort` are forwarded without a provider value
catalog; the selected backend decides which values it supports.
`extra_params` are top-level JSON fields, not a nested object. Keys cannot be
empty or collide with `model`, `session_id`, `messages`, `temperature`,
`max_tokens`, `top_p`, `service_tier`, `reasoning_effort`, or
`response_format`. Values must be JSON-serializable.
A rendered `session_id` is sent as a top-level JSON field, not as a header.
Empty values are omitted. The maximum length is 256 Unicode code points.
Messages without cache control use string `content`. A message with cache
control uses one text block:
```json
{
"role": "system",
"content": [{
"type": "text",
"text": "rendered text",
"cache_control": {"type": "ephemeral", "ttl": "1h"}
}]
}
```
When the prompt omits cache-control `ttl`, the payload omits `ttl`.
Structured JSON Schema output is sent as:
```json
{
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "schema name",
"strict": true,
"schema": {"type": "object"}
}
}
}
```
## Authentication And Timeout
When a direct request API key is present, Scriptorium sends
`Authorization: Bearer <key>` and does not read `api_key_env`. Otherwise, it
resolves the configured non-empty `api_key_env` at request time and sends the
same header. If neither mechanism supplies a key, it sends no
`Authorization` header.
The client base timeout is chosen at construction: a non-zero timeout on a
supplied `http.Client` takes precedence over `Config.Timeout`; otherwise a
positive `Config.Timeout` is used, then the internal default. A positive
effective `timeout_seconds` replaces that base. An explicit request override
of zero disables the HTTP-client timeout; negative values are rejected before a
request is sent.
## Response Subset And Failures
A successful provider response must supply non-empty
`choices[0].message.content`. Scriptorium reads these optional or required
usage fields when present:
- `usage.prompt_tokens`
- `usage.completion_tokens`
- `usage.total_tokens`
- `usage.prompt_tokens_details.cached_tokens`
- `usage.cache_write_tokens`
Missing cache usage is reported as zero. Invalid JSON, an empty choices array,
or empty first-choice content is a malformed provider response. Network and
request-construction failures, non-2xx responses, and malformed responses fail
the outbound call. Provider response bodies are not exposed by this client.
The client does not implement built-in retries, tool calls, top-level
`cache_control`, or multi-request payload modes.
## Related References
Prompt schema preparation and runner orchestration are described in
[runner internals](../internal/runner.md). Prompt and profile configuration is
defined by the [configuration reference](../config.md).

View File

@@ -2,112 +2,86 @@
## Purpose ## Purpose
Adapters translate external inputs into domain requests, compose dependencies, Scriptorium adapters translate executable inputs into Promptkit public requests
and translate domain results or errors back to their interface. They own IO and and translate Promptkit results or errors back to CLI or HTTP behavior. They
presentation mechanics; use-case decisions remain in `internal/usecase`. own IO and presentation mechanics, not framework decisions.
External contracts are canonical in the [CLI reference](../cli.md), [HTTP API External contracts are canonical in the [CLI reference](../cli.md) and
reference](../api.md), and [Go package contract](../consumers/pkg-scriptorium.md). [HTTP API reference](../api.md). Promptkit's public engine contract is
described by its tagged
[Go consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/consumers/pkg-promptkit.md).
## Components And Collaborators ## Components And Collaborators
- `cmd/scriptorium` passes process arguments and streams to - `cmd/scriptorium` passes process arguments and streams to
`internal/adapter/cli`. `internal/adapter/cli`.
- `internal/adapter/cli` parses commands, resolves application settings through - `internal/adapter/cli` resolves settings through `internal/config`,
`internal/config`, constructs a runner, and owns process output handling. constructs `promptkit.Engine`, maps CLI values to `promptkit.RunRequest`,
- `internal/adapter/http` decodes DTOs, maps them to `domain.RunRequest`, calls and owns output files, summaries, and exit codes.
a runner interface, and maps errors and results to HTTP DTOs. - `internal/adapter/http` strictly decodes request DTOs, maps them to Promptkit
- The root `scriptorium` package maps its public types and options to internal public values, calls its adapter-owned `Runner` interface, and maps results
collaborators and maps selected internal errors to public sentinels. and errors to HTTP DTOs.
- `internal/format` formats prepared runs for the CLI; `internal/llm`, - `internal/format` renders `promptkit.PreparedRun` values as deterministic text
`internal/prompt`, and source packages supply runner dependencies. or JSON.
## Wiring Flows ## Wiring Flows
### CLI ### CLI
The CLI resolves configuration before constructing dependencies. `run` builds a `run` calls `promptkit.Engine.Run`; `render` calls
runner with the ordinary composite artifact reader and invokes `Runner.Run`; `promptkit.Engine.Prepare`. Both share request mapping for prompt/profile
`render` uses the same wiring and invokes `Runner.Prepare`; `serve` replaces the selection, file inputs, variables, and presence-aware execution overrides.
file reader with the restricted artifact reader, builds an HTTP handler, and Omitted framework settings remain zero values so Promptkit resolves its own
starts the server. defaults.
Parser state records whether numeric runtime values were explicitly supplied. `serve` constructs Scriptorium's restricted HTTP artifact reader, injects it
That presence is carried into `domain.ExecutionTargetOverride`, allowing the with `promptkit.WithArtifactReader`, passes the engine through the HTTP
runner to distinguish omitted values from explicit zero overrides. adapter's consumer-owned `Runner` interface, and starts the server.
### HTTP ### HTTP
The handler first enforces transport limits, strict JSON decoding, and the The handler enforces transport limits and strict JSON decoding before mapping
minimal request shape. It maps DTO values to domain types without deciding DTOs into `promptkit.RunRequest`, `promptkit.ArtifactRef`, and
prompt selection, source behavior, or validation semantics. On success it maps `promptkit.ExecutionTargetOverride`. On success it reads Promptkit artifact,
the domain result to the response DTO; on failure it uses `errors.Is` over validation, model, usage, and metadata values directly.
runner, source, artifact, and profile errors to choose the public error mapping.
The [HTTP API reference](../api.md) owns the route, DTO schema, status codes, Failure mapping uses `errors.Is` against Promptkit's public sentinels and the
and externally observable limit behavior. HTTP reader's Scriptorium-owned containment and size errors. Wrapped reader
errors preserve their identity through Promptkit's artifact-load boundary.
### Public Go Facade
`NewEngine` applies public options, selects filesystem, `fs.FS`, single-file,
or in-memory dependencies, and constructs a runner. The conversion functions
copy maps and slices across the boundary so callers do not receive internal
domain values. The facade maps selected internal errors to the public sentinel
set and keeps direct request API keys out of public results.
## Package-Local Guarantees ## Package-Local Guarantees
- Adapters do not embed runner orchestration or source-loading decisions. - Adapters contain no copied framework types or orchestration.
- Configuration is resolved before adapter dependency composition. - Configuration is resolved before Promptkit engine construction.
- CLI and HTTP create runners without a repairer; a repairer is available only - Explicit numeric overrides preserve presence, including zero.
through explicit internal runner construction. - HTTP DTO and error mapping remains stable and transport-owned.
- DTO conversion preserves explicit numeric-override presence. - Resolved secrets are not serialized or printed.
- Error mapping matches error identities, not error text. - No adapter creates durable run state.
- No adapter creates durable run state; caller-selected output files are not
application state.
## Failure And Verification Boundaries ## Verification
Keep external error payloads concise, preserve strict external decoding, and do Inspect:
not serialize resolved secret values. Validation content failures remain result
state; runtime failures remain errors for the relevant adapter to map.
Inspect focused tests when changing this area:
- `internal/adapter/cli/run_test.go` - `internal/adapter/cli/run_test.go`
- `internal/adapter/http/handler_test.go` - `internal/adapter/http/handler_test.go`
- `engine_test.go` - `internal/adapter/http/artifact_reader_test.go`
- `internal/format/prepared_run_test.go` - `internal/format/prepared_run_test.go`
- `internal/adapter/dependency_test.go`
Run the affected adapter package tests and recheck the relevant canonical The adapter tests protect parsing, configuration mapping, output, status
contract. The [testing policy](../policy/testing.md) owns global test mapping, restricted artifacts, and representative real Promptkit-engine
sufficiency guidance. workflows. The dependency test protects the repository boundary.
## Change Recipes ## Change Recipes
### Application Configuration Fields For a CLI or HTTP change:
1. Add the field to the relevant `internal/config` shape and default handling. 1. identify the Scriptorium-owned external contract;
2. Parse and validate it, then preserve configuration and CLI-override 2. map through Promptkit public values without copying framework semantics;
precedence while wiring it through its consuming adapter. 3. add or update the narrow application-owned test;
3. Add focused configuration and adapter tests for parsing, mapping, and 4. update the canonical Scriptorium contract; and
effective behavior. 5. coordinate and tag Promptkit first if a required public capability is
4. Update the [configuration contract](../config.md) and any affected external genuinely absent.
contract.
### CLI Flags Update [source internals](sources.md) when application source locations or HTTP
artifact containment changes.
1. Add the flag to the relevant parser in `internal/adapter/cli/run.go`.
2. Keep command scope and application-configuration precedence intentional.
3. Add or update parser and command tests in
`internal/adapter/cli/run_test.go`.
4. Update the [CLI contract](../cli.md) and affected maintained examples.
### Adapter Capabilities
1. Define or reuse the appropriate domain or use-case interface boundary.
2. Implement translation and IO behavior without moving use-case decisions out
of `internal/usecase`.
3. Add focused mapping, parsing, and error-behavior tests.
4. Update this document and the affected public or integration contract. Update
[source internals](sources.md) when source-loading behavior changes.

View File

@@ -1,86 +0,0 @@
# LLM Internals
## Purpose
`internal/llm` defines the provider-neutral `Client` interface and the
OpenAI-compatible client implementation. The [OpenAI-compatible integration
contract](../integrations/openai-compatible-chat.md) owns the outbound HTTP wire
format and protocol behavior.
## Construction
`NewOpenAICompatibleClient` validates a non-empty configured base URL, records
an optional default model, and resolves one base timeout. A supplied client with
a non-zero timeout supplies that base; otherwise a positive configured timeout
is used, then the internal default.
When callers supply an `http.Client`, construction clones it rather than
mutating the caller's instance. A supplied client with no timeout receives the
resolved base timeout in the clone. The client stores the trimmed base URL,
default model, and cloned client.
## Generate Flow
`Generate` receives a `domain.GenerateRequest` from the runner:
1. validate the effective timeout and choose the request endpoint;
2. map the domain request to the internal wire-request representation;
3. validate and flatten extra parameters, encode JSON, and create the HTTP
request;
4. prefer a direct API key, otherwise resolve the configured key environment
variable;
5. derive a request HTTP client only when an explicit timeout changes the base
client;
6. execute the request, reject non-success status responses without returning
provider response bodies; and
7. decode the response subset into `domain.GenerateResponse`.
`openAIChatRequestFromGenerateRequest` is the conversion boundary for effective
model defaults, explicit numeric-presence state, rendered messages, structured
output, and session-ID validation. `openAIChatRequestPayload` protects reserved
fields and JSON encoding before an HTTP call. The external payload shape is
defined only in the [integration contract](../integrations/openai-compatible-chat.md).
## Error Categories
The package uses these internal sentinels:
- `ErrInvalidConfig` for invalid client construction;
- `ErrInvalidRequest` for invalid effective generation input;
- `ErrRequestFailed` for request construction or transport failures;
- `ErrUnexpectedStatus` for non-success HTTP responses; and
- `ErrMalformedResponse` for invalid or incomplete successful-response data.
The runner maps an invalid LLM request to its invalid-request category and
other LLM failures to its generation category. Adapters then apply their public
error contracts.
## Package-Local Guarantees
- The default-model fallback happens before wire encoding.
- Per-request timeout handling clones a configured HTTP client when needed; it
does not mutate shared client state.
- Direct API keys take precedence over environment lookup within this client.
- Provider response bodies are discarded for non-success status responses.
- The client does not implement retries, tool calls, or a stateful session
store.
## Verification And Change Recipe
Inspect:
- `internal/llm/openai_compatible_client_test.go`
- `internal/usecase/runner_test.go`
- `internal/adapter/http/handler_test.go`
When changing the client:
1. keep domain-to-wire mapping inside `internal/llm` and preserve the `Client`
interface;
2. test construction, timeout selection, mapping, and error categorization;
3. update the [OpenAI-compatible integration contract](../integrations/openai-compatible-chat.md)
for any observable wire or protocol change; and
4. update [runner internals](runner.md) if the client boundary or structured
output handoff changes.
The [testing policy](../policy/testing.md) owns global test sufficiency.

View File

@@ -1,48 +1,18 @@
# Internal Component Overview # Internal Component Overview
## Purpose This is the complete inventory of Scriptorium's implemented Go components.
The [architecture policy](../policy/architecture.md) owns normative boundaries;
This is the inventory of Scriptorium's implemented components for contributors. public behavior belongs in the linked contracts.
The [architecture policy](../policy/architecture.md) owns normative boundaries
and invariants; public behavior belongs in the linked contracts.
## Public And Command Entrypoints
| Component | Implemented responsibility | References | | Component | Implemented responsibility | References |
| --- | --- | --- | | --- | --- | --- |
| Root package `scriptorium` | Public Go facade that constructs the engine, exposes request/result types and options, and maps internal errors. | [Go package contract](../consumers/pkg-scriptorium.md), [adapter internals](adapters.md) | | `cmd/scriptorium` | Process entrypoint that delegates arguments and streams to the CLI adapter. | [CLI contract](../cli.md), [adapter internals](adapters.md) |
| `cmd/scriptorium` | Process entrypoint that delegates command execution to the CLI adapter. | [CLI contract](../cli.md), [adapter internals](adapters.md) | | `internal/adapter/cli` | Parses commands, resolves application settings, constructs Promptkit engines, maps requests, and owns process output and exit behavior. | [CLI contract](../cli.md), [adapter internals](adapters.md) |
| `internal/adapter/http` | Owns routes, DTOs, strict decoding, limits, Promptkit request/result mapping, public error mapping, and restricted HTTP artifact reading. | [HTTP API](../api.md), [adapter internals](adapters.md), [source internals](sources.md) |
| `internal/config` | Discovers and strictly decodes application configuration and applies built-in and CLI precedence. | [configuration contract](../config.md), [adapter internals](adapters.md) |
| `internal/defaults` | Holds Scriptorium-owned application and HTTP defaults. | [configuration contract](../config.md) |
| `internal/format` | Formats Promptkit prepared-run values for CLI text or JSON output. | [CLI contract](../cli.md), [adapter internals](adapters.md) |
## Adapters, Domain, And Use Case Framework implementation packages are provided by
[Promptkit v0.1.0](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/consumers/pkg-promptkit.md)
| Component | Implemented responsibility | References | and are not part of this repository.
| --- | --- | --- |
| `internal/adapter/cli` | Parses CLI commands, applies application wiring, and handles process input and output. | [CLI contract](../cli.md), [adapter internals](adapters.md) |
| `internal/adapter/http` | Maps HTTP requests and responses to domain operations and maps public errors. | [HTTP API contract](../api.md), [adapter internals](adapters.md) |
| `internal/domain` | Defines core request, result, output-contract, and LLM-boundary types. | [runner internals](runner.md) |
| `internal/usecase` | Implements `Runner` preparation, execution, validation coordination, and the repairer boundary. | [runner internals](runner.md) |
## Configuration And Sources
| Component | Implemented responsibility | References |
| --- | --- | --- |
| `internal/config` | Loads application settings, applies defaults, and applies CLI overrides. | [configuration contract](../config.md), [adapter internals](adapters.md) |
| `internal/defaults` | Holds compile-time default values used when application settings are resolved. | [configuration contract](../config.md) |
| `internal/promptdef` | Loads prompt definitions from filesystem and `fs.FS` sources. | [configuration contract](../config.md), [source internals](sources.md) |
| `internal/profile` | Loads filesystem and `fs.FS` execution profiles and combines profile repositories. | [configuration contract](../config.md), [source internals](sources.md) |
| `internal/profile/builtin` | Provides embedded built-in execution profiles as a repository. | [configuration contract](../config.md), [source internals](sources.md) |
| `internal/filecatalog` | Provides shared YAML discovery and source-root helpers. | [source internals](sources.md) |
| `internal/artifact` | Reads inline and file-backed input artifacts. | [configuration contract](../config.md), [HTTP API contract](../api.md), [source internals](sources.md) |
| `internal/prompt` | Renders prompt templates into messages. | [runner internals](runner.md) |
## Formatting, Validation, And Model Access
| Component | Implemented responsibility | References |
| --- | --- | --- |
| `internal/format` | Formats prepared-run information for CLI output. | [CLI contract](../cli.md), [adapter internals](adapters.md) |
| `internal/validate` | Defines validation interfaces and provides standard filesystem and `fs.FS` schema validation. | [configuration contract](../config.md), [source internals](sources.md), [runner internals](runner.md) |
| `internal/llm` | Defines the provider-neutral LLM client boundary and its OpenAI-compatible implementation. | [OpenAI-compatible integration](../integrations/openai-compatible-chat.md), [LLM internals](llm.md), [runner internals](runner.md) |
Focused internal documents describe the components that have detailed
orchestration, adapter, or source behavior. Package tests live alongside the
implementation and are identified in those focused documents where relevant.

View File

@@ -1,119 +0,0 @@
# Runner Internals
## Purpose
`internal/usecase.Runner` is the prompt-execution orchestrator. It prepares
domain requests, invokes an injected LLM client, validates output, and returns
domain results. Transport parsing, response mapping, and public type conversion
remain outside this package.
The [configuration reference](../config.md) owns prompt, profile, schema, and
runtime-setting definitions. Public error behavior is defined by the
[HTTP API](../api.md) and [Go package](../consumers/pkg-scriptorium.md)
contracts.
## Dependencies And Construction
`Runner` receives these collaborators:
- `promptdef.Repository`;
- `profile.Repository`;
- `artifact.Reader`;
- `prompt.Renderer`;
- `llm.Client`;
- `validate.Validator`; and
- an optional `OutputRepairer`.
`NewRunner` constructs a runner without a repairer. `NewRunnerWithRepairer`
accepts one explicitly. Adapters and the public engine choose concrete
repositories and readers; the runner does not load application configuration.
## Prepare Flow
`Prepare` performs one deterministic preparation pass for a request:
1. validate the prompt ID and load the prompt definition;
2. hash the definition and select the explicit or default profile;
3. load the profile and resolve effective execution settings;
4. validate endpoint, model, and credential availability;
5. resolve the output contract and, for JSON Schema output, load a structured
schema document before model execution;
6. read and hash input artifacts;
7. render messages and the session ID; and
8. return a `PreparedRun` containing the effective state and rendered-prompt
hash.
Execution settings merge defaults, profile values, and a request override.
Numeric override presence is retained so explicit zero values are not confused
with omissions.
## Run And Validation Flow
`Run` creates a run ID and timestamps, then calls `Prepare` rather than
duplicating preparation. It sends the prepared prompt, effective target,
target-presence state, and optional structured-output specification to the LLM
client. It converts the returned content to an output artifact, validates it,
and returns the artifact, validation, hashes, usage, and timing metadata.
A validator can return a content result or an operational error. Content
failures stay in the result; schema loading, compilation, and validator
operational failures are returned as `ErrValidation`. The canonical distinction
for callers is documented by the public contracts.
## Repair Boundary
Repair is an internal optional loop. It starts only when a repairer is present,
the output contract permits one or more attempts, validation failed, and the
validation mode is JSON or JSON Schema. Each repair receives the previous
output, validation errors, effective target, structured-output specification,
and attempt metadata; every repaired result is validated again.
`NewDefaultOutputRepairer` delegates to the injected LLM client. CLI, HTTP, and
the public engine use `NewRunner` and therefore do not inject this repairer.
## Error Translation
Runner sentinels identify failure categories for adapters:
- `ErrInvalidRequest`
- `ErrProfileRequired`
- `ErrAPIKeyEnvMissing` and `ErrAPIKeyRequired`
- `ErrPromptLoad`, `ErrProfileLoad`, and `ErrArtifactLoad`
- `ErrPromptRender`
- `ErrLLMGenerate`
- `ErrValidation`
Wrap errors with those sentinels and preserve their identities through
`errors.Is`; adapters must not classify errors by message text. The runner
passes direct keys only to the LLM boundary and never includes resolved key
values in prepared or run results.
## Package-Local Guarantees
- `Run` always reuses `Prepare`.
- Schema documents are loaded before the initial LLM call when structured output
is required.
- Output validation records attempts used, including repair attempts.
- Runner state is per request; the package does not create a durable run store
or manifest.
- Source, renderer, validator, and LLM implementations remain injected
boundaries.
## Verification And Change Recipe
Inspect:
- `internal/usecase/runner_test.go`
- `internal/usecase/integration_test.go`
- `engine_test.go`
When changing orchestration:
1. identify the collaborator boundary and the affected `Prepare` or `Run` state;
2. preserve the `Run`-through-`Prepare` path and error identity;
3. add focused runner or integration tests for changed state transitions,
validation, or repair behavior; and
4. update the owning external contract and any affected source or LLM internal
document.
The [testing policy](../policy/testing.md) owns global test sufficiency.

View File

@@ -2,96 +2,67 @@
## Purpose ## Purpose
This document describes how source packages load prompt definitions, profiles, This document covers Scriptorium-owned source locations and the restricted HTTP
schemas, and artifacts. The [configuration reference](../config.md) owns their artifact reader. Prompt, profile, schema, and ordinary artifact semantics are
user-facing formats and settings. The [HTTP API reference](../api.md) owns owned by the tagged
HTTP-visible artifact outcomes; [operations](../operations.md) owns deployment [Promptkit format reference](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/formats.md).
handling.
## Prompt Definitions ## Application Source Locations
`internal/promptdef` provides filesystem and `fs.FS` repositories. Both use `internal/config` resolves `prompt_dir`, `profile_dir`, and `schema_dir` from
`internal/filecatalog` for recursive YAML discovery, deterministic ordering, Scriptorium defaults, configuration files, and CLI overrides.
display paths, and root cleaning. `internal/adapter/cli` passes those paths into `promptkit.Config` when
constructing the engine.
Repositories select a prompt by YAML ID and optional version rather than by Scriptorium does not search, parse, validate, or overlay framework source files
path. They decode through strict YAML handling, reject duplicate matching itself. Promptkit owns prompt selection, profile built-ins and overlays, schema
definitions, and resolve `content_file` relative to the definition. The `fs.FS` resolution, ordinary file artifacts, and the related error identities.
implementation resolves content paths inside its source root; absolute paths and
traversal outside that root are rejected before file access.
## Profiles And Built-Ins The [configuration reference](../config.md) owns Scriptorium's source-location
fields and precedence. Maintained files under `examples/` are application
inputs that use Promptkit's tagged formats.
`internal/profile` provides filesystem, `fs.FS`, and overlay repositories. ## Restricted HTTP Artifact Reader
`internal/profile/builtin` exposes embedded assets through the same repository
interface.
An overlay asks its primary source first. It falls back only when the primary `internal/adapter/http` implements `promptkit.ArtifactReader` for HTTP
reports `ErrProfileNotFound`; invalid YAML, duplicate IDs, validation failures, requests. The `serve` path injects it with
and raw-key failures are returned rather than hidden by fallback. This makes a `promptkit.WithArtifactReader`, replacing Promptkit's ordinary reader for
custom ID override a built-in ID while retaining errors in the custom source. inbound HTTP inputs.
The public engine can overlay in-memory profiles ahead of both file-backed and The reader:
built-in repositories. Profile field definitions, validation ranges, and the
built-in catalog remain in the [configuration reference](../config.md).
## Schemas - accepts inline references without an artifact root;
- denies file references when no root is configured;
- resolves relative paths below the configured root;
- accepts absolute paths only when they are lexically within that root;
- rejects lexical traversal outside the root;
- applies the configured file byte limit, with zero meaning unlimited;
- preserves content type, body, size, hash, name, and URI metadata; and
- honors context cancellation.
`internal/validate` supplies `StandardValidator` for filesystem sources and Containment is lexical and does not resolve symlinks. The operating system
`FSValidator` for `fs.FS` sources. Directory-backed validation loads the named follows symlinks after the check. The [HTTP API](../api.md) owns observable
schema path; it does not search directories by basename. `fs.FS` schema paths request outcomes, and [operations](../operations.md) owns safe deployment
are cleaned and checked against their configured root, while a single-file permissions and root selection.
source matches its file base name.
The runner requests a schema document before generation when it needs Reader errors remain identifiable after Promptkit wraps them as artifact-load
structured output. JSON and schema mismatches in generated content are failures, allowing the HTTP adapter to preserve Scriptorium status and error
validation results; source access, decoding, registration, and compilation codes.
failures are operational errors.
## Artifacts
`internal/artifact` composes inline and file readers. The ordinary composite
reader used by CLI and the public engine reads file references from the process
filesystem. The restricted composite reader used by the HTTP adapter combines
inline reading with a rooted file reader and optional byte limit.
The rooted reader cleans paths and applies lexical containment without resolving
symlinks. It checks relative references against the configured root and accepts
absolute references only when they remain inside that lexical root. The OS still
follows symlinks after that check. The public containment outcome is documented
by the [HTTP API reference](../api.md); deployment permissions belong in
[operations](../operations.md).
## Failure Boundaries
Source packages report repository, decoding, duplicate, validation, and read
failures to their callers. They do not select public status codes or response
schemas. The runner wraps source failures with use-case categories; adapters map
them to their own external contract.
Source reads use current filesystem or `fs.FS` content for each request. These
packages create no manifests, checkpoints, or durable run state.
## Verification And Change Recipe ## Verification And Change Recipe
Inspect: Inspect:
- `internal/promptdef/repository_test.go` - `internal/config/config_test.go`
- `internal/profile/repository_test.go` - `internal/adapter/cli/run_test.go`
- `internal/profile/builtin/repository_test.go` - `internal/adapter/http/artifact_reader_test.go`
- `internal/artifact/reader_test.go` - `internal/adapter/http/handler_test.go`
- `internal/validate/standard_validator_test.go`
- `internal/usecase/integration_test.go`
- `engine_test.go`
When updating prompt, profile, schema, or built-in assets: When changing an application source location or HTTP artifact policy:
1. keep assets valid for the strict loader and the relevant source boundary; 1. preserve strict configuration precedence and the Promptkit public boundary;
2. update the [configuration reference](../config.md) when a file-format, 2. keep containment and size policy in Scriptorium;
catalog, or default changes; 3. update focused configuration, reader, and handler tests;
3. run focused source and integration tests, including the built-in repository 4. update the [configuration](../config.md), [HTTP](../api.md), and
test when embedded assets change; and [operations](../operations.md) contracts as applicable; and
4. update this document when discovery, precedence, containment, or failure 5. do not duplicate Promptkit loaders, formats, or ordinary artifact behavior.
mechanics change.
The [testing policy](../policy/testing.md) owns global test sufficiency.

View File

@@ -7,8 +7,11 @@ recovery for Scriptorium. It does not redefine invocation syntax, configuration
fields, or HTTP wire behavior. fields, or HTTP wire behavior.
- [CLI reference](cli.md): commands, output destinations, and exit codes. - [CLI reference](cli.md): commands, output destinations, and exit codes.
- [Configuration reference](config.md): configuration, prompt/profile/schema - [Configuration reference](config.md): application settings, source
formats, defaults, and credentials. locations, defaults, and credential mapping.
- [Promptkit framework formats](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/formats.md):
prompt, profile, schema, execution-setting, and framework credential
contracts.
- [HTTP API reference](api.md): route, request/response schema, status codes, - [HTTP API reference](api.md): route, request/response schema, status codes,
limits, and HTTP artifact access. limits, and HTTP artifact access.
- [Consumer integration overview](consumers/api.md): caller responsibilities. - [Consumer integration overview](consumers/api.md): caller responsibilities.
@@ -26,10 +29,10 @@ responsibilities.
## Deploy The Filesystem And Process ## Deploy The Filesystem And Process
Provide the process with readable prompt, profile, and schema sources. Keep Provide the process with readable configured Promptkit prompt, profile, and
prompt templates adjacent to the prompt definitions that reference them. For an schema sources that follow the tagged framework formats. For an HTTP deployment
HTTP deployment that accepts file artifacts, use a dedicated, narrow artifact that accepts file artifacts, use a dedicated, narrow artifact directory rather
directory rather than a general-purpose or sensitive filesystem tree. than a general-purpose or sensitive filesystem tree.
Run Scriptorium under an identity that can: Run Scriptorium under an identity that can:

View File

@@ -1,76 +1,102 @@
# Architecture # Architecture
This document is the development architecture policy for Scriptorium. This document defines Scriptorium's current application architecture and
durable development boundaries.
It is for developers and LLM coding agents. User-facing behavior belongs in `README.md` and the docs under `docs/` that target operators/users.
## System Shape ## System Shape
Scriptorium is a narrow prompt-execution application with three executable Scriptorium is an executable application with three entry paths: CLI `run`, CLI
entry paths: CLI `run`, CLI `render`, and the HTTP service started by `serve`. `render`, and the HTTP service started by `serve`. It does not expose a reusable
It also provides a public Go package for in-process use. Its current component root Go package.
inventory is maintained in the [internal overview](../internal/overview.md).
Domain behavior is centralized in `internal/usecase` and `internal/domain`. The application consumes
[Promptkit v0.1.0](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/consumers/pkg-promptkit.md)
through its supported root package. Promptkit owns prompt execution,
preparation, source formats, built-in profiles, model-client behavior, and
validation. Scriptorium owns application configuration, executable adapters,
prepared-run presentation, process behavior, and HTTP deployment policy.
## Core Principles The concrete package inventory is maintained in the
[internal overview](../internal/overview.md).
- Keep orchestration narrow: Scriptorium executes one prompt request; it is not a multi-step workflow engine.
- Keep adapter logic thin: adapters map external shapes to domain requests/results and should not hold domain decisions.
- Keep boundaries explicit: repositories/loaders/renderers/validators/LLM client stay behind package interfaces.
- Keep external decoding strict: configuration, prompt, and profile YAML and
HTTP JSON should reject unknown fields.
- Keep secrets out of payloads: raw API key values must not be accepted or emitted.
## Dependency Direction ## Dependency Direction
- Adapters translate external shapes and IO concerns; they do not make ```text
use-case decisions. cmd/scriptorium
- Use-case and domain code depend on explicit repository, renderer, validator, |
and LLM interfaces rather than adapter implementations. v
- Source, rendering, validation, and LLM implementations remain behind their CLI and HTTP adapters, configuration, defaults, and formatting
package boundaries. |
- Dependency-specific types must not leak across unrelated package boundaries. v
- Prefer the standard library; add an external dependency only when it gitea.maximumdirect.net/eric/promptkit
materially reduces risk or complexity. ```
## State And Persistence Policy - Retained application packages may import Promptkit's root package.
- They must not import Promptkit `internal` packages.
- They must not import the removed Scriptorium root facade or recreate former
framework package families.
- Adapter-owned interfaces use Promptkit public values when a consumer-side
substitution boundary is needed.
- Scriptorium passes omitted framework settings as zero values so Promptkit
applies its own defaults.
Scriptorium has no durable run-state store. The repository architecture guard enforces these import and removal
invariants.
- No built-in resume/checkpoint/archive behavior. ## Retained Boundaries
- Recovery model is rerun after correcting inputs/config/environment.
## Contract Ownership - `internal/adapter/cli` owns commands, flags, configuration precedence,
process streams, output files, summaries, and exit codes.
- `internal/adapter/http` owns routes, strict JSON DTOs, size limits, response
mapping, status mapping, and the restricted artifact reader.
- `internal/config` owns discovery and strict decoding of Scriptorium
application configuration.
- `internal/defaults` owns Scriptorium application and HTTP defaults only.
- `internal/format` owns deterministic prepared-run text and JSON presentation.
- Promptkit owns framework orchestration and contracts. Its
[format reference](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/formats.md)
and
[outbound integration contract](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/integrations/openai-compatible-chat.md)
are canonical.
The [CLI](../cli.md), [configuration](../config.md), [HTTP API](../api.md), ## HTTP Artifact Security Boundary
[public Go package](../consumers/pkg-scriptorium.md), and
[integration](../integrations/) documents own their respective external
contracts. This policy keeps only the architectural boundaries that govern
their implementation.
## Error Handling And Logging Ordinary CLI file loading is provided by Promptkit. Scriptorium's HTTP adapter
injects a restricted `promptkit.ArtifactReader` for inbound HTTP requests.
That reader denies file references without an artifact root, enforces the
configured byte limit, and applies Scriptorium's lexical root-containment rule.
The operating system still follows symlinks after the lexical check.
- Wrap errors with domain/operation context. The [HTTP API](../api.md) owns observable request outcomes, and
- Map domain errors to adapter-appropriate statuses/codes without leaking sensitive internals. [operations](../operations.md) owns deployment permissions and root selection.
- Never emit raw secret values.
## Testing And Documentation ## State, Errors, And Secrets
Testing philosophy and change-validation expectations are defined by the Scriptorium has no durable run-state store, checkpoint, cache, or resume
[testing policy](testing.md). Documentation ownership and maintenance rules are mechanism. Recovery is a new request after correcting inputs, configuration, or
defined by the [documentation policy](documentation.md). environment.
Adapters map Promptkit public error identities into CLI exits or HTTP statuses
without classifying by message text. Raw API keys are not accepted in
Scriptorium configuration, CLI arguments, or HTTP payloads, and resolved
secrets must not be emitted.
## Architectural Invariants ## Architectural Invariants
- `Runner.Run` reuses `Runner.Prepare` flow. - External YAML and JSON decoding remains strict.
- Raw API key values must not be accepted through external configuration or - CLI and HTTP behavior remains presentation and transport logic rather than
request payloads, and resolved secret values must not be emitted. framework orchestration.
- Explicit numeric request overrides preserve presence, including zero.
- HTTP artifact containment and byte limits remain Scriptorium policy.
- No application package depends on Promptkit implementation packages.
## Non-Goals ## Non-Goals
- Do not move orchestration responsibilities from external callers into Scriptorium. - Do not recreate an in-process Scriptorium framework API or compatibility
- Do not add adapter-specific business logic in `internal/adapter/*` packages. facade.
- Do not bypass repository/renderer/validator/LLM boundaries by introducing cross-package coupling. - Do not copy Promptkit types, defaults, built-in profiles, or implementation
into Scriptorium.
- Do not move CLI, inbound HTTP, process, or deployment policy into Promptkit.
- Do not add durable workflow, archive, or resume behavior.
Work that is not implemented belongs in `docs/roadmap/`. Work that is not implemented belongs in `docs/roadmap/`.

View File

@@ -69,11 +69,13 @@ secret values.
| Documentation organization | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and ADR/document lifecycle. | Application architecture or product behavior. | | Documentation organization | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and ADR/document lifecycle. | Application architecture or product behavior. |
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, test boundaries, doubles, coverage guidance, regression-test policy, and criteria for adding, rewriting, or deleting tests. | Subsystem behavior, application contracts, subsystem-specific test inventories, and implementation plans. | | Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, test boundaries, doubles, coverage guidance, regression-test policy, and criteria for adding, rewriting, or deleting tests. | Subsystem behavior, application contracts, subsystem-specific test inventories, and implementation plans. |
| CLI contract | `docs/cli.md` | Commands, arguments, flags, invocation semantics, and exit codes. | End-to-end operating procedures, configuration field definitions, runtime filesystem layout, module implementation details. | | CLI contract | `docs/cli.md` | Commands, arguments, flags, invocation semantics, and exit codes. | End-to-end operating procedures, configuration field definitions, runtime filesystem layout, module implementation details. |
| Configuration contract | `docs/config.md` | Discovery and precedence, file schema, fields, defaults, environment overrides, validation rules, and user-selectable module or validator keys. | Complete example files, CLI syntax, runtime state lifecycle, module implementation details. | | Configuration contract | `docs/config.md` | Application discovery and precedence, source locations, server fields, render default, HTTP limits, and credential mapping. | Promptkit framework formats and defaults, complete example files, CLI syntax, runtime lifecycle, and implementation detail. |
| Operations | `docs/operations.md` | Runtime workflows, physical filesystem and state layout, output, cache, and debug handling, resume, cleanup, permissions, recovery, and operational limits. | CLI flag syntax, configuration field definitions, logical output schemas, implementation mechanics. | | Operations | `docs/operations.md` | Runtime workflows, physical filesystem and state layout, output, cache, and debug handling, resume, cleanup, permissions, recovery, and operational limits. | CLI flag syntax, configuration field definitions, logical output schemas, implementation mechanics. |
| Release procedure | `docs/release.md` | Candidate validation, version and tag operations, hosted-workflow observation, and published-artifact verification. | Runtime operations, version-specific announcements, and complete application-interface contracts. |
| Version-specific release notes | `docs/releases/` | Immutable release summaries, compatibility notices, and migration announcements for one published version. | Complete CLI, HTTP, configuration, operations, or dependency contracts. |
| Public HTTP contract | `docs/api.md` | Routes, authentication, media types, request and response schemas, status codes, pagination, caching, idempotency, rate limits, and HTTP retry semantics. | Client walkthroughs, upstream or downstream integration internals, implementation detail. | | Public HTTP contract | `docs/api.md` | Routes, authentication, media types, request and response schemas, status codes, pagination, caching, idempotency, rate limits, and HTTP retry semantics. | Client walkthroughs, upstream or downstream integration internals, implementation detail. |
| Consumer guidance | `docs/consumers/` | Task-oriented use of the public interface, minimal client examples, and consumer responsibilities. | HTTP wire semantics, external protocol contracts, internal implementation detail. | | Consumer guidance | `docs/consumers/` | Choosing between Scriptorium's executable interfaces and understanding consumer responsibilities. | HTTP wire semantics, CLI syntax, Promptkit's Go package, and internal implementation detail. |
| External and durable integration contracts | `docs/integrations/` | External file formats and protocols, upstream and downstream contracts, logical output bundle paths and schemas, media types, and compatibility behavior. | Physical runtime placement and lifecycle, internal transformations, CLI syntax, configuration defaults. | | External and durable integration contracts | `docs/integrations/` | Scriptorium-owned process and executable integration contracts. | Promptkit framework formats and outbound provider protocols, physical runtime placement, internal transformations, CLI syntax, and configuration defaults. |
| Implemented component inventory | `docs/internal/overview.md` | Current packages and components, their implemented responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. | | Implemented component inventory | `docs/internal/overview.md` | Current packages and components, their implemented responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
| Internal component behavior | Other files under `docs/internal/` | Implementation flow, internal collaborators and state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. | | Internal component behavior | Other files under `docs/internal/` | Implementation flow, internal collaborators and state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
| Architectural decision history | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. | | Architectural decision history | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. |

View File

@@ -77,7 +77,7 @@ Test through the narrowest stable boundary that expresses the behavior clearly.
This is often the package API, but it may instead be: This is often the package API, but it may instead be:
- a smaller pure function when dense domain logic is most clearly isolated there; - a smaller pure function when dense application logic is most clearly isolated there;
- a package-level operation when several internal collaborators jointly produce the behavior; or - a package-level operation when several internal collaborators jointly produce the behavior; or
- a larger integration boundary when correctness emerges from interaction with a real dependency. - a larger integration boundary when correctness emerges from interaction with a real dependency.
@@ -162,13 +162,16 @@ Use a test-controlled limit and measure the behavior relative to that limit. Do
Each behavior should have a clear test owner. Each behavior should have a clear test owner.
- Parser tests own parsing cases. - Configuration tests own application YAML, discovery, precedence, and
- Validator tests own validation rules. application defaults.
- Domain tests own transformations and invariants. - CLI tests own argument mapping, streams, summaries, exit behavior, and
- Adapter tests own external integration behavior. representative command workflows.
- Orchestrator tests own coordination and failure propagation. - HTTP tests own DTOs, strict decoding, limits, status mapping, and restricted
- CLI tests own argument and configuration mapping. artifact policy.
- End-to-end tests prove that representative assembled workflows work. - Formatter tests own prepared-run text and JSON presentation.
- Architecture tests own dependency direction and removal invariants.
- Promptkit owns framework parsing, orchestration, validation, profiles, and
model-client behavior.
Higher-level tests should not repeat every lower-level case. A single intentional policy change should not require unrelated edits across many test files. Higher-level tests should not repeat every lower-level case. A single intentional policy change should not require unrelated edits across many test files.
@@ -221,7 +224,9 @@ Coverage is a diagnostic, not a target.
Use it to find untested critical branches and unexpectedly weak packages. Do not write low-value tests solely to increase a percentage, and do not infer test quality from coverage alone. Use it to find untested critical branches and unexpectedly weak packages. Do not write low-value tests solely to increase a percentage, and do not infer test quality from coverage alone.
Pure domain logic will often warrant higher coverage than CLI wiring or external adapters. Uneven coverage is acceptable when it reflects risk. Security-sensitive HTTP containment and external mappings may warrant denser
coverage than straightforward process wiring. Uneven coverage is acceptable
when it reflects risk.
Increasing coverage is valuable only when the newly covered behavior protects a meaningful risk at an acceptable cost. Increasing coverage is valuable only when the newly covered behavior protects a meaningful risk at an acceptable cost.

376
docs/release.md Normal file
View File

@@ -0,0 +1,376 @@
# Release Procedure
## Release Model And Status
Scriptorium publishes annotated semantic tags and tag-triggered Linux binary
releases. The hosted
[release workflow](../.woodpecker/release.yml) builds `amd64` and `arm64`
executables, publishes their SHA-256 checksums, and uses the matching file
under `docs/releases/` as the hosted release body.
`v0.12.0` is the first published application-only release. For each later
release, select a new `vMAJOR.MINOR.PATCH` version according to the intended
compatibility change. A selected version remains an unreleased candidate until
its annotated tag is published, the hosted workflow succeeds, and every
published artifact is verified.
Run this procedure from the Scriptorium repository root. A release must not
depend on a Go workspace, module replacement, vendor tree, sibling checkout,
unpublished dependency, or unpushed source commit.
## Establish The Candidate
Start a POSIX shell, choose a semantic version that has not been published, and
export it as `RELEASE_VERSION`. For example, if `v0.12.1` is the intended next
version and remains unpublished, select:
```sh
export RELEASE_VERSION=v0.12.1
```
Use the version appropriate to the actual compatibility change rather than
assuming that the example is the next release. Then run the following guard in
that same shell:
```sh
set -eu
: "${RELEASE_VERSION:?export an unpublished vMAJOR.MINOR.PATCH version}"
if ! printf '%s\n' "$RELEASE_VERSION" |
grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
then
printf '%s\n' "invalid release version: $RELEASE_VERSION" >&2
exit 1
fi
RELEASE_COMMIT=$(git rev-parse --verify 'HEAD^{commit}')
export RELEASE_COMMIT
check_release_candidate() {
test "$(git branch --show-current)" = main
test -z "$(git status --porcelain)"
gowork_value=$(go env GOWORK)
case "$gowork_value" in
''|off) ;;
*)
printf '%s\n' "active Go workspace: $gowork_value" >&2
return 1
;;
esac
test -z "$(git ls-files go.work go.work.sum)"
test ! -e vendor
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
then
printf '%s\n' 'go.mod contains a replacement' >&2
return 1
fi
git fetch origin main --tags
test "$RELEASE_COMMIT" = \
"$(git rev-parse --verify 'refs/remotes/origin/main^{commit}')"
if git show-ref --verify --quiet "refs/tags/$RELEASE_VERSION"
then
printf '%s\n' "local tag already exists: $RELEASE_VERSION" >&2
return 1
fi
if test -n "$(
git ls-remote --tags origin \
"refs/tags/$RELEASE_VERSION" \
"refs/tags/$RELEASE_VERSION^{}"
)"
then
printf '%s\n' "remote tag already exists: $RELEASE_VERSION" >&2
return 1
fi
}
check_release_candidate
```
Do not continue unless this guard succeeds. It deliberately requires the
candidate to be the exact clean commit already published at `origin/main`.
## Verify Modules And Repository Boundaries
Confirm the module path and declared Go version:
```sh
test "$(
GOWORK=off go list -m -f '{{.Path}} {{.GoVersion}}'
)" = 'gitea.maximumdirect.net/eric/scriptorium 1.25.5'
```
Require Promptkit `v0.1.0` as both the direct module-graph edge and the selected
module version:
```sh
direct_promptkit=$(
GOWORK=off go mod graph |
awk '
$1 == "gitea.maximumdirect.net/eric/scriptorium" &&
$2 ~ /^gitea\.maximumdirect\.net\/eric\/promptkit@/ {
print $2
}
'
)
test "$direct_promptkit" = \
'gitea.maximumdirect.net/eric/promptkit@v0.1.0'
test "$(
GOWORK=off go list -m -f '{{.Path}}@{{.Version}}' \
gitea.maximumdirect.net/eric/promptkit
)" = 'gitea.maximumdirect.net/eric/promptkit@v0.1.0'
GOWORK=off go list -m all
```
Require tidy module metadata and recheck the repository exclusions:
```sh
GOWORK=off go mod tidy -diff
test -z "$(git ls-files go.work go.work.sum)"
test ! -e vendor
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
then
printf '%s\n' 'go.mod contains a replacement' >&2
exit 1
fi
test -z "$(git status --porcelain)"
```
## Validate The Application
Run the complete application validation:
```sh
GOWORK=off go test ./...
GOWORK=off go test -race ./...
GOWORK=off go vet ./...
validation_build_dir=$(mktemp -d)
GOWORK=off go build \
-o "$validation_build_dir/scriptorium" \
./cmd/scriptorium
```
The ordinary test run includes the architecture guard that rejects a root Go
package, former framework package families, and imports of Promptkit internal
packages. Inspect the repository for generated binaries, credentials,
temporary output, sibling paths, and other files that do not belong in the
tracked release source.
Check every tracked Go file. This command must produce no output:
```sh
unformatted=$(
git ls-files '*.go' |
while IFS= read -r go_file
do
gofmt -l "$go_file"
done
)
test -z "$unformatted"
```
Run the maintained render script and smoke-test both maintained configuration
examples without a model call:
```sh
GOWORK=off ./examples/render-markdown-summary.sh
for config_file in examples/config.yml examples/config.full.yml
do
GOWORK=off go run ./cmd/scriptorium render \
--config "$config_file" \
--prompt generic.markdown_summary \
--input transcript=./examples/fixtures/transcript.md \
--input glossary=./examples/fixtures/glossary.yml \
--format json >/dev/null
done
```
Exercise usage output and offline rendering with the temporary native
executable:
```sh
usage_output="$validation_build_dir/usage.txt"
if "$validation_build_dir/scriptorium" >"$usage_output" 2>&1
then
printf '%s\n' 'expected an invocation without a command to fail' >&2
exit 1
fi
grep -F 'usage: scriptorium' "$usage_output"
"$validation_build_dir/scriptorium" render \
--config ./examples/config.yml \
--prompt generic.markdown_summary \
--input transcript=./examples/fixtures/transcript.md \
--input glossary=./examples/fixtures/glossary.yml \
--format text >/dev/null
```
Follow every maintained local, Promptkit-tagged, and other external Markdown
link. Confirm that all repository-relative link targets exist. Finish the
application checks with:
```sh
git diff --check
test -z "$(git status --porcelain)"
```
## Require Release Notes And Reproduce Packaging
The immutable release note must exist before tagging:
```sh
release_notes="docs/releases/$RELEASE_VERSION.md"
test -f "$release_notes"
test -s "$release_notes"
```
Validate every local and currently published link in the note. For links
pinned to the candidate Scriptorium tag, confirm that the corresponding
repository-relative path exists even though its tag URL is not live yet.
Reproduce the hosted build flags, targets, and filenames in a temporary
directory:
```sh
release_dist=$(mktemp -d)
release_package='gitea.maximumdirect.net/eric/scriptorium/cmd/scriptorium'
build_release_binary() {
target_os="$1"
target_arch="$2"
output="$release_dist/scriptorium-$RELEASE_VERSION-$target_os-$target_arch"
CGO_ENABLED=0 GOOS="$target_os" GOARCH="$target_arch" GOWORK=off \
go build -trimpath -ldflags '-s -w' \
-o "$output" "$release_package"
}
build_release_binary linux amd64
build_release_binary linux arm64
test -s "$release_dist/scriptorium-$RELEASE_VERSION-linux-amd64"
test -s "$release_dist/scriptorium-$RELEASE_VERSION-linux-arm64"
file "$release_dist/scriptorium-$RELEASE_VERSION-linux-amd64"
file "$release_dist/scriptorium-$RELEASE_VERSION-linux-arm64"
```
Require `file` to identify Linux executables for `x86-64` and `ARM aarch64`,
respectively. Inspect the
[hosted workflow](../.woodpecker/release.yml) and confirm that it uses the
same build flags and names, copies the selected release note to
`dist/RELEASE_NOTES.md`, publishes only `dist/scriptorium-*`, and keeps
checksum generation enabled.
## Create And Publish The Tag
Run the candidate guard again immediately before creating the tag:
```sh
check_release_candidate
test -f "$release_notes"
test -s "$release_notes"
```
Create an annotated tag explicitly bound to the validated commit, using the
version-specific release note as its message:
```sh
git tag --annotate "$RELEASE_VERSION" \
--file "$release_notes" \
"$RELEASE_COMMIT"
```
Inspect the tag and require it to resolve to the validated source:
```sh
test "$(git cat-file -t "refs/tags/$RELEASE_VERSION")" = tag
git show --no-patch --decorate "refs/tags/$RELEASE_VERSION"
test "$(
git rev-parse --verify "refs/tags/$RELEASE_VERSION^{commit}"
)" = "$RELEASE_COMMIT"
```
If inspection finds an error, delete only the unpublished local tag, correct
the candidate, and repeat the complete validation. Never move or recreate a
published tag.
Push only the selected tag ref:
```sh
git push origin \
"refs/tags/$RELEASE_VERSION:refs/tags/$RELEASE_VERSION"
```
## Observe And Verify Publication
Open the hosted workflow run for the selected tag. Require
`build-release-assets` to succeed before `publish-release`, then require the
publication step and hosted release to succeed. A queued, running, failed, or
partially published workflow is not a verified release.
Compare the local and remote annotated-tag objects and their source commits:
```sh
remote_tag=$(
git ls-remote --tags origin "refs/tags/$RELEASE_VERSION" |
awk 'NR == 1 { print $1 }'
)
remote_commit=$(
git ls-remote --tags origin "refs/tags/$RELEASE_VERSION^{}" |
awk 'NR == 1 { print $1 }'
)
test -n "$remote_tag"
test "$remote_tag" = \
"$(git rev-parse --verify "refs/tags/$RELEASE_VERSION")"
test "$remote_commit" = "$RELEASE_COMMIT"
```
Download the hosted binaries and checksum file into a temporary directory:
```sh
release_base="https://gitea.maximumdirect.net/eric/scriptorium/releases/download/$RELEASE_VERSION"
download_dir=$(mktemp -d)
(
cd "$download_dir"
for asset in \
"scriptorium-$RELEASE_VERSION-linux-amd64" \
"scriptorium-$RELEASE_VERSION-linux-arm64" \
SHA256SUMS
do
curl --fail --location --remote-name "$release_base/$asset"
done
test -s "scriptorium-$RELEASE_VERSION-linux-amd64"
test -s "scriptorium-$RELEASE_VERSION-linux-arm64"
test -s SHA256SUMS
sha256sum --check SHA256SUMS
test "$(wc -l < SHA256SUMS | tr -d ' ')" = 2
file "scriptorium-$RELEASE_VERSION-linux-amd64"
file "scriptorium-$RELEASE_VERSION-linux-arm64"
)
```
Require the same Linux architectures observed in the local packaging check and
confirm that the hosted release contains no unexpected asset. On a compatible
Linux host, make the matching downloaded binary executable and repeat the
usage-output and offline-render smoke checks against it.
Only after the tag, workflow, release body, binaries, architectures, and
checksums all pass verification is the candidate a verified published release.
## Handle Failures
Before tag publication, correct the release commit or note and restart the
complete procedure. After tag publication, never delete, move, overwrite, or
recreate the tag. A transient hosted failure may be retried only against the
same immutable tag and commit and only when doing so cannot overwrite or
silently retain partial assets. A source, packaging, note, or artifact defect
requires a new corrective semantic version from a new validated commit.
Record the selected version, validated commit, tag object, workflow result,
artifact names, checksum result, and smoke-check outcome in the release
checkpoint. Keep temporary builds and downloaded assets outside the repository,
and require a clean `main` synchronized with `origin/main` when verification
is complete.

36
docs/releases/v0.12.0.md Normal file
View File

@@ -0,0 +1,36 @@
# Scriptorium v0.12.0
## Breaking Project Boundary
Scriptorium is now an executable-only CLI and HTTP application. This is a
breaking change for Go consumers: the former root Go package is not included,
and no compatibility facade is provided.
Scriptorium `v0.11.1` was the final framework-bearing release. Former Go
consumers should follow the
[migration guide](https://gitea.maximumdirect.net/eric/scriptorium/src/tag/v0.12.0/docs/consumers/migrating-to-promptkit.md)
and adopt
[Promptkit `v0.1.0`](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/consumers/pkg-promptkit.md)
for in-process prompt preparation and execution.
## Application Interfaces
The Scriptorium command-line and HTTP application interfaces remain. Their
canonical documentation defines the supported commands, configuration,
requests, responses, operational behavior, and deployment responsibilities:
- [CLI reference](https://gitea.maximumdirect.net/eric/scriptorium/src/tag/v0.12.0/docs/cli.md)
- [HTTP API reference](https://gitea.maximumdirect.net/eric/scriptorium/src/tag/v0.12.0/docs/api.md)
- [Configuration reference](https://gitea.maximumdirect.net/eric/scriptorium/src/tag/v0.12.0/docs/config.md)
- [Operations guide](https://gitea.maximumdirect.net/eric/scriptorium/src/tag/v0.12.0/docs/operations.md)
## Framework Dependency And Consumers
The released Scriptorium binaries use Promptkit `v0.1.0` as their framework
dependency. Promptkit owns the reusable engine, source formats, profiles,
generation boundary, and validation contracts. See the
[Promptkit Go consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/consumers/pkg-promptkit.md)
for that supported API.
All known downstream Go consumers were migrated to Promptkit before this
release.

View File

@@ -1,141 +0,0 @@
# Documentation Compliance Roadmap
## Status
The initial documentation refresh and its follow-up compliance work were
completed on 2026-07-26. The documentation prerequisite for Step 2 of the
[Promptkit migration roadmap](migration.md) is satisfied.
## Objective
Maintain an accurate, concise documentation baseline under the canonical
ownership policy, and close the remaining review findings before architectural
migration begins.
## Completed Refresh
The initial refresh:
- established the documentation and ADR policies and recorded
[ADR 0001](../adr/0001-adopt-canonical-documentation-ownership.md);
- made `docs/development.md` the contributor entry point and added a canonical
internal component overview;
- refocused architecture on normative boundaries and moved implementation
detail into focused internal documents;
- consolidated CLI, configuration, HTTP, public Go, and integration contracts
under distinct canonical owners;
- rewrote operations as a runbook, incorporated useful recovery guidance, and
removed the standalone troubleshooting document;
- normalized maintained examples and added an offline HTTP request-example
check;
- corrected the factual discrepancies found by the original audit, including
the entry-path count, built-in profile catalog, prompt output optionality,
input-map behavior, validation diagnostics, session-ID units, and CLI flag
descriptions; and
- validated local links, configuration examples, smoke examples, tests, vet,
and the documented build.
## Follow-Up Findings
The post-completion review found:
1. The effective precedence between `Config.Timeout` and a supplied
`HTTPClient.Timeout` is not consistently expressed by the implementation,
tests, and documentation.
2. The public Go contract overgeneralizes basename selection for single-file
sources; prompt and profile files remain selected by YAML ID, while
`WithSchemaFile` uses the schema file's base name.
3. The maintained HTTP request-example test pins incidental fixture values
beyond the structural contract it needs to protect.
4. The Promptkit migration roadmap's overall status says no work is implemented
even though its documentation-refresh step is complete.
5. One architecture invariant incorrectly describes configuration, prompt, and
profile inputs as JSON rather than YAML.
## Follow-Up Implementation Sequence
### Stage 1: Resolve The Timeout Contract
1. Decide and document the precedence among:
- `Config.Timeout`;
- a non-zero timeout on a supplied `http.Client`; and
- an explicit per-request `timeout_seconds` override.
2. Align `internal/llm` with that decision, removing any constructor state that
is immediately discarded during generation.
3. Add or revise a behavior-level test that performs `Generate` with a supplied
HTTP client and proves the selected timeout without relying only on private
constructor state.
4. Update the canonical public Go contract, outbound OpenAI-compatible
integration contract, and internal LLM document together.
**Gate:** There is one documented timeout-precedence rule, the implementation
follows it, and a request-level test protects it.
### Stage 2: Correct The Remaining Contract And Architecture Text
1. Clarify in the public Go contract that:
- prompt and profile single-file sources are selected by their YAML IDs;
- `WithPromptFile` resolves relative `content_file` paths from the prompt
file's directory; and
- `WithSchemaFile` exposes the schema through its base name.
2. Correct or remove the duplicate architecture invariant so it accurately
distinguishes strict YAML configuration, prompt, and profile inputs from
strict JSON HTTP input.
3. Recheck the affected statements against the public facade and source
implementations without duplicating file-format definitions outside the
configuration contract.
**Gate:** Public source-option behavior and strict-decoding invariants agree
with the implementation and retain one canonical owner.
### Stage 3: Refine The Maintained HTTP Example Check
1. Preserve an offline check that `examples/http-run.json` is accepted by the
HTTP request contract.
2. Remove assertions that freeze prompt IDs, profile IDs, fixture paths, or
example variable values unless a specific value protects an independently
identified contract.
3. Rely on focused handler tests, rather than the maintained-example check, for
exhaustive DTO-to-domain mapping behavior.
4. Run the affected HTTP adapter tests and confirm that the revised test still
fails for structurally invalid example content.
**Gate:** The maintained example has durable structural protection without
duplicating mapping coverage or making legitimate fixture edits unnecessarily
expensive.
### Stage 4: Reconcile Status And Revalidate The Baseline
1. Update the Promptkit migration roadmap's overall status to distinguish the
completed documentation step from the unimplemented migration steps.
2. Recheck local Markdown links and paths.
3. Verify both maintained configuration files through the real configuration
loader.
4. Run the maintained render script and Go package example.
5. Run `go test ./...`, `go vet ./...`, and `go build ./cmd/scriptorium`.
6. Record completion of these follow-up stages here and confirm the
documentation gate in the Promptkit migration roadmap.
**Gate:** Code, tests, examples, canonical contracts, architecture, and both
roadmap status documents agree. Promptkit migration Step 2 may then begin.
## Completion Criteria
This follow-up is complete when:
- timeout precedence is unambiguous, implemented, and protected by an
execution-level test;
- public single-file source behavior is accurately documented;
- strict YAML and JSON boundaries are accurately stated;
- the HTTP example check protects structure without pinning incidental content;
- roadmap status accurately distinguishes completed and pending migration work;
and
- links, examples, tests, vet, and build validation pass.
## Completion Record
Completed on 2026-07-26. The timeout contract, single-file source contract,
strict-decoding boundary, and maintained HTTP example check were reconciled.
Final validation confirmed local links and paths, both maintained configuration
files through the real loader, the maintained render and Go package examples,
`go test ./...`, `go vet ./...`, and `go build ./cmd/scriptorium`.

View File

@@ -1,296 +0,0 @@
# Promptkit Migration Roadmap
## Status
Accepted plan. Step 1, the documentation refresh, is complete. Steps 2 through
9 remain proposed and are not yet implemented.
## Objective
Split the current repository into two projects:
- **Promptkit**: the reusable Go framework, public Go facade, execution engine,
source and validation support, OpenAI-compatible client, extension
interfaces, and built-in execution-profile registry.
- **Scriptorium**: a slim runnable application that imports Promptkit and
provides the CLI and HTTP interfaces.
Scriptorium will become another downstream Promptkit consumer rather than the
owner of the framework.
## Compatibility And Migration Policy
This is an intentionally breaking change.
- New and migrated Go consumers must import Promptkit instead of Scriptorium.
- Scriptorium will not retain type aliases, forwarding packages, deprecated
facade APIs, or other source-compatibility shims.
- Existing consumers may continue using a previously tagged Scriptorium module
version until they are migrated.
- The migration does not need to preserve compatibility between intermediate
development states. Each completed phase must instead leave the affected
repository internally consistent and tested.
- Promptkit should initially preserve the useful shape and behavior of the
current public Go facade where doing so reduces extraction risk. Broader API
redesign should follow the split unless required to establish the new
boundary.
## Target Ownership
Promptkit should own application-neutral framework behavior:
- public engine, request, result, option, extension, and error APIs;
- prompt-definition loading and rendering;
- execution profiles, overlays, and the built-in profile registry;
- artifact-loading interfaces and general-purpose `file` and `inline` support;
- schema loading and output validation;
- LLM client boundaries and the OpenAI-compatible implementation;
- preparation and execution orchestration;
- framework and execution defaults.
Scriptorium should own executable and transport concerns:
- the `scriptorium` command and its `run`, `render`, and `serve` interfaces;
- CLI parsing, output formatting, exit codes, and process behavior;
- application-config discovery and CLI precedence;
- HTTP routing, request and response DTOs, limits, and error/status mapping;
- HTTP artifact-root and deployment security policy;
- server and adapter defaults;
- executable examples, operations guidance, and transport documentation.
The intended dependency direction is:
```text
Scriptorium CLI and HTTP adapters
|
v
Promptkit
|
v
consumer-supplied sources and clients
```
Scriptorium must use Promptkit's public API. It must not depend on Promptkit
implementation packages or reproduce framework orchestration.
## Migration Steps
### Step 1: Refresh And Synchronize Documentation
Perform a repository-wide documentation refresh before migration development.
At minimum:
- reconcile all current-behavior documentation with the code, tests, examples,
defaults, and current public contracts;
- introduce the planned documentation-policy updates;
- establish an architecture decision record policy and canonical ADR location;
- resolve stale, duplicated, or misplaced material;
- validate documentation links and maintained examples;
- leave future migration behavior in `docs/roadmap/` until implemented.
**Gate:** Do not begin architectural migration work until the documentation
refresh and policy updates are merged and the repository has an agreed,
accurate baseline.
**Gate status:** Complete as of 2026-07-26. The completed documentation
refresh and follow-up verification record are in the [documentation compliance
roadmap](documentation.md).
### Step 2: Record The Architectural Decision And Detailed Boundary
Create an ADR, under the policy established in Step 1, that records:
- the decision to split Promptkit from Scriptorium;
- the target ownership and dependency direction;
- the selected Promptkit repository and Go module paths;
- the breaking-change and versioning policy;
- ownership of configuration fields and defaults;
- artifact-reader and HTTP containment responsibilities;
- local multi-repository development and release coordination;
- documentation ownership after the split.
Use the ADR to resolve any remaining public-boundary decisions before code is
moved.
**Gate:** The ADR is accepted, and every existing package, public contract,
configuration category, and maintained asset has a target owner.
### Step 3: Characterize Existing Framework Behavior
Strengthen or add contract-focused tests where needed so extraction can be
verified without relying on package placement.
Preserve coverage of:
- `Prepare` and `Run` behavior;
- prompt, profile, execution-default, and request-override precedence;
- presence-aware numeric overrides;
- built-in profile fallback and custom-profile overlays;
- strict YAML and JSON decoding;
- prompt, profile, schema, and artifact source behavior;
- structured-output requests and output validation;
- validation failures versus validation runtime errors;
- secret handling and redaction;
- public error classification;
- HTTP artifact restrictions and transport mappings.
**Gate:** Current framework and adapter contracts are represented by passing
tests sufficient to detect behavioral regressions during the split.
### Step 4: Make Scriptorium Adapters Consume The Public Facade
Within the current repository, refactor the CLI and HTTP adapters to use the
public framework facade rather than constructing or importing internal runner
components directly.
Add only the minimum public capabilities needed to support this boundary. These
may include:
- a small `Run`/`Prepare` consumer interface;
- injectable artifact-reading behavior for Scriptorium's restricted HTTP
policy;
- source options currently available only through internal constructors;
- prepared-run formatting based on public types;
- stable public error classification required by CLI and HTTP mappings.
Do not broadly export internal repositories, domain types, or use-case
implementations.
**Gate:** The CLI and HTTP adapters use only the public framework API for
framework behavior, and all tests and documented smoke commands pass.
### Step 5: Create The Promptkit Repository
Create the Promptkit repository and Go module as an explicit out-of-band
operation.
Establish:
- repository access, ownership, and branch protections;
- the module path selected by the ADR;
- baseline development, architecture, documentation, and release policies;
- CI for build, test, vet, and other agreed checks;
- an initial package layout centered on a small public facade with internal
implementation packages;
- a local development workflow for coordinated Promptkit and Scriptorium
changes, using a workspace or temporary uncommitted module replacement where
appropriate.
Do not commit local filesystem `replace` directives to release branches.
**Gate:** The Promptkit repository exists, is accessible to maintainers, has
working CI and policy scaffolding, and can receive the extracted framework.
Do not begin cross-repository extraction until this out-of-band work is
confirmed complete.
### Step 6: Extract And Stabilize Promptkit
Move the application-neutral framework and built-in profile assets into
Promptkit. Preserve implementation packages as internal where practical.
The initial public API should remain focused on the established engine workflow
and the source and client extension points required by real consumers. Avoid
combining the extraction with unrelated API redesign.
Move or recreate the relevant:
- framework implementation;
- public package tests and framework contract tests;
- built-in profile assets and registry tests;
- Go consumer examples;
- framework, consumer, configuration-format, and integration documentation.
Verify that Promptkit can be built, tested, and consumed independently of the
Scriptorium repository.
**Gate:** Promptkit independently provides the agreed framework contract,
passes its CI checks, and has a tagged version that Scriptorium and other
consumers can import.
### Step 7: Slim Scriptorium And Adopt Promptkit
Update Scriptorium to import the tagged Promptkit module and remove the
framework implementation and public Go facade that Promptkit replaces.
Retain only Scriptorium-owned executable and transport behavior. In particular:
- wire CLI and HTTP requests through Promptkit's public API;
- keep application config and transport defaults in Scriptorium;
- keep restricted HTTP artifact policy in Scriptorium while injecting it
through Promptkit's supported boundary;
- remove obsolete framework packages, tests, and documentation;
- update Scriptorium examples and docs to describe the CLI and HTTP application;
- direct Go framework consumers to Promptkit without providing compatibility
aliases or forwarding APIs.
**Gate:** Scriptorium builds and passes all tests using a tagged Promptkit
dependency, contains no duplicate framework implementation, and its current
documentation describes only the slimmed application.
### Step 8: Migrate Downstream Consumers To Promptkit
Inventory downstream Go consumers and migrate each from the Scriptorium package
to Promptkit. This work may occur in external repositories and must be tracked
explicitly.
For each consumer:
- update module imports and dependencies;
- adapt to any intentionally changed public API;
- run its tests and relevant integration or smoke checks;
- confirm configuration, source, validation, and error behavior;
- release or deploy the migrated consumer through its normal process.
Consumers that cannot migrate immediately may remain pinned to the last
framework-bearing Scriptorium tag. No compatibility work is required in the new
Scriptorium project for those consumers.
**Gate:** All in-scope downstream consumers are either migrated and verified or
explicitly recorded as remaining on the previous Scriptorium version with an
owner and follow-up plan. Do not declare the ecosystem migration complete until
the required out-of-band consumer changes are confirmed.
### Step 9: Complete Release And Documentation Cutover
Complete the coordinated project transition:
- publish Promptkit before dependent Scriptorium releases;
- release the breaking Scriptorium version against the tagged Promptkit
dependency;
- publish migration guidance that maps the former Scriptorium Go API to
Promptkit;
- update cross-project links, examples, package documentation, and release
notes;
- verify that no release artifact depends on local workspaces or replacements;
- archive completed roadmap material according to the documentation policy in
effect at that time.
**Gate:** Promptkit and Scriptorium are independently releasable, their
documentation has distinct and accurate ownership, and the migration status of
all identified downstream consumers is recorded.
## Cross-Cutting Constraints
- Preserve the invariant that execution orchestration remains narrow and
application-neutral.
- Keep adapter-specific decisions out of Promptkit.
- Keep Scriptorium dependent only on Promptkit's supported public API.
- Preserve strict external decoding, error classification, validation
semantics, and secret redaction throughout the migration.
- Keep each repository buildable and testable at merged phase boundaries.
- Coordinate cross-repository changes through tagged dependencies and explicit
gates rather than assuming atomic commits across repositories.
- Document only implemented behavior outside roadmap files.
## Completion Criteria
The migration is complete when:
- Promptkit is the independent owner of the reusable framework and built-in
profiles;
- Scriptorium is a slim CLI and HTTP consumer of Promptkit;
- Scriptorium no longer exposes or maintains the former public Go framework;
- all required downstream migrations and external repository work have been
completed or explicitly dispositioned;
- both repositories build, test, document, version, and release independently.

318
engine.go
View File

@@ -1,318 +0,0 @@
package scriptorium
import (
"context"
"errors"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
artifactadapter "gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
"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"
"gitea.maximumdirect.net/eric/scriptorium/internal/validate"
)
// ErrInvalidConfig indicates invalid public engine configuration.
var ErrInvalidConfig = errors.New("invalid engine configuration")
var (
ErrInvalidRequest = errors.New("invalid run request")
ErrPromptNotFound = errors.New("prompt not found")
ErrProfileNotFound = errors.New("profile not found")
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")
ErrValidation = errors.New("failed to validate output")
)
// Engine prepares and runs Scriptorium prompt requests.
type Engine struct {
runner *usecase.Runner
}
// Config configures a public Scriptorium engine.
type Config struct {
PromptDir string
ProfileDir string
SchemaDir string
Timeout time.Duration
HTTPClient *http.Client
}
// Option customizes engine construction.
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
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 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) {
var options engineOptions
for _, opt := range opts {
if opt == nil {
continue
}
if err := opt.apply(&options); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
}
}
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
if llmClient == nil {
var err error
llmClient, err = llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{
Timeout: cfg.Timeout,
HTTPClient: cfg.HTTPClient,
})
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
}
}
return &Engine{
runner: usecase.NewRunner(
promptDefs,
profiles,
artifactadapter.NewCompositeReader(),
prompt.NewGoRenderer(),
llmClient,
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)
}
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)
}
return fromDomainPreparedRun(prepared), nil
}
// Run executes a prompt request and returns the generated artifact and metadata.
func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
if e == nil || e.runner == nil {
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
}
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)
}
return fromDomainRunResult(result), nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,79 +0,0 @@
package scriptorium
import (
"errors"
"fmt"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
"gitea.maximumdirect.net/eric/scriptorium/internal/usecase"
)
func mapPublicError(err error) error {
if err == nil {
return nil
}
if hasPublicError(err) {
return err
}
publicErr := publicErrorFor(err)
if publicErr == nil {
return err
}
return fmt.Errorf("%w: %w", publicErr, err)
}
func hasPublicError(err error) bool {
for _, publicErr := range []error{
ErrInvalidConfig,
ErrInvalidRequest,
ErrPromptNotFound,
ErrProfileNotFound,
ErrPromptLoad,
ErrProfileLoad,
ErrArtifactLoad,
ErrPromptRender,
ErrLLMGenerate,
ErrValidation,
} {
if errors.Is(err, publicErr) {
return true
}
}
return false
}
func publicErrorFor(err error) error {
switch {
case errors.Is(err, promptdef.ErrPromptDefinitionNotFound):
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 isProfileLoadCause(err):
return ErrProfileLoad
case errors.Is(err, usecase.ErrArtifactLoad):
return ErrArtifactLoad
case errors.Is(err, usecase.ErrPromptRender):
return ErrPromptRender
case errors.Is(err, usecase.ErrLLMGenerate):
return ErrLLMGenerate
case errors.Is(err, usecase.ErrValidation):
return ErrValidation
case errors.Is(err, usecase.ErrInvalidRequest):
return ErrInvalidRequest
default:
return nil
}
}
func isProfileLoadCause(err error) bool {
return errors.Is(err, profile.ErrInvalidYAML) ||
errors.Is(err, profile.ErrInvalidProfile) ||
errors.Is(err, profile.ErrRawAPIKeyNotAllowed)
}

View File

@@ -1,50 +0,0 @@
package main
import (
"context"
"encoding/json"
"log"
"os"
"gitea.maximumdirect.net/eric/scriptorium"
)
func main() {
engine, err := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
ProfileDir: "./examples/profiles",
SchemaDir: "./examples/schemas",
})
if err != nil {
log.Fatal(err)
}
prepared, err := engine.Prepare(context.Background(), 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 {
log.Fatal(err)
}
summary := struct {
PromptID string `json:"prompt_id"`
SelectedProfileID string `json:"selected_profile_id"`
Model string `json:"model"`
MessageCount int `json:"message_count"`
InputHashes map[string]string `json:"input_hashes"`
}{
PromptID: prepared.PromptID,
SelectedProfileID: prepared.SelectedProfileID,
Model: prepared.EffectiveModelParams.Model,
MessageCount: len(prepared.Messages),
InputHashes: prepared.InputHashes,
}
if err := json.NewEncoder(os.Stdout).Encode(summary); err != nil {
log.Fatal(err)
}
}

View File

@@ -1,51 +0,0 @@
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),
)
}

7
go.mod
View File

@@ -3,8 +3,11 @@ module gitea.maximumdirect.net/eric/scriptorium
go 1.25.5 go 1.25.5
require ( require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 gitea.maximumdirect.net/eric/promptkit v0.1.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require golang.org/x/text v0.14.0 // indirect require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
golang.org/x/text v0.14.0 // indirect
)

2
go.sum
View File

@@ -1,3 +1,5 @@
gitea.maximumdirect.net/eric/promptkit v0.1.0 h1:vuKeBxkiY8E54LRFbLQFjlJJCiOfMvB1++DYBCrD/ug=
gitea.maximumdirect.net/eric/promptkit v0.1.0/go.mod h1:R95NM6fbMDGDC0/UomgnSBP6ui2ns+8SZb8bESNvrDQ=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=

View File

@@ -12,18 +12,11 @@ import (
"strings" "strings"
"time" "time"
"gitea.maximumdirect.net/eric/promptkit"
httpadapter "gitea.maximumdirect.net/eric/scriptorium/internal/adapter/http" httpadapter "gitea.maximumdirect.net/eric/scriptorium/internal/adapter/http"
artifactadapter "gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config" appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config"
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults" "gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format" renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
"gitea.maximumdirect.net/eric/scriptorium/internal/llm"
"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"
"gitea.maximumdirect.net/eric/scriptorium/internal/validate"
) )
const ( const (
@@ -140,15 +133,13 @@ func runCommand(args []string, stdout, stderr io.Writer) int {
return ExitRuntimeError return ExitRuntimeError
} }
llmClient, err := newOpenAIClient() engine, err := newEngine(cfg)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "llm client error: %v\n", err) fmt.Fprintf(stderr, "engine error: %v\n", err)
return ExitRuntimeError return ExitRuntimeError
} }
runner := newRunner(cfg.promptDir, cfg.profileDir, cfg.schemaDir, llmClient) res, runErr := engine.Run(context.Background(), req)
res, runErr := runner.Run(context.Background(), req)
if runErr != nil { if runErr != nil {
fmt.Fprintf(stderr, "run error: %v\n", runErr) fmt.Fprintf(stderr, "run error: %v\n", runErr)
return ExitRuntimeError return ExitRuntimeError
@@ -176,9 +167,13 @@ func renderCommand(args []string, stdout, stderr io.Writer) int {
return ExitRuntimeError return ExitRuntimeError
} }
runner := newRunner(cfg.promptDir, cfg.profileDir, cfg.schemaDir, nil) engine, err := newEngine(&cfg.runConfig)
if err != nil {
fmt.Fprintf(stderr, "engine error: %v\n", err)
return ExitRuntimeError
}
prepared, prepErr := runner.Prepare(context.Background(), req) prepared, prepErr := engine.Prepare(context.Background(), req)
if prepErr != nil { if prepErr != nil {
fmt.Fprintf(stderr, "render error: %v\n", prepErr) fmt.Fprintf(stderr, "render error: %v\n", prepErr)
return ExitRuntimeError return ExitRuntimeError
@@ -204,21 +199,23 @@ func serveCommand(args []string, stderr io.Writer) int {
return ExitRuntimeError return ExitRuntimeError
} }
llmClient, err := newOpenAIClient() artifactReader, err := httpadapter.NewRestrictedArtifactReader(cfg.artifactRoot, cfg.maxArtifactBytes)
if err != nil {
fmt.Fprintf(stderr, "llm client error: %v\n", err)
return ExitRuntimeError
}
artifactReader, err := artifactadapter.NewRestrictedCompositeReaderWithLimit(cfg.artifactRoot, cfg.maxArtifactBytes)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "artifact root error: %v\n", err) fmt.Fprintf(stderr, "artifact root error: %v\n", err)
return ExitRuntimeError return ExitRuntimeError
} }
runner := newRunnerWithArtifactReader(cfg.promptDir, cfg.profileDir, cfg.schemaDir, llmClient, artifactReader) engine, err := newEngine(&runConfig{
promptDir: cfg.promptDir,
profileDir: cfg.profileDir,
schemaDir: cfg.schemaDir,
}, promptkit.WithArtifactReader(artifactReader))
if err != nil {
fmt.Fprintf(stderr, "engine error: %v\n", err)
return ExitRuntimeError
}
h := httpadapter.NewHandlerWithOptions(runner, httpadapter.HandlerOptions{ h := httpadapter.NewHandlerWithOptions(engine, httpadapter.HandlerOptions{
MaxRequestBytes: cfg.maxRequestBytes, MaxRequestBytes: cfg.maxRequestBytes,
MaxResponseBytes: cfg.maxResponseBytes, MaxResponseBytes: cfg.maxResponseBytes,
}) })
@@ -362,7 +359,7 @@ func registerExecutionRequestFlags(fs *flag.FlagSet, cfg *runConfig) {
fs.Float64Var(&cfg.temperature, "temperature", 0, "optional temperature override") fs.Float64Var(&cfg.temperature, "temperature", 0, "optional temperature override")
fs.IntVar(&cfg.maxTokens, "max-tokens", 0, "optional max tokens override") fs.IntVar(&cfg.maxTokens, "max-tokens", 0, "optional max tokens override")
fs.Float64Var(&cfg.topP, "top-p", 0, "optional top_p override") fs.Float64Var(&cfg.topP, "top-p", 0, "optional top_p override")
fs.DurationVar(&cfg.timeout, "timeout", defaults.LLMRequestTimeoutDefault, "LLM request timeout") fs.DurationVar(&cfg.timeout, "timeout", 0, "LLM request timeout")
fs.StringVar(&cfg.promptID, "prompt-id", "", "deprecated alias for --prompt") fs.StringVar(&cfg.promptID, "prompt-id", "", "deprecated alias for --prompt")
fs.StringVar(&cfg.profileID, "profile-id", "", "deprecated alias for --profile") fs.StringVar(&cfg.profileID, "profile-id", "", "deprecated alias for --profile")
} }
@@ -541,52 +538,36 @@ func validateRequiredLibraryDirs(promptDir string) error {
return nil return nil
} }
func newRunner(promptDir, profileDir, schemaDir string, llmClient llm.Client) *usecase.Runner { func newEngine(cfg *runConfig, options ...promptkit.Option) (*promptkit.Engine, error) {
return newRunnerWithArtifactReader(promptDir, profileDir, schemaDir, llmClient, artifactadapter.NewCompositeReader()) return promptkit.NewEngine(promptkit.Config{
PromptDir: cfg.promptDir,
ProfileDir: cfg.profileDir,
SchemaDir: cfg.schemaDir,
}, options...)
} }
func newRunnerWithArtifactReader(promptDir, profileDir, schemaDir string, llmClient llm.Client, artifactReader artifactadapter.Reader) *usecase.Runner { func buildRunRequestFromConfig(cfg *runConfig) (promptkit.RunRequest, error) {
if artifactReader == nil {
artifactReader = artifactadapter.NewCompositeReader()
}
return usecase.NewRunner(
promptdef.NewFilesystemRepository(promptDir),
builtin.NewRepositoryWithDirectory(profileDir),
artifactReader,
prompt.NewGoRenderer(),
llmClient,
validate.NewStandardValidator(schemaDir),
)
}
func newOpenAIClient() (*llm.OpenAICompatibleClient, error) {
return llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{
Timeout: defaults.LLMRequestTimeoutDefault,
})
}
func buildRunRequestFromConfig(cfg *runConfig) (domain.RunRequest, error) {
inputMappings, err := parseMappings(cfg.inputRaw, false) inputMappings, err := parseMappings(cfg.inputRaw, false)
if err != nil { if err != nil {
return domain.RunRequest{}, fmt.Errorf("input parse error: %w", err) return promptkit.RunRequest{}, fmt.Errorf("input parse error: %w", err)
} }
varMappings := map[string]string{} varMappings := map[string]string{}
if len(cfg.varRaw) > 0 { if len(cfg.varRaw) > 0 {
varMappings, err = parseMappings(cfg.varRaw, false) varMappings, err = parseMappings(cfg.varRaw, false)
if err != nil { if err != nil {
return domain.RunRequest{}, fmt.Errorf("var parse error: %w", err) return promptkit.RunRequest{}, fmt.Errorf("var parse error: %w", err)
} }
} }
inputs := make(map[string]domain.ArtifactRef, len(inputMappings)) inputs := make(map[string]promptkit.ArtifactRef, len(inputMappings))
for name, path := range inputMappings { for name, path := range inputMappings {
inputs[name] = domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: path} inputs[name] = promptkit.File(path)
} }
var modelOverride *domain.ExecutionTargetOverride var modelOverride *promptkit.ExecutionTargetOverride
if cfg.llmBaseURLSet || cfg.modelSet || cfg.temperatureSet || cfg.maxTokensSet || cfg.topPSet || cfg.apiKeyEnvSet || cfg.timeoutSet { if cfg.llmBaseURLSet || cfg.modelSet || cfg.temperatureSet || cfg.maxTokensSet || cfg.topPSet || cfg.apiKeyEnvSet || cfg.timeoutSet {
modelOverride = &domain.ExecutionTargetOverride{ modelOverride = &promptkit.ExecutionTargetOverride{
Endpoint: cfg.llmBaseURL, Endpoint: cfg.llmBaseURL,
Model: cfg.model, Model: cfg.model,
APIKeyEnv: cfg.apiKeyEnv, APIKeyEnv: cfg.apiKeyEnv,
@@ -606,7 +587,7 @@ func buildRunRequestFromConfig(cfg *runConfig) (domain.RunRequest, error) {
} }
} }
return domain.RunRequest{ return promptkit.RunRequest{
PromptID: cfg.promptID, PromptID: cfg.promptID,
ProfileID: cfg.profileID, ProfileID: cfg.profileID,
Inputs: inputs, Inputs: inputs,
@@ -670,17 +651,17 @@ func writeOutput(stdout io.Writer, outputPath string, body []byte) error {
return os.WriteFile(outputPath, body, 0644) return os.WriteFile(outputPath, body, 0644)
} }
func determineExitCode(runErr error, result *domain.RunResult) int { func determineExitCode(runErr error, result *promptkit.RunResult) int {
if runErr != nil { if runErr != nil {
return ExitRuntimeError return ExitRuntimeError
} }
if result != nil && result.Validation.Status == domain.ValidationFailed { if result != nil && result.Validation.Status == promptkit.ValidationFailed {
return ExitValidationFailed return ExitValidationFailed
} }
return ExitOK return ExitOK
} }
func printSummary(stderr io.Writer, res *domain.RunResult) { func printSummary(stderr io.Writer, res *promptkit.RunResult) {
if res == nil { if res == nil {
return return
} }

View File

@@ -17,9 +17,9 @@ import (
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/promptkit"
appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config" appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config"
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults" "gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format" renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
) )
@@ -222,8 +222,8 @@ func TestParseRunArgsTimeout(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("expected valid run args, got %v", err) t.Fatalf("expected valid run args, got %v", err)
} }
if cfg.timeout != defaults.LLMRequestTimeoutDefault { if cfg.timeout != 0 {
t.Fatalf("expected default timeout %s, got %s", defaults.LLMRequestTimeoutDefault, cfg.timeout) t.Fatalf("expected omitted timeout to remain unset, got %s", cfg.timeout)
} }
cfg, err = parseRunArgs([]string{ cfg, err = parseRunArgs([]string{
@@ -655,6 +655,40 @@ func TestRunAndRenderBuildEquivalentRuntimeOverrideRequestsForSharedFlags(t *tes
} }
} }
func TestBuildRunRequestPreservesNumericOverridePresence(t *testing.T) {
omitted, err := buildRunRequestFromConfig(&runConfig{
promptID: "prompt-1",
inputRaw: []string{"transcript=./transcript.md"},
})
if err != nil {
t.Fatalf("expected omitted override request to build, got %v", err)
}
if omitted.Execution != nil {
t.Fatalf("expected omitted numeric flags to leave execution override nil, got %#v", omitted.Execution)
}
explicitZeros, err := buildRunRequestFromConfig(&runConfig{
promptID: "prompt-1",
inputRaw: []string{"transcript=./transcript.md"},
temperatureSet: true,
maxTokensSet: true,
topPSet: true,
timeoutSet: true,
})
if err != nil {
t.Fatalf("expected explicit zero override request to build, got %v", err)
}
if explicitZeros.Execution == nil {
t.Fatal("expected explicit numeric flags to create execution override")
}
if explicitZeros.Execution.Temperature == nil || explicitZeros.Execution.MaxTokens == nil || explicitZeros.Execution.TopP == nil || explicitZeros.Execution.TimeoutSeconds == nil {
t.Fatalf("expected explicit zero numeric overrides to remain non-nil, got %#v", explicitZeros.Execution)
}
if *explicitZeros.Execution.Temperature != 0 || *explicitZeros.Execution.MaxTokens != 0 || *explicitZeros.Execution.TopP != 0 || *explicitZeros.Execution.TimeoutSeconds != 0 {
t.Fatalf("expected explicit numeric overrides to retain zero values, got %#v", explicitZeros.Execution)
}
}
func TestParseRunArgsFailsClearlyWhenNoEffectivePromptDir(t *testing.T) { func TestParseRunArgsFailsClearlyWhenNoEffectivePromptDir(t *testing.T) {
configPath := writeAppConfigFile(t, ` configPath := writeAppConfigFile(t, `
profile_dir: ./profiles profile_dir: ./profiles
@@ -731,13 +765,13 @@ func TestDetermineExitCode(t *testing.T) {
if got := determineExitCode(errors.New("boom"), nil); got != ExitRuntimeError { if got := determineExitCode(errors.New("boom"), nil); got != ExitRuntimeError {
t.Fatalf("expected runtime exit code, got %d", got) t.Fatalf("expected runtime exit code, got %d", got)
} }
if got := determineExitCode(nil, &domain.RunResult{Validation: domain.ValidationResult{Status: domain.ValidationFailed}}); got != ExitValidationFailed { if got := determineExitCode(nil, &promptkit.RunResult{Validation: promptkit.ValidationResult{Status: promptkit.ValidationFailed}}); got != ExitValidationFailed {
t.Fatalf("expected validation exit code, got %d", got) t.Fatalf("expected validation exit code, got %d", got)
} }
if got := determineExitCode(nil, &domain.RunResult{Validation: domain.ValidationResult{Status: domain.ValidationPassed}}); got != ExitOK { if got := determineExitCode(nil, &promptkit.RunResult{Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed}}); got != ExitOK {
t.Fatalf("expected success exit code for passed validation, got %d", got) t.Fatalf("expected success exit code for passed validation, got %d", got)
} }
if got := determineExitCode(nil, &domain.RunResult{Validation: domain.ValidationResult{Status: domain.ValidationSkipped}}); got != ExitOK { if got := determineExitCode(nil, &promptkit.RunResult{Validation: promptkit.ValidationResult{Status: promptkit.ValidationSkipped}}); got != ExitOK {
t.Fatalf("expected success exit code for skipped validation, got %d", got) t.Fatalf("expected success exit code for skipped validation, got %d", got)
} }
} }
@@ -1208,12 +1242,12 @@ func TestWriteOutputAndSummaryUseSeparateWriters(t *testing.T) {
if err := writeOutput(&stdout, "", []byte("artifact-body")); err != nil { if err := writeOutput(&stdout, "", []byte("artifact-body")); err != nil {
t.Fatalf("unexpected writeOutput error: %v", err) t.Fatalf("unexpected writeOutput error: %v", err)
} }
printSummary(&stderr, &domain.RunResult{ printSummary(&stderr, &promptkit.RunResult{
PromptID: "p", PromptID: "p",
PromptVersion: "1", PromptVersion: "1",
SelectedProfileID: "exec", SelectedProfileID: "exec",
ModelName: "m", ModelName: "m",
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic}, Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic},
RenderedPromptHash: "h", RenderedPromptHash: "h",
InputHashes: map[string]string{"in": "x"}, InputHashes: map[string]string{"in": "x"},
}) })
@@ -1232,15 +1266,15 @@ func TestWriteOutputAndSummaryUseSeparateWriters(t *testing.T) {
func TestPrintSummaryIncludesCacheUsageWhenPresent(t *testing.T) { func TestPrintSummaryIncludesCacheUsageWhenPresent(t *testing.T) {
var stderr bytes.Buffer var stderr bytes.Buffer
printSummary(&stderr, &domain.RunResult{ printSummary(&stderr, &promptkit.RunResult{
PromptID: "p", PromptID: "p",
PromptVersion: "1", PromptVersion: "1",
SelectedProfileID: "exec", SelectedProfileID: "exec",
ModelName: "m", ModelName: "m",
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic}, Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic},
RenderedPromptHash: "h", RenderedPromptHash: "h",
InputHashes: map[string]string{"in": "x"}, InputHashes: map[string]string{"in": "x"},
Usage: domain.TokenUsage{ Usage: promptkit.TokenUsage{
PromptTokens: 10, PromptTokens: 10,
CompletionTokens: 5, CompletionTokens: 5,
TotalTokens: 15, TotalTokens: 15,

View File

@@ -0,0 +1,296 @@
package adapter_test
import (
"fmt"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
)
const (
scriptoriumModulePath = "gitea.maximumdirect.net/eric/scriptorium"
promptkitInternalPath = "gitea.maximumdirect.net/eric/promptkit/internal"
)
var (
removedFrameworkPackageRoots = []string{
scriptoriumModulePath + "/internal/artifact",
scriptoriumModulePath + "/internal/domain",
scriptoriumModulePath + "/internal/filecatalog",
scriptoriumModulePath + "/internal/llm",
scriptoriumModulePath + "/internal/profile",
scriptoriumModulePath + "/internal/prompt",
scriptoriumModulePath + "/internal/promptdef",
scriptoriumModulePath + "/internal/usecase",
scriptoriumModulePath + "/internal/validate",
}
removedFrameworkDirectories = []string{
"internal/artifact",
"internal/domain",
"internal/filecatalog",
"internal/llm",
"internal/profile",
"internal/prompt",
"internal/promptdef",
"internal/usecase",
"internal/validate",
}
nonSourceDirectories = map[string]struct{}{
".cache": {},
".codebase-memory": {},
".git": {},
"build": {},
"coverage": {},
"dist": {},
"node_modules": {},
"out": {},
"testdata": {},
"vendor": {},
}
)
type forbiddenImport struct {
filePath string
importPath string
}
func TestApplicationBoundary(t *testing.T) {
moduleRoot := moduleRootFromTestFile(t)
violations, err := findForbiddenProductionImports(moduleRoot)
if err != nil {
t.Fatalf("scan production imports: %v", err)
}
for _, violation := range violations {
t.Errorf("%s imports forbidden package %s", violation.filePath, violation.importPath)
}
assertFrameworkImplementationAbsent(t, moduleRoot)
}
func TestForbiddenImportScannerDetectsFormerRoot(t *testing.T) {
root := t.TempDir()
sourcePath := writeGoSource(t, root, "nested/consumer/root.go", scriptoriumModulePath)
violations, err := findForbiddenProductionImports(root)
if err != nil {
t.Fatalf("scan source fixture: %v", err)
}
assertSingleViolation(t, violations, sourcePath, scriptoriumModulePath)
}
func TestForbiddenImportScannerDetectsFormerFrameworkFamily(t *testing.T) {
root := t.TempDir()
importPath := scriptoriumModulePath + "/internal/profile/builtin"
sourcePath := writeGoSource(t, root, "nested/consumer/profile.go", importPath)
violations, err := findForbiddenProductionImports(root)
if err != nil {
t.Fatalf("scan source fixture: %v", err)
}
assertSingleViolation(t, violations, sourcePath, importPath)
}
func TestForbiddenImportScannerDetectsPromptkitInternalPackages(t *testing.T) {
tests := []struct {
name string
importPath string
}{
{name: "exact internal root", importPath: promptkitInternalPath},
{name: "internal descendant", importPath: promptkitInternalPath + "/domain"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
sourcePath := writeGoSource(t, root, "nested/consumer/promptkit.go", tc.importPath)
violations, err := findForbiddenProductionImports(root)
if err != nil {
t.Fatalf("scan source fixture: %v", err)
}
assertSingleViolation(t, violations, sourcePath, tc.importPath)
})
}
}
func TestForbiddenImportScannerAllowsRetainedApplicationPackages(t *testing.T) {
root := t.TempDir()
sourcePath := filepath.Join(root, "nested/consumer/application.go")
if err := os.MkdirAll(filepath.Dir(sourcePath), 0o755); err != nil {
t.Fatalf("create source fixture directory: %v", err)
}
source := `package consumer
import (
_ "gitea.maximumdirect.net/eric/promptkit"
_ "gitea.maximumdirect.net/eric/scriptorium/internal/adapter/http"
_ "gitea.maximumdirect.net/eric/scriptorium/internal/config"
_ "gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
_ "gitea.maximumdirect.net/eric/scriptorium/internal/format"
)
`
if err := os.WriteFile(sourcePath, []byte(source), 0o644); err != nil {
t.Fatalf("write source fixture: %v", err)
}
violations, err := findForbiddenProductionImports(root)
if err != nil {
t.Fatalf("scan source fixture: %v", err)
}
if len(violations) != 0 {
t.Fatalf("expected retained application imports to be allowed, got %#v", violations)
}
}
func findForbiddenProductionImports(root string) ([]forbiddenImport, error) {
var violations []forbiddenImport
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
if path != root && shouldSkipSourceDirectory(entry.Name()) {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
return nil
}
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
if err != nil {
return fmt.Errorf("parse imports in %s: %w", path, err)
}
for _, imported := range file.Imports {
importPath, err := strconv.Unquote(imported.Path.Value)
if err != nil {
return fmt.Errorf("parse import path in %s: %w", path, err)
}
if isForbiddenProductionImport(importPath) {
violations = append(violations, forbiddenImport{
filePath: path,
importPath: importPath,
})
}
}
return nil
})
if err != nil {
return nil, fmt.Errorf("walk repository root %s: %w", root, err)
}
return violations, nil
}
func shouldSkipSourceDirectory(name string) bool {
_, skip := nonSourceDirectories[name]
return skip
}
func isForbiddenProductionImport(importPath string) bool {
if importPath == scriptoriumModulePath {
return true
}
if importPath == promptkitInternalPath || strings.HasPrefix(importPath, promptkitInternalPath+"/") {
return true
}
for _, root := range removedFrameworkPackageRoots {
if importPath == root || strings.HasPrefix(importPath, root+"/") {
return true
}
}
return false
}
func moduleRootFromTestFile(t *testing.T) string {
t.Helper()
_, testFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate dependency guard source")
}
root, err := findModuleRoot(filepath.Dir(testFile))
if err != nil {
t.Fatal(err)
}
return root
}
func findModuleRoot(start string) (string, error) {
dir, err := filepath.Abs(start)
if err != nil {
return "", fmt.Errorf("resolve module search path: %w", err)
}
for {
goMod := filepath.Join(dir, "go.mod")
if info, err := os.Stat(goMod); err == nil && !info.IsDir() {
return dir, nil
} else if err != nil && !os.IsNotExist(err) {
return "", fmt.Errorf("inspect %s: %w", goMod, err)
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("locate go.mod from %s", start)
}
dir = parent
}
}
func assertFrameworkImplementationAbsent(t *testing.T, moduleRoot string) {
t.Helper()
entries, err := os.ReadDir(moduleRoot)
if err != nil {
t.Fatalf("read module root: %v", err)
}
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") && !strings.HasSuffix(entry.Name(), "_test.go") {
t.Errorf("module root contains production Go file %s", entry.Name())
}
}
for _, relativePath := range removedFrameworkDirectories {
path := filepath.Join(moduleRoot, filepath.FromSlash(relativePath))
if _, err := os.Stat(path); err == nil {
t.Errorf("removed framework directory still exists: %s", relativePath)
} else if !os.IsNotExist(err) {
t.Errorf("inspect removed framework directory %s: %v", relativePath, err)
}
}
}
func writeGoSource(t *testing.T, root, relativePath, importPath string) string {
t.Helper()
sourcePath := filepath.Join(root, filepath.FromSlash(relativePath))
if err := os.MkdirAll(filepath.Dir(sourcePath), 0o755); err != nil {
t.Fatalf("create source fixture directory: %v", err)
}
source := fmt.Sprintf("package consumer\n\nimport _ %q\n", importPath)
if err := os.WriteFile(sourcePath, []byte(source), 0o644); err != nil {
t.Fatalf("write source fixture: %v", err)
}
return sourcePath
}
func assertSingleViolation(t *testing.T, violations []forbiddenImport, sourcePath, importPath string) {
t.Helper()
if len(violations) != 1 {
t.Fatalf("expected one forbidden import, got %#v", violations)
}
if violations[0].filePath != sourcePath {
t.Fatalf("unexpected importing file: %q", violations[0].filePath)
}
if violations[0].importPath != importPath {
t.Fatalf("unexpected forbidden import: %q", violations[0].importPath)
}
}

View File

@@ -0,0 +1,165 @@
package httpadapter
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"io"
"mime"
"os"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/promptkit"
)
var (
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")
)
const fallbackArtifactContentType = "text/plain"
// NewRestrictedArtifactReader creates the HTTP artifact reader for a rooted
// filesystem and optional byte limit. An empty root permits inline artifacts
// but denies file references; a zero limit permits artifacts of any size.
func NewRestrictedArtifactReader(root string, maxBytes int64) (promptkit.ArtifactReader, 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 &restrictedArtifactReader{maxBytes: maxBytes}, nil
}
absRoot, err := filepath.Abs(filepath.Clean(cleanRoot))
if err != nil {
return nil, fmt.Errorf("resolve artifact root: %w", err)
}
return &restrictedArtifactReader{root: absRoot, maxBytes: maxBytes}, nil
}
type restrictedArtifactReader struct {
root string
maxBytes int64
}
var _ promptkit.ArtifactReader = (*restrictedArtifactReader)(nil)
func (r *restrictedArtifactReader) Read(ctx context.Context, ref promptkit.ArtifactRef) (*promptkit.Artifact, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
switch ref.Type {
case promptkit.ArtifactRefInline:
return readInlineArtifact(ref)
case promptkit.ArtifactRefFile:
return r.readFileArtifact(ref)
default:
return nil, fmt.Errorf("unsupported artifact reference type %q", ref.Type)
}
}
func readInlineArtifact(ref promptkit.ArtifactRef) (*promptkit.Artifact, error) {
if ref.Body == "" {
return nil, errors.New("inline artifact body is required")
}
body := []byte(ref.Body)
return &promptkit.Artifact{
ContentType: fallbackArtifactContentType,
Body: body,
Size: int64(len(body)),
Hash: artifactHash(body),
URI: ref.URI,
}, nil
}
func (r *restrictedArtifactReader) readFileArtifact(ref promptkit.ArtifactRef) (*promptkit.Artifact, error) {
if ref.URI == "" {
return nil, errors.New("file artifact path is required")
}
if r.root == "" {
return nil, ErrFileNotAllowed
}
path, err := r.resolveLexicalPath(ref.URI)
if err != nil {
return nil, err
}
return readArtifactFile(path, r.maxBytes)
}
// resolveLexicalPath checks cleaned path containment without resolving symlinks.
func (r *restrictedArtifactReader) resolveLexicalPath(rawPath string) (string, error) {
cleanPath := filepath.Clean(strings.TrimSpace(rawPath))
candidate := cleanPath
if !filepath.IsAbs(cleanPath) {
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 readArtifactFile(path string, maxBytes int64) (*promptkit.Artifact, error) {
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)
}
body, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
}
if maxBytes > 0 && int64(len(body)) > maxBytes {
return nil, ErrFileTooLarge
}
contentType := mime.TypeByExtension(filepath.Ext(path))
if contentType == "" {
contentType = fallbackArtifactContentType
}
return &promptkit.Artifact{
Name: filepath.Base(path),
ContentType: contentType,
Body: body,
URI: path,
Size: int64(len(body)),
Hash: artifactHash(body),
}, nil
}
func artifactHash(body []byte) string {
return fmt.Sprintf("%x", sha256.Sum256(body))
}

View File

@@ -0,0 +1,185 @@
package httpadapter
import (
"context"
"errors"
"mime"
"os"
"path/filepath"
"testing"
"gitea.maximumdirect.net/eric/promptkit"
)
func TestRestrictedArtifactReaderReadsContainedFiles(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
inputPath := filepath.Join(root, "input.html")
if err := os.WriteFile(inputPath, []byte("allowed"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "input.unknown"), []byte("unknown type"), 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)
}
expectedContentType := mime.TypeByExtension(filepath.Ext(inputPath))
if expectedContentType == "" {
t.Fatal("expected built-in HTML content type")
}
reader, err := NewRestrictedArtifactReader(root, 0)
if err != nil {
t.Fatalf("construct restricted reader: %v", err)
}
for _, ref := range []promptkit.ArtifactRef{
{Type: promptkit.ArtifactRefFile, URI: "nested/../input.html"},
{Type: promptkit.ArtifactRefFile, URI: inputPath},
} {
artifact, err := reader.Read(context.Background(), ref)
if err != nil {
t.Fatalf("read contained path %q: %v", ref.URI, err)
}
if artifact.Name != "input.html" || artifact.URI != inputPath || artifact.Size != int64(len("allowed")) || string(artifact.Body) != "allowed" {
t.Fatalf("unexpected artifact metadata: %#v", artifact)
}
if artifact.ContentType != expectedContentType {
t.Fatalf("unexpected artifact content type: got %q, want %q", artifact.ContentType, expectedContentType)
}
if artifact.Hash != artifactHash([]byte("allowed")) {
t.Fatalf("unexpected artifact hash: %q", artifact.Hash)
}
}
artifact, err := reader.Read(context.Background(), promptkit.File("input.unknown"))
if err != nil {
t.Fatalf("read unknown-extension path: %v", err)
}
if artifact.ContentType != fallbackArtifactContentType {
t.Fatalf("unexpected fallback content type: %q", artifact.ContentType)
}
for _, ref := range []promptkit.ArtifactRef{
{Type: promptkit.ArtifactRefFile, URI: filepath.Join("..", filepath.Base(outside), "secret.txt")},
{Type: promptkit.ArtifactRefFile, URI: filepath.Join(outside, "secret.txt")},
} {
_, err := reader.Read(context.Background(), ref)
if !errors.Is(err, ErrFileOutsideRoot) {
t.Fatalf("expected ErrFileOutsideRoot for %q, got %v", ref.URI, err)
}
}
}
func TestRestrictedArtifactReaderFollowsSymlinkAfterLexicalCheck(t *testing.T) {
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)
}
if err := os.Symlink(target, filepath.Join(root, "linked.txt")); err != nil {
t.Skipf("symlink creation unavailable: %v", err)
}
reader, err := NewRestrictedArtifactReader(root, 0)
if err != nil {
t.Fatalf("construct restricted reader: %v", err)
}
artifact, err := reader.Read(context.Background(), promptkit.File("linked.txt"))
if err != nil {
t.Fatalf("read symlink inside root: %v", err)
}
if string(artifact.Body) != "linked outside root" {
t.Fatalf("unexpected symlink artifact body: %q", artifact.Body)
}
}
func TestRestrictedArtifactReaderWithoutRootDeniesFiles(t *testing.T) {
reader, err := NewRestrictedArtifactReader("", 0)
if err != nil {
t.Fatalf("construct rootless reader: %v", err)
}
artifact, err := reader.Read(context.Background(), promptkit.Inline("inline"))
if err != nil {
t.Fatalf("read inline artifact: %v", err)
}
if artifact.ContentType != fallbackArtifactContentType || string(artifact.Body) != "inline" || artifact.Hash != artifactHash([]byte("inline")) {
t.Fatalf("unexpected inline artifact: %#v", artifact)
}
_, err = reader.Read(context.Background(), promptkit.File("input.txt"))
if !errors.Is(err, ErrFileNotAllowed) {
t.Fatalf("expected ErrFileNotAllowed, got %v", err)
}
}
func TestRestrictedArtifactReaderEnforcesLimits(t *testing.T) {
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 := NewRestrictedArtifactReader(root, 5)
if err != nil {
t.Fatalf("construct limited reader: %v", err)
}
artifact, err := reader.Read(context.Background(), promptkit.File("exact.txt"))
if err != nil || string(artifact.Body) != "12345" {
t.Fatalf("expected exact-limit artifact, got %#v and %v", artifact, err)
}
_, err = reader.Read(context.Background(), promptkit.File("large.txt"))
if !errors.Is(err, ErrFileTooLarge) {
t.Fatalf("expected ErrFileTooLarge, got %v", err)
}
unlimited, err := NewRestrictedArtifactReader(root, 0)
if err != nil {
t.Fatalf("construct unlimited reader: %v", err)
}
artifact, err = unlimited.Read(context.Background(), promptkit.File("large.txt"))
if err != nil || string(artifact.Body) != "123456" {
t.Fatalf("expected unlimited artifact, got %#v and %v", artifact, err)
}
if _, err := NewRestrictedArtifactReader(root, -1); err == nil {
t.Fatal("expected negative limit to fail")
}
}
func TestRestrictedArtifactReaderRejectsCanceledAndMalformedReferences(t *testing.T) {
reader, err := NewRestrictedArtifactReader(t.TempDir(), 0)
if err != nil {
t.Fatalf("construct reader: %v", err)
}
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
for _, ref := range []promptkit.ArtifactRef{
promptkit.Inline("input"),
promptkit.File("input.txt"),
} {
_, err := reader.Read(canceledCtx, ref)
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected cancellation for %#v, got %v", ref, err)
}
}
for _, ref := range []promptkit.ArtifactRef{
{Type: promptkit.ArtifactRefType("unsupported")},
{Type: promptkit.ArtifactRefInline},
{Type: promptkit.ArtifactRefFile},
} {
if _, err := reader.Read(context.Background(), ref); err == nil {
t.Fatalf("expected malformed reference %#v to fail", ref)
}
}
}

View File

@@ -8,16 +8,12 @@ import (
"net/http" "net/http"
"strings" "strings"
"gitea.maximumdirect.net/eric/scriptorium/internal/artifact" "gitea.maximumdirect.net/eric/promptkit"
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults" "gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
"gitea.maximumdirect.net/eric/scriptorium/internal/usecase"
) )
type Runner interface { type Runner interface {
Run(ctx context.Context, req domain.RunRequest) (*domain.RunResult, error) Run(ctx context.Context, req promptkit.RunRequest) (*promptkit.RunResult, error)
} }
type Handler struct { type Handler struct {
@@ -85,21 +81,21 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
mappedInputs := make(map[string]domain.ArtifactRef, len(req.Inputs)) mappedInputs := make(map[string]promptkit.ArtifactRef, len(req.Inputs))
for name, in := range req.Inputs { for name, in := range req.Inputs {
mappedInputs[name] = domain.ArtifactRef{ mappedInputs[name] = promptkit.ArtifactRef{
Type: domain.ArtifactRefType(in.Type), Type: promptkit.ArtifactRefType(in.Type),
URI: in.URI, URI: in.URI,
Body: in.Body, Body: in.Body,
} }
} }
var model *domain.ExecutionTargetOverride var model *promptkit.ExecutionTargetOverride
if req.Model != nil { if req.Model != nil {
model = executionTargetOverrideFromModelOverrideDTO(req.Model) model = executionTargetOverrideFromModelOverrideDTO(req.Model)
} }
res, err := h.runner.Run(r.Context(), domain.RunRequest{ res, err := h.runner.Run(r.Context(), promptkit.RunRequest{
PromptID: req.PromptID, PromptID: req.PromptID,
PromptVersion: req.PromptVersion, PromptVersion: req.PromptVersion,
ProfileID: req.ProfileID, ProfileID: req.ProfileID,
@@ -156,11 +152,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
writeLimitedJSON(w, http.StatusOK, resp, h.options.MaxResponseBytes) writeLimitedJSON(w, http.StatusOK, resp, h.options.MaxResponseBytes)
} }
func executionTargetOverrideFromModelOverrideDTO(dto *modelOverrideRequestDTO) *domain.ExecutionTargetOverride { func executionTargetOverrideFromModelOverrideDTO(dto *modelOverrideRequestDTO) *promptkit.ExecutionTargetOverride {
if dto == nil { if dto == nil {
return nil return nil
} }
return &domain.ExecutionTargetOverride{ return &promptkit.ExecutionTargetOverride{
Endpoint: dto.Endpoint, Endpoint: dto.Endpoint,
Model: dto.Model, Model: dto.Model,
Temperature: dto.Temperature, Temperature: dto.Temperature,
@@ -174,7 +170,7 @@ func executionTargetOverrideFromModelOverrideDTO(dto *modelOverrideRequestDTO) *
} }
} }
func modelParamsDTOFromExecutionTarget(target domain.ExecutionTarget) modelParamsDTO { func modelParamsDTOFromExecutionTarget(target promptkit.ExecutionTarget) modelParamsDTO {
return modelParamsDTO{ return modelParamsDTO{
Endpoint: target.Endpoint, Endpoint: target.Endpoint,
Model: target.Model, Model: target.Model,
@@ -189,7 +185,7 @@ func modelParamsDTOFromExecutionTarget(target domain.ExecutionTarget) modelParam
} }
} }
func mapValidation(v domain.ValidationResult) validationDTO { func mapValidation(v promptkit.ValidationResult) validationDTO {
return validationDTO{ return validationDTO{
Status: string(v.Status), Status: string(v.Status),
Mode: string(v.Mode), Mode: string(v.Mode),
@@ -202,35 +198,31 @@ func mapValidation(v domain.ValidationResult) validationDTO {
func mapRunError(err error) (int, string, string) { func mapRunError(err error) (int, string, string) {
switch { switch {
case errors.Is(err, promptdef.ErrPromptDefinitionNotFound): case errors.Is(err, promptkit.ErrPromptNotFound):
return http.StatusNotFound, "prompt_not_found", "prompt definition not found" return http.StatusNotFound, "prompt_not_found", "prompt definition not found"
case errors.Is(err, profile.ErrProfileNotFound): case errors.Is(err, promptkit.ErrProfileNotFound):
return http.StatusNotFound, "profile_not_found", "execution profile not found" return http.StatusNotFound, "profile_not_found", "execution profile not found"
case errors.Is(err, promptdef.ErrInvalidYAML), errors.Is(err, promptdef.ErrInvalidPromptDefinition): case errors.Is(err, promptkit.ErrProfileRequired):
return http.StatusBadRequest, "prompt_load_failed", "failed to load prompt definition"
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" return http.StatusBadRequest, "profile_required", "profile_id is required when prompt default_profile is not set"
case errors.Is(err, usecase.ErrAPIKeyEnvMissing): case errors.Is(err, promptkit.ErrAPIKeyEnvMissing):
return http.StatusBadRequest, "api_key_env_missing", "api_key_env is set but the environment variable is missing" return http.StatusBadRequest, "api_key_env_missing", "api_key_env is set but the environment variable is missing"
case errors.Is(err, usecase.ErrInvalidRequest): case errors.Is(err, promptkit.ErrPromptLoad):
return http.StatusBadRequest, "invalid_request", "invalid run request"
case errors.Is(err, usecase.ErrPromptLoad):
return http.StatusBadRequest, "prompt_load_failed", "failed to load prompt definition" return http.StatusBadRequest, "prompt_load_failed", "failed to load prompt definition"
case errors.Is(err, usecase.ErrProfileLoad): case errors.Is(err, promptkit.ErrProfileLoad):
return http.StatusBadRequest, "profile_load_failed", "failed to load execution profile" return http.StatusBadRequest, "profile_load_failed", "failed to load execution profile"
case errors.Is(err, artifact.ErrFileNotAllowed), errors.Is(err, artifact.ErrFileOutsideRoot): case errors.Is(err, promptkit.ErrInvalidRequest):
return http.StatusBadRequest, "invalid_request", "invalid run request"
case errors.Is(err, ErrFileNotAllowed), errors.Is(err, ErrFileOutsideRoot):
return http.StatusBadRequest, "artifact_not_allowed", "file input artifact is not allowed" return http.StatusBadRequest, "artifact_not_allowed", "file input artifact is not allowed"
case errors.Is(err, artifact.ErrFileTooLarge): case errors.Is(err, ErrFileTooLarge):
return http.StatusRequestEntityTooLarge, "artifact_too_large", "file input artifact is too large" return http.StatusRequestEntityTooLarge, "artifact_too_large", "file input artifact is too large"
case errors.Is(err, usecase.ErrArtifactLoad): case errors.Is(err, promptkit.ErrArtifactLoad):
return http.StatusBadRequest, "artifact_read_failed", "failed to read input artifact" return http.StatusBadRequest, "artifact_read_failed", "failed to read input artifact"
case errors.Is(err, usecase.ErrPromptRender): case errors.Is(err, promptkit.ErrPromptRender):
return http.StatusBadRequest, "prompt_render_failed", "failed to render prompt" return http.StatusBadRequest, "prompt_render_failed", "failed to render prompt"
case errors.Is(err, usecase.ErrLLMGenerate): case errors.Is(err, promptkit.ErrLLMGenerate):
return http.StatusBadGateway, "llm_failed", "model generation request failed" return http.StatusBadGateway, "llm_failed", "model generation request failed"
case errors.Is(err, usecase.ErrValidation): case errors.Is(err, promptkit.ErrValidation):
return http.StatusInternalServerError, "validation_runtime_failed", "validation runtime failed" return http.StatusInternalServerError, "validation_runtime_failed", "validation runtime failed"
default: default:
return http.StatusInternalServerError, "internal_error", "internal server error" return http.StatusInternalServerError, "internal_error", "internal server error"

View File

@@ -14,21 +14,16 @@ import (
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/scriptorium/internal/artifact" "gitea.maximumdirect.net/eric/promptkit"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"gitea.maximumdirect.net/eric/scriptorium/internal/llm"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
"gitea.maximumdirect.net/eric/scriptorium/internal/usecase"
) )
type fakeRunner struct { type fakeRunner struct {
result *domain.RunResult result *promptkit.RunResult
err error err error
last domain.RunRequest last promptkit.RunRequest
} }
func (f *fakeRunner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunResult, error) { func (f *fakeRunner) Run(ctx context.Context, req promptkit.RunRequest) (*promptkit.RunResult, error) {
f.last = req f.last = req
if f.err != nil { if f.err != nil {
return nil, f.err return nil, f.err
@@ -42,7 +37,7 @@ func TestMaintainedHTTPRunExampleMatchesRequestContract(t *testing.T) {
t.Fatalf("read maintained HTTP request example: %v", err) t.Fatalf("read maintained HTTP request example: %v", err)
} }
runner := &fakeRunner{result: &domain.RunResult{}} runner := &fakeRunner{result: &promptkit.RunResult{}}
h := NewHandler(runner) h := NewHandler(runner)
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewReader(body))
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -68,38 +63,10 @@ func TestMaintainedHTTPRunExampleMatchesRequestContract(t *testing.T) {
assertHTTPErrorCode(t, invalidW, http.StatusBadRequest, "invalid_json") assertHTTPErrorCode(t, invalidW, http.StatusBadRequest, "invalid_json")
} }
type handlerPromptRepo struct {
def *domain.PromptDefinition
}
func (r handlerPromptRepo) GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error) {
return r.def, nil
}
type handlerProfileRepo struct {
profile *domain.ExecutionProfile
}
func (r handlerProfileRepo) GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error) {
return r.profile, nil
}
type handlerArtifactReader struct{}
func (handlerArtifactReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
return &domain.Artifact{Name: "input", Body: []byte("input"), Hash: "hash"}, nil
}
type handlerRenderer struct{}
func (handlerRenderer) Render(ctx context.Context, definition *domain.PromptDefinition, inputs map[string]*domain.Artifact, vars map[string]string) (*domain.RenderedPrompt, error) {
return &domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}}, nil
}
type handlerLLMClient struct{} type handlerLLMClient struct{}
func (handlerLLMClient) Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) { func (handlerLLMClient) Generate(ctx context.Context, req promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
return &domain.GenerateResponse{Content: "ok"}, nil return &promptkit.GenerateResponse{Content: "ok"}, nil
} }
func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) { func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
@@ -108,16 +75,16 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
const envName = "SCRIPTORIUM_API_KEY" const envName = "SCRIPTORIUM_API_KEY"
const secret = "never-include-me" const secret = "never-include-me"
r := &fakeRunner{result: &domain.RunResult{ r := &fakeRunner{result: &promptkit.RunResult{
RunID: "11111111-1111-4111-8111-111111111111", RunID: "11111111-1111-4111-8111-111111111111",
Artifact: domain.Artifact{ Artifact: promptkit.Artifact{
Name: "output", Name: "output",
ContentType: "text/plain", ContentType: "text/plain",
Body: []byte("hello"), Body: []byte("hello"),
Size: 5, Size: 5,
Hash: "abc", Hash: "abc",
}, },
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
PromptID: "prompt-1", PromptID: "prompt-1",
PromptVersion: "1.0.0", PromptVersion: "1.0.0",
PromptHash: "phash", PromptHash: "phash",
@@ -125,7 +92,7 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
SelectedProfileID: "exec-default", SelectedProfileID: "exec-default",
ModelName: "m1", ModelName: "m1",
Endpoint: "http://llm/v1", Endpoint: "http://llm/v1",
EffectiveModelParams: domain.ExecutionTarget{ EffectiveModelParams: promptkit.ExecutionTarget{
Endpoint: "http://llm/v1", Endpoint: "http://llm/v1",
Model: "m1", Model: "m1",
Temperature: 0.2, Temperature: 0.2,
@@ -136,7 +103,7 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
APIKeyEnv: envName, APIKeyEnv: envName,
}, },
InputHashes: map[string]string{"transcript": "h1"}, InputHashes: map[string]string{"transcript": "h1"},
Usage: domain.TokenUsage{ Usage: promptkit.TokenUsage{
PromptTokens: 1, PromptTokens: 1,
CompletionTokens: 2, CompletionTokens: 2,
TotalTokens: 3, TotalTokens: 3,
@@ -325,13 +292,13 @@ func TestHandlerFileRefsOutsideArtifactRootAreRejected(t *testing.T) {
} }
func TestHandlerPostRunsSuccessUsingPromptDefaultProfile(t *testing.T) { func TestHandlerPostRunsSuccessUsingPromptDefaultProfile(t *testing.T) {
r := &fakeRunner{result: &domain.RunResult{ r := &fakeRunner{result: &promptkit.RunResult{
Artifact: domain.Artifact{Body: []byte("ok")}, Artifact: promptkit.Artifact{Body: []byte("ok")},
PromptID: "prompt-1", PromptID: "prompt-1",
PromptVersion: "1.0.0", PromptVersion: "1.0.0",
SelectedProfileID: "prompt-default", SelectedProfileID: "prompt-default",
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"}, EffectiveModelParams: promptkit.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
}} }}
h := NewHandler(r) h := NewHandler(r)
@@ -360,10 +327,10 @@ func TestHandlerPostRunsSuccessUsingPromptDefaultProfile(t *testing.T) {
} }
func TestHandlerModelOverrideMapsAllSupportedExecutionFields(t *testing.T) { func TestHandlerModelOverrideMapsAllSupportedExecutionFields(t *testing.T) {
r := &fakeRunner{result: &domain.RunResult{ r := &fakeRunner{result: &promptkit.RunResult{
Artifact: domain.Artifact{Body: []byte("ok")}, Artifact: promptkit.Artifact{Body: []byte("ok")},
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"}, EffectiveModelParams: promptkit.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
}} }}
h := NewHandler(r) h := NewHandler(r)
@@ -420,10 +387,10 @@ func TestHandlerModelOverrideMapsAllSupportedExecutionFields(t *testing.T) {
} }
func TestHandlerModelOverrideAcceptsJSONCompatibleExtraParams(t *testing.T) { func TestHandlerModelOverrideAcceptsJSONCompatibleExtraParams(t *testing.T) {
r := &fakeRunner{result: &domain.RunResult{ r := &fakeRunner{result: &promptkit.RunResult{
Artifact: domain.Artifact{Body: []byte("ok")}, Artifact: promptkit.Artifact{Body: []byte("ok")},
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"}, EffectiveModelParams: promptkit.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
}} }}
h := NewHandler(r) h := NewHandler(r)
@@ -464,10 +431,10 @@ func TestHandlerModelOverrideAcceptsJSONCompatibleExtraParams(t *testing.T) {
} }
func TestHandlerModelOverrideExplicitZeroTemperatureMapsAsPresent(t *testing.T) { func TestHandlerModelOverrideExplicitZeroTemperatureMapsAsPresent(t *testing.T) {
r := &fakeRunner{result: &domain.RunResult{ r := &fakeRunner{result: &promptkit.RunResult{
Artifact: domain.Artifact{Body: []byte("ok")}, Artifact: promptkit.Artifact{Body: []byte("ok")},
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0}, EffectiveModelParams: promptkit.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0},
}} }}
h := NewHandler(r) h := NewHandler(r)
@@ -491,10 +458,10 @@ func TestHandlerModelOverrideExplicitZeroTemperatureMapsAsPresent(t *testing.T)
} }
func TestHandlerModelOverrideOmittedTemperatureMapsAsAbsent(t *testing.T) { func TestHandlerModelOverrideOmittedTemperatureMapsAsAbsent(t *testing.T) {
r := &fakeRunner{result: &domain.RunResult{ r := &fakeRunner{result: &promptkit.RunResult{
Artifact: domain.Artifact{Body: []byte("ok")}, Artifact: promptkit.Artifact{Body: []byte("ok")},
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0.7}, EffectiveModelParams: promptkit.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0.7},
}} }}
h := NewHandler(r) h := NewHandler(r)
@@ -528,16 +495,16 @@ func TestHandlerModelOverrideOmittedTemperatureMapsAsAbsent(t *testing.T) {
} }
func TestHandlerResponseMetadataModelParamsIncludesAllSupportedFields(t *testing.T) { func TestHandlerResponseMetadataModelParamsIncludesAllSupportedFields(t *testing.T) {
r := &fakeRunner{result: &domain.RunResult{ r := &fakeRunner{result: &promptkit.RunResult{
Artifact: domain.Artifact{ Artifact: promptkit.Artifact{
Name: "output", Name: "output",
ContentType: "text/plain", ContentType: "text/plain",
Body: []byte("ok"), Body: []byte("ok"),
Size: 2, Size: 2,
Hash: "abc", Hash: "abc",
}, },
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
EffectiveModelParams: domain.ExecutionTarget{ EffectiveModelParams: promptkit.ExecutionTarget{
Endpoint: "http://llm/v1", Endpoint: "http://llm/v1",
Model: "gpt-test", Model: "gpt-test",
Temperature: 0.4, Temperature: 0.4,
@@ -657,10 +624,10 @@ func TestHandlerMalformedJSONBelowLimitStillBadRequest(t *testing.T) {
} }
func TestHandlerResponseTooLarge(t *testing.T) { func TestHandlerResponseTooLarge(t *testing.T) {
h := NewHandlerWithOptions(&fakeRunner{result: &domain.RunResult{ h := NewHandlerWithOptions(&fakeRunner{result: &promptkit.RunResult{
Artifact: domain.Artifact{Body: []byte(strings.Repeat("x", 128))}, Artifact: promptkit.Artifact{Body: []byte(strings.Repeat("x", 128))},
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"}, EffectiveModelParams: promptkit.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
}}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 64}) }}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 64})
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`)) req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`))
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -671,11 +638,11 @@ func TestHandlerResponseTooLarge(t *testing.T) {
} }
func TestHandlerRawOutputDoesNotBypassResponseLimit(t *testing.T) { func TestHandlerRawOutputDoesNotBypassResponseLimit(t *testing.T) {
h := NewHandlerWithOptions(&fakeRunner{result: &domain.RunResult{ h := NewHandlerWithOptions(&fakeRunner{result: &promptkit.RunResult{
Artifact: domain.Artifact{Body: []byte("ok")}, Artifact: promptkit.Artifact{Body: []byte("ok")},
RawOutput: strings.Repeat("raw", 80), RawOutput: strings.Repeat("raw", 80),
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"}, EffectiveModelParams: promptkit.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
}}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 128}) }}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 128})
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{ req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
"prompt_id":"p", "prompt_id":"p",
@@ -709,35 +676,12 @@ func TestHandlerMissingPromptID(t *testing.T) {
} }
} }
func TestHandlerReservedExtraParamsThroughRunnerMapsToInvalidRequest(t *testing.T) { func TestHandlerReservedExtraParamsThroughEngineMapsToInvalidRequest(t *testing.T) {
llmClient, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{}) h := NewHandler(newHandlerEngineWithDefaultClient(t))
if err != nil {
t.Fatal(err)
}
runner := usecase.NewRunner(
handlerPromptRepo{def: &domain.PromptDefinition{
ID: "p",
Version: "1",
DefaultProfile: "exec",
Templates: []domain.PromptMessageTemplate{{Role: "user", Content: "hi"}},
OutputFormat: domain.FormatText,
Validation: domain.OutputContract{Format: domain.FormatText, ValidationMode: domain.ValidationNone},
}},
handlerProfileRepo{profile: &domain.ExecutionProfile{
ID: "exec",
Endpoint: "http://example.invalid/v1",
Model: "model",
}},
handlerArtifactReader{},
handlerRenderer{},
llmClient,
nil,
)
h := NewHandler(runner)
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{ req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
"prompt_id":"p", "prompt_id":"p",
"inputs":{"x":{"type":"file","uri":"a"}}, "inputs":{"x":{"type":"inline","body":"input"}},
"model":{"extra_params":{"model":"collision"}} "model":{"extra_params":{"model":"collision"}}
}`)) }`))
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -757,7 +701,7 @@ func TestHandlerReservedExtraParamsThroughRunnerMapsToInvalidRequest(t *testing.
} }
} }
func TestHandlerUsecaseErrorMapping(t *testing.T) { func TestHandlerPublicErrorMapping(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
err error err error
@@ -766,18 +710,20 @@ func TestHandlerUsecaseErrorMapping(t *testing.T) {
message string message string
avoidCause string avoidCause string
}{ }{
{name: "prompt not found", err: wrap(usecase.ErrPromptLoad, promptdef.ErrPromptDefinitionNotFound), status: http.StatusNotFound, code: "prompt_not_found", message: "prompt definition not found"}, {name: "prompt not found", err: promptkit.ErrPromptNotFound, 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", err: wrap(promptkit.ErrPromptLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "prompt_load_failed", message: "failed to load prompt definition", avoidCause: "read failed"},
{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(promptkit.ErrProfileRequired, promptkit.ErrInvalidRequest), status: http.StatusBadRequest, code: "profile_required", message: "profile_id is required when prompt default_profile is not set"},
{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: promptkit.ErrProfileNotFound, status: http.StatusNotFound, code: "profile_not_found", message: "execution profile not found"},
{name: "profile not found", err: wrap(usecase.ErrProfileLoad, profile.ErrProfileNotFound), status: http.StatusNotFound, code: "profile_not_found", message: "execution profile not found"}, {name: "profile load", err: wrap(promptkit.ErrProfileLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "profile_load_failed", message: "failed to load execution profile", avoidCause: "read failed"},
{name: "profile invalid", err: wrap(usecase.ErrProfileLoad, profile.ErrInvalidProfile), status: http.StatusBadRequest, code: "profile_load_failed", message: "failed to load execution profile"}, {name: "api key env missing", err: wrap(promptkit.ErrAPIKeyEnvMissing, promptkit.ErrInvalidRequest), status: http.StatusBadRequest, code: "api_key_env_missing", message: "api_key_env is set but the environment variable is missing"},
{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: "invalid request", err: promptkit.ErrInvalidRequest, status: http.StatusBadRequest, code: "invalid_request", message: "invalid run request"},
{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: "file denied", err: ErrFileNotAllowed, status: http.StatusBadRequest, code: "artifact_not_allowed", message: "file input artifact is not allowed"},
{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: "file outside root", err: ErrFileOutsideRoot, status: http.StatusBadRequest, code: "artifact_not_allowed", message: "file input artifact is not allowed"},
{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"}, {name: "file too large", err: ErrFileTooLarge, status: http.StatusRequestEntityTooLarge, code: "artifact_too_large", message: "file input artifact is too large"},
{name: "llm", err: wrap(usecase.ErrLLMGenerate, fmt.Errorf("llm failed")), status: http.StatusBadGateway, code: "llm_failed", message: "model generation request failed", avoidCause: "llm failed"}, {name: "artifact", err: wrap(promptkit.ErrArtifactLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "artifact_read_failed", message: "failed to read input artifact", avoidCause: "read failed"},
{name: "validation runtime", err: wrap(usecase.ErrValidation, fmt.Errorf("validator broke")), status: http.StatusInternalServerError, code: "validation_runtime_failed", message: "validation runtime failed", avoidCause: "validator broke"}, {name: "prompt render", err: wrap(promptkit.ErrPromptRender, fmt.Errorf("render failed")), status: http.StatusBadRequest, code: "prompt_render_failed", message: "failed to render prompt", avoidCause: "render failed"},
{name: "llm", err: wrap(promptkit.ErrLLMGenerate, fmt.Errorf("llm failed")), status: http.StatusBadGateway, code: "llm_failed", message: "model generation request failed", avoidCause: "llm failed"},
{name: "validation runtime", err: wrap(promptkit.ErrValidation, fmt.Errorf("validator broke")), status: http.StatusInternalServerError, code: "validation_runtime_failed", message: "validation runtime failed", avoidCause: "validator broke"},
} }
for _, tc := range tests { for _, tc := range tests {
@@ -830,12 +776,12 @@ func TestHandlerRawAPIKeyRejectedByStrictJSON(t *testing.T) {
} }
func TestHandlerValidationFailureStillSuccessAndRawOutputOptIn(t *testing.T) { func TestHandlerValidationFailureStillSuccessAndRawOutputOptIn(t *testing.T) {
h := NewHandler(&fakeRunner{result: &domain.RunResult{ h := NewHandler(&fakeRunner{result: &promptkit.RunResult{
Artifact: domain.Artifact{Body: []byte("bad json")}, Artifact: promptkit.Artifact{Body: []byte("bad json")},
RawOutput: "bad json", RawOutput: "bad json",
Validation: domain.ValidationResult{ Validation: promptkit.ValidationResult{
Status: domain.ValidationFailed, Status: promptkit.ValidationFailed,
Mode: domain.ValidationJSON, Mode: promptkit.ValidationJSON,
Errors: []string{"invalid JSON"}, Errors: []string{"invalid JSON"},
}, },
}}) }})
@@ -889,30 +835,58 @@ func newArtifactRootHandler(t *testing.T, root string) *Handler {
func newArtifactRootHandlerWithLimit(t *testing.T, root string, maxArtifactBytes int64) *Handler { func newArtifactRootHandlerWithLimit(t *testing.T, root string, maxArtifactBytes int64) *Handler {
t.Helper() t.Helper()
reader, err := artifact.NewRestrictedCompositeReaderWithLimit(root, maxArtifactBytes) reader, err := NewRestrictedArtifactReader(root, maxArtifactBytes)
if err != nil { if err != nil {
t.Fatalf("expected restricted artifact reader: %v", err) t.Fatalf("expected restricted artifact reader: %v", err)
} }
runner := usecase.NewRunner( return NewHandler(newHandlerEngine(t, promptkit.WithArtifactReader(reader)))
handlerPromptRepo{def: &domain.PromptDefinition{ }
ID: "p",
Version: "1", func newHandlerEngine(t *testing.T, options ...promptkit.Option) *promptkit.Engine {
DefaultProfile: "exec", t.Helper()
Templates: []domain.PromptMessageTemplate{{Role: "user", Content: "hi"}},
OutputFormat: domain.FormatText, return newHandlerEngineWithOptions(t, append(options, promptkit.WithLLMClient(handlerLLMClient{}))...)
Validation: domain.OutputContract{Format: domain.FormatText, ValidationMode: domain.ValidationNone}, }
}},
handlerProfileRepo{profile: &domain.ExecutionProfile{ func newHandlerEngineWithDefaultClient(t *testing.T, options ...promptkit.Option) *promptkit.Engine {
ID: "exec", t.Helper()
Endpoint: "http://example.invalid/v1",
Model: "model", return newHandlerEngineWithOptions(t, options...)
}}, }
reader,
handlerRenderer{}, func newHandlerEngineWithOptions(t *testing.T, options ...promptkit.Option) *promptkit.Engine {
handlerLLMClient{}, t.Helper()
nil,
) promptDir := t.TempDir()
return NewHandler(runner) profileDir := t.TempDir()
if err := os.WriteFile(filepath.Join(promptDir, "prompt.yaml"), []byte(`id: p
version: "1"
default_profile: exec
messages:
- role: user
content: "hi"
output:
format: text
validation_mode: none
repair_attempts: 0
`), 0o644); err != nil {
t.Fatalf("write prompt fixture: %v", err)
}
if err := os.WriteFile(filepath.Join(profileDir, "profile.yaml"), []byte(`id: exec
endpoint: http://example.invalid/v1
model: model
`), 0o644); err != nil {
t.Fatalf("write profile fixture: %v", err)
}
engine, err := promptkit.NewEngine(promptkit.Config{
PromptDir: promptDir,
ProfileDir: profileDir,
}, options...)
if err != nil {
t.Fatalf("construct public engine: %v", err)
}
return engine
} }
func assertHTTPErrorCode(t *testing.T, w *httptest.ResponseRecorder, status int, code string) { func assertHTTPErrorCode(t *testing.T, w *httptest.ResponseRecorder, status int, code string) {

View File

@@ -1,242 +0,0 @@
package artifact
import (
"context"
"crypto/sha256"
"errors"
"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.
type Reader interface {
Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error)
}
// CompositeReader routes artifact resolution based on the reference type.
type CompositeReader struct {
inlineReader *inlineReader
fileReader Reader
}
func NewCompositeReader() Reader {
return &CompositeReader{
inlineReader: &inlineReader{},
fileReader: &fileReader{},
}
}
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():
return nil, ctx.Err()
default:
}
switch ref.Type {
case domain.ArtifactRefInline:
return c.inlineReader.Read(ctx, ref)
case domain.ArtifactRefFile:
return c.fileReader.Read(ctx, ref)
default:
return nil, fmt.Errorf("%w: %s", ErrUnsupportedRefType, ref.Type)
}
}
type inlineReader struct{}
func (r *inlineReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if ref.Body == "" {
return nil, ErrMissingInlineBody
}
body := []byte(ref.Body)
return &domain.Artifact{
ContentType: defaults.ContentTypeTextPlain,
Body: body,
Size: int64(len(body)),
Hash: fmt.Sprintf("%x", sha256.Sum256(body)),
URI: ref.URI,
}, nil
}
type fileReader struct{}
func (r *fileReader) 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
}
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:
}
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(path),
ContentType: contentType,
Body: data,
URI: path,
Size: int64(len(data)),
Hash: fmt.Sprintf("%x", sha256.Sum256(data)),
}, nil
}

View File

@@ -1,257 +0,0 @@
package artifact
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
func TestCompositeReader_Read(t *testing.T) {
reader := NewCompositeReader()
ctx := context.Background()
t.Run("inline artifact", func(t *testing.T) {
ref := domain.ArtifactRef{
Type: domain.ArtifactRefInline,
Body: "hello world",
}
art, err := reader.Read(ctx, ref)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(art.Body) != "hello world" {
t.Errorf("expected 'hello world', got %s", string(art.Body))
}
if art.ContentType != "text/plain" {
t.Errorf("expected text/plain content type, got %q", art.ContentType)
}
if art.Hash != "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" {
t.Errorf("unexpected hash: %s", art.Hash)
}
})
t.Run("inline artifact missing body", func(t *testing.T) {
ref := domain.ArtifactRef{
Type: domain.ArtifactRefInline,
Body: "",
}
_, err := reader.Read(ctx, ref)
if !errors.Is(err, ErrMissingInlineBody) {
t.Errorf("expected ErrMissingInlineBody, got %v", err)
}
})
t.Run("unsupported ref type", func(t *testing.T) {
ref := domain.ArtifactRef{
Type: domain.ArtifactRefType("unsupported"),
URI: "unsupported://bucket/key",
}
_, err := reader.Read(ctx, ref)
if !errors.Is(err, ErrUnsupportedRefType) {
t.Error("expected error for unsupported type")
}
})
}
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")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.Write(content); err != nil {
t.Fatal(err)
}
tmpFile.Close()
reader := NewCompositeReader()
ctx := context.Background()
t.Run("file artifact loading", func(t *testing.T) {
ref := domain.ArtifactRef{
Type: domain.ArtifactRefFile,
URI: tmpFile.Name(),
}
art, err := reader.Read(ctx, ref)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(art.Body) != string(content) {
t.Errorf("expected %s, got %s", string(content), string(art.Body))
}
if art.Name == "" {
t.Error("expected name to be inferred from filename")
}
if art.Hash != "60f5237ed4049f0382661ef009d2bc42e48c3ceb3edb6600f7024e7ab3b838f3" {
t.Errorf("unexpected hash: %s", art.Hash)
}
})
t.Run("missing file path", func(t *testing.T) {
ref := domain.ArtifactRef{
Type: domain.ArtifactRefFile,
URI: "",
}
_, err := reader.Read(ctx, ref)
if !errors.Is(err, ErrMissingFilePath) {
t.Errorf("expected ErrMissingFilePath, got %v", err)
}
})
}

View File

@@ -1,39 +1,13 @@
package defaults package defaults
import ( import "time"
"time"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
const ( const (
HTTPAddrDefault = ":8080" HTTPAddrDefault = ":8080"
SchemaDirDefault = "." SchemaDirDefault = "."
OutputArtifactName = "output"
ContentTypeTextPlain = "text/plain"
ContentTypeTextMarkdown = "text/markdown"
ContentTypeApplicationJSON = "application/json"
OpenAIChatCompletionsPath = "/chat/completions"
HTTPMaxRequestBytesDefault = 16 * 1024 * 1024 HTTPMaxRequestBytesDefault = 16 * 1024 * 1024
HTTPMaxArtifactBytesDefault = 16 * 1024 * 1024 HTTPMaxArtifactBytesDefault = 16 * 1024 * 1024
HTTPMaxResponseBytesDefault = 16 * 1024 * 1024 HTTPMaxResponseBytesDefault = 16 * 1024 * 1024
ExecutionDefaultTemperature = 0.0
ExecutionDefaultMaxTokens = 0
ExecutionDefaultTopP = 1.0
ExecutionDefaultTimeoutSeconds = 600
) )
var ( var HTTPReadHeaderTimeoutDefault = 10 * time.Second
LLMRequestTimeoutDefault = 10 * time.Minute
HTTPReadHeaderTimeoutDefault = 10 * time.Second
)
func ExecutionTargetDefault() domain.ExecutionTarget {
return domain.ExecutionTarget{
Temperature: ExecutionDefaultTemperature,
MaxTokens: ExecutionDefaultMaxTokens,
TopP: ExecutionDefaultTopP,
TimeoutSeconds: ExecutionDefaultTimeoutSeconds,
}
}

View File

@@ -1,288 +0,0 @@
package domain
import (
"time"
)
// ArtifactRefType defines how an artifact is referenced.
type ArtifactRefType string
const (
ArtifactRefInline ArtifactRefType = "inline"
ArtifactRefFile ArtifactRefType = "file"
)
// OutputFormat defines the desired format of the generated artifact.
type OutputFormat string
const (
FormatText OutputFormat = "text"
FormatMarkdown OutputFormat = "markdown"
FormatJSON OutputFormat = "json"
)
// ValidationMode defines how the output should be validated.
type ValidationMode string
const (
ValidationNone ValidationMode = "none"
ValidationBasic ValidationMode = "basic"
ValidationJSON ValidationMode = "json"
ValidationJSONSchema ValidationMode = "json_schema"
)
// ValidationStatus defines the result of a validation check.
type ValidationStatus string
const (
ValidationPassed ValidationStatus = "passed"
ValidationFailed ValidationStatus = "failed"
ValidationSkipped ValidationStatus = "skipped"
)
// CacheControlType defines provider cache behavior for prompt content.
type CacheControlType string
const (
CacheControlEphemeral CacheControlType = "ephemeral"
)
const (
// SessionIDMaxLength is OpenRouter's documented maximum session_id length.
SessionIDMaxLength = 256
)
// CacheControl describes provider cache metadata attached to prompt content.
type CacheControl struct {
Type CacheControlType `yaml:"type" json:"type"`
TTL string `yaml:"ttl,omitempty" json:"ttl,omitempty"`
}
// RunRequest represents a request to generate a single artifact.
type RunRequest struct {
PromptID string
PromptVersion string
ProfileID string
APIKey string `json:"-" yaml:"-"`
Inputs map[string]ArtifactRef
Vars map[string]string
Execution *ExecutionTargetOverride
Validation *OutputContract
Metadata map[string]string
}
// RunResult represents the complete result of a prompt execution run.
type RunResult struct {
RunID string
Artifact Artifact
RawOutput string
Validation ValidationResult
PromptID string
PromptVersion string
PromptHash string
RenderedPromptHash string
SelectedProfileID string
ModelName string
Endpoint string
EffectiveModelParams ExecutionTarget
InputHashes map[string]string
Usage TokenUsage
StartTime time.Time
EndTime time.Time
Duration time.Duration
}
// PreparedRun contains pre-LLM execution state from the prepare/render phase.
// It must never include resolved API key values, model output, or validation data.
type PreparedRun struct {
PromptID string `json:"prompt_id"`
PromptVersion string `json:"prompt_version,omitempty"`
PromptHash string `json:"prompt_hash,omitempty"`
SelectedProfileID string `json:"selected_profile_id"`
EffectiveModelParams ExecutionTarget `json:"effective_model_params"`
TargetPresence ExecutionTargetPresence `json:"-"`
OutputContract OutputContract `json:"output_contract"`
StructuredOutput *StructuredOutputSpec `json:"structured_output,omitempty"`
InputHashes map[string]string `json:"input_hashes,omitempty"`
SessionID string `json:"session_id,omitempty"`
RenderedPromptHash string `json:"rendered_prompt_hash"`
Messages []RenderedMessage `json:"messages"`
StartTime time.Time `json:"start_time,omitempty"`
EndTime time.Time `json:"end_time,omitempty"`
DurationMS int64 `json:"duration_ms,omitempty"`
}
// ArtifactRef represents a reference to an input artifact.
type ArtifactRef struct {
Type ArtifactRefType
URI string
Body string // Used for inline
}
// Artifact represents the actual loaded content of a reference.
type Artifact struct {
Name string
ContentType string
Body []byte
URI string
Size int64
Hash string
}
// PromptDefinition represents a configured prompt execution definition.
type PromptDefinition struct {
ID string `yaml:"id"`
Version string `yaml:"version"`
DefaultProfile string `yaml:"default_profile"`
Description string `yaml:"description"`
SessionID string `yaml:"session_id" json:"session_id,omitempty"`
Inputs []PromptInput `yaml:"inputs"`
Templates []PromptMessageTemplate `yaml:"templates"`
OutputFormat OutputFormat `yaml:"output_format"`
Validation OutputContract `yaml:"validation"`
}
// PromptInput describes one named input expected by a prompt definition.
type PromptInput struct {
Name string `yaml:"name"`
Required bool `yaml:"required"`
ContentType string `yaml:"content_type"`
Description string `yaml:"description"`
}
// PromptMessageTemplate defines a template for a chat message.
type PromptMessageTemplate struct {
Role string `yaml:"role"`
Content string `yaml:"content"`
ContentFile string `yaml:"content_file"`
CacheControl *CacheControl `yaml:"cache_control,omitempty" json:"cache_control,omitempty"`
}
// ExecutionProfile describes how and where to execute a model.
type ExecutionProfile struct {
ID string `yaml:"id"`
Endpoint string `yaml:"endpoint"`
Model string `yaml:"model"`
Temperature float64 `yaml:"temperature"`
MaxTokens int `yaml:"max_tokens"`
TopP float64 `yaml:"top_p"`
TimeoutSeconds int `yaml:"timeout_seconds"`
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"`
}
// ExecutionTargetOverride represents per-request runtime setting overrides.
type ExecutionTargetOverride struct {
Endpoint string `json:"endpoint,omitempty"`
Model string `json:"model,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
APIKeyEnv string `json:"api_key_env,omitempty"`
ExtraParams map[string]any `json:"extra_params,omitempty"`
}
// ExecutionTargetPresence tracks which effective runtime fields came from an
// explicit request override even when the resolved value is a zero value.
type ExecutionTargetPresence struct {
Temperature bool
MaxTokens bool
TopP bool
TimeoutSeconds bool
}
// ExecutionTarget represents effective model runtime settings for a run.
type ExecutionTarget struct {
Endpoint string `yaml:"endpoint" json:"endpoint"`
Model string `yaml:"model" json:"model"`
Temperature float64 `yaml:"temperature" json:"temperature"`
MaxTokens int `yaml:"max_tokens" json:"max_tokens"`
TopP float64 `yaml:"top_p" json:"top_p"`
TimeoutSeconds int `yaml:"timeout_seconds" json:"timeout_seconds"`
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"`
}
// OutputContract defines the requirements for the output artifact.
type OutputContract struct {
Format OutputFormat `yaml:"format"`
ValidationMode ValidationMode `yaml:"validation_mode"`
SchemaPath string `yaml:"schema_path"`
RepairAttempts int `yaml:"repair_attempts"`
}
// RenderedPrompt represents the prompt after template application.
type RenderedPrompt struct {
SessionID string `json:"session_id,omitempty"`
Messages []RenderedMessage `json:"messages"`
}
// RenderedMessage is a single message in a rendered prompt.
type RenderedMessage struct {
Role string `json:"role"`
Content string `json:"content"`
CacheControl *CacheControl `json:"cache_control,omitempty"`
}
// GenerateRequest is the internal request passed to the LLM client.
type GenerateRequest struct {
Prompt RenderedPrompt
Target ExecutionTarget
TargetPresence ExecutionTargetPresence
StructuredOutput *StructuredOutputSpec
}
// StructuredOutputType indicates which provider-level output mode is requested.
type StructuredOutputType string
const (
StructuredOutputJSONSchema StructuredOutputType = "json_schema"
)
// StructuredOutputSpec describes provider-level structured output requirements.
type StructuredOutputSpec struct {
Type StructuredOutputType `json:"type"`
JSONSchema *StructuredOutputJSONSpec `json:"json_schema,omitempty"`
}
// StructuredOutputJSONSpec contains json_schema output constraints.
type StructuredOutputJSONSpec struct {
Name string `json:"name"`
Strict bool `json:"strict"`
Schema any `json:"schema"`
}
// GenerateResponse is the response received from the LLM client.
type GenerateResponse struct {
Content string
Usage TokenUsage
}
// TokenUsage tracks token consumption.
type TokenUsage struct {
PromptTokens int
CompletionTokens int
TotalTokens int
CachedTokens int
CacheWriteTokens int
}
// ValidationResult represents the outcome of an output validation.
type ValidationResult struct {
Status ValidationStatus
Mode ValidationMode
Errors []string
SchemaPath string
RepairAttempts int
IsValid bool
}

View File

@@ -1,141 +0,0 @@
package domain
import (
"encoding/json"
"strings"
"testing"
)
func TestPreparedRunJSONDoesNotIncludeSecretValues(t *testing.T) {
const envName = "SCRIPTORIUM_TEST_API_KEY"
const secret = "super-secret-value"
t.Setenv(envName, secret)
prepared := PreparedRun{
PromptID: "prompt.id",
PromptVersion: "v1",
PromptHash: "prompt-hash",
SelectedProfileID: "local-fast",
EffectiveModelParams: ExecutionTarget{
Endpoint: "http://llm/v1",
Model: "gpt-test",
APIKeyEnv: envName,
APIKey: secret,
},
InputHashes: map[string]string{"transcript": "hash-1"},
RenderedPromptHash: "rendered-hash",
Messages: []RenderedMessage{
{Role: "system", Content: "You are helpful."},
{Role: "user", Content: "Summarize this."},
},
}
b, err := json.Marshal(prepared)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
out := string(b)
if strings.Contains(out, secret) {
t.Fatalf("prepared run JSON unexpectedly contains secret value: %s", out)
}
if !strings.Contains(out, `"api_key_env":"`+envName+`"`) {
t.Fatalf("prepared run JSON should include api_key_env name: %s", out)
}
var top map[string]any
if err := json.Unmarshal(b, &top); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
for _, forbidden := range []string{"raw_output", "validation", "artifact"} {
if _, ok := top[forbidden]; ok {
t.Fatalf("prepared run JSON should not include %q", forbidden)
}
}
}
func TestPreparedRunJSONIncludesMessageCacheControlOnlyWhenPresent(t *testing.T) {
prepared := PreparedRun{
PromptID: "prompt.id",
SelectedProfileID: "local-fast",
EffectiveModelParams: ExecutionTarget{
Endpoint: "http://llm/v1",
Model: "gpt-test",
},
RenderedPromptHash: "rendered-hash",
Messages: []RenderedMessage{
{
Role: "system",
Content: "You are helpful.",
CacheControl: &CacheControl{
Type: CacheControlEphemeral,
TTL: "1h",
},
},
{Role: "user", Content: "Summarize this."},
},
}
b, err := json.Marshal(prepared)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var decoded struct {
Messages []map[string]any `json:"messages"`
}
if err := json.Unmarshal(b, &decoded); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if len(decoded.Messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(decoded.Messages))
}
cacheControl, ok := decoded.Messages[0]["cache_control"].(map[string]any)
if !ok {
t.Fatalf("expected cache_control on first message, got %#v", decoded.Messages[0])
}
if cacheControl["type"] != string(CacheControlEphemeral) || cacheControl["ttl"] != "1h" {
t.Fatalf("unexpected cache_control payload: %#v", cacheControl)
}
if _, ok := decoded.Messages[1]["cache_control"]; ok {
t.Fatalf("expected second message to omit cache_control, got %#v", decoded.Messages[1])
}
}
func TestPreparedRunJSONIncludesSessionIDOnlyWhenPresent(t *testing.T) {
prepared := PreparedRun{
PromptID: "prompt.id",
SelectedProfileID: "local-fast",
EffectiveModelParams: ExecutionTarget{
Endpoint: "http://llm/v1",
Model: "gpt-test",
},
SessionID: "session-123",
RenderedPromptHash: "rendered-hash",
Messages: []RenderedMessage{{Role: "user", Content: "Summarize this."}},
}
b, err := json.Marshal(prepared)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(b, &decoded); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if decoded["session_id"] != "session-123" {
t.Fatalf("expected session_id in prepared run JSON, got %#v", decoded["session_id"])
}
prepared.SessionID = ""
b, err = json.Marshal(prepared)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
if strings.Contains(string(b), "session_id") {
t.Fatalf("expected empty session_id to be omitted, got %s", b)
}
}

View File

@@ -1,142 +0,0 @@
package filecatalog
import (
"context"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
)
// FindYAMLFiles returns sorted full paths for .yaml and .yml files under root.
func FindYAMLFiles(ctx context.Context, root string) ([]string, error) {
var files []string
err := filepath.WalkDir(root, func(path string, d os.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, path)
return nil
})
sort.Strings(files)
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, filePath string) string {
rel, err := filepath.Rel(root, filePath)
if err != nil {
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")
name = strings.TrimSuffix(name, ".yml")
return name
}
func IsYAMLFile(name string) bool {
return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml")
}

View File

@@ -1,270 +0,0 @@
package filecatalog
import (
"context"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"testing/fstest"
)
func TestFindYAMLFilesNestedSortedAndFiltered(t *testing.T) {
root := t.TempDir()
mustWriteFile(t, filepath.Join(root, "z", "prompt.yml"), "id: z")
mustWriteFile(t, filepath.Join(root, "a", "profile.yaml"), "id: a")
mustWriteFile(t, filepath.Join(root, "a", "ignore.txt"), "not yaml")
mustWriteFile(t, filepath.Join(root, "b", "ignore.yaml.bak"), "not yaml")
got, err := FindYAMLFiles(context.Background(), root)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
want := []string{
filepath.Join(root, "a", "profile.yaml"),
filepath.Join(root, "z", "prompt.yml"),
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected sorted YAML files %v, got %v", want, got)
}
}
func TestFindYAMLFilesHonorsContextCancellation(t *testing.T) {
root := t.TempDir()
mustWriteFile(t, filepath.Join(root, "one.yaml"), "id: one")
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := FindYAMLFiles(ctx, root)
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", err)
}
}
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")
got := RelativePath(root, path)
want := filepath.Join("nested", "profiles", "local.yaml")
if got != want {
t.Fatalf("expected relative path %q, got %q", want, got)
}
}
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
in string
want string
}{
{name: "yaml", in: "prompt.yaml", want: "prompt"},
{name: "yml", in: "profile.yml", want: "profile"},
{name: "other", in: "file.txt", want: "file.txt"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := Stem(tc.in); got != tc.want {
t.Fatalf("expected %q, got %q", tc.want, got)
}
})
}
}
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 {
t.Fatalf("failed to create directory: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("failed to write file %q: %v", path, err)
}
}

View File

@@ -1,4 +1,4 @@
// Package format formats already-prepared domain data for adapters. // Package format formats already-prepared public data for adapters.
package format package format
import ( import (
@@ -9,7 +9,7 @@ import (
"sort" "sort"
"strings" "strings"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain" "gitea.maximumdirect.net/eric/promptkit"
) )
var ErrUnknownPreparedRunFormat = errors.New("unknown prepared run format") var ErrUnknownPreparedRunFormat = errors.New("unknown prepared run format")
@@ -26,7 +26,7 @@ const (
// PreparedRunFormatter serializes a prepared run without performing use case work. // PreparedRunFormatter serializes a prepared run without performing use case work.
type PreparedRunFormatter interface { type PreparedRunFormatter interface {
Format(prepared *domain.PreparedRun) ([]byte, error) Format(prepared *promptkit.PreparedRun) ([]byte, error)
} }
// ParsePreparedRunOutputFormat parses a format name. // ParsePreparedRunOutputFormat parses a format name.
@@ -56,7 +56,7 @@ func FormatterForPreparedRun(outputFormat PreparedRunOutputFormat) (PreparedRunF
} }
// FormatPreparedRun formats a prepared run using the selected format. // FormatPreparedRun formats a prepared run using the selected format.
func FormatPreparedRun(prepared *domain.PreparedRun, outputFormat PreparedRunOutputFormat) ([]byte, error) { func FormatPreparedRun(prepared *promptkit.PreparedRun, outputFormat PreparedRunOutputFormat) ([]byte, error) {
formatter, err := FormatterForPreparedRun(outputFormat) formatter, err := FormatterForPreparedRun(outputFormat)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -65,7 +65,7 @@ func FormatPreparedRun(prepared *domain.PreparedRun, outputFormat PreparedRunOut
} }
// FormatPreparedRunByName parses a format name and formats a prepared run. // FormatPreparedRunByName parses a format name and formats a prepared run.
func FormatPreparedRunByName(prepared *domain.PreparedRun, rawFormat string) ([]byte, error) { func FormatPreparedRunByName(prepared *promptkit.PreparedRun, rawFormat string) ([]byte, error) {
outputFormat, err := ParsePreparedRunOutputFormat(rawFormat) outputFormat, err := ParsePreparedRunOutputFormat(rawFormat)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -75,7 +75,7 @@ func FormatPreparedRunByName(prepared *domain.PreparedRun, rawFormat string) ([]
type jsonPreparedRunFormatter struct{} type jsonPreparedRunFormatter struct{}
func (jsonPreparedRunFormatter) Format(prepared *domain.PreparedRun) ([]byte, error) { func (jsonPreparedRunFormatter) Format(prepared *promptkit.PreparedRun) ([]byte, error) {
if prepared == nil { if prepared == nil {
return nil, errors.New("prepared run is nil") return nil, errors.New("prepared run is nil")
} }
@@ -84,7 +84,7 @@ func (jsonPreparedRunFormatter) Format(prepared *domain.PreparedRun) ([]byte, er
type textPreparedRunFormatter struct{} type textPreparedRunFormatter struct{}
func (textPreparedRunFormatter) Format(prepared *domain.PreparedRun) ([]byte, error) { func (textPreparedRunFormatter) Format(prepared *promptkit.PreparedRun) ([]byte, error) {
if prepared == nil { if prepared == nil {
return nil, errors.New("prepared run is nil") return nil, errors.New("prepared run is nil")
} }
@@ -146,7 +146,7 @@ func (textPreparedRunFormatter) Format(prepared *domain.PreparedRun) ([]byte, er
fmt.Fprintln(&b, "messages:") fmt.Fprintln(&b, "messages:")
roleOrder := make([]string, 0) roleOrder := make([]string, 0)
byRole := make(map[string][]domain.RenderedMessage) byRole := make(map[string][]promptkit.RenderedMessage)
for _, msg := range prepared.Messages { for _, msg := range prepared.Messages {
if _, exists := byRole[msg.Role]; !exists { if _, exists := byRole[msg.Role]; !exists {
roleOrder = append(roleOrder, msg.Role) roleOrder = append(roleOrder, msg.Role)

View File

@@ -6,7 +6,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain" "gitea.maximumdirect.net/eric/promptkit"
) )
func TestTextFormatterIncludesPreparedRunDetails(t *testing.T) { func TestTextFormatterIncludesPreparedRunDetails(t *testing.T) {
@@ -94,9 +94,8 @@ func TestTextFormatterDoesNotIncludeResolvedAPIKeyValue(t *testing.T) {
func TestTextFormatterDoesNotIncludeDirectAPIKeyValue(t *testing.T) { func TestTextFormatterDoesNotIncludeDirectAPIKeyValue(t *testing.T) {
const directKey = "direct-format-key" const directKey = "direct-format-key"
// PreparedRun intentionally has no field for direct API keys.
prepared := samplePreparedRun() prepared := samplePreparedRun()
prepared.EffectiveModelParams.APIKey = directKey
out, err := FormatPreparedRun(prepared, PreparedRunFormatText) out, err := FormatPreparedRun(prepared, PreparedRunFormatText)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
@@ -108,12 +107,12 @@ func TestTextFormatterDoesNotIncludeDirectAPIKeyValue(t *testing.T) {
func TestTextFormatterIncludesMessageCacheControlBeforeContent(t *testing.T) { func TestTextFormatterIncludesMessageCacheControlBeforeContent(t *testing.T) {
prepared := samplePreparedRun() prepared := samplePreparedRun()
prepared.Messages = []domain.RenderedMessage{ prepared.Messages = []promptkit.RenderedMessage{
{ {
Role: "system", Role: "system",
Content: "System guidance.", Content: "System guidance.",
CacheControl: &domain.CacheControl{ CacheControl: &promptkit.CacheControl{
Type: domain.CacheControlEphemeral, Type: promptkit.CacheControlEphemeral,
TTL: "1h", TTL: "1h",
}, },
}, },
@@ -148,12 +147,12 @@ func TestTextFormatterIncludesSessionIDWhenPresent(t *testing.T) {
func TestTextFormatterOmitsEmptyCacheControlTTL(t *testing.T) { func TestTextFormatterOmitsEmptyCacheControlTTL(t *testing.T) {
prepared := samplePreparedRun() prepared := samplePreparedRun()
prepared.Messages = []domain.RenderedMessage{ prepared.Messages = []promptkit.RenderedMessage{
{ {
Role: "system", Role: "system",
Content: "System guidance.", Content: "System guidance.",
CacheControl: &domain.CacheControl{ CacheControl: &promptkit.CacheControl{
Type: domain.CacheControlEphemeral, Type: promptkit.CacheControlEphemeral,
}, },
}, },
} }
@@ -231,12 +230,12 @@ func TestJSONFormatterEmitsValidJSONAndIncludesPreparedRunFields(t *testing.T) {
func TestJSONFormatterIncludesMessageCacheControlOnlyWhenPresent(t *testing.T) { func TestJSONFormatterIncludesMessageCacheControlOnlyWhenPresent(t *testing.T) {
prepared := samplePreparedRun() prepared := samplePreparedRun()
prepared.Messages = []domain.RenderedMessage{ prepared.Messages = []promptkit.RenderedMessage{
{ {
Role: "system", Role: "system",
Content: "System guidance.", Content: "System guidance.",
CacheControl: &domain.CacheControl{ CacheControl: &promptkit.CacheControl{
Type: domain.CacheControlEphemeral, Type: promptkit.CacheControlEphemeral,
TTL: "1h", TTL: "1h",
}, },
}, },
@@ -262,7 +261,7 @@ func TestJSONFormatterIncludesMessageCacheControlOnlyWhenPresent(t *testing.T) {
if !ok { if !ok {
t.Fatalf("expected first message cache_control, got %#v", decoded.Messages[0]) t.Fatalf("expected first message cache_control, got %#v", decoded.Messages[0])
} }
if cacheControl["type"] != string(domain.CacheControlEphemeral) || cacheControl["ttl"] != "1h" { if cacheControl["type"] != string(promptkit.CacheControlEphemeral) || cacheControl["ttl"] != "1h" {
t.Fatalf("unexpected cache_control payload: %#v", cacheControl) t.Fatalf("unexpected cache_control payload: %#v", cacheControl)
} }
if _, ok := decoded.Messages[1]["cache_control"]; ok { if _, ok := decoded.Messages[1]["cache_control"]; ok {
@@ -285,9 +284,8 @@ func TestJSONFormatterDoesNotIncludeResolvedAPIKeyValue(t *testing.T) {
func TestJSONFormatterDoesNotIncludeDirectAPIKeyValue(t *testing.T) { func TestJSONFormatterDoesNotIncludeDirectAPIKeyValue(t *testing.T) {
const directKey = "direct-format-key" const directKey = "direct-format-key"
// PreparedRun intentionally has no field for direct API keys.
prepared := samplePreparedRun() prepared := samplePreparedRun()
prepared.EffectiveModelParams.APIKey = directKey
out, err := FormatPreparedRun(prepared, PreparedRunFormatJSON) out, err := FormatPreparedRun(prepared, PreparedRunFormatJSON)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
@@ -342,13 +340,13 @@ func TestFormatPreparedRunByNameUnknownFailsClearly(t *testing.T) {
} }
} }
func samplePreparedRun() *domain.PreparedRun { func samplePreparedRun() *promptkit.PreparedRun {
return &domain.PreparedRun{ return &promptkit.PreparedRun{
PromptID: "prompt.id", PromptID: "prompt.id",
PromptVersion: "v1", PromptVersion: "v1",
PromptHash: "prompt-hash", PromptHash: "prompt-hash",
SelectedProfileID: "local-fast", SelectedProfileID: "local-fast",
EffectiveModelParams: domain.ExecutionTarget{ EffectiveModelParams: promptkit.ExecutionTarget{
Endpoint: "http://llm/v1", Endpoint: "http://llm/v1",
Model: "gpt-test", Model: "gpt-test",
Temperature: 0.4, Temperature: 0.4,
@@ -364,7 +362,7 @@ func samplePreparedRun() *domain.PreparedRun {
"glossary": "hash-glossary", "glossary": "hash-glossary",
}, },
RenderedPromptHash: "rendered-hash", RenderedPromptHash: "rendered-hash",
Messages: []domain.RenderedMessage{ Messages: []promptkit.RenderedMessage{
{Role: "system", Content: "System guidance."}, {Role: "system", Content: "System guidance."},
{Role: "user", Content: "Summarize the transcript.\nInclude key entities."}, {Role: "user", Content: "Summarize the transcript.\nInclude key entities."},
{Role: "user", Content: "Second user message."}, {Role: "user", Content: "Second user message."},

View File

@@ -1,11 +0,0 @@
package llm
import (
"context"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
// Client executes a rendered prompt against an LLM endpoint.
type Client interface {
Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error)
}

View File

@@ -1,387 +0,0 @@
package llm
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"unicode/utf8"
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
var (
ErrInvalidConfig = errors.New("invalid llm client configuration")
ErrInvalidRequest = errors.New("invalid generate request")
ErrRequestFailed = errors.New("llm request failed")
ErrUnexpectedStatus = errors.New("llm returned non-success status")
ErrMalformedResponse = errors.New("malformed llm response")
)
type OpenAICompatibleConfig struct {
BaseURL string
Model string
Timeout time.Duration
HTTPClient *http.Client
}
type OpenAICompatibleClient struct {
baseURL string
defaultModel string
httpClient *http.Client
}
func NewOpenAICompatibleClient(cfg OpenAICompatibleConfig) (*OpenAICompatibleClient, error) {
baseURL := strings.TrimSpace(cfg.BaseURL)
if baseURL != "" {
if _, err := url.ParseRequestURI(baseURL); err != nil {
return nil, fmt.Errorf("%w: invalid base URL: %v", ErrInvalidConfig, err)
}
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = defaults.LLMRequestTimeoutDefault
}
var client *http.Client
if cfg.HTTPClient != nil {
cloned := *cfg.HTTPClient
if cloned.Timeout == 0 {
cloned.Timeout = timeout
}
client = &cloned
} else {
client = &http.Client{Timeout: timeout}
}
return &OpenAICompatibleClient{
baseURL: strings.TrimRight(baseURL, "/"),
defaultModel: cfg.Model,
httpClient: client,
}, nil
}
func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
if req.Target.TimeoutSeconds < 0 {
return nil, fmt.Errorf("%w: timeout_seconds must be greater than or equal to 0", ErrInvalidRequest)
}
endpoint := strings.TrimSpace(req.Target.Endpoint)
if endpoint == "" {
endpoint = c.baseURL
}
if endpoint == "" {
return nil, fmt.Errorf("%w: endpoint is required", ErrInvalidRequest)
}
endpoint = strings.TrimRight(endpoint, "/") + defaults.OpenAIChatCompletionsPath
wireReq, err := openAIChatRequestFromGenerateRequest(req, c.defaultModel)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
}
wirePayload, err := openAIChatRequestPayload(wireReq)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
}
payload, err := json.Marshal(wirePayload)
if err != nil {
return nil, fmt.Errorf("%w: failed to encode request: %v", ErrRequestFailed, err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("%w: failed to create request: %v", ErrRequestFailed, err)
}
httpReq.Header.Set("Content-Type", "application/json")
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)
}
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
}
httpClient := c.httpClient
if httpClient == nil {
httpClient = &http.Client{Timeout: defaults.LLMRequestTimeoutDefault}
}
if req.Target.TimeoutSeconds > 0 {
effectiveTimeout := time.Duration(req.Target.TimeoutSeconds) * time.Second
if httpClient.Timeout != effectiveTimeout {
cloned := *httpClient
cloned.Timeout = effectiveTimeout
httpClient = &cloned
}
} else if req.TargetPresence.TimeoutSeconds && httpClient.Timeout != 0 {
cloned := *httpClient
cloned.Timeout = 0
httpClient = &cloned
}
httpResp, err := httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrRequestFailed, err)
}
defer httpResp.Body.Close()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
_, _ = io.Copy(io.Discard, io.LimitReader(httpResp.Body, 4096))
return nil, fmt.Errorf("%w: status=%d", ErrUnexpectedStatus, httpResp.StatusCode)
}
var wireResp openAIChatResponse
if err := json.NewDecoder(httpResp.Body).Decode(&wireResp); err != nil {
return nil, fmt.Errorf("%w: failed to decode response: %v", ErrMalformedResponse, err)
}
if len(wireResp.Choices) == 0 {
return nil, fmt.Errorf("%w: no choices returned", ErrMalformedResponse)
}
content := wireResp.Choices[0].Message.Content
if content == "" {
return nil, fmt.Errorf("%w: first choice has empty message content", ErrMalformedResponse)
}
return &domain.GenerateResponse{
Content: content,
Usage: domain.TokenUsage{
PromptTokens: wireResp.Usage.PromptTokens,
CompletionTokens: wireResp.Usage.CompletionTokens,
TotalTokens: wireResp.Usage.TotalTokens,
CachedTokens: wireResp.Usage.PromptTokensDetails.CachedTokens,
CacheWriteTokens: wireResp.Usage.CacheWriteTokens,
},
}, nil
}
func openAIChatRequestFromGenerateRequest(req domain.GenerateRequest, defaultModel string) (openAIChatRequest, error) {
model := strings.TrimSpace(req.Target.Model)
if model == "" {
model = strings.TrimSpace(defaultModel)
}
if model == "" {
return openAIChatRequest{}, errors.New("model is required")
}
wireReq := openAIChatRequest{
Model: model,
}
if sessionID := strings.TrimSpace(req.Prompt.SessionID); sessionID != "" {
if n := utf8.RuneCountInString(sessionID); n > domain.SessionIDMaxLength {
return openAIChatRequest{}, fmt.Errorf("session_id length %d exceeds maximum %d", n, domain.SessionIDMaxLength)
}
wireReq.SessionID = sessionID
}
wireReq.Messages = make([]openAIChatRequestMessage, 0, len(req.Prompt.Messages))
for _, msg := range req.Prompt.Messages {
wireReq.Messages = append(wireReq.Messages, openAIChatRequestMessageFromRenderedMessage(msg))
}
if req.Target.Temperature != 0 || req.TargetPresence.Temperature {
wireReq.Temperature = &req.Target.Temperature
}
if req.Target.MaxTokens != 0 || req.TargetPresence.MaxTokens {
wireReq.MaxTokens = &req.Target.MaxTokens
}
if req.Target.TopP != 0 || req.TargetPresence.TopP {
wireReq.TopP = &req.Target.TopP
}
if strings.TrimSpace(req.Target.ServiceTier) != "" {
wireReq.ServiceTier = req.Target.ServiceTier
}
if strings.TrimSpace(req.Target.ReasoningEffort) != "" {
wireReq.ReasoningEffort = req.Target.ReasoningEffort
}
if len(req.Target.ExtraParams) > 0 {
wireReq.ExtraParams = req.Target.ExtraParams
}
if req.StructuredOutput != nil {
responseFormat, err := toOpenAIResponseFormat(req.StructuredOutput)
if err != nil {
return openAIChatRequest{}, err
}
wireReq.ResponseFormat = responseFormat
}
return wireReq, nil
}
type openAIChatRequest struct {
Model string `json:"model"`
SessionID string `json:"session_id,omitempty"`
Messages []openAIChatRequestMessage `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
ResponseFormat *openAIResponseFormat `json:"response_format,omitempty"`
ExtraParams map[string]any `json:"-"`
}
func openAIChatRequestPayload(req openAIChatRequest) (map[string]any, error) {
out := map[string]any{
"model": req.Model,
"messages": req.Messages,
}
if req.SessionID != "" {
out["session_id"] = req.SessionID
}
if req.Temperature != nil {
out["temperature"] = *req.Temperature
}
if req.MaxTokens != nil {
out["max_tokens"] = *req.MaxTokens
}
if req.TopP != nil {
out["top_p"] = *req.TopP
}
if req.ServiceTier != "" {
out["service_tier"] = req.ServiceTier
}
if req.ReasoningEffort != "" {
out["reasoning_effort"] = req.ReasoningEffort
}
if req.ResponseFormat != nil {
out["response_format"] = req.ResponseFormat
}
for key, value := range req.ExtraParams {
if key == "" {
return nil, errors.New("extra_params key must not be empty")
}
if _, reserved := reservedOpenAIChatRequestFields[key]; reserved {
return nil, fmt.Errorf("extra_params key %q collides with reserved request field", key)
}
if _, err := json.Marshal(value); err != nil {
return nil, fmt.Errorf("extra_params.%s must be JSON-serializable: %w", key, err)
}
out[key] = value
}
return out, nil
}
var reservedOpenAIChatRequestFields = map[string]struct{}{
"model": {},
"session_id": {},
"messages": {},
"temperature": {},
"max_tokens": {},
"top_p": {},
"service_tier": {},
"reasoning_effort": {},
"response_format": {},
}
type openAIChatRequestMessage struct {
Role string `json:"role"`
Content any `json:"content"`
}
type openAIChatTextContentBlock struct {
Type string `json:"type"`
Text string `json:"text"`
CacheControl *openAICacheControl `json:"cache_control,omitempty"`
}
type openAICacheControl struct {
Type string `json:"type"`
TTL string `json:"ttl,omitempty"`
}
type openAIChatResponseMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type openAIChatResponse struct {
Choices []struct {
Message openAIChatResponseMessage `json:"message"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
PromptTokensDetails struct {
CachedTokens int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
CacheWriteTokens int `json:"cache_write_tokens"`
} `json:"usage"`
}
type openAIResponseFormat struct {
Type string `json:"type"`
JSONSchema *openAIJSONSchemaEnvelope `json:"json_schema,omitempty"`
}
type openAIJSONSchemaEnvelope struct {
Name string `json:"name"`
Strict bool `json:"strict"`
Schema any `json:"schema"`
}
func openAIChatRequestMessageFromRenderedMessage(msg domain.RenderedMessage) openAIChatRequestMessage {
wireMsg := openAIChatRequestMessage{
Role: msg.Role,
Content: msg.Content,
}
if msg.CacheControl == nil {
return wireMsg
}
wireMsg.Content = []openAIChatTextContentBlock{
{
Type: "text",
Text: msg.Content,
CacheControl: &openAICacheControl{
Type: string(msg.CacheControl.Type),
TTL: msg.CacheControl.TTL,
},
},
}
return wireMsg
}
func toOpenAIResponseFormat(spec *domain.StructuredOutputSpec) (*openAIResponseFormat, error) {
if spec == nil {
return nil, nil
}
switch spec.Type {
case domain.StructuredOutputJSONSchema:
if spec.JSONSchema == nil {
return nil, errors.New("json_schema structured output requires schema payload")
}
if strings.TrimSpace(spec.JSONSchema.Name) == "" {
return nil, errors.New("json_schema structured output requires non-empty schema name")
}
if spec.JSONSchema.Schema == nil {
return nil, errors.New("json_schema structured output requires schema document")
}
return &openAIResponseFormat{
Type: "json_schema",
JSONSchema: &openAIJSONSchemaEnvelope{
Name: spec.JSONSchema.Name,
Strict: spec.JSONSchema.Strict,
Schema: spec.JSONSchema.Schema,
},
}, nil
default:
return nil, fmt.Errorf("unsupported structured output type %q", spec.Type)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +0,0 @@
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

@@ -1,7 +0,0 @@
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

@@ -1,7 +0,0 @@
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

@@ -1,7 +0,0 @@
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

@@ -1,7 +0,0 @@
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

@@ -1,7 +0,0 @@
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

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

View File

@@ -1,7 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -1,7 +0,0 @@
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

@@ -1,8 +0,0 @@
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

@@ -1,7 +0,0 @@
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

@@ -1,8 +0,0 @@
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

@@ -1,7 +0,0 @@
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

@@ -1,7 +0,0 @@
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

@@ -1,7 +0,0 @@
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

@@ -1,31 +0,0 @@
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

@@ -1,127 +0,0 @@
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

@@ -1,213 +0,0 @@
package profile
import (
"bytes"
"context"
"errors"
"fmt"
"io/fs"
"os"
"path"
"strings"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"gitea.maximumdirect.net/eric/scriptorium/internal/filecatalog"
"gopkg.in/yaml.v3"
)
var (
ErrProfileNotFound = errors.New("execution profile not found")
ErrInvalidYAML = errors.New("invalid YAML format")
ErrInvalidProfile = errors.New("invalid execution profile configuration")
ErrRawAPIKeyNotAllowed = errors.New("raw api_key is not allowed; use api_key_env")
)
type filesystemRepository struct {
dir string
}
func NewFilesystemRepository(dir string) Repository {
return &filesystemRepository{dir: dir}
}
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.FindFSYAMLFiles(ctx, fsys, root)
if err != nil {
return nil, fmt.Errorf("failed to read profile directory: %w", err)
}
var matches []profileMatch
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 {
return nil, fmt.Errorf("failed to read profile file %s: %w", relPath, err)
}
metadata := readProfileFileMetadata(data)
idMatch := fileMatch || metadata.id == id
if metadata.hasRawAPIKey {
if idMatch {
return nil, fmt.Errorf("%w: %s", ErrRawAPIKeyNotAllowed, relPath)
}
continue
}
var prof domain.ExecutionProfile
decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(true)
if err := decoder.Decode(&prof); err != nil {
if idMatch {
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, relPath, err)
}
continue
}
if prof.ID != id {
continue
}
if err := validateProfile(&prof); err != nil {
if errors.Is(err, ErrRawAPIKeyNotAllowed) {
return nil, fmt.Errorf("%w: %s", err, relPath)
}
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidProfile, relPath, err)
}
matches = append(matches, profileMatch{
profile: &prof,
path: relPath,
})
}
if len(matches) > 1 {
paths := make([]string, 0, len(matches))
for _, match := range matches {
paths = append(paths, match.path)
}
return nil, fmt.Errorf("%w: duplicate execution profile id %q found in: %s", ErrInvalidProfile, id, strings.Join(paths, ", "))
}
if len(matches) == 1 {
return matches[0].profile, nil
}
return nil, ErrProfileNotFound
}
type profileMatch struct {
profile *domain.ExecutionProfile
path string
}
type profileFileMetadata struct {
id string
hasRawAPIKey bool
}
func readProfileFileMetadata(data []byte) profileFileMetadata {
var node yaml.Node
if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&node); err != nil {
return profileFileMetadata{}
}
if node.Kind != yaml.DocumentNode || len(node.Content) == 0 {
return profileFileMetadata{}
}
mapping := node.Content[0]
if mapping.Kind != yaml.MappingNode {
return profileFileMetadata{}
}
var metadata profileFileMetadata
for i := 0; i+1 < len(mapping.Content); i += 2 {
key := mapping.Content[i]
value := mapping.Content[i+1]
switch key.Value {
case "id":
metadata.id = strings.TrimSpace(value.Value)
case "api_key":
metadata.hasRawAPIKey = true
}
}
return metadata
}
func validateProfile(p *domain.ExecutionProfile) error {
if strings.TrimSpace(p.ID) == "" {
return errors.New("id is required")
}
if strings.TrimSpace(p.Endpoint) == "" {
return errors.New("endpoint is required")
}
if strings.TrimSpace(p.Model) == "" {
return errors.New("model is required")
}
if p.Temperature < 0 || p.Temperature > 2 {
return errors.New("temperature must be between 0 and 2")
}
if p.MaxTokens < 0 {
return errors.New("max_tokens must be greater than or equal to 0")
}
if p.TopP < 0 || p.TopP > 1 {
return errors.New("top_p must be between 0 and 1")
}
if p.TimeoutSeconds < 0 {
return errors.New("timeout_seconds must be greater than or equal to 0")
}
return nil
}

View File

@@ -1,12 +0,0 @@
package profile
import (
"context"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
// Repository loads execution profiles.
type Repository interface {
GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error)
}

View File

@@ -1,479 +0,0 @@
package profile
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
func TestFilesystemRepository_GetProfile(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "execution_profile_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
files, err := os.ReadDir("testdata")
if err != nil {
t.Fatalf("failed to read testdata: %v", err)
}
for _, f := range files {
src := filepath.Join("testdata", f.Name())
dst := filepath.Join(tmpDir, f.Name())
data, err := os.ReadFile(src)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, data, 0644); err != nil {
t.Fatal(err)
}
}
repo := NewFilesystemRepository(tmpDir)
ctx := context.Background()
t.Run("valid local profile", func(t *testing.T) {
p, err := repo.GetProfile(ctx, "local-default")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if p.ID != "local-default" {
t.Fatalf("unexpected id: %q", p.ID)
}
if p.Endpoint == "" || p.Model == "" {
t.Fatalf("expected endpoint/model to be set: %+v", p)
}
})
t.Run("valid profile with api_key_env", func(t *testing.T) {
p, err := repo.GetProfile(ctx, "local-secure")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if p.APIKeyEnv != "SCRIPTORIUM_API_KEY" {
t.Fatalf("unexpected api_key_env: %q", p.APIKeyEnv)
}
if p.ReasoningEffort != "medium" {
t.Fatalf("unexpected reasoning_effort: %q", p.ReasoningEffort)
}
if p.ServiceTier != "priority" {
t.Fatalf("unexpected service_tier: %q", p.ServiceTier)
}
})
t.Run("valid nested profile", func(t *testing.T) {
nestedDir := filepath.Join(tmpDir, "local")
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
t.Fatal(err)
}
writeProfileTestFile(t, filepath.Join(nestedDir, "nested-local.yaml"), `
id: nested-local
endpoint: http://localhost:8000/v1
model: nested-model
temperature: 0.1
`)
p, err := repo.GetProfile(ctx, "nested-local")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if p.Model != "nested-model" {
t.Fatalf("unexpected model: %q", p.Model)
}
})
t.Run("valid profile with JSON-compatible extra params", func(t *testing.T) {
writeProfileTestFile(t, filepath.Join(tmpDir, "json-extra-params.yaml"), `
id: json-extra-params
endpoint: http://localhost:8000/v1
model: nested-model
extra_params:
string_value: enabled
number_value: 42
boolean_value: true
object_value:
nested: value
count: 2
array_value:
- first
- 3
- false
`)
p, err := repo.GetProfile(ctx, "json-extra-params")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
var got map[string]any
encoded, err := json.Marshal(p.ExtraParams)
if err != nil {
t.Fatalf("expected extra_params to marshal as JSON, got %v", err)
}
if err := json.Unmarshal(encoded, &got); err != nil {
t.Fatalf("expected extra_params JSON to decode, got %v", err)
}
if got["string_value"] != "enabled" {
t.Fatalf("unexpected string extra param: %#v", got["string_value"])
}
if got["number_value"] != float64(42) {
t.Fatalf("unexpected number extra param: %#v", got["number_value"])
}
if got["boolean_value"] != true {
t.Fatalf("unexpected boolean extra param: %#v", got["boolean_value"])
}
objectValue, ok := got["object_value"].(map[string]any)
if !ok {
t.Fatalf("expected object extra param, got %#v", got["object_value"])
}
if objectValue["nested"] != "value" || objectValue["count"] != float64(2) {
t.Fatalf("unexpected object extra param: %#v", objectValue)
}
arrayValue, ok := got["array_value"].([]any)
if !ok {
t.Fatalf("expected array extra param, got %#v", got["array_value"])
}
if len(arrayValue) != 3 || arrayValue[0] != "first" || arrayValue[1] != float64(3) || arrayValue[2] != false {
t.Fatalf("unexpected array extra param: %#v", arrayValue)
}
})
t.Run("duplicate profile IDs fail as ambiguous", func(t *testing.T) {
writeProfileTestFile(t, filepath.Join(tmpDir, "duplicate-profile-a.yaml"), `
id: duplicate-profile
endpoint: http://localhost:8000/v1
model: first-model
`)
nestedDir := filepath.Join(tmpDir, "duplicates")
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
t.Fatal(err)
}
writeProfileTestFile(t, filepath.Join(nestedDir, "duplicate-profile-b.yaml"), `
id: duplicate-profile
endpoint: http://localhost:8000/v1
model: second-model
`)
_, err := repo.GetProfile(ctx, "duplicate-profile")
if !errors.Is(err, ErrInvalidProfile) {
t.Fatalf("expected duplicate profile to return ErrInvalidProfile, got %v", err)
}
for _, want := range []string{"duplicate execution profile id", "duplicate-profile-a.yaml", filepath.Join("duplicates", "duplicate-profile-b.yaml")} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("expected error to contain %q, got %v", want, err)
}
}
})
t.Run("nested raw api_key rejected for likely target file", func(t *testing.T) {
nestedDir := filepath.Join(tmpDir, "secure")
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
t.Fatal(err)
}
writeProfileTestFile(t, filepath.Join(nestedDir, "not_named_like_id.yaml"), `
id: nested_raw_api_key
endpoint: http://localhost:8000/v1
model: m
api_key: secret
`)
_, err := repo.GetProfile(ctx, "nested_raw_api_key")
if !errors.Is(err, ErrRawAPIKeyNotAllowed) {
t.Fatalf("expected ErrRawAPIKeyNotAllowed, got %v", err)
}
if !strings.Contains(err.Error(), filepath.Join("secure", "not_named_like_id.yaml")) {
t.Fatalf("expected nested path in error, got %v", err)
}
})
t.Run("raw api_key in non-target profile is ignored", func(t *testing.T) {
writeProfileTestFile(t, filepath.Join(tmpDir, "raw-api-key-non-target.yaml"), `
id: raw-api-key-non-target
endpoint: http://localhost:8000/v1
model: m
api_key: secret
`)
_, err := repo.GetProfile(ctx, "does-not-exist-with-raw-key-nearby")
if !errors.Is(err, ErrProfileNotFound) {
t.Fatalf("expected ErrProfileNotFound for non-target raw api_key file, got %v", err)
}
})
t.Run("invalid yaml", func(t *testing.T) {
_, err := repo.GetProfile(ctx, "invalid_yaml")
if !errors.Is(err, ErrInvalidYAML) {
t.Fatalf("expected ErrInvalidYAML, got %v", err)
}
})
t.Run("missing id", func(t *testing.T) {
_, err := repo.GetProfile(ctx, "missing_id")
if !errors.Is(err, ErrProfileNotFound) {
t.Fatalf("expected ErrProfileNotFound, got %v", err)
}
})
t.Run("missing endpoint", func(t *testing.T) {
_, err := repo.GetProfile(ctx, "missing-endpoint")
if !errors.Is(err, ErrInvalidProfile) {
t.Fatalf("expected ErrInvalidProfile, got %v", err)
}
})
t.Run("missing model", func(t *testing.T) {
_, err := repo.GetProfile(ctx, "missing-model")
if !errors.Is(err, ErrInvalidProfile) {
t.Fatalf("expected ErrInvalidProfile, got %v", err)
}
})
t.Run("unknown field", func(t *testing.T) {
_, err := repo.GetProfile(ctx, "unknown_field")
if !errors.Is(err, ErrInvalidYAML) {
t.Fatalf("expected ErrInvalidYAML for strict decode unknown field, got %v", err)
}
})
t.Run("raw api_key rejected", func(t *testing.T) {
_, err := repo.GetProfile(ctx, "raw_api_key")
if !errors.Is(err, ErrRawAPIKeyNotAllowed) {
t.Fatalf("expected ErrRawAPIKeyNotAllowed, got %v", err)
}
})
t.Run("profile not found", func(t *testing.T) {
_, err := repo.GetProfile(ctx, "does-not-exist")
if !errors.Is(err, ErrProfileNotFound) {
t.Fatalf("expected ErrProfileNotFound, got %v", err)
}
})
}
func writeProfileTestFile(t *testing.T, path string, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(strings.TrimLeft(content, "\n")), 0o644); err != nil {
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

@@ -1,3 +0,0 @@
id: invalid_yaml
endpoint: http://localhost:8000/v1
model: [broken

View File

@@ -1,2 +0,0 @@
id: missing-endpoint
model: gpt-4o-mini

View File

@@ -1,2 +0,0 @@
endpoint: http://localhost:8000/v1
model: gpt-4o-mini

View File

@@ -1,2 +0,0 @@
id: missing-model
endpoint: http://localhost:8000/v1

View File

@@ -1,4 +0,0 @@
id: raw-api-key
endpoint: http://localhost:8000/v1
model: gpt-4o-mini
api_key: super-secret-should-not-be-here

View File

@@ -1,4 +0,0 @@
id: unknown-field
endpoint: http://localhost:8000/v1
model: gpt-4o-mini
foo: bar

View File

@@ -1,7 +0,0 @@
id: local-default
endpoint: http://localhost:8000/v1
model: gpt-4o-mini
temperature: 0.2
max_tokens: 700
top_p: 1.0
timeout_seconds: 120

View File

@@ -1,8 +0,0 @@
id: local-secure
endpoint: http://localhost:8000/v1
model: gpt-4o-mini
api_key_env: SCRIPTORIUM_API_KEY
service_tier: priority
reasoning_effort: medium
extra_params:
provider: local

View File

@@ -1,125 +0,0 @@
package prompt
import (
"bytes"
"context"
"errors"
"fmt"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"strings"
"text/template"
"unicode/utf8"
)
var (
ErrMissingRequiredInput = errors.New("missing required input artifact")
ErrUnknownInput = errors.New("referenced unknown input artifact")
ErrInvalidTemplate = errors.New("invalid prompt template")
ErrRenderFailure = errors.New("prompt render failure")
ErrInvalidMessageRole = errors.New("invalid or empty message role")
)
type goRenderer struct{}
func NewGoRenderer() Renderer {
return &goRenderer{}
}
func (r *goRenderer) Render(ctx context.Context, definition *domain.PromptDefinition, inputs map[string]*domain.Artifact, vars map[string]string) (*domain.RenderedPrompt, error) {
if definition == nil {
return nil, fmt.Errorf("%w: nil prompt definition", ErrRenderFailure)
}
// 1. Verify required inputs
for _, in := range definition.Inputs {
if !in.Required {
continue
}
art, ok := inputs[in.Name]
if !ok || art == nil {
return nil, fmt.Errorf("%w: %s", ErrMissingRequiredInput, in.Name)
}
}
// 2. Setup template functions
funcs := template.FuncMap{
"input": func(name string) (string, error) {
art, ok := inputs[name]
if !ok || art == nil {
return "", fmt.Errorf("%w: %s", ErrUnknownInput, name)
}
return string(art.Body), nil
},
}
sessionID, err := renderSessionID(definition.SessionID, funcs, vars)
if err != nil {
return nil, err
}
var renderedMessages []domain.RenderedMessage
for i, tmplMsg := range definition.Templates {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if tmplMsg.Role == "" {
return nil, fmt.Errorf("%w: message %d", ErrInvalidMessageRole, i)
}
// Parse and execute template
tmpl, err := template.New(fmt.Sprintf("msg_%d", i)).Funcs(funcs).Option("missingkey=error").Parse(tmplMsg.Content)
if err != nil {
return nil, fmt.Errorf("%w: message %d: %v", ErrInvalidTemplate, i, err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, vars); err != nil {
return nil, fmt.Errorf("%w: message %d: %w", ErrRenderFailure, i, err)
}
renderedMessages = append(renderedMessages, domain.RenderedMessage{
Role: tmplMsg.Role,
Content: buf.String(),
CacheControl: cloneCacheControl(tmplMsg.CacheControl),
})
}
return &domain.RenderedPrompt{
SessionID: sessionID,
Messages: renderedMessages,
}, nil
}
func renderSessionID(raw string, funcs template.FuncMap, vars map[string]string) (string, error) {
if strings.TrimSpace(raw) == "" {
return "", nil
}
tmpl, err := template.New("session_id").Funcs(funcs).Option("missingkey=error").Parse(raw)
if err != nil {
return "", fmt.Errorf("%w: session_id: %v", ErrInvalidTemplate, err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, vars); err != nil {
return "", fmt.Errorf("%w: session_id: %w", ErrRenderFailure, err)
}
sessionID := strings.TrimSpace(buf.String())
if n := utf8.RuneCountInString(sessionID); n > domain.SessionIDMaxLength {
return "", fmt.Errorf("%w: session_id length %d exceeds maximum %d", ErrRenderFailure, n, domain.SessionIDMaxLength)
}
return sessionID, nil
}
func cloneCacheControl(in *domain.CacheControl) *domain.CacheControl {
if in == nil {
return nil
}
out := *in
return &out
}

View File

@@ -1,11 +0,0 @@
package prompt
import (
"context"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
// Renderer renders prompt templates using named artifacts and variables.
type Renderer interface {
Render(ctx context.Context, definition *domain.PromptDefinition, inputs map[string]*domain.Artifact, vars map[string]string) (*domain.RenderedPrompt, error)
}

View File

@@ -1,345 +0,0 @@
package prompt
import (
"context"
"errors"
"strings"
"testing"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
func TestGoRenderer_Render(t *testing.T) {
renderer := NewGoRenderer()
ctx := context.Background()
inputs := map[string]*domain.Artifact{
"transcript": {Body: []byte("The quick brown fox.")},
}
vars := map[string]string{
"role": "helpful assistant",
"tone": "concise",
}
t.Run("rendering inline message content", func(t *testing.T) {
def := &domain.PromptDefinition{
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
Templates: []domain.PromptMessageTemplate{
{Role: "user", Content: "Analyze this: {{input \"transcript\"}}"},
},
}
res, err := renderer.Render(ctx, def, inputs, vars)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(res.Messages) != 1 {
t.Fatalf("expected 1 message, got %d", len(res.Messages))
}
if res.Messages[0].Content != "Analyze this: The quick brown fox." {
t.Fatalf("unexpected rendered content: %q", res.Messages[0].Content)
}
})
t.Run("rendering file-backed message content loaded into prompt definition", func(t *testing.T) {
def := &domain.PromptDefinition{
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
Templates: []domain.PromptMessageTemplate{
{Role: "user", Content: "From file: {{input \"transcript\"}}", ContentFile: "/tmp/user.tmpl"},
},
}
res, err := renderer.Render(ctx, def, inputs, vars)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := res.Messages[0].Content; got != "From file: The quick brown fox." {
t.Fatalf("unexpected file-backed render result: %q", got)
}
})
t.Run("rendering system and user messages", func(t *testing.T) {
def := &domain.PromptDefinition{
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
Templates: []domain.PromptMessageTemplate{
{Role: "system", Content: "You are a {{.role}}."},
{Role: "user", Content: "Analyze this: {{input \"transcript\"}}"},
},
}
res, err := renderer.Render(ctx, def, inputs, vars)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(res.Messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(res.Messages))
}
if res.Messages[0].Role != "system" || res.Messages[1].Role != "user" {
t.Fatalf("unexpected roles: %#v", res.Messages)
}
})
t.Run("copying cache control to rendered messages", func(t *testing.T) {
def := &domain.PromptDefinition{
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
Templates: []domain.PromptMessageTemplate{
{
Role: "system",
Content: "You are concise.",
CacheControl: &domain.CacheControl{
Type: domain.CacheControlEphemeral,
TTL: "1h",
},
},
{Role: "user", Content: "Analyze this: {{input \"transcript\"}}"},
},
}
res, err := renderer.Render(ctx, def, inputs, vars)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(res.Messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(res.Messages))
}
if res.Messages[0].CacheControl == nil {
t.Fatal("expected rendered cache control")
}
if res.Messages[0].CacheControl.Type != domain.CacheControlEphemeral {
t.Fatalf("unexpected cache control type: %q", res.Messages[0].CacheControl.Type)
}
if res.Messages[0].CacheControl.TTL != "1h" {
t.Fatalf("unexpected cache control ttl: %q", res.Messages[0].CacheControl.TTL)
}
if res.Messages[1].CacheControl != nil {
t.Fatalf("expected no cache control on second message, got %#v", res.Messages[1].CacheControl)
}
})
t.Run("rendered cache control does not alias source template", func(t *testing.T) {
source := &domain.CacheControl{Type: domain.CacheControlEphemeral, TTL: "1h"}
def := &domain.PromptDefinition{
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
Templates: []domain.PromptMessageTemplate{
{Role: "system", Content: "You are concise.", CacheControl: source},
},
}
res, err := renderer.Render(ctx, def, inputs, vars)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.Messages[0].CacheControl == source {
t.Fatal("expected rendered cache control to be cloned")
}
res.Messages[0].CacheControl.TTL = ""
if source.TTL != "1h" {
t.Fatalf("source cache control was mutated, ttl=%q", source.TTL)
}
})
t.Run("accessing vars", func(t *testing.T) {
def := &domain.PromptDefinition{
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
Templates: []domain.PromptMessageTemplate{
{Role: "system", Content: "Speak in a {{.tone}} tone."},
},
}
res, err := renderer.Render(ctx, def, inputs, vars)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.Messages[0].Content != "Speak in a concise tone." {
t.Fatalf("unexpected vars rendering: %q", res.Messages[0].Content)
}
})
t.Run("rendering session id from vars", func(t *testing.T) {
def := &domain.PromptDefinition{
SessionID: " {{ .session_id }} ",
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
Templates: []domain.PromptMessageTemplate{
{Role: "system", Content: "Speak in a {{.tone}} tone."},
},
}
res, err := renderer.Render(ctx, def, inputs, map[string]string{
"tone": "concise",
"session_id": "agent-session-123",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.SessionID != "agent-session-123" {
t.Fatalf("unexpected session id: %q", res.SessionID)
}
})
t.Run("empty rendered session id is omitted", func(t *testing.T) {
def := &domain.PromptDefinition{
SessionID: " ",
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
Templates: []domain.PromptMessageTemplate{
{Role: "system", Content: "Speak in a {{.tone}} tone."},
},
}
res, err := renderer.Render(ctx, def, inputs, vars)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.SessionID != "" {
t.Fatalf("expected empty session id, got %q", res.SessionID)
}
})
t.Run("missing session id var fails rendering", func(t *testing.T) {
def := &domain.PromptDefinition{
SessionID: "{{ .session_id }}",
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
Templates: []domain.PromptMessageTemplate{
{Role: "system", Content: "Speak in a {{.tone}} tone."},
},
}
_, err := renderer.Render(ctx, def, inputs, vars)
if !errors.Is(err, ErrRenderFailure) {
t.Fatalf("expected ErrRenderFailure, got %v", err)
}
})
t.Run("too long rendered session id fails rendering", func(t *testing.T) {
def := &domain.PromptDefinition{
SessionID: "{{ .session_id }}",
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
Templates: []domain.PromptMessageTemplate{
{Role: "system", Content: "Speak in a {{.tone}} tone."},
},
}
_, err := renderer.Render(ctx, def, inputs, map[string]string{
"tone": "concise",
"session_id": strings.Repeat("x", domain.SessionIDMaxLength+1),
})
if !errors.Is(err, ErrRenderFailure) {
t.Fatalf("expected ErrRenderFailure, got %v", err)
}
})
t.Run("inserting required input artifact", func(t *testing.T) {
def := &domain.PromptDefinition{
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
Templates: []domain.PromptMessageTemplate{
{Role: "user", Content: "{{input \"transcript\"}}"},
},
}
res, err := renderer.Render(ctx, def, inputs, vars)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.Messages[0].Content != "The quick brown fox." {
t.Fatalf("unexpected required input rendering: %q", res.Messages[0].Content)
}
})
t.Run("optional input absent and not referenced", func(t *testing.T) {
def := &domain.PromptDefinition{
Inputs: []domain.PromptInput{
{Name: "transcript", Required: true},
{Name: "glossary", Required: false},
},
Templates: []domain.PromptMessageTemplate{
{Role: "user", Content: "Transcript: {{input \"transcript\"}}"},
},
}
res, err := renderer.Render(ctx, def, inputs, vars)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(res.Messages) != 1 {
t.Fatalf("expected one rendered message, got %d", len(res.Messages))
}
})
t.Run("optional input absent but referenced, expecting failure", func(t *testing.T) {
def := &domain.PromptDefinition{
Inputs: []domain.PromptInput{
{Name: "transcript", Required: true},
{Name: "glossary", Required: false},
},
Templates: []domain.PromptMessageTemplate{
{Role: "user", Content: "Glossary: {{input \"glossary\"}}"},
},
}
_, err := renderer.Render(ctx, def, inputs, vars)
if !errors.Is(err, ErrRenderFailure) {
t.Fatalf("expected ErrRenderFailure, got %v", err)
}
if !errors.Is(err, ErrUnknownInput) {
t.Fatalf("expected ErrUnknownInput, got %v", err)
}
})
t.Run("required input missing, expecting failure", func(t *testing.T) {
def := &domain.PromptDefinition{
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
Templates: []domain.PromptMessageTemplate{
{Role: "user", Content: "Analyze this: {{input \"transcript\"}}"},
},
}
_, err := renderer.Render(ctx, def, map[string]*domain.Artifact{}, vars)
if !errors.Is(err, ErrMissingRequiredInput) {
t.Fatalf("expected ErrMissingRequiredInput, got %v", err)
}
})
t.Run("invalid template syntax", func(t *testing.T) {
def := &domain.PromptDefinition{
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
Templates: []domain.PromptMessageTemplate{
{Role: "user", Content: "Hello {{.unclosed"},
},
}
_, err := renderer.Render(ctx, def, inputs, vars)
if !errors.Is(err, ErrInvalidTemplate) {
t.Fatalf("expected ErrInvalidTemplate, got %v", err)
}
})
t.Run("unknown input reference", func(t *testing.T) {
def := &domain.PromptDefinition{
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
Templates: []domain.PromptMessageTemplate{
{Role: "user", Content: "Hello {{input \"ghost\"}}"},
},
}
_, err := renderer.Render(ctx, def, inputs, vars)
if !errors.Is(err, ErrRenderFailure) {
t.Fatalf("expected ErrRenderFailure, got %v", err)
}
if !errors.Is(err, ErrUnknownInput) {
t.Fatalf("expected ErrUnknownInput, got %v", err)
}
})
t.Run("empty message role", func(t *testing.T) {
def := &domain.PromptDefinition{
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
Templates: []domain.PromptMessageTemplate{
{Role: "", Content: "Hello"},
},
}
_, err := renderer.Render(ctx, def, inputs, vars)
if !errors.Is(err, ErrInvalidMessageRole) {
t.Fatalf("expected ErrInvalidMessageRole, got %v", err)
}
})
}

View File

@@ -1,484 +0,0 @@
package promptdef
import (
"bytes"
"context"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"gitea.maximumdirect.net/eric/scriptorium/internal/filecatalog"
"gopkg.in/yaml.v3"
)
var (
ErrPromptDefinitionNotFound = errors.New("prompt definition not found")
ErrInvalidYAML = errors.New("invalid YAML format")
ErrInvalidPromptDefinition = errors.New("invalid prompt definition configuration")
)
type filesystemRepository struct {
dir string
}
type fsRepository struct {
fsys fs.FS
root string
}
type promptDefinitionFile struct {
ID string `yaml:"id"`
Version string `yaml:"version"`
DefaultProfile *string `yaml:"default_profile"`
Description string `yaml:"description"`
SessionID string `yaml:"session_id"`
Inputs []promptInputFile `yaml:"inputs"`
Messages []promptMessageFile `yaml:"messages"`
Output promptOutputContractFile `yaml:"output"`
}
type promptInputFile struct {
Name string `yaml:"name"`
Required bool `yaml:"required"`
ContentType string `yaml:"content_type"`
Description string `yaml:"description"`
}
type promptMessageFile struct {
Role string `yaml:"role"`
Content string `yaml:"content"`
ContentFile string `yaml:"content_file"`
CacheControl *cacheControlFile `yaml:"cache_control"`
}
type cacheControlFile struct {
Type string `yaml:"type"`
TTL string `yaml:"ttl"`
}
type promptOutputContractFile struct {
Format domain.OutputFormat `yaml:"format"`
ValidationMode domain.ValidationMode `yaml:"validation_mode"`
SchemaPath string `yaml:"schema_path"`
RepairAttempts int `yaml:"repair_attempts"`
}
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)
}
files, err := filecatalog.FindYAMLFiles(ctx, r.dir)
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.RelativePath(r.dir, fullPath)
fileMatch := filecatalog.Stem(filepath.Base(fullPath)) == id
raw, err := loadPromptDefinitionFile(fullPath)
if err != nil {
if fileMatch || promptDefinitionFileHasID(fullPath, id) {
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, relPath, err)
}
continue
}
def, err := normalizePromptDefinition(raw, fullPath)
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 (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
}
func loadPromptDefinitionFile(path string) (*promptDefinitionFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read prompt definition file: %w", err)
}
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 promptDefinitionFileHasID(path string, id string) bool {
data, err := os.ReadFile(path)
if err != nil {
return false
}
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 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")
}
id := strings.TrimSpace(raw.ID)
if id == "" {
return nil, errors.New("id is required")
}
version := strings.TrimSpace(raw.Version)
if version == "" {
return nil, errors.New("version is required")
}
if len(raw.Messages) == 0 {
return nil, errors.New("at least one message is required")
}
inputs := make([]domain.PromptInput, 0, len(raw.Inputs))
seenInputNames := make(map[string]struct{}, len(raw.Inputs))
for i, in := range raw.Inputs {
name := strings.TrimSpace(in.Name)
if name == "" {
return nil, fmt.Errorf("input %d has empty name", i)
}
if _, exists := seenInputNames[name]; exists {
return nil, fmt.Errorf("duplicate input name %q", name)
}
seenInputNames[name] = struct{}{}
inputs = append(inputs, domain.PromptInput{
Name: name,
Required: in.Required,
ContentType: strings.TrimSpace(in.ContentType),
Description: strings.TrimSpace(in.Description),
})
}
templates := make([]domain.PromptMessageTemplate, 0, len(raw.Messages))
for i, msg := range raw.Messages {
role := strings.TrimSpace(msg.Role)
if role == "" {
return nil, fmt.Errorf("message %d role is required", i)
}
hasContent := strings.TrimSpace(msg.Content) != ""
hasContentFile := strings.TrimSpace(msg.ContentFile) != ""
if hasContent == hasContentFile {
return nil, fmt.Errorf("message %d (%s) must set exactly one of content or content_file", i, role)
}
cacheControl, err := normalizeCacheControl(msg.CacheControl)
if err != nil {
return nil, fmt.Errorf("message %d (%s) cache_control: %w", i, role, err)
}
templateContent := msg.Content
resolvedContentFile := ""
if hasContentFile {
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 = body
resolvedContentFile = resolvedPath
}
templates = append(templates, domain.PromptMessageTemplate{
Role: role,
Content: templateContent,
ContentFile: resolvedContentFile,
CacheControl: cacheControl,
})
}
if !isValidOutputFormat(raw.Output.Format) {
return nil, fmt.Errorf("invalid output format: %q", raw.Output.Format)
}
if !isValidValidationMode(raw.Output.ValidationMode) {
return nil, fmt.Errorf("invalid validation mode: %q", raw.Output.ValidationMode)
}
if raw.Output.ValidationMode == domain.ValidationJSONSchema && strings.TrimSpace(raw.Output.SchemaPath) == "" {
return nil, errors.New("output.schema_path is required when output.validation_mode is json_schema")
}
if raw.Output.RepairAttempts < 0 {
return nil, errors.New("output.repair_attempts must be greater than or equal to 0")
}
defaultProfile := ""
if raw.DefaultProfile != nil {
defaultProfile = strings.TrimSpace(*raw.DefaultProfile)
if defaultProfile == "" {
return nil, errors.New("default_profile must be a non-empty string when set")
}
}
return &domain.PromptDefinition{
ID: id,
Version: version,
DefaultProfile: defaultProfile,
Description: strings.TrimSpace(raw.Description),
SessionID: strings.TrimSpace(raw.SessionID),
Inputs: inputs,
Templates: templates,
OutputFormat: raw.Output.Format,
Validation: domain.OutputContract{
Format: raw.Output.Format,
ValidationMode: raw.Output.ValidationMode,
SchemaPath: strings.TrimSpace(raw.Output.SchemaPath),
RepairAttempts: raw.Output.RepairAttempts,
},
}, nil
}
func normalizeCacheControl(raw *cacheControlFile) (*domain.CacheControl, error) {
if raw == nil {
return nil, nil
}
cacheType := strings.TrimSpace(raw.Type)
if cacheType == "" {
return nil, errors.New("type is required")
}
if domain.CacheControlType(cacheType) != domain.CacheControlEphemeral {
return nil, fmt.Errorf("unsupported type %q", cacheType)
}
ttl := strings.TrimSpace(raw.TTL)
if ttl != "" && ttl != "1h" {
return nil, fmt.Errorf("unsupported ttl %q", ttl)
}
return &domain.CacheControl{
Type: domain.CacheControlType(cacheType),
TTL: ttl,
}, nil
}
func isValidOutputFormat(f domain.OutputFormat) bool {
switch f {
case domain.FormatText, domain.FormatMarkdown, domain.FormatJSON:
return true
default:
return false
}
}
func isValidValidationMode(m domain.ValidationMode) bool {
switch m {
case domain.ValidationNone, domain.ValidationBasic, domain.ValidationJSON, domain.ValidationJSONSchema:
return true
default:
return false
}
}

View File

@@ -1,12 +0,0 @@
package promptdef
import (
"context"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
// Repository loads prompt definitions.
type Repository interface {
GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error)
}

View File

@@ -1,526 +0,0 @@
package promptdef
import (
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
func TestFilesystemRepository_GetPromptDefinition(t *testing.T) {
tmpDir := t.TempDir()
if err := copyTree("testdata", tmpDir); err != nil {
t.Fatalf("failed to copy testdata: %v", err)
}
repo := NewFilesystemRepository(tmpDir)
ctx := context.Background()
t.Run("valid inline prompt", func(t *testing.T) {
p, err := repo.GetPromptDefinition(ctx, "valid-inline", "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if p.ID != "valid-inline" {
t.Fatalf("unexpected id: %q", p.ID)
}
if p.Version != "1.0.0" {
t.Fatalf("unexpected version: %q", p.Version)
}
if p.OutputFormat != domain.FormatMarkdown {
t.Fatalf("unexpected output format: %q", p.OutputFormat)
}
if p.Validation.ValidationMode != domain.ValidationBasic {
t.Fatalf("unexpected validation mode: %q", p.Validation.ValidationMode)
}
if len(p.Templates) != 2 {
t.Fatalf("expected 2 messages, got %d", len(p.Templates))
}
if len(p.Inputs) != 1 {
t.Fatalf("expected 1 input, got %d", len(p.Inputs))
}
if p.Inputs[0].ContentType != "text/markdown" {
t.Fatalf("expected input content_type to be preserved, got %q", p.Inputs[0].ContentType)
}
})
t.Run("valid file-backed prompt", func(t *testing.T) {
p, err := repo.GetPromptDefinition(ctx, "valid-file-backed", "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(p.Templates) != 2 {
t.Fatalf("expected 2 messages, got %d", len(p.Templates))
}
if !strings.Contains(p.Templates[1].Content, "{{input \"transcript\"}}") {
t.Fatalf("expected content_file template body to be loaded, got %q", p.Templates[1].Content)
}
if p.Templates[1].ContentFile == "" {
t.Fatal("expected ContentFile source metadata to be preserved")
}
if !filepath.IsAbs(p.Templates[1].ContentFile) {
t.Fatalf("expected resolved content_file path to be absolute, got %q", p.Templates[1].ContentFile)
}
})
t.Run("valid cache control with ttl", func(t *testing.T) {
p, err := repo.GetPromptDefinition(ctx, "valid-cache-control-ttl", "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(p.Templates) != 2 {
t.Fatalf("expected 2 messages, got %d", len(p.Templates))
}
assertCacheControl(t, p.Templates[0].CacheControl, domain.CacheControlEphemeral, "1h")
if p.Templates[1].CacheControl != nil {
t.Fatalf("expected second message cache control to be nil, got %#v", p.Templates[1].CacheControl)
}
})
t.Run("valid cache control without ttl", func(t *testing.T) {
p, err := repo.GetPromptDefinition(ctx, "valid-cache-control-without-ttl", "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(p.Templates) != 2 {
t.Fatalf("expected 2 messages, got %d", len(p.Templates))
}
assertCacheControl(t, p.Templates[0].CacheControl, domain.CacheControlEphemeral, "")
if p.Templates[1].CacheControl != nil {
t.Fatalf("expected second message cache control to be nil, got %#v", p.Templates[1].CacheControl)
}
})
t.Run("valid session id template", func(t *testing.T) {
p, err := repo.GetPromptDefinition(ctx, "valid-session-id", "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if p.SessionID != "{{ .session_id }}" {
t.Fatalf("expected trimmed session_id template, got %q", p.SessionID)
}
})
t.Run("valid nested file-backed prompt resolves content file relative to nested YAML", func(t *testing.T) {
nestedDir := filepath.Join(tmpDir, "dnd", "recap")
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
t.Fatal(err)
}
writePromptTestFile(t, filepath.Join(nestedDir, "nested_recap.yaml"), `
id: nested-recap
version: "1.0.0"
messages:
- role: user
content_file: ./nested_recap.user.tmpl
output:
format: markdown
validation_mode: basic
repair_attempts: 0
`)
writePromptTestFile(t, filepath.Join(nestedDir, "nested_recap.user.tmpl"), `Nested recap: {{input "transcript"}}`)
p, err := repo.GetPromptDefinition(ctx, "nested-recap", "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(p.Templates) != 1 {
t.Fatalf("expected one template, got %d", len(p.Templates))
}
if !strings.Contains(p.Templates[0].Content, "Nested recap") {
t.Fatalf("expected nested content file body, got %q", p.Templates[0].Content)
}
if !strings.Contains(p.Templates[0].ContentFile, filepath.Join("dnd", "recap", "nested_recap.user.tmpl")) {
t.Fatalf("expected nested content file path, got %q", p.Templates[0].ContentFile)
}
})
t.Run("prompt with default_profile", func(t *testing.T) {
p, err := repo.GetPromptDefinition(ctx, "with-default-profile", "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if p.DefaultProfile != "local-default" {
t.Fatalf("unexpected default profile: %q", p.DefaultProfile)
}
if len(p.Inputs) != 1 {
t.Fatalf("expected one input, got %d", len(p.Inputs))
}
if p.Inputs[0].ContentType != "" {
t.Fatalf("expected missing content_type to remain empty, got %q", p.Inputs[0].ContentType)
}
})
t.Run("duplicate prompt IDs fail as ambiguous", func(t *testing.T) {
writePromptTestFile(t, filepath.Join(tmpDir, "duplicate_a.yaml"), `
id: duplicate-prompt
version: "1.0.0"
messages:
- role: user
content: First duplicate.
output:
format: markdown
validation_mode: basic
repair_attempts: 0
`)
nestedDir := filepath.Join(tmpDir, "nested")
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
t.Fatal(err)
}
writePromptTestFile(t, filepath.Join(nestedDir, "duplicate_b.yaml"), `
id: duplicate-prompt
version: "2.0.0"
messages:
- role: user
content: Second duplicate.
output:
format: markdown
validation_mode: basic
repair_attempts: 0
`)
_, err := repo.GetPromptDefinition(ctx, "duplicate-prompt", "")
if !errors.Is(err, ErrInvalidPromptDefinition) {
t.Fatalf("expected duplicate prompt to return ErrInvalidPromptDefinition, got %v", err)
}
for _, want := range []string{"duplicate prompt definition id", "duplicate_a.yaml", filepath.Join("nested", "duplicate_b.yaml")} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("expected error to contain %q, got %v", want, err)
}
}
})
t.Run("duplicate prompt ID and requested version fails as ambiguous", func(t *testing.T) {
writePromptTestFile(t, filepath.Join(tmpDir, "version_duplicate_a.yaml"), `
id: duplicate-version-prompt
version: "1.0.0"
messages:
- role: user
content: First duplicate version.
output:
format: markdown
validation_mode: basic
repair_attempts: 0
`)
nestedDir := filepath.Join(tmpDir, "versioned")
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
t.Fatal(err)
}
writePromptTestFile(t, filepath.Join(nestedDir, "version_duplicate_b.yaml"), `
id: duplicate-version-prompt
version: "1.0.0"
messages:
- role: user
content: Second duplicate version.
output:
format: markdown
validation_mode: basic
repair_attempts: 0
`)
_, err := repo.GetPromptDefinition(ctx, "duplicate-version-prompt", "1.0.0")
if !errors.Is(err, ErrInvalidPromptDefinition) {
t.Fatalf("expected duplicate prompt version to return ErrInvalidPromptDefinition, got %v", err)
}
for _, want := range []string{"duplicate prompt definition id", "version \"1.0.0\"", "version_duplicate_a.yaml", filepath.Join("versioned", "version_duplicate_b.yaml")} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("expected error to contain %q, got %v", want, err)
}
}
})
t.Run("non-matching malformed nested prompt is ignored for not found lookup", func(t *testing.T) {
nestedDir := filepath.Join(tmpDir, "broken")
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
t.Fatal(err)
}
writePromptTestFile(t, filepath.Join(nestedDir, "unrelated.yaml"), "id: [")
_, err := repo.GetPromptDefinition(ctx, "does-not-exist-even-with-broken-nested-file", "")
if !errors.Is(err, ErrPromptDefinitionNotFound) {
t.Fatalf("expected ErrPromptDefinitionNotFound, got %v", err)
}
})
t.Run("strict decode failure in nested prompt matches by YAML ID", func(t *testing.T) {
nestedDir := filepath.Join(tmpDir, "strict")
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
t.Fatal(err)
}
writePromptTestFile(t, filepath.Join(nestedDir, "not_named_like_id.yaml"), `
id: nested-strict-error
version: "1.0.0"
unknown_field: true
messages:
- role: user
content: Invalid because of unknown field.
output:
format: markdown
validation_mode: basic
repair_attempts: 0
`)
_, err := repo.GetPromptDefinition(ctx, "nested-strict-error", "")
if !errors.Is(err, ErrInvalidYAML) {
t.Fatalf("expected ErrInvalidYAML, got %v", err)
}
if !strings.Contains(err.Error(), filepath.Join("strict", "not_named_like_id.yaml")) {
t.Fatalf("expected nested path in error, got %v", err)
}
})
t.Run("version lookup", func(t *testing.T) {
_, err := repo.GetPromptDefinition(ctx, "valid-inline", "9.9.9")
if !errors.Is(err, ErrPromptDefinitionNotFound) {
t.Fatalf("expected ErrPromptDefinitionNotFound, got %v", err)
}
})
cases := []struct {
name string
id string
targetErr error
errSubstrs []string
}{
{name: "invalid YAML", id: "invalid_yaml", targetErr: ErrInvalidYAML},
{name: "missing id", id: "missing_id", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"id is required"}},
{name: "no messages", id: "no_messages", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"at least one message is required"}},
{name: "both content and content_file", id: "both_content_and_content_file", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"exactly one"}},
{name: "neither content nor content_file", id: "neither_content_nor_content_file", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"exactly one"}},
{name: "missing content_file", id: "missing_content_file", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"failed to read content_file"}},
{name: "duplicate input names", id: "duplicate_input_names", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"duplicate input name"}},
{name: "invalid validation mode", id: "invalid_validation_mode", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"invalid validation mode"}},
{name: "json_schema without schema_path", id: "json_schema_without_schema_path", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"schema_path"}},
{name: "unknown input field", id: "unknown_input_field", targetErr: ErrInvalidYAML, errSubstrs: []string{"field unknown_input_setting not found"}},
{name: "empty cache control type", id: "empty_cache_control_type", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"cache_control", "type is required"}},
{name: "unsupported cache control type", id: "unsupported_cache_control_type", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"cache_control", "unsupported type"}},
{name: "unsupported cache control ttl", id: "unsupported_cache_control_ttl", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"cache_control", "unsupported ttl"}},
{name: "unknown cache control field", id: "unknown_cache_control_field", targetErr: ErrInvalidYAML, errSubstrs: []string{"field unexpected not found"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := repo.GetPromptDefinition(ctx, tc.id, "")
if !errors.Is(err, tc.targetErr) {
t.Fatalf("expected %v, got %v", tc.targetErr, err)
}
for _, sub := range tc.errSubstrs {
if !strings.Contains(err.Error(), sub) {
t.Fatalf("expected error to contain %q, got %v", sub, err)
}
}
})
}
t.Run("prompt definition not found", func(t *testing.T) {
_, err := repo.GetPromptDefinition(ctx, "does-not-exist", "")
if !errors.Is(err, ErrPromptDefinitionNotFound) {
t.Fatalf("expected ErrPromptDefinitionNotFound, got %v", err)
}
})
}
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 {
t.Fatal("expected cache control, got nil")
}
if got.Type != wantType {
t.Fatalf("unexpected cache control type: got %q want %q", got.Type, wantType)
}
if got.TTL != wantTTL {
t.Fatalf("unexpected cache control ttl: got %q want %q", got.TTL, wantTTL)
}
}
func writePromptTestFile(t *testing.T, path string, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(strings.TrimLeft(content, "\n")), 0o644); err != nil {
t.Fatalf("failed to write prompt test file %q: %v", path, err)
}
}
func copyTree(src, dst string) error {
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
if rel == "." {
return nil
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(target, data, 0o644)
})
}

View File

@@ -1,10 +0,0 @@
id: both-content-and-content-file
version: "1.0.0"
messages:
- role: user
content: "Hi"
content_file: ./messages/user_prompt.tmpl
output:
format: text
validation_mode: none
repair_attempts: 0

View File

@@ -1,14 +0,0 @@
id: duplicate-input-names
version: "1.0.0"
inputs:
- name: transcript
required: true
- name: transcript
required: false
messages:
- role: user
content: "Hi"
output:
format: text
validation_mode: none
repair_attempts: 0

View File

@@ -1,10 +0,0 @@
id: empty-cache-control-type
version: "1.0.0"
messages:
- role: system
content: "Use cached instructions."
cache_control: {}
output:
format: markdown
validation_mode: basic
repair_attempts: 0

Some files were not shown because too many files have changed in this diff Show More