@wwkit/llmproxy 1.0.1
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/bin/index.js +119 -0
- package/package.json +43 -0
- package/scripts/postinstall.js +25 -0
- package/src/config.js +51 -0
- package/src/config.json5 +51 -0
- package/src/core/auth.js +35 -0
- package/src/core/error.js +39 -0
- package/src/core/factory.js +102 -0
- package/src/core/retry.js +114 -0
- package/src/core/route.js +59 -0
- package/src/core/sign.js +32 -0
- package/src/core/transport.js +117 -0
- package/src/ctl-impl.js +55 -0
- package/src/ctl.js +311 -0
- package/src/index.js +4 -0
- package/src/providers/codearts/auth.js +325 -0
- package/src/providers/codearts/client.js +64 -0
- package/src/providers/codearts/config.js +8 -0
- package/src/providers/codearts/error.js +17 -0
- package/src/providers/codearts/sign.js +75 -0
- package/src/server.js +250 -0
- package/src/set.js +154 -0
- package/src/util.js +47 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// src/server.js — llmproxy HTTP 入口
|
|
2
|
+
//
|
|
3
|
+
// 路由:
|
|
4
|
+
// GET /health
|
|
5
|
+
// GET /<provider>/v1/models
|
|
6
|
+
// POST /<provider>/v1/chat/completions # 必须指定 provider
|
|
7
|
+
//
|
|
8
|
+
// Provider 注册表来自 config.json5。每个 provider 拥有:
|
|
9
|
+
// - authProvider (AuthProvider): 拿凭证(含自动续期)
|
|
10
|
+
// - signProvider (SignProvider): 对请求做签名
|
|
11
|
+
// - errorPatterns (ErrorPatterns): 识别并发超限等
|
|
12
|
+
// - client (CodeartsClient 等): 封装"鉴权 + 签名 + fetch"
|
|
13
|
+
//
|
|
14
|
+
// 通用逻辑在 core/:
|
|
15
|
+
// - transport.js: SSE 透传 + body.cancel
|
|
16
|
+
// - retry.js: 参数化重试 + 冷却
|
|
17
|
+
// - route.js: URL path 解析
|
|
18
|
+
|
|
19
|
+
import http from 'node:http'
|
|
20
|
+
import {
|
|
21
|
+
config,
|
|
22
|
+
getProviderIds,
|
|
23
|
+
getProviderConfig,
|
|
24
|
+
} from './config.js'
|
|
25
|
+
import { log, readBody, json, openaiError } from './util.js'
|
|
26
|
+
import { probeBridge } from './ctl-impl.js'
|
|
27
|
+
import {
|
|
28
|
+
buildAuthProvider,
|
|
29
|
+
buildSignProvider,
|
|
30
|
+
buildErrorPatterns,
|
|
31
|
+
buildClient,
|
|
32
|
+
getBenefitModels,
|
|
33
|
+
} from './core/factory.js'
|
|
34
|
+
import {
|
|
35
|
+
streamSSE,
|
|
36
|
+
streamJSON,
|
|
37
|
+
clientGoneSignal,
|
|
38
|
+
writeUpstreamError,
|
|
39
|
+
} from './core/transport.js'
|
|
40
|
+
import { retryLoop, waitForCooldown } from './core/retry.js'
|
|
41
|
+
import { parseRoute, resolveChatProvider, notFound, methodNotAllowed } from './core/route.js'
|
|
42
|
+
|
|
43
|
+
// ---- Provider 注册:懒加载(async,首次调用时动态 import 模块)----
|
|
44
|
+
const providerRegistry = new Map()
|
|
45
|
+
|
|
46
|
+
async function getProviderBundle(providerId) {
|
|
47
|
+
if (providerRegistry.has(providerId)) return providerRegistry.get(providerId)
|
|
48
|
+
const providerConfig = getProviderConfig(providerId)
|
|
49
|
+
if (!providerConfig) {
|
|
50
|
+
throw new Error(`unknown provider: ${providerId}`)
|
|
51
|
+
}
|
|
52
|
+
const authProvider = await buildAuthProvider(providerId, providerConfig.auth)
|
|
53
|
+
const signProvider = await buildSignProvider(providerId, null) // 从模块 SIGN_CONFIG 加载
|
|
54
|
+
const errorPatterns = await buildErrorPatterns(providerId, null) // 从模块 ERROR_CONFIG 加载
|
|
55
|
+
const client = await buildClient(providerId, providerConfig, { authProvider, signProvider })
|
|
56
|
+
const benefitModels = await getBenefitModels(providerId)
|
|
57
|
+
const bundle = { providerConfig, authProvider, signProvider, errorPatterns, client, benefitModels }
|
|
58
|
+
providerRegistry.set(providerId, bundle)
|
|
59
|
+
return bundle
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- 全局状态 ----
|
|
63
|
+
let inflight = 0
|
|
64
|
+
let reqSeq = 0
|
|
65
|
+
const retryState = { cooldownUntil: 0 }
|
|
66
|
+
|
|
67
|
+
// ---- benefit 模型判断 ----
|
|
68
|
+
function isBenefitModel(benefitModels, modelId) {
|
|
69
|
+
return benefitModels.map(m => m.toLowerCase()).includes(String(modelId).toLowerCase())
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function handleProviderModels(res, providerId) {
|
|
73
|
+
const bundle = await getProviderBundle(providerId)
|
|
74
|
+
const data = bundle.providerConfig.models.map((id, i) => ({
|
|
75
|
+
id,
|
|
76
|
+
object: 'model',
|
|
77
|
+
owned_by: isBenefitModel(bundle.benefitModels, id) ? `${providerId}-benefit` : providerId,
|
|
78
|
+
sort: i,
|
|
79
|
+
}))
|
|
80
|
+
json(res, 200, { object: 'list', data })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---- POST /v1/<provider>/chat/completions ----
|
|
84
|
+
async function handleChat(req, res, providerId) {
|
|
85
|
+
let bundle
|
|
86
|
+
try {
|
|
87
|
+
bundle = await getProviderBundle(providerId)
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return openaiError(res, e.message, 404, 'invalid_request_error')
|
|
90
|
+
}
|
|
91
|
+
const id = ++reqSeq
|
|
92
|
+
inflight++
|
|
93
|
+
const t0 = Date.now()
|
|
94
|
+
log(`[chat#${id}] ++ provider=${providerId} inflight=${inflight} ua=${req.headers['user-agent'] || '?'}`)
|
|
95
|
+
const done = () => { inflight--; log(`[chat#${id}] -- inflight=${inflight} elapsed=${Date.now() - t0}ms`) }
|
|
96
|
+
|
|
97
|
+
let body
|
|
98
|
+
try {
|
|
99
|
+
body = JSON.parse(await readBody(req))
|
|
100
|
+
} catch {
|
|
101
|
+
done()
|
|
102
|
+
return openaiError(res, '请求体不是合法 JSON', 400, 'invalid_request_error')
|
|
103
|
+
}
|
|
104
|
+
if (!Array.isArray(body.messages) || !body.messages.length) {
|
|
105
|
+
done()
|
|
106
|
+
return openaiError(res, 'messages 不能为空', 400, 'invalid_request_error')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const model = body.model || bundle.providerConfig.models[0]
|
|
110
|
+
const stream = body.stream === true
|
|
111
|
+
const tools = Array.isArray(body.tools) ? body.tools : []
|
|
112
|
+
log(`[chat#${id}] provider=${providerId} model=${model} stream=${stream} tools=${tools.length} messages=${body.messages.length}`)
|
|
113
|
+
|
|
114
|
+
// 客户端断开信号
|
|
115
|
+
const clientGone = clientGoneSignal(res)
|
|
116
|
+
|
|
117
|
+
// benefit 头
|
|
118
|
+
const extraHeaders = isBenefitModel(bundle.benefitModels, model) ? { 'maas_type': 'benefit' } : {}
|
|
119
|
+
|
|
120
|
+
body.stream = stream
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
// 全局冷却:acquireUpstream 等 cooldownUntil
|
|
124
|
+
await waitForCooldown(retryState)
|
|
125
|
+
|
|
126
|
+
// 重试循环
|
|
127
|
+
const result = await retryLoop({
|
|
128
|
+
attemptFn: () => bundle.client.chatCompletions(body, {
|
|
129
|
+
headers: extraHeaders,
|
|
130
|
+
signal: clientGone.signal,
|
|
131
|
+
}),
|
|
132
|
+
errorPatterns: bundle.errorPatterns,
|
|
133
|
+
config: config.retry,
|
|
134
|
+
state: retryState,
|
|
135
|
+
clientGone,
|
|
136
|
+
chatId: id,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
if (clientGone.signal.aborted) {
|
|
140
|
+
done()
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!result.ok) {
|
|
145
|
+
done()
|
|
146
|
+
return writeUpstreamError(res, result.status, result.text)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (stream) {
|
|
150
|
+
await streamSSE(result.response, res, { id, clientGone })
|
|
151
|
+
} else {
|
|
152
|
+
await streamJSON(result.response, res)
|
|
153
|
+
}
|
|
154
|
+
done()
|
|
155
|
+
} catch (e) {
|
|
156
|
+
if (e.name === 'AbortError') { done(); return }
|
|
157
|
+
log(`[chat#${id}] 异常: ${e.message}`)
|
|
158
|
+
done()
|
|
159
|
+
openaiError(res, `代理错误: ${e.message}`, 502, 'proxy_error')
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- HTTP 服务器 ----
|
|
164
|
+
const server = http.createServer(async (req, res) => {
|
|
165
|
+
const url = new URL(req.url, 'http://x')
|
|
166
|
+
const pathname = url.pathname.replace(/\/+$/, '') || '/'
|
|
167
|
+
const route = parseRoute(pathname)
|
|
168
|
+
|
|
169
|
+
if (pathname === '/health' || route?.kind === 'global') {
|
|
170
|
+
return json(res, 200, { ok: true, providers: getProviderIds() })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 鉴权
|
|
174
|
+
if (config.proxyApiKey) {
|
|
175
|
+
const h = req.headers['authorization'] || ''
|
|
176
|
+
const key = h.replace(/^Bearer\s+/i, '') || url.searchParams.get('key') || ''
|
|
177
|
+
if (key !== config.proxyApiKey) return openaiError(res, '无效的 API Key', 401, 'authentication_error')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!route) {
|
|
181
|
+
return notFound(res, req.method, pathname)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (route.kind === 'provider_models') {
|
|
185
|
+
if (req.method !== 'GET') return methodNotAllowed(res, req.method)
|
|
186
|
+
return handleProviderModels(res, route.provider)
|
|
187
|
+
}
|
|
188
|
+
if (route.kind === 'chat') {
|
|
189
|
+
if (req.method !== 'POST') return methodNotAllowed(res, req.method)
|
|
190
|
+
const providerId = resolveChatProvider(route)
|
|
191
|
+
if (!providerId) return notFound(res, req.method, pathname)
|
|
192
|
+
return handleChat(req, res, providerId)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
notFound(res, req.method, pathname)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// ---- 启动 ----
|
|
199
|
+
export async function boot() {
|
|
200
|
+
const p = await probeBridge(config.port)
|
|
201
|
+
if (p === true) {
|
|
202
|
+
log(`llmproxy 已在运行: http://127.0.0.1:${config.port}(本进程退出)`)
|
|
203
|
+
process.exit(0)
|
|
204
|
+
}
|
|
205
|
+
if (p === false) {
|
|
206
|
+
console.error(`端口 ${config.port} 被其他程序占用,启动中止。`)
|
|
207
|
+
process.exit(1)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
server.once('error', (err) => {
|
|
211
|
+
console.error(`启动失败: ${err.message}`)
|
|
212
|
+
process.exit(1)
|
|
213
|
+
})
|
|
214
|
+
server.once('listening', onListening)
|
|
215
|
+
server.listen(config.port)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function onListening() {
|
|
219
|
+
const addr = server.address()
|
|
220
|
+
log(`llmproxy 监听 http://127.0.0.1:${addr.port}`)
|
|
221
|
+
log('路由:')
|
|
222
|
+
log(' GET /health')
|
|
223
|
+
log(' GET /<provider>/v1/models')
|
|
224
|
+
log(' POST /<provider>/v1/chat/completions (必须指定 provider)')
|
|
225
|
+
log(`providers: ${getProviderIds().join(', ')}`)
|
|
226
|
+
|
|
227
|
+
// 预热:fire-and-forget 触发每个 provider 的 auth.getCredentials()
|
|
228
|
+
// 不阻塞 onListening —— 即使浏览器登录未完成,HTTP server 也能响应 /health 等路由
|
|
229
|
+
for (const id of getProviderIds()) {
|
|
230
|
+
warmupProvider(id).catch((e) => log(`[${id}] 预热异常: ${e.message}`))
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 预热:让凭证就绪(仅记录日志,不影响 server 启动)
|
|
235
|
+
async function warmupProvider(id) {
|
|
236
|
+
const bundle = await getProviderBundle(id)
|
|
237
|
+
// pnpm run login 注入 LLMPROXY_LOGIN=<id> 时,用 autoLogin 触发浏览器 OAuth
|
|
238
|
+
const shouldAutoLogin = process.env.LLMPROXY_LOGIN === id
|
|
239
|
+
try {
|
|
240
|
+
const cred = await bundle.authProvider.getCredentials({ autoLogin: shouldAutoLogin })
|
|
241
|
+
log(`[${id}] 鉴权就绪 AK=${cred.accessKeyId?.slice(0, 4)}****${cred.accessKeyId?.slice(-3)} expires=${new Date(cred.expiresAt).toISOString()}`)
|
|
242
|
+
} catch (e) {
|
|
243
|
+
log(`[${id}] 鉴权未就绪: ${e.message}(请执行 llmproxy login --provider ${id})`)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 直接运行时启动(非 import)
|
|
248
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
249
|
+
boot()
|
|
250
|
+
}
|
package/src/set.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// src/set.js — 设置 provider 到目标平台配置
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import JSON5 from 'json5'
|
|
6
|
+
import { config, getProviderIds, getProviderConfig } from './config.js'
|
|
7
|
+
|
|
8
|
+
const SUPPORTED_TARGETS = ['opencode']
|
|
9
|
+
const OPENCODE_PATH = path.join(os.homedir(), '.config/opencode/opencode.jsonc')
|
|
10
|
+
|
|
11
|
+
function buildOpencodeEntry(providerId, providerConfig) {
|
|
12
|
+
return {
|
|
13
|
+
npm: "@ai-sdk/openai-compatible",
|
|
14
|
+
name: providerId,
|
|
15
|
+
options: {
|
|
16
|
+
baseURL: `http://127.0.0.1:${config.port}/${providerId}/v1`,
|
|
17
|
+
apiKey: config.proxyApiKey || "noapikey",
|
|
18
|
+
},
|
|
19
|
+
models: Object.fromEntries(
|
|
20
|
+
(providerConfig.models || []).map(id => [id, { name: id }])
|
|
21
|
+
),
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function loadOpencodeConfig() {
|
|
26
|
+
if (fs.existsSync(OPENCODE_PATH)) {
|
|
27
|
+
return JSON5.parse(fs.readFileSync(OPENCODE_PATH, 'utf8'))
|
|
28
|
+
}
|
|
29
|
+
return {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function saveOpencodeConfig(opencodeConfig) {
|
|
33
|
+
fs.writeFileSync(OPENCODE_PATH, JSON.stringify(opencodeConfig, null, 2), 'utf8')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function setProviderToTarget(providerId, target, { dryRun = false } = {}) {
|
|
37
|
+
// 校验目标平台
|
|
38
|
+
if (!SUPPORTED_TARGETS.includes(target)) {
|
|
39
|
+
console.error(`不支持的目标: ${target}`)
|
|
40
|
+
console.error(`支持: ${SUPPORTED_TARGETS.join(', ')}`)
|
|
41
|
+
process.exit(1)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 确定要处理的 provider 列表
|
|
45
|
+
let providerIds
|
|
46
|
+
if (providerId) {
|
|
47
|
+
const allIds = getProviderIds()
|
|
48
|
+
if (!allIds.includes(providerId)) {
|
|
49
|
+
console.error(`未知 provider: ${providerId}`)
|
|
50
|
+
console.error(`可用: ${allIds.join(', ')}`)
|
|
51
|
+
process.exit(1)
|
|
52
|
+
}
|
|
53
|
+
providerIds = [providerId]
|
|
54
|
+
} else {
|
|
55
|
+
providerIds = getProviderIds()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (providerIds.length === 0) {
|
|
59
|
+
console.error('没有配置任何 provider')
|
|
60
|
+
process.exit(1)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 生成所有 provider 条目
|
|
64
|
+
const entries = {}
|
|
65
|
+
for (const id of providerIds) {
|
|
66
|
+
const providerConfig = getProviderConfig(id)
|
|
67
|
+
if (!providerConfig) {
|
|
68
|
+
console.error(`未知 provider: ${id},跳过`)
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
entries[id] = buildOpencodeEntry(id, providerConfig)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// dry-run 模式:只打印配置
|
|
75
|
+
if (dryRun) {
|
|
76
|
+
console.log(JSON.stringify(entries, null, 2))
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 读取并更新 opencode.jsonc
|
|
81
|
+
const opencodeConfig = loadOpencodeConfig()
|
|
82
|
+
if (!opencodeConfig.provider) {
|
|
83
|
+
opencodeConfig.provider = {}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let added = 0, updated = 0
|
|
87
|
+
for (const [id, entry] of Object.entries(entries)) {
|
|
88
|
+
if (opencodeConfig.provider[id]) {
|
|
89
|
+
console.log(`更新: ${id}`)
|
|
90
|
+
updated++
|
|
91
|
+
} else {
|
|
92
|
+
console.log(`新增: ${id}`)
|
|
93
|
+
added++
|
|
94
|
+
}
|
|
95
|
+
opencodeConfig.provider[id] = entry
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
saveOpencodeConfig(opencodeConfig)
|
|
99
|
+
console.log(`已写入 ${OPENCODE_PATH}`)
|
|
100
|
+
console.log(` 新增: ${added}, 更新: ${updated}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function removeProviderFromTarget(providerId, target, { dryRun = false } = {}) {
|
|
104
|
+
// 校验目标平台
|
|
105
|
+
if (!SUPPORTED_TARGETS.includes(target)) {
|
|
106
|
+
console.error(`不支持的目标: ${target}`)
|
|
107
|
+
console.error(`支持: ${SUPPORTED_TARGETS.join(', ')}`)
|
|
108
|
+
process.exit(1)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 读取 opencode.jsonc
|
|
112
|
+
const opencodeConfig = loadOpencodeConfig()
|
|
113
|
+
if (!opencodeConfig.provider) {
|
|
114
|
+
console.error('opencode 配置中没有 provider')
|
|
115
|
+
process.exit(1)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 确定要删除的 provider 列表
|
|
119
|
+
let providerIds
|
|
120
|
+
if (providerId) {
|
|
121
|
+
if (!opencodeConfig.provider[providerId]) {
|
|
122
|
+
console.error(`opencode 中不存在 provider: ${providerId}`)
|
|
123
|
+
const available = Object.keys(opencodeConfig.provider)
|
|
124
|
+
console.error(`可用: ${available.join(', ')}`)
|
|
125
|
+
process.exit(1)
|
|
126
|
+
}
|
|
127
|
+
providerIds = [providerId]
|
|
128
|
+
} else {
|
|
129
|
+
providerIds = Object.keys(opencodeConfig.provider)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (providerIds.length === 0) {
|
|
133
|
+
console.log('没有需要删除的 provider')
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// dry-run 模式:只打印
|
|
138
|
+
if (dryRun) {
|
|
139
|
+
for (const id of providerIds) {
|
|
140
|
+
console.log(`# ${id}`)
|
|
141
|
+
console.log(JSON.stringify(opencodeConfig.provider[id], null, 2))
|
|
142
|
+
}
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 删除
|
|
147
|
+
for (const id of providerIds) {
|
|
148
|
+
delete opencodeConfig.provider[id]
|
|
149
|
+
console.log(`已删除: ${id}`)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
saveOpencodeConfig(opencodeConfig)
|
|
153
|
+
console.log(`已写入 ${OPENCODE_PATH}`)
|
|
154
|
+
}
|
package/src/util.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// src/util.js — 公共工具:日志 / 超时请求 / HTTP 响应 / 延时
|
|
2
|
+
|
|
3
|
+
// 带时间戳的日志,供多模块统一复用
|
|
4
|
+
export function log(...args) {
|
|
5
|
+
console.log(`[${new Date().toISOString()}]`, ...args)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// 延时(毫秒)
|
|
9
|
+
export const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
|
10
|
+
|
|
11
|
+
// 带超时的 fetch:超时自动 abort,返回响应对象(调用方负责读取 body)
|
|
12
|
+
export async function fetchWithTimeout(url, init = {}, timeoutMs = 30_000) {
|
|
13
|
+
const ctrl = new AbortController()
|
|
14
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
|
|
15
|
+
try {
|
|
16
|
+
return await fetch(url, { ...init, signal: ctrl.signal })
|
|
17
|
+
} finally {
|
|
18
|
+
clearTimeout(timer)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 读取 HTTP 请求体(带大小上限,防止超大 body)
|
|
23
|
+
export function readBody(req, limit = 20 * 1024 * 1024) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
let size = 0
|
|
26
|
+
const chunks = []
|
|
27
|
+
req.on('data', c => {
|
|
28
|
+
size += c.length
|
|
29
|
+
if (size > limit) return reject(new Error('body too large'))
|
|
30
|
+
chunks.push(c)
|
|
31
|
+
})
|
|
32
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
33
|
+
req.on('error', reject)
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 写 JSON 响应
|
|
38
|
+
export function json(res, code, obj) {
|
|
39
|
+
if (res.writableEnded) return
|
|
40
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' })
|
|
41
|
+
res.end(JSON.stringify(obj))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 写 OpenAI 风格错误响应
|
|
45
|
+
export function openaiError(res, message, code = 500, type = 'proxy_error') {
|
|
46
|
+
json(res, code, { error: { message, type, code } })
|
|
47
|
+
}
|