1use std::pin::Pin;
9use std::sync::{Arc, Mutex};
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12use futures::{Stream, StreamExt};
13use rig::client::{CompletionClient, ModelListingClient};
14use rig::completion::{
15 CompletionError, CompletionModel, CompletionRequest, FinishReason, ToolDefinition,
16};
17use rig::http_client::{HeaderMap, HeaderValue};
18use rig::message::{
19 AssistantContent, Message, Reasoning, ReasoningContent, ToolCall, ToolCallId, ToolFunction,
20 UserContent,
21};
22use rig::providers::openai;
23use rig::streaming::{StreamedAssistantContent, StreamingCompletionResponse};
24
25use crate::event::LlmStreamEvent;
26use crate::message::{AgentMessage, ContentBlock, StopReason};
27
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub enum LlmEndpoint {
30 #[default]
31 Chat,
32 Responses,
33}
34
35#[derive(Clone, Debug, Default)]
36pub struct LlmOpts {
37 pub model: String,
38 pub base_url: Option<String>,
39 pub api_key: Option<String>,
40 pub temperature: Option<f64>,
43 pub endpoint: LlmEndpoint,
44 pub session_id: Option<String>,
47 pub effort: Option<String>,
54 pub retry: RetryPolicy,
57}
58
59pub type LlmStream = Pin<Box<dyn Stream<Item = LlmStreamEvent> + Send>>;
60
61pub type StreamFn =
62 Arc<dyn Fn(&[AgentMessage], &str, &[ToolDefinition], &LlmOpts) -> LlmStream + Send + Sync>;
63
64#[derive(Clone, PartialEq, Eq)]
70struct ClientKey {
71 base_url: Option<String>,
72 api_key: Option<String>,
73 session_id: Option<String>,
74}
75
76impl ClientKey {
77 fn of(opts: &LlmOpts) -> Self {
78 Self {
79 base_url: opts.base_url.clone(),
80 api_key: opts.api_key.clone(),
81 session_id: opts.session_id.clone(),
82 }
83 }
84}
85
86#[derive(Default)]
90struct ClientCache {
91 slot: Mutex<Option<(ClientKey, openai::Client)>>,
92 #[cfg(test)]
93 builds: std::sync::atomic::AtomicUsize,
94}
95
96impl ClientCache {
97 fn get(&self, opts: &LlmOpts) -> Result<openai::Client, String> {
99 let key = ClientKey::of(opts);
100 let mut slot = self.slot.lock().unwrap_or_else(|e| e.into_inner());
101 if let Some((cached_key, client)) = slot.as_ref()
102 && *cached_key == key
103 {
104 return Ok(client.clone());
105 }
106 let client = openai_client(opts)?;
107 #[cfg(test)]
108 self.builds
109 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
110 *slot = Some((key, client.clone()));
111 Ok(client)
112 }
113}
114
115pub fn rig_stream_fn() -> StreamFn {
120 let cache = ClientCache::default();
121 Arc::new(move |messages, system, tools, opts| adapt(&cache, opts, messages, system, tools))
122}
123
124fn openai_client(opts: &LlmOpts) -> Result<rig::providers::openai::Client, String> {
127 let key = opts
128 .api_key
129 .clone()
130 .or_else(|| std::env::var("OPENAI_API_KEY").ok());
131 let key = match key {
132 Some(k) => k,
133 None => {
134 let local = opts.base_url.as_deref().is_some_and(|url| {
138 let u = url.to_ascii_lowercase();
139 ["http://localhost", "http://127.0.0.1", "http://[::1]"]
140 .iter()
141 .any(|prefix| u.starts_with(prefix))
142 });
143 if local {
144 "wcode-local".to_string()
145 } else {
146 return Err(
147 "no API key: pass LlmOpts.api_key or set OPENAI_API_KEY (a localhost base_url works keyless)"
148 .to_string(),
149 );
150 }
151 }
152 };
153 let mut builder = openai::Client::builder()
154 .api_key::<rig::client::BearerAuth>(key)
155 .http_headers(session_headers(opts));
156 if let Some(base_url) = &opts.base_url {
157 builder = builder.base_url(base_url);
158 }
159 builder.build().map_err(|e| format!("openai client: {e}"))
160}
161
162fn session_headers(opts: &LlmOpts) -> HeaderMap {
165 let mut headers = HeaderMap::new();
166 headers.insert(
167 "user-agent",
168 HeaderValue::from_static(concat!("wcode/", env!("CARGO_PKG_VERSION"))),
169 );
170 if let Some(id) = opts.session_id.as_deref()
171 && let Ok(value) = HeaderValue::from_str(id)
172 {
173 headers.insert("x-opencode-session", value);
174 }
175 headers
176}
177
178pub async fn list_models(opts: &LlmOpts) -> Result<Vec<String>, String> {
183 let client = openai_client(opts)?;
184 let list = client.list_models().await.map_err(|e| e.to_string())?;
185 let mut ids: Vec<String> = list.iter().map(|m| m.id.clone()).collect();
186 ids.sort();
187 Ok(ids)
188}
189
190fn adapt(
191 cache: &ClientCache,
192 opts: &LlmOpts,
193 messages: &[AgentMessage],
194 system: &str,
195 tools: &[ToolDefinition],
196) -> LlmStream {
197 if let Some(message) = invalid_effort(opts) {
198 return error_stream(message);
199 }
200 let client = match cache.get(opts) {
201 Ok(client) => client,
202 Err(message) => return error_stream(message),
203 };
204 let request = build_request(messages, system, tools, opts);
205
206 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<LlmStreamEvent>();
213 let Ok(runtime) = tokio::runtime::Handle::try_current() else {
214 return error_stream("StreamFn requires a tokio runtime".to_string());
215 };
216 let policy = opts.retry;
217 let endpoint = opts.endpoint;
218 let model = opts.model.clone();
219 runtime.spawn(async move {
220 let open = || open_stream(client.clone(), endpoint, &model, request.clone());
221 let mut state = RetryState::new(policy.max);
226 let (stream, pending) = match connect_with_retry(&policy, &mut state, &open, &tx).await {
227 PreContent::Ready { stream, first } => (stream, first),
228 PreContent::Cancelled => return,
229 PreContent::Failed(error) => {
230 let _ = tx.send(LlmStreamEvent::Error {
231 message: error.to_string(),
232 fatal: fatal_class(&error),
233 });
234 return;
235 }
236 };
237 forward_stream(stream, pending, policy, &open, tx, state).await;
238 });
239 Box::pin(futures::stream::unfold(rx, |mut rx| async move {
240 rx.recv().await.map(|ev| (ev, rx))
241 }))
242}
243
244async fn forward_stream<S, F>(
250 mut stream: S,
251 mut pending: Option<Result<StreamedAssistantContent, CompletionError>>,
252 policy: RetryPolicy,
253 open: F,
254 tx: tokio::sync::mpsc::UnboundedSender<LlmStreamEvent>,
255 mut state: RetryState,
256) where
257 F: Fn() -> Pin<Box<dyn std::future::Future<Output = Result<S, CompletionError>> + Send>>,
258 S: futures::Stream<Item = Result<StreamedAssistantContent, CompletionError>> + Unpin + Send,
259{
260 let mut errored = false;
261 let mut content_forwarded = false;
262 let mut done_seen = false;
263 loop {
264 let item = match pending.take() {
270 Some(item) => item,
271 None => tokio::select! {
272 biased;
273 result = next_or_stall(&mut stream, policy.idle) => match result {
274 None => {
280 if done_seen || errored {
281 break;
282 }
283 let error = truncation_error();
284 if !content_forwarded && state.budget > 0 {
285 match pre_content_retry(&policy, &mut state, &open, &tx, &error).await {
288 PreContent::Ready { stream: s, first } => {
289 stream = s;
290 pending = first;
291 continue;
292 }
293 PreContent::Cancelled => return,
294 PreContent::Failed(e) => {
295 if tx
296 .send(LlmStreamEvent::Error {
297 message: e.to_string(),
298 fatal: fatal_class(&e),
299 })
300 .is_err()
301 {
302 return;
303 }
304 break;
305 }
306 }
307 } else {
308 if tx
312 .send(LlmStreamEvent::Error {
313 message: error.to_string(),
314 fatal: false,
315 })
316 .is_err()
317 {
318 return;
319 }
320 break;
321 }
322 }
323 Some(Stall::Item(item)) => item,
325 Some(Stall::Idle) => {
330 let error = stall_error(policy.idle);
331 if !content_forwarded && state.budget > 0 {
332 match pre_content_retry(&policy, &mut state, &open, &tx, &error).await {
333 PreContent::Ready { stream: s, first } => {
334 stream = s;
335 pending = first;
336 continue;
337 }
338 PreContent::Cancelled => return,
339 PreContent::Failed(e) => {
340 let fatal = fatal_class(&e);
341 if tx
342 .send(LlmStreamEvent::Error {
343 message: e.to_string(),
344 fatal,
345 })
346 .is_err()
347 {
348 return;
349 }
350 break;
351 }
352 }
353 } else {
354 if tx
355 .send(LlmStreamEvent::Error {
356 message: error.to_string(),
357 fatal: false,
358 })
359 .is_err()
360 {
361 return;
362 }
363 break;
364 }
365 }
366 },
367 _ = tx.closed() => break,
368 },
369 };
370 let events = match item {
371 Ok(content) => {
372 let is_final = matches!(content, StreamedAssistantContent::Final(_));
373 if errored && is_final {
374 continue;
375 }
376 let events = map_item(content);
377 content_forwarded |= !events.is_empty();
381 done_seen |= is_final;
382 events
383 }
384 Err(error) => {
385 if !content_forwarded && state.budget > 0 && retryable(&error) {
386 state.attempts += 1;
390 state.budget -= 1;
391 if !retry_wait(&tx, &policy, state.attempts, &error).await {
392 return;
393 }
394 match connect_with_retry(&policy, &mut state, &open, &tx).await {
395 PreContent::Ready { stream: s, first } => {
396 stream = s;
397 pending = first;
398 continue;
399 }
400 PreContent::Cancelled => return,
401 PreContent::Failed(e) => {
402 errored = true;
403 vec![LlmStreamEvent::Error {
404 message: e.to_string(),
405 fatal: fatal_class(&e),
406 }]
407 }
408 }
409 } else {
410 errored = true;
414 vec![LlmStreamEvent::Error {
415 message: error.to_string(),
416 fatal: fatal_class(&error),
417 }]
418 }
419 }
420 };
421 for ev in events {
422 if tx.send(ev).is_err() {
423 return;
424 }
425 }
426 }
427}
428
429fn error_stream(message: String) -> LlmStream {
430 Box::pin(futures::stream::iter(vec![LlmStreamEvent::Error {
433 message,
434 fatal: true,
435 }]))
436}
437
438#[derive(Clone, Copy, Debug, PartialEq, Eq)]
441pub struct RetryPolicy {
442 pub max: u32,
444 pub base: Duration,
446 pub cap: Duration,
448 pub ttft: Duration,
450 pub idle: Duration,
452}
453
454impl Default for RetryPolicy {
455 fn default() -> Self {
456 Self {
457 max: 3,
458 base: Duration::from_millis(500),
459 cap: Duration::from_secs(8),
460 ttft: Duration::from_secs(60),
461 idle: Duration::from_secs(120),
462 }
463 }
464}
465
466enum Stall<T> {
468 Item(T),
471 Idle,
473}
474
475async fn next_or_stall<S: Stream + Unpin>(
479 stream: &mut S,
480 deadline: Duration,
481) -> Option<Stall<S::Item>> {
482 if deadline.is_zero() {
483 return stream.next().await.map(Stall::Item);
484 }
485 tokio::select! {
486 biased;
487 item = stream.next() => item.map(Stall::Item),
488 _ = tokio::time::sleep(deadline) => Some(Stall::Idle),
489 }
490}
491fn is_transient_status(code: u16) -> bool {
494 matches!(code, 408 | 409 | 429 | 500 | 502 | 503 | 504)
495}
496
497fn retryable(error: &CompletionError) -> bool {
501 if let Some(status) = error.provider_response_status() {
502 return is_transient_status(status.as_u16());
503 }
504 match error {
505 CompletionError::HttpError(_) => true,
507 CompletionError::ProviderError(message) => is_transient_message(message),
511 _ => false,
512 }
513}
514
515fn is_transient_message(message: &str) -> bool {
518 const NEEDLES: [&str; 10] = [
519 "error sending request",
520 "error trying to connect",
521 "connection refused",
522 "connection reset",
523 "connection closed",
524 "timed out",
525 "timeout",
526 "unreachable",
527 "dns error",
528 "temporary failure",
529 ];
530 let m = message.to_ascii_lowercase();
531 NEEDLES.iter().any(|needle| m.contains(needle))
532}
533
534fn backoff(attempt: u32, base: Duration, cap: Duration) -> Duration {
537 let shift = attempt.saturating_sub(1).min(16);
538 let capped = base.saturating_mul(1u32 << shift).min(cap);
539 let millis = capped.as_millis() as u64;
540 if millis <= 1 {
541 return capped;
542 }
543 let jitter = clock_nanos() % (millis / 4 + 1);
544 Duration::from_millis(millis - jitter)
545}
546
547fn clock_nanos() -> u64 {
549 SystemTime::now()
550 .duration_since(UNIX_EPOCH)
551 .map(|d| u64::from(d.subsec_nanos()))
552 .unwrap_or(0)
553}
554
555fn open_stream(
558 client: rig::providers::openai::Client,
559 endpoint: LlmEndpoint,
560 model: &str,
561 request: CompletionRequest,
562) -> Pin<Box<dyn std::future::Future<Output = Result<StreamingCompletionResponse, CompletionError>> + Send>> {
563 match endpoint {
564 LlmEndpoint::Chat => {
565 let model = client.completions_api().completion_model(model.to_string());
566 Box::pin(async move { model.stream(request).await })
567 }
568 LlmEndpoint::Responses => {
569 let model = client.completion_model(model.to_string());
570 Box::pin(async move { model.stream(request).await })
571 }
572 }
573}
574
575enum PreContent<S> {
579 Ready {
580 stream: S,
581 first: Option<Result<StreamedAssistantContent, CompletionError>>,
582 },
583 Failed(CompletionError),
585 Cancelled,
587}
588
589#[derive(Clone, Copy)]
593struct RetryState {
594 budget: u32,
595 attempts: u32,
596}
597
598impl RetryState {
599 fn new(max: u32) -> Self {
600 RetryState {
601 budget: max,
602 attempts: 0,
603 }
604 }
605}
606
607fn fatal_class(error: &CompletionError) -> bool {
614 !retryable(error)
615}
616
617fn stall_error(d: Duration) -> CompletionError {
631 CompletionError::ProviderError(format!("stream stalled: no item within {d:?} (timed out)"))
632}
633fn truncation_error() -> CompletionError {
640 CompletionError::ProviderError(
641 "stream ended before its terminal record (truncated)".to_string(),
642 )
643}
644
645async fn connect_with_retry<S, F>(
646 policy: &RetryPolicy,
647 state: &mut RetryState,
648 mut open: F,
649 tx: &tokio::sync::mpsc::UnboundedSender<LlmStreamEvent>,
650) -> PreContent<S>
651where
652 F: FnMut() -> Pin<Box<dyn std::future::Future<Output = Result<S, CompletionError>> + Send>>,
653 S: futures::Stream<Item = Result<StreamedAssistantContent, CompletionError>> + Unpin + Send,
654{
655 loop {
656 state.attempts += 1;
657 let opened = if policy.ttft.is_zero() {
661 Ok(open().await)
662 } else {
663 tokio::select! {
664 biased;
665 r = open() => Ok(r),
666 _ = tokio::time::sleep(policy.ttft) => Err(()),
667 }
668 };
669 let mut stream = match opened {
670 Ok(Ok(stream)) => stream,
671 Ok(Err(error)) => {
672 if state.budget > 0 && retryable(&error) {
673 state.budget -= 1;
674 if !retry_wait(tx, policy, state.attempts, &error).await {
675 return PreContent::Cancelled;
676 }
677 continue;
678 }
679 return PreContent::Failed(error);
680 }
681 Err(()) => {
682 let error = stall_error(policy.ttft);
683 if state.budget > 0 {
684 state.budget -= 1;
685 if !retry_wait(tx, policy, state.attempts, &error).await {
686 return PreContent::Cancelled;
687 }
688 continue;
689 }
690 return PreContent::Failed(error);
691 }
692 };
693 match next_or_stall(&mut stream, policy.ttft).await {
696 None => return PreContent::Ready { stream, first: None },
697 Some(Stall::Item(Err(error))) => {
698 if state.budget > 0 && retryable(&error) {
699 state.budget -= 1;
700 if !retry_wait(tx, policy, state.attempts, &error).await {
701 return PreContent::Cancelled;
702 }
703 continue;
704 }
705 return PreContent::Ready {
709 stream,
710 first: Some(Err(error)),
711 };
712 }
713 Some(Stall::Item(Ok(content))) => {
714 return PreContent::Ready {
715 stream,
716 first: Some(Ok(content)),
717 };
718 }
719 Some(Stall::Idle) => {
720 let error = stall_error(policy.ttft);
721 if state.budget > 0 {
722 state.budget -= 1;
723 if !retry_wait(tx, policy, state.attempts, &error).await {
724 return PreContent::Cancelled;
725 }
726 continue;
727 }
728 return PreContent::Failed(error);
729 }
730 }
731 }
732}
733
734async fn pre_content_retry<S, F>(
741 policy: &RetryPolicy,
742 state: &mut RetryState,
743 open: &F,
744 tx: &tokio::sync::mpsc::UnboundedSender<LlmStreamEvent>,
745 error: &CompletionError,
746) -> PreContent<S>
747where
748 F: Fn() -> Pin<Box<dyn std::future::Future<Output = Result<S, CompletionError>> + Send>>,
749 S: futures::Stream<Item = Result<StreamedAssistantContent, CompletionError>> + Unpin + Send,
750{
751 state.attempts += 1;
752 state.budget -= 1;
753 if !retry_wait(tx, policy, state.attempts, error).await {
754 return PreContent::Cancelled;
755 }
756 connect_with_retry(policy, state, open, tx).await
757}
758
759fn retry_after(error: &CompletionError) -> Option<Duration> {
769 let raw = error.provider_response_headers()?.get("retry-after")?;
773 let seconds: u64 = raw.to_str().ok()?.trim().parse().ok()?;
775 Some(Duration::from_secs(seconds))
776}
777
778async fn retry_wait(
781 tx: &tokio::sync::mpsc::UnboundedSender<LlmStreamEvent>,
782 policy: &RetryPolicy,
783 attempt: u32,
784 error: &CompletionError,
785) -> bool {
786 let delay = retry_after(error)
790 .map(|d| d.min(policy.cap))
791 .unwrap_or_else(|| backoff(attempt, policy.base, policy.cap));
792 let _ = tx.send(LlmStreamEvent::Retrying {
793 attempt,
794 max: policy.max,
795 reason: error.to_string(),
796 });
797 tokio::select! {
798 biased;
799 _ = tx.closed() => false,
800 _ = tokio::time::sleep(delay) => true,
801 }
802}
803
804fn build_request(
805 messages: &[AgentMessage],
806 system: &str,
807 tools: &[ToolDefinition],
808 opts: &LlmOpts,
809) -> CompletionRequest {
810 let mut chat_history = Vec::with_capacity(messages.len() + 1);
811 if !system.is_empty() {
812 chat_history.push(Message::system(system));
813 }
814 chat_history.extend(messages.iter().filter_map(to_rig_message));
815 CompletionRequest {
816 model: None,
817 preamble: None,
820 chat_history,
821 documents: Vec::new(),
822 tools: tools.to_vec(),
823 temperature: opts.temperature,
824 max_tokens: None,
825 tool_choice: None,
826 additional_params: effort_params(opts),
827 output_schema: None,
828 record_telemetry_content: false,
829 }
830}
831
832const RESPONSES_EFFORT_LEVELS: &[&str] =
837 &["none", "minimal", "low", "medium", "high", "xhigh", "max"];
838
839fn invalid_effort(opts: &LlmOpts) -> Option<String> {
843 if opts.endpoint == LlmEndpoint::Responses
844 && let Some(effort) = opts.effort.as_deref()
845 && !RESPONSES_EFFORT_LEVELS.contains(&effort)
846 {
847 return Some(format!(
848 "effort `{effort}` is not valid for the Responses endpoint; use one of: {}",
849 RESPONSES_EFFORT_LEVELS.join(", ")
850 ));
851 }
852 None
853}
854
855fn effort_params(opts: &LlmOpts) -> Option<serde_json::Value> {
860 let effort = opts.effort.as_ref()?;
861 match opts.endpoint {
862 LlmEndpoint::Chat => Some(serde_json::json!({ "reasoning_effort": effort })),
863 LlmEndpoint::Responses => Some(serde_json::json!({ "reasoning": { "effort": effort } })),
864 }
865}
866
867const TOOL_OUTPUT_ERROR_PREFIX: &str = "ERROR: ";
874
875fn to_rig_message(m: &AgentMessage) -> Option<Message> {
876 match m {
877 AgentMessage::User { content } => {
878 let content: Vec<UserContent> = content
879 .iter()
880 .filter_map(|b| match b {
881 ContentBlock::Text { text } => Some(UserContent::text(text.clone())),
882 _ => None,
883 })
884 .collect();
885 (!content.is_empty()).then_some(Message::User { content })
886 }
887 AgentMessage::Assistant { content, .. } => {
888 let content: Vec<AssistantContent> = content
889 .iter()
890 .map(|b| match b {
891 ContentBlock::Text { text } => AssistantContent::text(text.clone()),
892 ContentBlock::Thinking { text } => {
893 AssistantContent::Reasoning(Reasoning::new(text))
894 }
895 ContentBlock::ToolCall {
896 id,
897 name,
898 arguments,
899 } => AssistantContent::ToolCall(ToolCall::new(
900 ToolCallId::new_or_mint(id.clone()),
901 ToolFunction::new(name.clone(), arguments.clone()),
902 )),
903 })
904 .collect();
905 (!content.is_empty()).then_some(Message::Assistant { id: None, content })
906 }
907 AgentMessage::ToolResult {
908 tool_call_id,
909 name,
910 output,
911 is_error,
912 } => {
913 let output = if *is_error {
917 format!("{TOOL_OUTPUT_ERROR_PREFIX}{output}")
918 } else {
919 output.clone()
920 };
921 Some(Message::tool_result(
922 tool_call_id.clone(),
923 name.clone(),
924 output,
925 ))
926 }
927 }
928}
929
930fn map_item(item: StreamedAssistantContent) -> Vec<LlmStreamEvent> {
931 match item {
932 StreamedAssistantContent::Text(text) => vec![LlmStreamEvent::TextDelta(text.text)],
933 StreamedAssistantContent::ReasoningDelta { reasoning, .. } => {
934 vec![LlmStreamEvent::ThinkingDelta(reasoning)]
935 }
936 StreamedAssistantContent::Reasoning { reasoning, .. } => {
939 vec![LlmStreamEvent::ThinkingReplace(reasoning_text(&reasoning))]
942 }
943 StreamedAssistantContent::ToolCallDelta { .. } => vec![],
950 StreamedAssistantContent::ToolCall { tool_call, .. } => vec![LlmStreamEvent::ToolCall {
951 id: tool_call.id.as_str().to_string(),
952 name: tool_call.function.name,
953 arguments: tool_call.function.arguments,
954 }],
955 StreamedAssistantContent::Final(final_record) => {
956 vec![LlmStreamEvent::Done {
957 stop_reason: final_record
958 .finish_reason
959 .map(finish_reason)
960 .unwrap_or(StopReason::Stop),
961 usage: Some(convert_usage(final_record.usage)),
962 }]
963 }
964 StreamedAssistantContent::Unknown(_) => vec![],
965 }
966}
967
968fn finish_reason(reason: FinishReason) -> StopReason {
969 match reason {
970 FinishReason::Stop => StopReason::Stop,
971 FinishReason::Length => StopReason::Length,
972 FinishReason::ToolCalls => StopReason::ToolUse,
973 FinishReason::ContentFilter | FinishReason::Other(_) => StopReason::Stop,
974 }
975}
976
977fn convert_usage(usage: rig::completion::Usage) -> crate::message::Usage {
978 crate::message::Usage {
979 input_tokens: usage.input_tokens,
980 output_tokens: usage.output_tokens,
981 cache_read_tokens: (usage.cached_input_tokens > 0).then_some(usage.cached_input_tokens),
983 cache_write_tokens: (usage.cache_creation_input_tokens > 0)
984 .then_some(usage.cache_creation_input_tokens),
985 }
986}
987
988fn reasoning_text(reasoning: &Reasoning) -> String {
989 reasoning
990 .content
991 .iter()
992 .filter_map(|block| match block {
993 ReasoningContent::Text { text, .. } => Some(text.as_str()),
994 _ => None,
995 })
996 .collect::<Vec<_>>()
997 .join("")
998}
999
1000#[cfg(test)]
1001mod tests {
1002 use super::*;
1003 use rig::completion::Usage as RigUsage;
1004 use rig::message::{ToolResultContent, UserContent};
1005 use rig::streaming::{RawStreamingToolCall, StreamFinal, ToolCallDeltaContent};
1006 use serde_json::json;
1007
1008 fn tool_call_item() -> StreamedAssistantContent {
1009 let raw =
1010 RawStreamingToolCall::new("call_1".to_string(), "run".to_string(), json!({"x": 1}));
1011 StreamedAssistantContent::ToolCall {
1012 tool_call: raw.into(),
1013 internal_call_id: "ic1".to_string(),
1014 }
1015 }
1016
1017 fn final_item(finish_reason: Option<FinishReason>) -> StreamedAssistantContent {
1018 let usage = RigUsage {
1019 input_tokens: 10,
1020 output_tokens: 5,
1021 total_tokens: 15,
1022 cached_input_tokens: 3,
1023 cache_creation_input_tokens: 2,
1024 ..RigUsage::default()
1025 };
1026 let final_record = StreamFinal::new("test", usage);
1027 StreamedAssistantContent::Final(match finish_reason {
1028 Some(reason) => final_record.with_finish_reason(reason),
1029 None => final_record,
1030 })
1031 }
1032
1033 #[test]
1034 fn text_item_maps_to_text_delta() {
1035 let events = map_item(StreamedAssistantContent::Text(rig::message::Text::new(
1036 "hi",
1037 )));
1038 assert_eq!(events, vec![LlmStreamEvent::TextDelta("hi".to_string())]);
1039 }
1040
1041 #[test]
1042 fn reasoning_delta_maps_to_thinking_delta() {
1043 let events = map_item(StreamedAssistantContent::ReasoningDelta {
1044 id: "r1".to_string(),
1045 provider_id: None,
1046 reasoning: "hmm".to_string(),
1047 });
1048 assert_eq!(
1049 events,
1050 vec![LlmStreamEvent::ThinkingDelta("hmm".to_string())]
1051 );
1052 }
1053
1054 #[test]
1055 fn reasoning_block_replaces_with_full_text() {
1056 let events = map_item(StreamedAssistantContent::Reasoning {
1057 reasoning: Reasoning::new("full thought"),
1058 id: "r1".to_string(),
1059 });
1060 assert_eq!(
1061 events,
1062 vec![LlmStreamEvent::ThinkingReplace("full thought".to_string())]
1063 );
1064 }
1065
1066 #[test]
1067 fn tool_call_delta_ignored() {
1068 for content in [
1069 ToolCallDeltaContent::Name("run".to_string()),
1070 ToolCallDeltaContent::Delta("{\"x".to_string()),
1071 ] {
1072 let events = map_item(StreamedAssistantContent::ToolCallDelta {
1073 internal_call_id: "ic1".to_string(),
1074 content,
1075 });
1076 assert!(events.is_empty());
1077 }
1078 }
1079
1080 #[test]
1081 fn tool_call_maps_to_call_event() {
1082 let events = map_item(tool_call_item());
1083 assert_eq!(
1084 events,
1085 vec![LlmStreamEvent::ToolCall {
1086 id: "call_1".to_string(),
1087 name: "run".to_string(),
1088 arguments: json!({"x": 1}),
1089 }]
1090 );
1091 }
1092
1093 #[test]
1094 fn final_maps_finish_reason_and_usage() {
1095 let cases = [
1096 (FinishReason::Stop, StopReason::Stop),
1097 (FinishReason::Length, StopReason::Length),
1098 (FinishReason::ToolCalls, StopReason::ToolUse),
1099 (FinishReason::ContentFilter, StopReason::Stop),
1100 (FinishReason::Other("weird".to_string()), StopReason::Stop),
1101 ];
1102 for (reason, expected) in cases {
1103 let events = map_item(final_item(Some(reason)));
1104 assert_eq!(
1105 events,
1106 vec![LlmStreamEvent::Done {
1107 stop_reason: expected,
1108 usage: Some(crate::message::Usage {
1109 input_tokens: 10,
1110 output_tokens: 5,
1111 cache_read_tokens: Some(3),
1112 cache_write_tokens: Some(2),
1113 }),
1114 }],
1115 "wrong mapping for {expected:?}"
1116 );
1117 }
1118 }
1119
1120 #[test]
1121 fn final_without_finish_reason_defaults_to_stop() {
1122 let events = map_item(final_item(None));
1123 match &events[..] {
1124 [LlmStreamEvent::Done { stop_reason, .. }] => {
1125 assert_eq!(*stop_reason, StopReason::Stop)
1126 }
1127 other => panic!("unexpected events: {other:?}"),
1128 }
1129 }
1130
1131 #[test]
1132 fn unknown_item_ignored() {
1133 let events = map_item(StreamedAssistantContent::Unknown(
1134 rig::streaming::UnknownPayload::new(json!({"mystery": 1})),
1135 ));
1136 assert!(events.is_empty());
1137 }
1138
1139 #[test]
1140 fn build_request_round_trips_context() {
1141 let opts = LlmOpts {
1142 model: "m1".to_string(),
1143 base_url: None,
1144 api_key: None,
1145 temperature: Some(0.5),
1146 ..LlmOpts::default()
1147 };
1148 let tools = vec![ToolDefinition {
1149 name: "get_weather".to_string(),
1150 description: "Weather lookup".to_string(),
1151 parameters: json!({"type": "object"}),
1152 }];
1153 let messages = vec![
1154 AgentMessage::user_text("What is the weather?"),
1155 AgentMessage::Assistant {
1156 content: vec![
1157 ContentBlock::Thinking { text: "hmm".into() },
1158 ContentBlock::ToolCall {
1159 id: "t1".into(),
1160 name: "get_weather".into(),
1161 arguments: json!({"city": "Tokyo"}),
1162 },
1163 ContentBlock::Text {
1164 text: "Let me check.".into(),
1165 },
1166 ],
1167 stop_reason: StopReason::ToolUse,
1168 usage: None,
1169 model: None,
1170 },
1171 AgentMessage::ToolResult {
1172 tool_call_id: "t1".into(),
1173 name: "get_weather".into(),
1174 output: "sunny".into(),
1175 is_error: false,
1176 },
1177 ];
1178
1179 let request = build_request(&messages, "You are terse.", &tools, &opts);
1180
1181 assert!(request.preamble.is_none());
1182 assert_eq!(request.temperature, Some(0.5));
1183 assert_eq!(request.tools.len(), 1);
1184 assert_eq!(request.tools[0].name, "get_weather");
1185
1186 let [
1187 Message::System { content: system },
1188 Message::User { content: user },
1189 Message::Assistant {
1190 content: assistant, ..
1191 },
1192 Message::User {
1193 content: tool_result,
1194 },
1195 ] = &request.chat_history[..]
1196 else {
1197 panic!("unexpected chat_history shape: {:?}", request.chat_history);
1198 };
1199 assert_eq!(system, "You are terse.");
1200 assert!(
1201 matches!(user.as_slice(), [UserContent::Text(t)] if t.text == "What is the weather?")
1202 );
1203 assert_eq!(assistant.len(), 3);
1204 assert!(
1205 matches!(&assistant[0], AssistantContent::Reasoning(r) if reasoning_text(r) == "hmm")
1206 );
1207 assert!(matches!(
1208 &assistant[1],
1209 AssistantContent::ToolCall(call)
1210 if call.id.as_str() == "t1"
1211 && call.function.name == "get_weather"
1212 && call.function.arguments == json!({"city": "Tokyo"})
1213 ));
1214 assert!(matches!(&assistant[2], AssistantContent::Text(t) if t.text == "Let me check."));
1215 assert!(matches!(
1216 tool_result.as_slice(),
1217 [UserContent::ToolResult(r)] if r.call.as_str() == "t1" && r.name == "get_weather"
1218 ));
1219 }
1220
1221 #[test]
1222 fn tool_result_error_is_annotated_on_the_wire_only() {
1223 let err = AgentMessage::ToolResult {
1224 tool_call_id: "t1".into(),
1225 name: "echo".into(),
1226 output: "boom".into(),
1227 is_error: true,
1228 };
1229 let ok = AgentMessage::ToolResult {
1230 tool_call_id: "t1".into(),
1231 name: "echo".into(),
1232 output: "boom".into(),
1233 is_error: false,
1234 };
1235
1236 fn wire_text(m: &AgentMessage) -> String {
1237 match to_rig_message(m) {
1238 Some(Message::User { content }) => match &content[..] {
1239 [UserContent::ToolResult(r)] => match &r.content[..] {
1240 [ToolResultContent::Text(t)] => t.text.clone(),
1241 other => panic!("unexpected tool result content: {other:?}"),
1242 },
1243 other => panic!("unexpected user content: {other:?}"),
1244 },
1245 other => panic!("unexpected wire message: {other:?}"),
1246 }
1247 }
1248
1249 assert_eq!(wire_text(&ok), "boom");
1251 assert_eq!(wire_text(&err), "ERROR: boom");
1252 match err {
1255 AgentMessage::ToolResult {
1256 output, is_error, ..
1257 } => {
1258 assert_eq!(output, "boom");
1259 assert!(is_error);
1260 }
1261 _ => unreachable!(),
1262 }
1263 }
1264
1265 #[test]
1266 fn endpoint_defaults_to_chat() {
1267 let opts = LlmOpts {
1268 model: "m1".to_string(),
1269 base_url: None,
1270 api_key: None,
1271 temperature: None,
1272 endpoint: LlmEndpoint::Chat,
1273 effort: None,
1274 session_id: None,
1275 retry: RetryPolicy::default(),
1276 };
1277 assert_eq!(opts.endpoint, LlmEndpoint::Chat);
1278 assert_eq!(LlmOpts::default().endpoint, LlmEndpoint::Chat);
1279 }
1280
1281 #[test]
1282 fn no_effort_sends_no_additional_params() {
1283 let opts = LlmOpts::default();
1284 let request = build_request(&[], "sys", &[], &opts);
1285 assert_eq!(request.additional_params, None);
1286 }
1287
1288 #[test]
1289 fn chat_effort_maps_to_reasoning_effort_param() {
1290 let opts = LlmOpts {
1291 effort: Some("high".into()),
1292 endpoint: LlmEndpoint::Chat,
1293 ..LlmOpts::default()
1294 };
1295 let request = build_request(&[], "sys", &[], &opts);
1296 assert_eq!(
1297 request.additional_params,
1298 Some(json!({ "reasoning_effort": "high" }))
1299 );
1300 }
1301
1302 #[test]
1303 fn responses_effort_maps_to_reasoning_object() {
1304 let opts = LlmOpts {
1305 effort: Some("xhigh".into()),
1306 endpoint: LlmEndpoint::Responses,
1307 ..LlmOpts::default()
1308 };
1309 let request = build_request(&[], "sys", &[], &opts);
1310 assert_eq!(
1311 request.additional_params,
1312 Some(json!({ "reasoning": { "effort": "xhigh" } }))
1313 );
1314 }
1315
1316 #[test]
1317 fn chat_effort_is_verbatim_passthrough() {
1318 for level in ["low", "medium", "my-custom-level", ""] {
1321 let opts = LlmOpts {
1322 effort: Some(level.into()),
1323 endpoint: LlmEndpoint::Chat,
1324 ..LlmOpts::default()
1325 };
1326 let request = build_request(&[], "sys", &[], &opts);
1327 assert_eq!(
1328 request.additional_params,
1329 Some(json!({ "reasoning_effort": level })),
1330 "level {level:?} must pass through verbatim on Chat"
1331 );
1332 }
1333 }
1334
1335 #[test]
1336 fn responses_effort_validation() {
1337 for level in ["none", "minimal", "low", "medium", "high", "xhigh", "max"] {
1340 let opts = LlmOpts {
1341 effort: Some(level.into()),
1342 endpoint: LlmEndpoint::Responses,
1343 ..LlmOpts::default()
1344 };
1345 assert_eq!(invalid_effort(&opts), None, "level {level} must be valid");
1346 }
1347 let opts = LlmOpts {
1348 effort: Some("deep".into()),
1349 endpoint: LlmEndpoint::Responses,
1350 ..LlmOpts::default()
1351 };
1352 let msg = invalid_effort(&opts).expect("unknown level must be rejected");
1353 assert!(msg.contains("not valid"), "got: {msg}");
1354 assert!(
1355 msg.contains("xhigh"),
1356 "message must list valid levels: {msg}"
1357 );
1358 let opts = LlmOpts {
1360 effort: Some("deep".into()),
1361 endpoint: LlmEndpoint::Chat,
1362 ..LlmOpts::default()
1363 };
1364 assert_eq!(invalid_effort(&opts), None);
1365 }
1366
1367 #[test]
1368 fn client_sends_session_header_and_user_agent() {
1369 let opts = LlmOpts {
1370 api_key: Some("test-key".to_string()),
1371 session_id: Some("sess-123".to_string()),
1372 ..LlmOpts::default()
1373 };
1374 let headers = openai_client(&opts)
1375 .expect("client builds")
1376 .headers()
1377 .clone();
1378 assert_eq!(
1379 headers.get("x-opencode-session").expect("session header"),
1380 "sess-123"
1381 );
1382 assert!(headers.contains_key("user-agent"), "got: {headers:?}");
1383 }
1384
1385 #[test]
1386 fn client_omits_session_header_when_unset() {
1387 let opts = LlmOpts {
1388 api_key: Some("test-key".to_string()),
1389 ..LlmOpts::default()
1390 };
1391 let headers = openai_client(&opts)
1392 .expect("client builds")
1393 .headers()
1394 .clone();
1395 assert!(!headers.contains_key("x-opencode-session"));
1396 assert!(headers.contains_key("user-agent"), "got: {headers:?}");
1397 }
1398
1399 #[test]
1400 fn localhost_base_url_builds_without_key() {
1401 for url in [
1404 "http://localhost:11434/v1",
1405 "http://127.0.0.1:8080/v1",
1406 "http://[::1]:8080/v1",
1407 ] {
1408 let opts = LlmOpts {
1409 base_url: Some(url.to_string()),
1410 api_key: None,
1411 ..LlmOpts::default()
1412 };
1413 assert!(
1414 openai_client(&opts).is_ok(),
1415 "keyless {url} must build a client"
1416 );
1417 }
1418 }
1419
1420 #[test]
1421 fn remote_base_url_without_key_is_error() {
1422 let opts = LlmOpts {
1423 base_url: Some("https://api.example.com/v1".to_string()),
1424 api_key: None,
1425 ..LlmOpts::default()
1426 };
1427 let err = openai_client(&opts).unwrap_err();
1428 assert!(err.contains("no API key"), "got: {err}");
1429 }
1430
1431 #[tokio::test]
1433 async fn list_models_unreachable_is_error() {
1434 let opts = LlmOpts {
1435 base_url: Some("http://127.0.0.1:9/v1".to_string()),
1436 api_key: Some("test-key".to_string()),
1437 ..LlmOpts::default()
1438 };
1439 let err = tokio::time::timeout(std::time::Duration::from_secs(10), list_models(&opts))
1440 .await
1441 .expect("list terminates")
1442 .expect_err("unreachable host must error");
1443 assert!(!err.is_empty());
1444 }
1445
1446 #[tokio::test]
1447 async fn list_models_without_key_is_error() {
1448 let opts = LlmOpts::default();
1449 let err = list_models(&opts).await.expect_err("no key must error");
1450 assert!(err.contains("no API key"), "got: {err}");
1451 }
1452
1453 #[test]
1454 fn client_cache_reuses_until_key_changes() {
1455 let opts = LlmOpts {
1456 base_url: Some("http://127.0.0.1:9/v1".to_string()),
1457 api_key: Some("k".to_string()),
1458 session_id: Some("s1".to_string()),
1459 ..LlmOpts::default()
1460 };
1461 let cache = ClientCache::default();
1462 let builds = || cache.builds.load(std::sync::atomic::Ordering::SeqCst);
1463
1464 cache.get(&opts).expect("builds");
1466 cache.get(&opts).expect("builds");
1467 assert_eq!(builds(), 1, "identical opts must reuse the client");
1468
1469 let model_swap = LlmOpts {
1471 model: "other".to_string(),
1472 effort: Some("high".to_string()),
1473 ..opts.clone()
1474 };
1475 cache.get(&model_swap).expect("builds");
1476 assert_eq!(builds(), 1, "model/effort swap must reuse the client");
1477
1478 for changed in [
1480 LlmOpts {
1481 base_url: Some("http://127.0.0.1:8/v1".to_string()),
1482 ..opts.clone()
1483 },
1484 LlmOpts {
1485 api_key: Some("k2".to_string()),
1486 ..opts.clone()
1487 },
1488 LlmOpts {
1489 session_id: Some("s2".to_string()),
1490 ..opts.clone()
1491 },
1492 ] {
1493 let before = builds();
1494 cache.get(&changed).expect("builds");
1495 assert_eq!(builds(), before + 1, "a key change must rebuild the client");
1496 }
1497 }
1498
1499 #[test]
1500 fn responses_request_builds_against_default_client_type() {
1501 use rig::client::CompletionClient;
1502 let client = openai::Client::builder()
1503 .api_key("test-key")
1504 .base_url("http://127.0.0.1:9/v1")
1505 .build()
1506 .expect("client builds without network");
1507 let model = client.completion_model("some-model");
1508 let request = build_request(&[], "sys", &[], &LlmOpts::default());
1509 let fut = rig::completion::CompletionModel::stream(&model, request);
1510 drop(fut);
1511 }
1512
1513 #[tokio::test]
1516 async fn adapter_surfaces_error_without_done() {
1517 let opts = LlmOpts {
1518 model: "m1".to_string(),
1519 base_url: Some("http://127.0.0.1:9/v1".to_string()),
1520 api_key: Some("test-key".to_string()),
1521 temperature: None,
1522 ..LlmOpts::default()
1523 };
1524 let stream_fn = rig_stream_fn();
1525 let stream = stream_fn(&[], "sys", &[], &opts);
1526 let events: Vec<LlmStreamEvent> =
1527 tokio::time::timeout(std::time::Duration::from_secs(10), stream.collect())
1528 .await
1529 .expect("stream terminates");
1530 assert!(
1531 events
1532 .iter()
1533 .any(|e| matches!(e, LlmStreamEvent::Error { .. })),
1534 "expected an Error event, got {events:?}"
1535 );
1536 assert!(
1537 !events
1538 .iter()
1539 .any(|e| matches!(e, LlmStreamEvent::Done { .. })),
1540 "no Done after error, got {events:?}"
1541 );
1542 }
1543
1544 #[tokio::test]
1547 async fn responses_adapter_surfaces_error_without_done() {
1548 let opts = LlmOpts {
1549 model: "m1".to_string(),
1550 base_url: Some("http://127.0.0.1:9/v1".to_string()),
1551 api_key: Some("test-key".to_string()),
1552 temperature: None,
1553 endpoint: LlmEndpoint::Responses,
1554 effort: None,
1555 session_id: None,
1556 retry: RetryPolicy::default(),
1557 };
1558 let stream_fn = rig_stream_fn();
1559 let stream = stream_fn(&[], "sys", &[], &opts);
1560 let events: Vec<LlmStreamEvent> =
1561 tokio::time::timeout(std::time::Duration::from_secs(10), stream.collect())
1562 .await
1563 .expect("stream terminates");
1564 assert!(
1565 events
1566 .iter()
1567 .any(|e| matches!(e, LlmStreamEvent::Error { .. })),
1568 "expected an Error event, got {events:?}"
1569 );
1570 assert!(
1571 !events
1572 .iter()
1573 .any(|e| matches!(e, LlmStreamEvent::Done { .. })),
1574 "no Done after error, got {events:?}"
1575 );
1576 }
1577 #[tokio::test]
1579 #[ignore = "live test; run with WCODE_LIVE=1 WCODE_BASE_URL=... WCODE_API_KEY=... WCODE_MODEL=... cargo test -p wcode-harness --lib -- --ignored"]
1580 async fn live_rig_stream_fn() {
1581 let (Ok(base_url), Ok(api_key), Ok(model)) = (
1582 std::env::var("WCODE_BASE_URL"),
1583 std::env::var("WCODE_API_KEY"),
1584 std::env::var("WCODE_MODEL"),
1585 ) else {
1586 eprintln!("skipping: WCODE_BASE_URL / WCODE_API_KEY / WCODE_MODEL not set");
1587 return;
1588 };
1589 if std::env::var("WCODE_LIVE").ok().as_deref() != Some("1") {
1590 eprintln!("skipping: WCODE_LIVE != 1");
1591 return;
1592 }
1593 let opts = LlmOpts {
1594 model,
1595 base_url: Some(base_url),
1596 api_key: Some(api_key),
1597 temperature: None,
1598 ..LlmOpts::default()
1599 };
1600 let stream_fn = rig_stream_fn();
1601 let stream = stream_fn(
1602 &[AgentMessage::user_text("Reply with exactly: pong")],
1603 "You are a terse echo assistant.",
1604 &[],
1605 &opts,
1606 );
1607 let events: Vec<LlmStreamEvent> = stream.collect().await;
1608 let text = events
1609 .iter()
1610 .filter_map(|e| match e {
1611 LlmStreamEvent::TextDelta(t) => Some(t.as_str()),
1612 _ => None,
1613 })
1614 .collect::<String>();
1615 println!("text: {text:?}");
1616 println!("events: {events:?}");
1617 assert!(
1618 events
1619 .iter()
1620 .any(|e| matches!(e, LlmStreamEvent::Done { .. }))
1621 );
1622 assert!(
1623 !events
1624 .iter()
1625 .any(|e| matches!(e, LlmStreamEvent::Error { .. }))
1626 );
1627 }
1628}
1629
1630#[cfg(test)]
1631mod retry_tests {
1632 use super::*;
1633 use std::sync::Arc;
1634 use std::sync::atomic::{AtomicUsize, Ordering};
1635
1636 type Chunks =
1637 futures::stream::Iter<std::vec::IntoIter<Result<StreamedAssistantContent, CompletionError>>>;
1638
1639 fn transient() -> CompletionError {
1641 CompletionError::ProviderError(
1642 "Http client error: error sending request for url (http://x)".into(),
1643 )
1644 }
1645
1646 fn fatal() -> CompletionError {
1647 CompletionError::ProviderError("invalid_request_error: bad tool schema".into())
1648 }
1649
1650 fn empty() -> Chunks {
1651 futures::stream::iter(Vec::new())
1652 }
1653
1654 fn done() -> StreamedAssistantContent {
1656 StreamedAssistantContent::Final(rig::streaming::StreamFinal::new(
1657 "test",
1658 rig::completion::Usage::default(),
1659 ))
1660 }
1661
1662 fn first_item_err(e: CompletionError) -> Chunks {
1663 futures::stream::iter(vec![Err(e)])
1664 }
1665
1666 #[test]
1667 fn transient_statuses_retry_client_errors_do_not() {
1668 for code in [408, 409, 429, 500, 502, 503, 504] {
1669 assert!(is_transient_status(code), "{code} should retry");
1670 }
1671 for code in [200, 400, 401, 403, 404, 422] {
1672 assert!(!is_transient_status(code), "{code} must not retry");
1673 }
1674 }
1675
1676 #[test]
1677 fn retryable_classifies_transport_vs_rejections() {
1678 assert!(retryable(&CompletionError::HttpError(
1679 rig::http_client::Error::StreamEnded
1680 )));
1681 assert!(retryable(&transient()));
1682 assert!(!retryable(&fatal()));
1683 assert!(!retryable(&CompletionError::ResponseError("bad".into())));
1684 }
1685
1686 #[test]
1687 fn backoff_grows_and_never_exceeds_the_cap() {
1688 let base = Duration::from_millis(100);
1689 let cap = Duration::from_millis(400);
1690 let d1 = backoff(1, base, cap);
1691 assert!(d1 <= base && d1 >= base * 3 / 4, "attempt 1 = {d1:?}");
1692 for n in 1..12 {
1693 assert!(backoff(n, base, cap) <= cap, "attempt {n} exceeds cap");
1694 }
1695 }
1696
1697 #[tokio::test(start_paused = true)]
1698 async fn connect_retries_transient_failures_then_succeeds() {
1699 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1700 let calls = Arc::new(AtomicUsize::new(0));
1701 let policy = RetryPolicy {
1702 max: 3,
1703 base: Duration::from_millis(10),
1704 cap: Duration::from_millis(50),
1705 ..RetryPolicy::default()
1706 };
1707 let c = calls.clone();
1708 let mut state = RetryState::new(policy.max);
1709 let out = connect_with_retry(
1710 &policy,
1711 &mut state,
1712 move || {
1713 let n = c.fetch_add(1, Ordering::SeqCst) + 1;
1714 Box::pin(async move {
1715 if n <= 2 {
1716 Err(transient())
1717 } else {
1718 Ok(empty())
1719 }
1720 })
1721 },
1722 &tx,
1723 )
1724 .await;
1725 assert!(matches!(out, PreContent::Ready { .. }));
1726 assert_eq!(calls.load(Ordering::SeqCst), 3);
1727 assert!(matches!(
1728 rx.try_recv(),
1729 Ok(LlmStreamEvent::Retrying { attempt: 1, .. })
1730 ));
1731 assert!(matches!(
1732 rx.try_recv(),
1733 Ok(LlmStreamEvent::Retrying { attempt: 2, .. })
1734 ));
1735 }
1736
1737 #[tokio::test(start_paused = true)]
1738 async fn connect_retries_a_first_item_transport_error() {
1739 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1740 let calls = Arc::new(AtomicUsize::new(0));
1741 let policy = RetryPolicy {
1742 max: 2,
1743 base: Duration::from_millis(5),
1744 cap: Duration::from_millis(5),
1745 ..RetryPolicy::default()
1746 };
1747 let c = calls.clone();
1748 let mut state = RetryState::new(policy.max);
1749 let out = connect_with_retry(
1750 &policy,
1751 &mut state,
1752 move || {
1753 let n = c.fetch_add(1, Ordering::SeqCst) + 1;
1754 Box::pin(async move {
1755 if n == 1 {
1756 Ok(first_item_err(transient()))
1757 } else {
1758 Ok(empty())
1759 }
1760 })
1761 },
1762 &tx,
1763 )
1764 .await;
1765 assert!(matches!(out, PreContent::Ready { .. }));
1766 assert_eq!(calls.load(Ordering::SeqCst), 2, "first-item error retried");
1767 }
1768
1769 #[tokio::test(start_paused = true)]
1770 async fn connect_gives_up_after_max_and_skips_non_retryable() {
1771 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1772 let policy = RetryPolicy {
1773 max: 1,
1774 base: Duration::from_millis(1),
1775 cap: Duration::from_millis(1),
1776 ..RetryPolicy::default()
1777 };
1778
1779 let calls = Arc::new(AtomicUsize::new(0));
1780 let c = calls.clone();
1781 let mut state = RetryState::new(policy.max);
1782 let out = connect_with_retry(
1783 &policy,
1784 &mut state,
1785 move || {
1786 c.fetch_add(1, Ordering::SeqCst);
1787 Box::pin(async { Err::<Chunks, CompletionError>(transient()) })
1788 },
1789 &tx,
1790 )
1791 .await;
1792 assert!(matches!(out, PreContent::Failed(_)));
1793 assert_eq!(calls.load(Ordering::SeqCst), 2, "one attempt + one retry");
1794
1795 let calls = Arc::new(AtomicUsize::new(0));
1796 let c = calls.clone();
1797 let mut state = RetryState::new(policy.max);
1798 let out = connect_with_retry(
1799 &policy,
1800 &mut state,
1801 move || {
1802 c.fetch_add(1, Ordering::SeqCst);
1803 Box::pin(async { Err::<Chunks, CompletionError>(fatal()) })
1804 },
1805 &tx,
1806 )
1807 .await;
1808 assert!(matches!(out, PreContent::Failed(_)));
1809 assert_eq!(calls.load(Ordering::SeqCst), 1, "non-retryable returns at once");
1810 }
1811
1812 #[test]
1813 fn fatal_class_flags_hard_errors_but_not_transient_transport() {
1814 assert!(!fatal_class(&transient()));
1818 assert!(!fatal_class(&CompletionError::HttpError(
1819 rig::http_client::Error::StreamEnded
1820 )));
1821 assert!(fatal_class(&fatal()));
1822 assert!(fatal_class(&CompletionError::ResponseError("bad".into())));
1823 assert!(fatal_class(&CompletionError::RequestError("bad".into())));
1824 let json_err = serde_json::from_str::<u8>("not a number").unwrap_err();
1825 assert!(fatal_class(&CompletionError::JsonError(json_err)));
1826 }
1827
1828 #[tokio::test]
1833 async fn refused_port_exhausts_retries_to_non_fatal_error() {
1834 let opts = LlmOpts {
1835 model: "m1".to_string(),
1836 base_url: Some("http://127.0.0.1:9/v1".to_string()),
1837 api_key: Some("test-key".to_string()),
1838 retry: RetryPolicy {
1839 max: 2,
1840 base: Duration::from_millis(10),
1841 cap: Duration::from_millis(50),
1842 ..RetryPolicy::default()
1843 },
1844 ..LlmOpts::default()
1845 };
1846 let stream_fn = rig_stream_fn();
1847 let stream = stream_fn(&[], "sys", &[], &opts);
1848 let events: Vec<LlmStreamEvent> =
1849 tokio::time::timeout(std::time::Duration::from_secs(10), stream.collect())
1850 .await
1851 .expect("stream terminates");
1852 let retries = events
1853 .iter()
1854 .filter(|e| matches!(e, LlmStreamEvent::Retrying { .. }))
1855 .count();
1856 assert_eq!(retries, 2, "two backoff notices before giving up: {events:?}");
1857 assert!(
1858 matches!(events.last(), Some(LlmStreamEvent::Error { fatal: false, .. })),
1859 "refused port is transient-class, not fatal: {events:?}"
1860 );
1861 }
1862
1863 fn err_with_retry_after(value: &str) -> CompletionError {
1867 let mut headers = HeaderMap::new();
1868 headers.insert("retry-after", HeaderValue::from_str(value).unwrap());
1869 CompletionError::ProviderResponse(
1870 rig::ProviderResponseError::without_status("429")
1871 .with_headers(Some(Box::new(headers))),
1872 )
1873 }
1874
1875 #[test]
1876 fn retry_after_parses_integer_seconds() {
1877 assert_eq!(
1878 retry_after(&err_with_retry_after("7")),
1879 Some(Duration::from_secs(7))
1880 );
1881 }
1882
1883 #[test]
1884 fn retry_after_is_none_when_absent() {
1885 assert_eq!(retry_after(&transient()), None);
1887 }
1888
1889 #[test]
1890 fn retry_after_is_none_on_non_numeric() {
1891 assert_eq!(
1894 retry_after(&err_with_retry_after("Wed, 21 Oct 2015 07:28:00 GMT")),
1895 None
1896 );
1897 assert_eq!(retry_after(&err_with_retry_after("soon")), None);
1898 }
1899
1900 #[tokio::test(start_paused = true)]
1901 async fn retry_wait_honors_retry_after_over_backoff() {
1902 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1903 let policy = RetryPolicy {
1906 base: Duration::from_millis(10),
1907 ..RetryPolicy::default()
1908 };
1909 let start = tokio::time::Instant::now();
1910 assert!(retry_wait(&tx, &policy, 1, &err_with_retry_after("3")).await);
1911 assert_eq!(start.elapsed(), Duration::from_secs(3));
1912 }
1913
1914 #[tokio::test(start_paused = true)]
1915 async fn retry_wait_clamps_retry_after_to_the_cap() {
1916 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1917 let policy = RetryPolicy {
1920 cap: Duration::from_secs(8),
1921 ..RetryPolicy::default()
1922 };
1923 let start = tokio::time::Instant::now();
1924 assert!(retry_wait(&tx, &policy, 1, &err_with_retry_after("100")).await);
1925 assert_eq!(start.elapsed(), Duration::from_secs(8));
1926 }
1927 type BoxStream =
1930 Pin<Box<dyn futures::Stream<Item = Result<StreamedAssistantContent, CompletionError>> + Send>>;
1931
1932 fn boxed<S>(s: S) -> BoxStream
1933 where
1934 S: futures::Stream<Item = Result<StreamedAssistantContent, CompletionError>>
1935 + Send
1936 + 'static,
1937 {
1938 Box::pin(s)
1939 }
1940
1941 fn silent_boxed() -> BoxStream {
1942 boxed(futures::stream::pending::<Result<StreamedAssistantContent, CompletionError>>())
1943 }
1944
1945 fn open_silent()
1948 -> Pin<Box<dyn std::future::Future<Output = Result<BoxStream, CompletionError>> + Send>> {
1949 Box::pin(async { Ok::<_, CompletionError>(silent_boxed()) })
1950 }
1951
1952 fn open_slow_ok()
1955 -> Pin<Box<dyn std::future::Future<Output = Result<BoxStream, CompletionError>> + Send>> {
1956 Box::pin(async {
1957 tokio::time::sleep(Duration::from_millis(50)).await;
1958 Ok::<_, CompletionError>(boxed(futures::stream::iter(vec![Ok::<_, CompletionError>(
1959 StreamedAssistantContent::Text(rig::message::Text::new("ok")),
1960 )])))
1961 })
1962 }
1963
1964 #[test]
1966 fn a_stall_error_is_transient_and_not_hard_fatal() {
1967 let d = Duration::from_secs(1);
1968 assert!(retryable(&stall_error(d)), "a stall must be transient (B1)");
1969 assert!(!fatal_class(&stall_error(d)), "a stall must not be hard-fatal");
1970 assert!(
1971 stall_error(d).to_string().contains("timed out"),
1972 "the (timed out) needle drives retryable()"
1973 );
1974 }
1975
1976 #[tokio::test(start_paused = true)]
1980 async fn a_silent_stream_trips_the_ttft_retries_then_fails() {
1981 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1982 let calls = Arc::new(AtomicUsize::new(0));
1983 let policy = RetryPolicy {
1984 max: 2,
1985 base: Duration::from_millis(10),
1986 cap: Duration::from_millis(10),
1987 ttft: Duration::from_secs(1),
1988 ..RetryPolicy::default()
1989 };
1990 let c = calls.clone();
1991 let open = move || {
1992 c.fetch_add(1, Ordering::SeqCst);
1993 open_silent()
1994 };
1995 let mut state = RetryState::new(policy.max);
1996 let out = connect_with_retry(&policy, &mut state, open, &tx).await;
1997
1998 let PreContent::Failed(error) = out else {
1999 panic!("expected Failed from an exhausted stall")
2000 };
2001 assert!(retryable(&error), "a stall is transient");
2002 assert!(!fatal_class(&error), "a stall is not hard-fatal");
2003 assert_eq!(calls.load(Ordering::SeqCst), 3, "one attempt + two retries");
2004 let retries = std::iter::from_fn(|| rx.try_recv().ok())
2005 .filter(|e| matches!(e, LlmStreamEvent::Retrying { .. }))
2006 .count();
2007 assert_eq!(retries, 2, "a Retrying notice per retry");
2008 }
2009
2010 #[tokio::test(start_paused = true)]
2014 async fn deltas_then_a_stall_is_a_nonfatal_error() {
2015 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2016 let policy = RetryPolicy {
2017 max: 3,
2018 base: Duration::from_millis(10),
2019 cap: Duration::from_millis(10),
2020 idle: Duration::from_secs(1),
2021 ..RetryPolicy::default()
2022 };
2023 let stream = boxed(
2024 futures::stream::iter(vec![Ok::<_, CompletionError>(
2025 StreamedAssistantContent::Text(rig::message::Text::new("hi")),
2026 )])
2027 .chain(futures::stream::pending::<Result<StreamedAssistantContent, CompletionError>>()),
2028 );
2029 let state = RetryState::new(policy.max);
2030 forward_stream(stream, None, policy, open_silent, tx, state).await;
2031
2032 let events: Vec<LlmStreamEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
2033 assert!(
2034 events
2035 .iter()
2036 .any(|e| matches!(e, LlmStreamEvent::TextDelta(t) if t == "hi")),
2037 "{events:?}"
2038 );
2039 let error = events
2040 .iter()
2041 .find_map(|e| match e {
2042 LlmStreamEvent::Error { message, fatal } => Some((message.clone(), *fatal)),
2043 _ => None,
2044 })
2045 .expect("the idle deadline surfaced an error");
2046 assert!(!error.1, "a post-content stall is non-fatal: {error:?}");
2047 assert!(
2048 !events.iter().any(|e| matches!(e, LlmStreamEvent::Retrying { .. })),
2049 "no retry once content flowed: {events:?}"
2050 );
2051 }
2052
2053 #[tokio::test(start_paused = true)]
2056 async fn a_clean_stream_is_unaffected() {
2057 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2058 let policy = RetryPolicy {
2059 idle: Duration::from_secs(1),
2060 ..RetryPolicy::default()
2061 };
2062 let stream = boxed(futures::stream::iter(vec![
2063 Ok::<_, CompletionError>(StreamedAssistantContent::Text(rig::message::Text::new("hi"))),
2064 Ok::<_, CompletionError>(done()),
2065 ]));
2066 let state = RetryState::new(policy.max);
2067 forward_stream(stream, None, policy, open_silent, tx, state).await;
2068
2069 let events: Vec<LlmStreamEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
2070 assert!(
2071 events
2072 .iter()
2073 .any(|e| matches!(e, LlmStreamEvent::TextDelta(t) if t == "hi")),
2074 "{events:?}"
2075 );
2076 assert!(
2077 events.iter().any(|e| matches!(e, LlmStreamEvent::Done { .. })),
2078 "{events:?}"
2079 );
2080 assert!(
2081 !events.iter().any(|e| matches!(e, LlmStreamEvent::Error { .. })),
2082 "{events:?}"
2083 );
2084 assert!(
2085 !events.iter().any(|e| matches!(e, LlmStreamEvent::Retrying { .. })),
2086 "{events:?}"
2087 );
2088 }
2089
2090 #[tokio::test(start_paused = true)]
2094 async fn zero_deadlines_disable_the_timeout() {
2095 let mut silent =
2097 futures::stream::pending::<Result<StreamedAssistantContent, CompletionError>>();
2098 let stalled = tokio::time::timeout(
2099 Duration::from_secs(1),
2100 next_or_stall(&mut silent, Duration::ZERO),
2101 )
2102 .await;
2103 assert!(stalled.is_err(), "ZERO must not trip the deadline");
2104
2105 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2107 let policy = RetryPolicy {
2108 ttft: Duration::ZERO,
2109 ..RetryPolicy::default()
2110 };
2111 let mut state = RetryState::new(policy.max);
2112 let out = connect_with_retry(&policy, &mut state, open_slow_ok, &tx).await;
2113 assert!(
2114 matches!(out, PreContent::Ready { .. }),
2115 "a slow connect must not be timed out under ZERO"
2116 );
2117 }
2118
2119 #[tokio::test(start_paused = true)]
2125 async fn a_stalled_connect_trips_the_ttft_retries_then_fails() {
2126 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2127 let calls = Arc::new(AtomicUsize::new(0));
2128 let policy = RetryPolicy {
2129 max: 2,
2130 base: Duration::from_millis(10),
2131 cap: Duration::from_millis(10),
2132 ttft: Duration::from_secs(1),
2133 ..RetryPolicy::default()
2134 };
2135 let c = calls.clone();
2136 let open = move || {
2137 c.fetch_add(1, Ordering::SeqCst);
2138 Box::pin(std::future::pending::<Result<BoxStream, CompletionError>>())
2139 as Pin<
2140 Box<
2141 dyn std::future::Future<Output = Result<BoxStream, CompletionError>> + Send,
2142 >,
2143 >
2144 };
2145 let mut state = RetryState::new(policy.max);
2146 let out = connect_with_retry(&policy, &mut state, open, &tx).await;
2147
2148 let PreContent::Failed(error) = out else {
2149 panic!("expected Failed from an exhausted connect stall")
2150 };
2151 assert!(retryable(&error), "a connect stall is transient");
2152 assert!(!fatal_class(&error), "a connect stall is not hard-fatal");
2153 assert_eq!(calls.load(Ordering::SeqCst), 3, "one attempt + two retries");
2154 let retries = std::iter::from_fn(|| rx.try_recv().ok())
2155 .filter(|e| matches!(e, LlmStreamEvent::Retrying { .. }))
2156 .count();
2157 assert_eq!(retries, 2, "a Retrying notice per retry");
2158 }
2159
2160 #[tokio::test(start_paused = true)]
2164 async fn a_pre_content_idle_stall_retries_and_reopens() {
2165 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2166 let policy = RetryPolicy {
2167 max: 2,
2168 base: Duration::from_millis(10),
2169 cap: Duration::from_millis(10),
2170 idle: Duration::from_secs(1),
2171 ..RetryPolicy::default()
2172 };
2173 let stream = silent_boxed();
2175 fn open_done()
2177 -> Pin<Box<dyn std::future::Future<Output = Result<BoxStream, CompletionError>> + Send>> {
2178 Box::pin(async {
2179 Ok::<_, CompletionError>(boxed(futures::stream::iter(vec![Ok::<_, CompletionError>(
2180 done(),
2181 )])))
2182 })
2183 }
2184 let state = RetryState::new(policy.max);
2185 forward_stream(stream, None, policy, open_done, tx, state).await;
2186
2187 let events: Vec<LlmStreamEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
2188 assert!(
2189 events
2190 .iter()
2191 .any(|e| matches!(e, LlmStreamEvent::Retrying { .. })),
2192 "a pre-content stall backs off: {events:?}"
2193 );
2194 assert!(
2195 events.iter().any(|e| matches!(e, LlmStreamEvent::Done { .. })),
2196 "the re-opened stream completed: {events:?}"
2197 );
2198 assert!(
2199 !events.iter().any(|e| matches!(e, LlmStreamEvent::Error { .. })),
2200 "no error once the retry succeeded: {events:?}"
2201 );
2202 }
2203
2204 fn open_empty()
2208 -> Pin<Box<dyn std::future::Future<Output = Result<BoxStream, CompletionError>> + Send>> {
2209 Box::pin(async { Ok::<_, CompletionError>(boxed(empty())) })
2210 }
2211
2212 #[tokio::test(start_paused = true)]
2216 async fn a_delta_then_a_truncated_end_is_a_nonfatal_error() {
2217 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2218 let policy = RetryPolicy {
2219 max: 3,
2220 base: Duration::from_millis(10),
2221 cap: Duration::from_millis(10),
2222 idle: Duration::ZERO,
2223 ..RetryPolicy::default()
2224 };
2225 let stream = boxed(futures::stream::iter(vec![Ok::<_, CompletionError>(
2226 StreamedAssistantContent::Text(rig::message::Text::new("hi")),
2227 )]));
2228 let state = RetryState::new(policy.max);
2229 forward_stream(stream, None, policy, open_silent, tx, state).await;
2230
2231 let events: Vec<LlmStreamEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
2232 assert!(
2233 events
2234 .iter()
2235 .any(|e| matches!(e, LlmStreamEvent::TextDelta(t) if t == "hi")),
2236 "{events:?}"
2237 );
2238 let (message, fatal) = events
2239 .iter()
2240 .find_map(|e| match e {
2241 LlmStreamEvent::Error { message, fatal } => Some((message.clone(), *fatal)),
2242 _ => None,
2243 })
2244 .expect("a truncated end surfaced an error");
2245 assert!(!fatal, "a post-content truncation is non-fatal: {message}");
2246 assert!(
2247 !events
2248 .iter()
2249 .any(|e| matches!(e, LlmStreamEvent::Done { .. })),
2250 "no Done on a truncated stream: {events:?}"
2251 );
2252 assert!(
2253 !events
2254 .iter()
2255 .any(|e| matches!(e, LlmStreamEvent::Retrying { .. })),
2256 "no retry once content flowed: {events:?}"
2257 );
2258 }
2259
2260 #[tokio::test(start_paused = true)]
2263 async fn a_final_terminated_stream_is_unchanged() {
2264 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2265 let policy = RetryPolicy {
2266 idle: Duration::ZERO,
2267 ..RetryPolicy::default()
2268 };
2269 let stream = boxed(futures::stream::iter(vec![
2270 Ok::<_, CompletionError>(StreamedAssistantContent::Text(rig::message::Text::new(
2271 "hi",
2272 ))),
2273 Ok::<_, CompletionError>(done()),
2274 ]));
2275 let state = RetryState::new(policy.max);
2276 forward_stream(stream, None, policy, open_silent, tx, state).await;
2277
2278 let events: Vec<LlmStreamEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
2279 assert!(
2280 events
2281 .iter()
2282 .any(|e| matches!(e, LlmStreamEvent::Done { .. })),
2283 "{events:?}"
2284 );
2285 assert!(
2286 !events
2287 .iter()
2288 .any(|e| matches!(e, LlmStreamEvent::Error { .. })),
2289 "a clean turn surfaces no error: {events:?}"
2290 );
2291 assert!(
2292 !events
2293 .iter()
2294 .any(|e| matches!(e, LlmStreamEvent::Retrying { .. })),
2295 "{events:?}"
2296 );
2297 }
2298
2299 #[tokio::test(start_paused = true)]
2304 async fn a_pre_content_no_item_stream_retries_then_errors() {
2305 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2306 let policy = RetryPolicy {
2307 max: 2,
2308 base: Duration::from_millis(10),
2309 cap: Duration::from_millis(10),
2310 idle: Duration::ZERO,
2311 ..RetryPolicy::default()
2312 };
2313 let state = RetryState::new(policy.max);
2314 forward_stream(boxed(empty()), None, policy, open_empty, tx, state).await;
2315
2316 let events: Vec<LlmStreamEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
2317 let retries = events
2318 .iter()
2319 .filter(|e| matches!(e, LlmStreamEvent::Retrying { .. }))
2320 .count();
2321 assert_eq!(
2322 retries, policy.max as usize,
2323 "one Retrying notice per retry: {events:?}"
2324 );
2325 let (message, fatal) = events
2326 .iter()
2327 .find_map(|e| match e {
2328 LlmStreamEvent::Error { message, fatal } => Some((message.clone(), *fatal)),
2329 _ => None,
2330 })
2331 .expect("an exhausted truncation budget surfaced an error");
2332 assert!(!fatal, "an exhausted truncation is non-fatal: {message}");
2333 assert!(
2334 !events
2335 .iter()
2336 .any(|e| matches!(e, LlmStreamEvent::Done { .. })),
2337 "no Done on a truncated stream: {events:?}"
2338 );
2339 }
2340}