Skip to main content

ironflow_core/providers/http/
adapter.rs

1//! Core adapter trait and generic provider wrapper for HTTP-based LLM APIs.
2
3use std::time::{Duration, Instant};
4
5use reqwest::Client;
6use serde_json::{Value, json};
7use tracing::{debug, info, warn};
8
9use crate::error::AgentError;
10use crate::provider::{
11    AgentConfig, AgentOutput, AgentProvider, DebugMessage, DebugToolCall, DebugToolResult,
12    InvokeFuture,
13};
14use crate::providers::http::sse::{SseDelta, collect_sse_stream};
15use crate::providers::http::tools::ToolRegistry;
16use crate::providers::http::tools::routing::route_tool_call;
17
18/// Normalized result of one API turn (one HTTP request/response cycle).
19#[derive(Debug)]
20pub struct TurnResult {
21    /// Free-form text content from the model.
22    pub text: Option<String>,
23    /// Tool calls requested by the model in this turn (unused in V1 - no tool execution).
24    #[allow(dead_code)]
25    pub tool_calls: Vec<HttpToolCall>,
26    /// Whether this is the final turn.
27    pub is_final: bool,
28    /// Extracted structured JSON value when a schema was requested.
29    pub structured_value: Option<Value>,
30    /// Token usage reported by the provider.
31    pub usage: HttpUsage,
32    /// Concrete model identifier returned by the provider.
33    pub model: Option<String>,
34}
35
36/// A single tool call requested by the model.
37#[derive(Debug, Clone)]
38#[allow(dead_code)]
39pub struct HttpToolCall {
40    /// Provider-assigned call identifier.
41    pub id: String,
42    /// Tool name.
43    pub name: String,
44    /// Input arguments as JSON.
45    pub input: Value,
46}
47
48/// Token usage from a single turn.
49#[derive(Debug, Default)]
50pub struct HttpUsage {
51    /// Input/prompt tokens consumed.
52    pub input_tokens: Option<u64>,
53    /// Output/completion tokens generated.
54    pub output_tokens: Option<u64>,
55}
56
57/// Internal trait implemented by each HTTP provider backend.
58///
59/// The generic [`HttpAgentProvider`] calls these methods to build requests,
60/// parse responses, and configure authentication. The agentic loop, retry,
61/// and timeout are handled by the wrapper.
62pub trait HttpAgentAdapter: Send + Sync + 'static {
63    /// Provider name for logging and errors.
64    fn provider_name(&self) -> &'static str;
65
66    /// Full endpoint URL for the given model.
67    fn endpoint_url(&self, model: &str) -> String;
68
69    /// Authentication and provider-specific headers.
70    fn auth_headers(&self) -> Vec<(String, String)>;
71
72    /// Build the initial JSON request body from an [`AgentConfig`].
73    fn build_request(&self, config: &AgentConfig) -> Result<Value, AgentError>;
74
75    /// Parse a non-streaming response body into a [`TurnResult`].
76    fn parse_response(&self, body: &Value, config: &AgentConfig) -> Result<TurnResult, AgentError>;
77
78    /// Parse a single SSE `data:` line into a streaming delta.
79    fn parse_sse_line(&self, line: &str) -> Option<SseDelta>;
80
81    /// Fold accumulated SSE deltas into a complete [`TurnResult`].
82    fn fold_sse_deltas(
83        &self,
84        deltas: Vec<SseDelta>,
85        config: &AgentConfig,
86    ) -> Result<TurnResult, AgentError>;
87
88    /// Compute cost in USD from token counts. Returns `None` if unknown.
89    fn compute_cost(&self, model: &str, input_tokens: u64, output_tokens: u64) -> Option<f64>;
90
91    /// Resolve model alias (e.g. "sonnet") to a provider-specific model ID.
92    fn resolve_model(&self, model: &str) -> String;
93}
94
95/// Default timeout for HTTP provider requests.
96const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
97
98/// Generic HTTP provider that wraps any [`HttpAgentAdapter`].
99///
100/// Implements [`AgentProvider`] by delegating request construction and response
101/// parsing to the adapter while handling the HTTP transport, timeout, and
102/// agentic execution loop.
103///
104/// When a [`ToolRegistry`] is attached via [`with_tools`](Self::with_tools),
105/// the provider runs a multi-turn agentic loop: executing tool calls locally
106/// and feeding results back to the model until it produces a final response
107/// (or hits `max_turns` / `max_budget_usd` limits).
108///
109/// Without a registry, the provider behaves as single-turn (backward-compatible).
110pub struct HttpAgentProvider<A: HttpAgentAdapter> {
111    adapter: A,
112    client: Client,
113    timeout: Duration,
114    tool_registry: Option<ToolRegistry>,
115}
116
117impl<A: HttpAgentAdapter> HttpAgentProvider<A> {
118    /// Create a new HTTP provider with the given adapter.
119    pub fn new(adapter: A) -> Self {
120        let client = Client::builder()
121            .timeout(DEFAULT_TIMEOUT)
122            .build()
123            .expect("failed to build reqwest client");
124        Self {
125            adapter,
126            client,
127            timeout: DEFAULT_TIMEOUT,
128            tool_registry: None,
129        }
130    }
131
132    /// Attach a tool registry to enable multi-turn agentic execution.
133    ///
134    /// When tools are registered, the provider will:
135    /// 1. Include the tools in every request (OpenAI `tools` format).
136    /// 2. Execute tool calls returned by the model.
137    /// 3. Loop until the model produces a final response or limits are hit.
138    pub fn with_tools(mut self, registry: ToolRegistry) -> Self {
139        self.tool_registry = Some(registry);
140        self
141    }
142
143    /// Override the request timeout.
144    pub fn with_timeout(mut self, timeout: Duration) -> Self {
145        self.timeout = timeout;
146        self.client = Client::builder()
147            .timeout(timeout)
148            .build()
149            .expect("failed to build reqwest client");
150        self
151    }
152
153    async fn execute_turn(
154        &self,
155        request_body: &Value,
156        config: &AgentConfig,
157    ) -> Result<TurnResult, AgentError> {
158        let model = self.adapter.resolve_model(&config.model);
159        let url = self.adapter.endpoint_url(&model);
160        let headers = self.adapter.auth_headers();
161
162        let mut req = self.client.post(&url).json(request_body);
163        for (key, value) in &headers {
164            req = req.header(key, value);
165        }
166
167        let response = tokio::time::timeout(self.timeout, req.send())
168            .await
169            .map_err(|_| AgentError::Timeout {
170                limit: self.timeout,
171            })?
172            .map_err(|e| {
173                if e.is_timeout() {
174                    AgentError::Timeout {
175                        limit: self.timeout,
176                    }
177                } else {
178                    AgentError::HttpProvider {
179                        provider: self.adapter.provider_name().to_string(),
180                        status_code: 0,
181                        message: format!("connection failed: {e}"),
182                    }
183                }
184            })?;
185
186        let status = response.status().as_u16();
187
188        if status == 429 {
189            let retry_after = response
190                .headers()
191                .get("retry-after")
192                .and_then(|v| v.to_str().ok())
193                .and_then(|v| v.parse::<u64>().ok());
194            return Err(AgentError::RateLimited {
195                provider: self.adapter.provider_name().to_string(),
196                retry_after_secs: retry_after,
197            });
198        }
199
200        if status >= 400 {
201            let body_text = response.text().await.unwrap_or_default();
202            let message = serde_json::from_str::<Value>(&body_text)
203                .ok()
204                .and_then(|v| {
205                    v.get("error")
206                        .and_then(|e| e.get("message"))
207                        .and_then(|m| m.as_str())
208                        .map(String::from)
209                })
210                .unwrap_or(body_text);
211            return Err(AgentError::HttpProvider {
212                provider: self.adapter.provider_name().to_string(),
213                status_code: status,
214                message,
215            });
216        }
217
218        if config.verbose {
219            let deltas = collect_sse_stream(&self.adapter, response, self.timeout).await?;
220            self.adapter.fold_sse_deltas(deltas, config)
221        } else {
222            let body: Value = response
223                .json()
224                .await
225                .map_err(|e| AgentError::HttpProvider {
226                    provider: self.adapter.provider_name().to_string(),
227                    status_code: 0,
228                    message: format!("failed to parse response JSON: {e}"),
229                })?;
230            self.adapter.parse_response(&body, config)
231        }
232    }
233}
234
235/// Accumulates usage across turns and builds the final [`AgentOutput`].
236struct LoopState {
237    start: Instant,
238    total_input_tokens: u64,
239    total_output_tokens: u64,
240    total_cost: f64,
241    model_name: Option<String>,
242    debug_messages: Vec<DebugMessage>,
243    verbose: bool,
244}
245
246impl LoopState {
247    fn new(start: Instant, verbose: bool) -> Self {
248        Self {
249            start,
250            total_input_tokens: 0,
251            total_output_tokens: 0,
252            total_cost: 0.0,
253            model_name: None,
254            debug_messages: Vec::new(),
255            verbose,
256        }
257    }
258
259    fn into_output(self, value: Value) -> AgentOutput {
260        AgentOutput {
261            value,
262            session_id: None,
263            cost_usd: if self.total_cost > 0.0 {
264                Some(self.total_cost)
265            } else {
266                None
267            },
268            input_tokens: Some(self.total_input_tokens),
269            output_tokens: Some(self.total_output_tokens),
270            model: self.model_name,
271            duration_ms: self.start.elapsed().as_millis() as u64,
272            debug_messages: if self.verbose {
273                Some(self.debug_messages)
274            } else {
275                None
276            },
277        }
278    }
279}
280
281/// Extract the final value from a turn result (structured or text).
282fn extract_value(turn_result: &TurnResult) -> Value {
283    if let Some(ref structured) = turn_result.structured_value {
284        structured.clone()
285    } else {
286        turn_result
287            .text
288            .as_ref()
289            .map(|t| Value::String(t.clone()))
290            .unwrap_or(Value::String(String::new()))
291    }
292}
293
294/// Extract the text value from a turn result (ignoring structured).
295fn extract_text_value(turn_result: &TurnResult) -> Value {
296    turn_result
297        .text
298        .as_ref()
299        .map(|t| Value::String(t.clone()))
300        .unwrap_or(Value::String(String::new()))
301}
302
303impl<A: HttpAgentAdapter> AgentProvider for HttpAgentProvider<A> {
304    fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a> {
305        Box::pin(async move {
306            let mut request_body = self.adapter.build_request(config)?;
307
308            // Inject tools into the request if a registry is available
309            if let Some(ref registry) = self.tool_registry
310                && !registry.is_empty()
311            {
312                let tools_array = registry.to_openai_tools();
313                request_body["tools"] = Value::Array(tools_array);
314            }
315
316            let max_turns = config.max_turns.unwrap_or(25) as usize;
317            let max_budget = config.max_budget_usd.unwrap_or(f64::MAX);
318            let mut state = LoopState::new(Instant::now(), config.verbose);
319
320            // Messages array for multi-turn
321            let mut messages: Vec<Value> = request_body
322                .get("messages")
323                .and_then(|m| m.as_array())
324                .cloned()
325                .unwrap_or_default();
326
327            for turn in 0..max_turns {
328                request_body["messages"] = Value::Array(messages.clone());
329                let turn_result = self.execute_turn(&request_body, config).await?;
330
331                // Accumulate usage
332                let turn_input = turn_result.usage.input_tokens.unwrap_or(0);
333                let turn_output = turn_result.usage.output_tokens.unwrap_or(0);
334                state.total_input_tokens += turn_input;
335                state.total_output_tokens += turn_output;
336
337                if state.model_name.is_none() {
338                    state.model_name = turn_result.model.clone();
339                }
340
341                if let Some(ref model) = state.model_name
342                    && let Some(turn_cost) =
343                        self.adapter.compute_cost(model, turn_input, turn_output)
344                {
345                    state.total_cost += turn_cost;
346                }
347
348                // Record debug trace for this turn
349                if config.verbose {
350                    let tool_calls_debug: Vec<DebugToolCall> = turn_result
351                        .tool_calls
352                        .iter()
353                        .map(|tc| DebugToolCall {
354                            id: Some(tc.id.clone()),
355                            name: tc.name.clone(),
356                            input: tc.input.clone(),
357                        })
358                        .collect();
359
360                    state.debug_messages.push(DebugMessage {
361                        text: turn_result.text.clone(),
362                        thinking: None,
363                        thinking_redacted: false,
364                        tool_calls: tool_calls_debug,
365                        tool_results: Vec::new(),
366                        stop_reason: if turn_result.is_final {
367                            Some("end_turn".to_string())
368                        } else {
369                            Some("tool_use".to_string())
370                        },
371                        input_tokens: Some(turn_input),
372                        output_tokens: Some(turn_output),
373                    });
374                }
375
376                // Final response (no tool calls) -> return
377                if turn_result.is_final || turn_result.tool_calls.is_empty() {
378                    info!(
379                        provider = self.adapter.provider_name(),
380                        turns = turn + 1,
381                        duration_ms = state.start.elapsed().as_millis() as u64,
382                        input_tokens = state.total_input_tokens,
383                        output_tokens = state.total_output_tokens,
384                        "invocation complete"
385                    );
386                    return Ok(state.into_output(extract_value(&turn_result)));
387                }
388
389                // Tool calls but no registry -> return text (backward compat)
390                let registry = match self.tool_registry {
391                    Some(ref r) => r,
392                    None => {
393                        warn!(
394                            provider = self.adapter.provider_name(),
395                            tool_calls = turn_result.tool_calls.len(),
396                            "model requested tool calls but no registry attached, returning text"
397                        );
398                        return Ok(state.into_output(extract_text_value(&turn_result)));
399                    }
400                };
401
402                // Budget exceeded -> stop
403                if state.total_cost >= max_budget {
404                    warn!(
405                        provider = self.adapter.provider_name(),
406                        cost = state.total_cost,
407                        budget = max_budget,
408                        "budget exceeded, stopping agentic loop"
409                    );
410                    return Ok(state.into_output(extract_text_value(&turn_result)));
411                }
412
413                // Build assistant message with tool_calls for conversation history
414                let assistant_tool_calls: Vec<Value> = turn_result
415                    .tool_calls
416                    .iter()
417                    .map(|tc| {
418                        json!({
419                            "id": tc.id,
420                            "type": "function",
421                            "function": {
422                                "name": tc.name,
423                                "arguments": tc.input.to_string()
424                            }
425                        })
426                    })
427                    .collect();
428
429                let mut assistant_msg = json!({"role": "assistant"});
430                if let Some(ref text) = turn_result.text {
431                    assistant_msg["content"] = Value::String(text.clone());
432                } else {
433                    assistant_msg["content"] = Value::Null;
434                }
435                assistant_msg["tool_calls"] = Value::Array(assistant_tool_calls);
436                messages.push(assistant_msg);
437
438                // Execute each tool call
439                let mut tool_results_debug: Vec<DebugToolResult> = Vec::new();
440
441                for tc in &turn_result.tool_calls {
442                    debug!(
443                        provider = self.adapter.provider_name(),
444                        tool = %tc.name,
445                        call_id = %tc.id,
446                        "executing tool call"
447                    );
448
449                    let connectors = registry.connectors();
450                    let (content, is_error) = if !connectors.is_empty() {
451                        match route_tool_call(&tc.name, connectors) {
452                            Ok(routed) => {
453                                match registry
454                                    .execute(&routed.registry_key, tc.input.clone())
455                                    .await
456                                {
457                                    Some(Ok(output)) => (output.content, output.is_error),
458                                    Some(Err(err)) => {
459                                        (format!("Tool execution error: {err}"), true)
460                                    }
461                                    None => (format!("Unknown tool: {}", tc.name), true),
462                                }
463                            }
464                            Err(routing_err) => (routing_err.to_string(), true),
465                        }
466                    } else {
467                        match registry.execute(&tc.name, tc.input.clone()).await {
468                            Some(Ok(output)) => (output.content, output.is_error),
469                            Some(Err(err)) => (format!("Tool execution error: {err}"), true),
470                            None => (format!("Unknown tool: {}", tc.name), true),
471                        }
472                    };
473
474                    messages.push(json!({
475                        "role": "tool",
476                        "tool_call_id": tc.id,
477                        "content": content
478                    }));
479
480                    if config.verbose {
481                        tool_results_debug.push(DebugToolResult {
482                            tool_use_id: Some(tc.id.clone()),
483                            content: Value::String(content.clone()),
484                            is_error,
485                        });
486                    }
487                }
488
489                if config.verbose
490                    && let Some(last_msg) = state.debug_messages.last_mut()
491                {
492                    last_msg.tool_results = tool_results_debug;
493                }
494
495                info!(
496                    provider = self.adapter.provider_name(),
497                    turn = turn + 1,
498                    tools_executed = turn_result.tool_calls.len(),
499                    "turn complete, continuing loop"
500                );
501            }
502
503            warn!(
504                provider = self.adapter.provider_name(),
505                max_turns, "max turns reached, returning last state"
506            );
507            Ok(state.into_output(Value::String(String::new())))
508        })
509    }
510}