oh-my-openagent 模型能力解析:在 Provider 元数据查找之前先归一化模型 ID 后缀
【免费下载链接】oh-my-openagentOmO: Just type "mass ulw" keyword with your prompt. Now you are the master of graph engineering.项目地址: https://gitcode.com/gh_mirrors/oh/oh-my-openagent
本文基于 oh-my-openagent 仓库中一份真实的缺陷修复证据文档 codex-p2-suffix-provider-lookup-20260804.md,完整复盘model-core包中“模型能力查找”的一次修复:当用户请求的模型 ID 带有推理档位后缀(如o3:high)或同 Provider 前缀(如custom/future-model:high)时,Provider 缓存只精确匹配裸模型 ID,导致supportsTemperature等元数据查不到,最终把用户显式配置的 temperature 错误地从出站请求中删除。读完本文,你能理解一次“精确匹配 vs 规范化 ID”的错位是如何被定位、如何用有序候选列表修复的,以及后续五轮跟进(前缀剥离、快照解析、别名归一化)各自钉住的行为边界,并能在仓库中用 bun 测试与真实chat.params驱动脚本复现全部验证。
缺陷背景:o3:high为什么找不到o3
OmO 的model-core包负责解析一个模型到底“支持什么”:是否支持 temperature、top_p、推理档位、思考模式、工具调用、输出上限等。解析结果直接决定 OpenCode 插件边界(chat.params处理函数)是否把用户配置的temperature透传给 Provider——对明确不支持该参数的模型,OmO 会主动删除该字段,避免请求被上游拒绝。
问题出在 ID 形态不一致上。请求侧的模型 ID 常常带着推理档位后缀,仓库的 model-string-parser.ts 中的parseVariantFromModelID(第 3-37 行)识别三种后缀形态:
- 冒号形式:
o3:high - 括号形式:
o3(high) - 空格形式:
o3 high
也就是说,同一个逻辑模型在系统里会以多种字符串形态出现。而 Provider 缓存的查找端却是精确匹配:connected-providers-cache.ts 中的findProviderModelMetadata逐条遍历该 Provider 上报的模型列表,用entry === modelID(第 254 行)或entry.id === modelID(第 260 行)做等值比较。Provider 通常只上报裸 ID(例如o3),于是请求o3:high时,"o3:high" === "o3"永远不成立,元数据查找落空,supportsTemperature保持undefined。
在修复前的代码里,getModelCapabilities把原始的input.modelID直接传给providerCache.findProviderModelMetadata;而同一次解析中的快照查找与模型族(family)检测却使用剥离后缀后的规范化 ID。证据文档对这一错位的原始描述是:
get-model-capabilities.tspassed the RAWinput.modelIDtoproviderCache.findProviderModelMetadata, while the snapshot lookup and family detection both use the canonical id.
这个 bug 为什么在这个 PR 之前“无害”?因为此前supportsTemperature查不到时就是undefined,处理逻辑会保留用户配置的 temperature,行为上没有可见错误。但同一个 PR 正在引入“族回退”(family fallback):当元数据缺失时,把判断权交给基于模型族命名的启发式推断。于是undefined的含义从“保持原样”变成了“交给启发式裁决”——一个 Provider 明明显式上报了裸模型o3支持 temperature,仅因为请求带了:high后缀,其配置的 temperature 就会被删除。显式的 Provider 元数据必须优先于族推断,这就是本次修复要守住的原则。
修复本体:先试精确 ID,再退回剥离后缀的形态
修复落在 get-model-capabilities.ts。核心是一个“有序候选列表”:
get-model-capabilities.ts 第 55-71 行:
function buildLookupCandidates(providerID: string, modelID: string): string[] { const bareModelID = parseVariantFromModelID(modelID, { allowMaxSuffix: true }).modelID const candidates = [ modelID, stripSameProviderPrefix(providerID, modelID), bareModelID, stripSameProviderPrefix(providerID, bareModelID), ] const uniqueCandidates: string[] = [] const seen = new Set<string>() for (const candidate of candidates) { if (!candidate || seen.has(candidate)) continue seen.add(candidate) uniqueCandidates.push(candidate) } return uniqueCandidates }顺序设计是刻意保留“更具体者优先”:
- 原样请求 ID——保证一个显式带后缀的缓存条目(Provider 真的上报了
o3:high)仍然压过裸o3条目; - 剥离同 Provider 前缀后的请求 ID——应对
custom/future-model:high这种“Provider 自己名前缀”的形态; - 剥离后缀的裸 ID——修复主体,让
o3:high命中o3; - 裸 ID 再剥离同 Provider 前缀——兜底
custom/future-model这类无后缀的前缀形态。
其中stripSameProviderPrefix(第 48-53 行)只在前缀确实等于providerID时才剥离:请求custom/...时剥掉custom/,但other/future-model:high在customProvider 下保持不解析,避免跨 Provider 的 ID 串味。
查找函数 findProviderMetadata(第 76-87 行) 按候选顺序逐个调用findProviderModelMetadata,第一个命中即返回:
function findProviderMetadata( providerCache: ProviderCache | undefined, providerID: string, modelID: string, ): ModelMetadata | undefined { if (!providerCache) return undefined for (const candidate of buildLookupCandidates(providerID, modelID)) { const match = providerCache.findProviderModelMetadata(providerID, candidate) if (match) return match } return undefined }证据文档记录该阶段的产品改动为 1 个文件、+20/-1。
RED / GREEN 证据与钉住的行为边界
证据目录采用严格的 RED(回退产品改动、保留测试)/ GREEN(恢复修复)对照法。首轮 RED 输出(bun test v1.3.14,Windows 11 采集)显示 2 pass / 2 fail,失败点正是期望值true收到undefined:
packages/model-core/src/model-capabilities-suffixed-provider-lookup.test.ts: 27 | expect(capabilities.supportsTemperature).toBe(true) error: expect(received).toBe(expected) Expected: true Received: undefined 2 pass / 2 fail, 4 expect() calls恢复修复后 4 pass / 0 fail,随后 model-core 全量套件 342 pass / 0 fail,typecheck:packages退出码 0。
这些断言最终沉淀在 model-capabilities-suffixed-provider-lookup.test.ts,共 8 个测试,把每一轮跟进的边界都变成了回归护栏。值得注意的四条:
- 精确后缀条目优先(第 44-61 行):缓存里同时存在
o3:high(temperature: false)和o3(temperature: true)且两者矛盾时,请求o3:high必须得到false——“explicit entry wins”不被归一化破坏; - 两形态都不命中则保持未解析(第 63-72 行):缓存只有
gpt-4o而请求o3:high时,supportsTemperature必须是undefined,即“不引入新的推断”; - 同 Provider 前缀可剥离、异 Provider 前缀不可剥离(第 74-103 行):
custom/future-model:high在customProvider 下解析出false且来源诊断为runtime;other/future-model:high在customProvider 下必须保持undefined; - 快照侧同样生效(第 105-160 行):带
openai/前缀或带:high后缀的请求都能命中捆绑快照(bundled snapshot)里的裸条目;当anthropic/claude-opus-4.8(temperature: true)与裸claude-opus-4.8(temperature: false)两个快照条目矛盾时,Provider 限定形式必须胜出。
五轮跟进:候选列表如何扩展到快照与别名
首轮修复只解决了 Provider 缓存这一路。证据文档记录了随后(2026-08-05 一天内)五轮跟进,每一轮都由一次评审意见或一次 CI 失败触发,全部用 RED/GREEN 对照收口。
1. 同 Provider 前缀 ID(候选列表从 2 项扩到 4 项)
前缀形态custom/future-model:high在裸缓存只有future-model时依然漏检。候选列表按上文四步顺序扩展后,一次公开 API 驱动观察到完整查找轨迹:
{"lookups":["custom/future-model:high","future-model:high","custom/future-model","future-model"],"supportsTemperature":false,"source":"runtime"}四个候选依次尝试、最后一步命中裸 ID,supportsTemperature得到false且来源是runtime(Provider 缓存),而不是启发式。
2. 带后缀的快照 ID(运行时快照与捆绑快照同用候选列表)
同样的有序候选随后被用于解析运行时快照(runtime snapshot)与捆绑快照(bundled snapshot)条目。优先级规则:运行时快照条目优先于捆绑快照;同一快照内精确带后缀条目优先于裸条目。这一轮修复前,openai/gpt-5.6-sol:high会漏掉捆绑快照中gpt-5.6-sol的 temperature 元数据(RED:6 pass / 1 fail);修复后真实 OpenCodechat.params驱动观察到:
[{"modelID":"gpt-5.6-sol:high","temperatureSent":false}, {"modelID":"gpt-5.4:high","temperatureSent":false}, {"modelID":"o3-deep-research","temperatureSent":true}]带:high的推理模型不再发 temperature,而o3-deep-research支持 temperature 正常发送。
3. Provider 限定快照条目优先
对应实现是 findSnapshotEntry(第 89-122 行):它先构造providerSpecificCandidates(形如anthropic/claude-opus-4.8及其裸 ID),再拼上generalCandidates(无 Provider 前缀的形态),去重后按此顺序在snapshot.models中查找。这样当“限定形式”和“裸形式”两个快照条目并存且结论相反时,Provider 专属能力被保留。该轮 RED 的失败描述很有代表性:both Anthropic forms selected bare temperature:false instead of provider-specific temperature:true。GREEN 后驱动观察到 anthropic 直连与anthropic/前缀两种请求都发送 temperature,而azure-anthropic(自定义 Provider 快照中无对应限定条目)正确地不发送:
[{"providerID":"anthropic","modelID":"claude-opus-4.8:high","temperatureSent":true}, {"providerID":"anthropic","modelID":"anthropic/claude-opus-4.8:high","temperatureSent":true}, {"providerID":"azure-anthropic","modelID":"claude-opus-4.8:high","temperatureSent":false}]4. 集成回归:别名应优先解析到 canonical 条目
与前一轮 CI 合并后,OpenAI fast-alias 契约在三个平台全部失败:Provider 限定的请求 ID 在resolveModelIDAlias()已经选出 canonical 模型的情况下仍然胜出。修复让快照查找顺序取决于归一化结果(getModelCapabilities 第 144-146 行):
const snapshotModelIDs = canonicalization.source === "canonical" ? [input.modelID, canonicalization.canonicalModelID] : [canonicalization.canonicalModelID, input.modelID]- 请求 ID 本身是 canonical 的:保持“请求形式 / 后缀形式优先”;
- 请求 ID 命中精确或模式别名:canonical 目标(含其 Provider 限定形式)排到别名自身形式之前。
对应测试 model-capabilities-openai-fast-aliases.test.ts 验证了gpt-5.6-sol-fast等别名继承 canonical 快照条目、诊断标记为alias-backed+ruleID: openai-gpt-fast-service-tier-alias,且无关 Provider(github-copilot)或无关后缀(-fast-preview)不被误伤(保持heuristic-backed)。别名注册表本体在 model-capability-aliases.ts:精确规则(EXACT_ALIAS_RULES)与模式规则(PATTERN_ALIAS_RULES,含 Provider 作用域与子 Provider 主机白名单)两条通路,由resolveModelIDAlias统一裁决。
5. 剥离后缀后再走别名注册表
最后一轮针对“直接别名解析未命中、但裸形态能命中”的漏网场景:resolveCapabilityModelAlias(第 124-133 行)先做直接解析;若结果是canonical(未命中任何别名)且 ID 带识别出的变体后缀,就把裸形态再送一次别名注册表。返回的能力记录保留原始请求 ID(requestedModelID),同时用 canonical 别名目标做快照查找。该轮 RED 失败描述:OpenAI and Vercel suffixed fast aliases stayed canonical and missed gpt-5.6-sol snapshot metadata。GREEN 后真实驱动观察到gpt-5.6-sol-fast:high(openai)与openai/gpt-5.6-sol-fast:high(vercel 子 Provider 前缀)都正确继承 canonical 元数据、不发 temperature:
[{"providerID":"openai","modelID":"gpt-5.6-sol-fast:high","temperatureSent":false}, {"providerID":"vercel","modelID":"openai/gpt-5.6-sol-fast:high","temperatureSent":false}]可观测性:诊断字段让每一次裁决可追溯
getModelCapabilities返回的diagnostics把每个能力项的来源标注为runtime/override/runtime-snapshot/bundled-snapshot/heuristic/none,并给出整体resolutionMode(snapshot-backed/heuristic-backed/alias-backed/unknown),见 第 213-252 行。这正是本文所有“来源断言”(如diagnostics.supportsTemperature.source === "runtime")能成立的机制:修复不仅改变了结果,还让“这个温度结论是 Provider 说的、快照说的,还是启发式猜的”成为可断言、可调试的一等公民。对维护者而言,这也意味着未来任何新的 ID 形态(新的后缀、新的 Provider 限定写法)一旦漏解析,都会在none/heuristic来源上暴露,而不是静默改变出站请求。
回归验证方式与复现路径
证据文档记录的完整收口命令(最后一轮 GREEN,可在仓库根目录按相同方式执行):
bun test packages/model-core/src/model-capabilities-suffixed-provider-lookup.test.ts # 9 pass, 0 fail, 15 expect calls bun test packages/model-core/src/model-capabilities-openai-fast-aliases.test.ts # 5 pass, 0 fail, 18 expect calls bun test packages/model-core/src # 351 pass, 0 fail, 680 expect calls bun run typecheck # exit 0 bun run build # exit 0除单测外,证据目录还保留了集成驱动 chat-params-driver.ts:它直接调用真实chat.params处理函数,构造“自定义 Provider 托管 Claude Opus 4.8、目录中查无此模型且配置了 temperature”的原始缺陷场景,断言 Opus 4.8 系请求不发 temperature、对照组openai/gpt-4o发 temperature。同目录下的 red-green-reframed-20260804.txt、live-request-BEFORE.txt 与 live-request-AFTER.txt 提供了修复前后的真实请求对照,可作为排查同类“元数据查找错位”问题时的取证模板。
小结
这次修复的技术核心可以用一句话概括:Provider 能力查找的键必须和快照、族检测使用同一套规范化候选,且候选顺序必须让“更具体、更显式”的条目始终优先。由此得到三条可迁移的工程经验:其一,当系统内同一实体存在多种字符串形态(后缀、前缀、别名)时,精确匹配查找是隐性 bug 的高发区,修复手段是集中式候选列表而非散落的字符串裁剪;其二,undefined在决策链中的语义(保持原样还是移交启发式)会随着其他改动悄悄改变,显式元数据优先于推断必须写成测试;其三,每一轮边界扩展(前缀、快照、别名)都配有可失败的 RED 证据与回归护栏,使“优先序”这种难以肉眼检验的行为拥有了可执行的定义。
【免费下载链接】oh-my-openagentOmO: Just type "mass ulw" keyword with your prompt. Now you are the master of graph engineering.项目地址: https://gitcode.com/gh_mirrors/oh/oh-my-openagent
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考