llm.anthropic.js 7.71 KB
/**
 * 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<import('../base.js').StreamResult>}
   */
  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<string, any>} */
        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<string, (body:Record<string,any>, headers:Record<string,string>, 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<any>} */
      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<object>}
   */
  _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)
}