package llm import ( "context" "errors" "fmt" "io/fs" "net/http" "path/filepath" "strings" "sync/atomic" ck "gitea.maximumdirect.net/eric/ConfigKit" "gitea.maximumdirect.net/eric/audita/internal/core/config" "gitea.maximumdirect.net/eric/audita/internal/core/redaction" "gitea.maximumdirect.net/eric/audita/internal/framework/responseschema" "gitea.maximumdirect.net/eric/audita/internal/prompts" pk "gitea.maximumdirect.net/eric/promptkit" ) // Runtime owns one PromptKit engine and the validated selections for both roles. // The same engine is shared by the role-bound clients during processing. type Runtime struct { Config config.Config Engine *pk.Engine Profiles map[string]pk.ProfileInspection // ProfilePaths contains filesystem sources actually read by PromptKit. ProfilePaths []string nextCallID atomic.Uint64 } // AssetError indicates a broken embedded prompt or schema rather than an // operator-provided setting or profile. type AssetError struct{ Err error } func (e *AssetError) Error() string { return e.Err.Error() } func (e *AssetError) Unwrap() error { return e.Err } func NewRuntime(ctx context.Context, c config.Config, mainPath string, client *http.Client) (*Runtime, error) { return newRuntimeWithPromptFS(ctx, c, mainPath, client, prompts.Filesystem()) } func newRuntimeWithPromptFS(ctx context.Context, c config.Config, mainPath string, client *http.Client, promptFS fs.FS) (*Runtime, error) { if err := ctx.Err(); err != nil { return nil, err } profileDir := c.ProfilesDir if !filepath.IsAbs(profileDir) { profileDir = filepath.Join(filepath.Dir(mainPath), profileDir) } promptInputs, err := pk.FilesystemInputs("Audita embedded prompts", promptFS, prompts.AssetRoot) if err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return nil, ctxErr } return nil, &AssetError{Err: fmt.Errorf("discover embedded prompts: %w", err)} } profileInputs, err := pk.DirectoryInputs(profileDir) if err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return nil, ctxErr } return nil, fmt.Errorf("discover operator definitions: %w", err) } if err := ctx.Err(); err != nil { return nil, err } applicationBackends := make(map[string]any, len(c.Backends)) for id := range c.Backends { backend := c.Backends[id] applicationBackends[id] = map[string]any{ "endpoint": backend.Endpoint, "concurrency_limit": backend.Concurrency, "queue_capacity": backend.QueueCapacity, "api_key_env": nil, } } sources := pk.ConfigurationSources{ Underlays: []ck.Underlay{ {Inputs: promptInputs}, {Inputs: profileInputs}, }, Main: &ck.Input{Source: ck.Mapping("application backends", map[string]any{"backends": applicationBackends})}, } engine, err := pk.NewEngine(pk.Config{ Sources: sources, HTTPClient: client, TransientRetries: &c.LLM.TransientRetries, }, pk.WithSchemaFS(responseschema.Filesystem(), responseschema.AssetRoot)) if err != nil { return nil, embeddedAssetOrInputError(ctx, promptInputs, fmt.Errorf("assemble LLM runtime: %w", err)) } for _, meta := range prompts.RegisteredMetadata() { if _, err := engine.InspectPrompt(ctx, meta.PromptID, meta.PromptVersion); err != nil { cause := fmt.Errorf("inspect prompt %s: %w", meta.PromptID, err) return nil, embeddedAssetOrInputError(ctx, promptInputs, cause) } } runtime := &Runtime{Config: c, Engine: engine, Profiles: make(map[string]pk.ProfileInspection, 2)} for _, selection := range []struct{ role, id string }{{"proposal", c.LLM.ProposalProfile}, {"validation", c.LLM.ValidationProfile}} { profile, err := engine.InspectProfile(ctx, selection.id) if err != nil { return nil, fmt.Errorf("inspect %s profile: %w", selection.role, err) } target := profile.EffectiveModelParams backend, ok := c.Backends[target.BackendID] if !ok { return nil, fmt.Errorf("%s profile must select a registered application backend", selection.role) } if strings.TrimSpace(target.Model) == "" { return nil, fmt.Errorf("%s profile must select a nonblank model", selection.role) } if strings.TrimSpace(target.Endpoint) == "" { return nil, fmt.Errorf("%s profile must resolve to a nonblank endpoint", selection.role) } endpoint := func(value string) string { return strings.TrimRight(strings.TrimSpace(value), "/") } if endpoint(target.Endpoint) != endpoint(backend.Endpoint) { return nil, fmt.Errorf("%s profile endpoint must match its registered backend", selection.role) } if target.APIKeyEnv != "" { return nil, fmt.Errorf("%s profile must use the backend credential, not its own environment lookup", selection.role) } if profile.APIKeyRequired && backend.Credential == nil { return nil, fmt.Errorf("%s profile requires a backend credential", selection.role) } runtime.Profiles[selection.role] = *profile } return runtime, nil } func embeddedAssetOrInputError(ctx context.Context, promptInputs []ck.Input, cause error) error { if err := ctx.Err(); err != nil { return err } embeddedSources := pk.ConfigurationSources{ Underlays: []ck.Underlay{{Inputs: promptInputs}}, } diagnosticEngine, err := pk.NewEngine(pk.Config{Sources: embeddedSources}, pk.WithSchemaFS(responseschema.Filesystem(), responseschema.AssetRoot)) if err == nil { for _, meta := range prompts.RegisteredMetadata() { if _, inspectErr := diagnosticEngine.InspectPrompt(ctx, meta.PromptID, meta.PromptVersion); inspectErr != nil { err = fmt.Errorf("inspect embedded prompt %s: %w", meta.PromptID, inspectErr) break } } } if ctxErr := ctx.Err(); ctxErr != nil { return ctxErr } if err != nil { return &AssetError{Err: fmt.Errorf("validate embedded prompt assets: %w", err)} } return cause } func IsAssetError(err error) bool { var target *AssetError; return errors.As(err, &target) } // RedactError replaces every configured resolved backend secret before a // library error is surfaced in CLI output or an artifact. func RedactError(err error, c config.Config) string { if err == nil { return "" } return redaction.New(ConfiguredSecrets(&c)).String(err.Error()) }