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:
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." };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in new issue
Block a user