Compare commits
6 Commits
c13e9710d9
...
369ab5392d
| Author | SHA1 | Date | |
|---|---|---|---|
| 369ab5392d | |||
| 2ba0146e5d | |||
| 6112c2af0c | |||
| f5e12c00f5 | |||
| 49fe402dd2 | |||
| c301eb8d55 |
12
backends.go
12
backends.go
@@ -40,12 +40,12 @@ type Backend struct {
|
||||
// calls allowed for this backend within one Engine. Zero leaves the backend
|
||||
// unlimited. A negative value makes NewEngine fail with ErrInvalidConfig.
|
||||
ConcurrencyLimit int
|
||||
// QueueCapacity controls how many additional Run calls may be admitted
|
||||
// beyond ConcurrencyLimit. Nil uses 1024 when ConcurrencyLimit is positive;
|
||||
// a pointer uses its exact value, including zero. The pointed-to value must
|
||||
// be non-negative, and QueueCapacity must be nil when ConcurrencyLimit is
|
||||
// zero. Their sum must fit in an int. WithBackend copies the value and does
|
||||
// not retain the pointer.
|
||||
// QueueCapacity controls how many additional Run or RunPrepared calls may
|
||||
// be admitted beyond ConcurrencyLimit. Nil uses 1024 when ConcurrencyLimit
|
||||
// is positive; a pointer uses its exact value, including zero. The pointed-to
|
||||
// value must be non-negative, and QueueCapacity must be nil when
|
||||
// ConcurrencyLimit is zero. Their sum must fit in an int. WithBackend copies
|
||||
// the value and does not retain the pointer.
|
||||
QueueCapacity *int
|
||||
}
|
||||
|
||||
|
||||
37
doc.go
37
doc.go
@@ -3,23 +3,25 @@
|
||||
//
|
||||
// Applications construct an [Engine] with [NewEngine], select filesystem or
|
||||
// in-memory sources and optional engine-scoped [Backend] registrations, and
|
||||
// call [Engine.Prepare] or [Engine.Run]. Concrete registries, repositories,
|
||||
// validators, and the built-in OpenAI-compatible client remain internal
|
||||
// implementation details.
|
||||
// call [Engine.Prepare], [Engine.PrepareExecution], [Engine.Run], or
|
||||
// [Engine.RunPrepared]. Concrete registries, repositories, validators, and the
|
||||
// built-in OpenAI-compatible client remain internal implementation details.
|
||||
//
|
||||
// # Concurrency and ownership
|
||||
//
|
||||
// An Engine supports concurrent Prepare and Run calls. Engine-local backend
|
||||
// policies bound admitted Run calls and model generations where configured,
|
||||
// while different backend pools and unlimited backends continue independently.
|
||||
// An injected [LLMClient] or [ArtifactReader] can therefore still receive
|
||||
// concurrent calls and must be safe for that use.
|
||||
// An Engine supports concurrent Prepare, PrepareExecution, Run, and RunPrepared
|
||||
// calls. Engine-local backend policies bound admitted Run and RunPrepared calls
|
||||
// and model generations where configured, while different backend pools and
|
||||
// unlimited backends continue independently. An injected [LLMClient] or
|
||||
// [ArtifactReader] can therefore still receive concurrent calls and must be
|
||||
// safe for that use.
|
||||
//
|
||||
// NewEngine copies in-memory profiles and backend definitions. 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.
|
||||
// NewEngine copies in-memory profiles and backend definitions. Prepare,
|
||||
// PrepareExecution, 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.
|
||||
//
|
||||
// # Security and sensitive data
|
||||
//
|
||||
@@ -45,10 +47,11 @@
|
||||
// [GenerateResponse], [ExecutionTargetPresence], and the string value types
|
||||
// used by those values.
|
||||
//
|
||||
// Construction values, including [Config], [Backend], [RunRequest],
|
||||
// [ArtifactRef], [ExecutionTargetOverride], [Profile], and
|
||||
// [OpenAICompatibleProfileConfig], do not have stable JSON representations.
|
||||
// Direct API keys are nevertheless excluded from JSON for every public value.
|
||||
// Construction and handle values, including [Config], [Backend], [RunRequest],
|
||||
// [ArtifactRef], [ExecutionTargetOverride], [Profile],
|
||||
// [OpenAICompatibleProfileConfig], and [PreparedExecution], 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
|
||||
|
||||
@@ -44,7 +44,9 @@ validation, and profile precedence are defined by the
|
||||
|
||||
[`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:
|
||||
calling a model client. Choose it when the prepared value is the final
|
||||
inspection or persistence result and no later execution must be tied to that
|
||||
exact snapshot:
|
||||
|
||||
```go
|
||||
prepared, err := engine.Prepare(ctx, promptkit.RunRequest{
|
||||
@@ -61,12 +63,48 @@ 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).
|
||||
|
||||
## Prepare Now And Execute The Same Snapshot Later
|
||||
|
||||
Use [`Engine.PrepareExecution`](../../engine.go) when an application must
|
||||
inspect or persist preflight details before deciding whether to start model
|
||||
work, while ensuring that later execution uses those exact rendered messages,
|
||||
inputs, target settings, and validation resources:
|
||||
|
||||
```go
|
||||
preparedExecution, err := engine.PrepareExecution(ctx, promptkit.RunRequest{
|
||||
PromptID: "meeting.summary",
|
||||
Inputs: map[string]promptkit.ArtifactRef{
|
||||
"note": promptkit.Inline("Synthetic meeting notes"),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer preparedExecution.Discard()
|
||||
|
||||
details := preparedExecution.Details()
|
||||
// Inspect or persist an application-selected safe subset of details.
|
||||
|
||||
result, err := engine.RunPrepared(ctx, preparedExecution)
|
||||
```
|
||||
|
||||
Preparation does not call the model or reserve backend capacity.
|
||||
`RunPrepared` executes from the retained snapshot rather than reloading
|
||||
consumer sources. The handle is opaque in-process state, while `Details`
|
||||
contains rendered content and remains subject to the application's data
|
||||
handling policy. The
|
||||
[`PreparedExecution` and method GoDoc](../../prepared_execution.go) and
|
||||
[engine operation GoDoc](../../engine.go) own exact lifecycle, engine-binding,
|
||||
credential, cancellation, timing, and error semantics.
|
||||
|
||||
## Execute And Validate
|
||||
|
||||
[`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.
|
||||
content in one call. Choose it when the application does not need a preflight
|
||||
boundary tied to the eventual execution. A completed content check may return
|
||||
`ValidationFailed` in the result; an operational inability to validate returns
|
||||
an error.
|
||||
|
||||
The maintained
|
||||
[offline execution example](../../examples/go-library/run/main.go) injects a
|
||||
|
||||
@@ -29,21 +29,28 @@ One pool owns immutable active and total limits plus mutex-protected admission
|
||||
count, active count, and ordered waiter list. Pool state exists only for the
|
||||
lifetime of its engine.
|
||||
|
||||
## Bounded Run Admission
|
||||
## Bounded Execution Admission
|
||||
|
||||
The runner asks the manager to admit a run after resolving the prompt, profile,
|
||||
selected backend, effective execution target, credentials, and output contract,
|
||||
but before schema loading, artifact loading, or rendering. Admission is
|
||||
immediate: a limited pool either reserves a slot or returns the internal
|
||||
`ErrCapacityExceeded` identity. The root facade maps that identity to the
|
||||
public error without treating it as an invalid request or generation failure.
|
||||
For ordinary `Run`, the runner asks the manager to admit after resolving the
|
||||
prompt, profile, selected backend, effective execution target, credentials, and
|
||||
output contract, but before schema loading, artifact loading, or rendering.
|
||||
`PrepareExecution` performs no admission. `RunPrepared` claims its handle,
|
||||
rechecks credential availability, and then asks the manager to admit the
|
||||
frozen backend before generation.
|
||||
|
||||
Admission is immediate: a limited pool either reserves a slot or returns the
|
||||
internal `ErrCapacityExceeded` identity. The root facade maps that identity to
|
||||
the public error without treating it as an invalid request or generation
|
||||
failure.
|
||||
|
||||
The total admitted bound is the active-generation limit plus its configured
|
||||
waiting capacity. The returned release function is idempotent. The runner
|
||||
defers it as soon as admission succeeds and holds the lease across remaining
|
||||
preparation, initial generation, validation, every repair attempt, and all
|
||||
failure or cancellation exits. A repair is part of its original admission and
|
||||
does not reserve another bounded slot.
|
||||
defers it as soon as admission succeeds. An ordinary run holds the lease across
|
||||
remaining preparation, initial generation, validation, every repair attempt,
|
||||
and all failure or cancellation exits. Prepared execution holds the normal
|
||||
lease across generation, validation, every internal repair attempt, and all
|
||||
execution exits. A repair is part of its original admission and does not
|
||||
reserve another bounded slot.
|
||||
|
||||
## FIFO Generation Permits
|
||||
|
||||
@@ -90,11 +97,17 @@ unlimited admission. The
|
||||
FIFO transfer, canceled-waiter removal, grant/cancel races, independent pools,
|
||||
unlimited calls, passthrough behavior, and panic release.
|
||||
|
||||
The [runner tests](../../internal/usecase/runner_test.go) own early admission,
|
||||
lease lifetime, failure release, and shared initial/repair scheduling. The
|
||||
The [runner tests](../../internal/usecase/runner_test.go) own ordinary early
|
||||
admission, lease lifetime, failure release, and shared initial/repair
|
||||
scheduling. The
|
||||
[prepared-execution use-case tests](../../internal/usecase/prepared_execution_test.go)
|
||||
own deferred admission, credential ordering, and prepared-execution lease
|
||||
release. The
|
||||
[external package capacity tests](../../capacity_contract_test.go) own the
|
||||
assembled public-engine behavior for configured limits, capacity errors,
|
||||
endpoint identity, engine independence, and injected clients. The
|
||||
[prepared-execution contract tests](../../prepared_execution_contract_test.go)
|
||||
own the public prepared-capacity boundary. The
|
||||
[root error-boundary tests](../../errors_internal_test.go) own preservation of
|
||||
the public generation category and context identity when generation is
|
||||
canceled.
|
||||
|
||||
@@ -43,6 +43,21 @@ without making the model client depend on registry configuration.
|
||||
The implementation has no retry loop, tool-call support, provider catalog,
|
||||
inbound HTTP behavior, or durable session store.
|
||||
|
||||
## Prepared Generation
|
||||
|
||||
For [`RunPrepared`](../../engine.go), the runner supplies the model client with
|
||||
the target, rendered messages, and structured-output constraint retained by
|
||||
executable preparation. Execution does not reopen or rerender consumer
|
||||
sources.
|
||||
|
||||
Before backend admission, the runner rechecks that the frozen credential
|
||||
environment-variable name is available. The handle does not retain the
|
||||
environment value; the model client resolves the value visible when generation
|
||||
begins. A direct request key remains in private execution state only until the
|
||||
claimed execution finishes or an unclaimed handle is discarded. Exact public
|
||||
ownership and redaction semantics belong to the
|
||||
[`PreparedExecution` GoDoc](../../prepared_execution.go).
|
||||
|
||||
## Failure Categories
|
||||
|
||||
The package preserves distinct error identities for invalid client
|
||||
|
||||
@@ -11,23 +11,23 @@ contributor workflow and validation.
|
||||
|
||||
| Component | Implemented responsibility | References |
|
||||
| --- | --- | --- |
|
||||
| Root `promptkit` package | Provides the supported engine facade, source, backend-registration, and injection options, public request and result values, profile construction, extension interfaces, value conversion, redacted formatting, public error mapping, and engine-local assembly. | [Package GoDoc](../../doc.go), [backend API](../../backends.go), [engine assembly](../../engine.go) |
|
||||
| Root `promptkit` package | Provides the supported engine facade, source, backend-registration, and injection options, public request and result values, opaque prepared-execution handles, profile construction, extension interfaces, value conversion, redacted formatting, public error mapping, and engine-local assembly. | [Package GoDoc](../../doc.go), [prepared execution](../../prepared_execution.go), [backend API](../../backends.go), [engine assembly](../../engine.go) |
|
||||
| `examples/go-library/prepare` | Demonstrates an offline downstream consumer using a prompt file, in-memory profile, inline input, and `Prepare`. It is not a public library package. | [Example program](../../examples/go-library/prepare/main.go) |
|
||||
| `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, in-memory profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) |
|
||||
| `internal/backend` | Constructs each engine's immutable registry from the built-in OpenRouter definition and consumer additions, validates and defensively copies definitions through the shared JSON-value package, and consumes the LLM-owned OpenAI-compatible reserved request-field rule. | [Backend registry](../../internal/backend/registry.go) |
|
||||
| `internal/capacity` | Owns engine-local bounded run admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) |
|
||||
| `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) |
|
||||
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, profiles, execution targets, rendering, generation, and validation. | [Domain declarations](../../internal/domain/domain.go) |
|
||||
| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
|
||||
| `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) |
|
||||
| `internal/jsonvalue` | Validates and deeply copies JSON-compatible extra-parameter trees while preserving supported concrete value types. | [JSON values](../../internal/jsonvalue/jsonvalue.go) |
|
||||
| `internal/jsonvalue` | Validates and deeply copies JSON-compatible extra-parameter and prepared-schema trees while preserving supported concrete value types. | [JSON values](../../internal/jsonvalue/jsonvalue.go) |
|
||||
| `internal/promptdef` | Loads strictly decoded, validated prompt definitions from filesystem and `fs.FS` sources, including version selection and contained file-backed message content. | [Framework formats](../formats.md), [prompt-definition repository](../../internal/promptdef/filesystem_repository.go) |
|
||||
| `internal/profile` | Loads strictly decoded, validated execution profiles, including backend selection, from filesystem and `fs.FS` sources and composes repositories with error-preserving fallback. | [Framework formats](../formats.md), [profile repositories](../../internal/profile/filesystem_repository.go) |
|
||||
| `internal/profile/builtin` | Embeds the built-in profile catalog, whose entries select OpenRouter, and combines it with an optional primary repository. | [Built-in catalog](../formats.md#built-in-profile-catalog), [repository](../../internal/profile/builtin/repository.go) |
|
||||
| `internal/prompt` | Renders prompt messages from Go templates with artifact, variable, session, and cache-control data. | [Go-template renderer](../../internal/prompt/go_renderer.go) |
|
||||
| `internal/artifact` | Resolves ordinary inline and unrestricted caller-selected file references into copied artifacts with metadata and hashes. | [Internal sources and validation](sources.md) |
|
||||
| `internal/validate` | Validates basic, JSON, and JSON Schema output using operating-system filesystem or `fs.FS` schema sources. | [Framework formats](../formats.md#schemas), [internal sources and validation](sources.md) |
|
||||
| `internal/validate` | Validates basic, JSON, and JSON Schema output using operating-system filesystem or `fs.FS` schema sources and creates frozen validation plans for prepared execution. | [Framework formats](../formats.md#schemas), [internal sources and validation](sources.md) |
|
||||
| `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests from resolved execution targets, including response decoding, authentication, deadline handling, and ownership of the OpenAI-compatible reserved request-field policy. | [Internal model client](llm.md) |
|
||||
| `internal/usecase` | Resolves backend, profile, and request settings and coordinates preparation and execution across internal sources, rendering, artifact loading, generation, validation, and optional repair. | [Internal runner](runner.md) |
|
||||
| `internal/usecase` | Resolves backend, profile, and request settings and coordinates preparation, ordinary execution, and one-attempt prepared execution across internal sources, rendering, artifact loading, generation, validation, capacity, and optional repair. | [Internal runner](runner.md), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) |
|
||||
|
||||
The root package assembles these internal components without exposing their
|
||||
representations. Consumers depend only on the root facade.
|
||||
|
||||
@@ -68,6 +68,22 @@ filesystem or an `fs.FS`. Invalid generated content is returned as a validation
|
||||
result; inability to load, register, or compile a schema is an operational
|
||||
error.
|
||||
|
||||
For executable preparation, the built-in validators create a frozen validation
|
||||
plan. None, basic, and JSON modes retain the effective output contract without
|
||||
source access. JSON Schema mode loads the root document, resolves and compiles
|
||||
every transitive reference during preparation, and retains the compiled
|
||||
validator. The provider-facing structured-output metadata uses that same
|
||||
captured root document.
|
||||
|
||||
`PrepareExecution` also completes prompt and profile selection, artifact
|
||||
loading and hashing, session and message rendering, and target resolution.
|
||||
`RunPrepared` uses the retained source-derived state and validation plan; it
|
||||
does not reopen prompt, profile, input, or schema sources and does not rerender
|
||||
the request. By contrast, ordinary `Prepare` produces an inspection value only:
|
||||
a later `Run` performs its own source resolution and preparation.
|
||||
|
||||
The [validator tests](../../internal/validate/standard_validator_test.go) own
|
||||
basic, JSON, JSON Schema, source resolution, schema loading, compilation, and
|
||||
content-failure behavior.
|
||||
basic, JSON, JSON Schema, source resolution, schema loading, compilation,
|
||||
frozen-reference behavior, and content-failure behavior. Prepared execution
|
||||
orchestration is owned by the
|
||||
[use-case tests](../../internal/usecase/prepared_execution_test.go).
|
||||
|
||||
@@ -33,26 +33,10 @@ consumers.
|
||||
|
||||
## Ideas
|
||||
|
||||
Executable preparation handles have been selected for active planning in the
|
||||
[focused feature roadmap](prepared-execution.md). The remaining ideas are
|
||||
Prompt-independent profile inspection has been selected for active planning in
|
||||
the [focused feature roadmap](profile-inspection.md). The remaining ideas are
|
||||
still available for future selection.
|
||||
|
||||
### Prompt-independent profile inspection
|
||||
|
||||
Provide exact profile lookup and structural resolution without requiring a
|
||||
synthetic prompt, placeholder inputs, or model generation. This shared need is
|
||||
described by
|
||||
[Notarius](notarius-promptkit-wishlist.md#priority-2-prompt-independent-profile-inspection)
|
||||
and
|
||||
[Weatherreporter](weatherreporter-promptkit-wishlist.md#priority-3-prompt-independent-profile-inspection).
|
||||
|
||||
- Apply ordinary built-in, file-backed, and programmatic profile precedence.
|
||||
- Validate referenced backend membership and the structurally resolved
|
||||
execution target.
|
||||
- Report credential requirements and environment-variable names without
|
||||
exposing credential values or requiring current credential availability.
|
||||
- Support exact lookup by profile ID; enumeration is not required initially.
|
||||
|
||||
### Prompt-definition inspection
|
||||
|
||||
Provide exact prompt-definition lookup without rendering, placeholder inputs,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -88,8 +88,8 @@ corresponds atomically to the actual execution.
|
||||
|
||||
## Priority 2: Prompt-Independent Profile Inspection
|
||||
|
||||
**Disposition:** Accepted into the
|
||||
[future catalog](future.md#prompt-independent-profile-inspection).
|
||||
**Disposition:** Covered by the accepted
|
||||
[prompt-independent profile inspection](profile-inspection.md) roadmap.
|
||||
|
||||
### Downstream need
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Executable Preparation Handles
|
||||
|
||||
**Status:** Accepted.
|
||||
**Status:** Complete.
|
||||
|
||||
## Purpose
|
||||
|
||||
|
||||
246
docs/roadmap/profile-inspection.md
Normal file
246
docs/roadmap/profile-inspection.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# Prompt-Independent Profile Inspection
|
||||
|
||||
**Status:** Accepted.
|
||||
|
||||
## Purpose
|
||||
|
||||
Allow consumers to look up one execution profile by ID and inspect its
|
||||
structurally resolved model target without selecting a prompt, supplying
|
||||
placeholder inputs, checking credential availability, or invoking a model.
|
||||
|
||||
This provides a direct configuration-validation boundary for
|
||||
[Notarius](notarius-promptkit-wishlist.md#priority-2-prompt-independent-profile-inspection)
|
||||
and
|
||||
[Weatherreporter](weatherreporter-promptkit-wishlist.md#priority-3-prompt-independent-profile-inspection).
|
||||
|
||||
## Motivation
|
||||
|
||||
Both downstream consumers need to reject invalid configured profile IDs before
|
||||
starting application work. They currently have to construct a synthetic prompt
|
||||
and call `Engine.Prepare` merely to exercise profile loading, backend lookup,
|
||||
and execution-target resolution.
|
||||
|
||||
That workaround couples profile validation to unrelated prompt definitions,
|
||||
fixture inputs, rendering, schema behavior, and current credential
|
||||
availability. Promptkit already owns profile precedence, backend membership,
|
||||
and target resolution, so it should expose that cohesive capability directly.
|
||||
|
||||
## Consumer Workflow
|
||||
|
||||
The target public workflow is:
|
||||
|
||||
```go
|
||||
inspection, err := engine.InspectProfile(ctx, profileID)
|
||||
if err != nil {
|
||||
// Reject or report the configured profile.
|
||||
return
|
||||
}
|
||||
|
||||
target := inspection.EffectiveModelParams
|
||||
if target.APIKeyEnv != "" {
|
||||
// Apply application policy for the named environment variable.
|
||||
}
|
||||
```
|
||||
|
||||
The target public surface is:
|
||||
|
||||
```go
|
||||
type ProfileInspection struct {
|
||||
ProfileID string
|
||||
EffectiveModelParams ExecutionTarget
|
||||
APIKeyRequired bool
|
||||
}
|
||||
|
||||
func (e *Engine) InspectProfile(
|
||||
ctx context.Context,
|
||||
profileID string,
|
||||
) (*ProfileInspection, error)
|
||||
```
|
||||
|
||||
The declarations and GoDoc will own the exact implemented contract. The
|
||||
important public shape is exact lookup through the existing engine, one
|
||||
caller-owned inspection value, the resolved `ExecutionTarget`, and an explicit
|
||||
signal for a direct API-key requirement.
|
||||
|
||||
`EffectiveModelParams.BackendID` identifies the selected registered backend
|
||||
and remains empty for endpoint-only profiles.
|
||||
`EffectiveModelParams.APIKeyEnv` reports the effective credential environment
|
||||
variable name. `APIKeyRequired` reports that the profile requires a direct
|
||||
request credential instead. These states are mutually exclusive after normal
|
||||
profile and backend precedence is applied.
|
||||
|
||||
`ProfileInspection` does not need a stable JSON representation. Consumers that
|
||||
persist application configuration or diagnostics can select the fields their
|
||||
own format requires.
|
||||
|
||||
## Lookup And Precedence
|
||||
|
||||
`InspectProfile` requires a non-blank explicit profile ID. It trims surrounding
|
||||
whitespace and otherwise performs the same case-sensitive exact lookup used by
|
||||
ordinary execution.
|
||||
|
||||
Lookup applies the engine's normal profile-source precedence:
|
||||
|
||||
- programmatic profiles supplied through `WithProfiles`;
|
||||
- the configured file, directory, or `fs.FS` profile source; and
|
||||
- the built-in profile catalog.
|
||||
|
||||
A valid higher-precedence match shadows a lower-precedence profile with the
|
||||
same ID. A malformed or unreadable higher-precedence match fails rather than
|
||||
silently falling back. The
|
||||
[profile format reference](../formats.md#profile-definitions) remains the
|
||||
canonical owner of profile-source and file-format behavior.
|
||||
|
||||
Inspection never derives a profile ID from a prompt's `default_profile`; the
|
||||
caller is inspecting one explicitly named profile.
|
||||
|
||||
## Structural Resolution
|
||||
|
||||
Inspection loads and validates the selected profile, verifies that a named
|
||||
backend exists in the engine's immutable backend registry, and applies normal
|
||||
framework-default, backend, and profile precedence to produce the effective
|
||||
target.
|
||||
|
||||
For the same engine state and profile ID, with no per-run execution override,
|
||||
the inspected target must match the target that ordinary preparation would
|
||||
resolve before applying request credentials and checking their availability.
|
||||
This equivalence must use one shared resolution path rather than a second set
|
||||
of precedence rules.
|
||||
|
||||
Structural resolution includes:
|
||||
|
||||
- backend routing identity;
|
||||
- endpoint and model;
|
||||
- sampling, token, timeout, service-tier, and reasoning settings;
|
||||
- the effective credential environment-variable name or direct-key
|
||||
requirement; and
|
||||
- deeply copied provider-specific extra parameters.
|
||||
|
||||
Inspection returns the effective target rather than a raw profile definition.
|
||||
This keeps framework and backend defaults visible to consumers without
|
||||
creating a second public profile-loading interface.
|
||||
|
||||
The result does not include request-override presence because no
|
||||
`ExecutionTargetOverride` participates in inspection.
|
||||
|
||||
## Credentials And Sensitive Data
|
||||
|
||||
Inspection reports credential requirements but never resolves, retains, or
|
||||
returns a credential value.
|
||||
|
||||
The operation does not read the named environment variable and succeeds when
|
||||
that variable is absent or blank. It accepts neither a direct API key nor an
|
||||
API-key environment override. Consumers decide whether credential availability
|
||||
must be enforced during application configuration, while `Prepare`,
|
||||
`PrepareExecution`, `Run`, and `RunPrepared` retain their execution-time
|
||||
credential contracts.
|
||||
|
||||
Error messages, formatting, and returned values must not expose environment
|
||||
values or other resolved secrets. Existing restrictions against raw API keys
|
||||
in profile sources remain unchanged.
|
||||
|
||||
## Ownership, Consistency, And Concurrency
|
||||
|
||||
Each successful call returns a caller-owned snapshot. Mutating the returned
|
||||
target or any nested extra-parameter map or slice cannot affect the engine,
|
||||
later inspection, or later execution.
|
||||
|
||||
Inspection is safe to call concurrently under the engine's existing immutable
|
||||
registry and repository contracts. It does not mutate profile sources or
|
||||
cache a result globally.
|
||||
|
||||
For filesystem-backed sources, an inspection describes the state observed by
|
||||
that call. It does not freeze the profile for a later `Run`; a source may
|
||||
change between operations. Consumers requiring an exact preflight-to-execution
|
||||
snapshot should use the existing prepared-execution workflow.
|
||||
|
||||
## Errors And Cancellation
|
||||
|
||||
The operation uses existing public error categories:
|
||||
|
||||
- a blank profile ID matches `ErrInvalidRequest`;
|
||||
- an absent exact ID matches `ErrProfileNotFound` and not `ErrProfileLoad`;
|
||||
- read, decode, validation, and source-selection failures match
|
||||
`ErrProfileLoad`; and
|
||||
- an unknown referenced backend or an invalid structurally resolved target
|
||||
matches `ErrProfileLoad`.
|
||||
|
||||
Errors should preserve useful underlying collaborator and context identities
|
||||
through `errors.Is` where the existing facade does so, without exposing
|
||||
internal package types. Context cancellation governs inspection and no partial
|
||||
inspection result is returned on failure.
|
||||
|
||||
Missing credential values are not inspection errors. The operation cannot
|
||||
return capacity or model-generation failures because it performs neither
|
||||
backend admission nor generation.
|
||||
|
||||
## Compatibility And Boundaries
|
||||
|
||||
This feature is additive. Existing profile formats, source precedence,
|
||||
backend registration, `Prepare`, prepared execution, and `Run` behavior remain
|
||||
unchanged.
|
||||
|
||||
The method belongs on the root `Engine` facade. Profile repositories and the
|
||||
backend registry remain internal implementation details, and no new public
|
||||
repository interface is introduced.
|
||||
|
||||
Inspection does not require prompt lookup, rendering, artifact loading, schema
|
||||
loading, validation, backend-capacity admission, or model-client access.
|
||||
Engine construction retains its ordinary configuration requirements; this
|
||||
feature does not introduce a separate profile-only engine.
|
||||
|
||||
## Documentation
|
||||
|
||||
The completed documentation set has these ownership boundaries:
|
||||
|
||||
- exported declarations and GoDoc own the exact method, result, ownership,
|
||||
credential, error, and cancellation contracts;
|
||||
- the promptkit consumer guide explains configuration-time profile inspection
|
||||
and distinguishes it from `Prepare` and prepared execution;
|
||||
- the profile format reference continues to own profile fields and source
|
||||
precedence; and
|
||||
- internal documentation describes shared profile and target resolution
|
||||
without duplicating public contracts.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
This work does not include:
|
||||
|
||||
- enumerating or searching profiles;
|
||||
- returning raw profile definitions or profile source paths;
|
||||
- accepting per-run execution overrides, direct API keys, or API-key
|
||||
environment overrides;
|
||||
- checking environment-variable contents or other credential availability;
|
||||
- semantic execution-target fingerprints or profile hashes;
|
||||
- prompt-definition inspection or prompt default-profile resolution;
|
||||
- full-corpus validation across every profile source;
|
||||
- freezing a filesystem-backed profile for later execution;
|
||||
- exposing backend concurrency limits, queue state, or capacity policy;
|
||||
- model generation, provider health checks, or endpoint connectivity tests;
|
||||
- dynamic backend or profile registration after engine construction; or
|
||||
- changing current profile, backend, prepared-execution, or stable JSON
|
||||
contracts.
|
||||
|
||||
## Target End State
|
||||
|
||||
After this work:
|
||||
|
||||
- consumers can validate one configured profile without inventing a prompt or
|
||||
placeholder inputs;
|
||||
- lookup observes ordinary programmatic, configured-source, and built-in
|
||||
precedence;
|
||||
- a successful result proves that the profile exists, is valid, references a
|
||||
registered backend when applicable, and resolves to a structurally valid
|
||||
effective target;
|
||||
- the inspected target matches ordinary preparation for the same profile and
|
||||
engine state before per-run overrides and credential availability checks;
|
||||
- credential requirements are visible without reading or exposing credential
|
||||
values;
|
||||
- returned targets and nested data are caller-owned;
|
||||
- inspection performs no rendering, source loading unrelated to the profile,
|
||||
capacity admission, or model work;
|
||||
- existing execution workflows and compatibility contracts remain unchanged;
|
||||
and
|
||||
- Promptkit owns reusable profile validation while downstream applications
|
||||
retain configuration policy, persistence, logging, and credential-timing
|
||||
decisions.
|
||||
@@ -185,8 +185,8 @@ and move failures ahead of weather collection.
|
||||
|
||||
## Priority 3: Prompt-Independent Profile Inspection
|
||||
|
||||
**Disposition:** Accepted into the
|
||||
[future catalog](future.md#prompt-independent-profile-inspection).
|
||||
**Disposition:** Covered by the accepted
|
||||
[prompt-independent profile inspection](profile-inspection.md) roadmap.
|
||||
|
||||
### Downstream need
|
||||
|
||||
|
||||
92
engine.go
92
engine.go
@@ -63,9 +63,10 @@ var (
|
||||
// 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")
|
||||
// ErrCapacityExceeded identifies a Run rejected because the selected backend
|
||||
// already admitted ConcurrencyLimit + QueueCapacity calls. It is not an
|
||||
// invalid request, an LLM or provider rate-limit response, or ErrLLMGenerate.
|
||||
// ErrCapacityExceeded identifies a Run or RunPrepared rejected because the
|
||||
// selected backend already admitted ConcurrencyLimit + QueueCapacity calls.
|
||||
// It is not an invalid request, an LLM or provider rate-limit response, or
|
||||
// ErrLLMGenerate.
|
||||
ErrCapacityExceeded = errors.New("backend capacity exceeded")
|
||||
// ErrLLMGenerate identifies a model-client failure or a nil successful
|
||||
// response. Errors returned by an injected LLMClient remain available
|
||||
@@ -79,10 +80,12 @@ var (
|
||||
|
||||
// Engine prepares and runs Promptkit prompt requests.
|
||||
//
|
||||
// An Engine is safe for concurrent calls to [Engine.Prepare] and [Engine.Run].
|
||||
// Each Engine owns independent backend-capacity pools that coordinate Run
|
||||
// admission and model generation. Injected collaborators may still be invoked
|
||||
// concurrently across different backend pools or for unlimited backends.
|
||||
// An Engine is safe for concurrent calls to [Engine.Prepare],
|
||||
// [Engine.PrepareExecution], [Engine.Run], and [Engine.RunPrepared]. Each
|
||||
// Engine owns independent backend-capacity pools that coordinate Run and
|
||||
// RunPrepared admission and model generation. Injected collaborators may still
|
||||
// be invoked concurrently across different backend pools or for unlimited
|
||||
// backends.
|
||||
type Engine struct {
|
||||
runner *usecase.Runner
|
||||
}
|
||||
@@ -146,12 +149,14 @@ type engineOptions struct {
|
||||
artifactSource bool
|
||||
}
|
||||
|
||||
// WithLLMClient replaces the built-in model client used by [Engine.Run].
|
||||
// WithLLMClient replaces the built-in model client used by [Engine.Run] and
|
||||
// [Engine.RunPrepared].
|
||||
//
|
||||
// A nil client makes NewEngine fail with ErrInvalidConfig. The Engine schedules
|
||||
// Generate calls according to the selected backend's capacity policy, but the
|
||||
// client may still be called concurrently across different backend pools or for
|
||||
// unlimited backends. The client is not used by [Engine.Prepare].
|
||||
// unlimited backends. The client is not used by [Engine.Prepare] or
|
||||
// [Engine.PrepareExecution].
|
||||
func WithLLMClient(client LLMClient) Option {
|
||||
return optionFunc(func(options *engineOptions) error {
|
||||
if client == nil {
|
||||
@@ -457,6 +462,38 @@ func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, err
|
||||
return fromDomainPreparedRun(prepared), nil
|
||||
}
|
||||
|
||||
// PrepareExecution completely prepares a prompt request without calling the
|
||||
// configured LLMClient or reserving backend admission capacity.
|
||||
//
|
||||
// The returned opaque handle is bound to this Engine and permits one
|
||||
// [Engine.RunPrepared] invocation. Preparation freezes the selected sources,
|
||||
// rendered messages, effective settings, inputs, provider structured-output
|
||||
// metadata, and validation resources needed by that invocation. The handle
|
||||
// retains a direct RunRequest.APIKey only in private execution state;
|
||||
// [PreparedExecution.Details] is credential-redacted.
|
||||
//
|
||||
// The context governs preparation only. Cancellation after this method
|
||||
// returns does not invalidate the handle or propagate to RunPrepared.
|
||||
// PrepareExecution returns the same error categories as [Engine.Prepare] and
|
||||
// returns no handle on error. A nil Engine returns an error matching
|
||||
// ErrInvalidConfig.
|
||||
func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*PreparedExecution, 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.PrepareExecution(ctx, domainReq)
|
||||
if err != nil {
|
||||
return nil, mapPublicError(err)
|
||||
}
|
||||
return &PreparedExecution{internal: prepared}, nil
|
||||
}
|
||||
|
||||
// Run prepares a request, invokes the configured LLMClient, and validates the
|
||||
// generated output.
|
||||
//
|
||||
@@ -491,3 +528,40 @@ func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
}
|
||||
return fromDomainRunResult(result), nil
|
||||
}
|
||||
|
||||
// RunPrepared atomically claims and executes a handle created by
|
||||
// [Engine.PrepareExecution].
|
||||
//
|
||||
// A valid owning-Engine invocation consumes the handle's one attempt before
|
||||
// credential revalidation, backend admission, generation, or validation.
|
||||
// Cancellation, capacity rejection, generation failure, operational
|
||||
// validation failure, and success all leave the handle unusable. A nil,
|
||||
// zero-value, foreign-Engine, discarded, claimed, or used handle returns an
|
||||
// error matching ErrInvalidRequest; a nil Engine returns ErrInvalidConfig and
|
||||
// does not claim the handle.
|
||||
//
|
||||
// The supplied context governs this execution attempt independently of the
|
||||
// preparation context. It covers credential revalidation, admission,
|
||||
// generation, validation, and any internal repair. Result timing begins after
|
||||
// the claim and excludes preparation and consumer-held delay.
|
||||
//
|
||||
// RunPrepared can return ErrInvalidRequest, ErrAPIKeyEnvMissing,
|
||||
// ErrCapacityExceeded, ErrLLMGenerate, or ErrValidation as applicable while
|
||||
// preserving documented collaborator and context identities. A completed
|
||||
// content-validation rejection is returned in RunResult, not as an
|
||||
// operational error. An operational error returns no partial RunResult.
|
||||
func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (*RunResult, error) {
|
||||
if e == nil || e.runner == nil {
|
||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||
}
|
||||
|
||||
var internal *usecase.PreparedExecution
|
||||
if prepared != nil {
|
||||
internal = prepared.internal
|
||||
}
|
||||
result, err := e.runner.RunPrepared(ctx, internal)
|
||||
if err != nil {
|
||||
return nil, mapPublicError(err)
|
||||
}
|
||||
return fromDomainRunResult(result), nil
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package jsonvalue validates and defensively copies JSON-compatible value
|
||||
// trees used by public configuration and request boundaries.
|
||||
// trees used by configuration, request, and prepared-state boundaries.
|
||||
package jsonvalue
|
||||
|
||||
import (
|
||||
@@ -18,13 +18,19 @@ type visit struct {
|
||||
ptr uintptr
|
||||
}
|
||||
|
||||
// Copy validates and deeply copies a JSON-compatible value while preserving
|
||||
// compatible concrete map, slice, array, scalar, and number types.
|
||||
func Copy(src any) (any, error) {
|
||||
return copyValue(reflect.ValueOf(src), "value", make(map[visit]struct{}), true)
|
||||
}
|
||||
|
||||
// CopyMap validates and deeply copies an extra-parameter map while preserving
|
||||
// compatible concrete map, slice, array, scalar, and number types.
|
||||
func CopyMap(src map[string]any) (map[string]any, error) {
|
||||
if src == nil {
|
||||
return nil, nil
|
||||
}
|
||||
copied, err := copyValue(reflect.ValueOf(src), "extra_params", make(map[visit]struct{}))
|
||||
copied, err := copyValue(reflect.ValueOf(src), "extra_params", make(map[visit]struct{}), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -35,7 +41,12 @@ func CopyMap(src map[string]any) (map[string]any, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func copyValue(value reflect.Value, path string, seen map[visit]struct{}) (any, error) {
|
||||
func copyValue(
|
||||
value reflect.Value,
|
||||
path string,
|
||||
seen map[visit]struct{},
|
||||
allowEmptyMapKeys bool,
|
||||
) (any, error) {
|
||||
if !value.IsValid() {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -43,7 +54,7 @@ func copyValue(value reflect.Value, path string, seen map[visit]struct{}) (any,
|
||||
if value.IsNil() {
|
||||
return nil, nil
|
||||
}
|
||||
return copyValue(value.Elem(), path, seen)
|
||||
return copyValue(value.Elem(), path, seen, allowEmptyMapKeys)
|
||||
}
|
||||
if !value.CanInterface() {
|
||||
return nil, fmt.Errorf("%s: value cannot be copied", path)
|
||||
@@ -88,22 +99,27 @@ func copyValue(value reflect.Value, path string, seen map[visit]struct{}) (any,
|
||||
}
|
||||
seen[current] = struct{}{}
|
||||
defer delete(seen, current)
|
||||
return copyValue(value.Elem(), path, seen)
|
||||
return copyValue(value.Elem(), path, seen, allowEmptyMapKeys)
|
||||
case reflect.Map:
|
||||
return copyMapValue(value, path, seen)
|
||||
return copyMapValue(value, path, seen, allowEmptyMapKeys)
|
||||
case reflect.Slice:
|
||||
if value.IsNil() {
|
||||
return nil, nil
|
||||
}
|
||||
return copySequenceValue(value, path, seen)
|
||||
return copySequenceValue(value, path, seen, allowEmptyMapKeys)
|
||||
case reflect.Array:
|
||||
return copySequenceValue(value, path, seen)
|
||||
return copySequenceValue(value, path, seen, allowEmptyMapKeys)
|
||||
default:
|
||||
return nil, fmt.Errorf("%s: unsupported JSON value type %s", path, value.Type())
|
||||
}
|
||||
}
|
||||
|
||||
func copyMapValue(value reflect.Value, path string, seen map[visit]struct{}) (any, error) {
|
||||
func copyMapValue(
|
||||
value reflect.Value,
|
||||
path string,
|
||||
seen map[visit]struct{},
|
||||
allowEmptyMapKeys bool,
|
||||
) (any, error) {
|
||||
if value.IsNil() {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -133,10 +149,10 @@ func copyMapValue(value reflect.Value, path string, seen map[visit]struct{}) (an
|
||||
elementType := value.Type().Elem()
|
||||
for _, key := range keys {
|
||||
name := key.String()
|
||||
if name == "" {
|
||||
if name == "" && !allowEmptyMapKeys {
|
||||
return nil, fmt.Errorf("%s: map key must not be empty", path)
|
||||
}
|
||||
copied, err := copyValue(value.MapIndex(key), path+"."+name, seen)
|
||||
copied, err := copyValue(value.MapIndex(key), path+"."+name, seen, allowEmptyMapKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -171,7 +187,12 @@ func copyMapValue(value reflect.Value, path string, seen map[visit]struct{}) (an
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func copySequenceValue(value reflect.Value, path string, seen map[visit]struct{}) (any, error) {
|
||||
func copySequenceValue(
|
||||
value reflect.Value,
|
||||
path string,
|
||||
seen map[visit]struct{},
|
||||
allowEmptyMapKeys bool,
|
||||
) (any, error) {
|
||||
var current visit
|
||||
if value.Kind() == reflect.Slice {
|
||||
current = visit{typ: value.Type(), ptr: value.Pointer()}
|
||||
@@ -186,7 +207,12 @@ func copySequenceValue(value reflect.Value, path string, seen map[visit]struct{}
|
||||
preserveType := true
|
||||
elementType := value.Type().Elem()
|
||||
for i := 0; i < value.Len(); i++ {
|
||||
copied, err := copyValue(value.Index(i), fmt.Sprintf("%s[%d]", path, i), seen)
|
||||
copied, err := copyValue(
|
||||
value.Index(i),
|
||||
fmt.Sprintf("%s[%d]", path, i),
|
||||
seen,
|
||||
allowEmptyMapKeys,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -44,6 +44,21 @@ func TestCopyMapPreservesTypesAndIsolatesMutations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyAllowsEmptyObjectKeysAndIsolatesMutations(t *testing.T) {
|
||||
nested := map[string]any{"": []any{"original"}}
|
||||
|
||||
copiedValue, err := jsonvalue.Copy(nested)
|
||||
if err != nil {
|
||||
t.Fatalf("copy value: %v", err)
|
||||
}
|
||||
nested[""].([]any)[0] = "changed"
|
||||
|
||||
copied := copiedValue.(map[string]any)
|
||||
if got := copied[""].([]any)[0]; got != "original" {
|
||||
t.Fatalf("copied value was not isolated: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyMapRejectsInvalidValues(t *testing.T) {
|
||||
cyclicMap := map[string]any{}
|
||||
cyclicMap["self"] = cyclicMap
|
||||
|
||||
298
internal/usecase/prepared_execution.go
Normal file
298
internal/usecase/prepared_execution.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/validate"
|
||||
)
|
||||
|
||||
type preparedExecutionState uint8
|
||||
|
||||
const (
|
||||
preparedExecutionReady preparedExecutionState = iota
|
||||
preparedExecutionClaimed
|
||||
preparedExecutionDiscarded
|
||||
)
|
||||
|
||||
// PreparedExecution owns one frozen, single-use runner execution.
|
||||
type PreparedExecution struct {
|
||||
owner *Runner
|
||||
mu sync.Mutex
|
||||
state preparedExecutionState
|
||||
details *domain.PreparedRun
|
||||
payload *preparedExecutionPayload
|
||||
}
|
||||
|
||||
type preparedExecutionPayload struct {
|
||||
prepared *domain.PreparedRun
|
||||
validation validate.PreparedValidation
|
||||
directKey string
|
||||
}
|
||||
|
||||
// PrepareExecution completes preparation without generation or admission and
|
||||
// returns a runner-bound, single-use execution.
|
||||
func (r *Runner) PrepareExecution(ctx context.Context, req domain.RunRequest) (*PreparedExecution, error) {
|
||||
state, err := r.resolvePreparation(ctx, req, time.Now().UTC())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
validationPlan, err := r.prepareValidation(ctx, state.effectiveContract)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
structuredOutput, err := r.structuredOutputFromValidationPlan(
|
||||
state.definition,
|
||||
state.effectiveContract,
|
||||
validationPlan,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prepared, err := r.completePreparationWithStructuredOutput(ctx, req, state, structuredOutput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
executionSnapshot, err := clonePreparedRun(prepared)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to copy prepared execution: %v", ErrInvalidRequest, err)
|
||||
}
|
||||
|
||||
details, err := clonePreparedRun(executionSnapshot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to copy prepared execution details: %v", ErrInvalidRequest, err)
|
||||
}
|
||||
|
||||
return &PreparedExecution{
|
||||
owner: r,
|
||||
state: preparedExecutionReady,
|
||||
details: details,
|
||||
payload: &preparedExecutionPayload{
|
||||
prepared: executionSnapshot,
|
||||
validation: validationPlan,
|
||||
directKey: state.effectiveModel.APIKey,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Runner) prepareValidation(
|
||||
ctx context.Context,
|
||||
contract domain.OutputContract,
|
||||
) (validate.PreparedValidation, error) {
|
||||
if r.validator == nil {
|
||||
return noOpPreparedValidation{contract: contract}, nil
|
||||
}
|
||||
preparer, ok := r.validator.(validate.ValidationPreparer)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: validator does not support prepared validation", ErrValidation)
|
||||
}
|
||||
plan, err := preparer.PrepareValidation(ctx, contract)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrValidation, err)
|
||||
}
|
||||
if plan == nil {
|
||||
return nil, fmt.Errorf("%w: validator returned nil prepared validation", ErrValidation)
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func (r *Runner) structuredOutputFromValidationPlan(
|
||||
def *domain.PromptDefinition,
|
||||
contract domain.OutputContract,
|
||||
plan validate.PreparedValidation,
|
||||
) (*domain.StructuredOutputSpec, error) {
|
||||
if contract.ValidationMode != domain.ValidationJSONSchema {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
schemaDocument := plan.SchemaDocument()
|
||||
if schemaDocument == nil {
|
||||
if r.validator == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: prepared json_schema validation has no schema document", ErrValidation)
|
||||
}
|
||||
return structuredOutputSpec(def, schemaDocument), nil
|
||||
}
|
||||
|
||||
// Details returns a fresh credential-redacted copy of the prepared run.
|
||||
func (p *PreparedExecution) Details() *domain.PreparedRun {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.mu.Lock()
|
||||
detailsSnapshot := p.details
|
||||
p.mu.Unlock()
|
||||
|
||||
details, err := clonePreparedRun(detailsSnapshot)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
// Discard invalidates an unclaimed execution and drops its private payload.
|
||||
func (p *PreparedExecution) Discard() {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
if p.state != preparedExecutionReady {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
p.state = preparedExecutionDiscarded
|
||||
payload := p.payload
|
||||
p.payload = nil
|
||||
p.mu.Unlock()
|
||||
|
||||
payload.clear()
|
||||
}
|
||||
|
||||
// RunPrepared claims and executes one prepared execution owned by this runner.
|
||||
func (r *Runner) RunPrepared(ctx context.Context, prepared *PreparedExecution) (*domain.RunResult, error) {
|
||||
payload, err := prepared.claim(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer payload.clear()
|
||||
|
||||
runID, err := newRunID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create run id: %w", err)
|
||||
}
|
||||
start := time.Now().UTC()
|
||||
|
||||
target := payload.prepared.EffectiveModelParams
|
||||
if err := validateAPIKey(target.APIKeyEnv, payload.directKey, target.APIKeyRequired); err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err)
|
||||
}
|
||||
|
||||
release, err := r.admitRun(ctx, target.BackendID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
return r.executePreparedRun(ctx, payload.prepared, payload.directKey, runID, start, func(
|
||||
ctx context.Context,
|
||||
artifact *domain.Artifact,
|
||||
attemptsUsed int,
|
||||
) (domain.ValidationResult, error) {
|
||||
result, validationErr := payload.validation.Validate(ctx, artifact)
|
||||
if validationErr != nil {
|
||||
return domain.ValidationResult{}, validationErr
|
||||
}
|
||||
result.RepairAttempts = attemptsUsed
|
||||
return result, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (p *PreparedExecution) claim(owner *Runner) (*preparedExecutionPayload, error) {
|
||||
if p == nil || owner == nil || p.owner != owner {
|
||||
return nil, fmt.Errorf("%w: prepared execution does not belong to this runner", ErrInvalidRequest)
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.state != preparedExecutionReady || p.payload == nil {
|
||||
return nil, fmt.Errorf("%w: prepared execution is not ready", ErrInvalidRequest)
|
||||
}
|
||||
p.state = preparedExecutionClaimed
|
||||
payload := p.payload
|
||||
p.payload = nil
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (p *preparedExecutionPayload) clear() {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if p.prepared != nil {
|
||||
p.prepared.EffectiveModelParams.APIKey = ""
|
||||
}
|
||||
p.prepared = nil
|
||||
p.validation = nil
|
||||
p.directKey = ""
|
||||
}
|
||||
|
||||
type noOpPreparedValidation struct {
|
||||
contract domain.OutputContract
|
||||
}
|
||||
|
||||
func (p noOpPreparedValidation) Validate(
|
||||
ctx context.Context,
|
||||
_ *domain.Artifact,
|
||||
) (domain.ValidationResult, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return domain.ValidationResult{}, err
|
||||
}
|
||||
return domain.ValidationResult{
|
||||
Status: domain.ValidationSkipped,
|
||||
Mode: p.contract.ValidationMode,
|
||||
SchemaPath: p.contract.SchemaPath,
|
||||
RepairAttempts: p.contract.RepairAttempts,
|
||||
IsValid: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (noOpPreparedValidation) SchemaDocument() any {
|
||||
return nil
|
||||
}
|
||||
|
||||
func clonePreparedRun(source *domain.PreparedRun) (*domain.PreparedRun, error) {
|
||||
if source == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
copied := *source
|
||||
extraParams, err := jsonvalue.CopyMap(source.EffectiveModelParams.ExtraParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copied.EffectiveModelParams.ExtraParams = extraParams
|
||||
|
||||
if source.InputHashes != nil {
|
||||
copied.InputHashes = make(map[string]string, len(source.InputHashes))
|
||||
for name, hash := range source.InputHashes {
|
||||
copied.InputHashes[name] = hash
|
||||
}
|
||||
}
|
||||
if source.Messages != nil {
|
||||
copied.Messages = make([]domain.RenderedMessage, len(source.Messages))
|
||||
for i, message := range source.Messages {
|
||||
copied.Messages[i] = message
|
||||
if message.CacheControl != nil {
|
||||
cacheControl := *message.CacheControl
|
||||
copied.Messages[i].CacheControl = &cacheControl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if source.StructuredOutput != nil {
|
||||
structuredOutput := *source.StructuredOutput
|
||||
copied.StructuredOutput = &structuredOutput
|
||||
if source.StructuredOutput.JSONSchema != nil {
|
||||
jsonSchema := *source.StructuredOutput.JSONSchema
|
||||
copied.StructuredOutput.JSONSchema = &jsonSchema
|
||||
schema, copyErr := cloneJSONValue(source.StructuredOutput.JSONSchema.Schema)
|
||||
if copyErr != nil {
|
||||
return nil, copyErr
|
||||
}
|
||||
copied.StructuredOutput.JSONSchema.Schema = schema
|
||||
}
|
||||
}
|
||||
return &copied, nil
|
||||
}
|
||||
|
||||
func cloneJSONValue(source any) (any, error) {
|
||||
return jsonvalue.Copy(source)
|
||||
}
|
||||
510
internal/usecase/prepared_execution_test.go
Normal file
510
internal/usecase/prepared_execution_test.go
Normal file
@@ -0,0 +1,510 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/validate"
|
||||
)
|
||||
|
||||
type recordingPreparedValidation struct {
|
||||
contract domain.OutputContract
|
||||
schemaDocument any
|
||||
results []domain.ValidationResult
|
||||
errs []error
|
||||
artifacts []string
|
||||
}
|
||||
|
||||
func (p *recordingPreparedValidation) Validate(
|
||||
_ context.Context,
|
||||
artifact *domain.Artifact,
|
||||
) (domain.ValidationResult, error) {
|
||||
p.artifacts = append(p.artifacts, string(artifact.Body))
|
||||
index := len(p.artifacts) - 1
|
||||
if index < len(p.errs) && p.errs[index] != nil {
|
||||
return domain.ValidationResult{}, p.errs[index]
|
||||
}
|
||||
if len(p.results) == 0 {
|
||||
return domain.ValidationResult{
|
||||
Status: domain.ValidationPassed,
|
||||
Mode: p.contract.ValidationMode,
|
||||
IsValid: true,
|
||||
}, nil
|
||||
}
|
||||
if index >= len(p.results) {
|
||||
index = len(p.results) - 1
|
||||
}
|
||||
return p.results[index], nil
|
||||
}
|
||||
|
||||
func (p *recordingPreparedValidation) SchemaDocument() any {
|
||||
return p.schemaDocument
|
||||
}
|
||||
|
||||
type recordingValidationPreparer struct {
|
||||
plan *recordingPreparedValidation
|
||||
prepareErr error
|
||||
prepareCalls int
|
||||
directValidateCalls int
|
||||
}
|
||||
|
||||
func (v *recordingValidationPreparer) Validate(
|
||||
context.Context,
|
||||
*domain.Artifact,
|
||||
domain.OutputContract,
|
||||
) (domain.ValidationResult, error) {
|
||||
v.directValidateCalls++
|
||||
return domain.ValidationResult{}, errors.New("live validation must not be used")
|
||||
}
|
||||
|
||||
func (v *recordingValidationPreparer) PrepareValidation(
|
||||
_ context.Context,
|
||||
contract domain.OutputContract,
|
||||
) (validate.PreparedValidation, error) {
|
||||
v.prepareCalls++
|
||||
if v.prepareErr != nil {
|
||||
return nil, v.prepareErr
|
||||
}
|
||||
v.plan.contract = contract
|
||||
return v.plan, nil
|
||||
}
|
||||
|
||||
func TestRunnerPrepareExecutionCompletesWithoutAdmissionOrGeneration(t *testing.T) {
|
||||
schemaDocument := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"value": map[string]any{"type": "string"},
|
||||
"": map[string]any{"type": "boolean"},
|
||||
},
|
||||
}
|
||||
def := promptDef(domain.FormatJSON, domain.ValidationJSONSchema, 0)
|
||||
def.Validation.SchemaPath = "schema.json"
|
||||
reader := defaultArtifactReader()
|
||||
renderer := &fakeRenderer{rendered: &domain.RenderedPrompt{
|
||||
SessionID: "prepared-session",
|
||||
Messages: []domain.RenderedMessage{{
|
||||
Role: "user",
|
||||
Content: "original message",
|
||||
}},
|
||||
}}
|
||||
llmClient := &fakeLLM{forbid: true}
|
||||
validator := &recordingValidationPreparer{
|
||||
plan: &recordingPreparedValidation{schemaDocument: schemaDocument},
|
||||
}
|
||||
admitter := &fakeRunAdmitter{}
|
||||
profile := defaultExecutionProfile()
|
||||
profile.ExtraParams = map[string]any{
|
||||
"metadata": map[string]any{"source": "original"},
|
||||
}
|
||||
runner := NewRunner(
|
||||
&fakePromptRepo{def: def},
|
||||
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": profile}},
|
||||
nil,
|
||||
reader,
|
||||
renderer,
|
||||
llmClient,
|
||||
validator,
|
||||
admitter,
|
||||
)
|
||||
|
||||
prepared, err := runner.PrepareExecution(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
APIKey: "direct-test-key",
|
||||
Inputs: singleInputRef(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare execution: %v", err)
|
||||
}
|
||||
defer prepared.Discard()
|
||||
|
||||
if validator.prepareCalls != 1 || validator.directValidateCalls != 0 {
|
||||
t.Fatalf(
|
||||
"validation calls=(prepare=%d direct=%d), want (1, 0)",
|
||||
validator.prepareCalls,
|
||||
validator.directValidateCalls,
|
||||
)
|
||||
}
|
||||
if reader.calls != 1 || renderer.calls != 1 {
|
||||
t.Fatalf("completion calls=(artifact=%d render=%d), want (1, 1)", reader.calls, renderer.calls)
|
||||
}
|
||||
if len(admitter.backendIDs) != 0 || llmClient.calls != 0 {
|
||||
t.Fatalf("prepare invoked execution collaborators: admission=%v generation=%d", admitter.backendIDs, llmClient.calls)
|
||||
}
|
||||
|
||||
first := prepared.Details()
|
||||
if first == nil {
|
||||
t.Fatal("prepared details are nil")
|
||||
}
|
||||
if first.EffectiveModelParams.APIKey != "" {
|
||||
t.Fatal("prepared details retained the direct API key")
|
||||
}
|
||||
if first.StructuredOutput == nil ||
|
||||
first.StructuredOutput.JSONSchema == nil ||
|
||||
!reflect.DeepEqual(first.StructuredOutput.JSONSchema.Schema, schemaDocument) {
|
||||
t.Fatalf("prepared details have unexpected structured output: %#v", first.StructuredOutput)
|
||||
}
|
||||
|
||||
first.Messages[0].Content = "caller mutation"
|
||||
first.InputHashes["input"] = "caller mutation"
|
||||
first.EffectiveModelParams.ExtraParams["metadata"].(map[string]any)["source"] = "caller mutation"
|
||||
first.StructuredOutput.JSONSchema.Schema.(map[string]any)["type"] = "string"
|
||||
renderer.rendered.Messages[0].Content = "source mutation"
|
||||
|
||||
second := prepared.Details()
|
||||
if second.Messages[0].Content != "original message" ||
|
||||
second.InputHashes["input"] == "caller mutation" ||
|
||||
second.EffectiveModelParams.ExtraParams["metadata"].(map[string]any)["source"] != "original" ||
|
||||
second.StructuredOutput.JSONSchema.Schema.(map[string]any)["type"] != "object" {
|
||||
t.Fatalf("details did not preserve an independent snapshot: %#v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerRunPreparedRechecksEnvironmentCredentialBeforeAdmission(t *testing.T) {
|
||||
const environmentName = "PROMPTKIT_PREPARED_EXECUTION_TEST_KEY"
|
||||
t.Setenv(environmentName, "available-during-preparation")
|
||||
|
||||
profile := defaultExecutionProfile()
|
||||
profile.APIKeyEnv = environmentName
|
||||
validator := &recordingValidationPreparer{plan: &recordingPreparedValidation{}}
|
||||
admitter := &fakeRunAdmitter{}
|
||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "unexpected"}}
|
||||
runner := NewRunner(
|
||||
&fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)},
|
||||
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": profile}},
|
||||
nil,
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
llmClient,
|
||||
validator,
|
||||
admitter,
|
||||
)
|
||||
prepared, err := runner.PrepareExecution(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
Inputs: singleInputRef(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare execution: %v", err)
|
||||
}
|
||||
if err := os.Unsetenv(environmentName); err != nil {
|
||||
t.Fatalf("unset credential environment: %v", err)
|
||||
}
|
||||
|
||||
result, err := runner.RunPrepared(context.Background(), prepared)
|
||||
if result != nil {
|
||||
t.Fatalf("credential failure returned partial result: %+v", result)
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidRequest) || !errors.Is(err, ErrAPIKeyEnvMissing) {
|
||||
t.Fatalf("credential error identities are missing: %v", err)
|
||||
}
|
||||
if len(admitter.backendIDs) != 0 || llmClient.calls != 0 {
|
||||
t.Fatalf("credential failure reached admission or generation: admission=%v generation=%d", admitter.backendIDs, llmClient.calls)
|
||||
}
|
||||
if _, err := runner.RunPrepared(context.Background(), prepared); !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatalf("credential failure did not consume execution: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerRunPreparedKeepsDirectCredentialOutOfMetadata(t *testing.T) {
|
||||
const directKey = "direct-prepared-test-key"
|
||||
|
||||
profile := defaultExecutionProfile()
|
||||
profile.APIKeyRequired = true
|
||||
client := &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}
|
||||
runner := NewRunner(
|
||||
&fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)},
|
||||
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": profile}},
|
||||
nil,
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
client,
|
||||
&recordingValidationPreparer{plan: &recordingPreparedValidation{}},
|
||||
nil,
|
||||
)
|
||||
|
||||
prepared, err := runner.PrepareExecution(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
APIKey: directKey,
|
||||
Inputs: singleInputRef(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare execution: %v", err)
|
||||
}
|
||||
if details := prepared.Details(); details.EffectiveModelParams.APIKey != "" {
|
||||
t.Fatal("prepared details retained direct credential")
|
||||
}
|
||||
|
||||
result, err := runner.RunPrepared(context.Background(), prepared)
|
||||
if err != nil {
|
||||
t.Fatalf("run prepared: %v", err)
|
||||
}
|
||||
if client.lastReq.Target.APIKey != directKey {
|
||||
t.Fatal("generation did not receive direct credential")
|
||||
}
|
||||
if result.EffectiveModelParams.APIKey != "" {
|
||||
t.Fatal("run result retained direct credential")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerRunPreparedUsesFrozenValidationForInitialAndRepairOutputs(t *testing.T) {
|
||||
validator := &recordingValidationPreparer{
|
||||
plan: &recordingPreparedValidation{
|
||||
results: []domain.ValidationResult{
|
||||
{
|
||||
Status: domain.ValidationFailed,
|
||||
Mode: domain.ValidationJSON,
|
||||
Errors: []string{"invalid"},
|
||||
IsValid: false,
|
||||
},
|
||||
{
|
||||
Status: domain.ValidationPassed,
|
||||
Mode: domain.ValidationJSON,
|
||||
IsValid: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
repairer := &fakeRepairer{
|
||||
responses: []*domain.GenerateResponse{{Content: `{"repaired":true}`}},
|
||||
}
|
||||
admitter := &fakeRunAdmitter{}
|
||||
reader := defaultArtifactReader()
|
||||
renderer := defaultRenderer()
|
||||
runner := NewRunnerWithRepairer(
|
||||
&fakePromptRepo{def: promptDef(domain.FormatJSON, domain.ValidationJSON, 1)},
|
||||
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}},
|
||||
nil,
|
||||
reader,
|
||||
renderer,
|
||||
&fakeLLM{resp: &domain.GenerateResponse{Content: `{"broken":true}`}},
|
||||
validator,
|
||||
repairer,
|
||||
admitter,
|
||||
)
|
||||
prepared, err := runner.PrepareExecution(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
Inputs: singleInputRef(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare execution: %v", err)
|
||||
}
|
||||
|
||||
result, err := runner.RunPrepared(context.Background(), prepared)
|
||||
if err != nil {
|
||||
t.Fatalf("run prepared: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(validator.plan.artifacts, []string{`{"broken":true}`, `{"repaired":true}`}) {
|
||||
t.Fatalf("prepared validation artifacts=%#v", validator.plan.artifacts)
|
||||
}
|
||||
if validator.directValidateCalls != 0 || repairer.calls != 1 {
|
||||
t.Fatalf("validation/repair calls=(direct=%d repair=%d), want (0, 1)", validator.directValidateCalls, repairer.calls)
|
||||
}
|
||||
if result.Validation.Status != domain.ValidationPassed || result.Validation.RepairAttempts != 1 {
|
||||
t.Fatalf("unexpected repaired validation result: %+v", result.Validation)
|
||||
}
|
||||
if admitter.releaseCalls != 1 {
|
||||
t.Fatalf("admission releases=%d, want 1", admitter.releaseCalls)
|
||||
}
|
||||
if reader.calls != 1 || renderer.calls != 1 {
|
||||
t.Fatalf("execution reopened preparation sources: artifact=%d render=%d", reader.calls, renderer.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerRunPreparedReleasesAdmissionAcrossExecutionErrors(t *testing.T) {
|
||||
generationFailure := errors.New("generation failed")
|
||||
validationFailure := errors.New("validation failed")
|
||||
repairFailure := errors.New("repair failed")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
generationErr error
|
||||
validation *recordingPreparedValidation
|
||||
repairer *fakeRepairer
|
||||
wantError error
|
||||
}{
|
||||
{
|
||||
name: "generation failure",
|
||||
generationErr: generationFailure,
|
||||
validation: &recordingPreparedValidation{},
|
||||
wantError: ErrLLMGenerate,
|
||||
},
|
||||
{
|
||||
name: "validation failure",
|
||||
validation: &recordingPreparedValidation{
|
||||
errs: []error{validationFailure},
|
||||
},
|
||||
wantError: ErrValidation,
|
||||
},
|
||||
{
|
||||
name: "repair failure",
|
||||
validation: &recordingPreparedValidation{
|
||||
results: []domain.ValidationResult{{
|
||||
Status: domain.ValidationFailed,
|
||||
Mode: domain.ValidationJSON,
|
||||
Errors: []string{"invalid"},
|
||||
IsValid: false,
|
||||
}},
|
||||
},
|
||||
repairer: &fakeRepairer{err: repairFailure},
|
||||
wantError: ErrValidation,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
def := promptDef(domain.FormatJSON, domain.ValidationJSON, 1)
|
||||
if test.repairer == nil {
|
||||
def.Validation.RepairAttempts = 0
|
||||
}
|
||||
validator := &recordingValidationPreparer{plan: test.validation}
|
||||
admitter := &fakeRunAdmitter{}
|
||||
runner := NewRunnerWithRepairer(
|
||||
&fakePromptRepo{def: def},
|
||||
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}},
|
||||
nil,
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
&fakeLLM{
|
||||
resp: &domain.GenerateResponse{Content: `{"value":true}`},
|
||||
err: test.generationErr,
|
||||
},
|
||||
validator,
|
||||
test.repairer,
|
||||
admitter,
|
||||
)
|
||||
prepared, err := runner.PrepareExecution(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
Inputs: singleInputRef(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare execution: %v", err)
|
||||
}
|
||||
|
||||
result, err := runner.RunPrepared(context.Background(), prepared)
|
||||
if result != nil || !errors.Is(err, test.wantError) {
|
||||
t.Fatalf("run prepared=(%+v, %v), want %v", result, err, test.wantError)
|
||||
}
|
||||
if len(admitter.backendIDs) != 1 || admitter.releaseCalls != 1 {
|
||||
t.Fatalf(
|
||||
"admission calls=%#v releases=%d, want one each",
|
||||
admitter.backendIDs,
|
||||
admitter.releaseCalls,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerPreparedExecutionOwnershipUseAndDiscard(t *testing.T) {
|
||||
newRunner := func() *Runner {
|
||||
return NewRunner(
|
||||
&fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)},
|
||||
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}},
|
||||
nil,
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
&fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}},
|
||||
&recordingValidationPreparer{plan: &recordingPreparedValidation{}},
|
||||
nil,
|
||||
)
|
||||
}
|
||||
request := domain.RunRequest{
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
Inputs: singleInputRef(),
|
||||
}
|
||||
|
||||
owner := newRunner()
|
||||
prepared, err := owner.PrepareExecution(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare execution: %v", err)
|
||||
}
|
||||
if _, err := newRunner().RunPrepared(context.Background(), prepared); !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatalf("foreign runner error=%v, want ErrInvalidRequest", err)
|
||||
}
|
||||
if _, err := owner.RunPrepared(context.Background(), prepared); err != nil {
|
||||
t.Fatalf("owner run prepared: %v", err)
|
||||
}
|
||||
if _, err := owner.RunPrepared(context.Background(), prepared); !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatalf("second owner run error=%v, want ErrInvalidRequest", err)
|
||||
}
|
||||
if prepared.Details() == nil {
|
||||
t.Fatal("details unavailable after execution")
|
||||
}
|
||||
|
||||
discarded, err := owner.PrepareExecution(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare discarded execution: %v", err)
|
||||
}
|
||||
discarded.Discard()
|
||||
discarded.Discard()
|
||||
if _, err := owner.RunPrepared(context.Background(), discarded); !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatalf("discarded execution error=%v, want ErrInvalidRequest", err)
|
||||
}
|
||||
if discarded.Details() == nil {
|
||||
t.Fatal("details unavailable after discard")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerPreparedExecutionWithoutValidatorSkipsValidation(t *testing.T) {
|
||||
runner := NewRunner(
|
||||
&fakePromptRepo{def: promptDef(domain.FormatJSON, domain.ValidationJSON, 0)},
|
||||
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}},
|
||||
nil,
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
&fakeLLM{resp: &domain.GenerateResponse{Content: `{}`}},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
prepared, err := runner.PrepareExecution(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
Inputs: singleInputRef(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare execution: %v", err)
|
||||
}
|
||||
|
||||
result, err := runner.RunPrepared(context.Background(), prepared)
|
||||
if err != nil {
|
||||
t.Fatalf("run prepared: %v", err)
|
||||
}
|
||||
if result.Validation.Status != domain.ValidationSkipped || !result.Validation.IsValid {
|
||||
t.Fatalf("unexpected no-validator result: %+v", result.Validation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerPrepareExecutionRequiresValidationPreparer(t *testing.T) {
|
||||
reader := defaultArtifactReader()
|
||||
runner := NewRunner(
|
||||
&fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationBasic, 0)},
|
||||
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}},
|
||||
nil,
|
||||
reader,
|
||||
defaultRenderer(),
|
||||
&fakeLLM{forbid: true},
|
||||
&fakeValidator{},
|
||||
nil,
|
||||
)
|
||||
|
||||
prepared, err := runner.PrepareExecution(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
Inputs: singleInputRef(),
|
||||
})
|
||||
if prepared != nil || !errors.Is(err, ErrValidation) {
|
||||
t.Fatalf("prepare execution=(%+v, %v), want ErrValidation", prepared, err)
|
||||
}
|
||||
if reader.calls != 0 {
|
||||
t.Fatalf("unsupported validator allowed completion, artifact calls=%d", reader.calls)
|
||||
}
|
||||
}
|
||||
@@ -131,29 +131,46 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if r.admitter != nil {
|
||||
release, admitErr := r.admitter.Admit(ctx, state.effectiveModel.BackendID)
|
||||
if admitErr != nil {
|
||||
if errors.Is(admitErr, capacity.ErrCapacityExceeded) {
|
||||
return nil, fmt.Errorf(
|
||||
"backend %q admission: %w",
|
||||
state.effectiveModel.BackendID,
|
||||
admitErr,
|
||||
)
|
||||
}
|
||||
return nil, admitErr
|
||||
release, err := r.admitRun(ctx, state.effectiveModel.BackendID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
}
|
||||
|
||||
prepared, err := r.completePreparation(ctx, req, state)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
directAPIKey := state.effectiveModel.APIKey
|
||||
|
||||
return r.executePreparedRun(ctx, prepared, directAPIKey, runID, start, func(
|
||||
ctx context.Context,
|
||||
artifact *domain.Artifact,
|
||||
attemptsUsed int,
|
||||
) (domain.ValidationResult, error) {
|
||||
return r.validateOutput(ctx, artifact, prepared.OutputContract, attemptsUsed)
|
||||
})
|
||||
}
|
||||
|
||||
type preparedValidationFunc func(
|
||||
context.Context,
|
||||
*domain.Artifact,
|
||||
int,
|
||||
) (domain.ValidationResult, error)
|
||||
|
||||
func (r *Runner) executePreparedRun(
|
||||
ctx context.Context,
|
||||
prepared *domain.PreparedRun,
|
||||
directAPIKey string,
|
||||
runID string,
|
||||
start time.Time,
|
||||
validateArtifact preparedValidationFunc,
|
||||
) (*domain.RunResult, error) {
|
||||
executionTarget := prepared.EffectiveModelParams
|
||||
executionTarget.APIKey = directAPIKey
|
||||
genResp, err := r.llm.Generate(ctx, domain.GenerateRequest{
|
||||
Prompt: domain.RenderedPrompt{SessionID: prepared.SessionID, Messages: prepared.Messages},
|
||||
Target: prepared.EffectiveModelParams,
|
||||
Target: executionTarget,
|
||||
TargetPresence: prepared.TargetPresence,
|
||||
StructuredOutput: prepared.StructuredOutput,
|
||||
})
|
||||
@@ -165,7 +182,7 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes
|
||||
}
|
||||
|
||||
outputArtifact := buildOutputArtifact(genResp.Content, prepared.OutputContract.Format)
|
||||
validationResult, err := r.validateOutput(ctx, &outputArtifact, prepared.OutputContract, 0)
|
||||
validationResult, err := validateArtifact(ctx, &outputArtifact, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrValidation, err)
|
||||
}
|
||||
@@ -179,7 +196,7 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes
|
||||
PreviousOutput: genResp.Content,
|
||||
ValidationErrors: validationResult.Errors,
|
||||
SessionID: prepared.SessionID,
|
||||
Target: prepared.EffectiveModelParams,
|
||||
Target: executionTarget,
|
||||
StructuredOutput: prepared.StructuredOutput,
|
||||
Attempt: attemptsUsed,
|
||||
MaxAttempts: prepared.OutputContract.RepairAttempts,
|
||||
@@ -195,7 +212,7 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes
|
||||
genResp = repairResp
|
||||
outputArtifact = buildOutputArtifact(genResp.Content, prepared.OutputContract.Format)
|
||||
|
||||
validationResult, err = r.validateOutput(ctx, &outputArtifact, prepared.OutputContract, attemptsUsed)
|
||||
validationResult, err = validateArtifact(ctx, &outputArtifact, attemptsUsed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrValidation, err)
|
||||
}
|
||||
@@ -203,6 +220,7 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes
|
||||
}
|
||||
|
||||
end := time.Now().UTC()
|
||||
executionTarget.APIKey = ""
|
||||
|
||||
return &domain.RunResult{
|
||||
RunID: runID,
|
||||
@@ -218,7 +236,7 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes
|
||||
SelectedBackendID: prepared.SelectedBackendID,
|
||||
ModelName: prepared.EffectiveModelParams.Model,
|
||||
Endpoint: prepared.EffectiveModelParams.Endpoint,
|
||||
EffectiveModelParams: prepared.EffectiveModelParams,
|
||||
EffectiveModelParams: executionTarget,
|
||||
InputHashes: prepared.InputHashes,
|
||||
Usage: genResp.Usage,
|
||||
StartTime: start,
|
||||
@@ -324,7 +342,15 @@ func (r *Runner) completePreparation(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.completePreparationWithStructuredOutput(ctx, req, state, structuredOutput)
|
||||
}
|
||||
|
||||
func (r *Runner) completePreparationWithStructuredOutput(
|
||||
ctx context.Context,
|
||||
req domain.RunRequest,
|
||||
state *preparationState,
|
||||
structuredOutput *domain.StructuredOutputSpec,
|
||||
) (*domain.PreparedRun, error) {
|
||||
resolvedInputs := make(map[string]*domain.Artifact, len(req.Inputs))
|
||||
inputHashes := make(map[string]string, len(req.Inputs))
|
||||
for name, ref := range req.Inputs {
|
||||
@@ -354,13 +380,15 @@ func (r *Runner) completePreparation(
|
||||
}
|
||||
|
||||
end := time.Now().UTC()
|
||||
effectiveModel := state.effectiveModel
|
||||
effectiveModel.APIKey = ""
|
||||
return &domain.PreparedRun{
|
||||
PromptID: state.definition.ID,
|
||||
PromptVersion: state.definition.Version,
|
||||
PromptHash: state.promptDefinitionHash,
|
||||
SelectedProfileID: state.selectedProfileID,
|
||||
SelectedBackendID: state.effectiveModel.BackendID,
|
||||
EffectiveModelParams: state.effectiveModel,
|
||||
EffectiveModelParams: effectiveModel,
|
||||
TargetPresence: state.targetPresence,
|
||||
OutputContract: state.effectiveContract,
|
||||
StructuredOutput: structuredOutput,
|
||||
@@ -374,6 +402,20 @@ func (r *Runner) completePreparation(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Runner) admitRun(ctx context.Context, backendID string) (func(), error) {
|
||||
if r.admitter == nil {
|
||||
return func() {}, nil
|
||||
}
|
||||
release, err := r.admitter.Admit(ctx, backendID)
|
||||
if err != nil {
|
||||
if errors.Is(err, capacity.ErrCapacityExceeded) {
|
||||
return nil, fmt.Errorf("backend %q admission: %w", backendID, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func (r *Runner) resolveStructuredOutput(ctx context.Context, def *domain.PromptDefinition, contract domain.OutputContract) (*domain.StructuredOutputSpec, error) {
|
||||
if contract.ValidationMode != domain.ValidationJSONSchema {
|
||||
return nil, nil
|
||||
@@ -389,14 +431,18 @@ func (r *Runner) resolveStructuredOutput(ctx context.Context, def *domain.Prompt
|
||||
return nil, fmt.Errorf("%w: failed to load json schema for structured output: %v", ErrValidation, err)
|
||||
}
|
||||
|
||||
return structuredOutputSpec(def, schemaDoc), nil
|
||||
}
|
||||
|
||||
func structuredOutputSpec(def *domain.PromptDefinition, schemaDocument any) *domain.StructuredOutputSpec {
|
||||
return &domain.StructuredOutputSpec{
|
||||
Type: domain.StructuredOutputJSONSchema,
|
||||
JSONSchema: &domain.StructuredOutputJSONSpec{
|
||||
Name: deriveStructuredSchemaName(def.ID, def.Version),
|
||||
Strict: true,
|
||||
Schema: schemaDoc,
|
||||
Schema: schemaDocument,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func deriveStructuredSchemaName(promptID string, promptVersion string) string {
|
||||
|
||||
@@ -1757,7 +1757,7 @@ func TestRunnerRunDirectAPIKeyBypassesMissingEnvAndReachesLLM(t *testing.T) {
|
||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), llmClient, nil, nil)
|
||||
|
||||
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
result, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
APIKey: directKey,
|
||||
@@ -1769,6 +1769,9 @@ func TestRunnerRunDirectAPIKeyBypassesMissingEnvAndReachesLLM(t *testing.T) {
|
||||
if llmClient.lastReq.Target.APIKey != directKey {
|
||||
t.Fatalf("expected direct API key to reach LLM request")
|
||||
}
|
||||
if result.EffectiveModelParams.APIKey != "" {
|
||||
t.Fatal("run result retained direct API key")
|
||||
}
|
||||
if llmClient.lastReq.Target.APIKeyEnv != "PROMPTKIT_MISSING_KEY" {
|
||||
t.Fatalf("expected api_key_env name to remain on target, got %q", llmClient.lastReq.Target.APIKeyEnv)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,101 @@ func (v *FSValidator) Validate(ctx context.Context, artifact *domain.Artifact, c
|
||||
return validateArtifact(ctx, artifact, contract, v.validateJSONSchema)
|
||||
}
|
||||
|
||||
type preparedValidation struct {
|
||||
contract domain.OutputContract
|
||||
schemaDocument any
|
||||
schema *jsonschema.Schema
|
||||
}
|
||||
|
||||
func (p *preparedValidation) Validate(ctx context.Context, artifact *domain.Artifact) (domain.ValidationResult, error) {
|
||||
return validateArtifact(ctx, artifact, p.contract, p.validateJSONSchema)
|
||||
}
|
||||
|
||||
func (p *preparedValidation) SchemaDocument() any {
|
||||
return p.schemaDocument
|
||||
}
|
||||
|
||||
func (p *preparedValidation) validateJSONSchema(instance any, _ string) ([]string, error) {
|
||||
if p.schema == nil {
|
||||
return nil, errors.New("prepared JSON schema is unavailable")
|
||||
}
|
||||
if err := p.schema.Validate(instance); err != nil {
|
||||
return []string{fmt.Sprintf("json schema validation failed: %v", err)}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (v *StandardValidator) PrepareValidation(ctx context.Context, contract domain.OutputContract) (PreparedValidation, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prepared := &preparedValidation{contract: contract}
|
||||
if contract.ValidationMode != domain.ValidationJSONSchema {
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
resolvedSchemaPath, err := v.resolveSchemaPath(contract.SchemaPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
schemaDocument, err := loadJSONSchemaFile(resolvedSchemaPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile JSON schema %q: %w", resolvedSchemaPath, err)
|
||||
}
|
||||
schemaRoot, err := v.schemaRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
compiler := newSchemaCompiler(standardSchemaLoader{root: schemaRoot})
|
||||
if err := compiler.AddResource(resolvedSchemaPath, schemaDocument); err != nil {
|
||||
return nil, fmt.Errorf("failed to register JSON schema %q: %w", resolvedSchemaPath, err)
|
||||
}
|
||||
schema, err := compiler.Compile(resolvedSchemaPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile JSON schema %q: %w", resolvedSchemaPath, err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prepared.schemaDocument = schemaDocument
|
||||
prepared.schema = schema
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
func (v *FSValidator) PrepareValidation(ctx context.Context, contract domain.OutputContract) (PreparedValidation, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prepared := &preparedValidation{contract: contract}
|
||||
if contract.ValidationMode != domain.ValidationJSONSchema {
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
schemaName, schemaDocument, err := v.loadSchemaDocument(contract.SchemaPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resourceURL := fsSchemaResourceURL(schemaName)
|
||||
compiler := newSchemaCompiler(fsSchemaLoader{fsys: v.fsys, root: filecatalog.CleanFSRoot(v.root)})
|
||||
if err := compiler.AddResource(resourceURL, schemaDocument); err != nil {
|
||||
return nil, fmt.Errorf("failed to register JSON schema %q: %w", schemaName, err)
|
||||
}
|
||||
schema, err := compiler.Compile(resourceURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile JSON schema %q: %w", schemaName, err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prepared.schemaDocument = schemaDocument
|
||||
prepared.schema = schema
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
type schemaValidatorFunc func(instance any, schemaPath string) ([]string, error)
|
||||
|
||||
func validateArtifact(ctx context.Context, artifact *domain.Artifact, contract domain.OutputContract, validateSchema schemaValidatorFunc) (domain.ValidationResult, error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -120,6 +121,68 @@ func TestStandardValidatorJSONSchemaSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardValidatorPreparedSchemaSurvivesSourceRemoval(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
rootSchema := []byte(`{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "original root",
|
||||
"type": "object",
|
||||
"required": ["value"],
|
||||
"properties": {
|
||||
"value": {"$ref": "value.json"}
|
||||
}
|
||||
}`)
|
||||
rootPath := filepath.Join(tmp, "schema.json")
|
||||
referencePath := filepath.Join(tmp, "value.json")
|
||||
if err := os.WriteFile(rootPath, rootSchema, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(referencePath, []byte(`{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "integer",
|
||||
"minimum": 2
|
||||
}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
validator := NewStandardValidator(tmp)
|
||||
preparer, ok := validator.(ValidationPreparer)
|
||||
if !ok {
|
||||
t.Fatal("standard validator does not support validation preparation")
|
||||
}
|
||||
prepared, err := preparer.PrepareValidation(context.Background(), domain.OutputContract{
|
||||
ValidationMode: domain.ValidationJSONSchema,
|
||||
SchemaPath: "schema.json",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare validation: %v", err)
|
||||
}
|
||||
assertSchemaDocument(t, prepared.SchemaDocument(), rootSchema)
|
||||
|
||||
if err := os.Remove(rootPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(referencePath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
valid, err := prepared.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"value":3}`)})
|
||||
if err != nil {
|
||||
t.Fatalf("validate prepared artifact: %v", err)
|
||||
}
|
||||
if valid.Status != domain.ValidationPassed || !valid.IsValid {
|
||||
t.Fatalf("expected passed/valid, got status=%q valid=%v errors=%v", valid.Status, valid.IsValid, valid.Errors)
|
||||
}
|
||||
|
||||
invalid, err := prepared.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"value":"changed"}`)})
|
||||
if err != nil {
|
||||
t.Fatalf("validate prepared artifact: %v", err)
|
||||
}
|
||||
if invalid.Status != domain.ValidationFailed || invalid.IsValid {
|
||||
t.Fatalf("expected failed/invalid, got status=%q valid=%v", invalid.Status, invalid.IsValid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardValidatorJSONSchemaNestedSchemaPathSuccess(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
nestedDir := filepath.Join(tmp, "dnd")
|
||||
@@ -295,6 +358,64 @@ func TestFSValidatorJSONSchemaSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSValidatorPreparedSchemaSurvivesSourceMutation(t *testing.T) {
|
||||
rootSchema := []byte(`{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "original root",
|
||||
"type": "object",
|
||||
"required": ["value"],
|
||||
"properties": {
|
||||
"value": {"$ref": "value.json"}
|
||||
}
|
||||
}`)
|
||||
fsys := fstest.MapFS{
|
||||
"schema.json": &fstest.MapFile{Data: rootSchema},
|
||||
"value.json": &fstest.MapFile{Data: []byte(`{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "integer",
|
||||
"minimum": 2
|
||||
}`)},
|
||||
}
|
||||
validator := NewFSValidator(fsys, ".")
|
||||
preparer, ok := validator.(ValidationPreparer)
|
||||
if !ok {
|
||||
t.Fatal("filesystem validator does not support validation preparation")
|
||||
}
|
||||
prepared, err := preparer.PrepareValidation(context.Background(), domain.OutputContract{
|
||||
ValidationMode: domain.ValidationJSONSchema,
|
||||
SchemaPath: "schema.json",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare validation: %v", err)
|
||||
}
|
||||
assertSchemaDocument(t, prepared.SchemaDocument(), rootSchema)
|
||||
|
||||
fsys["schema.json"] = &fstest.MapFile{Data: []byte(`{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "string"
|
||||
}`)}
|
||||
fsys["value.json"] = &fstest.MapFile{Data: []byte(`{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "string"
|
||||
}`)}
|
||||
|
||||
valid, err := prepared.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"value":3}`)})
|
||||
if err != nil {
|
||||
t.Fatalf("validate prepared artifact: %v", err)
|
||||
}
|
||||
if valid.Status != domain.ValidationPassed || !valid.IsValid {
|
||||
t.Fatalf("expected passed/valid, got status=%q valid=%v errors=%v", valid.Status, valid.IsValid, valid.Errors)
|
||||
}
|
||||
|
||||
invalid, err := prepared.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"value":"changed"}`)})
|
||||
if err != nil {
|
||||
t.Fatalf("validate prepared artifact: %v", err)
|
||||
}
|
||||
if invalid.Status != domain.ValidationFailed || invalid.IsValid {
|
||||
t.Fatalf("expected failed/invalid, got status=%q valid=%v", invalid.Status, invalid.IsValid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSValidatorJSONSchemaRegistrationError(t *testing.T) {
|
||||
v := NewFSValidator(fstest.MapFS{
|
||||
"schemas/%zz.json": &fstest.MapFile{Data: []byte(`{"type":"object"}`)},
|
||||
@@ -548,3 +669,15 @@ func TestJSONSchemaDialectIsDraft2020(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSchemaDocument(t *testing.T, got any, expectedJSON []byte) {
|
||||
t.Helper()
|
||||
|
||||
var expected any
|
||||
if err := json.Unmarshal(expectedJSON, &expected); err != nil {
|
||||
t.Fatalf("decode expected schema document: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, expected) {
|
||||
t.Fatalf("schema document mismatch:\n got: %#v\nwant: %#v", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package validate
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
@@ -10,6 +11,20 @@ type Validator interface {
|
||||
Validate(ctx context.Context, artifact *domain.Artifact, contract domain.OutputContract) (domain.ValidationResult, error)
|
||||
}
|
||||
|
||||
// PreparedValidation validates artifacts against one frozen output contract.
|
||||
type PreparedValidation interface {
|
||||
Validate(ctx context.Context, artifact *domain.Artifact) (domain.ValidationResult, error)
|
||||
// SchemaDocument returns the root JSON Schema document used for provider
|
||||
// structured output, or nil for non-schema modes. Returned internal
|
||||
// immutable state must not be mutated.
|
||||
SchemaDocument() any
|
||||
}
|
||||
|
||||
// ValidationPreparer freezes validation resources for one output contract.
|
||||
type ValidationPreparer interface {
|
||||
PrepareValidation(ctx context.Context, contract domain.OutputContract) (PreparedValidation, error)
|
||||
}
|
||||
|
||||
// SchemaDocumentLoader loads JSON schema documents using validator path semantics.
|
||||
type SchemaDocumentLoader interface {
|
||||
LoadSchemaDocument(ctx context.Context, schemaPath string) (any, error)
|
||||
|
||||
55
prepared_execution.go
Normal file
55
prepared_execution.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package promptkit
|
||||
|
||||
import "gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
||||
|
||||
const preparedExecutionString = "promptkit.PreparedExecution{opaque}"
|
||||
|
||||
// PreparedExecution is an opaque, in-process handle for one completely
|
||||
// prepared execution. A handle is bound to the [Engine] that created it and
|
||||
// permits one [Engine.RunPrepared] invocation.
|
||||
//
|
||||
// PreparedExecution contains no supported serializable state and cannot be
|
||||
// used as a restartable job. Copying the value preserves the same shared
|
||||
// lifecycle; it does not create another execution attempt.
|
||||
type PreparedExecution struct {
|
||||
internal *usecase.PreparedExecution
|
||||
}
|
||||
|
||||
// Details returns a fresh caller-owned, credential-redacted copy of the
|
||||
// prepared request details. Mutating the result cannot affect execution or a
|
||||
// later Details call. Details remains available after execution or discard.
|
||||
//
|
||||
// A nil receiver or zero-value PreparedExecution returns a zero [PreparedRun].
|
||||
func (p *PreparedExecution) Details() PreparedRun {
|
||||
if p == nil || p.internal == nil {
|
||||
return PreparedRun{}
|
||||
}
|
||||
details := fromDomainPreparedRun(p.internal.Details())
|
||||
if details == nil {
|
||||
return PreparedRun{}
|
||||
}
|
||||
return *details
|
||||
}
|
||||
|
||||
// Discard invalidates an unclaimed handle and drops Promptkit's references to
|
||||
// its execution-only state. Discard is nil-safe and idempotent. It does not
|
||||
// cancel an execution that has already claimed the handle; use the
|
||||
// [Engine.RunPrepared] context for cancellation.
|
||||
func (p *PreparedExecution) Discard() {
|
||||
if p == nil || p.internal == nil {
|
||||
return
|
||||
}
|
||||
p.internal.Discard()
|
||||
}
|
||||
|
||||
// String returns a constant representation that exposes no retained request,
|
||||
// rendered content, or credential data.
|
||||
func (p *PreparedExecution) String() string {
|
||||
return preparedExecutionString
|
||||
}
|
||||
|
||||
// GoString returns a constant Go-syntax representation that exposes no
|
||||
// retained request, rendered content, or credential data.
|
||||
func (p *PreparedExecution) GoString() string {
|
||||
return preparedExecutionString
|
||||
}
|
||||
768
prepared_execution_contract_test.go
Normal file
768
prepared_execution_contract_test.go
Normal file
@@ -0,0 +1,768 @@
|
||||
package promptkit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
)
|
||||
|
||||
func TestPreparedExecutionFreezesSourcesAndReturnsIndependentDetails(t *testing.T) {
|
||||
promptSource := preparedPromptSource("original")
|
||||
profileSource := preparedProfileSource("original-model")
|
||||
schemaSource := preparedSchemaSource()
|
||||
reader := &mutablePreparedArtifactReader{
|
||||
body: "original artifact",
|
||||
hash: "original-input-hash",
|
||||
}
|
||||
client := &preparedRecordingClient{
|
||||
response: &promptkit.GenerateResponse{Content: `{"value":3}`},
|
||||
}
|
||||
engine, err := promptkit.NewEngine(
|
||||
promptkit.Config{},
|
||||
promptkit.WithPromptFS(promptSource, "."),
|
||||
promptkit.WithProfileFS(profileSource, "."),
|
||||
promptkit.WithSchemaFS(schemaSource, "."),
|
||||
promptkit.WithArtifactReader(reader),
|
||||
promptkit.WithLLMClient(client),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("construct engine: %v", err)
|
||||
}
|
||||
|
||||
temperature := 0.25
|
||||
extraParams := map[string]any{
|
||||
"nested": map[string]any{"source": "original"},
|
||||
}
|
||||
request := promptkit.RunRequest{
|
||||
PromptID: "prepared",
|
||||
Inputs: map[string]promptkit.ArtifactRef{
|
||||
"input": promptkit.Inline("original request input"),
|
||||
},
|
||||
Vars: map[string]string{"label": "original variable"},
|
||||
Execution: &promptkit.ExecutionTargetOverride{
|
||||
Temperature: &temperature,
|
||||
ExtraParams: extraParams,
|
||||
},
|
||||
}
|
||||
preparationContext, cancelPreparation := context.WithCancel(context.Background())
|
||||
prepared, err := engine.PrepareExecution(preparationContext, request)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare execution: %v", err)
|
||||
}
|
||||
cancelPreparation()
|
||||
|
||||
request.PromptID = "changed"
|
||||
request.Inputs["input"] = promptkit.Inline("changed request input")
|
||||
request.Vars["label"] = "changed variable"
|
||||
temperature = 1.5
|
||||
extraParams["nested"].(map[string]any)["source"] = "changed"
|
||||
promptSource["prompt.yaml"] = &fstest.MapFile{Data: []byte(`id: changed`)}
|
||||
profileSource["profile.yaml"] = &fstest.MapFile{Data: []byte(`id: changed`)}
|
||||
schemaSource["schema.json"] = &fstest.MapFile{Data: []byte(`{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "changed root",
|
||||
"type": "string"
|
||||
}`)}
|
||||
schemaSource["value.json"] = &fstest.MapFile{Data: []byte(`{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "string"
|
||||
}`)}
|
||||
reader.set("changed artifact", "changed-input-hash")
|
||||
|
||||
first := prepared.Details()
|
||||
first.Messages[0].Content = "changed details"
|
||||
first.InputHashes["input"] = "changed-details-hash"
|
||||
first.EffectiveModelParams.ExtraParams["nested"].(map[string]any)["source"] = "changed details"
|
||||
first.StructuredOutput.JSONSchema.Schema.(map[string]any)["title"] = "changed details"
|
||||
|
||||
second := prepared.Details()
|
||||
if second.Messages[0].Content != "Input=original artifact Label=original variable" {
|
||||
t.Fatalf("details message changed: %q", second.Messages[0].Content)
|
||||
}
|
||||
if second.InputHashes["input"] != "original-input-hash" {
|
||||
t.Fatalf("details input hash changed: %q", second.InputHashes["input"])
|
||||
}
|
||||
if second.EffectiveModelParams.Model != "original-model" ||
|
||||
second.EffectiveModelParams.Temperature != 0.25 ||
|
||||
second.EffectiveModelParams.ExtraParams["nested"].(map[string]any)["source"] != "original" {
|
||||
t.Fatalf("details target changed: %+v", second.EffectiveModelParams)
|
||||
}
|
||||
schema := second.StructuredOutput.JSONSchema.Schema.(map[string]any)
|
||||
if schema["title"] != "original root" {
|
||||
t.Fatalf("details schema changed: %#v", schema)
|
||||
}
|
||||
|
||||
result, err := engine.RunPrepared(context.Background(), prepared)
|
||||
if err != nil {
|
||||
t.Fatalf("run prepared after preparation-context cancellation: %v", err)
|
||||
}
|
||||
if result.Validation.Status != promptkit.ValidationPassed || !result.Validation.IsValid {
|
||||
t.Fatalf("frozen schema did not validate original output: %+v", result.Validation)
|
||||
}
|
||||
if reader.callCount() != 1 {
|
||||
t.Fatalf("execution reopened artifact source: calls=%d", reader.callCount())
|
||||
}
|
||||
|
||||
requests := client.snapshot()
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("generation calls=%d, want 1", len(requests))
|
||||
}
|
||||
generated := requests[0]
|
||||
if generated.Prompt.Messages[0].Content != second.Messages[0].Content ||
|
||||
generated.Target.Model != second.EffectiveModelParams.Model ||
|
||||
!reflect.DeepEqual(generated.Target.ExtraParams, second.EffectiveModelParams.ExtraParams) ||
|
||||
!reflect.DeepEqual(generated.StructuredOutput, second.StructuredOutput) {
|
||||
t.Fatalf("generation did not use frozen details:\nrequest=%+v\ndetails=%+v", generated, second)
|
||||
}
|
||||
if result.PromptID != second.PromptID ||
|
||||
result.PromptVersion != second.PromptVersion ||
|
||||
result.PromptHash != second.PromptHash ||
|
||||
result.SessionID != second.SessionID ||
|
||||
result.RenderedPromptHash != second.RenderedPromptHash ||
|
||||
result.SelectedProfileID != second.SelectedProfileID ||
|
||||
result.SelectedBackendID != second.SelectedBackendID ||
|
||||
!reflect.DeepEqual(result.EffectiveModelParams, second.EffectiveModelParams) ||
|
||||
!reflect.DeepEqual(result.InputHashes, second.InputHashes) {
|
||||
t.Fatalf("result provenance does not match details:\nresult=%+v\ndetails=%+v", result, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedExecutionLifecycleAndEngineBinding(t *testing.T) {
|
||||
ownerClient := &preparedRecordingClient{
|
||||
response: &promptkit.GenerateResponse{Content: "ok"},
|
||||
}
|
||||
owner := newPreparedContractEngine(t, ownerClient, "owner content")
|
||||
foreign := newPreparedContractEngine(t, &preparedRecordingClient{
|
||||
response: &promptkit.GenerateResponse{Content: "unexpected"},
|
||||
}, "foreign content")
|
||||
|
||||
prepared, err := owner.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prepared"})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare execution: %v", err)
|
||||
}
|
||||
copied := *prepared
|
||||
|
||||
var nilEngine *promptkit.Engine
|
||||
if result, err := nilEngine.RunPrepared(context.Background(), prepared); result != nil ||
|
||||
!errors.Is(err, promptkit.ErrInvalidConfig) {
|
||||
t.Fatalf("nil engine result=(%+v, %v), want ErrInvalidConfig", result, err)
|
||||
}
|
||||
if result, err := foreign.RunPrepared(context.Background(), prepared); result != nil ||
|
||||
!errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||
t.Fatalf("foreign engine result=(%+v, %v), want ErrInvalidRequest", result, err)
|
||||
}
|
||||
if result, err := owner.RunPrepared(context.Background(), nil); result != nil ||
|
||||
!errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||
t.Fatalf("nil handle result=(%+v, %v), want ErrInvalidRequest", result, err)
|
||||
}
|
||||
if result, err := owner.RunPrepared(context.Background(), &promptkit.PreparedExecution{}); result != nil ||
|
||||
!errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||
t.Fatalf("zero handle result=(%+v, %v), want ErrInvalidRequest", result, err)
|
||||
}
|
||||
|
||||
result, err := owner.RunPrepared(context.Background(), &copied)
|
||||
if err != nil || result == nil {
|
||||
t.Fatalf("owner run prepared=(%+v, %v), want success", result, err)
|
||||
}
|
||||
for name, handle := range map[string]*promptkit.PreparedExecution{
|
||||
"original": prepared,
|
||||
"copy": &copied,
|
||||
} {
|
||||
if result, err := owner.RunPrepared(context.Background(), handle); result != nil ||
|
||||
!errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||
t.Fatalf("%s reused handle result=(%+v, %v), want ErrInvalidRequest", name, result, err)
|
||||
}
|
||||
if handle.Details().PromptID != "prepared" {
|
||||
t.Fatalf("%s details unavailable after execution", name)
|
||||
}
|
||||
}
|
||||
if len(ownerClient.snapshot()) != 1 {
|
||||
t.Fatalf("owner generation calls=%d, want 1", len(ownerClient.snapshot()))
|
||||
}
|
||||
|
||||
collaboratorFailure := errors.New("prepared collaborator failure")
|
||||
failingClient := &preparedRecordingClient{err: collaboratorFailure}
|
||||
failingEngine := newPreparedContractEngine(t, failingClient, "failure content")
|
||||
failing, err := failingEngine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prepared"})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare failing execution: %v", err)
|
||||
}
|
||||
if result, err := failingEngine.RunPrepared(context.Background(), failing); result != nil ||
|
||||
!errors.Is(err, promptkit.ErrLLMGenerate) ||
|
||||
!errors.Is(err, collaboratorFailure) {
|
||||
t.Fatalf("generation failure result=(%+v, %v), want public and collaborator identities", result, err)
|
||||
}
|
||||
if result, err := failingEngine.RunPrepared(context.Background(), failing); result != nil ||
|
||||
!errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||
t.Fatalf("failed execution was reusable: result=(%+v, %v)", result, err)
|
||||
}
|
||||
|
||||
cancellationRelease := make(chan struct{})
|
||||
cancellationStarted := make(chan struct{}, 1)
|
||||
cancelingEngine := newPreparedContractEngine(t, &preparedRecordingClient{
|
||||
response: &promptkit.GenerateResponse{Content: "unexpected"},
|
||||
started: cancellationStarted,
|
||||
release: cancellationRelease,
|
||||
}, "cancellation content")
|
||||
canceling, err := cancelingEngine.PrepareExecution(
|
||||
context.Background(),
|
||||
promptkit.RunRequest{PromptID: "prepared"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare canceled execution: %v", err)
|
||||
}
|
||||
executionContext, cancelExecution := context.WithCancel(context.Background())
|
||||
type canceledOutcome struct {
|
||||
result *promptkit.RunResult
|
||||
err error
|
||||
}
|
||||
canceledResult := make(chan canceledOutcome, 1)
|
||||
go func() {
|
||||
result, runErr := cancelingEngine.RunPrepared(executionContext, canceling)
|
||||
canceledResult <- canceledOutcome{result: result, err: runErr}
|
||||
}()
|
||||
select {
|
||||
case <-cancellationStarted:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for cancelable generation")
|
||||
}
|
||||
cancelExecution()
|
||||
select {
|
||||
case outcome := <-canceledResult:
|
||||
if outcome.result != nil ||
|
||||
!errors.Is(outcome.err, promptkit.ErrLLMGenerate) ||
|
||||
!errors.Is(outcome.err, context.Canceled) {
|
||||
t.Fatalf(
|
||||
"canceled execution=(%+v, %v), want generation and context identities",
|
||||
outcome.result,
|
||||
outcome.err,
|
||||
)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for canceled execution")
|
||||
}
|
||||
if result, err := cancelingEngine.RunPrepared(context.Background(), canceling); result != nil ||
|
||||
!errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||
t.Fatalf("canceled execution was reusable: result=(%+v, %v)", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedExecutionConcurrentClaimAllowsOneGeneration(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
client := &preparedRecordingClient{
|
||||
response: &promptkit.GenerateResponse{Content: "ok"},
|
||||
started: make(chan struct{}, 1),
|
||||
release: release,
|
||||
}
|
||||
engine := newPreparedContractEngine(t, client, "concurrent content")
|
||||
prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prepared"})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare execution: %v", err)
|
||||
}
|
||||
|
||||
type outcome struct {
|
||||
result *promptkit.RunResult
|
||||
err error
|
||||
}
|
||||
outcomes := make(chan outcome, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
go func() {
|
||||
result, runErr := engine.RunPrepared(context.Background(), prepared)
|
||||
outcomes <- outcome{result: result, err: runErr}
|
||||
}()
|
||||
}
|
||||
|
||||
select {
|
||||
case <-client.started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for generation")
|
||||
}
|
||||
select {
|
||||
case loser := <-outcomes:
|
||||
if loser.result != nil || !errors.Is(loser.err, promptkit.ErrInvalidRequest) {
|
||||
t.Fatalf("concurrent loser=(%+v, %v), want ErrInvalidRequest", loser.result, loser.err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for rejected concurrent claim")
|
||||
}
|
||||
|
||||
close(release)
|
||||
select {
|
||||
case winner := <-outcomes:
|
||||
if winner.err != nil || winner.result == nil {
|
||||
t.Fatalf("concurrent winner=(%+v, %v), want success", winner.result, winner.err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for successful concurrent claim")
|
||||
}
|
||||
if len(client.snapshot()) != 1 {
|
||||
t.Fatalf("generation calls=%d, want 1", len(client.snapshot()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedExecutionRunAndDiscardRaceHasOneWinner(t *testing.T) {
|
||||
const attempts = 32
|
||||
|
||||
for i := 0; i < attempts; i++ {
|
||||
client := &preparedRecordingClient{
|
||||
response: &promptkit.GenerateResponse{Content: "ok"},
|
||||
}
|
||||
engine := newPreparedContractEngine(t, client, "race content")
|
||||
prepared, err := engine.PrepareExecution(
|
||||
context.Background(),
|
||||
promptkit.RunRequest{PromptID: "prepared"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("attempt %d prepare execution: %v", i, err)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
type outcome struct {
|
||||
result *promptkit.RunResult
|
||||
err error
|
||||
}
|
||||
runOutcome := make(chan outcome, 1)
|
||||
discardDone := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
<-start
|
||||
result, runErr := engine.RunPrepared(context.Background(), prepared)
|
||||
runOutcome <- outcome{result: result, err: runErr}
|
||||
}()
|
||||
go func() {
|
||||
<-start
|
||||
prepared.Discard()
|
||||
close(discardDone)
|
||||
}()
|
||||
|
||||
close(start)
|
||||
runResult := <-runOutcome
|
||||
<-discardDone
|
||||
calls := len(client.snapshot())
|
||||
switch {
|
||||
case runResult.err == nil:
|
||||
if runResult.result == nil || calls != 1 {
|
||||
t.Fatalf(
|
||||
"attempt %d run won with outcome=(%+v, %v), generation calls=%d",
|
||||
i,
|
||||
runResult.result,
|
||||
runResult.err,
|
||||
calls,
|
||||
)
|
||||
}
|
||||
case errors.Is(runResult.err, promptkit.ErrInvalidRequest):
|
||||
if runResult.result != nil || calls != 0 {
|
||||
t.Fatalf(
|
||||
"attempt %d discard won with outcome=(%+v, %v), generation calls=%d",
|
||||
i,
|
||||
runResult.result,
|
||||
runResult.err,
|
||||
calls,
|
||||
)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("attempt %d unexpected run outcome=(%+v, %v)", i, runResult.result, runResult.err)
|
||||
}
|
||||
if prepared.Details().PromptID != "prepared" {
|
||||
t.Fatalf("attempt %d details unavailable after race", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedExecutionDiscardAndFormattingDoNotExposePrivateState(t *testing.T) {
|
||||
const (
|
||||
directCredential = "pk-test-direct-credential-41f7"
|
||||
renderedContent = "rendered-content-sentinel-98d2"
|
||||
)
|
||||
client := &preparedRecordingClient{
|
||||
response: &promptkit.GenerateResponse{Content: "generated output"},
|
||||
}
|
||||
engine, err := promptkit.NewEngine(
|
||||
promptkit.Config{},
|
||||
promptkit.WithPromptFS(contractPromptFS("prepared", "profile", renderedContent), "."),
|
||||
promptkit.WithProfiles(promptkit.Profile{
|
||||
ID: "profile",
|
||||
Endpoint: "http://example.test/v1",
|
||||
Model: "model",
|
||||
APIKeyRequired: true,
|
||||
}),
|
||||
promptkit.WithLLMClient(client),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("construct engine: %v", err)
|
||||
}
|
||||
prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{
|
||||
PromptID: "prepared",
|
||||
APIKey: directCredential,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare execution: %v", err)
|
||||
}
|
||||
|
||||
formattedValues := []string{
|
||||
fmt.Sprint(prepared),
|
||||
fmt.Sprintf("%+v", prepared),
|
||||
fmt.Sprintf("%#v", prepared),
|
||||
}
|
||||
for _, formatted := range formattedValues {
|
||||
if formatted != "promptkit.PreparedExecution{opaque}" {
|
||||
t.Fatalf("unexpected opaque formatting: %q", formatted)
|
||||
}
|
||||
assertPreparedPrivateValuesAbsent(t, formatted, directCredential, renderedContent)
|
||||
}
|
||||
payload, err := json.Marshal(prepared)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal opaque handle: %v", err)
|
||||
}
|
||||
assertPreparedPrivateValuesAbsent(t, string(payload), directCredential, renderedContent)
|
||||
|
||||
detailsBefore := prepared.Details()
|
||||
detailsJSON, err := json.Marshal(detailsBefore)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal prepared details: %v", err)
|
||||
}
|
||||
assertPreparedPrivateValuesAbsent(t, string(detailsJSON), directCredential)
|
||||
|
||||
prepared.Discard()
|
||||
prepared.Discard()
|
||||
result, lifecycleErr := engine.RunPrepared(context.Background(), prepared)
|
||||
if result != nil || !errors.Is(lifecycleErr, promptkit.ErrInvalidRequest) {
|
||||
t.Fatalf("discarded execution result=(%+v, %v), want ErrInvalidRequest", result, lifecycleErr)
|
||||
}
|
||||
assertPreparedPrivateValuesAbsent(t, lifecycleErr.Error(), directCredential, renderedContent)
|
||||
if !reflect.DeepEqual(prepared.Details(), detailsBefore) {
|
||||
t.Fatal("details changed after discard")
|
||||
}
|
||||
|
||||
executed, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{
|
||||
PromptID: "prepared",
|
||||
APIKey: directCredential,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare execution for request inspection: %v", err)
|
||||
}
|
||||
executionResult, err := engine.RunPrepared(context.Background(), executed)
|
||||
if err != nil {
|
||||
t.Fatalf("run execution for request inspection: %v", err)
|
||||
}
|
||||
requests := client.snapshot()
|
||||
if len(requests) != 1 || requests[0].APIKey != directCredential {
|
||||
t.Fatalf("direct credential did not reach only the client credential field: %#v", requests)
|
||||
}
|
||||
requestJSON, err := json.Marshal(requests[0])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal captured generate request: %v", err)
|
||||
}
|
||||
for _, value := range []string{
|
||||
fmt.Sprint(requests[0]),
|
||||
fmt.Sprintf("%+v", requests[0]),
|
||||
fmt.Sprintf("%#v", requests[0]),
|
||||
string(requestJSON),
|
||||
fmt.Sprint(executionResult),
|
||||
} {
|
||||
assertPreparedPrivateValuesAbsent(t, value, directCredential)
|
||||
}
|
||||
resultJSON, err := json.Marshal(executionResult)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal execution result: %v", err)
|
||||
}
|
||||
assertPreparedPrivateValuesAbsent(t, string(resultJSON), directCredential)
|
||||
|
||||
var nilHandle *promptkit.PreparedExecution
|
||||
nilHandle.Discard()
|
||||
if !reflect.DeepEqual(nilHandle.Details(), promptkit.PreparedRun{}) {
|
||||
t.Fatalf("nil handle details=%+v, want zero value", nilHandle.Details())
|
||||
}
|
||||
zeroHandle := &promptkit.PreparedExecution{}
|
||||
zeroHandle.Discard()
|
||||
if !reflect.DeepEqual(zeroHandle.Details(), promptkit.PreparedRun{}) {
|
||||
t.Fatalf("zero handle details=%+v, want zero value", zeroHandle.Details())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedExecutionCredentialCapacityAndTimingBoundaries(t *testing.T) {
|
||||
t.Run("credential is rechecked before generation", func(t *testing.T) {
|
||||
const (
|
||||
environmentName = "PROMPTKIT_PREPARED_CONTRACT_KEY"
|
||||
environmentKey = "environment-credential-sentinel"
|
||||
)
|
||||
t.Setenv(environmentName, environmentKey)
|
||||
client := &preparedRecordingClient{
|
||||
response: &promptkit.GenerateResponse{Content: "unexpected"},
|
||||
}
|
||||
engine, err := promptkit.NewEngine(
|
||||
promptkit.Config{},
|
||||
promptkit.WithPromptFS(contractPromptFS("prepared", "profile", "content"), "."),
|
||||
promptkit.WithProfileFS(preparedCredentialProfileSource(environmentName), "."),
|
||||
promptkit.WithLLMClient(client),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("construct credential engine: %v", err)
|
||||
}
|
||||
prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prepared"})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare credential execution: %v", err)
|
||||
}
|
||||
if err := os.Unsetenv(environmentName); err != nil {
|
||||
t.Fatalf("unset credential environment: %v", err)
|
||||
}
|
||||
|
||||
result, err := engine.RunPrepared(context.Background(), prepared)
|
||||
if result != nil ||
|
||||
!errors.Is(err, promptkit.ErrInvalidRequest) ||
|
||||
!errors.Is(err, promptkit.ErrAPIKeyEnvMissing) {
|
||||
t.Fatalf("credential execution=(%+v, %v), want credential identities", result, err)
|
||||
}
|
||||
if len(client.snapshot()) != 0 {
|
||||
t.Fatalf("credential failure reached generation: %d calls", len(client.snapshot()))
|
||||
}
|
||||
assertPreparedPrivateValuesAbsent(t, err.Error(), environmentKey)
|
||||
if result, err := engine.RunPrepared(context.Background(), prepared); result != nil ||
|
||||
!errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||
t.Fatalf("credential failure did not consume handle: result=(%+v, %v)", result, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preparation does not admit and execution timing starts after retention", func(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
client := newCapacityGateClient(release, 4)
|
||||
engine := newBackendCapacityEngine(t, client, 1, capacityInt(0), nil)
|
||||
|
||||
activeRun := make(chan capacityRunResult, 1)
|
||||
go runCapacityRequest(
|
||||
engine,
|
||||
context.Background(),
|
||||
promptkit.RunRequest{PromptID: "prompt"},
|
||||
activeRun,
|
||||
)
|
||||
awaitCapacityRequest(t, client.started)
|
||||
|
||||
prepared, err := engine.PrepareExecution(
|
||||
context.Background(),
|
||||
promptkit.RunRequest{PromptID: "prompt"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare while capacity is full: %v", err)
|
||||
}
|
||||
if _, _, calls := client.snapshot(); calls != 1 {
|
||||
t.Fatalf("preparation invoked generation: calls=%d", calls)
|
||||
}
|
||||
if result, err := engine.RunPrepared(context.Background(), prepared); result != nil ||
|
||||
!errors.Is(err, promptkit.ErrCapacityExceeded) {
|
||||
t.Fatalf("capacity execution=(%+v, %v), want ErrCapacityExceeded", result, err)
|
||||
}
|
||||
if result, err := engine.RunPrepared(context.Background(), prepared); result != nil ||
|
||||
!errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||
t.Fatalf("capacity rejection did not consume handle: result=(%+v, %v)", result, err)
|
||||
}
|
||||
if prepared.Details().PromptID != "prompt" {
|
||||
t.Fatal("details unavailable after capacity rejection")
|
||||
}
|
||||
|
||||
close(release)
|
||||
activeOutcome := awaitCapacityRun(t, activeRun)
|
||||
if activeOutcome.err != nil || activeOutcome.result == nil {
|
||||
t.Fatalf("active run outcome=(%+v, %v), want success", activeOutcome.result, activeOutcome.err)
|
||||
}
|
||||
|
||||
timed, err := engine.PrepareExecution(
|
||||
context.Background(),
|
||||
promptkit.RunRequest{PromptID: "prompt"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare timed execution: %v", err)
|
||||
}
|
||||
details := timed.Details()
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
executionFloor := time.Now().UTC()
|
||||
result, err := engine.RunPrepared(context.Background(), timed)
|
||||
if err != nil {
|
||||
t.Fatalf("run timed execution: %v", err)
|
||||
}
|
||||
if result.StartTime.Before(executionFloor) ||
|
||||
!result.StartTime.After(details.EndTime) ||
|
||||
result.EndTime.Before(result.StartTime) ||
|
||||
result.Duration != result.EndTime.Sub(result.StartTime) {
|
||||
t.Fatalf(
|
||||
"execution timing includes preparation or retention: details_end=%s floor=%s result=%+v",
|
||||
details.EndTime,
|
||||
executionFloor,
|
||||
result,
|
||||
)
|
||||
}
|
||||
if _, _, calls := client.snapshot(); calls != 2 {
|
||||
t.Fatalf("generation calls=%d, want active and timed executions only", calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type mutablePreparedArtifactReader struct {
|
||||
mu sync.Mutex
|
||||
body string
|
||||
hash string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *mutablePreparedArtifactReader) Read(
|
||||
_ context.Context,
|
||||
_ promptkit.ArtifactRef,
|
||||
) (*promptkit.Artifact, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.calls++
|
||||
return &promptkit.Artifact{
|
||||
Body: []byte(r.body),
|
||||
Hash: r.hash,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *mutablePreparedArtifactReader) set(body, hash string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.body = body
|
||||
r.hash = hash
|
||||
}
|
||||
|
||||
func (r *mutablePreparedArtifactReader) callCount() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.calls
|
||||
}
|
||||
|
||||
type preparedRecordingClient struct {
|
||||
mu sync.Mutex
|
||||
response *promptkit.GenerateResponse
|
||||
err error
|
||||
requests []promptkit.GenerateRequest
|
||||
started chan struct{}
|
||||
release <-chan struct{}
|
||||
}
|
||||
|
||||
func (c *preparedRecordingClient) Generate(
|
||||
ctx context.Context,
|
||||
request promptkit.GenerateRequest,
|
||||
) (*promptkit.GenerateResponse, error) {
|
||||
c.mu.Lock()
|
||||
c.requests = append(c.requests, request)
|
||||
c.mu.Unlock()
|
||||
|
||||
if c.started != nil {
|
||||
c.started <- struct{}{}
|
||||
}
|
||||
if c.release != nil {
|
||||
select {
|
||||
case <-c.release:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
return c.response, nil
|
||||
}
|
||||
|
||||
func (c *preparedRecordingClient) snapshot() []promptkit.GenerateRequest {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]promptkit.GenerateRequest(nil), c.requests...)
|
||||
}
|
||||
|
||||
func newPreparedContractEngine(
|
||||
t *testing.T,
|
||||
client promptkit.LLMClient,
|
||||
message string,
|
||||
) *promptkit.Engine {
|
||||
t.Helper()
|
||||
engine, err := promptkit.NewEngine(
|
||||
promptkit.Config{},
|
||||
promptkit.WithPromptFS(contractPromptFS("prepared", "profile", message), "."),
|
||||
promptkit.WithProfiles(promptkit.Profile{
|
||||
ID: "profile",
|
||||
Endpoint: "http://example.test/v1",
|
||||
Model: "model",
|
||||
}),
|
||||
promptkit.WithLLMClient(client),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("construct prepared execution engine: %v", err)
|
||||
}
|
||||
return engine
|
||||
}
|
||||
|
||||
func preparedPromptSource(label string) fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"prompt.yaml": &fstest.MapFile{Data: []byte(`id: prepared
|
||||
version: "1"
|
||||
default_profile: profile
|
||||
inputs:
|
||||
- name: input
|
||||
required: true
|
||||
messages:
|
||||
- role: user
|
||||
content: 'Input={{input "input"}} Label={{.label}}'
|
||||
description: ` + label + `
|
||||
output:
|
||||
format: json
|
||||
validation_mode: json_schema
|
||||
schema_path: schema.json
|
||||
`)},
|
||||
}
|
||||
}
|
||||
|
||||
func preparedProfileSource(model string) fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"profile.yaml": &fstest.MapFile{Data: []byte(`id: profile
|
||||
endpoint: http://example.test/v1
|
||||
model: ` + model + `
|
||||
`)},
|
||||
}
|
||||
}
|
||||
|
||||
func preparedCredentialProfileSource(environmentName string) fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"profile.yaml": &fstest.MapFile{Data: []byte(`id: profile
|
||||
endpoint: http://example.test/v1
|
||||
model: model
|
||||
api_key_env: ` + environmentName + `
|
||||
`)},
|
||||
}
|
||||
}
|
||||
|
||||
func preparedSchemaSource() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"schema.json": &fstest.MapFile{Data: []byte(`{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "original root",
|
||||
"type": "object",
|
||||
"required": ["value"],
|
||||
"properties": {
|
||||
"value": {"$ref": "value.json"}
|
||||
}
|
||||
}`)},
|
||||
"value.json": &fstest.MapFile{Data: []byte(`{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "integer",
|
||||
"minimum": 2
|
||||
}`)},
|
||||
}
|
||||
}
|
||||
|
||||
func assertPreparedPrivateValuesAbsent(t *testing.T, value string, privateValues ...string) {
|
||||
t.Helper()
|
||||
for _, privateValue := range privateValues {
|
||||
if strings.Contains(value, privateValue) {
|
||||
t.Fatalf("value exposed private data %q: %s", privateValue, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
63
types.go
63
types.go
@@ -51,7 +51,8 @@ const (
|
||||
// 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.
|
||||
// output. Engine.Run and Engine.RunPrepared return this status in a result,
|
||||
// not as an error.
|
||||
ValidationFailed ValidationStatus = "failed"
|
||||
// ValidationSkipped means ValidationNone selected no content check.
|
||||
ValidationSkipped ValidationStatus = "skipped"
|
||||
@@ -78,9 +79,10 @@ const (
|
||||
// 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.
|
||||
// Prepare, PrepareExecution, and Run copy the request's maps, pointers, and
|
||||
// nested JSON-compatible values before using them. The caller may mutate the
|
||||
// request after any method returns. A successful PrepareExecution retains its
|
||||
// own private execution snapshot for RunPrepared.
|
||||
type RunRequest struct {
|
||||
// PromptID is the required non-empty prompt identifier.
|
||||
PromptID string
|
||||
@@ -98,12 +100,14 @@ type RunRequest struct {
|
||||
// opaque consumer metadata, not a credential, and may be exposed in
|
||||
// prepared values, results, collaborator requests, provider requests, and
|
||||
// provider observability. Callers should use stable, non-sensitive
|
||||
// identifiers. An overlong direct value makes Prepare or Run return an
|
||||
// error matching ErrInvalidRequest.
|
||||
// identifiers. An overlong direct value makes Prepare, PrepareExecution, or
|
||||
// Run return an error matching ErrInvalidRequest.
|
||||
SessionID 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.
|
||||
// prepared values, results, hashes, JSON, String, or GoString output. A
|
||||
// successful PrepareExecution retains it only in the opaque handle until
|
||||
// RunPrepared claims the handle or Discard invalidates it.
|
||||
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.
|
||||
@@ -119,9 +123,10 @@ type RunRequest struct {
|
||||
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. PreparedRun has a stable JSON representation.
|
||||
// PreparedRun contains prepared prompt execution state returned by
|
||||
// [Engine.Prepare] or [PreparedExecution.Details]. It does not include resolved
|
||||
// API key values, model output, validation results, or internal target 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
|
||||
@@ -154,7 +159,8 @@ type PreparedRun struct {
|
||||
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 are the rendered messages that Run or RunPrepared 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"`
|
||||
@@ -214,12 +220,15 @@ type RunResult struct {
|
||||
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 is the UTC time immediately before ordinary Run preparation or
|
||||
// after RunPrepared claims its handle.
|
||||
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 covers preparation, generation, and validation for Run. For
|
||||
// RunPrepared it covers only the execution attempt after claim and excludes
|
||||
// preparation and consumer-held delay. JSON represents it as integer
|
||||
// milliseconds in duration_ms and omits a zero value.
|
||||
Duration time.Duration `json:"-"`
|
||||
}
|
||||
|
||||
@@ -259,10 +268,11 @@ type Artifact struct {
|
||||
// ArtifactReader resolves a prompt input reference into its content.
|
||||
//
|
||||
// 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.
|
||||
// Prepare, PrepareExecution, 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.
|
||||
//
|
||||
// An injected reader owns any application-specific path containment,
|
||||
// authorization, content-size, and content-type policy. It must protect
|
||||
@@ -559,25 +569,26 @@ type StructuredOutputJSONSpec struct {
|
||||
Schema any `json:"schema"`
|
||||
}
|
||||
|
||||
// LLMClient executes rendered prompts for [Engine.Run].
|
||||
// LLMClient executes rendered prompts for [Engine.Run] and
|
||||
// [Engine.RunPrepared].
|
||||
//
|
||||
// Generate is scheduled according to the resolved backend's capacity policy.
|
||||
// It may still be called concurrently for different backend pools or unlimited
|
||||
// backends. Cancellation while waiting for capacity can prevent Generate from
|
||||
// being called. Once invoked, 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.
|
||||
// and RunPrepared 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.
|
||||
//
|
||||
// Generate receives rendered messages and may receive a direct API key. A
|
||||
// client must protect those values and any raw output in its logging, storage,
|
||||
// and retained copies. It is responsible for the cancellation behavior of any
|
||||
// work it starts and for synchronizing access to retained or shared data.
|
||||
//
|
||||
// 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.
|
||||
// A returned error makes Run or RunPrepared 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 either method.
|
||||
type LLMClient interface {
|
||||
Generate(context.Context, GenerateRequest) (*GenerateResponse, error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user