zcode-qoder-connect 0.0.0-stage → 1.0.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.
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "zcode-qoder-connect",
3
+ "version": "1.0.0",
4
+ "description": "Bring Qoder CN models — domestic (DeepSeek-V4-Pro, Qwen3.8-Max, GLM-5.3, Kimi-K3.1-Pro …) — into ZCode as an OpenAI-compatible provider, with zero configuration.",
5
+ "description_i18n": {
6
+ "en": "Bring Qoder CN models into ZCode as OpenAI-compatible providers, with zero configuration.",
7
+ "zh-CN": "把 Qoder 国内版的模型(DeepSeek-V4-Pro、Qwen3.8-Max、GLM-5.3、Kimi-K3.1-Pro 等)零配置接入 ZCode 的模型供应商。"
8
+ },
9
+ "author": {
10
+ "name": "xiao-hans (a port of masknull/dsh-qoder-connect, MIT)"
11
+ },
12
+ "license": "MIT",
13
+ "homepage": "https://github.com/xiao-hans/zcode-ai-connect/tree/main/zcode-qoder-connect#readme",
14
+ "keywords": [
15
+ "qoder",
16
+ "openai-compatible",
17
+ "provider",
18
+ "glm",
19
+ "deepseek",
20
+ "qwen"
21
+ ],
22
+ "skills": "skills",
23
+ "hooks": {
24
+ "SessionStart": [
25
+ {
26
+ "hooks": [
27
+ {
28
+ "type": "process",
29
+ "command": "node",
30
+ "args": [
31
+ "${ZCODE_PLUGIN_ROOT}/hooks/service-ensure.mjs"
32
+ ],
33
+ "timeoutMs": 60000,
34
+ "statusMessage": "Starting the Qoder endpoint"
35
+ }
36
+ ]
37
+ }
38
+ ]
39
+ }
40
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hawchou1995
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,72 @@
1
- # Temporary Holding Version
1
+ # zcode-qoder-connect
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 把 **Qoder(国内版)桌面 App** 里的模型零配置接入 ZCode / pi 等 OpenAI 兼容客户端。
4
+
5
+ ```
6
+ 客户端 ──(OpenAI chat-completions)──▶ 127.0.0.1:39270 ──▶ gateway.qoder.com.cn
7
+ 本地端点 (COSY 签名 + WAF 编码)
8
+ ```
9
+
10
+ ## 特性
11
+
12
+ - **双轨凭据,零 PAT 依赖**:优先复用 Qoder CN 桌面 App 的登录保险库(Windows DPAPI + AES-GCM 的 `auth.v1.dat`,只读),`dt-` 换 `jt-` 作业令牌;也可用个人访问令牌(`qoder.cn → 账号设置 → 个人访问令牌`,`cli.mjs login <PAT>`)。桌面令牌约 16 天过期,打开一次 Qoder 桌面即自动刷新。
13
+ - **完整协议**:COSY 请求签名(AES-CBC + RSA + MD5)、WAF 请求体编码、双层 SSE 响应解析,推理内容(`reasoning_content`)与 `tool_calls` 均以 OpenAI 形状透传给调用方。
14
+ - **可读模型 id**:`deepseek-v4-pro`、`qwen3.8-max`、`glm-5.3`、`kimi-k3.1-pro` 等(`MODEL_ALIASES` 映射内部 key,内部 key 直连仍然有效)。
15
+ - **排队自愈**:免费档高峰的 10605 排队信号(HTTP 403 或 SSE 首帧 403 信封)自动换请求 id 重试。
16
+ - **每日签到**:`cli.mjs checkin` 领取当日积分(campaigns 端点,有活动时生效)。
17
+ - **ZCode 插件化**:SessionStart hook 自动拉起端点,skills 提供排障指南。
18
+
19
+ ## 已知边界(如实说明)
20
+
21
+ Qoder 网关会**包夹**调用方的 system 消息:前缀注入"You are Qoder…"身份与默认英文偏好,后缀注入"被问底层模型时拒答"。调用方的 system 原文完整保留在中间,指令遵从、工具调用、多轮上下文全部正常;仅模型的自我称谓与底层模型自述被锁定。这是端点固有行为,上游无裸 completions 通道(实测 7 个候选路径均 404)。
22
+
23
+ ## 流生命周期(为什么回合不会「突然不动」)
24
+
25
+ 长回合会把每一处代理缺陷暴露成同一种症状:模型忽然没声了、界面一直转、又没有任何报错。端点按下面五条收尾:
26
+
27
+ | 情况 | 端点行为 |
28
+ |---|---|
29
+ | 已开流后上游 180 秒零字节 | 写 `error` 帧(`type: "upstream_stalled"`)+ `finish_reason: "network_error"` + `[DONE]`,并中止上游请求。下游重试,不会挂在我们自己的心跳后面。 |
30
+ | 上游没给 finish 帧就断流 | 以 `finish_reason: "length"` 收尾——被截断就说截断,绝不改写成 `stop`(那正是「被切断的回答」被当成「模型自己讲完了」的原因)。 |
31
+ | 有 `tool_calls` 却标了 `stop` | 纠正为 `finish_reason: "tool_calls"`;下游的 agent 循环依赖这个值决定是否继续。 |
32
+ | 整回合没有任何产出(只有思考) | 发 `error` 帧(`type: "empty_answer"`),让下游重试,而不是接受一条空的 assistant 消息。 |
33
+ | 客户端中途断开 | 中止上游请求,取消的回合立即停止生成,也不占用账号并发额度。 |
34
+
35
+ 看门狗间隔可调:`ZC_STREAM_IDLE_TIMEOUT_MS`(默认 `180000`)。10 秒一次的心跳(`: heartbeat`)只保活**下游**读超时,刻意不重置看门狗——它只认真实的上游字节。
36
+
37
+ 回归测试:`tests/test-stream-guard-qoder.mjs`。
38
+
39
+ ## 安装
40
+
41
+ ### ZCode
42
+
43
+ ```jsonc
44
+ // %USERPROFILE%\.zcode\cli\config.json
45
+ { "plugins": { "dirs": ["D:/path/to/zcode-qoder-connect"] } }
46
+ ```
47
+
48
+ 重启 ZCode(托盘退出重开)后,模型选择器出现 `Qoder` 分组。端点由 SessionStart hook 自动启动。
49
+
50
+ ### 其他 OpenAI 客户端(pi 等)
51
+
52
+ ```sh
53
+ node bin/cli.mjs serve --port 39270 # 前台运行;healthz 鉴权用 state 目录 endpoint.json 里的 bearer
54
+ ```
55
+
56
+ 以 pi 为例,在 `~/.pi/agent/models.json` 添加 provider:`baseUrl http://127.0.0.1:39270/v1`、`api openai-completions`、`apiKey = endpoint.json 的 token`。
57
+
58
+ ## 命令
59
+
60
+ | 命令 | 作用 |
61
+ |---|---|
62
+ | `serve [--port N]` | 启动端点(默认 39270) |
63
+ | `status` | 凭据双轨状态、积分、模型名册 |
64
+ | `refresh` | 重拉模型名册 |
65
+ | `checkin` | 领取当日签到积分 |
66
+ | `login <PAT>` | 校验并保存个人访问令牌 |
67
+ | `logout` | 删除已保存 PAT(桌面登录不受影响) |
68
+ | `service-status` | 端点是否在运行 |
69
+
70
+ ## 致谢与许可
71
+
72
+ 协议实现源自 [dsh-qoder-connect](https://github.com/masknull/dsh-qoder-connect)(MIT,作者 corrinehu)。MIT,见 [LICENSE](LICENSE)。
package/bin/cli.mjs ADDED
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ // cli.mjs — zcode-qoder-connect command line.
3
+ //
4
+ // serve run the loopback OpenAI-compatible endpoint (default)
5
+ // status credential tracks, credits, model roster
6
+ // refresh re-pull the model roster
7
+ // checkin claim today's Qoder check-in credits (+100/day)
8
+ // login <PAT> save a personal access token (qoder.cn → 账号设置)
9
+ // logout drop the saved PAT (desktop vault untouched)
10
+ // service-status is the endpoint up
11
+ // doctor credential + upstream diagnostics
12
+
13
+ import { randomBytes } from 'node:crypto'
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+
17
+ const { defaultStateDir, configureStateDir, configureEndpoint, stateDir, endpointPath } = await import(new URL('../src/config.js', import.meta.url).href)
18
+ const { QoderCredentialStore, patPath, SIGN_IN_HINT } = await import(new URL('../src/credentials.js', import.meta.url).href)
19
+ const { QoderUpstreamClient } = await import(new URL('../src/upstream.js', import.meta.url).href)
20
+ const { QoderCatalog } = await import(new URL('../src/catalog.js', import.meta.url).href)
21
+ const { createQoderServer } = await import(new URL('../src/server.js', import.meta.url).href)
22
+
23
+ const HELP = `zcode-qoder-connect — Qoder CN 模型接入本地 OpenAI 端点
24
+
25
+ 用法: node bin/cli.mjs <command> [options]
26
+
27
+ commands:
28
+ serve [--port N] 启动本地 OpenAI 兼容端点(默认 39270)
29
+ status 凭据双轨状态、积分、模型名册
30
+ refresh 重新拉取模型名册
31
+ checkin 领取今日签到积分(+100/天)
32
+ login <PAT> 保存个人访问令牌(qoder.cn → 账号设置 → 个人访问令牌)
33
+ logout 删除已保存的 PAT(不影响 Qoder 桌面)
34
+ service-status 端点是否在运行
35
+ doctor 诊断
36
+
37
+ 凭据双轨:
38
+ 1. PAT(pat.txt)— 生成: https://qoder.cn/account/integrations
39
+ 2. Qoder CN 桌面登录(auth.v1.dat,只读;dt- 过期时需打开一次 Qoder)`
40
+
41
+ const args = process.argv.slice(2)
42
+ const command = args.find(a => !a.startsWith('--')) ?? 'serve'
43
+ const portArg = args.includes('--port') ? Number(args[args.indexOf('--port') + 1]) : 39270
44
+
45
+ configureStateDir(defaultStateDir())
46
+ mkdirSync(stateDir(), { recursive: true })
47
+
48
+ // --- endpoint token (stable across restarts) --------------------------------
49
+ function ensureToken() {
50
+ const p = endpointPath()
51
+ if (existsSync(p)) {
52
+ try {
53
+ const j = JSON.parse(readFileSync(p, 'utf8'))
54
+ if (j.token && j.port) {
55
+ configureEndpoint({ port: j.port, token: j.token })
56
+ return j.token
57
+ }
58
+ } catch {}
59
+ }
60
+ const token = randomBytes(24).toString('base64url')
61
+ writeFileSync(p, JSON.stringify({ token, port: portArg, createdAt: new Date().toISOString() }, null, 2), 'utf8')
62
+ configureEndpoint({ port: portArg, token })
63
+ return token
64
+ }
65
+
66
+ function buildStore() {
67
+ return new QoderCredentialStore({ stateDir: stateDir() })
68
+ }
69
+ function buildClient() {
70
+ return new QoderUpstreamClient({})
71
+ }
72
+
73
+ async function main() {
74
+ if (command === 'help' || args.includes('--help')) { console.log(HELP); return }
75
+
76
+ if (command === 'login') {
77
+ const pat = args.find((a, i) => i > 0 && !a.startsWith('--') && args[i - 1] !== '--port' && a !== 'login')
78
+ if (!pat) { console.error('用法: node bin/cli.mjs login <PAT>'); process.exitCode = 2; return }
79
+ // live-validate before saving
80
+ const { exchangePat } = await import(new URL('../src/credentials.js', import.meta.url).href)
81
+ try {
82
+ const data = await exchangePat(pat)
83
+ if (!data?.token) throw new Error('兑换未返回 job token')
84
+ writeFileSync(patPath(stateDir()), pat, 'utf8')
85
+ console.log('PAT 有效并已保存(有效期至 ' + (data.expires_at ?? data.expires_in + 's') + ')')
86
+ } catch (e) {
87
+ console.error('PAT 校验失败,未保存:', String(e.message ?? e).slice(0, 200)); process.exitCode = 2
88
+ }
89
+ return
90
+ }
91
+
92
+ if (command === 'logout') {
93
+ if (existsSync(patPath(stateDir()))) { unlinkSync(patPath(stateDir())); console.log('PAT 已删除(桌面登录不受影响)') }
94
+ else console.log('没有已保存的 PAT')
95
+ return
96
+ }
97
+
98
+ if (command === 'service-status') {
99
+ const token = ensureToken()
100
+ try {
101
+ const r = await fetch(`http://127.0.0.1:${portArg}/healthz`, { headers: { Authorization: `Bearer ${token}` } })
102
+ console.log(r.ok ? `endpoint http://127.0.0.1:${portArg}/v1 — up` : `endpoint responded HTTP ${r.status}`)
103
+ } catch {
104
+ console.log(`endpoint http://127.0.0.1:${portArg}/v1 — NOT reachable`)
105
+ console.log(' 启动: node bin/cli.mjs serve')
106
+ }
107
+ return
108
+ }
109
+
110
+ if (command === 'checkin') {
111
+ const store = buildStore()
112
+ const client = buildClient()
113
+ try {
114
+ const credential = await store.resolve()
115
+ console.log(`凭据来源: ${credential.source}`)
116
+ const result = await client.checkin(credential)
117
+ if (result.status === 'claimed') console.log(`签到成功: +${result.amount} credits`)
118
+ else if (result.status === 'already-claimed') console.log(`今日已签到(+${result.amount})`)
119
+ else console.log(`签到未完成: ${result.status} ${result.message ?? ''}`)
120
+ } catch (e) {
121
+ console.log('签到失败 —', String(e.message ?? e).slice(0, 220))
122
+ }
123
+ return
124
+ }
125
+
126
+ if (command === 'status' || command === 'doctor' || command === 'refresh') {
127
+ const store = buildStore()
128
+ const st = store.status()
129
+ console.log(`PAT: ${st.patConfigured ? '已配置' : '未配置'}`)
130
+ console.log(`桌面 vault: ${st.desktopVault ? '存在' : '不存在'}${st.desktopVault ? (st.desktopTokenFresh ? '(dt- 有效)' : '(dt- 已过期 → 打开一次 Qoder CN 桌面即可刷新)') : ''}`)
131
+ if (command !== 'refresh') {
132
+ try {
133
+ const client = buildClient()
134
+ const credential = await store.resolve()
135
+ console.log(`凭据: ${credential.source} (user ${credential.userID})`)
136
+ try {
137
+ const credits = await client.fetchCredits(credential)
138
+ console.log('积分:', JSON.stringify(credits).slice(0, 200))
139
+ } catch (e) { console.log('积分: 查询失败 —', String(e.message ?? e).slice(0, 120)) }
140
+ const models = await client.fetchModels(credential)
141
+ console.log(`Models (${models.length}):`)
142
+ for (const m of models) {
143
+ console.log(` ${m.id.padEnd(30)} ${m.name.padEnd(24)} ${m.contextWindow} ctx${m.supportsImages ? ' img' : ''}${m.reasoning?.supportedEfforts?.length ? ' efforts ' + m.reasoning.supportedEfforts.join('/') : ''}${m.priceFactor !== undefined ? ' x' + m.priceFactor : ''}`)
144
+ }
145
+ } catch (e) {
146
+ console.log('凭据不可用 —', String(e.message ?? e).slice(0, 240))
147
+ }
148
+ return
149
+ }
150
+ // refresh falls through to serve-side catalog refresh below when live
151
+ try {
152
+ const client = buildClient()
153
+ const credential = await store.resolve()
154
+ const models = await client.fetchModels(credential)
155
+ console.log(`refreshed ${models.length} models`)
156
+ } catch (e) {
157
+ console.log('refresh 失败 —', String(e.message ?? e).slice(0, 200)); process.exitCode = 1
158
+ }
159
+ return
160
+ }
161
+
162
+ if (command === 'serve') {
163
+ const token = ensureToken()
164
+ const store = buildStore()
165
+ const client = buildClient()
166
+ const catalog = new QoderCatalog()
167
+ const server = createQoderServer({
168
+ token,
169
+ port: portArg,
170
+ client,
171
+ variants: [{ id: 'qoder', path: '', prefix: '', store, catalog }],
172
+ })
173
+ await server.start()
174
+ console.log(`[zcode-qoder-connect] listening on http://127.0.0.1:${server.port()}`)
175
+ console.log(`[zcode-qoder-connect] OpenAI base: http://127.0.0.1:${server.port()}/v1`)
176
+ console.log(`[zcode-qoder-connect] bearer: ${token}`)
177
+ // initial catalog load (best-effort)
178
+ try {
179
+ const credential = await store.resolve()
180
+ const models = await client.fetchModels(credential)
181
+ catalog.set(models, { source: 'qoder-directory', fetchedAtMs: Date.now() })
182
+ catalog.setVisible(true)
183
+ console.log(`[zcode-qoder-connect] catalog: ${models.length} models`)
184
+ } catch (e) {
185
+ console.log(`[zcode-qoder-connect] catalog 未加载 — ${String(e.message ?? e).slice(0, 160)}`)
186
+ }
187
+ const shutdown = signal => {
188
+ console.log(`\n[zcode-qoder-connect] ${signal} — shutting down`)
189
+ server.close().then(() => process.exit(0))
190
+ }
191
+ process.on('SIGINT', () => shutdown('SIGINT'))
192
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
193
+ return
194
+ }
195
+
196
+ console.error(`未知命令: ${command}\n`)
197
+ console.log(HELP)
198
+ process.exitCode = 2
199
+ }
200
+
201
+ main().catch(error => {
202
+ console.error('fatal:', error)
203
+ process.exit(1)
204
+ })
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Strict validator for ZCode's `provider_config.json`.
3
+ *
4
+ * ZCode parses this document with zod schemas compiled into its Electron
5
+ * bundle, and those schemas are `.strict()`: a single unrecognised key fails
6
+ * the WHOLE document, and the app's failure mode is to discard the entire
7
+ * personal provider set and start from an empty one — silently breaking every
8
+ * other provider the user had configured, with no error surfaced anywhere.
9
+ *
10
+ * That failure mode is why `setup` writes into a temp file and this validator
11
+ * runs before the write lands. It mirrors the shapes read out of
12
+ * `ZCode\resources\app.asar` -> `out/host/index.js`:
13
+ *
14
+ * manualProviderModelRules[].config (the `extractManualModelConfig` shape)
15
+ * enabled?
16
+ * properties pick: contextWindow, supportsJsonSchemaOutput,
17
+ * supportsNativeWebSearch, supportsMidConversationSystem
18
+ * + inputFormat pick: supportsImage, supportsVideo, supportsPdf
19
+ * optionSpecs pick: reasoningLevel
20
+ * + maxOutputTokens pick: max (map is NOT accepted here)
21
+ *
22
+ * providerModelRules[].config (the full base shape, sparse)
23
+ * properties: requiresMfjsToolSchema, contextWindow,
24
+ * inputFormat{5 flags}, outputFormat,
25
+ * supportsToolCall, supportsJsonSchemaOutput,
26
+ * supportsNativeWebSearch, supportsMidConversationSystem
27
+ * optionSpecs: reasoningLevel{values,map}, maxOutputTokens{max,map}
28
+ *
29
+ * Usage: node docs/validate-provider-config.mjs [path]
30
+ * Exit 0 = valid, 1 = invalid (with the offending path printed).
31
+ */
32
+ import { readFileSync } from 'node:fs'
33
+ import { join } from 'node:path'
34
+ import { homedir } from 'node:os'
35
+
36
+ const path = process.argv[2] ?? join(homedir(), '.zcode', 'v2', 'provider_config.json')
37
+
38
+ /** Keys accepted inside `providerModelRules[].config.properties`. */
39
+ const PROPERTY_KEYS = new Set([
40
+ 'requiresMfjsToolSchema', 'contextWindow',
41
+ 'inputFormat', 'outputFormat',
42
+ 'supportsToolCall', 'supportsJsonSchemaOutput',
43
+ 'supportsNativeWebSearch', 'supportsMidConversationSystem',
44
+ ])
45
+ const INPUT_FORMAT_KEYS = new Set(['supportsText', 'supportsImage', 'supportsVideo', 'supportsAudio', 'supportsPdf'])
46
+ const OUTPUT_FORMAT_KEYS = new Set(['supportsText'])
47
+ const OPTION_SPEC_KEYS = new Set(['reasoningLevel', 'maxOutputTokens'])
48
+ const MAX_OUTPUT_TOKEN_KEYS = new Set(['max', 'map'])
49
+ const REASONING_LEVEL_KEYS = new Set(['values', 'map'])
50
+
51
+ /** The manual variant accepts only a subset of each shape. */
52
+ const CONFIG_KEYS = new Set(['enabled', 'properties', 'optionSpecs'])
53
+ const MANUAL_PROPERTY_KEYS = new Set([
54
+ 'contextWindow', 'supportsJsonSchemaOutput',
55
+ 'supportsNativeWebSearch', 'supportsMidConversationSystem', 'inputFormat',
56
+ ])
57
+ const MANUAL_INPUT_FORMAT_KEYS = new Set(['supportsImage', 'supportsVideo', 'supportsPdf'])
58
+ const MANUAL_OPTION_SPEC_KEYS = new Set(['reasoningLevel', 'maxOutputTokens'])
59
+ const MANUAL_MAX_OUTPUT_TOKEN_KEYS = new Set(['max'])
60
+
61
+ const RULE_KEYS = new Set(['modelId', 'providerId', 'config'])
62
+ const PROVIDER_RULE_KEYS = new Set(['providerId', 'templateId', 'providerName', 'enabled', 'config'])
63
+ const PROVIDER_CONFIG_KEYS = new Set([
64
+ 'group', 'logo', 'access', 'api', 'builtinModelIds', 'personalModelIds', 'modelOrder', 'visibility',
65
+ ])
66
+ const API_TYPES = new Set(['anthropic-messages', 'openai-chat-completions', 'openai-responses'])
67
+ const MODEL_RULE_SETS = new Set([
68
+ 'providerModelRules', 'manualProviderModelRules', 'modelRules', 'modelApiRules',
69
+ 'providerSiteRules', 'templateModelRules', 'builtinProviderModelRules',
70
+ ])
71
+
72
+ const problems = []
73
+ const isObj = value => typeof value === 'object' && value !== null && !Array.isArray(value)
74
+
75
+ function unknownKeys(obj, allowed, where) {
76
+ if (!isObj(obj)) return
77
+ for (const key of Object.keys(obj)) {
78
+ if (!allowed.has(key)) problems.push(`${where}: unrecognised key "${key}" (strict schema rejects the whole document)`)
79
+ }
80
+ }
81
+
82
+ function checkPositiveInt(value, where) {
83
+ if (value === undefined) return
84
+ if (!Number.isInteger(value) || value <= 0) {
85
+ problems.push(`${where}: must be a positive integer, got ${JSON.stringify(value)}`)
86
+ }
87
+ }
88
+
89
+ function walkConfig(config, where, { manual }) {
90
+ if (!isObj(config)) {
91
+ problems.push(`${where}: must be an object`)
92
+ return
93
+ }
94
+ unknownKeys(config, CONFIG_KEYS, where)
95
+ if (config['enabled'] !== undefined && typeof config['enabled'] !== 'boolean') {
96
+ problems.push(`${where}.enabled: must be boolean`)
97
+ }
98
+
99
+ const properties = config['properties']
100
+ if (properties !== undefined) {
101
+ unknownKeys(properties, manual ? MANUAL_PROPERTY_KEYS : PROPERTY_KEYS, `${where}.properties`)
102
+ checkPositiveInt(properties['contextWindow'], `${where}.properties.contextWindow`)
103
+ if (properties['inputFormat'] !== undefined) {
104
+ unknownKeys(properties['inputFormat'], manual ? MANUAL_INPUT_FORMAT_KEYS : INPUT_FORMAT_KEYS, `${where}.properties.inputFormat`)
105
+ }
106
+ if (properties['outputFormat'] !== undefined) {
107
+ unknownKeys(properties['outputFormat'], OUTPUT_FORMAT_KEYS, `${where}.properties.outputFormat`)
108
+ }
109
+ }
110
+
111
+ const specs = config['optionSpecs']
112
+ if (specs === undefined) return
113
+ unknownKeys(specs, manual ? MANUAL_OPTION_SPEC_KEYS : OPTION_SPEC_KEYS, `${where}.optionSpecs`)
114
+ const maxOutput = specs['maxOutputTokens']
115
+ if (maxOutput !== undefined) {
116
+ unknownKeys(maxOutput, manual ? MANUAL_MAX_OUTPUT_TOKEN_KEYS : MAX_OUTPUT_TOKEN_KEYS, `${where}.optionSpecs.maxOutputTokens`)
117
+ checkPositiveInt(maxOutput['max'], `${where}.optionSpecs.maxOutputTokens.max`)
118
+ }
119
+ const reasoning = specs['reasoningLevel']
120
+ if (reasoning !== undefined) {
121
+ unknownKeys(reasoning, REASONING_LEVEL_KEYS, `${where}.optionSpecs.reasoningLevel`)
122
+ }
123
+ }
124
+
125
+ let document
126
+ try {
127
+ document = JSON.parse(readFileSync(path, 'utf8'))
128
+ } catch (error) {
129
+ console.error(`INVALID — ${path}`)
130
+ console.error(` - not readable as JSON: ${String(error)}`)
131
+ process.exit(1)
132
+ }
133
+
134
+ if (document['schemaVersion'] !== 1) {
135
+ problems.push(`schemaVersion: must be 1, got ${JSON.stringify(document['schemaVersion'])}`)
136
+ }
137
+ const config = document['config']
138
+ if (!isObj(config)) {
139
+ console.error(`INVALID — ${path}`)
140
+ console.error(' - missing config object')
141
+ process.exit(1)
142
+ }
143
+ unknownKeys(config, new Set(['providerOrder', 'providerConfigRules', 'modelConfigRules', 'defaultModelSelection']), 'config')
144
+
145
+ const modelConfigRules = config['modelConfigRules']
146
+ if (modelConfigRules !== undefined) {
147
+ unknownKeys(modelConfigRules, MODEL_RULE_SETS, 'config.modelConfigRules')
148
+ for (const setName of ['providerModelRules', 'manualProviderModelRules']) {
149
+ const rules = modelConfigRules[setName]
150
+ if (rules === undefined) continue
151
+ if (!Array.isArray(rules)) {
152
+ problems.push(`config.modelConfigRules.${setName}: must be an array`)
153
+ continue
154
+ }
155
+ rules.forEach((rule, index) => {
156
+ const where = `config.modelConfigRules.${setName}[${index}]`
157
+ unknownKeys(rule, RULE_KEYS, where)
158
+ if (typeof rule?.['providerId'] !== 'string') problems.push(`${where}.providerId: required string`)
159
+ if (typeof rule?.['modelId'] !== 'string') problems.push(`${where}.modelId: required string`)
160
+ if (rule?.['config'] !== undefined) {
161
+ walkConfig(rule['config'], `${where}.config`, { manual: setName === 'manualProviderModelRules' })
162
+ }
163
+ })
164
+ }
165
+ }
166
+
167
+ const providerRules = config['providerConfigRules']?.['providerRules']
168
+ if (providerRules !== undefined) {
169
+ if (!Array.isArray(providerRules)) {
170
+ problems.push('config.providerConfigRules.providerRules: must be an array')
171
+ } else {
172
+ providerRules.forEach((rule, index) => {
173
+ const where = `config.providerConfigRules.providerRules[${index}]`
174
+ unknownKeys(rule, PROVIDER_RULE_KEYS, where)
175
+ const ruleConfig = rule?.['config']
176
+ if (ruleConfig === undefined) return
177
+ unknownKeys(ruleConfig, PROVIDER_CONFIG_KEYS, `${where}.config`)
178
+ const ids = ruleConfig['personalModelIds']
179
+ if (ids !== undefined && ids !== null && !Array.isArray(ids)) {
180
+ problems.push(`${where}.config.personalModelIds: must be an array or null`)
181
+ }
182
+ const modelOrder = ruleConfig['modelOrder']
183
+ if (modelOrder !== undefined && modelOrder !== null && !Array.isArray(modelOrder)) {
184
+ problems.push(`${where}.config.modelOrder: must be an array or null`)
185
+ }
186
+ const api = ruleConfig['api']
187
+ if (isObj(api) && typeof api['type'] === 'string' && !API_TYPES.has(api['type'])) {
188
+ problems.push(`${where}.config.api.type: unsupported "${api['type']}"`)
189
+ }
190
+ })
191
+ }
192
+ }
193
+
194
+ if (problems.length === 0) {
195
+ const ruleCount = (modelConfigRules?.['providerModelRules']?.length ?? 0)
196
+ + (modelConfigRules?.['manualProviderModelRules']?.length ?? 0)
197
+ console.log(`VALID — ${path}`)
198
+ console.log(` providers: ${Array.isArray(providerRules) ? providerRules.length : 0}, model rules: ${ruleCount}`)
199
+ process.exit(0)
200
+ }
201
+ console.error(`INVALID — ${path}`)
202
+ for (const problem of problems) console.error(` - ${problem}`)
203
+ process.exit(1)
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SessionStart hook: make sure the local Qoder endpoint is running.
4
+ *
5
+ * Same contract as zcode-trae-connect's hook: the provider entry in ZCode's
6
+ * provider_config.json points at a fixed loopback port (39270), so something
7
+ * has to be listening there before the first message. Self-verifying — the
8
+ * spawn call alone never counts as success — and never fails the session.
9
+ *
10
+ * @module zcode-qoder-connect/hooks/service-ensure
11
+ */
12
+
13
+ import { spawn } from 'node:child_process'
14
+ import { readFile, appendFile, mkdir } from 'node:fs/promises'
15
+ import { existsSync } from 'node:fs'
16
+ import { dirname, join } from 'node:path'
17
+ import { fileURLToPath } from 'node:url'
18
+
19
+ const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)))
20
+ const cliPath = join(pluginRoot, 'bin', 'cli.mjs')
21
+ const stateDir = process.env['ZCODE_QODER_CONNECT_HOME'] ?? join(process.env['USERPROFILE'] ?? '.', '.zcode-qoder-connect')
22
+ const endpointPath = join(stateDir, 'endpoint.json')
23
+ const logPath = join(stateDir, 'service.log')
24
+ const DEFAULT_PORT = 39270
25
+
26
+ async function log(line) {
27
+ try {
28
+ await mkdir(stateDir, { recursive: true })
29
+ await appendFile(logPath, `${new Date().toISOString()} ${line}\n`)
30
+ } catch {}
31
+ }
32
+
33
+ async function readEndpoint() {
34
+ try {
35
+ const document = JSON.parse(await readFile(endpointPath, 'utf8'))
36
+ const token = typeof document['token'] === 'string' ? document['token'] : ''
37
+ const port = typeof document['port'] === 'number' && document['port'] > 0 ? document['port'] : DEFAULT_PORT
38
+ return { token, port }
39
+ } catch {
40
+ return { token: '', port: DEFAULT_PORT }
41
+ }
42
+ }
43
+
44
+ async function healthy(port, token, timeoutMs = 2500) {
45
+ try {
46
+ const response = await fetch(`http://127.0.0.1:${port}/healthz`, {
47
+ headers: token === '' ? {} : { Authorization: `Bearer ${token}` },
48
+ signal: AbortSignal.timeout(timeoutMs),
49
+ })
50
+ if (!response.ok) return false
51
+ const body = await response.json()
52
+ return body?.['ok'] === true
53
+ } catch {
54
+ return false
55
+ }
56
+ }
57
+
58
+ async function waitForHealth(port, token, attempts = 20) {
59
+ for (let i = 0; i < attempts; i += 1) {
60
+ if (await healthy(port, token)) return true
61
+ await new Promise(resolve => setTimeout(resolve, 500))
62
+ }
63
+ return false
64
+ }
65
+
66
+ function spawnDetached(port) {
67
+ const child = spawn(process.execPath, [cliPath, 'serve', '--port', String(port)], {
68
+ detached: true,
69
+ stdio: 'ignore',
70
+ windowsHide: true,
71
+ cwd: pluginRoot,
72
+ })
73
+ child.unref()
74
+ return child.pid
75
+ }
76
+
77
+ async function main() {
78
+ if (!existsSync(cliPath)) {
79
+ await log(`cli not found at ${cliPath}; skipping`)
80
+ return
81
+ }
82
+ const { token, port } = await readEndpoint()
83
+
84
+ if (await healthy(port, token)) {
85
+ await log(`endpoint already healthy on :${port}`)
86
+ return
87
+ }
88
+
89
+ let pid
90
+ try {
91
+ pid = spawnDetached(port)
92
+ await log(`spawned detached pid ${pid} on :${port}`)
93
+ } catch (error) {
94
+ await log(`spawn refused: ${String(error)}`)
95
+ }
96
+
97
+ if (await waitForHealth(port, token)) {
98
+ await log(`endpoint healthy on :${port}${pid === undefined ? '' : ` (pid ${pid})`}`)
99
+ return
100
+ }
101
+
102
+ await log(`endpoint NOT healthy on :${port} after spawn attempt`)
103
+ console.log(
104
+ `[zcode-qoder-connect] The local endpoint is not running on port ${port}.`
105
+ + ' Its models will fail until it is started. Start it in a terminal with:\n'
106
+ + ` node "${cliPath}" serve --port ${port}\n`
107
+ + '凭据提示: Qoder 桌面 dt- 过期时,打开一次 Qoder CN 桌面即可刷新。',
108
+ )
109
+ }
110
+
111
+ main().catch(async error => {
112
+ await log(`hook error: ${String(error)}`)
113
+ }).finally(() => {
114
+ process.exit(0)
115
+ })