diff --git a/README.md b/README.md index 53214da014..bad2083c5a 100644 --- a/README.md +++ b/README.md @@ -580,6 +580,16 @@ NEURALWATT_API_KEY= +
+OrcaRouter + +```bash +# .env +ORCAROUTER_API_KEY= +``` + +
+
IO Intelligence diff --git a/crates/forge_app/src/dto/openai/fixtures/orcarouter_api_response.json b/crates/forge_app/src/dto/openai/fixtures/orcarouter_api_response.json new file mode 100644 index 0000000000..99b21a76d6 --- /dev/null +++ b/crates/forge_app/src/dto/openai/fixtures/orcarouter_api_response.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "id": "openai/gpt-4o-mini", + "object": "model", + "created": 1626777600, + "owned_by": "OpenAI", + "supported_endpoint_types": ["openai", "openai-response"], + "name": "OpenAI: GPT-4o-mini", + "description": "GPT-4o mini is OpenAI's most advanced small model, supporting both text and image inputs with text outputs.", + "context_length": 128000, + "max_completion_tokens": 16384, + "architecture": { + "input_modalities": ["text", "image", "file"], + "output_modalities": ["text"] + }, + "top_provider": { + "context_length": 128000, + "max_completion_tokens": 16384 + }, + "pricing": { + "prompt": "0.0000001500", + "completion": "0.0000006000", + "prompt_per_million": "0.150000", + "completion_per_million": "0.600000" + } + } + ], + "object": "list", + "success": true +} diff --git a/crates/forge_app/src/dto/openai/model.rs b/crates/forge_app/src/dto/openai/model.rs index f0e33ab6fc..78d2001a8c 100644 --- a/crates/forge_app/src/dto/openai/model.rs +++ b/crates/forge_app/src/dto/openai/model.rs @@ -44,8 +44,11 @@ pub struct Model { #[derive(Debug, Deserialize, Serialize, Clone)] pub struct Architecture { - pub modality: String, - pub tokenizer: String, + // Some OpenAI-compatible providers (e.g. OrcaRouter) return an + // `architecture` object carrying only the modality arrays, so every + // field must tolerate being absent. + pub modality: Option, + pub tokenizer: Option, pub instruct_type: Option, pub input_modalities: Option>, pub output_modalities: Option>, @@ -82,6 +85,7 @@ where pub struct TopProvider { pub context_length: Option, pub max_completion_tokens: Option, + #[serde(default)] pub is_moderated: bool, } @@ -241,6 +245,42 @@ mod tests { assert_eq!(pricing.request, None); } + #[tokio::test] + async fn test_orcarouter_api_response_format() { + // OrcaRouter returns an `architecture` object with only the modality + // arrays (no `modality`/`tokenizer`) and a `top_provider` without + // `is_moderated`; all of these must deserialize. + let fixture = load_fixture("orcarouter_api_response.json").await; + + let actual = serde_json::from_value::(fixture).unwrap(); + + assert_eq!(actual.data.len(), 1); + let model = &actual.data[0]; + assert_eq!(model.id.as_str(), "openai/gpt-4o-mini"); + assert_eq!(model.name, Some("OpenAI: GPT-4o-mini".to_string())); + assert_eq!(model.context_length, Some(128000)); + + let architecture = model.architecture.as_ref().unwrap(); + assert_eq!(architecture.modality, None); + assert_eq!(architecture.tokenizer, None); + assert_eq!( + architecture.input_modalities, + Some(vec![ + "text".to_string(), + "image".to_string(), + "file".to_string() + ]) + ); + + let top_provider = model.top_provider.as_ref().unwrap(); + assert_eq!(top_provider.context_length, Some(128000)); + assert_eq!(top_provider.is_moderated, false); + + let pricing = model.pricing.as_ref().unwrap(); + assert_eq!(pricing.prompt, Some(0.00000015)); + assert_eq!(pricing.completion, Some(0.0000006)); + } + #[tokio::test] async fn test_deserialize_model_with_invalid_string_pricing() { // Test that invalid string pricing formats fail gracefully diff --git a/crates/forge_domain/src/provider.rs b/crates/forge_domain/src/provider.rs index dce7598ce7..6e3d33c7f4 100644 --- a/crates/forge_domain/src/provider.rs +++ b/crates/forge_domain/src/provider.rs @@ -83,6 +83,7 @@ impl ProviderId { pub const NVIDIA: ProviderId = ProviderId(Cow::Borrowed("nvidia")); pub const AMBIENT: ProviderId = ProviderId(Cow::Borrowed("ambient")); pub const NEURALWATT: ProviderId = ProviderId(Cow::Borrowed("neuralwatt")); + pub const ORCA_ROUTER: ProviderId = ProviderId(Cow::Borrowed("orca_router")); /// Returns all built-in provider IDs /// @@ -125,6 +126,7 @@ impl ProviderId { ProviderId::NVIDIA, ProviderId::AMBIENT, ProviderId::NEURALWATT, + ProviderId::ORCA_ROUTER, ] } @@ -161,6 +163,7 @@ impl ProviderId { "nvidia" => "NVIDIA".to_string(), "ambient" => "Ambient".to_string(), "neuralwatt" => "Neuralwatt".to_string(), + "orca_router" => "OrcaRouter".to_string(), _ => { // For other providers, use UpperCamelCase conversion use convert_case::{Case, Casing}; @@ -218,6 +221,7 @@ impl std::str::FromStr for ProviderId { "nvidia" => ProviderId::NVIDIA, "ambient" => ProviderId::AMBIENT, "neuralwatt" => ProviderId::NEURALWATT, + "orca_router" => ProviderId::ORCA_ROUTER, // For custom providers, use Cow::Owned to avoid memory leaks custom => ProviderId(Cow::Owned(custom.to_string())), }; @@ -595,6 +599,7 @@ mod tests { assert_eq!(ProviderId::GOOGLE_AI_STUDIO.to_string(), "GoogleAIStudio"); assert_eq!(ProviderId::NVIDIA.to_string(), "NVIDIA"); assert_eq!(ProviderId::AMBIENT.to_string(), "Ambient"); + assert_eq!(ProviderId::ORCA_ROUTER.to_string(), "OrcaRouter"); } #[test] @@ -636,6 +641,7 @@ mod tests { assert!(built_in.contains(&ProviderId::GOOGLE_AI_STUDIO)); assert!(built_in.contains(&ProviderId::NVIDIA)); assert!(built_in.contains(&ProviderId::AMBIENT)); + assert!(built_in.contains(&ProviderId::ORCA_ROUTER)); } #[test] @@ -735,6 +741,24 @@ mod tests { assert!(built_in.contains(&ProviderId::NEURALWATT)); } + #[test] + fn test_orca_router_from_str() { + let actual = ProviderId::from_str("orca_router").unwrap(); + let expected = ProviderId::ORCA_ROUTER; + assert_eq!(actual, expected); + } + + #[test] + fn test_orca_router_display_name() { + assert_eq!(ProviderId::ORCA_ROUTER.to_string(), "OrcaRouter"); + } + + #[test] + fn test_orca_router_in_built_in_providers() { + let built_in = ProviderId::built_in_providers(); + assert!(built_in.contains(&ProviderId::ORCA_ROUTER)); + } + #[test] fn test_io_intelligence() { let fixture = "test_key"; diff --git a/crates/forge_repo/src/provider/provider.json b/crates/forge_repo/src/provider/provider.json index 731481c383..234f1afc49 100644 --- a/crates/forge_repo/src/provider/provider.json +++ b/crates/forge_repo/src/provider/provider.json @@ -4028,5 +4028,14 @@ "url": "https://api.ambient.xyz/v1/chat/completions", "models": "https://api.ambient.xyz/v1/models", "auth_methods": ["api_key"] + }, + { + "id": "orca_router", + "api_key_vars": "ORCAROUTER_API_KEY", + "url_param_vars": [], + "response_type": "OpenAI", + "url": "https://api.orcarouter.ai/v1/chat/completions", + "models": "https://api.orcarouter.ai/v1/models", + "auth_methods": ["api_key"] } ] diff --git a/crates/forge_repo/src/provider/provider_repo.rs b/crates/forge_repo/src/provider/provider_repo.rs index 28bbb255f0..e97cef4890 100644 --- a/crates/forge_repo/src/provider/provider_repo.rs +++ b/crates/forge_repo/src/provider/provider_repo.rs @@ -934,6 +934,29 @@ mod tests { } } + #[test] + fn test_orca_router_config() { + let configs = get_provider_configs(); + let config = configs + .iter() + .find(|c| c.id == ProviderId::ORCA_ROUTER) + .unwrap(); + assert_eq!(config.id, ProviderId::ORCA_ROUTER); + assert_eq!(config.api_key_vars, Some("ORCAROUTER_API_KEY".to_string())); + assert!(config.url_param_vars.is_empty()); + assert_eq!(config.response_type, Some(ProviderResponse::OpenAI)); + assert_eq!( + config.url.as_str(), + "https://api.orcarouter.ai/v1/chat/completions" + ); + match config.models.as_ref().expect("models should be present") { + Models::Url(model_url) => { + assert_eq!(model_url, "https://api.orcarouter.ai/v1/models"); + } + other => panic!("expected URL-driven models, got {other:?}"), + } + } + #[test] fn test_provider_entry_with_static_models_converts_to_hardcoded() { let model = forge_domain::Model::new("Qwen3.6-35B-A3b-q3-mlx")