diff --git a/backends.go b/backends.go index 8ae129b..354d44b 100644 --- a/backends.go +++ b/backends.go @@ -9,6 +9,11 @@ import ( // backend. const BackendOpenRouter = backend.OpenRouterID +// BackendLocal is the case-sensitive conventional ID used by [LocalBackend]. +// It is not a built-in or reserved backend and must be registered with +// [WithBackend]. +const BackendLocal = "local" + // Backend configures one engine-scoped OpenAI-compatible backend. // // Backend has no stable JSON representation. Use keyed literals so additions @@ -44,6 +49,26 @@ type Backend struct { QueueCapacity *int } +// LocalBackend returns a caller-owned Backend for a conventional local +// OpenAI-compatible endpoint. It sets ID to BackendLocal and copies endpoint +// and concurrencyLimit into Endpoint and ConcurrencyLimit without +// normalization or validation. APIKeyEnv, ExtraParams, and QueueCapacity keep +// their zero values. +// +// LocalBackend does not read environment variables, register the value, or +// mutate engine or package state. Supply the returned value through +// [WithBackend]; [NewEngine] then applies the ordinary backend validation and +// concurrency semantics, including default queue capacity for a positive +// limit, unlimited behavior for zero, and ErrInvalidConfig for a negative +// limit. +func LocalBackend(endpoint string, concurrencyLimit int) Backend { + return Backend{ + ID: BackendLocal, + Endpoint: endpoint, + ConcurrencyLimit: concurrencyLimit, + } +} + // WithBackend adds one Backend registration to the constructed Engine. // // Registrations accumulate in option order. Every normalized ID must be unique diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 0806cf7..dad477e 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,769 +1,315 @@ -# Backend-Specific Concurrency Management Implementation Plan +# Local Backend Convenience Implementation Plan -**Status:** Complete. +**Status:** Ready for implementation. ## Purpose This document is the decision-complete implementation plan for -[backend-specific concurrency management](concurrency.md). It is written for a -coding agent that will implement each stage in order. +[local backend convenience](local-backend.md). It is written for a coding +agent that will implement each stage in order. -The feature roadmap owns the intended capability, consumer value, policy -choices, compatibility decision, and target end state. This document owns the -concrete API, internal representation, scheduling architecture, implementation -sequence, test ownership, documentation updates, and completion gates. +The feature roadmap owns the motivation, consumer paths, policy choices, +compatibility requirements, non-goals, and target end state. This document +owns the exact proposed API, file-level changes, implementation sequence, test +ownership, documentation work, validation commands, and completion gates. ## Implementation Rules -- Complete the stages in order. Keep the repository compiling and the focused - tests passing at every stage boundary. -- Preserve unrelated working-tree changes. In particular, `concurrency.md` and - the removal of its source idea from `future.md` may already be uncommitted - when implementation begins; retain both. +- Complete the stages in order. Keep the root package compiling and its + focused tests passing at every stage boundary. +- Preserve unrelated working-tree changes. The feature roadmap may already be + uncommitted when implementation begins; retain it. - Follow every policy under `docs/policy/`, the task-specific reading guide in - `docs/development.md`, and the target behavior in `concurrency.md`. -- Keep the public API in the root `promptkit` package and implementation - details under `internal/`. Do not expose scheduler types or create another - public package. -- Use only the Go standard library for scheduling. Do not add a queue, - semaphore, worker-pool, or metrics dependency. -- Preserve synchronous, wait-for-result `Run`, unrestricted `Prepare`, - engine-local state, endpoint-only profiles, backend-selected profiles, - backend identity through endpoint overrides, and injected `LLMClient` - behavior. -- Do not broaden the work into asynchronous jobs, durable queues, retries, - rate limiting, dynamic configuration, priorities, worker lifecycle, - endpoint-keyed pools, or public queue observability. -- Keep all tests deterministic, bounded, offline, and race-safe. Coordinate - concurrent tests with channels and barriers rather than timing assumptions - or live providers. -- Update exact GoDoc with each exported declaration change. Update durable - current-state documents only after the corresponding behavior is - implemented. -- Test configurable mechanisms with small test-owned limits. Assert the exact - OpenRouter `16` and default queue `1024` values only at the registry contract - that owns those operational defaults. -- Do not create a release, change a module version, or tag a commit. The final - implementation handoff must identify the built-in OpenRouter behavior change - for the next pre-`v1` minor release. + `docs/development.md`, and the accepted behavior in + `local-backend.md`. +- Keep the API in the root `promptkit` package. Do not add a public or internal + package for this feature. +- Implement the helper as a transparent constructor for the existing + `Backend` type. Do not add a second backend representation or bypass + `WithBackend`. +- Leave normalization, validation, copying, registry construction, capacity + defaulting, and duplicate detection in their existing owners. +- Do not add environment-variable discovery, package-global registration, + implicit local defaults, model selection, profile construction, or support + for non-OpenAI-compatible transports. +- Keep tests lean and behavior-focused. Do not duplicate the existing + registry and concurrency test matrices merely because the constructor + reaches those mechanisms. +- Update exact GoDoc with the exported declarations. Update current-state + consumer guidance only after the corresponding API is implemented. +- Do not update release notes, create a release, change a module version, or + tag a commit as part of this work. ## Fixed Design -### Public Backend Configuration +### Exported API -Append these fields to the existing root `Backend` type in `backends.go`: +Add these declarations to `backends.go` in package `promptkit`: ```go -type Backend struct { - // Existing fields remain unchanged and in their current order. +// BackendLocal is the conventional ID used by LocalBackend. It is not a +// built-in or reserved backend and must be registered with WithBackend. +const BackendLocal = "local" - ConcurrencyLimit int - QueueCapacity *int +// LocalBackend returns a Backend for a conventional local OpenAI-compatible +// endpoint. +func LocalBackend(endpoint string, concurrencyLimit int) Backend { + return Backend{ + ID: BackendLocal, + Endpoint: endpoint, + ConcurrencyLimit: concurrencyLimit, + } } ``` -Use these exact semantics: +The exact GoDoc may be wrapped or expanded for clarity, but it must own and +communicate all of these contract points: -| Public values | Meaning | -| --- | --- | -| `ConcurrencyLimit == 0`, `QueueCapacity == nil` | Unlimited backend; preserve current behavior. | -| `ConcurrencyLimit > 0`, `QueueCapacity == nil` | Limit active generations and use the default waiting capacity of 1024. | -| `ConcurrencyLimit > 0`, `QueueCapacity != nil` | Limit active generations and use the pointed-to capacity exactly, including zero. | -| `ConcurrencyLimit < 0` | Invalid engine configuration. | -| `QueueCapacity != nil` and `*QueueCapacity < 0` | Invalid engine configuration. | -| `ConcurrencyLimit == 0` and `QueueCapacity != nil` | Invalid engine configuration because a queue without an active limit has no defined consumer value. | +- `BackendLocal` is the case-sensitive conventional ID `"local"`; +- it is neither built in nor reserved; +- calling `LocalBackend` does not register anything; +- the returned value must be supplied through `WithBackend`; +- `endpoint` and `concurrencyLimit` are copied into the corresponding fields + without normalization or validation; +- `APIKeyEnv`, `ExtraParams`, and `QueueCapacity` retain their zero values; and +- normal `NewEngine` backend validation and concurrency semantics apply after + registration. -`ConcurrencyLimit` counts simultaneous calls to the engine-owned internal -model-client boundary for this backend. `QueueCapacity` controls additional -accepted `Run` invocations beyond that limit. The maximum admitted runs for a -limited backend is therefore: +Keep `BackendOpenRouter` unchanged. `BackendLocal` must not alias an internal +registry constant because the internal registry has no special local-backend +identity or behavior. -```text -ConcurrencyLimit + effective QueueCapacity -``` +Place `BackendLocal` near `BackendOpenRouter`, and place `LocalBackend` after +the `Backend` declaration and before `WithBackend`. This keeps the conventional +IDs, configured value, convenience constructor, and registration option +discoverable in one file. -Guard that addition against integer overflow during backend validation. -Do not impose an arbitrary upper bound beyond non-negativity and overflow -safety. +### Constructor Semantics -The `QueueCapacity` pointer exists only to distinguish omission from explicit -zero. `WithBackend` and `NewEngine` must not retain the caller's pointer. -`Backend` continues to have no stable JSON representation, and consumers -remain directed to keyed literals. +`LocalBackend` is a pure struct constructor. Its complete behavior is +equivalent to the keyed literal shown above. -Do not add concurrency fields to `Profile`, `ExecutionTarget`, -`ExecutionTargetOverride`, `RunRequest`, prompt or profile files, or stable -prepared/result JSON. +In particular, the constructor must not: -### Built-In And Custom Defaults +- trim or parse the endpoint; +- reject blank endpoints or negative limits; +- choose a default limit; +- assign an API-key environment variable; +- allocate an empty `ExtraParams` map; +- assign a queue-capacity pointer; +- read process environment; +- mutate package-global or engine state; or +- call `WithBackend` itself. -The backend registry owns these exact operational defaults: +Deferred validation is intentional. It keeps one validation path for all +`Backend` values: `WithBackend` copies the public value into engine options, +and `NewEngine` constructs the validated immutable registry. A positive limit +with a nil queue continues to select the existing default queue capacity of +1024; zero continues to mean unlimited; a negative value continues to make +`NewEngine` fail with `ErrInvalidConfig`. + +The returned `Backend` remains an ordinary caller-owned value. Consumers can +modify it before passing it to `WithBackend`, although task-oriented +documentation should direct materially customized configurations to an +explicit keyed `Backend` literal. + +### Identity And Compatibility + +Do not add `"local"` to the internal built-in or reserved ID set. A consumer +must be able to register either: ```go -const ( - openRouterConcurrencyLimit = 16 - defaultQueueCapacity = 1024 -) +promptkit.LocalBackend(endpoint, limit) ``` -The built-in `openrouter` definition has a normalized concurrency limit of 16 -and queue capacity of 1024. - -Consumer registrations remain unlimited when concurrency is omitted. For a -consumer backend with a positive limit and omitted queue capacity, normalize -the queue capacity to 1024. Preserve an explicitly configured zero. - -Consumers still cannot replace the reserved `openrouter` registration. -Endpoint-only profiles have no backend policy and remain unlimited. A selected -backend retains its pool when a profile or request overrides only its endpoint. - -### Internal Backend Representation - -Extend `internal/domain.Backend` with scalar policy values and explicit -presence rather than retaining a pointer: +or: ```go -type Backend struct { - // Existing fields... - ConcurrencyLimit int - QueueCapacity int - QueueCapacitySet bool -} - -type BackendCapacityPolicy struct { - ConcurrencyLimit int - QueueCapacity int +promptkit.Backend{ + ID: promptkit.BackendLocal, + Endpoint: endpoint, } ``` -`WithBackend` converts the public pointer into `QueueCapacity` plus -`QueueCapacitySet`. Registry normalization validates the combinations above, -fills the default, and leaves every limited stored backend with -`QueueCapacitySet == true`. Unlimited stored backends retain zero values and -`QueueCapacitySet == false`. +through the existing `WithBackend` option. Both forms participate in ordinary +duplicate-ID detection. Existing consumers that already use the literal ID +`"local"` remain source- and behavior-compatible. -Add this internal registry method: +No existing `Backend`, `WithBackend`, profile, registry, execution-target, or +capacity semantics change. Do not modify `internal/backend`, +`internal/capacity`, `internal/domain`, `engine.go`, `profiles.go`, or +`types.go` for this feature. + +### Test Ownership + +The root external-package contract suite in `public_contract_test.go` owns the +new public behavior. Add one focused test named: ```go -func (r *Registry) CapacityPolicies() map[string]domain.BackendCapacityPolicy +func TestLocalBackendConstructsAndRegistersConventionalBackend(t *testing.T) ``` -It returns a newly allocated map containing only limited backends. Values are -scalars, so callers cannot mutate registry state. The built-in OpenRouter -policy is included. `GetBackend` continues returning a defensive backend copy, -now including normalized scalar capacity metadata. - -Capacity policy is operational registry metadata. Do not merge it into an -execution target or expose it to injected model clients. - -### Public Capacity Error - -Add this root sentinel beside the other run errors in `engine.go`: - -```go -var ErrCapacityExceeded = errors.New("backend capacity exceeded") -``` - -Its GoDoc must state that it identifies a `Run` rejected because the selected -backend has already admitted `ConcurrencyLimit + QueueCapacity` runs. It is -not an invalid request, an LLM/provider rate-limit response, or an -`ErrLLMGenerate` failure. - -The internal capacity component owns a corresponding internal -`ErrCapacityExceeded`. Add its mapping in `publicErrorFor` before the broader -generation and invalid-request cases. The public error must preserve the -internal error through wrapping while matching `ErrCapacityExceeded` with -`errors.Is`. - -A capacity rejection returns no partial result and must not invoke the -artifact reader, renderer, schema loader, validator, or model client. Prompt, -profile, and backend loading needed to select the pool may already have -occurred. - -### Internal Capacity Component - -Add `internal/capacity` as the single owner of engine-local run admission and -active-generation permits. - -Use these package-level boundaries: - -```go -var ErrCapacityExceeded error - -type Manager struct { - // Private immutable pool map. -} - -func NewManager( - policies map[string]domain.BackendCapacityPolicy, -) (*Manager, error) - -func (m *Manager) Admit( - ctx context.Context, - backendID string, -) (release func(), err error) - -func NewClient(m *Manager, next llm.Client) llm.Client -``` - -`NewManager` copies the supplied map and creates one independent pool per -limited backend. Defensively reject blank IDs, non-positive concurrency -limits, negative queue capacities, or total-capacity overflow even though the -registry normally supplies normalized values. Construction creates no worker -goroutines. - -An absent manager, blank backend ID, or ID absent from the policy map is -unlimited: - -- `Admit` succeeds with a non-nil no-op release function; and -- the client wrapper calls the next client directly. - -For a limited pool, `Admit` is immediate and context-aware: - -1. return `ctx.Err()` if the context is already done; -2. under the pool lock, compare admitted runs with - `ConcurrencyLimit + QueueCapacity`; -3. return an error matching internal `ErrCapacityExceeded` when full; or -4. increment admitted runs and return an idempotent release function. - -The release function decrements admission exactly once, even if accidentally -called more than once. It does not release an active-generation permit; those -permits have their own lifetime. - -### FIFO Generation Permits - -`NewClient` returns an internal `llm.Client` wrapper around either the built-in -client or the public-client adapter. It must preserve requests, successful -responses, nil responses, and collaborator error identities exactly. -`next` must be non-nil; `NewEngine` and internal runner construction maintain -that invariant. A nil manager returns `next` unchanged. - -For a configured backend ID, the wrapper: - -1. acquires one active-generation permit from the matching pool; -2. waits in FIFO order when the active count equals `ConcurrencyLimit`; -3. removes a canceled waiter and returns `ctx.Err()` when cancellation wins - before the permit is granted; -4. invokes the next client only after a permit is granted; and -5. releases the permit with `defer` after every success, nil response, - collaborator error, panic unwinding, or context outcome. - -Implement FIFO and cancellation explicitly with a mutex and an ordered waiter -list. A channel used only as a counting semaphore is insufficient because it -does not define FIFO ordering or safe removal of canceled waiters. - -Permit grant and cancellation must have one lock-protected linearization -point. If cancellation removes the waiter first, do not invoke the next -client. If grant wins first, invoke the next client with the caller's context; -the next client may then observe cancellation normally. Never lose or -double-release a permit in this race. - -Releasing a permit transfers it to the oldest non-canceled waiter before -making it generally available. Different backend pools never share admission -or active counts. - -The active wrapper enforces its limit even if an internal caller invokes it -without a run admission lease. Bounded backlog is guaranteed for ordinary -engine `Run` calls by the runner admission path; no public API exposes the -wrapped internal client directly. - -### Engine Assembly - -In `NewEngine`, after constructing the validated backend registry: - -1. obtain `backendRegistry.CapacityPolicies()`; -2. construct one `capacity.Manager`; -3. construct the selected base internal LLM client exactly as today; -4. wrap that base client with `capacity.NewClient`; and -5. pass both the wrapped client and manager-as-admitter to the runner. - -Every `NewEngine` call constructs a distinct manager. Do not cache managers, -pools, or policies in package globals. The wrapper must be applied after a -public injected client is adapted to `internal/llm.Client`, so built-in and -injected clients receive identical scheduling behavior. - -If `NewManager` reports a defensive configuration error, make `NewEngine` -return an error matching `ErrInvalidConfig`. - -`Prepare` does not use the manager. An injected client remains required to be -safe for concurrent calls because different backend pools and unlimited -backends may still invoke it concurrently. - -### Shared Two-Phase Preparation - -Refactor `internal/usecase.Runner` so `Prepare` and `Run` share one preparation -pipeline with two private phases. Do not duplicate prompt/profile/backend -selection or execution precedence. - -The first phase resolves only the state required before admission: - -1. validate `PromptID`; -2. normalize the direct session ID; -3. load the prompt definition; -4. hash the original prompt definition at its existing error-order position; -5. select and load the execution profile; -6. resolve the selected backend; -7. resolve and validate the effective execution target and credentials; and -8. resolve the effective output contract without loading its schema. - -Return a private state value containing the loaded definition, normalized -direct session, prompt-definition hash, selected profile ID, effective target, -numeric-presence metadata, effective output contract, and preparation start -time. Keep this value private to `internal/usecase`. - -The second phase consumes that state and performs: - -1. structured-output schema loading; -2. artifact loading and input hashing; -3. message and prompt-session rendering; -4. direct-session application; -5. rendered-prompt hashing; and -6. `PreparedRun` construction and timing. - -Preserve every existing precedence rule, error identity, direct-session -template bypass, hash input, selected identity, copy guarantee, and timing -field. Do not reload the prompt, profile, or backend between phases. - -`Runner.Prepare` records its start time, runs both phases consecutively, and -never calls admission. Its behavior and error ordering remain unchanged. - -`Runner.Run` records its existing run start time, runs the first preparation -phase, and then calls: - -```go -release, err := r.admitter.Admit(ctx, effectiveBackendID) -``` - -Use a narrow use-case-owned interface with the same signature: - -```go -type RunAdmitter interface { - Admit(context.Context, string) (func(), error) -} -``` - -A nil admitter means unlimited behavior for internal constructors and tests. -On successful admission, immediately `defer release()` around the remainder of -the run. Then run the second preparation phase, initial generation, -validation, and all repair attempts. - -If admission returns internal `capacity.ErrCapacityExceeded`, add useful -backend context without changing its identity. If it returns `ctx.Err()`, -preserve that identity directly rather than recategorizing it as invalid -request or generation failure. - -This refactor intentionally replaces the current literal `Run`-calls-`Prepare` -implementation with shared private phases. Update current-state documentation -to describe one shared pipeline rather than retaining an inaccurate call-graph -claim. - -### Generation And Repair Lifetime - -The admission lease covers the entire accepted run: - -- second-phase preparation; -- initial generation; -- validation; -- every repair; and -- all failure and cancellation exits. - -Preparation and validation do not hold an active-generation permit. The -wrapped client acquires a permit only around each actual `Generate` call. - -The runner's initial generation already carries the effective backend ID in -`GenerateRequest.Target`. Preserve that value. `RepairRequest.Target` and the -default repairer's generated request must continue carrying the same backend -ID, allowing each repair to reacquire the same pool's active permit. - -When testing or constructing `NewRunnerWithRepairer`, pass the same wrapped -client to both the runner and `NewDefaultOutputRepairer`. Do not add capacity -state to `RepairRequest`, `ExecutionTarget`, or public generation values. - -A repair remains within its existing admission lease. It waits for a FIFO -active permit but never performs a second bounded admission and therefore -cannot fail merely because later runs filled the admission capacity. - -### Error And Cancellation Semantics - -The required public outcomes are: - -| Situation | Required error identity | -| --- | --- | -| Admission capacity is full | `ErrCapacityExceeded` only; not `ErrInvalidRequest` or `ErrLLMGenerate`. | -| Context is done before admission succeeds | Preserve `ctx.Err()`; do not return capacity exhaustion. | -| Context cancels while waiting for an active permit | Preserve `ctx.Err()` through the existing `ErrLLMGenerate` generation category. | -| Wrapped client fails after permit acquisition | Preserve existing `ErrLLMGenerate` and collaborator identities. | -| Preparation or validation fails after admission | Preserve its existing category and release admission. | - -Maintain the existing rule that `Run` returns no partial result on any -operational error. Do not add queue status to errors or results. - -`RunResult.Duration` continues to start at runner entry and therefore includes -pre-admission resolution, accepted preparation, and active-permit waiting. -`PreparedRun.DurationMS` continues to cover only its shared preparation phases; -it does not include later generation waiting. Capacity-rejected calls have no -result or timing value. - -### Ownership And Concurrency Safety - -The registry, capacity policy map, pool map, and per-pool limits are immutable -after engine construction. Only admission counts, active counts, and waiter -lists are mutable and must be protected by the owning pool mutex. - -Do not retain public queue pointers, caller request values, contexts, or -generation requests after their call completes. A canceled waiter must be -unlinked so its context and request cannot remain reachable from the pool. - -Do not hold a pool mutex while: - -- loading or rendering prompts; -- reading artifacts or schemas; -- invoking a model client; -- validating output; -- closing a waiter notification channel if the implementation could re-enter - pool code; or -- calling consumer code. - -No scheduler operation may spawn a goroutine whose lifetime outlasts the -calling `Run`. The zero steady-state goroutine count is part of the -in-process/no-worker-lifecycle design. - -## Test Ownership - -Use this ownership split and avoid repeating the full policy matrix at every -layer: - -- `internal/backend/registry_test.go` owns normalization, validation, the exact - OpenRouter policy, the custom default queue, explicit zero, unlimited - omission, and policy-map copying. -- `internal/capacity/manager_test.go` owns admission bounds, idempotent release, - FIFO active permits, cancellation races, capacity recovery, independent - pools, unlimited IDs, and observed peak concurrency. -- `internal/capacity/client_test.go` owns wrapper request/response/error - transparency and the rule that cancellation before grant does not invoke the - next client. Combine these with manager tests if one coherent package test - expresses the behavior more clearly. -- `internal/usecase/runner_test.go` owns two-phase preparation parity, pool - selection, admission before expensive work, admission release across run - exits, `Prepare` bypass, and repair reuse of the admitted backend. -- Root external-package tests own public configuration conversion, assembled - engine-local behavior, endpoint-override routing, injected-client limiting, - and public capacity/context error identities. -- Existing model-client HTTP tests remain unchanged because scheduling does - not alter the OpenAI-compatible wire contract. - -Concurrency tests must use test-owned limits such as one or two and -channel-controlled blocking clients. Record observed active and peak counts -under a mutex or atomics. Do not use `time.Sleep` to infer queue state. -Package-internal tests may inspect a waiter list under its mutex through a -small test helper when necessary to establish deterministic FIFO ordering; do -not add production metrics or hooks solely for tests. - -Do not add separate tests for trivial scalar copies when registry or assembled -behavior already protects them. - -## Stage 1 — Backend Policy And Public Configuration - -**Status:** Complete. - -### Goal - -Add the public and internal backend policy representation, normalize all -configured states, and expose immutable normalized policies without changing -runtime scheduling yet. - -### Work - -1. Add `ConcurrencyLimit` and `QueueCapacity` to `Backend` in `backends.go` - with exact GoDoc for unlimited, defaulted, explicit-zero, invalid, and - engine-scoped behavior. -2. Convert the public queue pointer into scalar value plus presence in - `WithBackend`; do not retain the pointer. -3. Add the internal backend policy fields and - `BackendCapacityPolicy` to `internal/domain/domain.go`. -4. Add the two registry-owned constants and configure the built-in OpenRouter - definition with 16 and 1024. -5. Extend `normalizeBackend` with the fixed validation, defaulting, explicit - zero, and overflow rules. -6. Add `Registry.CapacityPolicies`, returning only limited policies in a fresh - map. -7. Update existing backend composite literals and assertions only where the - new fields are relevant. Continue using keyed literals. - -### Tests - -1. Extend the exact built-in registry test with the OpenRouter limit and queue. -2. Add one coherent table covering unlimited omission, default queue, - explicit-zero queue, negative values, queue-without-limit, and total - overflow. -3. Extend the registry copy/normalization test to prove returned policy maps - cannot mutate registry state. -4. Add root coverage only if needed to prove the public pointer/presence - conversion; do not reproduce registry validation cases at the facade. +The test must: + +1. call `promptkit.LocalBackend` with a test endpoint and a positive, + test-owned concurrency limit; +2. compare the returned value with this complete expected value: + + ```go + promptkit.Backend{ + ID: promptkit.BackendLocal, + Endpoint: endpoint, + ConcurrencyLimit: limit, + } + ``` + + A whole-value comparison is appropriate here because the exact zero-value + fields are part of this small public constructor's contract. +3. register that returned value with `WithBackend`; +4. add an in-memory profile whose `BackendID` is + `promptkit.BackendLocal`; +5. construct an engine through the existing contract-test prompt fixture; +6. call `Prepare`; and +7. assert that the prepared result exposes `BackendLocal` as the selected + backend and the supplied endpoint as the effective endpoint. + +This single test protects the realistic compatibility risks: accidental field +defaults, a changed conventional ID, failure to compose with `WithBackend`, +and accidental treatment of `"local"` as reserved. It also demonstrates that +the helper uses the existing backend/profile path. + +Do not add separate tests for blank endpoints, malformed endpoints, negative +limits, queue defaulting, duplicate IDs, engine isolation, runtime capacity, +or caller mutation. Those mechanisms are unchanged and already have tests at +their owning boundaries. Do not add internal-package tests for this root +facade constructor. + +### Consumer Documentation + +Update `docs/consumers/pkg-promptkit.md` after the API exists. Keep Go +declarations and GoDoc as the exact API owner; the guide should help consumers +choose a workflow and link to `backends.go` for precise semantics. + +Restructure the backend guidance to present these paths in increasing order of +configuration: + +1. **Endpoint-only profile.** Show a small in-memory `Profile` with + `Endpoint` and `Model`. Explain that this is the simplest choice when only + one profile needs the endpoint and shared backend identity or capacity + policy is unnecessary. +2. **Local convenience constructor.** Show + `WithBackend(promptkit.LocalBackend("http://localhost:8000/v1", 2))` + together with a profile using + `BackendID: promptkit.BackendLocal`. Explain briefly that the helper is + explicit, is not pre-registered, does not read environment variables, and + leaves the queue capacity at the existing default for a positive limit. +3. **Complete backend value.** Preserve an advanced example using a keyed + `Backend` literal for needs such as `APIKeyEnv`, an explicit + `QueueCapacity`, extra parameters, a custom ID, or multiple local + endpoints. Use a custom ID other than `"local"` in that example so the + distinction from the conventional helper is clear. + +Keep the existing backend-routing, selected-backend identity, concurrency, +credential, and error guidance unless a small wording adjustment is required +to make the new decision path coherent. Avoid repeating the complete field +contract or registry validation rules from GoDoc. + +Do not add a new maintained example, README section, format-reference entry, +integration-contract change, internal-document change, or release note. The +consumer-guide snippets are sufficient for this small convenience API, and +none of those other documents owns the affected task or contract. + +## Stage 1: Add The Public Constructor And Contract Test + +**Status:** Not started. + +### Objective + +Add the smallest public API that expresses the accepted local-backend +convention and protect its compatibility through the root public boundary. + +### Implementation Prompt + +1. Re-read `docs/development.md`, all files under `docs/policy/`, + `local-backend.md`, `backends.go`, the backend-related portion of + `public_contract_test.go`, and the existing backend/concurrency GoDoc before + editing. +2. Confirm the working tree and preserve the uncommitted roadmaps and any + unrelated consumer changes. +3. Add the untyped exported string constant `BackendLocal = "local"` to + `backends.go` without changing `BackendOpenRouter`. +4. Add `LocalBackend(endpoint string, concurrencyLimit int) Backend` to + `backends.go` using the exact keyed-literal implementation in the fixed + design. +5. Write complete GoDoc for both declarations. Make their conventional, + explicit, non-built-in, non-reserved, and deferred-validation semantics + unambiguous. +6. Add + `TestLocalBackendConstructsAndRegistersConventionalBackend` to + `public_contract_test.go` exactly as specified under Test Ownership. Reuse + the existing contract prompt fixture rather than adding a fixture or test + helper. +7. Do not edit internal packages. If the constructor appears to require an + internal change, stop and reconcile the implementation with the fixed + transparent-constructor design instead. ### Focused Validation Run: ```sh -gofmt -w backends.go internal/domain/domain.go \ - internal/backend/registry.go internal/backend/registry_test.go -go test . ./internal/backend -go vet . ./internal/backend +gofmt -w backends.go public_contract_test.go +go test . -run '^TestLocalBackendConstructsAndRegistersConventionalBackend$' +go test . +go vet . +go build . git diff --check ``` -Include another touched Go test file in `gofmt` only if it actually changed. +Inspect the diff and confirm that this stage changes only `backends.go`, +`public_contract_test.go`, and the already-present roadmap files. ### Completion Gate -This stage is complete when every public configuration state has one normalized -internal meaning, OpenRouter exposes exactly 16/1024, custom backends remain -unlimited by omission, and no runtime call is scheduled yet. +Stage 1 is complete when: -## Stage 2 — Engine-Local Capacity Manager +- the exported constant and constructor match the fixed API; +- the constructor returns only the three specified non-zero fields; +- the helper registers through the ordinary `WithBackend` path; +- `"local"` remains a valid consumer registration rather than a reserved + built-in; +- the focused public-contract test passes; and +- the root package test, vet, build, formatting, and whitespace checks pass. -**Status:** Complete. +## Stage 2: Publish Consumer Guidance And Validate The Repository -### Goal +**Status:** Not started. -Implement and prove the bounded admission mechanism and FIFO active-generation -client wrapper independently of runner orchestration. +### Objective -### Work +Make the simplest suitable local-endpoint configuration easy to discover, +confirm the complete change across the repository, and close the temporary +roadmaps. -1. Add `internal/capacity/manager.go` with the manager, immutable policy copy, - per-backend pools, internal error, immediate admission, idempotent release, - and FIFO context-aware active permits. -2. Add `internal/capacity/client.go` with the transparent `llm.Client` wrapper. -3. Use mutex-protected waiter state and an ordered list; explicitly resolve - grant-versus-cancel races. -4. Ensure unlimited and independent-pool fast paths avoid queue allocation. -5. Do not start workers, timers, cleanup goroutines, or process-global state. +### Implementation Prompt -### Tests - -1. Add a compact constructor-validation table for blank IDs, non-positive - limits, negative queues, and total-capacity overflow. -2. With a small configured policy, prove that exactly - `limit + queueCapacity` admissions succeed, the next matches - `ErrCapacityExceeded`, and a release permits another admission. -3. Prove release is idempotent. -4. Drive more blocked client calls than the active limit and assert observed - peak concurrency never exceeds that limit. -5. Prove FIFO order with deterministic queue-entry synchronization. -6. Cancel the first and a middle waiter and prove they are removed, never call - the wrapped client, and do not block later waiters. -7. Exercise the grant/cancel race repeatedly under `go test -race`, asserting - no permit leak or double invocation. -8. Prove different backend IDs proceed independently and blank, unknown, or - nil-manager paths remain unlimited. -9. Prove request values, successful and nil responses, and collaborator errors - pass through unchanged after permit acquisition. - -### Focused Validation - -Run: - -```sh -gofmt -w internal/capacity/manager.go \ - internal/capacity/manager_test.go \ - internal/capacity/client.go \ - internal/capacity/client_test.go -go test ./internal/capacity -go test -race ./internal/capacity -go vet ./internal/capacity -git diff --check -``` - -If tests are combined into one file, omit the nonexistent file from `gofmt`. - -### Completion Gate - -This stage is complete when the standalone component enforces relational -admission and active limits, FIFO cancellation is race-safe, separate pools -are independent, and the wrapper is transparent apart from waiting. - -## Stage 3 — Shared Preparation And Early Run Admission - -**Status:** Complete. - -### Goal - -Refactor runner preparation into one shared two-phase pipeline and place -bounded admission after backend resolution but before schema, artifact, and -rendering work. - -### Work - -1. Add the private pre-admission preparation state and split the existing - `Prepare` logic according to the fixed design. -2. Make `Runner.Prepare` call both phases without an admitter. -3. Add the `RunAdmitter` interface and runner field. -4. Update `NewRunner` and `NewRunnerWithRepairer` to accept the optional - admitter; update internal call sites with nil until root assembly is wired. -5. Change `Runner.Run` to use the first phase, admit by effective backend ID, - defer the returned release, and then use the second phase. -6. Preserve all existing error precedence, target resolution, hashes, - metadata, session behavior, and timing. -7. Return capacity and context errors with the fixed identities. Do not invoke - later collaborators after rejection. - -### Tests - -1. Keep the existing `Run`/`Prepare` parity coverage passing to prove the - shared phases do not drift. -2. Add a fake admitter that records backend IDs and release calls. -3. Prove a backend-selected run admits with the selected ID even when the - endpoint is overridden. -4. Prove an endpoint-only run uses the unlimited/blank identity and that - `Prepare` never calls admission. -5. Reject admission and assert schema, artifact, renderer, validator, repairer, - and LLM collaborators are not invoked. -6. Prove admission is released after one successful run and representative - second-phase, generation, and validation errors. Prefer a small table around - the single `defer` invariant rather than duplicating every error test. -7. Retain direct-session, backend precedence, credential, hashing, and repair - tests unchanged except for constructor arguments. - -### Focused Validation - -Run: - -```sh -gofmt -w internal/usecase/runner.go \ - internal/usecase/runner_test.go -go test ./internal/usecase -go test -race ./internal/usecase -go vet ./internal/usecase -git diff --check -``` - -### Completion Gate - -This stage is complete when `Prepare` remains unrestricted, `Run` admits after -one canonical routing phase and before expensive completion work, every exit -releases admission, and existing preparation semantics remain unchanged. - -## Stage 4 — Engine Assembly And Public Runtime Contract - -**Status:** Complete. - -### Goal - -Wire one manager into each engine, schedule built-in and injected clients, -expose the capacity error, and prove assembled runtime behavior. - -### Work - -1. Add public `ErrCapacityExceeded` and its exact GoDoc in `engine.go`. -2. Map internal capacity exhaustion in `errors.go`. -3. Construct the manager from the registry policy snapshot in `NewEngine`. -4. Wrap the selected internal client after built-in or injected-client - selection and pass the manager and wrapped client to the runner. -5. Update `Engine`, `NewEngine`, `Run`, `WithLLMClient`, and `LLMClient` GoDoc - only where concurrency, capacity, or cancellation statements change. -6. Ensure manager-construction errors match `ErrInvalidConfig`. -7. For internal repair coverage, construct the default repairer with the same - wrapped client used by its runner and confirm repair target backend identity - remains intact. - -### Tests - -1. Add an external-package assembled test with a small custom limit and a - blocking injected client; assert peak generation equals or remains below - the configured limit. -2. With queue capacity zero, block one accepted run before generation and - assert the next matching-backend run returns `ErrCapacityExceeded`, does not - match `ErrInvalidRequest` or `ErrLLMGenerate`, returns no result, and never - reaches expensive collaborators or the client. -3. In the same or another focused workflow, prove an endpoint override remains - in the selected backend's pool. -4. Prove two engines with the same backend ID have independent capacity. -5. Prove an unlimited custom backend and an endpoint-only profile preserve - concurrent behavior. -6. Cancel a call waiting for an active permit; assert it matches both - `context.Canceled` and `ErrLLMGenerate`, never invokes the injected client, - and leaves capacity reusable. -7. Add one internal repair workflow with concurrent runs or controlled permits - showing initial and repair generations never exceed the same backend limit - and repairs do not perform a second admission. -8. Extend the public error sentinel contract test with - `ErrCapacityExceeded`. - -Avoid a second HTTP-level concurrency suite: the capacity client tests and one -assembled injected-client workflow already protect the shared wrapper used by -the built-in client. - -### Focused Validation - -Run: - -```sh -gofmt -w engine.go errors.go backends.go \ - internal/usecase/runner.go internal/usecase/runner_test.go \ - engine_test.go public_contract_test.go -go test . ./internal/backend ./internal/capacity ./internal/usecase -go test -race . ./internal/capacity ./internal/usecase -go vet . ./internal/backend ./internal/capacity ./internal/usecase -git diff --check -``` - -Add any newly created capacity files to `gofmt` when they changed in this -stage. - -### Completion Gate - -This stage is complete when every engine has independent pools, limited runs -are bounded and FIFO at generation, endpoint routing is correct, capacity and -context errors are stable, repairs reuse admission, and both client kinds pass -through the same wrapper. - -## Stage 5 — Durable Documentation And Final Validation - -**Status:** Complete. - -### Goal - -Move implemented contracts into their durable owners, record compatibility -impact, and validate the complete repository. - -### Work - -1. Review every changed exported declaration. Ensure GoDoc is the canonical - owner of exact field types, nil/zero semantics, defaulting, error identity, - engine scope, concurrency safety, cancellation, and source compatibility. -2. Update `doc.go` so its concurrency summary acknowledges backend scheduling - while continuing to require injected collaborators to be concurrency-safe. -3. Update `docs/consumers/pkg-promptkit.md` with task-oriented examples for: - - a limited local backend; - - omitted queue capacity selecting 1024; - - explicit zero queue capacity; and - - handling `ErrCapacityExceeded`. - Keep exact field semantics in GoDoc rather than duplicating a full table. -4. Add `docs/internal/capacity.md` as the durable owner of pool lifecycle, - admission, FIFO active permits, cancellation, client wrapping, and test - ownership. -5. Add `internal/capacity` to `docs/internal/overview.md`. -6. Update `docs/policy/architecture.md` to include the implemented component - and root assembly dependency without turning policy into an API reference. -7. Update `docs/internal/runner.md` to describe the shared two-phase - preparation pipeline, early bounded admission, lease lifetime, generation - permits, repairs, capacity failures, and cancellation. -8. Review `docs/formats.md`; add only a concise link or clarification if needed - to explain that endpoint overrides preserve backend capacity identity. - Do not add concurrency fields to YAML. -9. Do not change the OpenAI-compatible integration contract or - `docs/internal/llm.md` unless implementation changes their current - statements; scheduling is outside the provider wire contract and concrete - model-client implementation. -10. Record in the implementation handoff that built-in OpenRouter now limits - active generations to 16 with queue capacity 1024 and that the release - must be a pre-`v1` minor release. Do not edit the release procedure or - create a tag. -11. After every check passes, set `concurrency.md`, this implementation plan, - and each stage status to `Complete`. Do not remove the roadmaps in the - implementation change; lifecycle retirement follows review. +1. Re-read the implemented declarations and GoDoc before describing them. +2. Update `docs/consumers/pkg-promptkit.md` according to the three-path + structure under Consumer Documentation. +3. Keep examples illustrative, minimal, secret-free, and consistent with the + implemented declarations. Link precise semantics to `backends.go` rather + than duplicating its field-by-field contract. +4. Follow every added or changed Markdown link and confirm its target exists. + Confirm that all local repository-relative links in + `local-backend.md`, this plan, and the changed consumer guide resolve. +5. Run the complete maintainer validation sequence below. +6. Inspect the final diff for accidental internal behavior changes, + generated artifacts, credentials, local workspace files, or module + replacements. +7. After every completion gate passes, set both stage statuses, this plan's + status, and the status in `local-backend.md` to `Complete`. Do not retire or + remove the roadmaps in the implementation change; roadmap retirement + follows implementation review. ### Full Validation @@ -792,50 +338,49 @@ git diff Confirm that: -- only intended backend, capacity, runner, facade, test, documentation, and - roadmap files changed; +- production changes are limited to the root convenience API; +- no internal backend, registry, capacity, profile, or engine behavior + changed; +- `BackendLocal` is conventional and consumer-registerable, not built in or + reserved; +- `LocalBackend` performs no validation, normalization, environment lookup, + registration, allocation, or hidden default selection; +- the returned `Backend` leaves `QueueCapacity` nil so existing positive-limit + defaulting remains owned by the registry; +- existing endpoint-only profiles and complete custom backends remain + documented and supported; +- current-state documentation describes only the now-implemented API and + links to the canonical GoDoc for exact semantics; - no `go.work`, `go.work.sum`, local module replacement, credential, - generated binary, coverage output, or unrelated change was introduced; -- the built-in OpenRouter policy is exactly 16/1024; -- custom and endpoint-only backends remain unlimited by omission; -- explicit queue zero is distinguishable from omission; -- no capacity value enters execution targets, generated requests, stable JSON, - prompt/profile YAML, or provider payloads; -- every engine owns distinct pools with no package-global mutable state; -- every initial and repair generation uses the active permit wrapper; -- capacity and waiter state is released on success, error, panic unwinding, - and cancellation; -- concurrency tests use deterministic coordination rather than sleeps; -- current-state documentation describes implemented behavior rather than - referring readers to the roadmaps; and + generated binary, or unrelated change was introduced; and - the feature and implementation roadmaps contain no unresolved work marked complete. ### Completion Gate -The implementation is complete only when every target-end-state item in -`concurrency.md` is implemented, race-enabled tests demonstrate the configured -limits and cancellation safety, durable contracts no longer depend on roadmap -prose, and the OpenRouter compatibility change is clearly reported for the -next minor release. +Stage 2 is complete when every target-end-state item in +`local-backend.md` is implemented, the consumer guide clearly presents all +three configuration paths, all complete validation commands pass, all changed +links resolve, and the roadmap statuses accurately report completion. ## Implementation Handoff -Backend-specific capacity management is implemented and has passed the complete -repository validation sequence. The built-in OpenRouter backend now permits 16 -active generations and a waiting capacity of 1024. Custom backends remain -unlimited when their limit is omitted, and endpoint-only profiles remain -unlimited. +The implementation handoff should report: -Publishing this behavior requires a pre-`v1` minor release. Its release notes -must identify that unusually high concurrent OpenRouter use can now wait or -return `ErrCapacityExceeded`. This implementation does not change a module -version or create a tag. +- the new `BackendLocal` and `LocalBackend` public API; +- that the helper remains explicit and composes with the ordinary backend + registry; +- the focused public-contract coverage added; +- the consumer-guide decision path added; +- the complete validation commands and results; and +- any unrelated working-tree changes that were preserved. + +Do not claim that a local backend is built in, pre-registered, configured from +environment variables, or assigned a default model or concurrency limit. ## Open Questions -None. The feature roadmap and this plan fix the public representation, -registry defaults, admission bound, FIFO generation behavior, early-routing -refactor, cancellation races, error identities, engine and repair lifetimes, -test ownership, compatibility treatment, and non-goals required for -implementation. +None. The feature roadmap and this plan fix the exported API, constructor +semantics, identity treatment, compatibility behavior, test boundary, +documentation ownership, implementation sequence, validation gates, and +non-goals required for implementation. diff --git a/docs/roadmap/local-backend.md b/docs/roadmap/local-backend.md new file mode 100644 index 0000000..e3013ea --- /dev/null +++ b/docs/roadmap/local-backend.md @@ -0,0 +1,163 @@ +# Local Backend Convenience + +**Status:** Accepted. + +## Purpose + +Make the common case of using a local OpenAI-compatible endpoint concise and +easy to discover without introducing implicit configuration or a separate +backend abstraction. + +The existing `Backend` type and registry remain the canonical, fully +configurable interface. A small convenience constructor will cover the usual +local-network case, while improved consumer documentation will make it clear +when an endpoint-only profile, the convenience constructor, or a complete +`Backend` value is appropriate. + +## Motivation + +Consumers can already use a local endpoint by setting `Profile.Endpoint`, or +register one as a backend with `WithBackend`. The first option is concise but +does not provide shared backend-level concurrency control. The second supports +the complete backend feature set but requires consumers to understand and +populate several fields for a common configuration. + +Most consumers adding a local backend need only: + +- a stable backend ID; +- an OpenAI-compatible endpoint; and +- a concurrency limit appropriate for the local server. + +Promptkit should provide a direct path for that case while keeping all +configuration explicit and preserving the full registry interface for +advanced needs. + +## Consumer Paths + +Documentation should present three progressively more configurable paths: + +1. Set `Profile.Endpoint` when a profile only needs to target a local endpoint + and does not need shared backend policy. +2. Use the local-backend convenience constructor when profiles should share a + named local endpoint and its concurrency limit. +3. Construct a complete `Backend` value when the consumer needs a custom + backend ID, authentication, extra request parameters, an explicit queue + capacity, or multiple local backends. + +These are complementary interfaces. The convenience constructor must return an +ordinary `Backend`, so it does not create a second configuration model. + +## Public Convenience API + +The public package should expose: + +```go +const BackendLocal = "local" + +func LocalBackend(endpoint string, concurrencyLimit int) Backend +``` + +`LocalBackend` should return a `Backend` with: + +- `ID` set to `BackendLocal`; +- `Endpoint` set to the supplied endpoint; +- `ConcurrencyLimit` set to the supplied limit; and +- all other fields left at their zero values. + +The returned value is passed to `WithBackend` and follows the same copying, +normalization, validation, and registration rules as any consumer-constructed +`Backend`. + +The constructor should be a transparent value constructor. It should not read +environment variables, mutate global state, register the backend, validate +arguments independently, or create profiles. Consumers may inspect or modify +the returned value before registration, although documentation should direct +substantially customized configurations to the full `Backend` form. + +## Identity and Registration + +`BackendLocal` is a conventional ID used by the convenience constructor. It is +not pre-registered and should not become a specially reserved registry ID. +Consumers remain responsible for registering the returned backend with +`WithBackend` and naming it from profiles through `BackendID`. + +This distinction preserves compatibility with consumers that may already +register their own backend using the ID `"local"`. Normal duplicate-ID rules +still apply if a consumer attempts to register more than one backend with that +ID. + +Consumers that need multiple local endpoints should choose distinct IDs and +use complete `Backend` values rather than the single conventional helper ID. + +## Concurrency and Queue Semantics + +The constructor must preserve the existing backend concurrency contract: + +- a positive concurrency limit bounds simultaneous requests and uses the + existing default queue capacity because `QueueCapacity` remains `nil`; +- a zero concurrency limit leaves the backend unconstrained; and +- a negative concurrency limit is rejected through the existing engine + configuration validation path. + +The constructor should not select a hidden default concurrency limit. Local +servers vary substantially in capacity, so the consumer should make this +choice explicitly. + +## Documentation + +The final documentation state has two canonical surfaces: + +- Public Go documentation describes the exact contract of + `BackendLocal` and `LocalBackend`, including their conventional, + non-pre-registered nature. +- The [promptkit consumer guide](../consumers/pkg-promptkit.md) includes + a task-oriented local-endpoint section that shows the three consumer paths, + explains the decision between them, and provides concise examples of the + endpoint-only and convenience-constructor forms. + +The consumer guide continues to document the full `Backend` interface as +the advanced path rather than attempting to reproduce every configuration +variation through convenience APIs. + +## Compatibility + +This feature is additive: + +- existing endpoint-only profiles continue to work unchanged; +- existing `Backend` values and `WithBackend` registrations remain the + canonical general-purpose interface; +- existing registrations using the literal ID `"local"` remain valid; and +- OpenRouter defaults and all other backend behavior remain unchanged. + +No consumer is required to adopt the convenience constructor. + +## Non-Goals + +This work does not include: + +- pre-registering or implicitly enabling a local backend; +- discovering a local endpoint, API key, or concurrency limit from environment + variables; +- adding local-backend fields to `Config`; +- selecting a default local model or creating a profile automatically; +- adding a combined backend-and-profile constructor; +- adding convenience parameters for API keys, extra request parameters, or + queue capacity; +- replacing or redesigning the backend registry; +- adding support for non-OpenAI-compatible local APIs; or +- changing backend routing, scheduling, or queue behavior. + +## Target End State + +After this work: + +- consumers with a simple one-profile local endpoint can continue to configure + it directly on the profile; +- consumers needing a shared local endpoint and concurrency policy can express + it with one `LocalBackend` call and register the returned value normally; +- consumers with advanced or multiple-local-backend requirements have a clear + path to the complete `Backend` interface; +- all local configuration remains explicit, inspectable, and compatible with + dependency injection; and +- canonical documentation makes the simplest suitable interface easy to find + without obscuring the underlying registry model. diff --git a/public_contract_test.go b/public_contract_test.go index 0055451..b58aeff 100644 --- a/public_contract_test.go +++ b/public_contract_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "reflect" "strings" "sync" "sync/atomic" @@ -138,6 +139,42 @@ func TestUnknownProfileBackendHasProfileLoadIdentity(t *testing.T) { } } +func TestLocalBackendConstructsAndRegistersConventionalBackend(t *testing.T) { + const limit = 2 + endpoint := "http://local.example/v1" + backend := promptkit.LocalBackend(endpoint, limit) + want := promptkit.Backend{ + ID: promptkit.BackendLocal, + Endpoint: endpoint, + ConcurrencyLimit: limit, + } + if !reflect.DeepEqual(backend, want) { + t.Fatalf("LocalBackend()=%+v, want %+v", backend, want) + } + + engine, err := promptkit.NewEngine(promptkit.Config{}, + promptkit.WithPromptFS(contractPromptFS("prompt", "local-profile", "message"), "."), + promptkit.WithBackend(backend), + promptkit.WithProfiles(promptkit.Profile{ + ID: "local-profile", + BackendID: promptkit.BackendLocal, + Model: "model", + }), + ) + if err != nil { + t.Fatalf("construct engine with local backend: %v", err) + } + + prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "prompt"}) + if err != nil { + t.Fatalf("prepare with local backend: %v", err) + } + if prepared.SelectedBackendID != promptkit.BackendLocal || + prepared.EffectiveModelParams.Endpoint != endpoint { + t.Fatalf("unexpected local backend preparation: %+v", prepared) + } +} + func TestCustomBackendFlowsThroughProfilesOverridesAndInjectedClient(t *testing.T) { t.Setenv("CUSTOM_LLM_KEY", "test-key") client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}