Address backend implementation review findings
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
||||||
)
|
)
|
||||||
|
|
||||||
func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
|
func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
|
||||||
@@ -133,7 +134,7 @@ func toDomainExecutionTargetOverride(override *ExecutionTargetOverride) (*domain
|
|||||||
if override == nil {
|
if override == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
extraParams, err := copyPublicJSONMap(override.ExtraParams)
|
extraParams, err := jsonvalue.CopyMap(override.ExtraParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ caller's client. Generation then:
|
|||||||
5. performs the outbound request under the applicable deadlines; and
|
5. performs the outbound request under the applicable deadlines; and
|
||||||
6. decodes the first response choice and token usage.
|
6. decodes the first response choice and token usage.
|
||||||
|
|
||||||
|
`internal/llm` owns the set of reserved OpenAI-compatible request fields used
|
||||||
|
when validating extra parameters. Backend registration consumes the same rule
|
||||||
|
without making the model client depend on registry configuration.
|
||||||
|
|
||||||
The implementation has no retry loop, tool-call support, provider catalog,
|
The implementation has no retry loop, tool-call support, provider catalog,
|
||||||
inbound HTTP behavior, or durable session store.
|
inbound HTTP behavior, or durable session store.
|
||||||
|
|
||||||
|
|||||||
@@ -14,17 +14,18 @@ contributor workflow and validation.
|
|||||||
| Root `promptkit` package | Provides the supported engine facade, source, backend-registration, and injection options, public request and result values, profile construction, extension interfaces, value conversion, redacted formatting, and public error mapping. | [Package GoDoc](../../doc.go), [backend API](../../backends.go), [engine assembly](../../engine.go) |
|
| Root `promptkit` package | Provides the supported engine facade, source, backend-registration, and injection options, public request and result values, profile construction, extension interfaces, value conversion, redacted formatting, and public error mapping. | [Package GoDoc](../../doc.go), [backend API](../../backends.go), [engine assembly](../../engine.go) |
|
||||||
| `examples/go-library/prepare` | Demonstrates an offline downstream consumer using a prompt file, in-memory profile, inline input, and `Prepare`. It is not a public library package. | [Example program](../../examples/go-library/prepare/main.go) |
|
| `examples/go-library/prepare` | Demonstrates an offline downstream consumer using a prompt file, in-memory profile, inline input, and `Prepare`. It is not a public library package. | [Example program](../../examples/go-library/prepare/main.go) |
|
||||||
| `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, in-memory profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) |
|
| `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, in-memory profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) |
|
||||||
| `internal/backend` | Constructs each engine's immutable registry from the built-in OpenRouter definition and consumer additions, validates and defensively copies definitions, and owns the shared reserved request-field rule. | [Backend registry](../../internal/backend/registry.go) |
|
| `internal/backend` | Constructs each engine's immutable registry from the built-in OpenRouter definition and consumer additions, validates and defensively copies definitions through the shared JSON-value package, and consumes the LLM-owned OpenAI-compatible reserved request-field rule. | [Backend registry](../../internal/backend/registry.go) |
|
||||||
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, profiles, execution targets, rendering, generation, and validation. | [Domain declarations](../../internal/domain/domain.go) |
|
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, profiles, execution targets, rendering, generation, and validation. | [Domain declarations](../../internal/domain/domain.go) |
|
||||||
| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
|
| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
|
||||||
| `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) |
|
| `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) |
|
||||||
|
| `internal/jsonvalue` | Validates and deeply copies JSON-compatible extra-parameter trees while preserving supported concrete value types. | [JSON values](../../internal/jsonvalue/jsonvalue.go) |
|
||||||
| `internal/promptdef` | Loads strictly decoded, validated prompt definitions from filesystem and `fs.FS` sources, including version selection and contained file-backed message content. | [Framework formats](../formats.md), [prompt-definition repository](../../internal/promptdef/filesystem_repository.go) |
|
| `internal/promptdef` | Loads strictly decoded, validated prompt definitions from filesystem and `fs.FS` sources, including version selection and contained file-backed message content. | [Framework formats](../formats.md), [prompt-definition repository](../../internal/promptdef/filesystem_repository.go) |
|
||||||
| `internal/profile` | Loads strictly decoded, validated execution profiles, including backend selection, from filesystem and `fs.FS` sources and composes repositories with error-preserving fallback. | [Framework formats](../formats.md), [profile repositories](../../internal/profile/filesystem_repository.go) |
|
| `internal/profile` | Loads strictly decoded, validated execution profiles, including backend selection, from filesystem and `fs.FS` sources and composes repositories with error-preserving fallback. | [Framework formats](../formats.md), [profile repositories](../../internal/profile/filesystem_repository.go) |
|
||||||
| `internal/profile/builtin` | Embeds the built-in profile catalog, whose entries select OpenRouter, and combines it with an optional primary repository. | [Built-in catalog](../formats.md#built-in-profile-catalog), [repository](../../internal/profile/builtin/repository.go) |
|
| `internal/profile/builtin` | Embeds the built-in profile catalog, whose entries select OpenRouter, and combines it with an optional primary repository. | [Built-in catalog](../formats.md#built-in-profile-catalog), [repository](../../internal/profile/builtin/repository.go) |
|
||||||
| `internal/prompt` | Renders prompt messages from Go templates with artifact, variable, session, and cache-control data. | [Go-template renderer](../../internal/prompt/go_renderer.go) |
|
| `internal/prompt` | Renders prompt messages from Go templates with artifact, variable, session, and cache-control data. | [Go-template renderer](../../internal/prompt/go_renderer.go) |
|
||||||
| `internal/artifact` | Resolves ordinary inline and unrestricted caller-selected file references into copied artifacts with metadata and hashes. | [Internal sources and validation](sources.md) |
|
| `internal/artifact` | Resolves ordinary inline and unrestricted caller-selected file references into copied artifacts with metadata and hashes. | [Internal sources and validation](sources.md) |
|
||||||
| `internal/validate` | Validates basic, JSON, and JSON Schema output using operating-system filesystem or `fs.FS` schema sources. | [Framework formats](../formats.md#schemas), [internal sources and validation](sources.md) |
|
| `internal/validate` | Validates basic, JSON, and JSON Schema output using operating-system filesystem or `fs.FS` schema sources. | [Framework formats](../formats.md#schemas), [internal sources and validation](sources.md) |
|
||||||
| `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests from resolved execution targets, including response decoding, authentication, and deadline handling. | [Internal model client](llm.md) |
|
| `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests from resolved execution targets, including response decoding, authentication, deadline handling, and ownership of the OpenAI-compatible reserved request-field policy. | [Internal model client](llm.md) |
|
||||||
| `internal/usecase` | Resolves backend, profile, and request settings and coordinates preparation and execution across internal sources, rendering, artifact loading, generation, validation, and optional repair. | [Internal runner](runner.md) |
|
| `internal/usecase` | Resolves backend, profile, and request settings and coordinates preparation and execution across internal sources, rendering, artifact loading, generation, validation, and optional repair. | [Internal runner](runner.md) |
|
||||||
|
|
||||||
The root package assembles these internal components without exposing their
|
The root package assembles these internal components without exposing their
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ The implemented internal components consist of:
|
|||||||
constructs the default execution target;
|
constructs the default execution target;
|
||||||
- `internal/filecatalog`, which discovers YAML files and provides source-path
|
- `internal/filecatalog`, which discovers YAML files and provides source-path
|
||||||
helpers for filesystem and `fs.FS` consumers;
|
helpers for filesystem and `fs.FS` consumers;
|
||||||
|
- `internal/jsonvalue`, which validates and defensively copies JSON-compatible
|
||||||
|
extra-parameter trees;
|
||||||
- `internal/promptdef`, which loads and validates prompt definitions from
|
- `internal/promptdef`, which loads and validates prompt definitions from
|
||||||
filesystem and `fs.FS` sources;
|
filesystem and `fs.FS` sources;
|
||||||
- `internal/profile`, which loads, validates, and overlays execution profiles
|
- `internal/profile`, which loads, validates, and overlays execution profiles
|
||||||
@@ -76,9 +78,11 @@ downstream consumers, including Scriptorium
|
|||||||
narrow injected abstractions
|
narrow injected abstractions
|
||||||
```
|
```
|
||||||
|
|
||||||
The backend registry depends on the domain model, has no mutation API after
|
The backend registry depends on the domain model and shared JSON-value
|
||||||
construction, and shares its OpenAI-compatible reserved request-field rule
|
validation, has no mutation API after construction, and consumes the
|
||||||
with the model client. The facade coordinates internal components and adapts
|
OpenAI-compatible reserved request-field rule owned by the model client. The
|
||||||
|
model client does not depend on registry configuration. The facade coordinates
|
||||||
|
internal components and adapts
|
||||||
the supported public extension interfaces to narrow internal abstractions.
|
the supported public extension interfaces to narrow internal abstractions.
|
||||||
Internal components must not depend on consumers or on Scriptorium.
|
Internal components must not depend on consumers or on Scriptorium.
|
||||||
|
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
# Extensible LLM Backend Registry
|
|
||||||
|
|
||||||
**Status:** Complete.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This roadmap defines the scope and target end state for formal
|
|
||||||
OpenAI-compatible backend support in Promptkit. It establishes the behavioral
|
|
||||||
boundary and policy choices for the work.
|
|
||||||
|
|
||||||
This document is planning material, not a description of current behavior.
|
|
||||||
Current public contracts remain owned by Go declarations and GoDoc, framework
|
|
||||||
file formats by the [format reference](../formats.md), and outbound HTTP
|
|
||||||
behavior by the
|
|
||||||
[OpenAI-compatible integration contract](../integrations/openai-compatible-chat.md).
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
Execution profiles currently carry backend connection and authentication
|
|
||||||
details alongside model and generation settings. This repeats values such as
|
|
||||||
the OpenRouter endpoint and `OPENROUTER_API_KEY` across profiles, leaves the
|
|
||||||
built-in catalog implicitly tied to one backend, and gives consumers no
|
|
||||||
explicit extension point for naming other OpenAI-compatible services.
|
|
||||||
|
|
||||||
Promptkit should distinguish:
|
|
||||||
|
|
||||||
- a **backend**, which identifies reusable connection, authentication, and
|
|
||||||
limited backend-wide request defaults; and
|
|
||||||
- a **profile**, which selects a model and its reusable generation settings.
|
|
||||||
|
|
||||||
That distinction should let consumers configure OpenRouter, OpenAI,
|
|
||||||
local-network services, or other OpenAI-compatible deployments without
|
|
||||||
duplicating backend defaults in every profile or replacing Promptkit's model
|
|
||||||
client.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
The work introduces an engine-scoped registry of OpenAI-compatible backend
|
|
||||||
definitions.
|
|
||||||
|
|
||||||
Each backend definition will provide:
|
|
||||||
|
|
||||||
- a stable, non-empty backend ID;
|
|
||||||
- a default OpenAI-compatible base endpoint;
|
|
||||||
- an optional environment-variable name for its API key; and
|
|
||||||
- limited JSON-compatible request defaults when they are genuinely
|
|
||||||
backend-wide rather than model-specific.
|
|
||||||
|
|
||||||
Promptkit will provide a small built-in backend catalog, initially containing
|
|
||||||
OpenRouter. Downstream consumers will be able to register additional backend
|
|
||||||
IDs during engine construction. Registration will be explicit and local to an
|
|
||||||
engine; it will not mutate package-global state.
|
|
||||||
|
|
||||||
Profiles will be able to name a backend. A profile that selects a backend may
|
|
||||||
override its default endpoint and other supported defaults while continuing to
|
|
||||||
own its model and generation settings. Existing endpoint-based custom profiles
|
|
||||||
will remain supported without requiring a backend ID.
|
|
||||||
|
|
||||||
The built-in model profile catalog will use the built-in OpenRouter backend
|
|
||||||
instead of repeating OpenRouter connection and credential defaults in every
|
|
||||||
profile.
|
|
||||||
|
|
||||||
## Resolution And Precedence
|
|
||||||
|
|
||||||
Effective execution settings will resolve in this order:
|
|
||||||
|
|
||||||
1. application-neutral framework defaults;
|
|
||||||
2. selected backend defaults, when a backend is named;
|
|
||||||
3. selected profile values; and
|
|
||||||
4. explicit per-run overrides.
|
|
||||||
|
|
||||||
A profile endpoint will override its selected backend's endpoint. A per-run
|
|
||||||
endpoint override will continue to take precedence over both.
|
|
||||||
|
|
||||||
Credential selection will preserve direct request credentials as the highest
|
|
||||||
precedence. An explicit per-run environment-variable override will take
|
|
||||||
precedence over profile credential configuration, which will take precedence
|
|
||||||
over the backend's default environment-variable name. Backend definitions and
|
|
||||||
profiles will contain credential-source metadata only, never resolved secret
|
|
||||||
values.
|
|
||||||
|
|
||||||
Backend request defaults, profile request values, and per-run request
|
|
||||||
overrides will follow one deterministic replacement rule. Resolution must not
|
|
||||||
introduce implicit deep merging whose result depends on map iteration or
|
|
||||||
incidental representation.
|
|
||||||
|
|
||||||
## Registration And Validation
|
|
||||||
|
|
||||||
Backend registration will be deterministic and validated during engine
|
|
||||||
construction.
|
|
||||||
|
|
||||||
- Built-in backend IDs are reserved and cannot be replaced by consumers.
|
|
||||||
- Consumer registrations may add only new IDs.
|
|
||||||
- Duplicate consumer IDs are invalid, including duplicates introduced through
|
|
||||||
repeated configuration.
|
|
||||||
- Backend IDs, endpoints, credential-source metadata, and request defaults
|
|
||||||
must be validated before an engine is returned.
|
|
||||||
- Selecting an unknown backend is an error associated with the profile or
|
|
||||||
request boundary that selected it.
|
|
||||||
- A profile without a backend must continue to provide the connection details
|
|
||||||
required by the current endpoint-based path.
|
|
||||||
- A backend may omit credential requirements so unauthenticated local-network
|
|
||||||
services remain supported.
|
|
||||||
|
|
||||||
Failures will retain error identities appropriate to the existing engine
|
|
||||||
configuration, profile-loading, and per-run validation boundaries.
|
|
||||||
|
|
||||||
## Public And Extension Boundaries
|
|
||||||
|
|
||||||
The effective backend ID will be observable in prepared and completed run
|
|
||||||
metadata and will be available to injected model clients. This gives consumers
|
|
||||||
and extensions a stable routing identity without requiring them to infer a
|
|
||||||
backend from an endpoint URL.
|
|
||||||
|
|
||||||
The existing public model-client injection boundary will remain supported.
|
|
||||||
Because the initial registry supports only OpenAI-compatible backends,
|
|
||||||
Promptkit does not need backend-specific transport factories or multiple
|
|
||||||
protocol implementations in this scope. The built-in client will continue to
|
|
||||||
send the resolved execution target to the selected OpenAI-compatible endpoint.
|
|
||||||
|
|
||||||
Backend definitions are configuration values, not live service objects. They
|
|
||||||
will not own mutable connections, credentials, health state, or process
|
|
||||||
lifecycle.
|
|
||||||
|
|
||||||
## Compatibility
|
|
||||||
|
|
||||||
The target end state preserves the existing ways consumers configure and run
|
|
||||||
Promptkit:
|
|
||||||
|
|
||||||
- Profiles with explicit endpoints and no backend ID continue to work.
|
|
||||||
- Profile endpoint overrides remain supported.
|
|
||||||
- Direct request API keys and environment-variable overrides retain their
|
|
||||||
precedence.
|
|
||||||
- Consumers may continue injecting a custom model client.
|
|
||||||
- Model IDs and generation settings remain profile concerns.
|
|
||||||
- Prompt selection, rendering, validation, artifacts, and synchronous
|
|
||||||
`Prepare` and `Run` behavior remain unchanged except for exposing the
|
|
||||||
resolved backend identity.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
This scope does not include:
|
|
||||||
|
|
||||||
- non-OpenAI-compatible protocols or provider-specific SDKs;
|
|
||||||
- backend-specific concurrency limits or admission queues;
|
|
||||||
- asynchronous task submission or durable jobs;
|
|
||||||
- model discovery, live model catalogs, or provider capability probing;
|
|
||||||
- retries, rate limiting, failover, load balancing, or health checks;
|
|
||||||
- automatic backend selection based on model names or endpoint inspection;
|
|
||||||
- global mutable registration;
|
|
||||||
- consumer replacement of built-in backend IDs;
|
|
||||||
- credential discovery beyond configured direct values and environment
|
|
||||||
variables; or
|
|
||||||
- provider-specific application, deployment, or security policy.
|
|
||||||
|
|
||||||
The dependent concurrency and bounded-queue work remains a separate future
|
|
||||||
unit after backend identity and resolution are stable.
|
|
||||||
|
|
||||||
## Target End State
|
|
||||||
|
|
||||||
This roadmap reaches its target end state when:
|
|
||||||
|
|
||||||
- Promptkit has an immutable, engine-scoped registry of validated
|
|
||||||
OpenAI-compatible backend definitions;
|
|
||||||
- OpenRouter is available as a built-in backend with its endpoint and
|
|
||||||
credential environment-variable default;
|
|
||||||
- consumers can register additional unique backend IDs without modifying
|
|
||||||
Promptkit;
|
|
||||||
- profiles can select a backend and optionally override its endpoint;
|
|
||||||
- existing endpoint-only profiles remain valid;
|
|
||||||
- backend, profile, and per-run values resolve through documented,
|
|
||||||
deterministic precedence;
|
|
||||||
- secret values remain outside backend and profile definitions;
|
|
||||||
- the effective backend identity is available in preparation, execution, and
|
|
||||||
injected-client values;
|
|
||||||
- the built-in model profiles use the OpenRouter backend rather than duplicate
|
|
||||||
its connection defaults;
|
|
||||||
- the built-in OpenAI-compatible client and injected clients continue to work
|
|
||||||
through the existing generation boundary; and
|
|
||||||
- current-state GoDoc, format, consumer, integration, architecture, and
|
|
||||||
internal documentation describe the implemented behavior without relying on
|
|
||||||
this roadmap.
|
|
||||||
@@ -35,9 +35,10 @@ consumers.
|
|||||||
|
|
||||||
### Backend-specific concurrency management
|
### Backend-specific concurrency management
|
||||||
|
|
||||||
Extend the selected
|
Extend the
|
||||||
[LLM backend registry](backends.md) with optional per-backend concurrency limits
|
[LLM backend registry](../consumers/pkg-promptkit.md#register-a-custom-backend)
|
||||||
and bounded, buffered admission queues. Promptkit could then route simultaneous
|
with optional per-backend concurrency limits and bounded, buffered admission
|
||||||
|
queues. Promptkit could then route simultaneous
|
||||||
generation requests according to backend capacity while containing accidental
|
generation requests according to backend capacity while containing accidental
|
||||||
runaway submission. Downstream consumers would continue invoking synchronous
|
runaway submission. Downstream consumers would continue invoking synchronous
|
||||||
`Run` calls, including concurrently from multiple goroutines, and each
|
`Run` calls, including concurrently from multiple goroutines, and each
|
||||||
|
|||||||
@@ -1,505 +0,0 @@
|
|||||||
# Extensible LLM Backend Registry Implementation Plan
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This document is the decision-complete implementation plan for the
|
|
||||||
[extensible LLM backend registry](backends.md). 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 `LLMClient` injection.
|
|
||||||
- Follow the architecture, documentation, and testing policies under
|
|
||||||
`docs/policy/`. In particular, keep the public API in the root package,
|
|
||||||
place implementation packages under `internal/`, 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:
|
|
||||||
|
|
||||||
```go
|
|
||||||
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:
|
|
||||||
|
|
||||||
- `Backend` configures one OpenAI-compatible backend and has no stable JSON
|
|
||||||
representation.
|
|
||||||
- `ID` is trimmed, non-empty, case-sensitive, and is the stable registry key.
|
|
||||||
- `Endpoint` is trimmed and must be an absolute `http` or `https` URL 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.
|
|
||||||
- `APIKeyEnv` is optional. When present, it is trimmed and must match the
|
|
||||||
portable environment-variable form `[A-Za-z_][A-Za-z0-9_]*`.
|
|
||||||
- `ExtraParams` follows the existing `Profile.ExtraParams` JSON-value rules:
|
|
||||||
non-empty string keys, finite numbers, JSON-compatible scalar and container
|
|
||||||
values, no cycles, and no collisions with `model`, `session_id`, `messages`,
|
|
||||||
`temperature`, `max_tokens`, `top_p`, `service_tier`, `reasoning_effort`, or
|
|
||||||
`response_format`. It is deeply copied during `NewEngine`.
|
|
||||||
- An empty `ExtraParams` map means that the backend supplies no request
|
|
||||||
defaults.
|
|
||||||
- Each `WithBackend` call 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 on `Option`.
|
|
||||||
- `BackendOpenRouter` is 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:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
backend: openrouter
|
|
||||||
```
|
|
||||||
|
|
||||||
The profile connection rule becomes:
|
|
||||||
|
|
||||||
- `model` remains required;
|
|
||||||
- at least one of `backend` or `endpoint` is 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:
|
|
||||||
|
|
||||||
```go
|
|
||||||
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:
|
|
||||||
|
|
||||||
```go
|
|
||||||
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:
|
|
||||||
|
|
||||||
1. framework defaults;
|
|
||||||
2. the selected backend, if any;
|
|
||||||
3. the selected profile; and
|
|
||||||
4. 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:
|
|
||||||
|
|
||||||
1. a non-blank `RunRequest.APIKey` wins and suppresses environment lookup;
|
|
||||||
2. a non-blank request `APIKeyEnv` wins over profile and backend metadata;
|
|
||||||
3. file-profile `api_key_env` wins over the backend default;
|
|
||||||
4. backend `APIKeyEnv` is used when no higher layer supplies a credential
|
|
||||||
source; and
|
|
||||||
5. a profile with `APIKeyRequired: true` clears an inherited backend
|
|
||||||
environment name and requires a direct key unless the request explicitly
|
|
||||||
supplies its own `APIKeyEnv`.
|
|
||||||
|
|
||||||
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`, and
|
|
||||||
`RunResult.SelectedBackendID` from the normalized selected profile backend.
|
|
||||||
- Endpoint-only profiles leave all three values empty.
|
|
||||||
- Pass `BackendID` through `GenerateRequest.Target` and repair targets so an
|
|
||||||
injected client can route or observe it.
|
|
||||||
- The built-in OpenAI-compatible client must not serialize `backend_id` as 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 `Prepare` and
|
|
||||||
`Run` behavior.
|
|
||||||
|
|
||||||
## Stage 1 — Registry Foundation
|
|
||||||
|
|
||||||
**Status:** Complete.
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Introduce the internal domain and immutable registry without changing the
|
|
||||||
public configuration or profile format.
|
|
||||||
|
|
||||||
### Work
|
|
||||||
|
|
||||||
1. Add the internal backend definition to `internal/domain`.
|
|
||||||
2. Create `internal/backend` with:
|
|
||||||
- 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.
|
|
||||||
3. Reuse or extract one validation rule for OpenAI-compatible reserved request
|
|
||||||
fields so registry validation and `internal/llm` payload construction cannot
|
|
||||||
drift. Do not maintain duplicate reserved-key lists.
|
|
||||||
4. Keep JSON-value copying type-preserving. Do not use a marshal/unmarshal
|
|
||||||
round trip that silently changes integer or container types.
|
|
||||||
5. Update `docs/policy/architecture.md` and `docs/internal/overview.md` only
|
|
||||||
after the package exists, describing `internal/backend` as 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
|
|
||||||
|
|
||||||
1. Add `BackendID` to internal execution profiles and targets and add the YAML
|
|
||||||
key `backend`.
|
|
||||||
2. Change file and public in-memory profile validation to require `model` plus
|
|
||||||
at least one of `backend` or `endpoint`. Preserve all existing numeric and
|
|
||||||
extra-parameter validation.
|
|
||||||
3. Add `BackendID` to `Profile` and
|
|
||||||
`OpenAICompatibleProfileConfig`, and add `BackendOpenRouter`.
|
|
||||||
4. Add backend identity to the public and internal execution target, prepared
|
|
||||||
run, and run result exactly as specified under **Public API**.
|
|
||||||
5. Add the `BackendResolver` dependency immediately after the profile
|
|
||||||
repository in both runner constructors and update all internal call sites
|
|
||||||
and test helpers explicitly.
|
|
||||||
6. Resolve the selected backend after profile loading and before effective
|
|
||||||
target resolution. Map lookup failure to `ErrProfileLoad`.
|
|
||||||
7. Implement the exact endpoint, credential, and whole-map precedence rules
|
|
||||||
under **Resolution**. Keep backend ID independent from endpoint overrides.
|
|
||||||
8. Construct the built-in-only registry in `NewEngine` and inject it into the
|
|
||||||
runner.
|
|
||||||
9. Replace `endpoint` and `api_key_env` in every
|
|
||||||
`internal/profile/builtin/assets/*.yaml` profile with
|
|
||||||
`backend: openrouter`. Retain each profile's model and generation settings.
|
|
||||||
10. Update conversions and copying so backend IDs survive every public/internal
|
|
||||||
boundary and registry-owned maps remain isolated.
|
|
||||||
11. Update the GoDoc for all changed public declarations.
|
|
||||||
12. Update `docs/formats.md`, `docs/internal/sources.md`, and
|
|
||||||
`docs/internal/runner.md` with the implemented profile field, conditional
|
|
||||||
endpoint rule, built-in selection, and effective resolution behavior.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
Assign test ownership as follows:
|
|
||||||
|
|
||||||
- `internal/profile` tests own strict YAML decoding and the conditional
|
|
||||||
backend-or-endpoint validation matrix.
|
|
||||||
- `internal/profile/builtin` tests own the invariant that every built-in
|
|
||||||
profile selects `openrouter` and no longer repeats its endpoint or
|
|
||||||
credential environment name.
|
|
||||||
- `internal/usecase` tests own backend/profile/request precedence, unknown-ID
|
|
||||||
error wrapping, endpoint override identity, credential precedence including
|
|
||||||
`APIKeyRequired`, 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
|
|
||||||
|
|
||||||
1. Add `Backend` and `WithBackend` exactly as specified under **Public API**,
|
|
||||||
preferably in a cohesive root `backends.go` file.
|
|
||||||
2. Convert, validate, normalize, and deeply copy public backend values during
|
|
||||||
`NewEngine`. Store pending additions in `engineOptions`; do not mutate a
|
|
||||||
registry from option application.
|
|
||||||
3. After all options have applied, construct one immutable registry from the
|
|
||||||
built-in and pending consumer definitions. Map every construction failure
|
|
||||||
to `ErrInvalidConfig`.
|
|
||||||
4. Make unique `WithBackend` calls additive. Reject duplicate IDs even when
|
|
||||||
they arrive through separate calls, and document this exception on
|
|
||||||
`Option` and `WithBackend`.
|
|
||||||
5. Make registered backends available equally to directory, `fs.FS`, single
|
|
||||||
file, in-memory, prompt-default, and explicit request profile selection.
|
|
||||||
6. Preserve the last-valid-option-wins behavior of every existing option
|
|
||||||
category.
|
|
||||||
7. Update `docs/consumers/pkg-promptkit.md` with 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 `WithBackend` calls accumulating;
|
|
||||||
- duplicate consumer and built-in ID failures matching `ErrInvalidConfig`;
|
|
||||||
- invalid public values and nested-map mutation isolation;
|
|
||||||
- endpoint-only compatibility with no `WithBackend` option;
|
|
||||||
- profile endpoint and request endpoint overrides retaining the custom backend
|
|
||||||
ID; and
|
|
||||||
- an injected `LLMClient` observing 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
|
|
||||||
|
|
||||||
1. 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.
|
|
||||||
2. Add or adjust one built-in-client transport test proving that effective
|
|
||||||
backend defaults reach the configured endpoint while `backend_id` is not
|
|
||||||
serialized. Use `httptest.Server` and synthetic credentials.
|
|
||||||
3. Confirm public stable-JSON round trips for `ExecutionTarget`,
|
|
||||||
`PreparedRun`, and `RunResult`, including empty backend omission for legacy
|
|
||||||
profiles.
|
|
||||||
4. Confirm concurrent `Prepare` and `Run` calls can read the immutable registry
|
|
||||||
under the race detector. Do not add concurrency limiting or a queue.
|
|
||||||
5. Update all affected current-state owners:
|
|
||||||
- public GoDoc for the exact API and errors;
|
|
||||||
- `docs/formats.md` for profile YAML and built-ins;
|
|
||||||
- `docs/consumers/pkg-promptkit.md` for construction and use;
|
|
||||||
- `docs/integrations/openai-compatible-chat.md` for resolved endpoint,
|
|
||||||
credentials, request defaults, and the non-serialized routing identity;
|
|
||||||
- `docs/internal/overview.md`, `docs/internal/sources.md`,
|
|
||||||
`docs/internal/runner.md`, and `docs/internal/llm.md` for package
|
|
||||||
responsibilities and data flow; and
|
|
||||||
- `docs/policy/architecture.md` only to keep its implemented component
|
|
||||||
inventory and dependency description accurate.
|
|
||||||
6. 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.
|
|
||||||
7. Keep future concurrency and queue behavior only in
|
|
||||||
[the future feature catalog](future.md); do not imply that this feature
|
|
||||||
implements either capability.
|
|
||||||
|
|
||||||
### Final Validation
|
|
||||||
|
|
||||||
Run the complete maintainer sequence from the repository root:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
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](backends.md) 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.
|
|
||||||
@@ -2357,6 +2357,11 @@ func TestExtraParamsTypedNestedValuesAreCopiedAcrossPublicBoundary(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunRejectsInvalidExtraParams(t *testing.T) {
|
func TestRunRejectsInvalidExtraParams(t *testing.T) {
|
||||||
|
cyclicMap := map[string]any{}
|
||||||
|
cyclicMap["self"] = cyclicMap
|
||||||
|
cyclicSlice := []any{nil}
|
||||||
|
cyclicSlice[0] = cyclicSlice
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
extraParams map[string]any
|
extraParams map[string]any
|
||||||
@@ -2368,42 +2373,10 @@ func TestRunRejectsInvalidExtraParams(t *testing.T) {
|
|||||||
{name: "nan", extraParams: map[string]any{"bad": math.NaN()}},
|
{name: "nan", extraParams: map[string]any{"bad": math.NaN()}},
|
||||||
{name: "positive infinity", extraParams: map[string]any{"bad": math.Inf(1)}},
|
{name: "positive infinity", extraParams: map[string]any{"bad": math.Inf(1)}},
|
||||||
{name: "negative infinity", extraParams: map[string]any{"bad": math.Inf(-1)}},
|
{name: "negative infinity", extraParams: map[string]any{"bad": math.Inf(-1)}},
|
||||||
}
|
{name: "cyclic map", extraParams: cyclicMap},
|
||||||
for _, tc := range tests {
|
{name: "cyclic slice", extraParams: map[string]any{"cycle": cyclicSlice}},
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
{name: "malformed JSON number", extraParams: map[string]any{"value": json.Number("+1")}},
|
||||||
fake := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}
|
{name: "empty nested key", extraParams: map[string]any{"nested": map[string]any{"": true}}},
|
||||||
engine := newContractEngineWithOptions(t, frameworkSchemaDir, promptkit.WithLLMClient(fake))
|
|
||||||
|
|
||||||
_, err := engine.Run(context.Background(), promptkit.RunRequest{
|
|
||||||
PromptID: frameworkMarkdownSummaryPromptID,
|
|
||||||
Inputs: map[string]promptkit.ArtifactRef{
|
|
||||||
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
||||||
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
||||||
},
|
|
||||||
Execution: &promptkit.ExecutionTargetOverride{ExtraParams: tc.extraParams},
|
|
||||||
})
|
|
||||||
if !errors.Is(err, promptkit.ErrInvalidRequest) {
|
|
||||||
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
|
||||||
}
|
|
||||||
if len(fake.requests) != 0 {
|
|
||||||
t.Fatalf("expected invalid request to fail before LLM call, got %d requests", len(fake.requests))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunRejectsCyclicExtraParams(t *testing.T) {
|
|
||||||
cyclicMap := map[string]any{}
|
|
||||||
cyclicMap["self"] = cyclicMap
|
|
||||||
cyclicSlice := []any{nil}
|
|
||||||
cyclicSlice[0] = cyclicSlice
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
extraParams map[string]any
|
|
||||||
}{
|
|
||||||
{name: "map", extraParams: cyclicMap},
|
|
||||||
{name: "slice", extraParams: map[string]any{"cycle": cyclicSlice}},
|
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -75,7 +77,7 @@ func (r *Registry) GetBackend(id string) (domain.Backend, error) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return domain.Backend{}, fmt.Errorf("%w: %q", ErrBackendNotFound, id)
|
return domain.Backend{}, fmt.Errorf("%w: %q", ErrBackendNotFound, id)
|
||||||
}
|
}
|
||||||
extraParams, err := copyJSONMap(definition.ExtraParams)
|
extraParams, err := jsonvalue.CopyMap(definition.ExtraParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Backend{}, fmt.Errorf("copy backend %q: %w", id, err)
|
return domain.Backend{}, fmt.Errorf("copy backend %q: %w", id, err)
|
||||||
}
|
}
|
||||||
@@ -83,25 +85,6 @@ func (r *Registry) GetBackend(id string) (domain.Backend, error) {
|
|||||||
return definition, nil
|
return definition, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsReservedRequestField reports whether name is owned by the standard
|
|
||||||
// OpenAI-compatible chat request rather than backend extra parameters.
|
|
||||||
func IsReservedRequestField(name string) bool {
|
|
||||||
switch name {
|
|
||||||
case "model",
|
|
||||||
"session_id",
|
|
||||||
"messages",
|
|
||||||
"temperature",
|
|
||||||
"max_tokens",
|
|
||||||
"top_p",
|
|
||||||
"service_tier",
|
|
||||||
"reasoning_effort",
|
|
||||||
"response_format":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeBackend(definition domain.Backend) (domain.Backend, error) {
|
func normalizeBackend(definition domain.Backend) (domain.Backend, error) {
|
||||||
definition.Endpoint = strings.TrimSpace(definition.Endpoint)
|
definition.Endpoint = strings.TrimSpace(definition.Endpoint)
|
||||||
if err := validateEndpoint(definition.Endpoint); err != nil {
|
if err := validateEndpoint(definition.Endpoint); err != nil {
|
||||||
@@ -126,7 +109,7 @@ func normalizeBackend(definition domain.Backend) (domain.Backend, error) {
|
|||||||
if key == "" {
|
if key == "" {
|
||||||
return domain.Backend{}, fmt.Errorf("backend %q extra parameter key must not be empty", definition.ID)
|
return domain.Backend{}, fmt.Errorf("backend %q extra parameter key must not be empty", definition.ID)
|
||||||
}
|
}
|
||||||
if IsReservedRequestField(key) {
|
if llm.IsReservedOpenAIChatRequestField(key) {
|
||||||
return domain.Backend{}, fmt.Errorf(
|
return domain.Backend{}, fmt.Errorf(
|
||||||
"backend %q extra parameter %q collides with a reserved request field",
|
"backend %q extra parameter %q collides with a reserved request field",
|
||||||
definition.ID,
|
definition.ID,
|
||||||
@@ -135,7 +118,7 @@ func normalizeBackend(definition domain.Backend) (domain.Backend, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extraParams, err := copyJSONMap(definition.ExtraParams)
|
extraParams, err := jsonvalue.CopyMap(definition.ExtraParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Backend{}, fmt.Errorf("backend %q extra parameters: %w", definition.ID, err)
|
return domain.Backend{}, fmt.Errorf("backend %q extra parameters: %w", definition.ID, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package backend_test
|
package backend_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"math"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -188,42 +186,13 @@ func TestNewRegistryValidatesEnvironmentVariableNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewRegistryValidatesExtraParameters(t *testing.T) {
|
func TestNewRegistryRejectsInvalidAndReservedExtraParameters(t *testing.T) {
|
||||||
cyclic := map[string]any{}
|
|
||||||
cyclic["self"] = cyclic
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
extraParams map[string]any
|
extraParams map[string]any
|
||||||
}{
|
}{
|
||||||
{name: "empty top-level key", extraParams: map[string]any{"": true}},
|
|
||||||
{name: "empty nested key", extraParams: map[string]any{"nested": map[string]int{"": 1}}},
|
|
||||||
{name: "non-string map key", extraParams: map[string]any{"nested": map[int]string{1: "one"}}},
|
|
||||||
{name: "unsupported value", extraParams: map[string]any{"value": make(chan int)}},
|
{name: "unsupported value", extraParams: map[string]any{"value": make(chan int)}},
|
||||||
{name: "cyclic value", extraParams: map[string]any{"value": cyclic}},
|
{name: "reserved key", extraParams: map[string]any{"model": "override"}},
|
||||||
{name: "NaN", extraParams: map[string]any{"value": math.NaN()}},
|
|
||||||
{name: "positive infinity", extraParams: map[string]any{"value": math.Inf(1)}},
|
|
||||||
{name: "unsafe integer", extraParams: map[string]any{"value": int64(1 << 53)}},
|
|
||||||
{name: "invalid JSON number", extraParams: map[string]any{"value": json.Number("not-a-number")}},
|
|
||||||
}
|
|
||||||
for _, key := range []string{
|
|
||||||
"model",
|
|
||||||
"session_id",
|
|
||||||
"messages",
|
|
||||||
"temperature",
|
|
||||||
"max_tokens",
|
|
||||||
"top_p",
|
|
||||||
"service_tier",
|
|
||||||
"reasoning_effort",
|
|
||||||
"response_format",
|
|
||||||
} {
|
|
||||||
tests = append(tests, struct {
|
|
||||||
name string
|
|
||||||
extraParams map[string]any
|
|
||||||
}{
|
|
||||||
name: "reserved key " + key,
|
|
||||||
extraParams: map[string]any{key: true},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
package backend
|
// Package jsonvalue validates and defensively copies JSON-compatible value
|
||||||
|
// trees used by public configuration and request boundaries.
|
||||||
|
package jsonvalue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -11,16 +13,18 @@ import (
|
|||||||
|
|
||||||
const maxSafeJSONInteger = 1<<53 - 1
|
const maxSafeJSONInteger = 1<<53 - 1
|
||||||
|
|
||||||
type jsonVisit struct {
|
type visit struct {
|
||||||
typ reflect.Type
|
typ reflect.Type
|
||||||
ptr uintptr
|
ptr uintptr
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyJSONMap(src map[string]any) (map[string]any, error) {
|
// CopyMap validates and deeply copies an extra-parameter map while preserving
|
||||||
|
// compatible concrete map, slice, array, scalar, and number types.
|
||||||
|
func CopyMap(src map[string]any) (map[string]any, error) {
|
||||||
if src == nil {
|
if src == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
copied, err := copyJSONValue(reflect.ValueOf(src), "extra_params", make(map[jsonVisit]struct{}))
|
copied, err := copyValue(reflect.ValueOf(src), "extra_params", make(map[visit]struct{}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -31,7 +35,7 @@ func copyJSONMap(src map[string]any) (map[string]any, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyJSONValue(value reflect.Value, path string, seen map[jsonVisit]struct{}) (any, error) {
|
func copyValue(value reflect.Value, path string, seen map[visit]struct{}) (any, error) {
|
||||||
if !value.IsValid() {
|
if !value.IsValid() {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -39,12 +43,15 @@ func copyJSONValue(value reflect.Value, path string, seen map[jsonVisit]struct{}
|
|||||||
if value.IsNil() {
|
if value.IsNil() {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return copyJSONValue(value.Elem(), path, seen)
|
return copyValue(value.Elem(), path, seen)
|
||||||
}
|
}
|
||||||
if !value.CanInterface() {
|
if !value.CanInterface() {
|
||||||
return nil, fmt.Errorf("%s: value cannot be copied", path)
|
return nil, fmt.Errorf("%s: value cannot be copied", path)
|
||||||
}
|
}
|
||||||
if number, ok := value.Interface().(json.Number); ok {
|
if number, ok := value.Interface().(json.Number); ok {
|
||||||
|
if _, err := json.Marshal(number); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: invalid JSON number", path)
|
||||||
|
}
|
||||||
f, err := strconv.ParseFloat(number.String(), 64)
|
f, err := strconv.ParseFloat(number.String(), 64)
|
||||||
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
|
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
|
||||||
return nil, fmt.Errorf("%s: invalid JSON number", path)
|
return nil, fmt.Errorf("%s: invalid JSON number", path)
|
||||||
@@ -75,28 +82,28 @@ func copyJSONValue(value reflect.Value, path string, seen map[jsonVisit]struct{}
|
|||||||
if value.IsNil() {
|
if value.IsNil() {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
visit := jsonVisit{typ: value.Type(), ptr: value.Pointer()}
|
current := visit{typ: value.Type(), ptr: value.Pointer()}
|
||||||
if _, ok := seen[visit]; ok {
|
if _, ok := seen[current]; ok {
|
||||||
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
||||||
}
|
}
|
||||||
seen[visit] = struct{}{}
|
seen[current] = struct{}{}
|
||||||
defer delete(seen, visit)
|
defer delete(seen, current)
|
||||||
return copyJSONValue(value.Elem(), path, seen)
|
return copyValue(value.Elem(), path, seen)
|
||||||
case reflect.Map:
|
case reflect.Map:
|
||||||
return copyJSONMapValue(value, path, seen)
|
return copyMapValue(value, path, seen)
|
||||||
case reflect.Slice:
|
case reflect.Slice:
|
||||||
if value.IsNil() {
|
if value.IsNil() {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return copyJSONSequenceValue(value, path, seen)
|
return copySequenceValue(value, path, seen)
|
||||||
case reflect.Array:
|
case reflect.Array:
|
||||||
return copyJSONSequenceValue(value, path, seen)
|
return copySequenceValue(value, path, seen)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("%s: unsupported JSON value type %s", path, value.Type())
|
return nil, fmt.Errorf("%s: unsupported JSON value type %s", path, value.Type())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyJSONMapValue(value reflect.Value, path string, seen map[jsonVisit]struct{}) (any, error) {
|
func copyMapValue(value reflect.Value, path string, seen map[visit]struct{}) (any, error) {
|
||||||
if value.IsNil() {
|
if value.IsNil() {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -104,12 +111,12 @@ func copyJSONMapValue(value reflect.Value, path string, seen map[jsonVisit]struc
|
|||||||
return nil, fmt.Errorf("%s: map key type %s is not supported", path, value.Type().Key())
|
return nil, fmt.Errorf("%s: map key type %s is not supported", path, value.Type().Key())
|
||||||
}
|
}
|
||||||
|
|
||||||
visit := jsonVisit{typ: value.Type(), ptr: value.Pointer()}
|
current := visit{typ: value.Type(), ptr: value.Pointer()}
|
||||||
if _, ok := seen[visit]; ok {
|
if _, ok := seen[current]; ok {
|
||||||
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
||||||
}
|
}
|
||||||
seen[visit] = struct{}{}
|
seen[current] = struct{}{}
|
||||||
defer delete(seen, visit)
|
defer delete(seen, current)
|
||||||
|
|
||||||
keys := value.MapKeys()
|
keys := value.MapKeys()
|
||||||
sort.Slice(keys, func(i, j int) bool {
|
sort.Slice(keys, func(i, j int) bool {
|
||||||
@@ -129,7 +136,7 @@ func copyJSONMapValue(value reflect.Value, path string, seen map[jsonVisit]struc
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
return nil, fmt.Errorf("%s: map key must not be empty", path)
|
return nil, fmt.Errorf("%s: map key must not be empty", path)
|
||||||
}
|
}
|
||||||
copied, err := copyJSONValue(value.MapIndex(key), path+"."+name, seen)
|
copied, err := copyValue(value.MapIndex(key), path+"."+name, seen)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -164,22 +171,22 @@ func copyJSONMapValue(value reflect.Value, path string, seen map[jsonVisit]struc
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyJSONSequenceValue(value reflect.Value, path string, seen map[jsonVisit]struct{}) (any, error) {
|
func copySequenceValue(value reflect.Value, path string, seen map[visit]struct{}) (any, error) {
|
||||||
var visit jsonVisit
|
var current visit
|
||||||
if value.Kind() == reflect.Slice {
|
if value.Kind() == reflect.Slice {
|
||||||
visit = jsonVisit{typ: value.Type(), ptr: value.Pointer()}
|
current = visit{typ: value.Type(), ptr: value.Pointer()}
|
||||||
if _, ok := seen[visit]; ok {
|
if _, ok := seen[current]; ok {
|
||||||
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
||||||
}
|
}
|
||||||
seen[visit] = struct{}{}
|
seen[current] = struct{}{}
|
||||||
defer delete(seen, visit)
|
defer delete(seen, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
values := make([]any, value.Len())
|
values := make([]any, value.Len())
|
||||||
preserveType := true
|
preserveType := true
|
||||||
elementType := value.Type().Elem()
|
elementType := value.Type().Elem()
|
||||||
for i := 0; i < value.Len(); i++ {
|
for i := 0; i < value.Len(); i++ {
|
||||||
copied, err := copyJSONValue(value.Index(i), fmt.Sprintf("%s[%d]", path, i), seen)
|
copied, err := copyValue(value.Index(i), fmt.Sprintf("%s[%d]", path, i), seen)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
97
internal/jsonvalue/jsonvalue_test.go
Normal file
97
internal/jsonvalue/jsonvalue_test.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package jsonvalue_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCopyMapPreservesTypesAndIsolatesMutations(t *testing.T) {
|
||||||
|
nested := map[string]int{"limit": 2}
|
||||||
|
sequence := []string{"one", "two"}
|
||||||
|
input := map[string]any{
|
||||||
|
"count": int64(7),
|
||||||
|
"number": json.Number("-1.25e+2"),
|
||||||
|
"nested": nested,
|
||||||
|
"sequence": sequence,
|
||||||
|
}
|
||||||
|
|
||||||
|
copied, err := jsonvalue.CopyMap(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("copy map: %v", err)
|
||||||
|
}
|
||||||
|
nested["limit"] = 99
|
||||||
|
sequence[0] = "changed"
|
||||||
|
input["added"] = true
|
||||||
|
|
||||||
|
if got, ok := copied["count"].(int64); !ok || got != 7 {
|
||||||
|
t.Fatalf("integer type or value changed: %#v", copied["count"])
|
||||||
|
}
|
||||||
|
if got, ok := copied["number"].(json.Number); !ok || got != "-1.25e+2" {
|
||||||
|
t.Fatalf("JSON number type or value changed: %#v", copied["number"])
|
||||||
|
}
|
||||||
|
if got := copied["nested"].(map[string]int)["limit"]; got != 2 {
|
||||||
|
t.Fatalf("nested map was not isolated: %d", got)
|
||||||
|
}
|
||||||
|
if got := copied["sequence"].([]string)[0]; got != "one" {
|
||||||
|
t.Fatalf("sequence was not isolated: %q", got)
|
||||||
|
}
|
||||||
|
if _, ok := copied["added"]; ok {
|
||||||
|
t.Fatalf("top-level map was not isolated: %#v", copied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyMapRejectsInvalidValues(t *testing.T) {
|
||||||
|
cyclicMap := map[string]any{}
|
||||||
|
cyclicMap["self"] = cyclicMap
|
||||||
|
cyclicSlice := []any{nil}
|
||||||
|
cyclicSlice[0] = cyclicSlice
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value any
|
||||||
|
}{
|
||||||
|
{name: "empty nested key", value: map[string]int{"": 1}},
|
||||||
|
{name: "non-string map key", value: map[int]string{1: "one"}},
|
||||||
|
{name: "unsupported value", value: make(chan int)},
|
||||||
|
{name: "cyclic map", value: cyclicMap},
|
||||||
|
{name: "cyclic slice", value: cyclicSlice},
|
||||||
|
{name: "NaN", value: math.NaN()},
|
||||||
|
{name: "positive infinity", value: math.Inf(1)},
|
||||||
|
{name: "unsafe signed integer", value: int64(1 << 53)},
|
||||||
|
{name: "unsafe unsigned integer", value: uint64(1 << 53)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if _, err := jsonvalue.CopyMap(map[string]any{"value": tc.value}); err == nil {
|
||||||
|
t.Fatal("expected validation error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyMapValidatesJSONNumberSyntaxAndRange(t *testing.T) {
|
||||||
|
for _, number := range []json.Number{"0", "-1", "1.25", "-1.25e+2"} {
|
||||||
|
t.Run("valid "+number.String(), func(t *testing.T) {
|
||||||
|
got, err := jsonvalue.CopyMap(map[string]any{"value": number})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("copy valid JSON number: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got["value"], number) {
|
||||||
|
t.Fatalf("JSON number changed: got %#v want %#v", got["value"], number)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, number := range []json.Number{"", "01", "+1", "1.", ".1", "1e9999", "not-a-number"} {
|
||||||
|
t.Run("invalid "+number.String(), func(t *testing.T) {
|
||||||
|
if _, err := jsonvalue.CopyMap(map[string]any{"value": number}); err == nil {
|
||||||
|
t.Fatal("expected invalid JSON number error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
@@ -263,7 +262,7 @@ func openAIChatRequestPayload(req openAIChatRequest) (map[string]any, error) {
|
|||||||
if key == "" {
|
if key == "" {
|
||||||
return nil, errors.New("extra_params key must not be empty")
|
return nil, errors.New("extra_params key must not be empty")
|
||||||
}
|
}
|
||||||
if backend.IsReservedRequestField(key) {
|
if IsReservedOpenAIChatRequestField(key) {
|
||||||
return nil, fmt.Errorf("extra_params key %q collides with reserved request field", key)
|
return nil, fmt.Errorf("extra_params key %q collides with reserved request field", key)
|
||||||
}
|
}
|
||||||
if _, err := json.Marshal(value); err != nil {
|
if _, err := json.Marshal(value); err != nil {
|
||||||
@@ -275,6 +274,25 @@ func openAIChatRequestPayload(req openAIChatRequest) (map[string]any, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsReservedOpenAIChatRequestField reports whether name is owned by the
|
||||||
|
// standard OpenAI-compatible chat request rather than extra parameters.
|
||||||
|
func IsReservedOpenAIChatRequestField(name string) bool {
|
||||||
|
switch name {
|
||||||
|
case "model",
|
||||||
|
"session_id",
|
||||||
|
"messages",
|
||||||
|
"temperature",
|
||||||
|
"max_tokens",
|
||||||
|
"top_p",
|
||||||
|
"service_tier",
|
||||||
|
"reasoning_effort",
|
||||||
|
"response_format":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type openAIChatRequestMessage struct {
|
type openAIChatRequestMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content any `json:"content"`
|
Content any `json:"content"`
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ func mergeExecutionTarget(base domain.ExecutionTarget, override domain.Execution
|
|||||||
if strings.TrimSpace(override.BackendID) != "" {
|
if strings.TrimSpace(override.BackendID) != "" {
|
||||||
out.BackendID = override.BackendID
|
out.BackendID = override.BackendID
|
||||||
}
|
}
|
||||||
if override.Endpoint != "" {
|
if strings.TrimSpace(override.Endpoint) != "" {
|
||||||
out.Endpoint = override.Endpoint
|
out.Endpoint = override.Endpoint
|
||||||
}
|
}
|
||||||
if override.Model != "" {
|
if override.Model != "" {
|
||||||
|
|||||||
218
json_copy.go
218
json_copy.go
@@ -1,218 +0,0 @@
|
|||||||
package promptkit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"reflect"
|
|
||||||
"strconv"
|
|
||||||
)
|
|
||||||
|
|
||||||
const maxSafeJSONInteger = 1<<53 - 1
|
|
||||||
|
|
||||||
type jsonVisit struct {
|
|
||||||
typ reflect.Type
|
|
||||||
ptr uintptr
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyPublicJSONMap(src map[string]any) (map[string]any, error) {
|
|
||||||
if src == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
copied, err := copyPublicJSONValue(reflect.ValueOf(src), "extra_params", make(map[jsonVisit]struct{}))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out, ok := copied.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("extra_params: expected object")
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyPublicJSONValue(value reflect.Value, path string, seen map[jsonVisit]struct{}) (any, error) {
|
|
||||||
if !value.IsValid() {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if value.Kind() == reflect.Interface {
|
|
||||||
if value.IsNil() {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return copyPublicJSONValue(value.Elem(), path, seen)
|
|
||||||
}
|
|
||||||
if !value.CanInterface() {
|
|
||||||
return nil, fmt.Errorf("%s: value cannot be copied", path)
|
|
||||||
}
|
|
||||||
if number, ok := value.Interface().(json.Number); ok {
|
|
||||||
f, err := strconv.ParseFloat(number.String(), 64)
|
|
||||||
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
|
|
||||||
return nil, fmt.Errorf("%s: invalid JSON number", path)
|
|
||||||
}
|
|
||||||
return number, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch value.Kind() {
|
|
||||||
case reflect.Bool, reflect.String:
|
|
||||||
return value.Interface(), nil
|
|
||||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
||||||
if value.Int() < -maxSafeJSONInteger || value.Int() > maxSafeJSONInteger {
|
|
||||||
return nil, fmt.Errorf("%s: integer is outside the JSON-safe range", path)
|
|
||||||
}
|
|
||||||
return value.Interface(), nil
|
|
||||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
|
||||||
if value.Uint() > maxSafeJSONInteger {
|
|
||||||
return nil, fmt.Errorf("%s: integer is outside the JSON-safe range", path)
|
|
||||||
}
|
|
||||||
return value.Interface(), nil
|
|
||||||
case reflect.Float32, reflect.Float64:
|
|
||||||
f := value.Convert(reflect.TypeOf(float64(0))).Float()
|
|
||||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
|
||||||
return nil, fmt.Errorf("%s: floating-point value must be finite", path)
|
|
||||||
}
|
|
||||||
return value.Interface(), nil
|
|
||||||
case reflect.Pointer:
|
|
||||||
if value.IsNil() {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
visit := jsonVisit{typ: value.Type(), ptr: value.Pointer()}
|
|
||||||
if _, ok := seen[visit]; ok {
|
|
||||||
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
|
||||||
}
|
|
||||||
seen[visit] = struct{}{}
|
|
||||||
defer delete(seen, visit)
|
|
||||||
return copyPublicJSONValue(value.Elem(), path, seen)
|
|
||||||
case reflect.Map:
|
|
||||||
return copyPublicJSONMapValue(value, path, seen)
|
|
||||||
case reflect.Slice:
|
|
||||||
if value.IsNil() {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return copyPublicJSONSequenceValue(value, path, seen)
|
|
||||||
case reflect.Array:
|
|
||||||
return copyPublicJSONSequenceValue(value, path, seen)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("%s: unsupported JSON value type %s", path, value.Type())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyPublicJSONMapValue(value reflect.Value, path string, seen map[jsonVisit]struct{}) (any, error) {
|
|
||||||
if value.IsNil() {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if value.Type().Key().Kind() != reflect.String {
|
|
||||||
return nil, fmt.Errorf("%s: map key type %s is not supported", path, value.Type().Key())
|
|
||||||
}
|
|
||||||
|
|
||||||
visit := jsonVisit{typ: value.Type(), ptr: value.Pointer()}
|
|
||||||
if _, ok := seen[visit]; ok {
|
|
||||||
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
|
||||||
}
|
|
||||||
seen[visit] = struct{}{}
|
|
||||||
defer delete(seen, visit)
|
|
||||||
|
|
||||||
type entry struct {
|
|
||||||
key reflect.Value
|
|
||||||
name string
|
|
||||||
value any
|
|
||||||
}
|
|
||||||
entries := make([]entry, 0, value.Len())
|
|
||||||
preserveType := true
|
|
||||||
elemType := value.Type().Elem()
|
|
||||||
iter := value.MapRange()
|
|
||||||
for iter.Next() {
|
|
||||||
key := iter.Key()
|
|
||||||
name := key.String()
|
|
||||||
copied, err := copyPublicJSONValue(iter.Value(), path+"."+name, seen)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
entries = append(entries, entry{key: key, name: name, value: copied})
|
|
||||||
if copied == nil {
|
|
||||||
if !canAssignNil(elemType) {
|
|
||||||
preserveType = false
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !reflect.TypeOf(copied).AssignableTo(elemType) {
|
|
||||||
preserveType = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if preserveType {
|
|
||||||
out := reflect.MakeMapWithSize(value.Type(), len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.value == nil {
|
|
||||||
out.SetMapIndex(entry.key, reflect.Zero(elemType))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out.SetMapIndex(entry.key, reflect.ValueOf(entry.value))
|
|
||||||
}
|
|
||||||
return out.Interface(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make(map[string]any, len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
out[entry.name] = entry.value
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyPublicJSONSequenceValue(value reflect.Value, path string, seen map[jsonVisit]struct{}) (any, error) {
|
|
||||||
var visit jsonVisit
|
|
||||||
if value.Kind() == reflect.Slice {
|
|
||||||
visit = jsonVisit{typ: value.Type(), ptr: value.Pointer()}
|
|
||||||
if _, ok := seen[visit]; ok {
|
|
||||||
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
|
||||||
}
|
|
||||||
seen[visit] = struct{}{}
|
|
||||||
defer delete(seen, visit)
|
|
||||||
}
|
|
||||||
|
|
||||||
values := make([]any, value.Len())
|
|
||||||
preserveType := true
|
|
||||||
elemType := value.Type().Elem()
|
|
||||||
for i := 0; i < value.Len(); i++ {
|
|
||||||
copied, err := copyPublicJSONValue(value.Index(i), fmt.Sprintf("%s[%d]", path, i), seen)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
values[i] = copied
|
|
||||||
if copied == nil {
|
|
||||||
if !canAssignNil(elemType) {
|
|
||||||
preserveType = false
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !reflect.TypeOf(copied).AssignableTo(elemType) {
|
|
||||||
preserveType = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if preserveType {
|
|
||||||
out := reflect.New(value.Type()).Elem()
|
|
||||||
if value.Kind() == reflect.Slice {
|
|
||||||
out = reflect.MakeSlice(value.Type(), value.Len(), value.Len())
|
|
||||||
}
|
|
||||||
for i, copied := range values {
|
|
||||||
if copied == nil {
|
|
||||||
out.Index(i).Set(reflect.Zero(elemType))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out.Index(i).Set(reflect.ValueOf(copied))
|
|
||||||
}
|
|
||||||
return out.Interface(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]any, len(values))
|
|
||||||
copy(out, values)
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func canAssignNil(typ reflect.Type) bool {
|
|
||||||
switch typ.Kind() {
|
|
||||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ func (r *memoryProfileRepository) GetProfile(_ context.Context, id string) (*dom
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toDomainProfile(publicProfile Profile) (domain.ExecutionProfile, error) {
|
func toDomainProfile(publicProfile Profile) (domain.ExecutionProfile, error) {
|
||||||
extraParams, err := copyPublicJSONMap(publicProfile.ExtraParams)
|
extraParams, err := jsonvalue.CopyMap(publicProfile.ExtraParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ExecutionProfile{}, err
|
return domain.ExecutionProfile{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ func TestCustomBackendFlowsThroughProfilesOverridesAndInjectedClient(t *testing.
|
|||||||
promptkit.WithProfiles(
|
promptkit.WithProfiles(
|
||||||
promptkit.Profile{ID: "backend-profile", BackendID: "custom", Model: "backend-model"},
|
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.Profile{ID: "profile-endpoint", BackendID: "custom", Endpoint: "http://profile.example/v1", Model: "profile-model"},
|
||||||
|
promptkit.Profile{ID: "blank-profile-endpoint", BackendID: "custom", Endpoint: " \t ", Model: "profile-model"},
|
||||||
),
|
),
|
||||||
promptkit.WithLLMClient(client),
|
promptkit.WithLLMClient(client),
|
||||||
)
|
)
|
||||||
@@ -188,6 +189,16 @@ func TestCustomBackendFlowsThroughProfilesOverridesAndInjectedClient(t *testing.
|
|||||||
t.Fatalf("profile endpoint override changed backend identity: %+v", prepared)
|
t.Fatalf("profile endpoint override changed backend identity: %+v", prepared)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prepared, err = engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||||
|
PromptID: "prompt", ProfileID: "blank-profile-endpoint",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepare blank profile endpoint: %v", err)
|
||||||
|
}
|
||||||
|
if prepared.SelectedBackendID != "custom" || prepared.EffectiveModelParams.Endpoint != "http://backend.example/v1" {
|
||||||
|
t.Fatalf("blank profile endpoint did not inherit backend endpoint: %+v", prepared)
|
||||||
|
}
|
||||||
|
|
||||||
prepared, err = engine.Prepare(context.Background(), promptkit.RunRequest{
|
prepared, err = engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||||
PromptID: "prompt",
|
PromptID: "prompt",
|
||||||
Execution: &promptkit.ExecutionTargetOverride{
|
Execution: &promptkit.ExecutionTargetOverride{
|
||||||
@@ -208,6 +219,7 @@ func TestCustomBackendSupportsFileProfileAndBothSelectionPaths(t *testing.T) {
|
|||||||
promptkit.WithProfileFS(fstest.MapFS{
|
promptkit.WithProfileFS(fstest.MapFS{
|
||||||
"profile.yaml": &fstest.MapFile{Data: []byte(`id: file-profile
|
"profile.yaml": &fstest.MapFile{Data: []byte(`id: file-profile
|
||||||
backend: file-backend
|
backend: file-backend
|
||||||
|
endpoint: " "
|
||||||
model: file-model
|
model: file-model
|
||||||
`)},
|
`)},
|
||||||
}, "."),
|
}, "."),
|
||||||
@@ -303,6 +315,7 @@ func TestBackendRegistrationRejectsInvalidAndDuplicateDefinitions(t *testing.T)
|
|||||||
{name: "invalid environment", backends: []promptkit.Backend{{ID: "custom", Endpoint: "http://example.test/v1", APIKeyEnv: "BAD-NAME"}}},
|
{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: "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: "cyclic extra parameter", backends: []promptkit.Backend{{ID: "custom", Endpoint: "http://example.test/v1", ExtraParams: cycle}}},
|
||||||
|
{name: "malformed JSON number", backends: []promptkit.Backend{{ID: "custom", Endpoint: "http://example.test/v1", ExtraParams: map[string]any{"value": json.Number("01")}}}},
|
||||||
{name: "duplicate consumer id", backends: []promptkit.Backend{
|
{name: "duplicate consumer id", backends: []promptkit.Backend{
|
||||||
{ID: " custom ", Endpoint: "http://one.example/v1"},
|
{ID: " custom ", Endpoint: "http://one.example/v1"},
|
||||||
{ID: "custom", Endpoint: "http://two.example/v1"},
|
{ID: "custom", Endpoint: "http://two.example/v1"},
|
||||||
|
|||||||
Reference in New Issue
Block a user