package promptkit import ( "context" "errors" "fmt" "io/fs" "net/http" "os" "path/filepath" "strings" "time" artifactadapter "gitea.maximumdirect.net/eric/promptkit/internal/artifact" "gitea.maximumdirect.net/eric/promptkit/internal/backend" "gitea.maximumdirect.net/eric/promptkit/internal/capacity" "gitea.maximumdirect.net/eric/promptkit/internal/defaults" "gitea.maximumdirect.net/eric/promptkit/internal/domain" "gitea.maximumdirect.net/eric/promptkit/internal/llm" "gitea.maximumdirect.net/eric/promptkit/internal/profile" "gitea.maximumdirect.net/eric/promptkit/internal/profile/builtin" "gitea.maximumdirect.net/eric/promptkit/internal/prompt" "gitea.maximumdirect.net/eric/promptkit/internal/promptdef" "gitea.maximumdirect.net/eric/promptkit/internal/usecase" "gitea.maximumdirect.net/eric/promptkit/internal/validate" ) // ErrInvalidConfig identifies invalid engine construction, including missing // required configuration, invalid options or backend registrations, 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 failure to read, decode, validate, select, or // hash a prompt definition, except for the not-found case represented by // ErrPromptNotFound. ErrPromptLoad = errors.New("failed to load prompt definition") // ErrProfileLoad identifies a failure to read, decode, validate, or select // an execution profile or resolve its backend, except for the profile // not-found case represented by ErrProfileNotFound. ErrProfileLoad = errors.New("failed to load execution profile") // ErrAPIKeyEnvMissing identifies an APIKeyEnv whose environment variable is // unset or empty when no direct RunRequest.APIKey takes precedence. Such an // error also matches ErrInvalidRequest. ErrAPIKeyEnvMissing = errors.New("api_key_env points to an unset environment variable") // ErrArtifactLoad 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. // 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 // through errors.Is. ErrLLMGenerate = errors.New("failed to generate output") // ErrValidation identifies an operational failure to load or compile a // schema or validate output. A completed validation whose Status is // ValidationFailed is returned in RunResult without this error. ErrValidation = errors.New("failed to validate output") ) // Engine inspects prompts and profiles and prepares and runs Promptkit prompt // requests. // // An Engine is safe for concurrent calls to [Engine.InspectPrompt], // [Engine.InspectProfile], [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 } // Config selects the directory-backed sources and built-in model-client // transport used by [NewEngine]. Config has no stable JSON representation. type Config struct { // PromptDir is the directory searched recursively for prompt definitions. // It is required unless a WithPromptFS or WithPromptFile option supplies the // prompt source. PromptDir string // ProfileDir is an optional directory whose profiles take precedence over // embedded built-in profiles. An empty value selects only built-ins unless // profile options are also supplied. ProfileDir string // SchemaDir 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 } // Option customizes engine construction. // // NewEngine applies options in argument order and ignores nil options. Within // each prompt-source, profile-source, in-memory-profile, schema-source, // model-client, and artifact-reader category, the last non-nil valid option // replaces earlier options in that category. WithBackend is the additive // exception: unique registrations accumulate, and a repeated backend ID is an // error rather than a replacement. 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 { llmClient llm.Client artifactReader artifactadapter.Reader promptDefs promptdef.Repository profiles profile.Repository memoryProfiles profile.Repository backends []domain.Backend validator validate.Validator promptSource bool profileSource bool memorySource bool validatorSource bool artifactSource bool } // 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.llmClient = 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.artifactReader = publicArtifactReaderAdapter{reader: reader} options.artifactSource = true return nil }) } // WithPromptFS loads prompt definitions from fsys under root. // // The source uses the same strict prompt YAML rules as configured prompt // directories, and prompt content_file paths resolve within this source. // fsys must be non-nil and root must be non-empty; otherwise NewEngine fails // with ErrInvalidConfig. This option replaces Config.PromptDir and earlier // prompt-source options. func WithPromptFS(fsys fs.FS, root string) Option { return optionFunc(func(options *engineOptions) error { if fsys == nil { return ErrInvalidConfig } if strings.TrimSpace(root) == "" { return ErrInvalidConfig } options.promptDefs = promptdef.NewFSRepository(fsys, root) options.promptSource = true return nil }) } // WithPromptFile loads prompt definitions from the single prompt file at path. // // Relative prompt content_file paths resolve from the file's directory. path // must name an existing non-directory file when NewEngine applies the option. // This option replaces Config.PromptDir and earlier prompt-source options. func WithPromptFile(path string) Option { return optionFunc(func(options *engineOptions) error { fsys, root, err := fileSource(path) if err != nil { return err } options.promptDefs = promptdef.NewFSRepository(fsys, root) options.promptSource = true return nil }) } // WithProfileFS loads execution profiles from fsys under root. // // Profiles from this source overlay built-in profiles. Profile YAML must use // api_key_env for environment-based credentials; raw API keys are rejected. // fsys must be non-nil and root must be non-empty; otherwise NewEngine fails // with ErrInvalidConfig. This option replaces Config.ProfileDir and earlier // file or FS profile-source options, but remains below WithProfiles in // precedence. func WithProfileFS(fsys fs.FS, root string) Option { return optionFunc(func(options *engineOptions) error { if fsys == nil { return ErrInvalidConfig } if strings.TrimSpace(root) == "" { return ErrInvalidConfig } options.profiles = profile.NewFSRepository(fsys, root) options.profileSource = true return nil }) } // WithProfileFile loads execution profiles from the single profile file at path. // // The profile overlays built-in profiles. Profile YAML must use api_key_env for // environment-based credentials; raw API keys are rejected. path must name an // existing non-directory file when NewEngine applies the option. This option // replaces Config.ProfileDir and earlier file or FS profile-source options, // but remains below WithProfiles in precedence. func WithProfileFile(path string) Option { return optionFunc(func(options *engineOptions) error { fsys, root, err := fileSource(path) if err != nil { return err } options.profiles = profile.NewFSRepository(fsys, root) options.profileSource = true return nil }) } // WithProfiles configures in-memory profiles that take precedence over // configured profile files and built-in profiles. // // NewEngine validates and copies every profile. IDs must be unique within one // call. An invalid profile, duplicate ID, or unsupported ExtraParams value // makes construction fail with ErrInvalidConfig. Repeating WithProfiles // replaces the complete earlier in-memory set rather than merging it. func WithProfiles(profiles ...Profile) Option { return optionFunc(func(options *engineOptions) error { repo, err := newMemoryProfileRepository(profiles) if err != nil { return err } options.memoryProfiles = repo options.memorySource = true return nil }) } // WithSchemaFS loads JSON Schema documents from fsys under root. // // Prompt schema_path values resolve within this source when schema validation // or structured output is requested. fsys must be non-nil and root must be // non-empty; otherwise NewEngine fails with ErrInvalidConfig. This option // replaces Config.SchemaDir and earlier schema-source options. func WithSchemaFS(fsys fs.FS, root string) Option { return optionFunc(func(options *engineOptions) error { if fsys == nil { return ErrInvalidConfig } if strings.TrimSpace(root) == "" { return ErrInvalidConfig } options.validator = validate.NewFSValidator(fsys, root) options.validatorSource = true return nil }) } // WithSchemaFile loads JSON Schema documents from the single schema file at path. // // Prompt schema_path values refer to the file's base name. path must name an // existing non-directory file when NewEngine applies the option. This option // replaces Config.SchemaDir and earlier schema-source options. func WithSchemaFile(path string) Option { return optionFunc(func(options *engineOptions) error { fsys, root, err := fileSource(path) if err != nil { return err } options.validator = validate.NewFSValidator(fsys, root) options.validatorSource = true return nil }) } // NewEngine constructs an Engine from configuration and options. // // Options are applied in order according to [Option]. PromptDir is required // unless a prompt-source option is present. Construction validates option // arguments, in-memory profiles, and backend registrations but defers reading // and validating prompt, file-backed profile, and schema contents until Prepare // or Run needs them. // // NewEngine returns an error matching ErrInvalidConfig for invalid // configuration, options, or backend-capacity policies. Each constructed // Engine has independent backend-capacity pools. Construction does not perform // model requests or require credentials. func NewEngine(cfg Config, opts ...Option) (*Engine, error) { var options engineOptions for _, opt := range opts { if opt == nil { continue } if err := opt.apply(&options); err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err) } } promptDefs := options.promptDefs if !options.promptSource { if strings.TrimSpace(cfg.PromptDir) == "" { return nil, fmt.Errorf("%w: prompt directory is required", ErrInvalidConfig) } promptDefs = promptdef.NewFilesystemRepository(cfg.PromptDir) } profiles := builtin.NewRepositoryWithDirectory(cfg.ProfileDir) if options.profileSource { profiles = builtin.NewRepositoryWithPrimary(options.profiles) } if options.memorySource { profiles = profile.NewOverlayRepository(options.memoryProfiles, profiles) } backendRegistry, err := backend.NewRegistry(options.backends) if err != nil { return nil, fmt.Errorf("%w: failed to construct backend registry: %v", ErrInvalidConfig, err) } capacityManager, err := capacity.NewManager(backendRegistry.CapacityPolicies()) if err != nil { return nil, fmt.Errorf("%w: failed to construct backend capacity manager: %v", ErrInvalidConfig, err) } validator := options.validator if !options.validatorSource { schemaDir := cfg.SchemaDir if strings.TrimSpace(schemaDir) == "" { schemaDir = defaults.SchemaDirDefault } validator = validate.NewStandardValidator(schemaDir) } llmClient := options.llmClient if llmClient == nil { var err error llmClient, err = llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{ Timeout: cfg.Timeout, HTTPClient: cfg.HTTPClient, }) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err) } } llmClient = capacity.NewClient(capacityManager, llmClient) artifacts := options.artifactReader if !options.artifactSource { artifacts = artifactadapter.NewCompositeReader() } return &Engine{ runner: usecase.NewRunner( promptDefs, profiles, backendRegistry, artifacts, prompt.NewGoRenderer(), llmClient, validator, capacityManager, ), }, nil } func fileSource(name string) (fs.FS, string, error) { cleanName := strings.TrimSpace(name) if cleanName == "" { return nil, "", ErrInvalidConfig } dir := filepath.Dir(cleanName) base := filepath.Base(cleanName) if base == "." || base == string(filepath.Separator) || strings.TrimSpace(base) == "" { return nil, "", ErrInvalidConfig } info, err := os.Stat(cleanName) if err != nil { return nil, "", fmt.Errorf("%w: failed to access source file %q: %v", ErrInvalidConfig, cleanName, err) } if info.IsDir() { return nil, "", fmt.Errorf("%w: source path %q must be a file", ErrInvalidConfig, cleanName) } return os.DirFS(dir), filepath.ToSlash(base), nil } // 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 source has one // selected ID; a nonempty version selects one exact ID/version pair. The // configured prompt source is used without merging, fallback, or enumeration. // // 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 definition and observed source state; 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. Filesystem-backed inspection is a // point-in-time lookup and does not freeze a definition for later execution. // // 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. Malformed, unreadable, duplicate, // ambiguous, referenced-content, or hashing failures match ErrPromptLoad. // Cancellation during lookup matches ErrPromptLoad while preserving 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.runner == nil { return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig) } inspection, err := e.runner.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 trims surrounding whitespace from profileID and looks up the // resulting nonblank ID exactly and case-sensitively through the engine's // ordinary in-memory, configured-source, and built-in profile precedence. It // applies framework defaults, the selected backend, and then the 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. Filesystem-backed inspection is a point-in-time lookup and // does not freeze the profile for a later execution. 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. Malformed or unreadable profile data, an unknown backend, // or an invalid resolved target matches ErrProfileLoad. Cancellation during // profile loading matches ErrProfileLoad while preserving 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.runner == nil { return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig) } inspection, err := e.runner.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. // // 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. func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, error) { if e == nil || e.runner == nil { return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig) } domainReq, err := toDomainRunRequest(req) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err) } prepared, err := e.runner.Prepare(ctx, domainReq) if err != nil { return nil, mapPublicError(err) } return fromDomainPreparedRun(prepared), nil } // 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. // // A content-validation failure is a successful run whose // RunResult.Validation has Status ValidationFailed. An inability to perform // validation returns an error matching ErrValidation and no partial result. // The public Engine does not perform output repair, so validation is // single-pass even when OutputContract.RepairAttempts is positive. // // Run can return every error category documented by [Engine.Prepare], plus // ErrCapacityExceeded and ErrLLMGenerate. ErrCapacityExceeded identifies // rejection before artifacts, schemas, rendering, or model generation because // the selected backend's admission capacity is full; it does not match // ErrInvalidRequest or ErrLLMGenerate. Errors from injected clients 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) { if e == nil || e.runner == nil { return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig) } domainReq, err := toDomainRunRequest(req) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err) } result, err := e.runner.Run(ctx, domainReq) if err != nil { return nil, mapPublicError(err) } return fromDomainRunResult(result), nil } // 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 }