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