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,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)
|