Skip to main content

mermaid_cli/models/
providers.rs

1//! Provider profiles for the OpenAI-compatible adapter.
2//!
3//! Every OpenAI-compatible provider (Groq, Together, Fireworks, OpenRouter,
4//! vLLM, DeepInfra, Cerebras, SambaNova, LMStudio, llama.cpp, …) speaks
5//! roughly the same `/v1/chat/completions` shape. The differences fit into
6//! two small dimensions:
7//!
8//!   1. How they want **reasoning depth** in the request. The de-facto
9//!      standard is a string `reasoning_effort: "low"|"medium"|"high"`
10//!      field; OpenRouter wraps it in a `reasoning: {effort: …}` object
11//!      and adds a few extras; some providers ignore reasoning entirely.
12//!   2. Where they put **reasoning content** in the streaming response.
13//!      Some emit `delta.reasoning_content`, some `delta.reasoning`, and
14//!      a couple stuff `<think>...</think>` tags inline in `delta.content`.
15//!
16//! `ProviderProfile` captures both dimensions plus base URL, auth env
17//! var, and any analytics headers (OpenRouter wants `HTTP-Referer` +
18//! `X-Title`). A `pub const REGISTRY` lists the known providers; users
19//! can override the URL / auth env / headers per-provider via
20//! `[providers.<name>]` in `config.toml` and add fully custom providers
21//! by reusing a known profile.
22
23use serde::Deserialize;
24use serde_json::{Value, json};
25
26use super::reasoning::{ReasoningChunk, ReasoningLevel};
27
28/// Static description of one OpenAI-compatible provider.
29#[derive(Debug, Clone)]
30pub struct ProviderProfile {
31    /// Provider identifier as it appears in model IDs (e.g. `"groq"` for
32    /// `groq/qwen-qwq-32b`). Lowercased; matched case-insensitively.
33    pub name: &'static str,
34    /// Default base URL for `/chat/completions` and friends. The trailing
35    /// `/v1` (or equivalent) is included so adapter code just appends
36    /// `/chat/completions` etc.
37    pub base_url: &'static str,
38    /// Default env var holding the API key. User config can override.
39    pub api_key_env: &'static str,
40    /// Where to get an API key, appended to the "missing key" error so the
41    /// message is actionable. `None` falls back to just naming the env var.
42    pub key_hint: Option<&'static str>,
43    /// Headers always sent in addition to `Authorization: Bearer ...`.
44    /// OpenRouter requires `HTTP-Referer` + `X-Title` for its analytics
45    /// dashboard; everyone else uses an empty list.
46    pub extra_headers: &'static [(&'static str, &'static str)],
47    /// How to render `ReasoningLevel` into the request body.
48    pub reasoning_strategy: ReasoningStrategy,
49    /// Where reasoning content lives in the streaming response.
50    pub reasoning_extraction: ReasoningExtraction,
51    /// Which completion-budget parameter this provider accepts in
52    /// `/chat/completions`.
53    pub max_tokens_param: MaxTokensParam,
54    /// Model IDs that support tools but must be forced to single tool-call
55    /// mode because the provider default enables unsupported parallel calls.
56    pub disable_parallel_tool_calls_for: &'static [&'static str],
57}
58
59/// Provider-specific spelling for the completion-token budget.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum MaxTokensParam {
62    /// OpenAI-compatible legacy spelling.
63    MaxTokens,
64    /// Newer OpenAI-compatible spelling used by Cerebras.
65    MaxCompletionTokens,
66}
67
68/// How to put `ReasoningLevel` onto the wire for a given provider.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum ReasoningStrategy {
71    /// Provider exposes no reasoning controls (Together, DeepInfra
72    /// pass-through). Adapter sends nothing extra.
73    None,
74    /// Standard `reasoning_effort: "low"|"medium"|"high"` field
75    /// (OpenAI Chat Completions, Groq for gpt-oss, Cerebras for
76    /// gpt-oss-120b, Fireworks for Qwen 3, etc.).
77    Effort,
78    /// OpenRouter's normalized `reasoning: {effort: "..."}` nested
79    /// object. Supports `low`, `medium`, `high`, `max`. `None` becomes
80    /// `{exclude: true}` (suppresses reasoning).
81    OpenRouterShape,
82}
83
84impl ReasoningStrategy {
85    /// Render a `ReasoningLevel` to the JSON fragment that should be
86    /// merged into the `/chat/completions` request body. Returns `None`
87    /// if there's nothing to add (strategy is `None`, or the level is
88    /// `None` for a provider that signals via field omission).
89    pub fn render(&self, level: ReasoningLevel) -> Option<Value> {
90        match self {
91            ReasoningStrategy::None => None,
92            ReasoningStrategy::Effort => match level {
93                // `none` is the explicit off-tier on GPT-5.1+. Providers
94                // that don't understand it either silently ignore or 400 —
95                // which is a clearer failure than omitting the field when
96                // the user explicitly asked for it.
97                ReasoningLevel::None => Some(json!({"reasoning_effort": "none"})),
98                ReasoningLevel::Minimal => Some(json!({"reasoning_effort": "minimal"})),
99                ReasoningLevel::Low => Some(json!({"reasoning_effort": "low"})),
100                ReasoningLevel::Medium => Some(json!({"reasoning_effort": "medium"})),
101                ReasoningLevel::High => Some(json!({"reasoning_effort": "high"})),
102                // XHigh renders verbatim to "xhigh" — the dedicated OpenAI
103                // GPT-5.2+ tier. Non-OpenAI Effort providers (Groq,
104                // Cerebras, Fireworks) will 400 on "xhigh"; that's
105                // preferable to silently downgrading the user's explicit
106                // choice.
107                ReasoningLevel::XHigh => Some(json!({"reasoning_effort": "xhigh"})),
108                // Max collapses to "high" on Effort-shape providers.
109                // OpenAI's Effort enum doesn't have a "max" value (goes
110                // `...high | xhigh` and stops); users wanting OpenAI's
111                // top tier should pick `XHigh` explicitly. Providers
112                // with a genuine "max" tier (Anthropic, OpenRouter) use
113                // their own strategy, not this one.
114                ReasoningLevel::Max => Some(json!({"reasoning_effort": "high"})),
115            },
116            ReasoningStrategy::OpenRouterShape => match level {
117                ReasoningLevel::None => Some(json!({"reasoning": {"exclude": true}})),
118                ReasoningLevel::Minimal => Some(json!({"reasoning": {"effort": "low"}})),
119                ReasoningLevel::Low => Some(json!({"reasoning": {"effort": "low"}})),
120                ReasoningLevel::Medium => Some(json!({"reasoning": {"effort": "medium"}})),
121                ReasoningLevel::High => Some(json!({"reasoning": {"effort": "high"}})),
122                // OpenRouter has no `xhigh` tier. Since XHigh sits between
123                // High and Max, snap DOWN to `high` — the user picked
124                // something above high but below max; giving them max would
125                // over-deliver.
126                ReasoningLevel::XHigh => Some(json!({"reasoning": {"effort": "high"}})),
127                ReasoningLevel::Max => Some(json!({"reasoning": {"effort": "max"}})),
128            },
129        }
130    }
131}
132
133/// Where reasoning content shows up in a streaming response delta.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum ReasoningExtraction {
136    /// Provider doesn't stream reasoning content (OpenAI Chat Completions
137    /// for o-series — encrypted server-side).
138    None,
139    /// Reasoning arrives in `delta.<field>` of every streaming chunk.
140    /// Common values: `"reasoning_content"` (vLLM, DeepInfra, DeepSeek)
141    /// and `"reasoning"` (Groq parsed mode, OpenRouter).
142    DeltaContentField(&'static str),
143    /// Reasoning is `<think>...</think>` inline in `delta.content`.
144    /// Together-R1, Groq raw mode, Fireworks `/think` suffix all do this.
145    /// Adapter strips tags and reroutes inside-tag bytes to the
146    /// reasoning channel via a streaming state machine.
147    InlineThinkTags,
148}
149
150impl ReasoningExtraction {
151    /// Pull reasoning content out of a streaming delta JSON. Returns
152    /// `None` if this strategy doesn't extract from the JSON body
153    /// (`None` and `InlineThinkTags`) or if the delta has no reasoning.
154    /// `InlineThinkTags` is handled separately at the byte-stream level
155    /// in the adapter; this method returns `None` for it.
156    pub fn parse_delta(&self, delta: &Value) -> Option<ReasoningChunk> {
157        match self {
158            ReasoningExtraction::None | ReasoningExtraction::InlineThinkTags => None,
159            ReasoningExtraction::DeltaContentField(field) => {
160                let text = delta.get(field).and_then(|v| v.as_str())?;
161                if text.is_empty() {
162                    None
163                } else {
164                    Some(ReasoningChunk {
165                        text: text.to_string(),
166                        signature: None,
167                    })
168                }
169            },
170        }
171    }
172}
173
174/// User-friendly string form for `compat = "..."` in config.toml when a
175/// fully custom provider needs to declare which profile shape to follow.
176#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
177#[serde(rename_all = "kebab-case")]
178pub enum CompatStyle {
179    /// Standard OpenAI Chat Completions shape, no reasoning extras
180    /// (matches Together, DeepInfra, Cerebras for non-gpt-oss models).
181    Openai,
182    /// Same shape but with `reasoning_effort` on requests.
183    OpenaiEffort,
184    /// OpenRouter's normalized reasoning object.
185    Openrouter,
186}
187
188impl CompatStyle {
189    pub fn reasoning_strategy(self) -> ReasoningStrategy {
190        match self {
191            CompatStyle::Openai => ReasoningStrategy::None,
192            CompatStyle::OpenaiEffort => ReasoningStrategy::Effort,
193            CompatStyle::Openrouter => ReasoningStrategy::OpenRouterShape,
194        }
195    }
196}
197
198/// Built-in provider registry. Lookups are case-insensitive on `name`.
199/// Add a provider here when its quirks fit the existing strategies; add
200/// a new `ReasoningStrategy` variant when a provider needs something
201/// the existing ones can't express.
202pub const REGISTRY: &[ProviderProfile] = &[
203    ProviderProfile {
204        name: "openai",
205        base_url: "https://api.openai.com/v1",
206        api_key_env: "OPENAI_API_KEY",
207        key_hint: Some("create one at https://platform.openai.com/api-keys"),
208        extra_headers: &[],
209        reasoning_strategy: ReasoningStrategy::Effort,
210        // Chat Completions doesn't stream reasoning content for o-series
211        // (encrypted server-side); only the Responses API does. Step 2
212        // targets Chat Completions, so None.
213        reasoning_extraction: ReasoningExtraction::None,
214        max_tokens_param: MaxTokensParam::MaxTokens,
215        disable_parallel_tool_calls_for: &[],
216    },
217    ProviderProfile {
218        name: "groq",
219        base_url: "https://api.groq.com/openai/v1",
220        api_key_env: "GROQ_API_KEY",
221        key_hint: Some("create one at https://console.groq.com/keys"),
222        extra_headers: &[],
223        reasoning_strategy: ReasoningStrategy::Effort,
224        // Default `reasoning_format=parsed` routes reasoning to its own
225        // `delta.reasoning` field; we read it from there.
226        reasoning_extraction: ReasoningExtraction::DeltaContentField("reasoning"),
227        max_tokens_param: MaxTokensParam::MaxTokens,
228        disable_parallel_tool_calls_for: &[],
229    },
230    ProviderProfile {
231        name: "openrouter",
232        base_url: "https://openrouter.ai/api/v1",
233        api_key_env: "OPENROUTER_API_KEY",
234        key_hint: Some("create one at https://openrouter.ai/keys"),
235        extra_headers: &[
236            ("HTTP-Referer", "https://github.com/noahsabaj/mermaid-cli"),
237            // Canonical attribution header as of April 2026. OpenRouter
238            // still accepts `X-Title` for backward compat, but new code
239            // should emit `X-OpenRouter-Title`.
240            ("X-OpenRouter-Title", "Mermaid"),
241        ],
242        reasoning_strategy: ReasoningStrategy::OpenRouterShape,
243        reasoning_extraction: ReasoningExtraction::DeltaContentField("reasoning"),
244        max_tokens_param: MaxTokensParam::MaxTokens,
245        disable_parallel_tool_calls_for: &[],
246    },
247    ProviderProfile {
248        name: "cerebras",
249        base_url: "https://api.cerebras.ai/v1",
250        api_key_env: "CEREBRAS_API_KEY",
251        key_hint: Some("create one at https://cloud.cerebras.ai"),
252        extra_headers: &[],
253        // Effort-style request param. `gpt-oss-120b` and `zai-glm-4.7`
254        // honor it (the latter accepts `none` to disable); other models
255        // silently ignore — wire shape is the same.
256        reasoning_strategy: ReasoningStrategy::Effort,
257        reasoning_extraction: ReasoningExtraction::None,
258        max_tokens_param: MaxTokensParam::MaxCompletionTokens,
259        disable_parallel_tool_calls_for: &["gpt-oss-120b"],
260    },
261    ProviderProfile {
262        name: "deepinfra",
263        base_url: "https://api.deepinfra.com/v1/openai",
264        api_key_env: "DEEPINFRA_API_KEY",
265        key_hint: Some("create one at https://deepinfra.com/dash/api_keys"),
266        extra_headers: &[],
267        // Pass-through; reasoning shape per upstream model. Most R1-style
268        // models on DeepInfra emit `delta.reasoning_content`.
269        reasoning_strategy: ReasoningStrategy::None,
270        reasoning_extraction: ReasoningExtraction::DeltaContentField("reasoning_content"),
271        max_tokens_param: MaxTokensParam::MaxTokens,
272        disable_parallel_tool_calls_for: &[],
273    },
274    ProviderProfile {
275        name: "together",
276        base_url: "https://api.together.xyz/v1",
277        api_key_env: "TOGETHER_API_KEY",
278        key_hint: Some("create one at https://api.together.ai/settings/api-keys"),
279        extra_headers: &[],
280        reasoning_strategy: ReasoningStrategy::None,
281        // DeepSeek-R1 and friends on Together emit `<think>...</think>`
282        // inside `delta.content`. Adapter strips and reroutes.
283        reasoning_extraction: ReasoningExtraction::InlineThinkTags,
284        max_tokens_param: MaxTokensParam::MaxTokens,
285        disable_parallel_tool_calls_for: &[],
286    },
287    ProviderProfile {
288        name: "nvidia",
289        base_url: "https://integrate.api.nvidia.com/v1",
290        api_key_env: "NVIDIA_API_KEY",
291        key_hint: Some("create one at https://build.nvidia.com"),
292        extra_headers: &[],
293        // NVIDIA NIM is a plain OpenAI-compatible endpoint. Its own snippets
294        // send no reasoning param, so `None` keeps the request to exactly what
295        // NIM documents — no risk of a rejected `reasoning_effort`. Reasoning
296        // models like GLM-5.2 still show their trace via the extraction below.
297        reasoning_strategy: ReasoningStrategy::None,
298        // GLM-5.2 (and Nemotron) stream thinking in `delta.reasoning_content`,
299        // the same shape as DeepInfra.
300        reasoning_extraction: ReasoningExtraction::DeltaContentField("reasoning_content"),
301        max_tokens_param: MaxTokensParam::MaxTokens,
302        disable_parallel_tool_calls_for: &[],
303    },
304    ProviderProfile {
305        name: "cloudflare",
306        // Documentary placeholder only — never sent a request. Cloudflare Workers
307        // AI's real endpoint embeds a per-account id; `providers::factory`
308        // synthesizes the actual base_url at runtime from `CLOUDFLARE_ACCOUNT_ID`
309        // (or a `[providers.cloudflare].base_url` override) for chat requests AND
310        // for discovery surfaces (`doctor`, the best-effort `/models` probe) via
311        // `factory::discovery_base_url`.
312        base_url: "https://api.cloudflare.com/client/v4/accounts/ACCOUNT_ID/ai/v1",
313        api_key_env: "CLOUDFLARE_API_TOKEN",
314        key_hint: Some(
315            "create a token at https://dash.cloudflare.com/profile/api-tokens and set \
316             CLOUDFLARE_ACCOUNT_ID",
317        ),
318        extra_headers: &[],
319        // GLM-5.2 accepts `reasoning_effort` (Cloudflare documents it), so expose the
320        // reasoning-level selector via Effort — the same shape Cerebras uses. Non-reasoning
321        // Cloudflare models silently ignore the field.
322        reasoning_strategy: ReasoningStrategy::Effort,
323        // GLM-5.2 streams its thinking in `delta.reasoning_content`, like NVIDIA/DeepInfra.
324        reasoning_extraction: ReasoningExtraction::DeltaContentField("reasoning_content"),
325        max_tokens_param: MaxTokensParam::MaxTokens,
326        disable_parallel_tool_calls_for: &[],
327    },
328];
329
330/// Look up a built-in provider by name. Case-insensitive.
331pub fn lookup_provider(name: &str) -> Option<&'static ProviderProfile> {
332    let lower = name.to_lowercase();
333    REGISTRY.iter().find(|p| p.name == lower)
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    // --- Registry lookup ---
341
342    #[test]
343    fn lookup_known_provider() {
344        let p = lookup_provider("groq").expect("groq is in the registry");
345        assert_eq!(p.name, "groq");
346        assert!(p.base_url.starts_with("https://api.groq.com"));
347        assert_eq!(p.api_key_env, "GROQ_API_KEY");
348    }
349
350    #[test]
351    fn lookup_nvidia_provider() {
352        let p = lookup_provider("nvidia").expect("nvidia is in the registry");
353        assert_eq!(p.name, "nvidia");
354        assert_eq!(p.base_url, "https://integrate.api.nvidia.com/v1");
355        assert_eq!(p.api_key_env, "NVIDIA_API_KEY");
356        // GLM-5.2 streams its reasoning trace in `delta.reasoning_content`.
357        assert_eq!(
358            p.reasoning_extraction,
359            ReasoningExtraction::DeltaContentField("reasoning_content")
360        );
361    }
362
363    #[test]
364    fn lookup_cloudflare_provider() {
365        let p = lookup_provider("cloudflare").expect("cloudflare is in the registry");
366        assert_eq!(p.name, "cloudflare");
367        assert_eq!(p.api_key_env, "CLOUDFLARE_API_TOKEN");
368        // Effort so the reasoning-level selector actually drives GLM-5.2 on Cloudflare
369        // (contrast the nvidia entry, which is None and inert).
370        assert_eq!(p.reasoning_strategy, ReasoningStrategy::Effort);
371        // GLM-5.2 streams its reasoning trace in `delta.reasoning_content`.
372        assert_eq!(
373            p.reasoning_extraction,
374            ReasoningExtraction::DeltaContentField("reasoning_content")
375        );
376    }
377
378    #[test]
379    fn lookup_is_case_insensitive() {
380        assert!(lookup_provider("OpenAI").is_some());
381        assert!(lookup_provider("OPENROUTER").is_some());
382    }
383
384    #[test]
385    fn lookup_unknown_provider() {
386        assert!(lookup_provider("does-not-exist").is_none());
387    }
388
389    #[test]
390    fn registry_has_eight_providers() {
391        assert_eq!(REGISTRY.len(), 8);
392    }
393
394    #[test]
395    fn openrouter_has_analytics_headers() {
396        let p = lookup_provider("openrouter").unwrap();
397        let names: Vec<&str> = p.extra_headers.iter().map(|(k, _)| *k).collect();
398        assert!(names.contains(&"HTTP-Referer"));
399        // Canonical header name as of 2026-04. `X-Title` is still
400        // accepted for backward compat but new code emits the rebranded
401        // version.
402        assert!(names.contains(&"X-OpenRouter-Title"));
403    }
404
405    // --- ReasoningStrategy::render ---
406
407    #[test]
408    fn effort_renders_string_per_level() {
409        let s = ReasoningStrategy::Effort;
410        // `None` is now the explicit off-tier per GPT-5.1+; we emit the
411        // string rather than omitting the field so the user's choice
412        // reaches the provider.
413        assert_eq!(
414            s.render(ReasoningLevel::None),
415            Some(json!({"reasoning_effort": "none"})),
416        );
417        assert_eq!(
418            s.render(ReasoningLevel::Low),
419            Some(json!({"reasoning_effort": "low"})),
420        );
421        assert_eq!(
422            s.render(ReasoningLevel::Medium),
423            Some(json!({"reasoning_effort": "medium"})),
424        );
425        assert_eq!(
426            s.render(ReasoningLevel::High),
427            Some(json!({"reasoning_effort": "high"})),
428        );
429        // XHigh — OpenAI GPT-5.2+ tier. Sits between High and Max in
430        // our enum but on the wire it's OpenAI's actual top string.
431        // Providers that don't expose xhigh will 400.
432        assert_eq!(
433            s.render(ReasoningLevel::XHigh),
434            Some(json!({"reasoning_effort": "xhigh"})),
435        );
436        // Max collapses to high — OpenAI's Effort enum has no "max".
437        // Users wanting OpenAI's actual top tier should pick XHigh.
438        assert_eq!(
439            s.render(ReasoningLevel::Max),
440            Some(json!({"reasoning_effort": "high"})),
441        );
442    }
443
444    #[test]
445    fn openrouter_shape_renders_nested_object() {
446        let s = ReasoningStrategy::OpenRouterShape;
447        // None means "exclude" on OpenRouter — explicitly suppress
448        // reasoning rather than fall through to the model default.
449        assert_eq!(
450            s.render(ReasoningLevel::None),
451            Some(json!({"reasoning": {"exclude": true}})),
452        );
453        assert_eq!(
454            s.render(ReasoningLevel::Medium),
455            Some(json!({"reasoning": {"effort": "medium"}})),
456        );
457        assert_eq!(
458            s.render(ReasoningLevel::Max),
459            Some(json!({"reasoning": {"effort": "max"}})),
460        );
461        // OpenRouter has no xhigh tier; XHigh (between High and Max)
462        // snaps DOWN to `high` — don't over-deliver by bumping to max.
463        assert_eq!(
464            s.render(ReasoningLevel::XHigh),
465            Some(json!({"reasoning": {"effort": "high"}})),
466        );
467    }
468
469    #[test]
470    fn none_strategy_renders_nothing() {
471        let s = ReasoningStrategy::None;
472        for level in [
473            ReasoningLevel::None,
474            ReasoningLevel::Low,
475            ReasoningLevel::Medium,
476            ReasoningLevel::High,
477            ReasoningLevel::Max,
478        ] {
479            assert_eq!(s.render(level), None);
480        }
481    }
482
483    // --- ReasoningExtraction::parse_delta ---
484
485    #[test]
486    fn delta_field_extraction_finds_named_field() {
487        let e = ReasoningExtraction::DeltaContentField("reasoning_content");
488        let delta = json!({"reasoning_content": "weighing options", "content": ""});
489        let chunk = e.parse_delta(&delta).expect("should extract");
490        assert_eq!(chunk.text, "weighing options");
491        assert!(chunk.signature.is_none());
492    }
493
494    #[test]
495    fn delta_field_extraction_returns_none_when_absent() {
496        let e = ReasoningExtraction::DeltaContentField("reasoning_content");
497        let delta = json!({"content": "regular text"});
498        assert!(e.parse_delta(&delta).is_none());
499    }
500
501    #[test]
502    fn delta_field_extraction_returns_none_for_empty_string() {
503        let e = ReasoningExtraction::DeltaContentField("reasoning");
504        let delta = json!({"reasoning": ""});
505        assert!(e.parse_delta(&delta).is_none());
506    }
507
508    #[test]
509    fn none_extraction_always_returns_none() {
510        let e = ReasoningExtraction::None;
511        assert!(e.parse_delta(&json!({"reasoning_content": "x"})).is_none());
512    }
513
514    #[test]
515    fn inline_think_tags_does_not_parse_via_json() {
516        // Inline tags are handled at the byte-stream level in the
517        // adapter (Wave 6); this method always returns None for them.
518        let e = ReasoningExtraction::InlineThinkTags;
519        assert!(
520            e.parse_delta(&json!({"content": "<think>x</think>"}))
521                .is_none()
522        );
523    }
524
525    // --- CompatStyle ---
526
527    #[test]
528    fn compat_style_maps_to_strategy() {
529        assert_eq!(
530            CompatStyle::Openai.reasoning_strategy(),
531            ReasoningStrategy::None
532        );
533        assert_eq!(
534            CompatStyle::OpenaiEffort.reasoning_strategy(),
535            ReasoningStrategy::Effort
536        );
537        assert_eq!(
538            CompatStyle::Openrouter.reasoning_strategy(),
539            ReasoningStrategy::OpenRouterShape
540        );
541    }
542}