dsh-tap 0.20.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/AGENTS.md +99 -0
- package/CHANGELOG.md +751 -0
- package/LICENSE +21 -0
- package/README.en.md +59 -0
- package/README.md +256 -0
- package/cordis.patch.yml +385 -0
- package/core/bridge.js +698 -0
- package/core/json-store.js +91 -0
- package/core/rotation.js +108 -0
- package/core/usage-meter.js +176 -0
- package/docs/diagnosis-cache-quota.md +181 -0
- package/docs/diagnosis-qoder-flash.md +67 -0
- package/docs/diagnosis-trae-3003.md +302 -0
- package/docs/goals/bridge-port-host-split.md +91 -0
- package/docs/goals/desktop-adaptation.md +106 -0
- package/docs/goals/qoder-cn-provider-design.md +308 -0
- package/docs/goals/settings-card-ux-redesign-plan.md +1680 -0
- package/docs/goals/settings-card-ux-redesign.md +162 -0
- package/docs/goals/trae-agent-v3.md +41 -0
- package/docs/goals/trae-work-cn-repair.md +44 -0
- package/docs/goals/v0.8-/351/242/235/345/272/246/345/217/257/350/247/201-/346/250/241/345/236/213/345/212/250/346/200/201/345/214/226-/345/244/232/346/234/215/345/212/241/345/225/206.md +85 -0
- package/docs/pitfalls.md +105 -0
- package/docs/reverse/trae-cloud-api.md +218 -0
- package/docs/reverse/trae-model-catalog.md +129 -0
- package/docs/reverse/traework-cn.md +520 -0
- package/docs/rules/STATE.md +198 -0
- package/docs/rules/content-moderation.md +54 -0
- package/docs/rules/dev-role-boundary.md +94 -0
- package/docs/rules/extra-providers.md +42 -0
- package/docs/rules/gateway-facts.md +91 -0
- package/docs/rules/oauth-handshake.md +76 -0
- package/docs/rules/prompt-cache.md +93 -0
- package/docs/rules/quota-signals.md +125 -0
- package/docs/rules/routing.md +84 -0
- package/docs/rules/templates/oauth-reverse-checklist.md +42 -0
- package/docs/rules/trae-surface.md +242 -0
- package/docs/rules/ua-validation.md +81 -0
- package/host-config.js +282 -0
- package/index.js +2306 -0
- package/lib/client.js +2893 -0
- package/local-scan.js +104 -0
- package/package.json +82 -0
- package/providers/ark/index.js +11 -0
- package/providers/bailian/index.js +10 -0
- package/providers/bigmodel/index.js +11 -0
- package/providers/codebuddy/agenttool.js +122 -0
- package/providers/codebuddy/catalog.js +227 -0
- package/providers/codebuddy/errors.js +44 -0
- package/providers/codebuddy/headers.js +36 -0
- package/providers/codebuddy/images.js +125 -0
- package/providers/codebuddy/index.js +123 -0
- package/providers/codebuddy/oauth.js +279 -0
- package/providers/deepseek/index.js +11 -0
- package/providers/moonshot/index.js +11 -0
- package/providers/openai-compat.js +177 -0
- package/providers/openrouter/index.js +27 -0
- package/providers/qoder/catalog.js +145 -0
- package/providers/qoder/cosy.js +419 -0
- package/providers/qoder/gateway.js +563 -0
- package/providers/qoder/index.js +116 -0
- package/providers/qoder/oauth.js +364 -0
- package/providers/qoder/qoder_auth.wasm +0 -0
- package/providers/qoder/quota.js +56 -0
- package/providers/qwen/index.js +15 -0
- package/providers/tool-pairing.js +129 -0
- package/providers/trae/catalog.js +103 -0
- package/providers/trae/errors.js +85 -0
- package/providers/trae/gateway.js +853 -0
- package/providers/trae/index.js +126 -0
- package/providers/trae/oauth.js +443 -0
- package/providers/trae/quota.js +75 -0
- package/providers/trae/remote.js +365 -0
- package/scripts/capture-cache.mjs +65 -0
- package/scripts/capture-traffic.mjs +83 -0
- package/scripts/hermes-probe-dev-role.mjs +263 -0
- package/scripts/measure-latency.mjs +253 -0
- package/scripts/probe-ark-thinking.mjs +298 -0
- package/scripts/probe-cache-decline.mjs +292 -0
- package/scripts/probe-cache-ttl.mjs +221 -0
- package/scripts/probe-cache.mjs +156 -0
- package/scripts/probe-codebuddy-efforts.mjs +355 -0
- package/scripts/probe-codebuddy-tier-wiring.mjs +244 -0
- package/scripts/probe-effort-gaps.mjs +133 -0
- package/scripts/probe-media.mjs +115 -0
- package/scripts/probe-moderation.mjs +159 -0
- package/scripts/probe-oauth.mjs +617 -0
- package/scripts/probe-qoder-attribution-arm8.mjs +92 -0
- package/scripts/probe-qoder-attribution-arm9.mjs +102 -0
- package/scripts/probe-qoder-attribution.mjs +266 -0
- package/scripts/probe-qoder-flash-confirm.mjs +94 -0
- package/scripts/probe-qoder-live.mjs +344 -0
- package/scripts/probe-qoder-matrix.mjs +274 -0
- package/scripts/probe-qoder-null-content.mjs +153 -0
- package/scripts/probe-qoder-pairing.mjs +308 -0
- package/scripts/probe-qoder-quota.mjs +162 -0
- package/scripts/probe-qoder-thinking-config.mjs +52 -0
- package/scripts/probe-qoder-thinking-efforts.mjs +269 -0
- package/scripts/probe-quota.mjs +136 -0
- package/scripts/probe-quota2.mjs +144 -0
- package/scripts/probe-routing.mjs +226 -0
- package/scripts/probe-trae-3003-diagnosis.mjs +139 -0
- package/scripts/probe-trae-agent-v3.mjs +399 -0
- package/scripts/probe-trae-efforts.mjs +149 -0
- package/scripts/probe-trae-live.mjs +147 -0
- package/scripts/probe-trae-max-effort.mjs +179 -0
- package/scripts/probe-trae-model-routing.mjs +381 -0
- package/scripts/probe-trae-thinking-scene.mjs +238 -0
- package/scripts/probe-trae-transport-outage.mjs +176 -0
- package/scripts/probe-ua.mjs +508 -0
- package/scripts/trae-model-catalog.mjs +632 -0
- package/scripts/verify-agents-md.mjs +45 -0
- package/scripts/verify-bridge.mjs +1041 -0
- package/scripts/verify-core-generic.mjs +302 -0
- package/scripts/verify-desktop-acceptance.mjs +184 -0
- package/scripts/verify-host-config.mjs +265 -0
- package/scripts/verify-models.mjs +390 -0
- package/scripts/verify-providers.mjs +255 -0
- package/scripts/verify-qoder-provider.mjs +1020 -0
- package/scripts/verify-rotation.mjs +308 -0
- package/scripts/verify-trae-model-catalog.mjs +284 -0
- package/scripts/verify-trae-provider.mjs +1451 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* providers/qoder/oauth.js — Qoder CN 浏览器设备流(OAuth 订阅额度跟账号走)。
|
|
3
|
+
*
|
|
4
|
+
* 流程(证据:@qodercn-ai/qoderclicn 1.1.57 bundle 反混淆 + 2026-09-19 无凭据探测,
|
|
5
|
+
* 见 docs/reverse/qoder-cn.md):
|
|
6
|
+
* 1. 本地生成 PKCE:verifier = 43–128 字符(charset A-Za-z0-9-._~),
|
|
7
|
+
* challenge = base64url(SHA256(verifier));nonce = UUID;machine_id 自持持久化。
|
|
8
|
+
* 2. 授权页 = <loginHost>/device/selectAccounts?challenge=&challenge_method=S256
|
|
9
|
+
* &nonce=&machine_id=&client_id=<prod uuid>
|
|
10
|
+
* (实测无 cookie → 302 <loginHost>/users/sign-in?oauth_callback=…;
|
|
11
|
+
* qoder.cn 与 qoder.com.cn 双域同构)
|
|
12
|
+
* 3. 轮询 GET <openapi>/api/v1/deviceToken/poll?nonce=&verifier=&challenge_method=S256
|
|
13
|
+
* - **404 = 未完成**(bundle 契约 `markErrorStatus: s=>404!==s.status`;实测
|
|
14
|
+
* 未登录/未注册 challenge 同为 404 {"errorCode":"NotFound"})
|
|
15
|
+
* - 缺参 = 400 精确业务码(DeviceTokenNonceRequired / DeviceTokenVerifierRequired)
|
|
16
|
+
* - 间隔 1s、上限 5 分钟(bundle 常量 _Ai=1e3 / G$a=3e5)
|
|
17
|
+
* - 完成 = 200 {token, refresh_token, expires_at, refresh_token_expires_at}
|
|
18
|
+
* 4. 刷新 POST <openapi>/api/v1/deviceToken/refresh {refresh_token, machine_id}
|
|
19
|
+
* → {device_token, refresh_token, expires_at, refresh_token_expires_at}
|
|
20
|
+
* **refresh_token 前缀强制 drt-**(实测 bogus → DeviceRefreshTokenPrefixInvalid)
|
|
21
|
+
* 5. 出站认证 = `Authorization: Bearer <token>`(无自研签名;签名只存在于
|
|
22
|
+
* /algo COSY 面,本通道不走那一面)
|
|
23
|
+
*
|
|
24
|
+
* 与 codebuddy/trae 两路的形态差异:
|
|
25
|
+
* - 没有本地回环回调服务(不像 Trae 的 /authorize)——授权在服务端完成,
|
|
26
|
+
* 我们只轮询,故无回调页 XSS 面;
|
|
27
|
+
* - 没有多 Key 轮换,单账号;
|
|
28
|
+
* - poll 的"未完成"是 404 而非业务码,因此**不能**把 404 当失败。
|
|
29
|
+
*
|
|
30
|
+
* 实例状态(refresh 单飞锁、poll 代际、pending 视图、relogin 粘性信号)全在
|
|
31
|
+
* 本工厂闭包内(踩坑 #20)。
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto'
|
|
35
|
+
|
|
36
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000
|
|
37
|
+
const LOGIN_POLL_INTERVAL_MS = 1000
|
|
38
|
+
const REFRESH_LEAD_MS = 60_000
|
|
39
|
+
|
|
40
|
+
/** bundle 常量:prod 与 dev 两个 client_id,本通道用 prod 形态。 */
|
|
41
|
+
export const QODER_CLIENT_ID = 'e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb'
|
|
42
|
+
|
|
43
|
+
/** 授权页允许的站点族:Qoder CN 官方域 + 回环(离线 mock 上游用)。 */
|
|
44
|
+
const LOGIN_SITE_SUFFIXES = ['qoder.cn', 'qoder.com.cn']
|
|
45
|
+
const isLoopbackHost = (h) => h === '127.0.0.1' || h === 'localhost' || h === '[::1]'
|
|
46
|
+
|
|
47
|
+
const VERIFIER_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
|
|
48
|
+
|
|
49
|
+
/** PKCE 参数形态对齐官方 CLI:长度 43–128 随机,S256 挑战。 */
|
|
50
|
+
export function createPkce() {
|
|
51
|
+
const len = 43 + Math.floor(Math.random() * 86)
|
|
52
|
+
const bytes = randomBytes(len)
|
|
53
|
+
let verifier = ''
|
|
54
|
+
for (let i = 0; i < len; i++) verifier += VERIFIER_CHARSET[bytes[i] % VERIFIER_CHARSET.length]
|
|
55
|
+
const challenge = createHash('sha256').update(verifier).digest().toString('base64url')
|
|
56
|
+
return { verifier, challenge }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 机器标识:48 位 hex(与官方长 hex 形态同类,随机自持,跨登录稳定)。 */
|
|
60
|
+
export function createMachineId() {
|
|
61
|
+
return randomBytes(24).toString('hex')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 有效期归一。2026-09-19 真实响应实证:poll 同时给 `expires_at`(**ISO 字符串**
|
|
66
|
+
* 或数字秒)与 `expires_in`(相对秒),refresh 给 `expires_at`/`expires_in`。
|
|
67
|
+
* 规则对齐 bundle 的 L4()??tIe():纯数字串按 epoch(秒/毫秒分档)否则按 ISO 解析,
|
|
68
|
+
* 再否则视为相对秒。无法判定返回 null。
|
|
69
|
+
*/
|
|
70
|
+
export function normalizeExpiry(value, now = Date.now()) {
|
|
71
|
+
if (value == null || value === '') return null
|
|
72
|
+
if (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value.trim()))) {
|
|
73
|
+
const n = Number(value)
|
|
74
|
+
if (!Number.isFinite(n) || n <= 0) return null
|
|
75
|
+
if (n > 1e12) return n // 绝对毫秒
|
|
76
|
+
if (n > 1e9) return n * 1000 // 绝对秒
|
|
77
|
+
return now + n * 1000 // 相对秒
|
|
78
|
+
}
|
|
79
|
+
const parsed = Date.parse(String(value))
|
|
80
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 授权 URL 出宿主前的门禁。
|
|
85
|
+
*
|
|
86
|
+
* 与 codebuddy 的 assertSafeAuthUrl 的**关键差异**:那边 authUrl 来自上游响应,
|
|
87
|
+
* 故"与 baseURL 同域"是一条有效判据;这里 URL 的 host 直接来自用户设置
|
|
88
|
+
* (qoderLoginHost),同域判据等于不设防(任何 https 域都能自证通过——
|
|
89
|
+
* verify [5] 实测抓到这一点)。因此本门禁**不认同域**,只认:
|
|
90
|
+
* - scheme = https(回环对回环例外,供离线 mock 上游);
|
|
91
|
+
* - host ∈ Qoder 官方站点族(qoder.cn / qoder.com.cn)或回环。
|
|
92
|
+
*/
|
|
93
|
+
function assertSafeAuthUrl(authUrl) {
|
|
94
|
+
let parsed
|
|
95
|
+
try { parsed = new URL(String(authUrl)) } catch { throw new Error('authUrl 不是合法 URL') }
|
|
96
|
+
if (parsed.protocol !== 'https:' && !isLoopbackHost(parsed.hostname)) {
|
|
97
|
+
throw new Error(`authUrl 必须使用 https(收到 ${parsed.protocol}//)`)
|
|
98
|
+
}
|
|
99
|
+
const hostOk = isLoopbackHost(parsed.hostname)
|
|
100
|
+
|| LOGIN_SITE_SUFFIXES.some((s) => parsed.hostname === s || parsed.hostname.endsWith(`.${s}`))
|
|
101
|
+
if (!hostOk) {
|
|
102
|
+
throw new Error(`authUrl 域名 ${parsed.hostname} 不在 Qoder 官方登录站点族(${LOGIN_SITE_SUFFIXES.join(' / ')})`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 基址前置校验:手改的设置文件可能是 javascript: / 非法 URL,必须响亮拒绝。 */
|
|
107
|
+
function assertLoginBase(loginHost) {
|
|
108
|
+
let parsed
|
|
109
|
+
try { parsed = new URL(String(loginHost)) } catch { throw new Error('qoderLoginHost 不是合法 URL') }
|
|
110
|
+
if (parsed.protocol !== 'https:' && !isLoopbackHost(parsed.hostname)) {
|
|
111
|
+
throw new Error(`qoderLoginHost 必须使用 https(收到 ${parsed.protocol}//)`)
|
|
112
|
+
}
|
|
113
|
+
return parsed
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** poll/refresh 两形态的令牌体归一(poll 给 token,refresh 给 device_token)。 */
|
|
117
|
+
function tokenFrom(body, now = Date.now()) {
|
|
118
|
+
const access = body?.token ?? body?.device_token ?? body?.access_token
|
|
119
|
+
if (typeof access !== 'string' || !access) return null
|
|
120
|
+
const refresh = body.refresh_token ?? body.refreshToken
|
|
121
|
+
return {
|
|
122
|
+
accessToken: access,
|
|
123
|
+
// 缺字段返回 undefined(不是 null):refresh 响应常只换 access 不带
|
|
124
|
+
// refresh_token,调用方据此沿用旧值——写 null 会断掉后续续期。
|
|
125
|
+
...(refresh != null ? { refreshToken: refresh } : {}),
|
|
126
|
+
expiresAt: normalizeExpiry(body.expires_at ?? body.expire_time ?? body.expires_in ?? body.expiresIn, now) ?? (now + 3600_000),
|
|
127
|
+
refreshExpiresAt: normalizeExpiry(body.refresh_token_expires_at ?? body.refresh_token_expire_time ?? body.refresh_token_expires_in, now),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {{ readAuth: () => object, writeAuth: (v: object) => void }} deps
|
|
133
|
+
* 令牌存储 IO(组合根绑定 ~/.dsh/qoder-plugin-auth.json;令牌与 machine_id
|
|
134
|
+
* 永不回传浏览器——oauthStatus() 只出视图字段)。
|
|
135
|
+
*/
|
|
136
|
+
export function createQoderOAuth({ readAuth, writeAuth }) {
|
|
137
|
+
let refreshInFlight = null
|
|
138
|
+
let pollGeneration = 0
|
|
139
|
+
// 令牌存储代际号:logout 令其失效。refresh 落盘是读-改-写(writeAuth
|
|
140
|
+
// ({...readAuth(), auth: {...}}))——与 logout 的整体覆写竞态时,在飞刷新
|
|
141
|
+
// 会把已登出的令牌写回(登出失效)。落盘前比对代际,过期即丢弃结果。
|
|
142
|
+
let storeGeneration = 0
|
|
143
|
+
let reloginNeeded = false
|
|
144
|
+
const pending = { active: false, authUrl: '', error: '' }
|
|
145
|
+
|
|
146
|
+
/** 机器标识幂等自持:首次使用生成并落盘,之后所有登录/刷新复用一个。 */
|
|
147
|
+
function ensureMachineId() {
|
|
148
|
+
const store = readAuth()
|
|
149
|
+
if (typeof store.machine?.machineId === 'string' && store.machine.machineId) return store.machine.machineId
|
|
150
|
+
const machineId = createMachineId()
|
|
151
|
+
writeAuth({ ...store, machine: { machineId } })
|
|
152
|
+
return machineId
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 出站 URL 拼装(openapi 面)。 */
|
|
156
|
+
const api = (s, path) => `${String(s.qoderOpenapiBaseURL).replace(/\/+$/, '')}${path}`
|
|
157
|
+
|
|
158
|
+
/** 刷新(单飞;失败返回 undefined 不抛,并置 reloginNeeded)。 */
|
|
159
|
+
async function refreshOAuth(s) {
|
|
160
|
+
if (refreshInFlight) return refreshInFlight
|
|
161
|
+
const generation = storeGeneration
|
|
162
|
+
refreshInFlight = (async () => {
|
|
163
|
+
try {
|
|
164
|
+
const store = readAuth()
|
|
165
|
+
const auth = store.auth
|
|
166
|
+
if (!auth?.refreshToken) { reloginNeeded = true; return undefined }
|
|
167
|
+
const res = await fetch(api(s, '/api/v1/deviceToken/refresh'), {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
170
|
+
body: JSON.stringify({ refresh_token: auth.refreshToken, machine_id: store.machine?.machineId ?? '' }),
|
|
171
|
+
})
|
|
172
|
+
if (!res.ok) { reloginNeeded = true; return undefined }
|
|
173
|
+
const body = await res.json().catch(() => null)
|
|
174
|
+
const next = tokenFrom(body)
|
|
175
|
+
if (!next) { reloginNeeded = true; return undefined }
|
|
176
|
+
// logout 竞态守卫:代际在飞期间已变(用户点了退出)→ 丢弃刷新结果
|
|
177
|
+
if (generation !== storeGeneration) return undefined
|
|
178
|
+
// 缺字段一律沿用旧值(refresh 响应常只轮换 access token)。
|
|
179
|
+
const merged = {
|
|
180
|
+
...readAuth(),
|
|
181
|
+
auth: {
|
|
182
|
+
accessToken: next.accessToken,
|
|
183
|
+
refreshToken: next.refreshToken ?? auth.refreshToken ?? null,
|
|
184
|
+
expiresAt: next.expiresAt,
|
|
185
|
+
refreshExpiresAt: next.refreshExpiresAt ?? auth.refreshExpiresAt ?? null,
|
|
186
|
+
loginMethod: auth.loginMethod ?? 'browser',
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
writeAuth(merged)
|
|
190
|
+
reloginNeeded = false
|
|
191
|
+
return merged.auth
|
|
192
|
+
} catch {
|
|
193
|
+
reloginNeeded = true
|
|
194
|
+
return undefined
|
|
195
|
+
} finally {
|
|
196
|
+
refreshInFlight = null
|
|
197
|
+
}
|
|
198
|
+
})()
|
|
199
|
+
return refreshInFlight
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 每次出站共用:临期自动刷新;返回 {authorization, machineId, uid} 或 null。
|
|
203
|
+
* expiresAt 缺失视为临期而非永不过期——拿着未知新鲜度的凭据出门,上游 401
|
|
204
|
+
* 远不如本地一次清晰刷新(有 refreshToken 时)。 */
|
|
205
|
+
async function resolveQoderCredential(s) {
|
|
206
|
+
const store = readAuth()
|
|
207
|
+
const auth = store.auth
|
|
208
|
+
if (!auth?.accessToken) return null
|
|
209
|
+
let current = auth
|
|
210
|
+
const stale = !auth.expiresAt || auth.expiresAt - Date.now() < REFRESH_LEAD_MS
|
|
211
|
+
if (auth.refreshToken && stale) {
|
|
212
|
+
const refreshed = await refreshOAuth(s)
|
|
213
|
+
if (!refreshed) return null
|
|
214
|
+
current = refreshed
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
authorization: `Bearer ${current.accessToken}`,
|
|
218
|
+
machineId: store.machine?.machineId ?? null,
|
|
219
|
+
uid: store.account?.uid ?? null,
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 组装授权页 URL(不发请求——设备流的 challenge 在客户端生成)。 */
|
|
224
|
+
function buildAuthUrl(s, machineId) {
|
|
225
|
+
const { verifier, challenge } = createPkce()
|
|
226
|
+
const nonce = randomUUID()
|
|
227
|
+
const params = new URLSearchParams({
|
|
228
|
+
challenge,
|
|
229
|
+
challenge_method: 'S256',
|
|
230
|
+
nonce,
|
|
231
|
+
machine_id: machineId,
|
|
232
|
+
client_id: s.qoderClientId || QODER_CLIENT_ID,
|
|
233
|
+
})
|
|
234
|
+
// URL 解析构造:基址带尾斜杠/尾路径时仍以 /device/selectAccounts 为准。
|
|
235
|
+
const authUrl = new URL(`/device/selectAccounts?${params.toString()}`, s.qoderLoginHost).toString()
|
|
236
|
+
return { authUrl, verifier, nonce }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 启动登录:置 pending → 返回授权页 URL → 后台轮询直到出令牌/超时。
|
|
241
|
+
* @returns {Promise<{started:true, authUrl:string}>}
|
|
242
|
+
*/
|
|
243
|
+
async function startOAuth(s) {
|
|
244
|
+
if (pending.active) return { started: true, authUrl: pending.authUrl }
|
|
245
|
+
|
|
246
|
+
// 基址校验置于 machine_id 生成之前:非法设置不产生任何写入、不激活 pending。
|
|
247
|
+
assertLoginBase(s.qoderLoginHost)
|
|
248
|
+
const machineId = ensureMachineId()
|
|
249
|
+
const { authUrl, verifier, nonce } = buildAuthUrl(s, machineId)
|
|
250
|
+
// 门禁置于置位之前:拒绝时 pending 不激活,不外泄未过检的 URL。
|
|
251
|
+
assertSafeAuthUrl(authUrl)
|
|
252
|
+
|
|
253
|
+
pending.active = true
|
|
254
|
+
pending.authUrl = authUrl
|
|
255
|
+
pending.error = ''
|
|
256
|
+
|
|
257
|
+
const poll = async () => {
|
|
258
|
+
const gen = pollGeneration
|
|
259
|
+
const alive = () => gen === pollGeneration && pending.active
|
|
260
|
+
const deadline = Date.now() + LOGIN_TIMEOUT_MS
|
|
261
|
+
try {
|
|
262
|
+
while (alive() && Date.now() < deadline) {
|
|
263
|
+
await new Promise((r) => setTimeout(r, LOGIN_POLL_INTERVAL_MS))
|
|
264
|
+
if (!alive()) return
|
|
265
|
+
let res
|
|
266
|
+
try {
|
|
267
|
+
const q = new URLSearchParams({ nonce, verifier, challenge_method: 'S256' })
|
|
268
|
+
res = await fetch(`${api(s, '/api/v1/deviceToken/poll')}?${q}`, { headers: { Accept: 'application/json' } })
|
|
269
|
+
} catch {
|
|
270
|
+
continue // 网络抖动:继续等,不当失败
|
|
271
|
+
}
|
|
272
|
+
// 404 = 未完成(bundle 契约)。绝不与"失败"混同。
|
|
273
|
+
if (res.status === 404) continue
|
|
274
|
+
const body = await res.json().catch(() => null)
|
|
275
|
+
if (!res.ok) {
|
|
276
|
+
const code = body?.errorCode ?? `HTTP ${res.status}`
|
|
277
|
+
pending.error = `登录失败:${code} ${body?.errorMessage ?? ''}`.trim()
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
const auth = tokenFrom(body)
|
|
281
|
+
if (!auth) continue // 200 但无令牌:形态异常,继续观察到超时
|
|
282
|
+
if (!alive()) return // 令牌在飞期间已 logout
|
|
283
|
+
const account = await fetchAccount(s, auth.accessToken)
|
|
284
|
+
if (!alive()) return
|
|
285
|
+
writeAuth({
|
|
286
|
+
...readAuth(),
|
|
287
|
+
auth: { ...auth, loginMethod: 'browser' },
|
|
288
|
+
...(account ? { account } : {}),
|
|
289
|
+
})
|
|
290
|
+
reloginNeeded = false
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
if (alive()) pending.error = '登录超时(5 分钟未完成授权)'
|
|
294
|
+
} finally {
|
|
295
|
+
if (gen === pollGeneration) pending.active = false
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// fire-and-forget 必须 .catch 落地(踩坑 #33:unhandledRejection 崩宿主)。
|
|
299
|
+
poll().catch((err) => { pending.error = String(err?.message ?? err) })
|
|
300
|
+
return { started: true, authUrl }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** 账号信息尽力而为(失败不影响登录成立)。 */
|
|
304
|
+
async function fetchAccount(s, accessToken) {
|
|
305
|
+
try {
|
|
306
|
+
const res = await fetch(api(s, '/api/v1/userinfo'), {
|
|
307
|
+
headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` },
|
|
308
|
+
})
|
|
309
|
+
const body = await res.json().catch(() => null)
|
|
310
|
+
if (!body || typeof body !== 'object') return null
|
|
311
|
+
const uid = body.uid ?? body.user_id ?? body.id ?? null
|
|
312
|
+
const nickname = body.name ?? body.username ?? body.user_name ?? null
|
|
313
|
+
if (!uid && !nickname) return null
|
|
314
|
+
return {
|
|
315
|
+
uid: uid != null ? String(uid) : null,
|
|
316
|
+
nickname,
|
|
317
|
+
email: body.email ?? null,
|
|
318
|
+
organizationId: body.organization_id ?? body.orgId ?? null,
|
|
319
|
+
}
|
|
320
|
+
} catch {
|
|
321
|
+
return null
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** 视图(令牌/machine_id 永不出宿主)。 */
|
|
326
|
+
function oauthStatus() {
|
|
327
|
+
const store = readAuth()
|
|
328
|
+
const auth = store.auth
|
|
329
|
+
const refreshExpired = typeof auth?.refreshExpiresAt === 'number' && auth.refreshExpiresAt > 0
|
|
330
|
+
&& auth.refreshExpiresAt <= Date.now()
|
|
331
|
+
return {
|
|
332
|
+
pending: pending.active,
|
|
333
|
+
authUrl: pending.active ? pending.authUrl : '',
|
|
334
|
+
error: pending.error,
|
|
335
|
+
signedIn: Boolean(auth?.accessToken),
|
|
336
|
+
needsRelogin: Boolean(auth?.accessToken) && (refreshExpired || reloginNeeded),
|
|
337
|
+
account: store.account?.nickname || store.account?.uid
|
|
338
|
+
? { nickname: store.account.nickname, uid: store.account.uid }
|
|
339
|
+
: null,
|
|
340
|
+
accessTokenExpiresAt: auth?.expiresAt ?? null,
|
|
341
|
+
refreshExpiresAt: auth?.refreshExpiresAt ?? null,
|
|
342
|
+
hasMachineId: Boolean(store.machine?.machineId),
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function logout() {
|
|
347
|
+
pollGeneration++ // 令在飞 poll 失效:logout 后才完成的授权不落盘
|
|
348
|
+
storeGeneration++ // 令在飞 refresh 失效:其落盘前的代际校验必然失败
|
|
349
|
+
pending.active = false
|
|
350
|
+
pending.error = ''
|
|
351
|
+
reloginNeeded = false
|
|
352
|
+
const store = readAuth()
|
|
353
|
+
writeAuth({ machine: store.machine }) // 机器标识保留,令牌与账号清除
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
startOAuth,
|
|
358
|
+
resolveQoderCredential,
|
|
359
|
+
refreshOAuth,
|
|
360
|
+
oauthStatus,
|
|
361
|
+
logout,
|
|
362
|
+
ensureMachineId,
|
|
363
|
+
}
|
|
364
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* providers/qoder/quota.js — Qoder CN 账户配额只读快照(设置卡用)。
|
|
3
|
+
*
|
|
4
|
+
* 端点:GET {qoderOpenapiBaseURL}/api/v2/quota/usage,裸 Bearer(openapi 明文面,
|
|
5
|
+
* 不走 COSY 签名——签名是 infer 面与 /algo 目录面的事)。响应形态实测见
|
|
6
|
+
* scripts/probe-qoder-quota.mjs 落盘(docs/probes/qoder-quota-*.json):
|
|
7
|
+
* { userQuota:{total,used,remaining,percentage,unit},
|
|
8
|
+
* addOnQuota:{total,used,remaining,percentage,unit,detailUrl}, … }
|
|
9
|
+
* addOnQuota.used 是实时配额计数器(配额扣减即时入账,臂 9 定论)。
|
|
10
|
+
* 成本纪律:60s memoize(与 codebuddy quotaSnapshot 同口径);永不 throw——
|
|
11
|
+
* 失败回 { error },调用方(设置卡)据此显示「—」,绝不编造数值。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export function createQoderQuota({ settings, oauth }) {
|
|
15
|
+
let cache = { at: 0, value: null }
|
|
16
|
+
|
|
17
|
+
async function fetchSnapshot() {
|
|
18
|
+
const s = settings()
|
|
19
|
+
const cred = await oauth.resolveQoderCredential(s)
|
|
20
|
+
if (!cred) return { error: '未登录', fetchedAt: Date.now() }
|
|
21
|
+
const res = await fetch(`${s.qoderOpenapiBaseURL}/api/v2/quota/usage`, {
|
|
22
|
+
headers: { Authorization: cred.authorization, Accept: 'application/json' },
|
|
23
|
+
signal: AbortSignal.timeout(15_000),
|
|
24
|
+
})
|
|
25
|
+
const body = await res.json().catch(() => null)
|
|
26
|
+
if (!res.ok || !body || typeof body !== 'object') {
|
|
27
|
+
return { error: `http ${res.status}`, fetchedAt: Date.now() }
|
|
28
|
+
}
|
|
29
|
+
const pick = (q) => (q && typeof q === 'object')
|
|
30
|
+
? {
|
|
31
|
+
total: typeof q.total === 'number' ? q.total : null,
|
|
32
|
+
used: typeof q.used === 'number' ? q.used : null,
|
|
33
|
+
remaining: typeof q.remaining === 'number' ? q.remaining : null,
|
|
34
|
+
unit: typeof q.unit === 'string' ? q.unit : null,
|
|
35
|
+
}
|
|
36
|
+
: null
|
|
37
|
+
return {
|
|
38
|
+
userQuota: pick(body.userQuota),
|
|
39
|
+
addOnQuota: pick(body.addOnQuota),
|
|
40
|
+
isQuotaExceeded: body.isQuotaExceeded === true,
|
|
41
|
+
fetchedAt: Date.now(),
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function snapshot() {
|
|
46
|
+
if (cache.value && Date.now() - cache.at < 60_000) return Promise.resolve(cache.value)
|
|
47
|
+
return fetchSnapshot()
|
|
48
|
+
.catch((err) => ({ error: err?.message ?? String(err), fetchedAt: Date.now() }))
|
|
49
|
+
.then((value) => {
|
|
50
|
+
cache = { at: Date.now(), value }
|
|
51
|
+
return value
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { snapshot }
|
|
56
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createOpenAICompatProvider } from '../openai-compat.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Qwen Code(portal.qwen.ai)preset。2026-08-19 实测:GET /models 不存在
|
|
5
|
+
* (404),/chat/completions 认证方言 = 标准 401 invalid_api_key。
|
|
6
|
+
* 注意:Qwen OAuth 免费额度已于 2026-04-15 停服(官方文档),本机
|
|
7
|
+
* oauth_creds.json 里的存量 token 大概率已失效——导入时探针会如实拒绝,
|
|
8
|
+
* 不留半成品。Coding Plan(sk-sp- key)是另一端点 coding.dashscope.aliyuncs.com。
|
|
9
|
+
*/
|
|
10
|
+
export default createOpenAICompatProvider({
|
|
11
|
+
id: 'qwen',
|
|
12
|
+
displayName: 'Qwen Code',
|
|
13
|
+
baseURL: 'https://portal.qwen.ai/v1',
|
|
14
|
+
fallbackModels: ['qwen3-coder-plus', 'qwen3-coder-flash'],
|
|
15
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* providers/tool-pairing.js — 出站 messages 的 tool 配对/可见性不变量修复(两个翻译网关共用)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它(2026-09-22 实测根因,踩坑 #39 + #41):
|
|
5
|
+
*
|
|
6
|
+
* A. 孤儿 tool(#39):宿主 `@earendil-works/pi-ai` 的 `transform-messages.js` 会把
|
|
7
|
+
* `stopReason` 为 `error`/`aborted` 的 assistant 消息**整条丢弃**(理由:中断轮次含
|
|
8
|
+
* 半截推理/工具调用,重放会触发 API 错误),但它产出的 `toolResult` 消息照旧保留——
|
|
9
|
+
* 于是出站序列里出现孤儿 `role:"tool"`(无前置 `assistant.tool_calls`)。
|
|
10
|
+
*
|
|
11
|
+
* B. 不可见 content(#41,真主因):严格上游(Qoder 的 dmodel/kmodel/mmodel 家族)的
|
|
12
|
+
* 配对校验器**把 `content` 为 `null`/缺键的消息整条当不存在**——`content:null` 的
|
|
13
|
+
* assistant.tool_calls 声明不再算"preceding message with tool_calls",`content:null`
|
|
14
|
+
* 的 tool 结果也不再算"responding to tool_call_id"。而 pi-ai 在
|
|
15
|
+
* `compat.requiresAssistantAfterToolResult === false`(provider 非 deepseek/moonshot/
|
|
16
|
+
* nvidia… 时的探测默认)下,**每个纯工具轮的 assistant 都是 `content: null`**
|
|
17
|
+
* (openai-completions.js:961),于是每一个正常工具轮都必 400。修复 A 补的桩若也用
|
|
18
|
+
* `content: null`,同样被判孤儿(实测同一条 400 文案)。
|
|
19
|
+
*
|
|
20
|
+
* 症状(两因同果):`provider_error` + details 内层
|
|
21
|
+
* `Messages with role 'tool' must be a response to a preceding message with 'tool_calls'`
|
|
22
|
+
* (dmodel)/ `Invalid request: tool_call_id is not found`(kmodel)/
|
|
23
|
+
* `invalid params, tool result's tool id(…) not found (2013)`(mmodel)。
|
|
24
|
+
* 触发一次即永久:失败那轮写进历史后,此后每次请求都带同一坏体("怎么重试都是同一个错")。
|
|
25
|
+
*
|
|
26
|
+
* 修复纪律:只补**结构**与**内容可见性**(null↔'' 在 OpenAI 方言里语义等价),
|
|
27
|
+
* 不碰任何字段取值语义——合法非空历史逐字节不变。
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** tool 配对修复时给"不可用"结果用的替代正文(留给模型读)。 */
|
|
31
|
+
export const INTERRUPTED_TOOL_RESULT = '(tool result unavailable: previous attempt was interrupted)'
|
|
32
|
+
|
|
33
|
+
/** assistant / tool 消息 content 为 null/缺失时的替代正文(语义等价,且严格上游认它)。 */
|
|
34
|
+
export const EMPTY_CONTENT = ''
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 修复 OpenAI messages 的 tool 配对 + 内容可见性不变量。
|
|
38
|
+
*
|
|
39
|
+
* - `content` 为 `null`/缺键的 assistant / tool 消息 → 补成 `''`(规则 B);
|
|
40
|
+
* - 孤儿 `role:"tool"`(前置 assistant 里没有这条 tool_call)→ 补一条仅含该
|
|
41
|
+
* tool_call 的 assistant 桩(id 取原 tool_call_id,name 取 tool 消息的 name 或
|
|
42
|
+
* `'tool'`,content 用 `''`),结果原样保留——比丢弃结果更保上下文;
|
|
43
|
+
* - 同 id 的重复结果 → 丢弃(上游会把第二条当孤儿);
|
|
44
|
+
* - assistant 声明了却没结果的 tool_call(会话收尾 / user 插在结果前)→ 补
|
|
45
|
+
* {@link INTERRUPTED_TOOL_RESULT} 结果,保住配对。
|
|
46
|
+
*
|
|
47
|
+
* @param {Array} messages OpenAI 方言 messages
|
|
48
|
+
* @returns {{messages: Array, repaired: {orphans: number, synthesized: number, duplicates: number, invisible: number}}}
|
|
49
|
+
*/
|
|
50
|
+
export function sanitizeToolPairing(messages) {
|
|
51
|
+
const out = []
|
|
52
|
+
const repaired = { orphans: 0, synthesized: 0, duplicates: 0, invisible: 0 }
|
|
53
|
+
let pending = null // 最近一条 assistant 声明、尚未配齐结果的 tool_call id 集合
|
|
54
|
+
let resolved = new Set() // 本 assistant 段内已收到结果的 id(挡同 id 重复结果)
|
|
55
|
+
|
|
56
|
+
/** 可见性归一:null/缺失 content → ''(严格上游的配对校验器不认 null 载体)。 */
|
|
57
|
+
const visible = (msg) => {
|
|
58
|
+
if (msg.content !== null && msg.content !== undefined) return msg
|
|
59
|
+
repaired.invisible++
|
|
60
|
+
return { ...msg, content: EMPTY_CONTENT }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 给所有未回结果的 tool_call 补"不可用"结果。 */
|
|
64
|
+
const flushPending = () => {
|
|
65
|
+
if (!pending || !pending.size) return
|
|
66
|
+
for (const id of pending) {
|
|
67
|
+
out.push({ role: 'tool', tool_call_id: id, content: INTERRUPTED_TOOL_RESULT })
|
|
68
|
+
repaired.synthesized++
|
|
69
|
+
}
|
|
70
|
+
pending = null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const msg of Array.isArray(messages) ? messages : []) {
|
|
74
|
+
if (!msg || typeof msg !== 'object') continue
|
|
75
|
+
if (msg.role === 'assistant') {
|
|
76
|
+
flushPending()
|
|
77
|
+
resolved = new Set()
|
|
78
|
+
const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : []
|
|
79
|
+
const ids = calls.map((tc) => (typeof tc?.id === 'string' && tc.id ? tc.id : null)).filter(Boolean)
|
|
80
|
+
pending = ids.length ? new Set(ids) : null
|
|
81
|
+
out.push(visible(msg))
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if (msg.role === 'tool') {
|
|
85
|
+
const id = typeof msg.tool_call_id === 'string' ? msg.tool_call_id : ''
|
|
86
|
+
if (id && resolved.has(id)) {
|
|
87
|
+
repaired.duplicates++
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (pending && id && pending.has(id)) {
|
|
91
|
+
pending.delete(id)
|
|
92
|
+
resolved.add(id)
|
|
93
|
+
out.push(visible(msg))
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
repaired.orphans++
|
|
97
|
+
const callId = id || `call_orphan_${repaired.orphans}`
|
|
98
|
+
out.push(visible({
|
|
99
|
+
role: 'assistant',
|
|
100
|
+
content: EMPTY_CONTENT,
|
|
101
|
+
tool_calls: [{
|
|
102
|
+
id: callId,
|
|
103
|
+
type: 'function',
|
|
104
|
+
function: {
|
|
105
|
+
name: typeof msg.name === 'string' && msg.name ? msg.name : 'tool',
|
|
106
|
+
arguments: '{}',
|
|
107
|
+
},
|
|
108
|
+
}],
|
|
109
|
+
}))
|
|
110
|
+
out.push(visible(id ? msg : { ...msg, tool_call_id: callId }))
|
|
111
|
+
if (id) resolved.add(id)
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
// user / system 插在结果之前:补齐未回结果的 tool_call
|
|
115
|
+
flushPending()
|
|
116
|
+
out.push(msg)
|
|
117
|
+
}
|
|
118
|
+
// 会话以未回结果的 tool_call 收尾:同样补齐
|
|
119
|
+
flushPending()
|
|
120
|
+
return { messages: out, repaired }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 取证日志用的单行摘要(无修复时返回 null)。 */
|
|
124
|
+
export function describeRepair(repaired) {
|
|
125
|
+
if (!repaired) return null
|
|
126
|
+
const { orphans = 0, synthesized = 0, duplicates = 0, invisible = 0 } = repaired
|
|
127
|
+
if (!orphans && !synthesized && !duplicates && !invisible) return null
|
|
128
|
+
return `orphans=${orphans} synthesized=${synthesized} duplicates=${duplicates} invisible=${invisible}`
|
|
129
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* providers/trae/catalog.js — Trae 本地模型目录接入。
|
|
3
|
+
*
|
|
4
|
+
* 数据源是本机 TRAE SOLO CN 的 VS Code 全局状态库(state.vscdb 的
|
|
5
|
+
* AI.agent.model.model_list_map),由 scripts/trae-model-catalog.mjs 的纯函数
|
|
6
|
+
* 负责(发现/复制/解析/归一化/scrub——该脚本的 CLI 入口带 import.meta 守卫,
|
|
7
|
+
* 作为库导入不会执行 main)。这里只做"目录 → dsh 模型 profile"的映射与镜像。
|
|
8
|
+
*
|
|
9
|
+
* 映射规则(docs/reverse/trae-model-catalog.md 的字段说明为裁判):
|
|
10
|
+
* - 只收 preset 型条目(provider 为空)——deepseek//… 等 BYOK 条目路由到
|
|
11
|
+
* 用户自己的 provider,经 Trae 通道会失败;
|
|
12
|
+
* - id 用目录原 id(dsh 里模型 id 按 provider 命名空间隔离,与 codebuddy
|
|
13
|
+
* 重名不冲突);
|
|
14
|
+
* - contextWindow 取 contextWindowDefault,缺失回落 max(数组取最大档);
|
|
15
|
+
* - maxTokens 取 maxOutputTokens;multimodal → input [text,image];
|
|
16
|
+
* - reasoning_effort_config.options → reasoningEfforts(键 = 宿主枚举映射
|
|
17
|
+
* TRAE_EFFORT_TO_HOST_KEY/枚举内恒等,值 = 声明拼写;support_thinking
|
|
18
|
+
* 非 true 或空 options 不出档)。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
22
|
+
import { tmpdir } from 'node:os'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
discoverStateDbs, copySqliteForRead, readModelListCandidates,
|
|
27
|
+
selectDefaultCandidate, normalizeCatalog,
|
|
28
|
+
} from '../../scripts/trae-model-catalog.mjs'
|
|
29
|
+
|
|
30
|
+
// Trae 声明拼写 → 宿主枚举键(保序映射:light<high<extra_high ↔ low<high<xhigh)。
|
|
31
|
+
// 宿主 llm-pi-ai 对 models[].reasoningEfforts 的键有固定枚举
|
|
32
|
+
//(off|minimal|low|medium|high|xhigh|max)——声明拼写原样做键会被宿主 schema
|
|
33
|
+
// 整块拒收(2026-10-04 实测 host-config lastError),值则恒为目录声明拼写
|
|
34
|
+
//(remote 通道 custom_model.reasoning_effort 线值,不臆造,踩坑 #42)。
|
|
35
|
+
const TRAE_EFFORT_TO_HOST_KEY = { light: 'low', high: 'high', extra_high: 'xhigh' }
|
|
36
|
+
// 宿主枚举全集:声明拼写本就在枚举内时恒等透传(如未来上游声明 low/max)。
|
|
37
|
+
const HOST_EFFORT_ENUM = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
|
|
38
|
+
|
|
39
|
+
/** 目录条目 → dsh 模型 profile(可路由集合 = preset 且未禁用)。 */
|
|
40
|
+
export function catalogToProfiles(catalog) {
|
|
41
|
+
const list = []
|
|
42
|
+
for (const m of catalog?.models ?? []) {
|
|
43
|
+
if (m.provider) continue // BYOK/托管条目不经 Trae 通道
|
|
44
|
+
if (m.selectable === false || m.status === false) continue
|
|
45
|
+
const p = { id: m.id, name: m.displayName ?? m.id }
|
|
46
|
+
const ctx = m.contextWindowDefault
|
|
47
|
+
?? (Array.isArray(m.contextWindowMax) ? Math.max(...m.contextWindowMax) : m.contextWindowMax)
|
|
48
|
+
?? null
|
|
49
|
+
if (ctx != null) p.contextWindow = ctx
|
|
50
|
+
if (m.maxOutputTokens != null) p.maxTokens = m.maxOutputTokens
|
|
51
|
+
if (m.multimodal === true) p.input = ['text', 'image']
|
|
52
|
+
// 档位投影(goal: trae-work-cn-repair G3):目录 reasoning_effort_config
|
|
53
|
+
// 声明 {support_thinking, options, default_level} → reasoningEfforts。
|
|
54
|
+
// supportThinking 非 true 或空 options 不出档(不摆假档位);未映射拼写
|
|
55
|
+
// 不进宿主表(仍可由设置卡档位控件按声明拼写使用)。
|
|
56
|
+
const eff = m.reasoningEffortConfig
|
|
57
|
+
if (eff && eff.supportThinking === true && Array.isArray(eff.options) && eff.options.length) {
|
|
58
|
+
const table = {}
|
|
59
|
+
for (const opt of eff.options) {
|
|
60
|
+
const hostKey = TRAE_EFFORT_TO_HOST_KEY[opt] ?? (HOST_EFFORT_ENUM.has(opt) ? opt : undefined)
|
|
61
|
+
if (hostKey) table[hostKey] = opt
|
|
62
|
+
}
|
|
63
|
+
// max 档:目录不声明但上游实测接受且产生比 extra_high 更多的推理内容
|
|
64
|
+
//(2026-10-05 探针 docs/probes/trae-max-effort-1791203547000.json:
|
|
65
|
+
// glm-5.3 high 2788 < extra_high 3209 < max 4139 推理字符,方向一致)。
|
|
66
|
+
// 只给声明了 extra_high 的模型追加(extra_high 是目录最高声明档位,
|
|
67
|
+
// max 作为其上位档;无 extra_high 的模型不加——不臆造未测路径)。
|
|
68
|
+
if (eff.options.includes('extra_high') && !table.max) table.max = 'max'
|
|
69
|
+
if (Object.keys(table).length) p.reasoningEfforts = table
|
|
70
|
+
}
|
|
71
|
+
list.push(p)
|
|
72
|
+
}
|
|
73
|
+
return list
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 从本机 state.vscdb 拉一次目录(发现 → 副本 → 解析 → 归一化)。
|
|
78
|
+
* 返回 { profiles, catalog, fetchedAt, source };任何一步失败抛错(调用方
|
|
79
|
+
* 决定保底策略——沿用旧镜像或清空)。
|
|
80
|
+
*/
|
|
81
|
+
export function fetchLocalCatalog({ dbPath } = {}) {
|
|
82
|
+
const target = dbPath ?? discoverStateDbs()[0]?.dbPath
|
|
83
|
+
if (!target) throw new Error('未发现 TRAE SOLO CN 的 state.vscdb(需要 --db 或本机安装)')
|
|
84
|
+
const tmp = mkdtempSync(join(tmpdir(), 'trae-catalog-'))
|
|
85
|
+
try {
|
|
86
|
+
const copy = copySqliteForRead(target, tmp)
|
|
87
|
+
const candidates = readModelListCandidates(copy)
|
|
88
|
+
for (const c of candidates) c.dbPath = target // 指纹指向原始路径
|
|
89
|
+
const best = selectDefaultCandidate(candidates)
|
|
90
|
+
if (!best) throw new Error('state.vscdb 中没有可解析的 model_list_map 候选')
|
|
91
|
+
const catalog = normalizeCatalog(best)
|
|
92
|
+
const profiles = catalogToProfiles(catalog)
|
|
93
|
+
if (!profiles.length) throw new Error('目录归一化后无可路由模型(全为 BYOK 或禁用?)')
|
|
94
|
+
return {
|
|
95
|
+
profiles,
|
|
96
|
+
catalog,
|
|
97
|
+
fetchedAt: catalog.source.generatedAt,
|
|
98
|
+
source: { candidateCount: candidates.length, chosen: catalog.source.itemKeyFingerprint },
|
|
99
|
+
}
|
|
100
|
+
} finally {
|
|
101
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
102
|
+
}
|
|
103
|
+
}
|