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.
- package/README.md +78 -0
- package/api/app.ts +53 -0
- package/api/index.ts +9 -0
- package/api/lib/logger.ts +65 -0
- package/api/lib/paths.ts +27 -0
- package/api/lib/providers.ts +66 -0
- package/api/lib/repository.ts +153 -0
- package/api/lib/types.ts +43 -0
- package/api/relay/config.ts +76 -0
- package/api/relay/protocol.ts +393 -0
- package/api/relay/server.ts +283 -0
- package/api/routes/backups.ts +73 -0
- package/api/routes/config.ts +197 -0
- package/api/routes/install.ts +158 -0
- package/api/routes/logs.ts +20 -0
- package/api/routes/providers.ts +13 -0
- package/api/routes/relay.ts +106 -0
- package/api/server.ts +40 -0
- package/cli/cli.js +454 -0
- package/dist/assets/index-BjvGHrGe.js +156 -0
- package/dist/assets/index-CQrGCyBr.css +1 -0
- package/dist/favicon.svg +4 -0
- package/dist/index.html +20 -0
- package/index.html +19 -0
- package/nodemon.json +10 -0
- package/package.json +82 -0
- package/postcss.config.js +10 -0
- package/scripts/install.bat +32 -0
- package/scripts/start.bat +46 -0
- package/scripts/start.ps1 +45 -0
- package/src/App.tsx +73 -0
- package/src/assets/react.svg +1 -0
- package/src/components/ConsolePanel.tsx +44 -0
- package/src/components/Empty.tsx +8 -0
- package/src/components/ErrorBoundary.tsx +54 -0
- package/src/components/Layout.tsx +17 -0
- package/src/components/Page.tsx +130 -0
- package/src/components/Sidebar.tsx +56 -0
- package/src/hooks/useTheme.ts +29 -0
- package/src/index.css +1350 -0
- package/src/lib/api.ts +120 -0
- package/src/lib/store.ts +166 -0
- package/src/lib/types.ts +117 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +10 -0
- package/src/pages/ConsolePage.tsx +48 -0
- package/src/pages/Dashboard.tsx +101 -0
- package/src/pages/Install.tsx +195 -0
- package/src/pages/Relay.tsx +409 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +13 -0
- package/tsconfig.json +40 -0
- 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
|
+
}
|