move formats/openai.rs into goose-providers crate, along with several dependencies (#9633)
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
+1
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod openai;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user