SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
21.5 KB · 458 lines typescript
Raw Blame History
1import Anthropic from "@anthropic-ai/sdk";2import type { MessageParam, ContentBlockParam, MessageCreateParamsStreaming, ToolUnion, ToolChoice, ThinkingConfigParam, RawMessageStreamEvent, OutputConfig } from "@anthropic-ai/sdk/resources/messages";3import type { ModelInfo } from "@anthropic-ai/sdk/resources/models";4import {5  type AIProviderAdapter,6  type PolyModel,7  type UnifiedChatRequest,8  type UnifiedChatResponse,9  type UnifiedStreamEvent,10  type ValidationResult,11  type TokenEstimate,12  type UnifiedMessage,13  type FinishReason,14  PolyProviderError,15  modelKey,16} from "@/lib/ai/core/types";17import { normalizeGenericError, refineByMessage, codeFromStatus, isRetryableStatus, parseRetryAfter } from "@/lib/ai/core/errors";18import { filterSettings, heuristicTokens } from "@/lib/ai/core/normalize";19import { collectStream, safeJson } from "@/lib/ai/core/stream-utils";20import { isTextLike, inlineTextFile } from "@/lib/ai/core/content";21import { anthropicPricingFor, ANTHROPIC_DEPRECATED, anthropicSortWeight, anthropicFamily } from "./catalog";22import { log } from "@/lib/log";2324const DEFAULT_TIMEOUT_MS = 10 * 60_000;2526function client(apiKey: string, timeoutMs = DEFAULT_TIMEOUT_MS) {27  return new Anthropic({ apiKey, maxRetries: 1, timeout: timeoutMs });28}2930// ---------------------------------------------------------------------------31// Model normalization (Models API → PolyModel)32// ---------------------------------------------------------------------------33type ModelCaps = NonNullable<ModelInfo["capabilities"]> & {34  thinking?: { supported?: boolean; types?: { adaptive?: { supported?: boolean }; enabled?: { supported?: boolean } } };35  effort?: { supported?: boolean; low?: { supported?: boolean }; medium?: { supported?: boolean }; high?: { supported?: boolean }; xhigh?: { supported?: boolean }; max?: { supported?: boolean } };36  image_input?: { supported?: boolean };37  pdf_input?: { supported?: boolean };38  structured_outputs?: { supported?: boolean };39  code_execution?: { supported?: boolean };40  citations?: { supported?: boolean };41};4243export function normalizeAnthropicModel(m: ModelInfo): PolyModel {44  const caps = (m.capabilities ?? {}) as ModelCaps;45  const adaptive = caps.thinking?.types?.adaptive?.supported === true;46  const enabledBudget = caps.thinking?.types?.enabled?.supported === true;47  const thinkingSupported = caps.thinking?.supported === true;48  const effortLevels = (["low", "medium", "high", "xhigh", "max"] as const).filter((l) => caps.effort?.[l]?.supported);49  // Sampling parameters (temperature/top_p/top_k) were removed on Opus 4.7+, Sonnet 5 and Fable 5;50  // those generations are exactly the ones where `thinking.types.enabled` is no longer supported.51  const legacySampling = !thinkingSupported || enabledBudget;52  const isFableTier = /claude-(fable|mythos)-/.test(m.id);53  const isLegacy3x = /claude-3/.test(m.id);54  const status = ANTHROPIC_DEPRECATED.has(m.id) || isLegacy3x ? "deprecated" : "active";5556  return {57    key: modelKey("anthropic", m.id),58    id: m.id,59    provider: "anthropic",60    displayName: m.display_name,61    family: anthropicFamily(m.id),62    capabilities: {63      text: true,64      vision: caps.image_input?.supported ?? true,65      audioInput: false,66      audioOutput: false,67      imageGeneration: false,68      video: false,69      reasoning: thinkingSupported,70      tools: true,71      structuredOutput: caps.structured_outputs?.supported ?? false,72      streaming: true,73      files: caps.pdf_input?.supported ?? false,74      webSearch: !isLegacy3x,75    },76    limits: { contextTokens: m.max_input_tokens ?? undefined, maxOutputTokens: m.max_tokens ?? undefined },77    parameters: {78      temperature: legacySampling,79      topP: legacySampling,80      topK: legacySampling,81      maxTokens: true,82      stop: true,83      reasoningEffort: effortLevels.length > 0,84      reasoningEffortLevels: effortLevels.length > 0 ? [...(isFableTier ? [] : ["none"]), ...effortLevels] : undefined,85      thinkingBudget: enabledBudget && !adaptive,86      thinkingBudgetRange: enabledBudget && !adaptive ? { min: 1024, max: Math.max(1024, (m.max_tokens ?? 32000) - 1) } : undefined,87      temperatureRange: { min: 0, max: 1 },88    },89    status,90    pricing: anthropicPricingFor(m.id),91    metadata: {92      createdAt: m.created_at,93      adaptiveThinking: adaptive,94      alwaysThinking: isFableTier,95      noForcedToolChoice: /claude-(fable|mythos)-5-1/.test(m.id),96      codeExecution: caps.code_execution?.supported ?? false,97      citations: caps.citations?.supported ?? false,98      sortWeight: anthropicSortWeight(m.id),99      webSearchToolType: isLegacy3x || /claude-(opus-4-5|sonnet-4-5|haiku-4-5|opus-4-1|opus-4-2|sonnet-4-2)/.test(m.id) ? "web_search_20250305" : "web_search_20260209",100    },101  };102}103104// ---------------------------------------------------------------------------105// Request translation106// ---------------------------------------------------------------------------107function toAnthropicMessages(messages: UnifiedMessage[], modelId: string): MessageParam[] {108  const out: MessageParam[] = [];109  for (const m of messages) {110    if (m.role === "system") continue; // system handled top-level111    if (m.role === "tool") {112      const blocks: ContentBlockParam[] = m.content113        .filter((p) => p.type === "tool-result")114        .map((p) => ({115          type: "tool_result" as const,116          tool_use_id: p.type === "tool-result" ? p.toolCallId : "",117          content: typeof (p as { result: unknown }).result === "string" ? ((p as { result: string }).result as string) : JSON.stringify((p as { result: unknown }).result ?? null),118          is_error: (p as { isError?: boolean }).isError ?? false,119        }));120      if (blocks.length) pushMerged(out, { role: "user", content: blocks });121      continue;122    }123    const blocks: ContentBlockParam[] = [];124    for (const p of m.content) {125      switch (p.type) {126        case "text":127          if (p.text) blocks.push({ type: "text", text: p.text });128          break;129        case "image":130          blocks.push({ type: "image", source: { type: "base64", media_type: p.mimeType as "image/png" | "image/jpeg" | "image/webp" | "image/gif", data: p.data } });131          break;132        case "file":133          if (p.mimeType === "application/pdf") {134            blocks.push({ type: "document", source: { type: "base64", media_type: "application/pdf", data: p.data }, title: p.name });135          } else if (isTextLike(p.mimeType, p.name)) {136            blocks.push({ type: "document", source: { type: "text", media_type: "text/plain", data: Buffer.from(p.data, "base64").toString("utf8") }, title: p.name });137          } else {138            blocks.push({ type: "text", text: inlineTextFile(p.name, p.data) });139          }140          break;141        case "reasoning":142          // Replay thinking blocks only on the same model (other models ignore/reject them).143          if (m.role === "assistant" && p.signature && (m.providerData?.model === modelId || !m.providerData?.model)) {144            blocks.push({ type: "thinking", thinking: p.text, signature: p.signature });145          }146          break;147        case "tool-call":148          blocks.push({ type: "tool_use", id: p.id, name: p.name, input: p.arguments });149          break;150        case "tool-result":151          blocks.push({ type: "tool_result", tool_use_id: p.toolCallId, content: typeof p.result === "string" ? p.result : JSON.stringify(p.result ?? null), is_error: p.isError });152          break;153      }154    }155    if (blocks.length === 0) continue;156    // thinking blocks must precede text/tool_use in an assistant turn157    if (m.role === "assistant") blocks.sort((a, b) => rank(a) - rank(b));158    pushMerged(out, { role: m.role === "assistant" ? "assistant" : "user", content: blocks });159  }160  // Anthropic requires the conversation to start with a user turn.161  while (out.length && out[0].role !== "user") out.shift();162  return out;163}164165function rank(b: ContentBlockParam): number {166  return b.type === "thinking" || b.type === "redacted_thinking" ? 0 : b.type === "text" ? 1 : 2;167}168169/** Consecutive same-role turns are merged (Anthropic requires strict alternation). */170function pushMerged(list: MessageParam[], msg: MessageParam) {171  const last = list[list.length - 1];172  if (last && last.role === msg.role && Array.isArray(last.content) && Array.isArray(msg.content)) {173    last.content = [...last.content, ...msg.content];174  } else {175    list.push(msg);176  }177}178179function buildParams(req: UnifiedChatRequest): MessageCreateParamsStreaming {180  const { settings } = filterSettings(req.settings, req.modelInfo);181  const meta = (req.modelInfo?.metadata ?? {}) as Record<string, unknown>;182  const maxOut = req.modelInfo?.limits?.maxOutputTokens ?? 32_000;183  const params: MessageCreateParamsStreaming = {184    model: req.model,185    max_tokens: settings.maxTokens ?? Math.min(maxOut, 16_000),186    messages: toAnthropicMessages(req.messages, req.model),187    stream: true,188  };189  if (req.system?.trim()) params.system = req.system;190  if (settings.temperature !== undefined) params.temperature = settings.temperature;191  if (settings.topP !== undefined) params.top_p = settings.topP;192  if (settings.topK !== undefined) params.top_k = settings.topK;193  if (settings.stop?.length) params.stop_sequences = settings.stop.slice(0, 4);194195  // --- reasoning ---------------------------------------------------------196  // Without a capability sheet we cannot know which thinking mode the model accepts → send none.197  const reasoningSupported = req.modelInfo ? req.modelInfo.capabilities.reasoning : false;198  const adaptive = meta.adaptiveThinking === true;199  const alwaysThinking = meta.alwaysThinking === true;200  const effort = settings.reasoningEffort;201  const display = settings.includeReasoning === false ? "omitted" : "summarized";202  const outputConfig: OutputConfig = {};203  if (reasoningSupported) {204    if (effort === "none" && !alwaysThinking) {205      // Opus 5 accepts {type:"disabled"} only at effort ≤ high; Opus 4.7/4.8/Sonnet 5 accept it always.206      params.thinking = { type: "disabled" } as ThinkingConfigParam;207    } else if (adaptive) {208      params.thinking = { type: "adaptive", display } as ThinkingConfigParam;209      if (effort && effort !== "none" && effort !== "minimal") outputConfig.effort = effort as OutputConfig["effort"];210      else if (effort === "minimal") outputConfig.effort = "low";211    } else if (settings.thinkingBudget) {212      // Haiku 4.5 / older: enabled + budget_tokens (< max_tokens, ≥ 1024)213      const budget = Math.max(1024, Math.min(settings.thinkingBudget, params.max_tokens - 1));214      if (params.max_tokens <= budget) params.max_tokens = budget + 1024;215      params.thinking = { type: "enabled", budget_tokens: budget } as ThinkingConfigParam;216    }217  }218219  // --- structured output ---------------------------------------------------220  if (settings.responseFormat?.type === "json_schema" && settings.responseFormat.schema) {221    outputConfig.format = { type: "json_schema", schema: settings.responseFormat.schema };222  } else if (settings.responseFormat?.type === "json") {223    outputConfig.format = { type: "json_schema", schema: { type: "object", additionalProperties: true } };224  }225  if (Object.keys(outputConfig).length) params.output_config = outputConfig;226227  // --- tools ---------------------------------------------------------------228  const tools: ToolUnion[] = [];229  for (const t of req.tools ?? []) {230    tools.push({231      name: t.name,232      description: t.description,233      input_schema: { type: "object", ...(t.parameters as Record<string, unknown>) } as ToolUnion extends infer U ? (U extends { input_schema: infer S } ? S : never) : never,234      ...(t.strict ? { strict: true } : {}),235    } as ToolUnion);236  }237  if (settings.webSearch) {238    const type = (meta.webSearchToolType as string) ?? "web_search_20260209";239    tools.push({ type, name: "web_search", max_uses: 5 } as unknown as ToolUnion);240  } else if (settings.codeExecution && meta.codeExecution) {241    tools.push({ type: "code_execution_20260521", name: "code_execution" } as unknown as ToolUnion);242  }243  if (tools.length) {244    params.tools = tools;245    const tc = settings.toolChoice;246    const noForced = meta.noForcedToolChoice === true;247    if (tc === "none") params.tool_choice = { type: "none" } as ToolChoice;248    else if (tc === "required" && !noForced) params.tool_choice = { type: "any" } as ToolChoice;249    else if (tc && typeof tc === "object" && !noForced) params.tool_choice = { type: "tool", name: tc.name } as ToolChoice;250  }251  return params;252}253254function mapStop(reason: string | null | undefined): FinishReason {255  switch (reason) {256    case "end_turn":257    case "stop_sequence":258    case "pause_turn":259      return "stop";260    case "max_tokens":261    case "model_context_window_exceeded":262      return "length";263    case "tool_use":264      return "tool-calls";265    case "refusal":266      return "content-filter";267    default:268      return "other";269  }270}271272// ---------------------------------------------------------------------------273// Adapter274// ---------------------------------------------------------------------------275export const anthropicAdapter: AIProviderAdapter = {276  id: "anthropic",277  name: "Anthropic",278  keyDocsUrl: "https://platform.claude.com/settings/keys",279  keyPrefixHint: "sk-ant-",280281  async validateApiKey(apiKey, signal): Promise<ValidationResult> {282    const t0 = Date.now();283    try {284      const page = await client(apiKey, 20_000).models.list({ limit: 100 }, { signal });285      return { ok: true, modelsAvailable: page.data.length, latencyMs: Date.now() - t0 };286    } catch (e) {287      const err = this.normalizeError(e);288      return { ok: false, error: err.toJSON(), latencyMs: Date.now() - t0 };289    }290  },291292  async listModels(apiKey, signal): Promise<PolyModel[]> {293    try {294      const out: PolyModel[] = [];295      for await (const m of client(apiKey, 30_000).models.list({ limit: 100 }, { signal })) {296        if (!m.id.startsWith("claude-")) continue;297        out.push(normalizeAnthropicModel(m));298      }299      return out;300    } catch (e) {301      throw this.normalizeError(e);302    }303  },304305  async chat(req): Promise<UnifiedChatResponse> {306    return collectStream("anthropic", req.model, this.streamChat(req));307  },308309  async *streamChat(req: UnifiedChatRequest): AsyncIterable<UnifiedStreamEvent> {310    const params = buildParams(req);311    if (process.env.POLYLLM_DEBUG_PROVIDER === "1") log.debug("anthropic request", { model: params.model, max_tokens: params.max_tokens, thinking: params.thinking, output_config: params.output_config, tools: params.tools?.length, temperature: params.temperature });312    let stream: AsyncIterable<RawMessageStreamEvent>;313    try {314      stream = await client(req.apiKey, req.timeoutMs).messages.create(params, { signal: req.signal });315    } catch (e) {316      yield { type: "error", error: this.normalizeError(e).toJSON() };317      return;318    }319    const toolNames = new Map<number, { id: string; name: string; server?: boolean; args: string }>();320    let finish: FinishReason = "other";321    let inputTokens = 0;322    let cached = 0;323    let outputTokens = 0;324    let reasoningTokens: number | undefined;325    const blockTypes = new Map<number, string>();326    try {327      for await (const ev of stream) {328        switch (ev.type) {329          case "message_start": {330            const u = ev.message.usage;331            inputTokens = u.input_tokens ?? 0;332            cached = u.cache_read_input_tokens ?? 0;333            // cache_creation tokens are billed as input at 1.25x; we fold them into input for display.334            inputTokens += u.cache_creation_input_tokens ?? 0;335            yield { type: "start", id: ev.message.id, model: ev.message.model };336            break;337          }338          case "content_block_start": {339            const b = ev.content_block;340            blockTypes.set(ev.index, b.type);341            if (b.type === "tool_use") {342              toolNames.set(ev.index, { id: b.id, name: b.name, args: "" });343              yield { type: "tool-start", id: b.id, name: b.name };344            } else if (b.type === "server_tool_use") {345              toolNames.set(ev.index, { id: b.id, name: b.name, server: true, args: "" });346              yield { type: "server-tool", name: b.name, status: "started" };347            } else if (b.type === "web_search_tool_result") {348              yield { type: "server-tool", name: "web_search", status: "completed", data: summarizeSearch(b.content) };349            } else if (b.type === "text" && b.text) {350              yield { type: "text-delta", text: b.text };351            }352            break;353          }354          case "content_block_delta": {355            const d = ev.delta;356            if (d.type === "text_delta") yield { type: "text-delta", text: d.text };357            else if (d.type === "thinking_delta") yield { type: "reasoning-delta", text: d.thinking };358            else if (d.type === "signature_delta") yield { type: "reasoning-signature", signature: d.signature };359            else if (d.type === "input_json_delta") {360              const t = toolNames.get(ev.index);361              if (t && !t.server) {362                t.args += d.partial_json;363                yield { type: "tool-delta", id: t.id, argumentsDelta: d.partial_json };364              }365            } else if (d.type === "citations_delta") {366              const c = d.citation as { type: string; url?: string; title?: string | null; cited_text?: string };367              if (c.type === "web_search_result_location") yield { type: "citation", citation: { url: c.url, title: c.title ?? undefined, snippet: c.cited_text, source: "web_search" } };368            }369            break;370          }371          case "content_block_stop": {372            const t = toolNames.get(ev.index);373            if (t && !t.server) yield { type: "tool-end", id: t.id, name: t.name, arguments: safeJson(t.args), argumentsText: t.args };374            break;375          }376          case "message_delta": {377            finish = mapStop(ev.delta.stop_reason);378            outputTokens = ev.usage.output_tokens ?? outputTokens;379            const details = (ev.usage as { output_tokens_details?: { thinking_tokens?: number | null } }).output_tokens_details;380            if (details?.thinking_tokens != null) reasoningTokens = details.thinking_tokens;381            if (ev.usage.input_tokens != null) inputTokens = ev.usage.input_tokens + (ev.usage.cache_creation_input_tokens ?? 0) + (ev.usage.cache_read_input_tokens ?? 0);382            if (ev.usage.cache_read_input_tokens != null) cached = ev.usage.cache_read_input_tokens;383            const stopDetails = (ev.delta as { stop_details?: { category?: string | null; explanation?: string | null } | null }).stop_details;384            if (ev.delta.stop_reason === "refusal") {385              yield { type: "provider-data", data: { refusal: { category: stopDetails?.category ?? null, explanation: stopDetails?.explanation ?? null } } };386            }387            break;388          }389          case "message_stop":390            break;391        }392      }393      yield { type: "usage", usage: { inputTokens, outputTokens, cachedInputTokens: cached, reasoningTokens, totalTokens: inputTokens + outputTokens } };394      yield { type: "finish", reason: finish };395    } catch (e) {396      yield { type: "error", error: this.normalizeError(e).toJSON() };397    }398  },399400  async estimateTokens(req): Promise<TokenEstimate> {401    try {402      const params = buildParams(req);403      const res = await client(req.apiKey, 20_000).messages.countTokens({404        model: params.model,405        messages: params.messages,406        system: params.system,407        tools: params.tools,408        thinking: params.thinking,409      });410      return { inputTokens: res.input_tokens, method: "provider" };411    } catch {412      const text = req.messages.map((m) => m.content.map((p) => (p.type === "text" ? p.text : "")).join(" ")).join(" ") + (req.system ?? "");413      return { inputTokens: heuristicTokens(text), method: "heuristic" };414    }415  },416417  normalizeError(error: unknown): PolyProviderError {418    if (error instanceof PolyProviderError) return error;419    if (error instanceof Anthropic.APIError) {420      const body = error.error as { error?: { type?: string; message?: string } } | undefined;421      const type = body?.error?.type;422      const message = body?.error?.message ?? error.message;423      let code = codeFromStatus(error.status);424      if (type === "authentication_error") code = "INVALID_API_KEY";425      else if (type === "permission_error") code = "PERMISSION_DENIED";426      else if (type === "not_found_error") code = "MODEL_NOT_FOUND";427      else if (type === "rate_limit_error") code = "RATE_LIMITED";428      else if (type === "overloaded_error") code = "PROVIDER_UNAVAILABLE";429      else if (type === "billing_error") code = "INSUFFICIENT_CREDITS";430      else if (type === "request_too_large") code = "CONTEXT_TOO_LONG";431      else if (type === "invalid_request_error") code = refineByMessage("INVALID_PARAMETER", message);432      const retryable = isRetryableStatus(error.status) || type === "overloaded_error";433      return new PolyProviderError({434        code,435        message: code === "INVALID_API_KEY" ? "Invalid API key" : message.slice(0, 600),436        provider: "anthropic",437        status: error.status,438        retryable: retryable && code !== "INSUFFICIENT_CREDITS",439        retryAfterMs: parseRetryAfter(error.headers ?? null),440        providerCode: type,441        cause: error,442      });443    }444    const err = normalizeGenericError("anthropic", error);445    if (err.code === "UNKNOWN_PROVIDER_ERROR") log.debug("anthropic unknown error", { name: (error as Error)?.name, message: err.message });446    return err;447  },448};449450function summarizeSearch(content: unknown): unknown {451  if (Array.isArray(content)) {452    return content.slice(0, 10).map((r: { url?: string; title?: string }) => ({ url: r.url, title: r.title }));453  }454  return content;455}456457export { safeJson };458