22 KiB
Extensible LLM Backend Registry Implementation Plan
Purpose
This document is the decision-complete implementation plan for the extensible LLM backend registry. It is written for a coding agent that will implement each stage in order.
This is planning material, not a description of current behavior. During implementation, update current-state GoDoc and documentation only as the corresponding behavior lands. The feature roadmap owns the intended end state and policy choices; this document owns API shape, package changes, sequencing, tests, and completion gates.
Implementation Rules
- Complete the stages in order. Do not begin a later stage while an earlier stage's completion gate is unmet.
- Preserve unrelated working-tree changes and do not broaden the feature into concurrency control, queues, retries, provider discovery, or non-OpenAI-compatible transports.
- Keep the registry immutable and scoped to one
Engine. Do not add package-global registration or mutation after construction. - Keep secrets out of backend definitions, profiles, prepared values, results, hashes, logs, and JSON. Backends store only an environment-variable name.
- Preserve endpoint-only profiles and custom
LLMClientinjection. - Follow the architecture, documentation, and testing policies under
docs/policy/. In particular, keep the public API in the root package, place implementation packages underinternal/, update the component inventory when the new package lands, and assign each behavior to one durable test owner. - Use deterministic, offline tests. No stage may require a real provider, credential, or network service.
Fixed Design
Public API
Add the following root-package declarations:
const BackendOpenRouter = "openrouter"
type Backend struct {
ID string
Endpoint string
APIKeyEnv string
ExtraParams map[string]any
}
func WithBackend(backend Backend) Option
Backend and WithBackend have these contracts:
Backendconfigures one OpenAI-compatible backend and has no stable JSON representation.IDis trimmed, non-empty, case-sensitive, and is the stable registry key.Endpointis trimmed and must be an absolutehttporhttpsURL with a host. Paths are allowed; user information, query strings, and fragments are rejected because this value is a base endpoint rather than a complete request URL.APIKeyEnvis optional. When present, it is trimmed and must match the portable environment-variable form[A-Za-z_][A-Za-z0-9_]*.ExtraParamsfollows the existingProfile.ExtraParamsJSON-value rules: non-empty string keys, finite numbers, JSON-compatible scalar and container values, no cycles, and no collisions withmodel,session_id,messages,temperature,max_tokens,top_p,service_tier,reasoning_effort, orresponse_format. It is deeply copied duringNewEngine.- An empty
ExtraParamsmap means that the backend supplies no request defaults. - Each
WithBackendcall adds one registration. Calls with different IDs accumulate in option order; a repeated ID is an error rather than a replacement. This is an explicit additive exception to the last-option-wins categories documented onOption. BackendOpenRouteris reserved. Consumers cannot replace it.- There is no public enumerate, lookup, remove, replace, or post-construction registration API in this scope.
Extend the existing public values as follows:
| Value | Addition | Contract |
|---|---|---|
Profile |
BackendID string |
Optional backend selection. Endpoint is required only when BackendID is blank. |
OpenAICompatibleProfileConfig |
BackendID string |
Copied to Profile.BackendID. |
ExecutionTarget |
BackendID string \json:"backend_id,omitempty"`` |
Effective routing identity supplied to injected clients; empty for endpoint-only profiles. |
PreparedRun |
SelectedBackendID string \json:"selected_backend_id,omitempty"`` |
Equals the effective target backend ID. |
RunResult |
SelectedBackendID string \json:"selected_backend_id,omitempty"`` |
Carries the prepared backend identity through execution. |
Do not add a backend field to RunRequest or ExecutionTargetOverride.
Backend selection remains a profile concern. Endpoint overrides do not change
the selected backend identity.
Trim Profile.BackendID and YAML backend values at their respective
conversion/load boundaries. Treat an all-whitespace value as absent and expose
only the trimmed value in effective targets and metadata.
These exported struct fields are intentionally additive. Adding fields can break downstream unkeyed composite literals even though keyed literals and ordinary field access remain source-compatible. Accept that narrow risk; use and document keyed literals, and do not add parallel wrapper types or a second configuration path solely to preserve unkeyed literals.
Profile Format
Add the optional strict-YAML field:
backend: openrouter
The profile connection rule becomes:
modelremains required;- at least one of
backendorendpointis required; - when both are present, the profile endpoint overrides the backend endpoint; and
- a profile with only an endpoint continues through the legacy path and has an empty effective backend ID.
Do not infer a backend from a model name or endpoint. Do not validate whether a backend ID exists while decoding a profile; registry membership is engine-scoped and is checked when the selected profile is prepared.
Registry And Internal Boundaries
Add internal/backend as the cohesive owner of:
- the immutable backend registry;
- backend-definition validation and defensive copying;
- the built-in OpenRouter definition; and
- the internal not-found error used when a selected ID is absent.
Add an internal domain backend value containing ID, Endpoint, APIKeyEnv,
and ExtraParams. The registry constructor accepts consumer additions, installs
built-ins first, rejects all collisions, and returns a fully constructed
read-only registry. Lookup returns a defensive value so callers cannot mutate
registry-owned maps.
Use these internal declarations:
var ErrBackendNotFound = errors.New("backend not found")
func NewRegistry(additions []domain.Backend) (*Registry, error)
func (r *Registry) GetBackend(id string) (domain.Backend, error)
internal/usecase owns the narrow resolver interface it consumes:
type BackendResolver interface {
GetBackend(string) (domain.Backend, error)
}
The concrete registry implements this interface. Supply it explicitly to
Runner through both runner constructors and from the root engine assembly.
A nil resolver must never panic; if an internally constructed runner selects a
backend without a resolver, preparation fails as a profile-load failure.
The built-in definition is exactly:
| Field | Value |
|---|---|
| ID | openrouter |
| Endpoint | https://openrouter.ai/api/v1 |
| API key environment variable | OPENROUTER_API_KEY |
| Extra parameters | none |
Define OpenRouterID as a constant in internal/backend and define the public
BackendOpenRouter constant from it so there is one canonical literal. This
root-to-internal implementation import does not expose an internal type in a
public signature.
Resolution
Resolve an execution target in this order:
- framework defaults;
- the selected backend, if any;
- the selected profile; and
- the request's
ExecutionTargetOverride.
The layers behave as follows:
| Setting | Backend layer | Profile layer | Request layer |
|---|---|---|---|
| Backend ID | Supplies selected ID | Selects the backend | Cannot change it |
| Endpoint | Supplies default | Non-empty value replaces backend | Non-empty value replaces profile/backend |
| Model and generation fields | Not supplied | Existing profile behavior | Existing override behavior |
| API-key environment name | Supplies default | Non-empty api_key_env replaces it |
Non-empty APIKeyEnv replaces it |
| Extra parameters | Non-empty map replaces prior map | Non-empty map replaces complete backend map | Non-empty map replaces complete profile/backend map |
Never deep-merge ExtraParams maps. A non-empty higher-precedence map replaces
the complete lower-precedence map. Preserve the existing distinction between a
nil or empty override map and a supplied non-empty replacement.
Credential handling remains:
- a non-blank
RunRequest.APIKeywins and suppresses environment lookup; - a non-blank request
APIKeyEnvwins over profile and backend metadata; - file-profile
api_key_envwins over the backend default; - backend
APIKeyEnvis used when no higher layer supplies a credential source; and - a profile with
APIKeyRequired: trueclears an inherited backend environment name and requires a direct key unless the request explicitly supplies its ownAPIKeyEnv.
Only the environment-variable name appears in effective targets. Preserve the current just-in-time environment lookup and missing-variable behavior.
Errors
Do not add a public backend-specific error sentinel.
| Failure | Required public identity |
|---|---|
| Invalid consumer backend definition | ErrInvalidConfig from NewEngine |
| Consumer ID duplicates another consumer ID | ErrInvalidConfig |
Consumer ID collides with BackendOpenRouter |
ErrInvalidConfig |
| Selected profile names an unknown backend | ErrProfileLoad, not ErrProfileNotFound or ErrInvalidRequest |
| File profile lacks both backend and endpoint | ErrProfileLoad |
| In-memory profile lacks both backend and endpoint | ErrInvalidConfig |
| Effective credential environment variable is unset | Existing ErrAPIKeyEnvMissing and ErrInvalidRequest identities |
| Other invalid per-run effective settings | Existing ErrInvalidRequest |
Error text must include the offending backend ID or field where useful, but exact prose is not a compatibility contract. Preserve wrapped internal causes where the existing error boundary permits it.
Metadata And Transport
- Set
ExecutionTarget.BackendID,PreparedRun.SelectedBackendID, andRunResult.SelectedBackendIDfrom the normalized selected profile backend. - Endpoint-only profiles leave all three values empty.
- Pass
BackendIDthroughGenerateRequest.Targetand repair targets so an injected client can route or observe it. - The built-in OpenAI-compatible client must not serialize
backend_idas a provider request field. It continues to use the effective endpoint, credential, typed generation fields, and extra parameters. - Backend selection does not change rendered-prompt hashes, prompt hashes,
session IDs, validation, artifacts, timing, or synchronous
PrepareandRunbehavior.
Stage 1 — Registry Foundation
Status: Complete.
Goal
Introduce the internal domain and immutable registry without changing the public configuration or profile format.
Work
- Add the internal backend definition to
internal/domain. - Create
internal/backendwith:- a registry constructor that installs OpenRouter and accepts additional internal definitions;
- validation for IDs, endpoints, environment-variable names, JSON values, and reserved request fields;
- deterministic built-in and consumer collision handling;
- immutable lookup with defensive map copying; and
- an internal recognizable not-found error.
- Reuse or extract one validation rule for OpenAI-compatible reserved request
fields so registry validation and
internal/llmpayload construction cannot drift. Do not maintain duplicate reserved-key lists. - Keep JSON-value copying type-preserving. Do not use a marshal/unmarshal round trip that silently changes integer or container types.
- Update
docs/policy/architecture.mdanddocs/internal/overview.mdonly after the package exists, describinginternal/backendas an implemented immutable configuration registry rather than a runtime service manager.
Tests
internal/backend owns focused package tests for:
- the exact OpenRouter built-in values;
- successful unique consumer additions;
- built-in and consumer duplicate rejection;
- blank and whitespace-normalized IDs;
- invalid or non-HTTP(S) endpoints;
- invalid environment-variable names;
- invalid, cyclic, non-finite, empty-key, and reserved-key extra parameters;
- not-found lookup; and
- mutation isolation of input maps and returned values.
Use one representative table per validation family rather than one test per branch. A race-specific registry test is unnecessary if the registry is immutable and the final repository race suite exercises concurrent reads.
Completion Gate
- No public declaration or profile behavior has changed.
- The registry has no mutating method after construction.
- The built-in definition contains no credential value.
- Focused backend tests and the complete repository validation pass.
Stage 2 — Profile Selection And Effective Resolution
Status: Complete.
Goal
Make the built-in OpenRouter backend selectable by file and in-memory profiles, migrate the built-in profiles, and expose effective backend identity.
Work
- Add
BackendIDto internal execution profiles and targets and add the YAML keybackend. - Change file and public in-memory profile validation to require
modelplus at least one ofbackendorendpoint. Preserve all existing numeric and extra-parameter validation. - Add
BackendIDtoProfileandOpenAICompatibleProfileConfig, and addBackendOpenRouter. - Add backend identity to the public and internal execution target, prepared run, and run result exactly as specified under Public API.
- Add the
BackendResolverdependency immediately after the profile repository in both runner constructors and update all internal call sites and test helpers explicitly. - Resolve the selected backend after profile loading and before effective
target resolution. Map lookup failure to
ErrProfileLoad. - Implement the exact endpoint, credential, and whole-map precedence rules under Resolution. Keep backend ID independent from endpoint overrides.
- Construct the built-in-only registry in
NewEngineand inject it into the runner. - Replace
endpointandapi_key_envin everyinternal/profile/builtin/assets/*.yamlprofile withbackend: openrouter. Retain each profile's model and generation settings. - Update conversions and copying so backend IDs survive every public/internal boundary and registry-owned maps remain isolated.
- Update the GoDoc for all changed public declarations.
- Update
docs/formats.md,docs/internal/sources.md, anddocs/internal/runner.mdwith the implemented profile field, conditional endpoint rule, built-in selection, and effective resolution behavior.
Tests
Assign test ownership as follows:
internal/profiletests own strict YAML decoding and the conditional backend-or-endpoint validation matrix.internal/profile/builtintests own the invariant that every built-in profile selectsopenrouterand no longer repeats its endpoint or credential environment name.internal/usecasetests own backend/profile/request precedence, unknown-ID error wrapping, endpoint override identity, credential precedence includingAPIKeyRequired, and whole-map replacement.- Root external-package contract tests own the new fields, JSON names and omission behavior, public copying, and public error identities.
- One assembled engine test must prove a built-in profile prepares with the expected backend ID, endpoint, and environment-variable name without making an HTTP call.
Do not duplicate every profile-parser validation case at the engine boundary.
Completion Gate
- All built-in and endpoint-only profiles prepare successfully.
- A selected unknown backend matches public
ErrProfileLoad. - Backend identity reaches prepared values, results, generation targets, and repair targets.
- No backend ID or secret is added to the outbound provider payload.
- Current-state format and internal documentation match the implemented built-in behavior.
- The complete repository validation passes.
Stage 3 — Consumer Registration
Status: Complete.
Goal
Expose the engine-scoped extension point for additional unique OpenAI-compatible backend IDs.
Work
- Add
BackendandWithBackendexactly as specified under Public API, preferably in a cohesive rootbackends.gofile. - Convert, validate, normalize, and deeply copy public backend values during
NewEngine. Store pending additions inengineOptions; do not mutate a registry from option application. - After all options have applied, construct one immutable registry from the
built-in and pending consumer definitions. Map every construction failure
to
ErrInvalidConfig. - Make unique
WithBackendcalls additive. Reject duplicate IDs even when they arrive through separate calls, and document this exception onOptionandWithBackend. - Make registered backends available equally to directory,
fs.FS, single file, in-memory, prompt-default, and explicit request profile selection. - Preserve the last-valid-option-wins behavior of every existing option category.
- Update
docs/consumers/pkg-promptkit.mdwith one minimal custom backend and profile example. Keep exact field-by-field API detail in GoDoc and link to it rather than duplicating it.
Tests
Root public contract tests own:
- one custom backend used by an in-memory profile;
- one custom backend used by a file-backed profile;
- multiple unique
WithBackendcalls accumulating; - duplicate consumer and built-in ID failures matching
ErrInvalidConfig; - invalid public values and nested-map mutation isolation;
- endpoint-only compatibility with no
WithBackendoption; - profile endpoint and request endpoint overrides retaining the custom backend ID; and
- an injected
LLMClientobserving the effective custom backend ID and settings.
Use an injected client or httptest.Server; never contact the configured
external endpoint. Keep lower-level registry validation cases in
internal/backend.
Completion Gate
- Consumers can add only new IDs and cannot mutate or replace built-ins.
- Registration is engine-local and two engines can use different definitions for the same consumer ID without interference.
- Existing option-category contract tests still pass unchanged except for the documented additive backend case.
- The consumer guide and GoDoc describe the implemented extension point.
- The complete repository validation passes.
Stage 4 — Contract Hardening And Documentation Completion
Status: Complete.
Goal
Audit the completed feature across public, profile, use-case, and transport boundaries and make the current-state documentation self-sufficient.
Work
- Review the preceding tests as a suite. Remove redundant cases and retain a lean precedence matrix that would catch layer-order, credential, copying, compatibility, and error-identity regressions.
- Add or adjust one built-in-client transport test proving that effective
backend defaults reach the configured endpoint while
backend_idis not serialized. Usehttptest.Serverand synthetic credentials. - Confirm public stable-JSON round trips for
ExecutionTarget,PreparedRun, andRunResult, including empty backend omission for legacy profiles. - Confirm concurrent
PrepareandRuncalls can read the immutable registry under the race detector. Do not add concurrency limiting or a queue. - Update all affected current-state owners:
- public GoDoc for the exact API and errors;
docs/formats.mdfor profile YAML and built-ins;docs/consumers/pkg-promptkit.mdfor construction and use;docs/integrations/openai-compatible-chat.mdfor resolved endpoint, credentials, request defaults, and the non-serialized routing identity;docs/internal/overview.md,docs/internal/sources.md,docs/internal/runner.md, anddocs/internal/llm.mdfor package responsibilities and data flow; anddocs/policy/architecture.mdonly to keep its implemented component inventory and dependency description accurate.
- Search current-state documentation for obsolete statements that every profile requires an endpoint or that every built-in repeats OpenRouter connection settings. Update the canonical owner and replace duplicates with links.
- Keep future concurrency and queue behavior only in the future feature catalog; do not imply that this feature implements either capability.
Final Validation
Run the complete maintainer sequence from the repository root:
go test ./...
go test -race ./...
go vet ./...
go build ./...
go run ./examples/go-library/prepare
gofmt -l $(git ls-files '*.go')
git diff --check
The formatting command must produce no paths. Follow every changed Markdown
link and confirm its target exists. Inspect the final diff for raw credentials,
global mutable state, generated workspace files, local replace directives,
and unrelated changes.
Completion Gate
- Every target-end-state item in the feature roadmap is implemented and protected at its owning boundary.
- The public API is limited to the fixed declarations in this plan.
- Endpoint-only profiles and injected clients remain compatible.
- Built-in profiles obtain OpenRouter endpoint and credential metadata only through the registry.
- Current-state documentation no longer relies on either roadmap to explain implemented behavior.
- All final validation commands pass offline.
Open Questions
None. The API shape, validation, precedence, compatibility behavior, error identities, package ownership, test ownership, and staging required for implementation are fixed by this plan.