Add engine-scoped backend registration

This commit is contained in:
2026-07-29 17:15:51 +00:00
parent 810f80e7c9
commit ae210b3c26
6 changed files with 318 additions and 9 deletions

54
backends.go Normal file
View File

@@ -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
})
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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.
//

View File

@@ -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{