agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
287 lines • 13.8 kB
JavaScript
/**
* BrowserAnthropicProvider — fetch-based Anthropic adapter for browsers.
*
* Pattern: Adapter (GoF). Zero peer dependencies — uses global `fetch`.
* Role: Outer ring. Same `LLMProvider` contract as `AnthropicProvider`,
* but skips `@anthropic-ai/sdk` (which doesn't bundle cleanly
* for browser). Aimed at playgrounds / prototypes where the
* user supplies their own key.
* Emits: N/A.
*
* Anthropic requires the `anthropic-dangerous-direct-browser-access: true`
* header for direct browser-to-API calls. This is intentional — production
* apps should proxy through a backend.
*
* ─── Limitations ────────────────────────────────────────────────────
*
* • Multi-modal NOT supported.
* • Browser CORS — works because Anthropic explicitly allows the
* dangerous-direct header. Future API changes could require a proxy.
*/
import { asContextWindowExceeded } from '../../provider-contract/contextWindow.js';
import { abortErrorOf, bodyChunks, throwIfAborted, untilAborted } from '../../lib/abort.js';
import { ANTHROPIC_PROMPT_CACHING } from './anthropicCacheWire.js';
import { thinkingModeWith } from './anthropicThinkingWire.js';
import { anthropicModelFacts, anthropicTakesForcedToolChoice, anthropicTakesNativeOutputSchema, } from './anthropicModels.js';
import { prepareAnthropicReplay, thinkingReplayBinding } from './anthropicThinkingReplay.js';
import { anthropicThinkingHandler } from '../../thinking/AnthropicThinkingHandler.js';
import { assembleAnthropicStream, buildMessagesBody, fromAnthropicResponse, } from './anthropicMessagesWire.js';
import { toolManifestOf } from './wireManifest.js';
const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages';
const ANTHROPIC_API_VERSION = '2023-06-01';
/**
* Which roles this wire carries inside `messages`.
*
* `'system'` is ABSENT and that is the wire's own rule, not a policy choice:
* `toAnthropicMessages` drops a `role: 'system'` message because Anthropic
* takes the system prompt as a separate top-level field. Declaring the truth
* here is what lets a `slot: 'messages'` injection be refused at run start
* rather than silently vanish between the recording and the request.
*/
const CARRIES_IN_MESSAGES = Object.freeze(['user', 'assistant']);
export function browserAnthropic(options) {
const apiKey = options.apiKey;
if (!apiKey) {
throw new Error('BrowserAnthropicProvider requires `apiKey`. Browser providers do not read environment variables.');
}
const apiUrl = options.apiUrl ?? ANTHROPIC_API_URL;
const defaultModel = options.defaultModel ?? 'claude-sonnet-4-5-20250929';
const defaultMaxTokens = options.defaultMaxTokens ?? 4096;
const parallelToolCalls = options.parallelToolCalls;
const fetchImpl = options._fetch ?? fetch;
/** The ONE mode function: declared below and used for every body. */
const modeOf = thinkingModeWith(options.thinkingMode);
const headers = {
'content-type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': ANTHROPIC_API_VERSION,
'anthropic-dangerous-direct-browser-access': 'true',
};
const provider = {
name: 'browser-anthropic',
carriesInMessages: CARRIES_IN_MESSAGES,
carriesForcedToolChoice: true,
supportsForcedToolChoice: (model) => anthropicTakesForcedToolChoice(modelOf(model, defaultModel)),
// `output_config.format` — the `'native'` output strategy — per model, from
// the same table (anthropicModels.ts) the body is built with.
carriesNativeOutputSchema: true,
supportsNativeOutputSchema: (model) => anthropicTakesNativeOutputSchema(modelOf(model, defaultModel)),
supportsAutoNativeOutputSchema: (model) => anthropicModelFacts(modelOf(model, defaultModel))?.nativeOutputSchema === true,
// The same body `anthropic()` builds — `buildMessagesBody` applies the
// markers — so the same declaration (see anthropicCacheWire.ts).
promptCaching: ANTHROPIC_PROMPT_CACHING,
thinkingHandler: anthropicThinkingHandler,
// …and the same per-model thinking shapes (anthropicThinkingWire.ts).
thinkingMode: (model) => modeOf(modelOf(model, defaultModel)),
async complete(req, hooks) {
// A cancelled call sends nothing (lib/abort.ts, law 1).
throwIfAborted(req.signal);
const preparation = prepareAnthropicReplay(req, modelOf(req.model, defaultModel), 'browser-anthropic');
const body = {
...buildBody(preparation.request, defaultModel, defaultMaxTokens, modeOf, parallelToolCalls),
};
const bindingFactory = thinkingReplayBinding(body, body.model);
const serializedBody = JSON.stringify(body);
if (preparation.omissions.length > 0)
hooks?.onReplayPrepared?.(preparation);
let response;
try {
// The signal rides the fetch, so an abort cancels the request itself;
// every wait after it (headers, body) is raced against it as well, so
// an answer that lands after the abort is refused (lib/abort.ts).
response = await untilAborted(fetchImpl(apiUrl, {
method: 'POST',
headers,
body: serializedBody,
...(req.signal && { signal: req.signal }),
}), req.signal);
}
catch (err) {
throw wrapError(err, req.signal);
}
if (!response.ok)
throw await untilAborted(wrapStatus(response), req.signal);
const json = (await untilAborted(response.json(), req.signal));
// Manifest read from the FINAL body — the very object JSON.stringify
// sent — after every transform (wireManifest.ts).
return {
...fromAnthropicResponse(json, json.id ? bindingFactory(json.id) : undefined),
wireManifest: toolManifestOf(body.tools, body.output_config),
...(preparation.omissions.length > 0 && { replayOmissions: preparation.omissions }),
};
},
async *stream(req, hooks) {
throwIfAborted(req.signal);
const preparation = prepareAnthropicReplay(req, modelOf(req.model, defaultModel), 'browser-anthropic');
const body = {
...buildBody(preparation.request, defaultModel, defaultMaxTokens, modeOf, parallelToolCalls),
stream: true,
};
const bindingFactory = thinkingReplayBinding(body, body.model);
const serializedBody = JSON.stringify(body);
if (preparation.omissions.length > 0)
hooks?.onReplayPrepared?.(preparation);
let response;
try {
response = await untilAborted(fetchImpl(apiUrl, {
method: 'POST',
headers,
body: serializedBody,
...(req.signal && { signal: req.signal }),
}), req.signal);
}
catch (err) {
throw wrapError(err, req.signal);
}
if (!response.ok)
throw await untilAborted(wrapStatus(response), req.signal);
if (!response.body)
throw new Error('[browser-anthropic] response has no body');
// Malformed tool-argument JSON becomes `{}` here — this adapter's
// behaviour since it shipped (the gateway adapter refuses instead).
// The signal rides INTO the parser: each read is raced against it, and
// the body is cancelled on the way out (lib/abort.ts · bodyChunks).
yield* assembleAnthropicStream(parseSSE(response.body, req.signal), {
bindingFactory,
replayOmissions: preparation.omissions,
wireManifest: toolManifestOf(body.tools, body.output_config),
onMalformedToolArgs: () => ({}),
});
},
};
return provider;
}
export class BrowserAnthropicProvider {
name = 'browser-anthropic';
carriesInMessages = CARRIES_IN_MESSAGES;
carriesForcedToolChoice = true;
/** The inner declaration resolves this adapter's shorthand before looking up the model. */
supportsForcedToolChoice;
carriesNativeOutputSchema = true;
/** The inner declaration resolves this adapter's shorthand before looking up the model. */
supportsNativeOutputSchema;
supportsAutoNativeOutputSchema;
promptCaching = ANTHROPIC_PROMPT_CACHING;
thinkingHandler = anthropicThinkingHandler;
/** The inner provider's own function — a closure, safe to forward unbound. */
thinkingMode;
inner;
constructor(options) {
this.inner = browserAnthropic(options);
this.thinkingMode = this.inner.thinkingMode;
this.supportsForcedToolChoice = this.inner.supportsForcedToolChoice;
this.supportsNativeOutputSchema = this.inner.supportsNativeOutputSchema;
this.supportsAutoNativeOutputSchema =
this.inner.supportsAutoNativeOutputSchema ?? (() => false);
}
// `hooks` is FORWARDED, not dropped — see LLMCallHooks in provider-contract/types.ts.
complete(req, hooks) {
return this.inner.complete(req, hooks);
}
stream(req, hooks) {
if (!this.inner.stream)
throw new Error('stream() unavailable');
return this.inner.stream(req, hooks);
}
}
// ─── Internals ──────────────────────────────────────────────────────
/** The model a request goes to — either shorthand is the default model. */
function modelOf(model, defaultModel) {
return model === 'anthropic' || model === 'browser-anthropic' ? defaultModel : model;
}
function buildBody(req, defaultModel, defaultMaxTokens, thinkingMode, parallelToolCalls) {
const model = modelOf(req.model, defaultModel);
// `model` first, then the shared Messages body (anthropicMessagesWire.ts).
return {
model,
...buildMessagesBody(req, {
model,
thinkingMode,
provider: 'browser-anthropic',
maxTokensDefault: defaultMaxTokens,
...(parallelToolCalls !== undefined && { parallelToolCalls }),
nativeOutputSchema: anthropicTakesNativeOutputSchema,
}),
};
}
/**
* Parse Anthropic's SSE event stream from a fetch ReadableStream — read under
* `signal` through `bodyChunks`, which races every read against it and
* CANCELS the body on every way out (the end, an early stop, an error, an
* abort), so a stream nobody reads never holds its socket open.
*/
async function* parseSSE(body, signal) {
const decoder = new TextDecoder();
let buf = '';
for await (const value of bodyChunks(body, signal)) {
buf += decoder.decode(value, { stream: true });
let idx;
// Events are separated by \n\n.
while ((idx = buf.indexOf('\n\n')) >= 0) {
throwIfAborted(signal);
const raw = buf.slice(0, idx);
buf = buf.slice(idx + 2);
let event = 'message';
const dataLines = [];
for (const line of raw.split('\n')) {
if (line.startsWith('event:'))
event = line.slice(6).trim();
else if (line.startsWith('data:'))
dataLines.push(line.slice(5).trim());
}
// Belt-and-suspenders: a malformed SSE chunk should not throw out of
// the async generator and tear down the whole stream. v1 wrapped
// this; an upstream proxy or a partial flush could in principle
// produce a non-JSON `data:` line.
let data = {};
if (dataLines.length > 0) {
try {
data = JSON.parse(dataLines.join('\n'));
}
catch {
data = {};
}
}
yield { event, data };
}
}
}
async function wrapStatus(response) {
let bodyText = '';
try {
bodyText = await response.text();
}
catch {
/* ignore */
}
// Same as the browser OpenAI adapter: an HTTP refusal never reaches
// `wrapError`, and the body is where "prompt is too long" is written.
const tooBig = asContextWindowExceeded(new Error(bodyText), {
provider: 'browser-anthropic',
bodyText,
status: response.status,
});
if (tooBig)
return tooBig;
return Object.assign(new Error(`[browser-anthropic] ${response.status} ${response.statusText} — ${bodyText.slice(0, 200)}`), {
name: 'BrowserAnthropicProviderError',
status: response.status,
});
}
function wrapError(err, signal) {
// A cancelled call fails AS the cancellation, never as a provider error that
// a retry would re-send (lib/abort.ts).
if (signal?.aborted)
return abortErrorOf(signal);
const tooBig = asContextWindowExceeded(err, { provider: 'browser-anthropic' });
if (tooBig)
return tooBig;
if (err instanceof Error) {
return Object.assign(new Error(`[browser-anthropic] ${err.message}`), {
name: 'BrowserAnthropicProviderError',
cause: err,
});
}
return new Error(`[browser-anthropic] ${String(err)}`);
}
//# sourceMappingURL=BrowserAnthropicProvider.js.map