diff --git a/crates/llm-worker/src/llm_client/scheme/openai_responses/events.rs b/crates/llm-worker/src/llm_client/scheme/openai_responses/events.rs index 6f8988be..1107ae56 100644 --- a/crates/llm-worker/src/llm_client/scheme/openai_responses/events.rs +++ b/crates/llm-worker/src/llm_client/scheme/openai_responses/events.rs @@ -5,9 +5,10 @@ //! insomnia 側 1 次元 `BlockStart/Delta/Stop::index` のマッピングは //! [`OpenAIResponsesState`] が保持する。 -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use serde::Deserialize; +use serde_json::{Map, Value}; use crate::llm_client::{ ClientError, @@ -255,12 +256,16 @@ struct InputTokensDetails { #[derive(Debug, Deserialize)] struct ResponseFailed { response: FailedResponse, + #[serde(flatten)] + extra: BTreeMap, } #[derive(Debug, Deserialize)] struct FailedResponse { #[serde(default)] error: Option, + #[serde(flatten)] + extra: BTreeMap, } #[derive(Debug, Deserialize)] @@ -269,6 +274,17 @@ struct ErrorDetail { error_type: Option, #[serde(default)] message: Option, + #[serde(default)] + code: Option, + #[serde(flatten)] + extra: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct TopLevelErrorEnvelope { + error: TopLevelError, + #[serde(flatten)] + extra: BTreeMap, } #[derive(Debug, Deserialize)] @@ -279,6 +295,8 @@ struct TopLevelError { error_type: Option, #[serde(default)] code: Option, + #[serde(flatten)] + extra: BTreeMap, } // ============================================================================ @@ -325,10 +343,7 @@ pub(crate) fn parse_sse( "response.failed" | "response.incomplete" => { let ev: ResponseFailed = from_json(data)?; - let (code, message) = match ev.response.error { - Some(err) => (err.error_type, err.message.unwrap_or_default()), - None => (None, format!("response {event_type}")), - }; + let (code, message) = response_failure_diagnostic(event_type, ev); Ok(vec![ Event::Error(ErrorEvent { code, message }), Event::Status(StatusEvent { @@ -551,15 +566,19 @@ pub(crate) fn parse_sse( } "error" => { - let ev: TopLevelError = from_json(data).unwrap_or(TopLevelError { - message: Some(data.to_string()), - error_type: None, - code: None, + let ev = from_json::(data).unwrap_or_else(|_| { + TopLevelErrorEnvelope { + error: TopLevelError { + message: Some(data.to_string()), + error_type: None, + code: None, + extra: BTreeMap::new(), + }, + extra: BTreeMap::new(), + } }); - Ok(vec![Event::Error(ErrorEvent { - code: ev.error_type.or(ev.code), - message: ev.message.unwrap_or_default(), - })]) + let (code, message) = top_level_error_diagnostic(ev); + Ok(vec![Event::Error(ErrorEvent { code, message })]) } // 未対応 / 情報系イベントは無視 @@ -567,6 +586,121 @@ pub(crate) fn parse_sse( } } +fn response_failure_diagnostic(event_type: &str, ev: ResponseFailed) -> (Option, String) { + let mut diagnostic = Map::new(); + diagnostic.insert("event".to_string(), Value::String(event_type.to_string())); + + let mut code = None; + let base_message = if let Some(err) = ev.response.error { + code = err.code.clone().or(err.error_type.clone()); + if let Some(error_type) = err.error_type { + diagnostic.insert("error_type".to_string(), Value::String(error_type)); + } + if let Some(error_code) = err.code { + diagnostic.insert("error_code".to_string(), Value::String(error_code)); + } + if !err.extra.is_empty() { + diagnostic.insert( + "error_extra".to_string(), + diagnostic_object(err.extra, DIAGNOSTIC_VALUE_LIMIT), + ); + } + err.message + .filter(|message| !message.trim().is_empty()) + .unwrap_or_else(|| format!("OpenAI Responses {event_type}")) + } else { + format!("OpenAI Responses {event_type}") + }; + + let response_extra = ev.response.extra; + if let Some(reason) = response_extra + .get("incomplete_details") + .and_then(|value| value.get("reason")) + .and_then(Value::as_str) + { + diagnostic.insert( + "incomplete_reason".to_string(), + Value::String(reason.to_string()), + ); + if code.is_none() { + code = Some(reason.to_string()); + } + } + if !response_extra.is_empty() { + diagnostic.insert( + "response_extra".to_string(), + diagnostic_object(response_extra, DIAGNOSTIC_VALUE_LIMIT), + ); + } + if !ev.extra.is_empty() { + diagnostic.insert( + "event_extra".to_string(), + diagnostic_object(ev.extra, DIAGNOSTIC_VALUE_LIMIT), + ); + } + + (code, append_diagnostic(base_message, diagnostic)) +} + +fn top_level_error_diagnostic(ev: TopLevelErrorEnvelope) -> (Option, String) { + let code = ev.error.code.clone().or(ev.error.error_type.clone()); + let mut diagnostic = Map::new(); + diagnostic.insert("event".to_string(), Value::String("error".to_string())); + if let Some(error_type) = ev.error.error_type { + diagnostic.insert("error_type".to_string(), Value::String(error_type)); + } + if let Some(error_code) = ev.error.code { + diagnostic.insert("error_code".to_string(), Value::String(error_code)); + } + if !ev.error.extra.is_empty() { + diagnostic.insert( + "error_extra".to_string(), + diagnostic_object(ev.error.extra, DIAGNOSTIC_VALUE_LIMIT), + ); + } + if !ev.extra.is_empty() { + diagnostic.insert( + "event_extra".to_string(), + diagnostic_object(ev.extra, DIAGNOSTIC_VALUE_LIMIT), + ); + } + + let message = ev + .error + .message + .filter(|message| !message.trim().is_empty()) + .unwrap_or_else(|| "OpenAI Responses error".to_string()); + (code, append_diagnostic(message, diagnostic)) +} + +const DIAGNOSTIC_VALUE_LIMIT: usize = 512; + +fn diagnostic_object(extra: BTreeMap, value_limit: usize) -> Value { + Value::Object( + extra + .into_iter() + .map(|(key, value)| (key, cap_json_value(value, value_limit))) + .collect(), + ) +} + +fn cap_json_value(value: Value, limit: usize) -> Value { + let rendered = value.to_string(); + if rendered.len() <= limit { + value + } else { + let capped: String = rendered.chars().take(limit).collect(); + Value::String(format!("{capped}…")) + } +} + +fn append_diagnostic(message: String, diagnostic: Map) -> String { + if diagnostic.len() <= 1 { + return message; + } + format!("{} | diagnostic={}", message, Value::Object(diagnostic)) +} + /// 対応する BlockStart がまだ発行されていなければ発行しつつ、delta を流す。 /// content_part.added を取りこぼしても delta 単独で復旧できるようにする。 fn ensure_and_delta( @@ -873,6 +1007,88 @@ mod tests { )); } + #[test] + fn incomplete_response_preserves_incomplete_reason_without_error() { + let data = r#"{ + "response": { + "status": "incomplete", + "incomplete_details": {"reason": "max_output_tokens"} + } + }"#; + let (events, _) = run("response.incomplete", data); + let Event::Error(err) = &events[0] else { + panic!("expected error event") + }; + assert_eq!(err.code.as_deref(), Some("max_output_tokens")); + assert!(err.message.contains("OpenAI Responses response.incomplete")); + assert!(err.message.contains("incomplete_reason")); + assert!(err.message.contains("max_output_tokens")); + assert!(!err.message.ends_with("response response.incomplete")); + } + + #[test] + fn incomplete_response_preserves_unknown_response_fields() { + let data = r#"{ + "response": { + "status": "incomplete", + "incomplete_details": {"reason": "content_filter"}, + "mystery_field": {"nested": true} + }, + "sequence_number": 42 + }"#; + let (events, _) = run("response.incomplete", data); + let Event::Error(err) = &events[0] else { + panic!("expected error event") + }; + assert!(err.message.contains("mystery_field")); + assert!(err.message.contains("sequence_number")); + assert!(err.message.contains("content_filter")); + } + + #[test] + fn failed_response_preserves_error_and_response_extra_fields() { + let data = r#"{ + "response": { + "error": { + "type": "server_error", + "code": "upstream_overloaded", + "message": "try later", + "param": "input" + }, + "retry_hint": "short" + } + }"#; + let (events, _) = run("response.failed", data); + let Event::Error(err) = &events[0] else { + panic!("expected error event") + }; + assert_eq!(err.code.as_deref(), Some("upstream_overloaded")); + assert!(err.message.contains("try later")); + assert!(err.message.contains("param")); + assert!(err.message.contains("retry_hint")); + } + + #[test] + fn top_level_error_preserves_unknown_fields() { + let data = r#"{ + "error": { + "type": "rate_limit_error", + "code": "rate_limit_exceeded", + "message": "slow down", + "retry_after_ms": 1000 + }, + "request_id": "req_123" + }"#; + let (events, _) = run("error", data); + let Event::Error(err) = &events[0] else { + panic!("expected error event") + }; + assert_eq!(err.code.as_deref(), Some("rate_limit_exceeded")); + assert!(err.message.contains("slow down")); + assert!(err.message.contains("retry_after_ms")); + assert!(err.message.contains("request_id")); + } + #[test] fn reasoning_output_item_emits_reasoning_item_with_text_summary_encrypted() { // 完成済み reasoning wrapper が text + summary[] + encrypted_content を持って