feat(ai): Anthropic provider over fetch with streaming tool use

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 03:15:33 +01:00
1 parent bccbc692e9
commit 3c2220145b
4 files changed
+352

No files matched your search

@@ -0,0 +1,139 @@
import type { AiProvider, ChatMessage, ToolSpec } from "@stc/contracts";
import type { Effort } from "../settings";
import { errorMessage, readSse } from "./sse";
export const DEFAULT_ANTHROPIC_MODEL = "claude-opus-5-5";
const FIRST_PARTY = "https://api.anthropic.com";
export interface AnthropicOptions {
apiKey: string;
model?: string;
baseUrl?: string;
effort?: Effort;
maxTokens?: number;
fetch?: typeof fetch;
}
type Block = Record<string, unknown> & { type: string };
type ApiMessage = { role: "user" | "assistant"; content: Block[] };
/**
* Anthropic Messages API over plain fetch with streaming and tool use.
* - Thinking is left to the model default (adaptive on current models); no temperature or forced tool_choice.
* - The frozen ChatMessage contract has no slot for thinking blocks, so the provider remembers every
* response's raw content blocks by tool_use id and replays them unchanged when the caller sends back
* the matching assistant tool-call message (required for thinking + tool use).
* - On the first-party endpoint, server-side refusal fallbacks are on ("default" routing).
*/
export function createAnthropicProvider(o: AnthropicOptions): AiProvider {
const doFetch = o.fetch ?? fetch;
const base = (o.baseUrl || FIRST_PARTY).replace(/\/+$/, "");
const firstParty = base === FIRST_PARTY;
const rawByToolId = new Map<string, Block[]>();
const toApi = (messages: ChatMessage[]): { system: string; messages: ApiMessage[] } => {
const system = messages.filter((m) => m.role === "system").map((m) => m.content).join("\n\n");
const out: ApiMessage[] = [];
const push = (role: "user" | "assistant", block: Block) => {
const last = out[out.length - 1];
if (last && last.role === role) last.content.push(block);
else out.push({ role, content: [block] });
};
for (const m of messages) {
if (m.role === "system") continue;
if (m.role === "tool") push("user", { type: "tool_result", tool_use_id: m.toolCallId ?? "", content: m.content });
else if (m.role === "assistant" && m.toolCallId) {
let input: unknown = {};
try { input = JSON.parse(m.content || "{}"); } catch { input = { value: m.content }; }
push("assistant", { type: "tool_use", id: m.toolCallId, name: m.name ?? "", input });
} else if (m.content) push(m.role === "assistant" ? "assistant" : "user", { type: "text", text: m.content });
}
// Replace assistant turns we produced with their exact original blocks (thinking signatures intact).
for (const msg of out) {
if (msg.role !== "assistant") continue;
const id = msg.content.find((b) => b.type === "tool_use")?.id as string | undefined;
const raw = id ? rawByToolId.get(id) : undefined;
if (raw) msg.content = structuredClone(raw);
}
return { system, messages: out };
};
return {
id: "anthropic",
async *chat(messages: ChatMessage[], opts: { tools?: ToolSpec[]; signal?: AbortSignal }) {
const { system, messages: apiMessages } = toApi(messages);
const body: Record<string, unknown> = {
model: o.model || DEFAULT_ANTHROPIC_MODEL,
max_tokens: o.maxTokens ?? 64000,
stream: true,
messages: apiMessages,
};
if (system) body.system = system;
if (opts.tools?.length) body.tools = opts.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.inputSchema }));
if (o.effort) body.output_config = { effort: o.effort };
const headers: Record<string, string> = {
"content-type": "application/json",
"x-api-key": o.apiKey,
"anthropic-version": "2023-06-01",
};
if (firstParty) {
body.fallbacks = "default";
headers["anthropic-beta"] = "server-side-fallback-2026-07-01";
}
const res = await doFetch(`${base}/v1/messages`, { method: "POST", headers, body: JSON.stringify(body), signal: opts.signal });
if (!res.ok || !res.body) throw new Error(`Anthropic API error ${await errorMessage(res)}`);
const blocks: Block[] = [];
const partialJson = new Map<number, string>();
const toolIds: string[] = [];
let stopReason: string | null = null;
for await (const { data } of readSse(res.body)) {
let ev: any;
try { ev = JSON.parse(data); } catch { continue; }
switch (ev.type) {
case "content_block_start": {
const b = { ...ev.content_block } as Block;
blocks[ev.index] = b;
if (b.type === "tool_use") partialJson.set(ev.index, "");
break;
}
case "content_block_delta": {
const b = blocks[ev.index];
const d = ev.delta ?? {};
if (!b) break;
if (d.type === "text_delta") {
b.text = String(b.text ?? "") + d.text;
yield { text: d.text as string };
} else if (d.type === "input_json_delta") partialJson.set(ev.index, (partialJson.get(ev.index) ?? "") + d.partial_json);
else if (d.type === "thinking_delta") b.thinking = String(b.thinking ?? "") + d.thinking;
else if (d.type === "signature_delta") b.signature = String(b.signature ?? "") + d.signature;
else if (d.type === "citations_delta") b.citations = [...((b.citations as unknown[]) ?? []), d.citation];
break;
}
case "content_block_stop": {
const b = blocks[ev.index];
if (b?.type === "tool_use") {
const json = partialJson.get(ev.index) ?? "";
let input: unknown = {};
try { input = json ? JSON.parse(json) : (b.input ?? {}); } catch { input = { _invalidJson: json }; }
b.input = input;
toolIds.push(b.id as string);
yield { toolCall: { id: b.id as string, name: b.name as string, input } };
}
break;
}
case "message_delta":
if (ev.delta?.stop_reason) stopReason = ev.delta.stop_reason;
break;
case "error":
throw new Error(`Anthropic API error: ${ev.error?.message ?? "stream error"}`);
}
}
const content = blocks.filter(Boolean);
for (const id of toolIds) rawByToolId.set(id, content);
if (stopReason === "refusal" && !content.some((b) => b.type === "text" && b.text)) {
yield { text: "The model declined to answer this request." };
}
},
};
}
+44
View File
@@ -0,0 +1,44 @@
/** Minimal Server-Sent Events reader: yields one {event, data} per blank-line-terminated record. */
export async function* readSse(body: ReadableStream<Uint8Array>): AsyncIterable<{ event: string; data: string }> {
const decoder = new TextDecoder();
let buf = "";
let event = "message";
let data: string[] = [];
const flushLine = function* (line: string): Generator<{ event: string; data: string }> {
if (line === "") {
if (data.length) yield { event, data: data.join("\n") };
event = "message";
data = [];
return;
}
if (line.startsWith(":")) return;
const i = line.indexOf(":");
const field = i === -1 ? line : line.slice(0, i);
let value = i === -1 ? "" : line.slice(i + 1);
if (value.startsWith(" ")) value = value.slice(1);
if (field === "event") event = value;
else if (field === "data") data.push(value);
};
for await (const chunk of body as unknown as AsyncIterable<Uint8Array>) {
buf += decoder.decode(chunk, { stream: true });
let nl: number;
while ((nl = buf.search(/\r\n|\n|\r/)) !== -1) {
const line = buf.slice(0, nl);
buf = buf.slice(nl + (buf.startsWith("\r\n", nl) ? 2 : 1));
yield* flushLine(line);
}
}
buf += decoder.decode();
if (buf) yield* flushLine(buf);
yield* flushLine("");
}
export async function errorMessage(res: Response): Promise<string> {
const text = await res.text().catch(() => "");
try {
const j = JSON.parse(text);
const m = j?.error?.message ?? j?.message ?? j?.error;
if (typeof m === "string") return `${res.status}: ${m}`;
} catch {}
return `${res.status}: ${text.slice(0, 300) || res.statusText}`;
}