Add #[source] error chaining to SdkError and tool_type field to ToolCall

Preserve original error chains (reqwest, serde_json, etc.) in SdkError
via Arc<dyn Error>-backed #[source] fields on Network, RequestTimeout,
Stream, and Configuration variants. This makes production debugging of
network/TLS/DNS issues easier since error reporters can now walk the
full chain. Serde-compatible via #[serde(skip)] — message string still
carries the text for serialized forms.

Also add a `type` field to ToolCall (defaulting to "function") so
non-function tool types from providers won't be silently mishandled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-23 15:38:00 -04:00
parent 86b7e4f7ba
commit b91ae3df47
No known key found for this signature in database
17 changed files with 254 additions and 99 deletions

View file

@ -755,8 +755,8 @@ match result {
println!("HTTP {code}");
}
}
Err(SdkError::RequestTimeout { message }) => println!("Timeout: {message}"),
Err(SdkError::Network { message }) => println!("Network: {message}"),
Err(SdkError::RequestTimeout { message, .. }) => println!("Timeout: {message}"),
Err(SdkError::Network { message, .. }) => println!("Network: {message}"),
Err(SdkError::Abort { message }) => println!("Cancelled: {message}"),
Err(e) => println!("Other: {e}"),
Ok(_) => {}

View file

@ -45,6 +45,7 @@ mod tests {
fn agent_error_from_sdk_error() {
let sdk_err = SdkError::Network {
message: "connection refused".into(),
source: None,
};
let agent_err = AgentError::from(sdk_err);
assert!(matches!(agent_err, AgentError::Llm(_)));
@ -87,6 +88,7 @@ mod tests {
fn serde_roundtrip_llm_network() {
let err = AgentError::Llm(SdkError::Network {
message: "connection refused".into(),
source: None,
});
let json = serde_json::to_string(&err).unwrap();
let deserialized: AgentError = serde_json::from_str(&json).unwrap();
@ -150,6 +152,7 @@ mod tests {
let errors: Vec<AgentError> = vec![
AgentError::Llm(SdkError::Network {
message: "refused".into(),
source: None,
}),
AgentError::SessionClosed,
AgentError::InvalidState("reason".into()),
@ -167,6 +170,7 @@ mod tests {
fn serde_tag_format_llm() {
let err = AgentError::Llm(SdkError::Network {
message: "refused".into(),
source: None,
});
let json = serde_json::to_string(&err).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();

View file

@ -770,6 +770,7 @@ impl Session {
None => {
return Err(self.emit_llm_error(SdkError::Stream {
message: "Stream ended without a Finish event (after retries)".into(),
source: None,
}))
}
};
@ -955,7 +956,7 @@ impl Session {
.and_then(fabro_model::Model::max_output)
}),
stop_sequences: None,
reasoning_effort: self.config.reasoning_effort.clone(),
reasoning_effort: self.config.reasoning_effort,
speed: self.config.speed.clone(),
metadata: None,
provider_options: None,
@ -1038,6 +1039,7 @@ mod tests {
async fn complete(&self, _request: &Request) -> Result<Response, SdkError> {
Err(SdkError::Configuration {
message: "ScriptedStreamProvider does not implement complete()".into(),
source: None,
})
}
@ -2032,6 +2034,7 @@ mod tests {
partial_text: "partial".into(),
error: SdkError::Stream {
message: "connection reset".into(),
source: None,
},
});
let client = make_client(provider as Arc<dyn ProviderAdapter>).await;
@ -2293,6 +2296,7 @@ mod tests {
async fn complete(&self, _request: &Request) -> Result<Response, SdkError> {
Err(SdkError::Stream {
message: "summarization failed".into(),
source: None,
})
}

View file

@ -389,6 +389,7 @@ mod tests {
ToolCall {
id: id.to_string(),
name: name.to_string(),
tool_type: "function".to_string(),
arguments: args,
raw_arguments: None,
provider_metadata: None,

View file

@ -671,6 +671,7 @@ mod tests {
let event = AgentEvent::Error {
error: crate::error::AgentError::Llm(fabro_llm::error::SdkError::Network {
message: "refused".into(),
source: None,
}),
};
let json = serde_json::to_string(&event).unwrap();

View file

@ -154,6 +154,7 @@ impl Client {
.or(self.default_provider.as_deref())
.ok_or_else(|| SdkError::Configuration {
message: "No provider specified and no default provider set".into(),
source: None,
})?;
self.providers
@ -161,6 +162,7 @@ impl Client {
.cloned()
.ok_or_else(|| SdkError::Configuration {
message: format!("Provider '{provider_name}' not registered"),
source: None,
})
}

View file

@ -51,6 +51,8 @@ impl ProviderErrorDetail {
}
}
use std::sync::Arc;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, thiserror::Error)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SdkError {
@ -61,16 +63,31 @@ pub enum SdkError {
},
#[error("Request timed out: {message}")]
RequestTimeout { message: String },
RequestTimeout {
message: String,
#[source]
#[serde(skip)]
source: Option<Arc<dyn std::error::Error + Send + Sync>>,
},
#[error("Request aborted: {message}")]
Abort { message: String },
#[error("Network error: {message}")]
Network { message: String },
Network {
message: String,
#[source]
#[serde(skip)]
source: Option<Arc<dyn std::error::Error + Send + Sync>>,
},
#[error("Stream error: {message}")]
Stream { message: String },
Stream {
message: String,
#[source]
#[serde(skip)]
source: Option<Arc<dyn std::error::Error + Send + Sync>>,
},
#[error("Invalid tool call: {message}")]
InvalidToolCall { message: String },
@ -79,13 +96,58 @@ pub enum SdkError {
NoObjectGenerated { message: String },
#[error("Configuration error: {message}")]
Configuration { message: String },
Configuration {
message: String,
#[source]
#[serde(skip)]
source: Option<Arc<dyn std::error::Error + Send + Sync>>,
},
#[error("Unsupported tool choice: {message}")]
UnsupportedToolChoice { message: String },
}
impl SdkError {
pub fn network(
message: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self::Network {
message: message.into(),
source: Some(Arc::new(source)),
}
}
pub fn request_timeout(
message: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self::RequestTimeout {
message: message.into(),
source: Some(Arc::new(source)),
}
}
pub fn stream_error(
message: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self::Stream {
message: message.into(),
source: Some(Arc::new(source)),
}
}
pub fn configuration_error(
message: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self::Configuration {
message: message.into(),
source: Some(Arc::new(source)),
}
}
#[must_use]
pub const fn retryable(&self) -> bool {
match self {
@ -228,6 +290,7 @@ pub fn error_from_status_code(
408 => {
return SdkError::RequestTimeout {
message: detail.message,
source: None,
}
}
413 => ProviderErrorKind::ContextLength,
@ -285,6 +348,7 @@ pub fn error_from_grpc_status(
"DEADLINE_EXCEEDED" => {
return SdkError::RequestTimeout {
message: detail.message,
source: None,
}
}
_ => ProviderErrorKind::Server,
@ -299,6 +363,7 @@ pub fn error_from_grpc_status(
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error as _;
#[test]
fn retryable_classification() {
@ -333,16 +398,19 @@ mod tests {
let timeout = SdkError::RequestTimeout {
message: "timed out".into(),
source: None,
};
assert!(!timeout.retryable());
let network = SdkError::Network {
message: "connection refused".into(),
source: None,
};
assert!(network.retryable());
let config = SdkError::Configuration {
message: "missing provider".into(),
source: None,
};
assert!(!config.retryable());
}
@ -786,6 +854,7 @@ mod tests {
let err = SdkError::Configuration {
message: "no provider".into(),
source: None,
};
assert_eq!(err.to_string(), "Configuration error: no provider");
}
@ -803,6 +872,7 @@ mod tests {
let err = SdkError::Network {
message: "refused".into(),
source: None,
};
assert_eq!(err.status_code(), None);
}
@ -820,6 +890,7 @@ mod tests {
fn provider_name_defaults_to_unknown() {
let err = SdkError::Network {
message: "refused".into(),
source: None,
};
assert_eq!(err.provider_name(), "unknown");
}
@ -850,17 +921,20 @@ mod tests {
#[test]
fn failover_eligible_transient_non_provider_errors() {
assert!(SdkError::RequestTimeout {
message: "timed out".into()
message: "timed out".into(),
source: None,
}
.failover_eligible());
assert!(SdkError::Network {
message: "refused".into()
message: "refused".into(),
source: None,
}
.failover_eligible());
assert!(SdkError::Stream {
message: "broken".into()
message: "broken".into(),
source: None,
}
.failover_eligible());
}
@ -897,7 +971,8 @@ mod tests {
#[test]
fn failover_not_eligible_non_provider_errors() {
assert!(!SdkError::Configuration {
message: "bad".into()
message: "bad".into(),
source: None,
}
.failover_eligible());
@ -1013,21 +1088,24 @@ mod tests {
fn failure_signature_hint_non_provider_variants() {
assert_eq!(
SdkError::RequestTimeout {
message: "timed out".into()
message: "timed out".into(),
source: None,
}
.failure_signature_hint(),
"api_transient|unknown|timeout"
);
assert_eq!(
SdkError::Network {
message: "refused".into()
message: "refused".into(),
source: None,
}
.failure_signature_hint(),
"api_transient|unknown|network"
);
assert_eq!(
SdkError::Stream {
message: "broken".into()
message: "broken".into(),
source: None,
}
.failure_signature_hint(),
"api_transient|unknown|stream"
@ -1041,7 +1119,8 @@ mod tests {
);
assert_eq!(
SdkError::Configuration {
message: "bad".into()
message: "bad".into(),
source: None,
}
.failure_signature_hint(),
"api_deterministic|unknown|configuration"
@ -1068,4 +1147,31 @@ mod tests {
"api_deterministic|unknown|unsupported_tool_choice"
);
}
#[test]
fn sdk_error_source_chaining() {
let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
let err = SdkError::network("connection failed", io_err);
assert!(err.source().is_some());
}
#[test]
fn sdk_error_source_chain_walkable() {
let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
let err = SdkError::network("connection failed", io_err);
// The source chain is walkable — the Arc wrapper preserves the inner error's display
let source = err.source().unwrap();
assert!(source.to_string().contains("refused"));
}
#[test]
fn sdk_error_serde_roundtrip_without_source() {
let io_err = std::io::Error::new(std::io::ErrorKind::Other, "boom");
let err = SdkError::network("network failed", io_err);
let json = serde_json::to_string(&err).unwrap();
let deserialized: SdkError = serde_json::from_str(&json).unwrap();
// source is lost through serde, message is preserved
assert!(deserialized.source().is_none());
assert_eq!(deserialized.to_string(), "Network error: network failed");
}
}

View file

@ -43,6 +43,7 @@ fn build_initial_messages(params: &GenerateParams) -> Result<Vec<Message>, SdkEr
if params.messages.is_some() {
return Err(SdkError::Configuration {
message: "Cannot specify both 'prompt' and 'messages'".into(),
source: None,
});
}
messages.push(Message::user(prompt));
@ -68,7 +69,7 @@ fn build_request(
top_p: params.top_p,
max_tokens: params.max_tokens,
stop_sequences: params.stop_sequences.clone(),
reasoning_effort: params.reasoning_effort.clone(),
reasoning_effort: params.reasoning_effort,
speed: params.speed.clone(),
metadata: params.metadata.clone(),
provider_options: params.provider_options.clone(),
@ -165,6 +166,7 @@ pub async fn generate(params: GenerateParams) -> Result<GenerateResult, SdkError
warn!(timeout_secs = per_step, "Per-step timeout exceeded");
SdkError::RequestTimeout {
message: format!("Per-step timeout of {per_step}s exceeded"),
source: None,
}
})?
} else {
@ -263,6 +265,7 @@ pub async fn generate(params: GenerateParams) -> Result<GenerateResult, SdkError
warn!(timeout_secs = total, "Total generation timeout exceeded");
SdkError::RequestTimeout {
message: format!("Total timeout of {total}s exceeded"),
source: None,
}
})?
} else {
@ -699,6 +702,7 @@ async fn stream_with_tool_loop(params: GenerateParams) -> Result<StreamEventStre
.unwrap_or_else(|_| {
Err(SdkError::RequestTimeout {
message: format!("Per-step timeout of {per_step}s exceeded"),
source: None,
})
})
} else {
@ -829,6 +833,7 @@ async fn stream_with_tool_loop(params: GenerateParams) -> Result<StreamEventStre
let _ = tx
.send(Err(SdkError::RequestTimeout {
message: format!("Total timeout of {total}s exceeded"),
source: None,
}))
.await;
}
@ -856,6 +861,7 @@ async fn stream_generate_raw(
.await
.map_err(|_| SdkError::RequestTimeout {
message: format!("Per-step timeout of {per_step}s exceeded"),
source: None,
})??
} else {
client.stream(&request).await?
@ -893,6 +899,7 @@ async fn stream_generate_raw(
Err(_) => Some((
Err(SdkError::RequestTimeout {
message: format!("Total timeout of {total_copy}s exceeded"),
source: None,
}),
(stream, true),
)),
@ -1081,6 +1088,7 @@ pub async fn stream_object(
Err(e) => {
events.push(Err(SdkError::Stream {
message: format!("{e}"),
source: None,
}));
}
}

View file

@ -75,6 +75,7 @@ impl Adapter {
response.ok_or_else(|| SdkError::Stream {
message: "complete_via_stream: stream ended without a Finish event".to_string(),
source: None,
})
}
}
@ -1230,8 +1231,11 @@ impl ProviderAdapter for Adapter {
}
let (body, headers) = send_and_read_response(req, &self.provider_name, "type").await?;
let api_resp: ApiResponse = serde_json::from_str(&body).map_err(|e| SdkError::Network {
message: format!("failed to parse {} response: {e}", self.provider_name),
let api_resp: ApiResponse = serde_json::from_str(&body).map_err(|e| {
SdkError::network(
format!("failed to parse {} response: {e}", self.provider_name),
e,
)
})?;
let content_parts: Vec<ContentPart> = api_resp
@ -1290,16 +1294,18 @@ impl ProviderAdapter for Adapter {
}
let (_api_request, req_builder) = build_api_request(self, request, true);
let http_resp = req_builder.send().await.map_err(|e| SdkError::Network {
message: e.to_string(),
})?;
let http_resp = req_builder
.send()
.await
.map_err(|e| SdkError::network(e.to_string(), e))?;
let status = http_resp.status();
if !status.is_success() {
let retry_after = parse_retry_after(http_resp.headers());
let body = http_resp.text().await.map_err(|e| SdkError::Network {
message: e.to_string(),
})?;
let body = http_resp
.text()
.await
.map_err(|e| SdkError::network(e.to_string(), e))?;
let (msg, code, raw) = parse_error_body(&body, "type");
return Err(crate::error::error_from_status_code(
status.as_u16(),
@ -1336,9 +1342,10 @@ impl ProviderAdapter for Adapter {
Ok(v) => v,
Err(e) => {
return Some((
Err(SdkError::Stream {
message: format!("failed to parse SSE data: {e}"),
}),
Err(SdkError::stream_error(
format!("failed to parse SSE data: {e}"),
e,
)),
state,
));
}

View file

@ -170,23 +170,20 @@ pub async fn send_and_read_response(
let http_resp = request.send().await.map_err(|e| {
if e.is_timeout() {
warn!(provider = %provider, error = %e, "Provider request timed out");
SdkError::RequestTimeout {
message: format!("{provider}: {e}"),
}
SdkError::request_timeout(format!("{provider}: {e}"), e)
} else {
warn!(provider = %provider, error = %e, "Provider network error");
SdkError::Network {
message: e.to_string(),
}
SdkError::network(e.to_string(), e)
}
})?;
let status = http_resp.status();
let retry_after = parse_retry_after(http_resp.headers());
let headers = http_resp.headers().clone();
let body = http_resp.text().await.map_err(|e| SdkError::Network {
message: e.to_string(),
})?;
let body = http_resp
.text()
.await
.map_err(|e| SdkError::network(e.to_string(), e))?;
if !status.is_success() {
warn!(provider = %provider, status = status.as_u16(), "Provider returned error");
@ -258,14 +255,13 @@ impl LineReader {
return Ok(Some(remaining));
}
Ok(Err(e)) => {
return Err(SdkError::Stream {
message: e.to_string(),
});
return Err(SdkError::stream_error(e.to_string(), e));
}
Err(_) => {
warn!("Stream read timed out waiting for next event");
return Err(SdkError::Stream {
message: "stream read timed out waiting for next event".to_string(),
source: None,
});
}
}

View file

@ -62,8 +62,8 @@ fn map_stop_reason(reason: &str) -> FinishReason {
/// Build the JSON request body by serializing the `Request` and injecting
/// the `stream` flag.
fn build_body(request: &Request, stream: bool) -> Result<serde_json::Value, SdkError> {
let mut body = serde_json::to_value(request).map_err(|e| SdkError::Configuration {
message: format!("failed to serialize request: {e}"),
let mut body = serde_json::to_value(request).map_err(|e| {
SdkError::configuration_error(format!("failed to serialize request: {e}"), e)
})?;
body["stream"] = serde_json::Value::Bool(stream);
Ok(body)
@ -80,13 +80,9 @@ async fn send_request(
) -> Result<reqwest::Response, SdkError> {
let http_resp = client.post(url).json(body).send().await.map_err(|e| {
if e.is_timeout() {
SdkError::RequestTimeout {
message: e.to_string(),
}
SdkError::request_timeout(e.to_string(), e)
} else {
SdkError::Network {
message: e.to_string(),
}
SdkError::network(e.to_string(), e)
}
})?;
@ -127,13 +123,14 @@ impl ProviderAdapter for Adapter {
let body = build_body(request, false)?;
let http_resp = send_request(&self.client, &url, &body, &self.provider_name).await?;
let resp_body = http_resp.text().await.map_err(|e| SdkError::Network {
message: e.to_string(),
})?;
let resp_body = http_resp
.text()
.await
.map_err(|e| SdkError::network(e.to_string(), e))?;
let server_resp: ServerCompletionResponse =
serde_json::from_str(&resp_body).map_err(|e| SdkError::Stream {
message: format!("failed to parse completion response: {e}"),
serde_json::from_str(&resp_body).map_err(|e| {
SdkError::stream_error(format!("failed to parse completion response: {e}"), e)
})?;
let finish_reason = map_stop_reason(&server_resp.stop_reason);
@ -175,11 +172,10 @@ impl ProviderAdapter for Adapter {
Ok(event) => return Some((Ok(event), reader)),
Err(e) => {
return Some((
Err(SdkError::Stream {
message: format!(
"failed to parse stream event: {e}"
),
}),
Err(SdkError::stream_error(
format!("failed to parse stream event: {e}"),
e,
)),
reader,
));
}

View file

@ -508,22 +508,19 @@ async fn send_gemini_response(
) -> Result<(String, reqwest::header::HeaderMap), SdkError> {
let http_resp = request.send().await.map_err(|e| {
if e.is_timeout() {
SdkError::RequestTimeout {
message: format!("gemini: {e}"),
}
SdkError::request_timeout(format!("gemini: {e}"), e)
} else {
SdkError::Network {
message: e.to_string(),
}
SdkError::network(e.to_string(), e)
}
})?;
let status = http_resp.status();
let retry_after = parse_retry_after(http_resp.headers());
let headers = http_resp.headers().clone();
let body = http_resp.text().await.map_err(|e| SdkError::Network {
message: e.to_string(),
})?;
let body = http_resp
.text()
.await
.map_err(|e| SdkError::network(e.to_string(), e))?;
if !status.is_success() {
let (msg, code, raw) = parse_error_body(&body, "status");
@ -568,16 +565,18 @@ fn gemini_error(
async fn send_streaming_request(
request: reqwest::RequestBuilder,
) -> Result<reqwest::Response, SdkError> {
let http_resp = request.send().await.map_err(|e| SdkError::Network {
message: e.to_string(),
})?;
let http_resp = request
.send()
.await
.map_err(|e| SdkError::network(e.to_string(), e))?;
let status = http_resp.status();
if !status.is_success() {
let retry_after = parse_retry_after(http_resp.headers());
let body = http_resp.text().await.map_err(|e| SdkError::Network {
message: e.to_string(),
})?;
let body = http_resp
.text()
.await
.map_err(|e| SdkError::network(e.to_string(), e))?;
let (msg, code, raw) = parse_error_body(&body, "status");
return Err(gemini_error(status.as_u16(), msg, code, raw, retry_after));
}
@ -635,9 +634,10 @@ fn process_sse_stream(
Ok(v) => v,
Err(e) => {
return Some((
Err(SdkError::Stream {
message: format!("failed to parse Gemini SSE chunk: {e}"),
}),
Err(SdkError::stream_error(
format!("failed to parse Gemini SSE chunk: {e}"),
e,
)),
state,
));
}
@ -903,9 +903,8 @@ impl ProviderAdapter for Adapter {
}
let (body, headers) = send_gemini_response(gemini_req).await?;
let api_resp: ApiResponse = serde_json::from_str(&body).map_err(|e| SdkError::Network {
message: format!("failed to parse Gemini response: {e}"),
})?;
let api_resp: ApiResponse = serde_json::from_str(&body)
.map_err(|e| SdkError::network(format!("failed to parse Gemini response: {e}"), e))?;
let candidate = api_resp
.candidates

View file

@ -107,6 +107,7 @@ impl Adapter {
}
last_response.ok_or_else(|| SdkError::Network {
message: "Stream ended without a finish event".into(),
source: None,
})
}
}
@ -954,9 +955,8 @@ impl ProviderAdapter for Adapter {
}
let (body, headers) = send_and_read_response(req, "openai", "type").await?;
let api_resp: ApiResponse = serde_json::from_str(&body).map_err(|e| SdkError::Network {
message: format!("failed to parse OpenAI response: {e}"),
})?;
let api_resp: ApiResponse = serde_json::from_str(&body)
.map_err(|e| SdkError::network(format!("failed to parse OpenAI response: {e}"), e))?;
let (content_parts, has_tool_calls) = parse_output(&api_resp.output);
let finish_reason = map_finish_reason(api_resp.status.as_deref(), has_tool_calls);
@ -1009,16 +1009,15 @@ impl ProviderAdapter for Adapter {
.json(&request_body)
.send()
.await
.map_err(|e| SdkError::Network {
message: e.to_string(),
})?;
.map_err(|e| SdkError::network(e.to_string(), e))?;
let status = http_resp.status();
if !status.is_success() {
let retry_after = parse_retry_after(http_resp.headers());
let body = http_resp.text().await.map_err(|e| SdkError::Network {
message: e.to_string(),
})?;
let body = http_resp
.text()
.await
.map_err(|e| SdkError::network(e.to_string(), e))?;
let (msg, code, raw) = parse_error_body(&body, "type");
return Err(crate::error::error_from_status_code(
status.as_u16(),

View file

@ -462,9 +462,8 @@ impl ProviderAdapter for Adapter {
}
let (body, headers) = send_and_read_response(req, &self.provider_name, "type").await?;
let api_resp: ApiResponse = serde_json::from_str(&body).map_err(|e| SdkError::Network {
message: format!("failed to parse response: {e}"),
})?;
let api_resp: ApiResponse = serde_json::from_str(&body)
.map_err(|e| SdkError::network(format!("failed to parse response: {e}"), e))?;
let choice = api_resp.choices.first().ok_or_else(|| SdkError::Provider {
kind: ProviderErrorKind::Server,
@ -541,16 +540,15 @@ impl ProviderAdapter for Adapter {
.json(&api_body)
.send()
.await
.map_err(|e| SdkError::Network {
message: e.to_string(),
})?;
.map_err(|e| SdkError::network(e.to_string(), e))?;
let status = http_resp.status();
if !status.is_success() {
let retry_after = parse_retry_after(http_resp.headers());
let body = http_resp.text().await.map_err(|e| SdkError::Network {
message: e.to_string(),
})?;
let body = http_resp
.text()
.await
.map_err(|e| SdkError::network(e.to_string(), e))?;
let (msg, code, raw) = parse_error_body(&body, "type");
return Err(error_from_status_code(
status.as_u16(),
@ -614,9 +612,10 @@ impl ProviderAdapter for Adapter {
Ok(c) => c,
Err(e) => {
return Some((
Err(SdkError::Stream {
message: format!("failed to parse SSE chunk: {e}"),
}),
Err(SdkError::stream_error(
format!("failed to parse SSE chunk: {e}"),
e,
)),
state,
));
}

View file

@ -49,10 +49,16 @@ pub struct ThinkingData {
// --- 5.4 ToolCall / ToolResult ---
fn default_tool_type() -> String {
"function".to_string()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
#[serde(rename = "type", default = "default_tool_type")]
pub tool_type: String,
pub arguments: serde_json::Value,
pub raw_arguments: Option<String>,
/// Opaque provider-specific metadata (e.g. Gemini `thought_signature`).
@ -71,6 +77,7 @@ impl ToolCall {
Self {
id: id.into(),
name: name.into(),
tool_type: "function".to_string(),
arguments,
raw_arguments: None,
provider_metadata: None,
@ -1158,6 +1165,7 @@ mod tests {
fn stream_event_error() {
let event = StreamEvent::error(SdkError::Stream {
message: "something went wrong".into(),
source: None,
});
match &event {
StreamEvent::Error { error, .. } => {
@ -1263,9 +1271,24 @@ mod tests {
let tc = ToolCall::new("c1", "test", serde_json::json!({}));
assert_eq!(tc.id, "c1");
assert_eq!(tc.name, "test");
assert_eq!(tc.tool_type, "function");
assert_eq!(tc.raw_arguments, None);
}
#[test]
fn tool_call_deserialize_without_type_defaults_to_function() {
let json = r#"{"id":"c1","name":"test","arguments":{}}"#;
let tc: ToolCall = serde_json::from_str(json).unwrap();
assert_eq!(tc.tool_type, "function");
}
#[test]
fn tool_call_serializes_type_field() {
let tc = ToolCall::new("c1", "test", serde_json::json!({}));
let json = serde_json::to_value(&tc).unwrap();
assert_eq!(json["type"], "function");
}
#[test]
fn stream_event_step_finish_constructor() {
let response = Response {

View file

@ -683,6 +683,7 @@ mod tests {
fn llm_error_display() {
let sdk_err = SdkError::Network {
message: "connection refused".into(),
source: None,
};
let err = FabroError::Llm(sdk_err);
assert_eq!(
@ -695,11 +696,13 @@ mod tests {
fn llm_error_retryable_delegates_to_sdk() {
let retryable = FabroError::Llm(SdkError::Network {
message: "timeout".into(),
source: None,
});
assert!(retryable.is_retryable());
let non_retryable = FabroError::Llm(SdkError::Configuration {
message: "bad config".into(),
source: None,
});
assert!(!non_retryable.is_retryable());
}
@ -708,6 +711,7 @@ mod tests {
fn llm_error_from_sdk_error() {
let sdk_err = SdkError::Stream {
message: "broken pipe".into(),
source: None,
};
let err = FabroError::from(sdk_err);
assert!(matches!(err, FabroError::Llm(_)));
@ -794,6 +798,7 @@ mod tests {
fn failure_class_llm_timeout() {
let err = FabroError::Llm(SdkError::RequestTimeout {
message: "timed out".into(),
source: None,
});
assert_eq!(err.failure_class(), FailureClass::TransientInfra);
}
@ -849,6 +854,7 @@ mod tests {
fn classify_sdk_request_timeout() {
let err = SdkError::RequestTimeout {
message: "timed out".into(),
source: None,
};
assert_eq!(classify_sdk_error(&err), FailureClass::TransientInfra);
}
@ -1546,6 +1552,7 @@ mod tests {
fn to_fail_outcome_includes_error_message_as_reason() {
let err = FabroError::Llm(SdkError::Network {
message: "connection refused".into(),
source: None,
});
let outcome = err.to_fail_outcome();
assert!(outcome
@ -1558,6 +1565,7 @@ mod tests {
fn to_fail_outcome_no_context_updates() {
let err = FabroError::Llm(SdkError::Network {
message: "refused".into(),
source: None,
});
let outcome = err.to_fail_outcome();
assert!(outcome.context_updates.is_empty());
@ -1600,6 +1608,7 @@ mod tests {
FabroError::handler("handler err"),
FabroError::Llm(SdkError::Network {
message: "refused".into(),
source: None,
}),
FabroError::Checkpoint("cp err".into()),
FabroError::Stylesheet("style err".into()),

View file

@ -1554,6 +1554,7 @@ mod tests {
delay_secs: 1.5,
error: fabro_llm::error::SdkError::Network {
message: "rate limited".to_string(),
source: None,
},
},
};