diff --git a/docs-site/src/content/docs/fr/reference/configuration/server.md b/docs-site/src/content/docs/fr/reference/configuration/server.md index ae0b6f3a585..b54585a5ef0 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/server.md +++ b/docs-site/src/content/docs/fr/reference/configuration/server.md @@ -178,9 +178,9 @@ l'abonnement avec un avertissement lorsque la détection n'est pas concluante. V Codex utilise de petits modèles auxiliaires pour des tâches telles que les titres et les messages de commit. Activez `shadowCallIntercept` pour rediriger les préfixes de modèle source reconnus vers un autre modèle configuré. Le modèle de remplacement conserve l'effort de raisonnement configuré pour la requête. Définissez `sourceModels` uniquement lorsqu'un client utilise d'autres identifiants de modèles auxiliaires. -Codex 0.145.0+ indique l'objet de la requête dans `x-codex-turn-metadata` : les requêtes normales portant `request_kind: "turn"` -conservent le modèle sélectionné, tandis que les requêtes de maintenance reconnues peuvent être redirigées. Les clients -qui ne fournissent pas ces métadonnées conservent le comportement historique fondé sur le préfixe. +L'interception dépend du modèle : toute requête dont l'identifiant de modèle nu correspond à `sourceModels` +peut être redirigée, y compris une requête normale portant `request_kind: "turn"`. +`x-codex-turn-metadata` n'exempte pas une requête correspondante. ```json { diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index ab318f92b9f..939ed837897 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -189,9 +189,9 @@ Codex uses small helper models for tasks such as titles and commit messages. Ena `shadowCallIntercept` to redirect recognized source-model prefixes to another configured model. The replacement keeps the request's configured reasoning effort. Set `sourceModels` only when a client uses different helper ids. -Codex 0.145.0+ marks request purpose in `x-codex-turn-metadata`: normal `request_kind: "turn"` -requests keep the selected model, while recognized maintenance requests can be redirected. Clients -without that metadata retain the legacy prefix behavior. +Interception is model-based: every request whose bare model id matches `sourceModels` can be +redirected, including normal `request_kind: "turn"` requests. `x-codex-turn-metadata` does not exempt +a matching request. ```json { diff --git a/docs-site/src/content/docs/tr/reference/configuration/server.md b/docs-site/src/content/docs/tr/reference/configuration/server.md index fef020194fc..7168ca3f8aa 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/server.md +++ b/docs-site/src/content/docs/tr/reference/configuration/server.md @@ -200,10 +200,9 @@ kullanır. Tanınan kaynak model öneklerini yapılandırılmış başka bir mod yönlendirmek için `shadowCallIntercept`'i etkinleştirin. Değiştirilen istek, yapılandırılmış akıl yürütme çabasını korur. `sourceModels`'ı yalnızca bir istemci farklı yardımcı kimlikleri kullandığında -ayarlayın. Codex 0.145.0+, istek amacını `x-codex-turn-metadata` içinde -işaretler: normal `request_kind: "turn"` istekleri seçilen modeli tutarken -tanınan bakım istekleri yeniden yönlendirilebilir. Bu meta verileri içermeyen -istemciler eski önek davranışını korur. +ayarlayın. Yakalama model tabanlıdır: çıplak model kimliği `sourceModels` ile +eşleşen her istek, normal `request_kind: "turn"` istekleri dahil, yeniden +yönlendirilebilir. `x-codex-turn-metadata` eşleşen bir isteği muaf tutmaz. ```json { diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md index 90e3256283b..b536704e267 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md @@ -24,7 +24,7 @@ description: 監聽器、遠端存取、許可金鑰、逾時、儲存、sidecar | `codexAutoStart?` | `boolean` | `true` | 讓 Codex shim 在啟動 Codex 前執行 `ocx ensure`。False 使 ensure 為 no-op。 | | `codexShimAutoRestore?` | `boolean` | `true` | 在完成的外部 Codex 更新取代已安裝的 shim 後還原它。環境退出:`OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 歷史相容性。原始中繼資料由 `ocx stop` / `ocx restore` 備份並還原。 | -| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | 將識別的 Codex helper/shadow call 重定向到所選模型,並保留為請求設定的 reasoning effort。預設來源前綴為 `gpt-5.4-mini` 與 `gpt-5.6-luna`。 | +| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | 將識別的 Codex helper/shadow call 重定向到所選模型,並保留為請求設定的 reasoning effort。預設來源前綴為 `gpt-5.6-luna`;0.144.x 及更舊的客戶端使用 `gpt-5.4-mini`,可透過 `sourceModels` 恢復。 | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | 可用時開啟 | 網頁搜尋 sidecar 選項。 | | `visionSidecar?` | `OcxVisionSidecarConfig` | 可用時開啟 | 圖片描述 sidecar 選項。 | | `images?` | `OcxImagesConfig` | 自動 OpenAI 選擇 | Codex `image_gen` 的獨立 Images 中繼選項。 | @@ -147,7 +147,7 @@ Codex 使用小型 helper 模型處理如標題與 commit 訊息等任務。啟 "shadowCallIntercept": { "enabled": true, "model": "gpt-5.5", - "sourceModels": ["gpt-5.4-mini", "gpt-5.6-luna"] + "sourceModels": ["gpt-5.6-luna"] } } ``` diff --git a/docs/shadow-call-intercept.md b/docs/shadow-call-intercept.md index 7e73c35d06c..d0e7248734f 100644 --- a/docs/shadow-call-intercept.md +++ b/docs/shadow-call-intercept.md @@ -64,18 +64,15 @@ the defaults rather than extending them: ### Behavior -- Matching maintenance requests, including `prewarm`, `compaction`, and `memory`, are - rewritten to the configured model -- Normal user turns identified by `x-codex-turn-metadata` with `request_kind: "turn"` are - never rewritten -- Headerless legacy clients retain the original prefix behavior: matching bare model ids are - rewritten -- Missing, malformed, or unrecognized turn metadata retains the legacy prefix behavior +- Every request whose bare model id matches a configured source prefix is rewritten to the + configured model, including normal `request_kind: "turn"` requests and maintenance requests + such as `prewarm`, `compaction`, and `memory` +- `x-codex-turn-metadata` does not exempt a matching request from interception - The request's configured reasoning effort is preserved - The original model ID is logged as `shadowCallRewrittenFrom` in request logs - When disabled (default), no interception occurs ### Warning -Headerless clients cannot distinguish foreground turns from background helper calls. If such a -client uses `gpt-5.6-luna` as its main model, narrow `sourceModels` or disable the intercept. +Interception is model-based and does not distinguish foreground turns from background helper calls. +If a client uses `gpt-5.6-luna` as its main model, narrow `sourceModels` or disable the intercept. diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index e9c7a8d62c5..84d2da1523e 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -383,8 +383,13 @@ export function visionModelOptions( /** Options for shadow-call replacement models use the proxy's canonical routing id. */ export function shadowCallModelOptions(models: ModelInfo[], current: string | undefined, sourceModels?: string[]) { const sourcePrefixes = shadowSourceModelList(sourceModels); - const intersecting = models.filter(model => - sourcePrefixes.some(prefix => model.namespaced.startsWith(prefix))); + const sourceIdentities = sourcePrefixes.flatMap(prefix => { + const source = models.find(model => model.namespaced.startsWith(prefix)) + ?? models.find(model => model.id.startsWith(prefix)); + return source ? [{ provider: source.provider, modelId: prefix }] : []; + }); + const intersecting = models.filter(model => sourceIdentities.some(source => + model.provider === source.provider && model.id.startsWith(source.modelId))); const invalidSelectors = new Set([ ...sourcePrefixes.flatMap(prefix => [prefix, `openai/${prefix}`]), ...intersecting.flatMap(model => [model.namespaced, `${model.provider}/${model.id}`]), diff --git a/gui/tests/shadow-call-model-options.test.ts b/gui/tests/shadow-call-model-options.test.ts index 968dc957f81..cc122f026c8 100644 --- a/gui/tests/shadow-call-model-options.test.ts +++ b/gui/tests/shadow-call-model-options.test.ts @@ -33,6 +33,16 @@ test("shadow-call options exclude only source targets on the intersecting provid expect(options).toContainEqual({ value: "xai/gpt-5.6-luna", label: "xai/gpt-5.6-luna" }); }); +test("shadow-call options exclude a custom-provider self-target", () => { + const options = shadowCallModelOptions([ + { provider: "xai", id: "custom-helper", namespaced: "xai/custom-helper" }, + { provider: "xai", id: "grok-4.5", namespaced: "xai/grok-4.5" }, + ], undefined, ["custom-helper"]); + + expect(options.map(option => option.value)).not.toContain("xai/custom-helper"); + expect(options).toContainEqual({ value: "xai/grok-4.5", label: "xai/grok-4.5" }); +}); + test("shadow-call options do not grandfather a stale self-target", () => { const withoutNativeSource = models.filter(model => model.namespaced !== "gpt-5.6-luna"); const options = shadowCallModelOptions(withoutNativeSource, "openai/gpt-5.6-luna", ["gpt-5.6-luna"]); diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index c5d09d9f9bb..192ca547502 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -65,6 +65,7 @@ import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostR import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; import type { ManagementContext } from "./context"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import { shadowCallTargetError } from "./shadow-call-validation"; /** Management wire shape: omit default imageInput "auto" (persist/response sparse). */ @@ -169,7 +170,6 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise(); if (oldPublicModel && oldPublicModel !== newPublicModel && previous?.nativeAlias !== true) { @@ -183,6 +183,15 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise 0) { const migrateReference = (model: string): string => migratedModels.get(model) ?? model; const migrateAgentReference = (model: string): string => { diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index b1ddb0cb75c..a30d8ac451d 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -25,7 +25,7 @@ import { import { removeCredential } from "../../oauth/store"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { isStreamMode } from "../../lib/bun-stream-caps"; -import { shadowCallTargetsIntersect, shadowSourceModels } from "../../lib/shadow-call"; +import { shadowSourceModels } from "../../lib/shadow-call"; import { configureAppOwnedMemoryBudget, enforceAppOwnedMemoryBudget, @@ -38,7 +38,7 @@ import { deriveProviderPresets } from "../../providers/derive"; import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; -import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; +import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { @@ -88,7 +88,7 @@ import { type DebugFlag, } from "../../lib/debug-settings"; import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types"; -import { routeConcreteModel, routeModel } from "../../router"; +import { shadowCallTargetError } from "./shadow-call-validation"; import { drainAndShutdown } from "../lifecycle"; import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log"; import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; @@ -869,25 +869,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise { - let source = { providerName: OPENAI_CODEX_PROVIDER_ID, modelId: sourceModel }; - try { - const resolved = routeConcreteModel(config, sourceModel); - source = { providerName: resolved.providerName, modelId: sourceModel }; - } catch { /* Unconfigured native Codex source models remain OpenAI-owned. */ } - return shadowCallTargetsIntersect(source, target); - }); - if (intersectsSource) { - return jsonResponse({ error: "shadow-call target must not intersect a source model" }, 400); - } - } + const targetError = shadowCallTargetError(config, candidateModel); + if (targetError) return jsonResponse({ error: targetError }, 400); config.shadowCallIntercept = { ...config.shadowCallIntercept }; if (typeof body.enabled === "boolean") config.shadowCallIntercept.enabled = body.enabled; if (typeof body.model === "string") { diff --git a/src/server/management/routing-profile-routes.ts b/src/server/management/routing-profile-routes.ts index 99ea1f64f8f..7cd7730db16 100644 --- a/src/server/management/routing-profile-routes.ts +++ b/src/server/management/routing-profile-routes.ts @@ -27,6 +27,7 @@ import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import { jsonResponse } from "../auth-cors"; import type { ManagementContext } from "./context"; import type { OcxConfig, OcxRoutingProfileConfig } from "../../types"; +import { shadowCallTargetError } from "./shadow-call-validation"; function profileDto(config: Parameters[0], id: string): Record | null { const profile = getRoutingProfile(config, id); @@ -276,10 +277,12 @@ export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promis } const previousProfile = mode === "update" ? getRoutingProfile(config, id) : undefined; + let aliasMigration: { oldPublicModel: string; newPublicModel: string } | undefined; if (mode === "update" && previousProfile) { const oldPublicModel = policyPublicModelId(id, previousProfile); const newProfile = normalizeRoutingProfile(id, body.profile as OcxRoutingProfileConfig); const newPublicModel = policyPublicModelId(id, newProfile); + aliasMigration = { oldPublicModel, newPublicModel }; const collision = modelMapMigrationCollision(config, oldPublicModel, newPublicModel); if (collision) { return jsonResponse({ @@ -289,6 +292,18 @@ export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promis } const nextProfiles = { ...(config.routingProfiles ?? {}) }; nextProfiles[id] = storedProfile(id, body.profile as OcxRoutingProfileConfig); + const currentShadowTarget = config.shadowCallIntercept?.model; + if (aliasMigration && currentShadowTarget === aliasMigration.oldPublicModel) { + const targetError = shadowCallTargetError( + { ...config, routingProfiles: nextProfiles }, + aliasMigration.newPublicModel, + ); + if (targetError) { + return jsonResponse({ + error: { code: "invalid_shadow_call_target", message: targetError }, + }, 400, req, config); + } + } config.routingProfiles = nextProfiles; // Creating the first profile on a process started profile-less must install the // compatibility provider now; activation is synchronous and idempotent per configDir. diff --git a/src/server/management/shadow-call-validation.ts b/src/server/management/shadow-call-validation.ts new file mode 100644 index 00000000000..f3953108c6e --- /dev/null +++ b/src/server/management/shadow-call-validation.ts @@ -0,0 +1,29 @@ +import { shadowCallTargetsIntersect, shadowSourceModels } from "../../lib/shadow-call"; +import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; +import { routeConcreteModel, routeModel } from "../../router"; +import type { OcxConfig } from "../../types"; + +/** Validate a prospective persisted shadow-call target against its resolved source identities. */ +export function shadowCallTargetError(config: OcxConfig, targetModel: string | undefined): string | null { + if (!targetModel) return null; + + let target; + try { + target = routeModel(config, targetModel); + } catch { + return "model must resolve to a configured provider"; + } + + const intersectsSource = shadowSourceModels(config.shadowCallIntercept?.sourceModels).some(sourceModel => { + let source = { providerName: OPENAI_CODEX_PROVIDER_ID, modelId: sourceModel }; + try { + const resolved = routeConcreteModel(config, sourceModel); + source = { providerName: resolved.providerName, modelId: sourceModel }; + } catch { /* Unconfigured native Codex source models remain OpenAI-owned. */ } + return shadowCallTargetsIntersect(source, target); + }); + + return intersectsSource + ? "shadow-call target must not intersect a source model" + : null; +} diff --git a/tests/combo-management-api.test.ts b/tests/combo-management-api.test.ts index 49ec7b53b6f..58917717379 100644 --- a/tests/combo-management-api.test.ts +++ b/tests/combo-management-api.test.ts @@ -773,6 +773,48 @@ describe("combo management API", () => { ]); }); + test("PUT alias changes reject a migrated shadow-call self-target (#2706)", async () => { + await withTempHome(async () => { + const config = baseConfig({ + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + apiKey: "test-xai-key", + models: ["custom-helper"], + }, + }, + combos: { + helper: { + alias: "old-public", + targets: [{ provider: "xai", model: "custom-helper" }], + }, + }, + shadowCallIntercept: { + enabled: true, + model: "old-public", + sourceModels: ["custom-helper"], + }, + }); + saveConfig(config); + const beforeMemory = structuredClone(config); + const beforeDisk = readFileSync(getConfigPath(), "utf8"); + + const response = await comboApi(config, "PUT", "/api/combos", { + id: "helper", + combo: { + alias: "custom-helper", + targets: [{ provider: "xai", model: "custom-helper" }], + }, + }); + + expect(response?.status).toBe(400); + expect(config).toEqual(beforeMemory); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeDisk); + }); + }); + test("PUT clearing an alias deduplicates migrated references in stable order", async () => { await withTempHome(async () => { const config = baseConfig({ diff --git a/tests/routing-profile-management-editor.test.ts b/tests/routing-profile-management-editor.test.ts index 50183dcf616..84fd79429fd 100644 --- a/tests/routing-profile-management-editor.test.ts +++ b/tests/routing-profile-management-editor.test.ts @@ -515,6 +515,55 @@ describe("routing profile management editor API", () => { expect(saves).toBe(0); }); + test("PUT update rejects a migrated shadow-call self-target (#2706)", async () => { + const config = baseConfig(); + config.defaultProvider = "xai"; + config.providers = { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + apiKey: "test-xai-key", + models: ["custom-helper"], + }, + }; + config.routingProfiles!.fast = { + alias: "old-public", + candidates: [{ provider: "xai", model: "custom-helper" }], + }; + config.shadowCallIntercept = { + enabled: true, + model: "old-public", + sourceModels: ["custom-helper"], + }; + const before = structuredClone(config); + let saves = 0; + let refreshes = 0; + const req = new ManagementRequest("http://localhost/api/routing-profiles", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "fast", + mode: "update", + profile: { + alias: "custom-helper", + candidates: [{ provider: "xai", model: "custom-helper" }], + }, + }), + }); + + const response = await handleManagementAPI( + req, + new URL(req.url), + config, + deps(() => { saves += 1; }, () => { refreshes += 1; }), + ); + + expect(response?.status).toBe(400); + expect(config).toEqual(before); + expect(saves).toBe(0); + expect(refreshes).toBe(0); + }); + test("DELETE removes a profile, persists, and refreshes the catalog", async () => { const config = baseConfig(); let saves = 0;