@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/bin/index.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bin/index.js — llmproxy CLI 入口
|
|
3
|
+
import { start, stop, restart, status, login, parseProviderArg, setProviderToTarget, removeProviderFromTarget, showProviderConfig, clearProvider, debug } from '../src/ctl.js'
|
|
4
|
+
|
|
5
|
+
const args = process.argv.slice(2)
|
|
6
|
+
const cmd = args[0] ?? 'help'
|
|
7
|
+
|
|
8
|
+
function parseTargetArg(argv) {
|
|
9
|
+
for (let i = 0; i < argv.length - 1; i++) {
|
|
10
|
+
if (argv[i] === '--to' || argv[i] === '-t') return argv[i + 1]
|
|
11
|
+
}
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseDryRun(argv) {
|
|
16
|
+
return argv.includes('--dry-run') || argv.includes('-n')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function printHelp() {
|
|
20
|
+
console.log(`llmproxy — 通用 OpenAI 兼容代理
|
|
21
|
+
|
|
22
|
+
用法:
|
|
23
|
+
llmproxy start 启动代理
|
|
24
|
+
llmproxy stop 停止代理
|
|
25
|
+
llmproxy restart 重启代理
|
|
26
|
+
llmproxy status 查看状态
|
|
27
|
+
llmproxy provider [-p <id>] 查看 provider 配置
|
|
28
|
+
llmproxy login -p <id> 登录指定 provider
|
|
29
|
+
llmproxy set -p <id> -t <target> 设置 provider 到目标平台
|
|
30
|
+
llmproxy unset -p <id> -t <target> 从目标平台删除 provider
|
|
31
|
+
llmproxy clear [-p <id>] 清除 provider 登录信息
|
|
32
|
+
llmproxy debug [-p <id>] 测试 provider 各模型可用性
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
--provider, -p <id> 指定 provider(省略则全部)
|
|
36
|
+
--to, -t <target> 目标平台(opencode)
|
|
37
|
+
--dry-run, -n 只打印,不写入
|
|
38
|
+
--help, -h 显示帮助
|
|
39
|
+
|
|
40
|
+
示例:
|
|
41
|
+
llmproxy debug -p codearts/176 测试 codearts/176 所有模型
|
|
42
|
+
llmproxy debug 测试所有 provider 的所有模型`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
switch (cmd) {
|
|
47
|
+
case 'start':
|
|
48
|
+
await start()
|
|
49
|
+
break
|
|
50
|
+
case 'stop':
|
|
51
|
+
await stop()
|
|
52
|
+
break
|
|
53
|
+
case 'restart':
|
|
54
|
+
await restart()
|
|
55
|
+
break
|
|
56
|
+
case 'status':
|
|
57
|
+
await status()
|
|
58
|
+
break
|
|
59
|
+
case 'provider': {
|
|
60
|
+
const provider = parseProviderArg(args.slice(1))
|
|
61
|
+
showProviderConfig(provider)
|
|
62
|
+
break
|
|
63
|
+
}
|
|
64
|
+
case 'login': {
|
|
65
|
+
const provider = parseProviderArg(args.slice(1))
|
|
66
|
+
await login({ provider })
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
case 'set': {
|
|
70
|
+
const provider = parseProviderArg(args.slice(1))
|
|
71
|
+
const target = parseTargetArg(args.slice(1))
|
|
72
|
+
const dryRun = parseDryRun(args.slice(1))
|
|
73
|
+
if (!target) {
|
|
74
|
+
console.error('用法: llmproxy set -t <target> [-p <provider>] [-n]')
|
|
75
|
+
process.exit(1)
|
|
76
|
+
}
|
|
77
|
+
await setProviderToTarget(provider, target, { dryRun })
|
|
78
|
+
break
|
|
79
|
+
}
|
|
80
|
+
case 'unset': {
|
|
81
|
+
const provider = parseProviderArg(args.slice(1))
|
|
82
|
+
const target = parseTargetArg(args.slice(1))
|
|
83
|
+
const dryRun = parseDryRun(args.slice(1))
|
|
84
|
+
if (!target) {
|
|
85
|
+
console.error('用法: llmproxy unset -t <target> [-p <provider>] [-n]')
|
|
86
|
+
process.exit(1)
|
|
87
|
+
}
|
|
88
|
+
await removeProviderFromTarget(provider, target, { dryRun })
|
|
89
|
+
break
|
|
90
|
+
}
|
|
91
|
+
case 'clear': {
|
|
92
|
+
const provider = parseProviderArg(args.slice(1))
|
|
93
|
+
await clearProvider(provider)
|
|
94
|
+
break
|
|
95
|
+
}
|
|
96
|
+
case 'debug': {
|
|
97
|
+
const provider = parseProviderArg(args.slice(1))
|
|
98
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
99
|
+
printHelp()
|
|
100
|
+
} else {
|
|
101
|
+
await debug(provider)
|
|
102
|
+
}
|
|
103
|
+
break
|
|
104
|
+
}
|
|
105
|
+
case 'help':
|
|
106
|
+
case '--help':
|
|
107
|
+
case '-h':
|
|
108
|
+
case undefined:
|
|
109
|
+
printHelp()
|
|
110
|
+
break
|
|
111
|
+
default:
|
|
112
|
+
console.error(`未知命令: ${cmd}`)
|
|
113
|
+
printHelp()
|
|
114
|
+
process.exit(1)
|
|
115
|
+
}
|
|
116
|
+
} catch (e) {
|
|
117
|
+
console.error(`错误: ${e.message}`)
|
|
118
|
+
process.exit(1)
|
|
119
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wwkit/llmproxy",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"author": "bluesliu <langcai163@163.com>",
|
|
5
|
+
"description": "Generic OpenAI-compatible proxy for multiple LLM providers (codearts, ...). Pluggable via config.json5.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "src/index.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"llmproxy": "./bin/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"src",
|
|
14
|
+
"scripts",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"json5": "^2.2.3",
|
|
23
|
+
"undici": "^8.10.1",
|
|
24
|
+
"@wwkit/cft": "1.0.2",
|
|
25
|
+
"@wwkit/shared": "1.0.2"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"registry": "https://registry.npmjs.org/",
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"scripts": {
|
|
33
|
+
"postinstall": "node scripts/postinstall.js",
|
|
34
|
+
"start": "node bin/index.js start",
|
|
35
|
+
"stop": "node bin/index.js stop",
|
|
36
|
+
"restart": "node bin/index.js restart",
|
|
37
|
+
"status": "node bin/index.js status",
|
|
38
|
+
"login": "node bin/index.js login",
|
|
39
|
+
"dev": "node src/server.js",
|
|
40
|
+
"test": "node --test",
|
|
41
|
+
"release": "pnpm version patch && pnpm publish"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { createConfigLoader, getXdgConfigDir } from '@wwkit/shared'
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
9
|
+
const src = path.join(__dirname, '..', 'src', 'config.json5')
|
|
10
|
+
const bin = path.join(__dirname, '..', 'bin', 'index.js')
|
|
11
|
+
|
|
12
|
+
const loader = createConfigLoader({
|
|
13
|
+
builtinConfigPath: src,
|
|
14
|
+
userConfigDir: getXdgConfigDir('llmproxy'),
|
|
15
|
+
envDirVar: 'LLMPROXY_CONFIG_DIR',
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
if (loader.copyBuiltinConfig()) {
|
|
19
|
+
console.log(`llmproxy: wrote default config to ${loader.userConfigPath}`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const start = spawnSync('node', [bin, 'start'], { stdio: 'inherit' })
|
|
23
|
+
if (start.status !== 0) {
|
|
24
|
+
console.error('llmproxy: postinstall (start) failed')
|
|
25
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// src/config.js — 配置加载(三层合并:内置 → 用户目录 → 环境变量)
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { createConfigLoader, getXdgConfigDir } from '@wwkit/shared'
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
|
|
8
|
+
const _loader = createConfigLoader({
|
|
9
|
+
builtinConfigPath: path.join(__dirname, 'config.json5'),
|
|
10
|
+
userConfigDir: getXdgConfigDir('llmproxy'),
|
|
11
|
+
envContentVar: 'LLMPROXY_CONFIG_CONTENT',
|
|
12
|
+
envDirVar: 'LLMPROXY_CONFIG_DIR',
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
// 导出 config 对象(兼容原接口)
|
|
16
|
+
let _config = null
|
|
17
|
+
|
|
18
|
+
export function getConfig() {
|
|
19
|
+
if (!_config) _config = _loader.getConfig()
|
|
20
|
+
return _config
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 兼容原 config 对象的导出方式
|
|
24
|
+
export const config = new Proxy({}, {
|
|
25
|
+
get(_, prop) {
|
|
26
|
+
return getConfig()[prop]
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export function getProviderIds() {
|
|
31
|
+
return Object.keys(getConfig().providers || {})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getProviderConfig(id) {
|
|
35
|
+
return getConfig().providers?.[id] || null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 重新加载配置(用于 login 等场景)
|
|
39
|
+
export function reloadConfig() {
|
|
40
|
+
_config = null
|
|
41
|
+
return getConfig()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 获取用户配置目录(用于凭证、日志等)
|
|
45
|
+
export function getUserConfigDir() {
|
|
46
|
+
return _loader.userConfigDir
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getUserConfigPath() {
|
|
50
|
+
return _loader.userConfigPath
|
|
51
|
+
}
|
package/src/config.json5
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// config.json5 — llmproxy 内置默认配置
|
|
2
|
+
{
|
|
3
|
+
// 监听端口
|
|
4
|
+
port: 1082,
|
|
5
|
+
|
|
6
|
+
// 客户端访问本代理时的 API Key
|
|
7
|
+
proxyApiKey: "noapikey",
|
|
8
|
+
|
|
9
|
+
// 全局重试 + 冷却参数(所有 provider 共享)
|
|
10
|
+
retry: {
|
|
11
|
+
sessionLimitMaxWaitMs: 600000, // TM.00001041 等并发超限:最多等 10 分钟
|
|
12
|
+
baseMs: 1000, // 退避起点
|
|
13
|
+
maxWaitMs: 15000, // 单次退避封顶
|
|
14
|
+
otherErrorMaxRetries: 4, // 其它错误重试 4 次后立即返错
|
|
15
|
+
cooldownMs: 30000, // 失败后全局冷却 30 秒
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
// Provider 注册表
|
|
19
|
+
// sign/errors/benefitModels 配置已内置在 providers/<type>/ 模块中,无需用户配置
|
|
20
|
+
// 命名约定:type/account(account 可选),如 codearts/acc1
|
|
21
|
+
providers: {
|
|
22
|
+
"codearts/176": {
|
|
23
|
+
auth: {
|
|
24
|
+
type: "oauth",
|
|
25
|
+
account: "",
|
|
26
|
+
password: "",
|
|
27
|
+
},
|
|
28
|
+
models: [
|
|
29
|
+
"GLM-5.2",
|
|
30
|
+
"GLM-4.7-SFT-Harmony",
|
|
31
|
+
"glm-5.3-flash",
|
|
32
|
+
"deepseek-v4-flash-0731",
|
|
33
|
+
"deepseek-v4-pro-0813",
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
"codearts/130": {
|
|
37
|
+
auth: {
|
|
38
|
+
type: "oauth",
|
|
39
|
+
account: "",
|
|
40
|
+
password: "",
|
|
41
|
+
},
|
|
42
|
+
models: [
|
|
43
|
+
"GLM-5.2",
|
|
44
|
+
"GLM-4.7-SFT-Harmony",
|
|
45
|
+
"glm-5.3-flash",
|
|
46
|
+
"deepseek-v4-flash-0731",
|
|
47
|
+
"deepseek-v4-pro-0813",
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}
|
package/src/core/auth.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// core/auth.js — AuthProvider 抽象基类
|
|
2
|
+
//
|
|
3
|
+
// 各 IDE/Cloud provider 实现自己的鉴权(OAuth / API Key / IAM 等)。
|
|
4
|
+
// 本类只定义接口契约:getCredentials() 返回带 security headers 的凭证。
|
|
5
|
+
|
|
6
|
+
export class AuthProvider {
|
|
7
|
+
/**
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {string} opts.id provider id
|
|
10
|
+
* @param {object} opts.config 该 provider 在 config.json5 里的 auth 段
|
|
11
|
+
*/
|
|
12
|
+
constructor(opts) {
|
|
13
|
+
this.id = opts.id;
|
|
14
|
+
this.config = opts.config || {};
|
|
15
|
+
this._cred = null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 拿到有效凭证(含续期/刷新),返回对象至少包含:
|
|
20
|
+
* { headers: {Authorization, X-Security-Token, ...}, expiresAt: number }
|
|
21
|
+
* @returns {Promise<object>}
|
|
22
|
+
*/
|
|
23
|
+
async getCredentials() {
|
|
24
|
+
throw new Error(`[${this.id}] AuthProvider.getCredentials() not implemented`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 触发重新登录(如浏览器 OAuth / 用户输入 API Key)
|
|
29
|
+
* 默认抛错——有些 provider 不需要重新登录
|
|
30
|
+
* @param {object} [opts]
|
|
31
|
+
*/
|
|
32
|
+
async login(opts = {}) {
|
|
33
|
+
throw new Error(`[${this.id}] AuthProvider.login() not implemented`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// core/error.js — ErrorPatterns 抽象基类
|
|
2
|
+
//
|
|
3
|
+
// 不同 provider 的"会话超限"错误码不同:
|
|
4
|
+
// - snap-access: TM.00001041 in body
|
|
5
|
+
// - OpenAI: 429 status with error.type=rate_limit_error
|
|
6
|
+
// - Anthropic: 529 status
|
|
7
|
+
|
|
8
|
+
export class ErrorPatterns {
|
|
9
|
+
/**
|
|
10
|
+
* @param {object} opts
|
|
11
|
+
* @param {string} opts.id provider id
|
|
12
|
+
* @param {object} opts.config provider 的 errors 段(如 { sessionLimit: "TM.00001041" })
|
|
13
|
+
*/
|
|
14
|
+
constructor(opts) {
|
|
15
|
+
this.id = opts.id;
|
|
16
|
+
this.config = opts.config || {};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 上游响应是否表示"并发会话超限"
|
|
21
|
+
* @param {number} status
|
|
22
|
+
* @param {string} bodyText
|
|
23
|
+
* @returns {boolean}
|
|
24
|
+
*/
|
|
25
|
+
isSessionLimit(status, bodyText) {
|
|
26
|
+
throw new Error(`[${this.id}] ErrorPatterns.isSessionLimit() not implemented`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 通用默认实现:在 bodyText 中查找子串
|
|
32
|
+
* 适用于华为云、阿里云、火山引擎等返回 JSON 错误码的 provider
|
|
33
|
+
*/
|
|
34
|
+
export class BodyStringErrorPatterns extends ErrorPatterns {
|
|
35
|
+
isSessionLimit(_status, bodyText) {
|
|
36
|
+
const marker = this.config.sessionLimit;
|
|
37
|
+
return marker && bodyText && bodyText.includes(marker);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// src/core/factory.js — Provider 实例化工厂(动态加载)
|
|
2
|
+
//
|
|
3
|
+
// 按约定路径 providers/<id>/auth.js 等动态 import,无需额外配置。
|
|
4
|
+
// 新增 provider 只需在 providers/<id>/ 下放对应模块文件,factory 自动发现。
|
|
5
|
+
//
|
|
6
|
+
// 模块文件必须 export default class(继承对应抽象基类)。
|
|
7
|
+
// 可选导出常量:SIGN_CONFIG, ERROR_CONFIG, BENEFIT_MODELS(固定实现细节,无需用户配置)。
|
|
8
|
+
|
|
9
|
+
import path from "node:path"
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
11
|
+
import { NoopSigner } from "./sign.js"
|
|
12
|
+
import { BodyStringErrorPatterns } from "./error.js"
|
|
13
|
+
|
|
14
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
const SRC_ROOT = path.resolve(__dirname, "..")
|
|
16
|
+
|
|
17
|
+
// 模块缓存:路径 → { default: Class, SIGN_CONFIG?, ERROR_CONFIG? }
|
|
18
|
+
const moduleCache = new Map()
|
|
19
|
+
|
|
20
|
+
async function loadModule(modulePath) {
|
|
21
|
+
if (moduleCache.has(modulePath)) return moduleCache.get(modulePath)
|
|
22
|
+
const resolved = pathToFileURL(path.resolve(SRC_ROOT, modulePath)).href
|
|
23
|
+
const mod = await import(resolved)
|
|
24
|
+
moduleCache.set(modulePath, mod)
|
|
25
|
+
return mod
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function loadClass(modulePath) {
|
|
29
|
+
const mod = await loadModule(modulePath)
|
|
30
|
+
const cls = mod.default || Object.values(mod).find(v => typeof v === 'function')
|
|
31
|
+
if (typeof cls !== 'function') {
|
|
32
|
+
throw new Error(`module ${modulePath} does not export a class`)
|
|
33
|
+
}
|
|
34
|
+
return cls
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function providerModule(providerId, file) {
|
|
38
|
+
// 支持 codearts/acc1 格式,取 / 前的部分作为代码目录
|
|
39
|
+
const type = providerId.includes('/') ? providerId.split('/')[0] : providerId
|
|
40
|
+
return `providers/${type}/${file}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function buildAuthProvider(providerId, authConfig) {
|
|
44
|
+
const Cls = await loadClass(providerModule(providerId, 'auth.js'))
|
|
45
|
+
return new Cls({ id: providerId, config: authConfig })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function buildSignProvider(providerId, signConfig) {
|
|
49
|
+
if (signConfig?.type === 'none') {
|
|
50
|
+
return new NoopSigner({ id: providerId, config: signConfig })
|
|
51
|
+
}
|
|
52
|
+
const mod = await loadModule(providerModule(providerId, 'sign.js'))
|
|
53
|
+
const Cls = mod.default || Object.values(mod).find(v => typeof v === 'function')
|
|
54
|
+
// 优先用模块导出的 SIGN_CONFIG,否则用配置传入的
|
|
55
|
+
const config = mod.SIGN_CONFIG || signConfig || {}
|
|
56
|
+
return new Cls({ id: providerId, config })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function buildErrorPatterns(providerId, errorConfig) {
|
|
60
|
+
// 尝试加载 provider 专用 error 模块
|
|
61
|
+
try {
|
|
62
|
+
const mod = await loadModule(providerModule(providerId, 'error.js'))
|
|
63
|
+
const Cls = mod.default || Object.values(mod).find(v => typeof v === 'function')
|
|
64
|
+
if (Cls) {
|
|
65
|
+
const config = mod.ERROR_CONFIG || errorConfig || {}
|
|
66
|
+
return new Cls({ id: providerId, config })
|
|
67
|
+
}
|
|
68
|
+
} catch (e) {
|
|
69
|
+
// 模块不存在,fallback
|
|
70
|
+
}
|
|
71
|
+
// fallback: BodyStringErrorPatterns
|
|
72
|
+
return new BodyStringErrorPatterns({ id: providerId, config: errorConfig || {} })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function buildClient(providerId, providerConfig, deps) {
|
|
76
|
+
const mod = await loadModule(providerModule(providerId, 'client.js'))
|
|
77
|
+
const Cls = mod.default || Object.values(mod).find(v => typeof v === 'function')
|
|
78
|
+
// 从 sign 模块获取 SIGN_CONFIG(host, basePath)
|
|
79
|
+
const signMod = await loadModule(providerModule(providerId, 'sign.js'))
|
|
80
|
+
const signConfig = signMod.SIGN_CONFIG || providerConfig.sign || {}
|
|
81
|
+
return new Cls({
|
|
82
|
+
id: providerId,
|
|
83
|
+
signProvider: deps.signProvider,
|
|
84
|
+
authProvider: deps.authProvider,
|
|
85
|
+
host: signConfig.host,
|
|
86
|
+
basePath: signConfig.basePath,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 获取 provider 的 benefit 模型列表(从 config.js 模块读取)
|
|
92
|
+
* @param {string} providerId
|
|
93
|
+
* @returns {Promise<string[]>}
|
|
94
|
+
*/
|
|
95
|
+
export async function getBenefitModels(providerId) {
|
|
96
|
+
try {
|
|
97
|
+
const mod = await loadModule(providerModule(providerId, 'config.js'))
|
|
98
|
+
return mod.BENEFIT_MODELS || []
|
|
99
|
+
} catch {
|
|
100
|
+
return []
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// core/retry.js — 通用重试循环
|
|
2
|
+
//
|
|
3
|
+
// 区分两种错误处理:
|
|
4
|
+
// - sessionLimit(上游并发槽超限):长重试(最多 sessionLimitMaxWaitMs 毫秒)
|
|
5
|
+
// 客户端断开立即退出;退避 baseMs * attempt,封顶 maxWaitMs
|
|
6
|
+
// - 其它错误:短重试 otherErrorMaxRetries 次后立即返错
|
|
7
|
+
//
|
|
8
|
+
// 每次失败同步触发全局冷却 cooldownMs(给上游回收已结束 session 时间)。
|
|
9
|
+
// 通用:所有 provider 共用,sessionLimit 检测由 ErrorPatterns 决定。
|
|
10
|
+
|
|
11
|
+
import { log, sleep } from "../util.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 重试循环参数
|
|
15
|
+
* @typedef {object} RetryConfig
|
|
16
|
+
* @property {number} sessionLimitMaxWaitMs 会话超限总等待上限(默认 10 分钟)
|
|
17
|
+
* @property {number} baseMs 退避基础(默认 1000)
|
|
18
|
+
* @property {number} maxWaitMs 单次退避封顶(默认 15000)
|
|
19
|
+
* @property {number} otherErrorMaxRetries 其它错误重试次数(默认 4)
|
|
20
|
+
* @property {number} cooldownMs 全局冷却(默认 30000)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object} ErrorPatterns
|
|
25
|
+
* @property {(status: number, bodyText: string) => boolean} isSessionLimit
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {object} RetryState
|
|
30
|
+
* @property {number} cooldownUntil 全局冷却截止时间戳(跨请求共享)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 执行带重试的请求循环
|
|
35
|
+
* @param {object} opts
|
|
36
|
+
* @param {() => Promise<Response>} opts.attemptFn 每次尝试发起请求;返回 fetch Response
|
|
37
|
+
* @param {ErrorPatterns} opts.errorPatterns 错误模式识别
|
|
38
|
+
* @param {RetryConfig} opts.config 重试参数
|
|
39
|
+
* @param {RetryState} opts.state 跨请求共享状态(cooldownUntil)
|
|
40
|
+
* @param {AbortSignal} opts.clientGone 客户端断开信号
|
|
41
|
+
* @param {number} opts.chatId 日志前缀
|
|
42
|
+
* @returns {Promise<{ok: true, response: Response} | {ok: false, status: number, text: string}>}
|
|
43
|
+
*/
|
|
44
|
+
export async function retryLoop({ attemptFn, errorPatterns, config, state, clientGone, chatId }) {
|
|
45
|
+
const tStart = Date.now();
|
|
46
|
+
let attempt = 0;
|
|
47
|
+
|
|
48
|
+
for (;;) {
|
|
49
|
+
if (clientGone.signal.aborted) {
|
|
50
|
+
return { ok: false, status: 0, text: "[client gone]" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let upstream;
|
|
54
|
+
try {
|
|
55
|
+
upstream = await attemptFn();
|
|
56
|
+
} catch (e) {
|
|
57
|
+
if (e.name === "AbortError") {
|
|
58
|
+
return { ok: false, status: 0, text: "[aborted]" };
|
|
59
|
+
}
|
|
60
|
+
throw e;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (upstream.ok) {
|
|
64
|
+
return { ok: true, response: upstream };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const text = await upstream.text();
|
|
68
|
+
const status = upstream.status;
|
|
69
|
+
const isSessionLimit = errorPatterns.isSessionLimit(status, text);
|
|
70
|
+
attempt++;
|
|
71
|
+
|
|
72
|
+
// 触发全局冷却
|
|
73
|
+
const newCooldown = Date.now() + config.cooldownMs;
|
|
74
|
+
if (newCooldown > state.cooldownUntil) {
|
|
75
|
+
state.cooldownUntil = newCooldown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 其它错误:短重试
|
|
79
|
+
if (!isSessionLimit) {
|
|
80
|
+
if (attempt >= config.otherErrorMaxRetries) {
|
|
81
|
+
log(`[chat#${chatId}] 上游错误 ${status}: ${text.slice(0, 200)}`);
|
|
82
|
+
return { ok: false, status, text };
|
|
83
|
+
}
|
|
84
|
+
const wait = config.baseMs * attempt;
|
|
85
|
+
log(`[chat#${chatId}] 上游错误 ${status},${wait}ms 后重试 (${attempt}/${config.otherErrorMaxRetries})`);
|
|
86
|
+
await sleep(wait);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 会话超限:长重试直到 sessionLimitMaxWaitMs
|
|
91
|
+
const elapsed = Date.now() - tStart;
|
|
92
|
+
if (elapsed >= config.sessionLimitMaxWaitMs) {
|
|
93
|
+
log(`[chat#${chatId}] 会话超限持续 ${Math.round(elapsed / 1000)}s 仍未恢复,放弃`);
|
|
94
|
+
return { ok: false, status, text };
|
|
95
|
+
}
|
|
96
|
+
const wait = Math.min(config.baseMs * attempt, config.maxWaitMs);
|
|
97
|
+
const remain = Math.round((config.sessionLimitMaxWaitMs - elapsed) / 1000);
|
|
98
|
+
log(`[chat#${chatId}] 会话超限,${wait}ms 后重试 (已等 ${Math.round(elapsed / 1000)}s/${remain}s 内继续)`);
|
|
99
|
+
await sleep(wait);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 入口信号:acquireUpstream 等待全局冷却结束
|
|
105
|
+
* @param {RetryState} state
|
|
106
|
+
* @returns {Promise<void>}
|
|
107
|
+
*/
|
|
108
|
+
export async function waitForCooldown(state) {
|
|
109
|
+
while (Date.now() < state.cooldownUntil) {
|
|
110
|
+
const remain = state.cooldownUntil - Date.now();
|
|
111
|
+
log(`[upstream] 冷却中, ${Math.ceil(remain / 1000)}s 后放行`);
|
|
112
|
+
await sleep(Math.min(remain, 5000));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// core/route.js — URL 路径 → provider id 解析
|
|
2
|
+
//
|
|
3
|
+
// 路径规则:
|
|
4
|
+
// /health → 全局
|
|
5
|
+
// /<provider>/v1/models → 单 provider 模型
|
|
6
|
+
// /<provider>/v1/chat/completions → chat
|
|
7
|
+
//
|
|
8
|
+
// provider id 支持 codearts/acc1 格式(type/account,account 可选)
|
|
9
|
+
import { openaiError } from "../util.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 解析 URL 路径为路由描述
|
|
13
|
+
* @param {string} pathname URL.pathname
|
|
14
|
+
* @returns {null | {kind: "global" | "provider_models" | "chat", provider?: string}}
|
|
15
|
+
*/
|
|
16
|
+
export function parseRoute(pathname) {
|
|
17
|
+
// 去掉尾部斜杠
|
|
18
|
+
const p = pathname.replace(/\/+$/, "") || "/";
|
|
19
|
+
|
|
20
|
+
if (p === "/health") return { kind: "global" };
|
|
21
|
+
|
|
22
|
+
// /<provider>/v1/models(provider 可含 /,如 codearts/acc1)
|
|
23
|
+
let m = p.match(/^\/(.+?)\/v1\/models$/);
|
|
24
|
+
if (m) {
|
|
25
|
+
return { kind: "provider_models", provider: m[1] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// /<provider>/v1/chat/completions
|
|
29
|
+
m = p.match(/^\/(.+?)\/v1\/chat\/completions$/);
|
|
30
|
+
if (m) {
|
|
31
|
+
return { kind: "chat", provider: m[1] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 解析 chat 路由:返回 provider id
|
|
39
|
+
* @param {object} route parseRoute 返回值
|
|
40
|
+
* @returns {string|null} provider id 或 null(路由不合法)
|
|
41
|
+
*/
|
|
42
|
+
export function resolveChatProvider(route) {
|
|
43
|
+
if (route.kind !== "chat") return null;
|
|
44
|
+
return route.provider || null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 写 404 错误
|
|
49
|
+
*/
|
|
50
|
+
export function notFound(res, method, pathname) {
|
|
51
|
+
openaiError(res, `Not Found: ${method} ${pathname}`, 404, "invalid_request_error");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 写 405 错误
|
|
56
|
+
*/
|
|
57
|
+
export function methodNotAllowed(res, method) {
|
|
58
|
+
openaiError(res, `Method Not Allowed: ${method}`, 405, "invalid_request_error");
|
|
59
|
+
}
|
package/src/core/sign.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// core/sign.js — SignProvider 抽象基类
|
|
2
|
+
//
|
|
3
|
+
// 有些 provider 需要对每个请求做签名(华为云 SDK-HMAC-SHA256、AWS SigV4)。
|
|
4
|
+
// 有些不需要(Bearer token 即可)。不签名的 provider 可用 NoopSigner。
|
|
5
|
+
|
|
6
|
+
export class SignProvider {
|
|
7
|
+
/**
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {string} opts.id provider id
|
|
10
|
+
* @param {object} opts.config 该 provider 在 config.json5 里的 sign 段
|
|
11
|
+
*/
|
|
12
|
+
constructor(opts) {
|
|
13
|
+
this.id = opts.id;
|
|
14
|
+
this.config = opts.config || {};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 对请求做签名,返回需要注入的 headers(不包含 Authorization 等鉴权头)
|
|
19
|
+
* 由 auth.getCredentials() 返回的 headers 合并
|
|
20
|
+
* @param {object} req { method, url, headers, bodyStr }
|
|
21
|
+
* @returns {object} 额外 headers,如 { Authorization, X-Sdk-Date }
|
|
22
|
+
*/
|
|
23
|
+
sign(req) {
|
|
24
|
+
throw new Error(`[${this.id}] SignProvider.sign() not implemented`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class NoopSigner extends SignProvider {
|
|
29
|
+
sign() {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|