From 8b40b4a276b25ed65631465e2e6d6faa35b19519 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 08:05:55 +0000 Subject: [PATCH 1/2] feat(codex): inbound model_mappings for codex-auto-review (#526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CPR-style routing.codex.model_mappings applied before platform detect, with product defaults codex-auto-review→gpt-6-luna and empty-target fallback to gpt-6.1-sol. Validates GPT targets, preserves requested vs upstream logs, docs and unit tests. Co-authored-by: ted --- CHANGELOG.md | 4 + docs/PANEL_API.md | 11 +- src/lib/protocol/codex-model-mapping.mjs | 129 +++++++++++++++++++++++ src/lib/protocol/codex-route.mjs | 5 + src/lib/protocol/handle-protocol.mjs | 19 ++++ test/unit/codex-model-mapping.test.mjs | 87 +++++++++++++++ 6 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 src/lib/protocol/codex-model-mapping.mjs create mode 100644 test/unit/codex-model-mapping.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index f16de4cd..0bf5f1fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,12 @@ ## 未发布 +<<<<<<< HEAD - 保留路径不再给没有可用签名的 thinking 补 `signature: ""`:缺失、空、短于 24、dummy 的删掉;签名可用的留,空正文也留。这一支里的 `redacted_thinking` 不看 data 和签名,原样留;不走保留路径时仍按原规则删。5.5 的非保留路径仍接受短的非 dummy 签名。没有按账号归属过滤签名。 - 协议页新增「thinking 显示」,`routing.compatibility.thinking_display` 默认 `summarized`。调用方打开 thinking 却要求隐藏(`omitted`、`updates`,或默认隐藏的模型上没写 display)时,出站改成 `summarized`;显式 `passthrough` 保留调用方的值。已保存的 `routing.json` 不写这个键也按 summarized 生效。 +======= +- Codex 入站在平台探测前支持 CPR 式 `routing.codex.model_mappings` 精确模型映射;默认 `codex-auto-review` → `gpt-6-luna`(清空该键时回退 `gpt-6.1-sol`)。日志保留 `requested_model` / `upstream_model`;非法或非 GPT 目标 400,不 hop Anthropic。(#526) +>>>>>>> 55afa54 (feat(codex): inbound model_mappings for codex-auto-review (#526)) ## 1.3.199 — 2026-10-19 diff --git a/docs/PANEL_API.md b/docs/PANEL_API.md index 2390218d..968f15ac 100644 --- a/docs/PANEL_API.md +++ b/docs/PANEL_API.md @@ -219,8 +219,17 @@ Claude PATCH `max_sessions` 返回 400 `claude_max_sessions_forbidden`;OpenAI | `codex.catalog_client_version` | `auto` | 模型目录同步的客户端版本,与推理 UA 分开 | | `codex.client_features.enabled` | `false` | 显式开启后上送槽位独立安装 UUID 与官方 `client_metadata`;不使用 VM/硬件指纹 | | `codex.analytics.enabled` | `false` | 显式开启后独立上报运行环境 Analytics,不是推理成功的前提 | +| `codex.model_mappings` | 见下 | 入站 `model` 精确别名 → 上游 GPT id(对标 [codex-proxy-rs `modelMappings`](https://github.com/zyycn/codex-proxy-rs));保存后随 `routing.json` 热读,无需重启 Node | -任一 WS 闸关闭时,新 WS 握手被拒绝,HTTP/SSE 入口继续可用且上游强制 HTTP。传输、UA 与隐私选项通过每次内部 envelope 的 `options` 生效,不为修改这些设置而重启共享 kernel;已开始的请求保留其选定策略。 +**`codex.model_mappings`(MVP)**:键为客户端请求的模型 id(精确匹配,含 Codex CLI 硬编码的 `codex-auto-review`),值为出站 GPT 模型 id。未配置或空对象时仍应用内置默认:`codex-auto-review` → `gpt-6-luna`;若显式将该键设为空字符串,则回退 `gpt-6.1-sol`。其它别名需自行添加映射;未命中键的请求模型保持原样(不会默认透传未知 id)。目标必须是已启用的 `gpt*`;否则 400(`codex_model_mapping_invalid` / `model_disabled`),且不会误判为 Anthropic。映射在 `detectInboundPlatform` 之前执行;日志 `requested_model` 保留客户端值,`upstream_model` 为映射结果,计费按上游模型。 + +示例(仅文档,可按需合并进 `routing.json`): + +```json +{"codex":{"model_mappings":{"codex-auto-review":"gpt-6-luna","my-alias":"gpt-6.1-sol"}}} +``` + +任一 WS 闸关闭时,新 WS 握手被拒绝,HTTP/SSE 入口继续可用且上游强制 HTTP。传输、UA 与隐私选项通过每次内部 envelope 的 `options` 生效,不为修改这些 settings 而重启共享 kernel;已开始的请求保留其选定策略。 安装 UUID 存在槽位 `run/codex-installation-id`,不取 MAC、主机名、machine-id 或调用方安装 ID。每次 envelope 的 `options.installation_id` 使用该文件的 UUID,覆盖运行中 kernel 的旧启动身份;非法安装 ID 在拨号前拒绝,避免复用旧进程时重新上传 VM 指纹。关闭安装元数据时,不生成 `client_metadata`,并从调用方的 turn 元数据移除安装 ID;会话路由所需信息保留。 diff --git a/src/lib/protocol/codex-model-mapping.mjs b/src/lib/protocol/codex-model-mapping.mjs new file mode 100644 index 00000000..f892d57f --- /dev/null +++ b/src/lib/protocol/codex-model-mapping.mjs @@ -0,0 +1,129 @@ +/** + * CPR-style inbound model id rewrite (client model → upstream GPT id). + * Applied before detectInboundPlatform; literal detect on raw ids is unchanged. + */ +import { isGptSeriesId } from './gpt-ids.mjs' +import { isGptModelEnabled } from './gpt-model-policy.mjs' + +/** Recommended secondary default when an operator clears the primary auto-review target. */ +export const DEFAULT_CODEX_MODEL_MAPPING_FALLBACK = 'gpt-6.1-sol' + +export const DEFAULT_CODEX_MODEL_MAPPINGS = Object.freeze({ + 'codex-auto-review': 'gpt-6-luna', +}) + +function bareModelId(model) { + const raw = String(model || '').trim() + if (!raw) return '' + return raw.split('/').filter(Boolean).pop() || raw +} + +function mappingLookup(map, model) { + const bare = bareModelId(model) + if (!bare) return null + const lower = bare.toLowerCase() + if (Object.prototype.hasOwnProperty.call(map, bare)) return map[bare] + if (Object.prototype.hasOwnProperty.call(map, lower)) return map[lower] + for (const [key, target] of Object.entries(map)) { + if (String(key).toLowerCase() === lower) return target + } + return null +} + +/** + * Merge routing overrides onto product defaults. Empty/null target for codex-auto-review + * falls back to {@link DEFAULT_CODEX_MODEL_MAPPING_FALLBACK}; other keys with empty target drop the override. + */ +export function normalizeCodexModelMappings(raw) { + const merged = { ...DEFAULT_CODEX_MODEL_MAPPINGS } + const src = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null + if (!src) return merged + for (const [key, value] of Object.entries(src)) { + const alias = String(key || '').trim() + if (!alias) continue + if (value === null || value === '') { + if (alias.toLowerCase() === 'codex-auto-review') { + merged[alias] = DEFAULT_CODEX_MODEL_MAPPING_FALLBACK + } else { + delete merged[alias] + if (alias.toLowerCase() !== alias) delete merged[alias.toLowerCase()] + } + continue + } + merged[alias] = String(value).trim() + } + return merged +} + +export function validateCodexModelMappingTarget(target) { + const id = String(target || '').trim() + if (!id || !isGptSeriesId(id)) { + return { + ok: false, + code: 'codex_model_mapping_invalid', + message: `model mapping target '${id || ''}' must be an enabled GPT chat model id`, + } + } + if (!isGptModelEnabled(id)) { + return { + ok: false, + code: 'model_disabled', + message: `model mapping target '${id}' is disabled by gateway GPT model policy`, + } + } + return { ok: true, target: id } +} + +export function validateCodexModelMappingsPatch(raw, path = 'codex.model_mappings') { + if (raw == null) return + if (typeof raw !== 'object' || Array.isArray(raw)) { + throw typedError(`${path} 必须是对象`, 'invalid_codex_model_mappings') + } + for (const [key, value] of Object.entries(raw)) { + const alias = String(key || '').trim() + if (!alias) throw typedError(`${path} 的键必须是非空字符串`, 'invalid_codex_model_mappings') + if (value === null || value === '') continue + if (typeof value !== 'string' || !value.trim()) { + throw typedError(`${path}.${alias} 必须是字符串、null 或空字符串`, 'invalid_codex_model_mappings') + } + const check = validateCodexModelMappingTarget(value.trim()) + if (!check.ok) throw typedError(`${path}.${alias}: ${check.message}`, check.code) + } +} + +function typedError(message, code) { + const error = new TypeError(message) + error.code = code + return error +} + +function rewriteModelField(model, upstreamId) { + const raw = String(model || '').trim() + const parts = raw.split('/').filter(Boolean) + if (parts.length <= 1) return upstreamId + parts[parts.length - 1] = upstreamId + return parts.join('/') +} + +/** + * @returns {{ ok: true, body: object, mapped?: boolean, requestedModel?: string, upstreamModel?: string | null } | { ok: false, code: string, message: string }} + */ +export function applyCodexInboundModelRewrite(body, routing = {}) { + if (!body || typeof body !== 'object') return { ok: true, body: body || {} } + const model = body.model + if (typeof model !== 'string' || !model.trim()) return { ok: true, body } + const codex = routing.codex && typeof routing.codex === 'object' ? routing.codex : routing + const mappings = normalizeCodexModelMappings(codex?.model_mappings) + const target = mappingLookup(mappings, model) + if (target == null) return { ok: true, body, requestedModel: model.trim(), upstreamModel: null, mapped: false } + const validated = validateCodexModelMappingTarget(target) + if (!validated.ok) return validated + const upstreamModel = validated.target + return { + ok: true, + body: { ...body, model: rewriteModelField(model, upstreamModel) }, + mapped: true, + requestedModel: model.trim(), + upstreamModel, + } +} diff --git a/src/lib/protocol/codex-route.mjs b/src/lib/protocol/codex-route.mjs index 391d31f5..d0d0c69b 100644 --- a/src/lib/protocol/codex-route.mjs +++ b/src/lib/protocol/codex-route.mjs @@ -3,6 +3,7 @@ */ import { isCodexVm } from '../vm/vm-kind.mjs' import { normalizeOpenAIQuotaPolicy } from '../pool/openai-quota-policy.mjs' +import { normalizeCodexModelMappings, validateCodexModelMappingsPatch } from './codex-model-mapping.mjs' export { isCodexVm, normalizeVmKind } from '../vm/vm-kind.mjs' @@ -103,6 +104,9 @@ export function validateInferenceWebsocketPatch(body = {}) { if (Object.prototype.hasOwnProperty.call(codex, 'analytics')) { validateEnabledToggle(codex.analytics, 'codex.analytics', 'invalid_codex_analytics') } + if (Object.prototype.hasOwnProperty.call(codex, 'model_mappings')) { + validateCodexModelMappingsPatch(codex.model_mappings) + } } export function inferenceWebsocketEnabled(routing = {}) { @@ -144,6 +148,7 @@ export function normalizeCodexRouting(raw = {}) { }, clients, quota: normalizeOpenAIQuotaPolicy(src.quota), + model_mappings: normalizeCodexModelMappings(src.model_mappings), } } diff --git a/src/lib/protocol/handle-protocol.mjs b/src/lib/protocol/handle-protocol.mjs index 68e69808..65c8fe43 100644 --- a/src/lib/protocol/handle-protocol.mjs +++ b/src/lib/protocol/handle-protocol.mjs @@ -43,6 +43,7 @@ import { disjointOpenaiUsage } from './openai-usage.mjs' import { costColumnsFromUsage } from '../admin/pricing.mjs' import { detectInboundPlatform } from './platform-detect.mjs' import { normalizeCodexRouting } from './codex-route.mjs' +import { applyCodexInboundModelRewrite } from './codex-model-mapping.mjs' import { hasClaudeCode1mSuffix } from './context-1m.mjs' import { DEFAULT_HEALTH_PROBE, @@ -635,6 +636,24 @@ export function createHandleProtocol(deps) { } const inferenceBackend = resolveInferenceBackend(req) let want1m = hasClaudeCode1mSuffix(inbound?.model) || hasClaudeCode1mSuffix(ctx.body?.model) + const modelRewrite = applyCodexInboundModelRewrite(ctx.body, getRouting() || {}) + if (!modelRewrite.ok) { + stats.errors++ + const errorResult = makeError({ + type: ErrorType.INVALID_REQUEST, + code: modelRewrite.code || 'codex_model_mapping_invalid', + message: modelRewrite.message, + status: 400, + param: 'model', + }) + logBag.error_code = errorResult.body?.error?.code || modelRewrite.code + logBag.error_message = errorResult.body?.error?.message || null + return json(res, errorResult.status, errorResult.body) + } + if (modelRewrite.mapped) { + ctx = { ...ctx, body: modelRewrite.body } + logBag.upstream_model = modelRewrite.upstreamModel || null + } const platform = detectInboundPlatform(ctx.body?.model) if (!platform.ok) { stats.errors++ diff --git a/test/unit/codex-model-mapping.test.mjs b/test/unit/codex-model-mapping.test.mjs new file mode 100644 index 00000000..8afefe52 --- /dev/null +++ b/test/unit/codex-model-mapping.test.mjs @@ -0,0 +1,87 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { detectInboundPlatform } from '../../src/lib/protocol/platform-detect.mjs' +import { + applyCodexInboundModelRewrite, + DEFAULT_CODEX_MODEL_MAPPING_FALLBACK, + DEFAULT_CODEX_MODEL_MAPPINGS, + normalizeCodexModelMappings, + validateCodexModelMappingTarget, +} from '../../src/lib/protocol/codex-model-mapping.mjs' +import { normalizeCodexRouting } from '../../src/lib/protocol/codex-route.mjs' +import { resetGptModelPolicy, saveGptModelPolicy } from '../../src/lib/protocol/gpt-model-policy.mjs' +import fs from 'node:fs' +import path from 'node:path' + +test('default map rewrites codex-auto-review before platform detect', () => { + resetGptModelPolicy() + const rewrite = applyCodexInboundModelRewrite({ model: 'codex-auto-review' }, {}) + assert.equal(rewrite.ok, true) + assert.equal(rewrite.mapped, true) + assert.equal(rewrite.requestedModel, 'codex-auto-review') + assert.equal(rewrite.upstreamModel, DEFAULT_CODEX_MODEL_MAPPINGS['codex-auto-review']) + const platform = detectInboundPlatform(rewrite.body.model) + assert.equal(platform.ok, true) + assert.equal(platform.platform, 'openai') + assert.equal(platform.model, 'gpt-6-luna') +}) + +test('literal detectInboundPlatform still fail-closes codex-auto-review', () => { + assert.equal(detectInboundPlatform('codex-auto-review').ok, false) +}) + +test('custom routing.model_mappings overrides default target', () => { + resetGptModelPolicy() + const routing = { codex: { model_mappings: { 'codex-auto-review': 'gpt-6-sol' } } } + const rewrite = applyCodexInboundModelRewrite({ model: 'codex-auto-review' }, routing) + assert.equal(rewrite.upstreamModel, 'gpt-6-sol') + assert.equal(detectInboundPlatform(rewrite.body.model).platform, 'openai') +}) + +test('empty configured target for codex-auto-review uses fallback sol model', () => { + resetGptModelPolicy() + const mappings = normalizeCodexModelMappings({ 'codex-auto-review': '' }) + assert.equal(mappings['codex-auto-review'], DEFAULT_CODEX_MODEL_MAPPING_FALLBACK) + const rewrite = applyCodexInboundModelRewrite( + { model: 'codex-auto-review' }, + { codex: { model_mappings: { 'codex-auto-review': '' } } }, + ) + assert.equal(rewrite.upstreamModel, DEFAULT_CODEX_MODEL_MAPPING_FALLBACK) +}) + +test('invalid non-GPT mapping target returns codex_model_mapping_invalid', () => { + resetGptModelPolicy() + const rewrite = applyCodexInboundModelRewrite( + { model: 'codex-auto-review' }, + { codex: { model_mappings: { 'codex-auto-review': 'claude-sonnet-4-6' } } }, + ) + assert.equal(rewrite.ok, false) + assert.equal(rewrite.code, 'codex_model_mapping_invalid') +}) + +test('disabled GPT mapping target returns model_disabled', () => { + resetGptModelPolicy() + saveGptModelPolicy({ + models: { + 'gpt-6-luna': { id: 'gpt-6-luna', enabled: false }, + }, + }) + const check = validateCodexModelMappingTarget('gpt-6-luna') + assert.equal(check.ok, false) + assert.equal(check.code, 'model_disabled') + const rewrite = applyCodexInboundModelRewrite({ model: 'codex-auto-review' }, {}) + assert.equal(rewrite.ok, false) + assert.equal(rewrite.code, 'model_disabled') +}) + +test('normalizeCodexRouting hot-loads model_mappings defaults', () => { + const codex = normalizeCodexRouting({}) + assert.equal(codex.model_mappings['codex-auto-review'], 'gpt-6-luna') +}) + +test('handle-protocol applies codex model rewrite before detectInboundPlatform', () => { + const src = fs.readFileSync(path.join(process.cwd(), 'src/lib/protocol/handle-protocol.mjs'), 'utf8') + const rewriteAt = src.indexOf('applyCodexInboundModelRewrite') + const detectAt = src.indexOf('detectInboundPlatform(ctx.body?.model)') + assert.ok(rewriteAt > 0 && detectAt > rewriteAt) +}) From 847c2cb1bc0829e3100f8c0c5e9ca39b5da3541d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 08:12:09 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(changelog):=20resolve=20=E6=9C=AA?= =?UTF-8?q?=E5=8F=91=E5=B8=83=20merge=20markers=20and=20order=20(#526)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ted --- CHANGELOG.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bf5f1fe..fcfb184c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,10 @@ ## 未发布 -<<<<<<< HEAD +- Codex 入站在平台探测前支持 CPR 式 `routing.codex.model_mappings` 精确模型映射;默认 `codex-auto-review` → `gpt-6-luna`(清空该键时回退 `gpt-6.1-sol`)。日志保留 `requested_model` / `upstream_model`;非法或非 GPT 目标 400,不 hop Anthropic。(#526) - 保留路径不再给没有可用签名的 thinking 补 `signature: ""`:缺失、空、短于 24、dummy 的删掉;签名可用的留,空正文也留。这一支里的 `redacted_thinking` 不看 data 和签名,原样留;不走保留路径时仍按原规则删。5.5 的非保留路径仍接受短的非 dummy 签名。没有按账号归属过滤签名。 - 协议页新增「thinking 显示」,`routing.compatibility.thinking_display` 默认 `summarized`。调用方打开 thinking 却要求隐藏(`omitted`、`updates`,或默认隐藏的模型上没写 display)时,出站改成 `summarized`;显式 `passthrough` 保留调用方的值。已保存的 `routing.json` 不写这个键也按 summarized 生效。 -======= -- Codex 入站在平台探测前支持 CPR 式 `routing.codex.model_mappings` 精确模型映射;默认 `codex-auto-review` → `gpt-6-luna`(清空该键时回退 `gpt-6.1-sol`)。日志保留 `requested_model` / `upstream_model`;非法或非 GPT 目标 400,不 hop Anthropic。(#526) ->>>>>>> 55afa54 (feat(codex): inbound model_mappings for codex-auto-review (#526)) +- Claude kernel / CLI 依赖固定到不可变快照 `payload-2026.10.19-kernel-cli`(kin-kernel + cli-node);Codex kernel 与其余原生产物保持不变。(#594) ## 1.3.199 — 2026-10-19