agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
292 lines • 14.6 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.BrowserAnthropicProvider = exports.browserAnthropic = void 0;
const contextWindow_js_1 = require("../../provider-contract/contextWindow.js");
const abort_js_1 = require("../../lib/abort.js");
const anthropicCacheWire_js_1 = require("./anthropicCacheWire.js");
const anthropicThinkingWire_js_1 = require("./anthropicThinkingWire.js");
const anthropicModels_js_1 = require("./anthropicModels.js");
const anthropicThinkingReplay_js_1 = require("./anthropicThinkingReplay.js");
const AnthropicThinkingHandler_js_1 = require("../../thinking/AnthropicThinkingHandler.js");
const anthropicMessagesWire_js_1 = require("./anthropicMessagesWire.js");
const wireManifest_js_1 = require("./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']);
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 = (0, anthropicThinkingWire_js_1.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) => (0, anthropicModels_js_1.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) => (0, anthropicModels_js_1.anthropicTakesNativeOutputSchema)(modelOf(model, defaultModel)),
supportsAutoNativeOutputSchema: (model) => (0, anthropicModels_js_1.anthropicModelFacts)(modelOf(model, defaultModel))?.nativeOutputSchema === true,
// The same body `anthropic()` builds — `buildMessagesBody` applies the
// markers — so the same declaration (see anthropicCacheWire.ts).
promptCaching: anthropicCacheWire_js_1.ANTHROPIC_PROMPT_CACHING,
thinkingHandler: AnthropicThinkingHandler_js_1.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).
(0, abort_js_1.throwIfAborted)(req.signal);
const preparation = (0, anthropicThinkingReplay_js_1.prepareAnthropicReplay)(req, modelOf(req.model, defaultModel), 'browser-anthropic');
const body = {
...buildBody(preparation.request, defaultModel, defaultMaxTokens, modeOf, parallelToolCalls),
};
const bindingFactory = (0, anthropicThinkingReplay_js_1.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 (0, abort_js_1.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 (0, abort_js_1.untilAborted)(wrapStatus(response), req.signal);
const json = (await (0, abort_js_1.untilAborted)(response.json(), req.signal));
// Manifest read from the FINAL body — the very object JSON.stringify
// sent — after every transform (wireManifest.ts).
return {
...(0, anthropicMessagesWire_js_1.fromAnthropicResponse)(json, json.id ? bindingFactory(json.id) : undefined),
wireManifest: (0, wireManifest_js_1.toolManifestOf)(body.tools, body.output_config),
...(preparation.omissions.length > 0 && { replayOmissions: preparation.omissions }),
};
},
async *stream(req, hooks) {
(0, abort_js_1.throwIfAborted)(req.signal);
const preparation = (0, anthropicThinkingReplay_js_1.prepareAnthropicReplay)(req, modelOf(req.model, defaultModel), 'browser-anthropic');
const body = {
...buildBody(preparation.request, defaultModel, defaultMaxTokens, modeOf, parallelToolCalls),
stream: true,
};
const bindingFactory = (0, anthropicThinkingReplay_js_1.thinkingReplayBinding)(body, body.model);
const serializedBody = JSON.stringify(body);
if (preparation.omissions.length > 0)
hooks?.onReplayPrepared?.(preparation);
let response;
try {
response = await (0, abort_js_1.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 (0, abort_js_1.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* (0, anthropicMessagesWire_js_1.assembleAnthropicStream)(parseSSE(response.body, req.signal), {
bindingFactory,
replayOmissions: preparation.omissions,
wireManifest: (0, wireManifest_js_1.toolManifestOf)(body.tools, body.output_config),
onMalformedToolArgs: () => ({}),
});
},
};
return provider;
}
exports.browserAnthropic = browserAnthropic;
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 = anthropicCacheWire_js_1.ANTHROPIC_PROMPT_CACHING;
thinkingHandler = AnthropicThinkingHandler_js_1.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);
}
}
exports.BrowserAnthropicProvider = BrowserAnthropicProvider;
// ─── 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,
...(0, anthropicMessagesWire_js_1.buildMessagesBody)(req, {
model,
thinkingMode,
provider: 'browser-anthropic',
maxTokensDefault: defaultMaxTokens,
...(parallelToolCalls !== undefined && { parallelToolCalls }),
nativeOutputSchema: anthropicModels_js_1.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 (0, abort_js_1.bodyChunks)(body, signal)) {
buf += decoder.decode(value, { stream: true });
let idx;
// Events are separated by \n\n.
while ((idx = buf.indexOf('\n\n')) >= 0) {
(0, abort_js_1.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 = (0, contextWindow_js_1.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 (0, abort_js_1.abortErrorOf)(signal);
const tooBig = (0, contextWindow_js_1.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