Skip to main content

wcode_harness/
streamfn.rs

1//! LLM stream-function seam + default `rig` (OpenAI-compatible) adapter.
2//!
3//! This module is the only place rig types appear in the kernel. A
4//! [`StreamFn`] turns harness conversation context into a [`LlmStream`] of
5//! [`LlmStreamEvent`]s; [`rig_stream_fn`] is the default implementation built
6//! on rig's Chat Completions API client.
7
8use std::pin::Pin;
9use std::sync::{Arc, Mutex};
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12use futures::{Stream, StreamExt};
13use rig::client::{CompletionClient, ModelListingClient};
14use rig::completion::{
15    CompletionError, CompletionModel, CompletionRequest, FinishReason, ToolDefinition,
16};
17use rig::http_client::{HeaderMap, HeaderValue};
18use rig::message::{
19    AssistantContent, Message, Reasoning, ReasoningContent, ToolCall, ToolCallId, ToolFunction,
20    UserContent,
21};
22use rig::providers::openai;
23use rig::streaming::{StreamedAssistantContent, StreamingCompletionResponse};
24
25use crate::event::LlmStreamEvent;
26use crate::message::{AgentMessage, ContentBlock, StopReason};
27
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub enum LlmEndpoint {
30    #[default]
31    Chat,
32    Responses,
33}
34
35#[derive(Clone, Debug, Default)]
36pub struct LlmOpts {
37    pub model: String,
38    pub base_url: Option<String>,
39    pub api_key: Option<String>,
40    /// Sampling temperature. No CLI flag surfaces it yet; a library embedder
41    /// that builds `LlmOpts` directly can set it.
42    pub temperature: Option<f64>,
43    pub endpoint: LlmEndpoint,
44    /// Stable per-conversation id, sent as `x-opencode-session` for providers
45    /// (e.g. OpenCode Go) that route/cache on it. None = header omitted.
46    pub session_id: Option<String>,
47    /// Free-style reasoning effort. None = send nothing (backends that reject
48    /// unknown fields keep working). Some(level) fans out per endpoint in
49    /// `build_request()`: Chat forwards any string verbatim under
50    /// `reasoning_effort`, while the Responses endpoint validates client-side
51    /// against `none|minimal|low|medium|high|xhigh|max` (rig's typed enum) — a
52    /// value outside that set fails with a clear error before the request.
53    pub effort: Option<String>,
54    /// Retry policy for the connect phase (transient failures). See
55    /// [`RetryPolicy`].
56    pub retry: RetryPolicy,
57}
58
59pub type LlmStream = Pin<Box<dyn Stream<Item = LlmStreamEvent> + Send>>;
60
61pub type StreamFn =
62    Arc<dyn Fn(&[AgentMessage], &str, &[ToolDefinition], &LlmOpts) -> LlmStream + Send + Sync>;
63
64/// Everything that shapes the underlying rig client: the endpoint, the key, and
65/// the routing/session header. `model`, `temperature`, and `effort` ride on the
66/// request, not the client, so a mid-conversation model swap still reuses the
67/// pooled HTTP connections. (`OPENAI_API_KEY`, the key fallback when `api_key`
68/// is None, is read once at build time and treated as constant.)
69#[derive(Clone, PartialEq, Eq)]
70struct ClientKey {
71    base_url: Option<String>,
72    api_key: Option<String>,
73    session_id: Option<String>,
74}
75
76impl ClientKey {
77    fn of(opts: &LlmOpts) -> Self {
78        Self {
79            base_url: opts.base_url.clone(),
80            api_key: opts.api_key.clone(),
81            session_id: opts.session_id.clone(),
82        }
83    }
84}
85
86/// One-entry client cache. rig's `Client` wraps a pooled `reqwest::Client`, so
87/// rebuilding it every turn would drop the pool and re-handshake TLS; this keeps
88/// one client alive across turns and rebuilds only when [`ClientKey`] changes.
89#[derive(Default)]
90struct ClientCache {
91    slot: Mutex<Option<(ClientKey, openai::Client)>>,
92    #[cfg(test)]
93    builds: std::sync::atomic::AtomicUsize,
94}
95
96impl ClientCache {
97    /// The client for `opts`, reusing the cached one when the key is unchanged.
98    fn get(&self, opts: &LlmOpts) -> Result<openai::Client, String> {
99        let key = ClientKey::of(opts);
100        let mut slot = self.slot.lock().unwrap_or_else(|e| e.into_inner());
101        if let Some((cached_key, client)) = slot.as_ref()
102            && *cached_key == key
103        {
104            return Ok(client.clone());
105        }
106        let client = openai_client(opts)?;
107        #[cfg(test)]
108        self.builds
109            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
110        *slot = Some((key, client.clone()));
111        Ok(client)
112    }
113}
114
115/// Default adapter: OpenAI-compatible Chat Completions endpoint via rig.
116///
117/// The provider client (and its pooled connections) is built once and reused
118/// across turns; only an endpoint/key/session change rebuilds it.
119pub fn rig_stream_fn() -> StreamFn {
120    let cache = ClientCache::default();
121    Arc::new(move |messages, system, tools, opts| adapt(&cache, opts, messages, system, tools))
122}
123
124/// Build the rig client shared by streaming and model listing, so both hit
125/// the same base_url with the same key.
126fn openai_client(opts: &LlmOpts) -> Result<rig::providers::openai::Client, String> {
127    let key = opts
128        .api_key
129        .clone()
130        .or_else(|| std::env::var("OPENAI_API_KEY").ok());
131    let key = match key {
132        Some(k) => k,
133        None => {
134            // Keyless local OpenAI-compatible endpoints (Ollama, llama.cpp,
135            // vLLM, ...) accept any bearer token, so a placeholder keeps them
136            // usable while a missing key against a real endpoint fails loudly.
137            let local = opts.base_url.as_deref().is_some_and(|url| {
138                let u = url.to_ascii_lowercase();
139                ["http://localhost", "http://127.0.0.1", "http://[::1]"]
140                    .iter()
141                    .any(|prefix| u.starts_with(prefix))
142            });
143            if local {
144                "wcode-local".to_string()
145            } else {
146                return Err(
147                    "no API key: pass LlmOpts.api_key or set OPENAI_API_KEY (a localhost base_url works keyless)"
148                        .to_string(),
149                );
150            }
151        }
152    };
153    let mut builder = openai::Client::builder()
154        .api_key::<rig::client::BearerAuth>(key)
155        .http_headers(session_headers(opts));
156    if let Some(base_url) = &opts.base_url {
157        builder = builder.base_url(base_url);
158    }
159    builder.build().map_err(|e| format!("openai client: {e}"))
160}
161
162/// Default headers for every provider request: own `User-Agent` (OpenCode Go
163/// rejects generic SDK names for routing) plus `x-opencode-session` when set.
164fn session_headers(opts: &LlmOpts) -> HeaderMap {
165    let mut headers = HeaderMap::new();
166    headers.insert(
167        "user-agent",
168        HeaderValue::from_static(concat!("wcode/", env!("CARGO_PKG_VERSION"))),
169    );
170    if let Some(id) = opts.session_id.as_deref()
171        && let Ok(value) = HeaderValue::from_str(id)
172    {
173        headers.insert("x-opencode-session", value);
174    }
175    headers
176}
177
178/// List models via `GET {base_url}/models` using the same client config as
179/// streaming. Ids are sorted for stable display. The endpoint only returns
180/// id/name metadata — no capability flags — so effort support stays
181/// user-managed.
182pub async fn list_models(opts: &LlmOpts) -> Result<Vec<String>, String> {
183    let client = openai_client(opts)?;
184    let list = client.list_models().await.map_err(|e| e.to_string())?;
185    let mut ids: Vec<String> = list.iter().map(|m| m.id.clone()).collect();
186    ids.sort();
187    Ok(ids)
188}
189
190fn adapt(
191    cache: &ClientCache,
192    opts: &LlmOpts,
193    messages: &[AgentMessage],
194    system: &str,
195    tools: &[ToolDefinition],
196) -> LlmStream {
197    if let Some(message) = invalid_effort(opts) {
198        return error_stream(message);
199    }
200    let client = match cache.get(opts) {
201        Ok(client) => client,
202        Err(message) => return error_stream(message),
203    };
204    let request = build_request(messages, system, tools, opts);
205
206    // Both wires yield the same normalized StreamingCompletionResponse, so
207    // only model construction branches; the forwarding loop below is shared.
208    // Retry the connect (and the first item — rig defers the request into the
209    // stream). Once output is forwarded a mid-stream error is surfaced as-is. A
210    // fresh model per attempt is cheap — it just wraps the pooled client;
211    // `request` is cloned per attempt and `policy` is `Copy`.
212    let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<LlmStreamEvent>();
213    let Ok(runtime) = tokio::runtime::Handle::try_current() else {
214        return error_stream("StreamFn requires a tokio runtime".to_string());
215    };
216    let policy = opts.retry;
217    let endpoint = opts.endpoint;
218    let model = opts.model.clone();
219    runtime.spawn(async move {
220        let open = || open_stream(client.clone(), endpoint, &model, request.clone());
221        // Retry budget for the whole pre-content window, shared across the
222        // connect, the first-item peek (rig defers the request into the stream,
223        // so a transport failure surfaces there), and any transient error that
224        // slips past the peek before the first content event is forwarded.
225        let mut state = RetryState::new(policy.max);
226        let (stream, pending) = match connect_with_retry(&policy, &mut state, &open, &tx).await {
227            PreContent::Ready { stream, first } => (stream, first),
228            PreContent::Cancelled => return,
229            PreContent::Failed(error) => {
230                let _ = tx.send(LlmStreamEvent::Error {
231                    message: error.to_string(),
232                    fatal: fatal_class(&error),
233                });
234                return;
235            }
236        };
237        forward_stream(stream, pending, policy, &open, tx, state).await;
238    });
239    Box::pin(futures::stream::unfold(rx, |mut rx| async move {
240        rx.recv().await.map(|ev| (ev, rx))
241    }))
242}
243
244/// The forwarding phase of a stream turn: pull items (the buffered `pending`
245/// first, then from `stream` under the idle deadline), map them to events, and
246/// send them on `tx`. A pre-content stall re-opens via `open` on the shared
247/// retry budget (mirroring a transient connect error); a post-content stall (or
248/// an exhausted budget) surfaces a non-fatal error and ends the loop.
249async fn forward_stream<S, F>(
250    mut stream: S,
251    mut pending: Option<Result<StreamedAssistantContent, CompletionError>>,
252    policy: RetryPolicy,
253    open: F,
254    tx: tokio::sync::mpsc::UnboundedSender<LlmStreamEvent>,
255    mut state: RetryState,
256) where
257    F: Fn() -> Pin<Box<dyn std::future::Future<Output = Result<S, CompletionError>> + Send>>,
258    S: futures::Stream<Item = Result<StreamedAssistantContent, CompletionError>> + Unpin + Send,
259{
260    let mut errored = false;
261    let mut content_forwarded = false;
262    let mut done_seen = false;
263    loop {
264        // The next item: a buffered first item, or one from the stream under the
265        // idle deadline. Cancel-safe: a dropped `next_or_stall` drops an
266        // in-flight `stream.next()` poll, which is safe to re-enter (the
267        // pre-content path re-opens a fresh stream); `biased` with the item arm
268        // first so a ready item is never lost.
269        let item = match pending.take() {
270            Some(item) => item,
271            None => tokio::select! {
272                biased;
273                result = next_or_stall(&mut stream, policy.idle) => match result {
274                    // The stream ended. A `Final` record (mapped to `Done`) is the
275                    // only clean end: rig emits neither a `Final` nor an `Err` when
276                    // the provider SSE body ends early (proxy idle-close / half-close
277                    // / EOF), so the normalized stream just ends with `None` — a
278                    // truncated turn, not a stop.
279                    None => {
280                        if done_seen || errored {
281                            break;
282                        }
283                        let error = truncation_error();
284                        if !content_forwarded && state.budget > 0 {
285                            // Pre-content: retry on the shared budget, exactly like
286                            // the idle-stall arm (one retry path, via the helper).
287                            match pre_content_retry(&policy, &mut state, &open, &tx, &error).await {
288                                PreContent::Ready { stream: s, first } => {
289                                    stream = s;
290                                    pending = first;
291                                    continue;
292                                }
293                                PreContent::Cancelled => return,
294                                PreContent::Failed(e) => {
295                                    if tx
296                                        .send(LlmStreamEvent::Error {
297                                            message: e.to_string(),
298                                            fatal: fatal_class(&e),
299                                        })
300                                        .is_err()
301                                    {
302                                        return;
303                                    }
304                                    break;
305                                }
306                            }
307                        } else {
308                            // NON-fatal: the loop feeds it back next turn and, if it
309                            // persists, ends StopReason::Error.
310                            // Chosen Error.message: `truncation_error().to_string()`.
311                            if tx
312                                .send(LlmStreamEvent::Error {
313                                    message: error.to_string(),
314                                    fatal: false,
315                                })
316                                .is_err()
317                            {
318                                return;
319                            }
320                            break;
321                        }
322                    }
323                    // One item — the stream's own `Result`.
324                    Some(Stall::Item(item)) => item,
325                    // The idle deadline fired. Pre-content → a transient retry
326                    // on the shared budget (re-open, mirroring a connect error);
327                    // post-content (or an exhausted budget) → a NON-fatal error
328                    // that ends the turn (a stalled stream will not resume).
329                    Some(Stall::Idle) => {
330                        let error = stall_error(policy.idle);
331                        if !content_forwarded && state.budget > 0 {
332                            match pre_content_retry(&policy, &mut state, &open, &tx, &error).await {
333                                PreContent::Ready { stream: s, first } => {
334                                    stream = s;
335                                    pending = first;
336                                    continue;
337                                }
338                                PreContent::Cancelled => return,
339                                PreContent::Failed(e) => {
340                                    let fatal = fatal_class(&e);
341                                    if tx
342                                        .send(LlmStreamEvent::Error {
343                                            message: e.to_string(),
344                                            fatal,
345                                        })
346                                        .is_err()
347                                    {
348                                        return;
349                                    }
350                                    break;
351                                }
352                            }
353                        } else {
354                            if tx
355                                .send(LlmStreamEvent::Error {
356                                    message: error.to_string(),
357                                    fatal: false,
358                                })
359                                .is_err()
360                            {
361                                return;
362                            }
363                            break;
364                        }
365                    }
366                },
367                _ = tx.closed() => break,
368            },
369        };
370        let events = match item {
371            Ok(content) => {
372                let is_final = matches!(content, StreamedAssistantContent::Final(_));
373                if errored && is_final {
374                    continue;
375                }
376                let events = map_item(content);
377                // Replay is only safe before the first forwarded event;
378                // once anything reached the consumer a later error cannot
379                // be retried (it would duplicate output).
380                content_forwarded |= !events.is_empty();
381                done_seen |= is_final;
382                events
383            }
384            Err(error) => {
385                if !content_forwarded && state.budget > 0 && retryable(&error) {
386                    // Nothing forwarded yet: back off and replay the request
387                    // from scratch. The re-open shares the same retry budget
388                    // and keeps backoff growing monotonically.
389                    state.attempts += 1;
390                    state.budget -= 1;
391                    if !retry_wait(&tx, &policy, state.attempts, &error).await {
392                        return;
393                    }
394                    match connect_with_retry(&policy, &mut state, &open, &tx).await {
395                        PreContent::Ready { stream: s, first } => {
396                            stream = s;
397                            pending = first;
398                            continue;
399                        }
400                        PreContent::Cancelled => return,
401                        PreContent::Failed(e) => {
402                            errored = true;
403                            vec![LlmStreamEvent::Error {
404                                message: e.to_string(),
405                                fatal: fatal_class(&e),
406                            }]
407                        }
408                    }
409                } else {
410                    // Final: a hard-fatal class, retries exhausted, or a
411                    // mid-stream error after content. The loop decides (via
412                    // `fatal`) whether to feed it back or end the run.
413                    errored = true;
414                    vec![LlmStreamEvent::Error {
415                        message: error.to_string(),
416                        fatal: fatal_class(&error),
417                    }]
418                }
419            }
420        };
421        for ev in events {
422            if tx.send(ev).is_err() {
423                return;
424            }
425        }
426    }
427}
428
429fn error_stream(message: String) -> LlmStream {
430    // Pre-request failures (invalid effort, client build, missing runtime) are
431    // hard-fatal: the request itself can never succeed, so the run must stop.
432    Box::pin(futures::stream::iter(vec![LlmStreamEvent::Error {
433        message,
434        fatal: true,
435    }]))
436}
437
438/// Retry policy for a stream call: the transient-connect retries plus the stall
439/// (idle / time-to-first-token) timeouts.
440#[derive(Clone, Copy, Debug, PartialEq, Eq)]
441pub struct RetryPolicy {
442    /// Retries after the first attempt (0 disables retrying).
443    pub max: u32,
444    /// Base backoff: attempt `n` waits `base * 2^(n-1)`, capped at `cap`.
445    pub base: Duration,
446    /// Upper bound on a single backoff wait.
447    pub cap: Duration,
448    /// Time-to-first-token: covers connect + the first stream item. `ZERO` = off.
449    pub ttft: Duration,
450    /// Inter-item idle: resets on every received item. `ZERO` = off.
451    pub idle: Duration,
452}
453
454impl Default for RetryPolicy {
455    fn default() -> Self {
456        Self {
457            max: 3,
458            base: Duration::from_millis(500),
459            cap: Duration::from_secs(8),
460            ttft: Duration::from_secs(60),
461            idle: Duration::from_secs(120),
462        }
463    }
464}
465
466/// One poll of a stream under a stall deadline (see [`next_or_stall`]).
467enum Stall<T> {
468    /// One item — the stream's OWN item type (for a rig stream, a
469    /// `Result<StreamedAssistantContent, CompletionError>`), not an `Option`.
470    Item(T),
471    /// The deadline fired before an item arrived.
472    Idle,
473}
474
475/// `stream.next()` under a `deadline`; `ZERO` means "no timeout" and collapses
476/// to a plain `stream.next().await`. `biased` toward the item so a ready item
477/// wins a tie with an expired deadline. Outer `None` = the stream ended.
478async fn next_or_stall<S: Stream + Unpin>(
479    stream: &mut S,
480    deadline: Duration,
481) -> Option<Stall<S::Item>> {
482    if deadline.is_zero() {
483        return stream.next().await.map(Stall::Item);
484    }
485    tokio::select! {
486        biased;
487        item = stream.next() => item.map(Stall::Item),
488        _ = tokio::time::sleep(deadline) => Some(Stall::Idle),
489    }
490}
491/// Transient HTTP statuses worth retrying: request timeout/conflict, rate
492/// limiting, and the 5xx family. Client errors (400/401/403/404/422) are not.
493fn is_transient_status(code: u16) -> bool {
494    matches!(code, 408 | 409 | 429 | 500 | 502 | 503 | 504)
495}
496
497/// Whether a failed connect is worth retrying. A preserved provider status is
498/// authoritative (transient statuses retry, client errors don't); with no
499/// status, a transport failure retries and a build/parse error does not.
500fn retryable(error: &CompletionError) -> bool {
501    if let Some(status) = error.provider_response_status() {
502        return is_transient_status(status.as_u16());
503    }
504    match error {
505        // Transport failure with no status (connect refused / reset / timeout).
506        CompletionError::HttpError(_) => true,
507        // rig's OpenAI path folds some transport failures into a
508        // `ProviderError(String)` (reqwest's "error sending request …"); retry
509        // those, but not provider rejections that share the variant.
510        CompletionError::ProviderError(message) => is_transient_message(message),
511        _ => false,
512    }
513}
514
515/// Heuristic for transport failures that arrive as `ProviderError` strings:
516/// connect/DNS/timeout/reset wording, not provider rejections.
517fn is_transient_message(message: &str) -> bool {
518    const NEEDLES: [&str; 10] = [
519        "error sending request",
520        "error trying to connect",
521        "connection refused",
522        "connection reset",
523        "connection closed",
524        "timed out",
525        "timeout",
526        "unreachable",
527        "dns error",
528        "temporary failure",
529    ];
530    let m = message.to_ascii_lowercase();
531    NEEDLES.iter().any(|needle| m.contains(needle))
532}
533
534/// Exponential backoff for `attempt` (1-based), capped, with jitter in
535/// [75%, 100%] so repeated retries don't align on the same instant.
536fn backoff(attempt: u32, base: Duration, cap: Duration) -> Duration {
537    let shift = attempt.saturating_sub(1).min(16);
538    let capped = base.saturating_mul(1u32 << shift).min(cap);
539    let millis = capped.as_millis() as u64;
540    if millis <= 1 {
541        return capped;
542    }
543    let jitter = clock_nanos() % (millis / 4 + 1);
544    Duration::from_millis(millis - jitter)
545}
546
547/// A cheap clock seed for backoff jitter.
548fn clock_nanos() -> u64 {
549    SystemTime::now()
550        .duration_since(UNIX_EPOCH)
551        .map(|d| u64::from(d.subsec_nanos()))
552        .unwrap_or(0)
553}
554
555/// Build one connect attempt: a fresh future per call, so the handshake can be
556/// retried. Once the response is in hand a mid-stream error is not retried.
557fn open_stream(
558    client: rig::providers::openai::Client,
559    endpoint: LlmEndpoint,
560    model: &str,
561    request: CompletionRequest,
562) -> Pin<Box<dyn std::future::Future<Output = Result<StreamingCompletionResponse, CompletionError>> + Send>> {
563    match endpoint {
564        LlmEndpoint::Chat => {
565            let model = client.completions_api().completion_model(model.to_string());
566            Box::pin(async move { model.stream(request).await })
567        }
568        LlmEndpoint::Responses => {
569            let model = client.completion_model(model.to_string());
570            Box::pin(async move { model.stream(request).await })
571        }
572    }
573}
574
575/// Outcome of the pre-content phase of a stream turn: a stream with its first
576/// (buffered) item — which may itself be an error, in which case the caller
577/// classifies it — or a final failure.
578enum PreContent<S> {
579    Ready {
580        stream: S,
581        first: Option<Result<StreamedAssistantContent, CompletionError>>,
582    },
583    /// Final failure: not in the transient class, or retries exhausted.
584    Failed(CompletionError),
585    /// The consumer dropped `tx` during a backoff wait (cancellation).
586    Cancelled,
587}
588
589/// Shared retry bookkeeping for one turn's pre-content window: how many retries
590/// remain across the connect/peek and any later pre-content errors, plus how
591/// many failed attempts have accumulated so backoff keeps growing monotonically.
592#[derive(Clone, Copy)]
593struct RetryState {
594    budget: u32,
595    attempts: u32,
596}
597
598impl RetryState {
599    fn new(max: u32) -> Self {
600        RetryState {
601            budget: max,
602            attempts: 0,
603        }
604    }
605}
606
607/// Whether a failed stream call is of a hard-fatal class that retrying or
608/// re-feeding cannot fix: hard client statuses (400/401/403/404/422) and
609/// build/parse failures (Json/Request/Response/Url). Transient-class errors
610/// (see [`retryable`]) may exhaust their retries — those surface with
611/// `fatal: false` so the loop can feed the failure back to the model instead
612/// of ending the run.
613fn fatal_class(error: &CompletionError) -> bool {
614    !retryable(error)
615}
616
617/// Open a stream, retrying transient failures on the connect and on anything
618/// the peek surfaces. rig's OpenAI path defers the HTTP request into the stream,
619/// so a connection failure surfaces on the first poll; retrying only the
620/// connect would miss it. `open` is re-invoked per attempt (a fresh future).
621/// The [`RetryState`] budget is shared with the forwarding loop, so a transient
622/// error that slips past the peek (still before any content was forwarded) is
623/// likewise retried at most `policy.max` times total per turn. Once any content
624/// has been forwarded a mid-stream error is surfaced as-is — retrying would
625/// duplicate output. Each backoff is abandoned if the consumer drops `tx`.
626/// A synthesized provider error for a stream stall. The message carries a
627/// needle `is_transient_message` already matches ("timed out"), so `retryable()`
628/// is `true` and `fatal_class()` is `false` (B1) — a stall rides the existing
629/// transient-retry / feed-back paths unchanged.
630fn stall_error(d: Duration) -> CompletionError {
631    CompletionError::ProviderError(format!("stream stalled: no item within {d:?} (timed out)"))
632}
633/// A synthesized stream-truncation failure, shaped like [`stall_error`]. The
634/// stream ended without its terminal record: rig emits neither a `Final` nor an
635/// `Err` when the provider SSE body ends early (no `[DONE]`/finish_reason), so
636/// the normalized stream simply ends with `None`. Not necessarily `retryable()`
637/// — the pre-content branch re-opens unconditionally — the message only feeds
638/// `retry_wait`'s notice text and the surfaced `Error.message`.
639fn truncation_error() -> CompletionError {
640    CompletionError::ProviderError(
641        "stream ended before its terminal record (truncated)".to_string(),
642    )
643}
644
645async fn connect_with_retry<S, F>(
646    policy: &RetryPolicy,
647    state: &mut RetryState,
648    mut open: F,
649    tx: &tokio::sync::mpsc::UnboundedSender<LlmStreamEvent>,
650) -> PreContent<S>
651where
652    F: FnMut() -> Pin<Box<dyn std::future::Future<Output = Result<S, CompletionError>> + Send>>,
653    S: futures::Stream<Item = Result<StreamedAssistantContent, CompletionError>> + Unpin + Send,
654{
655    loop {
656        state.attempts += 1;
657        // Race the connect against `ttft` — zero-aware: `ZERO` disables the
658        // deadline and awaits the connect directly (never `sleep(ZERO)`, which
659        // would fire at once). A connect stall yields a transient `stall_error`.
660        let opened = if policy.ttft.is_zero() {
661            Ok(open().await)
662        } else {
663            tokio::select! {
664                biased;
665                r = open() => Ok(r),
666                _ = tokio::time::sleep(policy.ttft) => Err(()),
667            }
668        };
669        let mut stream = match opened {
670            Ok(Ok(stream)) => stream,
671            Ok(Err(error)) => {
672                if state.budget > 0 && retryable(&error) {
673                    state.budget -= 1;
674                    if !retry_wait(tx, policy, state.attempts, &error).await {
675                        return PreContent::Cancelled;
676                    }
677                    continue;
678                }
679                return PreContent::Failed(error);
680            }
681            Err(()) => {
682                let error = stall_error(policy.ttft);
683                if state.budget > 0 {
684                    state.budget -= 1;
685                    if !retry_wait(tx, policy, state.attempts, &error).await {
686                        return PreContent::Cancelled;
687                    }
688                    continue;
689                }
690                return PreContent::Failed(error);
691            }
692        };
693        // Peek the first item under the SAME `ttft`: rig defers the request into
694        // the stream, so a transport failure (or a stall) surfaces here.
695        match next_or_stall(&mut stream, policy.ttft).await {
696            None => return PreContent::Ready { stream, first: None },
697            Some(Stall::Item(Err(error))) => {
698                if state.budget > 0 && retryable(&error) {
699                    state.budget -= 1;
700                    if !retry_wait(tx, policy, state.attempts, &error).await {
701                        return PreContent::Cancelled;
702                    }
703                    continue;
704                }
705                // A first-item error that cannot be retried is still an error
706                // item: hand it to the forwarding loop, which classifies it
707                // (fatal vs feed-back) like any other.
708                return PreContent::Ready {
709                    stream,
710                    first: Some(Err(error)),
711                };
712            }
713            Some(Stall::Item(Ok(content))) => {
714                return PreContent::Ready {
715                    stream,
716                    first: Some(Ok(content)),
717                };
718            }
719            Some(Stall::Idle) => {
720                let error = stall_error(policy.ttft);
721                if state.budget > 0 {
722                    state.budget -= 1;
723                    if !retry_wait(tx, policy, state.attempts, &error).await {
724                        return PreContent::Cancelled;
725                    }
726                    continue;
727                }
728                return PreContent::Failed(error);
729            }
730        }
731    }
732}
733
734/// One pre-content retry shared by the idle-stall arm and the truncation arm:
735/// spend one attempt + one budget unit on the shared [`RetryState`], back off
736/// (abandoning if the consumer dropped `tx`), then re-open via
737/// [`connect_with_retry`] with the SAME state so the budget stays monotone.
738/// Returns the same [`PreContent<S>`] the direct connect path returns, so both
739/// callers match identically — no new enum, no duplicated match.
740async fn pre_content_retry<S, F>(
741    policy: &RetryPolicy,
742    state: &mut RetryState,
743    open: &F,
744    tx: &tokio::sync::mpsc::UnboundedSender<LlmStreamEvent>,
745    error: &CompletionError,
746) -> PreContent<S>
747where
748    F: Fn() -> Pin<Box<dyn std::future::Future<Output = Result<S, CompletionError>> + Send>>,
749    S: futures::Stream<Item = Result<StreamedAssistantContent, CompletionError>> + Unpin + Send,
750{
751    state.attempts += 1;
752    state.budget -= 1;
753    if !retry_wait(tx, policy, state.attempts, error).await {
754        return PreContent::Cancelled;
755    }
756    connect_with_retry(policy, state, open, tx).await
757}
758
759/// The server's `Retry-After` header (integer delta-seconds), when the failed
760/// response preserved one. `None` when there is no preserved response, no
761/// header, a non-UTF-8 value, or a value that is not a non-negative integer.
762///
763/// SCOPE: integer delta-seconds ONLY. The HTTP-date form of `Retry-After`
764/// (RFC 9110 `Retry-After = HTTP-date / delay-seconds`) is deliberately out of
765/// scope: a date would need wall-clock parsing against the loop's monotonic
766/// `tokio::time` deadline, and a subtly wrong date is worse than ignoring it —
767/// a provider that sends one simply gets the normal backoff.
768fn retry_after(error: &CompletionError) -> Option<Duration> {
769    // `provider_response_headers()` already unwraps both the
770    // `HttpError(InvalidStatusCodeWithDetails)` and the `ProviderResponse`
771    // variants, so this reads either.
772    let raw = error.provider_response_headers()?.get("retry-after")?;
773    // A non-UTF-8 header value is ignored, not an error.
774    let seconds: u64 = raw.to_str().ok()?.trim().parse().ok()?;
775    Some(Duration::from_secs(seconds))
776}
777
778/// Emit a retry notice and sleep the backoff. Returns false if the consumer
779/// dropped `tx` (cancellation) during the wait.
780async fn retry_wait(
781    tx: &tokio::sync::mpsc::UnboundedSender<LlmStreamEvent>,
782    policy: &RetryPolicy,
783    attempt: u32,
784    error: &CompletionError,
785) -> bool {
786    // A server-supplied `Retry-After` is authoritative and wins over the local
787    // backoff, but it is clamped by the existing `policy.cap` so a hostile or
788    // buggy `Retry-After: 86400` cannot park the turn for a day.
789    let delay = retry_after(error)
790        .map(|d| d.min(policy.cap))
791        .unwrap_or_else(|| backoff(attempt, policy.base, policy.cap));
792    let _ = tx.send(LlmStreamEvent::Retrying {
793        attempt,
794        max: policy.max,
795        reason: error.to_string(),
796    });
797    tokio::select! {
798        biased;
799        _ = tx.closed() => false,
800        _ = tokio::time::sleep(delay) => true,
801    }
802}
803
804fn build_request(
805    messages: &[AgentMessage],
806    system: &str,
807    tools: &[ToolDefinition],
808    opts: &LlmOpts,
809) -> CompletionRequest {
810    let mut chat_history = Vec::with_capacity(messages.len() + 1);
811    if !system.is_empty() {
812        chat_history.push(Message::system(system));
813    }
814    chat_history.extend(messages.iter().filter_map(to_rig_message));
815    CompletionRequest {
816        model: None,
817        // System instructions ride as the leading Message::System, the
818        // canonical form rig's own request builder funnels `preamble` into.
819        preamble: None,
820        chat_history,
821        documents: Vec::new(),
822        tools: tools.to_vec(),
823        temperature: opts.temperature,
824        max_tokens: None,
825        tool_choice: None,
826        additional_params: effort_params(opts),
827        output_schema: None,
828        record_telemetry_content: false,
829    }
830}
831
832/// Effort levels accepted by the Responses wire. rig deserializes
833/// `reasoning.effort` into a snake_case enum client-side (`ReasoningEffort`),
834/// so any other string fails with an opaque RequestError at send time;
835/// `invalid_effort` surfaces a clear error instead. Chat forwards any string.
836const RESPONSES_EFFORT_LEVELS: &[&str] =
837    &["none", "minimal", "low", "medium", "high", "xhigh", "max"];
838
839/// `Some(message)` when `opts.effort` cannot cross the configured wire: the
840/// Responses endpoint validates effort client-side against the enum above,
841/// while Chat forwards any string to the provider.
842fn invalid_effort(opts: &LlmOpts) -> Option<String> {
843    if opts.endpoint == LlmEndpoint::Responses
844        && let Some(effort) = opts.effort.as_deref()
845        && !RESPONSES_EFFORT_LEVELS.contains(&effort)
846    {
847        return Some(format!(
848            "effort `{effort}` is not valid for the Responses endpoint; use one of: {}",
849            RESPONSES_EFFORT_LEVELS.join(", ")
850        ));
851    }
852    None
853}
854
855/// Free-style effort passthrough, fanned out per wire shape. None sends
856/// nothing (backends that reject unknown fields keep working). Values are
857/// forwarded verbatim on Chat (`reasoning_effort`); on Responses the payload
858/// is `reasoning: { effort }` after `invalid_effort` has validated the level.
859fn effort_params(opts: &LlmOpts) -> Option<serde_json::Value> {
860    let effort = opts.effort.as_ref()?;
861    match opts.endpoint {
862        LlmEndpoint::Chat => Some(serde_json::json!({ "reasoning_effort": effort })),
863        LlmEndpoint::Responses => Some(serde_json::json!({ "reasoning": { "effort": effort } })),
864    }
865}
866
867/// Prefix applied to a failed tool's output *on the wire only*. The chat wire
868/// has no structured error flag for tool results (rig's `ToolResult` only
869/// carries text/content), so a failed tool's output must be annotated in the
870/// text the model sees — otherwise a tool failure looks exactly like a
871/// success. The UI and the JSONL session keep the raw output; only the wire
872/// copy read by the LLM is marked.
873const TOOL_OUTPUT_ERROR_PREFIX: &str = "ERROR: ";
874
875fn to_rig_message(m: &AgentMessage) -> Option<Message> {
876    match m {
877        AgentMessage::User { content } => {
878            let content: Vec<UserContent> = content
879                .iter()
880                .filter_map(|b| match b {
881                    ContentBlock::Text { text } => Some(UserContent::text(text.clone())),
882                    _ => None,
883                })
884                .collect();
885            (!content.is_empty()).then_some(Message::User { content })
886        }
887        AgentMessage::Assistant { content, .. } => {
888            let content: Vec<AssistantContent> = content
889                .iter()
890                .map(|b| match b {
891                    ContentBlock::Text { text } => AssistantContent::text(text.clone()),
892                    ContentBlock::Thinking { text } => {
893                        AssistantContent::Reasoning(Reasoning::new(text))
894                    }
895                    ContentBlock::ToolCall {
896                        id,
897                        name,
898                        arguments,
899                    } => AssistantContent::ToolCall(ToolCall::new(
900                        ToolCallId::new_or_mint(id.clone()),
901                        ToolFunction::new(name.clone(), arguments.clone()),
902                    )),
903                })
904                .collect();
905            (!content.is_empty()).then_some(Message::Assistant { id: None, content })
906        }
907        AgentMessage::ToolResult {
908            tool_call_id,
909            name,
910            output,
911            is_error,
912        } => {
913            // The wire has no structured error flag for tool results, so mark
914            // failures in the text the model sees. The UI and the session keep
915            // the raw output — only this wire copy is prefixed.
916            let output = if *is_error {
917                format!("{TOOL_OUTPUT_ERROR_PREFIX}{output}")
918            } else {
919                output.clone()
920            };
921            Some(Message::tool_result(
922                tool_call_id.clone(),
923                name.clone(),
924                output,
925            ))
926        }
927    }
928}
929
930fn map_item(item: StreamedAssistantContent) -> Vec<LlmStreamEvent> {
931    match item {
932        StreamedAssistantContent::Text(text) => vec![LlmStreamEvent::TextDelta(text.text)],
933        StreamedAssistantContent::ReasoningDelta { reasoning, .. } => {
934            vec![LlmStreamEvent::ThinkingDelta(reasoning)]
935        }
936        // ponytail: kernel v1 is append-only; this complete-block branch is dead
937        // on chat-completions wires (deltas only). Restating wire => replacement variant.
938        StreamedAssistantContent::Reasoning { reasoning, .. } => {
939            // Replacement semantics: the complete block supersedes the
940            // accumulated deltas (the loop drops prior thinking on this event).
941            vec![LlmStreamEvent::ThinkingReplace(reasoning_text(&reasoning))]
942        }
943        // Deltas are dropped: rig's streaming fold accumulates every tool-call
944        // fragment and emits the complete `ToolCall` on `ToolInputEnd` (both
945        // chat-completions and responses wires do this), so the reassembled
946        // call always follows. A provider that ends input without closing it
947        // loses the call inside rig itself; the loop surfaces that as an error
948        // rather than silently treating the turn as done.
949        StreamedAssistantContent::ToolCallDelta { .. } => vec![],
950        StreamedAssistantContent::ToolCall { tool_call, .. } => vec![LlmStreamEvent::ToolCall {
951            id: tool_call.id.as_str().to_string(),
952            name: tool_call.function.name,
953            arguments: tool_call.function.arguments,
954        }],
955        StreamedAssistantContent::Final(final_record) => {
956            vec![LlmStreamEvent::Done {
957                stop_reason: final_record
958                    .finish_reason
959                    .map(finish_reason)
960                    .unwrap_or(StopReason::Stop),
961                usage: Some(convert_usage(final_record.usage)),
962            }]
963        }
964        StreamedAssistantContent::Unknown(_) => vec![],
965    }
966}
967
968fn finish_reason(reason: FinishReason) -> StopReason {
969    match reason {
970        FinishReason::Stop => StopReason::Stop,
971        FinishReason::Length => StopReason::Length,
972        FinishReason::ToolCalls => StopReason::ToolUse,
973        FinishReason::ContentFilter | FinishReason::Other(_) => StopReason::Stop,
974    }
975}
976
977fn convert_usage(usage: rig::completion::Usage) -> crate::message::Usage {
978    crate::message::Usage {
979        input_tokens: usage.input_tokens,
980        output_tokens: usage.output_tokens,
981        // Zero is rig's documented sentinel for "not reported".
982        cache_read_tokens: (usage.cached_input_tokens > 0).then_some(usage.cached_input_tokens),
983        cache_write_tokens: (usage.cache_creation_input_tokens > 0)
984            .then_some(usage.cache_creation_input_tokens),
985    }
986}
987
988fn reasoning_text(reasoning: &Reasoning) -> String {
989    reasoning
990        .content
991        .iter()
992        .filter_map(|block| match block {
993            ReasoningContent::Text { text, .. } => Some(text.as_str()),
994            _ => None,
995        })
996        .collect::<Vec<_>>()
997        .join("")
998}
999
1000#[cfg(test)]
1001mod tests {
1002    use super::*;
1003    use rig::completion::Usage as RigUsage;
1004    use rig::message::{ToolResultContent, UserContent};
1005    use rig::streaming::{RawStreamingToolCall, StreamFinal, ToolCallDeltaContent};
1006    use serde_json::json;
1007
1008    fn tool_call_item() -> StreamedAssistantContent {
1009        let raw =
1010            RawStreamingToolCall::new("call_1".to_string(), "run".to_string(), json!({"x": 1}));
1011        StreamedAssistantContent::ToolCall {
1012            tool_call: raw.into(),
1013            internal_call_id: "ic1".to_string(),
1014        }
1015    }
1016
1017    fn final_item(finish_reason: Option<FinishReason>) -> StreamedAssistantContent {
1018        let usage = RigUsage {
1019            input_tokens: 10,
1020            output_tokens: 5,
1021            total_tokens: 15,
1022            cached_input_tokens: 3,
1023            cache_creation_input_tokens: 2,
1024            ..RigUsage::default()
1025        };
1026        let final_record = StreamFinal::new("test", usage);
1027        StreamedAssistantContent::Final(match finish_reason {
1028            Some(reason) => final_record.with_finish_reason(reason),
1029            None => final_record,
1030        })
1031    }
1032
1033    #[test]
1034    fn text_item_maps_to_text_delta() {
1035        let events = map_item(StreamedAssistantContent::Text(rig::message::Text::new(
1036            "hi",
1037        )));
1038        assert_eq!(events, vec![LlmStreamEvent::TextDelta("hi".to_string())]);
1039    }
1040
1041    #[test]
1042    fn reasoning_delta_maps_to_thinking_delta() {
1043        let events = map_item(StreamedAssistantContent::ReasoningDelta {
1044            id: "r1".to_string(),
1045            provider_id: None,
1046            reasoning: "hmm".to_string(),
1047        });
1048        assert_eq!(
1049            events,
1050            vec![LlmStreamEvent::ThinkingDelta("hmm".to_string())]
1051        );
1052    }
1053
1054    #[test]
1055    fn reasoning_block_replaces_with_full_text() {
1056        let events = map_item(StreamedAssistantContent::Reasoning {
1057            reasoning: Reasoning::new("full thought"),
1058            id: "r1".to_string(),
1059        });
1060        assert_eq!(
1061            events,
1062            vec![LlmStreamEvent::ThinkingReplace("full thought".to_string())]
1063        );
1064    }
1065
1066    #[test]
1067    fn tool_call_delta_ignored() {
1068        for content in [
1069            ToolCallDeltaContent::Name("run".to_string()),
1070            ToolCallDeltaContent::Delta("{\"x".to_string()),
1071        ] {
1072            let events = map_item(StreamedAssistantContent::ToolCallDelta {
1073                internal_call_id: "ic1".to_string(),
1074                content,
1075            });
1076            assert!(events.is_empty());
1077        }
1078    }
1079
1080    #[test]
1081    fn tool_call_maps_to_call_event() {
1082        let events = map_item(tool_call_item());
1083        assert_eq!(
1084            events,
1085            vec![LlmStreamEvent::ToolCall {
1086                id: "call_1".to_string(),
1087                name: "run".to_string(),
1088                arguments: json!({"x": 1}),
1089            }]
1090        );
1091    }
1092
1093    #[test]
1094    fn final_maps_finish_reason_and_usage() {
1095        let cases = [
1096            (FinishReason::Stop, StopReason::Stop),
1097            (FinishReason::Length, StopReason::Length),
1098            (FinishReason::ToolCalls, StopReason::ToolUse),
1099            (FinishReason::ContentFilter, StopReason::Stop),
1100            (FinishReason::Other("weird".to_string()), StopReason::Stop),
1101        ];
1102        for (reason, expected) in cases {
1103            let events = map_item(final_item(Some(reason)));
1104            assert_eq!(
1105                events,
1106                vec![LlmStreamEvent::Done {
1107                    stop_reason: expected,
1108                    usage: Some(crate::message::Usage {
1109                        input_tokens: 10,
1110                        output_tokens: 5,
1111                        cache_read_tokens: Some(3),
1112                        cache_write_tokens: Some(2),
1113                    }),
1114                }],
1115                "wrong mapping for {expected:?}"
1116            );
1117        }
1118    }
1119
1120    #[test]
1121    fn final_without_finish_reason_defaults_to_stop() {
1122        let events = map_item(final_item(None));
1123        match &events[..] {
1124            [LlmStreamEvent::Done { stop_reason, .. }] => {
1125                assert_eq!(*stop_reason, StopReason::Stop)
1126            }
1127            other => panic!("unexpected events: {other:?}"),
1128        }
1129    }
1130
1131    #[test]
1132    fn unknown_item_ignored() {
1133        let events = map_item(StreamedAssistantContent::Unknown(
1134            rig::streaming::UnknownPayload::new(json!({"mystery": 1})),
1135        ));
1136        assert!(events.is_empty());
1137    }
1138
1139    #[test]
1140    fn build_request_round_trips_context() {
1141        let opts = LlmOpts {
1142            model: "m1".to_string(),
1143            base_url: None,
1144            api_key: None,
1145            temperature: Some(0.5),
1146            ..LlmOpts::default()
1147        };
1148        let tools = vec![ToolDefinition {
1149            name: "get_weather".to_string(),
1150            description: "Weather lookup".to_string(),
1151            parameters: json!({"type": "object"}),
1152        }];
1153        let messages = vec![
1154            AgentMessage::user_text("What is the weather?"),
1155            AgentMessage::Assistant {
1156                content: vec![
1157                    ContentBlock::Thinking { text: "hmm".into() },
1158                    ContentBlock::ToolCall {
1159                        id: "t1".into(),
1160                        name: "get_weather".into(),
1161                        arguments: json!({"city": "Tokyo"}),
1162                    },
1163                    ContentBlock::Text {
1164                        text: "Let me check.".into(),
1165                    },
1166                ],
1167                stop_reason: StopReason::ToolUse,
1168                usage: None,
1169                model: None,
1170            },
1171            AgentMessage::ToolResult {
1172                tool_call_id: "t1".into(),
1173                name: "get_weather".into(),
1174                output: "sunny".into(),
1175                is_error: false,
1176            },
1177        ];
1178
1179        let request = build_request(&messages, "You are terse.", &tools, &opts);
1180
1181        assert!(request.preamble.is_none());
1182        assert_eq!(request.temperature, Some(0.5));
1183        assert_eq!(request.tools.len(), 1);
1184        assert_eq!(request.tools[0].name, "get_weather");
1185
1186        let [
1187            Message::System { content: system },
1188            Message::User { content: user },
1189            Message::Assistant {
1190                content: assistant, ..
1191            },
1192            Message::User {
1193                content: tool_result,
1194            },
1195        ] = &request.chat_history[..]
1196        else {
1197            panic!("unexpected chat_history shape: {:?}", request.chat_history);
1198        };
1199        assert_eq!(system, "You are terse.");
1200        assert!(
1201            matches!(user.as_slice(), [UserContent::Text(t)] if t.text == "What is the weather?")
1202        );
1203        assert_eq!(assistant.len(), 3);
1204        assert!(
1205            matches!(&assistant[0], AssistantContent::Reasoning(r) if reasoning_text(r) == "hmm")
1206        );
1207        assert!(matches!(
1208            &assistant[1],
1209            AssistantContent::ToolCall(call)
1210                if call.id.as_str() == "t1"
1211                    && call.function.name == "get_weather"
1212                    && call.function.arguments == json!({"city": "Tokyo"})
1213        ));
1214        assert!(matches!(&assistant[2], AssistantContent::Text(t) if t.text == "Let me check."));
1215        assert!(matches!(
1216            tool_result.as_slice(),
1217            [UserContent::ToolResult(r)] if r.call.as_str() == "t1" && r.name == "get_weather"
1218        ));
1219    }
1220
1221    #[test]
1222    fn tool_result_error_is_annotated_on_the_wire_only() {
1223        let err = AgentMessage::ToolResult {
1224            tool_call_id: "t1".into(),
1225            name: "echo".into(),
1226            output: "boom".into(),
1227            is_error: true,
1228        };
1229        let ok = AgentMessage::ToolResult {
1230            tool_call_id: "t1".into(),
1231            name: "echo".into(),
1232            output: "boom".into(),
1233            is_error: false,
1234        };
1235
1236        fn wire_text(m: &AgentMessage) -> String {
1237            match to_rig_message(m) {
1238                Some(Message::User { content }) => match &content[..] {
1239                    [UserContent::ToolResult(r)] => match &r.content[..] {
1240                        [ToolResultContent::Text(t)] => t.text.clone(),
1241                        other => panic!("unexpected tool result content: {other:?}"),
1242                    },
1243                    other => panic!("unexpected user content: {other:?}"),
1244                },
1245                other => panic!("unexpected wire message: {other:?}"),
1246            }
1247        }
1248
1249        // Success passes through byte-for-byte; a failed tool is marked.
1250        assert_eq!(wire_text(&ok), "boom");
1251        assert_eq!(wire_text(&err), "ERROR: boom");
1252        // The stored message itself is untouched — the flag is only consumed
1253        // at the wire seam.
1254        match err {
1255            AgentMessage::ToolResult {
1256                output, is_error, ..
1257            } => {
1258                assert_eq!(output, "boom");
1259                assert!(is_error);
1260            }
1261            _ => unreachable!(),
1262        }
1263    }
1264
1265    #[test]
1266    fn endpoint_defaults_to_chat() {
1267        let opts = LlmOpts {
1268            model: "m1".to_string(),
1269            base_url: None,
1270            api_key: None,
1271            temperature: None,
1272            endpoint: LlmEndpoint::Chat,
1273            effort: None,
1274            session_id: None,
1275            retry: RetryPolicy::default(),
1276        };
1277        assert_eq!(opts.endpoint, LlmEndpoint::Chat);
1278        assert_eq!(LlmOpts::default().endpoint, LlmEndpoint::Chat);
1279    }
1280
1281    #[test]
1282    fn no_effort_sends_no_additional_params() {
1283        let opts = LlmOpts::default();
1284        let request = build_request(&[], "sys", &[], &opts);
1285        assert_eq!(request.additional_params, None);
1286    }
1287
1288    #[test]
1289    fn chat_effort_maps_to_reasoning_effort_param() {
1290        let opts = LlmOpts {
1291            effort: Some("high".into()),
1292            endpoint: LlmEndpoint::Chat,
1293            ..LlmOpts::default()
1294        };
1295        let request = build_request(&[], "sys", &[], &opts);
1296        assert_eq!(
1297            request.additional_params,
1298            Some(json!({ "reasoning_effort": "high" }))
1299        );
1300    }
1301
1302    #[test]
1303    fn responses_effort_maps_to_reasoning_object() {
1304        let opts = LlmOpts {
1305            effort: Some("xhigh".into()),
1306            endpoint: LlmEndpoint::Responses,
1307            ..LlmOpts::default()
1308        };
1309        let request = build_request(&[], "sys", &[], &opts);
1310        assert_eq!(
1311            request.additional_params,
1312            Some(json!({ "reasoning": { "effort": "xhigh" } }))
1313        );
1314    }
1315
1316    #[test]
1317    fn chat_effort_is_verbatim_passthrough() {
1318        // Free-style on the Chat wire: any string (including provider
1319        // dialects) is forwarded verbatim under `reasoning_effort`.
1320        for level in ["low", "medium", "my-custom-level", ""] {
1321            let opts = LlmOpts {
1322                effort: Some(level.into()),
1323                endpoint: LlmEndpoint::Chat,
1324                ..LlmOpts::default()
1325            };
1326            let request = build_request(&[], "sys", &[], &opts);
1327            assert_eq!(
1328                request.additional_params,
1329                Some(json!({ "reasoning_effort": level })),
1330                "level {level:?} must pass through verbatim on Chat"
1331            );
1332        }
1333    }
1334
1335    #[test]
1336    fn responses_effort_validation() {
1337        // Known snake_case levels pass; anything else is rejected with a clear
1338        // message (rig would otherwise fail the request with an opaque error).
1339        for level in ["none", "minimal", "low", "medium", "high", "xhigh", "max"] {
1340            let opts = LlmOpts {
1341                effort: Some(level.into()),
1342                endpoint: LlmEndpoint::Responses,
1343                ..LlmOpts::default()
1344            };
1345            assert_eq!(invalid_effort(&opts), None, "level {level} must be valid");
1346        }
1347        let opts = LlmOpts {
1348            effort: Some("deep".into()),
1349            endpoint: LlmEndpoint::Responses,
1350            ..LlmOpts::default()
1351        };
1352        let msg = invalid_effort(&opts).expect("unknown level must be rejected");
1353        assert!(msg.contains("not valid"), "got: {msg}");
1354        assert!(
1355            msg.contains("xhigh"),
1356            "message must list valid levels: {msg}"
1357        );
1358        // Chat stays verbatim even for a value Responses would reject.
1359        let opts = LlmOpts {
1360            effort: Some("deep".into()),
1361            endpoint: LlmEndpoint::Chat,
1362            ..LlmOpts::default()
1363        };
1364        assert_eq!(invalid_effort(&opts), None);
1365    }
1366
1367    #[test]
1368    fn client_sends_session_header_and_user_agent() {
1369        let opts = LlmOpts {
1370            api_key: Some("test-key".to_string()),
1371            session_id: Some("sess-123".to_string()),
1372            ..LlmOpts::default()
1373        };
1374        let headers = openai_client(&opts)
1375            .expect("client builds")
1376            .headers()
1377            .clone();
1378        assert_eq!(
1379            headers.get("x-opencode-session").expect("session header"),
1380            "sess-123"
1381        );
1382        assert!(headers.contains_key("user-agent"), "got: {headers:?}");
1383    }
1384
1385    #[test]
1386    fn client_omits_session_header_when_unset() {
1387        let opts = LlmOpts {
1388            api_key: Some("test-key".to_string()),
1389            ..LlmOpts::default()
1390        };
1391        let headers = openai_client(&opts)
1392            .expect("client builds")
1393            .headers()
1394            .clone();
1395        assert!(!headers.contains_key("x-opencode-session"));
1396        assert!(headers.contains_key("user-agent"), "got: {headers:?}");
1397    }
1398
1399    #[test]
1400    fn localhost_base_url_builds_without_key() {
1401        // Keyless local endpoints (Ollama, llama.cpp, ...) should work with no
1402        // configured key via a placeholder bearer token.
1403        for url in [
1404            "http://localhost:11434/v1",
1405            "http://127.0.0.1:8080/v1",
1406            "http://[::1]:8080/v1",
1407        ] {
1408            let opts = LlmOpts {
1409                base_url: Some(url.to_string()),
1410                api_key: None,
1411                ..LlmOpts::default()
1412            };
1413            assert!(
1414                openai_client(&opts).is_ok(),
1415                "keyless {url} must build a client"
1416            );
1417        }
1418    }
1419
1420    #[test]
1421    fn remote_base_url_without_key_is_error() {
1422        let opts = LlmOpts {
1423            base_url: Some("https://api.example.com/v1".to_string()),
1424            api_key: None,
1425            ..LlmOpts::default()
1426        };
1427        let err = openai_client(&opts).unwrap_err();
1428        assert!(err.contains("no API key"), "got: {err}");
1429    }
1430
1431    /// Unreachable endpoint: list_models surfaces a string error, no panic.
1432    #[tokio::test]
1433    async fn list_models_unreachable_is_error() {
1434        let opts = LlmOpts {
1435            base_url: Some("http://127.0.0.1:9/v1".to_string()),
1436            api_key: Some("test-key".to_string()),
1437            ..LlmOpts::default()
1438        };
1439        let err = tokio::time::timeout(std::time::Duration::from_secs(10), list_models(&opts))
1440            .await
1441            .expect("list terminates")
1442            .expect_err("unreachable host must error");
1443        assert!(!err.is_empty());
1444    }
1445
1446    #[tokio::test]
1447    async fn list_models_without_key_is_error() {
1448        let opts = LlmOpts::default();
1449        let err = list_models(&opts).await.expect_err("no key must error");
1450        assert!(err.contains("no API key"), "got: {err}");
1451    }
1452
1453    #[test]
1454    fn client_cache_reuses_until_key_changes() {
1455        let opts = LlmOpts {
1456            base_url: Some("http://127.0.0.1:9/v1".to_string()),
1457            api_key: Some("k".to_string()),
1458            session_id: Some("s1".to_string()),
1459            ..LlmOpts::default()
1460        };
1461        let cache = ClientCache::default();
1462        let builds = || cache.builds.load(std::sync::atomic::Ordering::SeqCst);
1463
1464        // Identical opts reuse the cached client (the whole point: one pool/TLS).
1465        cache.get(&opts).expect("builds");
1466        cache.get(&opts).expect("builds");
1467        assert_eq!(builds(), 1, "identical opts must reuse the client");
1468
1469        // model/temperature/effort ride on the request, not the client.
1470        let model_swap = LlmOpts {
1471            model: "other".to_string(),
1472            effort: Some("high".to_string()),
1473            ..opts.clone()
1474        };
1475        cache.get(&model_swap).expect("builds");
1476        assert_eq!(builds(), 1, "model/effort swap must reuse the client");
1477
1478        // Endpoint, key, and session header each force a rebuild.
1479        for changed in [
1480            LlmOpts {
1481                base_url: Some("http://127.0.0.1:8/v1".to_string()),
1482                ..opts.clone()
1483            },
1484            LlmOpts {
1485                api_key: Some("k2".to_string()),
1486                ..opts.clone()
1487            },
1488            LlmOpts {
1489                session_id: Some("s2".to_string()),
1490                ..opts.clone()
1491            },
1492        ] {
1493            let before = builds();
1494            cache.get(&changed).expect("builds");
1495            assert_eq!(builds(), before + 1, "a key change must rebuild the client");
1496        }
1497    }
1498
1499    #[test]
1500    fn responses_request_builds_against_default_client_type() {
1501        use rig::client::CompletionClient;
1502        let client = openai::Client::builder()
1503            .api_key("test-key")
1504            .base_url("http://127.0.0.1:9/v1")
1505            .build()
1506            .expect("client builds without network");
1507        let model = client.completion_model("some-model");
1508        let request = build_request(&[], "sys", &[], &LlmOpts::default());
1509        let fut = rig::completion::CompletionModel::stream(&model, request);
1510        drop(fut);
1511    }
1512
1513    /// Unreachable endpoint: the adapter surfaces an `Error` event and no
1514    /// `Done`, at the stream level (initial request failure).
1515    #[tokio::test]
1516    async fn adapter_surfaces_error_without_done() {
1517        let opts = LlmOpts {
1518            model: "m1".to_string(),
1519            base_url: Some("http://127.0.0.1:9/v1".to_string()),
1520            api_key: Some("test-key".to_string()),
1521            temperature: None,
1522            ..LlmOpts::default()
1523        };
1524        let stream_fn = rig_stream_fn();
1525        let stream = stream_fn(&[], "sys", &[], &opts);
1526        let events: Vec<LlmStreamEvent> =
1527            tokio::time::timeout(std::time::Duration::from_secs(10), stream.collect())
1528                .await
1529                .expect("stream terminates");
1530        assert!(
1531            events
1532                .iter()
1533                .any(|e| matches!(e, LlmStreamEvent::Error { .. })),
1534            "expected an Error event, got {events:?}"
1535        );
1536        assert!(
1537            !events
1538                .iter()
1539                .any(|e| matches!(e, LlmStreamEvent::Done { .. })),
1540            "no Done after error, got {events:?}"
1541        );
1542    }
1543
1544    /// Responses path hits the same shared forwarding loop: unreachable
1545    /// endpoint surfaces `Error` and no `Done`.
1546    #[tokio::test]
1547    async fn responses_adapter_surfaces_error_without_done() {
1548        let opts = LlmOpts {
1549            model: "m1".to_string(),
1550            base_url: Some("http://127.0.0.1:9/v1".to_string()),
1551            api_key: Some("test-key".to_string()),
1552            temperature: None,
1553            endpoint: LlmEndpoint::Responses,
1554            effort: None,
1555            session_id: None,
1556            retry: RetryPolicy::default(),
1557        };
1558        let stream_fn = rig_stream_fn();
1559        let stream = stream_fn(&[], "sys", &[], &opts);
1560        let events: Vec<LlmStreamEvent> =
1561            tokio::time::timeout(std::time::Duration::from_secs(10), stream.collect())
1562                .await
1563                .expect("stream terminates");
1564        assert!(
1565            events
1566                .iter()
1567                .any(|e| matches!(e, LlmStreamEvent::Error { .. })),
1568            "expected an Error event, got {events:?}"
1569        );
1570        assert!(
1571            !events
1572                .iter()
1573                .any(|e| matches!(e, LlmStreamEvent::Done { .. })),
1574            "no Done after error, got {events:?}"
1575        );
1576    }
1577    /// Live smoke: real OpenAI-compatible endpoint, gated on env.
1578    #[tokio::test]
1579    #[ignore = "live test; run with WCODE_LIVE=1 WCODE_BASE_URL=... WCODE_API_KEY=... WCODE_MODEL=... cargo test -p wcode-harness --lib -- --ignored"]
1580    async fn live_rig_stream_fn() {
1581        let (Ok(base_url), Ok(api_key), Ok(model)) = (
1582            std::env::var("WCODE_BASE_URL"),
1583            std::env::var("WCODE_API_KEY"),
1584            std::env::var("WCODE_MODEL"),
1585        ) else {
1586            eprintln!("skipping: WCODE_BASE_URL / WCODE_API_KEY / WCODE_MODEL not set");
1587            return;
1588        };
1589        if std::env::var("WCODE_LIVE").ok().as_deref() != Some("1") {
1590            eprintln!("skipping: WCODE_LIVE != 1");
1591            return;
1592        }
1593        let opts = LlmOpts {
1594            model,
1595            base_url: Some(base_url),
1596            api_key: Some(api_key),
1597            temperature: None,
1598            ..LlmOpts::default()
1599        };
1600        let stream_fn = rig_stream_fn();
1601        let stream = stream_fn(
1602            &[AgentMessage::user_text("Reply with exactly: pong")],
1603            "You are a terse echo assistant.",
1604            &[],
1605            &opts,
1606        );
1607        let events: Vec<LlmStreamEvent> = stream.collect().await;
1608        let text = events
1609            .iter()
1610            .filter_map(|e| match e {
1611                LlmStreamEvent::TextDelta(t) => Some(t.as_str()),
1612                _ => None,
1613            })
1614            .collect::<String>();
1615        println!("text: {text:?}");
1616        println!("events: {events:?}");
1617        assert!(
1618            events
1619                .iter()
1620                .any(|e| matches!(e, LlmStreamEvent::Done { .. }))
1621        );
1622        assert!(
1623            !events
1624                .iter()
1625                .any(|e| matches!(e, LlmStreamEvent::Error { .. }))
1626        );
1627    }
1628}
1629
1630#[cfg(test)]
1631mod retry_tests {
1632    use super::*;
1633    use std::sync::Arc;
1634    use std::sync::atomic::{AtomicUsize, Ordering};
1635
1636    type Chunks =
1637        futures::stream::Iter<std::vec::IntoIter<Result<StreamedAssistantContent, CompletionError>>>;
1638
1639    /// A transient transport failure as rig surfaces it on the OpenAI path.
1640    fn transient() -> CompletionError {
1641        CompletionError::ProviderError(
1642            "Http client error: error sending request for url (http://x)".into(),
1643        )
1644    }
1645
1646    fn fatal() -> CompletionError {
1647        CompletionError::ProviderError("invalid_request_error: bad tool schema".into())
1648    }
1649
1650    fn empty() -> Chunks {
1651        futures::stream::iter(Vec::new())
1652    }
1653
1654    /// A terminal `Final` item (the stream's `Done`).
1655    fn done() -> StreamedAssistantContent {
1656        StreamedAssistantContent::Final(rig::streaming::StreamFinal::new(
1657            "test",
1658            rig::completion::Usage::default(),
1659        ))
1660    }
1661
1662    fn first_item_err(e: CompletionError) -> Chunks {
1663        futures::stream::iter(vec![Err(e)])
1664    }
1665
1666    #[test]
1667    fn transient_statuses_retry_client_errors_do_not() {
1668        for code in [408, 409, 429, 500, 502, 503, 504] {
1669            assert!(is_transient_status(code), "{code} should retry");
1670        }
1671        for code in [200, 400, 401, 403, 404, 422] {
1672            assert!(!is_transient_status(code), "{code} must not retry");
1673        }
1674    }
1675
1676    #[test]
1677    fn retryable_classifies_transport_vs_rejections() {
1678        assert!(retryable(&CompletionError::HttpError(
1679            rig::http_client::Error::StreamEnded
1680        )));
1681        assert!(retryable(&transient()));
1682        assert!(!retryable(&fatal()));
1683        assert!(!retryable(&CompletionError::ResponseError("bad".into())));
1684    }
1685
1686    #[test]
1687    fn backoff_grows_and_never_exceeds_the_cap() {
1688        let base = Duration::from_millis(100);
1689        let cap = Duration::from_millis(400);
1690        let d1 = backoff(1, base, cap);
1691        assert!(d1 <= base && d1 >= base * 3 / 4, "attempt 1 = {d1:?}");
1692        for n in 1..12 {
1693            assert!(backoff(n, base, cap) <= cap, "attempt {n} exceeds cap");
1694        }
1695    }
1696
1697    #[tokio::test(start_paused = true)]
1698    async fn connect_retries_transient_failures_then_succeeds() {
1699        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1700        let calls = Arc::new(AtomicUsize::new(0));
1701        let policy = RetryPolicy {
1702            max: 3,
1703            base: Duration::from_millis(10),
1704            cap: Duration::from_millis(50),
1705            ..RetryPolicy::default()
1706        };
1707        let c = calls.clone();
1708        let mut state = RetryState::new(policy.max);
1709        let out = connect_with_retry(
1710            &policy,
1711            &mut state,
1712            move || {
1713                let n = c.fetch_add(1, Ordering::SeqCst) + 1;
1714                Box::pin(async move {
1715                    if n <= 2 {
1716                        Err(transient())
1717                    } else {
1718                        Ok(empty())
1719                    }
1720                })
1721            },
1722            &tx,
1723        )
1724        .await;
1725        assert!(matches!(out, PreContent::Ready { .. }));
1726        assert_eq!(calls.load(Ordering::SeqCst), 3);
1727        assert!(matches!(
1728            rx.try_recv(),
1729            Ok(LlmStreamEvent::Retrying { attempt: 1, .. })
1730        ));
1731        assert!(matches!(
1732            rx.try_recv(),
1733            Ok(LlmStreamEvent::Retrying { attempt: 2, .. })
1734        ));
1735    }
1736
1737    #[tokio::test(start_paused = true)]
1738    async fn connect_retries_a_first_item_transport_error() {
1739        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1740        let calls = Arc::new(AtomicUsize::new(0));
1741        let policy = RetryPolicy {
1742            max: 2,
1743            base: Duration::from_millis(5),
1744            cap: Duration::from_millis(5),
1745            ..RetryPolicy::default()
1746        };
1747        let c = calls.clone();
1748        let mut state = RetryState::new(policy.max);
1749        let out = connect_with_retry(
1750            &policy,
1751            &mut state,
1752            move || {
1753                let n = c.fetch_add(1, Ordering::SeqCst) + 1;
1754                Box::pin(async move {
1755                    if n == 1 {
1756                        Ok(first_item_err(transient()))
1757                    } else {
1758                        Ok(empty())
1759                    }
1760                })
1761            },
1762            &tx,
1763        )
1764        .await;
1765        assert!(matches!(out, PreContent::Ready { .. }));
1766        assert_eq!(calls.load(Ordering::SeqCst), 2, "first-item error retried");
1767    }
1768
1769    #[tokio::test(start_paused = true)]
1770    async fn connect_gives_up_after_max_and_skips_non_retryable() {
1771        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1772        let policy = RetryPolicy {
1773            max: 1,
1774            base: Duration::from_millis(1),
1775            cap: Duration::from_millis(1),
1776            ..RetryPolicy::default()
1777        };
1778
1779        let calls = Arc::new(AtomicUsize::new(0));
1780        let c = calls.clone();
1781        let mut state = RetryState::new(policy.max);
1782        let out = connect_with_retry(
1783            &policy,
1784            &mut state,
1785            move || {
1786                c.fetch_add(1, Ordering::SeqCst);
1787                Box::pin(async { Err::<Chunks, CompletionError>(transient()) })
1788            },
1789            &tx,
1790        )
1791        .await;
1792        assert!(matches!(out, PreContent::Failed(_)));
1793        assert_eq!(calls.load(Ordering::SeqCst), 2, "one attempt + one retry");
1794
1795        let calls = Arc::new(AtomicUsize::new(0));
1796        let c = calls.clone();
1797        let mut state = RetryState::new(policy.max);
1798        let out = connect_with_retry(
1799            &policy,
1800            &mut state,
1801            move || {
1802                c.fetch_add(1, Ordering::SeqCst);
1803                Box::pin(async { Err::<Chunks, CompletionError>(fatal()) })
1804            },
1805            &tx,
1806        )
1807        .await;
1808        assert!(matches!(out, PreContent::Failed(_)));
1809        assert_eq!(calls.load(Ordering::SeqCst), 1, "non-retryable returns at once");
1810    }
1811
1812    #[test]
1813    fn fatal_class_flags_hard_errors_but_not_transient_transport() {
1814        // Transient-class failures stay feedable after their retries are
1815        // exhausted (the loop feeds the message back to the model); hard
1816        // client/build failures are fatal (the run stops).
1817        assert!(!fatal_class(&transient()));
1818        assert!(!fatal_class(&CompletionError::HttpError(
1819            rig::http_client::Error::StreamEnded
1820        )));
1821        assert!(fatal_class(&fatal()));
1822        assert!(fatal_class(&CompletionError::ResponseError("bad".into())));
1823        assert!(fatal_class(&CompletionError::RequestError("bad".into())));
1824        let json_err = serde_json::from_str::<u8>("not a number").unwrap_err();
1825        assert!(fatal_class(&CompletionError::JsonError(json_err)));
1826    }
1827
1828    /// Live-ish check against a refused port: a transport failure exhausts the
1829    /// retry budget (Retrying notices) and surfaces as a *non-fatal* stream
1830    /// error — which is exactly what lets the loop feed it back next turn
1831    /// instead of dying.
1832    #[tokio::test]
1833    async fn refused_port_exhausts_retries_to_non_fatal_error() {
1834        let opts = LlmOpts {
1835            model: "m1".to_string(),
1836            base_url: Some("http://127.0.0.1:9/v1".to_string()),
1837            api_key: Some("test-key".to_string()),
1838            retry: RetryPolicy {
1839                max: 2,
1840                base: Duration::from_millis(10),
1841                cap: Duration::from_millis(50),
1842                ..RetryPolicy::default()
1843            },
1844            ..LlmOpts::default()
1845        };
1846        let stream_fn = rig_stream_fn();
1847        let stream = stream_fn(&[], "sys", &[], &opts);
1848        let events: Vec<LlmStreamEvent> =
1849            tokio::time::timeout(std::time::Duration::from_secs(10), stream.collect())
1850                .await
1851                .expect("stream terminates");
1852        let retries = events
1853            .iter()
1854            .filter(|e| matches!(e, LlmStreamEvent::Retrying { .. }))
1855            .count();
1856        assert_eq!(retries, 2, "two backoff notices before giving up: {events:?}");
1857        assert!(
1858            matches!(events.last(), Some(LlmStreamEvent::Error { fatal: false, .. })),
1859            "refused port is transient-class, not fatal: {events:?}"
1860        );
1861    }
1862
1863    /// A `CompletionError` carrying a preserved response with a `Retry-After`
1864    /// header (no HTTP status needed: the header path reads
1865    /// `provider_response_headers()` regardless of status).
1866    fn err_with_retry_after(value: &str) -> CompletionError {
1867        let mut headers = HeaderMap::new();
1868        headers.insert("retry-after", HeaderValue::from_str(value).unwrap());
1869        CompletionError::ProviderResponse(
1870            rig::ProviderResponseError::without_status("429")
1871                .with_headers(Some(Box::new(headers))),
1872        )
1873    }
1874
1875    #[test]
1876    fn retry_after_parses_integer_seconds() {
1877        assert_eq!(
1878            retry_after(&err_with_retry_after("7")),
1879            Some(Duration::from_secs(7))
1880        );
1881    }
1882
1883    #[test]
1884    fn retry_after_is_none_when_absent() {
1885        // A transport failure with no preserved response/headers.
1886        assert_eq!(retry_after(&transient()), None);
1887    }
1888
1889    #[test]
1890    fn retry_after_is_none_on_non_numeric() {
1891        // The HTTP-date form and garbage both parse to `None` (dates are out of
1892        // scope — see `retry_after`).
1893        assert_eq!(
1894            retry_after(&err_with_retry_after("Wed, 21 Oct 2015 07:28:00 GMT")),
1895            None
1896        );
1897        assert_eq!(retry_after(&err_with_retry_after("soon")), None);
1898    }
1899
1900    #[tokio::test(start_paused = true)]
1901    async fn retry_wait_honors_retry_after_over_backoff() {
1902        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1903        // base 10ms gives a ~10ms backoff for attempt 1; `Retry-After: 3` must
1904        // win, so the paused clock advances ~3s instead.
1905        let policy = RetryPolicy {
1906            base: Duration::from_millis(10),
1907            ..RetryPolicy::default()
1908        };
1909        let start = tokio::time::Instant::now();
1910        assert!(retry_wait(&tx, &policy, 1, &err_with_retry_after("3")).await);
1911        assert_eq!(start.elapsed(), Duration::from_secs(3));
1912    }
1913
1914    #[tokio::test(start_paused = true)]
1915    async fn retry_wait_clamps_retry_after_to_the_cap() {
1916        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1917        // A hostile/buggy `Retry-After: 100` must not park the turn for 100s:
1918        // it is clamped to `policy.cap` (8s), not honored verbatim.
1919        let policy = RetryPolicy {
1920            cap: Duration::from_secs(8),
1921            ..RetryPolicy::default()
1922        };
1923        let start = tokio::time::Instant::now();
1924        assert!(retry_wait(&tx, &policy, 1, &err_with_retry_after("100")).await);
1925        assert_eq!(start.elapsed(), Duration::from_secs(8));
1926    }
1927    // ---- #23: stream-stall timeouts ----
1928
1929    type BoxStream =
1930        Pin<Box<dyn futures::Stream<Item = Result<StreamedAssistantContent, CompletionError>> + Send>>;
1931
1932    fn boxed<S>(s: S) -> BoxStream
1933    where
1934        S: futures::Stream<Item = Result<StreamedAssistantContent, CompletionError>>
1935            + Send
1936            + 'static,
1937    {
1938        Box::pin(s)
1939    }
1940
1941    fn silent_boxed() -> BoxStream {
1942        boxed(futures::stream::pending::<Result<StreamedAssistantContent, CompletionError>>())
1943    }
1944
1945    /// A fresh silent stream per call — `forward_stream`/`connect_with_retry`'s
1946    /// re-open seam, never exercised where content already flowed.
1947    fn open_silent()
1948    -> Pin<Box<dyn std::future::Future<Output = Result<BoxStream, CompletionError>> + Send>> {
1949        Box::pin(async { Ok::<_, CompletionError>(silent_boxed()) })
1950    }
1951
1952    /// A slow (but successful) connect — used to prove `ttft = ZERO` awaits it
1953    /// directly instead of racing an immediate `sleep(ZERO)`.
1954    fn open_slow_ok()
1955    -> Pin<Box<dyn std::future::Future<Output = Result<BoxStream, CompletionError>> + Send>> {
1956        Box::pin(async {
1957            tokio::time::sleep(Duration::from_millis(50)).await;
1958            Ok::<_, CompletionError>(boxed(futures::stream::iter(vec![Ok::<_, CompletionError>(
1959                StreamedAssistantContent::Text(rig::message::Text::new("ok")),
1960            )])))
1961        })
1962    }
1963
1964    /// A stall is transient-class, so it rides the retry / feed-back paths (B1).
1965    #[test]
1966    fn a_stall_error_is_transient_and_not_hard_fatal() {
1967        let d = Duration::from_secs(1);
1968        assert!(retryable(&stall_error(d)), "a stall must be transient (B1)");
1969        assert!(!fatal_class(&stall_error(d)), "a stall must not be hard-fatal");
1970        assert!(
1971            stall_error(d).to_string().contains("timed out"),
1972            "the (timed out) needle drives retryable()"
1973        );
1974    }
1975
1976    /// A silent stream stalls on the `ttft` peek every attempt: each retry emits
1977    /// a `Retrying` notice, and once the budget is spent the failure is a
1978    /// transient (non-fatal) `stall_error`.
1979    #[tokio::test(start_paused = true)]
1980    async fn a_silent_stream_trips_the_ttft_retries_then_fails() {
1981        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1982        let calls = Arc::new(AtomicUsize::new(0));
1983        let policy = RetryPolicy {
1984            max: 2,
1985            base: Duration::from_millis(10),
1986            cap: Duration::from_millis(10),
1987            ttft: Duration::from_secs(1),
1988            ..RetryPolicy::default()
1989        };
1990        let c = calls.clone();
1991        let open = move || {
1992            c.fetch_add(1, Ordering::SeqCst);
1993            open_silent()
1994        };
1995        let mut state = RetryState::new(policy.max);
1996        let out = connect_with_retry(&policy, &mut state, open, &tx).await;
1997
1998        let PreContent::Failed(error) = out else {
1999            panic!("expected Failed from an exhausted stall")
2000        };
2001        assert!(retryable(&error), "a stall is transient");
2002        assert!(!fatal_class(&error), "a stall is not hard-fatal");
2003        assert_eq!(calls.load(Ordering::SeqCst), 3, "one attempt + two retries");
2004        let retries = std::iter::from_fn(|| rx.try_recv().ok())
2005            .filter(|e| matches!(e, LlmStreamEvent::Retrying { .. }))
2006            .count();
2007        assert_eq!(retries, 2, "a Retrying notice per retry");
2008    }
2009
2010    /// A stream that emits a delta and then goes silent trips the idle deadline:
2011    /// because content already flowed, the failure is a NON-fatal error (the
2012    /// loop feeds it back) and is not retried (that would duplicate output).
2013    #[tokio::test(start_paused = true)]
2014    async fn deltas_then_a_stall_is_a_nonfatal_error() {
2015        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2016        let policy = RetryPolicy {
2017            max: 3,
2018            base: Duration::from_millis(10),
2019            cap: Duration::from_millis(10),
2020            idle: Duration::from_secs(1),
2021            ..RetryPolicy::default()
2022        };
2023        let stream = boxed(
2024            futures::stream::iter(vec![Ok::<_, CompletionError>(
2025                StreamedAssistantContent::Text(rig::message::Text::new("hi")),
2026            )])
2027            .chain(futures::stream::pending::<Result<StreamedAssistantContent, CompletionError>>()),
2028        );
2029        let state = RetryState::new(policy.max);
2030        forward_stream(stream, None, policy, open_silent, tx, state).await;
2031
2032        let events: Vec<LlmStreamEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
2033        assert!(
2034            events
2035                .iter()
2036                .any(|e| matches!(e, LlmStreamEvent::TextDelta(t) if t == "hi")),
2037            "{events:?}"
2038        );
2039        let error = events
2040            .iter()
2041            .find_map(|e| match e {
2042                LlmStreamEvent::Error { message, fatal } => Some((message.clone(), *fatal)),
2043                _ => None,
2044            })
2045            .expect("the idle deadline surfaced an error");
2046        assert!(!error.1, "a post-content stall is non-fatal: {error:?}");
2047        assert!(
2048            !events.iter().any(|e| matches!(e, LlmStreamEvent::Retrying { .. })),
2049            "no retry once content flowed: {events:?}"
2050        );
2051    }
2052
2053    /// A clean stream (deltas then `Done`) is forwarded untouched: no error, no
2054    /// retry, the idle deadline never fires.
2055    #[tokio::test(start_paused = true)]
2056    async fn a_clean_stream_is_unaffected() {
2057        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2058        let policy = RetryPolicy {
2059            idle: Duration::from_secs(1),
2060            ..RetryPolicy::default()
2061        };
2062        let stream = boxed(futures::stream::iter(vec![
2063            Ok::<_, CompletionError>(StreamedAssistantContent::Text(rig::message::Text::new("hi"))),
2064            Ok::<_, CompletionError>(done()),
2065        ]));
2066        let state = RetryState::new(policy.max);
2067        forward_stream(stream, None, policy, open_silent, tx, state).await;
2068
2069        let events: Vec<LlmStreamEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
2070        assert!(
2071            events
2072                .iter()
2073                .any(|e| matches!(e, LlmStreamEvent::TextDelta(t) if t == "hi")),
2074            "{events:?}"
2075        );
2076        assert!(
2077            events.iter().any(|e| matches!(e, LlmStreamEvent::Done { .. })),
2078            "{events:?}"
2079        );
2080        assert!(
2081            !events.iter().any(|e| matches!(e, LlmStreamEvent::Error { .. })),
2082            "{events:?}"
2083        );
2084        assert!(
2085            !events.iter().any(|e| matches!(e, LlmStreamEvent::Retrying { .. })),
2086            "{events:?}"
2087        );
2088    }
2089
2090    /// `ZERO` disables the deadline at BOTH seams: the peek collapses to a plain
2091    /// `stream.next()` (a silent stream stays pending), and the connect race is
2092    /// skipped so a slow connect is not spuriously timed out.
2093    #[tokio::test(start_paused = true)]
2094    async fn zero_deadlines_disable_the_timeout() {
2095        // Peek seam: no `sleep(ZERO)`, so a silent stream never yields `Idle`.
2096        let mut silent =
2097            futures::stream::pending::<Result<StreamedAssistantContent, CompletionError>>();
2098        let stalled = tokio::time::timeout(
2099            Duration::from_secs(1),
2100            next_or_stall(&mut silent, Duration::ZERO),
2101        )
2102        .await;
2103        assert!(stalled.is_err(), "ZERO must not trip the deadline");
2104
2105        // Connect seam: `ZERO` awaits the connect directly.
2106        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2107        let policy = RetryPolicy {
2108            ttft: Duration::ZERO,
2109            ..RetryPolicy::default()
2110        };
2111        let mut state = RetryState::new(policy.max);
2112        let out = connect_with_retry(&policy, &mut state, open_slow_ok, &tx).await;
2113        assert!(
2114            matches!(out, PreContent::Ready { .. }),
2115            "a slow connect must not be timed out under ZERO"
2116        );
2117    }
2118
2119    /// The genuinely-stalled *connect* branch: `open()` never resolves, so the
2120    /// nonzero `ttft` race times out on every attempt. Each retry emits a
2121    /// `Retrying` notice; once the budget is spent the failure is a transient
2122    /// (non-fatal) `stall_error` — the same shape as a peek stall, but through
2123    /// the connect race's `Err(())` arm.
2124    #[tokio::test(start_paused = true)]
2125    async fn a_stalled_connect_trips_the_ttft_retries_then_fails() {
2126        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2127        let calls = Arc::new(AtomicUsize::new(0));
2128        let policy = RetryPolicy {
2129            max: 2,
2130            base: Duration::from_millis(10),
2131            cap: Duration::from_millis(10),
2132            ttft: Duration::from_secs(1),
2133            ..RetryPolicy::default()
2134        };
2135        let c = calls.clone();
2136        let open = move || {
2137            c.fetch_add(1, Ordering::SeqCst);
2138            Box::pin(std::future::pending::<Result<BoxStream, CompletionError>>())
2139                as Pin<
2140                    Box<
2141                        dyn std::future::Future<Output = Result<BoxStream, CompletionError>> + Send,
2142                    >,
2143                >
2144        };
2145        let mut state = RetryState::new(policy.max);
2146        let out = connect_with_retry(&policy, &mut state, open, &tx).await;
2147
2148        let PreContent::Failed(error) = out else {
2149            panic!("expected Failed from an exhausted connect stall")
2150        };
2151        assert!(retryable(&error), "a connect stall is transient");
2152        assert!(!fatal_class(&error), "a connect stall is not hard-fatal");
2153        assert_eq!(calls.load(Ordering::SeqCst), 3, "one attempt + two retries");
2154        let retries = std::iter::from_fn(|| rx.try_recv().ok())
2155            .filter(|e| matches!(e, LlmStreamEvent::Retrying { .. }))
2156            .count();
2157        assert_eq!(retries, 2, "a Retrying notice per retry");
2158    }
2159
2160    /// The forward loop's *pre-content* idle branch: a stream that emits nothing
2161    /// and then stalls, with budget remaining, backs off (a `Retrying` notice)
2162    /// and re-opens — here the re-opened stream succeeds and the turn completes.
2163    #[tokio::test(start_paused = true)]
2164    async fn a_pre_content_idle_stall_retries_and_reopens() {
2165        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2166        let policy = RetryPolicy {
2167            max: 2,
2168            base: Duration::from_millis(10),
2169            cap: Duration::from_millis(10),
2170            idle: Duration::from_secs(1),
2171            ..RetryPolicy::default()
2172        };
2173        // The initial stream never yields → the idle deadline trips pre-content.
2174        let stream = silent_boxed();
2175        // The re-open yields a `Final`, so the retried turn completes.
2176        fn open_done()
2177        -> Pin<Box<dyn std::future::Future<Output = Result<BoxStream, CompletionError>> + Send>> {
2178            Box::pin(async {
2179                Ok::<_, CompletionError>(boxed(futures::stream::iter(vec![Ok::<_, CompletionError>(
2180                    done(),
2181                )])))
2182            })
2183        }
2184        let state = RetryState::new(policy.max);
2185        forward_stream(stream, None, policy, open_done, tx, state).await;
2186
2187        let events: Vec<LlmStreamEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
2188        assert!(
2189            events
2190                .iter()
2191                .any(|e| matches!(e, LlmStreamEvent::Retrying { .. })),
2192            "a pre-content stall backs off: {events:?}"
2193        );
2194        assert!(
2195            events.iter().any(|e| matches!(e, LlmStreamEvent::Done { .. })),
2196            "the re-opened stream completed: {events:?}"
2197        );
2198        assert!(
2199            !events.iter().any(|e| matches!(e, LlmStreamEvent::Error { .. })),
2200            "no error once the retry succeeded: {events:?}"
2201        );
2202    }
2203
2204    /// A re-open that ALSO ends immediately, pre-content: paired with an empty
2205    /// initial stream, it drives the truncation arm's retries to exhaustion (each
2206    /// re-open yields no item, so nothing is ever forwarded).
2207    fn open_empty()
2208    -> Pin<Box<dyn std::future::Future<Output = Result<BoxStream, CompletionError>> + Send>> {
2209        Box::pin(async { Ok::<_, CompletionError>(boxed(empty())) })
2210    }
2211
2212    /// A delta followed by an early EOF (no `Final`) is a truncation: content
2213    /// already flowed, so it is NOT retried — a single NON-fatal `Error` ends the
2214    /// turn (the loop feeds it back), and no `Done` is emitted.
2215    #[tokio::test(start_paused = true)]
2216    async fn a_delta_then_a_truncated_end_is_a_nonfatal_error() {
2217        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2218        let policy = RetryPolicy {
2219            max: 3,
2220            base: Duration::from_millis(10),
2221            cap: Duration::from_millis(10),
2222            idle: Duration::ZERO,
2223            ..RetryPolicy::default()
2224        };
2225        let stream = boxed(futures::stream::iter(vec![Ok::<_, CompletionError>(
2226            StreamedAssistantContent::Text(rig::message::Text::new("hi")),
2227        )]));
2228        let state = RetryState::new(policy.max);
2229        forward_stream(stream, None, policy, open_silent, tx, state).await;
2230
2231        let events: Vec<LlmStreamEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
2232        assert!(
2233            events
2234                .iter()
2235                .any(|e| matches!(e, LlmStreamEvent::TextDelta(t) if t == "hi")),
2236            "{events:?}"
2237        );
2238        let (message, fatal) = events
2239            .iter()
2240            .find_map(|e| match e {
2241                LlmStreamEvent::Error { message, fatal } => Some((message.clone(), *fatal)),
2242                _ => None,
2243            })
2244            .expect("a truncated end surfaced an error");
2245        assert!(!fatal, "a post-content truncation is non-fatal: {message}");
2246        assert!(
2247            !events
2248                .iter()
2249                .any(|e| matches!(e, LlmStreamEvent::Done { .. })),
2250            "no Done on a truncated stream: {events:?}"
2251        );
2252        assert!(
2253            !events
2254                .iter()
2255                .any(|e| matches!(e, LlmStreamEvent::Retrying { .. })),
2256            "no retry once content flowed: {events:?}"
2257        );
2258    }
2259
2260    /// A `Final`-terminated stream is unchanged: it forwards `Done` and never
2261    /// trips the truncation guard (which would surface a spurious error).
2262    #[tokio::test(start_paused = true)]
2263    async fn a_final_terminated_stream_is_unchanged() {
2264        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2265        let policy = RetryPolicy {
2266            idle: Duration::ZERO,
2267            ..RetryPolicy::default()
2268        };
2269        let stream = boxed(futures::stream::iter(vec![
2270            Ok::<_, CompletionError>(StreamedAssistantContent::Text(rig::message::Text::new(
2271                "hi",
2272            ))),
2273            Ok::<_, CompletionError>(done()),
2274        ]));
2275        let state = RetryState::new(policy.max);
2276        forward_stream(stream, None, policy, open_silent, tx, state).await;
2277
2278        let events: Vec<LlmStreamEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
2279        assert!(
2280            events
2281                .iter()
2282                .any(|e| matches!(e, LlmStreamEvent::Done { .. })),
2283            "{events:?}"
2284        );
2285        assert!(
2286            !events
2287                .iter()
2288                .any(|e| matches!(e, LlmStreamEvent::Error { .. })),
2289            "a clean turn surfaces no error: {events:?}"
2290        );
2291        assert!(
2292            !events
2293                .iter()
2294                .any(|e| matches!(e, LlmStreamEvent::Retrying { .. })),
2295            "{events:?}"
2296        );
2297    }
2298
2299    /// A pre-content no-item stream with budget remaining retries on the shared
2300    /// budget; each re-open also ends empty, so the whole budget is spent before
2301    /// a NON-fatal error ends the turn — one `Retrying` notice per retry, no
2302    /// `Done`.
2303    #[tokio::test(start_paused = true)]
2304    async fn a_pre_content_no_item_stream_retries_then_errors() {
2305        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2306        let policy = RetryPolicy {
2307            max: 2,
2308            base: Duration::from_millis(10),
2309            cap: Duration::from_millis(10),
2310            idle: Duration::ZERO,
2311            ..RetryPolicy::default()
2312        };
2313        let state = RetryState::new(policy.max);
2314        forward_stream(boxed(empty()), None, policy, open_empty, tx, state).await;
2315
2316        let events: Vec<LlmStreamEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
2317        let retries = events
2318            .iter()
2319            .filter(|e| matches!(e, LlmStreamEvent::Retrying { .. }))
2320            .count();
2321        assert_eq!(
2322            retries, policy.max as usize,
2323            "one Retrying notice per retry: {events:?}"
2324        );
2325        let (message, fatal) = events
2326            .iter()
2327            .find_map(|e| match e {
2328                LlmStreamEvent::Error { message, fatal } => Some((message.clone(), *fatal)),
2329                _ => None,
2330            })
2331            .expect("an exhausted truncation budget surfaced an error");
2332        assert!(!fatal, "an exhausted truncation is non-fatal: {message}");
2333        assert!(
2334            !events
2335                .iter()
2336                .any(|e| matches!(e, LlmStreamEvent::Done { .. })),
2337            "no Done on a truncated stream: {events:?}"
2338        );
2339    }
2340}