agent_sdk_providers/provider.rs
1//! LLM provider trait and streaming helpers.
2//!
3//! This module defines the [`LlmProvider`] trait that all LLM backends implement,
4//! as well as the [`collect_stream`] helper for consuming a streaming response.
5
6#[cfg(feature = "anthropic")]
7use agent_sdk_foundation::llm::ToolChoice;
8use agent_sdk_foundation::llm::{
9 ChatOutcome, ChatRequest, ChatResponse, ContentBlock, ThinkingConfig, ThinkingMode, Usage,
10};
11use anyhow::Result;
12use async_trait::async_trait;
13use futures::StreamExt;
14use serde::{Deserialize, Serialize};
15
16use crate::model_capabilities::{
17 ModelCapabilities, default_max_output_tokens, get_model_capabilities,
18};
19use crate::model_features::{ModelFeatures, get_model_features};
20use crate::streaming::{StreamAccumulator, StreamBox, StreamDelta, StreamErrorKind};
21
22/// How a provider satisfies a [`ResponseFormat`](agent_sdk_foundation::llm::ResponseFormat)
23/// structured-output request.
24///
25/// The structured-output runner consults this to decide how to shape the
26/// request and where to read the final structured value from the response.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum StructuredOutputSupport {
29 /// The provider applies the schema natively (JSON-mode /
30 /// structured-outputs) when it sees `request.response_format`. The final
31 /// structured value is the JSON in the assistant's text output.
32 Native,
33 /// The provider has no native JSON-schema mode. The runner injects a
34 /// single forced "respond" tool whose `input_schema` is the output schema,
35 /// and reads the structured value from that tool call's input.
36 ToolForcing,
37}
38
39/// A single model entry returned by a provider's live model-listing endpoint.
40///
41/// This is the *dynamic* counterpart to the static
42/// [`ModelCapabilities`] table: it
43/// is populated from the provider's own `/models` API at runtime, so newly
44/// shipped models appear without an SDK code change. Fields beyond `id` are
45/// optional because not every provider's listing endpoint reports them.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct ModelInfo {
48 /// The model identifier as the provider's chat endpoint expects it.
49 pub id: String,
50 /// Human-friendly display name, when the listing endpoint provides one.
51 pub display_name: Option<String>,
52 /// Maximum total context window in tokens, when reported.
53 pub context_window: Option<u32>,
54 /// Maximum output tokens per response, when reported.
55 pub max_output_tokens: Option<u32>,
56}
57
58#[async_trait]
59pub trait LlmProvider: Send + Sync {
60 /// Non-streaming chat completion.
61 async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome>;
62
63 /// List the models the provider currently exposes, queried live from the
64 /// provider's own model-listing endpoint.
65 ///
66 /// The default implementation returns an error: model discovery is an
67 /// additive capability, so a provider that has not implemented it stays
68 /// source-compatible while reporting that the operation is unsupported.
69 ///
70 /// # Errors
71 ///
72 /// Returns an error when the provider does not support live model listing,
73 /// or when the underlying HTTP request or response parsing fails.
74 async fn list_models(&self) -> Result<Vec<ModelInfo>> {
75 Err(anyhow::anyhow!(
76 "list_models is not supported for provider {}",
77 self.provider()
78 ))
79 }
80
81 /// Cheaply check whether the provider's endpoint is currently reachable,
82 /// without dispatching a billable request.
83 ///
84 /// Durable runtimes poll this to wait out connectivity loss: after a
85 /// transport-classified stream failure
86 /// ([`StreamErrorKind::is_connectivity`]) they probe until reachability
87 /// returns, then re-dispatch the real call. Only an unreachable answer
88 /// keeps that wait free of charge, so implementations should answer from
89 /// a cheap endpoint — [`probe_http_reachability`] against the API host —
90 /// never a model call.
91 ///
92 /// The default returns `true` ("reachable, or unknown"): a provider that
93 /// cannot observe reachability keeps its callers on the bounded retry
94 /// path instead of parking them in an offline wait it can never end.
95 async fn probe_connectivity(&self) -> bool {
96 true
97 }
98
99 /// Streaming chat completion.
100 ///
101 /// Returns a stream of [`StreamDelta`] events. The default implementation
102 /// calls [`chat()`](Self::chat) and converts the result to a single-chunk stream.
103 ///
104 /// Providers should override this method to provide true streaming support.
105 fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
106 Box::pin(async_stream::stream! {
107 match self.chat(request).await {
108 Ok(outcome) => match outcome {
109 ChatOutcome::Success(response) => {
110 // Emit content as deltas
111 for (idx, block) in response.content.iter().enumerate() {
112 match block {
113 ContentBlock::Text { text } => {
114 yield Ok(StreamDelta::TextDelta {
115 delta: text.clone(),
116 block_index: idx,
117 });
118 }
119 ContentBlock::Thinking { thinking, .. } => {
120 yield Ok(StreamDelta::ThinkingDelta {
121 delta: thinking.clone(),
122 block_index: idx,
123 });
124 }
125 ContentBlock::RedactedThinking { .. }
126 | ContentBlock::ToolResult { .. }
127 | ContentBlock::Image { .. }
128 | ContentBlock::Document { .. } => {
129 // Not streamed in the default implementation
130 }
131 ContentBlock::OpaqueReasoning { provider, data } => {
132 yield Ok(StreamDelta::OpaqueReasoning {
133 provider: provider.clone(),
134 data: data.clone(),
135 block_index: idx,
136 });
137 }
138 ContentBlock::ToolUse { id, name, input, thought_signature } => {
139 yield Ok(StreamDelta::ToolUseStart {
140 id: id.clone(),
141 name: name.clone(),
142 block_index: idx,
143 thought_signature: thought_signature.clone(),
144 });
145 yield Ok(StreamDelta::ToolInputDelta {
146 id: id.clone(),
147 delta: serde_json::to_string(input).unwrap_or_default(),
148 block_index: idx,
149 });
150 }
151 // `ContentBlock` is `#[non_exhaustive]`; a future
152 // block kind we cannot stream is skipped rather than
153 // panicking the default fallback.
154 _ => {
155 log::warn!(
156 "chat_stream fallback skipping unrecognized content block at index {idx}"
157 );
158 }
159 }
160 }
161 yield Ok(StreamDelta::Usage(response.usage));
162 yield Ok(StreamDelta::Done {
163 stop_reason: response.stop_reason,
164 });
165 }
166 ChatOutcome::RateLimited(retry_after) => {
167 yield Ok(StreamDelta::Error {
168 message: "Rate limited".to_string(),
169 kind: StreamErrorKind::RateLimited(retry_after),
170 });
171 }
172 ChatOutcome::InvalidRequest(msg) => {
173 yield Ok(StreamDelta::Error {
174 message: msg,
175 kind: StreamErrorKind::InvalidRequest,
176 });
177 }
178 ChatOutcome::ServerError(msg) => {
179 yield Ok(StreamDelta::Error {
180 message: msg,
181 kind: StreamErrorKind::ServerError,
182 });
183 }
184 // `ChatOutcome` is `#[non_exhaustive]`; an outcome this SDK
185 // version does not model is surfaced as an unclassified
186 // (non-recoverable) stream error rather than dropped.
187 _ => {
188 yield Ok(StreamDelta::Error {
189 message: "Unrecognized chat outcome".to_string(),
190 kind: StreamErrorKind::Unknown,
191 });
192 }
193 },
194 Err(e) => yield Err(e),
195 }
196 })
197 }
198
199 fn model(&self) -> &str;
200 fn provider(&self) -> &'static str;
201
202 /// The endpoint that actually serves this provider's calls.
203 ///
204 /// [`Self::provider`] names the *API shape* a request is built for, and
205 /// several distinct endpoints share one shape: `OpenRouter`, `Baseten` and
206 /// `Fireworks` are all reached through [`OpenAIProvider`] pointed at a
207 /// different `base_url`, so all three answer `"openai"`. Durable audit rows
208 /// need to tell *model-X via a gateway* from *model-X native*, so this
209 /// reports the serving route instead — the two coincide only for providers
210 /// that front a single endpoint, which is why the default delegates.
211 ///
212 /// [`OpenAIProvider`]: crate::impls::openai::OpenAIProvider
213 fn route(&self) -> &str {
214 self.provider()
215 }
216
217 /// Provider-owned thinking configuration, if any.
218 fn configured_thinking(&self) -> Option<&ThinkingConfig> {
219 None
220 }
221
222 /// Canonical capability metadata for this provider/model, if known.
223 fn capabilities(&self) -> Option<&'static ModelCapabilities> {
224 get_model_capabilities(self.provider(), self.model()).or_else(|| match self.provider() {
225 "openai-responses" | "openai-codex" => get_model_capabilities("openai", self.model()),
226 "vertex" if self.model().starts_with("claude-") => {
227 get_model_capabilities("anthropic", self.model())
228 }
229 "vertex" => get_model_capabilities("gemini", self.model()),
230 _ => None,
231 })
232 }
233
234 /// API-shape feature metadata for this provider/model, when known.
235 ///
236 /// This complements [`Self::capabilities`] with endpoint, reasoning,
237 /// caching, and tool-control information that does not fit the legacy
238 /// context/pricing record.
239 fn model_features(&self) -> Option<&'static ModelFeatures> {
240 match self.provider() {
241 "openai" | "openai-responses" | "openai-codex" => get_model_features(self.model()),
242 _ => None,
243 }
244 }
245
246 /// Validate a thinking configuration against the provider/model capabilities.
247 ///
248 /// # Errors
249 ///
250 /// Returns an error when the requested thinking mode is not supported by
251 /// the active provider/model capability set.
252 fn validate_thinking_config(&self, thinking: Option<&ThinkingConfig>) -> Result<()> {
253 let Some(thinking) = thinking else {
254 return Ok(());
255 };
256
257 if self
258 .capabilities()
259 .is_some_and(|caps| !caps.supports_thinking)
260 {
261 return Err(anyhow::anyhow!(
262 "thinking is not supported for provider={} model={}",
263 self.provider(),
264 self.model()
265 ));
266 }
267
268 if matches!(thinking.mode, ThinkingMode::Adaptive)
269 && !self
270 .capabilities()
271 .is_some_and(|caps| caps.supports_adaptive_thinking)
272 {
273 return Err(anyhow::anyhow!(
274 "adaptive thinking is not supported for provider={} model={}",
275 self.provider(),
276 self.model()
277 ));
278 }
279
280 Ok(())
281 }
282
283 /// Resolve the effective thinking configuration for a request.
284 ///
285 /// Request-level thinking overrides provider-owned defaults when present.
286 ///
287 /// # Errors
288 ///
289 /// Returns an error when the resolved thinking configuration is not
290 /// supported by the active provider/model capability set.
291 fn resolve_thinking_config(
292 &self,
293 request_thinking: Option<&ThinkingConfig>,
294 ) -> Result<Option<ThinkingConfig>> {
295 let thinking = request_thinking.or_else(|| self.configured_thinking());
296 self.validate_thinking_config(thinking)?;
297 Ok(thinking.cloned())
298 }
299
300 /// Default maximum output tokens for this provider/model when the caller
301 /// does not explicitly override `AgentConfig.max_tokens`.
302 fn default_max_tokens(&self) -> u32 {
303 self.capabilities()
304 .and_then(|caps| caps.max_output_tokens)
305 .or_else(|| default_max_output_tokens(self.provider(), self.model()))
306 .unwrap_or(4096)
307 }
308
309 /// How this provider satisfies a structured-output
310 /// ([`ResponseFormat`](agent_sdk_foundation::llm::ResponseFormat)) request.
311 ///
312 /// Providers with a native JSON-schema / JSON-mode wire field
313 /// (OpenAI-family, Gemini, Vertex) report
314 /// [`StructuredOutputSupport::Native`] and consume
315 /// `request.response_format` directly. Providers without one (Anthropic)
316 /// report [`StructuredOutputSupport::ToolForcing`] so the runner forces a
317 /// single "respond" tool whose schema is the output schema. The default
318 /// is the conservative [`StructuredOutputSupport::ToolForcing`], which
319 /// works for any tool-capable provider.
320 fn structured_output_support(&self) -> StructuredOutputSupport {
321 match self.provider() {
322 "openai" | "openai-responses" | "openai-codex" | "gemini" => {
323 StructuredOutputSupport::Native
324 }
325 // Vertex multiplexes Anthropic and Gemini models. Only the Gemini
326 // side has a native structured-output field; Claude-on-Vertex uses
327 // the Messages API shape, which has no `response_format`.
328 "vertex" if !self.model().starts_with("claude-") => StructuredOutputSupport::Native,
329 _ => StructuredOutputSupport::ToolForcing,
330 }
331 }
332}
333
334/// Timeout for [`probe_http_reachability`]: long enough for a slow TLS
335/// handshake, short enough that a half-open network cannot stall the
336/// connectivity-wait loop it drives.
337const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
338
339/// `true` when an HTTP response arrives from `url` over a fully-established
340/// (and, for HTTPS, certificate-validated) connection.
341///
342/// The status code is deliberately ignored — a 4xx from the provider's edge
343/// proves the network path exactly as well as a 200 does, and needs no
344/// credentials. Every transport failure (DNS, connect, TLS, timeout) reports
345/// unreachable: this feeds offline-wait loops, where the fail-safe direction
346/// is to keep waiting. A captive portal that hijacks the hostname fails
347/// certificate validation and therefore stays unreachable until the real
348/// endpoint is back.
349pub async fn probe_http_reachability(client: &reqwest::Client, url: &str) -> bool {
350 client.head(url).timeout(PROBE_TIMEOUT).send().await.is_ok()
351}
352
353/// Drop extended thinking when the request forces a specific tool.
354///
355/// Anthropic-family models (native Anthropic and Claude-on-Vertex) reject a
356/// request that pairs extended `thinking` with a `tool_choice` that names a
357/// tool (HTTP 400). The forced-tool constraint is orthogonal to *where* the
358/// thinking came from: clearing `ChatRequest.thinking` upstream is not enough,
359/// because [`resolve_thinking_config`](LlmProvider::resolve_thinking_config)
360/// falls back to the provider-configured default when the request field is
361/// `None` — resurrecting thinking on the wire. The Claude request builders
362/// therefore funnel their already-resolved thinking through this at the wire
363/// boundary so a forced-tool request (e.g. structured-output's `respond` tool)
364/// stays well-formed regardless of the provider's configured thinking default.
365///
366/// `ToolChoice::Auto` (and the absence of any `tool_choice`) keeps thinking,
367/// matching the API's rule that thinking is only incompatible with a
368/// tool-forcing choice.
369#[must_use]
370#[cfg(feature = "anthropic")]
371pub(crate) const fn thinking_for_forced_tool(
372 thinking: Option<ThinkingConfig>,
373 tool_choice: Option<&ToolChoice>,
374) -> Option<ThinkingConfig> {
375 match tool_choice {
376 Some(ToolChoice::Tool(_)) => None,
377 Some(ToolChoice::Auto) | None => thinking,
378 }
379}
380
381/// Helper function to consume a stream and collect it into a `ChatResponse`.
382///
383/// This is useful for providers that want to test their streaming implementation
384/// or for cases where you need the full response after streaming.
385///
386/// # Errors
387///
388/// Returns an error if the stream yields an error result.
389pub async fn collect_stream(mut stream: StreamBox<'_>, model: String) -> Result<ChatOutcome> {
390 let mut accumulator = StreamAccumulator::new();
391 let mut last_error: Option<(String, StreamErrorKind)> = None;
392
393 while let Some(result) = stream.next().await {
394 match result {
395 Ok(delta) => {
396 if let StreamDelta::Error { message, kind } = &delta {
397 last_error = Some((message.clone(), *kind));
398 }
399 accumulator.apply(&delta);
400 }
401 Err(e) => return Err(e),
402 }
403 }
404
405 // If we encountered an error during streaming, map kind directly
406 // to the corresponding `ChatOutcome` variant. No string-matching
407 // heuristic is needed because the kind already records the
408 // category at the construction site.
409 if let Some((message, kind)) = last_error {
410 return Ok(match kind {
411 // The rate-limit kind carries whatever retry hint the provider gave
412 // (header or error body), so the reconstructed outcome honours the
413 // server exactly like the non-streaming `chat()` path does.
414 StreamErrorKind::RateLimited(retry_after) => ChatOutcome::RateLimited(retry_after),
415 StreamErrorKind::InvalidRequest => ChatOutcome::InvalidRequest(message),
416 // `StreamErrorKind::ServerError`, plus the `#[non_exhaustive]`
417 // catch-all (`Unknown` / future kinds): an unclassified error is
418 // treated as a (non-recoverable) server error so the caller still
419 // surfaces the failure rather than silently succeeding.
420 _ => ChatOutcome::ServerError(message),
421 });
422 }
423
424 // Extract usage and stop_reason before consuming the accumulator
425 let usage = accumulator.take_usage().unwrap_or(Usage {
426 input_tokens: 0,
427 output_tokens: 0,
428 cached_input_tokens: 0,
429 cache_creation_input_tokens: 0,
430 });
431 let stop_reason = accumulator.take_stop_reason();
432 let content = accumulator.into_content_blocks();
433
434 // Log accumulated response for debugging
435 log::debug!(
436 "Collected stream response: model={} stop_reason={:?} usage={{input_tokens={}, output_tokens={}}} content_blocks={}",
437 model,
438 stop_reason,
439 usage.input_tokens,
440 usage.output_tokens,
441 content.len()
442 );
443 for (i, block) in content.iter().enumerate() {
444 match block {
445 ContentBlock::Text { text } => {
446 log::debug!(" content_block[{}]: Text (len={})", i, text.len());
447 }
448 ContentBlock::Thinking { thinking, .. } => {
449 log::debug!(" content_block[{}]: Thinking (len={})", i, thinking.len());
450 }
451 ContentBlock::RedactedThinking { .. } => {
452 log::debug!(" content_block[{i}]: RedactedThinking");
453 }
454 ContentBlock::OpaqueReasoning { provider, .. } => {
455 log::debug!(
456 " content_block[{i}]: OpaqueReasoning provider={provider} payload=<redacted>"
457 );
458 }
459 ContentBlock::ToolUse {
460 id, name, input, ..
461 } => {
462 log::debug!(" content_block[{i}]: ToolUse id={id} name={name} input={input}");
463 }
464 ContentBlock::ToolResult {
465 tool_use_id,
466 content: result_content,
467 is_error,
468 } => {
469 log::debug!(
470 " content_block[{}]: ToolResult tool_use_id={} is_error={:?} content_len={}",
471 i,
472 tool_use_id,
473 is_error,
474 result_content.len()
475 );
476 }
477 ContentBlock::Image { source } => {
478 log::debug!(
479 " content_block[{i}]: Image media_type={}",
480 source.media_type
481 );
482 }
483 ContentBlock::Document { source } => {
484 log::debug!(
485 " content_block[{i}]: Document media_type={}",
486 source.media_type
487 );
488 }
489 // `ContentBlock` is `#[non_exhaustive]`; log unknown future block
490 // kinds generically so the debug dump stays exhaustive.
491 _ => {
492 log::debug!(" content_block[{i}]: <unrecognized block kind>");
493 }
494 }
495 }
496
497 Ok(ChatOutcome::Success(ChatResponse {
498 id: String::new(),
499 content,
500 model,
501 stop_reason,
502 usage,
503 }))
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use anyhow::Result;
510 use async_trait::async_trait;
511
512 struct Stub {
513 provider: &'static str,
514 model: &'static str,
515 }
516
517 #[async_trait]
518 impl LlmProvider for Stub {
519 async fn chat(&self, _request: ChatRequest) -> Result<ChatOutcome> {
520 Ok(ChatOutcome::ServerError("unused".to_owned()))
521 }
522
523 fn model(&self) -> &str {
524 self.model
525 }
526
527 fn provider(&self) -> &'static str {
528 self.provider
529 }
530 }
531
532 fn support_for(provider: &'static str, model: &'static str) -> StructuredOutputSupport {
533 Stub { provider, model }.structured_output_support()
534 }
535
536 #[test]
537 fn native_providers_report_native_support() {
538 for provider in ["openai", "openai-responses", "openai-codex", "gemini"] {
539 assert_eq!(
540 support_for(provider, "any-model"),
541 StructuredOutputSupport::Native,
542 "{provider} should be native"
543 );
544 }
545 }
546
547 #[test]
548 fn anthropic_reports_tool_forcing() {
549 assert_eq!(
550 support_for("anthropic", "claude-sonnet-4-5"),
551 StructuredOutputSupport::ToolForcing
552 );
553 }
554
555 #[test]
556 fn vertex_is_native_for_gemini_models_and_tool_forcing_for_claude() {
557 assert_eq!(
558 support_for("vertex", "gemini-3-flash-preview"),
559 StructuredOutputSupport::Native
560 );
561 assert_eq!(
562 support_for("vertex", "claude-sonnet-4-5"),
563 StructuredOutputSupport::ToolForcing
564 );
565 }
566
567 #[test]
568 fn unknown_provider_defaults_to_tool_forcing() {
569 assert_eq!(
570 support_for("some-new-provider", "x"),
571 StructuredOutputSupport::ToolForcing
572 );
573 }
574
575 #[test]
576 #[cfg(feature = "anthropic")]
577 fn thinking_for_forced_tool_drops_thinking_when_a_tool_is_forced() {
578 let cfg = ThinkingConfig::new(10_000);
579 let forced = ToolChoice::Tool("respond".to_owned());
580 assert!(
581 thinking_for_forced_tool(Some(cfg), Some(&forced)).is_none(),
582 "thinking must be dropped when tool_choice names a tool"
583 );
584 }
585
586 #[test]
587 #[cfg(feature = "anthropic")]
588 fn thinking_for_forced_tool_keeps_thinking_for_auto_and_none() {
589 let auto = ToolChoice::Auto;
590 assert!(
591 thinking_for_forced_tool(Some(ThinkingConfig::new(10_000)), Some(&auto)).is_some(),
592 "thinking must survive with ToolChoice::Auto"
593 );
594 assert!(
595 thinking_for_forced_tool(Some(ThinkingConfig::new(10_000)), None).is_some(),
596 "thinking must survive with no tool_choice"
597 );
598 }
599
600 #[test]
601 #[cfg(feature = "anthropic")]
602 fn thinking_for_forced_tool_is_a_noop_when_thinking_absent() {
603 let forced = ToolChoice::Tool("respond".to_owned());
604 assert!(thinking_for_forced_tool(None, Some(&forced)).is_none());
605 }
606
607 fn stream_of(deltas: Vec<StreamDelta>) -> StreamBox<'static> {
608 Box::pin(futures::stream::iter(deltas.into_iter().map(Ok)))
609 }
610
611 #[tokio::test]
612 async fn collect_stream_preserves_the_rate_limit_retry_hint() -> Result<()> {
613 let hint = std::time::Duration::from_secs(30);
614 let outcome = collect_stream(
615 stream_of(vec![StreamDelta::Error {
616 message: "Rate limited".to_owned(),
617 kind: StreamErrorKind::RateLimited(Some(hint)),
618 }]),
619 "test-model".to_owned(),
620 )
621 .await?;
622
623 assert!(
624 matches!(outcome, ChatOutcome::RateLimited(Some(delay)) if delay == hint),
625 "streamed Retry-After must survive reconstruction, got {outcome:?}"
626 );
627 Ok(())
628 }
629
630 #[tokio::test]
631 async fn collect_stream_reports_no_hint_when_the_provider_gave_none() -> Result<()> {
632 let outcome = collect_stream(
633 stream_of(vec![StreamDelta::Error {
634 message: "Rate limited".to_owned(),
635 kind: StreamErrorKind::RateLimited(None),
636 }]),
637 "test-model".to_owned(),
638 )
639 .await?;
640
641 assert!(
642 matches!(outcome, ChatOutcome::RateLimited(None)),
643 "a hintless rate limit must not invent a delay, got {outcome:?}"
644 );
645 Ok(())
646 }
647
648 #[tokio::test]
649 async fn default_chat_stream_carries_the_chat_retry_hint_into_the_error_delta() -> Result<()> {
650 // A provider that only implements `chat()` gets its streaming from the
651 // trait's default adapter; a rate limit's hint must not be lost there.
652 struct RateLimitedStub {
653 model: &'static str,
654 }
655
656 #[async_trait]
657 impl LlmProvider for RateLimitedStub {
658 async fn chat(&self, _request: ChatRequest) -> Result<ChatOutcome> {
659 Ok(ChatOutcome::RateLimited(Some(
660 std::time::Duration::from_secs(12),
661 )))
662 }
663 fn model(&self) -> &str {
664 self.model
665 }
666 fn provider(&self) -> &'static str {
667 "stub"
668 }
669 }
670
671 let stub = RateLimitedStub {
672 model: "stub-model",
673 };
674 let request = ChatRequest::new("", vec![agent_sdk_foundation::llm::Message::user("hi")]);
675 let outcome =
676 collect_stream(Box::pin(stub.chat_stream(request)), "stub-model".to_owned()).await?;
677
678 assert!(
679 matches!(outcome, ChatOutcome::RateLimited(Some(delay))
680 if delay == std::time::Duration::from_secs(12)),
681 "default chat_stream must forward chat()'s Retry-After, got {outcome:?}"
682 );
683 Ok(())
684 }
685
686 #[tokio::test]
687 async fn collect_stream_maps_other_kinds_without_a_hint() -> Result<()> {
688 let outcome = collect_stream(
689 stream_of(vec![StreamDelta::Error {
690 message: "boom".to_owned(),
691 kind: StreamErrorKind::ServerError,
692 }]),
693 "test-model".to_owned(),
694 )
695 .await?;
696
697 assert!(matches!(outcome, ChatOutcome::ServerError(msg) if msg == "boom"));
698 Ok(())
699 }
700}