openai-responses対応

This commit is contained in:
Keisuke Hirata 2026-04-20 02:59:16 +09:00
parent 5aea67ff5e
commit 74a45f86b9
12 changed files with 1613 additions and 46 deletions

View File

@ -10,6 +10,7 @@
pub mod anthropic;
pub mod gemini;
pub mod openai_chat;
pub mod openai_responses;
use serde_json::Value;

View File

@ -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 等)

View File

@ -3,7 +3,7 @@
//! - リクエストJSON生成
//! - SSEイベントパース → Event変換
mod capability;
pub(crate) mod capability;
mod events;
mod request;
mod scheme_impl;

View File

@ -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,
}
}

View File

@ -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());
}
}

View File

@ -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
}
}

View File

@ -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");
}
}

View File

@ -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()
}
}

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
// ========================================================================

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)
}
}
}

View File

@ -1,8 +1,12 @@
# OpenAI Responses scheme の新設
> **レビュー完了close 可)** — 詳細は [`llm-scheme-openai-responses.review.md`](llm-scheme-openai-responses.review.md)
> 9 要件すべて達成、指摘事項は優先度低の 2 件のみtool 引数 delta 先行時の空メタデータ、`tools[].strict` ハードコード)。いずれも実害薄。
## 背景
現状の `crates/llm-worker/src/llm_client/scheme/openai` は OpenAI Chat Completions (`/v1/chat/completions`) wire format のみ実装。OpenAI の Responses API (`/v1/responses`) はリクエスト body・SSE イベント構造ともに Chat Completions と別物で、同じ scheme には乗らない。
現状の `crates/llm-worker/src/llm_client/scheme/openai_chat` は OpenAI Chat Completions (`/v1/chat/completions`) wire format のみ実装。OpenAI の Responses API (`/v1/responses`) はリクエスト body・SSE イベント構造ともに Chat Completions と別物で、同じ scheme には乗らない。
Codex CLI (github.com/openai/codex) の実装を確認したところ、ChatGPT OAuth 経路でも OpenAI API Key 経路でもすべて `/v1/responses` を叩いており、Chat Completions は使っていない。Codex 流用(別チケット `llm-auth-codex-oauth`)を実現する前提として、この scheme が必要。
@ -13,56 +17,80 @@ Codex CLI (github.com/openai/codex) の実装を確認したところ、ChatGPT
1. **`scheme/openai_responses` を新設**し、`HttpTransport<S: Scheme>` に差し込めるようにする
2. **リクエスト body**`/v1/responses` の item-based 形式:
- `model`, `instructions` (system prompt 相当), `input: [ResponseItem]`, `tools`, `tool_choice`, `parallel_tool_calls`
- `reasoning: { effort?, summary? }`
- `store`, `stream: true`, `include: [String]`
- `service_tier?`, `prompt_cache_key?`, `text?: { verbosity?, format? }`
- `previous_response_id`**使わない**stateless で運用、履歴は insomnia 側管理)
- `model`, `instructions` (system prompt 相当), `input: [ResponseItem]`, `tools`
- `tool_choice: "auto"` / `parallel_tool_calls: true` は **scheme 固定値**で常時送信(将来必要になれば Request / RequestConfig に昇格、今は YAGNI
- `reasoning: { effort?, summary? }``ReasoningControl` から投影
- **`store: false` + `include: ["reasoning.encrypted_content"]` を scheme 固定値**で送信stateless 運用 + 再送のため encrypted reasoning を取得)
- `stream: true` 固定
- `service_tier?`, `prompt_cache_key?`, `text?: { verbosity?, format? }` は当面未使用、フィールドの予約のみ
- `previous_response_id`**使わない**stateless、履歴は insomnia 側管理)
3. **SSE event パース**:
- `response.created` / `response.completed` / `response.failed` / `response.incomplete`
- `response.output_item.added` / `response.output_item.done`
- `response.content_part.added` / `response.content_part.done`
- `response.output_text.delta`
- `response.custom_tool_call_input.delta`ToolCall 引数の partial JSON
- `response.function_call_arguments.delta`(通常 function tool の引数 partial JSON
- `response.custom_tool_call_input.delta`custom tool のフリーフォーム入力 partial JSON
- `response.reasoning_text.delta` / `response.reasoning_summary_text.delta`
4. **BlockType / DeltaContent との対応**:
- **text BlockStart**`response.content_part.added`Anthropic の `content_block_start` と対称)
- **tool_use BlockStart**`response.output_item.added`id と name が確定する時点、streaming に乗せるためここ)
- `response.output_text.delta``DeltaContent::Text`
- `response.reasoning_text.delta` / `response.reasoning_summary_text.delta``DeltaContent::Thinking`
- `response.custom_tool_call_input.delta` → `DeltaContent::InputJson`
- `response.output_item.done` (tool_use) → `BlockMetadata::ToolUse { id, name }``BlockStart` 生成
- `response.function_call_arguments.delta` と `response.custom_tool_call_input.delta` → 両方とも `DeltaContent::InputJson` に正規化
- `response.content_part.done` / `response.output_item.done``BlockStop`
5. **reasoning の item 構造対応**: `summary[]` / `encrypted_content` を持つ reasoning item の送受信をロスなく扱える
- 送信時は `BlockMetadata::Thinking` から `input[]` に再構築
- 受信時は `BlockType::Thinking` のブロックとしてストリームに流す
5. **`Item::Reasoning` の拡張**llm-worker/types.rs への変更を含む):
```rust
Item::Reasoning {
text: String,
summary: Vec<String>,
encrypted_content: Option<String>,
}
```
- 送信時は `input[]` の reasoning item に再構築(`encrypted_content` があれば添える)
- 受信時は SSE から `text` / `summary[]` / `encrypted_content` を組み立てて `Item::Reasoning` に格納
- 既存 `Item::Reasoning { text }` の 1 フィールドからの拡張。`summary` は空 Vec、`encrypted_content` は `None` で既存互換を保つ
- 将来 Anthropic の extended thinking で `signature: Option<String>` を追加する余地を残す
6. **認証は `AuthRef::ApiKey` のみ対応**: `Authorization: Bearer <api_key>` ヘッダ。`base_url` デフォルトは `https://api.openai.com`、パスは `/v1/responses`。ChatGPT OAuth 経路(`CodexOAuth`)は別チケット(`llm-auth-codex-oauth`)で追加
7. **Usage の正規化**: `response.completed``usage: { input_tokens, output_tokens, total_tokens }``UsageEvent` に変換。Chat Completions の `prompt_tokens` 等との表記揺れを scheme 側で吸収
7. **Usage の正規化**: `response.completed``usage: { input_tokens, output_tokens, total_tokens }``UsageEvent` に変換
8. **完了時の動作**: OpenAI API key (`OPENAI_API_KEY`) + モデル `gpt-5` 等で `ModelConfig { scheme: OpenAIResponses, base_url: https://api.openai.com, model_id: "gpt-5", auth: ApiKey }` を宣言すると、reasoning + tool call を含む会話が動作する
8. **capability テーブル**: GPT-5 / o3 / o4 のモデル ID 判定は `scheme/openai_chat/capability.rs` と重複するため **共通関数に切り出して共有**(配置は `scheme/openai_chat/capability.rs``pub(crate) fn classify(model_id) -> Option<OpenAiFamily>` を置くか、`scheme/openai_common/` を切り出すかは実装時判断。Responses 側は `ReasoningSupport::Effort` 固定でマッピング
9. **完了時の動作**: OpenAI API key (`OPENAI_API_KEY`) + モデル `gpt-5` 等で `ModelConfig { scheme: OpenAIResponses, base_url: https://api.openai.com, model_id: "gpt-5", auth: ApiKey }` を宣言すると、reasoning + tool call を含む会話が動作する
## 設計課題
### 1. reasoning item の encrypted_content
### 1. scheme-specific 設定の override フィールド
reasoning item の `encrypted_content` はサーバ側で暗号化された状態で返されることがあり、再送時にそのまま添える必要があるZDR 組織や `store=false` 運用時。insomnia の `Item` enum に透過的に保持する仕組みが要る
`store` / `include[]` を scheme 固定値にしたが、将来 ZDR 非対応環境で `store=true` を許したくなる可能性がある。`OpenAIResponsesScheme` 自身にフィールド (`store: bool`, `include_encrypted_content: bool` 等) を持たせ、`new()` 時に上書きできる形にする。`ModelCapability` には入れないscheme-specific な wire 設定なので)
### 2. `include[]``store` のデフォルト
### 2. Responses 非対応パラメータ
- `include: ["reasoning.encrypted_content"]` を常に付けるか、capability / config で制御するか
- `store=false` をデフォルトにするか `true` にするかZDR 既定なら false
### 3. Responses 非対応パラメータ
`service_tier` / `prompt_cache_key` / `text.verbosity` は当面不要かもしれないが、将来対応時に scheme 拡張で入れられる構造にしておく。
`service_tier` / `prompt_cache_key` / `text.verbosity` は当面不要だが、将来対応時に scheme 拡張で入れられる構造にしておく。
## Scope 外
- ChatGPT OAuth 認証(`llm-auth-codex-oauth`
- ChatGPT OAuth 認証(`llm-auth-codex-oauth` チケットで実装)
- `previous_response_id` を使う stateful 運用
- 高次ツール(`web_search` / `code_interpreter` / `computer_use`)— insomnia では採用しない方針
- `tool_choice` / `parallel_tool_calls` の Request 昇格(必要性が出てから別チケット)
## 依存
- `tickets/llm-model-config.md``HttpTransport<S>` 構造と `AuthRef` が前提)
- `tickets/llm-model-config.md` 完了済(`HttpTransport<S>` 構造と `AuthRef` が前提)
## 影響範囲
llm-worker 単独ではなく以下にまたがる:
- `crates/llm-worker/src/llm_client/types.rs`: `Item::Reasoning` の拡張
- `crates/llm-worker/src/llm_client/scheme/openai_responses/`: 新規
- `crates/llm-worker/src/llm_client/scheme/openai_chat/capability.rs`: モデル family 判定を `pub(crate)` に露出
- `crates/llm-worker/src/llm_client/scheme/mod.rs`: `pub mod openai_responses;`
- `crates/provider/src/lib.rs`: `build_client``SchemeKind::OpenaiResponses` アームを `SchemeNotImplemented` から実装に差し替え

View File

@ -0,0 +1,77 @@
# OpenAI Responses scheme の新設 — レビュー
## 前提・要件の再確認
チケット本体の 9 要件 + 2 設計課題を前提に、実装が意図と整合しているかを確認した。`cargo check`warning 1 / 旧由来)・`cargo test --workspace`(全 pass通過。変更量: 新規 5 ファイル1407 行)+ 既存 5 ファイル微修正。
## 要件達成度
| # | 要件 | 状況 | メモ |
|---|---|---|---|
| 1 | `scheme/openai_responses` 新設、`HttpTransport<S>` に差し込める | ✓ | `Scheme` trait 実装完了 |
| 2 | Request body: `tool_choice: "auto"` / `parallel_tool_calls: true` / `store: false` / `include` / `stream: true` 固定、`reasoning` 投影 | ✓ | `ResponsesRequest` 構造体に全項目、`build_request` で capability 照合して reasoning 投影 |
| 3 | SSE event パース (response.* 一式) | ✓ + α | ticket 列挙に加えて `response.content_part.done` / `response.reasoning_summary_part.added/done` / top-level `error` もカバー |
| 4 | BlockType / DeltaContent 対応text は `content_part.added`、tool_use は `output_item.added` で BlockStart | ✓ | `OpenAIResponsesState` の 3 種 SlotKeyOutputItem / ContentPart / Summaryで (output_index, content_index) → flat index を管理 |
| 5 | `Item::Reasoning` 拡張text + summary + encrypted_content | ✓ | `with_reasoning_summary` / `with_encrypted_content` ビルダー追加、既存コンストラクタは空値で互換 |
| 6 | `AuthRef::ApiKey` / `Authorization: Bearer` / base_url `https://api.openai.com` / パス `/v1/responses` | ✓ | scheme_impl.rs の `required_auth` / `default_base_url` / `path` |
| 7 | Usage 正規化 | ✓ | `response.completed``usage``UsageEvent` に変換、`total_tokens` 未提供時は input+output で補完 |
| 8 | capability 共通判定関数 | ✓ | `openai_chat/capability.rs::classify -> OpenAiFamily``pub(crate)` で公開、`openai_responses/capability.rs::lookup` が共有 |
| 9 | 完了時動作 | ✓ | `provider/lib.rs::build_client``OpenaiResponses` アームが `SchemeNotImplemented` から実装に差し替え済み |
## 設計決定への反映
| 決定 | 反映 |
|---|---|
| `store` / `include[]``OpenAIResponsesScheme` フィールドで override 可能、`ModelCapability` には入れない | ✓ `with_store` / `with_include_encrypted_content` ビルダー、デフォルトは stateless + ZDR 相当 |
| `ReasoningSupport::Effort / Both` 対応時のみ `reasoning` 送信 | ✓ `build_request` で capability と `request.config.reasoning.effort` の両方が揃う時のみ投影 |
| Responses 未使用パラメータ (`service_tier` / `prompt_cache_key` / `text.verbosity`) は予約のみ | ✓ `ResponsesRequest` 構造体には入れず、必要時に追加できる構造 |
## アーキテクチャ評価
### 良い点
- **`ensure_and_delta` による防御的設計**: `content_part.added` が欠落しても delta 単独で BlockStart + Delta を発行できる。`delta_without_prior_start_recovers` テストで確認済
- **`OpenAIResponsesState` の 3 種 SlotKey**: `OutputItem` (tool 全体) / `ContentPart { output, content }` (text/reasoning) / `Summary { output, summary }` (reasoning 要約) で Responses の 2 次元座標を flat index に自然にマップ。`parallel_output_items_get_distinct_indices` で並列 tool call の独立性を検証
- **テストの充実**: request 12 ケース (scheme defaults / tool_choice 固定 / role 別 content 型 / reasoning 有無 / round-trip / serialize shape)、events 10 ケース (text / function_call / custom_tool / reasoning_text / summary / 並列 / failed / unknown / 防御)
- **capability の一次情報共有**: `OpenAiFamily` enum を `openai_chat` に置いて両 scheme で共有、DRY と結合度のバランスが良い
- **`Item::Reasoning` 拡張の互換性**: `Vec::is_empty` / `Option::is_none` での skip_serializing、既存 `Item::reasoning()` コンストラクタは空値で互換。他 scheme の `request.rs``Item::Reasoning { text, .. }``..` 省略しており、追加フィールドで壊れない
## 指摘事項
### 優先度: 低
#### 1. tool 引数 delta 先行時の空メタデータ
`ensure_and_delta``function_call_arguments.delta` / `custom_tool_call_input.delta` で BlockStart 未発行の場合、`BlockMetadata::ToolUse { id: String::new(), name: String::new() }` を合成する。実運用では `output_item.added` が先行するはずで発動しないが、仮に発火すると後段で空 `call_id` を使うリスクがある。
対応案:
- `ToolUse` では `ensure_and_delta` を使わず、`output_item.added` 必須で、欠落時は warning ログ + Delta 破棄
- もしくは現状維持で「防御コードとして warning」出す
現状、`output_item.added` が保証されている Responses API の仕様に依存しており、実害は薄い。
#### 2. `tools[].strict: false` ハードコード
`ResponseTool::strict: false` を常時送信。Responses の `strict: true` は JSON Schema 準拠を強制する。`ModelCapability::structured_output == JsonSchema` のときに `strict: true` に昇格させる余地があるが、本チケットではスコープ外として許容範囲。
### 優先度: 極低(構造的なスコープ内)
#### 3. `summary` / `encrypted_content` が他 scheme で落ちる
`Item::Reasoning { text, .. }``..` 省略されている他 schemeAnthropic / OpenAI Chat / Gemini`request.rs``summary``encrypted_content` を送信しない。ただし:
- Anthropic は独自の `signature` が別途必要で将来拡張
- OpenAI Chat と Gemini は reasoning を first-class では送らない
- scheme をまたいだ履歴引き継ぎは現状想定外
構造的には設計通り、問題なし。
#### 4. `OpenAIResponsesScheme` の override が `build_client` から届かない
`with_store` / `with_include_encrypted_content` は存在するが、`build_client` 経由では常に `OpenAIResponsesScheme::new()` デフォルト。ZDR 非対応環境で `store=true` にする場合は provider 側で新しい経路が必要。
チケットの設計課題 1 で「将来対応」と明示されており、スコープ通り。
## 総合判定
**close 可能**。9 要件すべて達成、設計決定も正確に反映、テストも充実。指摘事項はいずれも実害が薄いか、チケット設計課題として明示されたスコープ内の将来対応。
`tickets/llm-auth-codex-oauth.md` (次のチケット) を進められる状態。