1use crate::agent::ToolCall;
5use async_trait::async_trait;
6use dashmap::DashMap;
7use serde::{Deserialize, Serialize};
8#[cfg(feature = "providers")]
9use std::collections::BTreeMap;
10use std::sync::Arc;
11#[cfg(feature = "providers")]
12use tracing::{debug, error};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum Role {
17 System,
18 User,
19 Assistant,
20 Tool,
21}
22
23impl std::fmt::Display for Role {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 match self {
26 Self::System => write!(f, "system"),
27 Self::User => write!(f, "user"),
28 Self::Assistant => write!(f, "assistant"),
29 Self::Tool => write!(f, "tool"),
30 }
31 }
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct Message {
36 pub role: Role,
37 pub content: String,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub tool_call_id: Option<String>,
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 pub tool_calls: Vec<ToolCall>,
42}
43
44impl Message {
45 pub fn new(role: Role, content: impl Into<String>) -> Self {
46 Self {
47 role,
48 content: content.into(),
49 tool_call_id: None,
50 tool_calls: Vec::new(),
51 }
52 }
53 pub fn user(content: impl Into<String>) -> Self {
54 Self::new(Role::User, content)
55 }
56 pub fn assistant(content: impl Into<String>) -> Self {
57 Self::new(Role::Assistant, content)
58 }
59 pub fn system(content: impl Into<String>) -> Self {
60 Self::new(Role::System, content)
61 }
62 pub fn tool(tool_call_id: &str, content: impl Into<String>) -> Self {
63 Self {
64 role: Role::Tool,
65 content: content.into(),
66 tool_call_id: Some(tool_call_id.to_string()),
67 tool_calls: Vec::new(),
68 }
69 }
70
71 pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
72 Self {
73 role: Role::Assistant,
74 content: content.into(),
75 tool_call_id: None,
76 tool_calls,
77 }
78 }
79}
80
81#[derive(Debug, Clone)]
83pub enum StreamEvent {
84 Delta(String),
85 ToolCall(ToolCall),
86 Usage(crate::cost::TokenUsage),
88 Done,
89}
90
91#[cfg(feature = "providers")]
92pub type StreamResult =
93 Box<dyn futures::Stream<Item = Result<StreamEvent, ProviderError>> + Send + Unpin>;
94
95#[async_trait]
97pub trait Provider: Send + Sync {
98 fn id(&self) -> &str;
99 fn name(&self) -> &str;
100
101 #[cfg(feature = "providers")]
102 async fn stream(
103 &self,
104 messages: &[Message],
105 system: &Option<String>,
106 model: &str,
107 tools: &[serde_json::Value],
108 reasoning_effort: Option<&str>,
109 ) -> Result<StreamResult, ProviderError>;
110
111 async fn generate(
113 &self,
114 messages: &[Message],
115 system: &Option<String>,
116 model: &str,
117 tools: &[serde_json::Value],
118 ) -> Result<String, ProviderError> {
119 #[cfg(feature = "providers")]
120 {
121 let mut content = String::new();
122 let mut stream = self.stream(messages, system, model, tools, None).await?;
123 use futures::StreamExt;
124 while let Some(event) = stream.next().await {
125 if let Ok(StreamEvent::Delta(delta)) = event {
126 content.push_str(&delta);
127 }
128 }
129 return Ok(content);
130 }
131 #[cfg(not(feature = "providers"))]
132 {
133 let _ = (messages, system, model, tools);
134 return Ok("[providers feature not enabled]".to_string());
135 }
136 }
137}
138
139pub struct ProviderRegistry {
141 providers: DashMap<String, Arc<dyn Provider>>,
142}
143
144impl ProviderRegistry {
145 pub fn new() -> Self {
146 Self {
147 providers: DashMap::new(),
148 }
149 }
150
151 pub fn register(&self, id: impl Into<String>, provider: Arc<dyn Provider>) {
152 self.providers.insert(id.into(), provider);
153 }
154
155 pub fn get(&self, id: &str) -> Option<Arc<dyn Provider>> {
156 self.providers.get(id).map(|p| p.clone())
157 }
158
159 pub fn count(&self) -> usize {
160 self.providers.len()
161 }
162
163 pub fn ids(&self) -> Vec<String> {
164 self.providers.iter().map(|p| p.key().clone()).collect()
165 }
166}
167
168impl Default for ProviderRegistry {
169 fn default() -> Self {
170 Self::new()
171 }
172}
173
174#[cfg(feature = "providers")]
176pub struct OpenAIProvider {
177 client: reqwest::Client,
178 base_url: String,
179 api_key: String,
180 provider_id: String,
181 provider_name: String,
182 prompt_cache: crate::prompt_cache::PromptCacheConfig,
183}
184
185#[cfg(feature = "providers")]
186impl OpenAIProvider {
187 pub fn new(api_key: impl Into<String>) -> Self {
188 Self::with_base_url("https://api.openai.com/v1", api_key, "openai", "OpenAI")
189 }
190
191 pub fn anthropic(api_key: impl Into<String>) -> Self {
192 Self::with_base_url(
193 "https://api.anthropic.com/v1",
194 api_key,
195 "anthropic",
196 "Anthropic",
197 )
198 }
199
200 pub fn ollama() -> Self {
201 Self::with_base_url("http://localhost:11434/v1", "", "local", "Ollama")
202 }
203
204 pub fn with_base_url(
205 base_url: impl Into<String>,
206 api_key: impl Into<String>,
207 provider_id: impl Into<String>,
208 provider_name: impl Into<String>,
209 ) -> Self {
210 let provider_id_str = provider_id.into();
211 let prompt_cache = if provider_id_str == "anthropic" {
212 crate::prompt_cache::PromptCacheConfig::anthropic()
213 } else if provider_id_str == "openai" {
214 crate::prompt_cache::PromptCacheConfig::openai()
215 } else {
216 crate::prompt_cache::PromptCacheConfig::disabled()
217 };
218 Self {
219 client: reqwest::Client::builder()
220 .pool_idle_timeout(std::time::Duration::from_secs(90))
221 .tcp_keepalive(std::time::Duration::from_secs(60))
222 .build()
223 .unwrap_or_else(|_| reqwest::Client::new()),
224 base_url: base_url.into(),
225 api_key: api_key.into(),
226 provider_id: provider_id_str,
227 provider_name: provider_name.into(),
228 prompt_cache,
229 }
230 }
231
232 pub fn with_prompt_cache(mut self, config: crate::prompt_cache::PromptCacheConfig) -> Self {
234 self.prompt_cache = config;
235 self
236 }
237
238 pub async fn prewarm(&self) -> Result<(), ProviderError> {
242 let url = format!("{}/models", self.base_url);
243 let mut req = self.client.head(&url);
244 if !self.api_key.is_empty() {
245 if self.provider_id == "anthropic" {
246 req = req
247 .header("x-api-key", &self.api_key)
248 .header("anthropic-version", "2023-06-01");
249 } else {
250 req = req.bearer_auth(&self.api_key);
251 }
252 }
253 let _ = req.send().await;
254 Ok(())
255 }
256
257 pub fn new_session(&self) -> ModelClientSession {
259 ModelClientSession {
260 connection_reused: false,
261 }
262 }
263}
264
265#[cfg(feature = "providers")]
268pub struct ModelClientSession {
269 connection_reused: bool,
270}
271
272#[cfg(feature = "providers")]
273impl ModelClientSession {
274 pub fn was_connection_reused(&self) -> bool {
275 self.connection_reused
276 }
277
278 pub fn set_connection_reused(&mut self, reused: bool) {
279 self.connection_reused = reused;
280 }
281}
282
283#[cfg(feature = "providers")]
284#[async_trait]
285impl Provider for OpenAIProvider {
286 fn id(&self) -> &str {
287 &self.provider_id
288 }
289 fn name(&self) -> &str {
290 &self.provider_name
291 }
292
293 async fn stream(
294 &self,
295 messages: &[Message],
296 system: &Option<String>,
297 model: &str,
298 tools: &[serde_json::Value],
299 reasoning_effort: Option<&str>,
300 ) -> Result<StreamResult, ProviderError> {
301 let body = if self.provider_id == "anthropic" {
302 anthropic_request(
303 messages,
304 system,
305 model,
306 tools,
307 reasoning_effort,
308 &self.prompt_cache,
309 )
310 } else {
311 openai_request(
312 messages,
313 system,
314 model,
315 tools,
316 reasoning_effort,
317 self.provider_id == "openai",
318 )
319 };
320
321 let endpoint = if self.provider_id == "anthropic" {
322 "messages"
323 } else {
324 "chat/completions"
325 };
326 let mut req = self
327 .client
328 .post(format!("{}/{}", self.base_url, endpoint))
329 .json(&body);
330
331 if !self.api_key.is_empty() {
332 if self.provider_id == "anthropic" {
333 req = req
334 .header("x-api-key", &self.api_key)
335 .header("anthropic-version", "2023-06-01");
336 } else {
337 req = req.bearer_auth(&self.api_key);
338 }
339 }
340
341 let response = req
342 .send()
343 .await
344 .map_err(|e| ProviderError::Http(e.to_string()))?;
345
346 if !response.status().is_success() {
347 let status = response.status();
348 let text = response.text().await.unwrap_or_default();
349 error!("provider error {status}: {text}");
350 return Err(ProviderError::Api(format!("{status}: {text}")));
351 }
352
353 let byte_stream = response.bytes_stream();
354 let sse_stream = eventsource_stream::Eventsource::eventsource(byte_stream);
355 let provider_id = self.provider_id.clone();
356
357 use futures::StreamExt;
358 let mapped = sse_stream
359 .scan(StreamState::default(), move |state, event_result| {
360 let result = match event_result {
361 Ok(event) if event.data == "[DONE]" => vec![Ok(StreamEvent::Done)],
362 Ok(event) => match serde_json::from_str::<serde_json::Value>(&event.data) {
363 Ok(json) => parse_sse_events(&json, &provider_id, state),
364 Err(e) => {
365 debug!(
366 "sse parse error: {e} (data: {})",
367 &event.data[..event.data.len().min(200)]
368 );
369 Vec::new()
370 }
371 },
372 Err(e) => vec![Err(ProviderError::Stream(e.to_string()))],
373 };
374 std::future::ready(Some(result))
375 })
376 .flat_map(futures::stream::iter);
377
378 Ok(Box::new(Box::pin(mapped)))
379 }
380}
381
382#[cfg(feature = "providers")]
383fn openai_request(
384 messages: &[Message],
385 system: &Option<String>,
386 model: &str,
387 tools: &[serde_json::Value],
388 reasoning_effort: Option<&str>,
389 include_usage: bool,
390) -> serde_json::Value {
391 let mut body = serde_json::json!({
392 "model": model,
393 "stream": true,
394 "messages": [],
395 });
396 if include_usage {
397 body["stream_options"] = serde_json::json!({"include_usage": true});
398 }
399
400 let msgs = body["messages"]
401 .as_array_mut()
402 .expect("messages is initialized as an array");
403 if let Some(sys) = system {
404 msgs.push(serde_json::json!({"role": "system", "content": sys}));
405 }
406 for m in messages {
407 let mut entry = serde_json::json!({"role": m.role, "content": m.content});
408 if let Some(tid) = &m.tool_call_id {
409 entry["tool_call_id"] = serde_json::json!(tid);
410 }
411 if !m.tool_calls.is_empty() {
412 entry["tool_calls"] = serde_json::Value::Array(
413 m.tool_calls
414 .iter()
415 .map(|call| {
416 serde_json::json!({
417 "id": call.id,
418 "type": "function",
419 "function": {"name": call.name, "arguments": call.arguments}
420 })
421 })
422 .collect(),
423 );
424 }
425 msgs.push(entry);
426 }
427
428 if !tools.is_empty() {
429 body["tools"] = serde_json::Value::Array(
430 tools
431 .iter()
432 .map(|tool| {
433 if tool.get("type").is_some() {
434 tool.clone()
435 } else {
436 serde_json::json!({"type": "function", "function": tool})
437 }
438 })
439 .collect(),
440 );
441 }
442 if let Some(effort) = reasoning_effort {
443 body["reasoning_effort"] = serde_json::json!(effort);
444 }
445 body
446}
447
448#[cfg(feature = "providers")]
449fn anthropic_request(
450 messages: &[Message],
451 system: &Option<String>,
452 model: &str,
453 tools: &[serde_json::Value],
454 reasoning_effort: Option<&str>,
455 prompt_cache: &crate::prompt_cache::PromptCacheConfig,
456) -> serde_json::Value {
457 let mut converted = Vec::with_capacity(messages.len());
458 for message in messages {
459 match message.role {
460 Role::System => {}
461 Role::Tool => converted.push(serde_json::json!({
462 "role": "user",
463 "content": [{
464 "type": "tool_result",
465 "tool_use_id": message.tool_call_id,
466 "content": message.content
467 }]
468 })),
469 Role::Assistant => {
470 let mut content = Vec::new();
471 if !message.content.is_empty() {
472 content.push(serde_json::json!({"type": "text", "text": message.content}));
473 }
474 content.extend(message.tool_calls.iter().map(|call| {
475 let input = serde_json::from_str(&call.arguments)
476 .unwrap_or_else(|_| serde_json::json!({"raw": call.arguments}));
477 serde_json::json!({
478 "type": "tool_use",
479 "id": call.id,
480 "name": call.name,
481 "input": input
482 })
483 }));
484 converted.push(serde_json::json!({"role": "assistant", "content": content}));
485 }
486 Role::User => {
487 converted.push(serde_json::json!({"role": "user", "content": message.content}))
488 }
489 }
490 }
491
492 crate::prompt_cache::apply_cache_control(&mut converted, prompt_cache);
494
495 let mut body = serde_json::json!({
496 "model": model,
497 "stream": true,
498 "max_tokens": 8192,
499 "messages": converted
500 });
501 if let Some(system) = system {
502 body["system"] = serde_json::json!(system);
503 }
504 if !tools.is_empty() {
505 body["tools"] = serde_json::Value::Array(
506 tools
507 .iter()
508 .map(|tool| {
509 let function = tool.get("function").unwrap_or(tool);
510 serde_json::json!({
511 "name": function["name"],
512 "description": function["description"],
513 "input_schema": function["parameters"]
514 })
515 })
516 .collect(),
517 );
518 }
519 if let Some(effort) = reasoning_effort {
520 let budget = match effort {
521 "low" => 1_024,
522 "medium" => 4_096,
523 "high" => 8_192,
524 "xhigh" => 16_384,
525 _ => 1_024,
526 };
527 body["thinking"] = serde_json::json!({"type": "enabled", "budget_tokens": budget});
528 body["max_tokens"] = serde_json::json!(budget + 8_192);
529 }
530 body
531}
532
533#[cfg(feature = "providers")]
534#[derive(Default)]
535struct StreamState {
536 tool_calls: BTreeMap<usize, ToolCall>,
537 usage: crate::cost::TokenUsage,
538}
539
540#[cfg(feature = "providers")]
541fn parse_sse_events(
542 json: &serde_json::Value,
543 provider_id: &str,
544 state: &mut StreamState,
545) -> Vec<Result<StreamEvent, ProviderError>> {
546 if provider_id == "anthropic" {
547 return parse_anthropic_event(json, state);
548 }
549
550 if let Some(usage) = json.get("usage").and_then(parse_token_usage) {
554 return vec![Ok(StreamEvent::Usage(usage))];
555 }
556
557 let delta = &json["choices"][0]["delta"];
558
559 if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
560 if !content.is_empty() {
561 return vec![Ok(StreamEvent::Delta(content.to_string()))];
562 }
563 }
564
565 if let Some(tool_calls) = delta.get("tool_calls").and_then(|t| t.as_array()) {
566 for fragment in tool_calls {
567 let index = fragment
568 .get("index")
569 .and_then(|value| value.as_u64())
570 .unwrap_or(0) as usize;
571 let call = state.tool_calls.entry(index).or_insert_with(|| ToolCall {
572 id: String::new(),
573 name: String::new(),
574 arguments: String::new(),
575 });
576 if let Some(id) = fragment.get("id").and_then(|value| value.as_str()) {
577 call.id.push_str(id);
578 }
579 if let Some(function) = fragment.get("function") {
580 if let Some(name) = function.get("name").and_then(|value| value.as_str()) {
581 call.name.push_str(name);
582 }
583 if let Some(arguments) = function.get("arguments").and_then(|value| value.as_str())
584 {
585 call.arguments.push_str(arguments);
586 }
587 }
588 }
589 }
590
591 let finish = json["choices"][0]
592 .get("finish_reason")
593 .and_then(|f| f.as_str());
594 if matches!(finish, Some("stop")) {
595 return vec![Ok(StreamEvent::Done)];
596 }
597 if matches!(finish, Some("tool_calls")) {
598 return state
599 .tool_calls
600 .split_off(&0)
601 .into_values()
602 .map(|call| Ok(StreamEvent::ToolCall(call)))
603 .collect();
604 }
605
606 Vec::new()
607}
608
609#[cfg(feature = "providers")]
610fn parse_anthropic_event(
611 json: &serde_json::Value,
612 state: &mut StreamState,
613) -> Vec<Result<StreamEvent, ProviderError>> {
614 match json.get("type").and_then(|value| value.as_str()) {
615 Some("content_block_start") if json["content_block"]["type"] == "tool_use" => {
616 let index = json["index"].as_u64().unwrap_or(0) as usize;
617 state.tool_calls.insert(
618 index,
619 ToolCall {
620 id: json["content_block"]["id"]
621 .as_str()
622 .unwrap_or_default()
623 .to_string(),
624 name: json["content_block"]["name"]
625 .as_str()
626 .unwrap_or_default()
627 .to_string(),
628 arguments: String::new(),
629 },
630 );
631 Vec::new()
632 }
633 Some("content_block_delta") if json["delta"]["type"] == "text_delta" => json["delta"]
634 ["text"]
635 .as_str()
636 .filter(|text| !text.is_empty())
637 .map(|text| vec![Ok(StreamEvent::Delta(text.to_string()))])
638 .unwrap_or_default(),
639 Some("content_block_delta") if json["delta"]["type"] == "input_json_delta" => {
640 let index = json["index"].as_u64().unwrap_or(0) as usize;
641 if let Some(call) = state.tool_calls.get_mut(&index) {
642 call.arguments
643 .push_str(json["delta"]["partial_json"].as_str().unwrap_or_default());
644 }
645 Vec::new()
646 }
647 Some("content_block_stop") => {
648 let index = json["index"].as_u64().unwrap_or(0) as usize;
649 state
650 .tool_calls
651 .remove(&index)
652 .map(|call| vec![Ok(StreamEvent::ToolCall(call))])
653 .unwrap_or_default()
654 }
655 Some("message_start") => {
656 if let Some(usage) = json
657 .get("message")
658 .and_then(|message| message.get("usage"))
659 .and_then(parse_token_usage)
660 {
661 state.usage.input_tokens = usage.input_tokens;
662 state.usage.cache_read_tokens = usage.cache_read_tokens;
663 state.usage.cache_write_tokens = usage.cache_write_tokens;
664 }
665 Vec::new()
666 }
667 Some("message_delta") => {
668 if let Some(usage) = json.get("usage").and_then(parse_token_usage) {
669 state.usage.output_tokens = usage.output_tokens;
670 }
671 vec![Ok(StreamEvent::Usage(state.usage))]
672 }
673 Some("message_stop") => {
674 let mut events = Vec::new();
675 if state.usage.input_tokens > 0
676 || state.usage.output_tokens > 0
677 || state.usage.cache_read_tokens > 0
678 || state.usage.cache_write_tokens > 0
679 {
680 events.push(Ok(StreamEvent::Usage(state.usage)));
681 }
682 events.push(Ok(StreamEvent::Done));
683 events
684 }
685 Some("error") => vec![Err(ProviderError::Api(
686 json["error"]["message"]
687 .as_str()
688 .unwrap_or("Anthropic stream error")
689 .to_string(),
690 ))],
691 _ => Vec::new(),
692 }
693}
694
695#[cfg(feature = "providers")]
696fn parse_token_usage(value: &serde_json::Value) -> Option<crate::cost::TokenUsage> {
697 let number = |key: &str| value.get(key).and_then(|v| v.as_u64()).unwrap_or(0) as usize;
698 let usage = crate::cost::TokenUsage {
699 input_tokens: number("input_tokens").max(number("prompt_tokens")),
700 output_tokens: number("output_tokens").max(number("completion_tokens")),
701 cache_read_tokens: number("cache_read_input_tokens").max(
702 value
703 .get("prompt_tokens_details")
704 .and_then(|details| details.get("cached_tokens"))
705 .and_then(|v| v.as_u64())
706 .unwrap_or(0) as usize,
707 ),
708 cache_write_tokens: number("cache_creation_input_tokens"),
709 };
710 (usage.input_tokens > 0
711 || usage.output_tokens > 0
712 || usage.cache_read_tokens > 0
713 || usage.cache_write_tokens > 0)
714 .then_some(usage)
715}
716
717#[derive(Debug, thiserror::Error)]
718pub enum ProviderError {
719 #[error("http error: {0}")]
720 Http(String),
721 #[error("api error: {0}")]
722 Api(String),
723 #[error("stream error: {0}")]
724 Stream(String),
725}
726
727impl ProviderError {
728 pub fn is_transient(&self) -> bool {
729 match self {
730 Self::Http(_) => true,
731 Self::Api(message) => matches!(
732 message.split_whitespace().next(),
733 Some("408" | "409" | "429" | "500" | "502" | "503" | "504")
734 ),
735 Self::Stream(_) => true,
736 }
737 }
738}
739
740#[cfg(test)]
741mod tests {
742 use super::ProviderError;
743 #[cfg(feature = "providers")]
744 use super::*;
745
746 #[test]
747 fn transient_errors_are_retryable() {
748 assert!(ProviderError::Http("reset".into()).is_transient());
749 assert!(ProviderError::Api("429 busy".into()).is_transient());
750 assert!(ProviderError::Api("503 unavailable".into()).is_transient());
751 assert!(!ProviderError::Api("401 unauthorized".into()).is_transient());
752 }
753
754 #[cfg(feature = "providers")]
755 #[test]
756 fn assembles_fragmented_openai_tool_calls() {
757 let mut state = StreamState::default();
758 let fragments = [
759 serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_","function":{"name":"re","arguments":"{\"pa"}}]}}]}),
760 serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"1","function":{"name":"ad","arguments":"th\":\"x\"}"}}]}}]}),
761 ];
762 for fragment in fragments {
763 assert!(parse_sse_events(&fragment, "openai", &mut state).is_empty());
764 }
765 let events = parse_sse_events(
766 &serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}),
767 "openai",
768 &mut state,
769 );
770 let StreamEvent::ToolCall(call) =
771 events.into_iter().next().expect("tool call").expect("ok")
772 else {
773 panic!("expected tool call");
774 };
775 assert_eq!(call.id, "call_1");
776 assert_eq!(call.name, "read");
777 assert_eq!(call.arguments, "{\"path\":\"x\"}");
778 }
779
780 #[cfg(feature = "providers")]
781 #[test]
782 fn wraps_registry_tools_for_openai_compatible_providers() {
783 let tools = vec![serde_json::json!({
784 "name":"read","description":"Read","parameters":{"type":"object"}
785 })];
786 let body = openai_request(&[], &None, "grok-4.5", &tools, Some("high"), false);
787 assert_eq!(body["tools"][0]["type"], "function");
788 assert_eq!(body["tools"][0]["function"], tools[0]);
789 assert!(body.get("stream_options").is_none());
790 }
791
792 #[cfg(feature = "providers")]
793 #[test]
794 fn parses_openai_usage_chunk() {
795 let mut state = StreamState::default();
796 let events = parse_sse_events(
797 &serde_json::json!({
798 "choices": [],
799 "usage": {
800 "prompt_tokens": 1000,
801 "completion_tokens": 200,
802 "prompt_tokens_details": {"cached_tokens": 800}
803 }
804 }),
805 "openai",
806 &mut state,
807 );
808 assert!(matches!(
809 events.into_iter().next().unwrap().unwrap(),
810 StreamEvent::Usage(crate::cost::TokenUsage {
811 input_tokens: 1000,
812 output_tokens: 200,
813 cache_read_tokens: 800,
814 ..
815 })
816 ));
817 }
818
819 #[cfg(feature = "providers")]
820 #[test]
821 fn parses_anthropic_usage_events() {
822 let mut state = StreamState::default();
823 assert!(parse_sse_events(
824 &serde_json::json!({
825 "type": "message_start",
826 "message": {"usage": {"input_tokens": 100, "cache_read_input_tokens": 60}}
827 }),
828 "anthropic",
829 &mut state,
830 )
831 .is_empty());
832 let events = parse_sse_events(
833 &serde_json::json!({"type":"message_delta","usage":{"output_tokens":25}}),
834 "anthropic",
835 &mut state,
836 );
837 assert!(matches!(
838 events.into_iter().next().unwrap().unwrap(),
839 StreamEvent::Usage(crate::cost::TokenUsage {
840 input_tokens: 100,
841 output_tokens: 25,
842 cache_read_tokens: 60,
843 ..
844 })
845 ));
846 }
847
848 #[cfg(feature = "providers")]
849 #[test]
850 fn builds_native_anthropic_request_and_stream() {
851 let messages = vec![
852 Message::user("inspect"),
853 Message::assistant_with_tools(
854 "",
855 vec![ToolCall {
856 id: "tool_1".into(),
857 name: "read".into(),
858 arguments: "{\"path\":\"x\"}".into(),
859 }],
860 ),
861 Message::tool("tool_1", "contents"),
862 ];
863 let tools = vec![serde_json::json!({
864 "name":"read","description":"Read","parameters":{"type":"object"}
865 })];
866 let body = anthropic_request(
867 &messages,
868 &Some("system".into()),
869 "claude-sonnet-4",
870 &tools,
871 Some("high"),
872 &crate::prompt_cache::PromptCacheConfig::disabled(),
873 );
874 assert_eq!(body["system"], "system");
875 assert_eq!(body["tools"][0]["input_schema"]["type"], "object");
876 assert_eq!(body["messages"][1]["content"][0]["type"], "tool_use");
877 assert_eq!(body["messages"][2]["content"][0]["type"], "tool_result");
878 assert_eq!(body["thinking"]["budget_tokens"], 8192);
879
880 let mut state = StreamState::default();
881 parse_sse_events(
882 &serde_json::json!({"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tool_1","name":"read","input":{}}}),
883 "anthropic",
884 &mut state,
885 );
886 parse_sse_events(
887 &serde_json::json!({"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"x\"}"}}),
888 "anthropic",
889 &mut state,
890 );
891 let events = parse_sse_events(
892 &serde_json::json!({"type":"content_block_stop","index":0}),
893 "anthropic",
894 &mut state,
895 );
896 let StreamEvent::ToolCall(call) =
897 events.into_iter().next().expect("tool call").expect("ok")
898 else {
899 panic!("expected tool call");
900 };
901 assert_eq!(call.arguments, "{\"path\":\"x\"}");
902 }
903
904 #[cfg(feature = "providers")]
905 #[test]
906 fn propagates_openai_reasoning_effort() {
907 let body = openai_request(
908 &[Message::user("solve")],
909 &None,
910 "gpt-5.6-sol",
911 &[],
912 Some("xhigh"),
913 true,
914 );
915 assert_eq!(body["stream_options"]["include_usage"], true);
916 assert_eq!(body["reasoning_effort"], "xhigh");
917 }
918
919 #[cfg(feature = "providers")]
920 #[test]
921 fn omits_openai_reasoning_effort_when_host_does_not_supply_one() {
922 let body = openai_request(
923 &[Message::user("solve")],
924 &None,
925 "grok-4.20-0309-reasoning",
926 &[],
927 None,
928 false,
929 );
930 assert!(body.get("reasoning_effort").is_none());
931 }
932}