SPB Git
7.7 KB · 126 lines typescript
1import Anthropic from "@anthropic-ai/sdk";2import { env } from "../env";3import type { ChatRequest, ProviderAdapter, StreamEvent } from "./types";45let client: Anthropic | null = null;6export function anthropicClient(): Anthropic {7  if (!client) client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY, timeout: 15 * 60 * 1000, maxRetries: 2 });8  return client;9}1011/** Adaptive thinking is accepted on 4.6+ families; older snapshots reject it. */12const supportsAdaptive = (id: string) => /-4-6|-4-7|-4-8|opus-5|sonnet-5|haiku-5|fable/.test(id);13const supportsEffort = (id: string) => supportsAdaptive(id);14/** Forced tool_choice is rejected on these; we always use auto anyway. */15const fallbackEligible = (id: string) => /fable-5-1|opus-5-5|opus-5$|sonnet-5-5/.test(id);16const temperatureAllowed = (id: string) => /-4-5-|haiku-4-5/.test(id);1718async function toMessages(req: ChatRequest): Promise<Anthropic.MessageParam[]> {19  const out: Anthropic.MessageParam[] = [];20  for (const m of req.messages) {21    if (m.role === "system") continue; // folded into system prompt by the engine22    if (m.role === "assistant") {23      const content: Anthropic.ContentBlockParam[] = [];24      const text = m.parts.filter(p => p.type === "text").map(p => (p as { text: string }).text).join("");25      if (text) content.push({ type: "text", text });26      const results: Anthropic.ToolResultBlockParam[] = [];27      for (const p of m.parts) if (p.type === "tool_call") {28        content.push({ type: "tool_use", id: p.id, name: p.name, input: p.input ?? {} });29        if (p.status === "ok" || p.status === "error" || p.status === "denied")30          results.push({ type: "tool_result", tool_use_id: p.id, is_error: p.status !== "ok", content: typeof p.output === "string" ? p.output : JSON.stringify(p.status === "ok" ? p.output ?? null : { error: p.error ?? p.status }) });31      }32      if (content.length) out.push({ role: "assistant", content });33      if (results.length) out.push({ role: "user", content: results });34      continue;35    }36    const content: Anthropic.ContentBlockParam[] = [];37    for (const p of m.parts) {38      if (p.type === "image") {39        const a = await req.loadAttachment(p.attachmentId);40        if (a) content.push({ type: "image", source: { type: "base64", media_type: a.mime as "image/png", data: a.data.toString("base64") } });41      } else if (p.type === "file") {42        const a = await req.loadAttachment(p.attachmentId);43        if (!a) continue;44        if (a.mime === "application/pdf") content.push({ type: "document", source: { type: "base64", media_type: "application/pdf", data: a.data.toString("base64") }, title: a.name, citations: { enabled: true } } as Anthropic.DocumentBlockParam);45        else if (a.text) content.push({ type: "document", source: { type: "text", media_type: "text/plain", data: a.text }, title: a.name } as Anthropic.DocumentBlockParam);46      }47    }48    for (const p of m.parts) if (p.type === "text" && p.text) content.push({ type: "text", text: p.text });49    if (content.length) {50      // merge consecutive user turns (tool results followed by a new user message are fine as separate turns)51      const last = out[out.length - 1];52      if (last && last.role === "user" && Array.isArray(last.content) && !(last.content as Anthropic.ContentBlockParam[]).some(c => c.type === "tool_result")) (last.content as Anthropic.ContentBlockParam[]).push(...content);53      else out.push({ role: "user", content });54    }55  }56  return out;57}5859export const anthropicAdapter: ProviderAdapter = {60  provider: "anthropic",61  async *stream(req: ChatRequest): AsyncGenerator<StreamEvent> {62    const id = req.model.id;63    const s = req.settings ?? {};64    const maxTokens = Math.min(s.maxOutputTokens ?? 32_000, req.model.maxOutputTokens ?? 64_000);65    const body: Anthropic.MessageStreamParams & Record<string, unknown> = {66      model: id,67      max_tokens: maxTokens,68      messages: await toMessages(req),69    };70    if (req.system) body.system = [{ type: "text", text: req.system, cache_control: { type: "ephemeral" } }];71    if (supportsAdaptive(id)) body.thinking = { type: "adaptive", display: s.reasoningSummary === false ? "omitted" : "summarized" } as Anthropic.ThinkingConfigParam;72    if (s.effort && supportsEffort(id)) body.output_config = { effort: s.effort };73    if (s.temperature != null && temperatureAllowed(id)) body.temperature = s.temperature;74    if (req.tools?.length) body.tools = req.tools.map(t => ({ name: t.name, description: t.description, input_schema: t.inputSchema as Anthropic.Tool["input_schema"] }));7576    let stream: ReturnType<Anthropic["messages"]["stream"]>;77    try {78      if (fallbackEligible(id)) {79        // server-side refusal fallback (beta): on a safety decline the API re-runs on a fallback model in the same call80        stream = (anthropicClient().beta.messages.stream as unknown as (p: Record<string, unknown>, o?: { signal?: AbortSignal }) => ReturnType<Anthropic["messages"]["stream"]>)({ ...body, betas: ["server-side-fallback-2026-07-01"], fallbacks: "default" }, { signal: req.signal });81      } else stream = anthropicClient().messages.stream(body, { signal: req.signal });82    } catch (e) { yield errorEvent(e); return; }8384    const tools = new Map<number, { id: string; name: string; json: string }>();85    let sawTool = false;86    try {87      for await (const ev of stream) {88        switch (ev.type) {89          case "content_block_start":90            if (ev.content_block.type === "tool_use") tools.set(ev.index, { id: ev.content_block.id, name: ev.content_block.name, json: "" });91            break;92          case "content_block_delta":93            if (ev.delta.type === "text_delta") yield { type: "text", delta: ev.delta.text };94            else if (ev.delta.type === "thinking_delta") { if (ev.delta.thinking) yield { type: "reasoning", delta: ev.delta.thinking }; }95            else if (ev.delta.type === "input_json_delta") { const t = tools.get(ev.index); if (t) t.json += ev.delta.partial_json; }96            break;97          case "content_block_stop": {98            const t = tools.get(ev.index);99            if (t) { sawTool = true; let input: unknown = {}; try { input = JSON.parse(t.json || "{}"); } catch { input = { _raw: t.json }; } yield { type: "tool_call", id: t.id, name: t.name, input }; tools.delete(ev.index); }100            break;101          }102          case "message_delta": {103            const u = ev.usage as Anthropic.MessageDeltaUsage;104            yield { type: "usage", usage: { input: (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0), output: u.output_tokens ?? 0, cached: u.cache_read_input_tokens ?? 0 } };105            if (ev.delta.stop_reason) yield { type: "stop", reason: ev.delta.stop_reason === "tool_use" || sawTool ? "tool_use" : ev.delta.stop_reason, details: (ev.delta as { stop_details?: unknown }).stop_details };106            break;107          }108        }109      }110      const final = await stream.finalMessage();111      if ((final as { model?: string }).model && final.model !== id) yield { type: "meta", data: { servedBy: final.model } };112    } catch (e) { yield errorEvent(e); }113  },114};115116function errorEvent(e: unknown): StreamEvent {117  if (e instanceof Anthropic.APIError) return { type: "error", message: e.message, status: e.status, retryable: e.status === 429 || e.status === 529 || (e.status ?? 0) >= 500 };118  if ((e as Error)?.name === "AbortError" || /aborted/i.test(String((e as Error)?.message))) return { type: "stop", reason: "cancelled" };119  return { type: "error", message: String((e as Error)?.message ?? e) };120}121122export async function anthropicCountTokens(model: string, system: string | undefined, messages: Anthropic.MessageParam[]): Promise<number> {123  const r = await anthropicClient().messages.countTokens({ model, messages, ...(system ? { system } : {}) });124  return r.input_tokens;125}126
Files · main
Menu

Theme