Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,8 @@ Manage API configurations for **Claude Code**, **Codex**, **Gemini**, **OpenCode
Pi provider management follows Pi's native additive model: membership comes from `models.json.providers`. CC-Switch does not modify Pi login credentials or its global default provider/model.
The Pi TUI keeps the same table/form/shortcut conventions as the other apps and exposes Presets, System Prompts, and Prompt Templates as separate pages.

The Codex provider form includes **Review Model**. Enter a model ID to override `review_model` for that provider when switching or launching, taking precedence over common config. Leave it empty to preserve existing configuration behavior. The selection is stored as `meta.codexReviewModel` and does not require model mapping. Use a model supported by the provider and restart Codex after changing it.

**Features:** One-click switching, standalone Claude settings export, multi-endpoint support, API key management, remote model discovery, and per-app diagnostics such as speed testing or stream health checks where supported.

```bash
Expand Down
2 changes: 2 additions & 0 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,8 @@ copy target\release\cc-switch.exe C:\Windows\System32\
Pi 供应商遵循原生的增量管理模型:是否启用完全取决于 `models.json.providers` 中的成员关系。CC-Switch 不会修改 Pi 的登录凭据或全局默认供应商/模型。
Pi TUI 延续其他应用的表格、表单与快捷键交互,并将预设、系统提示词和 Prompt Templates 分为独立页面。

Codex 供应商表单提供「审查模型」:填写模型 ID 后,该供应商切换或启动时会使用独立的 `review_model`,优先于通用配置;留空保留原有配置行为。此设置以 `meta.codexReviewModel` 保存在供应商中,不依赖模型映射。模型必须受该供应商支持,修改后请重新启动 Codex。

**功能:** 一键切换、Claude 独立 settings 导出、多端点支持、API 密钥管理、远端模型发现,以及按应用提供的速度测试、流式健康检查等诊断能力。

```bash
Expand Down
15 changes: 15 additions & 0 deletions src-tauri/src/cli/codex_shared_launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ fn shared_config(provider: &Provider, sqlite_home: &Path) -> Result<String, AppE
.get("config")
.and_then(|v| v.as_str())
.unwrap_or("");
let text = crate::codex_config::apply_codex_review_model(text, provider.codex_review_model())?;
let mut doc = text
.parse::<DocumentMut>()
.map_err(|err| AppError::Config(err.to_string()))?;
Expand Down Expand Up @@ -329,6 +330,20 @@ mod tests {
)
}

#[test]
fn codex_review_model_is_projected_for_launch_without_mutating_template() {
let original = "model = 'main'\nreview_model = 'legacy'\n";
let mut provider = provider("review", original);
provider.meta = Some(crate::provider::ProviderMeta {
codex_review_model: Some("vendor-review".into()),
..Default::default()
});
let config = shared_config(&provider, Path::new("/shared/sqlite")).unwrap();
let doc = config.parse::<toml_edit::DocumentMut>().unwrap();
assert_eq!(doc["review_model"].as_str(), Some("vendor-review"));
assert_eq!(provider.settings_config["config"], original);
}

#[test]
fn shared_config_preserves_selected_endpoint_and_original_settings() {
let original = "model_provider = 'relay'\nmodel = 'demo'\n[model_providers.relay]\nname = 'Relay'\nbase_url = 'https://relay.invalid/v1'\nexperimental_bearer_token = 'private-token'\nwire_api = 'responses'\n";
Expand Down
33 changes: 30 additions & 3 deletions src-tauri/src/cli/codex_temp_launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ where

let config_path = codex_home.join("config.toml");
write_secret_file(&config_path, launch_settings.config_text.as_bytes())?;
if provider.codex_review_model().is_some() {
write_secret_file(
&codex_home.join(crate::codex_config::CODEX_REVIEW_MODEL_MARKER),
b"1",
)?;
}

if let Some(auth) = launch_settings.auth {
let auth_path = codex_home.join("auth.json");
Expand All @@ -198,12 +204,12 @@ where
}
}

struct CodexLaunchSettings<'a> {
config_text: &'a str,
struct CodexLaunchSettings {
config_text: String,
auth: Option<Value>,
}

fn parse_launch_settings(provider: &Provider) -> Result<CodexLaunchSettings<'_>, AppError> {
fn parse_launch_settings(provider: &Provider) -> Result<CodexLaunchSettings, AppError> {
let settings = provider.settings_config.as_object().ok_or_else(|| {
AppError::localized(
"codex.temp_launch_settings_not_object",
Expand Down Expand Up @@ -240,6 +246,8 @@ fn parse_launch_settings(provider: &Provider) -> Result<CodexLaunchSettings<'_>,
}
};

let config_text =
crate::codex_config::apply_codex_review_model(config_text, provider.codex_review_model())?;
Ok(CodexLaunchSettings { config_text, auth })
}

Expand Down Expand Up @@ -382,6 +390,25 @@ mod tests {
}

#[cfg(unix)]
#[test]
fn codex_review_model_is_projected_for_launch_without_mutating_template() {
let original = "model = 'main'\nreview_model = 'legacy'\n";
let mut provider = provider_with(original, Some(serde_json::json!({})));
provider.meta = Some(crate::provider::ProviderMeta {
codex_review_model: Some("vendor-review".into()),
..Default::default()
});
let temp = TempDir::new().unwrap();
let path = write_temp_codex_home(temp.path(), &provider).unwrap();
assert!(path
.join(crate::codex_config::CODEX_REVIEW_MODEL_MARKER)
.exists());
let config = std::fs::read_to_string(path.join("config.toml")).unwrap();
let doc = config.parse::<toml_edit::DocumentMut>().unwrap();
assert_eq!(doc["review_model"].as_str(), Some("vendor-review"));
assert_eq!(provider.settings_config["config"], original);
}

#[test]
fn unix_handoff_command_exports_codex_home_and_cleans_up_temp_dir() {
let prepared = PreparedCodexLaunch {
Expand Down
8 changes: 8 additions & 0 deletions src-tauri/src/cli/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2300,6 +2300,14 @@ pub mod texts {
}
}

pub fn tui_label_codex_review_model() -> &'static str {
if is_chinese() {
"审查模型"
} else {
"Review Model"
}
}

pub fn tui_codex_reasoning_levels_header() -> &'static str {
if is_chinese() {
"档位"
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/cli/tui/form.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ pub enum ProviderAddField {
// (loaded from config, used as the serialization fallback) are kept.
#[allow(dead_code)]
CodexModel,
CodexReviewModel,
CodexAdvancedDivider,
CodexPromptCacheRouting,
CodexLocalRouting,
Expand Down Expand Up @@ -606,6 +607,7 @@ pub struct ProviderAddFormState {

pub codex_base_url: TextInput,
pub codex_model: TextInput,
pub codex_review_model: TextInput,
pub codex_wire_api: CodexWireApi,
pub codex_requires_openai_auth: bool,
pub codex_env_key: TextInput,
Expand Down
33 changes: 25 additions & 8 deletions src-tauri/src/cli/tui/form/provider_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ impl ProviderAddFormState {

pub(crate) fn effective_codex_config_text(&self) -> String {
if self.is_codex_official_provider() {
return self.effective_official_codex_config_text();
return self.preview_codex_review_model(self.effective_official_codex_config_text());
}

let fallback_model = if self.codex_model.is_blank() {
Expand All @@ -266,7 +266,12 @@ impl ProviderAddFormState {
} else {
fallback_model
};
self.effective_custom_codex_config_text(model)
self.preview_codex_review_model(self.effective_custom_codex_config_text(model))
}

fn preview_codex_review_model(&self, config: String) -> String {
crate::codex_config::apply_codex_review_model(&config, Some(&self.codex_review_model.value))
.unwrap_or(config)
}

pub(crate) fn effective_codex_config_text_with_common_config(
Expand All @@ -286,11 +291,13 @@ impl ProviderAddFormState {
)
.map_err(|err| err.to_string())?;

Ok(effective
.get("config")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string())
Ok(self.preview_codex_review_model(
effective
.get("config")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
))
}

fn codex_config_and_model_catalog_for_save(&self) -> (String, Vec<Value>) {
Expand Down Expand Up @@ -975,6 +982,7 @@ impl ProviderAddFormState {
&& !self.has_usage_script_meta()
&& !self.usage_query_touched
&& !should_write_full_url
&& self.codex_review_model.value.trim().is_empty()
&& !provider_obj.get("meta").is_some_and(Value::is_object)
{
return;
Expand Down Expand Up @@ -1103,6 +1111,10 @@ impl ProviderAddFormState {
}
}

if matches!(self.app_type, AppType::Codex) {
upsert_optional_trimmed(meta_obj, "codexReviewModel", &self.codex_review_model.value);
}

if matches!(self.app_type, AppType::Claude | AppType::Codex) {
if should_write_full_url {
meta_obj.insert("isFullUrl".to_string(), json!(true));
Expand Down Expand Up @@ -1185,7 +1197,12 @@ impl ProviderAddFormState {

self.update_usage_script_meta(meta_obj);

if meta_obj.is_empty() {
// An omitted meta means "preserve existing" to ProviderService::update.
// Keep an explicit empty object when clearing the last review override.
let clearing_review_model = matches!(self.app_type, AppType::Codex)
&& self.codex_review_model.is_blank()
&& self.extra.pointer("/meta/codexReviewModel").is_some();
if meta_obj.is_empty() && !clearing_review_model {
provider_obj.remove("meta");
}
}
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/cli/tui/form/provider_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ impl ProviderAddFormState {
codex_max_output_tokens: TextInput::new(""),
codex_base_url: TextInput::new(codex_defaults.0),
codex_model: TextInput::new(codex_defaults.1),
codex_review_model: TextInput::new(""),
codex_wire_api: codex_defaults.2,
codex_requires_openai_auth: codex_defaults.3,
codex_env_key: TextInput::new("OPENAI_API_KEY"),
Expand Down Expand Up @@ -519,6 +520,7 @@ impl ProviderAddFormState {
}
}
AppType::Codex => {
fields.push(ProviderAddField::CodexReviewModel);
if !self.is_codex_official_provider() {
fields.push(ProviderAddField::CodexBaseUrl);
fields.push(ProviderAddField::CodexApiKey);
Expand Down Expand Up @@ -727,6 +729,7 @@ impl ProviderAddFormState {
ProviderAddField::CodexBaseUrl => Some(&self.codex_base_url),
ProviderAddField::CodexMaxOutputTokens => Some(&self.codex_max_output_tokens),
ProviderAddField::CodexModel => Some(&self.codex_model),
ProviderAddField::CodexReviewModel => Some(&self.codex_review_model),
ProviderAddField::CodexEnvKey => Some(&self.codex_env_key),
ProviderAddField::CodexApiKey => Some(&self.codex_api_key),
ProviderAddField::GeminiApiKey => Some(&self.gemini_api_key),
Expand Down Expand Up @@ -793,6 +796,7 @@ impl ProviderAddFormState {
ProviderAddField::CodexBaseUrl => Some(&mut self.codex_base_url),
ProviderAddField::CodexMaxOutputTokens => Some(&mut self.codex_max_output_tokens),
ProviderAddField::CodexModel => Some(&mut self.codex_model),
ProviderAddField::CodexReviewModel => Some(&mut self.codex_review_model),
ProviderAddField::CodexEnvKey => Some(&mut self.codex_env_key),
ProviderAddField::CodexApiKey => Some(&mut self.codex_api_key),
ProviderAddField::GeminiApiKey => Some(&mut self.gemini_api_key),
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/cli/tui/form/provider_state_loading.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ fn populate_claude_form(form: &mut ProviderAddFormState, provider: &Provider) {
}

fn populate_codex_form(form: &mut ProviderAddFormState, provider: &Provider) {
form.codex_review_model
.set(provider.codex_review_model().unwrap_or(""));
if let Some(config) = provider
.settings_config
.get("config")
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/cli/tui/form/provider_templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ impl ProviderAddFormState {
self.codex_api_key.set("");
self.codex_base_url.set("");
self.codex_model.set(CODEX_DEFAULT_MODEL);
self.codex_review_model.set("");
self.codex_wire_api = CodexWireApi::Responses;
self.codex_requires_openai_auth = true;
self.codex_env_key.set("OPENAI_API_KEY");
Expand Down Expand Up @@ -561,6 +562,7 @@ impl ProviderAddFormState {
self.codex_api_key = defaults.codex_api_key;
self.codex_chat_reasoning = defaults.codex_chat_reasoning;
self.codex_prompt_cache_routing = defaults.codex_prompt_cache_routing;
self.codex_review_model = defaults.codex_review_model;
self.codex_model_catalog = defaults.codex_model_catalog;
self.codex_local_routing_enabled = defaults.codex_local_routing_enabled;
self.codex_goal_mode = defaults.codex_goal_mode;
Expand Down
71 changes: 71 additions & 0 deletions src-tauri/src/cli/tui/form/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8094,3 +8094,74 @@ fn provider_add_form_pi_preserves_raw_native_settings_and_names_a_copy() {
expected_copy
);
}

#[test]
fn codex_review_model_form_roundtrip_overrides_common_and_clears_to_legacy() {
let mut provider = Provider::with_id(
"review".into(),
"Review".into(),
json!({"auth": {}, "config": "model = \"main\"\nreview_model = \"legacy\"\n"}),
None,
);
provider.meta = Some(crate::provider::ProviderMeta {
apply_common_config: Some(true),
..Default::default()
});
let common = "review_model = \"shared\"\n";
for official in [false, true] {
provider.category = official.then(|| "official".into());
let mut form = ProviderAddFormState::from_provider_with_common_snippet(
AppType::Codex,
&provider,
common,
);
assert!(form.fields().contains(&ProviderAddField::CodexReviewModel));
assert!(form.codex_review_model.is_blank());
form.codex_review_model.set(" vendor-review ");
let saved: Provider = serde_json::from_value(form.to_provider_json_value()).unwrap();
assert_eq!(saved.codex_review_model(), Some("vendor-review"));
let mut reopened =
ProviderAddFormState::from_provider_with_common_snippet(AppType::Codex, &saved, common);
let preview: toml::Value = toml::from_str(
&reopened
.effective_codex_config_text_with_common_config(common)
.unwrap(),
)
.unwrap();
assert_eq!(preview["review_model"].as_str(), Some("vendor-review"));
reopened.codex_review_model.set("");
let cleared: Provider = serde_json::from_value(reopened.to_provider_json_value()).unwrap();
assert_eq!(cleared.codex_review_model(), None);
let preview: toml::Value = toml::from_str(
&reopened
.effective_codex_config_text_with_common_config(common)
.unwrap(),
)
.unwrap();
assert_eq!(preview["review_model"].as_str(), Some("shared"));
}
}

#[test]
fn codex_review_model_creates_metadata_for_existing_official_provider() {
let mut provider = Provider::with_id(
"official".into(),
"Official".into(),
json!({"auth": {}, "config": "model = 'main'\n"}),
None,
);
provider.category = Some("official".into());
assert!(provider.meta.is_none());
let mut form = ProviderAddFormState::from_provider(AppType::Codex, &provider);
form.codex_review_model.set("review-model");
let saved: Provider = serde_json::from_value(form.to_provider_json_value()).unwrap();
assert_eq!(saved.codex_review_model(), Some("review-model"));
let mut reopened = ProviderAddFormState::from_provider(AppType::Codex, &saved);
reopened.codex_review_model.set("");
let cleared: Provider = serde_json::from_value(reopened.to_provider_json_value()).unwrap();
assert!(
cleared.meta.is_some(),
"explicit empty meta must clear the stored override"
);
assert_eq!(cleared.codex_review_model(), None);
}
7 changes: 7 additions & 0 deletions src-tauri/src/cli/tui/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,13 @@ fn provider_field_help(app_type: AppType, field: ProviderAddField) -> HelpConten
"Provider API key. After saving, it is written using this app's config rules. The UI shows the current value in plaintext.",
),
),
ProviderAddField::CodexReviewModel => HelpContent::new(
texts::tui_label_codex_review_model(),
help_lines(
"仅此供应商的审查模型,优先于通用配置中的 review_model。留空保留原有配置行为。模型必须受此供应商支持。保存后重新启动 Codex 生效。",
"Review model for this provider, overriding review_model in common config. Leave empty to preserve the existing configuration behavior. The model must be supported by this provider. Restart Codex after saving.",
),
),
ProviderAddField::CodexModel => HelpContent::new(
texts::model_label(),
help_lines(
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/cli/tui/ui/forms/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1966,6 +1966,7 @@ pub(crate) fn provider_field_label_and_value(
texts::tui_label_codex_max_output_tokens().to_string()
}
ProviderAddField::CodexModel => texts::model_label().to_string(),
ProviderAddField::CodexReviewModel => texts::tui_label_codex_review_model().to_string(),
ProviderAddField::CodexPromptCacheRouting => {
texts::tui_label_codex_prompt_cache_routing().to_string()
}
Expand Down
Loading