feat(codex): declare gpt-5.6 context window for Claude Code takeover

- Update Codex OAuth presets to the gpt-5.6 family (haiku -> gpt-5.6-luna)
  and bump the custom Codex template default model
- Inject CLAUDE_CODE_MAX_CONTEXT_TOKENS / CLAUDE_CODE_AUTO_COMPACT_WINDOW
  (372000, the ChatGPT Codex catalog window with a ~353K effective budget,
  openai/codex#31860) into effective live settings so Claude Code stops
  assuming a 200K window and compacts before the upstream rejects the prompt
- Gate the injected defaults on every configured model being gpt-5.6*:
  gpt-5.5's upstream catalog oscillates between 272K and 372K and must not
  inherit them; explicit user values always win
- Strip the injected values on switch-away backfill (mirror-inverse of the
  injection conditions) so program defaults never harden into per-provider
  explicit values, and keep both keys out of the shared common-config snippet
This commit is contained in:
Jason
2026-07-12 23:20:55 +08:00
parent f15184edb0
commit 5c39dfbfbe
6 changed files with 458 additions and 6 deletions
+375
View File
@@ -21,6 +21,107 @@ use super::gemini_auth::{
}; };
use super::normalize_claude_models_in_value; use super::normalize_claude_models_in_value;
/// ChatGPT Codex catalogs gpt-5.6 at a 372K context window with a ~353K
/// effective budget (openai/codex#31860), far below the 1.05M API spec.
/// Declare the catalog window for both knobs: Claude Code's built-in output
/// reserve and compact buffer already keep the actual compact trigger
/// (~278K-339K) below the effective budget, so anything lower only wastes
/// usable context.
const CODEX_OAUTH_CLAUDE_MAX_CONTEXT_TOKENS: &str = "372000";
const CODEX_OAUTH_CLAUDE_AUTO_COMPACT_WINDOW: &str = "372000";
/// Model env keys Claude Code may route requests through. The defaults above
/// are calibrated against gpt-5.6's Codex catalog, so every configured model
/// must belong to that family before they are injected — gpt-5.5's upstream
/// catalog oscillates between 272K and 372K and must not inherit them.
const CODEX_OAUTH_MODEL_ENV_KEYS: [&str; 6] = [
"ANTHROPIC_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_FABLE_MODEL",
"CLAUDE_CODE_SUBAGENT_MODEL",
];
fn provider_env_targets_gpt56(provider_env: Option<&serde_json::Map<String, Value>>) -> bool {
let Some(env) = provider_env else {
return false;
};
let mut saw_model = false;
for key in CODEX_OAUTH_MODEL_ENV_KEYS {
let Some(value) = env.get(key) else {
continue;
};
let Some(model) = value.as_str() else {
return false;
};
let model = model.trim();
if model.is_empty() {
continue;
}
saw_model = true;
if !model.to_ascii_lowercase().starts_with("gpt-5.6") {
return false;
}
}
saw_model
}
/// Claude Code assigns unknown non-Claude model ids a 200K context window.
/// Codex OAuth deliberately exposes GPT ids through Claude Code, so enrich the
/// effective live settings for both newly-created and already-saved providers.
/// Explicit user values always win; the defaults are only injected when every
/// configured model targets gpt-5.6.
fn apply_codex_oauth_claude_context_defaults(settings: &mut Value, provider: &Provider) {
if !provider.is_codex_oauth() {
return;
}
// Read provider-owned values before mutably borrowing the effective
// settings. This also deliberately prevents a legacy common-config
// snippet from overriding model-specific context limits.
let provider_env = provider
.settings_config
.get("env")
.and_then(Value::as_object);
let Some(root) = settings.as_object_mut() else {
return;
};
let env = root.entry("env".to_string()).or_insert_with(|| json!({}));
let Some(env) = env.as_object_mut() else {
log::warn!(
"Cannot apply Codex OAuth Claude context defaults for '{}': env is not an object",
provider.id
);
return;
};
let inject_defaults = provider_env_targets_gpt56(provider_env);
for (key, default_value) in [
(
"CLAUDE_CODE_MAX_CONTEXT_TOKENS",
CODEX_OAUTH_CLAUDE_MAX_CONTEXT_TOKENS,
),
(
"CLAUDE_CODE_AUTO_COMPACT_WINDOW",
CODEX_OAUTH_CLAUDE_AUTO_COMPACT_WINDOW,
),
] {
match provider_env.and_then(|provider_env| provider_env.get(key)) {
Some(value) => {
env.insert(key.to_string(), value.clone());
}
None if inject_defaults => {
env.insert(key.to_string(), Value::String(default_value.to_string()));
}
// 老模型不注入默认值,同时剥掉遗留共享片段可能带进来的值
None => {
env.remove(key);
}
}
}
}
pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value { pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value {
let mut v = settings.clone(); let mut v = settings.clone();
if let Some(obj) = v.as_object_mut() { if let Some(obj) = v.as_object_mut() {
@@ -537,6 +638,10 @@ pub(crate) fn build_effective_settings_with_common_config(
} }
} }
if matches!(app_type, AppType::Claude) {
apply_codex_oauth_claude_context_defaults(&mut effective_settings, provider);
}
Ok(effective_settings) Ok(effective_settings)
} }
@@ -604,11 +709,55 @@ pub(crate) fn strip_common_config_from_live_settings(
restore_live_settings_for_provider_backfill(app_type, provider, backfill_settings) restore_live_settings_for_provider_backfill(app_type, provider, backfill_settings)
} }
/// 与 `apply_codex_oauth_claude_context_defaults` 严格对称:注入产物只活在
/// live,切走回填时必须剥掉,否则程序默认值会固化成供应商的"用户显式值",
/// 之后调整默认值或更换模型时旧值永远压住新默认。仅当"注入会发生且注入的
/// 就是这个值、且存储配置本来没有显式值"时才剥;用户显式存储的值和手改
/// live 成其他数字的值都保留。
fn strip_injected_codex_oauth_context_defaults(settings: &mut Value, provider: &Provider) {
if !provider.is_codex_oauth() {
return;
}
let provider_env = provider
.settings_config
.get("env")
.and_then(Value::as_object);
if !provider_env_targets_gpt56(provider_env) {
return;
}
let Some(env) = settings.get_mut("env").and_then(Value::as_object_mut) else {
return;
};
for (key, default_value) in [
(
"CLAUDE_CODE_MAX_CONTEXT_TOKENS",
CODEX_OAUTH_CLAUDE_MAX_CONTEXT_TOKENS,
),
(
"CLAUDE_CODE_AUTO_COMPACT_WINDOW",
CODEX_OAUTH_CLAUDE_AUTO_COMPACT_WINDOW,
),
] {
let stored_explicit = provider_env.is_some_and(|e| e.contains_key(key));
if stored_explicit {
continue;
}
if env.get(key).and_then(Value::as_str) == Some(default_value) {
env.remove(key);
}
}
}
fn restore_live_settings_for_provider_backfill( fn restore_live_settings_for_provider_backfill(
app_type: &AppType, app_type: &AppType,
provider: &Provider, provider: &Provider,
live_settings: Value, live_settings: Value,
) -> Value { ) -> Value {
if matches!(app_type, AppType::Claude) {
let mut settings = live_settings;
strip_injected_codex_oauth_context_defaults(&mut settings, provider);
return settings;
}
if !matches!(app_type, AppType::Codex) { if !matches!(app_type, AppType::Codex) {
return live_settings; return live_settings;
} }
@@ -1711,6 +1860,232 @@ mod tests {
use super::*; use super::*;
use serde_json::json; use serde_json::json;
#[test]
fn codex_oauth_effective_settings_backfill_gpt_context_defaults() {
let db = Database::memory().expect("create memory db");
let mut provider = Provider::with_id(
"codex-oauth".to_string(),
"Codex".to_string(),
json!({
"env": {
"ANTHROPIC_MODEL": "gpt-5.6"
}
}),
None,
);
provider.meta = Some(crate::provider::ProviderMeta {
provider_type: Some("codex_oauth".to_string()),
..Default::default()
});
let effective =
build_effective_settings_with_common_config(&db, &AppType::Claude, &provider)
.expect("build effective settings");
assert_eq!(
effective["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"],
json!("372000")
);
assert_eq!(
effective["env"]["CLAUDE_CODE_AUTO_COMPACT_WINDOW"],
json!("372000")
);
}
#[test]
fn codex_oauth_context_defaults_preserve_user_overrides() {
let db = Database::memory().expect("create memory db");
let mut provider = Provider::with_id(
"codex-oauth".to_string(),
"Codex".to_string(),
json!({
"env": {
"CLAUDE_CODE_MAX_CONTEXT_TOKENS": "500000",
"CLAUDE_CODE_AUTO_COMPACT_WINDOW": "350000"
}
}),
None,
);
provider.meta = Some(crate::provider::ProviderMeta {
provider_type: Some("codex_oauth".to_string()),
..Default::default()
});
let effective =
build_effective_settings_with_common_config(&db, &AppType::Claude, &provider)
.expect("build effective settings");
assert_eq!(
effective["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"],
json!("500000")
);
assert_eq!(
effective["env"]["CLAUDE_CODE_AUTO_COMPACT_WINDOW"],
json!("350000")
);
}
#[test]
fn codex_oauth_context_defaults_ignore_legacy_common_config_values() {
let db = Database::memory().expect("create memory db");
db.set_config_snippet(
AppType::Claude.as_str(),
Some(
json!({
"env": {
"CLAUDE_CODE_MAX_CONTEXT_TOKENS": "262144",
"CLAUDE_CODE_AUTO_COMPACT_WINDOW": "262144"
}
})
.to_string(),
),
)
.expect("save legacy common config");
let mut provider = Provider::with_id(
"codex-oauth".to_string(),
"Codex".to_string(),
json!({ "env": { "ANTHROPIC_MODEL": "gpt-5.6" } }),
None,
);
provider.meta = Some(crate::provider::ProviderMeta {
provider_type: Some("codex_oauth".to_string()),
common_config_enabled: Some(true),
..Default::default()
});
let effective =
build_effective_settings_with_common_config(&db, &AppType::Claude, &provider)
.expect("build effective settings");
assert_eq!(
effective["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"],
json!("372000")
);
assert_eq!(
effective["env"]["CLAUDE_CODE_AUTO_COMPACT_WINDOW"],
json!("372000")
);
}
#[test]
fn codex_oauth_context_defaults_skip_non_gpt56_models() {
let db = Database::memory().expect("create memory db");
db.set_config_snippet(
AppType::Claude.as_str(),
Some(
json!({
"env": {
"CLAUDE_CODE_MAX_CONTEXT_TOKENS": "262144",
"CLAUDE_CODE_AUTO_COMPACT_WINDOW": "262144"
}
})
.to_string(),
),
)
.expect("save legacy common config");
let mut provider = Provider::with_id(
"codex-oauth".to_string(),
"Codex".to_string(),
json!({
"env": {
"ANTHROPIC_MODEL": "gpt-5.5",
"CLAUDE_CODE_AUTO_COMPACT_WINDOW": "300000"
}
}),
None,
);
provider.meta = Some(crate::provider::ProviderMeta {
provider_type: Some("codex_oauth".to_string()),
common_config_enabled: Some(true),
..Default::default()
});
let effective =
build_effective_settings_with_common_config(&db, &AppType::Claude, &provider)
.expect("build effective settings");
// 旧模型不注入 372K 默认值,遗留共享片段带进来的值也要剥掉
assert!(effective["env"]
.get("CLAUDE_CODE_MAX_CONTEXT_TOKENS")
.is_none());
// 用户显式写在供应商配置里的值仍然生效
assert_eq!(
effective["env"]["CLAUDE_CODE_AUTO_COMPACT_WINDOW"],
json!("300000")
);
}
/// 往返不动点:注入产物只活在 live,切走回灌后存储配置必须与注入前一致,
/// 否则程序默认值固化成"用户显式值",之后调默认值永远压不动。
#[test]
fn codex_oauth_backfill_strips_injected_context_defaults() {
let db = Database::memory().expect("create memory db");
let mut provider = Provider::with_id(
"codex-oauth".to_string(),
"Codex".to_string(),
json!({ "env": { "ANTHROPIC_MODEL": "gpt-5.6" } }),
None,
);
provider.meta = Some(crate::provider::ProviderMeta {
provider_type: Some("codex_oauth".to_string()),
..Default::default()
});
// 模拟写 live:注入了两个上下文默认值
let live = build_effective_settings_with_common_config(&db, &AppType::Claude, &provider)
.expect("build effective settings");
assert_eq!(
live["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"],
json!("372000")
);
// 模拟切走回灌:注入产物被剥掉,其余字段原样保留
let backfilled =
strip_common_config_from_live_settings(&db, &AppType::Claude, &provider, live);
assert!(backfilled["env"]
.get("CLAUDE_CODE_MAX_CONTEXT_TOKENS")
.is_none());
assert!(backfilled["env"]
.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW")
.is_none());
assert_eq!(backfilled["env"]["ANTHROPIC_MODEL"], json!("gpt-5.6"));
}
#[test]
fn codex_oauth_backfill_keeps_user_context_values() {
let db = Database::memory().expect("create memory db");
let mut provider = Provider::with_id(
"codex-oauth".to_string(),
"Codex".to_string(),
json!({
"env": {
"ANTHROPIC_MODEL": "gpt-5.6",
"CLAUDE_CODE_MAX_CONTEXT_TOKENS": "500000"
}
}),
None,
);
provider.meta = Some(crate::provider::ProviderMeta {
provider_type: Some("codex_oauth".to_string()),
..Default::default()
});
// live 里:MAX 是用户显式值;ACW 被用户手改成了非默认数字
let live = json!({
"env": {
"ANTHROPIC_MODEL": "gpt-5.6",
"CLAUDE_CODE_MAX_CONTEXT_TOKENS": "500000",
"CLAUDE_CODE_AUTO_COMPACT_WINDOW": "300000"
}
});
let backfilled =
strip_common_config_from_live_settings(&db, &AppType::Claude, &provider, live);
assert_eq!(
backfilled["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"],
json!("500000")
);
assert_eq!(
backfilled["env"]["CLAUDE_CODE_AUTO_COMPACT_WINDOW"],
json!("300000")
);
}
/// C5 回归锁:前端表单的合并/剥离必须走 toml_edit 文档模型。 /// C5 回归锁:前端表单的合并/剥离必须走 toml_edit 文档模型。
/// smol-toml 的 parse→merge→stringify 整文档重序列化会丢注释、 /// smol-toml 的 parse→merge→stringify 整文档重序列化会丢注释、
/// 按字母序重排键、并为 dotted 表生成多余的空父表头。 /// 按字母序重排键、并为 dotted 表生成多余的空父表头。
+13
View File
@@ -722,6 +722,8 @@ mod tests {
"ANTHROPIC_BASE_URL": "https://example.com", "ANTHROPIC_BASE_URL": "https://example.com",
"ANTHROPIC_MODEL": "claude-x", "ANTHROPIC_MODEL": "claude-x",
"CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini", "CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini",
"CLAUDE_CODE_MAX_CONTEXT_TOKENS": "400000",
"CLAUDE_CODE_AUTO_COMPACT_WINDOW": "400000",
// 可共享、非机密配置(复数 _TOKENS 不应被误剥) // 可共享、非机密配置(复数 _TOKENS 不应被误剥)
"ENABLE_TOOL_SEARCH": "true", "ENABLE_TOOL_SEARCH": "true",
"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "8192" "CLAUDE_CODE_MAX_OUTPUT_TOKENS": "8192"
@@ -767,6 +769,12 @@ mod tests {
assert!(env assert!(env
.and_then(|e| e.get("CLAUDE_CODE_SUBAGENT_MODEL")) .and_then(|e| e.get("CLAUDE_CODE_SUBAGENT_MODEL"))
.is_none()); .is_none());
assert!(env
.and_then(|e| e.get("CLAUDE_CODE_MAX_CONTEXT_TOKENS"))
.is_none());
assert!(env
.and_then(|e| e.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW"))
.is_none());
// 可共享的非机密配置必须保留(含复数 _TOKENS 不被误剥) // 可共享的非机密配置必须保留(含复数 _TOKENS 不被误剥)
assert_eq!( assert_eq!(
@@ -2935,6 +2943,11 @@ impl ProviderService {
"ANTHROPIC_DEFAULT_FABLE_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL",
"ANTHROPIC_DEFAULT_FABLE_MODEL_NAME", "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME",
"CLAUDE_CODE_SUBAGENT_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL",
// Context limits follow the actual upstream model. Sharing these
// across providers can cap GPT/Kimi to the wrong window and make
// Claude Code compact too early or miss the upstream limit.
"CLAUDE_CODE_MAX_CONTEXT_TOKENS",
"CLAUDE_CODE_AUTO_COMPACT_WINDOW",
"ANTHROPIC_BASE_URL", "ANTHROPIC_BASE_URL",
]; ];
+1 -1
View File
@@ -394,7 +394,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
apiFormat: "openai_responses", apiFormat: "openai_responses",
providerType: "codex_oauth", providerType: "codex_oauth",
requiresOAuth: true, requiresOAuth: true,
modelRoutes: brandedRoutes("gpt-5.5", "gpt-5.5", "gpt-5.4-mini"), modelRoutes: brandedRoutes("gpt-5.6", "gpt-5.6", "gpt-5.6-luna"),
icon: "openai", icon: "openai",
iconColor: "#000000", iconColor: "#000000",
}, },
+26 -4
View File
@@ -1227,10 +1227,32 @@ export const providerPresets: ProviderPreset[] = [
// base_url 由代理后端强制重写为 chatgpt.com/backend-api/codex // base_url 由代理后端强制重写为 chatgpt.com/backend-api/codex
// 用户无需配置 // 用户无需配置
ANTHROPIC_BASE_URL: "https://chatgpt.com/backend-api/codex", ANTHROPIC_BASE_URL: "https://chatgpt.com/backend-api/codex",
ANTHROPIC_MODEL: "gpt-5.5", ANTHROPIC_MODEL: "gpt-5.6",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "gpt-5.4-mini", ANTHROPIC_DEFAULT_HAIKU_MODEL: "gpt-5.6-luna",
ANTHROPIC_DEFAULT_SONNET_MODEL: "gpt-5.5", ANTHROPIC_DEFAULT_SONNET_MODEL: "gpt-5.6",
ANTHROPIC_DEFAULT_OPUS_MODEL: "gpt-5.5", ANTHROPIC_DEFAULT_OPUS_MODEL: "gpt-5.6",
// Claude Code falls back to a 200K context window for unrecognized
// non-Claude model ids. The ChatGPT Codex backend catalogs gpt-5.6
// at a 372K window with a ~353K effective budget (openai/codex#31860),
// not the 1.05M API window. Declare 372K for both knobs: Claude Code's
// built-in output reserve and compact buffer already keep the compact
// trigger below the effective budget.
CLAUDE_CODE_MAX_CONTEXT_TOKENS: "${CLAUDE_CODE_MAX_CONTEXT_TOKENS}",
CLAUDE_CODE_AUTO_COMPACT_WINDOW: "${CLAUDE_CODE_AUTO_COMPACT_WINDOW}",
},
},
templateValues: {
CLAUDE_CODE_MAX_CONTEXT_TOKENS: {
label: "Max Context Tokens",
placeholder: "372000",
defaultValue: "372000",
editorValue: "372000",
},
CLAUDE_CODE_AUTO_COMPACT_WINDOW: {
label: "Auto Compact Window",
placeholder: "372000",
defaultValue: "372000",
editorValue: "372000",
}, },
}, },
category: "third_party", category: "third_party",
+1 -1
View File
@@ -14,7 +14,7 @@ export interface CodexTemplate {
*/ */
export function getCodexCustomTemplate(): CodexTemplate { export function getCodexCustomTemplate(): CodexTemplate {
const config = `model_provider = "custom" const config = `model_provider = "custom"
model = "gpt-5.5" model = "gpt-5.6"
model_reasoning_effort = "high" model_reasoning_effort = "high"
disable_response_storage = true disable_response_storage = true
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { providerPresets } from "@/config/claudeProviderPresets"; import { providerPresets } from "@/config/claudeProviderPresets";
import { applyTemplateValues } from "@/utils/providerConfigUtils";
describe("Kimi For Coding Provider Preset", () => { describe("Kimi For Coding Provider Preset", () => {
const kimiForCoding = providerPresets.find( const kimiForCoding = providerPresets.find(
@@ -28,6 +29,47 @@ describe("Kimi For Coding Provider Preset", () => {
}); });
}); });
describe("Codex Provider Preset", () => {
const codex = providerPresets.find((p) => p.name === "Codex");
it("should include the Codex preset", () => {
expect(codex).toBeDefined();
});
it("should override Claude Code's 200K fallback for GPT models", () => {
const env = (codex!.settingsConfig as any).env;
expect(env).toHaveProperty(
"CLAUDE_CODE_MAX_CONTEXT_TOKENS",
"${CLAUDE_CODE_MAX_CONTEXT_TOKENS}",
);
expect(env).toHaveProperty(
"CLAUDE_CODE_AUTO_COMPACT_WINDOW",
"${CLAUDE_CODE_AUTO_COMPACT_WINDOW}",
);
});
it("should expose the Codex-catalog 372K window for both context knobs", () => {
const values = codex!.templateValues as any;
expect(values?.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toMatchObject({
defaultValue: "372000",
editorValue: "372000",
});
expect(values?.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toMatchObject({
defaultValue: "372000",
editorValue: "372000",
});
});
it("should resolve both context placeholders into Claude Code env values", () => {
const config = applyTemplateValues(
codex!.settingsConfig,
codex!.templateValues,
) as any;
expect(config.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe("372000");
expect(config.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe("372000");
});
});
describe("AWS Bedrock Provider Presets", () => { describe("AWS Bedrock Provider Presets", () => {
const bedrockAksk = providerPresets.find( const bedrockAksk = providerPresets.find(
(p) => p.name === "AWS Bedrock (AKSK)", (p) => p.name === "AWS Bedrock (AKSK)",