Add engine-scoped backend registration
This commit is contained in:
54
backends.go
Normal file
54
backends.go
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
15
engine.go
15
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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user