After the base URL, name, and key steps, the login now opens a model
selection screen instead of blindly saving. The listing fetch runs
concurrently with a manual-id input — a WAF challenge page or a 404 no
longer passes validation silently: a base without a /vN tail is probed
once as {base}/v1 and the working base is adopted, an anthropic-wire
listing that 401s on x-api-key retries once with Bearer (relays gate
GET /models on Bearer while /messages takes the key header), and a
failed fetch shows its reason while manual add stays usable. The chosen
ids persist as [platforms.<name>] models = [...]; catalog assembly
synthesizes selected ids the listing never served (before [model.*]
overrides so they compose) and filters unselected provider entries
(after overrides, authoritative in the provider namespace). Provider-
owned entries keep their declared wire against donor backend
inheritance, and listing errors quote line/column only — a reflected
body could carry the key.
337 lines
11 KiB
Rust
337 lines
11 KiB
Rust
//! User-declared providers: a name, a base URL, and a wire.
|
|
|
|
use crate::{ListingDialect, PlatformChatCompat, PlatformKeyHeader, PlatformWireApi};
|
|
|
|
const MAX_NAME_LEN: usize = 32;
|
|
|
|
/// Wire dialect of a custom provider.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum CustomApi {
|
|
/// Chat Completions with a Bearer key.
|
|
OpenAi,
|
|
/// Messages with an `x-api-key` header.
|
|
Anthropic,
|
|
}
|
|
|
|
impl CustomApi {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::OpenAi => "openai",
|
|
Self::Anthropic => "anthropic",
|
|
}
|
|
}
|
|
|
|
pub fn parse(s: &str) -> Option<Self> {
|
|
match s.trim().to_ascii_lowercase().as_str() {
|
|
"openai" => Some(Self::OpenAi),
|
|
"anthropic" => Some(Self::Anthropic),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Login picker wording.
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Self::OpenAi => "OpenAI compatible",
|
|
Self::Anthropic => "Anthropic compatible",
|
|
}
|
|
}
|
|
|
|
pub fn wire_api(self) -> PlatformWireApi {
|
|
match self {
|
|
Self::OpenAi => PlatformWireApi::ChatCompletions,
|
|
Self::Anthropic => PlatformWireApi::Messages,
|
|
}
|
|
}
|
|
|
|
pub fn listing(self) -> ListingDialect {
|
|
match self {
|
|
Self::OpenAi => ListingDialect::OpenAi,
|
|
Self::Anthropic => ListingDialect::Anthropic,
|
|
}
|
|
}
|
|
|
|
pub fn key_header(self) -> PlatformKeyHeader {
|
|
match self {
|
|
Self::OpenAi => PlatformKeyHeader::Bearer,
|
|
Self::Anthropic => PlatformKeyHeader::XApiKey,
|
|
}
|
|
}
|
|
|
|
pub fn chat_compat(self) -> PlatformChatCompat {
|
|
PlatformChatCompat::Passthrough
|
|
}
|
|
}
|
|
|
|
/// Why a custom provider definition was refused.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum CustomProviderError {
|
|
Name(String),
|
|
BaseUrl(String),
|
|
}
|
|
|
|
impl std::fmt::Display for CustomProviderError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Name(reason) => write!(f, "provider name: {reason}"),
|
|
Self::BaseUrl(reason) => write!(f, "provider base_url: {reason}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for CustomProviderError {}
|
|
|
|
/// A validated provider definition.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CustomProvider {
|
|
pub name: String,
|
|
pub api: CustomApi,
|
|
/// Without a trailing slash.
|
|
pub base_url: String,
|
|
}
|
|
|
|
impl CustomProvider {
|
|
pub fn new(name: &str, api: CustomApi, base_url: &str) -> Result<Self, CustomProviderError> {
|
|
validate_name(name)?;
|
|
Ok(Self {
|
|
name: name.to_owned(),
|
|
api,
|
|
base_url: normalize_base_url(base_url)?,
|
|
})
|
|
}
|
|
|
|
/// The listing endpoint, with the dialect's query.
|
|
pub fn models_url(&self) -> String {
|
|
match self.api {
|
|
CustomApi::OpenAi => format!("{}/models", self.base_url),
|
|
// Anthropic paginates (default 20); 1000 is the documented max.
|
|
CustomApi::Anthropic => format!("{}/models?limit=1000", self.base_url),
|
|
}
|
|
}
|
|
|
|
/// Catalog key `{name}/{model_id}`.
|
|
pub fn managed_model_key(&self, model_id: &str) -> String {
|
|
format!("{}/{model_id}", self.name)
|
|
}
|
|
}
|
|
|
|
/// Names become auth.json scopes and catalog key prefixes.
|
|
pub fn validate_name(name: &str) -> Result<(), CustomProviderError> {
|
|
let err = |reason: &str| Err(CustomProviderError::Name(reason.to_owned()));
|
|
if name.is_empty() || name.len() > MAX_NAME_LEN {
|
|
return err("must be 1 to 32 characters");
|
|
}
|
|
let mut chars = name.chars();
|
|
if !chars
|
|
.next()
|
|
.is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
|
{
|
|
return err("must start with a lowercase letter or digit");
|
|
}
|
|
if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') {
|
|
return err("may hold only lowercase letters, digits, '-' and '_'");
|
|
}
|
|
if crate::PlatformId::parse(name).is_some() {
|
|
return err("is a built-in platform id");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Accepts `http(s)://host[:port][/path]`; the URL is logged, so no secrets.
|
|
pub fn normalize_base_url(raw: &str) -> Result<String, CustomProviderError> {
|
|
let err = |reason: &str| Err(CustomProviderError::BaseUrl(reason.to_owned()));
|
|
let url = raw.trim().trim_end_matches('/');
|
|
if url.chars().any(|c| c.is_whitespace() || c.is_control()) {
|
|
return err("must not contain whitespace");
|
|
}
|
|
let rest = ["https://", "http://"].iter().find_map(|scheme| {
|
|
url.get(..scheme.len())
|
|
.filter(|head| head.eq_ignore_ascii_case(scheme))
|
|
.map(|_| &url[scheme.len()..])
|
|
});
|
|
let Some(rest) = rest else {
|
|
return err("must start with http:// or https://");
|
|
};
|
|
let authority = rest.split('/').next().unwrap_or("");
|
|
if authority.is_empty() {
|
|
return err("has no host");
|
|
}
|
|
if authority.contains('@') {
|
|
return err("must not carry credentials");
|
|
}
|
|
if url.contains('?') || url.contains('#') {
|
|
return err("must not carry a query or fragment");
|
|
}
|
|
Ok(url.to_owned())
|
|
}
|
|
|
|
/// A manually added model id: no whitespace, no controls, bounded length.
|
|
pub fn validate_model_id(id: &str) -> Result<(), CustomProviderError> {
|
|
let err = |reason: &str| Err(CustomProviderError::Name(reason.to_owned()));
|
|
if id.is_empty() || id.len() > 128 {
|
|
return err("model id must be 1 to 128 characters");
|
|
}
|
|
if id.chars().any(|c| c.is_whitespace() || c.is_control()) {
|
|
return err("model id must not contain whitespace");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// A valid provider name derived from a host.
|
|
pub fn default_name(base_url: &str) -> String {
|
|
let host = base_url
|
|
.split("://")
|
|
.nth(1)
|
|
.and_then(|rest| rest.split('/').next())
|
|
.and_then(|authority| authority.split(':').next())
|
|
.unwrap_or("")
|
|
.to_ascii_lowercase();
|
|
let labels: Vec<&str> = host
|
|
.split('.')
|
|
.filter(|label| !label.is_empty() && !matches!(*label, "api" | "www"))
|
|
.collect();
|
|
let joined: String = labels
|
|
.join("-")
|
|
.chars()
|
|
.filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
|
|
.take(MAX_NAME_LEN)
|
|
.collect();
|
|
let name = joined.trim_matches('-').to_owned();
|
|
if validate_name(&name).is_ok() {
|
|
name
|
|
} else {
|
|
"custom".to_owned()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn default_names_come_from_the_host() {
|
|
for (url, expected) in [
|
|
("https://api.together.xyz/v1", "together-xyz"),
|
|
("https://openrouter.ai/api/v1", "openrouter-ai"),
|
|
("http://localhost:11434/v1", "localhost"),
|
|
("http://127.0.0.1:8080/v1", "127-0-0-1"),
|
|
("https://api.openai.com/v1", "openai-com"),
|
|
(
|
|
"https://gateway.internal.example.org/anthropic",
|
|
"gateway-internal-example-org",
|
|
),
|
|
("https://API.Example.COM/v1", "example-com"),
|
|
("not a url", "custom"),
|
|
("https://api/v1", "custom"),
|
|
("https://[::1]:9000/v1", "custom"),
|
|
] {
|
|
assert_eq!(default_name(url), expected, "{url}");
|
|
assert!(validate_name(&default_name(url)).is_ok(), "{url}");
|
|
}
|
|
assert!(default_name(&format!("https://{}.example/v1", "a".repeat(60))).len() <= 32);
|
|
}
|
|
|
|
#[test]
|
|
fn api_round_trips_and_maps_to_the_wire() {
|
|
for api in [CustomApi::OpenAi, CustomApi::Anthropic] {
|
|
assert_eq!(CustomApi::parse(api.as_str()), Some(api));
|
|
}
|
|
assert_eq!(CustomApi::parse(" OpenAI "), Some(CustomApi::OpenAi));
|
|
assert_eq!(CustomApi::parse("responses"), None);
|
|
assert_eq!(
|
|
CustomApi::OpenAi.wire_api(),
|
|
PlatformWireApi::ChatCompletions
|
|
);
|
|
assert_eq!(CustomApi::Anthropic.wire_api(), PlatformWireApi::Messages);
|
|
assert_eq!(CustomApi::OpenAi.key_header(), PlatformKeyHeader::Bearer);
|
|
assert_eq!(
|
|
CustomApi::Anthropic.key_header(),
|
|
PlatformKeyHeader::XApiKey
|
|
);
|
|
assert_eq!(CustomApi::OpenAi.listing(), ListingDialect::OpenAi);
|
|
assert_eq!(CustomApi::Anthropic.listing(), ListingDialect::Anthropic);
|
|
for api in [CustomApi::OpenAi, CustomApi::Anthropic] {
|
|
assert_eq!(api.chat_compat(), PlatformChatCompat::Passthrough);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn names_follow_the_rules() {
|
|
for ok in ["proxy", "my-proxy_2", "0x", "a"] {
|
|
assert!(validate_name(ok).is_ok(), "{ok}");
|
|
}
|
|
for bad in [
|
|
"",
|
|
"Proxy",
|
|
"pRoxy",
|
|
"-x",
|
|
"a/b",
|
|
"a b",
|
|
"a:b",
|
|
"é",
|
|
&"x".repeat(33),
|
|
] {
|
|
assert!(validate_name(bad).is_err(), "{bad}");
|
|
}
|
|
for id in crate::PlatformId::ALL {
|
|
assert!(validate_name(id.as_str()).is_err(), "{}", id.as_str());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn base_urls_are_normalized_or_refused() {
|
|
assert_eq!(
|
|
normalize_base_url(" https://api.example.com/v1/ ").unwrap(),
|
|
"https://api.example.com/v1"
|
|
);
|
|
assert_eq!(
|
|
normalize_base_url("HTTP://localhost:11434/v1").unwrap(),
|
|
"HTTP://localhost:11434/v1"
|
|
);
|
|
for bad in [
|
|
"",
|
|
"example.com/v1",
|
|
"ftp://example.com",
|
|
"https://",
|
|
"https:///v1",
|
|
"https://user:pw@example.com/v1",
|
|
"https://example.com/v1?key=x",
|
|
"https://example.com/v1#frag",
|
|
"https://exa mple.com",
|
|
"https://h\u{0}x",
|
|
"ht€ps://example.com",
|
|
"€https://example.com",
|
|
"https:/€",
|
|
"http:/€",
|
|
] {
|
|
assert!(normalize_base_url(bad).is_err(), "{bad}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn listing_urls_and_keys_follow_the_dialect() {
|
|
let openai =
|
|
CustomProvider::new("proxy", CustomApi::OpenAi, "https://h.example/v1/").unwrap();
|
|
assert_eq!(openai.models_url(), "https://h.example/v1/models");
|
|
assert_eq!(openai.managed_model_key("gpt-x"), "proxy/gpt-x");
|
|
let anthropic =
|
|
CustomProvider::new("claude-gw", CustomApi::Anthropic, "https://h.example/v1").unwrap();
|
|
assert_eq!(
|
|
anthropic.models_url(),
|
|
"https://h.example/v1/models?limit=1000"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_new_reports_which_field_failed() {
|
|
assert!(matches!(
|
|
CustomProvider::new("openai", CustomApi::OpenAi, "https://h.example/v1"),
|
|
Err(CustomProviderError::Name(_))
|
|
));
|
|
assert!(matches!(
|
|
CustomProvider::new("proxy", CustomApi::OpenAi, "h.example"),
|
|
Err(CustomProviderError::BaseUrl(_))
|
|
));
|
|
}
|
|
}
|