@x-otto/cli 0.0.1-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -0
- package/bin/otto.js +61 -0
- package/dist/auth-command-CXAB3_hs.js +11 -0
- package/dist/auth-command-CXAB3_hs.js.map +1 -0
- package/dist/build-id-BX4QHzM0.js +3 -0
- package/dist/build-id-BX4QHzM0.js.map +1 -0
- package/dist/bundled-extensions/plugin-otto-wire-protocols/otto-plugin.json +8 -0
- package/dist/bundled-extensions/plugin-otto-wire-protocols/plugin-dist/meta.json +1 -0
- package/dist/bundled-extensions/plugin-otto-wire-protocols/plugin-dist/plugin.cjs +60023 -0
- package/dist/bundled-extensions/plugin-otto-wire-protocols/plugin.ts +64 -0
- package/dist/bundled-extensions/plugin-otto-wire-protocols/src/anthropic-messages.ts +916 -0
- package/dist/bundled-extensions/plugin-otto-wire-protocols/src/anthropic-usage.ts +81 -0
- package/dist/bundled-extensions/plugin-otto-wire-protocols/src/openai-completions.ts +158 -0
- package/dist/bundled-extensions/plugin-otto-wire-protocols/src/openai-responses.ts +575 -0
- package/dist/bundled-extensions/plugin-skill-inductor/otto-plugin.json +8 -0
- package/dist/bundled-extensions/plugin-skill-inductor/plugin-dist/meta.json +1 -0
- package/dist/bundled-extensions/plugin-skill-inductor/plugin-dist/plugin.cjs +52781 -0
- package/dist/bundled-extensions/plugin-skill-inductor/plugin.ts +77 -0
- package/dist/bundled-extensions/plugin-skill-inductor/src/candidate-detector.ts +210 -0
- package/dist/bundled-extensions/plugin-skill-inductor/src/candidate-digest.ts +146 -0
- package/dist/bundled-extensions/plugin-skill-inductor/src/candidate-store.ts +87 -0
- package/dist/bundled-extensions/plugin-skill-inductor/src/episode-store.ts +122 -0
- package/dist/bundled-extensions/plugin-skill-inductor/src/episode.ts +125 -0
- package/dist/bundled-extensions/plugin-skill-inductor/src/extraction-prompt.ts +97 -0
- package/dist/bundled-extensions/plugin-skill-inductor/src/inductor-service.ts +183 -0
- package/dist/bundled-extensions/plugin-skill-inductor/src/similarity.ts +58 -0
- package/dist/command-dispatcher-BgStVcCF.js +13 -0
- package/dist/command-dispatcher-BgStVcCF.js.map +1 -0
- package/dist/config-migration-DatflIxs.js +21 -0
- package/dist/config-migration-DatflIxs.js.map +1 -0
- package/dist/daemon-COESl-zU.js +21 -0
- package/dist/daemon-COESl-zU.js.map +1 -0
- package/dist/daemon-base-YOWgYYN7.js +3 -0
- package/dist/daemon-base-YOWgYYN7.js.map +1 -0
- package/dist/debug-CiosFwme.js +34 -0
- package/dist/debug-CiosFwme.js.map +1 -0
- package/dist/discover-with-bundled-CznLjkkw.js +3 -0
- package/dist/discover-with-bundled-CznLjkkw.js.map +1 -0
- package/dist/doctor-C84-hhEr.js +30 -0
- package/dist/doctor-C84-hhEr.js.map +1 -0
- package/dist/doctor-attach-_zeIJewx.js +42 -0
- package/dist/doctor-attach-_zeIJewx.js.map +1 -0
- package/dist/doctor-cdp-K_mIFRfA.js +5 -0
- package/dist/doctor-cdp-K_mIFRfA.js.map +1 -0
- package/dist/doctor-heap-Rr2nNvcw.js +11 -0
- package/dist/doctor-heap-Rr2nNvcw.js.map +1 -0
- package/dist/doctor-ps-OL3FGQYu.js +8 -0
- package/dist/doctor-ps-OL3FGQYu.js.map +1 -0
- package/dist/extension-Bhzoel6s.js +169 -0
- package/dist/extension-Bhzoel6s.js.map +1 -0
- package/dist/extension-plugin-CnF6O_zK.js +7 -0
- package/dist/extension-plugin-CnF6O_zK.js.map +1 -0
- package/dist/extension-plugin-DWcakwRx.js +2 -0
- package/dist/extension-v-liRnUX.js +2 -0
- package/dist/feedback-BES_6ebi.js +21 -0
- package/dist/feedback-BES_6ebi.js.map +1 -0
- package/dist/feedback-BRbLUn9u.js +2 -0
- package/dist/feishu-setup-D6vnuNLw.js +6 -0
- package/dist/feishu-setup-D6vnuNLw.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/install-DlVJRHRT.js +11 -0
- package/dist/install-DlVJRHRT.js.map +1 -0
- package/dist/install-WCcQ9k9M.js +2 -0
- package/dist/mcp-DbTYT7hB.js +12 -0
- package/dist/mcp-DbTYT7hB.js.map +1 -0
- package/dist/migrate-boDXhEyV.js +14 -0
- package/dist/migrate-boDXhEyV.js.map +1 -0
- package/dist/model-auth-C27iG6Jq.js +3 -0
- package/dist/model-auth-C27iG6Jq.js.map +1 -0
- package/dist/model-auth-C4bsWvvn.js +2 -0
- package/dist/observe-CVg-MRM2.js +32 -0
- package/dist/observe-CVg-MRM2.js.map +1 -0
- package/dist/parse-cli-tFDOeVU1.js +3 -0
- package/dist/parse-cli-tFDOeVU1.js.map +1 -0
- package/dist/persistenced-CCwHu1Ww.js +36 -0
- package/dist/persistenced-CCwHu1Ww.js.map +1 -0
- package/dist/proxy-BvdBsB1O.js +2 -0
- package/dist/proxy-evg7ZvoD.js +3 -0
- package/dist/proxy-evg7ZvoD.js.map +1 -0
- package/dist/ps-DKrluEuy.js +9 -0
- package/dist/ps-DKrluEuy.js.map +1 -0
- package/dist/registry-B3nfN62q.js +2 -0
- package/dist/registry-D0NONjLv.js +6 -0
- package/dist/registry-D0NONjLv.js.map +1 -0
- package/dist/remote-persistence-server-B_hONoVR.js +35 -0
- package/dist/remote-persistence-server-B_hONoVR.js.map +1 -0
- package/dist/run-attach-Ci5jYyCa.js +3 -0
- package/dist/run-attach-Ci5jYyCa.js.map +1 -0
- package/dist/serve-pVR6UIT9.js +21 -0
- package/dist/serve-pVR6UIT9.js.map +1 -0
- package/dist/service-client-BLNeugM9.js +3 -0
- package/dist/service-client-BLNeugM9.js.map +1 -0
- package/dist/time-travel-ZozuPPI-.js +21 -0
- package/dist/time-travel-ZozuPPI-.js.map +1 -0
- package/dist/version-C0qM-qJM.js +3 -0
- package/dist/version-C0qM-qJM.js.map +1 -0
- package/package.json +61 -0
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import Anthropic from '@anthropic-ai/sdk'
|
|
3
|
+
import { ProviderError } from '@x-otto/shared'
|
|
4
|
+
import { normalizeImageData, toAnthropicImageMime } from '@x-otto/interchange'
|
|
5
|
+
import {
|
|
6
|
+
buildRequestHeaders,
|
|
7
|
+
sanitizeToolRoundtrips,
|
|
8
|
+
assertSystemNotificationPlacement,
|
|
9
|
+
degradedSystemNotificationText,
|
|
10
|
+
mergeConsecutiveSystemNotifications,
|
|
11
|
+
assembleAssistantMessage,
|
|
12
|
+
blocksContent,
|
|
13
|
+
initStreamUsage,
|
|
14
|
+
toolCallFromParsed,
|
|
15
|
+
recoverLiteralInvokeToolCalls,
|
|
16
|
+
registeredToolNames,
|
|
17
|
+
clampThinkingLevel,
|
|
18
|
+
} from '@x-otto/provider'
|
|
19
|
+
import { extractProviderUsageFromHeaders } from './anthropic-usage'
|
|
20
|
+
import type {
|
|
21
|
+
AssistantMessage,
|
|
22
|
+
Message,
|
|
23
|
+
Model,
|
|
24
|
+
ResolvedAuth,
|
|
25
|
+
StopReason,
|
|
26
|
+
StreamContext,
|
|
27
|
+
StreamEvent,
|
|
28
|
+
StreamOptions,
|
|
29
|
+
SystemPromptSegment,
|
|
30
|
+
CacheRetention,
|
|
31
|
+
ProviderStream,
|
|
32
|
+
StreamBlock,
|
|
33
|
+
StreamUsage,
|
|
34
|
+
TokenDerivedHeader,
|
|
35
|
+
} from '@x-otto/provider'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* RFC-185 D3:日志改走宿主注入端口(`AnthropicProviderOptions.logger`),插件源码禁止
|
|
39
|
+
* `createLogger` 自建——esbuild bundle 会复制出宿主管不着的第二份 pino 单例,trace 级
|
|
40
|
+
* 直写 stderr 破坏 TUI(findings-backlog「TUI 残影回归」根因环 1/2)。缺省 = no-op
|
|
41
|
+
* (红线:绝不 fallback 到 createLogger)。
|
|
42
|
+
*/
|
|
43
|
+
import type { WireProtocolLogger } from '@x-otto/provider'
|
|
44
|
+
|
|
45
|
+
const noopLogger: WireProtocolLogger = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }
|
|
46
|
+
|
|
47
|
+
export type AnthropicCredentialResolver = () => Promise<string | undefined>
|
|
48
|
+
export type AnthropicAuthResolver = () => Promise<ResolvedAuth | undefined>
|
|
49
|
+
export type AnthropicBaseUrlResolver = () => Promise<string | undefined>
|
|
50
|
+
|
|
51
|
+
async function resolveBaseUrl(
|
|
52
|
+
modelBaseUrl: string,
|
|
53
|
+
resolver?: AnthropicBaseUrlResolver,
|
|
54
|
+
): Promise<string> {
|
|
55
|
+
const envUrl = process.env['ANTHROPIC_BASE_URL']
|
|
56
|
+
if (envUrl) {
|
|
57
|
+
return envUrl
|
|
58
|
+
}
|
|
59
|
+
if (resolver) {
|
|
60
|
+
const authUrl = await resolver()
|
|
61
|
+
if (authUrl) {
|
|
62
|
+
return authUrl
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return modelBaseUrl
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function toBudgetTokens(level: string, maxOutputTokens: number): number {
|
|
69
|
+
// RFC-235:Anthropic 是 token 预算型(非 effort 关键字)。otto 档位 → maxOutput 百分比预算。
|
|
70
|
+
// 词表对齐 Claude Code(去 minimal 加 max):low 25% / medium 50% / high 75% / xhigh 90% / max 95%。
|
|
71
|
+
let budget: number
|
|
72
|
+
switch (level) {
|
|
73
|
+
case 'low':
|
|
74
|
+
budget = Math.floor(maxOutputTokens * 0.25)
|
|
75
|
+
break
|
|
76
|
+
case 'high':
|
|
77
|
+
budget = Math.floor(maxOutputTokens * 0.75)
|
|
78
|
+
break
|
|
79
|
+
case 'xhigh':
|
|
80
|
+
budget = Math.floor(maxOutputTokens * 0.9)
|
|
81
|
+
break
|
|
82
|
+
case 'max':
|
|
83
|
+
budget = Math.floor(maxOutputTokens * 0.95)
|
|
84
|
+
break
|
|
85
|
+
default:
|
|
86
|
+
budget = Math.floor(maxOutputTokens * 0.5)
|
|
87
|
+
}
|
|
88
|
+
return Math.min(Math.max(budget, 1024), maxOutputTokens - 1)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** thinking 配置的两种输出形态(由 model.thinkingMode 声明决定)。 */
|
|
92
|
+
export type ThinkingConfig =
|
|
93
|
+
| { type: 'enabled'; budget_tokens: number }
|
|
94
|
+
| { type: 'adaptive' }
|
|
95
|
+
|
|
96
|
+
export function buildThinkingConfig(
|
|
97
|
+
level: string | undefined,
|
|
98
|
+
reasoning: boolean,
|
|
99
|
+
maxOutputTokens: number,
|
|
100
|
+
thinkingMode?: 'enabled' | 'adaptive',
|
|
101
|
+
): ThinkingConfig | undefined {
|
|
102
|
+
if (!reasoning || !level) {
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
if (thinkingMode === 'adaptive') {
|
|
106
|
+
return { type: 'adaptive' }
|
|
107
|
+
}
|
|
108
|
+
if (maxOutputTokens <= 1024) {
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
return { type: 'enabled', budget_tokens: toBudgetTokens(level, maxOutputTokens) }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** adaptive 模式的 effort 值映射(output_config.effort)。 */
|
|
115
|
+
function toAdaptiveEffort(level: string): 'low' | 'medium' | 'high' {
|
|
116
|
+
switch (level) {
|
|
117
|
+
case 'low': return 'low'
|
|
118
|
+
case 'medium': return 'medium'
|
|
119
|
+
case 'high': return 'high'
|
|
120
|
+
case 'xhigh': return 'high'
|
|
121
|
+
case 'max': return 'high'
|
|
122
|
+
default: return 'medium'
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const CACHE_CONTROL = { type: 'ephemeral' as const }
|
|
127
|
+
const CACHE_CONTROL_1H = { type: 'ephemeral' as const, ttl: '1h' as const }
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* cacheRetention 选档 → ephemeral 断点(含 TTL):
|
|
131
|
+
* - 'none' → null(不放任何断点,调用方据此跳过);
|
|
132
|
+
* - 'short' / 缺省 → 5 分钟 TTL;
|
|
133
|
+
* - 'long' → 1 小时 TTL(SDK 0.88 原生 ttl,GA、无需 beta 头)。
|
|
134
|
+
* 返回 null(而非 undefined)以便与「参数省略走 5m 默认」区分。
|
|
135
|
+
*/
|
|
136
|
+
export function cacheControlFor(
|
|
137
|
+
retention?: CacheRetention,
|
|
138
|
+
): Anthropic.CacheControlEphemeral | null {
|
|
139
|
+
if (retention === 'none') {
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
return retention === 'long' ? CACHE_CONTROL_1H : CACHE_CONTROL
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 断点③——会话前缀尾:最后一条消息的最后一个 content block 挂 cache_control。
|
|
147
|
+
* 仅对安全的块类型(text/tool_result/image)标记;string content 先升格为 block 数组。
|
|
148
|
+
* cacheControl=null(retention 'none')→ 不放断点。参数省略默认 5m(保留旧直调行为)。
|
|
149
|
+
*/
|
|
150
|
+
export function withMessageCacheBreakpoint(
|
|
151
|
+
messages: Anthropic.MessageParam[],
|
|
152
|
+
cacheControl: Anthropic.CacheControlEphemeral | null = CACHE_CONTROL,
|
|
153
|
+
): Anthropic.MessageParam[] {
|
|
154
|
+
const last = messages[messages.length - 1]
|
|
155
|
+
if (!last || !cacheControl) {
|
|
156
|
+
return messages
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (typeof last.content === 'string') {
|
|
160
|
+
if (last.content.length === 0) {
|
|
161
|
+
return messages
|
|
162
|
+
}
|
|
163
|
+
last.content = [{ type: 'text', text: last.content, cache_control: cacheControl }]
|
|
164
|
+
return messages
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const lastBlock = last.content[last.content.length - 1]
|
|
168
|
+
if (
|
|
169
|
+
lastBlock &&
|
|
170
|
+
(lastBlock.type === 'text' || lastBlock.type === 'tool_result' || lastBlock.type === 'image') &&
|
|
171
|
+
!(lastBlock.type === 'text' && lastBlock.text.length === 0)
|
|
172
|
+
) {
|
|
173
|
+
lastBlock.cache_control = cacheControl
|
|
174
|
+
}
|
|
175
|
+
return messages
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 断点④——system 稳定前缀尾(动态):把 system 转为 text block 数组,
|
|
180
|
+
* 在「最后一个 stable 段」之后挂 cache_control。volatile 段(如 phase context)
|
|
181
|
+
* 照发但落在断点之后,不进缓存前缀——因此其逐轮变化不会击穿 system / messages 缓存。
|
|
182
|
+
*
|
|
183
|
+
* - segments 缺省(仅 string):整串视作单一 stable 段,断点落在尾部 == 旧行为。
|
|
184
|
+
* - 全 volatile(无 stable 段)/ cacheControl=null(retention 'none'):不挂断点。
|
|
185
|
+
* - 空内容:返回 undefined,调用方省略 system 字段。
|
|
186
|
+
* 参数省略默认 5m(保留旧直调行为)。
|
|
187
|
+
*/
|
|
188
|
+
export function buildSystemBlocks(
|
|
189
|
+
systemPrompt: string,
|
|
190
|
+
segments?: SystemPromptSegment[],
|
|
191
|
+
cacheControl: Anthropic.CacheControlEphemeral | null = CACHE_CONTROL,
|
|
192
|
+
): Anthropic.TextBlockParam[] | undefined {
|
|
193
|
+
const source: SystemPromptSegment[] =
|
|
194
|
+
segments && segments.length > 0 ? segments : systemPrompt ? [{ text: systemPrompt }] : []
|
|
195
|
+
|
|
196
|
+
const blocks = source.filter((s) => s.text.length > 0)
|
|
197
|
+
if (blocks.length === 0) {
|
|
198
|
+
return undefined
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let breakpoint = -1
|
|
202
|
+
if (cacheControl) {
|
|
203
|
+
for (const [i, seg] of blocks.entries()) {
|
|
204
|
+
if ((seg.cache ?? 'stable') === 'stable') {
|
|
205
|
+
breakpoint = i
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return blocks.map((seg, i) => ({
|
|
211
|
+
type: 'text' as const,
|
|
212
|
+
text: seg.text,
|
|
213
|
+
...(i === breakpoint && cacheControl ? { cache_control: cacheControl } : {}),
|
|
214
|
+
}))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* RFC-144 M2:mid-conversation system message 模型能力检测——据 Anthropic 官方文档
|
|
219
|
+
* (docs.anthropic.com/en/docs/build-with-claude/mid-conversation-system-messages,
|
|
220
|
+
* 2026-07 核实)"This feature is available on Claude Opus 4.8 only",且不支持 Amazon
|
|
221
|
+
* Bedrock / Google Cloud(本函数只判模型本身,不判部署平台——otto 当前只走 Claude API
|
|
222
|
+
* 直连,平台判定不在范围内)。M0 已用真实 API 调用验证 `anthropic/claude-opus-4-8`
|
|
223
|
+
* 支持该特性(见 RFC-144 milestone M0 执行记录)。
|
|
224
|
+
*
|
|
225
|
+
* 不支持的模型走降级路径(决策 1 已述:`role:'user'` + 显式文本前缀),由调用方
|
|
226
|
+
* (`buildAnthropicMessages` 的 system_notification 分支)据此结果切换序列化形态。
|
|
227
|
+
*/
|
|
228
|
+
export function supportsMidConversationSystemMessage(model: Model): boolean {
|
|
229
|
+
return model.id === 'anthropic/claude-opus-4-8'
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Anthropic 要求 `tool_use.id` / `tool_result.tool_use_id` 匹配 `^[a-zA-Z0-9_-]+$`
|
|
234
|
+
* (见 400 报错 `String should match pattern '^[a-zA-Z0-9_-]+$'`)。会话历史里的
|
|
235
|
+
* tool_call id 可能由其它 provider/网关生成,含点号、冒号、斜杠或非 ASCII 字符(例如跨
|
|
236
|
+
* provider 续聊、代理层重写的 id),直接透传就会被 Anthropic 拒绝整轮请求。
|
|
237
|
+
*
|
|
238
|
+
* 这里在 wire 边界做**确定性**清洗:合法 id 原样返回;非法 id 用其 sha256 摘要派生一个
|
|
239
|
+
* 稳定的合规 id。关键性质是纯函数——同一输入永远映射到同一输出,因此 assistant 的
|
|
240
|
+
* `tool_use.id` 与其配对的 `tool_result.tool_use_id` 各自独立清洗后仍然相等,roundtrip
|
|
241
|
+
* 配对不被破坏(不需要跨消息共享映射表)。
|
|
242
|
+
*/
|
|
243
|
+
const VALID_TOOL_USE_ID = /^[a-zA-Z0-9_-]+$/
|
|
244
|
+
|
|
245
|
+
export function sanitizeToolUseId(id: string): string {
|
|
246
|
+
if (id.length > 0 && VALID_TOOL_USE_ID.test(id)) {
|
|
247
|
+
return id
|
|
248
|
+
}
|
|
249
|
+
return `toolu_x_${createHash('sha256').update(id).digest('hex').slice(0, 32)}`
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* `Message[]` → Anthropic `MessageParam[]`。
|
|
254
|
+
*
|
|
255
|
+
* `model` 可选(缺省走降级路径,见 `supportsMidConversationSystemMessage`)——保持向后
|
|
256
|
+
* 兼容:既有调用方(如未来新增的测试/工具)不传 model 时不会因新增必填参数而破坏编译。
|
|
257
|
+
*
|
|
258
|
+
* SDK 类型滞后说明(RFC-144 M2):`@anthropic-ai/sdk@0.88.0` 的 `MessageParam.role` 类型声明
|
|
259
|
+
* 仅 `'user' | 'assistant'`,但 Anthropic 官方文档明确 API 本身接受 `{role:'system', ...}`
|
|
260
|
+
* 出现在 messages 数组中(M0 已用真实 API 调用验证)——这是 SDK 类型声明滞后于实际 API
|
|
261
|
+
* 能力的已知情况,用 `as Anthropic.MessageParam` 类型断言绕过,不代表真实类型契约。
|
|
262
|
+
*/
|
|
263
|
+
export function buildAnthropicMessages(messages: Message[], model?: Model): Anthropic.MessageParam[] {
|
|
264
|
+
const result: Anthropic.MessageParam[] = []
|
|
265
|
+
const nativeSystemMessageSupported = model ? supportsMidConversationSystemMessage(model) : false
|
|
266
|
+
|
|
267
|
+
// 2026-07 修正:Anthropic 官方约束"Consecutive system messages are not allowed"——原生
|
|
268
|
+
// 路径必须先把相邻的 system_notification 合并为一条,否则多个 lifecycleAsync 工具同轮
|
|
269
|
+
// 完成时会产出连续 system 消息,被 API 拒绝(400)。降级路径(role:'user')不受此限制,
|
|
270
|
+
// 不合并(各自独立 user 消息,Anthropic 对连续 user 无限制)。
|
|
271
|
+
const normalized = nativeSystemMessageSupported
|
|
272
|
+
? mergeConsecutiveSystemNotifications(messages)
|
|
273
|
+
: messages
|
|
274
|
+
|
|
275
|
+
// RFC-144 M2:放置规则断言——只有原生走 mid-conversation system message 的路径才受
|
|
276
|
+
// Anthropic 官方放置约束限制(降级路径 role:'user' 无此约束,断言在此处跑纯粹是
|
|
277
|
+
// 因为该模型能力已知支持,检查值得做;不支持的模型走断言意义不大但跑一次无害)。
|
|
278
|
+
// 对合并后的序列断言——合并已消除"连续 system_notification"这一类曾经的假阳性源。
|
|
279
|
+
assertSystemNotificationPlacement(normalized)
|
|
280
|
+
|
|
281
|
+
for (const msg of sanitizeToolRoundtrips(normalized)) {
|
|
282
|
+
if (msg.role === 'user') {
|
|
283
|
+
if (typeof msg.content === 'string') {
|
|
284
|
+
result.push({ role: 'user', content: msg.content })
|
|
285
|
+
} else {
|
|
286
|
+
const content: Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam> = []
|
|
287
|
+
|
|
288
|
+
for (const part of msg.content) {
|
|
289
|
+
if (part.type === 'text') {
|
|
290
|
+
if (part.text.length > 0) {
|
|
291
|
+
content.push({ type: 'text', text: part.text })
|
|
292
|
+
}
|
|
293
|
+
} else if (part.type === 'image') {
|
|
294
|
+
const { base64, mediaType, valid } = normalizeImageData(part.source, part.mime)
|
|
295
|
+
if (valid) {
|
|
296
|
+
content.push({
|
|
297
|
+
type: 'image',
|
|
298
|
+
source: {
|
|
299
|
+
type: 'base64',
|
|
300
|
+
media_type: toAnthropicImageMime(mediaType, base64),
|
|
301
|
+
data: base64,
|
|
302
|
+
},
|
|
303
|
+
})
|
|
304
|
+
} else {
|
|
305
|
+
content.push({ type: 'text', text: '[image omitted: corrupted or empty data]' })
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (content.length > 0) {
|
|
311
|
+
result.push({ role: 'user', content })
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} else if (msg.role === 'assistant') {
|
|
315
|
+
const content: Anthropic.ContentBlockParam[] = []
|
|
316
|
+
|
|
317
|
+
for (const part of msg.content) {
|
|
318
|
+
if (part.type === 'thinking') {
|
|
319
|
+
content.push({
|
|
320
|
+
type: 'thinking',
|
|
321
|
+
thinking: part.text,
|
|
322
|
+
signature: part.signature ?? '',
|
|
323
|
+
} as Anthropic.ThinkingBlockParam)
|
|
324
|
+
} else if (part.type === 'text') {
|
|
325
|
+
if (part.text.trim()) {
|
|
326
|
+
content.push({ type: 'text', text: part.text })
|
|
327
|
+
}
|
|
328
|
+
} else if (part.type === 'tool_call') {
|
|
329
|
+
content.push({
|
|
330
|
+
type: 'tool_use',
|
|
331
|
+
id: sanitizeToolUseId(part.id),
|
|
332
|
+
name: part.name,
|
|
333
|
+
input: part.arguments,
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (content.length > 0) {
|
|
339
|
+
result.push({ role: 'assistant', content })
|
|
340
|
+
}
|
|
341
|
+
} else if (msg.role === 'tool_result') {
|
|
342
|
+
const toolContent: Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam> = []
|
|
343
|
+
|
|
344
|
+
for (const c of msg.content) {
|
|
345
|
+
if (c.type === 'text') {
|
|
346
|
+
if (c.text.length > 0) {
|
|
347
|
+
toolContent.push({ type: 'text', text: c.text })
|
|
348
|
+
}
|
|
349
|
+
} else if (c.type === 'image') {
|
|
350
|
+
const { base64, mediaType, valid } = normalizeImageData(c.source, c.mime)
|
|
351
|
+
if (valid) {
|
|
352
|
+
toolContent.push({
|
|
353
|
+
type: 'image',
|
|
354
|
+
source: {
|
|
355
|
+
type: 'base64',
|
|
356
|
+
media_type: toAnthropicImageMime(mediaType, base64),
|
|
357
|
+
data: base64,
|
|
358
|
+
},
|
|
359
|
+
})
|
|
360
|
+
} else {
|
|
361
|
+
toolContent.push({ type: 'text', text: '[image omitted: corrupted or empty data]' })
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
result.push({
|
|
367
|
+
role: 'user',
|
|
368
|
+
content: [
|
|
369
|
+
{
|
|
370
|
+
type: 'tool_result',
|
|
371
|
+
tool_use_id: sanitizeToolUseId(msg.toolCallId),
|
|
372
|
+
content: toolContent,
|
|
373
|
+
...(msg.isError ? { is_error: true } : {}),
|
|
374
|
+
},
|
|
375
|
+
],
|
|
376
|
+
})
|
|
377
|
+
} else if (msg.role === 'system_notification') {
|
|
378
|
+
// RFC-144 M2:lifecycleAsync 工具完成通知——支持原生特性的模型走 mid-conversation
|
|
379
|
+
// system message(结构化保留"运营方权威"语义);不支持的模型降级为 role:'user' +
|
|
380
|
+
// 显式前缀(放置约束见函数头注释;追加时机天然满足"紧跟 tool_result 之后",因为
|
|
381
|
+
// work-loop.ts 的落库时机固定在下一次 runTurn 之前的静默期,见 RFC-144 硬约束 5)。
|
|
382
|
+
if (nativeSystemMessageSupported) {
|
|
383
|
+
result.push({ role: 'system', content: msg.content } as unknown as Anthropic.MessageParam)
|
|
384
|
+
} else {
|
|
385
|
+
result.push({ role: 'user', content: degradedSystemNotificationText(msg.content) })
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// RFC-144 M2 补充修复(2026-07):Anthropic mid-conversation system message 放置约束
|
|
391
|
+
// 的第二半(后继约束)——system 消息必须是数组末尾或紧接一个 assistant turn。
|
|
392
|
+
// assertSystemNotificationPlacement 在内部层面已检测到此违规并 warn,但 fail-soft 不阻断——
|
|
393
|
+
// 原生支持 mid-conversation system message 的模型仍可能在 wire 格式中产生
|
|
394
|
+
// [...assistant, system, user, ...] 这类违规序列(当 lifecycleAsync 工具在上一轮模型
|
|
395
|
+
// 响应后才完成、紧接着用户发送了新消息时),被 Anthropic 400 拒绝。
|
|
396
|
+
// 修复:对 wire 结果做一次后扫描,将放置违规的 system 消息降级为 role:'user' + 前缀
|
|
397
|
+
// (与此前非原生模型的降级路径同一序列化形态,不引入新格式)。
|
|
398
|
+
if (nativeSystemMessageSupported) {
|
|
399
|
+
for (let i = 0; i < result.length; i++) {
|
|
400
|
+
// SDK 的 MessageParam.role 类型声明仅 'user'|'assistant',但实际 wire 中可能有
|
|
401
|
+
// 'system'(Anthropic SDK 类型滞后于 API 能力,同 buildAnthropicMessages 内
|
|
402
|
+
// push 时的 as unknown as MessageParam 注释)。
|
|
403
|
+
if ((result[i] as unknown as { role: string }).role === 'system') {
|
|
404
|
+
const next = result[i + 1]
|
|
405
|
+
if (next && next.role !== 'assistant') {
|
|
406
|
+
const text = typeof result[i]!.content === 'string' ? (result[i]!.content as string) : ''
|
|
407
|
+
result[i] = { role: 'user', content: degradedSystemNotificationText(text) }
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return result
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function mapStopReason(reason: string | null | undefined): StopReason {
|
|
417
|
+
switch (reason) {
|
|
418
|
+
case 'end_turn':
|
|
419
|
+
return 'end_turn'
|
|
420
|
+
case 'max_tokens':
|
|
421
|
+
return 'max_tokens'
|
|
422
|
+
case 'tool_use':
|
|
423
|
+
return 'tool_use'
|
|
424
|
+
case 'stop_sequence':
|
|
425
|
+
return 'stop_sequence'
|
|
426
|
+
case 'refusal':
|
|
427
|
+
return 'refusal'
|
|
428
|
+
default:
|
|
429
|
+
return 'end_turn'
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
interface AnthropicStreamState extends StreamUsage {
|
|
434
|
+
model: string
|
|
435
|
+
blocks: Map<number, StreamBlock>
|
|
436
|
+
stopReason: StopReason
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function initStreamState(modelId: string): AnthropicStreamState {
|
|
440
|
+
return {
|
|
441
|
+
...initStreamUsage(),
|
|
442
|
+
model: modelId,
|
|
443
|
+
blocks: new Map(),
|
|
444
|
+
stopReason: 'end_turn',
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function buildAssistantMessage(state: AnthropicStreamState, final = false): AssistantMessage {
|
|
449
|
+
return assembleAssistantMessage(
|
|
450
|
+
{ api: 'anthropic-messages', provider: 'anthropic', model: state.model },
|
|
451
|
+
blocksContent(state.blocks, final),
|
|
452
|
+
state,
|
|
453
|
+
state.stopReason,
|
|
454
|
+
)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function dispatchStreamEvent(
|
|
458
|
+
event: Anthropic.MessageStreamEvent,
|
|
459
|
+
state: AnthropicStreamState,
|
|
460
|
+
): StreamEvent[] {
|
|
461
|
+
switch (event.type) {
|
|
462
|
+
case 'message_start': {
|
|
463
|
+
const u = event.message.usage
|
|
464
|
+
state.inputTokens = u.input_tokens
|
|
465
|
+
state.outputTokens = u.output_tokens
|
|
466
|
+
if (u.cache_read_input_tokens != null) {
|
|
467
|
+
state.cacheReadTokens = u.cache_read_input_tokens
|
|
468
|
+
}
|
|
469
|
+
if (u.cache_creation_input_tokens != null) {
|
|
470
|
+
state.cacheWriteTokens = u.cache_creation_input_tokens
|
|
471
|
+
}
|
|
472
|
+
return []
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
case 'content_block_start': {
|
|
476
|
+
const index = event.index
|
|
477
|
+
const block = event.content_block
|
|
478
|
+
|
|
479
|
+
if (block.type === 'text') {
|
|
480
|
+
state.blocks.set(index, { kind: 'text', text: '' })
|
|
481
|
+
return [{ type: 'text_start', index, partial: buildAssistantMessage(state) }]
|
|
482
|
+
} else if (block.type === 'thinking') {
|
|
483
|
+
state.blocks.set(index, { kind: 'thinking', text: '', signature: '' })
|
|
484
|
+
return [{ type: 'thinking_start', index, partial: buildAssistantMessage(state) }]
|
|
485
|
+
} else if (block.type === 'tool_use') {
|
|
486
|
+
state.blocks.set(index, {
|
|
487
|
+
kind: 'tool_call',
|
|
488
|
+
id: block.id,
|
|
489
|
+
name: block.name,
|
|
490
|
+
argumentsJson: '',
|
|
491
|
+
})
|
|
492
|
+
return [{ type: 'tool_call_start', index, partial: buildAssistantMessage(state) }]
|
|
493
|
+
}
|
|
494
|
+
return []
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
case 'content_block_delta': {
|
|
498
|
+
const index = event.index
|
|
499
|
+
const delta = event.delta
|
|
500
|
+
const block = state.blocks.get(index)
|
|
501
|
+
|
|
502
|
+
if (delta.type === 'text_delta' && block?.kind === 'text') {
|
|
503
|
+
block.text += delta.text
|
|
504
|
+
return [{
|
|
505
|
+
type: 'text_delta',
|
|
506
|
+
index,
|
|
507
|
+
delta: delta.text,
|
|
508
|
+
partial: buildAssistantMessage(state),
|
|
509
|
+
}]
|
|
510
|
+
} else if (delta.type === 'thinking_delta' && block?.kind === 'thinking') {
|
|
511
|
+
block.text += delta.thinking
|
|
512
|
+
return [{
|
|
513
|
+
type: 'thinking_delta',
|
|
514
|
+
index,
|
|
515
|
+
delta: delta.thinking,
|
|
516
|
+
partial: buildAssistantMessage(state),
|
|
517
|
+
}]
|
|
518
|
+
} else if (delta.type === 'signature_delta' && block?.kind === 'thinking') {
|
|
519
|
+
block.signature = (block.signature ?? '') + delta.signature
|
|
520
|
+
return []
|
|
521
|
+
} else if (delta.type === 'input_json_delta' && block?.kind === 'tool_call') {
|
|
522
|
+
block.argumentsJson += delta.partial_json
|
|
523
|
+
return [{
|
|
524
|
+
type: 'tool_call_delta',
|
|
525
|
+
index,
|
|
526
|
+
delta: delta.partial_json,
|
|
527
|
+
partial: buildAssistantMessage(state),
|
|
528
|
+
}]
|
|
529
|
+
}
|
|
530
|
+
return []
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
case 'content_block_stop': {
|
|
534
|
+
const index = event.index
|
|
535
|
+
const block = state.blocks.get(index)
|
|
536
|
+
|
|
537
|
+
if (block?.kind === 'text') {
|
|
538
|
+
return [{
|
|
539
|
+
type: 'text_end',
|
|
540
|
+
index,
|
|
541
|
+
content: block.text,
|
|
542
|
+
partial: buildAssistantMessage(state),
|
|
543
|
+
}]
|
|
544
|
+
} else if (block?.kind === 'thinking') {
|
|
545
|
+
return [{
|
|
546
|
+
type: 'thinking_end',
|
|
547
|
+
index,
|
|
548
|
+
content: block.text,
|
|
549
|
+
partial: buildAssistantMessage(state),
|
|
550
|
+
}]
|
|
551
|
+
} else if (block?.kind === 'tool_call') {
|
|
552
|
+
return [{
|
|
553
|
+
type: 'tool_call_end',
|
|
554
|
+
index,
|
|
555
|
+
toolCall: toolCallFromParsed(block.id, block.name, block.argumentsJson),
|
|
556
|
+
partial: buildAssistantMessage(state),
|
|
557
|
+
}]
|
|
558
|
+
}
|
|
559
|
+
return []
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
case 'message_delta': {
|
|
563
|
+
state.outputTokens = event.usage.output_tokens
|
|
564
|
+
// RFC-019 修复:message_delta 的 usage 是累积快照,input/cache 字段在服务端工具
|
|
565
|
+
// (web_search/code_execution 等)产出后追加到 prompt 时会重新下发且与 message_start
|
|
566
|
+
// 不同(官方 streaming 文档 web_search 示例:input_tokens 2679→10682)。
|
|
567
|
+
// 官方 SDK 自身的 MessageStream 累积器(accumulateMessage)对这三个字段做同款的
|
|
568
|
+
// "非 null 才覆盖"合并——此前仅取 message_start 快照会在服务端工具场景下把
|
|
569
|
+
// inputTokens/cacheReadTokens/cacheWriteTokens 钉死在过时值,连带 R1 命中率算错。
|
|
570
|
+
if (event.usage.input_tokens != null) {
|
|
571
|
+
state.inputTokens = event.usage.input_tokens
|
|
572
|
+
}
|
|
573
|
+
if (event.usage.cache_creation_input_tokens != null) {
|
|
574
|
+
state.cacheWriteTokens = event.usage.cache_creation_input_tokens
|
|
575
|
+
}
|
|
576
|
+
if (event.usage.cache_read_input_tokens != null) {
|
|
577
|
+
state.cacheReadTokens = event.usage.cache_read_input_tokens
|
|
578
|
+
}
|
|
579
|
+
state.stopReason = mapStopReason(event.delta.stop_reason)
|
|
580
|
+
return []
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
case 'message_stop':
|
|
584
|
+
return []
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* 请求定制点入参(RFC-140 D-copilot-spoof-split:厂商专属行为下沉插件)。
|
|
590
|
+
* `auth` 供定制器判断鉴权模式(如"仅 OAuth 模式才定制");`sessionId` 是本
|
|
591
|
+
* provider 实例进程内稳定的会话标识(同一实例每次请求复用同一个值)。
|
|
592
|
+
*/
|
|
593
|
+
export interface AnthropicRequestCustomizeInput {
|
|
594
|
+
auth: ResolvedAuth
|
|
595
|
+
sessionId: string
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* system 文本块的最小结构类型(有意不用 `Anthropic.TextBlockParam` SDK 具体类型):
|
|
600
|
+
* 定制器的宿主实现方(如 `packages/coding/src/plugin/`)不该被迫依赖 `@anthropic-ai/sdk`
|
|
601
|
+
* 只为了满足一个类型签名——两者结构兼容(本类型字段是 `Anthropic.TextBlockParam` 的子集),
|
|
602
|
+
* 调用方传入/接收 SDK 真实类型的值时无需转换,TypeScript 结构化类型系统天然兼容。
|
|
603
|
+
*/
|
|
604
|
+
export interface AnthropicSystemTextBlock {
|
|
605
|
+
type: 'text'
|
|
606
|
+
text: string
|
|
607
|
+
cache_control?: unknown
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** 定制点可覆盖的三个请求维度——与真实 SDK 调用形状对齐,均可选、缺省不改变默认行为。 */
|
|
611
|
+
export interface AnthropicRequestCustomization {
|
|
612
|
+
/** 附加/覆盖的 SDK 客户端默认请求头(如身份伪装头)。 */
|
|
613
|
+
defaultHeaders?: Record<string, string>
|
|
614
|
+
/** system 数组变换(如插入身份前缀块);接收当前 blocks,返回替换后的 blocks。 */
|
|
615
|
+
transformSystemBlocks?: (
|
|
616
|
+
blocks: AnthropicSystemTextBlock[] | undefined,
|
|
617
|
+
) => AnthropicSystemTextBlock[] | undefined
|
|
618
|
+
/** 附加的 body.metadata 字段(如 user_id 结构)。 */
|
|
619
|
+
metadata?: Record<string, unknown>
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export interface AnthropicProviderOptions {
|
|
623
|
+
resolveKey?: AnthropicCredentialResolver
|
|
624
|
+
resolveAuth?: AnthropicAuthResolver
|
|
625
|
+
resolveBaseUrl?: AnthropicBaseUrlResolver
|
|
626
|
+
/**
|
|
627
|
+
* 请求定制点(RFC-140 D-copilot-spoof-split,取代此前的 `enableClaudeCodeSpoof` 布尔开关):
|
|
628
|
+
* `createAnthropicProvider` 是**任意**声明 `wireApi: 'anthropic-messages'` 的插件共用的
|
|
629
|
+
* 通用协议工厂(`provider-loader.ts` `buildFactory`)——本文件不认识"Claude Code"这个
|
|
630
|
+
* 具体厂商概念,只暴露一个通用回调,由调用方(插件)决定要不要、如何定制请求(伪装
|
|
631
|
+
* 身份头/system 前缀/metadata,或其他厂商特定需求)。
|
|
632
|
+
*
|
|
633
|
+
* `extensions/plugin-anthropic` 通过此参数注入 Claude Code 客户端伪装逻辑(绕 Anthropic
|
|
634
|
+
* 对订阅 OAuth token 的客户端分类器限流)——伪装成官方 Claude Code CLI 这一行为只对
|
|
635
|
+
* "连接 api.anthropic.com、用 Claude Pro/Max 订阅 OAuth token"这一具体场景成立且必要。
|
|
636
|
+
* 第三方 `anthropic-messages` 兼容端点(代理网关、企业自建实例等)不传本参数,默认零定制、
|
|
637
|
+
* 零伪装——不再需要显式传 `false` 来"关闭"(本就没有内置的厂商专属行为可关)。
|
|
638
|
+
*/
|
|
639
|
+
customizeRequest?: (input: AnthropicRequestCustomizeInput) => AnthropicRequestCustomization
|
|
640
|
+
/**
|
|
641
|
+
* RFC-270 缺陷 1(P0):静态额外 headers(如 manifest 声明的 `x-llm-appcode` 企业网关
|
|
642
|
+
* 路由标识)。此前本 Options **完全没有这个字段**——`plugin.ts` 的 adapter 也因此未透传
|
|
643
|
+
* `input.headers`,声明式 provider 给本协议写的 headers 被静默丢弃且不报错。该缺陷长期
|
|
644
|
+
* 未被发现,是因为唯一的真实用例 `anthropic-version` 恰好被 @anthropic-ai/sdk 自身硬编码
|
|
645
|
+
* 补上(`client.js` buildRequest),掩盖了丢弃。
|
|
646
|
+
*/
|
|
647
|
+
headers?: Record<string, string>
|
|
648
|
+
/** RFC-122 D3:JWT claim 派生 headers(同缺陷 1,此前本协议未支持)。 */
|
|
649
|
+
tokenDerivedHeaders?: TokenDerivedHeader[]
|
|
650
|
+
/** RFC-270 D3:manifest 显式声明后,允许自定义头覆盖鉴权头(企业网关换鉴权)。 */
|
|
651
|
+
allowAuthHeaderOverride?: boolean
|
|
652
|
+
/** RFC-185 D3:宿主注入的日志端口,缺省 no-op(见文件头 noopLogger 注释)。 */
|
|
653
|
+
logger?: WireProtocolLogger
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
class AnthropicStream implements ProviderStream {
|
|
657
|
+
readonly id = 'anthropic-messages'
|
|
658
|
+
readonly displayName = 'Anthropic'
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* 进程内稳定的会话 id——供 `customizeRequest` 定制器使用(如订阅 OAuth 形状匹配的
|
|
662
|
+
* X-Claude-Code-Session-Id / metadata)。本文件不解读其含义,只透传给定制器。
|
|
663
|
+
*/
|
|
664
|
+
private readonly requestSessionId = randomUUID()
|
|
665
|
+
|
|
666
|
+
constructor(
|
|
667
|
+
private readonly resolveCredential?: AnthropicCredentialResolver,
|
|
668
|
+
private readonly baseUrlResolver?: AnthropicBaseUrlResolver,
|
|
669
|
+
private readonly authResolver?: AnthropicAuthResolver,
|
|
670
|
+
/** 见 `AnthropicProviderOptions.customizeRequest` 注释。缺省 = 零定制。 */
|
|
671
|
+
private readonly customizeRequest?: (input: AnthropicRequestCustomizeInput) => AnthropicRequestCustomization,
|
|
672
|
+
private readonly logger: WireProtocolLogger = noopLogger,
|
|
673
|
+
/** 见 `AnthropicProviderOptions.headers`(RFC-270 缺陷 1 修复)。 */
|
|
674
|
+
private readonly staticHeaders?: Record<string, string>,
|
|
675
|
+
private readonly tokenDerivedHeaders?: TokenDerivedHeader[],
|
|
676
|
+
private readonly allowAuthHeaderOverride?: boolean,
|
|
677
|
+
) {}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* 统一鉴权解析(RFC-140 D5,`ProviderStream.resolveAuth`):优先 resolveAuth(含
|
|
681
|
+
* mode/beta);回退到旧的字符串 resolveKey(视为 api_key);最后回退 ANTHROPIC_API_KEY env。
|
|
682
|
+
*/
|
|
683
|
+
async resolveAuth(_model: Model): Promise<ResolvedAuth> {
|
|
684
|
+
if (this.authResolver) {
|
|
685
|
+
const auth = await this.authResolver()
|
|
686
|
+
if (auth?.token) {
|
|
687
|
+
return auth
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (this.resolveCredential) {
|
|
692
|
+
const key = await this.resolveCredential()
|
|
693
|
+
if (key) {
|
|
694
|
+
return { token: key, mode: 'api_key' }
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const envKey = process.env['ANTHROPIC_API_KEY']
|
|
699
|
+
if (envKey) {
|
|
700
|
+
return { token: envKey, mode: 'api_key' }
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
throw new ProviderError(
|
|
704
|
+
'Missing Anthropic API key. Set ANTHROPIC_API_KEY or configure via auth file.',
|
|
705
|
+
{ code: 'PROVIDER_AUTH_MISSING' },
|
|
706
|
+
)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
private async buildRequestParams(
|
|
710
|
+
model: Model,
|
|
711
|
+
context: StreamContext,
|
|
712
|
+
auth: ResolvedAuth,
|
|
713
|
+
): Promise<{ client: Anthropic; requestParams: Anthropic.MessageStreamParams; baseURL: string }> {
|
|
714
|
+
const baseURL = await resolveBaseUrl(model.baseUrl, this.baseUrlResolver)
|
|
715
|
+
// RFC-140 D-copilot-spoof-split:本文件不再判断"是否 oauth 才伪装"——那是具体厂商
|
|
716
|
+
// (如 plugin-anthropic 的 Claude Code 伪装)的业务逻辑,交给 customizeRequest 定制器
|
|
717
|
+
// 自行按 input.auth.mode 判断要不要返回定制内容。未提供定制器 = 零调用零开销。
|
|
718
|
+
const customization = this.customizeRequest?.({ auth, sessionId: this.requestSessionId })
|
|
719
|
+
|
|
720
|
+
/*
|
|
721
|
+
* RFC-270 缺陷 1(P0)+ D1:统一头管线,**filter-output 形态**。
|
|
722
|
+
*
|
|
723
|
+
* 本协议经 @anthropic-ai/sdk 出站,鉴权由 SDK 构造参数(apiKey/authToken)承担、不进
|
|
724
|
+
* 管线——因此不传 `auth` 档。鉴权保护必须靠"交给 SDK 前剥离"而非"最后写入":SDK 内部
|
|
725
|
+
* 合并链是 `内部默认 < authHeaders < defaultHeaders < options.headers`(后写覆盖,见
|
|
726
|
+
* @anthropic-ai/sdk internal/headers.js),otto 交给它的 defaultHeaders 排在 SDK 鉴权头
|
|
727
|
+
* **之后**、能覆盖它,write-last 在这条路径物理不成立。管线在 `auth` 缺省时照常执行
|
|
728
|
+
* ②③④ 档的鉴权族剥离,正是为此。
|
|
729
|
+
*
|
|
730
|
+
* 两个 auth 分支统一走同一份头(此前仅 oauth 分支传 defaultHeaders,api_key 分支完全
|
|
731
|
+
* 不传——即使定制器返回了头也会丢,是缺陷 1 的次生项)。
|
|
732
|
+
*/
|
|
733
|
+
const defaultHeaders = buildRequestHeaders({
|
|
734
|
+
staticHeaders: this.staticHeaders,
|
|
735
|
+
tokenDerived: this.tokenDerivedHeaders?.length
|
|
736
|
+
? { headers: this.tokenDerivedHeaders, token: auth.token }
|
|
737
|
+
: undefined,
|
|
738
|
+
customized: customization?.defaultHeaders,
|
|
739
|
+
allowAuthHeaderOverride: this.allowAuthHeaderOverride,
|
|
740
|
+
logger: this.logger,
|
|
741
|
+
diagnosticLabel: `anthropic-messages:${model.provider}`,
|
|
742
|
+
})
|
|
743
|
+
const headerOptions = Object.keys(defaultHeaders).length > 0 ? { defaultHeaders } : {}
|
|
744
|
+
|
|
745
|
+
// 代理接线:**刻意不在这里传 `fetchOptions.dispatcher`**(RFC-288 D0a 修订,重要事项规则 11)。
|
|
746
|
+
//
|
|
747
|
+
// 本仓的代理由 `installGlobalProxyDispatcher`(`@x-otto/shared`)经 undici 全局 dispatcher
|
|
748
|
+
// 统一接管,Anthropic SDK 内部的 `fetch` 自动经代理,无需此处显式传参——已在真实 Node
|
|
749
|
+
// 运行时实测确认(非仅测试环境)。
|
|
750
|
+
//
|
|
751
|
+
// 为什么不能传:Node 22 内置 fetch 用的是 Node 自带的 undici **6.23**
|
|
752
|
+
// (`process.versions.undici`),而 `@x-otto/shared` 依赖的是 undici **8.7**。
|
|
753
|
+
// `createDispatcher()` 造出的是 8.7 的 `ProxyAgent` 实例,把它交给内置 fetch 会抛
|
|
754
|
+
// `UND_ERR_INVALID_ARG: invalid onRequestStart method`——**配了代理的用户会直接请求失败**,
|
|
755
|
+
// 比"不走代理"更糟。`setGlobalDispatcher` 之所以能跨版本工作,是因为它写的是 Node 的
|
|
756
|
+
// `globalThis` 共享槽;per-request 的 `dispatcher` 走的却是严格实例校验,两者不同路。
|
|
757
|
+
//
|
|
758
|
+
// 若将来要恢复 per-client 接线,必须先让 shared 的 undici 与 Node 内置版本对齐,
|
|
759
|
+
// 并补一条**真发请求**的回归(只断言构造参数抓不到这类失败——这正是本缺陷曾溜过 M0 的原因)。
|
|
760
|
+
const client =
|
|
761
|
+
auth.mode === 'oauth'
|
|
762
|
+
? new Anthropic({
|
|
763
|
+
apiKey: null,
|
|
764
|
+
authToken: auth.token,
|
|
765
|
+
baseURL,
|
|
766
|
+
maxRetries: 0,
|
|
767
|
+
...headerOptions,
|
|
768
|
+
})
|
|
769
|
+
: new Anthropic({
|
|
770
|
+
apiKey: auth.token,
|
|
771
|
+
baseURL,
|
|
772
|
+
maxRetries: 0,
|
|
773
|
+
...headerOptions,
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
const cacheControl = cacheControlFor(context.cacheRetention)
|
|
777
|
+
|
|
778
|
+
const tools: Anthropic.Tool[] = context.tools
|
|
779
|
+
? context.tools.map((t) => ({
|
|
780
|
+
name: t.name,
|
|
781
|
+
description: t.description,
|
|
782
|
+
input_schema: (t.parameters.toJSONSchema?.() ?? {}) as Anthropic.Tool['input_schema'],
|
|
783
|
+
}))
|
|
784
|
+
: []
|
|
785
|
+
const lastTool = tools[tools.length - 1]
|
|
786
|
+
if (lastTool && cacheControl) {
|
|
787
|
+
lastTool.cache_control = cacheControl
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const effectiveMaxTokens = context.maxTokens ?? model.maxOutputTokens
|
|
791
|
+
const thinkingConfig = buildThinkingConfig(
|
|
792
|
+
// RFC-235:按模型声明的 thinkingLevels 降级后再算预算(clamp 单一真源)。
|
|
793
|
+
context.thinkingLevel ? clampThinkingLevel(context.thinkingLevel, model.thinkingLevels) : undefined,
|
|
794
|
+
model.reasoning,
|
|
795
|
+
effectiveMaxTokens,
|
|
796
|
+
model.thinkingMode,
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
let systemBlocks = buildSystemBlocks(
|
|
800
|
+
context.systemPrompt,
|
|
801
|
+
context.systemPromptSegments,
|
|
802
|
+
cacheControl,
|
|
803
|
+
)
|
|
804
|
+
if (customization?.transformSystemBlocks) {
|
|
805
|
+
// 定制点契约刻意用 AnthropicSystemTextBlock(SDK 无关最小结构)而非 Anthropic.TextBlockParam,
|
|
806
|
+
// 使宿主实现方(如 coding/plugin/)不必依赖 @anthropic-ai/sdk。两者结构兼容(前者是后者的
|
|
807
|
+
// 字段子集),此处是唯一需要接回 SDK 具体类型的边界点。
|
|
808
|
+
systemBlocks = customization.transformSystemBlocks(systemBlocks) as Anthropic.TextBlockParam[] | undefined
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const requestParams: Anthropic.MessageStreamParams = {
|
|
812
|
+
model: model.name,
|
|
813
|
+
max_tokens: effectiveMaxTokens,
|
|
814
|
+
...(systemBlocks ? { system: systemBlocks } : {}),
|
|
815
|
+
messages: withMessageCacheBreakpoint(buildAnthropicMessages(context.messages, model), cacheControl),
|
|
816
|
+
...(tools.length > 0 ? { tools } : {}),
|
|
817
|
+
...(context.temperature !== undefined && !model.reasoning
|
|
818
|
+
? { temperature: context.temperature }
|
|
819
|
+
: {}),
|
|
820
|
+
...(thinkingConfig ? { thinking: thinkingConfig } : {}),
|
|
821
|
+
// adaptive 模式:网关要求顶层 output_config.effort 控制推理投入(与 thinking.type.adaptive 配对)。
|
|
822
|
+
...(thinkingConfig?.type === 'adaptive' && context.thinkingLevel
|
|
823
|
+
? { output_config: { effort: toAdaptiveEffort(clampThinkingLevel(context.thinkingLevel, model.thinkingLevels)) } }
|
|
824
|
+
: {}),
|
|
825
|
+
...(customization?.metadata ? { metadata: customization.metadata } : {}),
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
return { client, requestParams, baseURL }
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async *converse(
|
|
832
|
+
model: Model,
|
|
833
|
+
context: StreamContext,
|
|
834
|
+
options: StreamOptions,
|
|
835
|
+
signal: AbortSignal,
|
|
836
|
+
): AsyncIterable<StreamEvent> {
|
|
837
|
+
const auth = await this.resolveAuth(model)
|
|
838
|
+
const { client, requestParams, baseURL } = await this.buildRequestParams(model, context, auth)
|
|
839
|
+
|
|
840
|
+
const state = initStreamState(model.id)
|
|
841
|
+
let started = false
|
|
842
|
+
|
|
843
|
+
this.logger.debug({ model: model.id, baseURL }, 'Starting Anthropic stream')
|
|
844
|
+
|
|
845
|
+
try {
|
|
846
|
+
const stream = client.messages.stream(requestParams, {
|
|
847
|
+
signal,
|
|
848
|
+
...(options.timeout !== undefined ? { timeout: options.timeout } : {}),
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
for await (const event of stream) {
|
|
852
|
+
if (!started) {
|
|
853
|
+
started = true
|
|
854
|
+
yield { type: 'start', partial: buildAssistantMessage(state) }
|
|
855
|
+
}
|
|
856
|
+
yield* dispatchStreamEvent(event, state)
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// 成功路径 usage 快照:仅 Anthropic OAuth 订阅计划的响应带 anthropic-ratelimit-unified-*
|
|
860
|
+
// 头族(API key 走标准 RPM/TPM,headers 里没有该头族 → extractProviderUsageFromHeaders
|
|
861
|
+
// 返回 null,天然实现"API key 不显示")。stream.response 在流正常结束后仍可访问
|
|
862
|
+
// (SDK MessageStream #response 一经 _connected() 设置不再清空,见调研)。
|
|
863
|
+
const providerUsage = extractProviderUsageFromHeaders(stream.response?.headers)
|
|
864
|
+
const doneMessage = buildAssistantMessage(state, true)
|
|
865
|
+
yield {
|
|
866
|
+
type: 'done',
|
|
867
|
+
reason: doneMessage.stopReason,
|
|
868
|
+
// RFC-232(终局修正):跨 wire 协议救回"模型把工具调用写成字面文本 XML"违约响应
|
|
869
|
+
// (见 stream-state.ts recoverLiteralInvokeToolCalls 头注释)。判据内部短路,
|
|
870
|
+
// 无违约态时零开销(引用相等,不构造新对象)。
|
|
871
|
+
message: recoverLiteralInvokeToolCalls(doneMessage, registeredToolNames(context.tools)),
|
|
872
|
+
providerUsage,
|
|
873
|
+
}
|
|
874
|
+
} catch (error) {
|
|
875
|
+
if (error instanceof Anthropic.APIError) {
|
|
876
|
+
yield {
|
|
877
|
+
type: 'error',
|
|
878
|
+
error: new ProviderError(`Anthropic API error ${error.status}: ${error.message}`, {
|
|
879
|
+
code: 'PROVIDER_API_ERROR',
|
|
880
|
+
cause: error,
|
|
881
|
+
// 把 SDK 的 HTTP status/headers 转成 ProviderError 的 typed 字段
|
|
882
|
+
// (CodedError.status/headers),使 classifyStreamError 直接读 typed、不必沿 cause 链下挖。
|
|
883
|
+
status: error.status,
|
|
884
|
+
headers: error.headers,
|
|
885
|
+
// SDK 解析响应体 `error.error.type`(如 "authentication_error"/"permission_error")
|
|
886
|
+
// 到 APIError.type——机器可读的失效根因分类,供 stream.auth-failed 记录"为什么失效"
|
|
887
|
+
// (而不只是"401 unauthorized"这种笼统文案,见 CodedError.errorType 注释)。
|
|
888
|
+
errorType: error.type ?? undefined,
|
|
889
|
+
}),
|
|
890
|
+
}
|
|
891
|
+
} else if (error instanceof Error) {
|
|
892
|
+
yield { type: 'error', error }
|
|
893
|
+
} else {
|
|
894
|
+
yield {
|
|
895
|
+
type: 'error',
|
|
896
|
+
error: new ProviderError('Unknown Anthropic stream error', {
|
|
897
|
+
code: 'PROVIDER_STREAM_ERROR',
|
|
898
|
+
}),
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
export function createAnthropicProvider(options: AnthropicProviderOptions = {}): ProviderStream {
|
|
906
|
+
return new AnthropicStream(
|
|
907
|
+
options.resolveKey,
|
|
908
|
+
options.resolveBaseUrl,
|
|
909
|
+
options.resolveAuth,
|
|
910
|
+
options.customizeRequest,
|
|
911
|
+
options.logger,
|
|
912
|
+
options.headers,
|
|
913
|
+
options.tokenDerivedHeaders,
|
|
914
|
+
options.allowAuthHeaderOverride,
|
|
915
|
+
)
|
|
916
|
+
}
|