From 29c653d0915be12cbb24a10df9e912cc4aee9fa7 Mon Sep 17 00:00:00 2001 From: XiaoHuo888 Date: Fri, 14 Aug 2026 01:48:45 +0800 Subject: [PATCH] feat: add OrcaRouter as a named provider template Adds an OrcaRouter provider preset to the provider add flows (CLI `provider add --template orcarouter` and the TUI add-provider form) for Claude Code and Codex, mirroring the existing DeepSeek named template. Claude uses OrcaRouter's native Anthropic endpoint (ANTHROPIC_BASE_URL=https://api.orcarouter.ai/v1) and Codex gets a config.toml with wire_api="responses"; a model catalog covers orcarouter/fusion, orcarouter/fusion-flash and orcarouter/fusion-mini. Co-Authored-By: Claude --- src-tauri/src/cli/commands/provider_input.rs | 191 ++++++++++++++++++ .../src/cli/tui/form/provider_templates.rs | 98 ++++++++- src-tauri/src/cli/tui/form/tests.rs | 109 ++++++++++ src-tauri/src/provider_defaults.rs | 7 + 4 files changed, 403 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/cli/commands/provider_input.rs b/src-tauri/src/cli/commands/provider_input.rs index fc4bd586..1d4f12c4 100644 --- a/src-tauri/src/cli/commands/provider_input.rs +++ b/src-tauri/src/cli/commands/provider_input.rs @@ -45,6 +45,7 @@ pub enum ProviderAddTemplate { Qiniu, Fenno, Deepseek, + Orcarouter, } impl ProviderAddTemplate { @@ -65,6 +66,7 @@ impl ProviderAddTemplate { Self::Qiniu => "qiniu", Self::Fenno => "fenno", Self::Deepseek => "deepseek", + Self::Orcarouter => "orcarouter", } } @@ -85,6 +87,7 @@ impl ProviderAddTemplate { | Self::Qiniu | Self::Fenno | Self::Deepseek + | Self::Orcarouter ) } } @@ -100,6 +103,17 @@ base_url = "https://api.deepseek.com" wire_api = "responses" requires_openai_auth = true"#; +const ORCAROUTER_CODEX_CONFIG: &str = r#"model_provider = "custom" +model = "orcarouter/fusion" +model_reasoning_effort = "high" +disable_response_storage = true + +[model_providers.custom] +name = "orcarouter" +base_url = "https://api.orcarouter.ai/v1" +wire_api = "responses" +requires_openai_auth = true"#; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ProviderAddTemplateChoice { pub template: ProviderAddTemplate, @@ -198,6 +212,10 @@ pub fn provider_add_template_choices(app_type: &AppType) -> Vec vec![ ProviderAddTemplateChoice { @@ -208,6 +226,10 @@ pub fn provider_add_template_choices(app_type: &AppType) -> Vec vec![ ProviderAddTemplateChoice { @@ -354,6 +376,7 @@ fn template_default_name(template: ProviderAddTemplate) -> Result<&'static str, ProviderAddTemplate::OpenaiOfficial => "OpenAI Official", ProviderAddTemplate::GoogleOauth => "Google OAuth", ProviderAddTemplate::Deepseek => "DeepSeek", + ProviderAddTemplate::Orcarouter => "OrcaRouter", ProviderAddTemplate::Claudeapi | ProviderAddTemplate::Packycode | ProviderAddTemplate::Runapi @@ -376,6 +399,7 @@ fn template_default_website_url(template: ProviderAddTemplate) -> Option<&'stati ProviderAddTemplate::OpenaiOfficial => Some("https://chatgpt.com/codex"), ProviderAddTemplate::GoogleOauth => Some("https://ai.google.dev"), ProviderAddTemplate::Deepseek => Some("https://platform.deepseek.com"), + ProviderAddTemplate::Orcarouter => Some("https://www.orcarouter.ai"), ProviderAddTemplate::Claudeapi | ProviderAddTemplate::Packycode | ProviderAddTemplate::Runapi @@ -395,6 +419,7 @@ fn template_default_category(template: ProviderAddTemplate) -> Option<&'static s | ProviderAddTemplate::OpenaiOfficial | ProviderAddTemplate::GoogleOauth => Some("official"), ProviderAddTemplate::Deepseek => Some("cn_official"), + ProviderAddTemplate::Orcarouter => Some("aggregator"), ProviderAddTemplate::Runapi | ProviderAddTemplate::Openmodel | ProviderAddTemplate::Qiniu @@ -441,6 +466,19 @@ fn template_default_meta( }), ..Default::default() }), + ProviderAddTemplate::Orcarouter => { + if matches!(app_type, AppType::Claude) { + // OrcaRouter exposes a native Anthropic-compatible endpoint, so + // Claude Code talks to it in its default Anthropic format + // (meta.api_format stays unset). + None + } else { + Some(ProviderMeta { + api_format: Some("openai_chat".to_string()), + ..Default::default() + }) + } + } ProviderAddTemplate::GoogleOauth => Some(ProviderMeta { partner_promotion_key: Some("google-official".to_string()), ..Default::default() @@ -471,6 +509,7 @@ fn template_default_meta( fn template_default_icon(template: ProviderAddTemplate) -> Option<&'static str> { match template { ProviderAddTemplate::Deepseek => Some("deepseek"), + ProviderAddTemplate::Orcarouter => Some("orcarouter"), ProviderAddTemplate::Runapi => Some("runapi"), ProviderAddTemplate::Qiniu => Some("qiniu"), ProviderAddTemplate::Fenno => Some("fenno"), @@ -491,6 +530,7 @@ fn template_default_icon(template: ProviderAddTemplate) -> Option<&'static str> fn template_default_icon_color(template: ProviderAddTemplate) -> Option<&'static str> { match template { ProviderAddTemplate::Deepseek => Some("#1E88E5"), + ProviderAddTemplate::Orcarouter => Some("#7C3AED"), ProviderAddTemplate::Custom | ProviderAddTemplate::ClaudeOfficial | ProviderAddTemplate::CodexOauth @@ -523,6 +563,7 @@ fn build_provider_template_settings_config( })), ProviderAddTemplate::OpenaiOfficial => build_codex_official_settings_config(None), ProviderAddTemplate::Deepseek => Ok(build_codex_deepseek_settings_config()), + ProviderAddTemplate::Orcarouter => build_orcarouter_template_settings_config(app_type), ProviderAddTemplate::GoogleOauth => Ok(json!({ "env": {} })), ProviderAddTemplate::Claudeapi | ProviderAddTemplate::Packycode @@ -560,6 +601,44 @@ fn build_codex_deepseek_settings_config() -> Value { }) } +fn build_orcarouter_template_settings_config(app_type: &AppType) -> Result { + match app_type { + AppType::Claude => Ok(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.orcarouter.ai/v1", + "ANTHROPIC_MODEL": "orcarouter/fusion", + } + })), + AppType::Codex => Ok(build_codex_orcarouter_settings_config()), + _ => Err(unsupported_template_error(ProviderAddTemplate::Orcarouter)), + } +} + +fn build_codex_orcarouter_settings_config() -> Value { + json!({ + "config": ORCAROUTER_CODEX_CONFIG, + "modelCatalog": { + "models": [ + { + "model": "orcarouter/fusion", + "displayName": "OrcaRouter Fusion", + "contextWindow": 1000000, + }, + { + "model": "orcarouter/fusion-flash", + "displayName": "OrcaRouter Fusion Flash", + "contextWindow": 200000, + }, + { + "model": "orcarouter/fusion-mini", + "displayName": "OrcaRouter Fusion Mini", + "contextWindow": 1000000, + }, + ], + }, + }) +} + fn build_sponsor_template_settings_config( app_type: &AppType, preset: SponsorProviderPreset, @@ -1161,6 +1240,7 @@ requires_openai_auth = true "Custom", "Claude Official", "Codex", + "OrcaRouter", "* AICodeMirror", "* ClaudeAPI", "* Cubence", @@ -1177,6 +1257,7 @@ requires_openai_auth = true vec![ "Custom", "OpenAI Official", + "OrcaRouter", "* AICodeMirror", "* Cubence", "* OpenModel", @@ -1826,6 +1907,116 @@ requires_openai_auth = true ); } + #[test] + fn cli_codex_orcarouter_template_matches_preset_values() { + let provider = + build_provider_template_seed(&AppType::Codex, ProviderAddTemplate::Orcarouter, &[]) + .expect("build OrcaRouter Codex provider"); + + assert_eq!(provider.id, "orcarouter"); + assert_eq!(provider.name, "OrcaRouter"); + assert_eq!( + provider.website_url.as_deref(), + Some("https://www.orcarouter.ai") + ); + assert_eq!(provider.category.as_deref(), Some("aggregator")); + assert_eq!(provider.icon.as_deref(), Some("orcarouter")); + assert_eq!(provider.icon_color.as_deref(), Some("#7C3AED")); + assert!( + provider.settings_config.get("auth").is_none(), + "OrcaRouter preset should not persist an empty API key snapshot" + ); + + let config = provider + .settings_config + .get("config") + .and_then(Value::as_str) + .expect("OrcaRouter Codex config should be TOML string"); + assert!(config.contains("model_provider = \"custom\"")); + assert!(config.contains("model = \"orcarouter/fusion\"")); + assert!(config.contains("disable_response_storage = true")); + assert!(config.contains("[model_providers.custom]")); + assert!(config.contains("name = \"orcarouter\"")); + assert!(config.contains("base_url = \"https://api.orcarouter.ai/v1\"")); + assert!(config.contains("wire_api = \"responses\"")); + assert!(config.contains("requires_openai_auth = true")); + + assert_eq!( + provider.settings_config["modelCatalog"], + json!({ + "models": [ + { + "model": "orcarouter/fusion", + "displayName": "OrcaRouter Fusion", + "contextWindow": 1000000, + }, + { + "model": "orcarouter/fusion-flash", + "displayName": "OrcaRouter Fusion Flash", + "contextWindow": 200000, + }, + { + "model": "orcarouter/fusion-mini", + "displayName": "OrcaRouter Fusion Mini", + "contextWindow": 1000000, + }, + ], + }) + ); + + let meta = provider + .meta + .expect("OrcaRouter metadata should be present"); + assert_eq!(meta.api_format.as_deref(), Some("openai_chat")); + } + + #[test] + fn cli_claude_orcarouter_template_uses_native_anthropic_format() { + let provider = + build_provider_template_seed(&AppType::Claude, ProviderAddTemplate::Orcarouter, &[]) + .expect("build OrcaRouter Claude provider"); + + assert_eq!(provider.name, "OrcaRouter"); + assert_eq!( + provider.website_url.as_deref(), + Some("https://www.orcarouter.ai") + ); + assert_eq!(provider.category.as_deref(), Some("aggregator")); + + let env = provider + .settings_config + .get("env") + .and_then(Value::as_object) + .expect("OrcaRouter Claude preset should write an env block"); + assert_eq!( + env["ANTHROPIC_BASE_URL"], + json!("https://api.orcarouter.ai/v1") + ); + assert_eq!(env["ANTHROPIC_MODEL"], json!("orcarouter/fusion")); + + // Claude talks to OrcaRouter's native Anthropic-compatible endpoint. + assert!( + provider.meta.is_none(), + "OrcaRouter Claude preset should leave meta.api_format unset" + ); + } + + #[test] + fn cli_orcarouter_template_rejected_for_unsupported_apps() { + for app_type in [ + AppType::Gemini, + AppType::OpenCode, + AppType::Hermes, + AppType::OpenClaw, + ] { + assert!( + build_provider_template_seed(&app_type, ProviderAddTemplate::Orcarouter, &[]) + .is_err(), + "OrcaRouter preset should not be available for {app_type:?}" + ); + } + } + #[test] fn cli_sponsor_templates_match_tui_partner_metadata_and_app_shapes() { let codex = diff --git a/src-tauri/src/cli/tui/form/provider_templates.rs b/src-tauri/src/cli/tui/form/provider_templates.rs index 41540054..20ec42ff 100644 --- a/src-tauri/src/cli/tui/form/provider_templates.rs +++ b/src-tauri/src/cli/tui/form/provider_templates.rs @@ -25,6 +25,17 @@ base_url = "https://api.deepseek.com" wire_api = "responses" requires_openai_auth = true"#; +const ORCAROUTER_CODEX_CONFIG: &str = r#"model_provider = "custom" +model = "orcarouter/fusion" +model_reasoning_effort = "high" +disable_response_storage = true + +[model_providers.custom] +name = "orcarouter" +base_url = "https://api.orcarouter.ai/v1" +wire_api = "responses" +requires_openai_auth = true"#; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ProviderTemplateId { Custom, @@ -33,6 +44,7 @@ enum ProviderTemplateId { OpenAiOfficial, DeepSeek, GoogleOAuth, + OrcaRouter, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -52,7 +64,7 @@ impl SponsorProviderPreset { } } -static PROVIDER_TEMPLATE_DEFS_CLAUDE: [ProviderTemplateDef; 3] = [ +static PROVIDER_TEMPLATE_DEFS_CLAUDE: [ProviderTemplateDef; 4] = [ ProviderTemplateDef { id: ProviderTemplateId::Custom, label: "Custom", @@ -65,9 +77,13 @@ static PROVIDER_TEMPLATE_DEFS_CLAUDE: [ProviderTemplateDef; 3] = [ id: ProviderTemplateId::CodexOAuth, label: "Codex", }, + ProviderTemplateDef { + id: ProviderTemplateId::OrcaRouter, + label: "OrcaRouter", + }, ]; -static PROVIDER_TEMPLATE_DEFS_CODEX: [ProviderTemplateDef; 2] = [ +static PROVIDER_TEMPLATE_DEFS_CODEX: [ProviderTemplateDef; 3] = [ ProviderTemplateDef { id: ProviderTemplateId::Custom, label: "Custom", @@ -76,6 +92,10 @@ static PROVIDER_TEMPLATE_DEFS_CODEX: [ProviderTemplateDef; 2] = [ id: ProviderTemplateId::OpenAiOfficial, label: "OpenAI Official", }, + ProviderTemplateDef { + id: ProviderTemplateId::OrcaRouter, + label: "OrcaRouter", + }, ]; static PROVIDER_TEMPLATE_DEFS_CODEX_AFTER_SPONSORS: [ProviderTemplateDef; 1] = @@ -460,6 +480,80 @@ impl ProviderAddFormState { self.codex_model_catalog_idx = 0; self.codex_model_catalog_field = CodexModelCatalogField::Model; } + ProviderTemplateId::OrcaRouter => { + self.reset_claude_template_state(); + self.extra = json!({ + "category": "aggregator", + "icon": "orcarouter", + "iconColor": "#7C3AED", + }); + self.name.set("OrcaRouter"); + self.website_url.set("https://www.orcarouter.ai"); + if matches!(self.app_type, AppType::Claude) { + self.claude_base_url.set("https://api.orcarouter.ai/v1"); + self.claude_model.set("orcarouter/fusion"); + self.claude_fallback_model_touched = true; + } else if matches!(self.app_type, AppType::Codex) { + self.extra["settingsConfig"] = json!({ + "config": ORCAROUTER_CODEX_CONFIG, + "modelCatalog": { + "models": [ + { + "model": "orcarouter/fusion", + "displayName": "OrcaRouter Fusion", + "contextWindow": 1000000, + }, + { + "model": "orcarouter/fusion-flash", + "displayName": "OrcaRouter Fusion Flash", + "contextWindow": 200000, + }, + { + "model": "orcarouter/fusion-mini", + "displayName": "OrcaRouter Fusion Mini", + "contextWindow": 1000000, + }, + ], + }, + }); + self.codex_api_key.set(""); + self.codex_base_url.set("https://api.orcarouter.ai/v1"); + self.codex_model.set("orcarouter/fusion"); + self.codex_wire_api = CodexWireApi::Responses; + self.codex_requires_openai_auth = true; + self.codex_env_key.set(""); + self.claude_api_format = ClaudeApiFormat::OpenAiChat; + self.codex_model_catalog = vec![ + CodexModelCatalogRow { + model: "orcarouter/fusion".to_string(), + display_name: "OrcaRouter Fusion".to_string(), + context_window: "1000000".to_string(), + supports_parallel_tool_calls: None, + input_modalities: Vec::new(), + base_instructions: String::new(), + }, + CodexModelCatalogRow { + model: "orcarouter/fusion-flash".to_string(), + display_name: "OrcaRouter Fusion Flash".to_string(), + context_window: "200000".to_string(), + supports_parallel_tool_calls: None, + input_modalities: Vec::new(), + base_instructions: String::new(), + }, + CodexModelCatalogRow { + model: "orcarouter/fusion-mini".to_string(), + display_name: "OrcaRouter Fusion Mini".to_string(), + context_window: "1000000".to_string(), + supports_parallel_tool_calls: None, + input_modalities: Vec::new(), + base_instructions: String::new(), + }, + ]; + self.codex_local_routing_field_idx = 0; + self.codex_model_catalog_idx = 0; + self.codex_model_catalog_field = CodexModelCatalogField::Model; + } + } ProviderTemplateId::GoogleOAuth => { self.extra = json!({ "category": "official", diff --git a/src-tauri/src/cli/tui/form/tests.rs b/src-tauri/src/cli/tui/form/tests.rs index 4907a83c..2c6bab68 100644 --- a/src-tauri/src/cli/tui/form/tests.rs +++ b/src-tauri/src/cli/tui/form/tests.rs @@ -103,6 +103,7 @@ fn provider_add_form_template_labels_follow_explicit_support_matrix() { "Custom", "Claude Official", "Codex", + "OrcaRouter", "* AICodeMirror", "* ClaudeAPI", "* Cubence", @@ -121,6 +122,7 @@ fn provider_add_form_template_labels_follow_explicit_support_matrix() { vec![ "Custom", "OpenAI Official", + "OrcaRouter", "* AICodeMirror", "* Cubence", "* OpenModel", @@ -210,11 +212,21 @@ fn cli_provider_templates_match_tui_serializer_output() { "Claude Official", ), (AppType::Claude, ProviderAddTemplate::CodexOauth, "Codex"), + ( + AppType::Claude, + ProviderAddTemplate::Orcarouter, + "OrcaRouter", + ), ( AppType::Codex, ProviderAddTemplate::OpenaiOfficial, "OpenAI Official", ), + ( + AppType::Codex, + ProviderAddTemplate::Orcarouter, + "OrcaRouter", + ), ( AppType::Gemini, ProviderAddTemplate::GoogleOauth, @@ -396,6 +408,103 @@ fn provider_add_form_codex_deepseek_template_matches_upstream_preset_values() { ); } +#[test] +fn provider_add_form_orcarouter_codex_template_matches_preset_values() { + let mut form = ProviderAddFormState::new(AppType::Codex); + let existing_ids = Vec::::new(); + + form.apply_template( + template_index_by_label(AppType::Codex, "OrcaRouter"), + &existing_ids, + ); + + assert_eq!(form.id.value, "orcarouter"); + assert_eq!(form.name.value, "OrcaRouter"); + assert_eq!(form.website_url.value, "https://www.orcarouter.ai"); + assert_eq!(form.codex_base_url.value, "https://api.orcarouter.ai/v1"); + assert_eq!(form.codex_model.value, "orcarouter/fusion"); + assert_eq!(form.codex_wire_api, CodexWireApi::Responses); + assert!(form.codex_requires_openai_auth); + + let provider = form.to_provider_json_value(); + assert_eq!(provider["category"], "aggregator"); + assert_eq!(provider["icon"], "orcarouter"); + assert_eq!(provider["iconColor"], "#7C3AED"); + assert_eq!(provider["meta"]["apiFormat"], "openai_chat"); + + let cfg = provider["settingsConfig"]["config"] + .as_str() + .expect("settingsConfig.config should be TOML string"); + assert!(cfg.contains("model = \"orcarouter/fusion\"")); + assert!(cfg.contains("disable_response_storage = true")); + assert!(cfg.contains("name = \"orcarouter\"")); + assert!(cfg.contains("base_url = \"https://api.orcarouter.ai/v1\"")); + assert!(cfg.contains("wire_api = \"responses\"")); + assert!(cfg.contains("requires_openai_auth = true")); + + assert_eq!( + provider["settingsConfig"]["modelCatalog"], + json!({ + "models": [ + { + "model": "orcarouter/fusion", + "displayName": "OrcaRouter Fusion", + "contextWindow": 1000000, + }, + { + "model": "orcarouter/fusion-flash", + "displayName": "OrcaRouter Fusion Flash", + "contextWindow": 200000, + }, + { + "model": "orcarouter/fusion-mini", + "displayName": "OrcaRouter Fusion Mini", + "contextWindow": 1000000, + }, + ], + }) + ); +} + +#[test] +fn provider_add_form_orcarouter_claude_template_uses_native_anthropic() { + let mut form = ProviderAddFormState::new(AppType::Claude); + let existing_ids = Vec::::new(); + + form.apply_template( + template_index_by_label(AppType::Claude, "OrcaRouter"), + &existing_ids, + ); + + assert_eq!(form.name.value, "OrcaRouter"); + assert_eq!(form.claude_base_url.value, "https://api.orcarouter.ai/v1"); + assert_eq!(form.claude_model.value, "orcarouter/fusion"); + assert_eq!( + form.claude_api_format, + crate::cli::tui::form::ClaudeApiFormat::Anthropic + ); + + let provider = form.to_provider_json_value(); + assert_eq!( + provider["settingsConfig"]["env"]["ANTHROPIC_BASE_URL"], + json!("https://api.orcarouter.ai/v1") + ); + assert_eq!( + provider["settingsConfig"]["env"]["ANTHROPIC_MODEL"], + json!("orcarouter/fusion") + ); + // Native Anthropic format: no apiFormat override. (The form's generic + // commonConfig meta is stripped by the same normalization the CLI↔TUI + // serializer parity test applies.) + assert!( + provider + .get("meta") + .and_then(|meta| meta.get("apiFormat")) + .is_none(), + "Claude OrcaRouter preset should not write an apiFormat meta" + ); +} + #[test] fn provider_add_form_codex_oauth_template_matches_upstream_contract() { let mut form = ProviderAddFormState::new(AppType::Claude); diff --git a/src-tauri/src/provider_defaults.rs b/src-tauri/src/provider_defaults.rs index 3fb2ad06..7f345802 100644 --- a/src-tauri/src/provider_defaults.rs +++ b/src-tauri/src/provider_defaults.rs @@ -57,6 +57,13 @@ pub static DEFAULT_PROVIDER_ICONS: Lazy> = L color: "#1E88E5", }, ); + m.insert( + "orcarouter", + ProviderIcon { + name: "orcarouter", + color: "#7C3AED", + }, + ); m.insert( "kimi", ProviderIcon {