diff --git a/.gitignore b/.gitignore
index 57f2539..1cecff7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -92,3 +92,4 @@ decompiled
decompiled-ui
# opencode project-level config (auto-generated tool overrides)
.opencode/tools/
+.opencode/opencode.json
\ No newline at end of file
diff --git a/.opencode/opencode.example.json b/.opencode/opencode.example.json
index baf4b85..49cab35 100644
--- a/.opencode/opencode.example.json
+++ b/.opencode/opencode.example.json
@@ -1,74 +1,15 @@
{
"$schema": "https://opencode.ai/config.json",
- "model": "mimo/mimo-v2.5-pro",
+ "model": "cloudbase/glm-5",
"provider": {
- "mimo": {
- "npm": "@ai-sdk/openai-compatible",
- "name": "MiMo",
+ "cloudbase": {
"options": {
- "baseURL": "{env:OPENAI_API_ENDPOINT}",
- "apiKey": "{env:MIMO_API_KEY}"
+ "baseURL": "https://env-xxxxxxxx.api.tcloudbasegateway.com/v1/ai/cloudbase",
+ "apiKey": "{env:CLOUDBASE_API_KEY}"
},
"models": {
- "mimo-v2.5-pro": {
- "name": "MiMo V2.5 Pro",
- "tool_call": true,
- "limit": {
- "context": 1048576,
- "output": 131072
- },
- "modalities": {
- "input": ["text", "image"],
- "output": ["text"]
- }
- },
- "mimo-v2.5": {
- "name": "MiMo V2.5",
- "tool_call": true,
- "limit": {
- "context": 1048576,
- "output": 131072
- },
- "modalities": {
- "input": ["text"],
- "output": ["text"]
- }
- },
- "mimo-v2.5-tts": {
- "name": "MiMo V2.5 TTS",
- "tool_call": false,
- "limit": {
- "context": 8192,
- "output": 4096
- },
- "modalities": {
- "input": ["text"],
- "output": ["audio"]
- }
- },
- "mimo-v2.5-tts-voiceclone": {
- "name": "MiMo V2.5 TTS VoiceClone",
- "tool_call": false,
- "limit": {
- "context": 8192,
- "output": 4096
- },
- "modalities": {
- "input": ["text"],
- "output": ["audio"]
- }
- },
- "mimo-v2.5-tts-voicedesign": {
- "name": "MiMo V2.5 TTS VoiceDesign",
- "tool_call": false,
- "limit": {
- "context": 8192,
- "output": 4096
- },
- "modalities": {
- "input": ["text"],
- "output": ["audio"]
- }
+ "glm-5": {
+ "name": "glm-5"
}
}
}
diff --git a/.opencode/opencode.json b/.opencode/opencode.json
deleted file mode 100644
index be4ad4a..0000000
--- a/.opencode/opencode.json
+++ /dev/null
@@ -1,76 +0,0 @@
-{
- "$schema": "https://opencode.ai/config.json",
- "model": "mimo/mimo-v2.5-pro",
- "provider": {
- "mimo": {
- "npm": "@ai-sdk/openai-compatible",
- "name": "MiMo",
- "options": {
- "baseURL": "{env:OPENAI_API_ENDPOINT}",
- "apiKey": "{env:OPENAI_API_KEY}"
- },
- "models": {
- "mimo-v2.5-pro": {
- "name": "MiMo V2.5 Pro",
- "tool_call": true,
- "limit": {
- "context": 1048576,
- "output": 131072
- },
- "modalities": {
- "input": ["text", "image"],
- "output": ["text"]
- }
- },
- "mimo-v2.5": {
- "name": "MiMo V2.5",
- "tool_call": true,
- "limit": {
- "context": 1048576,
- "output": 131072
- },
- "modalities": {
- "input": ["text"],
- "output": ["text"]
- }
- },
- "mimo-v2.5-tts": {
- "name": "MiMo V2.5 TTS",
- "tool_call": false,
- "limit": {
- "context": 8192,
- "output": 4096
- },
- "modalities": {
- "input": ["text"],
- "output": ["audio"]
- }
- },
- "mimo-v2.5-tts-voiceclone": {
- "name": "MiMo V2.5 TTS VoiceClone",
- "tool_call": false,
- "limit": {
- "context": 8192,
- "output": 4096
- },
- "modalities": {
- "input": ["text"],
- "output": ["audio"]
- }
- },
- "mimo-v2.5-tts-voicedesign": {
- "name": "MiMo V2.5 TTS VoiceDesign",
- "tool_call": false,
- "limit": {
- "context": 8192,
- "output": 4096
- },
- "modalities": {
- "input": ["text"],
- "output": ["audio"]
- }
- }
- }
- }
- }
-}
diff --git a/.opencode/tools/AskUserQuestion.ts b/.opencode/tools/AskUserQuestion.ts
index 5aab24d..041c423 100644
--- a/.opencode/tools/AskUserQuestion.ts
+++ b/.opencode/tools/AskUserQuestion.ts
@@ -111,7 +111,9 @@ export default {
.join('; ')
return {
- output: `User answered: ${formatted}. You can continue with these answers in mind.`,
+ output: formatted,
+ hint: `You can continue with these answers in mind.`,
+ // output: `User answered: ${formatted}. You can continue with these answers in mind.`,
metadata: {
answers: data.answers,
},
diff --git a/Dockerfile b/Dockerfile
index f069049..1a7e769 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -57,6 +57,12 @@ COPY --from=build /app/packages/shared/dist ./packages/shared/dist
# skill-loader-override reads CODEBUDDY_BUNDLED_SKILLS_DIR = packages/server/skills
COPY --from=build /app/.agents/skills/cloudbase ./packages/server/skills/cloudbase
+# Copy opencode tool overrides (checked-in, not built into dist).
+# opencode-installer.ts:getOpencodeConfigDir() → resolveProjectRoot()/.opencode/,
+# and resolveProjectRoot() returns /app (ancestor containing packages/server/), so
+# tools must live at /app/.opencode/tools/*.ts in the runtime image.
+COPY --from=build /app/.opencode ./.opencode
+
# Point shared exports to built dist (source .ts files not available at runtime)
RUN sed -i 's|./src/index.ts|./dist/index.js|g' packages/shared/package.json
diff --git a/README.md b/README.md
index 30de018..07ed177 100644
--- a/README.md
+++ b/README.md
@@ -220,6 +220,96 @@ TCR_REPO_NAME=sandbox
TCR_TAG=latest
```
+## OpenCode 模型配置
+
+项目内置 OpenCode ACP runtime。如果前端需要使用 OpenCode agent,需要先配置至少一个
+provider(model 提供商)。
+
+### 前置:安装 opencode CLI
+
+```bash
+npm i -g opencode-ai
+# 验证
+opencode --version
+```
+
+### 一键配置
+
+```bash
+pnpm opencode:setup
+```
+
+该命令会:
+
+1. 从 [models.dev](https://models.dev)(opencode 官方 provider catalog)拉取 118+ provider 列表
+2. 检测 `.opencode/opencode.json` 已有 provider 的凭证状态,提示补齐缺失的 env
+3. 引导选择要新增的 provider(deepseek / moonshot / openai / anthropic / zhipuai / …)
+4. 提示输入对应的 API Key(如 `DEEPSEEK_API_KEY`)
+5. 选择默认模型
+6. 从 catalog 取完整 provider 配置(npm / baseURL / models 等)写入 `.opencode/opencode.json`
+7. 把 API Key 写入 `packages/server/.env`
+
+### 生成结果示例
+
+```jsonc
+// .opencode/opencode.json(自动生成,字段从 models.dev 获取)
+{
+ "$schema": "https://opencode.ai/config.json",
+ "model": "deepseek/deepseek-chat",
+ "provider": {
+ "deepseek": {
+ "npm": "@ai-sdk/openai-compatible",
+ "name": "DeepSeek",
+ "options": {
+ "baseURL": "https://api.deepseek.com",
+ "apiKey": "{env:DEEPSEEK_API_KEY}"
+ },
+ "models": {
+ "deepseek-chat": {
+ "name": "DeepSeek Chat",
+ "tool_call": true,
+ "limit": { "context": 1000000, "output": 384000 },
+ "modalities": { "input": ["text"], "output": ["text"] }
+ }
+ // ... 其他模型
+ }
+ }
+ }
+}
+```
+
+```bash
+# packages/server/.env 会追加 API Key
+DEEPSEEK_API_KEY=sk-***
+```
+
+> **为什么写完整字段而不是空对象?** opencode 子进程启动时也需要这些配置。如果只写 `{}`,
+> 子进程要自己从 models.dev 拉 catalog 才知道 npm / baseURL / models 等信息,一旦拉取失败
+> (网络/超时)就无法正常工作。写入完整字段让配置自包含,不依赖运行时网络请求。
+
+### 高级:自定义 provider / 覆盖字段
+
+如果需要:
+
+- 非 catalog 内置的 provider(如内网 LLM 网关、本地 Ollama)
+- 覆盖 catalog 默认的 `baseURL` / `headers`(如走国内镜像)
+- 用 `whitelist` / `blacklist` 限制要展示的模型
+- 配置 variants(如 Anthropic 的 thinking 预算)
+
+请参考 `.opencode/opencode.example.json` 和 [OpenCode 官方 providers 文档](https://opencode.ai/docs/zh-cn/providers/)
+直接手动编辑 `.opencode/opencode.json`。
+
+> 提示:`opencode.json` 顶部的 `$schema` 字段让 VS Code / Cursor 等编辑器支持字段自动补全
+> 和悬停文档,编辑时按 Ctrl+Space 可查看所有可选字段。
+
+### 重新配置 / 新增 provider
+
+`pnpm opencode:setup` 幂等,可多次运行:
+
+- **已存在的 provider** 不会被覆盖(避免丢失手动调整)
+- **已设置的 env key** 不会被重复询问
+- **缺失 env 的 provider** 会在启动时提示补齐
+
## 常用命令
```bash
@@ -248,6 +338,9 @@ pnpm db:studio # 打开 Drizzle Studio
# TCR
pnpm setup:tcr # 配置容器镜像服务
+
+# OpenCode
+pnpm opencode:setup # 配置 OpenCode provider 和模型
```
## 技术栈
diff --git a/docs/setup.md b/docs/setup.md
index b68034d..585a063 100644
--- a/docs/setup.md
+++ b/docs/setup.md
@@ -252,6 +252,56 @@ pnpm setup:tcr
pnpm rebuild better-sqlite3
```
+## OpenCode Agent 配置(可选)
+
+如果需要在前端使用 OpenCode agent(基于 [opencode-ai](https://github.com/sst/opencode) 的 ACP runtime),需要额外配置 LLM provider。
+
+### 前置:安装 opencode CLI
+
+```bash
+npm i -g opencode-ai
+opencode --version # 验证安装
+```
+
+### 配置 provider
+
+```bash
+pnpm opencode:setup
+```
+
+脚本会自动完成以下操作:
+
+1. 从 [models.dev](https://models.dev) 拉取 provider catalog(118+ 个 provider)
+2. 检测 `.opencode/opencode.json` 中已有 provider 的凭证状态,提示补齐缺失的 env
+3. 引导选择要新增的 provider 并输入 API Key
+4. 从 catalog 取完整配置写入 `.opencode/opencode.json`(含 npm/baseURL/models 等)
+5. 把 API Key 写入 `packages/server/.env`
+
+配置完成后**必须重启 server**(Node.js 的 `--env-file` 只在启动时加载一次)。
+
+### 涉及的文件
+
+| 文件 | 作用 | 是否 gitignore |
+|---|---|---|
+| `.opencode/opencode.json` | provider + model 定义(opencode 子进程 + server 均读取) | 否(应提交) |
+| `packages/server/.env` | API Key 等凭证 | 是 |
+
+### 常见问题
+
+| 问题 | 原因 | 解决 |
+|---|---|---|
+| 前端 OpenCode agent 模型列表为空 | `opencode.json` 未配置 provider 或对应 env 未设置 | 运行 `pnpm opencode:setup` |
+| 前端有模型但选中后 agent 无输出 | opencode.json 中 provider 字段不完整 | 重跑 `pnpm opencode:setup`,或手动补齐 npm/baseURL/models |
+| 出现不应该有的模型(如未配置的 OpenAI) | `.env` 中有通用 env 名(如 `OPENAI_API_KEY`)被 catalog 错误匹配 | 删除或注释 `.env` 中不需要的 key |
+| 配置后前端没变化 | server 未重启 | 重启 `pnpm dev:server` |
+
+### 更多文档
+
+- [OpenCode 配置](https://opencode.ai/docs/zh-cn/config/)
+- [OpenCode 模型](https://opencode.ai/docs/zh-cn/models/)
+- [OpenCode Provider](https://opencode.ai/docs/zh-cn/providers/)
+- [models.dev catalog](https://models.dev)
+
## 手动初始化的推荐顺序
如果不使用交互式脚本,建议按照以下顺序手动处理:
@@ -262,7 +312,8 @@ pnpm rebuild better-sqlite3
4. 配置 CodeBuddy 认证
5. 配置 TCR 镜像
6. 初始化数据库
-7. 运行构建或启动命令验证环境
+7. (可选)配置 OpenCode provider:`pnpm opencode:setup`
+8. 运行构建或启动命令验证环境
## 延伸阅读
diff --git a/package.json b/package.json
index b9ab5e1..7559bfb 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"setup:tcr": "node scripts/setup-tcr.mjs",
+ "opencode:setup": "node scripts/opencode-setup.mjs",
"prepare": "husky"
},
"devDependencies": {
diff --git a/packages/server/package.json b/packages/server/package.json
index dd305ed..05e7c06 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -10,7 +10,7 @@
},
"scripts": {
"dev": "tsup src/sandbox/tool-override.ts --format cjs --outDir dist/sandbox --no-splitting --silent && tsup src/util/skill-loader-override.ts --format cjs --outDir dist/util --no-splitting --silent && DOTENVX_PATH=.env tsx watch --env-file=.env src/index.ts",
- "build": "tsup src/sandbox/tool-override.ts --format cjs --outDir dist/sandbox --no-splitting && tsup src/util/skill-loader-override.ts --format cjs --outDir dist/util --no-splitting && tsup src/index.ts --format esm --target node22 && mkdir -p dist/agent/runtime/opencode-tool-templates && cp src/agent/runtime/opencode-tool-templates/*.ts dist/agent/runtime/opencode-tool-templates/",
+ "build": "tsup src/sandbox/tool-override.ts --format cjs --outDir dist/sandbox --no-splitting && tsup src/util/skill-loader-override.ts --format cjs --outDir dist/util --no-splitting && tsup src/index.ts --format esm --target node22",
"start": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest",
diff --git a/packages/server/src/agent/coding-mode.ts b/packages/server/src/agent/coding-mode.ts
index d726854..e01a212 100644
--- a/packages/server/src/agent/coding-mode.ts
+++ b/packages/server/src/agent/coding-mode.ts
@@ -40,9 +40,11 @@ export function getCodingSystemPrompt(envId: string, publishableKey: string): st
IMPORTANT: 必须先读取 src/utils/cloudbase.ts,将其中的 ENV_ID 和 PUBLISHABLE_KEY 替换为当前环境的真实值。
-IMPORTANT: 直接修改代码而非创建 .env 文件。
+IMPORTANT: 直接修改代码而非创建 .env 文件。有关登录态判断时使用 auth-web-cloudbase skill 使用 supabase-like 的 api。
- ENV_ID:${envId}
- PUBLISHABLE_KEY:${publishableKey}
+IMPORTANT: 注意数据库权限,默认 PUBLISHABLE_KEY 时是匿名身份,刚开始数据库最好公有读写,方便调试,后续完善。
+IMPORTANT: 页面需要做好 error 处理,显示出具体的错误堆栈信息,而非直接 crash。需要 toast 等方式显示而非 console 打印。
diff --git a/packages/server/src/agent/runtime/opencode-acp-runtime.ts b/packages/server/src/agent/runtime/opencode-acp-runtime.ts
index 9a99555..6e99dcb 100644
--- a/packages/server/src/agent/runtime/opencode-acp-runtime.ts
+++ b/packages/server/src/agent/runtime/opencode-acp-runtime.ts
@@ -39,6 +39,7 @@ import { persistenceService } from '../persistence.service.js'
import { CloudbaseAgentService } from '../cloudbase-agent.service.js'
import { getAcpTransportFactory, getResolvedBin, type AcpTransport } from './acp-transport.js'
import { getOpencodeConfigDir } from './opencode-installer.js'
+import { resolveModels } from './opencode-catalog.js'
import { registerPending, resolvePending, rejectPendingForConversation } from './pending-permission-registry.js'
import { resolvePendingQuestion, rejectPendingQuestionsForConversation } from './pending-question-registry.js'
import { OpencodeMessageBuilder, findLastRecordIds, buildHistoryContextPrompt } from './opencode-message-builder.js'
@@ -148,32 +149,18 @@ export class OpencodeAcpRuntime extends BaseAgentRuntime {
}
async getSupportedModels(): Promise {
- // Read model list from .opencode/opencode.json (the authoritative config file)
- try {
- const configPath = path.join(getOpencodeConfigDir(), 'opencode.json')
- const raw = fs.readFileSync(configPath, 'utf-8')
- const config = JSON.parse(raw) as {
- provider?: Record }>
- }
- const models: ModelInfo[] = []
- for (const [providerKey, providerDef] of Object.entries(config.provider ?? {})) {
- const vendorName =
- typeof providerDef.name === 'string' && !providerDef.name.startsWith('{env:')
- ? providerDef.name
- : process.env.OPENCODE_PROVIDER_NAME || providerKey
- for (const [modelKey, modelDef] of Object.entries(providerDef.models ?? {})) {
- models.push({
- id: `${providerKey}/${modelKey}`,
- name: modelDef.name || modelKey,
- vendor: vendorName,
- })
- }
- }
- if (models.length > 0) return models
- } catch {
- // fall through to env-based fallback
- }
- // Fallback: single model from env
+ // 合并 models.dev catalog + .opencode/opencode.json 的 provider override,按 env 过滤。
+ // 行为对齐 opencode 自身:
+ // - provider 写 `{}` 即启用(catalog 已有的 provider 自动获得 name/npm/api/models)
+ // - provider 级 env(如 DEEPSEEK_API_KEY)命中则算可用
+ // - options.apiKey 中的 {env:VAR} 占位符会被解析
+ // - whitelist/blacklist/enabled_providers/disabled_providers 支持
+ const models = await resolveModels({
+ opencodeConfigDir: getOpencodeConfigDir(),
+ env: process.env,
+ })
+ if (models.length > 0) return models
+ // 兜底:env 默认模型(保留旧行为,避免前端空列表)
const defaultModel = DEFAULT_OPENCODE_MODEL
const vendor = process.env.OPENCODE_PROVIDER_NAME || 'Custom'
return [{ id: defaultModel, name: defaultModel.split('/').pop() || defaultModel, vendor }]
@@ -521,6 +508,21 @@ export class OpencodeAcpRuntime extends BaseAgentRuntime {
prompt: [{ type: 'text', text: contextPrompt }],
})
+ // 非正常停止原因:LLM provider 拒绝 (refusal) / 超出 token 上限 (max_tokens) /
+ // 超过最大 turn 请求数 (max_turn_requests) 时,assistant 可能没有产出任何 text,
+ // 只有截断的 reasoning。给用户显式提示,避免"空回复"的困惑。
+ //
+ // 注意:'cancelled' 不在此处提示(abort 流程走 catch 分支处理),'end_turn' 是正常完成。
+ const stopReason = promptRes.stopReason
+ if (stopReason === 'refusal' || stopReason === 'max_tokens' || stopReason === 'max_turn_requests') {
+ const hint = buildStopReasonHint(stopReason)
+ try {
+ await emit({ type: 'text', content: hint })
+ } catch {
+ /* noop */
+ }
+ }
+
await emit({ type: 'agent_phase', phase: 'idle' })
await emit({
type: 'result',
@@ -748,3 +750,42 @@ function makeEmitter(ctx: {
// ─── Singleton ────────────────────────────────────────────────────────────
export const opencodeAcpRuntime = new OpencodeAcpRuntime()
+
+/**
+ * 为非正常 stopReason 生成面向用户的提示文本,塞到 assistant 消息尾部。
+ *
+ * - refusal:LLM provider 内容审查拒绝生成(如腾讯 MiMo 的 high risk 拦截)。
+ * LLM 通常在这种情况下直接中断流,没有产出 text,只剩截断的 reasoning。
+ * - max_tokens:触发输出 token 上限,回复被截断。
+ * - max_turn_requests:LLM 在单轮内调用工具次数过多(通常是死循环)。
+ */
+function buildStopReasonHint(stopReason: 'refusal' | 'max_tokens' | 'max_turn_requests'): string {
+ switch (stopReason) {
+ case 'refusal':
+ return [
+ '',
+ '---',
+ '⚠️ **模型拒绝回复**:当前提问被 LLM provider 的内容安全策略拦截了。',
+ '',
+ '可能的原因:',
+ '- 提问或上下文中包含被模型方风险模型判定为敏感的内容(人名、专有名词、政治/安全相关话题等)。',
+ '- 多轮对话累积的 history 中有 provider 敏感词。',
+ '',
+ '建议:换一种表述方式重试,或清空当前会话重新开始;必要时切换到其他模型。',
+ ].join('\n')
+ case 'max_tokens':
+ return [
+ '',
+ '---',
+ '⚠️ **输出被截断**:回复达到了模型的最大 token 上限,内容可能不完整。你可以回复"继续"让模型接着写。',
+ ].join('\n')
+ case 'max_turn_requests':
+ return [
+ '',
+ '---',
+ '⚠️ **单轮工具调用次数达到上限**:为避免死循环,本轮已停止。',
+ '',
+ '建议:拆分任务,或重新描述目标让模型更聚焦地执行。',
+ ].join('\n')
+ }
+}
diff --git a/packages/server/src/agent/runtime/opencode-catalog.ts b/packages/server/src/agent/runtime/opencode-catalog.ts
new file mode 100644
index 0000000..83d21ad
--- /dev/null
+++ b/packages/server/src/agent/runtime/opencode-catalog.ts
@@ -0,0 +1,340 @@
+/**
+ * OpenCode model catalog 解析
+ *
+ * 用途:
+ * - 拉取 models.dev/api.json(opencode 自身使用的权威 provider/model catalog)
+ * - 合并 .opencode/opencode.json 中的 provider override(与 opencode 官方行为对齐)
+ * - 按 env/凭证可用性过滤,展开为前端消费的 ModelInfo[]
+ *
+ * 关键事实(读 sst/opencode packages/opencode/src/provider/provider.ts):
+ * 1. database = models.dev/api.json 转换结果(含 env/npm/api/name/models 完整元数据)
+ * 2. opencode.json 的 provider 对该 database 做深合并 override
+ * 3. provider 是否"生效" = provider.env 里任一 key 被设 OR options.apiKey 设置 OR auth.json 命中
+ * 4. 最终展开 provider.models 为前端的模型列表
+ *
+ * 本文件实现 (3)(4) 的简化版,不处理 auth.json(仅项目级 .env),不处理 plugin 钩子。
+ *
+ * 缓存策略:进程内内存缓存,首次调用时惰性拉取。失败返 null(降级为只读 opencode.json)。
+ */
+
+import fs from 'node:fs'
+import path from 'node:path'
+import type { ModelInfo } from '@coder/shared'
+
+const MODELS_DEV_CATALOG_URL = process.env.MODELS_DEV_CATALOG_URL || 'https://models.dev/api.json'
+const CATALOG_FETCH_TIMEOUT_MS = Number(process.env.MODELS_DEV_FETCH_TIMEOUT_MS) || 5_000
+
+// ─── Types (对齐 models.dev schema,仅保留我们要消费的字段) ─────────────
+
+export interface ModelsDevModel {
+ id?: string
+ name?: string
+ family?: string
+ release_date?: string
+ attachment?: boolean
+ reasoning?: boolean
+ tool_call?: boolean
+ temperature?: boolean
+ modalities?: {
+ input?: Array<'text' | 'image' | 'audio' | 'video' | 'pdf'>
+ output?: Array<'text' | 'image' | 'audio' | 'video' | 'pdf'>
+ }
+ cost?: {
+ input?: number
+ output?: number
+ cache_read?: number
+ cache_write?: number
+ }
+ limit?: {
+ context?: number
+ input?: number
+ output?: number
+ }
+ status?: 'alpha' | 'beta' | 'deprecated'
+ options?: Record
+ headers?: Record
+ variants?: Record
+ [key: string]: unknown
+}
+
+export interface ModelsDevProvider {
+ id: string
+ name: string
+ npm?: string
+ api?: string | null
+ doc?: string
+ env?: string[]
+ models: Record
+ options?: Record
+ whitelist?: string[]
+ blacklist?: string[]
+ [key: string]: unknown
+}
+
+export type Catalog = Record
+
+// ─── Module-level cache ──────────────────────────────────────────────────
+
+let _catalogPromise: Promise | null = null
+
+/**
+ * 拉取 models.dev catalog(进程内缓存)。
+ *
+ * - 失败返 null(不抛),调用方自行降级
+ * - 可通过 MODELS_DEV_CATALOG_URL env 覆盖地址(用于测试或私有镜像)
+ * - 可通过 reset() 清缓存(测试场景)
+ */
+export function fetchCatalog(): Promise {
+ if (_catalogPromise) return _catalogPromise
+ _catalogPromise = (async () => {
+ try {
+ const controller = new AbortController()
+ const timer = setTimeout(() => controller.abort(), CATALOG_FETCH_TIMEOUT_MS)
+ const res = await fetch(MODELS_DEV_CATALOG_URL, { signal: controller.signal })
+ clearTimeout(timer)
+ if (!res.ok) {
+ console.warn('[opencode-catalog] fetch non-ok status:', res.status)
+ return null
+ }
+ const raw = (await res.json()) as unknown
+ if (!raw || typeof raw !== 'object') return null
+ // 轻量校验:顶层必须是对象,每个 value 至少有 id + models
+ const out: Catalog = {}
+ for (const [id, p] of Object.entries(raw as Record)) {
+ if (!p || typeof p !== 'object') continue
+ const prov = p as Partial
+ if (!prov.models || typeof prov.models !== 'object') continue
+ out[id] = {
+ id,
+ name: prov.name ?? id,
+ npm: prov.npm,
+ api: prov.api ?? null,
+ doc: prov.doc,
+ env: Array.isArray(prov.env) ? prov.env : [],
+ models: prov.models,
+ options: (prov.options as Record) ?? undefined,
+ }
+ }
+ return out
+ } catch (e) {
+ console.warn('[opencode-catalog] fetch failed:', (e as Error).message)
+ return null
+ }
+ })()
+ return _catalogPromise
+}
+
+/** 清模块级缓存(测试用) */
+export function resetCatalogCache(): void {
+ _catalogPromise = null
+}
+
+// ─── Merge & Filter ──────────────────────────────────────────────────────
+
+/**
+ * 深合并(仅处理 plain object,数组和基础类型用 override 覆盖)。
+ * 对齐 opencode 源码的 mergeDeep 语义(足够简化版)。
+ */
+function mergeDeep>(base: T, override: Partial): T {
+ const out: Record = { ...base }
+ for (const [k, v] of Object.entries(override)) {
+ if (v === undefined) continue
+ const existing = out[k]
+ if (
+ v !== null &&
+ typeof v === 'object' &&
+ !Array.isArray(v) &&
+ existing !== null &&
+ typeof existing === 'object' &&
+ !Array.isArray(existing)
+ ) {
+ out[k] = mergeDeep(existing as Record, v as Record)
+ } else {
+ out[k] = v
+ }
+ }
+ return out as T
+}
+
+/**
+ * opencode.json 的 provider 字段合并到 catalog。
+ *
+ * 行为对齐 opencode 源码第一次合并(provider.ts fromCfg 阶段):
+ * - catalog 有该 provider:深合并(option/models 均合并)
+ * - catalog 没有:视为完全自定义 provider,直接写入
+ */
+export function mergeProviders(catalog: Catalog, override: Record): Catalog {
+ const out: Catalog = { ...catalog }
+ for (const [id, cfgRaw] of Object.entries(override ?? {})) {
+ if (!cfgRaw || typeof cfgRaw !== 'object') continue
+ const cfg = cfgRaw as Partial
+ const base: ModelsDevProvider = out[id] ?? {
+ id,
+ name: cfg.name ?? id,
+ npm: cfg.npm,
+ api: cfg.api ?? null,
+ env: [],
+ models: {},
+ }
+ out[id] = mergeDeep(base, cfg as Partial)
+ }
+ return out
+}
+
+/**
+ * 解析 options.apiKey 中的 `{env:VAR}` 占位符,判断是否可用。
+ * 如果是明文字符串(非占位符且非空),视为可用。
+ */
+function isOptionsApiKeyResolved(apiKey: unknown, env: Record): boolean {
+ if (typeof apiKey !== 'string' || !apiKey) return false
+ const m = apiKey.match(/^\{env:([^}]+)\}$/)
+ if (m) return !!env[m[1]]
+ return true
+}
+
+/**
+ * 过滤出"对当前进程可用"的 provider + model:
+ * - provider.env 任一 key 在 env 中非空,或
+ * - provider.options.apiKey 已解析(占位符对应的 env 存在,或明文值)
+ * - 再按 whitelist/blacklist 过滤 model
+ * - 过滤 status=deprecated
+ */
+export function filterAvailable(
+ providers: Catalog,
+ env: Record,
+ opts: { enabledProviders?: string[]; disabledProviders?: string[] } = {},
+): Catalog {
+ const out: Catalog = {}
+ const enabled = opts.enabledProviders ? new Set(opts.enabledProviders) : null
+ const disabled = new Set(opts.disabledProviders ?? [])
+
+ for (const [id, p] of Object.entries(providers)) {
+ if (disabled.has(id)) continue
+ if (enabled && !enabled.has(id)) continue
+
+ const hasEnvKey = (p.env ?? []).some((k) => !!env[k])
+ const optApiKey = (p.options as { apiKey?: unknown } | undefined)?.apiKey
+ const hasOptionsKey = isOptionsApiKeyResolved(optApiKey, env)
+
+ if (!hasEnvKey && !hasOptionsKey) continue
+
+ const wl = p.whitelist && p.whitelist.length > 0 ? new Set(p.whitelist) : null
+ const bl = new Set(p.blacklist ?? [])
+
+ const filteredModels: Record = {}
+ for (const [mid, m] of Object.entries(p.models)) {
+ if (!m || typeof m !== 'object') continue
+ if (m.status === 'deprecated') continue
+ if (wl && !wl.has(mid)) continue
+ if (bl.has(mid)) continue
+ filteredModels[mid] = m
+ }
+ if (Object.keys(filteredModels).length === 0) continue
+
+ out[id] = { ...p, models: filteredModels }
+ }
+ return out
+}
+
+// ─── Flatten ─────────────────────────────────────────────────────────────
+
+/** 把 provider map 展开为前端消费的 ModelInfo[] */
+export function flattenModels(providers: Catalog): ModelInfo[] {
+ const out: ModelInfo[] = []
+ for (const [pid, p] of Object.entries(providers)) {
+ for (const [mid, m] of Object.entries(p.models)) {
+ out.push({
+ id: `${pid}/${mid}`,
+ name: m.name ?? mid,
+ vendor: p.name ?? pid,
+ contextLimit: m.limit?.context,
+ outputLimit: m.limit?.output,
+ inputModalities: m.modalities?.input,
+ outputModalities: m.modalities?.output,
+ toolCall: m.tool_call,
+ reasoning: m.reasoning,
+ attachment: m.attachment,
+ cost:
+ m.cost && typeof m.cost.input === 'number' && typeof m.cost.output === 'number'
+ ? {
+ input: m.cost.input,
+ output: m.cost.output,
+ cacheRead: m.cost.cache_read,
+ cacheWrite: m.cost.cache_write,
+ }
+ : undefined,
+ status: m.status,
+ })
+ }
+ }
+ return out
+}
+
+// ─── High-level helper ───────────────────────────────────────────────────
+
+export interface OpencodeJson {
+ $schema?: string
+ model?: string
+ provider?: Record
+ enabled_providers?: string[]
+ disabled_providers?: string[]
+ [key: string]: unknown
+}
+
+/**
+ * 安全读 .opencode/opencode.json。文件不存在或解析失败返 null(调用方降级)。
+ */
+export function readOpencodeJson(opencodeConfigDir: string): OpencodeJson | null {
+ try {
+ const p = path.join(opencodeConfigDir, 'opencode.json')
+ if (!fs.existsSync(p)) return null
+ const raw = fs.readFileSync(p, 'utf-8')
+ return JSON.parse(raw) as OpencodeJson
+ } catch (e) {
+ console.warn('[opencode-catalog] readOpencodeJson failed:', (e as Error).message)
+ return null
+ }
+}
+
+/**
+ * 主入口:合并 catalog + opencode.json,按 env 过滤,返回 ModelInfo[]。
+ *
+ * 重要:**只有 opencode.json 中显式声明的 provider 才参与输出**。
+ * catalog 仅用于填充元数据(name/env/npm/models 等),不会因为某个 env key
+ * 碰巧存在就自动启用 catalog 中的 provider。
+ *
+ * 这与 opencode CLI 自身行为不同(CLI 会自动启用所有 env 命中的 provider),
+ * 但在本项目场景下,opencode.json 的 provider 字段是"这个项目启用了哪些 provider"
+ * 的白名单,自动启用会导致 env 命名冲突时暴露意外的模型。
+ *
+ * 当 catalog 拉取失败时降级为"只读 opencode.json 中显式定义的自定义 provider",
+ * 以保证最小可用(与改造前行为近似一致)。
+ */
+export async function resolveModels(opts: {
+ opencodeConfigDir: string
+ env: Record
+}): Promise {
+ const config = readOpencodeJson(opts.opencodeConfigDir)
+ const catalog = await fetchCatalog()
+
+ // 降级:catalog 不可用 + config 不可用 → 空列表
+ if (!catalog && !config) return []
+
+ const declaredProviderIds = Object.keys(config?.provider ?? {})
+ if (declaredProviderIds.length === 0 && !config?.enabled_providers) return []
+
+ // 只从 catalog 中取 opencode.json 声明的 provider(白名单过滤)
+ const scopedCatalog: Catalog = {}
+ for (const id of declaredProviderIds) {
+ if (catalog && catalog[id]) {
+ scopedCatalog[id] = catalog[id]
+ }
+ }
+
+ const merged = mergeProviders(scopedCatalog, (config?.provider as Record) ?? {})
+ const available = filterAvailable(merged, opts.env, {
+ enabledProviders: config?.enabled_providers,
+ disabledProviders: config?.disabled_providers,
+ })
+ return flattenModels(available)
+}
diff --git a/packages/server/src/agent/runtime/opencode-installer.ts b/packages/server/src/agent/runtime/opencode-installer.ts
index e3f593a..fa208cb 100644
--- a/packages/server/src/agent/runtime/opencode-installer.ts
+++ b/packages/server/src/agent/runtime/opencode-installer.ts
@@ -1,73 +1,31 @@
/**
- * OpenCode 项目级配置安装器
+ * OpenCode 项目级配置路径解析
*
- * 目的:把 tool override 模板装到项目根目录的 `.opencode/tools/`,这样:
- * - 同一项目的所有 session 共享同一份 tools(session 之间不重复写入)
- * - 与用户的全局 `~/.config/opencode/` 完全隔离,不污染用户配置
- * - spawn 时通过 `OPENCODE_CONFIG_DIR=/.opencode` 让 opencode 只读项目目录
- * - 模板自身通过 `process.env.SANDBOX_*` 读取 per-session 凭证
- * - opencode 看到 custom tool `read` / `write` / ...,**覆盖** builtin 同名工具
+ * 背景:opencode 用 `OPENCODE_CONFIG_DIR` env 指向项目级配置目录,里面含:
+ * - `opencode.json`(model / provider / tools 声明)
+ * - `tools/*.ts`(checked-in 的 tool override 源文件,覆盖 builtin 同名工具)
*
- * 重要实现细节:
- * - 直接安装 `.ts` 源文件(opencode binary 原生支持 TS loader)
- * - tsup 打包后的 `.js` 含 `import { z } from "zod"`,opencode 当前版本不能解析这个 import,
- * 会静默忽略文件(tool 不被注册)。所以**必须用 .ts**。
- * - 幂等:同 hash 跳过;OPENCODE_TOOLS_FORCE_REINSTALL=1 强制覆盖。
+ * 我们的项目把 `.opencode/` 直接签入仓库(.opencode/tools/read.ts 等),
+ * 生产镜像 Dockerfile Stage 2 会把 `.opencode/` 拷贝到 `/app/.opencode/`。
+ * 本模块负责把 `__dirname` 映射回项目根,从而定位到 `.opencode/` 目录。
+ *
+ * 历史:曾有一个 `ensureOpencodeToolsInstalled()` 从 `src/agent/runtime/opencode-tool-templates/`
+ * 拷贝模板到 `.opencode/tools/`,但 38114f1 后 tools 直接签入仓库,安装步骤已废弃。
*/
import fs from 'node:fs'
import path from 'node:path'
-import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
-const TOOL_NAMES = ['read', 'write', 'edit', 'bash', 'grep', 'glob', 'AskUserQuestion'] as const
-export type ToolName = (typeof TOOL_NAMES)[number]
-
-/**
- * 找到 TS 模板源文件目录。
- * - 源码运行(tsx dev):__dirname = .../src/agent/runtime/
- * templates at .../src/agent/runtime/opencode-tool-templates/
- * - dist 运行:__dirname = .../dist/
- * 需要回溯到 src:.../dist/../src/agent/runtime/opencode-tool-templates/
- * 但 src 可能不在生产机器上,所以优先级:
- * 1. 环境变量 OPENCODE_TOOL_TEMPLATE_DIR
- * 2. __dirname 附近
- * 3. 回溯到 src(只在开发时可用)
- */
-function resolveTemplateDir(): string {
- const fromEnv = process.env.OPENCODE_TOOL_TEMPLATE_DIR
- if (fromEnv && fs.existsSync(fromEnv)) return fromEnv
-
- const candidates = [
- // 源码运行
- path.resolve(__dirname, 'opencode-tool-templates'),
- // dist 运行,指向 server 包内 src
- path.resolve(__dirname, '../../src/agent/runtime/opencode-tool-templates'),
- path.resolve(__dirname, '../src/agent/runtime/opencode-tool-templates'),
- // 兜底(workspace root 下的 server)
- path.resolve(process.cwd(), 'packages/server/src/agent/runtime/opencode-tool-templates'),
- ]
- for (const c of candidates) {
- if (fs.existsSync(c)) {
- // 再确认里面真的有 read.ts
- if (fs.existsSync(path.join(c, 'read.ts'))) return c
- }
- }
- throw new Error(
- `Cannot locate opencode tool templates. Tried: ${candidates.join(', ')}. Set OPENCODE_TOOL_TEMPLATE_DIR to override.`,
- )
-}
-
/**
* 解析项目根目录。
* 优先级:
* 1. OPENCODE_PROJECT_ROOT env(测试 / 自定义部署时覆盖)
- * 2. 从 __dirname 向上查找 package.json 中 name === 'coding-agent-template' 的目录
- * 3. 从 __dirname 向上找包含 packages/server 的 monorepo 根
- * 4. process.cwd() 兜底
+ * 2. 从 __dirname 向上找包含 packages/server 的 monorepo 根
+ * 3. process.cwd() 兜底
*/
export function resolveProjectRoot(): string {
const fromEnv = process.env.OPENCODE_PROJECT_ROOT
@@ -85,16 +43,6 @@ export function resolveProjectRoot(): string {
return process.cwd()
}
-/**
- * 安装目录:项目根 `.opencode/tools/`
- * 可通过 OPENCODE_TOOLS_INSTALL_DIR 完全覆盖(保留旧的逃生舱)。
- */
-function getInstallDir(): string {
- const override = process.env.OPENCODE_TOOLS_INSTALL_DIR
- if (override) return override
- return path.join(resolveProjectRoot(), '.opencode', 'tools')
-}
-
/**
* 返回项目级 `.opencode/` 目录路径(供 spawn 时注入 OPENCODE_CONFIG_DIR)。
* 可通过 OPENCODE_CONFIG_DIR env 覆盖(若用户自行设置则尊重用户)。
@@ -102,61 +50,3 @@ function getInstallDir(): string {
export function getOpencodeConfigDir(): string {
return process.env.OPENCODE_CONFIG_DIR ?? path.join(resolveProjectRoot(), '.opencode')
}
-
-/** 读文件返回 sha256 hex */
-function hashFile(p: string): string {
- const buf = fs.readFileSync(p)
- return crypto.createHash('sha256').update(buf).digest('hex')
-}
-
-interface InstallResult {
- installDir: string
- installed: ToolName[]
- skipped: ToolName[]
- forced: boolean
-}
-
-/**
- * 幂等安装所有 tool override 模板(.ts 源文件)到全局目录。
- * `OPENCODE_TOOLS_FORCE_REINSTALL=1` → 强制覆盖。
- */
-export async function ensureOpencodeToolsInstalled(): Promise {
- const templateDir = resolveTemplateDir()
- const installDir = getInstallDir()
- const force = process.env.OPENCODE_TOOLS_FORCE_REINSTALL === '1'
-
- fs.mkdirSync(installDir, { recursive: true })
-
- const installed: ToolName[] = []
- const skipped: ToolName[] = []
-
- for (const name of TOOL_NAMES) {
- const src = path.join(templateDir, `${name}.ts`)
- const dst = path.join(installDir, `${name}.ts`)
-
- if (!fs.existsSync(src)) {
- throw new Error(`Tool template not found: ${src}`)
- }
-
- if (fs.existsSync(dst) && !force && hashFile(src) === hashFile(dst)) {
- skipped.push(name)
- continue
- }
- fs.copyFileSync(src, dst)
- installed.push(name)
- }
-
- // 清理可能残留的 .js(旧的错误安装形式)
- for (const name of TOOL_NAMES) {
- const stale = path.join(installDir, `${name}.js`)
- if (fs.existsSync(stale)) {
- try {
- fs.unlinkSync(stale)
- } catch {
- /* noop */
- }
- }
- }
-
- return { installDir, installed, skipped, forced: force }
-}
diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json
index 18af8b3..fc3ad32 100644
--- a/packages/server/tsconfig.json
+++ b/packages/server/tsconfig.json
@@ -12,8 +12,5 @@
}
},
"include": ["src"],
- "exclude": [
- "src/agent/runtime/opencode-tool-templates/**",
- "src/**/__tests__/**"
- ]
+ "exclude": ["src/**/__tests__/**"]
}
diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts
index 1c33856..4e316c3 100644
--- a/packages/shared/src/types/agent.ts
+++ b/packages/shared/src/types/agent.ts
@@ -98,6 +98,19 @@ export interface ModelInfo {
supportsReasoning?: boolean
supportsToolCall?: boolean
tags?: string[]
+ /**
+ * OpenCode/models.dev 对齐的可选富字段。来自 models.dev catalog 合并结果,
+ * 前端可用于展示上下文长度、能力标记、成本等。CodeBuddy runtime 暂不填充。
+ */
+ contextLimit?: number
+ outputLimit?: number
+ inputModalities?: Array<'text' | 'image' | 'audio' | 'video' | 'pdf'>
+ outputModalities?: Array<'text' | 'image' | 'audio' | 'video' | 'pdf'>
+ toolCall?: boolean
+ reasoning?: boolean
+ attachment?: boolean
+ cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number }
+ status?: 'alpha' | 'beta' | 'deprecated'
[key: string]: unknown
}
diff --git a/scripts/opencode-setup.mjs b/scripts/opencode-setup.mjs
new file mode 100644
index 0000000..81868d0
--- /dev/null
+++ b/scripts/opencode-setup.mjs
@@ -0,0 +1,816 @@
+#!/usr/bin/env node
+
+/**
+ * OpenCode Provider 配置引导脚本
+ *
+ * 作用:
+ * - 从 models.dev 拉取 provider catalog(opencode 自身在用的同一份)
+ * - 引导用户选择要启用的 provider 并输入 API key
+ * - 把 provider 启用声明写入 .opencode/opencode.json
+ * (空对象 `{}` 风格,所有元数据由 opencode 运行时从 catalog 自动获取)
+ * - 把 API key 写入 packages/server/.env
+ *
+ * 设计约束:
+ * - 只处理项目级配置;不读写 ~/.local/share/opencode/auth.json
+ * - 不做 catalog 本地缓存;每次重新拉取
+ * - 凭证只存 packages/server/.env
+ *
+ * 用法:
+ * pnpm opencode:setup
+ */
+
+import fs from 'node:fs'
+import path from 'node:path'
+import readline from 'node:readline'
+import { createRequire } from 'node:module'
+
+const require = createRequire(import.meta.url)
+const CloudBaseManager = require('../packages/server/node_modules/@cloudbase/manager-node')
+let managerApp = null
+
+// ─── Constants ───────────────────────────────────────────────────────────
+
+const CATALOG_URL = process.env.MODELS_DEV_CATALOG_URL || 'https://models.dev/api.json'
+const CATALOG_FETCH_TIMEOUT_MS = Number(process.env.MODELS_DEV_FETCH_TIMEOUT_MS) || 10_000
+
+const ROOT = process.cwd()
+const OPENCODE_JSON = path.join(ROOT, '.opencode', 'opencode.json')
+const SERVER_ENV_FILE = path.join(ROOT, 'packages', 'server', '.env')
+
+const colors = {
+ reset: '\x1b[0m',
+ bold: '\x1b[1m',
+ dim: '\x1b[2m',
+ red: '\x1b[31m',
+ green: '\x1b[32m',
+ yellow: '\x1b[33m',
+ cyan: '\x1b[36m',
+}
+
+// ─── UI helpers ──────────────────────────────────────────────────────────
+
+function log(msg, type = 'info') {
+ const prefix = {
+ info: `${colors.cyan}→${colors.reset}`,
+ ok: `${colors.green}✓${colors.reset}`,
+ warn: `${colors.yellow}!${colors.reset}`,
+ err: `${colors.red}✗${colors.reset}`,
+ step: `${colors.bold}▸${colors.reset}`,
+ }[type]
+ console.log(`${prefix} ${msg}`)
+}
+
+function logSection(title) {
+ console.log('')
+ console.log(`${colors.bold}${colors.cyan}━━━ ${title} ━━━${colors.reset}`)
+}
+
+let _rl = null
+
+function drainStdin() {
+ return new Promise((resolve) => {
+ if (!process.stdin.readable) return resolve()
+ process.stdin.resume()
+ const drain = () => {
+ while (process.stdin.read() !== null) {
+ /* discard */
+ }
+ }
+ drain()
+ setTimeout(() => {
+ drain()
+ process.stdin.pause()
+ resolve()
+ }, 10)
+ })
+}
+
+async function prompt(question, { hidden = false, defaultValue = '' } = {}) {
+ if (hidden) {
+ if (_rl) {
+ _rl.close()
+ _rl = null
+ }
+ await drainStdin()
+ process.stdout.write(`${question}: `)
+ process.stdin.setRawMode(true)
+ process.stdin.resume()
+ return new Promise((resolve) => {
+ let buf = ''
+ const onData = (chunk) => {
+ const c = chunk.toString('utf8')
+ if (c === '\n' || c === '\r' || c === '\u0004') {
+ process.stdin.setRawMode(false)
+ process.stdin.pause()
+ process.stdin.removeListener('data', onData)
+ process.stdout.write('\n')
+ resolve(buf || defaultValue)
+ } else if (c === '\u0003') {
+ process.exit(130)
+ } else if (c.charCodeAt(0) === 127) {
+ buf = buf.slice(0, -1)
+ } else {
+ buf += c
+ }
+ }
+ process.stdin.on('data', onData)
+ })
+ }
+ if (_rl) {
+ _rl.close()
+ _rl = null
+ }
+ await drainStdin()
+ _rl = readline.createInterface({ input: process.stdin, output: process.stdout })
+ const hint = defaultValue ? ` ${colors.dim}[${defaultValue}]${colors.reset}` : ''
+ return new Promise((resolve) => {
+ _rl.question(`${question}${hint}: `, (answer) => {
+ _rl.close()
+ _rl = null
+ const value = answer.trim()
+ resolve(value || defaultValue)
+ })
+ })
+}
+
+// ─── Env file helpers ───────────────────────────────────────────────────
+
+function parseEnvFile(file) {
+ if (!fs.existsSync(file)) return {}
+ const env = {}
+ const content = fs.readFileSync(file, 'utf-8')
+ for (const line of content.split('\n')) {
+ const trimmed = line.trim()
+ if (!trimmed || trimmed.startsWith('#')) continue
+ const eq = trimmed.indexOf('=')
+ if (eq === -1) continue
+ const key = trimmed.slice(0, eq).trim()
+ const value = trimmed.slice(eq + 1).trim()
+ if (key) env[key] = value
+ }
+ return env
+}
+
+/**
+ * 往 env 文件写 key=value。
+ * - 已存在同名 key:替换该行
+ * - 不存在:追加到文件末尾(如果文件已存在,先补一个换行避免粘连)
+ */
+function upsertEnvFile(file, updates) {
+ const keys = Object.keys(updates)
+ if (keys.length === 0) return { updated: [], added: [] }
+
+ fs.mkdirSync(path.dirname(file), { recursive: true })
+ const updated = []
+ const added = []
+
+ let content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : ''
+ for (const key of keys) {
+ const value = updates[key]
+ // 替换已存在的行(匹配 "KEY=..." 或 "#KEY=...")
+ const re = new RegExp(`^[#\\s]*${escapeRegExp(key)}\\s*=.*$`, 'm')
+ if (re.test(content)) {
+ content = content.replace(re, `${key}=${value}`)
+ updated.push(key)
+ } else {
+ if (content.length > 0 && !content.endsWith('\n')) content += '\n'
+ content += `${key}=${value}\n`
+ added.push(key)
+ }
+ }
+ fs.writeFileSync(file, content)
+ return { updated, added }
+}
+
+function escapeRegExp(s) {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
+// ─── Catalog fetcher ────────────────────────────────────────────────────
+
+async function fetchCatalog() {
+ const controller = new AbortController()
+ const timer = setTimeout(() => controller.abort(), CATALOG_FETCH_TIMEOUT_MS)
+ try {
+ const res = await fetch(CATALOG_URL, { signal: controller.signal })
+ clearTimeout(timer)
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ const data = await res.json()
+ if (!data || typeof data !== 'object') throw new Error('invalid catalog shape')
+ return data
+ } catch (e) {
+ clearTimeout(timer)
+ throw e
+ }
+}
+
+// ─── opencode.json helpers ──────────────────────────────────────────────
+
+function readOpencodeJson() {
+ if (!fs.existsSync(OPENCODE_JSON)) {
+ return { $schema: 'https://opencode.ai/config.json', provider: {} }
+ }
+ try {
+ const raw = fs.readFileSync(OPENCODE_JSON, 'utf-8')
+ const parsed = JSON.parse(raw)
+ if (!parsed.provider || typeof parsed.provider !== 'object') parsed.provider = {}
+ if (!parsed.$schema) parsed.$schema = 'https://opencode.ai/config.json'
+ return parsed
+ } catch (e) {
+ log(`解析 .opencode/opencode.json 失败:${e.message}`, 'warn')
+ return { $schema: 'https://opencode.ai/config.json', provider: {} }
+ }
+}
+
+function writeOpencodeJson(config) {
+ fs.mkdirSync(path.dirname(OPENCODE_JSON), { recursive: true })
+ // 保持稳定字段顺序:$schema > model > provider > 其他
+ const ordered = {}
+ if (config.$schema) ordered.$schema = config.$schema
+ if (config.model) ordered.model = config.model
+ if (config.provider) ordered.provider = config.provider
+ for (const [k, v] of Object.entries(config)) {
+ if (k in ordered) continue
+ ordered[k] = v
+ }
+ fs.writeFileSync(OPENCODE_JSON, JSON.stringify(ordered, null, 2) + '\n')
+}
+
+/**
+ * 从 catalog 构建完整的 provider 配置(写入 opencode.json)。
+ *
+ * 为什么不用空对象 `{}`:opencode 子进程自身也需要拉 models.dev catalog 来
+ * 填充 npm / baseURL / models 等字段。如果拉取失败(网络/超时),空对象等于
+ * "什么都没有",导致模型不可用。写入完整字段则让 opencode.json 自包含,
+ * 不依赖运行时 catalog 拉取。
+ *
+ * 写入的字段:npm, name, options.baseURL, options.apiKey({env:VAR}),
+ * models(每个 model 的 name/tool_call/reasoning/limit/modalities)
+ * 不写入的字段:cost/release_date/family 等纯展示字段(opencode 不需要)
+ */
+function buildProviderConfig(catalog, providerId) {
+ const p = catalog[providerId]
+ if (!p) {
+ // catalog 里没有(不太可能走到这,因为 listProviders 已过滤)
+ return {}
+ }
+
+ const config = {}
+
+ // npm: 有则写,没有用默认
+ if (p.npm) config.npm = p.npm
+
+ // name
+ if (p.name && p.name !== providerId) config.name = p.name
+
+ // options: baseURL + apiKey(用 {env:VAR} 占位符)
+ const options = {}
+ if (p.api) options.baseURL = p.api
+ if (Array.isArray(p.env) && p.env.length > 0) {
+ options.apiKey = `{env:${p.env[0]}}`
+ }
+ if (Object.keys(options).length > 0) config.options = options
+
+ // models: 取 catalog 里所有非 deprecated 的 model,只保留 opencode 需要的字段
+ const models = {}
+ for (const [mid, m] of Object.entries(p.models || {})) {
+ if (!m || typeof m !== 'object') continue
+ if (m.status === 'deprecated') continue
+
+ const model = {}
+ if (m.name) model.name = m.name
+ if (m.tool_call !== undefined) model.tool_call = m.tool_call
+ if (m.reasoning !== undefined) model.reasoning = m.reasoning
+ if (m.limit && (m.limit.context || m.limit.output)) {
+ model.limit = {}
+ if (m.limit.context) model.limit.context = m.limit.context
+ if (m.limit.output) model.limit.output = m.limit.output
+ }
+ if (m.modalities) {
+ model.modalities = {}
+ if (Array.isArray(m.modalities.input)) model.modalities.input = m.modalities.input
+ if (Array.isArray(m.modalities.output)) model.modalities.output = m.modalities.output
+ }
+ models[mid] = model
+ }
+ if (Object.keys(models).length > 0) config.models = models
+
+ return config
+}
+
+function padEnd(str, width) {
+ // 粗略按 UTF-16 长度对齐(中文宽度不严格处理,够用)
+ const pad = Math.max(0, width - str.length)
+ return str + ' '.repeat(pad)
+}
+
+/**
+ * 从字符串里提取所有 {env:VAR} 占位符的变量名。
+ * 用于扫描自定义 provider 的 options.apiKey / options.baseURL / options.headers。
+ */
+function extractEnvPlaceholders(value) {
+ if (typeof value !== 'string') return []
+ const out = []
+ const re = /\{env:([^}]+)\}/g
+ let m
+ while ((m = re.exec(value)) !== null) {
+ if (m[1]) out.push(m[1])
+ }
+ return out
+}
+
+/**
+ * 递归收集 provider.options(含 nested headers/object)里所有的 {env:VAR}。
+ */
+function collectProviderEnvPlaceholders(provider) {
+ const set = new Set()
+ const walk = (node) => {
+ if (typeof node === 'string') {
+ for (const v of extractEnvPlaceholders(node)) set.add(v)
+ } else if (Array.isArray(node)) {
+ node.forEach(walk)
+ } else if (node && typeof node === 'object') {
+ Object.values(node).forEach(walk)
+ }
+ }
+ walk(provider?.options ?? {})
+ return Array.from(set)
+}
+
+/**
+ * 扫描 opencode.json 已存在的 provider,对每个识别其需要的 env、当前是否齐全。
+ *
+ * 三种判定来源(与运行时 `filterAvailable` 完全对齐):
+ * - catalog provider:`provider.env` 数组里任一命中即可
+ * - 自定义 provider(catalog 里没有):扫 options 里的 `{env:VAR}` 占位符
+ * - 用户在 opencode.json 里手动覆盖 `env` 数组:以用户值为准
+ */
+function inspectExistingProviders(existingProviderMap, catalog, envNow) {
+ const result = []
+ for (const [id, cfg] of Object.entries(existingProviderMap ?? {})) {
+ if (!cfg || typeof cfg !== 'object') continue
+
+ const fromCatalog = catalog[id]
+ const isInCatalog = !!fromCatalog
+ const name = cfg.name || fromCatalog?.name || id
+
+ // 候选 env 列表:用户覆盖 > catalog > 占位符提取
+ let envCandidates = []
+ if (Array.isArray(cfg.env) && cfg.env.length > 0) {
+ envCandidates = cfg.env.slice()
+ } else if (Array.isArray(fromCatalog?.env) && fromCatalog.env.length > 0) {
+ envCandidates = fromCatalog.env.slice()
+ }
+ // 自定义 provider 没有 catalog env 时,用占位符
+ const placeholders = collectProviderEnvPlaceholders(cfg)
+ for (const p of placeholders) {
+ if (!envCandidates.includes(p)) envCandidates.push(p)
+ }
+
+ // 是否"运行时可用"
+ const hasOptionsApiKey = (() => {
+ const k = cfg.options?.apiKey
+ if (typeof k !== 'string' || !k) return false
+ const m = k.match(/^\{env:([^}]+)\}$/)
+ if (m) return !!envNow[m[1]]
+ return true // 明文字符串
+ })()
+ const hasEnvSet = envCandidates.some((v) => !!envNow[v])
+ const ok = hasEnvSet || hasOptionsApiKey
+
+ // 缺失的 env:所有候选里没设置的
+ // - 对 catalog provider:只要任一 env 设了就算 ok(不算缺失)
+ // - 对自定义 provider 含 placeholder:每个 placeholder 都要设
+ let missingEnv = []
+ if (placeholders.length > 0) {
+ // 占位符模式:每个都必须设
+ missingEnv = placeholders.filter((v) => !envNow[v])
+ } else if (!ok) {
+ missingEnv = envCandidates.slice()
+ }
+
+ result.push({
+ id,
+ name,
+ isInCatalog,
+ isCustom: !isInCatalog,
+ envCandidates,
+ missingEnv,
+ ok,
+ modelCount: Object.keys(cfg.models ?? fromCatalog?.models ?? {}).length,
+ })
+ }
+ return result
+}
+
+function renderExistingTable(rows) {
+ if (rows.length === 0) return
+ console.log('')
+ console.log(`${colors.bold}已配置的 provider(来自 .opencode/opencode.json)${colors.reset}`)
+ console.log('')
+ console.log(
+ ` ${colors.dim}状态 ${padEnd('provider id', 22)} ${padEnd('类型', 10)} ${padEnd('依赖 env', 40)} 模型数${colors.reset}`,
+ )
+ for (const r of rows) {
+ const status = r.ok ? `${colors.green}●${colors.reset}` : `${colors.yellow}●${colors.reset}`
+ const kind = r.isInCatalog ? 'catalog' : '自定义'
+ // 先算"无颜色"的纯文本,按其长度 pad,再套色
+ const envPlain =
+ r.missingEnv.length > 0
+ ? `缺 ${r.missingEnv.join(', ')}`
+ : r.envCandidates.length > 0
+ ? r.envCandidates.join(' / ')
+ : '-'
+ const envColor =
+ r.missingEnv.length > 0 ? colors.yellow : r.envCandidates.length > 0 ? colors.green : colors.dim
+ const envLabel = `${envColor}${envPlain}${colors.reset}` + ' '.repeat(Math.max(0, 40 - envPlain.length))
+ console.log(` ${status} ${padEnd(r.id, 22)} ${padEnd(kind, 10)} ${envLabel} ${r.modelCount}`)
+ }
+ console.log(
+ ` ${colors.dim}● 绿色 = 凭证已就绪可用 ● 黄色 = 已声明但缺 env,前端不会展示${colors.reset}`,
+ )
+}
+
+/**
+ * 渲染 provider 选择列表。仅展示有 env 字段的 provider(我们能自动处理 api-key 模式)。
+ */
+function listProviders(catalog, existingProvider, envNow) {
+ const items = Object.entries(catalog)
+ .filter(([_, p]) => Array.isArray(p.env) && p.env.length > 0)
+ .map(([id, p]) => {
+ const alreadyEnabled = !!existingProvider[id]
+ const envKey = p.env[0]
+ const envSet = !!envNow[envKey]
+ return {
+ id,
+ name: p.name || id,
+ envKey,
+ envKeys: p.env,
+ modelCount: Object.keys(p.models || {}).length,
+ alreadyEnabled,
+ envSet,
+ }
+ })
+ .sort((a, b) => {
+ // cloudbase 始终排在第一位
+ if (a.id === 'cloudbase') return -1
+ if (b.id === 'cloudbase') return 1
+ // 已启用的排在前面,便于识别
+ if (a.alreadyEnabled !== b.alreadyEnabled) return a.alreadyEnabled ? -1 : 1
+ return a.id.localeCompare(b.id)
+ })
+ return items
+}
+
+function renderProviderTable(items) {
+ console.log('')
+ console.log(` ${colors.dim}状态 ${padEnd('provider id', 22)} ${padEnd('name', 24)} ${padEnd('env key', 28)} 模型数${colors.reset}`)
+ for (const it of items) {
+ const tick = it.alreadyEnabled ? `${colors.green}●${colors.reset}` : '○'
+ const envMark = it.envSet ? `${colors.green}${it.envKey}${colors.reset}` : it.envKey
+ console.log(
+ ` ${tick} ${padEnd(it.id, 22)} ${padEnd(it.name, 24)} ${padEnd(envMark, 28 + (it.envSet ? colors.green.length + colors.reset.length : 0))} ${it.modelCount}`,
+ )
+ }
+ console.log(` ${colors.dim}● = 已在 opencode.json 启用,绿色 env key = .env 已设置${colors.reset}`)
+}
+
+async function pickProviders(items) {
+ console.log('')
+ console.log(`输入要启用的 provider id(空格分隔,留空使用全部已启用的)`)
+ console.log(`${colors.dim}例如: deepseek moonshot openai${colors.reset}`)
+ const answer = await prompt('provider ids')
+ const raw = answer
+ .split(/[\s,]+/)
+ .map((s) => s.trim())
+ .filter(Boolean)
+ const selected = []
+ const unknown = []
+ const byId = new Map(items.map((it) => [it.id, it]))
+ for (const id of raw) {
+ if (byId.has(id)) selected.push(byId.get(id))
+ else unknown.push(id)
+ }
+ if (unknown.length > 0) {
+ log(`未知 provider id(忽略):${unknown.join(', ')}`, 'warn')
+ }
+ return selected
+}
+
+async function collectApiKeys(selected, envNow) {
+ const updates = {}
+ for (const it of selected) {
+ const existing = envNow[it.envKey]
+ if (existing) {
+ console.log(` ${colors.green}✓${colors.reset} ${it.envKey} 已在 packages/server/.env 中(跳过)`)
+ continue
+ }
+ console.log('')
+ console.log(` ${colors.bold}${it.name}${colors.reset} (${it.id})`)
+ if (it.envKeys.length > 1) {
+ console.log(` ${colors.dim}候选 env: ${it.envKeys.join(', ')} — 将使用 ${it.envKey}${colors.reset}`)
+ }
+ const value = await prompt(` ${it.envKey}`, { hidden: true })
+ if (!value || value.trim() === '') {
+ log(` 未输入值,provider ${it.id} 仍会写入 opencode.json 但运行时不会启用`, 'warn')
+ continue
+ }
+ updates[it.envKey] = value.trim()
+ }
+ return updates
+}
+
+/**
+ * 收集"已存在但缺 env 的 provider"的 env 值。
+ * 与 collectApiKeys 区别:这里是补齐,不需要再写 provider 对象到 opencode.json。
+ */
+async function collectMissingEnvs(rows) {
+ const updates = {}
+ // 把所有 row 的 missingEnv 去重展开成一个待补齐列表
+ const todoMap = new Map() // envKey -> Set
+ for (const r of rows) {
+ for (const k of r.missingEnv) {
+ if (!todoMap.has(k)) todoMap.set(k, new Set())
+ todoMap.get(k).add(r.id)
+ }
+ }
+ if (todoMap.size === 0) return updates
+
+ console.log('')
+ console.log(
+ `${colors.bold}补齐缺失的 env${colors.reset} ${colors.dim}(这些 provider 已在 opencode.json 声明,但当前 .env 缺凭证)${colors.reset}`,
+ )
+ console.log(`${colors.dim}回车留空 = 跳过该项${colors.reset}`)
+
+ for (const [envKey, providers] of todoMap) {
+ console.log('')
+ console.log(` ${colors.bold}${envKey}${colors.reset} ${colors.dim}(用于: ${[...providers].join(', ')})${colors.reset}`)
+ const value = await prompt(` ${envKey}`, { hidden: true })
+ if (value && value.trim() !== '') {
+ updates[envKey] = value.trim()
+ }
+ }
+ return updates
+}
+
+async function pickDefaultModel(catalog, selected, currentDefault) {
+ const candidates = []
+ for (const it of selected) {
+ const models = catalog[it.id]?.models ?? {}
+ for (const mid of Object.keys(models)) {
+ const m = models[mid]
+ if (m && m.status === 'deprecated') continue
+ candidates.push({
+ id: `${it.id}/${mid}`,
+ name: m?.name || mid,
+ provider: it.name,
+ })
+ }
+ }
+ if (candidates.length === 0) return currentDefault || ''
+
+ // 如果当前默认还在候选里,保留为默认
+ const current = candidates.find((c) => c.id === currentDefault)
+ const defaultIdx = current ? candidates.indexOf(current) : 0
+
+ console.log('')
+ console.log(`选择默认模型(会写入 opencode.json 的 "model" 字段):`)
+ const showTop = Math.min(candidates.length, 20)
+ for (let i = 0; i < showTop; i++) {
+ const c = candidates[i]
+ const mark = i === defaultIdx ? `${colors.green}*${colors.reset}` : ' '
+ console.log(` ${mark} ${String(i + 1).padStart(2, ' ')}) ${c.id} ${colors.dim}(${c.provider})${colors.reset}`)
+ }
+ if (candidates.length > showTop) {
+ console.log(` ${colors.dim}... 及其他 ${candidates.length - showTop} 个,输入序号或完整 id${colors.reset}`)
+ }
+ const answer = await prompt(`模型序号或 provider/model`, {
+ defaultValue: candidates[defaultIdx].id,
+ })
+
+ if (!answer) return candidates[defaultIdx].id
+ const idx = Number(answer) - 1
+ if (Number.isInteger(idx) && idx >= 0 && idx < candidates.length) {
+ return candidates[idx].id
+ }
+ // 允许用户直接输入完整 id(即便不在展示的前 20 个)
+ if (candidates.some((c) => c.id === answer)) return answer
+ log(`输入无效,使用 ${candidates[defaultIdx].id}`, 'warn')
+ return candidates[defaultIdx].id
+}
+
+function getManager(envId, secretId, secretKey) {
+ if (managerApp) return managerApp
+ managerApp = new CloudBaseManager({
+ envId,
+ secretId,
+ secretKey
+ })
+ return managerApp
+}
+
+async function describeAIModes(envId, secretId, secretKey) {
+ try {
+ const manager = getManager(envId, secretId, secretKey)
+ const commonService = manager.commonService('tcb', '2018-06-08')
+ const result = await commonService.call({
+ Action: 'DescribeAIModels',
+ Param: {
+ EnvId: envId,
+ },
+ })
+ return result?.AIModels || []
+ } catch (err) {
+ // Non-fatal: server-side SDK uses admin creds and bypasses rules.
+ console.error(
+ '[open code setup] Failed to describe ai models',
+ err instanceof Error ? err.message : err,
+ )
+ }
+}
+
+async function getCloudBaseModelConfig(envId, secretId, secretKey) {
+ const modelList = await describeAIModes(envId, secretId, secretKey)
+ let cloudBaseModels = {}
+ for (const it of modelList) {
+ if (it?.GroupName !== "cloudbase") {
+ continue;
+ }
+ for (const model of it?.Models){
+ cloudBaseModels[model.Model] ={
+ id : model.Model,
+ name : model.Model,
+ }
+ }
+ }
+
+ return {
+ id: "cloudbase",
+ env: ["CLOUDBASE_API_KEY"],
+ npm: "",
+ api: `https://${envId}.api.tcloudbasegateway.com/v1/ai/cloudbase`,
+ name: "cloudbase",
+ doc:"",
+ models: cloudBaseModels
+ }
+}
+
+// ─── Main ────────────────────────────────────────────────────────────────
+
+async function main() {
+ logSection('OpenCode Provider 配置')
+
+ const envNow = parseEnvFile(SERVER_ENV_FILE)
+ const envId = envNow['TCB_ENV_ID']
+ const secretId = envNow['TCB_SECRET_ID']
+ const secretKey = envNow['TCB_SECRET_KEY']
+
+ // 1. 拉 catalog
+ // log('拉取 models.dev catalog...', 'step')
+ let catalog = {}
+ // 默认不拉取第三方模型
+ // try {
+ // catalog = await fetchCatalog()
+ // log(`catalog 已拉取:${Object.keys(catalog).length} providers`, 'ok')
+ // } catch (e) {
+ // log(`拉取失败:${e.message}`, 'err')
+ // console.log('')
+ // console.log(`请检查网络或设置 ${colors.bold}MODELS_DEV_CATALOG_URL${colors.reset} env 指向镜像。`)
+ // process.exit(1)
+ // }
+
+ // 仅添加 cloudbase 模型
+ log('拉取 cloudbase 模型', 'step')
+ const cloudBaseModelConfig = await getCloudBaseModelConfig(envId, secretId, secretKey)
+
+ if (Object.keys(cloudBaseModelConfig.models).length === 0) {
+ logSection(`未配置 cloudbase 模型, 请前往 https://tcb.cloud.tencent.com/dev?envId=${envId}#/ai?tab=text-aiModel 开启模型配置` )
+ process.exit(1)
+ }
+
+ catalog['cloudbase'] = cloudBaseModelConfig
+
+ // 2. 读现状
+ const existing = readOpencodeJson()
+
+ // 3. 扫描已有 provider,识别缺失 env
+ const existingRows = inspectExistingProviders(existing.provider, catalog, envNow)
+ renderExistingTable(existingRows)
+
+ // 3a. 如有缺失 env,先邀请用户补齐
+ const missingRows = existingRows.filter((r) => r.missingEnv.length > 0)
+ let missingEnvUpdates = {}
+ if (missingRows.length > 0) {
+ console.log('')
+ const ans = await prompt(
+ `检测到 ${missingRows.length} 个 provider 缺少 env,是否现在补齐?(Y/n)`,
+ { defaultValue: 'Y' },
+ )
+ if (ans.toLowerCase() !== 'n' && ans.toLowerCase() !== 'no') {
+ missingEnvUpdates = await collectMissingEnvs(missingRows)
+ }
+ }
+
+ // 4. 展示 catalog provider 列表(添加新 provider 用)
+ const items = listProviders(catalog, existing.provider, { ...envNow, ...missingEnvUpdates })
+ if (items.length === 0) {
+ log('catalog 中没有可用 provider(这不太可能,请检查 catalog)', 'err')
+ process.exit(1)
+ }
+
+ console.log('')
+ console.log(`${colors.bold}添加 / 启用 catalog provider${colors.reset}`)
+ renderProviderTable(items)
+
+ // 5. 用户选
+ // const selected = await pickProviders(items)
+ // if (selected.length === 0 && Object.keys(missingEnvUpdates).length === 0) {
+ // log('没有任何变更,退出', 'info')
+ // process.exit(0)
+ // }
+ // 默认仅支持 cloudbase
+ let selected = []
+ const byId = new Map(items.map((it) => [it.id, it]))
+ if (byId.has("cloudbase")) {
+ selected.push(byId.get("cloudbase"))
+ }
+
+
+ // 6. 收 key(仅针对新选的 provider;missing 的已在 step 3a 处理)
+ let envUpdates = { ...missingEnvUpdates }
+ if (selected.length > 0) {
+ log(`新增启用:${selected.map((s) => s.id).join(', ')}`, 'ok')
+ logSection('API Key(新增 provider)')
+ console.log(`${colors.dim}所有 key 将写入 packages/server/.env(已 gitignore)。回车留空则跳过。${colors.reset}`)
+ const newKeyUpdates = await collectApiKeys(selected, { ...envNow, ...missingEnvUpdates })
+ envUpdates = { ...envUpdates, ...newKeyUpdates }
+ }
+
+ // 7. 选默认模型(在并集中选)
+ logSection('默认模型')
+ const allProvidersForModel = [
+ ...selected,
+ // 已存在且 catalog 里有的 provider 也作为候选源
+ ...existingRows
+ .filter((r) => r.isInCatalog && !selected.find((s) => s.id === r.id))
+ .map((r) => ({ id: r.id, name: r.name })),
+ ]
+ const defaultModel =
+ allProvidersForModel.length > 0
+ ? await pickDefaultModel(catalog, allProvidersForModel, existing.model)
+ : existing.model || ''
+
+ // 8. 构造新 opencode.json:从 catalog 取完整字段写入,保留已有 provider
+ const nextProvider = { ...existing.provider }
+ for (const it of selected) {
+ // if (nextProvider[it.id]) continue // 已存在的不覆盖(用户可能手动调过)
+ nextProvider[it.id] = buildProviderConfig(catalog, it.id)
+ }
+ const nextConfig = {
+ ...existing,
+ provider: nextProvider,
+ }
+ if (defaultModel) nextConfig.model = defaultModel
+
+ // 9. 落盘
+ logSection('写入文件')
+ // opencode.json:仅在结构有变化时写
+ const configChanged =
+ JSON.stringify(existing) !== JSON.stringify(nextConfig) || existing.model !== nextConfig.model
+ if (configChanged) {
+ writeOpencodeJson(nextConfig)
+ log(`已写入 ${path.relative(ROOT, OPENCODE_JSON)}`, 'ok')
+ } else {
+ log(`${path.relative(ROOT, OPENCODE_JSON)} 无变化`, 'info')
+ }
+
+ if (Object.keys(envUpdates).length > 0) {
+ const { updated, added } = upsertEnvFile(SERVER_ENV_FILE, envUpdates)
+ const parts = []
+ if (added.length > 0) parts.push(`新增 ${added.length} 项`)
+ if (updated.length > 0) parts.push(`更新 ${updated.length} 项`)
+ log(`已写入 ${path.relative(ROOT, SERVER_ENV_FILE)} (${parts.join(',')})`, 'ok')
+ } else {
+ log(`没有新的 env 变更`, 'info')
+ }
+
+ // 9. Summary
+ console.log('')
+ console.log(`${colors.bold}${colors.green}✓ 完成${colors.reset}`)
+ console.log('')
+ console.log(`${colors.dim}下一步:${colors.reset}`)
+ console.log(` 1) 重启 server(${colors.bold}pnpm dev:server${colors.reset} 或生产环境重启)`)
+ console.log(` 2) 访问前端 OpenCode agent,在模型下拉里应看到新模型`)
+ console.log(` 3) 如需 whitelist / 自定义 baseURL / 非 catalog provider,请手动编辑 ${colors.bold}.opencode/opencode.json${colors.reset}`)
+ console.log('')
+}
+
+main()
+ .then(() => {
+ if (_rl) _rl.close()
+ })
+ .catch((e) => {
+ if (_rl) _rl.close()
+ console.error(e)
+ process.exit(1)
+ })