Skip to main content

mermaid_model/models/adapters/
openai_compat.rs

1//! OpenAI-compatible Chat Completions adapter.
2//!
3//! Single adapter that targets `POST /chat/completions` (the universal
4//! shape across OpenAI itself and ~10 conformant providers — Groq,
5//! Together, Fireworks, OpenRouter, vLLM, DeepInfra, Cerebras,
6//! SambaNova, LMStudio, llama.cpp). Provider-specific quirks live in
7//! `ProviderProfile` (`crate::models::providers`) — the adapter asks the
8//! profile how to render reasoning depth and where to find reasoning
9//! content, and otherwise treats every provider identically.
10//!
11//! Streaming uses SSE (`data: <json>\n\n` ... `data: [DONE]\n\n`),
12//! drained via `crate::utils::drain_sse_events`. Tool calls arrive as
13//! chunked deltas indexed by `tool_calls[].index` and accumulated
14//! locally. Reasoning content arrives in either a named delta field
15//! (`delta.reasoning_content` for vLLM/DeepInfra/DeepSeek, `delta.reasoning`
16//! for Groq parsed mode + OpenRouter), inline `<think>...</think>`
17//! tags inside `delta.content` (Together-R1, Wave 6 adds the stripper),
18//! or not at all (OpenAI Chat Completions encrypts).
19//!
20//! # Why Chat Completions, not Responses API
21//!
22//! As of 2026-04, OpenAI's official docs flag the Responses API
23//! (`POST /responses`) as the recommended default and Chat Completions
24//! (`POST /chat/completions`) as legacy. Mermaid uses Chat Completions
25//! deliberately because it's the universal OpenAI-compat shape: Groq,
26//! OpenRouter, Cerebras, DeepInfra, Together, Fireworks, vLLM, and
27//! SambaNova all implement Chat Completions; the Responses API is
28//! OpenAI-only. Migrating this adapter would either (a) break OpenAI-
29//! compat coverage for those providers, or (b) require a separate
30//! OpenAI-direct adapter that bypasses this path. Both are non-trivial
31//! work for marginal gain — Chat Completions still works on the OpenAI
32//! direct endpoint, just without Responses-specific features (built-in
33//! reasoning summaries, structured-output tools, etc.).
34//!
35//! When/if a Responses-only feature becomes load-bearing for Mermaid,
36//! the right move is a focused new adapter (`openai_responses.rs`)
37//! routed through `providers::factory::ProviderFactory` for `provider == "openai"`,
38//! leaving this OpenAI-compat path for everyone else.
39
40use std::collections::HashMap;
41use std::time::Duration;
42
43use async_trait::async_trait;
44use futures::StreamExt;
45use reqwest::Client;
46use serde::{Deserialize, Serialize};
47use serde_json::{Value, json};
48
49use crate::constants::MAX_RESPONSE_CHARS;
50use crate::models::ModelCapabilities;
51use crate::models::config::ModelConfig;
52use crate::models::error::{BackendError, ModelError, Result};
53use crate::models::providers::{
54    MaxTokensParam, ProviderProfile, ReasoningExtraction, ReasoningStrategy,
55};
56use crate::models::reasoning::{
57    ReasoningCapability, ReasoningChunk, ReasoningLevel, nearest_effort,
58};
59use crate::models::stream::{StreamCallback, StreamEvent};
60use crate::models::tool_call::{FunctionCall, ToolCall};
61use crate::models::traits::Model;
62use crate::models::types::{ChatMessage, FinishReason, MessageRole, ModelResponse, TokenUsage};
63use crate::utils::drain_sse_events;
64
65const TRUNCATION_MARKER: &str = "\n\n[TRUNCATED: response exceeded size limit]";
66
67/// Append `chunk` to `buf`, char-boundary-safe truncation at `cap` bytes.
68/// Sets `*truncated` once tripped; subsequent calls become no-ops. Same
69/// shape as the helper in `adapters/ollama.rs` — duplicated rather than
70/// shared because (a) the marker text differs in spirit (provider-specific
71/// limits could grow different copy later), and (b) the dependency
72/// graph stays one-way (utils have no provider knowledge).
73fn push_capped(buf: &mut String, chunk: &str, truncated: &mut bool, cap: usize) {
74    if *truncated {
75        return;
76    }
77    buf.push_str(chunk);
78    if buf.len() > cap {
79        let end = buf.floor_char_boundary(cap);
80        buf.truncate(end);
81        buf.push_str(TRUNCATION_MARKER);
82        *truncated = true;
83    }
84}
85
86/// Append a streaming tool-argument fragment, hard-capping the buffer at
87/// `MAX_TOOL_ARG_BYTES`. A crafted stream could otherwise send unbounded
88/// `arguments` fragments and grow this buffer without limit (the daemon is
89/// long-lived). Past the cap we stop appending at a char boundary; the
90/// now-truncated JSON simply fails to parse and falls back to a raw string —
91/// bounded, not an OOM (#14).
92fn push_tool_arg(buf: &mut String, frag: &str) {
93    let cap = crate::constants::MAX_TOOL_ARG_BYTES;
94    if buf.len() >= cap {
95        return;
96    }
97    if buf.len() + frag.len() <= cap {
98        buf.push_str(frag);
99    } else {
100        let room = cap - buf.len();
101        let end = frag.floor_char_boundary(room);
102        buf.push_str(&frag[..end]);
103    }
104}
105
106/// Map OpenAI's `finish_reason` onto the normalized [`FinishReason`].
107fn map_openai_finish_reason(s: &str) -> FinishReason {
108    match s {
109        "stop" => FinishReason::Stop,
110        "length" => FinishReason::Length,
111        "tool_calls" | "function_call" => FinishReason::ToolUse,
112        "content_filter" => FinishReason::ContentFilter,
113        other => FinishReason::Other(other.to_string()),
114    }
115}
116
117/// F56: whether an OpenAI-compatible stream ended abnormally — it closed before
118/// any `finish_reason` was observed on a choice. The `[DONE]` sentinel is
119/// swallowed upstream by `drain_sse_events`, so a choice's `finish_reason`
120/// (`stop`/`length`/`tool_calls`/`content_filter`) is the only terminal marker
121/// this adapter can see; a conformant Chat Completions stream always carries
122/// one. Its absence means the connection dropped mid-response — returning a
123/// clean `Ok` (with `stop_reason: None`) would be indistinguishable from a real
124/// completion, so the caller surfaces a stream error. A `length` truncation
125/// sets a real `finish_reason`, so it is NOT abnormal and is preserved.
126fn stream_closed_abnormally(stop_reason: Option<&FinishReason>) -> bool {
127    stop_reason.is_none()
128}
129
130/// OpenAI-compatible model adapter.
131///
132/// Constructed via `OpenAICompatAdapter::new` from `providers::factory::ProviderFactory` once the
133/// provider name has been resolved against the registry / user config.
134/// All fields are owned (not borrowed) so the adapter outlives the
135/// factory call that built it.
136pub struct OpenAICompatAdapter {
137    client: Client,
138    profile: &'static ProviderProfile,
139    base_url: String,
140    /// `None` for keyless local endpoints (loopback/LAN OpenAI-compatible
141    /// servers like llama.cpp / vLLM) — no `Authorization` header is sent.
142    api_key: Option<String>,
143    model_name: String,
144    /// The merged header set: the profile's static `extra_headers`, then user
145    /// `extra_headers` overrides, then any env-sourced `env_headers`.
146    extra_headers: HashMap<String, String>,
147    capabilities: ModelCapabilities,
148}
149
150/// A random 128-bit `Idempotency-Key`, hex-encoded, for safe retry de-duplication
151/// (#F27). On the (vanishingly rare) OS-RNG failure, fall back to a
152/// process+time value so we still send *a* stable key rather than none.
153fn random_idempotency_key() -> String {
154    let mut bytes = [0u8; 16];
155    if getrandom::fill(&mut bytes).is_err() {
156        let nanos = std::time::SystemTime::now()
157            .duration_since(std::time::UNIX_EPOCH)
158            .map(|d| d.as_nanos())
159            .unwrap_or_default();
160        return format!("mermaid-{}-{nanos}", std::process::id());
161    }
162    use std::fmt::Write;
163    bytes.iter().fold(String::with_capacity(32), |mut s, b| {
164        let _ = write!(s, "{b:02x}");
165        s
166    })
167}
168
169impl OpenAICompatAdapter {
170    /// Create a new adapter. `base_url` is the resolved URL (registry
171    /// default OR user override); `api_key` is already resolved (caller uses
172    /// `crate::utils::resolve_api_key`), or `None` for a keyless local endpoint.
173    pub fn new(
174        profile: &'static ProviderProfile,
175        base_url: String,
176        api_key: Option<String>,
177        model_name: String,
178        extra_headers: HashMap<String, String>,
179    ) -> Result<Self> {
180        // Same client config as the Ollama adapter: connection-pooled,
181        // long-lived idle, no global request timeout (streaming responses
182        // can take minutes for large contexts).
183        let client = Client::builder()
184            .pool_max_idle_per_host(10)
185            .pool_idle_timeout(Duration::from_secs(90))
186            .tcp_keepalive(Duration::from_secs(60))
187            .connect_timeout(Duration::from_secs(10))
188            .build()
189            .map_err(|e| {
190                ModelError::Backend(BackendError::ConnectionFailed {
191                    backend: profile.name.to_string(),
192                    url: base_url.clone(),
193                    reason: e.to_string(),
194                })
195            })?;
196
197        let capabilities = derive_capabilities(profile, &model_name);
198
199        Ok(Self {
200            client,
201            profile,
202            base_url,
203            api_key,
204            model_name,
205            extra_headers,
206            capabilities,
207        })
208    }
209
210    /// Build the JSON request body for `/chat/completions`. Shared
211    /// between streaming and non-streaming paths.
212    fn build_request_body(
213        &self,
214        messages: &[ChatMessage],
215        config: &ModelConfig,
216        stream: bool,
217    ) -> Value {
218        let mut json_messages = Vec::new();
219
220        // Step 5h: combined_system_prompt joins the static base with
221        // any MERMAID.md content (separator `---`). On OpenAI-compat
222        // we have no per-block cache markers, so this is the right
223        // shape — the model just sees one extended system message.
224        if let Some(combined) = config.combined_system_prompt() {
225            json_messages.push(json!({
226                "role": "system",
227                "content": combined
228            }));
229        }
230
231        for msg in messages {
232            let role = match msg.role {
233                MessageRole::User => "user",
234                MessageRole::Assistant => "assistant",
235                MessageRole::System => "system",
236                MessageRole::Tool => "tool",
237            };
238            let mut json_msg = json!({ "role": role });
239            // Vision: a user message carrying images uses OpenAI's content-array
240            // shape (a text part plus one `image_url` part per image, as a
241            // base64 data URL). Previously images were dropped silently, so
242            // vision models saw nothing. Non-user roles / no images use a plain
243            // string content. Assistant-attached artifacts (screenshots) are not
244            // sent — OpenAI rejects images in assistant turns — matching the
245            // Anthropic adapter, which also only sends images on user messages.
246            if msg.role == MessageRole::User
247                && msg.images.as_ref().is_some_and(|images| !images.is_empty())
248            {
249                let mut parts: Vec<Value> = Vec::new();
250                if !msg.content.is_empty() {
251                    parts.push(json!({ "type": "text", "text": msg.content }));
252                }
253                for data in msg.images.iter().flatten() {
254                    // Default media type png — matches Mermaid's clipboard output;
255                    // an unsupported format surfaces a clear 4xx from the API.
256                    parts.push(json!({
257                        "type": "image_url",
258                        "image_url": { "url": format!("data:image/png;base64,{data}") },
259                    }));
260                }
261                json_msg["content"] = json!(parts);
262            } else {
263                json_msg["content"] = json!(msg.content);
264            }
265            if msg.role == MessageRole::Assistant
266                && let Some(tool_calls) = msg.tool_calls.as_ref().filter(|tc| !tc.is_empty())
267            {
268                // OpenAI requires each assistant tool call to carry `id`, a
269                // literal `"type": "function"`, and `function.arguments` as a
270                // JSON-ENCODED STRING. Serializing the internal `ToolCall` struct
271                // directly produced `arguments` as an object and omitted `type`,
272                // which strict endpoints (OpenAI, Groq) 400 on the next turn of a
273                // tool loop.
274                let wire: Vec<Value> = tool_calls
275                    .iter()
276                    .map(|tc| {
277                        let arguments = match &tc.function.arguments {
278                            // Already a raw JSON string (e.g. an unparseable-args
279                            // fallback) — pass through rather than double-encode.
280                            Value::String(s) => s.clone(),
281                            other => {
282                                serde_json::to_string(other).unwrap_or_else(|_| "{}".to_string())
283                            },
284                        };
285                        json!({
286                            "id": tc.id.clone().unwrap_or_default(),
287                            "type": "function",
288                            "function": {
289                                "name": tc.function.name,
290                                "arguments": arguments,
291                            },
292                        })
293                    })
294                    .collect();
295                json_msg["tool_calls"] = json!(wire);
296            }
297            // OpenAI tool result messages: `role: "tool"`, `tool_call_id`,
298            // and `name` (the tool name). Identical to Ollama's shape
299            // except the field is `name`, not `tool_name`.
300            if msg.role == MessageRole::Tool {
301                if let Some(ref tool_call_id) = msg.tool_call_id {
302                    json_msg["tool_call_id"] = json!(tool_call_id);
303                }
304                if let Some(ref tool_name) = msg.tool_name {
305                    json_msg["name"] = json!(tool_name);
306                }
307            }
308            json_messages.push(json_msg);
309        }
310
311        // Tool registration is the single capability boundary. If a tool
312        // reaches `config.tools`, its selected backend is usable; adapters
313        // serialize that decision without re-checking unrelated credentials.
314        let tools: Vec<&Value> = config.tools.iter().collect();
315
316        let mut body = json!({
317            "model": self.model_name,
318            "messages": json_messages,
319            "stream": stream,
320        });
321        // Temperature is sent only for models that accept it (catalog column):
322        // OpenAI o-series / gpt-5 reasoning models reject any non-default
323        // `temperature` with a 400 (#124), and gateway-served claude-opus-4-7+
324        // ids reject sampling params the same way. Clamp to the accepted 0..=2
325        // (a stale config value otherwise 400s).
326        if crate::models::catalog::lookup(&self.model_name).supports_temperature {
327            body["temperature"] = json!(config.temperature.clamp(0.0, 2.0));
328        }
329
330        if stream {
331            body["stream_options"] = json!({ "include_usage": true });
332        }
333
334        if !tools.is_empty() {
335            body["tools"] = json!(tools);
336            if self
337                .profile
338                .disable_parallel_tool_calls_for
339                .contains(&self.model_name.as_str())
340            {
341                body["parallel_tool_calls"] = json!(false);
342            }
343        }
344
345        // Completion budget spelling is provider-specific even inside the
346        // OpenAI-compatible family.
347        if config.max_tokens > 0 {
348            match self.profile.max_tokens_param {
349                MaxTokensParam::MaxTokens => body["max_tokens"] = json!(config.max_tokens),
350                MaxTokensParam::MaxCompletionTokens => {
351                    body["max_completion_tokens"] = json!(config.max_tokens);
352                },
353            }
354        }
355
356        // Reasoning depth: snap the requested level onto what the model
357        // actually supports (`nearest_effort`), then ask the profile what
358        // to splice in. Snap is a defensive guard — `Effort` and
359        // `OpenRouterShape` strategies currently advertise the full enum,
360        // but a future per-model capability shrink (e.g. a hypothetical
361        // `gpt-mini` exposing only Low/Medium) would land cleanly without
362        // touching the request-body builder.
363        let effective_reasoning = match &self.capabilities.supports_reasoning {
364            ReasoningCapability::Levels(supported) => {
365                nearest_effort(config.reasoning, supported).unwrap_or(ReasoningLevel::None)
366            },
367            _ => config.reasoning,
368        };
369        if let Some(reasoning_value) = self.profile.reasoning_strategy.render(effective_reasoning) {
370            // The strategy returns a one-key object; merge its top-level
371            // entries into the request body.
372            if let Some(obj) = reasoning_value.as_object() {
373                for (k, v) in obj {
374                    body[k] = v.clone();
375                }
376            }
377        }
378
379        // `--output-schema` formatting turn: native structured output.
380        // `strict: false` — strict mode rejects many hand-written schemas
381        // (every object needs additionalProperties: false etc.); client-side
382        // validation is the real gate. Some compat providers 400 on
383        // response_format entirely; that surfaces as a run error, documented.
384        if let Some(schema) = &config.output_schema {
385            body["response_format"] = json!({
386                "type": "json_schema",
387                "json_schema": {
388                    "name": "output",
389                    "strict": false,
390                    "schema": schema,
391                }
392            });
393        }
394
395        body
396    }
397
398    /// POST `/chat/completions` and return the raw response.
399    /// Transparently retries on 5xx, 429, or reqwest connect failures
400    /// via `crate::models::retry::retry_transient_http`. Useful for Groq /
401    /// OpenRouter / etc. when an upstream relay hiccups.
402    async fn send_chat(&self, body: &Value) -> Result<reqwest::Response> {
403        let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
404        // A stable idempotency key, generated ONCE and reused across every retry
405        // attempt, lets an OpenAI-compatible endpoint that honors `Idempotency-Key`
406        // (OpenAI, Groq, OpenRouter, …) dedupe a retried POST instead of generating
407        // — and billing — a second completion when a transient 5xx/connection drop
408        // is retried after the server already produced one (#F27). Endpoints that
409        // ignore the header are unaffected. (Anthropic has no documented
410        // equivalent; its retries mirror the official SDK default.)
411        let idempotency_key = random_idempotency_key();
412        crate::models::retry::retry_transient_http(|| async {
413            let mut req = self
414                .client
415                .post(&url)
416                .header("Idempotency-Key", &idempotency_key)
417                .json(body);
418            if let Some(key) = &self.api_key {
419                req = req.bearer_auth(key);
420            }
421            for (name, value) in &self.extra_headers {
422                req = req.header(name, value);
423            }
424            req.send().await.map_err(|e| {
425                ModelError::Backend(BackendError::ConnectionFailed {
426                    backend: self.profile.name.to_string(),
427                    url: url.clone(),
428                    reason: e.to_string(),
429                })
430            })
431        })
432        .await
433    }
434
435    /// Decode a single non-streaming response into `ModelResponse`.
436    async fn decode_non_streaming(&self, response: reqwest::Response) -> Result<ModelResponse> {
437        if !response.status().is_success() {
438            let status = response.status().as_u16();
439            let debug =
440                crate::models::error::ResponseDebugContext::from_headers(response.headers());
441            let body = response
442                .text()
443                .await
444                .unwrap_or_else(|_| "Unknown error".to_string());
445            return Err(ModelError::Backend(BackendError::HttpError {
446                status,
447                message: body,
448                debug,
449            }));
450        }
451        let json: ChatCompletion = response.json().await.map_err(|e| ModelError::ParseError {
452            message: format!("Failed to parse {} response: {}", self.profile.name, e),
453            raw: None,
454        })?;
455
456        let choice = json
457            .choices
458            .into_iter()
459            .next()
460            .ok_or_else(|| ModelError::ParseError {
461                message: format!("{} response had no choices", self.profile.name),
462                raw: None,
463            })?;
464
465        let usage = json.usage.map(token_usage_from_wire);
466
467        // Reasoning content: extract from the named field if the profile
468        // points at one. For `InlineThinkTags` the non-streaming body still
469        // contains `<think>…</think>`; run it through the same stripper the
470        // streaming path uses so reasoning is separated out of `content`.
471        let raw_content = choice.message.content.unwrap_or_default();
472        let (content, inline_thinking) = match self.profile.reasoning_extraction {
473            ReasoningExtraction::InlineThinkTags => {
474                let mut ts = ThinkTagState::new();
475                let (mut text, mut reasoning) = ts.feed(&raw_content);
476                let (text_tail, reasoning_tail) = ts.flush();
477                text.push_str(&text_tail);
478                reasoning.push_str(&reasoning_tail);
479                (text, (!reasoning.is_empty()).then_some(reasoning))
480            },
481            _ => (raw_content, None),
482        };
483
484        let thinking = match self.profile.reasoning_extraction {
485            ReasoningExtraction::DeltaContentField(field) => choice
486                .message
487                .extra
488                .get(field)
489                .and_then(|v| v.as_str())
490                .map(|s| s.to_string())
491                .filter(|s| !s.is_empty()),
492            ReasoningExtraction::InlineThinkTags => inline_thinking,
493            _ => None,
494        };
495
496        let tool_calls = choice
497            .message
498            .tool_calls
499            .filter(|v| !v.is_empty())
500            .map(|raw| raw.into_iter().map(parse_full_tool_call).collect());
501
502        let stop_reason = choice
503            .finish_reason
504            .as_deref()
505            .map(map_openai_finish_reason);
506        if content.is_empty()
507            && tool_calls.is_none()
508            && stop_reason == Some(FinishReason::ContentFilter)
509        {
510            return Err(ModelError::Backend(BackendError::ProviderError {
511                provider: self.profile.name.to_string(),
512                code: Some("content_filter".to_string()),
513                message: "Provider returned no content (content filter)".to_string(),
514                debug: crate::models::error::ResponseDebugContext::default(),
515            }));
516        }
517
518        Ok(ModelResponse {
519            content,
520            usage,
521            model_name: self.model_name.clone(),
522            stop_reason,
523            thinking,
524            tool_calls,
525            provider_continuation: None,
526        })
527    }
528
529    /// Stream the response, emit typed events through the callback,
530    /// return the final accumulated `ModelResponse`.
531    async fn handle_stream(
532        &self,
533        response: reqwest::Response,
534        callback: StreamCallback,
535        hide_reasoning_trace: bool,
536    ) -> Result<ModelResponse> {
537        if !response.status().is_success() {
538            let status = response.status().as_u16();
539            let debug =
540                crate::models::error::ResponseDebugContext::from_headers(response.headers());
541            let body = response
542                .text()
543                .await
544                .unwrap_or_else(|_| "Unknown error".to_string());
545            return Err(ModelError::Backend(BackendError::HttpError {
546                status,
547                message: body,
548                debug,
549            }));
550        }
551
552        let mut stream = response.bytes_stream();
553        let mut buf: Vec<u8> = Vec::new();
554
555        let mut content_acc = String::new();
556        let mut thinking_acc = String::new();
557        let mut tool_calls_partial: Vec<PartialToolCall> = Vec::new();
558        let mut truncated = false;
559        let mut stop_reason: Option<FinishReason> = None;
560        // The full token breakdown (cached-input + reasoning) from the last usage
561        // frame. Stays `None` until a usage frame arrives, so a stream that never
562        // reports usage returns `None` (the reducer then keeps its estimate)
563        // rather than a misleading zero (#125).
564        let mut usage_acc: Option<TokenUsage> = None;
565        // For providers that emit `<think>...</think>` inline in
566        // `delta.content`, route the content channel through this state
567        // machine so reasoning gets split out into its own
568        // `StreamEvent::Reasoning` events.
569        let inline_tags = matches!(
570            self.profile.reasoning_extraction,
571            ReasoningExtraction::InlineThinkTags
572        );
573        let mut think_state = ThinkTagState::new();
574
575        while let Some(chunk_result) = stream.next().await {
576            let chunk = chunk_result.map_err(|e| ModelError::StreamError(e.to_string()))?;
577            // Bound SSE reassembly: a server that streams bytes but never emits
578            // the `\n\n` event separator would otherwise grow `buf` without
579            // bound. At this point `buf` holds only the un-terminated residue
580            // from the previous drain, so this never trips on legitimately
581            // buffered complete events (#50).
582            if buf.len() > crate::constants::MAX_SSE_BUFFER_BYTES {
583                return Err(ModelError::StreamError(format!(
584                    "SSE stream exceeded {} byte reassembly cap without a complete event",
585                    crate::constants::MAX_SSE_BUFFER_BYTES
586                )));
587            }
588            buf.extend_from_slice(&chunk);
589
590            for payload in drain_sse_events(&mut buf) {
591                // A mid-stream error frame (common on OpenRouter) is an
592                // `{"error": ...}` object, not a chat chunk. Surface it as a
593                // typed provider error instead of the confusing "missing field
594                // choices" parse failure (#123) — mirrors the Gemini path.
595                let value: serde_json::Value = match serde_json::from_str(&payload) {
596                    Ok(v) => v,
597                    Err(e) => {
598                        return Err(ModelError::ParseError {
599                            message: format!(
600                                "Failed to parse {} stream chunk: {}",
601                                self.profile.name, e
602                            ),
603                            raw: Some(payload),
604                        });
605                    },
606                };
607                if let Some(err) = value.get("error") {
608                    let code = err.get("code").and_then(|v| {
609                        v.as_str()
610                            .map(str::to_string)
611                            .or_else(|| v.as_i64().map(|n| n.to_string()))
612                    });
613                    let message = err
614                        .get("message")
615                        .and_then(|v| v.as_str())
616                        .unwrap_or("stream error")
617                        .to_string();
618                    return Err(ModelError::Backend(BackendError::ProviderError {
619                        provider: self.profile.name.to_string(),
620                        code,
621                        message,
622                        debug: crate::models::error::ResponseDebugContext::default(),
623                    }));
624                }
625                let parsed: ChatCompletionChunk = match serde_json::from_value(value) {
626                    Ok(v) => v,
627                    Err(e) => {
628                        return Err(ModelError::ParseError {
629                            message: format!(
630                                "Failed to parse {} stream chunk: {}",
631                                self.profile.name, e
632                            ),
633                            raw: Some(payload),
634                        });
635                    },
636                };
637
638                if let Some(usage) = parsed.usage {
639                    // #12: capture the cached-input + reasoning breakdown via the
640                    // same converter the non-stream path uses. The last usage
641                    // frame wins.
642                    usage_acc = Some(token_usage_from_wire(usage));
643                }
644
645                let Some(choice) = parsed.choices.into_iter().next() else {
646                    continue;
647                };
648
649                if let Some(fr) = &choice.finish_reason {
650                    stop_reason = Some(map_openai_finish_reason(fr));
651                }
652                let delta = choice.delta;
653
654                // Reasoning extraction (separate field). InlineThinkTags
655                // is handled at the byte-stream level via Wave 6's state
656                // machine; it returns None here.
657                let reasoning_chunk = match self.profile.reasoning_extraction {
658                    ReasoningExtraction::DeltaContentField(field) => delta
659                        .extra
660                        .get(field)
661                        .and_then(|v| v.as_str())
662                        .filter(|s| !s.is_empty())
663                        .map(|s| ReasoningChunk {
664                            text: s.to_string(),
665                            signature: None,
666                        }),
667                    _ => None,
668                };
669                if let Some(chunk) = reasoning_chunk {
670                    if !hide_reasoning_trace {
671                        callback(StreamEvent::Reasoning(chunk.clone()));
672                    }
673                    push_capped(
674                        &mut thinking_acc,
675                        &chunk.text,
676                        &mut truncated,
677                        MAX_RESPONSE_CHARS,
678                    );
679                }
680
681                // Text content. For inline-tags providers, route through
682                // the ThinkTagState machine which splits out reasoning
683                // into its own channel; otherwise emit as plain text.
684                if let Some(text) = delta.content.as_ref()
685                    && !text.is_empty()
686                    && !truncated
687                {
688                    if inline_tags {
689                        let (text_part, reasoning_part) = think_state.feed(text);
690                        if !text_part.is_empty() {
691                            callback(StreamEvent::Text(text_part.clone()));
692                            push_capped(
693                                &mut content_acc,
694                                &text_part,
695                                &mut truncated,
696                                MAX_RESPONSE_CHARS,
697                            );
698                        }
699                        if !reasoning_part.is_empty() {
700                            if !hide_reasoning_trace {
701                                callback(StreamEvent::Reasoning(ReasoningChunk {
702                                    text: reasoning_part.clone(),
703                                    signature: None,
704                                }));
705                            }
706                            push_capped(
707                                &mut thinking_acc,
708                                &reasoning_part,
709                                &mut truncated,
710                                MAX_RESPONSE_CHARS,
711                            );
712                        }
713                    } else {
714                        callback(StreamEvent::Text(text.clone()));
715                        push_capped(&mut content_acc, text, &mut truncated, MAX_RESPONSE_CHARS);
716                    }
717                }
718
719                // Tool-call deltas — accumulate into partials.
720                if let Some(deltas) = delta.tool_calls {
721                    for tc_delta in deltas {
722                        accumulate_tool_call(&mut tool_calls_partial, tc_delta);
723                    }
724                }
725            }
726        }
727
728        // F56: a stream that ended before any `finish_reason` was dropped
729        // mid-response. Surface a stream error rather than a clean `Ok` (with
730        // `stop_reason: None`) that's indistinguishable from a real completion —
731        // checked before finalizing/emitting tool calls so a dropped connection
732        // doesn't hand back a half-built turn. A `length` truncation set a real
733        // `finish_reason`, so it does NOT trip this and is preserved.
734        if stream_closed_abnormally(stop_reason.as_ref()) {
735            return Err(ModelError::StreamError(format!(
736                "{} stream closed before a terminal finish_reason; the connection \
737                 was likely dropped mid-response",
738                self.profile.name
739            )));
740        }
741
742        // Flush any pending tag-state bytes (incomplete trailing tags
743        // get emitted to the text channel; see ThinkTagState::flush).
744        if inline_tags {
745            let (text_tail, reasoning_tail) = think_state.flush();
746            if !text_tail.is_empty() && !truncated {
747                callback(StreamEvent::Text(text_tail.clone()));
748                push_capped(
749                    &mut content_acc,
750                    &text_tail,
751                    &mut truncated,
752                    MAX_RESPONSE_CHARS,
753                );
754            }
755            if !reasoning_tail.is_empty() && !truncated {
756                if !hide_reasoning_trace {
757                    callback(StreamEvent::Reasoning(ReasoningChunk {
758                        text: reasoning_tail.clone(),
759                        signature: None,
760                    }));
761                }
762                push_capped(
763                    &mut thinking_acc,
764                    &reasoning_tail,
765                    &mut truncated,
766                    MAX_RESPONSE_CHARS,
767                );
768            }
769        }
770
771        // Finalize accumulated tool calls — parse arguments JSON, emit
772        // ToolCall events, build the response field.
773        let mut final_tool_calls: Vec<ToolCall> = Vec::new();
774        for partial in tool_calls_partial {
775            if let Some(tc) = partial.into_tool_call() {
776                callback(StreamEvent::ToolCall(tc.clone()));
777                final_tool_calls.push(tc);
778            }
779        }
780
781        // F3: wrapper emits the authoritative `Done` from the returned
782        // `ModelResponse`. See adapters/anthropic.rs for rationale.
783
784        let thinking = if thinking_acc.is_empty() {
785            None
786        } else {
787            Some(thinking_acc)
788        };
789        let tool_calls = if final_tool_calls.is_empty() {
790            None
791        } else {
792            Some(final_tool_calls)
793        };
794
795        // A content-filter refusal that produced no usable output is an error,
796        // not an empty success.
797        if content_acc.is_empty()
798            && tool_calls.is_none()
799            && stop_reason == Some(FinishReason::ContentFilter)
800        {
801            return Err(ModelError::Backend(BackendError::ProviderError {
802                provider: self.profile.name.to_string(),
803                code: Some("content_filter".to_string()),
804                message: "Provider returned no content (content filter)".to_string(),
805                debug: crate::models::error::ResponseDebugContext::default(),
806            }));
807        }
808
809        Ok(ModelResponse {
810            content: content_acc,
811            // `None` when the stream never reported usage, so the reducer keeps
812            // its char/4 estimate instead of resetting the gauge to zero (#125).
813            usage: usage_acc,
814            model_name: self.model_name.clone(),
815            stop_reason,
816            thinking,
817            tool_calls,
818            provider_continuation: None,
819        })
820    }
821}
822
823/// Derive `ModelCapabilities` from a `ProviderProfile` and model id. Reasoning
824/// support follows from the strategy:
825/// - `Effort` (OpenAI Chat Completions, Groq, Cerebras, Fireworks) advertises
826///   the full enum including `Minimal` because OpenAI GPT-5 has a real
827///   `minimal` tier and the wire field accepts it. Other models on this
828///   strategy that don't honor `minimal` simply ignore the field.
829/// - `OpenRouterShape` advertises `[None, Low, Medium, High, Max]` because
830///   OpenRouter's normalized object has no `minimal` — `Minimal` requests
831///   snap to `Low` via `nearest_effort`.
832/// - `None` advertises `Unsupported`.
833fn derive_capabilities(profile: &ProviderProfile, model_name: &str) -> ModelCapabilities {
834    use ReasoningCapability as Cap;
835    let supports_reasoning = match profile.reasoning_strategy {
836        ReasoningStrategy::None => Cap::Unsupported,
837        // Effort providers (OpenAI, Groq, Cerebras, Fireworks, …) accept
838        // the full enum on-paper. GPT-5.2+ is the only model that honors
839        // `xhigh`; others silently downgrade on the server side.
840        ReasoningStrategy::Effort => Cap::Levels(vec![
841            ReasoningLevel::None,
842            ReasoningLevel::Minimal,
843            ReasoningLevel::Low,
844            ReasoningLevel::Medium,
845            ReasoningLevel::High,
846            ReasoningLevel::Max,
847            ReasoningLevel::XHigh,
848        ]),
849        // OpenRouter's normalized object has no `minimal` and no `xhigh`;
850        // users who request those snap down via `nearest_effort` (Minimal
851        // → None → `{exclude: true}` fallback; XHigh → Max → `{effort: "max"}`).
852        ReasoningStrategy::OpenRouterShape => Cap::Levels(vec![
853            ReasoningLevel::None,
854            ReasoningLevel::Low,
855            ReasoningLevel::Medium,
856            ReasoningLevel::High,
857            ReasoningLevel::Max,
858        ]),
859    };
860    ModelCapabilities {
861        supports_tools: true,
862        // Vision is a property of the MODEL, not the provider — the catalog's
863        // substring markers match known image-capable families under any id
864        // (`gpt-4o`, `openai/gpt-4o`, `anthropic/claude-3.5-sonnet`).
865        // Conservative: an unknown id is treated as text-only. This only
866        // governs the capability we ADVERTISE — it never gates the send.
867        supports_vision: crate::models::catalog::lookup(model_name).vision,
868        supports_reasoning,
869        // Unknown statically; discovered live from `/models` metadata by the
870        // provider wrapper's `resolve_context_window` override.
871        max_context_tokens: None,
872        max_output_tokens: None,
873    }
874}
875
876impl OpenAICompatAdapter {
877    /// The registry/profile name of the provider this adapter targets (e.g.
878    /// `"cloudflare"`), for cache keys and diagnostics.
879    pub fn provider_name(&self) -> &str {
880        self.profile.name
881    }
882
883    /// `GET /models`, keeping the limit metadata providers attach
884    /// (`context_length`, `max_completion_tokens`, OpenRouter's
885    /// `top_provider.*`) instead of collapsing to bare ids. The `Model` trait's
886    /// `list_models` delegates here; the provider wrapper uses the limits to
887    /// resolve the live context window / output ceiling.
888    pub async fn list_models_detailed(&self) -> Result<Vec<ModelListing>> {
889        let url = format!("{}/models", self.base_url.trim_end_matches('/'));
890        let response = self.get_models_response(&url).await?;
891        let body: ListModelsResponse =
892            response.json().await.map_err(|e| ModelError::ParseError {
893                message: format!("Failed to parse {} models list: {}", self.profile.name, e),
894                raw: None,
895            })?;
896        Ok(body.data.into_iter().map(ModelListing::from).collect())
897    }
898
899    /// Limits-oriented listing. For most providers this is
900    /// `list_models_detailed`; for Cloudflare the OpenAI-compat `/models`
901    /// returns bare `{id}` entries, so the real limits come from the
902    /// account's `models/search` endpoint — first in `format=openrouter`
903    /// (context window + output cap, but only the curated marketplace
904    /// subset), then the default format (context window only, full catalog)
905    /// when the model isn't in that subset.
906    pub async fn list_models_for_limits(&self) -> Result<Vec<ModelListing>> {
907        let Some(search_base) = self.cloudflare_models_search_base() else {
908            return self.list_models_detailed().await;
909        };
910        // Cloudflare's `name` field IS the full model id (`@cf/vendor/model`),
911        // so searching by the last segment narrows the response to (usually)
912        // the one model the session runs.
913        let hint = self
914            .model_name
915            .rsplit('/')
916            .next()
917            .unwrap_or(&self.model_name);
918        if let Ok(listings) = self
919            .fetch_cloudflare_openrouter_format(&search_base, hint)
920            .await
921            && listings.iter().any(|m| m.id == self.model_name)
922        {
923            return Ok(listings);
924        }
925        // A default-format failure surfaces as Err so the wrapper skips
926        // caching (a transient outage must not pin `None` limits for the
927        // whole probe TTL).
928        self.fetch_cloudflare_default_format(&search_base, hint)
929            .await
930    }
931
932    /// Cloudflare's OpenAI-compat surface carries no limit metadata; the
933    /// account-level management endpoint `…/accounts/{id}/ai/models/search`
934    /// does (same bearer token). Derive it from the chat base_url when that
935    /// is the canonical account-scoped shape (`…/ai/v1`). AI Gateway
936    /// overrides (`…/workers-ai/v1`) don't end in `/ai/v1` and get `None` —
937    /// generic discovery, same as before.
938    fn cloudflare_models_search_base(&self) -> Option<String> {
939        if self.profile.name != "cloudflare" {
940            return None;
941        }
942        let root = self.base_url.trim_end_matches('/').strip_suffix("/v1")?;
943        root.ends_with("/ai")
944            .then(|| format!("{root}/models/search"))
945    }
946
947    async fn fetch_cloudflare_openrouter_format(
948        &self,
949        search_base: &str,
950        hint: &str,
951    ) -> Result<Vec<ModelListing>> {
952        let url = format!(
953            "{search_base}?format=openrouter&per_page=100&search={}",
954            encode_query_value(hint)
955        );
956        let response = self.get_models_response(&url).await?;
957        let body: ListModelsResponse =
958            response.json().await.map_err(|e| ModelError::ParseError {
959                message: format!("Failed to parse {} models search: {}", self.profile.name, e),
960                raw: None,
961            })?;
962        Ok(body.data.into_iter().map(ModelListing::from).collect())
963    }
964
965    async fn fetch_cloudflare_default_format(
966        &self,
967        search_base: &str,
968        hint: &str,
969    ) -> Result<Vec<ModelListing>> {
970        let url = format!(
971            "{search_base}?per_page=100&search={}",
972            encode_query_value(hint)
973        );
974        let response = self.get_models_response(&url).await?;
975        let body: CfModelsSearchResponse =
976            response.json().await.map_err(|e| ModelError::ParseError {
977                message: format!("Failed to parse {} models search: {}", self.profile.name, e),
978                raw: None,
979            })?;
980        Ok(body.result.into_iter().map(ModelListing::from).collect())
981    }
982
983    /// Shared GET + status/error mapping for the model-listing endpoints.
984    async fn get_models_response(&self, url: &str) -> Result<reqwest::Response> {
985        let mut req = self.client.get(url);
986        if let Some(key) = &self.api_key {
987            req = req.bearer_auth(key);
988        }
989        for (name, value) in &self.extra_headers {
990            req = req.header(name, value);
991        }
992        let response = req.send().await.map_err(|e| {
993            ModelError::Backend(BackendError::ConnectionFailed {
994                backend: self.profile.name.to_string(),
995                url: url.to_string(),
996                reason: e.to_string(),
997            })
998        })?;
999        if response.status() == reqwest::StatusCode::NOT_FOUND {
1000            return Err(ModelError::Unsupported {
1001                feature: format!("list_models (provider: {})", self.profile.name),
1002            });
1003        }
1004        if !response.status().is_success() {
1005            return Err(ModelError::Backend(BackendError::HttpError {
1006                status: response.status().as_u16(),
1007                message: format!("{} list_models failed", self.profile.name),
1008                debug: crate::models::error::ResponseDebugContext::from_headers(response.headers()),
1009            }));
1010        }
1011        Ok(response)
1012    }
1013}
1014
1015#[async_trait]
1016impl Model for OpenAICompatAdapter {
1017    fn name(&self) -> &str {
1018        &self.model_name
1019    }
1020
1021    fn capabilities(&self) -> &ModelCapabilities {
1022        &self.capabilities
1023    }
1024
1025    async fn list_models(&self) -> Result<Vec<String>> {
1026        Ok(self
1027            .list_models_detailed()
1028            .await?
1029            .into_iter()
1030            .map(|m| m.id)
1031            .collect())
1032    }
1033
1034    async fn chat(
1035        &self,
1036        messages: &[ChatMessage],
1037        config: &ModelConfig,
1038        callback: Option<StreamCallback>,
1039    ) -> Result<ModelResponse> {
1040        let stream = callback.is_some();
1041        let body = self.build_request_body(messages, config, stream);
1042        let response = self.send_chat(&body).await?;
1043
1044        if let Some(cb) = callback {
1045            self.handle_stream(response, cb, config.hide_reasoning_trace)
1046                .await
1047        } else {
1048            self.decode_non_streaming(response).await
1049        }
1050    }
1051}
1052
1053// ===== Wire types =====
1054
1055/// Non-streaming `/chat/completions` response.
1056#[derive(Debug, Deserialize)]
1057struct ChatCompletion {
1058    choices: Vec<NonStreamingChoice>,
1059    #[serde(default)]
1060    usage: Option<UsageWire>,
1061}
1062
1063#[derive(Debug, Deserialize)]
1064struct NonStreamingChoice {
1065    message: ResponseMessage,
1066    #[serde(default)]
1067    finish_reason: Option<String>,
1068}
1069
1070/// Non-streaming response message. `extra` captures whatever extra fields
1071/// (`reasoning_content`, `reasoning`) the provider emits — extracted via
1072/// `ReasoningExtraction::parse_delta`-like logic in the adapter.
1073#[derive(Debug, Deserialize)]
1074struct ResponseMessage {
1075    #[serde(default)]
1076    content: Option<String>,
1077    #[serde(default)]
1078    tool_calls: Option<Vec<ToolCallWire>>,
1079    #[serde(flatten)]
1080    extra: serde_json::Map<String, Value>,
1081}
1082
1083/// Streaming response chunk (one SSE event payload).
1084#[derive(Debug, Deserialize)]
1085struct ChatCompletionChunk {
1086    // A final usage-only frame (and some providers' keep-alives) carry no
1087    // `choices`; default to empty so it parses instead of 400-ing the stream
1088    // with "missing field choices" (#123).
1089    #[serde(default)]
1090    choices: Vec<StreamingChoice>,
1091    #[serde(default)]
1092    usage: Option<UsageWire>,
1093}
1094
1095#[derive(Debug, Deserialize)]
1096struct StreamingChoice {
1097    #[serde(default)]
1098    delta: DeltaMessage,
1099    /// Terminal reason (`stop`/`length`/`tool_calls`/`content_filter`).
1100    /// Mapped to `FinishReason` so truncation and refusals surface instead of
1101    /// looking like a clean finish.
1102    #[serde(default)]
1103    finish_reason: Option<String>,
1104}
1105
1106#[derive(Debug, Default, Deserialize)]
1107struct DeltaMessage {
1108    #[serde(default)]
1109    content: Option<String>,
1110    #[serde(default)]
1111    tool_calls: Option<Vec<ToolCallDeltaWire>>,
1112    /// All other fields (`reasoning_content`, `reasoning`, `role`, etc.)
1113    /// land here. The adapter uses `extra.get(field)` to pluck reasoning
1114    /// out per the profile's `ReasoningExtraction` setting.
1115    #[serde(flatten)]
1116    extra: serde_json::Map<String, Value>,
1117}
1118
1119#[derive(Debug, Deserialize)]
1120struct UsageWire {
1121    #[serde(default)]
1122    prompt_tokens: Option<usize>,
1123    #[serde(default)]
1124    completion_tokens: Option<usize>,
1125    #[serde(default)]
1126    prompt_tokens_details: Option<PromptTokensDetailsWire>,
1127    #[serde(default)]
1128    completion_tokens_details: Option<CompletionTokensDetailsWire>,
1129    #[serde(default)]
1130    input_tokens_details: Option<PromptTokensDetailsWire>,
1131    #[serde(default)]
1132    output_tokens_details: Option<CompletionTokensDetailsWire>,
1133}
1134
1135#[derive(Debug, Deserialize)]
1136struct PromptTokensDetailsWire {
1137    #[serde(default)]
1138    cached_tokens: Option<usize>,
1139}
1140
1141#[derive(Debug, Deserialize)]
1142struct CompletionTokensDetailsWire {
1143    #[serde(default)]
1144    reasoning_tokens: Option<usize>,
1145}
1146
1147fn token_usage_from_wire(usage: UsageWire) -> TokenUsage {
1148    let raw_prompt_tokens = usage.prompt_tokens.unwrap_or(0);
1149    let raw_completion_tokens = usage.completion_tokens.unwrap_or(0);
1150
1151    let cached_input_tokens = usage
1152        .prompt_tokens_details
1153        .as_ref()
1154        .and_then(|d| d.cached_tokens)
1155        .or_else(|| {
1156            usage
1157                .input_tokens_details
1158                .as_ref()
1159                .and_then(|d| d.cached_tokens)
1160        })
1161        .unwrap_or(0);
1162    // OpenAI's `prompt_tokens` already INCLUDES the cached tokens and its
1163    // `completion_tokens` already INCLUDES the reasoning tokens (both are
1164    // nested breakdowns), unlike Anthropic's disjoint buckets. Subtract
1165    // each so the shared `TokenUsage` component fields stay disjoint and
1166    // the derived totals don't double-count. The derived
1167    // `total_tokens()` then equals the wire total exactly.
1168    let prompt_tokens = raw_prompt_tokens.saturating_sub(cached_input_tokens);
1169    let reasoning_output_tokens = usage
1170        .completion_tokens_details
1171        .as_ref()
1172        .and_then(|d| d.reasoning_tokens)
1173        .or_else(|| {
1174            usage
1175                .output_tokens_details
1176                .as_ref()
1177                .and_then(|d| d.reasoning_tokens)
1178        })
1179        .unwrap_or(0);
1180    let completion_tokens = raw_completion_tokens.saturating_sub(reasoning_output_tokens);
1181
1182    TokenUsage::provider(prompt_tokens, completion_tokens)
1183        .with_cached_input(cached_input_tokens)
1184        .with_reasoning_output(reasoning_output_tokens)
1185}
1186
1187/// Full tool call as returned in non-streaming responses.
1188#[derive(Debug, Deserialize, Serialize, Clone)]
1189struct ToolCallWire {
1190    #[serde(default)]
1191    id: Option<String>,
1192    function: FunctionWire,
1193}
1194
1195#[derive(Debug, Deserialize, Serialize, Clone)]
1196struct FunctionWire {
1197    name: String,
1198    /// OpenAI emits `arguments` as a JSON-encoded string (not an object).
1199    /// We parse it lazily into `serde_json::Value` when constructing the
1200    /// `ToolCall` for the agent loop.
1201    #[serde(default)]
1202    arguments: String,
1203}
1204
1205/// Streaming tool-call delta. First chunk for a given `index` carries
1206/// `id` + `function.name`; subsequent chunks append to `function.arguments`
1207/// fragment-by-fragment.
1208#[derive(Debug, Deserialize)]
1209struct ToolCallDeltaWire {
1210    index: usize,
1211    #[serde(default)]
1212    id: Option<String>,
1213    #[serde(default)]
1214    function: Option<FunctionDeltaWire>,
1215}
1216
1217#[derive(Debug, Deserialize, Default)]
1218struct FunctionDeltaWire {
1219    #[serde(default)]
1220    name: Option<String>,
1221    #[serde(default)]
1222    arguments: Option<String>,
1223}
1224
1225/// Local accumulator for streaming tool calls. Indexed by the wire `index`
1226/// field; assembled into a `ToolCall` once the stream ends.
1227#[derive(Debug, Default)]
1228struct PartialToolCall {
1229    id: Option<String>,
1230    name: Option<String>,
1231    arguments_buf: String,
1232}
1233
1234impl PartialToolCall {
1235    fn into_tool_call(self) -> Option<ToolCall> {
1236        let name = self.name?;
1237        // Empty arguments buffer → empty JSON object. OpenAI's contract
1238        // is that `arguments` is a JSON-encoded string; parse it back.
1239        let arguments: Value = if self.arguments_buf.is_empty() {
1240            json!({})
1241        } else {
1242            match serde_json::from_str(&self.arguments_buf) {
1243                Ok(v) => v,
1244                Err(_) => {
1245                    // Malformed JSON: surface the raw string so the
1246                    // executor can decide how to handle it. The agent
1247                    // loop's parse-error path will catch this.
1248                    Value::String(self.arguments_buf)
1249                },
1250            }
1251        };
1252        Some(ToolCall {
1253            id: self.id,
1254            function: FunctionCall { name, arguments },
1255        })
1256    }
1257}
1258
1259fn accumulate_tool_call(partials: &mut Vec<PartialToolCall>, delta: ToolCallDeltaWire) {
1260    // Bound the stream-controlled index before it drives an allocation. A
1261    // crafted or buggy upstream could send `index: usize::MAX`, which would
1262    // otherwise try to grow `partials` by billions of entries and OOM the
1263    // (long-lived) daemon. No real response has this many parallel calls.
1264    if delta.index >= crate::constants::MAX_TOOL_CALLS {
1265        tracing::warn!(
1266            index = delta.index,
1267            "dropping tool-call delta with implausible index",
1268        );
1269        return;
1270    }
1271    while partials.len() <= delta.index {
1272        partials.push(PartialToolCall::default());
1273    }
1274    let slot = &mut partials[delta.index];
1275    if let Some(id) = delta.id {
1276        slot.id = Some(id);
1277    }
1278    if let Some(func) = delta.function {
1279        if let Some(name) = func.name {
1280            slot.name = Some(name);
1281        }
1282        if let Some(args) = func.arguments {
1283            push_tool_arg(&mut slot.arguments_buf, &args);
1284        }
1285    }
1286}
1287
1288fn parse_full_tool_call(wire: ToolCallWire) -> ToolCall {
1289    let name = wire.function.name;
1290    let arguments: Value = if wire.function.arguments.is_empty() {
1291        json!({})
1292    } else {
1293        match serde_json::from_str(&wire.function.arguments) {
1294            Ok(v) => v,
1295            Err(_) => Value::String(wire.function.arguments),
1296        }
1297    };
1298    ToolCall {
1299        id: wire.id,
1300        function: FunctionCall { name, arguments },
1301    }
1302}
1303
1304#[derive(Debug, Deserialize)]
1305struct ListModelsResponse {
1306    data: Vec<ModelInfo>,
1307}
1308
1309/// One `/models` entry. Providers decorate the OpenAI-standard `{id}` with
1310/// their own limit metadata — OpenRouter sends `context_length` +
1311/// `top_provider.max_completion_tokens`, others use `context_window` /
1312/// `max_completion_tokens` flat, Cloudflare's `models/search?format=openrouter`
1313/// sends `context_length` + `max_output_length`. All optional; absent fields
1314/// deserialize to `None` instead of failing the whole list.
1315#[derive(Debug, Deserialize)]
1316struct ModelInfo {
1317    id: String,
1318    #[serde(default)]
1319    context_length: Option<usize>,
1320    #[serde(default)]
1321    context_window: Option<usize>,
1322    #[serde(default)]
1323    max_completion_tokens: Option<usize>,
1324    #[serde(default)]
1325    max_output_tokens: Option<usize>,
1326    #[serde(default)]
1327    max_output_length: Option<usize>,
1328    #[serde(default)]
1329    top_provider: Option<TopProviderInfo>,
1330}
1331
1332/// OpenRouter's per-model routing metadata (the shape its `/models` uses for
1333/// limits).
1334#[derive(Debug, Deserialize)]
1335struct TopProviderInfo {
1336    #[serde(default)]
1337    context_length: Option<usize>,
1338    #[serde(default)]
1339    max_completion_tokens: Option<usize>,
1340}
1341
1342/// A `/models` entry with whatever limit metadata the provider exposed.
1343#[derive(Debug, Clone, PartialEq, Eq)]
1344pub struct ModelListing {
1345    pub id: String,
1346    pub max_context_tokens: Option<usize>,
1347    pub max_output_tokens: Option<usize>,
1348}
1349
1350impl From<ModelInfo> for ModelListing {
1351    fn from(m: ModelInfo) -> Self {
1352        let top = m.top_provider.as_ref();
1353        ModelListing {
1354            max_context_tokens: m
1355                .context_length
1356                .or(m.context_window)
1357                .or_else(|| top.and_then(|t| t.context_length)),
1358            max_output_tokens: m
1359                .max_completion_tokens
1360                .or(m.max_output_tokens)
1361                .or(m.max_output_length)
1362                .or_else(|| top.and_then(|t| t.max_completion_tokens)),
1363            id: m.id,
1364        }
1365    }
1366}
1367
1368/// Percent-encode a URL query value: RFC 3986 unreserved characters pass
1369/// through, everything else (including `/` and `@` in Cloudflare model ids)
1370/// is `%XX`-escaped. Only used for the `models/search` `search` param —
1371/// reqwest's `.query()` lives behind a cargo feature this crate doesn't pull.
1372fn encode_query_value(value: &str) -> String {
1373    let mut out = String::with_capacity(value.len());
1374    for byte in value.bytes() {
1375        match byte {
1376            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
1377                out.push(byte as char);
1378            },
1379            other => out.push_str(&format!("%{other:02X}")),
1380        }
1381    }
1382    out
1383}
1384
1385/// Cloudflare `models/search` (default format): each model's limits ride a
1386/// `properties` array of `{property_id, value}` pairs, where `value` is a
1387/// JSON *string* for scalars (`context_window: "262144"`) and an array for
1388/// `price` — hence `serde_json::Value`. This format covers the full catalog
1389/// (269+ models), unlike `format=openrouter`'s curated subset, but carries
1390/// no output-cap property.
1391#[derive(Debug, Deserialize)]
1392struct CfModelsSearchResponse {
1393    result: Vec<CfModelEntry>,
1394}
1395
1396#[derive(Debug, Deserialize)]
1397struct CfModelEntry {
1398    /// The full model id (`@cf/vendor/model`).
1399    name: String,
1400    #[serde(default)]
1401    properties: Vec<CfModelProperty>,
1402}
1403
1404#[derive(Debug, Deserialize)]
1405struct CfModelProperty {
1406    property_id: String,
1407    #[serde(default)]
1408    value: serde_json::Value,
1409}
1410
1411impl From<CfModelEntry> for ModelListing {
1412    fn from(m: CfModelEntry) -> Self {
1413        let max_context_tokens = m
1414            .properties
1415            .iter()
1416            .find(|p| p.property_id == "context_window")
1417            .and_then(|p| p.value.as_str())
1418            .and_then(|s| s.parse().ok());
1419        ModelListing {
1420            id: m.name,
1421            max_context_tokens,
1422            max_output_tokens: None,
1423        }
1424    }
1425}
1426
1427// ===== Inline <think> tag stripping (Wave 6) =====
1428//
1429// Some OpenAI-compatible providers (Together for DeepSeek-R1, Groq in
1430// `reasoning_format=raw` mode, Fireworks Qwen with `/think` suffixes)
1431// emit reasoning content as `<think>...</think>` tag pairs inside
1432// `delta.content` instead of in a separate `delta.reasoning_content`
1433// field. This state machine consumes content-channel bytes one at a
1434// time and routes them to either the text channel (outside tags) or the
1435// reasoning channel (inside tags). Tags can split across SSE chunks
1436// (`<thi` + `nk>`), so prefix bytes that *could* be the start of a tag
1437// are buffered until enough data arrives to disambiguate.
1438//
1439// Tag matching is case-sensitive on the literal `<think>` and `</think>`
1440// strings. Other angle-bracketed sequences (`<other>`, `<<`) flow
1441// through to the text channel unchanged.
1442
1443const THINK_OPEN: &str = "<think>";
1444const THINK_CLOSE: &str = "</think>";
1445
1446#[derive(Debug, Default)]
1447pub(crate) struct ThinkTagState {
1448    /// Bytes that could be the start of `<think>` or `</think>` and
1449    /// haven't been disambiguated yet. Always a prefix of one of those
1450    /// two strings (max 8 bytes).
1451    pending: String,
1452    /// True when we're currently between `<think>` and `</think>`.
1453    inside: bool,
1454}
1455
1456impl ThinkTagState {
1457    pub(crate) fn new() -> Self {
1458        Self::default()
1459    }
1460
1461    /// Feed a chunk of content text, returning `(text_out, reasoning_out)`.
1462    /// Either string may be empty.
1463    pub(crate) fn feed(&mut self, chunk: &str) -> (String, String) {
1464        let mut text = String::new();
1465        let mut reasoning = String::new();
1466        // Prepend any buffered prefix bytes from the previous chunk so
1467        // we can scan continuously.
1468        let mut buf = std::mem::take(&mut self.pending);
1469        buf.push_str(chunk);
1470
1471        let mut i = 0usize;
1472        while i < buf.len() {
1473            // The marker we're hunting for changes based on which side of
1474            // the tag pair we're currently on.
1475            let marker = if self.inside { THINK_CLOSE } else { THINK_OPEN };
1476            let remaining = &buf[i..];
1477
1478            // Look for a complete marker.
1479            if let Some(idx) = remaining.find(marker) {
1480                let (before, _after) = remaining.split_at(idx);
1481                if self.inside {
1482                    reasoning.push_str(before);
1483                } else {
1484                    text.push_str(before);
1485                }
1486                self.inside = !self.inside;
1487                i += idx + marker.len();
1488                continue;
1489            }
1490
1491            // No complete marker. Check whether the tail of `remaining`
1492            // could be the start of one — if so, buffer those bytes for
1493            // the next call. Anything before that goes out now.
1494            //
1495            // Markers are pure ASCII, so any matching tail is also pure
1496            // ASCII. We use `str::ends_with(&str)` (byte-based suffix
1497            // compare; doesn't slice into the string) to avoid panicking
1498            // on multi-byte codepoints near the end of `remaining`. Try
1499            // longest-prefix first (greedy: if `<thi` fits, hold it
1500            // rather than holding just `<`).
1501            let mut hold_len: Option<usize> = None;
1502            for back in (1..marker.len()).rev() {
1503                let candidate = &marker[..back];
1504                if remaining.ends_with(candidate) {
1505                    hold_len = Some(back);
1506                    break;
1507                }
1508            }
1509
1510            if let Some(back) = hold_len {
1511                let split_at = remaining.len() - back;
1512                let (before, hold) = remaining.split_at(split_at);
1513                if self.inside {
1514                    reasoning.push_str(before);
1515                } else {
1516                    text.push_str(before);
1517                }
1518                self.pending = hold.to_string();
1519            } else if self.inside {
1520                reasoning.push_str(remaining);
1521            } else {
1522                text.push_str(remaining);
1523            }
1524            break;
1525        }
1526
1527        (text, reasoning)
1528    }
1529
1530    /// Flush any pending buffered bytes at end-of-stream. Called once
1531    /// after the last chunk arrives. Trailing partial-tag bytes are
1532    /// emitted to the text channel as a fallback (better to surface them
1533    /// than silently drop, in case the stream truly ended mid-tag).
1534    pub(crate) fn flush(&mut self) -> (String, String) {
1535        let pending = std::mem::take(&mut self.pending);
1536        if self.inside {
1537            (String::new(), pending)
1538        } else {
1539            (pending, String::new())
1540        }
1541    }
1542}
1543
1544#[cfg(test)]
1545mod tests {
1546    use super::*;
1547    use crate::models::providers::lookup_provider;
1548
1549    #[test]
1550    fn model_listing_parses_provider_limit_shapes() {
1551        // OpenRouter shape: context_length + top_provider.max_completion_tokens.
1552        let openrouter: ListModelsResponse = serde_json::from_str(
1553            r#"{"data":[{"id":"z-ai/glm-5.2","context_length":1000000,
1554                 "top_provider":{"context_length":1000000,"max_completion_tokens":32000}}]}"#,
1555        )
1556        .unwrap();
1557        let m = ModelListing::from(openrouter.data.into_iter().next().unwrap());
1558        assert_eq!(m.id, "z-ai/glm-5.2");
1559        assert_eq!(m.max_context_tokens, Some(1_000_000));
1560        assert_eq!(m.max_output_tokens, Some(32_000));
1561
1562        // Flat shape: context_window + max_output_tokens.
1563        let flat: ListModelsResponse = serde_json::from_str(
1564            r#"{"data":[{"id":"m","context_window":128000,"max_output_tokens":16384}]}"#,
1565        )
1566        .unwrap();
1567        let m = ModelListing::from(flat.data.into_iter().next().unwrap());
1568        assert_eq!(m.max_context_tokens, Some(128_000));
1569        assert_eq!(m.max_output_tokens, Some(16_384));
1570
1571        // Bare OpenAI shape: id only — everything None, nothing fails.
1572        let bare: ListModelsResponse =
1573            serde_json::from_str(r#"{"data":[{"id":"gpt-x","object":"model"}]}"#).unwrap();
1574        let m = ModelListing::from(bare.data.into_iter().next().unwrap());
1575        assert_eq!(m.max_context_tokens, None);
1576        assert_eq!(m.max_output_tokens, None);
1577    }
1578
1579    #[test]
1580    fn model_listing_parses_cloudflare_openrouter_shape() {
1581        // Cloudflare `models/search?format=openrouter` (captured live
1582        // 2026-07-09): `context_length` + `max_output_length` — the output
1583        // cap rides a field name no other provider uses.
1584        let cf: ListModelsResponse = serde_json::from_str(
1585            r#"{"data":[{"id":"@cf/zai-org/glm-5.2","hugging_face_id":"zai-org/glm-5.2",
1586                 "context_length":262144,"max_output_length":262144,
1587                 "pricing":{"prompt":"0.0000014000","completion":"0.0000044000"}}]}"#,
1588        )
1589        .unwrap();
1590        let m = ModelListing::from(cf.data.into_iter().next().unwrap());
1591        assert_eq!(m.id, "@cf/zai-org/glm-5.2");
1592        assert_eq!(m.max_context_tokens, Some(262_144));
1593        assert_eq!(m.max_output_tokens, Some(262_144));
1594    }
1595
1596    #[test]
1597    fn cloudflare_models_search_default_format_parses_properties() {
1598        // Default-format `models/search` (captured live 2026-07-09): limits
1599        // ride a `properties` array; `context_window` is a JSON *string*,
1600        // `price` is an array — neither shape may fail the parse, and models
1601        // without the property (or without properties at all) stay `None`.
1602        let body: CfModelsSearchResponse = serde_json::from_str(
1603            r#"{"success":true,"result":[
1604                 {"name":"@cf/zai-org/glm-5.2","description":"agentic coding model",
1605                  "properties":[
1606                    {"property_id":"context_window","value":"262144"},
1607                    {"property_id":"price",
1608                     "value":[{"unit":"per M input tokens","price":1.4,"currency":"USD"}]},
1609                    {"property_id":"function_calling","value":"true"}]},
1610                 {"name":"@cf/meta/no-window","properties":[
1611                    {"property_id":"function_calling","value":"true"}]},
1612                 {"name":"@cf/meta/bare"}]}"#,
1613        )
1614        .unwrap();
1615        let listings: Vec<ModelListing> = body.result.into_iter().map(ModelListing::from).collect();
1616        assert_eq!(listings[0].id, "@cf/zai-org/glm-5.2");
1617        assert_eq!(listings[0].max_context_tokens, Some(262_144));
1618        // The default format carries no output-cap property.
1619        assert_eq!(listings[0].max_output_tokens, None);
1620        assert_eq!(listings[1].max_context_tokens, None);
1621        assert_eq!(listings[2].max_context_tokens, None);
1622    }
1623
1624    #[test]
1625    fn query_value_encoding_escapes_reserved_bytes() {
1626        // Unreserved characters pass through untouched.
1627        assert_eq!(encode_query_value("glm-5.2"), "glm-5.2");
1628        // Reserved/special bytes are %XX-escaped (full model ids included).
1629        assert_eq!(
1630            encode_query_value("@cf/zai-org/glm-5.2"),
1631            "%40cf%2Fzai-org%2Fglm-5.2"
1632        );
1633        assert_eq!(encode_query_value("a b&c=d"), "a%20b%26c%3Dd");
1634    }
1635
1636    #[test]
1637    fn cloudflare_models_search_base_derives_only_from_account_scoped_url() {
1638        let cloudflare = lookup_provider("cloudflare").unwrap();
1639        let adapter = |base: &str, profile: &'static ProviderProfile| {
1640            OpenAICompatAdapter::new(
1641                profile,
1642                base.to_string(),
1643                Some("test-token".to_string()),
1644                "@cf/zai-org/glm-5.2".to_string(),
1645                HashMap::new(),
1646            )
1647            .expect("adapter constructs")
1648        };
1649        // Canonical account-scoped URL → the management search endpoint.
1650        let a = adapter(
1651            "https://api.cloudflare.com/client/v4/accounts/abc123/ai/v1",
1652            cloudflare,
1653        );
1654        assert_eq!(
1655            a.cloudflare_models_search_base().as_deref(),
1656            Some("https://api.cloudflare.com/client/v4/accounts/abc123/ai/models/search"),
1657        );
1658        // Trailing slash tolerated.
1659        let a = adapter(
1660            "https://api.cloudflare.com/client/v4/accounts/abc123/ai/v1/",
1661            cloudflare,
1662        );
1663        assert!(a.cloudflare_models_search_base().is_some());
1664        // AI Gateway override ends in `workers-ai/v1`, which is NOT the
1665        // account-scoped `/ai/v1` — no derivation, generic fallback.
1666        let a = adapter(
1667            "https://gateway.ai.cloudflare.com/v1/abc/gw/workers-ai/v1",
1668            cloudflare,
1669        );
1670        assert_eq!(a.cloudflare_models_search_base(), None);
1671        // Non-cloudflare profile never derives, even from a lookalike URL.
1672        let a = adapter(
1673            "https://api.cloudflare.com/client/v4/accounts/abc123/ai/v1",
1674            test_profile(),
1675        );
1676        assert_eq!(a.cloudflare_models_search_base(), None);
1677    }
1678
1679    #[test]
1680    fn maps_openai_finish_reasons() {
1681        assert_eq!(map_openai_finish_reason("stop"), FinishReason::Stop);
1682        assert_eq!(map_openai_finish_reason("length"), FinishReason::Length);
1683        assert_eq!(
1684            map_openai_finish_reason("tool_calls"),
1685            FinishReason::ToolUse
1686        );
1687        assert_eq!(
1688            map_openai_finish_reason("content_filter"),
1689            FinishReason::ContentFilter
1690        );
1691    }
1692
1693    #[test]
1694    fn stream_closed_abnormally_distinguishes_drop_from_completion() {
1695        // F56: no finish_reason observed → the stream dropped mid-response and
1696        // must surface as a stream error, not a clean Ok.
1697        assert!(stream_closed_abnormally(None));
1698        // A real terminal finish_reason → clean completion.
1699        assert!(!stream_closed_abnormally(Some(&FinishReason::Stop)));
1700        assert!(!stream_closed_abnormally(Some(&FinishReason::ToolUse)));
1701        // CRUCIAL: a `length` truncation is a real finish_reason — NOT abnormal.
1702        assert!(!stream_closed_abnormally(Some(&FinishReason::Length)));
1703    }
1704
1705    #[test]
1706    fn think_tags_stripped_via_feed_then_flush() {
1707        // The non-streaming InlineThinkTags path feeds the whole body then
1708        // flushes, splitting reasoning out of content (#5).
1709        let mut ts = ThinkTagState::new();
1710        let (mut text, mut reasoning) = ts.feed("<think>weighing</think>answer");
1711        let (t2, r2) = ts.flush();
1712        text.push_str(&t2);
1713        reasoning.push_str(&r2);
1714        assert_eq!(text, "answer");
1715        assert_eq!(reasoning, "weighing");
1716    }
1717
1718    #[test]
1719    fn accumulate_tool_call_drops_implausible_index() {
1720        // H9: a stream-controlled huge index must not grow the Vec.
1721        let mut partials: Vec<PartialToolCall> = Vec::new();
1722        let delta: ToolCallDeltaWire =
1723            serde_json::from_value(serde_json::json!({"index": 1_000_000})).unwrap();
1724        accumulate_tool_call(&mut partials, delta);
1725        assert!(partials.is_empty(), "huge index must be dropped");
1726
1727        // A normal index still accumulates.
1728        let ok: ToolCallDeltaWire =
1729            serde_json::from_value(serde_json::json!({"index": 0, "function": {"name": "x"}}))
1730                .unwrap();
1731        accumulate_tool_call(&mut partials, ok);
1732        assert_eq!(partials.len(), 1);
1733    }
1734
1735    fn test_profile() -> &'static ProviderProfile {
1736        lookup_provider("openai").expect("openai is in the registry")
1737    }
1738
1739    fn test_adapter() -> OpenAICompatAdapter {
1740        OpenAICompatAdapter::new(
1741            test_profile(),
1742            "https://api.openai.com/v1".to_string(),
1743            Some("test-key".to_string()),
1744            "gpt-5-mini".to_string(),
1745            HashMap::new(),
1746        )
1747        .expect("adapter constructs")
1748    }
1749
1750    #[test]
1751    fn chat_completion_chunk_parses_usage_only_frame() {
1752        // #123: a final usage-only frame carries no `choices`; with the field
1753        // defaulted it must parse instead of failing "missing field choices".
1754        let chunk: ChatCompletionChunk = serde_json::from_str(
1755            r#"{"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}"#,
1756        )
1757        .expect("usage-only frame must parse");
1758        assert!(chunk.choices.is_empty());
1759        assert!(chunk.usage.is_some());
1760    }
1761
1762    #[test]
1763    fn reasoning_models_omit_temperature_per_catalog() {
1764        use crate::models::catalog::lookup;
1765        for m in [
1766            "o1",
1767            "o1-mini",
1768            "o3",
1769            "o3-mini",
1770            "o4-mini",
1771            "gpt-5",
1772            "gpt-5-mini",
1773        ] {
1774            assert!(
1775                !lookup(m).supports_temperature,
1776                "{m} should omit temperature"
1777            );
1778        }
1779        for m in ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "chatgpt-4o-latest"] {
1780            assert!(
1781                lookup(m).supports_temperature,
1782                "{m} should send temperature"
1783            );
1784        }
1785    }
1786
1787    #[test]
1788    fn token_usage_from_wire_derives_total_from_components() {
1789        let usage = token_usage_from_wire(UsageWire {
1790            prompt_tokens: Some(100),
1791            completion_tokens: Some(25),
1792            prompt_tokens_details: None,
1793            completion_tokens_details: None,
1794            input_tokens_details: None,
1795            output_tokens_details: None,
1796        });
1797
1798        assert_eq!(usage.prompt_tokens, 100);
1799        assert_eq!(usage.completion_tokens, 25);
1800        assert_eq!(usage.total_tokens(), 125);
1801    }
1802
1803    #[test]
1804    fn token_usage_from_wire_keeps_components_disjoint() {
1805        // OpenAI nests cached inside prompt_tokens and reasoning inside
1806        // completion_tokens; both must be carved out so the derived totals
1807        // don't double-count, and the derived total equals the wire total.
1808        let usage = token_usage_from_wire(UsageWire {
1809            prompt_tokens: Some(100),
1810            completion_tokens: Some(25),
1811            prompt_tokens_details: Some(PromptTokensDetailsWire {
1812                cached_tokens: Some(40),
1813            }),
1814            completion_tokens_details: Some(CompletionTokensDetailsWire {
1815                reasoning_tokens: Some(12),
1816            }),
1817            input_tokens_details: None,
1818            output_tokens_details: None,
1819        });
1820
1821        assert_eq!(usage.prompt_tokens, 60);
1822        assert_eq!(usage.cached_input_tokens, 40);
1823        assert_eq!(usage.completion_tokens, 13);
1824        assert_eq!(usage.reasoning_output_tokens, 12);
1825        assert_eq!(usage.total_tokens(), 125);
1826    }
1827
1828    #[test]
1829    fn cache_hit_does_not_double_count_input_total() {
1830        // #6: OpenAI nests cached tokens inside prompt_tokens; input_total must
1831        // be 100 (the real input), not 100 + 40.
1832        let usage = token_usage_from_wire(UsageWire {
1833            prompt_tokens: Some(100),
1834            completion_tokens: Some(25),
1835            prompt_tokens_details: Some(PromptTokensDetailsWire {
1836                cached_tokens: Some(40),
1837            }),
1838            completion_tokens_details: None,
1839            input_tokens_details: None,
1840            output_tokens_details: None,
1841        });
1842        assert_eq!(usage.input_total_tokens(), 100);
1843        assert_eq!(usage.prompt_tokens, 60);
1844        assert_eq!(usage.cached_input_tokens, 40);
1845    }
1846
1847    #[test]
1848    fn reasoning_does_not_double_count_output_total() {
1849        // OpenAI nests reasoning tokens inside completion_tokens; output_total
1850        // must be 100 (the wire completion), not 100 + 40.
1851        let usage = token_usage_from_wire(UsageWire {
1852            prompt_tokens: Some(10),
1853            completion_tokens: Some(100),
1854            prompt_tokens_details: None,
1855            completion_tokens_details: Some(CompletionTokensDetailsWire {
1856                reasoning_tokens: Some(40),
1857            }),
1858            input_tokens_details: None,
1859            output_tokens_details: None,
1860        });
1861        assert_eq!(usage.completion_tokens, 60);
1862        assert_eq!(usage.reasoning_output_tokens, 40);
1863        assert_eq!(usage.output_total_tokens(), 100);
1864    }
1865
1866    #[test]
1867    fn capabilities_reflect_profile() {
1868        let adapter = test_adapter();
1869        let caps = adapter.capabilities();
1870        assert!(caps.supports_tools);
1871        // gpt-5-mini (the test model) is vision-capable — the flag is now
1872        // model-driven and reflects that, rather than being hardcoded false.
1873        assert!(caps.supports_vision);
1874        match &caps.supports_reasoning {
1875            ReasoningCapability::Levels(levels) => {
1876                assert!(levels.contains(&ReasoningLevel::Medium));
1877                assert!(levels.contains(&ReasoningLevel::Max));
1878            },
1879            other => panic!("expected Levels for openai, got {:?}", other),
1880        }
1881    }
1882
1883    #[test]
1884    fn model_vision_detection_is_model_driven() {
1885        for vision in [
1886            "gpt-4o",
1887            "gpt-4o-mini",
1888            "gpt-5-mini",
1889            "openai/gpt-4.1",
1890            "anthropic/claude-3.5-sonnet",
1891            "google/gemini-2.0-flash",
1892            "qwen/qwen2.5-vl-7b-instruct",
1893            "mistralai/pixtral-12b",
1894            "meta-llama/llama-4-scout",
1895        ] {
1896            assert!(
1897                crate::models::catalog::lookup(vision).vision,
1898                "{vision} should be detected as vision-capable"
1899            );
1900        }
1901        for text_only in [
1902            "gpt-3.5-turbo",
1903            "groq/llama-3.3-70b-versatile",
1904            "deepseek-r1",
1905            "mistralai/mistral-7b-instruct",
1906            "qwen/qwen2.5-coder-32b",
1907        ] {
1908            assert!(
1909                !crate::models::catalog::lookup(text_only).vision,
1910                "{text_only} should be detected as text-only"
1911            );
1912        }
1913    }
1914
1915    #[test]
1916    fn capabilities_unsupported_for_no_reasoning_provider() {
1917        let together = lookup_provider("together").unwrap();
1918        let adapter = OpenAICompatAdapter::new(
1919            together,
1920            together.base_url.to_string(),
1921            Some("k".to_string()),
1922            "deepseek-r1".to_string(),
1923            HashMap::new(),
1924        )
1925        .unwrap();
1926        assert_eq!(
1927            adapter.capabilities().supports_reasoning,
1928            ReasoningCapability::Unsupported
1929        );
1930    }
1931
1932    #[test]
1933    fn name_returns_model_name() {
1934        let adapter = test_adapter();
1935        assert_eq!(adapter.name(), "gpt-5-mini");
1936    }
1937
1938    /// Adapter contract (see `MessageAudience`): harness steering must reach
1939    /// the model. This shape carries a native mid-conversation system role, so
1940    /// it passes through in place — at the history TAIL, which is the position
1941    /// the plan-mode reminder depends on.
1942    #[test]
1943    fn model_directed_system_messages_reach_the_wire_in_place() {
1944        use crate::models::ChatMessageKind;
1945        let adapter = test_adapter();
1946        let mut nudge = ChatMessage::system("Reminder: plan mode is active.");
1947        nudge.kind = ChatMessageKind::RecoveryNudge;
1948        let messages = vec![ChatMessage::user("ok"), nudge];
1949        let body = adapter.build_request_body(&messages, &ModelConfig::default(), false);
1950
1951        let msgs = body["messages"].as_array().expect("messages array");
1952        let last = msgs.last().expect("non-empty");
1953        assert_eq!(last["role"], "system", "delivered as a system turn");
1954        assert!(
1955            last["content"]
1956                .as_str()
1957                .unwrap()
1958                .contains("plan mode is active"),
1959        );
1960    }
1961
1962    #[test]
1963    fn build_request_body_includes_basic_fields() {
1964        let adapter = test_adapter();
1965        let messages = vec![ChatMessage::user("hello")];
1966        let config = ModelConfig::default();
1967        let body = adapter.build_request_body(&messages, &config, true);
1968        assert_eq!(body["model"], "gpt-5-mini");
1969        assert_eq!(body["stream"], true);
1970        assert!(body["messages"].is_array());
1971        // Default reasoning is Medium → Effort strategy emits the field.
1972        assert_eq!(body["reasoning_effort"], "medium");
1973    }
1974
1975    #[test]
1976    fn build_request_body_maps_output_schema_to_response_format() {
1977        let adapter = test_adapter();
1978        let messages = vec![ChatMessage::user("format it")];
1979        let config = ModelConfig {
1980            output_schema: Some(serde_json::json!({
1981                "type": "object",
1982                "properties": {"answer": {"type": "integer"}}
1983            })),
1984            ..Default::default()
1985        };
1986        let body = adapter.build_request_body(&messages, &config, false);
1987        assert_eq!(body["response_format"]["type"], "json_schema");
1988        assert_eq!(body["response_format"]["json_schema"]["name"], "output");
1989        assert_eq!(body["response_format"]["json_schema"]["strict"], false);
1990        assert_eq!(
1991            body["response_format"]["json_schema"]["schema"]["type"],
1992            "object"
1993        );
1994        // Absent -> no response_format at all.
1995        let body = adapter.build_request_body(&messages, &ModelConfig::default(), false);
1996        assert!(body.get("response_format").is_none());
1997    }
1998
1999    #[test]
2000    fn build_request_body_serializes_tool_calls_in_openai_shape() {
2001        // A replayed assistant tool call must carry `type: "function"` and
2002        // `function.arguments` as a JSON-ENCODED STRING. Serializing the internal
2003        // ToolCall struct directly emitted an object with no `type`, which strict
2004        // endpoints (OpenAI, Groq) 400 on the next turn of a tool loop.
2005        let adapter = test_adapter();
2006        let tc = crate::models::tool_call::ToolCall {
2007            id: Some("call_abc".to_string()),
2008            function: crate::models::tool_call::FunctionCall {
2009                name: "read_file".to_string(),
2010                arguments: serde_json::json!({"path": "src/main.rs"}),
2011            },
2012        };
2013        let messages = vec![ChatMessage::assistant("").with_tool_calls(vec![tc])];
2014        let body = adapter.build_request_body(&messages, &ModelConfig::default(), false);
2015        let msgs = body["messages"].as_array().unwrap();
2016        let assistant = msgs
2017            .iter()
2018            .find(|m| m["role"] == "assistant")
2019            .expect("assistant message present");
2020        let call = &assistant["tool_calls"][0];
2021        assert_eq!(call["type"], "function");
2022        assert_eq!(call["id"], "call_abc");
2023        assert_eq!(call["function"]["name"], "read_file");
2024        let args = call["function"]["arguments"]
2025            .as_str()
2026            .expect("arguments must be a JSON-encoded string, not an object");
2027        assert!(args.contains("\"path\"") && args.contains("src/main.rs"));
2028    }
2029
2030    #[test]
2031    fn build_request_body_wires_user_images_as_vision_parts() {
2032        // Images on a user message must reach vision models as OpenAI content
2033        // parts — they were silently dropped before.
2034        let adapter = test_adapter();
2035        let messages =
2036            vec![ChatMessage::user("what is this").with_images(vec!["BASE64DATA".to_string()])];
2037        let body = adapter.build_request_body(&messages, &ModelConfig::default(), false);
2038        let msgs = body["messages"].as_array().unwrap();
2039        let user = msgs
2040            .iter()
2041            .find(|m| m["role"] == "user")
2042            .expect("user message present");
2043        let parts = user["content"]
2044            .as_array()
2045            .expect("content must be an array when images are present");
2046        assert!(
2047            parts
2048                .iter()
2049                .any(|p| p["type"] == "text" && p["text"] == "what is this")
2050        );
2051        let image = parts
2052            .iter()
2053            .find(|p| p["type"] == "image_url")
2054            .expect("an image_url part");
2055        assert_eq!(
2056            image["image_url"]["url"],
2057            "data:image/png;base64,BASE64DATA"
2058        );
2059    }
2060
2061    #[test]
2062    fn build_request_body_plain_user_message_keeps_string_content() {
2063        // The common path (no images) must still serialize `content` as a plain
2064        // string, not an array.
2065        let adapter = test_adapter();
2066        let body =
2067            adapter.build_request_body(&[ChatMessage::user("hi")], &ModelConfig::default(), false);
2068        let msgs = body["messages"].as_array().unwrap();
2069        let user = msgs.iter().find(|m| m["role"] == "user").unwrap();
2070        assert!(user["content"].is_string());
2071        assert_eq!(user["content"], "hi");
2072    }
2073
2074    #[test]
2075    fn build_request_body_includes_system_prompt() {
2076        let adapter = test_adapter();
2077        let messages = vec![ChatMessage::user("hi")];
2078        let config = ModelConfig {
2079            system_prompt: Some("You are a helpful assistant.".to_string()),
2080            ..Default::default()
2081        };
2082        let body = adapter.build_request_body(&messages, &config, false);
2083        let messages_arr = body["messages"].as_array().unwrap();
2084        assert_eq!(messages_arr[0]["role"], "system");
2085        assert_eq!(messages_arr[0]["content"], "You are a helpful assistant.");
2086    }
2087
2088    /// Step 5h: OpenAI-compat doesn't expose per-block cache markers, so
2089    /// the dynamic MERMAID.md suffix is concatenated onto the static system
2090    /// message with a `---` separator. Single system message; both halves
2091    /// reach the model in one content payload.
2092    #[test]
2093    fn build_request_body_concats_dynamic_suffix_to_system_message() {
2094        let adapter = test_adapter();
2095        let messages = vec![ChatMessage::user("hi")];
2096        let config = ModelConfig {
2097            system_prompt: Some("You are Mermaid.".to_string()),
2098            dynamic_system_suffix: Some("Project rule: always snake_case.".to_string()),
2099            ..Default::default()
2100        };
2101        let body = adapter.build_request_body(&messages, &config, false);
2102        let messages_arr = body["messages"].as_array().unwrap();
2103        assert_eq!(messages_arr[0]["role"], "system");
2104        let content = messages_arr[0]["content"].as_str().unwrap();
2105        assert!(content.contains("You are Mermaid."));
2106        assert!(content.contains("Project rule: always snake_case."));
2107        assert!(content.contains("---"));
2108    }
2109
2110    #[test]
2111    fn build_request_body_includes_tools_and_omits_temperature_for_reasoning() {
2112        // gpt-5-mini is a reasoning model: tools still pass through, but
2113        // `temperature` must be omitted — OpenAI 400s on it (#124).
2114        let adapter = test_adapter();
2115        let messages = vec![ChatMessage::user("hi")];
2116        // v7: tools come from config (populated by the provider
2117        // wrapper); adapter passes them through in OpenAI shape.
2118        let config = ModelConfig {
2119            tools: (0..5)
2120                .map(|i| {
2121                    serde_json::json!({
2122                        "type": "function",
2123                        "function": {
2124                            "name": format!("tool_{}", i),
2125                            "description": "a test tool",
2126                            "parameters": {"type": "object"}
2127                        }
2128                    })
2129                })
2130                .collect(),
2131            ..Default::default()
2132        };
2133        let body = adapter.build_request_body(&messages, &config, true);
2134        assert!(body["tools"].is_array());
2135        assert_eq!(body["tools"].as_array().unwrap().len(), 5);
2136        assert!(
2137            body.get("temperature").is_none(),
2138            "a reasoning model must omit temperature, got {:?}",
2139            body.get("temperature")
2140        );
2141    }
2142
2143    #[test]
2144    fn build_request_body_preserves_registry_selected_web_tools() {
2145        let adapter = test_adapter();
2146        let config = ModelConfig {
2147            tools: ["web_fetch", "web_search"]
2148                .into_iter()
2149                .map(|name| {
2150                    serde_json::json!({
2151                        "type": "function",
2152                        "function": {
2153                            "name": name,
2154                            "description": "registered web tool",
2155                            "parameters": {"type": "object"}
2156                        }
2157                    })
2158                })
2159                .collect(),
2160            ..Default::default()
2161        };
2162
2163        let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false);
2164        let names: Vec<&str> = body["tools"]
2165            .as_array()
2166            .expect("tools array")
2167            .iter()
2168            .filter_map(|tool| tool.pointer("/function/name").and_then(Value::as_str))
2169            .collect();
2170        assert_eq!(names, ["web_fetch", "web_search"]);
2171    }
2172
2173    #[test]
2174    fn build_request_body_includes_temperature_for_non_reasoning_model() {
2175        // A non-reasoning model (gpt-4o) still receives `temperature` (#124).
2176        let adapter = OpenAICompatAdapter::new(
2177            test_profile(),
2178            "https://api.openai.com/v1".to_string(),
2179            Some("test-key".to_string()),
2180            "gpt-4o".to_string(),
2181            HashMap::new(),
2182        )
2183        .expect("adapter constructs");
2184        let config = ModelConfig::default();
2185        let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false);
2186        assert_eq!(body["temperature"], config.temperature);
2187    }
2188
2189    #[test]
2190    fn cerebras_uses_supported_token_budget_field() {
2191        let cerebras = lookup_provider("cerebras").unwrap();
2192        let adapter = OpenAICompatAdapter::new(
2193            cerebras,
2194            cerebras.base_url.to_string(),
2195            Some("k".to_string()),
2196            "gpt-oss-120b".to_string(),
2197            HashMap::new(),
2198        )
2199        .unwrap();
2200        let messages = vec![ChatMessage::user("hi")];
2201        let config = ModelConfig {
2202            max_tokens: 1234,
2203            ..Default::default()
2204        };
2205        let body = adapter.build_request_body(&messages, &config, true);
2206        assert_eq!(body["max_completion_tokens"], 1234);
2207        assert!(body.get("max_tokens").is_none());
2208    }
2209
2210    #[test]
2211    fn auto_max_tokens_omits_the_cap_field() {
2212        // `max_tokens == 0` is AUTO: omit the cap entirely so the provider
2213        // applies its own per-response maximum (the model-scaled budget).
2214        let groq = lookup_provider("groq").unwrap();
2215        let adapter = OpenAICompatAdapter::new(
2216            groq,
2217            groq.base_url.to_string(),
2218            Some("k".to_string()),
2219            "qwen-qwq-32b".to_string(),
2220            HashMap::new(),
2221        )
2222        .unwrap();
2223        let config = ModelConfig {
2224            max_tokens: 0,
2225            ..Default::default()
2226        };
2227        let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, true);
2228        assert!(body.get("max_tokens").is_none());
2229        assert!(body.get("max_completion_tokens").is_none());
2230    }
2231
2232    #[test]
2233    fn cerebras_gpt_oss_disables_parallel_tool_calls() {
2234        let cerebras = lookup_provider("cerebras").unwrap();
2235        let adapter = OpenAICompatAdapter::new(
2236            cerebras,
2237            cerebras.base_url.to_string(),
2238            Some("k".to_string()),
2239            "gpt-oss-120b".to_string(),
2240            HashMap::new(),
2241        )
2242        .unwrap();
2243        let messages = vec![ChatMessage::user("hi")];
2244        let config = ModelConfig {
2245            tools: vec![serde_json::json!({
2246                "type": "function",
2247                "function": {
2248                    "name": "read_file",
2249                    "description": "read a file",
2250                    "parameters": {"type": "object"}
2251                }
2252            })],
2253            ..Default::default()
2254        };
2255        let body = adapter.build_request_body(&messages, &config, true);
2256        assert_eq!(body["parallel_tool_calls"], false);
2257    }
2258
2259    #[test]
2260    fn build_request_body_omits_reasoning_for_none_strategy() {
2261        let together = lookup_provider("together").unwrap();
2262        let adapter = OpenAICompatAdapter::new(
2263            together,
2264            together.base_url.to_string(),
2265            Some("k".to_string()),
2266            "deepseek-r1".to_string(),
2267            HashMap::new(),
2268        )
2269        .unwrap();
2270        let messages = vec![ChatMessage::user("hi")];
2271        let config = ModelConfig::default();
2272        let body = adapter.build_request_body(&messages, &config, true);
2273        assert!(body.get("reasoning_effort").is_none());
2274        assert!(body.get("reasoning").is_none());
2275    }
2276
2277    /// XHigh on an Effort-strategy provider round-trips intact as
2278    /// `reasoning_effort: "xhigh"`. OpenAI GPT-5.2+ honors it; other
2279    /// providers on this strategy will 400 (explicit failure is
2280    /// preferable to silent downgrade).
2281    #[test]
2282    fn build_request_body_emits_xhigh_for_xhigh_level() {
2283        let adapter = test_adapter();
2284        let messages = vec![ChatMessage::user("hi")];
2285        let config = ModelConfig {
2286            reasoning: ReasoningLevel::XHigh,
2287            ..Default::default()
2288        };
2289        let body = adapter.build_request_body(&messages, &config, true);
2290        assert_eq!(body["reasoning_effort"], "xhigh");
2291    }
2292
2293    /// None on Effort emits the explicit `"none"` string (GPT-5.1+)
2294    /// rather than omitting the field — the user explicitly asked for
2295    /// no reasoning, and we propagate that intent.
2296    #[test]
2297    fn build_request_body_emits_none_for_none_level() {
2298        let adapter = test_adapter();
2299        let messages = vec![ChatMessage::user("hi")];
2300        let config = ModelConfig {
2301            reasoning: ReasoningLevel::None,
2302            ..Default::default()
2303        };
2304        let body = adapter.build_request_body(&messages, &config, true);
2305        assert_eq!(body["reasoning_effort"], "none");
2306    }
2307
2308    /// `Minimal` is in the `Effort`-strategy supported set, so it
2309    /// round-trips intact (OpenAI GPT-5 honors `reasoning_effort:
2310    /// "minimal"`). This locks in the no-silent-drop guarantee for the
2311    /// only level that's restricted to a single provider.
2312    #[test]
2313    fn build_request_body_preserves_minimal_for_effort_strategy() {
2314        let adapter = test_adapter();
2315        let messages = vec![ChatMessage::user("hi")];
2316        let config = ModelConfig {
2317            reasoning: ReasoningLevel::Minimal,
2318            ..Default::default()
2319        };
2320        let body = adapter.build_request_body(&messages, &config, true);
2321        assert_eq!(body["reasoning_effort"], "minimal");
2322    }
2323
2324    /// OpenRouter's normalized object has no `minimal` tier — `Minimal`
2325    /// requests must snap to the next-lowest supported level (`Low`)
2326    /// rather than silently sending `None` or 400ing. Verifies the
2327    /// `nearest_effort` wire-up works for the snap-down case.
2328    #[test]
2329    fn build_request_body_snaps_minimal_to_low_for_openrouter() {
2330        let openrouter = lookup_provider("openrouter").unwrap();
2331        let adapter = OpenAICompatAdapter::new(
2332            openrouter,
2333            openrouter.base_url.to_string(),
2334            Some("k".to_string()),
2335            "anthropic/claude-3.7-sonnet".to_string(),
2336            HashMap::new(),
2337        )
2338        .unwrap();
2339        let messages = vec![ChatMessage::user("hi")];
2340        let config = ModelConfig {
2341            reasoning: ReasoningLevel::Minimal,
2342            ..Default::default()
2343        };
2344        let body = adapter.build_request_body(&messages, &config, true);
2345        // Minimal isn't in OpenRouter's supported set; nearest_effort
2346        // returns None (highest at-or-below). When None lands in
2347        // OpenRouterShape.render, it emits {exclude: true}.
2348        assert_eq!(body["reasoning"], json!({"exclude": true}));
2349    }
2350
2351    #[test]
2352    fn build_request_body_uses_openrouter_shape() {
2353        let openrouter = lookup_provider("openrouter").unwrap();
2354        let adapter = OpenAICompatAdapter::new(
2355            openrouter,
2356            openrouter.base_url.to_string(),
2357            Some("k".to_string()),
2358            "anthropic/claude-3.7-sonnet".to_string(),
2359            HashMap::new(),
2360        )
2361        .unwrap();
2362        let messages = vec![ChatMessage::user("hi")];
2363        let config = ModelConfig {
2364            reasoning: ReasoningLevel::High,
2365            ..Default::default()
2366        };
2367        let body = adapter.build_request_body(&messages, &config, true);
2368        assert_eq!(body["reasoning"], json!({"effort": "high"}));
2369        assert!(body.get("reasoning_effort").is_none());
2370    }
2371
2372    #[test]
2373    fn tool_call_accumulator_assembles_fragmented_args() {
2374        // Simulate the standard 3-chunk OpenAI tool-call streaming
2375        // pattern: chunk 1 carries id+name, chunks 2/3 carry argument
2376        // string fragments.
2377        let mut partials: Vec<PartialToolCall> = Vec::new();
2378
2379        accumulate_tool_call(
2380            &mut partials,
2381            ToolCallDeltaWire {
2382                index: 0,
2383                id: Some("call_abc".to_string()),
2384                function: Some(FunctionDeltaWire {
2385                    name: Some("get_weather".to_string()),
2386                    arguments: Some(String::new()),
2387                }),
2388            },
2389        );
2390        accumulate_tool_call(
2391            &mut partials,
2392            ToolCallDeltaWire {
2393                index: 0,
2394                id: None,
2395                function: Some(FunctionDeltaWire {
2396                    name: None,
2397                    arguments: Some("{\"loc".to_string()),
2398                }),
2399            },
2400        );
2401        accumulate_tool_call(
2402            &mut partials,
2403            ToolCallDeltaWire {
2404                index: 0,
2405                id: None,
2406                function: Some(FunctionDeltaWire {
2407                    name: None,
2408                    arguments: Some("\":\"SF\"}".to_string()),
2409                }),
2410            },
2411        );
2412
2413        let tc = partials
2414            .into_iter()
2415            .next()
2416            .unwrap()
2417            .into_tool_call()
2418            .unwrap();
2419        assert_eq!(tc.id.as_deref(), Some("call_abc"));
2420        assert_eq!(tc.function.name, "get_weather");
2421        assert_eq!(tc.function.arguments, json!({"loc": "SF"}));
2422    }
2423
2424    #[test]
2425    fn tool_call_accumulator_handles_empty_args() {
2426        let mut partials: Vec<PartialToolCall> = Vec::new();
2427        accumulate_tool_call(
2428            &mut partials,
2429            ToolCallDeltaWire {
2430                index: 0,
2431                id: Some("call_x".to_string()),
2432                function: Some(FunctionDeltaWire {
2433                    name: Some("list_windows".to_string()),
2434                    arguments: None,
2435                }),
2436            },
2437        );
2438        let tc = partials
2439            .into_iter()
2440            .next()
2441            .unwrap()
2442            .into_tool_call()
2443            .unwrap();
2444        assert_eq!(tc.function.arguments, json!({}));
2445    }
2446
2447    #[test]
2448    fn tool_call_accumulator_handles_multiple_indices() {
2449        // Provider streams two parallel tool calls — index 0 and index 1
2450        // delta chunks interleaved.
2451        let mut partials: Vec<PartialToolCall> = Vec::new();
2452        accumulate_tool_call(
2453            &mut partials,
2454            ToolCallDeltaWire {
2455                index: 0,
2456                id: Some("call_a".to_string()),
2457                function: Some(FunctionDeltaWire {
2458                    name: Some("fn_a".to_string()),
2459                    arguments: Some("{}".to_string()),
2460                }),
2461            },
2462        );
2463        accumulate_tool_call(
2464            &mut partials,
2465            ToolCallDeltaWire {
2466                index: 1,
2467                id: Some("call_b".to_string()),
2468                function: Some(FunctionDeltaWire {
2469                    name: Some("fn_b".to_string()),
2470                    arguments: Some("{}".to_string()),
2471                }),
2472            },
2473        );
2474
2475        let parsed: Vec<_> = partials
2476            .into_iter()
2477            .filter_map(|p| p.into_tool_call())
2478            .collect();
2479        assert_eq!(parsed.len(), 2);
2480        assert_eq!(parsed[0].function.name, "fn_a");
2481        assert_eq!(parsed[1].function.name, "fn_b");
2482    }
2483
2484    // --- ThinkTagState (Wave 6) ---
2485
2486    #[test]
2487    fn think_state_passes_plain_text_through() {
2488        let mut s = ThinkTagState::new();
2489        let (text, reasoning) = s.feed("hello world, no tags here");
2490        assert_eq!(text, "hello world, no tags here");
2491        assert!(reasoning.is_empty());
2492        let (tail_text, tail_reasoning) = s.flush();
2493        assert!(tail_text.is_empty());
2494        assert!(tail_reasoning.is_empty());
2495    }
2496
2497    #[test]
2498    fn think_state_extracts_complete_tag_pair_in_one_chunk() {
2499        let mut s = ThinkTagState::new();
2500        let (text, reasoning) = s.feed("before<think>reasoning content</think>after");
2501        assert_eq!(text, "beforeafter");
2502        assert_eq!(reasoning, "reasoning content");
2503    }
2504
2505    #[test]
2506    fn think_state_handles_tag_split_across_chunks() {
2507        let mut s = ThinkTagState::new();
2508        // Chunk 1 ends mid-opening-tag.
2509        let (text1, reasoning1) = s.feed("before<thi");
2510        assert_eq!(text1, "before");
2511        assert!(reasoning1.is_empty());
2512        // Chunk 2 completes the opening tag and includes the closing tag.
2513        let (text2, reasoning2) = s.feed("nk>X</think>after");
2514        assert_eq!(text2, "after");
2515        assert_eq!(reasoning2, "X");
2516    }
2517
2518    #[test]
2519    fn think_state_handles_closing_tag_split() {
2520        let mut s = ThinkTagState::new();
2521        let (text1, reasoning1) = s.feed("<think>weighing options</thi");
2522        assert!(text1.is_empty());
2523        assert_eq!(reasoning1, "weighing options");
2524        let (text2, reasoning2) = s.feed("nk>final answer");
2525        assert_eq!(text2, "final answer");
2526        assert!(reasoning2.is_empty());
2527    }
2528
2529    #[test]
2530    fn think_state_handles_multiple_tag_pairs() {
2531        let mut s = ThinkTagState::new();
2532        let (text, reasoning) = s.feed("a<think>r1</think>b<think>r2</think>c");
2533        assert_eq!(text, "abc");
2534        // Both reasoning runs come back concatenated since `feed`
2535        // returns one (text, reasoning) pair per call.
2536        assert_eq!(reasoning, "r1r2");
2537    }
2538
2539    #[test]
2540    fn think_state_preserves_cjk_inside_tags() {
2541        let mut s = ThinkTagState::new();
2542        let (text, reasoning) = s.feed("英語<think>思考中</think>結果");
2543        assert_eq!(text, "英語結果");
2544        assert_eq!(reasoning, "思考中");
2545    }
2546
2547    #[test]
2548    fn think_state_flush_emits_partial_tag_as_text() {
2549        let mut s = ThinkTagState::new();
2550        // Stream ends mid-opening-tag — partial bytes flush to text so
2551        // we don't silently drop user-visible content.
2552        let (text1, _) = s.feed("hello<thi");
2553        assert_eq!(text1, "hello");
2554        let (text_tail, reasoning_tail) = s.flush();
2555        assert_eq!(text_tail, "<thi");
2556        assert!(reasoning_tail.is_empty());
2557    }
2558
2559    #[test]
2560    fn think_state_does_not_match_other_angle_brackets() {
2561        let mut s = ThinkTagState::new();
2562        let (text, reasoning) = s.feed("<other>tag-like</other> and <not a tag");
2563        // Output exactly the input — no `<think>` anywhere, so no split.
2564        // The tail `<not` would be buffered as a possible opening-tag
2565        // prefix, but since `<not` isn't a prefix of `<think>`, it
2566        // flushes through to text.
2567        assert_eq!(text, "<other>tag-like</other> and <not a tag");
2568        assert!(reasoning.is_empty());
2569    }
2570
2571    #[test]
2572    fn truncation_marker_preserved_byte_for_byte() {
2573        // Sanity that this adapter's marker matches the agreed shape so
2574        // any consumer that greps for it (TUI's chat widget, log
2575        // scrapers) sees the same text.
2576        let mut buf = String::new();
2577        let mut t = false;
2578        push_capped(&mut buf, &"a".repeat(50), &mut t, 10);
2579        assert!(t);
2580        assert!(buf.ends_with(TRUNCATION_MARKER));
2581    }
2582}