Compare commits
10 Commits
4ca3be2c14
...
c239304c2a
| Author | SHA1 | Date | |
|---|---|---|---|
| c239304c2a | |||
| 159b02116f | |||
| e5b7adfb49 | |||
| fa2384e696 | |||
| af0bd3f31a | |||
| 5fff8cd623 | |||
| b2a6c47778 | |||
| a1805fe550 | |||
| 93af155254 | |||
| d783b687a5 |
16
backends.go
16
backends.go
@@ -9,6 +9,10 @@ import (
|
|||||||
// backend.
|
// backend.
|
||||||
const BackendOpenRouter = backend.OpenRouterID
|
const BackendOpenRouter = backend.OpenRouterID
|
||||||
|
|
||||||
|
// BackendRakestrawHome is the reserved ID of Promptkit's built-in
|
||||||
|
// Rakestrawhome backend.
|
||||||
|
const BackendRakestrawHome = backend.RakestrawHomeID
|
||||||
|
|
||||||
// BackendLocal is the case-sensitive conventional ID used by [LocalBackend].
|
// BackendLocal is the case-sensitive conventional ID used by [LocalBackend].
|
||||||
// It is not a built-in or reserved backend and must be registered with
|
// It is not a built-in or reserved backend and must be registered with
|
||||||
// [WithBackend].
|
// [WithBackend].
|
||||||
@@ -20,7 +24,7 @@ const BackendLocal = "local"
|
|||||||
// to this configuration value do not break source compatibility.
|
// to this configuration value do not break source compatibility.
|
||||||
type Backend struct {
|
type Backend struct {
|
||||||
// ID is the stable, case-sensitive registry key. NewEngine trims it and
|
// ID is the stable, case-sensitive registry key. NewEngine trims it and
|
||||||
// requires a non-blank value. BackendOpenRouter is reserved.
|
// requires a non-blank value. Built-in backend IDs are reserved.
|
||||||
ID string
|
ID string
|
||||||
// Endpoint is the OpenAI-compatible base endpoint. NewEngine trims it and
|
// Endpoint is the OpenAI-compatible base endpoint. NewEngine trims it and
|
||||||
// requires an absolute HTTP or HTTPS URL with a host and without user
|
// requires an absolute HTTP or HTTPS URL with a host and without user
|
||||||
@@ -74,11 +78,11 @@ func LocalBackend(endpoint string, concurrencyLimit int) Backend {
|
|||||||
//
|
//
|
||||||
// Registrations accumulate in option order. Every normalized ID must be unique
|
// Registrations accumulate in option order. Every normalized ID must be unique
|
||||||
// across consumer registrations and built-ins; a duplicate or invalid
|
// across consumer registrations and built-ins; a duplicate or invalid
|
||||||
// definition makes NewEngine fail with ErrInvalidConfig. In particular,
|
// definition makes NewEngine fail with ErrInvalidConfig. Built-in IDs,
|
||||||
// BackendOpenRouter cannot be replaced. The immutable registration is scoped
|
// including [BackendOpenRouter] and [BackendRakestrawHome], cannot be
|
||||||
// to the resulting Engine and cannot be enumerated, replaced, removed, or
|
// replaced. The immutable registration is scoped to the resulting Engine and
|
||||||
// mutated after construction. WithBackend does not install package-global
|
// cannot be enumerated, replaced, removed, or mutated after construction.
|
||||||
// state.
|
// WithBackend does not install package-global state.
|
||||||
func WithBackend(backend Backend) Option {
|
func WithBackend(backend Backend) Option {
|
||||||
queueCapacity := 0
|
queueCapacity := 0
|
||||||
queueCapacitySet := backend.QueueCapacity != nil
|
queueCapacitySet := backend.QueueCapacity != nil
|
||||||
|
|||||||
16
doc.go
16
doc.go
@@ -23,8 +23,9 @@
|
|||||||
// InspectProfile return copied inspection values. Returned values and values
|
// InspectProfile return copied inspection values. Returned values and values
|
||||||
// passed to extension interfaces are likewise isolated from engine state.
|
// passed to extension interfaces are likewise isolated from engine state.
|
||||||
// Callers own those copies and may mutate them after the call that supplied or
|
// Callers own those copies and may mutate them after the call that supplied or
|
||||||
// returned them. Returned structured errors are likewise caller-owned and may
|
// returned them. [CapacityError] values are caller-owned and may be mutated
|
||||||
// be mutated without affecting engine state or another error.
|
// without affecting engine state or another error. Immutable [GenerationError]
|
||||||
|
// values are also caller-owned and do not retain shared engine state.
|
||||||
//
|
//
|
||||||
// # Security and sensitive data
|
// # Security and sensitive data
|
||||||
//
|
//
|
||||||
@@ -53,9 +54,14 @@
|
|||||||
// Construction, inspection, handle, and error values, including [Config],
|
// Construction, inspection, handle, and error values, including [Config],
|
||||||
// [Backend], [RunRequest], [ArtifactRef], [ExecutionTargetOverride], [Profile],
|
// [Backend], [RunRequest], [ArtifactRef], [ExecutionTargetOverride], [Profile],
|
||||||
// [OpenAICompatibleProfileConfig], [ProfileInspection],
|
// [OpenAICompatibleProfileConfig], [ProfileInspection],
|
||||||
// [PromptInputDefinition], [PromptInspection], [PreparedExecution], and
|
// [PromptInputDefinition], [PromptInspection], [PreparedExecution],
|
||||||
// [CapacityError], do not have stable JSON representations. Direct API keys
|
// [CapacityError], and [GenerationError], do not have stable JSON
|
||||||
// are nevertheless excluded from JSON for every public value.
|
// representations. Direct API keys are nevertheless excluded from JSON for
|
||||||
|
// every public value.
|
||||||
|
|
||||||
|
// Provider-derived [GenerationError] accessor values are untrusted and can
|
||||||
|
// contain sensitive request or schema fragments. Applications must apply their
|
||||||
|
// own disclosure policy before logging, displaying, or returning them.
|
||||||
//
|
//
|
||||||
// JSON timestamps use time.Time's RFC 3339 encoding and are omitted when zero.
|
// JSON timestamps use time.Time's RFC 3339 encoding and are omitted when zero.
|
||||||
// PreparedRun and RunResult durations are encoded as integer milliseconds in
|
// PreparedRun and RunResult durations are encoded as integer milliseconds in
|
||||||
|
|||||||
@@ -189,6 +189,33 @@ For programmatic profiles,
|
|||||||
[`OpenAICompatibleProfile`](../../profiles.go) converts ordinary
|
[`OpenAICompatibleProfile`](../../profiles.go) converts ordinary
|
||||||
OpenAI-compatible settings into a value accepted by `WithProfiles`.
|
OpenAI-compatible settings into a value accepted by `WithProfiles`.
|
||||||
|
|
||||||
|
### Use The Rakestrawhome Built-In Profile
|
||||||
|
|
||||||
|
Set `RAKESTRAWHOME_INFERENCE_API_KEY` in the application environment, then
|
||||||
|
select `rakestrawhome-gemma-4-31b` as an ordinary profile ID. For example, a
|
||||||
|
prepared result identifies the selected built-in through
|
||||||
|
`BackendRakestrawHome`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
prepared, err := engine.Prepare(ctx, promptkit.RunRequest{
|
||||||
|
PromptID: "meeting.summary",
|
||||||
|
ProfileID: "rakestrawhome-gemma-4-31b",
|
||||||
|
Inputs: inputs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if prepared.SelectedBackendID != promptkit.BackendRakestrawHome {
|
||||||
|
return fmt.Errorf("unexpected backend %q", prepared.SelectedBackendID)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not register `rakestrawhome` manually. When adopting this built-in, remove
|
||||||
|
an existing `WithBackend` registration with that exact ID; retaining it causes
|
||||||
|
the intentional duplicate-ID configuration error. Direct request credentials
|
||||||
|
and runtime endpoint overrides remain supported under their ordinary GoDoc and
|
||||||
|
format contracts.
|
||||||
|
|
||||||
### Inspect A Profile Before Prompt Work
|
### Inspect A Profile Before Prompt Work
|
||||||
|
|
||||||
Use [`Engine.InspectProfile`](../../engine.go) to validate one configured
|
Use [`Engine.InspectProfile`](../../engine.go) to validate one configured
|
||||||
@@ -423,6 +450,24 @@ status; those choices remain with the consuming application. The
|
|||||||
contract, while the [`Engine.Run` and error GoDoc](../../engine.go) owns broad
|
contract, while the [`Engine.Run` and error GoDoc](../../engine.go) owns broad
|
||||||
error and cancellation identities.
|
error and cancellation identities.
|
||||||
|
|
||||||
|
For a non-2xx response from the built-in OpenAI-compatible client, inspect the
|
||||||
|
status and deliberately selected provider diagnostic when useful:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var generationErr *promptkit.GenerationError
|
||||||
|
if errors.As(err, &generationErr) {
|
||||||
|
status := generationErr.StatusCode()
|
||||||
|
message := generationErr.ProviderMessage()
|
||||||
|
_, _ = status, message // Apply application retry and presentation policy.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All provider fields are untrusted and can contain sensitive request or schema
|
||||||
|
fragments. Do not log, display, or return them without an application-specific
|
||||||
|
disclosure policy. Promptkit does not assign retry or presentation behavior.
|
||||||
|
The [`GenerationError` GoDoc](../../generation_error.go) owns the exact typed
|
||||||
|
error contract.
|
||||||
|
|
||||||
## Application Boundary
|
## Application Boundary
|
||||||
|
|
||||||
Promptkit is an importable library. It does not own a command, inbound HTTP
|
Promptkit is an importable library. It does not own a command, inbound HTTP
|
||||||
|
|||||||
@@ -144,6 +144,17 @@ A request-level `OutputContract` replaces the complete prompt output contract.
|
|||||||
It does not merge individual fields. If its format is empty, Promptkit uses
|
It does not merge individual fields. If its format is empty, Promptkit uses
|
||||||
`text`.
|
`text`.
|
||||||
|
|
||||||
|
## Built-In Backends
|
||||||
|
|
||||||
|
Every engine provides these reserved OpenAI-compatible backend IDs. Consumers
|
||||||
|
must not register either ID with `WithBackend`; exact registration and
|
||||||
|
reservation behavior belongs to the [`Backend` GoDoc](../backends.go).
|
||||||
|
|
||||||
|
| ID | Base endpoint | API-key environment variable | Active generation limit | Default queue capacity |
|
||||||
|
| --- | --- | --- | ---: | ---: |
|
||||||
|
| `openrouter` | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` | 16 | 1024 |
|
||||||
|
| `rakestrawhome` | `https://inference.ai.rakestrawhome.com/v1` | `RAKESTRAWHOME_INFERENCE_API_KEY` | 4 | 1024 |
|
||||||
|
|
||||||
## Profile Definitions
|
## Profile Definitions
|
||||||
|
|
||||||
A profile supplies model execution settings:
|
A profile supplies model execution settings:
|
||||||
@@ -182,8 +193,8 @@ variable name in `api_key_env`.
|
|||||||
|
|
||||||
Promptkit does not infer a backend from a model or endpoint. Endpoint-only
|
Promptkit does not infer a backend from a model or endpoint. Endpoint-only
|
||||||
profiles remain supported and have no effective backend ID.
|
profiles remain supported and have no effective backend ID.
|
||||||
The engine always provides the built-in `openrouter` ID. Consumers can add
|
The engine always provides the built-in `openrouter` and `rakestrawhome` IDs.
|
||||||
engine-scoped IDs with
|
Consumers can add engine-scoped IDs with
|
||||||
[`WithBackend`](../backends.go); exact registration validation belongs to its
|
[`WithBackend`](../backends.go); exact registration validation belongs to its
|
||||||
GoDoc.
|
GoDoc.
|
||||||
|
|
||||||
@@ -257,39 +268,38 @@ precedence.
|
|||||||
|
|
||||||
## Built-In Profile Catalog
|
## Built-In Profile Catalog
|
||||||
|
|
||||||
Every built-in selects the `openrouter` backend. The engine's built-in backend
|
Every built-in profile selects one maintained built-in backend and inherits
|
||||||
registry supplies `https://openrouter.ai/api/v1` and the environment-variable
|
that backend's connection and credential metadata. Profile files do not repeat
|
||||||
name `OPENROUTER_API_KEY`, so individual profiles contain only model and
|
those values. A configured, application fallback, or in-memory profile with
|
||||||
generation settings. Built-in profile files do not repeat those connection
|
the same profile ID takes precedence.
|
||||||
values. A configured, application fallback, or in-memory profile with the same
|
|
||||||
profile ID takes precedence.
|
|
||||||
|
|
||||||
| Provider | ID | Model |
|
| Provider | ID | Backend | Model |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| aion-labs | `aion-2` | `aion-labs/aion-2.0` |
|
| aion-labs | `aion-2` | `openrouter` | `aion-labs/aion-2.0` |
|
||||||
| anthropic | `claude-fable-latest` | `~anthropic/claude-fable-latest` |
|
| anthropic | `claude-fable-latest` | `openrouter` | `~anthropic/claude-fable-latest` |
|
||||||
| anthropic | `claude-haiku-latest` | `~anthropic/claude-haiku-latest` |
|
| anthropic | `claude-haiku-latest` | `openrouter` | `~anthropic/claude-haiku-latest` |
|
||||||
| anthropic | `claude-opus-latest` | `~anthropic/claude-opus-latest` |
|
| anthropic | `claude-opus-latest` | `openrouter` | `~anthropic/claude-opus-latest` |
|
||||||
| anthropic | `claude-sonnet-latest` | `~anthropic/claude-sonnet-latest` |
|
| anthropic | `claude-sonnet-latest` | `openrouter` | `~anthropic/claude-sonnet-latest` |
|
||||||
| deepseek | `deepseek-3-2` | `deepseek/deepseek-v3.2` |
|
| deepseek | `deepseek-3-2` | `openrouter` | `deepseek/deepseek-v3.2` |
|
||||||
| deepseek | `deepseek-4-flash` | `deepseek/deepseek-v4-flash` |
|
| deepseek | `deepseek-4-flash` | `openrouter` | `deepseek/deepseek-v4-flash` |
|
||||||
| deepseek | `deepseek-4-pro` | `deepseek/deepseek-v4-pro` |
|
| deepseek | `deepseek-4-pro` | `openrouter` | `deepseek/deepseek-v4-pro` |
|
||||||
| google | `gemini-2-flash` | `google/gemini-2.5-flash` |
|
| google | `gemini-2-flash` | `openrouter` | `google/gemini-2.5-flash` |
|
||||||
| google | `gemini-2-flash-lite` | `google/gemini-2.5-flash-lite` |
|
| google | `gemini-2-flash-lite` | `openrouter` | `google/gemini-2.5-flash-lite` |
|
||||||
| google | `gemini-2-pro` | `google/gemini-2.5-pro` |
|
| google | `gemini-2-pro` | `openrouter` | `google/gemini-2.5-pro` |
|
||||||
| google | `gemini-3-flash-lite` | `google/gemini-3.1-flash-lite` |
|
| google | `gemini-3-flash-lite` | `openrouter` | `google/gemini-3.1-flash-lite` |
|
||||||
| google | `gemini-flash-latest` | `~google/gemini-flash-latest` |
|
| google | `gemini-flash-latest` | `openrouter` | `~google/gemini-flash-latest` |
|
||||||
| google | `gemini-pro-latest` | `~google/gemini-pro-latest` |
|
| google | `gemini-pro-latest` | `openrouter` | `~google/gemini-pro-latest` |
|
||||||
| google | `gemma-4-31b` | `google/gemma-4-31b-it:exacto` |
|
| google | `gemma-4-31b` | `openrouter` | `google/gemma-4-31b-it:exacto` |
|
||||||
| minimax | `minimax-m2` | `minimax/minimax-m2.5` |
|
| google | `rakestrawhome-gemma-4-31b` | `rakestrawhome` | `google/gemma-4-31b-it` |
|
||||||
| minimax | `minimax-m3` | `minimax/minimax-m3` |
|
| minimax | `minimax-m2` | `openrouter` | `minimax/minimax-m2.5` |
|
||||||
| mistral | `mistral-large-2512` | `mistralai/mistral-large-2512` |
|
| minimax | `minimax-m3` | `openrouter` | `minimax/minimax-m3` |
|
||||||
| mistral | `mistral-medium-3-5` | `mistralai/mistral-medium-3-5` |
|
| mistral | `mistral-large-2512` | `openrouter` | `mistralai/mistral-large-2512` |
|
||||||
| mistral | `mistral-small-3` | `mistralai/mistral-small-3.2-24b-instruct` |
|
| mistral | `mistral-medium-3-5` | `openrouter` | `mistralai/mistral-medium-3-5` |
|
||||||
| mistral | `mistral-small-4` | `mistralai/mistral-small-2603` |
|
| mistral | `mistral-small-3` | `openrouter` | `mistralai/mistral-small-3.2-24b-instruct` |
|
||||||
| nvidia | `nemotron-3-ultra` | `nvidia/nemotron-3-ultra-550b-a55b` |
|
| mistral | `mistral-small-4` | `openrouter` | `mistralai/mistral-small-2603` |
|
||||||
| openai | `gpt-5-mini` | `openai/gpt-5.4-mini` |
|
| nvidia | `nemotron-3-ultra` | `openrouter` | `nvidia/nemotron-3-ultra-550b-a55b` |
|
||||||
| openai | `gpt-5-nano` | `openai/gpt-5.4-nano` |
|
| openai | `gpt-5-mini` | `openrouter` | `openai/gpt-5.4-mini` |
|
||||||
|
| openai | `gpt-5-nano` | `openrouter` | `openai/gpt-5.4-nano` |
|
||||||
|
|
||||||
## Schemas
|
## Schemas
|
||||||
|
|
||||||
|
|||||||
@@ -102,10 +102,26 @@ and cache-write token counts. Invalid or truncated JSON, trailing non-whitespace
|
|||||||
data, a second JSON value, absent choices, empty first-choice content, and size
|
data, a second JSON value, absent choices, empty first-choice content, and size
|
||||||
overflow are malformed responses and return no partial result.
|
overflow are malformed responses and return no partial result.
|
||||||
|
|
||||||
For a non-2xx status, the error includes the status code but never the provider
|
For a non-2xx status, Promptkit recognizes one JSON document with a top-level
|
||||||
response body. Promptkit does not yet parse provider error envelopes; bounded
|
object-valued `error` member. Its optional `message` and `type` fields must be
|
||||||
non-success parsing belongs to the
|
strings, and `code` may be a string or JSON number. Valid supported fields are
|
||||||
[structured-generation-error roadmap](../roadmap/structured-generation-errors.md).
|
handled independently, numeric codes retain their JSON number text, and
|
||||||
|
unknown fields are ignored. Missing, invalid, malformed, or multiply framed
|
||||||
|
envelopes contribute no provider detail.
|
||||||
|
|
||||||
|
Non-success bodies have a 65,536-byte limit. A larger declared
|
||||||
|
`Content-Length` is not read; otherwise the client reads at most one additional
|
||||||
|
byte to detect streamed or underreported overflow. Empty, unreadable,
|
||||||
|
oversized, malformed, and unrecognized bodies retain only the received status.
|
||||||
|
The body is always closed and no oversized stream is drained beyond that probe.
|
||||||
|
|
||||||
|
Extracted strings are made valid UTF-8, trimmed, and converted to one line by
|
||||||
|
collapsing Unicode whitespace, control, and format-character runs. Blank
|
||||||
|
values are omitted. Codes and types longer than 256 Unicode code points are
|
||||||
|
omitted; messages longer than 4,096 code points are truncated at a code-point
|
||||||
|
boundary with an ellipsis inside the limit. Promptkit never exposes raw bodies,
|
||||||
|
headers, endpoints, credentials, request data, schemas, generated content, or
|
||||||
|
unsupported provider metadata through this handling.
|
||||||
|
|
||||||
An outbound `http.Client.Do` failure retains both Promptkit's request-failure
|
An outbound `http.Client.Do` failure retains both Promptkit's request-failure
|
||||||
identity and the exact transport error for `errors.Is` and `errors.As` checks.
|
identity and the exact transport error for `errors.Is` and `errors.As` checks.
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ resolved request target may supply the endpoint. Generation then:
|
|||||||
4. composes `/chat/completions` through parsed URL path operations;
|
4. composes `/chat/completions` through parsed URL path operations;
|
||||||
5. resolves authentication;
|
5. resolves authentication;
|
||||||
6. performs the outbound request under the applicable deadlines; and
|
6. performs the outbound request under the applicable deadlines; and
|
||||||
7. decodes one strictly framed, size-bounded response object and maps its first
|
7. decodes one strictly framed, size-bounded successful response object and
|
||||||
choice and token usage.
|
maps its first choice and token usage, or decodes bounded structured
|
||||||
|
non-success detail.
|
||||||
|
|
||||||
`internal/llm` owns the set of reserved OpenAI-compatible request fields used
|
`internal/llm` owns the set of reserved OpenAI-compatible request fields used
|
||||||
when validating extra parameters. Backend registration consumes the same rule
|
when validating extra parameters. Backend registration consumes the same rule
|
||||||
@@ -78,10 +79,15 @@ length and by reading at most one byte beyond the boundary. The decoder accepts
|
|||||||
exactly one JSON object plus trailing whitespace and EOF. Size overflow,
|
exactly one JSON object plus trailing whitespace and EOF. Size overflow,
|
||||||
truncation, malformed JSON, trailing data, and a second value are malformed
|
truncation, malformed JSON, trailing data, and a second value are malformed
|
||||||
responses with no partial result or provider content in the error. Every body
|
responses with no partial result or provider content in the error. Every body
|
||||||
is closed, and an unbounded oversized stream is not drained. Non-success
|
is closed, and an unbounded oversized stream is not drained.
|
||||||
responses remain status-only; bounded provider error-envelope parsing belongs
|
|
||||||
to the
|
For a non-success response, `ProviderHTTPError` retains the HTTP status and
|
||||||
[structured-generation-error roadmap](../roadmap/structured-generation-errors.md).
|
only normalized detail from the bounded recognized envelope. It retains
|
||||||
|
`ErrUnexpectedStatus` through unwrapping. The client owns response closure;
|
||||||
|
its bounded reader and parser never close or drain a body themselves. The root
|
||||||
|
facade converts this concrete internal error into the public
|
||||||
|
[`GenerationError`](../../generation_error.go), while arbitrary injected-client
|
||||||
|
errors continue through the ordinary generation-error mapping unchanged.
|
||||||
|
|
||||||
An `http.Client.Do` failure is represented by a redacting multi-cause error:
|
An `http.Client.Do` failure is represented by a redacting multi-cause error:
|
||||||
the package request-failure sentinel and the exact returned transport error are
|
the package request-failure sentinel and the exact returned transport error are
|
||||||
@@ -99,10 +105,14 @@ The
|
|||||||
own configuration, client cloning, deterministic deadline precedence,
|
own configuration, client cloning, deterministic deadline precedence,
|
||||||
authentication, request and response mapping, malformed data, error identity,
|
authentication, request and response mapping, malformed data, error identity,
|
||||||
cancellation, endpoint selection and composition, pre-transport rejection, and
|
cancellation, endpoint selection and composition, pre-transport rejection, and
|
||||||
bounded single-document response framing, closure, and response-body
|
bounded single-document successful-response framing, closure, and
|
||||||
suppression. The root
|
response-body suppression. The focused
|
||||||
transport contract tests also verify that resolved backend settings reach this
|
[provider HTTP error tests](../../internal/llm/provider_http_error_test.go)
|
||||||
client without serializing backend identity and that ordinary-run cancellation
|
own envelope parsing, normalization, and bounded-reader cases; their
|
||||||
retains its public generation and context identities. All use local test
|
[transport tests](../../internal/llm/provider_http_error_transport_test.go)
|
||||||
servers or controlled test transports; the default suite makes no live or paid
|
own non-success response closure and integration. Root transport contract tests
|
||||||
provider requests.
|
own public `GenerationError` conversion, while also verifying that resolved
|
||||||
|
backend settings reach this client without serializing backend identity and
|
||||||
|
that ordinary-run cancellation retains its public generation and context
|
||||||
|
identities. All use local test servers or controlled test transports; the
|
||||||
|
default suite makes no live or paid provider requests.
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ contributor workflow and validation.
|
|||||||
|
|
||||||
| Component | Implemented responsibility | References |
|
| Component | Implemented responsibility | References |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Root `promptkit` package | Provides the supported engine facade, source, backend-registration, and injection options, public request, result, prompt-inspection, and profile-inspection values, opaque prepared-execution handles, profile construction, extension interfaces, value conversion, redacted formatting, typed capacity errors, public error mapping, and engine-local profile-source assembly including application fallbacks. | [Package GoDoc](../../doc.go), [prepared execution](../../prepared_execution.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, result, prompt-inspection, and profile-inspection values, opaque prepared-execution handles, profile construction, extension interfaces, value conversion, redacted formatting, typed capacity and generation error mapping, and engine-local profile-source assembly including application fallbacks. | [Package GoDoc](../../doc.go), [prepared execution](../../prepared_execution.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 through the shared JSON-value package, and consumes the LLM-owned OpenAI-compatible reserved request-field rule. | [Backend registry](../../internal/backend/registry.go) |
|
| `internal/backend` | Constructs each engine's immutable registry from the maintained built-in definitions 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/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) |
|
| `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) |
|
||||||
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, profiles, execution targets, rendering, generation, and validation, and owns source-neutral invariants for shared execution settings, OpenAI-compatible base endpoints, session identifiers, and output contracts. Source parsing, required fields, other source-specific normalization, defaulting, and boundary-specific error classification remain with their callers. | [Domain declarations](../../internal/domain/domain.go), [endpoint invariant](../../internal/domain/endpoint.go) |
|
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, profiles, execution targets, rendering, generation, and validation, and owns source-neutral invariants for shared execution settings, OpenAI-compatible base endpoints, session identifiers, and output contracts. Source parsing, required fields, other source-specific normalization, defaulting, and boundary-specific error classification remain with their callers. | [Domain declarations](../../internal/domain/domain.go), [endpoint invariant](../../internal/domain/endpoint.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) |
|
||||||
@@ -22,11 +22,11 @@ contributor workflow and validation.
|
|||||||
| `internal/jsonvalue` | Validates and deeply copies bounded JSON-compatible extra-parameter and prepared-schema trees while preserving supported concrete value types and rejecting cycles or excessive depth and work. | [JSON values](../../internal/jsonvalue/jsonvalue.go) |
|
| `internal/jsonvalue` | Validates and deeply copies bounded JSON-compatible extra-parameter and prepared-schema trees while preserving supported concrete value types and rejecting cycles or excessive depth and work. | [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. | [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 maintained built-in backends. | [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 and creates operation-local validation plans with canonical contained schema resources. | [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 and creates operation-local validation plans with canonical contained schema resources. | [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, deadline handling, and ownership of the OpenAI-compatible reserved request-field policy. | [Internal model client](llm.md) |
|
| `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests from resolved execution targets, including bounded structured non-success response decoding, successful-response decoding, authentication, deadline handling, and ownership of the OpenAI-compatible reserved request-field policy. | [Internal model client](llm.md) |
|
||||||
| `internal/usecase` | Resolves prompt definitions and hashes, profiles, backends, and targets for exact inspection and request settings for preparation, and coordinates ordinary execution and one-attempt prepared execution across internal sources, rendering, artifact loading, operation-local validation plans, generation, capacity, and optional repair. | [Internal runner](runner.md), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) |
|
| `internal/usecase` | Resolves prompt definitions and hashes, profiles, backends, and targets for exact inspection and request settings for preparation, and coordinates ordinary execution and one-attempt prepared execution across internal sources, rendering, artifact loading, operation-local validation plans, generation, capacity, and optional repair. | [Internal runner](runner.md), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) |
|
||||||
|
|
||||||
The root package assembles these internal components without exposing their
|
The root package assembles these internal components without exposing their
|
||||||
|
|||||||
@@ -69,12 +69,13 @@ profile sources and checks the resolved target without reading prompt, input,
|
|||||||
or schema sources. It does not retain that lookup for a later execution.
|
or schema sources. It does not retain that lookup for a later execution.
|
||||||
|
|
||||||
`internal/profile/builtin` embeds the maintained built-in profile catalog.
|
`internal/profile/builtin` embeds the maintained built-in profile catalog.
|
||||||
Every embedded profile selects `openrouter` and inherits its endpoint and
|
Every embedded profile selects a maintained built-in backend and inherits that
|
||||||
credential environment-variable name from the built-in backend registry rather
|
backend's endpoint and credential environment-variable name from the built-in
|
||||||
than repeating those values. Profile loading and overlay behavior are owned by
|
backend registry rather than repeating those values. Profile loading and
|
||||||
the [profile repository tests](../../internal/profile/repository_test.go),
|
overlay behavior are owned by the
|
||||||
while catalog completeness, the backend-selection invariant, and duplicate IDs
|
[profile repository tests](../../internal/profile/repository_test.go), while
|
||||||
are owned by the
|
catalog completeness, the backend-selection invariant, and duplicate IDs are
|
||||||
|
owned by the
|
||||||
[built-in repository tests](../../internal/profile/builtin/repository_test.go).
|
[built-in repository tests](../../internal/profile/builtin/repository_test.go).
|
||||||
|
|
||||||
## Ordinary Artifacts
|
## Ordinary Artifacts
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ The implemented internal components consist of:
|
|||||||
- `internal/domain`, which owns framework data values and source-neutral
|
- `internal/domain`, which owns framework data values and source-neutral
|
||||||
invariants shared by later internal components;
|
invariants shared by later internal components;
|
||||||
- `internal/backend`, which owns validated immutable OpenAI-compatible backend
|
- `internal/backend`, which owns validated immutable OpenAI-compatible backend
|
||||||
definitions and the built-in OpenRouter definition;
|
definitions and the maintained built-in definitions;
|
||||||
- `internal/capacity`, which owns engine-local bounded run admission and
|
- `internal/capacity`, which owns engine-local bounded run admission and
|
||||||
model-generation scheduling for limited backends;
|
model-generation scheduling for limited backends;
|
||||||
- `internal/defaults`, which owns application-neutral framework defaults and
|
- `internal/defaults`, which owns application-neutral framework defaults and
|
||||||
|
|||||||
437
docs/roadmap/implementation.md
Normal file
437
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
# Structured Generation Errors Implementation Plan
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Implement the target state defined in the
|
||||||
|
[structured generation errors roadmap](structured-generation-errors.md): turn
|
||||||
|
every non-2xx response from the built-in OpenAI-compatible client into a
|
||||||
|
bounded, immutable public error that exposes deliberate provider diagnostics
|
||||||
|
through `errors.As` while retaining `ErrLLMGenerate` through `errors.Is`.
|
||||||
|
|
||||||
|
This document owns implementation sequencing. The feature roadmap owns the
|
||||||
|
consumer intent, public-policy decisions, safety limits, compatibility
|
||||||
|
boundaries, and non-goals. Follow the architecture, documentation, and testing
|
||||||
|
policies under [`docs/policy/`](../policy/) throughout the work.
|
||||||
|
|
||||||
|
## Fixed Decisions
|
||||||
|
|
||||||
|
- The public type is `GenerationError`. Engine-produced values are pointers;
|
||||||
|
fields are unexported; no public constructor or mutation API is added.
|
||||||
|
- Public methods are `StatusCode() int`, `ProviderCode() string`,
|
||||||
|
`ProviderType() string`, `ProviderMessage() string`, `Error() string`,
|
||||||
|
`GoString() string`, and `Unwrap() error`.
|
||||||
|
- Public `Unwrap` returns `ErrLLMGenerate`. Accessors, formatting, and unwrapping
|
||||||
|
are safe on a nil receiver and a zero value.
|
||||||
|
- Engine-produced `Error()` text is exactly
|
||||||
|
`failed to generate output: provider returned HTTP status N`, where `N` is
|
||||||
|
the received status. A nil receiver or zero status returns exactly
|
||||||
|
`failed to generate output`. `GoString()` returns the same redacted text as
|
||||||
|
`Error()` so `%#v` cannot reveal unexported provider fields.
|
||||||
|
- Default formatting contains no provider-controlled code, type, message, or
|
||||||
|
raw response content. The type has no stable JSON representation.
|
||||||
|
- The recognized body is one JSON document with a top-level object-valued
|
||||||
|
`error`. `message` and `type` accept strings; `code` accepts a string or an
|
||||||
|
exact `json.Number`; supported fields are independent and unknown fields are
|
||||||
|
ignored.
|
||||||
|
- The non-success-body limit is 65,536 bytes. Declared oversize bodies are not
|
||||||
|
read; other bodies are read through a 65,537-byte bound. Oversize, malformed,
|
||||||
|
unrecognized, or unreadable bodies produce status-only detail.
|
||||||
|
- Normalization converts invalid UTF-8, collapses Unicode whitespace, control,
|
||||||
|
and format-character runs to one ASCII space, trims the result, omits blank
|
||||||
|
values, and produces one-line strings.
|
||||||
|
- Codes and types longer than 256 Unicode code points are omitted. Messages
|
||||||
|
longer than 4,096 code points retain the first 4,095 code points followed by
|
||||||
|
`…`, for a total limit of 4,096.
|
||||||
|
- Only the concrete built-in transport error is converted into a new public
|
||||||
|
`GenerationError`. Arbitrary injected-client errors are never inspected or
|
||||||
|
enriched.
|
||||||
|
- The use-case and domain layers remain provider-neutral. No retryability,
|
||||||
|
retry, logging, presentation, provider-specific envelope, header, or success-
|
||||||
|
response behavior is added.
|
||||||
|
|
||||||
|
## Execution Rules
|
||||||
|
|
||||||
|
- Complete the stages in numerical order. Each stage is scoped for one
|
||||||
|
gpt-5.6-terra implementation prompt and must finish its focused tests before
|
||||||
|
the next begins.
|
||||||
|
- Treat all stages as one feature delivery. Intermediate stages intentionally
|
||||||
|
create internal machinery before exposing it; do not release, tag, or claim
|
||||||
|
the feature is available until Stage 5 is complete.
|
||||||
|
- At the start of each stage, reread the feature roadmap and the task-specific
|
||||||
|
references in [`docs/development.md`](../development.md). Preserve unrelated
|
||||||
|
working-tree changes.
|
||||||
|
- Use classical behavior tests at the narrowest owner. Table-drive parser and
|
||||||
|
boundary cases, use controlled transports or local servers, and do not
|
||||||
|
duplicate the internal envelope matrix at the root engine boundary.
|
||||||
|
- Do not contact a live provider, add dependencies, commit, tag, push, or edit
|
||||||
|
release documentation unless separately instructed.
|
||||||
|
- Current-state prose documentation changes in Stage 5. Public GoDoc changes
|
||||||
|
alongside the public declarations in Stage 4 because GoDoc owns that API.
|
||||||
|
|
||||||
|
## Stage 1: Add the Internal Structured Status Error and Envelope Parser
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Create the transport-owned structured value and pure parsing and normalization
|
||||||
|
logic without changing `OpenAICompatibleClient.Generate` yet.
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
1. Add `internal/llm/provider_http_error.go`. Keep all provider HTTP mechanics
|
||||||
|
in `internal/llm`; do not add an HTTP error DTO to `internal/domain` or
|
||||||
|
`internal/usecase`.
|
||||||
|
2. Define these private constants:
|
||||||
|
- `maxProviderErrorResponseBytes int64 = 64 << 10`;
|
||||||
|
- `maxProviderErrorIdentifierRunes = 256`; and
|
||||||
|
- `maxProviderErrorMessageRunes = 4096`.
|
||||||
|
3. Add an exported-within-`internal` `ProviderHTTPError` type with unexported
|
||||||
|
`statusCode`, `providerCode`, `providerType`, and `providerMessage` fields.
|
||||||
|
The root facade will need to name this concrete type in Stage 4, but no
|
||||||
|
representation is public outside the module's `internal` boundary.
|
||||||
|
4. Give `ProviderHTTPError` nil-safe read-only accessors with the same four
|
||||||
|
names as the planned public type. Implement:
|
||||||
|
- `Error()` as `llm returned non-success status: status=N` when status is
|
||||||
|
nonzero and `llm returned non-success status` otherwise;
|
||||||
|
- `GoString()` by returning `Error()`; and
|
||||||
|
- `Unwrap()` by returning `ErrUnexpectedStatus`.
|
||||||
|
Never include provider-derived strings in either formatter.
|
||||||
|
5. Add a private `providerErrorDetails` value and a private constructor that
|
||||||
|
builds `*ProviderHTTPError` from a status plus already normalized details.
|
||||||
|
6. Add `parseProviderErrorEnvelope([]byte) providerErrorDetails` with these
|
||||||
|
rules:
|
||||||
|
- use `json.Decoder` with `UseNumber` and require EOF after trailing JSON
|
||||||
|
whitespace;
|
||||||
|
- require a top-level object and object-valued `error` member;
|
||||||
|
- retain supported fields as `json.RawMessage` so each can be decoded and
|
||||||
|
validated independently;
|
||||||
|
- accept string `message` and `type` values;
|
||||||
|
- accept string or `json.Number` `code`, preserving validated number text
|
||||||
|
without float conversion;
|
||||||
|
- ignore unknown fields and treat invalid supported-field values as absent;
|
||||||
|
and
|
||||||
|
- return empty details for malformed framing, a missing or invalid `error`
|
||||||
|
object, or an object with no usable fields.
|
||||||
|
7. Add private normalization helpers that implement the roadmap's UTF-8,
|
||||||
|
single-line, whitespace/control/format handling and exact rune limits. Use
|
||||||
|
rune-aware operations; do not truncate bytes in the middle of UTF-8. Omit
|
||||||
|
overlong code and type identifiers, and truncate overlong messages to 4,095
|
||||||
|
code points plus `…`.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Add `internal/llm/provider_http_error_test.go` in package `llm` with focused,
|
||||||
|
table-driven tests:
|
||||||
|
|
||||||
|
1. `TestProviderHTTPErrorEnvelopeParsing` covers:
|
||||||
|
- all supported string fields;
|
||||||
|
- string, integer, fractional, and exponent-form numeric codes without
|
||||||
|
float coercion;
|
||||||
|
- `null` and invalid field types handled independently;
|
||||||
|
- unknown top-level and nested fields;
|
||||||
|
- missing, null, scalar, and empty `error` values;
|
||||||
|
- malformed, truncated, trailing-garbage, and second-document input; and
|
||||||
|
- no raw or unsupported metadata retained.
|
||||||
|
2. `TestProviderErrorTextNormalizationAndLimits` covers valid multibyte text,
|
||||||
|
invalid UTF-8 replacement, leading/trailing and repeated whitespace,
|
||||||
|
newline/tab/control/format characters, blank normalization, exact identifier
|
||||||
|
and message boundaries, identifier omission one rune over, and rune-safe
|
||||||
|
message ellipsis one rune over.
|
||||||
|
3. `TestProviderHTTPErrorIdentityAndFormatting` covers exact accessors,
|
||||||
|
`errors.Is(err, ErrUnexpectedStatus)`, nil receivers, zero values, and safe
|
||||||
|
`%v`, `%+v`, and `%#v` formatting with distinctive provider markers absent.
|
||||||
|
|
||||||
|
Do not test body reading, HTTP response ownership, the root public type, or
|
||||||
|
assembled engine behavior in this stage.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/llm -run 'TestProvider'
|
||||||
|
go test ./internal/llm
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage 1 is complete when the parser and internal error are fully protected but
|
||||||
|
the live non-2xx branch remains unchanged.
|
||||||
|
|
||||||
|
**Status:** Complete.
|
||||||
|
|
||||||
|
## Stage 2: Add the Bounded Non-Success Body Reader
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Implement and test bounded response-body extraction independently from HTTP
|
||||||
|
client integration, keeping status preservation separate from envelope
|
||||||
|
validity.
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
1. In `internal/llm/provider_http_error.go`, add a private helper with the
|
||||||
|
equivalent contract of:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func providerHTTPErrorFromBody(
|
||||||
|
statusCode int,
|
||||||
|
contentLength int64,
|
||||||
|
body io.Reader,
|
||||||
|
) *ProviderHTTPError
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Always return a nonnil `ProviderHTTPError` carrying `statusCode`.
|
||||||
|
3. When `contentLength` is greater than 65,536, return status-only detail
|
||||||
|
without reading `body`.
|
||||||
|
4. Otherwise read through an `io.LimitedReader` capped at 65,537 bytes. Return
|
||||||
|
status-only detail on a read error or when the extra byte is consumed. Do
|
||||||
|
not parse a bounded prefix of an incomplete oversized body.
|
||||||
|
5. For a complete body at or below the limit, call
|
||||||
|
`parseProviderErrorEnvelope` and construct the error from its normalized
|
||||||
|
details.
|
||||||
|
6. The helper does not close or drain `body`; the HTTP caller retains response-
|
||||||
|
body ownership. It must never read beyond the one-byte overflow probe.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Extend `internal/llm/provider_http_error_test.go` with
|
||||||
|
`TestProviderHTTPErrorBodyBounds`, using counting, failing, and guarded readers
|
||||||
|
instead of an HTTP server. Cover:
|
||||||
|
|
||||||
|
- an ordinary recognized envelope;
|
||||||
|
- an exact 65,536-byte body, using trailing JSON whitespace to reach the
|
||||||
|
boundary while remaining one valid document;
|
||||||
|
- a declared 65,537-byte body with zero reads;
|
||||||
|
- unknown-length and underreported 65,537-byte bodies with exactly 65,537 bytes
|
||||||
|
read and status-only detail;
|
||||||
|
- an early read failure with status-only detail; and
|
||||||
|
- an empty body with status-only detail.
|
||||||
|
|
||||||
|
Assert the provider markers are absent whenever extraction is discarded. Do
|
||||||
|
not add body-closure assertions here because this helper does not own closing.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/llm -run 'TestProviderHTTPErrorBodyBounds|TestProvider'
|
||||||
|
go test ./internal/llm
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage 2 is complete when every read path is deterministically bounded and the
|
||||||
|
HTTP client's current branch is still untouched.
|
||||||
|
|
||||||
|
**Status:** Complete.
|
||||||
|
|
||||||
|
## Stage 3: Integrate Structured Status Errors into the Built-In Client
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Replace the built-in client's status-only discard branch with the bounded
|
||||||
|
internal error while preserving all successful, cancellation, transport, and
|
||||||
|
body-ownership behavior.
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
1. In `internal/llm/openai_compatible_client.go`, replace the non-2xx branch's
|
||||||
|
4,096-byte discard and formatted sentinel with
|
||||||
|
`providerHTTPErrorFromBody(httpResp.StatusCode, httpResp.ContentLength,
|
||||||
|
httpResp.Body)`.
|
||||||
|
2. Keep the existing `defer httpResp.Body.Close()` as the single body-closure
|
||||||
|
owner. Do not close in the helper, drain after the bound, or reuse the 16 MiB
|
||||||
|
successful-response decoder or limit.
|
||||||
|
3. Return no partial `GenerateResponse` for every non-2xx response.
|
||||||
|
4. Preserve `errors.Is(err, ErrUnexpectedStatus)` through
|
||||||
|
`ProviderHTTPError.Unwrap`. Do not change `requestFailedError`, endpoint or
|
||||||
|
request validation, authentication, timeout handling, successful response
|
||||||
|
decoding, or response-size behavior.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
1. Update the existing non-success case in
|
||||||
|
`internal/llm/openai_compatible_client_test.go` to assert
|
||||||
|
`errors.As(err, &providerHTTPError)`, exact status, and continued
|
||||||
|
`ErrUnexpectedStatus` identity. Keep the existing raw-body redaction check.
|
||||||
|
2. Add `internal/llm/provider_http_error_transport_test.go` with:
|
||||||
|
- `TestOpenAICompatibleClientStructuredNonSuccessResponse`, proving a
|
||||||
|
recognized envelope supplies all normalized internal fields and returns
|
||||||
|
no generation result;
|
||||||
|
- `TestOpenAICompatibleClientNonSuccessBodyOwnership`, table-driving normal,
|
||||||
|
declared-oversize, streamed-oversize, underreported, malformed, and read-
|
||||||
|
failure cases through a controlled transport; and
|
||||||
|
- assertions that every body is closed, no case reads beyond its bound,
|
||||||
|
declared oversize performs no read, and discarded details remain empty.
|
||||||
|
3. Reuse existing controlled-transport and counting-reader helpers when they
|
||||||
|
are clear and package-local. Do not duplicate successful-response framing,
|
||||||
|
timeout, authentication, or endpoint matrices.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/llm -run 'TestOpenAICompatibleClient.*NonSuccess|TestProvider'
|
||||||
|
go test ./internal/llm
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage 3 is complete when the built-in client emits the structured internal
|
||||||
|
error for every non-2xx response and all prior internal identities remain
|
||||||
|
green.
|
||||||
|
|
||||||
|
**Status:** Complete.
|
||||||
|
|
||||||
|
## Stage 4: Add the Public Error and Root Mapping Contract
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Translate only the built-in transport's structured error at the root facade,
|
||||||
|
publish the immutable consumer API, and prove ordinary, prepared, and injected-
|
||||||
|
client behavior.
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
1. Add `generation_error.go` in package `promptkit` with an immutable public
|
||||||
|
`GenerationError` whose four fields are unexported strings or integers. Add
|
||||||
|
one private constructor that accepts the four normalized scalar values. The
|
||||||
|
public error file must not import `internal/llm` or retain the internal error
|
||||||
|
or raw body; `errors.go` performs that adaptation at the facade boundary.
|
||||||
|
2. Implement the four public accessors exactly as fixed above. Each returns
|
||||||
|
zero or empty on a nil receiver.
|
||||||
|
3. Implement `Error()` with the fixed strings from this plan, `GoString()` by
|
||||||
|
returning `Error()`, and `Unwrap()` by returning `ErrLLMGenerate` even for a
|
||||||
|
nil receiver. Do not implement mutable fields, an exported constructor,
|
||||||
|
retry helpers, HTTP mapping, `fmt.Formatter`, or JSON methods.
|
||||||
|
4. Write complete GoDoc covering:
|
||||||
|
- built-in-client and non-2xx scope;
|
||||||
|
- ordinary and prepared execution;
|
||||||
|
- `errors.Is` and pointer-target `errors.As` usage;
|
||||||
|
- immutable and caller-owned semantics;
|
||||||
|
- nil and zero behavior;
|
||||||
|
- lack of stable JSON;
|
||||||
|
- safe default formatting; and
|
||||||
|
- the fact that every provider accessor is untrusted and may contain
|
||||||
|
sensitive request or schema fragments.
|
||||||
|
5. In `errors.go`, after the special capacity conversion and before generic
|
||||||
|
sentinel wrapping, use `errors.As` for a nonnil concrete
|
||||||
|
`*llm.ProviderHTTPError`. Convert it to `*GenerationError` and return that
|
||||||
|
public value directly. Do not parse error text or recognize an interface
|
||||||
|
that an injected client could accidentally satisfy.
|
||||||
|
6. Preserve the existing generic `publicErrorFor` path for all other failures.
|
||||||
|
In particular, arbitrary injected-client errors remain wrapped with
|
||||||
|
`ErrLLMGenerate` and retain their original identity.
|
||||||
|
7. Update public GoDoc in the same stage:
|
||||||
|
- the `ErrLLMGenerate` declaration points to `GenerationError` for built-in
|
||||||
|
non-2xx responses;
|
||||||
|
- `Engine.Run` and `Engine.RunPrepared` mention the typed error without
|
||||||
|
restating its accessors;
|
||||||
|
- `doc.go` distinguishes mutable `CapacityError` from immutable
|
||||||
|
`GenerationError`, lists both as lacking stable JSON, and calls out the
|
||||||
|
provider-detail trust boundary; and
|
||||||
|
- injected `LLMClient` GoDoc remains clear that arbitrary client errors are
|
||||||
|
preserved rather than translated.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
1. Add package-internal tests for `GenerationError` nil and zero receivers,
|
||||||
|
exact fixed formatting, and unwrapping. Do not expose a test-only public
|
||||||
|
constructor or turn the lack of stable JSON into a serialized-output
|
||||||
|
contract.
|
||||||
|
2. Add a focused external-package contract test, preferably in
|
||||||
|
`generation_error_contract_test.go`, that obtains errors through a real
|
||||||
|
assembled engine with a controlled HTTP transport:
|
||||||
|
- an ordinary `Run` with a recognized envelope asserts a nil result,
|
||||||
|
`errors.Is(err, ErrLLMGenerate)`, pointer-target `errors.As`, all four
|
||||||
|
accessors, exact status-bearing formatting, and absence of distinctive
|
||||||
|
code/type/message markers from `%v`, `%+v`, and `%#v`;
|
||||||
|
- one `RunPrepared` case proves the same public type and status cross the
|
||||||
|
prepared boundary without repeating every parser field; and
|
||||||
|
- neither case contacts a live provider or uses a real credential.
|
||||||
|
3. Extend the existing injected-client preservation owner with one assertion
|
||||||
|
that an arbitrary injected error does not become a `*GenerationError`, while
|
||||||
|
still matching both `ErrLLMGenerate` and the injected error.
|
||||||
|
4. Keep detailed envelope, normalization, size, and body-ownership matrices in
|
||||||
|
`internal/llm`; root tests remain representative.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test . -run 'TestGenerationError|TestBuiltInGenerationError|TestRunAddsLLMGenerate'
|
||||||
|
go test ./internal/llm
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage 4 is complete when consumers can inspect built-in non-2xx details through
|
||||||
|
the stable root contract and injected errors remain untouched.
|
||||||
|
|
||||||
|
**Status:** Complete.
|
||||||
|
|
||||||
|
## Stage 5: Update Canonical Documentation and Run Full Validation
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Make durable documentation match the implemented contract, remove the roadmap
|
||||||
|
links that describe parsing as future work, and complete repository-wide
|
||||||
|
validation.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
1. Update `docs/integrations/openai-compatible-chat.md` as the canonical wire
|
||||||
|
owner. Replace the status-only paragraph with:
|
||||||
|
- the recognized top-level envelope and independent field types;
|
||||||
|
- strict single-document framing and unknown-field behavior;
|
||||||
|
- the exact 65,536-byte read policy and status-only fallbacks;
|
||||||
|
- exact normalization and field limits;
|
||||||
|
- response closure and no-overread behavior; and
|
||||||
|
- the prohibition on raw bodies, headers, endpoints, credentials, request
|
||||||
|
data, schemas, and generated content.
|
||||||
|
2. Update `docs/internal/llm.md` with the internal `ProviderHTTPError`, bounded
|
||||||
|
reader and parser flow, retained `ErrUnexpectedStatus` identity, root
|
||||||
|
conversion boundary, and narrow test ownership. Remove its link that defers
|
||||||
|
parsing to the feature roadmap.
|
||||||
|
3. Update `docs/consumers/pkg-promptkit.md` under `Handle Errors` with one short
|
||||||
|
`errors.As` example. Show status and deliberate message access, warn that all
|
||||||
|
provider fields are untrusted and potentially sensitive, leave retry and
|
||||||
|
presentation policy to the application, and link to `GenerationError`
|
||||||
|
GoDoc rather than duplicating its full contract.
|
||||||
|
4. Update `docs/internal/overview.md` only enough to inventory implemented
|
||||||
|
responsibilities: the root facade owns typed capacity and generation error
|
||||||
|
mapping, and `internal/llm` owns bounded structured non-success response
|
||||||
|
decoding. Do not duplicate limits or accessor details there.
|
||||||
|
5. Do not change `docs/policy/architecture.md`, `docs/formats.md`, backend
|
||||||
|
documentation, release notes, or the README unless implementation reveals a
|
||||||
|
concrete inaccurate statement. Their canonical topics do not own this
|
||||||
|
contract.
|
||||||
|
|
||||||
|
### Final Validation
|
||||||
|
|
||||||
|
Run the complete maintainer workflow from
|
||||||
|
[`docs/development.md#maintainer-validation`](../development.md#maintainer-validation):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./...
|
||||||
|
go test -race ./...
|
||||||
|
go vet ./...
|
||||||
|
go build ./...
|
||||||
|
go run ./examples/go-library/prepare
|
||||||
|
go run ./examples/go-library/run
|
||||||
|
```
|
||||||
|
|
||||||
|
Also perform the documented Go-formatting, local Markdown-link, repository-
|
||||||
|
hygiene, ignored-file, and credential scans. Review both example outputs and
|
||||||
|
confirm that all commands remain deterministic, offline, and credential-free.
|
||||||
|
|
||||||
|
Finally review the complete diff against the feature roadmap and confirm:
|
||||||
|
|
||||||
|
- every built-in non-2xx response produces a status-bearing public type;
|
||||||
|
- malformed and oversized bodies cannot erase status or leak partial content;
|
||||||
|
- provider-derived strings appear only through deliberate accessors;
|
||||||
|
- default and Go-syntax formatting are redacted;
|
||||||
|
- successful, cancellation, capacity, validation, repair, and injected-client
|
||||||
|
behavior is unchanged;
|
||||||
|
- no provider policy entered the use-case or domain layers; and
|
||||||
|
- each exact contract has one canonical documentation and test owner.
|
||||||
|
|
||||||
|
**Status:** Complete.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None. The roadmap and this plan fix the public API, internal representation,
|
||||||
|
wire envelope, normalization and bounds, status fallback, formatting,
|
||||||
|
propagation, compatibility, documentation, and verification decisions required
|
||||||
|
for implementation.
|
||||||
@@ -5,67 +5,256 @@
|
|||||||
Promptkit should give downstream applications actionable, machine-readable
|
Promptkit should give downstream applications actionable, machine-readable
|
||||||
details when the built-in OpenAI-compatible client receives a non-success HTTP
|
details when the built-in OpenAI-compatible client receives a non-success HTTP
|
||||||
response. Today the client reports only the status code and discards the
|
response. Today the client reports only the status code and discards the
|
||||||
provider response body. This makes ordinary configuration failures—such as an
|
provider response body. This makes ordinary configuration failures, such as an
|
||||||
unsupported strict JSON Schema keyword—unnecessarily difficult to diagnose.
|
unsupported strict JSON Schema keyword, unnecessarily difficult to diagnose.
|
||||||
|
|
||||||
|
This feature supplies bounded facts about the provider response. It does not
|
||||||
|
make retry, presentation, or logging decisions for consumers.
|
||||||
|
|
||||||
## Target End State
|
## Target End State
|
||||||
|
|
||||||
Failures from the built-in transport are available through a public typed error
|
Every non-2xx response received by Promptkit's built-in OpenAI-compatible
|
||||||
that works with `errors.As` while continuing to match `ErrLLMGenerate` through
|
client becomes a public typed generation error. A consumer can use
|
||||||
`errors.Is`. The error should expose:
|
`errors.As` to obtain the HTTP status and any safely extracted provider fields,
|
||||||
|
and `errors.Is` continues to match `ErrLLMGenerate`.
|
||||||
|
|
||||||
- the HTTP status code;
|
The typed contract is available from both `Run` and `RunPrepared`. It is not
|
||||||
- a normalized provider error code or type when supplied; and
|
produced during preparation, which performs no model request. Successful
|
||||||
- a bounded provider message extracted from a recognized OpenAI-compatible
|
responses, transport failures before a response is received, cancellation,
|
||||||
JSON error envelope.
|
capacity failures, validation failures, and nil responses from injected model
|
||||||
|
clients retain their existing categories and behavior.
|
||||||
|
|
||||||
The ordinary `Error()` string should remain safe and concise: it should include
|
An unusable response body never hides the known HTTP status. Empty, malformed,
|
||||||
the status and provider code or type, but not automatically include the
|
unrecognized, unreadable, or oversized bodies therefore produce the same typed
|
||||||
provider message. Consumers that deliberately want the provider's diagnostic
|
error with status-only detail rather than falling back to an unstructured
|
||||||
text can retrieve it from the typed error and apply their own disclosure and
|
error or becoming a malformed-success response.
|
||||||
logging policy.
|
|
||||||
|
|
||||||
This contract should be available for both ordinary and prepared execution.
|
## Public Contract
|
||||||
Errors returned by injected model clients must continue to preserve their own
|
|
||||||
identity and should not be converted into fabricated HTTP details.
|
|
||||||
|
|
||||||
## Safety And Compatibility Boundaries
|
The root package exposes an immutable `GenerationError` type with unexported
|
||||||
|
state and these read-only accessors:
|
||||||
|
|
||||||
- Never expose the raw response body, response headers, endpoint, credentials,
|
- `StatusCode() int` returns the received HTTP status code;
|
||||||
request messages, schema document, or generated content through this API.
|
- `ProviderCode() string` returns a normalized provider code, when present;
|
||||||
- Read only a small fixed maximum response body, reject malformed or
|
- `ProviderType() string` returns a normalized provider error type, when
|
||||||
unrecognized envelopes, normalize invalid UTF-8 and control characters, and
|
present; and
|
||||||
cap every retained diagnostic field independently.
|
- `ProviderMessage() string` returns the bounded normalized diagnostic message,
|
||||||
- Treat the extracted provider message as untrusted and potentially sensitive:
|
when present.
|
||||||
its GoDoc must tell consumers not to log or display it without applying their
|
|
||||||
own policy.
|
|
||||||
- Preserve the existing generic behavior when a response is empty, non-JSON,
|
|
||||||
oversized, or does not match a recognized error envelope.
|
|
||||||
- Do not assign retryability from an HTTP status. Promptkit supplies facts;
|
|
||||||
downstream applications retain retry and presentation policy.
|
|
||||||
|
|
||||||
## Recommended API Direction
|
The engine returns a `*GenerationError`, so the idiomatic inspection form is:
|
||||||
|
|
||||||
Prefer one immutable public `GenerationError` value, constructed internally and
|
```go
|
||||||
carrying accessors for HTTP status, provider code or type, and provider message.
|
var generationErr *promptkit.GenerationError
|
||||||
This keeps the exact representation evolvable while giving consumers an
|
if errors.As(err, &generationErr) {
|
||||||
idiomatic `errors.As` contract. Public Go declarations and GoDoc should own the
|
status := generationErr.StatusCode()
|
||||||
final exact names and semantics.
|
message := generationErr.ProviderMessage()
|
||||||
|
_, _ = status, message
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
The internal OpenAI-compatible client should parse only the conventional
|
There is no public constructor or mutation API. The type implements `error`,
|
||||||
top-level `error` envelope and pass normalized details through the use-case and
|
unwraps to `ErrLLMGenerate`, and provides safe ordinary and Go-syntax
|
||||||
public error-mapping layers. The integration documentation should continue to
|
formatting. `Error()` and `GoString()` include the HTTP status but no provider-
|
||||||
own wire behavior; the public declarations should own the consumer contract.
|
controlled code, type, or message. Consumers must use the accessors
|
||||||
|
deliberately when they want provider details and must not classify failures by
|
||||||
|
matching error text.
|
||||||
|
|
||||||
|
The zero value and a nil `*GenerationError` receiver are safe: accessors return
|
||||||
|
zero or empty values, formatting returns a generic redacted generation-failure
|
||||||
|
description, and unwrapping still identifies `ErrLLMGenerate`. Engine-produced
|
||||||
|
values always have the non-2xx status received from the provider. The type has
|
||||||
|
no stable JSON representation.
|
||||||
|
|
||||||
|
All provider-derived strings remain untrusted even after normalization. GoDoc
|
||||||
|
must warn consumers that provider fields can contain sensitive request or
|
||||||
|
schema fragments and must not be logged, displayed, or returned to another
|
||||||
|
caller without an application-appropriate disclosure policy.
|
||||||
|
|
||||||
|
## Recognized Provider Envelope
|
||||||
|
|
||||||
|
Promptkit recognizes only the conventional OpenAI-compatible top-level error
|
||||||
|
object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"message": "diagnostic text",
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"code": "unsupported_parameter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The envelope must be one JSON document followed only by JSON whitespace. The
|
||||||
|
top-level `error` value must be an object. Unknown top-level and error-object
|
||||||
|
fields are ignored. The optional supported fields are interpreted
|
||||||
|
independently:
|
||||||
|
|
||||||
|
- `message` and `type` must be JSON strings;
|
||||||
|
- `code` may be a JSON string or number and is exposed as normalized text;
|
||||||
|
numeric codes retain their validated JSON number text without floating-point
|
||||||
|
coercion; and
|
||||||
|
- `null`, booleans, arrays, objects, or otherwise invalid values are treated as
|
||||||
|
absent for that field.
|
||||||
|
|
||||||
|
An invalid optional field does not discard other valid supported fields. An
|
||||||
|
absent `error` object, malformed or multiply framed JSON, or an object with no
|
||||||
|
usable supported fields simply leaves all provider accessors empty while
|
||||||
|
preserving the typed status error.
|
||||||
|
|
||||||
|
Promptkit does not expose `param`, metadata objects, nested causes, headers, or
|
||||||
|
provider-specific extensions in this feature.
|
||||||
|
|
||||||
|
## Bounded Reading And Normalization
|
||||||
|
|
||||||
|
Non-success bodies have a separate fixed limit of 64 KiB (65,536 bytes). This
|
||||||
|
is intentionally much smaller than the successful completion-body limit while
|
||||||
|
remaining large enough for useful schema diagnostics.
|
||||||
|
|
||||||
|
- A declared `Content-Length` above the limit is rejected without reading the
|
||||||
|
body for detail extraction.
|
||||||
|
- Otherwise Promptkit reads at most one byte beyond the limit so streamed,
|
||||||
|
chunked, and underreported bodies are bounded.
|
||||||
|
- A body over the limit contributes no provider fields; Promptkit does not
|
||||||
|
parse or retain a prefix as though it were a complete envelope.
|
||||||
|
- Read failures likewise discard provider fields while preserving the status.
|
||||||
|
- The response body is closed on every outcome and is not drained beyond the
|
||||||
|
bounded read.
|
||||||
|
|
||||||
|
Extracted strings are converted to valid UTF-8, trimmed, and made single-line:
|
||||||
|
invalid UTF-8 is replaced, and runs of Unicode whitespace, control characters,
|
||||||
|
and formatting controls are replaced with one ASCII space. Empty normalized
|
||||||
|
values are treated as absent.
|
||||||
|
|
||||||
|
Normalized provider codes and types are retained only when they contain at
|
||||||
|
most 256 Unicode code points. Longer values are omitted rather than truncated
|
||||||
|
so consumers never classify on a fabricated partial identifier. A provider
|
||||||
|
message is limited to 4,096 Unicode code points; a longer normalized message is
|
||||||
|
truncated at a code-point boundary with a visible ellipsis inside that limit.
|
||||||
|
The raw response body and pre-normalized strings are never exposed or retained
|
||||||
|
in the public error.
|
||||||
|
|
||||||
|
## Error Propagation And Compatibility
|
||||||
|
|
||||||
|
- Every built-in-client non-2xx response matches `ErrLLMGenerate` and supports
|
||||||
|
`errors.As` to `*GenerationError`, including status-only cases.
|
||||||
|
- The internal model client retains its non-success-status identity for its
|
||||||
|
own package tests. The use-case layer remains provider-neutral and continues
|
||||||
|
to add only its generation category.
|
||||||
|
- The root error boundary converts only the built-in transport's structured
|
||||||
|
status error. It does not parse arbitrary error text, inspect consumer error
|
||||||
|
fields, or fabricate HTTP details for an injected `LLMClient`.
|
||||||
|
- Errors returned by injected clients remain in the chain exactly as today.
|
||||||
|
If an injected client deliberately returns an existing `*GenerationError`,
|
||||||
|
its identity may pass through ordinary wrapping, but Promptkit does not
|
||||||
|
construct or enrich one on that client's behalf.
|
||||||
|
- Existing cancellation and deadline identities, capacity errors, validation
|
||||||
|
behavior, repair behavior, and successful response decoding remain
|
||||||
|
unchanged.
|
||||||
|
- This is an additive public API. Existing consumers that use
|
||||||
|
`errors.Is(err, ErrLLMGenerate)` continue to work; consumers should not rely
|
||||||
|
on the previous rendered wording of non-success errors.
|
||||||
|
|
||||||
|
## Architecture And Ownership
|
||||||
|
|
||||||
|
The provider-envelope parser and bounded body reader belong in `internal/llm`,
|
||||||
|
which owns the OpenAI-compatible transport. The internal transport error owns
|
||||||
|
only normalized status facts and continues to match the package's existing
|
||||||
|
non-success-status sentinel.
|
||||||
|
|
||||||
|
The use-case package does not gain HTTP DTOs, status policy, or a provider-
|
||||||
|
specific branch. Its existing wrapping carries the internal error to the root
|
||||||
|
facade. The root error mapper recognizes the internal structured status error
|
||||||
|
and constructs the public `GenerationError` without exposing an internal type
|
||||||
|
or raw cause through public fields. No transport error is added to
|
||||||
|
`internal/domain`.
|
||||||
|
|
||||||
|
The public type and its exact Go semantics are owned by its declaration and
|
||||||
|
GoDoc. The
|
||||||
|
[OpenAI-compatible integration contract](../integrations/openai-compatible-chat.md)
|
||||||
|
owns recognized wire shapes, limits, and observable response behavior. The
|
||||||
|
[internal model-client document](../internal/llm.md) owns implementation flow,
|
||||||
|
internal failure categories, and test ownership. Architecture policy does not
|
||||||
|
need a new package or dependency rule for this feature.
|
||||||
|
|
||||||
|
## Documentation End State
|
||||||
|
|
||||||
|
Canonical documentation at the target state has these responsibilities:
|
||||||
|
|
||||||
|
- the `GenerationError` declaration and GoDoc define the exact public methods,
|
||||||
|
formatting, unwrapping, zero-value behavior, and trust boundary;
|
||||||
|
- `Engine.Run` and `Engine.RunPrepared` GoDoc identify the typed error without
|
||||||
|
duplicating its accessor contract;
|
||||||
|
- the consumer guide includes one short `errors.As` example and links to the
|
||||||
|
public declaration;
|
||||||
|
- the integration document replaces its status-only description with the
|
||||||
|
bounded envelope contract; and
|
||||||
|
- the internal model-client document describes parsing, conversion ownership,
|
||||||
|
and narrow test owners.
|
||||||
|
|
||||||
|
The architecture policy, framework format reference, and built-in backend
|
||||||
|
catalog do not duplicate this API or wire contract.
|
||||||
|
|
||||||
|
## Verification Expectations
|
||||||
|
|
||||||
|
Verification protects each behavior at its narrowest stable owner:
|
||||||
|
|
||||||
|
- internal model-client tests cover recognized string and numeric codes,
|
||||||
|
independent optional-field handling, unknown fields, empty and malformed
|
||||||
|
envelopes, single-document framing, read failures, declared and streamed
|
||||||
|
size boundaries, body closure, normalization, field limits, and absence of
|
||||||
|
raw provider content from rendered errors;
|
||||||
|
- root error-boundary tests cover conversion to the immutable public type,
|
||||||
|
every accessor, `errors.Is`, `errors.As`, and safe `%v`, `%+v`, and `%#v`
|
||||||
|
formatting;
|
||||||
|
- one representative ordinary run and one prepared run prove that the built-in
|
||||||
|
transport contract crosses the assembled engine boundary, without repeating
|
||||||
|
the complete parser matrix;
|
||||||
|
- existing injected-client tests continue to prove preservation of consumer
|
||||||
|
error identity without fabricated provider details; and
|
||||||
|
- all tests use controlled transports or local servers and never contact a
|
||||||
|
live or paid provider.
|
||||||
|
|
||||||
|
Security limits and their exact boundaries are contractual enough to warrant
|
||||||
|
literal boundary tests. Higher-level tests should remain representative and
|
||||||
|
must not duplicate the internal transport matrix.
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- A downstream consumer can distinguish a provider HTTP 400 from other
|
- A consumer can distinguish an HTTP 400 from other generation failures and
|
||||||
generation failures and obtain a bounded provider explanation when present.
|
deliberately obtain a bounded provider explanation when one is available.
|
||||||
- The typed error still satisfies `errors.Is(err, ErrLLMGenerate)`.
|
- The same typed error remains available through ordinary and prepared
|
||||||
- Existing cancellation, capacity, validation, and injected-client error
|
execution and still satisfies `errors.Is(err, ErrLLMGenerate)`.
|
||||||
identities remain unchanged.
|
- Default and Go-syntax error formatting cannot disclose any provider-derived
|
||||||
- Tests cover recognized string and numeric provider codes, absent and malformed
|
string or raw response content.
|
||||||
envelopes, oversized bodies and fields, control characters, and error-chain
|
- Empty, malformed, unreadable, unrecognized, and oversized bodies preserve a
|
||||||
behavior without making live provider requests.
|
typed status-only error.
|
||||||
- Current-state GoDoc and the OpenAI-compatible integration and internal-client
|
- No read, retained field, or formatted representation can exceed its stated
|
||||||
documents are updated only when the implementation lands.
|
bound, and the body is closed on every outcome.
|
||||||
|
- Existing success, cancellation, capacity, validation, repair, and injected-
|
||||||
|
client contracts remain unchanged.
|
||||||
|
- Current-state documentation changes only when the implementation exists and
|
||||||
|
follows the repository's canonical ownership policy.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
This feature does not add:
|
||||||
|
|
||||||
|
- retryability classification, retry loops, backoff, failover, or routing;
|
||||||
|
- parsing of success bodies as errors or changes to successful-response limits;
|
||||||
|
- provider-specific envelope variants beyond the conventional top-level
|
||||||
|
`error` object;
|
||||||
|
- response headers such as `Retry-After`, raw bodies, request data, endpoints,
|
||||||
|
credentials, schema documents, generated content, or provider metadata;
|
||||||
|
- logging, telemetry, redaction policy for downstream applications, HTTP status
|
||||||
|
mapping for consumer servers, or user-facing presentation;
|
||||||
|
- translation or enrichment of arbitrary injected-client errors; or
|
||||||
|
- a new public package, public constructor, mutable error value, or transport
|
||||||
|
type in the domain model.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None. The public type direction, accessor surface, formatting and error-chain
|
||||||
|
behavior, envelope scope, normalization, safety limits, fallback behavior,
|
||||||
|
layer ownership, compatibility boundaries, documentation ownership, and test
|
||||||
|
boundaries are fixed by this roadmap.
|
||||||
|
|||||||
24
engine.go
24
engine.go
@@ -69,8 +69,9 @@ var (
|
|||||||
// request, an LLM or provider rate-limit response, or ErrLLMGenerate.
|
// request, an LLM or provider rate-limit response, or ErrLLMGenerate.
|
||||||
ErrCapacityExceeded = errors.New("backend capacity exceeded")
|
ErrCapacityExceeded = errors.New("backend capacity exceeded")
|
||||||
// ErrLLMGenerate identifies a model-client failure or a nil successful
|
// ErrLLMGenerate identifies a model-client failure or a nil successful
|
||||||
// response. Errors returned by an injected LLMClient remain available
|
// response. A built-in OpenAI-compatible non-2xx response is available as a
|
||||||
// through errors.Is.
|
// [GenerationError]. Errors returned by an injected LLMClient remain
|
||||||
|
// available through errors.Is.
|
||||||
ErrLLMGenerate = errors.New("failed to generate output")
|
ErrLLMGenerate = errors.New("failed to generate output")
|
||||||
// ErrValidation identifies an operational failure to load or compile a
|
// ErrValidation identifies an operational failure to load or compile a
|
||||||
// schema or validate output. A completed validation whose Status is
|
// schema or validate output. A completed validation whose Status is
|
||||||
@@ -646,11 +647,13 @@ func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*Prepare
|
|||||||
// discoverable as [CapacityError] and still matches ErrCapacityExceeded. It
|
// discoverable as [CapacityError] and still matches ErrCapacityExceeded. It
|
||||||
// occurs before artifacts, schemas, rendering, or model generation because the
|
// occurs before artifacts, schemas, rendering, or model generation because the
|
||||||
// selected backend's admission capacity is full; it does not match
|
// selected backend's admission capacity is full; it does not match
|
||||||
// ErrInvalidRequest or ErrLLMGenerate. Errors from injected clients remain
|
// ErrInvalidRequest or ErrLLMGenerate. A built-in OpenAI-compatible non-2xx
|
||||||
// available through errors.Is. Cancellation while waiting for model-generation
|
// response is discoverable as [GenerationError]. Errors from injected clients
|
||||||
// capacity matches both ErrLLMGenerate and the context error. Cancellation
|
// remain available through errors.Is. Cancellation while waiting for
|
||||||
// otherwise follows the active collaborator's documented behavior. A nil
|
// model-generation capacity matches both ErrLLMGenerate and the context error.
|
||||||
// Engine returns ErrInvalidConfig. Run returns no partial result on error.
|
// Cancellation otherwise follows the active collaborator's documented
|
||||||
|
// behavior. A nil Engine returns ErrInvalidConfig. Run returns no partial
|
||||||
|
// result on error.
|
||||||
func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||||
if e == nil || e.runner == nil {
|
if e == nil || e.runner == nil {
|
||||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
@@ -688,9 +691,10 @@ func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
|||||||
// ErrCapacityExceeded, ErrLLMGenerate, or ErrValidation as applicable while
|
// ErrCapacityExceeded, ErrLLMGenerate, or ErrValidation as applicable while
|
||||||
// preserving documented collaborator and context identities. An engine
|
// preserving documented collaborator and context identities. An engine
|
||||||
// admission rejection is discoverable as [CapacityError] and still matches
|
// admission rejection is discoverable as [CapacityError] and still matches
|
||||||
// ErrCapacityExceeded. A completed content-validation rejection is returned
|
// ErrCapacityExceeded. A built-in OpenAI-compatible non-2xx response is
|
||||||
// in RunResult, not as an operational error. An operational error returns no
|
// discoverable as [GenerationError]. A completed content-validation rejection
|
||||||
// partial RunResult.
|
// is returned in RunResult, not as an operational error. An operational error
|
||||||
|
// returns no partial RunResult.
|
||||||
func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (*RunResult, error) {
|
func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (*RunResult, error) {
|
||||||
if e == nil || e.runner == nil {
|
if e == nil || e.runner == nil {
|
||||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
|
|||||||
101
engine_test.go
101
engine_test.go
@@ -1164,8 +1164,9 @@ func TestArtifactReaderFailuresPreserveArtifactLoadErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunAddsLLMGenerateToCollaboratorPublicError(t *testing.T) {
|
func TestRunAddsLLMGenerateToCollaboratorPublicError(t *testing.T) {
|
||||||
|
injectedErr := errors.New("injected model client failure")
|
||||||
engine := newContractEngineWithOptions(t, frameworkSchemaDir,
|
engine := newContractEngineWithOptions(t, frameworkSchemaDir,
|
||||||
promptkit.WithLLMClient(&fakeLLMClient{err: promptkit.ErrArtifactLoad}),
|
promptkit.WithLLMClient(&fakeLLMClient{err: injectedErr}),
|
||||||
)
|
)
|
||||||
|
|
||||||
_, err := engine.Run(context.Background(), promptkit.RunRequest{
|
_, err := engine.Run(context.Background(), promptkit.RunRequest{
|
||||||
@@ -1178,8 +1179,12 @@ func TestRunAddsLLMGenerateToCollaboratorPublicError(t *testing.T) {
|
|||||||
if !errors.Is(err, promptkit.ErrLLMGenerate) {
|
if !errors.Is(err, promptkit.ErrLLMGenerate) {
|
||||||
t.Fatalf("expected ErrLLMGenerate, got %v", err)
|
t.Fatalf("expected ErrLLMGenerate, got %v", err)
|
||||||
}
|
}
|
||||||
if !errors.Is(err, promptkit.ErrArtifactLoad) {
|
if !errors.Is(err, injectedErr) {
|
||||||
t.Fatalf("expected preserved ErrArtifactLoad, got %v", err)
|
t.Fatalf("expected preserved injected error, got %v", err)
|
||||||
|
}
|
||||||
|
var generationErr *promptkit.GenerationError
|
||||||
|
if errors.As(err, &generationErr) {
|
||||||
|
t.Fatalf("injected error became GenerationError: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1492,43 +1497,63 @@ func TestSelectedProfileRepositoryReadFailureMapsToProfileLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPrepareUsesBuiltInProfileWithoutProfileDir(t *testing.T) {
|
func TestPrepareUsesBuiltInProfileWithoutProfileDir(t *testing.T) {
|
||||||
t.Setenv("OPENROUTER_API_KEY", "test-key")
|
tests := []struct {
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{
|
name string
|
||||||
PromptDir: frameworkPromptDir,
|
profileID string
|
||||||
SchemaDir: frameworkSchemaDir,
|
backendID string
|
||||||
})
|
endpoint string
|
||||||
if err != nil {
|
apiKeyEnv string
|
||||||
t.Fatalf("expected engine construction to succeed, got %v", err)
|
model string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "OpenRouter",
|
||||||
|
profileID: "mistral-small-3",
|
||||||
|
backendID: promptkit.BackendOpenRouter,
|
||||||
|
endpoint: "https://openrouter.ai/api/v1",
|
||||||
|
apiKeyEnv: "OPENROUTER_API_KEY",
|
||||||
|
model: "mistralai/mistral-small-3.2-24b-instruct",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Rakestrawhome",
|
||||||
|
profileID: "rakestrawhome-gemma-4-31b",
|
||||||
|
backendID: promptkit.BackendRakestrawHome,
|
||||||
|
endpoint: "https://inference.ai.rakestrawhome.com/v1",
|
||||||
|
apiKeyEnv: "RAKESTRAWHOME_INFERENCE_API_KEY",
|
||||||
|
model: "google/gemma-4-31b-it",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
for _, tc := range tests {
|
||||||
PromptID: frameworkMarkdownSummaryPromptID,
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
ProfileID: "mistral-small-3",
|
t.Setenv(tc.apiKeyEnv, "test-key")
|
||||||
Inputs: map[string]promptkit.ArtifactRef{
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||||
"transcript": promptkit.Inline("Rin opens the gate."),
|
PromptDir: frameworkPromptDir,
|
||||||
"glossary": promptkit.Inline("gate: A guarded passage."),
|
SchemaDir: frameworkSchemaDir,
|
||||||
},
|
})
|
||||||
})
|
if err != nil {
|
||||||
if err != nil {
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
||||||
t.Fatalf("expected built-in profile prepare to succeed, got %v", err)
|
}
|
||||||
}
|
|
||||||
if prepared.SelectedProfileID != "mistral-small-3" {
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||||
t.Fatalf("unexpected selected profile: %q", prepared.SelectedProfileID)
|
PromptID: frameworkMarkdownSummaryPromptID,
|
||||||
}
|
ProfileID: tc.profileID,
|
||||||
if prepared.SelectedBackendID != promptkit.BackendOpenRouter {
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
t.Fatalf("unexpected selected backend: %q", prepared.SelectedBackendID)
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
||||||
}
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
||||||
if prepared.EffectiveModelParams.BackendID != promptkit.BackendOpenRouter {
|
},
|
||||||
t.Fatalf("unexpected effective backend: %q", prepared.EffectiveModelParams.BackendID)
|
})
|
||||||
}
|
if err != nil {
|
||||||
if prepared.EffectiveModelParams.Endpoint != "https://openrouter.ai/api/v1" {
|
t.Fatalf("expected built-in profile prepare to succeed, got %v", err)
|
||||||
t.Fatalf("unexpected built-in endpoint: %q", prepared.EffectiveModelParams.Endpoint)
|
}
|
||||||
}
|
if prepared.SelectedProfileID != tc.profileID ||
|
||||||
if prepared.EffectiveModelParams.APIKeyEnv != "OPENROUTER_API_KEY" {
|
prepared.SelectedBackendID != tc.backendID ||
|
||||||
t.Fatalf("unexpected built-in api key environment name: %q", prepared.EffectiveModelParams.APIKeyEnv)
|
prepared.EffectiveModelParams.BackendID != tc.backendID ||
|
||||||
}
|
prepared.EffectiveModelParams.Endpoint != tc.endpoint ||
|
||||||
if prepared.EffectiveModelParams.Model != "mistralai/mistral-small-3.2-24b-instruct" {
|
prepared.EffectiveModelParams.APIKeyEnv != tc.apiKeyEnv ||
|
||||||
t.Fatalf("unexpected built-in model: %q", prepared.EffectiveModelParams.Model)
|
prepared.EffectiveModelParams.Model != tc.model {
|
||||||
|
t.Fatalf("unexpected built-in preparation: %#v", prepared)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
errors.go
10
errors.go
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
||||||
@@ -20,6 +21,15 @@ func mapPublicError(err error) error {
|
|||||||
strings.TrimSpace(internalCapacityError.BackendID) != "" {
|
strings.TrimSpace(internalCapacityError.BackendID) != "" {
|
||||||
return &CapacityError{BackendID: internalCapacityError.BackendID}
|
return &CapacityError{BackendID: internalCapacityError.BackendID}
|
||||||
}
|
}
|
||||||
|
var providerHTTPError *llm.ProviderHTTPError
|
||||||
|
if errors.As(err, &providerHTTPError) && providerHTTPError != nil {
|
||||||
|
return newGenerationError(
|
||||||
|
providerHTTPError.StatusCode(),
|
||||||
|
providerHTTPError.ProviderCode(),
|
||||||
|
providerHTTPError.ProviderType(),
|
||||||
|
providerHTTPError.ProviderMessage(),
|
||||||
|
)
|
||||||
|
}
|
||||||
publicErr := publicErrorFor(err)
|
publicErr := publicErrorFor(err)
|
||||||
if publicErr == nil {
|
if publicErr == nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
87
generation_error.go
Normal file
87
generation_error.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// GenerationError reports a non-2xx response from Promptkit's built-in
|
||||||
|
// OpenAI-compatible client during [Engine.Run] or [Engine.RunPrepared].
|
||||||
|
//
|
||||||
|
// Engine-produced values are immutable, caller-owned values. Use errors.Is to
|
||||||
|
// match [ErrLLMGenerate] and errors.As with a *GenerationError target to obtain
|
||||||
|
// this type. The four provider accessors expose untrusted provider-controlled
|
||||||
|
// values that can contain sensitive request or schema fragments. Applications
|
||||||
|
// must apply their own disclosure policy before logging, displaying, or
|
||||||
|
// returning them to another caller.
|
||||||
|
//
|
||||||
|
// Accessors, Error, GoString, and Unwrap are safe on a nil receiver and a zero
|
||||||
|
// value. Default and Go-syntax formatting deliberately redact provider details.
|
||||||
|
// GenerationError has no stable JSON representation.
|
||||||
|
type GenerationError struct {
|
||||||
|
statusCode int
|
||||||
|
providerCode string
|
||||||
|
providerType string
|
||||||
|
providerMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGenerationError(statusCode int, providerCode, providerType, providerMessage string) *GenerationError {
|
||||||
|
return &GenerationError{
|
||||||
|
statusCode: statusCode,
|
||||||
|
providerCode: providerCode,
|
||||||
|
providerType: providerType,
|
||||||
|
providerMessage: providerMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusCode returns the received provider HTTP status code, or zero for a nil
|
||||||
|
// receiver or zero value.
|
||||||
|
func (e *GenerationError) StatusCode() int {
|
||||||
|
if e == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return e.statusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderCode returns the normalized provider error code, if present. Its
|
||||||
|
// value is untrusted and may contain sensitive data.
|
||||||
|
func (e *GenerationError) ProviderCode() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.providerCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderType returns the normalized provider error type, if present. Its
|
||||||
|
// value is untrusted and may contain sensitive data.
|
||||||
|
func (e *GenerationError) ProviderType() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.providerType
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderMessage returns the bounded normalized provider diagnostic, if
|
||||||
|
// present. Its value is untrusted and may contain sensitive data.
|
||||||
|
func (e *GenerationError) ProviderMessage() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.providerMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns a redacted diagnostic that is not a parsing contract.
|
||||||
|
func (e *GenerationError) Error() string {
|
||||||
|
if e == nil || e.statusCode == 0 {
|
||||||
|
return ErrLLMGenerate.Error()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s: provider returned HTTP status %d", ErrLLMGenerate, e.statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoString returns the same redacted diagnostic as Error.
|
||||||
|
func (e *GenerationError) GoString() string {
|
||||||
|
return e.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap returns ErrLLMGenerate. It is safe to call on a nil receiver or zero
|
||||||
|
// value.
|
||||||
|
func (e *GenerationError) Unwrap() error {
|
||||||
|
return ErrLLMGenerate
|
||||||
|
}
|
||||||
95
generation_error_contract_test.go
Normal file
95
generation_error_contract_test.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuiltInGenerationError(t *testing.T) {
|
||||||
|
const (
|
||||||
|
codeMarker = "provider-code-marker"
|
||||||
|
typeMarker = "provider-type-marker"
|
||||||
|
messageMarker = "provider-message-marker"
|
||||||
|
)
|
||||||
|
engine := newBuiltInGenerationErrorEngine(t, http.StatusUnprocessableEntity,
|
||||||
|
`{"error":{"code":"`+codeMarker+`","type":"`+typeMarker+`","message":"`+messageMarker+`"}}`)
|
||||||
|
|
||||||
|
result, err := engine.Run(context.Background(), generationErrorRunRequest())
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("Run result = %#v, want nil", result)
|
||||||
|
}
|
||||||
|
assertGenerationError(t, err, http.StatusUnprocessableEntity, codeMarker, typeMarker, messageMarker)
|
||||||
|
|
||||||
|
preparedEngine := newBuiltInGenerationErrorEngine(t, http.StatusServiceUnavailable, `{"error":{}}`)
|
||||||
|
prepared, err := preparedEngine.PrepareExecution(context.Background(), generationErrorRunRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PrepareExecution: %v", err)
|
||||||
|
}
|
||||||
|
result, err = preparedEngine.RunPrepared(context.Background(), prepared)
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("RunPrepared result = %#v, want nil", result)
|
||||||
|
}
|
||||||
|
assertGenerationError(t, err, http.StatusServiceUnavailable, "", "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertGenerationError(t *testing.T, err error, statusCode int, code, providerType, message string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if !errors.Is(err, promptkit.ErrLLMGenerate) {
|
||||||
|
t.Fatalf("errors.Is(%v, ErrLLMGenerate) = false", err)
|
||||||
|
}
|
||||||
|
var generationErr *promptkit.GenerationError
|
||||||
|
if !errors.As(err, &generationErr) || generationErr == nil {
|
||||||
|
t.Fatalf("error = %T, want *GenerationError", err)
|
||||||
|
}
|
||||||
|
if generationErr.StatusCode() != statusCode || generationErr.ProviderCode() != code || generationErr.ProviderType() != providerType || generationErr.ProviderMessage() != message {
|
||||||
|
t.Fatalf("GenerationError = %#v", generationErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantFormatted := fmt.Sprintf("failed to generate output: provider returned HTTP status %d", statusCode)
|
||||||
|
for _, rendered := range []string{fmt.Sprintf("%v", generationErr), fmt.Sprintf("%+v", generationErr), fmt.Sprintf("%#v", generationErr)} {
|
||||||
|
if rendered != wantFormatted {
|
||||||
|
t.Fatalf("formatted error = %q, want %q", rendered, wantFormatted)
|
||||||
|
}
|
||||||
|
for _, marker := range []string{code, providerType, message} {
|
||||||
|
if marker != "" && strings.Contains(rendered, marker) {
|
||||||
|
t.Fatalf("formatted error exposed provider marker %q: %q", marker, rendered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBuiltInGenerationErrorEngine(t *testing.T, statusCode int, body string) *promptkit.Engine {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
config := contractConfig(frameworkSchemaDir)
|
||||||
|
config.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: statusCode,
|
||||||
|
ContentLength: int64(len(body)),
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
}, nil
|
||||||
|
})}
|
||||||
|
engine, err := promptkit.NewEngine(config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewEngine: %v", err)
|
||||||
|
}
|
||||||
|
return engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func generationErrorRunRequest() promptkit.RunRequest {
|
||||||
|
return promptkit.RunRequest{
|
||||||
|
PromptID: frameworkMarkdownSummaryPromptID,
|
||||||
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
||||||
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
32
generation_error_internal_test.go
Normal file
32
generation_error_internal_test.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerationErrorNilAndZeroValue(t *testing.T) {
|
||||||
|
var nilError *GenerationError
|
||||||
|
zeroError := &GenerationError{}
|
||||||
|
|
||||||
|
for name, err := range map[string]*GenerationError{
|
||||||
|
"nil": nilError,
|
||||||
|
"zero": zeroError,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if err.StatusCode() != 0 || err.ProviderCode() != "" || err.ProviderType() != "" || err.ProviderMessage() != "" {
|
||||||
|
t.Fatalf("accessors returned provider details: %#v", err)
|
||||||
|
}
|
||||||
|
if err.Error() != "failed to generate output" || err.GoString() != "failed to generate output" {
|
||||||
|
t.Fatalf("redacted formatting = (%q, %q)", err.Error(), err.GoString())
|
||||||
|
}
|
||||||
|
if fmt.Sprintf("%v", err) != "failed to generate output" || fmt.Sprintf("%#v", err) != "failed to generate output" {
|
||||||
|
t.Fatalf("formatted error = (%q, %q)", fmt.Sprintf("%v", err), fmt.Sprintf("%#v", err))
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrLLMGenerate) {
|
||||||
|
t.Fatalf("errors.Is(%v, ErrLLMGenerate) = false", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,12 +18,20 @@ const (
|
|||||||
// OpenRouterID is the reserved ID of Promptkit's built-in OpenRouter
|
// OpenRouterID is the reserved ID of Promptkit's built-in OpenRouter
|
||||||
// backend.
|
// backend.
|
||||||
OpenRouterID = "openrouter"
|
OpenRouterID = "openrouter"
|
||||||
|
// RakestrawHomeID is the reserved ID of Promptkit's built-in Rakestrawhome
|
||||||
|
// backend.
|
||||||
|
RakestrawHomeID = "rakestrawhome"
|
||||||
|
|
||||||
openRouterEndpoint = "https://openrouter.ai/api/v1"
|
openRouterEndpoint = "https://openrouter.ai/api/v1"
|
||||||
openRouterAPIKeyEnv = "OPENROUTER_API_KEY"
|
openRouterAPIKeyEnv = "OPENROUTER_API_KEY"
|
||||||
|
|
||||||
openRouterConcurrencyLimit = 16
|
openRouterConcurrencyLimit = 16
|
||||||
defaultQueueCapacity = 1024
|
|
||||||
|
rakestrawHomeEndpoint = "https://inference.ai.rakestrawhome.com/v1"
|
||||||
|
rakestrawHomeAPIKeyEnv = "RAKESTRAWHOME_INFERENCE_API_KEY"
|
||||||
|
|
||||||
|
rakestrawHomeConcurrencyLimit = 4
|
||||||
|
defaultQueueCapacity = 1024
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrBackendNotFound identifies a registry lookup for an unknown backend ID.
|
// ErrBackendNotFound identifies a registry lookup for an unknown backend ID.
|
||||||
@@ -36,20 +44,16 @@ type Registry struct {
|
|||||||
backends map[string]domain.Backend
|
backends map[string]domain.Backend
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRegistry constructs a registry containing the built-in OpenRouter
|
// NewRegistry constructs a registry containing the built-in definitions
|
||||||
// definition followed by the supplied additions. Every ID must be unique.
|
// followed by the supplied additions. Every ID must be unique.
|
||||||
func NewRegistry(additions []domain.Backend) (*Registry, error) {
|
func NewRegistry(additions []domain.Backend) (*Registry, error) {
|
||||||
|
builtIns := builtInBackends()
|
||||||
registry := &Registry{
|
registry := &Registry{
|
||||||
backends: make(map[string]domain.Backend, len(additions)+1),
|
backends: make(map[string]domain.Backend, len(builtIns)+len(additions)),
|
||||||
}
|
}
|
||||||
|
|
||||||
definitions := make([]domain.Backend, 0, len(additions)+1)
|
definitions := make([]domain.Backend, 0, len(builtIns)+len(additions))
|
||||||
definitions = append(definitions, domain.Backend{
|
definitions = append(definitions, builtIns...)
|
||||||
ID: OpenRouterID,
|
|
||||||
Endpoint: openRouterEndpoint,
|
|
||||||
APIKeyEnv: openRouterAPIKeyEnv,
|
|
||||||
ConcurrencyLimit: openRouterConcurrencyLimit,
|
|
||||||
})
|
|
||||||
definitions = append(definitions, additions...)
|
definitions = append(definitions, additions...)
|
||||||
|
|
||||||
for _, definition := range definitions {
|
for _, definition := range definitions {
|
||||||
@@ -71,6 +75,23 @@ func NewRegistry(additions []domain.Backend) (*Registry, error) {
|
|||||||
return registry, nil
|
return registry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func builtInBackends() []domain.Backend {
|
||||||
|
return []domain.Backend{
|
||||||
|
{
|
||||||
|
ID: OpenRouterID,
|
||||||
|
Endpoint: openRouterEndpoint,
|
||||||
|
APIKeyEnv: openRouterAPIKeyEnv,
|
||||||
|
ConcurrencyLimit: openRouterConcurrencyLimit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: RakestrawHomeID,
|
||||||
|
Endpoint: rakestrawHomeEndpoint,
|
||||||
|
APIKeyEnv: rakestrawHomeAPIKeyEnv,
|
||||||
|
ConcurrencyLimit: rakestrawHomeConcurrencyLimit,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetBackend returns a defensive copy of the backend registered with id.
|
// GetBackend returns a defensive copy of the backend registered with id.
|
||||||
func (r *Registry) GetBackend(id string) (domain.Backend, error) {
|
func (r *Registry) GetBackend(id string) (domain.Backend, error) {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
|
|||||||
@@ -11,32 +11,63 @@ import (
|
|||||||
|
|
||||||
const validEndpoint = "https://backend.example/v1"
|
const validEndpoint = "https://backend.example/v1"
|
||||||
|
|
||||||
func TestRegistryIncludesExactOpenRouterDefinition(t *testing.T) {
|
func TestRegistryIncludesExactBuiltInDefinitions(t *testing.T) {
|
||||||
registry, err := backend.NewRegistry(nil)
|
registry, err := backend.NewRegistry(nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("construct registry: %v", err)
|
t.Fatalf("construct registry: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
definition, err := registry.GetBackend(backend.OpenRouterID)
|
tests := []struct {
|
||||||
if err != nil {
|
name string
|
||||||
t.Fatalf("look up OpenRouter: %v", err)
|
id string
|
||||||
|
endpoint string
|
||||||
|
apiKeyEnv string
|
||||||
|
concurrent int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "OpenRouter",
|
||||||
|
id: backend.OpenRouterID,
|
||||||
|
endpoint: "https://openrouter.ai/api/v1",
|
||||||
|
apiKeyEnv: "OPENROUTER_API_KEY",
|
||||||
|
concurrent: 16,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Rakestrawhome",
|
||||||
|
id: backend.RakestrawHomeID,
|
||||||
|
endpoint: "https://inference.ai.rakestrawhome.com/v1",
|
||||||
|
apiKeyEnv: "RAKESTRAWHOME_INFERENCE_API_KEY",
|
||||||
|
concurrent: 4,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if definition.ID != "openrouter" ||
|
for _, tc := range tests {
|
||||||
definition.Endpoint != "https://openrouter.ai/api/v1" ||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
definition.APIKeyEnv != "OPENROUTER_API_KEY" ||
|
definition, err := registry.GetBackend(tc.id)
|
||||||
definition.ConcurrencyLimit != 16 ||
|
if err != nil {
|
||||||
definition.QueueCapacity != 1024 ||
|
t.Fatalf("look up built-in: %v", err)
|
||||||
!definition.QueueCapacitySet ||
|
}
|
||||||
definition.ExtraParams != nil {
|
if definition.ID != tc.id ||
|
||||||
t.Fatalf("unexpected OpenRouter definition: %#v", definition)
|
definition.Endpoint != tc.endpoint ||
|
||||||
|
definition.APIKeyEnv != tc.apiKeyEnv ||
|
||||||
|
definition.ConcurrencyLimit != tc.concurrent ||
|
||||||
|
definition.QueueCapacity != 1024 ||
|
||||||
|
!definition.QueueCapacitySet ||
|
||||||
|
definition.ExtraParams != nil {
|
||||||
|
t.Fatalf("unexpected built-in definition: %#v", definition)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
policies := registry.CapacityPolicies()
|
policies := registry.CapacityPolicies()
|
||||||
if len(policies) != 1 ||
|
if len(policies) != 2 ||
|
||||||
policies["openrouter"] != (domain.BackendCapacityPolicy{
|
policies[backend.OpenRouterID] != (domain.BackendCapacityPolicy{
|
||||||
ConcurrencyLimit: 16,
|
ConcurrencyLimit: 16,
|
||||||
QueueCapacity: 1024,
|
QueueCapacity: 1024,
|
||||||
|
}) ||
|
||||||
|
policies[backend.RakestrawHomeID] != (domain.BackendCapacityPolicy{
|
||||||
|
ConcurrencyLimit: 4,
|
||||||
|
QueueCapacity: 1024,
|
||||||
}) {
|
}) {
|
||||||
t.Fatalf("unexpected OpenRouter capacity policies: %#v", policies)
|
t.Fatalf("unexpected built-in capacity policies: %#v", policies)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,11 +140,12 @@ func TestRegistryNormalizesUniqueAdditionsAndIsolatesMutations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
policies := registry.CapacityPolicies()
|
policies := registry.CapacityPolicies()
|
||||||
if len(policies) != 2 {
|
if len(policies) != 3 {
|
||||||
t.Fatalf("unexpected capacity policy count: %#v", policies)
|
t.Fatalf("unexpected capacity policy count: %#v", policies)
|
||||||
}
|
}
|
||||||
policies["custom"] = domain.BackendCapacityPolicy{}
|
policies["custom"] = domain.BackendCapacityPolicy{}
|
||||||
delete(policies, backend.OpenRouterID)
|
delete(policies, backend.OpenRouterID)
|
||||||
|
delete(policies, backend.RakestrawHomeID)
|
||||||
againPolicies := registry.CapacityPolicies()
|
againPolicies := registry.CapacityPolicies()
|
||||||
if againPolicies["custom"] != (domain.BackendCapacityPolicy{
|
if againPolicies["custom"] != (domain.BackendCapacityPolicy{
|
||||||
ConcurrencyLimit: 3,
|
ConcurrencyLimit: 3,
|
||||||
@@ -122,7 +154,10 @@ func TestRegistryNormalizesUniqueAdditionsAndIsolatesMutations(t *testing.T) {
|
|||||||
t.Fatalf("capacity policy map mutated registry state: %#v", againPolicies)
|
t.Fatalf("capacity policy map mutated registry state: %#v", againPolicies)
|
||||||
}
|
}
|
||||||
if _, ok := againPolicies[backend.OpenRouterID]; !ok {
|
if _, ok := againPolicies[backend.OpenRouterID]; !ok {
|
||||||
t.Fatalf("capacity policy deletion mutated registry state: %#v", againPolicies)
|
t.Fatalf("OpenRouter capacity policy deletion mutated registry state: %#v", againPolicies)
|
||||||
|
}
|
||||||
|
if _, ok := againPolicies[backend.RakestrawHomeID]; !ok {
|
||||||
|
t.Fatalf("Rakestrawhome capacity policy deletion mutated registry state: %#v", againPolicies)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,11 +277,14 @@ func TestNewRegistryRejectsDuplicateIDs(t *testing.T) {
|
|||||||
wantID string
|
wantID string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "built-in collision after normalization",
|
name: "OpenRouter collision after normalization",
|
||||||
additions: []domain.Backend{{
|
additions: []domain.Backend{{ID: " openrouter "}},
|
||||||
ID: " openrouter ",
|
wantID: backend.OpenRouterID,
|
||||||
}},
|
},
|
||||||
wantID: "openrouter",
|
{
|
||||||
|
name: "Rakestrawhome collision after normalization",
|
||||||
|
additions: []domain.Backend{{ID: " rakestrawhome "}},
|
||||||
|
wantID: backend.RakestrawHomeID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "consumer collision after normalization",
|
name: "consumer collision after normalization",
|
||||||
|
|||||||
@@ -155,8 +155,11 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera
|
|||||||
defer httpResp.Body.Close()
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||||
_, _ = io.Copy(io.Discard, io.LimitReader(httpResp.Body, 4096))
|
return nil, providerHTTPErrorFromBody(
|
||||||
return nil, fmt.Errorf("%w: status=%d", ErrUnexpectedStatus, httpResp.StatusCode)
|
httpResp.StatusCode,
|
||||||
|
httpResp.ContentLength,
|
||||||
|
httpResp.Body,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if httpResp.ContentLength > maxOpenAIChatResponseBytes {
|
if httpResp.ContentLength > maxOpenAIChatResponseBytes {
|
||||||
return nil, openAIChatResponseTooLargeError()
|
return nil, openAIChatResponseTooLargeError()
|
||||||
|
|||||||
@@ -1114,6 +1114,15 @@ func checkCommonResponseFailures(t *testing.T) {
|
|||||||
if !errors.Is(err, tc.wantErr) {
|
if !errors.Is(err, tc.wantErr) {
|
||||||
t.Fatalf("error = %v, want %v", err, tc.wantErr)
|
t.Fatalf("error = %v, want %v", err, tc.wantErr)
|
||||||
}
|
}
|
||||||
|
if tc.statusCode < http.StatusOK || tc.statusCode >= http.StatusMultipleChoices {
|
||||||
|
var providerHTTPError *ProviderHTTPError
|
||||||
|
if !errors.As(err, &providerHTTPError) {
|
||||||
|
t.Fatalf("error = %T, want *ProviderHTTPError", err)
|
||||||
|
}
|
||||||
|
if got := providerHTTPError.StatusCode(); got != tc.statusCode {
|
||||||
|
t.Fatalf("provider status = %d, want %d", got, tc.statusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
if tc.wantText != "" && !strings.Contains(err.Error(), tc.wantText) {
|
if tc.wantText != "" && !strings.Contains(err.Error(), tc.wantText) {
|
||||||
t.Fatalf("error %q does not contain %q", err, tc.wantText)
|
t.Fatalf("error %q does not contain %q", err, tc.wantText)
|
||||||
}
|
}
|
||||||
|
|||||||
189
internal/llm/provider_http_error.go
Normal file
189
internal/llm/provider_http_error.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxProviderErrorResponseBytes int64 = 64 << 10
|
||||||
|
maxProviderErrorIdentifierRunes = 256
|
||||||
|
maxProviderErrorMessageRunes = 4096
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProviderHTTPError describes a non-success response from an LLM provider.
|
||||||
|
type ProviderHTTPError struct {
|
||||||
|
statusCode int
|
||||||
|
providerCode string
|
||||||
|
providerType string
|
||||||
|
providerMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ProviderHTTPError) StatusCode() int {
|
||||||
|
if e == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return e.statusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ProviderHTTPError) ProviderCode() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.providerCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ProviderHTTPError) ProviderType() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.providerType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ProviderHTTPError) ProviderMessage() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.providerMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ProviderHTTPError) Error() string {
|
||||||
|
if e == nil || e.statusCode == 0 {
|
||||||
|
return ErrUnexpectedStatus.Error()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s: status=%d", ErrUnexpectedStatus, e.statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ProviderHTTPError) GoString() string {
|
||||||
|
return e.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ProviderHTTPError) Unwrap() error {
|
||||||
|
return ErrUnexpectedStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
type providerErrorDetails struct {
|
||||||
|
providerCode string
|
||||||
|
providerType string
|
||||||
|
providerMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProviderHTTPError(statusCode int, details providerErrorDetails) *ProviderHTTPError {
|
||||||
|
return &ProviderHTTPError{
|
||||||
|
statusCode: statusCode,
|
||||||
|
providerCode: details.providerCode,
|
||||||
|
providerType: details.providerType,
|
||||||
|
providerMessage: details.providerMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerHTTPErrorFromBody(statusCode int, contentLength int64, body io.Reader) *ProviderHTTPError {
|
||||||
|
if contentLength > maxProviderErrorResponseBytes {
|
||||||
|
return newProviderHTTPError(statusCode, providerErrorDetails{})
|
||||||
|
}
|
||||||
|
|
||||||
|
limited := &io.LimitedReader{
|
||||||
|
R: body,
|
||||||
|
N: maxProviderErrorResponseBytes + 1,
|
||||||
|
}
|
||||||
|
contents, err := io.ReadAll(limited)
|
||||||
|
if err != nil || limited.N == 0 {
|
||||||
|
return newProviderHTTPError(statusCode, providerErrorDetails{})
|
||||||
|
}
|
||||||
|
return newProviderHTTPError(statusCode, parseProviderErrorEnvelope(contents))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseProviderErrorEnvelope(body []byte) providerErrorDetails {
|
||||||
|
decoder := json.NewDecoder(strings.NewReader(string(body)))
|
||||||
|
decoder.UseNumber()
|
||||||
|
|
||||||
|
var envelope map[string]json.RawMessage
|
||||||
|
if err := decoder.Decode(&envelope); err != nil {
|
||||||
|
return providerErrorDetails{}
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||||
|
return providerErrorDetails{}
|
||||||
|
}
|
||||||
|
|
||||||
|
rawError, ok := envelope["error"]
|
||||||
|
if !ok {
|
||||||
|
return providerErrorDetails{}
|
||||||
|
}
|
||||||
|
var providerError map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(rawError, &providerError); err != nil || providerError == nil {
|
||||||
|
return providerErrorDetails{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var details providerErrorDetails
|
||||||
|
if raw, ok := providerError["message"]; ok {
|
||||||
|
var value string
|
||||||
|
if json.Unmarshal(raw, &value) == nil {
|
||||||
|
details.providerMessage = normalizeProviderErrorMessage(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw, ok := providerError["type"]; ok {
|
||||||
|
var value string
|
||||||
|
if json.Unmarshal(raw, &value) == nil {
|
||||||
|
details.providerType = normalizeProviderErrorIdentifier(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw, ok := providerError["code"]; ok {
|
||||||
|
var value any
|
||||||
|
fieldDecoder := json.NewDecoder(strings.NewReader(string(raw)))
|
||||||
|
fieldDecoder.UseNumber()
|
||||||
|
if fieldDecoder.Decode(&value) == nil {
|
||||||
|
switch value := value.(type) {
|
||||||
|
case string:
|
||||||
|
details.providerCode = normalizeProviderErrorIdentifier(value)
|
||||||
|
case json.Number:
|
||||||
|
details.providerCode = normalizeProviderErrorIdentifier(value.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeProviderErrorIdentifier(value string) string {
|
||||||
|
normalized := normalizeProviderErrorText(value)
|
||||||
|
if len([]rune(normalized)) > maxProviderErrorIdentifierRunes {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeProviderErrorMessage(value string) string {
|
||||||
|
normalized := normalizeProviderErrorText(value)
|
||||||
|
runes := []rune(normalized)
|
||||||
|
if len(runes) <= maxProviderErrorMessageRunes {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
return string(runes[:maxProviderErrorMessageRunes-1]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeProviderErrorText(value string) string {
|
||||||
|
value = strings.ToValidUTF8(value, "<22>")
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
result.Grow(len(value))
|
||||||
|
separatorPending := false
|
||||||
|
for _, r := range value {
|
||||||
|
if unicode.IsSpace(r) || unicode.IsControl(r) || unicode.In(r, unicode.Cf) {
|
||||||
|
if result.Len() > 0 {
|
||||||
|
separatorPending = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if separatorPending {
|
||||||
|
result.WriteByte(' ')
|
||||||
|
separatorPending = false
|
||||||
|
}
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
255
internal/llm/provider_http_error_test.go
Normal file
255
internal/llm/provider_http_error_test.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
type guardedReader struct {
|
||||||
|
reader io.Reader
|
||||||
|
remaining int64
|
||||||
|
bytes int64
|
||||||
|
violated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *guardedReader) Read(buffer []byte) (int, error) {
|
||||||
|
if int64(len(buffer)) > r.remaining {
|
||||||
|
r.violated = true
|
||||||
|
return 0, errors.New("reader was read past its allowed boundary")
|
||||||
|
}
|
||||||
|
n, err := r.reader.Read(buffer)
|
||||||
|
r.bytes += int64(n)
|
||||||
|
r.remaining -= int64(n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingReader struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r failingReader) Read([]byte) (int, error) {
|
||||||
|
return 0, r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderHTTPErrorEnvelopeParsing(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want providerErrorDetails
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "all supported string fields",
|
||||||
|
body: `{"error":{"message":"diagnostic","type":"invalid_request_error","code":"unsupported_parameter"}}`,
|
||||||
|
want: providerErrorDetails{providerMessage: "diagnostic", providerType: "invalid_request_error", providerCode: "unsupported_parameter"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "integer code",
|
||||||
|
body: `{"error":{"code":17}}`,
|
||||||
|
want: providerErrorDetails{providerCode: "17"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fractional code",
|
||||||
|
body: `{"error":{"code":1.25}}`,
|
||||||
|
want: providerErrorDetails{providerCode: "1.25"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exponent code",
|
||||||
|
body: `{"error":{"code":6.02e+23}}`,
|
||||||
|
want: providerErrorDetails{providerCode: "6.02e+23"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid fields do not discard valid fields",
|
||||||
|
body: `{"error":{"message":null,"type":"invalid_request_error","code":false}}`,
|
||||||
|
want: providerErrorDetails{providerType: "invalid_request_error"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown fields are ignored",
|
||||||
|
body: `{"trace":"do not retain","error":{"param":"temperature","metadata":{"secret":"x"}}}`,
|
||||||
|
want: providerErrorDetails{},
|
||||||
|
},
|
||||||
|
{name: "missing error", body: `{}`, want: providerErrorDetails{}},
|
||||||
|
{name: "null error", body: `{"error":null}`, want: providerErrorDetails{}},
|
||||||
|
{name: "scalar error", body: `{"error":"nope"}`, want: providerErrorDetails{}},
|
||||||
|
{name: "empty error", body: `{"error":{}}`, want: providerErrorDetails{}},
|
||||||
|
{name: "malformed", body: `{"error":`, want: providerErrorDetails{}},
|
||||||
|
{name: "truncated", body: `{"error":{"message":"x"`, want: providerErrorDetails{}},
|
||||||
|
{name: "trailing garbage", body: `{"error":{"message":"x"}} garbage`, want: providerErrorDetails{}},
|
||||||
|
{name: "second document", body: `{"error":{"message":"x"}} {}`, want: providerErrorDetails{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := parseProviderErrorEnvelope([]byte(tc.body)); !reflect.DeepEqual(got, tc.want) {
|
||||||
|
t.Fatalf("parseProviderErrorEnvelope() = %#v, want %#v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderErrorTextNormalizationAndLimits(t *testing.T) {
|
||||||
|
validIdentifier := strings.Repeat("界", maxProviderErrorIdentifierRunes)
|
||||||
|
validMessage := strings.Repeat("界", maxProviderErrorMessageRunes)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
got string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "multibyte text", got: "Grüße 世界", want: "Grüße 世界"},
|
||||||
|
{name: "invalid UTF-8", got: string([]byte{'a', 0xff, 'b'}), want: "a<>b"},
|
||||||
|
{name: "whitespace control and format runs", got: " \n\talpha\x00\u200b\u200bbeta \r ", want: "alpha beta"},
|
||||||
|
{name: "blank normalization", got: "\t\u200b\n", want: ""},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := normalizeProviderErrorText(tc.got); got != tc.want {
|
||||||
|
t.Fatalf("normalizeProviderErrorText() = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := normalizeProviderErrorIdentifier(validIdentifier); got != validIdentifier {
|
||||||
|
t.Fatalf("exact identifier boundary = %q, want retained value", got)
|
||||||
|
}
|
||||||
|
if got := normalizeProviderErrorIdentifier(validIdentifier + "界"); got != "" {
|
||||||
|
t.Fatalf("overlong identifier = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := normalizeProviderErrorMessage(validMessage); got != validMessage {
|
||||||
|
t.Fatalf("exact message boundary = %q, want retained value", got)
|
||||||
|
}
|
||||||
|
wantTruncatedMessage := strings.Repeat("界", maxProviderErrorMessageRunes-1) + "…"
|
||||||
|
if got := normalizeProviderErrorMessage(validMessage + "界"); got != wantTruncatedMessage {
|
||||||
|
t.Fatalf("overlong message length = %d, want %d", utf8.RuneCountInString(got), maxProviderErrorMessageRunes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderHTTPErrorIdentityAndFormatting(t *testing.T) {
|
||||||
|
const marker = "provider-secret-marker"
|
||||||
|
err := newProviderHTTPError(429, providerErrorDetails{
|
||||||
|
providerCode: marker + "-code",
|
||||||
|
providerType: marker + "-type",
|
||||||
|
providerMessage: marker + "-message",
|
||||||
|
})
|
||||||
|
|
||||||
|
if err.StatusCode() != 429 || err.ProviderCode() != marker+"-code" || err.ProviderType() != marker+"-type" || err.ProviderMessage() != marker+"-message" {
|
||||||
|
t.Fatalf("accessors returned unexpected values: %#v", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrUnexpectedStatus) {
|
||||||
|
t.Fatalf("errors.Is(%v, ErrUnexpectedStatus) = false", err)
|
||||||
|
}
|
||||||
|
for _, rendered := range []string{fmt.Sprintf("%v", err), fmt.Sprintf("%+v", err), fmt.Sprintf("%#v", err)} {
|
||||||
|
if rendered != "llm returned non-success status: status=429" {
|
||||||
|
t.Fatalf("formatted error = %q", rendered)
|
||||||
|
}
|
||||||
|
if strings.Contains(rendered, marker) {
|
||||||
|
t.Fatalf("formatted error exposed provider marker: %q", rendered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nilError *ProviderHTTPError
|
||||||
|
if nilError.StatusCode() != 0 || nilError.ProviderCode() != "" || nilError.ProviderType() != "" || nilError.ProviderMessage() != "" {
|
||||||
|
t.Fatal("nil accessors returned provider values")
|
||||||
|
}
|
||||||
|
if nilError.Error() != "llm returned non-success status" || nilError.GoString() != "llm returned non-success status" || !errors.Is(nilError, ErrUnexpectedStatus) {
|
||||||
|
t.Fatalf("nil error behavior is not safe: %v", nilError)
|
||||||
|
}
|
||||||
|
|
||||||
|
zero := &ProviderHTTPError{}
|
||||||
|
if zero.Error() != "llm returned non-success status" || zero.GoString() != "llm returned non-success status" || !errors.Is(zero, ErrUnexpectedStatus) {
|
||||||
|
t.Fatalf("zero error behavior is not safe: %v", zero)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderHTTPErrorBodyBounds(t *testing.T) {
|
||||||
|
const (
|
||||||
|
statusCode = 502
|
||||||
|
marker = "provider-body-marker"
|
||||||
|
)
|
||||||
|
|
||||||
|
ordinaryBody := `{"error":{"message":"` + marker + `"}}`
|
||||||
|
exactLimitBody := ordinaryBody + strings.Repeat(" ", int(maxProviderErrorResponseBytes)-len(ordinaryBody))
|
||||||
|
overLimitBody := ordinaryBody + strings.Repeat(" ", int(maxProviderErrorResponseBytes)+1-len(ordinaryBody))
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
contentLength int64
|
||||||
|
reader io.Reader
|
||||||
|
wantRead int64
|
||||||
|
wantMessage string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "recognized envelope",
|
||||||
|
contentLength: int64(len(ordinaryBody)),
|
||||||
|
reader: strings.NewReader(ordinaryBody),
|
||||||
|
wantRead: int64(len(ordinaryBody)),
|
||||||
|
wantMessage: marker,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact limit",
|
||||||
|
contentLength: maxProviderErrorResponseBytes,
|
||||||
|
reader: strings.NewReader(exactLimitBody),
|
||||||
|
wantRead: maxProviderErrorResponseBytes,
|
||||||
|
wantMessage: marker,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "declared oversize does not read",
|
||||||
|
contentLength: maxProviderErrorResponseBytes + 1,
|
||||||
|
reader: strings.NewReader(ordinaryBody),
|
||||||
|
wantRead: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown length oversize",
|
||||||
|
contentLength: -1,
|
||||||
|
reader: strings.NewReader(overLimitBody),
|
||||||
|
wantRead: maxProviderErrorResponseBytes + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "underreported oversize",
|
||||||
|
contentLength: maxProviderErrorResponseBytes,
|
||||||
|
reader: strings.NewReader(overLimitBody),
|
||||||
|
wantRead: maxProviderErrorResponseBytes + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read failure",
|
||||||
|
contentLength: -1,
|
||||||
|
reader: failingReader{err: errors.New("read failure")},
|
||||||
|
wantRead: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty body",
|
||||||
|
contentLength: 0,
|
||||||
|
reader: strings.NewReader(""),
|
||||||
|
wantRead: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
reader := &guardedReader{
|
||||||
|
reader: tc.reader,
|
||||||
|
remaining: maxProviderErrorResponseBytes + 1,
|
||||||
|
}
|
||||||
|
err := providerHTTPErrorFromBody(statusCode, tc.contentLength, reader)
|
||||||
|
if err == nil || err.StatusCode() != statusCode {
|
||||||
|
t.Fatalf("error status = %v, want %d", err, statusCode)
|
||||||
|
}
|
||||||
|
if reader.bytes != tc.wantRead {
|
||||||
|
t.Fatalf("body bytes read = %d, want %d", reader.bytes, tc.wantRead)
|
||||||
|
}
|
||||||
|
if reader.violated {
|
||||||
|
t.Fatal("body reader was asked to read beyond the overflow probe")
|
||||||
|
}
|
||||||
|
if got := err.ProviderMessage(); got != tc.wantMessage {
|
||||||
|
t.Fatalf("provider message = %q, want %q", got, tc.wantMessage)
|
||||||
|
}
|
||||||
|
if tc.wantMessage == "" {
|
||||||
|
if err.ProviderCode() != "" || err.ProviderType() != "" || strings.Contains(err.Error(), marker) {
|
||||||
|
t.Fatalf("discarded details were retained: %#v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
145
internal/llm/provider_http_error_transport_test.go
Normal file
145
internal/llm/provider_http_error_transport_test.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenAICompatibleClientStructuredNonSuccessResponse(t *testing.T) {
|
||||||
|
body := `{"error":{"message":" provider\nmessage\u200b","type":"invalid\ttype","code":1.5e+4}}`
|
||||||
|
responseBody := &countingReadCloser{reader: strings.NewReader(body)}
|
||||||
|
client := newNonSuccessResponseClient(t, http.StatusBadRequest, int64(len(body)), responseBody)
|
||||||
|
|
||||||
|
response, err := client.Generate(context.Background(), ordinaryGenerateRequest())
|
||||||
|
if response != nil {
|
||||||
|
t.Fatalf("response = %#v, want nil", response)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrUnexpectedStatus) {
|
||||||
|
t.Fatalf("errors.Is(%v, ErrUnexpectedStatus) = false", err)
|
||||||
|
}
|
||||||
|
var providerHTTPError *ProviderHTTPError
|
||||||
|
if !errors.As(err, &providerHTTPError) {
|
||||||
|
t.Fatalf("error = %T, want *ProviderHTTPError", err)
|
||||||
|
}
|
||||||
|
if providerHTTPError.StatusCode() != http.StatusBadRequest || providerHTTPError.ProviderCode() != "1.5e+4" || providerHTTPError.ProviderType() != "invalid type" || providerHTTPError.ProviderMessage() != "provider message" {
|
||||||
|
t.Fatalf("provider error = %#v", providerHTTPError)
|
||||||
|
}
|
||||||
|
if !responseBody.closed {
|
||||||
|
t.Fatal("non-success response body was not closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompatibleClientNonSuccessBodyOwnership(t *testing.T) {
|
||||||
|
const marker = "provider-body-marker"
|
||||||
|
normalBody := `{"error":{"message":"` + marker + `"}}`
|
||||||
|
overLimitBody := normalBody + strings.Repeat(" ", int(maxProviderErrorResponseBytes)+1-len(normalBody))
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
contentLength int64
|
||||||
|
reader io.Reader
|
||||||
|
wantRead int64
|
||||||
|
wantMessage string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "normal",
|
||||||
|
contentLength: int64(len(normalBody)),
|
||||||
|
reader: strings.NewReader(normalBody),
|
||||||
|
wantRead: int64(len(normalBody)),
|
||||||
|
wantMessage: marker,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "declared oversize",
|
||||||
|
contentLength: maxProviderErrorResponseBytes + 1,
|
||||||
|
reader: strings.NewReader(normalBody),
|
||||||
|
wantRead: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "streamed oversize",
|
||||||
|
contentLength: -1,
|
||||||
|
reader: &guardedReader{
|
||||||
|
reader: strings.NewReader(overLimitBody),
|
||||||
|
remaining: maxProviderErrorResponseBytes + 1,
|
||||||
|
},
|
||||||
|
wantRead: maxProviderErrorResponseBytes + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "underreported oversize",
|
||||||
|
contentLength: maxProviderErrorResponseBytes,
|
||||||
|
reader: &guardedReader{
|
||||||
|
reader: strings.NewReader(overLimitBody),
|
||||||
|
remaining: maxProviderErrorResponseBytes + 1,
|
||||||
|
},
|
||||||
|
wantRead: maxProviderErrorResponseBytes + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed",
|
||||||
|
contentLength: 1,
|
||||||
|
reader: strings.NewReader("{"),
|
||||||
|
wantRead: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read failure",
|
||||||
|
contentLength: -1,
|
||||||
|
reader: failingReader{err: errors.New("response read failed")},
|
||||||
|
wantRead: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
body := &countingReadCloser{reader: tc.reader}
|
||||||
|
client := newNonSuccessResponseClient(t, http.StatusBadGateway, tc.contentLength, body)
|
||||||
|
|
||||||
|
response, err := client.Generate(context.Background(), ordinaryGenerateRequest())
|
||||||
|
if response != nil {
|
||||||
|
t.Fatalf("response = %#v, want nil", response)
|
||||||
|
}
|
||||||
|
var providerHTTPError *ProviderHTTPError
|
||||||
|
if !errors.As(err, &providerHTTPError) {
|
||||||
|
t.Fatalf("error = %T, want *ProviderHTTPError", err)
|
||||||
|
}
|
||||||
|
if !body.closed {
|
||||||
|
t.Fatal("response body was not closed")
|
||||||
|
}
|
||||||
|
if body.bytesRead != tc.wantRead {
|
||||||
|
t.Fatalf("body bytes read = %d, want %d", body.bytesRead, tc.wantRead)
|
||||||
|
}
|
||||||
|
if body.bytesRead > maxProviderErrorResponseBytes+1 {
|
||||||
|
t.Fatalf("body bytes read = %d, exceeds overflow probe", body.bytesRead)
|
||||||
|
}
|
||||||
|
if guarded, ok := tc.reader.(*guardedReader); ok && guarded.violated {
|
||||||
|
t.Fatal("body reader was asked to read beyond the overflow probe")
|
||||||
|
}
|
||||||
|
if got := providerHTTPError.ProviderMessage(); got != tc.wantMessage {
|
||||||
|
t.Fatalf("provider message = %q, want %q", got, tc.wantMessage)
|
||||||
|
}
|
||||||
|
if tc.wantMessage == "" && (providerHTTPError.ProviderCode() != "" || providerHTTPError.ProviderType() != "" || strings.Contains(providerHTTPError.Error(), marker)) {
|
||||||
|
t.Fatalf("discarded details were retained: %#v", providerHTTPError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNonSuccessResponseClient(t *testing.T, statusCode int, contentLength int64, body io.ReadCloser) *OpenAICompatibleClient {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||||
|
BaseURL: "https://provider.example/v1",
|
||||||
|
Model: "m",
|
||||||
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: statusCode,
|
||||||
|
ContentLength: contentLength,
|
||||||
|
Body: body,
|
||||||
|
}, nil
|
||||||
|
})},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct client: %v", err)
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
id: rakestrawhome-gemma-4-31b
|
||||||
|
backend: rakestrawhome
|
||||||
|
model: google/gemma-4-31b-it
|
||||||
@@ -26,8 +26,8 @@ func TestBuiltInProfilesValidateThroughRepository(t *testing.T) {
|
|||||||
if p.ID != id {
|
if p.ID != id {
|
||||||
t.Fatalf("expected profile id %q, got %q", id, p.ID)
|
t.Fatalf("expected profile id %q, got %q", id, p.ID)
|
||||||
}
|
}
|
||||||
if p.BackendID != backend.OpenRouterID {
|
if !builtInBackendIDs[p.BackendID] {
|
||||||
t.Fatalf("expected profile %q to select %q, got %q", id, backend.OpenRouterID, p.BackendID)
|
t.Fatalf("expected profile %q to select a maintained built-in, got %q", id, p.BackendID)
|
||||||
}
|
}
|
||||||
if p.Endpoint != "" || p.APIKeyEnv != "" {
|
if p.Endpoint != "" || p.APIKeyEnv != "" {
|
||||||
t.Fatalf("expected profile %q to inherit backend connection settings, got endpoint=%q api_key_env=%q", id, p.Endpoint, p.APIKeyEnv)
|
t.Fatalf("expected profile %q to inherit backend connection settings, got endpoint=%q api_key_env=%q", id, p.Endpoint, p.APIKeyEnv)
|
||||||
@@ -40,6 +40,33 @@ func TestBuiltInProfilesDoNotContainDuplicateIDsOrRawAPIKeys(t *testing.T) {
|
|||||||
loadBuiltInProfileIDs(t)
|
loadBuiltInProfileIDs(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRakestrawhomeGemmaProfileUsesNativeDefaults(t *testing.T) {
|
||||||
|
p, err := NewRepository().GetProfile(context.Background(), "rakestrawhome-gemma-4-31b")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load Rakestrawhome Gemma profile: %v", err)
|
||||||
|
}
|
||||||
|
if p.ID != "rakestrawhome-gemma-4-31b" ||
|
||||||
|
p.BackendID != backend.RakestrawHomeID ||
|
||||||
|
p.Model != "google/gemma-4-31b-it" ||
|
||||||
|
p.Endpoint != "" ||
|
||||||
|
p.Temperature != 0 ||
|
||||||
|
p.MaxTokens != 0 ||
|
||||||
|
p.TopP != 0 ||
|
||||||
|
p.TimeoutSeconds != 0 ||
|
||||||
|
p.ServiceTier != "" ||
|
||||||
|
p.ReasoningEffort != "" ||
|
||||||
|
p.APIKeyEnv != "" ||
|
||||||
|
p.APIKeyRequired ||
|
||||||
|
p.ExtraParams != nil {
|
||||||
|
t.Fatalf("unexpected Rakestrawhome Gemma profile: %#v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var builtInBackendIDs = map[string]bool{
|
||||||
|
backend.OpenRouterID: true,
|
||||||
|
backend.RakestrawHomeID: true,
|
||||||
|
}
|
||||||
|
|
||||||
func loadBuiltInProfileIDs(t *testing.T) map[string]string {
|
func loadBuiltInProfileIDs(t *testing.T) map[string]string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -64,8 +91,9 @@ func loadBuiltInProfileIDs(t *testing.T) map[string]string {
|
|||||||
if _, ok := raw["api_key"]; ok {
|
if _, ok := raw["api_key"]; ok {
|
||||||
t.Fatalf("built-in profile %s contains raw api_key", name)
|
t.Fatalf("built-in profile %s contains raw api_key", name)
|
||||||
}
|
}
|
||||||
if raw["backend"] != backend.OpenRouterID {
|
backendID, ok := raw["backend"].(string)
|
||||||
t.Fatalf("built-in profile %s does not select %q", name, backend.OpenRouterID)
|
if !ok || !builtInBackendIDs[backendID] {
|
||||||
|
t.Fatalf("built-in profile %s does not select a maintained built-in: %#v", name, raw["backend"])
|
||||||
}
|
}
|
||||||
if _, ok := raw["endpoint"]; ok {
|
if _, ok := raw["endpoint"]; ok {
|
||||||
t.Fatalf("built-in profile %s repeats endpoint", name)
|
t.Fatalf("built-in profile %s repeats endpoint", name)
|
||||||
|
|||||||
@@ -784,9 +784,12 @@ func TestBackendRegistrationRejectsInvalidAndDuplicateDefinitions(t *testing.T)
|
|||||||
{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"},
|
||||||
}},
|
}},
|
||||||
{name: "reserved built-in id", backends: []promptkit.Backend{{
|
{name: "reserved OpenRouter ID", backends: []promptkit.Backend{{
|
||||||
ID: promptkit.BackendOpenRouter, Endpoint: "http://replacement.example/v1",
|
ID: promptkit.BackendOpenRouter, Endpoint: "http://replacement.example/v1",
|
||||||
}}},
|
}}},
|
||||||
|
{name: "reserved Rakestrawhome ID", backends: []promptkit.Backend{{
|
||||||
|
ID: promptkit.BackendRakestrawHome, Endpoint: "http://replacement.example/v1",
|
||||||
|
}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
8
types.go
8
types.go
@@ -665,10 +665,10 @@ type StructuredOutputJSONSpec struct {
|
|||||||
// and retained copies. It is responsible for the cancellation behavior of any
|
// and retained copies. It is responsible for the cancellation behavior of any
|
||||||
// work it starts and for synchronizing access to retained or shared data.
|
// work it starts and for synchronizing access to retained or shared data.
|
||||||
//
|
//
|
||||||
// A returned error makes Run or RunPrepared return ErrLLMGenerate while
|
// An arbitrary returned error makes Run or RunPrepared return ErrLLMGenerate
|
||||||
// preserving the client error through errors.Is. A nil response with a nil
|
// while preserving the client error through errors.Is rather than translating
|
||||||
// error also produces ErrLLMGenerate. Promptkit copies the non-nil response
|
// it. A nil response with a nil error also produces ErrLLMGenerate. Promptkit
|
||||||
// before returning from either method.
|
// copies the non-nil response before returning from either method.
|
||||||
type LLMClient interface {
|
type LLMClient interface {
|
||||||
Generate(context.Context, GenerateRequest) (*GenerateResponse, error)
|
Generate(context.Context, GenerateRequest) (*GenerateResponse, error)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user