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