mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
refactor: 大规模模块拆分与重组,新增 aether-admin crate
- 新建独立 aether-admin crate 承载 admin 相关共享契约与纯辅助函数 - 拆分 ai_pipeline 下 kiro/private_envelope/conversion/planner 等大文件为子模块目录 - 重组 admin handlers 各业务域(billing/oauth/provider/system/users 等)为目录结构,移除 shared.rs/builders.rs 等反模式 - 移除 ai_pipeline runtime adapters 旧实现(claude/openai/gemini/kiro/vertex/antigravity 等),改由 provider transport 统一承载 - 移除 control_facade/execution_facade/auth_snapshot_facade 等冗余 facade 层 - 拆分 query/billing 与 query/monitoring 模块、state/runtime/payments 与 security 模块 - 扩展架构测试覆盖 admin_billing/admin_model/admin_users 等新模块 - 删除 docs/architecture/refactor-execution-plan.md 已完成的执行计划文档
This commit is contained in:
176
crates/aether-ai-pipeline/src/adaptation/kiro_stream.rs
Normal file
176
crates/aether-ai-pipeline/src/adaptation/kiro_stream.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub const KIRO_CONTEXT_WINDOW_TOKENS: f64 = 200_000.0;
|
||||
pub const KIRO_MAX_THINKING_BUFFER: usize = 1024 * 1024;
|
||||
|
||||
const KIRO_QUOTE_CHARS: &str = "`\"'\\#!@$%^&*()-_=+[]{};:<>,.?/";
|
||||
|
||||
pub fn encode_kiro_sse_events(events: Vec<Value>) -> Result<Vec<u8>, serde_json::Error> {
|
||||
let mut output = Vec::new();
|
||||
for event in events {
|
||||
output.extend(encode_kiro_sse_event(&event)?);
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub fn encode_kiro_sse_event(event: &Value) -> Result<Vec<u8>, serde_json::Error> {
|
||||
let encoded = serde_json::to_string(event)?;
|
||||
if let Some(event_type) = event.get("type").and_then(Value::as_str) {
|
||||
Ok(format!("event: {event_type}\ndata: {encoded}\n\n").into_bytes())
|
||||
} else {
|
||||
Ok(format!("data: {encoded}\n\n").into_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_kiro_initial_sse_events(
|
||||
message_id: &str,
|
||||
model: &str,
|
||||
estimated_input_tokens: usize,
|
||||
) -> Vec<Value> {
|
||||
vec![json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": message_id,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [],
|
||||
"model": model,
|
||||
"stop_reason": Value::Null,
|
||||
"stop_sequence": Value::Null,
|
||||
"usage": {
|
||||
"input_tokens": estimated_input_tokens as u64,
|
||||
"output_tokens": 1,
|
||||
},
|
||||
}
|
||||
})]
|
||||
}
|
||||
|
||||
pub fn build_kiro_stream_error_sse_events(error_type: &str, message: &str) -> Vec<Value> {
|
||||
vec![json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": error_type,
|
||||
"message": message,
|
||||
}
|
||||
})]
|
||||
}
|
||||
|
||||
pub fn build_kiro_final_message_sse_events(
|
||||
stop_reason: &str,
|
||||
input_tokens: usize,
|
||||
output_tokens: usize,
|
||||
) -> Vec<Value> {
|
||||
vec![
|
||||
json!({
|
||||
"type": "message_delta",
|
||||
"delta": {
|
||||
"stop_reason": stop_reason,
|
||||
"stop_sequence": Value::Null,
|
||||
},
|
||||
"usage": {
|
||||
"input_tokens": input_tokens as u64,
|
||||
"output_tokens": output_tokens as u64,
|
||||
}
|
||||
}),
|
||||
json!({"type": "message_stop"}),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn calculate_kiro_context_input_tokens(percentage: f64) -> usize {
|
||||
((percentage * KIRO_CONTEXT_WINDOW_TOKENS) / 100.0) as usize
|
||||
}
|
||||
|
||||
pub fn estimate_kiro_tokens(text: &str) -> usize {
|
||||
if text.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let mut chinese = 0usize;
|
||||
let mut other = 0usize;
|
||||
for ch in text.chars() {
|
||||
if ('\u{4e00}'..='\u{9fff}').contains(&ch) {
|
||||
chinese += 1;
|
||||
} else {
|
||||
other += 1;
|
||||
}
|
||||
}
|
||||
let chinese_tokens = (chinese * 2).div_ceil(3);
|
||||
let other_tokens = other.div_ceil(4);
|
||||
(chinese_tokens + other_tokens).max(1)
|
||||
}
|
||||
|
||||
pub fn find_kiro_real_thinking_start_tag(buffer: &str) -> Option<usize> {
|
||||
let tag = "<thinking>";
|
||||
let mut search = 0usize;
|
||||
loop {
|
||||
let pos = buffer[search..].find(tag).map(|value| value + search)?;
|
||||
let has_before = pos > 0 && is_kiro_quote_char(buffer, pos - 1);
|
||||
let after_pos = pos + tag.len();
|
||||
let has_after = is_kiro_quote_char(buffer, after_pos);
|
||||
if !has_before && !has_after {
|
||||
return Some(pos);
|
||||
}
|
||||
search = pos + 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_kiro_real_thinking_end_tag(buffer: &str) -> Option<usize> {
|
||||
let tag = "</thinking>";
|
||||
let mut search = 0usize;
|
||||
loop {
|
||||
let pos = buffer[search..].find(tag).map(|value| value + search)?;
|
||||
let has_before = pos > 0 && is_kiro_quote_char(buffer, pos - 1);
|
||||
let after_pos = pos + tag.len();
|
||||
let has_after = is_kiro_quote_char(buffer, after_pos);
|
||||
if has_before || has_after {
|
||||
search = pos + 1;
|
||||
continue;
|
||||
}
|
||||
let after = &buffer[after_pos..];
|
||||
if after.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
if after.starts_with("\n\n") {
|
||||
return Some(pos);
|
||||
}
|
||||
search = pos + 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_kiro_real_thinking_end_tag_at_buffer_end(buffer: &str) -> Option<usize> {
|
||||
let tag = "</thinking>";
|
||||
let mut search = 0usize;
|
||||
loop {
|
||||
let pos = buffer[search..].find(tag).map(|value| value + search)?;
|
||||
let has_before = pos > 0 && is_kiro_quote_char(buffer, pos - 1);
|
||||
let after_pos = pos + tag.len();
|
||||
let has_after = is_kiro_quote_char(buffer, after_pos);
|
||||
if has_before || has_after {
|
||||
search = pos + 1;
|
||||
continue;
|
||||
}
|
||||
if buffer[after_pos..].trim().is_empty() {
|
||||
return Some(pos);
|
||||
}
|
||||
search = pos + 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kiro_crc32(data: &[u8]) -> u32 {
|
||||
let mut crc = 0xffff_ffffu32;
|
||||
for &byte in data {
|
||||
crc ^= byte as u32;
|
||||
for _ in 0..8 {
|
||||
let mask = if crc & 1 == 1 { 0xedb8_8320 } else { 0 };
|
||||
crc = (crc >> 1) ^ mask;
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
|
||||
fn is_kiro_quote_char(buffer: &str, pos: usize) -> bool {
|
||||
buffer
|
||||
.as_bytes()
|
||||
.get(pos)
|
||||
.map(|byte| KIRO_QUOTE_CHARS.as_bytes().contains(byte))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
pub mod kiro_stream;
|
||||
pub mod private_envelope;
|
||||
pub mod surfaces;
|
||||
|
||||
416
crates/aether-ai-pipeline/src/adaptation/private_envelope.rs
Normal file
416
crates/aether-ai-pipeline/src/adaptation/private_envelope.rs
Normal file
@@ -0,0 +1,416 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::surfaces::{
|
||||
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_descriptor_for_envelope,
|
||||
provider_adaptation_should_unwrap_stream_envelope, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME,
|
||||
};
|
||||
|
||||
pub fn provider_private_response_allows_sync_finalize(report_context: &Value) -> bool {
|
||||
let has_envelope = report_context
|
||||
.get("has_envelope")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
if !has_envelope {
|
||||
return true;
|
||||
}
|
||||
let envelope_name = report_context
|
||||
.get("envelope_name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let provider_api_format = report_context
|
||||
.get("provider_api_format")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
provider_adaptation_allows_sync_finalize_envelope(envelope_name, provider_api_format)
|
||||
|| matches!(envelope_name, "claude:cli")
|
||||
}
|
||||
|
||||
pub fn normalize_provider_private_report_context(report_context: Option<&Value>) -> Option<Value> {
|
||||
let report_context = report_context?;
|
||||
if !report_context
|
||||
.get("has_envelope")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some(report_context.clone());
|
||||
}
|
||||
let envelope_name = report_context
|
||||
.get("envelope_name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let provider_api_format = report_context
|
||||
.get("provider_api_format")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if provider_adaptation_descriptor_for_envelope(envelope_name, provider_api_format).is_none() {
|
||||
return Some(report_context.clone());
|
||||
}
|
||||
Some(clear_private_envelope_context(report_context))
|
||||
}
|
||||
|
||||
pub fn normalize_provider_private_response_value(
|
||||
data: Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
if !report_context
|
||||
.get("has_envelope")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some(data);
|
||||
}
|
||||
let mut unwrapped = match report_context.get("envelope_name").and_then(Value::as_str) {
|
||||
Some("claude:cli") | Some(KIRO_ENVELOPE_NAME) => data,
|
||||
Some(GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME) => {
|
||||
if let Some(response) = data
|
||||
.get("response")
|
||||
.and_then(Value::as_object)
|
||||
.filter(|response| !response.contains_key("response"))
|
||||
{
|
||||
Value::Object(response.clone())
|
||||
} else {
|
||||
data
|
||||
}
|
||||
}
|
||||
Some(ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME) => {
|
||||
if let Some(response) = data
|
||||
.get("response")
|
||||
.and_then(Value::as_object)
|
||||
.filter(|response| !response.contains_key("response"))
|
||||
{
|
||||
let mut unwrapped = response.clone();
|
||||
if let Some(response_id) = data.get("responseId").cloned() {
|
||||
unwrapped.insert("_v1internal_response_id".to_string(), response_id);
|
||||
}
|
||||
Value::Object(unwrapped)
|
||||
} else {
|
||||
data
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
postprocess_private_response_value(&mut unwrapped, report_context);
|
||||
Some(unwrapped)
|
||||
}
|
||||
|
||||
pub fn transform_provider_private_stream_line(
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<Vec<u8>, serde_json::Error> {
|
||||
let Ok(text) = std::str::from_utf8(&line) else {
|
||||
return Ok(line);
|
||||
};
|
||||
let trimmed = text.trim_matches('\r').trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with(':') || trimmed.starts_with("event:") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(data_line) = trimmed.strip_prefix("data:") else {
|
||||
return Ok(line);
|
||||
};
|
||||
let data_line = data_line.trim();
|
||||
if data_line.is_empty() || data_line == "[DONE]" {
|
||||
return Ok(line);
|
||||
}
|
||||
|
||||
let body: Value = match serde_json::from_str(data_line) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Ok(line),
|
||||
};
|
||||
|
||||
let envelope_name = report_context
|
||||
.get("envelope_name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let provider_api_format = report_context
|
||||
.get("provider_api_format")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if !provider_adaptation_should_unwrap_stream_envelope(envelope_name, provider_api_format) {
|
||||
return Ok(line);
|
||||
}
|
||||
let unwrapped = match envelope_name {
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME => body.get("response").cloned().unwrap_or(body),
|
||||
ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME => {
|
||||
let mut response = body.get("response").cloned().unwrap_or(body.clone());
|
||||
if let Some(response_id) = body.get("responseId").cloned() {
|
||||
if let Some(object) = response.as_object_mut() {
|
||||
object
|
||||
.entry("_v1internal_response_id".to_string())
|
||||
.or_insert(response_id);
|
||||
}
|
||||
}
|
||||
inject_antigravity_stream_tool_ids(&mut response);
|
||||
response
|
||||
}
|
||||
_ => body,
|
||||
};
|
||||
|
||||
let mut out = b"data: ".to_vec();
|
||||
out.extend(serde_json::to_vec(&unwrapped)?);
|
||||
out.extend_from_slice(b"\n\n");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn stream_body_contains_error_event(body: &[u8]) -> bool {
|
||||
let Ok(text) = std::str::from_utf8(body) else {
|
||||
return false;
|
||||
};
|
||||
let mut current_event_type: Option<String> = None;
|
||||
for raw_line in text.lines() {
|
||||
let line = raw_line.trim_matches('\r').trim();
|
||||
if line.is_empty() || line.starts_with(':') {
|
||||
continue;
|
||||
}
|
||||
if let Some(event_name) = line.strip_prefix("event:") {
|
||||
current_event_type = Some(event_name.trim().to_string());
|
||||
continue;
|
||||
}
|
||||
let data_line = if let Some(rest) = line.strip_prefix("data:") {
|
||||
rest.trim()
|
||||
} else {
|
||||
line
|
||||
};
|
||||
if data_line.is_empty() || data_line == "[DONE]" {
|
||||
continue;
|
||||
}
|
||||
let Ok(mut event) = serde_json::from_str::<Value>(data_line) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(event_object) = event.as_object_mut() {
|
||||
if !event_object.contains_key("type") {
|
||||
if let Some(event_name) = current_event_type.take() {
|
||||
event_object.insert("type".to_string(), Value::String(event_name));
|
||||
}
|
||||
}
|
||||
}
|
||||
if event
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("error"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
current_event_type = None;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn clear_private_envelope_context(report_context: &Value) -> Value {
|
||||
let mut normalized = report_context.clone();
|
||||
if let Some(object) = normalized.as_object_mut() {
|
||||
object.insert("has_envelope".to_string(), Value::Bool(false));
|
||||
object.remove("envelope_name");
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn local_finalize_response_model(report_context: &Value) -> &str {
|
||||
report_context
|
||||
.get("mapped_model")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn inject_antigravity_stream_tool_ids(value: &mut Value) {
|
||||
let Some(candidates) = value.get_mut("candidates").and_then(Value::as_array_mut) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for candidate in candidates {
|
||||
let Some(parts) = candidate
|
||||
.get_mut("content")
|
||||
.and_then(Value::as_object_mut)
|
||||
.and_then(|content| content.get_mut("parts"))
|
||||
.and_then(Value::as_array_mut)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut counters: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for part in parts {
|
||||
let Some(function_call) = part.get_mut("functionCall").and_then(Value::as_object_mut)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let has_id = function_call
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
if has_id {
|
||||
continue;
|
||||
}
|
||||
let name = function_call
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let index = counters.entry(name.clone()).or_insert(0);
|
||||
function_call.insert(
|
||||
"id".to_string(),
|
||||
Value::String(format!("call_{name}_{index}")),
|
||||
);
|
||||
*index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inject_antigravity_sync_tool_ids(response: &mut Value, model: &str) {
|
||||
if !model.to_ascii_lowercase().contains("claude") {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(candidates) = response.get_mut("candidates").and_then(Value::as_array_mut) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for candidate in candidates {
|
||||
let Some(parts) = candidate
|
||||
.get_mut("content")
|
||||
.and_then(Value::as_object_mut)
|
||||
.and_then(|content| content.get_mut("parts"))
|
||||
.and_then(Value::as_array_mut)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut name_counters: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for part in parts {
|
||||
let function_call = if let Some(function_call) =
|
||||
part.get_mut("functionCall").and_then(Value::as_object_mut)
|
||||
{
|
||||
function_call
|
||||
} else if let Some(function_call) =
|
||||
part.get_mut("function_call").and_then(Value::as_object_mut)
|
||||
{
|
||||
function_call
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let has_id = function_call
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
if has_id {
|
||||
continue;
|
||||
}
|
||||
let function_name = function_call
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let count = name_counters.entry(function_name.clone()).or_insert(0);
|
||||
function_call.insert(
|
||||
"id".to_string(),
|
||||
Value::String(format!("call_{function_name}_{count}")),
|
||||
);
|
||||
*count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn postprocess_private_response_value(data: &mut Value, report_context: &Value) {
|
||||
if !matches!(
|
||||
report_context.get("envelope_name").and_then(Value::as_str),
|
||||
Some(ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if let Some(object) = data.as_object_mut() {
|
||||
if !object.contains_key("_v1internal_response_id") {
|
||||
if let Some(response_id) = object.remove("responseId") {
|
||||
object.insert("_v1internal_response_id".to_string(), response_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
inject_antigravity_sync_tool_ids(data, local_finalize_response_model(report_context));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
normalize_provider_private_report_context, normalize_provider_private_response_value,
|
||||
stream_body_contains_error_event, transform_provider_private_stream_line,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn normalizes_supported_private_report_context() {
|
||||
let report_context = json!({
|
||||
"has_envelope": true,
|
||||
"envelope_name": "antigravity:v1internal",
|
||||
"provider_api_format": "gemini:cli",
|
||||
});
|
||||
let normalized = normalize_provider_private_report_context(Some(&report_context))
|
||||
.expect("context should normalize");
|
||||
assert_eq!(normalized["has_envelope"], json!(false));
|
||||
assert!(normalized.get("envelope_name").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwraps_antigravity_sync_response_and_injects_ids() {
|
||||
let report_context = json!({
|
||||
"has_envelope": true,
|
||||
"provider_api_format": "gemini:cli",
|
||||
"envelope_name": "antigravity:v1internal",
|
||||
"mapped_model": "claude-sonnet-4-5",
|
||||
});
|
||||
let body = json!({
|
||||
"response": {
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [{
|
||||
"functionCall": {
|
||||
"name": "get_weather",
|
||||
"args": {"city": "SF"}
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
},
|
||||
"responseId": "resp_123"
|
||||
});
|
||||
|
||||
let normalized = normalize_provider_private_response_value(body, &report_context)
|
||||
.expect("body should normalize");
|
||||
assert_eq!(normalized["_v1internal_response_id"], json!("resp_123"));
|
||||
assert_eq!(
|
||||
normalized["candidates"][0]["content"]["parts"][0]["functionCall"]["id"],
|
||||
json!("call_get_weather_0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwraps_antigravity_stream_line_and_injects_ids() {
|
||||
let report_context = json!({
|
||||
"has_envelope": true,
|
||||
"provider_api_format": "gemini:cli",
|
||||
"client_api_format": "gemini:cli",
|
||||
"envelope_name": "antigravity:v1internal",
|
||||
"mapped_model": "claude-sonnet-4-5",
|
||||
});
|
||||
let output = transform_provider_private_stream_line(
|
||||
&report_context,
|
||||
b"data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"},\"index\":0}],\"modelVersion\":\"claude-sonnet-4-5\"},\"responseId\":\"resp_123\"}\n\n".to_vec(),
|
||||
)
|
||||
.expect("unwrap should succeed");
|
||||
let output_text = String::from_utf8(output).expect("text should decode");
|
||||
assert!(output_text.contains("\"_v1internal_response_id\":\"resp_123\""));
|
||||
assert!(output_text.contains("\"id\":\"call_get_weather_0\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_sse_error_events_without_explicit_type_field() {
|
||||
let body = br#"event: error
|
||||
data: {"message":"bad"}
|
||||
|
||||
"#;
|
||||
assert!(stream_body_contains_error_event(body));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user