agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
242 lines (241 loc) • 12.6 kB
TypeScript
/**
* InvokeModelGatewayProvider — Anthropic models behind a gateway that speaks the
* Bedrock InvokeModel wire over plain HTTPS with an API-key header.
*
* Pattern: Adapter (GoF). Zero peer dependencies — uses `fetch`.
* Role: Outer ring. Translates `LLMRequest`/`LLMResponse` to and from the
* gateway's wire. Knows nothing about agents, skills or routing.
*
* ─── Why this is not `bedrock()` ────────────────────────────────────
*
* `bedrock()` drives the AWS SDK: SigV4 credentials against the CONVERSE
* operation. Many organisations put the models behind their own gateway
* instead — one that authenticates with an API-key header and forwards the
* INVOKE operation. Neither the auth nor the operation matches, and
* `providerFromEnv()` has no base URL for this wire, so until this adapter an
* app had to write its own (the field case that prompted it was ~450 lines).
*
* ─── The wire ───────────────────────────────────────────────────────
*
* POST {baseUrl}/model/{modelId}/invoke complete()
* POST {baseUrl}/model/{modelId}/invoke-with-response-stream stream()
* headers: content-type: application/json, {apiKeyHeader}: <key>
*
* The body is Anthropic's Messages API with two differences: the literal
* `anthropic_version: "bedrock-2023-05-31"`, and NO `model` field — the model
* is in the path. Everything else (`system` as a top-level field, content
* blocks, `tools` with `input_schema`, `tool_choice`) is the shape
* `anthropicMessagesWire.ts` owns.
*
* The stream answers SSE (`data: {…}` lines, with or without `event:` lines):
* the gateway has already decoded AWS's binary event-stream framing, so there
* is no SDK and no event-stream parser here.
*
* ─── Retries are NOT here ───────────────────────────────────────────
*
* This adapter makes ONE attempt per call. Every failure it raises is an
* {@link InvokeModelGatewayError} carrying the two fields `withRetry`'s default
* predicate reads: `status` on an HTTP refusal (429 and 5xx retried, other 4xx
* not) and `retryable: false` on every failure that asking again cannot mend —
* a refusal raised before any request, or a 2xx answer it could not read (a
* re-send may run and bill the model again). So the default policy retries a
* 429, a 5xx, a network failure and a timeout, and nothing else. Compose:
*
* withRetry(invokeModelGateway({ ... }))
*
* A retry loop inside an adapter is a second policy nobody can see or tune.
*
* ─── What this adapter DOES own ─────────────────────────────────────
*
* - The stated wait. A throttled gateway says how long to wait — a
* `Retry-After` header when it feels like it, and often only in the body
* ("Rate limit is exceeded. Try again in 4 seconds."). The wording is this
* wire's, so it is read HERE and declared as `retryAfterMs` (header first);
* `withRetry` waits max(its schedule, that), capped by its `maxDelayMs`.
* - The deadline. `timeoutMs` bounds the wait for the response headers, for a
* complete() body, and for EACH stream read (the first chunk and every gap
* between chunks — a stream that is still talking is never cut off), and
* for the response body to close. A miss
* aborts the request and raises `reason: 'timeout'`, retryable; the
* caller's `req.signal` still wins.
*/
import type { LLMCallHooks, LLMChunk, LLMProvider, LLMRequest, LLMResponse } from '../../provider-contract/types.js';
import type { ThinkingMode } from '../../thinking/types.js';
/** The literal this wire requires in the body in place of a version header. */
export declare const INVOKE_MODEL_ANTHROPIC_VERSION = "bedrock-2023-05-31";
/**
* Where the key for a call comes from.
*
* - a string — one key for every model;
* - a map from model id to key — for gateways that scope a key to a set of
* models (asking with the wrong key answers 403). A model id missing from
* the map is refused by name before any request is sent;
* - a function of the model id — re-read before EVERY request, so a rotated
* key is picked up without rebuilding the provider.
*/
export type InvokeModelGatewayKey = string | Readonly<Record<string, string>> | ((modelId: string) => string | undefined | Promise<string | undefined>);
/** Anything `fetch`-shaped — an mTLS agent, a proxy, or a scripted test double. */
export type InvokeModelGatewayFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
export interface InvokeModelGatewayOptions {
/**
* The gateway root. Requests go to `{baseUrl}/model/{modelId}/invoke` and
* `…/invoke-with-response-stream`. A trailing slash is ignored.
*/
readonly baseUrl: string;
/**
* The header the gateway reads the key from, e.g. `'api-key'` or
* `'x-api-key'`. Required — a guessed default would fail as a 401 that looks
* like a bad key. For a bearer scheme use `'authorization'` and put
* `'Bearer …'` in the key.
*/
readonly apiKeyHeader: string;
/** The key, a key per model id, or a function of the model id. */
readonly apiKey: InvokeModelGatewayKey;
/**
* The model id the gateway knows, e.g. `'us.anthropic.claude-haiku-4-5-20251001-v1:0'`.
* Used when `LLMRequest.model` is the shorthand `'invoke-model-gateway'`;
* any other request model is sent as given.
*/
readonly model?: string;
/** Default `max_tokens` when the request sets none. Default 4096. */
readonly defaultMaxTokens?: number;
/**
* May the model ask for several tools in one reply? `false` sends
* `tool_choice: { type: 'auto', disable_parallel_tool_use: true }`.
* Omitted sends nothing (the model's default — batching allowed).
*/
readonly parallelToolCalls?: boolean;
/**
* Which thinking request a model takes, for an id the built-in table cannot
* read — the alias your gateway knows, an application-inference-profile ARN,
* a provisioned-model ARN. The table reads Bedrock ids
* (`us.anthropic.claude-…-v1:0`) itself; an id it does not know is read as
* adaptive (every Claude model since 4.7), so a budget-only model behind an
* opaque id (Claude 4.5 or earlier) is declared here. Return `undefined` to
* use the table. The provider declares the result AND sends that shape.
*
* @example
* invokeModelGateway({ …, model: 'haiku-fast', thinkingMode: (id) => (id === 'haiku-fast' ? 'budget' : undefined) })
*/
readonly thinkingMode?: (model: string) => ThinkingMode | undefined;
/** Replace `fetch` — for an mTLS agent, a proxy, or a test. Default: global `fetch`. */
readonly fetch?: InvokeModelGatewayFetch;
/**
* Per-request deadline in ms. Bounds the wait for the response headers, for
* a complete() body, and for each stream read — the first chunk and every
* gap between chunks (an idle deadline: a stream that keeps talking is never
* cut off), and the response body close. A miss aborts the request and raises an
* {@link InvokeModelGatewayError} with `reason: 'timeout'`, `retryable:
* true` — `withRetry` asks again only while no chunk has reached the caller.
* A wrapper of your own that re-sends on `retryable` alone must make the
* same check: a timeout BETWEEN chunks is also `retryable`, and re-sending
* then would deliver the start of the answer twice. The caller's `req.signal` still wins. Omitted: no deadline (the default).
*/
readonly timeoutMs?: number;
}
/** Which failure an {@link InvokeModelGatewayError} is. */
export type InvokeModelGatewayErrorReason =
/** The options cannot make a request (no base URL, header, or key). */
'invalid-options'
/** The request named no model and the provider has no `model` to fall back to. */
| 'no-model'
/** No key for this model id — the map lacks it, or the function returned none. */
| 'no-key'
/** The gateway answered non-2xx. `status` is set; `bodyExcerpt` says why. */
| 'http-status'
/** The request never got an answer (DNS, TLS, connection reset). */
| 'network'
/** No answer — headers, body, or the next stream chunk — within `timeoutMs`. */
| 'timeout'
/** complete() answered 2xx with a body that is not a Messages response. */
| 'unreadable-response'
/** The stream answered 2xx with no body, sent an error event, or an unreadable event. */
| 'stream'
/** A tool call's streamed argument JSON did not parse. Refused, never run as `{}`. */
| 'malformed-tool-args';
/**
* Every failure `invokeModelGateway()` raises, told in words that name the
* model and the fix. `reason` is the discriminator.
*
* `retryable` is `true` for a 429, a 5xx, a network failure and a timeout, and `false`
* for every other reason, and `withRetry`'s default predicate honours the
* `false` — so a refusal raised before any request (`no-key`, `no-model`,
* `invalid-options`) is never repeated, and a 2xx answer it could not read
* (`unreadable-response`) is never re-sent to a model that may already have
* run. `status` is set ONLY for `'http-status'`. The key is never in the
* message.
*/
export declare class InvokeModelGatewayError extends Error {
readonly name = "InvokeModelGatewayError";
readonly reason: InvokeModelGatewayErrorReason;
/** The model id the call was for, when one was resolved. */
readonly modelId?: string;
/** The HTTP status, for `'http-status'` only. */
readonly status?: number;
/**
* Whether asking again can mend it: a 429, a 5xx, a network failure, or a
* timeout. `withRetry`'s default predicate reads the `false`.
*/
readonly retryable: boolean;
/** The gateway's `Retry-After` header, in seconds, when it sent a number. */
readonly retryAfterSeconds?: number;
/**
* The wait the gateway STATED, in ms: its `Retry-After` header (seconds or
* an HTTP-date) or, when there is none, its body's "try again in N
* seconds". `withRetry` waits at least this long (capped by `maxDelayMs`).
*/
readonly retryAfterMs?: number;
/** The first 400 characters of the gateway's refusal body. */
readonly bodyExcerpt?: string;
/** For `'malformed-tool-args'`: the tool whose arguments did not parse. */
readonly toolName?: string;
constructor(init: {
reason: InvokeModelGatewayErrorReason;
message: string;
modelId?: string;
status?: number;
retryAfterSeconds?: number;
retryAfterMs?: number;
bodyExcerpt?: string;
toolName?: string;
cause?: unknown;
});
}
/**
* An `LLMProvider` for Anthropic models behind a gateway that speaks the
* Bedrock InvokeModel wire with an API-key header.
*
* @example
* ```ts
* import { Agent } from 'agentfootprint';
* import { invokeModelGateway } from 'agentfootprint/providers';
* import { withRetry } from 'agentfootprint/resilience';
*
* const provider = withRetry(
* invokeModelGateway({
* baseUrl: 'https://llm-gateway.example.com/bedrock',
* apiKeyHeader: 'api-key',
* apiKey: process.env.GATEWAY_KEY!,
* model: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
* }),
* );
* const agent = Agent.create({ provider, model: 'invoke-model-gateway' }).build();
* ```
*/
export declare function invokeModelGateway(options: InvokeModelGatewayOptions): LLMProvider;
/**
* Class form of {@link invokeModelGateway}, for code that prefers `new`.
*/
export declare class InvokeModelGatewayProvider implements LLMProvider {
readonly name = "invoke-model-gateway";
readonly carriesForcedToolChoice = true;
/** The inner declaration resolves this adapter's shorthand before looking up the model. */
readonly supportsForcedToolChoice: (model: string) => boolean;
readonly thinkingHandler: import("../../thinking/types.js").ThinkingHandler;
/** The inner provider's own function — a closure, safe to forward unbound. */
readonly thinkingMode: (model: string) => ThinkingMode;
private readonly inner;
constructor(options: InvokeModelGatewayOptions);
complete(req: LLMRequest, hooks?: LLMCallHooks): Promise<LLMResponse>;
stream(req: LLMRequest, hooks?: LLMCallHooks): AsyncIterable<LLMChunk>;
}