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.
Files changed (121) hide show
  1. package/AGENTS.md +99 -0
  2. package/CHANGELOG.md +751 -0
  3. package/LICENSE +21 -0
  4. package/README.en.md +59 -0
  5. package/README.md +256 -0
  6. package/cordis.patch.yml +385 -0
  7. package/core/bridge.js +698 -0
  8. package/core/json-store.js +91 -0
  9. package/core/rotation.js +108 -0
  10. package/core/usage-meter.js +176 -0
  11. package/docs/diagnosis-cache-quota.md +181 -0
  12. package/docs/diagnosis-qoder-flash.md +67 -0
  13. package/docs/diagnosis-trae-3003.md +302 -0
  14. package/docs/goals/bridge-port-host-split.md +91 -0
  15. package/docs/goals/desktop-adaptation.md +106 -0
  16. package/docs/goals/qoder-cn-provider-design.md +308 -0
  17. package/docs/goals/settings-card-ux-redesign-plan.md +1680 -0
  18. package/docs/goals/settings-card-ux-redesign.md +162 -0
  19. package/docs/goals/trae-agent-v3.md +41 -0
  20. package/docs/goals/trae-work-cn-repair.md +44 -0
  21. 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
  22. package/docs/pitfalls.md +105 -0
  23. package/docs/reverse/trae-cloud-api.md +218 -0
  24. package/docs/reverse/trae-model-catalog.md +129 -0
  25. package/docs/reverse/traework-cn.md +520 -0
  26. package/docs/rules/STATE.md +198 -0
  27. package/docs/rules/content-moderation.md +54 -0
  28. package/docs/rules/dev-role-boundary.md +94 -0
  29. package/docs/rules/extra-providers.md +42 -0
  30. package/docs/rules/gateway-facts.md +91 -0
  31. package/docs/rules/oauth-handshake.md +76 -0
  32. package/docs/rules/prompt-cache.md +93 -0
  33. package/docs/rules/quota-signals.md +125 -0
  34. package/docs/rules/routing.md +84 -0
  35. package/docs/rules/templates/oauth-reverse-checklist.md +42 -0
  36. package/docs/rules/trae-surface.md +242 -0
  37. package/docs/rules/ua-validation.md +81 -0
  38. package/host-config.js +282 -0
  39. package/index.js +2306 -0
  40. package/lib/client.js +2893 -0
  41. package/local-scan.js +104 -0
  42. package/package.json +82 -0
  43. package/providers/ark/index.js +11 -0
  44. package/providers/bailian/index.js +10 -0
  45. package/providers/bigmodel/index.js +11 -0
  46. package/providers/codebuddy/agenttool.js +122 -0
  47. package/providers/codebuddy/catalog.js +227 -0
  48. package/providers/codebuddy/errors.js +44 -0
  49. package/providers/codebuddy/headers.js +36 -0
  50. package/providers/codebuddy/images.js +125 -0
  51. package/providers/codebuddy/index.js +123 -0
  52. package/providers/codebuddy/oauth.js +279 -0
  53. package/providers/deepseek/index.js +11 -0
  54. package/providers/moonshot/index.js +11 -0
  55. package/providers/openai-compat.js +177 -0
  56. package/providers/openrouter/index.js +27 -0
  57. package/providers/qoder/catalog.js +145 -0
  58. package/providers/qoder/cosy.js +419 -0
  59. package/providers/qoder/gateway.js +563 -0
  60. package/providers/qoder/index.js +116 -0
  61. package/providers/qoder/oauth.js +364 -0
  62. package/providers/qoder/qoder_auth.wasm +0 -0
  63. package/providers/qoder/quota.js +56 -0
  64. package/providers/qwen/index.js +15 -0
  65. package/providers/tool-pairing.js +129 -0
  66. package/providers/trae/catalog.js +103 -0
  67. package/providers/trae/errors.js +85 -0
  68. package/providers/trae/gateway.js +853 -0
  69. package/providers/trae/index.js +126 -0
  70. package/providers/trae/oauth.js +443 -0
  71. package/providers/trae/quota.js +75 -0
  72. package/providers/trae/remote.js +365 -0
  73. package/scripts/capture-cache.mjs +65 -0
  74. package/scripts/capture-traffic.mjs +83 -0
  75. package/scripts/hermes-probe-dev-role.mjs +263 -0
  76. package/scripts/measure-latency.mjs +253 -0
  77. package/scripts/probe-ark-thinking.mjs +298 -0
  78. package/scripts/probe-cache-decline.mjs +292 -0
  79. package/scripts/probe-cache-ttl.mjs +221 -0
  80. package/scripts/probe-cache.mjs +156 -0
  81. package/scripts/probe-codebuddy-efforts.mjs +355 -0
  82. package/scripts/probe-codebuddy-tier-wiring.mjs +244 -0
  83. package/scripts/probe-effort-gaps.mjs +133 -0
  84. package/scripts/probe-media.mjs +115 -0
  85. package/scripts/probe-moderation.mjs +159 -0
  86. package/scripts/probe-oauth.mjs +617 -0
  87. package/scripts/probe-qoder-attribution-arm8.mjs +92 -0
  88. package/scripts/probe-qoder-attribution-arm9.mjs +102 -0
  89. package/scripts/probe-qoder-attribution.mjs +266 -0
  90. package/scripts/probe-qoder-flash-confirm.mjs +94 -0
  91. package/scripts/probe-qoder-live.mjs +344 -0
  92. package/scripts/probe-qoder-matrix.mjs +274 -0
  93. package/scripts/probe-qoder-null-content.mjs +153 -0
  94. package/scripts/probe-qoder-pairing.mjs +308 -0
  95. package/scripts/probe-qoder-quota.mjs +162 -0
  96. package/scripts/probe-qoder-thinking-config.mjs +52 -0
  97. package/scripts/probe-qoder-thinking-efforts.mjs +269 -0
  98. package/scripts/probe-quota.mjs +136 -0
  99. package/scripts/probe-quota2.mjs +144 -0
  100. package/scripts/probe-routing.mjs +226 -0
  101. package/scripts/probe-trae-3003-diagnosis.mjs +139 -0
  102. package/scripts/probe-trae-agent-v3.mjs +399 -0
  103. package/scripts/probe-trae-efforts.mjs +149 -0
  104. package/scripts/probe-trae-live.mjs +147 -0
  105. package/scripts/probe-trae-max-effort.mjs +179 -0
  106. package/scripts/probe-trae-model-routing.mjs +381 -0
  107. package/scripts/probe-trae-thinking-scene.mjs +238 -0
  108. package/scripts/probe-trae-transport-outage.mjs +176 -0
  109. package/scripts/probe-ua.mjs +508 -0
  110. package/scripts/trae-model-catalog.mjs +632 -0
  111. package/scripts/verify-agents-md.mjs +45 -0
  112. package/scripts/verify-bridge.mjs +1041 -0
  113. package/scripts/verify-core-generic.mjs +302 -0
  114. package/scripts/verify-desktop-acceptance.mjs +184 -0
  115. package/scripts/verify-host-config.mjs +265 -0
  116. package/scripts/verify-models.mjs +390 -0
  117. package/scripts/verify-providers.mjs +255 -0
  118. package/scripts/verify-qoder-provider.mjs +1020 -0
  119. package/scripts/verify-rotation.mjs +308 -0
  120. package/scripts/verify-trae-model-catalog.mjs +284 -0
  121. 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
+ }