UNPKG

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 • 12.7 kB
/** * 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>; } //# sourceMappingURL=InvokeModelGatewayProvider.d.ts.map