fix(channels): 同步契约按内核源码逐条核对并修正

对照 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 <noreply@anthropic.com>
This commit is contained in:
晴天
2026-07-05 12:44:41 -07:00
parent 79863ffddd
commit bc55cf4829
5 changed files with 70 additions and 22 deletions

View File

@@ -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)

View File

@@ -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-CompatibleOPENAI_BASE_URL + OPENAI_API_KEY
* 覆盖官方 OpenAI、聚合网关、NewAPI、本地 Ollama 等一切 OpenAI 兼容端点)
* - anthropic-messages → 'anthropic'ANTHROPIC_API_KEY
* - google-generative-ai → 'gemini'(内核经 OpenAI 兼容端点接入 GeminiGOOGLE_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 providerAPI Key 型 + transport 匹配),不支持返回 null */
/** 解析渠道对应的 Hermes providerAPI 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) {
/**
* 同步到 HermesAPI 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 : '',
})

View File

@@ -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', '同步到助手'),

View File

@@ -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
? `<span style="color:var(--success)">✓ ${esc(item.label)}</span>`
: `<span style="color:var(--text-tertiary)" title="${attr(item.hint)}">— ${esc(item.label)}</span>`)
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) {
<select class="form-input" id="mch-api-type">
${API_TYPES.map(item => `<option value="${attr(item.value)}" ${item.value === draft.apiType ? 'selected' : ''}>${esc(item.label)}</option>`).join('')}
</select>
<div class="form-hint" id="mch-compat">${renderCompatHint(draft.apiType)}</div>
</div>
<div class="form-group">
<label class="form-label" for="mch-api-key">${t('modelChannels.apiKey')}</label>
@@ -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)
}
})
// 模型列表变化时同步默认模型下拉选项

View File

@@ -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 并保留未知字段', () => {