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.
- package/.zcode-plugin/plugin.json +40 -0
- package/LICENSE +21 -0
- package/README.md +71 -2
- package/bin/cli.mjs +204 -0
- package/docs/validate-provider-config.mjs +203 -0
- package/hooks/service-ensure.mjs +115 -0
- package/package.json +50 -4
- package/skills/qoder-models/SKILL.md +68 -0
- package/src/catalog.js +58 -0
- package/src/config.js +81 -0
- package/src/credentials.js +185 -0
- package/src/loopback.js +40 -0
- package/src/server.js +799 -0
- package/src/upstream.js +581 -0
|
@@ -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
|
-
#
|
|
1
|
+
# zcode-qoder-connect
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
})
|