Skip to main content

harness/
model.rs

1use async_trait::async_trait;
2use eventsource_stream::Eventsource;
3use futures::stream::{BoxStream, StreamExt};
4use serde_json::{json, Value};
5
6use crate::event::HarnessUsage;
7use crate::model_catalog::{ReasoningMode, ResolvedModelConfig, WireProtocol};
8use crate::tools::{ToolInvocation, ToolSpec};
9
10/// One token-level event from the model. The harness loop consumes a
11/// `Stream<ModelChunk>` and forwards `TextDelta` straight through as
12/// `HarnessInternalEvent::AssistantTextChunk` so callers see live
13/// generation; tool-call chunks are accumulated internally and only
14/// emitted once `ToolCallEnd` lands so the harness can dispatch the call
15/// with a complete `serde_json::Value` input.
16///
17/// Provider-agnostic: `OpenAiCompatibleModelClient` translates from
18/// OpenAI's chat-completions SSE shape, and the future Anthropic client
19/// will project Messages-API events into the same enum. `ScriptedModelClient`
20/// synthesizes whichever sequence its scripted `ModelResponse` would
21/// have implied.
22#[derive(Debug, Clone, PartialEq)]
23pub enum ModelChunk {
24    TextDelta {
25        msg_id: String,
26        delta: String,
27    },
28    ThinkingDelta {
29        thinking_id: String,
30        delta: String,
31        /// Provider-emitted signature (Anthropic extended thinking) that
32        /// MUST be round-tripped to history verbatim, otherwise some
33        /// providers reject the next turn. `None` for OpenAI today.
34        signature: Option<String>,
35    },
36    /// First chunk of a tool call. The harness records `(id, name)` and
37    /// starts buffering `ToolCallInputDelta`s under this id.
38    ToolCallStart {
39        id: String,
40        name: String,
41    },
42    /// Streaming JSON arguments for a previously-announced tool call.
43    /// OpenAI emits these as a string that, when concatenated, is valid
44    /// JSON. Harness accumulates these into a single string per id, then
45    /// parses on `ToolCallEnd`.
46    ToolCallInputDelta {
47        id: String,
48        delta: String,
49    },
50    /// Tool call finalised. `input` is the parsed JSON value if the
51    /// provider sent the full object on this chunk (Anthropic), or a
52    /// placeholder if the harness still needs to parse the accumulated
53    /// `ToolCallInputDelta` buffer (OpenAI). Either way the harness
54    /// treats `input` as authoritative when present.
55    ToolCallEnd {
56        id: String,
57        input: Option<Value>,
58    },
59    /// Final chunk. `usage` carries the provider's reported token count
60    /// for this call (None if the gateway elides it).
61    Done {
62        stop_reason: String,
63        usage: Option<HarnessUsage>,
64    },
65}
66
67/// Verbatim thinking block emitted by Anthropic extended thinking. The
68/// `signature` is a provider-supplied opaque token that MUST be
69/// round-tripped on subsequent turns — Anthropic rejects modified
70/// thinking blocks. OpenAI does not emit thinking blocks at all, so this
71/// field is always `None` on that path.
72#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
73pub struct AssistantThinking {
74    pub text: String,
75    pub signature: Option<String>,
76}
77
78/// Image attached to a user message. Both providers accept either inline
79/// base64 bytes or a URL the provider fetches; we surface both shapes
80/// rather than always inlining (URLs save bandwidth + sandbox upload).
81#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
82pub struct ImageSource {
83    /// IANA media type. Anthropic accepts `image/jpeg`, `image/png`,
84    /// `image/gif`, `image/webp`; OpenAI accepts the same set. Both
85    /// validate at request time — junk values surface as 400.
86    pub media_type: String,
87    pub data: ImageData,
88}
89
90#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
91pub enum ImageData {
92    /// Base64-encoded image bytes. Most universal — supported by every
93    /// modern multimodal model. Encoded without the `data:image/...;base64,`
94    /// prefix (the projection layer adds it when the provider's wire
95    /// format requires it; e.g. OpenAI image_url).
96    Base64(String),
97    /// URL the provider fetches. Provider-side IP egress / latency
98    /// tradeoff — convenient for public assets, brittle for private ones.
99    Url(String),
100}
101
102/// One attachment on a `ChatMessage::User`. `Image` is the only variant
103/// today; documents / file_id round-trips slot in as future variants
104/// without breaking pattern matches (callers should use `, ..` rest
105/// pattern when destructuring User to stay forward-compat).
106#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
107pub enum UserAttachment {
108    Image(ImageSource),
109}
110
111/// One conversation entry as seen by the model. Mirrors the
112/// `system / user / assistant / tool` set OpenAI chat/completions expects.
113/// Anthropic's Messages API uses a different shape (tool_use / tool_result
114/// content blocks instead of separate `tool` role) but consumes the same
115/// `ChatMessage` history — the projection lives in the per-provider
116/// model client, keeping the harness loop provider-agnostic.
117#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
118pub enum ChatMessage {
119    User {
120        content: String,
121        /// Non-text attachments (images today; file_id / documents later).
122        /// Renders as additional content blocks alongside the `content`
123        /// text on the wire — projection per provider in §8 / §9.
124        attachments: Vec<UserAttachment>,
125    },
126    /// Assistant turn. May carry any combination of: a `thinking` block
127    /// (Anthropic extended thinking), final text, and one+ tool calls.
128    /// All three render into the assistant message's content array on
129    /// the Anthropic wire; OpenAI uses `tool_calls` for tool calls and
130    /// ignores thinking entirely.
131    Assistant {
132        text: Option<String>,
133        tool_calls: Vec<ToolInvocation>,
134        /// Thinking block to round-trip verbatim. `None` for OpenAI /
135        /// Anthropic-without-extended-thinking turns.
136        thinking: Option<AssistantThinking>,
137        /// Provider-reported token tally for this assistant turn, when the
138        /// provider supplied one. Records the exact context size up to and
139        /// including this message, so context-size estimation can anchor on
140        /// a measured value and estimate only the messages appended after it.
141        /// `None` when the turn carried no reported usage.
142        #[serde(default, skip_serializing_if = "Option::is_none")]
143        usage: Option<crate::event::HarnessUsage>,
144    },
145    /// Tool response paired by `tool_call_id`. `content` is the
146    /// serialized tool output (model side sees a string regardless of
147    /// the underlying JSON shape). `attachments` carries structured
148    /// non-text content the tool produced — e.g. a screenshot MCP tool
149    /// returning an image. Only providers with a tool-content array
150    /// (Anthropic) surface these on the wire; OpenAI tool role is
151    /// strictly string-typed, so attachments degrade to a placeholder
152    /// in the `content` string there.
153    Tool {
154        tool_call_id: String,
155        content: String,
156        is_error: bool,
157        attachments: Vec<UserAttachment>,
158    },
159}
160
161#[derive(Debug, Clone, PartialEq)]
162pub struct ModelTurnInput {
163    /// Optional system prompt (already composed: spec_snapshot system_prompt
164    /// + driver.append_system_prompt). `None` ⇒ no system message sent.
165    pub system_prompt: Option<String>,
166    /// Full conversation history for this turn. AgentLoopHarness appends
167    /// each `Assistant` / `Tool` message as the loop progresses so the
168    /// model retains its own prior reasoning across tool round-trips.
169    pub messages: Vec<ChatMessage>,
170    /// Tool specs available this turn. Sourced from `ToolRuntime::specs()`
171    /// so adding / removing a tool changes one place. Empty Vec ⇒ no tools
172    /// advertised (final-answer-only mode).
173    pub tools: Vec<ToolSpec>,
174    /// Provider-executed tools. These are not dispatched through
175    /// `ToolRuntime`; the model provider runs them server-side and streams
176    /// the final answer back through normal text deltas.
177    pub hosted_tools: Vec<HostedTool>,
178    /// How the model should pick (or skip) tools. Defaults to `Auto`.
179    /// Set via `AgentLoopHarness::with_tool_choice` from
180    /// `bootstrap.driver.native_model.tool_choice`.
181    pub tool_choice: ToolChoice,
182    /// Whether the model may emit multiple tool_use blocks in one
183    /// response (OpenAI's `parallel_tool_calls`). `None` ⇒ provider
184    /// default (true for OpenAI). Anthropic is always implicitly
185    /// multi-tool-capable so this field is OpenAI-only.
186    pub parallel_tool_calls: Option<bool>,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
190pub enum HostedCapability {
191    WebSearch,
192}
193
194/// Whether a concrete model client can carry a hosted capability over its
195/// current wire protocol.
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
197pub enum CapabilitySupport {
198    Supported,
199    Unsupported,
200    Unknown,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
204pub enum HostedTool {
205    WebSearch,
206}
207
208/// How the model should route tool selection for the current turn.
209/// Mapped to each provider's wire field by `chat_request_body`:
210///
211/// | variant      | OpenAI                                                  | Anthropic                       |
212/// |--------------|---------------------------------------------------------|---------------------------------|
213/// | `Auto`       | `"auto"` (or omitted)                                   | `{"type":"auto"}` (or omitted)  |
214/// | `None`       | `"none"` — model MUST NOT call a tool                   | (degrades: tools field dropped) |
215/// | `Required`   | `"required"` — model MUST call at least one tool        | `{"type":"any"}`                |
216/// | `Tool(name)` | `{"type":"function","function":{"name":name}}` — forced | `{"type":"tool","name":name}`   |
217///
218/// The `None` variant has no direct Anthropic equivalent — Anthropic
219/// forces tool consideration whenever `tools` is non-empty. We
220/// approximate it by dropping the `tools` field entirely for that turn;
221/// the model can't call what it doesn't know about.
222#[derive(Debug, Clone, PartialEq, Default)]
223pub enum ToolChoice {
224    #[default]
225    Auto,
226    None,
227    Required,
228    Tool(String),
229}
230
231impl ToolChoice {
232    /// Parse from the bootstrap.yaml string form.
233    /// Accepts `"auto"`, `"none"`, `"required"`, `"tool:<name>"`.
234    /// Empty / unknown strings degrade to `Auto` for forward compat —
235    /// callers that want strict validation can match before calling.
236    pub fn parse(s: &str) -> Self {
237        let trimmed = s.trim();
238        if let Some(name) = trimmed.strip_prefix("tool:") {
239            return Self::Tool(name.trim().to_string());
240        }
241        match trimmed.to_ascii_lowercase().as_str() {
242            "" | "auto" => Self::Auto,
243            "none" => Self::None,
244            "required" | "any" => Self::Required,
245            _ => Self::Auto,
246        }
247    }
248}
249
250/// Response from a single model call. `Message` is a final answer (no
251/// follow-up tool needed); `ToolCall` hands control back to the harness
252/// loop to execute the tool and feed the result back next turn.
253///
254/// `usage` carries the provider-reported token tally for *this* call.
255/// AgentLoopHarness accumulates these across all steps of a turn and
256/// attaches the total to the final `HarnessInternalEvent::TurnEnd`.
257/// `None` ⇒ provider didn't report usage on this call (e.g. mid-stream
258/// chunk, scripted fake client).
259#[derive(Debug, Clone, PartialEq)]
260pub enum ModelResponse {
261    Message {
262        text: String,
263        stop_reason: String,
264        usage: Option<HarnessUsage>,
265    },
266    ToolCall {
267        preface: Option<String>,
268        invocation: ToolInvocation,
269        usage: Option<HarnessUsage>,
270    },
271}
272
273impl ModelResponse {
274    /// Per-call usage, regardless of variant. Pulled out so AgentLoopHarness
275    /// can fold it into the running total without matching on every branch.
276    pub fn usage(&self) -> Option<&HarnessUsage> {
277        match self {
278            ModelResponse::Message { usage, .. } | ModelResponse::ToolCall { usage, .. } => {
279                usage.as_ref()
280            }
281        }
282    }
283}
284
285/// Categorised model-client failures. Mirrors `NativeHarnessError`'s
286/// `Model*` variants 1:1 so the agent loop can map across without
287/// pattern-matching gymnastics.
288///
289/// Retryability:
290///   - `RateLimit` / `Network` / `ServerError` → transient, safe to retry with backoff
291///   - `Auth` / `ContextOverflow` / `BadRequest` → config error, never retry
292#[derive(Debug, thiserror::Error)]
293pub enum ModelClientError {
294    /// HTTP 429 — back off and retry.
295    #[error("rate limit: {0}")]
296    RateLimit(String),
297    /// HTTP 401 / 403 — wrong key or no permission; do not retry.
298    #[error("auth: {0}")]
299    Auth(String),
300    /// HTTP 400 that looks like context overflow; do not retry.
301    #[error("context overflow: {0}")]
302    ContextOverflow(String),
303    /// HTTP 400 (invalid model, bad params, etc.) — config error; do not retry.
304    #[error("bad request: {0}")]
305    BadRequest(String),
306    /// HTTP 5xx or transport failure — transient; safe to retry.
307    #[error("server error: {0}")]
308    ServerError(String),
309    /// DNS / TCP / TLS failure — transient; safe to retry.
310    #[error("network: {0}")]
311    Network(String),
312    /// Anything else we couldn't bucket; treated as non-retryable.
313    #[error("model error: {0}")]
314    Other(String),
315}
316
317impl ModelClientError {
318    /// Returns `true` if the caller should back off and retry this error.
319    pub fn retryable(&self) -> bool {
320        matches!(
321            self,
322            Self::RateLimit(_) | Self::Network(_) | Self::ServerError(_)
323        )
324    }
325}
326
327#[async_trait]
328pub trait ModelClient: Send + Sync {
329    /// Report support for provider-executed capabilities on this client's
330    /// actual API path. Automatic routing only selects `Supported` values.
331    fn hosted_capability(&self, capability: HostedCapability) -> CapabilitySupport;
332
333    /// Stream the model's response as a sequence of `ModelChunk`s. Production
334    /// `ScriptedModelClient` / `OpenAiCompatibleModelClient` implement this;
335    /// `next()` is provided as a folding convenience for callers that don't
336    /// need token-level events.
337    async fn stream(
338        &self,
339        input: ModelTurnInput,
340    ) -> Result<BoxStream<'static, Result<ModelChunk, ModelClientError>>, ModelClientError>;
341
342    /// Buffer the stream into a single `ModelResponse`. Default impl
343    /// `await`s every chunk and then runs `collect_model_response`. Override
344    /// only if a provider has a cheaper non-streaming path (none today —
345    /// even OpenAI's non-stream call still goes through the same SSE-or-not
346    /// branch on our side).
347    async fn next(&self, input: ModelTurnInput) -> Result<ModelResponse, ModelClientError> {
348        let stream = self.stream(input).await?;
349        collect_model_response(stream).await
350    }
351}
352
353/// Fold a `ModelChunk` stream into the legacy `ModelResponse` shape. Used
354/// by tests, by the default `next()` impl, and by any caller that prefers
355/// "one response at a time" over a streaming feed. Provider-agnostic —
356/// the same logic works for OpenAI and Anthropic chunk streams because
357/// `ModelChunk` is the projection target both clients normalise into.
358pub async fn collect_model_response(
359    mut stream: BoxStream<'static, Result<ModelChunk, ModelClientError>>,
360) -> Result<ModelResponse, ModelClientError> {
361    let mut text_buf = String::new();
362    let mut text_msg_id: Option<String> = None;
363    // Per tool-call-id: name + accumulated argument bytes + early input (Anthropic-style).
364    let mut tool_states: Vec<ToolStreamState> = Vec::new();
365    let mut stop_reason: Option<String> = None;
366    let mut usage: Option<HarnessUsage> = None;
367
368    while let Some(item) = stream.next().await {
369        match item? {
370            ModelChunk::TextDelta { msg_id, delta } => {
371                if text_msg_id.as_deref() != Some(&msg_id) {
372                    text_msg_id = Some(msg_id);
373                    text_buf.clear();
374                }
375                text_buf.push_str(&delta);
376            }
377            ModelChunk::ThinkingDelta { .. } => {
378                // collect_model_response is a back-compat surface; thinking
379                // blocks don't fit into the simple Message/ToolCall enum,
380                // so we silently drop them. Callers that care use the
381                // streaming API directly.
382            }
383            ModelChunk::ToolCallStart { id, name } => {
384                tool_states.push(ToolStreamState {
385                    id,
386                    name,
387                    args_buf: String::new(),
388                    early_input: None,
389                });
390            }
391            ModelChunk::ToolCallInputDelta { id, delta } => {
392                if let Some(state) = tool_states.iter_mut().find(|s| s.id == id) {
393                    state.args_buf.push_str(&delta);
394                }
395            }
396            ModelChunk::ToolCallEnd { id, input } => {
397                if let Some(state) = tool_states.iter_mut().find(|s| s.id == id) {
398                    state.early_input = input;
399                }
400            }
401            ModelChunk::Done {
402                stop_reason: sr,
403                usage: u,
404            } => {
405                stop_reason = Some(sr);
406                usage = u;
407            }
408        }
409    }
410
411    // Tool calls take precedence — when the model decides to use a tool,
412    // the loop must execute it before any final-answer text matters. Pick
413    // the first tool call (multiple-tool parallelism is a future
414    // extension; today RD-side serialises them).
415    if let Some(state) = tool_states.into_iter().next() {
416        let parsed_input = match state.early_input {
417            Some(v) => v,
418            None => serde_json::from_str(state.args_buf.as_str().trim()).map_err(|e| {
419                ModelClientError::Other(format!(
420                    "decode tool arguments for {id}: {e}",
421                    id = state.id
422                ))
423            })?,
424        };
425        let raw_emitted_args = raw_args_for_input(&state.args_buf, &parsed_input);
426        return Ok(ModelResponse::ToolCall {
427            preface: (!text_buf.is_empty()).then(|| text_buf.clone()),
428            invocation: ToolInvocation {
429                id: state.id,
430                name: state.name,
431                input: parsed_input,
432                raw_emitted_args,
433            },
434            usage,
435        });
436    }
437
438    Ok(ModelResponse::Message {
439        text: text_buf,
440        stop_reason: stop_reason.unwrap_or_else(|| "end_turn".into()),
441        usage,
442    })
443}
444
445/// Per-tool-call buffer used while folding a stream. Lives inline in
446/// `collect_model_response`; pulled out as a struct only because Rust
447/// closures over a Vec of tuples get noisy fast.
448struct ToolStreamState {
449    id: String,
450    name: String,
451    args_buf: String,
452    early_input: Option<Value>,
453}
454
455fn raw_args_for_input(raw: &str, input: &Value) -> Option<String> {
456    let trimmed = raw.trim();
457    if trimmed.is_empty() {
458        return None;
459    }
460    match serde_json::from_str::<Value>(trimmed) {
461        Ok(parsed) if parsed == *input => Some(trimmed.to_string()),
462        _ => None,
463    }
464}
465
466fn tool_invocation_args_for_wire(tc: &ToolInvocation) -> String {
467    tc.raw_emitted_args
468        .as_deref()
469        .and_then(|raw| raw_args_for_input(raw, &tc.input))
470        .unwrap_or_else(|| tc.input.to_string())
471}
472
473#[derive(Debug, Clone)]
474pub struct OpenAiCompatibleConfig {
475    pub base_url: String,
476    pub api_key: String,
477    pub model: ResolvedModelConfig,
478}
479
480#[derive(Debug, Clone)]
481pub struct OpenAiCompatibleModelClient {
482    http: reqwest::Client,
483    config: OpenAiCompatibleConfig,
484}
485
486impl OpenAiCompatibleModelClient {
487    pub fn new(config: OpenAiCompatibleConfig) -> Self {
488        assert_eq!(
489            config.model.wire_protocol,
490            WireProtocol::OpenAiCompatible,
491            "resolved model protocol must match OpenAiCompatibleModelClient"
492        );
493        // Fail fast if the TCP handshake takes too long. Do not set a
494        // read_timeout: streaming model responses may legitimately pause
495        // longer than a fixed per-read timeout between SSE frames.
496        let http = reqwest::Client::builder()
497            .connect_timeout(std::time::Duration::from_secs(15))
498            .build()
499            .unwrap_or_else(|_| reqwest::Client::new());
500        Self { http, config }
501    }
502
503    fn endpoint(&self) -> String {
504        // `base_url` is the full API prefix supplied by the caller, including
505        // any version segment — e.g. `https://api.openai.com/v1` or
506        // `https://open.bigmodel.cn/api/paas/v4`. We only append the route;
507        // picking the version is the caller's responsibility, since it differs
508        // across OpenAI-compatible providers.
509        let base = self.config.base_url.trim_end_matches('/');
510        if base.ends_with("/chat/completions") {
511            base.to_string()
512        } else {
513            format!("{base}/chat/completions")
514        }
515    }
516
517    fn request_body(&self, input: &ModelTurnInput) -> Value {
518        let mut messages = Vec::with_capacity(input.messages.len() + 1);
519        if let Some(sys) = input.system_prompt.as_deref().filter(|s| !s.is_empty()) {
520            messages.push(json!({ "role": "system", "content": sys }));
521        }
522        for msg in &input.messages {
523            messages.push(chat_message_to_wire(msg));
524        }
525
526        let mut body = json!({
527            "model": self.config.model.model,
528            "messages": messages,
529        });
530        // Tool advertising obeys `tool_choice`:
531        //   - Auto / Required / Tool(name) — send tools + matching
532        //     `tool_choice` field; model gets the wire-level constraint.
533        //   - None — drop the tools entirely. OpenAI does accept
534        //     `tool_choice: "none"` to forbid use, but dropping
535        //     `tools` is cheaper (fewer prompt tokens) and the model
536        //     can't call what it doesn't see.
537        let send_tools = !input.tools.is_empty() && !matches!(input.tool_choice, ToolChoice::None);
538        if send_tools {
539            body["tools"] = json!(input
540                .tools
541                .iter()
542                .map(tool_spec_to_openai_function)
543                .collect::<Vec<_>>());
544            body["tool_choice"] = openai_tool_choice_value(&input.tool_choice);
545            if let Some(parallel) = input.parallel_tool_calls {
546                body["parallel_tool_calls"] = json!(parallel);
547            }
548        }
549        if let Some(temperature) = self.config.model.temperature {
550            body["temperature"] = json!(temperature);
551        }
552        body["max_tokens"] = json!(self.config.model.max_output_tokens);
553        apply_openai_compatible_reasoning(&mut body, &self.config.model);
554        body
555    }
556}
557
558fn apply_openai_compatible_reasoning(body: &mut Value, model: &ResolvedModelConfig) {
559    if matches!(model.reasoning.mode, ReasoningMode::Default) {
560        return;
561    }
562    if let Some(effort) = model.reasoning.effort.as_deref() {
563        body["reasoning_effort"] = json!(effort);
564        return;
565    }
566    let mut thinking = json!({
567        "type": if matches!(model.reasoning.mode, ReasoningMode::Enabled) {
568            "enabled"
569        } else {
570            "disabled"
571        }
572    });
573    if let Some(tokens) = model.reasoning.budget_tokens {
574        thinking["budget_tokens"] = json!(tokens);
575    }
576    body["thinking"] = thinking;
577}
578
579fn openai_tool_choice_value(c: &ToolChoice) -> Value {
580    match c {
581        ToolChoice::Auto => json!("auto"),
582        // ToolChoice::None lands in the caller's "drop tools" branch
583        // so it never reaches here, but encode it defensively.
584        ToolChoice::None => json!("none"),
585        ToolChoice::Required => json!("required"),
586        ToolChoice::Tool(name) => json!({
587            "type": "function",
588            "function": {"name": name},
589        }),
590    }
591}
592
593/// Pull token counts out of an OpenAI chat/completions `usage` block.
594/// Returns `None` if the field is missing / empty — some compat gateways
595/// (older DeepSeek, certain proxies) elide it. Maps directly:
596///   * `prompt_tokens`        → `input_tokens`
597///   * `completion_tokens`    → `output_tokens`
598///   * `prompt_tokens_details.cached_tokens` → `cache_read_input_tokens`
599///     (OpenAI semantics: cached_tokens is a subset of prompt_tokens — we
600///     keep that view rather than subtracting; matches the proto field's
601///     "展示提示" intent).
602///   * cache_creation: no OpenAI equivalent → 0.
603fn parse_openai_usage(usage: Option<&Value>) -> Option<HarnessUsage> {
604    let u = usage?;
605    let input = u.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
606    let output = u
607        .get("completion_tokens")
608        .and_then(|v| v.as_u64())
609        .unwrap_or(0);
610    let cache_read = u
611        .get("prompt_tokens_details")
612        .and_then(|d| d.get("cached_tokens"))
613        .and_then(|v| v.as_u64())
614        .unwrap_or(0);
615    // Heuristic: if every counter is zero, treat as "no data reported" so
616    // downstream telemetry doesn't mis-attribute a no-op response.
617    if input == 0 && output == 0 && cache_read == 0 {
618        return None;
619    }
620    Some(HarnessUsage {
621        input_tokens: input,
622        output_tokens: output,
623        cache_read_input_tokens: cache_read,
624        cache_creation_input_tokens: 0,
625        // Provider-reported usage on the main step never carries
626        // compaction tokens — agent_loop accumulates those separately
627        // when a compaction summarize call returns its own usage.
628        compaction_input_tokens: 0,
629        compaction_output_tokens: 0,
630    })
631}
632
633/// Render one `ToolSpec` to the OpenAI chat/completions `tools[*]` shape.
634/// Pulled out so future Anthropic client can project the same spec to its
635/// Messages API `input_schema` form without duplicating tool metadata.
636/// Project an `ImageSource` to the OpenAI chat/completions `image_url`
637/// content part shape. Inline base64 gets wrapped in a `data:` URI
638/// because OpenAI's API expects the full URL form there — the model
639/// won't accept raw base64 as a sibling key.
640fn image_to_openai_part(src: &ImageSource) -> Value {
641    let url = match &src.data {
642        ImageData::Base64(b64) => {
643            // `data:image/png;base64,xxx`. OpenAI documents this form
644            // in the vision quickstart. We don't validate media_type
645            // here — invalid values surface as a 400 at request time
646            // (caller saw it in classify_openai_http_error).
647            format!("data:{};base64,{}", src.media_type, b64)
648        }
649        ImageData::Url(u) => u.clone(),
650    };
651    json!({
652        "type": "image_url",
653        "image_url": { "url": url },
654    })
655}
656
657fn tool_spec_to_openai_function(spec: &ToolSpec) -> Value {
658    json!({
659        "type": "function",
660        "function": {
661            "name": spec.name,
662            "description": spec.description,
663            "parameters": spec.input_schema,
664        }
665    })
666}
667
668/// Replay-compaction thresholds for a single tool result. A result is
669/// compacted only when it exceeds the token estimate or the byte budget —
670/// small results are never touched. The kept window is split half head /
671/// half tail so the model sees the command's start AND its trailing
672/// errors / summary.
673const MAX_TOOL_RESULT_REPLAY_TOKENS: u64 = 2_000;
674const MAX_TOOL_RESULT_REPLAY_BYTES: usize = 12 * 1024;
675const COMPACTED_TOOL_RESULT_KEEP_CHARS: usize = 3_000;
676
677/// Compact one oversized tool result for model replay. Applied at wire
678/// projection time ONLY — the full result stays verbatim in the in-memory
679/// history and in the persisted `messages.jsonl`, so resume and operators
680/// keep the raw data while every subsequent model call stops re-paying
681/// thousands of tokens for it. Deterministic (same input → same output),
682/// which keeps the projected prefix byte-stable for provider prompt caches.
683/// Orthogonal to message-level compaction: this trims per-result on every
684/// call, without waiting for a context-window trigger.
685fn compact_tool_result_for_replay(content: &str) -> std::borrow::Cow<'_, str> {
686    let estimated_tokens = crate::compaction::estimate_tokens(content);
687    if estimated_tokens <= MAX_TOOL_RESULT_REPLAY_TOKENS
688        && content.len() <= MAX_TOOL_RESULT_REPLAY_BYTES
689    {
690        return std::borrow::Cow::Borrowed(content);
691    }
692    let chars: Vec<char> = content.chars().collect();
693    if chars.len() <= COMPACTED_TOOL_RESULT_KEEP_CHARS {
694        return std::borrow::Cow::Borrowed(content);
695    }
696    let head_len = COMPACTED_TOOL_RESULT_KEEP_CHARS / 2;
697    let tail_len = COMPACTED_TOOL_RESULT_KEEP_CHARS - head_len;
698    let head: String = chars[..head_len].iter().collect();
699    let tail: String = chars[chars.len() - tail_len..].iter().collect();
700    let omitted = chars.len() - COMPACTED_TOOL_RESULT_KEEP_CHARS;
701    std::borrow::Cow::Owned(format!(
702        "[tool result compacted for model replay]\n\
703         original_estimated_tokens={estimated_tokens} original_chars={} \
704         retained_head_chars={head_len} retained_tail_chars={tail_len}\n\
705         The full raw tool result remains in session history; this replay is abbreviated.\n\n\
706         --- head ---\n{head}\n\n\
707         --- omitted ---\n[... omitted {omitted} chars from tool result replay ...]\n\n\
708         --- tail ---\n{tail}",
709        chars.len(),
710    ))
711}
712
713/// Render one `ChatMessage` to the OpenAI chat/completions wire shape.
714/// Pulled out so tests and (future) other providers can share / diff the
715/// projection.
716fn chat_message_to_wire(msg: &ChatMessage) -> Value {
717    match msg {
718        ChatMessage::User {
719            content,
720            attachments,
721        } => {
722            // Fast path: no attachments → content stays as a plain
723            // string. Keeps simple text-only requests byte-identical to
724            // pre-multimodal output (Anthropic prompt cache friendliness
725            // on OpenAI-compatible gateways that mirror that behaviour).
726            if attachments.is_empty() {
727                json!({ "role": "user", "content": content })
728            } else {
729                // Mixed multimodal — promote `content` to an array of
730                // OpenAI vision parts. Text always lands first (the
731                // common chat ordering); image_url parts follow. Empty
732                // text is dropped — OpenAI accepts arrays with only
733                // image parts.
734                let mut parts: Vec<Value> = Vec::with_capacity(attachments.len() + 1);
735                if !content.is_empty() {
736                    parts.push(json!({ "type": "text", "text": content }));
737                }
738                for att in attachments {
739                    match att {
740                        UserAttachment::Image(src) => {
741                            parts.push(image_to_openai_part(src));
742                        }
743                    }
744                }
745                json!({ "role": "user", "content": parts })
746            }
747        }
748        ChatMessage::Assistant {
749            text,
750            tool_calls,
751            thinking: _,
752            usage: _,
753        } => {
754            // OpenAI chat/completions has no equivalent of Anthropic
755            // thinking blocks; we drop the field here. The Anthropic
756            // projection (chat_messages_to_anthropic_messages) consumes
757            // it.
758            let mut obj = json!({ "role": "assistant" });
759            if let Some(t) = text.as_deref().filter(|s| !s.is_empty()) {
760                obj["content"] = json!(t);
761            } else {
762                obj["content"] = Value::Null;
763            }
764            if !tool_calls.is_empty() {
765                let calls: Vec<Value> = tool_calls
766                    .iter()
767                    .map(|tc| {
768                        json!({
769                            "id": tc.id,
770                            "type": "function",
771                            "function": {
772                                "name": tc.name,
773                                "arguments": tool_invocation_args_for_wire(tc),
774                            },
775                        })
776                    })
777                    .collect();
778                obj["tool_calls"] = json!(calls);
779            }
780            obj
781        }
782        ChatMessage::Tool {
783            tool_call_id,
784            content,
785            attachments,
786            is_error: _,
787        } => {
788            // OpenAI tool role is strictly string-typed (no content
789            // block array). Non-text attachments (e.g. image returned
790            // by an MCP screenshot tool) can't ride here — we surface
791            // them as a placeholder appended to `content` so the model
792            // is at least aware something visual was attached. Lossy
793            // by design; if the agent needs to see the image, use the
794            // Anthropic provider.
795            let mut content_str = compact_tool_result_for_replay(content).into_owned();
796            for att in attachments {
797                let UserAttachment::Image(src) = att;
798                content_str.push_str(&format!(
799                    "\n[image attached: {} (not visible via OpenAI tool role)]",
800                    src.media_type
801                ));
802            }
803            json!({
804                "role": "tool",
805                "tool_call_id": tool_call_id,
806                "content": content_str,
807            })
808        }
809    }
810}
811
812#[async_trait]
813impl ModelClient for OpenAiCompatibleModelClient {
814    fn hosted_capability(&self, _capability: HostedCapability) -> CapabilitySupport {
815        // Chat Completions has no portable hosted-tool contract. A gateway
816        // calling itself OpenAI-compatible is not evidence that Responses
817        // hosted tools are accepted.
818        CapabilitySupport::Unsupported
819    }
820
821    async fn stream(
822        &self,
823        input: ModelTurnInput,
824    ) -> Result<BoxStream<'static, Result<ModelChunk, ModelClientError>>, ModelClientError> {
825        if !input.hosted_tools.is_empty() {
826            return Err(ModelClientError::Other(
827                "hosted tools are not supported by OpenAiCompatibleModelClient; \
828                 OpenAI web_search requires a Responses API client"
829                    .into(),
830            ));
831        }
832        // Bolt-on streaming flags. `stream_options.include_usage` is a
833        // recent OpenAI addition that makes the final SSE event carry the
834        // usage block; without it streaming responses drop usage entirely.
835        // Compat gateways (DeepSeek, Groq, etc.) mostly accept the field
836        // and either honour it or ignore — passing it is forward-safe.
837        let mut body = self.request_body(&input);
838        body["stream"] = json!(true);
839        body["stream_options"] = json!({ "include_usage": true });
840
841        let resp = match self
842            .http
843            .post(self.endpoint())
844            .bearer_auth(&self.config.api_key)
845            .json(&body)
846            .send()
847            .await
848        {
849            Ok(r) => r,
850            Err(e) => return Err(classify_reqwest_error(&e, e.to_string())),
851        };
852        let status = resp.status();
853        if !status.is_success() {
854            let body_text = resp.text().await.unwrap_or_default();
855            return Err(classify_openai_http_error(status, &body_text));
856        }
857
858        // bytes_stream → SSE event_stream → ModelChunk stream. The SSE
859        // parser handles UTF-8 boundaries, multi-line `data:` reassembly,
860        // and the `[DONE]` sentinel; we layer ModelChunk extraction on
861        // top with an explicit state machine because OpenAI ships `id`
862        // / `name` only on the first delta of each tool call.
863        let event_stream = resp.bytes_stream().eventsource();
864        let (tx, rx) = tokio::sync::mpsc::channel::<Result<ModelChunk, ModelClientError>>(8);
865
866        tokio::spawn(async move {
867            let mut state = OpenAiStreamState::default();
868            futures::pin_mut!(event_stream);
869            while let Some(ev) = event_stream.next().await {
870                let chunks = match ev {
871                    Ok(event) => match state.feed_data(&event.data) {
872                        Ok(c) => c,
873                        Err(e) => {
874                            let _ = tx.send(Err(e)).await;
875                            return;
876                        }
877                    },
878                    Err(e) => {
879                        let _ = tx
880                            .send(Err(ModelClientError::Network(format!(
881                                "SSE transport error: {e}"
882                            ))))
883                            .await;
884                        return;
885                    }
886                };
887                for c in chunks {
888                    if tx.send(Ok(c)).await.is_err() {
889                        return;
890                    }
891                }
892            }
893            // Stream closed. Distinguish a clean end from a premature cut-off:
894            //   * clean  — we saw `[DONE]` or a `finish_reason`. Emit the final
895            //     Done (covers gateways that close without `[DONE]` but DO send
896            //     a finish_reason).
897            //   * cut off — neither marker arrived. The connection dropped mid
898            //     response (proxy/idle timeout, upstream truncation). Surfacing
899            //     this as a (retryable) error instead of fabricating a Done is
900            //     critical: otherwise a truncated answer is silently accepted as
901            //     complete, and the user sees a half-finished reply with no hint
902            //     anything went wrong.
903            if state.ended_cleanly() {
904                if let Some(final_chunk) = state.finalize() {
905                    let _ = tx.send(Ok(final_chunk)).await;
906                }
907            } else {
908                let _ = tx
909                    .send(Err(ModelClientError::Network(
910                        "model stream closed before completion (no finish_reason or [DONE]) \
911                         — connection dropped or upstream truncated the response"
912                            .into(),
913                    )))
914                    .await;
915            }
916        });
917
918        Ok(tokio_stream::wrappers::ReceiverStream::new(rx).boxed())
919    }
920}
921
922/// State machine that turns OpenAI chat.completion.chunk SSE events into
923/// `ModelChunk` stream items. Lives outside the trait impl so the parsing
924/// logic is unit-testable without standing up an HTTP server.
925#[derive(Debug, Default)]
926struct OpenAiStreamState {
927    /// Carries the assistant message id forward from the first chunk that
928    /// supplied it (OpenAI uses `chatcmpl-...`). Falls back to a synthetic
929    /// id if the provider doesn't send one — text deltas without an id
930    /// would otherwise be dropped on the floor by `native_adapter`'s chunk
931    /// accumulator.
932    msg_id: Option<String>,
933    /// Maps the OpenAI `tool_calls[i].index` to the eventual call id so
934    /// subsequent `arguments` deltas can route to the right buffer.
935    tool_call_by_index: std::collections::HashMap<u64, String>,
936    finish_reason: Option<String>,
937    pending_usage: Option<HarnessUsage>,
938    /// Set once `[DONE]` or a finish_reason chunk lands; suppresses the
939    /// duplicate `Done` we'd otherwise emit from `finalize()`.
940    done_emitted: bool,
941}
942
943impl OpenAiStreamState {
944    fn feed_data(&mut self, data: &str) -> Result<Vec<ModelChunk>, ModelClientError> {
945        // `[DONE]` is OpenAI's end-of-stream sentinel; some compat gateways
946        // also emit it, others just close. Either path lands in finalize().
947        if data.trim() == "[DONE]" {
948            if let Some(done) = self.emit_done() {
949                return Ok(vec![done]);
950            }
951            return Ok(vec![]);
952        }
953        let value: Value = serde_json::from_str(data)
954            .map_err(|e| ModelClientError::Other(format!("SSE data not JSON: {e}; raw={data}")))?;
955
956        let mut out: Vec<ModelChunk> = Vec::new();
957
958        // The final chunk on `stream_options.include_usage=true` arrives
959        // with empty `choices` and a populated `usage` block.
960        if let Some(usage) = parse_openai_usage(value.get("usage")) {
961            self.pending_usage = Some(usage);
962        }
963
964        if let Some(id) = value.get("id").and_then(|v| v.as_str()) {
965            if self.msg_id.is_none() && !id.is_empty() {
966                self.msg_id = Some(id.to_string());
967            }
968        }
969
970        let Some(choices) = value.get("choices").and_then(|v| v.as_array()) else {
971            return Ok(out);
972        };
973        let Some(choice) = choices.first() else {
974            return Ok(out);
975        };
976        let Some(delta) = choice.get("delta") else {
977            // Some gateways send a usage-only chunk with no delta — fine.
978            if let Some(reason) = choice.get("finish_reason").and_then(|v| v.as_str()) {
979                self.finish_reason = Some(reason.to_string());
980            }
981            return Ok(out);
982        };
983
984        // Text token delta. Falls back to "msg_native_default" if the
985        // provider hasn't emitted a chunk id; `native_adapter`'s
986        // accumulator groups deltas by id so we must keep it stable.
987        if let Some(text) = delta.get("content").and_then(|v| v.as_str()) {
988            if !text.is_empty() {
989                let msg_id = self
990                    .msg_id
991                    .clone()
992                    .unwrap_or_else(|| "msg_native_default".to_string());
993                out.push(ModelChunk::TextDelta {
994                    msg_id,
995                    delta: text.to_string(),
996                });
997            }
998        }
999
1000        // Tool call deltas. Each entry in `tool_calls[]` has an `index`
1001        // that's stable across chunks; the first chunk for a given index
1002        // carries `id` + `function.name`, later chunks just stream
1003        // `function.arguments`. We route every arguments delta through
1004        // the index→id map.
1005        if let Some(tcs) = delta.get("tool_calls").and_then(|v| v.as_array()) {
1006            for tc in tcs {
1007                let index = tc.get("index").and_then(|v| v.as_u64()).unwrap_or(0);
1008                if let Some(id) = tc.get("id").and_then(|v| v.as_str()) {
1009                    if !id.is_empty() {
1010                        self.tool_call_by_index.insert(index, id.to_string());
1011                        let name = tc
1012                            .get("function")
1013                            .and_then(|f| f.get("name"))
1014                            .and_then(|v| v.as_str())
1015                            .unwrap_or("")
1016                            .to_string();
1017                        out.push(ModelChunk::ToolCallStart {
1018                            id: id.to_string(),
1019                            name,
1020                        });
1021                    }
1022                }
1023                // Streaming argument bytes — concatenation across chunks
1024                // forms a single JSON object string. Handed back as a
1025                // delta so collect_model_response (or any other folder)
1026                // can accumulate.
1027                if let Some(args) = tc
1028                    .get("function")
1029                    .and_then(|f| f.get("arguments"))
1030                    .and_then(|v| v.as_str())
1031                {
1032                    if let Some(id) = self.tool_call_by_index.get(&index).cloned() {
1033                        if !args.is_empty() {
1034                            out.push(ModelChunk::ToolCallInputDelta {
1035                                id,
1036                                delta: args.to_string(),
1037                            });
1038                        }
1039                    }
1040                }
1041            }
1042        }
1043
1044        if let Some(reason) = choice.get("finish_reason").and_then(|v| v.as_str()) {
1045            self.finish_reason = Some(reason.to_string());
1046            // OpenAI's tool-call streaming ends with finish_reason="tool_calls";
1047            // emit a `ToolCallEnd` for each open tool call (without a parsed
1048            // input — `collect_model_response` will parse the accumulated
1049            // buffer if it needs the value).
1050            if reason == "tool_calls" {
1051                for (_idx, id) in self.tool_call_by_index.iter() {
1052                    out.push(ModelChunk::ToolCallEnd {
1053                        id: id.clone(),
1054                        input: None,
1055                    });
1056                }
1057            }
1058            // Some gateways then close the stream without `[DONE]`. We
1059            // could emit Done eagerly here, but to handle the include_usage
1060            // case (usage arrives in a later chunk), defer to finalize().
1061        }
1062
1063        Ok(out)
1064    }
1065
1066    fn finalize(&mut self) -> Option<ModelChunk> {
1067        self.emit_done()
1068    }
1069
1070    /// True once we've observed a legitimate end-of-response signal — either
1071    /// the `[DONE]` sentinel (sets `done_emitted`) or a chunk carrying a
1072    /// `finish_reason` (e.g. `stop` / `length` / `tool_calls`). When a stream
1073    /// closes WITHOUT either, the response was cut off mid-flight (dropped
1074    /// connection, proxy/idle timeout, upstream truncation) and must NOT be
1075    /// treated as a complete answer.
1076    fn ended_cleanly(&self) -> bool {
1077        self.done_emitted || self.finish_reason.is_some()
1078    }
1079
1080    fn emit_done(&mut self) -> Option<ModelChunk> {
1081        if self.done_emitted {
1082            return None;
1083        }
1084        self.done_emitted = true;
1085        let stop_reason = map_openai_finish_reason(self.finish_reason.as_deref());
1086        Some(ModelChunk::Done {
1087            stop_reason,
1088            usage: self.pending_usage.take(),
1089        })
1090    }
1091}
1092
1093/// Map OpenAI's `finish_reason` to the harness's stop_reason vocabulary
1094/// used by `dispatch::map_stop_reason`. Unknown / null values fall back
1095/// to "end_turn" (the model produced something), not "unknown_stop_reason"
1096/// (which `dispatch::map_stop_reason` would otherwise classify as
1097/// RUNTIME_ERROR — wrong here, the call succeeded).
1098fn map_openai_finish_reason(reason: Option<&str>) -> String {
1099    match reason {
1100        Some("stop") => "end_turn".into(),
1101        Some("length") => "max_tokens".into(),
1102        Some("tool_calls") => "end_turn".into(),
1103        Some("content_filter") => "refusal".into(),
1104        Some(other) if !other.is_empty() => other.to_string(),
1105        _ => "end_turn".into(),
1106    }
1107}
1108
1109/// Bucket an HTTP error into the right `ModelClientError` variant. Looks
1110/// at status code first; falls back to the response body for the trickier
1111/// case (BadRequest could be context overflow, malformed prompt, or just
1112/// a bad parameter).
1113fn classify_openai_http_error(status: reqwest::StatusCode, body: &str) -> ModelClientError {
1114    use reqwest::StatusCode;
1115    let snippet = body.chars().take(512).collect::<String>();
1116
1117    // Retryable errors
1118    if status == StatusCode::TOO_MANY_REQUESTS {
1119        return ModelClientError::RateLimit(format!("HTTP {status}: {snippet}"));
1120    }
1121    if status.is_server_error() {
1122        // 5xx are transient — proxy overloaded, upstream error, etc.
1123        return ModelClientError::ServerError(format!("HTTP {status}: {snippet}"));
1124    }
1125
1126    // Non-retryable config/client errors
1127    if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
1128        return ModelClientError::Auth(format!("HTTP {status}: {snippet}"));
1129    }
1130    if status == StatusCode::BAD_REQUEST && looks_like_context_overflow(body) {
1131        return ModelClientError::ContextOverflow(format!("HTTP {status}: {snippet}"));
1132    }
1133    if status == StatusCode::BAD_REQUEST {
1134        // "Invalid model name", missing required fields, etc. — config error.
1135        return ModelClientError::BadRequest(format!("HTTP {status}: {snippet}"));
1136    }
1137
1138    ModelClientError::Other(format!("HTTP {status}: {snippet}"))
1139}
1140
1141/// Heuristic match against the common phrasings OpenAI-compatible
1142/// providers use to flag "this prompt is too long for the model". Different
1143/// gateways word it differently; we widen the net by lowercasing + token
1144/// search rather than trying to JSON-parse the body (which may be a
1145/// non-JSON HTML 400 from some proxies).
1146fn looks_like_context_overflow(body: &str) -> bool {
1147    let lower = body.to_lowercase();
1148    lower.contains("context length")
1149        || lower.contains("maximum context")
1150        || lower.contains("context_length_exceeded")
1151        || lower.contains("too many tokens")
1152        || lower.contains("exceeds the model")
1153}
1154
1155/// Map a `reqwest::Error` to either `Network` (transport-layer / DNS /
1156/// connect / timeout / body) or `Other` (everything else — e.g. invalid
1157/// URL built locally, which is a programmer bug rather than transient).
1158fn classify_reqwest_error(err: &reqwest::Error, msg: String) -> ModelClientError {
1159    if err.is_connect() || err.is_timeout() || err.is_request() || err.is_body() {
1160        ModelClientError::Network(msg)
1161    } else {
1162        ModelClientError::Other(msg)
1163    }
1164}
1165
1166/// In-process model client used by tests / dev. Inspects the most recent
1167/// `User` message in `input.messages` and either fires a deterministic
1168/// tool call (if a prior `Tool` result hasn't landed yet) or emits a
1169/// summary text reply (once it has).
1170///
1171/// Implements `stream` natively so the agent loop exercises the
1172/// streaming code path even in tests; the chunk sequence it produces
1173/// matches what `OpenAiCompatibleModelClient` would emit for the same
1174/// logical response.
1175#[derive(Debug, Default, Clone)]
1176pub struct ScriptedModelClient;
1177
1178#[async_trait]
1179impl ModelClient for ScriptedModelClient {
1180    fn hosted_capability(&self, _capability: HostedCapability) -> CapabilitySupport {
1181        CapabilitySupport::Unsupported
1182    }
1183
1184    async fn stream(
1185        &self,
1186        input: ModelTurnInput,
1187    ) -> Result<BoxStream<'static, Result<ModelChunk, ModelClientError>>, ModelClientError> {
1188        let chunks = scripted_chunks_for(&input);
1189        let stream = futures::stream::iter(chunks.into_iter().map(Ok));
1190        Ok(stream.boxed())
1191    }
1192}
1193
1194/// Decide which `ModelChunk` sequence the scripted client would emit for a
1195/// given `ModelTurnInput`. Same heuristics as the old `next()` impl, just
1196/// rendered as a stream so tests that expect tool→summary→done chunking
1197/// see the right shape.
1198fn scripted_chunks_for(input: &ModelTurnInput) -> Vec<ModelChunk> {
1199    // If we already have a tool result in history, produce the final
1200    // summary message — text-only chunk followed by Done.
1201    let last_tool = input.messages.iter().rev().find_map(|m| match m {
1202        ChatMessage::Tool {
1203            tool_call_id,
1204            content,
1205            is_error,
1206            ..
1207        } => Some((tool_call_id.clone(), content.clone(), *is_error)),
1208        _ => None,
1209    });
1210    if let Some((id, content, is_error)) = last_tool {
1211        let summary = if is_error {
1212            format!("tool {id} failed: {content}")
1213        } else {
1214            format!("tool {id} completed: {content}")
1215        };
1216        return vec![
1217            ModelChunk::TextDelta {
1218                msg_id: "scripted_msg".into(),
1219                delta: summary,
1220            },
1221            ModelChunk::Done {
1222                stop_reason: "end_turn".into(),
1223                usage: None,
1224            },
1225        ];
1226    }
1227
1228    // Otherwise look at the latest user prompt and pick a tool.
1229    let user_prompt = input
1230        .messages
1231        .iter()
1232        .rev()
1233        .find_map(|m| match m {
1234            ChatMessage::User { content, .. } => Some(content.clone()),
1235            _ => None,
1236        })
1237        .unwrap_or_default();
1238    let prompt = user_prompt.trim();
1239    let (id, name, args) = if let Some(path) = prompt.strip_prefix("read ") {
1240        ("tc_read_1", "read", json!({"path": path.trim()}))
1241    } else if let Some(rest) = prompt.strip_prefix("write ") {
1242        let (path, content) = rest.split_once(' ').unwrap_or((rest, ""));
1243        (
1244            "tc_write_1",
1245            "write",
1246            json!({"path": path.trim(), "content": content}),
1247        )
1248    } else {
1249        ("tc_bash_1", "bash", json!({"command": prompt}))
1250    };
1251
1252    vec![
1253        ModelChunk::TextDelta {
1254            msg_id: "scripted_msg".into(),
1255            delta: format!("native model selected tool: {name}"),
1256        },
1257        ModelChunk::ToolCallStart {
1258            id: id.into(),
1259            name: name.into(),
1260        },
1261        ModelChunk::ToolCallEnd {
1262            id: id.into(),
1263            input: Some(args),
1264        },
1265        ModelChunk::Done {
1266            stop_reason: "end_turn".into(),
1267            usage: None,
1268        },
1269    ]
1270}
1271
1272// ─── Anthropic Messages API ──────────────────────────────────────────────
1273//
1274// Streaming Messages API differs from OpenAI chat/completions in three
1275// load-bearing places:
1276//
1277//   1. Endpoint + auth   POST /v1/messages with `x-api-key` +
1278//                        `anthropic-version: 2023-06-01`, no Bearer.
1279//   2. Body              `system` is a top-level field (not a chat role);
1280//                        `tool_result` lives as a content block inside the
1281//                        next user message (not a separate `tool` role);
1282//                        `max_tokens` is required.
1283//   3. SSE shape         Uses both `event:` and `data:` lines. Event types
1284//                        partition the chunk types we care about, and tool
1285//                        arguments arrive as `input_json_delta` strings
1286//                        that concatenate into a single JSON object.
1287//
1288// All of the work in this section is wire-shape translation; the harness
1289// loop and `ModelChunk` enum stay provider-agnostic.
1290
1291/// Deployment-side credentials + endpoint for the Anthropic Messages API.
1292/// `max_tokens` is required by Anthropic on every request, so we hold it
1293/// here (default below) rather than relying on the agent recipe.
1294#[derive(Debug, Clone)]
1295pub struct AnthropicConfig {
1296    pub base_url: String,
1297    pub api_key: String,
1298    pub model: ResolvedModelConfig,
1299    /// Wire-format version pin. Defaults to "2023-06-01" — the only one
1300    /// supported by Messages API at time of writing. Override if Anthropic
1301    /// ever publishes a newer one we want to opt into.
1302    pub anthropic_version: String,
1303}
1304
1305impl AnthropicConfig {
1306    /// Default `anthropic-version` header value the client sends if HR
1307    /// doesn't override it.
1308    pub const DEFAULT_VERSION: &'static str = "2023-06-01";
1309}
1310
1311#[derive(Debug, Clone)]
1312pub struct AnthropicModelClient {
1313    http: reqwest::Client,
1314    config: AnthropicConfig,
1315}
1316
1317impl AnthropicModelClient {
1318    pub fn new(config: AnthropicConfig) -> Self {
1319        assert_eq!(
1320            config.model.wire_protocol,
1321            WireProtocol::Anthropic,
1322            "resolved model protocol must match AnthropicModelClient"
1323        );
1324        let http = reqwest::Client::builder()
1325            .connect_timeout(std::time::Duration::from_secs(15))
1326            .build()
1327            .unwrap_or_else(|_| reqwest::Client::new());
1328        Self { http, config }
1329    }
1330
1331    fn endpoint(&self) -> String {
1332        // `base_url` is the full API prefix supplied by the caller, including
1333        // the version segment — e.g. `https://api.anthropic.com/v1`. We only
1334        // append the route; picking the version is the caller's job.
1335        let base = self.config.base_url.trim_end_matches('/');
1336        if base.ends_with("/messages") {
1337            base.to_string()
1338        } else {
1339            format!("{base}/messages")
1340        }
1341    }
1342
1343    /// Build the JSON body for `POST /v1/messages`. Mirrors `OpenAi`'s
1344    /// `request_body` but projects to the Messages API wire shape — see
1345    /// the section header for the differences.
1346    fn request_body(&self, input: &ModelTurnInput) -> Value {
1347        let messages = chat_messages_to_anthropic_messages(&input.messages);
1348        // `tool_choice::None` has no direct Anthropic equivalent
1349        // (Anthropic forces tool consideration once `tools` is non-
1350        // empty). Best approximation is to drop the tools entirely.
1351        let tools = if matches!(input.tool_choice, ToolChoice::None) {
1352            Vec::new()
1353        } else {
1354            let mut tools = input
1355                .tools
1356                .iter()
1357                .map(tool_spec_to_anthropic_tool)
1358                .collect::<Vec<_>>();
1359            tools.extend(input.hosted_tools.iter().map(hosted_tool_to_anthropic_tool));
1360            tools
1361        };
1362        let system_field = anthropic_system_field(input.system_prompt.as_deref());
1363
1364        // Cache strategy is applied last so it can decorate the final
1365        // serialized shape. Builds up to 4 ephemeral breakpoints (system,
1366        // last tool, last message, optionally mid message for long chats).
1367        let cached = apply_anthropic_cache_strategy(system_field, tools, messages);
1368
1369        let mut body = json!({
1370            "model": self.config.model.model,
1371            "max_tokens": self.config.model.max_output_tokens,
1372            "messages": cached.messages,
1373            "stream": true,
1374        });
1375        if let Some(sys) = cached.system {
1376            body["system"] = sys;
1377        }
1378        if !cached.tools.is_empty() {
1379            body["tools"] = json!(cached.tools);
1380            // tool_choice only meaningful when we're actually sending
1381            // tools; Auto is Anthropic's default so we omit it to keep
1382            // the wire body byte-identical for the common case
1383            // (matters for prompt cache stability).
1384            if !matches!(input.tool_choice, ToolChoice::Auto) {
1385                body["tool_choice"] = anthropic_tool_choice_value(&input.tool_choice);
1386            }
1387        }
1388        // Anthropic doesn't support `parallel_tool_calls` — it's an
1389        // OpenAI-only knob. We silently ignore input.parallel_tool_calls
1390        // on this provider. (Anthropic returns multiple tool_use blocks
1391        // freely when the model decides to.)
1392        if let Some(t) = self.config.model.temperature {
1393            body["temperature"] = json!(t);
1394        }
1395        apply_anthropic_reasoning(&mut body, &self.config.model);
1396        body
1397    }
1398}
1399
1400fn apply_anthropic_reasoning(body: &mut Value, model: &ResolvedModelConfig) {
1401    if matches!(model.reasoning.mode, ReasoningMode::Default) {
1402        return;
1403    }
1404    if matches!(model.reasoning.mode, ReasoningMode::Disabled) {
1405        body["thinking"] = json!({"type": "disabled"});
1406        return;
1407    }
1408    if let Some(tokens) = model.reasoning.budget_tokens {
1409        body["thinking"] = json!({"type": "enabled", "budget_tokens": tokens});
1410        return;
1411    }
1412    if let Some(effort) = model.reasoning.effort.as_deref() {
1413        body["thinking"] = json!({"type": "adaptive"});
1414        body["output_config"] = json!({"effort": effort});
1415        return;
1416    }
1417    body["thinking"] = json!({"type": "enabled"});
1418}
1419
1420fn anthropic_tool_choice_value(c: &ToolChoice) -> Value {
1421    match c {
1422        ToolChoice::Auto => json!({"type": "auto"}),
1423        ToolChoice::None => json!({"type": "auto"}), // unreachable in practice (caller drops tools)
1424        ToolChoice::Required => json!({"type": "any"}),
1425        ToolChoice::Tool(name) => json!({"type": "tool", "name": name}),
1426    }
1427}
1428
1429#[async_trait]
1430impl ModelClient for AnthropicModelClient {
1431    fn hosted_capability(&self, capability: HostedCapability) -> CapabilitySupport {
1432        match capability {
1433            HostedCapability::WebSearch => {
1434                official_endpoint_support(&self.config.base_url, &["api.anthropic.com"])
1435            }
1436        }
1437    }
1438
1439    async fn stream(
1440        &self,
1441        input: ModelTurnInput,
1442    ) -> Result<BoxStream<'static, Result<ModelChunk, ModelClientError>>, ModelClientError> {
1443        let resp = match self
1444            .http
1445            .post(self.endpoint())
1446            .header("x-api-key", &self.config.api_key)
1447            .header("anthropic-version", &self.config.anthropic_version)
1448            .header("content-type", "application/json")
1449            .json(&self.request_body(&input))
1450            .send()
1451            .await
1452        {
1453            Ok(r) => r,
1454            Err(e) => return Err(classify_reqwest_error(&e, e.to_string())),
1455        };
1456        let status = resp.status();
1457        if !status.is_success() {
1458            let body_text = resp.text().await.unwrap_or_default();
1459            return Err(classify_anthropic_http_error(status, &body_text));
1460        }
1461
1462        let event_stream = resp.bytes_stream().eventsource();
1463        let (tx, rx) = tokio::sync::mpsc::channel::<Result<ModelChunk, ModelClientError>>(8);
1464        tokio::spawn(async move {
1465            let mut state = AnthropicStreamState::default();
1466            futures::pin_mut!(event_stream);
1467            while let Some(ev) = event_stream.next().await {
1468                let chunks = match ev {
1469                    Ok(event) => match state.feed_event(&event.event, &event.data) {
1470                        Ok(c) => c,
1471                        Err(e) => {
1472                            let _ = tx.send(Err(e)).await;
1473                            return;
1474                        }
1475                    },
1476                    Err(e) => {
1477                        let _ = tx
1478                            .send(Err(ModelClientError::Network(format!(
1479                                "SSE transport error: {e}"
1480                            ))))
1481                            .await;
1482                        return;
1483                    }
1484                };
1485                for c in chunks {
1486                    if tx.send(Ok(c)).await.is_err() {
1487                        return;
1488                    }
1489                }
1490            }
1491            if let Some(done) = state.finalize() {
1492                let _ = tx.send(Ok(done)).await;
1493            }
1494        });
1495        Ok(tokio_stream::wrappers::ReceiverStream::new(rx).boxed())
1496    }
1497}
1498
1499/// Render `ChatMessage[]` into Anthropic Messages API `messages[]`. The
1500/// projection has two non-trivial rules:
1501///
1502///   1. `ChatMessage::Tool` doesn't have a dedicated role on Anthropic —
1503///      tool results live as content blocks on the user message that
1504///      follows them. We buffer pending tool results and flush them
1505///      into the next user message (or a synthetic user message at the
1506///      end if the conversation ends on tool results).
1507///   2. `ChatMessage::Assistant` may carry `thinking`, plain `text`, and
1508///      tool calls; all three render as content blocks under one
1509///      assistant message in that exact order (thinking → text →
1510///      tool_use). Anthropic API rejects modified thinking blocks, so
1511///      we round-trip the signature verbatim.
1512fn chat_messages_to_anthropic_messages(messages: &[ChatMessage]) -> Vec<Value> {
1513    let mut out: Vec<Value> = Vec::with_capacity(messages.len());
1514    let mut pending_tool_results: Vec<Value> = Vec::new();
1515
1516    let flush_tool_results = |bucket: &mut Vec<Value>, out: &mut Vec<Value>| {
1517        if !bucket.is_empty() {
1518            let blocks = std::mem::take(bucket);
1519            out.push(json!({"role": "user", "content": blocks}));
1520        }
1521    };
1522
1523    for msg in messages {
1524        match msg {
1525            ChatMessage::User {
1526                content,
1527                attachments,
1528            } => {
1529                // Build the content block list in this order:
1530                //   1. flushed tool_result blocks (if any pending)
1531                //   2. text block (when content non-empty)
1532                //   3. image blocks per attachment, in original order
1533                //
1534                // Anthropic requires the user message body to alternate
1535                // with assistant, so when tool_results are pending they
1536                // merge into THIS user message (saving an extra hop).
1537                let mut blocks: Vec<Value> = std::mem::take(&mut pending_tool_results);
1538                if !content.is_empty() {
1539                    blocks.push(json!({"type":"text","text":content}));
1540                }
1541                for att in attachments {
1542                    match att {
1543                        UserAttachment::Image(src) => {
1544                            blocks.push(image_to_anthropic_block(src));
1545                        }
1546                    }
1547                }
1548                // Defensive: if everything was empty (no tool_results,
1549                // empty content, no attachments), still emit a single
1550                // empty text block — Anthropic API rejects empty
1551                // `content` arrays.
1552                if blocks.is_empty() {
1553                    blocks.push(json!({"type":"text","text":""}));
1554                }
1555                out.push(json!({"role": "user", "content": blocks}));
1556            }
1557            ChatMessage::Assistant {
1558                text,
1559                tool_calls,
1560                thinking,
1561                usage: _,
1562            } => {
1563                // tool_results must be flushed before we can append an
1564                // assistant message (Anthropic rejects two consecutive
1565                // assistant messages).
1566                flush_tool_results(&mut pending_tool_results, &mut out);
1567                let mut blocks: Vec<Value> = Vec::new();
1568                // Thinking first — both as observed in Anthropic's own
1569                // serialisations and as a stable position for cache
1570                // breakpoint placement.
1571                if let Some(t) = thinking {
1572                    let mut tb = json!({"type": "thinking", "thinking": t.text});
1573                    if let Some(sig) = t.signature.as_deref() {
1574                        if !sig.is_empty() {
1575                            tb["signature"] = json!(sig);
1576                        }
1577                    }
1578                    blocks.push(tb);
1579                }
1580                if let Some(t) = text.as_deref() {
1581                    if !t.is_empty() {
1582                        blocks.push(json!({"type": "text", "text": t}));
1583                    }
1584                }
1585                for tc in tool_calls {
1586                    blocks.push(json!({
1587                        "type": "tool_use",
1588                        "id": tc.id,
1589                        "name": tc.name,
1590                        "input": tc.input,
1591                    }));
1592                }
1593                if blocks.is_empty() {
1594                    // Pathological case — assistant turn with nothing
1595                    // to project. Anthropic rejects empty content arrays,
1596                    // so skip the message entirely.
1597                    continue;
1598                }
1599                out.push(json!({"role": "assistant", "content": blocks}));
1600            }
1601            ChatMessage::Tool {
1602                tool_call_id,
1603                content,
1604                is_error,
1605                attachments,
1606            } => {
1607                // Anthropic tool_result CAN carry image content blocks
1608                // (in addition to text). Render text first, then any
1609                // image attachments — same `text → image` ordering used
1610                // for the user message projection (§9 in internals doc).
1611                let mut blocks: Vec<Value> = Vec::new();
1612                if !content.is_empty() {
1613                    let replay = compact_tool_result_for_replay(content);
1614                    blocks.push(json!({"type": "text", "text": replay}));
1615                }
1616                for att in attachments {
1617                    let UserAttachment::Image(src) = att;
1618                    blocks.push(image_to_anthropic_block(src));
1619                }
1620                // tool_result.content can be either a string or a block
1621                // array per Anthropic docs. We use the array form
1622                // uniformly so attachments-or-not codepath is one.
1623                if blocks.is_empty() {
1624                    blocks.push(json!({"type": "text", "text": ""}));
1625                }
1626                pending_tool_results.push(json!({
1627                    "type": "tool_result",
1628                    "tool_use_id": tool_call_id,
1629                    "content": blocks,
1630                    "is_error": is_error,
1631                }));
1632            }
1633        }
1634    }
1635
1636    // Conversation ended on tool results without a follow-up user message
1637    // — synthesize one so Anthropic sees the results.
1638    flush_tool_results(&mut pending_tool_results, &mut out);
1639    out
1640}
1641
1642/// Anthropic accepts `system` as either a string OR a content-block
1643/// array (the latter lets us put `cache_control` on it). We always
1644/// pre-shape it as the array form so the cache strategy layer can apply
1645/// markers without re-allocating. `None` ⇒ no system field on the wire.
1646fn anthropic_system_field(prompt: Option<&str>) -> Option<Value> {
1647    let s = prompt?.trim();
1648    if s.is_empty() {
1649        return None;
1650    }
1651    Some(json!([{"type": "text", "text": s}]))
1652}
1653
1654/// Render a `ToolSpec` into Anthropic's tool definition shape. Same
1655/// `input_schema` field both providers use, just different envelope.
1656/// Project an `ImageSource` to an Anthropic `image` content block.
1657/// Anthropic accepts two source shapes:
1658///   * `{type:"base64", media_type, data}` for inline bytes
1659///   * `{type:"url", url}` for fetched-by-server URLs (added 2024)
1660fn image_to_anthropic_block(src: &ImageSource) -> Value {
1661    let source = match &src.data {
1662        ImageData::Base64(b64) => json!({
1663            "type": "base64",
1664            "media_type": src.media_type,
1665            "data": b64,
1666        }),
1667        ImageData::Url(url) => json!({
1668            "type": "url",
1669            "url": url,
1670        }),
1671    };
1672    json!({"type": "image", "source": source})
1673}
1674
1675fn tool_spec_to_anthropic_tool(spec: &ToolSpec) -> Value {
1676    json!({
1677        "name": spec.name,
1678        "description": spec.description,
1679        "input_schema": spec.input_schema,
1680    })
1681}
1682
1683fn hosted_tool_to_anthropic_tool(tool: &HostedTool) -> Value {
1684    match tool {
1685        HostedTool::WebSearch => json!({
1686            "type": "web_search_20250305",
1687            "name": "web_search",
1688        }),
1689    }
1690}
1691
1692struct AnthropicCached {
1693    system: Option<Value>,
1694    tools: Vec<Value>,
1695    messages: Vec<Value>,
1696}
1697
1698/// Apply up-to-four `cache_control: ephemeral` breakpoints to the
1699/// request, mirroring OMA `default-loop.ts:997-1039`. Anthropic rate-
1700/// limits cache writes to 4 breakpoints per request; we spend them on:
1701///
1702///   1. **System block (last content)** — caches everything before the
1703///      messages section (system + tools).
1704///   2. **Last tool definition** — defensive when system also has
1705///      dynamic content (a system change shouldn't bust the tools cache).
1706///   3. **Last message** — multi-turn chat tail breakpoint, where most
1707///      hits come from.
1708///   4. **Mid message** (only when `messages.len() > 30`) — Anthropic's
1709///      20-block lookback window means long single turns blow past the
1710///      tail breakpoint; the intermediate one catches reads inside the
1711///      turn.
1712///
1713/// Empty `system` skips its breakpoint (Anthropic API rejects
1714/// `cache_control` on empty text blocks).
1715fn apply_anthropic_cache_strategy(
1716    system: Option<Value>,
1717    tools: Vec<Value>,
1718    messages: Vec<Value>,
1719) -> AnthropicCached {
1720    let mut system = system;
1721    if let Some(sys) = system.as_mut() {
1722        if let Some(arr) = sys.as_array_mut() {
1723            if let Some(last) = arr.last_mut() {
1724                if last
1725                    .get("text")
1726                    .and_then(|v| v.as_str())
1727                    .map(|s| !s.is_empty())
1728                    .unwrap_or(false)
1729                {
1730                    last["cache_control"] = json!({"type": "ephemeral"});
1731                }
1732            }
1733        }
1734    }
1735
1736    let mut tools = tools;
1737    if let Some(last) = tools.last_mut() {
1738        last["cache_control"] = json!({"type": "ephemeral"});
1739    }
1740
1741    let mut messages = messages;
1742    // Last message tail breakpoint — applied to the last content block
1743    // of the last message (mirrors OMA's behaviour and matches what
1744    // Anthropic docs recommend for chat tails).
1745    if let Some(last) = messages.last_mut() {
1746        if let Some(blocks) = last.get_mut("content").and_then(|v| v.as_array_mut()) {
1747            if let Some(last_block) = blocks.last_mut() {
1748                last_block["cache_control"] = json!({"type": "ephemeral"});
1749            }
1750        }
1751    }
1752    // Mid breakpoint for long chats — same shape, applied at the message
1753    // sitting at the midpoint when there are >30 messages.
1754    if messages.len() > 30 {
1755        let mid = messages.len() / 2;
1756        if let Some(blocks) = messages[mid]
1757            .get_mut("content")
1758            .and_then(|v| v.as_array_mut())
1759        {
1760            if let Some(last_block) = blocks.last_mut() {
1761                last_block["cache_control"] = json!({"type": "ephemeral"});
1762            }
1763        }
1764    }
1765
1766    AnthropicCached {
1767        system,
1768        tools,
1769        messages,
1770    }
1771}
1772
1773/// Anthropic streaming uses `event:` to discriminate frame types. This
1774/// state machine maps the lifecycle into our `ModelChunk` enum.
1775///
1776/// Frames we care about (Anthropic docs § streaming):
1777///   * `message_start` — carries message id (msg_xxxxx) + initial usage.
1778///   * `content_block_start` — declares a new block at `index` with
1779///     `type: text | thinking | tool_use`. tool_use carries id + name.
1780///   * `content_block_delta` — `delta.type: text_delta | input_json_delta
1781///     | thinking_delta | signature_delta`.
1782///   * `content_block_stop` — block at index complete. For tool_use,
1783///     this is where we emit `ToolCallEnd`.
1784///   * `message_delta` — final stop_reason + output_tokens.
1785///   * `message_stop` — frame after which the connection closes.
1786///   * `ping` — keepalive, ignored.
1787///   * `error` — provider-side fault.
1788#[derive(Debug, Default)]
1789struct AnthropicStreamState {
1790    msg_id: Option<String>,
1791    /// Stream-local block index → kind + tool metadata.
1792    blocks: std::collections::HashMap<u64, AnthropicBlock>,
1793    stop_reason: Option<String>,
1794    pending_usage: Option<HarnessUsage>,
1795    done_emitted: bool,
1796}
1797
1798#[derive(Debug)]
1799enum AnthropicBlock {
1800    Text,
1801    Thinking { thinking_id: String },
1802    ToolUse { id: String },
1803    Ignored,
1804}
1805
1806impl AnthropicStreamState {
1807    fn feed_event(&mut self, event: &str, data: &str) -> Result<Vec<ModelChunk>, ModelClientError> {
1808        // Anthropic sends `ping` for keepalive — ignore. Unknown events
1809        // we also ignore (forward-compat with future event types).
1810        match event {
1811            "ping" | "" => return Ok(vec![]),
1812            "error" => {
1813                return Err(ModelClientError::Other(format!(
1814                    "anthropic stream error event: {data}"
1815                )));
1816            }
1817            _ => {}
1818        }
1819
1820        let value: Value = serde_json::from_str(data).map_err(|e| {
1821            ModelClientError::Other(format!(
1822                "anthropic SSE data not JSON (event={event}): {e}; raw={data}"
1823            ))
1824        })?;
1825        let mut out: Vec<ModelChunk> = Vec::new();
1826
1827        match event {
1828            "message_start" => {
1829                let msg = value.get("message");
1830                if let Some(id) = msg.and_then(|m| m.get("id")).and_then(|v| v.as_str()) {
1831                    if !id.is_empty() {
1832                        self.msg_id = Some(id.to_string());
1833                    }
1834                }
1835                if let Some(u) = msg.and_then(|m| m.get("usage")) {
1836                    self.pending_usage =
1837                        Some(merge_anthropic_usage(self.pending_usage.clone(), u, true));
1838                }
1839            }
1840            "content_block_start" => {
1841                let index = value.get("index").and_then(|v| v.as_u64()).unwrap_or(0);
1842                let block = value.get("content_block");
1843                let kind = block.and_then(|b| b.get("type")).and_then(|v| v.as_str());
1844                match kind {
1845                    Some("text") => {
1846                        self.blocks.insert(index, AnthropicBlock::Text);
1847                    }
1848                    Some("thinking") => {
1849                        let thinking_id = self
1850                            .msg_id
1851                            .clone()
1852                            .map(|m| format!("{m}_t{index}"))
1853                            .unwrap_or_else(|| format!("thinking_{index}"));
1854                        self.blocks
1855                            .insert(index, AnthropicBlock::Thinking { thinking_id });
1856                    }
1857                    Some("tool_use") => {
1858                        let id = block
1859                            .and_then(|b| b.get("id"))
1860                            .and_then(|v| v.as_str())
1861                            .unwrap_or_default()
1862                            .to_string();
1863                        let name = block
1864                            .and_then(|b| b.get("name"))
1865                            .and_then(|v| v.as_str())
1866                            .unwrap_or_default()
1867                            .to_string();
1868                        if !id.is_empty() && !name.is_empty() {
1869                            out.push(ModelChunk::ToolCallStart {
1870                                id: id.clone(),
1871                                name,
1872                            });
1873                        }
1874                        self.blocks.insert(index, AnthropicBlock::ToolUse { id });
1875                    }
1876                    Some("server_tool_use") | Some("web_search_tool_result") => {
1877                        self.blocks.insert(index, AnthropicBlock::Ignored);
1878                    }
1879                    _ => {
1880                        // Unknown block type — record as Text so we
1881                        // don't panic on later deltas; ignoring them
1882                        // is the safer forward-compat path.
1883                        self.blocks.insert(index, AnthropicBlock::Ignored);
1884                    }
1885                }
1886            }
1887            "content_block_delta" => {
1888                let index = value.get("index").and_then(|v| v.as_u64()).unwrap_or(0);
1889                let delta = match value.get("delta") {
1890                    Some(d) => d,
1891                    None => return Ok(out),
1892                };
1893                let delta_type = delta.get("type").and_then(|v| v.as_str()).unwrap_or("");
1894                match (self.blocks.get(&index), delta_type) {
1895                    (Some(AnthropicBlock::Text), "text_delta") => {
1896                        if let Some(text) = delta.get("text").and_then(|v| v.as_str()) {
1897                            if !text.is_empty() {
1898                                let msg_id = self
1899                                    .msg_id
1900                                    .clone()
1901                                    .unwrap_or_else(|| "msg_anthropic_default".into());
1902                                out.push(ModelChunk::TextDelta {
1903                                    msg_id,
1904                                    delta: text.to_string(),
1905                                });
1906                            }
1907                        }
1908                    }
1909                    (Some(AnthropicBlock::Thinking { thinking_id }), "thinking_delta") => {
1910                        if let Some(text) = delta.get("thinking").and_then(|v| v.as_str()) {
1911                            if !text.is_empty() {
1912                                out.push(ModelChunk::ThinkingDelta {
1913                                    thinking_id: thinking_id.clone(),
1914                                    delta: text.to_string(),
1915                                    signature: None,
1916                                });
1917                            }
1918                        }
1919                    }
1920                    (Some(AnthropicBlock::Thinking { thinking_id }), "signature_delta") => {
1921                        if let Some(sig) = delta.get("signature").and_then(|v| v.as_str()) {
1922                            // Signature deltas land empty-text + signed.
1923                            // We pass them through as ThinkingDelta with
1924                            // empty `delta` so agent_loop's signature
1925                            // latch fires. Empty-delta ThinkingChunk is
1926                            // suppressed downstream by the empty check.
1927                            out.push(ModelChunk::ThinkingDelta {
1928                                thinking_id: thinking_id.clone(),
1929                                delta: String::new(),
1930                                signature: Some(sig.to_string()),
1931                            });
1932                        }
1933                    }
1934                    (Some(AnthropicBlock::ToolUse { id }), "input_json_delta") => {
1935                        if let Some(partial) = delta.get("partial_json").and_then(|v| v.as_str()) {
1936                            if !partial.is_empty() {
1937                                out.push(ModelChunk::ToolCallInputDelta {
1938                                    id: id.clone(),
1939                                    delta: partial.to_string(),
1940                                });
1941                            }
1942                        }
1943                    }
1944                    _ => { /* unknown delta type or block-kind mismatch */ }
1945                }
1946            }
1947            "content_block_stop" => {
1948                let index = value.get("index").and_then(|v| v.as_u64()).unwrap_or(0);
1949                if let Some(AnthropicBlock::ToolUse { id }) = self.blocks.get(&index) {
1950                    // Tool argument bytes accumulated as deltas; harness
1951                    // parses them in `consume_step_stream`. We don't
1952                    // attach an early input here — Anthropic ships the
1953                    // arguments only via deltas.
1954                    out.push(ModelChunk::ToolCallEnd {
1955                        id: id.clone(),
1956                        input: None,
1957                    });
1958                }
1959            }
1960            "message_delta" => {
1961                if let Some(reason) = value
1962                    .get("delta")
1963                    .and_then(|d| d.get("stop_reason"))
1964                    .and_then(|v| v.as_str())
1965                {
1966                    self.stop_reason = Some(reason.to_string());
1967                }
1968                if let Some(u) = value.get("usage") {
1969                    // message_delta only refreshes the output_tokens —
1970                    // input_tokens come from message_start. Merge.
1971                    self.pending_usage =
1972                        Some(merge_anthropic_usage(self.pending_usage.clone(), u, false));
1973                }
1974            }
1975            "message_stop" => {
1976                if let Some(done) = self.emit_done() {
1977                    out.push(done);
1978                }
1979            }
1980            _ => { /* forward-compat: silently ignore unknown event types */ }
1981        }
1982        Ok(out)
1983    }
1984
1985    fn finalize(&mut self) -> Option<ModelChunk> {
1986        self.emit_done()
1987    }
1988
1989    fn emit_done(&mut self) -> Option<ModelChunk> {
1990        if self.done_emitted {
1991            return None;
1992        }
1993        self.done_emitted = true;
1994        let stop_reason = map_anthropic_stop_reason(self.stop_reason.as_deref());
1995        Some(ModelChunk::Done {
1996            stop_reason,
1997            usage: self.pending_usage.take(),
1998        })
1999    }
2000}
2001
2002/// Merge an Anthropic usage block (from `message_start` or `message_delta`)
2003/// into the running tally. When `include_input=true`, accept both
2004/// `input_tokens` and the cache-related fields; otherwise only refresh
2005/// `output_tokens` (per Anthropic's spec — `message_delta` only carries
2006/// output deltas).
2007fn merge_anthropic_usage(
2008    prior: Option<HarnessUsage>,
2009    incoming: &Value,
2010    include_input: bool,
2011) -> HarnessUsage {
2012    let mut u = prior.unwrap_or_default();
2013    if include_input {
2014        if let Some(v) = incoming.get("input_tokens").and_then(|v| v.as_u64()) {
2015            u.input_tokens = v;
2016        }
2017        if let Some(v) = incoming
2018            .get("cache_read_input_tokens")
2019            .and_then(|v| v.as_u64())
2020        {
2021            u.cache_read_input_tokens = v;
2022        }
2023        if let Some(v) = incoming
2024            .get("cache_creation_input_tokens")
2025            .and_then(|v| v.as_u64())
2026        {
2027            u.cache_creation_input_tokens = v;
2028        }
2029    }
2030    if let Some(v) = incoming.get("output_tokens").and_then(|v| v.as_u64()) {
2031        u.output_tokens = v;
2032    }
2033    u
2034}
2035
2036fn map_anthropic_stop_reason(reason: Option<&str>) -> String {
2037    // Anthropic Messages API stop_reason values (per docs):
2038    //   end_turn / max_tokens / stop_sequence / tool_use / refusal
2039    match reason {
2040        Some("end_turn") | Some("stop_sequence") | Some("tool_use") => "end_turn".into(),
2041        Some("max_tokens") => "max_tokens".into(),
2042        Some("refusal") => "refusal".into(),
2043        Some(other) if !other.is_empty() => other.to_string(),
2044        _ => "end_turn".into(),
2045    }
2046}
2047
2048/// HTTP status → `ModelClientError` for Anthropic. Status codes mostly
2049/// align with OpenAI's so we reuse the classifier logic; the body
2050/// heuristics differ slightly (Anthropic uses `type:"error"` with a
2051/// `message` field rather than nested `error.message`).
2052fn classify_anthropic_http_error(status: reqwest::StatusCode, body: &str) -> ModelClientError {
2053    use reqwest::StatusCode;
2054    let snippet = body.chars().take(512).collect::<String>();
2055    if status == StatusCode::TOO_MANY_REQUESTS {
2056        return ModelClientError::RateLimit(format!("HTTP {status}: {snippet}"));
2057    }
2058    if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
2059        return ModelClientError::Auth(format!("HTTP {status}: {snippet}"));
2060    }
2061    if status == StatusCode::BAD_REQUEST && looks_like_context_overflow(body) {
2062        return ModelClientError::ContextOverflow(format!("HTTP {status}: {snippet}"));
2063    }
2064    if status == StatusCode::BAD_REQUEST {
2065        return ModelClientError::BadRequest(format!("HTTP {status}: {snippet}"));
2066    }
2067    if status.is_server_error() {
2068        return ModelClientError::ServerError(format!("HTTP {status}: {snippet}"));
2069    }
2070    ModelClientError::Other(format!("HTTP {status}: {snippet}"))
2071}
2072
2073// ─── OpenAI Responses API ────────────────────────────────────────────────
2074//
2075// The Responses API (`POST /v1/responses`) is OpenAI's current-generation
2076// surface for gpt-5 / o-series reasoning models. It differs from
2077// chat/completions in three load-bearing places:
2078//
2079//   1. Request shape   `input[]` (a heterogeneous list of typed items)
2080//                      replaces `messages[]`; `system` moves to the
2081//                      top-level `instructions` field; tool calls ride as
2082//                      `function_call` / `function_call_output` items
2083//                      (not a `tool` role); tools are FLAT
2084//                      (`{type:"function", name, ...}`, no nested
2085//                      `function{}`); `max_output_tokens` replaces
2086//                      `max_tokens`.
2087//   2. Reasoning       Reasoning surfaces as `reasoning` items. In the
2088//                      stateless mode we use (`store:false`) the model's
2089//                      reasoning must be round-tripped verbatim by echoing
2090//                      the item `id` + `encrypted_content` back on the next
2091//                      turn (requested via `include`). We fold both into
2092//                      the existing `AssistantThinking.signature` field so
2093//                      `ChatMessage` stays unchanged.
2094//   3. SSE shape       Events are discriminated by a JSON `type` field
2095//                      (`response.output_text.delta`, `response.completed`,
2096//                      …) inside `data:`, not by chat's `choices[].delta`.
2097//
2098// As with the Anthropic section, all the work here is wire-shape
2099// translation into the provider-agnostic `ModelChunk` enum; the harness
2100// loop stays provider-agnostic.
2101
2102/// Deployment-side credentials + endpoint + knobs for the OpenAI Responses
2103/// API.
2104///
2105/// The client always operates **statelessly** (`store:false`): this harness
2106/// owns conversation history itself (persisted to `messages.jsonl`) and
2107/// replays it in full every turn, so it never references OpenAI's
2108/// server-side response storage. Consequently reasoning is round-tripped by
2109/// echoing each item's `encrypted_content` (requested via `include`), not by
2110/// `previous_response_id` / `item_reference`. A stateful (`store:true`) mode
2111/// would require that item-reference machinery, which is intentionally not
2112/// implemented — so no `store` knob is exposed.
2113#[derive(Debug, Clone)]
2114pub struct OpenAiResponsesConfig {
2115    /// Full API prefix including any version segment — e.g.
2116    /// `https://api.openai.com/v1`. The `/responses` route is appended.
2117    pub base_url: String,
2118    pub api_key: String,
2119    pub model: ResolvedModelConfig,
2120    /// `"auto"` → `reasoning.summary`, which makes the model stream a
2121    /// human-readable summary of its reasoning (surfaced as thinking
2122    /// deltas). `None` omits it — the model still reasons, just silently.
2123    pub reasoning_summary: Option<String>,
2124}
2125
2126impl OpenAiResponsesConfig {
2127    /// Default API prefix if the caller doesn't override `base_url`.
2128    pub const DEFAULT_BASE_URL: &'static str = "https://api.openai.com/v1";
2129}
2130
2131#[derive(Debug, Clone)]
2132pub struct OpenAiResponsesModelClient {
2133    http: reqwest::Client,
2134    config: OpenAiResponsesConfig,
2135}
2136
2137impl OpenAiResponsesModelClient {
2138    pub fn new(config: OpenAiResponsesConfig) -> Self {
2139        assert_eq!(
2140            config.model.wire_protocol,
2141            WireProtocol::OpenAiResponses,
2142            "resolved model protocol must match OpenAiResponsesModelClient"
2143        );
2144        // Same construction as the other clients: fail fast on a slow TCP
2145        // handshake, but never cap per-read time — streaming reasoning
2146        // responses legitimately pause between SSE frames.
2147        let http = reqwest::Client::builder()
2148            .connect_timeout(std::time::Duration::from_secs(15))
2149            .build()
2150            .unwrap_or_else(|_| reqwest::Client::new());
2151        Self { http, config }
2152    }
2153
2154    fn endpoint(&self) -> String {
2155        // `base_url` carries the version segment; we only append the route.
2156        let base = self.config.base_url.trim_end_matches('/');
2157        if base.ends_with("/responses") {
2158            base.to_string()
2159        } else {
2160            format!("{base}/responses")
2161        }
2162    }
2163
2164    fn request_body(&self, input: &ModelTurnInput) -> Value {
2165        let mut body = json!({
2166            "model": self.config.model.model,
2167            "input": chat_messages_to_responses_input(&input.messages),
2168            "stream": true,
2169            // Always stateless — see `OpenAiResponsesConfig`. The harness
2170            // replays full history itself, so it never leans on server-side
2171            // response storage.
2172            "store": false,
2173        });
2174        // System prompt rides on the top-level `instructions` field — the
2175        // Responses-recommended home for it (kept out of the `input[]` list).
2176        if let Some(sys) = input.system_prompt.as_deref().filter(|s| !s.is_empty()) {
2177            body["instructions"] = json!(sys);
2178        }
2179
2180        // `ToolChoice::None` means "no tools this turn" — that must also
2181        // suppress hosted (provider-run) tools, otherwise the model could
2182        // still fire e.g. server-side web_search, defeating the caller's
2183        // intent (and incurring unexpected egress / cost). Mirrors the
2184        // Anthropic path, which drops all tools for `None`.
2185        let advertise_tools = !matches!(input.tool_choice, ToolChoice::None);
2186        let mut tools: Vec<Value> = Vec::new();
2187        if advertise_tools {
2188            tools.extend(input.tools.iter().map(tool_spec_to_responses_tool));
2189            tools.extend(input.hosted_tools.iter().map(hosted_tool_to_responses_tool));
2190        }
2191        if !tools.is_empty() {
2192            body["tools"] = json!(tools);
2193            // Emit `tool_choice` whenever it's meaningful for the advertised
2194            // set — NOT only when client function tools exist:
2195            //   * Auto / Required apply to the whole set (function + hosted),
2196            //     so send them as long as any tool is advertised. Gating on
2197            //     function tools would silently downgrade a hosted-only
2198            //     `Required` to the default auto.
2199            //   * Tool(name) force-selects a *function* tool, so only send it
2200            //     when a function tool exists — encoding a hosted tool as
2201            //     `{type:"function", name}` would be rejected as a 400.
2202            let send_choice = match input.tool_choice {
2203                ToolChoice::Auto | ToolChoice::Required => true,
2204                ToolChoice::Tool(_) => !input.tools.is_empty(),
2205                ToolChoice::None => false, // unreachable: advertise_tools is false
2206            };
2207            if send_choice {
2208                body["tool_choice"] = responses_tool_choice_value(&input.tool_choice);
2209            }
2210            if let Some(parallel) = input.parallel_tool_calls {
2211                body["parallel_tool_calls"] = json!(parallel);
2212            }
2213        }
2214
2215        if let Some(temperature) = self.config.model.temperature {
2216            body["temperature"] = json!(temperature);
2217        }
2218        body["max_output_tokens"] = json!(self.config.model.max_output_tokens);
2219
2220        let effort = self
2221            .config
2222            .model
2223            .reasoning
2224            .effort
2225            .as_deref()
2226            .filter(|s| !s.is_empty());
2227        let summary = self
2228            .config
2229            .reasoning_summary
2230            .as_deref()
2231            .filter(|s| !s.is_empty());
2232        if effort.is_some() || summary.is_some() {
2233            let mut reasoning = json!({});
2234            if let Some(e) = effort {
2235                reasoning["effort"] = json!(e);
2236            }
2237            if let Some(s) = summary {
2238                reasoning["summary"] = json!(s);
2239            }
2240            body["reasoning"] = reasoning;
2241        }
2242
2243        // Stateless mode always asks OpenAI to include the encrypted
2244        // reasoning payload so we can echo it back on the next turn (see
2245        // `chat_messages_to_responses_input`).
2246        body["include"] = json!(["reasoning.encrypted_content"]);
2247
2248        body
2249    }
2250}
2251
2252/// Render one `ToolSpec` into the Responses FLAT tool shape. Note the
2253/// difference from chat/completions (`tool_spec_to_openai_function`), which
2254/// nests everything under a `function` key.
2255fn tool_spec_to_responses_tool(spec: &ToolSpec) -> Value {
2256    json!({
2257        "type": "function",
2258        "name": spec.name,
2259        "description": spec.description,
2260        "parameters": spec.input_schema,
2261    })
2262}
2263
2264/// Project a `HostedTool` to a Responses hosted-tool definition. Unlike the
2265/// chat/completions client — which rejects hosted tools outright — Responses
2266/// runs them server-side and streams the result back inline.
2267fn hosted_tool_to_responses_tool(tool: &HostedTool) -> Value {
2268    match tool {
2269        HostedTool::WebSearch => json!({ "type": "web_search" }),
2270    }
2271}
2272
2273fn responses_tool_choice_value(c: &ToolChoice) -> Value {
2274    match c {
2275        ToolChoice::Auto => json!("auto"),
2276        // Unreachable in practice (caller drops function tools for None),
2277        // but encode defensively.
2278        ToolChoice::None => json!("none"),
2279        ToolChoice::Required => json!("required"),
2280        ToolChoice::Tool(name) => json!({ "type": "function", "name": name }),
2281    }
2282}
2283
2284/// Render an `ImageSource` to the string the Responses `input_image` part
2285/// expects. Inline base64 becomes a `data:` URI; a URL passes through.
2286fn image_to_responses_data_url(src: &ImageSource) -> String {
2287    match &src.data {
2288        ImageData::Base64(b64) => format!("data:{};base64,{}", src.media_type, b64),
2289        ImageData::Url(u) => u.clone(),
2290    }
2291}
2292
2293/// Pack a Responses reasoning item's `id` + `encrypted_content` into the
2294/// single `AssistantThinking.signature` slot so reasoning round-trips
2295/// without changing `ChatMessage`. The item id never contains a newline, so
2296/// a `\n` separator is unambiguous. `decode_reasoning_signature` reverses it;
2297/// a signature without a `\n` (e.g. an Anthropic thinking signature) decodes
2298/// to `None` and is simply not re-sent as a reasoning item.
2299fn encode_reasoning_signature(item_id: &str, encrypted_content: &str) -> String {
2300    format!("{item_id}\n{encrypted_content}")
2301}
2302
2303fn decode_reasoning_signature(sig: &str) -> Option<(String, String)> {
2304    let (id, enc) = sig.split_once('\n')?;
2305    if id.is_empty() || enc.is_empty() {
2306        return None;
2307    }
2308    Some((id.to_string(), enc.to_string()))
2309}
2310
2311/// Render `ChatMessage[]` into the Responses `input[]` list. Rules:
2312///
2313///   1. `User` → `{role:"user", content:[input_text, input_image...]}`.
2314///   2. `Assistant` expands into up to three item kinds, in order:
2315///      reasoning (from `thinking`, if its signature round-trips) →
2316///      `{role:"assistant", content:[output_text]}` → one `function_call`
2317///      per tool call.
2318///   3. `Tool` → `function_call_output` keyed by `call_id`. Image
2319///      attachments ride as an `input_image` content array (Responses
2320///      supports structured tool output — no lossy placeholder like the
2321///      chat/completions `tool` role).
2322fn chat_messages_to_responses_input(messages: &[ChatMessage]) -> Vec<Value> {
2323    let mut out: Vec<Value> = Vec::with_capacity(messages.len());
2324    for msg in messages {
2325        match msg {
2326            ChatMessage::User {
2327                content,
2328                attachments,
2329            } => {
2330                let mut parts: Vec<Value> = Vec::with_capacity(attachments.len() + 1);
2331                if !content.is_empty() {
2332                    parts.push(json!({"type": "input_text", "text": content}));
2333                }
2334                for att in attachments {
2335                    let UserAttachment::Image(src) = att;
2336                    parts.push(json!({
2337                        "type": "input_image",
2338                        "image_url": image_to_responses_data_url(src),
2339                    }));
2340                }
2341                // Responses rejects empty content arrays.
2342                if parts.is_empty() {
2343                    parts.push(json!({"type": "input_text", "text": ""}));
2344                }
2345                out.push(json!({"role": "user", "content": parts}));
2346            }
2347            ChatMessage::Assistant {
2348                text,
2349                tool_calls,
2350                thinking,
2351                usage: _,
2352            } => {
2353                // Reasoning first — only re-sent when the signature carries a
2354                // decodable (item_id, encrypted_content) pair. A plain-text
2355                // or Anthropic-style signature simply isn't projected.
2356                if let Some(t) = thinking {
2357                    if let Some((item_id, enc)) =
2358                        t.signature.as_deref().and_then(decode_reasoning_signature)
2359                    {
2360                        let summary = if t.text.is_empty() {
2361                            json!([])
2362                        } else {
2363                            json!([{"type": "summary_text", "text": t.text}])
2364                        };
2365                        out.push(json!({
2366                            "type": "reasoning",
2367                            "id": item_id,
2368                            "summary": summary,
2369                            "encrypted_content": enc,
2370                        }));
2371                    }
2372                }
2373                if let Some(t) = text.as_deref().filter(|s| !s.is_empty()) {
2374                    out.push(json!({
2375                        "role": "assistant",
2376                        "content": [{"type": "output_text", "text": t}],
2377                    }));
2378                }
2379                for tc in tool_calls {
2380                    out.push(json!({
2381                        "type": "function_call",
2382                        "call_id": tc.id,
2383                        "name": tc.name,
2384                        "arguments": tool_invocation_args_for_wire(tc),
2385                    }));
2386                }
2387            }
2388            ChatMessage::Tool {
2389                tool_call_id,
2390                content,
2391                is_error: _,
2392                attachments,
2393            } => {
2394                if attachments.is_empty() {
2395                    let replay = compact_tool_result_for_replay(content).into_owned();
2396                    out.push(json!({
2397                        "type": "function_call_output",
2398                        "call_id": tool_call_id,
2399                        "output": replay,
2400                    }));
2401                } else {
2402                    let mut parts: Vec<Value> = Vec::new();
2403                    if !content.is_empty() {
2404                        let replay = compact_tool_result_for_replay(content).into_owned();
2405                        parts.push(json!({"type": "input_text", "text": replay}));
2406                    }
2407                    for att in attachments {
2408                        let UserAttachment::Image(src) = att;
2409                        parts.push(json!({
2410                            "type": "input_image",
2411                            "image_url": image_to_responses_data_url(src),
2412                        }));
2413                    }
2414                    out.push(json!({
2415                        "type": "function_call_output",
2416                        "call_id": tool_call_id,
2417                        "output": parts,
2418                    }));
2419                }
2420            }
2421        }
2422    }
2423    out
2424}
2425
2426/// Pull token counts out of a Responses `usage` block. Maps:
2427///   * `input_tokens`                          → `input_tokens`
2428///   * `output_tokens`                         → `output_tokens`
2429///   * `input_tokens_details.cached_tokens`    → `cache_read_input_tokens`
2430///
2431/// Returns `None` when every counter is zero / absent (no-op response).
2432fn parse_responses_usage(usage: Option<&Value>) -> Option<HarnessUsage> {
2433    let u = usage?;
2434    let input = u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
2435    let output = u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
2436    let cache_read = u
2437        .get("input_tokens_details")
2438        .and_then(|d| d.get("cached_tokens"))
2439        .and_then(|v| v.as_u64())
2440        .unwrap_or(0);
2441    if input == 0 && output == 0 && cache_read == 0 {
2442        return None;
2443    }
2444    Some(HarnessUsage {
2445        input_tokens: input,
2446        output_tokens: output,
2447        cache_read_input_tokens: cache_read,
2448        cache_creation_input_tokens: 0,
2449        compaction_input_tokens: 0,
2450        compaction_output_tokens: 0,
2451    })
2452}
2453
2454/// Map a Responses `incomplete_details.reason` to the harness stop_reason
2455/// vocabulary. A clean finish (no reason) is `end_turn` regardless of
2456/// whether a tool was called — the harness dispatches tools whenever a
2457/// tool call landed, independent of stop_reason.
2458fn map_responses_stop_reason(reason: Option<&str>) -> String {
2459    match reason {
2460        Some("max_output_tokens") => "max_tokens".into(),
2461        Some("content_filter") => "refusal".into(),
2462        _ => "end_turn".into(),
2463    }
2464}
2465
2466/// Bucket a Responses stream-level failure (`response.failed` /
2467/// top-level `error` event) into a `ModelClientError`. Mirrors the HTTP
2468/// classifier's retryability split.
2469fn classify_responses_error(code: &str, message: &str) -> ModelClientError {
2470    let full = if code.is_empty() {
2471        message.to_string()
2472    } else {
2473        format!("{code}: {message}")
2474    };
2475    if code == "context_length_exceeded" || looks_like_context_overflow(message) {
2476        return ModelClientError::ContextOverflow(full);
2477    }
2478    if code == "rate_limit_exceeded" {
2479        return ModelClientError::RateLimit(full);
2480    }
2481    ModelClientError::BadRequest(full)
2482}
2483
2484#[async_trait]
2485impl ModelClient for OpenAiResponsesModelClient {
2486    fn hosted_capability(&self, capability: HostedCapability) -> CapabilitySupport {
2487        match capability {
2488            HostedCapability::WebSearch => {
2489                official_endpoint_support(&self.config.base_url, &["api.openai.com"])
2490            }
2491        }
2492    }
2493
2494    async fn stream(
2495        &self,
2496        input: ModelTurnInput,
2497    ) -> Result<BoxStream<'static, Result<ModelChunk, ModelClientError>>, ModelClientError> {
2498        let body = self.request_body(&input);
2499        let resp = match self
2500            .http
2501            .post(self.endpoint())
2502            .bearer_auth(&self.config.api_key)
2503            .json(&body)
2504            .send()
2505            .await
2506        {
2507            Ok(r) => r,
2508            Err(e) => return Err(classify_reqwest_error(&e, e.to_string())),
2509        };
2510        let status = resp.status();
2511        if !status.is_success() {
2512            let body_text = resp.text().await.unwrap_or_default();
2513            // Responses shares OpenAI's HTTP error shape, so the same
2514            // status/body classifier applies.
2515            return Err(classify_openai_http_error(status, &body_text));
2516        }
2517
2518        let event_stream = resp.bytes_stream().eventsource();
2519        let (tx, rx) = tokio::sync::mpsc::channel::<Result<ModelChunk, ModelClientError>>(8);
2520        tokio::spawn(async move {
2521            let mut state = OpenAiResponsesStreamState::default();
2522            futures::pin_mut!(event_stream);
2523            while let Some(ev) = event_stream.next().await {
2524                let chunks = match ev {
2525                    Ok(event) => match state.feed_data(&event.data) {
2526                        Ok(c) => c,
2527                        Err(e) => {
2528                            let _ = tx.send(Err(e)).await;
2529                            return;
2530                        }
2531                    },
2532                    Err(e) => {
2533                        let _ = tx
2534                            .send(Err(ModelClientError::Network(format!(
2535                                "SSE transport error: {e}"
2536                            ))))
2537                            .await;
2538                        return;
2539                    }
2540                };
2541                for c in chunks {
2542                    if tx.send(Ok(c)).await.is_err() {
2543                        return;
2544                    }
2545                }
2546            }
2547            // Same clean-vs-cut-off discipline as the OpenAI chat path: a
2548            // terminal `response.*` event marks a legitimate end. Without
2549            // one, the connection dropped mid-response and we must surface a
2550            // retryable error rather than fabricate a completed answer.
2551            if state.ended_cleanly() {
2552                if let Some(done) = state.finalize() {
2553                    let _ = tx.send(Ok(done)).await;
2554                }
2555            } else {
2556                let _ = tx
2557                    .send(Err(ModelClientError::Network(
2558                        "model stream closed before completion (no terminal response event) \
2559                         — connection dropped or upstream truncated the response"
2560                            .into(),
2561                    )))
2562                    .await;
2563            }
2564        });
2565        Ok(tokio_stream::wrappers::ReceiverStream::new(rx).boxed())
2566    }
2567}
2568
2569fn official_endpoint_support(base_url: &str, official_hosts: &[&str]) -> CapabilitySupport {
2570    let Ok(url) = reqwest::Url::parse(base_url) else {
2571        return CapabilitySupport::Unknown;
2572    };
2573    let Some(host) = url.host_str() else {
2574        return CapabilitySupport::Unknown;
2575    };
2576    if official_hosts
2577        .iter()
2578        .any(|official| host.eq_ignore_ascii_case(official))
2579    {
2580        CapabilitySupport::Supported
2581    } else {
2582        CapabilitySupport::Unknown
2583    }
2584}
2585
2586/// State machine turning Responses SSE events into `ModelChunk`s. Events are
2587/// dispatched on the `type` field inside each `data:` payload (more reliable
2588/// than the SSE `event:` line). Lives outside the trait impl so the parsing
2589/// is unit-testable without an HTTP server.
2590#[derive(Debug, Default)]
2591struct OpenAiResponsesStreamState {
2592    /// First `response.id` we see; used as a fallback text/msg id.
2593    response_id: Option<String>,
2594    /// Maps a `function_call` item id (`fc_...`) to its `call_id`
2595    /// (`call_...`). Arguments deltas arrive keyed by the item id, but the
2596    /// harness pairs tool results by `call_id`, so we route through this.
2597    fc_call_by_item: std::collections::HashMap<String, String>,
2598    /// `"{item_id}:{summary_index}"` keys for reasoning-summary parts that
2599    /// have already emitted a text delta. Used to prefix a part boundary to
2600    /// the first real text of parts after index 0.
2601    summary_parts_seen: std::collections::HashSet<String>,
2602    stop_reason: Option<String>,
2603    pending_usage: Option<HarnessUsage>,
2604    done_emitted: bool,
2605    /// Set once a terminal `response.*` event (completed / incomplete /
2606    /// failed) lands, distinguishing a clean end from a dropped connection.
2607    saw_terminal: bool,
2608}
2609
2610impl OpenAiResponsesStreamState {
2611    fn feed_data(&mut self, data: &str) -> Result<Vec<ModelChunk>, ModelClientError> {
2612        let trimmed = data.trim();
2613        // Responses uses terminal `response.*` events, not `[DONE]`, but
2614        // tolerate an empty keepalive frame or a stray sentinel.
2615        if trimmed.is_empty() || trimmed == "[DONE]" {
2616            return Ok(vec![]);
2617        }
2618        let value: Value = serde_json::from_str(trimmed).map_err(|e| {
2619            ModelClientError::Other(format!("Responses SSE data not JSON: {e}; raw={trimmed}"))
2620        })?;
2621        let event_type = value.get("type").and_then(|v| v.as_str()).unwrap_or("");
2622
2623        // Latch the response id from any event that carries it.
2624        if let Some(rid) = value
2625            .get("response")
2626            .and_then(|r| r.get("id"))
2627            .and_then(|v| v.as_str())
2628        {
2629            if self.response_id.is_none() && !rid.is_empty() {
2630                self.response_id = Some(rid.to_string());
2631            }
2632        }
2633
2634        let mut out: Vec<ModelChunk> = Vec::new();
2635        match event_type {
2636            "response.output_text.delta" => {
2637                if let Some(delta) = value.get("delta").and_then(|v| v.as_str()) {
2638                    if !delta.is_empty() {
2639                        let msg_id = value
2640                            .get("item_id")
2641                            .and_then(|v| v.as_str())
2642                            .map(String::from)
2643                            .or_else(|| self.response_id.clone())
2644                            .unwrap_or_else(|| "msg_responses_default".into());
2645                        out.push(ModelChunk::TextDelta {
2646                            msg_id,
2647                            delta: delta.to_string(),
2648                        });
2649                    }
2650                }
2651            }
2652            // Summary reasoning. A summary can arrive as multiple parts, each
2653            // keyed by an incrementing `summary_index`. The harness thinking
2654            // model is a single text stream (no per-part structure to fill),
2655            // so we preserve the boundary by prefixing a blank line to the
2656            // FIRST real text delta of every part after the first. We key off
2657            // the text delta — not the structural `..._part.added` event — so
2658            // the separator only ever rides genuine model output (a bare
2659            // separator on a structural event would register as "progress" in
2660            // the agent loop and could persist for an empty part).
2661            //
2662            // `response.reasoning_summary.delta` is an alias some
2663            // Responses-compatible gateways emit instead of the `_text`
2664            // variant; accept both so their reasoning summaries aren't lost.
2665            "response.reasoning_summary_text.delta" | "response.reasoning_summary.delta" => {
2666                if let Some(delta) = value.get("delta").and_then(|v| v.as_str()) {
2667                    if !delta.is_empty() {
2668                        let item_id = value
2669                            .get("item_id")
2670                            .and_then(|v| v.as_str())
2671                            .unwrap_or("reasoning-0");
2672                        let idx = value
2673                            .get("summary_index")
2674                            .and_then(|v| v.as_u64())
2675                            .unwrap_or(0);
2676                        // `insert` returns true the first time we see this
2677                        // (item, part) — that's the part's first text delta.
2678                        let first_of_part =
2679                            self.summary_parts_seen.insert(format!("{item_id}:{idx}"));
2680                        let text = if first_of_part && idx > 0 {
2681                            format!("\n\n{delta}")
2682                        } else {
2683                            delta.to_string()
2684                        };
2685                        out.push(ModelChunk::ThinkingDelta {
2686                            thinking_id: item_id.to_string(),
2687                            delta: text,
2688                            signature: None,
2689                        });
2690                    }
2691                }
2692            }
2693            // Non-summary reasoning text is a single stream — no part
2694            // boundaries to bridge.
2695            "response.reasoning_text.delta" => {
2696                if let Some(delta) = value.get("delta").and_then(|v| v.as_str()) {
2697                    if !delta.is_empty() {
2698                        let thinking_id = value
2699                            .get("item_id")
2700                            .and_then(|v| v.as_str())
2701                            .unwrap_or("reasoning-0")
2702                            .to_string();
2703                        out.push(ModelChunk::ThinkingDelta {
2704                            thinking_id,
2705                            delta: delta.to_string(),
2706                            signature: None,
2707                        });
2708                    }
2709                }
2710            }
2711            "response.output_item.added" => {
2712                if let Some(item) = value.get("item") {
2713                    if item.get("type").and_then(|v| v.as_str()) == Some("function_call") {
2714                        let fc_id = item.get("id").and_then(|v| v.as_str()).unwrap_or("");
2715                        let call_id = item
2716                            .get("call_id")
2717                            .and_then(|v| v.as_str())
2718                            .filter(|s| !s.is_empty())
2719                            .unwrap_or(fc_id);
2720                        let name = item
2721                            .get("name")
2722                            .and_then(|v| v.as_str())
2723                            .unwrap_or("")
2724                            .to_string();
2725                        if !call_id.is_empty() {
2726                            if !fc_id.is_empty() {
2727                                self.fc_call_by_item
2728                                    .insert(fc_id.to_string(), call_id.to_string());
2729                            }
2730                            out.push(ModelChunk::ToolCallStart {
2731                                id: call_id.to_string(),
2732                                name,
2733                            });
2734                        }
2735                    }
2736                }
2737            }
2738            "response.function_call_arguments.delta" => {
2739                let item_id = value.get("item_id").and_then(|v| v.as_str()).unwrap_or("");
2740                if let Some(delta) = value.get("delta").and_then(|v| v.as_str()) {
2741                    if !delta.is_empty() {
2742                        let call_id = self
2743                            .fc_call_by_item
2744                            .get(item_id)
2745                            .cloned()
2746                            .unwrap_or_else(|| item_id.to_string());
2747                        out.push(ModelChunk::ToolCallInputDelta {
2748                            id: call_id,
2749                            delta: delta.to_string(),
2750                        });
2751                    }
2752                }
2753            }
2754            "response.output_item.done" => {
2755                if let Some(item) = value.get("item") {
2756                    let itype = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
2757                    if itype == "function_call" {
2758                        let fc_id = item.get("id").and_then(|v| v.as_str()).unwrap_or("");
2759                        let call_id = self
2760                            .fc_call_by_item
2761                            .get(fc_id)
2762                            .cloned()
2763                            .or_else(|| {
2764                                item.get("call_id")
2765                                    .and_then(|v| v.as_str())
2766                                    .filter(|s| !s.is_empty())
2767                                    .map(String::from)
2768                            })
2769                            .unwrap_or_else(|| fc_id.to_string());
2770                        // Defensive: if we never saw `output_item.added` for
2771                        // this call, synthesize the start so the folder has a
2772                        // tool_state to attach the end to.
2773                        if !fc_id.is_empty() && !self.fc_call_by_item.contains_key(fc_id) {
2774                            let name = item
2775                                .get("name")
2776                                .and_then(|v| v.as_str())
2777                                .unwrap_or("")
2778                                .to_string();
2779                            out.push(ModelChunk::ToolCallStart {
2780                                id: call_id.clone(),
2781                                name,
2782                            });
2783                            self.fc_call_by_item
2784                                .insert(fc_id.to_string(), call_id.clone());
2785                        }
2786                        // The done item carries the complete argument string;
2787                        // parse it as authoritative early input when present.
2788                        let input = item
2789                            .get("arguments")
2790                            .and_then(|v| v.as_str())
2791                            .map(str::trim)
2792                            .filter(|s| !s.is_empty())
2793                            .and_then(|s| serde_json::from_str::<Value>(s).ok());
2794                        out.push(ModelChunk::ToolCallEnd { id: call_id, input });
2795                    } else if itype == "reasoning" {
2796                        // Capture the encrypted reasoning payload and fold it
2797                        // into a signature-only ThinkingDelta so the final
2798                        // ChatMessage::Assistant.thinking carries it for
2799                        // round-trip on the next turn.
2800                        let rid = item.get("id").and_then(|v| v.as_str()).unwrap_or("");
2801                        if let Some(enc) = item
2802                            .get("encrypted_content")
2803                            .and_then(|v| v.as_str())
2804                            .filter(|s| !s.is_empty())
2805                        {
2806                            if !rid.is_empty() {
2807                                out.push(ModelChunk::ThinkingDelta {
2808                                    thinking_id: rid.to_string(),
2809                                    delta: String::new(),
2810                                    signature: Some(encode_reasoning_signature(rid, enc)),
2811                                });
2812                            }
2813                        }
2814                    }
2815                }
2816            }
2817            "response.completed" | "response.incomplete" => {
2818                self.saw_terminal = true;
2819                let resp = value.get("response");
2820                self.pending_usage = parse_responses_usage(resp.and_then(|r| r.get("usage")));
2821                let reason = resp
2822                    .and_then(|r| r.get("incomplete_details"))
2823                    .and_then(|d| d.get("reason"))
2824                    .and_then(|v| v.as_str());
2825                self.stop_reason = Some(map_responses_stop_reason(reason));
2826                if let Some(done) = self.emit_done() {
2827                    out.push(done);
2828                }
2829            }
2830            "response.failed" => {
2831                self.saw_terminal = true;
2832                let err = value.get("response").and_then(|r| r.get("error"));
2833                let code = err
2834                    .and_then(|e| e.get("code"))
2835                    .and_then(|v| v.as_str())
2836                    .unwrap_or("");
2837                let message = err
2838                    .and_then(|e| e.get("message"))
2839                    .and_then(|v| v.as_str())
2840                    .unwrap_or("Responses response failed");
2841                return Err(classify_responses_error(code, message));
2842            }
2843            "error" => {
2844                self.saw_terminal = true;
2845                let code = value.get("code").and_then(|v| v.as_str()).unwrap_or("");
2846                let message = value
2847                    .get("message")
2848                    .and_then(|v| v.as_str())
2849                    .unwrap_or("Responses stream error");
2850                return Err(classify_responses_error(code, message));
2851            }
2852            // forward-compat: ignore unknown / display-only event types
2853            // (response.created, *.output_text.done, *_part.added, ping, …)
2854            _ => {}
2855        }
2856        Ok(out)
2857    }
2858
2859    fn finalize(&mut self) -> Option<ModelChunk> {
2860        self.emit_done()
2861    }
2862
2863    fn ended_cleanly(&self) -> bool {
2864        self.saw_terminal || self.done_emitted
2865    }
2866
2867    fn emit_done(&mut self) -> Option<ModelChunk> {
2868        if self.done_emitted {
2869            return None;
2870        }
2871        self.done_emitted = true;
2872        Some(ModelChunk::Done {
2873            stop_reason: self
2874                .stop_reason
2875                .clone()
2876                .unwrap_or_else(|| "end_turn".into()),
2877            usage: self.pending_usage.take(),
2878        })
2879    }
2880}
2881
2882#[cfg(test)]
2883mod tests {
2884    use super::*;
2885    use crate::model_catalog::{
2886        LimitsSource, ModelCapabilities, ModelLimits, ReasoningConfig, ReasoningOption,
2887    };
2888
2889    fn resolved_model(
2890        id: &str,
2891        protocol: WireProtocol,
2892        max_output_tokens: u64,
2893        temperature: Option<f64>,
2894        reasoning: ReasoningConfig,
2895    ) -> ResolvedModelConfig {
2896        ResolvedModelConfig {
2897            model: id.into(),
2898            wire_protocol: protocol,
2899            max_output_tokens,
2900            temperature,
2901            reasoning,
2902            capabilities: ModelCapabilities {
2903                id: id.into(),
2904                limits: ModelLimits {
2905                    context: 1_000_000,
2906                    input: None,
2907                    output: 384_000,
2908                },
2909                reasoning: true,
2910                reasoning_options: vec![
2911                    ReasoningOption::Toggle,
2912                    ReasoningOption::Effort {
2913                        values: vec!["none".into(), "low".into(), "medium".into(), "high".into()],
2914                    },
2915                ],
2916                temperature: true,
2917                tool_call: true,
2918                interleaved: None,
2919                status: None,
2920                limits_source: LimitsSource::Catalog,
2921            },
2922        }
2923    }
2924
2925    fn default_model(id: &str, protocol: WireProtocol) -> ResolvedModelConfig {
2926        resolved_model(id, protocol, 2_048, None, ReasoningConfig::default())
2927    }
2928
2929    fn user(prompt: &str) -> ModelTurnInput {
2930        ModelTurnInput {
2931            system_prompt: None,
2932            messages: vec![ChatMessage::User {
2933                content: prompt.into(),
2934                attachments: vec![],
2935            }],
2936            tools: vec![],
2937            hosted_tools: vec![],
2938            tool_choice: ToolChoice::Auto,
2939            parallel_tool_calls: None,
2940        }
2941    }
2942
2943    fn bash_spec() -> ToolSpec {
2944        ToolSpec {
2945            name: "bash".into(),
2946            description: "Run a shell command inside the sandbox.".into(),
2947            input_schema: json!({
2948                "type": "object",
2949                "properties": {"command": {"type": "string"}},
2950                "required": ["command"],
2951                "additionalProperties": false
2952            }),
2953        }
2954    }
2955
2956    #[test]
2957    fn openai_client_builds_chat_completions_request() {
2958        // base_url is the full prefix: the route is appended verbatim, the
2959        // version segment is NOT injected — a trailing slash is tolerated.
2960        let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
2961            base_url: "https://example.test/v1/".into(),
2962            api_key: "sk-test".into(),
2963            model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
2964        });
2965        assert_eq!(
2966            client.endpoint(),
2967            "https://example.test/v1/chat/completions"
2968        );
2969        let client_with_v1 = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
2970            base_url: "https://example.test/v1".into(),
2971            api_key: "sk-test".into(),
2972            model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
2973        });
2974        assert_eq!(
2975            client_with_v1.endpoint(),
2976            "https://example.test/v1/chat/completions"
2977        );
2978        // Providers whose version segment is not `/v1` (e.g. GLM's `/v4`) are
2979        // honored verbatim — regression guard for the `/v1`-injection bug.
2980        let glm = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
2981            base_url: "https://open.bigmodel.cn/api/coding/paas/v4".into(),
2982            api_key: "sk-test".into(),
2983            model: default_model("glm-4.6", WireProtocol::OpenAiCompatible),
2984        });
2985        assert_eq!(
2986            glm.endpoint(),
2987            "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions"
2988        );
2989        let body = client.request_body(&user("hello"));
2990        assert_eq!(body["model"], "gpt-test");
2991        assert_eq!(body["messages"][0]["role"], "user");
2992        assert_eq!(body["messages"][0]["content"], "hello");
2993        // tools omitted ⇒ no `tools` / `tool_choice` keys
2994        assert!(body.get("tools").is_none());
2995        assert!(body.get("tool_choice").is_none());
2996
2997        // Now pass an actual ToolSpec and confirm it renders as a function.
2998        let with_tools = ModelTurnInput {
2999            system_prompt: None,
3000            messages: vec![ChatMessage::User {
3001                content: "hello".into(),
3002                attachments: vec![],
3003            }],
3004            tools: vec![bash_spec()],
3005            hosted_tools: vec![],
3006            tool_choice: ToolChoice::Auto,
3007            parallel_tool_calls: None,
3008        };
3009        let body = client.request_body(&with_tools);
3010        assert_eq!(body["tools"][0]["function"]["name"], "bash");
3011        assert_eq!(
3012            body["tools"][0]["function"]["parameters"]["required"][0],
3013            "command"
3014        );
3015        assert_eq!(body["tool_choice"], "auto");
3016        // parallel_tool_calls defaults to None ⇒ field omitted (let
3017        // OpenAI server-side default apply).
3018        assert!(body.get("parallel_tool_calls").is_none());
3019    }
3020
3021    #[test]
3022    fn openai_client_emits_tool_choice_required() {
3023        let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
3024            base_url: "https://example.test".into(),
3025            api_key: "sk-test".into(),
3026            model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
3027        });
3028        let body = client.request_body(&ModelTurnInput {
3029            system_prompt: None,
3030            messages: vec![ChatMessage::User {
3031                content: "go".into(),
3032                attachments: vec![],
3033            }],
3034            tools: vec![bash_spec()],
3035            hosted_tools: vec![],
3036            tool_choice: ToolChoice::Required,
3037            parallel_tool_calls: Some(false),
3038        });
3039        assert_eq!(body["tool_choice"], "required");
3040        assert_eq!(body["parallel_tool_calls"], false);
3041    }
3042
3043    #[test]
3044    fn openai_client_emits_tool_choice_named_tool() {
3045        let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
3046            base_url: "https://example.test".into(),
3047            api_key: "sk-test".into(),
3048            model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
3049        });
3050        let body = client.request_body(&ModelTurnInput {
3051            system_prompt: None,
3052            messages: vec![ChatMessage::User {
3053                content: "go".into(),
3054                attachments: vec![],
3055            }],
3056            tools: vec![bash_spec()],
3057            hosted_tools: vec![],
3058            tool_choice: ToolChoice::Tool("bash".into()),
3059            parallel_tool_calls: None,
3060        });
3061        assert_eq!(body["tool_choice"]["type"], "function");
3062        assert_eq!(body["tool_choice"]["function"]["name"], "bash");
3063    }
3064
3065    #[test]
3066    fn openai_client_drops_tools_when_choice_is_none() {
3067        // tool_choice: None — we drop the tools entirely so the model
3068        // can't call what it doesn't see (cheaper prompt + equivalent
3069        // semantic).
3070        let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
3071            base_url: "https://example.test".into(),
3072            api_key: "sk-test".into(),
3073            model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
3074        });
3075        let body = client.request_body(&ModelTurnInput {
3076            system_prompt: None,
3077            messages: vec![ChatMessage::User {
3078                content: "go".into(),
3079                attachments: vec![],
3080            }],
3081            tools: vec![bash_spec()],
3082            hosted_tools: vec![],
3083            tool_choice: ToolChoice::None,
3084            parallel_tool_calls: None,
3085        });
3086        assert!(body.get("tools").is_none(), "tools should be dropped");
3087        assert!(body.get("tool_choice").is_none());
3088    }
3089
3090    #[tokio::test]
3091    async fn openai_compatible_rejects_hosted_web_search() {
3092        let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
3093            base_url: "https://example.test".into(),
3094            api_key: "sk-test".into(),
3095            model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
3096        });
3097        let err = match client
3098            .stream(ModelTurnInput {
3099                system_prompt: None,
3100                messages: vec![ChatMessage::User {
3101                    content: "search".into(),
3102                    attachments: vec![],
3103                }],
3104                tools: vec![],
3105                hosted_tools: vec![HostedTool::WebSearch],
3106                tool_choice: ToolChoice::Auto,
3107                parallel_tool_calls: None,
3108            })
3109            .await
3110        {
3111            Ok(_) => panic!("expected hosted tool rejection"),
3112            Err(err) => err,
3113        };
3114        assert!(err.to_string().contains("Responses API"));
3115    }
3116
3117    #[test]
3118    fn tool_choice_parse_handles_canonical_strings() {
3119        assert!(matches!(ToolChoice::parse(""), ToolChoice::Auto));
3120        assert!(matches!(ToolChoice::parse("auto"), ToolChoice::Auto));
3121        assert!(matches!(ToolChoice::parse("AUTO"), ToolChoice::Auto));
3122        assert!(matches!(ToolChoice::parse("none"), ToolChoice::None));
3123        assert!(matches!(
3124            ToolChoice::parse("required"),
3125            ToolChoice::Required
3126        ));
3127        assert!(matches!(ToolChoice::parse("any"), ToolChoice::Required));
3128        match ToolChoice::parse("tool:bash") {
3129            ToolChoice::Tool(name) => assert_eq!(name, "bash"),
3130            other => panic!("expected Tool(bash), got {other:?}"),
3131        }
3132        // Unknown degrades to Auto (forward-compat).
3133        assert!(matches!(ToolChoice::parse("garbage"), ToolChoice::Auto));
3134    }
3135
3136    #[test]
3137    fn openai_client_prepends_system_when_set() {
3138        let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
3139            base_url: "https://example.test".into(),
3140            api_key: "sk-test".into(),
3141            model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
3142        });
3143        let input = ModelTurnInput {
3144            system_prompt: Some("you are concise".into()),
3145            messages: vec![ChatMessage::User {
3146                content: "hi".into(),
3147                attachments: vec![],
3148            }],
3149            tools: vec![],
3150            hosted_tools: vec![],
3151            tool_choice: ToolChoice::Auto,
3152            parallel_tool_calls: None,
3153        };
3154        let body = client.request_body(&input);
3155        assert_eq!(body["messages"][0]["role"], "system");
3156        assert_eq!(body["messages"][0]["content"], "you are concise");
3157        assert_eq!(body["messages"][1]["role"], "user");
3158    }
3159
3160    #[test]
3161    fn parse_openai_usage_extracts_token_counts() {
3162        let u = parse_openai_usage(Some(&json!({
3163            "prompt_tokens": 12,
3164            "completion_tokens": 7,
3165            "total_tokens": 19,
3166            "prompt_tokens_details": {"cached_tokens": 4}
3167        })))
3168        .expect("usage parsed");
3169        assert_eq!(u.input_tokens, 12);
3170        assert_eq!(u.output_tokens, 7);
3171        assert_eq!(u.cache_read_input_tokens, 4);
3172        assert_eq!(u.cache_creation_input_tokens, 0);
3173    }
3174
3175    #[test]
3176    fn parse_openai_usage_without_cache_details() {
3177        let u = parse_openai_usage(Some(&json!({
3178            "prompt_tokens": 200,
3179            "completion_tokens": 30
3180        })))
3181        .expect("usage parsed");
3182        assert_eq!(u.input_tokens, 200);
3183        assert_eq!(u.output_tokens, 30);
3184        assert_eq!(u.cache_read_input_tokens, 0);
3185    }
3186
3187    #[test]
3188    fn openai_stream_state_emits_text_deltas_then_done() {
3189        let mut state = OpenAiStreamState::default();
3190        // First chunk seeds id + role.
3191        let out = state
3192            .feed_data(
3193                r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"role":"assistant","content":""}}]}"#,
3194            )
3195            .unwrap();
3196        assert!(out.is_empty(), "empty content shouldn't emit");
3197        // Text deltas.
3198        let out = state
3199            .feed_data(r#"{"choices":[{"index":0,"delta":{"content":"Hello"}}]}"#)
3200            .unwrap();
3201        assert_eq!(out.len(), 1);
3202        match &out[0] {
3203            ModelChunk::TextDelta { msg_id, delta } => {
3204                assert_eq!(msg_id, "chatcmpl-1");
3205                assert_eq!(delta, "Hello");
3206            }
3207            other => panic!("expected TextDelta, got {other:?}"),
3208        }
3209        let out = state
3210            .feed_data(r#"{"choices":[{"index":0,"delta":{"content":" world"}}]}"#)
3211            .unwrap();
3212        assert_eq!(out.len(), 1);
3213        // finish_reason in penultimate chunk (no Done yet — usage may
3214        // follow if include_usage was set).
3215        let out = state
3216            .feed_data(r#"{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#)
3217            .unwrap();
3218        assert!(out.is_empty());
3219        // include_usage final chunk (empty choices, usage populated).
3220        let out = state
3221            .feed_data(r#"{"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":3}}"#)
3222            .unwrap();
3223        assert!(out.is_empty());
3224        // [DONE] sentinel produces the final Done chunk.
3225        let out = state.feed_data("[DONE]").unwrap();
3226        assert_eq!(out.len(), 1);
3227        match &out[0] {
3228            ModelChunk::Done { stop_reason, usage } => {
3229                assert_eq!(stop_reason, "end_turn");
3230                let u = usage.as_ref().expect("usage propagated");
3231                assert_eq!(u.input_tokens, 10);
3232                assert_eq!(u.output_tokens, 3);
3233            }
3234            other => panic!("expected Done, got {other:?}"),
3235        }
3236    }
3237
3238    #[test]
3239    fn openai_stream_state_emits_tool_call_chunks() {
3240        let mut state = OpenAiStreamState::default();
3241        // First tool-call chunk: id + name + initial arguments.
3242        let out = state
3243            .feed_data(
3244                r#"{"id":"c1","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_x","type":"function","function":{"name":"bash","arguments":""}}]}}]}"#,
3245            )
3246            .unwrap();
3247        assert_eq!(out.len(), 1);
3248        match &out[0] {
3249            ModelChunk::ToolCallStart { id, name } => {
3250                assert_eq!(id, "call_x");
3251                assert_eq!(name, "bash");
3252            }
3253            other => panic!("expected ToolCallStart, got {other:?}"),
3254        }
3255        // Streaming arguments come split into multiple chunks.
3256        let out = state
3257            .feed_data(
3258                r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]}}]}"#,
3259            )
3260            .unwrap();
3261        assert_eq!(out.len(), 1);
3262        let ModelChunk::ToolCallInputDelta { delta, .. } = &out[0] else {
3263            panic!("expected ToolCallInputDelta");
3264        };
3265        assert_eq!(delta, "{\"");
3266
3267        let out = state
3268            .feed_data(
3269                r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"cmd\":\"pwd\"}"}}]}}]}"#,
3270            )
3271            .unwrap();
3272        let ModelChunk::ToolCallInputDelta { delta, .. } = &out[0] else {
3273            panic!("expected ToolCallInputDelta");
3274        };
3275        assert_eq!(delta, "cmd\":\"pwd\"}");
3276
3277        // finish_reason="tool_calls" should emit ToolCallEnd for every
3278        // open tool call.
3279        let out = state
3280            .feed_data(r#"{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#)
3281            .unwrap();
3282        assert_eq!(out.len(), 1);
3283        match &out[0] {
3284            ModelChunk::ToolCallEnd { id, input } => {
3285                assert_eq!(id, "call_x");
3286                assert!(input.is_none(), "OpenAI streaming defers parsing");
3287            }
3288            other => panic!("expected ToolCallEnd, got {other:?}"),
3289        }
3290
3291        // Stream closes (no [DONE] sentinel from some gateways) → finalize().
3292        let final_chunk = state.finalize().expect("finalize emits Done");
3293        match final_chunk {
3294            ModelChunk::Done { stop_reason, .. } => assert_eq!(stop_reason, "end_turn"),
3295            other => panic!("expected Done from finalize, got {other:?}"),
3296        }
3297    }
3298
3299    #[tokio::test]
3300    async fn collect_model_response_preserves_raw_tool_arguments() {
3301        let chunks = vec![
3302            Ok(ModelChunk::ToolCallStart {
3303                id: "call_x".into(),
3304                name: "bash".into(),
3305            }),
3306            Ok(ModelChunk::ToolCallInputDelta {
3307                id: "call_x".into(),
3308                delta: r#"{ "b": 2, "#.into(),
3309            }),
3310            Ok(ModelChunk::ToolCallInputDelta {
3311                id: "call_x".into(),
3312                delta: r#""a": 1 }"#.into(),
3313            }),
3314            Ok(ModelChunk::ToolCallEnd {
3315                id: "call_x".into(),
3316                input: None,
3317            }),
3318            Ok(ModelChunk::Done {
3319                stop_reason: "end_turn".into(),
3320                usage: None,
3321            }),
3322        ];
3323
3324        let response = collect_model_response(futures::stream::iter(chunks).boxed())
3325            .await
3326            .unwrap();
3327        let ModelResponse::ToolCall { invocation, .. } = response else {
3328            panic!("expected tool call response");
3329        };
3330
3331        assert_eq!(invocation.input, json!({"b": 2, "a": 1}));
3332        assert_eq!(
3333            invocation.raw_emitted_args.as_deref(),
3334            Some(r#"{ "b": 2, "a": 1 }"#)
3335        );
3336    }
3337
3338    #[test]
3339    fn openai_projection_uses_raw_tool_arguments_when_matching_input() {
3340        let msg = ChatMessage::Assistant {
3341            text: None,
3342            tool_calls: vec![ToolInvocation {
3343                id: "call_1".into(),
3344                name: "bash".into(),
3345                input: json!({"b": 2, "a": 1}),
3346                raw_emitted_args: Some(r#"{ "b": 2, "a": 1 }"#.into()),
3347            }],
3348            thinking: None,
3349            usage: None,
3350        };
3351
3352        let wire = chat_message_to_wire(&msg);
3353        assert_eq!(
3354            wire["tool_calls"][0]["function"]["arguments"],
3355            r#"{ "b": 2, "a": 1 }"#
3356        );
3357    }
3358
3359    #[test]
3360    fn openai_projection_ignores_stale_raw_tool_arguments() {
3361        let msg = ChatMessage::Assistant {
3362            text: None,
3363            tool_calls: vec![ToolInvocation {
3364                id: "call_1".into(),
3365                name: "bash".into(),
3366                input: json!({"command": "pwd"}),
3367                raw_emitted_args: Some(r#"{"command": "rm -rf /"}"#.into()),
3368            }],
3369            thinking: None,
3370            usage: None,
3371        };
3372
3373        let wire = chat_message_to_wire(&msg);
3374        assert_eq!(
3375            wire["tool_calls"][0]["function"]["arguments"],
3376            json!({"command": "pwd"}).to_string()
3377        );
3378    }
3379
3380    #[test]
3381    fn map_openai_finish_reason_table() {
3382        assert_eq!(map_openai_finish_reason(Some("stop")), "end_turn");
3383        assert_eq!(map_openai_finish_reason(Some("length")), "max_tokens");
3384        assert_eq!(map_openai_finish_reason(Some("tool_calls")), "end_turn");
3385        assert_eq!(map_openai_finish_reason(Some("content_filter")), "refusal");
3386        assert_eq!(map_openai_finish_reason(None), "end_turn");
3387        assert_eq!(map_openai_finish_reason(Some("")), "end_turn");
3388    }
3389
3390    // ── Anthropic Messages API ────────────────────────────────────────
3391
3392    #[test]
3393    fn chat_message_to_openai_wire_text_only_keeps_string_content() {
3394        // Fast path: no attachments → content stays as a plain string
3395        // (byte-identical to pre-multimodal behaviour).
3396        let msg = ChatMessage::User {
3397            content: "hello".into(),
3398            attachments: vec![],
3399        };
3400        let v = chat_message_to_wire(&msg);
3401        assert_eq!(v["role"], "user");
3402        assert_eq!(v["content"], "hello");
3403        // Not an array — that distinction matters for OpenAI-compatible
3404        // gateways that strict-parse the chat schema.
3405        assert!(v["content"].is_string());
3406    }
3407
3408    #[test]
3409    fn chat_message_to_openai_wire_with_base64_image() {
3410        let msg = ChatMessage::User {
3411            content: "describe this".into(),
3412            attachments: vec![UserAttachment::Image(ImageSource {
3413                media_type: "image/png".into(),
3414                data: ImageData::Base64("iVBORw0KG...".into()),
3415            })],
3416        };
3417        let v = chat_message_to_wire(&msg);
3418        let parts = v["content"].as_array().expect("content array");
3419        assert_eq!(parts.len(), 2);
3420        assert_eq!(parts[0]["type"], "text");
3421        assert_eq!(parts[0]["text"], "describe this");
3422        assert_eq!(parts[1]["type"], "image_url");
3423        // base64 must be wrapped in a data URI for OpenAI.
3424        let url = parts[1]["image_url"]["url"].as_str().unwrap();
3425        assert!(url.starts_with("data:image/png;base64,"));
3426        assert!(url.contains("iVBORw0KG..."));
3427    }
3428
3429    #[test]
3430    fn chat_message_to_openai_wire_with_url_image() {
3431        let msg = ChatMessage::User {
3432            content: "".into(), // empty text, image-only
3433            attachments: vec![UserAttachment::Image(ImageSource {
3434                media_type: "image/jpeg".into(),
3435                data: ImageData::Url("https://cdn.example.com/cat.jpg".into()),
3436            })],
3437        };
3438        let v = chat_message_to_wire(&msg);
3439        let parts = v["content"].as_array().unwrap();
3440        // Empty text dropped — only image part survives.
3441        assert_eq!(parts.len(), 1);
3442        assert_eq!(parts[0]["type"], "image_url");
3443        assert_eq!(
3444            parts[0]["image_url"]["url"],
3445            "https://cdn.example.com/cat.jpg"
3446        );
3447    }
3448
3449    #[test]
3450    fn chat_message_to_openai_tool_role_degrades_image_to_placeholder() {
3451        // OpenAI's `tool` role is strictly string-typed — images coming
3452        // out of MCP tool calls have to degrade. We append a placeholder
3453        // line per attachment so the model still notices something
3454        // visual was returned, even if it can't see it.
3455        let msg = ChatMessage::Tool {
3456            tool_call_id: "call_x".into(),
3457            content: "ok".into(),
3458            is_error: false,
3459            attachments: vec![UserAttachment::Image(ImageSource {
3460                media_type: "image/png".into(),
3461                data: ImageData::Base64("AAA".into()),
3462            })],
3463        };
3464        let v = chat_message_to_wire(&msg);
3465        assert_eq!(v["role"], "tool");
3466        assert_eq!(v["tool_call_id"], "call_x");
3467        let content = v["content"].as_str().unwrap();
3468        assert!(content.starts_with("ok\n"));
3469        assert!(content.contains("image attached: image/png"));
3470        // Base64 bytes must NOT leak into the wire — degradation, not
3471        // smuggling.
3472        assert!(!content.contains("AAA"));
3473    }
3474
3475    // ── E7: tool_result replay compaction ──
3476
3477    #[test]
3478    fn replay_compaction_leaves_small_results_untouched() {
3479        let small = "x".repeat(1_000);
3480        assert!(matches!(
3481            compact_tool_result_for_replay(&small),
3482            std::borrow::Cow::Borrowed(_)
3483        ));
3484        // Over the token budget (10_000 ASCII chars / 4 = 2_500 > 2_000)
3485        // → compacted even though bytes are under 12 KB.
3486        let medium = "word ".repeat(2_000);
3487        let out = compact_tool_result_for_replay(&medium);
3488        assert!(out.contains("compacted for model replay"));
3489    }
3490
3491    #[test]
3492    fn replay_compaction_keeps_head_and_tail_deterministically() {
3493        let body = format!("HEAD_MARK{}TAIL_MARK", "x".repeat(20_000));
3494        let first = compact_tool_result_for_replay(&body).into_owned();
3495        let second = compact_tool_result_for_replay(&body).into_owned();
3496        // Deterministic: byte-identical across calls (prompt-cache safety).
3497        assert_eq!(first, second);
3498        assert!(first.starts_with("[tool result compacted for model replay]"));
3499        assert!(first.contains("HEAD_MARK"), "head survives");
3500        assert!(first.contains("TAIL_MARK"), "tail survives");
3501        assert!(first.contains("omitted"), "omission marker present");
3502        // Massively smaller than the original.
3503        assert!(first.len() < body.len() / 2);
3504    }
3505
3506    #[test]
3507    fn openai_projection_compacts_oversized_tool_result() {
3508        let big = format!("START{}END", "y".repeat(20_000));
3509        let msg = ChatMessage::Tool {
3510            tool_call_id: "call_big".into(),
3511            content: big.clone(),
3512            is_error: false,
3513            attachments: vec![],
3514        };
3515        let v = chat_message_to_wire(&msg);
3516        let content = v["content"].as_str().unwrap();
3517        assert!(content.contains("compacted for model replay"));
3518        assert!(content.contains("START") && content.contains("END"));
3519        // Projection-only: the source message still holds the full result.
3520        match &msg {
3521            ChatMessage::Tool { content, .. } => assert_eq!(content.len(), big.len()),
3522            _ => unreachable!(),
3523        }
3524    }
3525
3526    #[test]
3527    fn anthropic_projection_compacts_oversized_tool_result() {
3528        let big = "z".repeat(20_000);
3529        let msgs = vec![
3530            ChatMessage::Assistant {
3531                text: None,
3532                tool_calls: vec![crate::tools::ToolInvocation {
3533                    id: "tc_big".into(),
3534                    name: "bash".into(),
3535                    input: json!({}),
3536                    raw_emitted_args: None,
3537                }],
3538                thinking: None,
3539                usage: None,
3540            },
3541            ChatMessage::Tool {
3542                tool_call_id: "tc_big".into(),
3543                content: big,
3544                is_error: false,
3545                attachments: vec![],
3546            },
3547        ];
3548        let wire = chat_messages_to_anthropic_messages(&msgs);
3549        let rendered = serde_json::to_string(&wire).unwrap();
3550        assert!(rendered.contains("compacted for model replay"));
3551    }
3552
3553    #[test]
3554    fn chat_messages_to_anthropic_tool_result_carries_image_block() {
3555        // After an assistant tool_use, a Tool message that carries an
3556        // image attachment (e.g. MCP screenshot) should project to a
3557        // tool_result whose content array contains both the text and
3558        // the image content block.
3559        let msgs = vec![
3560            ChatMessage::Assistant {
3561                text: None,
3562                tool_calls: vec![ToolInvocation {
3563                    id: "tc_img".into(),
3564                    name: "screenshot".into(),
3565                    input: json!({}),
3566                    raw_emitted_args: None,
3567                }],
3568                thinking: None,
3569                usage: None,
3570            },
3571            ChatMessage::Tool {
3572                tool_call_id: "tc_img".into(),
3573                content: "see image".into(),
3574                is_error: false,
3575                attachments: vec![UserAttachment::Image(ImageSource {
3576                    media_type: "image/png".into(),
3577                    data: ImageData::Base64("PNGBYTES".into()),
3578                })],
3579            },
3580        ];
3581        let out = chat_messages_to_anthropic_messages(&msgs);
3582        // [assistant tool_use, user(tool_result containing text+image)]
3583        assert_eq!(out.len(), 2);
3584        let user = &out[1];
3585        assert_eq!(user["role"], "user");
3586        let outer = user["content"].as_array().unwrap();
3587        assert_eq!(outer.len(), 1);
3588        assert_eq!(outer[0]["type"], "tool_result");
3589        assert_eq!(outer[0]["tool_use_id"], "tc_img");
3590        let inner = outer[0]["content"].as_array().unwrap();
3591        // tool_result content is now block-array form (not a string)
3592        // when attachments are present.
3593        assert_eq!(inner.len(), 2);
3594        assert_eq!(inner[0]["type"], "text");
3595        assert_eq!(inner[0]["text"], "see image");
3596        assert_eq!(inner[1]["type"], "image");
3597        assert_eq!(inner[1]["source"]["type"], "base64");
3598        assert_eq!(inner[1]["source"]["media_type"], "image/png");
3599        assert_eq!(inner[1]["source"]["data"], "PNGBYTES");
3600    }
3601
3602    #[test]
3603    fn chat_messages_to_anthropic_renders_user_text_with_image_block() {
3604        let msgs = vec![ChatMessage::User {
3605            content: "what is this".into(),
3606            attachments: vec![UserAttachment::Image(ImageSource {
3607                media_type: "image/png".into(),
3608                data: ImageData::Base64("AAAA".into()),
3609            })],
3610        }];
3611        let out = chat_messages_to_anthropic_messages(&msgs);
3612        assert_eq!(out.len(), 1);
3613        let blocks = out[0]["content"].as_array().unwrap();
3614        // Text first, then image — same ordering as OpenAI parts.
3615        assert_eq!(blocks[0]["type"], "text");
3616        assert_eq!(blocks[0]["text"], "what is this");
3617        assert_eq!(blocks[1]["type"], "image");
3618        // base64 source shape
3619        assert_eq!(blocks[1]["source"]["type"], "base64");
3620        assert_eq!(blocks[1]["source"]["media_type"], "image/png");
3621        assert_eq!(blocks[1]["source"]["data"], "AAAA");
3622    }
3623
3624    #[test]
3625    fn chat_messages_to_anthropic_renders_url_image() {
3626        let msgs = vec![ChatMessage::User {
3627            content: "".into(),
3628            attachments: vec![UserAttachment::Image(ImageSource {
3629                media_type: "image/jpeg".into(),
3630                data: ImageData::Url("https://example.com/x.jpg".into()),
3631            })],
3632        }];
3633        let out = chat_messages_to_anthropic_messages(&msgs);
3634        let blocks = out[0]["content"].as_array().unwrap();
3635        // Empty-text user with image: only one block (the image).
3636        assert_eq!(blocks.len(), 1);
3637        assert_eq!(blocks[0]["type"], "image");
3638        assert_eq!(blocks[0]["source"]["type"], "url");
3639        assert_eq!(blocks[0]["source"]["url"], "https://example.com/x.jpg");
3640    }
3641
3642    #[test]
3643    fn chat_message_to_anthropic_merges_tool_results_and_image() {
3644        // Tool result pending + a user message that also carries an image:
3645        // both must land in the same user message's content array.
3646        let msgs = vec![
3647            ChatMessage::Assistant {
3648                text: None,
3649                tool_calls: vec![ToolInvocation {
3650                    id: "tc_1".into(),
3651                    name: "screenshot".into(),
3652                    input: json!({}),
3653                    raw_emitted_args: None,
3654                }],
3655                thinking: None,
3656                usage: None,
3657            },
3658            ChatMessage::Tool {
3659                tool_call_id: "tc_1".into(),
3660                content: "captured".into(),
3661                is_error: false,
3662                attachments: vec![],
3663            },
3664            ChatMessage::User {
3665                content: "what changed?".into(),
3666                attachments: vec![UserAttachment::Image(ImageSource {
3667                    media_type: "image/png".into(),
3668                    data: ImageData::Base64("ZZ".into()),
3669                })],
3670            },
3671        ];
3672        let out = chat_messages_to_anthropic_messages(&msgs);
3673        // [assistant tool_use, user(tool_result + text + image)]
3674        assert_eq!(out.len(), 2);
3675        let blocks = out[1]["content"].as_array().unwrap();
3676        assert_eq!(blocks.len(), 3);
3677        assert_eq!(blocks[0]["type"], "tool_result");
3678        assert_eq!(blocks[0]["tool_use_id"], "tc_1");
3679        assert_eq!(blocks[1]["type"], "text");
3680        assert_eq!(blocks[1]["text"], "what changed?");
3681        assert_eq!(blocks[2]["type"], "image");
3682    }
3683
3684    #[test]
3685    fn chat_messages_to_anthropic_renders_simple_user_assistant() {
3686        let msgs = vec![
3687            ChatMessage::User {
3688                content: "hi".into(),
3689                attachments: vec![],
3690            },
3691            ChatMessage::Assistant {
3692                text: Some("hello".into()),
3693                tool_calls: vec![],
3694                thinking: None,
3695                usage: None,
3696            },
3697        ];
3698        let out = chat_messages_to_anthropic_messages(&msgs);
3699        assert_eq!(out.len(), 2);
3700        assert_eq!(out[0]["role"], "user");
3701        assert_eq!(out[0]["content"][0]["type"], "text");
3702        assert_eq!(out[0]["content"][0]["text"], "hi");
3703        assert_eq!(out[1]["role"], "assistant");
3704        assert_eq!(out[1]["content"][0]["text"], "hello");
3705    }
3706
3707    #[test]
3708    fn chat_messages_to_anthropic_folds_tool_results_into_next_user() {
3709        // After an assistant tool_use, the next user message is the one
3710        // carrying the tool_result content block — no separate user/tool
3711        // hop on the wire.
3712        let msgs = vec![
3713            ChatMessage::User {
3714                content: "do it".into(),
3715                attachments: vec![],
3716            },
3717            ChatMessage::Assistant {
3718                text: None,
3719                tool_calls: vec![ToolInvocation {
3720                    id: "call_1".into(),
3721                    name: "bash".into(),
3722                    input: json!({"command": "pwd"}),
3723                    raw_emitted_args: None,
3724                }],
3725                thinking: None,
3726                usage: None,
3727            },
3728            ChatMessage::Tool {
3729                tool_call_id: "call_1".into(),
3730                content: "{\"stdout\":\"/\"}".into(),
3731                is_error: false,
3732                attachments: vec![],
3733            },
3734            ChatMessage::User {
3735                content: "explain".into(),
3736                attachments: vec![],
3737            },
3738        ];
3739        let out = chat_messages_to_anthropic_messages(&msgs);
3740        // user, assistant(tool_use), user(tool_result + "explain")
3741        assert_eq!(out.len(), 3);
3742        assert_eq!(out[1]["role"], "assistant");
3743        assert_eq!(out[1]["content"][0]["type"], "tool_use");
3744        assert_eq!(out[1]["content"][0]["id"], "call_1");
3745        assert_eq!(out[2]["role"], "user");
3746        assert_eq!(out[2]["content"][0]["type"], "tool_result");
3747        assert_eq!(out[2]["content"][0]["tool_use_id"], "call_1");
3748        assert_eq!(out[2]["content"][1]["type"], "text");
3749        assert_eq!(out[2]["content"][1]["text"], "explain");
3750    }
3751
3752    #[test]
3753    fn chat_messages_to_anthropic_renders_thinking_then_text_then_tool_use() {
3754        let msgs = vec![ChatMessage::Assistant {
3755            text: Some("preface".into()),
3756            tool_calls: vec![ToolInvocation {
3757                id: "t".into(),
3758                name: "n".into(),
3759                input: json!({"a": 1}),
3760                raw_emitted_args: None,
3761            }],
3762            thinking: Some(AssistantThinking {
3763                text: "deep thought".into(),
3764                signature: Some("sig123".into()),
3765            }),
3766            usage: None,
3767        }];
3768        let out = chat_messages_to_anthropic_messages(&msgs);
3769        let blocks = out[0]["content"].as_array().unwrap();
3770        // Order: thinking → text → tool_use.
3771        assert_eq!(blocks[0]["type"], "thinking");
3772        assert_eq!(blocks[0]["thinking"], "deep thought");
3773        assert_eq!(blocks[0]["signature"], "sig123");
3774        assert_eq!(blocks[1]["type"], "text");
3775        assert_eq!(blocks[1]["text"], "preface");
3776        assert_eq!(blocks[2]["type"], "tool_use");
3777    }
3778
3779    #[test]
3780    fn chat_messages_to_anthropic_trailing_tool_results_flushed() {
3781        // Conversation ends on tool results without a follow-up user
3782        // turn — we still need to send the results to the model.
3783        let msgs = vec![
3784            ChatMessage::Assistant {
3785                text: None,
3786                tool_calls: vec![ToolInvocation {
3787                    id: "t".into(),
3788                    name: "n".into(),
3789                    input: json!({}),
3790                    raw_emitted_args: None,
3791                }],
3792                thinking: None,
3793                usage: None,
3794            },
3795            ChatMessage::Tool {
3796                tool_call_id: "t".into(),
3797                content: "ok".into(),
3798                is_error: false,
3799                attachments: vec![],
3800            },
3801        ];
3802        let out = chat_messages_to_anthropic_messages(&msgs);
3803        assert_eq!(out.len(), 2);
3804        assert_eq!(out[1]["role"], "user");
3805        assert_eq!(out[1]["content"][0]["type"], "tool_result");
3806    }
3807
3808    #[test]
3809    fn apply_anthropic_cache_strategy_marks_system_last_tool_and_last_message() {
3810        let system = anthropic_system_field(Some("system prompt"));
3811        let tools = vec![
3812            json!({"name": "a", "description": "", "input_schema": {"type": "object"}}),
3813            json!({"name": "b", "description": "", "input_schema": {"type": "object"}}),
3814        ];
3815        let messages = vec![
3816            json!({"role": "user", "content": [{"type": "text", "text": "hi"}]}),
3817            json!({"role": "assistant", "content": [{"type": "text", "text": "hello"}]}),
3818        ];
3819        let out = apply_anthropic_cache_strategy(system, tools, messages);
3820        let sys_block = &out.system.as_ref().unwrap()[0];
3821        assert_eq!(sys_block["cache_control"]["type"], "ephemeral");
3822        // Last tool (only the LAST one) cached, not the first.
3823        assert!(out.tools[0].get("cache_control").is_none());
3824        assert_eq!(out.tools[1]["cache_control"]["type"], "ephemeral");
3825        // Last message's last content block cached.
3826        let last_msg_blocks = out.messages.last().unwrap()["content"].as_array().unwrap();
3827        assert_eq!(
3828            last_msg_blocks.last().unwrap()["cache_control"]["type"],
3829            "ephemeral"
3830        );
3831    }
3832
3833    #[test]
3834    fn apply_anthropic_cache_strategy_skips_empty_system() {
3835        // Empty system → omitted entirely (Anthropic rejects cache_control
3836        // on empty text blocks).
3837        let out = apply_anthropic_cache_strategy(None, vec![], vec![]);
3838        assert!(out.system.is_none());
3839    }
3840
3841    fn anthropic_client_for_tool_choice_tests() -> AnthropicModelClient {
3842        AnthropicModelClient::new(AnthropicConfig {
3843            base_url: "https://example.test".into(),
3844            api_key: "sk-test".into(),
3845            model: resolved_model(
3846                "claude-test",
3847                WireProtocol::Anthropic,
3848                1_024,
3849                None,
3850                ReasoningConfig::default(),
3851            ),
3852            anthropic_version: AnthropicConfig::DEFAULT_VERSION.into(),
3853        })
3854    }
3855
3856    #[test]
3857    fn anthropic_client_omits_tool_choice_when_auto() {
3858        // Auto is Anthropic's default — keep the wire byte-identical
3859        // by omitting the field (prompt cache stability).
3860        let client = anthropic_client_for_tool_choice_tests();
3861        let body = client.request_body(&ModelTurnInput {
3862            system_prompt: None,
3863            messages: vec![ChatMessage::User {
3864                content: "go".into(),
3865                attachments: vec![],
3866            }],
3867            tools: vec![bash_spec()],
3868            hosted_tools: vec![],
3869            tool_choice: ToolChoice::Auto,
3870            parallel_tool_calls: None,
3871        });
3872        assert!(body["tools"].as_array().unwrap().len() > 0);
3873        assert!(body.get("tool_choice").is_none());
3874        // parallel_tool_calls is OpenAI-only — Anthropic body must
3875        // never carry it.
3876        assert!(body.get("parallel_tool_calls").is_none());
3877    }
3878
3879    #[test]
3880    fn anthropic_client_emits_tool_choice_required_as_any() {
3881        let client = anthropic_client_for_tool_choice_tests();
3882        let body = client.request_body(&ModelTurnInput {
3883            system_prompt: None,
3884            messages: vec![ChatMessage::User {
3885                content: "go".into(),
3886                attachments: vec![],
3887            }],
3888            tools: vec![bash_spec()],
3889            hosted_tools: vec![],
3890            tool_choice: ToolChoice::Required,
3891            parallel_tool_calls: Some(true),
3892        });
3893        assert_eq!(body["tool_choice"]["type"], "any");
3894        // OpenAI-only knob must NOT leak into Anthropic body even when
3895        // caller set it (harness passes it uniformly to both providers).
3896        assert!(body.get("parallel_tool_calls").is_none());
3897    }
3898
3899    #[test]
3900    fn anthropic_client_projects_hosted_web_search_tool() {
3901        let client = anthropic_client_for_tool_choice_tests();
3902        let body = client.request_body(&ModelTurnInput {
3903            system_prompt: None,
3904            messages: vec![ChatMessage::User {
3905                content: "research current AI market".into(),
3906                attachments: vec![],
3907            }],
3908            tools: vec![bash_spec()],
3909            hosted_tools: vec![HostedTool::WebSearch],
3910            tool_choice: ToolChoice::Auto,
3911            parallel_tool_calls: None,
3912        });
3913        let tools = body["tools"].as_array().unwrap();
3914        let web = tools
3915            .iter()
3916            .find(|tool| tool.get("name").and_then(Value::as_str) == Some("web_search"))
3917            .unwrap();
3918        assert_eq!(web["type"], "web_search_20250305");
3919        assert!(web.get("max_uses").is_none());
3920    }
3921
3922    #[test]
3923    fn clients_report_web_search_support_by_protocol_and_endpoint() {
3924        let chat = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
3925            base_url: "https://api.openai.com/v1".into(),
3926            api_key: "sk-test".into(),
3927            model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
3928        });
3929        assert_eq!(
3930            chat.hosted_capability(HostedCapability::WebSearch),
3931            CapabilitySupport::Unsupported
3932        );
3933
3934        let responses = OpenAiResponsesModelClient::new(OpenAiResponsesConfig {
3935            base_url: "https://api.openai.com/v1".into(),
3936            api_key: "sk-test".into(),
3937            model: default_model("gpt-test", WireProtocol::OpenAiResponses),
3938            reasoning_summary: None,
3939        });
3940        assert_eq!(
3941            responses.hosted_capability(HostedCapability::WebSearch),
3942            CapabilitySupport::Supported
3943        );
3944
3945        let gateway = OpenAiResponsesModelClient::new(OpenAiResponsesConfig {
3946            base_url: "https://gateway.example/v1".into(),
3947            api_key: "sk-test".into(),
3948            model: default_model("gpt-test", WireProtocol::OpenAiResponses),
3949            reasoning_summary: None,
3950        });
3951        assert_eq!(
3952            gateway.hosted_capability(HostedCapability::WebSearch),
3953            CapabilitySupport::Unknown
3954        );
3955    }
3956
3957    #[test]
3958    fn anthropic_client_emits_tool_choice_named_tool() {
3959        let client = anthropic_client_for_tool_choice_tests();
3960        let body = client.request_body(&ModelTurnInput {
3961            system_prompt: None,
3962            messages: vec![ChatMessage::User {
3963                content: "go".into(),
3964                attachments: vec![],
3965            }],
3966            tools: vec![bash_spec()],
3967            hosted_tools: vec![],
3968            tool_choice: ToolChoice::Tool("bash".into()),
3969            parallel_tool_calls: None,
3970        });
3971        assert_eq!(body["tool_choice"]["type"], "tool");
3972        assert_eq!(body["tool_choice"]["name"], "bash");
3973    }
3974
3975    #[test]
3976    fn anthropic_client_drops_tools_when_choice_is_none() {
3977        // Anthropic has no native "tool_choice: none" — best
3978        // approximation is dropping the tools array. The body's
3979        // `tools` key must be absent and `tool_choice` too.
3980        let client = anthropic_client_for_tool_choice_tests();
3981        let body = client.request_body(&ModelTurnInput {
3982            system_prompt: None,
3983            messages: vec![ChatMessage::User {
3984                content: "go".into(),
3985                attachments: vec![],
3986            }],
3987            tools: vec![bash_spec()],
3988            hosted_tools: vec![],
3989            tool_choice: ToolChoice::None,
3990            parallel_tool_calls: None,
3991        });
3992        assert!(body.get("tools").is_none());
3993        assert!(body.get("tool_choice").is_none());
3994    }
3995
3996    #[test]
3997    fn anthropic_stream_state_text_only() {
3998        let mut s = AnthropicStreamState::default();
3999        // message_start with id + usage.
4000        let _ = s
4001            .feed_event(
4002                "message_start",
4003                r#"{"type":"message_start","message":{"id":"msg_01","usage":{"input_tokens":10,"output_tokens":0}}}"#,
4004            )
4005            .unwrap();
4006        let _ = s
4007            .feed_event(
4008                "content_block_start",
4009                r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#,
4010            )
4011            .unwrap();
4012        let out = s
4013            .feed_event(
4014                "content_block_delta",
4015                r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#,
4016            )
4017            .unwrap();
4018        assert_eq!(out.len(), 1);
4019        match &out[0] {
4020            ModelChunk::TextDelta { msg_id, delta } => {
4021                assert_eq!(msg_id, "msg_01");
4022                assert_eq!(delta, "Hello");
4023            }
4024            other => panic!("expected TextDelta, got {other:?}"),
4025        }
4026        let _ = s.feed_event(
4027            "message_delta",
4028            r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}"#,
4029        );
4030        let out = s
4031            .feed_event("message_stop", r#"{"type":"message_stop"}"#)
4032            .unwrap();
4033        assert_eq!(out.len(), 1);
4034        match &out[0] {
4035            ModelChunk::Done { stop_reason, usage } => {
4036                assert_eq!(stop_reason, "end_turn");
4037                let u = usage.as_ref().unwrap();
4038                assert_eq!(u.input_tokens, 10);
4039                assert_eq!(u.output_tokens, 5);
4040            }
4041            other => panic!("expected Done, got {other:?}"),
4042        }
4043    }
4044
4045    #[test]
4046    fn anthropic_stream_state_thinking_block_emits_delta_and_signature() {
4047        let mut s = AnthropicStreamState::default();
4048        let _ = s.feed_event(
4049            "message_start",
4050            r#"{"type":"message_start","message":{"id":"msg_t"}}"#,
4051        );
4052        let _ = s.feed_event(
4053            "content_block_start",
4054            r#"{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#,
4055        );
4056        let out = s
4057            .feed_event(
4058                "content_block_delta",
4059                r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"reasoning..."}}"#,
4060            )
4061            .unwrap();
4062        assert_eq!(out.len(), 1);
4063        let ModelChunk::ThinkingDelta {
4064            delta, signature, ..
4065        } = &out[0]
4066        else {
4067            panic!("expected ThinkingDelta");
4068        };
4069        assert_eq!(delta, "reasoning...");
4070        assert!(signature.is_none());
4071
4072        let out = s
4073            .feed_event(
4074                "content_block_delta",
4075                r#"{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig_abc"}}"#,
4076            )
4077            .unwrap();
4078        let ModelChunk::ThinkingDelta {
4079            delta, signature, ..
4080        } = &out[0]
4081        else {
4082            panic!("expected ThinkingDelta");
4083        };
4084        assert_eq!(delta, "");
4085        assert_eq!(signature.as_deref(), Some("sig_abc"));
4086    }
4087
4088    #[test]
4089    fn anthropic_stream_state_tool_use_streamed_input() {
4090        let mut s = AnthropicStreamState::default();
4091        let _ = s.feed_event(
4092            "message_start",
4093            r#"{"type":"message_start","message":{"id":"msg_x"}}"#,
4094        );
4095        let out = s
4096            .feed_event(
4097                "content_block_start",
4098                r#"{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"bash","input":{}}}"#,
4099            )
4100            .unwrap();
4101        assert_eq!(out.len(), 1);
4102        match &out[0] {
4103            ModelChunk::ToolCallStart { id, name } => {
4104                assert_eq!(id, "toolu_1");
4105                assert_eq!(name, "bash");
4106            }
4107            other => panic!("expected ToolCallStart, got {other:?}"),
4108        }
4109        let out = s
4110            .feed_event(
4111                "content_block_delta",
4112                r#"{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"cmd\":"}}"#,
4113            )
4114            .unwrap();
4115        let ModelChunk::ToolCallInputDelta { id, delta } = &out[0] else {
4116            panic!("expected ToolCallInputDelta");
4117        };
4118        assert_eq!(id, "toolu_1");
4119        assert_eq!(delta, "{\"cmd\":");
4120
4121        let out = s
4122            .feed_event(
4123                "content_block_stop",
4124                r#"{"type":"content_block_stop","index":0}"#,
4125            )
4126            .unwrap();
4127        match &out[0] {
4128            ModelChunk::ToolCallEnd { id, input } => {
4129                assert_eq!(id, "toolu_1");
4130                assert!(input.is_none());
4131            }
4132            other => panic!("expected ToolCallEnd, got {other:?}"),
4133        }
4134    }
4135
4136    #[test]
4137    fn anthropic_stream_state_finalises_on_close_without_message_stop() {
4138        // Some gateways drop `message_stop`; the harness still needs a
4139        // Done. finalize() bridges that gap.
4140        let mut s = AnthropicStreamState::default();
4141        let _ = s
4142            .feed_event(
4143                "message_delta",
4144                r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":100}}"#,
4145            )
4146            .unwrap();
4147        let done = s.finalize().unwrap();
4148        match done {
4149            ModelChunk::Done { stop_reason, .. } => assert_eq!(stop_reason, "max_tokens"),
4150            other => panic!("expected Done, got {other:?}"),
4151        }
4152    }
4153
4154    #[test]
4155    fn anthropic_stop_reason_mapping() {
4156        assert_eq!(map_anthropic_stop_reason(Some("end_turn")), "end_turn");
4157        assert_eq!(map_anthropic_stop_reason(Some("tool_use")), "end_turn");
4158        assert_eq!(map_anthropic_stop_reason(Some("max_tokens")), "max_tokens");
4159        assert_eq!(map_anthropic_stop_reason(Some("stop_sequence")), "end_turn");
4160        assert_eq!(map_anthropic_stop_reason(Some("refusal")), "refusal");
4161        assert_eq!(map_anthropic_stop_reason(None), "end_turn");
4162    }
4163
4164    #[test]
4165    fn classify_anthropic_http_error_buckets_by_status() {
4166        use reqwest::StatusCode;
4167        assert!(matches!(
4168            classify_anthropic_http_error(StatusCode::TOO_MANY_REQUESTS, "{}"),
4169            ModelClientError::RateLimit(_)
4170        ));
4171        assert!(matches!(
4172            classify_anthropic_http_error(StatusCode::UNAUTHORIZED, "{}"),
4173            ModelClientError::Auth(_)
4174        ));
4175        assert!(matches!(
4176            classify_anthropic_http_error(
4177                StatusCode::BAD_REQUEST,
4178                "{\"error\":{\"message\":\"prompt is too long; context_length_exceeded\"}}"
4179            ),
4180            ModelClientError::ContextOverflow(_)
4181        ));
4182        assert!(matches!(
4183            classify_anthropic_http_error(StatusCode::BAD_REQUEST, "invalid model"),
4184            ModelClientError::BadRequest(_)
4185        ));
4186        assert!(matches!(
4187            classify_anthropic_http_error(StatusCode::INTERNAL_SERVER_ERROR, "oops"),
4188            ModelClientError::ServerError(_)
4189        ));
4190    }
4191
4192    #[tokio::test]
4193    async fn collect_model_response_folds_streamed_tool_call_arguments() {
4194        // Simulate an OpenAI-style streaming tool call whose arguments
4195        // arrive in three chunks. collect_model_response should glue them
4196        // back into a parsed JSON value and ignore the leading text.
4197        let chunks = vec![
4198            Ok(ModelChunk::TextDelta {
4199                msg_id: "m".into(),
4200                delta: "ok ".into(),
4201            }),
4202            Ok(ModelChunk::ToolCallStart {
4203                id: "call_1".into(),
4204                name: "bash".into(),
4205            }),
4206            Ok(ModelChunk::ToolCallInputDelta {
4207                id: "call_1".into(),
4208                delta: "{\"command\":".into(),
4209            }),
4210            Ok(ModelChunk::ToolCallInputDelta {
4211                id: "call_1".into(),
4212                delta: "\"pwd\"}".into(),
4213            }),
4214            Ok(ModelChunk::ToolCallEnd {
4215                id: "call_1".into(),
4216                input: None,
4217            }),
4218            Ok(ModelChunk::Done {
4219                stop_reason: "end_turn".into(),
4220                usage: None,
4221            }),
4222        ];
4223        let stream = futures::stream::iter(chunks).boxed();
4224        let response = collect_model_response(stream).await.unwrap();
4225        let ModelResponse::ToolCall {
4226            invocation,
4227            preface,
4228            ..
4229        } = response
4230        else {
4231            panic!("expected ToolCall");
4232        };
4233        assert_eq!(invocation.name, "bash");
4234        assert_eq!(invocation.input["command"], "pwd");
4235        assert_eq!(preface.as_deref(), Some("ok "));
4236    }
4237
4238    #[test]
4239    fn classify_openai_http_error_buckets_by_status_and_body() {
4240        use reqwest::StatusCode;
4241        assert!(matches!(
4242            classify_openai_http_error(StatusCode::TOO_MANY_REQUESTS, "rate limit hit"),
4243            ModelClientError::RateLimit(_)
4244        ));
4245        assert!(matches!(
4246            classify_openai_http_error(StatusCode::UNAUTHORIZED, "bad key"),
4247            ModelClientError::Auth(_)
4248        ));
4249        assert!(matches!(
4250            classify_openai_http_error(StatusCode::FORBIDDEN, "no access"),
4251            ModelClientError::Auth(_)
4252        ));
4253        assert!(matches!(
4254            classify_openai_http_error(
4255                StatusCode::BAD_REQUEST,
4256                "{\"error\":{\"message\":\"this model's maximum context length is 8192\"}}"
4257            ),
4258            ModelClientError::ContextOverflow(_)
4259        ));
4260        // BAD_REQUEST without context_overflow tell-tale → BadRequest.
4261        assert!(matches!(
4262            classify_openai_http_error(StatusCode::BAD_REQUEST, "missing argument"),
4263            ModelClientError::BadRequest(_)
4264        ));
4265        // 5xx → ServerError (retryable with backoff).
4266        assert!(matches!(
4267            classify_openai_http_error(StatusCode::INTERNAL_SERVER_ERROR, "oops"),
4268            ModelClientError::ServerError(_)
4269        ));
4270    }
4271
4272    #[test]
4273    fn looks_like_context_overflow_matches_common_phrasings() {
4274        assert!(looks_like_context_overflow(
4275            "context_length_exceeded: this model has a maximum context length of 8192"
4276        ));
4277        assert!(looks_like_context_overflow("too many tokens in prompt"));
4278        assert!(looks_like_context_overflow(
4279            "Prompt exceeds the model's maximum context"
4280        ));
4281        assert!(!looks_like_context_overflow("invalid api key"));
4282    }
4283
4284    #[test]
4285    fn parse_openai_usage_returns_none_for_missing_or_all_zero() {
4286        // Field absent entirely.
4287        assert!(parse_openai_usage(None).is_none());
4288        // Field present but all zeros — treat as "provider didn't report".
4289        assert!(parse_openai_usage(Some(&json!({
4290            "prompt_tokens": 0,
4291            "completion_tokens": 0
4292        })))
4293        .is_none());
4294    }
4295
4296    #[test]
4297    fn openai_client_renders_multi_turn_history() {
4298        let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
4299            base_url: "https://example.test".into(),
4300            api_key: "sk-test".into(),
4301            model: resolved_model(
4302                "gpt-test",
4303                WireProtocol::OpenAiCompatible,
4304                128,
4305                Some(0.2),
4306                ReasoningConfig::default(),
4307            ),
4308        });
4309        let body = client.request_body(&ModelTurnInput {
4310            system_prompt: None,
4311            messages: vec![
4312                ChatMessage::User {
4313                    content: "run pwd".into(),
4314                    attachments: vec![],
4315                },
4316                ChatMessage::Assistant {
4317                    text: None,
4318                    tool_calls: vec![ToolInvocation {
4319                        id: "call_1".into(),
4320                        name: "bash".into(),
4321                        input: json!({"command": "pwd"}),
4322                        raw_emitted_args: None,
4323                    }],
4324                    thinking: None,
4325                    usage: None,
4326                },
4327                ChatMessage::Tool {
4328                    tool_call_id: "call_1".into(),
4329                    content: "{\"stdout\":\"/home/user\"}".into(),
4330                    is_error: false,
4331                    attachments: vec![],
4332                },
4333            ],
4334            tools: vec![],
4335            hosted_tools: vec![],
4336            tool_choice: ToolChoice::Auto,
4337            parallel_tool_calls: None,
4338        });
4339        assert_eq!(body["temperature"], 0.2);
4340        assert_eq!(body["max_tokens"], 128);
4341        assert_eq!(body["messages"][0]["role"], "user");
4342        assert_eq!(body["messages"][1]["role"], "assistant");
4343        assert_eq!(body["messages"][1]["tool_calls"][0]["id"], "call_1");
4344        assert_eq!(
4345            body["messages"][1]["tool_calls"][0]["function"]["name"],
4346            "bash"
4347        );
4348        assert_eq!(body["messages"][2]["role"], "tool");
4349        assert_eq!(body["messages"][2]["tool_call_id"], "call_1");
4350    }
4351
4352    #[test]
4353    fn reasoning_controls_translate_by_wire_protocol() {
4354        let openai = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
4355            base_url: "https://example.test".into(),
4356            api_key: "test".into(),
4357            model: resolved_model(
4358                "deepseek-v4-pro",
4359                WireProtocol::OpenAiCompatible,
4360                4_096,
4361                None,
4362                ReasoningConfig {
4363                    mode: ReasoningMode::Enabled,
4364                    effort: None,
4365                    budget_tokens: None,
4366                },
4367            ),
4368        });
4369        let body = openai.request_body(&user("hi"));
4370        assert_eq!(body["thinking"]["type"], "enabled");
4371
4372        let anthropic = AnthropicModelClient::new(AnthropicConfig {
4373            base_url: "https://example.test/v1".into(),
4374            api_key: "test".into(),
4375            model: resolved_model(
4376                "claude-sonnet-4-6",
4377                WireProtocol::Anthropic,
4378                4_096,
4379                None,
4380                ReasoningConfig {
4381                    mode: ReasoningMode::Enabled,
4382                    effort: Some("high".into()),
4383                    budget_tokens: None,
4384                },
4385            ),
4386            anthropic_version: AnthropicConfig::DEFAULT_VERSION.into(),
4387        });
4388        let body = anthropic.request_body(&user("hi"));
4389        assert_eq!(body["thinking"]["type"], "adaptive");
4390        assert_eq!(body["output_config"]["effort"], "high");
4391
4392        let responses = OpenAiResponsesModelClient::new(OpenAiResponsesConfig {
4393            base_url: "https://example.test/v1".into(),
4394            api_key: "test".into(),
4395            model: resolved_model(
4396                "gpt-5.5",
4397                WireProtocol::OpenAiResponses,
4398                4_096,
4399                None,
4400                ReasoningConfig {
4401                    mode: ReasoningMode::Disabled,
4402                    effort: Some("none".into()),
4403                    budget_tokens: None,
4404                },
4405            ),
4406            reasoning_summary: None,
4407        });
4408        let body = responses.request_body(&user("hi"));
4409        assert_eq!(body["reasoning"]["effort"], "none");
4410    }
4411
4412    #[tokio::test]
4413    async fn scripted_client_emits_tool_call_then_summary() {
4414        let scripted = ScriptedModelClient;
4415        let first = scripted
4416            .next(user("read README.md"))
4417            .await
4418            .expect("scripted first");
4419        let ModelResponse::ToolCall { invocation, .. } = first else {
4420            panic!("expected tool call on first step");
4421        };
4422        assert_eq!(invocation.name, "read");
4423
4424        // Simulate the loop appending Assistant + Tool messages, then ask
4425        // the scripted client for its next move — should be a summary.
4426        let history = ModelTurnInput {
4427            system_prompt: None,
4428            messages: vec![
4429                ChatMessage::User {
4430                    content: "read README.md".into(),
4431                    attachments: vec![],
4432                },
4433                ChatMessage::Assistant {
4434                    text: None,
4435                    tool_calls: vec![invocation.clone()],
4436                    thinking: None,
4437                    usage: None,
4438                },
4439                ChatMessage::Tool {
4440                    tool_call_id: invocation.id.clone(),
4441                    content: "{\"content\":\"hi\"}".into(),
4442                    is_error: false,
4443                    attachments: vec![],
4444                },
4445            ],
4446            tools: vec![],
4447            hosted_tools: vec![],
4448            tool_choice: ToolChoice::Auto,
4449            parallel_tool_calls: None,
4450        };
4451        let second = scripted.next(history).await.expect("scripted second");
4452        let ModelResponse::Message { text, .. } = second else {
4453            panic!("expected final message after tool result");
4454        };
4455        assert!(text.contains("completed"));
4456    }
4457
4458    // ─── OpenAI Responses API ────────────────────────────────────────────
4459
4460    #[test]
4461    fn responses_client_builds_responses_endpoint_and_body() {
4462        let mk = |base: &str| {
4463            OpenAiResponsesModelClient::new(OpenAiResponsesConfig {
4464                base_url: base.into(),
4465                api_key: "sk-test".into(),
4466                model: resolved_model(
4467                    "gpt-5",
4468                    WireProtocol::OpenAiResponses,
4469                    2_048,
4470                    None,
4471                    ReasoningConfig {
4472                        mode: ReasoningMode::Enabled,
4473                        effort: Some("high".into()),
4474                        budget_tokens: None,
4475                    },
4476                ),
4477                reasoning_summary: None,
4478            })
4479        };
4480        // Route appended; version segment respected; no double-append.
4481        assert_eq!(
4482            mk("https://api.openai.com/v1").endpoint(),
4483            "https://api.openai.com/v1/responses"
4484        );
4485        assert_eq!(
4486            mk("https://api.openai.com/v1/").endpoint(),
4487            "https://api.openai.com/v1/responses"
4488        );
4489        assert_eq!(
4490            mk("https://api.openai.com/v1/responses").endpoint(),
4491            "https://api.openai.com/v1/responses"
4492        );
4493
4494        let client = mk("https://api.openai.com/v1");
4495        let mut input = user("hello");
4496        input.system_prompt = Some("be terse".into());
4497        input.tools = vec![bash_spec()];
4498        let body = client.request_body(&input);
4499        assert_eq!(body["model"], "gpt-5");
4500        assert_eq!(body["stream"], true);
4501        assert_eq!(body["store"], false);
4502        assert_eq!(body["instructions"], "be terse");
4503        assert_eq!(body["max_output_tokens"], 2048);
4504        assert_eq!(body["reasoning"]["effort"], "high");
4505        // Stateless mode requests encrypted reasoning for round-trip.
4506        assert_eq!(body["include"][0], "reasoning.encrypted_content");
4507        // Flat tool shape (not nested under `function`).
4508        assert_eq!(body["tools"][0]["type"], "function");
4509        assert_eq!(body["tools"][0]["name"], "bash");
4510        assert!(body["tools"][0]["parameters"].is_object());
4511        assert_eq!(body["tool_choice"], "auto");
4512        // User message projects to an input_text content part.
4513        assert_eq!(body["input"][0]["role"], "user");
4514        assert_eq!(body["input"][0]["content"][0]["type"], "input_text");
4515        assert_eq!(body["input"][0]["content"][0]["text"], "hello");
4516    }
4517
4518    #[test]
4519    fn responses_tool_choice_none_drops_client_and_hosted_tools() {
4520        let client = OpenAiResponsesModelClient::new(OpenAiResponsesConfig {
4521            base_url: "https://api.openai.com/v1".into(),
4522            api_key: "sk-test".into(),
4523            model: default_model("gpt-5", WireProtocol::OpenAiResponses),
4524            reasoning_summary: None,
4525        });
4526        let mut input = user("hi");
4527        input.tools = vec![bash_spec()];
4528        input.hosted_tools = vec![HostedTool::WebSearch];
4529        input.tool_choice = ToolChoice::None;
4530        let body = client.request_body(&input);
4531        // `None` means no tools this turn — that MUST also suppress hosted
4532        // (provider-run) tools, else server-side web_search could still fire.
4533        assert!(
4534            body.get("tools").is_none(),
4535            "tools should be absent, got {:?}",
4536            body.get("tools")
4537        );
4538        assert!(body.get("tool_choice").is_none());
4539
4540        // Sanity: with Auto, both the function tool and the hosted web_search
4541        // are advertised.
4542        input.tool_choice = ToolChoice::Auto;
4543        let body = client.request_body(&input);
4544        let tools = body["tools"].as_array().expect("tools present");
4545        assert_eq!(tools.len(), 2);
4546        assert!(tools.iter().any(|t| t["type"] == "function"));
4547        assert!(tools.iter().any(|t| t["type"] == "web_search"));
4548    }
4549
4550    #[test]
4551    fn responses_tool_choice_required_sent_for_hosted_only() {
4552        let client = OpenAiResponsesModelClient::new(OpenAiResponsesConfig {
4553            base_url: "https://api.openai.com/v1".into(),
4554            api_key: "sk-test".into(),
4555            model: default_model("gpt-5", WireProtocol::OpenAiResponses),
4556            reasoning_summary: None,
4557        });
4558        // Hosted tool only — no client function tools.
4559        let mut input = user("search the web");
4560        input.hosted_tools = vec![HostedTool::WebSearch];
4561
4562        // Required must be sent even without function tools, else it silently
4563        // downgrades to the default auto.
4564        input.tool_choice = ToolChoice::Required;
4565        let body = client.request_body(&input);
4566        assert_eq!(body["tools"][0]["type"], "web_search");
4567        assert_eq!(body["tool_choice"], "required");
4568
4569        // Tool(name) force-selects a FUNCTION tool; with only a hosted tool
4570        // present there's nothing to force, so it must NOT be sent (encoding a
4571        // hosted tool as {type:function,name} would 400).
4572        input.tool_choice = ToolChoice::Tool("bash".into());
4573        let body = client.request_body(&input);
4574        assert_eq!(body["tools"][0]["type"], "web_search");
4575        assert!(
4576            body.get("tool_choice").is_none(),
4577            "Tool(name) must not be sent for a hosted-only turn, got {:?}",
4578            body.get("tool_choice")
4579        );
4580
4581        // With a function tool present, Tool(name) IS sent as a function choice.
4582        input.tools = vec![bash_spec()];
4583        let body = client.request_body(&input);
4584        assert_eq!(body["tool_choice"]["type"], "function");
4585        assert_eq!(body["tool_choice"]["name"], "bash");
4586    }
4587
4588    #[test]
4589    fn responses_stream_state_accepts_reasoning_summary_alias() {
4590        // Some gateways emit `response.reasoning_summary.delta` instead of the
4591        // `_text` variant — both must surface as thinking.
4592        let mut st = OpenAiResponsesStreamState::default();
4593        let chunks = st
4594            .feed_data(
4595                r#"{"type":"response.reasoning_summary.delta","item_id":"rs_1","summary_index":0,"delta":"aliased"}"#,
4596            )
4597            .expect("feed_data");
4598        assert!(
4599            matches!(&chunks[0], ModelChunk::ThinkingDelta { delta, .. } if delta == "aliased")
4600        );
4601    }
4602
4603    #[test]
4604    fn responses_stream_state_separates_reasoning_summary_parts() {
4605        let mut st = OpenAiResponsesStreamState::default();
4606        let mut chunks: Vec<ModelChunk> = Vec::new();
4607        let mut feed = |st: &mut OpenAiResponsesStreamState, data: &str| {
4608            chunks.extend(st.feed_data(data).expect("feed_data"));
4609        };
4610        // The separator rides the FIRST real text delta of a later part — not
4611        // the structural `part.added` event — so it only ever attaches to
4612        // genuine model output. Part 0 gets no prefix; part 1's first delta
4613        // is prefixed with a blank line; subsequent deltas of a part are not.
4614        feed(
4615            &mut st,
4616            r#"{"type":"response.reasoning_summary_part.added","item_id":"rs_1","summary_index":0}"#,
4617        );
4618        feed(
4619            &mut st,
4620            r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","summary_index":0,"delta":"first"}"#,
4621        );
4622        feed(
4623            &mut st,
4624            r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","summary_index":0,"delta":" more"}"#,
4625        );
4626        feed(
4627            &mut st,
4628            r#"{"type":"response.reasoning_summary_part.added","item_id":"rs_1","summary_index":1}"#,
4629        );
4630        feed(
4631            &mut st,
4632            r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","summary_index":1,"delta":"second"}"#,
4633        );
4634        let deltas: Vec<&str> = chunks
4635            .iter()
4636            .filter_map(|c| match c {
4637                ModelChunk::ThinkingDelta { delta, .. } => Some(delta.as_str()),
4638                _ => None,
4639            })
4640            .collect();
4641        // `part.added` produces nothing; the boundary is folded into the
4642        // first text delta of part 1.
4643        assert_eq!(deltas, vec!["first", " more", "\n\nsecond"]);
4644    }
4645
4646    #[test]
4647    fn responses_input_projection_round_trips_tool_calls_and_reasoning() {
4648        let sig = encode_reasoning_signature("rs_123", "ENC==");
4649        let messages = vec![
4650            ChatMessage::User {
4651                content: "hi".into(),
4652                attachments: vec![],
4653            },
4654            ChatMessage::Assistant {
4655                text: Some("looked".into()),
4656                tool_calls: vec![ToolInvocation {
4657                    id: "call_1".into(),
4658                    name: "bash".into(),
4659                    input: json!({"command": "ls"}),
4660                    raw_emitted_args: None,
4661                }],
4662                thinking: Some(AssistantThinking {
4663                    text: "let me look".into(),
4664                    signature: Some(sig),
4665                }),
4666                usage: None,
4667            },
4668            ChatMessage::Tool {
4669                tool_call_id: "call_1".into(),
4670                content: "file.txt".into(),
4671                is_error: false,
4672                attachments: vec![],
4673            },
4674        ];
4675        let input = chat_messages_to_responses_input(&messages);
4676        assert_eq!(input[0]["role"], "user");
4677        // Reasoning item precedes text + tool call, carrying encrypted payload.
4678        assert_eq!(input[1]["type"], "reasoning");
4679        assert_eq!(input[1]["id"], "rs_123");
4680        assert_eq!(input[1]["encrypted_content"], "ENC==");
4681        assert_eq!(input[1]["summary"][0]["text"], "let me look");
4682        assert_eq!(input[2]["role"], "assistant");
4683        assert_eq!(input[2]["content"][0]["type"], "output_text");
4684        // Tool call keyed by call_id (the harness's pairing key).
4685        assert_eq!(input[3]["type"], "function_call");
4686        assert_eq!(input[3]["call_id"], "call_1");
4687        assert_eq!(input[3]["name"], "bash");
4688        assert_eq!(input[4]["type"], "function_call_output");
4689        assert_eq!(input[4]["call_id"], "call_1");
4690        assert_eq!(input[4]["output"], "file.txt");
4691    }
4692
4693    #[test]
4694    fn responses_stream_state_emits_text_toolcall_and_done() {
4695        let mut st = OpenAiResponsesStreamState::default();
4696        let mut chunks: Vec<ModelChunk> = Vec::new();
4697        let mut feed = |st: &mut OpenAiResponsesStreamState, data: &str| {
4698            chunks.extend(st.feed_data(data).expect("feed_data"));
4699        };
4700        feed(
4701            &mut st,
4702            r#"{"type":"response.created","response":{"id":"resp_1"}}"#,
4703        );
4704        feed(
4705            &mut st,
4706            r#"{"type":"response.output_text.delta","item_id":"msg_1","delta":"Hel"}"#,
4707        );
4708        feed(
4709            &mut st,
4710            r#"{"type":"response.output_text.delta","item_id":"msg_1","delta":"lo"}"#,
4711        );
4712        feed(
4713            &mut st,
4714            r#"{"type":"response.output_item.added","item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"bash"}}"#,
4715        );
4716        feed(
4717            &mut st,
4718            r#"{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{\"command\":"}"#,
4719        );
4720        feed(
4721            &mut st,
4722            r#"{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"\"ls\"}"}"#,
4723        );
4724        feed(
4725            &mut st,
4726            r#"{"type":"response.output_item.done","item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
4727        );
4728        feed(
4729            &mut st,
4730            r#"{"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":10,"output_tokens":5,"input_tokens_details":{"cached_tokens":2}}}}"#,
4731        );
4732
4733        assert!(matches!(&chunks[0], ModelChunk::TextDelta { delta, .. } if delta == "Hel"));
4734        assert!(matches!(&chunks[1], ModelChunk::TextDelta { delta, .. } if delta == "lo"));
4735        assert!(
4736            matches!(&chunks[2], ModelChunk::ToolCallStart { id, name } if id == "call_1" && name == "bash")
4737        );
4738        assert!(matches!(&chunks[3], ModelChunk::ToolCallInputDelta { id, .. } if id == "call_1"));
4739        assert!(matches!(&chunks[4], ModelChunk::ToolCallInputDelta { id, .. } if id == "call_1"));
4740        match &chunks[5] {
4741            ModelChunk::ToolCallEnd { id, input } => {
4742                assert_eq!(id, "call_1");
4743                assert_eq!(input.as_ref().expect("early input")["command"], "ls");
4744            }
4745            other => panic!("expected ToolCallEnd, got {other:?}"),
4746        }
4747        match chunks.last().expect("done chunk") {
4748            ModelChunk::Done { stop_reason, usage } => {
4749                assert_eq!(stop_reason, "end_turn");
4750                let u = usage.as_ref().expect("usage");
4751                assert_eq!(u.input_tokens, 10);
4752                assert_eq!(u.output_tokens, 5);
4753                assert_eq!(u.cache_read_input_tokens, 2);
4754            }
4755            other => panic!("expected Done, got {other:?}"),
4756        }
4757        assert!(st.ended_cleanly());
4758    }
4759
4760    #[test]
4761    fn responses_stream_state_round_trips_reasoning_signature() {
4762        let mut st = OpenAiResponsesStreamState::default();
4763        let mut chunks: Vec<ModelChunk> = Vec::new();
4764        chunks.extend(
4765            st.feed_data(
4766                r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","delta":"pondering"}"#,
4767            )
4768            .unwrap(),
4769        );
4770        chunks.extend(
4771            st.feed_data(
4772                r#"{"type":"response.output_item.done","item":{"type":"reasoning","id":"rs_1","encrypted_content":"ENC=="}}"#,
4773            )
4774            .unwrap(),
4775        );
4776        assert!(
4777            matches!(&chunks[0], ModelChunk::ThinkingDelta { delta, signature, .. } if delta == "pondering" && signature.is_none())
4778        );
4779        match &chunks[1] {
4780            ModelChunk::ThinkingDelta {
4781                delta, signature, ..
4782            } => {
4783                assert!(delta.is_empty());
4784                let (id, enc) =
4785                    decode_reasoning_signature(signature.as_ref().expect("signature")).unwrap();
4786                assert_eq!(id, "rs_1");
4787                assert_eq!(enc, "ENC==");
4788            }
4789            other => panic!("expected ThinkingDelta, got {other:?}"),
4790        }
4791    }
4792
4793    #[test]
4794    fn responses_stream_state_reports_cutoff_without_terminal_event() {
4795        let mut st = OpenAiResponsesStreamState::default();
4796        st.feed_data(r#"{"type":"response.output_text.delta","item_id":"m","delta":"hi"}"#)
4797            .unwrap();
4798        // No terminal `response.*` event arrived → the stream was cut off.
4799        assert!(!st.ended_cleanly());
4800    }
4801
4802    #[test]
4803    fn reasoning_signature_encode_decode_roundtrip() {
4804        let sig = encode_reasoning_signature("rs_abc", "base64==payload");
4805        assert_eq!(
4806            decode_reasoning_signature(&sig),
4807            Some(("rs_abc".into(), "base64==payload".into()))
4808        );
4809        // A plain (non-Responses) signature has no newline → not decodable,
4810        // so it is never mis-projected as a reasoning item.
4811        assert_eq!(decode_reasoning_signature("anthropic-sig-no-newline"), None);
4812    }
4813}