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