@goodandready/dsh-subscriptions 0.6.70 → 0.6.72
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 +7 -1
- package/README.ru.md +7 -1
- package/README.zh.md +7 -1
- package/lib/accounts-view.js +4 -0
- package/lib/accounts.js +13 -0
- package/lib/adapter-manager.js +2 -0
- package/lib/adapter.js +8 -0
- package/lib/client.js +637 -807
- package/lib/config-schema.js +4 -0
- package/lib/diagnostics.js +2 -0
- package/lib/doctor.js +163 -0
- package/lib/instructions.js +138 -0
- package/lib/quarantine.js +6 -2
- package/lib/reset-clock.js +87 -0
- package/lib/rotate.js +34 -3
- package/lib/routes/accounts.js +72 -0
- package/lib/routes/status.js +99 -5
- package/lib/sentinel.js +136 -0
- package/lib/stream-rotate.js +26 -10
- package/package.json +1 -1
package/lib/config-schema.js
CHANGED
|
@@ -110,6 +110,10 @@ export const Config = z.object({
|
|
|
110
110
|
.description('#349 Opt-in cross-vendor fallback when all accounts of a provider are exhausted or rate limited.'),
|
|
111
111
|
cascadingFallbackOrder: z.array(z.string()).volatile().default(PROVIDERS)
|
|
112
112
|
.description('Configurable global priority sequence for cross-vendor fallback.'),
|
|
113
|
+
rotationStrategy: z.string().volatile().default('round-robin')
|
|
114
|
+
.description('Load balancing strategy among available accounts: round-robin (balanced), least-loaded (fair-share quota), or priority (sequential).'),
|
|
115
|
+
enableSoftFailureSentinel: z.boolean().volatile().default(true)
|
|
116
|
+
.description('Intercept HTTP 200 soft-failure capacity apologies and automatically cascade to next healthy account.'),
|
|
113
117
|
cascadingChain: z.any().volatile().default({})
|
|
114
118
|
.description('#349 Custom cross-vendor fallback chains (e.g. { claude: ["copilot", "cursor"] }).'),
|
|
115
119
|
webhookUrl: z.string().volatile().default('')
|
package/lib/diagnostics.js
CHANGED
|
@@ -83,6 +83,8 @@ export async function createDiagnosticsReport({ live, store, history, NS, pkgVer
|
|
|
83
83
|
proxySlots: slots.filter((s) => s.proxyUrl).length,
|
|
84
84
|
cascadingFallback: !!cfg.cascadingFallback,
|
|
85
85
|
cascadingFallbackOrder: cfg.cascadingFallbackOrder || PROVIDERS,
|
|
86
|
+
rotationStrategy: cfg.rotationStrategy || 'round-robin',
|
|
87
|
+
enableSoftFailureSentinel: cfg.enableSoftFailureSentinel !== false,
|
|
86
88
|
},
|
|
87
89
|
})
|
|
88
90
|
}
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendor Auth Doctor: Diagnose authentication, authorization, quota, and network errors
|
|
3
|
+
* into structured, human-understandable recommendations.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const DOCTOR_CODES = {
|
|
7
|
+
GEO_BLOCKED: 'GEO_BLOCKED',
|
|
8
|
+
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
|
|
9
|
+
TOKEN_REVOKED: 'TOKEN_REVOKED',
|
|
10
|
+
MISSING_SCOPE: 'MISSING_SCOPE',
|
|
11
|
+
QUOTA_EXHAUSTED: 'QUOTA_EXHAUSTED',
|
|
12
|
+
RATE_LIMIT: 'RATE_LIMIT',
|
|
13
|
+
PROXY_ERROR: 'PROXY_ERROR',
|
|
14
|
+
UNKNOWN: 'UNKNOWN',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Classifies vendor errors and generates actionable recommendations.
|
|
19
|
+
* @param {any} err
|
|
20
|
+
* @param {string} [provider]
|
|
21
|
+
* @param {any} [_account]
|
|
22
|
+
* @returns {{ code: string, title: string, details: string, recommendation: string } | null}
|
|
23
|
+
*/
|
|
24
|
+
export function diagnoseVendorError(err, provider = '', _account = null) {
|
|
25
|
+
if (!err) return null
|
|
26
|
+
|
|
27
|
+
const msg = String(err.message || err.error || err || '').toLowerCase()
|
|
28
|
+
const code = String(err.code || err.status || err.statusCode || '').toUpperCase()
|
|
29
|
+
const p = String(provider || '').toLowerCase()
|
|
30
|
+
|
|
31
|
+
// 1. Geo-blocking / Cloudflare Firewall (403, 1020, region forbidden)
|
|
32
|
+
if (
|
|
33
|
+
msg.includes('country') ||
|
|
34
|
+
msg.includes('location') ||
|
|
35
|
+
msg.includes('region') ||
|
|
36
|
+
msg.includes('not supported in your country') ||
|
|
37
|
+
msg.includes('cloudflare') ||
|
|
38
|
+
msg.includes('cf-ray') ||
|
|
39
|
+
msg.includes('error 1020') ||
|
|
40
|
+
msg.includes('access denied') ||
|
|
41
|
+
(code === '403' && (msg.includes('forbidden') || msg.includes('blocked') || msg.includes('geoblock')))
|
|
42
|
+
) {
|
|
43
|
+
return {
|
|
44
|
+
code: DOCTOR_CODES.GEO_BLOCKED,
|
|
45
|
+
title: 'Region or IP Blocked by Vendor Firewall',
|
|
46
|
+
details: `The vendor (${p || 'provider'}) returned a geo-restriction or Cloudflare block.`,
|
|
47
|
+
recommendation: 'Configure a dedicated SOCKS5 or HTTP proxy located in a supported region (US, EU, JP) in proxy settings.',
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 2. Token Expired / Session Expired
|
|
52
|
+
if (
|
|
53
|
+
msg.includes('token_expired') ||
|
|
54
|
+
msg.includes('jwt expired') ||
|
|
55
|
+
msg.includes('invalid_grant') ||
|
|
56
|
+
msg.includes('session expired') ||
|
|
57
|
+
msg.includes('refresh token expired') ||
|
|
58
|
+
msg.includes('token has expired') ||
|
|
59
|
+
msg.includes('login_required')
|
|
60
|
+
) {
|
|
61
|
+
return {
|
|
62
|
+
code: DOCTOR_CODES.TOKEN_EXPIRED,
|
|
63
|
+
title: 'OAuth Access or Refresh Token Expired',
|
|
64
|
+
details: 'The stored OAuth credentials have expired and could not be refreshed automatically.',
|
|
65
|
+
recommendation: 'Click "Connect" or "Device Login" to re-authorize the account and establish a fresh session.',
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 3. Token Revoked / Bad Credentials
|
|
70
|
+
if (
|
|
71
|
+
msg.includes('token_revoked') ||
|
|
72
|
+
msg.includes('revoked') ||
|
|
73
|
+
msg.includes('account_deactivated') ||
|
|
74
|
+
msg.includes('invalid_token') ||
|
|
75
|
+
msg.includes('bad_credentials') ||
|
|
76
|
+
code === 'TOKEN_REVOKED' ||
|
|
77
|
+
(code === '401' && (msg.includes('unauthorized') || msg.includes('invalid api key') || msg.includes('incorrect api key')))
|
|
78
|
+
) {
|
|
79
|
+
return {
|
|
80
|
+
code: DOCTOR_CODES.TOKEN_REVOKED,
|
|
81
|
+
title: 'Token Invalid or Revoked',
|
|
82
|
+
details: 'The vendor rejected the API key or OAuth token as invalid or revoked.',
|
|
83
|
+
recommendation: 'Re-issue a fresh session token, API key, or CLI credentials in the vendor dashboard and save it.',
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 4. Missing Scope / Permissions
|
|
88
|
+
if (
|
|
89
|
+
msg.includes('insufficient_scope') ||
|
|
90
|
+
msg.includes('missing_scope') ||
|
|
91
|
+
msg.includes('scope required') ||
|
|
92
|
+
msg.includes('permission_denied') ||
|
|
93
|
+
msg.includes('model_not_found') ||
|
|
94
|
+
msg.includes('not have access') ||
|
|
95
|
+
msg.includes('license_required')
|
|
96
|
+
) {
|
|
97
|
+
return {
|
|
98
|
+
code: DOCTOR_CODES.MISSING_SCOPE,
|
|
99
|
+
title: 'Missing Scope or Model Permissions',
|
|
100
|
+
details: 'The account does not have sufficient entitlements or required scope permissions for this model.',
|
|
101
|
+
recommendation: 'Check your organization role or subscription tier to ensure permissions and access to requested models is granted.',
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 5. Quota Exhausted / Balance Zero
|
|
106
|
+
if (
|
|
107
|
+
msg.includes('insufficient_quota') ||
|
|
108
|
+
msg.includes('exceeded your current quota') ||
|
|
109
|
+
msg.includes('credit_balance_zero') ||
|
|
110
|
+
msg.includes('billing_not_active') ||
|
|
111
|
+
msg.includes('quota exceeded') ||
|
|
112
|
+
msg.includes('run out of credits') ||
|
|
113
|
+
code === 'QUOTA'
|
|
114
|
+
) {
|
|
115
|
+
return {
|
|
116
|
+
code: DOCTOR_CODES.QUOTA_EXHAUSTED,
|
|
117
|
+
title: 'Subscription Quota or Credits Exhausted',
|
|
118
|
+
details: 'The account has consumed 100% of its current billing cycle or token usage quota.',
|
|
119
|
+
recommendation: 'Check your subscription billing dashboard or wait until the quota window resets.',
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 6. Rate Limit / 429
|
|
124
|
+
if (
|
|
125
|
+
code === '429' ||
|
|
126
|
+
code === 'RATE_LIMIT' ||
|
|
127
|
+
msg.includes('too many requests') ||
|
|
128
|
+
msg.includes('rate_limit') ||
|
|
129
|
+
msg.includes('rate limit')
|
|
130
|
+
) {
|
|
131
|
+
return {
|
|
132
|
+
code: DOCTOR_CODES.RATE_LIMIT,
|
|
133
|
+
title: 'Temporary Rate Limit Reached',
|
|
134
|
+
details: 'The vendor is throttling requests (RPM/TPM limit reached) for this account.',
|
|
135
|
+
recommendation: 'Account is placed in temporary cooldown. Requests will automatically route to another account.',
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 7. Network / Proxy Connectivity
|
|
140
|
+
if (
|
|
141
|
+
msg.includes('econnrefused') ||
|
|
142
|
+
msg.includes('etimedout') ||
|
|
143
|
+
msg.includes('enotfound') ||
|
|
144
|
+
msg.includes('fetch failed') ||
|
|
145
|
+
msg.includes('socks') ||
|
|
146
|
+
code === 'ENOTFOUND' ||
|
|
147
|
+
code === 'ETIMEDOUT'
|
|
148
|
+
) {
|
|
149
|
+
return {
|
|
150
|
+
code: DOCTOR_CODES.PROXY_ERROR,
|
|
151
|
+
title: 'Network or Proxy Connection Failure',
|
|
152
|
+
details: 'Failed to establish TCP/TLS connection with the provider API or configured proxy server.',
|
|
153
|
+
recommendation: 'Check server network access, DNS resolution, and verify the configured SOCKS/HTTP proxy is online.',
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
code: DOCTOR_CODES.UNKNOWN,
|
|
159
|
+
title: 'Unexpected Vendor Error',
|
|
160
|
+
details: msg.slice(0, 200) || 'Unknown error occurred during vendor request.',
|
|
161
|
+
recommendation: 'Review vendor API status or re-check account credentials.',
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
export const INSTRUCTIONS = {
|
|
2
|
+
codex: {
|
|
3
|
+
title: "OpenAI / ChatGPT Codex",
|
|
4
|
+
stepsEn: [
|
|
5
|
+
"1. Log in via CLI on server: run \"codex login\" in terminal.",
|
|
6
|
+
"2. Click \"📥 From CLI\" to auto-import ~/.codex/auth.json.",
|
|
7
|
+
"3. Or click \"Device Login\" above and enter the code at auth.openai.com.",
|
|
8
|
+
],
|
|
9
|
+
stepsZh: [
|
|
10
|
+
"1. 在服务器上通过 CLI 登录:在终端运行 \"codex login\"。",
|
|
11
|
+
"2. 点击 \"📥 从 CLI 导入\" 自动读取 ~/.codex/auth.json。",
|
|
12
|
+
"3. 或者点击上方的 \"设备码登录\" (Device Login) 并在 auth.openai.com 输入代码。",
|
|
13
|
+
],
|
|
14
|
+
link: "https://chatgpt.com",
|
|
15
|
+
},
|
|
16
|
+
claude: {
|
|
17
|
+
title: "Claude Code",
|
|
18
|
+
stepsEn: [
|
|
19
|
+
"1. Run \"claude login\" in server console.",
|
|
20
|
+
"2. Click \"📥 From CLI\" — plugin will import ~/.claude/credentials.json.",
|
|
21
|
+
"3. Or paste your Session Token / API Key from Anthropic Console and click \"Save token\".",
|
|
22
|
+
],
|
|
23
|
+
stepsZh: [
|
|
24
|
+
"1. 在服务器控制台运行 \"claude login\"。",
|
|
25
|
+
"2. 点击 \"📥 从 CLI 导入\" — 插件将自动导入 ~/.claude/credentials.json。",
|
|
26
|
+
"3. 或者从 Anthropic 控制台复制您的 Session Token 或 API Key 并点击 \"保存令牌\"。",
|
|
27
|
+
],
|
|
28
|
+
link: "https://console.anthropic.com/settings/keys",
|
|
29
|
+
},
|
|
30
|
+
cursor: {
|
|
31
|
+
title: "Cursor IDE",
|
|
32
|
+
stepsEn: [
|
|
33
|
+
"1. Auto CLI: if Cursor is used on server, click \"📥 From CLI\" (reads from ~/.cursor/cli-config.json).",
|
|
34
|
+
"2. Manual: open Cursor Settings (Connect button or link below), copy your session token or API key.",
|
|
35
|
+
"3. Paste it into the input field and click \"Save token\".",
|
|
36
|
+
],
|
|
37
|
+
stepsZh: [
|
|
38
|
+
"1. CLI 自动导入:若服务器安装了 Cursor,点击 \"📥 从 CLI 导入\" (读取自 ~/.cursor/cli-config.json)。",
|
|
39
|
+
"2. 手动导入:打开 Cursor 设置页面(点击连接或下方链接),复制 WorkOS 会话令牌或 API 密钥。",
|
|
40
|
+
"3. 将其粘贴到输入框中并点击 \"保存令牌\"。",
|
|
41
|
+
],
|
|
42
|
+
link: "https://cursor.com/settings",
|
|
43
|
+
},
|
|
44
|
+
antigravity: {
|
|
45
|
+
title: "Google Antigravity / Gemini",
|
|
46
|
+
stepsEn: [
|
|
47
|
+
"1. Easiest way: click \"📥 From CLI\" — server already has active token in ~/.gemini/antigravity-cli/antigravity-oauth-token.",
|
|
48
|
+
"2. Clicking \"📥 From CLI\" instantly loads fresh credentials.",
|
|
49
|
+
"3. Manual: paste session JSON or OAuth tokens and click \"Save token\".",
|
|
50
|
+
"Note: \"Connect\" button requires custom Google client_id in plugin settings; use \"📥 From CLI\" for seamless login!",
|
|
51
|
+
],
|
|
52
|
+
stepsZh: [
|
|
53
|
+
"1. 推荐方式:点击 \"📥 从 CLI 导入\" — 服务器已保存 ~/.gemini/antigravity-cli/antigravity-oauth-token 会话。",
|
|
54
|
+
"2. 点击 \"📥 从 CLI 导入\" 可即时加载最新的凭据与 refresh_token。",
|
|
55
|
+
"3. 手动输入:将会话 JSON 或 OAuth access_token/refresh_token 粘贴至输入框并保存。",
|
|
56
|
+
"注:\"连接\" 按钮需要自定义 Google OAuth Client ID,推荐直接使用 \"📥 从 CLI 导入\"!",
|
|
57
|
+
],
|
|
58
|
+
link: "https://antigravity.google",
|
|
59
|
+
},
|
|
60
|
+
kimi: {
|
|
61
|
+
title: "Moonshot Kimi",
|
|
62
|
+
stepsEn: [
|
|
63
|
+
"1. Open Moonshot developer console (link below or click Connect).",
|
|
64
|
+
"2. Generate and copy your API Key (sk-...).",
|
|
65
|
+
"3. Paste it into the input field and click \"Save token\".",
|
|
66
|
+
"4. Or click \"📥 From CLI\" if ~/.kimi-code is configured.",
|
|
67
|
+
],
|
|
68
|
+
stepsZh: [
|
|
69
|
+
"1. 打开 Moonshot 开发者控制台(点击下方链接或点击连接)。",
|
|
70
|
+
"2. 创建并复制您的 API 密钥 (sk-...)。",
|
|
71
|
+
"3. 将密钥粘贴到槽位输入框中并点击 \"保存令牌\"。",
|
|
72
|
+
"4. 如果配置了 Kimi Code CLI,也可点击 \"📥 从 CLI 导入\" (~/.kimi-code)。",
|
|
73
|
+
],
|
|
74
|
+
link: "https://platform.moonshot.cn/console/api-keys",
|
|
75
|
+
},
|
|
76
|
+
glm: {
|
|
77
|
+
title: "Zhipu GLM (Z.AI)",
|
|
78
|
+
stepsEn: [
|
|
79
|
+
"1. Open Zhipu BigModel API Keys center (link below or click Connect).",
|
|
80
|
+
"2. Copy your GLM API Key.",
|
|
81
|
+
"3. Paste it into the input field and click \"Save token\".",
|
|
82
|
+
"4. Or click \"📥 From CLI\" if ZCode CLI is installed (~/.zcode).",
|
|
83
|
+
],
|
|
84
|
+
stepsZh: [
|
|
85
|
+
"1. 打开智谱 AI 开放平台(点击下方链接或点击连接)。",
|
|
86
|
+
"2. 复制您的 GLM API 密钥。",
|
|
87
|
+
"3. 粘贴到输入框中并点击 \"保存令牌\"。",
|
|
88
|
+
"4. 若已安装 ZCode CLI (~/.zcode),点击 \"📥 从 CLI 导入\" 即可自动导入。",
|
|
89
|
+
],
|
|
90
|
+
link: "https://bigmodel.cn/usercenter/proj-mgmt/apikeys",
|
|
91
|
+
},
|
|
92
|
+
grok: {
|
|
93
|
+
title: "xAI Grok",
|
|
94
|
+
stepsEn: [
|
|
95
|
+
"1. Click \"Device Login\" (or \"Connect\") on the slot card.",
|
|
96
|
+
"2. A one-time code will appear on the card.",
|
|
97
|
+
"3. Open https://accounts.x.ai/oauth2/device, enter the code, and approve access for your xAI account.",
|
|
98
|
+
"4. The plugin automatically captures tokens and enables Grok models.",
|
|
99
|
+
"5. Or click \"📥 From CLI\" (~/.grok/auth.json) / paste an xAI API Key.",
|
|
100
|
+
],
|
|
101
|
+
stepsZh: [
|
|
102
|
+
"1. 点击卡片上的 \"设备码登录\" (Device Login) 或 \"连接\"。",
|
|
103
|
+
"2. 界面将展示一次性设备授权码。",
|
|
104
|
+
"3. 打开 https://accounts.x.ai/oauth2/device,输入代码,并为您拥有 Grok 订阅的 xAI 账户授权。",
|
|
105
|
+
"4. 授权成功后,插件会自动捕获会话并激活 Grok 官方模型。",
|
|
106
|
+
"5. 或点击 \"📥 从 CLI 导入\" (~/.grok/auth.json) / 直接粘贴 xAI API 密钥。",
|
|
107
|
+
],
|
|
108
|
+
link: "https://accounts.x.ai/oauth2/device",
|
|
109
|
+
},
|
|
110
|
+
kiro: {
|
|
111
|
+
title: "AWS Kiro",
|
|
112
|
+
stepsEn: [
|
|
113
|
+
"1. Click \"📥 From CLI\" to import from ~/.kiro/credentials.json.",
|
|
114
|
+
"2. Or paste KIRO_API_KEY and click \"Save token\".",
|
|
115
|
+
],
|
|
116
|
+
stepsZh: [
|
|
117
|
+
"1. 点击 \"📥 从 CLI 导入\" 从 ~/.kiro/credentials.json 或 ~/.aws/sso 导入凭据。",
|
|
118
|
+
"2. 或直接粘贴 KIRO_API_KEY 并点击 \"保存令牌\"。",
|
|
119
|
+
],
|
|
120
|
+
link: "https://aws.amazon.com",
|
|
121
|
+
},
|
|
122
|
+
copilot: {
|
|
123
|
+
title: "GitHub Copilot",
|
|
124
|
+
stepsEn: [
|
|
125
|
+
"1. Click \"Device Login\" (or \"Connect\") on the slot card.",
|
|
126
|
+
"2. A one-time code will appear on the card.",
|
|
127
|
+
"3. Open https://github.com/login/device, enter the code, and approve access for your GitHub account with Copilot subscription.",
|
|
128
|
+
"4. The plugin automatically captures tokens and enables Copilot models.",
|
|
129
|
+
],
|
|
130
|
+
stepsZh: [
|
|
131
|
+
"1. 点击卡片上的 \"设备码登录\" (Device Login) 或 \"连接\"。",
|
|
132
|
+
"2. 界面将展示一次性设备授权码。",
|
|
133
|
+
"3. 在浏览器打开 https://github.com/login/device 输入代码,并为您拥有 Copilot 订阅的 GitHub 账户授权。",
|
|
134
|
+
"4. 授权成功后,插件会自动捕获会话并激活 GitHub Copilot 官方模型。",
|
|
135
|
+
],
|
|
136
|
+
link: "https://github.com/login/device",
|
|
137
|
+
},
|
|
138
|
+
}
|
package/lib/quarantine.js
CHANGED
|
@@ -3,6 +3,7 @@ export const REASON_RATE_LIMIT = 'RATE_LIMIT'
|
|
|
3
3
|
export const REASON_HARD_LIMIT = 'HARD_LIMIT'
|
|
4
4
|
export const REASON_REVOKED = 'TOKEN_REVOKED'
|
|
5
5
|
export const REASON_MANUAL_PARK = 'MANUAL_PARK'
|
|
6
|
+
export const REASON_DEGRADED = 'DEGRADED_CAPACITY'
|
|
6
7
|
|
|
7
8
|
export const STATUS_ACTIVE = 'active'
|
|
8
9
|
export const STATUS_QUARANTINE = 'quarantine'
|
|
@@ -16,18 +17,21 @@ export function quarantineDuration(reason) {
|
|
|
16
17
|
return 7 * 24 * 60 * 60 * 1000 // 7 days (requires re-auth)
|
|
17
18
|
case REASON_MANUAL_PARK:
|
|
18
19
|
return 60 * 60 * 1000 // 1 hour (manual park)
|
|
20
|
+
case REASON_DEGRADED:
|
|
21
|
+
return 5 * 60 * 1000 // 5 minutes degraded cooldown
|
|
19
22
|
case REASON_RATE_LIMIT:
|
|
20
23
|
default:
|
|
21
24
|
return 60 * 60 * 1000 // 1 hour
|
|
22
25
|
}
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
export function putInQuarantine(account, reason, nowMs = Date.now()) {
|
|
28
|
+
export function putInQuarantine(account, reason, nowMs = Date.now(), customUntil = null) {
|
|
26
29
|
const duration = quarantineDuration(reason)
|
|
30
|
+
const until = (customUntil && Number(customUntil) > Number(nowMs)) ? Number(customUntil) : (Number(nowMs) + duration)
|
|
27
31
|
return {
|
|
28
32
|
...account,
|
|
29
33
|
quarantineReason: reason,
|
|
30
|
-
quarantineUntil:
|
|
34
|
+
quarantineUntil: until,
|
|
31
35
|
status: STATUS_QUARANTINE,
|
|
32
36
|
}
|
|
33
37
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { parseHeaders } from './ratelimit.js'
|
|
2
|
+
|
|
3
|
+
export const VENDOR_WINDOW_MS = {
|
|
4
|
+
claude: 5 * 3600 * 1000,
|
|
5
|
+
codex: 3 * 3600 * 1000,
|
|
6
|
+
grok: 2 * 3600 * 1000,
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function parseResetFromText(message, nowMs = Date.now()) {
|
|
10
|
+
if (!message || typeof message !== 'string') return null
|
|
11
|
+
const s = message.trim()
|
|
12
|
+
|
|
13
|
+
const secMatch = s.match(/(?:try again in|retry after|wait|resets? in)\s+(\d+)\s*(?:s|sec|seconds?)/i)
|
|
14
|
+
if (secMatch) {
|
|
15
|
+
const sec = Number(secMatch[1])
|
|
16
|
+
if (Number.isFinite(sec) && sec > 0) return nowMs + sec * 1000
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const minMatch = s.match(/(?:try again in|retry after|wait|resets? in)\s+(\d+)\s*(?:m|min|minutes?)/i)
|
|
20
|
+
if (minMatch) {
|
|
21
|
+
const min = Number(minMatch[1])
|
|
22
|
+
if (Number.isFinite(min) && min > 0) return nowMs + min * 60 * 1000
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const hrMatch = s.match(/(?:try again in|retry after|wait|resets? in)\s+(\d+)\s*(?:h|hr|hours?)/i)
|
|
26
|
+
if (hrMatch) {
|
|
27
|
+
const hr = Number(hrMatch[1])
|
|
28
|
+
if (Number.isFinite(hr) && hr > 0) return nowMs + hr * 3600 * 1000
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveAccurateReset(err, account, provider, nowMs = Date.now(), safetyBufferMs = 10000) {
|
|
35
|
+
const now = Number(nowMs) || Date.now()
|
|
36
|
+
let targetReset = null
|
|
37
|
+
|
|
38
|
+
if (err && err.resetAt) {
|
|
39
|
+
const val = Number(err.resetAt)
|
|
40
|
+
if (Number.isFinite(val) && val > now) {
|
|
41
|
+
targetReset = val
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!targetReset && err) {
|
|
46
|
+
const headers = err.headers || (err.response && err.response.headers)
|
|
47
|
+
if (headers) {
|
|
48
|
+
const parsed = parseHeaders(headers, now)
|
|
49
|
+
if (parsed && parsed.resetAt && parsed.resetAt > now) {
|
|
50
|
+
targetReset = parsed.resetAt
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!targetReset && err && err.retryAfter) {
|
|
56
|
+
const n = Number(err.retryAfter)
|
|
57
|
+
if (Number.isFinite(n) && n > 0) {
|
|
58
|
+
targetReset = n > 1e9 ? (n > 1e12 ? n : n * 1000) : now + n * 1000
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!targetReset && err && err.message) {
|
|
63
|
+
const textReset = parseResetFromText(err.message, now)
|
|
64
|
+
if (textReset && textReset > now) {
|
|
65
|
+
targetReset = textReset
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!targetReset && account && account.quota && account.quota.resetAt) {
|
|
70
|
+
const qReset = Number(account.quota.resetAt)
|
|
71
|
+
if (Number.isFinite(qReset) && qReset > now) {
|
|
72
|
+
targetReset = qReset
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (targetReset && targetReset > now && targetReset <= now + 7 * 86400 * 1000) {
|
|
77
|
+
return targetReset + (Number(safetyBufferMs) || 0)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const p = String(provider || (account && account.provider) || '').toLowerCase()
|
|
81
|
+
const windowDefault = VENDOR_WINDOW_MS[p]
|
|
82
|
+
if (windowDefault && err && (err.status === 429 || err.code === 'RATE_LIMIT' || err.code === 'QUOTA')) {
|
|
83
|
+
return now + windowDefault + (Number(safetyBufferMs) || 0)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null
|
|
87
|
+
}
|
package/lib/rotate.js
CHANGED
|
@@ -7,6 +7,7 @@ const SWITCH_CODES = new Set([
|
|
|
7
7
|
'QUOTA',
|
|
8
8
|
'QUOTA_EXCEEDED',
|
|
9
9
|
'LICENSE_REQUIRED',
|
|
10
|
+
'SOFT_FAILURE',
|
|
10
11
|
])
|
|
11
12
|
|
|
12
13
|
export function isRegionError(err) {
|
|
@@ -18,7 +19,7 @@ export function isRegionError(err) {
|
|
|
18
19
|
|
|
19
20
|
export function isSwitchableError(err) {
|
|
20
21
|
if (!err || typeof err !== 'object') return false
|
|
21
|
-
if (err.name === 'TimeoutError' || err.code === 'TIMEOUT' || err.code === 'EMPTY_RESPONSE') return true
|
|
22
|
+
if (err.name === 'TimeoutError' || err.code === 'TIMEOUT' || err.code === 'EMPTY_RESPONSE' || err.code === 'SOFT_FAILURE' || err.isSoftFailure) return true
|
|
22
23
|
if (err.name === 'AbortError' || err.code === 'ABORT_ERR' || err.code === 'ERR_ABORTED' || (err.message && /aborted/i.test(err.message))) return false
|
|
23
24
|
const code = err.code || (err.failure && err.failure.code)
|
|
24
25
|
if (SWITCH_CODES.has(code)) return true
|
|
@@ -263,6 +264,33 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
|
|
|
263
264
|
}
|
|
264
265
|
}
|
|
265
266
|
|
|
267
|
+
const strategy = (opts && opts.rotationStrategy) || 'round-robin'
|
|
268
|
+
if (strategy === 'least-loaded') {
|
|
269
|
+
const aRem = getAccountRemainingPercent(a)
|
|
270
|
+
const bRem = getAccountRemainingPercent(b)
|
|
271
|
+
if (aRem != null && bRem != null) {
|
|
272
|
+
if (Math.abs(aRem - bRem) >= 5) {
|
|
273
|
+
return bRem - aRem
|
|
274
|
+
}
|
|
275
|
+
} else if (aRem != null && bRem == null) {
|
|
276
|
+
return -1
|
|
277
|
+
} else if (aRem == null && bRem != null) {
|
|
278
|
+
return 1
|
|
279
|
+
}
|
|
280
|
+
if (a.usagePercent != null && b.usagePercent != null) {
|
|
281
|
+
if (Math.abs(Number(a.usagePercent) - Number(b.usagePercent)) >= 5) {
|
|
282
|
+
return Number(a.usagePercent) - Number(b.usagePercent)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} else if (strategy === 'priority') {
|
|
286
|
+
const aWeight = a.weight || (a.isPro ? 5 : 1)
|
|
287
|
+
const bWeight = b.weight || (b.isPro ? 5 : 1)
|
|
288
|
+
if (aWeight !== bWeight) return bWeight - aWeight
|
|
289
|
+
const aIdx = list.indexOf(a)
|
|
290
|
+
const bIdx = list.indexOf(b)
|
|
291
|
+
if (aIdx !== -1 && bIdx !== -1 && aIdx !== bIdx) return aIdx - bIdx
|
|
292
|
+
}
|
|
293
|
+
|
|
266
294
|
// 0. Auto-Pacing: lower pacing risk preferred (#352)
|
|
267
295
|
const aRisk = Number(a.pacingRisk) || 0
|
|
268
296
|
const bRisk = Number(b.pacingRisk) || 0
|
|
@@ -333,14 +361,17 @@ export function pickAccount(accounts, nowMs, opts) {
|
|
|
333
361
|
return res ? res.account : null
|
|
334
362
|
}
|
|
335
363
|
|
|
336
|
-
export function markCooldown(account, nowMs, cooldownMs, family) {
|
|
364
|
+
export function markCooldown(account, nowMs, cooldownMs, family, customUntil = null) {
|
|
337
365
|
const wait = Number(cooldownMs)
|
|
338
366
|
const ms = Number.isFinite(wait) && wait > 0 ? wait : 30 * 60 * 1000
|
|
367
|
+
const until = (customUntil && Number(customUntil) > (Number(nowMs) || 0))
|
|
368
|
+
? Number(customUntil)
|
|
369
|
+
: ((Number(nowMs) || 0) + ms)
|
|
339
370
|
const prev = Array.isArray(account.cooldownFamilies) ? account.cooldownFamilies.slice() : []
|
|
340
371
|
const fams = family ? (prev.includes(family) ? prev : prev.concat(family)) : prev
|
|
341
372
|
return {
|
|
342
373
|
...account,
|
|
343
|
-
cooldownUntil:
|
|
374
|
+
cooldownUntil: until,
|
|
344
375
|
...(fams.length ? { cooldownFamilies: fams } : {}),
|
|
345
376
|
}
|
|
346
377
|
}
|
package/lib/routes/accounts.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { diagnoseVendorError } from '../doctor.js'
|
|
1
2
|
import { exportVault, importVault } from '../vault.js'
|
|
2
3
|
import { normalizeSlots, vendorConfig } from '../accounts.js'
|
|
3
4
|
import { getVendor } from '../vendors/index.js'
|
|
@@ -445,6 +446,77 @@ export function registerAccountsRoutes(ctx, state) {
|
|
|
445
446
|
}),
|
|
446
447
|
}), 'dsh-subscriptions: /import-token')
|
|
447
448
|
|
|
449
|
+
// Issue #541: Dynamic model catalog discovery & sync
|
|
450
|
+
ctx.effect(() => ctx.webServer.register({
|
|
451
|
+
kind: 'exact',
|
|
452
|
+
path: '/dsh-subscriptions/accounts/sync-models',
|
|
453
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
454
|
+
if (req.method !== 'POST') {
|
|
455
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
459
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
let payload = {}
|
|
463
|
+
try { payload = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}') } catch { /* ignore */ }
|
|
464
|
+
const provider = payload.provider
|
|
465
|
+
const index = payload.index || 1
|
|
466
|
+
if (!provider || !isProvider(provider)) {
|
|
467
|
+
writeJson(res, 400, { ok: false, error: { code: 'invalid_provider', message: 'Invalid or missing provider' } })
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
const ref = oauthRef(provider, index)
|
|
471
|
+
const info = await store.describeRef(ref)
|
|
472
|
+
if (!info.configured) {
|
|
473
|
+
writeJson(res, 400, { ok: false, error: { code: 'not_configured', message: 'Account is not configured' } })
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
let blob = await store.loadBlob(ref)
|
|
478
|
+
blob = await store.ensureFresh(provider, blob, ref)
|
|
479
|
+
const vendor = getVendor(provider)
|
|
480
|
+
const cfg = vendorConfig(provider, live())
|
|
481
|
+
const doFetch = (typeof state.fetchForRef === 'function' && state.fetchForRef(ref)) || fetch
|
|
482
|
+
let models = []
|
|
483
|
+
if (typeof vendor.listModels === 'function') {
|
|
484
|
+
const cat = await vendor.listModels(blob, cfg, doFetch)
|
|
485
|
+
if (Array.isArray(cat)) {
|
|
486
|
+
models = cat.map((m) => (typeof m === 'string' ? m : (m.id || m.slug || m.name))).filter(Boolean)
|
|
487
|
+
} else if (cat && Array.isArray(cat.models)) {
|
|
488
|
+
models = cat.models.map((m) => (typeof m === 'string' ? m : (m.id || m.slug || m.name))).filter(Boolean)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (!models.length && vendor.defaults) {
|
|
492
|
+
models = (vendor.defaults().models || []).map((m) => (typeof m === 'string' ? m : (m.id || m.slug || m.name))).filter(Boolean)
|
|
493
|
+
}
|
|
494
|
+
if (typeof store.rememberDynamicModels === 'function') {
|
|
495
|
+
store.rememberDynamicModels(ref, models)
|
|
496
|
+
}
|
|
497
|
+
writeJson(res, 200, {
|
|
498
|
+
ok: true,
|
|
499
|
+
provider,
|
|
500
|
+
index,
|
|
501
|
+
ref,
|
|
502
|
+
count: models.length,
|
|
503
|
+
models,
|
|
504
|
+
})
|
|
505
|
+
} catch (err) {
|
|
506
|
+
const doc = diagnoseVendorError(err, provider)
|
|
507
|
+
writeJson(res, 502, {
|
|
508
|
+
ok: false,
|
|
509
|
+
provider,
|
|
510
|
+
index,
|
|
511
|
+
ref,
|
|
512
|
+
doctor: doc,
|
|
513
|
+
error: { code: 'sync_failed', message: String(err && err.message || err) },
|
|
514
|
+
})
|
|
515
|
+
}
|
|
516
|
+
}),
|
|
517
|
+
}), 'dsh-subscriptions: /accounts/sync-models')
|
|
518
|
+
|
|
519
|
+
|
|
448
520
|
|
|
449
521
|
ctx.effect(() => ctx.webServer.register({
|
|
450
522
|
kind: 'exact',
|