From bc55cf4829eacc329c329c835c15f4cc6aa63f9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sun, 5 Jul 2026 12:44:41 -0700 Subject: [PATCH] =?UTF-8?q?fix(channels):=20=E5=90=8C=E6=AD=A5=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E6=8C=89=E5=86=85=E6=A0=B8=E6=BA=90=E7=A0=81=E9=80=90?= =?UTF-8?q?=E6=9D=A1=E6=A0=B8=E5=AF=B9=E5=B9=B6=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照 OpenClaw(zod-schema.core.ts / model-ref-shared.ts)与 Hermes (config.py / auth.py / model_normalize.py)内核源码验证同步写入契约, 修正三处不一致: - OpenClaw 模型条目恒写含 id/name 的完整对象(内核 strict schema 两字段必填,裸字符串有被拒风险),name 缺省回退为模型 ID; 模型引用按第一个 / 切分已确认正确,含斜杠模型 ID 无需处理 - Hermes model.default 改写纯模型 ID:内核不解析 "provider/model" 前缀(provider 由 model.provider 单独指定),原先拼接前缀会写出 内核不认识的模型名 - Hermes 回退 provider 按注册表真实 id 匹配:OpenAI 兼容 → 'custom' (注册表无独立 'openai' API-Key id),Gemini → 'gemini'(内核经 OpenAI 兼容端点接入,按 transport 匹配会误判为不支持); 自定义 Base URL 仅对 OpenAI 兼容渠道写入,避免破坏 anthropic/gemini 的专用端点格式 另:渠道编辑器新增「可同步到」实时适配提示(切换 API 类型即显示 OpenClaw / Hermes / 助手的支持情况);测试断言更新为核对后的契约 (428 通过)。发现的存量问题已记录:最新内核 api 枚举中 openai-codex-responses 已改名,共享 API_TYPES 需对照推荐稳定版处理。 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + src/lib/model-channels.js | 50 +++++++++++++++++---------- src/locales/modules/model-channels.js | 1 + src/pages/model-channels.js | 22 ++++++++++++ tests/model-channels.test.js | 18 +++++++--- 5 files changed, 70 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14f2e43..f20342e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - **配置步骤错误内联展示** — API Key / 配置保存错误改为页面内醒目提示(原为一闪而过的 toast),并提示可用「获取模型列表」验证 Key - **模型配置入口统一** — OpenClaw 模型配置页、Hermes 安装向导配置步、晴辰助手 API 设置均新增「前往模型渠道」引导入口,模型接入配置收口到模型渠道页统一维护;渠道卡片新增「绑定状态」标题,与 OpenClaw / Hermes / 助手的同步绑定关系一目了然 +- **同步契约按内核源码逐条核对** — 渠道同步写入的字段与两个内核的实际解析逻辑对齐:OpenClaw 模型条目恒写含 id/name 的完整对象(内核 strict 校验);Hermes 的默认模型写纯模型 ID(内核不解析 provider 前缀)、OpenAI 兼容渠道回退到 custom provider、Gemini 渠道正确接入(内核经 OpenAI 兼容端点);渠道编辑器新增「可同步到」实时提示,选择 API 类型即知道哪些内核适用 ### 修复 (Fixes) diff --git a/src/lib/model-channels.js b/src/lib/model-channels.js index 4c3132e..1092110 100644 --- a/src/lib/model-channels.js +++ b/src/lib/model-channels.js @@ -12,11 +12,18 @@ import { api } from './tauri-api.js' export const ASSISTANT_STORAGE_KEY = 'clawpanel-assistant' -/** 渠道 apiType → Hermes transport 与回退 provider(仅 API Key 型三族可同步) */ -export const HERMES_TRANSPORT_MAP = { - 'openai-completions': { transport: 'openai_chat', fallbackProvider: 'openai' }, - 'anthropic-messages': { transport: 'anthropic_messages', fallbackProvider: 'anthropic' }, - 'google-generative-ai': { transport: 'google_gemini', fallbackProvider: 'google' }, +/** + * 渠道 apiType → Hermes 回退 provider id(已按内核注册表逐一核对): + * - openai-completions → 'custom'(Custom OpenAI-Compatible:OPENAI_BASE_URL + OPENAI_API_KEY, + * 覆盖官方 OpenAI、聚合网关、NewAPI、本地 Ollama 等一切 OpenAI 兼容端点) + * - anthropic-messages → 'anthropic'(ANTHROPIC_API_KEY) + * - google-generative-ai → 'gemini'(内核经 OpenAI 兼容端点接入 Gemini,GOOGLE_API_KEY) + * 仅 API Key 型可同步;OAuth / 云 SDK 型仍走 Hermes 向导。 + */ +export const HERMES_TARGET_MAP = { + 'openai-completions': { fallbackProvider: 'custom' }, + 'anthropic-messages': { fallbackProvider: 'anthropic' }, + 'google-generative-ai': { fallbackProvider: 'gemini' }, } /** 晴辰助手支持的 apiType 族(见 assistant.js normalizeApiType) */ @@ -25,7 +32,7 @@ export const ASSISTANT_SUPPORTED_API_TYPES = [ ] export function hermesSyncSupported(channel) { - return Boolean(HERMES_TRANSPORT_MAP[channel?.apiType]) + return Boolean(HERMES_TARGET_MAP[channel?.apiType]) } export function assistantSyncSupported(channel) { @@ -80,13 +87,19 @@ export async function syncChannelToOpenclaw(channel, { setDefault = false } = {} const existing = asObject(config.models.providers[providerKey]) const existingModels = Array.isArray(existing.models) ? existing.models : [] + // 内核 ModelDefinitionSchema 为 strict 且 id/name 必填(zod-schema.core.ts): + // 恒写完整对象、name 缺省回退为 id,不写裸字符串;保留目标已有条目的 + // cost/reasoning 等扩展元数据(展开 prevObj) const models = (channel.models || []).map(model => { const prev = existingModels.find(e => (typeof e === 'string' ? e : e?.id) === model.id) const prevObj = prev && typeof prev === 'object' ? prev : {} - const merged = { ...prevObj, id: model.id } - if (model.name) merged.name = model.name + const merged = { + ...prevObj, + id: model.id, + name: model.name || prevObj.name || model.id, + } if (model.contextWindow) merged.contextWindow = model.contextWindow - return Object.keys(merged).length > 1 ? merged : model.id + return merged }) config.models.providers[providerKey] = { @@ -110,15 +123,14 @@ export async function syncChannelToOpenclaw(channel, { setDefault = false } = {} return { providerKey, modelCount: models.length } } -/** 解析渠道对应的 Hermes provider(API Key 型 + transport 匹配),不支持返回 null */ +/** 解析渠道对应的 Hermes provider(API Key 型;预设命中优先,否则用核对过的回退 id),不支持返回 null */ export async function resolveHermesTarget(channel) { - const mapping = HERMES_TRANSPORT_MAP[channel?.apiType] + const mapping = HERMES_TARGET_MAP[channel?.apiType] if (!mapping) return null const raw = await api.hermesListProviders().catch(() => null) const list = Array.isArray(raw) ? raw : (Array.isArray(raw?.providers) ? raw.providers : []) const usable = p => p && p.authType === 'api_key' - && p.transport === mapping.transport && Array.isArray(p.apiKeyEnvVars) && p.apiKeyEnvVars.length > 0 const byPreset = list.find(p => p.id === String(channel?.presetKey || '') && usable(p)) const fallback = list.find(p => p.id === mapping.fallbackProvider && usable(p)) @@ -127,7 +139,7 @@ export async function resolveHermesTarget(channel) { /** * 同步到 Hermes:API Key 写入对应环境变量(Hermes .env), - * 自定义 Base URL 写 baseUrlEnvVar;可选设为默认模型(config.yaml,自带备份)。 + * OpenAI 兼容渠道的自定义 Base URL 写 baseUrlEnvVar;可选设为默认模型(config.yaml,自带备份)。 */ export async function syncChannelToHermes(channel, { setDefault = false } = {}) { const target = await resolveHermesTarget(channel) @@ -137,18 +149,20 @@ export async function syncChannelToHermes(channel, { setDefault = false } = {}) await api.hermesEnvSet(target.apiKeyEnvVars[0], apiKey) + // 仅 OpenAI 兼容渠道写自定义端点:anthropic / gemini 在 Hermes 侧走各自的 + // transport 专用端点,渠道里的原生 API 地址格式不一定一致,误写会破坏请求 const targetBase = String(target.baseUrl || '').replace(/\/+$/, '') - const baseUrlDiffers = Boolean(channel.baseUrl) && channel.baseUrl !== targetBase + const baseUrlDiffers = channel.apiType === 'openai-completions' + && Boolean(channel.baseUrl) && channel.baseUrl !== targetBase if (baseUrlDiffers && target.baseUrlEnvVar) { await api.hermesEnvSet(target.baseUrlEnvVar, channel.baseUrl) } if (setDefault && channel.defaultModel) { - const modelDefault = channel.defaultModel.includes('/') - ? channel.defaultModel - : `${target.id}/${channel.defaultModel}` + // 内核核对结论:model.default 是纯模型 ID(含斜杠的聚合器模型 ID 原样保留), + // provider 由 model.provider 单独指定,内核不解析 "provider/model" 前缀 await api.hermesModelConfigSave({ - modelDefault, + modelDefault: channel.defaultModel, modelProvider: target.id, modelBaseUrl: baseUrlDiffers && !target.baseUrlEnvVar ? channel.baseUrl : '', }) diff --git a/src/locales/modules/model-channels.js b/src/locales/modules/model-channels.js index ac54067..db92319 100644 --- a/src/locales/modules/model-channels.js +++ b/src/locales/modules/model-channels.js @@ -43,6 +43,7 @@ export default { baseUrlRequired: _('请填写以 http:// 或 https:// 开头的接口地址', 'Base URL must start with http:// or https://', '請填寫以 http:// 或 https:// 開頭的介面位址'), modelCount: _('{count} 个模型', '{count} models', '{count} 個模型'), bindings: _('绑定状态', 'Bindings', '綁定狀態'), + compatLabel: _('可同步到', 'Can sync to', '可同步到'), syncOpenclaw: _('同步到 OpenClaw', 'Sync to OpenClaw', '同步到 OpenClaw'), syncHermes: _('同步到 Hermes', 'Sync to Hermes', '同步到 Hermes'), syncAssistant: _('同步到助手', 'Sync to Assistant', '同步到助手'), diff --git a/src/pages/model-channels.js b/src/pages/model-channels.js index 17c265a..394c8c2 100644 --- a/src/pages/model-channels.js +++ b/src/pages/model-channels.js @@ -37,6 +37,20 @@ function apiTypeLabel(value) { return API_TYPES.find(item => item.value === value)?.label || value } +// 编辑器内的内核适配性提示:该 API 类型能同步到哪些目标 +function renderCompatHint(apiType) { + const fake = { apiType } + const targets = [ + { label: t('modelChannels.targetOpenclaw'), ok: true, hint: '' }, + { label: t('modelChannels.targetHermes'), ok: hermesSyncSupported(fake), hint: t('modelChannels.syncHermesUnsupported') }, + { label: t('modelChannels.targetAssistant'), ok: assistantSyncSupported(fake), hint: t('modelChannels.syncAssistantUnsupported') }, + ] + const parts = targets.map(item => item.ok + ? `✓ ${esc(item.label)}` + : `— ${esc(item.label)}`) + return `${esc(t('modelChannels.compatLabel'))}: ${parts.join(' · ')}` +} + export async function render() { const page = document.createElement('div') page.className = 'page channels-hub-page' @@ -231,6 +245,7 @@ function renderEditor(state) { +
${renderCompatHint(draft.apiType)}
@@ -294,8 +309,15 @@ function bindEvents(page, state) { if (apiTypeSelect && preset.api) apiTypeSelect.value = preset.api const nameInput = page.querySelector('#mch-name') if (nameInput && !nameInput.value.trim()) nameInput.value = preset.label + const compat = page.querySelector('#mch-compat') + if (compat && preset.api) compat.innerHTML = renderCompatHint(preset.api) } } + // API 类型变化 → 实时刷新内核适配性提示 + if (event.target.id === 'mch-api-type' && state.editing) { + const compat = page.querySelector('#mch-compat') + if (compat) compat.innerHTML = renderCompatHint(event.target.value) + } }) // 模型列表变化时同步默认模型下拉选项 diff --git a/tests/model-channels.test.js b/tests/model-channels.test.js index 20afa95..e20acc6 100644 --- a/tests/model-channels.test.js +++ b/tests/model-channels.test.js @@ -32,11 +32,21 @@ test('渠道读取只返回掩码,写入支持保留旧 Key 哨兵', () => { assert.match(devApi, /isChannelKeepSentinel/, 'dev-api 必须实现相同的哨兵语义') }) -test('Hermes 同步仅覆盖 API Key 型三类 transport', () => { - assert.match(lib, /'openai-completions':\s*\{\s*transport:\s*'openai_chat'/, 'openai transport 映射缺失') - assert.match(lib, /'anthropic-messages':\s*\{\s*transport:\s*'anthropic_messages'/, 'anthropic transport 映射缺失') - assert.match(lib, /'google-generative-ai':\s*\{\s*transport:\s*'google_gemini'/, 'gemini transport 映射缺失') +test('Hermes 同步契约与内核注册表一致(已按内核源码核对)', () => { + // 回退 provider id 必须是注册表真实存在的 API Key 型 id + assert.match(lib, /'openai-completions':\s*\{\s*fallbackProvider:\s*'custom'/, 'OpenAI 兼容渠道应回退到 custom provider') + assert.match(lib, /'anthropic-messages':\s*\{\s*fallbackProvider:\s*'anthropic'/, 'Anthropic 渠道应回退到 anthropic provider') + assert.match(lib, /'google-generative-ai':\s*\{\s*fallbackProvider:\s*'gemini'/, 'Gemini 渠道应回退到 gemini provider(内核经 OpenAI 兼容端点接入)') assert.match(lib, /authType === 'api_key'/, 'OAuth/SDK 型 provider 必须被排除在渠道同步之外') + // 内核不解析 "provider/model" 前缀:model.default 必须写纯模型 ID + assert.match(lib, /modelDefault:\s*channel\.defaultModel,/, 'model.default 必须是纯模型 ID,不得拼接 provider 前缀') + // 自定义端点只对 OpenAI 兼容渠道生效,避免破坏 anthropic/gemini 的专用端点 + assert.match(lib, /channel\.apiType === 'openai-completions'\s*&&\s*Boolean\(channel\.baseUrl\)/, '自定义 Base URL 仅限 OpenAI 兼容渠道') +}) + +test('OpenClaw 模型条目恒写完整对象(内核 strict schema 要求 id/name 必填)', () => { + assert.match(lib, /name:\s*model\.name\s*\|\|\s*prevObj\.name\s*\|\|\s*model\.id/, '模型条目 name 必填,缺省回退为 id') + assert.doesNotMatch(lib, /:\s*model\.id\s*\n\s*\}\)\s*$/m, '不得写裸字符串模型条目') }) test('OpenClaw 同步只 upsert 单个 provider 并保留未知字段', () => {