codewhale_config/route/ids.rs
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
23use std::fmt;
24
25use serde::{Deserialize, Serialize};
26
27use crate::ProviderKind;
28
29macro_rules! string_newtype {
30 ($(#[$meta:meta])* $name:ident) => {
31 $(#[$meta])*
32 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
33 #[serde(transparent)]
34 pub struct $name(String);
35
36 impl $name {
37 /// Borrow the inner string slice.
38 #[must_use]
39 pub fn as_str(&self) -> &str {
40 &self.0
41 }
42 }
43
44 impl From<&str> for $name {
45 fn from(value: &str) -> Self {
46 Self(value.to_string())
47 }
48 }
49
50 impl From<String> for $name {
51 fn from(value: String) -> Self {
52 Self(value)
53 }
54 }
55
56 impl AsRef<str> for $name {
57 fn as_ref(&self) -> &str {
58 &self.0
59 }
60 }
61
62 impl fmt::Display for $name {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 f.write_str(&self.0)
65 }
66 }
67 };
68}
69
70string_newtype!(
71 /// A provider's canonical identifier (e.g. `"deepseek"`, `"openrouter"`).
72 ProviderId
73);
74
75string_newtype!(
76 /// A canonical, provider-agnostic logical model identity.
77 ///
78 /// Distinct from [`WireModelId`]: this is "what the model is", not "what
79 /// string a provider expects on the wire".
80 ModelId
81);
82
83string_newtype!(
84 /// A provider-owned wire model id sent verbatim on the request.
85 ///
86 /// Distinct from [`ModelId`]: aggregator-prefixed strings such as
87 /// `"deepseek-ai/DeepSeek-V4-Pro"` are wire ids, not canonical identities.
88 WireModelId
89);
90
91string_newtype!(
92 /// A user/selector reference to a model.
93 ///
94 /// May be the `"auto"` sentinel, a bare model name, or an
95 /// aggregator-prefixed string. A [`LogicalModelRef`] carries no provider
96 /// authority by itself; see [`Self::namespace_hint`].
97 LogicalModelRef
98);
99
100impl ProviderId {
101 /// Build a [`ProviderId`] from a [`ProviderKind`] using its canonical id.
102 #[must_use]
103 pub fn from_kind(kind: ProviderKind) -> Self {
104 Self(kind.as_str().to_string())
105 }
106}
107
108/// A leading namespace/organization prefix carried by a [`LogicalModelRef`].
109///
110/// A namespace hint is a *catalog* hint only. It is NEVER convertible to a
111/// [`ProviderId`]; an aggregator may serve `deepseek-ai/...` without being
112/// DeepSeek, and a custom endpoint may legitimately use a look-alike string.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
114#[serde(rename_all = "kebab-case")]
115pub enum NamespaceHint {
116 /// `deepseek-ai/` prefix.
117 DeepseekAi,
118 /// `deepseek/` prefix.
119 Deepseek,
120 /// `anthropic/` prefix.
121 Anthropic,
122 /// `openai/` prefix.
123 Openai,
124 /// `qwen/` prefix.
125 Qwen,
126}
127
128impl LogicalModelRef {
129 /// Borrow the raw selector string.
130 #[must_use]
131 pub fn raw(&self) -> &str {
132 self.as_str()
133 }
134
135 /// Whether this selector is the explicit `auto` router sentinel.
136 ///
137 /// `auto` is an opt-in router sentinel, never a literal model id.
138 #[must_use]
139 pub fn is_auto(&self) -> bool {
140 self.raw() == "auto"
141 }
142
143 /// Parse the leading namespace prefix, if any.
144 ///
145 /// Returns `Some` only for the curated aggregator/organization prefixes.
146 /// This is a hint about catalog namespace and does NOT identify a provider.
147 #[must_use]
148 pub fn namespace_hint(&self) -> Option<NamespaceHint> {
149 let raw = self.raw();
150 // Order matters: `deepseek-ai/` must be matched before `deepseek/`.
151 if raw.starts_with("deepseek-ai/") {
152 Some(NamespaceHint::DeepseekAi)
153 } else if raw.starts_with("deepseek/") {
154 Some(NamespaceHint::Deepseek)
155 } else if raw.starts_with("anthropic/") {
156 Some(NamespaceHint::Anthropic)
157 } else if raw.starts_with("openai/") {
158 Some(NamespaceHint::Openai)
159 } else if raw.starts_with("qwen/") {
160 Some(NamespaceHint::Qwen)
161 } else {
162 None
163 }
164 }
165}