package promptkit import ( "context" "errors" "fmt" "io/fs" "net/http" "time" "gitea.maximumdirect.net/eric/ConfigKit" "gitea.maximumdirect.net/eric/promptkit/internal/domain" internalengine "gitea.maximumdirect.net/eric/promptkit/internal/engine" "gitea.maximumdirect.net/eric/promptkit/internal/observation" "gitea.maximumdirect.net/eric/promptkit/internal/usecase" ) // ErrInvalidConfig identifies invalid engine construction, including missing // effective configuration, invalid options, and a nil // Engine receiver. var ErrInvalidConfig = errors.New("invalid engine configuration") var ( // ErrInvalidRequest identifies a request whose required values, overrides, // credentials, or effective settings are invalid. ErrInvalidRequest = errors.New("invalid run request") // ErrPromptNotFound identifies a requested prompt ID or version that is not // present in the selected prompt source. It does not also match // ErrPromptLoad. ErrPromptNotFound = errors.New("prompt not found") // ErrProfileNotFound identifies a selected profile ID that is absent from // every configured profile source. It does not also match ErrProfileLoad. ErrProfileNotFound = errors.New("profile not found") // ErrProfileRequired identifies a request for which neither RunRequest.ProfileID // nor the selected prompt's default profile is present. Such an error also // matches ErrInvalidRequest. ErrProfileRequired = errors.New("profile selection is required") // ErrPromptLoad identifies a prompt selection failure other than the // not-found case represented by ErrPromptNotFound. Invalid definitions // instead fail construction with ErrInvalidConfig. ErrPromptLoad = errors.New("failed to load prompt definition") // ErrProfileLoad identifies a profile selection or resolution failure other // than the not-found case represented by ErrProfileNotFound. Invalid // definitions instead fail construction with ErrInvalidConfig. ErrProfileLoad = errors.New("failed to load execution profile") // ErrAPIKeyEnvMissing identifies an explicitly required APIKeyEnv whose // environment variable is unset or empty after direct RunRequest.APIKey // precedence is applied. Such an error also matches ErrInvalidRequest. ErrAPIKeyEnvMissing = errors.New("api_key_env points to an unset environment variable") // ErrArtifactLoad identifies a failure to resolve an input artifact. Errors // returned by an injected ArtifactReader remain available through errors.Is. ErrArtifactLoad = errors.New("failed to load artifact") // ErrPromptRender identifies a failure to render prompt messages or the // session ID from the resolved inputs and variables. ErrPromptRender = errors.New("failed to render prompt") // ErrCapacityExceeded identifies a Run or RunPrepared rejected because the // selected backend already admitted ConcurrencyLimit + QueueCapacity calls. // A [CapacityError] reports the selected backend ID. 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. A built-in OpenAI-compatible non-2xx response is available as a // [GenerationError]. Errors returned by an injected LLMClient remain // available through errors.Is. ErrLLMGenerate = errors.New("failed to generate output") // ErrTransport identifies a failed HTTP request by the built-in model // client. Generation errors in this category also match ErrLLMGenerate and // retain the transport cause for errors.Is and errors.As. It does not imply // that the provider received no work. ErrTransport = errors.New("model transport failed") // ErrMalformedResponse identifies an invalid successful model response or // a nil successful response from an injected client. Generation errors in // this category also match ErrLLMGenerate. ErrMalformedResponse = errors.New("malformed model response") // ErrRetriesExhausted identifies a built-in generation sequence whose // positive transient retry budget ended with another eligible HTTP status. // RetryExhaustedError retains the final cause and actual attempt count. ErrRetriesExhausted = errors.New("transient model retries exhausted") // ErrValidation identifies an operational failure to load or compile a // schema or validate output. A completed validation whose Status is // ValidationFailed is returned in RunResult without this error. ErrValidation = errors.New("failed to validate output") ) // Engine inspects prompts and profiles, enumerates prompt requirements, and // prepares and runs Promptkit prompt requests. // // An Engine is safe for concurrent calls to [Engine.InspectConfiguration], // [Engine.InspectPrompt], // [Engine.InspectRequirements], [Engine.InspectProfile], [Engine.Prepare], [Engine.PrepareExecution], // [Engine.Run], [Engine.RunPrepared], and [Engine.SmokeTest]. Each Engine owns // independent backend-capacity pools that coordinate Run, RunPrepared, and // inference SmokeTest admission and model generation. Injected collaborators // may still be invoked concurrently across different backend pools or for // unlimited backends. type Engine struct { internal *internalengine.Engine } // ConfigurationSources selects ordered ConfigKit underlays and an optional // highest-priority main input. Each underlay's inputs strictly compose within // their group; later underlays overlay earlier ones. A nil Main uses // ConfigKit's empty source. A non-nil Main is passed through unchanged and // may fail construction if invalid. type ConfigurationSources struct { Underlays []configkit.Underlay Main *configkit.Input } // Config selects configuration sources and built-in model-client transport // used by [NewEngine]. Config has no stable JSON representation. type Config struct { // Sources combines maintained definitions with caller-selected inputs. Sources ConfigurationSources // SchemaDir is the root for JSON Schema files. An empty value uses the // current directory. WithSchemaFS or WithSchemaFile replaces this source. SchemaDir string // Timeout is the transport-wide safety cap for the built-in LLM client // when HTTPClient is absent or has a non-positive timeout. A zero or negative // value selects the 10-minute default. Timeout time.Duration // HTTPClient is cloned for the built-in LLM client. Its positive Timeout // takes precedence over Timeout. A zero or negative client Timeout inherits // Timeout or the 10-minute default. The supplied client is not mutated. This // field is ignored when WithLLMClient is used. HTTPClient *http.Client // TransientRetries selects additional built-in HTTP attempts after eligible // provider statuses. Nil defaults to two; zero disables retries. Negative // values are invalid. Injected LLMClient implementations are never retried // automatically. TransientRetries *int } // Option customizes engine construction. // // NewEngine applies options in argument order and ignores nil options. Within // each schema-source, model-client, and artifact-reader category, the last // non-nil valid option replaces earlier options. An invalid option fails // construction even if a later option would replace it. type Option interface { apply(*engineOptions) error } type optionFunc func(*engineOptions) error func (f optionFunc) apply(options *engineOptions) error { return f(options) } type engineOptions struct { internal internalengine.Options } // 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] or // [Engine.PrepareExecution]. func WithLLMClient(client LLMClient) Option { return optionFunc(func(options *engineOptions) error { if client == nil { return ErrInvalidConfig } options.internal.SetLLMClient(publicLLMClientAdapter{client: client}) return nil }) } // WithArtifactReader replaces the default reader for every input artifact // reference, regardless of its ArtifactRef.Type. // // A nil reader makes NewEngine fail with ErrInvalidConfig. The reader may be // called concurrently. func WithArtifactReader(reader ArtifactReader) Option { return optionFunc(func(options *engineOptions) error { if reader == nil { return ErrInvalidConfig } options.internal.SetArtifactReader(publicArtifactReaderAdapter{reader: reader}) return nil }) } // WithSchemaFS loads JSON Schema documents from fsys under root. // // Prompt schema_path values resolve within this source when schema validation // or structured output is requested. fsys must be non-nil and root must be // non-empty; otherwise NewEngine fails with ErrInvalidConfig. This option // replaces Config.SchemaDir and earlier schema-source options. func WithSchemaFS(fsys fs.FS, root string) Option { return optionFunc(func(options *engineOptions) error { return options.internal.SetSchemaFS(fsys, root) }) } // WithSchemaFile loads JSON Schema documents from the single schema file at path. // // Prompt schema_path values refer to the file's base name. path must name an // existing non-directory file when NewEngine applies the option. This option // replaces Config.SchemaDir and earlier schema-source options. func WithSchemaFile(path string) Option { return optionFunc(func(options *engineOptions) error { return options.internal.SetSchemaFile(path) }) } // NewEngine constructs an Engine from configuration and options. // // ConfigKit composes maintained catalog inputs, PromptKit-owned definitions, // caller underlays, and the optional main input in that order. Construction // validates the final effective backend, profile, and prompt definitions and // captures referenced prompt content. A missing prompt collection is allowed. // Schema documents and request artifacts remain runtime resources. // // NewEngine returns an error matching ErrInvalidConfig for invalid // configuration or options, preserving a ConfigKit LoadError when loading // fails. Each constructed // Engine has independent backend-capacity pools. Construction does not perform // model requests or require credentials. A negative Config.TransientRetries // is invalid. func NewEngine(cfg Config, opts ...Option) (*Engine, error) { var options engineOptions for _, opt := range opts { if opt == nil { continue } if err := opt.apply(&options); err != nil { return nil, fmt.Errorf("%w: %w", ErrInvalidConfig, err) } } assembled, err := internalengine.New( internalengine.Config{Sources: internalengine.ConfigurationSources{Underlays: cfg.Sources.Underlays, Main: cfg.Sources.Main}, SchemaDir: cfg.SchemaDir, Timeout: cfg.Timeout, HTTPClient: cfg.HTTPClient, TransientRetries: copyIntPtr(cfg.TransientRetries)}, options.internal, ) if err != nil { return nil, fmt.Errorf("%w: %w", ErrInvalidConfig, err) } return &Engine{internal: assembled}, nil } // InspectPrompt resolves one explicit prompt definition without selecting a // profile or starting execution work. // // InspectPrompt requires a nonblank promptID. It passes nonblank promptID and // promptVersion values unchanged to the engine's ordinary, case-sensitive // prompt selection. An empty version succeeds only when that ID has one // version; a nonempty version selects one exact ID/version pair from the // captured configuration snapshot. // // A successful result proves that the selected definition and any referenced // message content files were structurally loaded. Inputs are returned in // definition order. DefaultProfileID is declared metadata only and is not // resolved. OutputContract is the normalized declared contract, with a JSON // Schema path when declared but without loading or compiling that schema. // PromptHash is the same opaque equality value as PreparedRun.PromptHash for // the selected captured definition; its spelling, length, // encoding, algorithm, and security properties are not contracts. // // This method does not return prompt bodies, templates, source paths, schemas, // rendered messages, or execution settings. It does not resolve a profile or // credential, read artifacts or schemas, render, validate, admit capacity, // contact a provider, or generate model output. The returned PromptInspection // and its input slice are caller-owned. Inspection does not reload configuration // or content files; construct another Engine to observe source changes. // // A nil Engine returns an error matching ErrInvalidConfig. A blank prompt ID // matches ErrInvalidRequest. An absent exact ID or version matches // ErrPromptNotFound and not ErrPromptLoad. An ambiguous omitted version matches // ErrPromptLoad. Invalid definitions and resource failures reject construction // instead. Cancellation during lookup preserves the context error. InspectPrompt // returns no partial result on error. func (e *Engine) InspectPrompt( ctx context.Context, promptID string, promptVersion string, ) (*PromptInspection, error) { if e == nil || e.internal == nil { return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig) } inspection, err := e.internal.InspectPrompt(ctx, promptID, promptVersion) if err != nil { return nil, mapPublicError(err) } return fromDomainPromptInspection(inspection), nil } // InspectProfile resolves one explicit profile without selecting a prompt or // starting execution work. // // InspectProfile looks up a nonblank profileID exactly and case-sensitively // in the captured effective configuration. It applies the framework timeout // baseline, selected // backend, and then selected profile to EffectiveModelParams without a request // override. BackendID is empty for an endpoint-only profile. // // APIKeyEnv in the returned target is an environment-variable name, never its // value. APIKeyRequired instead reports a direct credential requirement and is // mutually exclusive with a nonblank APIKeyEnv. InspectProfile neither derives // an ID from a prompt default_profile nor checks credential availability, so an // absent or blank named environment variable is not an error. // // The returned ProfileInspection and all nested mutable values are // caller-owned. Inspection does not reload configuration; construct another // Engine to observe source changes. This method does not load // a prompt, render, read artifacts or schemas, admit backend capacity, contact // a provider, or generate model output. // // A nil Engine returns an error matching ErrInvalidConfig. A blank profile ID // matches ErrInvalidRequest. An absent exact ID matches ErrProfileNotFound and // not ErrProfileLoad. Invalid definitions or unknown backends reject // construction instead. Cancellation preserves the context error. // InspectProfile returns no partial result on error. func (e *Engine) InspectProfile(ctx context.Context, profileID string) (*ProfileInspection, error) { if e == nil || e.internal == nil { return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig) } inspection, err := e.internal.InspectProfile(ctx, profileID) if err != nil { return nil, mapPublicError(err) } return fromDomainProfileInspection(inspection), nil } // Prepare resolves and renders a prompt request without calling an LLM. // It appends any validated RunRequest.AppendedMessages after rendered // definition messages in the returned caller-owned snapshot. // // Prepare selects the prompt and profile, resolves any selected backend and // effective execution settings, resolves the output contract, loads and hashes // inputs, loads structured-output schema metadata when required, and renders // the session ID and messages. The returned PreparedRun is owned by the caller // and never contains a resolved API-key value, model output, or validation // result. // // A nil Engine returns an error matching ErrInvalidConfig. Request and // preparation failures may match ErrInvalidRequest, ErrPromptNotFound, // ErrPromptLoad, ErrProfileNotFound, ErrProfileLoad, ErrProfileRequired, // ErrAPIKeyEnvMissing, ErrArtifactLoad, ErrPromptRender, or ErrValidation as // applicable. Cancellation is passed to the active collaborator and is // reported in the applicable operation category; no general errors.Is // relationship to ctx.Err is promised. Prepare returns no partial result on // error. It validates RunRequest.TransientRetries but makes no model attempts. func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, error) { prepared, err := e.prepareRequest(ctx, req) if err != nil { return nil, err } return fromDomainPreparedRun(prepared), nil } // ValidateRequest reports whether req passes the same preparation path used by // [Engine.Prepare] at the time of this call. A nil error means this concrete // request passed preparation. It checks only this invocation and returns errors // as encountered rather than aggregating them. All supplied inputs follow the // same loading behavior as [Engine.Prepare], including inputs unused by the // selected template branch. The method does not call an LLM or retain a // prepared snapshot or executable handle, but preparation may still perform // resource I/O and incur its associated time and memory costs. // // Its errors and context handling follow [Engine.Prepare], including an error // matching ErrInvalidConfig when the Engine is nil. Success does not guarantee // provider acceptance or later execution success. Use [Engine.PrepareExecution] // when execution must use the snapshot produced by preparation. func (e *Engine) ValidateRequest(ctx context.Context, req RunRequest) error { _, err := e.prepareRequest(ctx, req) return err } func (e *Engine) prepareRequest(ctx context.Context, req RunRequest) (*domain.PreparedRun, error) { ctx = observation.WithoutExecutionState(ctx) if e == nil || e.internal == 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.internal.Prepare(ctx, domainReq) if err != nil { return nil, mapPublicError(err) } return prepared, nil } // PrepareExecution completely prepares a prompt request without calling the // configured LLMClient or reserving backend admission capacity. Validated // RunRequest.AppendedMessages are included in the frozen effective messages. // // 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. The effective transient retry budget is frozen privately // for RunPrepared and is absent from Details. A supplied Observer is retained // privately for RunPrepared; preparation itself emits no events. func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*PreparedExecution, error) { ctx = observation.WithoutExecutionState(ctx) if e == nil || e.internal == 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.internal.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. Each call uses the Engine's captured configuration and // composes a fresh effective prompt with any validated // RunRequest.AppendedMessages. // // A content-validation failure is a successful run whose // RunResult.Validation has Status ValidationFailed. When its output contract // has a positive repair budget, a failed eligible validation can make bounded // additional model calls and stops at the first valid candidate. Exhaustion // returns the final failed validation result with cumulative usage and actual // repair attempts. An inability to generate or validate returns an error and // no partial result. // // The built-in client retries eligible provider statuses using the effective // Config or request budget. With R additional transient attempts and P repair // generations, one run can dispatch at most (R+1)×(P+1) Promptkit-controlled // model attempts. Each dispatched attempt receives its own generation and // HTTP-client timeout; the caller context governs the full operation. // Injected LLMClient implementations are not retried automatically. // With Observer, Run emits started before facade request conversion or a nil // engine check, and finished on every normal return after public error mapping // and resource release. The event RunID matches a successful result. Without // Observer, result timing retains its existing convention; observed wall // duration can be slightly longer because it spans the facade boundary. // // Run can return every error category documented by [Engine.Prepare], plus // ErrCapacityExceeded and ErrLLMGenerate. Built-in HTTP execution failures // additionally match ErrTransport; malformed successful responses and nil // successful injected responses additionally match ErrMalformedResponse. // An engine admission rejection is // discoverable as [CapacityError] and still matches ErrCapacityExceeded. It // occurs before artifacts, schemas, rendering, or model generation because the // selected backend's admission capacity is full; it does not match // ErrInvalidRequest or ErrLLMGenerate. A built-in OpenAI-compatible non-2xx // response is discoverable as [GenerationError]. Eligible-status exhaustion // with a positive budget is discoverable as [RetryExhaustedError]. Injected // errors remain available through errors.Is. Cancellation while waiting for // model-generation capacity matches both ErrLLMGenerate and the context error. // Cancellation otherwise follows the active collaborator's documented // behavior. A nil Engine returns ErrInvalidConfig. Run returns no partial // result on error. func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) { ctx = observation.WithoutExecutionState(ctx) if req.Observer == nil { return e.run(ctx, req) } recorder, idErr := observation.New(func(event domain.ExecutionEvent) { req.Observer(fromDomainExecutionEvent(event)) }) recorder.Start() if idErr != nil { err := fmt.Errorf("failed to create run id: %w", idErr) terminal := observedFinish(nil, err) recorder.Finish(observation.FinishData{Outcome: terminal.Outcome, Failure: terminal.Failure}) return nil, err } result, err := e.run(observation.WithRecorder(ctx, recorder), req) terminal := observedFinish(result, err) recorder.Finish(observation.FinishData{Outcome: terminal.Outcome, Failure: terminal.Failure, StatusCode: terminal.StatusCode, RetriesExhausted: terminal.RetriesExhausted}) return result, err } func (e *Engine) run(ctx context.Context, req RunRequest) (*RunResult, error) { if e == nil || e.internal == nil { return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig) } domainReq, err := toDomainRunRequest(req) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err) } result, err := e.internal.Run(ctx, domainReq) if err != nil { return nil, mapPublicError(err) } 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 bounded output 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. An engine // admission rejection is discoverable as [CapacityError] and still matches // ErrCapacityExceeded. A built-in OpenAI-compatible non-2xx response is // discoverable as [GenerationError]. Built-in HTTP execution failures match // ErrTransport; malformed successful responses and nil successful injected // responses match ErrMalformedResponse. Positive-budget eligible-status // exhaustion is discoverable as [RetryExhaustedError]. A completed // content-validation rejection, including repair exhaustion, is returned in // RunResult, not as an operational // error. RunPrepared uses the retry choice frozen during PrepareExecution. An // operational error returns no partial RunResult. A retained Observer begins // only after a successful atomic claim, with identities from the frozen // snapshot, and receives a terminal event on normal return after public error // mapping. Invalid, foreign, discarded, or reused handles emit no events. // Callbacks run outside the handle and capacity locks; panics propagate and // do not promise a terminal event. func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (*RunResult, error) { ctx = observation.WithoutExecutionState(ctx) if e == nil || e.internal == nil { return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig) } var internal *usecase.PreparedExecution if prepared != nil { internal = prepared.internal } var recorder *observation.Recorder result, err := e.internal.RunPrepared(observation.WithClaimSink(ctx, &recorder), internal) if err != nil { mapped := mapPublicError(err) if recorder != nil { terminal := observedFinish(nil, mapped) recorder.Finish(observation.FinishData{Outcome: terminal.Outcome, Failure: terminal.Failure, StatusCode: terminal.StatusCode, RetriesExhausted: terminal.RetriesExhausted}) } return nil, mapped } publicResult := fromDomainRunResult(result) if recorder != nil { terminal := observedFinish(publicResult, nil) recorder.Finish(observation.FinishData{Outcome: terminal.Outcome}) } return publicResult, nil }