返回 CodeWhale
ids.rs
根目录 / crates / config / src / 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
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
202 lines RUST