mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-07-12 16:01:56 +08:00
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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 : '',
|
||||
})
|
||||
|
||||
@@ -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', '同步到助手'),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
// 模型列表变化时同步默认模型下拉选项
|
||||
|
||||
@@ -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 并保留未知字段', () => {
|
||||
|
||||
Reference in New Issue
Block a user