Skip to content

Commit f792b7e

Browse files
authored
feat(provider): add openrouter (#63)
1 parent 1a6acb4 commit f792b7e

9 files changed

Lines changed: 115 additions & 20 deletions

File tree

src/config/entities/providers-schema.json

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,15 @@
55
"name": { "type": "string" },
66
"type": {
77
"type": "string",
8-
"enum": ["anthropic", "azure", "bedrock", "deepseek", "gemini", "openai"]
8+
"enum": [
9+
"anthropic",
10+
"azure",
11+
"bedrock",
12+
"deepseek",
13+
"gemini",
14+
"openai",
15+
"openrouter"
16+
]
917
},
1018
"config": { "type": "object" }
1119
},
@@ -37,7 +45,9 @@
3745
{
3846
"if": {
3947
"properties": {
40-
"type": { "enum": ["anthropic", "deepseek", "gemini", "openai"] }
48+
"type": {
49+
"enum": ["anthropic", "deepseek", "gemini", "openai", "openrouter"]
50+
}
4151
},
4252
"required": ["type"]
4353
},

src/config/entities/providers.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ pub enum ProviderConfig {
3434
Gemini(configs::GeminiProviderConfig),
3535
#[serde(rename = "openai")]
3636
OpenAI(configs::OpenAIProviderConfig),
37+
#[serde(rename = "openrouter")]
38+
OpenRouter(configs::OpenRouterProviderConfig),
3739
}
3840

3941
impl ProviderConfig {
@@ -45,6 +47,7 @@ impl ProviderConfig {
4547
Self::DeepSeek(_) => identifiers::DEEPSEEK,
4648
Self::Gemini(_) => identifiers::GEMINI,
4749
Self::OpenAI(_) => identifiers::OPENAI,
50+
Self::OpenRouter(_) => identifiers::OPENROUTER,
4851
}
4952
}
5053
}
@@ -131,6 +134,11 @@ mod tests {
131134
"secret_access_key": "secret"
132135
}
133136
}), true, None)]
137+
#[case::openrouter_ok(json!({
138+
"name": "openrouter-primary",
139+
"type": "openrouter",
140+
"config": { "api_key": "test_key" }
141+
}), true, None)]
134142
#[case::missing_type(json!({
135143
"name": "openai-primary",
136144
"config": { "api_key": "test_key" }

src/gateway/providers/mod.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,30 +5,34 @@ pub mod deepseek;
55
pub mod gemini;
66
pub mod macros;
77
pub mod openai;
8+
pub mod openrouter;
89

910
pub use anthropic::AnthropicDef;
1011
pub use azure::AzureDef;
1112
pub use bedrock::BedrockDef;
1213
pub use deepseek::DeepSeek;
1314
pub use gemini::GoogleDef;
1415
pub use openai::OpenAIDef;
16+
pub use openrouter::OpenRouter;
1517

1618
pub mod identifiers {
17-
use super::{anthropic, azure, bedrock, deepseek, gemini, openai};
19+
use super::{anthropic, azure, bedrock, deepseek, gemini, openai, openrouter};
1820

1921
pub const ANTHROPIC: &str = anthropic::IDENTIFIER;
2022
pub const AZURE: &str = azure::IDENTIFIER;
2123
pub const BEDROCK: &str = bedrock::IDENTIFIER;
2224
pub const DEEPSEEK: &str = deepseek::IDENTIFIER;
2325
pub const GEMINI: &str = gemini::IDENTIFIER;
2426
pub const OPENAI: &str = openai::IDENTIFIER;
27+
pub const OPENROUTER: &str = openrouter::IDENTIFIER;
2528
}
2629

2730
pub mod configs {
2831
pub use super::{
2932
anthropic::AnthropicProviderConfig, azure::AzureProviderConfig,
3033
bedrock::BedrockProviderConfig, deepseek::DeepSeekProviderConfig,
3134
gemini::GeminiProviderConfig, openai::OpenAIProviderConfig,
35+
openrouter::OpenRouterProviderConfig,
3236
};
3337
}
3438

@@ -41,7 +45,8 @@ pub fn default_provider_registry() -> Result<ProviderRegistry> {
4145
.register(BedrockDef)?
4246
.register(DeepSeek)?
4347
.register(GoogleDef)?
44-
.register(OpenAIDef)?;
48+
.register(OpenAIDef)?
49+
.register(OpenRouter)?;
4550
Ok(builder.build())
4651
}
4752

@@ -59,6 +64,7 @@ mod tests {
5964
assert_eq!(registry.get("bedrock").unwrap().name(), "bedrock");
6065
assert_eq!(registry.get("gemini").unwrap().name(), "gemini");
6166
assert_eq!(registry.get("deepseek").unwrap().name(), "deepseek");
67+
assert_eq!(registry.get("openrouter").unwrap().name(), "openrouter");
6268
assert!(registry.get("missing").is_none());
6369
}
6470
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
use serde::{Deserialize, Serialize};
2+
3+
use crate::gateway::providers::macros::provider;
4+
5+
/// Provider identifier string used to look up OpenRouter in the gateway registry.
6+
pub const IDENTIFIER: &str = "openrouter";
7+
8+
/// Configuration for an OpenRouter provider deployment.
9+
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
10+
pub struct OpenRouterProviderConfig {
11+
pub api_key: String,
12+
13+
#[serde(skip_serializing_if = "Option::is_none")]
14+
pub api_base: Option<String>,
15+
}
16+
17+
provider!(OpenRouter {
18+
display_name: "openrouter",
19+
base_url: "https://openrouter.ai/api/v1",
20+
chat_path: "/chat/completions",
21+
auth: bearer,
22+
});
23+
24+
#[cfg(test)]
25+
mod tests {
26+
use super::OpenRouter;
27+
use crate::gateway::traits::ProviderMeta;
28+
29+
#[test]
30+
fn provider_macro_expands_correctly() {
31+
let provider = OpenRouter;
32+
33+
pretty_assertions::assert_eq!(provider.name(), "openrouter");
34+
pretty_assertions::assert_eq!(provider.default_base_url(), "https://openrouter.ai/api/v1");
35+
pretty_assertions::assert_eq!(provider.chat_endpoint_path("ignored"), "/chat/completions");
36+
37+
pretty_assertions::assert_eq!(
38+
provider.build_url(provider.default_base_url(), "ignored"),
39+
"https://openrouter.ai/api/v1/chat/completions"
40+
);
41+
}
42+
}

src/proxy/provider.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ fn provider_auth_and_base_url(config: &ProviderConfig) -> Result<(ProviderAuth,
7070
ProviderAuth::ApiKey(config.api_key.clone()),
7171
parse_base_url(config.api_base.as_deref())?,
7272
),
73+
ProviderConfig::OpenRouter(config) => (
74+
ProviderAuth::ApiKey(config.api_key.clone()),
75+
parse_base_url(config.api_base.as_deref())?,
76+
),
7377
};
7478

7579
Ok((auth, base_url_override))
@@ -155,7 +159,9 @@ mod tests {
155159
use super::provider_auth_and_base_url;
156160
use crate::{
157161
config::entities::providers::ProviderConfig,
158-
gateway::providers::configs::{AzureProviderConfig, BedrockProviderConfig},
162+
gateway::providers::configs::{
163+
AzureProviderConfig, BedrockProviderConfig, OpenRouterProviderConfig,
164+
},
159165
};
160166

161167
#[test]
@@ -197,6 +203,22 @@ mod tests {
197203
);
198204
}
199205

206+
#[test]
207+
fn provider_auth_and_base_url_returns_openrouter_api_key_and_optional_base_url() {
208+
let config = ProviderConfig::OpenRouter(OpenRouterProviderConfig {
209+
api_key: "openrouter-key".into(),
210+
api_base: Some("https://openrouter.ai/api/v1".into()),
211+
});
212+
213+
let (auth, base_url_override) = provider_auth_and_base_url(&config).unwrap();
214+
215+
assert_eq!(auth.api_key_for("openrouter").unwrap(), "openrouter-key");
216+
assert_eq!(
217+
base_url_override.as_ref().map(Url::as_str),
218+
Some("https://openrouter.ai/api/v1")
219+
);
220+
}
221+
200222
#[test]
201223
fn provider_auth_and_base_url_returns_bedrock_static_credentials() {
202224
let config = ProviderConfig::Bedrock(BedrockProviderConfig {

ui/src/components/providers/provider-form.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
TooltipContent,
2525
TooltipTrigger,
2626
} from '@/components/ui/tooltip';
27+
import { PROVIDER_TYPE_VARIANTS } from '@/lib/api/types';
2728
import type { Provider, ProviderType } from '@/lib/api/types';
2829

2930
export interface ProviderFormProps {
@@ -36,14 +37,7 @@ export interface ProviderFormProps {
3637
extraActions?: React.ReactNode;
3738
}
3839

39-
const PROVIDER_TYPES: ProviderType[] = [
40-
'openai',
41-
'azure',
42-
'anthropic',
43-
'gemini',
44-
'deepseek',
45-
'bedrock',
46-
];
40+
const PROVIDER_TYPES = Array.from(PROVIDER_TYPE_VARIANTS);
4741

4842
function trimOptional(value: string): string | undefined {
4943
const trimmed = value.trim();

ui/src/i18n/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@
148148
"concurrency": "Concurrency",
149149
"providers": {
150150
"openai": "OpenAI",
151+
"openrouter": "OpenRouter",
151152
"azure": "Azure OpenAI",
152153
"anthropic": "Anthropic",
153154
"gemini": "Gemini",
@@ -216,6 +217,7 @@
216217
"endpointHint": "Leave blank to use the standard runtime endpoint for the selected region.",
217218
"types": {
218219
"openai": "OpenAI",
220+
"openrouter": "OpenRouter",
219221
"azure": "Azure OpenAI",
220222
"anthropic": "Anthropic",
221223
"gemini": "Gemini",

ui/src/i18n/locales/zh-CN.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@
148148
"concurrency": "并发",
149149
"providers": {
150150
"openai": "OpenAI",
151+
"openrouter": "OpenRouter",
151152
"azure": "Azure OpenAI",
152153
"anthropic": "Anthropic",
153154
"gemini": "Gemini",
@@ -216,6 +217,7 @@
216217
"endpointHint": "留空则根据所选 Region 使用标准运行时地址。",
217218
"types": {
218219
"openai": "OpenAI",
220+
"openrouter": "OpenRouter",
219221
"azure": "Azure OpenAI",
220222
"anthropic": "Anthropic",
221223
"gemini": "Gemini",

ui/src/lib/api/types.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,17 @@ export interface Model {
3737
rate_limit?: RateLimit;
3838
}
3939

40-
export type ProviderType =
41-
| 'anthropic'
42-
| 'azure'
43-
| 'bedrock'
44-
| 'deepseek'
45-
| 'gemini'
46-
| 'openai';
40+
export const PROVIDER_TYPE_VARIANTS = [
41+
'openai',
42+
'openrouter',
43+
'azure',
44+
'anthropic',
45+
'gemini',
46+
'deepseek',
47+
'bedrock',
48+
] as const;
49+
50+
export type ProviderType = (typeof PROVIDER_TYPE_VARIANTS)[number];
4751

4852
export interface ApiBaseProviderConfig {
4953
api_key: string;
@@ -90,6 +94,11 @@ export type Provider =
9094
type: 'openai';
9195
config: ApiBaseProviderConfig;
9296
}
97+
| {
98+
name: string;
99+
type: 'openrouter';
100+
config: ApiBaseProviderConfig;
101+
}
93102
| {
94103
name: string;
95104
type: 'bedrock';

0 commit comments

Comments
 (0)