feat(models): custom provider wire kinds, name and base-URL rules
This commit is contained in:
1 parent
45edd7bf33
commit
4514b66ffe
2 files changed
+275
No files matched your search
@@ -0,0 +1,274 @@
|
||||
//! 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())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub mod custom;
|
||||
pub mod enrichment;
|
||||
|
||||
// Platform registry (PRD F2)
|
||||
|
||||
Reference in new issue
Block a user