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,265 @@
1
+ /**
2
+ * verify-host-config.mjs — 宿主配置层(host-config.js)离线回归。
3
+ *
4
+ * 锁定 dsh 0.1.7 的配置持久化迁移:settings.yaml(dsh-settings-file,已删除)
5
+ * → profile 的 cordis.patch.yml(dsh-settings 的 forms seam + dsh-config-editor)。
6
+ * 两代宿主的选路、写前比对、revision 冲突重试、不可写降级、legacy 回退的注释
7
+ * 保留,全部用假服务/临时文件断言,不碰真实 ~/.dsh。
8
+ *
9
+ * 跑法:node scripts/verify-host-config.mjs
10
+ */
11
+ import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import { createHostConfigLayer } from '../host-config.js'
15
+
16
+ let pass = 0
17
+ let fail = 0
18
+ const ok = (desc, cond, extra = '') => {
19
+ if (cond) { pass++; console.log(` ok ${desc}`) }
20
+ else { fail++; console.log(` FAIL ${desc}${extra ? ' — ' + extra : ''}`) }
21
+ }
22
+ const section = (t) => console.log(`\n[${t}]`)
23
+
24
+ // --- 假宿主:0.1.7 形态的 Settings forms 服务 -------------------------------
25
+ function makeFormsService(opts = {}) {
26
+ const state = { providers: structuredClone(opts.providers ?? {}) }
27
+ let revision = opts.revision ?? 1
28
+ let conflicts = opts.conflicts ?? 0
29
+ const calls = { mutate: [], configure: [], register: [], dispose: 0 }
30
+ const svc = {
31
+ calls,
32
+ get writable() { return opts.writable !== false },
33
+ documentPath: opts.documentPath ?? '/fake/.dsh/profiles/web/cordis.patch.yml',
34
+ prepareDocument: async () => svc.documentPath,
35
+ configure(presentation, owner) {
36
+ calls.configure.push({ presentation, owner })
37
+ return () => { calls.dispose++ }
38
+ },
39
+ describe() {
40
+ return [
41
+ { ns: 'llm-pi-ai', revision, value: { providers: state.providers }, autoGenerate: false, applies: 'live', schema: { uid: 'x' } },
42
+ { ns: 'ui-theme', revision: 9, value: { preference: 'dark' }, autoGenerate: true, applies: 'live', schema: {} },
43
+ ]
44
+ },
45
+ async mutate(ns, ops, expected) {
46
+ calls.mutate.push({ ns, ops: structuredClone(ops), expected })
47
+ if (expected !== revision) {
48
+ const e = new Error(`expected ${expected}, actual ${revision}`)
49
+ e.code = 'SETTINGS_CONFLICT'
50
+ throw e
51
+ }
52
+ if (conflicts > 0) {
53
+ conflicts--
54
+ revision++
55
+ const e = new Error('injected conflict')
56
+ e.code = 'SETTINGS_CONFLICT'
57
+ throw e
58
+ }
59
+ for (const op of ops) {
60
+ const path = op.path
61
+ if (op.op === 'unset') {
62
+ let cur = state
63
+ for (let i = 0; i < path.length - 1; i++) cur = cur?.[path[i]]
64
+ if (cur) delete cur[path[path.length - 1]]
65
+ } else {
66
+ let cur = state
67
+ for (let i = 0; i < path.length - 1; i++) { cur[path[i]] ??= {}; cur = cur[path[i]] }
68
+ cur[path[path.length - 1]] = structuredClone(op.value)
69
+ }
70
+ }
71
+ revision++
72
+ },
73
+ _state: state,
74
+ _revision: () => revision,
75
+ }
76
+ return svc
77
+ }
78
+
79
+ /** 只有 register() 的旧宿主(≤0.1.6)。 */
80
+ function makeLegacyService() {
81
+ const calls = { register: [], configure: [] }
82
+ return { calls, register(ns, schema) { calls.register.push({ ns, schema }) } }
83
+ }
84
+
85
+ function fakeCtx(service, fiber = { id: 'dsh-tap-fiber' }) {
86
+ return {
87
+ fiber,
88
+ inject(deps, cb) {
89
+ if (Array.isArray(deps) && deps.includes('settings')) cb({ settings: service })
90
+ },
91
+ }
92
+ }
93
+
94
+ const TRAE_BLOCK = {
95
+ displayName: 'TraeWork CN',
96
+ api: 'openai-completions',
97
+ baseURL: 'http://127.0.0.1:3902/v1',
98
+ headers: { Authorization: 'Bearer dsh-trae-bridge' },
99
+ models: [{ id: 'tm1', name: 'Trae M1', contextWindow: 128000, maxTokens: 32000 }],
100
+ }
101
+
102
+ // === A. forms 选路(dsh 0.1.7+)============================================
103
+ section('A] forms seam(0.1.7+:写落 profile patch)')
104
+ {
105
+ const svc = makeFormsService()
106
+ const layer = createHostConfigLayer({ settingsPath: '/nonexistent/settings.yaml', schema: { fake: true }, log: () => {} })
107
+ layer.attach(fakeCtx(svc))
108
+
109
+ ok('A1 attach 走 configure({auto:false}) 而不是 register',
110
+ svc.calls.configure.length === 1 && svc.calls.configure[0].presentation.auto === false && svc.calls.register.length === 0,
111
+ JSON.stringify({ configure: svc.calls.configure.length, register: svc.calls.register.length }))
112
+ ok('A2 configure 带上调用方 fiber(策略归属本插件实例)',
113
+ svc.calls.configure[0]?.owner?.id === 'dsh-tap-fiber')
114
+ ok('A3 mode() = forms', layer.mode() === 'forms', layer.mode())
115
+ ok('A4 servicePresent() = true', layer.servicePresent() === true)
116
+
117
+ const r1 = await layer.applyOps([{ op: 'set', path: ['providers', 'trae'], value: TRAE_BLOCK }])
118
+ ok('A5 set 新块:ok 且 changed', r1.ok === true && r1.changed === true, JSON.stringify(r1))
119
+ ok('A6 mutate 命名空间 = profile entry id llm-pi-ai', svc.calls.mutate[0]?.ns === 'llm-pi-ai', svc.calls.mutate[0]?.ns)
120
+ ok('A7 mutate 带 describe 拿到的 expectedRevision', svc.calls.mutate[0]?.expected === 1, String(svc.calls.mutate[0]?.expected))
121
+ ok('A8 块真的进了有效配置', layer.readProviders().trae?.baseURL === 'http://127.0.0.1:3902/v1')
122
+
123
+ const before = svc.calls.mutate.length
124
+ const r2 = await layer.applyOps([{ op: 'set', path: ['providers', 'trae'], value: structuredClone(TRAE_BLOCK) }])
125
+ ok('A9 同值重写不再 mutate(避免 revision 抖动/reload 风暴)',
126
+ r2.ok === true && r2.changed === false && svc.calls.mutate.length === before, JSON.stringify(r2))
127
+
128
+ const r3 = await layer.applyOps([{ op: 'unset', path: ['providers', 'nope'] }])
129
+ ok('A10 unset 不存在的路径:不写', r3.ok === true && r3.changed === false && svc.calls.mutate.length === before)
130
+
131
+ const r4 = await layer.applyOps([{ op: 'unset', path: ['providers', 'trae'] }])
132
+ ok('A11 unset 存在的路径:删除生效', r4.ok === true && r4.changed === true && layer.readProviders().trae === undefined)
133
+
134
+ const r5 = await layer.applyOps([{ op: 'set', path: ['providers', 'codebuddy', 'models'], value: [{ id: 'deepseek-v3', name: 'DeepSeek V3', contextWindow: 131072, maxTokens: 32768 }] }])
135
+ ok('A12 深路径(providers.codebuddy.models)可写', r5.ok === true && layer.readProviders().codebuddy?.models?.[0]?.id === 'deepseek-v3')
136
+
137
+ const p = layer.probe()
138
+ ok('A13 probe 报 forms/可写/documentPath/entry/autoGenerate',
139
+ p.mode === 'forms' && p.writable === true && /cordis\.patch\.yml$/.test(p.documentPath) && p.entryNs === 'llm-pi-ai' && p.autoGenerate === false,
140
+ JSON.stringify({ mode: p.mode, writable: p.writable, doc: p.documentPath, ns: p.entryNs, auto: p.autoGenerate }))
141
+ ok('A14 probe 列出全部命名空间(升级排查用)', Array.isArray(p.allNamespaces) && p.allNamespaces.includes('ui-theme'))
142
+
143
+ const disposer = svc.calls.dispose
144
+ layer.dispose()
145
+ ok('A15 dispose 释放 configure 的 effect', svc.calls.dispose === disposer + 1)
146
+ }
147
+
148
+ section('A] forms seam:冲突与降级')
149
+ {
150
+ const svc = makeFormsService({ conflicts: 1 })
151
+ const layer = createHostConfigLayer({ settingsPath: '/nonexistent/settings.yaml', schema: {}, log: () => {} })
152
+ layer.attach(fakeCtx(svc))
153
+ const r = await layer.applyOps([{ op: 'set', path: ['providers', 'trae'], value: TRAE_BLOCK }])
154
+ ok('A16 SETTINGS_CONFLICT 后重读 revision 重试成功',
155
+ r.ok === true && svc.calls.mutate.length === 2 && svc.calls.mutate[1].expected === 2,
156
+ JSON.stringify({ ok: r.ok, n: svc.calls.mutate.length, exp: svc.calls.mutate.map((c) => c.expected) }))
157
+ }
158
+ {
159
+ const svc = makeFormsService({ writable: false })
160
+ const layer = createHostConfigLayer({ settingsPath: '/nonexistent/settings.yaml', schema: {}, log: () => {} })
161
+ layer.attach(fakeCtx(svc))
162
+ const r = await layer.applyOps([{ op: 'set', path: ['providers', 'trae'], value: TRAE_BLOCK }])
163
+ ok('A17 不可写 profile:返回失败而不是抛(踩坑 #33)',
164
+ r.ok === false && /SETTINGS_NOT_WRITABLE/.test(r.error ?? '') && svc.calls.mutate.length === 0, JSON.stringify(r))
165
+ ok('A18 失败落 lastError', layer.lastError === r.error)
166
+ }
167
+ {
168
+ const svc = makeFormsService()
169
+ svc.mutate = async () => { throw new Error('boom upstream') }
170
+ const layer = createHostConfigLayer({ settingsPath: '/nonexistent/settings.yaml', schema: {}, log: () => {} })
171
+ layer.attach(fakeCtx(svc))
172
+ const r = await layer.applyOps([{ op: 'set', path: ['providers', 'trae'], value: TRAE_BLOCK }])
173
+ ok('A19 mutate 抛非冲突错:不 reject,带错误码前缀', r.ok === false && /boom upstream/.test(r.error ?? ''), JSON.stringify(r))
174
+ const r2 = await layer.applyOps([])
175
+ ok('A20 空 ops 直接成功且不触服务', r2.ok === true && r2.changed === false)
176
+ }
177
+
178
+ // === A2. 宿主替换 Settings 实例(0.1.7 profile 重载语义)====================
179
+ section('A] 宿主替换 Settings 实例后仍能写(踩坑 #43 同批实测)')
180
+ {
181
+ // 每次写 profile patch 都会触发重载,Settings 服务实例可能被**替换**;
182
+ // cordis 的 ctx.settings 是实时 getter,缓存旧引用会带着陈旧 revision 写,
183
+ // 实测症状 = SETTINGS_CONFLICT 连撞三次全败(镜像停在残缺清单)。
184
+ const makeSvc = (rev) => makeFormsService({ revision: rev })
185
+ let current = makeSvc(1)
186
+ const ctx = {
187
+ fiber: { id: 'dsh-tap-fiber' },
188
+ inject(deps, cb) {
189
+ if (Array.isArray(deps) && deps.includes('settings')) cb({ get settings() { return current } })
190
+ },
191
+ }
192
+ const layer = createHostConfigLayer({ settingsPath: '/nonexistent/settings.yaml', schema: {}, log: () => {} })
193
+ layer.attach(ctx)
194
+ const r1 = await layer.applyOps([{ op: 'set', path: ['providers', 'trae'], value: TRAE_BLOCK }])
195
+ ok('A21 首次写入成功', r1.ok === true, JSON.stringify(r1))
196
+
197
+ // 宿主重载:旧实例作废(再写即 SETTINGS_CLOSED),换上 revision 不同的新实例
198
+ const stale = current
199
+ stale.mutate = async () => { const e = new Error('service closed'); e.code = 'SETTINGS_CLOSED'; throw e }
200
+ current = makeSvc(20)
201
+ const r2 = await layer.applyOps([{ op: 'set', path: ['providers', 'qoder'], value: { displayName: 'Qoder CN', models: [{ id: 'qmodel' }] } }])
202
+ ok('A22 服务被替换后仍写成功(活取 ctx.settings,不吃陈旧引用)', r2.ok === true, JSON.stringify(r2))
203
+ ok('A23 写入落在新实例上、命名空间正确', current.calls.mutate.length === 1 && current.calls.mutate[0].ns === 'llm-pi-ai' && current.calls.mutate[0].expected === 20,
204
+ JSON.stringify(current.calls.mutate.map((c) => c.expected)))
205
+ ok('A24 陈旧实例没有被再碰过(否则会 SETTINGS_CLOSED)', stale.calls.mutate.length === 1, String(stale.calls.mutate.length))
206
+ }
207
+
208
+ // === B. legacy 回退(≤0.1.6:settings.yaml 文档编辑)========================
209
+ section('B] legacy 回退(settings.yaml + 注释保留)')
210
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-tap-hostconfig-'))
211
+ {
212
+ const path = join(dir, 'settings.yaml')
213
+ writeFileSync(path, [
214
+ '# 用户手写注释必须活下来',
215
+ 'ui-theme:',
216
+ ' preference: dark',
217
+ 'llm-pi-ai:',
218
+ ' providers:',
219
+ ' codebuddy:',
220
+ ' models:',
221
+ ' - id: deepseek-v3',
222
+ ' name: DeepSeek V3',
223
+ '',
224
+ ].join('\n'))
225
+ const svc = makeLegacyService()
226
+ const layer = createHostConfigLayer({ settingsPath: path, schema: { fake: 'schema' }, log: () => {} })
227
+ layer.attach(fakeCtx(svc))
228
+
229
+ ok('B1 旧宿主走 register(命名空间, schema)',
230
+ svc.calls.register.length === 1 && svc.calls.register[0].ns === 'dsh-tap' && svc.calls.register[0].schema?.fake === 'schema' && svc.calls.configure.length === 0,
231
+ JSON.stringify(svc.calls))
232
+ ok('B2 mode() = legacy', layer.mode() === 'legacy', layer.mode())
233
+ ok('B3 readProviders 读 settings.yaml', layer.readProviders().codebuddy?.models?.[0]?.id === 'deepseek-v3')
234
+
235
+ const r = await layer.applyOps([{ op: 'set', path: ['providers', 'trae'], value: TRAE_BLOCK }])
236
+ const text = readFileSync(path, 'utf8')
237
+ ok('B4 set 写进 llm-pi-ai.providers.trae', r.ok === true && /trae:/.test(text) && /TraeWork CN/.test(text))
238
+ ok('B5 用户注释与无关段保留', text.includes('# 用户手写注释必须活下来') && /ui-theme:/.test(text) && /preference: dark/.test(text))
239
+ ok('B6 既有 codebuddy 块未被整段重写掉', /codebuddy:/.test(text) && /deepseek-v3/.test(text))
240
+
241
+ const snapshot = readFileSync(path, 'utf8')
242
+ const r2 = await layer.applyOps([{ op: 'set', path: ['providers', 'trae'], value: structuredClone(TRAE_BLOCK) }])
243
+ ok('B7 同值不写文件', r2.ok === true && r2.changed === false && readFileSync(path, 'utf8') === snapshot)
244
+
245
+ await layer.applyOps([{ op: 'unset', path: ['providers', 'trae'] }])
246
+ ok('B8 unset 删块(路由存在性管理的旧路径)', !/trae:/.test(readFileSync(path, 'utf8')))
247
+ ok('B9 删块后注释仍在', readFileSync(path, 'utf8').includes('# 用户手写注释必须活下来'))
248
+ }
249
+
250
+ // === C. 完全没有 settings 服务(rc.6 等)===================================
251
+ section('C] 无 settings 服务')
252
+ {
253
+ const path = join(dir, 'bare.yaml')
254
+ const layer = createHostConfigLayer({ settingsPath: path, schema: {}, log: () => {} })
255
+ layer.attach({ fiber: null, inject: () => {} }) // inject 永不回调
256
+ ok('C1 servicePresent = false 且 mode = legacy', layer.servicePresent() === false && layer.mode() === 'legacy')
257
+ const r = await layer.applyOps([{ op: 'set', path: ['providers', 'qoder'], value: { displayName: 'Qoder CN', models: [{ id: 'qmodel' }] } }])
258
+ ok('C2 仍能把块写进文件(宿主无 settings 服务时不失能)',
259
+ r.ok === true && existsSync(path) && /qoder:/.test(readFileSync(path, 'utf8')))
260
+ ok('C3 probe 不炸且报 legacy', layer.probe().mode === 'legacy' && layer.probe().servicePresent === false)
261
+ }
262
+
263
+ rmSync(dir, { recursive: true, force: true })
264
+ console.log(`\n=== verify-host-config: ${pass} 通过 / ${fail} 失败 ===`)
265
+ process.exit(fail ? 1 : 0)
@@ -0,0 +1,390 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Verify that every model declared in cordis.patch.yml is still served by
4
+ * the CodeBuddy gateway. Retired or unknown model ids fail with error
5
+ * 11102 ("service info not found").
6
+ *
7
+ * Usage:
8
+ * node scripts/verify-models.mjs --list # offline: print configured models
9
+ * node scripts/verify-models.mjs # online: probe each model
10
+ * node scripts/verify-models.mjs --sync # online: diff against the
11
+ * # gateway's /v3/config catalog
12
+ * node scripts/verify-models.mjs --efforts [id ...]
13
+ * # online: probe reasoning_effort
14
+ * # levels (omit/low/medium/high/
15
+ * # max) per model; defaults to
16
+ * # models lacking reasoningEfforts
17
+ *
18
+ * No npm dependencies. The API key is read from CODEBUDDY_API_KEY (env) or
19
+ * ~/.dsh/.credentials.yaml — the same resolution order dsh uses.
20
+ */
21
+
22
+ import { readFileSync, existsSync } from 'node:fs'
23
+ import { homedir } from 'node:os'
24
+ import { join, dirname } from 'node:path'
25
+ import { fileURLToPath } from 'node:url'
26
+
27
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
28
+ const PATCH_FILE = join(ROOT, 'cordis.patch.yml')
29
+ const BASE_URL = 'https://copilot.tencent.com/v2'
30
+
31
+ // The gateway has no OpenAI-style GET /models endpoint (404), which is why
32
+ // dsh's built-in "fetch available models" cannot work. The catalog the
33
+ // official CLI itself uses lives at /v3/config instead — with its own auth
34
+ // dialect: x-api-key header, and a UA that must match the CLI shape or the
35
+ // gateway answers error 12403 ("check ua"). The /v2 client UA is rejected.
36
+ const CATALOG_URL = 'https://copilot.tencent.com/v3/config'
37
+ const CATALOG_USER_AGENT = 'CLI/unknown CodeBuddy/2.136.0'
38
+
39
+ // Same header set the provider config sends — the gateway routes and logs
40
+ // CodeBuddy traffic by these client markers.
41
+ const CLIENT_HEADERS = {
42
+ 'User-Agent': 'CodeBuddyCode/1.0',
43
+ 'X-IDE-Type': 'CLI',
44
+ 'X-IDE-Name': 'CLI',
45
+ 'X-IDE-Version': '2.133.1',
46
+ 'X-Product-Version': '2.133.1',
47
+ 'X-Requested-With': 'XMLHttpRequest',
48
+ 'X-Private-Data': 'false',
49
+ }
50
+
51
+ /**
52
+ * Extract the `models:` list from cordis.patch.yml. The patch has a fixed,
53
+ * flat shape, so a line scanner scoped to the models block is enough —
54
+ * anything failing to parse here means the patch layout changed and the
55
+ * script must be updated.
56
+ */
57
+ function parseModels(text) {
58
+ const models = []
59
+ let modelsIndent = null
60
+ let current = null
61
+ for (const line of text.split('\n')) {
62
+ if (modelsIndent === null) {
63
+ const header = line.match(/^(\s*)models:\s*$/)
64
+ if (header) modelsIndent = header[1].length
65
+ continue
66
+ }
67
+ if (!line.trim() || line.trim().startsWith('#')) continue
68
+ const indent = line.match(/^\s*/)[0].length
69
+ if (indent <= modelsIndent) break // models block ended
70
+ const entry = line.match(/^\s*-\s*id:\s*(\S+)\s*$/)
71
+ if (entry) {
72
+ current = { id: entry[1], contextWindow: null, maxTokens: null, input: [] }
73
+ models.push(current)
74
+ continue
75
+ }
76
+ const kv = line.match(/^\s*(contextWindow|maxTokens):\s*(\d+)/)
77
+ if (kv && current) current[kv[1]] = Number(kv[2])
78
+ if (/^\s*reasoningEfforts:/.test(line) && current) current.reasoningEfforts = true
79
+ const input = line.match(/^\s*input:\s*\[([^\]]*)\]/)
80
+ if (input && current) {
81
+ current.input = input[1].split(',').map((s) => s.trim()).filter(Boolean)
82
+ }
83
+ }
84
+ return models
85
+ }
86
+
87
+ function loadApiKey() {
88
+ if (process.env.CODEBUDDY_API_KEY) return process.env.CODEBUDDY_API_KEY
89
+ const credFile = join(homedir(), '.dsh', '.credentials.yaml')
90
+ if (existsSync(credFile)) {
91
+ const m = readFileSync(credFile, 'utf8').match(
92
+ /^\s*CODEBUDDY_API_KEY:\s*["']?([^"'\s]+)["']?\s*$/m,
93
+ )
94
+ if (m) return m[1]
95
+ }
96
+ return null
97
+ }
98
+
99
+ async function fetchCatalog(apiKey) {
100
+ const res = await fetch(CATALOG_URL, {
101
+ headers: {
102
+ accept: 'application/json',
103
+ 'x-api-key': apiKey,
104
+ 'user-agent': CATALOG_USER_AGENT,
105
+ 'x-product': 'SaaS',
106
+ },
107
+ })
108
+ if (!res.ok) throw new Error(`catalog endpoint returned HTTP ${res.status}`)
109
+ const body = await res.json()
110
+ if (body?.code !== 0) {
111
+ throw new Error(`catalog error: ${body?.msg ?? body?.code}`)
112
+ }
113
+ const data = body.data ?? {}
114
+ const models = new Map((data.models ?? []).map((m) => [m.id, m]))
115
+ const cliEnabled = new Set(
116
+ (data.agents ?? []).find((a) => a.name === 'cli')?.models ?? [],
117
+ )
118
+ return { models, cliEnabled }
119
+ }
120
+
121
+ async function probeModel(id, apiKey) {
122
+ let res
123
+ try {
124
+ res = await fetch(`${BASE_URL}/chat/completions`, {
125
+ method: 'POST',
126
+ headers: {
127
+ ...CLIENT_HEADERS,
128
+ Authorization: `Bearer ${apiKey}`,
129
+ 'Content-Type': 'application/json',
130
+ },
131
+ body: JSON.stringify({
132
+ model: id,
133
+ messages: [{ role: 'user', content: 'hi' }],
134
+ max_tokens: 1,
135
+ stream: true,
136
+ }),
137
+ })
138
+ } catch (err) {
139
+ return { ok: false, detail: `network error: ${err.message}` }
140
+ }
141
+ if (!res.ok) {
142
+ const body = await res.text().catch(() => '')
143
+ return { ok: false, detail: `HTTP ${res.status} ${body.slice(0, 200)}` }
144
+ }
145
+ // The gateway may answer 200 and still stream an error payload (e.g.
146
+ // code 11102 for retired ids), so scan the stream instead of trusting
147
+ // the status code.
148
+ let streamed = ''
149
+ try {
150
+ const reader = res.body.getReader()
151
+ const decoder = new TextDecoder()
152
+ for (;;) {
153
+ const { done, value } = await reader.read()
154
+ if (done) break
155
+ streamed += decoder.decode(value, { stream: true })
156
+ if (streamed.length > 4096) break
157
+ }
158
+ } catch {
159
+ // Body already partially read; what we have is enough to judge.
160
+ }
161
+ const errMatch = streamed.match(/"code"\s*:\s*(\d+)|"error"\s*:/)
162
+ if (errMatch) {
163
+ return { ok: false, detail: `streamed error: ${streamed.slice(0, 200)}` }
164
+ }
165
+ return { ok: true, detail: 'ok' }
166
+ }
167
+
168
+ /**
169
+ * Diff the patch's model list against the /v3/config catalog: report
170
+ * parameter drift, ids the catalog no longer lists, and catalog ids the
171
+ * patch could adopt; emit corrected YAML entries for the drifted ones.
172
+ * Exits 1 only when a model present in both disagrees with the catalog —
173
+ * unlisted legacy ids and not-yet-adopted catalog ids are informational.
174
+ */
175
+ async function runSync(models, apiKey) {
176
+ const { models: catalog, cliEnabled } = await fetchCatalog(apiKey)
177
+ const inPatch = new Set(models.map((m) => m.id))
178
+ const drifted = []
179
+ const aligned = []
180
+ const unlisted = []
181
+
182
+ for (const m of models) {
183
+ const c = catalog.get(m.id)
184
+ if (!c) {
185
+ unlisted.push(m.id)
186
+ continue
187
+ }
188
+ const issues = []
189
+ if (c.maxInputTokens != null && m.contextWindow !== c.maxInputTokens) {
190
+ issues.push(`contextWindow ${m.contextWindow} → ${c.maxInputTokens}`)
191
+ }
192
+ if (c.maxOutputTokens != null && m.maxTokens !== c.maxOutputTokens) {
193
+ issues.push(`maxTokens ${m.maxTokens} → ${c.maxOutputTokens}`)
194
+ }
195
+ const wantsImage = c.supportsImages === true
196
+ const hasImage = m.input.includes('image')
197
+ if (wantsImage !== hasImage) {
198
+ issues.push(`input ${hasImage ? '[text, image]' : '[text]'} → ${wantsImage ? '[text, image]' : '[text]'}`)
199
+ }
200
+ issues.length ? drifted.push({ m, c, issues }) : aligned.push(m.id)
201
+ }
202
+
203
+ console.log(`catalog: ${catalog.size} models, ${cliEnabled.size} CLI-enabled`)
204
+ console.log(`\naligned with catalog (${aligned.length}): ${aligned.join(', ')}`)
205
+
206
+ console.log(`\nparameter drift (${drifted.length}):`)
207
+ if (drifted.length === 0) {
208
+ console.log(' none')
209
+ } else {
210
+ for (const { m, issues } of drifted) {
211
+ console.log(` ${m.id}`)
212
+ for (const issue of issues) console.log(` ${issue}`)
213
+ }
214
+ console.log('\ncorrected entries (replace the matching ids in the patch):')
215
+ for (const { m, c } of drifted) {
216
+ const lines = [
217
+ ` - id: ${m.id}`,
218
+ ` name: ${m.name ?? m.id}`,
219
+ ]
220
+ if (c.maxInputTokens != null) lines.push(` contextWindow: ${c.maxInputTokens}`)
221
+ if (c.maxOutputTokens != null) lines.push(` maxTokens: ${c.maxOutputTokens}`)
222
+ if (c.supportsImages === true) lines.push(' input: [text, image]')
223
+ console.log(lines.join('\n'))
224
+ }
225
+ }
226
+
227
+ console.log(`\nnot in catalog, still serving as legacy ids (${unlisted.length}):`)
228
+ for (const id of unlisted) console.log(` ${id}`)
229
+
230
+ const adoptable = [...catalog.keys()].filter((id) => !inPatch.has(id))
231
+ console.log(`\nin catalog but not in the patch (${adoptable.length}):`)
232
+ for (const id of adoptable) {
233
+ const c = catalog.get(id)
234
+ const cli = cliEnabled.has(id) ? ' [CLI]' : ''
235
+ const sizes = c.maxInputTokens != null
236
+ ? `ctx=${c.maxInputTokens} max=${c.maxOutputTokens}`
237
+ : 'no token fields'
238
+ const img = c.supportsImages === true ? ' img' : ''
239
+ console.log(` ${id}${cli} ${sizes}${img}`)
240
+ }
241
+
242
+ return drifted.length > 0 ? 1 : 0
243
+ }
244
+
245
+ /**
246
+ * Probe which reasoning_effort levels a model accepts: one streaming request
247
+ * per level (omitting the parameter is the baseline). reasoning_content vs
248
+ * content length tells whether the level actually changed the thinking
249
+ * budget — identical lengths across all levels mean the gateway ignores
250
+ * the parameter for that model.
251
+ */
252
+ const EFFORT_LEVELS = [null, 'low', 'medium', 'high', 'max']
253
+
254
+ async function probeEffort(id, apiKey, effort) {
255
+ const body = {
256
+ model: id,
257
+ messages: [{ role: 'user', content: '9.11和9.9哪个大?先想清楚再给一句话结论。' }],
258
+ max_tokens: 800,
259
+ stream: true,
260
+ }
261
+ if (effort) body.reasoning_effort = effort
262
+ let res
263
+ try {
264
+ res = await fetch(`${BASE_URL}/chat/completions`, {
265
+ method: 'POST',
266
+ headers: {
267
+ ...CLIENT_HEADERS,
268
+ Authorization: `Bearer ${apiKey}`,
269
+ 'Content-Type': 'application/json',
270
+ },
271
+ body: JSON.stringify(body),
272
+ })
273
+ } catch (err) {
274
+ return { error: `network: ${err.message}` }
275
+ }
276
+ if (!res.ok) {
277
+ return { error: `HTTP ${res.status} ${(await res.text().catch(() => '')).slice(0, 120)}` }
278
+ }
279
+ let buf = ''
280
+ let reasonChars = 0
281
+ let contentChars = 0
282
+ let streamErr = null
283
+ let finish = null
284
+ try {
285
+ const reader = res.body.getReader()
286
+ const dec = new TextDecoder()
287
+ for (;;) {
288
+ const { done, value } = await reader.read()
289
+ if (done) break
290
+ buf += dec.decode(value, { stream: true })
291
+ let nl
292
+ while ((nl = buf.indexOf('\n')) >= 0) {
293
+ const line = buf.slice(0, nl).trim()
294
+ buf = buf.slice(nl + 1)
295
+ if (!line.startsWith('data:')) continue
296
+ const payload = line.slice(5).trim()
297
+ if (payload === '[DONE]') continue
298
+ try {
299
+ const j = JSON.parse(payload)
300
+ if (j.error || (j.code != null && j.code !== 0)) streamErr = payload.slice(0, 120)
301
+ const ch = j.choices?.[0]
302
+ if (ch?.delta?.reasoning_content) reasonChars += ch.delta.reasoning_content.length
303
+ if (ch?.delta?.content) contentChars += ch.delta.content.length
304
+ if (ch?.finish_reason) finish = ch.finish_reason
305
+ } catch {
306
+ // incomplete JSON inside a complete line — skip it
307
+ }
308
+ }
309
+ if (reasonChars + contentChars > 6000) break
310
+ }
311
+ } catch {
312
+ // partial stream is still evidence
313
+ }
314
+ return { reasonChars, contentChars, finish, streamErr }
315
+ }
316
+
317
+ async function runEfforts(models, apiKey) {
318
+ for (const m of models) {
319
+ console.log(`\n${m.id}`)
320
+ for (const e of EFFORT_LEVELS) {
321
+ const label = (e ?? '(omit)').padEnd(8)
322
+ process.stdout.write(` ${label}`)
323
+ const r = await probeEffort(m.id, apiKey, e)
324
+ if (r.error || r.streamErr) {
325
+ console.log(` ✗ ${r.error ?? r.streamErr}`)
326
+ } else {
327
+ console.log(
328
+ ` reason=${String(r.reasonChars).padStart(5)} content=${String(r.contentChars).padStart(4)} finish=${r.finish ?? '-'}`,
329
+ )
330
+ }
331
+ }
332
+ }
333
+ return 0
334
+ }
335
+
336
+ const models = parseModels(readFileSync(PATCH_FILE, 'utf8'))
337
+ if (models.length === 0) {
338
+ console.error(`Failed to parse any model from ${PATCH_FILE} — patch layout changed?`)
339
+ process.exit(1)
340
+ }
341
+
342
+ if (process.argv.includes('--list')) {
343
+ for (const m of models) {
344
+ const input = m.input.length ? m.input.join('+') : 'text'
345
+ console.log(
346
+ `${m.id.padEnd(18)} contextWindow=${String(m.contextWindow ?? '?').padEnd(7)} maxTokens=${String(m.maxTokens ?? '?').padEnd(6)} input=${input}`,
347
+ )
348
+ }
349
+ console.log(`\n${models.length} models configured`)
350
+ process.exit(0)
351
+ }
352
+
353
+ const apiKey = loadApiKey()
354
+ if (!apiKey) {
355
+ console.error('CODEBUDDY_API_KEY not found (env or ~/.dsh/.credentials.yaml)')
356
+ process.exit(1)
357
+ }
358
+
359
+ if (process.argv.includes('--sync')) {
360
+ try {
361
+ process.exit(await runSync(models, apiKey))
362
+ } catch (err) {
363
+ console.error(`catalog fetch failed: ${err.message}`)
364
+ process.exit(1)
365
+ }
366
+ }
367
+
368
+ const effortIdx = process.argv.indexOf('--efforts')
369
+ if (effortIdx !== -1) {
370
+ const requested = process.argv.slice(effortIdx + 1).filter((a) => !a.startsWith('--'))
371
+ const targets = requested.length
372
+ ? models.filter((m) => requested.includes(m.id))
373
+ : models.filter((m) => !m.reasoningEfforts)
374
+ if (targets.length === 0) {
375
+ console.error('no matching models to probe')
376
+ process.exit(1)
377
+ }
378
+ await runEfforts(targets, apiKey)
379
+ process.exit(0)
380
+ }
381
+
382
+ let failed = 0
383
+ for (const m of models) {
384
+ process.stdout.write(`probing ${m.id.padEnd(18)} ... `)
385
+ const r = await probeModel(m.id, apiKey)
386
+ console.log(r.ok ? 'OK' : `FAIL (${r.detail})`)
387
+ if (!r.ok) failed++
388
+ }
389
+ console.log(`\n${models.length - failed}/${models.length} models available`)
390
+ process.exit(failed > 0 ? 1 : 0)