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}