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::{ModelCapabilities, ModelLimits, ReasoningConfig, ReasoningOption};
2886
2887    fn resolved_model(
2888        id: &str,
2889        protocol: WireProtocol,
2890        max_output_tokens: u64,
2891        temperature: Option<f64>,
2892        reasoning: ReasoningConfig,
2893    ) -> ResolvedModelConfig {
2894        ResolvedModelConfig {
2895            model: id.into(),
2896            wire_protocol: protocol,
2897            max_output_tokens,
2898            temperature,
2899            reasoning,
2900            capabilities: ModelCapabilities {
2901                id: id.into(),
2902                limits: ModelLimits {
2903                    context: 1_000_000,
2904                    input: None,
2905                    output: 384_000,
2906                },
2907                reasoning: true,
2908                reasoning_options: vec![
2909                    ReasoningOption::Toggle,
2910                    ReasoningOption::Effort {
2911                        values: vec!["none".into(), "low".into(), "medium".into(), "high".into()],
2912                    },
2913                ],
2914                temperature: true,
2915                tool_call: true,
2916                interleaved: None,
2917                status: None,
2918            },
2919        }
2920    }
2921
2922    fn default_model(id: &str, protocol: WireProtocol) -> ResolvedModelConfig {
2923        resolved_model(id, protocol, 2_048, None, ReasoningConfig::default())
2924    }
2925
2926    fn user(prompt: &str) -> ModelTurnInput {
2927        ModelTurnInput {
2928            system_prompt: None,
2929            messages: vec![ChatMessage::User {
2930                content: prompt.into(),
2931                attachments: vec![],
2932            }],
2933            tools: vec![],
2934            hosted_tools: vec![],
2935            tool_choice: ToolChoice::Auto,
2936            parallel_tool_calls: None,
2937        }
2938    }
2939
2940    fn bash_spec() -> ToolSpec {
2941        ToolSpec {
2942            name: "bash".into(),
2943            description: "Run a shell command inside the sandbox.".into(),
2944            input_schema: json!({
2945                "type": "object",
2946                "properties": {"command": {"type": "string"}},
2947                "required": ["command"],
2948                "additionalProperties": false
2949            }),
2950        }
2951    }
2952
2953    #[test]
2954    fn openai_client_builds_chat_completions_request() {
2955        // base_url is the full prefix: the route is appended verbatim, the
2956        // version segment is NOT injected — a trailing slash is tolerated.
2957        let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
2958            base_url: "https://example.test/v1/".into(),
2959            api_key: "sk-test".into(),
2960            model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
2961        });
2962        assert_eq!(
2963            client.endpoint(),
2964            "https://example.test/v1/chat/completions"
2965        );
2966        let client_with_v1 = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
2967            base_url: "https://example.test/v1".into(),
2968            api_key: "sk-test".into(),
2969            model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
2970        });
2971        assert_eq!(
2972            client_with_v1.endpoint(),
2973            "https://example.test/v1/chat/completions"
2974        );
2975        // Providers whose version segment is not `/v1` (e.g. GLM's `/v4`) are
2976        // honored verbatim — regression guard for the `/v1`-injection bug.
2977        let glm = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
2978            base_url: "https://open.bigmodel.cn/api/coding/paas/v4".into(),
2979            api_key: "sk-test".into(),
2980            model: default_model("glm-4.6", WireProtocol::OpenAiCompatible),
2981        });
2982        assert_eq!(
2983            glm.endpoint(),
2984            "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions"
2985        );
2986        let body = client.request_body(&user("hello"));
2987        assert_eq!(body["model"], "gpt-test");
2988        assert_eq!(body["messages"][0]["role"], "user");
2989        assert_eq!(body["messages"][0]["content"], "hello");
2990        // tools omitted ⇒ no `tools` / `tool_choice` keys
2991        assert!(body.get("tools").is_none());
2992        assert!(body.get("tool_choice").is_none());
2993
2994        // Now pass an actual ToolSpec and confirm it renders as a function.
2995        let with_tools = ModelTurnInput {
2996            system_prompt: None,
2997            messages: vec![ChatMessage::User {
2998                content: "hello".into(),
2999                attachments: vec![],
3000            }],
3001            tools: vec![bash_spec()],
3002            hosted_tools: vec![],
3003            tool_choice: ToolChoice::Auto,
3004            parallel_tool_calls: None,
3005        };
3006        let body = client.request_body(&with_tools);
3007        assert_eq!(body["tools"][0]["function"]["name"], "bash");
3008        assert_eq!(
3009            body["tools"][0]["function"]["parameters"]["required"][0],
3010            "command"
3011        );
3012        assert_eq!(body["tool_choice"], "auto");
3013        // parallel_tool_calls defaults to None ⇒ field omitted (let
3014        // OpenAI server-side default apply).
3015        assert!(body.get("parallel_tool_calls").is_none());
3016    }
3017
3018    #[test]
3019    fn openai_client_emits_tool_choice_required() {
3020        let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
3021            base_url: "https://example.test".into(),
3022            api_key: "sk-test".into(),
3023            model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
3024        });
3025        let body = client.request_body(&ModelTurnInput {
3026            system_prompt: None,
3027            messages: vec![ChatMessage::User {
3028                content: "go".into(),
3029                attachments: vec![],
3030            }],
3031            tools: vec![bash_spec()],
3032            hosted_tools: vec![],
3033            tool_choice: ToolChoice::Required,
3034            parallel_tool_calls: Some(false),
3035        });
3036        assert_eq!(body["tool_choice"], "required");
3037        assert_eq!(body["parallel_tool_calls"], false);
3038    }
3039
3040    #[test]
3041    fn openai_client_emits_tool_choice_named_tool() {
3042        let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
3043            base_url: "https://example.test".into(),
3044            api_key: "sk-test".into(),
3045            model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
3046        });
3047        let body = client.request_body(&ModelTurnInput {
3048            system_prompt: None,
3049            messages: vec![ChatMessage::User {
3050                content: "go".into(),
3051                attachments: vec![],
3052            }],
3053            tools: vec![bash_spec()],
3054            hosted_tools: vec![],
3055            tool_choice: ToolChoice::Tool("bash".into()),
3056            parallel_tool_calls: None,
3057        });
3058        assert_eq!(body["tool_choice"]["type"], "function");
3059        assert_eq!(body["tool_choice"]["function"]["name"], "bash");
3060    }
3061
3062    #[test]
3063    fn openai_client_drops_tools_when_choice_is_none() {
3064        // tool_choice: None — we drop the tools entirely so the model
3065        // can't call what it doesn't see (cheaper prompt + equivalent
3066        // semantic).
3067        let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
3068            base_url: "https://example.test".into(),
3069            api_key: "sk-test".into(),
3070            model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
3071        });
3072        let body = client.request_body(&ModelTurnInput {
3073            system_prompt: None,
3074            messages: vec![ChatMessage::User {
3075                content: "go".into(),
3076                attachments: vec![],
3077            }],
3078            tools: vec![bash_spec()],
3079            hosted_tools: vec![],
3080            tool_choice: ToolChoice::None,
3081            parallel_tool_calls: None,
3082        });
3083        assert!(body.get("tools").is_none(), "tools should be dropped");
3084        assert!(body.get("tool_choice").is_none());
3085    }
3086
3087    #[tokio::test]
3088    async fn openai_compatible_rejects_hosted_web_search() {
3089        let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
3090            base_url: "https://example.test".into(),
3091            api_key: "sk-test".into(),
3092            model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
3093        });
3094        let err = match client
3095            .stream(ModelTurnInput {
3096                system_prompt: None,
3097                messages: vec![ChatMessage::User {
3098                    content: "search".into(),
3099                    attachments: vec![],
3100                }],
3101                tools: vec![],
3102                hosted_tools: vec![HostedTool::WebSearch],
3103                tool_choice: ToolChoice::Auto,
3104                parallel_tool_calls: None,
3105            })
3106            .await
3107        {
3108            Ok(_) => panic!("expected hosted tool rejection"),
3109            Err(err) => err,
3110        };
3111        assert!(err.to_string().contains("Responses API"));
3112    }
3113
3114    #[test]
3115    fn tool_choice_parse_handles_canonical_strings() {
3116        assert!(matches!(ToolChoice::parse(""), ToolChoice::Auto));
3117        assert!(matches!(ToolChoice::parse("auto"), ToolChoice::Auto));
3118        assert!(matches!(ToolChoice::parse("AUTO"), ToolChoice::Auto));
3119        assert!(matches!(ToolChoice::parse("none"), ToolChoice::None));
3120        assert!(matches!(
3121            ToolChoice::parse("required"),
3122            ToolChoice::Required
3123        ));
3124        assert!(matches!(ToolChoice::parse("any"), ToolChoice::Required));
3125        match ToolChoice::parse("tool:bash") {
3126            ToolChoice::Tool(name) => assert_eq!(name, "bash"),
3127            other => panic!("expected Tool(bash), got {other:?}"),
3128        }
3129        // Unknown degrades to Auto (forward-compat).
3130        assert!(matches!(ToolChoice::parse("garbage"), ToolChoice::Auto));
3131    }
3132
3133    #[test]
3134    fn openai_client_prepends_system_when_set() {
3135        let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
3136            base_url: "https://example.test".into(),
3137            api_key: "sk-test".into(),
3138            model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
3139        });
3140        let input = ModelTurnInput {
3141            system_prompt: Some("you are concise".into()),
3142            messages: vec![ChatMessage::User {
3143                content: "hi".into(),
3144                attachments: vec![],
3145            }],
3146            tools: vec![],
3147            hosted_tools: vec![],
3148            tool_choice: ToolChoice::Auto,
3149            parallel_tool_calls: None,
3150        };
3151        let body = client.request_body(&input);
3152        assert_eq!(body["messages"][0]["role"], "system");
3153        assert_eq!(body["messages"][0]["content"], "you are concise");
3154        assert_eq!(body["messages"][1]["role"], "user");
3155    }
3156
3157    #[test]
3158    fn parse_openai_usage_extracts_token_counts() {
3159        let u = parse_openai_usage(Some(&json!({
3160            "prompt_tokens": 12,
3161            "completion_tokens": 7,
3162            "total_tokens": 19,
3163            "prompt_tokens_details": {"cached_tokens": 4}
3164        })))
3165        .expect("usage parsed");
3166        assert_eq!(u.input_tokens, 12);
3167        assert_eq!(u.output_tokens, 7);
3168        assert_eq!(u.cache_read_input_tokens, 4);
3169        assert_eq!(u.cache_creation_input_tokens, 0);
3170    }
3171
3172    #[test]
3173    fn parse_openai_usage_without_cache_details() {
3174        let u = parse_openai_usage(Some(&json!({
3175            "prompt_tokens": 200,
3176            "completion_tokens": 30
3177        })))
3178        .expect("usage parsed");
3179        assert_eq!(u.input_tokens, 200);
3180        assert_eq!(u.output_tokens, 30);
3181        assert_eq!(u.cache_read_input_tokens, 0);
3182    }
3183
3184    #[test]
3185    fn openai_stream_state_emits_text_deltas_then_done() {
3186        let mut state = OpenAiStreamState::default();
3187        // First chunk seeds id + role.
3188        let out = state
3189            .feed_data(
3190                r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"role":"assistant","content":""}}]}"#,
3191            )
3192            .unwrap();
3193        assert!(out.is_empty(), "empty content shouldn't emit");
3194        // Text deltas.
3195        let out = state
3196            .feed_data(r#"{"choices":[{"index":0,"delta":{"content":"Hello"}}]}"#)
3197            .unwrap();
3198        assert_eq!(out.len(), 1);
3199        match &out[0] {
3200            ModelChunk::TextDelta { msg_id, delta } => {
3201                assert_eq!(msg_id, "chatcmpl-1");
3202                assert_eq!(delta, "Hello");
3203            }
3204            other => panic!("expected TextDelta, got {other:?}"),
3205        }
3206        let out = state
3207            .feed_data(r#"{"choices":[{"index":0,"delta":{"content":" world"}}]}"#)
3208            .unwrap();
3209        assert_eq!(out.len(), 1);
3210        // finish_reason in penultimate chunk (no Done yet — usage may
3211        // follow if include_usage was set).
3212        let out = state
3213            .feed_data(r#"{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#)
3214            .unwrap();
3215        assert!(out.is_empty());
3216        // include_usage final chunk (empty choices, usage populated).
3217        let out = state
3218            .feed_data(r#"{"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":3}}"#)
3219            .unwrap();
3220        assert!(out.is_empty());
3221        // [DONE] sentinel produces the final Done chunk.
3222        let out = state.feed_data("[DONE]").unwrap();
3223        assert_eq!(out.len(), 1);
3224        match &out[0] {
3225            ModelChunk::Done { stop_reason, usage } => {
3226                assert_eq!(stop_reason, "end_turn");
3227                let u = usage.as_ref().expect("usage propagated");
3228                assert_eq!(u.input_tokens, 10);
3229                assert_eq!(u.output_tokens, 3);
3230            }
3231            other => panic!("expected Done, got {other:?}"),
3232        }
3233    }
3234
3235    #[test]
3236    fn openai_stream_state_emits_tool_call_chunks() {
3237        let mut state = OpenAiStreamState::default();
3238        // First tool-call chunk: id + name + initial arguments.
3239        let out = state
3240            .feed_data(
3241                r#"{"id":"c1","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_x","type":"function","function":{"name":"bash","arguments":""}}]}}]}"#,
3242            )
3243            .unwrap();
3244        assert_eq!(out.len(), 1);
3245        match &out[0] {
3246            ModelChunk::ToolCallStart { id, name } => {
3247                assert_eq!(id, "call_x");
3248                assert_eq!(name, "bash");
3249            }
3250            other => panic!("expected ToolCallStart, got {other:?}"),
3251        }
3252        // Streaming arguments come split into multiple chunks.
3253        let out = state
3254            .feed_data(
3255                r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]}}]}"#,
3256            )
3257            .unwrap();
3258        assert_eq!(out.len(), 1);
3259        let ModelChunk::ToolCallInputDelta { delta, .. } = &out[0] else {
3260            panic!("expected ToolCallInputDelta");
3261        };
3262        assert_eq!(delta, "{\"");
3263
3264        let out = state
3265            .feed_data(
3266                r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"cmd\":\"pwd\"}"}}]}}]}"#,
3267            )
3268            .unwrap();
3269        let ModelChunk::ToolCallInputDelta { delta, .. } = &out[0] else {
3270            panic!("expected ToolCallInputDelta");
3271        };
3272        assert_eq!(delta, "cmd\":\"pwd\"}");
3273
3274        // finish_reason="tool_calls" should emit ToolCallEnd for every
3275        // open tool call.
3276        let out = state
3277            .feed_data(r#"{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#)
3278            .unwrap();
3279        assert_eq!(out.len(), 1);
3280        match &out[0] {
3281            ModelChunk::ToolCallEnd { id, input } => {
3282                assert_eq!(id, "call_x");
3283                assert!(input.is_none(), "OpenAI streaming defers parsing");
3284            }
3285            other => panic!("expected ToolCallEnd, got {other:?}"),
3286        }
3287
3288        // Stream closes (no [DONE] sentinel from some gateways) → finalize().
3289        let final_chunk = state.finalize().expect("finalize emits Done");
3290        match final_chunk {
3291            ModelChunk::Done { stop_reason, .. } => assert_eq!(stop_reason, "end_turn"),
3292            other => panic!("expected Done from finalize, got {other:?}"),
3293        }
3294    }
3295
3296    #[tokio::test]
3297    async fn collect_model_response_preserves_raw_tool_arguments() {
3298        let chunks = vec![
3299            Ok(ModelChunk::ToolCallStart {
3300                id: "call_x".into(),
3301                name: "bash".into(),
3302            }),
3303            Ok(ModelChunk::ToolCallInputDelta {
3304                id: "call_x".into(),
3305                delta: r#"{ "b": 2, "#.into(),
3306            }),
3307            Ok(ModelChunk::ToolCallInputDelta {
3308                id: "call_x".into(),
3309                delta: r#""a": 1 }"#.into(),
3310            }),
3311            Ok(ModelChunk::ToolCallEnd {
3312                id: "call_x".into(),
3313                input: None,
3314            }),
3315            Ok(ModelChunk::Done {
3316                stop_reason: "end_turn".into(),
3317                usage: None,
3318            }),
3319        ];
3320
3321        let response = collect_model_response(futures::stream::iter(chunks).boxed())
3322            .await
3323            .unwrap();
3324        let ModelResponse::ToolCall { invocation, .. } = response else {
3325            panic!("expected tool call response");
3326        };
3327
3328        assert_eq!(invocation.input, json!({"b": 2, "a": 1}));
3329        assert_eq!(
3330            invocation.raw_emitted_args.as_deref(),
3331            Some(r#"{ "b": 2, "a": 1 }"#)
3332        );
3333    }
3334
3335    #[test]
3336    fn openai_projection_uses_raw_tool_arguments_when_matching_input() {
3337        let msg = ChatMessage::Assistant {
3338            text: None,
3339            tool_calls: vec![ToolInvocation {
3340                id: "call_1".into(),
3341                name: "bash".into(),
3342                input: json!({"b": 2, "a": 1}),
3343                raw_emitted_args: Some(r#"{ "b": 2, "a": 1 }"#.into()),
3344            }],
3345            thinking: None,
3346            usage: None,
3347        };
3348
3349        let wire = chat_message_to_wire(&msg);
3350        assert_eq!(
3351            wire["tool_calls"][0]["function"]["arguments"],
3352            r#"{ "b": 2, "a": 1 }"#
3353        );
3354    }
3355
3356    #[test]
3357    fn openai_projection_ignores_stale_raw_tool_arguments() {
3358        let msg = ChatMessage::Assistant {
3359            text: None,
3360            tool_calls: vec![ToolInvocation {
3361                id: "call_1".into(),
3362                name: "bash".into(),
3363                input: json!({"command": "pwd"}),
3364                raw_emitted_args: Some(r#"{"command": "rm -rf /"}"#.into()),
3365            }],
3366            thinking: None,
3367            usage: None,
3368        };
3369
3370        let wire = chat_message_to_wire(&msg);
3371        assert_eq!(
3372            wire["tool_calls"][0]["function"]["arguments"],
3373            json!({"command": "pwd"}).to_string()
3374        );
3375    }
3376
3377    #[test]
3378    fn map_openai_finish_reason_table() {
3379        assert_eq!(map_openai_finish_reason(Some("stop")), "end_turn");
3380        assert_eq!(map_openai_finish_reason(Some("length")), "max_tokens");
3381        assert_eq!(map_openai_finish_reason(Some("tool_calls")), "end_turn");
3382        assert_eq!(map_openai_finish_reason(Some("content_filter")), "refusal");
3383        assert_eq!(map_openai_finish_reason(None), "end_turn");
3384        assert_eq!(map_openai_finish_reason(Some("")), "end_turn");
3385    }
3386
3387    // ── Anthropic Messages API ────────────────────────────────────────
3388
3389    #[test]
3390    fn chat_message_to_openai_wire_text_only_keeps_string_content() {
3391        // Fast path: no attachments → content stays as a plain string
3392        // (byte-identical to pre-multimodal behaviour).
3393        let msg = ChatMessage::User {
3394            content: "hello".into(),
3395            attachments: vec![],
3396        };
3397        let v = chat_message_to_wire(&msg);
3398        assert_eq!(v["role"], "user");
3399        assert_eq!(v["content"], "hello");
3400        // Not an array — that distinction matters for OpenAI-compatible
3401        // gateways that strict-parse the chat schema.
3402        assert!(v["content"].is_string());
3403    }
3404
3405    #[test]
3406    fn chat_message_to_openai_wire_with_base64_image() {
3407        let msg = ChatMessage::User {
3408            content: "describe this".into(),
3409            attachments: vec![UserAttachment::Image(ImageSource {
3410                media_type: "image/png".into(),
3411                data: ImageData::Base64("iVBORw0KG...".into()),
3412            })],
3413        };
3414        let v = chat_message_to_wire(&msg);
3415        let parts = v["content"].as_array().expect("content array");
3416        assert_eq!(parts.len(), 2);
3417        assert_eq!(parts[0]["type"], "text");
3418        assert_eq!(parts[0]["text"], "describe this");
3419        assert_eq!(parts[1]["type"], "image_url");
3420        // base64 must be wrapped in a data URI for OpenAI.
3421        let url = parts[1]["image_url"]["url"].as_str().unwrap();
3422        assert!(url.starts_with("data:image/png;base64,"));
3423        assert!(url.contains("iVBORw0KG..."));
3424    }
3425
3426    #[test]
3427    fn chat_message_to_openai_wire_with_url_image() {
3428        let msg = ChatMessage::User {
3429            content: "".into(), // empty text, image-only
3430            attachments: vec![UserAttachment::Image(ImageSource {
3431                media_type: "image/jpeg".into(),
3432                data: ImageData::Url("https://cdn.example.com/cat.jpg".into()),
3433            })],
3434        };
3435        let v = chat_message_to_wire(&msg);
3436        let parts = v["content"].as_array().unwrap();
3437        // Empty text dropped — only image part survives.
3438        assert_eq!(parts.len(), 1);
3439        assert_eq!(parts[0]["type"], "image_url");
3440        assert_eq!(
3441            parts[0]["image_url"]["url"],
3442            "https://cdn.example.com/cat.jpg"
3443        );
3444    }
3445
3446    #[test]
3447    fn chat_message_to_openai_tool_role_degrades_image_to_placeholder() {
3448        // OpenAI's `tool` role is strictly string-typed — images coming
3449        // out of MCP tool calls have to degrade. We append a placeholder
3450        // line per attachment so the model still notices something
3451        // visual was returned, even if it can't see it.
3452        let msg = ChatMessage::Tool {
3453            tool_call_id: "call_x".into(),
3454            content: "ok".into(),
3455            is_error: false,
3456            attachments: vec![UserAttachment::Image(ImageSource {
3457                media_type: "image/png".into(),
3458                data: ImageData::Base64("AAA".into()),
3459            })],
3460        };
3461        let v = chat_message_to_wire(&msg);
3462        assert_eq!(v["role"], "tool");
3463        assert_eq!(v["tool_call_id"], "call_x");
3464        let content = v["content"].as_str().unwrap();
3465        assert!(content.starts_with("ok\n"));
3466        assert!(content.contains("image attached: image/png"));
3467        // Base64 bytes must NOT leak into the wire — degradation, not
3468        // smuggling.
3469        assert!(!content.contains("AAA"));
3470    }
3471
3472    // ── E7: tool_result replay compaction ──
3473
3474    #[test]
3475    fn replay_compaction_leaves_small_results_untouched() {
3476        let small = "x".repeat(1_000);
3477        assert!(matches!(
3478            compact_tool_result_for_replay(&small),
3479            std::borrow::Cow::Borrowed(_)
3480        ));
3481        // Over the token budget (10_000 ASCII chars / 4 = 2_500 > 2_000)
3482        // → compacted even though bytes are under 12 KB.
3483        let medium = "word ".repeat(2_000);
3484        let out = compact_tool_result_for_replay(&medium);
3485        assert!(out.contains("compacted for model replay"));
3486    }
3487
3488    #[test]
3489    fn replay_compaction_keeps_head_and_tail_deterministically() {
3490        let body = format!("HEAD_MARK{}TAIL_MARK", "x".repeat(20_000));
3491        let first = compact_tool_result_for_replay(&body).into_owned();
3492        let second = compact_tool_result_for_replay(&body).into_owned();
3493        // Deterministic: byte-identical across calls (prompt-cache safety).
3494        assert_eq!(first, second);
3495        assert!(first.starts_with("[tool result compacted for model replay]"));
3496        assert!(first.contains("HEAD_MARK"), "head survives");
3497        assert!(first.contains("TAIL_MARK"), "tail survives");
3498        assert!(first.contains("omitted"), "omission marker present");
3499        // Massively smaller than the original.
3500        assert!(first.len() < body.len() / 2);
3501    }
3502
3503    #[test]
3504    fn openai_projection_compacts_oversized_tool_result() {
3505        let big = format!("START{}END", "y".repeat(20_000));
3506        let msg = ChatMessage::Tool {
3507            tool_call_id: "call_big".into(),
3508            content: big.clone(),
3509            is_error: false,
3510            attachments: vec![],
3511        };
3512        let v = chat_message_to_wire(&msg);
3513        let content = v["content"].as_str().unwrap();
3514        assert!(content.contains("compacted for model replay"));
3515        assert!(content.contains("START") && content.contains("END"));
3516        // Projection-only: the source message still holds the full result.
3517        match &msg {
3518            ChatMessage::Tool { content, .. } => assert_eq!(content.len(), big.len()),
3519            _ => unreachable!(),
3520        }
3521    }
3522
3523    #[test]
3524    fn anthropic_projection_compacts_oversized_tool_result() {
3525        let big = "z".repeat(20_000);
3526        let msgs = vec![
3527            ChatMessage::Assistant {
3528                text: None,
3529                tool_calls: vec![crate::tools::ToolInvocation {
3530                    id: "tc_big".into(),
3531                    name: "bash".into(),
3532                    input: json!({}),
3533                    raw_emitted_args: None,
3534                }],
3535                thinking: None,
3536                usage: None,
3537            },
3538            ChatMessage::Tool {
3539                tool_call_id: "tc_big".into(),
3540                content: big,
3541                is_error: false,
3542                attachments: vec![],
3543            },
3544        ];
3545        let wire = chat_messages_to_anthropic_messages(&msgs);
3546        let rendered = serde_json::to_string(&wire).unwrap();
3547        assert!(rendered.contains("compacted for model replay"));
3548    }
3549
3550    #[test]
3551    fn chat_messages_to_anthropic_tool_result_carries_image_block() {
3552        // After an assistant tool_use, a Tool message that carries an
3553        // image attachment (e.g. MCP screenshot) should project to a
3554        // tool_result whose content array contains both the text and
3555        // the image content block.
3556        let msgs = vec![
3557            ChatMessage::Assistant {
3558                text: None,
3559                tool_calls: vec![ToolInvocation {
3560                    id: "tc_img".into(),
3561                    name: "screenshot".into(),
3562                    input: json!({}),
3563                    raw_emitted_args: None,
3564                }],
3565                thinking: None,
3566                usage: None,
3567            },
3568            ChatMessage::Tool {
3569                tool_call_id: "tc_img".into(),
3570                content: "see image".into(),
3571                is_error: false,
3572                attachments: vec![UserAttachment::Image(ImageSource {
3573                    media_type: "image/png".into(),
3574                    data: ImageData::Base64("PNGBYTES".into()),
3575                })],
3576            },
3577        ];
3578        let out = chat_messages_to_anthropic_messages(&msgs);
3579        // [assistant tool_use, user(tool_result containing text+image)]
3580        assert_eq!(out.len(), 2);
3581        let user = &out[1];
3582        assert_eq!(user["role"], "user");
3583        let outer = user["content"].as_array().unwrap();
3584        assert_eq!(outer.len(), 1);
3585        assert_eq!(outer[0]["type"], "tool_result");
3586        assert_eq!(outer[0]["tool_use_id"], "tc_img");
3587        let inner = outer[0]["content"].as_array().unwrap();
3588        // tool_result content is now block-array form (not a string)
3589        // when attachments are present.
3590        assert_eq!(inner.len(), 2);
3591        assert_eq!(inner[0]["type"], "text");
3592        assert_eq!(inner[0]["text"], "see image");
3593        assert_eq!(inner[1]["type"], "image");
3594        assert_eq!(inner[1]["source"]["type"], "base64");
3595        assert_eq!(inner[1]["source"]["media_type"], "image/png");
3596        assert_eq!(inner[1]["source"]["data"], "PNGBYTES");
3597    }
3598
3599    #[test]
3600    fn chat_messages_to_anthropic_renders_user_text_with_image_block() {
3601        let msgs = vec![ChatMessage::User {
3602            content: "what is this".into(),
3603            attachments: vec![UserAttachment::Image(ImageSource {
3604                media_type: "image/png".into(),
3605                data: ImageData::Base64("AAAA".into()),
3606            })],
3607        }];
3608        let out = chat_messages_to_anthropic_messages(&msgs);
3609        assert_eq!(out.len(), 1);
3610        let blocks = out[0]["content"].as_array().unwrap();
3611        // Text first, then image — same ordering as OpenAI parts.
3612        assert_eq!(blocks[0]["type"], "text");
3613        assert_eq!(blocks[0]["text"], "what is this");
3614        assert_eq!(blocks[1]["type"], "image");
3615        // base64 source shape
3616        assert_eq!(blocks[1]["source"]["type"], "base64");
3617        assert_eq!(blocks[1]["source"]["media_type"], "image/png");
3618        assert_eq!(blocks[1]["source"]["data"], "AAAA");
3619    }
3620
3621    #[test]
3622    fn chat_messages_to_anthropic_renders_url_image() {
3623        let msgs = vec![ChatMessage::User {
3624            content: "".into(),
3625            attachments: vec![UserAttachment::Image(ImageSource {
3626                media_type: "image/jpeg".into(),
3627                data: ImageData::Url("https://example.com/x.jpg".into()),
3628            })],
3629        }];
3630        let out = chat_messages_to_anthropic_messages(&msgs);
3631        let blocks = out[0]["content"].as_array().unwrap();
3632        // Empty-text user with image: only one block (the image).
3633        assert_eq!(blocks.len(), 1);
3634        assert_eq!(blocks[0]["type"], "image");
3635        assert_eq!(blocks[0]["source"]["type"], "url");
3636        assert_eq!(blocks[0]["source"]["url"], "https://example.com/x.jpg");
3637    }
3638
3639    #[test]
3640    fn chat_message_to_anthropic_merges_tool_results_and_image() {
3641        // Tool result pending + a user message that also carries an image:
3642        // both must land in the same user message's content array.
3643        let msgs = vec![
3644            ChatMessage::Assistant {
3645                text: None,
3646                tool_calls: vec![ToolInvocation {
3647                    id: "tc_1".into(),
3648                    name: "screenshot".into(),
3649                    input: json!({}),
3650                    raw_emitted_args: None,
3651                }],
3652                thinking: None,
3653                usage: None,
3654            },
3655            ChatMessage::Tool {
3656                tool_call_id: "tc_1".into(),
3657                content: "captured".into(),
3658                is_error: false,
3659                attachments: vec![],
3660            },
3661            ChatMessage::User {
3662                content: "what changed?".into(),
3663                attachments: vec![UserAttachment::Image(ImageSource {
3664                    media_type: "image/png".into(),
3665                    data: ImageData::Base64("ZZ".into()),
3666                })],
3667            },
3668        ];
3669        let out = chat_messages_to_anthropic_messages(&msgs);
3670        // [assistant tool_use, user(tool_result + text + image)]
3671        assert_eq!(out.len(), 2);
3672        let blocks = out[1]["content"].as_array().unwrap();
3673        assert_eq!(blocks.len(), 3);
3674        assert_eq!(blocks[0]["type"], "tool_result");
3675        assert_eq!(blocks[0]["tool_use_id"], "tc_1");
3676        assert_eq!(blocks[1]["type"], "text");
3677        assert_eq!(blocks[1]["text"], "what changed?");
3678        assert_eq!(blocks[2]["type"], "image");
3679    }
3680
3681    #[test]
3682    fn chat_messages_to_anthropic_renders_simple_user_assistant() {
3683        let msgs = vec![
3684            ChatMessage::User {
3685                content: "hi".into(),
3686                attachments: vec![],
3687            },
3688            ChatMessage::Assistant {
3689                text: Some("hello".into()),
3690                tool_calls: vec![],
3691                thinking: None,
3692                usage: None,
3693            },
3694        ];
3695        let out = chat_messages_to_anthropic_messages(&msgs);
3696        assert_eq!(out.len(), 2);
3697        assert_eq!(out[0]["role"], "user");
3698        assert_eq!(out[0]["content"][0]["type"], "text");
3699        assert_eq!(out[0]["content"][0]["text"], "hi");
3700        assert_eq!(out[1]["role"], "assistant");
3701        assert_eq!(out[1]["content"][0]["text"], "hello");
3702    }
3703
3704    #[test]
3705    fn chat_messages_to_anthropic_folds_tool_results_into_next_user() {
3706        // After an assistant tool_use, the next user message is the one
3707        // carrying the tool_result content block — no separate user/tool
3708        // hop on the wire.
3709        let msgs = vec![
3710            ChatMessage::User {
3711                content: "do it".into(),
3712                attachments: vec![],
3713            },
3714            ChatMessage::Assistant {
3715                text: None,
3716                tool_calls: vec![ToolInvocation {
3717                    id: "call_1".into(),
3718                    name: "bash".into(),
3719                    input: json!({"command": "pwd"}),
3720                    raw_emitted_args: None,
3721                }],
3722                thinking: None,
3723                usage: None,
3724            },
3725            ChatMessage::Tool {
3726                tool_call_id: "call_1".into(),
3727                content: "{\"stdout\":\"/\"}".into(),
3728                is_error: false,
3729                attachments: vec![],
3730            },
3731            ChatMessage::User {
3732                content: "explain".into(),
3733                attachments: vec![],
3734            },
3735        ];
3736        let out = chat_messages_to_anthropic_messages(&msgs);
3737        // user, assistant(tool_use), user(tool_result + "explain")
3738        assert_eq!(out.len(), 3);
3739        assert_eq!(out[1]["role"], "assistant");
3740        assert_eq!(out[1]["content"][0]["type"], "tool_use");
3741        assert_eq!(out[1]["content"][0]["id"], "call_1");
3742        assert_eq!(out[2]["role"], "user");
3743        assert_eq!(out[2]["content"][0]["type"], "tool_result");
3744        assert_eq!(out[2]["content"][0]["tool_use_id"], "call_1");
3745        assert_eq!(out[2]["content"][1]["type"], "text");
3746        assert_eq!(out[2]["content"][1]["text"], "explain");
3747    }
3748
3749    #[test]
3750    fn chat_messages_to_anthropic_renders_thinking_then_text_then_tool_use() {
3751        let msgs = vec![ChatMessage::Assistant {
3752            text: Some("preface".into()),
3753            tool_calls: vec![ToolInvocation {
3754                id: "t".into(),
3755                name: "n".into(),
3756                input: json!({"a": 1}),
3757                raw_emitted_args: None,
3758            }],
3759            thinking: Some(AssistantThinking {
3760                text: "deep thought".into(),
3761                signature: Some("sig123".into()),
3762            }),
3763            usage: None,
3764        }];
3765        let out = chat_messages_to_anthropic_messages(&msgs);
3766        let blocks = out[0]["content"].as_array().unwrap();
3767        // Order: thinking → text → tool_use.
3768        assert_eq!(blocks[0]["type"], "thinking");
3769        assert_eq!(blocks[0]["thinking"], "deep thought");
3770        assert_eq!(blocks[0]["signature"], "sig123");
3771        assert_eq!(blocks[1]["type"], "text");
3772        assert_eq!(blocks[1]["text"], "preface");
3773        assert_eq!(blocks[2]["type"], "tool_use");
3774    }
3775
3776    #[test]
3777    fn chat_messages_to_anthropic_trailing_tool_results_flushed() {
3778        // Conversation ends on tool results without a follow-up user
3779        // turn — we still need to send the results to the model.
3780        let msgs = vec![
3781            ChatMessage::Assistant {
3782                text: None,
3783                tool_calls: vec![ToolInvocation {
3784                    id: "t".into(),
3785                    name: "n".into(),
3786                    input: json!({}),
3787                    raw_emitted_args: None,
3788                }],
3789                thinking: None,
3790                usage: None,
3791            },
3792            ChatMessage::Tool {
3793                tool_call_id: "t".into(),
3794                content: "ok".into(),
3795                is_error: false,
3796                attachments: vec![],
3797            },
3798        ];
3799        let out = chat_messages_to_anthropic_messages(&msgs);
3800        assert_eq!(out.len(), 2);
3801        assert_eq!(out[1]["role"], "user");
3802        assert_eq!(out[1]["content"][0]["type"], "tool_result");
3803    }
3804
3805    #[test]
3806    fn apply_anthropic_cache_strategy_marks_system_last_tool_and_last_message() {
3807        let system = anthropic_system_field(Some("system prompt"));
3808        let tools = vec![
3809            json!({"name": "a", "description": "", "input_schema": {"type": "object"}}),
3810            json!({"name": "b", "description": "", "input_schema": {"type": "object"}}),
3811        ];
3812        let messages = vec![
3813            json!({"role": "user", "content": [{"type": "text", "text": "hi"}]}),
3814            json!({"role": "assistant", "content": [{"type": "text", "text": "hello"}]}),
3815        ];
3816        let out = apply_anthropic_cache_strategy(system, tools, messages);
3817        let sys_block = &out.system.as_ref().unwrap()[0];
3818        assert_eq!(sys_block["cache_control"]["type"], "ephemeral");
3819        // Last tool (only the LAST one) cached, not the first.
3820        assert!(out.tools[0].get("cache_control").is_none());
3821        assert_eq!(out.tools[1]["cache_control"]["type"], "ephemeral");
3822        // Last message's last content block cached.
3823        let last_msg_blocks = out.messages.last().unwrap()["content"].as_array().unwrap();
3824        assert_eq!(
3825            last_msg_blocks.last().unwrap()["cache_control"]["type"],
3826            "ephemeral"
3827        );
3828    }
3829
3830    #[test]
3831    fn apply_anthropic_cache_strategy_skips_empty_system() {
3832        // Empty system → omitted entirely (Anthropic rejects cache_control
3833        // on empty text blocks).
3834        let out = apply_anthropic_cache_strategy(None, vec![], vec![]);
3835        assert!(out.system.is_none());
3836    }
3837
3838    fn anthropic_client_for_tool_choice_tests() -> AnthropicModelClient {
3839        AnthropicModelClient::new(AnthropicConfig {
3840            base_url: "https://example.test".into(),
3841            api_key: "sk-test".into(),
3842            model: resolved_model(
3843                "claude-test",
3844                WireProtocol::Anthropic,
3845                1_024,
3846                None,
3847                ReasoningConfig::default(),
3848            ),
3849            anthropic_version: AnthropicConfig::DEFAULT_VERSION.into(),
3850        })
3851    }
3852
3853    #[test]
3854    fn anthropic_client_omits_tool_choice_when_auto() {
3855        // Auto is Anthropic's default — keep the wire byte-identical
3856        // by omitting the field (prompt cache stability).
3857        let client = anthropic_client_for_tool_choice_tests();
3858        let body = client.request_body(&ModelTurnInput {
3859            system_prompt: None,
3860            messages: vec![ChatMessage::User {
3861                content: "go".into(),
3862                attachments: vec![],
3863            }],
3864            tools: vec![bash_spec()],
3865            hosted_tools: vec![],
3866            tool_choice: ToolChoice::Auto,
3867            parallel_tool_calls: None,
3868        });
3869        assert!(body["tools"].as_array().unwrap().len() > 0);
3870        assert!(body.get("tool_choice").is_none());
3871        // parallel_tool_calls is OpenAI-only — Anthropic body must
3872        // never carry it.
3873        assert!(body.get("parallel_tool_calls").is_none());
3874    }
3875
3876    #[test]
3877    fn anthropic_client_emits_tool_choice_required_as_any() {
3878        let client = anthropic_client_for_tool_choice_tests();
3879        let body = client.request_body(&ModelTurnInput {
3880            system_prompt: None,
3881            messages: vec![ChatMessage::User {
3882                content: "go".into(),
3883                attachments: vec![],
3884            }],
3885            tools: vec![bash_spec()],
3886            hosted_tools: vec![],
3887            tool_choice: ToolChoice::Required,
3888            parallel_tool_calls: Some(true),
3889        });
3890        assert_eq!(body["tool_choice"]["type"], "any");
3891        // OpenAI-only knob must NOT leak into Anthropic body even when
3892        // caller set it (harness passes it uniformly to both providers).
3893        assert!(body.get("parallel_tool_calls").is_none());
3894    }
3895
3896    #[test]
3897    fn anthropic_client_projects_hosted_web_search_tool() {
3898        let client = anthropic_client_for_tool_choice_tests();
3899        let body = client.request_body(&ModelTurnInput {
3900            system_prompt: None,
3901            messages: vec![ChatMessage::User {
3902                content: "research current AI market".into(),
3903                attachments: vec![],
3904            }],
3905            tools: vec![bash_spec()],
3906            hosted_tools: vec![HostedTool::WebSearch],
3907            tool_choice: ToolChoice::Auto,
3908            parallel_tool_calls: None,
3909        });
3910        let tools = body["tools"].as_array().unwrap();
3911        let web = tools
3912            .iter()
3913            .find(|tool| tool.get("name").and_then(Value::as_str) == Some("web_search"))
3914            .unwrap();
3915        assert_eq!(web["type"], "web_search_20250305");
3916        assert!(web.get("max_uses").is_none());
3917    }
3918
3919    #[test]
3920    fn clients_report_web_search_support_by_protocol_and_endpoint() {
3921        let chat = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
3922            base_url: "https://api.openai.com/v1".into(),
3923            api_key: "sk-test".into(),
3924            model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
3925        });
3926        assert_eq!(
3927            chat.hosted_capability(HostedCapability::WebSearch),
3928            CapabilitySupport::Unsupported
3929        );
3930
3931        let responses = OpenAiResponsesModelClient::new(OpenAiResponsesConfig {
3932            base_url: "https://api.openai.com/v1".into(),
3933            api_key: "sk-test".into(),
3934            model: default_model("gpt-test", WireProtocol::OpenAiResponses),
3935            reasoning_summary: None,
3936        });
3937        assert_eq!(
3938            responses.hosted_capability(HostedCapability::WebSearch),
3939            CapabilitySupport::Supported
3940        );
3941
3942        let gateway = OpenAiResponsesModelClient::new(OpenAiResponsesConfig {
3943            base_url: "https://gateway.example/v1".into(),
3944            api_key: "sk-test".into(),
3945            model: default_model("gpt-test", WireProtocol::OpenAiResponses),
3946            reasoning_summary: None,
3947        });
3948        assert_eq!(
3949            gateway.hosted_capability(HostedCapability::WebSearch),
3950            CapabilitySupport::Unknown
3951        );
3952    }
3953
3954    #[test]
3955    fn anthropic_client_emits_tool_choice_named_tool() {
3956        let client = anthropic_client_for_tool_choice_tests();
3957        let body = client.request_body(&ModelTurnInput {
3958            system_prompt: None,
3959            messages: vec![ChatMessage::User {
3960                content: "go".into(),
3961                attachments: vec![],
3962            }],
3963            tools: vec![bash_spec()],
3964            hosted_tools: vec![],
3965            tool_choice: ToolChoice::Tool("bash".into()),
3966            parallel_tool_calls: None,
3967        });
3968        assert_eq!(body["tool_choice"]["type"], "tool");
3969        assert_eq!(body["tool_choice"]["name"], "bash");
3970    }
3971
3972    #[test]
3973    fn anthropic_client_drops_tools_when_choice_is_none() {
3974        // Anthropic has no native "tool_choice: none" — best
3975        // approximation is dropping the tools array. The body's
3976        // `tools` key must be absent and `tool_choice` too.
3977        let client = anthropic_client_for_tool_choice_tests();
3978        let body = client.request_body(&ModelTurnInput {
3979            system_prompt: None,
3980            messages: vec![ChatMessage::User {
3981                content: "go".into(),
3982                attachments: vec![],
3983            }],
3984            tools: vec![bash_spec()],
3985            hosted_tools: vec![],
3986            tool_choice: ToolChoice::None,
3987            parallel_tool_calls: None,
3988        });
3989        assert!(body.get("tools").is_none());
3990        assert!(body.get("tool_choice").is_none());
3991    }
3992
3993    #[test]
3994    fn anthropic_stream_state_text_only() {
3995        let mut s = AnthropicStreamState::default();
3996        // message_start with id + usage.
3997        let _ = s
3998            .feed_event(
3999                "message_start",
4000                r#"{"type":"message_start","message":{"id":"msg_01","usage":{"input_tokens":10,"output_tokens":0}}}"#,
4001            )
4002            .unwrap();
4003        let _ = s
4004            .feed_event(
4005                "content_block_start",
4006                r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#,
4007            )
4008            .unwrap();
4009        let out = s
4010            .feed_event(
4011                "content_block_delta",
4012                r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#,
4013            )
4014            .unwrap();
4015        assert_eq!(out.len(), 1);
4016        match &out[0] {
4017            ModelChunk::TextDelta { msg_id, delta } => {
4018                assert_eq!(msg_id, "msg_01");
4019                assert_eq!(delta, "Hello");
4020            }
4021            other => panic!("expected TextDelta, got {other:?}"),
4022        }
4023        let _ = s.feed_event(
4024            "message_delta",
4025            r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}"#,
4026        );
4027        let out = s
4028            .feed_event("message_stop", r#"{"type":"message_stop"}"#)
4029            .unwrap();
4030        assert_eq!(out.len(), 1);
4031        match &out[0] {
4032            ModelChunk::Done { stop_reason, usage } => {
4033                assert_eq!(stop_reason, "end_turn");
4034                let u = usage.as_ref().unwrap();
4035                assert_eq!(u.input_tokens, 10);
4036                assert_eq!(u.output_tokens, 5);
4037            }
4038            other => panic!("expected Done, got {other:?}"),
4039        }
4040    }
4041
4042    #[test]
4043    fn anthropic_stream_state_thinking_block_emits_delta_and_signature() {
4044        let mut s = AnthropicStreamState::default();
4045        let _ = s.feed_event(
4046            "message_start",
4047            r#"{"type":"message_start","message":{"id":"msg_t"}}"#,
4048        );
4049        let _ = s.feed_event(
4050            "content_block_start",
4051            r#"{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#,
4052        );
4053        let out = s
4054            .feed_event(
4055                "content_block_delta",
4056                r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"reasoning..."}}"#,
4057            )
4058            .unwrap();
4059        assert_eq!(out.len(), 1);
4060        let ModelChunk::ThinkingDelta {
4061            delta, signature, ..
4062        } = &out[0]
4063        else {
4064            panic!("expected ThinkingDelta");
4065        };
4066        assert_eq!(delta, "reasoning...");
4067        assert!(signature.is_none());
4068
4069        let out = s
4070            .feed_event(
4071                "content_block_delta",
4072                r#"{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig_abc"}}"#,
4073            )
4074            .unwrap();
4075        let ModelChunk::ThinkingDelta {
4076            delta, signature, ..
4077        } = &out[0]
4078        else {
4079            panic!("expected ThinkingDelta");
4080        };
4081        assert_eq!(delta, "");
4082        assert_eq!(signature.as_deref(), Some("sig_abc"));
4083    }
4084
4085    #[test]
4086    fn anthropic_stream_state_tool_use_streamed_input() {
4087        let mut s = AnthropicStreamState::default();
4088        let _ = s.feed_event(
4089            "message_start",
4090            r#"{"type":"message_start","message":{"id":"msg_x"}}"#,
4091        );
4092        let out = s
4093            .feed_event(
4094                "content_block_start",
4095                r#"{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"bash","input":{}}}"#,
4096            )
4097            .unwrap();
4098        assert_eq!(out.len(), 1);
4099        match &out[0] {
4100            ModelChunk::ToolCallStart { id, name } => {
4101                assert_eq!(id, "toolu_1");
4102                assert_eq!(name, "bash");
4103            }
4104            other => panic!("expected ToolCallStart, got {other:?}"),
4105        }
4106        let out = s
4107            .feed_event(
4108                "content_block_delta",
4109                r#"{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"cmd\":"}}"#,
4110            )
4111            .unwrap();
4112        let ModelChunk::ToolCallInputDelta { id, delta } = &out[0] else {
4113            panic!("expected ToolCallInputDelta");
4114        };
4115        assert_eq!(id, "toolu_1");
4116        assert_eq!(delta, "{\"cmd\":");
4117
4118        let out = s
4119            .feed_event(
4120                "content_block_stop",
4121                r#"{"type":"content_block_stop","index":0}"#,
4122            )
4123            .unwrap();
4124        match &out[0] {
4125            ModelChunk::ToolCallEnd { id, input } => {
4126                assert_eq!(id, "toolu_1");
4127                assert!(input.is_none());
4128            }
4129            other => panic!("expected ToolCallEnd, got {other:?}"),
4130        }
4131    }
4132
4133    #[test]
4134    fn anthropic_stream_state_finalises_on_close_without_message_stop() {
4135        // Some gateways drop `message_stop`; the harness still needs a
4136        // Done. finalize() bridges that gap.
4137        let mut s = AnthropicStreamState::default();
4138        let _ = s
4139            .feed_event(
4140                "message_delta",
4141                r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":100}}"#,
4142            )
4143            .unwrap();
4144        let done = s.finalize().unwrap();
4145        match done {
4146            ModelChunk::Done { stop_reason, .. } => assert_eq!(stop_reason, "max_tokens"),
4147            other => panic!("expected Done, got {other:?}"),
4148        }
4149    }
4150
4151    #[test]
4152    fn anthropic_stop_reason_mapping() {
4153        assert_eq!(map_anthropic_stop_reason(Some("end_turn")), "end_turn");
4154        assert_eq!(map_anthropic_stop_reason(Some("tool_use")), "end_turn");
4155        assert_eq!(map_anthropic_stop_reason(Some("max_tokens")), "max_tokens");
4156        assert_eq!(map_anthropic_stop_reason(Some("stop_sequence")), "end_turn");
4157        assert_eq!(map_anthropic_stop_reason(Some("refusal")), "refusal");
4158        assert_eq!(map_anthropic_stop_reason(None), "end_turn");
4159    }
4160
4161    #[test]
4162    fn classify_anthropic_http_error_buckets_by_status() {
4163        use reqwest::StatusCode;
4164        assert!(matches!(
4165            classify_anthropic_http_error(StatusCode::TOO_MANY_REQUESTS, "{}"),
4166            ModelClientError::RateLimit(_)
4167        ));
4168        assert!(matches!(
4169            classify_anthropic_http_error(StatusCode::UNAUTHORIZED, "{}"),
4170            ModelClientError::Auth(_)
4171        ));
4172        assert!(matches!(
4173            classify_anthropic_http_error(
4174                StatusCode::BAD_REQUEST,
4175                "{\"error\":{\"message\":\"prompt is too long; context_length_exceeded\"}}"
4176            ),
4177            ModelClientError::ContextOverflow(_)
4178        ));
4179        assert!(matches!(
4180            classify_anthropic_http_error(StatusCode::BAD_REQUEST, "invalid model"),
4181            ModelClientError::BadRequest(_)
4182        ));
4183        assert!(matches!(
4184            classify_anthropic_http_error(StatusCode::INTERNAL_SERVER_ERROR, "oops"),
4185            ModelClientError::ServerError(_)
4186        ));
4187    }
4188
4189    #[tokio::test]
4190    async fn collect_model_response_folds_streamed_tool_call_arguments() {
4191        // Simulate an OpenAI-style streaming tool call whose arguments
4192        // arrive in three chunks. collect_model_response should glue them
4193        // back into a parsed JSON value and ignore the leading text.
4194        let chunks = vec![
4195            Ok(ModelChunk::TextDelta {
4196                msg_id: "m".into(),
4197                delta: "ok ".into(),
4198            }),
4199            Ok(ModelChunk::ToolCallStart {
4200                id: "call_1".into(),
4201                name: "bash".into(),
4202            }),
4203            Ok(ModelChunk::ToolCallInputDelta {
4204                id: "call_1".into(),
4205                delta: "{\"command\":".into(),
4206            }),
4207            Ok(ModelChunk::ToolCallInputDelta {
4208                id: "call_1".into(),
4209                delta: "\"pwd\"}".into(),
4210            }),
4211            Ok(ModelChunk::ToolCallEnd {
4212                id: "call_1".into(),
4213                input: None,
4214            }),
4215            Ok(ModelChunk::Done {
4216                stop_reason: "end_turn".into(),
4217                usage: None,
4218            }),
4219        ];
4220        let stream = futures::stream::iter(chunks).boxed();
4221        let response = collect_model_response(stream).await.unwrap();
4222        let ModelResponse::ToolCall {
4223            invocation,
4224            preface,
4225            ..
4226        } = response
4227        else {
4228            panic!("expected ToolCall");
4229        };
4230        assert_eq!(invocation.name, "bash");
4231        assert_eq!(invocation.input["command"], "pwd");
4232        assert_eq!(preface.as_deref(), Some("ok "));
4233    }
4234
4235    #[test]
4236    fn classify_openai_http_error_buckets_by_status_and_body() {
4237        use reqwest::StatusCode;
4238        assert!(matches!(
4239            classify_openai_http_error(StatusCode::TOO_MANY_REQUESTS, "rate limit hit"),
4240            ModelClientError::RateLimit(_)
4241        ));
4242        assert!(matches!(
4243            classify_openai_http_error(StatusCode::UNAUTHORIZED, "bad key"),
4244            ModelClientError::Auth(_)
4245        ));
4246        assert!(matches!(
4247            classify_openai_http_error(StatusCode::FORBIDDEN, "no access"),
4248            ModelClientError::Auth(_)
4249        ));
4250        assert!(matches!(
4251            classify_openai_http_error(
4252                StatusCode::BAD_REQUEST,
4253                "{\"error\":{\"message\":\"this model's maximum context length is 8192\"}}"
4254            ),
4255            ModelClientError::ContextOverflow(_)
4256        ));
4257        // BAD_REQUEST without context_overflow tell-tale → BadRequest.
4258        assert!(matches!(
4259            classify_openai_http_error(StatusCode::BAD_REQUEST, "missing argument"),
4260            ModelClientError::BadRequest(_)
4261        ));
4262        // 5xx → ServerError (retryable with backoff).
4263        assert!(matches!(
4264            classify_openai_http_error(StatusCode::INTERNAL_SERVER_ERROR, "oops"),
4265            ModelClientError::ServerError(_)
4266        ));
4267    }
4268
4269    #[test]
4270    fn looks_like_context_overflow_matches_common_phrasings() {
4271        assert!(looks_like_context_overflow(
4272            "context_length_exceeded: this model has a maximum context length of 8192"
4273        ));
4274        assert!(looks_like_context_overflow("too many tokens in prompt"));
4275        assert!(looks_like_context_overflow(
4276            "Prompt exceeds the model's maximum context"
4277        ));
4278        assert!(!looks_like_context_overflow("invalid api key"));
4279    }
4280
4281    #[test]
4282    fn parse_openai_usage_returns_none_for_missing_or_all_zero() {
4283        // Field absent entirely.
4284        assert!(parse_openai_usage(None).is_none());
4285        // Field present but all zeros — treat as "provider didn't report".
4286        assert!(parse_openai_usage(Some(&json!({
4287            "prompt_tokens": 0,
4288            "completion_tokens": 0
4289        })))
4290        .is_none());
4291    }
4292
4293    #[test]
4294    fn openai_client_renders_multi_turn_history() {
4295        let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
4296            base_url: "https://example.test".into(),
4297            api_key: "sk-test".into(),
4298            model: resolved_model(
4299                "gpt-test",
4300                WireProtocol::OpenAiCompatible,
4301                128,
4302                Some(0.2),
4303                ReasoningConfig::default(),
4304            ),
4305        });
4306        let body = client.request_body(&ModelTurnInput {
4307            system_prompt: None,
4308            messages: vec![
4309                ChatMessage::User {
4310                    content: "run pwd".into(),
4311                    attachments: vec![],
4312                },
4313                ChatMessage::Assistant {
4314                    text: None,
4315                    tool_calls: vec![ToolInvocation {
4316                        id: "call_1".into(),
4317                        name: "bash".into(),
4318                        input: json!({"command": "pwd"}),
4319                        raw_emitted_args: None,
4320                    }],
4321                    thinking: None,
4322                    usage: None,
4323                },
4324                ChatMessage::Tool {
4325                    tool_call_id: "call_1".into(),
4326                    content: "{\"stdout\":\"/home/user\"}".into(),
4327                    is_error: false,
4328                    attachments: vec![],
4329                },
4330            ],
4331            tools: vec![],
4332            hosted_tools: vec![],
4333            tool_choice: ToolChoice::Auto,
4334            parallel_tool_calls: None,
4335        });
4336        assert_eq!(body["temperature"], 0.2);
4337        assert_eq!(body["max_tokens"], 128);
4338        assert_eq!(body["messages"][0]["role"], "user");
4339        assert_eq!(body["messages"][1]["role"], "assistant");
4340        assert_eq!(body["messages"][1]["tool_calls"][0]["id"], "call_1");
4341        assert_eq!(
4342            body["messages"][1]["tool_calls"][0]["function"]["name"],
4343            "bash"
4344        );
4345        assert_eq!(body["messages"][2]["role"], "tool");
4346        assert_eq!(body["messages"][2]["tool_call_id"], "call_1");
4347    }
4348
4349    #[test]
4350    fn reasoning_controls_translate_by_wire_protocol() {
4351        let openai = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
4352            base_url: "https://example.test".into(),
4353            api_key: "test".into(),
4354            model: resolved_model(
4355                "deepseek-v4-pro",
4356                WireProtocol::OpenAiCompatible,
4357                4_096,
4358                None,
4359                ReasoningConfig {
4360                    mode: ReasoningMode::Enabled,
4361                    effort: None,
4362                    budget_tokens: None,
4363                },
4364            ),
4365        });
4366        let body = openai.request_body(&user("hi"));
4367        assert_eq!(body["thinking"]["type"], "enabled");
4368
4369        let anthropic = AnthropicModelClient::new(AnthropicConfig {
4370            base_url: "https://example.test/v1".into(),
4371            api_key: "test".into(),
4372            model: resolved_model(
4373                "claude-sonnet-4-6",
4374                WireProtocol::Anthropic,
4375                4_096,
4376                None,
4377                ReasoningConfig {
4378                    mode: ReasoningMode::Enabled,
4379                    effort: Some("high".into()),
4380                    budget_tokens: None,
4381                },
4382            ),
4383            anthropic_version: AnthropicConfig::DEFAULT_VERSION.into(),
4384        });
4385        let body = anthropic.request_body(&user("hi"));
4386        assert_eq!(body["thinking"]["type"], "adaptive");
4387        assert_eq!(body["output_config"]["effort"], "high");
4388
4389        let responses = OpenAiResponsesModelClient::new(OpenAiResponsesConfig {
4390            base_url: "https://example.test/v1".into(),
4391            api_key: "test".into(),
4392            model: resolved_model(
4393                "gpt-5.5",
4394                WireProtocol::OpenAiResponses,
4395                4_096,
4396                None,
4397                ReasoningConfig {
4398                    mode: ReasoningMode::Disabled,
4399                    effort: Some("none".into()),
4400                    budget_tokens: None,
4401                },
4402            ),
4403            reasoning_summary: None,
4404        });
4405        let body = responses.request_body(&user("hi"));
4406        assert_eq!(body["reasoning"]["effort"], "none");
4407    }
4408
4409    #[tokio::test]
4410    async fn scripted_client_emits_tool_call_then_summary() {
4411        let scripted = ScriptedModelClient;
4412        let first = scripted
4413            .next(user("read README.md"))
4414            .await
4415            .expect("scripted first");
4416        let ModelResponse::ToolCall { invocation, .. } = first else {
4417            panic!("expected tool call on first step");
4418        };
4419        assert_eq!(invocation.name, "read");
4420
4421        // Simulate the loop appending Assistant + Tool messages, then ask
4422        // the scripted client for its next move — should be a summary.
4423        let history = ModelTurnInput {
4424            system_prompt: None,
4425            messages: vec![
4426                ChatMessage::User {
4427                    content: "read README.md".into(),
4428                    attachments: vec![],
4429                },
4430                ChatMessage::Assistant {
4431                    text: None,
4432                    tool_calls: vec![invocation.clone()],
4433                    thinking: None,
4434                    usage: None,
4435                },
4436                ChatMessage::Tool {
4437                    tool_call_id: invocation.id.clone(),
4438                    content: "{\"content\":\"hi\"}".into(),
4439                    is_error: false,
4440                    attachments: vec![],
4441                },
4442            ],
4443            tools: vec![],
4444            hosted_tools: vec![],
4445            tool_choice: ToolChoice::Auto,
4446            parallel_tool_calls: None,
4447        };
4448        let second = scripted.next(history).await.expect("scripted second");
4449        let ModelResponse::Message { text, .. } = second else {
4450            panic!("expected final message after tool result");
4451        };
4452        assert!(text.contains("completed"));
4453    }
4454
4455    // ─── OpenAI Responses API ────────────────────────────────────────────
4456
4457    #[test]
4458    fn responses_client_builds_responses_endpoint_and_body() {
4459        let mk = |base: &str| {
4460            OpenAiResponsesModelClient::new(OpenAiResponsesConfig {
4461                base_url: base.into(),
4462                api_key: "sk-test".into(),
4463                model: resolved_model(
4464                    "gpt-5",
4465                    WireProtocol::OpenAiResponses,
4466                    2_048,
4467                    None,
4468                    ReasoningConfig {
4469                        mode: ReasoningMode::Enabled,
4470                        effort: Some("high".into()),
4471                        budget_tokens: None,
4472                    },
4473                ),
4474                reasoning_summary: None,
4475            })
4476        };
4477        // Route appended; version segment respected; no double-append.
4478        assert_eq!(
4479            mk("https://api.openai.com/v1").endpoint(),
4480            "https://api.openai.com/v1/responses"
4481        );
4482        assert_eq!(
4483            mk("https://api.openai.com/v1/").endpoint(),
4484            "https://api.openai.com/v1/responses"
4485        );
4486        assert_eq!(
4487            mk("https://api.openai.com/v1/responses").endpoint(),
4488            "https://api.openai.com/v1/responses"
4489        );
4490
4491        let client = mk("https://api.openai.com/v1");
4492        let mut input = user("hello");
4493        input.system_prompt = Some("be terse".into());
4494        input.tools = vec![bash_spec()];
4495        let body = client.request_body(&input);
4496        assert_eq!(body["model"], "gpt-5");
4497        assert_eq!(body["stream"], true);
4498        assert_eq!(body["store"], false);
4499        assert_eq!(body["instructions"], "be terse");
4500        assert_eq!(body["max_output_tokens"], 2048);
4501        assert_eq!(body["reasoning"]["effort"], "high");
4502        // Stateless mode requests encrypted reasoning for round-trip.
4503        assert_eq!(body["include"][0], "reasoning.encrypted_content");
4504        // Flat tool shape (not nested under `function`).
4505        assert_eq!(body["tools"][0]["type"], "function");
4506        assert_eq!(body["tools"][0]["name"], "bash");
4507        assert!(body["tools"][0]["parameters"].is_object());
4508        assert_eq!(body["tool_choice"], "auto");
4509        // User message projects to an input_text content part.
4510        assert_eq!(body["input"][0]["role"], "user");
4511        assert_eq!(body["input"][0]["content"][0]["type"], "input_text");
4512        assert_eq!(body["input"][0]["content"][0]["text"], "hello");
4513    }
4514
4515    #[test]
4516    fn responses_tool_choice_none_drops_client_and_hosted_tools() {
4517        let client = OpenAiResponsesModelClient::new(OpenAiResponsesConfig {
4518            base_url: "https://api.openai.com/v1".into(),
4519            api_key: "sk-test".into(),
4520            model: default_model("gpt-5", WireProtocol::OpenAiResponses),
4521            reasoning_summary: None,
4522        });
4523        let mut input = user("hi");
4524        input.tools = vec![bash_spec()];
4525        input.hosted_tools = vec![HostedTool::WebSearch];
4526        input.tool_choice = ToolChoice::None;
4527        let body = client.request_body(&input);
4528        // `None` means no tools this turn — that MUST also suppress hosted
4529        // (provider-run) tools, else server-side web_search could still fire.
4530        assert!(
4531            body.get("tools").is_none(),
4532            "tools should be absent, got {:?}",
4533            body.get("tools")
4534        );
4535        assert!(body.get("tool_choice").is_none());
4536
4537        // Sanity: with Auto, both the function tool and the hosted web_search
4538        // are advertised.
4539        input.tool_choice = ToolChoice::Auto;
4540        let body = client.request_body(&input);
4541        let tools = body["tools"].as_array().expect("tools present");
4542        assert_eq!(tools.len(), 2);
4543        assert!(tools.iter().any(|t| t["type"] == "function"));
4544        assert!(tools.iter().any(|t| t["type"] == "web_search"));
4545    }
4546
4547    #[test]
4548    fn responses_tool_choice_required_sent_for_hosted_only() {
4549        let client = OpenAiResponsesModelClient::new(OpenAiResponsesConfig {
4550            base_url: "https://api.openai.com/v1".into(),
4551            api_key: "sk-test".into(),
4552            model: default_model("gpt-5", WireProtocol::OpenAiResponses),
4553            reasoning_summary: None,
4554        });
4555        // Hosted tool only — no client function tools.
4556        let mut input = user("search the web");
4557        input.hosted_tools = vec![HostedTool::WebSearch];
4558
4559        // Required must be sent even without function tools, else it silently
4560        // downgrades to the default auto.
4561        input.tool_choice = ToolChoice::Required;
4562        let body = client.request_body(&input);
4563        assert_eq!(body["tools"][0]["type"], "web_search");
4564        assert_eq!(body["tool_choice"], "required");
4565
4566        // Tool(name) force-selects a FUNCTION tool; with only a hosted tool
4567        // present there's nothing to force, so it must NOT be sent (encoding a
4568        // hosted tool as {type:function,name} would 400).
4569        input.tool_choice = ToolChoice::Tool("bash".into());
4570        let body = client.request_body(&input);
4571        assert_eq!(body["tools"][0]["type"], "web_search");
4572        assert!(
4573            body.get("tool_choice").is_none(),
4574            "Tool(name) must not be sent for a hosted-only turn, got {:?}",
4575            body.get("tool_choice")
4576        );
4577
4578        // With a function tool present, Tool(name) IS sent as a function choice.
4579        input.tools = vec![bash_spec()];
4580        let body = client.request_body(&input);
4581        assert_eq!(body["tool_choice"]["type"], "function");
4582        assert_eq!(body["tool_choice"]["name"], "bash");
4583    }
4584
4585    #[test]
4586    fn responses_stream_state_accepts_reasoning_summary_alias() {
4587        // Some gateways emit `response.reasoning_summary.delta` instead of the
4588        // `_text` variant — both must surface as thinking.
4589        let mut st = OpenAiResponsesStreamState::default();
4590        let chunks = st
4591            .feed_data(
4592                r#"{"type":"response.reasoning_summary.delta","item_id":"rs_1","summary_index":0,"delta":"aliased"}"#,
4593            )
4594            .expect("feed_data");
4595        assert!(
4596            matches!(&chunks[0], ModelChunk::ThinkingDelta { delta, .. } if delta == "aliased")
4597        );
4598    }
4599
4600    #[test]
4601    fn responses_stream_state_separates_reasoning_summary_parts() {
4602        let mut st = OpenAiResponsesStreamState::default();
4603        let mut chunks: Vec<ModelChunk> = Vec::new();
4604        let mut feed = |st: &mut OpenAiResponsesStreamState, data: &str| {
4605            chunks.extend(st.feed_data(data).expect("feed_data"));
4606        };
4607        // The separator rides the FIRST real text delta of a later part — not
4608        // the structural `part.added` event — so it only ever attaches to
4609        // genuine model output. Part 0 gets no prefix; part 1's first delta
4610        // is prefixed with a blank line; subsequent deltas of a part are not.
4611        feed(
4612            &mut st,
4613            r#"{"type":"response.reasoning_summary_part.added","item_id":"rs_1","summary_index":0}"#,
4614        );
4615        feed(
4616            &mut st,
4617            r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","summary_index":0,"delta":"first"}"#,
4618        );
4619        feed(
4620            &mut st,
4621            r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","summary_index":0,"delta":" more"}"#,
4622        );
4623        feed(
4624            &mut st,
4625            r#"{"type":"response.reasoning_summary_part.added","item_id":"rs_1","summary_index":1}"#,
4626        );
4627        feed(
4628            &mut st,
4629            r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","summary_index":1,"delta":"second"}"#,
4630        );
4631        let deltas: Vec<&str> = chunks
4632            .iter()
4633            .filter_map(|c| match c {
4634                ModelChunk::ThinkingDelta { delta, .. } => Some(delta.as_str()),
4635                _ => None,
4636            })
4637            .collect();
4638        // `part.added` produces nothing; the boundary is folded into the
4639        // first text delta of part 1.
4640        assert_eq!(deltas, vec!["first", " more", "\n\nsecond"]);
4641    }
4642
4643    #[test]
4644    fn responses_input_projection_round_trips_tool_calls_and_reasoning() {
4645        let sig = encode_reasoning_signature("rs_123", "ENC==");
4646        let messages = vec![
4647            ChatMessage::User {
4648                content: "hi".into(),
4649                attachments: vec![],
4650            },
4651            ChatMessage::Assistant {
4652                text: Some("looked".into()),
4653                tool_calls: vec![ToolInvocation {
4654                    id: "call_1".into(),
4655                    name: "bash".into(),
4656                    input: json!({"command": "ls"}),
4657                    raw_emitted_args: None,
4658                }],
4659                thinking: Some(AssistantThinking {
4660                    text: "let me look".into(),
4661                    signature: Some(sig),
4662                }),
4663                usage: None,
4664            },
4665            ChatMessage::Tool {
4666                tool_call_id: "call_1".into(),
4667                content: "file.txt".into(),
4668                is_error: false,
4669                attachments: vec![],
4670            },
4671        ];
4672        let input = chat_messages_to_responses_input(&messages);
4673        assert_eq!(input[0]["role"], "user");
4674        // Reasoning item precedes text + tool call, carrying encrypted payload.
4675        assert_eq!(input[1]["type"], "reasoning");
4676        assert_eq!(input[1]["id"], "rs_123");
4677        assert_eq!(input[1]["encrypted_content"], "ENC==");
4678        assert_eq!(input[1]["summary"][0]["text"], "let me look");
4679        assert_eq!(input[2]["role"], "assistant");
4680        assert_eq!(input[2]["content"][0]["type"], "output_text");
4681        // Tool call keyed by call_id (the harness's pairing key).
4682        assert_eq!(input[3]["type"], "function_call");
4683        assert_eq!(input[3]["call_id"], "call_1");
4684        assert_eq!(input[3]["name"], "bash");
4685        assert_eq!(input[4]["type"], "function_call_output");
4686        assert_eq!(input[4]["call_id"], "call_1");
4687        assert_eq!(input[4]["output"], "file.txt");
4688    }
4689
4690    #[test]
4691    fn responses_stream_state_emits_text_toolcall_and_done() {
4692        let mut st = OpenAiResponsesStreamState::default();
4693        let mut chunks: Vec<ModelChunk> = Vec::new();
4694        let mut feed = |st: &mut OpenAiResponsesStreamState, data: &str| {
4695            chunks.extend(st.feed_data(data).expect("feed_data"));
4696        };
4697        feed(
4698            &mut st,
4699            r#"{"type":"response.created","response":{"id":"resp_1"}}"#,
4700        );
4701        feed(
4702            &mut st,
4703            r#"{"type":"response.output_text.delta","item_id":"msg_1","delta":"Hel"}"#,
4704        );
4705        feed(
4706            &mut st,
4707            r#"{"type":"response.output_text.delta","item_id":"msg_1","delta":"lo"}"#,
4708        );
4709        feed(
4710            &mut st,
4711            r#"{"type":"response.output_item.added","item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"bash"}}"#,
4712        );
4713        feed(
4714            &mut st,
4715            r#"{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{\"command\":"}"#,
4716        );
4717        feed(
4718            &mut st,
4719            r#"{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"\"ls\"}"}"#,
4720        );
4721        feed(
4722            &mut st,
4723            r#"{"type":"response.output_item.done","item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
4724        );
4725        feed(
4726            &mut st,
4727            r#"{"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":10,"output_tokens":5,"input_tokens_details":{"cached_tokens":2}}}}"#,
4728        );
4729
4730        assert!(matches!(&chunks[0], ModelChunk::TextDelta { delta, .. } if delta == "Hel"));
4731        assert!(matches!(&chunks[1], ModelChunk::TextDelta { delta, .. } if delta == "lo"));
4732        assert!(
4733            matches!(&chunks[2], ModelChunk::ToolCallStart { id, name } if id == "call_1" && name == "bash")
4734        );
4735        assert!(matches!(&chunks[3], ModelChunk::ToolCallInputDelta { id, .. } if id == "call_1"));
4736        assert!(matches!(&chunks[4], ModelChunk::ToolCallInputDelta { id, .. } if id == "call_1"));
4737        match &chunks[5] {
4738            ModelChunk::ToolCallEnd { id, input } => {
4739                assert_eq!(id, "call_1");
4740                assert_eq!(input.as_ref().expect("early input")["command"], "ls");
4741            }
4742            other => panic!("expected ToolCallEnd, got {other:?}"),
4743        }
4744        match chunks.last().expect("done chunk") {
4745            ModelChunk::Done { stop_reason, usage } => {
4746                assert_eq!(stop_reason, "end_turn");
4747                let u = usage.as_ref().expect("usage");
4748                assert_eq!(u.input_tokens, 10);
4749                assert_eq!(u.output_tokens, 5);
4750                assert_eq!(u.cache_read_input_tokens, 2);
4751            }
4752            other => panic!("expected Done, got {other:?}"),
4753        }
4754        assert!(st.ended_cleanly());
4755    }
4756
4757    #[test]
4758    fn responses_stream_state_round_trips_reasoning_signature() {
4759        let mut st = OpenAiResponsesStreamState::default();
4760        let mut chunks: Vec<ModelChunk> = Vec::new();
4761        chunks.extend(
4762            st.feed_data(
4763                r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","delta":"pondering"}"#,
4764            )
4765            .unwrap(),
4766        );
4767        chunks.extend(
4768            st.feed_data(
4769                r#"{"type":"response.output_item.done","item":{"type":"reasoning","id":"rs_1","encrypted_content":"ENC=="}}"#,
4770            )
4771            .unwrap(),
4772        );
4773        assert!(
4774            matches!(&chunks[0], ModelChunk::ThinkingDelta { delta, signature, .. } if delta == "pondering" && signature.is_none())
4775        );
4776        match &chunks[1] {
4777            ModelChunk::ThinkingDelta {
4778                delta, signature, ..
4779            } => {
4780                assert!(delta.is_empty());
4781                let (id, enc) =
4782                    decode_reasoning_signature(signature.as_ref().expect("signature")).unwrap();
4783                assert_eq!(id, "rs_1");
4784                assert_eq!(enc, "ENC==");
4785            }
4786            other => panic!("expected ThinkingDelta, got {other:?}"),
4787        }
4788    }
4789
4790    #[test]
4791    fn responses_stream_state_reports_cutoff_without_terminal_event() {
4792        let mut st = OpenAiResponsesStreamState::default();
4793        st.feed_data(r#"{"type":"response.output_text.delta","item_id":"m","delta":"hi"}"#)
4794            .unwrap();
4795        // No terminal `response.*` event arrived → the stream was cut off.
4796        assert!(!st.ended_cleanly());
4797    }
4798
4799    #[test]
4800    fn reasoning_signature_encode_decode_roundtrip() {
4801        let sig = encode_reasoning_signature("rs_abc", "base64==payload");
4802        assert_eq!(
4803            decode_reasoning_signature(&sig),
4804            Some(("rs_abc".into(), "base64==payload".into()))
4805        );
4806        // A plain (non-Responses) signature has no newline → not decodable,
4807        // so it is never mis-projected as a reasoning item.
4808        assert_eq!(decode_reasoning_signature("anthropic-sig-no-newline"), None);
4809    }
4810}