Documentation
¶
Overview ¶
Package provider abstracts model backends behind Anthropic Messages semantics. A provider instance is constructed purely from configuration (protocol / model / base_url / api_key — CLAUDE.md principle 4): the anthropic protocol adapter works against ANY endpoint speaking Anthropic Messages — a gateway, a proxy, or a self-hosted model — never just api.anthropic.com. The registry resolves an agent's model string to a configured provider.
Index ¶
Constants ¶
const DefaultStallTimeout = 10 * time.Minute
DefaultStallTimeout is how long a model endpoint may say nothing at all before its turn is abandoned, when a route configures no stall_timeout of its own. It is the anthropic-sdk-go's own judgment for the same hazard — that SDK's defaultResponseHeaderTimeout — because the worst legitimate silence is the same wait it bounds: an endpoint that queues a request sends no response header until it starts generating. Sized to never end a healthy turn; operators who know their endpoint answers faster tighten it per route.
Variables ¶
var ErrStalled = errors.New("model endpoint stalled")
ErrStalled reports that an endpoint accepted the request and then went silent for its whole stall budget. It is a model-side failure like any other — the brain settles it as a session.error rather than abandoning the turn to its lease — and it exists as a sentinel so a caller can tell an endpoint that stopped answering from a context its own caller cancelled, which are the same cancellation on the wire.
Functions ¶
func ProgressBody ¶ added in v0.2.0
func ProgressBody(ctx context.Context, body io.ReadCloser) io.ReadCloser
ProgressBody wraps a response body so that every byte it delivers is a sign of life for the stall guard ctx carries, returning it unchanged when there is none. Liveness is measured in bytes rather than in protocol frames because the frames that prove a quiet endpoint is alive never reach an adapter: the SDK's stream decoder swallows Anthropic's ping events, and an SSE comment is not an event at all. A guard fed only by content would kill a model that is still thinking.
Types ¶
type Chunk ¶
type Chunk struct {
Kind ChunkKind
Index int64 // content block index (text/thinking deltas)
Text string // text/thinking fragment
ToolUse *ToolUse // KindToolUse only
StopReason string // KindDone only: end_turn | tool_use | max_tokens | …
// Usage is KindDone only, and nil means the endpoint reported none —
// not that it reported zeroes. An adapter must send nil when no usage
// object arrived on the wire: an OpenAI-compatible gateway that ignores
// stream_options.include_usage would otherwise be indistinguishable from
// a model that ran for free, and the token metric would record it as one
// (#90).
Usage *domain.ModelUsage
}
Chunk is one streaming increment.
type ChunkKind ¶
type ChunkKind string
ChunkKind discriminates streaming increments of a turn.
const ( // KindTextDelta appends text to the content block at Index. KindTextDelta ChunkKind = "text_delta" // KindThinkingDelta appends thinking text to the block at Index. KindThinkingDelta ChunkKind = "thinking_delta" // KindToolUse is one complete tool invocation (input fully accumulated). KindToolUse ChunkKind = "tool_use" // KindDone closes the turn with stop reason and usage. KindDone ChunkKind = "done" )
type Config ¶
type Config struct {
Protocol string // "anthropic" | "openai"
Model string // model id sent to the upstream endpoint
BaseURL string // endpoint base URL; required (no implicit default host)
APIKey string // credential for the endpoint
Headers map[string]string // optional extra headers (gateway routing etc.)
// StallTimeout is how long this endpoint may send nothing at all before its
// turn is abandoned (#121); zero takes DefaultStallTimeout. It is per route
// because the worst legitimate silence is a property of the endpoint — a
// hosted gateway answers in seconds, a queued self-hosted model may take
// minutes to send its first byte. See StallGuard for what it bounds and why
// it is not an HTTP client timeout.
StallTimeout time.Duration
// MaxTokens is the default output cap for turns that set none themselves;
// a Request.MaxTokens always wins. Per route because the right cap is a
// property of the endpoint and the workload behind it: an agent that
// writes whole files through tool calls dies mid-input on a cap sized for
// chat (the outcomes acceptance hit exactly that). Zero keeps the
// adapter's own default — anthropic sends its required-field fallback,
// openai omits the field so the endpoint's default applies.
MaxTokens int64
}
Config constructs one provider instance.
type Descriptor ¶ added in v0.2.0
Descriptor is what may be said out loud about the backend a model routes to: the protocol its endpoint speaks and the model id sent upstream. It exists so telemetry can name the backend without being handed a Config, which carries the credential — the redaction is the type's shape, not a caller's discipline.
type Factory ¶
Factory constructs a Provider for one protocol. It must be cheap and must acquire no per-instance resource — no private http.Transport, no connection pool, no goroutine — because the registry calls it once per turn rather than retaining what it builds (see Registry). Both adapters satisfy this by sharing http.DefaultClient.
It must also be safe to call concurrently: the registry holds no lock, so turns in flight on different sessions enter the factory at the same time.
An adapter that genuinely cannot be cheap must cache the shared resource itself, and must key that cache by the endpoint alone — protocol, base URL, credential, headers. NOT by the whole Config: under a pass-through route Config.Model is the agent's model string, so a Config-keyed cache would be keyed by client input and would rebuild issue #88 inside the adapter.
None of this forbids a *turn* from acquiring something: Generate starts the StallGuard's watcher goroutine, which the stream releases on Close. What the factory must not acquire is anything that outlives the instance it builds.
type Message ¶
type Message struct {
Role string // "user" | "assistant"
Content json.RawMessage // Anthropic content: a string or an array of blocks
}
Message is one conversational turn.
type Redactor ¶ added in v0.2.0
type Redactor struct {
// contains filtered or unexported fields
}
Redactor removes the credentials one provider was configured with from text that quotes an endpoint.
Adapters quote what the endpoint said about a failure, because a status alone rarely explains a gateway misconfiguration. But an endpoint that echoes the request's auth header into its own diagnostic body — some gateways do on a 401 — would otherwise put the credential into an error that becomes a session.error event: append-only in Postgres, and re-served to API clients on every read.
Matching is by exact value, not by token shape. The adapter holds the secret, so it need not guess what a credential looks like — a base_url may point at any gateway, proxy, or self-hosted model, whose token format is unknowable. Shape-matching "Bearer …" would also have missed the very leak this was written for: the Anthropic protocol sends x-api-key, and the observed echo was a bare value with no scheme prefix and no header name beside it.
func NewRedactor ¶ added in v0.2.0
NewRedactor collects the credentials cfg carries.
func (Redactor) Error ¶ added in v0.2.0
Error returns err with its message redacted, keeping the original reachable through errors.As and errors.Is. Wrapping with fmt.Errorf("%w") would not do: %w re-renders the wrapped message, which is the leak itself. Nothing in this repo unwraps a provider error today, but retry logic reading an upstream status is the obvious future caller, and it should not have to choose between the status and a safe message.
func (Redactor) Longest ¶ added in v0.2.0
Longest reports the length of the longest registered secret, so a caller that quotes only a bounded prefix of a response body can over-read by that much: a credential straddling the cap would otherwise be cut in half and survive redaction as an unmatched fragment.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry resolves agent model strings to constructed providers. Routing is exact-match with an optional "*" default, so an enterprise maps its model names onto endpoints purely in configuration.
Nothing is retained, deliberately. Providers used to be cached per model string, and under a "*" route that string is whatever a client put on its agent — so the map grew without bound in the long-running brain (issue #88). Keying it by the route instead would bound it, but the cache was buying too little to pay for the branch: a constructed provider is a value struct over the shared http.DefaultClient, the connection pool that matters lives in the process-global http.DefaultTransport, and Provider is called once per turn immediately before a model round trip that dwarfs the construction. With nothing to insert into, the bound is a property of the type rather than of a policy — and the registry owns copies of everything it was given, so it is immutable after NewRegistry and needs no lock.
func NewRegistry ¶
func (*Registry) Describe ¶ added in v0.2.0
func (r *Registry) Describe(model string) (Descriptor, bool)
Describe resolves a model string to its backend's Descriptor, reporting whether a route exists. It answers from configuration alone and never constructs a provider, so telemetry about an unroutable model costs nothing.
func (*Registry) Provider ¶
Provider constructs the provider for an agent's model string. When the route's config has no upstream model set, the agent's own model string passes through (a gateway that understands the platform's model names). Every call constructs a fresh instance; see the Registry doc for why.
type Request ¶
type Request struct {
System string
Messages []Message
Tools []json.RawMessage // Anthropic tool definitions, verbatim
MaxTokens int64
}
Request is one model turn in Anthropic Messages semantics. Content and tool definitions stay as raw Anthropic wire JSON so the anthropic-protocol adapter is near-zero-conversion; lossy mappings are confined to the non-Anthropic adapters (see provider/openai).
type Route ¶
Route binds an agent-facing model string to a provider config. Model "*" is the default route.
func LoadRoutes ¶
LoadRoutes reads a model_providers JSON file into registry routes. Structural validation happens here (unknown keys are config typos, not extensions); route-level validation stays in NewRegistry.
type StallGuard ¶ added in v0.2.0
type StallGuard struct {
// contains filtered or unexported fields
}
StallGuard bounds a turn by the endpoint's silence rather than by its duration: it cancels the request context once nothing has arrived for the budget, and every sign of life on the wire buys another budget. Duration alone cannot be the bound — a model streaming a large answer legitimately holds one request open for many minutes, while an endpoint that completes the handshake and then never speaks holds it open forever (#121). Both halves of that hang are one silence: the wait for response headers, which the anthropic SDK's own timeout covers only when the caller lets it install its HTTP client, and a stream that dies mid-SSE, which nothing covered at all.
It guards the request context rather than the HTTP client, deliberately. Registered on a client instead, a header timeout surfaces as a transport error the SDK retries — three wedged attempts, three budgets — while a per-route client would give every provider instance its own connection pool, which the registry's per-turn construction cannot afford (see Registry).
The shape mirrors queue.KeepLease: the constructor returns the context to run the work under, the work reports progress, and Stop releases the watcher.
Three limits are worth naming, because what this measures is strictly "no response byte was read for the budget". A consumer that stops reading charges its own slowness to the endpoint's budget (between chunks the brain does one event append, so only a database stall of a whole budget could reach it — at the default it cannot, at a tightened per-route one it could, and the turn would then blame the model for Postgres). A request body still being uploaded counts as silence too, which is the right reading: a peer too slow to receive the request is the wedged peer this bounds. And a peer that dribbles one byte per budget is never bounded at all, by construction — every byte buys another budget. Bounding that one needs a total deadline, which would have to be sized for the longest healthy turn and so would not bound anything worth bounding.
func NewStallGuard ¶ added in v0.2.0
NewStallGuard returns a child context cancelled once the endpoint has been silent for d, and the guard watching it. A d of zero or less takes DefaultStallTimeout. Run the request under the returned context, hand its response body to ProgressBody, call Stop when the stream is finished with, and pass any error the stream reports through Cause so a stall is named as one.
func (*StallGuard) Cause ¶ added in v0.2.0
func (g *StallGuard) Cause(err error) error
Cause names a stall as one, and passes anything else through. A tripped guard replaces the error outright rather than wrapping it: what the aborted read actually reports is "context canceled", which says nothing about who cancelled or why and would leave the session.error indistinguishable from a shutdown.
One case it labels wrongly, knowingly: the anthropic SDK honors an upstream Retry-After verbatim and uncapped, and sleeps it out under this context. A backoff longer than the budget is therefore cut short — a good bound, since an uncapped Retry-After is a hang of its own — but reported as a stall, and the endpoint's 429 is lost with it. Nothing distinguishes the SDK's own sleep from the endpoint's silence from outside the SDK.
func (*StallGuard) Progress ¶ added in v0.2.0
func (g *StallGuard) Progress()
Progress records a sign of life from the endpoint. It is deliberately cheap and lock-free: ProgressBody calls it on every read that delivered bytes, so it runs far more often than once per turn.
func (*StallGuard) Stop ¶ added in v0.2.0
func (g *StallGuard) Stop()
Stop releases the guard and the context it returned. It is idempotent, so a stream may be closed more than once.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package anthropic adapts any endpoint speaking the Anthropic Messages protocol to the provider interface, via the official SDK with a configurable base URL — an enterprise gateway, a proxy, or a self-hosted model are all just configuration.
|
Package anthropic adapts any endpoint speaking the Anthropic Messages protocol to the provider interface, via the official SDK with a configurable base URL — an enterprise gateway, a proxy, or a self-hosted model are all just configuration. |
|
Package openai adapts an OpenAI Chat Completions endpoint (OpenAI itself, a vLLM server, or an internal OpenAI-compatible gateway) to the provider interface.
|
Package openai adapts an OpenAI Chat Completions endpoint (OpenAI itself, a vLLM server, or an internal OpenAI-compatible gateway) to the provider interface. |
|
Package providertest is the shared contract suite every provider.Provider adapter must pass (CLAUDE.md: backend variability lives behind an interface with one shared suite, as internal/sandbox/sandboxtest and internal/blob/ blobtest already do for their backends).
|
Package providertest is the shared contract suite every provider.Provider adapter must pass (CLAUDE.md: backend variability lives behind an interface with one shared suite, as internal/sandbox/sandboxtest and internal/blob/ blobtest already do for their backends). |