From f50f6e7bb6b6415af2558862d410682d468b9b7c Mon Sep 17 00:00:00 2001 From: Yuimi-chaya Date: Wed, 28 Oct 2026 23:57:37 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix(app):=20=E4=BF=AE=E5=A4=8D=E6=97=A7?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B=E6=A8=A1=E5=9E=8B=E9=80=89=E6=8B=A9=E8=A2=AB?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E7=9A=84=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/thread-model-preservation.test.ts | 57 +++++++++++++++++++ assets/inject/renderer-inject.js | 32 +++-------- assets/inject/renderer-inject/00-prelude.js | 2 +- .../inject/renderer-inject/30-service-tier.js | 4 +- .../renderer-inject/70-model-catalog.js | 26 ++------- assets/inject/renderer-inject/manifest.json | 2 +- .../codex-plus-core/tests/all/cdp_bridge.rs | 44 +++++++++----- 7 files changed, 104 insertions(+), 63 deletions(-) create mode 100644 apps/codex-plus-manager/src/thread-model-preservation.test.ts diff --git a/apps/codex-plus-manager/src/thread-model-preservation.test.ts b/apps/codex-plus-manager/src/thread-model-preservation.test.ts new file mode 100644 index 0000000000..2c6e619fdb --- /dev/null +++ b/apps/codex-plus-manager/src/thread-model-preservation.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { describe, it } from "node:test"; + +const source = readFileSync(new URL( + "../../../assets/inject/renderer-inject/30-service-tier.js", import.meta.url), "utf8"); +const override = source.slice(source.indexOf(" function codexRemoteSessionProviderRequestMethod("), + source.indexOf(" function codexManagedSessionConnection(")); + +function runtime(mode = "pureApi") { + return new Function("codexRemoteSessionProviderOverrideEnabled", "codexRemoteSessionActiveProfile", + "codexRemoteSessionTargetProvider", "sendCodexPlusDiagnostic", + `${override}\nreturn applyCodexRemoteSessionProviderOverride;`)( + () => true, () => ({ relayMode: mode }), () => "custom", () => {}); +} + +describe("thread model restoration ownership", () => { + it("leaves thread-only resumes intact across distinct saved model and reasoning choices", () => { + const apply = runtime(); + for (const [threadId, model, effort] of [ + ["thread-a", "model-a", "high"], ["thread-b", "model-b", "ultra"], + ["thread-c", "outside-current-catalog", "low"], + ]) { + const params: { threadId: string; model?: string; modelProvider?: string; reasoningEffort?: string } = { threadId }; + const patched = apply("thread/resume", params); + assert.equal(patched, params); + assert.equal(Object.hasOwn(patched, "modelProvider"), false); + assert.deepEqual(params, { threadId }); + const saved = { model, reasoningEffort: effort }; + const native = patched.model || patched.modelProvider || patched.reasoningEffort + ? { model: "global-default", reasoningEffort: "medium" } : saved; + assert.equal(native.model, model); + assert.equal(native.reasoningEffort, effort); + } + }); + + it("preserves explicit provider spelling, null models and caller reasoning", () => { + const apply = runtime(); + for (const params of [ + { threadId: "thread", model_provider: "openai" }, + { threadId: "thread", modelProvider: "openai", model: null }, + { threadId: "thread", reasoningEffort: "high" }, + { threadId: "thread", model: "chosen", modelProvider: "other", reasoningEffort: "ultra" }, + ]) { + assert.equal(apply("thread/resume", params), params); + } + }); + + it("retains provider routing for starts and explicit model switches", () => { + const apply = runtime(); + const params = { threadId: "thread", model: "chosen", reasoningEffort: "high" }; + assert.deepEqual(apply("thread/resume", params), { ...params, modelProvider: "custom" }); + assert.equal(apply("thread/start", {}).modelProvider, "custom"); + assert.equal(apply("thread/resume", params).model, "chosen"); + assert.equal(runtime("official")("thread/resume", params), params); + }); +}); diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 71baa61914..3007d6273a 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -431,7 +431,7 @@ const codexPlusSettingsKey = "codexPlusSettings"; const codexThreadScrollKey = "codexThreadScroll"; const codexServiceTierRequestOverrideVersion = "10"; - const codexAppServerModelRequestPatchVersion = "27"; + const codexAppServerModelRequestPatchVersion = "28"; const codexAppServerClientCaptureMarker = "AppServerRequestClient is missing a message dispatcher"; const codexAppServerClientCaptureAnchor = "async sendRequest("; const codexRemoteSessionRecoveryVersion = "8"; @@ -5455,8 +5455,8 @@ if (!params || typeof params !== "object" || Array.isArray(params)) return params; const profile = codexRemoteSessionActiveProfile(); const pureApi = String(profile?.relayMode || "") === "pureApi"; - // Pure API 恢复旧会话时必须覆盖持久化的 openai;官登会话保留原生恢复语义。 - if (requestMethod === "thread/resume" && !pureApi) return params; + // 仅有 threadId 的恢复必须让原生读取保存的模型和推理设置;注入 provider 会跳过它们。 + if (requestMethod === "thread/resume" && (!pureApi || !params.model)) return params; if (requestMethod === "turn/start" && !pureApi) return params; const hasModelProvider = Object.prototype.hasOwnProperty.call(params, "modelProvider") || Object.prototype.hasOwnProperty.call(params, "model_provider"); @@ -11525,11 +11525,11 @@ if (snapshot) snapshot.patched = [...collection]; } - function patchModelDefaults(value, allowed) { - const policy = modelFilterPolicy(allowed); + function patchModelDefaults(value) { const previous = codexPlusModelDefaultSnapshots.get(value); let changed = false; - if (previous && previous.policy !== policy) { + // 只撤销旧注入留下的默认值替换;宿主随后选择的值保持不动。 + if (previous) { for (const [key, state] of Object.entries(previous.fields)) { if (value[key] === state.patched && value[key] !== state.original) { value[key] = state.original; @@ -11538,22 +11538,6 @@ } codexPlusModelDefaultSnapshots.delete(value); } - if (!allowed) return changed; - const snapshot = codexPlusModelDefaultSnapshots.get(value) || { policy, fields: {} }; - const fallback = [codexModelCatalog.model, codexModelCatalog.default_model, ...allowed] - .find((name) => typeof name === "string" && allowed.has(name)); - for (const key of ["defaultModel", "default_model"]) { - const original = value[key]; - const name = typeof original === "string" ? original : original?.model || original?.id || original?.slug; - if (!name || allowed.has(name)) continue; - const patched = typeof original === "string" ? fallback : codexPlusModelDescriptor(fallback); - if (!snapshot.fields[key] || original !== snapshot.fields[key].patched) { - snapshot.fields[key] = { original, patched }; - } - value[key] = patched; - changed = true; - } - codexPlusModelDefaultSnapshots.set(value, snapshot); return changed; } @@ -11678,7 +11662,7 @@ value.hidden_models = value.hidden_models.filter((name) => !names.includes(name)); if (value.hidden_models.length !== before) changed = true; } - if (patchModelDefaults(value, allowed)) changed = true; + if (patchModelDefaults(value)) changed = true; return changed; } @@ -11744,7 +11728,7 @@ if (defaultSnapshot) codexPlusModelDefaultSnapshots.set(nextValue, { ...defaultSnapshot, fields: { ...defaultSnapshot.fields } }); hostId = modelResponseHostId(hostId, value); const listChanged = patchModelNameArray(availableModels, hostId); - const defaultsChanged = patchModelDefaults(nextValue, codexPureApiAllowedModelSet(hostId)); + const defaultsChanged = patchModelDefaults(nextValue); if (!listChanged && !defaultsChanged) return config; try { config.value = nextValue; diff --git a/assets/inject/renderer-inject/00-prelude.js b/assets/inject/renderer-inject/00-prelude.js index 7d2691f55d..7aee01a7f0 100644 --- a/assets/inject/renderer-inject/00-prelude.js +++ b/assets/inject/renderer-inject/00-prelude.js @@ -431,7 +431,7 @@ const codexPlusSettingsKey = "codexPlusSettings"; const codexThreadScrollKey = "codexThreadScroll"; const codexServiceTierRequestOverrideVersion = "10"; - const codexAppServerModelRequestPatchVersion = "27"; + const codexAppServerModelRequestPatchVersion = "28"; const codexAppServerClientCaptureMarker = "AppServerRequestClient is missing a message dispatcher"; const codexAppServerClientCaptureAnchor = "async sendRequest("; const codexRemoteSessionRecoveryVersion = "8"; diff --git a/assets/inject/renderer-inject/30-service-tier.js b/assets/inject/renderer-inject/30-service-tier.js index 9ed9f47ca4..182fd1df1d 100644 --- a/assets/inject/renderer-inject/30-service-tier.js +++ b/assets/inject/renderer-inject/30-service-tier.js @@ -90,8 +90,8 @@ if (!params || typeof params !== "object" || Array.isArray(params)) return params; const profile = codexRemoteSessionActiveProfile(); const pureApi = String(profile?.relayMode || "") === "pureApi"; - // Pure API 恢复旧会话时必须覆盖持久化的 openai;官登会话保留原生恢复语义。 - if (requestMethod === "thread/resume" && !pureApi) return params; + // 仅有 threadId 的恢复必须让原生读取保存的模型和推理设置;注入 provider 会跳过它们。 + if (requestMethod === "thread/resume" && (!pureApi || !params.model)) return params; if (requestMethod === "turn/start" && !pureApi) return params; const hasModelProvider = Object.prototype.hasOwnProperty.call(params, "modelProvider") || Object.prototype.hasOwnProperty.call(params, "model_provider"); diff --git a/assets/inject/renderer-inject/70-model-catalog.js b/assets/inject/renderer-inject/70-model-catalog.js index df9c4e3699..1f7b797daf 100644 --- a/assets/inject/renderer-inject/70-model-catalog.js +++ b/assets/inject/renderer-inject/70-model-catalog.js @@ -275,11 +275,11 @@ if (snapshot) snapshot.patched = [...collection]; } - function patchModelDefaults(value, allowed) { - const policy = modelFilterPolicy(allowed); + function patchModelDefaults(value) { const previous = codexPlusModelDefaultSnapshots.get(value); let changed = false; - if (previous && previous.policy !== policy) { + // 只撤销旧注入留下的默认值替换;宿主随后选择的值保持不动。 + if (previous) { for (const [key, state] of Object.entries(previous.fields)) { if (value[key] === state.patched && value[key] !== state.original) { value[key] = state.original; @@ -288,22 +288,6 @@ } codexPlusModelDefaultSnapshots.delete(value); } - if (!allowed) return changed; - const snapshot = codexPlusModelDefaultSnapshots.get(value) || { policy, fields: {} }; - const fallback = [codexModelCatalog.model, codexModelCatalog.default_model, ...allowed] - .find((name) => typeof name === "string" && allowed.has(name)); - for (const key of ["defaultModel", "default_model"]) { - const original = value[key]; - const name = typeof original === "string" ? original : original?.model || original?.id || original?.slug; - if (!name || allowed.has(name)) continue; - const patched = typeof original === "string" ? fallback : codexPlusModelDescriptor(fallback); - if (!snapshot.fields[key] || original !== snapshot.fields[key].patched) { - snapshot.fields[key] = { original, patched }; - } - value[key] = patched; - changed = true; - } - codexPlusModelDefaultSnapshots.set(value, snapshot); return changed; } @@ -428,7 +412,7 @@ value.hidden_models = value.hidden_models.filter((name) => !names.includes(name)); if (value.hidden_models.length !== before) changed = true; } - if (patchModelDefaults(value, allowed)) changed = true; + if (patchModelDefaults(value)) changed = true; return changed; } @@ -494,7 +478,7 @@ if (defaultSnapshot) codexPlusModelDefaultSnapshots.set(nextValue, { ...defaultSnapshot, fields: { ...defaultSnapshot.fields } }); hostId = modelResponseHostId(hostId, value); const listChanged = patchModelNameArray(availableModels, hostId); - const defaultsChanged = patchModelDefaults(nextValue, codexPureApiAllowedModelSet(hostId)); + const defaultsChanged = patchModelDefaults(nextValue); if (!listChanged && !defaultsChanged) return config; try { config.value = nextValue; diff --git a/assets/inject/renderer-inject/manifest.json b/assets/inject/renderer-inject/manifest.json index fb2d6c26e0..630d01b2ca 100644 --- a/assets/inject/renderer-inject/manifest.json +++ b/assets/inject/renderer-inject/manifest.json @@ -58,7 +58,7 @@ { "name": "70-model-catalog.js", "description": "全局状态、模型目录、模型白名单与解锁", - "lines": 1157 + "lines": 1141 }, { "name": "71-model-picker-groups.js", diff --git a/crates/codex-plus-core/tests/all/cdp_bridge.rs b/crates/codex-plus-core/tests/all/cdp_bridge.rs index aaeddc8e87..72a1ea96f2 100644 --- a/crates/codex-plus-core/tests/all/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/all/cdp_bridge.rs @@ -3345,7 +3345,7 @@ fn injection_script_unlocks_custom_model_catalog() { assert!(script.contains("loadAppServerRequestCandidates")); assert!(script.contains("appServerFallbackAssetUrls")); assert!(script.contains("collectAppServerRequestCandidatesFromModule")); - assert!(script.contains("codexAppServerModelRequestPatchVersion = \"27\"")); + assert!(script.contains("codexAppServerModelRequestPatchVersion = \"28\"")); assert!(script.contains("list-models-for-host")); assert!(script.contains("appServerModelRequestMethod")); @@ -3390,6 +3390,7 @@ fn model_whitelist_never_claims_the_host_default_model() { format!( r#" const codexModelCatalog = {{ + status: "ok", default_model: "supplier-default", model: "supplier-default", provider_name: "Supplier", @@ -3399,28 +3400,31 @@ const codexPlusModelMetadata = () => null; const modelReasoningEfforts = () => []; const applyCodexPlusModelMetadata = () => false; const codexPlusModelNames = () => ["supplier-default", "extra-model"]; -const codexRemoteSessionActiveProfile = () => ({{ id: "custom", relayMode: "official" }}); +let relayMode = "official"; +const codexRemoteSessionActiveProfile = () => ({{ id: "custom", relayMode }}); const codexPlusModelUnlockEnabled = () => true; const codexPlusBackendSettingsLoaded = true; const codexPlusModelCollectionSnapshots = new WeakMap(); const codexPlusModelDefaultSnapshots = new WeakMap(); {function_source} +for (const mode of ["official", "pureApi"]) {{ +relayMode = mode; const missingDefault = {{ value: {{ available_models: ["native-model"] }} }}; -const patchedMissingDefault = patchStatsigModelDynamicConfig(missingDefault); +const patchedMissingDefault = patchStatsigModelDynamicConfig(missingDefault, "local"); if (Object.prototype.hasOwnProperty.call(patchedMissingDefault.value, "default_model")) process.exit(2); if (!patchedMissingDefault.value.available_models.includes("supplier-default")) process.exit(3); if (!patchedMissingDefault.value.available_models.includes("extra-model")) process.exit(4); const existingDefault = {{ value: {{ available_models: ["native-model"], default_model: "thread-model" }} }}; -const patchedExistingDefault = patchStatsigModelDynamicConfig(existingDefault); +const patchedExistingDefault = patchStatsigModelDynamicConfig(existingDefault, "local"); if (patchedExistingDefault.value.default_model !== "thread-model") process.exit(5); const missingContainerDefault = {{ models: [{{ model: "native-model", isDefault: true }}], availableModels: ["native-model"], }}; -patchModelContainer(missingContainerDefault); +patchModelContainer(missingContainerDefault, "local"); if (Object.prototype.hasOwnProperty.call(missingContainerDefault, "defaultModel")) process.exit(6); if (Object.prototype.hasOwnProperty.call(missingContainerDefault, "model")) process.exit(7); const injectedDefault = missingContainerDefault.models.find((item) => item.model === "supplier-default"); @@ -3434,9 +3438,21 @@ const existingContainerDefault = {{ defaultModel: hostDefault, model: hostModel, }}; -patchModelContainer(existingContainerDefault); +patchModelContainer(existingContainerDefault, "local"); if (existingContainerDefault.defaultModel !== hostDefault) process.exit(9); if (existingContainerDefault.model !== hostModel) process.exit(10); +}} + +const originalDefault = {{ model: "native-model" }}; +const legacyDefault = {{ model: "supplier-default" }}; +const legacy = {{ defaultModel: legacyDefault, default_model: "new-native-choice" }}; +codexPlusModelDefaultSnapshots.set(legacy, {{ fields: {{ + defaultModel: {{ original: originalDefault, patched: legacyDefault }}, + default_model: {{ original: "old-native-choice", patched: "supplier-default" }}, +}} }}); +patchModelContainer(legacy, "local"); +if (legacy.defaultModel !== originalDefault || legacy.default_model !== "new-native-choice") process.exit(11); +if (codexPlusModelDefaultSnapshots.has(legacy)) process.exit(12); "# ), ) @@ -3664,8 +3680,8 @@ fn injection_script_preserves_native_tiers_and_shared_provider_contracts() { assert_eq!(cases["missingActiveProviderUnchanged"], true); assert_eq!(cases["missingActiveRecoveryUnscheduled"], true); assert_eq!(cases["pureApiThreadStartProvider"], "custom"); - assert_eq!(cases["pureApiThreadResumeProvider"], "custom"); - assert_eq!(cases["pureApiNativeResumeProvider"], "custom"); + assert_eq!(cases["pureApiThreadResumePreserved"], true); + assert_eq!(cases["pureApiNativeResumePreserved"], true); assert_eq!(cases["pureApiExplicitResumePreserved"], true); assert_eq!(cases["officialResumePreserved"], true); assert_eq!(cases["pureApiTurnStartProvider"], "custom"); @@ -3712,8 +3728,8 @@ fn injection_script_preserves_native_tiers_and_shared_provider_contracts() { cases["unfilteredModelScenarios"], json!([true, true, true, true, true]) ); - assert_eq!(cases["pureApiDefaultModel"], "relay-model"); - assert_eq!(cases["pureApiStatsigDefault"], "relay-model"); + assert_eq!(cases["pureApiDefaultModel"], "gpt-unavailable"); + assert_eq!(cases["pureApiStatsigDefault"], "gpt-unavailable"); assert_eq!(cases["modelsRestoredAfterModeChange"], true); assert_eq!(cases["modelsRestoredAfterReinjection"], true); assert_eq!(cases["modelsRestoredAfterCatalogFailure"], true); @@ -4309,9 +4325,9 @@ const pureApiThreadResumeParams = {{ threadId: "thread-mobile-pure-api", model_provider: "openai", }}; -const pureApiThreadResumeProvider = api.applyProviderOverride("thread/resume", pureApiThreadResumeParams)?.modelProvider; +const pureApiThreadResumePreserved = api.applyProviderOverride("thread/resume", pureApiThreadResumeParams) === pureApiThreadResumeParams; const pureApiNativeResumeParams = {{ threadId: "thread-mobile-native-resume" }}; -const pureApiNativeResumeProvider = api.applyProviderOverride("thread/resume", pureApiNativeResumeParams)?.modelProvider; +const pureApiNativeResumePreserved = api.applyProviderOverride("thread/resume", pureApiNativeResumeParams) === pureApiNativeResumeParams; const explicitResume = {{ threadId: "thread-other-provider", modelProvider: "other", model: "gpt-custom", reasoningEffort: "high" }}; const pureApiExplicitResumePreserved = api.applyProviderOverride("thread/resume", explicitResume) === explicitResume; const pureApiTurnStartProvider = api.applyProviderOverride("turn/start", {{ @@ -4669,8 +4685,8 @@ process.stdout.write(JSON.stringify({{ missingActiveProviderUnchanged, missingActiveRecoveryUnscheduled, pureApiThreadStartProvider, - pureApiThreadResumeProvider, - pureApiNativeResumeProvider, + pureApiThreadResumePreserved, + pureApiNativeResumePreserved, pureApiExplicitResumePreserved, officialResumePreserved, pureApiTurnStartProvider, From 110ac249931c36d451625382b14d3c064b66bc4b Mon Sep 17 00:00:00 2001 From: Yuimi-chaya Date: Thu, 29 Oct 2026 17:30:39 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(app):=20=E6=94=B6=E7=AA=84=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E6=81=A2=E5=A4=8D=E6=94=BE=E8=A1=8C=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E5=B9=B6=E4=BF=9D=E7=95=99=E9=BB=98=E8=AE=A4=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/thread-model-preservation.test.ts | 43 ++++++++++++---- assets/inject/renderer-inject.js | 35 +++++++++---- assets/inject/renderer-inject/00-prelude.js | 2 +- .../inject/renderer-inject/30-service-tier.js | 7 +-- .../renderer-inject/70-model-catalog.js | 26 ++++++++-- assets/inject/renderer-inject/manifest.json | 4 +- .../codex-plus-core/tests/all/cdp_bridge.rs | 50 ++++++++----------- 7 files changed, 110 insertions(+), 57 deletions(-) diff --git a/apps/codex-plus-manager/src/thread-model-preservation.test.ts b/apps/codex-plus-manager/src/thread-model-preservation.test.ts index 2c6e619fdb..d4924b09d8 100644 --- a/apps/codex-plus-manager/src/thread-model-preservation.test.ts +++ b/apps/codex-plus-manager/src/thread-model-preservation.test.ts @@ -4,14 +4,14 @@ import { describe, it } from "node:test"; const source = readFileSync(new URL( "../../../assets/inject/renderer-inject/30-service-tier.js", import.meta.url), "utf8"); -const override = source.slice(source.indexOf(" function codexRemoteSessionProviderRequestMethod("), - source.indexOf(" function codexManagedSessionConnection(")); +const override = source.slice(0, source.indexOf(" function codexManagedSessionConnection(")); -function runtime(mode = "pureApi") { - return new Function("codexRemoteSessionProviderOverrideEnabled", "codexRemoteSessionActiveProfile", - "codexRemoteSessionTargetProvider", "sendCodexPlusDiagnostic", +function runtime(mode = "pureApi", providerSyncEnabled = false) { + return new Function("codexPlusBackendSettings", "codexModelCatalog", "sendCodexPlusDiagnostic", `${override}\nreturn applyCodexRemoteSessionProviderOverride;`)( - () => true, () => ({ relayMode: mode }), () => "custom", () => {}); + { relayProfilesEnabled: true, activeRelayId: "relay", providerSyncEnabled, + relayProfiles: [{ id: "relay", relayMode: mode, configContents: 'model_provider = "custom"' }] }, + {}, () => {}); } describe("thread model restoration ownership", () => { @@ -34,11 +34,9 @@ describe("thread model restoration ownership", () => { } }); - it("preserves explicit provider spelling, null models and caller reasoning", () => { + it("preserves field-free and foreign-provider resumes", () => { const apply = runtime(); for (const params of [ - { threadId: "thread", model_provider: "openai" }, - { threadId: "thread", modelProvider: "openai", model: null }, { threadId: "thread", reasoningEffort: "high" }, { threadId: "thread", model: "chosen", modelProvider: "other", reasoningEffort: "ultra" }, ]) { @@ -46,6 +44,33 @@ describe("thread model restoration ownership", () => { } }); + it("rewrites explicit openai providers with supplier sync enabled or disabled", () => { + for (const sync of [false, true]) { + const apply = runtime("pureApi", sync); + for (const key of ["modelProvider", "model_provider"]) { + const params = { threadId: "old-openai", [key]: "openai" }; + const snapshot = { ...params }; + const patched = apply("thread/resume", params); + assert.equal(patched.modelProvider, "custom"); + assert.equal(Object.hasOwn(patched, "model_provider"), false); + assert.equal(Object.hasOwn(patched, "model"), false); + assert.deepEqual(params, snapshot); + } + } + }); + + it("treats model and provider fields by presence, including null and empty values", () => { + const apply = runtime(); + for (const params of [ + { threadId: "thread", model: null }, + { threadId: "thread", model: "" }, + { threadId: "thread", modelProvider: null }, + { threadId: "thread", model_provider: "" }, + ]) { + assert.equal(apply("thread/resume", params).modelProvider, "custom"); + } + }); + it("retains provider routing for starts and explicit model switches", () => { const apply = runtime(); const params = { threadId: "thread", model: "chosen", reasoningEffort: "high" }; diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 3007d6273a..31b07c300d 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -431,7 +431,7 @@ const codexPlusSettingsKey = "codexPlusSettings"; const codexThreadScrollKey = "codexThreadScroll"; const codexServiceTierRequestOverrideVersion = "10"; - const codexAppServerModelRequestPatchVersion = "28"; + const codexAppServerModelRequestPatchVersion = "29"; const codexAppServerClientCaptureMarker = "AppServerRequestClient is missing a message dispatcher"; const codexAppServerClientCaptureAnchor = "async sendRequest("; const codexRemoteSessionRecoveryVersion = "8"; @@ -5455,11 +5455,12 @@ if (!params || typeof params !== "object" || Array.isArray(params)) return params; const profile = codexRemoteSessionActiveProfile(); const pureApi = String(profile?.relayMode || "") === "pureApi"; - // 仅有 threadId 的恢复必须让原生读取保存的模型和推理设置;注入 provider 会跳过它们。 - if (requestMethod === "thread/resume" && (!pureApi || !params.model)) return params; - if (requestMethod === "turn/start" && !pureApi) return params; const hasModelProvider = Object.prototype.hasOwnProperty.call(params, "modelProvider") || Object.prototype.hasOwnProperty.call(params, "model_provider"); + const hasModel = Object.prototype.hasOwnProperty.call(params, "model"); + // 仅无模型/provider 字段的恢复交给原生;显式 provider 仍需沿用供应商路由。 + if (requestMethod === "thread/resume" && (!pureApi || (!hasModel && !hasModelProvider))) return params; + if (requestMethod === "turn/start" && !pureApi) return params; if (requestMethod === "turn/start" && !hasModelProvider) return params; const targetProvider = codexRemoteSessionTargetProvider(); if (!targetProvider || targetProvider === "openai") return params; @@ -11525,11 +11526,11 @@ if (snapshot) snapshot.patched = [...collection]; } - function patchModelDefaults(value) { + function patchModelDefaults(value, allowed) { + const policy = modelFilterPolicy(allowed); const previous = codexPlusModelDefaultSnapshots.get(value); let changed = false; - // 只撤销旧注入留下的默认值替换;宿主随后选择的值保持不动。 - if (previous) { + if (previous && previous.policy !== policy) { for (const [key, state] of Object.entries(previous.fields)) { if (value[key] === state.patched && value[key] !== state.original) { value[key] = state.original; @@ -11538,6 +11539,22 @@ } codexPlusModelDefaultSnapshots.delete(value); } + if (!allowed) return changed; + const snapshot = codexPlusModelDefaultSnapshots.get(value) || { policy, fields: {} }; + const fallback = [codexModelCatalog.model, codexModelCatalog.default_model, ...allowed] + .find((name) => typeof name === "string" && allowed.has(name)); + for (const key of ["defaultModel", "default_model"]) { + const original = value[key]; + const name = typeof original === "string" ? original : original?.model || original?.id || original?.slug; + if (!name || allowed.has(name)) continue; + const patched = typeof original === "string" ? fallback : codexPlusModelDescriptor(fallback); + if (!snapshot.fields[key] || original !== snapshot.fields[key].patched) { + snapshot.fields[key] = { original, patched }; + } + value[key] = patched; + changed = true; + } + codexPlusModelDefaultSnapshots.set(value, snapshot); return changed; } @@ -11662,7 +11679,7 @@ value.hidden_models = value.hidden_models.filter((name) => !names.includes(name)); if (value.hidden_models.length !== before) changed = true; } - if (patchModelDefaults(value)) changed = true; + if (patchModelDefaults(value, allowed)) changed = true; return changed; } @@ -11728,7 +11745,7 @@ if (defaultSnapshot) codexPlusModelDefaultSnapshots.set(nextValue, { ...defaultSnapshot, fields: { ...defaultSnapshot.fields } }); hostId = modelResponseHostId(hostId, value); const listChanged = patchModelNameArray(availableModels, hostId); - const defaultsChanged = patchModelDefaults(nextValue); + const defaultsChanged = patchModelDefaults(nextValue, codexPureApiAllowedModelSet(hostId)); if (!listChanged && !defaultsChanged) return config; try { config.value = nextValue; diff --git a/assets/inject/renderer-inject/00-prelude.js b/assets/inject/renderer-inject/00-prelude.js index 7aee01a7f0..a4991367ff 100644 --- a/assets/inject/renderer-inject/00-prelude.js +++ b/assets/inject/renderer-inject/00-prelude.js @@ -431,7 +431,7 @@ const codexPlusSettingsKey = "codexPlusSettings"; const codexThreadScrollKey = "codexThreadScroll"; const codexServiceTierRequestOverrideVersion = "10"; - const codexAppServerModelRequestPatchVersion = "28"; + const codexAppServerModelRequestPatchVersion = "29"; const codexAppServerClientCaptureMarker = "AppServerRequestClient is missing a message dispatcher"; const codexAppServerClientCaptureAnchor = "async sendRequest("; const codexRemoteSessionRecoveryVersion = "8"; diff --git a/assets/inject/renderer-inject/30-service-tier.js b/assets/inject/renderer-inject/30-service-tier.js index 182fd1df1d..930fa43deb 100644 --- a/assets/inject/renderer-inject/30-service-tier.js +++ b/assets/inject/renderer-inject/30-service-tier.js @@ -90,11 +90,12 @@ if (!params || typeof params !== "object" || Array.isArray(params)) return params; const profile = codexRemoteSessionActiveProfile(); const pureApi = String(profile?.relayMode || "") === "pureApi"; - // 仅有 threadId 的恢复必须让原生读取保存的模型和推理设置;注入 provider 会跳过它们。 - if (requestMethod === "thread/resume" && (!pureApi || !params.model)) return params; - if (requestMethod === "turn/start" && !pureApi) return params; const hasModelProvider = Object.prototype.hasOwnProperty.call(params, "modelProvider") || Object.prototype.hasOwnProperty.call(params, "model_provider"); + const hasModel = Object.prototype.hasOwnProperty.call(params, "model"); + // 仅无模型/provider 字段的恢复交给原生;显式 provider 仍需沿用供应商路由。 + if (requestMethod === "thread/resume" && (!pureApi || (!hasModel && !hasModelProvider))) return params; + if (requestMethod === "turn/start" && !pureApi) return params; if (requestMethod === "turn/start" && !hasModelProvider) return params; const targetProvider = codexRemoteSessionTargetProvider(); if (!targetProvider || targetProvider === "openai") return params; diff --git a/assets/inject/renderer-inject/70-model-catalog.js b/assets/inject/renderer-inject/70-model-catalog.js index 1f7b797daf..df9c4e3699 100644 --- a/assets/inject/renderer-inject/70-model-catalog.js +++ b/assets/inject/renderer-inject/70-model-catalog.js @@ -275,11 +275,11 @@ if (snapshot) snapshot.patched = [...collection]; } - function patchModelDefaults(value) { + function patchModelDefaults(value, allowed) { + const policy = modelFilterPolicy(allowed); const previous = codexPlusModelDefaultSnapshots.get(value); let changed = false; - // 只撤销旧注入留下的默认值替换;宿主随后选择的值保持不动。 - if (previous) { + if (previous && previous.policy !== policy) { for (const [key, state] of Object.entries(previous.fields)) { if (value[key] === state.patched && value[key] !== state.original) { value[key] = state.original; @@ -288,6 +288,22 @@ } codexPlusModelDefaultSnapshots.delete(value); } + if (!allowed) return changed; + const snapshot = codexPlusModelDefaultSnapshots.get(value) || { policy, fields: {} }; + const fallback = [codexModelCatalog.model, codexModelCatalog.default_model, ...allowed] + .find((name) => typeof name === "string" && allowed.has(name)); + for (const key of ["defaultModel", "default_model"]) { + const original = value[key]; + const name = typeof original === "string" ? original : original?.model || original?.id || original?.slug; + if (!name || allowed.has(name)) continue; + const patched = typeof original === "string" ? fallback : codexPlusModelDescriptor(fallback); + if (!snapshot.fields[key] || original !== snapshot.fields[key].patched) { + snapshot.fields[key] = { original, patched }; + } + value[key] = patched; + changed = true; + } + codexPlusModelDefaultSnapshots.set(value, snapshot); return changed; } @@ -412,7 +428,7 @@ value.hidden_models = value.hidden_models.filter((name) => !names.includes(name)); if (value.hidden_models.length !== before) changed = true; } - if (patchModelDefaults(value)) changed = true; + if (patchModelDefaults(value, allowed)) changed = true; return changed; } @@ -478,7 +494,7 @@ if (defaultSnapshot) codexPlusModelDefaultSnapshots.set(nextValue, { ...defaultSnapshot, fields: { ...defaultSnapshot.fields } }); hostId = modelResponseHostId(hostId, value); const listChanged = patchModelNameArray(availableModels, hostId); - const defaultsChanged = patchModelDefaults(nextValue); + const defaultsChanged = patchModelDefaults(nextValue, codexPureApiAllowedModelSet(hostId)); if (!listChanged && !defaultsChanged) return config; try { config.value = nextValue; diff --git a/assets/inject/renderer-inject/manifest.json b/assets/inject/renderer-inject/manifest.json index 630d01b2ca..c5c19b6c0f 100644 --- a/assets/inject/renderer-inject/manifest.json +++ b/assets/inject/renderer-inject/manifest.json @@ -23,7 +23,7 @@ { "name": "30-service-tier.js", "description": "供应商会话适配、请求桥接与原生状态模块定位", - "lines": 900 + "lines": 901 }, { "name": "31-config-read-deadline.js", @@ -58,7 +58,7 @@ { "name": "70-model-catalog.js", "description": "全局状态、模型目录、模型白名单与解锁", - "lines": 1141 + "lines": 1157 }, { "name": "71-model-picker-groups.js", diff --git a/crates/codex-plus-core/tests/all/cdp_bridge.rs b/crates/codex-plus-core/tests/all/cdp_bridge.rs index 72a1ea96f2..5edbdb2b96 100644 --- a/crates/codex-plus-core/tests/all/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/all/cdp_bridge.rs @@ -3345,7 +3345,7 @@ fn injection_script_unlocks_custom_model_catalog() { assert!(script.contains("loadAppServerRequestCandidates")); assert!(script.contains("appServerFallbackAssetUrls")); assert!(script.contains("collectAppServerRequestCandidatesFromModule")); - assert!(script.contains("codexAppServerModelRequestPatchVersion = \"28\"")); + assert!(script.contains("codexAppServerModelRequestPatchVersion = \"29\"")); assert!(script.contains("list-models-for-host")); assert!(script.contains("appServerModelRequestMethod")); @@ -3390,7 +3390,6 @@ fn model_whitelist_never_claims_the_host_default_model() { format!( r#" const codexModelCatalog = {{ - status: "ok", default_model: "supplier-default", model: "supplier-default", provider_name: "Supplier", @@ -3400,31 +3399,28 @@ const codexPlusModelMetadata = () => null; const modelReasoningEfforts = () => []; const applyCodexPlusModelMetadata = () => false; const codexPlusModelNames = () => ["supplier-default", "extra-model"]; -let relayMode = "official"; -const codexRemoteSessionActiveProfile = () => ({{ id: "custom", relayMode }}); +const codexRemoteSessionActiveProfile = () => ({{ id: "custom", relayMode: "official" }}); const codexPlusModelUnlockEnabled = () => true; const codexPlusBackendSettingsLoaded = true; const codexPlusModelCollectionSnapshots = new WeakMap(); const codexPlusModelDefaultSnapshots = new WeakMap(); {function_source} -for (const mode of ["official", "pureApi"]) {{ -relayMode = mode; const missingDefault = {{ value: {{ available_models: ["native-model"] }} }}; -const patchedMissingDefault = patchStatsigModelDynamicConfig(missingDefault, "local"); +const patchedMissingDefault = patchStatsigModelDynamicConfig(missingDefault); if (Object.prototype.hasOwnProperty.call(patchedMissingDefault.value, "default_model")) process.exit(2); if (!patchedMissingDefault.value.available_models.includes("supplier-default")) process.exit(3); if (!patchedMissingDefault.value.available_models.includes("extra-model")) process.exit(4); const existingDefault = {{ value: {{ available_models: ["native-model"], default_model: "thread-model" }} }}; -const patchedExistingDefault = patchStatsigModelDynamicConfig(existingDefault, "local"); +const patchedExistingDefault = patchStatsigModelDynamicConfig(existingDefault); if (patchedExistingDefault.value.default_model !== "thread-model") process.exit(5); const missingContainerDefault = {{ models: [{{ model: "native-model", isDefault: true }}], availableModels: ["native-model"], }}; -patchModelContainer(missingContainerDefault, "local"); +patchModelContainer(missingContainerDefault); if (Object.prototype.hasOwnProperty.call(missingContainerDefault, "defaultModel")) process.exit(6); if (Object.prototype.hasOwnProperty.call(missingContainerDefault, "model")) process.exit(7); const injectedDefault = missingContainerDefault.models.find((item) => item.model === "supplier-default"); @@ -3438,21 +3434,9 @@ const existingContainerDefault = {{ defaultModel: hostDefault, model: hostModel, }}; -patchModelContainer(existingContainerDefault, "local"); +patchModelContainer(existingContainerDefault); if (existingContainerDefault.defaultModel !== hostDefault) process.exit(9); if (existingContainerDefault.model !== hostModel) process.exit(10); -}} - -const originalDefault = {{ model: "native-model" }}; -const legacyDefault = {{ model: "supplier-default" }}; -const legacy = {{ defaultModel: legacyDefault, default_model: "new-native-choice" }}; -codexPlusModelDefaultSnapshots.set(legacy, {{ fields: {{ - defaultModel: {{ original: originalDefault, patched: legacyDefault }}, - default_model: {{ original: "old-native-choice", patched: "supplier-default" }}, -}} }}); -patchModelContainer(legacy, "local"); -if (legacy.defaultModel !== originalDefault || legacy.default_model !== "new-native-choice") process.exit(11); -if (codexPlusModelDefaultSnapshots.has(legacy)) process.exit(12); "# ), ) @@ -3676,11 +3660,13 @@ fn injection_script_preserves_native_tiers_and_shared_provider_contracts() { assert_eq!(cases["refreshedOpenAiProvider"], "openai"); assert_eq!(cases["refreshedPureApiResumeProvider"], "custom"); assert_eq!(cases["refreshedPureApiResumeModel"], "gpt-5.6-terra"); + assert_eq!(cases["syncDisabledExplicitProvider"], "custom"); + assert_eq!(cases["syncDisabledModelAbsent"], true); assert_eq!(cases["failedRefreshProviderUnchanged"], "openai"); assert_eq!(cases["missingActiveProviderUnchanged"], true); assert_eq!(cases["missingActiveRecoveryUnscheduled"], true); assert_eq!(cases["pureApiThreadStartProvider"], "custom"); - assert_eq!(cases["pureApiThreadResumePreserved"], true); + assert_eq!(cases["pureApiThreadResumeProvider"], "custom"); assert_eq!(cases["pureApiNativeResumePreserved"], true); assert_eq!(cases["pureApiExplicitResumePreserved"], true); assert_eq!(cases["officialResumePreserved"], true); @@ -3728,8 +3714,8 @@ fn injection_script_preserves_native_tiers_and_shared_provider_contracts() { cases["unfilteredModelScenarios"], json!([true, true, true, true, true]) ); - assert_eq!(cases["pureApiDefaultModel"], "gpt-unavailable"); - assert_eq!(cases["pureApiStatsigDefault"], "gpt-unavailable"); + assert_eq!(cases["pureApiDefaultModel"], "relay-model"); + assert_eq!(cases["pureApiStatsigDefault"], "relay-model"); assert_eq!(cases["modelsRestoredAfterModeChange"], true); assert_eq!(cases["modelsRestoredAfterReinjection"], true); assert_eq!(cases["modelsRestoredAfterCatalogFailure"], true); @@ -4272,7 +4258,7 @@ window.__codexSessionDeleteBridge = async (path) => {{ if (path === "/settings/get") return {{ launchMode: "patch", enhancementsEnabled: true, - providerSyncEnabled: true, + providerSyncEnabled: false, relayProfilesEnabled: true, activeRelayId: "refreshed-pure-api", activeRelaySessionProvider: "custom", @@ -4288,6 +4274,12 @@ await appServerClient.sendRequest("thread/resume", {{ }}, {{ signal: "pure-api-switch" }}); const refreshedPureApiResumeProvider = appServerCalls.at(-1)?.params?.modelProvider || ""; const refreshedPureApiResumeModel = appServerCalls.at(-1)?.params?.model || ""; +await appServerClient.sendRequest("thread/resume", {{ + threadId: "thread-mobile-openai-sync-disabled", + model_provider: "openai", +}}, {{ signal: "pure-api-sync-disabled" }}); +const syncDisabledExplicitProvider = appServerCalls.at(-1)?.params?.modelProvider || ""; +const syncDisabledModelAbsent = !Object.prototype.hasOwnProperty.call(appServerCalls.at(-1)?.params || {{}}, "model"); delete window.__codexSessionDeleteBridge; api.setBackendSettings({{ relayProfilesEnabled: true, @@ -4325,7 +4317,7 @@ const pureApiThreadResumeParams = {{ threadId: "thread-mobile-pure-api", model_provider: "openai", }}; -const pureApiThreadResumePreserved = api.applyProviderOverride("thread/resume", pureApiThreadResumeParams) === pureApiThreadResumeParams; +const pureApiThreadResumeProvider = api.applyProviderOverride("thread/resume", pureApiThreadResumeParams)?.modelProvider; const pureApiNativeResumeParams = {{ threadId: "thread-mobile-native-resume" }}; const pureApiNativeResumePreserved = api.applyProviderOverride("thread/resume", pureApiNativeResumeParams) === pureApiNativeResumeParams; const explicitResume = {{ threadId: "thread-other-provider", modelProvider: "other", model: "gpt-custom", reasoningEffort: "high" }}; @@ -4681,11 +4673,13 @@ process.stdout.write(JSON.stringify({{ refreshedOpenAiProvider, refreshedPureApiResumeProvider, refreshedPureApiResumeModel, + syncDisabledExplicitProvider, + syncDisabledModelAbsent, failedRefreshProviderUnchanged, missingActiveProviderUnchanged, missingActiveRecoveryUnscheduled, pureApiThreadStartProvider, - pureApiThreadResumePreserved, + pureApiThreadResumeProvider, pureApiNativeResumePreserved, pureApiExplicitResumePreserved, officialResumePreserved, From 72578206276cbe22c5ee6bf32c1c7d3f33f4cb39 Mon Sep 17 00:00:00 2001 From: Yuimi-chaya Date: Thu, 29 Oct 2026 17:40:34 +0800 Subject: [PATCH 3/7] =?UTF-8?q?test(app):=20=E5=90=8C=E6=AD=A5=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=81=A2=E5=A4=8D=E7=94=A8=E4=BE=8B=E7=9A=84=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E8=AE=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/codex-plus-core/tests/all/cdp_bridge.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/codex-plus-core/tests/all/cdp_bridge.rs b/crates/codex-plus-core/tests/all/cdp_bridge.rs index 5edbdb2b96..3fe61536f1 100644 --- a/crates/codex-plus-core/tests/all/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/all/cdp_bridge.rs @@ -3597,7 +3597,7 @@ fn injection_script_preserves_native_tiers_and_shared_provider_contracts() { assert_eq!(cases["currentStateApi"], true); assert_eq!(cases["appServerParamsUnchanged"], true); assert_eq!(cases["appServerProjectlessParamsUnchanged"], true); - assert_eq!(cases["appServerSentCount"], 7); + assert_eq!(cases["appServerSentCount"], 8); assert_eq!(cases["serviceTierClientPatched"], true); assert_eq!(cases["serviceTierClientSendTier"], "priority"); assert_eq!(cases["serviceTierClientPrewarmTier"], "priority"); From 35eded6f04845b08e1693487d69964b93f594906 Mon Sep 17 00:00:00 2001 From: Yuimi-chaya Date: Thu, 29 Oct 2026 19:17:55 +0800 Subject: [PATCH 4/7] fix: restore pure API threads with saved model settings and current provider --- .../src/session-connection.test.ts | 2 + .../src/thread-model-preservation.test.ts | 230 ++++++++++++++++++ assets/inject/renderer-inject.js | 104 +++++++- assets/inject/renderer-inject/00-prelude.js | 2 +- .../inject/renderer-inject/30-service-tier.js | 94 +++++++ .../renderer-inject/70-model-catalog.js | 8 +- assets/inject/renderer-inject/manifest.json | 4 +- crates/codex-plus-core/src/codex_sqlite.rs | 68 ++++++ crates/codex-plus-core/src/routes.rs | 18 ++ .../tests/all/bridge_routes.rs | 11 + .../codex-plus-core/tests/all/cdp_bridge.rs | 2 +- .../codex-plus-core/tests/all/codex_sqlite.rs | 70 ++++++ 12 files changed, 604 insertions(+), 9 deletions(-) diff --git a/apps/codex-plus-manager/src/session-connection.test.ts b/apps/codex-plus-manager/src/session-connection.test.ts index 2c05690896..e0a8b39a8d 100644 --- a/apps/codex-plus-manager/src/session-connection.test.ts +++ b/apps/codex-plus-manager/src/session-connection.test.ts @@ -334,6 +334,8 @@ describe("managed aggregate session connection", () => { codexRemoteSessionProviderPatchEnabled: () => true, codexRemoteSessionProviderOverrideEnabled: () => false, applyCodexRemoteSessionProviderOverride: (_method: string, params: any) => params, + prepareCodexPureApiResume: async (_method: string, params: any) => params, + verifyCodexPureApiResumeResult() {}, applyCodexServiceTierRequestOnly: (_method: string, params: any) => params, loadBackendSettingsState: async () => { calls.push("settings/get"); return true; }, codexAppServerPreparationTimeoutMs: 1500, diff --git a/apps/codex-plus-manager/src/thread-model-preservation.test.ts b/apps/codex-plus-manager/src/thread-model-preservation.test.ts index d4924b09d8..4f9c71e502 100644 --- a/apps/codex-plus-manager/src/thread-model-preservation.test.ts +++ b/apps/codex-plus-manager/src/thread-model-preservation.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { describe, it } from "node:test"; +import vm from "node:vm"; const source = readFileSync(new URL( "../../../assets/inject/renderer-inject/30-service-tier.js", import.meta.url), "utf8"); @@ -80,3 +81,232 @@ describe("thread model restoration ownership", () => { assert.equal(runtime("official")("thread/resume", params), params); }); }); + +const readSource = source.slice(source.indexOf(" async function readCodexAppServerPreparation("), + source.indexOf(" async function loadBackendSettingsState(")); +function snapshotRuntime(read?: (payload: any) => Promise) { + const settings: any = { relayProfilesEnabled: true, activeRelayId: "relay", providerSyncEnabled: false, + relayProfiles: [{ id: "relay", relayMode: "pureApi", configContents: 'model_provider = "custom"' }] }; + const calls: any[] = []; + const create = new Function("codexPlusBackendSettings", "codexModelCatalog", "sendCodexPlusDiagnostic", + "postJson", "codexAppServerPreparationTimeoutMs", "window", `${override}\n${readSource} + return { prepare: prepareCodexPureApiResume, apply: applyCodexRemoteSessionProviderOverride, + verify: verifyCodexPureApiResumeResult };`); + const api = create(settings, {}, () => {}, async (route: string, payload: any) => { + assert.equal(route, "/thread-resume-model-metadata"); + calls.push(payload); + return read ? read(payload) : { status: "ok", threadId: payload.thread_id, model: "saved-model", reasoningEffort: "high" }; + }, 30, {}); + return { ...api, settings, calls }; +} + +describe("pure API persisted model snapshot", () => { + it("restores each saved model and effort with the current provider without changing caller objects", async () => { + const api = snapshotRuntime(async ({ thread_id }) => ({ status: "ok", threadId: thread_id, + model: `saved-${thread_id}`, reasoningEffort: thread_id === "a" ? "ultra" : "low" })); + for (const sync of [false, true]) { + api.settings.providerSyncEnabled = sync; + for (const threadId of ["a", "b"]) { + const params = { threadId, config: { model_context_window: 500000 } }; + const patched = await api.prepare("thread/resume", params, "local"); + assert.equal(patched.model, `saved-${threadId}`); + assert.equal(patched.modelProvider, "custom"); + assert.equal(patched.config.model_reasoning_effort, threadId === "a" ? "ultra" : "low"); + assert.equal(patched.config.model_context_window, 500000); + assert.deepEqual(params, { threadId, config: { model_context_window: 500000 } }); + } + } + }); + + it("preserves explicit reasoning overrides and native semantics for an unspecified saved effort", async () => { + const api = snapshotRuntime(); + const patched = await api.prepare("thread/resume", + { threadId: "a", config: { model_reasoning_effort: "low" } }, "local"); + assert.equal(patched.config.model_reasoning_effort, "low"); + const unspecified = snapshotRuntime(async ({ thread_id }) => + ({ status: "ok", threadId: thread_id, model: "saved-model", reasoningEffort: null })); + const result = await unspecified.prepare("thread/resume", { threadId: "a" }, "local"); + assert.equal(result.model, "saved-model"); + assert.equal(Object.hasOwn(result.config, "model_reasoning_effort"), false); + }); + + it("never probes remote hosts, official profiles or explicit model/provider requests", async () => { + const api = snapshotRuntime(async () => { throw new Error("must not read"); }); + for (const host of ["remote", "", undefined]) { + const params = { threadId: "a" }; + assert.equal(await api.prepare("thread/resume", params, host), params); + } + for (const params of [ + { threadId: "a", model: "chosen" }, { threadId: "a", modelProvider: "openai" }, + { threadId: "a", model_provider: "openai" }, + { hostId: "remote", method: "thread/resume", params: { threadId: "a" } }, + ]) { + assert.equal(await api.prepare("method" in params ? "send-cli-request-for-host" : "thread/resume", params, "local"), params); + } + const configured = await api.prepare("thread/resume", { threadId: "a", config: { model: "explicit" } }, "local"); + assert.equal(configured.model, "explicit"); + assert.equal(configured.modelProvider, "custom"); + api.settings.relayProfiles[0].relayMode = "official"; + const params = { threadId: "a" }; + assert.equal(await api.prepare("thread/resume", params, "local"), params); + assert.equal(api.calls.length, 0); + }); + + it("rejects incomplete, missing, mismatched or failed metadata instead of issuing an unsafe resume", async () => { + for (const data of [ + null, { status: "failed" }, { status: "ok", threadId: "other", model: "saved", reasoningEffort: "high" }, + { status: "ok", threadId: "a", model: "", reasoningEffort: "high" }, + { status: "ok", threadId: "a", model: "saved" }, + { status: "ok", threadId: "a", model: "saved", reasoningEffort: "" }, + ]) { + const api = snapshotRuntime(async () => data); + await assert.rejects(api.prepare("thread/resume", { threadId: "a" }, "local"), /无法读取/); + } + const failed = snapshotRuntime(async () => { throw new Error("SQLite unavailable"); }); + await assert.rejects(failed.prepare("thread/resume", { threadId: "a" }, "local"), /SQLite unavailable/); + await assert.rejects(failed.prepare("thread/resume", { threadId: "a" }, "local", true), /无法确认/); + assert.equal(failed.calls.length, 1); + }); + + it("bounds read waits and ignores late snapshots", async () => { + let finish!: (value: any) => void; + const api = snapshotRuntime(() => new Promise(resolve => { finish = resolve; })); + const params = { threadId: "a" }; + await assert.rejects(api.prepare("thread/resume", params, "local"), /timed out/); + finish({ status: "ok", threadId: "a", model: "late", reasoningEffort: "high" }); + await Promise.resolve(); + assert.deepEqual(params, { threadId: "a" }); + }); + + it("rejects a provider change while reading the saved model", async () => { + const api = snapshotRuntime(async ({ thread_id }) => { + api.settings.relayProfiles[0].configContents = 'model_provider = "changed"'; + return { status: "ok", threadId: thread_id, model: "saved", reasoningEffort: "high" }; + }); + await assert.rejects(api.prepare("thread/resume", { threadId: "a" }, "local"), /配置已变化/); + }); + + it("rejects model or config changes during the metadata read", async () => { + for (const key of ["model", "config"]) { + const params: any = { threadId: "a" }; + const api = snapshotRuntime(async ({ thread_id }) => { + params[key] = key === "model" ? "new-choice" : { model_reasoning_effort: "low" }; + return { status: "ok", threadId: thread_id, model: "saved", reasoningEffort: "high" }; + }); + await assert.rejects(api.prepare("thread/resume", params, "local"), /设置已变化/); + } + }); + + it("rejects loaded old-provider sessions without unloading and verifies every restored setting", async () => { + for (const status of ["notLoaded", "idle", "active"]) { + const api = snapshotRuntime(); + const calls: string[] = []; + const rpc = async (method: string) => { + calls.push(method); + return { thread: { id: "a", modelProvider: "openai", status: { type: status } } }; + }; + if (status !== "notLoaded") { + await assert.rejects(api.prepare("thread/resume", { threadId: "a" }, "local", false, rpc), /旧供应商连接/); + assert.deepEqual(calls, ["thread/read"]); + continue; + } + const patched = await api.prepare("thread/resume", { threadId: "a" }, "local", false, rpc); + assert.deepEqual(calls, ["thread/read"]); + api.verify("thread/resume", patched, { model: "saved-model", modelProvider: "custom", reasoningEffort: "high" }, "local"); + for (const result of [ + { model: "saved-model", modelProvider: "openai" }, { model: "global", modelProvider: "custom" }, + { model: "saved-model", modelProvider: "custom", reasoningEffort: "low" }, + ]) { + assert.throws(() => api.verify("thread/resume", patched, result, "local"), /未采用保存的模型/); + } + } + }); + + it("never continues after failed or timed-out state reads", async () => { + const api = snapshotRuntime(); + for (const rpc of [ + async () => { throw new Error("read unavailable"); }, + async () => new Promise(() => {}), + ]) { + await assert.rejects(api.prepare("thread/resume", { threadId: "a" }, "local", false, rpc), + /read unavailable|读取会话状态超时/); + } + }); + + it("prepares nested local resumes without changing the envelope", async () => { + const api = snapshotRuntime(); + const params = { hostId: "local", method: "thread/resume", params: { threadId: "a" }, trace: "keep" }; + const result = await api.prepare("send-cli-request-for-host", params, "local"); + assert.equal(result.params.model, "saved-model"); + assert.equal(result.params.modelProvider, "custom"); + assert.equal(result.trace, "keep"); + assert.deepEqual(params.params, { threadId: "a" }); + }); + + it("rejects a changed host envelope before passing a local snapshot to another host", async () => { + const params = { hostId: "local", method: "thread/resume", params: { threadId: "a" } }; + const api = snapshotRuntime(async ({ thread_id }) => { + params.hostId = "remote"; + return { status: "ok", threadId: thread_id, model: "saved", reasoningEffort: "high" }; + }); + const calls: any[] = []; + await assert.rejects(api.prepare("send-cli-request-for-host", params, "local", false, + async (...args: any[]) => { calls.push(args); }), /主机已变化/); + assert.equal(calls.length, 0); + }); + + it("uses the snapshot in instance, prototype and host-envelope wrappers with exactly one native resume", async () => { + const catalog = readFileSync(new URL("../../../assets/inject/renderer-inject/70-model-catalog.js", import.meta.url), "utf8"); + const instance = catalog.slice(catalog.indexOf(" function patchAppServerModelRequestClient("), catalog.indexOf(" // issue #2177")); + const prototype = catalog.slice(catalog.indexOf(" function installCodexAppServerClientPrototypePatch()"), + catalog.indexOf(" const appServerModelRequestPatchMaxMisses")); + for (const mode of ["instance", "prototype", "envelope"]) { + const calls: any[] = []; + class Client { + hostId = "local"; + async sendRequest(method: string, params: any) { + calls.push({ method, params }); + const innerMethod = method === "send-cli-request-for-host" ? params.method : method; + const innerParams = method === "send-cli-request-for-host" ? params.params : params; + if (innerMethod === "thread/read") { + return { thread: { id: "a", modelProvider: "openai", status: { type: "notLoaded" } } }; + } + return { model: innerParams.model, modelProvider: innerParams.modelProvider, + reasoningEffort: innerParams.config?.model_reasoning_effort, thread: { id: "a" } }; + } + } + const client = new Client(); + const context = vm.createContext({ + window: { __codexPlusAppServerClientClass: Client, __codexSessionDeleteBridge: () => {} }, client, + codexAppServerModelRequestPatchVersion: "30", + codexPlusBackendSettings: { relayProfilesEnabled: true, activeRelayId: "relay", providerSyncEnabled: false, + relayProfiles: [{ id: "relay", relayMode: "pureApi", configContents: 'model_provider = "custom"' }] }, + codexModelCatalog: {}, codexPlusBackendSettingsLoaded: true, + codexAppServerPreparationTimeoutMs: 1000, + postJson: async (_route: string, payload: any) => ({ status: "ok", threadId: payload.thread_id, + model: "saved-outside-catalog", reasoningEffort: "ultra" }), + registerNativeHostClient() {}, sendCodexPlusDiagnostic() {}, codexPlusPluginNativeInterceptClient: () => null, + appServerModelRequestMethod: (method: string, params: any) => method === "send-cli-request-for-host" ? params.method : method, + loadBackendSettingsState: async () => true, loadCodexModelCatalog: async () => ({}), + prepareCodexManagedSessionConnection: async (_method: string, params: any) => params, + codexManagedSessionConnection: () => null, refreshCodexThreadModelBeforeTurn: async () => null, + sendCodexManagedSessionTurn: async () => null, noteCodexManagedSessionStart() {}, + codexThreadModelRequestState: () => ({}), codexPlusModelUnlockEnabled: () => false, + codexPluginMarketplacePatchEnabled: () => false, codexPlusSettings: () => ({}), + setTimeout, clearTimeout, + }); + vm.runInContext(override + readSource + instance + prototype, context); + vm.runInContext(mode === "prototype" ? "installCodexAppServerClientPrototypePatch()" : "patchAppServerModelRequestClient(client)", context); + const params = { threadId: "a" }; + await client.sendRequest(mode === "envelope" ? "send-cli-request-for-host" : "thread/resume", + mode === "envelope" ? { hostId: "local", method: "thread/resume", params } : params); + const resumes = calls.filter(call => (mode === "envelope" ? call.params.method : call.method) === "thread/resume"); + assert.equal(resumes.length, 1); + assert.equal(calls.length, 2); + const received = mode === "envelope" ? resumes[0].params.params : resumes[0].params; + assert.equal(received.model, "saved-outside-catalog"); + assert.equal(received.modelProvider, "custom"); + assert.equal(received.config.model_reasoning_effort, "ultra"); + } + }); +}); diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 31b07c300d..be0fd6f299 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -431,7 +431,7 @@ const codexPlusSettingsKey = "codexPlusSettings"; const codexThreadScrollKey = "codexThreadScroll"; const codexServiceTierRequestOverrideVersion = "10"; - const codexAppServerModelRequestPatchVersion = "29"; + const codexAppServerModelRequestPatchVersion = "30"; const codexAppServerClientCaptureMarker = "AppServerRequestClient is missing a message dispatcher"; const codexAppServerClientCaptureAnchor = "async sendRequest("; const codexRemoteSessionRecoveryVersion = "8"; @@ -5481,6 +5481,100 @@ return nextParams; } + async function prepareCodexPureApiResume(method, params, hostId, refreshFailed = false, sendRequest) { + if (hostId !== "local") return params; + if ([params?.hostId, params?.host_id].some((host) => typeof host === "string" && host !== "local")) return params; + if (method === "send-cli-request-for-host" && params?.method) { + if ([params.hostId, params.host_id].some((host) => typeof host === "string" && host !== "local")) return params; + const envelope = () => JSON.stringify([params.hostId, params.host_id, params.method]); + const before = envelope(); + const inner = params.params; + const assertEnvelope = () => { + if (envelope() !== before || params.params !== inner) throw new Error("会话主机已变化,请重新打开会话"); + }; + const nested = await prepareCodexPureApiResume(String(params.method), params.params, hostId, refreshFailed, + sendRequest && ((nestedMethod, nestedParams, options) => { + assertEnvelope(); + return sendRequest(method, { ...params, method: nestedMethod, params: nestedParams }, options); + })); + assertEnvelope(); + return nested === params.params ? params : { ...params, params: nested }; + } + if (method !== "thread/resume" || !params || typeof params !== "object" || Array.isArray(params) + || codexRemoteSessionActiveProfile()?.relayMode !== "pureApi" + || ["model", "modelProvider", "model_provider"].some((key) => Object.prototype.hasOwnProperty.call(params, key))) { + return params; + } + if (refreshFailed) throw new Error("无法确认当前供应商,请重新打开会话后再试"); + if (Object.prototype.hasOwnProperty.call(params.config || {}, "model")) { + return applyCodexRemoteSessionProviderOverride(method, { ...params, model: params.config.model }); + } + const target = codexRemoteSessionTargetProvider(); + if (!target || target === "openai") throw new Error("当前纯 API 供应商不可用"); + const requestSignature = () => JSON.stringify([params.threadId, + ...["model", "modelProvider", "model_provider"].map((key) => [Object.prototype.hasOwnProperty.call(params, key), params[key]]), + params.config]); + const requestBefore = requestSignature(); + const signature = () => { + const profile = codexRemoteSessionActiveProfile(); + return JSON.stringify([profile?.id, profile?.relayMode, profile?.configContents, codexRemoteSessionTargetProvider()]); + }; + const before = signature(); + const assertUnchanged = () => { + if (signature() !== before) throw new Error("供应商配置已变化,请重新打开会话"); + if (requestSignature() !== requestBefore) throw new Error("会话设置已变化,请重新打开会话"); + }; + const metadata = await readCodexAppServerPreparation("/thread-resume-model-metadata", + { thread_id: params.threadId, host_id: "local" }, codexAppServerPreparationTimeoutMs); + assertUnchanged(); + if (metadata?.status !== "ok" || metadata.threadId !== params.threadId + || typeof metadata.model !== "string" || !metadata.model.trim() + || !Object.prototype.hasOwnProperty.call(metadata, "reasoningEffort") + || (metadata.reasoningEffort !== null + && (typeof metadata.reasoningEffort !== "string" || !metadata.reasoningEffort.trim()))) { + throw new Error("无法读取此会话保存的模型设置,未覆盖原有选择"); + } + const config = { ...(params.config || {}) }; + // null 表示原线程未指定档位,沿用原生默认语义;显式请求设置始终优先。 + if (metadata.reasoningEffort !== null && !Object.prototype.hasOwnProperty.call(config, "model_reasoning_effort")) { + config.model_reasoning_effort = metadata.reasoningEffort; + } + if (sendRequest) { + // 已加载会话可能忽略连接覆盖;不卸载正在被原生界面使用的实例。 + let timer; + let read; + try { + read = await Promise.race([ + sendRequest("thread/read", { threadId: params.threadId, includeTurns: false }, + { timeoutMs: codexAppServerPreparationTimeoutMs }), + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("读取会话状态超时")), codexAppServerPreparationTimeoutMs); }), + ]); + } finally { clearTimeout(timer); } + assertUnchanged(); + const status = read?.thread?.status?.type; + if (!["idle", "notLoaded", "active"].includes(status)) throw new Error("无法确认会话连接状态,请重新打开会话"); + if (status !== "notLoaded" && read.thread.modelProvider !== target) { + throw new Error("此会话仍使用旧供应商连接,请重新启动 Codex 后重试"); + } + } + const next = { ...params, model: metadata.model, modelProvider: target, config }; + (window.__codexPlusPureApiPreparedResumes ||= new WeakSet()).add(next); + return next; + } + + function verifyCodexPureApiResumeResult(method, params, result, hostId) { + if (hostId !== "local") return; + if (method === "send-cli-request-for-host" && params?.method) { + return verifyCodexPureApiResumeResult(String(params.method), params.params, result, hostId); + } + if (method !== "thread/resume" || !window.__codexPlusPureApiPreparedResumes?.has(params)) return; + if (result?.modelProvider !== params.modelProvider || result?.model !== params.model + || (Object.prototype.hasOwnProperty.call(params.config, "model_reasoning_effort") + && result?.reasoningEffort !== params.config.model_reasoning_effort)) { + throw new Error("会话未采用保存的模型和当前供应商,请重新启动 Codex 后重试"); + } + } + function codexManagedSessionConnection() { const profile = codexRemoteSessionActiveProfile(); const connection = codexPlusBackendSettings.managedSessionConnection; @@ -11985,8 +12079,9 @@ const providerParams = providerRefreshFailed ? params : applyCodexRemoteSessionProviderOverride(requestMethod, params); + const resumeParams = await prepareCodexPureApiResume(String(method || ""), providerParams, client.hostId, providerRefreshFailed, originalSendRequest); const nextParams = await prepareCodexManagedSessionConnection(String(method || ""), - providerParams, client.hostId, originalSendRequest); + resumeParams, client.hostId, originalSendRequest); const modelContextRefresh = await refreshCodexThreadModelBeforeTurn( client, originalSendRequest, @@ -11999,6 +12094,7 @@ if (managedTurn) return managedTurn.result; const result = await sendAppServerRequestWithSideChat(client, method, nextParams, () => originalSendRequest(method, nextParams, options)); + verifyCodexPureApiResumeResult(String(method || ""), nextParams, result, client.hostId); noteCodexManagedSessionStart(String(method || ""), nextParams, result, client.hostId); const threadState = codexThreadModelRequestState(String(method || ""), nextParams, result); if (modelContextRefresh !== false && threadState.threadId && threadState.model @@ -12196,8 +12292,9 @@ const providerParams = providerRefreshFailed ? params : applyCodexRemoteSessionProviderOverride(requestMethod, params); + const resumeParams = await prepareCodexPureApiResume(String(method || ""), providerParams, client.hostId, providerRefreshFailed, originalSendRequest.bind(client)); const nextParams = await prepareCodexManagedSessionConnection(String(method || ""), - providerParams, client.hostId, originalSendRequest.bind(client)); + resumeParams, client.hostId, originalSendRequest.bind(client)); const modelContextRefresh = await refreshCodexThreadModelBeforeTurn( client, originalSendRequest.bind(client), @@ -12210,6 +12307,7 @@ if (managedTurn) return managedTurn.result; const result = await sendAppServerRequestWithSideChat(client, method, nextParams, () => originalSendRequest.call(client, method, nextParams, options)); + verifyCodexPureApiResumeResult(String(method || ""), nextParams, result, client.hostId); noteCodexManagedSessionStart(String(method || ""), nextParams, result, client.hostId); const threadState = codexThreadModelRequestState(String(method || ""), nextParams, result); if (modelContextRefresh !== false && threadState.threadId && threadState.model diff --git a/assets/inject/renderer-inject/00-prelude.js b/assets/inject/renderer-inject/00-prelude.js index a4991367ff..2fd0a6ba8d 100644 --- a/assets/inject/renderer-inject/00-prelude.js +++ b/assets/inject/renderer-inject/00-prelude.js @@ -431,7 +431,7 @@ const codexPlusSettingsKey = "codexPlusSettings"; const codexThreadScrollKey = "codexThreadScroll"; const codexServiceTierRequestOverrideVersion = "10"; - const codexAppServerModelRequestPatchVersion = "29"; + const codexAppServerModelRequestPatchVersion = "30"; const codexAppServerClientCaptureMarker = "AppServerRequestClient is missing a message dispatcher"; const codexAppServerClientCaptureAnchor = "async sendRequest("; const codexRemoteSessionRecoveryVersion = "8"; diff --git a/assets/inject/renderer-inject/30-service-tier.js b/assets/inject/renderer-inject/30-service-tier.js index 930fa43deb..05b1022268 100644 --- a/assets/inject/renderer-inject/30-service-tier.js +++ b/assets/inject/renderer-inject/30-service-tier.js @@ -116,6 +116,100 @@ return nextParams; } + async function prepareCodexPureApiResume(method, params, hostId, refreshFailed = false, sendRequest) { + if (hostId !== "local") return params; + if ([params?.hostId, params?.host_id].some((host) => typeof host === "string" && host !== "local")) return params; + if (method === "send-cli-request-for-host" && params?.method) { + if ([params.hostId, params.host_id].some((host) => typeof host === "string" && host !== "local")) return params; + const envelope = () => JSON.stringify([params.hostId, params.host_id, params.method]); + const before = envelope(); + const inner = params.params; + const assertEnvelope = () => { + if (envelope() !== before || params.params !== inner) throw new Error("会话主机已变化,请重新打开会话"); + }; + const nested = await prepareCodexPureApiResume(String(params.method), params.params, hostId, refreshFailed, + sendRequest && ((nestedMethod, nestedParams, options) => { + assertEnvelope(); + return sendRequest(method, { ...params, method: nestedMethod, params: nestedParams }, options); + })); + assertEnvelope(); + return nested === params.params ? params : { ...params, params: nested }; + } + if (method !== "thread/resume" || !params || typeof params !== "object" || Array.isArray(params) + || codexRemoteSessionActiveProfile()?.relayMode !== "pureApi" + || ["model", "modelProvider", "model_provider"].some((key) => Object.prototype.hasOwnProperty.call(params, key))) { + return params; + } + if (refreshFailed) throw new Error("无法确认当前供应商,请重新打开会话后再试"); + if (Object.prototype.hasOwnProperty.call(params.config || {}, "model")) { + return applyCodexRemoteSessionProviderOverride(method, { ...params, model: params.config.model }); + } + const target = codexRemoteSessionTargetProvider(); + if (!target || target === "openai") throw new Error("当前纯 API 供应商不可用"); + const requestSignature = () => JSON.stringify([params.threadId, + ...["model", "modelProvider", "model_provider"].map((key) => [Object.prototype.hasOwnProperty.call(params, key), params[key]]), + params.config]); + const requestBefore = requestSignature(); + const signature = () => { + const profile = codexRemoteSessionActiveProfile(); + return JSON.stringify([profile?.id, profile?.relayMode, profile?.configContents, codexRemoteSessionTargetProvider()]); + }; + const before = signature(); + const assertUnchanged = () => { + if (signature() !== before) throw new Error("供应商配置已变化,请重新打开会话"); + if (requestSignature() !== requestBefore) throw new Error("会话设置已变化,请重新打开会话"); + }; + const metadata = await readCodexAppServerPreparation("/thread-resume-model-metadata", + { thread_id: params.threadId, host_id: "local" }, codexAppServerPreparationTimeoutMs); + assertUnchanged(); + if (metadata?.status !== "ok" || metadata.threadId !== params.threadId + || typeof metadata.model !== "string" || !metadata.model.trim() + || !Object.prototype.hasOwnProperty.call(metadata, "reasoningEffort") + || (metadata.reasoningEffort !== null + && (typeof metadata.reasoningEffort !== "string" || !metadata.reasoningEffort.trim()))) { + throw new Error("无法读取此会话保存的模型设置,未覆盖原有选择"); + } + const config = { ...(params.config || {}) }; + // null 表示原线程未指定档位,沿用原生默认语义;显式请求设置始终优先。 + if (metadata.reasoningEffort !== null && !Object.prototype.hasOwnProperty.call(config, "model_reasoning_effort")) { + config.model_reasoning_effort = metadata.reasoningEffort; + } + if (sendRequest) { + // 已加载会话可能忽略连接覆盖;不卸载正在被原生界面使用的实例。 + let timer; + let read; + try { + read = await Promise.race([ + sendRequest("thread/read", { threadId: params.threadId, includeTurns: false }, + { timeoutMs: codexAppServerPreparationTimeoutMs }), + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("读取会话状态超时")), codexAppServerPreparationTimeoutMs); }), + ]); + } finally { clearTimeout(timer); } + assertUnchanged(); + const status = read?.thread?.status?.type; + if (!["idle", "notLoaded", "active"].includes(status)) throw new Error("无法确认会话连接状态,请重新打开会话"); + if (status !== "notLoaded" && read.thread.modelProvider !== target) { + throw new Error("此会话仍使用旧供应商连接,请重新启动 Codex 后重试"); + } + } + const next = { ...params, model: metadata.model, modelProvider: target, config }; + (window.__codexPlusPureApiPreparedResumes ||= new WeakSet()).add(next); + return next; + } + + function verifyCodexPureApiResumeResult(method, params, result, hostId) { + if (hostId !== "local") return; + if (method === "send-cli-request-for-host" && params?.method) { + return verifyCodexPureApiResumeResult(String(params.method), params.params, result, hostId); + } + if (method !== "thread/resume" || !window.__codexPlusPureApiPreparedResumes?.has(params)) return; + if (result?.modelProvider !== params.modelProvider || result?.model !== params.model + || (Object.prototype.hasOwnProperty.call(params.config, "model_reasoning_effort") + && result?.reasoningEffort !== params.config.model_reasoning_effort)) { + throw new Error("会话未采用保存的模型和当前供应商,请重新启动 Codex 后重试"); + } + } + function codexManagedSessionConnection() { const profile = codexRemoteSessionActiveProfile(); const connection = codexPlusBackendSettings.managedSessionConnection; diff --git a/assets/inject/renderer-inject/70-model-catalog.js b/assets/inject/renderer-inject/70-model-catalog.js index df9c4e3699..0f211a4599 100644 --- a/assets/inject/renderer-inject/70-model-catalog.js +++ b/assets/inject/renderer-inject/70-model-catalog.js @@ -734,8 +734,9 @@ const providerParams = providerRefreshFailed ? params : applyCodexRemoteSessionProviderOverride(requestMethod, params); + const resumeParams = await prepareCodexPureApiResume(String(method || ""), providerParams, client.hostId, providerRefreshFailed, originalSendRequest); const nextParams = await prepareCodexManagedSessionConnection(String(method || ""), - providerParams, client.hostId, originalSendRequest); + resumeParams, client.hostId, originalSendRequest); const modelContextRefresh = await refreshCodexThreadModelBeforeTurn( client, originalSendRequest, @@ -748,6 +749,7 @@ if (managedTurn) return managedTurn.result; const result = await sendAppServerRequestWithSideChat(client, method, nextParams, () => originalSendRequest(method, nextParams, options)); + verifyCodexPureApiResumeResult(String(method || ""), nextParams, result, client.hostId); noteCodexManagedSessionStart(String(method || ""), nextParams, result, client.hostId); const threadState = codexThreadModelRequestState(String(method || ""), nextParams, result); if (modelContextRefresh !== false && threadState.threadId && threadState.model @@ -945,8 +947,9 @@ const providerParams = providerRefreshFailed ? params : applyCodexRemoteSessionProviderOverride(requestMethod, params); + const resumeParams = await prepareCodexPureApiResume(String(method || ""), providerParams, client.hostId, providerRefreshFailed, originalSendRequest.bind(client)); const nextParams = await prepareCodexManagedSessionConnection(String(method || ""), - providerParams, client.hostId, originalSendRequest.bind(client)); + resumeParams, client.hostId, originalSendRequest.bind(client)); const modelContextRefresh = await refreshCodexThreadModelBeforeTurn( client, originalSendRequest.bind(client), @@ -959,6 +962,7 @@ if (managedTurn) return managedTurn.result; const result = await sendAppServerRequestWithSideChat(client, method, nextParams, () => originalSendRequest.call(client, method, nextParams, options)); + verifyCodexPureApiResumeResult(String(method || ""), nextParams, result, client.hostId); noteCodexManagedSessionStart(String(method || ""), nextParams, result, client.hostId); const threadState = codexThreadModelRequestState(String(method || ""), nextParams, result); if (modelContextRefresh !== false && threadState.threadId && threadState.model diff --git a/assets/inject/renderer-inject/manifest.json b/assets/inject/renderer-inject/manifest.json index c5c19b6c0f..2e8ae3bb89 100644 --- a/assets/inject/renderer-inject/manifest.json +++ b/assets/inject/renderer-inject/manifest.json @@ -23,7 +23,7 @@ { "name": "30-service-tier.js", "description": "供应商会话适配、请求桥接与原生状态模块定位", - "lines": 901 + "lines": 995 }, { "name": "31-config-read-deadline.js", @@ -58,7 +58,7 @@ { "name": "70-model-catalog.js", "description": "全局状态、模型目录、模型白名单与解锁", - "lines": 1157 + "lines": 1161 }, { "name": "71-model-picker-groups.js", diff --git a/crates/codex-plus-core/src/codex_sqlite.rs b/crates/codex-plus-core/src/codex_sqlite.rs index f8b1ed554f..e3d1d2fe78 100644 --- a/crates/codex-plus-core/src/codex_sqlite.rs +++ b/crates/codex-plus-core/src/codex_sqlite.rs @@ -175,6 +175,74 @@ fn connection_has_column(db: &Connection, table: &str, column: &str) -> bool { .is_ok() } +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ThreadResumeModelMetadata { + pub thread_id: String, + pub model: String, + pub reasoning_effort: Option, +} + +/// 一次只读查询取得原生恢复所用的模型设置;不读取或改写会话正文。 +pub fn thread_resume_model_metadata( + home: &Path, + thread_id: &str, +) -> anyhow::Result> { + use rusqlite::OptionalExtension; + anyhow::ensure!( + !thread_id.is_empty() && thread_id.len() <= 128 && thread_id.trim() == thread_id, + "Invalid thread ID" + ); + let mut snapshot = None; + for db_path in codex_session_db_paths_from_home(home) { + if !db_path.exists() { + continue; + } + let db = Connection::open_with_flags( + &db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + )?; + db.busy_timeout(std::time::Duration::from_millis(50))?; + if !connection_has_table(&db, "threads") { + continue; + } + if !connection_has_column(&db, "threads", "model") + || !connection_has_column(&db, "threads", "reasoning_effort") + { + let contains_thread: bool = db.query_row( + "SELECT EXISTS(SELECT 1 FROM threads WHERE id = ?1)", [thread_id], |row| row.get(0), + )?; + anyhow::ensure!(!contains_thread, "Saved thread model settings are unavailable"); + continue; + } + let row = db.query_row( + "SELECT model, reasoning_effort FROM threads WHERE id = ?1", + [thread_id], + |row| Ok((row.get::<_, Option>(0)?, row.get::<_, Option>(1)?)), + ).optional()?; + let Some((model, reasoning_effort)) = row else { + continue; + }; + let model = model.filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("Saved thread model is missing"))?; + anyhow::ensure!( + reasoning_effort.as_ref().is_none_or(|value| !value.trim().is_empty()), + "Saved thread reasoning effort is invalid" + ); + let current = ThreadResumeModelMetadata { + thread_id: thread_id.to_string(), + model, + reasoning_effort, + }; + anyhow::ensure!( + snapshot.as_ref().is_none_or(|previous| previous == ¤t), + "Conflicting saved thread model settings" + ); + snapshot = Some(current); + } + Ok(snapshot) +} + /// 读出「未归档目标模式任务」对应 thread 的持久化模型(取 updated_at 最新一条)。 /// 用于重启后把 config.toml 的默认 model 对齐到长线目标任务实际使用的模型, /// 防止恢复任务静默回落到 model_list 第一条(issue #2264)。 diff --git a/crates/codex-plus-core/src/routes.rs b/crates/codex-plus-core/src/routes.rs index 81bab257a1..08bd8ef794 100644 --- a/crates/codex-plus-core/src/routes.rs +++ b/crates/codex-plus-core/src/routes.rs @@ -458,6 +458,24 @@ pub async fn handle_bridge_request( .to_string(); ctx.data.recover_remote_control_session(thread_id).await } + "/thread-resume-model-metadata" => { + let request = payload.clone(); + tokio::task::spawn_blocking(move || { + anyhow::ensure!( + request.get("host_id").and_then(Value::as_str) == Some("local") + && request.get("hostId").is_none_or(|host| host.as_str() == Some("local")), + "Thread model metadata is local-only" + ); + let thread_id = request.get("thread_id").and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("Thread ID is required"))?; + let snapshot = crate::codex_sqlite::thread_resume_model_metadata( + &crate::codex_home::default_codex_home_dir(), thread_id, + )?.ok_or_else(|| anyhow::anyhow!("Saved thread model settings were not found"))?; + let mut value = serde_json::to_value(snapshot)?; + value["status"] = json!("ok"); + Ok(value) + }).await.unwrap_or_else(|error| Err(anyhow::anyhow!(error.to_string()))) + } "/side-chat/source" | "/side-chat/save" => { let request = payload.clone(); let operation = if path == "/side-chat/source" { diff --git a/crates/codex-plus-core/tests/all/bridge_routes.rs b/crates/codex-plus-core/tests/all/bridge_routes.rs index a72962040a..0ffe2528f4 100644 --- a/crates/codex-plus-core/tests/all/bridge_routes.rs +++ b/crates/codex-plus-core/tests/all/bridge_routes.rs @@ -15,6 +15,17 @@ use codex_plus_core::status::StatusStore; use codex_plus_core::user_scripts::UserScriptManager; use serde_json::{Value, json}; +#[tokio::test] +async fn thread_resume_metadata_requires_an_explicit_local_host_before_reading_storage() { + for payload in [json!({"thread_id":"a"}), json!({"thread_id":"a","host_id":"remote"}), + json!({"thread_id":"a","host_id":"local","hostId":"remote"})] { + let ctx = test_context(); + let result = handle_bridge_request(ctx, "/thread-resume-model-metadata", payload).await; + assert_eq!(result["status"], "failed"); + assert!(result["message"].as_str().unwrap().contains("local-only")); + } +} + #[tokio::test] async fn bridge_routes_cover_all_current_paths() { let ctx = test_context(); diff --git a/crates/codex-plus-core/tests/all/cdp_bridge.rs b/crates/codex-plus-core/tests/all/cdp_bridge.rs index 3fe61536f1..e85f161f1a 100644 --- a/crates/codex-plus-core/tests/all/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/all/cdp_bridge.rs @@ -3345,7 +3345,7 @@ fn injection_script_unlocks_custom_model_catalog() { assert!(script.contains("loadAppServerRequestCandidates")); assert!(script.contains("appServerFallbackAssetUrls")); assert!(script.contains("collectAppServerRequestCandidatesFromModule")); - assert!(script.contains("codexAppServerModelRequestPatchVersion = \"29\"")); + assert!(script.contains("codexAppServerModelRequestPatchVersion = \"30\"")); assert!(script.contains("list-models-for-host")); assert!(script.contains("appServerModelRequestMethod")); diff --git a/crates/codex-plus-core/tests/all/codex_sqlite.rs b/crates/codex-plus-core/tests/all/codex_sqlite.rs index 9e23194176..0094c6d71f 100644 --- a/crates/codex-plus-core/tests/all/codex_sqlite.rs +++ b/crates/codex-plus-core/tests/all/codex_sqlite.rs @@ -4,6 +4,76 @@ use codex_plus_core::codex_sqlite::{ }; use rusqlite::Connection; +#[test] +fn resume_model_metadata_is_read_only_and_reads_one_thread_tuple() { + let _guard = super::common::global_state_lock(); + let home = tempfile::tempdir().unwrap(); + let path = home.path().join("state_5.sqlite"); + let db = Connection::open(&path).unwrap(); + db.execute_batch( + "CREATE TABLE threads(id TEXT PRIMARY KEY, model TEXT, reasoning_effort TEXT); + INSERT INTO threads VALUES('a','saved-model-a','ultra'),('b','saved-model-b','low'),('c','saved-model-c',NULL);" + ).unwrap(); + drop(db); + let before = std::fs::read(&path).unwrap(); + for (id, model, effort) in [("a", "saved-model-a", Some("ultra")), ("b", "saved-model-b", Some("low")), ("c", "saved-model-c", None)] { + let value = codex_plus_core::codex_sqlite::thread_resume_model_metadata(home.path(), id).unwrap().unwrap(); + assert_eq!(value.model, model); + assert_eq!(value.reasoning_effort.as_deref(), effort); + assert_eq!(value.thread_id, id); + } + assert!(codex_plus_core::codex_sqlite::thread_resume_model_metadata(home.path(), "missing").unwrap().is_none()); + assert_eq!(std::fs::read(&path).unwrap(), before); +} + +#[test] +fn resume_model_metadata_does_not_create_a_missing_database_or_guess_missing_fields() { + let _guard = super::common::global_state_lock(); + let home = tempfile::tempdir().unwrap(); + let read = |id| codex_plus_core::codex_sqlite::thread_resume_model_metadata(home.path(), id); + assert!(read("a").unwrap().is_none()); + assert!(!home.path().join("state_5.sqlite").exists()); + assert!(read("").is_err()); + let db = Connection::open(home.path().join("state_5.sqlite")).unwrap(); + db.execute_batch("CREATE TABLE threads(id TEXT PRIMARY KEY, model TEXT); INSERT INTO threads VALUES('a','saved');").unwrap(); + assert!(read("a").is_err()); + db.execute_batch("ALTER TABLE threads ADD COLUMN reasoning_effort TEXT; UPDATE threads SET model='', reasoning_effort='high';").unwrap(); + assert!(read("a").is_err()); + db.execute_batch("UPDATE threads SET model='saved', reasoning_effort='';").unwrap(); + assert!(read("a").is_err()); +} + +#[test] +fn resume_model_metadata_rejects_conflicting_candidate_databases() { + let _guard = super::common::global_state_lock(); + let home = tempfile::tempdir().unwrap(); + std::fs::create_dir(home.path().join("sqlite")).unwrap(); + for (path, model) in [(home.path().join("state_5.sqlite"), "old"), (home.path().join("sqlite/codex-dev.db"), "current")] { + let db = Connection::open(path).unwrap(); + db.execute_batch("CREATE TABLE threads(id TEXT PRIMARY KEY, model TEXT, reasoning_effort TEXT);").unwrap(); + db.execute("INSERT INTO threads VALUES('a',?1,'high')", [model]).unwrap(); + } + assert!(codex_plus_core::codex_sqlite::thread_resume_model_metadata(home.path(), "a").is_err()); +} + +#[test] +fn resume_model_metadata_skips_unrelated_legacy_rows_and_accepts_identical_tuples() { + let _guard = super::common::global_state_lock(); + let home = tempfile::tempdir().unwrap(); + std::fs::create_dir(home.path().join("sqlite")).unwrap(); + let legacy = Connection::open(home.path().join("sqlite/codex-dev.db")).unwrap(); + legacy.execute_batch("CREATE TABLE threads(id TEXT PRIMARY KEY); INSERT INTO threads VALUES('other');").unwrap(); + let modern = Connection::open(home.path().join("state_5.sqlite")).unwrap(); + modern.execute_batch("CREATE TABLE threads(id TEXT PRIMARY KEY, model TEXT, reasoning_effort TEXT); + INSERT INTO threads VALUES('a','saved','high');").unwrap(); + let read = || codex_plus_core::codex_sqlite::thread_resume_model_metadata(home.path(), "a"); + assert_eq!(read().unwrap().unwrap().model, "saved"); + legacy.execute_batch("ALTER TABLE threads ADD COLUMN model TEXT; + ALTER TABLE threads ADD COLUMN reasoning_effort TEXT; + INSERT INTO threads VALUES('a','saved','high');").unwrap(); + assert_eq!(read().unwrap().unwrap().reasoning_effort.as_deref(), Some("high")); +} + fn create_threads_table(conn: &Connection) { conn.execute( "CREATE TABLE threads ( From c5268792bd11f86b8f42dc6822606ad49973ee7e Mon Sep 17 00:00:00 2001 From: Yuimi-chaya Date: Thu, 29 Oct 2026 21:43:54 +0800 Subject: [PATCH 5/7] fix: preserve saved models for nullable resume fields and native defaults --- .../src/thread-model-preservation.test.ts | 81 +++++++++++++++++-- assets/inject/renderer-inject.js | 42 ++++------ assets/inject/renderer-inject/00-prelude.js | 2 +- .../inject/renderer-inject/30-service-tier.js | 18 +++-- .../renderer-inject/70-model-catalog.js | 22 +---- assets/inject/renderer-inject/manifest.json | 4 +- .../codex-plus-core/tests/all/cdp_bridge.rs | 25 +++++- 7 files changed, 130 insertions(+), 64 deletions(-) diff --git a/apps/codex-plus-manager/src/thread-model-preservation.test.ts b/apps/codex-plus-manager/src/thread-model-preservation.test.ts index 4f9c71e502..4cb25ba786 100644 --- a/apps/codex-plus-manager/src/thread-model-preservation.test.ts +++ b/apps/codex-plus-manager/src/thread-model-preservation.test.ts @@ -130,15 +130,15 @@ describe("pure API persisted model snapshot", () => { assert.equal(Object.hasOwn(result.config, "model_reasoning_effort"), false); }); - it("never probes remote hosts, official profiles or explicit model/provider requests", async () => { + it("never probes remote hosts, official profiles, explicit models or foreign providers", async () => { const api = snapshotRuntime(async () => { throw new Error("must not read"); }); for (const host of ["remote", "", undefined]) { const params = { threadId: "a" }; assert.equal(await api.prepare("thread/resume", params, host), params); } for (const params of [ - { threadId: "a", model: "chosen" }, { threadId: "a", modelProvider: "openai" }, - { threadId: "a", model_provider: "openai" }, + { threadId: "a", model: "chosen" }, { threadId: "a", modelProvider: "foreign" }, + { threadId: "a", model_provider: "foreign" }, { hostId: "remote", method: "thread/resume", params: { threadId: "a" } }, ]) { assert.equal(await api.prepare("method" in params ? "send-cli-request-for-host" : "thread/resume", params, "local"), params); @@ -152,6 +152,38 @@ describe("pure API persisted model snapshot", () => { assert.equal(api.calls.length, 0); }); + it("restores persisted choices through provider-only and empty optional fields in the actual routing order", async () => { + for (const optional of [ + { model: null }, { model: "" }, { model: " " }, { model: undefined }, + { modelProvider: "openai" }, { model_provider: "openai" }, + { modelProvider: "custom" }, { modelProvider: null }, { model_provider: "" }, + { config: { model: null, model_reasoning_effort: null } }, + { config: { model: "", model_reasoning_effort: "" } }, + ] as Record[]) { + const api = snapshotRuntime(async ({ thread_id }) => + ({ status: "ok", threadId: thread_id, model: "gpt-6.1-terra", reasoningEffort: "xhigh" })); + const params: Record = { threadId: "a", ...optional }; + const routed = api.apply("thread/resume", params); + const result = await api.prepare("thread/resume", routed, "local"); + assert.equal(api.calls.length, 1, JSON.stringify(optional)); + assert.equal(result.model, "gpt-6.1-terra"); + assert.equal(result.modelProvider, "custom"); + assert.equal(result.config.model_reasoning_effort, "xhigh"); + assert.equal(Object.hasOwn(result, "model_provider"), false); + assert.equal(Object.hasOwn(result.config, "model"), false); + assert.equal(Object.hasOwn(params, "config") ? params.config : undefined, optional.config); + } + }); + + it("leaves new-thread model fallback with native defaults", async () => { + const api = snapshotRuntime(); + const params = api.apply("thread/start", {}); + const prepared = await api.prepare("thread/start", params, "local"); + assert.equal(prepared.modelProvider, "custom"); + assert.equal(Object.hasOwn(prepared, "model"), false); + assert.equal(api.calls.length, 0); + }); + it("rejects incomplete, missing, mismatched or failed metadata instead of issuing an unsafe resume", async () => { for (const data of [ null, { status: "failed" }, { status: "ok", threadId: "other", model: "saved", reasoningEffort: "high" }, @@ -260,7 +292,9 @@ describe("pure API persisted model snapshot", () => { const instance = catalog.slice(catalog.indexOf(" function patchAppServerModelRequestClient("), catalog.indexOf(" // issue #2177")); const prototype = catalog.slice(catalog.indexOf(" function installCodexAppServerClientPrototypePatch()"), catalog.indexOf(" const appServerModelRequestPatchMaxMisses")); - for (const mode of ["instance", "prototype", "envelope"]) { + for (const [mode, optional] of ["instance", "prototype", "envelope"].flatMap(mode => + [{}, { model: null }, { modelProvider: "openai" }, + { config: { model: null, model_reasoning_effort: null } }].map(optional => [mode, optional] as const))) { const calls: any[] = []; class Client { hostId = "local"; @@ -278,7 +312,7 @@ describe("pure API persisted model snapshot", () => { const client = new Client(); const context = vm.createContext({ window: { __codexPlusAppServerClientClass: Client, __codexSessionDeleteBridge: () => {} }, client, - codexAppServerModelRequestPatchVersion: "30", + codexAppServerModelRequestPatchVersion: "31", codexPlusBackendSettings: { relayProfilesEnabled: true, activeRelayId: "relay", providerSyncEnabled: false, relayProfiles: [{ id: "relay", relayMode: "pureApi", configContents: 'model_provider = "custom"' }] }, codexModelCatalog: {}, codexPlusBackendSettingsLoaded: true, @@ -297,7 +331,7 @@ describe("pure API persisted model snapshot", () => { }); vm.runInContext(override + readSource + instance + prototype, context); vm.runInContext(mode === "prototype" ? "installCodexAppServerClientPrototypePatch()" : "patchAppServerModelRequestClient(client)", context); - const params = { threadId: "a" }; + const params = { threadId: "a", ...optional }; await client.sendRequest(mode === "envelope" ? "send-cli-request-for-host" : "thread/resume", mode === "envelope" ? { hostId: "local", method: "thread/resume", params } : params); const resumes = calls.filter(call => (mode === "envelope" ? call.params.method : call.method) === "thread/resume"); @@ -310,3 +344,38 @@ describe("pure API persisted model snapshot", () => { } }); }); + +describe("native default model ownership in pure API filtering", () => { + const catalog = readFileSync(new URL("../../../assets/inject/renderer-inject/70-model-catalog.js", import.meta.url), "utf8"); + const descriptor = catalog.slice(catalog.indexOf(" function codexPlusModelDescriptor("), + catalog.indexOf(" function modelArrayLooksPatchable(")); + const defaults = catalog.slice(catalog.indexOf(" function patchModelDefaults("), + catalog.indexOf(" // 只收窄当前纯 API")); + it("keeps existing host defaults even outside the current provider list", () => { + const snapshots = new WeakMap(); + const patch = new Function("codexModelCatalog", "codexPlusModelDefaultSnapshots", "modelFilterPolicy", + "codexPlusModelMetadata", "modelReasoningEfforts", `${descriptor}\n${defaults}\nreturn patchModelDefaults;`)( + { model: "gpt-6.1-sol", default_model: "gpt-6.1-sol" }, snapshots, () => "pureApi:local", + () => null, () => []); + const original = { model: "gpt-6.1-terra", defaultReasoningEffort: "xhigh" }; + const host: any = { defaultModel: original, default_model: "gpt-6.1-terra", model: "saved-choice" }; + assert.equal(patch(host, new Set(["gpt-6.1-sol"])), false); + assert.equal(host.defaultModel, original); + assert.equal(host.default_model, "gpt-6.1-terra"); + assert.equal(host.model, "saved-choice"); + const missing = {}; + patch(missing, new Set(["gpt-6.1-sol"])); + assert.deepEqual(missing, {}); + + const oldPatched = { model: "gpt-6.1-sol", defaultReasoningEffort: "medium" }; + host.defaultModel = oldPatched; + snapshots.set(host, { policy: "old", fields: { defaultModel: { original, patched: oldPatched } } }); + assert.equal(patch(host, new Set(["gpt-6.1-sol"])), true); + assert.equal(host.defaultModel, original); + host.defaultModel = { model: "new-user-choice" }; + const userChoice = host.defaultModel; + snapshots.set(host, { policy: "old", fields: { defaultModel: { original, patched: oldPatched } } }); + patch(host, new Set(["gpt-6.1-sol"])); + assert.equal(host.defaultModel, userChoice); + }); +}); diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index be0fd6f299..8767bcac1d 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -431,7 +431,7 @@ const codexPlusSettingsKey = "codexPlusSettings"; const codexThreadScrollKey = "codexThreadScroll"; const codexServiceTierRequestOverrideVersion = "10"; - const codexAppServerModelRequestPatchVersion = "30"; + const codexAppServerModelRequestPatchVersion = "31"; const codexAppServerClientCaptureMarker = "AppServerRequestClient is missing a message dispatcher"; const codexAppServerClientCaptureAnchor = "async sendRequest("; const codexRemoteSessionRecoveryVersion = "8"; @@ -5501,15 +5501,18 @@ return nested === params.params ? params : { ...params, params: nested }; } if (method !== "thread/resume" || !params || typeof params !== "object" || Array.isArray(params) - || codexRemoteSessionActiveProfile()?.relayMode !== "pureApi" - || ["model", "modelProvider", "model_provider"].some((key) => Object.prototype.hasOwnProperty.call(params, key))) { + || codexRemoteSessionActiveProfile()?.relayMode !== "pureApi") { return params; } + const specified = (value) => typeof value === "string" && !!value.trim(); + const target = codexRemoteSessionTargetProvider(); + const requestedProvider = String(params.modelProvider || params.model_provider || "").trim(); + // nullable 字段和仅指定供应商不是模型选择;补供应商时必须同时保留保存的模型设置。 + if (specified(params.model) || (requestedProvider && requestedProvider !== "openai" && requestedProvider !== target)) return params; if (refreshFailed) throw new Error("无法确认当前供应商,请重新打开会话后再试"); - if (Object.prototype.hasOwnProperty.call(params.config || {}, "model")) { + if (specified(params.config?.model)) { return applyCodexRemoteSessionProviderOverride(method, { ...params, model: params.config.model }); } - const target = codexRemoteSessionTargetProvider(); if (!target || target === "openai") throw new Error("当前纯 API 供应商不可用"); const requestSignature = () => JSON.stringify([params.threadId, ...["model", "modelProvider", "model_provider"].map((key) => [Object.prototype.hasOwnProperty.call(params, key), params[key]]), @@ -5535,9 +5538,11 @@ throw new Error("无法读取此会话保存的模型设置,未覆盖原有选择"); } const config = { ...(params.config || {}) }; + delete config.model; // null 表示原线程未指定档位,沿用原生默认语义;显式请求设置始终优先。 - if (metadata.reasoningEffort !== null && !Object.prototype.hasOwnProperty.call(config, "model_reasoning_effort")) { - config.model_reasoning_effort = metadata.reasoningEffort; + if (!specified(config.model_reasoning_effort)) { + delete config.model_reasoning_effort; + if (metadata.reasoningEffort !== null) config.model_reasoning_effort = metadata.reasoningEffort; } if (sendRequest) { // 已加载会话可能忽略连接覆盖;不卸载正在被原生界面使用的实例。 @@ -5558,6 +5563,7 @@ } } const next = { ...params, model: metadata.model, modelProvider: target, config }; + delete next.model_provider; (window.__codexPlusPureApiPreparedResumes ||= new WeakSet()).add(next); return next; } @@ -11620,11 +11626,11 @@ if (snapshot) snapshot.patched = [...collection]; } - function patchModelDefaults(value, allowed) { - const policy = modelFilterPolicy(allowed); + function patchModelDefaults(value) { const previous = codexPlusModelDefaultSnapshots.get(value); let changed = false; - if (previous && previous.policy !== policy) { + // 目录只提供选项;宿主默认值和线程选择不能由当前供应商的首个模型接管。 + if (previous) { for (const [key, state] of Object.entries(previous.fields)) { if (value[key] === state.patched && value[key] !== state.original) { value[key] = state.original; @@ -11633,22 +11639,6 @@ } codexPlusModelDefaultSnapshots.delete(value); } - if (!allowed) return changed; - const snapshot = codexPlusModelDefaultSnapshots.get(value) || { policy, fields: {} }; - const fallback = [codexModelCatalog.model, codexModelCatalog.default_model, ...allowed] - .find((name) => typeof name === "string" && allowed.has(name)); - for (const key of ["defaultModel", "default_model"]) { - const original = value[key]; - const name = typeof original === "string" ? original : original?.model || original?.id || original?.slug; - if (!name || allowed.has(name)) continue; - const patched = typeof original === "string" ? fallback : codexPlusModelDescriptor(fallback); - if (!snapshot.fields[key] || original !== snapshot.fields[key].patched) { - snapshot.fields[key] = { original, patched }; - } - value[key] = patched; - changed = true; - } - codexPlusModelDefaultSnapshots.set(value, snapshot); return changed; } diff --git a/assets/inject/renderer-inject/00-prelude.js b/assets/inject/renderer-inject/00-prelude.js index 2fd0a6ba8d..73c4146fd2 100644 --- a/assets/inject/renderer-inject/00-prelude.js +++ b/assets/inject/renderer-inject/00-prelude.js @@ -431,7 +431,7 @@ const codexPlusSettingsKey = "codexPlusSettings"; const codexThreadScrollKey = "codexThreadScroll"; const codexServiceTierRequestOverrideVersion = "10"; - const codexAppServerModelRequestPatchVersion = "30"; + const codexAppServerModelRequestPatchVersion = "31"; const codexAppServerClientCaptureMarker = "AppServerRequestClient is missing a message dispatcher"; const codexAppServerClientCaptureAnchor = "async sendRequest("; const codexRemoteSessionRecoveryVersion = "8"; diff --git a/assets/inject/renderer-inject/30-service-tier.js b/assets/inject/renderer-inject/30-service-tier.js index 05b1022268..8da7b7e744 100644 --- a/assets/inject/renderer-inject/30-service-tier.js +++ b/assets/inject/renderer-inject/30-service-tier.js @@ -136,15 +136,18 @@ return nested === params.params ? params : { ...params, params: nested }; } if (method !== "thread/resume" || !params || typeof params !== "object" || Array.isArray(params) - || codexRemoteSessionActiveProfile()?.relayMode !== "pureApi" - || ["model", "modelProvider", "model_provider"].some((key) => Object.prototype.hasOwnProperty.call(params, key))) { + || codexRemoteSessionActiveProfile()?.relayMode !== "pureApi") { return params; } + const specified = (value) => typeof value === "string" && !!value.trim(); + const target = codexRemoteSessionTargetProvider(); + const requestedProvider = String(params.modelProvider || params.model_provider || "").trim(); + // nullable 字段和仅指定供应商不是模型选择;补供应商时必须同时保留保存的模型设置。 + if (specified(params.model) || (requestedProvider && requestedProvider !== "openai" && requestedProvider !== target)) return params; if (refreshFailed) throw new Error("无法确认当前供应商,请重新打开会话后再试"); - if (Object.prototype.hasOwnProperty.call(params.config || {}, "model")) { + if (specified(params.config?.model)) { return applyCodexRemoteSessionProviderOverride(method, { ...params, model: params.config.model }); } - const target = codexRemoteSessionTargetProvider(); if (!target || target === "openai") throw new Error("当前纯 API 供应商不可用"); const requestSignature = () => JSON.stringify([params.threadId, ...["model", "modelProvider", "model_provider"].map((key) => [Object.prototype.hasOwnProperty.call(params, key), params[key]]), @@ -170,9 +173,11 @@ throw new Error("无法读取此会话保存的模型设置,未覆盖原有选择"); } const config = { ...(params.config || {}) }; + delete config.model; // null 表示原线程未指定档位,沿用原生默认语义;显式请求设置始终优先。 - if (metadata.reasoningEffort !== null && !Object.prototype.hasOwnProperty.call(config, "model_reasoning_effort")) { - config.model_reasoning_effort = metadata.reasoningEffort; + if (!specified(config.model_reasoning_effort)) { + delete config.model_reasoning_effort; + if (metadata.reasoningEffort !== null) config.model_reasoning_effort = metadata.reasoningEffort; } if (sendRequest) { // 已加载会话可能忽略连接覆盖;不卸载正在被原生界面使用的实例。 @@ -193,6 +198,7 @@ } } const next = { ...params, model: metadata.model, modelProvider: target, config }; + delete next.model_provider; (window.__codexPlusPureApiPreparedResumes ||= new WeakSet()).add(next); return next; } diff --git a/assets/inject/renderer-inject/70-model-catalog.js b/assets/inject/renderer-inject/70-model-catalog.js index 0f211a4599..171fec38a4 100644 --- a/assets/inject/renderer-inject/70-model-catalog.js +++ b/assets/inject/renderer-inject/70-model-catalog.js @@ -275,11 +275,11 @@ if (snapshot) snapshot.patched = [...collection]; } - function patchModelDefaults(value, allowed) { - const policy = modelFilterPolicy(allowed); + function patchModelDefaults(value) { const previous = codexPlusModelDefaultSnapshots.get(value); let changed = false; - if (previous && previous.policy !== policy) { + // 目录只提供选项;宿主默认值和线程选择不能由当前供应商的首个模型接管。 + if (previous) { for (const [key, state] of Object.entries(previous.fields)) { if (value[key] === state.patched && value[key] !== state.original) { value[key] = state.original; @@ -288,22 +288,6 @@ } codexPlusModelDefaultSnapshots.delete(value); } - if (!allowed) return changed; - const snapshot = codexPlusModelDefaultSnapshots.get(value) || { policy, fields: {} }; - const fallback = [codexModelCatalog.model, codexModelCatalog.default_model, ...allowed] - .find((name) => typeof name === "string" && allowed.has(name)); - for (const key of ["defaultModel", "default_model"]) { - const original = value[key]; - const name = typeof original === "string" ? original : original?.model || original?.id || original?.slug; - if (!name || allowed.has(name)) continue; - const patched = typeof original === "string" ? fallback : codexPlusModelDescriptor(fallback); - if (!snapshot.fields[key] || original !== snapshot.fields[key].patched) { - snapshot.fields[key] = { original, patched }; - } - value[key] = patched; - changed = true; - } - codexPlusModelDefaultSnapshots.set(value, snapshot); return changed; } diff --git a/assets/inject/renderer-inject/manifest.json b/assets/inject/renderer-inject/manifest.json index 2e8ae3bb89..7f3cebfb6b 100644 --- a/assets/inject/renderer-inject/manifest.json +++ b/assets/inject/renderer-inject/manifest.json @@ -23,7 +23,7 @@ { "name": "30-service-tier.js", "description": "供应商会话适配、请求桥接与原生状态模块定位", - "lines": 995 + "lines": 1001 }, { "name": "31-config-read-deadline.js", @@ -58,7 +58,7 @@ { "name": "70-model-catalog.js", "description": "全局状态、模型目录、模型白名单与解锁", - "lines": 1161 + "lines": 1145 }, { "name": "71-model-picker-groups.js", diff --git a/crates/codex-plus-core/tests/all/cdp_bridge.rs b/crates/codex-plus-core/tests/all/cdp_bridge.rs index e85f161f1a..df1a133f45 100644 --- a/crates/codex-plus-core/tests/all/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/all/cdp_bridge.rs @@ -3345,7 +3345,7 @@ fn injection_script_unlocks_custom_model_catalog() { assert!(script.contains("loadAppServerRequestCandidates")); assert!(script.contains("appServerFallbackAssetUrls")); assert!(script.contains("collectAppServerRequestCandidatesFromModule")); - assert!(script.contains("codexAppServerModelRequestPatchVersion = \"30\"")); + assert!(script.contains("codexAppServerModelRequestPatchVersion = \"31\"")); assert!(script.contains("list-models-for-host")); assert!(script.contains("appServerModelRequestMethod")); @@ -3390,6 +3390,7 @@ fn model_whitelist_never_claims_the_host_default_model() { format!( r#" const codexModelCatalog = {{ + status: "ok", default_model: "supplier-default", model: "supplier-default", provider_name: "Supplier", @@ -3399,7 +3400,8 @@ const codexPlusModelMetadata = () => null; const modelReasoningEfforts = () => []; const applyCodexPlusModelMetadata = () => false; const codexPlusModelNames = () => ["supplier-default", "extra-model"]; -const codexRemoteSessionActiveProfile = () => ({{ id: "custom", relayMode: "official" }}); +let relayMode = "official"; +const codexRemoteSessionActiveProfile = () => ({{ id: "custom", relayMode }}); const codexPlusModelUnlockEnabled = () => true; const codexPlusBackendSettingsLoaded = true; const codexPlusModelCollectionSnapshots = new WeakMap(); @@ -3437,6 +3439,21 @@ const existingContainerDefault = {{ patchModelContainer(existingContainerDefault); if (existingContainerDefault.defaultModel !== hostDefault) process.exit(9); if (existingContainerDefault.model !== hostModel) process.exit(10); + +relayMode = "pureApi"; +const savedDefault = {{ model: "outside-provider-list", defaultReasoningEffort: "xhigh" }}; +const pureApiHost = {{ + models: [{{ model: "native-model", isDefault: true }}], + defaultModel: savedDefault, + default_model: "outside-provider-list", + model: hostModel, +}}; +patchModelContainer(pureApiHost, "local"); +if (pureApiHost.defaultModel !== savedDefault) process.exit(11); +if (pureApiHost.defaultModel.defaultReasoningEffort !== "xhigh") process.exit(12); +if (pureApiHost.default_model !== "outside-provider-list") process.exit(13); +if (pureApiHost.model !== hostModel) process.exit(14); +if (!pureApiHost.models.some((item) => item.model === "supplier-default")) process.exit(15); "# ), ) @@ -3714,8 +3731,8 @@ fn injection_script_preserves_native_tiers_and_shared_provider_contracts() { cases["unfilteredModelScenarios"], json!([true, true, true, true, true]) ); - assert_eq!(cases["pureApiDefaultModel"], "relay-model"); - assert_eq!(cases["pureApiStatsigDefault"], "relay-model"); + assert_eq!(cases["pureApiDefaultModel"], "gpt-unavailable"); + assert_eq!(cases["pureApiStatsigDefault"], "gpt-unavailable"); assert_eq!(cases["modelsRestoredAfterModeChange"], true); assert_eq!(cases["modelsRestoredAfterReinjection"], true); assert_eq!(cases["modelsRestoredAfterCatalogFailure"], true); From 892ede071428c1ba0244b3d65a9734f8c8b03a0e Mon Sep 17 00:00:00 2001 From: Yuimi-chaya Date: Thu, 29 Oct 2026 22:44:26 +0800 Subject: [PATCH 6/7] =?UTF-8?q?test(app):=20=E8=A1=A5=E9=BD=90=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=B8=82=E5=9C=BA=E5=A4=B9=E5=85=B7=E7=9A=84=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=81=A2=E5=A4=8D=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/inject/plugin-market-adapter.test.cjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/assets/inject/plugin-market-adapter.test.cjs b/assets/inject/plugin-market-adapter.test.cjs index 2cc91dec83..64868deebe 100644 --- a/assets/inject/plugin-market-adapter.test.cjs +++ b/assets/inject/plugin-market-adapter.test.cjs @@ -64,6 +64,8 @@ function fixture(handler, { enabled = true } = {}) { codexPlusSettings: () => ({ serviceTierControls: false }), codexRemoteSessionProviderRequestMethod: () => false, codexRemoteSessionProviderOverrideEnabled: () => false, applyCodexRemoteSessionProviderOverride: (_, params) => params, applyCodexServiceTierRequestOnly: (_, params) => params, + prepareCodexPureApiResume: async (_, params) => params, + verifyCodexPureApiResumeResult: () => {}, refreshCodexThreadModelBeforeTurn: async () => null, codexThreadModelRequestState: () => ({ threadId: "", model: "" }), NativeClient, From 20147fd60011ccc3cd5c49a84cbb1444c86cce59 Mon Sep 17 00:00:00 2001 From: Yuimi-chaya Date: Fri, 30 Oct 2026 08:20:15 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix(app):=20=E4=BF=9D=E7=95=99=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E6=A8=A1=E5=9E=8B=E6=9B=BF=E6=8D=A2=E5=B9=B6=E4=B8=BA?= =?UTF-8?q?=E6=97=A7=E7=BA=BF=E7=A8=8B=E6=81=A2=E5=A4=8D=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E9=99=8D=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/thread-model-preservation.test.ts | 137 ++++++++++++++---- assets/inject/renderer-inject.js | 56 +++++-- assets/inject/renderer-inject/00-prelude.js | 2 +- .../inject/renderer-inject/30-service-tier.js | 32 +++- .../renderer-inject/70-model-catalog.js | 22 ++- assets/inject/renderer-inject/manifest.json | 4 +- .../codex-plus-core/tests/all/cdp_bridge.rs | 12 +- 7 files changed, 206 insertions(+), 59 deletions(-) diff --git a/apps/codex-plus-manager/src/thread-model-preservation.test.ts b/apps/codex-plus-manager/src/thread-model-preservation.test.ts index 4cb25ba786..e6dc3e044d 100644 --- a/apps/codex-plus-manager/src/thread-model-preservation.test.ts +++ b/apps/codex-plus-manager/src/thread-model-preservation.test.ts @@ -88,16 +88,17 @@ function snapshotRuntime(read?: (payload: any) => Promise) { const settings: any = { relayProfilesEnabled: true, activeRelayId: "relay", providerSyncEnabled: false, relayProfiles: [{ id: "relay", relayMode: "pureApi", configContents: 'model_provider = "custom"' }] }; const calls: any[] = []; + const diagnostics: any[] = []; const create = new Function("codexPlusBackendSettings", "codexModelCatalog", "sendCodexPlusDiagnostic", "postJson", "codexAppServerPreparationTimeoutMs", "window", `${override}\n${readSource} return { prepare: prepareCodexPureApiResume, apply: applyCodexRemoteSessionProviderOverride, verify: verifyCodexPureApiResumeResult };`); - const api = create(settings, {}, () => {}, async (route: string, payload: any) => { + const api = create(settings, {}, (event: string, detail: any) => diagnostics.push({ event, detail }), async (route: string, payload: any) => { assert.equal(route, "/thread-resume-model-metadata"); calls.push(payload); return read ? read(payload) : { status: "ok", threadId: payload.thread_id, model: "saved-model", reasoningEffort: "high" }; }, 30, {}); - return { ...api, settings, calls }; + return { ...api, settings, calls, diagnostics }; } describe("pure API persisted model snapshot", () => { @@ -184,30 +185,73 @@ describe("pure API persisted model snapshot", () => { assert.equal(api.calls.length, 0); }); - it("rejects incomplete, missing, mismatched or failed metadata instead of issuing an unsafe resume", async () => { + it("falls back to the current provider without a model for incomplete, conflicting or failed metadata", async () => { for (const data of [ - null, { status: "failed" }, { status: "ok", threadId: "other", model: "saved", reasoningEffort: "high" }, + null, { status: "failed" }, { status: "failed", error: "conflicting thread models" }, + { status: "ok", threadId: "other", model: "saved", reasoningEffort: "high" }, + { status: "ok", threadId: "a", model: null, reasoningEffort: "high" }, { status: "ok", threadId: "a", model: "", reasoningEffort: "high" }, { status: "ok", threadId: "a", model: "saved" }, { status: "ok", threadId: "a", model: "saved", reasoningEffort: "" }, ]) { const api = snapshotRuntime(async () => data); - await assert.rejects(api.prepare("thread/resume", { threadId: "a" }, "local"), /无法读取/); + const params = { threadId: "a", model: null, model_provider: "openai", + config: { model: "", model_reasoning_effort: "low", model_context_window: 500000 } }; + const original = structuredClone(params); + const result = await api.prepare("thread/resume", params, "local"); + assert.equal(result.modelProvider, "custom"); + assert.equal(Object.hasOwn(result, "model"), false); + assert.equal(Object.hasOwn(result, "model_provider"), false); + assert.equal(Object.hasOwn(result.config, "model"), false); + assert.equal(result.config.model_reasoning_effort, "low"); + assert.equal(result.config.model_context_window, 500000); + assert.deepEqual(params, original); + assert.deepEqual(api.diagnostics, [{ event: "pure_api_resume_model_fallback", + detail: { reason: "metadata_unavailable" } }]); + api.verify("thread/resume", result, + { model: "native-default", modelProvider: "custom", reasoningEffort: "low" }, "local"); + assert.throws(() => api.verify("thread/resume", result, + { model: "native-default", modelProvider: "openai", reasoningEffort: "low" }, "local"), /当前供应商/); } const failed = snapshotRuntime(async () => { throw new Error("SQLite unavailable"); }); - await assert.rejects(failed.prepare("thread/resume", { threadId: "a" }, "local"), /SQLite unavailable/); - await assert.rejects(failed.prepare("thread/resume", { threadId: "a" }, "local", true), /无法确认/); + const result = await failed.prepare("thread/resume", { threadId: "a" }, "local"); + assert.equal(result.modelProvider, "custom"); + assert.equal(Object.hasOwn(result, "model"), false); + assert.equal(Object.hasOwn(result.config, "model_reasoning_effort"), false); assert.equal(failed.calls.length, 1); }); - it("bounds read waits and ignores late snapshots", async () => { + it("uses the current provider after refresh failure without reading or injecting saved defaults", async () => { + const api = snapshotRuntime(async () => { throw new Error("must not read"); }); + api.settings.relayProfiles[0].configContents = 'model_provider = "deepseek"'; + api.settings.activeRelayCodexProvider = "stale-provider"; + const result = await api.prepare("thread/resume", + { threadId: "a", model: null, config: { model: null, model_reasoning_effort: null } }, "local", true); + assert.equal(result.modelProvider, "deepseek"); + assert.equal(Object.hasOwn(result, "model"), false); + assert.equal(Object.hasOwn(result.config, "model"), false); + assert.equal(Object.hasOwn(result.config, "model_reasoning_effort"), false); + assert.equal(api.calls.length, 0); + assert.deepEqual(api.diagnostics, [{ event: "pure_api_resume_model_fallback", + detail: { reason: "provider_refresh_failed" } }]); + const explicit = await api.prepare("thread/resume", + { threadId: "a", config: { model: "chosen" } }, "local", true); + assert.equal(explicit.model, "chosen"); + assert.equal(explicit.modelProvider, "deepseek"); + }); + + it("bounds read waits, falls back and ignores late snapshots", async () => { let finish!: (value: any) => void; const api = snapshotRuntime(() => new Promise(resolve => { finish = resolve; })); const params = { threadId: "a" }; - await assert.rejects(api.prepare("thread/resume", params, "local"), /timed out/); + const result = await api.prepare("thread/resume", params, "local"); + assert.equal(result.modelProvider, "custom"); + assert.equal(Object.hasOwn(result, "model"), false); finish({ status: "ok", threadId: "a", model: "late", reasoningEffort: "high" }); await Promise.resolve(); assert.deepEqual(params, { threadId: "a" }); + assert.equal(Object.hasOwn(result, "model"), false); + assert.equal(api.diagnostics.filter((item: any) => item.event === "pure_api_resume_model_fallback").length, 1); }); it("rejects a provider change while reading the saved model", async () => { @@ -229,6 +273,26 @@ describe("pure API persisted model snapshot", () => { } }); + it("does not downgrade concurrent setting changes into an availability fallback", async () => { + for (const changed of ["provider", "model"]) { + const params: any = { threadId: "a" }; + const api = snapshotRuntime(async () => { + if (changed === "provider") api.settings.relayProfiles[0].configContents = 'model_provider = "changed"'; + else params.model = "new-choice"; + throw new Error("metadata unavailable"); + }); + await assert.rejects(api.prepare("thread/resume", params, "local"), /已变化/); + assert.equal(api.diagnostics.length, 0); + } + }); + + it("keeps loaded-provider guards on fallback resumes", async () => { + const api = snapshotRuntime(async () => null); + await assert.rejects(api.prepare("thread/resume", { threadId: "a" }, "local", false, + async () => ({ thread: { modelProvider: "openai", status: { type: "idle" } } })), /旧供应商连接/); + assert.equal(api.diagnostics.length, 0); + }); + it("rejects loaded old-provider sessions without unloading and verifies every restored setting", async () => { for (const status of ["notLoaded", "idle", "active"]) { const api = snapshotRuntime(); @@ -292,10 +356,15 @@ describe("pure API persisted model snapshot", () => { const instance = catalog.slice(catalog.indexOf(" function patchAppServerModelRequestClient("), catalog.indexOf(" // issue #2177")); const prototype = catalog.slice(catalog.indexOf(" function installCodexAppServerClientPrototypePatch()"), catalog.indexOf(" const appServerModelRequestPatchMaxMisses")); - for (const [mode, optional] of ["instance", "prototype", "envelope"].flatMap(mode => - [{}, { model: null }, { modelProvider: "openai" }, - { config: { model: null, model_reasoning_effort: null } }].map(optional => [mode, optional] as const))) { + const version = readFileSync(new URL("../../../assets/inject/renderer-inject/00-prelude.js", import.meta.url), "utf8") + .match(/const codexAppServerModelRequestPatchVersion = "([^"]+)"/)?.[1]; + for (const [mode, scenario, optional] of ["instance", "prototype", "envelope"].flatMap(mode => + ["snapshot", "missing", "read-failed", "refresh-failed"].flatMap(scenario => + [{}, { model: null }, { modelProvider: "openai" }, + { config: { model: null, model_reasoning_effort: null } }].map(optional => [mode, scenario, optional] as const)))) { const calls: any[] = []; + const diagnostics: string[] = []; + let metadataReads = 0; class Client { hostId = "local"; async sendRequest(method: string, params: any) { @@ -305,23 +374,28 @@ describe("pure API persisted model snapshot", () => { if (innerMethod === "thread/read") { return { thread: { id: "a", modelProvider: "openai", status: { type: "notLoaded" } } }; } - return { model: innerParams.model, modelProvider: innerParams.modelProvider, + return { model: innerParams.model || "native-default", modelProvider: innerParams.modelProvider, reasoningEffort: innerParams.config?.model_reasoning_effort, thread: { id: "a" } }; } } const client = new Client(); const context = vm.createContext({ window: { __codexPlusAppServerClientClass: Client, __codexSessionDeleteBridge: () => {} }, client, - codexAppServerModelRequestPatchVersion: "31", + codexAppServerModelRequestPatchVersion: version, codexPlusBackendSettings: { relayProfilesEnabled: true, activeRelayId: "relay", providerSyncEnabled: false, relayProfiles: [{ id: "relay", relayMode: "pureApi", configContents: 'model_provider = "custom"' }] }, codexModelCatalog: {}, codexPlusBackendSettingsLoaded: true, codexAppServerPreparationTimeoutMs: 1000, - postJson: async (_route: string, payload: any) => ({ status: "ok", threadId: payload.thread_id, - model: "saved-outside-catalog", reasoningEffort: "ultra" }), - registerNativeHostClient() {}, sendCodexPlusDiagnostic() {}, codexPlusPluginNativeInterceptClient: () => null, + postJson: async (_route: string, payload: any) => { + metadataReads++; + if (scenario === "missing") return { status: "failed" }; + if (scenario === "read-failed") throw new Error("metadata unavailable"); + return { status: "ok", threadId: payload.thread_id, model: "saved-outside-catalog", reasoningEffort: "ultra" }; + }, + registerNativeHostClient() {}, sendCodexPlusDiagnostic(event: string) { diagnostics.push(event); }, + codexPlusPluginNativeInterceptClient: () => null, appServerModelRequestMethod: (method: string, params: any) => method === "send-cli-request-for-host" ? params.method : method, - loadBackendSettingsState: async () => true, loadCodexModelCatalog: async () => ({}), + loadBackendSettingsState: async () => scenario !== "refresh-failed", loadCodexModelCatalog: async () => ({}), prepareCodexManagedSessionConnection: async (_method: string, params: any) => params, codexManagedSessionConnection: () => null, refreshCodexThreadModelBeforeTurn: async () => null, sendCodexManagedSessionTurn: async () => null, noteCodexManagedSessionStart() {}, @@ -338,20 +412,29 @@ describe("pure API persisted model snapshot", () => { assert.equal(resumes.length, 1); assert.equal(calls.length, 2); const received = mode === "envelope" ? resumes[0].params.params : resumes[0].params; - assert.equal(received.model, "saved-outside-catalog"); assert.equal(received.modelProvider, "custom"); - assert.equal(received.config.model_reasoning_effort, "ultra"); + assert.equal(metadataReads, scenario === "refresh-failed" ? 0 : 1); + if (scenario === "snapshot") { + assert.equal(received.model, "saved-outside-catalog"); + assert.equal(received.config.model_reasoning_effort, "ultra"); + assert.equal(diagnostics.includes("pure_api_resume_model_fallback"), false); + } else { + assert.equal(Object.hasOwn(received, "model"), false); + assert.equal(Object.hasOwn(received.config, "model"), false); + assert.equal(Object.hasOwn(received.config, "model_reasoning_effort"), false); + assert.equal(diagnostics.filter(event => event === "pure_api_resume_model_fallback").length, 1); + } } }); }); -describe("native default model ownership in pure API filtering", () => { +describe("native default model fallback in pure API filtering", () => { const catalog = readFileSync(new URL("../../../assets/inject/renderer-inject/70-model-catalog.js", import.meta.url), "utf8"); const descriptor = catalog.slice(catalog.indexOf(" function codexPlusModelDescriptor("), catalog.indexOf(" function modelArrayLooksPatchable(")); const defaults = catalog.slice(catalog.indexOf(" function patchModelDefaults("), catalog.indexOf(" // 只收窄当前纯 API")); - it("keeps existing host defaults even outside the current provider list", () => { + it("retains upstream replacement for unsupported new-thread defaults without changing the thread model", () => { const snapshots = new WeakMap(); const patch = new Function("codexModelCatalog", "codexPlusModelDefaultSnapshots", "modelFilterPolicy", "codexPlusModelMetadata", "modelReasoningEfforts", `${descriptor}\n${defaults}\nreturn patchModelDefaults;`)( @@ -359,9 +442,9 @@ describe("native default model ownership in pure API filtering", () => { () => null, () => []); const original = { model: "gpt-6.1-terra", defaultReasoningEffort: "xhigh" }; const host: any = { defaultModel: original, default_model: "gpt-6.1-terra", model: "saved-choice" }; - assert.equal(patch(host, new Set(["gpt-6.1-sol"])), false); - assert.equal(host.defaultModel, original); - assert.equal(host.default_model, "gpt-6.1-terra"); + assert.equal(patch(host, new Set(["gpt-6.1-sol"])), true); + assert.equal(host.defaultModel.model, "gpt-6.1-sol"); + assert.equal(host.default_model, "gpt-6.1-sol"); assert.equal(host.model, "saved-choice"); const missing = {}; patch(missing, new Set(["gpt-6.1-sol"])); @@ -370,12 +453,12 @@ describe("native default model ownership in pure API filtering", () => { const oldPatched = { model: "gpt-6.1-sol", defaultReasoningEffort: "medium" }; host.defaultModel = oldPatched; snapshots.set(host, { policy: "old", fields: { defaultModel: { original, patched: oldPatched } } }); - assert.equal(patch(host, new Set(["gpt-6.1-sol"])), true); + assert.equal(patch(host, null), true); assert.equal(host.defaultModel, original); host.defaultModel = { model: "new-user-choice" }; const userChoice = host.defaultModel; snapshots.set(host, { policy: "old", fields: { defaultModel: { original, patched: oldPatched } } }); - patch(host, new Set(["gpt-6.1-sol"])); + patch(host, null); assert.equal(host.defaultModel, userChoice); }); }); diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 8767bcac1d..3d7aee3d70 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -431,7 +431,7 @@ const codexPlusSettingsKey = "codexPlusSettings"; const codexThreadScrollKey = "codexThreadScroll"; const codexServiceTierRequestOverrideVersion = "10"; - const codexAppServerModelRequestPatchVersion = "31"; + const codexAppServerModelRequestPatchVersion = "32"; const codexAppServerClientCaptureMarker = "AppServerRequestClient is missing a message dispatcher"; const codexAppServerClientCaptureAnchor = "async sendRequest("; const codexRemoteSessionRecoveryVersion = "8"; @@ -5509,7 +5509,6 @@ const requestedProvider = String(params.modelProvider || params.model_provider || "").trim(); // nullable 字段和仅指定供应商不是模型选择;补供应商时必须同时保留保存的模型设置。 if (specified(params.model) || (requestedProvider && requestedProvider !== "openai" && requestedProvider !== target)) return params; - if (refreshFailed) throw new Error("无法确认当前供应商,请重新打开会话后再试"); if (specified(params.config?.model)) { return applyCodexRemoteSessionProviderOverride(method, { ...params, model: params.config.model }); } @@ -5527,22 +5526,30 @@ if (signature() !== before) throw new Error("供应商配置已变化,请重新打开会话"); if (requestSignature() !== requestBefore) throw new Error("会话设置已变化,请重新打开会话"); }; - const metadata = await readCodexAppServerPreparation("/thread-resume-model-metadata", - { thread_id: params.threadId, host_id: "local" }, codexAppServerPreparationTimeoutMs); + let metadata; + let fallbackReason = refreshFailed ? "provider_refresh_failed" : ""; + if (!fallbackReason) { + try { + metadata = await readCodexAppServerPreparation("/thread-resume-model-metadata", + { thread_id: params.threadId, host_id: "local" }, codexAppServerPreparationTimeoutMs); + } catch { + fallbackReason = "metadata_unavailable"; + } + } assertUnchanged(); if (metadata?.status !== "ok" || metadata.threadId !== params.threadId || typeof metadata.model !== "string" || !metadata.model.trim() || !Object.prototype.hasOwnProperty.call(metadata, "reasoningEffort") || (metadata.reasoningEffort !== null && (typeof metadata.reasoningEffort !== "string" || !metadata.reasoningEffort.trim()))) { - throw new Error("无法读取此会话保存的模型设置,未覆盖原有选择"); + fallbackReason ||= "metadata_unavailable"; } const config = { ...(params.config || {}) }; delete config.model; // null 表示原线程未指定档位,沿用原生默认语义;显式请求设置始终优先。 if (!specified(config.model_reasoning_effort)) { delete config.model_reasoning_effort; - if (metadata.reasoningEffort !== null) config.model_reasoning_effort = metadata.reasoningEffort; + if (!fallbackReason && metadata.reasoningEffort !== null) config.model_reasoning_effort = metadata.reasoningEffort; } if (sendRequest) { // 已加载会话可能忽略连接覆盖;不卸载正在被原生界面使用的实例。 @@ -5562,7 +5569,14 @@ throw new Error("此会话仍使用旧供应商连接,请重新启动 Codex 后重试"); } } - const next = { ...params, model: metadata.model, modelProvider: target, config }; + const next = { ...params, modelProvider: target, config }; + if (fallbackReason) { + // 旧线程缺少可靠设置时优先保持可打开,由原生选择默认模型。 + delete next.model; + sendCodexPlusDiagnostic("pure_api_resume_model_fallback", { reason: fallbackReason }); + } else { + next.model = metadata.model; + } delete next.model_provider; (window.__codexPlusPureApiPreparedResumes ||= new WeakSet()).add(next); return next; @@ -5574,10 +5588,12 @@ return verifyCodexPureApiResumeResult(String(params.method), params.params, result, hostId); } if (method !== "thread/resume" || !window.__codexPlusPureApiPreparedResumes?.has(params)) return; - if (result?.modelProvider !== params.modelProvider || result?.model !== params.model + if (result?.modelProvider !== params.modelProvider || (params.model && result?.model !== params.model) || (Object.prototype.hasOwnProperty.call(params.config, "model_reasoning_effort") && result?.reasoningEffort !== params.config.model_reasoning_effort)) { - throw new Error("会话未采用保存的模型和当前供应商,请重新启动 Codex 后重试"); + throw new Error(params.model + ? "会话未采用保存的模型和当前供应商,请重新启动 Codex 后重试" + : "会话未采用当前供应商或指定的推理档位,请重新启动 Codex 后重试"); } } @@ -11626,11 +11642,11 @@ if (snapshot) snapshot.patched = [...collection]; } - function patchModelDefaults(value) { + function patchModelDefaults(value, allowed) { + const policy = modelFilterPolicy(allowed); const previous = codexPlusModelDefaultSnapshots.get(value); let changed = false; - // 目录只提供选项;宿主默认值和线程选择不能由当前供应商的首个模型接管。 - if (previous) { + if (previous && previous.policy !== policy) { for (const [key, state] of Object.entries(previous.fields)) { if (value[key] === state.patched && value[key] !== state.original) { value[key] = state.original; @@ -11639,6 +11655,22 @@ } codexPlusModelDefaultSnapshots.delete(value); } + if (!allowed) return changed; + const snapshot = codexPlusModelDefaultSnapshots.get(value) || { policy, fields: {} }; + const fallback = [codexModelCatalog.model, codexModelCatalog.default_model, ...allowed] + .find((name) => typeof name === "string" && allowed.has(name)); + for (const key of ["defaultModel", "default_model"]) { + const original = value[key]; + const name = typeof original === "string" ? original : original?.model || original?.id || original?.slug; + if (!name || allowed.has(name)) continue; + const patched = typeof original === "string" ? fallback : codexPlusModelDescriptor(fallback); + if (!snapshot.fields[key] || original !== snapshot.fields[key].patched) { + snapshot.fields[key] = { original, patched }; + } + value[key] = patched; + changed = true; + } + codexPlusModelDefaultSnapshots.set(value, snapshot); return changed; } diff --git a/assets/inject/renderer-inject/00-prelude.js b/assets/inject/renderer-inject/00-prelude.js index 73c4146fd2..8c715f73be 100644 --- a/assets/inject/renderer-inject/00-prelude.js +++ b/assets/inject/renderer-inject/00-prelude.js @@ -431,7 +431,7 @@ const codexPlusSettingsKey = "codexPlusSettings"; const codexThreadScrollKey = "codexThreadScroll"; const codexServiceTierRequestOverrideVersion = "10"; - const codexAppServerModelRequestPatchVersion = "31"; + const codexAppServerModelRequestPatchVersion = "32"; const codexAppServerClientCaptureMarker = "AppServerRequestClient is missing a message dispatcher"; const codexAppServerClientCaptureAnchor = "async sendRequest("; const codexRemoteSessionRecoveryVersion = "8"; diff --git a/assets/inject/renderer-inject/30-service-tier.js b/assets/inject/renderer-inject/30-service-tier.js index 8da7b7e744..e9863f6ba2 100644 --- a/assets/inject/renderer-inject/30-service-tier.js +++ b/assets/inject/renderer-inject/30-service-tier.js @@ -144,7 +144,6 @@ const requestedProvider = String(params.modelProvider || params.model_provider || "").trim(); // nullable 字段和仅指定供应商不是模型选择;补供应商时必须同时保留保存的模型设置。 if (specified(params.model) || (requestedProvider && requestedProvider !== "openai" && requestedProvider !== target)) return params; - if (refreshFailed) throw new Error("无法确认当前供应商,请重新打开会话后再试"); if (specified(params.config?.model)) { return applyCodexRemoteSessionProviderOverride(method, { ...params, model: params.config.model }); } @@ -162,22 +161,30 @@ if (signature() !== before) throw new Error("供应商配置已变化,请重新打开会话"); if (requestSignature() !== requestBefore) throw new Error("会话设置已变化,请重新打开会话"); }; - const metadata = await readCodexAppServerPreparation("/thread-resume-model-metadata", - { thread_id: params.threadId, host_id: "local" }, codexAppServerPreparationTimeoutMs); + let metadata; + let fallbackReason = refreshFailed ? "provider_refresh_failed" : ""; + if (!fallbackReason) { + try { + metadata = await readCodexAppServerPreparation("/thread-resume-model-metadata", + { thread_id: params.threadId, host_id: "local" }, codexAppServerPreparationTimeoutMs); + } catch { + fallbackReason = "metadata_unavailable"; + } + } assertUnchanged(); if (metadata?.status !== "ok" || metadata.threadId !== params.threadId || typeof metadata.model !== "string" || !metadata.model.trim() || !Object.prototype.hasOwnProperty.call(metadata, "reasoningEffort") || (metadata.reasoningEffort !== null && (typeof metadata.reasoningEffort !== "string" || !metadata.reasoningEffort.trim()))) { - throw new Error("无法读取此会话保存的模型设置,未覆盖原有选择"); + fallbackReason ||= "metadata_unavailable"; } const config = { ...(params.config || {}) }; delete config.model; // null 表示原线程未指定档位,沿用原生默认语义;显式请求设置始终优先。 if (!specified(config.model_reasoning_effort)) { delete config.model_reasoning_effort; - if (metadata.reasoningEffort !== null) config.model_reasoning_effort = metadata.reasoningEffort; + if (!fallbackReason && metadata.reasoningEffort !== null) config.model_reasoning_effort = metadata.reasoningEffort; } if (sendRequest) { // 已加载会话可能忽略连接覆盖;不卸载正在被原生界面使用的实例。 @@ -197,7 +204,14 @@ throw new Error("此会话仍使用旧供应商连接,请重新启动 Codex 后重试"); } } - const next = { ...params, model: metadata.model, modelProvider: target, config }; + const next = { ...params, modelProvider: target, config }; + if (fallbackReason) { + // 旧线程缺少可靠设置时优先保持可打开,由原生选择默认模型。 + delete next.model; + sendCodexPlusDiagnostic("pure_api_resume_model_fallback", { reason: fallbackReason }); + } else { + next.model = metadata.model; + } delete next.model_provider; (window.__codexPlusPureApiPreparedResumes ||= new WeakSet()).add(next); return next; @@ -209,10 +223,12 @@ return verifyCodexPureApiResumeResult(String(params.method), params.params, result, hostId); } if (method !== "thread/resume" || !window.__codexPlusPureApiPreparedResumes?.has(params)) return; - if (result?.modelProvider !== params.modelProvider || result?.model !== params.model + if (result?.modelProvider !== params.modelProvider || (params.model && result?.model !== params.model) || (Object.prototype.hasOwnProperty.call(params.config, "model_reasoning_effort") && result?.reasoningEffort !== params.config.model_reasoning_effort)) { - throw new Error("会话未采用保存的模型和当前供应商,请重新启动 Codex 后重试"); + throw new Error(params.model + ? "会话未采用保存的模型和当前供应商,请重新启动 Codex 后重试" + : "会话未采用当前供应商或指定的推理档位,请重新启动 Codex 后重试"); } } diff --git a/assets/inject/renderer-inject/70-model-catalog.js b/assets/inject/renderer-inject/70-model-catalog.js index 171fec38a4..0f211a4599 100644 --- a/assets/inject/renderer-inject/70-model-catalog.js +++ b/assets/inject/renderer-inject/70-model-catalog.js @@ -275,11 +275,11 @@ if (snapshot) snapshot.patched = [...collection]; } - function patchModelDefaults(value) { + function patchModelDefaults(value, allowed) { + const policy = modelFilterPolicy(allowed); const previous = codexPlusModelDefaultSnapshots.get(value); let changed = false; - // 目录只提供选项;宿主默认值和线程选择不能由当前供应商的首个模型接管。 - if (previous) { + if (previous && previous.policy !== policy) { for (const [key, state] of Object.entries(previous.fields)) { if (value[key] === state.patched && value[key] !== state.original) { value[key] = state.original; @@ -288,6 +288,22 @@ } codexPlusModelDefaultSnapshots.delete(value); } + if (!allowed) return changed; + const snapshot = codexPlusModelDefaultSnapshots.get(value) || { policy, fields: {} }; + const fallback = [codexModelCatalog.model, codexModelCatalog.default_model, ...allowed] + .find((name) => typeof name === "string" && allowed.has(name)); + for (const key of ["defaultModel", "default_model"]) { + const original = value[key]; + const name = typeof original === "string" ? original : original?.model || original?.id || original?.slug; + if (!name || allowed.has(name)) continue; + const patched = typeof original === "string" ? fallback : codexPlusModelDescriptor(fallback); + if (!snapshot.fields[key] || original !== snapshot.fields[key].patched) { + snapshot.fields[key] = { original, patched }; + } + value[key] = patched; + changed = true; + } + codexPlusModelDefaultSnapshots.set(value, snapshot); return changed; } diff --git a/assets/inject/renderer-inject/manifest.json b/assets/inject/renderer-inject/manifest.json index 7f3cebfb6b..ec7dc2507e 100644 --- a/assets/inject/renderer-inject/manifest.json +++ b/assets/inject/renderer-inject/manifest.json @@ -23,7 +23,7 @@ { "name": "30-service-tier.js", "description": "供应商会话适配、请求桥接与原生状态模块定位", - "lines": 1001 + "lines": 1017 }, { "name": "31-config-read-deadline.js", @@ -58,7 +58,7 @@ { "name": "70-model-catalog.js", "description": "全局状态、模型目录、模型白名单与解锁", - "lines": 1145 + "lines": 1161 }, { "name": "71-model-picker-groups.js", diff --git a/crates/codex-plus-core/tests/all/cdp_bridge.rs b/crates/codex-plus-core/tests/all/cdp_bridge.rs index df1a133f45..e07b850cd9 100644 --- a/crates/codex-plus-core/tests/all/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/all/cdp_bridge.rs @@ -3345,7 +3345,7 @@ fn injection_script_unlocks_custom_model_catalog() { assert!(script.contains("loadAppServerRequestCandidates")); assert!(script.contains("appServerFallbackAssetUrls")); assert!(script.contains("collectAppServerRequestCandidatesFromModule")); - assert!(script.contains("codexAppServerModelRequestPatchVersion = \"31\"")); + assert!(script.contains("codexAppServerModelRequestPatchVersion = \"32\"")); assert!(script.contains("list-models-for-host")); assert!(script.contains("appServerModelRequestMethod")); @@ -3449,9 +3449,9 @@ const pureApiHost = {{ model: hostModel, }}; patchModelContainer(pureApiHost, "local"); -if (pureApiHost.defaultModel !== savedDefault) process.exit(11); -if (pureApiHost.defaultModel.defaultReasoningEffort !== "xhigh") process.exit(12); -if (pureApiHost.default_model !== "outside-provider-list") process.exit(13); +if (pureApiHost.defaultModel.model !== "supplier-default") process.exit(11); +if (pureApiHost.defaultModel.defaultReasoningEffort !== "medium") process.exit(12); +if (pureApiHost.default_model !== "supplier-default") process.exit(13); if (pureApiHost.model !== hostModel) process.exit(14); if (!pureApiHost.models.some((item) => item.model === "supplier-default")) process.exit(15); "# @@ -3731,8 +3731,8 @@ fn injection_script_preserves_native_tiers_and_shared_provider_contracts() { cases["unfilteredModelScenarios"], json!([true, true, true, true, true]) ); - assert_eq!(cases["pureApiDefaultModel"], "gpt-unavailable"); - assert_eq!(cases["pureApiStatsigDefault"], "gpt-unavailable"); + assert_eq!(cases["pureApiDefaultModel"], "relay-model"); + assert_eq!(cases["pureApiStatsigDefault"], "relay-model"); assert_eq!(cases["modelsRestoredAfterModeChange"], true); assert_eq!(cases["modelsRestoredAfterReinjection"], true); assert_eq!(cases["modelsRestoredAfterCatalogFailure"], true);