Skip to main content

alien_core/
ai_catalog.rs

1//! Curated, per-cloud model catalog for the AI gateway.
2//!
3//! Single source of truth for which public model ids each cloud exposes, the
4//! upstream id the gateway forwards, and the wire protocol of the model's native
5//! endpoint. Backs `getAvailableModels()` and the gateway's `/v1/models`, and the
6//! Azure controller deploys the Azure entries as named deployments at provision
7//! time (see `azure_deployments`).
8//!
9//! A model is includable only if its cloud serves it over a protocol the client
10//! SDK already speaks (OpenAI Chat Completions or Anthropic Messages), so the
11//! gateway forwards the request body untranslated.
12
13use crate::Platform;
14use serde::{Deserialize, Serialize};
15
16/// The upstream wire protocol a model speaks. The gateway forwards to the
17/// matching native endpoint; the client SDK is responsible for speaking it.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum Protocol {
21    /// OpenAI Chat Completions (`/v1/chat/completions`).
22    OpenAi,
23    /// Anthropic Messages (`/v1/messages`).
24    Anthropic,
25}
26
27/// The one-time action, if any, a customer must take in the cloud provider before
28/// the gateway can invoke a model. Static per (provider, cloud), surfaced in docs
29/// and the example README. Distinct from runtime availability, which
30/// `getAvailableModels` probes live per deployment.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Activation {
33    /// Enabled by default; nothing for the customer to do (quota still applies).
34    OutOfBox,
35    /// Needs a one-time customer action first; the string says what.
36    RequiresOneTimeStep(&'static str),
37}
38
39/// One curated model: the public id an app requests, the cloud that serves it,
40/// the upstream id the gateway forwards (for Azure this is the deployment name),
41/// and the protocol of its native endpoint.
42#[derive(Debug, Clone)]
43pub struct CatalogModel {
44    pub public_id: &'static str,
45    pub cloud: Platform,
46    pub upstream_id: &'static str,
47    pub protocol: Protocol,
48}
49
50impl CatalogModel {
51    /// The model's publisher, for grouping in a picker. Derived from the public id,
52    /// so the same public id reports the same provider on every cloud.
53    pub fn provider(&self) -> &'static str {
54        let id = self.public_id;
55        if id.starts_with("claude") {
56            "anthropic"
57        } else if id.starts_with("gpt") || id == "model-router" {
58            "openai"
59        } else if id.starts_with("gemini") || id.starts_with("gemma") {
60            "google"
61        } else if id.starts_with("qwen") {
62            "qwen"
63        } else if id.starts_with("deepseek") {
64            "deepseek"
65        } else if id.starts_with("mistral")
66            || id.starts_with("devstral")
67            || id.starts_with("magistral")
68            || id.starts_with("ministral")
69        {
70            "mistral"
71        } else if id.starts_with("minimax") {
72            "minimax"
73        } else if id.starts_with("kimi") {
74            "moonshotai"
75        } else if id.starts_with("nemotron") {
76            "nvidia"
77        } else if id.starts_with("glm") {
78            "zai"
79        } else if id.starts_with("palmyra") {
80            "writer"
81        } else {
82            "unknown"
83        }
84    }
85
86    /// A human label for a model picker. Curated per id rather than derived so the
87    /// acronyms (GPT, OSS, GLM, VL) and versions read correctly.
88    pub fn display_name(&self) -> &'static str {
89        match self.public_id {
90            "gpt-oss-20b" => "GPT-OSS 20B",
91            "gpt-oss-120b" => "GPT-OSS 120B",
92            "gpt-oss-safeguard-20b" => "GPT-OSS Safeguard 20B",
93            "gpt-oss-safeguard-120b" => "GPT-OSS Safeguard 120B",
94            "deepseek-v3.2" => "DeepSeek V3.2",
95            "qwen3-32b" => "Qwen3 32B",
96            "qwen3-coder-30b" => "Qwen3 Coder 30B",
97            "qwen3-coder-next" => "Qwen3 Coder Next",
98            "qwen3-next-80b" => "Qwen3 Next 80B",
99            "qwen3-vl-235b" => "Qwen3 VL 235B",
100            "mistral-large-3" => "Mistral Large 3",
101            "devstral-2" => "Devstral 2",
102            "magistral-small" => "Magistral Small",
103            "ministral-3-14b" => "Ministral 3 14B",
104            "ministral-3-8b" => "Ministral 3 8B",
105            "ministral-3-3b" => "Ministral 3 3B",
106            "minimax-m2" => "MiniMax M2",
107            "minimax-m2.1" => "MiniMax M2.1",
108            "minimax-m2.5" => "MiniMax M2.5",
109            "kimi-k2.5" => "Kimi K2.5",
110            "nemotron-nano-9b" => "Nemotron Nano 9B",
111            "nemotron-nano-12b" => "Nemotron Nano 12B",
112            "nemotron-nano-3-30b" => "Nemotron Nano 3 30B",
113            "nemotron-super-3-120b" => "Nemotron Super 3 120B",
114            "gemma-3-4b" => "Gemma 3 4B",
115            "gemma-3-12b" => "Gemma 3 12B",
116            "gemma-3-27b" => "Gemma 3 27B",
117            "glm-4.7" => "GLM 4.7",
118            "glm-4.7-flash" => "GLM 4.7 Flash",
119            "glm-5" => "GLM 5",
120            "palmyra-vision-7b" => "Palmyra Vision 7B",
121            "claude-sonnet-5" => "Claude Sonnet 5",
122            "claude-opus-4.8" => "Claude Opus 4.8",
123            "claude-opus-4.7" => "Claude Opus 4.7",
124            "claude-opus-4.6" => "Claude Opus 4.6",
125            "claude-opus-4.5" => "Claude Opus 4.5",
126            "claude-opus-4.1" => "Claude Opus 4.1",
127            "claude-sonnet-4.6" => "Claude Sonnet 4.6",
128            "claude-sonnet-4.5" => "Claude Sonnet 4.5",
129            "claude-haiku-4.5" => "Claude Haiku 4.5",
130            "claude-fable-5" => "Claude Fable 5",
131            "claude-mythos-5" => "Claude Mythos 5",
132            "gemini-2.5-pro" => "Gemini 2.5 Pro",
133            "gemini-2.5-flash" => "Gemini 2.5 Flash",
134            "gemini-2.5-flash-lite" => "Gemini 2.5 Flash Lite",
135            "gemini-3.5-flash" => "Gemini 3.5 Flash",
136            "gemini-3.1-flash-lite" => "Gemini 3.1 Flash Lite",
137            "gpt-4.1" => "GPT-4.1",
138            "gpt-4o-mini" => "GPT-4o mini",
139            "model-router" => "Model Router",
140            other => other,
141        }
142    }
143
144    /// The one-time enablement step for this model on its cloud, if any. Only Claude
145    /// needs one today, and the step differs per cloud.
146    pub fn activation(&self) -> Activation {
147        if !self.public_id.starts_with("claude") {
148            return Activation::OutOfBox;
149        }
150        match self.cloud {
151            Platform::Aws => Activation::RequiresOneTimeStep(
152                "Submit the one-time Anthropic use-case form in the Bedrock console.",
153            ),
154            Platform::Gcp => Activation::RequiresOneTimeStep(
155                "Enable Claude in Vertex AI Model Garden and accept Anthropic's terms of service, one-time, in the Google Cloud console.",
156            ),
157            Platform::Azure => Activation::RequiresOneTimeStep(
158                "Accept the Marketplace terms and create the Claude deployment in the Microsoft Foundry portal (one-time).",
159            ),
160            _ => Activation::OutOfBox,
161        }
162    }
163}
164
165static CATALOG: &[CatalogModel] = &[
166    // AWS Bedrock over `/openai/v1` chat completions. The plain Bedrock model id,
167    // not the `us.*` cross-region inference profile — that endpoint rejects it.
168    // Invoke/Converse-only models (older Llama/Mistral-v0/Nova) can't be served here.
169    CatalogModel { public_id: "gpt-oss-20b", cloud: Platform::Aws, upstream_id: "openai.gpt-oss-20b-1:0", protocol: Protocol::OpenAi },
170    CatalogModel { public_id: "gpt-oss-120b", cloud: Platform::Aws, upstream_id: "openai.gpt-oss-120b-1:0", protocol: Protocol::OpenAi },
171    CatalogModel { public_id: "gpt-oss-safeguard-20b", cloud: Platform::Aws, upstream_id: "openai.gpt-oss-safeguard-20b", protocol: Protocol::OpenAi },
172    CatalogModel { public_id: "gpt-oss-safeguard-120b", cloud: Platform::Aws, upstream_id: "openai.gpt-oss-safeguard-120b", protocol: Protocol::OpenAi },
173    CatalogModel { public_id: "deepseek-v3.2", cloud: Platform::Aws, upstream_id: "deepseek.v3.2", protocol: Protocol::OpenAi },
174    CatalogModel { public_id: "qwen3-32b", cloud: Platform::Aws, upstream_id: "qwen.qwen3-32b-v1:0", protocol: Protocol::OpenAi },
175    CatalogModel { public_id: "qwen3-coder-30b", cloud: Platform::Aws, upstream_id: "qwen.qwen3-coder-30b-a3b-v1:0", protocol: Protocol::OpenAi },
176    CatalogModel { public_id: "qwen3-coder-next", cloud: Platform::Aws, upstream_id: "qwen.qwen3-coder-next", protocol: Protocol::OpenAi },
177    CatalogModel { public_id: "qwen3-next-80b", cloud: Platform::Aws, upstream_id: "qwen.qwen3-next-80b-a3b", protocol: Protocol::OpenAi },
178    CatalogModel { public_id: "qwen3-vl-235b", cloud: Platform::Aws, upstream_id: "qwen.qwen3-vl-235b-a22b", protocol: Protocol::OpenAi },
179    CatalogModel { public_id: "mistral-large-3", cloud: Platform::Aws, upstream_id: "mistral.mistral-large-3-675b-instruct", protocol: Protocol::OpenAi },
180    CatalogModel { public_id: "devstral-2", cloud: Platform::Aws, upstream_id: "mistral.devstral-2-123b", protocol: Protocol::OpenAi },
181    CatalogModel { public_id: "magistral-small", cloud: Platform::Aws, upstream_id: "mistral.magistral-small-2509", protocol: Protocol::OpenAi },
182    CatalogModel { public_id: "ministral-3-14b", cloud: Platform::Aws, upstream_id: "mistral.ministral-3-14b-instruct", protocol: Protocol::OpenAi },
183    CatalogModel { public_id: "ministral-3-8b", cloud: Platform::Aws, upstream_id: "mistral.ministral-3-8b-instruct", protocol: Protocol::OpenAi },
184    CatalogModel { public_id: "ministral-3-3b", cloud: Platform::Aws, upstream_id: "mistral.ministral-3-3b-instruct", protocol: Protocol::OpenAi },
185    CatalogModel { public_id: "minimax-m2", cloud: Platform::Aws, upstream_id: "minimax.minimax-m2", protocol: Protocol::OpenAi },
186    CatalogModel { public_id: "minimax-m2.1", cloud: Platform::Aws, upstream_id: "minimax.minimax-m2.1", protocol: Protocol::OpenAi },
187    CatalogModel { public_id: "minimax-m2.5", cloud: Platform::Aws, upstream_id: "minimax.minimax-m2.5", protocol: Protocol::OpenAi },
188    CatalogModel { public_id: "kimi-k2.5", cloud: Platform::Aws, upstream_id: "moonshotai.kimi-k2.5", protocol: Protocol::OpenAi },
189    CatalogModel { public_id: "nemotron-nano-9b", cloud: Platform::Aws, upstream_id: "nvidia.nemotron-nano-9b-v2", protocol: Protocol::OpenAi },
190    CatalogModel { public_id: "nemotron-nano-12b", cloud: Platform::Aws, upstream_id: "nvidia.nemotron-nano-12b-v2", protocol: Protocol::OpenAi },
191    CatalogModel { public_id: "nemotron-nano-3-30b", cloud: Platform::Aws, upstream_id: "nvidia.nemotron-nano-3-30b", protocol: Protocol::OpenAi },
192    CatalogModel { public_id: "nemotron-super-3-120b", cloud: Platform::Aws, upstream_id: "nvidia.nemotron-super-3-120b", protocol: Protocol::OpenAi },
193    CatalogModel { public_id: "gemma-3-4b", cloud: Platform::Aws, upstream_id: "google.gemma-3-4b-it", protocol: Protocol::OpenAi },
194    CatalogModel { public_id: "gemma-3-12b", cloud: Platform::Aws, upstream_id: "google.gemma-3-12b-it", protocol: Protocol::OpenAi },
195    CatalogModel { public_id: "gemma-3-27b", cloud: Platform::Aws, upstream_id: "google.gemma-3-27b-it", protocol: Protocol::OpenAi },
196    CatalogModel { public_id: "glm-4.7", cloud: Platform::Aws, upstream_id: "zai.glm-4.7", protocol: Protocol::OpenAi },
197    CatalogModel { public_id: "glm-4.7-flash", cloud: Platform::Aws, upstream_id: "zai.glm-4.7-flash", protocol: Protocol::OpenAi },
198    CatalogModel { public_id: "glm-5", cloud: Platform::Aws, upstream_id: "zai.glm-5", protocol: Protocol::OpenAi },
199    CatalogModel { public_id: "palmyra-vision-7b", cloud: Platform::Aws, upstream_id: "writer.palmyra-vision-7b", protocol: Protocol::OpenAi },
200    // AWS Bedrock, Claude over classic InvokeModel (the Anthropic Messages body is
201    // the InvokeModel body; the model travels in the URL). `upstream_id` is the plain
202    // Bedrock model id; the gateway prepends the region's cross-region inference-profile
203    // geo prefix (`us.`/`eu.`/`apac.`) at request time, since Claude is invocable only
204    // through a profile. Dated ids (`…-<date>-v1:0`) are required where AWS has no short
205    // alias. These need Claude model access granted on the deployment's account.
206    CatalogModel { public_id: "claude-sonnet-5", cloud: Platform::Aws, upstream_id: "anthropic.claude-sonnet-5", protocol: Protocol::Anthropic },
207    CatalogModel { public_id: "claude-opus-4.8", cloud: Platform::Aws, upstream_id: "anthropic.claude-opus-4-8", protocol: Protocol::Anthropic },
208    CatalogModel { public_id: "claude-opus-4.7", cloud: Platform::Aws, upstream_id: "anthropic.claude-opus-4-7", protocol: Protocol::Anthropic },
209    CatalogModel { public_id: "claude-opus-4.6", cloud: Platform::Aws, upstream_id: "anthropic.claude-opus-4-6-v1", protocol: Protocol::Anthropic },
210    CatalogModel { public_id: "claude-opus-4.5", cloud: Platform::Aws, upstream_id: "anthropic.claude-opus-4-5-20251101-v1:0", protocol: Protocol::Anthropic },
211    CatalogModel { public_id: "claude-opus-4.1", cloud: Platform::Aws, upstream_id: "anthropic.claude-opus-4-1-20250805-v1:0", protocol: Protocol::Anthropic },
212    CatalogModel { public_id: "claude-sonnet-4.6", cloud: Platform::Aws, upstream_id: "anthropic.claude-sonnet-4-6", protocol: Protocol::Anthropic },
213    CatalogModel { public_id: "claude-sonnet-4.5", cloud: Platform::Aws, upstream_id: "anthropic.claude-sonnet-4-5-20250929-v1:0", protocol: Protocol::Anthropic },
214    CatalogModel { public_id: "claude-haiku-4.5", cloud: Platform::Aws, upstream_id: "anthropic.claude-haiku-4-5-20251001-v1:0", protocol: Protocol::Anthropic },
215    CatalogModel { public_id: "claude-fable-5", cloud: Platform::Aws, upstream_id: "anthropic.claude-fable-5", protocol: Protocol::Anthropic },
216    CatalogModel { public_id: "claude-mythos-5", cloud: Platform::Aws, upstream_id: "anthropic.claude-mythos-5", protocol: Protocol::Anthropic },
217    // GCP Vertex, Gemini. The OpenAI-compatible Vertex endpoint expects the `google/` prefix.
218    // The 2.5 family serves in-region; the 3.x models serve on the `global` location.
219    CatalogModel { public_id: "gemini-2.5-pro", cloud: Platform::Gcp, upstream_id: "google/gemini-2.5-pro", protocol: Protocol::OpenAi },
220    CatalogModel { public_id: "gemini-2.5-flash", cloud: Platform::Gcp, upstream_id: "google/gemini-2.5-flash", protocol: Protocol::OpenAi },
221    CatalogModel { public_id: "gemini-2.5-flash-lite", cloud: Platform::Gcp, upstream_id: "google/gemini-2.5-flash-lite", protocol: Protocol::OpenAi },
222    CatalogModel { public_id: "gemini-3.5-flash", cloud: Platform::Gcp, upstream_id: "google/gemini-3.5-flash", protocol: Protocol::OpenAi },
223    CatalogModel { public_id: "gemini-3.1-flash-lite", cloud: Platform::Gcp, upstream_id: "google/gemini-3.1-flash-lite", protocol: Protocol::OpenAi },
224    // GCP Vertex, Claude. The upstream id is the Vertex Model Garden id that travels
225    // in the `:rawPredict` URL path (`publishers/anthropic/models/<id>`); models past
226    // Sonnet 4.5 carry no date suffix, older ones keep an `@<date>` version. Needs
227    // Claude model access granted on the deployment's project.
228    CatalogModel { public_id: "claude-sonnet-5", cloud: Platform::Gcp, upstream_id: "claude-sonnet-5", protocol: Protocol::Anthropic },
229    CatalogModel { public_id: "claude-opus-4.8", cloud: Platform::Gcp, upstream_id: "claude-opus-4-8", protocol: Protocol::Anthropic },
230    CatalogModel { public_id: "claude-opus-4.7", cloud: Platform::Gcp, upstream_id: "claude-opus-4-7", protocol: Protocol::Anthropic },
231    CatalogModel { public_id: "claude-opus-4.6", cloud: Platform::Gcp, upstream_id: "claude-opus-4-6", protocol: Protocol::Anthropic },
232    CatalogModel { public_id: "claude-opus-4.5", cloud: Platform::Gcp, upstream_id: "claude-opus-4-5@20251101", protocol: Protocol::Anthropic },
233    CatalogModel { public_id: "claude-sonnet-4.6", cloud: Platform::Gcp, upstream_id: "claude-sonnet-4-6", protocol: Protocol::Anthropic },
234    CatalogModel { public_id: "claude-sonnet-4.5", cloud: Platform::Gcp, upstream_id: "claude-sonnet-4-5@20250929", protocol: Protocol::Anthropic },
235    CatalogModel { public_id: "claude-haiku-4.5", cloud: Platform::Gcp, upstream_id: "claude-haiku-4-5@20251001", protocol: Protocol::Anthropic },
236    CatalogModel { public_id: "claude-fable-5", cloud: Platform::Gcp, upstream_id: "claude-fable-5", protocol: Protocol::Anthropic },
237    // Azure, OpenAI-protocol. The upstream id is the deployment name the controller
238    // creates (see AZURE_DEPLOYMENTS); the app requests it by the same id. Azure serves
239    // only what is deployed, so this list must stay in sync with AZURE_DEPLOYMENTS.
240    CatalogModel { public_id: "gpt-4.1", cloud: Platform::Azure, upstream_id: "gpt-4.1", protocol: Protocol::OpenAi },
241    CatalogModel { public_id: "gpt-4o-mini", cloud: Platform::Azure, upstream_id: "gpt-4o-mini", protocol: Protocol::OpenAi },
242    CatalogModel { public_id: "model-router", cloud: Platform::Azure, upstream_id: "model-router", protocol: Protocol::OpenAi },
243    // Azure, Claude over the Foundry Anthropic endpoint. The upstream id is the
244    // Foundry deployment name (defaults to the model id). Unlike the OpenAI list,
245    // these are not in AZURE_DEPLOYMENTS: a first Claude deployment requires
246    // accepting Azure Marketplace terms, a portal step the controller cannot
247    // perform, so Claude deployments are created in the Foundry portal. These stay
248    // in the catalog as the deployment-name contract, but the gateway's /v1/models
249    // availability probe drops any that the portal step has not created, so the
250    // list omits any Claude that Foundry would 404.
251    CatalogModel { public_id: "claude-sonnet-5", cloud: Platform::Azure, upstream_id: "claude-sonnet-5", protocol: Protocol::Anthropic },
252    CatalogModel { public_id: "claude-opus-4.8", cloud: Platform::Azure, upstream_id: "claude-opus-4-8", protocol: Protocol::Anthropic },
253    CatalogModel { public_id: "claude-opus-4.7", cloud: Platform::Azure, upstream_id: "claude-opus-4-7", protocol: Protocol::Anthropic },
254    CatalogModel { public_id: "claude-opus-4.6", cloud: Platform::Azure, upstream_id: "claude-opus-4-6", protocol: Protocol::Anthropic },
255    CatalogModel { public_id: "claude-opus-4.5", cloud: Platform::Azure, upstream_id: "claude-opus-4-5", protocol: Protocol::Anthropic },
256    CatalogModel { public_id: "claude-sonnet-4.6", cloud: Platform::Azure, upstream_id: "claude-sonnet-4-6", protocol: Protocol::Anthropic },
257    CatalogModel { public_id: "claude-sonnet-4.5", cloud: Platform::Azure, upstream_id: "claude-sonnet-4-5", protocol: Protocol::Anthropic },
258    CatalogModel { public_id: "claude-haiku-4.5", cloud: Platform::Azure, upstream_id: "claude-haiku-4-5", protocol: Protocol::Anthropic },
259    CatalogModel { public_id: "claude-fable-5", cloud: Platform::Azure, upstream_id: "claude-fable-5", protocol: Protocol::Anthropic },
260];
261
262/// Azure deployments to create at provision time: (deployment name, model name,
263/// model version). The deployment name is the catalog `upstream_id`. The version
264/// is validated against the target region's model catalog at deploy time.
265static AZURE_DEPLOYMENTS: &[(&str, &str, &str)] = &[
266    ("gpt-4.1", "gpt-4.1", "2025-04-14"),
267    ("gpt-4o-mini", "gpt-4o-mini", "2024-07-18"),
268    ("model-router", "model-router", "2025-11-18"),
269];
270
271/// AWS models servable over the bedrock-mantle OpenAI Responses API, mapped to the
272/// id that endpoint expects (mantle drops the InvokeModel version suffix, and only
273/// a subset of the chat catalog supports Responses at all — Claude is Messages-only
274/// and e.g. Qwen rejects it). Kept explicit rather than derived: the two id schemes
275/// differ per model family, not by a rule.
276static RESPONSES_UPSTREAM: &[(&str, &str)] = &[
277    ("gpt-oss-20b", "openai.gpt-oss-20b"),
278    ("gpt-oss-120b", "openai.gpt-oss-120b"),
279];
280
281/// The bedrock-mantle Responses-API id for a public model id, or `None` when the
282/// model is not servable over the Responses API.
283pub fn responses_upstream_id(public_id: &str) -> Option<&'static str> {
284    RESPONSES_UPSTREAM
285        .iter()
286        .find(|(public, _)| *public == public_id)
287        .map(|(_, upstream)| *upstream)
288}
289
290pub fn models_for(cloud: Platform) -> Vec<&'static CatalogModel> {
291    CATALOG.iter().filter(|m| m.cloud == cloud).collect()
292}
293
294/// The catalog model for a public id, or `None` if it is not exposed.
295///
296/// First match: for an id serving on more than one cloud this is the AWS entry;
297/// cloud-scoped callers use `lookup_for` via `resolve_for`.
298pub fn lookup(public_id: &str) -> Option<&'static CatalogModel> {
299    CATALOG.iter().find(|m| m.public_id == public_id)
300}
301
302fn lookup_for(public_id: &str, cloud: Platform) -> Option<&'static CatalogModel> {
303    CATALOG.iter().find(|m| m.public_id == public_id && m.cloud == cloud)
304}
305
306/// The catalog model for a client-sent model id on a specific cloud. A public id
307/// can appear once per cloud (Claude serves on more than one), so resolution must
308/// scope to the binding's cloud rather than filter a first-match lookup — the
309/// first match is another cloud's entry whenever ids overlap.
310pub fn resolve_for(model_id: &str, cloud: Platform) -> Option<&'static CatalogModel> {
311    lookup_for(model_id, cloud).or_else(|| lookup_for(&canonical_public_id(model_id), cloud))
312}
313
314/// The catalog model for a client-sent model id, accepting the Anthropic-native
315/// spellings agent CLIs actually send alongside the catalog's public ids.
316///
317/// Claude Code's `/model` emits ids like `claude-sonnet-4-5-20250929` or
318/// `claude-haiku-4-5`, Bedrock-aware clients may carry the full upstream id
319/// (`us.anthropic.claude-haiku-4-5-20251001-v1:0`), and Vertex clients the
320/// `@date` form (`claude-sonnet-4-5@20250929`). Exact public ids win; otherwise
321/// the id is canonicalized — vendor/geo prefix, InvokeModel `-vN[:M]` suffix,
322/// and either release-date suffix drop off, and a dashed minor version becomes
323/// the catalog's dotted form (`claude-haiku-4-5` → `claude-haiku-4.5`).
324///
325/// A public id can appear once per cloud, and this returns the first catalog
326/// entry — for a multi-cloud id that is the AWS one. Callers routing by a
327/// binding must use `resolve_for` with the binding's cloud.
328pub fn resolve(model_id: &str) -> Option<&'static CatalogModel> {
329    lookup(model_id).or_else(|| lookup(&canonical_public_id(model_id)))
330}
331
332fn canonical_public_id(model_id: &str) -> String {
333    let mut id = model_id;
334    if let Some(pos) = id.rfind("anthropic.") {
335        id = &id[pos + "anthropic.".len()..];
336    }
337    // Vertex spells the release date as an `@` suffix rather than a dash.
338    id = id.split_once('@').map_or(id, |(base, _)| base);
339    id = strip_invoke_version(id);
340    id = strip_release_date(id);
341    dot_minor_version(id)
342}
343
344/// Strip an InvokeModel version suffix: `-v1:0` or `-v1`.
345fn strip_invoke_version(id: &str) -> &str {
346    let base = id.split_once(':').map_or(id, |(base, _)| base);
347    match base.rsplit_once("-v") {
348        Some((stem, digits)) if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) => {
349            stem
350        }
351        _ => base,
352    }
353}
354
355/// Strip a release-date suffix: `-20251001`.
356fn strip_release_date(id: &str) -> &str {
357    match id.rsplit_once('-') {
358        Some((stem, date))
359            if date.len() == 8 && date.starts_with("20") && date.bytes().all(|b| b.is_ascii_digit()) =>
360        {
361            stem
362        }
363        _ => id,
364    }
365}
366
367/// Rewrite a trailing dashed minor version to the catalog's dotted form:
368/// `claude-haiku-4-5` → `claude-haiku-4.5`. Whole versions (`claude-sonnet-5`)
369/// are already in catalog form and pass through.
370fn dot_minor_version(id: &str) -> String {
371    let Some((stem, minor)) = id.rsplit_once('-') else {
372        return id.to_string();
373    };
374    let Some((prefix, major)) = stem.rsplit_once('-') else {
375        return id.to_string();
376    };
377    let both_numeric = !major.is_empty()
378        && !minor.is_empty()
379        && major.bytes().all(|b| b.is_ascii_digit())
380        && minor.bytes().all(|b| b.is_ascii_digit());
381    if both_numeric {
382        format!("{prefix}-{major}.{minor}")
383    } else {
384        id.to_string()
385    }
386}
387
388/// The Azure predefined model deployments, as (deployment name, model name, version).
389pub fn azure_deployments() -> Vec<(&'static str, &'static str, &'static str)> {
390    AZURE_DEPLOYMENTS.to_vec()
391}
392
393#[cfg(test)]
394mod tests {
395    /// A public id may serve on more than one cloud (Claude does), but must appear at
396    /// most once per cloud — a duplicate within a cloud would make `resolve_for`
397    /// silently pick whichever entry comes first.
398    #[test]
399    fn public_ids_are_unique_per_cloud() {
400        let mut seen = std::collections::HashSet::new();
401        for model in super::CATALOG {
402            assert!(
403                seen.insert((model.cloud, model.public_id)),
404                "public id '{}' appears more than once under {:?}",
405                model.public_id,
406                model.cloud
407            );
408        }
409    }
410
411    use super::*;
412
413    #[test]
414    fn resolve_accepts_anthropic_native_spellings() {
415        // Claude Code /model forms: dashed minor version, with and without date.
416        assert_eq!(resolve("claude-haiku-4-5").unwrap().public_id, "claude-haiku-4.5");
417        assert_eq!(
418            resolve("claude-sonnet-4-5-20250929").unwrap().public_id,
419            "claude-sonnet-4.5"
420        );
421        // Full Bedrock upstream ids, with geo/vendor prefix and version suffix.
422        assert_eq!(
423            resolve("us.anthropic.claude-haiku-4-5-20251001-v1:0").unwrap().public_id,
424            "claude-haiku-4.5"
425        );
426        assert_eq!(
427            resolve("anthropic.claude-opus-4-6-v1").unwrap().public_id,
428            "claude-opus-4.6"
429        );
430        // Whole versions are already catalog form.
431        assert_eq!(resolve("claude-sonnet-5").unwrap().public_id, "claude-sonnet-5");
432        // Exact public ids still win untouched.
433        assert_eq!(resolve("claude-opus-4.8").unwrap().public_id, "claude-opus-4.8");
434        assert_eq!(resolve("gpt-oss-20b").unwrap().public_id, "gpt-oss-20b");
435        // Unknowns stay unknown — no fuzzy matching.
436        assert!(resolve("claude-nonexistent-9-9").is_none());
437        assert!(resolve("gpt-5").is_none());
438    }
439
440    #[test]
441    fn aws_has_openai_and_anthropic_with_plain_ids() {
442        let aws = models_for(Platform::Aws);
443        assert!(!aws.is_empty());
444        assert!(aws
445            .iter()
446            .any(|m| m.public_id == "gpt-oss-20b" && m.protocol == Protocol::OpenAi));
447        assert!(
448            aws.iter().any(|m| m.protocol == Protocol::Anthropic),
449            "Claude must be included via the Anthropic protocol"
450        );
451        // The OpenAI endpoint rejects `us.*` cross-region profile ids.
452        assert!(aws.iter().all(|m| !m.upstream_id.starts_with("us.")));
453    }
454
455    #[test]
456    fn resolve_for_scopes_to_cloud() {
457        // The same public id serves on more than one cloud with different upstream
458        // ids, so resolution must scope to the binding's cloud.
459        let aws = resolve_for("claude-opus-4.8", Platform::Aws).expect("aws claude");
460        assert_eq!(aws.upstream_id, "anthropic.claude-opus-4-8");
461        let gcp = resolve_for("claude-opus-4.8", Platform::Gcp).expect("gcp claude");
462        assert_eq!(gcp.upstream_id, "claude-opus-4-8");
463        assert_eq!(gcp.protocol, Protocol::Anthropic);
464        // Canonicalization applies per cloud: Claude Code's dashed release-date
465        // spelling resolves to the Vertex `@date` id.
466        let dated = resolve_for("claude-haiku-4-5-20251001", Platform::Gcp).expect("dated id");
467        assert_eq!(dated.upstream_id, "claude-haiku-4-5@20251001");
468        // A Vertex-native `@date` spelling resolves too — it is the very id the
469        // GCP catalog stores upstream.
470        let vertex = resolve_for("claude-sonnet-4-5@20250929", Platform::Gcp).expect("vertex id");
471        assert_eq!(vertex.upstream_id, "claude-sonnet-4-5@20250929");
472        // A model serving on one cloud does not resolve on another.
473        assert!(resolve_for("gemini-2.5-pro", Platform::Aws).is_none());
474        assert!(resolve_for("gpt-4.1", Platform::Gcp).is_none());
475    }
476
477    #[test]
478    fn lookup_round_trips() {
479        let m = lookup("gpt-oss-20b").expect("known model");
480        assert_eq!(m.cloud, Platform::Aws);
481        assert_eq!(m.protocol, Protocol::OpenAi);
482        assert_eq!(m.upstream_id, "openai.gpt-oss-20b-1:0");
483
484        let c = lookup("claude-opus-4.8").expect("claude known");
485        assert_eq!(c.protocol, Protocol::Anthropic);
486
487        assert!(lookup("nonexistent-model").is_none());
488    }
489
490    #[test]
491    fn azure_deployments_map_to_catalog() {
492        assert!(!azure_deployments().is_empty());
493        for (deployment, _, _) in azure_deployments() {
494            assert!(
495                models_for(Platform::Azure)
496                    .iter()
497                    .any(|m| m.upstream_id == deployment),
498                "azure deployment {deployment} must map to a catalog model"
499            );
500        }
501    }
502
503    #[test]
504    fn protocol_serializes_lowercase() {
505        assert_eq!(serde_json::to_string(&Protocol::OpenAi).unwrap(), "\"openai\"");
506        assert_eq!(serde_json::to_string(&Protocol::Anthropic).unwrap(), "\"anthropic\"");
507    }
508
509    #[test]
510    fn every_model_has_provider_display_name_and_activation() {
511        for m in CATALOG {
512            assert_ne!(m.provider(), "unknown", "no provider mapping for '{}'", m.public_id);
513            assert_ne!(
514                m.display_name(),
515                m.public_id,
516                "no curated display_name for '{}'",
517                m.public_id
518            );
519            // Only Claude needs a one-time step; everything else is out of the box.
520            let is_claude = m.public_id.starts_with("claude");
521            match m.activation() {
522                Activation::OutOfBox => {
523                    assert!(!is_claude, "'{}' (Claude) must require a one-time step", m.public_id)
524                }
525                Activation::RequiresOneTimeStep(summary) => {
526                    assert!(is_claude, "'{}' must be out of the box", m.public_id);
527                    assert!(!summary.is_empty(), "'{}' step summary is empty", m.public_id);
528                }
529            }
530        }
531    }
532}