fix: preserve openai responses incomplete diagnostics
This commit is contained in:
parent
33884bd0ce
commit
f56793589f
|
|
@ -5,9 +5,10 @@
|
||||||
//! insomnia 側 1 次元 `BlockStart/Delta/Stop::index` のマッピングは
|
//! insomnia 側 1 次元 `BlockStart/Delta/Stop::index` のマッピングは
|
||||||
//! [`OpenAIResponsesState`] が保持する。
|
//! [`OpenAIResponsesState`] が保持する。
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
use crate::llm_client::{
|
use crate::llm_client::{
|
||||||
ClientError,
|
ClientError,
|
||||||
|
|
@ -255,12 +256,16 @@ struct InputTokensDetails {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ResponseFailed {
|
struct ResponseFailed {
|
||||||
response: FailedResponse,
|
response: FailedResponse,
|
||||||
|
#[serde(flatten)]
|
||||||
|
extra: BTreeMap<String, Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct FailedResponse {
|
struct FailedResponse {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
error: Option<ErrorDetail>,
|
error: Option<ErrorDetail>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
extra: BTreeMap<String, Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -269,6 +274,17 @@ struct ErrorDetail {
|
||||||
error_type: Option<String>,
|
error_type: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
message: Option<String>,
|
message: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
code: Option<String>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
extra: BTreeMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TopLevelErrorEnvelope {
|
||||||
|
error: TopLevelError,
|
||||||
|
#[serde(flatten)]
|
||||||
|
extra: BTreeMap<String, Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -279,6 +295,8 @@ struct TopLevelError {
|
||||||
error_type: Option<String>,
|
error_type: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
code: Option<String>,
|
code: Option<String>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
extra: BTreeMap<String, Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -325,10 +343,7 @@ pub(crate) fn parse_sse(
|
||||||
|
|
||||||
"response.failed" | "response.incomplete" => {
|
"response.failed" | "response.incomplete" => {
|
||||||
let ev: ResponseFailed = from_json(data)?;
|
let ev: ResponseFailed = from_json(data)?;
|
||||||
let (code, message) = match ev.response.error {
|
let (code, message) = response_failure_diagnostic(event_type, ev);
|
||||||
Some(err) => (err.error_type, err.message.unwrap_or_default()),
|
|
||||||
None => (None, format!("response {event_type}")),
|
|
||||||
};
|
|
||||||
Ok(vec![
|
Ok(vec![
|
||||||
Event::Error(ErrorEvent { code, message }),
|
Event::Error(ErrorEvent { code, message }),
|
||||||
Event::Status(StatusEvent {
|
Event::Status(StatusEvent {
|
||||||
|
|
@ -551,15 +566,19 @@ pub(crate) fn parse_sse(
|
||||||
}
|
}
|
||||||
|
|
||||||
"error" => {
|
"error" => {
|
||||||
let ev: TopLevelError = from_json(data).unwrap_or(TopLevelError {
|
let ev = from_json::<TopLevelErrorEnvelope>(data).unwrap_or_else(|_| {
|
||||||
|
TopLevelErrorEnvelope {
|
||||||
|
error: TopLevelError {
|
||||||
message: Some(data.to_string()),
|
message: Some(data.to_string()),
|
||||||
error_type: None,
|
error_type: None,
|
||||||
code: None,
|
code: None,
|
||||||
|
extra: BTreeMap::new(),
|
||||||
|
},
|
||||||
|
extra: BTreeMap::new(),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
Ok(vec![Event::Error(ErrorEvent {
|
let (code, message) = top_level_error_diagnostic(ev);
|
||||||
code: ev.error_type.or(ev.code),
|
Ok(vec![Event::Error(ErrorEvent { code, message })])
|
||||||
message: ev.message.unwrap_or_default(),
|
|
||||||
})])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 未対応 / 情報系イベントは無視
|
// 未対応 / 情報系イベントは無視
|
||||||
|
|
@ -567,6 +586,121 @@ pub(crate) fn parse_sse(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn response_failure_diagnostic(event_type: &str, ev: ResponseFailed) -> (Option<String>, 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>, 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<String, Value>, 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, Value>) -> String {
|
||||||
|
if diagnostic.len() <= 1 {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
format!("{} | diagnostic={}", message, Value::Object(diagnostic))
|
||||||
|
}
|
||||||
|
|
||||||
/// 対応する BlockStart がまだ発行されていなければ発行しつつ、delta を流す。
|
/// 対応する BlockStart がまだ発行されていなければ発行しつつ、delta を流す。
|
||||||
/// content_part.added を取りこぼしても delta 単独で復旧できるようにする。
|
/// content_part.added を取りこぼしても delta 単独で復旧できるようにする。
|
||||||
fn ensure_and_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]
|
#[test]
|
||||||
fn reasoning_output_item_emits_reasoning_item_with_text_summary_encrypted() {
|
fn reasoning_output_item_emits_reasoning_item_with_text_summary_encrypted() {
|
||||||
// 完成済み reasoning wrapper が text + summary[] + encrypted_content を持って
|
// 完成済み reasoning wrapper が text + summary[] + encrypted_content を持って
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user