| 1 | //! Transparent string newtypes for provider/model/route identities. |
| 2 | //! |
| 3 | //! These types make the distinct *meanings* of route strings unmistakable at |
| 4 | //! the type level so callers can no longer mix: |
| 5 | //! |
| 6 | //! - [`ProviderId`] — a provider's canonical id (e.g. `"deepseek"`). |
| 7 | //! - [`ModelId`] — a canonical, provider-agnostic logical model id. |
| 8 | //! - [`WireModelId`] — a provider-owned wire id sent on the request |
| 9 | //! (e.g. `"deepseek-ai/DeepSeek-V4-Pro"` on Together). |
| 10 | //! - [`LogicalModelRef`] — a user/selector reference to a model, which may be |
| 11 | //! `"auto"`, a bare model, or an aggregator-prefixed string. |
| 12 | //! |
| 13 | //! [`ModelId`] and [`WireModelId`] are deliberately DISTINCT types and are |
| 14 | //! never interchangeable: a canonical model identity is not the same thing as |
| 15 | //! the provider-specific string put on the wire. |
| 16 | //! |
| 17 | //! INVARIANT (load-bearing for #2608): a namespace prefix can NEVER become a |
| 18 | //! provider. There is intentionally NO `From`/`Into` conversion from |
| 19 | //! [`LogicalModelRef`] or [`NamespaceHint`] to [`ProviderId`]. A prefix like |
| 20 | //! `deepseek-ai/` is a catalog/namespace hint only; it is not proof of |
| 21 | //! provider ownership. Do not add such a conversion. |
| 22 | |
| 23 | use std::fmt; |
| 24 | |
| 25 | use serde::{Deserialize, Serialize}; |
| 26 | |
| 27 | /// The `"auto"` router sentinel for [`LogicalModelRef`]. |
| 28 | /// |
| 29 | /// `auto` is an opt-in router sentinel — it never refers to a literal model |
| 30 | /// named "auto". Centralized here so every comparison site uses the same |
| 31 | /// spelling (#4158). |
| 32 | pub const AUTO_SENTINEL: &str = "auto"; |
| 33 | |
| 34 | use crate::ProviderKind; |
| 35 | |
| 36 | macro_rules! string_newtype { |
| 37 | ($(#[$meta:meta])* $name:ident) => { |
| 38 | $(#[$meta])* |
| 39 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] |
| 40 | #[serde(transparent)] |
| 41 | pub struct $name(String); |
| 42 | |
| 43 | impl $name { |
| 44 | /// Borrow the inner string slice. |
| 45 | #[must_use] |
| 46 | pub fn as_str(&self) -> &str { |
| 47 | &self.0 |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | impl From<&str> for $name { |
| 52 | fn from(value: &str) -> Self { |
| 53 | Self(value.to_string()) |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | impl From<String> for $name { |
| 58 | fn from(value: String) -> Self { |
| 59 | Self(value) |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | impl AsRef<str> for $name { |
| 64 | fn as_ref(&self) -> &str { |
| 65 | &self.0 |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | impl fmt::Display for $name { |
| 70 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 71 | f.write_str(&self.0) |
| 72 | } |
| 73 | } |
| 74 | }; |
| 75 | } |
| 76 | |
| 77 | string_newtype!( |
| 78 | /// A provider's canonical identifier (e.g. `"deepseek"`, `"openrouter"`). |
| 79 | ProviderId |
| 80 | ); |
| 81 | |
| 82 | string_newtype!( |
| 83 | /// A flat kebab route id (e.g. `"deepseek"`, `"alibaba-modelstudio-coding-plan"`). |
| 84 | /// |
| 85 | /// Route ids are open strings, stable across releases, and are **not** a |
| 86 | /// closed enum. Unknown catalog rows are still valid ids. |
| 87 | RouteId |
| 88 | ); |
| 89 | |
| 90 | string_newtype!( |
| 91 | /// A canonical, provider-agnostic logical model identity. |
| 92 | /// |
| 93 | /// Distinct from [`WireModelId`]: this is "what the model is", not "what |
| 94 | /// string a provider expects on the wire". |
| 95 | ModelId |
| 96 | ); |
| 97 | |
| 98 | string_newtype!( |
| 99 | /// A provider-owned wire model id sent verbatim on the request. |
| 100 | /// |
| 101 | /// Distinct from [`ModelId`]: aggregator-prefixed strings such as |
| 102 | /// `"deepseek-ai/DeepSeek-V4-Pro"` are wire ids, not canonical identities. |
| 103 | WireModelId |
| 104 | ); |
| 105 | |
| 106 | string_newtype!( |
| 107 | /// A user/selector reference to a model. |
| 108 | /// |
| 109 | /// May be the `"auto"` sentinel, a bare model name, or an |
| 110 | /// aggregator-prefixed string. A [`LogicalModelRef`] carries no provider |
| 111 | /// authority by itself; see [`Self::namespace_hint`]. |
| 112 | LogicalModelRef |
| 113 | ); |
| 114 | |
| 115 | impl ProviderId { |
| 116 | /// Build a [`ProviderId`] from a [`ProviderKind`] using its canonical id. |
| 117 | #[must_use] |
| 118 | pub fn from_kind(kind: ProviderKind) -> Self { |
| 119 | Self(kind.as_str().to_string()) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | impl RouteId { |
| 124 | /// Whether `raw` is a well-formed kebab route id (lowercase ASCII, digits, `-`). |
| 125 | #[must_use] |
| 126 | pub fn is_well_formed(raw: &str) -> bool { |
| 127 | let trimmed = raw.trim(); |
| 128 | !trimmed.is_empty() |
| 129 | && trimmed |
| 130 | .chars() |
| 131 | .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') |
| 132 | && !trimmed.starts_with('-') |
| 133 | && !trimmed.ends_with('-') |
| 134 | && !trimmed.contains("--") |
| 135 | } |
| 136 | |
| 137 | /// The route id for a known kind. Catalog rows that are not kinds use [`RouteId::from`]. |
| 138 | #[must_use] |
| 139 | pub fn from_kind(kind: ProviderKind) -> Self { |
| 140 | Self(kind.as_str().to_string()) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | /// A leading namespace/organization prefix carried by a [`LogicalModelRef`]. |
| 145 | /// |
| 146 | /// A namespace hint is a *catalog* hint only. It is NEVER convertible to a |
| 147 | /// [`ProviderId`]; an aggregator may serve `deepseek-ai/...` without being |
| 148 | /// DeepSeek, and a custom endpoint may legitimately use a look-alike string. |
| 149 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] |
| 150 | #[serde(rename_all = "kebab-case")] |
| 151 | pub enum NamespaceHint { |
| 152 | /// `deepseek-ai/` prefix. |
| 153 | DeepseekAi, |
| 154 | /// `deepseek/` prefix. |
| 155 | Deepseek, |
| 156 | /// `anthropic/` prefix. |
| 157 | Anthropic, |
| 158 | /// `openai/` prefix. |
| 159 | Openai, |
| 160 | /// `qwen/` prefix. |
| 161 | Qwen, |
| 162 | } |
| 163 | |
| 164 | impl LogicalModelRef { |
| 165 | /// Borrow the raw selector string. |
| 166 | #[must_use] |
| 167 | pub fn raw(&self) -> &str { |
| 168 | self.as_str() |
| 169 | } |
| 170 | |
| 171 | /// Whether this selector is the explicit `auto` router sentinel. |
| 172 | /// |
| 173 | /// `auto` is an opt-in router sentinel, never a literal model id. |
| 174 | #[must_use] |
| 175 | pub fn is_auto(&self) -> bool { |
| 176 | self.raw() == AUTO_SENTINEL |
| 177 | } |
| 178 | |
| 179 | /// Parse the leading namespace prefix, if any. |
| 180 | /// |
| 181 | /// Returns `Some` only for the curated aggregator/organization prefixes. |
| 182 | /// This is a hint about catalog namespace and does NOT identify a provider. |
| 183 | #[must_use] |
| 184 | pub fn namespace_hint(&self) -> Option<NamespaceHint> { |
| 185 | let raw = self.raw(); |
| 186 | // Order matters: `deepseek-ai/` must be matched before `deepseek/`. |
| 187 | if raw.starts_with("deepseek-ai/") { |
| 188 | Some(NamespaceHint::DeepseekAi) |
| 189 | } else if raw.starts_with("deepseek/") { |
| 190 | Some(NamespaceHint::Deepseek) |
| 191 | } else if raw.starts_with("anthropic/") { |
| 192 | Some(NamespaceHint::Anthropic) |
| 193 | } else if raw.starts_with("openai/") { |
| 194 | Some(NamespaceHint::Openai) |
| 195 | } else if raw.starts_with("qwen/") { |
| 196 | Some(NamespaceHint::Qwen) |
| 197 | } else { |
| 198 | None |
| 199 | } |
| 200 | } |
| 201 | } |
| 202 |