openai-responses対応
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -86,8 +86,16 @@ pub enum Item {
|
||||
/// Optional item ID
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<ItemId>,
|
||||
/// Reasoning text
|
||||
/// Reasoning text(reasoning body, `reasoning_text.delta` の累積)
|
||||
text: String,
|
||||
/// Reasoning summary(OpenAI 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
|
||||
// ========================================================================
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user