diff --git a/doc.go b/doc.go index 42400e6..f520dcc 100644 --- a/doc.go +++ b/doc.go @@ -1,8 +1,39 @@ // Package promptkit provides an embeddable engine for preparing and executing // prompt-defined LLM workflows. // -// Applications construct an Engine with NewEngine, select filesystem or -// in-memory definition sources with options, and use Prepare or Run to execute -// requests. Concrete repositories, validators, and outbound clients remain -// internal implementation details. +// Applications construct an [Engine] with [NewEngine], select filesystem or +// in-memory sources with options, and call [Engine.Prepare] or [Engine.Run]. +// Concrete repositories, validators, and the built-in OpenAI-compatible client +// remain internal implementation details. +// +// # Concurrency and ownership +// +// An Engine supports concurrent Prepare and Run calls. An injected [LLMClient] +// or [ArtifactReader] can therefore receive concurrent calls and must be safe +// for that use. +// +// NewEngine copies in-memory profiles. Prepare and Run copy request maps, +// slices, pointer values, and JSON-compatible extra parameters before using +// them. Returned values and values passed to extension interfaces are likewise +// isolated from engine state. Callers own those copies and may mutate them +// after the call that supplied or returned them. +// +// # JSON +// +// Stable JSON representations are provided for [PreparedRun], [RunResult], +// [Artifact], [ExecutionTarget], [OutputContract], [ValidationResult], +// [TokenUsage], [RenderedPrompt], [RenderedMessage], [CacheControl], +// [StructuredOutputSpec], [StructuredOutputJSONSpec], [GenerateRequest], +// [GenerateResponse], [ExecutionTargetPresence], and the string value types +// used by those values. +// +// Construction values, including [Config], [RunRequest], [ArtifactRef], +// [ExecutionTargetOverride], [Profile], and +// [OpenAICompatibleProfileConfig], do not have stable JSON representations. +// Direct API keys are nevertheless excluded from JSON for every public value. +// +// JSON timestamps use time.Time's RFC 3339 encoding and are omitted when zero. +// PreparedRun and RunResult durations are encoded as integer milliseconds in +// duration_ms and omitted when zero. Run IDs and all exposed hashes are opaque: +// their spelling, length, character set, and algorithm are not API contracts. package promptkit diff --git a/docs/consumers/pkg-promptkit.md b/docs/consumers/pkg-promptkit.md index 989e887..3ca4502 100644 --- a/docs/consumers/pkg-promptkit.md +++ b/docs/consumers/pkg-promptkit.md @@ -1,151 +1,122 @@ # Package `promptkit` -Import path: +## Purpose + +This guide helps Go consumers assemble Promptkit and choose the main +preparation or execution workflow. The declarations and GoDoc in the +[root package](../../doc.go) own exact field, option, serialization, +concurrency, ownership, failure, and cancellation semantics. The +[framework format reference](../formats.md) owns prompt, profile, and schema +file contracts. + +Import the package as: ```go import "gitea.maximumdirect.net/eric/promptkit" ``` -Package `promptkit` is the supported Go contract for in-process prompt -preparation and execution. The declarations and their GoDoc in the -[root package](../../doc.go) own the exact API; this guide explains how the -pieces are used together. The [framework format reference](../formats.md) owns -prompt, profile, and schema file contracts. +The following Go fragments are illustrative and omit surrounding package, +import, and error-handling code. Use the maintained example for a complete +program. -## Engine Construction And Sources +## Construct An Engine -Construct an engine with [`NewEngine`, `Config`, and -`Option`](../../engine.go). `PromptDir` is required unless a prompt source -option is supplied. `ProfileDir` optionally overlays built-in profiles, and an -empty `SchemaDir` uses the current directory. `Timeout` is the transport-wide -safety cap for the built-in OpenAI-compatible client. An optional `HTTPClient` -is cloned; its positive timeout takes precedence. +Create an engine with +[`NewEngine`](../../engine.go). A directory-backed setup supplies a prompt +directory and may supply profile and schema directories: -Nil options are ignored. Invalid construction, including a nil injected client -or artifact reader, returns an error matching `ErrInvalidConfig`. +```go +engine, err := promptkit.NewEngine(promptkit.Config{ + PromptDir: "prompts", + ProfileDir: "profiles", + SchemaDir: "schemas", +}) +``` -The [source options](../../engine.go) replace their matching directory source: - -- `WithPromptFS` and `WithPromptFile` select prompt definitions; -- `WithProfileFS` and `WithProfileFile` overlay built-in profiles; -- `WithProfiles` adds in-memory profiles ahead of file and built-in profiles; -- `WithSchemaFS` and `WithSchemaFile` select JSON Schema documents; -- `WithLLMClient` replaces the built-in model client; and -- `WithArtifactReader` replaces the default reader for every input. - -Source selection, path resolution, strict decoding, profile overlays, and -file-to-request precedence are defined in the +Options support single-file or `fs.FS` sources, in-memory profiles, and +injected artifact or model clients. Consult the +[constructor and option GoDoc](../../engine.go) for composition, precedence, +validation, and default transport behavior. Source discovery, format +validation, and profile precedence are defined by the [framework format reference](../formats.md). -Per-generation timeout values from profiles or requests are independent of -the transport cap and caller context. An explicit request value of zero -disables only the per-generation deadline. The -[outbound integration contract](../integrations/openai-compatible-chat.md#timeout-and-cancellation) -defines the complete timeout layering. +## Prepare Without Model Execution -## Preparation And Execution +[`Engine.Prepare`](../../engine.go) resolves the selected prompt and profile, +loads inputs and any structured-output schema, and renders messages without +calling a model client: -[`Engine.Prepare` and `Engine.Run`](../../engine.go) accept the public -[`RunRequest`](../../types.go). `Prepare` resolves the prompt, profile, input -artifacts, validation contract, and rendered messages without calling an LLM. -`Run` performs the same preparation, calls the configured client, and validates -the generated content. The maintained +```go +prepared, err := engine.Prepare(ctx, promptkit.RunRequest{ + PromptID: "meeting.summary", + Inputs: map[string]promptkit.ArtifactRef{ + "note": promptkit.Inline("Synthetic meeting notes"), + }, +}) +``` + +The maintained [offline preparation example](../../examples/go-library/prepare/main.go) -provides a complete runnable workflow using a prompt file, in-memory profile, -and inline input. +shows a complete runnable setup with a prompt file, in-memory profile, and +inline input. Exact request requirements and prepared-result fields belong to +the [`RunRequest` and `PreparedRun` GoDoc](../../types.go). -[`PreparedRun` and `RunResult`](../../types.go) expose copied public values. -Preparation returns effective settings, hashes, rendered messages, selected -profile, structured-output information, and timing without resolved secrets or -model output. Execution adds the generated artifact and raw output, validation -state, model metadata, usage, run ID, and duration. +## Execute And Validate -A generated-content validation failure returns a result with -`Validation.Status == ValidationFailed`. An inability to perform validation -returns an error matching `ErrValidation`. +[`Engine.Run`](../../engine.go) performs the same preparation, invokes the +configured model client, classifies the generated artifact, and validates the +content. A completed content check may return `ValidationFailed` in the result; +an operational inability to validate returns an error. -## Requests, Inputs, And Overrides +Use the [`RunResult` and `ValidationResult` GoDoc](../../types.go) for the +returned data and the `Engine.Run` GoDoc for failure and cancellation +semantics. The +[OpenAI-compatible integration contract](../integrations/openai-compatible-chat.md) +owns the built-in client's outbound HTTP behavior. -The [request and value declarations](../../types.go) own the available fields, -serialized constants, and result shapes. Use `File`, `Inline`, or -`InlineWithURI` to construct artifact references. The -[framework format reference](../formats.md) defines declared inputs, template -references, output contracts, and the relationship between file values and -request overrides. +## Inputs, Profiles, And Overrides -`ExecutionTargetOverride` uses pointers for numeric settings so an explicit -zero remains distinct from no override. `ExtraParams` accepts JSON-compatible -strings, booleans, finite numbers, string-keyed objects, arrays or slices, and -nil. Unsupported values, non-string map keys, non-finite numbers, and cycles -match `ErrInvalidConfig` in profiles or `ErrInvalidRequest` in request -overrides. +Use `File`, `Inline`, or `InlineWithURI` to construct request inputs. A request +can select a profile explicitly or use the prompt's default profile, and can +replace execution settings or the complete output contract. -Returned requests, profiles, prepared values, results, artifacts, maps, and -slices are isolated from internal engine state. Consumers and injected -extensions should not retain or mutate values owned by another caller. +The [public value GoDoc](../../types.go) defines nil, empty, zero, replacement, +copy, and credential behavior. The +[framework format reference](../formats.md) defines how those request values +interact with prompt definitions, file-backed profiles, built-ins, schemas, +and framework defaults. -## Profiles And Credentials +For programmatic profiles, +[`OpenAICompatibleProfile`](../../profiles.go) converts ordinary +OpenAI-compatible settings into a value accepted by `WithProfiles`. -[`OpenAICompatibleProfile`](../../profiles.go) constructs an ordinary -in-memory profile for an OpenAI-compatible chat-completions endpoint. -`WithProfiles` rejects duplicate IDs in one call and gives in-memory profiles -precedence over explicit file sources and built-ins. +## Credentials -Raw API keys do not belong in profiles. File-backed profiles may name an -environment variable, while an in-memory profile can require a request key. -A direct `RunRequest.APIKey` is request-scoped and takes precedence over an -environment lookup for the built-in client. Profile fields, ranges, built-ins, -precedence, and credential rules are owned by the -[framework format reference](../formats.md). - -API keys are excluded from JSON, prepared values, and results. The public -`String` and `GoString` methods report only whether a direct key is present. -Avoid reflection-based dumps of request structs, which can bypass that -redaction. +File-backed profiles name an environment variable; in-memory profiles can +require a direct request key. Direct keys are request-scoped and are excluded +from supported JSON values and the package's `String` and `GoString` +summaries. The exact precedence and redaction guarantees belong to +[`RunRequest`, `GenerateRequest`, and the profile GoDoc](../../types.go). ## Extension Interfaces -The [`LLMClient`, `GenerateRequest`, and -`GenerateResponse`](../../types.go) boundary lets a consumer replace model -generation. Injected clients receive copied rendered messages, effective -settings, explicit numeric-setting presence, structured-output constraints, -and the request-scoped key. They return generated content and token usage. +Inject an [`LLMClient` or `ArtifactReader`](../../types.go) when the built-in +behavior does not fit the application. Their GoDoc defines concurrent use, +context handling, ownership of copied values, nil responses, and preservation +of collaborator errors. -The [`ArtifactReader`](../../types.go) boundary replaces the default inline and -file reader for every input. Readers provide artifact content and metadata; the -engine fills an empty artifact name from the input-map key. A reader error -matches `ErrArtifactLoad` while preserving the original identity for -`errors.Is`. A nil artifact with a nil error is also an artifact-load failure. +## Handle Errors -Extensions should honor context cancellation and avoid logging raw prompts, -artifacts, or credentials. +Use `errors.Is` with the +[public error sentinels and operation GoDoc](../../engine.go). The declarations +distinguish invalid construction, invalid requests, absent sources, +source-loading failures, collaborator failures, and operational validation +failures. Specific request conditions may also match the broader +`ErrInvalidRequest`, and injected collaborator identities are preserved where +documented. -## Errors - -The [public error declarations](../../engine.go) and -[mapping](../../errors.go) preserve these sentinel checks through `errors.Is`: - -- `ErrInvalidConfig` -- `ErrInvalidRequest` -- `ErrPromptNotFound` -- `ErrProfileNotFound` -- `ErrProfileRequired` -- `ErrPromptLoad` -- `ErrProfileLoad` -- `ErrAPIKeyEnvMissing` -- `ErrArtifactLoad` -- `ErrPromptRender` -- `ErrLLMGenerate` -- `ErrValidation` - -`ErrProfileRequired` and `ErrAPIKeyEnvMissing` also match -`ErrInvalidRequest`, allowing either broad request handling or a specific -condition. Wrapped collaborator errors retain their identity where the public -contract promises it. - -## Consumer Boundary +## Application Boundary Promptkit is an importable library. It does not own a command, inbound HTTP -API, process configuration, or deployment policy. Scriptorium is one -downstream application that maps this root package contract into those -application concerns. +API, process configuration, or deployment policy. Applications map the root +package's results and errors into those concerns. diff --git a/docs/roadmap/documentation.md b/docs/roadmap/documentation.md index 2608742..3236d1f 100644 --- a/docs/roadmap/documentation.md +++ b/docs/roadmap/documentation.md @@ -57,47 +57,15 @@ that is currently ambiguous. - [x] Confirm the supported JSON Schema dialect and reference boundaries, including whether remote references are allowed. -The resolved contract is: +The selected exported API contracts are implemented and tested. Their durable +definitions now belong to the root package declarations and GoDoc. -- `RunRequest.Metadata` had no observable purpose and has been removed from the - public and internal request values. -- One engine supports overlapping `Prepare` and `Run` calls. Built-in - collaborators satisfy that contract; injected collaborators may be called - concurrently and therefore share responsibility for concurrency safety. -- Options are applied in order. Within each prompt-source, profile-source, - in-memory-profile, schema-source, model-client, or artifact-reader category, - the last non-nil valid option replaces the earlier value for that category. - An invalid earlier option still makes construction fail. -- The supported JSON values are `PreparedRun`, `RunResult`, `Artifact`, - `ExecutionTarget`, `OutputContract`, `ValidationResult`, `TokenUsage`, - `RenderedPrompt`, `RenderedMessage`, `CacheControl`, - `StructuredOutputSpec`, `StructuredOutputJSONSpec`, `GenerateRequest`, - `GenerateResponse`, `ExecutionTargetPresence`, and the public string value - types used by them. Construction inputs such as `Config`, `RunRequest`, - `ArtifactRef`, `ExecutionTargetOverride`, `Profile`, and - `OpenAICompatibleProfileConfig` do not have stable JSON representations. - Request-scoped API keys remain excluded from JSON as a security guarantee, - including on otherwise unsupported construction values. -- JSON timestamps use `time.Time`'s RFC 3339 representation and are omitted - when zero. Both prepared and completed run durations use integer - milliseconds in `duration_ms` and are omitted when zero. A prepared duration - measures preparation only; a run-result duration measures the complete run, - including its preparation, generation, and validation. -- Run IDs, prompt hashes, rendered-prompt hashes, input hashes, and artifact - hashes are non-empty correlation or equality values where produced. Their - spelling, length, character set, and algorithm are opaque and not stable - formats. -- The built-in transport timeout defaults to 10 minutes. A zero or negative - `Config.Timeout` selects that default. A supplied HTTP client's positive - timeout takes precedence; its zero or negative timeout inherits the positive - configured timeout or the default. These transport semantics are independent - of caller cancellation and per-generation timeout settings. -- JSON Schema uses Draft 2020-12; omission of `$schema` selects that dialect - and an explicit different dialect is rejected. Same-document fragment - references are supported. Relative references may load other schema - documents only within a configured directory or `fs.FS` schema root. - A single-file schema source supports only references contained in that - document. Absolute, escaping, and remote references are not allowed. +One format-level decision remains here until Stage 5 moves it to the framework +format reference: JSON Schema uses Draft 2020-12, with that dialect selected +when `$schema` is omitted. Same-document fragments and relative references +contained by a directory or `fs.FS` schema root are supported. A single-file +source supports only references contained in that document. Absolute, +escaping, and remote references are rejected. **Gate:** Each question has an explicit answer backed by existing behavior or by an accepted implementation change and proportionate tests. No later stage @@ -108,21 +76,21 @@ should invent a contract merely to fill a documentation gap. Strengthen the root package declarations so `go doc` is sufficient to understand exact public behavior without relying on internal documents. -- [ ] Add useful field-level GoDoc to configuration, request, profile, +- [x] Add useful field-level GoDoc to configuration, request, profile, execution-target, result, artifact, validation, structured-output, and model client values. -- [ ] Document required fields and nil, empty, and zero-value semantics. -- [ ] Document override, replacement, profile-precedence, and copy-ownership +- [x] Document required fields and nil, empty, and zero-value semantics. +- [x] Document override, replacement, profile-precedence, and copy-ownership behavior where it belongs to the exported API. -- [ ] Document credential inputs, redaction, and the values intentionally +- [x] Document credential inputs, redaction, and the values intentionally excluded from serialization. -- [ ] Give each public error sentinel an accurate comment and document the +- [x] Give each public error sentinel an accurate comment and document the supported `errors.Is` relationships. -- [ ] Document engine concurrency and option-composition behavior selected in +- [x] Document engine concurrency and option-composition behavior selected in Stage 1. -- [ ] Document serialization, time, run-ID, and hash semantics selected in +- [x] Document serialization, time, run-ID, and hash semantics selected in Stage 1. -- [ ] Review constructor, option, extension-interface, `Prepare`, and `Run` +- [x] Review constructor, option, extension-interface, `Prepare`, and `Run` GoDoc for complete failure and cancellation expectations. Update the [consumer guide](../consumers/pkg-promptkit.md) to summarize and link diff --git a/engine.go b/engine.go index 8a14155..063f576 100644 --- a/engine.go +++ b/engine.go @@ -22,42 +22,93 @@ import ( "gitea.maximumdirect.net/eric/promptkit/internal/validate" ) -// ErrInvalidConfig indicates invalid public engine configuration. +// ErrInvalidConfig identifies invalid engine construction, including missing +// required configuration, invalid options, and a nil Engine receiver. 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") - ErrProfileRequired = errors.New("profile selection is required") - ErrPromptLoad = errors.New("failed to load prompt definition") - ErrProfileLoad = errors.New("failed to load execution profile") + // ErrInvalidRequest identifies a request whose required values, overrides, + // credentials, or effective settings are invalid. + ErrInvalidRequest = errors.New("invalid run request") + // ErrPromptNotFound identifies a requested prompt ID or version that is not + // present in the selected prompt source. It does not also match + // ErrPromptLoad. + ErrPromptNotFound = errors.New("prompt not found") + // ErrProfileNotFound identifies a selected profile ID that is absent from + // every configured profile source. It does not also match ErrProfileLoad. + ErrProfileNotFound = errors.New("profile not found") + // ErrProfileRequired identifies a request for which neither RunRequest.ProfileID + // nor the selected prompt's default profile is present. Such an error also + // matches ErrInvalidRequest. + ErrProfileRequired = errors.New("profile selection is required") + // ErrPromptLoad identifies a failure to read, decode, validate, select, or + // hash a prompt definition, except for the not-found case represented by + // ErrPromptNotFound. + ErrPromptLoad = errors.New("failed to load prompt definition") + // ErrProfileLoad identifies a failure to read, decode, validate, or select + // an execution profile, except for the not-found case represented by + // ErrProfileNotFound. + ErrProfileLoad = errors.New("failed to load execution profile") + // ErrAPIKeyEnvMissing identifies an APIKeyEnv whose environment variable is + // unset or empty when no direct RunRequest.APIKey takes precedence. Such an + // error also matches ErrInvalidRequest. ErrAPIKeyEnvMissing = errors.New("api_key_env points to an unset environment variable") - 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") + // ErrArtifactLoad identifies a failure to resolve an input artifact. Errors + // returned by an injected ArtifactReader remain available through errors.Is. + ErrArtifactLoad = errors.New("failed to load artifact") + // ErrPromptRender identifies a failure to render prompt messages or the + // session ID from the resolved inputs and variables. + ErrPromptRender = errors.New("failed to render prompt") + // ErrLLMGenerate identifies a model-client failure or a nil successful + // response. Errors returned by an injected LLMClient remain available + // through errors.Is. + ErrLLMGenerate = errors.New("failed to generate output") + // ErrValidation identifies an operational failure to load or compile a + // schema or validate output. A completed validation whose Status is + // ValidationFailed is returned in RunResult without this error. + ErrValidation = errors.New("failed to validate output") ) // Engine prepares and runs Promptkit prompt requests. +// +// An Engine is safe for concurrent calls to [Engine.Prepare] and [Engine.Run]. +// Injected collaborators may consequently be invoked concurrently. type Engine struct { runner *usecase.Runner } -// Config configures a public Promptkit engine. +// Config selects the directory-backed sources and built-in model-client +// transport used by [NewEngine]. Config has no stable JSON representation. type Config struct { - PromptDir string + // PromptDir is the directory searched recursively for prompt definitions. + // It is required unless a WithPromptFS or WithPromptFile option supplies the + // prompt source. + PromptDir string + // ProfileDir is an optional directory whose profiles take precedence over + // embedded built-in profiles. An empty value selects only built-ins unless + // profile options are also supplied. ProfileDir string - SchemaDir string + // SchemaDir is the root for JSON Schema files. An empty value uses the + // current directory. WithSchemaFS or WithSchemaFile replaces this source. + SchemaDir string // Timeout is the transport-wide safety cap for the built-in LLM client - // when HTTPClient is absent or has a non-positive timeout. + // when HTTPClient is absent or has a non-positive timeout. A zero or negative + // value selects the 10-minute default. Timeout time.Duration // HTTPClient is cloned for the built-in LLM client. Its positive Timeout - // takes precedence over Config.Timeout as the transport-wide safety cap. + // takes precedence over Timeout. A zero or negative client Timeout inherits + // Timeout or the 10-minute default. The supplied client is not mutated. This + // field is ignored when WithLLMClient is used. HTTPClient *http.Client } // Option customizes engine construction. +// +// NewEngine applies options in argument order and ignores nil options. Within +// each prompt-source, profile-source, in-memory-profile, schema-source, +// model-client, and artifact-reader category, the last non-nil valid option +// replaces earlier options in that category. An invalid option fails +// construction even if a later option would replace it. type Option interface { apply(*engineOptions) error } @@ -82,7 +133,10 @@ type engineOptions struct { artifactSource bool } -// WithLLMClient injects a custom LLM client for execution. +// WithLLMClient replaces the built-in model client used by [Engine.Run]. +// +// A nil client makes NewEngine fail with ErrInvalidConfig. The client may be +// called concurrently and is not used by [Engine.Prepare]. func WithLLMClient(client LLMClient) Option { return optionFunc(func(options *engineOptions) error { if client == nil { @@ -93,7 +147,11 @@ func WithLLMClient(client LLMClient) Option { }) } -// WithArtifactReader injects a reader for every input artifact reference. +// WithArtifactReader replaces the default reader for every input artifact +// reference, regardless of its ArtifactRef.Type. +// +// A nil reader makes NewEngine fail with ErrInvalidConfig. The reader may be +// called concurrently. func WithArtifactReader(reader ArtifactReader) Option { return optionFunc(func(options *engineOptions) error { if reader == nil { @@ -109,6 +167,9 @@ func WithArtifactReader(reader ArtifactReader) Option { // // The source uses the same strict prompt YAML rules as configured prompt // directories, and prompt content_file paths resolve within this source. +// fsys must be non-nil and root must be non-empty; otherwise NewEngine fails +// with ErrInvalidConfig. This option replaces Config.PromptDir and earlier +// prompt-source options. func WithPromptFS(fsys fs.FS, root string) Option { return optionFunc(func(options *engineOptions) error { if fsys == nil { @@ -125,7 +186,9 @@ func WithPromptFS(fsys fs.FS, root string) Option { // WithPromptFile loads prompt definitions from the single prompt file at path. // -// Relative prompt content_file paths resolve from the file's directory. +// Relative prompt content_file paths resolve from the file's directory. path +// must name an existing non-directory file when NewEngine applies the option. +// This option replaces Config.PromptDir and earlier prompt-source options. func WithPromptFile(path string) Option { return optionFunc(func(options *engineOptions) error { fsys, root, err := fileSource(path) @@ -142,6 +205,10 @@ func WithPromptFile(path string) Option { // // Profiles from this source overlay built-in profiles. Profile YAML must use // api_key_env for environment-based credentials; raw API keys are rejected. +// fsys must be non-nil and root must be non-empty; otherwise NewEngine fails +// with ErrInvalidConfig. This option replaces Config.ProfileDir and earlier +// file or FS profile-source options, but remains below WithProfiles in +// precedence. func WithProfileFS(fsys fs.FS, root string) Option { return optionFunc(func(options *engineOptions) error { if fsys == nil { @@ -159,7 +226,10 @@ func WithProfileFS(fsys fs.FS, root string) Option { // 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. +// environment-based credentials; raw API keys are rejected. path must name an +// existing non-directory file when NewEngine applies the option. This option +// replaces Config.ProfileDir and earlier file or FS profile-source options, +// but remains below WithProfiles in precedence. func WithProfileFile(path string) Option { return optionFunc(func(options *engineOptions) error { fsys, root, err := fileSource(path) @@ -174,6 +244,11 @@ func WithProfileFile(path string) Option { // WithProfiles configures in-memory profiles that take precedence over // configured profile files and built-in profiles. +// +// NewEngine validates and copies every profile. IDs must be unique within one +// call. An invalid profile, duplicate ID, or unsupported ExtraParams value +// makes construction fail with ErrInvalidConfig. Repeating WithProfiles +// replaces the complete earlier in-memory set rather than merging it. func WithProfiles(profiles ...Profile) Option { return optionFunc(func(options *engineOptions) error { repo, err := newMemoryProfileRepository(profiles) @@ -189,7 +264,9 @@ func WithProfiles(profiles ...Profile) Option { // 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. +// or structured output is requested. fsys must be non-nil and root must be +// non-empty; otherwise NewEngine fails with ErrInvalidConfig. This option +// replaces Config.SchemaDir and earlier schema-source options. func WithSchemaFS(fsys fs.FS, root string) Option { return optionFunc(func(options *engineOptions) error { if fsys == nil { @@ -206,7 +283,9 @@ func WithSchemaFS(fsys fs.FS, root string) Option { // WithSchemaFile loads JSON Schema documents from the single schema file at path. // -// Prompt schema_path values refer to the file's base name. +// Prompt schema_path values refer to the file's base name. path must name an +// existing non-directory file when NewEngine applies the option. This option +// replaces Config.SchemaDir and earlier schema-source options. func WithSchemaFile(path string) Option { return optionFunc(func(options *engineOptions) error { fsys, root, err := fileSource(path) @@ -220,6 +299,15 @@ func WithSchemaFile(path string) Option { } // NewEngine constructs an Engine from configuration and options. +// +// Options are applied in order according to [Option]. PromptDir is required +// unless a prompt-source option is present. Construction validates option +// arguments and in-memory profiles but defers reading and validating prompt, +// file-backed profile, and schema contents until Prepare or Run needs them. +// +// NewEngine returns an error matching ErrInvalidConfig for invalid +// configuration or options. It does not perform model requests or require +// credentials. func NewEngine(cfg Config, opts ...Option) (*Engine, error) { var options engineOptions for _, opt := range opts { @@ -305,7 +393,22 @@ func fileSource(name string) (fs.FS, string, error) { return os.DirFS(dir), filepath.ToSlash(base), nil } -// Prepare resolves a prompt request without calling an LLM. +// Prepare resolves and renders a prompt request without calling an LLM. +// +// Prepare selects the prompt and profile, resolves effective execution +// settings and the output contract, loads and hashes inputs, loads structured +// output schema metadata when required, and renders the session ID and +// messages. The returned PreparedRun is owned by the caller and never contains +// a resolved API-key value, model output, or validation result. +// +// A nil Engine returns an error matching ErrInvalidConfig. Request and +// preparation failures may match ErrInvalidRequest, ErrPromptNotFound, +// ErrPromptLoad, ErrProfileNotFound, ErrProfileLoad, ErrProfileRequired, +// ErrAPIKeyEnvMissing, ErrArtifactLoad, ErrPromptRender, or ErrValidation as +// applicable. Cancellation is passed to the active collaborator and is +// reported in the applicable operation category; no general errors.Is +// relationship to ctx.Err is promised. Prepare returns no partial result on +// error. 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) @@ -323,7 +426,21 @@ func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, err return fromDomainPreparedRun(prepared), nil } -// Run executes a prompt request and returns the generated artifact and metadata. +// Run prepares a request, invokes the configured LLMClient, and validates the +// generated output. +// +// A content-validation failure is a successful run whose +// RunResult.Validation has Status ValidationFailed. An inability to perform +// validation returns an error matching ErrValidation and no partial result. +// The public Engine does not perform output repair, so validation is +// single-pass even when OutputContract.RepairAttempts is positive. +// +// Run can return every error category documented by [Engine.Prepare], plus +// ErrLLMGenerate. Errors from injected clients remain available through +// errors.Is. Cancellation is passed through the active collaborator and is +// reported in the applicable operation category; no general errors.Is +// relationship to ctx.Err is promised. A nil Engine returns ErrInvalidConfig. +// Run returns no partial result on error. 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) diff --git a/engine_test.go b/engine_test.go index 220f72f..c20338c 100644 --- a/engine_test.go +++ b/engine_test.go @@ -1020,6 +1020,7 @@ func TestPublicErrorsSupportErrorsIs(t *testing.T) { client promptkit.LLMClient schemaDir string want error + notWant error }{ { name: "invalid request", @@ -1028,10 +1029,11 @@ func TestPublicErrorsSupportErrorsIs(t *testing.T) { want: promptkit.ErrInvalidRequest, }, { - name: "prompt not found", - req: promptkit.RunRequest{PromptID: "missing.prompt"}, - client: &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}, - want: promptkit.ErrPromptNotFound, + name: "prompt not found", + req: promptkit.RunRequest{PromptID: "missing.prompt"}, + client: &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}, + want: promptkit.ErrPromptNotFound, + notWant: promptkit.ErrPromptLoad, }, { name: "profile not found", @@ -1042,8 +1044,9 @@ func TestPublicErrorsSupportErrorsIs(t *testing.T) { "transcript": promptkit.Inline("Rin opens the gate."), }, }, - client: &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}, - want: promptkit.ErrProfileNotFound, + client: &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}, + want: promptkit.ErrProfileNotFound, + notWant: promptkit.ErrProfileLoad, }, { name: "artifact load", @@ -1114,6 +1117,9 @@ func TestPublicErrorsSupportErrorsIs(t *testing.T) { if !errors.Is(err, tc.want) { t.Fatalf("expected errors.Is(%v), got %v", tc.want, err) } + if tc.notWant != nil && errors.Is(err, tc.notWant) { + t.Fatalf("did not expect errors.Is(%v), got %v", tc.notWant, err) + } }) } } @@ -1699,6 +1705,16 @@ func TestEngineRunLayersTransportAndGenerationTimeouts(t *testing.T) { configTimeout: 5 * time.Second, wantRemainingAtRequest: 5 * time.Second, }, + { + name: "zero configuration uses ten minute transport default", + wantRemainingAtRequest: 10 * time.Minute, + }, + { + name: "negative configuration uses ten minute transport default", + configTimeout: -2 * time.Second, + suppliedClientTimeout: -3 * time.Second, + wantRemainingAtRequest: 10 * time.Minute, + }, { name: "profile deadline is shorter than transport cap", suppliedClientTimeout: 6 * time.Second, diff --git a/formatting.go b/formatting.go index ba5f262..1985836 100644 --- a/formatting.go +++ b/formatting.go @@ -2,12 +2,16 @@ package promptkit import "fmt" -// String returns a concise request summary without exposing direct API keys. +// String returns a concise request summary without exposing the direct API key +// or input and variable contents. Reflection-based formatting does not carry +// this guarantee. func (r RunRequest) String() string { return r.redactedString() } -// GoString returns a concise request summary without exposing direct API keys. +// GoString returns a concise request summary without exposing the direct API +// key or input and variable contents. Reflection-based formatting does not +// carry this guarantee. func (r RunRequest) GoString() string { return r.redactedString() } @@ -27,13 +31,15 @@ func (r RunRequest) redactedString() string { } // String returns a concise request summary without exposing direct API keys or -// rendered prompt content. +// rendered prompt content. Reflection-based formatting does not carry this +// guarantee. func (r GenerateRequest) String() string { return r.redactedString() } // GoString returns a concise request summary without exposing direct API keys or -// rendered prompt content. +// rendered prompt content. Reflection-based formatting does not carry this +// guarantee. func (r GenerateRequest) GoString() string { return r.redactedString() } diff --git a/json.go b/json.go index 35ff28e..3a6e3cb 100644 --- a/json.go +++ b/json.go @@ -5,7 +5,8 @@ import ( "time" ) -// MarshalJSON emits prepared-run timestamps only when they are non-zero. +// MarshalJSON implements json.Marshaler for PreparedRun. It uses RFC 3339 +// timestamps, integer duration_ms, and omits zero timing values. func (r PreparedRun) MarshalJSON() ([]byte, error) { var startTime, endTime *time.Time if !r.StartTime.IsZero() { @@ -53,7 +54,8 @@ func (r PreparedRun) MarshalJSON() ([]byte, error) { }) } -// MarshalJSON emits run duration in milliseconds and omits zero timing values. +// MarshalJSON implements json.Marshaler for RunResult. It encodes Duration as +// integer milliseconds in duration_ms and omits zero timing values. func (r RunResult) MarshalJSON() ([]byte, error) { var startTime, endTime *time.Time if !r.StartTime.IsZero() { @@ -90,7 +92,8 @@ func (r RunResult) MarshalJSON() ([]byte, error) { }) } -// UnmarshalJSON decodes the supported run-result representation. +// UnmarshalJSON implements json.Unmarshaler for RunResult. It decodes +// duration_ms into Duration with millisecond precision. func (r *RunResult) UnmarshalJSON(data []byte) error { var wire runResultJSON if err := json.Unmarshal(data, &wire); err != nil { diff --git a/profiles.go b/profiles.go index 44f5158..6fbb2f1 100644 --- a/profiles.go +++ b/profiles.go @@ -16,6 +16,10 @@ import ( // It does not register global state, maintain a model catalog, or resolve // credentials. If APIKeyRequired is true, callers satisfy it with // RunRequest.APIKey. Raw API keys do not belong in profiles. +// +// The function copies the ExtraParams map itself but does not recursively copy +// nested values. Validation and a deep copy occur when NewEngine applies a +// WithProfiles option containing the returned Profile. func OpenAICompatibleProfile(cfg OpenAICompatibleProfileConfig) Profile { return Profile{ ID: cfg.ID, diff --git a/public_contract_test.go b/public_contract_test.go index 70145dc..e08d55f 100644 --- a/public_contract_test.go +++ b/public_contract_test.go @@ -26,6 +26,30 @@ func TestPreparedRunJSONOmitsZeroTimingValues(t *testing.T) { } } +func TestPreparedRunJSONTimingRoundTrips(t *testing.T) { + start := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC) + prepared := promptkit.PreparedRun{ + PromptID: "prompt", + StartTime: start, + EndTime: start.Add(1250 * time.Millisecond), + DurationMS: 1250, + } + + payload, err := json.Marshal(prepared) + if err != nil { + t.Fatalf("marshal prepared run: %v", err) + } + var decoded promptkit.PreparedRun + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal prepared run: %v", err) + } + if decoded.DurationMS != prepared.DurationMS || + !decoded.StartTime.Equal(prepared.StartTime) || + !decoded.EndTime.Equal(prepared.EndTime) { + t.Fatalf("timing values did not round trip: got %#v, want %#v", decoded, prepared) + } +} + func TestRunResultJSONUsesMillisecondsAndRoundTrips(t *testing.T) { start := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC) result := promptkit.RunResult{ @@ -74,6 +98,36 @@ func TestRunResultJSONUsesMillisecondsAndRoundTrips(t *testing.T) { } } +func TestEngineValidationIsSinglePass(t *testing.T) { + client := &fakeLLMClient{ + response: &promptkit.GenerateResponse{Content: "not-json"}, + } + engine := newContractEngineWithOptions(t, frameworkSchemaDir, promptkit.WithLLMClient(client)) + + result, err := engine.Run(context.Background(), promptkit.RunRequest{ + PromptID: frameworkMarkdownSummaryPromptID, + Inputs: map[string]promptkit.ArtifactRef{ + "transcript": promptkit.Inline("Rin opens the gate."), + "glossary": promptkit.Inline("gate: A guarded passage."), + }, + Validation: &promptkit.OutputContract{ + Format: promptkit.FormatJSON, + ValidationMode: promptkit.ValidationJSON, + RepairAttempts: 3, + }, + }) + if err != nil { + t.Fatalf("run with failed content validation: %v", err) + } + if result.Validation.Status != promptkit.ValidationFailed || + result.Validation.RepairAttempts != 0 { + t.Fatalf("expected failed single-pass validation, got %#v", result.Validation) + } + if len(client.requests) != 1 { + t.Fatalf("expected one model generation, got %d", len(client.requests)) + } +} + func TestRepeatedOptionsUseLastValueInEachCategory(t *testing.T) { profile := promptkit.Profile{ID: "profile", Endpoint: "http://example.test/v1", Model: "model"} diff --git a/types.go b/types.go index a81035d..875da9d 100644 --- a/types.go +++ b/types.go @@ -5,160 +5,314 @@ import ( "time" ) -// ArtifactRefType defines how an artifact is referenced. +// ArtifactRefType identifies how an [ArtifactRef] supplies content. type ArtifactRefType string const ( + // ArtifactRefInline selects ArtifactRef.Body as the content. ArtifactRefInline ArtifactRefType = "inline" - ArtifactRefFile ArtifactRefType = "file" + // ArtifactRefFile selects the filesystem path in ArtifactRef.URI. + ArtifactRefFile ArtifactRefType = "file" ) -// OutputFormat defines the desired output format. +// OutputFormat identifies the media format of generated output. +// OutputFormat has a stable JSON string representation. type OutputFormat string const ( - FormatText OutputFormat = "text" + // FormatText identifies plain-text output. + FormatText OutputFormat = "text" + // FormatMarkdown identifies Markdown output. FormatMarkdown OutputFormat = "markdown" - FormatJSON OutputFormat = "json" + // FormatJSON identifies JSON output. + FormatJSON OutputFormat = "json" ) -// ValidationMode defines the output validation strategy. +// ValidationMode identifies how generated output is checked. +// ValidationMode has a stable JSON string representation. type ValidationMode string const ( - ValidationNone ValidationMode = "none" - ValidationBasic ValidationMode = "basic" - ValidationJSON ValidationMode = "json" + // ValidationNone skips content validation. + ValidationNone ValidationMode = "none" + // ValidationBasic requires non-empty output. + ValidationBasic ValidationMode = "basic" + // ValidationJSON requires syntactically valid JSON. + ValidationJSON ValidationMode = "json" + // ValidationJSONSchema requires JSON that satisfies OutputContract.SchemaPath. ValidationJSONSchema ValidationMode = "json_schema" ) -// ValidationStatus defines the result of a validation check. +// ValidationStatus identifies the completed state of an output check. +// ValidationStatus has a stable JSON string representation. type ValidationStatus string const ( - ValidationPassed ValidationStatus = "passed" - ValidationFailed ValidationStatus = "failed" + // ValidationPassed means the generated output satisfied its contract. + ValidationPassed ValidationStatus = "passed" + // ValidationFailed means validation completed and rejected the generated + // output. Engine.Run returns this status in a result, not as an error. + ValidationFailed ValidationStatus = "failed" + // ValidationSkipped means ValidationNone selected no content check. ValidationSkipped ValidationStatus = "skipped" ) -// CacheControlType defines provider cache behavior for prompt content. +// CacheControlType identifies provider cache behavior for prompt content. +// CacheControlType has a stable JSON string representation. type CacheControlType string const ( + // CacheControlEphemeral requests provider-defined ephemeral caching. CacheControlEphemeral CacheControlType = "ephemeral" ) // StructuredOutputType identifies provider-level structured output modes. +// StructuredOutputType has a stable JSON string representation. type StructuredOutputType string const ( + // StructuredOutputJSONSchema supplies JSON Schema response constraints. StructuredOutputJSONSchema StructuredOutputType = "json_schema" ) -// RunRequest represents a request to prepare or run a single prompt. +// RunRequest selects one prompt execution. It has no stable JSON +// representation. +// +// Prepare and Run copy the request's maps, pointers, and nested +// JSON-compatible values before using them. The caller may mutate the request +// after either method returns. type RunRequest struct { - PromptID string + // PromptID is the required non-empty prompt identifier. + PromptID string + // PromptVersion optionally selects one version of PromptID. When empty, the + // prompt source must contain exactly one matching version. PromptVersion string - ProfileID string - APIKey string `json:"-"` - Inputs map[string]ArtifactRef - Vars map[string]string - Execution *ExecutionTargetOverride - Validation *OutputContract + // ProfileID selects an execution profile. When empty, the prompt's default + // profile is used; if both are empty, the error matches ErrProfileRequired + // and ErrInvalidRequest. + ProfileID string + // APIKey is a request-scoped direct credential. It takes precedence over + // APIKeyEnv, is passed to the selected LLMClient, and is never included in + // prepared values, results, hashes, JSON, String, or GoString output. + APIKey string `json:"-"` + // Inputs maps prompt input names to references. A nil or empty map is valid + // only when the selected prompt and its templates require no inputs. + Inputs map[string]ArtifactRef + // Vars supplies Go-template data for messages and the session ID. Nil and + // empty maps are equivalent. + Vars map[string]string + // Execution optionally overrides individual profile execution settings. + // Nil uses the selected profile over framework defaults. + Execution *ExecutionTargetOverride + // Validation optionally replaces the prompt's complete output contract. It + // does not merge individual fields. Nil uses the prompt contract. + Validation *OutputContract } // PreparedRun contains prepared prompt execution state. It does not include // resolved API key values, model output, validation results, or internal target -// presence metadata. +// presence metadata. PreparedRun has a stable JSON representation. +// +// All maps, slices, pointers, and schema values are caller-owned copies. JSON +// timestamps use RFC 3339 and zero timing values are omitted. Hash formats are +// opaque. 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"` - 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"` + // PromptID is the selected prompt identifier. + PromptID string `json:"prompt_id"` + // PromptVersion is the selected prompt version. + PromptVersion string `json:"prompt_version,omitempty"` + // PromptHash is an opaque equality value for the selected definition. + PromptHash string `json:"prompt_hash,omitempty"` + // SelectedProfileID is the explicit request profile or prompt default that + // supplied execution settings. + SelectedProfileID string `json:"selected_profile_id"` + // EffectiveModelParams contains framework defaults overlaid by the selected + // profile and then request overrides. It excludes resolved API-key values. + EffectiveModelParams ExecutionTarget `json:"effective_model_params"` + // OutputContract is the complete effective output contract. + OutputContract OutputContract `json:"output_contract"` + // StructuredOutput is non-nil for JSON Schema validation and contains the + // provider-facing response constraint passed to an LLM client. + StructuredOutput *StructuredOutputSpec `json:"structured_output,omitempty"` + // InputHashes maps every supplied input name to its opaque artifact hash. + InputHashes map[string]string `json:"input_hashes,omitempty"` + // SessionID is the trimmed rendered session identifier, if any. + SessionID string `json:"session_id,omitempty"` + // RenderedPromptHash is an opaque equality value for SessionID and Messages. + RenderedPromptHash string `json:"rendered_prompt_hash"` + // Messages are the rendered messages that Run passes to the LLM client. + Messages []RenderedMessage `json:"messages"` + // StartTime is the UTC time at which preparation began. + StartTime time.Time `json:"start_time,omitempty"` + // EndTime is the UTC time at which preparation completed. + EndTime time.Time `json:"end_time,omitempty"` + // DurationMS is preparation elapsed time in integer milliseconds. JSON uses + // duration_ms and omits a zero value. + DurationMS int64 `json:"duration_ms,omitempty"` } // RunResult contains generated output, validation state, and run metadata. +// RunResult has a stable JSON representation and round-trips its Duration +// through the duration_ms JSON field. +// +// All maps, slices, and nested values are caller-owned copies. JSON timestamps +// use RFC 3339 and zero timing values are omitted. Run IDs and hash formats are +// opaque. type RunResult struct { - RunID string `json:"run_id"` - Artifact Artifact `json:"artifact"` - RawOutput string `json:"raw_output"` - Validation ValidationResult `json:"validation"` - PromptID string `json:"prompt_id"` - PromptVersion string `json:"prompt_version,omitempty"` - PromptHash string `json:"prompt_hash,omitempty"` - RenderedPromptHash string `json:"rendered_prompt_hash"` - SelectedProfileID string `json:"selected_profile_id"` - ModelName string `json:"model_name"` - Endpoint string `json:"endpoint"` - EffectiveModelParams ExecutionTarget `json:"effective_model_params"` - InputHashes map[string]string `json:"input_hashes,omitempty"` - Usage TokenUsage `json:"usage"` - StartTime time.Time `json:"start_time,omitempty"` - EndTime time.Time `json:"end_time,omitempty"` - Duration time.Duration `json:"-"` + // RunID is an opaque identifier for this invocation. + RunID string `json:"run_id"` + // Artifact contains the generated output and derived metadata. + Artifact Artifact `json:"artifact"` + // RawOutput is the exact generated content before artifact classification + // and validation. + RawOutput string `json:"raw_output"` + // Validation records the completed content check. + Validation ValidationResult `json:"validation"` + // PromptID is the selected prompt identifier. + PromptID string `json:"prompt_id"` + // PromptVersion is the selected prompt version. + PromptVersion string `json:"prompt_version,omitempty"` + // PromptHash is the same opaque definition equality value exposed by + // PreparedRun. + PromptHash string `json:"prompt_hash,omitempty"` + // RenderedPromptHash is the same opaque rendered-prompt equality value + // computed during preparation. + RenderedPromptHash string `json:"rendered_prompt_hash"` + // SelectedProfileID identifies the profile used for execution. + SelectedProfileID string `json:"selected_profile_id"` + // ModelName is the effective model name and equals + // EffectiveModelParams.Model. + ModelName string `json:"model_name"` + // Endpoint is the effective base endpoint and equals + // EffectiveModelParams.Endpoint. + Endpoint string `json:"endpoint"` + // EffectiveModelParams contains the settings supplied to the LLM client, + // excluding resolved API-key values. + EffectiveModelParams ExecutionTarget `json:"effective_model_params"` + // InputHashes are the opaque input equality values computed during + // preparation. + InputHashes map[string]string `json:"input_hashes,omitempty"` + // Usage is the token accounting reported by the LLM client. + Usage TokenUsage `json:"usage"` + // StartTime is the UTC time immediately before preparation begins. + StartTime time.Time `json:"start_time,omitempty"` + // EndTime is the UTC time after generation and validation complete. + EndTime time.Time `json:"end_time,omitempty"` + // Duration covers preparation, generation, and validation. JSON represents + // it as integer milliseconds in duration_ms and omits a zero value. + Duration time.Duration `json:"-"` } -// ArtifactRef represents a reference to prompt input content. +// ArtifactRef identifies prompt input content. It has no stable JSON +// representation. Prefer [File], [Inline], or [InlineWithURI] to construct one. type ArtifactRef struct { + // Type must be ArtifactRefInline or ArtifactRefFile. Type ArtifactRefType - URI string + // URI is the file path for ArtifactRefFile and optional provenance metadata + // for ArtifactRefInline. + URI string + // Body is the content for ArtifactRefInline and is ignored for + // ArtifactRefFile. Body string } -// Artifact represents loaded artifact content. +// Artifact represents loaded or generated content and has a stable JSON +// representation. Body uses encoding/json's base64 representation for []byte. type Artifact struct { - Name string `json:"name"` + // Name is artifact metadata. During input preparation the engine fills an + // empty reader-supplied name with the request input-map key. + Name string `json:"name"` + // ContentType is the media type reported by the reader or derived for + // generated output. ContentType string `json:"content_type"` - Body []byte `json:"body"` - URI string `json:"uri"` - Size int64 `json:"size"` - Hash string `json:"hash"` + // Body is the artifact content. Engine boundaries copy this slice. + Body []byte `json:"body"` + // URI is optional source or result provenance metadata. + URI string `json:"uri"` + // Size is content-size metadata in bytes. + Size int64 `json:"size"` + // Hash is an opaque content equality value when the producing reader + // supplies one. Its format and algorithm are not API contracts. + Hash string `json:"hash"` } // ArtifactReader resolves a prompt input reference into its content. // -// Readers are responsible for supplying artifact metadata. The engine assigns -// an input-map name only when the returned artifact name is empty. +// Read may be called concurrently. It must honor ctx cancellation to make +// Prepare and Run responsive to cancellation. The engine passes a copied ref +// and immediately copies the returned Artifact.Body; it does not retain either +// value. Readers supply artifact metadata, and the engine assigns an input-map +// name only when the returned artifact name is empty. +// +// Returning a non-nil error makes the engine return an error matching +// ErrArtifactLoad while preserving the reader error through errors.Is. +// Returning a nil artifact with a nil error also produces ErrArtifactLoad. type ArtifactReader interface { Read(context.Context, ArtifactRef) (*Artifact, error) } -// ExecutionTarget represents effective model runtime settings. +// ExecutionTarget represents effective model runtime settings and has a stable +// JSON representation. It never exposes a resolved API-key value. type ExecutionTarget struct { - Endpoint string `json:"endpoint"` - Model string `json:"model"` - Temperature float64 `json:"temperature"` - MaxTokens int `json:"max_tokens"` - TopP float64 `json:"top_p"` - TimeoutSeconds int `json:"timeout_seconds"` - ServiceTier string `json:"service_tier"` - ReasoningEffort string `json:"reasoning_effort"` - APIKeyEnv string `json:"api_key_env"` - ExtraParams map[string]any `json:"extra_params"` + // Endpoint is the model-provider base URL. + Endpoint string `json:"endpoint"` + // Model is the provider model identifier. + Model string `json:"model"` + // Temperature is the effective sampling temperature from 0 through 2. + Temperature float64 `json:"temperature"` + // MaxTokens is the non-negative effective output-token limit. Zero leaves + // the limit unspecified to compatible providers unless it was an explicit + // request override. + MaxTokens int `json:"max_tokens"` + // TopP is the effective nucleus-sampling value from 0 through 1. + TopP float64 `json:"top_p"` + // TimeoutSeconds is the non-negative per-generation deadline. Zero disables + // this deadline without disabling caller cancellation or the transport cap. + TimeoutSeconds int `json:"timeout_seconds"` + // ServiceTier is an optional provider-specific request tier. + ServiceTier string `json:"service_tier"` + // ReasoningEffort is an optional provider-specific reasoning setting. + ReasoningEffort string `json:"reasoning_effort"` + // APIKeyEnv is an environment-variable name, not its credential value. + APIKeyEnv string `json:"api_key_env"` + // ExtraParams contains copied JSON-compatible provider parameters. + ExtraParams map[string]any `json:"extra_params"` } -// ExecutionTargetOverride represents per-request runtime setting overrides. +// ExecutionTargetOverride represents per-request runtime setting overrides and +// has no stable JSON representation. +// +// Non-empty string fields replace profile values. Non-nil numeric pointers +// replace profile values and preserve explicit zero. A non-empty ExtraParams +// map replaces the complete profile map rather than merging keys. Empty string +// fields, nil pointers, and a nil or empty ExtraParams map inherit the selected +// profile over framework defaults. type ExecutionTargetOverride struct { - Endpoint string - Model string - Temperature *float64 - MaxTokens *int - TopP *float64 - TimeoutSeconds *int - ServiceTier string + // Endpoint replaces the profile endpoint when non-empty. + Endpoint string + // Model replaces the profile model when non-empty. + Model string + // Temperature, when non-nil, must point to a value from 0 through 2. + Temperature *float64 + // MaxTokens, when non-nil, must point to a non-negative value. + MaxTokens *int + // TopP, when non-nil, must point to a value from 0 through 1. + TopP *float64 + // TimeoutSeconds, when non-nil, must point to a non-negative value. A + // pointed-to zero disables the per-generation deadline. + TimeoutSeconds *int + // ServiceTier replaces the profile value when non-blank. + ServiceTier string + // ReasoningEffort replaces the profile value when non-blank. An empty value + // cannot clear a profile setting. ReasoningEffort string - APIKeyEnv string - ExtraParams map[string]any + // APIKeyEnv replaces the profile environment-variable name when non-blank. + // A direct RunRequest.APIKey still takes precedence over environment lookup. + APIKeyEnv string + // ExtraParams, when non-empty, replaces the profile map. Values must be + // JSON-compatible: nil, booleans, finite numbers, strings, arrays or slices, + // and maps with non-empty string keys. Cycles are invalid. + ExtraParams map[string]any } // Profile is an in-memory execution profile for library consumers. @@ -166,19 +320,39 @@ type ExecutionTargetOverride struct { // It is equivalent to a loaded profile file after validation. Raw API keys do // not belong in profiles; use APIKeyRequired to require callers to provide // RunRequest.APIKey for each request, or use profile YAML api_key_env with file -// and FS profile sources. +// and FS profile sources. Profile has no stable JSON representation. +// +// WithProfiles validates and copies Profile values during NewEngine. Numeric +// zero, blank strings, and an empty ExtraParams map inherit framework defaults; +// use ExecutionTargetOverride pointer fields to request explicit numeric zero. type Profile struct { - ID string - Endpoint string - Model string - Temperature float64 - MaxTokens int - TopP float64 - TimeoutSeconds int - ServiceTier string + // ID is the required non-blank profile identifier. WithProfiles trims it. + ID string + // Endpoint is the required non-blank model-provider base URL. + Endpoint string + // Model is the required non-blank provider model identifier. + Model string + // Temperature is from 0 through 2. Zero inherits the framework default. + Temperature float64 + // MaxTokens is non-negative. Zero inherits the framework default. + MaxTokens int + // TopP is from 0 through 1. Zero inherits the framework default rather than + // selecting an explicit zero. + TopP float64 + // TimeoutSeconds is non-negative. Zero inherits the framework default. + TimeoutSeconds int + // ServiceTier is optional; a blank value inherits the framework default. + ServiceTier string + // ReasoningEffort is optional; a blank value inherits the framework + // default. ReasoningEffort string - APIKeyRequired bool - ExtraParams map[string]any + // APIKeyRequired requires a non-blank RunRequest.APIKey. It does not store a + // credential or enable environment lookup. + APIKeyRequired bool + // ExtraParams contains provider-specific JSON-compatible values. An empty + // map inherits framework defaults. WithProfiles validates and deeply copies + // it during NewEngine. + ExtraParams map[string]any } // OpenAICompatibleProfileConfig configures an OpenAI-compatible in-memory @@ -186,120 +360,215 @@ type Profile struct { // // It contains ordinary profile fields for OpenAI-compatible chat-completions // endpoints. APIKeyRequired is satisfied by RunRequest.APIKey. Raw API keys do -// not belong in this config. +// not belong in this config. OpenAICompatibleProfileConfig has no stable JSON +// representation and is not validated until its resulting Profile is supplied +// through WithProfiles to NewEngine. type OpenAICompatibleProfileConfig struct { - ID string - Endpoint string - Model string - APIKeyRequired bool - Temperature float64 - MaxTokens int - TopP float64 - TimeoutSeconds int - ServiceTier string + // ID becomes Profile.ID. + ID string + // Endpoint becomes Profile.Endpoint. + Endpoint string + // Model becomes Profile.Model. + Model string + // APIKeyRequired becomes Profile.APIKeyRequired. + APIKeyRequired bool + // Temperature becomes Profile.Temperature. + Temperature float64 + // MaxTokens becomes Profile.MaxTokens. + MaxTokens int + // TopP becomes Profile.TopP. + TopP float64 + // TimeoutSeconds becomes Profile.TimeoutSeconds. + TimeoutSeconds int + // ServiceTier becomes Profile.ServiceTier. + ServiceTier string + // ReasoningEffort becomes Profile.ReasoningEffort. ReasoningEffort string - ExtraParams map[string]any + // ExtraParams becomes a shallow-copied Profile.ExtraParams map. NewEngine + // performs validation and a deep copy when WithProfiles applies the result. + ExtraParams map[string]any } // ExecutionTargetPresence tracks which numeric runtime settings were explicit -// request overrides. +// request overrides, including explicit zero values. It has a stable JSON +// representation and is supplied to injected LLM clients so they can preserve +// omission semantics. type ExecutionTargetPresence struct { - Temperature bool `json:"temperature"` - MaxTokens bool `json:"max_tokens"` - TopP bool `json:"top_p"` + // Temperature reports a non-nil ExecutionTargetOverride.Temperature. + Temperature bool `json:"temperature"` + // MaxTokens reports a non-nil ExecutionTargetOverride.MaxTokens. + MaxTokens bool `json:"max_tokens"` + // TopP reports a non-nil ExecutionTargetOverride.TopP. + TopP bool `json:"top_p"` + // TimeoutSeconds reports a non-nil ExecutionTargetOverride.TimeoutSeconds. TimeoutSeconds bool `json:"timeout_seconds"` } -// OutputContract defines output and validation requirements. +// OutputContract defines output and validation requirements and has a stable +// JSON representation. +// +// A non-nil RunRequest.Validation replaces the complete prompt contract. It +// does not merge fields. The public Engine validates generated output once and +// does not install an output repairer. type OutputContract struct { - Format OutputFormat `json:"format"` + // Format selects generated artifact metadata. An empty effective value + // defaults to FormatText. + Format OutputFormat `json:"format"` + // ValidationMode selects the content check. Use one of the declared + // ValidationMode constants. ValidationMode ValidationMode `json:"validation_mode"` - SchemaPath string `json:"schema_path"` - RepairAttempts int `json:"repair_attempts"` + // SchemaPath is required when ValidationMode is ValidationJSONSchema and is + // ignored by other modes. + SchemaPath string `json:"schema_path"` + // RepairAttempts is a requested repair limit. A non-positive value requests + // no repairs. The public Engine performs no repairs even when this value is + // positive, so its runs report zero attempts used. + RepairAttempts int `json:"repair_attempts"` } -// ValidationResult represents output validation state. +// ValidationResult represents a completed output check and has a stable JSON +// representation. An operational inability to perform validation is returned +// as ErrValidation instead of a ValidationResult. type ValidationResult struct { - Status ValidationStatus `json:"status"` - Mode ValidationMode `json:"mode"` - Errors []string `json:"errors,omitempty"` - SchemaPath string `json:"schema_path,omitempty"` - RepairAttempts int `json:"repair_attempts"` - IsValid bool `json:"is_valid"` + // Status is Passed, Failed, or Skipped. + Status ValidationStatus `json:"status"` + // Mode is the effective validation mode. + Mode ValidationMode `json:"mode"` + // Errors contains validation diagnostics when Status is ValidationFailed. + Errors []string `json:"errors,omitempty"` + // SchemaPath is the effective schema path for JSON Schema validation. + SchemaPath string `json:"schema_path,omitempty"` + // RepairAttempts is the number of repairs actually attempted. It is always + // zero for the public Engine. + RepairAttempts int `json:"repair_attempts"` + // IsValid is true for ValidationPassed and ValidationSkipped and false for + // ValidationFailed. + IsValid bool `json:"is_valid"` } -// TokenUsage tracks token consumption. +// TokenUsage contains model-client token accounting and has a stable JSON +// representation. Promptkit preserves values reported by the client and does +// not derive or reconcile them. type TokenUsage struct { - PromptTokens int `json:"prompt_tokens"` + // PromptTokens is the reported input-token count. + PromptTokens int `json:"prompt_tokens"` + // CompletionTokens is the reported generated-token count. CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - CachedTokens int `json:"cached_tokens"` + // TotalTokens is the reported total-token count. + TotalTokens int `json:"total_tokens"` + // CachedTokens is the reported cached-input-token count. + CachedTokens int `json:"cached_tokens"` + // CacheWriteTokens is the reported cache-write-token count. CacheWriteTokens int `json:"cache_write_tokens"` } -// RenderedPrompt is the fully rendered prompt passed to an LLM client. +// RenderedPrompt is the fully rendered prompt passed to an LLM client and has +// a stable JSON representation. type RenderedPrompt struct { - SessionID string `json:"session_id,omitempty"` - Messages []RenderedMessage `json:"messages"` + // SessionID is the optional trimmed session identifier rendered from the + // prompt definition. + SessionID string `json:"session_id,omitempty"` + // Messages contains rendered messages in definition order. + Messages []RenderedMessage `json:"messages"` } -// RenderedMessage is a rendered chat message. +// RenderedMessage is a rendered chat message and has a stable JSON +// representation. type RenderedMessage struct { - Role string `json:"role"` - Content string `json:"content"` + // Role is the definition-supplied chat role. + Role string `json:"role"` + // Content is the rendered message text. + Content string `json:"content"` + // CacheControl is optional provider cache metadata. CacheControl *CacheControl `json:"cache_control,omitempty"` } -// CacheControl describes provider cache metadata attached to prompt content. +// CacheControl describes provider cache metadata attached to prompt content +// and has a stable JSON representation. type CacheControl struct { + // Type identifies the cache behavior. Type CacheControlType `json:"type"` - TTL string `json:"ttl,omitempty"` + // TTL is an optional provider cache lifetime. + TTL string `json:"ttl,omitempty"` } -// StructuredOutputSpec describes provider-level structured output. +// StructuredOutputSpec describes provider-level structured output and has a +// stable JSON representation. type StructuredOutputSpec struct { - Type StructuredOutputType `json:"type"` + // Type identifies the structured-output mechanism. + Type StructuredOutputType `json:"type"` + // JSONSchema contains constraints when Type is StructuredOutputJSONSchema. JSONSchema *StructuredOutputJSONSpec `json:"json_schema,omitempty"` } -// StructuredOutputJSONSpec contains JSON Schema output constraints. +// StructuredOutputJSONSpec contains provider-facing JSON Schema output +// constraints and has a stable JSON representation. type StructuredOutputJSONSpec struct { - Name string `json:"name"` - Strict bool `json:"strict"` - Schema any `json:"schema"` + // Name is the provider-facing schema name. + Name string `json:"name"` + // Strict requests strict provider enforcement of Schema. + Strict bool `json:"strict"` + // Schema is a caller-owned copy of the loaded JSON Schema document. + Schema any `json:"schema"` } -// LLMClient executes rendered prompts for Engine.Run. +// LLMClient executes rendered prompts for [Engine.Run]. +// +// Generate may be called concurrently. It must honor context cancellation to +// make Run responsive to cancellation. The request and all nested maps, +// slices, and pointers are client-owned copies and may be mutated or retained +// without affecting engine state. +// +// A returned error makes Run return ErrLLMGenerate while preserving the client +// error through errors.Is. A nil response with a nil error also produces +// ErrLLMGenerate. Promptkit copies the non-nil response before returning from +// Run. type LLMClient interface { Generate(context.Context, GenerateRequest) (*GenerateResponse, error) } -// GenerateRequest is passed to an injected LLM client. +// GenerateRequest is passed to an injected LLM client and has a stable JSON +// representation. Its String and GoString methods omit rendered content and +// direct credentials. type GenerateRequest struct { - Prompt RenderedPrompt `json:"prompt"` - Target ExecutionTarget `json:"target"` - TargetPresence ExecutionTargetPresence `json:"target_presence"` - StructuredOutput *StructuredOutputSpec `json:"structured_output,omitempty"` - APIKey string `json:"-"` + // Prompt contains the rendered session ID and messages. + Prompt RenderedPrompt `json:"prompt"` + // Target contains effective model settings without the direct API key. + Target ExecutionTarget `json:"target"` + // TargetPresence distinguishes inherited numeric zeros from explicit + // request overrides. + TargetPresence ExecutionTargetPresence `json:"target_presence"` + // StructuredOutput contains provider response constraints when requested. + StructuredOutput *StructuredOutputSpec `json:"structured_output,omitempty"` + // APIKey is the direct request-scoped credential, if any. It is excluded + // from JSON, String, and GoString output. + APIKey string `json:"-"` } -// GenerateResponse is returned by an injected LLM client. +// GenerateResponse is returned by an injected LLM client and has a stable JSON +// representation. type GenerateResponse struct { - Content string `json:"content"` - Usage TokenUsage `json:"usage"` + // Content is the generated output. It must be non-empty when using the + // built-in client; injected clients may return empty content for Promptkit + // validation to classify. + Content string `json:"content"` + // Usage is the client's token accounting. + Usage TokenUsage `json:"usage"` } -// File returns a file-backed artifact reference. +// File returns a file-backed artifact reference whose URI is path. func File(path string) ArtifactRef { return ArtifactRef{Type: ArtifactRefFile, URI: path} } -// Inline returns an inline artifact reference. +// Inline returns an inline artifact reference whose Body is body and whose URI +// is empty. func Inline(body string) ArtifactRef { return ArtifactRef{Type: ArtifactRefInline, Body: body} } -// InlineWithURI returns an inline artifact reference with URI metadata. +// InlineWithURI returns an inline artifact reference with body content and uri +// provenance metadata. func InlineWithURI(uri string, body string) ArtifactRef { return ArtifactRef{Type: ArtifactRefInline, URI: uri, Body: body} }