Skip to main content

toolnexus/
client.rs

1//! Unified LLM client (SPEC §8): one tool-calling loop over the OpenAI
2//! (Chat Completions) and Anthropic (Messages) wire styles, with hooks,
3//! streaming, retries (incl. `Retry-After`), conversation memory, metrics,
4//! prompt caching, tool tiers and §10 suspension (`wait_for` / `pending`).
5//!
6//! Mirrors `golang/client.go`. Gemini is a schema adapter only (§0 item 7), as in
7//! every other port: there is no Gemini wire style in the loop.
8
9mod acp_bridge;
10mod cache;
11mod emit;
12mod inprocess;
13mod metrics;
14mod models;
15mod tiers;
16mod translate;
17
18use std::collections::HashMap;
19use std::future::Future;
20use std::pin::Pin;
21use std::sync::{Arc, Mutex};
22use std::time::{Duration, Instant};
23
24use serde::{Deserialize, Serialize};
25use serde_json::{Map, Value, json};
26use tokio::sync::mpsc;
27use tokio_util::sync::CancellationToken;
28
29pub use acp_bridge::{acp_generate, acp_generate_stream, acp_messages};
30pub use cache::PromptCacheOptions;
31pub use emit::{UNSUPPORTED_PART_AS_ERROR, UNSUPPORTED_PART_AS_TEXT, validate_part};
32pub use inprocess::{
33    EmitFn, GenerateFn, GenerateStreamFn, InProcessOptions, InProcessRequest, InProcessResponse,
34    InProcessToolCall, InProcessUsage, create_in_process_client,
35};
36pub use metrics::MetricEvent;
37pub use models::{ModelInfo, list_models};
38pub use tiers::{CountTokensFn, ToolTier, ToolTierOptions, UsageCountFn};
39pub use translate::{TranslateRequest, TranslateResult, TranslatedToolCall};
40
41use crate::store::{ConversationStore, InMemoryConversationStore};
42use crate::tool::{Answer, ContentPart, Request, ToolContext, ToolHints, ToolResult, pending_of};
43use crate::toolkit::Toolkit;
44use crate::{to_anthropic, to_openai};
45
46// ---------------------------------------------------------------------------
47// options
48// ---------------------------------------------------------------------------
49
50/// The provider wire style.
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum Style {
54    #[default]
55    #[serde(rename = "openai")]
56    OpenAI,
57    Anthropic,
58}
59
60/// A boxed, sendable future (hooks and `wait_for` are async).
61pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
62pub type BeforeLlmHook =
63    Arc<dyn Fn(BeforeLlmEvent) -> BoxFuture<Result<Option<LlmOverride>, String>> + Send + Sync>;
64pub type AfterLlmHook = Arc<dyn Fn(AfterLlmEvent) -> BoxFuture<Result<(), String>> + Send + Sync>;
65pub type BeforeToolHook =
66    Arc<dyn Fn(BeforeToolEvent) -> BoxFuture<Result<Option<ToolOverride>, String>> + Send + Sync>;
67pub type AfterToolHook =
68    Arc<dyn Fn(AfterToolEvent) -> BoxFuture<Result<Option<ToolOverride>, String>> + Send + Sync>;
69/// §10 host slot: resolve a suspension.
70pub type WaitForFn = Arc<dyn Fn(Request) -> BoxFuture<Result<Answer, String>> + Send + Sync>;
71/// §10 host slot with the turn's cancellation (a pending question ends with its turn).
72pub type WaitForCtxFn =
73    Arc<dyn Fn(CancellationToken, Request) -> BoxFuture<Result<Answer, String>> + Send + Sync>;
74pub type OnMetricFn = Arc<dyn Fn(&MetricEvent) + Send + Sync>;
75pub type OnErrorFn = Arc<dyn Fn(&ErrorInfo) -> ErrorTier + Send + Sync>;
76pub type BodyTransformFn = Arc<dyn Fn(Value) -> Option<Value> + Send + Sync>;
77
78/// Lifecycle middleware (§8 Hooks). Every hook may fail the run with `Err`.
79#[derive(Clone, Default)]
80pub struct Hooks {
81    pub before_llm: Option<BeforeLlmHook>,
82    pub after_llm: Option<AfterLlmHook>,
83    pub before_tool: Option<BeforeToolHook>,
84    pub after_tool: Option<AfterToolHook>,
85}
86
87#[derive(Debug, Clone)]
88pub struct BeforeLlmEvent {
89    pub messages: Vec<Value>,
90    pub tools: Vec<Value>,
91    pub model: String,
92    pub turn: usize,
93}
94
95#[derive(Debug, Clone)]
96pub struct AfterLlmEvent {
97    pub response: Value,
98    pub model: String,
99    pub turn: usize,
100}
101
102#[derive(Debug, Clone)]
103pub struct BeforeToolEvent {
104    pub name: String,
105    pub args: Value,
106    pub id: String,
107    pub turn: usize,
108    /// The tool's advisory hints (for permission gates).
109    pub hints: Option<ToolHints>,
110}
111
112#[derive(Debug, Clone)]
113pub struct AfterToolEvent {
114    pub name: String,
115    pub args: Value,
116    pub result: ToolResult,
117    pub id: String,
118    pub turn: usize,
119}
120
121/// `before_llm` override: replace messages / tools, or route this call to another model.
122#[derive(Debug, Clone, Default)]
123pub struct LlmOverride {
124    pub messages: Option<Vec<Value>>,
125    pub tools: Option<Vec<Value>>,
126    pub model: Option<String>,
127}
128
129/// `before_tool` / `after_tool` override: rewrite args, or short-circuit with a result.
130#[derive(Debug, Clone, Default)]
131pub struct ToolOverride {
132    pub args: Option<Value>,
133    pub result: Option<ToolResult>,
134}
135
136/// What `on_error` decides for one failed attempt.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum ErrorTier {
139    Retry,
140    Fail,
141}
142
143/// One failed LLM attempt, for `on_error`.
144#[derive(Debug, Clone)]
145pub struct ErrorInfo {
146    pub error: String,
147    /// HTTP status, 0 for a transport error.
148    pub status: u16,
149    pub attempt: u32,
150    /// The default classification.
151    pub retryable: bool,
152}
153
154/// Client options (§8). Field names are the parity-checked option names.
155#[derive(Clone, Default)]
156pub struct ClientOptions {
157    pub base_url: String,
158    pub style: Style,
159    pub model: String,
160    /// Explicit key. Else `api_key_env`, else the env var that matches the host.
161    pub api_key: String,
162    /// Name of the env var to read the key from (any host).
163    pub api_key_env: String,
164    pub headers: HashMap<String, String>,
165    pub system_prompt: String,
166    /// 0 ⇒ 10.
167    pub max_turns: usize,
168    /// Cap on concurrency-safe tool calls of one response running at once (SPEC §8
169    /// "Tool scheduling"). 0 ⇒ env `TOOLNEXUS_MAX_TOOL_CONCURRENCY` when a positive
170    /// integer, else 10. Unsafe calls (anything not read-only) always run alone.
171    pub max_tool_concurrency: usize,
172    pub hooks: Hooks,
173    /// 0 ⇒ 2 retries (3 attempts); -1 ⇒ no retries; n ⇒ n.
174    pub retries: i32,
175    /// 0 ⇒ 500 ms.
176    pub retry_base_ms: u64,
177    /// Extra statuses to retry, ADDED to 429/500/502/503/504/529.
178    pub retryable_statuses: Vec<u16>,
179    /// Whole-run deadline in ms (0 = none).
180    pub timeout_ms: u64,
181    /// `None` ⇒ in-memory.
182    pub store: Option<Arc<dyn ConversationStore>>,
183    pub on_metric: Option<OnMetricFn>,
184    pub wait_for: Option<WaitForFn>,
185    /// Used instead of `wait_for` when both are set.
186    pub wait_for_ctx: Option<WaitForCtxFn>,
187    /// Merged into every request body (not `messages`/`tools`/`stream`).
188    pub request_params: Map<String, Value>,
189    /// Last word on the request body (`None` return keeps it).
190    pub body_transform: Option<BodyTransformFn>,
191    pub prompt_cache: Option<PromptCacheOptions>,
192    pub tool_tiers: Option<ToolTierOptions>,
193    pub http_client: Option<reqwest::Client>,
194    pub on_error: Option<OnErrorFn>,
195    /// Per-part decoded size cap (0 = none).
196    pub max_part_bytes: usize,
197    /// `"error"` | `"text"` | empty (strict for attached parts, placeholder for tool parts).
198    pub on_unsupported_part: String,
199}
200
201// ---------------------------------------------------------------------------
202// results, events, errors
203// ---------------------------------------------------------------------------
204
205/// One executed tool call.
206#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
207pub struct ToolCall {
208    pub name: String,
209    pub args: Value,
210    pub output: String,
211    #[serde(rename = "isError")]
212    pub is_error: bool,
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub metadata: Option<Map<String, Value>>,
215    /// The tool result's non-text parts (§1B); present iff it had parts.
216    #[serde(default, skip_serializing_if = "Vec::is_empty")]
217    pub parts: Vec<ContentPart>,
218}
219
220/// A non-text part produced by a tool call during a run (§8 `RunResult.files`).
221#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
222pub struct RunFile {
223    #[serde(rename = "type")]
224    pub kind: String,
225    #[serde(rename = "mimeType", default, skip_serializing_if = "String::is_empty")]
226    pub mime_type: String,
227    #[serde(default, skip_serializing_if = "String::is_empty")]
228    pub url: String,
229    #[serde(default, skip_serializing_if = "String::is_empty")]
230    pub data: String,
231    /// The part's name, when it has one.
232    #[serde(default, skip_serializing_if = "String::is_empty")]
233    pub name: String,
234    /// The tool's name.
235    pub from: String,
236    /// The model's tool-call id.
237    #[serde(rename = "callId")]
238    pub call_id: String,
239}
240
241/// The `files` entries a tool call contributes: every non-text part, in order.
242pub fn run_files_of(call: &ToolCall, call_id: &str) -> Vec<RunFile> {
243    call.parts
244        .iter()
245        .filter(|p| p.kind != "text")
246        .map(|p| RunFile {
247            kind: p.kind.clone(),
248            mime_type: p.mime_type.clone(),
249            url: p.url.clone(),
250            data: p.data.clone(),
251            name: p.name.clone(),
252            from: call.name.clone(),
253            call_id: call_id.to_string(),
254        })
255        .collect()
256}
257
258/// Token usage, summed over a run.
259#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
260pub struct Usage {
261    #[serde(rename = "promptTokens")]
262    pub prompt_tokens: i64,
263    #[serde(rename = "completionTokens")]
264    pub completion_tokens: i64,
265    #[serde(rename = "totalTokens")]
266    pub total_tokens: i64,
267    #[serde(rename = "cacheReadTokens", skip_serializing_if = "is_zero", default)]
268    pub cache_read_tokens: i64,
269    #[serde(rename = "cacheWriteTokens", skip_serializing_if = "is_zero", default)]
270    pub cache_write_tokens: i64,
271}
272
273fn is_zero(n: &i64) -> bool {
274    *n == 0
275}
276
277/// The outcome of a run. `status`: `done` | `pending` | `incomplete` (with `limit`).
278#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
279pub struct RunResult {
280    pub text: String,
281    pub messages: Vec<Value>,
282    #[serde(rename = "toolCalls")]
283    pub tool_calls: Vec<ToolCall>,
284    /// Every non-text part tool calls produced this run, in call order (§8).
285    #[serde(default)]
286    pub files: Vec<RunFile>,
287    #[serde(rename = "toolCallCount")]
288    pub tool_call_count: usize,
289    pub turns: usize,
290    pub usage: Usage,
291    pub model: String,
292    pub status: String,
293    #[serde(skip_serializing_if = "String::is_empty", default)]
294    pub limit: String,
295    #[serde(skip_serializing_if = "Option::is_none", default)]
296    pub pending: Option<Request>,
297}
298
299/// A streaming event. `kind`: `text` | `thought` | `tool_call` | `tool_result` |
300/// `usage` | `pending` | `done` | `error`. `thought` (an ACP agent's
301/// `agent_thought_chunk`, `via: "acp"`) is never part of the answer text.
302#[derive(Debug, Clone, Default)]
303pub struct StreamEvent {
304    pub kind: String,
305    pub delta: String,
306    pub name: String,
307    /// `"call_tool"` when the model called the tool through `call_tool`; `"acp"` from an ACP agent.
308    pub via: String,
309    pub id: String,
310    pub args: Value,
311    pub output: String,
312    pub is_error: bool,
313    pub metadata: Option<Map<String, Value>>,
314    pub usage: Option<Usage>,
315    pub result: Option<Box<RunResult>>,
316    pub request: Option<Request>,
317    pub error: Option<ClientError>,
318}
319
320impl StreamEvent {
321    fn of(kind: &str) -> Self {
322        Self {
323            kind: kind.into(),
324            ..Default::default()
325        }
326    }
327    /// A text delta (for in-process `generate_stream` emitters).
328    pub fn text(delta: impl Into<String>) -> Self {
329        Self {
330            kind: "text".into(),
331            delta: delta.into(),
332            ..Default::default()
333        }
334    }
335}
336
337/// A non-2xx provider answer. The body is redacted (account ids) and dropped
338/// entirely for 401/403.
339#[derive(Debug, Clone, PartialEq)]
340pub struct ProviderError {
341    pub status: u16,
342    pub body: String,
343    pub retry_after: String,
344}
345
346/// Marker that replaces a redacted account identifier in a provider error body.
347pub const REDACTED_MARKER: &str = "«redacted»";
348
349/// Redact account identifiers from a provider error body (empty for 401/403).
350pub fn redact_provider_body(status: u16, body: &str) -> String {
351    if status == 401 || status == 403 {
352        return String::new();
353    }
354    let s = body.trim();
355    if s.is_empty() {
356        return String::new();
357    }
358    static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
359    let re = RE.get_or_init(|| {
360        regex::Regex::new(
361            r#"("(?:user_id|account_id|org_id|organization)"\s*:\s*)("(?:[^"\\]|\\.)*"|[^,}\s]+)"#,
362        )
363        .expect("static regex")
364    });
365    re.replace_all(s, format!("${{1}}\"{REDACTED_MARKER}\"").as_str())
366        .into_owned()
367}
368
369impl std::fmt::Display for ProviderError {
370    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
371        if self.body.is_empty() {
372            return write!(f, "LLM {}", self.status);
373        }
374        let b = &self.body;
375        if b.len() > 200 {
376            let mut n = 200;
377            while !b.is_char_boundary(n) {
378                n -= 1;
379            }
380            write!(f, "LLM {}: {}…", self.status, &b[..n])
381        } else {
382            write!(f, "LLM {}: {}", self.status, b)
383        }
384    }
385}
386
387/// Every way a run can fail.
388#[derive(Debug, Clone, thiserror::Error)]
389pub enum ClientError {
390    #[error("{0}")]
391    NoApiKey(String),
392    #[error("{0}")]
393    Provider(ProviderError),
394    #[error("{0}")]
395    Transport(String),
396    #[error("decode LLM response: {0}")]
397    Decode(String),
398    #[error("{0}")]
399    Hook(String),
400    #[error("waitFor: {0}")]
401    WaitFor(String),
402    #[error("{0}")]
403    Part(String),
404    #[error("conversation store: {0}")]
405    Store(String),
406    #[error("context canceled")]
407    Cancelled,
408    /// The whole-run `timeout_ms` elapsed; `partial` is the incomplete result
409    /// (`status:"incomplete"`, `limit:"timeout"`).
410    #[error("run timeout after {timeout_ms}ms")]
411    RunTimeout {
412        timeout_ms: u64,
413        partial: Box<RunResult>,
414    },
415    #[error("{0}")]
416    Unsupported(String),
417    /// A durable resume (`run_with_answer` / `ask_with_answer`) was refused.
418    #[error("{0}")]
419    Resume(String),
420}
421
422/// What a run starts from.
423#[derive(Debug, Clone)]
424pub enum Prompt {
425    Text(String),
426    /// Multimodal user turn (§1B).
427    Parts(Vec<ContentPart>),
428}
429
430impl From<&str> for Prompt {
431    fn from(s: &str) -> Self {
432        Prompt::Text(s.into())
433    }
434}
435impl From<String> for Prompt {
436    fn from(s: String) -> Self {
437        Prompt::Text(s)
438    }
439}
440impl From<Vec<ContentPart>> for Prompt {
441    fn from(p: Vec<ContentPart>) -> Self {
442        Prompt::Parts(p)
443    }
444}
445
446fn user_message(p: &Prompt) -> Value {
447    match p {
448        Prompt::Text(s) => json!({"role": "user", "content": s}),
449        Prompt::Parts(parts) => json!({"role": "user", "content": parts}),
450    }
451}
452
453// ---------------------------------------------------------------------------
454// client
455// ---------------------------------------------------------------------------
456
457const DEFAULT_RETRYABLE: [u16; 6] = [429, 500, 502, 503, 504, 529];
458
459fn is_retryable_status(status: u16, extra: &[u16]) -> bool {
460    DEFAULT_RETRYABLE.contains(&status) || extra.contains(&status)
461}
462
463/// Parse a `Retry-After` header: plain non-negative integer seconds ≤ i32::MAX only.
464pub fn retry_after_seconds(v: &str) -> Option<Duration> {
465    let s = v.trim();
466    if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
467        return None;
468    }
469    let secs: i64 = s.parse().ok()?;
470    if secs > i32::MAX as i64 {
471        return None;
472    }
473    Some(Duration::from_secs(secs as u64))
474}
475
476pub(crate) enum Transport {
477    Http(reqwest::Client),
478    InProcess(Arc<inprocess::InProcess>),
479}
480
481pub(crate) enum RespBody {
482    Http(reqwest::Response),
483    Full(Vec<u8>),
484    Chunks(mpsc::UnboundedReceiver<Result<Vec<u8>, String>>),
485}
486
487pub(crate) struct RawResponse {
488    pub status: u16,
489    pub retry_after: String,
490    pub body: RespBody,
491}
492
493impl RawResponse {
494    async fn read_all(self) -> Result<Vec<u8>, ClientError> {
495        match self.body {
496            RespBody::Full(b) => Ok(b),
497            RespBody::Http(r) => r
498                .bytes()
499                .await
500                .map(|b| b.to_vec())
501                .map_err(|e| ClientError::Transport(e.to_string())),
502            RespBody::Chunks(mut rx) => {
503                let mut out = Vec::new();
504                while let Some(c) = rx.recv().await {
505                    out.extend(c.map_err(ClientError::Transport)?);
506                }
507                Ok(out)
508            }
509        }
510    }
511}
512
513/// The §7D global turn gate: at most N LLM calls in flight across every
514/// client that shares it. It wraps ONLY the provider call (never a whole run),
515/// and the slot is released on any exit, including a cancelled caller.
516pub(crate) struct TurnGate {
517    sem: Arc<tokio::sync::Semaphore>,
518    counts: Mutex<(usize, usize)>,
519}
520
521pub(crate) struct TurnSlot<'a> {
522    gate: &'a TurnGate,
523    _permit: tokio::sync::OwnedSemaphorePermit,
524}
525
526impl Drop for TurnSlot<'_> {
527    fn drop(&mut self) {
528        let mut c = self.gate.counts.lock().unwrap_or_else(|e| e.into_inner());
529        c.0 -= 1;
530    }
531}
532
533impl TurnGate {
534    pub(crate) fn new(n: usize) -> Self {
535        TurnGate {
536            sem: Arc::new(tokio::sync::Semaphore::new(n.max(1))),
537            counts: Mutex::new((0, 0)),
538        }
539    }
540    async fn acquire(&self) -> TurnSlot<'_> {
541        let permit = self
542            .sem
543            .clone()
544            .acquire_owned()
545            .await
546            .expect("turn gate semaphore is never closed");
547        let mut c = self.counts.lock().unwrap_or_else(|e| e.into_inner());
548        c.0 += 1;
549        if c.0 > c.1 {
550            c.1 = c.0;
551        }
552        TurnSlot {
553            gate: self,
554            _permit: permit,
555        }
556    }
557    /// The high-water mark of in-flight calls.
558    pub(crate) fn max_observed(&self) -> usize {
559        self.counts.lock().unwrap_or_else(|e| e.into_inner()).1
560    }
561}
562
563struct Inner {
564    opts: ClientOptions,
565    transport: Transport,
566    gate: Option<Arc<TurnGate>>,
567    store: Arc<dyn ConversationStore>,
568    registry: metrics::Registry,
569    plans: Mutex<HashMap<usize, Arc<tiers::Plan>>>,
570}
571
572/// The unified client. Cheap to clone.
573#[derive(Clone)]
574pub struct Client {
575    inner: Arc<Inner>,
576}
577
578impl std::fmt::Debug for Client {
579    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
580        f.debug_struct("Client")
581            .field("base_url", &self.inner.opts.base_url)
582            .field("style", &self.inner.opts.style)
583            .field("model", &self.inner.opts.model)
584            .finish_non_exhaustive()
585    }
586}
587
588/// Build a client (§8).
589pub fn create_client(opts: ClientOptions) -> Client {
590    let transport = Transport::Http(opts.http_client.clone().unwrap_or_default());
591    Client::with_transport(opts, transport)
592}
593
594#[derive(Clone)]
595struct Call {
596    id: String,
597    name: String,
598    args: Value,
599}
600
601#[derive(Default)]
602struct RunState {
603    messages: Vec<Value>,
604    tool_calls: Vec<ToolCall>,
605    files: Vec<RunFile>,
606    usage: Usage,
607    turns: usize,
608    last_model: String,
609    /// The transcript was built (an error after this point keeps a partial).
610    started: bool,
611}
612
613enum TurnEnd {
614    /// No choices: end with empty text, no assistant message.
615    Empty,
616    Final(String),
617    Calls(Vec<Call>),
618}
619
620type Tx = mpsc::UnboundedSender<StreamEvent>;
621
622fn send(tx: Option<&Tx>, ev: StreamEvent) {
623    if let Some(tx) = tx {
624        let _ = tx.send(ev);
625    }
626}
627
628fn ms_since(t: Instant) -> i64 {
629    t.elapsed().as_millis() as i64
630}
631
632fn num(raw: &Value, k: &str) -> i64 {
633    raw.get(k).and_then(Value::as_f64).unwrap_or(0.0) as i64
634}
635
636fn add_usage(acc: &mut Usage, raw: &Value, style: Style) {
637    if !raw.is_object() {
638        return;
639    }
640    if style == Style::Anthropic {
641        let (i, o) = (num(raw, "input_tokens"), num(raw, "output_tokens"));
642        acc.prompt_tokens += i;
643        acc.completion_tokens += o;
644        acc.total_tokens += i + o;
645        acc.cache_read_tokens += num(raw, "cache_read_input_tokens");
646        acc.cache_write_tokens += num(raw, "cache_creation_input_tokens");
647        return;
648    }
649    if let Some(d) = raw.get("prompt_tokens_details") {
650        acc.cache_read_tokens += num(d, "cached_tokens");
651    }
652    let (p, c) = (num(raw, "prompt_tokens"), num(raw, "completion_tokens"));
653    acc.prompt_tokens += p;
654    acc.completion_tokens += c;
655    let total = num(raw, "total_tokens");
656    acc.total_tokens += if total != 0 { total } else { p + c };
657}
658
659fn per_call(raw: &Value, style: Style) -> (i64, i64) {
660    if style == Style::Anthropic {
661        (num(raw, "input_tokens"), num(raw, "output_tokens"))
662    } else {
663        (num(raw, "prompt_tokens"), num(raw, "completion_tokens"))
664    }
665}
666
667fn safe_json_args(s: &str) -> Value {
668    match serde_json::from_str::<Value>(s) {
669        Ok(Value::Object(m)) => Value::Object(m),
670        _ => Value::Object(Map::new()),
671    }
672}
673
674fn obj_or_empty(v: Option<&Value>) -> Value {
675    match v {
676        Some(Value::Object(m)) => Value::Object(m.clone()),
677        _ => Value::Object(Map::new()),
678    }
679}
680
681fn last_text(messages: &[Value]) -> String {
682    for m in messages.iter().rev() {
683        if m.get("role").and_then(Value::as_str) != Some("assistant") {
684            continue;
685        }
686        if let Some(s) = m.get("content").and_then(Value::as_str) {
687            return s.into();
688        }
689    }
690    String::new()
691}
692
693const INTERRUPTED_OUTPUT: &str = "interrupted: the turn ended before this tool returned. Whether its side effects happened is unknown. Do not re-run it automatically; check first.";
694
695/// Pair every unanswered tool call of the last assistant turn with an
696/// `interrupted` result, so a saved transcript stays valid for the provider.
697fn seal_interrupted(mut messages: Vec<Value>) -> Vec<Value> {
698    let Some(last) = messages
699        .iter()
700        .rposition(|m| m.get("role").and_then(Value::as_str) == Some("assistant"))
701    else {
702        return messages;
703    };
704    let asst = messages[last].clone();
705    if let Some(calls) = asst
706        .get("tool_calls")
707        .and_then(Value::as_array)
708        .filter(|c| !c.is_empty())
709    {
710        let have: std::collections::HashSet<String> = messages[last + 1..]
711            .iter()
712            .filter(|m| m.get("role").and_then(Value::as_str) == Some("tool"))
713            .filter_map(|m| {
714                m.get("tool_call_id")
715                    .and_then(Value::as_str)
716                    .map(String::from)
717            })
718            .collect();
719        for tc in calls {
720            let id = tc.get("id").and_then(Value::as_str).unwrap_or("");
721            if id.is_empty() || have.contains(id) {
722                continue;
723            }
724            messages
725                .push(json!({"role": "tool", "tool_call_id": id, "content": INTERRUPTED_OUTPUT}));
726        }
727        return messages;
728    }
729    let ids: Vec<String> = asst
730        .get("content")
731        .and_then(Value::as_array)
732        .map(|bs| {
733            bs.iter()
734                .filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_use"))
735                .filter_map(|b| b.get("id").and_then(Value::as_str))
736                .filter(|s| !s.is_empty())
737                .map(String::from)
738                .collect()
739        })
740        .unwrap_or_default();
741    if ids.is_empty() {
742        return messages;
743    }
744    let mut blocks: Vec<Value> = Vec::new();
745    let mut have = std::collections::HashSet::new();
746    let mut has_user = false;
747    if let Some(um) = messages.get(last + 1)
748        && um.get("role").and_then(Value::as_str) == Some("user")
749        && let Some(bs) = um.get("content").and_then(Value::as_array)
750    {
751        has_user = true;
752        blocks.extend(bs.iter().cloned());
753        for b in bs {
754            if b.get("type").and_then(Value::as_str) == Some("tool_result")
755                && let Some(id) = b.get("tool_use_id").and_then(Value::as_str)
756            {
757                have.insert(id.to_owned());
758            }
759        }
760    }
761    let mut added = false;
762    for id in ids {
763        if !have.contains(&id) {
764            blocks.push(json!({"type": "tool_result", "tool_use_id": id, "content": INTERRUPTED_OUTPUT, "is_error": true}));
765            added = true;
766        }
767    }
768    if !added {
769        return messages;
770    }
771    let user = json!({"role": "user", "content": blocks});
772    if has_user {
773        messages[last + 1] = user;
774    } else {
775        messages.push(user);
776    }
777    messages
778}
779
780/// Split SSE bytes into lines (`\r` trimmed).
781struct LineReader {
782    buf: Vec<u8>,
783}
784
785impl LineReader {
786    fn push(&mut self, chunk: &[u8]) -> Vec<String> {
787        self.buf.extend_from_slice(chunk);
788        let mut out = Vec::new();
789        while let Some(i) = self.buf.iter().position(|b| *b == b'\n') {
790            let line: Vec<u8> = self.buf.drain(..=i).collect();
791            let s = String::from_utf8_lossy(&line[..line.len() - 1]);
792            out.push(s.trim_end_matches('\r').to_owned());
793        }
794        out
795    }
796    fn finish(&mut self) -> Option<String> {
797        if self.buf.is_empty() {
798            return None;
799        }
800        let s = String::from_utf8_lossy(&self.buf)
801            .trim_end_matches('\r')
802            .to_owned();
803        self.buf.clear();
804        Some(s)
805    }
806}
807
808impl Client {
809    pub(crate) fn with_transport(opts: ClientOptions, transport: Transport) -> Client {
810        Self::with_transport_gate(opts, transport, None)
811    }
812
813    /// A client whose provider calls pass through a shared [`TurnGate`]; with
814    /// `in_process` the model is a host function (as `create_in_process_client`).
815    pub(crate) fn gated(
816        opts: ClientOptions,
817        in_process: Option<GenerateFn>,
818        gate: Arc<TurnGate>,
819    ) -> Client {
820        let transport = match in_process {
821            Some(g) => inprocess::transport_of(g),
822            None => Transport::Http(opts.http_client.clone().unwrap_or_default()),
823        };
824        Self::with_transport_gate(opts, transport, Some(gate))
825    }
826
827    fn with_transport_gate(
828        opts: ClientOptions,
829        transport: Transport,
830        gate: Option<Arc<TurnGate>>,
831    ) -> Client {
832        let store = opts
833            .store
834            .clone()
835            .unwrap_or_else(|| Arc::new(InMemoryConversationStore::new()));
836        Client {
837            inner: Arc::new(Inner {
838                opts,
839                transport,
840                gate,
841                store,
842                registry: metrics::Registry::default(),
843                plans: Mutex::new(HashMap::new()),
844            }),
845        }
846    }
847
848    fn opts(&self) -> &ClientOptions {
849        &self.inner.opts
850    }
851
852    /// Prometheus text for everything this client did (byte-identical across ports).
853    pub fn metrics(&self) -> String {
854        self.inner.registry.render()
855    }
856
857    /// Feed one event to the registry and `on_metric` (used by tests and hosts that
858    /// aggregate runs from elsewhere).
859    pub fn record_metric(&self, ev: MetricEvent) {
860        self.inner.registry.record(&ev);
861        if let Some(f) = &self.opts().on_metric {
862            f(&ev);
863        }
864    }
865
866    /// The conversation store `ask` uses.
867    pub fn conversation_store(&self) -> Arc<dyn ConversationStore> {
868        self.inner.store.clone()
869    }
870
871    fn emit_llm(&self, model: &str, status: &str, start: Instant, p: i64, c: i64) {
872        self.record_metric(MetricEvent {
873            event: "llm".into(),
874            model: model.into(),
875            status: status.into(),
876            ms: ms_since(start),
877            prompt_tokens: p,
878            completion_tokens: c,
879            ..Default::default()
880        });
881    }
882
883    fn emit_run(&self, st: &RunState, start: Instant, error: String) {
884        self.record_metric(MetricEvent {
885            event: "run".into(),
886            model: st.last_model.clone(),
887            turns: st.turns as i64,
888            tool_calls: st.tool_calls.len() as i64,
889            total_tokens: st.usage.total_tokens,
890            ms: ms_since(start),
891            error,
892            ..Default::default()
893        });
894    }
895
896    fn emit_tool(&self, name: &str, source: &str, r: &ToolResult, start: Instant) {
897        let pending = pending_of(r).is_some();
898        self.record_metric(MetricEvent {
899            event: "tool".into(),
900            tool: name.into(),
901            source: source.into(),
902            is_error: !pending && r.is_error,
903            pending,
904            ms: ms_since(start),
905            ..Default::default()
906        });
907    }
908
909    fn max_turns(&self) -> usize {
910        if self.opts().max_turns > 0 {
911            self.opts().max_turns
912        } else {
913            10
914        }
915    }
916
917    fn retries(&self) -> u32 {
918        match self.opts().retries {
919            -1 => 0,
920            n if n > 0 => n as u32,
921            _ => 2,
922        }
923    }
924
925    fn retry_base_ms(&self) -> u64 {
926        if self.opts().retry_base_ms > 0 {
927            self.opts().retry_base_ms
928        } else {
929            500
930        }
931    }
932
933    /// The wait before retry `attempt`: an honoured `Retry-After`, else
934    /// exponential backoff with up to 100 ms of jitter.
935    pub fn backoff(&self, attempt: u32, retry_after: &str) -> Duration {
936        if let Some(d) = retry_after_seconds(retry_after) {
937            return d;
938        }
939        let jitter = (std::time::SystemTime::now()
940            .duration_since(std::time::UNIX_EPOCH)
941            .map(|d| d.subsec_nanos())
942            .unwrap_or(0)
943            % 100) as u64;
944        Duration::from_millis(self.retry_base_ms() * (1u64 << attempt.min(30)) + jitter)
945    }
946
947    /// The API key for this client's host (SPEC §8, 0.22.0): explicit `api_key`,
948    /// else `api_key_env`, else the provider env var matching an https host. A key
949    /// is never sent to a host it does not belong to.
950    pub fn resolve_key(&self) -> Result<String, ClientError> {
951        let o = self.opts();
952        if !o.api_key.is_empty() {
953            return Ok(o.api_key.clone());
954        }
955        let env_name = if o.api_key_env.is_empty() {
956            key_env_for_base_url(&o.base_url).to_owned()
957        } else {
958            o.api_key_env.clone()
959        };
960        if env_name.is_empty() {
961            return Err(ClientError::NoApiKey(format!(
962                "no API key: set APIKey or APIKeyEnv (provider env vars are only used for https://api.openai.com, https://api.anthropic.com and https://openrouter.ai, never for {:?})",
963                o.base_url
964            )));
965        }
966        match std::env::var(&env_name) {
967            Ok(k) if !k.is_empty() => Ok(k),
968            _ => Err(ClientError::NoApiKey(format!(
969                "no API key: set APIKey or {env_name}"
970            ))),
971        }
972    }
973
974    fn system(&self, tk: &Toolkit) -> String {
975        let mut parts = Vec::new();
976        if !self.opts().system_prompt.is_empty() {
977            parts.push(self.opts().system_prompt.clone());
978        }
979        let sp = tk.skills_prompt();
980        if !sp.is_empty() {
981            parts.push(sp);
982        }
983        parts.join("\n\n")
984    }
985
986    // -- tool tiers ---------------------------------------------------------
987
988    fn plan(&self, tk: &Toolkit) -> Option<Arc<tiers::Plan>> {
989        let o = self.opts().tool_tiers.as_ref()?;
990        let mut plans = self.inner.plans.lock().unwrap_or_else(|e| e.into_inner());
991        Some(
992            plans
993                .entry(tk.identity())
994                .or_insert_with(|| Arc::new(tiers::build_plan(o, &tk.tools())))
995                .clone(),
996        )
997    }
998
999    /// The frozen tier of every tool in `tk` (`None` without `tool_tiers`).
1000    pub fn tool_tier_plan(&self, tk: &Toolkit) -> Option<HashMap<String, ToolTier>> {
1001        self.plan(tk)
1002            .map(|p| p.tier.iter().map(|(k, v)| (k.clone(), *v)).collect())
1003    }
1004
1005    /// Drop the frozen plan; the next request rebuilds it.
1006    pub fn reset_tool_tiers(&self, tk: &Toolkit) {
1007        self.inner
1008            .plans
1009            .lock()
1010            .unwrap_or_else(|e| e.into_inner())
1011            .remove(&tk.identity());
1012    }
1013
1014    /// The tools sent on a request: everything, or (with tiers) the core schemas
1015    /// plus `tool_search` and `call_tool`.
1016    pub fn request_tools(&self, tk: &Toolkit) -> Vec<crate::tool::Tool> {
1017        let Some(p) = self.plan(tk) else {
1018            return tk.tools();
1019        };
1020        let mut out: Vec<_> = p.core.iter().filter_map(|n| tk.get(n)).collect();
1021        out.push(p.search.clone());
1022        out.push(p.call.clone());
1023        out
1024    }
1025
1026    /// Run `tool_search` against a toolkit (what the loop does for the model).
1027    pub fn tool_search(&self, tk: &Toolkit, args: &Value) -> ToolResult {
1028        tiers::tool_search(tk, args)
1029    }
1030
1031    /// One call's safety; looks through `call_tool` to the tool it names. Unknown ⇒ unsafe.
1032    fn call_safe(&self, tk: &Toolkit, call: &Call) -> bool {
1033        if self.opts().tool_tiers.is_some() && call.name == "tool_search" {
1034            return true;
1035        }
1036        let (name, args, _) = self.unwrap_call(&call.name, &call.args);
1037        tk.get(&name)
1038            .map(|t| crate::tool::is_concurrency_safe(&t, &args))
1039            .unwrap_or(false)
1040    }
1041
1042    fn unwrap_call(&self, name: &str, args: &Value) -> (String, Value, bool) {
1043        if self.opts().tool_tiers.is_none() || name != "call_tool" {
1044            return (name.into(), args.clone(), false);
1045        }
1046        let real = args
1047            .get("name")
1048            .and_then(Value::as_str)
1049            .unwrap_or("")
1050            .to_owned();
1051        (real, obj_or_empty(args.get("arguments")), true)
1052    }
1053
1054    // -- HTTP -----------------------------------------------------------------
1055
1056    fn finalize_body(&self, mut body: Map<String, Value>) -> Value {
1057        let o = self.opts();
1058        if let Some(pc) = &o.prompt_cache {
1059            cache::apply(&mut body, o.style, pc);
1060        }
1061        for (k, v) in &o.request_params {
1062            if k == "messages" || k == "tools" || k == "stream" {
1063                eprintln!(
1064                    "[toolnexus] requestParams key {k:?} is not allowed — ignored (use BodyTransform)"
1065                );
1066                continue;
1067            }
1068            body.insert(k.clone(), v.clone());
1069        }
1070        let body = Value::Object(body);
1071        if let Some(t) = &o.body_transform
1072            && let Some(out) = t(body.clone())
1073        {
1074            return out;
1075        }
1076        body
1077    }
1078
1079    async fn send_once(
1080        &self,
1081        endpoint: &str,
1082        headers: &[(String, String)],
1083        raw: &[u8],
1084        cancel: &CancellationToken,
1085        side: Option<&Tx>,
1086    ) -> Result<RawResponse, String> {
1087        let _slot = match &self.inner.gate {
1088            Some(g) => Some(g.acquire().await),
1089            None => None,
1090        };
1091        match &self.inner.transport {
1092            Transport::InProcess(ip) => ip.respond(raw, cancel, side).await,
1093            Transport::Http(http) => {
1094                let mut req = http
1095                    .post(endpoint)
1096                    .header("Content-Type", "application/json");
1097                for (k, v) in headers {
1098                    req = req.header(k, v);
1099                }
1100                for (k, v) in &self.opts().headers {
1101                    req = req.header(k, v);
1102                }
1103                let resp = req
1104                    .body(raw.to_vec())
1105                    .send()
1106                    .await
1107                    .map_err(|e| e.to_string())?;
1108                let retry_after = resp
1109                    .headers()
1110                    .get("retry-after")
1111                    .and_then(|v| v.to_str().ok())
1112                    .unwrap_or("")
1113                    .to_owned();
1114                Ok(RawResponse {
1115                    status: resp.status().as_u16(),
1116                    retry_after,
1117                    body: RespBody::Http(resp),
1118                })
1119            }
1120        }
1121    }
1122
1123    async fn sleep(&self, d: Duration, cancel: &CancellationToken) -> Result<(), ClientError> {
1124        tokio::select! {
1125            _ = cancel.cancelled() => Err(ClientError::Cancelled),
1126            _ = tokio::time::sleep(d) => Ok(()),
1127        }
1128    }
1129
1130    /// POST with retries (§8 Resilience). A final non-2xx is returned for the
1131    /// caller to surface as a [`ProviderError`].
1132    async fn llm_fetch(
1133        &self,
1134        endpoint: &str,
1135        headers: &[(String, String)],
1136        raw: &[u8],
1137        cancel: &CancellationToken,
1138        side: Option<&Tx>,
1139    ) -> Result<RawResponse, ClientError> {
1140        let retries = self.retries();
1141        let classify = |info: &ErrorInfo| match &self.opts().on_error {
1142            Some(f) => f(info),
1143            None => {
1144                if info.retryable {
1145                    ErrorTier::Retry
1146                } else {
1147                    ErrorTier::Fail
1148                }
1149            }
1150        };
1151        let mut attempt = 0;
1152        loop {
1153            let r = tokio::select! {
1154                _ = cancel.cancelled() => return Err(ClientError::Cancelled),
1155                r = self.send_once(endpoint, headers, raw, cancel, side) => r,
1156            };
1157            match r {
1158                Err(e) => {
1159                    if cancel.is_cancelled() {
1160                        return Err(ClientError::Cancelled);
1161                    }
1162                    let tier = classify(&ErrorInfo {
1163                        error: e.clone(),
1164                        status: 0,
1165                        attempt,
1166                        retryable: true,
1167                    });
1168                    if tier == ErrorTier::Fail || attempt == retries {
1169                        return Err(ClientError::Transport(e));
1170                    }
1171                    self.sleep(self.backoff(attempt, ""), cancel).await?;
1172                }
1173                Ok(resp) => {
1174                    if resp.status < 300 {
1175                        return Ok(resp);
1176                    }
1177                    let retryable =
1178                        is_retryable_status(resp.status, &self.opts().retryable_statuses);
1179                    let tier = classify(&ErrorInfo {
1180                        error: format!("LLM {}", resp.status),
1181                        status: resp.status,
1182                        attempt,
1183                        retryable,
1184                    });
1185                    if tier == ErrorTier::Fail || attempt == retries {
1186                        return Ok(resp);
1187                    }
1188                    let ra = resp.retry_after.clone();
1189                    drop(resp);
1190                    self.sleep(self.backoff(attempt, &ra), cancel).await?;
1191                }
1192            }
1193            attempt += 1;
1194        }
1195    }
1196
1197    async fn provider_error(resp: RawResponse) -> ClientError {
1198        let (status, ra) = (resp.status, resp.retry_after.clone());
1199        let body = resp.read_all().await.unwrap_or_default();
1200        ClientError::Provider(ProviderError {
1201            status,
1202            body: redact_provider_body(status, &String::from_utf8_lossy(&body)),
1203            retry_after: ra,
1204        })
1205    }
1206
1207    fn endpoint(&self) -> String {
1208        let base = self.opts().base_url.trim_end_matches('/');
1209        match self.opts().style {
1210            Style::OpenAI => format!("{base}/chat/completions"),
1211            Style::Anthropic => {
1212                if base.ends_with("/v1") {
1213                    format!("{base}/messages")
1214                } else {
1215                    format!("{base}/v1/messages")
1216                }
1217            }
1218        }
1219    }
1220
1221    fn auth_headers(&self, key: &str) -> Vec<(String, String)> {
1222        match self.opts().style {
1223            Style::OpenAI => vec![("Authorization".into(), format!("Bearer {key}"))],
1224            Style::Anthropic => vec![
1225                ("x-api-key".into(), key.into()),
1226                ("anthropic-version".into(), "2023-06-01".into()),
1227            ],
1228        }
1229    }
1230
1231    fn wire(&self, messages: &[Value]) -> Result<Vec<Value>, ClientError> {
1232        if !emit::any_parts(messages) {
1233            return Ok(messages.to_vec());
1234        }
1235        let mut e = emit::Emitter {
1236            style: self.opts().style,
1237            max_bytes: self.opts().max_part_bytes,
1238            mode: self.opts().on_unsupported_part.clone(),
1239            warned: false,
1240        };
1241        e.emit(messages).map_err(ClientError::Part)
1242    }
1243
1244    // -- one model call ---------------------------------------------------------
1245
1246    /// Call the model once; returns the assistant message to append (if any) and
1247    /// how the turn ends.
1248    #[allow(clippy::too_many_arguments)]
1249    async fn call_model(
1250        &self,
1251        st: &mut RunState,
1252        key: &str,
1253        model: &str,
1254        system: &str,
1255        tools: &[Value],
1256        turn: usize,
1257        cancel: &CancellationToken,
1258        tx: Option<&Tx>,
1259    ) -> Result<(Option<Value>, TurnEnd), ClientError> {
1260        let style = self.opts().style;
1261        let streaming = tx.is_some();
1262        let wire = self.wire(&st.messages)?;
1263        let mut body = Map::new();
1264        body.insert("model".into(), json!(model));
1265        if style == Style::Anthropic {
1266            body.insert("max_tokens".into(), json!(4096));
1267        }
1268        body.insert("messages".into(), Value::Array(wire));
1269        if streaming {
1270            body.insert("stream".into(), json!(true));
1271            if style == Style::OpenAI {
1272                body.insert("stream_options".into(), json!({"include_usage": true}));
1273            }
1274        }
1275        if style == Style::Anthropic && !system.is_empty() {
1276            body.insert("system".into(), json!(system));
1277        }
1278        if !tools.is_empty() {
1279            body.insert("tools".into(), Value::Array(tools.to_vec()));
1280            if style == Style::OpenAI {
1281                body.insert("tool_choice".into(), json!("auto"));
1282            }
1283        }
1284        let body = self.finalize_body(body);
1285        let raw = serde_json::to_vec(&body).map_err(|e| ClientError::Decode(e.to_string()))?;
1286        let t0 = Instant::now();
1287        st.last_model = model.into();
1288        let before = st.usage;
1289        let resp = match self
1290            .llm_fetch(&self.endpoint(), &self.auth_headers(key), &raw, cancel, tx)
1291            .await
1292        {
1293            Ok(r) => r,
1294            Err(e) => {
1295                self.emit_llm(model, "error", t0, 0, 0);
1296                return Err(e);
1297            }
1298        };
1299        if !(200..300).contains(&resp.status) {
1300            self.emit_llm(model, "error", t0, 0, 0);
1301            return Err(Self::provider_error(resp).await);
1302        }
1303        let parsed = if streaming {
1304            self.read_stream(st, resp, cancel, tx).await
1305        } else {
1306            self.read_full(st, resp).await
1307        };
1308        let (assistant, end, response) = match parsed {
1309            Ok(x) => x,
1310            Err(e) => {
1311                self.emit_llm(model, "error", t0, 0, 0);
1312                return Err(e);
1313            }
1314        };
1315        if streaming {
1316            self.emit_llm(
1317                model,
1318                "ok",
1319                t0,
1320                st.usage.prompt_tokens - before.prompt_tokens,
1321                st.usage.completion_tokens - before.completion_tokens,
1322            );
1323        }
1324        if let Some(h) = &self.opts().hooks.after_llm {
1325            h(AfterLlmEvent {
1326                response,
1327                model: model.into(),
1328                turn,
1329            })
1330            .await
1331            .map_err(ClientError::Hook)?;
1332        }
1333        Ok((assistant, end))
1334    }
1335
1336    async fn read_full(
1337        &self,
1338        st: &mut RunState,
1339        resp: RawResponse,
1340    ) -> Result<(Option<Value>, TurnEnd, Value), ClientError> {
1341        let style = self.opts().style;
1342        let model = st.last_model.clone();
1343        let t0 = Instant::now();
1344        let raw = resp.read_all().await?;
1345        let data: Value =
1346            serde_json::from_slice(&raw).map_err(|e| ClientError::Decode(e.to_string()))?;
1347        if !data.is_object() {
1348            return Err(ClientError::Decode("response is not an object".into()));
1349        }
1350        let usage = data.get("usage").cloned().unwrap_or(Value::Null);
1351        let (p, c) = per_call(&usage, style);
1352        self.emit_llm(&model, "ok", t0, p, c);
1353        add_usage(&mut st.usage, &usage, style);
1354        if style == Style::OpenAI {
1355            let Some(msg) = data
1356                .get("choices")
1357                .and_then(Value::as_array)
1358                .and_then(|c| c.first())
1359                .map(|c| c.get("message").cloned().unwrap_or(Value::Null))
1360            else {
1361                return Ok((None, TurnEnd::Empty, data));
1362            };
1363            let content = msg
1364                .get("content")
1365                .and_then(Value::as_str)
1366                .unwrap_or("")
1367                .to_owned();
1368            let mut asst = json!({"role": "assistant", "content": content});
1369            let calls: Vec<Call> = msg
1370                .get("tool_calls")
1371                .and_then(Value::as_array)
1372                .map(|tcs| {
1373                    tcs.iter()
1374                        .map(|tc| Call {
1375                            id: tc.get("id").and_then(Value::as_str).unwrap_or("").into(),
1376                            name: tc
1377                                .pointer("/function/name")
1378                                .and_then(Value::as_str)
1379                                .unwrap_or("")
1380                                .into(),
1381                            args: safe_json_args(
1382                                tc.pointer("/function/arguments")
1383                                    .and_then(Value::as_str)
1384                                    .unwrap_or(""),
1385                            ),
1386                        })
1387                        .collect()
1388                })
1389                .unwrap_or_default();
1390            if !calls.is_empty() {
1391                let raw_calls: Vec<Value> = msg["tool_calls"]
1392                    .as_array()
1393                    .into_iter()
1394                    .flatten()
1395                    .map(|tc| {
1396                        json!({
1397                            "id": tc.get("id").and_then(Value::as_str).unwrap_or(""),
1398                            "type": "function",
1399                            "function": {
1400                                "name": tc.pointer("/function/name").and_then(Value::as_str).unwrap_or(""),
1401                                "arguments": tc.pointer("/function/arguments").and_then(Value::as_str).unwrap_or(""),
1402                            }
1403                        })
1404                    })
1405                    .collect();
1406                asst["tool_calls"] = Value::Array(raw_calls);
1407                return Ok((Some(asst), TurnEnd::Calls(calls), data));
1408            }
1409            return Ok((Some(asst), TurnEnd::Final(content), data));
1410        }
1411        let mut content = Vec::new();
1412        let mut text = String::new();
1413        let mut calls = Vec::new();
1414        for b in data
1415            .get("content")
1416            .and_then(Value::as_array)
1417            .into_iter()
1418            .flatten()
1419        {
1420            match b.get("type").and_then(Value::as_str) {
1421                Some("text") => {
1422                    let t = b.get("text").and_then(Value::as_str).unwrap_or("");
1423                    content.push(json!({"type": "text", "text": t}));
1424                    text.push_str(t);
1425                }
1426                Some("tool_use") => {
1427                    let id = b.get("id").and_then(Value::as_str).unwrap_or("");
1428                    let name = b.get("name").and_then(Value::as_str).unwrap_or("");
1429                    let input = b.get("input").cloned().unwrap_or(Value::Null);
1430                    content
1431                        .push(json!({"type": "tool_use", "id": id, "name": name, "input": input}));
1432                    calls.push(Call {
1433                        id: id.into(),
1434                        name: name.into(),
1435                        args: obj_or_empty(Some(&input)),
1436                    });
1437                }
1438                _ => {}
1439            }
1440        }
1441        let asst = json!({"role": "assistant", "content": content});
1442        if calls.is_empty() {
1443            return Ok((Some(asst), TurnEnd::Final(text), data));
1444        }
1445        Ok((Some(asst), TurnEnd::Calls(calls), data))
1446    }
1447
1448    async fn read_stream(
1449        &self,
1450        st: &mut RunState,
1451        resp: RawResponse,
1452        cancel: &CancellationToken,
1453        tx: Option<&Tx>,
1454    ) -> Result<(Option<Value>, TurnEnd, Value), ClientError> {
1455        let style = self.opts().style;
1456        let mut reader = LineReader { buf: Vec::new() };
1457        // openai
1458        let mut content = String::new();
1459        let mut acc: Vec<(i64, Call, String)> = Vec::new(); // (index, call, raw args)
1460        // anthropic
1461        #[derive(Default)]
1462        struct Block {
1463            typ: String,
1464            text: String,
1465            id: String,
1466            name: String,
1467            json: String,
1468        }
1469        let mut blocks: Vec<(i64, Block)> = Vec::new();
1470        let mut stop_reason = String::new();
1471        let mut done = false;
1472
1473        let mut handle = |line: &str, st: &mut RunState| -> bool {
1474            let Some(payload) = line.strip_prefix("data:") else {
1475                return true;
1476            };
1477            let payload = payload.trim();
1478            if style == Style::OpenAI && payload == "[DONE]" {
1479                return false;
1480            }
1481            let Ok(j) = serde_json::from_str::<Value>(payload) else {
1482                return true;
1483            };
1484            if !j.is_object() {
1485                return true;
1486            }
1487            if style == Style::OpenAI {
1488                if let Some(u) = j.get("usage").filter(|u| u.is_object()) {
1489                    add_usage(&mut st.usage, u, style);
1490                }
1491                let Some(d) = j
1492                    .get("choices")
1493                    .and_then(Value::as_array)
1494                    .and_then(|c| c.first())
1495                    .and_then(|c| c.get("delta"))
1496                    .filter(|d| d.is_object())
1497                else {
1498                    return true;
1499                };
1500                if let Some(t) = d.get("content").and_then(Value::as_str)
1501                    && !t.is_empty()
1502                {
1503                    content.push_str(t);
1504                    send(tx, StreamEvent::text(t));
1505                }
1506                for tc in d
1507                    .get("tool_calls")
1508                    .and_then(Value::as_array)
1509                    .into_iter()
1510                    .flatten()
1511                {
1512                    if !tc.is_object() {
1513                        continue;
1514                    }
1515                    let idx = tc.get("index").and_then(Value::as_f64).unwrap_or(0.0) as i64;
1516                    let pos = match acc.iter().position(|(i, _, _)| *i == idx) {
1517                        Some(p) => p,
1518                        None => {
1519                            acc.push((
1520                                idx,
1521                                Call {
1522                                    id: String::new(),
1523                                    name: String::new(),
1524                                    args: Value::Null,
1525                                },
1526                                String::new(),
1527                            ));
1528                            acc.len() - 1
1529                        }
1530                    };
1531                    let slot = &mut acc[pos];
1532                    if let Some(id) = tc.get("id").and_then(Value::as_str)
1533                        && !id.is_empty()
1534                    {
1535                        slot.1.id = id.into();
1536                    }
1537                    if let Some(n) = tc.pointer("/function/name").and_then(Value::as_str) {
1538                        slot.1.name.push_str(n);
1539                    }
1540                    if let Some(a) = tc.pointer("/function/arguments").and_then(Value::as_str) {
1541                        slot.2.push_str(a);
1542                    }
1543                }
1544                return true;
1545            }
1546            match j.get("type").and_then(Value::as_str) {
1547                Some("message_start") => {
1548                    if let Some(u) = j.pointer("/message/usage").filter(|u| u.is_object()) {
1549                        add_usage(&mut st.usage, u, style);
1550                    }
1551                }
1552                Some("content_block_start") => {
1553                    let idx = j.get("index").and_then(Value::as_f64).unwrap_or(0.0) as i64;
1554                    let cb = j.get("content_block");
1555                    let s = |k: &str| {
1556                        cb.and_then(|c| c.get(k))
1557                            .and_then(Value::as_str)
1558                            .unwrap_or("")
1559                            .to_owned()
1560                    };
1561                    let b = Block {
1562                        typ: s("type"),
1563                        id: s("id"),
1564                        name: s("name"),
1565                        ..Default::default()
1566                    };
1567                    blocks.retain(|(i, _)| *i != idx);
1568                    blocks.push((idx, b));
1569                }
1570                Some("content_block_delta") => {
1571                    let idx = j.get("index").and_then(Value::as_f64).unwrap_or(0.0) as i64;
1572                    let Some((_, b)) = blocks.iter_mut().rev().find(|(i, _)| *i == idx) else {
1573                        return true;
1574                    };
1575                    match j.pointer("/delta/type").and_then(Value::as_str) {
1576                        Some("text_delta") => {
1577                            if let Some(t) = j.pointer("/delta/text").and_then(Value::as_str) {
1578                                b.text.push_str(t);
1579                                send(tx, StreamEvent::text(t));
1580                            }
1581                        }
1582                        Some("input_json_delta") => {
1583                            if let Some(p) =
1584                                j.pointer("/delta/partial_json").and_then(Value::as_str)
1585                            {
1586                                b.json.push_str(p);
1587                            }
1588                        }
1589                        _ => {}
1590                    }
1591                }
1592                Some("message_delta") => {
1593                    if let Some(sr) = j.pointer("/delta/stop_reason").and_then(Value::as_str)
1594                        && !sr.is_empty()
1595                    {
1596                        stop_reason = sr.into();
1597                    }
1598                    if let Some(u) = j.get("usage").filter(|u| u.is_object()) {
1599                        add_usage(&mut st.usage, u, style);
1600                    }
1601                }
1602                _ => {}
1603            }
1604            true
1605        };
1606
1607        match resp.body {
1608            RespBody::Full(b) => {
1609                for line in reader.push(&b).into_iter().chain(reader.finish()) {
1610                    if !handle(&line, st) {
1611                        break;
1612                    }
1613                }
1614            }
1615            RespBody::Http(mut r) => loop {
1616                let chunk = tokio::select! {
1617                    _ = cancel.cancelled() => return Err(ClientError::Cancelled),
1618                    c = r.chunk() => c.map_err(|e| ClientError::Transport(e.to_string()))?,
1619                };
1620                let lines = match chunk {
1621                    Some(c) => reader.push(&c),
1622                    None => {
1623                        done = true;
1624                        reader.finish().into_iter().collect()
1625                    }
1626                };
1627                let mut stop = false;
1628                for line in lines {
1629                    if !handle(&line, st) {
1630                        stop = true;
1631                        break;
1632                    }
1633                }
1634                if stop || done {
1635                    break;
1636                }
1637            },
1638            RespBody::Chunks(mut rx) => loop {
1639                let chunk = tokio::select! {
1640                    _ = cancel.cancelled() => return Err(ClientError::Cancelled),
1641                    c = rx.recv() => c,
1642                };
1643                let lines = match chunk {
1644                    Some(Ok(c)) => reader.push(&c),
1645                    Some(Err(e)) => return Err(ClientError::Transport(e)),
1646                    None => {
1647                        done = true;
1648                        reader.finish().into_iter().collect()
1649                    }
1650                };
1651                let mut stop = false;
1652                for line in lines {
1653                    if !handle(&line, st) {
1654                        stop = true;
1655                        break;
1656                    }
1657                }
1658                if stop || done {
1659                    break;
1660                }
1661            },
1662        }
1663        if cancel.is_cancelled() {
1664            return Err(ClientError::Cancelled);
1665        }
1666        let streamed = json!({"streamed": true});
1667        if style == Style::OpenAI {
1668            if acc.is_empty() {
1669                let asst = json!({"role": "assistant", "content": content});
1670                return Ok((Some(asst), TurnEnd::Final(content), streamed));
1671            }
1672            let raw_calls: Vec<Value> = acc
1673                .iter()
1674                .map(|(_, c, a)| json!({"id": c.id, "type": "function", "function": {"name": c.name, "arguments": a}}))
1675                .collect();
1676            let asst = json!({
1677                "role": "assistant",
1678                "tool_calls": raw_calls,
1679                "content": if content.is_empty() { Value::Null } else { json!(content) },
1680            });
1681            let calls = acc
1682                .into_iter()
1683                .map(|(_, mut c, a)| {
1684                    c.args = safe_json_args(&a);
1685                    c
1686                })
1687                .collect();
1688            return Ok((Some(asst), TurnEnd::Calls(calls), streamed));
1689        }
1690        let mut content_blocks = Vec::new();
1691        let mut text = String::new();
1692        let mut calls = Vec::new();
1693        for (_, b) in blocks {
1694            if b.typ == "tool_use" {
1695                let input = safe_json_args(&b.json);
1696                content_blocks
1697                    .push(json!({"type": "tool_use", "id": b.id, "name": b.name, "input": input}));
1698                calls.push(Call {
1699                    id: b.id,
1700                    name: b.name,
1701                    args: input,
1702                });
1703            } else {
1704                content_blocks.push(json!({"type": "text", "text": b.text}));
1705                text.push_str(&b.text);
1706            }
1707        }
1708        let asst = json!({"role": "assistant", "content": content_blocks});
1709        if stop_reason != "tool_use" || calls.is_empty() {
1710            return Ok((Some(asst), TurnEnd::Final(text), streamed));
1711        }
1712        Ok((Some(asst), TurnEnd::Calls(calls), streamed))
1713    }
1714
1715    // -- tools ------------------------------------------------------------------
1716
1717    async fn run_tool(
1718        &self,
1719        tk: &Toolkit,
1720        call: &Call,
1721        turn: usize,
1722        cancel: &CancellationToken,
1723    ) -> Result<(Value, ToolResult), ClientError> {
1724        if self.opts().tool_tiers.is_some() && call.name == "tool_search" {
1725            let start = Instant::now();
1726            let r = tiers::tool_search(tk, &call.args);
1727            self.emit_tool(&call.name, "builtin", &r, start);
1728            return Ok((call.args.clone(), r));
1729        }
1730        let (real, inner, wrapped) = self.unwrap_call(&call.name, &call.args);
1731        let (a, r) = self
1732            .run_tool_direct(tk, &real, inner, &call.id, turn, cancel)
1733            .await?;
1734        if wrapped {
1735            return Ok((json!({"name": real, "arguments": a}), r));
1736        }
1737        Ok((a, r))
1738    }
1739
1740    fn source_of(tk: &Toolkit, name: &str) -> (String, Option<ToolHints>) {
1741        match tk.get(name) {
1742            Some(t) => (
1743                serde_json::to_value(t.source)
1744                    .ok()
1745                    .and_then(|v| v.as_str().map(String::from))
1746                    .unwrap_or_else(|| "custom".into()),
1747                t.hints,
1748            ),
1749            None => ("custom".into(), None),
1750        }
1751    }
1752
1753    async fn run_tool_direct(
1754        &self,
1755        tk: &Toolkit,
1756        name: &str,
1757        args: Value,
1758        id: &str,
1759        turn: usize,
1760        cancel: &CancellationToken,
1761    ) -> Result<(Value, ToolResult), ClientError> {
1762        let hooks = &self.opts().hooks;
1763        let (source, hints) = Self::source_of(tk, name);
1764        let start = Instant::now();
1765        let mut a = args;
1766        if let Some(h) = &hooks.before_tool {
1767            let ov = h(BeforeToolEvent {
1768                name: name.into(),
1769                args: a.clone(),
1770                id: id.into(),
1771                turn,
1772                hints,
1773            })
1774            .await
1775            .map_err(ClientError::Hook)?;
1776            if let Some(ov) = ov {
1777                if let Some(r) = ov.result {
1778                    self.emit_tool(name, &source, &r, start);
1779                    return Ok((a, r));
1780                }
1781                if let Some(na) = ov.args {
1782                    a = na;
1783                }
1784            }
1785        }
1786        let ctx = ToolContext {
1787            cancel: cancel.clone(),
1788            call_id: Some(id.into()),
1789            ..Default::default()
1790        };
1791        let mut result = tk.execute(name, a.clone(), ctx).await;
1792        if let Some(h) = &hooks.after_tool
1793            && pending_of(&result).is_none()
1794        {
1795            let ov = h(AfterToolEvent {
1796                name: name.into(),
1797                args: a.clone(),
1798                result: result.clone(),
1799                id: id.into(),
1800                turn,
1801            })
1802            .await
1803            .map_err(ClientError::Hook)?;
1804            if let Some(r) = ov.and_then(|o| o.result) {
1805                result = r;
1806            }
1807        }
1808        self.emit_tool(name, &source, &result, start);
1809        Ok((a, result))
1810    }
1811
1812    /// §10: resolve a suspension through `wait_for`, re-execute once with the
1813    /// answer, or (no `wait_for`) halt the run.
1814    #[allow(clippy::too_many_arguments)]
1815    async fn resolve_pending(
1816        &self,
1817        tk: &Toolkit,
1818        name: &str,
1819        args: &Value,
1820        request: Request,
1821        id: &str,
1822        turn: usize,
1823        cancel: &CancellationToken,
1824    ) -> Result<(ToolResult, Option<Request>), ClientError> {
1825        let (name, args, _) = self.unwrap_call(name, args);
1826        let o = self.opts();
1827        let answer = if let Some(w) = &o.wait_for_ctx {
1828            w(cancel.clone(), request.clone()).await
1829        } else if let Some(w) = &o.wait_for {
1830            w(request.clone()).await
1831        } else {
1832            return Ok((ToolResult::error(request.prompt.clone()), Some(request)));
1833        };
1834        let answer = answer.map_err(ClientError::WaitFor)?;
1835        if !answer.ok {
1836            return Ok((
1837                ToolResult::error(format!("declined/expired: {}", request.prompt)),
1838                None,
1839            ));
1840        }
1841        let start = Instant::now();
1842        let ctx = ToolContext {
1843            cancel: cancel.clone(),
1844            call_id: Some(id.into()),
1845            answer: Some(answer),
1846            ..Default::default()
1847        };
1848        let mut result = tk.execute(&name, args.clone(), ctx).await;
1849        if let Some(h) = &o.hooks.after_tool {
1850            let ov = h(AfterToolEvent {
1851                name: name.clone(),
1852                args: args.clone(),
1853                result: result.clone(),
1854                id: id.into(),
1855                turn,
1856            })
1857            .await
1858            .map_err(ClientError::Hook)?;
1859            if let Some(r) = ov.and_then(|o| o.result) {
1860                result = r;
1861            }
1862        }
1863        let (source, _) = Self::source_of(tk, &name);
1864        self.emit_tool(&name, &source, &result, start);
1865        if pending_of(&result).is_some() {
1866            result = ToolResult::error(format!("unresolved: {}", request.prompt));
1867        }
1868        Ok((result, None))
1869    }
1870
1871    fn call_event(&self, c: &Call) -> StreamEvent {
1872        let (real, inner, wrapped) = self.unwrap_call(&c.name, &c.args);
1873        StreamEvent {
1874            kind: "tool_call".into(),
1875            id: c.id.clone(),
1876            name: real,
1877            args: inner,
1878            via: if wrapped {
1879                "call_tool".into()
1880            } else {
1881                String::new()
1882            },
1883            ..Default::default()
1884        }
1885    }
1886
1887    fn result_event(&self, c: &Call, r: &ToolResult) -> StreamEvent {
1888        let (real, _, wrapped) = self.unwrap_call(&c.name, &c.args);
1889        StreamEvent {
1890            kind: "tool_result".into(),
1891            id: c.id.clone(),
1892            name: real,
1893            output: r.output.clone(),
1894            is_error: r.is_error,
1895            metadata: r.metadata.clone(),
1896            via: if wrapped {
1897                "call_tool".into()
1898            } else {
1899                String::new()
1900            },
1901            ..Default::default()
1902        }
1903    }
1904
1905    // -- the loop ----------------------------------------------------------------
1906
1907    fn result(&self, st: &RunState, text: String) -> RunResult {
1908        RunResult {
1909            text,
1910            messages: st.messages.clone(),
1911            tool_calls: st.tool_calls.clone(),
1912            files: st.files.clone(),
1913            tool_call_count: st.tool_calls.len(),
1914            turns: st.turns,
1915            usage: st.usage,
1916            model: st.last_model.clone(),
1917            status: "done".into(),
1918            ..Default::default()
1919        }
1920    }
1921
1922    async fn run_loop(
1923        &self,
1924        st: &mut RunState,
1925        prompt: &Prompt,
1926        tk: &Toolkit,
1927        history: Vec<Value>,
1928        cancel: &CancellationToken,
1929        tx: Option<&Tx>,
1930    ) -> Result<RunResult, ClientError> {
1931        let key = self.resolve_key()?;
1932        let style = self.opts().style;
1933        let system = self.system(tk);
1934        if !history.is_empty() {
1935            st.messages = history;
1936        } else if style == Style::OpenAI && !system.is_empty() {
1937            st.messages
1938                .push(json!({"role": "system", "content": system}));
1939        }
1940        // A resume continues from the transcript, with no new user turn.
1941        if !matches!(prompt, Prompt::Text(t) if t == crate::relay::RESUME_NO_PROMPT) {
1942            st.messages.push(user_message(prompt));
1943        }
1944        st.started = true;
1945        let req_tools = self.request_tools(tk);
1946        let mut tools = match style {
1947            Style::OpenAI => to_openai(&req_tools),
1948            Style::Anthropic => to_anthropic(&req_tools),
1949        };
1950        for turn in 0..self.max_turns() {
1951            st.turns += 1;
1952            let mut turn_model = self.opts().model.clone();
1953            if let Some(h) = &self.opts().hooks.before_llm {
1954                let ov = h(BeforeLlmEvent {
1955                    messages: st.messages.clone(),
1956                    tools: tools.clone(),
1957                    model: self.opts().model.clone(),
1958                    turn,
1959                })
1960                .await
1961                .map_err(ClientError::Hook)?;
1962                if let Some(ov) = ov {
1963                    if let Some(m) = ov.messages {
1964                        st.messages = m;
1965                    }
1966                    if let Some(t) = ov.tools {
1967                        tools = t;
1968                    }
1969                    if let Some(m) = ov.model.filter(|m| !m.is_empty()) {
1970                        turn_model = m;
1971                    }
1972                }
1973            }
1974            let (asst, end) = self
1975                .call_model(st, &key, &turn_model, &system, &tools, turn, cancel, tx)
1976                .await?;
1977            if let Some(a) = asst {
1978                st.messages.push(a);
1979            }
1980            let calls = match end {
1981                TurnEnd::Empty => return Ok(self.end_run(st, String::new(), tx)),
1982                TurnEnd::Final(text) => return Ok(self.end_run(st, text, tx)),
1983                TurnEnd::Calls(c) => c,
1984            };
1985            for c in &calls {
1986                send(tx, self.call_event(c));
1987            }
1988            let futs = calls.iter().map(|c| async move {
1989                let (args, mut result) = self.run_tool(tk, c, turn, cancel).await?;
1990                let mut halted = None;
1991                if let Some(mut req) = pending_of(&result) {
1992                    // the tool cannot know its own call id; the loop does
1993                    crate::relay::stamp_relay_call_id(&mut req, &c.id);
1994                    send(
1995                        tx,
1996                        StreamEvent {
1997                            request: Some(req.clone()),
1998                            ..StreamEvent::of("pending")
1999                        },
2000                    );
2001                    let (r, h) = self
2002                        .resolve_pending(tk, &c.name, &args, req, &c.id, turn, cancel)
2003                        .await?;
2004                    result = r;
2005                    halted = h;
2006                }
2007                if cancel.is_cancelled() {
2008                    return Err(ClientError::Cancelled);
2009                }
2010                Ok::<_, ClientError>((args, result, halted))
2011            });
2012            let safe: Vec<bool> = calls.iter().map(|c| self.call_safe(tk, c)).collect();
2013            let cap = crate::tool::resolve_max_tool_concurrency(self.opts().max_tool_concurrency);
2014            let futs: Vec<_> = futs.collect();
2015            let outcomes = crate::tool::schedule_calls(futs, &safe, cap).await;
2016            let entry = |c: &Call, r: &ToolResult| match style {
2017                Style::OpenAI => emit::with_tool_parts(
2018                    json!({"role": "tool", "tool_call_id": c.id, "content": r.output}),
2019                    &c.name,
2020                    &r.parts,
2021                ),
2022                Style::Anthropic => emit::with_tool_parts(
2023                    json!({"type": "tool_result", "tool_use_id": c.id, "content": r.output, "is_error": r.is_error}),
2024                    &c.name,
2025                    &r.parts,
2026                ),
2027            };
2028            // A tool-tiers `call_tool` call is recorded as the REAL tool with
2029            // its inner args, as hooks and stream events see it (§4C); the
2030            // transcript keeps the wrapper.
2031            let record = |c: &Call, args: &Value, r: &ToolResult| {
2032                let (name, args, _) = self.unwrap_call(&c.name, args);
2033                ToolCall {
2034                    name,
2035                    args,
2036                    output: r.output.clone(),
2037                    is_error: r.is_error,
2038                    metadata: r.metadata.clone(),
2039                    parts: r.parts.clone(),
2040                }
2041            };
2042            let push = |st: &mut RunState, tc: ToolCall, id: &str| {
2043                st.files.extend(run_files_of(&tc, id));
2044                st.tool_calls.push(tc);
2045            };
2046            if let Some(err) = outcomes.iter().find_map(|o| o.as_ref().err()).cloned() {
2047                // keep the finished pairs; the rest are sealed as interrupted
2048                let mut blocks = Vec::new();
2049                for (c, o) in calls.iter().zip(&outcomes) {
2050                    if let Ok((args, r, _)) = o {
2051                        match style {
2052                            Style::OpenAI => {
2053                                push(st, record(c, args, r), &c.id);
2054                                st.messages.push(entry(c, r));
2055                            }
2056                            Style::Anthropic => blocks.push(entry(c, r)),
2057                        }
2058                    }
2059                }
2060                if !blocks.is_empty() {
2061                    st.messages.push(json!({"role": "user", "content": blocks}));
2062                }
2063                return Err(err);
2064            }
2065            let mut blocks = Vec::new();
2066            let mut halt: Option<Request> = None;
2067            let mut done = Vec::with_capacity(outcomes.len());
2068            let mut halts = Vec::with_capacity(outcomes.len());
2069            for o in outcomes {
2070                let (args, r, h) = o?;
2071                done.push((args, r));
2072                halts.push(h);
2073            }
2074            // Fold every relay suspension of this turn into the first (§10,
2075            // ADR-0010 F2-a); the first-in-order halt rule below is untouched.
2076            crate::relay::merge_relay_calls(&mut halts);
2077            for ((c, (args, r)), h) in calls.iter().zip(done).zip(halts) {
2078                push(st, record(c, &args, &r), &c.id);
2079                match style {
2080                    Style::OpenAI => st.messages.push(entry(c, &r)),
2081                    Style::Anthropic => blocks.push(entry(c, &r)),
2082                }
2083                if h.is_some() {
2084                    halt = h;
2085                    break;
2086                }
2087                send(tx, self.result_event(c, &r));
2088            }
2089            if style == Style::Anthropic {
2090                st.messages.push(json!({"role": "user", "content": blocks}));
2091            }
2092            if let Some(req) = halt {
2093                return Ok(self.pending_run(st, req, tx));
2094            }
2095        }
2096        let text = if style == Style::OpenAI {
2097            last_text(&st.messages)
2098        } else {
2099            String::new()
2100        };
2101        let mut res = self.end_run(st, text.clone(), None);
2102        if text.is_empty() {
2103            res.status = "incomplete".into();
2104            res.limit = "maxTurns".into();
2105        }
2106        if tx.is_some() {
2107            send(
2108                tx,
2109                StreamEvent {
2110                    usage: Some(res.usage),
2111                    result: Some(Box::new(res.clone())),
2112                    ..StreamEvent::of("done")
2113                },
2114            );
2115        }
2116        Ok(res)
2117    }
2118
2119    fn end_run(&self, st: &RunState, text: String, tx: Option<&Tx>) -> RunResult {
2120        if let Some(tx) = tx {
2121            let _ = tx.send(StreamEvent {
2122                usage: Some(st.usage),
2123                ..StreamEvent::of("usage")
2124            });
2125        }
2126        self.record_metric(MetricEvent {
2127            event: "run".into(),
2128            model: st.last_model.clone(),
2129            turns: st.turns as i64,
2130            tool_calls: st.tool_calls.len() as i64,
2131            total_tokens: st.usage.total_tokens,
2132            ..Default::default()
2133        });
2134        let res = self.result(st, text);
2135        send(
2136            tx,
2137            StreamEvent {
2138                usage: Some(res.usage),
2139                result: Some(Box::new(res.clone())),
2140                ..StreamEvent::of("done")
2141            },
2142        );
2143        res
2144    }
2145
2146    fn pending_run(&self, st: &RunState, req: Request, tx: Option<&Tx>) -> RunResult {
2147        self.record_metric(MetricEvent {
2148            event: "run".into(),
2149            model: st.last_model.clone(),
2150            turns: st.turns as i64,
2151            tool_calls: st.tool_calls.len() as i64,
2152            total_tokens: st.usage.total_tokens,
2153            ..Default::default()
2154        });
2155        let mut res = self.result(st, req.prompt.clone());
2156        res.status = "pending".into();
2157        res.pending = Some(req);
2158        send(
2159            tx,
2160            StreamEvent {
2161                usage: Some(res.usage),
2162                result: Some(Box::new(res.clone())),
2163                ..StreamEvent::of("done")
2164            },
2165        );
2166        res
2167    }
2168
2169    /// Drive one run: deadline, cancellation, run metric on error, partial
2170    /// transcript sealing. Returns the outcome and the (possibly partial) transcript.
2171    async fn drive(
2172        &self,
2173        prompt: &Prompt,
2174        tk: &Toolkit,
2175        history: Vec<Value>,
2176        cancel: CancellationToken,
2177        tx: Option<&Tx>,
2178    ) -> (Result<RunResult, ClientError>, Option<Vec<Value>>) {
2179        let run_cancel = cancel.child_token();
2180        let start = Instant::now();
2181        let mut st = RunState {
2182            last_model: self.opts().model.clone(),
2183            ..Default::default()
2184        };
2185        let timeout_ms = self.opts().timeout_ms;
2186        let r = {
2187            let fut = self.run_loop(&mut st, prompt, tk, history, &run_cancel, tx);
2188            if timeout_ms > 0 {
2189                match tokio::time::timeout(Duration::from_millis(timeout_ms), fut).await {
2190                    Ok(r) => r,
2191                    Err(_) => {
2192                        run_cancel.cancel();
2193                        Err(ClientError::RunTimeout {
2194                            timeout_ms,
2195                            partial: Box::default(),
2196                        })
2197                    }
2198                }
2199            } else {
2200                fut.await
2201            }
2202        };
2203        match r {
2204            Ok(res) => (Ok(res), None),
2205            Err(e) => {
2206                let e = if timeout_ms > 0
2207                    && matches!(e, ClientError::Cancelled)
2208                    && start.elapsed() >= Duration::from_millis(timeout_ms)
2209                {
2210                    ClientError::RunTimeout {
2211                        timeout_ms,
2212                        partial: Box::default(),
2213                    }
2214                } else {
2215                    e
2216                };
2217                self.emit_run(&st, start, e.to_string());
2218                let partial = if st.started {
2219                    st.messages = seal_interrupted(std::mem::take(&mut st.messages));
2220                    Some(st.messages.clone())
2221                } else {
2222                    None
2223                };
2224                let e = match e {
2225                    ClientError::RunTimeout { timeout_ms, .. } => {
2226                        let mut res = self.result(&st, String::new());
2227                        res.status = "incomplete".into();
2228                        res.limit = "timeout".into();
2229                        ClientError::RunTimeout {
2230                            timeout_ms,
2231                            partial: Box::new(res),
2232                        }
2233                    }
2234                    e => e,
2235                };
2236                (Err(e), partial)
2237            }
2238        }
2239    }
2240
2241    // -- public surface -------------------------------------------------------------
2242
2243    /// One run from a fresh transcript.
2244    pub async fn run(
2245        &self,
2246        prompt: impl Into<Prompt>,
2247        tk: &Toolkit,
2248    ) -> Result<RunResult, ClientError> {
2249        self.run_ctx(CancellationToken::new(), prompt, tk, Vec::new())
2250            .await
2251    }
2252
2253    /// Continue from a prior transcript (`history` replaces the system message).
2254    pub async fn run_with_history(
2255        &self,
2256        prompt: impl Into<Prompt>,
2257        tk: &Toolkit,
2258        history: Vec<Value>,
2259    ) -> Result<RunResult, ClientError> {
2260        self.run_ctx(CancellationToken::new(), prompt, tk, history)
2261            .await
2262    }
2263
2264    /// The general form: cancellable, with optional history. Multimodal prompts
2265    /// are validated against `max_part_bytes` first.
2266    pub async fn run_ctx(
2267        &self,
2268        cancel: CancellationToken,
2269        prompt: impl Into<Prompt>,
2270        tk: &Toolkit,
2271        history: Vec<Value>,
2272    ) -> Result<RunResult, ClientError> {
2273        let prompt = prompt.into();
2274        if let Prompt::Parts(parts) = &prompt {
2275            for p in parts {
2276                validate_part(p, self.opts().max_part_bytes).map_err(ClientError::Part)?;
2277            }
2278        }
2279        self.drive(&prompt, tk, history, cancel, None).await.0
2280    }
2281
2282    /// Resume a run that halted with status `pending` (§10 durable path,
2283    /// ADR-0010): `history` is the transcript the host persisted, `pending` the
2284    /// Request it received, `answer` the results it executed itself. Every tool
2285    /// call of the halted turn gets a tool result (the placeholder is
2286    /// replaced), and the loop continues with no new user turn. The answer must
2287    /// echo the request id.
2288    pub async fn run_with_answer(
2289        &self,
2290        cancel: CancellationToken,
2291        tk: &Toolkit,
2292        history: Vec<Value>,
2293        pending: &Request,
2294        answer: &Answer,
2295    ) -> Result<RunResult, ClientError> {
2296        if answer.id != pending.id {
2297            return Err(ClientError::Resume(format!(
2298                "toolnexus: cannot resume — answer id {:?} does not echo the pending request id {:?}",
2299                answer.id, pending.id
2300            )));
2301        }
2302        if history.is_empty() {
2303            return Err(ClientError::Resume(
2304                "toolnexus: cannot resume — no transcript supplied".into(),
2305            ));
2306        }
2307        let repaired = crate::relay::repair_halted_turn(&history, pending, answer)
2308            .map_err(ClientError::Resume)?;
2309        self.run_ctx(cancel, crate::relay::RESUME_NO_PROMPT, tk, repaired)
2310            .await
2311    }
2312
2313    /// [`Client::run_with_answer`] against a stored conversation: load `id`,
2314    /// resume it, save the updated transcript back.
2315    pub async fn ask_with_answer(
2316        &self,
2317        cancel: CancellationToken,
2318        tk: &Toolkit,
2319        id: &str,
2320        pending: &Request,
2321        answer: &Answer,
2322    ) -> Result<RunResult, ClientError> {
2323        if id.is_empty() {
2324            return Err(ClientError::Resume(
2325                "toolnexus: AskWithAnswer needs a conversation id".into(),
2326            ));
2327        }
2328        let history = self
2329            .inner
2330            .store
2331            .get(id)
2332            .map_err(ClientError::Store)?
2333            .unwrap_or_default();
2334        let res = self
2335            .run_with_answer(cancel, tk, history, pending, answer)
2336            .await?;
2337        self.inner
2338            .store
2339            .save(id, &res.messages)
2340            .map_err(ClientError::Store)?;
2341        Ok(res)
2342    }
2343
2344    /// A run remembered under `id` in the conversation store (empty id ⇒ `run`).
2345    /// An interrupted turn is still saved (sealed); the error wins.
2346    pub async fn ask(
2347        &self,
2348        prompt: impl Into<Prompt>,
2349        tk: &Toolkit,
2350        id: &str,
2351    ) -> Result<RunResult, ClientError> {
2352        self.ask_ctx(CancellationToken::new(), prompt, tk, id).await
2353    }
2354
2355    pub async fn ask_ctx(
2356        &self,
2357        cancel: CancellationToken,
2358        prompt: impl Into<Prompt>,
2359        tk: &Toolkit,
2360        id: &str,
2361    ) -> Result<RunResult, ClientError> {
2362        let prompt = prompt.into();
2363        if id.is_empty() {
2364            return self.run_ctx(cancel, prompt, tk, Vec::new()).await;
2365        }
2366        let history = self
2367            .inner
2368            .store
2369            .get(id)
2370            .map_err(ClientError::Store)?
2371            .unwrap_or_default();
2372        let (r, partial) = self.drive(&prompt, tk, history.clone(), cancel, None).await;
2373        match r {
2374            Ok(res) => {
2375                self.inner
2376                    .store
2377                    .save(id, &res.messages)
2378                    .map_err(ClientError::Store)?;
2379                Ok(res)
2380            }
2381            Err(e) => {
2382                self.save_partial(id, history, &prompt, tk, partial);
2383                Err(e)
2384            }
2385        }
2386    }
2387
2388    /// `ask` with streamed text: `on_text` gets every delta.
2389    pub async fn ask_stream(
2390        &self,
2391        prompt: impl Into<Prompt>,
2392        tk: &Toolkit,
2393        id: &str,
2394        mut on_text: impl FnMut(&str) + Send,
2395    ) -> Result<RunResult, ClientError> {
2396        let mut rx = self.stream_with_id(prompt, tk, id)?;
2397        let mut result = None;
2398        let mut err = None;
2399        while let Some(ev) = rx.recv().await {
2400            match ev.kind.as_str() {
2401                "text" => on_text(&ev.delta),
2402                "done" => result = ev.result.map(|r| *r),
2403                "error" => err = ev.error,
2404                _ => {}
2405            }
2406        }
2407        if let Some(e) = err {
2408            return Err(e);
2409        }
2410        Ok(result.unwrap_or_default())
2411    }
2412
2413    fn save_partial(
2414        &self,
2415        id: &str,
2416        history: Vec<Value>,
2417        prompt: &Prompt,
2418        tk: &Toolkit,
2419        partial: Option<Vec<Value>>,
2420    ) {
2421        let msgs = match partial {
2422            Some(m) => m,
2423            None => {
2424                let empty = history.is_empty();
2425                let mut m = history;
2426                if empty && self.opts().style != Style::Anthropic {
2427                    let sys = self.system(tk);
2428                    if !sys.is_empty() {
2429                        m.push(json!({"role": "system", "content": sys}));
2430                    }
2431                }
2432                m.push(user_message(prompt));
2433                m
2434            }
2435        };
2436        let _ = self.inner.store.save(id, &msgs);
2437    }
2438
2439    /// Stream a fresh run: `text`, `tool_call`, `tool_result`, `pending`, `usage`,
2440    /// then `done` (or `error`). The channel closes after the last event.
2441    pub fn stream(
2442        &self,
2443        prompt: impl Into<Prompt>,
2444        tk: &Toolkit,
2445    ) -> Result<mpsc::UnboundedReceiver<StreamEvent>, ClientError> {
2446        self.stream_ctx(CancellationToken::new(), prompt, tk, "")
2447    }
2448
2449    /// Stream a run remembered under `id` (empty ⇒ not remembered).
2450    pub fn stream_with_id(
2451        &self,
2452        prompt: impl Into<Prompt>,
2453        tk: &Toolkit,
2454        id: &str,
2455    ) -> Result<mpsc::UnboundedReceiver<StreamEvent>, ClientError> {
2456        self.stream_ctx(CancellationToken::new(), prompt, tk, id)
2457    }
2458
2459    pub fn stream_ctx(
2460        &self,
2461        cancel: CancellationToken,
2462        prompt: impl Into<Prompt>,
2463        tk: &Toolkit,
2464        id: &str,
2465    ) -> Result<mpsc::UnboundedReceiver<StreamEvent>, ClientError> {
2466        let prompt = prompt.into();
2467        let history = if id.is_empty() {
2468            Vec::new()
2469        } else {
2470            self.inner
2471                .store
2472                .get(id)
2473                .map_err(ClientError::Store)?
2474                .unwrap_or_default()
2475        };
2476        let (tx, rx) = mpsc::unbounded_channel();
2477        let (this, tk, id) = (self.clone(), tk.clone(), id.to_owned());
2478        tokio::spawn(async move {
2479            let (r, partial) = this
2480                .drive(&prompt, &tk, history.clone(), cancel, Some(&tx))
2481                .await;
2482            match r {
2483                Ok(res) => {
2484                    if !id.is_empty()
2485                        && let Err(e) = this.inner.store.save(&id, &res.messages)
2486                    {
2487                        let _ = tx.send(StreamEvent {
2488                            error: Some(ClientError::Store(e)),
2489                            ..StreamEvent::of("error")
2490                        });
2491                    }
2492                }
2493                Err(e) => {
2494                    if !id.is_empty() {
2495                        this.save_partial(&id, history, &prompt, &tk, partial);
2496                    }
2497                    let _ = tx.send(StreamEvent {
2498                        error: Some(e),
2499                        ..StreamEvent::of("error")
2500                    });
2501                }
2502            }
2503        });
2504        Ok(rx)
2505    }
2506
2507    /// A stateful conversation over this client (history kept in memory).
2508    pub fn conversation(&self, tk: &Toolkit) -> Conversation {
2509        Conversation {
2510            client: self.clone(),
2511            tk: tk.clone(),
2512            messages: Vec::new(),
2513        }
2514    }
2515}
2516
2517/// An in-memory multi-turn conversation.
2518#[derive(Debug, Clone)]
2519pub struct Conversation {
2520    client: Client,
2521    tk: Toolkit,
2522    pub messages: Vec<Value>,
2523}
2524
2525impl Conversation {
2526    pub async fn send(&mut self, prompt: impl Into<Prompt>) -> Result<RunResult, ClientError> {
2527        let res = self
2528            .client
2529            .run_with_history(prompt, &self.tk, self.messages.clone())
2530            .await?;
2531        self.messages = res.messages.clone();
2532        Ok(res)
2533    }
2534    pub fn reset(&mut self) {
2535        self.messages.clear();
2536    }
2537}
2538
2539/// The provider env var for an https base URL's host (`""` for any other host).
2540pub fn key_env_for_base_url(base_url: &str) -> &'static str {
2541    let h = host_of(base_url);
2542    if h == "api.openai.com" {
2543        "OPENAI_API_KEY"
2544    } else if h == "api.anthropic.com" {
2545        "ANTHROPIC_API_KEY"
2546    } else if h == "openrouter.ai" || h.ends_with(".openrouter.ai") {
2547        "OPENROUTER_API_KEY"
2548    } else {
2549        ""
2550    }
2551}
2552
2553fn host_of(base_url: &str) -> String {
2554    let Some(rest) = base_url.strip_prefix("https://").or_else(|| {
2555        // scheme is case-insensitive
2556        if base_url.len() > 8 && base_url[..8].eq_ignore_ascii_case("https://") {
2557            Some(&base_url[8..])
2558        } else {
2559            None
2560        }
2561    }) else {
2562        return String::new();
2563    };
2564    let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
2565    let hostport = authority.rsplit('@').next().unwrap_or("");
2566    let host = if let Some(stripped) = hostport.strip_prefix('[') {
2567        stripped.split(']').next().unwrap_or("")
2568    } else {
2569        hostport.split(':').next().unwrap_or("")
2570    };
2571    host.to_ascii_lowercase()
2572}