diff --git a/crates/codegen/kigi-models/src/custom.rs b/crates/codegen/kigi-models/src/custom.rs new file mode 100644 index 0000000..719fcbb --- /dev/null +++ b/crates/codegen/kigi-models/src/custom.rs @@ -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 { + 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 { + 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 { + 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(_)) + )); + } +} diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index 1cec225..941cf54 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -16,6 +16,7 @@ use std::sync::LazyLock; +pub mod custom; pub mod enrichment; // Platform registry (PRD F2)