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/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "zcode-qoder-connect",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Bring Qoder CN models (DeepSeek-V4-Pro, Qwen3.8-Max, GLM-5.3 …) into ZCode as an OpenAI-compatible provider, reusing the Qoder desktop sign-in — zero configuration.",
5
+ "description_i18n": {
6
+ "en": "Bring Qoder CN models into ZCode as an OpenAI-compatible provider, reusing the Qoder desktop sign-in.",
7
+ "zh-CN": "把 Qoder 国内版的模型(DeepSeek-V4-Pro、Qwen3.8-Max、GLM-5.3 等)复用桌面登录零配置接入 ZCode 的模型供应商。"
8
+ },
9
+ "author": {
10
+ "name": "xiao-hans",
11
+ "url": "https://github.com/xiao-hans"
12
+ },
13
+ "license": "MIT",
14
+ "type": "module",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/xiao-hans/zcode-ai-connect.git",
18
+ "directory": "zcode-qoder-connect"
19
+ },
20
+ "homepage": "https://github.com/xiao-hans/zcode-ai-connect/tree/main/zcode-qoder-connect#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/xiao-hans/zcode-ai-connect/issues"
23
+ },
24
+ "os": [
25
+ "win32"
26
+ ],
27
+ "engines": {
28
+ "node": ">=18.17.0"
29
+ },
30
+ "bin": {
31
+ "zcode-qoder-connect": "bin/cli.mjs"
32
+ },
33
+ "files": [
34
+ ".zcode-plugin",
35
+ "bin",
36
+ "src",
37
+ "hooks",
38
+ "skills",
39
+ "docs",
40
+ "LICENSE",
41
+ "README.md"
42
+ ],
43
+ "dependencies": {},
44
+ "keywords": [
45
+ "qoder",
46
+ "openai-compatible",
47
+ "provider",
48
+ "deepseek",
49
+ "qwen",
50
+ "glm"
51
+ ]
52
+ }
@@ -0,0 +1,68 @@
1
+ ---
2
+ name: qoder-models
3
+ description: Operate the Qoder model provider — start/stop the local endpoint, check credential and quota, list or refresh the model roster, run daily check-in, and troubleshoot "model fails to respond" errors. Use when the user asks about Qoder models in ZCode, the provider shows no models, a Qoder model errors out, quota balance, or the check-in credits.
4
+ when_to_use: The user mentions Qoder, the Qoder provider, or a Qoder model (DeepSeek-V4-Pro, DeepSeek-Flash, Qwen3.8-Max, Qwen3.8-Flash, GLM-5.3, Fast, Auto), or reports that a model from this provider fails.
5
+ ---
6
+
7
+ # Qoder models in ZCode
8
+
9
+ This plugin exposes the models inside the **Qoder CN desktop app** as a ZCode
10
+ model provider. Credentials are reused read-only from the Qoder desktop's own
11
+ sign-in vault (DPAPI + AES-GCM `auth.v1.dat`); a PAT
12
+ (`qoder.cn → 账号设置 → 个人访问令牌`) is an optional fallback. A small local
13
+ service translates between OpenAI chat-completions and Qoder's COSY-signed
14
+ gateway protocol:
15
+
16
+ ```
17
+ ZCode ──(OpenAI chat-completions)──▶ 127.0.0.1:39270 ──▶ gateway.qoder.com.cn
18
+ local endpoint (COSY + WAF encode)
19
+ ```
20
+
21
+ ## Where things are
22
+
23
+ | Thing | Path |
24
+ |---|---|
25
+ | Service + CLI | `${ZCODE_PLUGIN_ROOT}` |
26
+ | State (PAT, endpoint bearer, logs) | `%USERPROFILE%\.zcode-qoder-connect` |
27
+ | Provider entry (registry the picker reads) | `%USERPROFILE%\.zcode\v2\provider_config.json` |
28
+ | Qoder desktop vault (read-only) | `%APPDATA%\com.qodercn.app.stable\auth.v1.dat` |
29
+
30
+ All CLI commands are run as `node "${ZCODE_PLUGIN_ROOT}/bin/cli.mjs" <command>`.
31
+
32
+ ## The one thing that usually matters
33
+
34
+ The provider only works while the local endpoint is running. A `SessionStart`
35
+ hook starts it automatically, but if a session reports "model not responding":
36
+
37
+ ```sh
38
+ node "${ZCODE_PLUGIN_ROOT}/bin/cli.mjs" service-status
39
+ ```
40
+
41
+ - **`endpoint … up`** — the service is fine; look elsewhere (quota, credential).
42
+ - **`endpoint … NOT reachable`** — start it:
43
+ `node "${ZCODE_PLUGIN_ROOT}/bin/cli.mjs" serve --port 39270`
44
+
45
+ ## Commands
46
+
47
+ | Command | What it does |
48
+ |---|---|
49
+ | `serve [--port N]` | Run the endpoint (default 39270) |
50
+ | `status` | Credential tracks (desktop vault / PAT), quota, model roster |
51
+ | `refresh` | Re-pull the model roster from the gateway |
52
+ | `checkin` | Claim today's Qoder check-in credits when a campaign is live |
53
+ | `login <PAT>` | Validate and save a personal access token |
54
+ | `logout` | Drop the saved PAT (desktop vault untouched) |
55
+ | `service-status` | Is the endpoint reachable |
56
+
57
+ ## Diagnosing a failure
58
+
59
+ 1. **Is the endpoint up?** `service-status`. If not reachable, start it.
60
+ 2. **Is the desktop dt- token fresh?** `status`. When it reads `dt- 已过期`,
61
+ the fix is opening the Qoder CN desktop app ONCE so it refreshes the vault
62
+ (the plugin re-reads it automatically; no re-login needed on this side).
63
+ 3. **Quota left?** `status` prints the credits document
64
+ (`isQuotaExceeded`, `totalUsagePercentage`).
65
+ 4. **Free-tier models queue at peak** — the service retries a queued request
66
+ (code 10605) for ~24s; a 429-ish failure right after usually means the queue
67
+ was longer still. Retry, or switch to a paid-tier model.
68
+ 5. **Model roster changed?** `refresh` then `status`.
package/src/catalog.js ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Qoder model catalog: the roster the endpoint publishes.
3
+ *
4
+ * Content lives in the upstream module (FALLBACK_MODELS plus the live roster
5
+ * from the Qoder model list); this holder adds the two things the endpoint
6
+ * needs on top of it — visibility and provenance. Visibility is separate from
7
+ * content because a signed-out account must expose *no* models: serving the
8
+ * fallback roster to a signed-out user offers models that can only fail, which
9
+ * is worse than showing nothing.
10
+ *
11
+ * @module zcode-qoder-connect/catalog
12
+ */
13
+
14
+ import { FALLBACK_MODELS } from './upstream.js'
15
+
16
+ /** The roster the endpoint serves before its first successful fetch. */
17
+ function initialRoster() {
18
+ return [...FALLBACK_MODELS]
19
+ }
20
+
21
+ export class QoderCatalog {
22
+ constructor() {
23
+ this.models = initialRoster()
24
+ this.visible = false
25
+ /** Provenance of the roster currently held. */
26
+ this.source = 'fallback'
27
+ this.fetchedAtMs = undefined
28
+ }
29
+
30
+ /** Current entries; empty while the catalog has no usable credential. */
31
+ current() {
32
+ if (!this.visible) return []
33
+ return this.models
34
+ }
35
+
36
+ /** Replace the list with a freshly fetched roster. */
37
+ set(models, { source, fetchedAtMs } = {}) {
38
+ this.models = [...models]
39
+ if (source !== undefined) this.source = source
40
+ this.fetchedAtMs = fetchedAtMs ?? Date.now()
41
+ }
42
+
43
+ isVisible() {
44
+ return this.visible
45
+ }
46
+
47
+ /** Show or hide the whole catalog. Returns whether the value changed. */
48
+ setVisible(visible) {
49
+ if (this.visible === visible) return false
50
+ this.visible = visible
51
+ return true
52
+ }
53
+
54
+ /** Models to fall back to when the upstream fetch fails; ignores visibility. */
55
+ fallback() {
56
+ return this.models
57
+ }
58
+ }
package/src/config.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Runtime paths shared across the Qoder connect service.
3
+ *
4
+ * Vendor-agnostic shape copied from zcode-trae-connect; only the env var and
5
+ * state-directory name are Qoder-specific so the credential copy and the
6
+ * endpoint bearer live under their own folder, never inside the Qoder desktop
7
+ * app's own files.
8
+ *
9
+ * @module zcode-qoder-connect/config
10
+ */
11
+
12
+ import { join } from 'node:path'
13
+ import { homedir } from 'node:os'
14
+
15
+ /**
16
+ * Root of everything this service owns on disk.
17
+ *
18
+ * Deliberately outside the Qoder app's data dir: the credential copy and the
19
+ * endpoint bearer are this service's state.
20
+ */
21
+ export function defaultStateDir() {
22
+ const fromEnv = process.env['ZCODE_QODER_CONNECT_HOME']
23
+ if (fromEnv !== undefined && fromEnv.trim() !== '') return fromEnv
24
+ return join(homedir(), '.zcode-qoder-connect')
25
+ }
26
+
27
+ const state = {
28
+ stateDir: defaultStateDir(),
29
+ /** Fixed loopback port; 0 asks the OS for a free port. */
30
+ port: 0,
31
+ /** Bearer the OpenAI-compatible endpoint requires. */
32
+ token: '',
33
+ }
34
+
35
+ /** Point the service's state (credential copy, caches) at a directory. */
36
+ export function configureStateDir(dir) {
37
+ if (typeof dir === 'string' && dir !== '') state.stateDir = dir
38
+ }
39
+
40
+ export function stateDir() {
41
+ return state.stateDir
42
+ }
43
+
44
+ export function configureEndpoint({ port, token } = {}) {
45
+ if (typeof port === 'number' && Number.isInteger(port) && port >= 0 && port <= 65535) state.port = port
46
+ if (typeof token === 'string' && token !== '') state.token = token
47
+ }
48
+
49
+ export function endpointConfig() {
50
+ return { port: state.port, token: state.token }
51
+ }
52
+
53
+ /** Path of the plugin-owned Qoder credential copy. */
54
+ export function ownAuthPath() {
55
+ return join(state.stateDir, '.qoder-credentials.json')
56
+ }
57
+
58
+ /** Path of the persisted endpoint settings (port and bearer). */
59
+ export function endpointPath() {
60
+ return join(state.stateDir, 'endpoint.json')
61
+ }
62
+
63
+ /**
64
+ * The endpoint's bearer, generated once and persisted.
65
+ *
66
+ * It has to be a *stable* value rather than a per-process secret: the client
67
+ * that points at this endpoint records its bearer in its own configuration
68
+ * file, so a token rotating on every start would break it on restart.
69
+ */
70
+ export function loadToken(fallback) {
71
+ if (state.token !== '') return state.token
72
+ if (typeof fallback === 'string' && fallback !== '') {
73
+ state.token = fallback
74
+ return state.token
75
+ }
76
+ return ''
77
+ }
78
+
79
+ export function setToken(token) {
80
+ if (typeof token === 'string') state.token = token
81
+ }
@@ -0,0 +1,185 @@
1
+ // credentials.js — Qoder credential handling for zcode-qoder-connect.
2
+ //
3
+ // Two tracks, tried in order:
4
+ // 1. PAT — a personal access token pasted by the human into
5
+ // %USERPROFILE%\.zcode-qoder-connect\pat.txt (generated at
6
+ // qoder.cn → 账号设置 → 个人访问令牌). Exchanged at
7
+ // POST {openapi}/api/v1/jobToken/exchange {personal_token}.
8
+ // 2. Desktop — Qoder CN's own sign-in vault, reused read-only:
9
+ // %APPDATA%/com.qodercn.app.stable/Local State
10
+ // └─ os_crypt.encrypted_key ("DPAPI" prefix + blob)
11
+ // → DPAPI Unprotect (powershell System.Security) → AES key
12
+ // %APPDATA%/com.qodercn.app.stable/auth.v1.dat
13
+ // └─ "v10" + nonce[12] + AES-256-GCM(ct + tag[16])
14
+ // → { token:"dt-…", refreshToken:"drt-…", expiresAt, user }
15
+ // dt- → POST {openapi}/api/v1/me/jobToken {clientId} → jt-.
16
+ // When dt- is expired the fix is opening the Qoder desktop app
17
+ // once (it refreshes the vault); the drt- refresh endpoint is not
18
+ // public and was not reverse-engineered.
19
+ //
20
+ // Both tracks converge on a job token ("jt-") + userID; COSY-signed gateway
21
+ // calls (models/chat) carry them. Cached for 10 minutes.
22
+
23
+ import { createDecipheriv } from 'node:crypto'
24
+ import { execFileSync } from 'node:child_process'
25
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
26
+ import { join } from 'node:path'
27
+ import { homedir } from 'node:os'
28
+
29
+ /** Qoder CN desktop data directory. */
30
+ export function qoderDataDir() {
31
+ const fromEnv = process.env['QODER_DATA_DIR']
32
+ if (fromEnv !== undefined && fromEnv.trim() !== '') return fromEnv
33
+ return join(process.env['APPDATA'] ?? join(homedir(), 'AppData', 'Roaming'), 'com.qodercn.app.stable')
34
+ }
35
+
36
+ export const OPENAPI_BASE = 'https://openapi.qoder.com.cn'
37
+ const JOB_TOKEN_CLIENT_ID = '732aef47-9cf2-46a2-95fe-4cebb5d0d1fa'
38
+ const CACHE_TTL_MS = 10 * 60 * 1000
39
+
40
+ export const SIGN_IN_HINT =
41
+ '两条路任选:1) 打开 https://qoder.cn/account/integrations 生成个人访问令牌,运行 `node bin/cli.mjs login <PAT>`;2) 打开一次 Qoder CN 桌面程序让它刷新登录态(auth.v1.dat 的 dt- 已过期时必须这样做)'
42
+
43
+ export function patPath(stateDir) {
44
+ return join(stateDir, 'pat.txt')
45
+ }
46
+
47
+ /** DPAPI-unprotect the Chromium master key via PowerShell. */
48
+ function deriveMasterKey(dataDir) {
49
+ const state = JSON.parse(readFileSync(join(dataDir, 'Local State'), 'utf8'))
50
+ const blob = Buffer.from(state.os_crypt.encrypted_key, 'base64')
51
+ if (blob.subarray(0, 5).toString() !== 'DPAPI') throw new Error('Local State encrypted_key 不是 DPAPI 格式')
52
+ const b64 = blob.subarray(5).toString('base64')
53
+ const ps = "Add-Type -AssemblyName System.Security;[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String('" + b64 + "'),$null,'CurrentUser'))"
54
+ const out = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).trim()
55
+ const key = Buffer.from(out, 'base64')
56
+ if (key.length !== 32) throw new Error('DPAPI 解出的主密钥长度异常: ' + key.length)
57
+ return key
58
+ }
59
+
60
+ /** AES-256-GCM decrypt of auth.v1.dat (v10 + nonce[12] + ct + tag[16]). */
61
+ export function decryptAuthVault(masterKey, authPath) {
62
+ const buf = readFileSync(authPath)
63
+ if (!(buf[0] === 0x76 && buf[1] === 0x31 && buf[2] === 0x30)) {
64
+ throw new Error(`auth.v1.dat 魔数异常:${buf.subarray(0, 3).toString('hex')}(期望 v10)`)
65
+ }
66
+ const decipher = createDecipheriv('aes-256-gcm', masterKey, buf.subarray(3, 15))
67
+ decipher.setAuthTag(buf.subarray(buf.length - 16))
68
+ const plaintext = Buffer.concat([decipher.update(buf.subarray(15, buf.length - 16)), decipher.final()])
69
+ return JSON.parse(plaintext.toString('utf8'))
70
+ }
71
+
72
+ async function openApiJson(url, { method, token, body } = {}) {
73
+ const response = await fetch(url, {
74
+ method: method ?? (body !== undefined ? 'POST' : 'GET'),
75
+ headers: {
76
+ accept: 'application/json',
77
+ 'content-type': 'application/json',
78
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
79
+ },
80
+ body: body !== undefined ? JSON.stringify(body) : undefined,
81
+ signal: AbortSignal.timeout(20000),
82
+ })
83
+ const text = await response.text()
84
+ if (!response.ok) {
85
+ const err = new Error(`qoder openapi [${response.status}]: ${text.slice(0, 200)}`)
86
+ err.status = response.status
87
+ throw err
88
+ }
89
+ let payload
90
+ try { payload = JSON.parse(text) } catch { throw new Error('qoder openapi: 非 JSON 响应') }
91
+ if (payload && typeof payload === 'object' && 'code' in payload && payload.code !== 0 && payload.code !== '0') {
92
+ const err = new Error(`qoder openapi code ${payload.code}: ${String(payload.message ?? payload.msg ?? '').slice(0, 160)}`)
93
+ err.status = 401
94
+ throw err
95
+ }
96
+ return payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload
97
+ }
98
+
99
+ /** PAT → job token via the documented exchange. */
100
+ export async function exchangePat(pat) {
101
+ return await openApiJson(`${OPENAPI_BASE}/api/v1/jobToken/exchange`, { body: { personal_token: pat } })
102
+ }
103
+
104
+ /** dt- → job token via the desktop client id. */
105
+ export async function exchangeJobToken(dtToken) {
106
+ return await openApiJson(`${OPENAPI_BASE}/api/v1/me/jobToken`, {
107
+ token: dtToken,
108
+ body: { clientId: JOB_TOKEN_CLIENT_ID },
109
+ })
110
+ }
111
+
112
+ /** jt- → {id, email, name}. */
113
+ async function fetchUserInfo(jobToken) {
114
+ const info = await openApiJson(`${OPENAPI_BASE}/api/v1/userinfo`, { token: jobToken })
115
+ if (!info?.id) throw new Error('qoder userinfo 未返回用户 id')
116
+ return { userID: info.id, email: info.email ?? '', name: info.name ?? info.username ?? 'Qoder User' }
117
+ }
118
+
119
+ export function readMachineId(dataDir = qoderDataDir()) {
120
+ try { return readFileSync(join(dataDir, 'auth.machine-id'), 'utf8').trim() } catch { return '' }
121
+ }
122
+
123
+ /** The vault stores expiresAt as either epoch-ms or an ISO string. */
124
+ function dtExpiresAtMs(auth) {
125
+ const v = auth?.expiresAt
126
+ if (typeof v === 'number') return v
127
+ if (typeof v === 'string') { const p = Date.parse(v); return Number.isNaN(p) ? 0 : p }
128
+ return 0
129
+ }
130
+
131
+ export class QoderCredentialStore {
132
+ constructor({ stateDir, dataDir } = {}) {
133
+ this.stateDir = stateDir
134
+ this.dataDir = dataDir ?? qoderDataDir()
135
+ this.cache = null
136
+ }
137
+
138
+ /** Which tracks are configured, without contacting the network. */
139
+ status() {
140
+ const pat = existsSync(patPath(this.stateDir))
141
+ const vault = existsSync(join(this.dataDir, 'auth.v1.dat'))
142
+ let dtFresh = false
143
+ if (vault) {
144
+ try {
145
+ const masterKey = deriveMasterKey(this.dataDir)
146
+ const auth = decryptAuthVault(masterKey, join(this.dataDir, 'auth.v1.dat'))
147
+ dtFresh = dtExpiresAtMs(auth) > Date.now()
148
+ } catch {}
149
+ }
150
+ return { patConfigured: pat, desktopVault: vault, desktopTokenFresh: dtFresh }
151
+ }
152
+
153
+ async resolve() {
154
+ if (this.cache && this.cache.expiresAt > Date.now()) return this.cache.credential
155
+ let jobToken = null
156
+ let source = ''
157
+ const patFile = patPath(this.stateDir)
158
+ if (existsSync(patFile)) {
159
+ const pat = readFileSync(patFile, 'utf8').trim()
160
+ if (pat) {
161
+ const data = await exchangePat(pat)
162
+ if (data?.token) { jobToken = data.token; source = 'pat' }
163
+ }
164
+ }
165
+ if (!jobToken && existsSync(join(this.dataDir, 'auth.v1.dat'))) {
166
+ const masterKey = deriveMasterKey(this.dataDir)
167
+ const auth = decryptAuthVault(masterKey, join(this.dataDir, 'auth.v1.dat'))
168
+ if (!(dtExpiresAtMs(auth) > Date.now())) throw new Error(`Qoder 桌面 dt- 令牌已过期(${new Date(dtExpiresAtMs(auth)).toISOString()})。${SIGN_IN_HINT}`)
169
+ const data = await exchangeJobToken(auth.token)
170
+ if (data?.token) { jobToken = data.token; source = 'desktop' }
171
+ }
172
+ if (!jobToken) throw new Error(`没有任何可用的 Qoder 凭据。${SIGN_IN_HINT}`)
173
+ const info = await fetchUserInfo(jobToken)
174
+ const credential = {
175
+ userID: info.userID,
176
+ authToken: jobToken,
177
+ name: info.name,
178
+ email: info.email,
179
+ machineID: readMachineId(this.dataDir) || '00000000-0000-0000-0000-000000000000',
180
+ source,
181
+ }
182
+ this.cache = { credential, expiresAt: Date.now() + CACHE_TTL_MS }
183
+ return credential
184
+ }
185
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Loopback checks for the local OpenAI-compatible endpoint.
3
+ *
4
+ * Binding to 127.0.0.1 is not by itself a trust boundary: any local process,
5
+ * and a DNS-rebinding page in a browser, can reach a loopback port. Every
6
+ * request must therefore also name a loopback Host, and browser-sent Origins
7
+ * must be loopback too, so a rebinding page is rejected on both counts.
8
+ *
9
+ * @module zcode-trae-connect/loopback
10
+ */
11
+
12
+ /** Hostname of a Host header value, with the port and IPv6 brackets removed. */
13
+ export function hostnameOfHost(host) {
14
+ if (typeof host !== 'string' || host === '') return ''
15
+ const trimmed = host.trim()
16
+ // IPv6 literal: [::1]:1234 or [::1]
17
+ const v6 = /^\[([^\]]+)\](?::\d+)?$/u.exec(trimmed)
18
+ if (v6 !== null) return (v6[1] ?? '').toLowerCase()
19
+ const colon = trimmed.lastIndexOf(':')
20
+ return (colon === -1 ? trimmed : trimmed.slice(0, colon)).toLowerCase()
21
+ }
22
+
23
+ /** Whether a hostname names the loopback interface. */
24
+ export function hostIsLoopback(host) {
25
+ const name = hostnameOfHost(host)
26
+ return name === '127.0.0.1' || name === 'localhost' || name === '::1' || name === '[::1]'
27
+ }
28
+
29
+ /** Whether an Origin header (absent counts as fine) is a loopback origin. */
30
+ export function originIsLoopback(origin) {
31
+ if (origin === undefined || origin === null || origin === '') return true
32
+ if (typeof origin !== 'string') return false
33
+ let parsed
34
+ try {
35
+ parsed = new URL(origin)
36
+ } catch {
37
+ return false
38
+ }
39
+ return hostIsLoopback(parsed.host)
40
+ }