llm.anthropic.js
7.71 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
/**
* 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)
}