Skip to main content

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::streaming::{StreamAccumulator, StreamBox, StreamDelta, StreamErrorKind};
20
21/// How a provider satisfies a [`ResponseFormat`](agent_sdk_foundation::llm::ResponseFormat)
22/// structured-output request.
23///
24/// The structured-output runner consults this to decide how to shape the
25/// request and where to read the final structured value from the response.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum StructuredOutputSupport {
28    /// The provider applies the schema natively (JSON-mode /
29    /// structured-outputs) when it sees `request.response_format`. The final
30    /// structured value is the JSON in the assistant's text output.
31    Native,
32    /// The provider has no native JSON-schema mode. The runner injects a
33    /// single forced "respond" tool whose `input_schema` is the output schema,
34    /// and reads the structured value from that tool call's input.
35    ToolForcing,
36}
37
38/// A single model entry returned by a provider's live model-listing endpoint.
39///
40/// This is the *dynamic* counterpart to the static
41/// [`ModelCapabilities`] table: it
42/// is populated from the provider's own `/models` API at runtime, so newly
43/// shipped models appear without an SDK code change. Fields beyond `id` are
44/// optional because not every provider's listing endpoint reports them.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct ModelInfo {
47    /// The model identifier as the provider's chat endpoint expects it.
48    pub id: String,
49    /// Human-friendly display name, when the listing endpoint provides one.
50    pub display_name: Option<String>,
51    /// Maximum total context window in tokens, when reported.
52    pub context_window: Option<u32>,
53    /// Maximum output tokens per response, when reported.
54    pub max_output_tokens: Option<u32>,
55}
56
57#[async_trait]
58pub trait LlmProvider: Send + Sync {
59    /// Non-streaming chat completion.
60    async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome>;
61
62    /// List the models the provider currently exposes, queried live from the
63    /// provider's own model-listing endpoint.
64    ///
65    /// The default implementation returns an error: model discovery is an
66    /// additive capability, so a provider that has not implemented it stays
67    /// source-compatible while reporting that the operation is unsupported.
68    ///
69    /// # Errors
70    ///
71    /// Returns an error when the provider does not support live model listing,
72    /// or when the underlying HTTP request or response parsing fails.
73    async fn list_models(&self) -> Result<Vec<ModelInfo>> {
74        Err(anyhow::anyhow!(
75            "list_models is not supported for provider {}",
76            self.provider()
77        ))
78    }
79
80    /// Streaming chat completion.
81    ///
82    /// Returns a stream of [`StreamDelta`] events. The default implementation
83    /// calls [`chat()`](Self::chat) and converts the result to a single-chunk stream.
84    ///
85    /// Providers should override this method to provide true streaming support.
86    fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
87        Box::pin(async_stream::stream! {
88            match self.chat(request).await {
89                Ok(outcome) => match outcome {
90                    ChatOutcome::Success(response) => {
91                        // Emit content as deltas
92                        for (idx, block) in response.content.iter().enumerate() {
93                            match block {
94                                ContentBlock::Text { text } => {
95                                    yield Ok(StreamDelta::TextDelta {
96                                        delta: text.clone(),
97                                        block_index: idx,
98                                    });
99                                }
100                                ContentBlock::Thinking { thinking, .. } => {
101                                    yield Ok(StreamDelta::ThinkingDelta {
102                                        delta: thinking.clone(),
103                                        block_index: idx,
104                                    });
105                                }
106                                ContentBlock::RedactedThinking { .. }
107                                | ContentBlock::ToolResult { .. }
108                                | ContentBlock::Image { .. }
109                                | ContentBlock::Document { .. } => {
110                                    // Not streamed in the default implementation
111                                }
112                                ContentBlock::ToolUse { id, name, input, thought_signature } => {
113                                    yield Ok(StreamDelta::ToolUseStart {
114                                        id: id.clone(),
115                                        name: name.clone(),
116                                        block_index: idx,
117                                        thought_signature: thought_signature.clone(),
118                                    });
119                                    yield Ok(StreamDelta::ToolInputDelta {
120                                        id: id.clone(),
121                                        delta: serde_json::to_string(input).unwrap_or_default(),
122                                        block_index: idx,
123                                    });
124                                }
125                                // `ContentBlock` is `#[non_exhaustive]`; a future
126                                // block kind we cannot stream is skipped rather than
127                                // panicking the default fallback.
128                                _ => {
129                                    log::warn!(
130                                        "chat_stream fallback skipping unrecognized content block at index {idx}"
131                                    );
132                                }
133                            }
134                        }
135                        yield Ok(StreamDelta::Usage(response.usage));
136                        yield Ok(StreamDelta::Done {
137                            stop_reason: response.stop_reason,
138                        });
139                    }
140                    ChatOutcome::RateLimited(_) => {
141                        yield Ok(StreamDelta::Error {
142                            message: "Rate limited".to_string(),
143                            kind: StreamErrorKind::RateLimited,
144                        });
145                    }
146                    ChatOutcome::InvalidRequest(msg) => {
147                        yield Ok(StreamDelta::Error {
148                            message: msg,
149                            kind: StreamErrorKind::InvalidRequest,
150                        });
151                    }
152                    ChatOutcome::ServerError(msg) => {
153                        yield Ok(StreamDelta::Error {
154                            message: msg,
155                            kind: StreamErrorKind::ServerError,
156                        });
157                    }
158                    // `ChatOutcome` is `#[non_exhaustive]`; an outcome this SDK
159                    // version does not model is surfaced as an unclassified
160                    // (non-recoverable) stream error rather than dropped.
161                    _ => {
162                        yield Ok(StreamDelta::Error {
163                            message: "Unrecognized chat outcome".to_string(),
164                            kind: StreamErrorKind::Unknown,
165                        });
166                    }
167                },
168                Err(e) => yield Err(e),
169            }
170        })
171    }
172
173    fn model(&self) -> &str;
174    fn provider(&self) -> &'static str;
175
176    /// Provider-owned thinking configuration, if any.
177    fn configured_thinking(&self) -> Option<&ThinkingConfig> {
178        None
179    }
180
181    /// Canonical capability metadata for this provider/model, if known.
182    fn capabilities(&self) -> Option<&'static ModelCapabilities> {
183        get_model_capabilities(self.provider(), self.model()).or_else(|| match self.provider() {
184            "openai-responses" | "openai-codex" => get_model_capabilities("openai", self.model()),
185            "vertex" if self.model().starts_with("claude-") => {
186                get_model_capabilities("anthropic", self.model())
187            }
188            "vertex" => get_model_capabilities("gemini", self.model()),
189            _ => None,
190        })
191    }
192
193    /// Validate a thinking configuration against the provider/model capabilities.
194    ///
195    /// # Errors
196    ///
197    /// Returns an error when the requested thinking mode is not supported by
198    /// the active provider/model capability set.
199    fn validate_thinking_config(&self, thinking: Option<&ThinkingConfig>) -> Result<()> {
200        let Some(thinking) = thinking else {
201            return Ok(());
202        };
203
204        if self
205            .capabilities()
206            .is_some_and(|caps| !caps.supports_thinking)
207        {
208            return Err(anyhow::anyhow!(
209                "thinking is not supported for provider={} model={}",
210                self.provider(),
211                self.model()
212            ));
213        }
214
215        if matches!(thinking.mode, ThinkingMode::Adaptive)
216            && !self
217                .capabilities()
218                .is_some_and(|caps| caps.supports_adaptive_thinking)
219        {
220            return Err(anyhow::anyhow!(
221                "adaptive thinking is not supported for provider={} model={}",
222                self.provider(),
223                self.model()
224            ));
225        }
226
227        Ok(())
228    }
229
230    /// Resolve the effective thinking configuration for a request.
231    ///
232    /// Request-level thinking overrides provider-owned defaults when present.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error when the resolved thinking configuration is not
237    /// supported by the active provider/model capability set.
238    fn resolve_thinking_config(
239        &self,
240        request_thinking: Option<&ThinkingConfig>,
241    ) -> Result<Option<ThinkingConfig>> {
242        let thinking = request_thinking.or_else(|| self.configured_thinking());
243        self.validate_thinking_config(thinking)?;
244        Ok(thinking.cloned())
245    }
246
247    /// Default maximum output tokens for this provider/model when the caller
248    /// does not explicitly override `AgentConfig.max_tokens`.
249    fn default_max_tokens(&self) -> u32 {
250        self.capabilities()
251            .and_then(|caps| caps.max_output_tokens)
252            .or_else(|| default_max_output_tokens(self.provider(), self.model()))
253            .unwrap_or(4096)
254    }
255
256    /// How this provider satisfies a structured-output
257    /// ([`ResponseFormat`](agent_sdk_foundation::llm::ResponseFormat)) request.
258    ///
259    /// Providers with a native JSON-schema / JSON-mode wire field
260    /// (OpenAI-family, Gemini, Vertex) report
261    /// [`StructuredOutputSupport::Native`] and consume
262    /// `request.response_format` directly. Providers without one (Anthropic)
263    /// report [`StructuredOutputSupport::ToolForcing`] so the runner forces a
264    /// single "respond" tool whose schema is the output schema. The default
265    /// is the conservative [`StructuredOutputSupport::ToolForcing`], which
266    /// works for any tool-capable provider.
267    fn structured_output_support(&self) -> StructuredOutputSupport {
268        match self.provider() {
269            "openai" | "openai-responses" | "openai-codex" | "gemini" => {
270                StructuredOutputSupport::Native
271            }
272            // Vertex multiplexes Anthropic and Gemini models. Only the Gemini
273            // side has a native structured-output field; Claude-on-Vertex uses
274            // the Messages API shape, which has no `response_format`.
275            "vertex" if !self.model().starts_with("claude-") => StructuredOutputSupport::Native,
276            _ => StructuredOutputSupport::ToolForcing,
277        }
278    }
279}
280
281/// Drop extended thinking when the request forces a specific tool.
282///
283/// Anthropic-family models (native Anthropic and Claude-on-Vertex) reject a
284/// request that pairs extended `thinking` with a `tool_choice` that names a
285/// tool (HTTP 400). The forced-tool constraint is orthogonal to *where* the
286/// thinking came from: clearing `ChatRequest.thinking` upstream is not enough,
287/// because [`resolve_thinking_config`](LlmProvider::resolve_thinking_config)
288/// falls back to the provider-configured default when the request field is
289/// `None` — resurrecting thinking on the wire. The Claude request builders
290/// therefore funnel their already-resolved thinking through this at the wire
291/// boundary so a forced-tool request (e.g. structured-output's `respond` tool)
292/// stays well-formed regardless of the provider's configured thinking default.
293///
294/// `ToolChoice::Auto` (and the absence of any `tool_choice`) keeps thinking,
295/// matching the API's rule that thinking is only incompatible with a
296/// tool-forcing choice.
297#[must_use]
298#[cfg(feature = "anthropic")]
299pub(crate) const fn thinking_for_forced_tool(
300    thinking: Option<ThinkingConfig>,
301    tool_choice: Option<&ToolChoice>,
302) -> Option<ThinkingConfig> {
303    match tool_choice {
304        Some(ToolChoice::Tool(_)) => None,
305        Some(ToolChoice::Auto) | None => thinking,
306    }
307}
308
309/// Helper function to consume a stream and collect it into a `ChatResponse`.
310///
311/// This is useful for providers that want to test their streaming implementation
312/// or for cases where you need the full response after streaming.
313///
314/// # Errors
315///
316/// Returns an error if the stream yields an error result.
317pub async fn collect_stream(mut stream: StreamBox<'_>, model: String) -> Result<ChatOutcome> {
318    let mut accumulator = StreamAccumulator::new();
319    let mut last_error: Option<(String, StreamErrorKind)> = None;
320
321    while let Some(result) = stream.next().await {
322        match result {
323            Ok(delta) => {
324                if let StreamDelta::Error { message, kind } = &delta {
325                    last_error = Some((message.clone(), *kind));
326                }
327                accumulator.apply(&delta);
328            }
329            Err(e) => return Err(e),
330        }
331    }
332
333    // If we encountered an error during streaming, map kind directly
334    // to the corresponding `ChatOutcome` variant.  No string-matching
335    // heuristic is needed because the kind already records the
336    // category at the construction site.
337    if let Some((message, kind)) = last_error {
338        return Ok(match kind {
339            // The streaming error channel does not carry a `Retry-After`, so
340            // the reconstructed outcome reports no server-supplied delay.
341            StreamErrorKind::RateLimited => ChatOutcome::RateLimited(None),
342            StreamErrorKind::InvalidRequest => ChatOutcome::InvalidRequest(message),
343            // `StreamErrorKind::ServerError`, plus the `#[non_exhaustive]`
344            // catch-all (`Unknown` / future kinds): an unclassified error is
345            // treated as a (non-recoverable) server error so the caller still
346            // surfaces the failure rather than silently succeeding.
347            _ => ChatOutcome::ServerError(message),
348        });
349    }
350
351    // Extract usage and stop_reason before consuming the accumulator
352    let usage = accumulator.take_usage().unwrap_or(Usage {
353        input_tokens: 0,
354        output_tokens: 0,
355        cached_input_tokens: 0,
356        cache_creation_input_tokens: 0,
357    });
358    let stop_reason = accumulator.take_stop_reason();
359    let content = accumulator.into_content_blocks();
360
361    // Log accumulated response for debugging
362    log::debug!(
363        "Collected stream response: model={} stop_reason={:?} usage={{input_tokens={}, output_tokens={}}} content_blocks={}",
364        model,
365        stop_reason,
366        usage.input_tokens,
367        usage.output_tokens,
368        content.len()
369    );
370    for (i, block) in content.iter().enumerate() {
371        match block {
372            ContentBlock::Text { text } => {
373                log::debug!("  content_block[{}]: Text (len={})", i, text.len());
374            }
375            ContentBlock::Thinking { thinking, .. } => {
376                log::debug!("  content_block[{}]: Thinking (len={})", i, thinking.len());
377            }
378            ContentBlock::RedactedThinking { .. } => {
379                log::debug!("  content_block[{i}]: RedactedThinking");
380            }
381            ContentBlock::ToolUse {
382                id, name, input, ..
383            } => {
384                log::debug!("  content_block[{i}]: ToolUse id={id} name={name} input={input}");
385            }
386            ContentBlock::ToolResult {
387                tool_use_id,
388                content: result_content,
389                is_error,
390            } => {
391                log::debug!(
392                    "  content_block[{}]: ToolResult tool_use_id={} is_error={:?} content_len={}",
393                    i,
394                    tool_use_id,
395                    is_error,
396                    result_content.len()
397                );
398            }
399            ContentBlock::Image { source } => {
400                log::debug!(
401                    "  content_block[{i}]: Image media_type={}",
402                    source.media_type
403                );
404            }
405            ContentBlock::Document { source } => {
406                log::debug!(
407                    "  content_block[{i}]: Document media_type={}",
408                    source.media_type
409                );
410            }
411            // `ContentBlock` is `#[non_exhaustive]`; log unknown future block
412            // kinds generically so the debug dump stays exhaustive.
413            _ => {
414                log::debug!("  content_block[{i}]: <unrecognized block kind>");
415            }
416        }
417    }
418
419    Ok(ChatOutcome::Success(ChatResponse {
420        id: String::new(),
421        content,
422        model,
423        stop_reason,
424        usage,
425    }))
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use anyhow::Result;
432    use async_trait::async_trait;
433
434    struct Stub {
435        provider: &'static str,
436        model: &'static str,
437    }
438
439    #[async_trait]
440    impl LlmProvider for Stub {
441        async fn chat(&self, _request: ChatRequest) -> Result<ChatOutcome> {
442            Ok(ChatOutcome::ServerError("unused".to_owned()))
443        }
444
445        fn model(&self) -> &str {
446            self.model
447        }
448
449        fn provider(&self) -> &'static str {
450            self.provider
451        }
452    }
453
454    fn support_for(provider: &'static str, model: &'static str) -> StructuredOutputSupport {
455        Stub { provider, model }.structured_output_support()
456    }
457
458    #[test]
459    fn native_providers_report_native_support() {
460        for provider in ["openai", "openai-responses", "openai-codex", "gemini"] {
461            assert_eq!(
462                support_for(provider, "any-model"),
463                StructuredOutputSupport::Native,
464                "{provider} should be native"
465            );
466        }
467    }
468
469    #[test]
470    fn anthropic_reports_tool_forcing() {
471        assert_eq!(
472            support_for("anthropic", "claude-sonnet-4-5"),
473            StructuredOutputSupport::ToolForcing
474        );
475    }
476
477    #[test]
478    fn vertex_is_native_for_gemini_models_and_tool_forcing_for_claude() {
479        assert_eq!(
480            support_for("vertex", "gemini-3-flash-preview"),
481            StructuredOutputSupport::Native
482        );
483        assert_eq!(
484            support_for("vertex", "claude-sonnet-4-5"),
485            StructuredOutputSupport::ToolForcing
486        );
487    }
488
489    #[test]
490    fn unknown_provider_defaults_to_tool_forcing() {
491        assert_eq!(
492            support_for("some-new-provider", "x"),
493            StructuredOutputSupport::ToolForcing
494        );
495    }
496
497    #[test]
498    #[cfg(feature = "anthropic")]
499    fn thinking_for_forced_tool_drops_thinking_when_a_tool_is_forced() {
500        let cfg = ThinkingConfig::new(10_000);
501        let forced = ToolChoice::Tool("respond".to_owned());
502        assert!(
503            thinking_for_forced_tool(Some(cfg), Some(&forced)).is_none(),
504            "thinking must be dropped when tool_choice names a tool"
505        );
506    }
507
508    #[test]
509    #[cfg(feature = "anthropic")]
510    fn thinking_for_forced_tool_keeps_thinking_for_auto_and_none() {
511        let auto = ToolChoice::Auto;
512        assert!(
513            thinking_for_forced_tool(Some(ThinkingConfig::new(10_000)), Some(&auto)).is_some(),
514            "thinking must survive with ToolChoice::Auto"
515        );
516        assert!(
517            thinking_for_forced_tool(Some(ThinkingConfig::new(10_000)), None).is_some(),
518            "thinking must survive with no tool_choice"
519        );
520    }
521
522    #[test]
523    #[cfg(feature = "anthropic")]
524    fn thinking_for_forced_tool_is_a_noop_when_thinking_absent() {
525        let forced = ToolChoice::Tool("respond".to_owned());
526        assert!(thinking_for_forced_tool(None, Some(&forced)).is_none());
527    }
528}