1mod 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
29pub(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
119fn 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
139pub(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
150pub(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
185pub(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#[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 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 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 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 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 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 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 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 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 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 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 struct LlmStub {
803 addr: std::net::SocketAddr,
804 shutdown: std::sync::Arc<std::sync::atomic::AtomicBool>,
805 handle: Option<std::thread::JoinHandle<()>>,
806 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 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 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 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 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 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}