| 1 | //! Provider descriptors over the existing built-in provider registry (#3084). |
| 2 | //! |
| 3 | //! A [`ProviderDescriptor`] is a thin, route-facing view over the static |
| 4 | //! [`provider::Provider`] trait objects already in [`crate::provider`]. It |
| 5 | //! surfaces only the transport facts route resolution needs (id, base URL, |
| 6 | //! default wire model, env vars, protocol) without duplicating the registry. |
| 7 | //! |
| 8 | //! Because a descriptor holds a `&'static dyn Provider`, it is intentionally |
| 9 | //! NOT `Serialize`/`PartialEq`-derivable. Never embed a [`ProviderDescriptor`] |
| 10 | //! inside a `Serialize` struct; serialize the resolved facts instead. |
| 11 | |
| 12 | use crate::ProviderKind; |
| 13 | use crate::provider::{self, CredentialAcquisition, Provider, WirePolicy}; |
| 14 | use serde::{Deserialize, Serialize}; |
| 15 | |
| 16 | use super::RequestProtocol; |
| 17 | use super::auth::AuthMethod; |
| 18 | use super::ids::{ProviderId, RouteId, WireModelId}; |
| 19 | |
| 20 | /// Route-facing view of a built-in provider's transport facts. |
| 21 | /// |
| 22 | /// Holds a trait object, so it is deliberately not serializable/comparable. |
| 23 | #[derive(Clone, Copy)] |
| 24 | pub struct ProviderDescriptor { |
| 25 | /// The provider kind this descriptor describes. |
| 26 | pub kind: ProviderKind, |
| 27 | /// Backing static provider metadata entry. |
| 28 | pub inner: &'static dyn Provider, |
| 29 | } |
| 30 | |
| 31 | impl ProviderDescriptor { |
| 32 | /// Build a descriptor for a known provider kind. |
| 33 | #[must_use] |
| 34 | pub fn for_kind(kind: ProviderKind) -> Self { |
| 35 | Self { |
| 36 | kind, |
| 37 | inner: provider::provider_for_kind(kind), |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | /// Canonical provider id. |
| 42 | #[must_use] |
| 43 | pub fn id(&self) -> ProviderId { |
| 44 | ProviderId::from(self.inner.id()) |
| 45 | } |
| 46 | |
| 47 | /// Flat kebab route id for this descriptor. |
| 48 | #[must_use] |
| 49 | pub fn route_id(&self) -> RouteId { |
| 50 | RouteId::from_kind(self.kind) |
| 51 | } |
| 52 | |
| 53 | /// Display-grouping family. Not a second identity: stored identity is [`Self::route_id`]. |
| 54 | #[must_use] |
| 55 | pub fn family(&self) -> &'static str { |
| 56 | family_for(self.kind) |
| 57 | } |
| 58 | |
| 59 | /// Bespoke-transport classification. OpenAI-compatible catalog rows share one kind. |
| 60 | #[must_use] |
| 61 | pub fn transport(&self) -> TransportKind { |
| 62 | TransportKind::for_kind(self.kind) |
| 63 | } |
| 64 | |
| 65 | /// Declared auth methods. OAuth is a type only; no adapter is implemented here. |
| 66 | #[must_use] |
| 67 | pub fn auth_methods(&self) -> &'static [AuthMethod] { |
| 68 | auth_methods_for(self.kind) |
| 69 | } |
| 70 | |
| 71 | /// Default base URL when no override is present. |
| 72 | #[must_use] |
| 73 | pub fn default_base_url(&self) -> &'static str { |
| 74 | self.inner.default_base_url() |
| 75 | } |
| 76 | |
| 77 | /// Default wire model id when no model is selected. |
| 78 | #[must_use] |
| 79 | pub fn default_wire_model(&self) -> WireModelId { |
| 80 | WireModelId::from(self.inner.default_model()) |
| 81 | } |
| 82 | |
| 83 | /// Environment variable candidates for this provider's API key. |
| 84 | #[must_use] |
| 85 | pub fn env_vars(&self) -> &'static [&'static str] { |
| 86 | self.inner.env_vars() |
| 87 | } |
| 88 | |
| 89 | /// Policy used to select this provider's wire protocol. |
| 90 | #[must_use] |
| 91 | pub fn wire_policy(&self) -> WirePolicy { |
| 92 | self.inner.wire_policy() |
| 93 | } |
| 94 | |
| 95 | /// Resolve the concrete protocol for an offering endpoint key. |
| 96 | #[must_use] |
| 97 | pub fn protocol_for_endpoint(&self, endpoint_key: &str) -> Option<RequestProtocol> { |
| 98 | self.wire_policy().resolve(endpoint_key) |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | impl std::fmt::Debug for ProviderDescriptor { |
| 103 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 104 | f.debug_struct("ProviderDescriptor") |
| 105 | .field("kind", &self.kind) |
| 106 | .field("id", &self.inner.id()) |
| 107 | .field("wire_policy", &self.inner.wire_policy()) |
| 108 | .finish() |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | /// A concrete endpoint's transport facts. |
| 113 | /// |
| 114 | /// Unlike [`ProviderDescriptor`], this owns plain data and is safe to embed in |
| 115 | /// serializable route output (see [`super::candidate::ResolvedEndpoint`]). |
| 116 | #[derive(Debug, Clone)] |
| 117 | pub struct EndpointDescriptor { |
| 118 | /// Stable endpoint key (e.g. `"chat"`, `"responses"`). |
| 119 | pub endpoint_key: String, |
| 120 | /// Wire protocol spoken at this endpoint. |
| 121 | pub protocol: RequestProtocol, |
| 122 | /// Default base URL for this endpoint. |
| 123 | pub default_base_url: String, |
| 124 | /// Whether streaming is supported. |
| 125 | pub streaming: bool, |
| 126 | } |
| 127 | |
| 128 | /// Bespoke-transport classification. Catalog rows that speak OpenAI Chat |
| 129 | /// Completions share [`TransportKind::ChatCompletions`]; only genuinely |
| 130 | /// different wires get their own kind. This is the direction `ProviderKind` |
| 131 | /// shrinks toward. |
| 132 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] |
| 133 | #[serde(rename_all = "kebab-case")] |
| 134 | pub enum TransportKind { |
| 135 | /// OpenAI-compatible `/v1/chat/completions`. |
| 136 | ChatCompletions, |
| 137 | /// Native Anthropic Messages (`/v1/messages`). |
| 138 | AnthropicMessages, |
| 139 | /// OpenAI Responses (`/responses`). |
| 140 | OpenAiResponses, |
| 141 | /// Closed, model-aware protocol roster (OpenCode Zen, DeepSeek dual-wire). |
| 142 | ModelAware, |
| 143 | /// ChatGPT Codex OAuth route. |
| 144 | Codex, |
| 145 | /// Retired Antigravity identity retained for legacy config inspection. |
| 146 | Antigravity, |
| 147 | /// Local runtime (Ollama / vLLM / SGLang). |
| 148 | LocalRuntime, |
| 149 | /// User-defined OpenAI-compatible endpoint. |
| 150 | Custom, |
| 151 | } |
| 152 | |
| 153 | impl TransportKind { |
| 154 | /// Map a known kind onto the ~8 bespoke transports. Everything else is a |
| 155 | /// catalog row on [`Self::ChatCompletions`]. |
| 156 | #[must_use] |
| 157 | pub fn for_kind(kind: ProviderKind) -> Self { |
| 158 | match kind { |
| 159 | ProviderKind::Anthropic |
| 160 | | ProviderKind::DeepseekAnthropic |
| 161 | | ProviderKind::MinimaxAnthropic |
| 162 | | ProviderKind::ModelstudioTokenPlanAnthropic |
| 163 | | ProviderKind::ModelstudioCodingPlanAnthropic => Self::AnthropicMessages, |
| 164 | ProviderKind::OpenaiCodex => Self::Codex, |
| 165 | ProviderKind::Antigravity => Self::Antigravity, |
| 166 | ProviderKind::Ollama | ProviderKind::Vllm | ProviderKind::Sglang => Self::LocalRuntime, |
| 167 | ProviderKind::Custom => Self::Custom, |
| 168 | ProviderKind::Deepseek | ProviderKind::OpencodeZen | ProviderKind::OpencodeGo => { |
| 169 | Self::ModelAware |
| 170 | } |
| 171 | _ => match kind.provider().wire_policy() { |
| 172 | WirePolicy::ModelAware => Self::ModelAware, |
| 173 | WirePolicy::Fixed(crate::provider::WireFormat::Responses) => Self::OpenAiResponses, |
| 174 | WirePolicy::Fixed(crate::provider::WireFormat::AnthropicMessages) => { |
| 175 | Self::AnthropicMessages |
| 176 | } |
| 177 | WirePolicy::Fixed(crate::provider::WireFormat::ChatCompletions) => { |
| 178 | Self::ChatCompletions |
| 179 | } |
| 180 | }, |
| 181 | } |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | /// Display-grouping family. Selecting a family with multiple routes asks a |
| 186 | /// `select` whose option value **is a route id**. |
| 187 | #[must_use] |
| 188 | pub fn family_for(kind: ProviderKind) -> &'static str { |
| 189 | match kind { |
| 190 | ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic => "deepseek", |
| 191 | ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => "minimax", |
| 192 | ProviderKind::ModelstudioTokenPlan |
| 193 | | ProviderKind::ModelstudioTokenPlanAnthropic |
| 194 | | ProviderKind::ModelstudioCodingPlan |
| 195 | | ProviderKind::ModelstudioCodingPlanAnthropic => "alibaba-modelstudio", |
| 196 | ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => "siliconflow", |
| 197 | ProviderKind::Ollama | ProviderKind::OllamaCloud => "ollama", |
| 198 | other => other.as_str(), |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | /// Auth methods declared for a provider kind. OAuth is a type, not an adapter. |
| 203 | #[must_use] |
| 204 | pub fn auth_methods_for(kind: ProviderKind) -> &'static [AuthMethod] { |
| 205 | if kind == ProviderKind::Antigravity { |
| 206 | return &[]; |
| 207 | } |
| 208 | match kind.provider().credential_help().acquisition { |
| 209 | CredentialAcquisition::ApiKey => &[AuthMethod::API_KEY], |
| 210 | CredentialAcquisition::ApiKeyOrOAuth => &[AuthMethod::API_KEY, AuthMethod::OAUTH], |
| 211 | CredentialAcquisition::LocalOptional => &[AuthMethod::KEYLESS], |
| 212 | CredentialAcquisition::OAuth => &[AuthMethod::OAUTH], |
| 213 | CredentialAcquisition::Configuration => &[AuthMethod::EXTERNAL_CONSENT], |
| 214 | } |
| 215 | } |
| 216 |