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/// A public API accepted from an application client.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
19#[serde(rename_all = "kebab-case")]
20pub enum ClientApi {
21    OpenAiChatCompletions,
22    OpenAiResponses,
23    AnthropicMessages,
24}
25
26/// The provider API used for the upstream request. This is deliberately
27/// separate from [`ClientApi`]: an adapter may expose one client API over a
28/// different provider API, but only after that exact combination is qualified.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
31#[serde(rename_all = "lowercase")]
32pub enum ProviderApi {
33    /// OpenAI Chat Completions (`/v1/chat/completions`).
34    OpenAi,
35    /// Anthropic Messages (`/v1/messages`).
36    Anthropic,
37    /// OpenAI Responses on bedrock-mantle. The only API the GPT-5 family serves;
38    /// the exact path is per-model, see `RESPONSES_UPSTREAM`.
39    OpenAiResponses,
40}
41
42/// The one-time action, if any, a customer must take in the cloud provider before
43/// the gateway can invoke a model. Static per (provider, cloud), surfaced in docs
44/// and the example README. Distinct from the read-only availability observation
45/// reported by the resource heartbeat for a particular deployment.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Activation {
48    /// Enabled by default; nothing for the customer to do (quota still applies).
49    OutOfBox,
50    /// Needs a one-time customer action first; the string says what.
51    RequiresOneTimeStep(&'static str),
52}
53
54/// One curated model: the public id an app requests, the cloud that serves it,
55/// the upstream id the gateway forwards (for Azure this is the deployment name),
56/// and the protocol of its native endpoint.
57#[derive(Debug, Clone)]
58pub struct CatalogModel {
59    pub public_id: &'static str,
60    pub cloud: Platform,
61    pub upstream_id: &'static str,
62    pub client_apis: &'static [ClientApi],
63    pub provider_api: ProviderApi,
64}
65
66/// One model served directly by Anthropic. This is separate from `CatalogModel`:
67/// direct Anthropic is a provider connection, not a deployable cloud platform.
68#[derive(Debug, Clone, Copy)]
69pub struct DirectAnthropicModel {
70    pub public_id: &'static str,
71    pub upstream_id: &'static str,
72}
73
74/// One OpenAI model qualified through the Gateway's direct-provider route.
75///
76/// OpenAI's account model listing also contains embeddings, image, audio,
77/// moderation, realtime, and other APIs. Keep this list explicit so
78/// `GET /v1/models` never claims that an observed provider model is callable
79/// through Chat Completions or Responses when it is not.
80#[derive(Debug, Clone, Copy)]
81pub struct DirectOpenAiModel {
82    pub public_id: &'static str,
83    pub client_apis: &'static [ClientApi],
84}
85
86/// One Databricks-hosted model service qualified through Unity AI Gateway.
87#[derive(Debug, Clone, Copy)]
88pub struct DirectDatabricksModel {
89    pub public_id: &'static str,
90    pub upstream_id: &'static str,
91    pub client_apis: &'static [ClientApi],
92}
93
94impl DirectAnthropicModel {
95    pub fn display_name(&self) -> &'static str {
96        resolve(self.public_id)
97            .map(CatalogModel::display_name)
98            .unwrap_or(self.public_id)
99    }
100}
101
102impl CatalogModel {
103    /// The model's publisher, for grouping in a picker. Derived from the public id,
104    /// so the same public id reports the same provider on every cloud.
105    pub fn provider(&self) -> &'static str {
106        let id = self.public_id;
107        if id.starts_with("claude") {
108            "anthropic"
109        } else if id.starts_with("gpt") || id == "model-router" {
110            "openai"
111        } else if id.starts_with("gemini") || id.starts_with("gemma") {
112            "google"
113        } else if id.starts_with("qwen") {
114            "qwen"
115        } else if id.starts_with("deepseek") {
116            "deepseek"
117        } else if id.starts_with("mistral")
118            || id.starts_with("devstral")
119            || id.starts_with("magistral")
120            || id.starts_with("ministral")
121        {
122            "mistral"
123        } else if id.starts_with("minimax") {
124            "minimax"
125        } else if id.starts_with("kimi") {
126            "moonshotai"
127        } else if id.starts_with("nemotron") {
128            "nvidia"
129        } else if id.starts_with("glm") {
130            "zai"
131        } else if id.starts_with("palmyra") {
132            "writer"
133        } else {
134            "unknown"
135        }
136    }
137
138    /// A human label for a model picker. Curated per id rather than derived so the
139    /// acronyms (GPT, OSS, GLM, VL) and versions read correctly.
140    pub fn display_name(&self) -> &'static str {
141        match self.public_id {
142            "gpt-5.6-sol" => "GPT-5.6 Sol",
143            "gpt-5.6-terra" => "GPT-5.6 Terra",
144            "gpt-5.6-luna" => "GPT-5.6 Luna",
145            "gpt-5.5" => "GPT-5.5",
146            "gpt-5.4" => "GPT-5.4",
147            "gpt-oss-20b" => "GPT-OSS 20B",
148            "gpt-oss-120b" => "GPT-OSS 120B",
149            "gpt-oss-safeguard-20b" => "GPT-OSS Safeguard 20B",
150            "gpt-oss-safeguard-120b" => "GPT-OSS Safeguard 120B",
151            "deepseek-v3.2" => "DeepSeek V3.2",
152            "qwen3-32b" => "Qwen3 32B",
153            "qwen3-coder-30b" => "Qwen3 Coder 30B",
154            "qwen3-next-80b" => "Qwen3 Next 80B",
155            "qwen3-vl-235b" => "Qwen3 VL 235B",
156            "mistral-large-3" => "Mistral Large 3",
157            "devstral-2" => "Devstral 2",
158            "magistral-small" => "Magistral Small",
159            "ministral-3-14b" => "Ministral 3 14B",
160            "ministral-3-8b" => "Ministral 3 8B",
161            "ministral-3-3b" => "Ministral 3 3B",
162            "minimax-m2" => "MiniMax M2",
163            "minimax-m2.1" => "MiniMax M2.1",
164            "minimax-m2.5" => "MiniMax M2.5",
165            "kimi-k2.5" => "Kimi K2.5",
166            "nemotron-nano-9b" => "Nemotron Nano 9B",
167            "nemotron-nano-12b" => "Nemotron Nano 12B",
168            "nemotron-nano-3-30b" => "Nemotron Nano 3 30B",
169            "nemotron-super-3-120b" => "Nemotron Super 3 120B",
170            "gemma-3-4b" => "Gemma 3 4B",
171            "gemma-3-12b" => "Gemma 3 12B",
172            "gemma-3-27b" => "Gemma 3 27B",
173            "glm-4.7" => "GLM 4.7",
174            "glm-4.7-flash" => "GLM 4.7 Flash",
175            "glm-5" => "GLM 5",
176            "palmyra-vision-7b" => "Palmyra Vision 7B",
177            "claude-opus-5" => "Claude Opus 5",
178            "claude-sonnet-5" => "Claude Sonnet 5",
179            "claude-opus-4.8" => "Claude Opus 4.8",
180            "claude-opus-4.7" => "Claude Opus 4.7",
181            "claude-opus-4.6" => "Claude Opus 4.6",
182            "claude-opus-4.5" => "Claude Opus 4.5",
183            "claude-sonnet-4.6" => "Claude Sonnet 4.6",
184            "claude-sonnet-4.5" => "Claude Sonnet 4.5",
185            "claude-haiku-4.5" => "Claude Haiku 4.5",
186            "claude-fable-5" => "Claude Fable 5",
187            "gemini-2.5-pro" => "Gemini 2.5 Pro",
188            "gemini-2.5-flash" => "Gemini 2.5 Flash",
189            "gemini-2.5-flash-lite" => "Gemini 2.5 Flash Lite",
190            "gemini-3.5-flash" => "Gemini 3.5 Flash",
191            "gemini-3.1-flash-lite" => "Gemini 3.1 Flash Lite",
192            "gpt-4.1" => "GPT-4.1",
193            "gpt-4o-mini" => "GPT-4o mini",
194            "model-router" => "Model Router",
195            other => other,
196        }
197    }
198
199    /// The one-time enablement step for this model on its cloud, if any. Only Claude
200    /// needs one today, and the step differs per cloud.
201    pub fn activation(&self) -> Activation {
202        if !self.public_id.starts_with("claude") {
203            return Activation::OutOfBox;
204        }
205        match self.cloud {
206            Platform::Aws => Activation::RequiresOneTimeStep(
207                "Submit the one-time Anthropic use-case form in the Bedrock console.",
208            ),
209            Platform::Gcp => Activation::RequiresOneTimeStep(
210                "Enable Claude in Vertex AI Model Garden and accept Anthropic's terms of service, one-time, in the Google Cloud console.",
211            ),
212            Platform::Azure => Activation::RequiresOneTimeStep(
213                "Accept the Marketplace terms and create the Claude deployment in the Microsoft Foundry portal (one-time).",
214            ),
215            _ => Activation::OutOfBox,
216        }
217    }
218}
219
220static CATALOG: &[CatalogModel] = &[
221    // AWS Bedrock over `/openai/v1` chat completions. The plain Bedrock model id,
222    // not the `us.*` cross-region inference profile — that endpoint rejects it.
223    // Invoke/Converse-only models (older Llama/Mistral-v0/Nova) can't be served here.
224    // The GPT-5 family is Responses-only: chat completions, converse and invoke are
225    // all unavailable, so `upstream_id` here is the mantle id.
226    CatalogModel {
227        public_id: "gpt-5.6-sol",
228        cloud: Platform::Aws,
229        upstream_id: "openai.gpt-5.6-sol",
230        client_apis: &[ClientApi::OpenAiResponses],
231        provider_api: ProviderApi::OpenAiResponses,
232    },
233    CatalogModel {
234        public_id: "gpt-5.6-terra",
235        cloud: Platform::Aws,
236        upstream_id: "openai.gpt-5.6-terra",
237        client_apis: &[ClientApi::OpenAiResponses],
238        provider_api: ProviderApi::OpenAiResponses,
239    },
240    CatalogModel {
241        public_id: "gpt-5.6-luna",
242        cloud: Platform::Aws,
243        upstream_id: "openai.gpt-5.6-luna",
244        client_apis: &[ClientApi::OpenAiResponses],
245        provider_api: ProviderApi::OpenAiResponses,
246    },
247    CatalogModel {
248        public_id: "gpt-5.5",
249        cloud: Platform::Aws,
250        upstream_id: "openai.gpt-5.5",
251        client_apis: &[ClientApi::OpenAiResponses],
252        provider_api: ProviderApi::OpenAiResponses,
253    },
254    CatalogModel {
255        public_id: "gpt-5.4",
256        cloud: Platform::Aws,
257        upstream_id: "openai.gpt-5.4",
258        client_apis: &[ClientApi::OpenAiResponses],
259        provider_api: ProviderApi::OpenAiResponses,
260    },
261    CatalogModel {
262        public_id: "gpt-oss-20b",
263        cloud: Platform::Aws,
264        upstream_id: "openai.gpt-oss-20b-1:0",
265        client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses],
266        provider_api: ProviderApi::OpenAi,
267    },
268    CatalogModel {
269        public_id: "gpt-oss-120b",
270        cloud: Platform::Aws,
271        upstream_id: "openai.gpt-oss-120b-1:0",
272        client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses],
273        provider_api: ProviderApi::OpenAi,
274    },
275    CatalogModel {
276        public_id: "gpt-oss-safeguard-20b",
277        cloud: Platform::Aws,
278        upstream_id: "openai.gpt-oss-safeguard-20b",
279        client_apis: &[ClientApi::OpenAiChatCompletions],
280        provider_api: ProviderApi::OpenAi,
281    },
282    CatalogModel {
283        public_id: "gpt-oss-safeguard-120b",
284        cloud: Platform::Aws,
285        upstream_id: "openai.gpt-oss-safeguard-120b",
286        client_apis: &[ClientApi::OpenAiChatCompletions],
287        provider_api: ProviderApi::OpenAi,
288    },
289    CatalogModel {
290        public_id: "deepseek-v3.2",
291        cloud: Platform::Aws,
292        upstream_id: "deepseek.v3.2",
293        client_apis: &[ClientApi::OpenAiChatCompletions],
294        provider_api: ProviderApi::OpenAi,
295    },
296    CatalogModel {
297        public_id: "qwen3-32b",
298        cloud: Platform::Aws,
299        upstream_id: "qwen.qwen3-32b-v1:0",
300        client_apis: &[ClientApi::OpenAiChatCompletions],
301        provider_api: ProviderApi::OpenAi,
302    },
303    CatalogModel {
304        public_id: "qwen3-coder-30b",
305        cloud: Platform::Aws,
306        upstream_id: "qwen.qwen3-coder-30b-a3b-v1:0",
307        client_apis: &[ClientApi::OpenAiChatCompletions],
308        provider_api: ProviderApi::OpenAi,
309    },
310    CatalogModel {
311        public_id: "qwen3-next-80b",
312        cloud: Platform::Aws,
313        upstream_id: "qwen.qwen3-next-80b-a3b",
314        client_apis: &[ClientApi::OpenAiChatCompletions],
315        provider_api: ProviderApi::OpenAi,
316    },
317    CatalogModel {
318        public_id: "qwen3-vl-235b",
319        cloud: Platform::Aws,
320        upstream_id: "qwen.qwen3-vl-235b-a22b",
321        client_apis: &[ClientApi::OpenAiChatCompletions],
322        provider_api: ProviderApi::OpenAi,
323    },
324    CatalogModel {
325        public_id: "mistral-large-3",
326        cloud: Platform::Aws,
327        upstream_id: "mistral.mistral-large-3-675b-instruct",
328        client_apis: &[ClientApi::OpenAiChatCompletions],
329        provider_api: ProviderApi::OpenAi,
330    },
331    CatalogModel {
332        public_id: "devstral-2",
333        cloud: Platform::Aws,
334        upstream_id: "mistral.devstral-2-123b",
335        client_apis: &[ClientApi::OpenAiChatCompletions],
336        provider_api: ProviderApi::OpenAi,
337    },
338    CatalogModel {
339        public_id: "magistral-small",
340        cloud: Platform::Aws,
341        upstream_id: "mistral.magistral-small-2509",
342        client_apis: &[ClientApi::OpenAiChatCompletions],
343        provider_api: ProviderApi::OpenAi,
344    },
345    CatalogModel {
346        public_id: "ministral-3-14b",
347        cloud: Platform::Aws,
348        upstream_id: "mistral.ministral-3-14b-instruct",
349        client_apis: &[ClientApi::OpenAiChatCompletions],
350        provider_api: ProviderApi::OpenAi,
351    },
352    CatalogModel {
353        public_id: "ministral-3-8b",
354        cloud: Platform::Aws,
355        upstream_id: "mistral.ministral-3-8b-instruct",
356        client_apis: &[ClientApi::OpenAiChatCompletions],
357        provider_api: ProviderApi::OpenAi,
358    },
359    CatalogModel {
360        public_id: "ministral-3-3b",
361        cloud: Platform::Aws,
362        upstream_id: "mistral.ministral-3-3b-instruct",
363        client_apis: &[ClientApi::OpenAiChatCompletions],
364        provider_api: ProviderApi::OpenAi,
365    },
366    CatalogModel {
367        public_id: "minimax-m2",
368        cloud: Platform::Aws,
369        upstream_id: "minimax.minimax-m2",
370        client_apis: &[ClientApi::OpenAiChatCompletions],
371        provider_api: ProviderApi::OpenAi,
372    },
373    CatalogModel {
374        public_id: "minimax-m2.1",
375        cloud: Platform::Aws,
376        upstream_id: "minimax.minimax-m2.1",
377        client_apis: &[ClientApi::OpenAiChatCompletions],
378        provider_api: ProviderApi::OpenAi,
379    },
380    CatalogModel {
381        public_id: "minimax-m2.5",
382        cloud: Platform::Aws,
383        upstream_id: "minimax.minimax-m2.5",
384        client_apis: &[ClientApi::OpenAiChatCompletions],
385        provider_api: ProviderApi::OpenAi,
386    },
387    CatalogModel {
388        public_id: "kimi-k2.5",
389        cloud: Platform::Aws,
390        upstream_id: "moonshotai.kimi-k2.5",
391        client_apis: &[ClientApi::OpenAiChatCompletions],
392        provider_api: ProviderApi::OpenAi,
393    },
394    CatalogModel {
395        public_id: "nemotron-nano-9b",
396        cloud: Platform::Aws,
397        upstream_id: "nvidia.nemotron-nano-9b-v2",
398        client_apis: &[ClientApi::OpenAiChatCompletions],
399        provider_api: ProviderApi::OpenAi,
400    },
401    CatalogModel {
402        public_id: "nemotron-nano-12b",
403        cloud: Platform::Aws,
404        upstream_id: "nvidia.nemotron-nano-12b-v2",
405        client_apis: &[ClientApi::OpenAiChatCompletions],
406        provider_api: ProviderApi::OpenAi,
407    },
408    CatalogModel {
409        public_id: "nemotron-nano-3-30b",
410        cloud: Platform::Aws,
411        upstream_id: "nvidia.nemotron-nano-3-30b",
412        client_apis: &[ClientApi::OpenAiChatCompletions],
413        provider_api: ProviderApi::OpenAi,
414    },
415    CatalogModel {
416        public_id: "nemotron-super-3-120b",
417        cloud: Platform::Aws,
418        upstream_id: "nvidia.nemotron-super-3-120b",
419        client_apis: &[ClientApi::OpenAiChatCompletions],
420        provider_api: ProviderApi::OpenAi,
421    },
422    CatalogModel {
423        public_id: "gemma-3-4b",
424        cloud: Platform::Aws,
425        upstream_id: "google.gemma-3-4b-it",
426        client_apis: &[ClientApi::OpenAiChatCompletions],
427        provider_api: ProviderApi::OpenAi,
428    },
429    CatalogModel {
430        public_id: "gemma-3-12b",
431        cloud: Platform::Aws,
432        upstream_id: "google.gemma-3-12b-it",
433        client_apis: &[ClientApi::OpenAiChatCompletions],
434        provider_api: ProviderApi::OpenAi,
435    },
436    CatalogModel {
437        public_id: "gemma-3-27b",
438        cloud: Platform::Aws,
439        upstream_id: "google.gemma-3-27b-it",
440        client_apis: &[ClientApi::OpenAiChatCompletions],
441        provider_api: ProviderApi::OpenAi,
442    },
443    CatalogModel {
444        public_id: "glm-4.7",
445        cloud: Platform::Aws,
446        upstream_id: "zai.glm-4.7",
447        client_apis: &[ClientApi::OpenAiChatCompletions],
448        provider_api: ProviderApi::OpenAi,
449    },
450    CatalogModel {
451        public_id: "glm-4.7-flash",
452        cloud: Platform::Aws,
453        upstream_id: "zai.glm-4.7-flash",
454        client_apis: &[ClientApi::OpenAiChatCompletions],
455        provider_api: ProviderApi::OpenAi,
456    },
457    CatalogModel {
458        public_id: "glm-5",
459        cloud: Platform::Aws,
460        upstream_id: "zai.glm-5",
461        client_apis: &[ClientApi::OpenAiChatCompletions],
462        provider_api: ProviderApi::OpenAi,
463    },
464    CatalogModel {
465        public_id: "palmyra-vision-7b",
466        cloud: Platform::Aws,
467        upstream_id: "writer.palmyra-vision-7b",
468        client_apis: &[ClientApi::OpenAiChatCompletions],
469        provider_api: ProviderApi::OpenAi,
470    },
471    // AWS Bedrock, Claude over classic InvokeModel (the Anthropic Messages body is
472    // the InvokeModel body; the model travels in the URL). `upstream_id` is the plain
473    // Bedrock model id; the gateway prepends the region's cross-region inference-profile
474    // geo prefix (`us.`/`eu.`/`apac.`) at request time, since Claude is invocable only
475    // through a profile. Dated ids (`…-<date>-v1:0`) are required where AWS has no short
476    // alias. These need Claude model access granted on the deployment's account.
477    CatalogModel {
478        public_id: "claude-opus-5",
479        cloud: Platform::Aws,
480        upstream_id: "anthropic.claude-opus-5",
481        client_apis: &[ClientApi::AnthropicMessages],
482        provider_api: ProviderApi::Anthropic,
483    },
484    CatalogModel {
485        public_id: "claude-sonnet-5",
486        cloud: Platform::Aws,
487        upstream_id: "anthropic.claude-sonnet-5",
488        client_apis: &[ClientApi::AnthropicMessages],
489        provider_api: ProviderApi::Anthropic,
490    },
491    CatalogModel {
492        public_id: "claude-opus-4.8",
493        cloud: Platform::Aws,
494        upstream_id: "anthropic.claude-opus-4-8",
495        client_apis: &[ClientApi::AnthropicMessages],
496        provider_api: ProviderApi::Anthropic,
497    },
498    CatalogModel {
499        public_id: "claude-opus-4.7",
500        cloud: Platform::Aws,
501        upstream_id: "anthropic.claude-opus-4-7",
502        client_apis: &[ClientApi::AnthropicMessages],
503        provider_api: ProviderApi::Anthropic,
504    },
505    CatalogModel {
506        public_id: "claude-opus-4.6",
507        cloud: Platform::Aws,
508        upstream_id: "anthropic.claude-opus-4-6-v1",
509        client_apis: &[ClientApi::AnthropicMessages],
510        provider_api: ProviderApi::Anthropic,
511    },
512    CatalogModel {
513        public_id: "claude-opus-4.5",
514        cloud: Platform::Aws,
515        upstream_id: "anthropic.claude-opus-4-5-20251101-v1:0",
516        client_apis: &[ClientApi::AnthropicMessages],
517        provider_api: ProviderApi::Anthropic,
518    },
519    CatalogModel {
520        public_id: "claude-sonnet-4.6",
521        cloud: Platform::Aws,
522        upstream_id: "anthropic.claude-sonnet-4-6",
523        client_apis: &[ClientApi::AnthropicMessages],
524        provider_api: ProviderApi::Anthropic,
525    },
526    CatalogModel {
527        public_id: "claude-sonnet-4.5",
528        cloud: Platform::Aws,
529        upstream_id: "anthropic.claude-sonnet-4-5-20250929-v1:0",
530        client_apis: &[ClientApi::AnthropicMessages],
531        provider_api: ProviderApi::Anthropic,
532    },
533    CatalogModel {
534        public_id: "claude-haiku-4.5",
535        cloud: Platform::Aws,
536        upstream_id: "anthropic.claude-haiku-4-5-20251001-v1:0",
537        client_apis: &[ClientApi::AnthropicMessages],
538        provider_api: ProviderApi::Anthropic,
539    },
540    CatalogModel {
541        public_id: "claude-fable-5",
542        cloud: Platform::Aws,
543        upstream_id: "anthropic.claude-fable-5",
544        client_apis: &[ClientApi::AnthropicMessages],
545        provider_api: ProviderApi::Anthropic,
546    },
547    // GCP Vertex, Gemini. The OpenAI-compatible Vertex endpoint expects the `google/` prefix.
548    // The 2.5 family serves in-region; the 3.x models serve on the `global` location.
549    CatalogModel {
550        public_id: "gemini-2.5-pro",
551        cloud: Platform::Gcp,
552        upstream_id: "google/gemini-2.5-pro",
553        client_apis: &[ClientApi::OpenAiChatCompletions],
554        provider_api: ProviderApi::OpenAi,
555    },
556    CatalogModel {
557        public_id: "gemini-2.5-flash",
558        cloud: Platform::Gcp,
559        upstream_id: "google/gemini-2.5-flash",
560        client_apis: &[ClientApi::OpenAiChatCompletions],
561        provider_api: ProviderApi::OpenAi,
562    },
563    CatalogModel {
564        public_id: "gemini-2.5-flash-lite",
565        cloud: Platform::Gcp,
566        upstream_id: "google/gemini-2.5-flash-lite",
567        client_apis: &[ClientApi::OpenAiChatCompletions],
568        provider_api: ProviderApi::OpenAi,
569    },
570    CatalogModel {
571        public_id: "gemini-3.5-flash",
572        cloud: Platform::Gcp,
573        upstream_id: "google/gemini-3.5-flash",
574        client_apis: &[ClientApi::OpenAiChatCompletions],
575        provider_api: ProviderApi::OpenAi,
576    },
577    CatalogModel {
578        public_id: "gemini-3.1-flash-lite",
579        cloud: Platform::Gcp,
580        upstream_id: "google/gemini-3.1-flash-lite",
581        client_apis: &[ClientApi::OpenAiChatCompletions],
582        provider_api: ProviderApi::OpenAi,
583    },
584    // GCP Vertex, Claude. The upstream id is the Vertex Model Garden id that travels
585    // in the `:rawPredict` URL path (`publishers/anthropic/models/<id>`); models past
586    // Sonnet 4.5 carry no date suffix, older ones keep an `@<date>` version. Needs
587    // Claude model access granted on the deployment's project.
588    CatalogModel {
589        public_id: "claude-opus-5",
590        cloud: Platform::Gcp,
591        upstream_id: "claude-opus-5",
592        client_apis: &[ClientApi::AnthropicMessages],
593        provider_api: ProviderApi::Anthropic,
594    },
595    CatalogModel {
596        public_id: "claude-sonnet-5",
597        cloud: Platform::Gcp,
598        upstream_id: "claude-sonnet-5",
599        client_apis: &[ClientApi::AnthropicMessages],
600        provider_api: ProviderApi::Anthropic,
601    },
602    CatalogModel {
603        public_id: "claude-opus-4.8",
604        cloud: Platform::Gcp,
605        upstream_id: "claude-opus-4-8",
606        client_apis: &[ClientApi::AnthropicMessages],
607        provider_api: ProviderApi::Anthropic,
608    },
609    CatalogModel {
610        public_id: "claude-opus-4.7",
611        cloud: Platform::Gcp,
612        upstream_id: "claude-opus-4-7",
613        client_apis: &[ClientApi::AnthropicMessages],
614        provider_api: ProviderApi::Anthropic,
615    },
616    CatalogModel {
617        public_id: "claude-opus-4.6",
618        cloud: Platform::Gcp,
619        upstream_id: "claude-opus-4-6",
620        client_apis: &[ClientApi::AnthropicMessages],
621        provider_api: ProviderApi::Anthropic,
622    },
623    CatalogModel {
624        public_id: "claude-opus-4.5",
625        cloud: Platform::Gcp,
626        upstream_id: "claude-opus-4-5@20251101",
627        client_apis: &[ClientApi::AnthropicMessages],
628        provider_api: ProviderApi::Anthropic,
629    },
630    CatalogModel {
631        public_id: "claude-sonnet-4.6",
632        cloud: Platform::Gcp,
633        upstream_id: "claude-sonnet-4-6",
634        client_apis: &[ClientApi::AnthropicMessages],
635        provider_api: ProviderApi::Anthropic,
636    },
637    CatalogModel {
638        public_id: "claude-sonnet-4.5",
639        cloud: Platform::Gcp,
640        upstream_id: "claude-sonnet-4-5@20250929",
641        client_apis: &[ClientApi::AnthropicMessages],
642        provider_api: ProviderApi::Anthropic,
643    },
644    CatalogModel {
645        public_id: "claude-haiku-4.5",
646        cloud: Platform::Gcp,
647        upstream_id: "claude-haiku-4-5@20251001",
648        client_apis: &[ClientApi::AnthropicMessages],
649        provider_api: ProviderApi::Anthropic,
650    },
651    CatalogModel {
652        public_id: "claude-fable-5",
653        cloud: Platform::Gcp,
654        upstream_id: "claude-fable-5",
655        client_apis: &[ClientApi::AnthropicMessages],
656        provider_api: ProviderApi::Anthropic,
657    },
658    // Azure, OpenAI-protocol. The upstream id is the deployment name the controller
659    // creates (see AZURE_DEPLOYMENTS); the app requests it by the same id. Azure serves
660    // only what is deployed, so this list must stay in sync with AZURE_DEPLOYMENTS.
661    CatalogModel {
662        public_id: "gpt-4.1",
663        cloud: Platform::Azure,
664        upstream_id: "gpt-4.1",
665        client_apis: &[ClientApi::OpenAiChatCompletions],
666        provider_api: ProviderApi::OpenAi,
667    },
668    CatalogModel {
669        public_id: "gpt-4o-mini",
670        cloud: Platform::Azure,
671        upstream_id: "gpt-4o-mini",
672        client_apis: &[ClientApi::OpenAiChatCompletions],
673        provider_api: ProviderApi::OpenAi,
674    },
675    CatalogModel {
676        public_id: "model-router",
677        cloud: Platform::Azure,
678        upstream_id: "model-router",
679        client_apis: &[ClientApi::OpenAiChatCompletions],
680        provider_api: ProviderApi::OpenAi,
681    },
682    // Azure, Claude over the Foundry Anthropic endpoint. The upstream id is the
683    // Foundry deployment name (defaults to the model id). Unlike the OpenAI list,
684    // these are not in AZURE_DEPLOYMENTS: a first Claude deployment requires
685    // accepting Azure Marketplace terms, a portal step the controller cannot
686    // perform, so Claude deployments are created in the Foundry portal. These stay
687    // in the catalog as the deployment-name mapping. The resource heartbeat lists
688    // actual deployments, so the gateway omits Claude until that deployment exists.
689    CatalogModel {
690        public_id: "claude-opus-5",
691        cloud: Platform::Azure,
692        upstream_id: "claude-opus-5",
693        client_apis: &[ClientApi::AnthropicMessages],
694        provider_api: ProviderApi::Anthropic,
695    },
696    CatalogModel {
697        public_id: "claude-sonnet-5",
698        cloud: Platform::Azure,
699        upstream_id: "claude-sonnet-5",
700        client_apis: &[ClientApi::AnthropicMessages],
701        provider_api: ProviderApi::Anthropic,
702    },
703    CatalogModel {
704        public_id: "claude-opus-4.8",
705        cloud: Platform::Azure,
706        upstream_id: "claude-opus-4-8",
707        client_apis: &[ClientApi::AnthropicMessages],
708        provider_api: ProviderApi::Anthropic,
709    },
710    CatalogModel {
711        public_id: "claude-opus-4.7",
712        cloud: Platform::Azure,
713        upstream_id: "claude-opus-4-7",
714        client_apis: &[ClientApi::AnthropicMessages],
715        provider_api: ProviderApi::Anthropic,
716    },
717    CatalogModel {
718        public_id: "claude-opus-4.6",
719        cloud: Platform::Azure,
720        upstream_id: "claude-opus-4-6",
721        client_apis: &[ClientApi::AnthropicMessages],
722        provider_api: ProviderApi::Anthropic,
723    },
724    CatalogModel {
725        public_id: "claude-opus-4.5",
726        cloud: Platform::Azure,
727        upstream_id: "claude-opus-4-5",
728        client_apis: &[ClientApi::AnthropicMessages],
729        provider_api: ProviderApi::Anthropic,
730    },
731    CatalogModel {
732        public_id: "claude-sonnet-4.6",
733        cloud: Platform::Azure,
734        upstream_id: "claude-sonnet-4-6",
735        client_apis: &[ClientApi::AnthropicMessages],
736        provider_api: ProviderApi::Anthropic,
737    },
738    CatalogModel {
739        public_id: "claude-sonnet-4.5",
740        cloud: Platform::Azure,
741        upstream_id: "claude-sonnet-4-5",
742        client_apis: &[ClientApi::AnthropicMessages],
743        provider_api: ProviderApi::Anthropic,
744    },
745    CatalogModel {
746        public_id: "claude-haiku-4.5",
747        cloud: Platform::Azure,
748        upstream_id: "claude-haiku-4-5",
749        client_apis: &[ClientApi::AnthropicMessages],
750        provider_api: ProviderApi::Anthropic,
751    },
752    CatalogModel {
753        public_id: "claude-fable-5",
754        cloud: Platform::Azure,
755        upstream_id: "claude-fable-5",
756        client_apis: &[ClientApi::AnthropicMessages],
757        provider_api: ProviderApi::Anthropic,
758    },
759];
760
761/// Changes whenever public model ids or their supported client APIs change.
762/// Heartbeat consumers use this to distinguish an old observation from a
763/// current catalog without duplicating the catalog in durable state.
764pub const AI_CATALOG_REVISION: &str = "2026-08-09.1";
765
766/// Azure deployments to create at provision time: (deployment name, model name,
767/// model version). The deployment name is the catalog `upstream_id`. The version
768/// is validated against the target region's model catalog at deploy time.
769static AZURE_DEPLOYMENTS: &[(&str, &str, &str)] = &[
770    ("gpt-4.1", "gpt-4.1", "2025-04-14"),
771    ("model-router", "model-router", "2025-11-18"),
772];
773
774/// Direct Anthropic aliases qualified for the native Messages API. Keep this
775/// explicit: the three cloud providers use different upstream IDs and cannot be
776/// used as an accidental source of direct-provider routing data.
777static DIRECT_ANTHROPIC_MODELS: &[DirectAnthropicModel] = &[
778    DirectAnthropicModel {
779        public_id: "claude-opus-5",
780        upstream_id: "claude-opus-5",
781    },
782    DirectAnthropicModel {
783        public_id: "claude-sonnet-5",
784        upstream_id: "claude-sonnet-5",
785    },
786    DirectAnthropicModel {
787        public_id: "claude-opus-4.8",
788        upstream_id: "claude-opus-4-8",
789    },
790    DirectAnthropicModel {
791        public_id: "claude-opus-4.7",
792        upstream_id: "claude-opus-4-7",
793    },
794    DirectAnthropicModel {
795        public_id: "claude-opus-4.6",
796        upstream_id: "claude-opus-4-6",
797    },
798    DirectAnthropicModel {
799        public_id: "claude-opus-4.5",
800        upstream_id: "claude-opus-4-5",
801    },
802    DirectAnthropicModel {
803        public_id: "claude-sonnet-4.6",
804        upstream_id: "claude-sonnet-4-6",
805    },
806    DirectAnthropicModel {
807        public_id: "claude-sonnet-4.5",
808        upstream_id: "claude-sonnet-4-5-20250929",
809    },
810    DirectAnthropicModel {
811        public_id: "claude-haiku-4.5",
812        upstream_id: "claude-haiku-4-5-20251001",
813    },
814    DirectAnthropicModel {
815        public_id: "claude-fable-5",
816        upstream_id: "claude-fable-5",
817    },
818];
819
820/// Direct OpenAI models qualified end to end through the Gateway.
821///
822/// Add a model only after exercising every client API listed for it against a
823/// real provider account. Provider discovery is then used as the account-level
824/// availability filter; discovery alone is not sufficient to expose a model.
825///
826/// Every entry below was exercised live (2026-08-09) against both
827/// `/v1/chat/completions` and `/v1/responses`. Note the GPT-5 family answers
828/// both APIs here, unlike on bedrock-mantle where it is Responses-only.
829static DIRECT_OPENAI_MODELS: &[DirectOpenAiModel] = &[
830    DirectOpenAiModel {
831        public_id: "gpt-5.6-sol",
832        client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses],
833    },
834    DirectOpenAiModel {
835        public_id: "gpt-5.6-terra",
836        client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses],
837    },
838    DirectOpenAiModel {
839        public_id: "gpt-5.6-luna",
840        client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses],
841    },
842    DirectOpenAiModel {
843        public_id: "gpt-5.5",
844        client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses],
845    },
846    DirectOpenAiModel {
847        public_id: "gpt-5.4",
848        client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses],
849    },
850    DirectOpenAiModel {
851        public_id: "gpt-4.1",
852        client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses],
853    },
854    DirectOpenAiModel {
855        public_id: "gpt-4.1-mini",
856        client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses],
857    },
858    DirectOpenAiModel {
859        public_id: "gpt-4o-mini",
860        client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses],
861    },
862];
863
864/// Generally available Databricks-hosted text-generation models that have an
865/// exact Alien public-model ID, reviewed against Databricks' supported-models
866/// catalog on 2026-08-10. Preview, deprecated, embedding, and image-generation
867/// models are intentionally excluded. Credential verification is separate from
868/// model access: Databricks can accept OAuth while a service is disabled by quota.
869/// Source: <https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models>
870static DIRECT_DATABRICKS_MODELS: &[DirectDatabricksModel] = &[
871    DirectDatabricksModel {
872        public_id: "gpt-5.6-sol",
873        upstream_id: "databricks-gpt-5-6-sol",
874        client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses],
875    },
876    DirectDatabricksModel {
877        public_id: "gpt-5.6-terra",
878        upstream_id: "databricks-gpt-5-6-terra",
879        client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses],
880    },
881    DirectDatabricksModel {
882        public_id: "gpt-5.6-luna",
883        upstream_id: "databricks-gpt-5-6-luna",
884        client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses],
885    },
886    DirectDatabricksModel {
887        public_id: "gpt-5.5",
888        upstream_id: "databricks-gpt-5-5",
889        client_apis: &[ClientApi::OpenAiResponses],
890    },
891    DirectDatabricksModel {
892        public_id: "gpt-5.4",
893        upstream_id: "databricks-gpt-5-4",
894        client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses],
895    },
896    DirectDatabricksModel {
897        public_id: "claude-haiku-4.5",
898        upstream_id: "databricks-claude-haiku-4-5",
899        client_apis: &[
900            ClientApi::OpenAiChatCompletions,
901            ClientApi::OpenAiResponses,
902            ClientApi::AnthropicMessages,
903        ],
904    },
905    DirectDatabricksModel {
906        public_id: "claude-opus-5",
907        upstream_id: "system.ai.claude-opus-5",
908        client_apis: &[
909            ClientApi::OpenAiChatCompletions,
910            ClientApi::OpenAiResponses,
911            ClientApi::AnthropicMessages,
912        ],
913    },
914    DirectDatabricksModel {
915        public_id: "claude-sonnet-5",
916        upstream_id: "databricks-claude-sonnet-5",
917        client_apis: &[
918            ClientApi::OpenAiChatCompletions,
919            ClientApi::OpenAiResponses,
920            ClientApi::AnthropicMessages,
921        ],
922    },
923    DirectDatabricksModel {
924        public_id: "claude-sonnet-4.6",
925        upstream_id: "databricks-claude-sonnet-4-6",
926        client_apis: &[
927            ClientApi::OpenAiChatCompletions,
928            ClientApi::OpenAiResponses,
929            ClientApi::AnthropicMessages,
930        ],
931    },
932    DirectDatabricksModel {
933        public_id: "claude-sonnet-4.5",
934        upstream_id: "databricks-claude-sonnet-4-5",
935        client_apis: &[
936            ClientApi::OpenAiChatCompletions,
937            ClientApi::OpenAiResponses,
938            ClientApi::AnthropicMessages,
939        ],
940    },
941    DirectDatabricksModel {
942        public_id: "claude-fable-5",
943        upstream_id: "databricks-claude-fable-5",
944        client_apis: &[
945            ClientApi::OpenAiChatCompletions,
946            ClientApi::OpenAiResponses,
947            ClientApi::AnthropicMessages,
948        ],
949    },
950    DirectDatabricksModel {
951        public_id: "claude-opus-4.8",
952        upstream_id: "databricks-claude-opus-4-8",
953        client_apis: &[
954            ClientApi::OpenAiChatCompletions,
955            ClientApi::OpenAiResponses,
956            ClientApi::AnthropicMessages,
957        ],
958    },
959    DirectDatabricksModel {
960        public_id: "claude-opus-4.7",
961        upstream_id: "databricks-claude-opus-4-7",
962        client_apis: &[
963            ClientApi::OpenAiChatCompletions,
964            ClientApi::OpenAiResponses,
965            ClientApi::AnthropicMessages,
966        ],
967    },
968    DirectDatabricksModel {
969        public_id: "claude-opus-4.6",
970        upstream_id: "databricks-claude-opus-4-6",
971        client_apis: &[
972            ClientApi::OpenAiChatCompletions,
973            ClientApi::OpenAiResponses,
974            ClientApi::AnthropicMessages,
975        ],
976    },
977    DirectDatabricksModel {
978        public_id: "claude-opus-4.5",
979        upstream_id: "databricks-claude-opus-4-5",
980        client_apis: &[
981            ClientApi::OpenAiChatCompletions,
982            ClientApi::OpenAiResponses,
983            ClientApi::AnthropicMessages,
984        ],
985    },
986    DirectDatabricksModel {
987        public_id: "gemini-3.5-flash",
988        upstream_id: "databricks-gemini-3-5-flash",
989        client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses],
990    },
991    DirectDatabricksModel {
992        public_id: "gemini-3.1-flash-lite",
993        upstream_id: "databricks-gemini-3-1-flash-lite",
994        client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses],
995    },
996    DirectDatabricksModel {
997        public_id: "gpt-oss-120b",
998        upstream_id: "databricks-gpt-oss-120b",
999        client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses],
1000    },
1001    DirectDatabricksModel {
1002        public_id: "gpt-oss-20b",
1003        upstream_id: "databricks-gpt-oss-20b",
1004        client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses],
1005    },
1006    DirectDatabricksModel {
1007        public_id: "gemma-3-12b",
1008        upstream_id: "databricks-gemma-3-12b",
1009        client_apis: &[ClientApi::OpenAiChatCompletions, ClientApi::OpenAiResponses],
1010    },
1011];
1012
1013/// Where a model sits on the bedrock-mantle Responses API.
1014#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1015pub struct ResponsesTarget {
1016    /// The id mantle expects, which drops the InvokeModel version suffix.
1017    pub upstream_id: &'static str,
1018    /// Path under the mantle host. The GPT-5 family serves on `/openai/v1/responses`,
1019    /// the open-weight models on `/v1/responses`.
1020    pub path: &'static str,
1021}
1022
1023/// AWS models servable over the bedrock-mantle OpenAI Responses API. Only a subset
1024/// of the chat catalog supports Responses at all — Claude is Messages-only and e.g.
1025/// Qwen rejects it. Kept explicit rather than derived: both the id scheme and the
1026/// path differ per model family, not by a rule.
1027static RESPONSES_UPSTREAM: &[(&str, ResponsesTarget)] = &[
1028    (
1029        "gpt-oss-20b",
1030        ResponsesTarget {
1031            upstream_id: "openai.gpt-oss-20b",
1032            path: "/v1/responses",
1033        },
1034    ),
1035    (
1036        "gpt-oss-120b",
1037        ResponsesTarget {
1038            upstream_id: "openai.gpt-oss-120b",
1039            path: "/v1/responses",
1040        },
1041    ),
1042    (
1043        "gpt-5.6-sol",
1044        ResponsesTarget {
1045            upstream_id: "openai.gpt-5.6-sol",
1046            path: "/openai/v1/responses",
1047        },
1048    ),
1049    (
1050        "gpt-5.6-terra",
1051        ResponsesTarget {
1052            upstream_id: "openai.gpt-5.6-terra",
1053            path: "/openai/v1/responses",
1054        },
1055    ),
1056    (
1057        "gpt-5.6-luna",
1058        ResponsesTarget {
1059            upstream_id: "openai.gpt-5.6-luna",
1060            path: "/openai/v1/responses",
1061        },
1062    ),
1063    (
1064        "gpt-5.5",
1065        ResponsesTarget {
1066            upstream_id: "openai.gpt-5.5",
1067            path: "/openai/v1/responses",
1068        },
1069    ),
1070    (
1071        "gpt-5.4",
1072        ResponsesTarget {
1073            upstream_id: "openai.gpt-5.4",
1074            path: "/openai/v1/responses",
1075        },
1076    ),
1077];
1078
1079/// The bedrock-mantle Responses target for a public model id, or `None` when the
1080/// model is not servable over the Responses API.
1081pub fn responses_target(public_id: &str) -> Option<ResponsesTarget> {
1082    RESPONSES_UPSTREAM
1083        .iter()
1084        .find(|(public, _)| *public == public_id)
1085        .map(|(_, target)| *target)
1086}
1087
1088pub fn models_for(cloud: Platform) -> Vec<&'static CatalogModel> {
1089    CATALOG.iter().filter(|m| m.cloud == cloud).collect()
1090}
1091
1092pub fn direct_anthropic_models() -> Vec<&'static DirectAnthropicModel> {
1093    DIRECT_ANTHROPIC_MODELS.iter().collect()
1094}
1095
1096pub fn resolve_direct_anthropic(public_id: &str) -> Option<&'static DirectAnthropicModel> {
1097    DIRECT_ANTHROPIC_MODELS
1098        .iter()
1099        .find(|model| model.public_id == public_id)
1100}
1101
1102pub fn direct_openai_models() -> Vec<&'static DirectOpenAiModel> {
1103    DIRECT_OPENAI_MODELS.iter().collect()
1104}
1105
1106pub fn resolve_direct_openai(public_id: &str) -> Option<&'static DirectOpenAiModel> {
1107    DIRECT_OPENAI_MODELS
1108        .iter()
1109        .find(|model| model.public_id == public_id)
1110}
1111
1112pub fn direct_databricks_models() -> Vec<&'static DirectDatabricksModel> {
1113    DIRECT_DATABRICKS_MODELS.iter().collect()
1114}
1115
1116pub fn resolve_direct_databricks(public_id: &str) -> Option<&'static DirectDatabricksModel> {
1117    DIRECT_DATABRICKS_MODELS
1118        .iter()
1119        .find(|model| model.public_id == public_id)
1120}
1121
1122/// The catalog model for a public id, or `None` if it is not exposed.
1123///
1124/// First match: for an id serving on more than one cloud this is the AWS entry;
1125/// cloud-scoped callers use `lookup_for` via `resolve_for`.
1126pub fn lookup(public_id: &str) -> Option<&'static CatalogModel> {
1127    CATALOG.iter().find(|m| m.public_id == public_id)
1128}
1129
1130fn lookup_for(public_id: &str, cloud: Platform) -> Option<&'static CatalogModel> {
1131    CATALOG
1132        .iter()
1133        .find(|m| m.public_id == public_id && m.cloud == cloud)
1134}
1135
1136/// The catalog model for a client-sent model id on a specific cloud. A public id
1137/// can appear once per cloud (Claude serves on more than one), so resolution must
1138/// scope to the binding's cloud rather than filter a first-match lookup — the
1139/// first match is another cloud's entry whenever ids overlap.
1140pub fn resolve_for(model_id: &str, cloud: Platform) -> Option<&'static CatalogModel> {
1141    lookup_for(model_id, cloud).or_else(|| lookup_for(&canonical_public_id(model_id), cloud))
1142}
1143
1144/// The catalog model for a client-sent model id, accepting the Anthropic-native
1145/// spellings agent CLIs actually send alongside the catalog's public ids.
1146///
1147/// Claude Code's `/model` emits ids like `claude-sonnet-4-5-20250929` or
1148/// `claude-haiku-4-5`, Bedrock-aware clients may carry the full upstream id
1149/// (`us.anthropic.claude-haiku-4-5-20251001-v1:0`), and Vertex clients the
1150/// `@date` form (`claude-sonnet-4-5@20250929`). Exact public ids win; otherwise
1151/// the id is canonicalized — vendor/geo prefix, InvokeModel `-vN[:M]` suffix,
1152/// and either release-date suffix drop off, and a dashed minor version becomes
1153/// the catalog's dotted form (`claude-haiku-4-5` → `claude-haiku-4.5`).
1154///
1155/// A public id can appear once per cloud, and this returns the first catalog
1156/// entry — for a multi-cloud id that is the AWS one. Callers routing by a
1157/// binding must use `resolve_for` with the binding's cloud.
1158pub fn resolve(model_id: &str) -> Option<&'static CatalogModel> {
1159    lookup(model_id).or_else(|| lookup(&canonical_public_id(model_id)))
1160}
1161
1162fn canonical_public_id(model_id: &str) -> String {
1163    let mut id = model_id;
1164    if let Some(pos) = id.rfind("anthropic.") {
1165        id = &id[pos + "anthropic.".len()..];
1166    }
1167    // Vertex spells the release date as an `@` suffix rather than a dash.
1168    id = id.split_once('@').map_or(id, |(base, _)| base);
1169    id = strip_invoke_version(id);
1170    id = strip_release_date(id);
1171    dot_minor_version(id)
1172}
1173
1174/// Strip an InvokeModel version suffix: `-v1:0` or `-v1`.
1175fn strip_invoke_version(id: &str) -> &str {
1176    let base = id.split_once(':').map_or(id, |(base, _)| base);
1177    match base.rsplit_once("-v") {
1178        Some((stem, digits))
1179            if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) =>
1180        {
1181            stem
1182        }
1183        _ => base,
1184    }
1185}
1186
1187/// Strip a release-date suffix: `-20251001`.
1188fn strip_release_date(id: &str) -> &str {
1189    match id.rsplit_once('-') {
1190        Some((stem, date))
1191            if date.len() == 8
1192                && date.starts_with("20")
1193                && date.bytes().all(|b| b.is_ascii_digit()) =>
1194        {
1195            stem
1196        }
1197        _ => id,
1198    }
1199}
1200
1201/// Rewrite a trailing dashed minor version to the catalog's dotted form:
1202/// `claude-haiku-4-5` → `claude-haiku-4.5`. Whole versions (`claude-sonnet-5`)
1203/// are already in catalog form and pass through.
1204fn dot_minor_version(id: &str) -> String {
1205    let Some((stem, minor)) = id.rsplit_once('-') else {
1206        return id.to_string();
1207    };
1208    let Some((prefix, major)) = stem.rsplit_once('-') else {
1209        return id.to_string();
1210    };
1211    let both_numeric = !major.is_empty()
1212        && !minor.is_empty()
1213        && major.bytes().all(|b| b.is_ascii_digit())
1214        && minor.bytes().all(|b| b.is_ascii_digit());
1215    if both_numeric {
1216        format!("{prefix}-{major}.{minor}")
1217    } else {
1218        id.to_string()
1219    }
1220}
1221
1222/// The Azure predefined model deployments, as (deployment name, model name, version).
1223pub fn azure_deployments() -> Vec<(&'static str, &'static str, &'static str)> {
1224    AZURE_DEPLOYMENTS.to_vec()
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229    /// A public id may serve on more than one cloud (Claude does), but must appear at
1230    /// most once per cloud — a duplicate within a cloud would make `resolve_for`
1231    /// silently pick whichever entry comes first.
1232    #[test]
1233    fn public_ids_are_unique_per_cloud() {
1234        let mut seen = std::collections::HashSet::new();
1235        for model in super::CATALOG {
1236            assert!(
1237                seen.insert((model.cloud, model.public_id)),
1238                "public id '{}' appears more than once under {:?}",
1239                model.public_id,
1240                model.cloud
1241            );
1242        }
1243    }
1244
1245    #[test]
1246    fn direct_anthropic_aliases_are_unique_and_round_trip() {
1247        let mut public_ids = std::collections::HashSet::new();
1248        let mut upstream_ids = std::collections::HashSet::new();
1249        for model in DIRECT_ANTHROPIC_MODELS {
1250            assert!(public_ids.insert(model.public_id));
1251            assert!(upstream_ids.insert(model.upstream_id));
1252            assert_eq!(
1253                resolve_direct_anthropic(model.public_id)
1254                    .expect("direct model must resolve")
1255                    .upstream_id,
1256                model.upstream_id
1257            );
1258            assert_ne!(model.display_name(), model.public_id);
1259        }
1260        assert!(resolve_direct_anthropic("claude-not-real").is_none());
1261    }
1262
1263    #[test]
1264    fn direct_openai_models_are_unique_and_have_qualified_apis() {
1265        let mut public_ids = std::collections::HashSet::new();
1266        for model in DIRECT_OPENAI_MODELS {
1267            assert!(public_ids.insert(model.public_id));
1268            assert!(!model.client_apis.is_empty());
1269            assert_eq!(
1270                resolve_direct_openai(model.public_id)
1271                    .expect("direct model must resolve")
1272                    .client_apis,
1273                model.client_apis
1274            );
1275        }
1276        assert!(resolve_direct_openai("text-embedding-3-small").is_none());
1277        assert!(resolve_direct_openai("gpt-image-1").is_none());
1278    }
1279
1280    #[test]
1281    fn direct_databricks_models_are_unique_and_resolve_to_provider_ids() {
1282        let mut public_ids = std::collections::HashSet::new();
1283        for model in DIRECT_DATABRICKS_MODELS {
1284            assert!(public_ids.insert(model.public_id));
1285            assert!(
1286                model.upstream_id.starts_with("databricks-")
1287                    || model.upstream_id.starts_with("system.ai.")
1288            );
1289            assert!(!model.client_apis.is_empty());
1290            assert_eq!(
1291                resolve_direct_databricks(model.public_id)
1292                    .expect("direct Databricks model must resolve")
1293                    .upstream_id,
1294                model.upstream_id
1295            );
1296        }
1297        assert!(resolve_direct_databricks("bge-large-en").is_none());
1298        assert!(resolve_direct_databricks("databricks-genie").is_none());
1299        assert_eq!(
1300            resolve_direct_databricks("claude-opus-5")
1301                .expect("Claude Opus 5 must resolve")
1302                .upstream_id,
1303            "system.ai.claude-opus-5"
1304        );
1305        assert_eq!(
1306            resolve_direct_databricks("gpt-5.5")
1307                .expect("GPT-5.5 must resolve")
1308                .client_apis,
1309            &[ClientApi::OpenAiResponses]
1310        );
1311    }
1312
1313    use super::*;
1314
1315    #[test]
1316    fn resolve_accepts_anthropic_native_spellings() {
1317        // Claude Code /model forms: dashed minor version, with and without date.
1318        assert_eq!(
1319            resolve("claude-haiku-4-5").unwrap().public_id,
1320            "claude-haiku-4.5"
1321        );
1322        assert_eq!(
1323            resolve("claude-sonnet-4-5-20250929").unwrap().public_id,
1324            "claude-sonnet-4.5"
1325        );
1326        // Full Bedrock upstream ids, with geo/vendor prefix and version suffix.
1327        assert_eq!(
1328            resolve("us.anthropic.claude-haiku-4-5-20251001-v1:0")
1329                .unwrap()
1330                .public_id,
1331            "claude-haiku-4.5"
1332        );
1333        assert_eq!(
1334            resolve("anthropic.claude-opus-4-6-v1").unwrap().public_id,
1335            "claude-opus-4.6"
1336        );
1337        // Whole versions are already catalog form.
1338        assert_eq!(
1339            resolve("claude-sonnet-5").unwrap().public_id,
1340            "claude-sonnet-5"
1341        );
1342        // Exact public ids still win untouched.
1343        assert_eq!(
1344            resolve("claude-opus-4.8").unwrap().public_id,
1345            "claude-opus-4.8"
1346        );
1347        assert_eq!(resolve("gpt-oss-20b").unwrap().public_id, "gpt-oss-20b");
1348        // Unknowns stay unknown — no fuzzy matching.
1349        assert!(resolve("claude-nonexistent-9-9").is_none());
1350        assert!(resolve("gpt-5").is_none());
1351    }
1352
1353    #[test]
1354    fn aws_has_openai_and_anthropic_with_plain_ids() {
1355        let aws = models_for(Platform::Aws);
1356        assert!(!aws.is_empty());
1357        assert!(aws
1358            .iter()
1359            .any(|m| m.public_id == "gpt-oss-20b" && m.provider_api == ProviderApi::OpenAi));
1360        assert!(
1361            aws.iter().any(|m| m.provider_api == ProviderApi::Anthropic),
1362            "Claude must be included via the Anthropic protocol"
1363        );
1364        // The OpenAI endpoint rejects `us.*` cross-region profile ids.
1365        assert!(aws.iter().all(|m| !m.upstream_id.starts_with("us.")));
1366    }
1367
1368    #[test]
1369    fn resolve_for_scopes_to_cloud() {
1370        // The same public id serves on more than one cloud with different upstream
1371        // ids, so resolution must scope to the binding's cloud.
1372        let aws = resolve_for("claude-opus-4.8", Platform::Aws).expect("aws claude");
1373        assert_eq!(aws.upstream_id, "anthropic.claude-opus-4-8");
1374        let gcp = resolve_for("claude-opus-4.8", Platform::Gcp).expect("gcp claude");
1375        assert_eq!(gcp.upstream_id, "claude-opus-4-8");
1376        assert_eq!(gcp.provider_api, ProviderApi::Anthropic);
1377        // Canonicalization applies per cloud: Claude Code's dashed release-date
1378        // spelling resolves to the Vertex `@date` id.
1379        let dated = resolve_for("claude-haiku-4-5-20251001", Platform::Gcp).expect("dated id");
1380        assert_eq!(dated.upstream_id, "claude-haiku-4-5@20251001");
1381        // A Vertex-native `@date` spelling resolves too — it is the very id the
1382        // GCP catalog stores upstream.
1383        let vertex = resolve_for("claude-sonnet-4-5@20250929", Platform::Gcp).expect("vertex id");
1384        assert_eq!(vertex.upstream_id, "claude-sonnet-4-5@20250929");
1385        // A model serving on one cloud does not resolve on another.
1386        assert!(resolve_for("gemini-2.5-pro", Platform::Aws).is_none());
1387        assert!(resolve_for("gpt-4.1", Platform::Gcp).is_none());
1388    }
1389
1390    #[test]
1391    fn lookup_round_trips() {
1392        let m = lookup("gpt-oss-20b").expect("known model");
1393        assert_eq!(m.cloud, Platform::Aws);
1394        assert_eq!(m.provider_api, ProviderApi::OpenAi);
1395        assert_eq!(m.upstream_id, "openai.gpt-oss-20b-1:0");
1396
1397        let c = lookup("claude-opus-4.8").expect("claude known");
1398        assert_eq!(c.provider_api, ProviderApi::Anthropic);
1399
1400        assert!(lookup("nonexistent-model").is_none());
1401    }
1402
1403    #[test]
1404    fn azure_deployments_map_to_catalog() {
1405        assert!(!azure_deployments().is_empty());
1406        for (deployment, _, _) in azure_deployments() {
1407            assert!(
1408                models_for(Platform::Azure)
1409                    .iter()
1410                    .any(|m| m.upstream_id == deployment),
1411                "azure deployment {deployment} must map to a catalog model"
1412            );
1413        }
1414    }
1415
1416    #[test]
1417    fn api_kinds_have_stable_wire_names() {
1418        assert_eq!(
1419            serde_json::to_string(&ProviderApi::OpenAi).unwrap(),
1420            "\"openai\""
1421        );
1422        assert_eq!(
1423            serde_json::to_string(&ProviderApi::Anthropic).unwrap(),
1424            "\"anthropic\""
1425        );
1426        assert_eq!(
1427            serde_json::to_string(&ProviderApi::OpenAiResponses).unwrap(),
1428            "\"openairesponses\""
1429        );
1430        assert_eq!(
1431            serde_json::to_string(&ClientApi::OpenAiChatCompletions).unwrap(),
1432            "\"open-ai-chat-completions\""
1433        );
1434        assert_eq!(
1435            serde_json::to_string(&ClientApi::OpenAiResponses).unwrap(),
1436            "\"open-ai-responses\""
1437        );
1438        assert_eq!(
1439            serde_json::to_string(&ClientApi::AnthropicMessages).unwrap(),
1440            "\"anthropic-messages\""
1441        );
1442    }
1443
1444    #[test]
1445    fn client_apis_are_explicit_and_non_empty() {
1446        for model in CATALOG {
1447            assert!(
1448                !model.client_apis.is_empty(),
1449                "'{}' has no supported client API",
1450                model.public_id
1451            );
1452        }
1453
1454        let gpt_oss = resolve_for("gpt-oss-20b", Platform::Aws).unwrap();
1455        assert!(gpt_oss
1456            .client_apis
1457            .contains(&ClientApi::OpenAiChatCompletions));
1458        assert!(gpt_oss.client_apis.contains(&ClientApi::OpenAiResponses));
1459    }
1460
1461    #[test]
1462    fn every_model_has_provider_display_name_and_activation() {
1463        for m in CATALOG {
1464            assert_ne!(
1465                m.provider(),
1466                "unknown",
1467                "no provider mapping for '{}'",
1468                m.public_id
1469            );
1470            assert_ne!(
1471                m.display_name(),
1472                m.public_id,
1473                "no curated display_name for '{}'",
1474                m.public_id
1475            );
1476            // Only Claude needs a one-time step; everything else is out of the box.
1477            let is_claude = m.public_id.starts_with("claude");
1478            match m.activation() {
1479                Activation::OutOfBox => {
1480                    assert!(
1481                        !is_claude,
1482                        "'{}' (Claude) must require a one-time step",
1483                        m.public_id
1484                    )
1485                }
1486                Activation::RequiresOneTimeStep(summary) => {
1487                    assert!(is_claude, "'{}' must be out of the box", m.public_id);
1488                    assert!(
1489                        !summary.is_empty(),
1490                        "'{}' step summary is empty",
1491                        m.public_id
1492                    );
1493                }
1494            }
1495        }
1496    }
1497
1498    /// The gateway forwards, it does not translate, so a client picks its wire format
1499    /// from the model id alone. Break this and an OpenAI body reaches the Anthropic
1500    /// upstream, or the reverse, for a bare 400 no caller can act on.
1501    #[test]
1502    fn only_claude_ids_speak_the_anthropic_protocol() {
1503        for m in CATALOG {
1504            assert_eq!(
1505                m.provider_api == ProviderApi::Anthropic,
1506                m.public_id.starts_with("claude"),
1507                "'{}' is {:?} but its id says otherwise",
1508                m.public_id,
1509                m.provider_api
1510            );
1511        }
1512    }
1513}