diff --git a/backends.go b/backends.go new file mode 100644 index 0000000..5d3cb1b --- /dev/null +++ b/backends.go @@ -0,0 +1,54 @@ +package promptkit + +import ( + "gitea.maximumdirect.net/eric/promptkit/internal/backend" + "gitea.maximumdirect.net/eric/promptkit/internal/domain" +) + +// BackendOpenRouter identifies Promptkit's built-in OpenRouter backend. +const BackendOpenRouter = backend.OpenRouterID + +// Backend configures one engine-scoped OpenAI-compatible backend. +// +// Backend has no stable JSON representation. Use keyed literals so additions +// to this configuration value do not break source compatibility. +type Backend struct { + // ID is the stable, case-sensitive registry key. NewEngine trims it and + // requires a non-blank value. BackendOpenRouter is reserved. + ID string + // Endpoint is the OpenAI-compatible base endpoint. NewEngine trims it and + // requires an absolute HTTP or HTTPS URL with a host and without user + // information, a query string, or a fragment. Paths are allowed. + Endpoint string + // APIKeyEnv optionally names the environment variable containing the API + // key. NewEngine trims it and requires the portable form + // [A-Za-z_][A-Za-z0-9_]*. Store only the name, never a credential value. + APIKeyEnv string + // ExtraParams contains backend-wide request defaults. Values must be + // JSON-compatible, finite, acyclic, and keyed by non-empty strings. Keys + // must not be model, session_id, messages, temperature, max_tokens, top_p, + // service_tier, reasoning_effort, or response_format. An empty map supplies + // no defaults. NewEngine deeply copies the map. + ExtraParams map[string]any +} + +// WithBackend adds one Backend registration to the constructed Engine. +// +// Registrations accumulate in option order. Every normalized ID must be unique +// across consumer registrations and built-ins; a duplicate or invalid +// definition makes NewEngine fail with ErrInvalidConfig. In particular, +// BackendOpenRouter cannot be replaced. The immutable registration is scoped +// to the resulting Engine and cannot be enumerated, replaced, removed, or +// mutated after construction. WithBackend does not install package-global +// state. +func WithBackend(backend Backend) Option { + return optionFunc(func(options *engineOptions) error { + options.backends = append(options.backends, domain.Backend{ + ID: backend.ID, + Endpoint: backend.Endpoint, + APIKeyEnv: backend.APIKeyEnv, + ExtraParams: backend.ExtraParams, + }) + return nil + }) +} diff --git a/docs/consumers/pkg-promptkit.md b/docs/consumers/pkg-promptkit.md index 34ba6ff..aec262e 100644 --- a/docs/consumers/pkg-promptkit.md +++ b/docs/consumers/pkg-promptkit.md @@ -97,6 +97,32 @@ For programmatic profiles, [`OpenAICompatibleProfile`](../../profiles.go) converts ordinary OpenAI-compatible settings into a value accepted by `WithProfiles`. +### Register A Custom Backend + +Register a reusable OpenAI-compatible connection once, then select it from a +profile: + +```go +engine, err := promptkit.NewEngine(promptkit.Config{ + PromptDir: "prompts", +}, + promptkit.WithBackend(promptkit.Backend{ + ID: "local", + Endpoint: "http://localhost:8000/v1", + APIKeyEnv: "LOCAL_LLM_API_KEY", + }), + promptkit.WithProfiles(promptkit.Profile{ + ID: "local-summary", + BackendID: "local", + Model: "example-model", + }), +) +``` + +Registrations belong to one engine and custom IDs cannot replace built-ins. +The [`Backend` and `WithBackend` GoDoc](../../backends.go) defines validation, +copying, uniqueness, and request-default behavior. + ## Credentials File-backed profiles name an environment variable; in-memory profiles can diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 3e8b176..aacb68b 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -369,6 +369,8 @@ Do not duplicate every profile-parser validation case at the engine boundary. ## Stage 3 — Consumer Registration +**Status:** Complete. + ### Goal Expose the engine-scoped extension point for additional unique diff --git a/engine.go b/engine.go index 9548534..b1eb53b 100644 --- a/engine.go +++ b/engine.go @@ -14,6 +14,7 @@ import ( artifactadapter "gitea.maximumdirect.net/eric/promptkit/internal/artifact" "gitea.maximumdirect.net/eric/promptkit/internal/backend" "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" @@ -108,8 +109,10 @@ type Config struct { // NewEngine applies options in argument order and ignores nil options. Within // each prompt-source, profile-source, in-memory-profile, schema-source, // model-client, and artifact-reader category, the last non-nil valid option -// replaces earlier options in that category. An invalid option fails -// construction even if a later option would replace it. +// 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 } @@ -126,6 +129,7 @@ type engineOptions struct { promptDefs promptdef.Repository profiles profile.Repository memoryProfiles profile.Repository + backends []domain.Backend validator validate.Validator promptSource bool profileSource bool @@ -303,8 +307,9 @@ func WithSchemaFile(path string) Option { // // Options are applied in order according to [Option]. PromptDir is required // unless a prompt-source option is present. Construction validates option -// arguments and in-memory profiles but defers reading and validating prompt, -// file-backed profile, and schema contents until Prepare or Run needs them. +// 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 or options. It does not perform model requests or require @@ -336,7 +341,7 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) { profiles = profile.NewOverlayRepository(options.memoryProfiles, profiles) } - backendRegistry, err := backend.NewRegistry(nil) + backendRegistry, err := backend.NewRegistry(options.backends) if err != nil { return nil, fmt.Errorf("%w: failed to construct backend registry: %v", ErrInvalidConfig, err) } diff --git a/profiles.go b/profiles.go index 8d22f7b..13cda8a 100644 --- a/profiles.go +++ b/profiles.go @@ -6,14 +6,10 @@ import ( "fmt" "strings" - "gitea.maximumdirect.net/eric/promptkit/internal/backend" "gitea.maximumdirect.net/eric/promptkit/internal/domain" "gitea.maximumdirect.net/eric/promptkit/internal/profile" ) -// BackendOpenRouter identifies Promptkit's built-in OpenRouter backend. -const BackendOpenRouter = backend.OpenRouterID - // OpenAICompatibleProfile returns an ordinary in-memory Profile for an // OpenAI-compatible chat-completions endpoint. // diff --git a/public_contract_test.go b/public_contract_test.go index bcf0465..07fd3b9 100644 --- a/public_contract_test.go +++ b/public_contract_test.go @@ -83,6 +83,232 @@ func TestUnknownProfileBackendHasProfileLoadIdentity(t *testing.T) { } } +func TestCustomBackendFlowsThroughProfilesOverridesAndInjectedClient(t *testing.T) { + t.Setenv("CUSTOM_LLM_KEY", "test-key") + client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}} + engine, err := promptkit.NewEngine(promptkit.Config{}, + promptkit.WithPromptFS(contractPromptFS("prompt", "backend-profile", "message"), "."), + promptkit.WithBackend(promptkit.Backend{ + ID: " custom ", + Endpoint: " http://backend.example/v1 ", + APIKeyEnv: " CUSTOM_LLM_KEY ", + ExtraParams: map[string]any{ + "provider": "custom", + }, + }), + promptkit.WithProfiles( + promptkit.Profile{ID: "backend-profile", BackendID: "custom", Model: "backend-model"}, + promptkit.Profile{ID: "profile-endpoint", BackendID: "custom", Endpoint: "http://profile.example/v1", Model: "profile-model"}, + ), + promptkit.WithLLMClient(client), + ) + if err != nil { + t.Fatalf("construct engine: %v", err) + } + + result, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt"}) + if err != nil { + t.Fatalf("run with custom backend: %v", err) + } + if len(client.requests) != 1 { + t.Fatalf("expected one injected-client request, got %d", len(client.requests)) + } + target := client.requests[0].Target + if target.BackendID != "custom" || + target.Endpoint != "http://backend.example/v1" || + target.APIKeyEnv != "CUSTOM_LLM_KEY" || + target.Model != "backend-model" || + target.ExtraParams["provider"] != "custom" || + result.SelectedBackendID != "custom" { + t.Fatalf("unexpected custom backend settings: target=%+v result_backend=%q", target, result.SelectedBackendID) + } + + prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{ + PromptID: "prompt", ProfileID: "profile-endpoint", + }) + if err != nil { + t.Fatalf("prepare profile endpoint override: %v", err) + } + if prepared.SelectedBackendID != "custom" || prepared.EffectiveModelParams.Endpoint != "http://profile.example/v1" { + t.Fatalf("profile endpoint override changed backend identity: %+v", prepared) + } + + prepared, err = engine.Prepare(context.Background(), promptkit.RunRequest{ + PromptID: "prompt", + Execution: &promptkit.ExecutionTargetOverride{ + Endpoint: "http://request.example/v1", + }, + }) + if err != nil { + t.Fatalf("prepare request endpoint override: %v", err) + } + if prepared.SelectedBackendID != "custom" || prepared.EffectiveModelParams.Endpoint != "http://request.example/v1" { + t.Fatalf("request endpoint override changed backend identity: %+v", prepared) + } +} + +func TestCustomBackendSupportsFileProfileAndBothSelectionPaths(t *testing.T) { + engine, err := promptkit.NewEngine(promptkit.Config{}, + promptkit.WithPromptFS(contractPromptFS("prompt", "file-profile", "message"), "."), + promptkit.WithProfileFS(fstest.MapFS{ + "profile.yaml": &fstest.MapFile{Data: []byte(`id: file-profile +backend: file-backend +model: file-model +`)}, + }, "."), + promptkit.WithBackend(promptkit.Backend{ + ID: "file-backend", + Endpoint: "http://file-backend.example/v1", + }), + ) + if err != nil { + t.Fatalf("construct engine: %v", err) + } + + for _, request := range []promptkit.RunRequest{ + {PromptID: "prompt"}, + {PromptID: "prompt", ProfileID: "file-profile"}, + } { + prepared, err := engine.Prepare(context.Background(), request) + if err != nil { + t.Fatalf("prepare file profile: %v", err) + } + if prepared.SelectedBackendID != "file-backend" || + prepared.EffectiveModelParams.Endpoint != "http://file-backend.example/v1" { + t.Fatalf("unexpected file-profile backend resolution: %+v", prepared) + } + } +} + +func TestBackendOptionsAccumulateAndRegistrationsAreEngineLocal(t *testing.T) { + engine, err := promptkit.NewEngine(promptkit.Config{}, + promptkit.WithPromptFS(contractPromptFS("prompt", "first-profile", "message"), "."), + promptkit.WithBackend(promptkit.Backend{ID: "first", Endpoint: "http://first.example/v1"}), + promptkit.WithBackend(promptkit.Backend{ID: "second", Endpoint: "http://second.example/v1"}), + promptkit.WithProfiles( + promptkit.Profile{ID: "first-profile", BackendID: "first", Model: "model"}, + promptkit.Profile{ID: "second-profile", BackendID: "second", Model: "model"}, + ), + ) + if err != nil { + t.Fatalf("construct engine with accumulated registrations: %v", err) + } + for profileID, wantEndpoint := range map[string]string{ + "first-profile": "http://first.example/v1", + "second-profile": "http://second.example/v1", + } { + prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{ + PromptID: "prompt", ProfileID: profileID, + }) + if err != nil { + t.Fatalf("prepare %s: %v", profileID, err) + } + if prepared.EffectiveModelParams.Endpoint != wantEndpoint { + t.Fatalf("profile %s endpoint=%q, want %q", profileID, prepared.EffectiveModelParams.Endpoint, wantEndpoint) + } + } + + newEngine := func(endpoint string) *promptkit.Engine { + t.Helper() + value, err := promptkit.NewEngine(promptkit.Config{}, + promptkit.WithPromptFS(contractPromptFS("prompt", "profile", "message"), "."), + promptkit.WithBackend(promptkit.Backend{ID: "same-id", Endpoint: endpoint}), + promptkit.WithProfiles(promptkit.Profile{ID: "profile", BackendID: "same-id", Model: "model"}), + ) + if err != nil { + t.Fatalf("construct isolated engine: %v", err) + } + return value + } + firstEngine := newEngine("http://one.example/v1") + secondEngine := newEngine("http://two.example/v1") + for engine, wantEndpoint := range map[*promptkit.Engine]string{ + firstEngine: "http://one.example/v1", + secondEngine: "http://two.example/v1", + } { + prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "prompt"}) + if err != nil { + t.Fatalf("prepare isolated engine: %v", err) + } + if prepared.EffectiveModelParams.Endpoint != wantEndpoint { + t.Fatalf("isolated engine endpoint=%q, want %q", prepared.EffectiveModelParams.Endpoint, wantEndpoint) + } + } +} + +func TestBackendRegistrationRejectsInvalidAndDuplicateDefinitions(t *testing.T) { + cycle := map[string]any{} + cycle["self"] = cycle + tests := []struct { + name string + backends []promptkit.Backend + }{ + {name: "blank id", backends: []promptkit.Backend{{Endpoint: "http://example.test/v1"}}}, + {name: "invalid endpoint", backends: []promptkit.Backend{{ID: "custom", Endpoint: "ftp://example.test/v1"}}}, + {name: "invalid environment", backends: []promptkit.Backend{{ID: "custom", Endpoint: "http://example.test/v1", APIKeyEnv: "BAD-NAME"}}}, + {name: "reserved extra parameter", backends: []promptkit.Backend{{ID: "custom", Endpoint: "http://example.test/v1", ExtraParams: map[string]any{"model": "override"}}}}, + {name: "cyclic extra parameter", backends: []promptkit.Backend{{ID: "custom", Endpoint: "http://example.test/v1", ExtraParams: cycle}}}, + {name: "duplicate consumer id", backends: []promptkit.Backend{ + {ID: " custom ", Endpoint: "http://one.example/v1"}, + {ID: "custom", Endpoint: "http://two.example/v1"}, + }}, + {name: "reserved built-in id", backends: []promptkit.Backend{{ + ID: promptkit.BackendOpenRouter, Endpoint: "http://replacement.example/v1", + }}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + options := []promptkit.Option{ + promptkit.WithPromptFS(contractPromptFS("prompt", "profile", "message"), "."), + } + for _, backend := range tt.backends { + options = append(options, promptkit.WithBackend(backend)) + } + _, err := promptkit.NewEngine(promptkit.Config{}, options...) + if !errors.Is(err, promptkit.ErrInvalidConfig) { + t.Fatalf("expected ErrInvalidConfig, got %v", err) + } + }) + } +} + +func TestBackendExtraParamsAreDeeplyCopiedAtConstructionAndLookup(t *testing.T) { + nested := map[string]any{"value": "original"} + extraParams := map[string]any{"nested": nested} + engine, err := promptkit.NewEngine(promptkit.Config{}, + promptkit.WithPromptFS(contractPromptFS("prompt", "profile", "message"), "."), + promptkit.WithBackend(promptkit.Backend{ + ID: "custom", Endpoint: "http://example.test/v1", ExtraParams: extraParams, + }), + promptkit.WithProfiles(promptkit.Profile{ID: "profile", BackendID: "custom", Model: "model"}), + ) + if err != nil { + t.Fatalf("construct engine: %v", err) + } + nested["value"] = "mutated input" + extraParams["later"] = true + + prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "prompt"}) + if err != nil { + t.Fatalf("first prepare: %v", err) + } + gotNested := prepared.EffectiveModelParams.ExtraParams["nested"].(map[string]any) + if gotNested["value"] != "original" || prepared.EffectiveModelParams.ExtraParams["later"] != nil { + t.Fatalf("backend retained caller mutations: %#v", prepared.EffectiveModelParams.ExtraParams) + } + gotNested["value"] = "mutated lookup" + + prepared, err = engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "prompt"}) + if err != nil { + t.Fatalf("second prepare: %v", err) + } + gotNested = prepared.EffectiveModelParams.ExtraParams["nested"].(map[string]any) + if gotNested["value"] != "original" { + t.Fatalf("backend retained lookup mutation: %#v", prepared.EffectiveModelParams.ExtraParams) + } +} + func TestPreparedRunJSONTimingRoundTrips(t *testing.T) { start := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC) prepared := promptkit.PreparedRun{