diff --git a/src/config/entities/providers-schema.json b/src/config/entities/providers-schema.json index 0e6170d..54ecd99 100644 --- a/src/config/entities/providers-schema.json +++ b/src/config/entities/providers-schema.json @@ -5,7 +5,15 @@ "name": { "type": "string" }, "type": { "type": "string", - "enum": ["anthropic", "azure", "bedrock", "deepseek", "gemini", "openai"] + "enum": [ + "anthropic", + "azure", + "bedrock", + "deepseek", + "gemini", + "openai", + "openrouter" + ] }, "config": { "type": "object" } }, @@ -37,7 +45,9 @@ { "if": { "properties": { - "type": { "enum": ["anthropic", "deepseek", "gemini", "openai"] } + "type": { + "enum": ["anthropic", "deepseek", "gemini", "openai", "openrouter"] + } }, "required": ["type"] }, diff --git a/src/config/entities/providers.rs b/src/config/entities/providers.rs index 8323339..28889b0 100644 --- a/src/config/entities/providers.rs +++ b/src/config/entities/providers.rs @@ -34,6 +34,8 @@ pub enum ProviderConfig { Gemini(configs::GeminiProviderConfig), #[serde(rename = "openai")] OpenAI(configs::OpenAIProviderConfig), + #[serde(rename = "openrouter")] + OpenRouter(configs::OpenRouterProviderConfig), } impl ProviderConfig { @@ -45,6 +47,7 @@ impl ProviderConfig { Self::DeepSeek(_) => identifiers::DEEPSEEK, Self::Gemini(_) => identifiers::GEMINI, Self::OpenAI(_) => identifiers::OPENAI, + Self::OpenRouter(_) => identifiers::OPENROUTER, } } } @@ -131,6 +134,11 @@ mod tests { "secret_access_key": "secret" } }), true, None)] + #[case::openrouter_ok(json!({ + "name": "openrouter-primary", + "type": "openrouter", + "config": { "api_key": "test_key" } + }), true, None)] #[case::missing_type(json!({ "name": "openai-primary", "config": { "api_key": "test_key" } diff --git a/src/gateway/providers/mod.rs b/src/gateway/providers/mod.rs index 183b060..f4da759 100644 --- a/src/gateway/providers/mod.rs +++ b/src/gateway/providers/mod.rs @@ -5,6 +5,7 @@ pub mod deepseek; pub mod gemini; pub mod macros; pub mod openai; +pub mod openrouter; pub use anthropic::AnthropicDef; pub use azure::AzureDef; @@ -12,9 +13,10 @@ pub use bedrock::BedrockDef; pub use deepseek::DeepSeek; pub use gemini::GoogleDef; pub use openai::OpenAIDef; +pub use openrouter::OpenRouter; pub mod identifiers { - use super::{anthropic, azure, bedrock, deepseek, gemini, openai}; + use super::{anthropic, azure, bedrock, deepseek, gemini, openai, openrouter}; pub const ANTHROPIC: &str = anthropic::IDENTIFIER; pub const AZURE: &str = azure::IDENTIFIER; @@ -22,6 +24,7 @@ pub mod identifiers { pub const DEEPSEEK: &str = deepseek::IDENTIFIER; pub const GEMINI: &str = gemini::IDENTIFIER; pub const OPENAI: &str = openai::IDENTIFIER; + pub const OPENROUTER: &str = openrouter::IDENTIFIER; } pub mod configs { @@ -29,6 +32,7 @@ pub mod configs { anthropic::AnthropicProviderConfig, azure::AzureProviderConfig, bedrock::BedrockProviderConfig, deepseek::DeepSeekProviderConfig, gemini::GeminiProviderConfig, openai::OpenAIProviderConfig, + openrouter::OpenRouterProviderConfig, }; } @@ -41,7 +45,8 @@ pub fn default_provider_registry() -> Result { .register(BedrockDef)? .register(DeepSeek)? .register(GoogleDef)? - .register(OpenAIDef)?; + .register(OpenAIDef)? + .register(OpenRouter)?; Ok(builder.build()) } @@ -59,6 +64,7 @@ mod tests { assert_eq!(registry.get("bedrock").unwrap().name(), "bedrock"); assert_eq!(registry.get("gemini").unwrap().name(), "gemini"); assert_eq!(registry.get("deepseek").unwrap().name(), "deepseek"); + assert_eq!(registry.get("openrouter").unwrap().name(), "openrouter"); assert!(registry.get("missing").is_none()); } } diff --git a/src/gateway/providers/openrouter.rs b/src/gateway/providers/openrouter.rs new file mode 100644 index 0000000..a6d9358 --- /dev/null +++ b/src/gateway/providers/openrouter.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; + +use crate::gateway::providers::macros::provider; + +/// Provider identifier string used to look up OpenRouter in the gateway registry. +pub const IDENTIFIER: &str = "openrouter"; + +/// Configuration for an OpenRouter provider deployment. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct OpenRouterProviderConfig { + pub api_key: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub api_base: Option, +} + +provider!(OpenRouter { + display_name: "openrouter", + base_url: "https://openrouter.ai/api/v1", + chat_path: "/chat/completions", + auth: bearer, +}); + +#[cfg(test)] +mod tests { + use super::OpenRouter; + use crate::gateway::traits::ProviderMeta; + + #[test] + fn provider_macro_expands_correctly() { + let provider = OpenRouter; + + pretty_assertions::assert_eq!(provider.name(), "openrouter"); + pretty_assertions::assert_eq!(provider.default_base_url(), "https://openrouter.ai/api/v1"); + pretty_assertions::assert_eq!(provider.chat_endpoint_path("ignored"), "/chat/completions"); + + pretty_assertions::assert_eq!( + provider.build_url(provider.default_base_url(), "ignored"), + "https://openrouter.ai/api/v1/chat/completions" + ); + } +} diff --git a/src/proxy/provider.rs b/src/proxy/provider.rs index 6528809..bdffbf0 100644 --- a/src/proxy/provider.rs +++ b/src/proxy/provider.rs @@ -70,6 +70,10 @@ fn provider_auth_and_base_url(config: &ProviderConfig) -> Result<(ProviderAuth, ProviderAuth::ApiKey(config.api_key.clone()), parse_base_url(config.api_base.as_deref())?, ), + ProviderConfig::OpenRouter(config) => ( + ProviderAuth::ApiKey(config.api_key.clone()), + parse_base_url(config.api_base.as_deref())?, + ), }; Ok((auth, base_url_override)) @@ -155,7 +159,9 @@ mod tests { use super::provider_auth_and_base_url; use crate::{ config::entities::providers::ProviderConfig, - gateway::providers::configs::{AzureProviderConfig, BedrockProviderConfig}, + gateway::providers::configs::{ + AzureProviderConfig, BedrockProviderConfig, OpenRouterProviderConfig, + }, }; #[test] @@ -197,6 +203,22 @@ mod tests { ); } + #[test] + fn provider_auth_and_base_url_returns_openrouter_api_key_and_optional_base_url() { + let config = ProviderConfig::OpenRouter(OpenRouterProviderConfig { + api_key: "openrouter-key".into(), + api_base: Some("https://openrouter.ai/api/v1".into()), + }); + + let (auth, base_url_override) = provider_auth_and_base_url(&config).unwrap(); + + assert_eq!(auth.api_key_for("openrouter").unwrap(), "openrouter-key"); + assert_eq!( + base_url_override.as_ref().map(Url::as_str), + Some("https://openrouter.ai/api/v1") + ); + } + #[test] fn provider_auth_and_base_url_returns_bedrock_static_credentials() { let config = ProviderConfig::Bedrock(BedrockProviderConfig { diff --git a/ui/src/components/providers/provider-form.tsx b/ui/src/components/providers/provider-form.tsx index 7022d40..ce03047 100644 --- a/ui/src/components/providers/provider-form.tsx +++ b/ui/src/components/providers/provider-form.tsx @@ -24,6 +24,7 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; +import { PROVIDER_TYPE_VARIANTS } from '@/lib/api/types'; import type { Provider, ProviderType } from '@/lib/api/types'; export interface ProviderFormProps { @@ -36,14 +37,7 @@ export interface ProviderFormProps { extraActions?: React.ReactNode; } -const PROVIDER_TYPES: ProviderType[] = [ - 'openai', - 'azure', - 'anthropic', - 'gemini', - 'deepseek', - 'bedrock', -]; +const PROVIDER_TYPES = Array.from(PROVIDER_TYPE_VARIANTS); function trimOptional(value: string): string | undefined { const trimmed = value.trim(); diff --git a/ui/src/i18n/locales/en.json b/ui/src/i18n/locales/en.json index 37cec9c..f7ba360 100644 --- a/ui/src/i18n/locales/en.json +++ b/ui/src/i18n/locales/en.json @@ -148,6 +148,7 @@ "concurrency": "Concurrency", "providers": { "openai": "OpenAI", + "openrouter": "OpenRouter", "azure": "Azure OpenAI", "anthropic": "Anthropic", "gemini": "Gemini", @@ -216,6 +217,7 @@ "endpointHint": "Leave blank to use the standard runtime endpoint for the selected region.", "types": { "openai": "OpenAI", + "openrouter": "OpenRouter", "azure": "Azure OpenAI", "anthropic": "Anthropic", "gemini": "Gemini", diff --git a/ui/src/i18n/locales/zh-CN.json b/ui/src/i18n/locales/zh-CN.json index 48c6fab..c6039ef 100644 --- a/ui/src/i18n/locales/zh-CN.json +++ b/ui/src/i18n/locales/zh-CN.json @@ -148,6 +148,7 @@ "concurrency": "并发", "providers": { "openai": "OpenAI", + "openrouter": "OpenRouter", "azure": "Azure OpenAI", "anthropic": "Anthropic", "gemini": "Gemini", @@ -216,6 +217,7 @@ "endpointHint": "留空则根据所选 Region 使用标准运行时地址。", "types": { "openai": "OpenAI", + "openrouter": "OpenRouter", "azure": "Azure OpenAI", "anthropic": "Anthropic", "gemini": "Gemini", diff --git a/ui/src/lib/api/types.ts b/ui/src/lib/api/types.ts index 6ef2178..66e6510 100644 --- a/ui/src/lib/api/types.ts +++ b/ui/src/lib/api/types.ts @@ -37,13 +37,17 @@ export interface Model { rate_limit?: RateLimit; } -export type ProviderType = - | 'anthropic' - | 'azure' - | 'bedrock' - | 'deepseek' - | 'gemini' - | 'openai'; +export const PROVIDER_TYPE_VARIANTS = [ + 'openai', + 'openrouter', + 'azure', + 'anthropic', + 'gemini', + 'deepseek', + 'bedrock', +] as const; + +export type ProviderType = (typeof PROVIDER_TYPE_VARIANTS)[number]; export interface ApiBaseProviderConfig { api_key: string; @@ -90,6 +94,11 @@ export type Provider = type: 'openai'; config: ApiBaseProviderConfig; } + | { + name: string; + type: 'openrouter'; + config: ApiBaseProviderConfig; + } | { name: string; type: 'bedrock';