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 /// Headers always sent in addition to `Authorization: Bearer ...`.
41 /// OpenRouter requires `HTTP-Referer` + `X-Title` for its analytics
42 /// dashboard; everyone else uses an empty list.
43 pub extra_headers: &'static [(&'static str, &'static str)],
44 /// How to render `ReasoningLevel` into the request body.
45 pub reasoning_strategy: ReasoningStrategy,
46 /// Where reasoning content lives in the streaming response.
47 pub reasoning_extraction: ReasoningExtraction,
48 /// Which completion-budget parameter this provider accepts in
49 /// `/chat/completions`.
50 pub max_tokens_param: MaxTokensParam,
51 /// Model IDs that support tools but must be forced to single tool-call
52 /// mode because the provider default enables unsupported parallel calls.
53 pub disable_parallel_tool_calls_for: &'static [&'static str],
54}
55
56/// Provider-specific spelling for the completion-token budget.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum MaxTokensParam {
59 /// OpenAI-compatible legacy spelling.
60 MaxTokens,
61 /// Newer OpenAI-compatible spelling used by Cerebras.
62 MaxCompletionTokens,
63}
64
65/// How to put `ReasoningLevel` onto the wire for a given provider.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ReasoningStrategy {
68 /// Provider exposes no reasoning controls (Together, DeepInfra
69 /// pass-through). Adapter sends nothing extra.
70 None,
71 /// Standard `reasoning_effort: "low"|"medium"|"high"` field
72 /// (OpenAI Chat Completions, Groq for gpt-oss, Cerebras for
73 /// gpt-oss-120b, Fireworks for Qwen 3, etc.).
74 Effort,
75 /// OpenRouter's normalized `reasoning: {effort: "..."}` nested
76 /// object. Supports `low`, `medium`, `high`, `max`. `None` becomes
77 /// `{exclude: true}` (suppresses reasoning).
78 OpenRouterShape,
79}
80
81impl ReasoningStrategy {
82 /// Render a `ReasoningLevel` to the JSON fragment that should be
83 /// merged into the `/chat/completions` request body. Returns `None`
84 /// if there's nothing to add (strategy is `None`, or the level is
85 /// `None` for a provider that signals via field omission).
86 pub fn render(&self, level: ReasoningLevel) -> Option<Value> {
87 match self {
88 ReasoningStrategy::None => None,
89 ReasoningStrategy::Effort => match level {
90 // `none` is the explicit off-tier on GPT-5.1+. Providers
91 // that don't understand it either silently ignore or 400 —
92 // which is a clearer failure than omitting the field when
93 // the user explicitly asked for it.
94 ReasoningLevel::None => Some(json!({"reasoning_effort": "none"})),
95 ReasoningLevel::Minimal => Some(json!({"reasoning_effort": "minimal"})),
96 ReasoningLevel::Low => Some(json!({"reasoning_effort": "low"})),
97 ReasoningLevel::Medium => Some(json!({"reasoning_effort": "medium"})),
98 ReasoningLevel::High => Some(json!({"reasoning_effort": "high"})),
99 // XHigh renders verbatim to "xhigh" — the dedicated OpenAI
100 // GPT-5.2+ tier. Non-OpenAI Effort providers (Groq,
101 // Cerebras, Fireworks) will 400 on "xhigh"; that's
102 // preferable to silently downgrading the user's explicit
103 // choice.
104 ReasoningLevel::XHigh => Some(json!({"reasoning_effort": "xhigh"})),
105 // Max collapses to "high" on Effort-shape providers.
106 // OpenAI's Effort enum doesn't have a "max" value (goes
107 // `...high | xhigh` and stops); users wanting OpenAI's
108 // top tier should pick `XHigh` explicitly. Providers
109 // with a genuine "max" tier (Anthropic, OpenRouter) use
110 // their own strategy, not this one.
111 ReasoningLevel::Max => Some(json!({"reasoning_effort": "high"})),
112 },
113 ReasoningStrategy::OpenRouterShape => match level {
114 ReasoningLevel::None => Some(json!({"reasoning": {"exclude": true}})),
115 ReasoningLevel::Minimal => Some(json!({"reasoning": {"effort": "low"}})),
116 ReasoningLevel::Low => Some(json!({"reasoning": {"effort": "low"}})),
117 ReasoningLevel::Medium => Some(json!({"reasoning": {"effort": "medium"}})),
118 ReasoningLevel::High => Some(json!({"reasoning": {"effort": "high"}})),
119 // OpenRouter has no `xhigh` tier. Since XHigh sits between
120 // High and Max, snap DOWN to `high` — the user picked
121 // something above high but below max; giving them max would
122 // over-deliver.
123 ReasoningLevel::XHigh => Some(json!({"reasoning": {"effort": "high"}})),
124 ReasoningLevel::Max => Some(json!({"reasoning": {"effort": "max"}})),
125 },
126 }
127 }
128}
129
130/// Where reasoning content shows up in a streaming response delta.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum ReasoningExtraction {
133 /// Provider doesn't stream reasoning content (OpenAI Chat Completions
134 /// for o-series — encrypted server-side).
135 None,
136 /// Reasoning arrives in `delta.<field>` of every streaming chunk.
137 /// Common values: `"reasoning_content"` (vLLM, DeepInfra, DeepSeek)
138 /// and `"reasoning"` (Groq parsed mode, OpenRouter).
139 DeltaContentField(&'static str),
140 /// Reasoning is `<think>...</think>` inline in `delta.content`.
141 /// Together-R1, Groq raw mode, Fireworks `/think` suffix all do this.
142 /// Adapter strips tags and reroutes inside-tag bytes to the
143 /// reasoning channel via a streaming state machine.
144 InlineThinkTags,
145}
146
147impl ReasoningExtraction {
148 /// Pull reasoning content out of a streaming delta JSON. Returns
149 /// `None` if this strategy doesn't extract from the JSON body
150 /// (`None` and `InlineThinkTags`) or if the delta has no reasoning.
151 /// `InlineThinkTags` is handled separately at the byte-stream level
152 /// in the adapter; this method returns `None` for it.
153 pub fn parse_delta(&self, delta: &Value) -> Option<ReasoningChunk> {
154 match self {
155 ReasoningExtraction::None | ReasoningExtraction::InlineThinkTags => None,
156 ReasoningExtraction::DeltaContentField(field) => {
157 let text = delta.get(field).and_then(|v| v.as_str())?;
158 if text.is_empty() {
159 None
160 } else {
161 Some(ReasoningChunk {
162 text: text.to_string(),
163 signature: None,
164 })
165 }
166 },
167 }
168 }
169}
170
171/// User-friendly string form for `compat = "..."` in config.toml when a
172/// fully custom provider needs to declare which profile shape to follow.
173#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
174#[serde(rename_all = "kebab-case")]
175pub enum CompatStyle {
176 /// Standard OpenAI Chat Completions shape, no reasoning extras
177 /// (matches Together, DeepInfra, Cerebras for non-gpt-oss models).
178 Openai,
179 /// Same shape but with `reasoning_effort` on requests.
180 OpenaiEffort,
181 /// OpenRouter's normalized reasoning object.
182 Openrouter,
183}
184
185impl CompatStyle {
186 pub fn reasoning_strategy(self) -> ReasoningStrategy {
187 match self {
188 CompatStyle::Openai => ReasoningStrategy::None,
189 CompatStyle::OpenaiEffort => ReasoningStrategy::Effort,
190 CompatStyle::Openrouter => ReasoningStrategy::OpenRouterShape,
191 }
192 }
193}
194
195/// Built-in provider registry. Lookups are case-insensitive on `name`.
196/// Add a provider here when its quirks fit the existing strategies; add
197/// a new `ReasoningStrategy` variant when a provider needs something
198/// the existing ones can't express.
199pub const REGISTRY: &[ProviderProfile] = &[
200 ProviderProfile {
201 name: "openai",
202 base_url: "https://api.openai.com/v1",
203 api_key_env: "OPENAI_API_KEY",
204 extra_headers: &[],
205 reasoning_strategy: ReasoningStrategy::Effort,
206 // Chat Completions doesn't stream reasoning content for o-series
207 // (encrypted server-side); only the Responses API does. Step 2
208 // targets Chat Completions, so None.
209 reasoning_extraction: ReasoningExtraction::None,
210 max_tokens_param: MaxTokensParam::MaxTokens,
211 disable_parallel_tool_calls_for: &[],
212 },
213 ProviderProfile {
214 name: "groq",
215 base_url: "https://api.groq.com/openai/v1",
216 api_key_env: "GROQ_API_KEY",
217 extra_headers: &[],
218 reasoning_strategy: ReasoningStrategy::Effort,
219 // Default `reasoning_format=parsed` routes reasoning to its own
220 // `delta.reasoning` field; we read it from there.
221 reasoning_extraction: ReasoningExtraction::DeltaContentField("reasoning"),
222 max_tokens_param: MaxTokensParam::MaxTokens,
223 disable_parallel_tool_calls_for: &[],
224 },
225 ProviderProfile {
226 name: "openrouter",
227 base_url: "https://openrouter.ai/api/v1",
228 api_key_env: "OPENROUTER_API_KEY",
229 extra_headers: &[
230 ("HTTP-Referer", "https://github.com/noahsabaj/mermaid-cli"),
231 // Canonical attribution header as of April 2026. OpenRouter
232 // still accepts `X-Title` for backward compat, but new code
233 // should emit `X-OpenRouter-Title`.
234 ("X-OpenRouter-Title", "Mermaid"),
235 ],
236 reasoning_strategy: ReasoningStrategy::OpenRouterShape,
237 reasoning_extraction: ReasoningExtraction::DeltaContentField("reasoning"),
238 max_tokens_param: MaxTokensParam::MaxTokens,
239 disable_parallel_tool_calls_for: &[],
240 },
241 ProviderProfile {
242 name: "cerebras",
243 base_url: "https://api.cerebras.ai/v1",
244 api_key_env: "CEREBRAS_API_KEY",
245 extra_headers: &[],
246 // Effort-style request param. `gpt-oss-120b` and `zai-glm-4.7`
247 // honor it (the latter accepts `none` to disable); other models
248 // silently ignore — wire shape is the same.
249 reasoning_strategy: ReasoningStrategy::Effort,
250 reasoning_extraction: ReasoningExtraction::None,
251 max_tokens_param: MaxTokensParam::MaxCompletionTokens,
252 disable_parallel_tool_calls_for: &["gpt-oss-120b"],
253 },
254 ProviderProfile {
255 name: "deepinfra",
256 base_url: "https://api.deepinfra.com/v1/openai",
257 api_key_env: "DEEPINFRA_API_KEY",
258 extra_headers: &[],
259 // Pass-through; reasoning shape per upstream model. Most R1-style
260 // models on DeepInfra emit `delta.reasoning_content`.
261 reasoning_strategy: ReasoningStrategy::None,
262 reasoning_extraction: ReasoningExtraction::DeltaContentField("reasoning_content"),
263 max_tokens_param: MaxTokensParam::MaxTokens,
264 disable_parallel_tool_calls_for: &[],
265 },
266 ProviderProfile {
267 name: "together",
268 base_url: "https://api.together.xyz/v1",
269 api_key_env: "TOGETHER_API_KEY",
270 extra_headers: &[],
271 reasoning_strategy: ReasoningStrategy::None,
272 // DeepSeek-R1 and friends on Together emit `<think>...</think>`
273 // inside `delta.content`. Adapter strips and reroutes.
274 reasoning_extraction: ReasoningExtraction::InlineThinkTags,
275 max_tokens_param: MaxTokensParam::MaxTokens,
276 disable_parallel_tool_calls_for: &[],
277 },
278];
279
280/// Look up a built-in provider by name. Case-insensitive.
281pub fn lookup_provider(name: &str) -> Option<&'static ProviderProfile> {
282 let lower = name.to_lowercase();
283 REGISTRY.iter().find(|p| p.name == lower)
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 // --- Registry lookup ---
291
292 #[test]
293 fn lookup_known_provider() {
294 let p = lookup_provider("groq").expect("groq is in the registry");
295 assert_eq!(p.name, "groq");
296 assert!(p.base_url.starts_with("https://api.groq.com"));
297 assert_eq!(p.api_key_env, "GROQ_API_KEY");
298 }
299
300 #[test]
301 fn lookup_is_case_insensitive() {
302 assert!(lookup_provider("OpenAI").is_some());
303 assert!(lookup_provider("OPENROUTER").is_some());
304 }
305
306 #[test]
307 fn lookup_unknown_provider() {
308 assert!(lookup_provider("does-not-exist").is_none());
309 }
310
311 #[test]
312 fn registry_has_six_providers() {
313 assert_eq!(REGISTRY.len(), 6);
314 }
315
316 #[test]
317 fn openrouter_has_analytics_headers() {
318 let p = lookup_provider("openrouter").unwrap();
319 let names: Vec<&str> = p.extra_headers.iter().map(|(k, _)| *k).collect();
320 assert!(names.contains(&"HTTP-Referer"));
321 // Canonical header name as of 2026-04. `X-Title` is still
322 // accepted for backward compat but new code emits the rebranded
323 // version.
324 assert!(names.contains(&"X-OpenRouter-Title"));
325 }
326
327 // --- ReasoningStrategy::render ---
328
329 #[test]
330 fn effort_renders_string_per_level() {
331 let s = ReasoningStrategy::Effort;
332 // `None` is now the explicit off-tier per GPT-5.1+; we emit the
333 // string rather than omitting the field so the user's choice
334 // reaches the provider.
335 assert_eq!(
336 s.render(ReasoningLevel::None),
337 Some(json!({"reasoning_effort": "none"})),
338 );
339 assert_eq!(
340 s.render(ReasoningLevel::Low),
341 Some(json!({"reasoning_effort": "low"})),
342 );
343 assert_eq!(
344 s.render(ReasoningLevel::Medium),
345 Some(json!({"reasoning_effort": "medium"})),
346 );
347 assert_eq!(
348 s.render(ReasoningLevel::High),
349 Some(json!({"reasoning_effort": "high"})),
350 );
351 // XHigh — OpenAI GPT-5.2+ tier. Sits between High and Max in
352 // our enum but on the wire it's OpenAI's actual top string.
353 // Providers that don't expose xhigh will 400.
354 assert_eq!(
355 s.render(ReasoningLevel::XHigh),
356 Some(json!({"reasoning_effort": "xhigh"})),
357 );
358 // Max collapses to high — OpenAI's Effort enum has no "max".
359 // Users wanting OpenAI's actual top tier should pick XHigh.
360 assert_eq!(
361 s.render(ReasoningLevel::Max),
362 Some(json!({"reasoning_effort": "high"})),
363 );
364 }
365
366 #[test]
367 fn openrouter_shape_renders_nested_object() {
368 let s = ReasoningStrategy::OpenRouterShape;
369 // None means "exclude" on OpenRouter — explicitly suppress
370 // reasoning rather than fall through to the model default.
371 assert_eq!(
372 s.render(ReasoningLevel::None),
373 Some(json!({"reasoning": {"exclude": true}})),
374 );
375 assert_eq!(
376 s.render(ReasoningLevel::Medium),
377 Some(json!({"reasoning": {"effort": "medium"}})),
378 );
379 assert_eq!(
380 s.render(ReasoningLevel::Max),
381 Some(json!({"reasoning": {"effort": "max"}})),
382 );
383 // OpenRouter has no xhigh tier; XHigh (between High and Max)
384 // snaps DOWN to `high` — don't over-deliver by bumping to max.
385 assert_eq!(
386 s.render(ReasoningLevel::XHigh),
387 Some(json!({"reasoning": {"effort": "high"}})),
388 );
389 }
390
391 #[test]
392 fn none_strategy_renders_nothing() {
393 let s = ReasoningStrategy::None;
394 for level in [
395 ReasoningLevel::None,
396 ReasoningLevel::Low,
397 ReasoningLevel::Medium,
398 ReasoningLevel::High,
399 ReasoningLevel::Max,
400 ] {
401 assert_eq!(s.render(level), None);
402 }
403 }
404
405 // --- ReasoningExtraction::parse_delta ---
406
407 #[test]
408 fn delta_field_extraction_finds_named_field() {
409 let e = ReasoningExtraction::DeltaContentField("reasoning_content");
410 let delta = json!({"reasoning_content": "weighing options", "content": ""});
411 let chunk = e.parse_delta(&delta).expect("should extract");
412 assert_eq!(chunk.text, "weighing options");
413 assert!(chunk.signature.is_none());
414 }
415
416 #[test]
417 fn delta_field_extraction_returns_none_when_absent() {
418 let e = ReasoningExtraction::DeltaContentField("reasoning_content");
419 let delta = json!({"content": "regular text"});
420 assert!(e.parse_delta(&delta).is_none());
421 }
422
423 #[test]
424 fn delta_field_extraction_returns_none_for_empty_string() {
425 let e = ReasoningExtraction::DeltaContentField("reasoning");
426 let delta = json!({"reasoning": ""});
427 assert!(e.parse_delta(&delta).is_none());
428 }
429
430 #[test]
431 fn none_extraction_always_returns_none() {
432 let e = ReasoningExtraction::None;
433 assert!(e.parse_delta(&json!({"reasoning_content": "x"})).is_none());
434 }
435
436 #[test]
437 fn inline_think_tags_does_not_parse_via_json() {
438 // Inline tags are handled at the byte-stream level in the
439 // adapter (Wave 6); this method always returns None for them.
440 let e = ReasoningExtraction::InlineThinkTags;
441 assert!(
442 e.parse_delta(&json!({"content": "<think>x</think>"}))
443 .is_none()
444 );
445 }
446
447 // --- CompatStyle ---
448
449 #[test]
450 fn compat_style_maps_to_strategy() {
451 assert_eq!(
452 CompatStyle::Openai.reasoning_strategy(),
453 ReasoningStrategy::None
454 );
455 assert_eq!(
456 CompatStyle::OpenaiEffort.reasoning_strategy(),
457 ReasoningStrategy::Effort
458 );
459 assert_eq!(
460 CompatStyle::Openrouter.reasoning_strategy(),
461 ReasoningStrategy::OpenRouterShape
462 );
463 }
464}