move formats/openai.rs into goose-providers crate, along with several dependencies (#9633)

This commit is contained in:
Jack Amadeo
2026-06-08 11:26:33 -04:00
committed by GitHub
parent 4904e3c856
commit e5fd568e01
85 changed files with 1847 additions and 1793 deletions
+8 -2
View File
@@ -13,11 +13,14 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
async-stream = { workspace = true }
base64 = { workspace = true }
chrono = { workspace = true }
futures = { workspace = true }
once_cell = { workspace = true }
regex = { workspace = true }
rmcp = { workspace = true, features = ["server"] }
regex = { workspace = true, features = ["unicode"] }
reqwest = { workspace = true }
rmcp = { workspace = true, features = ["server", "macros"] }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
@@ -28,3 +31,6 @@ uuid = { workspace = true, features = ["v4", "std"] }
[dev-dependencies]
test-case = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }
+17
View File
@@ -0,0 +1,17 @@
use std::future::Future;
pub struct Error;
pub struct Model {
pub name: String,
}
pub struct StreamingRequest {
pub model: Model,
}
pub struct StreamingResponse;
pub trait Provider {
fn stream(req: StreamingRequest) -> impl Future<Output = Result<StreamingResponse, Error>>;
}
@@ -7,6 +7,7 @@ use thiserror::Error;
use utoipa::ToSchema;
pub mod message;
pub mod token_usage;
mod tool_result_serde;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)]
@@ -0,0 +1,148 @@
use std::ops::{Add, AddAssign};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderUsage {
pub model: String,
pub usage: Usage,
}
impl ProviderUsage {
pub fn new(model: String, usage: Usage) -> Self {
Self { model, usage }
}
/// Combine this ProviderUsage with another, adding their token counts
/// Uses the model from this ProviderUsage
pub fn combine_with(&self, other: &ProviderUsage) -> ProviderUsage {
ProviderUsage {
model: self.model.clone(),
usage: self.usage + other.usage,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, Copy)]
pub struct Usage {
pub input_tokens: Option<i32>,
pub output_tokens: Option<i32>,
pub total_tokens: Option<i32>,
pub cache_read_input_tokens: Option<i32>,
pub cache_write_input_tokens: Option<i32>,
}
fn sum_optionals<T>(a: Option<T>, b: Option<T>) -> Option<T>
where
T: Add<Output = T> + Default,
{
match (a, b) {
(Some(x), Some(y)) => Some(x + y),
(Some(x), None) => Some(x + T::default()),
(None, Some(y)) => Some(T::default() + y),
(None, None) => None,
}
}
impl Add for Usage {
type Output = Self;
fn add(self, other: Self) -> Self {
Self::new(
sum_optionals(self.input_tokens, other.input_tokens),
sum_optionals(self.output_tokens, other.output_tokens),
sum_optionals(self.total_tokens, other.total_tokens),
)
.with_cache_tokens(
sum_optionals(self.cache_read_input_tokens, other.cache_read_input_tokens),
sum_optionals(
self.cache_write_input_tokens,
other.cache_write_input_tokens,
),
)
}
}
impl AddAssign for Usage {
fn add_assign(&mut self, rhs: Self) {
*self = *self + rhs;
}
}
impl Usage {
pub fn new(
input_tokens: Option<i32>,
output_tokens: Option<i32>,
total_tokens: Option<i32>,
) -> Self {
let calculated_total = if total_tokens.is_none() {
match (input_tokens, output_tokens) {
(Some(input), Some(output)) => Some(input + output),
(Some(input), None) => Some(input),
(None, Some(output)) => Some(output),
(None, None) => None,
}
} else {
total_tokens
};
Self {
input_tokens,
output_tokens,
total_tokens: calculated_total,
cache_read_input_tokens: None,
cache_write_input_tokens: None,
}
}
pub fn with_cache_tokens(
mut self,
cache_read_input_tokens: Option<i32>,
cache_write_input_tokens: Option<i32>,
) -> Self {
self.cache_read_input_tokens = cache_read_input_tokens;
self.cache_write_input_tokens = cache_write_input_tokens;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use serde_json::json;
#[test]
fn test_usage_serialization() -> Result<()> {
let usage = Usage::new(Some(10), Some(20), Some(30));
let serialized = serde_json::to_string(&usage)?;
let deserialized: Usage = serde_json::from_str(&serialized)?;
assert_eq!(usage.input_tokens, deserialized.input_tokens);
assert_eq!(usage.output_tokens, deserialized.output_tokens);
assert_eq!(usage.total_tokens, deserialized.total_tokens);
// Test JSON structure
let json_value: serde_json::Value = serde_json::from_str(&serialized)?;
assert_eq!(json_value["input_tokens"], json!(10));
assert_eq!(json_value["output_tokens"], json!(20));
assert_eq!(json_value["total_tokens"], json!(30));
Ok(())
}
#[test]
fn test_usage_addition_includes_cached_tokens() {
let usage_a =
Usage::new(Some(100), Some(20), Some(120)).with_cache_tokens(Some(10), Some(5));
let usage_b = Usage::new(Some(50), Some(8), Some(58)).with_cache_tokens(Some(4), Some(1));
let combined = usage_a + usage_b;
assert_eq!(combined.input_tokens, Some(150));
assert_eq!(combined.output_tokens, Some(28));
assert_eq!(combined.total_tokens, Some(178));
assert_eq!(combined.cache_read_input_tokens, Some(14));
assert_eq!(combined.cache_write_input_tokens, Some(6));
}
}
+161
View File
@@ -0,0 +1,161 @@
use reqwest::StatusCode;
use std::time::Duration;
use thiserror::Error;
#[derive(Error, Debug, Clone, PartialEq)]
pub enum ProviderError {
#[error("Authentication error: {0}")]
Authentication(String),
#[error("Context length exceeded: {0}")]
ContextLengthExceeded(String),
#[error("Rate limit exceeded: {details}")]
RateLimitExceeded {
details: String,
retry_delay: Option<Duration>,
},
#[error("Server error: {0}")]
ServerError(String),
#[error("Network error: {0}")]
NetworkError(String),
#[error("Request failed: {0}")]
RequestFailed(String),
#[error("Execution error: {0}")]
ExecutionError(String),
#[error("Usage data error: {0}")]
UsageError(String),
#[error("Unsupported operation: {0}")]
NotImplemented(String),
#[error("Endpoint not found (404): {0}")]
EndpointNotFound(String),
#[error("Credits exhausted: {details}")]
CreditsExhausted {
details: String,
top_up_url: Option<String>,
},
}
impl ProviderError {
pub fn telemetry_type(&self) -> &'static str {
match self {
ProviderError::Authentication(_) => "auth",
ProviderError::ContextLengthExceeded(_) => "context_length",
ProviderError::RateLimitExceeded { .. } => "rate_limit",
ProviderError::ServerError(_) => "server",
ProviderError::NetworkError(_) => "network",
ProviderError::RequestFailed(_) => "request",
ProviderError::ExecutionError(_) => "execution",
ProviderError::UsageError(_) => "usage",
ProviderError::NotImplemented(_) => "not_implemented",
ProviderError::EndpointNotFound(_) => "endpoint_not_found",
ProviderError::CreditsExhausted { .. } => "credits_exhausted",
}
}
pub fn is_endpoint_not_found(&self) -> bool {
matches!(self, ProviderError::EndpointNotFound(_))
}
}
fn is_network_error(err: &reqwest::Error) -> bool {
err.is_connect() || err.is_timeout() || (err.status().is_none() && err.is_request())
}
fn provider_error_from_reqwest(error: &reqwest::Error) -> ProviderError {
if is_network_error(error) {
let msg = if error.is_timeout() {
"Request timed out — check your network connection and try again.".to_string()
} else if error.is_connect() {
if let Some(url) = error.url() {
if let Some(host) = url.host_str() {
let port_info = url.port().map(|p| format!(":{}", p)).unwrap_or_default();
format!(
"Could not connect to {}{} — check your network connection and try again.",
host, port_info
)
} else {
"Could not connect to the provider — check your network connection and try again.".to_string()
}
} else {
"Could not connect to the provider — check your network connection and try again."
.to_string()
}
} else {
"Network error — check your network connection and try again.".to_string()
};
return ProviderError::NetworkError(msg);
}
let mut details = vec![];
if let Some(status) = error.status() {
details.push(format!("status: {}", status));
}
let msg = if details.is_empty() {
error.to_string()
} else {
format!("{} ({})", error, details.join(", "))
};
ProviderError::RequestFailed(msg)
}
impl From<anyhow::Error> for ProviderError {
fn from(error: anyhow::Error) -> Self {
if let Some(reqwest_err) = error.downcast_ref::<reqwest::Error>() {
return provider_error_from_reqwest(reqwest_err);
}
ProviderError::ExecutionError(error.to_string())
}
}
impl From<reqwest::Error> for ProviderError {
fn from(error: reqwest::Error) -> Self {
provider_error_from_reqwest(&error)
}
}
#[derive(Debug)]
pub enum GoogleErrorCode {
BadRequest = 400,
Unauthorized = 401,
Forbidden = 403,
NotFound = 404,
TooManyRequests = 429,
InternalServerError = 500,
ServiceUnavailable = 503,
}
impl GoogleErrorCode {
pub fn to_status_code(&self) -> StatusCode {
match self {
Self::BadRequest => StatusCode::BAD_REQUEST,
Self::Unauthorized => StatusCode::UNAUTHORIZED,
Self::Forbidden => StatusCode::FORBIDDEN,
Self::NotFound => StatusCode::NOT_FOUND,
Self::TooManyRequests => StatusCode::TOO_MANY_REQUESTS,
Self::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
Self::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE,
}
}
pub fn from_code(code: u64) -> Option<Self> {
match code {
400 => Some(Self::BadRequest),
401 => Some(Self::Unauthorized),
403 => Some(Self::Forbidden),
404 => Some(Self::NotFound),
429 => Some(Self::TooManyRequests),
500 => Some(Self::InternalServerError),
503 => Some(Self::ServiceUnavailable),
_ => Some(Self::InternalServerError),
}
}
}
+1
View File
@@ -0,0 +1 @@
pub mod openai;
File diff suppressed because it is too large Load Diff
+218
View File
@@ -0,0 +1,218 @@
use std::{io::Read as _, path::Path};
use base64::Engine as _;
use rmcp::model::{AnnotateAble as _, ImageContent, RawImageContent};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::errors::ProviderError;
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
pub enum ImageFormat {
OpenAi,
Anthropic,
}
/// Convert an image content into an image json based on format
pub fn convert_image(image: &ImageContent, image_format: &ImageFormat) -> Value {
match image_format {
ImageFormat::OpenAi => json!({
"type": "image_url",
"image_url": {
"url": format!("data:{};base64,{}", image.mime_type, image.data)
}
}),
ImageFormat::Anthropic => json!({
"type": "image",
"source": {
"type": "base64",
"media_type": image.mime_type,
"data": image.data,
}
}),
}
}
/// Detect if a string contains a path to an image file
pub fn detect_image_path(text: &str) -> Option<&str> {
// Basic image file extension check
let extensions = [".png", ".jpg", ".jpeg"];
// Find any word that ends with an image extension
for word in text.split_whitespace() {
if extensions
.iter()
.any(|ext| word.to_lowercase().ends_with(ext))
{
let path = Path::new(word);
// Check if it's an absolute path and file exists
if path.is_absolute() && path.is_file() {
// Verify it's actually an image file
if is_image_file(path) {
return Some(word);
}
}
}
}
None
}
/// Check if a file is actually an image by examining its magic bytes
fn is_image_file(path: &Path) -> bool {
if let Ok(mut file) = std::fs::File::open(path) {
let mut buffer = [0u8; 8]; // Large enough for most image magic numbers
if file.read(&mut buffer).is_ok() {
// Check magic numbers for common image formats
return match &buffer[0..4] {
// PNG: 89 50 4E 47
[0x89, 0x50, 0x4E, 0x47] => true,
// JPEG: FF D8 FF
[0xFF, 0xD8, 0xFF, _] => true,
// GIF: 47 49 46 38
[0x47, 0x49, 0x46, 0x38] => true,
_ => false,
};
}
}
false
}
/// Convert a local image file to base64 encoded ImageContent
pub fn load_image_file(path: &str) -> Result<ImageContent, ProviderError> {
let path = Path::new(path);
// Verify it's an image before proceeding
if !is_image_file(path) {
return Err(ProviderError::RequestFailed(
"File is not a valid image".to_string(),
));
}
// Read the file
let bytes = std::fs::read(path)
.map_err(|e| ProviderError::RequestFailed(format!("Failed to read image file: {}", e)))?;
// Detect mime type from extension
let mime_type = match path.extension().and_then(|e| e.to_str()) {
Some(ext) => match ext.to_lowercase().as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
_ => {
return Err(ProviderError::RequestFailed(
"Unsupported image format".to_string(),
))
}
},
None => {
return Err(ProviderError::RequestFailed(
"Unknown image format".to_string(),
))
}
};
// Convert to base64
let data = base64::prelude::BASE64_STANDARD.encode(&bytes);
Ok(RawImageContent {
mime_type: mime_type.to_string(),
data,
meta: None,
}
.no_annotation())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile;
#[test]
fn test_detect_image_path() {
// Create a temporary PNG file with valid PNG magic numbers
let temp_dir = tempfile::tempdir().unwrap();
let png_path = temp_dir.path().join("test.png");
let png_data = [
0x89, 0x50, 0x4E, 0x47, // PNG magic number
0x0D, 0x0A, 0x1A, 0x0A, // PNG header
0x00, 0x00, 0x00, 0x0D, // Rest of fake PNG data
];
std::fs::write(&png_path, png_data).unwrap();
let png_path_str = png_path.to_str().unwrap();
// Create a fake PNG (wrong magic numbers)
let fake_png_path = temp_dir.path().join("fake.png");
std::fs::write(&fake_png_path, b"not a real png").unwrap();
// Test with valid PNG file using absolute path
let text = format!("Here is an image {}", png_path_str);
assert_eq!(detect_image_path(&text), Some(png_path_str));
// Test with non-image file that has .png extension
let text = format!("Here is a fake image {}", fake_png_path.to_str().unwrap());
assert_eq!(detect_image_path(&text), None);
// Test with nonexistent file
let text = "Here is a fake.png that doesn't exist";
assert_eq!(detect_image_path(text), None);
// Test with non-image file
let text = "Here is a file.txt";
assert_eq!(detect_image_path(text), None);
// Test with relative path (should not match)
let text = "Here is a relative/path/image.png";
assert_eq!(detect_image_path(text), None);
}
#[test]
fn test_load_image_file() {
// Create a temporary PNG file with valid PNG magic numbers
let temp_dir = tempfile::tempdir().unwrap();
let png_path = temp_dir.path().join("test.png");
let png_data = [
0x89, 0x50, 0x4E, 0x47, // PNG magic number
0x0D, 0x0A, 0x1A, 0x0A, // PNG header
0x00, 0x00, 0x00, 0x0D, // Rest of fake PNG data
];
std::fs::write(&png_path, png_data).unwrap();
let png_path_str = png_path.to_str().unwrap();
// Create a fake PNG (wrong magic numbers)
let fake_png_path = temp_dir.path().join("fake.png");
std::fs::write(&fake_png_path, b"not a real png").unwrap();
let fake_png_path_str = fake_png_path.to_str().unwrap();
// Test loading valid PNG file
let result = load_image_file(png_path_str);
assert!(result.is_ok());
let image = result.unwrap();
assert_eq!(image.mime_type, "image/png");
// Test loading fake PNG file
let result = load_image_file(fake_png_path_str);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("not a valid image"));
// Test nonexistent file
let result = load_image_file("nonexistent.png");
assert!(result.is_err());
// Create a GIF file with valid header bytes
let gif_path = temp_dir.path().join("test.gif");
// Minimal GIF89a header
let gif_data = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
std::fs::write(&gif_path, gif_data).unwrap();
let gif_path_str = gif_path.to_str().unwrap();
// Test loading unsupported GIF format
let result = load_image_file(gif_path_str);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Unsupported image format"));
}
}
+221
View File
@@ -0,0 +1,221 @@
/// Safely parse a JSON string that may contain doubly-encoded or malformed JSON.
/// This function first attempts to parse the input string as-is. If that fails,
/// it applies control character escaping and truncated JSON repair and tries again.
///
/// This approach preserves valid JSON like `{"key1": "value1",\n"key2": "value"}`
/// (which contains a literal \n but is perfectly valid JSON) while still fixing
/// broken JSON like `{"key1": "value1\n","key2": "value"}` (which contains an
/// unescaped newline character).
pub fn safely_parse_json(s: &str) -> Result<serde_json::Value, serde_json::Error> {
// First, try parsing the string as-is
match serde_json::from_str(s) {
Ok(value) => Ok(value),
Err(_) => {
for candidate in [
repair_truncated_json(s),
json_escape_control_chars_in_string(s),
] {
if let Ok(value) = serde_json::from_str(&candidate) {
return Ok(value);
}
}
let repaired = repair_truncated_json(&json_escape_control_chars_in_string(s));
serde_json::from_str(&repaired)
}
}
}
fn repair_truncated_json(s: &str) -> String {
let mut repaired = String::with_capacity(s.len() + 8);
let mut in_string = false;
let mut escape_next = false;
let mut closers = Vec::new();
for c in s.chars() {
repaired.push(c);
if in_string {
if escape_next {
escape_next = false;
continue;
}
match c {
'\\' => escape_next = true,
'"' => in_string = false,
_ => {}
}
continue;
}
match c {
'"' => in_string = true,
'{' => closers.push('}'),
'[' => closers.push(']'),
'}' | ']' => {
if closers.last() == Some(&c) {
closers.pop();
}
}
_ => {}
}
}
if in_string {
if escape_next {
repaired.push('\\');
}
repaired.push('"');
}
while let Some(closer) = closers.pop() {
repaired.push(closer);
}
repaired
}
/// Helper to escape control characters in a string that is supposed to be a JSON document.
/// This function iterates through the input string `s` and replaces any literal
/// control characters (U+0000 to U+001F) with their JSON-escaped equivalents
/// (e.g., '\n' becomes "\\n", '\u0001' becomes "\\u0001").
///
/// It does NOT escape quotes (") or backslashes (\) because it assumes `s` is a
/// full JSON document, and these characters might be structural (e.g., object delimiters,
/// existing valid escape sequences). The goal is to fix common LLM errors where
/// control characters are emitted raw into what should be JSON string values,
/// making the overall JSON structure unparsable.
///
/// If the input string `s` has other JSON syntax errors (e.g., an unescaped quote
/// *within* a string value like `{"key": "string with " quote"}`), this function
/// will not fix them. It specifically targets unescaped control characters.
pub fn json_escape_control_chars_in_string(s: &str) -> String {
let mut r = String::with_capacity(s.len()); // Pre-allocate for efficiency
for c in s.chars() {
match c {
// ASCII Control characters (U+0000 to U+001F)
'\u{0000}'..='\u{001F}' => {
match c {
'\u{0008}' => r.push_str("\\b"), // Backspace
'\u{000C}' => r.push_str("\\f"), // Form feed
'\n' => r.push_str("\\n"), // Line feed
'\r' => r.push_str("\\r"), // Carriage return
'\t' => r.push_str("\\t"), // Tab
// Other control characters (e.g., NUL, SOH, VT, etc.)
// that don't have a specific short escape sequence.
_ => {
r.push_str(&format!("\\u{:04x}", c as u32));
}
}
}
// Other characters are passed through.
// This includes quotes (") and backslashes (\). If these are part of the
// JSON structure (e.g. {"key": "value"}) or part of an already correctly
// escaped sequence within a string value (e.g. "string with \\\" quote"),
// they are preserved as is. This function does not attempt to fix
// malformed quote or backslash usage *within* string values if the LLM
// generates them incorrectly (e.g. {"key": "unescaped " quote in string"}).
_ => r.push(c),
}
}
r
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_safely_parse_json() {
// Test valid JSON that should parse without escaping (contains proper escape sequence)
let valid_json = r#"{"key1": "value1","key2": "value2"}"#;
let result = safely_parse_json(valid_json).unwrap();
assert_eq!(result["key1"], "value1");
assert_eq!(result["key2"], "value2");
// Test JSON with actual unescaped newlines that needs escaping
let invalid_json = "{\"key1\": \"value1\n\",\"key2\": \"value2\"}";
let result = safely_parse_json(invalid_json).unwrap();
assert_eq!(result["key1"], "value1\n");
assert_eq!(result["key2"], "value2");
// Test already valid JSON - should parse on first try
let good_json = r#"{"test": "value"}"#;
let result = safely_parse_json(good_json).unwrap();
assert_eq!(result["test"], "value");
// Test truncated JSON with unclosed string, object, and array
let truncated_json = r#"{"key": "unclosed_string","nested": {"items": [1, 2, 3"#;
let result = safely_parse_json(truncated_json).unwrap();
assert_eq!(result["key"], "unclosed_string");
assert_eq!(result["nested"]["items"], json!([1, 2, 3]));
// Test dangling backslash at end of a truncated string
let dangling_escape_json = String::from(r#"{"path":"abc\"#);
let result = safely_parse_json(&dangling_escape_json).unwrap();
assert_eq!(result["path"], "abc\\");
// Test empty object
let empty_json = "{}";
let result = safely_parse_json(empty_json).unwrap();
assert!(result.as_object().unwrap().is_empty());
// Test JSON with escaped newlines (valid JSON) - should parse on first try
let escaped_json = r#"{"key": "value with\nnewline"}"#;
let result = safely_parse_json(escaped_json).unwrap();
assert_eq!(result["key"], "value with\nnewline");
}
#[test]
fn test_json_escape_control_chars_in_string() {
// Test basic control character escaping
assert_eq!(
json_escape_control_chars_in_string("Hello\nWorld"),
"Hello\\nWorld"
);
assert_eq!(
json_escape_control_chars_in_string("Hello\tWorld"),
"Hello\\tWorld"
);
assert_eq!(
json_escape_control_chars_in_string("Hello\rWorld"),
"Hello\\rWorld"
);
// Test multiple control characters
assert_eq!(
json_escape_control_chars_in_string("Hello\n\tWorld\r"),
"Hello\\n\\tWorld\\r"
);
// Test that quotes and backslashes are preserved (not escaped)
assert_eq!(
json_escape_control_chars_in_string("Hello \"World\""),
"Hello \"World\""
);
assert_eq!(
json_escape_control_chars_in_string("Hello\\World"),
"Hello\\World"
);
// Test JSON-like string with control characters
assert_eq!(
json_escape_control_chars_in_string("{\"message\": \"Hello\nWorld\"}"),
"{\"message\": \"Hello\\nWorld\"}"
);
// Test no changes for normal strings
assert_eq!(
json_escape_control_chars_in_string("Hello World"),
"Hello World"
);
// Test other control characters get unicode escapes
assert_eq!(
json_escape_control_chars_in_string("Hello\u{0001}World"),
"Hello\\u0001World"
);
}
}
+8 -2
View File
@@ -1,4 +1,10 @@
pub mod base;
pub mod canonical;
pub mod conversation;
mod mcp_utils;
mod utils;
pub mod errors;
pub mod formats;
pub mod images;
pub mod json;
pub(crate) mod mcp_utils;
pub mod thinking;
pub mod utils;
+587
View File
@@ -0,0 +1,587 @@
use std::{fmt, str::FromStr, sync::LazyLock};
use regex::Regex;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
pub const GEMINI_THOUGHT_SIGNATURE_KEY: &str = "thoughtSignature";
pub fn split_think_blocks(text: &str) -> (String, String) {
let mut filter = ThinkFilter::new();
let mut out = filter.push(text);
let final_out = filter.finish();
out.content.push_str(&final_out.content);
out.thinking.push_str(&final_out.thinking);
(out.content, out.thinking)
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct FilterOut {
pub content: String,
pub thinking: String,
}
pub struct ThinkFilter {
buffer: String,
inside_think: bool,
think_depth: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ThinkTag {
Open,
Close,
SelfClosing,
}
enum BufferEvent {
Tag {
pos: usize,
end: usize,
kind: ThinkTag,
},
Partial(usize),
}
impl ThinkFilter {
pub fn new() -> Self {
Self {
buffer: String::new(),
inside_think: false,
think_depth: 0,
}
}
pub fn push(&mut self, chunk: &str) -> FilterOut {
self.buffer.push_str(chunk);
self.process_buffer()
}
pub fn finish(mut self) -> FilterOut {
let mut out = self.process_buffer();
if !self.buffer.is_empty() {
if self.inside_think {
out.thinking.push_str(&self.buffer);
} else {
out.content.push_str(&self.buffer);
}
self.buffer.clear();
}
out
}
fn process_buffer(&mut self) -> FilterOut {
let mut out = FilterOut::default();
loop {
match next_buffer_event(&self.buffer, self.inside_think) {
Some(BufferEvent::Tag { pos, end, kind }) => {
if pos > 0 {
let prefix = self.buffer.get(..pos).unwrap_or_default().to_string();
if self.inside_think {
out.thinking.push_str(&prefix);
} else {
out.content.push_str(&prefix);
}
}
self.buffer.drain(..end);
match kind {
ThinkTag::Open => {
self.think_depth += 1;
self.inside_think = true;
}
ThinkTag::Close => {
self.think_depth = self.think_depth.saturating_sub(1);
self.inside_think = self.think_depth > 0;
}
ThinkTag::SelfClosing => {}
}
}
Some(BufferEvent::Partial(pos)) => {
if pos > 0 {
let prefix = self.buffer.get(..pos).unwrap_or_default().to_string();
if self.inside_think {
out.thinking.push_str(&prefix);
} else {
out.content.push_str(&prefix);
}
self.buffer.drain(..pos);
}
break;
}
None => {
if !self.buffer.is_empty() {
if self.inside_think {
out.thinking.push_str(&self.buffer);
} else {
out.content.push_str(&self.buffer);
}
self.buffer.clear();
}
break;
}
}
}
out
}
}
impl Default for ThinkFilter {
fn default() -> Self {
Self::new()
}
}
fn next_buffer_event(buffer: &str, inside_think: bool) -> Option<BufferEvent> {
let mut search_from = 0;
while let Some(rel_pos) = buffer.get(search_from..).and_then(|rest| rest.find('<')) {
let pos = search_from + rel_pos;
let suffix = buffer.get(pos..).unwrap_or_default();
if let Some((kind, end)) = parse_think_tag(buffer, pos) {
if inside_think || matches!(kind, ThinkTag::Open | ThinkTag::SelfClosing) {
return Some(BufferEvent::Tag { pos, end, kind });
}
} else if !contains_unquoted_gt(suffix) && is_possible_partial_think_tag(suffix) {
return Some(BufferEvent::Partial(pos));
}
search_from = pos + 1;
}
None
}
fn parse_think_tag(buffer: &str, start: usize) -> Option<(ThinkTag, usize)> {
let bytes = buffer.as_bytes();
if bytes.get(start) != Some(&b'<') {
return None;
}
let mut idx = start + 1;
let is_close = if bytes.get(idx) == Some(&b'/') {
idx += 1;
true
} else {
false
};
let name_start = idx;
while bytes.get(idx).is_some_and(u8::is_ascii_alphabetic) {
idx += 1;
}
if idx == name_start {
return None;
}
let name = buffer.get(name_start..idx).unwrap_or_default();
let is_think = name.eq_ignore_ascii_case("think") || name.eq_ignore_ascii_case("thinking");
if !is_think {
return None;
}
if is_close {
while bytes.get(idx).is_some_and(u8::is_ascii_whitespace) {
idx += 1;
}
if bytes.get(idx) == Some(&b'>') {
return Some((ThinkTag::Close, idx + 1));
}
return None;
}
// Require a real tag boundary immediately after the name (>, /, or whitespace).
// Without this, `<thinking-mode>` or `<thinking123>` would be classified as a
// think tag and stripped from normal content.
let valid_open_boundary = match bytes.get(idx) {
Some(&b) => b == b'>' || b == b'/' || b.is_ascii_whitespace(),
None => false,
};
if !valid_open_boundary {
return None;
}
let mut quote: Option<u8> = None;
let mut last_non_ws: Option<u8> = None;
while let Some(&byte) = bytes.get(idx) {
match quote {
Some(quote_byte) => {
if byte == quote_byte {
quote = None;
}
}
None if matches!(byte, b'"' | b'\'') => {
quote = Some(byte);
last_non_ws = Some(byte);
}
None if byte == b'>' => {
let kind = if last_non_ws == Some(b'/') {
ThinkTag::SelfClosing
} else {
ThinkTag::Open
};
return Some((kind, idx + 1));
}
None if !byte.is_ascii_whitespace() => {
last_non_ws = Some(byte);
}
None => {}
}
idx += 1;
}
None
}
fn is_possible_partial_think_tag(suffix: &str) -> bool {
if contains_unquoted_gt(suffix) {
return false;
}
// Allow a trailing `/` so a chunk boundary that lands between `<think` and
// `>` in a self-closing `<think/>` (or `<thinking/>`) is still recognised
// as a partial tag and buffered until the `>` arrives in the next chunk.
static OPEN_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?is)^<(?:t(?:h(?:i(?:n(?:k(?:i(?:n(?:g)?)?)?)?)?)?)?)(?:\s.*|/)?$").unwrap()
});
static CLOSE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?is)^</(?:t(?:h(?:i(?:n(?:k(?:i(?:n(?:g)?)?)?)?)?)?)?)(?:\s*)?$").unwrap()
});
OPEN_RE.is_match(suffix) || CLOSE_RE.is_match(suffix)
}
fn contains_unquoted_gt(text: &str) -> bool {
let mut quote: Option<u8> = None;
for &byte in text.as_bytes() {
match quote {
Some(quote_byte) => {
if byte == quote_byte {
quote = None;
}
}
None if matches!(byte, b'"' | b'\'') => quote = Some(byte),
None if byte == b'>' => return true,
None => {}
}
}
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum ThinkingEffort {
Off,
Low,
Medium,
High,
Max,
}
impl FromStr for ThinkingEffort {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"off" | "disabled" | "none" => Ok(Self::Off),
"low" => Ok(Self::Low),
"medium" | "med" => Ok(Self::Medium),
"high" => Ok(Self::High),
"max" | "xhigh" => Ok(Self::Max),
other => Err(format!("unknown thinking effort: '{other}'")),
}
}
}
impl fmt::Display for ThinkingEffort {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Off => write!(f, "off"),
Self::Low => write!(f, "low"),
Self::Medium => write!(f, "medium"),
Self::High => write!(f, "high"),
Self::Max => write!(f, "max"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_think_blocks_extracts_inline_reasoning() {
assert_eq!(
split_think_blocks("<think>x</think>y"),
("y".to_string(), "x".to_string())
);
}
#[test]
fn test_split_think_blocks_is_case_insensitive() {
assert_eq!(
split_think_blocks("<THINK>x</think>y"),
("y".to_string(), "x".to_string())
);
}
#[test]
fn test_split_think_blocks_handles_multiple_blocks() {
assert_eq!(
split_think_blocks("<think>a</think>b<think>c</think>d"),
("bd".to_string(), "ac".to_string())
);
}
#[test]
fn test_split_think_blocks_without_tags() {
assert_eq!(
split_think_blocks("plain content"),
("plain content".to_string(), String::new())
);
}
#[test]
fn test_split_think_blocks_handles_attributes() {
assert_eq!(
split_think_blocks(r#"<think class="x">a</think>b"#),
("b".to_string(), "a".to_string())
);
}
#[test]
fn test_split_think_blocks_handles_quoted_gt_in_self_closing_attributes() {
for input in [
r#"<think data="a>b"/>Visible"#,
"<think data='a>b'/>Visible",
] {
assert_eq!(
split_think_blocks(input),
("Visible".to_string(), String::new()),
"mismatch for {input:?}"
);
}
}
#[test]
fn test_split_think_blocks_handles_quoted_gt_in_open_attributes() {
assert_eq!(
split_think_blocks(r#"<think data="a>b">Hidden</think>Visible"#),
("Visible".to_string(), "Hidden".to_string())
);
}
#[test]
fn test_split_think_blocks_handles_thinking_variant() {
assert_eq!(
split_think_blocks("<thinking>a</thinking>b"),
("b".to_string(), "a".to_string())
);
}
#[test]
fn test_think_filter_streaming_across_partial_tags() {
let mut filter = ThinkFilter::new();
let mut out = FilterOut::default();
for chunk in ["<thi", "nk>x</thi", "nk>y"] {
let partial = filter.push(chunk);
out.content.push_str(&partial.content);
out.thinking.push_str(&partial.thinking);
}
let final_out = filter.finish();
out.content.push_str(&final_out.content);
out.thinking.push_str(&final_out.thinking);
assert_eq!(out.content, "y");
assert_eq!(out.thinking, "x");
}
#[test]
fn test_think_filter_preserves_non_think_tags() {
let mut filter = ThinkFilter::new();
let mut out = filter.push("<table>");
let final_out = filter.finish();
out.content.push_str(&final_out.content);
out.thinking.push_str(&final_out.thinking);
assert_eq!(out.content, "<table>");
assert!(out.thinking.is_empty());
}
#[test]
fn test_think_filter_finish_treats_unterminated_think_as_thinking() {
let mut filter = ThinkFilter::new();
let mut out = filter.push("<think>unfinished");
let final_out = filter.finish();
out.content.push_str(&final_out.content);
out.thinking.push_str(&final_out.thinking);
assert!(out.content.is_empty());
assert_eq!(out.thinking, "unfinished");
}
#[test]
fn test_think_filter_tracks_generation_prompt_open_block() {
let mut filter = ThinkFilter::new();
let _ = filter.push("<|assistant|><think>\n");
let mut out = filter.push("hidden reasoning</think>visible answer");
let final_out = filter.finish();
out.content.push_str(&final_out.content);
out.thinking.push_str(&final_out.thinking);
assert_eq!(out.content, "visible answer");
assert_eq!(out.thinking, "hidden reasoning");
}
#[test]
fn test_think_filter_preserves_tags_with_think_prefix() {
for input in [
"<thinking-mode>hello</thinking-mode>",
"<thinking123>payload</thinking123>",
"<thinker>note</thinker>",
] {
let mut filter = ThinkFilter::new();
let mut out = filter.push(input);
let final_out = filter.finish();
out.content.push_str(&final_out.content);
out.thinking.push_str(&final_out.thinking);
assert_eq!(out.content, input, "content mismatch for {input:?}");
assert!(
out.thinking.is_empty(),
"unexpected thinking for {input:?}: {:?}",
out.thinking
);
}
}
#[test]
fn test_think_filter_accepts_think_with_attributes() {
let mut filter = ThinkFilter::new();
let mut out = filter.push("<think data-source=\"x\">hidden</think>visible");
let final_out = filter.finish();
out.content.push_str(&final_out.content);
out.thinking.push_str(&final_out.thinking);
assert_eq!(out.content, "visible");
assert_eq!(out.thinking, "hidden");
}
#[test]
fn test_think_filter_treats_self_closing_as_noop() {
// `<think/>` carries no reasoning payload. It must not flip the filter
// into "inside_think" mode, and the tag itself must not leak into
// visible content.
for input in [
"before <think/> after",
"before <think /> after",
"before <thinking/> after",
"before <think data-source=\"x\"/> after",
] {
let mut filter = ThinkFilter::new();
let mut out = filter.push(input);
let final_out = filter.finish();
out.content.push_str(&final_out.content);
out.thinking.push_str(&final_out.thinking);
assert_eq!(
out.content, "before after",
"content mismatch for {input:?}"
);
assert!(
out.thinking.is_empty(),
"unexpected thinking for {input:?}: {:?}",
out.thinking
);
}
}
#[test]
fn test_think_filter_self_closing_does_not_swallow_following_content() {
// Regression: a self-closing `<think/>` used to be classified as an
// Open tag, which incremented think_depth and routed everything after
// it into the thinking bucket for the rest of the stream.
let mut filter = ThinkFilter::new();
let mut out = filter.push("<think/>visible chunk 1");
let final_out = filter.push("visible chunk 2");
let tail_out = filter.finish();
out.content.push_str(&final_out.content);
out.thinking.push_str(&final_out.thinking);
out.content.push_str(&tail_out.content);
out.thinking.push_str(&tail_out.thinking);
assert_eq!(out.content, "visible chunk 1visible chunk 2");
assert!(out.thinking.is_empty());
}
#[test]
fn test_think_filter_streaming_across_self_closing_boundary() {
// Regression: a chunk boundary between `<think` and `>` in a
// self-closing `<think/>` used to fall out of the partial-tag regex
// (which only allowed `<think<ws>...`), so the `<think/` prefix leaked
// into visible content before the `>` arrived.
for (a, b) in [
("before <think/", "> after"),
("before <thinking/", "> after"),
("head <think ", "/> tail"),
] {
let mut filter = ThinkFilter::new();
let mut out = filter.push(a);
let second = filter.push(b);
let final_out = filter.finish();
out.content.push_str(&second.content);
out.content.push_str(&final_out.content);
out.thinking.push_str(&second.thinking);
out.thinking.push_str(&final_out.thinking);
assert!(
!out.content.contains('<'),
"partial tag leaked into content for ({a:?}, {b:?}): {:?}",
out.content
);
assert!(
out.thinking.is_empty(),
"unexpected thinking for ({a:?}, {b:?}): {:?}",
out.thinking
);
}
}
#[test]
fn test_think_filter_streaming_across_quoted_attribute_boundary() {
let mut filter = ThinkFilter::new();
let mut out = filter.push(r#"<think data="a>b"#);
assert!(out.content.is_empty());
assert!(out.thinking.is_empty());
let second = filter.push(r#""/>Visible"#);
let final_out = filter.finish();
out.content.push_str(&second.content);
out.content.push_str(&final_out.content);
out.thinking.push_str(&second.thinking);
out.thinking.push_str(&final_out.thinking);
assert_eq!(out.content, "Visible");
assert!(out.thinking.is_empty());
}
#[test]
fn test_think_filter_self_closing_inside_open_block_closes_nothing() {
// `<think/>` inside an open `<think>` block is still a no-op: depth
// should stay at 1 until the real `</think>` arrives.
let mut filter = ThinkFilter::new();
let mut out = filter.push("before <think>hidden1 <think/> hidden2</think>visible");
let final_out = filter.finish();
out.content.push_str(&final_out.content);
out.thinking.push_str(&final_out.thinking);
assert_eq!(out.content, "before visible");
assert_eq!(out.thinking, "hidden1 hidden2");
}
}