use crate::constants::{MAX_RESPONSE_CHARS, MAX_TOOL_ARG_BYTES, MAX_TOOL_CALLS};
use crate::models::error::{BackendError, ModelError, ResponseDebugContext};
use crate::models::types::FinishReason;
pub(super) const TRUNCATION_MARKER: &str = "\n\n[TRUNCATED: response exceeded size limit]";
pub(super) const MAX_ERROR_BODY_BYTES: usize = 64 * 1024;
#[derive(Debug)]
pub(super) struct CappedText {
buf: String,
cap: usize,
truncated: bool,
}
impl Default for CappedText {
fn default() -> Self {
Self::new()
}
}
impl CappedText {
pub(super) const fn new() -> Self {
Self::with_cap(MAX_RESPONSE_CHARS)
}
pub(super) const fn with_cap(cap: usize) -> Self {
Self {
buf: String::new(),
cap,
truncated: false,
}
}
pub(super) fn push(&mut self, chunk: &str) {
push_capped(&mut self.buf, chunk, &mut self.truncated, self.cap);
}
pub(super) fn accepting(&self) -> bool {
!self.truncated
}
#[cfg(test)]
pub(super) fn truncated(&self) -> bool {
self.truncated
}
#[cfg(test)]
pub(super) fn as_str(&self) -> &str {
&self.buf
}
pub(super) fn is_empty(&self) -> bool {
self.buf.is_empty()
}
pub(super) fn into_string(self) -> String {
self.buf
}
}
pub(super) fn push_capped(buf: &mut String, chunk: &str, truncated: &mut bool, cap: usize) {
if *truncated {
return;
}
buf.push_str(chunk);
if buf.len() > cap {
let end = buf.floor_char_boundary(cap);
buf.truncate(end);
buf.push_str(TRUNCATION_MARKER);
*truncated = true;
}
}
pub(super) fn push_tool_arg(buf: &mut String, frag: &str) {
let cap = MAX_TOOL_ARG_BYTES;
if buf.len() >= cap {
return;
}
let room = cap - buf.len();
if frag.len() <= room {
buf.push_str(frag);
} else {
let end = frag.floor_char_boundary(room);
buf.push_str(&frag[..end]);
}
}
pub(super) fn slot_in_bounds(index: usize) -> bool {
index < MAX_TOOL_CALLS
}
pub(super) fn parse_tool_args(tool: &str, raw: String) -> serde_json::Value {
match serde_json::from_str::<serde_json::Value>(&raw) {
Ok(value) => value,
Err(error) => {
tracing::warn!(
tool,
bytes = raw.len(),
%error,
"tool arguments did not parse; passing the raw text through"
);
serde_json::Value::String(raw)
},
}
}
pub(super) fn ended_without_terminal(finish_reason: Option<&FinishReason>) -> bool {
finish_reason.is_none()
}
pub(super) async fn error_body(mut response: reqwest::Response, fallback: &str) -> String {
let mut buf: Vec<u8> = Vec::new();
let mut truncated = false;
while let Ok(Some(bytes)) = response.chunk().await {
let room = MAX_ERROR_BODY_BYTES.saturating_sub(buf.len());
if bytes.len() > room {
buf.extend_from_slice(&bytes[..room]);
truncated = true;
break;
}
buf.extend_from_slice(&bytes);
}
if buf.is_empty() {
return fallback.to_string();
}
let mut text = String::from_utf8_lossy(&buf).into_owned();
if truncated {
text.push_str("\n[error body truncated]");
}
crate::utils::redact_secrets(&text)
}
pub(super) async fn http_error(response: reqwest::Response, fallback: &str) -> ModelError {
let status = response.status().as_u16();
let debug = ResponseDebugContext::from_headers(response.headers());
let message = error_body(response, fallback).await;
ModelError::Backend(BackendError::HttpError {
status,
message,
debug,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn capped_text_marks_once_and_then_ignores() {
let mut t = CappedText::with_cap(10);
t.push("hello");
assert!(t.accepting());
t.push(" world!!");
assert!(t.truncated());
assert!(!t.accepting());
let cut = t.as_str().to_string();
t.push("more");
assert_eq!(t.as_str(), cut, "a full buffer ignores later chunks");
assert!(cut.starts_with("hello worl"));
assert!(cut.ends_with(TRUNCATION_MARKER));
assert_eq!(cut.matches(TRUNCATION_MARKER).count(), 1);
}
#[test]
fn capped_text_cuts_at_a_char_boundary() {
let mut t = CappedText::with_cap(4);
t.push("ä½ ä½ ä½ ä½ ");
assert!(t.truncated());
assert!(t.as_str().starts_with('ä½ '));
assert!(t.as_str().is_char_boundary(3));
}
#[test]
fn reasoning_and_content_truncate_independently() {
let mut reasoning = CappedText::with_cap(8);
let mut content = CappedText::with_cap(8);
reasoning.push("a very long thought");
assert!(reasoning.truncated());
assert!(
content.accepting(),
"one buffer's cap must not gate the other"
);
content.push("answer");
assert_eq!(content.as_str(), "answer");
}
#[test]
fn tool_arg_buffer_is_bounded() {
let mut buf = String::new();
push_tool_arg(&mut buf, &"x".repeat(MAX_TOOL_ARG_BYTES + 100));
assert_eq!(buf.len(), MAX_TOOL_ARG_BYTES);
push_tool_arg(&mut buf, "more");
assert_eq!(buf.len(), MAX_TOOL_ARG_BYTES);
}
#[test]
fn slot_bound_matches_the_tool_call_cap() {
assert!(slot_in_bounds(0));
assert!(slot_in_bounds(MAX_TOOL_CALLS - 1));
assert!(!slot_in_bounds(MAX_TOOL_CALLS));
}
#[test]
fn unparseable_tool_args_fall_back_to_the_raw_text() {
assert_eq!(
parse_tool_args("t", r#"{"a":1}"#.to_string()),
serde_json::json!({"a": 1})
);
assert_eq!(
parse_tool_args("t", r#"{"a":"#.to_string()),
serde_json::Value::String(r#"{"a":"#.to_string())
);
}
#[test]
fn a_missing_finish_reason_is_an_abnormal_close() {
assert!(ended_without_terminal(None));
assert!(!ended_without_terminal(Some(&FinishReason::Length)));
assert!(!ended_without_terminal(Some(&FinishReason::Stop)));
}
fn response_with_body(status: u16, body: String) -> reqwest::Response {
http::Response::builder()
.status(status)
.body(body)
.expect("test response")
.into()
}
#[tokio::test]
async fn error_body_is_redacted() {
let body = r#"{"error":"bad header: Authorization: Bearer sk-abcdefghijklmnopqrstuvwxyz0123456789"}"#;
let text = error_body(response_with_body(401, body.to_string()), "fallback").await;
assert!(!text.contains("sk-abcdefghijklmnopqrstuvwxyz"), "{text}");
assert!(text.contains("bad header"), "{text}");
}
#[tokio::test]
async fn error_body_is_capped() {
let body = "x".repeat(MAX_ERROR_BODY_BYTES * 3);
let text = error_body(response_with_body(500, body), "fallback").await;
assert!(text.len() < MAX_ERROR_BODY_BYTES + 64, "{}", text.len());
assert!(text.ends_with("[error body truncated]"));
}
#[tokio::test]
async fn an_empty_error_body_reads_as_the_fallback() {
let text = error_body(
response_with_body(502, String::new()),
"gateway said nothing",
)
.await;
assert_eq!(text, "gateway said nothing");
let err = http_error(response_with_body(503, String::new()), "down").await;
match err {
ModelError::Backend(BackendError::HttpError {
status, message, ..
}) => {
assert_eq!(status, 503);
assert_eq!(message, "down");
},
other => panic!("expected HttpError, got {other:?}"),
}
}
}