Skip to main content

rx4/
provider.rs

1//! Provider abstraction with async streaming (pi_agent_rust pattern).
2//! Real SSE streaming via reqwest + eventsource-stream.
3
4use 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/// Streaming events from a provider (pi_agent_rust StreamEvent pattern).
82#[derive(Debug, Clone)]
83pub enum StreamEvent {
84    Delta(String),
85    ToolCall(ToolCall),
86    /// Provider-reported token usage for the completed request.
87    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/// The provider trait — implementations stream completions from LLM backends.
96#[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    /// Non-streaming fallback (used when providers feature is off).
112    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
139/// Provider registry (dashmap, grok pattern).
140pub 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/// OpenAI-compatible provider with real SSE streaming.
175#[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    /// Override prompt-cache configuration (Anthropic cache_control markers).
233    pub fn with_prompt_cache(mut self, config: crate::prompt_cache::PromptCacheConfig) -> Self {
234        self.prompt_cache = config;
235        self
236    }
237
238    /// Prewarm the connection pool by sending a lightweight HEAD request.
239    /// This establishes TCP/TLS connections before the first real request,
240    /// reducing first-request latency (codex-rs preconnect pattern).
241    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    /// Create a session-scoped client that preserves connection state across retries.
258    pub fn new_session(&self) -> ModelClientSession {
259        ModelClientSession {
260            connection_reused: false,
261        }
262    }
263}
264
265/// Session-scoped client state — preserves connection and routing info
266/// across retries within a single turn (codex-rs ModelClientSession pattern).
267#[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    // Apply Anthropic cache_control markers when configured.
493    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    // OpenAI-compatible providers send usage in a final stream chunk when
551    // `stream_options.include_usage` is enabled. That chunk commonly has no
552    // choices, so inspect it before looking for a delta.
553    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}