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#[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 signature: Option<String>,
35 },
36 ToolCallStart {
39 id: String,
40 name: String,
41 },
42 ToolCallInputDelta {
47 id: String,
48 delta: String,
49 },
50 ToolCallEnd {
56 id: String,
57 input: Option<Value>,
58 },
59 Done {
62 stop_reason: String,
63 usage: Option<HarnessUsage>,
64 },
65}
66
67#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
73pub struct AssistantThinking {
74 pub text: String,
75 pub signature: Option<String>,
76}
77
78#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
82pub struct ImageSource {
83 pub media_type: String,
87 pub data: ImageData,
88}
89
90#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
91pub enum ImageData {
92 Base64(String),
97 Url(String),
100}
101
102#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
107pub enum UserAttachment {
108 Image(ImageSource),
109}
110
111#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
118pub enum ChatMessage {
119 User {
120 content: String,
121 attachments: Vec<UserAttachment>,
125 },
126 Assistant {
132 text: Option<String>,
133 tool_calls: Vec<ToolInvocation>,
134 thinking: Option<AssistantThinking>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
143 usage: Option<crate::event::HarnessUsage>,
144 },
145 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 pub system_prompt: Option<String>,
166 pub messages: Vec<ChatMessage>,
170 pub tools: Vec<ToolSpec>,
174 pub hosted_tools: Vec<HostedTool>,
178 pub tool_choice: ToolChoice,
182 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#[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#[derive(Debug, Clone, PartialEq, Default)]
223pub enum ToolChoice {
224 #[default]
225 Auto,
226 None,
227 Required,
228 Tool(String),
229}
230
231impl ToolChoice {
232 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#[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 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#[derive(Debug, thiserror::Error)]
293pub enum ModelClientError {
294 #[error("rate limit: {0}")]
296 RateLimit(String),
297 #[error("auth: {0}")]
299 Auth(String),
300 #[error("context overflow: {0}")]
302 ContextOverflow(String),
303 #[error("bad request: {0}")]
305 BadRequest(String),
306 #[error("server error: {0}")]
308 ServerError(String),
309 #[error("network: {0}")]
311 Network(String),
312 #[error("model error: {0}")]
314 Other(String),
315}
316
317impl ModelClientError {
318 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 fn hosted_capability(&self, capability: HostedCapability) -> CapabilitySupport;
332
333 async fn stream(
338 &self,
339 input: ModelTurnInput,
340 ) -> Result<BoxStream<'static, Result<ModelChunk, ModelClientError>>, ModelClientError>;
341
342 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
353pub 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 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 }
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 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
445struct 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 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 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 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 => json!("none"),
585 ToolChoice::Required => json!("required"),
586 ToolChoice::Tool(name) => json!({
587 "type": "function",
588 "function": {"name": name},
589 }),
590 }
591}
592
593fn 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 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 compaction_input_tokens: 0,
629 compaction_output_tokens: 0,
630 })
631}
632
633fn image_to_openai_part(src: &ImageSource) -> Value {
641 let url = match &src.data {
642 ImageData::Base64(b64) => {
643 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
668const 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
677fn 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
713fn chat_message_to_wire(msg: &ChatMessage) -> Value {
717 match msg {
718 ChatMessage::User {
719 content,
720 attachments,
721 } => {
722 if attachments.is_empty() {
727 json!({ "role": "user", "content": content })
728 } else {
729 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 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 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 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 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 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 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#[derive(Debug, Default)]
926struct OpenAiStreamState {
927 msg_id: Option<String>,
933 tool_call_by_index: std::collections::HashMap<u64, String>,
936 finish_reason: Option<String>,
937 pending_usage: Option<HarnessUsage>,
938 done_emitted: bool,
941}
942
943impl OpenAiStreamState {
944 fn feed_data(&mut self, data: &str) -> Result<Vec<ModelChunk>, ModelClientError> {
945 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 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 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 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 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 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 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 }
1062
1063 Ok(out)
1064 }
1065
1066 fn finalize(&mut self) -> Option<ModelChunk> {
1067 self.emit_done()
1068 }
1069
1070 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
1093fn 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
1109fn 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 if status == StatusCode::TOO_MANY_REQUESTS {
1119 return ModelClientError::RateLimit(format!("HTTP {status}: {snippet}"));
1120 }
1121 if status.is_server_error() {
1122 return ModelClientError::ServerError(format!("HTTP {status}: {snippet}"));
1124 }
1125
1126 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 return ModelClientError::BadRequest(format!("HTTP {status}: {snippet}"));
1136 }
1137
1138 ModelClientError::Other(format!("HTTP {status}: {snippet}"))
1139}
1140
1141fn 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
1155fn 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#[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
1194fn scripted_chunks_for(input: &ModelTurnInput) -> Vec<ModelChunk> {
1199 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 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#[derive(Debug, Clone)]
1295pub struct AnthropicConfig {
1296 pub base_url: String,
1297 pub api_key: String,
1298 pub model: ResolvedModelConfig,
1299 pub anthropic_version: String,
1303}
1304
1305impl AnthropicConfig {
1306 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 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 fn request_body(&self, input: &ModelTurnInput) -> Value {
1347 let messages = chat_messages_to_anthropic_messages(&input.messages);
1348 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 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 if !matches!(input.tool_choice, ToolChoice::Auto) {
1385 body["tool_choice"] = anthropic_tool_choice_value(&input.tool_choice);
1386 }
1387 }
1388 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"}), 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
1499fn 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 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 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 flush_tool_results(&mut pending_tool_results, &mut out);
1567 let mut blocks: Vec<Value> = Vec::new();
1568 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 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 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 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 flush_tool_results(&mut pending_tool_results, &mut out);
1639 out
1640}
1641
1642fn 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
1654fn 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
1698fn 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 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 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#[derive(Debug, Default)]
1789struct AnthropicStreamState {
1790 msg_id: Option<String>,
1791 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 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 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 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 _ => { }
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 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 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 _ => { }
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
2002fn 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 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
2048fn 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#[derive(Debug, Clone)]
2114pub struct OpenAiResponsesConfig {
2115 pub base_url: String,
2118 pub api_key: String,
2119 pub model: ResolvedModelConfig,
2120 pub reasoning_summary: Option<String>,
2124}
2125
2126impl OpenAiResponsesConfig {
2127 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 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 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 "store": false,
2173 });
2174 if let Some(sys) = input.system_prompt.as_deref().filter(|s| !s.is_empty()) {
2177 body["instructions"] = json!(sys);
2178 }
2179
2180 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 let send_choice = match input.tool_choice {
2203 ToolChoice::Auto | ToolChoice::Required => true,
2204 ToolChoice::Tool(_) => !input.tools.is_empty(),
2205 ToolChoice::None => false, };
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 body["include"] = json!(["reasoning.encrypted_content"]);
2247
2248 body
2249 }
2250}
2251
2252fn 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
2264fn 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 ToolChoice::None => json!("none"),
2279 ToolChoice::Required => json!("required"),
2280 ToolChoice::Tool(name) => json!({ "type": "function", "name": name }),
2281 }
2282}
2283
2284fn 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
2293fn 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
2311fn 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 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 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
2426fn 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
2454fn 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
2466fn 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 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 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#[derive(Debug, Default)]
2591struct OpenAiResponsesStreamState {
2592 response_id: Option<String>,
2594 fc_call_by_item: std::collections::HashMap<String, String>,
2598 summary_parts_seen: std::collections::HashSet<String>,
2602 stop_reason: Option<String>,
2603 pending_usage: Option<HarnessUsage>,
2604 done_emitted: bool,
2605 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 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 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 "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 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 "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 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 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 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 _ => {}
2855 }
2856 Ok(out)
2857 }
2858
2859 fn finalize(&mut self) -> Option<ModelChunk> {
2860 self.emit_done()
2861 }
2862
2863 fn ended_cleanly(&self) -> bool {
2864 self.saw_terminal || self.done_emitted
2865 }
2866
2867 fn emit_done(&mut self) -> Option<ModelChunk> {
2868 if self.done_emitted {
2869 return None;
2870 }
2871 self.done_emitted = true;
2872 Some(ModelChunk::Done {
2873 stop_reason: self
2874 .stop_reason
2875 .clone()
2876 .unwrap_or_else(|| "end_turn".into()),
2877 usage: self.pending_usage.take(),
2878 })
2879 }
2880}
2881
2882#[cfg(test)]
2883mod tests {
2884 use super::*;
2885 use crate::model_catalog::{
2886 LimitsSource, ModelCapabilities, ModelLimits, ReasoningConfig, ReasoningOption,
2887 };
2888
2889 fn resolved_model(
2890 id: &str,
2891 protocol: WireProtocol,
2892 max_output_tokens: u64,
2893 temperature: Option<f64>,
2894 reasoning: ReasoningConfig,
2895 ) -> ResolvedModelConfig {
2896 ResolvedModelConfig {
2897 model: id.into(),
2898 wire_protocol: protocol,
2899 max_output_tokens,
2900 temperature,
2901 reasoning,
2902 capabilities: ModelCapabilities {
2903 id: id.into(),
2904 limits: ModelLimits {
2905 context: 1_000_000,
2906 input: None,
2907 output: 384_000,
2908 },
2909 reasoning: true,
2910 reasoning_options: vec![
2911 ReasoningOption::Toggle,
2912 ReasoningOption::Effort {
2913 values: vec!["none".into(), "low".into(), "medium".into(), "high".into()],
2914 },
2915 ],
2916 temperature: true,
2917 tool_call: true,
2918 interleaved: None,
2919 status: None,
2920 limits_source: LimitsSource::Catalog,
2921 },
2922 }
2923 }
2924
2925 fn default_model(id: &str, protocol: WireProtocol) -> ResolvedModelConfig {
2926 resolved_model(id, protocol, 2_048, None, ReasoningConfig::default())
2927 }
2928
2929 fn user(prompt: &str) -> ModelTurnInput {
2930 ModelTurnInput {
2931 system_prompt: None,
2932 messages: vec![ChatMessage::User {
2933 content: prompt.into(),
2934 attachments: vec![],
2935 }],
2936 tools: vec![],
2937 hosted_tools: vec![],
2938 tool_choice: ToolChoice::Auto,
2939 parallel_tool_calls: None,
2940 }
2941 }
2942
2943 fn bash_spec() -> ToolSpec {
2944 ToolSpec {
2945 name: "bash".into(),
2946 description: "Run a shell command inside the sandbox.".into(),
2947 input_schema: json!({
2948 "type": "object",
2949 "properties": {"command": {"type": "string"}},
2950 "required": ["command"],
2951 "additionalProperties": false
2952 }),
2953 }
2954 }
2955
2956 #[test]
2957 fn openai_client_builds_chat_completions_request() {
2958 let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
2961 base_url: "https://example.test/v1/".into(),
2962 api_key: "sk-test".into(),
2963 model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
2964 });
2965 assert_eq!(
2966 client.endpoint(),
2967 "https://example.test/v1/chat/completions"
2968 );
2969 let client_with_v1 = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
2970 base_url: "https://example.test/v1".into(),
2971 api_key: "sk-test".into(),
2972 model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
2973 });
2974 assert_eq!(
2975 client_with_v1.endpoint(),
2976 "https://example.test/v1/chat/completions"
2977 );
2978 let glm = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
2981 base_url: "https://open.bigmodel.cn/api/coding/paas/v4".into(),
2982 api_key: "sk-test".into(),
2983 model: default_model("glm-4.6", WireProtocol::OpenAiCompatible),
2984 });
2985 assert_eq!(
2986 glm.endpoint(),
2987 "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions"
2988 );
2989 let body = client.request_body(&user("hello"));
2990 assert_eq!(body["model"], "gpt-test");
2991 assert_eq!(body["messages"][0]["role"], "user");
2992 assert_eq!(body["messages"][0]["content"], "hello");
2993 assert!(body.get("tools").is_none());
2995 assert!(body.get("tool_choice").is_none());
2996
2997 let with_tools = ModelTurnInput {
2999 system_prompt: None,
3000 messages: vec![ChatMessage::User {
3001 content: "hello".into(),
3002 attachments: vec![],
3003 }],
3004 tools: vec![bash_spec()],
3005 hosted_tools: vec![],
3006 tool_choice: ToolChoice::Auto,
3007 parallel_tool_calls: None,
3008 };
3009 let body = client.request_body(&with_tools);
3010 assert_eq!(body["tools"][0]["function"]["name"], "bash");
3011 assert_eq!(
3012 body["tools"][0]["function"]["parameters"]["required"][0],
3013 "command"
3014 );
3015 assert_eq!(body["tool_choice"], "auto");
3016 assert!(body.get("parallel_tool_calls").is_none());
3019 }
3020
3021 #[test]
3022 fn openai_client_emits_tool_choice_required() {
3023 let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
3024 base_url: "https://example.test".into(),
3025 api_key: "sk-test".into(),
3026 model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
3027 });
3028 let body = client.request_body(&ModelTurnInput {
3029 system_prompt: None,
3030 messages: vec![ChatMessage::User {
3031 content: "go".into(),
3032 attachments: vec![],
3033 }],
3034 tools: vec![bash_spec()],
3035 hosted_tools: vec![],
3036 tool_choice: ToolChoice::Required,
3037 parallel_tool_calls: Some(false),
3038 });
3039 assert_eq!(body["tool_choice"], "required");
3040 assert_eq!(body["parallel_tool_calls"], false);
3041 }
3042
3043 #[test]
3044 fn openai_client_emits_tool_choice_named_tool() {
3045 let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
3046 base_url: "https://example.test".into(),
3047 api_key: "sk-test".into(),
3048 model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
3049 });
3050 let body = client.request_body(&ModelTurnInput {
3051 system_prompt: None,
3052 messages: vec![ChatMessage::User {
3053 content: "go".into(),
3054 attachments: vec![],
3055 }],
3056 tools: vec![bash_spec()],
3057 hosted_tools: vec![],
3058 tool_choice: ToolChoice::Tool("bash".into()),
3059 parallel_tool_calls: None,
3060 });
3061 assert_eq!(body["tool_choice"]["type"], "function");
3062 assert_eq!(body["tool_choice"]["function"]["name"], "bash");
3063 }
3064
3065 #[test]
3066 fn openai_client_drops_tools_when_choice_is_none() {
3067 let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
3071 base_url: "https://example.test".into(),
3072 api_key: "sk-test".into(),
3073 model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
3074 });
3075 let body = client.request_body(&ModelTurnInput {
3076 system_prompt: None,
3077 messages: vec![ChatMessage::User {
3078 content: "go".into(),
3079 attachments: vec![],
3080 }],
3081 tools: vec![bash_spec()],
3082 hosted_tools: vec![],
3083 tool_choice: ToolChoice::None,
3084 parallel_tool_calls: None,
3085 });
3086 assert!(body.get("tools").is_none(), "tools should be dropped");
3087 assert!(body.get("tool_choice").is_none());
3088 }
3089
3090 #[tokio::test]
3091 async fn openai_compatible_rejects_hosted_web_search() {
3092 let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
3093 base_url: "https://example.test".into(),
3094 api_key: "sk-test".into(),
3095 model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
3096 });
3097 let err = match client
3098 .stream(ModelTurnInput {
3099 system_prompt: None,
3100 messages: vec![ChatMessage::User {
3101 content: "search".into(),
3102 attachments: vec![],
3103 }],
3104 tools: vec![],
3105 hosted_tools: vec![HostedTool::WebSearch],
3106 tool_choice: ToolChoice::Auto,
3107 parallel_tool_calls: None,
3108 })
3109 .await
3110 {
3111 Ok(_) => panic!("expected hosted tool rejection"),
3112 Err(err) => err,
3113 };
3114 assert!(err.to_string().contains("Responses API"));
3115 }
3116
3117 #[test]
3118 fn tool_choice_parse_handles_canonical_strings() {
3119 assert!(matches!(ToolChoice::parse(""), ToolChoice::Auto));
3120 assert!(matches!(ToolChoice::parse("auto"), ToolChoice::Auto));
3121 assert!(matches!(ToolChoice::parse("AUTO"), ToolChoice::Auto));
3122 assert!(matches!(ToolChoice::parse("none"), ToolChoice::None));
3123 assert!(matches!(
3124 ToolChoice::parse("required"),
3125 ToolChoice::Required
3126 ));
3127 assert!(matches!(ToolChoice::parse("any"), ToolChoice::Required));
3128 match ToolChoice::parse("tool:bash") {
3129 ToolChoice::Tool(name) => assert_eq!(name, "bash"),
3130 other => panic!("expected Tool(bash), got {other:?}"),
3131 }
3132 assert!(matches!(ToolChoice::parse("garbage"), ToolChoice::Auto));
3134 }
3135
3136 #[test]
3137 fn openai_client_prepends_system_when_set() {
3138 let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
3139 base_url: "https://example.test".into(),
3140 api_key: "sk-test".into(),
3141 model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
3142 });
3143 let input = ModelTurnInput {
3144 system_prompt: Some("you are concise".into()),
3145 messages: vec![ChatMessage::User {
3146 content: "hi".into(),
3147 attachments: vec![],
3148 }],
3149 tools: vec![],
3150 hosted_tools: vec![],
3151 tool_choice: ToolChoice::Auto,
3152 parallel_tool_calls: None,
3153 };
3154 let body = client.request_body(&input);
3155 assert_eq!(body["messages"][0]["role"], "system");
3156 assert_eq!(body["messages"][0]["content"], "you are concise");
3157 assert_eq!(body["messages"][1]["role"], "user");
3158 }
3159
3160 #[test]
3161 fn parse_openai_usage_extracts_token_counts() {
3162 let u = parse_openai_usage(Some(&json!({
3163 "prompt_tokens": 12,
3164 "completion_tokens": 7,
3165 "total_tokens": 19,
3166 "prompt_tokens_details": {"cached_tokens": 4}
3167 })))
3168 .expect("usage parsed");
3169 assert_eq!(u.input_tokens, 12);
3170 assert_eq!(u.output_tokens, 7);
3171 assert_eq!(u.cache_read_input_tokens, 4);
3172 assert_eq!(u.cache_creation_input_tokens, 0);
3173 }
3174
3175 #[test]
3176 fn parse_openai_usage_without_cache_details() {
3177 let u = parse_openai_usage(Some(&json!({
3178 "prompt_tokens": 200,
3179 "completion_tokens": 30
3180 })))
3181 .expect("usage parsed");
3182 assert_eq!(u.input_tokens, 200);
3183 assert_eq!(u.output_tokens, 30);
3184 assert_eq!(u.cache_read_input_tokens, 0);
3185 }
3186
3187 #[test]
3188 fn openai_stream_state_emits_text_deltas_then_done() {
3189 let mut state = OpenAiStreamState::default();
3190 let out = state
3192 .feed_data(
3193 r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"role":"assistant","content":""}}]}"#,
3194 )
3195 .unwrap();
3196 assert!(out.is_empty(), "empty content shouldn't emit");
3197 let out = state
3199 .feed_data(r#"{"choices":[{"index":0,"delta":{"content":"Hello"}}]}"#)
3200 .unwrap();
3201 assert_eq!(out.len(), 1);
3202 match &out[0] {
3203 ModelChunk::TextDelta { msg_id, delta } => {
3204 assert_eq!(msg_id, "chatcmpl-1");
3205 assert_eq!(delta, "Hello");
3206 }
3207 other => panic!("expected TextDelta, got {other:?}"),
3208 }
3209 let out = state
3210 .feed_data(r#"{"choices":[{"index":0,"delta":{"content":" world"}}]}"#)
3211 .unwrap();
3212 assert_eq!(out.len(), 1);
3213 let out = state
3216 .feed_data(r#"{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#)
3217 .unwrap();
3218 assert!(out.is_empty());
3219 let out = state
3221 .feed_data(r#"{"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":3}}"#)
3222 .unwrap();
3223 assert!(out.is_empty());
3224 let out = state.feed_data("[DONE]").unwrap();
3226 assert_eq!(out.len(), 1);
3227 match &out[0] {
3228 ModelChunk::Done { stop_reason, usage } => {
3229 assert_eq!(stop_reason, "end_turn");
3230 let u = usage.as_ref().expect("usage propagated");
3231 assert_eq!(u.input_tokens, 10);
3232 assert_eq!(u.output_tokens, 3);
3233 }
3234 other => panic!("expected Done, got {other:?}"),
3235 }
3236 }
3237
3238 #[test]
3239 fn openai_stream_state_emits_tool_call_chunks() {
3240 let mut state = OpenAiStreamState::default();
3241 let out = state
3243 .feed_data(
3244 r#"{"id":"c1","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_x","type":"function","function":{"name":"bash","arguments":""}}]}}]}"#,
3245 )
3246 .unwrap();
3247 assert_eq!(out.len(), 1);
3248 match &out[0] {
3249 ModelChunk::ToolCallStart { id, name } => {
3250 assert_eq!(id, "call_x");
3251 assert_eq!(name, "bash");
3252 }
3253 other => panic!("expected ToolCallStart, got {other:?}"),
3254 }
3255 let out = state
3257 .feed_data(
3258 r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]}}]}"#,
3259 )
3260 .unwrap();
3261 assert_eq!(out.len(), 1);
3262 let ModelChunk::ToolCallInputDelta { delta, .. } = &out[0] else {
3263 panic!("expected ToolCallInputDelta");
3264 };
3265 assert_eq!(delta, "{\"");
3266
3267 let out = state
3268 .feed_data(
3269 r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"cmd\":\"pwd\"}"}}]}}]}"#,
3270 )
3271 .unwrap();
3272 let ModelChunk::ToolCallInputDelta { delta, .. } = &out[0] else {
3273 panic!("expected ToolCallInputDelta");
3274 };
3275 assert_eq!(delta, "cmd\":\"pwd\"}");
3276
3277 let out = state
3280 .feed_data(r#"{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#)
3281 .unwrap();
3282 assert_eq!(out.len(), 1);
3283 match &out[0] {
3284 ModelChunk::ToolCallEnd { id, input } => {
3285 assert_eq!(id, "call_x");
3286 assert!(input.is_none(), "OpenAI streaming defers parsing");
3287 }
3288 other => panic!("expected ToolCallEnd, got {other:?}"),
3289 }
3290
3291 let final_chunk = state.finalize().expect("finalize emits Done");
3293 match final_chunk {
3294 ModelChunk::Done { stop_reason, .. } => assert_eq!(stop_reason, "end_turn"),
3295 other => panic!("expected Done from finalize, got {other:?}"),
3296 }
3297 }
3298
3299 #[tokio::test]
3300 async fn collect_model_response_preserves_raw_tool_arguments() {
3301 let chunks = vec![
3302 Ok(ModelChunk::ToolCallStart {
3303 id: "call_x".into(),
3304 name: "bash".into(),
3305 }),
3306 Ok(ModelChunk::ToolCallInputDelta {
3307 id: "call_x".into(),
3308 delta: r#"{ "b": 2, "#.into(),
3309 }),
3310 Ok(ModelChunk::ToolCallInputDelta {
3311 id: "call_x".into(),
3312 delta: r#""a": 1 }"#.into(),
3313 }),
3314 Ok(ModelChunk::ToolCallEnd {
3315 id: "call_x".into(),
3316 input: None,
3317 }),
3318 Ok(ModelChunk::Done {
3319 stop_reason: "end_turn".into(),
3320 usage: None,
3321 }),
3322 ];
3323
3324 let response = collect_model_response(futures::stream::iter(chunks).boxed())
3325 .await
3326 .unwrap();
3327 let ModelResponse::ToolCall { invocation, .. } = response else {
3328 panic!("expected tool call response");
3329 };
3330
3331 assert_eq!(invocation.input, json!({"b": 2, "a": 1}));
3332 assert_eq!(
3333 invocation.raw_emitted_args.as_deref(),
3334 Some(r#"{ "b": 2, "a": 1 }"#)
3335 );
3336 }
3337
3338 #[test]
3339 fn openai_projection_uses_raw_tool_arguments_when_matching_input() {
3340 let msg = ChatMessage::Assistant {
3341 text: None,
3342 tool_calls: vec![ToolInvocation {
3343 id: "call_1".into(),
3344 name: "bash".into(),
3345 input: json!({"b": 2, "a": 1}),
3346 raw_emitted_args: Some(r#"{ "b": 2, "a": 1 }"#.into()),
3347 }],
3348 thinking: None,
3349 usage: None,
3350 };
3351
3352 let wire = chat_message_to_wire(&msg);
3353 assert_eq!(
3354 wire["tool_calls"][0]["function"]["arguments"],
3355 r#"{ "b": 2, "a": 1 }"#
3356 );
3357 }
3358
3359 #[test]
3360 fn openai_projection_ignores_stale_raw_tool_arguments() {
3361 let msg = ChatMessage::Assistant {
3362 text: None,
3363 tool_calls: vec![ToolInvocation {
3364 id: "call_1".into(),
3365 name: "bash".into(),
3366 input: json!({"command": "pwd"}),
3367 raw_emitted_args: Some(r#"{"command": "rm -rf /"}"#.into()),
3368 }],
3369 thinking: None,
3370 usage: None,
3371 };
3372
3373 let wire = chat_message_to_wire(&msg);
3374 assert_eq!(
3375 wire["tool_calls"][0]["function"]["arguments"],
3376 json!({"command": "pwd"}).to_string()
3377 );
3378 }
3379
3380 #[test]
3381 fn map_openai_finish_reason_table() {
3382 assert_eq!(map_openai_finish_reason(Some("stop")), "end_turn");
3383 assert_eq!(map_openai_finish_reason(Some("length")), "max_tokens");
3384 assert_eq!(map_openai_finish_reason(Some("tool_calls")), "end_turn");
3385 assert_eq!(map_openai_finish_reason(Some("content_filter")), "refusal");
3386 assert_eq!(map_openai_finish_reason(None), "end_turn");
3387 assert_eq!(map_openai_finish_reason(Some("")), "end_turn");
3388 }
3389
3390 #[test]
3393 fn chat_message_to_openai_wire_text_only_keeps_string_content() {
3394 let msg = ChatMessage::User {
3397 content: "hello".into(),
3398 attachments: vec![],
3399 };
3400 let v = chat_message_to_wire(&msg);
3401 assert_eq!(v["role"], "user");
3402 assert_eq!(v["content"], "hello");
3403 assert!(v["content"].is_string());
3406 }
3407
3408 #[test]
3409 fn chat_message_to_openai_wire_with_base64_image() {
3410 let msg = ChatMessage::User {
3411 content: "describe this".into(),
3412 attachments: vec![UserAttachment::Image(ImageSource {
3413 media_type: "image/png".into(),
3414 data: ImageData::Base64("iVBORw0KG...".into()),
3415 })],
3416 };
3417 let v = chat_message_to_wire(&msg);
3418 let parts = v["content"].as_array().expect("content array");
3419 assert_eq!(parts.len(), 2);
3420 assert_eq!(parts[0]["type"], "text");
3421 assert_eq!(parts[0]["text"], "describe this");
3422 assert_eq!(parts[1]["type"], "image_url");
3423 let url = parts[1]["image_url"]["url"].as_str().unwrap();
3425 assert!(url.starts_with("data:image/png;base64,"));
3426 assert!(url.contains("iVBORw0KG..."));
3427 }
3428
3429 #[test]
3430 fn chat_message_to_openai_wire_with_url_image() {
3431 let msg = ChatMessage::User {
3432 content: "".into(), attachments: vec![UserAttachment::Image(ImageSource {
3434 media_type: "image/jpeg".into(),
3435 data: ImageData::Url("https://cdn.example.com/cat.jpg".into()),
3436 })],
3437 };
3438 let v = chat_message_to_wire(&msg);
3439 let parts = v["content"].as_array().unwrap();
3440 assert_eq!(parts.len(), 1);
3442 assert_eq!(parts[0]["type"], "image_url");
3443 assert_eq!(
3444 parts[0]["image_url"]["url"],
3445 "https://cdn.example.com/cat.jpg"
3446 );
3447 }
3448
3449 #[test]
3450 fn chat_message_to_openai_tool_role_degrades_image_to_placeholder() {
3451 let msg = ChatMessage::Tool {
3456 tool_call_id: "call_x".into(),
3457 content: "ok".into(),
3458 is_error: false,
3459 attachments: vec![UserAttachment::Image(ImageSource {
3460 media_type: "image/png".into(),
3461 data: ImageData::Base64("AAA".into()),
3462 })],
3463 };
3464 let v = chat_message_to_wire(&msg);
3465 assert_eq!(v["role"], "tool");
3466 assert_eq!(v["tool_call_id"], "call_x");
3467 let content = v["content"].as_str().unwrap();
3468 assert!(content.starts_with("ok\n"));
3469 assert!(content.contains("image attached: image/png"));
3470 assert!(!content.contains("AAA"));
3473 }
3474
3475 #[test]
3478 fn replay_compaction_leaves_small_results_untouched() {
3479 let small = "x".repeat(1_000);
3480 assert!(matches!(
3481 compact_tool_result_for_replay(&small),
3482 std::borrow::Cow::Borrowed(_)
3483 ));
3484 let medium = "word ".repeat(2_000);
3487 let out = compact_tool_result_for_replay(&medium);
3488 assert!(out.contains("compacted for model replay"));
3489 }
3490
3491 #[test]
3492 fn replay_compaction_keeps_head_and_tail_deterministically() {
3493 let body = format!("HEAD_MARK{}TAIL_MARK", "x".repeat(20_000));
3494 let first = compact_tool_result_for_replay(&body).into_owned();
3495 let second = compact_tool_result_for_replay(&body).into_owned();
3496 assert_eq!(first, second);
3498 assert!(first.starts_with("[tool result compacted for model replay]"));
3499 assert!(first.contains("HEAD_MARK"), "head survives");
3500 assert!(first.contains("TAIL_MARK"), "tail survives");
3501 assert!(first.contains("omitted"), "omission marker present");
3502 assert!(first.len() < body.len() / 2);
3504 }
3505
3506 #[test]
3507 fn openai_projection_compacts_oversized_tool_result() {
3508 let big = format!("START{}END", "y".repeat(20_000));
3509 let msg = ChatMessage::Tool {
3510 tool_call_id: "call_big".into(),
3511 content: big.clone(),
3512 is_error: false,
3513 attachments: vec![],
3514 };
3515 let v = chat_message_to_wire(&msg);
3516 let content = v["content"].as_str().unwrap();
3517 assert!(content.contains("compacted for model replay"));
3518 assert!(content.contains("START") && content.contains("END"));
3519 match &msg {
3521 ChatMessage::Tool { content, .. } => assert_eq!(content.len(), big.len()),
3522 _ => unreachable!(),
3523 }
3524 }
3525
3526 #[test]
3527 fn anthropic_projection_compacts_oversized_tool_result() {
3528 let big = "z".repeat(20_000);
3529 let msgs = vec![
3530 ChatMessage::Assistant {
3531 text: None,
3532 tool_calls: vec![crate::tools::ToolInvocation {
3533 id: "tc_big".into(),
3534 name: "bash".into(),
3535 input: json!({}),
3536 raw_emitted_args: None,
3537 }],
3538 thinking: None,
3539 usage: None,
3540 },
3541 ChatMessage::Tool {
3542 tool_call_id: "tc_big".into(),
3543 content: big,
3544 is_error: false,
3545 attachments: vec![],
3546 },
3547 ];
3548 let wire = chat_messages_to_anthropic_messages(&msgs);
3549 let rendered = serde_json::to_string(&wire).unwrap();
3550 assert!(rendered.contains("compacted for model replay"));
3551 }
3552
3553 #[test]
3554 fn chat_messages_to_anthropic_tool_result_carries_image_block() {
3555 let msgs = vec![
3560 ChatMessage::Assistant {
3561 text: None,
3562 tool_calls: vec![ToolInvocation {
3563 id: "tc_img".into(),
3564 name: "screenshot".into(),
3565 input: json!({}),
3566 raw_emitted_args: None,
3567 }],
3568 thinking: None,
3569 usage: None,
3570 },
3571 ChatMessage::Tool {
3572 tool_call_id: "tc_img".into(),
3573 content: "see image".into(),
3574 is_error: false,
3575 attachments: vec![UserAttachment::Image(ImageSource {
3576 media_type: "image/png".into(),
3577 data: ImageData::Base64("PNGBYTES".into()),
3578 })],
3579 },
3580 ];
3581 let out = chat_messages_to_anthropic_messages(&msgs);
3582 assert_eq!(out.len(), 2);
3584 let user = &out[1];
3585 assert_eq!(user["role"], "user");
3586 let outer = user["content"].as_array().unwrap();
3587 assert_eq!(outer.len(), 1);
3588 assert_eq!(outer[0]["type"], "tool_result");
3589 assert_eq!(outer[0]["tool_use_id"], "tc_img");
3590 let inner = outer[0]["content"].as_array().unwrap();
3591 assert_eq!(inner.len(), 2);
3594 assert_eq!(inner[0]["type"], "text");
3595 assert_eq!(inner[0]["text"], "see image");
3596 assert_eq!(inner[1]["type"], "image");
3597 assert_eq!(inner[1]["source"]["type"], "base64");
3598 assert_eq!(inner[1]["source"]["media_type"], "image/png");
3599 assert_eq!(inner[1]["source"]["data"], "PNGBYTES");
3600 }
3601
3602 #[test]
3603 fn chat_messages_to_anthropic_renders_user_text_with_image_block() {
3604 let msgs = vec![ChatMessage::User {
3605 content: "what is this".into(),
3606 attachments: vec![UserAttachment::Image(ImageSource {
3607 media_type: "image/png".into(),
3608 data: ImageData::Base64("AAAA".into()),
3609 })],
3610 }];
3611 let out = chat_messages_to_anthropic_messages(&msgs);
3612 assert_eq!(out.len(), 1);
3613 let blocks = out[0]["content"].as_array().unwrap();
3614 assert_eq!(blocks[0]["type"], "text");
3616 assert_eq!(blocks[0]["text"], "what is this");
3617 assert_eq!(blocks[1]["type"], "image");
3618 assert_eq!(blocks[1]["source"]["type"], "base64");
3620 assert_eq!(blocks[1]["source"]["media_type"], "image/png");
3621 assert_eq!(blocks[1]["source"]["data"], "AAAA");
3622 }
3623
3624 #[test]
3625 fn chat_messages_to_anthropic_renders_url_image() {
3626 let msgs = vec![ChatMessage::User {
3627 content: "".into(),
3628 attachments: vec![UserAttachment::Image(ImageSource {
3629 media_type: "image/jpeg".into(),
3630 data: ImageData::Url("https://example.com/x.jpg".into()),
3631 })],
3632 }];
3633 let out = chat_messages_to_anthropic_messages(&msgs);
3634 let blocks = out[0]["content"].as_array().unwrap();
3635 assert_eq!(blocks.len(), 1);
3637 assert_eq!(blocks[0]["type"], "image");
3638 assert_eq!(blocks[0]["source"]["type"], "url");
3639 assert_eq!(blocks[0]["source"]["url"], "https://example.com/x.jpg");
3640 }
3641
3642 #[test]
3643 fn chat_message_to_anthropic_merges_tool_results_and_image() {
3644 let msgs = vec![
3647 ChatMessage::Assistant {
3648 text: None,
3649 tool_calls: vec![ToolInvocation {
3650 id: "tc_1".into(),
3651 name: "screenshot".into(),
3652 input: json!({}),
3653 raw_emitted_args: None,
3654 }],
3655 thinking: None,
3656 usage: None,
3657 },
3658 ChatMessage::Tool {
3659 tool_call_id: "tc_1".into(),
3660 content: "captured".into(),
3661 is_error: false,
3662 attachments: vec![],
3663 },
3664 ChatMessage::User {
3665 content: "what changed?".into(),
3666 attachments: vec![UserAttachment::Image(ImageSource {
3667 media_type: "image/png".into(),
3668 data: ImageData::Base64("ZZ".into()),
3669 })],
3670 },
3671 ];
3672 let out = chat_messages_to_anthropic_messages(&msgs);
3673 assert_eq!(out.len(), 2);
3675 let blocks = out[1]["content"].as_array().unwrap();
3676 assert_eq!(blocks.len(), 3);
3677 assert_eq!(blocks[0]["type"], "tool_result");
3678 assert_eq!(blocks[0]["tool_use_id"], "tc_1");
3679 assert_eq!(blocks[1]["type"], "text");
3680 assert_eq!(blocks[1]["text"], "what changed?");
3681 assert_eq!(blocks[2]["type"], "image");
3682 }
3683
3684 #[test]
3685 fn chat_messages_to_anthropic_renders_simple_user_assistant() {
3686 let msgs = vec![
3687 ChatMessage::User {
3688 content: "hi".into(),
3689 attachments: vec![],
3690 },
3691 ChatMessage::Assistant {
3692 text: Some("hello".into()),
3693 tool_calls: vec![],
3694 thinking: None,
3695 usage: None,
3696 },
3697 ];
3698 let out = chat_messages_to_anthropic_messages(&msgs);
3699 assert_eq!(out.len(), 2);
3700 assert_eq!(out[0]["role"], "user");
3701 assert_eq!(out[0]["content"][0]["type"], "text");
3702 assert_eq!(out[0]["content"][0]["text"], "hi");
3703 assert_eq!(out[1]["role"], "assistant");
3704 assert_eq!(out[1]["content"][0]["text"], "hello");
3705 }
3706
3707 #[test]
3708 fn chat_messages_to_anthropic_folds_tool_results_into_next_user() {
3709 let msgs = vec![
3713 ChatMessage::User {
3714 content: "do it".into(),
3715 attachments: vec![],
3716 },
3717 ChatMessage::Assistant {
3718 text: None,
3719 tool_calls: vec![ToolInvocation {
3720 id: "call_1".into(),
3721 name: "bash".into(),
3722 input: json!({"command": "pwd"}),
3723 raw_emitted_args: None,
3724 }],
3725 thinking: None,
3726 usage: None,
3727 },
3728 ChatMessage::Tool {
3729 tool_call_id: "call_1".into(),
3730 content: "{\"stdout\":\"/\"}".into(),
3731 is_error: false,
3732 attachments: vec![],
3733 },
3734 ChatMessage::User {
3735 content: "explain".into(),
3736 attachments: vec![],
3737 },
3738 ];
3739 let out = chat_messages_to_anthropic_messages(&msgs);
3740 assert_eq!(out.len(), 3);
3742 assert_eq!(out[1]["role"], "assistant");
3743 assert_eq!(out[1]["content"][0]["type"], "tool_use");
3744 assert_eq!(out[1]["content"][0]["id"], "call_1");
3745 assert_eq!(out[2]["role"], "user");
3746 assert_eq!(out[2]["content"][0]["type"], "tool_result");
3747 assert_eq!(out[2]["content"][0]["tool_use_id"], "call_1");
3748 assert_eq!(out[2]["content"][1]["type"], "text");
3749 assert_eq!(out[2]["content"][1]["text"], "explain");
3750 }
3751
3752 #[test]
3753 fn chat_messages_to_anthropic_renders_thinking_then_text_then_tool_use() {
3754 let msgs = vec![ChatMessage::Assistant {
3755 text: Some("preface".into()),
3756 tool_calls: vec![ToolInvocation {
3757 id: "t".into(),
3758 name: "n".into(),
3759 input: json!({"a": 1}),
3760 raw_emitted_args: None,
3761 }],
3762 thinking: Some(AssistantThinking {
3763 text: "deep thought".into(),
3764 signature: Some("sig123".into()),
3765 }),
3766 usage: None,
3767 }];
3768 let out = chat_messages_to_anthropic_messages(&msgs);
3769 let blocks = out[0]["content"].as_array().unwrap();
3770 assert_eq!(blocks[0]["type"], "thinking");
3772 assert_eq!(blocks[0]["thinking"], "deep thought");
3773 assert_eq!(blocks[0]["signature"], "sig123");
3774 assert_eq!(blocks[1]["type"], "text");
3775 assert_eq!(blocks[1]["text"], "preface");
3776 assert_eq!(blocks[2]["type"], "tool_use");
3777 }
3778
3779 #[test]
3780 fn chat_messages_to_anthropic_trailing_tool_results_flushed() {
3781 let msgs = vec![
3784 ChatMessage::Assistant {
3785 text: None,
3786 tool_calls: vec![ToolInvocation {
3787 id: "t".into(),
3788 name: "n".into(),
3789 input: json!({}),
3790 raw_emitted_args: None,
3791 }],
3792 thinking: None,
3793 usage: None,
3794 },
3795 ChatMessage::Tool {
3796 tool_call_id: "t".into(),
3797 content: "ok".into(),
3798 is_error: false,
3799 attachments: vec![],
3800 },
3801 ];
3802 let out = chat_messages_to_anthropic_messages(&msgs);
3803 assert_eq!(out.len(), 2);
3804 assert_eq!(out[1]["role"], "user");
3805 assert_eq!(out[1]["content"][0]["type"], "tool_result");
3806 }
3807
3808 #[test]
3809 fn apply_anthropic_cache_strategy_marks_system_last_tool_and_last_message() {
3810 let system = anthropic_system_field(Some("system prompt"));
3811 let tools = vec![
3812 json!({"name": "a", "description": "", "input_schema": {"type": "object"}}),
3813 json!({"name": "b", "description": "", "input_schema": {"type": "object"}}),
3814 ];
3815 let messages = vec![
3816 json!({"role": "user", "content": [{"type": "text", "text": "hi"}]}),
3817 json!({"role": "assistant", "content": [{"type": "text", "text": "hello"}]}),
3818 ];
3819 let out = apply_anthropic_cache_strategy(system, tools, messages);
3820 let sys_block = &out.system.as_ref().unwrap()[0];
3821 assert_eq!(sys_block["cache_control"]["type"], "ephemeral");
3822 assert!(out.tools[0].get("cache_control").is_none());
3824 assert_eq!(out.tools[1]["cache_control"]["type"], "ephemeral");
3825 let last_msg_blocks = out.messages.last().unwrap()["content"].as_array().unwrap();
3827 assert_eq!(
3828 last_msg_blocks.last().unwrap()["cache_control"]["type"],
3829 "ephemeral"
3830 );
3831 }
3832
3833 #[test]
3834 fn apply_anthropic_cache_strategy_skips_empty_system() {
3835 let out = apply_anthropic_cache_strategy(None, vec![], vec![]);
3838 assert!(out.system.is_none());
3839 }
3840
3841 fn anthropic_client_for_tool_choice_tests() -> AnthropicModelClient {
3842 AnthropicModelClient::new(AnthropicConfig {
3843 base_url: "https://example.test".into(),
3844 api_key: "sk-test".into(),
3845 model: resolved_model(
3846 "claude-test",
3847 WireProtocol::Anthropic,
3848 1_024,
3849 None,
3850 ReasoningConfig::default(),
3851 ),
3852 anthropic_version: AnthropicConfig::DEFAULT_VERSION.into(),
3853 })
3854 }
3855
3856 #[test]
3857 fn anthropic_client_omits_tool_choice_when_auto() {
3858 let client = anthropic_client_for_tool_choice_tests();
3861 let body = client.request_body(&ModelTurnInput {
3862 system_prompt: None,
3863 messages: vec![ChatMessage::User {
3864 content: "go".into(),
3865 attachments: vec![],
3866 }],
3867 tools: vec![bash_spec()],
3868 hosted_tools: vec![],
3869 tool_choice: ToolChoice::Auto,
3870 parallel_tool_calls: None,
3871 });
3872 assert!(body["tools"].as_array().unwrap().len() > 0);
3873 assert!(body.get("tool_choice").is_none());
3874 assert!(body.get("parallel_tool_calls").is_none());
3877 }
3878
3879 #[test]
3880 fn anthropic_client_emits_tool_choice_required_as_any() {
3881 let client = anthropic_client_for_tool_choice_tests();
3882 let body = client.request_body(&ModelTurnInput {
3883 system_prompt: None,
3884 messages: vec![ChatMessage::User {
3885 content: "go".into(),
3886 attachments: vec![],
3887 }],
3888 tools: vec![bash_spec()],
3889 hosted_tools: vec![],
3890 tool_choice: ToolChoice::Required,
3891 parallel_tool_calls: Some(true),
3892 });
3893 assert_eq!(body["tool_choice"]["type"], "any");
3894 assert!(body.get("parallel_tool_calls").is_none());
3897 }
3898
3899 #[test]
3900 fn anthropic_client_projects_hosted_web_search_tool() {
3901 let client = anthropic_client_for_tool_choice_tests();
3902 let body = client.request_body(&ModelTurnInput {
3903 system_prompt: None,
3904 messages: vec![ChatMessage::User {
3905 content: "research current AI market".into(),
3906 attachments: vec![],
3907 }],
3908 tools: vec![bash_spec()],
3909 hosted_tools: vec![HostedTool::WebSearch],
3910 tool_choice: ToolChoice::Auto,
3911 parallel_tool_calls: None,
3912 });
3913 let tools = body["tools"].as_array().unwrap();
3914 let web = tools
3915 .iter()
3916 .find(|tool| tool.get("name").and_then(Value::as_str) == Some("web_search"))
3917 .unwrap();
3918 assert_eq!(web["type"], "web_search_20250305");
3919 assert!(web.get("max_uses").is_none());
3920 }
3921
3922 #[test]
3923 fn clients_report_web_search_support_by_protocol_and_endpoint() {
3924 let chat = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
3925 base_url: "https://api.openai.com/v1".into(),
3926 api_key: "sk-test".into(),
3927 model: default_model("gpt-test", WireProtocol::OpenAiCompatible),
3928 });
3929 assert_eq!(
3930 chat.hosted_capability(HostedCapability::WebSearch),
3931 CapabilitySupport::Unsupported
3932 );
3933
3934 let responses = OpenAiResponsesModelClient::new(OpenAiResponsesConfig {
3935 base_url: "https://api.openai.com/v1".into(),
3936 api_key: "sk-test".into(),
3937 model: default_model("gpt-test", WireProtocol::OpenAiResponses),
3938 reasoning_summary: None,
3939 });
3940 assert_eq!(
3941 responses.hosted_capability(HostedCapability::WebSearch),
3942 CapabilitySupport::Supported
3943 );
3944
3945 let gateway = OpenAiResponsesModelClient::new(OpenAiResponsesConfig {
3946 base_url: "https://gateway.example/v1".into(),
3947 api_key: "sk-test".into(),
3948 model: default_model("gpt-test", WireProtocol::OpenAiResponses),
3949 reasoning_summary: None,
3950 });
3951 assert_eq!(
3952 gateway.hosted_capability(HostedCapability::WebSearch),
3953 CapabilitySupport::Unknown
3954 );
3955 }
3956
3957 #[test]
3958 fn anthropic_client_emits_tool_choice_named_tool() {
3959 let client = anthropic_client_for_tool_choice_tests();
3960 let body = client.request_body(&ModelTurnInput {
3961 system_prompt: None,
3962 messages: vec![ChatMessage::User {
3963 content: "go".into(),
3964 attachments: vec![],
3965 }],
3966 tools: vec![bash_spec()],
3967 hosted_tools: vec![],
3968 tool_choice: ToolChoice::Tool("bash".into()),
3969 parallel_tool_calls: None,
3970 });
3971 assert_eq!(body["tool_choice"]["type"], "tool");
3972 assert_eq!(body["tool_choice"]["name"], "bash");
3973 }
3974
3975 #[test]
3976 fn anthropic_client_drops_tools_when_choice_is_none() {
3977 let client = anthropic_client_for_tool_choice_tests();
3981 let body = client.request_body(&ModelTurnInput {
3982 system_prompt: None,
3983 messages: vec![ChatMessage::User {
3984 content: "go".into(),
3985 attachments: vec![],
3986 }],
3987 tools: vec![bash_spec()],
3988 hosted_tools: vec![],
3989 tool_choice: ToolChoice::None,
3990 parallel_tool_calls: None,
3991 });
3992 assert!(body.get("tools").is_none());
3993 assert!(body.get("tool_choice").is_none());
3994 }
3995
3996 #[test]
3997 fn anthropic_stream_state_text_only() {
3998 let mut s = AnthropicStreamState::default();
3999 let _ = s
4001 .feed_event(
4002 "message_start",
4003 r#"{"type":"message_start","message":{"id":"msg_01","usage":{"input_tokens":10,"output_tokens":0}}}"#,
4004 )
4005 .unwrap();
4006 let _ = s
4007 .feed_event(
4008 "content_block_start",
4009 r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#,
4010 )
4011 .unwrap();
4012 let out = s
4013 .feed_event(
4014 "content_block_delta",
4015 r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#,
4016 )
4017 .unwrap();
4018 assert_eq!(out.len(), 1);
4019 match &out[0] {
4020 ModelChunk::TextDelta { msg_id, delta } => {
4021 assert_eq!(msg_id, "msg_01");
4022 assert_eq!(delta, "Hello");
4023 }
4024 other => panic!("expected TextDelta, got {other:?}"),
4025 }
4026 let _ = s.feed_event(
4027 "message_delta",
4028 r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}"#,
4029 );
4030 let out = s
4031 .feed_event("message_stop", r#"{"type":"message_stop"}"#)
4032 .unwrap();
4033 assert_eq!(out.len(), 1);
4034 match &out[0] {
4035 ModelChunk::Done { stop_reason, usage } => {
4036 assert_eq!(stop_reason, "end_turn");
4037 let u = usage.as_ref().unwrap();
4038 assert_eq!(u.input_tokens, 10);
4039 assert_eq!(u.output_tokens, 5);
4040 }
4041 other => panic!("expected Done, got {other:?}"),
4042 }
4043 }
4044
4045 #[test]
4046 fn anthropic_stream_state_thinking_block_emits_delta_and_signature() {
4047 let mut s = AnthropicStreamState::default();
4048 let _ = s.feed_event(
4049 "message_start",
4050 r#"{"type":"message_start","message":{"id":"msg_t"}}"#,
4051 );
4052 let _ = s.feed_event(
4053 "content_block_start",
4054 r#"{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#,
4055 );
4056 let out = s
4057 .feed_event(
4058 "content_block_delta",
4059 r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"reasoning..."}}"#,
4060 )
4061 .unwrap();
4062 assert_eq!(out.len(), 1);
4063 let ModelChunk::ThinkingDelta {
4064 delta, signature, ..
4065 } = &out[0]
4066 else {
4067 panic!("expected ThinkingDelta");
4068 };
4069 assert_eq!(delta, "reasoning...");
4070 assert!(signature.is_none());
4071
4072 let out = s
4073 .feed_event(
4074 "content_block_delta",
4075 r#"{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig_abc"}}"#,
4076 )
4077 .unwrap();
4078 let ModelChunk::ThinkingDelta {
4079 delta, signature, ..
4080 } = &out[0]
4081 else {
4082 panic!("expected ThinkingDelta");
4083 };
4084 assert_eq!(delta, "");
4085 assert_eq!(signature.as_deref(), Some("sig_abc"));
4086 }
4087
4088 #[test]
4089 fn anthropic_stream_state_tool_use_streamed_input() {
4090 let mut s = AnthropicStreamState::default();
4091 let _ = s.feed_event(
4092 "message_start",
4093 r#"{"type":"message_start","message":{"id":"msg_x"}}"#,
4094 );
4095 let out = s
4096 .feed_event(
4097 "content_block_start",
4098 r#"{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"bash","input":{}}}"#,
4099 )
4100 .unwrap();
4101 assert_eq!(out.len(), 1);
4102 match &out[0] {
4103 ModelChunk::ToolCallStart { id, name } => {
4104 assert_eq!(id, "toolu_1");
4105 assert_eq!(name, "bash");
4106 }
4107 other => panic!("expected ToolCallStart, got {other:?}"),
4108 }
4109 let out = s
4110 .feed_event(
4111 "content_block_delta",
4112 r#"{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"cmd\":"}}"#,
4113 )
4114 .unwrap();
4115 let ModelChunk::ToolCallInputDelta { id, delta } = &out[0] else {
4116 panic!("expected ToolCallInputDelta");
4117 };
4118 assert_eq!(id, "toolu_1");
4119 assert_eq!(delta, "{\"cmd\":");
4120
4121 let out = s
4122 .feed_event(
4123 "content_block_stop",
4124 r#"{"type":"content_block_stop","index":0}"#,
4125 )
4126 .unwrap();
4127 match &out[0] {
4128 ModelChunk::ToolCallEnd { id, input } => {
4129 assert_eq!(id, "toolu_1");
4130 assert!(input.is_none());
4131 }
4132 other => panic!("expected ToolCallEnd, got {other:?}"),
4133 }
4134 }
4135
4136 #[test]
4137 fn anthropic_stream_state_finalises_on_close_without_message_stop() {
4138 let mut s = AnthropicStreamState::default();
4141 let _ = s
4142 .feed_event(
4143 "message_delta",
4144 r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":100}}"#,
4145 )
4146 .unwrap();
4147 let done = s.finalize().unwrap();
4148 match done {
4149 ModelChunk::Done { stop_reason, .. } => assert_eq!(stop_reason, "max_tokens"),
4150 other => panic!("expected Done, got {other:?}"),
4151 }
4152 }
4153
4154 #[test]
4155 fn anthropic_stop_reason_mapping() {
4156 assert_eq!(map_anthropic_stop_reason(Some("end_turn")), "end_turn");
4157 assert_eq!(map_anthropic_stop_reason(Some("tool_use")), "end_turn");
4158 assert_eq!(map_anthropic_stop_reason(Some("max_tokens")), "max_tokens");
4159 assert_eq!(map_anthropic_stop_reason(Some("stop_sequence")), "end_turn");
4160 assert_eq!(map_anthropic_stop_reason(Some("refusal")), "refusal");
4161 assert_eq!(map_anthropic_stop_reason(None), "end_turn");
4162 }
4163
4164 #[test]
4165 fn classify_anthropic_http_error_buckets_by_status() {
4166 use reqwest::StatusCode;
4167 assert!(matches!(
4168 classify_anthropic_http_error(StatusCode::TOO_MANY_REQUESTS, "{}"),
4169 ModelClientError::RateLimit(_)
4170 ));
4171 assert!(matches!(
4172 classify_anthropic_http_error(StatusCode::UNAUTHORIZED, "{}"),
4173 ModelClientError::Auth(_)
4174 ));
4175 assert!(matches!(
4176 classify_anthropic_http_error(
4177 StatusCode::BAD_REQUEST,
4178 "{\"error\":{\"message\":\"prompt is too long; context_length_exceeded\"}}"
4179 ),
4180 ModelClientError::ContextOverflow(_)
4181 ));
4182 assert!(matches!(
4183 classify_anthropic_http_error(StatusCode::BAD_REQUEST, "invalid model"),
4184 ModelClientError::BadRequest(_)
4185 ));
4186 assert!(matches!(
4187 classify_anthropic_http_error(StatusCode::INTERNAL_SERVER_ERROR, "oops"),
4188 ModelClientError::ServerError(_)
4189 ));
4190 }
4191
4192 #[tokio::test]
4193 async fn collect_model_response_folds_streamed_tool_call_arguments() {
4194 let chunks = vec![
4198 Ok(ModelChunk::TextDelta {
4199 msg_id: "m".into(),
4200 delta: "ok ".into(),
4201 }),
4202 Ok(ModelChunk::ToolCallStart {
4203 id: "call_1".into(),
4204 name: "bash".into(),
4205 }),
4206 Ok(ModelChunk::ToolCallInputDelta {
4207 id: "call_1".into(),
4208 delta: "{\"command\":".into(),
4209 }),
4210 Ok(ModelChunk::ToolCallInputDelta {
4211 id: "call_1".into(),
4212 delta: "\"pwd\"}".into(),
4213 }),
4214 Ok(ModelChunk::ToolCallEnd {
4215 id: "call_1".into(),
4216 input: None,
4217 }),
4218 Ok(ModelChunk::Done {
4219 stop_reason: "end_turn".into(),
4220 usage: None,
4221 }),
4222 ];
4223 let stream = futures::stream::iter(chunks).boxed();
4224 let response = collect_model_response(stream).await.unwrap();
4225 let ModelResponse::ToolCall {
4226 invocation,
4227 preface,
4228 ..
4229 } = response
4230 else {
4231 panic!("expected ToolCall");
4232 };
4233 assert_eq!(invocation.name, "bash");
4234 assert_eq!(invocation.input["command"], "pwd");
4235 assert_eq!(preface.as_deref(), Some("ok "));
4236 }
4237
4238 #[test]
4239 fn classify_openai_http_error_buckets_by_status_and_body() {
4240 use reqwest::StatusCode;
4241 assert!(matches!(
4242 classify_openai_http_error(StatusCode::TOO_MANY_REQUESTS, "rate limit hit"),
4243 ModelClientError::RateLimit(_)
4244 ));
4245 assert!(matches!(
4246 classify_openai_http_error(StatusCode::UNAUTHORIZED, "bad key"),
4247 ModelClientError::Auth(_)
4248 ));
4249 assert!(matches!(
4250 classify_openai_http_error(StatusCode::FORBIDDEN, "no access"),
4251 ModelClientError::Auth(_)
4252 ));
4253 assert!(matches!(
4254 classify_openai_http_error(
4255 StatusCode::BAD_REQUEST,
4256 "{\"error\":{\"message\":\"this model's maximum context length is 8192\"}}"
4257 ),
4258 ModelClientError::ContextOverflow(_)
4259 ));
4260 assert!(matches!(
4262 classify_openai_http_error(StatusCode::BAD_REQUEST, "missing argument"),
4263 ModelClientError::BadRequest(_)
4264 ));
4265 assert!(matches!(
4267 classify_openai_http_error(StatusCode::INTERNAL_SERVER_ERROR, "oops"),
4268 ModelClientError::ServerError(_)
4269 ));
4270 }
4271
4272 #[test]
4273 fn looks_like_context_overflow_matches_common_phrasings() {
4274 assert!(looks_like_context_overflow(
4275 "context_length_exceeded: this model has a maximum context length of 8192"
4276 ));
4277 assert!(looks_like_context_overflow("too many tokens in prompt"));
4278 assert!(looks_like_context_overflow(
4279 "Prompt exceeds the model's maximum context"
4280 ));
4281 assert!(!looks_like_context_overflow("invalid api key"));
4282 }
4283
4284 #[test]
4285 fn parse_openai_usage_returns_none_for_missing_or_all_zero() {
4286 assert!(parse_openai_usage(None).is_none());
4288 assert!(parse_openai_usage(Some(&json!({
4290 "prompt_tokens": 0,
4291 "completion_tokens": 0
4292 })))
4293 .is_none());
4294 }
4295
4296 #[test]
4297 fn openai_client_renders_multi_turn_history() {
4298 let client = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
4299 base_url: "https://example.test".into(),
4300 api_key: "sk-test".into(),
4301 model: resolved_model(
4302 "gpt-test",
4303 WireProtocol::OpenAiCompatible,
4304 128,
4305 Some(0.2),
4306 ReasoningConfig::default(),
4307 ),
4308 });
4309 let body = client.request_body(&ModelTurnInput {
4310 system_prompt: None,
4311 messages: vec![
4312 ChatMessage::User {
4313 content: "run pwd".into(),
4314 attachments: vec![],
4315 },
4316 ChatMessage::Assistant {
4317 text: None,
4318 tool_calls: vec![ToolInvocation {
4319 id: "call_1".into(),
4320 name: "bash".into(),
4321 input: json!({"command": "pwd"}),
4322 raw_emitted_args: None,
4323 }],
4324 thinking: None,
4325 usage: None,
4326 },
4327 ChatMessage::Tool {
4328 tool_call_id: "call_1".into(),
4329 content: "{\"stdout\":\"/home/user\"}".into(),
4330 is_error: false,
4331 attachments: vec![],
4332 },
4333 ],
4334 tools: vec![],
4335 hosted_tools: vec![],
4336 tool_choice: ToolChoice::Auto,
4337 parallel_tool_calls: None,
4338 });
4339 assert_eq!(body["temperature"], 0.2);
4340 assert_eq!(body["max_tokens"], 128);
4341 assert_eq!(body["messages"][0]["role"], "user");
4342 assert_eq!(body["messages"][1]["role"], "assistant");
4343 assert_eq!(body["messages"][1]["tool_calls"][0]["id"], "call_1");
4344 assert_eq!(
4345 body["messages"][1]["tool_calls"][0]["function"]["name"],
4346 "bash"
4347 );
4348 assert_eq!(body["messages"][2]["role"], "tool");
4349 assert_eq!(body["messages"][2]["tool_call_id"], "call_1");
4350 }
4351
4352 #[test]
4353 fn reasoning_controls_translate_by_wire_protocol() {
4354 let openai = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
4355 base_url: "https://example.test".into(),
4356 api_key: "test".into(),
4357 model: resolved_model(
4358 "deepseek-v4-pro",
4359 WireProtocol::OpenAiCompatible,
4360 4_096,
4361 None,
4362 ReasoningConfig {
4363 mode: ReasoningMode::Enabled,
4364 effort: None,
4365 budget_tokens: None,
4366 },
4367 ),
4368 });
4369 let body = openai.request_body(&user("hi"));
4370 assert_eq!(body["thinking"]["type"], "enabled");
4371
4372 let anthropic = AnthropicModelClient::new(AnthropicConfig {
4373 base_url: "https://example.test/v1".into(),
4374 api_key: "test".into(),
4375 model: resolved_model(
4376 "claude-sonnet-4-6",
4377 WireProtocol::Anthropic,
4378 4_096,
4379 None,
4380 ReasoningConfig {
4381 mode: ReasoningMode::Enabled,
4382 effort: Some("high".into()),
4383 budget_tokens: None,
4384 },
4385 ),
4386 anthropic_version: AnthropicConfig::DEFAULT_VERSION.into(),
4387 });
4388 let body = anthropic.request_body(&user("hi"));
4389 assert_eq!(body["thinking"]["type"], "adaptive");
4390 assert_eq!(body["output_config"]["effort"], "high");
4391
4392 let responses = OpenAiResponsesModelClient::new(OpenAiResponsesConfig {
4393 base_url: "https://example.test/v1".into(),
4394 api_key: "test".into(),
4395 model: resolved_model(
4396 "gpt-5.5",
4397 WireProtocol::OpenAiResponses,
4398 4_096,
4399 None,
4400 ReasoningConfig {
4401 mode: ReasoningMode::Disabled,
4402 effort: Some("none".into()),
4403 budget_tokens: None,
4404 },
4405 ),
4406 reasoning_summary: None,
4407 });
4408 let body = responses.request_body(&user("hi"));
4409 assert_eq!(body["reasoning"]["effort"], "none");
4410 }
4411
4412 #[tokio::test]
4413 async fn scripted_client_emits_tool_call_then_summary() {
4414 let scripted = ScriptedModelClient;
4415 let first = scripted
4416 .next(user("read README.md"))
4417 .await
4418 .expect("scripted first");
4419 let ModelResponse::ToolCall { invocation, .. } = first else {
4420 panic!("expected tool call on first step");
4421 };
4422 assert_eq!(invocation.name, "read");
4423
4424 let history = ModelTurnInput {
4427 system_prompt: None,
4428 messages: vec![
4429 ChatMessage::User {
4430 content: "read README.md".into(),
4431 attachments: vec![],
4432 },
4433 ChatMessage::Assistant {
4434 text: None,
4435 tool_calls: vec![invocation.clone()],
4436 thinking: None,
4437 usage: None,
4438 },
4439 ChatMessage::Tool {
4440 tool_call_id: invocation.id.clone(),
4441 content: "{\"content\":\"hi\"}".into(),
4442 is_error: false,
4443 attachments: vec![],
4444 },
4445 ],
4446 tools: vec![],
4447 hosted_tools: vec![],
4448 tool_choice: ToolChoice::Auto,
4449 parallel_tool_calls: None,
4450 };
4451 let second = scripted.next(history).await.expect("scripted second");
4452 let ModelResponse::Message { text, .. } = second else {
4453 panic!("expected final message after tool result");
4454 };
4455 assert!(text.contains("completed"));
4456 }
4457
4458 #[test]
4461 fn responses_client_builds_responses_endpoint_and_body() {
4462 let mk = |base: &str| {
4463 OpenAiResponsesModelClient::new(OpenAiResponsesConfig {
4464 base_url: base.into(),
4465 api_key: "sk-test".into(),
4466 model: resolved_model(
4467 "gpt-5",
4468 WireProtocol::OpenAiResponses,
4469 2_048,
4470 None,
4471 ReasoningConfig {
4472 mode: ReasoningMode::Enabled,
4473 effort: Some("high".into()),
4474 budget_tokens: None,
4475 },
4476 ),
4477 reasoning_summary: None,
4478 })
4479 };
4480 assert_eq!(
4482 mk("https://api.openai.com/v1").endpoint(),
4483 "https://api.openai.com/v1/responses"
4484 );
4485 assert_eq!(
4486 mk("https://api.openai.com/v1/").endpoint(),
4487 "https://api.openai.com/v1/responses"
4488 );
4489 assert_eq!(
4490 mk("https://api.openai.com/v1/responses").endpoint(),
4491 "https://api.openai.com/v1/responses"
4492 );
4493
4494 let client = mk("https://api.openai.com/v1");
4495 let mut input = user("hello");
4496 input.system_prompt = Some("be terse".into());
4497 input.tools = vec![bash_spec()];
4498 let body = client.request_body(&input);
4499 assert_eq!(body["model"], "gpt-5");
4500 assert_eq!(body["stream"], true);
4501 assert_eq!(body["store"], false);
4502 assert_eq!(body["instructions"], "be terse");
4503 assert_eq!(body["max_output_tokens"], 2048);
4504 assert_eq!(body["reasoning"]["effort"], "high");
4505 assert_eq!(body["include"][0], "reasoning.encrypted_content");
4507 assert_eq!(body["tools"][0]["type"], "function");
4509 assert_eq!(body["tools"][0]["name"], "bash");
4510 assert!(body["tools"][0]["parameters"].is_object());
4511 assert_eq!(body["tool_choice"], "auto");
4512 assert_eq!(body["input"][0]["role"], "user");
4514 assert_eq!(body["input"][0]["content"][0]["type"], "input_text");
4515 assert_eq!(body["input"][0]["content"][0]["text"], "hello");
4516 }
4517
4518 #[test]
4519 fn responses_tool_choice_none_drops_client_and_hosted_tools() {
4520 let client = OpenAiResponsesModelClient::new(OpenAiResponsesConfig {
4521 base_url: "https://api.openai.com/v1".into(),
4522 api_key: "sk-test".into(),
4523 model: default_model("gpt-5", WireProtocol::OpenAiResponses),
4524 reasoning_summary: None,
4525 });
4526 let mut input = user("hi");
4527 input.tools = vec![bash_spec()];
4528 input.hosted_tools = vec![HostedTool::WebSearch];
4529 input.tool_choice = ToolChoice::None;
4530 let body = client.request_body(&input);
4531 assert!(
4534 body.get("tools").is_none(),
4535 "tools should be absent, got {:?}",
4536 body.get("tools")
4537 );
4538 assert!(body.get("tool_choice").is_none());
4539
4540 input.tool_choice = ToolChoice::Auto;
4543 let body = client.request_body(&input);
4544 let tools = body["tools"].as_array().expect("tools present");
4545 assert_eq!(tools.len(), 2);
4546 assert!(tools.iter().any(|t| t["type"] == "function"));
4547 assert!(tools.iter().any(|t| t["type"] == "web_search"));
4548 }
4549
4550 #[test]
4551 fn responses_tool_choice_required_sent_for_hosted_only() {
4552 let client = OpenAiResponsesModelClient::new(OpenAiResponsesConfig {
4553 base_url: "https://api.openai.com/v1".into(),
4554 api_key: "sk-test".into(),
4555 model: default_model("gpt-5", WireProtocol::OpenAiResponses),
4556 reasoning_summary: None,
4557 });
4558 let mut input = user("search the web");
4560 input.hosted_tools = vec![HostedTool::WebSearch];
4561
4562 input.tool_choice = ToolChoice::Required;
4565 let body = client.request_body(&input);
4566 assert_eq!(body["tools"][0]["type"], "web_search");
4567 assert_eq!(body["tool_choice"], "required");
4568
4569 input.tool_choice = ToolChoice::Tool("bash".into());
4573 let body = client.request_body(&input);
4574 assert_eq!(body["tools"][0]["type"], "web_search");
4575 assert!(
4576 body.get("tool_choice").is_none(),
4577 "Tool(name) must not be sent for a hosted-only turn, got {:?}",
4578 body.get("tool_choice")
4579 );
4580
4581 input.tools = vec![bash_spec()];
4583 let body = client.request_body(&input);
4584 assert_eq!(body["tool_choice"]["type"], "function");
4585 assert_eq!(body["tool_choice"]["name"], "bash");
4586 }
4587
4588 #[test]
4589 fn responses_stream_state_accepts_reasoning_summary_alias() {
4590 let mut st = OpenAiResponsesStreamState::default();
4593 let chunks = st
4594 .feed_data(
4595 r#"{"type":"response.reasoning_summary.delta","item_id":"rs_1","summary_index":0,"delta":"aliased"}"#,
4596 )
4597 .expect("feed_data");
4598 assert!(
4599 matches!(&chunks[0], ModelChunk::ThinkingDelta { delta, .. } if delta == "aliased")
4600 );
4601 }
4602
4603 #[test]
4604 fn responses_stream_state_separates_reasoning_summary_parts() {
4605 let mut st = OpenAiResponsesStreamState::default();
4606 let mut chunks: Vec<ModelChunk> = Vec::new();
4607 let mut feed = |st: &mut OpenAiResponsesStreamState, data: &str| {
4608 chunks.extend(st.feed_data(data).expect("feed_data"));
4609 };
4610 feed(
4615 &mut st,
4616 r#"{"type":"response.reasoning_summary_part.added","item_id":"rs_1","summary_index":0}"#,
4617 );
4618 feed(
4619 &mut st,
4620 r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","summary_index":0,"delta":"first"}"#,
4621 );
4622 feed(
4623 &mut st,
4624 r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","summary_index":0,"delta":" more"}"#,
4625 );
4626 feed(
4627 &mut st,
4628 r#"{"type":"response.reasoning_summary_part.added","item_id":"rs_1","summary_index":1}"#,
4629 );
4630 feed(
4631 &mut st,
4632 r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","summary_index":1,"delta":"second"}"#,
4633 );
4634 let deltas: Vec<&str> = chunks
4635 .iter()
4636 .filter_map(|c| match c {
4637 ModelChunk::ThinkingDelta { delta, .. } => Some(delta.as_str()),
4638 _ => None,
4639 })
4640 .collect();
4641 assert_eq!(deltas, vec!["first", " more", "\n\nsecond"]);
4644 }
4645
4646 #[test]
4647 fn responses_input_projection_round_trips_tool_calls_and_reasoning() {
4648 let sig = encode_reasoning_signature("rs_123", "ENC==");
4649 let messages = vec![
4650 ChatMessage::User {
4651 content: "hi".into(),
4652 attachments: vec![],
4653 },
4654 ChatMessage::Assistant {
4655 text: Some("looked".into()),
4656 tool_calls: vec![ToolInvocation {
4657 id: "call_1".into(),
4658 name: "bash".into(),
4659 input: json!({"command": "ls"}),
4660 raw_emitted_args: None,
4661 }],
4662 thinking: Some(AssistantThinking {
4663 text: "let me look".into(),
4664 signature: Some(sig),
4665 }),
4666 usage: None,
4667 },
4668 ChatMessage::Tool {
4669 tool_call_id: "call_1".into(),
4670 content: "file.txt".into(),
4671 is_error: false,
4672 attachments: vec![],
4673 },
4674 ];
4675 let input = chat_messages_to_responses_input(&messages);
4676 assert_eq!(input[0]["role"], "user");
4677 assert_eq!(input[1]["type"], "reasoning");
4679 assert_eq!(input[1]["id"], "rs_123");
4680 assert_eq!(input[1]["encrypted_content"], "ENC==");
4681 assert_eq!(input[1]["summary"][0]["text"], "let me look");
4682 assert_eq!(input[2]["role"], "assistant");
4683 assert_eq!(input[2]["content"][0]["type"], "output_text");
4684 assert_eq!(input[3]["type"], "function_call");
4686 assert_eq!(input[3]["call_id"], "call_1");
4687 assert_eq!(input[3]["name"], "bash");
4688 assert_eq!(input[4]["type"], "function_call_output");
4689 assert_eq!(input[4]["call_id"], "call_1");
4690 assert_eq!(input[4]["output"], "file.txt");
4691 }
4692
4693 #[test]
4694 fn responses_stream_state_emits_text_toolcall_and_done() {
4695 let mut st = OpenAiResponsesStreamState::default();
4696 let mut chunks: Vec<ModelChunk> = Vec::new();
4697 let mut feed = |st: &mut OpenAiResponsesStreamState, data: &str| {
4698 chunks.extend(st.feed_data(data).expect("feed_data"));
4699 };
4700 feed(
4701 &mut st,
4702 r#"{"type":"response.created","response":{"id":"resp_1"}}"#,
4703 );
4704 feed(
4705 &mut st,
4706 r#"{"type":"response.output_text.delta","item_id":"msg_1","delta":"Hel"}"#,
4707 );
4708 feed(
4709 &mut st,
4710 r#"{"type":"response.output_text.delta","item_id":"msg_1","delta":"lo"}"#,
4711 );
4712 feed(
4713 &mut st,
4714 r#"{"type":"response.output_item.added","item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"bash"}}"#,
4715 );
4716 feed(
4717 &mut st,
4718 r#"{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{\"command\":"}"#,
4719 );
4720 feed(
4721 &mut st,
4722 r#"{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"\"ls\"}"}"#,
4723 );
4724 feed(
4725 &mut st,
4726 r#"{"type":"response.output_item.done","item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
4727 );
4728 feed(
4729 &mut st,
4730 r#"{"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":10,"output_tokens":5,"input_tokens_details":{"cached_tokens":2}}}}"#,
4731 );
4732
4733 assert!(matches!(&chunks[0], ModelChunk::TextDelta { delta, .. } if delta == "Hel"));
4734 assert!(matches!(&chunks[1], ModelChunk::TextDelta { delta, .. } if delta == "lo"));
4735 assert!(
4736 matches!(&chunks[2], ModelChunk::ToolCallStart { id, name } if id == "call_1" && name == "bash")
4737 );
4738 assert!(matches!(&chunks[3], ModelChunk::ToolCallInputDelta { id, .. } if id == "call_1"));
4739 assert!(matches!(&chunks[4], ModelChunk::ToolCallInputDelta { id, .. } if id == "call_1"));
4740 match &chunks[5] {
4741 ModelChunk::ToolCallEnd { id, input } => {
4742 assert_eq!(id, "call_1");
4743 assert_eq!(input.as_ref().expect("early input")["command"], "ls");
4744 }
4745 other => panic!("expected ToolCallEnd, got {other:?}"),
4746 }
4747 match chunks.last().expect("done chunk") {
4748 ModelChunk::Done { stop_reason, usage } => {
4749 assert_eq!(stop_reason, "end_turn");
4750 let u = usage.as_ref().expect("usage");
4751 assert_eq!(u.input_tokens, 10);
4752 assert_eq!(u.output_tokens, 5);
4753 assert_eq!(u.cache_read_input_tokens, 2);
4754 }
4755 other => panic!("expected Done, got {other:?}"),
4756 }
4757 assert!(st.ended_cleanly());
4758 }
4759
4760 #[test]
4761 fn responses_stream_state_round_trips_reasoning_signature() {
4762 let mut st = OpenAiResponsesStreamState::default();
4763 let mut chunks: Vec<ModelChunk> = Vec::new();
4764 chunks.extend(
4765 st.feed_data(
4766 r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","delta":"pondering"}"#,
4767 )
4768 .unwrap(),
4769 );
4770 chunks.extend(
4771 st.feed_data(
4772 r#"{"type":"response.output_item.done","item":{"type":"reasoning","id":"rs_1","encrypted_content":"ENC=="}}"#,
4773 )
4774 .unwrap(),
4775 );
4776 assert!(
4777 matches!(&chunks[0], ModelChunk::ThinkingDelta { delta, signature, .. } if delta == "pondering" && signature.is_none())
4778 );
4779 match &chunks[1] {
4780 ModelChunk::ThinkingDelta {
4781 delta, signature, ..
4782 } => {
4783 assert!(delta.is_empty());
4784 let (id, enc) =
4785 decode_reasoning_signature(signature.as_ref().expect("signature")).unwrap();
4786 assert_eq!(id, "rs_1");
4787 assert_eq!(enc, "ENC==");
4788 }
4789 other => panic!("expected ThinkingDelta, got {other:?}"),
4790 }
4791 }
4792
4793 #[test]
4794 fn responses_stream_state_reports_cutoff_without_terminal_event() {
4795 let mut st = OpenAiResponsesStreamState::default();
4796 st.feed_data(r#"{"type":"response.output_text.delta","item_id":"m","delta":"hi"}"#)
4797 .unwrap();
4798 assert!(!st.ended_cleanly());
4800 }
4801
4802 #[test]
4803 fn reasoning_signature_encode_decode_roundtrip() {
4804 let sig = encode_reasoning_signature("rs_abc", "base64==payload");
4805 assert_eq!(
4806 decode_reasoning_signature(&sig),
4807 Some(("rs_abc".into(), "base64==payload".into()))
4808 );
4809 assert_eq!(decode_reasoning_signature("anthropic-sig-no-newline"), None);
4812 }
4813}