#[cfg(feature = "anthropic")]
use agent_sdk_foundation::llm::ToolChoice;
use agent_sdk_foundation::llm::{
ChatOutcome, ChatRequest, ChatResponse, ContentBlock, ThinkingConfig, ThinkingMode, Usage,
};
use anyhow::Result;
use async_trait::async_trait;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use crate::model_capabilities::{
ModelCapabilities, default_max_output_tokens, get_model_capabilities,
};
use crate::model_features::{ModelFeatures, get_model_features};
use crate::streaming::{StreamAccumulator, StreamBox, StreamDelta, StreamErrorKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StructuredOutputSupport {
Native,
ToolForcing,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelInfo {
pub id: String,
pub display_name: Option<String>,
pub context_window: Option<u32>,
pub max_output_tokens: Option<u32>,
}
#[async_trait]
pub trait LlmProvider: Send + Sync {
async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome>;
async fn list_models(&self) -> Result<Vec<ModelInfo>> {
Err(anyhow::anyhow!(
"list_models is not supported for provider {}",
self.provider()
))
}
fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
Box::pin(async_stream::stream! {
match self.chat(request).await {
Ok(outcome) => match outcome {
ChatOutcome::Success(response) => {
for (idx, block) in response.content.iter().enumerate() {
match block {
ContentBlock::Text { text } => {
yield Ok(StreamDelta::TextDelta {
delta: text.clone(),
block_index: idx,
});
}
ContentBlock::Thinking { thinking, .. } => {
yield Ok(StreamDelta::ThinkingDelta {
delta: thinking.clone(),
block_index: idx,
});
}
ContentBlock::RedactedThinking { .. }
| ContentBlock::ToolResult { .. }
| ContentBlock::Image { .. }
| ContentBlock::Document { .. } => {
}
ContentBlock::OpaqueReasoning { provider, data } => {
yield Ok(StreamDelta::OpaqueReasoning {
provider: provider.clone(),
data: data.clone(),
block_index: idx,
});
}
ContentBlock::ToolUse { id, name, input, thought_signature } => {
yield Ok(StreamDelta::ToolUseStart {
id: id.clone(),
name: name.clone(),
block_index: idx,
thought_signature: thought_signature.clone(),
});
yield Ok(StreamDelta::ToolInputDelta {
id: id.clone(),
delta: serde_json::to_string(input).unwrap_or_default(),
block_index: idx,
});
}
_ => {
log::warn!(
"chat_stream fallback skipping unrecognized content block at index {idx}"
);
}
}
}
yield Ok(StreamDelta::Usage(response.usage));
yield Ok(StreamDelta::Done {
stop_reason: response.stop_reason,
});
}
ChatOutcome::RateLimited(_) => {
yield Ok(StreamDelta::Error {
message: "Rate limited".to_string(),
kind: StreamErrorKind::RateLimited,
});
}
ChatOutcome::InvalidRequest(msg) => {
yield Ok(StreamDelta::Error {
message: msg,
kind: StreamErrorKind::InvalidRequest,
});
}
ChatOutcome::ServerError(msg) => {
yield Ok(StreamDelta::Error {
message: msg,
kind: StreamErrorKind::ServerError,
});
}
_ => {
yield Ok(StreamDelta::Error {
message: "Unrecognized chat outcome".to_string(),
kind: StreamErrorKind::Unknown,
});
}
},
Err(e) => yield Err(e),
}
})
}
fn model(&self) -> &str;
fn provider(&self) -> &'static str;
fn configured_thinking(&self) -> Option<&ThinkingConfig> {
None
}
fn capabilities(&self) -> Option<&'static ModelCapabilities> {
get_model_capabilities(self.provider(), self.model()).or_else(|| match self.provider() {
"openai-responses" | "openai-codex" => get_model_capabilities("openai", self.model()),
"vertex" if self.model().starts_with("claude-") => {
get_model_capabilities("anthropic", self.model())
}
"vertex" => get_model_capabilities("gemini", self.model()),
_ => None,
})
}
fn model_features(&self) -> Option<&'static ModelFeatures> {
match self.provider() {
"openai" | "openai-responses" | "openai-codex" => get_model_features(self.model()),
_ => None,
}
}
fn validate_thinking_config(&self, thinking: Option<&ThinkingConfig>) -> Result<()> {
let Some(thinking) = thinking else {
return Ok(());
};
if self
.capabilities()
.is_some_and(|caps| !caps.supports_thinking)
{
return Err(anyhow::anyhow!(
"thinking is not supported for provider={} model={}",
self.provider(),
self.model()
));
}
if matches!(thinking.mode, ThinkingMode::Adaptive)
&& !self
.capabilities()
.is_some_and(|caps| caps.supports_adaptive_thinking)
{
return Err(anyhow::anyhow!(
"adaptive thinking is not supported for provider={} model={}",
self.provider(),
self.model()
));
}
Ok(())
}
fn resolve_thinking_config(
&self,
request_thinking: Option<&ThinkingConfig>,
) -> Result<Option<ThinkingConfig>> {
let thinking = request_thinking.or_else(|| self.configured_thinking());
self.validate_thinking_config(thinking)?;
Ok(thinking.cloned())
}
fn default_max_tokens(&self) -> u32 {
self.capabilities()
.and_then(|caps| caps.max_output_tokens)
.or_else(|| default_max_output_tokens(self.provider(), self.model()))
.unwrap_or(4096)
}
fn structured_output_support(&self) -> StructuredOutputSupport {
match self.provider() {
"openai" | "openai-responses" | "openai-codex" | "gemini" => {
StructuredOutputSupport::Native
}
"vertex" if !self.model().starts_with("claude-") => StructuredOutputSupport::Native,
_ => StructuredOutputSupport::ToolForcing,
}
}
}
#[must_use]
#[cfg(feature = "anthropic")]
pub(crate) const fn thinking_for_forced_tool(
thinking: Option<ThinkingConfig>,
tool_choice: Option<&ToolChoice>,
) -> Option<ThinkingConfig> {
match tool_choice {
Some(ToolChoice::Tool(_)) => None,
Some(ToolChoice::Auto) | None => thinking,
}
}
pub async fn collect_stream(mut stream: StreamBox<'_>, model: String) -> Result<ChatOutcome> {
let mut accumulator = StreamAccumulator::new();
let mut last_error: Option<(String, StreamErrorKind)> = None;
while let Some(result) = stream.next().await {
match result {
Ok(delta) => {
if let StreamDelta::Error { message, kind } = &delta {
last_error = Some((message.clone(), *kind));
}
accumulator.apply(&delta);
}
Err(e) => return Err(e),
}
}
if let Some((message, kind)) = last_error {
return Ok(match kind {
StreamErrorKind::RateLimited => ChatOutcome::RateLimited(None),
StreamErrorKind::InvalidRequest => ChatOutcome::InvalidRequest(message),
_ => ChatOutcome::ServerError(message),
});
}
let usage = accumulator.take_usage().unwrap_or(Usage {
input_tokens: 0,
output_tokens: 0,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
});
let stop_reason = accumulator.take_stop_reason();
let content = accumulator.into_content_blocks();
log::debug!(
"Collected stream response: model={} stop_reason={:?} usage={{input_tokens={}, output_tokens={}}} content_blocks={}",
model,
stop_reason,
usage.input_tokens,
usage.output_tokens,
content.len()
);
for (i, block) in content.iter().enumerate() {
match block {
ContentBlock::Text { text } => {
log::debug!(" content_block[{}]: Text (len={})", i, text.len());
}
ContentBlock::Thinking { thinking, .. } => {
log::debug!(" content_block[{}]: Thinking (len={})", i, thinking.len());
}
ContentBlock::RedactedThinking { .. } => {
log::debug!(" content_block[{i}]: RedactedThinking");
}
ContentBlock::OpaqueReasoning { provider, .. } => {
log::debug!(
" content_block[{i}]: OpaqueReasoning provider={provider} payload=<redacted>"
);
}
ContentBlock::ToolUse {
id, name, input, ..
} => {
log::debug!(" content_block[{i}]: ToolUse id={id} name={name} input={input}");
}
ContentBlock::ToolResult {
tool_use_id,
content: result_content,
is_error,
} => {
log::debug!(
" content_block[{}]: ToolResult tool_use_id={} is_error={:?} content_len={}",
i,
tool_use_id,
is_error,
result_content.len()
);
}
ContentBlock::Image { source } => {
log::debug!(
" content_block[{i}]: Image media_type={}",
source.media_type
);
}
ContentBlock::Document { source } => {
log::debug!(
" content_block[{i}]: Document media_type={}",
source.media_type
);
}
_ => {
log::debug!(" content_block[{i}]: <unrecognized block kind>");
}
}
}
Ok(ChatOutcome::Success(ChatResponse {
id: String::new(),
content,
model,
stop_reason,
usage,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use async_trait::async_trait;
struct Stub {
provider: &'static str,
model: &'static str,
}
#[async_trait]
impl LlmProvider for Stub {
async fn chat(&self, _request: ChatRequest) -> Result<ChatOutcome> {
Ok(ChatOutcome::ServerError("unused".to_owned()))
}
fn model(&self) -> &str {
self.model
}
fn provider(&self) -> &'static str {
self.provider
}
}
fn support_for(provider: &'static str, model: &'static str) -> StructuredOutputSupport {
Stub { provider, model }.structured_output_support()
}
#[test]
fn native_providers_report_native_support() {
for provider in ["openai", "openai-responses", "openai-codex", "gemini"] {
assert_eq!(
support_for(provider, "any-model"),
StructuredOutputSupport::Native,
"{provider} should be native"
);
}
}
#[test]
fn anthropic_reports_tool_forcing() {
assert_eq!(
support_for("anthropic", "claude-sonnet-4-5"),
StructuredOutputSupport::ToolForcing
);
}
#[test]
fn vertex_is_native_for_gemini_models_and_tool_forcing_for_claude() {
assert_eq!(
support_for("vertex", "gemini-3-flash-preview"),
StructuredOutputSupport::Native
);
assert_eq!(
support_for("vertex", "claude-sonnet-4-5"),
StructuredOutputSupport::ToolForcing
);
}
#[test]
fn unknown_provider_defaults_to_tool_forcing() {
assert_eq!(
support_for("some-new-provider", "x"),
StructuredOutputSupport::ToolForcing
);
}
#[test]
#[cfg(feature = "anthropic")]
fn thinking_for_forced_tool_drops_thinking_when_a_tool_is_forced() {
let cfg = ThinkingConfig::new(10_000);
let forced = ToolChoice::Tool("respond".to_owned());
assert!(
thinking_for_forced_tool(Some(cfg), Some(&forced)).is_none(),
"thinking must be dropped when tool_choice names a tool"
);
}
#[test]
#[cfg(feature = "anthropic")]
fn thinking_for_forced_tool_keeps_thinking_for_auto_and_none() {
let auto = ToolChoice::Auto;
assert!(
thinking_for_forced_tool(Some(ThinkingConfig::new(10_000)), Some(&auto)).is_some(),
"thinking must survive with ToolChoice::Auto"
);
assert!(
thinking_for_forced_tool(Some(ThinkingConfig::new(10_000)), None).is_some(),
"thinking must survive with no tool_choice"
);
}
#[test]
#[cfg(feature = "anthropic")]
fn thinking_for_forced_tool_is_a_noop_when_thinking_absent() {
let forced = ToolChoice::Tool("respond".to_owned());
assert!(thinking_for_forced_tool(None, Some(&forced)).is_none());
}
}