fix(codex): stop aggregated sessions from compacting on the previous provider

In aggregation mode the route provider's catalog rows kept their template
comp_hash (official GPT mirrors carry "3000") while Stack member rows used
"cc-switch". Switching a session between the two made Codex run a
pre-turn compaction with the previous model; when that provider was out
of quota the compaction failed, custom providers get no retry with the
current model, and every later turn hit the same provider again.

Third-party route rows in the merged catalog now share the Stack
comp_hash, which also unsticks sessions already stuck this way. The
official route keeps its native rows: Codex retries with the current
model there and the official compaction semantics stay intact.

Closes #8485
This commit is contained in:
Jason committed 2026-10-19 18:01:47 +08:00
1 parent 273d166b2f
commit 8a0eddb0f0
2 files changed
+28 -11

No files matched your search

+26 -9
View File
@@ -2530,10 +2530,15 @@ pub(crate) enum CodexStackRoute<'a> {
},
}
/// 合并目录里 Stack 行统一的 `comp_hash`。Codex 在一个会话记下的值变了时会压缩一次;
/// 模板带来的值会随来源漂移(DeepSeek 官方目录是 "3000",从 Codex 缓存克隆的 gpt-5.5
/// 跟着缓存变),固定值才稳定。路由那家的行不改:它的值要和名单为空时的目录一致,否则
/// 加进第一家、移除最后一家都会让路由上的会话恢复时被压缩一次。
/// 合并目录里 Stack 行统一的 `comp_hash`。Codex 在一个会话记下的值变了时,会先用**上一个
/// 模型**压缩一次(codex-rs `maybe_run_previous_model_inline_compact`);模板带来的值会随
/// 来源漂移(DeepSeek 官方目录是 "3000",镜像官方的 GPT 行也是),固定值才稳定。
///
/// 第三方路由那家的行也写这个值:否则在它和 Stack 行之间切换都要先用上一家压缩,上一家
/// 不可用(额度用完)时压缩失败,而自定义供应商不会改用当前模型重试,会话从此每轮都卡在
/// 上一家(#8485)。代价是加进第一家、移除最后一家时,路由上原本带值的会话会用路由那家
/// 自己的模型压缩一次。官方路由的原生行不改:失败时 Codex 会改用当前模型重试,官方的
/// 压缩语义也要保留。
const CODEX_STACK_COMP_HASH: &str = "cc-switch";
/// Stack 名单非空时的模型目录:路由那家的行在前,各 Stack 供应商的行按名单顺序在后,
@@ -2549,7 +2554,15 @@ pub(crate) fn plan_codex_stack_catalog(
classic_subagents: bool,
) -> Result<Value, AppError> {
let mut entries = match route {
CodexStackRoute::ThirdParty(row) => codex_stack_third_party_rows(&row)?,
CodexStackRoute::ThirdParty(row) => {
let mut rows = codex_stack_third_party_rows(&row)?;
for entry in &mut rows {
if let Some(obj) = entry.as_object_mut() {
obj.insert("comp_hash".to_string(), json!(CODEX_STACK_COMP_HASH));
}
}
rows
}
CodexStackRoute::Official {
mut native,
config_text,
@@ -2634,7 +2647,7 @@ pub(crate) fn plan_codex_stack_catalog(
/// `config.toml` 里 `[features] multi_agent_v2 = true` 比目录优先,这时写了也不生效。
const CODEX_CLASSIC_SUBAGENTS: &str = "v1";
/// 一家第三方供应商在合并目录里的行(`comp_hash` 保持模板的值)。
/// 一家第三方供应商在合并目录里的行(`comp_hash` 保持模板的值,由调用方统一改写)。
pub(crate) fn codex_stack_third_party_rows(
row: &CodexCatalogRow<'_>,
) -> Result<Vec<Value>, AppError> {
@@ -5781,8 +5794,11 @@ wire_api = "responses"
assert_eq!(models, vec!["deepseek/deepseek-v4"]);
}
/// #8485:第三方路由那家的行和 Stack 行同一个 `comp_hash`,两者之间切换时 Codex 不会先
/// 用上一家压缩(上一家额度用完时压缩失败,会话每轮都卡在上一家)。名单为空时的目录
/// 仍是模板的值。
#[test]
fn the_route_rows_keep_the_comp_hash_they_have_without_stack_models() {
fn third_party_route_rows_share_the_stack_comp_hash() {
let route_settings =
json!({ "modelCatalog": { "models": [{ "model": "deepseek-v4-pro" }] } });
let route_text = "model_provider = \"deepseek\"\nmodel = \"deepseek-v4-pro\"\n\
@@ -5814,9 +5830,10 @@ wire_api = "responses"
.unwrap();
let models = stacked["models"].as_array().unwrap();
assert_eq!(models[0]["slug"], "deepseek-v4-pro");
assert_eq!(models[0]["comp_hash"], plain["models"][0]["comp_hash"]);
assert_eq!(models[1]["slug"], "ccs-ds/deepseek-v4-pro");
assert_eq!(models[1]["comp_hash"], "cc-switch");
for model in models {
assert_eq!(model["comp_hash"], "cc-switch", "{}", model["slug"]);
}
}
#[test]
+2 -2
View File
@@ -6987,8 +6987,8 @@ model_provider = "c"
assert_eq!(route["priority"], 1);
assert_eq!(route["context_window"], 200000);
assert_eq!(route["auto_compact_token_limit"], 150000);
// 路由那家的行保持模板的值(这里模板没有),加进第一家不会让路由上的会话被压缩。
assert_eq!(route["comp_hash"], Value::Null);
// 第三方路由那家的行和 Stack 行同一个值,互相切换不会先用上一家压缩(#8485)。
assert_eq!(route["comp_hash"], "cc-switch");
assert_eq!(stacked["priority"], 2);
assert_eq!(stacked["display_name"], "DeepSeek V4 Pro(DEEPSEEK)");
// DeepSeek 官方目录的 "3000" 不带过来,窗口按它自己的行算。