ck-pi-zen-session 0.1.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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 narumiruna
4
+ Copyright (c) 传康Kk
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # ck-pi-zen-session
2
+
3
+ OpenCode Zen 免费模型同步、请求头伪装与 Session 会话自动维护插件,专为 [Pi 编码智能体](https://github.com/earendil-works/pi) 打造。
4
+
5
+ ---
6
+
7
+ ## 🌟 核心特性
8
+
9
+ - ⚡ **/zen 指令一键激活**:输入一次 API Key (`/zen oc_sk_...`),全自动验证、探测 9 款可用免费模型并注入 Pi 与 CC-Switch。
10
+ - 🔄 **官方算法深度逆向**:完整对齐 OpenCode 客户端 `Identifier.descending("ses")` 与 `Identifier.ascending("msg")` 算法,生成 30 位降序会话 ID 与升序请求 ID,支持毫秒级时间戳反解。
11
+ - 🛡️ **全套官方级伪装请求头**:自动注入全部 6 个官方客户端对齐请求头:
12
+ - `User-Agent`: `opencode/1.18.32 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14`
13
+ - `x-opencode-client`: `cli`
14
+ - `x-opencode-session`: `ses_<12位反转时间戳十六进制><14位Base62>`
15
+ - `x-session-affinity`: 与 session 严格同步
16
+ - `X-Session-Id`: 与 session 严格同步
17
+ - `x-opencode-request`: `msg_<12位升序时间戳十六进制><14位Base62>`(每次 HTTP 请求唯一)
18
+ - ⏱️ **45 分钟无感前置平滑轮换**:官方后端限制免费会话时长约为 1 小时(超时直接 403 `FreeTierError`)。本插件在底层请求拦截器(`before_provider_headers`)和 Agent 循环守卫(`before_agent_start`)中设置 45 分钟主动换新阈值,确保永不触碰 1 小时硬限制。
19
+ - 🧰 **Agent 工具链兼容性守卫**:OpenCode Zen 免费端点对工具集有硬性要求(无工具直接拒绝)。插件在 `before_provider_request` 钩子中实时监控请求体,若用户处于无工具或纯文本问答模式,自动补齐兼容性工具定义,彻底根除 403 拦截。
20
+ - 🌐 **独立供应商隔离命名空间**:注册为独立供应商 `opencode-zen-free`,彻底避开并兼容用户自带的官方登录 `opencode` / `opencode-zen` 套餐,互不干扰、平稳共存。
21
+ - 🧠 **精准上下文与思维链适配**:严格对齐 9 款免费模型的 `contextWindow`、`maxTokens` 与 `thinkingLevelMap`(涵盖小米 MiMo、英伟达 Nemotron、Meta Muse Spark、Ling、Jev 等)。
22
+ - 🔌 **全自动多端持久化同步**:自动双写 Pi 本地配置(`~/.pi/agent/models.json` 和 `~/.pi/agent/auth.json`),若检测到 CC-Switch 亦无缝同步其 SQLite 数据库。
23
+
24
+ ---
25
+
26
+ ## 📦 安装方法
27
+
28
+ ### 方式 1:npm 安装(推荐)
29
+
30
+ ```bash
31
+ pi install npm:ck-pi-zen-session
32
+ ```
33
+
34
+ ### 方式 2:GitHub 源码安装
35
+
36
+ ```bash
37
+ pi install git:github.com/1837620622/ck-pi-extension
38
+ ```
39
+
40
+ ---
41
+
42
+ ## 🚀 指令用法
43
+
44
+ 在 Pi 交互界面中直接键入 `/zen`:
45
+
46
+ | 指令 | 说明 |
47
+ | --- | --- |
48
+ | `/zen <oc_sk_xxx>` | 设置/更新 API Key,自动在线验证并全量同步模型与全套请求头 |
49
+ | `/zen` | 智能检查当前会话状态;若会话临近过期自动换新;若未配置 Key 则弹出交互输入框 |
50
+ | `/zen refresh` | 强制生成全新的合法降序 Session ID 并更新所有请求头与落盘配置 |
51
+ | `/zen status` | 查看当前 API Key 掩码、活跃会话精确存活时间及模型就绪状态 |
52
+ | `/zen list`(或 `/zen models`) | 查看当前已激活的所有免费模型列表 |
53
+
54
+ ---
55
+
56
+ ## 🤖 内置免费模型库参数
57
+
58
+ 插件自动同步官方 9 款免费模型并配置精准上限与思维链等级:
59
+
60
+ | 模型 ID | 上下文窗口 | 最大输出 | 推理思考等级 (thinkingLevelMap) | 支持模态 |
61
+ | --- | --- | --- | --- | --- |
62
+ | `mimo-v2.5-free` | 200,000 | 32,000 | low / high / max | 文本、图像 |
63
+ | `mimo-v2.6-flash-free` | 200,000 | 32,000 | low / high / max | 文本、图像 |
64
+ | `nemotron-3.5-lightning-free` | 262,144 | 262,144 | low / high / max | 文本 |
65
+ | `nemotron-3-ultra-free` | 1,000,000 (1M) | 128,000 | low / high / max | 文本 |
66
+ | `ling-3.0-flash-fin-free` | 262,144 | 32,768 | low / high / max | 文本 |
67
+ | `big-pickle` | 200,000 | 32,000 | low / high / max | 文本 |
68
+ | `muse-spark-1.3-contributor-free` | 1,048,576 (1M) | 131,072 | minimal / low / medium / high / xhigh | 文本、图像 |
69
+ | `muse-spark-1.2-contributor-free` | 1,048,576 (1M) | 131,072 | minimal / low / medium / high / xhigh | 文本、图像 |
70
+ | `jev-1.13-free` | 128,000 | 16,384 | 基础模型(无 reasoning) | 文本 |
71
+
72
+ > 若 OpenCode 后端未来发布新免费模型,插件将自动探测发现并使用安全的自适应参数接入。
73
+
74
+ ---
75
+
76
+ ## 🔬 官方算法逆向解析
77
+
78
+ OpenCode Zen 客户端针对防刷和网关鉴权设计了基于时间戳编码的降序(descending)与升序(ascending)标识符体系:
79
+
80
+ ```typescript
81
+ // 1. Session ID (前缀 ses_, 降序排列)
82
+ export function generateZenSessionId(timestampMs = Date.now()): string {
83
+ const combined = BigInt(timestampMs) * 0x1000n + BigInt(sequenceCounter);
84
+ const inverted = ~combined; // 关键位反转:时间越新,字典序十六进制越小
85
+ let hex = "";
86
+ for (let i = 0; i < 6; i++) {
87
+ const byte = Number((inverted >> BigInt(40 - 8 * i)) & 0xffn);
88
+ hex += byte.toString(16).padStart(2, "0");
89
+ }
90
+ return `ses_${hex}${randomBase62(14)}`; // 30 字符
91
+ }
92
+
93
+ // 2. Request ID (前缀 msg_, 升序排列)
94
+ export function generateZenRequestId(timestampMs = Date.now()): string {
95
+ const combined = BigInt(timestampMs) * 0x1000n + BigInt(sequenceCounter);
96
+ let hex = "";
97
+ for (let i = 0; i < 6; i++) {
98
+ const byte = Number((combined >> BigInt(40 - 8 * i)) & 0xffn);
99
+ hex += byte.toString(16).padStart(2, "0");
100
+ }
101
+ return `msg_${hex}${randomBase62(14)}`; // 30 字符
102
+ }
103
+ ```
104
+
105
+ ---
106
+
107
+ ## 👨‍💻 作者与许可
108
+
109
+ - 作者:传康Kk
110
+ - 邮箱:`1837620622@qq.com`
111
+ - 仓库:[https://github.com/1837620622/ck-pi-extension](https://github.com/1837620622/ck-pi-extension)
112
+ - 协议:MIT License
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "ck-pi-zen-session",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode Zen session auto-refresh and free-tier model catalog synchronization for the Pi coding agent.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "pi-coding-agent",
9
+ "opencode",
10
+ "zen",
11
+ "session",
12
+ "free-tier"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "传康Kk <1837620622@qq.com>",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/1837620622/ck-pi-extension.git",
19
+ "directory": "packages/pi-zen-session"
20
+ },
21
+ "homepage": "https://github.com/1837620622/ck-pi-extension#pi-zen-session",
22
+ "bugs": {
23
+ "url": "https://github.com/1837620622/ck-pi-extension/issues"
24
+ },
25
+ "type": "module",
26
+ "files": [
27
+ "src",
28
+ "!src/*.test.ts",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "pi": {
33
+ "extensions": [
34
+ "./src/index.ts"
35
+ ]
36
+ },
37
+ "peerDependencies": {
38
+ "@earendil-works/pi-coding-agent": ">=0.85.0"
39
+ },
40
+ "scripts": {
41
+ "test": "esbuild src/index.test.ts --bundle --packages=external --platform=node --format=esm --outfile=.test-dist/test.mjs --log-level=warning && node --test .test-dist/test.mjs"
42
+ }
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,358 @@
1
+ /**
2
+ * OpenCode Zen 插件主入口 (ck-pi-zen-session)
3
+ *
4
+ * 功能特色:
5
+ * 1. 提供 /zen 命令:支持输入 Key 即刻激活、刷新 Session、查看状态与模型清单;
6
+ * 2. 逆向对齐官方 Identifier.descending("ses") 算法,保真生成 30 位降序会话 ID;
7
+ * 3. 自动注入伪装请求头 (User-Agent, x-opencode-session);
8
+ * 4. 自动双写 ~/.pi/agent/models.json 与 CC-Switch 本地数据库;
9
+ * 5. 常驻 before_agent_start 钩子:每当 Session 临近过期(>12h),后台无感自动刷新。
10
+ */
11
+
12
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
+ import {
14
+ KNOWN_ZEN_FREE_MODELS,
15
+ ZEN_BASE_URL,
16
+ ZEN_PROVIDER_ID,
17
+ ZEN_USER_AGENT,
18
+ } from "./models-registry.js";
19
+ import {
20
+ getStoredZenApiKey,
21
+ getStoredZenSessionId,
22
+ getZenStatusInfo,
23
+ syncZenConfiguration,
24
+ } from "./sync.js";
25
+ import {
26
+ DEFAULT_SESSION_MAX_AGE_MS,
27
+ generateZenRequestId,
28
+ generateZenSessionId,
29
+ isZenSessionExpired,
30
+ isValidZenSessionId,
31
+ } from "./session.js";
32
+
33
+ export function registerZenProviderToPi(
34
+ pi: ExtensionAPI,
35
+ apiKey: string,
36
+ sessionId: string = getStoredZenSessionId() || generateZenSessionId(),
37
+ ): void {
38
+ try {
39
+ pi.registerProvider(ZEN_PROVIDER_ID, {
40
+ name: "OpenCode Zen (Free)",
41
+ baseUrl: ZEN_BASE_URL,
42
+ apiKey: apiKey,
43
+ api: "openai-completions",
44
+ headers: {
45
+ "User-Agent": ZEN_USER_AGENT,
46
+ "x-opencode-client": "cli",
47
+ "x-opencode-session": sessionId,
48
+ "x-session-affinity": sessionId,
49
+ "X-Session-Id": sessionId,
50
+ },
51
+ compat: {
52
+ maxTokensField: "max_tokens",
53
+ requiresReasoningContentOnAssistantMessages: true,
54
+ supportsDeveloperRole: false,
55
+ supportsStore: false,
56
+ },
57
+ models: Object.values(KNOWN_ZEN_FREE_MODELS),
58
+ });
59
+ } catch {
60
+ // 忽略重复注册
61
+ }
62
+ }
63
+
64
+ export default function piZenSession(pi: ExtensionAPI): void {
65
+ // 1. 若本地已配置 Key,启动时即刻注册独立供应商 opencode-zen-free
66
+ // 独立命名空间,彻底避开并兼容用户自带的官方登录 opencode / opencode-zen 套餐
67
+ const existingKey = getStoredZenApiKey();
68
+ if (existingKey) {
69
+ registerZenProviderToPi(pi, existingKey);
70
+ }
71
+
72
+ // 2. 核心请求头拦截钩子:在每一次请求发往 Provider 前注入完整伪装请求头
73
+ pi.on("before_provider_headers", (event, ctx) => {
74
+ const model = ctx.model;
75
+ const isZenModel =
76
+ model?.provider === ZEN_PROVIDER_ID ||
77
+ model?.id?.includes("-free") ||
78
+ model?.id === "big-pickle";
79
+
80
+ if (isZenModel) {
81
+ // 检查 x-opencode-session 是否过期 (>45分钟) 或缺失
82
+ let currentSession = event.headers["x-opencode-session"];
83
+ if (
84
+ typeof currentSession !== "string" ||
85
+ !isValidZenSessionId(currentSession) ||
86
+ isZenSessionExpired(currentSession, DEFAULT_SESSION_MAX_AGE_MS)
87
+ ) {
88
+ currentSession = generateZenSessionId();
89
+ event.headers["x-opencode-session"] = currentSession;
90
+ // 异步落盘,保持配置同步
91
+ syncZenConfiguration({ forceSession: true }).catch(() => {});
92
+ }
93
+
94
+ // 注入全部官方客户端对齐请求头
95
+ event.headers["User-Agent"] = ZEN_USER_AGENT;
96
+ event.headers["x-opencode-client"] = "cli";
97
+ event.headers["x-opencode-session"] = currentSession;
98
+ event.headers["x-session-affinity"] = currentSession;
99
+ event.headers["X-Session-Id"] = currentSession;
100
+ event.headers["x-opencode-request"] = generateZenRequestId();
101
+ }
102
+ });
103
+
104
+ // 3. 核心请求体守卫钩子:OpenCode Zen 免费端点对工具集有硬性要求,若缺失工具会报 403 FreeTierError
105
+ pi.on("before_provider_request", (event, ctx) => {
106
+ const model = ctx.model;
107
+ const isZenModel =
108
+ model?.provider === ZEN_PROVIDER_ID ||
109
+ model?.id?.includes("-free") ||
110
+ model?.id === "big-pickle";
111
+
112
+ if (isZenModel && event.payload && typeof event.payload === "object") {
113
+ const payload = event.payload as Record<string, unknown>;
114
+ const tools = payload.tools;
115
+ // 若当前请求没有附带任何工具定义(如纯文本问答模式),自动补齐兼容性工具定义以规避 403 拦截
116
+ if (!Array.isArray(tools) || tools.length === 0) {
117
+ return {
118
+ ...payload,
119
+ tools: [
120
+ {
121
+ type: "function",
122
+ function: {
123
+ name: "bash",
124
+ description: "Execute bash command in the terminal",
125
+ parameters: {
126
+ type: "object",
127
+ properties: {
128
+ command: { type: "string", description: "Command to execute" },
129
+ },
130
+ required: ["command"],
131
+ },
132
+ },
133
+ },
134
+ {
135
+ type: "function",
136
+ function: {
137
+ name: "read",
138
+ description: "Read contents of a file",
139
+ parameters: {
140
+ type: "object",
141
+ properties: {
142
+ path: { type: "string", description: "Path to file" },
143
+ },
144
+ required: ["path"],
145
+ },
146
+ },
147
+ },
148
+ ],
149
+ };
150
+ }
151
+ }
152
+ });
153
+
154
+ // 4. 常驻生命周期守卫:在每次 Agent 循环执行前,自动检测 Session 有效性并无感续期
155
+ pi.on("before_agent_start", async () => {
156
+ try {
157
+ const currentSession = getStoredZenSessionId();
158
+ // 若当前会话不存在或已使用超过 45 分钟,后台自动续期
159
+ if (!currentSession || isZenSessionExpired(currentSession, DEFAULT_SESSION_MAX_AGE_MS)) {
160
+ const apiKey = getStoredZenApiKey();
161
+ if (apiKey) {
162
+ await syncZenConfiguration({ forceSession: true });
163
+ }
164
+ }
165
+ } catch {
166
+ // 后台静默处理,绝不中断用户的编码交互流程
167
+ }
168
+ });
169
+
170
+ // 4. 注册 /zen 指令
171
+ pi.registerCommand("zen", {
172
+ description: "OpenCode Zen 免费模型与 Session 会话自动维护 (/zen [key|refresh|status|list])",
173
+ getArgumentCompletions: (prefix: string) => {
174
+ const candidates = ["refresh", "status", "list", "models"]
175
+ .filter((cmd) => cmd.startsWith(prefix.toLowerCase()))
176
+ .map((cmd) => ({ value: cmd, label: cmd }));
177
+ return candidates.length > 0 ? candidates : null;
178
+ },
179
+ handler: async (args, ctx) => {
180
+ await handleZenCommand(args, ctx, pi);
181
+ },
182
+ });
183
+ }
184
+
185
+ export async function handleZenCommand(
186
+ args: string,
187
+ ctx: ExtensionCommandContext,
188
+ pi?: ExtensionAPI,
189
+ ): Promise<void> {
190
+ const rawArg = args.trim();
191
+ const command = rawArg.toLowerCase();
192
+
193
+ // 1. 查看状态: /zen status
194
+ if (command === "status") {
195
+ const status = getZenStatusInfo();
196
+ if (!status.hasKey) {
197
+ notify(ctx, "OpenCode Zen 尚未配置 API Key。请使用 /zen <oc_sk_xxx> 进行配置。", "warning");
198
+ return;
199
+ }
200
+
201
+ const ageDesc = status.sessionAgeMinutes < 60
202
+ ? `${status.sessionAgeMinutes} 分钟`
203
+ : `${(status.sessionAgeMinutes / 60).toFixed(1)} 小时`;
204
+
205
+ const msg = [
206
+ "✨ [OpenCode Zen 运行状态]",
207
+ `• API Key: ${status.maskedKey}`,
208
+ `• Session: ${status.sessionId || "未生成"} (${status.sessionExpired ? "已过期" : `有效,创建于 ${ageDesc}前`})`,
209
+ `• 模型库: 已激活 ${status.modelsCount} 个免费模型`,
210
+ "",
211
+ "常用指令:",
212
+ " /zen <key> - 更新 Key 并全量重新同步",
213
+ " /zen refresh - 强制换新 Session ID",
214
+ " /zen list - 查看所有可用免费模型规格",
215
+ ].join("\n");
216
+
217
+ notify(ctx, msg, "info");
218
+ return;
219
+ }
220
+
221
+ // 2. 查看模型清单: /zen list 或 /zen models
222
+ if (command === "list" || command === "models") {
223
+ const status = getZenStatusInfo();
224
+ if (status.modelIds.length === 0) {
225
+ notify(ctx, "当前尚未同步任何模型,请先运行 /zen <key>。", "warning");
226
+ return;
227
+ }
228
+
229
+ const listMsg = [
230
+ `✨ [OpenCode Zen 免费模型列表 (${status.modelIds.length} 个)]`,
231
+ ...status.modelIds.map((id) => ` • ${id}`),
232
+ "",
233
+ "提示:这些模型已就绪,可在 Pi 中直接切换使用!",
234
+ ].join("\n");
235
+
236
+ notify(ctx, listMsg, "info");
237
+ return;
238
+ }
239
+
240
+ // 3. 强制换新 Session: /zen refresh
241
+ if (command === "refresh") {
242
+ try {
243
+ notify(ctx, "正在生成全新 OpenCode Zen 会话 ID 并同步...", "info");
244
+ const result = await syncZenConfiguration({ forceSession: true });
245
+ if (pi) {
246
+ registerZenProviderToPi(pi, result.apiKey, result.sessionId);
247
+ }
248
+ const successMsg = [
249
+ "✓ OpenCode Zen Session 已成功刷新!",
250
+ `• 全新 Session: ${result.sessionId}`,
251
+ `• 请求头伪装: User-Agent, x-opencode-session 已注入`,
252
+ `• 同步状态: models.json ✓ | CC-Switch ${result.ccSwitchUpdated ? "✓" : "-(未安装或无此条目)"}`,
253
+ ].join("\n");
254
+ notify(ctx, successMsg, "info");
255
+ } catch (error) {
256
+ notify(ctx, `刷新失败: ${formatError(error)}`, "error");
257
+ }
258
+ return;
259
+ }
260
+
261
+ // 4. 输入了具体的 API Key: /zen oc_sk_... 或其它 key
262
+ if (rawArg.length > 0 && !["status", "refresh", "list", "models"].includes(command)) {
263
+ try {
264
+ notify(ctx, "正在验证 OpenCode Zen API Key 并探测免费模型...", "info");
265
+ const result = await syncZenConfiguration({ apiKey: rawArg, forceSession: true });
266
+ if (pi) {
267
+ registerZenProviderToPi(pi, result.apiKey, result.sessionId);
268
+ }
269
+ const msg = [
270
+ "🎉 OpenCode Zen 配置成功!免费套餐与请求头已全部就绪:",
271
+ `• API Key: ${maskKey(result.apiKey)} (已验证并保存)`,
272
+ `• 会话 Session: ${result.sessionId} (官方降序时间戳逆向算法)`,
273
+ `• 发现免费模型: ${result.modelsCount} 个已自动配置 (含思维链 thinkingLevelMap 适配)`,
274
+ `• 同步路径: ${result.modelsPath}`,
275
+ result.ccSwitchUpdated ? "• CC-Switch: 本地数据库已同步更新" : "",
276
+ ].filter(Boolean).join("\n");
277
+ notify(ctx, msg, "info");
278
+ } catch (error) {
279
+ notify(ctx, `配置失败: ${formatError(error)}`, "error");
280
+ }
281
+ return;
282
+ }
283
+
284
+ // 5. 无参调用: /zen
285
+ const status = getZenStatusInfo();
286
+ if (status.hasKey) {
287
+ // 已有 Key 时,自动检测并刷新 Session
288
+ try {
289
+ notify(ctx, "正在检测并刷新 OpenCode Zen 会话...", "info");
290
+ const result = await syncZenConfiguration({ forceSession: status.sessionExpired });
291
+ if (pi) {
292
+ registerZenProviderToPi(pi, result.apiKey, result.sessionId);
293
+ }
294
+ const msg = [
295
+ "✨ [OpenCode Zen 已连接就绪]",
296
+ `• API Key: ${maskKey(result.apiKey)}`,
297
+ `• 活跃 Session: ${result.sessionId} (${result.isNewSession ? "已换新" : "仍有效"})`,
298
+ `• 免费模型: ${result.modelsCount} 个可用`,
299
+ "",
300
+ "常用操作:",
301
+ " /zen <key> - 更换 API Key",
302
+ " /zen refresh - 换新会话 ID",
303
+ " /zen status - 详情状态",
304
+ ].join("\n");
305
+ notify(ctx, msg, "info");
306
+ } catch (error) {
307
+ notify(ctx, `同步状态失败: ${formatError(error)}`, "error");
308
+ }
309
+ return;
310
+ }
311
+
312
+ // 没有 Key,如果在支持交互的 UI 环境下,弹出输入框
313
+ if (ctx.hasUI && typeof ctx.ui.input === "function") {
314
+ try {
315
+ const enteredKey = await ctx.ui.input(
316
+ "请输入 OpenCode Zen API Key (形如 oc_sk_...):",
317
+ "oc_sk_",
318
+ );
319
+ if (enteredKey && enteredKey.trim()) {
320
+ await handleZenCommand(enteredKey.trim(), ctx);
321
+ return;
322
+ }
323
+ notify(ctx, "已取消输入。如需配置请使用 /zen <oc_sk_xxx>", "info");
324
+ return;
325
+ } catch {
326
+ // fallthrough to text usage
327
+ }
328
+ }
329
+
330
+ notify(ctx, "请提供 OpenCode Zen API Key。用法:/zen <oc_sk_xxx>", "warning");
331
+ }
332
+
333
+ function maskKey(key: string): string {
334
+ if (!key) return "未配置";
335
+ if (key.length <= 12) return "******";
336
+ return `${key.slice(0, 8)}...${key.slice(-4)}`;
337
+ }
338
+
339
+ function notify(
340
+ ctx: ExtensionCommandContext,
341
+ message: string,
342
+ level: "info" | "warning" | "error" = "info",
343
+ ): void {
344
+ try {
345
+ if (ctx.hasUI) {
346
+ ctx.ui.notify(message, level);
347
+ } else {
348
+ // 终端或非交互环境日志输出
349
+ console.log(`[${level.toUpperCase()}] ${message}`);
350
+ }
351
+ } catch {
352
+ // 容错
353
+ }
354
+ }
355
+
356
+ function formatError(error: unknown): string {
357
+ return error instanceof Error ? error.message : String(error);
358
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * OpenCode Zen 免费模型参数与配置注册表
3
+ *
4
+ * 严格基于 OpenCode 官方客户端内置参数与在线端点响应对齐:
5
+ * 包含精确上下文上限(Context Window)、最大输出(Max Tokens)、推理思考等级映射(thinkingLevelMap)与输入模态。
6
+ */
7
+
8
+ import type { ZenModelDefinition } from "./types.js";
9
+
10
+ export const ZEN_PROVIDER_ID = "opencode-zen-free";
11
+ export const ZEN_BASE_URL = "https://opencode.ai/zen/v1";
12
+ export const ZEN_USER_AGENT = "opencode/1.18.32 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14";
13
+
14
+ const STANDARD_THINKING_LEVELS: Record<string, string | null> = {
15
+ low: "low",
16
+ high: "high",
17
+ max: "max",
18
+ medium: null,
19
+ minimal: null,
20
+ off: null,
21
+ xhigh: null,
22
+ };
23
+
24
+ const MUSE_SPARK_THINKING_LEVELS: Record<string, string | null> = {
25
+ minimal: "minimal",
26
+ low: "low",
27
+ medium: "medium",
28
+ high: "high",
29
+ xhigh: "xhigh",
30
+ max: "xhigh",
31
+ off: null,
32
+ };
33
+
34
+ /**
35
+ * 官方已知免费模型精确规格字典
36
+ */
37
+ export const KNOWN_ZEN_FREE_MODELS: Record<string, ZenModelDefinition> = {
38
+ "mimo-v2.5-free": {
39
+ id: "mimo-v2.5-free",
40
+ name: "Xiaomi Mimo v2.5 Free (OpenCode Zen)",
41
+ contextWindow: 200000,
42
+ maxTokens: 32000,
43
+ reasoning: true,
44
+ input: ["text", "image"],
45
+ thinkingLevelMap: STANDARD_THINKING_LEVELS,
46
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
47
+ },
48
+ "mimo-v2.6-flash-free": {
49
+ id: "mimo-v2.6-flash-free",
50
+ name: "Xiaomi Mimo v2.6 Flash Free (OpenCode Zen)",
51
+ contextWindow: 200000,
52
+ maxTokens: 32000,
53
+ reasoning: true,
54
+ input: ["text", "image"],
55
+ thinkingLevelMap: STANDARD_THINKING_LEVELS,
56
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
57
+ },
58
+ "nemotron-3.5-lightning-free": {
59
+ id: "nemotron-3.5-lightning-free",
60
+ name: "Nemotron 3.5 Lightning Free (OpenCode Zen)",
61
+ contextWindow: 262144,
62
+ maxTokens: 262144,
63
+ reasoning: true,
64
+ input: ["text"],
65
+ thinkingLevelMap: STANDARD_THINKING_LEVELS,
66
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
67
+ },
68
+ "nemotron-3-ultra-free": {
69
+ id: "nemotron-3-ultra-free",
70
+ name: "Nemotron 3 Ultra Free (OpenCode Zen)",
71
+ contextWindow: 1000000,
72
+ maxTokens: 128000,
73
+ reasoning: true,
74
+ input: ["text"],
75
+ thinkingLevelMap: STANDARD_THINKING_LEVELS,
76
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
77
+ },
78
+ "ling-3.0-flash-fin-free": {
79
+ id: "ling-3.0-flash-fin-free",
80
+ name: "Ling 3.0 Flash Fin Free (OpenCode Zen)",
81
+ contextWindow: 262144,
82
+ maxTokens: 32768,
83
+ reasoning: true,
84
+ input: ["text"],
85
+ thinkingLevelMap: STANDARD_THINKING_LEVELS,
86
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
87
+ },
88
+ "big-pickle": {
89
+ id: "big-pickle",
90
+ name: "Big Pickle Free (OpenCode Zen)",
91
+ contextWindow: 200000,
92
+ maxTokens: 32000,
93
+ reasoning: true,
94
+ input: ["text"],
95
+ thinkingLevelMap: STANDARD_THINKING_LEVELS,
96
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
97
+ },
98
+ "muse-spark-1.3-contributor-free": {
99
+ id: "muse-spark-1.3-contributor-free",
100
+ name: "Meta Muse Spark 1.3 Free (OpenCode Zen)",
101
+ contextWindow: 1048576,
102
+ maxTokens: 131072,
103
+ reasoning: true,
104
+ input: ["text", "image"],
105
+ thinkingLevelMap: MUSE_SPARK_THINKING_LEVELS,
106
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
107
+ },
108
+ "muse-spark-1.2-contributor-free": {
109
+ id: "muse-spark-1.2-contributor-free",
110
+ name: "Meta Muse Spark 1.2 Free (OpenCode Zen)",
111
+ contextWindow: 1048576,
112
+ maxTokens: 131072,
113
+ reasoning: true,
114
+ input: ["text", "image"],
115
+ thinkingLevelMap: MUSE_SPARK_THINKING_LEVELS,
116
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
117
+ },
118
+ "jev-1.13-free": {
119
+ id: "jev-1.13-free",
120
+ name: "TypeSafe Jev 1.13 System-1 Free (OpenCode Zen)",
121
+ contextWindow: 128000,
122
+ maxTokens: 16384,
123
+ reasoning: false,
124
+ input: ["text"],
125
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
126
+ },
127
+ };
128
+
129
+ /**
130
+ * 为未来可能新增的免费模型生成自适应默认配置
131
+ */
132
+ export function buildFallbackModelDefinition(modelId: string): ZenModelDefinition {
133
+ const isReasoning = !modelId.includes("chat") && !modelId.includes("turbo");
134
+ const friendlyName = modelId
135
+ .split(/[-_]/)
136
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
137
+ .join(" ");
138
+
139
+ return {
140
+ id: modelId,
141
+ name: `${friendlyName} (OpenCode Zen)`,
142
+ contextWindow: 200000,
143
+ maxTokens: 32000,
144
+ reasoning: isReasoning,
145
+ input: ["text", "image"],
146
+ thinkingLevelMap: isReasoning ? STANDARD_THINKING_LEVELS : undefined,
147
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
148
+ };
149
+ }
150
+
151
+ /**
152
+ * 解析并生成完整的模型列表
153
+ */
154
+ export function resolveModelDefinitions(discoveredIds: string[]): ZenModelDefinition[] {
155
+ const list: ZenModelDefinition[] = [];
156
+ const seen = new Set<string>();
157
+
158
+ for (const id of discoveredIds) {
159
+ if (seen.has(id)) continue;
160
+ seen.add(id);
161
+
162
+ if (KNOWN_ZEN_FREE_MODELS[id]) {
163
+ list.push(KNOWN_ZEN_FREE_MODELS[id]);
164
+ } else {
165
+ list.push(buildFallbackModelDefinition(id));
166
+ }
167
+ }
168
+
169
+ return list;
170
+ }
package/src/session.ts ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * OpenCode Zen 客户端会话 ID 逆向生成器与时间戳解析器
3
+ *
4
+ * 逆向自 OpenCode 官方二进制客户端 Identifier.descending("ses") 算法:
5
+ * 1. 结构:ses_<12位反转时间戳十六进制><14位Base62随机安全字符>,总长 30 字符。
6
+ * 2. 时间戳编码:
7
+ * combined = BigInt(Date.now()) * 4096n + BigInt(counter)
8
+ * inverted = ~combined
9
+ * 取低 48 位按大端序编码为 12 位十六进制。
10
+ * 3. 降序特性(descending):时间越晚,按字典序排序越靠前。
11
+ */
12
+
13
+ import { webcrypto } from "node:crypto";
14
+
15
+ const B62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
16
+
17
+ let lastTimestamp = 0;
18
+ let sequenceCounter = 0;
19
+
20
+ /**
21
+ * 生成符合 OpenCode 官方规约的 Session ID (前缀 ses_)
22
+ * @param timestampMs 毫秒时间戳,默认为 Date.now()
23
+ */
24
+ export function generateZenSessionId(timestampMs: number = Date.now()): string {
25
+ if (timestampMs !== lastTimestamp) {
26
+ lastTimestamp = timestampMs;
27
+ sequenceCounter = 0;
28
+ }
29
+ sequenceCounter++;
30
+
31
+ // 0x1000n = 4096n
32
+ const combined = BigInt(timestampMs) * 0x1000n + BigInt(sequenceCounter);
33
+ const inverted = ~combined;
34
+
35
+ // 6 字节(48 位)大端序十六进制
36
+ let hex = "";
37
+ for (let i = 0; i < 6; i++) {
38
+ const byte = Number((inverted >> BigInt(40 - 8 * i)) & 0xffn);
39
+ hex += byte.toString(16).padStart(2, "0");
40
+ }
41
+
42
+ // 14 字符 Base62 密码学安全随机串
43
+ const randBytes = new Uint8Array(14);
44
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
45
+ crypto.getRandomValues(randBytes);
46
+ } else if (webcrypto && webcrypto.getRandomValues) {
47
+ webcrypto.getRandomValues(randBytes);
48
+ } else {
49
+ for (let i = 0; i < 14; i++) {
50
+ randBytes[i] = Math.floor(Math.random() * 256);
51
+ }
52
+ }
53
+
54
+ let randStr = "";
55
+ for (let i = 0; i < 14; i++) {
56
+ randStr += B62_CHARS[randBytes[i] % 62];
57
+ }
58
+
59
+ return `ses_${hex}${randStr}`;
60
+ }
61
+
62
+ /**
63
+ * 生成符合 OpenCode 官方规约的 Request 请求唯一标识符 (前缀 msg_, 升序 ascending)
64
+ * @param timestampMs 毫秒时间戳,默认为 Date.now()
65
+ */
66
+ export function generateZenRequestId(timestampMs: number = Date.now()): string {
67
+ if (timestampMs !== lastTimestamp) {
68
+ lastTimestamp = timestampMs;
69
+ sequenceCounter = 0;
70
+ }
71
+ sequenceCounter++;
72
+
73
+ const combined = BigInt(timestampMs) * 0x1000n + BigInt(sequenceCounter);
74
+ // 升序直接编码,不取反
75
+ let hex = "";
76
+ for (let i = 0; i < 6; i++) {
77
+ const byte = Number((combined >> BigInt(40 - 8 * i)) & 0xffn);
78
+ hex += byte.toString(16).padStart(2, "0");
79
+ }
80
+
81
+ const randBytes = new Uint8Array(14);
82
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
83
+ crypto.getRandomValues(randBytes);
84
+ } else if (webcrypto && webcrypto.getRandomValues) {
85
+ webcrypto.getRandomValues(randBytes);
86
+ } else {
87
+ for (let i = 0; i < 14; i++) {
88
+ randBytes[i] = Math.floor(Math.random() * 256);
89
+ }
90
+ }
91
+
92
+ let randStr = "";
93
+ for (let i = 0; i < 14; i++) {
94
+ randStr += B62_CHARS[randBytes[i] % 62];
95
+ }
96
+
97
+ return `msg_${hex}${randStr}`;
98
+ }
99
+
100
+ /**
101
+ * 校验字符串是否为标准 OpenCode Zen Session ID
102
+ */
103
+ export function isValidZenSessionId(id: string | null | undefined): boolean {
104
+ if (!id || typeof id !== "string") return false;
105
+ return /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/.test(id);
106
+ }
107
+
108
+ /**
109
+ * 从降序 Session ID 中提取精确创建毫秒时间戳
110
+ * @param sessionId ses_ 开头的 30 位 ID
111
+ * @param referenceTimeMs 参考基准时间(默认当前时间),用于消解 36 位周期(约 2.18 年)
112
+ */
113
+ export function extractZenSessionTimestamp(
114
+ sessionId: string,
115
+ referenceTimeMs: number = Date.now(),
116
+ ): number | null {
117
+ if (!isValidZenSessionId(sessionId)) return null;
118
+
119
+ const hex = sessionId.slice(4, 16);
120
+ const inverted48 = BigInt(`0x${hex}`);
121
+ // inverted48 = 0xffffffffffffn - (combined & 0xffffffffffffn)
122
+ const combined48 = 0xffffffffffffn - inverted48;
123
+ const ts48 = combined48 / 0x1000n; // 36 位时间戳部分
124
+
125
+ const MOD_36 = 1n << 36n;
126
+ const refBig = BigInt(referenceTimeMs);
127
+ const refBase = refBig - (refBig % MOD_36);
128
+ let candidate = refBase + ts48;
129
+
130
+ // 若相差超过半个周期,微调回正确的时间跨度
131
+ if (candidate > refBig + (MOD_36 / 2n)) {
132
+ candidate -= MOD_36;
133
+ } else if (candidate < refBig - (MOD_36 / 2n)) {
134
+ candidate += MOD_36;
135
+ }
136
+
137
+ return Number(candidate);
138
+ }
139
+
140
+ /**
141
+ * 官方 OpenCode Zen 后端对会话生命周期严格限制(超 1 小时即报 403 FreeTierError)
142
+ * 默认设定为 45 分钟,确保在达到 1 小时硬限制前无感平滑轮换
143
+ */
144
+ export const DEFAULT_SESSION_MAX_AGE_MS = 45 * 60 * 1000;
145
+
146
+ /**
147
+ * 判断指定 Session ID 是否已过期
148
+ * @param sessionId 会话 ID
149
+ * @param maxAgeMs 最大允许有效时长(默认 45 分钟)
150
+ * @param nowMs 当前时间戳
151
+ */
152
+ export function isZenSessionExpired(
153
+ sessionId: string | null | undefined,
154
+ maxAgeMs: number = DEFAULT_SESSION_MAX_AGE_MS,
155
+ nowMs: number = Date.now(),
156
+ ): boolean {
157
+ if (!sessionId) return true;
158
+ const ts = extractZenSessionTimestamp(sessionId, nowMs);
159
+ if (ts === null) return true;
160
+ const age = nowMs - ts;
161
+ // 超过最大年限或超出未来 5 分钟(时钟漂移异常)均视为过期
162
+ return age > maxAgeMs || age < -(5 * 60 * 1000);
163
+ }
package/src/sync.ts ADDED
@@ -0,0 +1,375 @@
1
+ /**
2
+ * OpenCode Zen 模型发现、Session 维护与配置同步服务
3
+ *
4
+ * 负责:
5
+ * 1. 验证 OpenCode Zen API Key 并在线获取最新可用免费模型清单;
6
+ * 2. 依据官方 Identifier.descending 规约生成/刷新合规 Session ID;
7
+ * 3. 自动注入伪装请求头 (User-Agent 与 x-opencode-session);
8
+ * 4. 同步更新 ~/.pi/agent/models.json 与 ~/.pi/agent/auth.json;
9
+ * 5. 若已安装 CC-Switch,自动无缝同步更新 ~/.cc-switch/cc-switch.db。
10
+ */
11
+
12
+ import { execFileSync } from "node:child_process";
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { dirname, join } from "node:path";
16
+ import { DatabaseSync } from "node:sqlite";
17
+ import {
18
+ KNOWN_ZEN_FREE_MODELS,
19
+ resolveModelDefinitions,
20
+ ZEN_BASE_URL,
21
+ ZEN_PROVIDER_ID,
22
+ ZEN_USER_AGENT,
23
+ } from "./models-registry.js";
24
+ import {
25
+ DEFAULT_SESSION_MAX_AGE_MS,
26
+ extractZenSessionTimestamp,
27
+ generateZenSessionId,
28
+ isZenSessionExpired,
29
+ isValidZenSessionId,
30
+ } from "./session.js";
31
+ import type { ZenStatusInfo, ZenSyncOptions, ZenSyncResult } from "./types.js";
32
+
33
+ export function defaultModelsPath(): string {
34
+ return join(homedir(), ".pi", "agent", "models.json");
35
+ }
36
+
37
+ export function defaultAuthPath(): string {
38
+ return join(homedir(), ".pi", "agent", "auth.json");
39
+ }
40
+
41
+ export function defaultCcSwitchDbPath(): string {
42
+ return join(homedir(), ".cc-switch", "cc-switch.db");
43
+ }
44
+
45
+ /**
46
+ * 校验 API Key 并拉取 OpenCode Zen 在线模型清单
47
+ */
48
+ export async function fetchZenFreeModels(apiKey: string): Promise<string[]> {
49
+ const res = await fetch(`${ZEN_BASE_URL}/models`, {
50
+ method: "GET",
51
+ headers: {
52
+ Authorization: `Bearer ${apiKey.trim()}`,
53
+ "User-Agent": ZEN_USER_AGENT,
54
+ },
55
+ });
56
+
57
+ if (!res.ok) {
58
+ const text = await res.text().catch(() => "");
59
+ throw new Error(`OpenCode Zen API 验证失败 (HTTP ${res.status}): ${text || res.statusText}`);
60
+ }
61
+
62
+ const data = (await res.json()) as { data?: Array<{ id: string }> };
63
+ if (!data || !Array.isArray(data.data)) {
64
+ return Object.keys(KNOWN_ZEN_FREE_MODELS);
65
+ }
66
+
67
+ const freeIds = data.data
68
+ .map((item) => item.id)
69
+ .filter((id) => id.includes("free") || id === "big-pickle" || id.includes("pickle"));
70
+
71
+ return freeIds.length > 0 ? freeIds : Object.keys(KNOWN_ZEN_FREE_MODELS);
72
+ }
73
+
74
+ /**
75
+ * 读取当前系统中已配置的 OpenCode Zen API Key
76
+ */
77
+ export function getStoredZenApiKey(authPath = defaultAuthPath(), modelsPath = defaultModelsPath()): string | null {
78
+ try {
79
+ if (existsSync(authPath)) {
80
+ const auth = JSON.parse(readFileSync(authPath, "utf8")) as Record<string, { key?: string }>;
81
+ if (auth[ZEN_PROVIDER_ID]?.key) {
82
+ return auth[ZEN_PROVIDER_ID].key;
83
+ }
84
+ }
85
+ } catch {
86
+ // 忽略读取错误
87
+ }
88
+
89
+ try {
90
+ if (existsSync(modelsPath)) {
91
+ const models = JSON.parse(readFileSync(modelsPath, "utf8")) as {
92
+ providers?: Record<string, { apiKey?: string }>;
93
+ };
94
+ if (models.providers?.[ZEN_PROVIDER_ID]?.apiKey) {
95
+ return models.providers[ZEN_PROVIDER_ID].apiKey;
96
+ }
97
+ }
98
+ } catch {
99
+ // 忽略读取错误
100
+ }
101
+
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * 读取当前已配置的 Session ID
107
+ */
108
+ export function getStoredZenSessionId(modelsPath = defaultModelsPath()): string | null {
109
+ try {
110
+ if (existsSync(modelsPath)) {
111
+ const models = JSON.parse(readFileSync(modelsPath, "utf8")) as {
112
+ providers?: Record<string, { headers?: { "x-opencode-session"?: string } }>;
113
+ };
114
+ const ses = models.providers?.[ZEN_PROVIDER_ID]?.headers?.["x-opencode-session"];
115
+ if (isValidZenSessionId(ses)) return ses;
116
+ }
117
+ } catch {
118
+ // 忽略错误
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * 获取 OpenCode Zen 的运行状态摘要
125
+ */
126
+ export function getZenStatusInfo(
127
+ modelsPath = defaultModelsPath(),
128
+ authPath = defaultAuthPath(),
129
+ ): ZenStatusInfo {
130
+ const key = getStoredZenApiKey(authPath, modelsPath);
131
+ const sessionId = getStoredZenSessionId(modelsPath) ?? "";
132
+ const ts = sessionId ? extractZenSessionTimestamp(sessionId) : null;
133
+ const now = Date.now();
134
+ const ageMinutes = ts ? Math.max(0, Math.round((now - ts) / 60000)) : 999999;
135
+ const expired = isZenSessionExpired(sessionId, DEFAULT_SESSION_MAX_AGE_MS, now);
136
+
137
+ let modelIds: string[] = [];
138
+ try {
139
+ if (existsSync(modelsPath)) {
140
+ const models = JSON.parse(readFileSync(modelsPath, "utf8")) as {
141
+ providers?: Record<string, { models?: Array<{ id: string }> }>;
142
+ };
143
+ const list = models.providers?.[ZEN_PROVIDER_ID]?.models;
144
+ if (Array.isArray(list)) {
145
+ modelIds = list.map((m) => m.id);
146
+ }
147
+ }
148
+ } catch {
149
+ // ignore
150
+ }
151
+
152
+ let maskedKey = "未配置";
153
+ if (key) {
154
+ maskedKey = key.length > 12 ? `${key.slice(0, 8)}...${key.slice(-4)}` : "******";
155
+ }
156
+
157
+ return {
158
+ hasKey: Boolean(key),
159
+ maskedKey,
160
+ sessionId,
161
+ sessionAgeMinutes: ageMinutes,
162
+ sessionExpired: expired,
163
+ modelsCount: modelIds.length,
164
+ modelIds,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * 执行 OpenCode Zen 全量同步(Key 校验、模型拉取、Session 生成、文件落盘、CC-Switch 数据库同步)
170
+ */
171
+ export async function syncZenConfiguration(options: ZenSyncOptions = {}): Promise<ZenSyncResult> {
172
+ const modelsPath = options.modelsPath ?? defaultModelsPath();
173
+ const authPath = options.authPath ?? defaultAuthPath();
174
+ const dbPath = options.dbPath ?? defaultCcSwitchDbPath();
175
+
176
+ const resolvedApiKey = options.apiKey?.trim() || getStoredZenApiKey(authPath, modelsPath);
177
+ if (!resolvedApiKey) {
178
+ throw new Error("未检测到 OpenCode Zen API Key。请指定 Key,例如:/zen oc_sk_xxx");
179
+ }
180
+
181
+ // 1. 在线校验 API Key 并拉取最新免费模型
182
+ const fetcher = options.fetchModels ?? fetchZenFreeModels;
183
+ let freeModelIds: string[] = [];
184
+ try {
185
+ freeModelIds = await fetcher(resolvedApiKey);
186
+ } catch (error) {
187
+ // 若因网络波动失败但已有已知列表,允许回退
188
+ if (!options.apiKey || options.fetchModels) {
189
+ freeModelIds = Object.keys(KNOWN_ZEN_FREE_MODELS);
190
+ } else {
191
+ throw error;
192
+ }
193
+ }
194
+
195
+ const resolvedModels = resolveModelDefinitions(freeModelIds);
196
+
197
+ // 2. 确定 Session ID:若显式强制更新或当前已过期,则生成全新合规 Session
198
+ const currentSession = getStoredZenSessionId(modelsPath);
199
+ let targetSession = currentSession;
200
+ let isNewSession = false;
201
+
202
+ if (options.forceSession || !currentSession || isZenSessionExpired(currentSession, DEFAULT_SESSION_MAX_AGE_MS)) {
203
+ targetSession = generateZenSessionId();
204
+ isNewSession = true;
205
+ }
206
+
207
+ // 3. 更新 ~/.pi/agent/models.json
208
+ let modelsDoc: { providers?: Record<string, unknown> } = {};
209
+ try {
210
+ if (existsSync(modelsPath)) {
211
+ modelsDoc = JSON.parse(readFileSync(modelsPath, "utf8")) as { providers?: Record<string, unknown> };
212
+ }
213
+ } catch {
214
+ modelsDoc = {};
215
+ }
216
+ if (!modelsDoc.providers || typeof modelsDoc.providers !== "object") {
217
+ modelsDoc.providers = {};
218
+ }
219
+
220
+ modelsDoc.providers[ZEN_PROVIDER_ID] = {
221
+ baseUrl: ZEN_BASE_URL,
222
+ api: "openai-completions",
223
+ apiKey: resolvedApiKey,
224
+ headers: {
225
+ "User-Agent": ZEN_USER_AGENT,
226
+ "x-opencode-client": "cli",
227
+ "x-opencode-session": targetSession,
228
+ "x-session-affinity": targetSession,
229
+ "X-Session-Id": targetSession,
230
+ },
231
+ compat: {
232
+ maxTokensField: "max_tokens",
233
+ requiresReasoningContentOnAssistantMessages: true,
234
+ supportsDeveloperRole: false,
235
+ supportsStore: false,
236
+ },
237
+ models: resolvedModels,
238
+ };
239
+
240
+ mkdirSync(dirname(modelsPath), { recursive: true });
241
+ writeFileSync(modelsPath, `${JSON.stringify(modelsDoc, null, 2)}\n`, "utf8");
242
+
243
+ // 4. 更新 ~/.pi/agent/auth.json
244
+ let authDoc: Record<string, unknown> = {};
245
+ try {
246
+ if (existsSync(authPath)) {
247
+ authDoc = JSON.parse(readFileSync(authPath, "utf8")) as Record<string, unknown>;
248
+ }
249
+ } catch {
250
+ authDoc = {};
251
+ }
252
+ authDoc[ZEN_PROVIDER_ID] = {
253
+ type: "api_key",
254
+ key: resolvedApiKey,
255
+ };
256
+ mkdirSync(dirname(authPath), { recursive: true });
257
+ writeFileSync(authPath, `${JSON.stringify(authDoc, null, 2)}\n`, "utf8");
258
+
259
+ // 5. 同步 CC-Switch 数据库
260
+ let ccSwitchUpdated = false;
261
+ try {
262
+ ccSwitchUpdated = updateCcSwitchDb(dbPath, resolvedApiKey, targetSession, resolvedModels);
263
+ } catch {
264
+ ccSwitchUpdated = false;
265
+ }
266
+
267
+ return {
268
+ apiKey: resolvedApiKey,
269
+ sessionId: targetSession,
270
+ isNewSession,
271
+ modelsCount: resolvedModels.length,
272
+ models: resolvedModels.map((m) => m.id),
273
+ modelsPath,
274
+ ccSwitchUpdated,
275
+ };
276
+ }
277
+
278
+ /**
279
+ * 同步 CC-Switch 数据库中的 opencode-zen-free 配置
280
+ */
281
+ export function updateCcSwitchDb(
282
+ dbPath: string,
283
+ apiKey: string,
284
+ sessionId: string,
285
+ models: unknown[],
286
+ ): boolean {
287
+ if (!existsSync(dbPath)) return false;
288
+
289
+ // 优先使用 Node 22+ 内置的 node:sqlite
290
+ try {
291
+ const db = new DatabaseSync(dbPath);
292
+ const selectStmt = db.prepare("SELECT app_type, settings_config FROM providers WHERE id = ?");
293
+ const rows = selectStmt.all(ZEN_PROVIDER_ID) as Array<{ app_type: string; settings_config: string }>;
294
+
295
+ if (rows.length > 0) {
296
+ const updateStmt = db.prepare(
297
+ "UPDATE providers SET settings_config = ? WHERE id = ? AND app_type = ?",
298
+ );
299
+
300
+ for (const row of rows) {
301
+ try {
302
+ const cfg = JSON.parse(row.settings_config) as Record<string, unknown>;
303
+ if (row.app_type === "pi") {
304
+ cfg.apiKey = apiKey;
305
+ const headers = (cfg.headers ?? {}) as Record<string, string>;
306
+ headers["User-Agent"] = ZEN_USER_AGENT;
307
+ headers["x-opencode-client"] = "cli";
308
+ headers["x-opencode-session"] = sessionId;
309
+ headers["x-session-affinity"] = sessionId;
310
+ headers["X-Session-Id"] = sessionId;
311
+ cfg.headers = headers;
312
+ cfg.models = models;
313
+ } else if (row.app_type === "opencode") {
314
+ const opts = (cfg.options ?? {}) as Record<string, unknown>;
315
+ opts.apiKey = apiKey;
316
+ const headers = (opts.headers ?? {}) as Record<string, string>;
317
+ headers["User-Agent"] = ZEN_USER_AGENT;
318
+ headers["x-opencode-client"] = "cli";
319
+ headers["x-opencode-session"] = sessionId;
320
+ headers["x-session-affinity"] = sessionId;
321
+ headers["X-Session-Id"] = sessionId;
322
+ opts.headers = headers;
323
+ cfg.options = opts;
324
+ }
325
+ updateStmt.run(JSON.stringify(cfg), ZEN_PROVIDER_ID, row.app_type);
326
+ } catch {
327
+ // 忽略单行解析失败
328
+ }
329
+ }
330
+ db.close();
331
+ return true;
332
+ }
333
+ db.close();
334
+ } catch {
335
+ // 若 node:sqlite 不可用,尝试调用 Python3 脚本无痛回退
336
+ try {
337
+ const script = `
338
+ import sqlite3, json, sys
339
+ conn = sqlite3.connect('${dbPath}')
340
+ c = conn.cursor()
341
+ c.execute("SELECT app_type, settings_config FROM providers WHERE id = '${ZEN_PROVIDER_ID}'")
342
+ rows = c.fetchall()
343
+ if rows:
344
+ for app, cfg_str in rows:
345
+ try:
346
+ cfg = json.loads(cfg_str)
347
+ if app == 'pi':
348
+ cfg['apiKey'] = '${apiKey}'
349
+ headers = cfg.get('headers', {})
350
+ headers['User-Agent'] = '${ZEN_USER_AGENT}'
351
+ headers['x-opencode-session'] = '${sessionId}'
352
+ cfg['headers'] = headers
353
+ elif app == 'opencode':
354
+ opts = cfg.get('options', {})
355
+ opts['apiKey'] = '${apiKey}'
356
+ headers = opts.get('headers', {})
357
+ headers['User-Agent'] = '${ZEN_USER_AGENT}'
358
+ headers['x-opencode-session'] = '${sessionId}'
359
+ opts['headers'] = headers
360
+ cfg['options'] = opts
361
+ c.execute("UPDATE providers SET settings_config = ? WHERE id = ? AND app_type = ?", (json.dumps(cfg), '${ZEN_PROVIDER_ID}', app))
362
+ except Exception:
363
+ pass
364
+ conn.commit()
365
+ conn.close()
366
+ `;
367
+ execFileSync("python3", ["-c", script], { timeout: 3000 });
368
+ return true;
369
+ } catch {
370
+ return false;
371
+ }
372
+ }
373
+
374
+ return false;
375
+ }
package/src/types.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * OpenCode Zen 类型定义
3
+ */
4
+
5
+ export interface ZenModelDefinition {
6
+ id: string;
7
+ name: string;
8
+ contextWindow: number;
9
+ maxTokens: number;
10
+ reasoning: boolean;
11
+ input: ("text" | "image")[];
12
+ thinkingLevelMap?: Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max", string | null>>;
13
+ cost: {
14
+ input: number;
15
+ output: number;
16
+ cacheRead: number;
17
+ cacheWrite: number;
18
+ };
19
+ }
20
+
21
+ export interface ZenProviderConfig {
22
+ baseUrl: string;
23
+ api: string;
24
+ apiKey: string;
25
+ headers: {
26
+ "User-Agent": string;
27
+ "x-opencode-session": string;
28
+ [key: string]: string;
29
+ };
30
+ compat: {
31
+ maxTokensField: string;
32
+ requiresReasoningContentOnAssistantMessages: boolean;
33
+ supportsDeveloperRole: boolean;
34
+ supportsStore: boolean;
35
+ [key: string]: unknown;
36
+ };
37
+ models: ZenModelDefinition[];
38
+ }
39
+
40
+ export interface ZenSyncOptions {
41
+ apiKey?: string;
42
+ forceSession?: boolean;
43
+ modelsPath?: string;
44
+ authPath?: string;
45
+ dbPath?: string;
46
+ fetchModels?: (apiKey: string) => Promise<string[]>;
47
+ }
48
+
49
+ export interface ZenSyncResult {
50
+ apiKey: string;
51
+ sessionId: string;
52
+ isNewSession: boolean;
53
+ modelsCount: number;
54
+ models: string[];
55
+ modelsPath: string;
56
+ ccSwitchUpdated: boolean;
57
+ }
58
+
59
+ export interface ZenStatusInfo {
60
+ hasKey: boolean;
61
+ maskedKey: string;
62
+ sessionId: string;
63
+ sessionAgeMinutes: number;
64
+ sessionExpired: boolean;
65
+ modelsCount: number;
66
+ modelIds: string[];
67
+ }