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