Skip to main content

supercode_runtime/
provider.rs

1//! The model transport.
2//!
3//! [`OpenAiProvider`] speaks the OpenAI chat-completions wire format and
4//! defaults to OpenRouter, so a single implementation reaches Claude, GPT,
5//! Gemini, Llama, and anything else OpenRouter (or another OpenAI-compatible
6//! gateway) exposes. Streaming is used so callers can render tokens live.
7
8use std::collections::HashMap;
9use std::time::Duration;
10
11use async_trait::async_trait;
12use futures::StreamExt;
13use serde::{Deserialize, Serialize};
14
15use supercode_interchange::{ChatMessage, FunctionCall, Role, ToolCall};
16
17use crate::{CachePlan, ChatRequest, Result, RuntimeError as Error, ToolSchema, Usage};
18
19/// Bounds TCP/TLS establishment for the provider HTTP client. Matches
20/// `doctor`'s 10 s timeout (`crates/cli/src/main.rs`) for consistency.
21const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
22
23/// Per-read-operation idle timeout. Resets on every received chunk, so a live
24/// SSE stream emitting deltas is never killed — only a silent connection (no
25/// bytes for the window, including a server that accepts but never sends
26/// response headers) errors out. Generous enough for slow time-to-first-token,
27/// small enough to unstick a dead connection well within one agent turn.
28const READ_IDLE_TIMEOUT: Duration = Duration::from_secs(120);
29
30/// Maximum number of retries for the *initial* request (so at most
31/// `MAX_RETRIES + 1` attempts total). Only connection-level failures and 5xx
32/// responses are retried; once SSE streaming has begun, errors propagate as-is.
33const MAX_RETRIES: u32 = 2;
34
35/// Base backoff between retries; the delay for attempt `n` (0-indexed) is
36/// `RETRY_BACKOFF_BASE * 2^n` (no jitter — not needed at this scale).
37const RETRY_BACKOFF_BASE: Duration = Duration::from_millis(500);
38
39/// Crate-internal knobs for the provider's HTTP client and retry behavior.
40/// `connect_timeout`/`read_idle_timeout` stay test-only overrides (no
41/// `Config`/CLI surface); `max_retries`/`retry_backoff_base` gained one via
42/// [`Self::from_retry_config`] (P4b, §1.1/§3.1 `core.retry`) — see that
43/// constructor's doc comment.
44#[derive(Debug, Clone, Copy)]
45#[doc(hidden)]
46pub struct HttpOptions {
47    pub(crate) connect_timeout: Duration,
48    pub(crate) read_idle_timeout: Duration,
49    pub(crate) max_retries: u32,
50    pub(crate) retry_backoff_base: Duration,
51}
52
53impl Default for HttpOptions {
54    fn default() -> Self {
55        HttpOptions {
56            connect_timeout: CONNECT_TIMEOUT,
57            read_idle_timeout: READ_IDLE_TIMEOUT,
58            max_retries: MAX_RETRIES,
59            retry_backoff_base: RETRY_BACKOFF_BASE,
60        }
61    }
62}
63
64impl HttpOptions {
65    /// P4b (design §5.2 "P4", §1.1/§3.1 `core.retry`, pi§3 naming
66    /// precedent): derive the transport's retry behavior from
67    /// [`crate::Config`]'s `retry_*` fields, keeping every other
68    /// [`HttpOptions`] field at its built-in default. `enabled = false`
69    /// (a NEW capability — today's transport retry has no off-switch) forces
70    /// `max_retries` to `0`; `enabled = true` (the [`crate::Config`] default,
71    /// matching today's always-on behavior) keeps retrying, using
72    /// `max_retries`/`base_delay_ms` to OVERRIDE the built-in
73    /// [`MAX_RETRIES`]/[`RETRY_BACKOFF_BASE`] when `Some`, else leaving them
74    /// untouched — so a `Config` that sets none of the three `retry_*`
75    /// fields (today's only reachable shape, pre-P4b) produces an
76    /// [`HttpOptions`] byte-identical to [`HttpOptions::default`].
77    #[doc(hidden)]
78    pub fn from_retry_config(
79        enabled: bool,
80        max_retries: Option<u32>,
81        base_delay_ms: Option<u64>,
82    ) -> HttpOptions {
83        let base = HttpOptions::default();
84        HttpOptions {
85            max_retries: if enabled {
86                max_retries.unwrap_or(base.max_retries)
87            } else {
88                0
89            },
90            retry_backoff_base: base_delay_ms
91                .map(Duration::from_millis)
92                .unwrap_or(base.retry_backoff_base),
93            ..base
94        }
95    }
96}
97
98/// Build the JSON request body for an OpenAI-compatible chat-completions call.
99/// Exposed (crate-internal) so the wire shape can be unit-tested without a
100/// network round-trip.
101pub(crate) fn build_request_body(req: &ChatRequest, stream: bool) -> serde_json::Value {
102    use serde_json::json;
103    let mut body = json!({
104        "model": req.model,
105        "messages": req.messages,
106        "stream": stream,
107    });
108    let obj = body.as_object_mut().unwrap();
109    if !req.tools.is_empty() {
110        obj.insert(
111            "tools".into(),
112            serde_json::to_value(req.tools.iter().map(WireTool::from).collect::<Vec<_>>()).unwrap(),
113        );
114    }
115    if let Some(t) = req.temperature {
116        obj.insert("temperature".into(), json!(t));
117    }
118    if let Some(m) = req.max_tokens {
119        obj.insert("max_tokens".into(), json!(m));
120    }
121    if let Some(e) = &req.effort {
122        obj.insert("reasoning_effort".into(), json!(e));
123    }
124    if let Some(rf) = &req.response_format {
125        obj.insert("response_format".into(), rf.clone());
126    }
127    if stream {
128        obj.insert("stream_options".into(), json!({"include_usage": true}));
129    }
130    // Provider-native passthrough wins last (lets callers override anything).
131    for (k, v) in &req.extra_body {
132        obj.insert(k.clone(), v.clone());
133    }
134    body
135}
136
137/// SPEC.md B7: annotate a CLONE of `messages` with Anthropic-style
138/// `cache_control: {"type":"ephemeral"}` prompt-cache breakpoints, message-level
139/// (never the top-level `extra_body` passthrough `build_request_body` supports
140/// for other provider knobs — OpenRouter's Anthropic cache keys off per-message
141/// `cache_control` inside the `content` array, so only this placement can say
142/// where the stable prefix ends).
143///
144/// `imported_prefix_len` counts leading messages of `messages` (from index 0,
145/// inclusive of the system message) that make up the stable, byte-identical-
146/// across-turns prefix — a caller's own leading system message plus every
147/// message of a previously-imported session (`Agent::load_session`). Under
148/// [`CachePlan::ImportedPrefix`], two breakpoints are placed (Anthropic allows
149/// up to 4): `messages[0]` (the system message) and
150/// `messages[imported_prefix_len - 1]` (the LAST message of the imported
151/// prefix) — deduplicated when they're the same index. Each target message's
152/// `content` moves into `content_parts` form with a trailing
153/// `{"type":"text","text":…,"cache_control":{"type":"ephemeral"}}` part; an
154/// already-multimodal message gets the annotation on its LAST existing text
155/// part instead of growing a new one.
156///
157/// [`CachePlan::Off`] (or a missing/zero `imported_prefix_len`) returns an
158/// unannotated clone. Either way this never mutates `messages` in place — the
159/// purity requirement (SPEC.md B7-AC2) that `Agent::history` and the sidecar
160/// never see `cache_control` depends on this being a read-only projection over
161/// a caller-owned copy, never the retained history itself.
162#[doc(hidden)]
163pub fn apply_cache_plan(
164    messages: &[ChatMessage],
165    plan: CachePlan,
166    imported_prefix_len: Option<usize>,
167) -> Vec<ChatMessage> {
168    let mut out = messages.to_vec();
169    if !matches!(plan, CachePlan::ImportedPrefix) {
170        return out;
171    }
172    let Some(len) = imported_prefix_len.filter(|&n| n > 0) else {
173        return out;
174    };
175    let last = len - 1;
176    let mut targets = vec![0usize];
177    if last != 0 {
178        targets.push(last);
179    }
180    for idx in targets {
181        if let Some(msg) = out.get_mut(idx) {
182            annotate_cache_breakpoint(msg);
183        }
184    }
185    out
186}
187
188/// Move `msg`'s text content into an ephemeral-cache-annotated
189/// `content_parts` entry — see [`apply_cache_plan`].
190fn annotate_cache_breakpoint(msg: &mut ChatMessage) {
191    let cache_control = serde_json::json!({"type": "ephemeral"});
192    if let Some(parts) = msg.content_parts.as_mut() {
193        // Already multimodal: annotate the LAST existing text part.
194        if let Some(text_part) = parts
195            .iter_mut()
196            .rev()
197            .find(|p| p.get("type").and_then(serde_json::Value::as_str) == Some("text"))
198        {
199            if let Some(obj) = text_part.as_object_mut() {
200                obj.insert("cache_control".to_string(), cache_control);
201            }
202        }
203        return;
204    }
205    let text = msg.content.take().unwrap_or_default();
206    msg.content_parts = Some(vec![serde_json::json!({
207        "type": "text",
208        "text": text,
209        "cache_control": cache_control,
210    })]);
211}
212
213/// TR-8 (T5): whether the advertised tool-schema tier configuration changed
214/// since the last request this agent built. Under [`CachePlan::ImportedPrefix`]
215/// this is a cache-bust event: the `tools` array sent alongside `messages` is
216/// part of the cache key on the prompt-caching implementations this plan
217/// targets, so a byte-identical imported-message prefix does not, on its
218/// own, guarantee a cache hit once the advertised schema set has been
219/// reshaped by a tier change.
220///
221/// `previous` is `None` on an agent's very first request (nothing to have
222/// busted yet), so this only ever fires from the second request onward, and
223/// only for the one request immediately after the change — the caller
224/// (`Agent::build_request_messages`) is expected to record the new signature
225/// right after consulting this, so the NEXT request (same tier) is not
226/// flagged again.
227#[doc(hidden)]
228pub fn tier_change_is_cache_bust(previous: Option<u64>, current: u64) -> bool {
229    previous.is_some_and(|p| p != current)
230}
231
232/// UX-26 (B7-warn): Anthropic's default ephemeral prompt-cache TTL, in
233/// seconds. Every breakpoint supercode places
234/// ([`annotate_cache_breakpoint`]) is `{"type":"ephemeral"}` — never the
235/// extended 1-hour-beta `ttl` field — so 5 minutes is the correct assumption
236/// for every cache-annotated request this binary sends (Anthropic's
237/// documented default TTL for an ephemeral breakpoint with no `ttl` set).
238pub(crate) const CACHE_TTL_SECS: i64 = 300;
239
240/// UX-26: cache-read ratio below which a completed, reuse-expected turn is
241/// treated as an unexpected miss rather than provider-side rounding/paging
242/// noise. Anthropic bills cache reads as an exact token count (not an
243/// estimate), so a genuine warm hit reports at or near 100% of the
244/// protected prefix's tokens; anything under 10% reflects a real miss.
245pub(crate) const CACHE_MISS_RATIO_THRESHOLD: f64 = 0.10;
246
247/// UX-26 T1 (accuracy fold-in): cache-read ratio at/above which a completed
248/// turn's OWN `usage` is strong enough evidence to override an
249/// idle-time-based [`CacheColdReason::Stale`] verdict. `idle_secs` is a
250/// cross-process, timestamp-derived signal (see `cache_cold_reason`'s doc
251/// comment) that can be stale itself — e.g. a sibling process re-resumes the
252/// SAME original session file (whose on-disk timestamps never advance) and
253/// warms the identical prefix within the TTL; this process's `idle_secs`
254/// still reads as "past the TTL" even though the provider just proved
255/// otherwise. Deliberately the exact mirror of
256/// [`CACHE_MISS_RATIO_THRESHOLD`] (`1.0 -` that bar) rather than reusing it
257/// directly: reusing 10% (i.e. "disprove whenever it's not already a Miss")
258/// would let a merely-ambiguous ratio — e.g. 50%, no stronger evidence of
259/// warmth than of staleness — silently swallow a genuinely cold turn. 90%
260/// demands the same "at or near 100%" standard the Miss check already uses
261/// to call a hit warm, applied in the opposite direction, so a turn only
262/// suppresses `Stale` when its own usage affirmatively looks warm — not
263/// merely "not obviously a miss."
264pub(crate) const CACHE_STALE_DISPROVE_RATIO_THRESHOLD: f64 = 1.0 - CACHE_MISS_RATIO_THRESHOLD;
265
266/// UX-26 T2 (accuracy fold-in): whether `model` is Anthropic-family, i.e.
267/// whether [`CacheColdReason::message`]'s Anthropic-shaped wording (a fixed
268/// 5-minute ephemeral TTL, cache-read ratio semantics) actually describes
269/// the provider this request is going to. Every resolved model slug this
270/// binary sends is either an OpenRouter-style `vendor/model` slug — see
271/// `userconfig::alias_table` and [`KNOWN_MODEL_CONTEXT_LIMITS`], which both
272/// use the exact same `"anthropic/…"` shape as the one and only Anthropic
273/// prefix — or, for a caller pointed directly at Anthropic's own API via
274/// `--base-url`, a bare `claude-…` slug with no vendor prefix at all (that
275/// endpoint doesn't use OpenRouter's vendor-prefixed naming). Both forms are
276/// unambiguous: no other vendor slug in this codebase starts with `claude`.
277///
278/// This is intentionally narrower than "could plausibly be Anthropic" — an
279/// unrecognized custom slug is NOT assumed Anthropic (mirrors
280/// [`model_context_limit`]'s "unknown is never assumed favorable" stance) —
281/// so this only ever narrows the warning, never broadens it past what T1's
282/// accuracy bar already allows.
283#[doc(hidden)]
284pub fn is_anthropic_family_model(model: &str) -> bool {
285    model.starts_with("anthropic/") || model.starts_with("claude-") || model.starts_with("claude/")
286}
287
288/// UX-26 (B7-warn): why a completed, reuse-expected turn likely paid a
289/// full-price prompt-cache miss. See [`cache_cold_reason`].
290#[derive(Debug, Clone, Copy, PartialEq)]
291#[doc(hidden)]
292pub enum CacheColdReason {
293    /// This turn was sent `idle_secs` after the cache entry was last
294    /// established/refreshed — at or beyond [`CACHE_TTL_SECS`], so the
295    /// provider has almost certainly already evicted it. Computable
296    /// pre-send (doesn't need `usage`).
297    Stale {
298        /// Seconds since the cache entry was last known warm.
299        idle_secs: i64,
300    },
301    /// The provider's own usage reported `cached_tokens` out of
302    /// `prompt_tokens` — below [`CACHE_MISS_RATIO_THRESHOLD`] despite reuse
303    /// being expected, and NOT already explained by [`Self::Stale`] (this
304    /// turn was sent inside the TTL window).
305    Miss {
306        /// Tokens the provider reports as served from cache.
307        cached_tokens: u64,
308        /// Total prompt (input) tokens for this turn.
309        prompt_tokens: u64,
310    },
311}
312
313impl CacheColdReason {
314    /// Render as the ready-to-print stderr line (no trailing newline).
315    #[doc(hidden)]
316    pub fn message(&self) -> String {
317        match self {
318            CacheColdReason::Stale { idle_secs } => format!(
319                "cache likely cold — this turn was sent {}m{:02}s after the cache was last \
320                 refreshed (Anthropic's ephemeral prompt cache expires after 5m idle) — this \
321                 turn likely paid full input cost for the cached prefix",
322                idle_secs / 60,
323                idle_secs % 60,
324            ),
325            CacheColdReason::Miss {
326                cached_tokens,
327                prompt_tokens,
328            } => format!(
329                "unexpected cache miss — only {cached_tokens}/{prompt_tokens} prompt tokens \
330                 were served from cache this turn even though reuse was expected — this turn \
331                 likely paid full input cost for the cached prefix",
332            ),
333        }
334    }
335}
336
337/// UX-26 (B7-warn, dev/01+dev/02): whether a completed turn likely paid a
338/// full-price cache miss.
339///
340/// Takes two INDEPENDENT preconditions rather than one combined
341/// "reuse expected" flag, because they cover genuinely different turns:
342///
343/// - `will_annotate`: THIS request actually carries a
344///   [`CachePlan::ImportedPrefix`] `cache_control` breakpoint (not a
345///   same-turn tool-schema-tier bust, not `CachePlan::Off`). Gates BOTH
346///   checks below — with no annotation there was never anything to reuse,
347///   by construction.
348/// - `cache_established`: a PRIOR request already placed that same
349///   breakpoint (in THIS process, or inferred from `idle_secs` having a
350///   value at all — see below). Gates ONLY the [`CacheColdReason::Miss`]
351///   check: on the very FIRST annotated request for a prefix, the provider
352///   legitimately reports ~0 cached tokens (it's establishing the entry,
353///   not reusing it) — reporting that as a "miss" would be a false
354///   positive on every resume's opening turn.
355///
356/// [`CacheColdReason::Stale`] deliberately does NOT require
357/// `cache_established`: `idle_secs` itself is derived (by the caller,
358/// `Agent::build_request_messages`) from the RESUMED SESSION's own last
359/// message timestamp when this agent has never sent a request yet — a
360/// cross-process signal of how long the prefix has sat untouched by ANY
361/// tool. That is precisely the flagship case (`docs/jcode-ux-parity.md`
362/// §6c.1): a session idle for 20 minutes, resumed, and its very first turn
363/// in supercode is a foregone cold read — which is exactly when the user
364/// most needs the heads-up, not only on turn 2+. `idle_secs` is `None`
365/// whenever no such signal exists (a session with no parseable timestamp),
366/// so this never guesses.
367///
368/// Checks [`CacheColdReason::Stale`] before [`CacheColdReason::Miss`] (needs
369/// the completed `usage`, so only consulted once elapsed time is inside the
370/// TTL window) so a genuinely stale turn is never double-reported.
371///
372/// UX-26 T1 (accuracy fold-in): `Stale` is nominally computable pre-send
373/// (from `idle_secs` alone), but `usage` — for the very turn about to be
374/// reported `Stale` — is always in hand by the time this fn actually runs
375/// (the caller only has a completed `usage` to give it). When that usage
376/// affirmatively PROVES the turn was warm (cache-read ratio at/above
377/// [`CACHE_STALE_DISPROVE_RATIO_THRESHOLD`] — see its doc comment for why
378/// that bar, not [`CACHE_MISS_RATIO_THRESHOLD`], is used here), the
379/// idle-clock-based `Stale` verdict is disproven and suppressed: a stale
380/// *clock* reading doesn't mean a stale *cache* when the provider's own
381/// billed usage says otherwise. Usage that's absent, unparseable, or merely
382/// ambiguous (below the disprove bar but not a `Miss` either) offers no such
383/// disproof, so `Stale` still fires exactly as before.
384#[doc(hidden)]
385pub fn cache_cold_reason(
386    will_annotate: bool,
387    cache_established: bool,
388    idle_secs: Option<i64>,
389    usage: &Usage,
390) -> Option<CacheColdReason> {
391    if !will_annotate {
392        return None;
393    }
394    if let Some(idle_secs) = idle_secs {
395        if idle_secs >= CACHE_TTL_SECS {
396            let disproven_by_usage = usage
397                .prompt_tokens_details
398                .filter(|_| usage.prompt_tokens > 0)
399                .is_some_and(|details| {
400                    details.cached_tokens as f64 / usage.prompt_tokens as f64
401                        >= CACHE_STALE_DISPROVE_RATIO_THRESHOLD
402                });
403            if !disproven_by_usage {
404                return Some(CacheColdReason::Stale { idle_secs });
405            }
406        }
407    }
408    if !cache_established {
409        // First annotated request for this prefix: a legitimate cold WRITE,
410        // never a "miss" — nothing to compare `usage` against.
411        return None;
412    }
413    let details = usage.prompt_tokens_details?;
414    if usage.prompt_tokens == 0 {
415        // Nothing was actually read as prompt input this turn (unusual, but
416        // possible for a degenerate request) — no signal either way.
417        return None;
418    }
419    let ratio = details.cached_tokens as f64 / usage.prompt_tokens as f64;
420    if ratio < CACHE_MISS_RATIO_THRESHOLD {
421        return Some(CacheColdReason::Miss {
422            cached_tokens: details.cached_tokens,
423            prompt_tokens: usage.prompt_tokens,
424        });
425    }
426    None
427}
428
429/// The transport abstraction. Implement this to back the agent with something
430/// other than an OpenAI-compatible HTTP endpoint (a local model, a mock, etc.).
431#[async_trait]
432pub trait Provider: Send + Sync {
433    /// Run one completion. `on_delta` is called with each text chunk as it
434    /// streams in. Returns the fully assembled assistant message and usage.
435    async fn complete(
436        &self,
437        req: &ChatRequest,
438        on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
439    ) -> Result<(ChatMessage, Usage)>;
440}
441
442/// An OpenAI-compatible HTTP provider. The composition layer supplies its
443/// endpoint, credentials, and headers from runtime configuration.
444pub struct OpenAiProvider {
445    client: reqwest::Client,
446    base_url: String,
447    api_key: String,
448    extra_headers: HashMap<String, String>,
449    http_options: HttpOptions,
450}
451
452impl OpenAiProvider {
453    /// Construct a provider for the given endpoint and key.
454    pub fn new(
455        base_url: impl Into<String>,
456        api_key: impl Into<String>,
457        extra_headers: HashMap<String, String>,
458    ) -> Self {
459        Self::new_with_options(base_url, api_key, extra_headers, HttpOptions::default())
460    }
461
462    /// Same as [`Self::new`] but with crate-internal HTTP timeout/retry
463    /// options — used by tests to shrink timeouts and backoff so they run
464    /// fast. Not part of the public API (no `Config`/CLI surface for these
465    /// knobs).
466    #[doc(hidden)]
467    pub fn new_with_options(
468        base_url: impl Into<String>,
469        api_key: impl Into<String>,
470        extra_headers: HashMap<String, String>,
471        http_options: HttpOptions,
472    ) -> Self {
473        OpenAiProvider {
474            client: reqwest::Client::builder()
475                .connect_timeout(http_options.connect_timeout)
476                .read_timeout(http_options.read_idle_timeout)
477                .build()
478                .expect("static reqwest client config cannot fail"),
479            base_url: base_url.into(),
480            api_key: api_key.into(),
481            extra_headers,
482            http_options,
483        }
484    }
485
486    fn endpoint(&self) -> String {
487        format!("{}/chat/completions", self.base_url.trim_end_matches('/'))
488    }
489
490    /// Send the initial request, retrying connection-level failures and 5xx
491    /// responses with backoff. 4xx (and any other non-success, non-5xx)
492    /// statuses return immediately, unretried. Once a 2xx response is
493    /// received it is returned as-is for the caller to stream; this loop
494    /// never runs again for the lifetime of that response (no mid-stream
495    /// retry/resume).
496    async fn send_with_retry(&self, wire: &serde_json::Value) -> Result<reqwest::Response> {
497        let mut attempt = 0u32;
498        loop {
499            let mut builder = self
500                .client
501                .post(self.endpoint())
502                .bearer_auth(&self.api_key)
503                .header("Content-Type", "application/json");
504            for (k, v) in &self.extra_headers {
505                builder = builder.header(k, v);
506            }
507
508            let sent = builder.json(wire).send().await;
509            let (retryable, result): (bool, Result<reqwest::Response>) = match sent {
510                Err(e) => (true, Err(Error::from(e))),
511                Ok(resp) => {
512                    let status = resp.status();
513                    if status.is_success() {
514                        (false, Ok(resp))
515                    } else if status.is_server_error() {
516                        let body = resp.text().await.unwrap_or_default();
517                        (
518                            true,
519                            Err(Error::Provider {
520                                status: status.as_u16(),
521                                body: truncate(&body, 2000),
522                            }),
523                        )
524                    } else {
525                        let body = resp.text().await.unwrap_or_default();
526                        (
527                            false,
528                            Err(Error::Provider {
529                                status: status.as_u16(),
530                                body: truncate(&body, 2000),
531                            }),
532                        )
533                    }
534                }
535            };
536
537            if !retryable || attempt >= self.http_options.max_retries {
538                return result;
539            }
540            let backoff = self.http_options.retry_backoff_base * 2u32.pow(attempt);
541            tokio::time::sleep(backoff).await;
542            attempt += 1;
543        }
544    }
545}
546
547#[async_trait]
548impl Provider for OpenAiProvider {
549    async fn complete(
550        &self,
551        req: &ChatRequest,
552        on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
553    ) -> Result<(ChatMessage, Usage)> {
554        let wire = build_request_body(req, true);
555
556        let resp = self.send_with_retry(&wire).await?;
557
558        let mut acc = Accumulator::default();
559        // Buffer raw bytes, not a lossy-decoded String: network chunks split at
560        // arbitrary byte offsets, so decoding each chunk independently would turn
561        // any multi-byte UTF-8 scalar straddling a boundary into replacement
562        // characters. We only decode *complete* SSE lines (terminated by '\n',
563        // an ASCII byte that can never fall inside a multi-byte sequence).
564        let mut buf: Vec<u8> = Vec::new();
565        let mut deltas: Vec<String> = Vec::new();
566        let mut stream = resp.bytes_stream();
567        while let Some(chunk) = stream.next().await {
568            let bytes = chunk?;
569            buf.extend_from_slice(&bytes);
570            drain_sse_lines(&mut buf, &mut acc, &mut deltas)?;
571            for d in deltas.drain(..) {
572                on_delta(&d);
573            }
574        }
575        // Flush any trailing buffered line (no terminating newline).
576        let tail = String::from_utf8_lossy(&buf);
577        if !tail.trim().is_empty() {
578            handle_sse_line(tail.trim(), &mut acc, &mut deltas)?;
579            for d in deltas.drain(..) {
580                on_delta(&d);
581            }
582        }
583
584        Ok((acc.to_message(), acc_usage(&acc)))
585    }
586}
587
588// ---- streaming assembly ---------------------------------------------------
589
590#[derive(Default)]
591struct Accumulator {
592    content: String,
593    tool_calls: Vec<ToolCallAccum>,
594    usage: Usage,
595}
596
597#[derive(Default)]
598struct ToolCallAccum {
599    id: String,
600    name: String,
601    arguments: String,
602}
603
604impl Accumulator {
605    fn ensure(&mut self, index: usize) -> &mut ToolCallAccum {
606        while self.tool_calls.len() <= index {
607            self.tool_calls.push(ToolCallAccum::default());
608        }
609        &mut self.tool_calls[index]
610    }
611
612    fn to_message(&self) -> ChatMessage {
613        let calls: Vec<ToolCall> = self
614            .tool_calls
615            .iter()
616            .filter(|c| !c.id.is_empty() || !c.name.is_empty())
617            .map(|c| ToolCall {
618                id: c.id.clone(),
619                kind: "function".to_string(),
620                function: FunctionCall {
621                    name: c.name.clone(),
622                    arguments: c.arguments.clone(),
623                },
624            })
625            .collect();
626        ChatMessage {
627            role: Role::Assistant,
628            content: (!self.content.is_empty()).then(|| self.content.clone()),
629            content_parts: None,
630            tool_calls: (!calls.is_empty()).then_some(calls),
631            tool_call_id: None,
632            name: None,
633            metadata: Default::default(),
634        }
635    }
636}
637
638fn acc_usage(acc: &Accumulator) -> Usage {
639    acc.usage.clone()
640}
641
642fn drain_sse_lines(
643    buf: &mut Vec<u8>,
644    acc: &mut Accumulator,
645    deltas: &mut Vec<String>,
646) -> Result<()> {
647    while let Some(pos) = buf.iter().position(|&b| b == b'\n') {
648        let line: Vec<u8> = buf.drain(..=pos).collect();
649        let line = String::from_utf8_lossy(&line);
650        handle_sse_line(line.trim(), acc, deltas)?;
651    }
652    Ok(())
653}
654
655fn handle_sse_line(line: &str, acc: &mut Accumulator, deltas: &mut Vec<String>) -> Result<()> {
656    let Some(data) = line.strip_prefix("data:") else {
657        return Ok(());
658    };
659    let data = data.trim();
660    if data.is_empty() || data == "[DONE]" {
661        return Ok(());
662    }
663    let chunk: StreamChunk = match serde_json::from_str(data) {
664        Ok(c) => c,
665        Err(_) => return Ok(()), // tolerate keep-alive / partial frames
666    };
667    if let Some(u) = chunk.usage {
668        acc.usage = u;
669    }
670    for choice in chunk.choices {
671        if let Some(text) = choice.delta.content {
672            if !text.is_empty() {
673                acc.content.push_str(&text);
674                deltas.push(text);
675            }
676        }
677        for tc in choice.delta.tool_calls.unwrap_or_default() {
678            let slot = acc.ensure(tc.index);
679            if let Some(id) = tc.id {
680                slot.id = id;
681            }
682            if let Some(f) = tc.function {
683                if let Some(name) = f.name {
684                    slot.name.push_str(&name);
685                }
686                if let Some(args) = f.arguments {
687                    slot.arguments.push_str(&args);
688                }
689            }
690        }
691    }
692    Ok(())
693}
694
695fn truncate(s: &str, max: usize) -> String {
696    if s.len() <= max {
697        s.to_string()
698    } else {
699        // Walk back to a char boundary so we never slice mid-codepoint (which
700        // would panic) — provider error bodies can contain non-ASCII text.
701        let mut end = max;
702        while end > 0 && !s.is_char_boundary(end) {
703            end -= 1;
704        }
705        format!("{}…", &s[..end])
706    }
707}
708
709// ---- wire types -----------------------------------------------------------
710
711#[derive(Serialize)]
712struct WireTool<'a> {
713    #[serde(rename = "type")]
714    kind: &'static str,
715    function: WireFunction<'a>,
716}
717
718#[derive(Serialize)]
719struct WireFunction<'a> {
720    name: &'a str,
721    description: &'a str,
722    parameters: &'a serde_json::Value,
723}
724
725impl<'a> From<&'a ToolSchema> for WireTool<'a> {
726    fn from(t: &'a ToolSchema) -> Self {
727        WireTool {
728            kind: "function",
729            function: WireFunction {
730                name: &t.name,
731                description: &t.description,
732                parameters: &t.parameters,
733            },
734        }
735    }
736}
737
738#[derive(Deserialize)]
739struct StreamChunk {
740    #[serde(default)]
741    choices: Vec<StreamChoice>,
742    #[serde(default)]
743    usage: Option<Usage>,
744}
745
746#[derive(Deserialize)]
747struct StreamChoice {
748    delta: Delta,
749}
750
751#[derive(Deserialize)]
752struct Delta {
753    #[serde(default)]
754    content: Option<String>,
755    #[serde(default)]
756    tool_calls: Option<Vec<ToolCallDelta>>,
757}
758
759#[derive(Deserialize)]
760struct ToolCallDelta {
761    #[serde(default)]
762    index: usize,
763    #[serde(default)]
764    id: Option<String>,
765    #[serde(default)]
766    function: Option<FnDelta>,
767}
768
769#[derive(Deserialize)]
770struct FnDelta {
771    #[serde(default)]
772    name: Option<String>,
773    #[serde(default)]
774    arguments: Option<String>,
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780    use crate::PromptTokensDetails;
781    use supercode_interchange::ChatMessage;
782
783    #[test]
784    fn request_body_includes_effort_format_and_passthrough() {
785        let mut req = ChatRequest::new("m", vec![ChatMessage::user("hi")]);
786        req.effort = Some("high".into());
787        req.response_format =
788            Some(serde_json::json!({"type": "json_schema", "json_schema": {"name": "x"}}));
789        req.extra_body.insert(
790            "cache_control".into(),
791            serde_json::json!({"type": "ephemeral"}),
792        );
793        req.extra_body.insert(
794            "provider".into(),
795            serde_json::json!({"order": ["anthropic"]}),
796        );
797
798        let body = build_request_body(&req, false);
799        assert_eq!(body["model"], "m");
800        assert_eq!(body["reasoning_effort"], "high");
801        assert_eq!(body["response_format"]["type"], "json_schema");
802        assert_eq!(body["cache_control"]["type"], "ephemeral");
803        assert_eq!(body["provider"]["order"][0], "anthropic");
804        // Non-streaming requests omit stream_options.
805        assert!(body.get("stream_options").is_none());
806    }
807
808    #[test]
809    fn extra_body_overrides_modeled_fields() {
810        let mut req = ChatRequest::new("m", vec![ChatMessage::user("hi")]);
811        req.max_tokens = Some(100);
812        req.extra_body
813            .insert("max_tokens".into(), serde_json::json!(999));
814        let body = build_request_body(&req, true);
815        assert_eq!(body["max_tokens"], 999, "extra_body wins");
816        assert_eq!(body["stream_options"]["include_usage"], true);
817    }
818
819    // ---- B7: prompt caching on the imported prefix -----------------------
820
821    /// AC1 (wire placement): history `[system, u1, a1, u2]`,
822    /// `imported_prefix_len == 3` (system + u1 + a1 — the last message of the
823    /// imported prefix is `a1` at index 2), plan `ImportedPrefix` ->
824    /// `build_request_body` places `cache_control` at `messages[0]` and
825    /// `messages[2]` only, and `messages[2]`'s text is byte-identical to the
826    /// original.
827    #[test]
828    fn cache_plan_annotates_system_and_last_imported_message_only() {
829        let messages = vec![
830            ChatMessage::system("sys"),
831            ChatMessage::user("u1"),
832            ChatMessage::assistant("a1"),
833            ChatMessage::user("u2"),
834        ];
835        let mut req = ChatRequest::new("m", messages);
836        req.messages = apply_cache_plan(&req.messages, crate::CachePlan::ImportedPrefix, Some(3));
837
838        let body = build_request_body(&req, false);
839        let msgs = body["messages"].as_array().unwrap();
840        assert_eq!(msgs.len(), 4, "annotation must not change message count");
841
842        assert_eq!(
843            msgs[0]["content"][0]["cache_control"]["type"], "ephemeral",
844            "breakpoint 1: system message"
845        );
846        assert_eq!(
847            msgs[2]["content"][0]["cache_control"]["type"], "ephemeral",
848            "breakpoint 2: last message of the imported prefix (a1)"
849        );
850        assert_eq!(
851            msgs[2]["content"][0]["text"], "a1",
852            "annotated text must be byte-identical to the original content"
853        );
854
855        // No other message carries a cache_control anywhere in its content.
856        for (i, m) in msgs.iter().enumerate() {
857            if i == 0 || i == 2 {
858                continue;
859            }
860            let has_cc = match &m["content"] {
861                serde_json::Value::Array(parts) => {
862                    parts.iter().any(|p| p.get("cache_control").is_some())
863                }
864                serde_json::Value::String(_) => false,
865                _ => false,
866            };
867            assert!(!has_cc, "message {i} must not carry cache_control: {m:?}");
868        }
869    }
870
871    #[test]
872    fn cache_plan_off_never_annotates() {
873        let messages = vec![ChatMessage::system("sys"), ChatMessage::user("u1")];
874        let out = apply_cache_plan(&messages, crate::CachePlan::Off, Some(2));
875        assert_eq!(out[0].content_parts, None);
876        assert_eq!(out[1].content_parts, None);
877    }
878
879    #[test]
880    fn tier_change_is_cache_bust_truth_table() {
881        // First-ever request: nothing to have busted yet.
882        assert!(!tier_change_is_cache_bust(None, 42));
883        // Same signature across two requests: not a bust.
884        assert!(!tier_change_is_cache_bust(Some(42), 42));
885        // Different signature: a bust.
886        assert!(tier_change_is_cache_bust(Some(42), 7));
887    }
888
889    #[test]
890    fn cache_plan_dedupes_when_prefix_is_only_the_system_message() {
891        // imported_prefix_len == 1: system message is both breakpoints ->
892        // only one annotation, never a duplicate/overwritten second one.
893        let messages = vec![ChatMessage::system("sys"), ChatMessage::user("u1")];
894        let out = apply_cache_plan(&messages, crate::CachePlan::ImportedPrefix, Some(1));
895        assert!(out[0].content_parts.is_some());
896        assert_eq!(out[1].content_parts, None);
897    }
898
899    #[test]
900    fn cache_plan_annotates_last_text_part_of_already_multimodal_message() {
901        let imported_last = ChatMessage::user_with_images("caption", &["https://x/y.png".into()]);
902        // Sanity: text part is index 0, image part index 1.
903        assert_eq!(
904            imported_last.content_parts.as_ref().unwrap()[0]["type"],
905            "text"
906        );
907        let messages = vec![ChatMessage::system("sys"), imported_last];
908        let out = apply_cache_plan(&messages, crate::CachePlan::ImportedPrefix, Some(2));
909        let parts = out[1].content_parts.as_ref().unwrap();
910        assert_eq!(parts[0]["cache_control"]["type"], "ephemeral");
911        assert_eq!(parts[0]["text"], "caption");
912        assert!(
913            parts[1].get("cache_control").is_none(),
914            "the image_url part must not be annotated"
915        );
916    }
917
918    /// AC5 (usage surfacing, optional): an SSE usage line with
919    /// `prompt_tokens_details.cached_tokens` parses through the existing
920    /// drain path into `Usage::prompt_tokens_details`.
921    #[test]
922    fn usage_parses_prompt_tokens_details_cached_tokens() {
923        let acc = drain(&[
924            r#"data: {"choices":[{"delta":{"content":"hi"}}]}"#,
925            r#"data: {"usage":{"prompt_tokens":100,"completion_tokens":5,"prompt_tokens_details":{"cached_tokens":90}}}"#,
926            "data: [DONE]",
927        ]);
928        assert_eq!(acc.usage.prompt_tokens, 100);
929        let details = acc.usage.prompt_tokens_details.expect("details present");
930        assert_eq!(details.cached_tokens, 90);
931    }
932
933    // ---- UX-26 (B7-warn): cache_cold_reason -------------------------------
934
935    fn warm_usage() -> Usage {
936        // 950/1000 cached — a realistic warm hit (system+imported prefix
937        // cached, a little fresh per-turn content on top).
938        Usage {
939            prompt_tokens: 1000,
940            completion_tokens: 20,
941            total_tokens: 1020,
942            prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 950 }),
943        }
944    }
945
946    fn cold_usage() -> Usage {
947        // Reports a real prompt read but ~nothing served from cache.
948        Usage {
949            prompt_tokens: 1000,
950            completion_tokens: 20,
951            total_tokens: 1020,
952            prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 3 }),
953        }
954    }
955
956    /// UX-26 T1: deliberately ambiguous — at 50% it's neither below
957    /// [`CACHE_MISS_RATIO_THRESHOLD`] (so it never triggers `Miss`) nor at/above
958    /// [`CACHE_STALE_DISPROVE_RATIO_THRESHOLD`] (so it never disproves
959    /// `Stale`). Used to isolate the TTL-boundary check itself from the T1
960    /// disprove-by-usage branch — a fixture that can't accidentally satisfy
961    /// either ratio gate.
962    fn moderate_usage() -> Usage {
963        Usage {
964            prompt_tokens: 1000,
965            completion_tokens: 20,
966            total_tokens: 1020,
967            prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 500 }),
968        }
969    }
970
971    /// dev/02: `will_annotate == false` (a same-turn bust, or
972    /// `CachePlan::Off`) never fires, REGARDLESS of how stale or how low the
973    /// ratio is — there was nothing to reuse, by construction.
974    #[test]
975    fn cache_cold_reason_never_fires_when_not_annotated() {
976        assert_eq!(
977            cache_cold_reason(false, true, Some(10_000), &cold_usage()),
978            None
979        );
980        assert_eq!(cache_cold_reason(false, false, None, &cold_usage()), None);
981    }
982
983    /// The very FIRST annotated request for a prefix (`cache_established ==
984    /// false`) never fires `Miss` no matter how low the ratio is — that
985    /// request IS the write, so a near-zero cache-read is expected, not a
986    /// miss. `Stale` is independent of `cache_established` and still fires
987    /// if `idle_secs` says so (covered separately below).
988    #[test]
989    fn cache_cold_reason_first_annotated_request_never_reports_miss() {
990        assert_eq!(
991            cache_cold_reason(true, false, Some(1), &cold_usage()),
992            None,
993            "first write: a near-zero cache-read ratio is expected, not a miss"
994        );
995    }
996
997    /// dev/02: a genuinely back-to-back warm turn (already established,
998    /// well inside the TTL, usage reports a near-100% cache-read ratio)
999    /// prints no warning — no false positive on the common case.
1000    #[test]
1001    fn cache_cold_reason_silent_on_warm_back_to_back_turn() {
1002        assert_eq!(cache_cold_reason(true, true, Some(5), &warm_usage()), None);
1003        // No idle signal available at all (e.g. a synthetic session with no
1004        // parseable timestamp): ratio alone decides.
1005        assert_eq!(cache_cold_reason(true, true, None, &warm_usage()), None);
1006    }
1007
1008    /// dev/01 (TTL branch), flagship case: a session resumed after sitting
1009    /// idle past the TTL fires `Stale` on its very FIRST turn in this
1010    /// process (`cache_established == false`) — `idle_secs` here models the
1011    /// cross-process signal derived from the session's own last message
1012    /// timestamp, not an in-process one. Uses `cold_usage` (not
1013    /// `warm_usage`, see the T1 test right below for that half) so this
1014    /// stays a clean test of "no disproof available → the idle-clock verdict
1015    /// stands," independent of the T1 disprove branch.
1016    #[test]
1017    fn cache_cold_reason_fires_stale_on_first_turn_of_a_resumed_idle_session() {
1018        assert_eq!(
1019            cache_cold_reason(true, false, Some(20 * 60), &cold_usage()),
1020            Some(CacheColdReason::Stale { idle_secs: 20 * 60 })
1021        );
1022    }
1023
1024    /// UX-26 T1 (accuracy fold-in — FAILS pre-fix): the exact false-positive
1025    /// this fix targets. A sibling process re-resumes the SAME original
1026    /// session file (its on-disk timestamps never advance) and warms the
1027    /// identical prefix inside the TTL; THIS process still derives
1028    /// `idle_secs` past the TTL from those stale timestamps, but the
1029    /// completed request's own `usage` proves ~100% cache-read. Before T1,
1030    /// `cache_cold_reason` never consulted `usage` for the `Stale` branch and
1031    /// fired anyway (see the previous test's history / the "on first turn"
1032    /// test above it used to assert `Some(Stale)` here with `warm_usage`).
1033    /// After T1, affirmatively warm usage disproves the stale-clock verdict
1034    /// and suppresses the warning — regardless of `cache_established`,
1035    /// because the disproof comes from THIS turn's own usage, not from
1036    /// whether a prior in-process send happened.
1037    #[test]
1038    fn cache_cold_reason_stale_suppressed_when_usage_disproves_it() {
1039        assert_eq!(
1040            cache_cold_reason(true, false, Some(20 * 60), &warm_usage()),
1041            None,
1042            "cache_established == false, but usage still disproves staleness"
1043        );
1044        assert_eq!(
1045            cache_cold_reason(true, true, Some(CACHE_TTL_SECS), &warm_usage()),
1046            None,
1047            "cache_established == true, at the TTL boundary, usage disproves staleness"
1048        );
1049    }
1050
1051    /// UX-26 T1: the disprove bar is inclusive at
1052    /// [`CACHE_STALE_DISPROVE_RATIO_THRESHOLD`] (90%) and exclusive just
1053    /// under it — mirroring [`cache_cold_reason_ratio_threshold_is_exclusive`]'s
1054    /// treatment of the `Miss` threshold, but from the opposite direction:
1055    /// here, AT the bar counts as strong enough evidence to suppress;
1056    /// strictly under it does not.
1057    #[test]
1058    fn cache_cold_reason_stale_disprove_threshold_boundary() {
1059        let at_bar = Usage {
1060            prompt_tokens: 1000,
1061            completion_tokens: 1,
1062            total_tokens: 1001,
1063            prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 900 }), // exactly 90%
1064        };
1065        assert_eq!(
1066            cache_cold_reason(true, false, Some(CACHE_TTL_SECS), &at_bar),
1067            None,
1068            "exactly at the disprove bar suppresses Stale"
1069        );
1070
1071        let just_under = Usage {
1072            prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 899 }),
1073            ..at_bar
1074        };
1075        assert_eq!(
1076            cache_cold_reason(true, false, Some(CACHE_TTL_SECS), &just_under),
1077            Some(CacheColdReason::Stale {
1078                idle_secs: CACHE_TTL_SECS
1079            }),
1080            "one token under the disprove bar must not suppress Stale"
1081        );
1082    }
1083
1084    /// UX-26 T1: usage that's ambiguous (below the disprove bar, but not low
1085    /// enough to be a `Miss` either) offers no disproof — `Stale` still
1086    /// fires. Being "not obviously a miss" is not the same evidentiary bar
1087    /// as "affirmatively warm" (see [`CACHE_STALE_DISPROVE_RATIO_THRESHOLD`]'s
1088    /// doc comment for why reusing the `Miss` bar directly was rejected).
1089    #[test]
1090    fn cache_cold_reason_stale_not_suppressed_by_ambiguous_usage() {
1091        assert_eq!(
1092            cache_cold_reason(true, false, Some(20 * 60), &moderate_usage()),
1093            Some(CacheColdReason::Stale { idle_secs: 20 * 60 })
1094        );
1095    }
1096
1097    /// UX-26 T1: usage with no `prompt_tokens_details` at all (a provider
1098    /// that doesn't report the cache breakdown) offers no disproof either —
1099    /// `Stale` still fires. No signal, no suppression.
1100    #[test]
1101    fn cache_cold_reason_stale_not_suppressed_by_missing_usage_details() {
1102        let no_details = Usage {
1103            prompt_tokens: 1000,
1104            completion_tokens: 20,
1105            total_tokens: 1020,
1106            prompt_tokens_details: None,
1107        };
1108        assert_eq!(
1109            cache_cold_reason(true, false, Some(20 * 60), &no_details),
1110            Some(CacheColdReason::Stale { idle_secs: 20 * 60 })
1111        );
1112    }
1113
1114    /// dev/01 (TTL branch) boundary, established case: idle_secs at/over the
1115    /// 5-minute Anthropic ephemeral-cache TTL fires `Stale`; one second
1116    /// under does not. Uses `moderate_usage` (not `warm_usage`) so this test
1117    /// isolates the TTL-boundary check itself from the T1 disprove-by-usage
1118    /// branch covered separately above.
1119    #[test]
1120    fn cache_cold_reason_fires_stale_at_ttl_boundary() {
1121        assert_eq!(
1122            cache_cold_reason(true, true, Some(CACHE_TTL_SECS), &moderate_usage()),
1123            Some(CacheColdReason::Stale {
1124                idle_secs: CACHE_TTL_SECS
1125            })
1126        );
1127        assert_eq!(
1128            cache_cold_reason(true, true, Some(CACHE_TTL_SECS - 1), &moderate_usage()),
1129            None,
1130            "one second under the TTL must not fire"
1131        );
1132    }
1133
1134    /// dev/01 (ratio branch): established, inside the TTL window, but the
1135    /// provider reports a near-zero cache-read ratio — an unexpected miss.
1136    #[test]
1137    fn cache_cold_reason_fires_miss_on_low_ratio_inside_ttl() {
1138        assert_eq!(
1139            cache_cold_reason(true, true, Some(1), &cold_usage()),
1140            Some(CacheColdReason::Miss {
1141                cached_tokens: 3,
1142                prompt_tokens: 1000,
1143            })
1144        );
1145    }
1146
1147    /// Ratio right at the 10% threshold does not fire (only strictly under);
1148    /// just below it does.
1149    #[test]
1150    fn cache_cold_reason_ratio_threshold_is_exclusive() {
1151        let at_threshold = Usage {
1152            prompt_tokens: 1000,
1153            completion_tokens: 1,
1154            total_tokens: 1001,
1155            prompt_tokens_details: Some(PromptTokensDetails {
1156                cached_tokens: 100, // exactly 10%
1157            }),
1158        };
1159        assert_eq!(cache_cold_reason(true, true, Some(1), &at_threshold), None);
1160
1161        let just_under = Usage {
1162            prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 99 }),
1163            ..at_threshold
1164        };
1165        assert!(cache_cold_reason(true, true, Some(1), &just_under).is_some());
1166    }
1167
1168    /// No `prompt_tokens_details` at all (a provider that doesn't report
1169    /// cache stats), established, inside the TTL: nothing to compare, no
1170    /// verdict — never guessed.
1171    #[test]
1172    fn cache_cold_reason_no_verdict_without_usage_details() {
1173        let usage = Usage {
1174            prompt_tokens: 1000,
1175            completion_tokens: 5,
1176            total_tokens: 1005,
1177            prompt_tokens_details: None,
1178        };
1179        assert_eq!(cache_cold_reason(true, true, Some(1), &usage), None);
1180    }
1181
1182    /// A degenerate zero-prompt-token response, established, inside the
1183    /// TTL: no signal either way (can't compute a ratio), so no verdict.
1184    #[test]
1185    fn cache_cold_reason_no_verdict_on_zero_prompt_tokens() {
1186        let usage = Usage {
1187            prompt_tokens: 0,
1188            completion_tokens: 5,
1189            total_tokens: 5,
1190            prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 0 }),
1191        };
1192        assert_eq!(cache_cold_reason(true, true, Some(1), &usage), None);
1193    }
1194
1195    // ---- UX-26 T2 (accuracy fold-in): is_anthropic_family_model -----------
1196
1197    /// The OpenRouter-style `anthropic/…` vendor-prefixed slugs this binary
1198    /// actually resolves to (default model, and every alias in
1199    /// `userconfig::alias_table`) are recognized.
1200    #[test]
1201    fn is_anthropic_family_model_recognizes_vendor_prefixed_slugs() {
1202        assert!(is_anthropic_family_model("anthropic/claude-opus-4-8"));
1203        assert!(is_anthropic_family_model("anthropic/claude-sonnet-4-6"));
1204        assert!(is_anthropic_family_model("anthropic/claude-haiku-4-5"));
1205    }
1206
1207    /// A bare `claude-…` slug (no vendor prefix), as a caller pointed
1208    /// directly at Anthropic's own API via `--base-url` would use, is also
1209    /// recognized.
1210    #[test]
1211    fn is_anthropic_family_model_recognizes_bare_claude_slugs() {
1212        assert!(is_anthropic_family_model("claude-opus-4-8"));
1213        assert!(is_anthropic_family_model("claude-3-5-sonnet-20241022"));
1214    }
1215
1216    /// Every other vendor slug in `KNOWN_MODEL_CONTEXT_LIMITS` (the
1217    /// non-Anthropic ones) is correctly rejected — this is a NARROWING gate,
1218    /// never a broadening one.
1219    #[test]
1220    fn is_anthropic_family_model_rejects_other_known_vendors() {
1221        assert!(!is_anthropic_family_model("openai/gpt-5"));
1222        assert!(!is_anthropic_family_model("openai/gpt-5.5"));
1223        assert!(!is_anthropic_family_model("google/gemini-2.5-pro"));
1224        assert!(!is_anthropic_family_model("deepseek/deepseek-v4-pro"));
1225        assert!(!is_anthropic_family_model("meta-llama/llama-4-maverick"));
1226    }
1227
1228    /// An unrecognized custom slug is NOT assumed Anthropic — mirrors
1229    /// `model_context_limit`'s "unknown is never assumed favorable" stance.
1230    #[test]
1231    fn is_anthropic_family_model_does_not_assume_unknown_slugs() {
1232        assert!(!is_anthropic_family_model("my-custom-local-model"));
1233        assert!(!is_anthropic_family_model(""));
1234    }
1235
1236    #[test]
1237    fn truncate_never_splits_a_codepoint() {
1238        // "é" is 2 bytes; a naive `&s[..max]` slicing mid-codepoint would panic.
1239        let s = "é".repeat(2000); // 4000 bytes
1240        let out = truncate(&s, 2001); // 2001 lands mid-"é"
1241        assert!(out.ends_with('…'));
1242        assert!(out.len() <= 2001 + '…'.len_utf8());
1243    }
1244
1245    // Feed a sequence of complete SSE lines through the assembler.
1246    fn drain(lines: &[&str]) -> Accumulator {
1247        let mut acc = Accumulator::default();
1248        let mut deltas = Vec::new();
1249        let mut buf: Vec<u8> = Vec::new();
1250        for l in lines {
1251            buf.extend_from_slice(l.as_bytes());
1252            buf.push(b'\n');
1253        }
1254        drain_sse_lines(&mut buf, &mut acc, &mut deltas).unwrap();
1255        acc
1256    }
1257
1258    #[test]
1259    fn streaming_assembles_tool_calls_and_usage_across_deltas() {
1260        let acc = drain(&[
1261            r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_"}}]}}]}"#,
1262            r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"file","arguments":"{\"path\":"}}]}}]}"#,
1263            r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"a\"}"}}]}}]}"#,
1264            r#"data: {"choices":[{"delta":{"content":"done"}}]}"#,
1265            r#"data: {"usage":{"prompt_tokens":3,"completion_tokens":5}}"#,
1266            "data: [DONE]",
1267        ]);
1268        let msg = acc.to_message();
1269        let calls = msg.tool_calls.expect("tool calls");
1270        assert_eq!(calls.len(), 1);
1271        assert_eq!(calls[0].id, "call_1");
1272        assert_eq!(
1273            calls[0].function.name, "read_file",
1274            "name spread over deltas"
1275        );
1276        assert_eq!(calls[0].function.arguments, r#"{"path":"a"}"#);
1277        assert_eq!(msg.content.as_deref(), Some("done"));
1278        assert_eq!(acc.usage.completion_tokens, 5);
1279    }
1280
1281    #[test]
1282    fn streaming_tolerates_done_keepalive_and_blank_lines() {
1283        // Blank lines, comments, [DONE], and unparseable frames must not break it.
1284        let acc = drain(&[
1285            "",
1286            ": keep-alive",
1287            r#"data: {"choices":[{"delta":{"content":"hi"}}]}"#,
1288            "data: not-json",
1289            "data: [DONE]",
1290        ]);
1291        assert_eq!(acc.to_message().content.as_deref(), Some("hi"));
1292    }
1293
1294    #[tokio::test]
1295    async fn non_success_status_becomes_provider_error() {
1296        use crate::RuntimeError as Error;
1297        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1298
1299        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1300        let addr = listener.local_addr().unwrap();
1301        let server = tokio::spawn(async move {
1302            let (mut sock, _) = listener.accept().await.unwrap();
1303            let mut buf = [0u8; 2048];
1304            let _ = sock.read(&mut buf).await;
1305            let body = r#"{"error":{"message":"bad key"}}"#;
1306            let resp = format!(
1307                "HTTP/1.1 401 Unauthorized\r\nContent-Length: {}\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{}",
1308                body.len(),
1309                body
1310            );
1311            sock.write_all(resp.as_bytes()).await.unwrap();
1312            sock.flush().await.unwrap();
1313        });
1314
1315        let provider = OpenAiProvider::new(format!("http://{addr}"), "k", HashMap::new());
1316        let req = ChatRequest::new("m", vec![ChatMessage::user("hi")]);
1317        let err = provider.complete(&req, &|_: &str| {}).await.unwrap_err();
1318        match err {
1319            Error::Provider { status, body } => {
1320                assert_eq!(status, 401);
1321                assert!(body.contains("bad key"), "body: {body}");
1322            }
1323            other => panic!("expected Provider error, got: {other:?}"),
1324        }
1325        server.await.unwrap();
1326    }
1327
1328    #[tokio::test]
1329    async fn streams_a_200_response_into_a_message() {
1330        use std::sync::{Arc, Mutex};
1331        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1332
1333        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1334        let addr = listener.local_addr().unwrap();
1335        let server = tokio::spawn(async move {
1336            let (mut sock, _) = listener.accept().await.unwrap();
1337            let mut buf = [0u8; 2048];
1338            let _ = sock.read(&mut buf).await;
1339            let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n\
1340                       data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n\
1341                       data: [DONE]\n\n";
1342            let resp = format!(
1343                "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1344                sse.len(),
1345                sse
1346            );
1347            sock.write_all(resp.as_bytes()).await.unwrap();
1348            sock.flush().await.unwrap();
1349        });
1350
1351        let provider = OpenAiProvider::new(format!("http://{addr}"), "k", HashMap::new());
1352        let req = ChatRequest::new("m", vec![ChatMessage::user("hi")]);
1353        let seen = Arc::new(Mutex::new(String::new()));
1354        let seen2 = seen.clone();
1355        let on_delta = move |s: &str| seen2.lock().unwrap().push_str(s);
1356        let (msg, _usage) = provider.complete(&req, &on_delta).await.unwrap();
1357        assert_eq!(msg.content.as_deref(), Some("hello"));
1358        assert_eq!(*seen.lock().unwrap(), "hello", "deltas streamed live");
1359        server.await.unwrap();
1360    }
1361
1362    // ---- P4b: HttpOptions::from_retry_config (§1.1/§3.1 `core.retry`) ----
1363
1364    #[test]
1365    fn from_retry_config_unset_is_byte_identical_to_default() {
1366        let opts = HttpOptions::from_retry_config(true, None, None);
1367        let default = HttpOptions::default();
1368        assert_eq!(opts.max_retries, default.max_retries);
1369        assert_eq!(opts.retry_backoff_base, default.retry_backoff_base);
1370        assert_eq!(opts.connect_timeout, default.connect_timeout);
1371        assert_eq!(opts.read_idle_timeout, default.read_idle_timeout);
1372    }
1373
1374    #[test]
1375    fn from_retry_config_disabled_forces_zero_retries() {
1376        let opts = HttpOptions::from_retry_config(false, None, None);
1377        assert_eq!(opts.max_retries, 0);
1378        // Disabling retry must not also change the backoff base a caller
1379        // never consults when max_retries is 0 — only max_retries changes.
1380        assert_eq!(
1381            opts.retry_backoff_base,
1382            HttpOptions::default().retry_backoff_base
1383        );
1384    }
1385
1386    #[test]
1387    fn from_retry_config_disabled_with_explicit_max_retries_still_forces_zero() {
1388        // `enabled = false` is the hard override — an explicit max_retries
1389        // alongside it must not silently re-enable retrying.
1390        let opts = HttpOptions::from_retry_config(false, Some(5), None);
1391        assert_eq!(opts.max_retries, 0);
1392    }
1393
1394    #[test]
1395    fn from_retry_config_overrides_apply_when_enabled() {
1396        let opts = HttpOptions::from_retry_config(true, Some(7), Some(1234));
1397        assert_eq!(opts.max_retries, 7);
1398        assert_eq!(opts.retry_backoff_base, Duration::from_millis(1234));
1399    }
1400
1401    #[test]
1402    fn from_retry_config_partial_override_leaves_the_other_at_default() {
1403        let opts = HttpOptions::from_retry_config(true, Some(9), None);
1404        assert_eq!(opts.max_retries, 9);
1405        assert_eq!(
1406            opts.retry_backoff_base,
1407            HttpOptions::default().retry_backoff_base
1408        );
1409    }
1410
1411    /// Test-shrunk timeouts/backoff so the timeout and retry tests run in
1412    /// milliseconds instead of the production 10s/120s/500ms defaults.
1413    fn test_http_options() -> HttpOptions {
1414        HttpOptions {
1415            connect_timeout: Duration::from_millis(250),
1416            read_idle_timeout: Duration::from_millis(250),
1417            max_retries: 2,
1418            retry_backoff_base: Duration::from_millis(10),
1419        }
1420    }
1421
1422    #[tokio::test]
1423    async fn hung_connection_errors_via_read_timeout_within_bounded_time() {
1424        use tokio::io::AsyncReadExt;
1425
1426        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1427        let addr = listener.local_addr().unwrap();
1428        // Accept every connection the client opens (one per retry attempt,
1429        // since a timed-out attempt drops its connection rather than being
1430        // reused) and hold each socket open without ever writing a response,
1431        // so every attempt must time out via the read-idle timeout.
1432        let server = tokio::spawn(async move {
1433            loop {
1434                let Ok((mut sock, _)) = listener.accept().await else {
1435                    break;
1436                };
1437                tokio::spawn(async move {
1438                    let mut buf = [0u8; 2048];
1439                    let _ = sock.read(&mut buf).await;
1440                    // Hold the socket open well past the test's bounded
1441                    // window, then let it drop.
1442                    tokio::time::sleep(Duration::from_secs(2)).await;
1443                });
1444            }
1445        });
1446
1447        let provider = OpenAiProvider::new_with_options(
1448            format!("http://{addr}"),
1449            "k",
1450            HashMap::new(),
1451            test_http_options(),
1452        );
1453        let req = ChatRequest::new("m", vec![ChatMessage::user("hi")]);
1454
1455        // The outer timeout is the actual assertion: with retries enabled the
1456        // bound is (read_timeout + backoff) * attempts, which with the
1457        // test-shrunk options above is well under 5s. If the client ever hung
1458        // on a dead connection instead of erroring via the read timeout, this
1459        // outer timeout would fire and the test would fail here rather than
1460        // proving the inner error path.
1461        let outcome = tokio::time::timeout(Duration::from_secs(5), async {
1462            provider.complete(&req, &|_: &str| {}).await
1463        })
1464        .await
1465        .expect("complete() must return within the outer bound, not hang forever");
1466
1467        match outcome {
1468            Err(Error::Http(_)) => {}
1469            other => panic!("expected Err(Error::Http(_)) from the read timeout, got: {other:?}"),
1470        }
1471
1472        server.abort();
1473    }
1474
1475    #[tokio::test]
1476    async fn retries_503_then_succeeds_with_exactly_two_requests() {
1477        use std::sync::atomic::{AtomicUsize, Ordering};
1478        use std::sync::Arc;
1479        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1480
1481        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1482        let addr = listener.local_addr().unwrap();
1483        let connections = Arc::new(AtomicUsize::new(0));
1484        let connections2 = connections.clone();
1485        let server = tokio::spawn(async move {
1486            for _ in 0..2 {
1487                let (mut sock, _) = listener.accept().await.unwrap();
1488                let n = connections2.fetch_add(1, Ordering::SeqCst) + 1;
1489                let mut buf = [0u8; 2048];
1490                let _ = sock.read(&mut buf).await;
1491                if n == 1 {
1492                    let resp =
1493                        "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
1494                    sock.write_all(resp.as_bytes()).await.unwrap();
1495                } else {
1496                    let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n\
1497                               data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n\
1498                               data: [DONE]\n\n";
1499                    let resp = format!(
1500                        "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1501                        sse.len(),
1502                        sse
1503                    );
1504                    sock.write_all(resp.as_bytes()).await.unwrap();
1505                }
1506                sock.flush().await.unwrap();
1507            }
1508        });
1509
1510        let provider = OpenAiProvider::new_with_options(
1511            format!("http://{addr}"),
1512            "k",
1513            HashMap::new(),
1514            test_http_options(),
1515        );
1516        let req = ChatRequest::new("m", vec![ChatMessage::user("hi")]);
1517        let (msg, _usage) = provider.complete(&req, &|_: &str| {}).await.unwrap();
1518        assert_eq!(msg.content.as_deref(), Some("hello"));
1519        server.await.unwrap();
1520        assert_eq!(
1521            connections.load(Ordering::SeqCst),
1522            2,
1523            "exactly 2 requests made: one 503, one successful retry"
1524        );
1525    }
1526
1527    #[test]
1528    fn streaming_decodes_multibyte_across_chunk_boundaries() {
1529        // An SSE data line whose JSON content is split mid-codepoint across two
1530        // byte chunks must not produce replacement characters.
1531        let line = "data: {\"choices\":[{\"delta\":{\"content\":\"héllo🌍\"}}]}\n";
1532        let bytes = line.as_bytes();
1533        let mut deltas = Vec::new();
1534        // Split at every byte offset to exercise all boundary positions.
1535        for split in 1..bytes.len() {
1536            let mut acc = Accumulator::default();
1537            let mut buf: Vec<u8> = Vec::new();
1538            buf.extend_from_slice(&bytes[..split]);
1539            drain_sse_lines(&mut buf, &mut acc, &mut deltas).unwrap();
1540            buf.extend_from_slice(&bytes[split..]);
1541            drain_sse_lines(&mut buf, &mut acc, &mut deltas).unwrap();
1542            assert_eq!(acc.content, "héllo🌍", "split at byte {split}");
1543            assert!(!acc.content.contains('\u{FFFD}'));
1544        }
1545    }
1546}