/** * Anthropic Messages 子类 —— 只覆写 chat + messageToValue。 * * 厂商专有概念全部圈在本文件:`x-api-key`/`anthropic-version` header、system 顶级字段、 * 必填 max_tokens、`content_block_delta`/`message_delta` 事件帧、`cache_control`(prompt-caching)。 * prompt-caching 是请求级 opt(opts.cache):支持时在最后一条 system/user 注入 cache_control, * 不另起并行 provider。流式/重试/trace/多模态归一仍全在 LLMBase。 */ import { LLMBase } from '../base.js' const DEFAULT_BASE_URL = 'https://api.anthropic.com/v1' const ANTHROPIC_VERSION = '2023-06-01' const DEFAULT_MAX_TOKENS = 4096 export default class AnthropicProvider extends LLMBase { /** * @param {import('../index.js').ChatRequest} req * @param {import('../index.js').ChatOpts} [opts] * @returns {Promise} */ async chat(req, opts = {}) { const model = req.model ?? this.model return this.request({ req, opts, affinity_key: opts.trace_ctx?.session_id, doFetch: (lease, { signal }) => { const base = lease.base_url ?? DEFAULT_BASE_URL const headers = { 'content-type': 'application/json', 'x-api-key': lease.api_key, 'anthropic-version': ANTHROPIC_VERSION, } const { system, messages } = this._splitMessages(req, opts.cache === true) /** @type {Record} */ const body = { model, messages, max_tokens: req.max_tokens ?? DEFAULT_MAX_TOKENS, stream: true, } if (system) body.system = system if (req.temperature != null) body.temperature = req.temperature if (req.tools) body.tools = this._toAnthropicTools(req.tools) if (req.thinking) body.thinking = req.thinking this.applyResponseFormat(body, headers, req.response_format) return this.httpFetch(`${base}/messages`, { method: 'POST', signal, headers, body: JSON.stringify(body), }) }, }) } /** * Anthropic structured output 分派表:Messages API 以 output_config.format 表达 json_schema * (+ beta gate header)。无 schema-less 的 json_object 模式,故只声明 json_schema; * json_object 由 base 统一 badRequest。 * @protected * @returns {Record, headers:Record, rf:{schema?:object, name?:string}) => void>} */ responseFormatHandlers() { return { json_schema: (body, headers, rf) => { body.output_config = { format: { type: 'json_schema', name: rf.name ?? 'response', schema: rf.schema } } headers['anthropic-beta'] = 'structured-outputs-2025-11-13' }, } } /** * 拆 system 顶级 + user/assistant messages;按需注入 cache_control(prompt-caching)。 * @private * @param {import('../index.js').ChatRequest} req * @param {boolean} cache * @returns {{ system:any, messages:Array<{ role:string, content:any }> }} */ _splitMessages(req, cache) { /** @type {any} */ let system /** @type {Array<{ role:string, content:any }>} */ const messages = [] for (const m of req.messages) { if (m.role === 'system') { system = m.content continue } if (m.role === 'assistant' && m.content?.kind === 'tool_calls') { messages.push({ role: 'assistant', content: m.content.tool_calls.map((c) => ({ type: 'tool_use', id: c.id, name: c.name, input: c.args })), }) continue } if (m.role === 'tool') { // Anthropic 无 tool 角色:tool_result 作为 user block 落地。 messages.push({ role: 'user', content: [{ type: 'tool_result', tool_use_id: m.content.tool_call_id, content: m.content.result }], }) continue } messages.push({ role: m.role, content: m.content }) } const images = this.buildMediaParts(req.images, 'image') if (images.length && messages.length) { const last = messages[messages.length - 1] /** @type {Array} */ const blocks = [{ type: 'text', text: typeof last.content === 'string' ? last.content : '' }] for (const img of images) { blocks.push({ type: 'image', source: img.url ? { type: 'url', url: img.url } : { type: 'base64', media_type: img.mime, data: img.data }, }) } last.content = blocks } if (cache && system != null) { // 把 system 升级为带 cache_control 的 block 列表(Anthropic 专有,留在本文件内)。 system = [{ type: 'text', text: String(system), cache_control: { type: 'ephemeral' } }] } return { system, messages } } /** * 中性 tool spec → Anthropic tool 形态(parameters 即 input_schema)。 * @protected * @param {Array<{ name:string, description:string, parameters:object }>} tools * @returns {Array} */ _toAnthropicTools(tools) { return tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters })) } /** * @param {any} frame Anthropic SSE data 帧(已解析 JSON)。 * @returns {import('../base.js').ParsedValue | null} */ messageToValue(frame) { if (!frame || typeof frame !== 'object') return null switch (frame.type) { case 'content_block_start': { const cb = frame.content_block if (cb?.type !== 'tool_use') return null // 工具参数不在此帧(cb.input 恒 {}):只发 id/name 首帧,参数经后续 input_json_delta 分片,base 按 index 聚合。 return { type: 'tool_call_delta', index: frame.index, id: cb.id, name: cb.name, args_fragment: '' } } case 'content_block_delta': { const d = frame.delta if (d?.type === 'thinking_delta' && typeof d.thinking === 'string') { return { type: 'reasoning', text: d.thinking } } if (d?.type === 'text_delta' && typeof d.text === 'string') { return { type: 'text', text: d.text } } // 工具参数分片:partial_json 拼进对应 index 的 argsText,base 流结束时一次 JSON.parse。 if (d?.type === 'input_json_delta' && typeof d.partial_json === 'string') { return { type: 'tool_call_delta', index: frame.index, args_fragment: d.partial_json } } return null } case 'message_start': { const u = frame.message?.usage if (!u) return null return { type: 'usage', // 不在此设 total_tokens:output 由后续 message_delta 给最终值, // base 在流结束时按 prompt+completion 兜底重算 total。 usage: { prompt_tokens: requiredUsageInteger(u.input_tokens, 'input_tokens'), completion_tokens: requiredUsageInteger(u.output_tokens, 'output_tokens'), cached_tokens: optionalUsageInteger(u.cache_read_input_tokens, 'cache_read_input_tokens'), }, } } case 'message_delta': { const u = frame.usage if (!u) return null // message_delta 只更新 output_tokens;保留为 usage 增量(base 以最后值覆盖)。 return { type: 'usage', usage: { completion_tokens: requiredUsageInteger(u.output_tokens, 'output_tokens'), }, } } default: return null } } } function requiredUsageInteger(value, field) { if (!Number.isSafeInteger(value) || value < 0) { throw new Error(`[anthropic] usage.${field} 必须是非负安全整数`) } return value } function optionalUsageInteger(value, field) { if (value === undefined) return 0 return requiredUsageInteger(value, field) }