Skip to main content

harn_vm/llm/
api.rs

1//! LLM API entry points and re-exports. The transport layer, request/
2//! response parsing, auth, context-window discovery, and option/result
3//! types each live in their own submodule under [`self`]; this file only
4//! wires them together and hosts the `vm_call_llm_full*` chat entry
5//! points that provider-specific completion / agent paths dispatch into.
6
7mod auth;
8mod completion;
9mod context_window;
10mod errors;
11mod ollama;
12mod openai_normalize;
13pub(crate) mod options;
14mod partial_tool_args;
15mod response;
16mod result;
17mod schema_stream;
18mod telemetry;
19mod thinking;
20mod transport;
21
22use crate::value::{ErrorCategory, VmError, VmValue};
23
24use super::mock::{
25    fixture_hash, get_replay_mode, load_fixture, mock_llm_response, record_cli_llm_result,
26    save_fixture, LlmReplayMode,
27};
28
29// ─── Public surface (crate-wide) ────────────────────────────────────────
30
31pub(crate) use auth::apply_auth_headers;
32pub(crate) use completion::vm_call_completion_full;
33pub use context_window::fetch_provider_max_context;
34pub(crate) use errors::{
35    classify_llm_error, classify_provider_http_error, err_for_non_success, retry_after_header,
36    LlmErrorInfo, LlmErrorKind, LlmErrorReason,
37};
38pub(crate) use ollama::apply_ollama_runtime_settings;
39pub(crate) use ollama::ollama_unload_grace_duration_from_env;
40pub use ollama::{
41    normalize_ollama_keep_alive, ollama_readiness, ollama_runtime_settings_from_env,
42    warm_ollama_model, warm_ollama_model_with_settings, OllamaReadinessOptions,
43    OllamaReadinessResult, OllamaRuntimeSettings, OllamaWarmupResult, HARN_OLLAMA_KEEP_ALIVE_ENV,
44    HARN_OLLAMA_NUM_CTX_ENV, OLLAMA_DEFAULT_KEEP_ALIVE, OLLAMA_DEFAULT_NUM_CTX, OLLAMA_HOST_ENV,
45};
46pub(crate) use openai_normalize::normalize_openai_style_messages;
47pub(crate) use options::{
48    push_unique_anthropic_beta_feature, DeltaSender, LlmApiMode, LlmCallOptions, LlmRequestPayload,
49    LlmRouteAlternative, LlmRouteFallback, LlmRoutePolicy, LlmRoutingDecision, OutputFormat,
50    PromptCacheTtl, ReasoningEffort, ReminderLifecycleEmission, ThinkingConfig, ToolSearchConfig,
51    ToolSearchMode, ToolSearchVariant,
52};
53pub(crate) use response::{
54    extract_cache_read_tokens, extract_cache_write_tokens,
55    parse_llm_response as parse_llm_response_for_provider, parse_openai_responses_response,
56};
57pub(crate) use result::{vm_build_llm_result, LlmResult, RawProviderToolCall};
58pub(crate) use schema_stream::{
59    aborted_result_value as schema_stream_aborted_result_value, parse_schema_stream_abort,
60    SchemaStreamAbort, StreamSchemaWatch,
61};
62pub(crate) use telemetry::elapsed_ms;
63pub use telemetry::{source as telemetry_source, OllamaPsModel, ProviderTelemetry};
64pub(crate) use thinking::{split_openai_thinking_blocks, ThinkingStreamSplitter};
65pub(crate) use transport::vm_call_llm_api_with_body;
66
67use transport::vm_call_llm_api;
68
69#[derive(Debug, Clone)]
70struct OffthreadLlmError {
71    message: String,
72    category: Option<ErrorCategory>,
73}
74
75impl OffthreadLlmError {
76    fn from_vm_error(err: VmError) -> Self {
77        match err {
78            VmError::CategorizedError { message, category } => Self {
79                message,
80                category: Some(category),
81            },
82            VmError::Thrown(VmValue::String(message)) => {
83                Self::from_display_message(message.to_string())
84            }
85            other => Self::from_display_message(other.to_string()),
86        }
87    }
88
89    fn from_display_message(message: String) -> Self {
90        if let Some((category, stripped)) = parse_displayed_categorized_error(&message) {
91            return Self {
92                message: stripped.to_string(),
93                category: Some(category),
94            };
95        }
96        Self {
97            message,
98            category: None,
99        }
100    }
101
102    fn into_vm_error(self) -> VmError {
103        match self.category {
104            Some(category) => VmError::CategorizedError {
105                message: self.message,
106                category,
107            },
108            None => VmError::Thrown(VmValue::String(arcstr::ArcStr::from(self.message))),
109        }
110    }
111}
112
113fn parse_displayed_categorized_error(message: &str) -> Option<(ErrorCategory, &str)> {
114    let body = message.strip_prefix("Error [")?;
115    let (category, rest) = body.split_once("]: ")?;
116    Some((ErrorCategory::parse(category), rest))
117}
118
119/// Route a logical call when policy is present. The boxed boundary breaks the
120/// intentional async cycle: routing executes links through observability, which
121/// reaches the explicit single-route primitives after clearing the policy.
122fn routed_llm_call<'a>(
123    opts: &'a LlmCallOptions,
124    delta_tx: Option<DeltaSender>,
125) -> Option<impl std::future::Future<Output = Result<LlmResult, VmError>> + 'a> {
126    let policy = opts.routing_policy.as_ref()?;
127    Some(async move {
128        Box::pin(super::routing::execute_with_routing(
129            policy,
130            opts.clone(),
131            None,
132            delta_tx,
133        ))
134        .await
135        .map(|(result, _trace)| result)
136    })
137}
138
139/// Execute a logical LLM call. A configured routing policy runs first; each
140/// routed link re-enters the single-route path with its policy cleared. Calls
141/// without routing always go through the streaming path with a discarding
142/// receiver so status/error handling stays shared.
143pub(crate) async fn vm_call_llm_full(opts: &LlmCallOptions) -> Result<LlmResult, VmError> {
144    if let Some(call) = routed_llm_call(opts, None) {
145        return call.await;
146    }
147    vm_call_llm_full_single_route(opts).await
148}
149
150/// Execute exactly one provider/model route. Observability calls this primitive
151/// after it has established the physical-attempt span; routing calls back into
152/// observability with `routing_policy` cleared on each link.
153pub(crate) async fn vm_call_llm_full_single_route(
154    opts: &LlmCallOptions,
155) -> Result<LlmResult, VmError> {
156    super::cost::check_llm_preflight_budget(opts)?;
157    let (delta_tx, mut delta_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
158    let mut first_token = super::first_token::FirstTokenTimer::for_current_span();
159    let mut deltas_open = true;
160    let mut call = Box::pin(vm_call_llm_full_inner(opts, Some(delta_tx)));
161    let result = loop {
162        tokio::select! {
163            maybe_delta = delta_rx.recv(), if deltas_open => {
164                match maybe_delta {
165                    Some(_) => first_token.observe_delta(),
166                    None => deltas_open = false,
167                }
168            }
169            result = &mut call => break result?,
170        }
171    };
172    while delta_rx.try_recv().is_ok() {
173        first_token.observe_delta();
174    }
175    super::cost::record_llm_usage_for_provider(
176        &result.provider,
177        &result.model,
178        result.input_tokens,
179        result.output_tokens,
180        result.served_fast,
181    )?;
182    Ok(result)
183}
184
185/// Execute an LLM call, streaming text deltas to `delta_tx`.
186pub(crate) async fn vm_call_llm_full_streaming(
187    opts: &LlmCallOptions,
188    delta_tx: DeltaSender,
189) -> Result<LlmResult, VmError> {
190    if let Some(call) = routed_llm_call(opts, Some(delta_tx.clone())) {
191        return call.await;
192    }
193    vm_call_llm_full_streaming_single_route(opts, delta_tx).await
194}
195
196pub(crate) async fn vm_call_llm_full_streaming_single_route(
197    opts: &LlmCallOptions,
198    delta_tx: DeltaSender,
199) -> Result<LlmResult, VmError> {
200    super::cost::check_llm_preflight_budget(opts)?;
201    let result = vm_call_llm_full_inner(opts, Some(delta_tx)).await?;
202    super::cost::record_llm_usage_for_provider(
203        &result.provider,
204        &result.model,
205        result.input_tokens,
206        result.output_tokens,
207        result.served_fast,
208    )?;
209    Ok(result)
210}
211
212/// Execute provider I/O on Tokio's multithreaded scheduler while keeping
213/// VM-local values and transcript assembly on the caller's LocalSet.
214#[cfg(test)]
215pub(crate) async fn vm_call_llm_full_streaming_offthread(
216    opts: &LlmCallOptions,
217    delta_tx: DeltaSender,
218) -> Result<LlmResult, VmError> {
219    if let Some(call) = routed_llm_call(opts, Some(delta_tx.clone())) {
220        return call.await;
221    }
222    vm_call_llm_full_streaming_offthread_single_route(opts, delta_tx).await
223}
224
225pub(crate) async fn vm_call_llm_full_streaming_offthread_single_route(
226    opts: &LlmCallOptions,
227    delta_tx: DeltaSender,
228) -> Result<LlmResult, VmError> {
229    super::cost::check_llm_preflight_budget(opts)?;
230    let request = LlmRequestPayload::from(opts);
231    let cached = super::trigger_predicate::lookup_cached_result(&request).is_some();
232    let intercepted = crate::llm::providers::MockProvider::should_intercept_request(&request)
233        || crate::llm::fake::FakeLlmProvider::should_intercept(&request.provider);
234    let replay_mode = get_replay_mode();
235    if !cached && !intercepted && replay_mode == LlmReplayMode::Replay {
236        let hash = fixture_hash(&request.model, &request.messages, request.system.as_deref());
237        if load_fixture(&hash).is_none() {
238            return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
239                format!("No fixture found for LLM call (hash: {hash}). Run with --record first."),
240            ))));
241        }
242    }
243    if !cached && !intercepted && replay_mode != LlmReplayMode::Replay {
244        super::ensure_real_llm_allowed(&request.provider)?;
245    }
246    request.emit_reminder_lifecycle();
247    let raw_capture_context = crate::llm::agent_observe::current_raw_provider_capture_context();
248    let result = tokio::task::spawn(async move {
249        if let Some(context) = raw_capture_context {
250            crate::llm::agent_observe::with_raw_provider_capture_context(context, async {
251                vm_call_llm_full_inner_offthread(&request, Some(delta_tx)).await
252            })
253            .await
254        } else {
255            vm_call_llm_full_inner_offthread(&request, Some(delta_tx)).await
256        }
257    })
258    .await
259    .map_err(|join_err| {
260        VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
261            "llm_call background task failed: {join_err}"
262        ))))
263    })?
264    .map_err(OffthreadLlmError::into_vm_error)?;
265    super::cost::record_llm_usage_for_provider(
266        &result.provider,
267        &result.model,
268        result.input_tokens,
269        result.output_tokens,
270        result.served_fast,
271    )?;
272    Ok(result)
273}
274
275async fn vm_call_llm_full_inner(
276    opts: &LlmCallOptions,
277    delta_tx: Option<DeltaSender>,
278) -> Result<LlmResult, VmError> {
279    let request = LlmRequestPayload::from(opts);
280    vm_call_llm_full_inner_request(&request, delta_tx).await
281}
282
283async fn vm_call_llm_full_inner_request(
284    request: &LlmRequestPayload,
285    delta_tx: Option<DeltaSender>,
286) -> Result<LlmResult, VmError> {
287    if let Some(result) = super::trigger_predicate::lookup_cached_result(request) {
288        request.emit_reminder_lifecycle();
289        record_cli_llm_result(request, &result);
290        if let Some(tx) = delta_tx {
291            if !result.text.is_empty() {
292                let _ = tx.send(result.text.clone());
293            }
294        }
295        return Ok(result);
296    }
297
298    if crate::llm::providers::MockProvider::should_intercept_request(request) {
299        request.emit_reminder_lifecycle();
300        let result = mock_llm_response(request)?;
301        super::trigger_predicate::note_result(request, &result);
302        record_cli_llm_result(request, &result);
303        if let Some(tx) = delta_tx {
304            // A mock may script an ordered chunk sequence to emulate a real
305            // token stream; otherwise fall back to a single full-text delta so
306            // streaming callers still see the visible text (the graceful
307            // non-streaming path). `stream_chunks.concat() == result.text`.
308            if let Some(chunks) = super::mock::take_mock_stream_chunks() {
309                for chunk in chunks {
310                    let _ = tx.send(chunk);
311                }
312                return Ok(result);
313            }
314            if !result.text.is_empty() {
315                let _ = tx.send(result.text.clone());
316            }
317            return Ok(result);
318        }
319        return Ok(result);
320    }
321
322    if crate::llm::fake::FakeLlmProvider::should_intercept(&request.provider) {
323        // Bypass fixture/replay so the script-driven fake never collides
324        // with HARN_LLM_REPLAY/RECORD being set from an outer harness.
325        request.emit_reminder_lifecycle();
326        let result = crate::llm::fake::FakeLlmProvider
327            .chat_impl(request, delta_tx)
328            .await?;
329        super::trigger_predicate::note_result(request, &result);
330        record_cli_llm_result(request, &result);
331        return Ok(result);
332    }
333
334    let replay_mode = get_replay_mode();
335    let hash = fixture_hash(&request.model, &request.messages, request.system.as_deref());
336
337    if replay_mode == LlmReplayMode::Replay {
338        if let Some(result) = load_fixture(&hash) {
339            request.emit_reminder_lifecycle();
340            super::trigger_predicate::note_result(request, &result);
341            return Ok(result);
342        }
343        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
344            format!("No fixture found for LLM call (hash: {hash}). Run with --record first."),
345        ))));
346    }
347
348    super::ensure_real_llm_allowed(&request.provider)?;
349    request.emit_reminder_lifecycle();
350
351    // Provider/model failover is owned by `routing::execute_with_routing`.
352    // This layer executes exactly one route so no attempt can bypass the
353    // canonical ledger, quarantine, or exhaustion contract.
354    let result = vm_call_llm_api(request, delta_tx).await?;
355
356    if replay_mode == LlmReplayMode::Record {
357        save_fixture(&hash, &result);
358    }
359    super::trigger_predicate::note_result(request, &result);
360    record_cli_llm_result(request, &result);
361
362    Ok(result)
363}
364
365async fn vm_call_llm_full_inner_offthread(
366    request: &LlmRequestPayload,
367    delta_tx: Option<DeltaSender>,
368) -> Result<LlmResult, OffthreadLlmError> {
369    if let Some(result) = super::trigger_predicate::lookup_cached_result(request) {
370        record_cli_llm_result(request, &result);
371        return Ok(result);
372    }
373
374    if crate::llm::providers::MockProvider::should_intercept_request(request) {
375        let result = mock_llm_response(request).map_err(OffthreadLlmError::from_vm_error)?;
376        super::trigger_predicate::note_result(request, &result);
377        record_cli_llm_result(request, &result);
378        return Ok(result);
379    }
380
381    if crate::llm::fake::FakeLlmProvider::should_intercept(&request.provider) {
382        let result = crate::llm::fake::FakeLlmProvider
383            .chat_impl(request, delta_tx)
384            .await
385            .map_err(OffthreadLlmError::from_vm_error)?;
386        super::trigger_predicate::note_result(request, &result);
387        record_cli_llm_result(request, &result);
388        return Ok(result);
389    }
390
391    let replay_mode = get_replay_mode();
392    let hash = fixture_hash(&request.model, &request.messages, request.system.as_deref());
393
394    if replay_mode == LlmReplayMode::Replay {
395        return load_fixture(&hash)
396            .inspect(|result| {
397                super::trigger_predicate::note_result(request, result);
398            })
399            .ok_or_else(|| {
400                OffthreadLlmError::from_display_message(format!(
401                    "No fixture found for LLM call (hash: {hash}). Run with --record first."
402                ))
403            });
404    }
405
406    super::ensure_real_llm_allowed(&request.provider).map_err(OffthreadLlmError::from_vm_error)?;
407
408    // Keep the off-thread transport primitive single-route as well. The caller
409    // routing executor owns all retries across provider/model alternatives.
410    let result = vm_call_llm_api(request, delta_tx)
411        .await
412        .map_err(OffthreadLlmError::from_vm_error)?;
413
414    if replay_mode == LlmReplayMode::Record {
415        save_fixture(&hash, &result);
416    }
417    super::trigger_predicate::note_result(request, &result);
418    record_cli_llm_result(request, &result);
419
420    Ok(result)
421}
422
423#[cfg(test)]
424mod tests {
425    use super::options::base_opts;
426    use super::{
427        vm_call_llm_full, vm_call_llm_full_streaming, vm_call_llm_full_streaming_offthread,
428        LlmRequestPayload, ThinkingConfig,
429    };
430    use crate::llm::env_guard;
431
432    struct ScopedEnvVar {
433        key: &'static str,
434        previous: Option<String>,
435    }
436
437    impl ScopedEnvVar {
438        fn set(key: &'static str, value: &str) -> Self {
439            let previous = std::env::var(key).ok();
440            unsafe {
441                std::env::set_var(key, value);
442            }
443            Self { key, previous }
444        }
445
446        fn remove(key: &'static str) -> Self {
447            let previous = std::env::var(key).ok();
448            unsafe {
449                std::env::remove_var(key);
450            }
451            Self { key, previous }
452        }
453    }
454
455    impl Drop for ScopedEnvVar {
456        fn drop(&mut self) {
457            match &self.previous {
458                Some(value) => unsafe { std::env::set_var(self.key, value) },
459                None => unsafe { std::env::remove_var(self.key) },
460            }
461        }
462    }
463
464    fn allow_stubbed_llm_transport() -> ScopedEnvVar {
465        ScopedEnvVar::remove(crate::llm::LLM_CALLS_DISABLED_ENV)
466    }
467
468    #[test]
469    fn openai_compat_prefill_appends_assistant_and_sets_chat_template_kwargs() {
470        use crate::llm::providers::OpenAiCompatibleProvider;
471
472        let mut opts = base_opts("local");
473        opts.model = "Qwen/Qwen3.5-Coder-32B".to_string();
474        opts.prefill = Some("<done>##DONE##</done>".to_string());
475        let payload = LlmRequestPayload::from(&opts);
476        let body = OpenAiCompatibleProvider::build_request_body(&payload, false);
477
478        let messages = body["messages"].as_array().expect("messages array");
479        let last = messages.last().expect("at least one message");
480        assert_eq!(last["role"].as_str(), Some("assistant"));
481        assert_eq!(last["content"].as_str(), Some("<done>##DONE##</done>"));
482
483        let kw = &body["chat_template_kwargs"];
484        assert_eq!(kw["add_generation_prompt"].as_bool(), Some(false));
485        assert_eq!(kw["continue_final_message"].as_bool(), Some(true));
486    }
487
488    #[test]
489    fn openai_compat_without_prefill_omits_continue_flags() {
490        use crate::llm::providers::OpenAiCompatibleProvider;
491
492        let opts = base_opts("openai");
493        let payload = LlmRequestPayload::from(&opts);
494        let body = OpenAiCompatibleProvider::build_request_body(&payload, false);
495
496        let kw = &body["chat_template_kwargs"];
497        assert!(kw.get("add_generation_prompt").is_none());
498        assert!(kw.get("continue_final_message").is_none());
499    }
500
501    #[test]
502    fn anthropic_prefill_appends_assistant_for_legacy_model() {
503        use crate::llm::providers::AnthropicProvider;
504
505        let mut opts = base_opts("anthropic");
506        opts.model = "claude-sonnet-4-20250514".to_string();
507        opts.prefill = Some("<done>##DONE##</done>".to_string());
508        let payload = LlmRequestPayload::from(&opts);
509        let body = AnthropicProvider::build_request_body(&payload);
510
511        let messages = body["messages"].as_array().expect("messages array");
512        let last = messages.last().expect("at least one message");
513        assert_eq!(last["role"].as_str(), Some("assistant"));
514        assert_eq!(last["content"].as_str(), Some("<done>##DONE##</done>"));
515    }
516
517    #[test]
518    fn anthropic_prefill_skipped_for_deprecated_4_6_model() {
519        use crate::llm::providers::AnthropicProvider;
520
521        let mut opts = base_opts("anthropic");
522        opts.model = "claude-opus-4-6".to_string();
523        opts.prefill = Some("<done>##DONE##</done>".to_string());
524        let payload = LlmRequestPayload::from(&opts);
525        let body = AnthropicProvider::build_request_body(&payload);
526
527        let messages = body["messages"].as_array().expect("messages array");
528        // User message only; prefill dropped silently.
529        assert_eq!(messages.len(), 1);
530        assert_eq!(messages[0]["role"].as_str(), Some("user"));
531    }
532
533    #[test]
534    fn anthropic_prefill_skipped_for_opus_4_7() {
535        use crate::llm::providers::AnthropicProvider;
536
537        let mut opts = base_opts("anthropic");
538        opts.model = "claude-opus-4-7".to_string();
539        opts.prefill = Some("<done>##DONE##</done>".to_string());
540        let payload = LlmRequestPayload::from(&opts);
541        let body = AnthropicProvider::build_request_body(&payload);
542
543        let messages = body["messages"].as_array().expect("messages array");
544        assert_eq!(messages.len(), 1);
545        assert_eq!(messages[0]["role"].as_str(), Some("user"));
546    }
547
548    #[test]
549    fn anthropic_sampling_params_stripped_for_opus_4_7() {
550        use crate::llm::providers::AnthropicProvider;
551
552        let mut opts = base_opts("anthropic");
553        opts.model = "claude-opus-4-7".to_string();
554        // base_opts already supplies temperature/top_p/top_k.
555        let payload = LlmRequestPayload::from(&opts);
556        let body = AnthropicProvider::build_request_body(&payload);
557
558        assert!(
559            body.get("temperature").is_none(),
560            "Opus 4.7 body must omit temperature (returns HTTP 400 otherwise)"
561        );
562        assert!(body.get("top_p").is_none(), "Opus 4.7 body must omit top_p");
563        assert!(body.get("top_k").is_none(), "Opus 4.7 body must omit top_k");
564    }
565
566    #[test]
567    fn anthropic_sampling_params_preserved_for_opus_4_6() {
568        use crate::llm::providers::AnthropicProvider;
569
570        let mut opts = base_opts("anthropic");
571        opts.model = "claude-opus-4-6".to_string();
572        let payload = LlmRequestPayload::from(&opts);
573        let body = AnthropicProvider::build_request_body(&payload);
574
575        assert_eq!(body["temperature"].as_f64(), Some(0.2));
576        assert_eq!(body["top_p"].as_f64(), Some(0.8));
577        assert_eq!(body["top_k"].as_i64(), Some(40));
578    }
579
580    #[test]
581    fn disabled_llm_calls_reject_real_provider_before_transport() {
582        let _guard = env_guard();
583        let _disabled = ScopedEnvVar::set(crate::llm::LLM_CALLS_DISABLED_ENV, "1");
584        let runtime = tokio::runtime::Builder::new_current_thread()
585            .enable_all()
586            .build()
587            .expect("runtime");
588
589        let err = runtime
590            .block_on(vm_call_llm_full(&base_opts("local")))
591            .expect_err("local provider should be blocked before transport");
592        let message = err.to_string();
593        assert!(message.contains("HARN_LLM_CALLS_DISABLED"), "{message}");
594        assert!(message.contains("provider `local`"), "{message}");
595    }
596
597    #[test]
598    fn offthread_error_preserves_schema_stream_abort_category() {
599        let abort = super::SchemaStreamAbort {
600            provider: "openrouter".to_string(),
601            model: "mistralai/devstral-small".to_string(),
602            reason: "expected JSON value, got '`'".to_string(),
603            path: "$".to_string(),
604            chunks_consumed: 1,
605        };
606
607        let err = super::OffthreadLlmError::from_vm_error(abort.into_vm_error()).into_vm_error();
608        let parsed = super::parse_schema_stream_abort(&err)
609            .expect("schema stream abort must survive off-thread conversion");
610
611        assert_eq!(parsed.provider, "openrouter");
612        assert_eq!(parsed.model, "mistralai/devstral-small");
613        assert_eq!(parsed.path, "$");
614        assert_eq!(parsed.chunks_consumed, 1);
615    }
616
617    #[test]
618    fn disabled_llm_calls_still_allow_mock_provider() {
619        let _guard = env_guard();
620        let _disabled = ScopedEnvVar::set(crate::llm::LLM_CALLS_DISABLED_ENV, "1");
621        let runtime = tokio::runtime::Builder::new_current_thread()
622            .enable_all()
623            .build()
624            .expect("runtime");
625
626        let result = runtime
627            .block_on(vm_call_llm_full(&base_opts("mock")))
628            .expect("mock provider remains available");
629        assert_eq!(result.provider, "mock");
630    }
631
632    #[test]
633    fn fake_provider_routes_through_full_pipeline_with_streaming_deltas() {
634        use crate::llm::fake::{
635            install_fake_llm_script, FakeLlmEvent, FakeLlmScript, FakeStopReason,
636        };
637
638        let _guard = env_guard();
639        // Even with HARN_LLM_CALLS_DISABLED, the fake must pass through —
640        // it never hits the network, so it must not be gated by that env.
641        let _disabled = ScopedEnvVar::set(crate::llm::LLM_CALLS_DISABLED_ENV, "1");
642        let runtime = tokio::runtime::Builder::new_current_thread()
643            .enable_all()
644            .build()
645            .expect("runtime");
646
647        let _script = install_fake_llm_script(FakeLlmScript::streaming(vec![
648            FakeLlmEvent::Token("alpha".into()),
649            FakeLlmEvent::Token(" beta".into()),
650            FakeLlmEvent::Done(FakeStopReason::EndTurn),
651        ]));
652
653        runtime.block_on(async {
654            let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
655            let result = vm_call_llm_full_streaming(&base_opts("fake"), tx)
656                .await
657                .expect("fake provider routes through dispatch");
658            assert_eq!(result.provider, "fake");
659            assert_eq!(result.text, "alpha beta");
660            let mut deltas = Vec::new();
661            while let Ok(delta) = rx.try_recv() {
662                deltas.push(delta);
663            }
664            assert_eq!(deltas, vec!["alpha".to_string(), " beta".to_string()]);
665        });
666    }
667
668    #[test]
669    fn anthropic_thinking_rewritten_to_adaptive_for_opus_4_7() {
670        use crate::llm::providers::AnthropicProvider;
671
672        let mut opts = base_opts("anthropic");
673        opts.model = "claude-opus-4-7".to_string();
674        opts.thinking = ThinkingConfig::Enabled {
675            budget_tokens: None,
676        };
677        let payload = LlmRequestPayload::from(&opts);
678        let body = AnthropicProvider::build_request_body(&payload);
679
680        let thinking = &body["thinking"];
681        assert_eq!(thinking["type"].as_str(), Some("adaptive"));
682        assert!(
683            thinking.get("budget_tokens").is_none(),
684            "Opus 4.7 adaptive thinking must not carry budget_tokens"
685        );
686    }
687
688    #[test]
689    fn anthropic_thinking_budget_discarded_for_opus_4_7() {
690        use crate::llm::providers::AnthropicProvider;
691
692        let mut opts = base_opts("anthropic");
693        opts.model = "claude-opus-4-7".to_string();
694        opts.thinking = ThinkingConfig::Enabled {
695            budget_tokens: Some(32000),
696        };
697        let payload = LlmRequestPayload::from(&opts);
698        let body = AnthropicProvider::build_request_body(&payload);
699
700        let thinking = &body["thinking"];
701        assert_eq!(thinking["type"].as_str(), Some("adaptive"));
702        assert!(thinking.get("budget_tokens").is_none());
703    }
704
705    #[test]
706    fn anthropic_thinking_preserves_extended_for_opus_4_6() {
707        use crate::llm::providers::AnthropicProvider;
708
709        let mut opts = base_opts("anthropic");
710        opts.model = "claude-opus-4-6".to_string();
711        opts.thinking = ThinkingConfig::Enabled {
712            budget_tokens: Some(16000),
713        };
714        let payload = LlmRequestPayload::from(&opts);
715        let body = AnthropicProvider::build_request_body(&payload);
716
717        let thinking = &body["thinking"];
718        assert_eq!(thinking["type"].as_str(), Some("enabled"));
719        assert_eq!(thinking["budget_tokens"].as_i64(), Some(16000));
720    }
721
722    #[test]
723    fn anthropic_prefill_preserved_for_or_opus_dotted_older_generations() {
724        use crate::llm::providers::AnthropicProvider;
725
726        // Dotted "claude-opus-4.5" style should NOT hit the 4.6 gate.
727        let mut opts = base_opts("anthropic");
728        opts.model = "anthropic/claude-opus-4.5".to_string();
729        opts.prefill = Some("<done>##DONE##</done>".to_string());
730        let payload = LlmRequestPayload::from(&opts);
731        let body = AnthropicProvider::build_request_body(&payload);
732
733        let messages = body["messages"].as_array().expect("messages array");
734        assert_eq!(messages.len(), 2);
735        assert_eq!(messages.last().unwrap()["role"].as_str(), Some("assistant"));
736    }
737
738    #[test]
739    fn anthropic_prefill_skipped_for_or_opus_4_7_dotted() {
740        use crate::llm::providers::AnthropicProvider;
741
742        let mut opts = base_opts("anthropic");
743        opts.model = "anthropic/claude-opus-4.7".to_string();
744        opts.prefill = Some("<done>##DONE##</done>".to_string());
745        let payload = LlmRequestPayload::from(&opts);
746        let body = AnthropicProvider::build_request_body(&payload);
747
748        let messages = body["messages"].as_array().expect("messages array");
749        assert_eq!(messages.len(), 1);
750        assert_eq!(messages[0]["role"].as_str(), Some("user"));
751    }
752
753    /// Cooperative accept: blocks the stub thread on a real
754    /// `accept()` call until a client connects, then returns the
755    /// stream. Shutdown wakes the thread by self-connecting to the
756    /// listener (see [`LlmStub::drop`]) — when the resulting `accept`
757    /// returns, the shutdown flag is checked and the synthetic stream
758    /// is discarded.
759    ///
760    /// This replaces a previous nonblocking polling loop with a 5ms
761    /// sleep tick. Polling introduced two flake modes under nextest's
762    /// 50× concurrent flake-detection profile: (1) a real client
763    /// connection could land between two polls and reqwest could time
764    /// out on the SYN-ACK before the stub thread woke; (2) under
765    /// heavy CPU contention the 5ms tick could stretch to tens of
766    /// milliseconds, compounding (1). Blocking accept removes the
767    /// scheduling-latency variable entirely.
768    fn accept_with_shutdown(
769        listener: &std::net::TcpListener,
770        label: &str,
771        shutdown: &std::sync::atomic::AtomicBool,
772    ) -> Option<std::net::TcpStream> {
773        let (stream, _peer) = listener
774            .accept()
775            .unwrap_or_else(|e| panic!("{label}: accept failed: {e}"));
776        if shutdown.load(std::sync::atomic::Ordering::Acquire) {
777            drop(stream);
778            return None;
779        }
780        stream
781            .set_read_timeout(Some(std::time::Duration::from_secs(30)))
782            .ok();
783        stream
784            .set_write_timeout(Some(std::time::Duration::from_secs(30)))
785            .ok();
786        Some(stream)
787    }
788
789    /// Wake a stub thread blocked in [`accept_with_shutdown`] by
790    /// opening a one-shot self-connection to its listener. The thread
791    /// then observes the shutdown flag and exits without serving the
792    /// connection. The connect uses a short timeout so a deferred
793    /// shutdown (e.g. drop during panic unwind on a saturated CI
794    /// worker) cannot wedge the test process.
795    fn wake_accept_for_shutdown(addr: std::net::SocketAddr) {
796        let _ = std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500));
797    }
798
799    /// RAII guard for an in-process LLM stub. Dropping signals the stub
800    /// thread to exit and joins it so no FDs leak past the test, even on
801    /// panic.
802    struct LlmStub {
803        addr: std::net::SocketAddr,
804        shutdown: std::sync::Arc<std::sync::atomic::AtomicBool>,
805        handle: Option<std::thread::JoinHandle<()>>,
806        /// Maximum number of `accept()` calls the stub thread can be
807        /// parked on. Single-shot stubs use 1; `spawn_llm_stub_many`
808        /// uses its connection count. Drop fires that many self-
809        /// connections so every parked accept observes shutdown.
810        pending_accepts: usize,
811    }
812
813    impl LlmStub {
814        fn addr(&self) -> std::net::SocketAddr {
815            self.addr
816        }
817    }
818
819    impl Drop for LlmStub {
820        fn drop(&mut self) {
821            self.shutdown
822                .store(true, std::sync::atomic::Ordering::Release);
823            // Self-connect to unblock any thread parked inside
824            // `accept_with_shutdown`. Multiple stubs in
825            // `spawn_llm_stub_many` may need waking, so issue one
826            // wake per outstanding accept slot.
827            for _ in 0..self.pending_accepts.max(1) {
828                wake_accept_for_shutdown(self.addr);
829            }
830            if let Some(handle) = self.handle.take() {
831                let _ = handle.join();
832            }
833        }
834    }
835
836    /// Bind a localhost listener and run `body` on a background thread once
837    /// a client connects. Wraps the listener in an [`LlmStub`] guard whose
838    /// lifetime bounds the stub thread.
839    fn spawn_llm_stub<F>(label: &'static str, body: F) -> LlmStub
840    where
841        F: FnOnce(&mut std::net::TcpStream) + Send + 'static,
842    {
843        use std::net::TcpListener;
844        let listener = TcpListener::bind("127.0.0.1:0").expect("bind llm stub");
845        let addr = listener.local_addr().expect("stub addr");
846        let shutdown = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
847        let shutdown_thread = shutdown.clone();
848        let handle = std::thread::spawn(move || {
849            let Some(mut stream) = accept_with_shutdown(&listener, label, &shutdown_thread) else {
850                return;
851            };
852            body(&mut stream);
853        });
854        LlmStub {
855            addr,
856            shutdown,
857            handle: Some(handle),
858            pending_accepts: 1,
859        }
860    }
861
862    fn spawn_llm_stub_many<F>(label: &'static str, connections: usize, mut body: F) -> LlmStub
863    where
864        F: FnMut(usize, &mut std::net::TcpStream) + Send + 'static,
865    {
866        use std::net::TcpListener;
867        let listener = TcpListener::bind("127.0.0.1:0").expect("bind llm stub");
868        let addr = listener.local_addr().expect("stub addr");
869        let shutdown = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
870        let shutdown_thread = shutdown.clone();
871        let handle = std::thread::spawn(move || {
872            for attempt in 0..connections {
873                let Some(mut stream) = accept_with_shutdown(&listener, label, &shutdown_thread)
874                else {
875                    return;
876                };
877                body(attempt, &mut stream);
878            }
879        });
880        LlmStub {
881            addr,
882            shutdown,
883            handle: Some(handle),
884            pending_accepts: connections,
885        }
886    }
887
888    fn spawn_ollama_stub() -> LlmStub {
889        spawn_llm_stub("ollama stub", |stream| {
890            use std::io::{Read, Write};
891            let mut buf = vec![0u8; 8192];
892            let n = stream.read(&mut buf).expect("read request");
893            let request = String::from_utf8_lossy(&buf[..n]);
894            assert!(request.starts_with("POST /api/chat HTTP/1.1\r\n"));
895
896            let body = concat!(
897                "{\"message\":{\"role\":\"assistant\",\"content\":\"hello \"},\"done\":false,\"model\":\"stub-model\"}\n",
898                "{\"message\":{\"role\":\"assistant\",\"content\":\"world\"},\"done\":false}\n",
899                "{\"done\":true,\"prompt_eval_count\":3,\"eval_count\":2,\"model\":\"stub-model\"}\n"
900            );
901            let response = format!(
902                "HTTP/1.1 200 OK\r\ncontent-type: application/x-ndjson\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
903                body.len(),
904                body
905            );
906            stream
907                .write_all(response.as_bytes())
908                .expect("write response");
909        })
910    }
911
912    fn spawn_ollama_empty_then_success_stub(
913        request_count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
914    ) -> LlmStub {
915        spawn_llm_stub_many("ollama retry stub", 2, move |attempt, stream| {
916            use std::io::{Read, Write};
917            request_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
918            let mut buf = vec![0u8; 8192];
919            let n = stream.read(&mut buf).expect("read request");
920            let request = String::from_utf8_lossy(&buf[..n]);
921            assert!(request.starts_with("POST /api/chat HTTP/1.1\r\n"));
922
923            let body = if attempt == 0 {
924                "{\"message\":{\"role\":\"assistant\",\"content\":\"\"},\"done\":true,\"prompt_eval_count\":5,\"eval_count\":3}\n"
925            } else {
926                concat!(
927                    "{\"message\":{\"role\":\"assistant\",\"content\":\"retried\"},\"done\":false,\"model\":\"stub-model\"}\n",
928                    "{\"done\":true,\"prompt_eval_count\":5,\"eval_count\":1,\"model\":\"stub-model\"}\n"
929                )
930            };
931            let response = format!(
932                "HTTP/1.1 200 OK\r\ncontent-type: application/x-ndjson\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
933                body.len(),
934                body
935            );
936            stream
937                .write_all(response.as_bytes())
938                .expect("write response");
939        })
940    }
941
942    fn spawn_openai_empty_stub(
943        request_count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
944    ) -> LlmStub {
945        spawn_openai_empty_stub_many(request_count, 2)
946    }
947
948    fn spawn_openai_empty_stub_many(
949        request_count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
950        max_requests: usize,
951    ) -> LlmStub {
952        spawn_llm_stub_many(
953            "openai empty stub",
954            max_requests,
955            move |_attempt, stream| {
956                use std::io::{Read, Write};
957                request_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
958                let mut buf = vec![0u8; 16_384];
959                let n = stream.read(&mut buf).expect("read request");
960                let request = String::from_utf8_lossy(&buf[..n]);
961                assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1\r\n"));
962                let body = r#"{"id":"empty","object":"chat.completion","created":0,"model":"empty-primary","choices":[{"index":0,"message":{"role":"assistant","content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":0,"total_tokens":1}}"#;
963                let response = format!(
964                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
965                body.len(),
966                body
967            );
968                stream
969                    .write_all(response.as_bytes())
970                    .expect("write response");
971            },
972        )
973    }
974
975    fn install_openai_stub_provider(provider: &str, addr: std::net::SocketAddr) {
976        let mut overlay = crate::llm_config::ProvidersConfig::default();
977        overlay.providers.insert(
978            provider.to_string(),
979            crate::llm_config::ProviderDef {
980                base_url: format!("http://{addr}/v1"),
981                auth_style: "none".to_string(),
982                auth_env: crate::llm_config::AuthEnv::None,
983                chat_endpoint: "/chat/completions".to_string(),
984                ..Default::default()
985            },
986        );
987        crate::llm_config::set_user_overrides(Some(overlay));
988    }
989
990    fn spawn_ollama_stub_with_body_capture(
991        captured: std::sync::Arc<std::sync::Mutex<Option<String>>>,
992    ) -> LlmStub {
993        spawn_llm_stub("ollama stub (capture)", move |stream| {
994            use std::io::{Read, Write};
995            let mut buf = vec![0u8; 16384];
996            let n = stream.read(&mut buf).expect("read request");
997            let request = String::from_utf8_lossy(&buf[..n]).to_string();
998            let body = request
999                .split("\r\n\r\n")
1000                .nth(1)
1001                .unwrap_or_default()
1002                .to_string();
1003            *captured.lock().expect("capture body") = Some(body);
1004
1005            let body = concat!(
1006                "{\"message\":{\"role\":\"assistant\",\"content\":\"ok\"},\"done\":false}\n",
1007                "{\"done\":true,\"prompt_eval_count\":1,\"eval_count\":1}\n"
1008            );
1009            let response = format!(
1010                "HTTP/1.1 200 OK\r\ncontent-type: application/x-ndjson\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
1011                body.len(),
1012                body
1013            );
1014            stream
1015                .write_all(response.as_bytes())
1016                .expect("write response");
1017        })
1018    }
1019
1020    fn spawn_ollama_raw_generate_stub(
1021        captured: std::sync::Arc<std::sync::Mutex<Option<String>>>,
1022    ) -> LlmStub {
1023        spawn_llm_stub("ollama raw stub", move |stream| {
1024            use std::io::{Read, Write};
1025            let mut buf = vec![0u8; 16384];
1026            let n = stream.read(&mut buf).expect("read request");
1027            let request = String::from_utf8_lossy(&buf[..n]).to_string();
1028            assert!(request.starts_with("POST /api/generate HTTP/1.1\r\n"));
1029            let body = request
1030                .split("\r\n\r\n")
1031                .nth(1)
1032                .unwrap_or_default()
1033                .to_string();
1034            *captured.lock().expect("capture body") = Some(body);
1035
1036            let body = concat!(
1037                "{\"response\":\"<tool_call>\\nedit({ path: \\\"a.rs\\\" })\\n</tool_call>\",\"done\":false,\"model\":\"qwen3.5:stub\"}\n",
1038                "{\"done\":true,\"prompt_eval_count\":7,\"eval_count\":11,\"model\":\"qwen3.5:stub\",\"done_reason\":\"stop\"}\n"
1039            );
1040            let response = format!(
1041                "HTTP/1.1 200 OK\r\ncontent-type: application/x-ndjson\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
1042                body.len(),
1043                body
1044            );
1045            stream
1046                .write_all(response.as_bytes())
1047                .expect("write response");
1048        })
1049    }
1050
1051    fn spawn_anthropic_stub_with_request_capture(
1052        captured: std::sync::Arc<std::sync::Mutex<Option<String>>>,
1053    ) -> LlmStub {
1054        spawn_llm_stub("anthropic stub (capture)", move |stream| {
1055            use std::io::{Read, Write};
1056            let mut buf = vec![0u8; 16384];
1057            let n = stream.read(&mut buf).expect("read request");
1058            let request = String::from_utf8_lossy(&buf[..n]).to_string();
1059            *captured.lock().expect("capture request") = Some(request);
1060
1061            let body = concat!(
1062                r#"{"id":"msg_1","type":"message","role":"assistant","model":"claude-opus-4-6","#,
1063                r#""content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","#,
1064                r#""usage":{"input_tokens":1,"output_tokens":1}}"#
1065            );
1066            let response = format!(
1067                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
1068                body.len(),
1069                body
1070            );
1071            stream
1072                .write_all(response.as_bytes())
1073                .expect("write response");
1074        })
1075    }
1076
1077    #[test]
1078    fn anthropic_interleaved_thinking_beta_header_is_sent_for_supported_model() {
1079        let _guard = env_guard();
1080        let _allow_llm_transport = allow_stubbed_llm_transport();
1081        let runtime = tokio::runtime::Builder::new_current_thread()
1082            .enable_all()
1083            .build()
1084            .expect("runtime");
1085
1086        runtime.block_on(async {
1087            let captured = std::sync::Arc::new(std::sync::Mutex::new(None));
1088            let server = spawn_anthropic_stub_with_request_capture(captured.clone());
1089            let mut overlay = crate::llm_config::ProvidersConfig::default();
1090            overlay.providers.insert(
1091                "anthropic".to_string(),
1092                crate::llm_config::ProviderDef {
1093                    base_url: format!("http://{}", server.addr()),
1094                    auth_style: "none".to_string(),
1095                    auth_env: crate::llm_config::AuthEnv::None,
1096                    extra_headers: std::collections::BTreeMap::from([(
1097                        "anthropic-version".to_string(),
1098                        "2023-06-01".to_string(),
1099                    )]),
1100                    chat_endpoint: "/messages".to_string(),
1101                    ..Default::default()
1102                },
1103            );
1104            crate::llm_config::set_user_overrides(Some(overlay));
1105
1106            let mut opts = base_opts("anthropic");
1107            opts.model = "claude-opus-4-6".to_string();
1108            opts.stream = false;
1109            opts.thinking = ThinkingConfig::Enabled {
1110                budget_tokens: Some(8000),
1111            };
1112            let result = vm_call_llm_full(&opts)
1113                .await
1114                .expect("stubbed Anthropic response");
1115
1116            crate::llm_config::clear_user_overrides();
1117            drop(server);
1118
1119            assert_eq!(result.text, "ok");
1120            let request = captured
1121                .lock()
1122                .expect("captured request")
1123                .clone()
1124                .expect("request captured")
1125                .to_lowercase();
1126            assert!(
1127                request.contains("anthropic-beta: interleaved-thinking-2025-05-14\r\n"),
1128                "{request}"
1129            );
1130        });
1131    }
1132
1133    #[test]
1134    fn offthread_streaming_completes_inside_localset() {
1135        let _guard = env_guard();
1136        let _allow_llm_transport = allow_stubbed_llm_transport();
1137        let runtime = tokio::runtime::Builder::new_multi_thread()
1138            .enable_all()
1139            .worker_threads(2)
1140            .build()
1141            .expect("runtime");
1142
1143        runtime.block_on(async {
1144            let server = spawn_ollama_stub();
1145            let addr = server.addr();
1146            let prev_ollama_host = std::env::var("OLLAMA_HOST").ok();
1147            unsafe {
1148                std::env::set_var("OLLAMA_HOST", format!("http://{addr}"));
1149            }
1150
1151            let local = tokio::task::LocalSet::new();
1152            let result = local
1153                .run_until(async {
1154                    let opts = base_opts("ollama");
1155                    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1156                    let result = vm_call_llm_full_streaming_offthread(&opts, tx)
1157                        .await
1158                        .expect("llm call should succeed");
1159
1160                    let mut deltas = Vec::new();
1161                    while let Ok(delta) = rx.try_recv() {
1162                        deltas.push(delta);
1163                    }
1164                    (result, deltas)
1165                })
1166                .await;
1167
1168            match prev_ollama_host {
1169                Some(value) => unsafe {
1170                    std::env::set_var("OLLAMA_HOST", value);
1171                },
1172                None => unsafe {
1173                    std::env::remove_var("OLLAMA_HOST");
1174                },
1175            }
1176
1177            drop(server);
1178
1179            let (result, deltas) = result;
1180            assert_eq!(result.text, "hello world");
1181            assert_eq!(result.model, "stub-model");
1182            assert_eq!(result.input_tokens, 3);
1183            assert_eq!(result.output_tokens, 2);
1184            assert_eq!(deltas.join(""), "hello world");
1185        });
1186    }
1187
1188    #[test]
1189    fn ollama_empty_content_done_frame_retries_once() {
1190        let _guard = env_guard();
1191        let _allow_llm_transport = allow_stubbed_llm_transport();
1192        let runtime = tokio::runtime::Builder::new_multi_thread()
1193            .enable_all()
1194            .worker_threads(2)
1195            .build()
1196            .expect("runtime");
1197
1198        runtime.block_on(async {
1199            let request_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1200            let server = spawn_ollama_empty_then_success_stub(request_count.clone());
1201            let addr = server.addr();
1202            let prev_ollama_host = std::env::var("OLLAMA_HOST").ok();
1203            unsafe {
1204                std::env::set_var("OLLAMA_HOST", format!("http://{addr}"));
1205            }
1206
1207            let local = tokio::task::LocalSet::new();
1208            let result = local
1209                .run_until(async {
1210                    let opts = base_opts("ollama");
1211                    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1212                    let result = vm_call_llm_full_streaming_offthread(&opts, tx)
1213                        .await
1214                        .expect("retry should recover from empty done frame");
1215
1216                    let mut deltas = Vec::new();
1217                    while let Ok(delta) = rx.try_recv() {
1218                        deltas.push(delta);
1219                    }
1220                    (result, deltas)
1221                })
1222                .await;
1223
1224            match prev_ollama_host {
1225                Some(value) => unsafe { std::env::set_var("OLLAMA_HOST", value) },
1226                None => unsafe { std::env::remove_var("OLLAMA_HOST") },
1227            }
1228
1229            drop(server);
1230
1231            let (result, deltas) = result;
1232            assert_eq!(request_count.load(std::sync::atomic::Ordering::SeqCst), 2);
1233            assert_eq!(result.text, "retried");
1234            assert_eq!(deltas.join(""), "retried");
1235        });
1236    }
1237
1238    #[test]
1239    fn empty_generation_exhausts_primary_then_recovers_on_routed_backup() {
1240        use crate::llm::fake::{
1241            install_fake_llm_script, FakeLlmEvent, FakeLlmScript, FakeLlmTurn, FakeStopReason,
1242        };
1243
1244        let _guard = env_guard();
1245        let _allow_llm_transport = allow_stubbed_llm_transport();
1246        let runtime = tokio::runtime::Builder::new_multi_thread()
1247            .enable_all()
1248            .worker_threads(2)
1249            .build()
1250            .expect("runtime");
1251
1252        runtime.block_on(async {
1253            let request_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1254            let server = spawn_openai_empty_stub(request_count.clone());
1255            install_openai_stub_provider("empty-primary", server.addr());
1256            let _fake =
1257                install_fake_llm_script(FakeLlmScript::new().push(FakeLlmTurn::stream(vec![
1258                    FakeLlmEvent::Token("recovered on backup".into()),
1259                    FakeLlmEvent::Done(FakeStopReason::EndTurn),
1260                ])));
1261
1262            let mut opts = base_opts("empty-primary");
1263            opts.model = "empty-primary-model".to_string();
1264            opts.stream = false;
1265            let policy = crate::llm::routing::build_transport_failover_policy(
1266                &opts.provider,
1267                &opts.model,
1268                &[super::LlmRouteFallback {
1269                    provider: "fake".to_string(),
1270                    model: "fake-backup-model".to_string(),
1271                }],
1272                &[],
1273            )
1274            .expect("credentialed backup creates routing policy");
1275
1276            let local = tokio::task::LocalSet::new();
1277            let (result, trace) = local
1278                .run_until(crate::llm::routing::execute_with_routing(
1279                    &policy, opts, None, None,
1280                ))
1281                .await
1282                .expect("empty primary must recover transparently on backup");
1283
1284            assert_eq!(result.provider, "fake");
1285            assert_eq!(result.text, "recovered on backup");
1286            assert_eq!(
1287                request_count.load(std::sync::atomic::Ordering::SeqCst),
1288                2,
1289                "primary receives the initial request plus one bounded same-route retry"
1290            );
1291            assert_eq!(trace.attempts.len(), 2);
1292            let primary_error = trace.attempts[0]
1293                .error
1294                .as_ref()
1295                .expect("primary route failure receipt");
1296            assert_eq!(primary_error.reason.as_deref(), Some("empty_generation"));
1297            assert_eq!(primary_error.attempt_count, Some(2));
1298            assert!(matches!(
1299                trace.attempts[1].status,
1300                crate::llm::routing::AttemptStatus::Succeeded
1301            ));
1302
1303            crate::llm_config::clear_user_overrides();
1304            drop(server);
1305        });
1306    }
1307
1308    #[test]
1309    fn repeated_empty_generations_quarantine_primary_without_a_phantom_request() {
1310        use crate::llm::fake::{
1311            install_fake_llm_script, FakeLlmEvent, FakeLlmScript, FakeLlmTurn, FakeStopReason,
1312        };
1313
1314        let _guard = env_guard();
1315        let _allow_llm_transport = allow_stubbed_llm_transport();
1316        let runtime = tokio::runtime::Builder::new_multi_thread()
1317            .enable_all()
1318            .worker_threads(2)
1319            .build()
1320            .expect("runtime");
1321
1322        runtime.block_on(async {
1323            let request_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1324            let server = spawn_openai_empty_stub_many(
1325                request_count.clone(),
1326                2 * crate::llm::rate_limit::UNPRODUCTIVE_COMPLETION_BREAKER_THRESHOLD as usize,
1327            );
1328            install_openai_stub_provider("empty-storm-primary", server.addr());
1329
1330            let mut opts = base_opts("empty-storm-primary");
1331            opts.model = "empty-storm-model".to_string();
1332            opts.stream = false;
1333
1334            let local = tokio::task::LocalSet::new();
1335            local
1336                .run_until(async {
1337                    for _ in 0..crate::llm::rate_limit::UNPRODUCTIVE_COMPLETION_BREAKER_THRESHOLD {
1338                        crate::llm::agent_observe::observed_llm_call(
1339                            &opts, None, None, None, false, false, None, None,
1340                        )
1341                        .await
1342                        .expect_err("each terminal empty generation must exhaust its route");
1343                    }
1344                })
1345                .await;
1346            let requests_before_quarantine =
1347                request_count.load(std::sync::atomic::Ordering::SeqCst);
1348            assert_eq!(
1349                requests_before_quarantine,
1350                2 * crate::llm::rate_limit::UNPRODUCTIVE_COMPLETION_BREAKER_THRESHOLD as usize,
1351                "each admitted route performs one initial request and one bounded retry"
1352            );
1353
1354            let _fake =
1355                install_fake_llm_script(FakeLlmScript::new().push(FakeLlmTurn::stream(vec![
1356                    FakeLlmEvent::Token("recovered after quarantine".into()),
1357                    FakeLlmEvent::Done(FakeStopReason::EndTurn),
1358                ])));
1359            let policy = crate::llm::routing::build_transport_failover_policy(
1360                &opts.provider,
1361                &opts.model,
1362                &[super::LlmRouteFallback {
1363                    provider: "fake".to_string(),
1364                    model: "fake-after-quarantine".to_string(),
1365                }],
1366                &[],
1367            )
1368            .expect("backup creates routing policy");
1369            let (result, trace) = local
1370                .run_until(crate::llm::routing::execute_with_routing(
1371                    &policy, opts, None, None,
1372                ))
1373                .await
1374                .expect("routing must advance past the quarantined primary");
1375
1376            assert_eq!(result.text, "recovered after quarantine");
1377            assert_eq!(result.provider, "fake");
1378            assert_eq!(
1379                request_count.load(std::sync::atomic::Ordering::SeqCst),
1380                requests_before_quarantine,
1381                "the quarantined primary must perform zero additional HTTP requests"
1382            );
1383            let quarantined = trace.attempts.first().expect("primary attempt receipt");
1384            let error = quarantined
1385                .error
1386                .as_ref()
1387                .expect("quarantine error receipt");
1388            assert_eq!(error.code.as_deref(), Some("route_quarantined"));
1389            assert_eq!(error.attempt_count, Some(0));
1390            assert!(matches!(
1391                trace.attempts[1].status,
1392                crate::llm::routing::AttemptStatus::Succeeded
1393            ));
1394
1395            crate::llm_config::clear_user_overrides();
1396            drop(server);
1397        });
1398    }
1399
1400    #[test]
1401    fn empty_generation_without_backup_returns_typed_attempted_chain() {
1402        let _guard = env_guard();
1403        let _allow_llm_transport = allow_stubbed_llm_transport();
1404        let runtime = tokio::runtime::Builder::new_multi_thread()
1405            .enable_all()
1406            .worker_threads(2)
1407            .build()
1408            .expect("runtime");
1409
1410        runtime.block_on(async {
1411            let request_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1412            let server = spawn_openai_empty_stub(request_count.clone());
1413            install_openai_stub_provider("empty-alone", server.addr());
1414            let mut opts = base_opts("empty-alone");
1415            opts.model = "empty-alone-model".to_string();
1416            opts.stream = false;
1417
1418            let local = tokio::task::LocalSet::new();
1419            let error = local
1420                .run_until(crate::llm::agent_observe::observed_llm_call(
1421                    &opts, None, None, None, false, false, None, None,
1422                ))
1423                .await
1424                .expect_err("an empty route with no backup must exhaust");
1425
1426            assert_eq!(request_count.load(std::sync::atomic::Ordering::SeqCst), 2);
1427            let consumer_error =
1428                crate::llm::call::build_llm_error_dict(&error, &opts.provider, &opts.model);
1429            let consumer_fields = consumer_error
1430                .as_dict()
1431                .expect("llm_call consumer error envelope");
1432            assert_eq!(
1433                consumer_fields
1434                    .get("code")
1435                    .map(crate::value::VmValue::display),
1436                Some("provider_exhausted".to_string()),
1437                "llm_call must preserve the dispatch-owned typed error"
1438            );
1439            assert!(
1440                matches!(
1441                    consumer_fields.get("attempts"),
1442                    Some(crate::value::VmValue::List(attempts)) if attempts.len() == 1
1443                ),
1444                "llm_call must preserve the complete attempted-route receipt"
1445            );
1446            let crate::value::VmError::Thrown(crate::value::VmValue::Dict(fields)) = error else {
1447                panic!("expected structured provider exhaustion");
1448            };
1449            assert_eq!(
1450                fields.get("code").map(crate::value::VmValue::display),
1451                Some("provider_exhausted".to_string())
1452            );
1453            assert_eq!(
1454                fields.get("reason").map(crate::value::VmValue::display),
1455                Some("empty_generation".to_string())
1456            );
1457            assert_eq!(
1458                fields
1459                    .get("attempt_count")
1460                    .and_then(crate::value::VmValue::as_int),
1461                Some(2)
1462            );
1463            let Some(crate::value::VmValue::List(attempts)) = fields.get("attempts") else {
1464                panic!("expected attempted route ledger");
1465            };
1466            assert_eq!(attempts.len(), 1);
1467            let attempt = attempts[0].as_dict().expect("attempt receipt");
1468            assert_eq!(
1469                attempt.get("provider").map(crate::value::VmValue::display),
1470                Some("empty-alone".to_string())
1471            );
1472            assert_eq!(
1473                attempt
1474                    .get("attempt_count")
1475                    .and_then(crate::value::VmValue::as_int),
1476                Some(2)
1477            );
1478            assert!(
1479                attempt
1480                    .get("duration_ms")
1481                    .and_then(crate::value::VmValue::as_int)
1482                    .is_some(),
1483                "the terminal chain must retain measured route latency"
1484            );
1485
1486            crate::llm_config::clear_user_overrides();
1487            drop(server);
1488        });
1489    }
1490
1491    #[test]
1492    fn direct_vm_call_entrypoint_honors_routing_policy() {
1493        use crate::llm::fake::{
1494            install_fake_llm_script, FakeLlmEvent, FakeLlmScript, FakeLlmTurn, FakeStopReason,
1495        };
1496
1497        let runtime = tokio::runtime::Builder::new_current_thread()
1498            .enable_all()
1499            .build()
1500            .expect("runtime");
1501        runtime.block_on(async {
1502            let transcript_dir = tempfile::tempdir().expect("transcript tempdir");
1503            crate::llm::agent_observe::push_llm_transcript_dir(
1504                transcript_dir.path().to_str().expect("utf8 tempdir"),
1505            );
1506            let _fake = install_fake_llm_script(
1507                FakeLlmScript::new()
1508                    .push(FakeLlmTurn::error(
1509                        crate::value::ErrorCategory::CircuitOpen,
1510                        "primary route unavailable",
1511                    ))
1512                    .push(FakeLlmTurn::stream(vec![
1513                        FakeLlmEvent::Token("direct entrypoint recovered".into()),
1514                        FakeLlmEvent::Done(FakeStopReason::EndTurn),
1515                    ])),
1516            );
1517            let mut opts = base_opts("fake");
1518            opts.model = "fake-primary".to_string();
1519            opts.routing_policy = crate::llm::routing::build_transport_failover_policy(
1520                &opts.provider,
1521                &opts.model,
1522                &[super::LlmRouteFallback {
1523                    provider: "fake".to_string(),
1524                    model: "fake-backup".to_string(),
1525                }],
1526                &[],
1527            );
1528
1529            let result = vm_call_llm_full(&opts)
1530                .await
1531                .expect("direct VM caller must use the configured routing chain");
1532            crate::llm::agent_observe::pop_llm_transcript_dir();
1533            assert_eq!(result.text, "direct entrypoint recovered");
1534            assert_eq!(result.model, "fake-backup");
1535
1536            let transcript =
1537                std::fs::read_to_string(transcript_dir.path().join("llm_transcript.jsonl"))
1538                    .expect("routing transcript");
1539            let requests: Vec<serde_json::Value> = transcript
1540                .lines()
1541                .map(|line| serde_json::from_str(line).expect("valid transcript JSON"))
1542                .filter(|event: &serde_json::Value| event["type"] == "provider_call_request")
1543                .collect();
1544            assert_eq!(
1545                requests.len(),
1546                2,
1547                "each physical route must emit exactly one request; no outer logical-call phantom"
1548            );
1549            assert_eq!(requests[0]["model"], "fake-primary");
1550            assert_eq!(requests[1]["model"], "fake-backup");
1551        });
1552    }
1553
1554    #[test]
1555    fn routing_stream_fails_over_before_output_and_emits_only_backup_text() {
1556        use crate::llm::fake::{
1557            install_fake_llm_script, FakeLlmEvent, FakeLlmScript, FakeLlmTurn, FakeStopReason,
1558        };
1559
1560        let runtime = tokio::runtime::Builder::new_current_thread()
1561            .enable_all()
1562            .build()
1563            .expect("runtime");
1564        runtime.block_on(async {
1565            let _fake = install_fake_llm_script(
1566                FakeLlmScript::new()
1567                    .push(FakeLlmTurn::error(
1568                        crate::value::ErrorCategory::CircuitOpen,
1569                        "primary unavailable before output",
1570                    ))
1571                    .push(FakeLlmTurn::stream(vec![
1572                        FakeLlmEvent::Token("backup only".into()),
1573                        FakeLlmEvent::Done(FakeStopReason::EndTurn),
1574                    ])),
1575            );
1576            let mut opts = base_opts("fake");
1577            opts.model = "fake-primary".to_string();
1578            opts.routing_policy = crate::llm::routing::build_transport_failover_policy(
1579                &opts.provider,
1580                &opts.model,
1581                &[super::LlmRouteFallback {
1582                    provider: "fake".to_string(),
1583                    model: "fake-backup".to_string(),
1584                }],
1585                &[],
1586            );
1587            let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1588            let result = vm_call_llm_full_streaming(&opts, tx)
1589                .await
1590                .expect("pre-output failure should recover on backup");
1591            let mut deltas = Vec::new();
1592            while let Ok(delta) = rx.try_recv() {
1593                deltas.push(delta);
1594            }
1595            assert_eq!(result.text, "backup only");
1596            assert_eq!(deltas, vec!["backup only".to_string()]);
1597            assert_eq!(crate::llm::fake::fake_llm_captured_calls().len(), 2);
1598        });
1599    }
1600
1601    #[test]
1602    fn routing_stream_never_splices_backup_after_primary_output() {
1603        use crate::llm::fake::{
1604            install_fake_llm_script, FakeLlmError, FakeLlmEvent, FakeLlmScript,
1605        };
1606
1607        let runtime = tokio::runtime::Builder::new_current_thread()
1608            .enable_all()
1609            .build()
1610            .expect("runtime");
1611        runtime.block_on(async {
1612            let _fake = install_fake_llm_script(FakeLlmScript::streaming(vec![
1613                FakeLlmEvent::Token("partial primary".into()),
1614                FakeLlmEvent::Error(FakeLlmError::new(
1615                    crate::value::ErrorCategory::TransientNetwork,
1616                    "connection reset after response bytes",
1617                )),
1618            ]));
1619            let mut opts = base_opts("fake");
1620            opts.model = "fake-primary".to_string();
1621            opts.routing_policy = crate::llm::routing::build_transport_failover_policy(
1622                &opts.provider,
1623                &opts.model,
1624                &[super::LlmRouteFallback {
1625                    provider: "fake".to_string(),
1626                    model: "fake-backup".to_string(),
1627                }],
1628                &[],
1629            );
1630            let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1631            vm_call_llm_full_streaming(&opts, tx)
1632                .await
1633                .expect_err("a committed primary stream must surface its own failure");
1634            let mut deltas = Vec::new();
1635            while let Ok(delta) = rx.try_recv() {
1636                deltas.push(delta);
1637            }
1638            assert_eq!(deltas, vec!["partial primary".to_string()]);
1639            assert_eq!(
1640                crate::llm::fake::fake_llm_captured_calls().len(),
1641                1,
1642                "no backup call may run after public output commits the primary"
1643            );
1644        });
1645    }
1646
1647    #[test]
1648    fn ollama_chat_applies_env_runtime_overrides() {
1649        let _guard = env_guard();
1650        let _allow_llm_transport = allow_stubbed_llm_transport();
1651        let runtime = tokio::runtime::Builder::new_multi_thread()
1652            .enable_all()
1653            .worker_threads(2)
1654            .build()
1655            .expect("runtime");
1656
1657        runtime.block_on(async {
1658            let captured = std::sync::Arc::new(std::sync::Mutex::new(None));
1659            let server = spawn_ollama_stub_with_body_capture(captured.clone());
1660            let addr = server.addr();
1661            let prev_ollama_host = std::env::var("OLLAMA_HOST").ok();
1662            let prev_num_ctx = std::env::var("HARN_OLLAMA_NUM_CTX").ok();
1663            let prev_keep_alive = std::env::var("HARN_OLLAMA_KEEP_ALIVE").ok();
1664            unsafe {
1665                std::env::set_var("OLLAMA_HOST", format!("http://{addr}"));
1666                std::env::set_var("HARN_OLLAMA_NUM_CTX", "131072");
1667                std::env::set_var("HARN_OLLAMA_KEEP_ALIVE", "forever");
1668            }
1669
1670            let local = tokio::task::LocalSet::new();
1671            let result = local
1672                .run_until(async {
1673                    let opts = base_opts("ollama");
1674                    let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1675                    vm_call_llm_full_streaming_offthread(&opts, tx)
1676                        .await
1677                        .expect("llm call should succeed")
1678                })
1679                .await;
1680
1681            match prev_ollama_host {
1682                Some(value) => unsafe { std::env::set_var("OLLAMA_HOST", value) },
1683                None => unsafe { std::env::remove_var("OLLAMA_HOST") },
1684            }
1685            match prev_num_ctx {
1686                Some(value) => unsafe { std::env::set_var("HARN_OLLAMA_NUM_CTX", value) },
1687                None => unsafe { std::env::remove_var("HARN_OLLAMA_NUM_CTX") },
1688            }
1689            match prev_keep_alive {
1690                Some(value) => unsafe { std::env::set_var("HARN_OLLAMA_KEEP_ALIVE", value) },
1691                None => unsafe { std::env::remove_var("HARN_OLLAMA_KEEP_ALIVE") },
1692            }
1693
1694            drop(server);
1695            assert_eq!(result.text, "ok");
1696            let body = captured
1697                .lock()
1698                .expect("captured body")
1699                .clone()
1700                .expect("request body");
1701            let json: serde_json::Value = serde_json::from_str(&body).expect("valid request json");
1702            assert_eq!(json["keep_alive"].as_i64(), Some(-1));
1703            assert_eq!(json["options"]["num_ctx"].as_u64(), Some(131072));
1704        });
1705    }
1706
1707    #[test]
1708    fn ollama_qwen_text_tool_route_bypasses_chat_parser_with_raw_generate() {
1709        let _guard = env_guard();
1710        let _allow_llm_transport = allow_stubbed_llm_transport();
1711        let runtime = tokio::runtime::Builder::new_multi_thread()
1712            .enable_all()
1713            .worker_threads(2)
1714            .build()
1715            .expect("runtime");
1716
1717        runtime.block_on(async {
1718            let captured = std::sync::Arc::new(std::sync::Mutex::new(None));
1719            let server = spawn_ollama_raw_generate_stub(captured.clone());
1720            let addr = server.addr();
1721            let prev_ollama_host = std::env::var("OLLAMA_HOST").ok();
1722            unsafe {
1723                std::env::set_var("OLLAMA_HOST", format!("http://{addr}"));
1724            }
1725
1726            let local = tokio::task::LocalSet::new();
1727            let result = local
1728                .run_until(async {
1729                    let mut opts = base_opts("ollama");
1730                    opts.model = "qwen3.5:35b-a3b-coding-nvfp4".to_string();
1731                    opts.native_tools = None;
1732                    opts.output_format = crate::llm::api::OutputFormat::Text;
1733                    opts.response_format = None;
1734                    opts.json_schema = None;
1735                    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1736                    let result = vm_call_llm_full_streaming_offthread(&opts, tx)
1737                        .await
1738                        .expect("raw-generate route should succeed");
1739                    let mut deltas = Vec::new();
1740                    while let Ok(delta) = rx.try_recv() {
1741                        deltas.push(delta);
1742                    }
1743                    (result, deltas)
1744                })
1745                .await;
1746
1747            match prev_ollama_host {
1748                Some(value) => unsafe { std::env::set_var("OLLAMA_HOST", value) },
1749                None => unsafe { std::env::remove_var("OLLAMA_HOST") },
1750            }
1751
1752            drop(server);
1753            let (result, deltas) = result;
1754            assert_eq!(
1755                result.text,
1756                "<tool_call>\nedit({ path: \"a.rs\" })\n</tool_call>"
1757            );
1758            assert_eq!(deltas.join(""), result.text);
1759            assert_eq!(result.model, "qwen3.5:stub");
1760            assert_eq!(result.input_tokens, 7);
1761            assert_eq!(result.output_tokens, 11);
1762            assert_eq!(result.stop_reason.as_deref(), Some("stop"));
1763
1764            let body = captured
1765                .lock()
1766                .expect("captured body")
1767                .clone()
1768                .expect("request body");
1769            let json: serde_json::Value = serde_json::from_str(&body).expect("valid request json");
1770            assert_eq!(json["raw"].as_bool(), Some(true));
1771            assert!(json["prompt"]
1772                .as_str()
1773                .unwrap_or_default()
1774                .contains("<|im_start|>assistant\n"));
1775            assert!(json.get("chat_template_kwargs").is_none());
1776        });
1777    }
1778
1779    #[test]
1780    fn ollama_warmup_applies_shared_runtime_settings() {
1781        let _guard = env_guard();
1782        let _allow_llm_transport = allow_stubbed_llm_transport();
1783        let runtime = tokio::runtime::Builder::new_multi_thread()
1784            .enable_all()
1785            .worker_threads(2)
1786            .build()
1787            .expect("runtime");
1788
1789        runtime.block_on(async {
1790            let captured = std::sync::Arc::new(std::sync::Mutex::new(None));
1791            let server = spawn_ollama_stub_with_body_capture(captured.clone());
1792            let addr = server.addr();
1793            let _num_ctx = ScopedEnvVar::set("HARN_OLLAMA_NUM_CTX", "65536");
1794            let _keep_alive = ScopedEnvVar::set("HARN_OLLAMA_KEEP_ALIVE", "forever");
1795
1796            super::ollama::warm_ollama_model("qwen3.5:35b", Some(&format!("http://{addr}")))
1797                .await
1798                .expect("warmup should succeed");
1799
1800            drop(server);
1801            let body = captured
1802                .lock()
1803                .expect("captured body")
1804                .clone()
1805                .expect("request body");
1806            let json: serde_json::Value = serde_json::from_str(&body).expect("valid request json");
1807            assert_eq!(json["model"].as_str(), Some("qwen3.5:35b"));
1808            assert_eq!(json["keep_alive"].as_i64(), Some(-1));
1809            assert_eq!(json["options"]["num_ctx"].as_u64(), Some(65536));
1810        });
1811    }
1812
1813    /// Bind a stub listener that serves one canned HTTP error response.
1814    /// The returned [`LlmStub`] guard owns the listener and the worker
1815    /// thread, so dropping it (test exit, panic) signals shutdown and
1816    /// joins — a stuck or misrouted client can never wedge the suite.
1817    fn spawn_openai_error_stub(
1818        status_line: &'static str,
1819        extra_headers: &'static str,
1820        body: &'static str,
1821    ) -> LlmStub {
1822        spawn_llm_stub("openai error stub", move |stream| {
1823            use std::io::{Read, Write};
1824            let mut buf = vec![0u8; 16384];
1825            let _ = stream.read(&mut buf);
1826            let response = format!(
1827                "{status_line}\r\ncontent-type: application/json\r\ncontent-length: {}\r\n{extra_headers}connection: close\r\n\r\n{body}",
1828                body.len()
1829            );
1830            let _ = stream.write_all(response.as_bytes());
1831            let _ = stream.flush();
1832        })
1833    }
1834
1835    /// Single-entrypoint helper that serializes env-var mutation and the
1836    /// LLM call behind `env_lock`, so parallel streaming error tests can't
1837    /// clobber each other's `LOCAL_LLM_BASE_URL` and leak an unconnected
1838    /// stub whose `join()` would hang the test binary.
1839    fn run_streaming_error_case(
1840        status_line: &'static str,
1841        extra_headers: &'static str,
1842        body: &'static str,
1843    ) -> String {
1844        let _guard = env_guard();
1845        let _allow_llm_transport = allow_stubbed_llm_transport();
1846        let server = spawn_openai_error_stub(status_line, extra_headers, body);
1847        let addr = server.addr();
1848        let prev = std::env::var("LOCAL_LLM_BASE_URL").ok();
1849        unsafe {
1850            std::env::set_var("LOCAL_LLM_BASE_URL", format!("http://{addr}"));
1851        }
1852        let runtime = tokio::runtime::Builder::new_multi_thread()
1853            .enable_all()
1854            .worker_threads(2)
1855            .build()
1856            .expect("runtime");
1857        let err = runtime.block_on(async {
1858            let local = tokio::task::LocalSet::new();
1859            local
1860                .run_until(async {
1861                    let mut opts = base_opts("local");
1862                    opts.tools = None;
1863                    opts.native_tools = None;
1864                    opts.tool_choice = None;
1865                    opts.output_format = crate::llm::api::OutputFormat::Text;
1866                    opts.response_format = None;
1867                    opts.json_schema = None;
1868                    opts.output_schema = None;
1869                    let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1870                    let call = tokio::time::timeout(
1871                        // Must stay inside the stub's accept window.
1872                        std::time::Duration::from_secs(30),
1873                        vm_call_llm_full_streaming_offthread(&opts, tx),
1874                    )
1875                    .await;
1876                    match call {
1877                        Ok(Ok(_)) => panic!("expected streaming call to fail"),
1878                        Ok(Err(err)) => err.to_string(),
1879                        Err(elapsed) => panic!("streaming call timed out ({elapsed})"),
1880                    }
1881                })
1882                .await
1883        });
1884        match prev {
1885            Some(v) => unsafe { std::env::set_var("LOCAL_LLM_BASE_URL", v) },
1886            None => unsafe { std::env::remove_var("LOCAL_LLM_BASE_URL") },
1887        }
1888        drop(server);
1889        err
1890    }
1891
1892    #[test]
1893    fn streaming_path_classifies_context_overflow() {
1894        let err = run_streaming_error_case(
1895            "HTTP/1.1 400 Bad Request",
1896            "",
1897            r#"{"error":{"message":"This model's maximum context length is 8192 tokens. However, your prompt is too long."}}"#,
1898        );
1899        assert!(err.contains("[context_overflow]"), "err was: {err}");
1900        assert!(err.contains("local HTTP 400"), "err was: {err}");
1901    }
1902
1903    #[test]
1904    fn streaming_path_classifies_rate_limit_with_retry_after() {
1905        let err = run_streaming_error_case(
1906            "HTTP/1.1 429 Too Many Requests",
1907            "retry-after: 7\r\n",
1908            r#"{"error":{"type":"rate_limit_error","message":"slow down"}}"#,
1909        );
1910        assert!(err.contains("[rate_limited]"), "err was: {err}");
1911        assert!(err.contains("(retry-after: 7)"), "err was: {err}");
1912    }
1913
1914    #[test]
1915    fn streaming_path_classifies_opaque_500_as_http_error() {
1916        let err = run_streaming_error_case(
1917            "HTTP/1.1 500 Internal Server Error",
1918            "",
1919            r#"{"error":"upstream exploded"}"#,
1920        );
1921        assert!(err.contains("[http_error]"), "err was: {err}");
1922        assert!(err.contains("upstream exploded"), "err was: {err}");
1923    }
1924}