//! 请求处理器 //! //! 处理各种API端点的HTTP请求 //! //! 重构后的结构: //! - 通用逻辑提取到 `handler_context` 和 `response_processor` 模块 //! - 各 handler 只保留独特的业务逻辑 //! - Claude 的格式转换逻辑保留在此文件(用于 OpenRouter 旧接口回退) use super::{ error_mapper::{get_error_message, map_proxy_error_to_status}, handler_config::{ claude_stream_usage_event_filter, CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG, }, handler_context::RequestContext, providers::{ get_adapter, get_claude_api_format, streaming::create_anthropic_sse_stream, streaming_gemini::create_anthropic_sse_stream_from_gemini, streaming_responses::create_anthropic_sse_stream_from_responses, transform, transform_gemini, transform_responses, }, response_processor::{ create_logged_passthrough_stream, process_response, read_decoded_body, strip_entity_headers_for_rebuilt_body, strip_hop_by_hop_response_headers, usage_logging_enabled, SseUsageCollector, }, server::ProxyState, sse::{strip_sse_field, take_sse_block}, types::*, usage::parser::TokenUsage, ProxyError, }; use crate::app_config::AppType; use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use bytes::Bytes; use http_body_util::BodyExt; use serde_json::{json, Value}; // ============================================================================ // 健康检查和状态查询(简单端点) // ============================================================================ /// 健康检查 pub async fn health_check() -> (StatusCode, Json) { ( StatusCode::OK, Json(json!({ "status": "healthy", "timestamp": chrono::Utc::now().to_rfc3339(), })), ) } /// 获取服务状态 pub async fn get_status(State(state): State) -> Result, ProxyError> { let status = state.status.read().await.clone(); Ok(Json(status)) } // ============================================================================ // Claude API 处理器(包含格式转换逻辑) // ============================================================================ /// 处理 /v1/messages 请求(Claude API) /// /// Claude 处理器包含独特的格式转换逻辑: /// - 过去用于 OpenRouter 的 OpenAI Chat Completions 兼容接口(Anthropic ↔ OpenAI 转换) /// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退) pub async fn handle_messages( State(state): State, request: axum::extract::Request, ) -> Result { handle_messages_for_app(state, request, AppType::Claude, "Claude", "claude", None).await } pub async fn handle_claude_desktop_messages( State(state): State, request: axum::extract::Request, ) -> Result { validate_claude_desktop_gateway_auth(&state, request.headers())?; handle_messages_for_app( state, request, AppType::ClaudeDesktop, "Claude Desktop", "claude-desktop", Some("/claude-desktop"), ) .await } pub async fn handle_claude_desktop_models( State(state): State, headers: axum::http::HeaderMap, ) -> Result, ProxyError> { validate_claude_desktop_gateway_auth(&state, &headers)?; let providers = state .provider_router .select_providers("claude-desktop") .await .map_err(|e| ProxyError::DatabaseError(e.to_string()))?; let provider = providers.first().ok_or(ProxyError::NoAvailableProvider)?; let response = crate::claude_desktop_config::model_list_response(provider) .map_err(|e| ProxyError::ConfigError(e.to_string()))?; Ok(Json(response)) } async fn handle_messages_for_app( state: ProxyState, request: axum::extract::Request, app_type: AppType, tag: &'static str, app_type_str: &'static str, strip_prefix: Option<&'static str>, ) -> Result { let (parts, body) = request.into_parts(); let uri = parts.uri; let headers = parts.headers; let extensions = parts.extensions; let body_bytes = body .collect() .await .map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))? .to_bytes(); let body: Value = serde_json::from_slice(&body_bytes) .map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?; let mut ctx = RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?; let raw_endpoint = uri .path_and_query() .map(|path_and_query| path_and_query.as_str()) .unwrap_or(uri.path()); let endpoint = strip_prefix .and_then(|prefix| raw_endpoint.strip_prefix(prefix)) .unwrap_or(raw_endpoint); let is_stream = body .get("stream") .and_then(|s| s.as_bool()) .unwrap_or(false); // 转发请求 let forwarder = ctx.create_forwarder(&state); let result = match forwarder .forward_with_retry( &app_type, endpoint, body.clone(), headers, extensions, ctx.get_providers(), ) .await { Ok(result) => result, Err(mut err) => { if let Some(provider) = err.provider.take() { ctx.provider = provider; } log_forward_error(&state, &ctx, is_stream, &err.error); return Err(err.error); } }; ctx.provider = result.provider; let api_format = result .claude_api_format .as_deref() .unwrap_or_else(|| get_claude_api_format(&ctx.provider)) .to_string(); let response = result.response; // 检查是否需要格式转换(OpenRouter 等中转服务) let adapter = get_adapter(&app_type); let needs_transform = adapter.needs_transform(&ctx.provider); // Claude 特有:格式转换处理 if needs_transform { return handle_claude_transform(response, &ctx, &state, &body, is_stream, &api_format) .await; } // 通用响应处理(透传模式) process_response(response, &ctx, &state, &CLAUDE_PARSER_CONFIG).await } fn validate_claude_desktop_gateway_auth( state: &ProxyState, headers: &axum::http::HeaderMap, ) -> Result<(), ProxyError> { let expected = crate::claude_desktop_config::get_or_create_gateway_token(state.db.as_ref()) .map_err(|e| ProxyError::AuthError(e.to_string()))?; let Some(value) = headers.get(axum::http::header::AUTHORIZATION) else { return Err(ProxyError::AuthError( "Claude Desktop gateway 缺少 Authorization 头".to_string(), )); }; let value = value .to_str() .map_err(|_| ProxyError::AuthError("Authorization 头格式无效".to_string()))?; let token = value .strip_prefix("Bearer ") .or_else(|| value.strip_prefix("bearer ")) .unwrap_or("") .trim(); if token != expected { return Err(ProxyError::AuthError( "Claude Desktop gateway token 无效".to_string(), )); } Ok(()) } /// Claude 格式转换处理(独有逻辑) /// /// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换 async fn handle_claude_transform( response: super::hyper_client::ProxyResponse, ctx: &RequestContext, state: &ProxyState, original_body: &Value, is_stream: bool, api_format: &str, ) -> Result { let status = response.status(); let is_codex_oauth = ctx .provider .meta .as_ref() .and_then(|meta| meta.provider_type.as_deref()) == Some("codex_oauth"); // Codex OAuth 会把 openai_responses 响应强制升级为 SSE,即使客户端发的是 stream:false。 // should_use_claude_transform_streaming 默认会把这个组合路由到流式转换器——虽然能避免 // JSON parse 报 422,但会让非流客户端收到 text/event-stream,违反 Anthropic 非流语义。 // 这里为这个特定组合打开 override:把上游 SSE 聚合成 Anthropic JSON 回给客户端,其它 // 场景(任意上游 is_sse、非 Codex OAuth 等)仍沿用原有流式兜底。 let aggregate_codex_oauth_responses_sse = !is_stream && is_codex_oauth && api_format == "openai_responses"; let use_streaming = if aggregate_codex_oauth_responses_sse { false } else { should_use_claude_transform_streaming( is_stream, response.is_sse(), api_format, is_codex_oauth, ) }; let tool_schema_hints = transform_gemini::extract_anthropic_tool_schema_hints(original_body); let tool_schema_hints = (!tool_schema_hints.is_empty()).then_some(tool_schema_hints); if use_streaming { // 根据 api_format 选择流式转换器 let stream = response.bytes_stream(); let sse_stream: Box< dyn futures::Stream> + Send + Unpin, > = if api_format == "openai_responses" { Box::new(Box::pin(create_anthropic_sse_stream_from_responses(stream))) } else if api_format == "gemini_native" { Box::new(Box::pin(create_anthropic_sse_stream_from_gemini( stream, Some(state.gemini_shadow.clone()), Some(ctx.provider.id.clone()), Some(ctx.session_id.clone()), tool_schema_hints.clone(), ))) } else { Box::new(Box::pin(create_anthropic_sse_stream(stream))) }; // 创建使用量收集器;关闭 usage logging 时不要再解析转换后的 SSE。 let usage_collector = if usage_logging_enabled(state) { let state = state.clone(); let provider_id = ctx.provider.id.clone(); let model = ctx.request_model.clone(); let status_code = status.as_u16(); let start_time = ctx.start_time; let session_id = ctx.session_id.clone(); Some(SseUsageCollector::new( start_time, Some(claude_stream_usage_event_filter), move |events, first_token_ms| { if let Some(usage) = TokenUsage::from_claude_stream_events(&events) { let latency_ms = start_time.elapsed().as_millis() as u64; let state = state.clone(); let provider_id = provider_id.clone(); let model = model.clone(); let session_id = session_id.clone(); tokio::spawn(async move { log_usage( &state, &provider_id, "claude", &model, &model, usage, latency_ms, first_token_ms, true, status_code, Some(session_id), ) .await; }); } else { log::debug!("[Claude] OpenRouter 流式响应缺少 usage 统计,跳过消费记录"); } }, )) } else { None }; // 获取流式超时配置 let timeout_config = ctx.streaming_timeout_config(); let logged_stream = create_logged_passthrough_stream( sse_stream, "Claude/OpenRouter", usage_collector, timeout_config, ); let mut headers = axum::http::HeaderMap::new(); headers.insert( "Content-Type", axum::http::HeaderValue::from_static("text/event-stream"), ); headers.insert( "Cache-Control", axum::http::HeaderValue::from_static("no-cache"), ); let body = axum::body::Body::from_stream(logged_stream); return Ok((headers, body).into_response()); } // 非流式响应转换 (OpenAI/Responses → Anthropic) let body_timeout = if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 { std::time::Duration::from_secs(ctx.app_config.non_streaming_timeout as u64) } else { std::time::Duration::ZERO }; let (mut response_headers, _status, body_bytes) = read_decoded_body(response, ctx.tag, body_timeout).await?; let body_str = String::from_utf8_lossy(&body_bytes); let upstream_response: Value = if aggregate_codex_oauth_responses_sse { responses_sse_to_response_value(&body_str)? } else { serde_json::from_slice(&body_bytes).map_err(|e| { log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}"); ProxyError::TransformError(format!("Failed to parse upstream response: {e}")) })? }; // 根据 api_format 选择非流式转换器 let anthropic_response = if api_format == "openai_responses" { transform_responses::responses_to_anthropic(upstream_response) } else if api_format == "gemini_native" { transform_gemini::gemini_to_anthropic_with_shadow_and_hints( upstream_response, Some(state.gemini_shadow.as_ref()), Some(&ctx.provider.id), Some(&ctx.session_id), tool_schema_hints.as_ref(), ) } else { transform::openai_to_anthropic(upstream_response) } .map_err(|e| { log::error!("[Claude] 转换响应失败: {e}"); e })?; // 记录使用量 if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) { let model = anthropic_response .get("model") .and_then(|m| m.as_str()) .unwrap_or("unknown"); let latency_ms = ctx.latency_ms(); let request_model = ctx.request_model.clone(); tokio::spawn({ let state = state.clone(); let provider_id = ctx.provider.id.clone(); let model = model.to_string(); let session_id = ctx.session_id.clone(); async move { log_usage( &state, &provider_id, "claude", &model, &request_model, usage, latency_ms, None, false, status.as_u16(), Some(session_id), ) .await; } }); } // 构建响应 let mut builder = axum::response::Response::builder().status(status); strip_entity_headers_for_rebuilt_body(&mut response_headers); strip_hop_by_hop_response_headers(&mut response_headers); for (key, value) in response_headers.iter() { builder = builder.header(key, value); } builder = builder.header("content-type", "application/json"); let response_body = serde_json::to_vec(&anthropic_response).map_err(|e| { log::error!("[Claude] 序列化响应失败: {e}"); ProxyError::TransformError(format!("Failed to serialize response: {e}")) })?; let body = axum::body::Body::from(response_body); builder.body(body).map_err(|e| { log::error!("[Claude] 构建响应失败: {e}"); ProxyError::Internal(format!("Failed to build response: {e}")) }) } fn endpoint_with_query(uri: &axum::http::Uri, endpoint: &str) -> String { match uri.query() { Some(query) => format!("{endpoint}?{query}"), None => endpoint.to_string(), } } // ============================================================================ // Codex API 处理器 // ============================================================================ /// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI) pub async fn handle_chat_completions( State(state): State, request: axum::extract::Request, ) -> Result { let (parts, req_body) = request.into_parts(); let uri = parts.uri; let headers = parts.headers; let extensions = parts.extensions; let body_bytes = req_body .collect() .await .map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))? .to_bytes(); let body: Value = serde_json::from_slice(&body_bytes) .map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?; let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?; let endpoint = endpoint_with_query(&uri, "/chat/completions"); let is_stream = body .get("stream") .and_then(|v| v.as_bool()) .unwrap_or(false); let forwarder = ctx.create_forwarder(&state); let result = match forwarder .forward_with_retry( &AppType::Codex, &endpoint, body, headers, extensions, ctx.get_providers(), ) .await { Ok(result) => result, Err(mut err) => { if let Some(provider) = err.provider.take() { ctx.provider = provider; } log_forward_error(&state, &ctx, is_stream, &err.error); return Err(err.error); } }; ctx.provider = result.provider; let response = result.response; process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await } /// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传) pub async fn handle_responses( State(state): State, request: axum::extract::Request, ) -> Result { let (parts, req_body) = request.into_parts(); let uri = parts.uri; let headers = parts.headers; let extensions = parts.extensions; let body_bytes = req_body .collect() .await .map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))? .to_bytes(); let body: Value = serde_json::from_slice(&body_bytes) .map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?; let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?; let endpoint = endpoint_with_query(&uri, "/responses"); let is_stream = body .get("stream") .and_then(|v| v.as_bool()) .unwrap_or(false); let forwarder = ctx.create_forwarder(&state); let result = match forwarder .forward_with_retry( &AppType::Codex, &endpoint, body, headers, extensions, ctx.get_providers(), ) .await { Ok(result) => result, Err(mut err) => { if let Some(provider) = err.provider.take() { ctx.provider = provider; } log_forward_error(&state, &ctx, is_stream, &err.error); return Err(err.error); } }; ctx.provider = result.provider; let response = result.response; process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await } /// 处理 /v1/responses/compact 请求(OpenAI Responses Compact API - Codex CLI 透传) pub async fn handle_responses_compact( State(state): State, request: axum::extract::Request, ) -> Result { let (parts, req_body) = request.into_parts(); let uri = parts.uri; let headers = parts.headers; let extensions = parts.extensions; let body_bytes = req_body .collect() .await .map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))? .to_bytes(); let body: Value = serde_json::from_slice(&body_bytes) .map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?; let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?; let endpoint = endpoint_with_query(&uri, "/responses/compact"); let is_stream = body .get("stream") .and_then(|v| v.as_bool()) .unwrap_or(false); let forwarder = ctx.create_forwarder(&state); let result = match forwarder .forward_with_retry( &AppType::Codex, &endpoint, body, headers, extensions, ctx.get_providers(), ) .await { Ok(result) => result, Err(mut err) => { if let Some(provider) = err.provider.take() { ctx.provider = provider; } log_forward_error(&state, &ctx, is_stream, &err.error); return Err(err.error); } }; ctx.provider = result.provider; let response = result.response; process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await } // ============================================================================ // Gemini API 处理器 // ============================================================================ /// 处理 Gemini API 请求(透传,包括查询参数) pub async fn handle_gemini( State(state): State, uri: axum::http::Uri, request: axum::extract::Request, ) -> Result { let (parts, req_body) = request.into_parts(); let headers = parts.headers; let extensions = parts.extensions; let body_bytes = req_body .collect() .await .map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))? .to_bytes(); let body: Value = serde_json::from_slice(&body_bytes) .map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?; // Gemini 的模型名称在 URI 中 let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Gemini, "Gemini", "gemini") .await? .with_model_from_uri(&uri); // 提取完整的路径和查询参数 let endpoint = uri .path_and_query() .map(|pq| pq.as_str()) .unwrap_or(uri.path()); let is_stream = body .get("stream") .and_then(|v| v.as_bool()) .unwrap_or(false); let forwarder = ctx.create_forwarder(&state); let result = match forwarder .forward_with_retry( &AppType::Gemini, endpoint, body, headers, extensions, ctx.get_providers(), ) .await { Ok(result) => result, Err(mut err) => { if let Some(provider) = err.provider.take() { ctx.provider = provider; } log_forward_error(&state, &ctx, is_stream, &err.error); return Err(err.error); } }; ctx.provider = result.provider; let response = result.response; process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await } fn should_use_claude_transform_streaming( requested_streaming: bool, upstream_is_sse: bool, api_format: &str, is_codex_oauth: bool, ) -> bool { requested_streaming || upstream_is_sse || (is_codex_oauth && api_format == "openai_responses") } /// 把 OpenAI Responses SSE 流聚合成一个完整的 Responses JSON 对象,供下游转成 Anthropic /// 非流响应。仅在 Codex OAuth 把 `stream:false` 强制升级为 SSE 的场景下调用。 /// /// 复用 `proxy::sse` 的 `take_sse_block`/`strip_sse_field`:`take_sse_block` 同时支持 /// `\n\n` 与 `\r\n\r\n` 两种分隔符,`strip_sse_field` 兼容带/不带空格的字段写法。 fn responses_sse_to_response_value(body: &str) -> Result { let mut buffer = body.to_string(); let mut completed_response: Option = None; let mut output_items = Vec::new(); while let Some(block) = take_sse_block(&mut buffer) { let mut event_name = ""; let mut data_lines: Vec<&str> = Vec::new(); for line in block.lines() { if let Some(evt) = strip_sse_field(line, "event") { event_name = evt.trim(); } else if let Some(d) = strip_sse_field(line, "data") { data_lines.push(d); } } if data_lines.is_empty() { continue; } let data_str = data_lines.join("\n"); if data_str.trim() == "[DONE]" { continue; } let data: Value = serde_json::from_str(&data_str).map_err(|e| { ProxyError::TransformError(format!("Failed to parse upstream SSE event: {e}")) })?; match event_name { "response.output_item.done" => { if let Some(item) = data.get("item") { output_items.push(item.clone()); } } "response.completed" => { completed_response = Some(data.get("response").cloned().unwrap_or(data)); } "response.failed" => { let message = data .pointer("/response/error/message") .and_then(|v| v.as_str()) .unwrap_or("response.failed event received"); return Err(ProxyError::TransformError(message.to_string())); } _ => {} } } let mut response = completed_response.ok_or_else(|| { ProxyError::TransformError("No response.completed event in upstream SSE".to_string()) })?; if !output_items.is_empty() { if let Some(obj) = response.as_object_mut() { obj.insert("output".to_string(), Value::Array(output_items)); } else { return Err(ProxyError::TransformError( "response.completed payload is not an object".to_string(), )); } } Ok(response) } // ============================================================================ // 使用量记录(保留用于 Claude 转换逻辑) // ============================================================================ fn log_forward_error( state: &ProxyState, ctx: &RequestContext, is_streaming: bool, error: &ProxyError, ) { use super::usage::logger::UsageLogger; let logger = UsageLogger::new(&state.db); let status_code = map_proxy_error_to_status(error); let error_message = get_error_message(error); let request_id = uuid::Uuid::new_v4().to_string(); if let Err(e) = logger.log_error_with_context( request_id, ctx.provider.id.clone(), ctx.app_type_str.to_string(), ctx.request_model.clone(), status_code, error_message, ctx.latency_ms(), is_streaming, Some(ctx.session_id.clone()), None, ) { log::warn!("记录失败请求日志失败: {e}"); } } /// 记录请求使用量 #[allow(clippy::too_many_arguments)] async fn log_usage( state: &ProxyState, provider_id: &str, app_type: &str, model: &str, request_model: &str, usage: TokenUsage, latency_ms: u64, first_token_ms: Option, is_streaming: bool, status_code: u16, session_id: Option, ) { use super::usage::logger::UsageLogger; if !usage_logging_enabled(state) { return; } let logger = UsageLogger::new(&state.db); let (multiplier, pricing_model_source) = logger.resolve_pricing_config(provider_id, app_type).await; let pricing_model = if pricing_model_source == "request" { request_model } else { model }; let request_id = usage.dedup_request_id(); if let Err(e) = logger.log_with_calculation( request_id, provider_id.to_string(), app_type.to_string(), model.to_string(), request_model.to_string(), pricing_model.to_string(), usage, multiplier, latency_ms, first_token_ms, status_code, session_id, None, // provider_type is_streaming, ) { log::warn!("[USG-001] 记录使用量失败: {e}"); } } #[cfg(test)] mod tests { use super::{responses_sse_to_response_value, should_use_claude_transform_streaming}; use crate::proxy::ProxyError; #[test] fn codex_oauth_responses_force_streaming_even_if_client_sent_false() { assert!(should_use_claude_transform_streaming( false, false, "openai_responses", true, )); } #[test] fn upstream_sse_response_always_uses_streaming_path() { assert!(should_use_claude_transform_streaming( false, true, "openai_chat", false, )); } #[test] fn non_streaming_response_stays_non_streaming_for_regular_openai_responses() { assert!(!should_use_claude_transform_streaming( false, false, "openai_responses", false, )); } #[test] fn responses_sse_to_response_value_collects_output_items() { let sse = r#"event: response.output_item.done data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}} event: response.completed data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":10,"output_tokens":2}}} "#; let response = responses_sse_to_response_value(sse).unwrap(); assert_eq!(response["id"], "resp_1"); assert_eq!(response["output"][0]["type"], "message"); assert_eq!(response["output"][0]["content"][0]["text"], "hello"); } #[test] fn responses_sse_to_response_value_handles_crlf_delimiters() { // 真实 HTTP SSE 按规范使用 \r\n\r\n 分隔事件;take_sse_block 必须同时处理两种分隔符, // 否则此路径在任何标准上游(含 Codex OAuth HTTPS 后端)下都会 TransformError。 let sse = "event: response.output_item.done\r\n\ data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"hi\"}]}}\r\n\ \r\n\ event: response.completed\r\n\ data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_crlf\",\"status\":\"completed\",\"model\":\"gpt-5.4\",\"output\":[],\"usage\":{\"input_tokens\":5,\"output_tokens\":1}}}\r\n\ \r\n"; let response = responses_sse_to_response_value(sse).unwrap(); assert_eq!(response["id"], "resp_crlf"); assert_eq!(response["output"][0]["type"], "message"); assert_eq!(response["output"][0]["content"][0]["text"], "hi"); } #[test] fn responses_sse_to_response_value_returns_err_on_response_failed() { let sse = "event: response.failed\n\ data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"message\":\"upstream blew up\"}}}\n\n"; let err = responses_sse_to_response_value(sse).unwrap_err(); match err { ProxyError::TransformError(msg) => assert!(msg.contains("upstream blew up")), other => panic!("expected TransformError, got {other:?}"), } } #[test] fn responses_sse_to_response_value_errors_when_no_completed_event() { let sse = "event: response.output_item.done\n\ data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\"}}\n\n"; assert!(responses_sse_to_response_value(sse).is_err()); } }