1use std::collections::HashMap;
41use std::time::Duration;
42
43use async_trait::async_trait;
44use futures::StreamExt;
45use reqwest::Client;
46use serde::{Deserialize, Serialize};
47use serde_json::{Value, json};
48
49use crate::constants::MAX_RESPONSE_CHARS;
50use crate::models::ModelCapabilities;
51use crate::models::config::ModelConfig;
52use crate::models::error::{BackendError, ModelError, Result};
53use crate::models::providers::{
54 MaxTokensParam, ProviderProfile, ReasoningExtraction, ReasoningStrategy,
55};
56use crate::models::reasoning::{
57 ReasoningCapability, ReasoningChunk, ReasoningLevel, nearest_effort,
58};
59use crate::models::stream::{StreamCallback, StreamEvent};
60use crate::models::tool_call::{FunctionCall, ToolCall};
61use crate::models::traits::Model;
62use crate::models::types::{ChatMessage, FinishReason, MessageRole, ModelResponse, TokenUsage};
63use crate::utils::drain_sse_events;
64
65const TRUNCATION_MARKER: &str = "\n\n[TRUNCATED: response exceeded size limit]";
66
67fn push_capped(buf: &mut String, chunk: &str, truncated: &mut bool, cap: usize) {
74 if *truncated {
75 return;
76 }
77 buf.push_str(chunk);
78 if buf.len() > cap {
79 let end = buf.floor_char_boundary(cap);
80 buf.truncate(end);
81 buf.push_str(TRUNCATION_MARKER);
82 *truncated = true;
83 }
84}
85
86fn push_tool_arg(buf: &mut String, frag: &str) {
93 let cap = crate::constants::MAX_TOOL_ARG_BYTES;
94 if buf.len() >= cap {
95 return;
96 }
97 if buf.len() + frag.len() <= cap {
98 buf.push_str(frag);
99 } else {
100 let room = cap - buf.len();
101 let end = frag.floor_char_boundary(room);
102 buf.push_str(&frag[..end]);
103 }
104}
105
106fn map_openai_finish_reason(s: &str) -> FinishReason {
108 match s {
109 "stop" => FinishReason::Stop,
110 "length" => FinishReason::Length,
111 "tool_calls" | "function_call" => FinishReason::ToolUse,
112 "content_filter" => FinishReason::ContentFilter,
113 other => FinishReason::Other(other.to_string()),
114 }
115}
116
117fn stream_closed_abnormally(stop_reason: Option<&FinishReason>) -> bool {
127 stop_reason.is_none()
128}
129
130pub struct OpenAICompatAdapter {
137 client: Client,
138 profile: &'static ProviderProfile,
139 base_url: String,
140 api_key: Option<String>,
143 model_name: String,
144 extra_headers: HashMap<String, String>,
147 capabilities: ModelCapabilities,
148}
149
150fn random_idempotency_key() -> String {
154 let mut bytes = [0u8; 16];
155 if getrandom::fill(&mut bytes).is_err() {
156 let nanos = std::time::SystemTime::now()
157 .duration_since(std::time::UNIX_EPOCH)
158 .map(|d| d.as_nanos())
159 .unwrap_or_default();
160 return format!("mermaid-{}-{nanos}", std::process::id());
161 }
162 use std::fmt::Write;
163 bytes.iter().fold(String::with_capacity(32), |mut s, b| {
164 let _ = write!(s, "{b:02x}");
165 s
166 })
167}
168
169impl OpenAICompatAdapter {
170 pub fn new(
174 profile: &'static ProviderProfile,
175 base_url: String,
176 api_key: Option<String>,
177 model_name: String,
178 extra_headers: HashMap<String, String>,
179 ) -> Result<Self> {
180 let client = Client::builder()
184 .pool_max_idle_per_host(10)
185 .pool_idle_timeout(Duration::from_secs(90))
186 .tcp_keepalive(Duration::from_secs(60))
187 .connect_timeout(Duration::from_secs(10))
188 .build()
189 .map_err(|e| {
190 ModelError::Backend(BackendError::ConnectionFailed {
191 backend: profile.name.to_string(),
192 url: base_url.clone(),
193 reason: e.to_string(),
194 })
195 })?;
196
197 let capabilities = derive_capabilities(profile, &model_name);
198
199 Ok(Self {
200 client,
201 profile,
202 base_url,
203 api_key,
204 model_name,
205 extra_headers,
206 capabilities,
207 })
208 }
209
210 fn build_request_body(
213 &self,
214 messages: &[ChatMessage],
215 config: &ModelConfig,
216 stream: bool,
217 ) -> Value {
218 let mut json_messages = Vec::new();
219
220 if let Some(combined) = config.combined_system_prompt() {
225 json_messages.push(json!({
226 "role": "system",
227 "content": combined
228 }));
229 }
230
231 for msg in messages {
232 let role = match msg.role {
233 MessageRole::User => "user",
234 MessageRole::Assistant => "assistant",
235 MessageRole::System => "system",
236 MessageRole::Tool => "tool",
237 };
238 let mut json_msg = json!({ "role": role });
239 if msg.role == MessageRole::User
247 && msg.images.as_ref().is_some_and(|images| !images.is_empty())
248 {
249 let mut parts: Vec<Value> = Vec::new();
250 if !msg.content.is_empty() {
251 parts.push(json!({ "type": "text", "text": msg.content }));
252 }
253 for data in msg.images.iter().flatten() {
254 parts.push(json!({
257 "type": "image_url",
258 "image_url": { "url": format!("data:image/png;base64,{data}") },
259 }));
260 }
261 json_msg["content"] = json!(parts);
262 } else {
263 json_msg["content"] = json!(msg.content);
264 }
265 if msg.role == MessageRole::Assistant
266 && let Some(tool_calls) = msg.tool_calls.as_ref().filter(|tc| !tc.is_empty())
267 {
268 let wire: Vec<Value> = tool_calls
275 .iter()
276 .map(|tc| {
277 let arguments = match &tc.function.arguments {
278 Value::String(s) => s.clone(),
281 other => {
282 serde_json::to_string(other).unwrap_or_else(|_| "{}".to_string())
283 },
284 };
285 json!({
286 "id": tc.id.clone().unwrap_or_default(),
287 "type": "function",
288 "function": {
289 "name": tc.function.name,
290 "arguments": arguments,
291 },
292 })
293 })
294 .collect();
295 json_msg["tool_calls"] = json!(wire);
296 }
297 if msg.role == MessageRole::Tool {
301 if let Some(ref tool_call_id) = msg.tool_call_id {
302 json_msg["tool_call_id"] = json!(tool_call_id);
303 }
304 if let Some(ref tool_name) = msg.tool_name {
305 json_msg["name"] = json!(tool_name);
306 }
307 }
308 json_messages.push(json_msg);
309 }
310
311 let tools: Vec<&Value> = config.tools.iter().collect();
315
316 let mut body = json!({
317 "model": self.model_name,
318 "messages": json_messages,
319 "stream": stream,
320 });
321 if crate::models::catalog::lookup(&self.model_name).supports_temperature {
327 body["temperature"] = json!(config.temperature.clamp(0.0, 2.0));
328 }
329
330 if stream {
331 body["stream_options"] = json!({ "include_usage": true });
332 }
333
334 if !tools.is_empty() {
335 body["tools"] = json!(tools);
336 if self
337 .profile
338 .disable_parallel_tool_calls_for
339 .contains(&self.model_name.as_str())
340 {
341 body["parallel_tool_calls"] = json!(false);
342 }
343 }
344
345 if config.max_tokens > 0 {
348 match self.profile.max_tokens_param {
349 MaxTokensParam::MaxTokens => body["max_tokens"] = json!(config.max_tokens),
350 MaxTokensParam::MaxCompletionTokens => {
351 body["max_completion_tokens"] = json!(config.max_tokens);
352 },
353 }
354 }
355
356 let effective_reasoning = match &self.capabilities.supports_reasoning {
364 ReasoningCapability::Levels(supported) => {
365 nearest_effort(config.reasoning, supported).unwrap_or(ReasoningLevel::None)
366 },
367 _ => config.reasoning,
368 };
369 if let Some(reasoning_value) = self.profile.reasoning_strategy.render(effective_reasoning) {
370 if let Some(obj) = reasoning_value.as_object() {
373 for (k, v) in obj {
374 body[k] = v.clone();
375 }
376 }
377 }
378
379 if let Some(schema) = &config.output_schema {
385 body["response_format"] = json!({
386 "type": "json_schema",
387 "json_schema": {
388 "name": "output",
389 "strict": false,
390 "schema": schema,
391 }
392 });
393 }
394
395 body
396 }
397
398 async fn send_chat(&self, body: &Value) -> Result<reqwest::Response> {
403 let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
404 let idempotency_key = random_idempotency_key();
412 crate::models::retry::retry_transient_http(|| async {
413 let mut req = self
414 .client
415 .post(&url)
416 .header("Idempotency-Key", &idempotency_key)
417 .json(body);
418 if let Some(key) = &self.api_key {
419 req = req.bearer_auth(key);
420 }
421 for (name, value) in &self.extra_headers {
422 req = req.header(name, value);
423 }
424 req.send().await.map_err(|e| {
425 ModelError::Backend(BackendError::ConnectionFailed {
426 backend: self.profile.name.to_string(),
427 url: url.clone(),
428 reason: e.to_string(),
429 })
430 })
431 })
432 .await
433 }
434
435 async fn decode_non_streaming(&self, response: reqwest::Response) -> Result<ModelResponse> {
437 if !response.status().is_success() {
438 let status = response.status().as_u16();
439 let debug =
440 crate::models::error::ResponseDebugContext::from_headers(response.headers());
441 let body = response
442 .text()
443 .await
444 .unwrap_or_else(|_| "Unknown error".to_string());
445 return Err(ModelError::Backend(BackendError::HttpError {
446 status,
447 message: body,
448 debug,
449 }));
450 }
451 let json: ChatCompletion = response.json().await.map_err(|e| ModelError::ParseError {
452 message: format!("Failed to parse {} response: {}", self.profile.name, e),
453 raw: None,
454 })?;
455
456 let choice = json
457 .choices
458 .into_iter()
459 .next()
460 .ok_or_else(|| ModelError::ParseError {
461 message: format!("{} response had no choices", self.profile.name),
462 raw: None,
463 })?;
464
465 let usage = json.usage.map(token_usage_from_wire);
466
467 let raw_content = choice.message.content.unwrap_or_default();
472 let (content, inline_thinking) = match self.profile.reasoning_extraction {
473 ReasoningExtraction::InlineThinkTags => {
474 let mut ts = ThinkTagState::new();
475 let (mut text, mut reasoning) = ts.feed(&raw_content);
476 let (text_tail, reasoning_tail) = ts.flush();
477 text.push_str(&text_tail);
478 reasoning.push_str(&reasoning_tail);
479 (text, (!reasoning.is_empty()).then_some(reasoning))
480 },
481 _ => (raw_content, None),
482 };
483
484 let thinking = match self.profile.reasoning_extraction {
485 ReasoningExtraction::DeltaContentField(field) => choice
486 .message
487 .extra
488 .get(field)
489 .and_then(|v| v.as_str())
490 .map(|s| s.to_string())
491 .filter(|s| !s.is_empty()),
492 ReasoningExtraction::InlineThinkTags => inline_thinking,
493 _ => None,
494 };
495
496 let tool_calls = choice
497 .message
498 .tool_calls
499 .filter(|v| !v.is_empty())
500 .map(|raw| raw.into_iter().map(parse_full_tool_call).collect());
501
502 let stop_reason = choice
503 .finish_reason
504 .as_deref()
505 .map(map_openai_finish_reason);
506 if content.is_empty()
507 && tool_calls.is_none()
508 && stop_reason == Some(FinishReason::ContentFilter)
509 {
510 return Err(ModelError::Backend(BackendError::ProviderError {
511 provider: self.profile.name.to_string(),
512 code: Some("content_filter".to_string()),
513 message: "Provider returned no content (content filter)".to_string(),
514 debug: crate::models::error::ResponseDebugContext::default(),
515 }));
516 }
517
518 Ok(ModelResponse {
519 content,
520 usage,
521 model_name: self.model_name.clone(),
522 stop_reason,
523 thinking,
524 tool_calls,
525 provider_continuation: None,
526 })
527 }
528
529 async fn handle_stream(
532 &self,
533 response: reqwest::Response,
534 callback: StreamCallback,
535 hide_reasoning_trace: bool,
536 ) -> Result<ModelResponse> {
537 if !response.status().is_success() {
538 let status = response.status().as_u16();
539 let debug =
540 crate::models::error::ResponseDebugContext::from_headers(response.headers());
541 let body = response
542 .text()
543 .await
544 .unwrap_or_else(|_| "Unknown error".to_string());
545 return Err(ModelError::Backend(BackendError::HttpError {
546 status,
547 message: body,
548 debug,
549 }));
550 }
551
552 let mut stream = response.bytes_stream();
553 let mut buf: Vec<u8> = Vec::new();
554
555 let mut content_acc = String::new();
556 let mut thinking_acc = String::new();
557 let mut tool_calls_partial: Vec<PartialToolCall> = Vec::new();
558 let mut truncated = false;
559 let mut stop_reason: Option<FinishReason> = None;
560 let mut usage_acc: Option<TokenUsage> = None;
565 let inline_tags = matches!(
570 self.profile.reasoning_extraction,
571 ReasoningExtraction::InlineThinkTags
572 );
573 let mut think_state = ThinkTagState::new();
574
575 while let Some(chunk_result) = stream.next().await {
576 let chunk = chunk_result.map_err(|e| ModelError::StreamError(e.to_string()))?;
577 if buf.len() > crate::constants::MAX_SSE_BUFFER_BYTES {
583 return Err(ModelError::StreamError(format!(
584 "SSE stream exceeded {} byte reassembly cap without a complete event",
585 crate::constants::MAX_SSE_BUFFER_BYTES
586 )));
587 }
588 buf.extend_from_slice(&chunk);
589
590 for payload in drain_sse_events(&mut buf) {
591 let value: serde_json::Value = match serde_json::from_str(&payload) {
596 Ok(v) => v,
597 Err(e) => {
598 return Err(ModelError::ParseError {
599 message: format!(
600 "Failed to parse {} stream chunk: {}",
601 self.profile.name, e
602 ),
603 raw: Some(payload),
604 });
605 },
606 };
607 if let Some(err) = value.get("error") {
608 let code = err.get("code").and_then(|v| {
609 v.as_str()
610 .map(str::to_string)
611 .or_else(|| v.as_i64().map(|n| n.to_string()))
612 });
613 let message = err
614 .get("message")
615 .and_then(|v| v.as_str())
616 .unwrap_or("stream error")
617 .to_string();
618 return Err(ModelError::Backend(BackendError::ProviderError {
619 provider: self.profile.name.to_string(),
620 code,
621 message,
622 debug: crate::models::error::ResponseDebugContext::default(),
623 }));
624 }
625 let parsed: ChatCompletionChunk = match serde_json::from_value(value) {
626 Ok(v) => v,
627 Err(e) => {
628 return Err(ModelError::ParseError {
629 message: format!(
630 "Failed to parse {} stream chunk: {}",
631 self.profile.name, e
632 ),
633 raw: Some(payload),
634 });
635 },
636 };
637
638 if let Some(usage) = parsed.usage {
639 usage_acc = Some(token_usage_from_wire(usage));
643 }
644
645 let Some(choice) = parsed.choices.into_iter().next() else {
646 continue;
647 };
648
649 if let Some(fr) = &choice.finish_reason {
650 stop_reason = Some(map_openai_finish_reason(fr));
651 }
652 let delta = choice.delta;
653
654 let reasoning_chunk = match self.profile.reasoning_extraction {
658 ReasoningExtraction::DeltaContentField(field) => delta
659 .extra
660 .get(field)
661 .and_then(|v| v.as_str())
662 .filter(|s| !s.is_empty())
663 .map(|s| ReasoningChunk {
664 text: s.to_string(),
665 signature: None,
666 }),
667 _ => None,
668 };
669 if let Some(chunk) = reasoning_chunk {
670 if !hide_reasoning_trace {
671 callback(StreamEvent::Reasoning(chunk.clone()));
672 }
673 push_capped(
674 &mut thinking_acc,
675 &chunk.text,
676 &mut truncated,
677 MAX_RESPONSE_CHARS,
678 );
679 }
680
681 if let Some(text) = delta.content.as_ref()
685 && !text.is_empty()
686 && !truncated
687 {
688 if inline_tags {
689 let (text_part, reasoning_part) = think_state.feed(text);
690 if !text_part.is_empty() {
691 callback(StreamEvent::Text(text_part.clone()));
692 push_capped(
693 &mut content_acc,
694 &text_part,
695 &mut truncated,
696 MAX_RESPONSE_CHARS,
697 );
698 }
699 if !reasoning_part.is_empty() {
700 if !hide_reasoning_trace {
701 callback(StreamEvent::Reasoning(ReasoningChunk {
702 text: reasoning_part.clone(),
703 signature: None,
704 }));
705 }
706 push_capped(
707 &mut thinking_acc,
708 &reasoning_part,
709 &mut truncated,
710 MAX_RESPONSE_CHARS,
711 );
712 }
713 } else {
714 callback(StreamEvent::Text(text.clone()));
715 push_capped(&mut content_acc, text, &mut truncated, MAX_RESPONSE_CHARS);
716 }
717 }
718
719 if let Some(deltas) = delta.tool_calls {
721 for tc_delta in deltas {
722 accumulate_tool_call(&mut tool_calls_partial, tc_delta);
723 }
724 }
725 }
726 }
727
728 if stream_closed_abnormally(stop_reason.as_ref()) {
735 return Err(ModelError::StreamError(format!(
736 "{} stream closed before a terminal finish_reason; the connection \
737 was likely dropped mid-response",
738 self.profile.name
739 )));
740 }
741
742 if inline_tags {
745 let (text_tail, reasoning_tail) = think_state.flush();
746 if !text_tail.is_empty() && !truncated {
747 callback(StreamEvent::Text(text_tail.clone()));
748 push_capped(
749 &mut content_acc,
750 &text_tail,
751 &mut truncated,
752 MAX_RESPONSE_CHARS,
753 );
754 }
755 if !reasoning_tail.is_empty() && !truncated {
756 if !hide_reasoning_trace {
757 callback(StreamEvent::Reasoning(ReasoningChunk {
758 text: reasoning_tail.clone(),
759 signature: None,
760 }));
761 }
762 push_capped(
763 &mut thinking_acc,
764 &reasoning_tail,
765 &mut truncated,
766 MAX_RESPONSE_CHARS,
767 );
768 }
769 }
770
771 let mut final_tool_calls: Vec<ToolCall> = Vec::new();
774 for partial in tool_calls_partial {
775 if let Some(tc) = partial.into_tool_call() {
776 callback(StreamEvent::ToolCall(tc.clone()));
777 final_tool_calls.push(tc);
778 }
779 }
780
781 let thinking = if thinking_acc.is_empty() {
785 None
786 } else {
787 Some(thinking_acc)
788 };
789 let tool_calls = if final_tool_calls.is_empty() {
790 None
791 } else {
792 Some(final_tool_calls)
793 };
794
795 if content_acc.is_empty()
798 && tool_calls.is_none()
799 && stop_reason == Some(FinishReason::ContentFilter)
800 {
801 return Err(ModelError::Backend(BackendError::ProviderError {
802 provider: self.profile.name.to_string(),
803 code: Some("content_filter".to_string()),
804 message: "Provider returned no content (content filter)".to_string(),
805 debug: crate::models::error::ResponseDebugContext::default(),
806 }));
807 }
808
809 Ok(ModelResponse {
810 content: content_acc,
811 usage: usage_acc,
814 model_name: self.model_name.clone(),
815 stop_reason,
816 thinking,
817 tool_calls,
818 provider_continuation: None,
819 })
820 }
821}
822
823fn derive_capabilities(profile: &ProviderProfile, model_name: &str) -> ModelCapabilities {
834 use ReasoningCapability as Cap;
835 let supports_reasoning = match profile.reasoning_strategy {
836 ReasoningStrategy::None => Cap::Unsupported,
837 ReasoningStrategy::Effort => Cap::Levels(vec![
841 ReasoningLevel::None,
842 ReasoningLevel::Minimal,
843 ReasoningLevel::Low,
844 ReasoningLevel::Medium,
845 ReasoningLevel::High,
846 ReasoningLevel::Max,
847 ReasoningLevel::XHigh,
848 ]),
849 ReasoningStrategy::OpenRouterShape => Cap::Levels(vec![
853 ReasoningLevel::None,
854 ReasoningLevel::Low,
855 ReasoningLevel::Medium,
856 ReasoningLevel::High,
857 ReasoningLevel::Max,
858 ]),
859 };
860 ModelCapabilities {
861 supports_tools: true,
862 supports_vision: crate::models::catalog::lookup(model_name).vision,
868 supports_reasoning,
869 max_context_tokens: None,
872 max_output_tokens: None,
873 }
874}
875
876impl OpenAICompatAdapter {
877 pub fn provider_name(&self) -> &str {
880 self.profile.name
881 }
882
883 pub async fn list_models_detailed(&self) -> Result<Vec<ModelListing>> {
889 let url = format!("{}/models", self.base_url.trim_end_matches('/'));
890 let response = self.get_models_response(&url).await?;
891 let body: ListModelsResponse =
892 response.json().await.map_err(|e| ModelError::ParseError {
893 message: format!("Failed to parse {} models list: {}", self.profile.name, e),
894 raw: None,
895 })?;
896 Ok(body.data.into_iter().map(ModelListing::from).collect())
897 }
898
899 pub async fn list_models_for_limits(&self) -> Result<Vec<ModelListing>> {
907 let Some(search_base) = self.cloudflare_models_search_base() else {
908 return self.list_models_detailed().await;
909 };
910 let hint = self
914 .model_name
915 .rsplit('/')
916 .next()
917 .unwrap_or(&self.model_name);
918 if let Ok(listings) = self
919 .fetch_cloudflare_openrouter_format(&search_base, hint)
920 .await
921 && listings.iter().any(|m| m.id == self.model_name)
922 {
923 return Ok(listings);
924 }
925 self.fetch_cloudflare_default_format(&search_base, hint)
929 .await
930 }
931
932 fn cloudflare_models_search_base(&self) -> Option<String> {
939 if self.profile.name != "cloudflare" {
940 return None;
941 }
942 let root = self.base_url.trim_end_matches('/').strip_suffix("/v1")?;
943 root.ends_with("/ai")
944 .then(|| format!("{root}/models/search"))
945 }
946
947 async fn fetch_cloudflare_openrouter_format(
948 &self,
949 search_base: &str,
950 hint: &str,
951 ) -> Result<Vec<ModelListing>> {
952 let url = format!(
953 "{search_base}?format=openrouter&per_page=100&search={}",
954 encode_query_value(hint)
955 );
956 let response = self.get_models_response(&url).await?;
957 let body: ListModelsResponse =
958 response.json().await.map_err(|e| ModelError::ParseError {
959 message: format!("Failed to parse {} models search: {}", self.profile.name, e),
960 raw: None,
961 })?;
962 Ok(body.data.into_iter().map(ModelListing::from).collect())
963 }
964
965 async fn fetch_cloudflare_default_format(
966 &self,
967 search_base: &str,
968 hint: &str,
969 ) -> Result<Vec<ModelListing>> {
970 let url = format!(
971 "{search_base}?per_page=100&search={}",
972 encode_query_value(hint)
973 );
974 let response = self.get_models_response(&url).await?;
975 let body: CfModelsSearchResponse =
976 response.json().await.map_err(|e| ModelError::ParseError {
977 message: format!("Failed to parse {} models search: {}", self.profile.name, e),
978 raw: None,
979 })?;
980 Ok(body.result.into_iter().map(ModelListing::from).collect())
981 }
982
983 async fn get_models_response(&self, url: &str) -> Result<reqwest::Response> {
985 let mut req = self.client.get(url);
986 if let Some(key) = &self.api_key {
987 req = req.bearer_auth(key);
988 }
989 for (name, value) in &self.extra_headers {
990 req = req.header(name, value);
991 }
992 let response = req.send().await.map_err(|e| {
993 ModelError::Backend(BackendError::ConnectionFailed {
994 backend: self.profile.name.to_string(),
995 url: url.to_string(),
996 reason: e.to_string(),
997 })
998 })?;
999 if response.status() == reqwest::StatusCode::NOT_FOUND {
1000 return Err(ModelError::Unsupported {
1001 feature: format!("list_models (provider: {})", self.profile.name),
1002 });
1003 }
1004 if !response.status().is_success() {
1005 return Err(ModelError::Backend(BackendError::HttpError {
1006 status: response.status().as_u16(),
1007 message: format!("{} list_models failed", self.profile.name),
1008 debug: crate::models::error::ResponseDebugContext::from_headers(response.headers()),
1009 }));
1010 }
1011 Ok(response)
1012 }
1013}
1014
1015#[async_trait]
1016impl Model for OpenAICompatAdapter {
1017 fn name(&self) -> &str {
1018 &self.model_name
1019 }
1020
1021 fn capabilities(&self) -> &ModelCapabilities {
1022 &self.capabilities
1023 }
1024
1025 async fn list_models(&self) -> Result<Vec<String>> {
1026 Ok(self
1027 .list_models_detailed()
1028 .await?
1029 .into_iter()
1030 .map(|m| m.id)
1031 .collect())
1032 }
1033
1034 async fn chat(
1035 &self,
1036 messages: &[ChatMessage],
1037 config: &ModelConfig,
1038 callback: Option<StreamCallback>,
1039 ) -> Result<ModelResponse> {
1040 let stream = callback.is_some();
1041 let body = self.build_request_body(messages, config, stream);
1042 let response = self.send_chat(&body).await?;
1043
1044 if let Some(cb) = callback {
1045 self.handle_stream(response, cb, config.hide_reasoning_trace)
1046 .await
1047 } else {
1048 self.decode_non_streaming(response).await
1049 }
1050 }
1051}
1052
1053#[derive(Debug, Deserialize)]
1057struct ChatCompletion {
1058 choices: Vec<NonStreamingChoice>,
1059 #[serde(default)]
1060 usage: Option<UsageWire>,
1061}
1062
1063#[derive(Debug, Deserialize)]
1064struct NonStreamingChoice {
1065 message: ResponseMessage,
1066 #[serde(default)]
1067 finish_reason: Option<String>,
1068}
1069
1070#[derive(Debug, Deserialize)]
1074struct ResponseMessage {
1075 #[serde(default)]
1076 content: Option<String>,
1077 #[serde(default)]
1078 tool_calls: Option<Vec<ToolCallWire>>,
1079 #[serde(flatten)]
1080 extra: serde_json::Map<String, Value>,
1081}
1082
1083#[derive(Debug, Deserialize)]
1085struct ChatCompletionChunk {
1086 #[serde(default)]
1090 choices: Vec<StreamingChoice>,
1091 #[serde(default)]
1092 usage: Option<UsageWire>,
1093}
1094
1095#[derive(Debug, Deserialize)]
1096struct StreamingChoice {
1097 #[serde(default)]
1098 delta: DeltaMessage,
1099 #[serde(default)]
1103 finish_reason: Option<String>,
1104}
1105
1106#[derive(Debug, Default, Deserialize)]
1107struct DeltaMessage {
1108 #[serde(default)]
1109 content: Option<String>,
1110 #[serde(default)]
1111 tool_calls: Option<Vec<ToolCallDeltaWire>>,
1112 #[serde(flatten)]
1116 extra: serde_json::Map<String, Value>,
1117}
1118
1119#[derive(Debug, Deserialize)]
1120struct UsageWire {
1121 #[serde(default)]
1122 prompt_tokens: Option<usize>,
1123 #[serde(default)]
1124 completion_tokens: Option<usize>,
1125 #[serde(default)]
1126 prompt_tokens_details: Option<PromptTokensDetailsWire>,
1127 #[serde(default)]
1128 completion_tokens_details: Option<CompletionTokensDetailsWire>,
1129 #[serde(default)]
1130 input_tokens_details: Option<PromptTokensDetailsWire>,
1131 #[serde(default)]
1132 output_tokens_details: Option<CompletionTokensDetailsWire>,
1133}
1134
1135#[derive(Debug, Deserialize)]
1136struct PromptTokensDetailsWire {
1137 #[serde(default)]
1138 cached_tokens: Option<usize>,
1139}
1140
1141#[derive(Debug, Deserialize)]
1142struct CompletionTokensDetailsWire {
1143 #[serde(default)]
1144 reasoning_tokens: Option<usize>,
1145}
1146
1147fn token_usage_from_wire(usage: UsageWire) -> TokenUsage {
1148 let raw_prompt_tokens = usage.prompt_tokens.unwrap_or(0);
1149 let raw_completion_tokens = usage.completion_tokens.unwrap_or(0);
1150
1151 let cached_input_tokens = usage
1152 .prompt_tokens_details
1153 .as_ref()
1154 .and_then(|d| d.cached_tokens)
1155 .or_else(|| {
1156 usage
1157 .input_tokens_details
1158 .as_ref()
1159 .and_then(|d| d.cached_tokens)
1160 })
1161 .unwrap_or(0);
1162 let prompt_tokens = raw_prompt_tokens.saturating_sub(cached_input_tokens);
1169 let reasoning_output_tokens = usage
1170 .completion_tokens_details
1171 .as_ref()
1172 .and_then(|d| d.reasoning_tokens)
1173 .or_else(|| {
1174 usage
1175 .output_tokens_details
1176 .as_ref()
1177 .and_then(|d| d.reasoning_tokens)
1178 })
1179 .unwrap_or(0);
1180 let completion_tokens = raw_completion_tokens.saturating_sub(reasoning_output_tokens);
1181
1182 TokenUsage::provider(prompt_tokens, completion_tokens)
1183 .with_cached_input(cached_input_tokens)
1184 .with_reasoning_output(reasoning_output_tokens)
1185}
1186
1187#[derive(Debug, Deserialize, Serialize, Clone)]
1189struct ToolCallWire {
1190 #[serde(default)]
1191 id: Option<String>,
1192 function: FunctionWire,
1193}
1194
1195#[derive(Debug, Deserialize, Serialize, Clone)]
1196struct FunctionWire {
1197 name: String,
1198 #[serde(default)]
1202 arguments: String,
1203}
1204
1205#[derive(Debug, Deserialize)]
1209struct ToolCallDeltaWire {
1210 index: usize,
1211 #[serde(default)]
1212 id: Option<String>,
1213 #[serde(default)]
1214 function: Option<FunctionDeltaWire>,
1215}
1216
1217#[derive(Debug, Deserialize, Default)]
1218struct FunctionDeltaWire {
1219 #[serde(default)]
1220 name: Option<String>,
1221 #[serde(default)]
1222 arguments: Option<String>,
1223}
1224
1225#[derive(Debug, Default)]
1228struct PartialToolCall {
1229 id: Option<String>,
1230 name: Option<String>,
1231 arguments_buf: String,
1232}
1233
1234impl PartialToolCall {
1235 fn into_tool_call(self) -> Option<ToolCall> {
1236 let name = self.name?;
1237 let arguments: Value = if self.arguments_buf.is_empty() {
1240 json!({})
1241 } else {
1242 match serde_json::from_str(&self.arguments_buf) {
1243 Ok(v) => v,
1244 Err(_) => {
1245 Value::String(self.arguments_buf)
1249 },
1250 }
1251 };
1252 Some(ToolCall {
1253 id: self.id,
1254 function: FunctionCall { name, arguments },
1255 })
1256 }
1257}
1258
1259fn accumulate_tool_call(partials: &mut Vec<PartialToolCall>, delta: ToolCallDeltaWire) {
1260 if delta.index >= crate::constants::MAX_TOOL_CALLS {
1265 tracing::warn!(
1266 index = delta.index,
1267 "dropping tool-call delta with implausible index",
1268 );
1269 return;
1270 }
1271 while partials.len() <= delta.index {
1272 partials.push(PartialToolCall::default());
1273 }
1274 let slot = &mut partials[delta.index];
1275 if let Some(id) = delta.id {
1276 slot.id = Some(id);
1277 }
1278 if let Some(func) = delta.function {
1279 if let Some(name) = func.name {
1280 slot.name = Some(name);
1281 }
1282 if let Some(args) = func.arguments {
1283 push_tool_arg(&mut slot.arguments_buf, &args);
1284 }
1285 }
1286}
1287
1288fn parse_full_tool_call(wire: ToolCallWire) -> ToolCall {
1289 let name = wire.function.name;
1290 let arguments: Value = if wire.function.arguments.is_empty() {
1291 json!({})
1292 } else {
1293 match serde_json::from_str(&wire.function.arguments) {
1294 Ok(v) => v,
1295 Err(_) => Value::String(wire.function.arguments),
1296 }
1297 };
1298 ToolCall {
1299 id: wire.id,
1300 function: FunctionCall { name, arguments },
1301 }
1302}
1303
1304#[derive(Debug, Deserialize)]
1305struct ListModelsResponse {
1306 data: Vec<ModelInfo>,
1307}
1308
1309#[derive(Debug, Deserialize)]
1316struct ModelInfo {
1317 id: String,
1318 #[serde(default)]
1319 context_length: Option<usize>,
1320 #[serde(default)]
1321 context_window: Option<usize>,
1322 #[serde(default)]
1323 max_completion_tokens: Option<usize>,
1324 #[serde(default)]
1325 max_output_tokens: Option<usize>,
1326 #[serde(default)]
1327 max_output_length: Option<usize>,
1328 #[serde(default)]
1329 top_provider: Option<TopProviderInfo>,
1330}
1331
1332#[derive(Debug, Deserialize)]
1335struct TopProviderInfo {
1336 #[serde(default)]
1337 context_length: Option<usize>,
1338 #[serde(default)]
1339 max_completion_tokens: Option<usize>,
1340}
1341
1342#[derive(Debug, Clone, PartialEq, Eq)]
1344pub struct ModelListing {
1345 pub id: String,
1346 pub max_context_tokens: Option<usize>,
1347 pub max_output_tokens: Option<usize>,
1348}
1349
1350impl From<ModelInfo> for ModelListing {
1351 fn from(m: ModelInfo) -> Self {
1352 let top = m.top_provider.as_ref();
1353 ModelListing {
1354 max_context_tokens: m
1355 .context_length
1356 .or(m.context_window)
1357 .or_else(|| top.and_then(|t| t.context_length)),
1358 max_output_tokens: m
1359 .max_completion_tokens
1360 .or(m.max_output_tokens)
1361 .or(m.max_output_length)
1362 .or_else(|| top.and_then(|t| t.max_completion_tokens)),
1363 id: m.id,
1364 }
1365 }
1366}
1367
1368fn encode_query_value(value: &str) -> String {
1373 let mut out = String::with_capacity(value.len());
1374 for byte in value.bytes() {
1375 match byte {
1376 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
1377 out.push(byte as char);
1378 },
1379 other => out.push_str(&format!("%{other:02X}")),
1380 }
1381 }
1382 out
1383}
1384
1385#[derive(Debug, Deserialize)]
1392struct CfModelsSearchResponse {
1393 result: Vec<CfModelEntry>,
1394}
1395
1396#[derive(Debug, Deserialize)]
1397struct CfModelEntry {
1398 name: String,
1400 #[serde(default)]
1401 properties: Vec<CfModelProperty>,
1402}
1403
1404#[derive(Debug, Deserialize)]
1405struct CfModelProperty {
1406 property_id: String,
1407 #[serde(default)]
1408 value: serde_json::Value,
1409}
1410
1411impl From<CfModelEntry> for ModelListing {
1412 fn from(m: CfModelEntry) -> Self {
1413 let max_context_tokens = m
1414 .properties
1415 .iter()
1416 .find(|p| p.property_id == "context_window")
1417 .and_then(|p| p.value.as_str())
1418 .and_then(|s| s.parse().ok());
1419 ModelListing {
1420 id: m.name,
1421 max_context_tokens,
1422 max_output_tokens: None,
1423 }
1424 }
1425}
1426
1427const THINK_OPEN: &str = "<think>";
1444const THINK_CLOSE: &str = "</think>";
1445
1446#[derive(Debug, Default)]
1447pub(crate) struct ThinkTagState {
1448 pending: String,
1452 inside: bool,
1454}
1455
1456impl ThinkTagState {
1457 pub(crate) fn new() -> Self {
1458 Self::default()
1459 }
1460
1461 pub(crate) fn feed(&mut self, chunk: &str) -> (String, String) {
1464 let mut text = String::new();
1465 let mut reasoning = String::new();
1466 let mut buf = std::mem::take(&mut self.pending);
1469 buf.push_str(chunk);
1470
1471 let mut i = 0usize;
1472 while i < buf.len() {
1473 let marker = if self.inside { THINK_CLOSE } else { THINK_OPEN };
1476 let remaining = &buf[i..];
1477
1478 if let Some(idx) = remaining.find(marker) {
1480 let (before, _after) = remaining.split_at(idx);
1481 if self.inside {
1482 reasoning.push_str(before);
1483 } else {
1484 text.push_str(before);
1485 }
1486 self.inside = !self.inside;
1487 i += idx + marker.len();
1488 continue;
1489 }
1490
1491 let mut hold_len: Option<usize> = None;
1502 for back in (1..marker.len()).rev() {
1503 let candidate = &marker[..back];
1504 if remaining.ends_with(candidate) {
1505 hold_len = Some(back);
1506 break;
1507 }
1508 }
1509
1510 if let Some(back) = hold_len {
1511 let split_at = remaining.len() - back;
1512 let (before, hold) = remaining.split_at(split_at);
1513 if self.inside {
1514 reasoning.push_str(before);
1515 } else {
1516 text.push_str(before);
1517 }
1518 self.pending = hold.to_string();
1519 } else if self.inside {
1520 reasoning.push_str(remaining);
1521 } else {
1522 text.push_str(remaining);
1523 }
1524 break;
1525 }
1526
1527 (text, reasoning)
1528 }
1529
1530 pub(crate) fn flush(&mut self) -> (String, String) {
1535 let pending = std::mem::take(&mut self.pending);
1536 if self.inside {
1537 (String::new(), pending)
1538 } else {
1539 (pending, String::new())
1540 }
1541 }
1542}
1543
1544#[cfg(test)]
1545mod tests {
1546 use super::*;
1547 use crate::models::providers::lookup_provider;
1548
1549 #[test]
1550 fn model_listing_parses_provider_limit_shapes() {
1551 let openrouter: ListModelsResponse = serde_json::from_str(
1553 r#"{"data":[{"id":"z-ai/glm-5.2","context_length":1000000,
1554 "top_provider":{"context_length":1000000,"max_completion_tokens":32000}}]}"#,
1555 )
1556 .unwrap();
1557 let m = ModelListing::from(openrouter.data.into_iter().next().unwrap());
1558 assert_eq!(m.id, "z-ai/glm-5.2");
1559 assert_eq!(m.max_context_tokens, Some(1_000_000));
1560 assert_eq!(m.max_output_tokens, Some(32_000));
1561
1562 let flat: ListModelsResponse = serde_json::from_str(
1564 r#"{"data":[{"id":"m","context_window":128000,"max_output_tokens":16384}]}"#,
1565 )
1566 .unwrap();
1567 let m = ModelListing::from(flat.data.into_iter().next().unwrap());
1568 assert_eq!(m.max_context_tokens, Some(128_000));
1569 assert_eq!(m.max_output_tokens, Some(16_384));
1570
1571 let bare: ListModelsResponse =
1573 serde_json::from_str(r#"{"data":[{"id":"gpt-x","object":"model"}]}"#).unwrap();
1574 let m = ModelListing::from(bare.data.into_iter().next().unwrap());
1575 assert_eq!(m.max_context_tokens, None);
1576 assert_eq!(m.max_output_tokens, None);
1577 }
1578
1579 #[test]
1580 fn model_listing_parses_cloudflare_openrouter_shape() {
1581 let cf: ListModelsResponse = serde_json::from_str(
1585 r#"{"data":[{"id":"@cf/zai-org/glm-5.2","hugging_face_id":"zai-org/glm-5.2",
1586 "context_length":262144,"max_output_length":262144,
1587 "pricing":{"prompt":"0.0000014000","completion":"0.0000044000"}}]}"#,
1588 )
1589 .unwrap();
1590 let m = ModelListing::from(cf.data.into_iter().next().unwrap());
1591 assert_eq!(m.id, "@cf/zai-org/glm-5.2");
1592 assert_eq!(m.max_context_tokens, Some(262_144));
1593 assert_eq!(m.max_output_tokens, Some(262_144));
1594 }
1595
1596 #[test]
1597 fn cloudflare_models_search_default_format_parses_properties() {
1598 let body: CfModelsSearchResponse = serde_json::from_str(
1603 r#"{"success":true,"result":[
1604 {"name":"@cf/zai-org/glm-5.2","description":"agentic coding model",
1605 "properties":[
1606 {"property_id":"context_window","value":"262144"},
1607 {"property_id":"price",
1608 "value":[{"unit":"per M input tokens","price":1.4,"currency":"USD"}]},
1609 {"property_id":"function_calling","value":"true"}]},
1610 {"name":"@cf/meta/no-window","properties":[
1611 {"property_id":"function_calling","value":"true"}]},
1612 {"name":"@cf/meta/bare"}]}"#,
1613 )
1614 .unwrap();
1615 let listings: Vec<ModelListing> = body.result.into_iter().map(ModelListing::from).collect();
1616 assert_eq!(listings[0].id, "@cf/zai-org/glm-5.2");
1617 assert_eq!(listings[0].max_context_tokens, Some(262_144));
1618 assert_eq!(listings[0].max_output_tokens, None);
1620 assert_eq!(listings[1].max_context_tokens, None);
1621 assert_eq!(listings[2].max_context_tokens, None);
1622 }
1623
1624 #[test]
1625 fn query_value_encoding_escapes_reserved_bytes() {
1626 assert_eq!(encode_query_value("glm-5.2"), "glm-5.2");
1628 assert_eq!(
1630 encode_query_value("@cf/zai-org/glm-5.2"),
1631 "%40cf%2Fzai-org%2Fglm-5.2"
1632 );
1633 assert_eq!(encode_query_value("a b&c=d"), "a%20b%26c%3Dd");
1634 }
1635
1636 #[test]
1637 fn cloudflare_models_search_base_derives_only_from_account_scoped_url() {
1638 let cloudflare = lookup_provider("cloudflare").unwrap();
1639 let adapter = |base: &str, profile: &'static ProviderProfile| {
1640 OpenAICompatAdapter::new(
1641 profile,
1642 base.to_string(),
1643 Some("test-token".to_string()),
1644 "@cf/zai-org/glm-5.2".to_string(),
1645 HashMap::new(),
1646 )
1647 .expect("adapter constructs")
1648 };
1649 let a = adapter(
1651 "https://api.cloudflare.com/client/v4/accounts/abc123/ai/v1",
1652 cloudflare,
1653 );
1654 assert_eq!(
1655 a.cloudflare_models_search_base().as_deref(),
1656 Some("https://api.cloudflare.com/client/v4/accounts/abc123/ai/models/search"),
1657 );
1658 let a = adapter(
1660 "https://api.cloudflare.com/client/v4/accounts/abc123/ai/v1/",
1661 cloudflare,
1662 );
1663 assert!(a.cloudflare_models_search_base().is_some());
1664 let a = adapter(
1667 "https://gateway.ai.cloudflare.com/v1/abc/gw/workers-ai/v1",
1668 cloudflare,
1669 );
1670 assert_eq!(a.cloudflare_models_search_base(), None);
1671 let a = adapter(
1673 "https://api.cloudflare.com/client/v4/accounts/abc123/ai/v1",
1674 test_profile(),
1675 );
1676 assert_eq!(a.cloudflare_models_search_base(), None);
1677 }
1678
1679 #[test]
1680 fn maps_openai_finish_reasons() {
1681 assert_eq!(map_openai_finish_reason("stop"), FinishReason::Stop);
1682 assert_eq!(map_openai_finish_reason("length"), FinishReason::Length);
1683 assert_eq!(
1684 map_openai_finish_reason("tool_calls"),
1685 FinishReason::ToolUse
1686 );
1687 assert_eq!(
1688 map_openai_finish_reason("content_filter"),
1689 FinishReason::ContentFilter
1690 );
1691 }
1692
1693 #[test]
1694 fn stream_closed_abnormally_distinguishes_drop_from_completion() {
1695 assert!(stream_closed_abnormally(None));
1698 assert!(!stream_closed_abnormally(Some(&FinishReason::Stop)));
1700 assert!(!stream_closed_abnormally(Some(&FinishReason::ToolUse)));
1701 assert!(!stream_closed_abnormally(Some(&FinishReason::Length)));
1703 }
1704
1705 #[test]
1706 fn think_tags_stripped_via_feed_then_flush() {
1707 let mut ts = ThinkTagState::new();
1710 let (mut text, mut reasoning) = ts.feed("<think>weighing</think>answer");
1711 let (t2, r2) = ts.flush();
1712 text.push_str(&t2);
1713 reasoning.push_str(&r2);
1714 assert_eq!(text, "answer");
1715 assert_eq!(reasoning, "weighing");
1716 }
1717
1718 #[test]
1719 fn accumulate_tool_call_drops_implausible_index() {
1720 let mut partials: Vec<PartialToolCall> = Vec::new();
1722 let delta: ToolCallDeltaWire =
1723 serde_json::from_value(serde_json::json!({"index": 1_000_000})).unwrap();
1724 accumulate_tool_call(&mut partials, delta);
1725 assert!(partials.is_empty(), "huge index must be dropped");
1726
1727 let ok: ToolCallDeltaWire =
1729 serde_json::from_value(serde_json::json!({"index": 0, "function": {"name": "x"}}))
1730 .unwrap();
1731 accumulate_tool_call(&mut partials, ok);
1732 assert_eq!(partials.len(), 1);
1733 }
1734
1735 fn test_profile() -> &'static ProviderProfile {
1736 lookup_provider("openai").expect("openai is in the registry")
1737 }
1738
1739 fn test_adapter() -> OpenAICompatAdapter {
1740 OpenAICompatAdapter::new(
1741 test_profile(),
1742 "https://api.openai.com/v1".to_string(),
1743 Some("test-key".to_string()),
1744 "gpt-5-mini".to_string(),
1745 HashMap::new(),
1746 )
1747 .expect("adapter constructs")
1748 }
1749
1750 #[test]
1751 fn chat_completion_chunk_parses_usage_only_frame() {
1752 let chunk: ChatCompletionChunk = serde_json::from_str(
1755 r#"{"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}"#,
1756 )
1757 .expect("usage-only frame must parse");
1758 assert!(chunk.choices.is_empty());
1759 assert!(chunk.usage.is_some());
1760 }
1761
1762 #[test]
1763 fn reasoning_models_omit_temperature_per_catalog() {
1764 use crate::models::catalog::lookup;
1765 for m in [
1766 "o1",
1767 "o1-mini",
1768 "o3",
1769 "o3-mini",
1770 "o4-mini",
1771 "gpt-5",
1772 "gpt-5-mini",
1773 ] {
1774 assert!(
1775 !lookup(m).supports_temperature,
1776 "{m} should omit temperature"
1777 );
1778 }
1779 for m in ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "chatgpt-4o-latest"] {
1780 assert!(
1781 lookup(m).supports_temperature,
1782 "{m} should send temperature"
1783 );
1784 }
1785 }
1786
1787 #[test]
1788 fn token_usage_from_wire_derives_total_from_components() {
1789 let usage = token_usage_from_wire(UsageWire {
1790 prompt_tokens: Some(100),
1791 completion_tokens: Some(25),
1792 prompt_tokens_details: None,
1793 completion_tokens_details: None,
1794 input_tokens_details: None,
1795 output_tokens_details: None,
1796 });
1797
1798 assert_eq!(usage.prompt_tokens, 100);
1799 assert_eq!(usage.completion_tokens, 25);
1800 assert_eq!(usage.total_tokens(), 125);
1801 }
1802
1803 #[test]
1804 fn token_usage_from_wire_keeps_components_disjoint() {
1805 let usage = token_usage_from_wire(UsageWire {
1809 prompt_tokens: Some(100),
1810 completion_tokens: Some(25),
1811 prompt_tokens_details: Some(PromptTokensDetailsWire {
1812 cached_tokens: Some(40),
1813 }),
1814 completion_tokens_details: Some(CompletionTokensDetailsWire {
1815 reasoning_tokens: Some(12),
1816 }),
1817 input_tokens_details: None,
1818 output_tokens_details: None,
1819 });
1820
1821 assert_eq!(usage.prompt_tokens, 60);
1822 assert_eq!(usage.cached_input_tokens, 40);
1823 assert_eq!(usage.completion_tokens, 13);
1824 assert_eq!(usage.reasoning_output_tokens, 12);
1825 assert_eq!(usage.total_tokens(), 125);
1826 }
1827
1828 #[test]
1829 fn cache_hit_does_not_double_count_input_total() {
1830 let usage = token_usage_from_wire(UsageWire {
1833 prompt_tokens: Some(100),
1834 completion_tokens: Some(25),
1835 prompt_tokens_details: Some(PromptTokensDetailsWire {
1836 cached_tokens: Some(40),
1837 }),
1838 completion_tokens_details: None,
1839 input_tokens_details: None,
1840 output_tokens_details: None,
1841 });
1842 assert_eq!(usage.input_total_tokens(), 100);
1843 assert_eq!(usage.prompt_tokens, 60);
1844 assert_eq!(usage.cached_input_tokens, 40);
1845 }
1846
1847 #[test]
1848 fn reasoning_does_not_double_count_output_total() {
1849 let usage = token_usage_from_wire(UsageWire {
1852 prompt_tokens: Some(10),
1853 completion_tokens: Some(100),
1854 prompt_tokens_details: None,
1855 completion_tokens_details: Some(CompletionTokensDetailsWire {
1856 reasoning_tokens: Some(40),
1857 }),
1858 input_tokens_details: None,
1859 output_tokens_details: None,
1860 });
1861 assert_eq!(usage.completion_tokens, 60);
1862 assert_eq!(usage.reasoning_output_tokens, 40);
1863 assert_eq!(usage.output_total_tokens(), 100);
1864 }
1865
1866 #[test]
1867 fn capabilities_reflect_profile() {
1868 let adapter = test_adapter();
1869 let caps = adapter.capabilities();
1870 assert!(caps.supports_tools);
1871 assert!(caps.supports_vision);
1874 match &caps.supports_reasoning {
1875 ReasoningCapability::Levels(levels) => {
1876 assert!(levels.contains(&ReasoningLevel::Medium));
1877 assert!(levels.contains(&ReasoningLevel::Max));
1878 },
1879 other => panic!("expected Levels for openai, got {:?}", other),
1880 }
1881 }
1882
1883 #[test]
1884 fn model_vision_detection_is_model_driven() {
1885 for vision in [
1886 "gpt-4o",
1887 "gpt-4o-mini",
1888 "gpt-5-mini",
1889 "openai/gpt-4.1",
1890 "anthropic/claude-3.5-sonnet",
1891 "google/gemini-2.0-flash",
1892 "qwen/qwen2.5-vl-7b-instruct",
1893 "mistralai/pixtral-12b",
1894 "meta-llama/llama-4-scout",
1895 ] {
1896 assert!(
1897 crate::models::catalog::lookup(vision).vision,
1898 "{vision} should be detected as vision-capable"
1899 );
1900 }
1901 for text_only in [
1902 "gpt-3.5-turbo",
1903 "groq/llama-3.3-70b-versatile",
1904 "deepseek-r1",
1905 "mistralai/mistral-7b-instruct",
1906 "qwen/qwen2.5-coder-32b",
1907 ] {
1908 assert!(
1909 !crate::models::catalog::lookup(text_only).vision,
1910 "{text_only} should be detected as text-only"
1911 );
1912 }
1913 }
1914
1915 #[test]
1916 fn capabilities_unsupported_for_no_reasoning_provider() {
1917 let together = lookup_provider("together").unwrap();
1918 let adapter = OpenAICompatAdapter::new(
1919 together,
1920 together.base_url.to_string(),
1921 Some("k".to_string()),
1922 "deepseek-r1".to_string(),
1923 HashMap::new(),
1924 )
1925 .unwrap();
1926 assert_eq!(
1927 adapter.capabilities().supports_reasoning,
1928 ReasoningCapability::Unsupported
1929 );
1930 }
1931
1932 #[test]
1933 fn name_returns_model_name() {
1934 let adapter = test_adapter();
1935 assert_eq!(adapter.name(), "gpt-5-mini");
1936 }
1937
1938 #[test]
1943 fn model_directed_system_messages_reach_the_wire_in_place() {
1944 use crate::models::ChatMessageKind;
1945 let adapter = test_adapter();
1946 let mut nudge = ChatMessage::system("Reminder: plan mode is active.");
1947 nudge.kind = ChatMessageKind::RecoveryNudge;
1948 let messages = vec![ChatMessage::user("ok"), nudge];
1949 let body = adapter.build_request_body(&messages, &ModelConfig::default(), false);
1950
1951 let msgs = body["messages"].as_array().expect("messages array");
1952 let last = msgs.last().expect("non-empty");
1953 assert_eq!(last["role"], "system", "delivered as a system turn");
1954 assert!(
1955 last["content"]
1956 .as_str()
1957 .unwrap()
1958 .contains("plan mode is active"),
1959 );
1960 }
1961
1962 #[test]
1963 fn build_request_body_includes_basic_fields() {
1964 let adapter = test_adapter();
1965 let messages = vec![ChatMessage::user("hello")];
1966 let config = ModelConfig::default();
1967 let body = adapter.build_request_body(&messages, &config, true);
1968 assert_eq!(body["model"], "gpt-5-mini");
1969 assert_eq!(body["stream"], true);
1970 assert!(body["messages"].is_array());
1971 assert_eq!(body["reasoning_effort"], "medium");
1973 }
1974
1975 #[test]
1976 fn build_request_body_maps_output_schema_to_response_format() {
1977 let adapter = test_adapter();
1978 let messages = vec![ChatMessage::user("format it")];
1979 let config = ModelConfig {
1980 output_schema: Some(serde_json::json!({
1981 "type": "object",
1982 "properties": {"answer": {"type": "integer"}}
1983 })),
1984 ..Default::default()
1985 };
1986 let body = adapter.build_request_body(&messages, &config, false);
1987 assert_eq!(body["response_format"]["type"], "json_schema");
1988 assert_eq!(body["response_format"]["json_schema"]["name"], "output");
1989 assert_eq!(body["response_format"]["json_schema"]["strict"], false);
1990 assert_eq!(
1991 body["response_format"]["json_schema"]["schema"]["type"],
1992 "object"
1993 );
1994 let body = adapter.build_request_body(&messages, &ModelConfig::default(), false);
1996 assert!(body.get("response_format").is_none());
1997 }
1998
1999 #[test]
2000 fn build_request_body_serializes_tool_calls_in_openai_shape() {
2001 let adapter = test_adapter();
2006 let tc = crate::models::tool_call::ToolCall {
2007 id: Some("call_abc".to_string()),
2008 function: crate::models::tool_call::FunctionCall {
2009 name: "read_file".to_string(),
2010 arguments: serde_json::json!({"path": "src/main.rs"}),
2011 },
2012 };
2013 let messages = vec![ChatMessage::assistant("").with_tool_calls(vec![tc])];
2014 let body = adapter.build_request_body(&messages, &ModelConfig::default(), false);
2015 let msgs = body["messages"].as_array().unwrap();
2016 let assistant = msgs
2017 .iter()
2018 .find(|m| m["role"] == "assistant")
2019 .expect("assistant message present");
2020 let call = &assistant["tool_calls"][0];
2021 assert_eq!(call["type"], "function");
2022 assert_eq!(call["id"], "call_abc");
2023 assert_eq!(call["function"]["name"], "read_file");
2024 let args = call["function"]["arguments"]
2025 .as_str()
2026 .expect("arguments must be a JSON-encoded string, not an object");
2027 assert!(args.contains("\"path\"") && args.contains("src/main.rs"));
2028 }
2029
2030 #[test]
2031 fn build_request_body_wires_user_images_as_vision_parts() {
2032 let adapter = test_adapter();
2035 let messages =
2036 vec![ChatMessage::user("what is this").with_images(vec!["BASE64DATA".to_string()])];
2037 let body = adapter.build_request_body(&messages, &ModelConfig::default(), false);
2038 let msgs = body["messages"].as_array().unwrap();
2039 let user = msgs
2040 .iter()
2041 .find(|m| m["role"] == "user")
2042 .expect("user message present");
2043 let parts = user["content"]
2044 .as_array()
2045 .expect("content must be an array when images are present");
2046 assert!(
2047 parts
2048 .iter()
2049 .any(|p| p["type"] == "text" && p["text"] == "what is this")
2050 );
2051 let image = parts
2052 .iter()
2053 .find(|p| p["type"] == "image_url")
2054 .expect("an image_url part");
2055 assert_eq!(
2056 image["image_url"]["url"],
2057 "data:image/png;base64,BASE64DATA"
2058 );
2059 }
2060
2061 #[test]
2062 fn build_request_body_plain_user_message_keeps_string_content() {
2063 let adapter = test_adapter();
2066 let body =
2067 adapter.build_request_body(&[ChatMessage::user("hi")], &ModelConfig::default(), false);
2068 let msgs = body["messages"].as_array().unwrap();
2069 let user = msgs.iter().find(|m| m["role"] == "user").unwrap();
2070 assert!(user["content"].is_string());
2071 assert_eq!(user["content"], "hi");
2072 }
2073
2074 #[test]
2075 fn build_request_body_includes_system_prompt() {
2076 let adapter = test_adapter();
2077 let messages = vec![ChatMessage::user("hi")];
2078 let config = ModelConfig {
2079 system_prompt: Some("You are a helpful assistant.".to_string()),
2080 ..Default::default()
2081 };
2082 let body = adapter.build_request_body(&messages, &config, false);
2083 let messages_arr = body["messages"].as_array().unwrap();
2084 assert_eq!(messages_arr[0]["role"], "system");
2085 assert_eq!(messages_arr[0]["content"], "You are a helpful assistant.");
2086 }
2087
2088 #[test]
2093 fn build_request_body_concats_dynamic_suffix_to_system_message() {
2094 let adapter = test_adapter();
2095 let messages = vec![ChatMessage::user("hi")];
2096 let config = ModelConfig {
2097 system_prompt: Some("You are Mermaid.".to_string()),
2098 dynamic_system_suffix: Some("Project rule: always snake_case.".to_string()),
2099 ..Default::default()
2100 };
2101 let body = adapter.build_request_body(&messages, &config, false);
2102 let messages_arr = body["messages"].as_array().unwrap();
2103 assert_eq!(messages_arr[0]["role"], "system");
2104 let content = messages_arr[0]["content"].as_str().unwrap();
2105 assert!(content.contains("You are Mermaid."));
2106 assert!(content.contains("Project rule: always snake_case."));
2107 assert!(content.contains("---"));
2108 }
2109
2110 #[test]
2111 fn build_request_body_includes_tools_and_omits_temperature_for_reasoning() {
2112 let adapter = test_adapter();
2115 let messages = vec![ChatMessage::user("hi")];
2116 let config = ModelConfig {
2119 tools: (0..5)
2120 .map(|i| {
2121 serde_json::json!({
2122 "type": "function",
2123 "function": {
2124 "name": format!("tool_{}", i),
2125 "description": "a test tool",
2126 "parameters": {"type": "object"}
2127 }
2128 })
2129 })
2130 .collect(),
2131 ..Default::default()
2132 };
2133 let body = adapter.build_request_body(&messages, &config, true);
2134 assert!(body["tools"].is_array());
2135 assert_eq!(body["tools"].as_array().unwrap().len(), 5);
2136 assert!(
2137 body.get("temperature").is_none(),
2138 "a reasoning model must omit temperature, got {:?}",
2139 body.get("temperature")
2140 );
2141 }
2142
2143 #[test]
2144 fn build_request_body_preserves_registry_selected_web_tools() {
2145 let adapter = test_adapter();
2146 let config = ModelConfig {
2147 tools: ["web_fetch", "web_search"]
2148 .into_iter()
2149 .map(|name| {
2150 serde_json::json!({
2151 "type": "function",
2152 "function": {
2153 "name": name,
2154 "description": "registered web tool",
2155 "parameters": {"type": "object"}
2156 }
2157 })
2158 })
2159 .collect(),
2160 ..Default::default()
2161 };
2162
2163 let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false);
2164 let names: Vec<&str> = body["tools"]
2165 .as_array()
2166 .expect("tools array")
2167 .iter()
2168 .filter_map(|tool| tool.pointer("/function/name").and_then(Value::as_str))
2169 .collect();
2170 assert_eq!(names, ["web_fetch", "web_search"]);
2171 }
2172
2173 #[test]
2174 fn build_request_body_includes_temperature_for_non_reasoning_model() {
2175 let adapter = OpenAICompatAdapter::new(
2177 test_profile(),
2178 "https://api.openai.com/v1".to_string(),
2179 Some("test-key".to_string()),
2180 "gpt-4o".to_string(),
2181 HashMap::new(),
2182 )
2183 .expect("adapter constructs");
2184 let config = ModelConfig::default();
2185 let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false);
2186 assert_eq!(body["temperature"], config.temperature);
2187 }
2188
2189 #[test]
2190 fn cerebras_uses_supported_token_budget_field() {
2191 let cerebras = lookup_provider("cerebras").unwrap();
2192 let adapter = OpenAICompatAdapter::new(
2193 cerebras,
2194 cerebras.base_url.to_string(),
2195 Some("k".to_string()),
2196 "gpt-oss-120b".to_string(),
2197 HashMap::new(),
2198 )
2199 .unwrap();
2200 let messages = vec![ChatMessage::user("hi")];
2201 let config = ModelConfig {
2202 max_tokens: 1234,
2203 ..Default::default()
2204 };
2205 let body = adapter.build_request_body(&messages, &config, true);
2206 assert_eq!(body["max_completion_tokens"], 1234);
2207 assert!(body.get("max_tokens").is_none());
2208 }
2209
2210 #[test]
2211 fn auto_max_tokens_omits_the_cap_field() {
2212 let groq = lookup_provider("groq").unwrap();
2215 let adapter = OpenAICompatAdapter::new(
2216 groq,
2217 groq.base_url.to_string(),
2218 Some("k".to_string()),
2219 "qwen-qwq-32b".to_string(),
2220 HashMap::new(),
2221 )
2222 .unwrap();
2223 let config = ModelConfig {
2224 max_tokens: 0,
2225 ..Default::default()
2226 };
2227 let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, true);
2228 assert!(body.get("max_tokens").is_none());
2229 assert!(body.get("max_completion_tokens").is_none());
2230 }
2231
2232 #[test]
2233 fn cerebras_gpt_oss_disables_parallel_tool_calls() {
2234 let cerebras = lookup_provider("cerebras").unwrap();
2235 let adapter = OpenAICompatAdapter::new(
2236 cerebras,
2237 cerebras.base_url.to_string(),
2238 Some("k".to_string()),
2239 "gpt-oss-120b".to_string(),
2240 HashMap::new(),
2241 )
2242 .unwrap();
2243 let messages = vec![ChatMessage::user("hi")];
2244 let config = ModelConfig {
2245 tools: vec![serde_json::json!({
2246 "type": "function",
2247 "function": {
2248 "name": "read_file",
2249 "description": "read a file",
2250 "parameters": {"type": "object"}
2251 }
2252 })],
2253 ..Default::default()
2254 };
2255 let body = adapter.build_request_body(&messages, &config, true);
2256 assert_eq!(body["parallel_tool_calls"], false);
2257 }
2258
2259 #[test]
2260 fn build_request_body_omits_reasoning_for_none_strategy() {
2261 let together = lookup_provider("together").unwrap();
2262 let adapter = OpenAICompatAdapter::new(
2263 together,
2264 together.base_url.to_string(),
2265 Some("k".to_string()),
2266 "deepseek-r1".to_string(),
2267 HashMap::new(),
2268 )
2269 .unwrap();
2270 let messages = vec![ChatMessage::user("hi")];
2271 let config = ModelConfig::default();
2272 let body = adapter.build_request_body(&messages, &config, true);
2273 assert!(body.get("reasoning_effort").is_none());
2274 assert!(body.get("reasoning").is_none());
2275 }
2276
2277 #[test]
2282 fn build_request_body_emits_xhigh_for_xhigh_level() {
2283 let adapter = test_adapter();
2284 let messages = vec![ChatMessage::user("hi")];
2285 let config = ModelConfig {
2286 reasoning: ReasoningLevel::XHigh,
2287 ..Default::default()
2288 };
2289 let body = adapter.build_request_body(&messages, &config, true);
2290 assert_eq!(body["reasoning_effort"], "xhigh");
2291 }
2292
2293 #[test]
2297 fn build_request_body_emits_none_for_none_level() {
2298 let adapter = test_adapter();
2299 let messages = vec![ChatMessage::user("hi")];
2300 let config = ModelConfig {
2301 reasoning: ReasoningLevel::None,
2302 ..Default::default()
2303 };
2304 let body = adapter.build_request_body(&messages, &config, true);
2305 assert_eq!(body["reasoning_effort"], "none");
2306 }
2307
2308 #[test]
2313 fn build_request_body_preserves_minimal_for_effort_strategy() {
2314 let adapter = test_adapter();
2315 let messages = vec![ChatMessage::user("hi")];
2316 let config = ModelConfig {
2317 reasoning: ReasoningLevel::Minimal,
2318 ..Default::default()
2319 };
2320 let body = adapter.build_request_body(&messages, &config, true);
2321 assert_eq!(body["reasoning_effort"], "minimal");
2322 }
2323
2324 #[test]
2329 fn build_request_body_snaps_minimal_to_low_for_openrouter() {
2330 let openrouter = lookup_provider("openrouter").unwrap();
2331 let adapter = OpenAICompatAdapter::new(
2332 openrouter,
2333 openrouter.base_url.to_string(),
2334 Some("k".to_string()),
2335 "anthropic/claude-3.7-sonnet".to_string(),
2336 HashMap::new(),
2337 )
2338 .unwrap();
2339 let messages = vec![ChatMessage::user("hi")];
2340 let config = ModelConfig {
2341 reasoning: ReasoningLevel::Minimal,
2342 ..Default::default()
2343 };
2344 let body = adapter.build_request_body(&messages, &config, true);
2345 assert_eq!(body["reasoning"], json!({"exclude": true}));
2349 }
2350
2351 #[test]
2352 fn build_request_body_uses_openrouter_shape() {
2353 let openrouter = lookup_provider("openrouter").unwrap();
2354 let adapter = OpenAICompatAdapter::new(
2355 openrouter,
2356 openrouter.base_url.to_string(),
2357 Some("k".to_string()),
2358 "anthropic/claude-3.7-sonnet".to_string(),
2359 HashMap::new(),
2360 )
2361 .unwrap();
2362 let messages = vec![ChatMessage::user("hi")];
2363 let config = ModelConfig {
2364 reasoning: ReasoningLevel::High,
2365 ..Default::default()
2366 };
2367 let body = adapter.build_request_body(&messages, &config, true);
2368 assert_eq!(body["reasoning"], json!({"effort": "high"}));
2369 assert!(body.get("reasoning_effort").is_none());
2370 }
2371
2372 #[test]
2373 fn tool_call_accumulator_assembles_fragmented_args() {
2374 let mut partials: Vec<PartialToolCall> = Vec::new();
2378
2379 accumulate_tool_call(
2380 &mut partials,
2381 ToolCallDeltaWire {
2382 index: 0,
2383 id: Some("call_abc".to_string()),
2384 function: Some(FunctionDeltaWire {
2385 name: Some("get_weather".to_string()),
2386 arguments: Some(String::new()),
2387 }),
2388 },
2389 );
2390 accumulate_tool_call(
2391 &mut partials,
2392 ToolCallDeltaWire {
2393 index: 0,
2394 id: None,
2395 function: Some(FunctionDeltaWire {
2396 name: None,
2397 arguments: Some("{\"loc".to_string()),
2398 }),
2399 },
2400 );
2401 accumulate_tool_call(
2402 &mut partials,
2403 ToolCallDeltaWire {
2404 index: 0,
2405 id: None,
2406 function: Some(FunctionDeltaWire {
2407 name: None,
2408 arguments: Some("\":\"SF\"}".to_string()),
2409 }),
2410 },
2411 );
2412
2413 let tc = partials
2414 .into_iter()
2415 .next()
2416 .unwrap()
2417 .into_tool_call()
2418 .unwrap();
2419 assert_eq!(tc.id.as_deref(), Some("call_abc"));
2420 assert_eq!(tc.function.name, "get_weather");
2421 assert_eq!(tc.function.arguments, json!({"loc": "SF"}));
2422 }
2423
2424 #[test]
2425 fn tool_call_accumulator_handles_empty_args() {
2426 let mut partials: Vec<PartialToolCall> = Vec::new();
2427 accumulate_tool_call(
2428 &mut partials,
2429 ToolCallDeltaWire {
2430 index: 0,
2431 id: Some("call_x".to_string()),
2432 function: Some(FunctionDeltaWire {
2433 name: Some("list_windows".to_string()),
2434 arguments: None,
2435 }),
2436 },
2437 );
2438 let tc = partials
2439 .into_iter()
2440 .next()
2441 .unwrap()
2442 .into_tool_call()
2443 .unwrap();
2444 assert_eq!(tc.function.arguments, json!({}));
2445 }
2446
2447 #[test]
2448 fn tool_call_accumulator_handles_multiple_indices() {
2449 let mut partials: Vec<PartialToolCall> = Vec::new();
2452 accumulate_tool_call(
2453 &mut partials,
2454 ToolCallDeltaWire {
2455 index: 0,
2456 id: Some("call_a".to_string()),
2457 function: Some(FunctionDeltaWire {
2458 name: Some("fn_a".to_string()),
2459 arguments: Some("{}".to_string()),
2460 }),
2461 },
2462 );
2463 accumulate_tool_call(
2464 &mut partials,
2465 ToolCallDeltaWire {
2466 index: 1,
2467 id: Some("call_b".to_string()),
2468 function: Some(FunctionDeltaWire {
2469 name: Some("fn_b".to_string()),
2470 arguments: Some("{}".to_string()),
2471 }),
2472 },
2473 );
2474
2475 let parsed: Vec<_> = partials
2476 .into_iter()
2477 .filter_map(|p| p.into_tool_call())
2478 .collect();
2479 assert_eq!(parsed.len(), 2);
2480 assert_eq!(parsed[0].function.name, "fn_a");
2481 assert_eq!(parsed[1].function.name, "fn_b");
2482 }
2483
2484 #[test]
2487 fn think_state_passes_plain_text_through() {
2488 let mut s = ThinkTagState::new();
2489 let (text, reasoning) = s.feed("hello world, no tags here");
2490 assert_eq!(text, "hello world, no tags here");
2491 assert!(reasoning.is_empty());
2492 let (tail_text, tail_reasoning) = s.flush();
2493 assert!(tail_text.is_empty());
2494 assert!(tail_reasoning.is_empty());
2495 }
2496
2497 #[test]
2498 fn think_state_extracts_complete_tag_pair_in_one_chunk() {
2499 let mut s = ThinkTagState::new();
2500 let (text, reasoning) = s.feed("before<think>reasoning content</think>after");
2501 assert_eq!(text, "beforeafter");
2502 assert_eq!(reasoning, "reasoning content");
2503 }
2504
2505 #[test]
2506 fn think_state_handles_tag_split_across_chunks() {
2507 let mut s = ThinkTagState::new();
2508 let (text1, reasoning1) = s.feed("before<thi");
2510 assert_eq!(text1, "before");
2511 assert!(reasoning1.is_empty());
2512 let (text2, reasoning2) = s.feed("nk>X</think>after");
2514 assert_eq!(text2, "after");
2515 assert_eq!(reasoning2, "X");
2516 }
2517
2518 #[test]
2519 fn think_state_handles_closing_tag_split() {
2520 let mut s = ThinkTagState::new();
2521 let (text1, reasoning1) = s.feed("<think>weighing options</thi");
2522 assert!(text1.is_empty());
2523 assert_eq!(reasoning1, "weighing options");
2524 let (text2, reasoning2) = s.feed("nk>final answer");
2525 assert_eq!(text2, "final answer");
2526 assert!(reasoning2.is_empty());
2527 }
2528
2529 #[test]
2530 fn think_state_handles_multiple_tag_pairs() {
2531 let mut s = ThinkTagState::new();
2532 let (text, reasoning) = s.feed("a<think>r1</think>b<think>r2</think>c");
2533 assert_eq!(text, "abc");
2534 assert_eq!(reasoning, "r1r2");
2537 }
2538
2539 #[test]
2540 fn think_state_preserves_cjk_inside_tags() {
2541 let mut s = ThinkTagState::new();
2542 let (text, reasoning) = s.feed("英語<think>思考中</think>結果");
2543 assert_eq!(text, "英語結果");
2544 assert_eq!(reasoning, "思考中");
2545 }
2546
2547 #[test]
2548 fn think_state_flush_emits_partial_tag_as_text() {
2549 let mut s = ThinkTagState::new();
2550 let (text1, _) = s.feed("hello<thi");
2553 assert_eq!(text1, "hello");
2554 let (text_tail, reasoning_tail) = s.flush();
2555 assert_eq!(text_tail, "<thi");
2556 assert!(reasoning_tail.is_empty());
2557 }
2558
2559 #[test]
2560 fn think_state_does_not_match_other_angle_brackets() {
2561 let mut s = ThinkTagState::new();
2562 let (text, reasoning) = s.feed("<other>tag-like</other> and <not a tag");
2563 assert_eq!(text, "<other>tag-like</other> and <not a tag");
2568 assert!(reasoning.is_empty());
2569 }
2570
2571 #[test]
2572 fn truncation_marker_preserved_byte_for_byte() {
2573 let mut buf = String::new();
2577 let mut t = false;
2578 push_capped(&mut buf, &"a".repeat(50), &mut t, 10);
2579 assert!(t);
2580 assert!(buf.ends_with(TRUNCATION_MARKER));
2581 }
2582}