From 92f229929482934225e2ff05d5932be7bbcd0a16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=9D=99=E8=BF=9C?= <837317210@qq.com> Date: Sat, 9 May 2026 18:50:27 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81opencode=20?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E6=A8=A1=E5=9E=8B=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .opencode/opencode.example.json | 2 +- .opencode/opencode.json | 50 +- README.md | 74 ++ package.json | 1 + .../src/agent/runtime/opencode-acp-runtime.ts | 39 +- .../src/agent/runtime/opencode-catalog.ts | 323 +++++++++ packages/shared/src/types/agent.ts | 13 + scripts/opencode-setup.mjs | 668 ++++++++++++++++++ 8 files changed, 1129 insertions(+), 41 deletions(-) create mode 100644 packages/server/src/agent/runtime/opencode-catalog.ts create mode 100644 scripts/opencode-setup.mjs diff --git a/.opencode/opencode.example.json b/.opencode/opencode.example.json index baf4b85..8a63cea 100644 --- a/.opencode/opencode.example.json +++ b/.opencode/opencode.example.json @@ -6,7 +6,7 @@ "npm": "@ai-sdk/openai-compatible", "name": "MiMo", "options": { - "baseURL": "{env:OPENAI_API_ENDPOINT}", + "baseURL": "{env:MIMO_API_ENDPOINT}", "apiKey": "{env:MIMO_API_KEY}" }, "models": { diff --git a/.opencode/opencode.json b/.opencode/opencode.json index be4ad4a..b5bcdbe 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -1,13 +1,13 @@ { "$schema": "https://opencode.ai/config.json", - "model": "mimo/mimo-v2.5-pro", + "model": "deepseek/deepseek-v4-flash", "provider": { "mimo": { "npm": "@ai-sdk/openai-compatible", "name": "MiMo", "options": { - "baseURL": "{env:OPENAI_API_ENDPOINT}", - "apiKey": "{env:OPENAI_API_KEY}" + "baseURL": "{env:MIMO_API_ENDPOINT}", + "apiKey": "{env:MIMO_API_KEY}" }, "models": { "mimo-v2.5-pro": { @@ -18,8 +18,13 @@ "output": 131072 }, "modalities": { - "input": ["text", "image"], - "output": ["text"] + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] } }, "mimo-v2.5": { @@ -30,8 +35,12 @@ "output": 131072 }, "modalities": { - "input": ["text"], - "output": ["text"] + "input": [ + "text" + ], + "output": [ + "text" + ] } }, "mimo-v2.5-tts": { @@ -42,8 +51,12 @@ "output": 4096 }, "modalities": { - "input": ["text"], - "output": ["audio"] + "input": [ + "text" + ], + "output": [ + "audio" + ] } }, "mimo-v2.5-tts-voiceclone": { @@ -54,8 +67,12 @@ "output": 4096 }, "modalities": { - "input": ["text"], - "output": ["audio"] + "input": [ + "text" + ], + "output": [ + "audio" + ] } }, "mimo-v2.5-tts-voicedesign": { @@ -66,11 +83,16 @@ "output": 4096 }, "modalities": { - "input": ["text"], - "output": ["audio"] + "input": [ + "text" + ], + "output": [ + "audio" + ] } } } - } + }, + "deepseek": {} } } diff --git a/README.md b/README.md index 30de018..9c93b96 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,77 @@ 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 自身使用的 catalog)拉取 118+ provider 列表 +2. 引导选择要启用的 provider(deepseek / moonshot / openai / anthropic / zhipuai / …) +3. 提示输入对应的 API Key(如 `DEEPSEEK_API_KEY`) +4. 选择默认模型 +5. 把 provider 启用声明写入 `.opencode/opencode.json`(空对象风格,与 opencode 官方推荐一致) +6. 把 API Key 写入 `packages/server/.env` + +### 生成结果示例 + +```jsonc +// .opencode/opencode.json +{ + "$schema": "https://opencode.ai/config.json", + "model": "deepseek/deepseek-chat", + "provider": { + "deepseek": {}, + "moonshot": {} + } +} +``` + +```bash +# packages/server/.env 会追加 API Key +DEEPSEEK_API_KEY=sk-*** +MOONSHOT_API_KEY=sk-*** +``` + +`provider.: {}` 表示启用该 provider,其 `npm`/`api`/`name`/`models` 等元数据由 opencode +运行时从 models.dev 自动获取。这样做的好处: + +- 与 opencode 官方行为完全一致,不用冗余维护字段 +- models.dev 上游新增模型时自动生效,无需改项目配置 + +### 高级:自定义 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`。setup 脚本只覆盖最常用的"启用 + 灌 key + 选默认 +model"三件事。 + +### 重新配置 / 新增 provider + +`pnpm opencode:setup` 幂等,可多次运行。已存在的 provider / env key 不会被重复询问。 + ## 常用命令 ```bash @@ -248,6 +319,9 @@ pnpm db:studio # 打开 Drizzle Studio # TCR pnpm setup:tcr # 配置容器镜像服务 + +# OpenCode +pnpm opencode:setup # 配置 OpenCode provider 和模型 ``` ## 技术栈 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/src/agent/runtime/opencode-acp-runtime.ts b/packages/server/src/agent/runtime/opencode-acp-runtime.ts index 9a99555..4d65ad9 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 }] 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..6ba1303 --- /dev/null +++ b/packages/server/src/agent/runtime/opencode-catalog.ts @@ -0,0 +1,323 @@ +/** + * 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[]。 + * + * 当 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 [] + + // catalog 不可用:把 config.provider 当作全部自定义 provider(需要用户填了完整字段) + const baseCatalog: Catalog = catalog ?? {} + const merged = mergeProviders(baseCatalog, (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/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..f11947e --- /dev/null +++ b/scripts/opencode-setup.mjs @@ -0,0 +1,668 @@ +#!/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' + +// ─── 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') +} + +// ─── UI flow ──────────────────────────────────────────────────────────── + +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) => { + // 已启用的排在前面,便于识别 + 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 +} + +// ─── Main ──────────────────────────────────────────────────────────────── + +async function main() { + logSection('OpenCode Provider 配置') + + // 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) + } + + // 2. 读现状 + const existing = readOpencodeJson() + const envNow = parseEnvFile(SERVER_ENV_FILE) + + // 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) + } + + // 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:已选 provider 用 `{}`,保留其余已有 provider + const nextProvider = { ...existing.provider } + for (const it of selected) { + if (!nextProvider[it.id]) nextProvider[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) + }) From 924ef86a6c56219ca2fa4fc71c15bba5757e8484 Mon Sep 17 00:00:00 2001 From: yang Date: Sat, 9 May 2026 21:21:41 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(opencode-acp):=20stopReason=20?= =?UTF-8?q?=E4=B8=BA=20refusal/max=5Ftokens=20=E6=97=B6=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E5=8F=8B=E5=A5=BD=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **问题**:LLM provider(如腾讯 MiMo)的内容安全策略偶发会拦截请求,返回 ACP `stopReason: 'refusal'`,此时 LLM 流被中断,assistant record 常常只有截断的 reasoning 没有 text,UI 显示为一条"空回复",用户不知道发生了什么。 **实现**: prompt 返回后检查 `promptRes.stopReason`: - refusal / max_tokens / max_turn_requests 三种非正常结束原因:emit 一条 text 事件(⚠️ 开头的提示段),通过 makeEmitter 同时走两条通道: 1. messageBuilder.pushEvent → 作为 text part 持久化到 DB,刷新后仍可见 2. convertToSessionUpdate → agent_message_chunk SSE 事件,实时展示 - cancelled 不在此处处理(abort 走 catch 分支),end_turn 是正常完成。 **为什么 emit text 而不是 emit error**: error 被前端渲染为 log 级别的小字状态信息,而我们要的是把这段"为什么没有 回复"的解释**作为 assistant 回答的一部分**留在对话流里,让用户下次刷新也能 看到原因。 --- .opencode/tools/AskUserQuestion.ts | 4 +- .../src/agent/runtime/opencode-acp-runtime.ts | 54 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) 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/packages/server/src/agent/runtime/opencode-acp-runtime.ts b/packages/server/src/agent/runtime/opencode-acp-runtime.ts index 4d65ad9..6e99dcb 100644 --- a/packages/server/src/agent/runtime/opencode-acp-runtime.ts +++ b/packages/server/src/agent/runtime/opencode-acp-runtime.ts @@ -508,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', @@ -735,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') + } +} From 3cbf85839025e120ff2338143967d21384b48163 Mon Sep 17 00:00:00 2001 From: yang Date: Sat, 9 May 2026 21:44:39 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(build):=20=E7=A7=BB=E9=99=A4=E5=B7=B2?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E7=9B=AE=E5=BD=95=E7=9A=84=20cp=20=E6=AD=A5?= =?UTF-8?q?=E9=AA=A4=20+=20=E7=94=9F=E4=BA=A7=E9=95=9C=E5=83=8F=E6=8B=B7?= =?UTF-8?q?=E8=B4=9D=20.opencode/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **现象**:云托管构建 `pnpm build` 报 `cp: cannot stat 'src/agent/runtime/opencode-tool-templates/*.ts'` → exit 1。 **根因**:提交 38114f1 把 opencode tool 模板从 `packages/server/src/agent/runtime/opencode-tool-templates/` 迁移到 `.opencode/tools/`(checked-in,不再需要安装步骤),但遗漏了两处清理: 1. `packages/server/package.json` build 尾部仍执行 `mkdir -p dist/.../opencode-tool-templates && cp src/.../opencode-tool-templates/*.ts ...`, 源目录已不存在 → cp 失败 → 整个 build 失败。 2. `Dockerfile` Stage 2 只拷贝 `dist/`,没拷 `.opencode/`,即便 build 能过运行时也会在 `getOpencodeConfigDir()` 找不到 tools。 **修复**: - package.json:删除 build 末尾的 mkdir/cp 片段,保留三个 tsup 步骤。 - Dockerfile:Stage 2 新增 `COPY --from=build /app/.opencode ./.opencode`。 resolveProjectRoot() 从 dist 向上找到含 packages/server 的 /app → 工具解析为 /app/.opencode/tools/*.ts,与源码运行一致。 - tsconfig.json:顺带清理已失效的 exclude 路径(保留 __tests__ 一行)。 本地验证:pnpm --filter @coder/server build、pnpm type-check、pnpm lint 全绿。 --- Dockerfile | 6 ++++++ packages/server/package.json | 2 +- packages/server/tsconfig.json | 5 +---- 3 files changed, 8 insertions(+), 5 deletions(-) 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/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/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__/**"] } From fa221a5f330e86a93b561435e5df28bfad013f05 Mon Sep 17 00:00:00 2001 From: yang Date: Sat, 9 May 2026 21:46:08 +0800 Subject: [PATCH 4/8] =?UTF-8?q?chore(opencode):=20=E5=88=A0=E9=99=A4=20ope?= =?UTF-8?q?ncode-installer=20=E4=B8=AD=E7=9A=84=E6=AD=BB=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自 38114f1(tool 模板迁入 .opencode/tools/ 并 checked in)以来这些已无调用方: - TOOL_NAMES 常量 + ToolName 导出类型 - resolveTemplateDir()(定位 src/agent/runtime/opencode-tool-templates/, 而该目录已删除) - getInstallDir() - hashFile() + crypto import - InstallResult interface - ensureOpencodeToolsInstalled()(安装器本体) - 3 个对应的环境变量开关:OPENCODE_TOOL_TEMPLATE_DIR / OPENCODE_TOOLS_INSTALL_DIR / OPENCODE_TOOLS_FORCE_REINSTALL 保留两个仍在 opencode-acp-runtime.ts 使用的 export: - resolveProjectRoot() - getOpencodeConfigDir() 文件从 163 行缩到 53 行;顶部注释重写说明新架构(.opencode 签入 + Dockerfile COPY)并留下一句历史说明指向本次清理。 --- .../src/agent/runtime/opencode-installer.ts | 134 ++---------------- 1 file changed, 12 insertions(+), 122 deletions(-) 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 } -} From a144e6dcd0a7e7d3950df37d036f2e3282ff29bd Mon Sep 17 00:00:00 2001 From: yang Date: Sun, 10 May 2026 01:33:31 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/server/src/agent/coding-mode.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 打印。 From fa70b29eed8ceaeaf2f51d8617b2c7424bb2a412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=9D=99=E8=BF=9C?= <837317210@qq.com> Date: Tue, 12 May 2026 16:25:12 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20opencode=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .opencode/opencode.json | 143 +++++++++++++++++- README.md | 57 ++++--- docs/setup.md | 53 ++++++- .../src/agent/runtime/opencode-catalog.ts | 23 ++- scripts/opencode-setup.mjs | 67 +++++++- 5 files changed, 316 insertions(+), 27 deletions(-) diff --git a/.opencode/opencode.json b/.opencode/opencode.json index b5bcdbe..0f011b0 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -93,6 +93,147 @@ } } }, - "deepseek": {} + "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" + ] + } + }, + "deepseek-reasoner": { + "name": "DeepSeek Reasoner", + "tool_call": true, + "reasoning": true, + "limit": { + "context": 1000000, + "output": 384000 + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + } + }, + "deepseek-v4-flash": { + "name": "DeepSeek V4 Flash", + "tool_call": true, + "reasoning": true, + "limit": { + "context": 1000000, + "output": 384000 + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + } + }, + "deepseek-v4-pro": { + "name": "DeepSeek V4 Pro", + "tool_call": true, + "reasoning": true, + "limit": { + "context": 1000000, + "output": 384000 + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + } + } + } + }, + "kimi-for-coding": { + "npm": "@ai-sdk/anthropic", + "name": "Kimi For Coding", + "options": { + "baseURL": "https://api.kimi.com/coding/v1", + "apiKey": "{env:KIMI_API_KEY}" + }, + "models": { + "k2p6": { + "name": "Kimi K2.6", + "tool_call": true, + "reasoning": true, + "limit": { + "context": 262144, + "output": 32768 + }, + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + } + }, + "k2p5": { + "name": "Kimi K2.5", + "tool_call": true, + "reasoning": true, + "limit": { + "context": 262144, + "output": 32768 + }, + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + } + }, + "kimi-k2-thinking": { + "name": "Kimi K2 Thinking", + "tool_call": true, + "reasoning": true, + "limit": { + "context": 262144, + "output": 32768 + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + } + } + } + } } } diff --git a/README.md b/README.md index 9c93b96..07ed177 100644 --- a/README.md +++ b/README.md @@ -241,23 +241,39 @@ pnpm opencode:setup 该命令会: -1. 从 [models.dev](https://models.dev)(opencode 自身使用的 catalog)拉取 118+ provider 列表 -2. 引导选择要启用的 provider(deepseek / moonshot / openai / anthropic / zhipuai / …) -3. 提示输入对应的 API Key(如 `DEEPSEEK_API_KEY`) -4. 选择默认模型 -5. 把 provider 启用声明写入 `.opencode/opencode.json`(空对象风格,与 opencode 官方推荐一致) -6. 把 API Key 写入 `packages/server/.env` +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 +// .opencode/opencode.json(自动生成,字段从 models.dev 获取) { "$schema": "https://opencode.ai/config.json", "model": "deepseek/deepseek-chat", "provider": { - "deepseek": {}, - "moonshot": {} + "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"] } + } + // ... 其他模型 + } + } } } ``` @@ -265,31 +281,34 @@ pnpm opencode:setup ```bash # packages/server/.env 会追加 API Key DEEPSEEK_API_KEY=sk-*** -MOONSHOT_API_KEY=sk-*** ``` -`provider.: {}` 表示启用该 provider,其 `npm`/`api`/`name`/`models` 等元数据由 opencode -运行时从 models.dev 自动获取。这样做的好处: - -- 与 opencode 官方行为完全一致,不用冗余维护字段 -- models.dev 上游新增模型时自动生效,无需改项目配置 +> **为什么写完整字段而不是空对象?** opencode 子进程启动时也需要这些配置。如果只写 `{}`, +> 子进程要自己从 models.dev 拉 catalog 才知道 npm / baseURL / models 等信息,一旦拉取失败 +> (网络/超时)就无法正常工作。写入完整字段让配置自包含,不依赖运行时网络请求。 ### 高级:自定义 provider / 覆盖字段 如果需要: - 非 catalog 内置的 provider(如内网 LLM 网关、本地 Ollama) -- 覆盖 catalog 默认的 `baseURL` / `headers` +- 覆盖 catalog 默认的 `baseURL` / `headers`(如走国内镜像) - 用 `whitelist` / `blacklist` 限制要展示的模型 - 配置 variants(如 Anthropic 的 thinking 预算) 请参考 `.opencode/opencode.example.json` 和 [OpenCode 官方 providers 文档](https://opencode.ai/docs/zh-cn/providers/) -直接手动编辑 `.opencode/opencode.json`。setup 脚本只覆盖最常用的"启用 + 灌 key + 选默认 -model"三件事。 +直接手动编辑 `.opencode/opencode.json`。 + +> 提示:`opencode.json` 顶部的 `$schema` 字段让 VS Code / Cursor 等编辑器支持字段自动补全 +> 和悬停文档,编辑时按 Ctrl+Space 可查看所有可选字段。 ### 重新配置 / 新增 provider -`pnpm opencode:setup` 幂等,可多次运行。已存在的 provider / env key 不会被重复询问。 +`pnpm opencode:setup` 幂等,可多次运行: + +- **已存在的 provider** 不会被覆盖(避免丢失手动调整) +- **已设置的 env key** 不会被重复询问 +- **缺失 env 的 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/packages/server/src/agent/runtime/opencode-catalog.ts b/packages/server/src/agent/runtime/opencode-catalog.ts index 6ba1303..83d21ad 100644 --- a/packages/server/src/agent/runtime/opencode-catalog.ts +++ b/packages/server/src/agent/runtime/opencode-catalog.ts @@ -299,6 +299,14 @@ export function readOpencodeJson(opencodeConfigDir: string): OpencodeJson | 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", * 以保证最小可用(与改造前行为近似一致)。 */ @@ -312,9 +320,18 @@ export async function resolveModels(opts: { // 降级:catalog 不可用 + config 不可用 → 空列表 if (!catalog && !config) return [] - // catalog 不可用:把 config.provider 当作全部自定义 provider(需要用户填了完整字段) - const baseCatalog: Catalog = catalog ?? {} - const merged = mergeProviders(baseCatalog, (config?.provider as Record) ?? {}) + 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, diff --git a/scripts/opencode-setup.mjs b/scripts/opencode-setup.mjs index f11947e..8fd8351 100644 --- a/scripts/opencode-setup.mjs +++ b/scripts/opencode-setup.mjs @@ -231,7 +231,67 @@ function writeOpencodeJson(config) { fs.writeFileSync(OPENCODE_JSON, JSON.stringify(ordered, null, 2) + '\n') } -// ─── UI flow ──────────────────────────────────────────────────────────── +/** + * 从 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 长度对齐(中文宽度不严格处理,够用) @@ -613,10 +673,11 @@ async function main() { ? await pickDefaultModel(catalog, allProvidersForModel, existing.model) : existing.model || '' - // 8. 构造新 opencode.json:已选 provider 用 `{}`,保留其余已有 provider + // 8. 构造新 opencode.json:从 catalog 取完整字段写入,保留已有 provider const nextProvider = { ...existing.provider } for (const it of selected) { - if (!nextProvider[it.id]) nextProvider[it.id] = {} + if (nextProvider[it.id]) continue // 已存在的不覆盖(用户可能手动调过) + nextProvider[it.id] = buildProviderConfig(catalog, it.id) } const nextConfig = { ...existing, From 8a12bab28cfd41178c725e1418fe5beb8a51d217 Mon Sep 17 00:00:00 2001 From: coylexie Date: Fri, 15 May 2026 17:47:07 +0800 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20token=20hub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/opencode-setup.mjs | 73 +++++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/scripts/opencode-setup.mjs b/scripts/opencode-setup.mjs index 8fd8351..75c6dd3 100644 --- a/scripts/opencode-setup.mjs +++ b/scripts/opencode-setup.mjs @@ -22,6 +22,11 @@ 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 ─────────────────────────────────────────────────────────── @@ -447,6 +452,9 @@ function listProviders(catalog, existingProvider, envNow) { } }) .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) @@ -591,6 +599,62 @@ async function pickDefaultModel(catalog, selected, currentDefault) { 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() { @@ -609,9 +673,16 @@ async function main() { process.exit(1) } + const envNow = parseEnvFile(SERVER_ENV_FILE) + const envId = envNow['TCB_ENV_ID'] + const secretId = envNow['TCB_SECRET_ID'] + const secretKey = envNow['TCB_SECRET_KEY'] + + // 添加 cloudbase 模型 + catalog['cloudbase'] = await getCloudBaseModelConfig(envId, secretId, secretKey) + // 2. 读现状 const existing = readOpencodeJson() - const envNow = parseEnvFile(SERVER_ENV_FILE) // 3. 扫描已有 provider,识别缺失 env const existingRows = inspectExistingProviders(existing.provider, catalog, envNow) From bce79711ef7af3c29c0d0ac8a247ba46ac043bc3 Mon Sep 17 00:00:00 2001 From: coylexie Date: Wed, 20 May 2026 14:45:14 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=20opencode=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E9=85=8D=E7=BD=AE=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .opencode/opencode.example.json | 71 +--------- .opencode/opencode.json | 239 -------------------------------- scripts/opencode-setup.mjs | 56 +++++--- 4 files changed, 43 insertions(+), 324 deletions(-) delete mode 100644 .opencode/opencode.json 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 8a63cea..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:MIMO_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 0f011b0..0000000 --- a/.opencode/opencode.json +++ /dev/null @@ -1,239 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "model": "deepseek/deepseek-v4-flash", - "provider": { - "mimo": { - "npm": "@ai-sdk/openai-compatible", - "name": "MiMo", - "options": { - "baseURL": "{env:MIMO_API_ENDPOINT}", - "apiKey": "{env:MIMO_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" - ] - } - } - } - }, - "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" - ] - } - }, - "deepseek-reasoner": { - "name": "DeepSeek Reasoner", - "tool_call": true, - "reasoning": true, - "limit": { - "context": 1000000, - "output": 384000 - }, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - } - }, - "deepseek-v4-flash": { - "name": "DeepSeek V4 Flash", - "tool_call": true, - "reasoning": true, - "limit": { - "context": 1000000, - "output": 384000 - }, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - } - }, - "deepseek-v4-pro": { - "name": "DeepSeek V4 Pro", - "tool_call": true, - "reasoning": true, - "limit": { - "context": 1000000, - "output": 384000 - }, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - } - } - } - }, - "kimi-for-coding": { - "npm": "@ai-sdk/anthropic", - "name": "Kimi For Coding", - "options": { - "baseURL": "https://api.kimi.com/coding/v1", - "apiKey": "{env:KIMI_API_KEY}" - }, - "models": { - "k2p6": { - "name": "Kimi K2.6", - "tool_call": true, - "reasoning": true, - "limit": { - "context": 262144, - "output": 32768 - }, - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - } - }, - "k2p5": { - "name": "Kimi K2.5", - "tool_call": true, - "reasoning": true, - "limit": { - "context": 262144, - "output": 32768 - }, - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - } - }, - "kimi-k2-thinking": { - "name": "Kimi K2 Thinking", - "tool_call": true, - "reasoning": true, - "limit": { - "context": 262144, - "output": 32768 - }, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - } - } - } - } - } -} diff --git a/scripts/opencode-setup.mjs b/scripts/opencode-setup.mjs index 75c6dd3..81868d0 100644 --- a/scripts/opencode-setup.mjs +++ b/scripts/opencode-setup.mjs @@ -660,26 +660,35 @@ async function getCloudBaseModelConfig(envId, secretId, secretKey) { async function main() { logSection('OpenCode Provider 配置') - // 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) - } - const envNow = parseEnvFile(SERVER_ENV_FILE) const envId = envNow['TCB_ENV_ID'] const secretId = envNow['TCB_SECRET_ID'] const secretKey = envNow['TCB_SECRET_KEY'] - // 添加 cloudbase 模型 - catalog['cloudbase'] = await getCloudBaseModelConfig(envId, secretId, secretKey) + // 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() @@ -714,12 +723,19 @@ async function main() { renderProviderTable(items) // 5. 用户选 - const selected = await pickProviders(items) - if (selected.length === 0 && Object.keys(missingEnvUpdates).length === 0) { - log('没有任何变更,退出', 'info') - process.exit(0) + // 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) { @@ -747,7 +763,7 @@ async function main() { // 8. 构造新 opencode.json:从 catalog 取完整字段写入,保留已有 provider const nextProvider = { ...existing.provider } for (const it of selected) { - if (nextProvider[it.id]) continue // 已存在的不覆盖(用户可能手动调过) + // if (nextProvider[it.id]) continue // 已存在的不覆盖(用户可能手动调过) nextProvider[it.id] = buildProviderConfig(catalog, it.id) } const nextConfig = {