Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

## 未发布

- 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 生效。
- Claude kernel / CLI 依赖固定到不可变快照 `payload-2026.10.19-kernel-cli`(kin-kernel + cli-node);Codex kernel 与其余原生产物保持不变。(#594)

## 1.3.199 — 2026-10-19

Expand Down
11 changes: 10 additions & 1 deletion docs/PANEL_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;会话路由所需信息保留。

Expand Down
129 changes: 129 additions & 0 deletions src/lib/protocol/codex-model-mapping.mjs
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fallback mapping target not catalog-safe

Medium Severity

Clearing codex-auto-review is documented to fall back to gpt-6.1-sol, but that id is not in seedGptPolicy, patch validation skips empty values, and persist then stores the resolved fallback. After a Codex catalog sync, unseen GPT ids start disabled (or are unknown), so auto-review requests 400 with model_disabled, and a later PUT of the GET payload can fail even though the empty save succeeded.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 847c2cb. Configure here.

}

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,
}
}
5 changes: 5 additions & 0 deletions src/lib/protocol/codex-route.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 = {}) {
Expand Down Expand Up @@ -144,6 +148,7 @@ export function normalizeCodexRouting(raw = {}) {
},
clients,
quota: normalizeOpenAIQuotaPolicy(src.quota),
model_mappings: normalizeCodexModelMappings(src.model_mappings),
}
}

Expand Down
19 changes: 19 additions & 0 deletions src/lib/protocol/handle-protocol.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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++
Expand Down
87 changes: 87 additions & 0 deletions test/unit/codex-model-mapping.test.mjs
Original file line number Diff line number Diff line change
@@ -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)
})
Loading