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