bhg-helper 1.0.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.
Files changed (53) hide show
  1. package/README.md +78 -0
  2. package/api/app.ts +53 -0
  3. package/api/index.ts +9 -0
  4. package/api/lib/logger.ts +65 -0
  5. package/api/lib/paths.ts +27 -0
  6. package/api/lib/providers.ts +66 -0
  7. package/api/lib/repository.ts +153 -0
  8. package/api/lib/types.ts +43 -0
  9. package/api/relay/config.ts +76 -0
  10. package/api/relay/protocol.ts +393 -0
  11. package/api/relay/server.ts +283 -0
  12. package/api/routes/backups.ts +73 -0
  13. package/api/routes/config.ts +197 -0
  14. package/api/routes/install.ts +158 -0
  15. package/api/routes/logs.ts +20 -0
  16. package/api/routes/providers.ts +13 -0
  17. package/api/routes/relay.ts +106 -0
  18. package/api/server.ts +40 -0
  19. package/cli/cli.js +454 -0
  20. package/dist/assets/index-BjvGHrGe.js +156 -0
  21. package/dist/assets/index-CQrGCyBr.css +1 -0
  22. package/dist/favicon.svg +4 -0
  23. package/dist/index.html +20 -0
  24. package/index.html +19 -0
  25. package/nodemon.json +10 -0
  26. package/package.json +82 -0
  27. package/postcss.config.js +10 -0
  28. package/scripts/install.bat +32 -0
  29. package/scripts/start.bat +46 -0
  30. package/scripts/start.ps1 +45 -0
  31. package/src/App.tsx +73 -0
  32. package/src/assets/react.svg +1 -0
  33. package/src/components/ConsolePanel.tsx +44 -0
  34. package/src/components/Empty.tsx +8 -0
  35. package/src/components/ErrorBoundary.tsx +54 -0
  36. package/src/components/Layout.tsx +17 -0
  37. package/src/components/Page.tsx +130 -0
  38. package/src/components/Sidebar.tsx +56 -0
  39. package/src/hooks/useTheme.ts +29 -0
  40. package/src/index.css +1350 -0
  41. package/src/lib/api.ts +120 -0
  42. package/src/lib/store.ts +166 -0
  43. package/src/lib/types.ts +117 -0
  44. package/src/lib/utils.ts +6 -0
  45. package/src/main.tsx +10 -0
  46. package/src/pages/ConsolePage.tsx +48 -0
  47. package/src/pages/Dashboard.tsx +101 -0
  48. package/src/pages/Install.tsx +195 -0
  49. package/src/pages/Relay.tsx +409 -0
  50. package/src/vite-env.d.ts +1 -0
  51. package/tailwind.config.js +13 -0
  52. package/tsconfig.json +40 -0
  53. package/vite.config.ts +28 -0
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Anthropic → OpenAI 兼容转换的工具函数
3
+ *
4
+ * 输入:Anthropic Messages API 格式
5
+ * 输出:OpenAI Chat Completions 格式
6
+ * 以及反向:OpenAI 响应 → Anthropic 响应
7
+ */
8
+
9
+ export interface AnthropicMessage {
10
+ role: 'user' | 'assistant'
11
+ content: string | AnthropicContentBlock[]
12
+ }
13
+
14
+ export type AnthropicContentBlock =
15
+ | { type: 'text'; text: string }
16
+ | { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }
17
+ | { type: 'tool_use'; id: string; name: string; input: unknown }
18
+ | { type: 'tool_result'; tool_use_id: string; content: string | unknown[] }
19
+
20
+ export interface AnthropicRequest {
21
+ model: string
22
+ messages: AnthropicMessage[]
23
+ system?: string | Array<{ type: 'text'; text: string; cache_control?: unknown }>
24
+ max_tokens?: number
25
+ temperature?: number
26
+ top_p?: number
27
+ stop_sequences?: string[]
28
+ stream?: boolean
29
+ tools?: Array<{ name: string; description?: string; input_schema: unknown }>
30
+ tool_choice?: unknown
31
+ metadata?: { user_id?: string }
32
+ }
33
+
34
+ export interface OpenAIMessage {
35
+ role: 'system' | 'user' | 'assistant' | 'tool'
36
+ content?: string | null
37
+ name?: string
38
+ tool_calls?: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }>
39
+ tool_call_id?: string
40
+ }
41
+
42
+ export interface OpenAIRequest {
43
+ model: string
44
+ messages: OpenAIMessage[]
45
+ max_tokens?: number
46
+ temperature?: number
47
+ top_p?: number
48
+ stop?: string | string[]
49
+ stream?: boolean
50
+ tools?: Array<{
51
+ type: 'function'
52
+ function: { name: string; description?: string; parameters: unknown }
53
+ }>
54
+ tool_choice?: unknown
55
+ user?: string
56
+ }
57
+
58
+ // ============================================
59
+ // Request: Anthropic → OpenAI
60
+ // ============================================
61
+
62
+ export function anthropicToOpenAI(req: AnthropicRequest): OpenAIRequest {
63
+ const out: OpenAIRequest = {
64
+ model: req.model,
65
+ messages: [],
66
+ }
67
+
68
+ // system: 顶层的 system 字段 → messages[0] role=system
69
+ if (req.system) {
70
+ const sysText = Array.isArray(req.system)
71
+ ? req.system.map((s) => s.text).join('\n')
72
+ : req.system
73
+ if (sysText) {
74
+ out.messages.push({ role: 'system', content: sysText })
75
+ }
76
+ }
77
+
78
+ // messages 转换
79
+ for (const m of req.messages) {
80
+ if (typeof m.content === 'string') {
81
+ out.messages.push({ role: m.role, content: m.content })
82
+ continue
83
+ }
84
+ // content 是 block 数组
85
+ if (m.role === 'user') {
86
+ const textParts: string[] = []
87
+ const toolResults: OpenAIMessage[] = []
88
+ for (const b of m.content) {
89
+ if (b.type === 'text') {
90
+ textParts.push(b.text)
91
+ } else if (b.type === 'image') {
92
+ // DeepSeek 当前不支持图片,跳过或转 base64(这里我们暂时跳过避免 400)
93
+ textParts.push(`[图片内容已省略: ${b.source.media_type}]`)
94
+ } else if (b.type === 'tool_result') {
95
+ const c = typeof b.content === 'string' ? b.content : JSON.stringify(b.content)
96
+ toolResults.push({ role: 'tool', tool_call_id: b.tool_use_id, content: c })
97
+ }
98
+ }
99
+ if (textParts.length) {
100
+ out.messages.push({ role: 'user', content: textParts.join('\n') })
101
+ }
102
+ for (const tr of toolResults) out.messages.push(tr)
103
+ } else if (m.role === 'assistant') {
104
+ let text = ''
105
+ const toolCalls: NonNullable<OpenAIMessage['tool_calls']> = []
106
+ for (const b of m.content) {
107
+ if (b.type === 'text') text += b.text
108
+ else if (b.type === 'tool_use') {
109
+ toolCalls.push({
110
+ id: b.id,
111
+ type: 'function',
112
+ function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) },
113
+ })
114
+ }
115
+ }
116
+ const msg: OpenAIMessage = { role: 'assistant' }
117
+ if (text) msg.content = text
118
+ else msg.content = null
119
+ if (toolCalls.length) msg.tool_calls = toolCalls
120
+ out.messages.push(msg)
121
+ }
122
+ }
123
+
124
+ if (req.max_tokens !== undefined) out.max_tokens = req.max_tokens
125
+ if (req.temperature !== undefined) out.temperature = req.temperature
126
+ if (req.top_p !== undefined) out.top_p = req.top_p
127
+ if (req.stop_sequences?.length) out.stop = req.stop_sequences
128
+ if (req.stream !== undefined) out.stream = req.stream
129
+ if (req.tools?.length) {
130
+ out.tools = req.tools.map((t) => ({
131
+ type: 'function',
132
+ function: { name: t.name, description: t.description, parameters: t.input_schema },
133
+ }))
134
+ }
135
+ if (req.tool_choice !== undefined) out.tool_choice = req.tool_choice as never
136
+ if (req.metadata?.user_id) out.user = req.metadata.user_id
137
+
138
+ // DeepSeek V4 默认开启思考模式,会消耗大量 token 给 reasoning,
139
+ // 经常导致 content 为空。claude.exe 没传 reasoning_effort 时强制关掉
140
+ const openAiRequest = out as unknown as Record<string, unknown>
141
+ if (!('reasoning_effort' in openAiRequest) && !('thinking' in openAiRequest)) {
142
+ openAiRequest.thinking = { type: 'disabled' }
143
+ }
144
+
145
+ return out
146
+ }
147
+
148
+ // ============================================
149
+ // Response: OpenAI → Anthropic
150
+ // ============================================
151
+
152
+ export interface AnthropicResponse {
153
+ id: string
154
+ type: 'message'
155
+ role: 'assistant'
156
+ content: AnthropicContentBlock[]
157
+ model: string
158
+ stop_reason: 'end_turn' | 'max_tokens' | 'stop_sequence' | 'tool_use' | null
159
+ stop_sequence: string | null
160
+ usage: {
161
+ input_tokens: number
162
+ output_tokens: number
163
+ cache_read_input_tokens?: number
164
+ cache_creation_input_tokens?: number
165
+ }
166
+ }
167
+
168
+ export function openAIToAnthropic(resp: {
169
+ id?: string
170
+ model: string
171
+ choices: Array<{
172
+ index: number
173
+ message: { role: string; content: string | null; tool_calls?: unknown }
174
+ finish_reason: string | null
175
+ }>
176
+ usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
177
+ }): AnthropicResponse {
178
+ const choice = resp.choices[0]
179
+ const content: AnthropicContentBlock[] = []
180
+ // 兼容 DeepSeek 思考模式:优先用 reasoning_content(如果 content 是空)
181
+ const reasoning = (choice?.message as { reasoning_content?: string })?.reasoning_content
182
+ const mainContent = choice?.message?.content
183
+ const text = mainContent || (reasoning && !mainContent ? `<think>\n${reasoning}\n</think>` : '')
184
+ if (text) {
185
+ content.push({ type: 'text', text })
186
+ }
187
+ // 工具调用支持(DeepSeek 当前不返回,但保持兼容)
188
+ const tc = (choice?.message as { tool_calls?: Array<{ id: string; function: { name: string; arguments: string } }> })?.tool_calls
189
+ if (tc?.length) {
190
+ for (const t of tc) {
191
+ let input: unknown = {}
192
+ try { input = JSON.parse(t.function.arguments) } catch { input = {} }
193
+ content.push({ type: 'tool_use', id: t.id, name: t.function.name, input })
194
+ }
195
+ }
196
+
197
+ const stopReason = mapStopReason(choice?.finish_reason ?? null, !!tc?.length)
198
+
199
+ return {
200
+ id: resp.id ?? `msg_${Date.now()}`,
201
+ type: 'message',
202
+ role: 'assistant',
203
+ content,
204
+ model: resp.model,
205
+ stop_reason: stopReason,
206
+ stop_sequence: null,
207
+ usage: {
208
+ input_tokens: resp.usage?.prompt_tokens ?? 0,
209
+ output_tokens: resp.usage?.completion_tokens ?? 0,
210
+ },
211
+ }
212
+ }
213
+
214
+ function mapStopReason(
215
+ fr: string | null,
216
+ hasTool: boolean
217
+ ): AnthropicResponse['stop_reason'] {
218
+ if (hasTool) return 'tool_use'
219
+ if (fr === 'length') return 'max_tokens'
220
+ if (fr === 'stop' || fr === null) return 'end_turn'
221
+ if (fr === 'content_filter') return 'end_turn'
222
+ return 'end_turn'
223
+ }
224
+
225
+ // ============================================
226
+ // Streaming: OpenAI chunk → Anthropic SSE events
227
+ // ============================================
228
+
229
+ export interface AnthropicStreamEvent {
230
+ type:
231
+ | 'message_start'
232
+ | 'content_block_start'
233
+ | 'ping'
234
+ | 'content_block_delta'
235
+ | 'content_block_stop'
236
+ | 'message_delta'
237
+ | 'message_stop'
238
+ | 'error'
239
+ [key: string]: unknown
240
+ }
241
+
242
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
243
+
244
+ function makeId(): string {
245
+ // 用 crypto.randomUUID 如果存在
246
+ if (typeof globalThis.crypto?.randomUUID === 'function') {
247
+ return globalThis.crypto.randomUUID()
248
+ }
249
+ // fallback
250
+ return 'msg_' + Math.random().toString(36).slice(2)
251
+ }
252
+
253
+ export function* openAIStreamToAnthropicEvents(
254
+ model: string,
255
+ openAIChunks: Array<{
256
+ id?: string
257
+ model?: string
258
+ choices?: Array<{
259
+ index: number
260
+ delta: {
261
+ role?: string
262
+ content?: string | null
263
+ tool_calls?: Array<{
264
+ index: number
265
+ id?: string
266
+ function?: { name?: string; arguments?: string }
267
+ }>
268
+ }
269
+ finish_reason?: string | null
270
+ }>
271
+ usage?: { prompt_tokens?: number; completion_tokens?: number }
272
+ }>
273
+ ): Generator<AnthropicStreamEvent> {
274
+ const msgId = makeId()
275
+ const blockIndex = 0
276
+ let totalOutput = 0
277
+ let inputTokens = 0
278
+ let finishReason: string | null = null
279
+ let hasText = false
280
+ let toolBlockIndex = -1
281
+ let toolInputBuffer = ''
282
+ let toolId: string | null = null
283
+ let toolName: string | null = null
284
+ const usedModel = openAIChunks[0]?.model ?? model
285
+
286
+ // message_start
287
+ yield {
288
+ type: 'message_start',
289
+ message: {
290
+ id: msgId,
291
+ type: 'message',
292
+ role: 'assistant',
293
+ content: [],
294
+ model: usedModel,
295
+ stop_reason: null,
296
+ stop_sequence: null,
297
+ usage: { input_tokens: 0, output_tokens: 0 },
298
+ },
299
+ }
300
+ yield { type: 'ping' }
301
+
302
+ // 先假定输出文本 block
303
+ // 这里我们采用"先开 text block,遇到 tool_call 时 close + 开 tool_use block"的策略
304
+ // 但 DeepSeek 暂时不会输出 tool_call,所以先开一个 text block
305
+
306
+ let textBlockOpen = false
307
+ function openTextBlockIfNeeded(): AnthropicStreamEvent | null {
308
+ if (!textBlockOpen) {
309
+ textBlockOpen = true
310
+ hasText = true
311
+ return { type: 'content_block_start', index: blockIndex, content_block: { type: 'text', text: '' } }
312
+ }
313
+ return null
314
+ }
315
+
316
+ for (const chunk of openAIChunks) {
317
+ const choice = chunk.choices?.[0]
318
+ if (!choice) continue
319
+ if (chunk.usage?.prompt_tokens) inputTokens = chunk.usage.prompt_tokens
320
+ if (chunk.usage?.completion_tokens) totalOutput = chunk.usage.completion_tokens
321
+
322
+ const delta = choice.delta
323
+ const reasoningDelta = (delta as { reasoning_content?: string })?.reasoning_content
324
+ if (delta?.content || reasoningDelta) {
325
+ const ev = openTextBlockIfNeeded()
326
+ if (ev) yield ev
327
+ // 思考模式时也把推理内容输出(前面打 <think> 标签以区分)
328
+ if (reasoningDelta && !delta?.content) {
329
+ yield {
330
+ type: 'content_block_delta',
331
+ index: blockIndex,
332
+ delta: { type: 'text_delta', text: reasoningDelta },
333
+ }
334
+ } else if (delta?.content) {
335
+ yield {
336
+ type: 'content_block_delta',
337
+ index: blockIndex,
338
+ delta: { type: 'text_delta', text: delta.content },
339
+ }
340
+ }
341
+ }
342
+ if (delta?.tool_calls?.length) {
343
+ // 简化处理:累计到一段再开 tool_use block
344
+ for (const tc of delta.tool_calls) {
345
+ if (textBlockOpen) {
346
+ yield { type: 'content_block_stop', index: blockIndex }
347
+ textBlockOpen = false
348
+ }
349
+ if (tc.id) toolId = tc.id
350
+ if (tc.function?.name) {
351
+ toolName = tc.function.name
352
+ toolBlockIndex++
353
+ yield {
354
+ type: 'content_block_start',
355
+ index: toolBlockIndex,
356
+ content_block: { type: 'tool_use', id: toolId, name: toolName, input: {} },
357
+ }
358
+ }
359
+ if (tc.function?.arguments) {
360
+ toolInputBuffer += tc.function.arguments
361
+ yield {
362
+ type: 'content_block_delta',
363
+ index: toolBlockIndex,
364
+ delta: { type: 'input_json_delta', partial_json: tc.function.arguments },
365
+ }
366
+ }
367
+ }
368
+ }
369
+ if (choice.finish_reason) {
370
+ finishReason = choice.finish_reason
371
+ }
372
+ }
373
+
374
+ if (textBlockOpen) {
375
+ yield { type: 'content_block_stop', index: blockIndex }
376
+ }
377
+ if (toolBlockIndex >= 0) {
378
+ yield { type: 'content_block_stop', index: toolBlockIndex }
379
+ }
380
+
381
+ const stopReason = mapStopReason(finishReason, toolBlockIndex >= 0)
382
+ yield {
383
+ type: 'message_delta',
384
+ delta: { stop_reason: stopReason, stop_sequence: null },
385
+ usage: { output_tokens: totalOutput },
386
+ }
387
+ yield {
388
+ type: 'message_stop',
389
+ usage: { input_tokens: inputTokens, output_tokens: totalOutput },
390
+ }
391
+ // keep hasText referenced (otherwise tsc will complain)
392
+ void hasText
393
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * 中转服务 HTTP 进程
3
+ *
4
+ * 监听 localhost:<port>,对外提供 Anthropic Messages API 兼容端点。
5
+ * 收到请求后翻译成 OpenAI Chat Completions,转发给 DeepSeek,再把响应
6
+ * (含流式 SSE)翻译回 Anthropic 事件后返回。
7
+ */
8
+ import http, { type IncomingMessage, type ServerResponse } from 'node:http'
9
+ import { AnthropicRequest, anthropicToOpenAI, openAIToAnthropic, openAIStreamToAnthropicEvents } from './protocol.js'
10
+ import { log } from '../lib/logger.js'
11
+ import { loadRelayConfig, type RelayConfig } from './config.js'
12
+
13
+ let currentServer: http.Server | null = null
14
+ let currentConfig: RelayConfig | null = null
15
+
16
+ export function isRelayRunning(): boolean {
17
+ return currentServer?.listening ?? false
18
+ }
19
+
20
+ export function getRelayInfo(): { port: number; running: boolean; baseUrl: string; modelMap: Record<string, string>; deepseekApiKey: string } | null {
21
+ if (!currentConfig) return null
22
+ return {
23
+ port: currentConfig.port,
24
+ running: isRelayRunning(),
25
+ baseUrl: currentConfig.deepseekBaseUrl,
26
+ modelMap: currentConfig.modelMap,
27
+ deepseekApiKey: currentConfig.deepseekApiKey ? maskKey(currentConfig.deepseekApiKey) : '',
28
+ }
29
+ }
30
+
31
+ function maskKey(k: string): string {
32
+ if (k.length <= 8) return '••••'
33
+ return k.slice(0, 4) + '••••' + k.slice(-4)
34
+ }
35
+
36
+ export async function startRelay(): Promise<RelayConfig> {
37
+ if (currentServer?.listening) {
38
+ log('WARN', 'relay', 'already running')
39
+ return currentConfig!
40
+ }
41
+ const cfg = await loadRelayConfig()
42
+ currentConfig = cfg
43
+ currentServer = http.createServer(handler)
44
+ await new Promise<void>((resolve, reject) => {
45
+ const onErr = (e: Error) => reject(e)
46
+ currentServer!.once('error', onErr)
47
+ currentServer!.listen(cfg.port, '127.0.0.1', () => {
48
+ currentServer!.off('error', onErr)
49
+ log('OK', 'relay', `listening on http://127.0.0.1:${cfg.port} (upstream: ${cfg.deepseekBaseUrl})`)
50
+ resolve()
51
+ })
52
+ })
53
+ return cfg
54
+ }
55
+
56
+ export async function stopRelay(): Promise<void> {
57
+ if (!currentServer) return
58
+ await new Promise<void>((resolve) => currentServer!.close(() => resolve()))
59
+ currentServer = null
60
+ log('OK', 'relay', 'stopped')
61
+ }
62
+
63
+ export async function restartRelay(): Promise<RelayConfig> {
64
+ if (currentServer) await stopRelay()
65
+ return startRelay()
66
+ }
67
+
68
+ async function readBody(req: IncomingMessage): Promise<string> {
69
+ return new Promise((resolve, reject) => {
70
+ const chunks: Buffer[] = []
71
+ req.on('data', (c) => chunks.push(c))
72
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
73
+ req.on('error', reject)
74
+ })
75
+ }
76
+
77
+ function sendJson(res: ServerResponse, status: number, body: unknown): void {
78
+ const text = JSON.stringify(body, null, 2)
79
+ res.statusCode = status
80
+ res.setHeader('Content-Type', 'application/json')
81
+ res.setHeader('Content-Length', Buffer.byteLength(text))
82
+ res.end(text)
83
+ }
84
+
85
+ function sendAnthropicError(res: ServerResponse, status: number, type: string, message: string): void {
86
+ sendJson(res, status, { type: 'error', error: { type, message } })
87
+ }
88
+
89
+ async function handler(req: IncomingMessage, res: ServerResponse): Promise<void> {
90
+ const rawUrl = req.url ?? '/'
91
+ const [path] = rawUrl.split('?') // 把 ?beta=true 这种 query 剥掉
92
+ const method = req.method ?? 'GET'
93
+ // CORS
94
+ res.setHeader('Access-Control-Allow-Origin', '*')
95
+ res.setHeader('Access-Control-Allow-Headers', '*')
96
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
97
+ if (method === 'OPTIONS') {
98
+ res.statusCode = 204
99
+ res.end()
100
+ return
101
+ }
102
+
103
+ log('INFO', 'relay', `${method} ${rawUrl} from ${req.socket.remoteAddress}`)
104
+
105
+ if (method === 'GET' && path === '/health') {
106
+ sendJson(res, 200, { ok: true, data: { running: true, port: currentConfig?.port } })
107
+ return
108
+ }
109
+
110
+ if (method === 'GET' && path === '/v1/models') {
111
+ // Claude Code 用 Anthropic SDK 解析这个端点。
112
+ // Anthropic 格式:{ data: [{ id, display_name, type: "model", created_at }] }
113
+ // 为了兼容可能的旧 client,也补一份 OpenAI 字段(id/object/owned_by/created)。
114
+ const cfg = currentConfig!
115
+ const base = Date.now()
116
+ const data = Object.keys(cfg.modelMap).map((id, i) => {
117
+ const target = cfg.modelMap[id]
118
+ // 把 -cc / [1m] 之类剥掉,从真实名得到一个友好的 display_name
119
+ const display = id
120
+ .replace(/\[.*?\]$/, '')
121
+ .replace(/-cc$/, '')
122
+ .replace(/-v4-/, ' V4 ')
123
+ return {
124
+ // Anthropic
125
+ id,
126
+ display_name: display,
127
+ type: 'model',
128
+ created_at: new Date(base - i).toISOString(),
129
+ // OpenAI 兼容(部分 SDK 也会读)
130
+ object: 'model',
131
+ owned_by: cfg.spoofProvider,
132
+ created: Math.floor((base - i) / 1000),
133
+ // 调试用:真实转发的目标
134
+ target_model: target,
135
+ }
136
+ })
137
+ sendJson(res, 200, { data, object: 'list' })
138
+ return
139
+ }
140
+
141
+ if (method === 'POST' && path === '/v1/messages') {
142
+ return handleMessages(req, res)
143
+ }
144
+
145
+ // 兜底:404
146
+ sendAnthropicError(res, 404, 'not_found_error', `unknown route: ${method} ${rawUrl}`)
147
+ }
148
+
149
+ async function handleMessages(req: IncomingMessage, res: ServerResponse): Promise<void> {
150
+ const cfg = currentConfig!
151
+ if (!cfg.deepseekApiKey) {
152
+ sendAnthropicError(res, 500, 'configuration_error', 'deepseekApiKey not configured')
153
+ return
154
+ }
155
+
156
+ let body: AnthropicRequest
157
+ try {
158
+ const raw = await readBody(req)
159
+ body = JSON.parse(raw) as AnthropicRequest
160
+ } catch (err) {
161
+ const msg = err instanceof Error ? err.message : String(err)
162
+ sendAnthropicError(res, 400, 'invalid_request_error', `bad json: ${msg}`)
163
+ return
164
+ }
165
+
166
+ // 先按原名查;查不到就剥掉 [1m] / [200k] 这类上下文后缀再查一次
167
+ const requested = body.model
168
+ const stripped = requested.replace(/\[.*?\]$/, '')
169
+ const mappedModel = cfg.modelMap[requested] ?? cfg.modelMap[stripped] ?? stripped
170
+ const openaiBody = anthropicToOpenAI({ ...body, model: mappedModel })
171
+
172
+ if (cfg.verbose) {
173
+ const msgPreview = openaiBody.messages
174
+ .map((m) => (typeof m.content === 'string' ? m.content.slice(0, 80) : '[non-text]'))
175
+ .join(' | ')
176
+ log(
177
+ 'INFO',
178
+ 'relay',
179
+ `→ deepseek model=${mappedModel} stream=${!!openaiBody.stream} messages=${openaiBody.messages.length} preview="${msgPreview.slice(0, 120)}"`
180
+ )
181
+ }
182
+
183
+ // 调用 DeepSeek
184
+ const upstreamUrl = `${cfg.deepseekBaseUrl.replace(/\/$/, '')}/v1/chat/completions`
185
+ let upstreamRes: Response
186
+ try {
187
+ upstreamRes = await fetch(upstreamUrl, {
188
+ method: 'POST',
189
+ headers: {
190
+ 'Content-Type': 'application/json',
191
+ Authorization: `Bearer ${cfg.deepseekApiKey}`,
192
+ Accept: openaiBody.stream ? 'text/event-stream' : 'application/json',
193
+ },
194
+ body: JSON.stringify(openaiBody),
195
+ })
196
+ } catch (err) {
197
+ const msg = err instanceof Error ? err.message : String(err)
198
+ log('ERR', 'relay', `upstream fetch failed: ${msg}`)
199
+ sendAnthropicError(res, 502, 'upstream_error', `DeepSeek unreachable: ${msg}`)
200
+ return
201
+ }
202
+
203
+ if (!upstreamRes.ok) {
204
+ const errText = await upstreamRes.text().catch(() => '')
205
+ log('ERR', 'relay', `deepseek ${upstreamRes.status}: ${errText.slice(0, 200)}`)
206
+ sendJson(res, upstreamRes.status, { type: 'error', error: { type: 'upstream_error', message: errText } })
207
+ return
208
+ }
209
+
210
+ if (openaiBody.stream) {
211
+ await streamUpstreamToAnthropic(upstreamRes, res, mappedModel)
212
+ } else {
213
+ const json = (await upstreamRes.json()) as Parameters<typeof openAIToAnthropic>[0]
214
+ const anth = openAIToAnthropic(json)
215
+ res.statusCode = 200
216
+ res.setHeader('Content-Type', 'application/json')
217
+ res.end(JSON.stringify(anth))
218
+ log('OK', 'relay', `← ${anth.usage.input_tokens}+${anth.usage.output_tokens} tokens (model=${anth.model})`)
219
+ }
220
+ }
221
+
222
+ async function streamUpstreamToAnthropic(
223
+ upstream: Response,
224
+ res: ServerResponse,
225
+ model: string
226
+ ): Promise<void> {
227
+ res.statusCode = 200
228
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
229
+ res.setHeader('Cache-Control', 'no-cache')
230
+ res.setHeader('Connection', 'keep-alive')
231
+ res.setHeader('X-Accel-Buffering', 'no')
232
+
233
+ const reader = upstream.body?.getReader()
234
+ if (!reader) {
235
+ sendAnthropicError(res, 500, 'upstream_error', 'no body from deepseek')
236
+ return
237
+ }
238
+
239
+ const decoder = new TextDecoder('utf-8')
240
+ let buffer = ''
241
+ const chunks: Array<Parameters<typeof openAIStreamToAnthropicEvents>[1][number]> = []
242
+ let outTokens = 0
243
+
244
+ try {
245
+ while (true) {
246
+ const { value, done } = await reader.read()
247
+ if (done) break
248
+ buffer += decoder.decode(value, { stream: true })
249
+ let nlIdx: number
250
+ while ((nlIdx = buffer.indexOf('\n')) >= 0) {
251
+ const line = buffer.slice(0, nlIdx).replace(/\r$/, '')
252
+ buffer = buffer.slice(nlIdx + 1)
253
+ const trimmed = line.trim()
254
+ if (!trimmed.startsWith('data:')) continue
255
+ const data = trimmed.slice(5).trim()
256
+ if (data === '[DONE]') continue
257
+ try {
258
+ const chunk = JSON.parse(data)
259
+ chunks.push(chunk)
260
+ } catch {
261
+ // 忽略
262
+ }
263
+ }
264
+ }
265
+ } catch (err) {
266
+ const msg = err instanceof Error ? err.message : String(err)
267
+ log('ERR', 'relay', `stream read error: ${msg}`)
268
+ try { res.end() } catch { /* */ }
269
+ return
270
+ }
271
+
272
+ // 把累计的 chunks 转成 Anthropic 事件
273
+ for (const ev of openAIStreamToAnthropicEvents(model, chunks)) {
274
+ if (ev.type === 'message_delta') {
275
+ const u = (ev as { usage?: { output_tokens?: number } }).usage
276
+ if (u?.output_tokens) outTokens = u.output_tokens
277
+ }
278
+ res.write(`event: ${ev.type}\n`)
279
+ res.write(`data: ${JSON.stringify(ev)}\n\n`)
280
+ }
281
+ res.end()
282
+ log('OK', 'relay', `← stream done (out~${outTokens} tokens)`)
283
+ }