mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
fix(codex): add multi-platform CLI discovery and static gpt-5.5 template fallback (#3382)
* fix(codex): add multi-platform CLI discovery and static gpt-5.5 template fallback for model catalog generation When cc-switch generates a Codex model catalog for third-party providers, it needs the gpt-5.5 model definition as a template. Previously it relied on either ~/.codex/models_cache.json (only exists when Codex has connected to OpenAI) or running 'codex debug models --bundled' (fails when codex is not on the Tauri app's PATH, common in macOS GUI environments). This commit adds a three-tier fallback: 1. Try 'codex' from PATH, then platform-specific common paths (/opt/homebrew/bin/codex, /usr/local/bin/codex, etc.) 2. If all CLI attempts fail, use a compile-time embedded gpt-5.5 template (extracted from codex 0.135.0 bundled models) Also adds tests for the static template validity and CLI candidates. * fix(codex): discover user node codex installs --------- Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
+300
-20
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::config::{
|
||||
@@ -393,39 +394,227 @@ fn load_codex_model_template_from_cache() -> Result<Option<Value>, AppError> {
|
||||
Ok(find_codex_model_template(&catalog))
|
||||
}
|
||||
|
||||
fn load_codex_model_template_from_bundled() -> Result<Option<Value>, AppError> {
|
||||
let output = match Command::new("codex")
|
||||
.args(["debug", "models", "--bundled"])
|
||||
.output()
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(err) => {
|
||||
log::debug!("failed to run `codex debug models --bundled`: {err}");
|
||||
return Ok(None);
|
||||
}
|
||||
/// Fixed candidates for locating the `codex` CLI when it is not on the process
|
||||
/// PATH (common in GUI apps launched outside a terminal).
|
||||
const CODEX_CLI_FIXED_CANDIDATES: &[&str] = &[
|
||||
"codex", // PATH (all platforms)
|
||||
"/opt/homebrew/bin/codex", // macOS Apple Silicon Homebrew
|
||||
"/usr/local/bin/codex", // macOS Intel Homebrew / Linux
|
||||
"/home/linuxbrew/.linuxbrew/bin/codex", // Linux Homebrew
|
||||
];
|
||||
|
||||
fn push_codex_cli_candidate(
|
||||
candidates: &mut Vec<PathBuf>,
|
||||
seen: &mut HashSet<String>,
|
||||
candidate: PathBuf,
|
||||
) {
|
||||
let key = candidate.to_string_lossy().into_owned();
|
||||
if seen.insert(key) {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_existing_codex_cli_candidate(
|
||||
candidates: &mut Vec<PathBuf>,
|
||||
seen: &mut HashSet<String>,
|
||||
candidate: PathBuf,
|
||||
) {
|
||||
if candidate.exists() {
|
||||
push_codex_cli_candidate(candidates, seen, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_codex_cli_candidates_from_version_dirs(
|
||||
candidates: &mut Vec<PathBuf>,
|
||||
seen: &mut HashSet<String>,
|
||||
versions_dir: PathBuf,
|
||||
suffix: &[&str],
|
||||
) {
|
||||
let Ok(entries) = fs::read_dir(versions_dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::debug!("`codex debug models --bundled` failed: {stderr}");
|
||||
return Ok(None);
|
||||
let mut discovered = entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| {
|
||||
let mut candidate = entry.path();
|
||||
for component in suffix {
|
||||
candidate.push(component);
|
||||
}
|
||||
candidate
|
||||
})
|
||||
.filter(|candidate| candidate.exists())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Prefer newer-looking version directories before older global installs.
|
||||
discovered.sort_by(|a, b| b.cmp(a));
|
||||
for candidate in discovered {
|
||||
push_codex_cli_candidate(candidates, seen, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_home_codex_cli_candidates(
|
||||
candidates: &mut Vec<PathBuf>,
|
||||
seen: &mut HashSet<String>,
|
||||
home: &Path,
|
||||
) {
|
||||
for relative in [
|
||||
".nvm/current/bin/codex",
|
||||
".volta/bin/codex",
|
||||
".asdf/shims/codex",
|
||||
".local/share/mise/shims/codex",
|
||||
".config/mise/shims/codex",
|
||||
".local/bin/codex",
|
||||
".npm-global/bin/codex",
|
||||
".npm-packages/bin/codex",
|
||||
".local/share/pnpm/codex",
|
||||
"Library/pnpm/codex",
|
||||
] {
|
||||
push_existing_codex_cli_candidate(candidates, seen, home.join(relative));
|
||||
}
|
||||
|
||||
let catalog: Value = serde_json::from_slice(&output.stdout).map_err(|e| {
|
||||
AppError::Message(format!(
|
||||
"Failed to parse `codex debug models --bundled` output: {e}"
|
||||
))
|
||||
})?;
|
||||
Ok(find_codex_model_template(&catalog))
|
||||
push_codex_cli_candidates_from_version_dirs(
|
||||
candidates,
|
||||
seen,
|
||||
home.join(".nvm/versions/node"),
|
||||
&["bin", "codex"],
|
||||
);
|
||||
push_codex_cli_candidates_from_version_dirs(
|
||||
candidates,
|
||||
seen,
|
||||
home.join(".local/share/fnm/node-versions"),
|
||||
&["installation", "bin", "codex"],
|
||||
);
|
||||
push_codex_cli_candidates_from_version_dirs(
|
||||
candidates,
|
||||
seen,
|
||||
home.join("Library/Application Support/fnm/node-versions"),
|
||||
&["installation", "bin", "codex"],
|
||||
);
|
||||
}
|
||||
|
||||
fn push_env_codex_cli_candidates(candidates: &mut Vec<PathBuf>, seen: &mut HashSet<String>) {
|
||||
for (env_key, suffix) in [
|
||||
("NPM_CONFIG_PREFIX", &["bin", "codex"][..]),
|
||||
("VOLTA_HOME", &["bin", "codex"][..]),
|
||||
("ASDF_DATA_DIR", &["shims", "codex"][..]),
|
||||
("MISE_DATA_DIR", &["shims", "codex"][..]),
|
||||
("PNPM_HOME", &["codex"][..]),
|
||||
] {
|
||||
let Some(prefix) = std::env::var_os(env_key) else {
|
||||
continue;
|
||||
};
|
||||
let mut candidate = PathBuf::from(prefix);
|
||||
for component in suffix {
|
||||
candidate.push(component);
|
||||
}
|
||||
push_existing_codex_cli_candidate(candidates, seen, candidate);
|
||||
}
|
||||
|
||||
if let Some(nvm_dir) = std::env::var_os("NVM_DIR") {
|
||||
push_codex_cli_candidates_from_version_dirs(
|
||||
candidates,
|
||||
seen,
|
||||
PathBuf::from(nvm_dir).join("versions/node"),
|
||||
&["bin", "codex"],
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(fnm_dir) = std::env::var_os("FNM_DIR") {
|
||||
push_codex_cli_candidates_from_version_dirs(
|
||||
candidates,
|
||||
seen,
|
||||
PathBuf::from(fnm_dir).join("node-versions"),
|
||||
&["installation", "bin", "codex"],
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(appdata) = std::env::var_os("APPDATA") {
|
||||
let npm_dir = PathBuf::from(appdata).join("npm");
|
||||
for name in ["codex.cmd", "codex.exe", "codex"] {
|
||||
push_existing_codex_cli_candidate(candidates, seen, npm_dir.join(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_cli_candidates() -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for candidate in CODEX_CLI_FIXED_CANDIDATES {
|
||||
push_codex_cli_candidate(&mut candidates, &mut seen, PathBuf::from(candidate));
|
||||
}
|
||||
|
||||
push_env_codex_cli_candidates(&mut candidates, &mut seen);
|
||||
push_home_codex_cli_candidates(&mut candidates, &mut seen, &get_home_dir());
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn load_codex_model_template_from_bundled() -> Result<Option<Value>, AppError> {
|
||||
for candidate in codex_cli_candidates() {
|
||||
let candidate_label = candidate.to_string_lossy();
|
||||
let output = match Command::new(&candidate)
|
||||
.args(["debug", "models", "--bundled"])
|
||||
.output()
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(err) => {
|
||||
log::debug!("failed to run `{candidate_label} debug models --bundled`: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::debug!("`{candidate_label} debug models --bundled` failed: {stderr}");
|
||||
continue;
|
||||
}
|
||||
|
||||
let catalog: Value = match serde_json::from_slice(&output.stdout) {
|
||||
Ok(catalog) => catalog,
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"Failed to parse `{candidate_label} debug models --bundled` output: {e}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(template) = find_codex_model_template(&catalog) {
|
||||
return Ok(Some(template));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn load_codex_model_template_static() -> Option<Value> {
|
||||
let text = include_str!("resources/gpt5_5_template.json");
|
||||
match serde_json::from_str(text) {
|
||||
Ok(template) => Some(template),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to parse bundled gpt-5.5 template: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_codex_model_catalog_template() -> Result<Value, AppError> {
|
||||
// ① models_cache.json (created by Codex when it connects to OpenAI)
|
||||
if let Some(template) = load_codex_model_template_from_cache()? {
|
||||
return Ok(template);
|
||||
}
|
||||
// ② codex CLI (PATH + platform-specific common paths)
|
||||
if let Some(template) = load_codex_model_template_from_bundled()? {
|
||||
return Ok(template);
|
||||
}
|
||||
// ③ Static fallback bundled at compile time
|
||||
if let Some(template) = load_codex_model_template_static() {
|
||||
return Ok(template);
|
||||
}
|
||||
|
||||
Err(AppError::Message(format!(
|
||||
"Codex model catalog template `{CODEX_MODEL_CATALOG_TEMPLATE_SLUG}` not found. Please start Codex once so models_cache.json is available, or ensure the `codex` CLI is on PATH."
|
||||
@@ -1702,4 +1891,95 @@ name = "any"
|
||||
"entries lacking slug are skipped; a fully-skipped catalog yields None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_cli_candidates_are_non_empty() {
|
||||
let candidates = codex_cli_candidates();
|
||||
assert!(
|
||||
candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate == Path::new("codex")),
|
||||
"codex CLI candidates must include the PATH entry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_cli_candidates_include_user_node_manager_bins() {
|
||||
let temp_home = tempfile::tempdir().expect("create temp home");
|
||||
let home = temp_home.path();
|
||||
let expected = [
|
||||
home.join(".nvm/versions/node/v22.14.0/bin/codex"),
|
||||
home.join(".volta/bin/codex"),
|
||||
home.join(".asdf/shims/codex"),
|
||||
home.join(".local/share/mise/shims/codex"),
|
||||
home.join(".local/share/fnm/node-versions/v22.14.0/installation/bin/codex"),
|
||||
];
|
||||
|
||||
for candidate in &expected {
|
||||
std::fs::create_dir_all(candidate.parent().expect("candidate parent"))
|
||||
.expect("create candidate parent");
|
||||
std::fs::write(candidate, "").expect("create candidate");
|
||||
}
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
push_home_codex_cli_candidates(&mut candidates, &mut seen, home);
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"user-level Codex CLI candidate should be discovered: {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_cli_candidates_deduplicate_entries() {
|
||||
let temp_home = tempfile::tempdir().expect("create temp home");
|
||||
let home = temp_home.path();
|
||||
let candidate = home.join(".volta/bin/codex");
|
||||
std::fs::create_dir_all(candidate.parent().expect("candidate parent"))
|
||||
.expect("create candidate parent");
|
||||
std::fs::write(&candidate, "").expect("create candidate");
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
push_existing_codex_cli_candidate(&mut candidates, &mut seen, candidate.clone());
|
||||
push_home_codex_cli_candidates(&mut candidates, &mut seen, home);
|
||||
|
||||
assert_eq!(
|
||||
candidates.iter().filter(|path| **path == candidate).count(),
|
||||
1,
|
||||
"duplicate candidates should be removed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_template_is_valid_json_with_slug() {
|
||||
let template =
|
||||
load_codex_model_template_static().expect("static template must parse as valid JSON");
|
||||
assert_eq!(
|
||||
template.get("slug").and_then(|v| v.as_str()),
|
||||
Some("gpt-5.5"),
|
||||
"static template slug must be gpt-5.5"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_template_has_required_keys() {
|
||||
let template =
|
||||
load_codex_model_template_static().expect("static template must parse as valid JSON");
|
||||
for key in &[
|
||||
"model_messages",
|
||||
"base_instructions",
|
||||
"context_window",
|
||||
"display_name",
|
||||
] {
|
||||
assert!(
|
||||
template.get(key).is_some(),
|
||||
"static template must contain key '{key}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user