openai-responses対応

This commit is contained in:
2026-04-20 02:59:16 +09:00
parent 5aea67ff5e
commit 74a45f86b9
12 changed files with 1613 additions and 46 deletions
@@ -10,6 +10,7 @@
pub mod anthropic;
pub mod gemini;
pub mod openai_chat;
pub mod openai_responses;
use serde_json::Value;
@@ -3,47 +3,71 @@
//! OpenAI 本家の主要モデルのみ網羅する。OpenRouter / xAI / Groq 等は
//! モデル ID が各社独自なので、マニフェスト側で明示 override する
//! 前提。
//!
//! [`classify`] はモデル ID から family を判定する一次情報で、
//! `scheme/openai_responses` からも参照される。
use crate::llm_client::capability::{
CacheStrategy, ModelCapability, ReasoningSupport, StructuredOutput, ToolCallingSupport,
};
pub(crate) fn lookup(model_id: &str) -> Option<ModelCapability> {
// GPT-5 / o1 / o3 / o4 reasoning 系
/// OpenAI 本家のモデル family 分類。
///
/// `openai_chat` と `openai_responses` で共有する一次情報。各 scheme は
/// この分類に自 scheme 固有の `ReasoningSupport` 等を当てはめて
/// `ModelCapability` を組み立てる。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OpenAiFamily {
/// GPT-5 / o1 / o3 / o4 系 — reasoning 対応
Reasoning,
/// GPT-4o / GPT-4 系
Gpt4,
/// GPT-3.5 系(旧式)
Gpt35,
}
/// モデル ID の prefix から family を判定する。未知は `None`。
pub(crate) fn classify(model_id: &str) -> Option<OpenAiFamily> {
if model_id.starts_with("gpt-5")
|| model_id.starts_with("o1")
|| model_id.starts_with("o3")
|| model_id.starts_with("o4")
{
return Some(ModelCapability {
return Some(OpenAiFamily::Reasoning);
}
if model_id.starts_with("gpt-4") {
return Some(OpenAiFamily::Gpt4);
}
if model_id.starts_with("gpt-3.5") {
return Some(OpenAiFamily::Gpt35);
}
None
}
pub(crate) fn lookup(model_id: &str) -> Option<ModelCapability> {
classify(model_id).map(|family| match family {
OpenAiFamily::Reasoning => ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
structured_output: StructuredOutput::JsonSchema,
reasoning: Some(ReasoningSupport::Effort),
vision: true,
prompt_caching: CacheStrategy::Auto,
});
}
// GPT-4o / GPT-4 系
if model_id.starts_with("gpt-4") {
return Some(ModelCapability {
},
OpenAiFamily::Gpt4 => ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
structured_output: StructuredOutput::JsonSchema,
reasoning: None,
vision: true,
prompt_caching: CacheStrategy::Auto,
});
}
// GPT-3.5 系(旧式・structured output 限定)
if model_id.starts_with("gpt-3.5") {
return Some(ModelCapability {
},
OpenAiFamily::Gpt35 => ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
structured_output: StructuredOutput::JsonObject,
reasoning: None,
vision: false,
prompt_caching: CacheStrategy::Auto,
});
}
None
},
})
}
/// Scheme 既定の capability。OpenAI 互換ルーター系(xAI / Groq / OpenRouter 等)
@@ -3,7 +3,7 @@
//! - リクエストJSON生成
//! - SSEイベントパース → Event変換
mod capability;
pub(crate) mod capability;
mod events;
mod request;
mod scheme_impl;
@@ -0,0 +1,46 @@
//! `model_id → ModelCapability` 静的テーブル(OpenAI Responses API)。
//!
//! モデル family 判定は `scheme/openai_chat/capability.rs::classify` を
//! 共有する。Responses 側は `ReasoningSupport::Effort` 固定で、prompt
//! caching はサーバ側自動(`CacheStrategy::Auto`)。
use crate::llm_client::capability::{
CacheStrategy, ModelCapability, ReasoningSupport, StructuredOutput, ToolCallingSupport,
};
use crate::llm_client::scheme::openai_chat::capability::{OpenAiFamily, classify};
pub(crate) fn lookup(model_id: &str) -> Option<ModelCapability> {
classify(model_id).map(|family| match family {
OpenAiFamily::Reasoning => ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
structured_output: StructuredOutput::JsonSchema,
reasoning: Some(ReasoningSupport::Effort),
vision: true,
prompt_caching: CacheStrategy::Auto,
},
OpenAiFamily::Gpt4 => ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
structured_output: StructuredOutput::JsonSchema,
reasoning: None,
vision: true,
prompt_caching: CacheStrategy::Auto,
},
OpenAiFamily::Gpt35 => ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
structured_output: StructuredOutput::JsonObject,
reasoning: None,
vision: false,
prompt_caching: CacheStrategy::Auto,
},
})
}
pub(crate) fn default_capability() -> ModelCapability {
ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
structured_output: StructuredOutput::JsonSchema,
reasoning: None,
vision: false,
prompt_caching: CacheStrategy::Auto,
}
}
@@ -0,0 +1,782 @@
//! OpenAI Responses API の SSE イベントパース
//!
//! `response.*` 名前空間の SSE を共通の [`Event`](crate::llm_client::event::Event)
//! に変換する。Responses の (output_index, content_index) 2 次元座標と
//! insomnia 側 1 次元 `BlockStart/Delta/Stop::index` のマッピングは
//! [`OpenAIResponsesState`] が保持する。
use std::collections::HashMap;
use serde::Deserialize;
use crate::llm_client::{
ClientError,
event::{
BlockDelta, BlockMetadata, BlockStart, BlockStop, BlockType, DeltaContent, ErrorEvent,
Event, ResponseStatus, StatusEvent, UsageEvent,
},
};
/// SSE パース中の座標 → flat block index マップ。
#[derive(Debug, Default)]
pub struct OpenAIResponsesState {
slots: HashMap<SlotKey, SlotInfo>,
next_index: usize,
}
impl OpenAIResponsesState {
fn allocate(&mut self, key: SlotKey, block_type: BlockType) -> SlotInfo {
let info = SlotInfo {
flat_index: self.next_index,
block_type,
};
self.next_index += 1;
self.slots.insert(key, info);
info
}
/// 既存 slot を取得。無ければ `block_type` で暗黙に確保し、
/// 新規確保したかを併せて返す。delta 先行 / content_part.added が
/// 抜けたときの防御。
fn get_or_allocate(
&mut self,
key: SlotKey,
block_type: BlockType,
) -> (SlotInfo, bool) {
if let Some(info) = self.slots.get(&key).copied() {
(info, false)
} else {
(self.allocate(key, block_type), true)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum SlotKey {
/// tool_use (function_call / custom_tool_call) — output_item 全体で 1 block
OutputItem(usize),
/// message の output_text / reasoning item の reasoning_text
ContentPart { output: usize, content: usize },
/// reasoning item の summary_text (summary_index)
Summary { output: usize, summary: usize },
}
#[derive(Debug, Clone, Copy)]
struct SlotInfo {
flat_index: usize,
block_type: BlockType,
}
// ============================================================================
// SSE イベントの JSON 構造
// ============================================================================
#[derive(Debug, Deserialize)]
struct OutputItemAdded {
output_index: usize,
item: OutputItem,
}
#[derive(Debug, Deserialize)]
struct OutputItemDone {
output_index: usize,
#[allow(dead_code)]
item: OutputItem,
}
/// `response.output_item.added/done` の `item`。
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum OutputItem {
Message {
#[allow(dead_code)]
id: Option<String>,
},
Reasoning {
#[allow(dead_code)]
id: Option<String>,
},
FunctionCall {
#[allow(dead_code)]
#[serde(default)]
id: Option<String>,
call_id: String,
name: String,
},
CustomToolCall {
#[allow(dead_code)]
#[serde(default)]
id: Option<String>,
call_id: String,
name: String,
},
#[serde(other)]
Other,
}
#[derive(Debug, Deserialize)]
struct ContentPartAdded {
output_index: usize,
content_index: usize,
part: ContentPart,
}
#[derive(Debug, Deserialize)]
struct ContentPartDone {
output_index: usize,
content_index: usize,
#[allow(dead_code)]
part: ContentPart,
}
/// `response.content_part.added/done` の `part`。
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ContentPart {
OutputText {
#[allow(dead_code)]
#[serde(default)]
text: String,
},
ReasoningText {
#[allow(dead_code)]
#[serde(default)]
text: String,
},
#[serde(other)]
Other,
}
#[derive(Debug, Deserialize)]
struct OutputTextDelta {
output_index: usize,
content_index: usize,
delta: String,
}
#[derive(Debug, Deserialize)]
struct ReasoningTextDelta {
output_index: usize,
content_index: usize,
delta: String,
}
#[derive(Debug, Deserialize)]
struct ReasoningSummaryPartAdded {
output_index: usize,
summary_index: usize,
#[allow(dead_code)]
#[serde(default)]
part: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct ReasoningSummaryTextDelta {
output_index: usize,
summary_index: usize,
delta: String,
}
#[derive(Debug, Deserialize)]
struct ReasoningSummaryPartDone {
output_index: usize,
summary_index: usize,
}
#[derive(Debug, Deserialize)]
struct FunctionCallArgumentsDelta {
output_index: usize,
delta: String,
}
#[derive(Debug, Deserialize)]
struct CustomToolCallInputDelta {
output_index: usize,
delta: String,
}
#[derive(Debug, Deserialize)]
struct ResponseCompleted {
response: CompletedResponse,
}
#[derive(Debug, Deserialize)]
struct CompletedResponse {
#[serde(default)]
usage: Option<ResponsesUsage>,
}
#[derive(Debug, Deserialize)]
struct ResponsesUsage {
#[serde(default)]
input_tokens: Option<u64>,
#[serde(default)]
output_tokens: Option<u64>,
#[serde(default)]
total_tokens: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct ResponseFailed {
response: FailedResponse,
}
#[derive(Debug, Deserialize)]
struct FailedResponse {
#[serde(default)]
error: Option<ErrorDetail>,
}
#[derive(Debug, Deserialize)]
struct ErrorDetail {
#[serde(rename = "type", default)]
error_type: Option<String>,
#[serde(default)]
message: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TopLevelError {
#[serde(default)]
message: Option<String>,
#[serde(rename = "type", default)]
error_type: Option<String>,
#[serde(default)]
code: Option<String>,
}
// ============================================================================
// parse entry point
// ============================================================================
/// SSE フレーム 1 件をパースし、0 個以上の [`Event`] に変換する。
///
/// `event_type` は SSE の `event:` フィールド。未対応の event は
/// 静かに無視する。`data` が JSON でない / 必要なフィールドが抜けて
/// いる等は [`ClientError::Api`] で返す。
pub(crate) fn parse_sse(
event_type: &str,
data: &str,
state: &mut OpenAIResponsesState,
) -> Result<Vec<Event>, ClientError> {
match event_type {
"response.created" => Ok(vec![Event::Status(StatusEvent {
status: ResponseStatus::Started,
})]),
"response.completed" => {
let ev: ResponseCompleted = from_json(data)?;
let mut out = Vec::new();
if let Some(usage) = ev.response.usage {
out.push(Event::Usage(UsageEvent {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
total_tokens: usage.total_tokens.or_else(|| {
Some(usage.input_tokens.unwrap_or(0) + usage.output_tokens.unwrap_or(0))
}),
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
}));
}
out.push(Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}));
Ok(out)
}
"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}")),
};
Ok(vec![
Event::Error(ErrorEvent { code, message }),
Event::Status(StatusEvent {
status: ResponseStatus::Failed,
}),
])
}
"response.output_item.added" => {
let ev: OutputItemAdded = from_json(data)?;
match ev.item {
OutputItem::FunctionCall { call_id, name, .. }
| OutputItem::CustomToolCall { call_id, name, .. } => {
let info = state
.allocate(SlotKey::OutputItem(ev.output_index), BlockType::ToolUse);
Ok(vec![Event::BlockStart(BlockStart {
index: info.flat_index,
block_type: BlockType::ToolUse,
metadata: BlockMetadata::ToolUse {
id: call_id,
name,
},
})])
}
_ => Ok(Vec::new()),
}
}
"response.output_item.done" => {
let ev: OutputItemDone = from_json(data)?;
if let Some(info) = state.slots.remove(&SlotKey::OutputItem(ev.output_index)) {
Ok(vec![Event::BlockStop(BlockStop {
index: info.flat_index,
block_type: info.block_type,
stop_reason: None,
})])
} else {
Ok(Vec::new())
}
}
"response.content_part.added" => {
let ev: ContentPartAdded = from_json(data)?;
let (block_type, metadata) = match ev.part {
ContentPart::OutputText { .. } => (BlockType::Text, BlockMetadata::Text),
ContentPart::ReasoningText { .. } => (BlockType::Thinking, BlockMetadata::Thinking),
ContentPart::Other => return Ok(Vec::new()),
};
let info = state.allocate(
SlotKey::ContentPart {
output: ev.output_index,
content: ev.content_index,
},
block_type,
);
Ok(vec![Event::BlockStart(BlockStart {
index: info.flat_index,
block_type,
metadata,
})])
}
"response.content_part.done" => {
let ev: ContentPartDone = from_json(data)?;
if let Some(info) = state.slots.remove(&SlotKey::ContentPart {
output: ev.output_index,
content: ev.content_index,
}) {
Ok(vec![Event::BlockStop(BlockStop {
index: info.flat_index,
block_type: info.block_type,
stop_reason: None,
})])
} else {
Ok(Vec::new())
}
}
"response.output_text.delta" => {
let ev: OutputTextDelta = from_json(data)?;
Ok(ensure_and_delta(
state,
SlotKey::ContentPart {
output: ev.output_index,
content: ev.content_index,
},
BlockType::Text,
BlockMetadata::Text,
DeltaContent::Text(ev.delta),
))
}
"response.reasoning_text.delta" => {
let ev: ReasoningTextDelta = from_json(data)?;
Ok(ensure_and_delta(
state,
SlotKey::ContentPart {
output: ev.output_index,
content: ev.content_index,
},
BlockType::Thinking,
BlockMetadata::Thinking,
DeltaContent::Thinking(ev.delta),
))
}
"response.reasoning_summary_part.added" => {
let ev: ReasoningSummaryPartAdded = from_json(data)?;
let info = state.allocate(
SlotKey::Summary {
output: ev.output_index,
summary: ev.summary_index,
},
BlockType::Thinking,
);
Ok(vec![Event::BlockStart(BlockStart {
index: info.flat_index,
block_type: BlockType::Thinking,
metadata: BlockMetadata::Thinking,
})])
}
"response.reasoning_summary_text.delta" => {
let ev: ReasoningSummaryTextDelta = from_json(data)?;
Ok(ensure_and_delta(
state,
SlotKey::Summary {
output: ev.output_index,
summary: ev.summary_index,
},
BlockType::Thinking,
BlockMetadata::Thinking,
DeltaContent::Thinking(ev.delta),
))
}
"response.reasoning_summary_part.done" => {
let ev: ReasoningSummaryPartDone = from_json(data)?;
if let Some(info) = state.slots.remove(&SlotKey::Summary {
output: ev.output_index,
summary: ev.summary_index,
}) {
Ok(vec![Event::BlockStop(BlockStop {
index: info.flat_index,
block_type: info.block_type,
stop_reason: None,
})])
} else {
Ok(Vec::new())
}
}
"response.function_call_arguments.delta" => {
let ev: FunctionCallArgumentsDelta = from_json(data)?;
Ok(ensure_and_delta(
state,
SlotKey::OutputItem(ev.output_index),
BlockType::ToolUse,
BlockMetadata::ToolUse {
id: String::new(),
name: String::new(),
},
DeltaContent::InputJson(ev.delta),
))
}
"response.custom_tool_call_input.delta" => {
let ev: CustomToolCallInputDelta = from_json(data)?;
Ok(ensure_and_delta(
state,
SlotKey::OutputItem(ev.output_index),
BlockType::ToolUse,
BlockMetadata::ToolUse {
id: String::new(),
name: String::new(),
},
DeltaContent::InputJson(ev.delta),
))
}
"error" => {
let ev: TopLevelError = from_json(data).unwrap_or(TopLevelError {
message: Some(data.to_string()),
error_type: None,
code: None,
});
Ok(vec![Event::Error(ErrorEvent {
code: ev.error_type.or(ev.code),
message: ev.message.unwrap_or_default(),
})])
}
// 未対応 / 情報系イベントは無視
_ => Ok(Vec::new()),
}
}
/// 対応する BlockStart がまだ発行されていなければ発行しつつ、delta を流す。
/// content_part.added を取りこぼしても delta 単独で復旧できるようにする。
fn ensure_and_delta(
state: &mut OpenAIResponsesState,
key: SlotKey,
block_type: BlockType,
metadata: BlockMetadata,
delta: DeltaContent,
) -> Vec<Event> {
let (info, just_created) = state.get_or_allocate(key, block_type);
let mut out = Vec::with_capacity(2);
if just_created {
out.push(Event::BlockStart(BlockStart {
index: info.flat_index,
block_type,
metadata,
}));
}
out.push(Event::BlockDelta(BlockDelta {
index: info.flat_index,
delta,
}));
out
}
fn from_json<T: for<'de> Deserialize<'de>>(data: &str) -> Result<T, ClientError> {
serde_json::from_str(data).map_err(|e| ClientError::Api {
status: None,
code: Some("parse_error".to_string()),
message: format!("Failed to parse SSE data: {e}"),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn run(event_type: &str, data: &str) -> (Vec<Event>, OpenAIResponsesState) {
let mut state = OpenAIResponsesState::default();
let events = parse_sse(event_type, data, &mut state).unwrap();
(events, state)
}
fn with(
state: &mut OpenAIResponsesState,
event_type: &str,
data: &str,
) -> Vec<Event> {
parse_sse(event_type, data, state).unwrap()
}
#[test]
fn created_emits_status_started() {
let (events, _) = run("response.created", r#"{"response":{}}"#);
assert!(matches!(
events[0],
Event::Status(StatusEvent {
status: ResponseStatus::Started
})
));
}
#[test]
fn completed_emits_usage_and_status() {
let data = r#"{"response":{"usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30}}}"#;
let (events, _) = run("response.completed", data);
assert!(matches!(events[0], Event::Usage(_)));
assert!(matches!(
events[1],
Event::Status(StatusEvent {
status: ResponseStatus::Completed
})
));
if let Event::Usage(u) = &events[0] {
assert_eq!(u.input_tokens, Some(10));
assert_eq!(u.output_tokens, Some(20));
assert_eq!(u.total_tokens, Some(30));
}
}
#[test]
fn text_stream_start_delta_stop() {
let mut state = OpenAIResponsesState::default();
// output_item.added (message) → 無視
with(
&mut state,
"response.output_item.added",
r#"{"output_index":0,"item":{"type":"message","id":"m1"}}"#,
);
// content_part.added (output_text) → BlockStart(Text)
let ev = with(
&mut state,
"response.content_part.added",
r#"{"output_index":0,"content_index":0,"item_id":"m1","part":{"type":"output_text","text":""}}"#,
);
assert_eq!(ev.len(), 1);
assert!(matches!(ev[0], Event::BlockStart(_)));
// delta
let ev = with(
&mut state,
"response.output_text.delta",
r#"{"output_index":0,"content_index":0,"item_id":"m1","delta":"hi"}"#,
);
assert_eq!(ev.len(), 1);
if let Event::BlockDelta(d) = &ev[0] {
assert!(matches!(&d.delta, DeltaContent::Text(t) if t == "hi"));
} else {
panic!("expected delta");
}
// content_part.done → BlockStop
let ev = with(
&mut state,
"response.content_part.done",
r#"{"output_index":0,"content_index":0,"item_id":"m1","part":{"type":"output_text","text":"hi"}}"#,
);
assert_eq!(ev.len(), 1);
if let Event::BlockStop(s) = &ev[0] {
assert_eq!(s.block_type, BlockType::Text);
} else {
panic!("expected stop");
}
}
#[test]
fn function_call_start_delta_stop() {
let mut state = OpenAIResponsesState::default();
// output_item.added (function_call) → BlockStart(ToolUse, id, name)
let ev = with(
&mut state,
"response.output_item.added",
r#"{"output_index":1,"item":{"type":"function_call","id":"fc1","call_id":"call_abc","name":"get_weather"}}"#,
);
assert_eq!(ev.len(), 1);
if let Event::BlockStart(s) = &ev[0] {
assert_eq!(s.block_type, BlockType::ToolUse);
if let BlockMetadata::ToolUse { id, name } = &s.metadata {
assert_eq!(id, "call_abc");
assert_eq!(name, "get_weather");
} else {
panic!("expected ToolUse metadata");
}
} else {
panic!("expected BlockStart");
}
// arguments delta
let ev = with(
&mut state,
"response.function_call_arguments.delta",
r#"{"output_index":1,"item_id":"fc1","delta":"{\"x\":"}"#,
);
assert_eq!(ev.len(), 1);
if let Event::BlockDelta(d) = &ev[0] {
assert!(matches!(&d.delta, DeltaContent::InputJson(j) if j == "{\"x\":"));
}
// output_item.done → BlockStop
let ev = with(
&mut state,
"response.output_item.done",
r#"{"output_index":1,"item":{"type":"function_call","call_id":"call_abc","name":"get_weather","arguments":"{\"x\":1}"}}"#,
);
assert_eq!(ev.len(), 1);
assert!(matches!(ev[0], Event::BlockStop(_)));
}
#[test]
fn custom_tool_call_input_delta_parsed() {
let mut state = OpenAIResponsesState::default();
with(
&mut state,
"response.output_item.added",
r#"{"output_index":0,"item":{"type":"custom_tool_call","id":"ct1","call_id":"call_xyz","name":"custom"}}"#,
);
let ev = with(
&mut state,
"response.custom_tool_call_input.delta",
r#"{"output_index":0,"item_id":"ct1","delta":"raw"}"#,
);
assert_eq!(ev.len(), 1);
if let Event::BlockDelta(d) = &ev[0] {
assert!(matches!(&d.delta, DeltaContent::InputJson(j) if j == "raw"));
} else {
panic!("expected delta");
}
}
#[test]
fn reasoning_text_delta_emits_thinking() {
let mut state = OpenAIResponsesState::default();
with(
&mut state,
"response.content_part.added",
r#"{"output_index":0,"content_index":0,"item_id":"r1","part":{"type":"reasoning_text","text":""}}"#,
);
let ev = with(
&mut state,
"response.reasoning_text.delta",
r#"{"output_index":0,"content_index":0,"item_id":"r1","delta":"think"}"#,
);
if let Event::BlockDelta(d) = &ev[0] {
assert!(matches!(&d.delta, DeltaContent::Thinking(t) if t == "think"));
} else {
panic!("expected thinking delta");
}
}
#[test]
fn reasoning_summary_start_delta_stop() {
let mut state = OpenAIResponsesState::default();
let ev = with(
&mut state,
"response.reasoning_summary_part.added",
r#"{"output_index":0,"summary_index":0,"item_id":"r1","part":{"type":"summary_text","text":""}}"#,
);
assert!(matches!(ev[0], Event::BlockStart(_)));
let ev = with(
&mut state,
"response.reasoning_summary_text.delta",
r#"{"output_index":0,"summary_index":0,"item_id":"r1","delta":"sum"}"#,
);
if let Event::BlockDelta(d) = &ev[0] {
assert!(matches!(&d.delta, DeltaContent::Thinking(t) if t == "sum"));
}
let ev = with(
&mut state,
"response.reasoning_summary_part.done",
r#"{"output_index":0,"summary_index":0,"item_id":"r1"}"#,
);
assert!(matches!(ev[0], Event::BlockStop(_)));
}
#[test]
fn delta_without_prior_start_recovers() {
// 防御: content_part.added が落ちても delta 単独で BlockStart+Delta を発行
let mut state = OpenAIResponsesState::default();
let ev = with(
&mut state,
"response.output_text.delta",
r#"{"output_index":0,"content_index":0,"item_id":"m1","delta":"hi"}"#,
);
assert_eq!(ev.len(), 2);
assert!(matches!(ev[0], Event::BlockStart(_)));
assert!(matches!(ev[1], Event::BlockDelta(_)));
}
#[test]
fn parallel_output_items_get_distinct_indices() {
// 2 つの function_call が並列で output_item.added される場合、
// flat index が別々になる(Parallel tool calling の基本)。
let mut state = OpenAIResponsesState::default();
let ev1 = with(
&mut state,
"response.output_item.added",
r#"{"output_index":0,"item":{"type":"function_call","id":"a","call_id":"c1","name":"t1"}}"#,
);
let ev2 = with(
&mut state,
"response.output_item.added",
r#"{"output_index":1,"item":{"type":"function_call","id":"b","call_id":"c2","name":"t2"}}"#,
);
let i1 = if let Event::BlockStart(s) = &ev1[0] {
s.index
} else {
panic!()
};
let i2 = if let Event::BlockStart(s) = &ev2[0] {
s.index
} else {
panic!()
};
assert_ne!(i1, i2);
}
#[test]
fn failed_response_emits_error_and_status() {
let data =
r#"{"response":{"error":{"type":"invalid_request_error","message":"bad"}}}"#;
let (events, _) = run("response.failed", data);
assert_eq!(events.len(), 2);
assert!(matches!(events[0], Event::Error(_)));
assert!(matches!(
events[1],
Event::Status(StatusEvent {
status: ResponseStatus::Failed
})
));
}
#[test]
fn unknown_event_is_ignored() {
let (events, _) = run("response.in_progress", "{}");
assert!(events.is_empty());
}
}
@@ -0,0 +1,59 @@
//! OpenAI Responses API スキーマ (`/v1/responses`)
//!
//! Chat Completions とは別物の item-based wire format。reasoning item と
//! function_call item が first-class で、SSE イベントも `response.*` 名前空間で
//! 流れる。ChatGPT OAuth 経路 (codex) は本 scheme 必須。
//!
//! - リクエスト JSON 生成: [`request`]
//! - SSE イベントパース → [`Event`](crate::llm_client::event::Event) 変換: [`events`]
mod capability;
mod events;
mod request;
mod scheme_impl;
pub use scheme_impl::OpenAIResponsesState;
/// OpenAI Responses scheme 本体。
///
/// `store` / `include_encrypted_content` は scheme 固定の wire 設定で、
/// デフォルトは stateless + ZDR 相当 (`store=false`, `include=[...]`)。
/// 将来 ZDR 非対応環境で `store=true` にしたくなった場合に限り override
/// する。`ModelCapability` には入れない(これはモデルの能力ではなく、
/// クライアントの運用方針)。
#[derive(Debug, Clone)]
pub struct OpenAIResponsesScheme {
/// サーバ側に response を保存するか。ZDR/stateless 運用では `false`。
pub store: bool,
/// `include: ["reasoning.encrypted_content"]` を付けるか。
/// `store=false` で reasoning を使うなら必須。
pub include_encrypted_content: bool,
}
impl Default for OpenAIResponsesScheme {
fn default() -> Self {
Self {
store: false,
include_encrypted_content: true,
}
}
}
impl OpenAIResponsesScheme {
/// デフォルト設定 (`store=false`, `include=["reasoning.encrypted_content"]`)。
pub fn new() -> Self {
Self::default()
}
/// `store` を上書き。
pub fn with_store(mut self, store: bool) -> Self {
self.store = store;
self
}
/// `include: ["reasoning.encrypted_content"]` の有無を上書き。
pub fn with_include_encrypted_content(mut self, include: bool) -> Self {
self.include_encrypted_content = include;
self
}
}
@@ -0,0 +1,461 @@
//! OpenAI Responses API リクエスト body 生成
//!
//! Chat Completions の `messages` と違い、Responses は `input[]` の
//! item 配列で reasoning / function_call / function_call_output が
//! first-class。`Item` を素に近い形で `input[]` に投影できる。
use serde::Serialize;
use serde_json::Value;
use crate::llm_client::{
Request,
capability::{ModelCapability, ReasoningEffort, ReasoningSupport},
types::{ContentPart, Item, Role, ToolDefinition, parse_tool_arguments},
};
use super::OpenAIResponsesScheme;
/// `/v1/responses` のリクエスト body。
#[derive(Debug, Serialize)]
pub(crate) struct ResponsesRequest {
pub model: String,
/// システムプロンプト相当。`input[]` とは別フィールド。
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
pub input: Vec<InputItem>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<ResponseTool>,
/// 常時 `"auto"` を送る。scheme 固定値。
pub tool_choice: &'static str,
/// 常時 `true` を送る。scheme 固定値。
pub parallel_tool_calls: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<ReasoningConfig>,
/// ZDR / stateless 運用では `false`。
pub store: bool,
/// 常時 `true`。
pub stream: bool,
/// `["reasoning.encrypted_content"]` 等。
#[serde(skip_serializing_if = "Vec::is_empty")]
pub include: Vec<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
}
/// reasoning 制御。
#[derive(Debug, Serialize)]
pub(crate) struct ReasoningConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<&'static str>,
/// summary の出力制御。`"auto"` 固定で summary_text を受け取る。
pub summary: &'static str,
}
/// `input[]` の 1 要素。
///
/// Responses API の item 型を素に近い形で投影する。未対応 type は
/// 無視(reasoning 送信時に `content: []` の場合は `None` として弾く)。
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum InputItem {
/// 会話メッセージ。user / assistant / system のいずれか。
Message {
role: &'static str,
content: Vec<InputContent>,
},
/// 過去の function tool 呼び出し(assistant 側)。
FunctionCall {
call_id: String,
name: String,
/// JSON 文字列(object でなくても正規化済み)。
arguments: String,
},
/// function tool の結果(user 側)。
FunctionCallOutput {
call_id: String,
/// Responses は文字列 or 構造化 output を許すが、ここでは
/// `summary` + `content` を改行連結した文字列で送る。
output: String,
},
/// reasoning item。`encrypted_content` があれば必ず添える。
Reasoning {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
summary: Vec<ReasoningSummaryPart>,
#[serde(skip_serializing_if = "Vec::is_empty")]
content: Vec<ReasoningContentPart>,
#[serde(skip_serializing_if = "Option::is_none")]
encrypted_content: Option<String>,
},
}
/// メッセージ content_part。role で input/output を使い分ける。
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum InputContent {
/// user / system 側のテキスト
InputText { text: String },
/// assistant 側のテキスト
OutputText { text: String },
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum ReasoningSummaryPart {
SummaryText { text: String },
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum ReasoningContentPart {
ReasoningText { text: String },
}
/// Responses 用 tool 定義。Chat と違い function キーでネストせず
/// トップレベルに `name` / `parameters` が載る。
#[derive(Debug, Serialize)]
pub(crate) struct ResponseTool {
#[serde(rename = "type")]
pub r#type: &'static str,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub parameters: Value,
/// Structured output モード制御。デフォルト false。
pub strict: bool,
}
impl OpenAIResponsesScheme {
/// `Request` から wire 形式の body を組み立てる。
pub(crate) fn build_request(
&self,
model: &str,
request: &Request,
capability: &ModelCapability,
) -> ResponsesRequest {
let input = convert_items_to_input(&request.items);
let tools = request.tools.iter().map(convert_tool).collect();
// Reasoning 投影: capability が Effort / Both をサポートし、かつ
// request 側で effort が指定されているときだけ reasoning を付ける。
let supports_effort = matches!(
capability.reasoning,
Some(ReasoningSupport::Effort | ReasoningSupport::Both),
);
let reasoning = request
.config
.reasoning
.as_ref()
.and_then(|rc| rc.effort)
.filter(|_| supports_effort)
.map(|effort| ReasoningConfig {
effort: Some(match effort {
ReasoningEffort::Low => "low",
ReasoningEffort::Medium => "medium",
ReasoningEffort::High => "high",
}),
summary: "auto",
});
let include: Vec<&'static str> = if self.include_encrypted_content {
vec!["reasoning.encrypted_content"]
} else {
Vec::new()
};
ResponsesRequest {
model: model.to_string(),
instructions: request.system_prompt.clone(),
input,
tools,
tool_choice: "auto",
parallel_tool_calls: true,
reasoning,
store: self.store,
stream: true,
include,
max_output_tokens: request.config.max_tokens,
temperature: request.config.temperature,
top_p: request.config.top_p,
}
}
}
/// `Item` 列を `input[]` に変換する。
fn convert_items_to_input(items: &[Item]) -> Vec<InputItem> {
let mut out = Vec::with_capacity(items.len());
for item in items {
match item {
Item::Message { role, content, .. } => {
let (role_str, text_variant): (&'static str, fn(String) -> InputContent) = match role
{
Role::User => ("user", |t| InputContent::InputText { text: t }),
Role::Assistant => ("assistant", |t| InputContent::OutputText { text: t }),
Role::System => ("system", |t| InputContent::InputText { text: t }),
};
let parts: Vec<InputContent> = content
.iter()
.map(|p| match p {
ContentPart::Text { text } => text_variant(text.clone()),
ContentPart::Refusal { refusal } => text_variant(refusal.clone()),
})
.collect();
out.push(InputItem::Message {
role: role_str,
content: parts,
});
}
Item::ToolCall {
call_id,
name,
arguments,
..
} => {
// 非 object / 旧形式の "null" を "{}" に正規化。
let normalized = parse_tool_arguments(arguments).to_string();
out.push(InputItem::FunctionCall {
call_id: call_id.clone(),
name: name.clone(),
arguments: normalized,
});
}
Item::ToolResult {
call_id,
summary,
content,
..
} => {
let text = match content {
Some(c) => format!("{summary}\n{c}"),
None => summary.clone(),
};
out.push(InputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: text,
});
}
Item::Reasoning {
id,
text,
summary,
encrypted_content,
..
} => {
let summary_parts = summary
.iter()
.filter(|s| !s.is_empty())
.map(|s| ReasoningSummaryPart::SummaryText { text: s.clone() })
.collect();
let content_parts = if text.is_empty() {
Vec::new()
} else {
vec![ReasoningContentPart::ReasoningText { text: text.clone() }]
};
out.push(InputItem::Reasoning {
id: id.clone(),
summary: summary_parts,
content: content_parts,
encrypted_content: encrypted_content.clone(),
});
}
}
}
out
}
fn convert_tool(tool: &ToolDefinition) -> ResponseTool {
ResponseTool {
r#type: "function",
name: tool.name.clone(),
description: tool.description.clone(),
parameters: tool.input_schema.clone(),
strict: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm_client::capability::{
CacheStrategy, ModelCapability, ReasoningControl, ReasoningEffort, ReasoningSupport,
StructuredOutput, ToolCallingSupport,
};
fn cap_with_reasoning() -> ModelCapability {
ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
structured_output: StructuredOutput::JsonSchema,
reasoning: Some(ReasoningSupport::Effort),
vision: true,
prompt_caching: CacheStrategy::Auto,
}
}
fn cap_no_reasoning() -> ModelCapability {
ModelCapability {
reasoning: None,
..cap_with_reasoning()
}
}
#[test]
fn scheme_defaults_to_stateless_zdr() {
let s = OpenAIResponsesScheme::new();
assert!(!s.store);
assert!(s.include_encrypted_content);
}
#[test]
fn includes_encrypted_content_when_enabled() {
let scheme = OpenAIResponsesScheme::new();
let req = Request::new().user("hi");
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
assert_eq!(body.include, vec!["reasoning.encrypted_content"]);
assert!(!body.store);
assert!(body.stream);
}
#[test]
fn instructions_from_system_prompt() {
let scheme = OpenAIResponsesScheme::new();
let req = Request::new().system("be terse").user("hi");
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
assert_eq!(body.instructions.as_deref(), Some("be terse"));
assert_eq!(body.input.len(), 1);
}
#[test]
fn tool_choice_and_parallel_are_fixed() {
let scheme = OpenAIResponsesScheme::new();
let req = Request::new().user("hi");
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
assert_eq!(body.tool_choice, "auto");
assert!(body.parallel_tool_calls);
}
#[test]
fn user_message_uses_input_text() {
let scheme = OpenAIResponsesScheme::new();
let req = Request::new().user("hi");
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
match &body.input[0] {
InputItem::Message { role, content } => {
assert_eq!(*role, "user");
assert_eq!(content.len(), 1);
assert!(matches!(&content[0], InputContent::InputText { text } if text == "hi"));
}
_ => panic!("expected message"),
}
}
#[test]
fn assistant_message_uses_output_text() {
let scheme = OpenAIResponsesScheme::new();
let req = Request::new().user("hi").assistant("hello");
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
match &body.input[1] {
InputItem::Message { role, content } => {
assert_eq!(*role, "assistant");
assert!(
matches!(&content[0], InputContent::OutputText { text } if text == "hello")
);
}
_ => panic!("expected message"),
}
}
#[test]
fn tool_call_and_result_become_function_items() {
let scheme = OpenAIResponsesScheme::new();
let req = Request::new()
.user("run")
.item(Item::tool_call("c1", "t", r#"{"a":1}"#))
.item(Item::tool_result("c1", "ok"));
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
assert!(matches!(body.input[1], InputItem::FunctionCall { .. }));
assert!(matches!(body.input[2], InputItem::FunctionCallOutput { .. }));
}
#[test]
fn reasoning_item_round_trips_encrypted_content() {
let scheme = OpenAIResponsesScheme::new();
let item = Item::reasoning("inner")
.with_reasoning_summary(vec!["s1".into()])
.with_encrypted_content("ENC");
let req = Request::new().user("hi").item(item);
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
match &body.input[1] {
InputItem::Reasoning {
summary,
content,
encrypted_content,
..
} => {
assert_eq!(summary.len(), 1);
assert_eq!(content.len(), 1);
assert_eq!(encrypted_content.as_deref(), Some("ENC"));
}
_ => panic!("expected reasoning"),
}
}
#[test]
fn reasoning_effort_projected_when_supported() {
let scheme = OpenAIResponsesScheme::new();
let mut req = Request::new().user("hi");
req.config.reasoning = Some(ReasoningControl {
effort: Some(ReasoningEffort::High),
budget_tokens: None,
});
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
let reasoning = body.reasoning.expect("reasoning should be set");
assert_eq!(reasoning.effort, Some("high"));
assert_eq!(reasoning.summary, "auto");
}
#[test]
fn reasoning_omitted_when_unsupported() {
let scheme = OpenAIResponsesScheme::new();
let mut req = Request::new().user("hi");
req.config.reasoning = Some(ReasoningControl {
effort: Some(ReasoningEffort::High),
budget_tokens: None,
});
let body = scheme.build_request("gpt-4o", &req, &cap_no_reasoning());
assert!(body.reasoning.is_none());
}
#[test]
fn max_output_tokens_passed_through() {
let scheme = OpenAIResponsesScheme::new();
let req = Request::new().user("hi").max_tokens(100);
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
assert_eq!(body.max_output_tokens, Some(100));
}
#[test]
fn serialized_body_has_expected_shape() {
// wire 形式が崩れていないかのスモークテスト
let scheme = OpenAIResponsesScheme::new();
let req = Request::new()
.system("sys")
.user("hi")
.tool(ToolDefinition::new("t").description("d"));
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
let json = serde_json::to_value(&body).unwrap();
assert_eq!(json["model"], "gpt-5");
assert_eq!(json["instructions"], "sys");
assert_eq!(json["tool_choice"], "auto");
assert_eq!(json["parallel_tool_calls"], true);
assert_eq!(json["store"], false);
assert_eq!(json["stream"], true);
assert_eq!(json["include"][0], "reasoning.encrypted_content");
assert_eq!(json["tools"][0]["type"], "function");
assert_eq!(json["tools"][0]["name"], "t");
}
}
@@ -0,0 +1,59 @@
//! `impl Scheme for OpenAIResponsesScheme`
use serde_json::Value;
use crate::llm_client::{
ClientError,
auth::AuthRequirement,
capability::ModelCapability,
event::Event,
scheme::Scheme,
types::Request,
};
use super::OpenAIResponsesScheme;
pub use super::events::OpenAIResponsesState;
impl Scheme for OpenAIResponsesScheme {
type State = OpenAIResponsesState;
fn default_base_url(&self) -> &'static str {
"https://api.openai.com"
}
fn path(&self, _model_id: &str) -> String {
"/v1/responses".to_string()
}
fn required_auth(&self) -> AuthRequirement {
AuthRequirement::Bearer
}
fn build_request_body(
&self,
model_id: &str,
request: &Request,
capability: &ModelCapability,
) -> Value {
let body = self.build_request(model_id, request, capability);
serde_json::to_value(&body).expect("ResponsesRequest is always serialisable")
}
fn parse_sse(
&self,
event_type: &str,
data: &str,
state: &mut Self::State,
) -> Result<Vec<Event>, ClientError> {
super::events::parse_sse(event_type, data, state)
}
fn capability_for(&self, model_id: &str) -> Option<ModelCapability> {
super::capability::lookup(model_id)
}
fn default_capability(&self) -> ModelCapability {
super::capability::default_capability()
}
}
+30 -1
View File
@@ -86,8 +86,16 @@ pub enum Item {
/// Optional item ID
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<ItemId>,
/// Reasoning text
/// Reasoning textreasoning body, `reasoning_text.delta` の累積)
text: String,
/// Reasoning summaryOpenAI Responses の `summary_text[]` を格納。
/// 他 scheme は空)
#[serde(default, skip_serializing_if = "Vec::is_empty")]
summary: Vec<String>,
/// サーバから返された暗号化済み reasoning blob。ZDR / `store=false`
/// 運用で stateless に再送するときそのまま添える必要がある。
#[serde(default, skip_serializing_if = "Option::is_none")]
encrypted_content: Option<String>,
/// Item status
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<ItemStatus>,
@@ -214,10 +222,31 @@ impl Item {
Self::Reasoning {
id: None,
text: text.into(),
summary: Vec::new(),
encrypted_content: None,
status: None,
}
}
/// Set reasoning summary on a `Reasoning` item. No-op on other variants.
pub fn with_reasoning_summary(mut self, new_summary: Vec<String>) -> Self {
if let Self::Reasoning { summary, .. } = &mut self {
*summary = new_summary;
}
self
}
/// Set `encrypted_content` on a `Reasoning` item. No-op on other variants.
pub fn with_encrypted_content(mut self, content: impl Into<String>) -> Self {
if let Self::Reasoning {
encrypted_content, ..
} = &mut self
{
*encrypted_content = Some(content.into());
}
self
}
// ========================================================================
// Builder methods
// ========================================================================
+4 -3
View File
@@ -15,6 +15,7 @@ use llm_worker::llm_client::{
capability::ModelCapability,
scheme::{
Scheme, anthropic::AnthropicScheme, gemini::GeminiScheme, openai_chat::OpenAIScheme,
openai_responses::OpenAIResponsesScheme,
},
transport::{HttpTransport, ResolvedAuth},
};
@@ -119,9 +120,9 @@ pub fn build_client(config: &ModelConfig) -> Result<Box<dyn LlmClient>, Provider
SchemeKind::Anthropic => build_transport(AnthropicScheme::new(), config, resolved),
SchemeKind::OpenaiChat => build_transport(OpenAIScheme::new(), config, resolved),
SchemeKind::Gemini => build_transport(GeminiScheme::new(), config, resolved),
SchemeKind::OpenaiResponses => Err(ProviderError::SchemeNotImplemented {
scheme: config.scheme,
}),
SchemeKind::OpenaiResponses => {
build_transport(OpenAIResponsesScheme::new(), config, resolved)
}
}
}