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