fix: return thinking as reasoning output item per OpenAI spec

Thinking content was previously returned as non-standard top-level
fields (thinking, thinking_duration). Now follows the official OpenAI
Responses API format:

- Reasoning appears as a 'type: reasoning' item in the output array
  with summary[].text containing the thinking content
- Message item follows after the reasoning item
- thinking_signature kept as proxy extension (internal multi-turn data)
- Removed ResponseOutput/OutputContent structs in favor of
  serde_json::Value for polymorphic output items
This commit is contained in:
Nikketryhard
2026-02-14 19:16:12 -06:00
parent 7c4e781900
commit 19dc920872
2 changed files with 68 additions and 83 deletions

View File

@@ -76,7 +76,9 @@ pub(crate) struct ResponsesResponse {
#[serde(serialize_with = "serialize_option_u64")]
pub max_output_tokens: Option<u64>,
pub model: String,
pub output: Vec<ResponseOutput>,
/// Output items — can contain both `"reasoning"` and `"message"` items.
/// Uses serde_json::Value because reasoning and message items have different shapes.
pub output: Vec<serde_json::Value>,
pub parallel_tool_calls: bool,
pub previous_response_id: Option<String>,
pub reasoning: Reasoning,
@@ -91,34 +93,10 @@ pub(crate) struct ResponsesResponse {
pub user: Option<String>,
pub metadata: serde_json::Value,
/// Proxy extension: opaque thinking verification signature.
/// Present for all models. Required for multi-turn chaining with thinking models.
/// Required for multi-turn chaining with thinking models.
/// Not part of the official OpenAI spec — internal proxy data.
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking_signature: Option<String>,
/// Proxy extension: the model's internal reasoning/thinking content.
/// Available for all models (Opus, Gemini Flash, Gemini Pro).
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<String>,
/// Proxy extension: time spent thinking (e.g. "0.041999832s").
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking_duration: Option<String>,
}
#[derive(Serialize, Clone)]
pub(crate) struct ResponseOutput {
#[serde(rename = "type")]
pub output_type: &'static str,
pub id: String,
pub status: &'static str,
pub role: &'static str,
pub content: Vec<OutputContent>,
}
#[derive(Serialize, Clone)]
pub(crate) struct OutputContent {
#[serde(rename = "type")]
pub content_type: &'static str,
pub text: String,
pub annotations: Vec<serde_json::Value>,
}
#[derive(Serialize, Clone)]
@@ -201,6 +179,47 @@ impl Default for TextFormat {
}
}
// ─── Output item builders ────────────────────────────────────────────────────
/// Build a reasoning output item (goes BEFORE the message item in `output`).
/// Matches: https://platform.openai.com/docs/api-reference/responses
pub fn build_reasoning_output(thinking_text: &str) -> serde_json::Value {
serde_json::json!({
"id": format!("rs_{}", uuid::Uuid::new_v4().to_string().replace('-', "")),
"type": "reasoning",
"summary": [{
"type": "summary_text",
"text": thinking_text,
}],
})
}
/// Build a message output item.
pub fn build_message_output(msg_id: &str, text: &str) -> serde_json::Value {
serde_json::json!({
"type": "message",
"id": msg_id,
"status": "completed",
"role": "assistant",
"content": [{
"type": "output_text",
"text": text,
"annotations": [],
}],
})
}
/// Build an in-progress message output item (no content yet, for streaming).
pub fn build_message_output_in_progress(msg_id: &str) -> serde_json::Value {
serde_json::json!({
"type": "message",
"id": msg_id,
"status": "in_progress",
"role": "assistant",
"content": [],
})
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/// Serialize Option<u64> as either the number or JSON null (not omitted).