use std::collections::BTreeMap;
use serde::Deserialize;
use bitrouter_ai::types::{ApiProtocol, ProtocolList};
use bitrouter_sdk::config::ProviderClass;
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProviderEntry {
pub id: String,
pub display_name: String,
pub api_base: String,
pub api_protocol: ProtocolMapping,
#[serde(default)]
pub protocol_endpoints: BTreeMap<String, String>,
pub auth: AuthScheme,
pub doc_url: String,
#[serde(default)]
pub class: Option<ProviderClass>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum ProtocolMapping {
Single(ProtocolList),
PerModel(BTreeMap<String, ProtocolList>),
}
impl ProtocolMapping {
pub fn resolve(&self, model_id: &str) -> Option<Vec<ApiProtocol>> {
match self {
ProtocolMapping::Single(list) => Some(list.as_slice().to_vec()),
ProtocolMapping::PerModel(map) => {
let mut best: Option<(&str, &ProtocolList)> = None;
for (pat, list) in map {
if pattern_matches(pat, model_id)
&& best.as_ref().is_none_or(|(b, _)| pat.len() > b.len())
{
best = Some((pat.as_str(), list));
}
}
best.map(|(_, l)| l.as_slice().to_vec())
}
}
}
}
fn pattern_matches(pat: &str, model_id: &str) -> bool {
if let Some(prefix) = pat.strip_suffix('*') {
model_id.starts_with(prefix)
} else {
pat == model_id
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AuthScheme {
Bearer {
env: String,
},
Header {
header: String,
env: String,
#[serde(default)]
extra_headers: BTreeMap<String, String>,
},
Oauth {
handler: String,
#[serde(default)]
params: BTreeMap<String, toml::Value>,
},
Native {
handler: String,
},
}
impl AuthScheme {
pub fn env_var(&self) -> Option<&str> {
match self {
AuthScheme::Bearer { env } | AuthScheme::Header { env, .. } => Some(env.as_str()),
AuthScheme::Oauth { .. } | AuthScheme::Native { .. } => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Context;
#[test]
fn parses_bearer() -> anyhow::Result<()> {
let src = r#"
id = "openai"
display_name = "OpenAI"
api_base = "https://api.openai.com/v1"
api_protocol = "chat_completions"
doc_url = "https://platform.openai.com/docs/api-reference"
[auth]
kind = "bearer"
env = "OPENAI_API_KEY"
"#;
let entry: ProviderEntry = toml::from_str(src).context("required fixture value")?;
assert_eq!(entry.id, "openai");
assert!(matches!(entry.auth, AuthScheme::Bearer { .. }));
assert_eq!(entry.auth.env_var(), Some("OPENAI_API_KEY"));
assert_eq!(
entry.api_protocol.resolve("gpt-4o"),
Some(vec![ApiProtocol::ChatCompletions])
);
Ok(())
}
#[test]
fn parses_header_with_extras() -> anyhow::Result<()> {
let src = r#"
id = "anthropic"
display_name = "Anthropic"
api_base = "https://api.anthropic.com/v1"
api_protocol = "messages"
doc_url = "https://docs.anthropic.com/en/api/messages"
[auth]
kind = "header"
header = "x-api-key"
env = "ANTHROPIC_API_KEY"
[auth.extra_headers]
"anthropic-version" = "2023-06-01"
"#;
let entry: ProviderEntry = toml::from_str(src).context("required fixture value")?;
match &entry.auth {
AuthScheme::Header {
header,
env,
extra_headers,
} => {
assert_eq!(header, "x-api-key");
assert_eq!(env, "ANTHROPIC_API_KEY");
assert_eq!(
extra_headers.get("anthropic-version").map(String::as_str),
Some("2023-06-01")
);
}
other => anyhow::bail!("expected Header, got {other:?}"),
}
Ok(())
}
#[test]
fn parses_per_model_protocol_map() -> anyhow::Result<()> {
let src = r#"
id = "opencode-zen"
display_name = "opencode zen"
api_base = "https://zen.opencode.example/v1"
doc_url = "https://example.com"
[api_protocol]
"claude-*" = "messages"
"*" = "chat_completions"
[auth]
kind = "bearer"
env = "OPENCODE_ZEN_API_KEY"
"#;
let entry: ProviderEntry = toml::from_str(src).context("required fixture value")?;
assert_eq!(
entry.api_protocol.resolve("claude-opus-4-1"),
Some(vec![ApiProtocol::Messages])
);
assert_eq!(
entry.api_protocol.resolve("gpt-5-mini"),
Some(vec![ApiProtocol::ChatCompletions])
);
Ok(())
}
#[test]
fn parses_multi_protocol_set_with_endpoints() -> anyhow::Result<()> {
let src = r#"
id = "minimax"
display_name = "MiniMax"
api_base = "https://api.minimax.io"
doc_url = "https://www.minimax.io/platform/document"
[api_protocol]
"*" = ["chat_completions", "responses", "messages"]
[protocol_endpoints]
messages = "https://api.minimax.io/anthropic/v1"
[auth]
kind = "bearer"
env = "MINIMAX_API_KEY"
"#;
let entry: ProviderEntry = toml::from_str(src).context("required fixture value")?;
assert_eq!(
entry.api_protocol.resolve("MiniMax-M2"),
Some(vec![
ApiProtocol::ChatCompletions,
ApiProtocol::Responses,
ApiProtocol::Messages,
])
);
assert_eq!(
entry.protocol_endpoints.get("messages").map(String::as_str),
Some("https://api.minimax.io/anthropic/v1")
);
Ok(())
}
#[test]
fn rejects_unknown_fields() -> anyhow::Result<()> {
let src = r#"
id = "x"
display_name = "X"
apt_base = "https://x.example/v1"
api_protocol = "chat_completions"
doc_url = "https://x.example"
[auth]
kind = "bearer"
env = "X_API_KEY"
"#;
assert!(toml::from_str::<ProviderEntry>(src).is_err());
Ok(())
}
}