140 lines
6.3 KiB
TypeScript
140 lines
6.3 KiB
TypeScript
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." };
|
|
}
|
|
},
|
|
};
|
|
}
|