//! Vendor HTTP transport: request bodies, posting, and SSE decoding //! (`SMH-SPEC-SPEC0001`, Network transport). //! //! Body builders are pure functions over [`smith::provider::ProviderRequest`] so their vendor //! shapes are unit-tested without network. The transport posts and feeds the //! matching vendor decoder on a dedicated thread, surfacing normalized //! events through a channel-backed stream. use crate::anthropic::AnthropicDecoder; use crate::conformance::StreamDecoder; use crate::gemini::GeminiDecoder; use crate::models::ProviderKind; use crate::openai::OpenAiDecoder; use futures::StreamExt; use smith::error::{ProviderFault, Result, SmithError}; use smith::http::{ HttpBodyEnd, HttpBodyItem, HttpExchange, HttpExecutorRef, HttpHeader, HttpMethod, }; use smith::message::{ContentBlock, Role}; use smith::provider::{ ProviderEffectOutcome, ProviderEffectRecord, ProviderRequest, RecordSink, StreamFn, }; use smith::tool::CancelHandle; use std::sync::Arc; /// Where provider effect records go until the agent drains them into the /// session; a closed drain drops records. pub type ProviderRecordSpool = RecordSink; fn spool_record(spool: &ProviderRecordSpool, record: ProviderEffectRecord) { // The documented closed-drain case drops records after the consumer exits. let _send_result: std::result::Result<(), std::sync::mpsc::SendError> = spool.send(record); } /// Vendor endpoint description for one model. #[derive(Clone)] pub struct VendorEndpoint { /// Adapter family. pub kind: ProviderKind, /// Base URL without trailing slash. pub base_url: String, /// Model id for the request body. pub model: String, /// Bearer or key credential value. pub api_key: String, /// The host HTTP engine; provider traffic never posts directly. pub executor: HttpExecutorRef, /// Redacted effect records, drained by the agent into the session. pub records: ProviderRecordSpool, } /// Build the vendor request body for one provider request. /// /// # Errors /// /// Returns a provider protocol fault when the conversation contains content /// the vendor body cannot represent. fn build_body(endpoint: &VendorEndpoint, request: &ProviderRequest) -> Result { match endpoint.kind { ProviderKind::OpenAiCompatible | ProviderKind::Plugin => openai_body(endpoint, request), ProviderKind::Anthropic => Ok(anthropic_body(endpoint, request)), ProviderKind::Google => Ok(google_body(endpoint, request)), } } fn openai_body(endpoint: &VendorEndpoint, request: &ProviderRequest) -> Result { let mut messages = Vec::new(); for message in &request.messages { match message.role { Role::System => messages.push(serde_json::json!({ "role": "system", "content": flatten_text(message), })), Role::User => messages.push(serde_json::json!({ "role": "user", "content": flatten_text(message), })), Role::Assistant => messages.push(serde_json::json!({ "role": "assistant", "content": flatten_text(message), })), Role::Tool => { // Each tool result block becomes its own tool message. for block in &message.blocks { if let ContentBlock::ToolResult { call_id, output, .. } = block { messages.push(serde_json::json!({ "role": "tool", "tool_call_id": call_id.to_string(), "content": output, })); } } } } } // OpenAI expects assistant tool calls on the assistant message; the // normalized conversation records them as blocks, mapped here. append_assistant_tool_calls(&request.messages, &mut messages)?; let mut body = serde_json::json!({ "model": endpoint.model, "messages": messages, "stream": true, "stream_options": {"include_usage": true}, }); if !request.tools.is_empty() { body["tools"] = serde_json::json!( request .tools .iter() .map(|tool| serde_json::json!({ "type": "function", "function": { "name": tool.name, "description": tool.description, "parameters": tool.input_schema, } })) .collect::>() ); } if let Some(max) = request.params.output_tokens_max { body["max_tokens"] = serde_json::json!(max); } Ok(body) } fn append_assistant_tool_calls( messages: &[smith::message::Message], out: &mut [serde_json::Value], ) -> Result<()> { for message in messages { if message.role != Role::Assistant { continue; } let mut calls = Vec::new(); for block in &message.blocks { let ContentBlock::ToolUse { name, input, call_id, } = block else { continue; }; let arguments = serde_json::to_string(input).map_err(|error| SmithError::Provider { fault: ProviderFault::Protocol { message: format!("cannot encode OpenAI tool arguments: {error}"), }, })?; calls.push(serde_json::json!({ "id": call_id.to_string(), "type": "function", "function": {"name": name, "arguments": arguments}, })); } if calls.is_empty() { continue; } if let Some(last) = out.iter_mut().rev().find(|entry| { entry.get("role").and_then(serde_json::Value::as_str) == Some("assistant") }) { last["tool_calls"] = serde_json::json!(calls); } } Ok(()) } fn anthropic_body(endpoint: &VendorEndpoint, request: &ProviderRequest) -> serde_json::Value { let mut system = String::new(); let mut messages = Vec::new(); for message in &request.messages { match message.role { Role::System => { if !system.is_empty() { system.push('\n'); } system.push_str(&flatten_text(message)); } Role::User => messages.push(serde_json::json!({ "role": "user", "content": [{"type": "text", "text": flatten_text(message)}], })), Role::Assistant => { let mut content = Vec::new(); for block in &message.blocks { match block { ContentBlock::Text(text) => content.push(serde_json::json!({ "type": "text", "text": text, })), ContentBlock::ToolUse { name, input, call_id, .. } => content.push(serde_json::json!({ "type": "tool_use", "id": call_id.to_string(), "name": name, "input": input, })), _ => {} } } messages.push(serde_json::json!({"role": "assistant", "content": content})); } Role::Tool => { let mut content = Vec::new(); for block in &message.blocks { if let ContentBlock::ToolResult { call_id, output, .. } = block { content.push(serde_json::json!({ "type": "tool_result", "tool_use_id": call_id.to_string(), "content": output, })); } } messages.push(serde_json::json!({"role": "user", "content": content})); } } } let mut body = serde_json::json!({ "model": endpoint.model, "max_tokens": request.params.output_tokens_max.unwrap_or(4096), "messages": messages, "stream": true, }); if !system.is_empty() { body["system"] = serde_json::json!(system); } if !request.tools.is_empty() { body["tools"] = serde_json::json!( request .tools .iter() .map(|tool| serde_json::json!({ "name": tool.name, "description": tool.description, "input_schema": tool.input_schema, })) .collect::>() ); } body } fn google_body(_endpoint: &VendorEndpoint, request: &ProviderRequest) -> serde_json::Value { let system: Vec = request .messages .iter() .filter(|message| message.role == Role::System) .map(flatten_text) .collect(); let mut contents = Vec::new(); for message in &request.messages { let role = match message.role { Role::User | Role::Tool => "user", Role::Assistant => "model", Role::System => continue, }; let mut parts = Vec::new(); for block in &message.blocks { match block { ContentBlock::Text(text) => parts.push(serde_json::json!({"text": text})), ContentBlock::ToolUse { name, input, .. } => parts.push(serde_json::json!({ "functionCall": {"name": name, "args": input}, })), ContentBlock::ToolResult { output, .. } => { parts.push(serde_json::json!({ "functionResponse": {"name": "tool", "response": {"output": output}}, })); } ContentBlock::Thinking(_) => {} } } contents.push(serde_json::json!({"role": role, "parts": parts})); } let mut body = serde_json::json!({ "contents": contents, "generationConfig": { "maxOutputTokens": request.params.output_tokens_max.unwrap_or(4096), }, }); if !system.is_empty() { body["systemInstruction"] = serde_json::json!({"parts": [{"text": system.join("\n")}]}); } if !request.tools.is_empty() { body["tools"] = serde_json::json!([{ "functionDeclarations": request .tools .iter() .map(|tool| serde_json::json!({ "name": tool.name, "description": tool.description, "parameters": tool.input_schema, })) .collect::>(), }]); } body } fn flatten_text(message: &smith::message::Message) -> String { let mut out = String::new(); for block in &message.blocks { if let ContentBlock::Text(text) = block { if !out.is_empty() { out.push('\n'); } out.push_str(text); } } out } /// The request URL for one endpoint. #[must_use] pub fn endpoint_url(endpoint: &VendorEndpoint) -> String { match endpoint.kind { ProviderKind::OpenAiCompatible | ProviderKind::Plugin => { format!("{}/chat/completions", endpoint.base_url) } ProviderKind::Anthropic => format!("{}/v1/messages", endpoint.base_url), ProviderKind::Google => format!( "{}/v1beta/models/{}:streamGenerateContent?alt=sse", endpoint.base_url, endpoint.model ), } } /// Headers the vendor requires. #[must_use] pub fn endpoint_headers(endpoint: &VendorEndpoint) -> Vec<(String, String)> { match endpoint.kind { ProviderKind::OpenAiCompatible | ProviderKind::Plugin => { vec![( "Authorization".to_string(), format!("Bearer {}", endpoint.api_key), )] } ProviderKind::Anthropic => vec![ ("x-api-key".to_string(), endpoint.api_key.clone()), ("anthropic-version".to_string(), "2023-06-01".to_string()), ], ProviderKind::Google => vec![("x-goog-api-key".to_string(), endpoint.api_key.clone())], } } fn make_decoder(kind: ProviderKind) -> Box { match kind { ProviderKind::OpenAiCompatible | ProviderKind::Plugin => Box::new(OpenAiDecoder::new()), ProviderKind::Anthropic => Box::new(AnthropicDecoder::new()), ProviderKind::Google => Box::new(GeminiDecoder::new()), } } /// Bounded decoded stream items awaiting the synchronous agent loop. const STREAM_CHANNEL_ITEMS: usize = 64; /// Build a [`StreamFn`] that posts to one vendor endpoint. /// /// The transport is deliberately synchronous-threaded: the agent loop polls /// streams synchronously, so a reader thread feeds decoded events through a /// channel. /// /// # Errors /// /// The returned stream's first item is `Err` when the request cannot be /// built, credentials are missing, or the HTTP call fails. #[must_use] pub fn transport(endpoint: VendorEndpoint) -> StreamFn { Arc::new(move |request: ProviderRequest, cancel: CancelHandle| { let endpoint = endpoint.clone(); let (sender, receiver) = std::sync::mpsc::sync_channel::(STREAM_CHANNEL_ITEMS); std::thread::spawn(move || { let result = post_and_decode(&endpoint, &request, &cancel, &sender); if let Err(error) = result && sender.send(Err(error)).is_err() { // Receiver dropped: the turn was abandoned. } }); futures::stream::unfold(receiver, |receiver| async move { receiver.recv().map_or(None, |item| Some((item, receiver))) }) .boxed() }) } fn post_and_decode( endpoint: &VendorEndpoint, request: &ProviderRequest, cancel: &CancelHandle, sender: &std::sync::mpsc::SyncSender, ) -> Result<()> { use ProviderEffectOutcome::{Cancelled, Completed, Failed}; let body = build_body(endpoint, request)?; let headers = endpoint_headers(endpoint) .into_iter() .map(|(name, value)| HttpHeader { name, value }) .collect::>(); let exchange = HttpExchange { method: HttpMethod::Post, url: endpoint_url(endpoint), headers: headers.clone(), body: serde_json::to_vec(&body).unwrap_or_default(), }; spool_record( &endpoint.records, ProviderEffectRecord::Intent { method: exchange.method, url: exchange.url.clone(), headers: smith::http::redact_headers(&headers), request_bytes: exchange.body.len(), }, ); let response = endpoint.executor.execute(exchange, cancel.clone())?; if !(200..300).contains(&response.head.status) { let mut body = Vec::new(); for item in response.body { match item { HttpBodyItem::Chunk(chunk) => body.extend_from_slice(&chunk.bytes), HttpBodyItem::End(_) => break, } } let message = String::from_utf8_lossy(&body).into_owned(); spool_record( &endpoint.records, ProviderEffectRecord::Outcome { status: response.head.status, response_bytes: u64::try_from(body.len()).unwrap_or(u64::MAX), outcome: Failed, }, ); return Err(SmithError::Provider { fault: status_fault(response.head.status, message), }); } let mut decoder = make_decoder(endpoint.kind); let mut received: u64 = 0; let mut terminal = None; let outcome = |received: u64, outcome| ProviderEffectRecord::Outcome { status: response.head.status, response_bytes: received, outcome, }; loop { if cancel.is_cancelled() { spool_record(&endpoint.records, outcome(received, Cancelled)); return Err(SmithError::Cancelled); } let error = match response.body.recv() { Ok(HttpBodyItem::Chunk(chunk)) => { received += u64::try_from(chunk.bytes.len()).unwrap_or(u64::MAX); if !forward_events(decoder.push(&chunk.bytes)?, sender, &mut terminal) { return Ok(()); } continue; } Ok(HttpBodyItem::End(HttpBodyEnd::Complete)) => break, Ok(HttpBodyItem::End(HttpBodyEnd::Cancelled)) => SmithError::Cancelled, Ok(HttpBodyItem::End(HttpBodyEnd::Truncated { limit })) => SmithError::Provider { fault: ProviderFault::Transient { message: format!("response body exceeded {limit} bytes"), }, }, Ok(HttpBodyItem::End(HttpBodyEnd::Failed { message })) => SmithError::Provider { fault: ProviderFault::Transient { message }, }, Err(_) => SmithError::Provider { fault: ProviderFault::Transient { message: "body channel closed without an end".to_string(), }, }, }; spool_record(&endpoint.records, outcome(received, Failed)); return Err(error); } if !forward_events(decoder.finish()?, sender, &mut terminal) { return Ok(()); } spool_record(&endpoint.records, outcome(received, Completed)); if let Some(stop) = terminal { // A closed stream receiver means consumer cancellation after completion. let _send_result: std::result::Result< (), std::sync::mpsc::SendError, > = sender.send(Ok(stop)); } Ok(()) } fn forward_events( events: Vec, sender: &std::sync::mpsc::SyncSender, terminal: &mut Option, ) -> bool { for event in events { if matches!(event, smith::stream::StreamEvent::Stop { .. }) { *terminal = Some(event); } else if sender.send(Ok(event)).is_err() { return false; } } true } /// The provider fault a non-2xx status maps to. fn status_fault(status: u16, message: String) -> ProviderFault { match status { 429 => ProviderFault::RateLimit { retry_after_ms: None, }, 401 | 403 => ProviderFault::Authentication { message }, 400 | 404 => ProviderFault::Invalid { field: None, message, }, 503 => ProviderFault::Overloaded { message }, _ => ProviderFault::Transient { message }, } } #[cfg(test)] mod tests { use super::*; use smith::id::ToolCallId; use smith::message::{Message, Role}; use smith::stream::StreamEvent; use smith::tool::ToolMetadata; fn endpoint(kind: ProviderKind, model: &str) -> VendorEndpoint { VendorEndpoint { kind, base_url: "https://example.test".to_string(), model: model.to_string(), api_key: "k".to_string(), executor: std::sync::Arc::new(RefusesExecutor), records: smith::provider::record_channel().0, } } /// Body-builder tests never execute exchanges. struct RefusesExecutor; impl smith::http::HttpExecutor for RefusesExecutor { fn execute( &self, _exchange: smith::http::HttpExchange, _cancel: smith::tool::CancelHandle, ) -> smith::error::Result { Err(SmithError::Provider { fault: ProviderFault::Invalid { field: None, message: "executor must not run in body tests".to_string(), }, }) } } #[test] fn openai_body_carries_messages_tools_and_usage_flag() { let call_id = ToolCallId::new(); let mut assistant = Message::new(Role::Assistant); assistant.add_block(ContentBlock::text("Writing.")); assistant.add_block(ContentBlock::tool_use( "write", smith::structured::ToolArguments::from_serializable(&serde_json::json!({"path": "f"})) .unwrap(), call_id, )); let mut result = Message::new(Role::Tool); result.add_block(ContentBlock::tool_result(call_id, true, "written")); let request = ProviderRequest::new( "gpt", vec![ Message::with_text(Role::System, "be brief"), Message::with_text(Role::User, "hi"), assistant, result, ], ) .with_tools(vec![ToolMetadata::new("read", "Read")]); let body = build_body(&endpoint(ProviderKind::OpenAiCompatible, "gpt"), &request).unwrap(); assert_eq!(body["model"], "gpt"); assert_eq!(body["stream"], true); assert_eq!(body["stream_options"]["include_usage"], true); assert_eq!(body["messages"][0]["role"], "system"); assert_eq!(body["messages"][3]["role"], "tool"); assert!(body["messages"][2]["tool_calls"].is_array()); assert_eq!(body["tools"][0]["function"]["name"], "read"); } #[test] fn anthropic_body_hoists_system_and_pairs_tool_blocks() { let call_id = ToolCallId::new(); let mut assistant = Message::new(Role::Assistant); assistant.add_block(ContentBlock::text("Reading.")); assistant.add_block(ContentBlock::tool_use( "read", smith::structured::ToolArguments::from_serializable(&serde_json::json!({"path": "f"})) .unwrap(), call_id, )); let mut result = Message::new(Role::Tool); result.add_block(ContentBlock::tool_result(call_id, true, "ok")); let request = ProviderRequest::new( "claude", vec![ Message::with_text(Role::System, "sys"), Message::with_text(Role::User, "hi"), assistant, result, ], ) .with_tools(vec![ToolMetadata::new("read", "Read")]); let body = build_body(&endpoint(ProviderKind::Anthropic, "claude"), &request).unwrap(); assert_eq!(body["system"], "sys"); assert_eq!(body["messages"].as_array().map(Vec::len), Some(3)); assert_eq!( body["messages"][1]["content"][0], serde_json::json!({"type": "text", "text": "Reading."}) ); assert_eq!(body["messages"][1]["content"][1]["type"], "tool_use"); assert_eq!(body["messages"][2]["content"][0]["type"], "tool_result"); assert_eq!(body["tools"][0]["name"], "read"); } /// Answers every exchange with `status` and `chunks`, then a complete end. struct Canned { status: u16, chunks: Vec<&'static str>, } impl smith::http::HttpExecutor for Canned { fn execute( &self, _exchange: HttpExchange, _cancel: CancelHandle, ) -> Result { let (sender, body) = std::sync::mpsc::channel(); for chunk in &self.chunks { let bytes = chunk.as_bytes().to_vec(); let _ = sender.send(HttpBodyItem::Chunk(smith::http::HttpChunk { bytes })); } let _ = sender.send(HttpBodyItem::End(HttpBodyEnd::Complete)); Ok(smith::http::HttpResponse { head: smith::http::HttpHead { status: self.status, }, body, }) } } /// Post one request against `canned`; the result, streamed items, and /// spooled records. fn post( canned: Canned, ) -> ( Result<()>, Vec, Vec, ) { let (records, drain) = smith::provider::record_channel(); let endpoint = VendorEndpoint { executor: Arc::new(canned), records, ..endpoint(ProviderKind::OpenAiCompatible, "gpt") }; let request = ProviderRequest::new("gpt", vec![Message::with_text(Role::User, "hi")]); let (sender, items) = std::sync::mpsc::sync_channel(STREAM_CHANNEL_ITEMS); let result = post_and_decode(&endpoint, &request, &CancelHandle::new(), &sender); drop(sender); ( result, items.try_iter().collect(), drain .try_iter() .map(|record| serde_json::to_value(record).unwrap()) .collect(), ) } #[test] fn non_success_statuses_map_to_provider_faults_carrying_the_body() { use crate::conformance::faults::*; let cases = [ (429, rate_limit()), (401, authentication()), (403, authentication()), (400, invalid()), (404, invalid()), (503, overloaded()), (500, transient()), ]; for (status, fault) in cases { let (result, items, _) = post(Canned { status, chunks: vec!["m"], }); assert_eq!( result.err(), Some(SmithError::Provider { fault }), "{status}" ); assert!(items.is_empty(), "{status}"); } } #[test] fn terminal_event_waits_for_durable_outcome_record() { struct Delayed(std::sync::mpsc::SyncSender>); impl smith::http::HttpExecutor for Delayed { fn execute( &self, _exchange: HttpExchange, _cancel: CancelHandle, ) -> Result { let (sender, body) = std::sync::mpsc::sync_channel(STREAM_CHANNEL_ITEMS); sender.send(HttpBodyItem::Chunk(smith::http::HttpChunk { bytes: b"data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n".to_vec(), })).unwrap(); self.0.send(sender).unwrap(); Ok(smith::http::HttpResponse { head: smith::http::HttpHead { status: 200 }, body, }) } } let (control, ready) = std::sync::mpsc::sync_channel(1); let (records, drain) = smith::provider::record_channel(); let endpoint = VendorEndpoint { executor: Arc::new(Delayed(control)), records, ..endpoint(ProviderKind::OpenAiCompatible, "gpt") }; let request = ProviderRequest::new("gpt", vec![Message::with_text(Role::User, "hi")]); let (sender, items) = std::sync::mpsc::sync_channel(STREAM_CHANNEL_ITEMS); let worker = std::thread::spawn(move || { post_and_decode(&endpoint, &request, &CancelHandle::new(), &sender) }); let finish = ready .recv_timeout(std::time::Duration::from_secs(10)) .unwrap(); assert!(matches!( items .recv_timeout(std::time::Duration::from_secs(10)) .unwrap(), Ok(StreamEvent::TextDelta { .. }) )); assert!( matches!(items.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty)), "terminal event must await body completion" ); finish .send(HttpBodyItem::End(HttpBodyEnd::Complete)) .unwrap(); assert!(matches!( items .recv_timeout(std::time::Duration::from_secs(10)) .unwrap(), Ok(StreamEvent::Stop { .. }) )); worker.join().unwrap().unwrap(); assert!(matches!( drain.try_iter().last().unwrap(), ProviderEffectRecord::Outcome { outcome: ProviderEffectOutcome::Completed, .. } )); } #[test] fn completed_exchange_streams_events_and_records_received_bytes() { let chunks = vec![ "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n", "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", ]; let received = chunks.iter().map(|chunk| chunk.len()).sum::(); let (result, items, records) = post(Canned { status: 200, chunks, }); assert_eq!(result.ok(), Some(())); assert!(matches!( items.as_slice(), [ Ok(StreamEvent::TextDelta { .. }), Ok(StreamEvent::Stop { .. }) ] )); assert_eq!( records.last(), Some(&serde_json::json!({ "phase": "outcome", "status": 200, "response_bytes": received, "outcome": "completed", })) ); } #[test] fn google_body_maps_roles_and_declares_functions() { let request = ProviderRequest::new( "gemini", vec![ Message::with_text(Role::System, "sys"), Message::with_text(Role::User, "hi"), ], ) .with_tools(vec![ToolMetadata::new("read", "Read")]); let body = build_body(&endpoint(ProviderKind::Google, "gemini"), &request).unwrap(); assert_eq!(body["systemInstruction"]["parts"][0]["text"], "sys"); assert_eq!(body["contents"][0]["role"], "user"); assert_eq!(body["tools"][0]["functionDeclarations"][0]["name"], "read"); } #[test] fn urls_and_headers_match_vendor_conventions() { let openai = endpoint(ProviderKind::OpenAiCompatible, "gpt"); assert!(endpoint_url(&openai).ends_with("/chat/completions")); let anthropic = endpoint(ProviderKind::Anthropic, "claude"); assert!(endpoint_url(&anthropic).ends_with("/v1/messages")); let google = endpoint(ProviderKind::Google, "gemini"); assert!(endpoint_url(&google).contains("alt=sse")); let headers = endpoint_headers(&anthropic); assert!(headers.iter().any(|(name, _)| name == "x-api-key")); } #[test] fn stream_events_flow_through_the_shape_they_came_in() { // Body builders stay pure; the unfold transport is covered by the // decoders, so this pins the vendor kind to decoder mapping. let mut decoder = make_decoder(ProviderKind::OpenAiCompatible); let mut events = decoder .push(b"data: {\"choices\":[{\"delta\":{\"content\":\"x\"}}]}\n\n") .unwrap(); assert!(matches!(events.pop(), Some(StreamEvent::TextDelta { .. }))); } }