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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 148 additions & 12 deletions src-tauri/src/codex_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1358,6 +1358,91 @@ fn sort_codex_catalog_specs_for_picker(
indexed_specs.into_iter().map(|(_, spec)| spec).collect()
}

/// 读取 Codex 官方模型缓存(models 数组)。
///
/// CC Switch 接管后会把路由目录写进 models_cache.json(etag 标记为 CC_SWITCH 拥有),
/// 官方原始档位在 backup 文件里。此处与 enrich_codex_catalog_with_official_metadata
/// 保持同一选择逻辑:缓存被 CC Switch 拥有时优先读 backup。
/// 任何读取/解析失败都返回 None(静默降级,不阻断投影)。
fn codex_official_models_cache() -> Option<Vec<Value>> {
let cache_path = get_codex_models_cache_path();
let backup_path = get_codex_models_cache_backup_path();
let existing_cache = read_json_file_if_exists(&cache_path).ok().flatten();
let official_cache = match existing_cache.as_ref() {
Some(cache) if codex_models_cache_is_cc_switch_owned(cache) => {
read_json_file_if_exists(&backup_path)
.ok()
.flatten()
.or_else(|| existing_cache.clone())
}
_ => existing_cache,
}?;
let models = official_cache.get("models").and_then(Value::as_array)?.clone();
Some(models)
}

/// 从 Codex 官方缓存为指定 slug 构造 reasoning capability。
///
/// 官方缓存字段是 snake_case,`supported_reasoning_levels` 可能是字符串数组
/// (["low","medium",...])或对象数组([{"effort":"low","description":...},...]):
/// CCSM 写入的 cache 为字符串数组,官方 backup 为对象数组,两种都兼容。
/// 官方 GPT 模型走 OpenAI 顶层 `reasoning_effort` 字段,effort_map 用 identity。
/// 任何校验失败都返回 None(保守降级为 Unknown,不产生虚假档位)。
fn official_reasoning_capability_for_model(
model: &str,
official_models: &[Value],
) -> Option<crate::proxy::providers::codex_reasoning::CodexModelReasoningCapability> {
use crate::proxy::providers::codex_reasoning::{
CodexModelReasoningCapability, CodexModelReasoningUpstream,
};
let entry = official_models.iter().find(|entry| {
entry
.get("slug")
.and_then(Value::as_str)
.is_some_and(|slug| slug.eq_ignore_ascii_case(model))
})?;
let levels: Vec<String> = entry
.get("supported_reasoning_levels")
.and_then(Value::as_array)?
.iter()
.filter_map(|level| {
level
.as_str()
.or_else(|| level.get("effort").and_then(Value::as_str))
.map(str::trim)
.map(ToString::to_string)
})
.filter(|level| !level.is_empty())
.collect();
if levels.is_empty() {
return None;
}
let default_effort = entry
.get("default_reasoning_level")
.and_then(Value::as_str)
.map(str::trim)
.filter(|level| !level.is_empty())
.map(ToString::to_string);
let capability = CodexModelReasoningCapability {
supported: true,
supported_efforts: levels.clone(),
default_effort,
disable_allowed: false,
upstream: CodexModelReasoningUpstream {
format: "string".to_string(),
parameter: "reasoning_effort".to_string(),
effort_map: levels
.into_iter()
.map(|level| (level.clone(), level))
.collect(),
},
output_format: None,
source: Some("official".to_string()),
};
capability.validate().ok()?;
Some(capability)
}

fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCatalogModelSpec> {
let Some(models) = settings
.get("modelCatalog")
Expand All @@ -1373,6 +1458,7 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCa
let cached_context_windows = codex_cached_model_context_windows();
let mut seen = std::collections::HashSet::new();
let mut specs = Vec::new();
let official_models = codex_official_models_cache().unwrap_or_default();

for model_config in models {
let Some(model) = model_config
Expand Down Expand Up @@ -1465,7 +1551,8 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCa
upstream_model.as_deref().and_then(
crate::proxy::providers::codex_reasoning::builtin_reasoning_capability_for_model,
)
});
})
.or_else(|| official_reasoning_capability_for_model(model, &official_models));

specs.push(CodexCatalogModelSpec {
model: model.to_string(),
Expand Down Expand Up @@ -6081,6 +6168,61 @@ pub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) ->
mod tests {
use super::*;

#[test]
fn official_reasoning_capability_reads_snake_case_levels() {
let official = serde_json::json!([{
"slug": "gpt-5.6-luna",
"supported_reasoning_levels": ["low", "medium", "high", "xhigh", "max"],
"default_reasoning_level": "medium"
}]);
let models = official.as_array().unwrap().clone();
let capability = official_reasoning_capability_for_model("gpt-5.6-luna", &models)
.expect("luna official capability");
assert_eq!(
capability.supported_efforts,
vec!["low", "medium", "high", "xhigh", "max"]
);
assert_eq!(capability.default_effort.as_deref(), Some("medium"));
assert_eq!(capability.source.as_deref(), Some("official"));
// 官方档位含 ultra 的模型(sol/terra)也能通过 validate
let sol = serde_json::json!([{
"slug": "gpt-5.6-sol",
"supported_reasoning_levels": ["low", "medium", "high", "xhigh", "max", "ultra"],
"default_reasoning_level": "low"
}]);
let sol_models = sol.as_array().unwrap().clone();
let sol_capability = official_reasoning_capability_for_model("gpt-5.6-sol", &sol_models)
.expect("sol official capability");
assert!(sol_capability.supported_efforts.contains(&"ultra".to_string()));
// 不匹配的 slug 返回 None
assert!(official_reasoning_capability_for_model("gpt-5.6-sol", &models).is_none());
}

#[test]
fn official_reasoning_capability_accepts_object_levels_from_backup() {
// 官方 backup 文件(models_cache.cc-switch-backup.json)里
// supported_reasoning_levels 是对象数组 {effort, description},必须兼容。
let official = serde_json::json!([{
"slug": "gpt-5.6-luna",
"supported_reasoning_levels": [
{"effort": "low", "description": "Fast"},
{"effort": "medium", "description": "Balanced"},
{"effort": "high", "description": "Deep"},
{"effort": "xhigh", "description": "Extra deep"},
{"effort": "max", "description": "Maximum"}
],
"default_reasoning_level": "medium"
}]);
let models = official.as_array().unwrap().clone();
let capability = official_reasoning_capability_for_model("gpt-5.6-luna", &models)
.expect("object levels must resolve");
assert_eq!(
capability.supported_efforts,
vec!["low", "medium", "high", "xhigh", "max"]
);
assert_eq!(capability.default_effort.as_deref(), Some("medium"));
}

fn codex_subagent_profile_status_json(
settings: &Value,
provider_context: Option<&ProviderClassificationContext>,
Expand Down Expand Up @@ -6200,7 +6342,7 @@ mod tests {
"supportKind": "effort_levels",
"source": "builtin",
"confidence": "confirmed",
"codexSelectableEfforts": ["none", "low", "medium", "high", "xhigh", "max"],
"codexSelectableEfforts": ["low", "high", "max"],
"providerAcceptedEfforts": ["low", "high", "max"],
"providerDefaultEffort": "high",
"disableAllowed": true,
Expand Down Expand Up @@ -6296,7 +6438,7 @@ mod tests {
"supportKind": "effort_levels",
"source": "builtin",
"confidence": "confirmed",
"codexSelectableEfforts": ["none", "low", "medium", "high", "xhigh", "max"],
"codexSelectableEfforts": ["low", "high", "max"],
"providerAcceptedEfforts": ["low", "high", "max"],
"providerDefaultEffort": "high",
"disableAllowed": true,
Expand Down Expand Up @@ -7054,7 +7196,7 @@ mod tests {
"supportKind": "effort_levels",
"source": "builtin",
"confidence": "confirmed",
"codexSelectableEfforts": ["none", "low", "medium", "high", "xhigh", "max"],
"codexSelectableEfforts": ["low", "high", "max"],
"providerAcceptedEfforts": ["low", "high", "max"],
"providerDefaultEffort": "high",
"disableAllowed": true,
Expand Down Expand Up @@ -10547,14 +10689,8 @@ openai_base_url = "http://127.0.0.1:15721/v1"

assert_eq!(entry["default_reasoning_level"], "high");
assert_eq!(entry["defaultReasoningEffort"], "high");
assert_eq!(
levels,
vec!["none", "low", "medium", "high", "xhigh", "max"]
);
assert_eq!(
desktop_levels,
vec!["none", "low", "medium", "high", "xhigh", "max"]
);
assert_eq!(levels, vec!["low", "high", "max"]);
assert_eq!(desktop_levels, vec!["low", "high", "max"]);
assert!(!levels.contains(&"ultra"));
}

Expand Down
68 changes: 60 additions & 8 deletions src-tauri/src/codex_subagent_profiles.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Codex V2 questionnaire persistence, validation, compilation, and safe preview projection.

use crate::proxy::providers::codex_reasoning::{
CodexReasoningEffort, ResolvedSubagentReasoningCapability,
CodexReasoningEffort, ReasoningSupportKind, ResolvedSubagentReasoningCapability,
};
use serde::ser::{SerializeMap, SerializeStruct};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
Expand Down Expand Up @@ -820,14 +820,31 @@ fn compile_reasoning_policy(
"fixed reasoning policy requires effort",
)
})?;
if !capability.codex_selectable_efforts.contains(&effort) {
return Err(validation_error(
"unsupported_reasoning_effort",
Some(profile_key),
"fixed reasoning effort is not supported by the target model",
));
match capability.support_kind {
// 能力明确:宽校验 + 保留显式档位。legacy/schema1 声明的映射档位
// (xhigh/medium 等)经 effort_map 映射后若落在模型真实档位内
// (如 high),则编译通过并**保留原档位**,由代理层运行时映射
// 到上游;映射后仍不在可选档位内才拒绝(Unsupported/BooleanOnly
// 的 selectable 集合不含该档位,同样会在此拒绝)。
ReasoningSupportKind::EffortLevels
| ReasoningSupportKind::BooleanOnly
| ReasoningSupportKind::Unsupported => {
let resolved = capability.effort_map.get(&effort).unwrap_or(&effort);
if capability.codex_selectable_efforts.contains(resolved) {
Ok(Some(effort))
} else {
return Err(validation_error(
"unsupported_reasoning_effort",
Some(profile_key),
"fixed reasoning effort is not supported by the target model",
));
}
}
// 能力未知(Unknown):无法验证档位合法性,但 Unknown ≠ Unsupported。
// 信任用户显式声明的 Fixed effort,避免 schema1 旧配置(legacy effort
// 迁移为 Fixed)在模型目录未声明能力时直接编译失败(v27/v28 回归)。
ReasoningSupportKind::Unknown => Ok(Some(effort)),
}
Ok(Some(effort))
}
ReasoningRuntimePolicy::Disabled => {
if !capability.disable_allowed
Expand Down Expand Up @@ -2650,6 +2667,41 @@ mod tests {
);
}

#[test]
fn reasoning_policy_fixed_unknown_capability_trusts_declared_effort() {
// schema1 旧配置(legacy reasoningEffort)迁移为 Fixed 后,目标模型能力
// Unknown 时不得编译失败——Unknown ≠ Unsupported,信任用户显式声明。
// v27/v28 曾因 selectable 为空集而报 unsupported_reasoning_effort,
// 导致 "Unable to inspect Codex subagent profiles" 全线失败。
let capability = ResolvedSubagentReasoningCapability {
support_kind: ReasoningSupportKind::Unknown,
source: None,
confidence: ReasoningConfidence::Unverified,
codex_selectable_efforts: vec![],
provider_accepted_efforts: vec![],
provider_default_effort: None,
disable_allowed: false,
effort_map: BTreeMap::new(),
};
assert_eq!(
compile_reasoning_policy(
&fixed_reasoning(CodexReasoningEffort::High),
&capability,
"legacy-model",
)
.expect("Unknown capability + Fixed must compile"),
Some(CodexReasoningEffort::High)
);
// EffortLevels 能力下 Ultra 仍被拒绝(能力明确时不放行)
let deepseek = deepseek_reasoning();
assert!(compile_reasoning_policy(
&fixed_reasoning(CodexReasoningEffort::Ultra),
&deepseek,
"flash",
)
.is_err());
}

#[test]
fn codex_subagent_v2_delegated_effort_is_not_selected_from_complex_strength() {
generated_for_profile(
Expand Down
27 changes: 22 additions & 5 deletions src-tauri/src/proxy/providers/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1928,12 +1928,9 @@ fn encode_codex_capability_effort_mode(
.map(|effort| effort.as_str())
.collect::<Vec<_>>()
.join(",");
let mappings = supported_efforts
let mappings = effort_map
.iter()
.map(|effort| {
let mapped = effort_map.get(effort).unwrap_or(effort);
format!("{}={}", effort.as_str(), mapped.as_str())
})
.map(|(source, target)| format!("{}={}", source.as_str(), target.as_str()))
.collect::<Vec<_>>()
.join(",");
format!("capability|{allowed}|{mappings}")
Expand Down Expand Up @@ -2794,6 +2791,26 @@ mod tests {
};
use serde_json::json;

#[test]
fn capability_effort_mode_keeps_wide_mappings_for_narrow_selectable() {
let capability = crate::proxy::providers::codex_reasoning::builtin_reasoning_capability_for_model(
"deepseek-v4-flash",
)
.expect("deepseek builtin");
let resolved = crate::proxy::providers::codex_reasoning::resolve_subagent_reasoning_capability(
Some(&capability),
);
let mode = encode_codex_capability_effort_mode(
&resolved.codex_selectable_efforts,
&resolved.effort_map,
);
// allowed 收窄为真实档位,mappings 保留 medium/xhigh 上游映射(宽映射兜底)
assert_eq!(
mode,
"capability|low,high,max|low=low,medium=high,high=high,xhigh=high,max=max"
);
}

fn create_provider(config: serde_json::Value) -> Provider {
Provider {
id: "test".to_string(),
Expand Down
Loading
Loading