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