mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
migrate ai format conversion to responses adapters
This commit is contained in:
@@ -839,6 +839,8 @@ fn admin_usage_api_format_defaults_to_non_stream(item: &StoredRequestUsageAudit)
|
||||
api_format,
|
||||
Some(value)
|
||||
if value.eq_ignore_ascii_case("openai:chat")
|
||||
|| value.eq_ignore_ascii_case("openai:responses")
|
||||
|| value.eq_ignore_ascii_case("openai:responses:compact")
|
||||
|| value.eq_ignore_ascii_case("openai:cli")
|
||||
|| value.eq_ignore_ascii_case("openai:compact")
|
||||
|| value.eq_ignore_ascii_case("openai:image")
|
||||
@@ -2333,10 +2335,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_requested_stream_defaults_to_non_stream_for_openai_cli_request_body_without_flag() {
|
||||
fn client_requested_stream_defaults_to_non_stream_for_openai_responses_request_body_without_flag(
|
||||
) {
|
||||
let item = StoredRequestUsageAudit {
|
||||
is_stream: true,
|
||||
api_format: Some("openai:cli".to_string()),
|
||||
api_format: Some("openai:responses".to_string()),
|
||||
request_body: Some(json!({
|
||||
"model": "gpt-5.4",
|
||||
"input": [{"role": "user", "content": "hi"}],
|
||||
|
||||
@@ -530,16 +530,16 @@ const ADMIN_API_FORMAT_DEFINITIONS: &[AdminApiFormatDefinition] = &[
|
||||
],
|
||||
},
|
||||
AdminApiFormatDefinition {
|
||||
value: "openai:cli",
|
||||
label: "OpenAI CLI",
|
||||
value: "openai:responses",
|
||||
label: "OpenAI Responses",
|
||||
default_path: "/v1/responses",
|
||||
aliases: &["openai_cli", "responses"],
|
||||
aliases: &["openai_cli", "openai:cli", "responses"],
|
||||
},
|
||||
AdminApiFormatDefinition {
|
||||
value: "openai:compact",
|
||||
label: "OpenAI Compact",
|
||||
value: "openai:responses:compact",
|
||||
label: "OpenAI Responses Compact",
|
||||
default_path: "/v1/responses/compact",
|
||||
aliases: &["openai_compact", "responses_compact"],
|
||||
aliases: &["openai_compact", "openai:compact", "responses_compact"],
|
||||
},
|
||||
AdminApiFormatDefinition {
|
||||
value: "openai:image",
|
||||
|
||||
13
crates/aether-ai-formats/Cargo.toml
Normal file
13
crates/aether-ai-formats/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "aether-ai-formats"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Canonical AI format IR and adapters for Aether"
|
||||
|
||||
[dependencies]
|
||||
regex.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
5870
crates/aether-ai-formats/src/canonical.rs
Normal file
5870
crates/aether-ai-formats/src/canonical.rs
Normal file
File diff suppressed because it is too large
Load Diff
2
crates/aether-ai-formats/src/conversion/mod.rs
Normal file
2
crates/aether-ai-formats/src/conversion/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
@@ -1,8 +1,6 @@
|
||||
mod claude;
|
||||
mod gemini;
|
||||
mod openai_cli;
|
||||
mod shared;
|
||||
|
||||
pub use claude::convert_openai_chat_request_to_claude_request;
|
||||
pub use gemini::convert_openai_chat_request_to_gemini_request;
|
||||
pub use openai_cli::convert_openai_chat_request_to_openai_cli_request;
|
||||
20
crates/aether-ai-formats/src/conversion/request/mod.rs
Normal file
20
crates/aether-ai-formats/src/conversion/request/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
//! Pairwise request adapters kept for compatibility and focused tests.
|
||||
//!
|
||||
//! New request routing should use the registry so every conversion passes
|
||||
//! through the typed canonical IR.
|
||||
|
||||
pub mod from_openai_chat;
|
||||
pub mod openai_responses;
|
||||
pub mod to_openai_chat;
|
||||
|
||||
pub use from_openai_chat::{
|
||||
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
|
||||
};
|
||||
pub use openai_responses::{
|
||||
convert_openai_chat_request_to_openai_responses_request,
|
||||
normalize_openai_responses_request_to_openai_chat_request,
|
||||
};
|
||||
pub use to_openai_chat::{
|
||||
extract_openai_text_content, normalize_claude_request_to_openai_chat_request,
|
||||
normalize_gemini_request_to_openai_chat_request, parse_openai_tool_result_content,
|
||||
};
|
||||
@@ -7,7 +7,7 @@ use crate::planner::openai::{
|
||||
copy_request_number_field, extract_openai_reasoning_effort, value_as_u64,
|
||||
};
|
||||
|
||||
pub fn convert_openai_chat_request_to_openai_cli_request(
|
||||
pub fn convert_openai_chat_request_to_openai_responses_request(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
@@ -36,7 +36,7 @@ pub fn convert_openai_chat_request_to_openai_cli_request(
|
||||
}
|
||||
}
|
||||
"user" | "assistant" => {
|
||||
let mut content_items = convert_openai_content_to_openai_cli_items(
|
||||
let mut content_items = convert_openai_content_to_openai_responses_items(
|
||||
message_object.get("content"),
|
||||
role.as_str(),
|
||||
)?;
|
||||
@@ -197,20 +197,21 @@ pub fn convert_openai_chat_request_to_openai_cli_request(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(text) = build_openai_cli_text_config_from_openai_chat_request(request) {
|
||||
if let Some(text) = build_openai_responses_text_config_from_openai_chat_request(request) {
|
||||
output.insert("text".to_string(), Value::Object(text));
|
||||
}
|
||||
if let Some(tools) = build_openai_cli_tools_from_openai_chat_request(request) {
|
||||
if let Some(tools) = build_openai_responses_tools_from_openai_chat_request(request) {
|
||||
output.insert("tools".to_string(), Value::Array(tools));
|
||||
}
|
||||
if let Some(tool_choice) = build_openai_cli_tool_choice_from_openai_chat_request(request) {
|
||||
if let Some(tool_choice) = build_openai_responses_tool_choice_from_openai_chat_request(request)
|
||||
{
|
||||
output.insert("tool_choice".to_string(), tool_choice);
|
||||
}
|
||||
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
|
||||
fn convert_openai_content_to_openai_cli_items(
|
||||
fn convert_openai_content_to_openai_responses_items(
|
||||
content: Option<&Value>,
|
||||
role: &str,
|
||||
) -> Option<Vec<Value>> {
|
||||
@@ -218,6 +219,7 @@ fn convert_openai_content_to_openai_cli_items(
|
||||
return Some(Vec::new());
|
||||
};
|
||||
match content {
|
||||
Value::Null => Some(Vec::new()),
|
||||
Value::String(text) => {
|
||||
if text.is_empty() {
|
||||
Some(Vec::new())
|
||||
@@ -337,7 +339,7 @@ fn convert_openai_content_to_openai_cli_items(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_openai_cli_text_config_from_openai_chat_request(
|
||||
fn build_openai_responses_text_config_from_openai_chat_request(
|
||||
request: &Map<String, Value>,
|
||||
) -> Option<Map<String, Value>> {
|
||||
let mut text = Map::new();
|
||||
@@ -350,7 +352,7 @@ fn build_openai_cli_text_config_from_openai_chat_request(
|
||||
(!text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
fn build_openai_cli_tools_from_openai_chat_request(
|
||||
fn build_openai_responses_tools_from_openai_chat_request(
|
||||
request: &Map<String, Value>,
|
||||
) -> Option<Vec<Value>> {
|
||||
let mut tools = Vec::new();
|
||||
@@ -437,7 +439,7 @@ fn build_openai_cli_tools_from_openai_chat_request(
|
||||
(!tools.is_empty()).then_some(tools)
|
||||
}
|
||||
|
||||
fn build_openai_cli_tool_choice_from_openai_chat_request(
|
||||
fn build_openai_responses_tool_choice_from_openai_chat_request(
|
||||
request: &Map<String, Value>,
|
||||
) -> Option<Value> {
|
||||
let tool_choice = request.get("tool_choice")?;
|
||||
@@ -504,11 +506,11 @@ fn copy_request_bool_field(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::convert_openai_chat_request_to_openai_cli_request;
|
||||
use super::convert_openai_chat_request_to_openai_responses_request;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn preserves_shared_openai_chat_controls_when_converting_to_openai_cli() {
|
||||
fn preserves_shared_openai_chat_controls_when_converting_to_openai_responses() {
|
||||
let request = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
@@ -522,7 +524,7 @@ mod tests {
|
||||
"top_logprobs": 3,
|
||||
});
|
||||
|
||||
let converted = convert_openai_chat_request_to_openai_cli_request(
|
||||
let converted = convert_openai_chat_request_to_openai_responses_request(
|
||||
&request,
|
||||
"gpt-5-upstream",
|
||||
false,
|
||||
@@ -542,7 +544,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_assistant_refusal_when_converting_to_openai_cli() {
|
||||
fn preserves_assistant_refusal_when_converting_to_openai_responses() {
|
||||
let request = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{
|
||||
@@ -552,7 +554,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let converted = convert_openai_chat_request_to_openai_cli_request(
|
||||
let converted = convert_openai_chat_request_to_openai_responses_request(
|
||||
&request,
|
||||
"gpt-5-upstream",
|
||||
false,
|
||||
@@ -0,0 +1,54 @@
|
||||
mod from_chat;
|
||||
mod to_chat;
|
||||
|
||||
pub use from_chat::convert_openai_chat_request_to_openai_responses_request;
|
||||
pub use to_chat::normalize_openai_responses_request_to_openai_chat_request;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
convert_openai_chat_request_to_openai_responses_request,
|
||||
normalize_openai_responses_request_to_openai_chat_request,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn converts_chat_to_responses_wire_shape() {
|
||||
let request = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"max_completion_tokens": 16
|
||||
});
|
||||
|
||||
let converted = convert_openai_chat_request_to_openai_responses_request(
|
||||
&request,
|
||||
"gpt-5-mini",
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("responses request");
|
||||
|
||||
assert_eq!(converted["model"], "gpt-5-mini");
|
||||
assert_eq!(converted["input"][0]["type"], "message");
|
||||
assert_eq!(converted["max_output_tokens"], 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_responses_wire_shape_to_chat() {
|
||||
let request = json!({
|
||||
"model": "gpt-5",
|
||||
"input": [{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "hello"}]
|
||||
}]
|
||||
});
|
||||
|
||||
let converted = normalize_openai_responses_request_to_openai_chat_request(&request)
|
||||
.expect("chat request");
|
||||
|
||||
assert_eq!(converted["messages"][0]["role"], "user");
|
||||
assert_eq!(converted["messages"][0]["content"][0]["type"], "text");
|
||||
assert_eq!(converted["messages"][0]["content"][0]["text"], "hello");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::shared::{extract_openai_text_content, parse_openai_tool_result_content};
|
||||
use super::super::to_openai_chat::{extract_openai_text_content, parse_openai_tool_result_content};
|
||||
use crate::planner::openai::extract_openai_reasoning_effort;
|
||||
|
||||
pub fn normalize_openai_cli_request_to_openai_chat_request(body_json: &Value) -> Option<Value> {
|
||||
pub fn normalize_openai_responses_request_to_openai_chat_request(
|
||||
body_json: &Value,
|
||||
) -> Option<Value> {
|
||||
let request = body_json.as_object()?;
|
||||
let mut output = Map::new();
|
||||
if let Some(model) = request.get("model") {
|
||||
@@ -20,7 +22,7 @@ pub fn normalize_openai_cli_request_to_openai_chat_request(body_json: &Value) ->
|
||||
}));
|
||||
}
|
||||
}
|
||||
messages.extend(normalize_openai_cli_input_to_openai_chat_messages(
|
||||
messages.extend(normalize_openai_responses_input_to_openai_chat_messages(
|
||||
request.get("input"),
|
||||
)?);
|
||||
output.insert("messages".to_string(), Value::Array(messages));
|
||||
@@ -70,16 +72,16 @@ pub fn normalize_openai_cli_request_to_openai_chat_request(body_json: &Value) ->
|
||||
{
|
||||
output.insert("verbosity".to_string(), verbosity);
|
||||
}
|
||||
if let Some(tools) = normalize_openai_cli_tools_to_openai_chat(request.get("tools"))? {
|
||||
if let Some(tools) = normalize_openai_responses_tools_to_openai_chat(request.get("tools"))? {
|
||||
output.insert("tools".to_string(), Value::Array(tools));
|
||||
}
|
||||
if let Some(web_search_options) =
|
||||
extract_openai_cli_web_search_options(request.get("tools").and_then(Value::as_array))
|
||||
extract_openai_responses_web_search_options(request.get("tools").and_then(Value::as_array))
|
||||
{
|
||||
output.insert("web_search_options".to_string(), web_search_options);
|
||||
}
|
||||
if let Some(tool_choice) =
|
||||
normalize_openai_cli_tool_choice_to_openai_chat(request.get("tool_choice"))?
|
||||
normalize_openai_responses_tool_choice_to_openai_chat(request.get("tool_choice"))?
|
||||
{
|
||||
output.insert("tool_choice".to_string(), tool_choice);
|
||||
}
|
||||
@@ -87,7 +89,9 @@ pub fn normalize_openai_cli_request_to_openai_chat_request(body_json: &Value) ->
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
|
||||
fn normalize_openai_cli_input_to_openai_chat_messages(input: Option<&Value>) -> Option<Vec<Value>> {
|
||||
fn normalize_openai_responses_input_to_openai_chat_messages(
|
||||
input: Option<&Value>,
|
||||
) -> Option<Vec<Value>> {
|
||||
let Some(input) = input else {
|
||||
return Some(Vec::new());
|
||||
};
|
||||
@@ -142,14 +146,14 @@ fn normalize_openai_cli_input_to_openai_chat_messages(input: Option<&Value>) ->
|
||||
continue;
|
||||
}
|
||||
let normalized_content =
|
||||
normalize_openai_cli_message_content(item_object.get("content"))?;
|
||||
normalize_openai_responses_message_content(item_object.get("content"))?;
|
||||
let mut message = serde_json::Map::new();
|
||||
message.insert("role".to_string(), Value::String(role.clone()));
|
||||
message.insert("content".to_string(), normalized_content);
|
||||
if role == "assistant" {
|
||||
if let Some(refusal) =
|
||||
extract_openai_cli_message_refusal(item_object.get("content"))?
|
||||
{
|
||||
if let Some(refusal) = extract_openai_responses_message_refusal(
|
||||
item_object.get("content"),
|
||||
)? {
|
||||
message.insert("refusal".to_string(), Value::String(refusal));
|
||||
}
|
||||
}
|
||||
@@ -222,7 +226,7 @@ fn normalize_openai_cli_input_to_openai_chat_messages(input: Option<&Value>) ->
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_openai_cli_message_content(content: Option<&Value>) -> Option<Value> {
|
||||
fn normalize_openai_responses_message_content(content: Option<&Value>) -> Option<Value> {
|
||||
let Some(content) = content else {
|
||||
return Some(Value::Array(Vec::new()));
|
||||
};
|
||||
@@ -314,7 +318,7 @@ fn normalize_openai_cli_message_content(content: Option<&Value>) -> Option<Value
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_openai_cli_message_refusal(content: Option<&Value>) -> Option<Option<String>> {
|
||||
fn extract_openai_responses_message_refusal(content: Option<&Value>) -> Option<Option<String>> {
|
||||
let Some(content) = content else {
|
||||
return Some(None);
|
||||
};
|
||||
@@ -347,7 +351,9 @@ fn extract_openai_cli_message_refusal(content: Option<&Value>) -> Option<Option<
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_openai_cli_tools_to_openai_chat(tools: Option<&Value>) -> Option<Option<Vec<Value>>> {
|
||||
fn normalize_openai_responses_tools_to_openai_chat(
|
||||
tools: Option<&Value>,
|
||||
) -> Option<Option<Vec<Value>>> {
|
||||
let Some(Value::Array(tool_values)) = tools else {
|
||||
return Some(None);
|
||||
};
|
||||
@@ -387,7 +393,7 @@ fn normalize_openai_cli_tools_to_openai_chat(tools: Option<&Value>) -> Option<Op
|
||||
Some((!normalized.is_empty()).then_some(normalized))
|
||||
}
|
||||
|
||||
fn extract_openai_cli_web_search_options(tools: Option<&Vec<Value>>) -> Option<Value> {
|
||||
fn extract_openai_responses_web_search_options(tools: Option<&Vec<Value>>) -> Option<Value> {
|
||||
let tool_values = tools?;
|
||||
for tool in tool_values {
|
||||
let tool_object = tool.as_object()?;
|
||||
@@ -428,7 +434,7 @@ fn extract_openai_cli_web_search_options(tools: Option<&Vec<Value>>) -> Option<V
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_openai_cli_tool_choice_to_openai_chat(
|
||||
fn normalize_openai_responses_tool_choice_to_openai_chat(
|
||||
tool_choice: Option<&Value>,
|
||||
) -> Option<Option<Value>> {
|
||||
let Some(tool_choice) = tool_choice else {
|
||||
@@ -460,11 +466,11 @@ fn normalize_openai_cli_tool_choice_to_openai_chat(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_openai_cli_request_to_openai_chat_request;
|
||||
use super::normalize_openai_responses_request_to_openai_chat_request;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn preserves_openai_cli_text_and_passthrough_fields_when_normalizing_to_chat() {
|
||||
fn preserves_openai_responses_text_and_passthrough_fields_when_normalizing_to_chat() {
|
||||
let request = json!({
|
||||
"model": "gpt-5",
|
||||
"max_output_tokens": 128,
|
||||
@@ -487,7 +493,7 @@ mod tests {
|
||||
"top_logprobs": 4
|
||||
});
|
||||
|
||||
let converted = normalize_openai_cli_request_to_openai_chat_request(&request)
|
||||
let converted = normalize_openai_responses_request_to_openai_chat_request(&request)
|
||||
.expect("responses request should normalize to chat");
|
||||
|
||||
assert_eq!(converted["max_completion_tokens"], 128);
|
||||
@@ -518,7 +524,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let converted = normalize_openai_cli_request_to_openai_chat_request(&request)
|
||||
let converted = normalize_openai_responses_request_to_openai_chat_request(&request)
|
||||
.expect("responses request should normalize to chat");
|
||||
|
||||
assert_eq!(converted["messages"][0]["role"], "assistant");
|
||||
@@ -537,7 +543,7 @@ mod tests {
|
||||
"input": "hello"
|
||||
});
|
||||
|
||||
let converted = normalize_openai_cli_request_to_openai_chat_request(&request)
|
||||
let converted = normalize_openai_responses_request_to_openai_chat_request(&request)
|
||||
.expect("responses request should normalize to chat");
|
||||
|
||||
assert_eq!(converted["stream"], true);
|
||||
@@ -556,7 +562,7 @@ mod tests {
|
||||
"input": "hello"
|
||||
});
|
||||
|
||||
let converted = normalize_openai_cli_request_to_openai_chat_request(&request)
|
||||
let converted = normalize_openai_responses_request_to_openai_chat_request(&request)
|
||||
.expect("responses request should normalize to chat");
|
||||
|
||||
assert_eq!(converted["stream_options"]["include_usage"], false);
|
||||
@@ -1,9 +1,7 @@
|
||||
mod claude;
|
||||
mod gemini;
|
||||
mod openai_cli;
|
||||
mod shared;
|
||||
|
||||
pub use claude::normalize_claude_request_to_openai_chat_request;
|
||||
pub use gemini::normalize_gemini_request_to_openai_chat_request;
|
||||
pub use openai_cli::normalize_openai_cli_request_to_openai_chat_request;
|
||||
pub use shared::{extract_openai_text_content, parse_openai_tool_result_content};
|
||||
@@ -0,0 +1,6 @@
|
||||
mod claude_chat;
|
||||
mod gemini_chat;
|
||||
mod shared;
|
||||
|
||||
pub use claude_chat::convert_openai_chat_response_to_claude_chat;
|
||||
pub use gemini_chat::convert_openai_chat_response_to_gemini_chat;
|
||||
@@ -0,0 +1,24 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
pub(super) fn parse_openai_function_arguments(arguments: Option<&Value>) -> Option<Value> {
|
||||
match arguments.cloned().unwrap_or(Value::Object(Map::new())) {
|
||||
Value::Object(object) => Some(Value::Object(object)),
|
||||
Value::String(text) => {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
Some(Value::Object(Map::new()))
|
||||
} else {
|
||||
match serde_json::from_str::<Value>(trimmed) {
|
||||
Ok(Value::Object(object)) => Some(Value::Object(object)),
|
||||
Ok(other) => Some(json!({ "raw": other })),
|
||||
Err(_) => Some(json!({ "raw": text })),
|
||||
}
|
||||
}
|
||||
}
|
||||
other => Some(json!({ "raw": other })),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_generated_tool_call_id(index: usize) -> String {
|
||||
format!("call_auto_{index}")
|
||||
}
|
||||
21
crates/aether-ai-formats/src/conversion/response/mod.rs
Normal file
21
crates/aether-ai-formats/src/conversion/response/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! Pairwise response adapters kept for compatibility and focused tests.
|
||||
//!
|
||||
//! New response routing should use the registry so every conversion passes
|
||||
//! through the typed canonical IR.
|
||||
|
||||
pub mod from_openai_chat;
|
||||
pub mod openai_responses;
|
||||
pub mod to_openai_chat;
|
||||
|
||||
pub use from_openai_chat::{
|
||||
convert_openai_chat_response_to_claude_chat, convert_openai_chat_response_to_gemini_chat,
|
||||
};
|
||||
pub use openai_responses::{
|
||||
build_openai_responses_response, build_openai_responses_response_with_content,
|
||||
build_openai_responses_response_with_reasoning, convert_claude_response_to_openai_responses,
|
||||
convert_gemini_response_to_openai_responses, convert_openai_chat_response_to_openai_responses,
|
||||
convert_openai_responses_response_to_openai_chat, OpenAiResponsesResponseUsage,
|
||||
};
|
||||
pub use to_openai_chat::{
|
||||
convert_claude_chat_response_to_openai_chat, convert_gemini_chat_response_to_openai_chat,
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::shared::{
|
||||
build_openai_cli_response_with_content, canonicalize_tool_arguments, OpenAiCliResponseUsage,
|
||||
build_openai_responses_response_with_content, canonicalize_tool_arguments,
|
||||
OpenAiResponsesResponseUsage,
|
||||
};
|
||||
|
||||
pub fn convert_openai_chat_response_to_openai_cli(
|
||||
pub fn convert_openai_chat_response_to_openai_responses(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
compact: bool,
|
||||
@@ -98,11 +99,11 @@ pub fn convert_openai_chat_response_to_openai_cli(
|
||||
message_content.push(image_part);
|
||||
}
|
||||
} else if matches!(part_type.as_str(), "file" | "input_file") {
|
||||
if let Some(file_part) = build_openai_cli_file_part(part) {
|
||||
if let Some(file_part) = build_openai_responses_file_part(part) {
|
||||
message_content.push(file_part);
|
||||
}
|
||||
} else if part_type == "input_audio" {
|
||||
if let Some(audio_part) = build_openai_cli_input_audio_part(part) {
|
||||
if let Some(audio_part) = build_openai_responses_input_audio_part(part) {
|
||||
message_content.push(audio_part);
|
||||
}
|
||||
}
|
||||
@@ -176,13 +177,13 @@ pub fn convert_openai_chat_response_to_openai_cli(
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.unwrap_or("unknown");
|
||||
|
||||
let mut response = build_openai_cli_response_with_content(
|
||||
let mut response = build_openai_responses_response_with_content(
|
||||
&response_id,
|
||||
model,
|
||||
message_content,
|
||||
reasoning_summaries,
|
||||
function_calls,
|
||||
OpenAiCliResponseUsage {
|
||||
OpenAiResponsesResponseUsage {
|
||||
prompt_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
@@ -246,7 +247,7 @@ pub fn convert_openai_chat_response_to_openai_cli(
|
||||
Some(response)
|
||||
}
|
||||
|
||||
fn build_openai_cli_file_part(part: &serde_json::Map<String, Value>) -> Option<Value> {
|
||||
fn build_openai_responses_file_part(part: &serde_json::Map<String, Value>) -> Option<Value> {
|
||||
let file_object = part.get("file").and_then(Value::as_object).unwrap_or(part);
|
||||
let mut file = serde_json::Map::new();
|
||||
for key in ["file_data", "file_id", "filename"] {
|
||||
@@ -267,7 +268,7 @@ fn build_openai_cli_file_part(part: &serde_json::Map<String, Value>) -> Option<V
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_openai_cli_input_audio_part(part: &serde_json::Map<String, Value>) -> Option<Value> {
|
||||
fn build_openai_responses_input_audio_part(part: &serde_json::Map<String, Value>) -> Option<Value> {
|
||||
let audio_object = part
|
||||
.get("input_audio")
|
||||
.and_then(Value::as_object)
|
||||
@@ -291,7 +292,7 @@ fn build_openai_cli_input_audio_part(part: &serde_json::Map<String, Value>) -> O
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::convert_openai_chat_response_to_openai_cli;
|
||||
use super::convert_openai_chat_response_to_openai_responses;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
@@ -339,7 +340,7 @@ mod tests {
|
||||
});
|
||||
|
||||
let converted =
|
||||
convert_openai_chat_response_to_openai_cli(&response, &report_context, false)
|
||||
convert_openai_chat_response_to_openai_responses(&response, &report_context, false)
|
||||
.expect("chat response should convert to responses");
|
||||
|
||||
assert_eq!(converted["created_at"], 1741569952i64);
|
||||
@@ -410,8 +411,9 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let converted = convert_openai_chat_response_to_openai_cli(&response, &json!({}), false)
|
||||
.expect("chat response should convert to responses");
|
||||
let converted =
|
||||
convert_openai_chat_response_to_openai_responses(&response, &json!({}), false)
|
||||
.expect("chat response should convert to responses");
|
||||
|
||||
assert_eq!(
|
||||
converted["output"][0]["content"],
|
||||
@@ -0,0 +1,87 @@
|
||||
mod from_chat;
|
||||
mod shared;
|
||||
mod to_chat;
|
||||
|
||||
pub use from_chat::convert_openai_chat_response_to_openai_responses;
|
||||
pub use shared::{
|
||||
build_openai_responses_response, build_openai_responses_response_with_content,
|
||||
build_openai_responses_response_with_reasoning, OpenAiResponsesResponseUsage,
|
||||
};
|
||||
pub use to_chat::convert_openai_responses_response_to_openai_chat;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn convert_claude_response_to_openai_responses(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
let chat_response = super::to_openai_chat::convert_claude_chat_response_to_openai_chat(
|
||||
body_json,
|
||||
report_context,
|
||||
)?;
|
||||
convert_openai_chat_response_to_openai_responses(&chat_response, report_context, false)
|
||||
}
|
||||
|
||||
pub fn convert_gemini_response_to_openai_responses(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
let chat_response = super::to_openai_chat::convert_gemini_chat_response_to_openai_chat(
|
||||
body_json,
|
||||
report_context,
|
||||
)?;
|
||||
convert_openai_chat_response_to_openai_responses(&chat_response, report_context, false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
convert_openai_chat_response_to_openai_responses,
|
||||
convert_openai_responses_response_to_openai_chat,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn converts_chat_response_to_responses_wire_shape() {
|
||||
let response = json!({
|
||||
"id": "chatcmpl_1",
|
||||
"object": "chat.completion",
|
||||
"model": "gpt-5",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "done"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
|
||||
});
|
||||
|
||||
let converted =
|
||||
convert_openai_chat_response_to_openai_responses(&response, &json!({}), false)
|
||||
.expect("responses response");
|
||||
|
||||
assert_eq!(converted["object"], "response");
|
||||
assert_eq!(converted["output"][0]["content"][0]["text"], "done");
|
||||
assert_eq!(converted["usage"]["input_tokens"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_responses_wire_shape_to_chat_response() {
|
||||
let response = json!({
|
||||
"id": "resp_1",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"model": "gpt-5",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "done", "annotations": []}]
|
||||
}]
|
||||
});
|
||||
|
||||
let converted = convert_openai_responses_response_to_openai_chat(&response, &json!({}))
|
||||
.expect("chat response");
|
||||
|
||||
assert_eq!(converted["object"], "chat.completion");
|
||||
assert_eq!(converted["choices"][0]["message"]["content"], "done");
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OpenAiCliResponseUsage {
|
||||
pub struct OpenAiResponsesResponseUsage {
|
||||
pub prompt_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
pub fn build_openai_cli_response(
|
||||
pub fn build_openai_responses_response(
|
||||
response_id: &str,
|
||||
model: &str,
|
||||
text: &str,
|
||||
@@ -25,13 +25,13 @@ pub fn build_openai_cli_response(
|
||||
"annotations": []
|
||||
})]
|
||||
};
|
||||
build_openai_cli_response_with_content(
|
||||
build_openai_responses_response_with_content(
|
||||
response_id,
|
||||
model,
|
||||
content,
|
||||
Vec::new(),
|
||||
function_calls,
|
||||
OpenAiCliResponseUsage {
|
||||
OpenAiResponsesResponseUsage {
|
||||
prompt_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
@@ -39,13 +39,13 @@ pub fn build_openai_cli_response(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_openai_cli_response_with_reasoning(
|
||||
pub fn build_openai_responses_response_with_reasoning(
|
||||
response_id: &str,
|
||||
model: &str,
|
||||
text: &str,
|
||||
reasoning_summaries: Vec<String>,
|
||||
function_calls: Vec<Value>,
|
||||
usage: OpenAiCliResponseUsage,
|
||||
usage: OpenAiResponsesResponseUsage,
|
||||
) -> Value {
|
||||
let content = if text.is_empty() {
|
||||
Vec::new()
|
||||
@@ -56,7 +56,7 @@ pub fn build_openai_cli_response_with_reasoning(
|
||||
"annotations": []
|
||||
})]
|
||||
};
|
||||
build_openai_cli_response_with_content(
|
||||
build_openai_responses_response_with_content(
|
||||
response_id,
|
||||
model,
|
||||
content,
|
||||
@@ -66,13 +66,13 @@ pub fn build_openai_cli_response_with_reasoning(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_openai_cli_response_with_content(
|
||||
pub fn build_openai_responses_response_with_content(
|
||||
response_id: &str,
|
||||
model: &str,
|
||||
content: Vec<Value>,
|
||||
reasoning_summaries: Vec<String>,
|
||||
function_calls: Vec<Value>,
|
||||
usage: OpenAiCliResponseUsage,
|
||||
usage: OpenAiResponsesResponseUsage,
|
||||
) -> Value {
|
||||
let mut output = Vec::new();
|
||||
for (index, summary) in reasoning_summaries.into_iter().enumerate() {
|
||||
@@ -114,25 +114,6 @@ pub fn build_openai_cli_response_with_content(
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn parse_openai_function_arguments(arguments: Option<&Value>) -> Option<Value> {
|
||||
match arguments.cloned().unwrap_or(Value::Object(Map::new())) {
|
||||
Value::Object(object) => Some(Value::Object(object)),
|
||||
Value::String(text) => {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
Some(Value::Object(Map::new()))
|
||||
} else {
|
||||
match serde_json::from_str::<Value>(trimmed) {
|
||||
Ok(Value::Object(object)) => Some(Value::Object(object)),
|
||||
Ok(other) => Some(json!({ "raw": other })),
|
||||
Err(_) => Some(json!({ "raw": text })),
|
||||
}
|
||||
}
|
||||
}
|
||||
other => Some(json!({ "raw": other })),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_generated_tool_call_id(index: usize) -> String {
|
||||
format!("call_auto_{index}")
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use serde_json::{json, Map, Value};
|
||||
|
||||
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
|
||||
|
||||
pub fn convert_openai_cli_response_to_openai_chat(
|
||||
pub fn convert_openai_responses_response_to_openai_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
@@ -377,7 +377,7 @@ fn offset_annotation_indices(annotation: &Value, offset: i64) -> Value {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::convert_openai_cli_response_to_openai_chat;
|
||||
use super::convert_openai_responses_response_to_openai_chat;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
@@ -409,7 +409,7 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let converted = convert_openai_cli_response_to_openai_chat(&response, &json!({}))
|
||||
let converted = convert_openai_responses_response_to_openai_chat(&response, &json!({}))
|
||||
.expect("responses response should convert to chat");
|
||||
|
||||
assert_eq!(converted["created"], 1741476542i64);
|
||||
@@ -467,7 +467,7 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let converted = convert_openai_cli_response_to_openai_chat(&response, &json!({}))
|
||||
let converted = convert_openai_responses_response_to_openai_chat(&response, &json!({}))
|
||||
.expect("responses response should convert to chat");
|
||||
|
||||
assert_eq!(
|
||||
@@ -0,0 +1,6 @@
|
||||
mod claude_chat;
|
||||
mod gemini_chat;
|
||||
mod shared;
|
||||
|
||||
pub use claude_chat::convert_claude_chat_response_to_openai_chat;
|
||||
pub use gemini_chat::convert_gemini_chat_response_to_openai_chat;
|
||||
112
crates/aether-ai-formats/src/formats.rs
Normal file
112
crates/aether-ai-formats/src/formats.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum FormatFamily {
|
||||
OpenAi,
|
||||
Claude,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum FormatProfile {
|
||||
Default,
|
||||
Compact,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum FormatId {
|
||||
OpenAiChat,
|
||||
OpenAiResponses,
|
||||
OpenAiResponsesCompact,
|
||||
ClaudeMessages,
|
||||
GeminiGenerateContent,
|
||||
}
|
||||
|
||||
impl FormatId {
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
value.parse().ok()
|
||||
}
|
||||
|
||||
pub fn canonical(self) -> Self {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn family(self) -> FormatFamily {
|
||||
match self {
|
||||
Self::OpenAiChat | Self::OpenAiResponses | Self::OpenAiResponsesCompact => {
|
||||
FormatFamily::OpenAi
|
||||
}
|
||||
Self::ClaudeMessages => FormatFamily::Claude,
|
||||
Self::GeminiGenerateContent => FormatFamily::Gemini,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn profile(self) -> FormatProfile {
|
||||
match self {
|
||||
Self::OpenAiResponsesCompact => FormatProfile::Compact,
|
||||
_ => FormatProfile::Default,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAiChat => "openai:chat",
|
||||
Self::OpenAiResponses => "openai:responses",
|
||||
Self::OpenAiResponsesCompact => "openai:responses:compact",
|
||||
Self::ClaudeMessages => "claude:messages",
|
||||
Self::GeminiGenerateContent => "gemini:generate_content",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for FormatId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for FormatId {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"openai" | "openai:chat" | "/v1/chat/completions" => Ok(Self::OpenAiChat),
|
||||
"openai:responses" | "openai:cli" | "/v1/responses" => Ok(Self::OpenAiResponses),
|
||||
"openai:responses:compact" | "openai:compact" | "/v1/responses/compact" => {
|
||||
Ok(Self::OpenAiResponsesCompact)
|
||||
}
|
||||
"claude:messages" | "claude:chat" | "claude:cli" | "/v1/messages" => {
|
||||
Ok(Self::ClaudeMessages)
|
||||
}
|
||||
"gemini:generate_content" | "gemini:chat" | "gemini:cli" => {
|
||||
Ok(Self::GeminiGenerateContent)
|
||||
}
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::FormatId;
|
||||
|
||||
#[test]
|
||||
fn normalizes_legacy_aliases() {
|
||||
assert_eq!(
|
||||
FormatId::parse("openai:cli"),
|
||||
Some(FormatId::OpenAiResponses)
|
||||
);
|
||||
assert_eq!(
|
||||
FormatId::parse("openai:compact"),
|
||||
Some(FormatId::OpenAiResponsesCompact)
|
||||
);
|
||||
assert_eq!(
|
||||
FormatId::parse("claude:cli"),
|
||||
Some(FormatId::ClaudeMessages)
|
||||
);
|
||||
assert_eq!(
|
||||
FormatId::parse("gemini:chat"),
|
||||
Some(FormatId::GeminiGenerateContent)
|
||||
);
|
||||
}
|
||||
}
|
||||
28
crates/aether-ai-formats/src/lib.rs
Normal file
28
crates/aether-ai-formats/src/lib.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
pub mod canonical;
|
||||
pub mod conversion;
|
||||
pub mod formats;
|
||||
pub mod planner;
|
||||
pub mod proxy;
|
||||
pub mod registry;
|
||||
pub mod stream;
|
||||
|
||||
pub use canonical::{
|
||||
canonical_request_unknown_block_count, canonical_response_unknown_block_count,
|
||||
canonical_to_claude_request, canonical_to_claude_response, canonical_to_gemini_request,
|
||||
canonical_to_gemini_response, canonical_to_openai_chat_request,
|
||||
canonical_to_openai_chat_response, canonical_to_openai_responses_compact_request,
|
||||
canonical_to_openai_responses_compact_response, canonical_to_openai_responses_request,
|
||||
canonical_to_openai_responses_response, canonical_unknown_block_count,
|
||||
from_claude_to_canonical_request, from_claude_to_canonical_response,
|
||||
from_gemini_to_canonical_request, from_gemini_to_canonical_response,
|
||||
from_openai_chat_to_canonical_request, from_openai_chat_to_canonical_response,
|
||||
from_openai_responses_to_canonical_request, from_openai_responses_to_canonical_response,
|
||||
CanonicalContentBlock, CanonicalGenerationConfig, CanonicalInstruction, CanonicalMessage,
|
||||
CanonicalRequest, CanonicalResponse, CanonicalResponseFormat, CanonicalResponseOutput,
|
||||
CanonicalRole, CanonicalStopReason, CanonicalStreamEvent, CanonicalStreamFrame,
|
||||
CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition, CanonicalUsage,
|
||||
};
|
||||
pub use formats::{FormatFamily, FormatId, FormatProfile};
|
||||
pub use registry::{
|
||||
build_stream_transcoder, convert_request, convert_response, FormatContext, FormatError,
|
||||
};
|
||||
1
crates/aether-ai-formats/src/planner/mod.rs
Normal file
1
crates/aether-ai-formats/src/planner/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod openai;
|
||||
104
crates/aether-ai-formats/src/planner/openai.rs
Normal file
104
crates/aether-ai-formats/src/planner/openai.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub fn parse_openai_stop_sequences(stop: Option<&Value>) -> Option<Vec<Value>> {
|
||||
match stop {
|
||||
Some(Value::String(value)) if !value.trim().is_empty() => {
|
||||
Some(vec![Value::String(value.clone())])
|
||||
}
|
||||
Some(Value::Array(values)) => Some(
|
||||
values
|
||||
.iter()
|
||||
.filter_map(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| Value::String(value.to_string()))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.filter(|values| !values.is_empty()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_openai_chat_max_tokens(request: &Map<String, Value>) -> u64 {
|
||||
request
|
||||
.get("max_completion_tokens")
|
||||
.and_then(value_as_u64)
|
||||
.or_else(|| request.get("max_tokens").and_then(value_as_u64))
|
||||
.unwrap_or(4096)
|
||||
}
|
||||
|
||||
pub fn value_as_u64(value: &Value) -> Option<u64> {
|
||||
value
|
||||
.as_u64()
|
||||
.or_else(|| value.as_i64().and_then(|value| u64::try_from(value).ok()))
|
||||
}
|
||||
|
||||
pub fn copy_request_number_field(
|
||||
request: &Map<String, Value>,
|
||||
target: &mut Map<String, Value>,
|
||||
key: &str,
|
||||
) {
|
||||
copy_request_number_field_as(request, target, key, key);
|
||||
}
|
||||
|
||||
pub fn copy_request_number_field_as(
|
||||
request: &Map<String, Value>,
|
||||
target: &mut Map<String, Value>,
|
||||
source_key: &str,
|
||||
target_key: &str,
|
||||
) {
|
||||
if let Some(value) = request.get(source_key).cloned() {
|
||||
if value.is_number() {
|
||||
target.insert(target_key.to_string(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_openai_reasoning_effort_to_claude_output(value: &str) -> Option<&'static str> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"low" => Some("low"),
|
||||
"medium" => Some("medium"),
|
||||
"high" => Some("high"),
|
||||
"xhigh" => Some("max"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_openai_reasoning_effort_to_thinking_budget(value: &str) -> Option<u64> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"low" => Some(1280),
|
||||
"medium" => Some(2048),
|
||||
"high" => Some(4096),
|
||||
"xhigh" => Some(8192),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_openai_reasoning_effort_to_gemini_budget(value: &str) -> Option<u64> {
|
||||
map_openai_reasoning_effort_to_thinking_budget(value)
|
||||
}
|
||||
|
||||
pub fn map_thinking_budget_to_openai_reasoning_effort(value: u64) -> &'static str {
|
||||
match value {
|
||||
0..=1664 => "low",
|
||||
1665..=3072 => "medium",
|
||||
3073..=6144 => "high",
|
||||
_ => "xhigh",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_openai_reasoning_effort(request: &Map<String, Value>) -> Option<String> {
|
||||
request
|
||||
.get("reasoning_effort")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
request
|
||||
.get("reasoning")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|reasoning| reasoning.get("effort"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
}
|
||||
6
crates/aether-ai-formats/src/proxy/mod.rs
Normal file
6
crates/aether-ai-formats/src/proxy/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod rules;
|
||||
|
||||
pub use rules::{
|
||||
apply_local_body_rules, apply_local_header_rules, body_rules_are_locally_supported,
|
||||
body_rules_handle_path, header_rules_are_locally_supported,
|
||||
};
|
||||
1616
crates/aether-ai-formats/src/proxy/rules.rs
Normal file
1616
crates/aether-ai-formats/src/proxy/rules.rs
Normal file
File diff suppressed because it is too large
Load Diff
312
crates/aether-ai-formats/src/registry.rs
Normal file
312
crates/aether-ai-formats/src/registry.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
canonical::{
|
||||
canonical_to_claude_request, canonical_to_claude_response, canonical_to_gemini_request,
|
||||
canonical_to_gemini_response, canonical_to_openai_chat_request,
|
||||
canonical_to_openai_chat_response, canonical_to_openai_responses_compact_request,
|
||||
canonical_to_openai_responses_compact_response, canonical_to_openai_responses_request,
|
||||
canonical_to_openai_responses_response, from_claude_to_canonical_request,
|
||||
from_claude_to_canonical_response, from_gemini_to_canonical_request,
|
||||
from_gemini_to_canonical_response, from_openai_chat_to_canonical_request,
|
||||
from_openai_chat_to_canonical_response, from_openai_responses_to_canonical_request,
|
||||
from_openai_responses_to_canonical_response, CanonicalRequest, CanonicalResponse,
|
||||
},
|
||||
formats::FormatId,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FormatContext {
|
||||
pub mapped_model: Option<String>,
|
||||
pub request_path: Option<String>,
|
||||
pub upstream_is_stream: bool,
|
||||
pub report_context: Option<Value>,
|
||||
}
|
||||
|
||||
impl FormatContext {
|
||||
pub fn with_mapped_model(mut self, mapped_model: impl Into<String>) -> Self {
|
||||
self.mapped_model = Some(mapped_model.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_request_path(mut self, request_path: impl Into<String>) -> Self {
|
||||
self.request_path = Some(request_path.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_upstream_stream(mut self, upstream_is_stream: bool) -> Self {
|
||||
self.upstream_is_stream = upstream_is_stream;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_report_context(mut self, report_context: Value) -> Self {
|
||||
self.report_context = Some(report_context);
|
||||
self
|
||||
}
|
||||
|
||||
fn mapped_model_or<'a>(&'a self, fallback: &'a str) -> &'a str {
|
||||
self.mapped_model
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
fn report_context_value(&self) -> Value {
|
||||
self.report_context.clone().unwrap_or_else(|| {
|
||||
json!({
|
||||
"mapped_model": self.mapped_model,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FormatError {
|
||||
UnsupportedFormat(String),
|
||||
RequestParseFailed { format: String },
|
||||
RequestEmitFailed { format: String },
|
||||
ResponseParseFailed { format: String },
|
||||
ResponseEmitFailed { format: String },
|
||||
}
|
||||
|
||||
impl fmt::Display for FormatError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::UnsupportedFormat(format) => write!(f, "unsupported AI format: {format}"),
|
||||
Self::RequestParseFailed { format } => {
|
||||
write!(f, "failed to parse {format} request")
|
||||
}
|
||||
Self::RequestEmitFailed { format } => write!(f, "failed to emit {format} request"),
|
||||
Self::ResponseParseFailed { format } => {
|
||||
write!(f, "failed to parse {format} response")
|
||||
}
|
||||
Self::ResponseEmitFailed { format } => write!(f, "failed to emit {format} response"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for FormatError {}
|
||||
|
||||
pub fn parse_request(
|
||||
source_format: &str,
|
||||
body: &Value,
|
||||
ctx: &FormatContext,
|
||||
) -> Result<CanonicalRequest, FormatError> {
|
||||
let source = parse_format(source_format)?;
|
||||
match source {
|
||||
FormatId::OpenAiChat => from_openai_chat_to_canonical_request(body),
|
||||
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact => {
|
||||
from_openai_responses_to_canonical_request(body)
|
||||
}
|
||||
FormatId::ClaudeMessages => from_claude_to_canonical_request(body),
|
||||
FormatId::GeminiGenerateContent => {
|
||||
from_gemini_to_canonical_request(body, ctx.request_path.as_deref().unwrap_or_default())
|
||||
}
|
||||
}
|
||||
.ok_or_else(|| FormatError::RequestParseFailed {
|
||||
format: source.as_str().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn emit_request(
|
||||
target_format: &str,
|
||||
request: &CanonicalRequest,
|
||||
ctx: &FormatContext,
|
||||
) -> Result<Value, FormatError> {
|
||||
let target = parse_format(target_format)?;
|
||||
let mut request = request.clone();
|
||||
if let Some(mapped_model) = ctx
|
||||
.mapped_model
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
request.model = mapped_model.to_string();
|
||||
}
|
||||
let mapped_model = ctx.mapped_model_or(request.model.as_str());
|
||||
match target {
|
||||
FormatId::OpenAiChat => {
|
||||
let mut body = canonical_to_openai_chat_request(&request);
|
||||
force_openai_chat_stream_options(&mut body, ctx.upstream_is_stream);
|
||||
Some(body)
|
||||
}
|
||||
FormatId::OpenAiResponses => {
|
||||
canonical_to_openai_responses_request(&request, mapped_model, ctx.upstream_is_stream)
|
||||
}
|
||||
FormatId::OpenAiResponsesCompact => {
|
||||
canonical_to_openai_responses_compact_request(&request, mapped_model)
|
||||
}
|
||||
FormatId::ClaudeMessages => {
|
||||
canonical_to_claude_request(&request, mapped_model, ctx.upstream_is_stream)
|
||||
}
|
||||
FormatId::GeminiGenerateContent => {
|
||||
canonical_to_gemini_request(&request, mapped_model, ctx.upstream_is_stream)
|
||||
}
|
||||
}
|
||||
.ok_or_else(|| FormatError::RequestEmitFailed {
|
||||
format: target.as_str().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn convert_request(
|
||||
source_format: &str,
|
||||
target_format: &str,
|
||||
body: &Value,
|
||||
ctx: &FormatContext,
|
||||
) -> Result<Value, FormatError> {
|
||||
let request = parse_request(source_format, body, ctx)?;
|
||||
emit_request(target_format, &request, ctx)
|
||||
}
|
||||
|
||||
pub fn parse_response(
|
||||
source_format: &str,
|
||||
body: &Value,
|
||||
_ctx: &FormatContext,
|
||||
) -> Result<CanonicalResponse, FormatError> {
|
||||
let source = parse_format(source_format)?;
|
||||
match source {
|
||||
FormatId::OpenAiChat => from_openai_chat_to_canonical_response(body),
|
||||
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact => {
|
||||
from_openai_responses_to_canonical_response(body)
|
||||
}
|
||||
FormatId::ClaudeMessages => from_claude_to_canonical_response(body),
|
||||
FormatId::GeminiGenerateContent => from_gemini_to_canonical_response(body),
|
||||
}
|
||||
.ok_or_else(|| FormatError::ResponseParseFailed {
|
||||
format: source.as_str().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn emit_response(
|
||||
target_format: &str,
|
||||
response: &CanonicalResponse,
|
||||
ctx: &FormatContext,
|
||||
) -> Result<Value, FormatError> {
|
||||
let target = parse_format(target_format)?;
|
||||
let report_context = ctx.report_context_value();
|
||||
match target {
|
||||
FormatId::OpenAiChat => {
|
||||
let mut response = canonical_to_openai_chat_response(response);
|
||||
if response.get("service_tier").is_none() {
|
||||
if let Some(service_tier) = report_context
|
||||
.get("original_request_body")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|request| request.get("service_tier"))
|
||||
.cloned()
|
||||
{
|
||||
response["service_tier"] = service_tier;
|
||||
}
|
||||
}
|
||||
Some(response)
|
||||
}
|
||||
FormatId::OpenAiResponses => Some(canonical_to_openai_responses_response(
|
||||
response,
|
||||
&report_context,
|
||||
)),
|
||||
FormatId::OpenAiResponsesCompact => Some(canonical_to_openai_responses_compact_response(
|
||||
response,
|
||||
&report_context,
|
||||
)),
|
||||
FormatId::ClaudeMessages => Some(canonical_to_claude_response(response)),
|
||||
FormatId::GeminiGenerateContent => canonical_to_gemini_response(response, &report_context),
|
||||
}
|
||||
.ok_or_else(|| FormatError::ResponseEmitFailed {
|
||||
format: target.as_str().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn convert_response(
|
||||
source_format: &str,
|
||||
target_format: &str,
|
||||
body: &Value,
|
||||
ctx: &FormatContext,
|
||||
) -> Result<Value, FormatError> {
|
||||
let mut response = parse_response(source_format, body, ctx)?;
|
||||
if response.model.trim().is_empty() || response.model == "unknown" {
|
||||
if let Some(mapped_model) = ctx
|
||||
.mapped_model
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
response.model = mapped_model.to_string();
|
||||
}
|
||||
}
|
||||
emit_response(target_format, &response, ctx)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StreamTranscoderSpec {
|
||||
pub source: FormatId,
|
||||
pub target: FormatId,
|
||||
}
|
||||
|
||||
pub fn build_stream_transcoder(
|
||||
source_format: &str,
|
||||
target_format: &str,
|
||||
_ctx: &FormatContext,
|
||||
) -> Result<StreamTranscoderSpec, FormatError> {
|
||||
Ok(StreamTranscoderSpec {
|
||||
source: parse_format(source_format)?,
|
||||
target: parse_format(target_format)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_format(format: &str) -> Result<FormatId, FormatError> {
|
||||
FormatId::parse(format).ok_or_else(|| FormatError::UnsupportedFormat(format.to_string()))
|
||||
}
|
||||
|
||||
fn force_openai_chat_stream_options(body: &mut Value, upstream_is_stream: bool) {
|
||||
if !upstream_is_stream {
|
||||
return;
|
||||
}
|
||||
let Some(object) = body.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
object.insert("stream".to_string(), Value::Bool(true));
|
||||
match object.get_mut("stream_options") {
|
||||
Some(Value::Object(stream_options)) => {
|
||||
stream_options.insert("include_usage".to_string(), Value::Bool(true));
|
||||
}
|
||||
_ => {
|
||||
object.insert(
|
||||
"stream_options".to_string(),
|
||||
json!({
|
||||
"include_usage": true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{convert_request, FormatContext};
|
||||
use crate::formats::FormatId;
|
||||
|
||||
#[test]
|
||||
fn cli_alias_routes_to_openai_responses() {
|
||||
assert_eq!(
|
||||
FormatId::parse("openai:cli"),
|
||||
Some(FormatId::OpenAiResponses)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_openai_chat_to_responses_via_registry() {
|
||||
let body = json!({
|
||||
"model": "gpt-source",
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
let ctx = FormatContext::default().with_mapped_model("gpt-target");
|
||||
|
||||
let converted = convert_request("openai:chat", "openai:responses", &body, &ctx)
|
||||
.expect("request conversion should succeed");
|
||||
|
||||
assert_eq!(converted["model"], "gpt-target");
|
||||
assert_eq!(converted["input"][0]["type"], "message");
|
||||
assert_eq!(converted["input"][0]["content"][0]["type"], "input_text");
|
||||
}
|
||||
}
|
||||
67
crates/aether-ai-formats/src/stream.rs
Normal file
67
crates/aether-ai-formats/src/stream.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CanonicalUsage {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub cache_creation_tokens: u64,
|
||||
pub cache_creation_ephemeral_5m_tokens: u64,
|
||||
pub cache_creation_ephemeral_1h_tokens: u64,
|
||||
pub cache_read_tokens: u64,
|
||||
pub reasoning_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum CanonicalContentPart {
|
||||
ImageUrl(String),
|
||||
File {
|
||||
file_data: Option<String>,
|
||||
reference: Option<String>,
|
||||
mime_type: Option<String>,
|
||||
filename: Option<String>,
|
||||
},
|
||||
Audio {
|
||||
data: String,
|
||||
format: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum CanonicalStreamEvent {
|
||||
Start,
|
||||
TextDelta(String),
|
||||
ReasoningDelta(String),
|
||||
ReasoningSignature(String),
|
||||
ContentPart(CanonicalContentPart),
|
||||
ToolCallStart {
|
||||
index: usize,
|
||||
call_id: String,
|
||||
name: String,
|
||||
},
|
||||
ToolCallArgumentsDelta {
|
||||
index: usize,
|
||||
arguments: String,
|
||||
},
|
||||
ToolResultDelta {
|
||||
index: usize,
|
||||
tool_use_id: String,
|
||||
name: Option<String>,
|
||||
content: String,
|
||||
},
|
||||
UnknownEvent(Value),
|
||||
Finish {
|
||||
finish_reason: Option<String>,
|
||||
usage: Option<CanonicalUsage>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CanonicalStreamFrame {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub event: CanonicalStreamEvent,
|
||||
}
|
||||
@@ -7,6 +7,7 @@ repository.workspace = true
|
||||
description = "Shared AI pipeline contracts and planner logic for Aether"
|
||||
|
||||
[dependencies]
|
||||
aether-ai-formats.workspace = true
|
||||
aether-contracts.workspace = true
|
||||
aether-usage-runtime.workspace = true
|
||||
base64.workspace = true
|
||||
|
||||
@@ -22,7 +22,8 @@ pub use crate::contracts::augment_sync_report_context;
|
||||
pub use crate::contracts::{
|
||||
core_error_background_report_kind, core_error_default_client_api_format,
|
||||
core_success_background_report_kind, generic_decision_missing_exact_provider_request,
|
||||
implicit_sync_finalize_report_kind, ExecutionRuntimeAuthContext, GatewayControlPlanRequest,
|
||||
implicit_sync_finalize_report_kind, is_openai_responses_stream_plan_kind,
|
||||
is_openai_responses_sync_plan_kind, ExecutionRuntimeAuthContext, GatewayControlPlanRequest,
|
||||
GatewayControlPlanResponse, GatewayControlSyncDecisionResponse, LocalStreamPlanAndReport,
|
||||
LocalSyncPlanAndReport, CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_STREAM_SUCCESS_REPORT_KIND,
|
||||
CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND, CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND,
|
||||
@@ -51,33 +52,56 @@ pub use crate::contracts::{
|
||||
OPENAI_COMPACT_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND,
|
||||
OPENAI_IMAGE_STREAM_SUCCESS_REPORT_KIND, OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND,
|
||||
OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND,
|
||||
OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_CONTENT_PLAN_KIND,
|
||||
OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_ERROR_REPORT_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_STREAM_PLAN_KIND,
|
||||
OPENAI_RESPONSES_STREAM_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_SYNC_ERROR_REPORT_KIND,
|
||||
OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND, OPENAI_RESPONSES_SYNC_PLAN_KIND,
|
||||
OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND,
|
||||
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
||||
};
|
||||
pub use crate::conversion::request::{
|
||||
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
|
||||
convert_openai_chat_request_to_openai_cli_request, extract_openai_text_content,
|
||||
convert_openai_chat_request_to_openai_responses_request, extract_openai_text_content,
|
||||
normalize_claude_request_to_openai_chat_request,
|
||||
normalize_gemini_request_to_openai_chat_request,
|
||||
normalize_openai_cli_request_to_openai_chat_request, parse_openai_tool_result_content,
|
||||
normalize_openai_responses_request_to_openai_chat_request, parse_openai_tool_result_content,
|
||||
};
|
||||
pub use crate::conversion::response::{
|
||||
build_openai_cli_response, convert_claude_chat_response_to_openai_chat,
|
||||
convert_claude_cli_response_to_openai_cli, convert_gemini_chat_response_to_openai_chat,
|
||||
convert_gemini_cli_response_to_openai_cli, convert_openai_chat_response_to_claude_chat,
|
||||
convert_openai_chat_response_to_gemini_chat, convert_openai_chat_response_to_openai_cli,
|
||||
convert_openai_cli_response_to_openai_chat,
|
||||
build_openai_responses_response, build_openai_responses_response_with_content,
|
||||
build_openai_responses_response_with_reasoning, convert_claude_chat_response_to_openai_chat,
|
||||
convert_claude_response_to_openai_responses, convert_gemini_chat_response_to_openai_chat,
|
||||
convert_gemini_response_to_openai_responses, convert_openai_chat_response_to_claude_chat,
|
||||
convert_openai_chat_response_to_gemini_chat, convert_openai_chat_response_to_openai_responses,
|
||||
convert_openai_responses_response_to_openai_chat, OpenAiResponsesResponseUsage,
|
||||
};
|
||||
pub use crate::conversion::{
|
||||
build_core_error_body_for_client_format, is_core_error_finalize_kind,
|
||||
request_candidate_api_format_preference, request_candidate_api_formats,
|
||||
request_conversion_direct_auth, request_conversion_enabled_for_transport,
|
||||
request_conversion_kind, request_conversion_requires_enable_flag,
|
||||
request_conversion_transport_supported, request_conversion_transport_unsupported_reason,
|
||||
request_pair_allowed_for_transport, sync_chat_response_conversion_kind,
|
||||
sync_cli_response_conversion_kind, LocalCoreSyncErrorKind, RequestConversionKind,
|
||||
SyncChatResponseConversionKind, SyncCliResponseConversionKind,
|
||||
build_core_error_body_for_client_format, canonical_request_unknown_block_count,
|
||||
canonical_response_unknown_block_count, canonical_to_claude_request,
|
||||
canonical_to_claude_response, canonical_to_gemini_request, canonical_to_gemini_response,
|
||||
canonical_to_openai_chat_request, canonical_to_openai_chat_response,
|
||||
canonical_to_openai_responses_compact_request, canonical_to_openai_responses_compact_response,
|
||||
canonical_to_openai_responses_request, canonical_to_openai_responses_response,
|
||||
canonical_unknown_block_count, convert_request, convert_response,
|
||||
from_claude_to_canonical_request, from_claude_to_canonical_response,
|
||||
from_gemini_to_canonical_request, from_gemini_to_canonical_response,
|
||||
from_openai_chat_to_canonical_request, from_openai_chat_to_canonical_response,
|
||||
from_openai_responses_to_canonical_request, from_openai_responses_to_canonical_response,
|
||||
is_core_error_finalize_kind, request_candidate_api_format_preference,
|
||||
request_candidate_api_formats, request_conversion_direct_auth,
|
||||
request_conversion_enabled_for_transport, request_conversion_kind,
|
||||
request_conversion_requires_enable_flag, request_conversion_transport_supported,
|
||||
request_conversion_transport_unsupported_reason, request_pair_allowed_for_transport,
|
||||
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind, CanonicalContentBlock,
|
||||
CanonicalGenerationConfig, CanonicalInstruction, CanonicalMessage, CanonicalRequest,
|
||||
CanonicalResponse, CanonicalResponseFormat, CanonicalResponseOutput, CanonicalRole,
|
||||
CanonicalStopReason, CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition,
|
||||
CanonicalUsage, FormatContext, FormatError, FormatFamily, FormatId, FormatProfile,
|
||||
LocalCoreSyncErrorKind, RequestConversionKind, SyncChatResponseConversionKind,
|
||||
SyncCliResponseConversionKind,
|
||||
};
|
||||
pub use crate::finalize::common::{
|
||||
build_generated_tool_call_id, build_local_success_background_report,
|
||||
@@ -88,8 +112,8 @@ pub use crate::finalize::sse::{encode_done_sse, encode_json_sse, map_claude_stop
|
||||
pub use crate::finalize::standard::claude::stream::{ClaudeClientEmitter, ClaudeProviderState};
|
||||
pub use crate::finalize::standard::gemini::stream::{GeminiClientEmitter, GeminiProviderState};
|
||||
pub use crate::finalize::standard::openai::stream::{
|
||||
OpenAIChatClientEmitter, OpenAIChatProviderState, OpenAICliClientEmitter,
|
||||
OpenAICliProviderState,
|
||||
OpenAIChatClientEmitter, OpenAIChatProviderState, OpenAIResponsesClientEmitter,
|
||||
OpenAIResponsesProviderState,
|
||||
};
|
||||
pub use crate::finalize::standard::stream_core::common::*;
|
||||
pub use crate::finalize::standard::stream_core::{
|
||||
@@ -97,12 +121,12 @@ pub use crate::finalize::standard::stream_core::{
|
||||
};
|
||||
pub use crate::finalize::sync_products::{
|
||||
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
|
||||
aggregate_openai_chat_stream_sync_response, aggregate_openai_cli_stream_sync_response,
|
||||
aggregate_openai_chat_stream_sync_response, aggregate_openai_responses_stream_sync_response,
|
||||
aggregate_standard_chat_stream_sync_response, aggregate_standard_cli_stream_sync_response,
|
||||
convert_standard_chat_response, convert_standard_cli_response,
|
||||
maybe_build_openai_chat_cross_format_sync_product_from_normalized_payload,
|
||||
maybe_build_openai_cli_cross_format_sync_product_from_normalized_payload,
|
||||
maybe_build_openai_cli_same_family_sync_body_from_normalized_payload,
|
||||
maybe_build_openai_responses_cross_format_sync_product_from_normalized_payload,
|
||||
maybe_build_openai_responses_same_family_sync_body_from_normalized_payload,
|
||||
maybe_build_standard_cross_format_sync_product,
|
||||
maybe_build_standard_cross_format_sync_product_from_normalized_payload,
|
||||
maybe_build_standard_same_format_sync_body_from_normalized_payload,
|
||||
@@ -145,11 +169,18 @@ pub use crate::planner::specialized::{
|
||||
LocalVideoCreateSpec,
|
||||
},
|
||||
};
|
||||
#[allow(deprecated)]
|
||||
pub use crate::planner::standard::apply_openai_compact_special_body_edits;
|
||||
#[allow(deprecated)]
|
||||
pub use crate::planner::standard::{
|
||||
apply_codex_openai_cli_special_body_edits, apply_codex_openai_cli_special_headers,
|
||||
apply_openai_compact_special_body_edits, build_cross_format_openai_chat_request_body,
|
||||
build_cross_format_openai_cli_request_body, build_local_openai_chat_request_body,
|
||||
build_local_openai_cli_request_body, build_standard_request_body, build_standard_upstream_url,
|
||||
};
|
||||
pub use crate::planner::standard::{
|
||||
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
|
||||
apply_openai_responses_compact_special_body_edits, build_cross_format_openai_chat_request_body,
|
||||
build_cross_format_openai_responses_request_body, build_local_openai_chat_request_body,
|
||||
build_local_openai_responses_request_body, build_standard_request_body,
|
||||
build_standard_upstream_url,
|
||||
claude::{
|
||||
resolve_stream_spec as resolve_claude_stream_spec,
|
||||
resolve_sync_spec as resolve_claude_sync_spec,
|
||||
@@ -159,9 +190,9 @@ pub use crate::planner::standard::{
|
||||
resolve_sync_spec as resolve_gemini_sync_spec,
|
||||
},
|
||||
normalize_standard_request_to_openai_chat_request,
|
||||
openai_cli::{
|
||||
resolve_stream_spec as resolve_openai_cli_stream_spec,
|
||||
resolve_sync_spec as resolve_openai_cli_sync_spec, LocalOpenAiCliSpec,
|
||||
openai_responses::{
|
||||
resolve_stream_spec as resolve_openai_responses_stream_spec,
|
||||
resolve_sync_spec as resolve_openai_responses_sync_spec, LocalOpenAiResponsesSpec,
|
||||
},
|
||||
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
|
||||
CODEX_OPENAI_IMAGE_DEFAULT_MODEL, CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT,
|
||||
|
||||
@@ -16,6 +16,7 @@ pub use control_payloads::{
|
||||
LocalSyncPlanAndReport,
|
||||
};
|
||||
pub use plan_kinds::{
|
||||
is_openai_responses_stream_plan_kind, is_openai_responses_sync_plan_kind,
|
||||
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||
CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
|
||||
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND, GEMINI_FILES_DELETE_PLAN_KIND,
|
||||
@@ -24,6 +25,8 @@ pub use plan_kinds::{
|
||||
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
|
||||
OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
|
||||
OPENAI_COMPACT_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND,
|
||||
OPENAI_RESPONSES_STREAM_PLAN_KIND, OPENAI_RESPONSES_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_CONTENT_PLAN_KIND,
|
||||
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
||||
@@ -46,5 +49,10 @@ pub use report_kinds::{
|
||||
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND, OPENAI_CLI_SYNC_SUCCESS_REPORT_KIND,
|
||||
OPENAI_COMPACT_SYNC_ERROR_REPORT_KIND, OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND,
|
||||
OPENAI_IMAGE_STREAM_SUCCESS_REPORT_KIND, OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND,
|
||||
OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND, OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND,
|
||||
OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_ERROR_REPORT_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_STREAM_SUCCESS_REPORT_KIND,
|
||||
OPENAI_RESPONSES_SYNC_ERROR_REPORT_KIND, OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND,
|
||||
OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND, OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND,
|
||||
};
|
||||
|
||||
@@ -14,15 +14,39 @@ pub const GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND: &str = "gemini_video_cancel_sync";
|
||||
pub const OPENAI_CHAT_STREAM_PLAN_KIND: &str = "openai_chat_stream";
|
||||
pub const CLAUDE_CHAT_STREAM_PLAN_KIND: &str = "claude_chat_stream";
|
||||
pub const GEMINI_CHAT_STREAM_PLAN_KIND: &str = "gemini_chat_stream";
|
||||
pub const OPENAI_RESPONSES_STREAM_PLAN_KIND: &str = "openai_responses_stream";
|
||||
pub const OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND: &str = "openai_responses_compact_stream";
|
||||
pub const OPENAI_CLI_STREAM_PLAN_KIND: &str = "openai_cli_stream";
|
||||
pub const OPENAI_COMPACT_STREAM_PLAN_KIND: &str = "openai_compact_stream";
|
||||
pub const CLAUDE_CLI_STREAM_PLAN_KIND: &str = "claude_cli_stream";
|
||||
pub const GEMINI_CLI_STREAM_PLAN_KIND: &str = "gemini_cli_stream";
|
||||
pub const OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND: &str = "openai_video_create_sync";
|
||||
pub const OPENAI_CHAT_SYNC_PLAN_KIND: &str = "openai_chat_sync";
|
||||
pub const OPENAI_RESPONSES_SYNC_PLAN_KIND: &str = "openai_responses_sync";
|
||||
pub const OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND: &str = "openai_responses_compact_sync";
|
||||
pub const OPENAI_CLI_SYNC_PLAN_KIND: &str = "openai_cli_sync";
|
||||
pub const OPENAI_COMPACT_SYNC_PLAN_KIND: &str = "openai_compact_sync";
|
||||
pub const CLAUDE_CHAT_SYNC_PLAN_KIND: &str = "claude_chat_sync";
|
||||
pub const GEMINI_CHAT_SYNC_PLAN_KIND: &str = "gemini_chat_sync";
|
||||
pub const CLAUDE_CLI_SYNC_PLAN_KIND: &str = "claude_cli_sync";
|
||||
pub const GEMINI_CLI_SYNC_PLAN_KIND: &str = "gemini_cli_sync";
|
||||
|
||||
pub fn is_openai_responses_stream_plan_kind(plan_kind: &str) -> bool {
|
||||
matches!(
|
||||
plan_kind,
|
||||
OPENAI_RESPONSES_STREAM_PLAN_KIND
|
||||
| OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND
|
||||
| OPENAI_CLI_STREAM_PLAN_KIND
|
||||
| OPENAI_COMPACT_STREAM_PLAN_KIND
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_openai_responses_sync_plan_kind(plan_kind: &str) -> bool {
|
||||
matches!(
|
||||
plan_kind,
|
||||
OPENAI_RESPONSES_SYNC_PLAN_KIND
|
||||
| OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND
|
||||
| OPENAI_CLI_SYNC_PLAN_KIND
|
||||
| OPENAI_COMPACT_SYNC_PLAN_KIND
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@ use crate::contracts::{
|
||||
CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
|
||||
GEMINI_CLI_SYNC_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND,
|
||||
OPENAI_COMPACT_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, OPENAI_RESPONSES_SYNC_PLAN_KIND,
|
||||
};
|
||||
|
||||
pub const OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND: &str = "openai_chat_sync_finalize";
|
||||
pub const CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND: &str = "claude_chat_sync_finalize";
|
||||
pub const GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND: &str = "gemini_chat_sync_finalize";
|
||||
pub const OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND: &str = "openai_responses_sync_finalize";
|
||||
pub const OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND: &str =
|
||||
"openai_responses_compact_sync_finalize";
|
||||
pub const OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND: &str = "openai_cli_sync_finalize";
|
||||
pub const OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND: &str = "openai_compact_sync_finalize";
|
||||
pub const OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND: &str = "openai_image_sync_finalize";
|
||||
@@ -18,6 +22,9 @@ pub const GEMINI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND: &str = "gemini_video_cr
|
||||
pub const OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "openai_chat_sync_success";
|
||||
pub const CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "claude_chat_sync_success";
|
||||
pub const GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "gemini_chat_sync_success";
|
||||
pub const OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND: &str = "openai_responses_sync_success";
|
||||
pub const OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND: &str =
|
||||
"openai_responses_compact_sync_success";
|
||||
pub const OPENAI_CLI_SYNC_SUCCESS_REPORT_KIND: &str = "openai_cli_sync_success";
|
||||
pub const OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND: &str = "openai_image_sync_success";
|
||||
pub const CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND: &str = "claude_cli_sync_success";
|
||||
@@ -26,6 +33,9 @@ pub const GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND: &str = "gemini_cli_sync_success";
|
||||
pub const OPENAI_CHAT_STREAM_SUCCESS_REPORT_KIND: &str = "openai_chat_stream_success";
|
||||
pub const CLAUDE_CHAT_STREAM_SUCCESS_REPORT_KIND: &str = "claude_chat_stream_success";
|
||||
pub const GEMINI_CHAT_STREAM_SUCCESS_REPORT_KIND: &str = "gemini_chat_stream_success";
|
||||
pub const OPENAI_RESPONSES_STREAM_SUCCESS_REPORT_KIND: &str = "openai_responses_stream_success";
|
||||
pub const OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND: &str =
|
||||
"openai_responses_compact_stream_success";
|
||||
pub const OPENAI_CLI_STREAM_SUCCESS_REPORT_KIND: &str = "openai_cli_stream_success";
|
||||
pub const OPENAI_IMAGE_STREAM_SUCCESS_REPORT_KIND: &str = "openai_image_stream_success";
|
||||
pub const CLAUDE_CLI_STREAM_SUCCESS_REPORT_KIND: &str = "claude_cli_stream_success";
|
||||
@@ -34,6 +44,9 @@ pub const GEMINI_CLI_STREAM_SUCCESS_REPORT_KIND: &str = "gemini_cli_stream_succe
|
||||
pub const OPENAI_CHAT_SYNC_ERROR_REPORT_KIND: &str = "openai_chat_sync_error";
|
||||
pub const CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND: &str = "claude_chat_sync_error";
|
||||
pub const GEMINI_CHAT_SYNC_ERROR_REPORT_KIND: &str = "gemini_chat_sync_error";
|
||||
pub const OPENAI_RESPONSES_SYNC_ERROR_REPORT_KIND: &str = "openai_responses_sync_error";
|
||||
pub const OPENAI_RESPONSES_COMPACT_SYNC_ERROR_REPORT_KIND: &str =
|
||||
"openai_responses_compact_sync_error";
|
||||
pub const OPENAI_CLI_SYNC_ERROR_REPORT_KIND: &str = "openai_cli_sync_error";
|
||||
pub const OPENAI_COMPACT_SYNC_ERROR_REPORT_KIND: &str = "openai_compact_sync_error";
|
||||
pub const CLAUDE_CLI_SYNC_ERROR_REPORT_KIND: &str = "claude_cli_sync_error";
|
||||
@@ -44,6 +57,10 @@ pub fn implicit_sync_finalize_report_kind(plan_kind: &str) -> Option<&'static st
|
||||
OPENAI_CHAT_SYNC_PLAN_KIND => Some(OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND),
|
||||
CLAUDE_CHAT_SYNC_PLAN_KIND => Some(CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND),
|
||||
GEMINI_CHAT_SYNC_PLAN_KIND => Some(GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND),
|
||||
OPENAI_RESPONSES_SYNC_PLAN_KIND => Some(OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND),
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND => {
|
||||
Some(OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND)
|
||||
}
|
||||
OPENAI_CLI_SYNC_PLAN_KIND => Some(OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND),
|
||||
OPENAI_COMPACT_SYNC_PLAN_KIND => Some(OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND),
|
||||
OPENAI_IMAGE_SYNC_PLAN_KIND => Some(OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND),
|
||||
@@ -58,8 +75,10 @@ pub fn core_error_default_client_api_format(report_kind: &str) -> Option<&'stati
|
||||
OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("openai:chat"),
|
||||
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("claude:chat"),
|
||||
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("gemini:chat"),
|
||||
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND => Some("openai:cli"),
|
||||
OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => Some("openai:compact"),
|
||||
OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND => Some("openai:responses"),
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND => Some("openai:responses:compact"),
|
||||
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND => Some("openai:responses"),
|
||||
OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => Some("openai:responses:compact"),
|
||||
OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND => Some("openai:image"),
|
||||
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND => Some("claude:cli"),
|
||||
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND => Some("gemini:cli"),
|
||||
@@ -72,8 +91,13 @@ pub fn core_error_background_report_kind(report_kind: &str) -> Option<&'static s
|
||||
OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_CHAT_SYNC_ERROR_REPORT_KIND),
|
||||
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND),
|
||||
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CHAT_SYNC_ERROR_REPORT_KIND),
|
||||
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_CLI_SYNC_ERROR_REPORT_KIND),
|
||||
OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_COMPACT_SYNC_ERROR_REPORT_KIND),
|
||||
OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_RESPONSES_SYNC_ERROR_REPORT_KIND),
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND => {
|
||||
Some(OPENAI_RESPONSES_COMPACT_SYNC_ERROR_REPORT_KIND)
|
||||
}
|
||||
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND | OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => {
|
||||
Some(OPENAI_RESPONSES_SYNC_ERROR_REPORT_KIND)
|
||||
}
|
||||
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CLI_SYNC_ERROR_REPORT_KIND),
|
||||
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CLI_SYNC_ERROR_REPORT_KIND),
|
||||
_ => None,
|
||||
@@ -86,9 +110,14 @@ pub fn core_success_background_report_kind(report_kind: &str) -> Option<&'static
|
||||
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND),
|
||||
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND),
|
||||
OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND),
|
||||
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND | OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => {
|
||||
Some(OPENAI_CLI_SYNC_SUCCESS_REPORT_KIND)
|
||||
OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND => {
|
||||
Some(OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND)
|
||||
}
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND => {
|
||||
Some(OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND)
|
||||
}
|
||||
OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND),
|
||||
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND),
|
||||
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND),
|
||||
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND),
|
||||
_ => None,
|
||||
|
||||
3
crates/aether-ai-pipeline/src/conversion/canonical.rs
Normal file
3
crates/aether-ai-pipeline/src/conversion/canonical.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
#![allow(deprecated)]
|
||||
|
||||
pub use aether_ai_formats::canonical::*;
|
||||
@@ -38,7 +38,11 @@ pub fn build_core_error_body_for_client_format(
|
||||
error_object.insert("message".to_string(), Value::String(message.to_string()));
|
||||
|
||||
match client_api_format.trim().to_ascii_lowercase().as_str() {
|
||||
"openai:chat" | "openai:cli" | "openai:compact" => {
|
||||
"openai:chat"
|
||||
| "openai:responses"
|
||||
| "openai:cli"
|
||||
| "openai:compact"
|
||||
| "openai:responses:compact" => {
|
||||
error_object.insert(
|
||||
"type".to_string(),
|
||||
Value::String(map_local_sync_error_kind_to_openai_type(kind).to_string()),
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
pub mod canonical;
|
||||
mod error;
|
||||
mod registry;
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
|
||||
pub use aether_ai_formats::{
|
||||
convert_request, convert_response, FormatContext, FormatError, FormatFamily, FormatId,
|
||||
FormatProfile,
|
||||
};
|
||||
pub use canonical::{
|
||||
canonical_request_unknown_block_count, canonical_response_unknown_block_count,
|
||||
canonical_to_claude_request, canonical_to_claude_response, canonical_to_gemini_request,
|
||||
canonical_to_gemini_response, canonical_to_openai_chat_request,
|
||||
canonical_to_openai_chat_response, canonical_to_openai_responses_compact_request,
|
||||
canonical_to_openai_responses_compact_response, canonical_to_openai_responses_request,
|
||||
canonical_to_openai_responses_response, canonical_unknown_block_count,
|
||||
from_claude_to_canonical_request, from_claude_to_canonical_response,
|
||||
from_gemini_to_canonical_request, from_gemini_to_canonical_response,
|
||||
from_openai_chat_to_canonical_request, from_openai_chat_to_canonical_response,
|
||||
from_openai_responses_to_canonical_request, from_openai_responses_to_canonical_response,
|
||||
CanonicalContentBlock, CanonicalGenerationConfig, CanonicalInstruction, CanonicalMessage,
|
||||
CanonicalRequest, CanonicalResponse, CanonicalResponseFormat, CanonicalResponseOutput,
|
||||
CanonicalRole, CanonicalStopReason, CanonicalStreamEvent, CanonicalStreamFrame,
|
||||
CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition, CanonicalUsage,
|
||||
};
|
||||
pub use error::{
|
||||
build_core_error_body_for_client_format, core_error_background_report_kind,
|
||||
core_error_default_client_api_format, core_success_background_report_kind,
|
||||
|
||||
@@ -19,7 +19,7 @@ use aether_provider_transport::GatewayProviderTransportSnapshot;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RequestConversionKind {
|
||||
ToOpenAIChat,
|
||||
ToOpenAIFamilyCli,
|
||||
ToOpenAiResponses,
|
||||
ToClaudeStandard,
|
||||
ToGeminiStandard,
|
||||
}
|
||||
@@ -33,13 +33,14 @@ pub enum SyncChatResponseConversionKind {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SyncCliResponseConversionKind {
|
||||
ToOpenAIFamilyCli,
|
||||
ToOpenAiResponses,
|
||||
ToClaudeCli,
|
||||
ToGeminiCli,
|
||||
}
|
||||
|
||||
const NON_COMPACT_STANDARD_CANDIDATE_API_FORMATS: &[&str] = &[
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
"openai:cli",
|
||||
"claude:chat",
|
||||
"claude:cli",
|
||||
@@ -55,15 +56,24 @@ pub fn request_candidate_api_format_preference(
|
||||
let client_api_format = client_api_format.trim().to_ascii_lowercase();
|
||||
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
|
||||
|
||||
if client_api_format == "openai:compact" {
|
||||
return (provider_api_format == "openai:compact").then_some((0, 0));
|
||||
if matches!(
|
||||
client_api_format.as_str(),
|
||||
"openai:compact" | "openai:responses:compact"
|
||||
) {
|
||||
return matches!(
|
||||
provider_api_format.as_str(),
|
||||
"openai:compact" | "openai:responses:compact"
|
||||
)
|
||||
.then_some((0, 0));
|
||||
}
|
||||
|
||||
let (client_family, client_kind) =
|
||||
parse_non_compact_standard_api_format(client_api_format.as_str())?;
|
||||
let (provider_family, provider_kind) =
|
||||
parse_non_compact_standard_api_format(provider_api_format.as_str())?;
|
||||
let preference_bucket = if client_family == provider_family && client_kind == provider_kind {
|
||||
let preference_bucket = if canonical_standard_api_format(client_api_format.as_str())
|
||||
== canonical_standard_api_format(provider_api_format.as_str())
|
||||
{
|
||||
0
|
||||
} else if client_kind == provider_kind {
|
||||
1
|
||||
@@ -84,8 +94,11 @@ pub fn request_candidate_api_formats(
|
||||
_require_streaming: bool,
|
||||
) -> Vec<&'static str> {
|
||||
let client_api_format = client_api_format.trim().to_ascii_lowercase();
|
||||
if client_api_format == "openai:compact" {
|
||||
return vec!["openai:compact"];
|
||||
if matches!(
|
||||
client_api_format.as_str(),
|
||||
"openai:compact" | "openai:responses:compact"
|
||||
) {
|
||||
return vec!["openai:responses:compact", "openai:compact"];
|
||||
}
|
||||
if parse_non_compact_standard_api_format(client_api_format.as_str()).is_none() {
|
||||
return Vec::new();
|
||||
@@ -108,18 +121,28 @@ pub fn request_conversion_kind(
|
||||
if client_api_format == provider_api_format {
|
||||
return None;
|
||||
}
|
||||
if normalized_same_standard_api_format(client_api_format.as_str(), provider_api_format.as_str())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if !is_standard_api_format(client_api_format.as_str())
|
||||
|| !is_standard_api_format(provider_api_format.as_str())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if client_api_format == "openai:compact" || provider_api_format == "openai:compact" {
|
||||
if matches!(
|
||||
client_api_format.as_str(),
|
||||
"openai:compact" | "openai:responses:compact"
|
||||
) || matches!(
|
||||
provider_api_format.as_str(),
|
||||
"openai:compact" | "openai:responses:compact"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
match provider_api_format.as_str() {
|
||||
"openai:chat" => Some(RequestConversionKind::ToOpenAIChat),
|
||||
"openai:cli" => Some(RequestConversionKind::ToOpenAIFamilyCli),
|
||||
"openai:responses" | "openai:cli" => Some(RequestConversionKind::ToOpenAiResponses),
|
||||
"claude:chat" | "claude:cli" => Some(RequestConversionKind::ToClaudeStandard),
|
||||
"gemini:chat" | "gemini:cli" => Some(RequestConversionKind::ToGeminiStandard),
|
||||
_ => None,
|
||||
@@ -135,6 +158,10 @@ pub fn sync_chat_response_conversion_kind(
|
||||
if provider_api_format == client_api_format {
|
||||
return None;
|
||||
}
|
||||
if normalized_same_standard_api_format(provider_api_format.as_str(), client_api_format.as_str())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if !is_standard_api_format(provider_api_format.as_str()) {
|
||||
return None;
|
||||
}
|
||||
@@ -156,14 +183,23 @@ pub fn sync_cli_response_conversion_kind(
|
||||
if provider_api_format == client_api_format {
|
||||
return None;
|
||||
}
|
||||
if normalized_same_standard_api_format(provider_api_format.as_str(), client_api_format.as_str())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if !is_standard_api_format(provider_api_format.as_str()) {
|
||||
return None;
|
||||
}
|
||||
if client_api_format != "openai:compact" {
|
||||
if !matches!(
|
||||
client_api_format.as_str(),
|
||||
"openai:compact" | "openai:responses:compact"
|
||||
) {
|
||||
request_conversion_kind(client_api_format.as_str(), provider_api_format.as_str())?;
|
||||
}
|
||||
match client_api_format.as_str() {
|
||||
"openai:cli" | "openai:compact" => Some(SyncCliResponseConversionKind::ToOpenAIFamilyCli),
|
||||
"openai:responses" | "openai:cli" | "openai:compact" | "openai:responses:compact" => {
|
||||
Some(SyncCliResponseConversionKind::ToOpenAiResponses)
|
||||
}
|
||||
"claude:cli" => Some(SyncCliResponseConversionKind::ToClaudeCli),
|
||||
"gemini:cli" => Some(SyncCliResponseConversionKind::ToGeminiCli),
|
||||
_ => None,
|
||||
@@ -277,11 +313,11 @@ pub fn request_conversion_transport_unsupported_reason(
|
||||
.as_str()
|
||||
{
|
||||
"openai:chat" => local_openai_chat_transport_unsupported_reason(transport),
|
||||
"openai:cli" => {
|
||||
local_standard_transport_unsupported_reason_with_network(transport, "openai:cli")
|
||||
}
|
||||
"openai:compact" => {
|
||||
local_standard_transport_unsupported_reason_with_network(transport, "openai:compact")
|
||||
"openai:responses" | "openai:cli" | "openai:responses:compact" | "openai:compact" => {
|
||||
local_standard_transport_unsupported_reason_with_network(
|
||||
transport,
|
||||
transport.endpoint.api_format.trim(),
|
||||
)
|
||||
}
|
||||
"claude:chat" => {
|
||||
local_standard_transport_unsupported_reason_with_network(transport, "claude:chat")
|
||||
@@ -313,9 +349,11 @@ pub fn request_conversion_direct_auth(
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"openai:chat" | "openai:cli" | "openai:compact" => {
|
||||
resolve_local_openai_bearer_auth(transport)
|
||||
}
|
||||
"openai:chat"
|
||||
| "openai:responses"
|
||||
| "openai:cli"
|
||||
| "openai:compact"
|
||||
| "openai:responses:compact" => resolve_local_openai_bearer_auth(transport),
|
||||
"gemini:chat" | "gemini:cli" => {
|
||||
if is_vertex_api_key_transport_context(transport) {
|
||||
resolve_local_vertex_api_key_query_auth(transport)
|
||||
@@ -333,8 +371,10 @@ fn is_standard_api_format(api_format: &str) -> bool {
|
||||
matches!(
|
||||
api_format,
|
||||
"openai:chat"
|
||||
| "openai:responses"
|
||||
| "openai:cli"
|
||||
| "openai:compact"
|
||||
| "openai:responses:compact"
|
||||
| "claude:chat"
|
||||
| "claude:cli"
|
||||
| "gemini:chat"
|
||||
@@ -344,6 +384,9 @@ fn is_standard_api_format(api_format: &str) -> bool {
|
||||
|
||||
fn parse_non_compact_standard_api_format(api_format: &str) -> Option<(&str, &str)> {
|
||||
let (family, kind) = api_format.split_once(':')?;
|
||||
if family == "openai" && kind == "responses" {
|
||||
return Some((family, "cli"));
|
||||
}
|
||||
if !STANDARD_API_FAMILY_ORDER.contains(&family) || !matches!(kind, "chat" | "cli") {
|
||||
return None;
|
||||
}
|
||||
@@ -362,11 +405,31 @@ fn api_data_format_id(api_format: &str) -> Option<&'static str> {
|
||||
"claude:chat" | "claude:cli" => Some("claude"),
|
||||
"gemini:chat" | "gemini:cli" => Some("gemini"),
|
||||
"openai:chat" => Some("openai_chat"),
|
||||
"openai:cli" | "openai:compact" => Some("openai_responses"),
|
||||
"openai:responses" | "openai:cli" | "openai:compact" | "openai:responses:compact" => {
|
||||
Some("openai_responses")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_same_standard_api_format(left: &str, right: &str) -> bool {
|
||||
matches!(
|
||||
(left, right),
|
||||
("openai:responses", "openai:cli")
|
||||
| ("openai:cli", "openai:responses")
|
||||
| ("openai:responses:compact", "openai:compact")
|
||||
| ("openai:compact", "openai:responses:compact")
|
||||
)
|
||||
}
|
||||
|
||||
fn canonical_standard_api_format(api_format: &str) -> &str {
|
||||
match api_format {
|
||||
"openai:cli" => "openai:responses",
|
||||
"openai:compact" => "openai:responses:compact",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint_accepts_client_api_format(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
client_api_format: &str,
|
||||
@@ -437,7 +500,7 @@ mod tests {
|
||||
fn expected_request_conversion_kind(provider_api_format: &str) -> RequestConversionKind {
|
||||
match provider_api_format {
|
||||
"openai:chat" => RequestConversionKind::ToOpenAIChat,
|
||||
"openai:cli" => RequestConversionKind::ToOpenAIFamilyCli,
|
||||
"openai:cli" => RequestConversionKind::ToOpenAiResponses,
|
||||
"claude:chat" | "claude:cli" => RequestConversionKind::ToClaudeStandard,
|
||||
"gemini:chat" | "gemini:cli" => RequestConversionKind::ToGeminiStandard,
|
||||
other => panic!("unexpected provider api format: {other}"),
|
||||
@@ -448,7 +511,7 @@ mod tests {
|
||||
fn request_conversion_registry_supports_bidirectional_standard_matrix() {
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:chat", "openai:cli"),
|
||||
Some(RequestConversionKind::ToOpenAIFamilyCli)
|
||||
Some(RequestConversionKind::ToOpenAiResponses)
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:chat", "claude:cli"),
|
||||
@@ -474,6 +537,14 @@ mod tests {
|
||||
request_conversion_kind("openai:chat", "openai:compact"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:responses", "openai:cli"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:compact", "openai:responses:compact"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("claude:chat", "claude:cli"),
|
||||
Some(RequestConversionKind::ToClaudeStandard)
|
||||
@@ -522,11 +593,11 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("claude:chat", "openai:cli"),
|
||||
Some(SyncCliResponseConversionKind::ToOpenAIFamilyCli)
|
||||
Some(SyncCliResponseConversionKind::ToOpenAiResponses)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("claude:cli", "openai:compact"),
|
||||
Some(SyncCliResponseConversionKind::ToOpenAIFamilyCli)
|
||||
Some(SyncCliResponseConversionKind::ToOpenAiResponses)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("openai:compact", "claude:cli"),
|
||||
@@ -536,6 +607,14 @@ mod tests {
|
||||
sync_cli_response_conversion_kind("gemini:cli", "claude:cli"),
|
||||
Some(SyncCliResponseConversionKind::ToClaudeCli)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("openai:responses", "openai:cli"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("openai:compact", "openai:responses:compact"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -574,7 +653,7 @@ mod tests {
|
||||
);
|
||||
} else {
|
||||
let expected = match client_api_format {
|
||||
"openai:cli" => SyncCliResponseConversionKind::ToOpenAIFamilyCli,
|
||||
"openai:cli" => SyncCliResponseConversionKind::ToOpenAiResponses,
|
||||
"claude:cli" => SyncCliResponseConversionKind::ToClaudeCli,
|
||||
"gemini:cli" => SyncCliResponseConversionKind::ToGeminiCli,
|
||||
other => panic!("unexpected cli client api format: {other}"),
|
||||
@@ -597,6 +676,7 @@ mod tests {
|
||||
"openai:chat",
|
||||
"claude:chat",
|
||||
"gemini:chat",
|
||||
"openai:responses",
|
||||
"openai:cli",
|
||||
"claude:cli",
|
||||
"gemini:cli",
|
||||
@@ -605,6 +685,19 @@ mod tests {
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:cli", false),
|
||||
vec![
|
||||
"openai:responses",
|
||||
"openai:cli",
|
||||
"claude:cli",
|
||||
"gemini:cli",
|
||||
"openai:chat",
|
||||
"claude:chat",
|
||||
"gemini:chat",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:responses", false),
|
||||
vec![
|
||||
"openai:responses",
|
||||
"openai:cli",
|
||||
"claude:cli",
|
||||
"gemini:cli",
|
||||
@@ -617,6 +710,7 @@ mod tests {
|
||||
request_candidate_api_formats("claude:cli", false),
|
||||
vec![
|
||||
"claude:cli",
|
||||
"openai:responses",
|
||||
"openai:cli",
|
||||
"gemini:cli",
|
||||
"claude:chat",
|
||||
@@ -626,7 +720,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:compact", false),
|
||||
vec!["openai:compact"]
|
||||
vec!["openai:responses:compact", "openai:compact"]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -750,9 +844,9 @@ mod tests {
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
api_format: "openai:cli".to_string(),
|
||||
api_format: "openai:responses".to_string(),
|
||||
api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("cli".to_string()),
|
||||
endpoint_kind: Some("responses".to_string()),
|
||||
is_active: true,
|
||||
base_url: "https://right.codes/codex".to_string(),
|
||||
header_rules: None,
|
||||
@@ -772,7 +866,7 @@ mod tests {
|
||||
name: "key".to_string(),
|
||||
auth_type: "bearer".to_string(),
|
||||
is_active: true,
|
||||
api_formats: Some(vec!["openai:cli".to_string()]),
|
||||
api_formats: Some(vec!["openai:responses".to_string()]),
|
||||
allowed_models: None,
|
||||
capabilities: None,
|
||||
rate_multipliers: None,
|
||||
@@ -788,17 +882,17 @@ mod tests {
|
||||
assert!(request_conversion_enabled_for_transport(
|
||||
&transport,
|
||||
"claude:cli",
|
||||
"openai:cli"
|
||||
"openai:responses"
|
||||
));
|
||||
assert!(request_pair_allowed_for_transport(
|
||||
&transport,
|
||||
"claude:cli",
|
||||
"openai:cli"
|
||||
"openai:responses"
|
||||
));
|
||||
assert!(!request_pair_allowed_for_transport(
|
||||
&transport,
|
||||
"gemini:cli",
|
||||
"openai:cli"
|
||||
"openai:responses"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -823,9 +917,9 @@ mod tests {
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
api_format: "openai:cli".to_string(),
|
||||
api_format: "openai:responses".to_string(),
|
||||
api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("cli".to_string()),
|
||||
endpoint_kind: Some("responses".to_string()),
|
||||
is_active: true,
|
||||
base_url: "https://right.codes/codex".to_string(),
|
||||
header_rules: None,
|
||||
@@ -845,7 +939,7 @@ mod tests {
|
||||
name: "key".to_string(),
|
||||
auth_type: "bearer".to_string(),
|
||||
is_active: true,
|
||||
api_formats: Some(vec!["openai:cli".to_string()]),
|
||||
api_formats: Some(vec!["openai:responses".to_string()]),
|
||||
allowed_models: None,
|
||||
capabilities: None,
|
||||
rate_multipliers: None,
|
||||
@@ -861,7 +955,7 @@ mod tests {
|
||||
assert!(!request_conversion_enabled_for_transport(
|
||||
&transport,
|
||||
"claude:cli",
|
||||
"openai:cli"
|
||||
"openai:responses"
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1 @@
|
||||
pub mod from_openai_chat;
|
||||
pub mod to_openai_chat;
|
||||
|
||||
pub use from_openai_chat::{
|
||||
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
|
||||
convert_openai_chat_request_to_openai_cli_request,
|
||||
};
|
||||
pub use to_openai_chat::{
|
||||
extract_openai_text_content, normalize_claude_request_to_openai_chat_request,
|
||||
normalize_gemini_request_to_openai_chat_request,
|
||||
normalize_openai_cli_request_to_openai_chat_request, parse_openai_tool_result_content,
|
||||
};
|
||||
pub use aether_ai_formats::conversion::request::*;
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
mod claude_chat;
|
||||
mod gemini_chat;
|
||||
mod openai_cli;
|
||||
mod shared;
|
||||
|
||||
pub use claude_chat::convert_openai_chat_response_to_claude_chat;
|
||||
pub use gemini_chat::convert_openai_chat_response_to_gemini_chat;
|
||||
pub use openai_cli::convert_openai_chat_response_to_openai_cli;
|
||||
pub use shared::{
|
||||
build_openai_cli_response, build_openai_cli_response_with_content,
|
||||
build_openai_cli_response_with_reasoning, OpenAiCliResponseUsage,
|
||||
};
|
||||
@@ -1,13 +1 @@
|
||||
pub mod from_openai_chat;
|
||||
pub mod to_openai_chat;
|
||||
|
||||
pub use from_openai_chat::{
|
||||
build_openai_cli_response, build_openai_cli_response_with_reasoning,
|
||||
convert_openai_chat_response_to_claude_chat, convert_openai_chat_response_to_gemini_chat,
|
||||
convert_openai_chat_response_to_openai_cli, OpenAiCliResponseUsage,
|
||||
};
|
||||
pub use to_openai_chat::{
|
||||
convert_claude_chat_response_to_openai_chat, convert_claude_cli_response_to_openai_cli,
|
||||
convert_gemini_chat_response_to_openai_chat, convert_gemini_cli_response_to_openai_cli,
|
||||
convert_openai_cli_response_to_openai_chat,
|
||||
};
|
||||
pub use aether_ai_formats::conversion::response::*;
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use super::super::from_openai_chat::convert_openai_chat_response_to_openai_cli;
|
||||
use super::claude_chat::convert_claude_chat_response_to_openai_chat;
|
||||
|
||||
pub fn convert_claude_cli_response_to_openai_cli(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
let canonical = convert_claude_chat_response_to_openai_chat(body_json, report_context)?;
|
||||
convert_openai_chat_response_to_openai_cli(&canonical, report_context, false)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use super::super::from_openai_chat::convert_openai_chat_response_to_openai_cli;
|
||||
use super::gemini_chat::convert_gemini_chat_response_to_openai_chat;
|
||||
|
||||
pub fn convert_gemini_cli_response_to_openai_cli(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
let canonical = convert_gemini_chat_response_to_openai_chat(body_json, report_context)?;
|
||||
convert_openai_chat_response_to_openai_cli(&canonical, report_context, false)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
mod claude_chat;
|
||||
mod claude_cli;
|
||||
mod gemini_chat;
|
||||
mod gemini_cli;
|
||||
mod openai_cli;
|
||||
mod shared;
|
||||
|
||||
pub use claude_chat::convert_claude_chat_response_to_openai_chat;
|
||||
pub use claude_cli::convert_claude_cli_response_to_openai_cli;
|
||||
pub use gemini_chat::convert_gemini_chat_response_to_openai_chat;
|
||||
pub use gemini_cli::convert_gemini_cli_response_to_openai_cli;
|
||||
pub use openai_cli::convert_openai_cli_response_to_openai_chat;
|
||||
@@ -47,6 +47,15 @@ impl ClaudeProviderState {
|
||||
self.started = true;
|
||||
}
|
||||
|
||||
fn unknown_frame(&self, report_context: &Value, payload: Value) -> CanonicalStreamFrame {
|
||||
let (id, model) = self.identity(report_context);
|
||||
CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::UnknownEvent(payload),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
@@ -181,7 +190,11 @@ impl ClaudeProviderState {
|
||||
event: CanonicalStreamEvent::ReasoningSignature(signature.to_string()),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
_ => {
|
||||
out.push(
|
||||
self.unknown_frame(report_context, Value::Object(event_object.clone())),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
"content_block_start" => {
|
||||
@@ -245,6 +258,9 @@ impl ClaudeProviderState {
|
||||
return Ok(out);
|
||||
}
|
||||
if block_type != "tool_use" {
|
||||
out.push(
|
||||
self.unknown_frame(report_context, Value::Object(event_object.clone())),
|
||||
);
|
||||
return Ok(out);
|
||||
}
|
||||
self.ensure_started(report_context, &mut out);
|
||||
@@ -312,7 +328,10 @@ impl ClaudeProviderState {
|
||||
});
|
||||
self.finished = true;
|
||||
}
|
||||
_ => {}
|
||||
"content_block_stop" | "message_stop" | "ping" => {}
|
||||
_ => {
|
||||
out.push(self.unknown_frame(report_context, value.clone()));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -524,6 +543,43 @@ impl ClaudeClientEmitter {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn emit_tool_result_block(
|
||||
&mut self,
|
||||
index: usize,
|
||||
tool_use_id: String,
|
||||
name: Option<String>,
|
||||
content: String,
|
||||
) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(self.close_open_block()?);
|
||||
let block_index = self.next_block_index;
|
||||
self.next_block_index += 1;
|
||||
let mut content_block = Map::new();
|
||||
content_block.insert("type".to_string(), Value::String("tool_result".to_string()));
|
||||
content_block.insert("tool_use_id".to_string(), Value::String(tool_use_id));
|
||||
if let Some(name) = name.filter(|value| !value.trim().is_empty()) {
|
||||
content_block.insert("name".to_string(), Value::String(name));
|
||||
}
|
||||
content_block.insert("content".to_string(), Value::String(content));
|
||||
out.extend(encode_json_sse(
|
||||
Some("content_block_start"),
|
||||
&json!({
|
||||
"type": "content_block_start",
|
||||
"index": block_index,
|
||||
"content_block": Value::Object(content_block),
|
||||
}),
|
||||
)?);
|
||||
out.extend(encode_json_sse(
|
||||
Some("content_block_stop"),
|
||||
&json!({
|
||||
"type": "content_block_stop",
|
||||
"index": block_index,
|
||||
"canonical_index": index,
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
self.update_identity(&frame);
|
||||
match frame.event {
|
||||
@@ -632,6 +688,13 @@ impl ClaudeClientEmitter {
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ToolResultDelta {
|
||||
index,
|
||||
tool_use_id,
|
||||
name,
|
||||
content,
|
||||
} => self.emit_tool_result_block(index, tool_use_id, name, content),
|
||||
CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()),
|
||||
CanonicalStreamEvent::Finish {
|
||||
finish_reason,
|
||||
usage,
|
||||
@@ -809,6 +872,9 @@ fn merge_claude_usage(mut current: CanonicalUsage, next: CanonicalUsage) -> Cano
|
||||
if next.cache_read_tokens > 0 {
|
||||
current.cache_read_tokens = next.cache_read_tokens;
|
||||
}
|
||||
if next.reasoning_tokens > 0 {
|
||||
current.reasoning_tokens = next.reasoning_tokens;
|
||||
}
|
||||
current.total_tokens = current
|
||||
.input_tokens
|
||||
.saturating_add(current.output_tokens)
|
||||
@@ -917,6 +983,29 @@ mod tests {
|
||||
format!("data: {}\n", value).into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_provider_state_emits_unknown_events_for_unknown_stream_types() {
|
||||
let mut state = ClaudeProviderState::default();
|
||||
let report_context = json!({});
|
||||
let frames = state
|
||||
.push_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"type": "future_event",
|
||||
"payload": {
|
||||
"kept": true
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect("unknown stream event should parse");
|
||||
|
||||
assert!(frames.iter().any(|frame| matches!(
|
||||
frame.event,
|
||||
CanonicalStreamEvent::UnknownEvent(ref payload)
|
||||
if payload.get("type").and_then(Value::as_str) == Some("future_event")
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_provider_state_parses_thinking_deltas() {
|
||||
let mut state = ClaudeProviderState::default();
|
||||
@@ -1146,4 +1235,28 @@ mod tests {
|
||||
assert!(sse.contains("\"data\":\"iVBORw0KGgo=\""));
|
||||
assert!(sse.contains("event: content_block_stop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_client_emitter_emits_tool_result_blocks() {
|
||||
let mut emitter = ClaudeClientEmitter::default();
|
||||
let bytes = emitter
|
||||
.emit(CanonicalStreamFrame {
|
||||
id: "msg_tool_result_123".to_string(),
|
||||
model: "claude-sonnet-4-5".to_string(),
|
||||
event: CanonicalStreamEvent::ToolResultDelta {
|
||||
index: 2,
|
||||
tool_use_id: "toolu_1".to_string(),
|
||||
name: Some("lookup".to_string()),
|
||||
content: "{\"ok\":true}".to_string(),
|
||||
},
|
||||
})
|
||||
.expect("tool result should encode");
|
||||
|
||||
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
||||
assert!(sse.contains("\"type\":\"tool_result\""));
|
||||
assert!(sse.contains("\"tool_use_id\":\"toolu_1\""));
|
||||
assert!(sse.contains("\"name\":\"lookup\""));
|
||||
assert!(sse.contains("\"content\":\"{\\\"ok\\\":true}\""));
|
||||
assert!(sse.contains("\"canonical_index\":2"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ struct GeminiProviderToolState {
|
||||
started_emitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct GeminiProviderToolResultState {
|
||||
content: String,
|
||||
emitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GeminiProviderState {
|
||||
response_id: Option<String>,
|
||||
@@ -26,6 +32,7 @@ pub struct GeminiProviderState {
|
||||
reasoning_signatures: BTreeMap<usize, String>,
|
||||
content_parts: BTreeMap<usize, CanonicalContentPart>,
|
||||
tool_calls: BTreeMap<usize, GeminiProviderToolState>,
|
||||
tool_results: BTreeMap<usize, GeminiProviderToolResultState>,
|
||||
}
|
||||
|
||||
impl GeminiProviderState {
|
||||
@@ -51,6 +58,15 @@ impl GeminiProviderState {
|
||||
self.started = true;
|
||||
}
|
||||
|
||||
fn unknown_frame(&self, report_context: &Value, payload: Value) -> CanonicalStreamFrame {
|
||||
let (id, model) = self.identity(report_context);
|
||||
CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::UnknownEvent(payload),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
@@ -79,6 +95,7 @@ impl GeminiProviderState {
|
||||
|
||||
let mut out = Vec::new();
|
||||
let Some(candidates) = event_object.get("candidates").and_then(Value::as_array) else {
|
||||
out.push(self.unknown_frame(report_context, value.clone()));
|
||||
return Ok(out);
|
||||
};
|
||||
|
||||
@@ -154,6 +171,53 @@ impl GeminiProviderState {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(function_response) = part_object
|
||||
.get("functionResponse")
|
||||
.or_else(|| part_object.get("function_response"))
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
let tool_use_id = function_response
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
||||
let name = function_response
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let content = gemini_function_response_content(
|
||||
function_response.get("response").unwrap_or(&Value::Null),
|
||||
);
|
||||
let state = self.tool_results.entry(index).or_default();
|
||||
let delta = if !state.emitted {
|
||||
content.clone()
|
||||
} else if content.starts_with(&state.content) {
|
||||
content[state.content.len()..].to_string()
|
||||
} else if state.content == content {
|
||||
String::new()
|
||||
} else {
|
||||
content.clone()
|
||||
};
|
||||
if !delta.is_empty() || !state.emitted {
|
||||
state.emitted = true;
|
||||
state.content.push_str(&delta);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolResultDelta {
|
||||
index,
|
||||
tool_use_id,
|
||||
name,
|
||||
content: delta,
|
||||
},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(function_call) =
|
||||
part_object.get("functionCall").and_then(Value::as_object)
|
||||
else {
|
||||
@@ -172,6 +236,10 @@ impl GeminiProviderState {
|
||||
event: CanonicalStreamEvent::ContentPart(content_part),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
out.push(
|
||||
self.unknown_frame(report_context, Value::Object(part_object.clone())),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
@@ -336,14 +404,27 @@ impl GeminiClientEmitter {
|
||||
Value::Array(vec![Value::Object(candidate)]),
|
||||
);
|
||||
if let Some(usage) = usage {
|
||||
response.insert(
|
||||
"usageMetadata".to_string(),
|
||||
json!({
|
||||
"promptTokenCount": usage.input_tokens,
|
||||
"candidatesTokenCount": usage.output_tokens,
|
||||
"totalTokenCount": usage.total_tokens,
|
||||
}),
|
||||
let visible_output_tokens = usage.output_tokens.saturating_sub(usage.reasoning_tokens);
|
||||
let mut usage_metadata = Map::new();
|
||||
usage_metadata.insert(
|
||||
"promptTokenCount".to_string(),
|
||||
Value::from(usage.input_tokens),
|
||||
);
|
||||
usage_metadata.insert(
|
||||
"candidatesTokenCount".to_string(),
|
||||
Value::from(visible_output_tokens),
|
||||
);
|
||||
usage_metadata.insert(
|
||||
"totalTokenCount".to_string(),
|
||||
Value::from(usage.total_tokens),
|
||||
);
|
||||
if usage.reasoning_tokens > 0 {
|
||||
usage_metadata.insert(
|
||||
"thoughtsTokenCount".to_string(),
|
||||
Value::from(usage.reasoning_tokens),
|
||||
);
|
||||
}
|
||||
response.insert("usageMetadata".to_string(), Value::Object(usage_metadata));
|
||||
}
|
||||
encode_json_sse(None, &Value::Object(response))
|
||||
}
|
||||
@@ -447,6 +528,17 @@ impl GeminiClientEmitter {
|
||||
};
|
||||
self.emit_candidate(vec![part], None, None)
|
||||
}
|
||||
CanonicalStreamEvent::ToolResultDelta {
|
||||
tool_use_id,
|
||||
name,
|
||||
content,
|
||||
..
|
||||
} => self.emit_candidate(
|
||||
vec![gemini_function_response_part(tool_use_id, name, content)],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()),
|
||||
CanonicalStreamEvent::Finish {
|
||||
finish_reason,
|
||||
usage,
|
||||
@@ -472,6 +564,45 @@ impl GeminiClientEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
fn gemini_function_response_part(
|
||||
tool_use_id: String,
|
||||
name: Option<String>,
|
||||
content: String,
|
||||
) -> Value {
|
||||
json!({
|
||||
"functionResponse": {
|
||||
"id": tool_use_id,
|
||||
"name": name
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
"response": gemini_function_response_value(&content),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn gemini_function_response_value(content: &str) -> Value {
|
||||
if content.trim().is_empty() {
|
||||
return Value::Object(Map::new());
|
||||
}
|
||||
match serde_json::from_str::<Value>(content) {
|
||||
Ok(Value::Object(map)) => Value::Object(map),
|
||||
Ok(value) => json!({ "output": value }),
|
||||
Err(_) => json!({ "output": content }),
|
||||
}
|
||||
}
|
||||
|
||||
fn gemini_function_response_content(response: &Value) -> String {
|
||||
match response {
|
||||
Value::Object(object) => object
|
||||
.get("result")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Value::Object(object.clone()))
|
||||
.to_string(),
|
||||
Value::Null => String::new(),
|
||||
value => value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_gemini_part_as_text(part: &Map<String, Value>) -> Option<String> {
|
||||
if let Some(text) = part.get("text").and_then(Value::as_str) {
|
||||
return Some(text.to_string());
|
||||
@@ -694,6 +825,39 @@ mod tests {
|
||||
format!("data: {}\n", value).into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_provider_state_emits_unknown_events_for_unknown_parts() {
|
||||
let mut state = GeminiProviderState::default();
|
||||
let report_context = json!({});
|
||||
let frames = state
|
||||
.push_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"responseId": "resp_unknown_123",
|
||||
"modelVersion": "gemini-2.5-pro",
|
||||
"candidates": [{
|
||||
"index": 0,
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"futurePart": {
|
||||
"kept": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}]
|
||||
})),
|
||||
)
|
||||
.expect("unknown part should parse");
|
||||
|
||||
assert!(frames.iter().any(|frame| matches!(
|
||||
frame.event,
|
||||
CanonicalStreamEvent::UnknownEvent(ref payload)
|
||||
if payload.get("futurePart").is_some()
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_provider_state_parses_thoughts_code_and_content_filter_finish() {
|
||||
let mut state = GeminiProviderState::default();
|
||||
@@ -717,7 +881,8 @@ mod tests {
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 1,
|
||||
"candidatesTokenCount": 2,
|
||||
"totalTokenCount": 3
|
||||
"thoughtsTokenCount": 4,
|
||||
"totalTokenCount": 7
|
||||
}
|
||||
})),
|
||||
)
|
||||
@@ -741,6 +906,56 @@ mod tests {
|
||||
CanonicalStreamEvent::Finish { ref finish_reason, .. }
|
||||
if finish_reason.as_deref() == Some("content_filter")
|
||||
)));
|
||||
assert!(frames.iter().any(|frame| matches!(
|
||||
frame.event,
|
||||
CanonicalStreamEvent::Finish {
|
||||
usage: Some(CanonicalUsage {
|
||||
input_tokens: 1,
|
||||
output_tokens: 6,
|
||||
reasoning_tokens: 4,
|
||||
total_tokens: 7,
|
||||
..
|
||||
}),
|
||||
..
|
||||
}
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_provider_state_parses_function_response_as_tool_result() {
|
||||
let mut state = GeminiProviderState::default();
|
||||
let report_context = json!({});
|
||||
let frames = state
|
||||
.push_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"responseId": "resp_tool_result_123",
|
||||
"modelVersion": "gemini-2.5-pro",
|
||||
"candidates": [{
|
||||
"index": 0,
|
||||
"content": {
|
||||
"parts": [{
|
||||
"functionResponse": {
|
||||
"id": "call_123",
|
||||
"name": "lookup",
|
||||
"response": {"ok": true}
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
})),
|
||||
)
|
||||
.expect("function response should parse");
|
||||
|
||||
assert!(frames.iter().any(|frame| matches!(
|
||||
frame.event,
|
||||
CanonicalStreamEvent::ToolResultDelta {
|
||||
index: 0,
|
||||
ref tool_use_id,
|
||||
name: Some(ref name),
|
||||
ref content,
|
||||
} if tool_use_id == "call_123" && name == "lookup" && content == "{\"ok\":true}"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -771,8 +986,9 @@ mod tests {
|
||||
finish_reason: Some("stop".to_string()),
|
||||
usage: Some(CanonicalUsage {
|
||||
input_tokens: 1,
|
||||
output_tokens: 2,
|
||||
total_tokens: 3,
|
||||
output_tokens: 3,
|
||||
reasoning_tokens: 1,
|
||||
total_tokens: 4,
|
||||
..CanonicalUsage::default()
|
||||
}),
|
||||
},
|
||||
@@ -783,6 +999,8 @@ mod tests {
|
||||
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
||||
assert!(sse.contains("\"thought\":true"));
|
||||
assert!(sse.contains("\"thoughtSignature\":\"sig_123\""));
|
||||
assert!(sse.contains("\"thoughtsTokenCount\":1"));
|
||||
assert!(sse.contains("\"candidatesTokenCount\":2"));
|
||||
assert!(sse.contains("\"finishReason\":\"STOP\""));
|
||||
}
|
||||
|
||||
@@ -833,4 +1051,27 @@ mod tests {
|
||||
sse.contains("\"inlineData\":{\"mimeType\":\"image/png\",\"data\":\"iVBORw0KGgo=\"}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_client_emitter_emits_function_response_for_tool_results() {
|
||||
let mut emitter = GeminiClientEmitter::default();
|
||||
let bytes = emitter
|
||||
.emit(CanonicalStreamFrame {
|
||||
id: "resp_tool_result_123".to_string(),
|
||||
model: "gemini-2.5-pro".to_string(),
|
||||
event: CanonicalStreamEvent::ToolResultDelta {
|
||||
index: 2,
|
||||
tool_use_id: "call_123".to_string(),
|
||||
name: Some("lookup".to_string()),
|
||||
content: "{\"ok\":true}".to_string(),
|
||||
},
|
||||
})
|
||||
.expect("tool result should encode");
|
||||
|
||||
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
||||
assert!(sse.contains("\"functionResponse\""));
|
||||
assert!(sse.contains("\"id\":\"call_123\""));
|
||||
assert!(sse.contains("\"name\":\"lookup\""));
|
||||
assert!(sse.contains("\"response\":{\"ok\":true}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::conversion::response::OpenAiCliResponseUsage;
|
||||
use crate::finalize::common::build_generated_tool_call_id;
|
||||
use crate::finalize::sse::{encode_done_sse, encode_json_sse};
|
||||
use crate::finalize::standard::stream_core::common::*;
|
||||
@@ -26,7 +25,7 @@ pub struct OpenAIChatProviderState {
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct OpenAICliProviderToolState {
|
||||
struct OpenAIResponsesProviderToolState {
|
||||
call_id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
@@ -34,14 +33,21 @@ struct OpenAICliProviderToolState {
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OpenAICliProviderState {
|
||||
struct OpenAIResponsesProviderToolResultState {
|
||||
content: String,
|
||||
emitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OpenAIResponsesProviderState {
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
text: String,
|
||||
reasoning: String,
|
||||
tool_calls: BTreeMap<usize, OpenAICliProviderToolState>,
|
||||
tool_calls: BTreeMap<usize, OpenAIResponsesProviderToolState>,
|
||||
tool_results: BTreeMap<usize, OpenAIResponsesProviderToolResultState>,
|
||||
tool_index_by_key: BTreeMap<String, usize>,
|
||||
last_tool_index: Option<usize>,
|
||||
}
|
||||
@@ -86,6 +92,15 @@ impl OpenAIChatProviderState {
|
||||
self.started = true;
|
||||
}
|
||||
|
||||
fn unknown_frame(&self, report_context: &Value, payload: Value) -> CanonicalStreamFrame {
|
||||
let (id, model) = self.identity(report_context);
|
||||
CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::UnknownEvent(payload),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
@@ -122,6 +137,13 @@ impl OpenAIChatProviderState {
|
||||
},
|
||||
});
|
||||
self.finished = true;
|
||||
} else if chunk_object.contains_key("choices")
|
||||
|| chunk_object
|
||||
.get("object")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|object| object.contains("chat.completion"))
|
||||
{
|
||||
out.push(self.unknown_frame(report_context, value.clone()));
|
||||
}
|
||||
return Ok(out);
|
||||
};
|
||||
@@ -146,8 +168,10 @@ impl OpenAIChatProviderState {
|
||||
}
|
||||
for chunk_choice in chunk_choices {
|
||||
let Some(choice_object) = chunk_choice.as_object() else {
|
||||
out.push(self.unknown_frame(report_context, chunk_choice.clone()));
|
||||
continue;
|
||||
};
|
||||
let finish_reason_key_present = choice_object.contains_key("finish_reason");
|
||||
let Some(delta) = choice_object.get("delta").and_then(Value::as_object) else {
|
||||
if let Some(finish_reason) = normalize_openai_finish_reason(
|
||||
choice_object.get("finish_reason").and_then(Value::as_str),
|
||||
@@ -167,15 +191,24 @@ impl OpenAIChatProviderState {
|
||||
} else {
|
||||
self.pending_finish_reason = Some(finish_reason);
|
||||
}
|
||||
} else if !finish_reason_key_present {
|
||||
out.push(
|
||||
self.unknown_frame(report_context, Value::Object(choice_object.clone())),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut recognized_delta = false;
|
||||
if delta.get("role").and_then(Value::as_str) == Some("assistant") {
|
||||
recognized_delta = true;
|
||||
self.ensure_started(report_context, &mut out);
|
||||
} else if delta.contains_key("role") {
|
||||
recognized_delta = true;
|
||||
}
|
||||
|
||||
if let Some(content) = delta.get("content").and_then(Value::as_str) {
|
||||
recognized_delta = true;
|
||||
if !content.is_empty() {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
@@ -185,9 +218,12 @@ impl OpenAIChatProviderState {
|
||||
event: CanonicalStreamEvent::TextDelta(content.to_string()),
|
||||
});
|
||||
}
|
||||
} else if delta.contains_key("content") {
|
||||
recognized_delta = true;
|
||||
}
|
||||
if let Some(reasoning_content) = delta.get("reasoning_content").and_then(Value::as_str)
|
||||
{
|
||||
recognized_delta = true;
|
||||
if !reasoning_content.is_empty() {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
@@ -197,9 +233,12 @@ impl OpenAIChatProviderState {
|
||||
event: CanonicalStreamEvent::ReasoningDelta(reasoning_content.to_string()),
|
||||
});
|
||||
}
|
||||
} else if delta.contains_key("reasoning_content") {
|
||||
recognized_delta = true;
|
||||
}
|
||||
|
||||
if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
|
||||
recognized_delta = true;
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
for tool_call in tool_calls {
|
||||
@@ -270,11 +309,14 @@ impl OpenAIChatProviderState {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if delta.contains_key("tool_calls") {
|
||||
recognized_delta = true;
|
||||
}
|
||||
|
||||
if let Some(finish_reason) = normalize_openai_finish_reason(
|
||||
choice_object.get("finish_reason").and_then(Value::as_str),
|
||||
) {
|
||||
recognized_delta = true;
|
||||
if let Some(usage) = Self::finish_usage(chunk_object.get("usage")) {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
@@ -291,6 +333,9 @@ impl OpenAIChatProviderState {
|
||||
self.pending_finish_reason = Some(finish_reason);
|
||||
}
|
||||
}
|
||||
if !recognized_delta && !finish_reason_key_present {
|
||||
out.push(self.unknown_frame(report_context, Value::Object(choice_object.clone())));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
@@ -316,7 +361,7 @@ impl OpenAIChatProviderState {
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenAICliProviderState {
|
||||
impl OpenAIResponsesProviderState {
|
||||
fn identity(&self, report_context: &Value) -> (String, String) {
|
||||
resolve_identity(
|
||||
self.response_id.as_deref(),
|
||||
@@ -339,6 +384,15 @@ impl OpenAICliProviderState {
|
||||
self.started = true;
|
||||
}
|
||||
|
||||
fn unknown_frame(&self, report_context: &Value, payload: Value) -> CanonicalStreamFrame {
|
||||
let (id, model) = self.identity(report_context);
|
||||
CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::UnknownEvent(payload),
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_index_for_key(&mut self, key: Option<String>, output_index: Option<usize>) -> usize {
|
||||
if let Some(output_index) = output_index {
|
||||
if let Some(key) = key.as_ref() {
|
||||
@@ -491,6 +545,79 @@ impl OpenAICliProviderState {
|
||||
});
|
||||
}
|
||||
|
||||
fn emit_missing_tool_result(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
out: &mut Vec<CanonicalStreamFrame>,
|
||||
index: usize,
|
||||
tool_use_id: String,
|
||||
name: Option<String>,
|
||||
content: &str,
|
||||
) {
|
||||
self.ensure_started(report_context, out);
|
||||
let state = self.tool_results.entry(index).or_default();
|
||||
let missing = if !state.emitted {
|
||||
content.to_string()
|
||||
} else if content.starts_with(&state.content) {
|
||||
content[state.content.len()..].to_string()
|
||||
} else if state.content == content {
|
||||
String::new()
|
||||
} else {
|
||||
content.to_string()
|
||||
};
|
||||
if missing.is_empty() && state.emitted {
|
||||
return;
|
||||
}
|
||||
state.emitted = true;
|
||||
state.content.push_str(&missing);
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::ToolResultDelta {
|
||||
index,
|
||||
tool_use_id,
|
||||
name,
|
||||
content: missing,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fn emit_tool_result_item(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
out: &mut Vec<CanonicalStreamFrame>,
|
||||
item: &Map<String, Value>,
|
||||
output_index: Option<usize>,
|
||||
) {
|
||||
if item.get("type").and_then(Value::as_str) != Some("function_call_output") {
|
||||
return;
|
||||
}
|
||||
let tool_use_id = item
|
||||
.get("call_id")
|
||||
.or_else(|| item.get("tool_call_id"))
|
||||
.or_else(|| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("call_auto_0")
|
||||
.to_string();
|
||||
let index = self.tool_index_for_key(
|
||||
Some(format!("function_call_output:{tool_use_id}")),
|
||||
output_index,
|
||||
);
|
||||
let content = openai_tool_result_content_from_value(
|
||||
item.get("output")
|
||||
.or_else(|| item.get("content"))
|
||||
.or_else(|| item.get("delta")),
|
||||
);
|
||||
let name = item
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
self.emit_missing_tool_result(report_context, out, index, tool_use_id, name, &content);
|
||||
}
|
||||
|
||||
fn emit_message_item(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
@@ -684,13 +811,18 @@ impl OpenAICliProviderState {
|
||||
"function_call" => {
|
||||
self.emit_tool_call_item(report_context, &mut out, item, output_index);
|
||||
}
|
||||
"function_call_output" => {
|
||||
self.emit_tool_result_item(report_context, &mut out, item, output_index);
|
||||
}
|
||||
"message" => {
|
||||
self.emit_message_item(report_context, &mut out, item);
|
||||
}
|
||||
"reasoning" => {
|
||||
self.emit_reasoning_item(report_context, &mut out, item);
|
||||
}
|
||||
_ => {}
|
||||
_ => {
|
||||
out.push(self.unknown_frame(report_context, Value::Object(item.clone())));
|
||||
}
|
||||
}
|
||||
}
|
||||
"response.function_call_arguments.delta" => {
|
||||
@@ -842,6 +974,44 @@ impl OpenAICliProviderState {
|
||||
});
|
||||
}
|
||||
}
|
||||
"response.function_call_output.delta" | "response.function_call_output.done" => {
|
||||
let tool_use_id = value
|
||||
.get("call_id")
|
||||
.or_else(|| value.get("tool_call_id"))
|
||||
.or_else(|| value.get("item_id"))
|
||||
.or_else(|| value.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("call_auto_0")
|
||||
.to_string();
|
||||
let output_index = value
|
||||
.get("output_index")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as usize);
|
||||
let index = self.tool_index_for_key(
|
||||
Some(format!("function_call_output:{tool_use_id}")),
|
||||
output_index,
|
||||
);
|
||||
let content = openai_tool_result_content_from_value(
|
||||
value
|
||||
.get("delta")
|
||||
.or_else(|| value.get("output"))
|
||||
.or_else(|| value.get("content")),
|
||||
);
|
||||
let name = value
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
self.emit_missing_tool_result(
|
||||
report_context,
|
||||
&mut out,
|
||||
index,
|
||||
tool_use_id,
|
||||
name,
|
||||
&content,
|
||||
);
|
||||
}
|
||||
"response.output_item.done" => {
|
||||
let Some(item) = value.get("item").and_then(Value::as_object) else {
|
||||
return Ok(out);
|
||||
@@ -854,13 +1024,18 @@ impl OpenAICliProviderState {
|
||||
"function_call" => {
|
||||
self.emit_tool_call_item(report_context, &mut out, item, output_index);
|
||||
}
|
||||
"function_call_output" => {
|
||||
self.emit_tool_result_item(report_context, &mut out, item, output_index);
|
||||
}
|
||||
"message" => {
|
||||
self.emit_message_item(report_context, &mut out, item);
|
||||
}
|
||||
"reasoning" => {
|
||||
self.emit_reasoning_item(report_context, &mut out, item);
|
||||
}
|
||||
_ => {}
|
||||
_ => {
|
||||
out.push(self.unknown_frame(report_context, Value::Object(item.clone())));
|
||||
}
|
||||
}
|
||||
}
|
||||
"response.completed" => {
|
||||
@@ -870,11 +1045,12 @@ impl OpenAICliProviderState {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
|
||||
for raw_item in response
|
||||
for (output_index, raw_item) in response
|
||||
.get("output")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
{
|
||||
let Some(item) = raw_item.as_object() else {
|
||||
continue;
|
||||
@@ -884,12 +1060,29 @@ impl OpenAICliProviderState {
|
||||
self.emit_message_item(report_context, &mut out, item);
|
||||
}
|
||||
"function_call" => {
|
||||
self.emit_tool_call_item(report_context, &mut out, item, None);
|
||||
self.emit_tool_call_item(
|
||||
report_context,
|
||||
&mut out,
|
||||
item,
|
||||
Some(output_index),
|
||||
);
|
||||
}
|
||||
"function_call_output" => {
|
||||
self.emit_tool_result_item(
|
||||
report_context,
|
||||
&mut out,
|
||||
item,
|
||||
Some(output_index),
|
||||
);
|
||||
}
|
||||
"reasoning" => {
|
||||
self.emit_reasoning_item(report_context, &mut out, item);
|
||||
}
|
||||
_ => {}
|
||||
_ => {
|
||||
out.push(
|
||||
self.unknown_frame(report_context, Value::Object(item.clone())),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -908,7 +1101,9 @@ impl OpenAICliProviderState {
|
||||
});
|
||||
self.finished = true;
|
||||
}
|
||||
_ => {}
|
||||
_ => {
|
||||
out.push(self.unknown_frame(report_context, value.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
@@ -948,15 +1143,24 @@ pub struct OpenAIChatClientEmitter {
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct OpenAICliClientToolState {
|
||||
struct OpenAIResponsesClientToolState {
|
||||
call_id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
output_index: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct OpenAIResponsesClientToolResultState {
|
||||
tool_use_id: String,
|
||||
name: Option<String>,
|
||||
content: String,
|
||||
output_index: Option<usize>,
|
||||
item_started: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OpenAICliClientEmitter {
|
||||
pub struct OpenAIResponsesClientEmitter {
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
message_item_id: Option<String>,
|
||||
@@ -973,7 +1177,8 @@ pub struct OpenAICliClientEmitter {
|
||||
message_output_index: Option<usize>,
|
||||
text: String,
|
||||
reasoning: String,
|
||||
tool_calls: BTreeMap<usize, OpenAICliClientToolState>,
|
||||
tool_calls: BTreeMap<usize, OpenAIResponsesClientToolState>,
|
||||
tool_results: BTreeMap<usize, OpenAIResponsesClientToolResultState>,
|
||||
}
|
||||
|
||||
impl OpenAIChatClientEmitter {
|
||||
@@ -1111,6 +1316,38 @@ impl OpenAIChatClientEmitter {
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ToolResultDelta {
|
||||
tool_use_id,
|
||||
name,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
let mut out = self.ensure_started()?;
|
||||
let mut delta = Map::new();
|
||||
delta.insert("role".to_string(), Value::String("tool".to_string()));
|
||||
delta.insert("tool_call_id".to_string(), Value::String(tool_use_id));
|
||||
if let Some(name) = name.filter(|value| !value.trim().is_empty()) {
|
||||
delta.insert("name".to_string(), Value::String(name));
|
||||
}
|
||||
delta.insert("content".to_string(), Value::String(content));
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&json!({
|
||||
"id": self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
"object": "chat.completion.chunk",
|
||||
"model": self.model.as_deref().unwrap_or("unknown"),
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": Value::Object(delta),
|
||||
"finish_reason": Value::Null
|
||||
}]
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()),
|
||||
CanonicalStreamEvent::Finish {
|
||||
finish_reason,
|
||||
usage,
|
||||
@@ -1140,6 +1377,7 @@ impl OpenAIChatClientEmitter {
|
||||
usage.input_tokens,
|
||||
usage.output_tokens,
|
||||
usage.total_tokens,
|
||||
usage.reasoning_tokens,
|
||||
),
|
||||
)?);
|
||||
}
|
||||
@@ -1171,7 +1409,7 @@ impl OpenAIChatClientEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenAICliClientEmitter {
|
||||
impl OpenAIResponsesClientEmitter {
|
||||
fn response_id(&self) -> &str {
|
||||
self.response_id.as_deref().unwrap_or("resp-local-stream")
|
||||
}
|
||||
@@ -1299,6 +1537,19 @@ impl OpenAICliClientEmitter {
|
||||
output_index
|
||||
}
|
||||
|
||||
fn ensure_tool_result_output_index(&mut self, index: usize) -> usize {
|
||||
if let Some(output_index) = self
|
||||
.tool_results
|
||||
.get(&index)
|
||||
.and_then(|state| state.output_index)
|
||||
{
|
||||
return output_index;
|
||||
}
|
||||
let output_index = self.allocate_output_index();
|
||||
self.tool_results.entry(index).or_default().output_index = Some(output_index);
|
||||
output_index
|
||||
}
|
||||
|
||||
fn ensure_reasoning_item_started(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
let mut out = self.ensure_started()?;
|
||||
let output_index = self.ensure_reasoning_output_index();
|
||||
@@ -1539,7 +1790,42 @@ impl OpenAICliClientEmitter {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn completed_response(&self, usage: OpenAiCliResponseUsage) -> Value {
|
||||
fn finish_tool_result_items(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
let mut out = Vec::new();
|
||||
let indices = self.tool_results.keys().copied().collect::<Vec<_>>();
|
||||
for index in indices {
|
||||
let output_index = self.ensure_tool_result_output_index(index);
|
||||
let state = self.tool_results.get(&index).cloned().unwrap_or_default();
|
||||
let item_id = if state.tool_use_id.is_empty() {
|
||||
build_generated_tool_call_id(index)
|
||||
} else {
|
||||
state.tool_use_id.clone()
|
||||
};
|
||||
let mut item = Map::new();
|
||||
item.insert(
|
||||
"type".to_string(),
|
||||
Value::String("function_call_output".to_string()),
|
||||
);
|
||||
item.insert("id".to_string(), Value::String(format!("{item_id}_output")));
|
||||
item.insert("call_id".to_string(), Value::String(item_id));
|
||||
if let Some(name) = state.name.filter(|value| !value.trim().is_empty()) {
|
||||
item.insert("name".to_string(), Value::String(name));
|
||||
}
|
||||
item.insert("output".to_string(), Value::String(state.content));
|
||||
out.extend(self.encode_response_event(
|
||||
"response.output_item.done",
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"response_id": self.response_id(),
|
||||
"output_index": output_index,
|
||||
"item": Value::Object(item),
|
||||
}),
|
||||
)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn completed_response(&self, usage: CanonicalUsage) -> Value {
|
||||
let mut ordered_output = Vec::new();
|
||||
if !self.reasoning.trim().is_empty() {
|
||||
ordered_output.push((
|
||||
@@ -1598,8 +1884,48 @@ impl OpenAICliClientEmitter {
|
||||
));
|
||||
}
|
||||
}
|
||||
for (index, state) in &self.tool_results {
|
||||
if let Some(output_index) = state.output_index {
|
||||
let item_id = if state.tool_use_id.is_empty() {
|
||||
build_generated_tool_call_id(*index)
|
||||
} else {
|
||||
state.tool_use_id.clone()
|
||||
};
|
||||
let mut item = Map::new();
|
||||
item.insert(
|
||||
"type".to_string(),
|
||||
Value::String("function_call_output".to_string()),
|
||||
);
|
||||
item.insert("id".to_string(), Value::String(format!("{item_id}_output")));
|
||||
item.insert("call_id".to_string(), Value::String(item_id));
|
||||
if let Some(name) = state
|
||||
.name
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.cloned()
|
||||
{
|
||||
item.insert("name".to_string(), Value::String(name));
|
||||
}
|
||||
item.insert("output".to_string(), Value::String(state.content.clone()));
|
||||
ordered_output.push((output_index, Value::Object(item)));
|
||||
}
|
||||
}
|
||||
ordered_output.sort_by_key(|(output_index, _)| *output_index);
|
||||
|
||||
let mut usage_payload = Map::new();
|
||||
usage_payload.insert("input_tokens".to_string(), Value::from(usage.input_tokens));
|
||||
usage_payload.insert(
|
||||
"output_tokens".to_string(),
|
||||
Value::from(usage.output_tokens),
|
||||
);
|
||||
usage_payload.insert("total_tokens".to_string(), Value::from(usage.total_tokens));
|
||||
if usage.reasoning_tokens > 0 {
|
||||
usage_payload.insert(
|
||||
"output_tokens_details".to_string(),
|
||||
json!({ "reasoning_tokens": usage.reasoning_tokens }),
|
||||
);
|
||||
}
|
||||
|
||||
json!({
|
||||
"id": self.response_id(),
|
||||
"object": "response",
|
||||
@@ -1609,11 +1935,7 @@ impl OpenAICliClientEmitter {
|
||||
.into_iter()
|
||||
.map(|(_, item)| item)
|
||||
.collect::<Vec<_>>(),
|
||||
"usage": {
|
||||
"input_tokens": usage.prompt_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
"total_tokens": usage.total_tokens,
|
||||
}
|
||||
"usage": usage_payload,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1726,6 +2048,82 @@ impl OpenAICliClientEmitter {
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ToolResultDelta {
|
||||
index,
|
||||
tool_use_id,
|
||||
name,
|
||||
content,
|
||||
} => {
|
||||
let mut out = self.ensure_started()?;
|
||||
let output_index = self.ensure_tool_result_output_index(index);
|
||||
let response_id = self.response_id().to_string();
|
||||
let state = self.tool_results.entry(index).or_default();
|
||||
if state.tool_use_id.is_empty() {
|
||||
state.tool_use_id = tool_use_id.clone();
|
||||
}
|
||||
if name.is_some() {
|
||||
state.name = name.clone();
|
||||
}
|
||||
let emitted_tool_use_id = if state.tool_use_id.is_empty() {
|
||||
tool_use_id
|
||||
} else {
|
||||
state.tool_use_id.clone()
|
||||
};
|
||||
if !state.item_started {
|
||||
let mut item = Map::new();
|
||||
item.insert(
|
||||
"type".to_string(),
|
||||
Value::String("function_call_output".to_string()),
|
||||
);
|
||||
item.insert(
|
||||
"id".to_string(),
|
||||
Value::String(format!("{emitted_tool_use_id}_output")),
|
||||
);
|
||||
item.insert(
|
||||
"call_id".to_string(),
|
||||
Value::String(emitted_tool_use_id.clone()),
|
||||
);
|
||||
if let Some(name) = state
|
||||
.name
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.cloned()
|
||||
{
|
||||
item.insert("name".to_string(), Value::String(name));
|
||||
}
|
||||
item.insert("output".to_string(), Value::String(String::new()));
|
||||
out.extend(self.encode_response_event(
|
||||
"response.output_item.added",
|
||||
json!({
|
||||
"type": "response.output_item.added",
|
||||
"response_id": response_id,
|
||||
"output_index": output_index,
|
||||
"item": Value::Object(item),
|
||||
}),
|
||||
)?);
|
||||
self.tool_results.entry(index).or_default().item_started = true;
|
||||
}
|
||||
self.tool_results
|
||||
.entry(index)
|
||||
.or_default()
|
||||
.content
|
||||
.push_str(&content);
|
||||
if !content.is_empty() {
|
||||
out.extend(self.encode_response_event(
|
||||
"response.function_call_output.delta",
|
||||
json!({
|
||||
"type": "response.function_call_output.delta",
|
||||
"response_id": self.response_id(),
|
||||
"output_index": output_index,
|
||||
"item_id": format!("{emitted_tool_use_id}_output"),
|
||||
"call_id": emitted_tool_use_id,
|
||||
"delta": content,
|
||||
}),
|
||||
)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()),
|
||||
CanonicalStreamEvent::Finish { usage, .. } => {
|
||||
if self.finished {
|
||||
return Ok(Vec::new());
|
||||
@@ -1734,16 +2132,13 @@ impl OpenAICliClientEmitter {
|
||||
out.extend(self.finish_reasoning_item()?);
|
||||
out.extend(self.finish_text_item()?);
|
||||
out.extend(self.finish_tool_items()?);
|
||||
out.extend(self.finish_tool_result_items()?);
|
||||
let usage = usage.unwrap_or_default();
|
||||
out.extend(self.encode_response_event(
|
||||
"response.completed",
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": self.completed_response(OpenAiCliResponseUsage {
|
||||
prompt_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
total_tokens: usage.total_tokens,
|
||||
}),
|
||||
"response": self.completed_response(usage),
|
||||
}),
|
||||
)?);
|
||||
self.finished = true;
|
||||
@@ -1808,6 +2203,14 @@ fn openai_stream_placeholder_for_content_part(part: &CanonicalContentPart) -> St
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_tool_result_content_from_value(value: Option<&Value>) -> String {
|
||||
match value {
|
||||
Some(Value::String(text)) => text.clone(),
|
||||
Some(Value::Null) | None => String::new(),
|
||||
Some(value) => value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1835,10 +2238,71 @@ mod tests {
|
||||
sequence_numbers
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_provider_state_emits_unknown_events_for_unrecognized_deltas() {
|
||||
let mut state = OpenAIChatProviderState::default();
|
||||
let report_context = json!({});
|
||||
let frames = state
|
||||
.push_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"id": "chatcmpl_unknown_123",
|
||||
"model": "gpt-5.4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"future_delta_type": {
|
||||
"payload": true
|
||||
}
|
||||
}
|
||||
}]
|
||||
})),
|
||||
)
|
||||
.expect("unknown delta should parse");
|
||||
|
||||
assert!(frames.iter().any(|frame| matches!(
|
||||
frame.event,
|
||||
CanonicalStreamEvent::UnknownEvent(ref payload)
|
||||
if payload.get("delta")
|
||||
.and_then(|delta| delta.get("future_delta_type"))
|
||||
.is_some()
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_provider_state_emits_unknown_events_for_unknown_response_types() {
|
||||
let mut state = OpenAIResponsesProviderState::default();
|
||||
let report_context = json!({});
|
||||
let frames = state
|
||||
.push_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"type": "response.future.delta",
|
||||
"response": {
|
||||
"id": "resp_unknown_123",
|
||||
"model": "gpt-5.4"
|
||||
},
|
||||
"delta": {
|
||||
"payload": true
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect("unknown response event should parse");
|
||||
|
||||
assert!(frames.iter().any(|frame| matches!(
|
||||
frame.event,
|
||||
CanonicalStreamEvent::UnknownEvent(ref payload)
|
||||
if payload.get("type").and_then(Value::as_str) == Some("response.future.delta")
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_usage_derives_missing_input_tokens_from_total() {
|
||||
let usage = canonical_usage_from_openai_usage(Some(&json!({
|
||||
"output_tokens": 177,
|
||||
"output_tokens_details": {
|
||||
"reasoning_tokens": 7,
|
||||
},
|
||||
"total_tokens": 20_612,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 19_840,
|
||||
@@ -1849,6 +2313,7 @@ mod tests {
|
||||
assert_eq!(usage.input_tokens, 20_435);
|
||||
assert_eq!(usage.output_tokens, 177);
|
||||
assert_eq!(usage.cache_read_tokens, 19_840);
|
||||
assert_eq!(usage.reasoning_tokens, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1897,6 +2362,7 @@ mod tests {
|
||||
input_tokens: 26,
|
||||
output_tokens: 144,
|
||||
cache_read_tokens: 0,
|
||||
reasoning_tokens: 10,
|
||||
..
|
||||
}),
|
||||
} if reason == "stop"
|
||||
@@ -1904,8 +2370,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_cli_provider_state_extracts_response_completed_usage() {
|
||||
let mut state = OpenAICliProviderState::default();
|
||||
fn openai_responses_provider_state_extracts_response_completed_usage() {
|
||||
let mut state = OpenAIResponsesProviderState::default();
|
||||
let report_context = json!({});
|
||||
let frames = state
|
||||
.push_line(
|
||||
@@ -1950,8 +2416,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_cli_client_emitter_emits_doc_like_text_events() {
|
||||
let mut emitter = OpenAICliClientEmitter::default();
|
||||
fn openai_responses_client_emitter_emits_doc_like_text_events() {
|
||||
let mut emitter = OpenAIResponsesClientEmitter::default();
|
||||
let start = CanonicalStreamFrame {
|
||||
id: "chatcmpl_stream_123".to_string(),
|
||||
model: "gpt-5.4".to_string(),
|
||||
@@ -1997,8 +2463,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_cli_client_emitter_keeps_text_item_id_stable_after_text_started() {
|
||||
let mut emitter = OpenAICliClientEmitter::default();
|
||||
fn openai_responses_client_emitter_keeps_text_item_id_stable_after_text_started() {
|
||||
let mut emitter = OpenAIResponsesClientEmitter::default();
|
||||
let mut bytes = emitter
|
||||
.emit(CanonicalStreamFrame {
|
||||
id: "msg_first".to_string(),
|
||||
@@ -2022,8 +2488,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_cli_provider_state_accepts_done_events_without_deltas() {
|
||||
let mut state = OpenAICliProviderState::default();
|
||||
fn openai_responses_provider_state_accepts_done_events_without_deltas() {
|
||||
let mut state = OpenAIResponsesProviderState::default();
|
||||
let report_context = json!({});
|
||||
let mut frames = Vec::new();
|
||||
|
||||
@@ -2142,8 +2608,90 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_cli_provider_state_accepts_legacy_outtext_delta_alias() {
|
||||
let mut state = OpenAICliProviderState::default();
|
||||
fn openai_responses_provider_state_parses_function_call_output_as_tool_result() {
|
||||
let mut state = OpenAIResponsesProviderState::default();
|
||||
let report_context = json!({});
|
||||
let mut frames = Vec::new();
|
||||
|
||||
frames.extend(
|
||||
state
|
||||
.push_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_123",
|
||||
"model": "gpt-5.4",
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect("created should parse"),
|
||||
);
|
||||
frames.extend(
|
||||
state
|
||||
.push_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"type": "response.function_call_output.done",
|
||||
"response_id": "resp_123",
|
||||
"output_index": 2,
|
||||
"call_id": "call_123",
|
||||
"name": "lookup",
|
||||
"output": {"ok": true},
|
||||
})),
|
||||
)
|
||||
.expect("tool result should parse"),
|
||||
);
|
||||
|
||||
assert!(frames.iter().any(|frame| matches!(
|
||||
frame.event,
|
||||
CanonicalStreamEvent::ToolResultDelta {
|
||||
index: 2,
|
||||
ref tool_use_id,
|
||||
name: Some(ref name),
|
||||
ref content,
|
||||
} if tool_use_id == "call_123" && name == "lookup" && content == "{\"ok\":true}"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_client_emitter_emits_function_call_output_events() {
|
||||
let mut emitter = OpenAIResponsesClientEmitter::default();
|
||||
let mut bytes = emitter
|
||||
.emit(CanonicalStreamFrame {
|
||||
id: "resp_123".to_string(),
|
||||
model: "gpt-5.4".to_string(),
|
||||
event: CanonicalStreamEvent::ToolResultDelta {
|
||||
index: 1,
|
||||
tool_use_id: "call_123".to_string(),
|
||||
name: Some("lookup".to_string()),
|
||||
content: "{\"ok\":true}".to_string(),
|
||||
},
|
||||
})
|
||||
.expect("tool result should encode");
|
||||
bytes.extend(
|
||||
emitter
|
||||
.emit(CanonicalStreamFrame {
|
||||
id: "resp_123".to_string(),
|
||||
model: "gpt-5.4".to_string(),
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: Some("stop".to_string()),
|
||||
usage: None,
|
||||
},
|
||||
})
|
||||
.expect("finish should encode"),
|
||||
);
|
||||
|
||||
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
||||
assert!(sse.contains("event: response.function_call_output.delta\n"));
|
||||
assert!(sse.contains("\"type\":\"function_call_output\""));
|
||||
assert!(sse.contains("\"call_id\":\"call_123\""));
|
||||
assert!(sse.contains("\"output\":\"{\\\"ok\\\":true}\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_provider_state_accepts_legacy_outtext_delta_alias() {
|
||||
let mut state = OpenAIResponsesProviderState::default();
|
||||
let report_context = json!({});
|
||||
let mut frames = Vec::new();
|
||||
|
||||
@@ -2254,6 +2802,7 @@ mod tests {
|
||||
input_tokens: 1,
|
||||
output_tokens: 2,
|
||||
total_tokens: 3,
|
||||
reasoning_tokens: 1,
|
||||
..CanonicalUsage::default()
|
||||
}),
|
||||
},
|
||||
@@ -2266,6 +2815,7 @@ mod tests {
|
||||
assert!(sse.contains("\"choices\":[]"));
|
||||
assert!(sse.contains("\"prompt_tokens\":1"));
|
||||
assert!(sse.contains("\"completion_tokens\":2"));
|
||||
assert!(sse.contains("\"completion_tokens_details\":{\"reasoning_tokens\":1}"));
|
||||
assert!(sse.contains("\"total_tokens\":3"));
|
||||
assert!(sse.contains("data: [DONE]\n\n"));
|
||||
}
|
||||
@@ -2344,8 +2894,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_cli_client_emitter_includes_reasoning_in_completed_response() {
|
||||
let mut emitter = OpenAICliClientEmitter::default();
|
||||
fn openai_responses_client_emitter_includes_reasoning_in_completed_response() {
|
||||
let mut emitter = OpenAIResponsesClientEmitter::default();
|
||||
let mut bytes = emitter
|
||||
.emit(CanonicalStreamFrame {
|
||||
id: "resp_123".to_string(),
|
||||
@@ -2373,6 +2923,7 @@ mod tests {
|
||||
input_tokens: 1,
|
||||
output_tokens: 2,
|
||||
total_tokens: 3,
|
||||
reasoning_tokens: 1,
|
||||
..CanonicalUsage::default()
|
||||
}),
|
||||
},
|
||||
@@ -2383,11 +2934,12 @@ mod tests {
|
||||
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
||||
assert!(sse.contains("\"type\":\"reasoning\""));
|
||||
assert!(sse.contains("\"text\":\"because\""));
|
||||
assert!(sse.contains("\"output_tokens_details\":{\"reasoning_tokens\":1}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_cli_client_emitter_emits_doc_like_reasoning_events() {
|
||||
let mut emitter = OpenAICliClientEmitter::default();
|
||||
fn openai_responses_client_emitter_emits_doc_like_reasoning_events() {
|
||||
let mut emitter = OpenAIResponsesClientEmitter::default();
|
||||
let mut bytes = emitter
|
||||
.emit(CanonicalStreamFrame {
|
||||
id: "resp_456".to_string(),
|
||||
@@ -2428,8 +2980,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_cli_client_emitter_emits_failed_event_with_sequence_number() {
|
||||
let mut emitter = OpenAICliClientEmitter::default();
|
||||
fn openai_responses_client_emitter_emits_failed_event_with_sequence_number() {
|
||||
let mut emitter = OpenAIResponsesClientEmitter::default();
|
||||
let mut bytes = emitter
|
||||
.emit(CanonicalStreamFrame {
|
||||
id: "resp_err_123".to_string(),
|
||||
@@ -2466,8 +3018,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_cli_provider_state_accepts_reasoning_summary_events() {
|
||||
let mut state = OpenAICliProviderState::default();
|
||||
fn openai_responses_provider_state_accepts_reasoning_summary_events() {
|
||||
let mut state = OpenAIResponsesProviderState::default();
|
||||
let report_context = json!({});
|
||||
let mut frames = Vec::new();
|
||||
|
||||
|
||||
@@ -1,59 +1,8 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CanonicalUsage {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub cache_creation_tokens: u64,
|
||||
pub cache_creation_ephemeral_5m_tokens: u64,
|
||||
pub cache_creation_ephemeral_1h_tokens: u64,
|
||||
pub cache_read_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CanonicalContentPart {
|
||||
ImageUrl(String),
|
||||
File {
|
||||
file_data: Option<String>,
|
||||
reference: Option<String>,
|
||||
mime_type: Option<String>,
|
||||
filename: Option<String>,
|
||||
},
|
||||
Audio {
|
||||
data: String,
|
||||
format: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CanonicalStreamEvent {
|
||||
Start,
|
||||
TextDelta(String),
|
||||
ReasoningDelta(String),
|
||||
ReasoningSignature(String),
|
||||
ContentPart(CanonicalContentPart),
|
||||
ToolCallStart {
|
||||
index: usize,
|
||||
call_id: String,
|
||||
name: String,
|
||||
},
|
||||
ToolCallArgumentsDelta {
|
||||
index: usize,
|
||||
arguments: String,
|
||||
},
|
||||
Finish {
|
||||
finish_reason: Option<String>,
|
||||
usage: Option<CanonicalUsage>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CanonicalStreamFrame {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub event: CanonicalStreamEvent,
|
||||
}
|
||||
pub use aether_ai_formats::stream::{
|
||||
CanonicalContentPart, CanonicalStreamEvent, CanonicalStreamFrame, CanonicalUsage,
|
||||
};
|
||||
|
||||
pub fn decode_json_data_line(line: &[u8]) -> Option<Value> {
|
||||
let text = std::str::from_utf8(line).ok()?;
|
||||
@@ -123,6 +72,18 @@ pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<Canoni
|
||||
.and_then(Value::as_u64)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let reasoning_tokens = usage
|
||||
.get("reasoning_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.or_else(|| {
|
||||
usage
|
||||
.get("output_tokens_details")
|
||||
.or_else(|| usage.get("completion_tokens_details"))
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|details| details.get("reasoning_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let total_tokens = usage.get("total_tokens").and_then(Value::as_u64).unwrap_or(
|
||||
input_tokens
|
||||
.saturating_add(output_tokens)
|
||||
@@ -138,6 +99,7 @@ pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<Canoni
|
||||
total_tokens,
|
||||
cache_creation_tokens,
|
||||
cache_read_tokens,
|
||||
reasoning_tokens,
|
||||
..CanonicalUsage::default()
|
||||
})
|
||||
}
|
||||
@@ -174,6 +136,10 @@ pub fn canonical_usage_from_claude_usage(value: Option<&Value>) -> Option<Canoni
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let reasoning_tokens = usage
|
||||
.get("reasoning_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
Some(CanonicalUsage {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
@@ -185,6 +151,7 @@ pub fn canonical_usage_from_claude_usage(value: Option<&Value>) -> Option<Canoni
|
||||
cache_creation_ephemeral_5m_tokens,
|
||||
cache_creation_ephemeral_1h_tokens,
|
||||
cache_read_tokens,
|
||||
reasoning_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -198,6 +165,10 @@ pub fn canonical_usage_from_gemini_usage(value: Option<&Value>) -> Option<Canoni
|
||||
.get("candidatesTokenCount")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let reasoning_tokens = usage
|
||||
.get("thoughtsTokenCount")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let cache_read_tokens = usage
|
||||
.get("cachedContentTokenCount")
|
||||
.and_then(Value::as_u64)
|
||||
@@ -212,9 +183,10 @@ pub fn canonical_usage_from_gemini_usage(value: Option<&Value>) -> Option<Canoni
|
||||
);
|
||||
Some(CanonicalUsage {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
output_tokens: output_tokens.saturating_add(reasoning_tokens),
|
||||
total_tokens,
|
||||
cache_read_tokens,
|
||||
reasoning_tokens,
|
||||
..CanonicalUsage::default()
|
||||
})
|
||||
}
|
||||
@@ -316,16 +288,26 @@ pub fn build_openai_chat_usage_chunk(
|
||||
prompt_tokens: u64,
|
||||
completion_tokens: u64,
|
||||
total_tokens: u64,
|
||||
reasoning_tokens: u64,
|
||||
) -> Value {
|
||||
let mut usage = Map::new();
|
||||
usage.insert("prompt_tokens".to_string(), Value::from(prompt_tokens));
|
||||
usage.insert(
|
||||
"completion_tokens".to_string(),
|
||||
Value::from(completion_tokens),
|
||||
);
|
||||
usage.insert("total_tokens".to_string(), Value::from(total_tokens));
|
||||
if reasoning_tokens > 0 {
|
||||
usage.insert(
|
||||
"completion_tokens_details".to_string(),
|
||||
json!({ "reasoning_tokens": reasoning_tokens }),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"id": id,
|
||||
"object": "chat.completion.chunk",
|
||||
"model": model,
|
||||
"choices": [],
|
||||
"usage": {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
"usage": usage,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use aether_ai_formats::FormatId;
|
||||
use aether_contracts::{ExecutionStreamTerminalSummary, StandardizedUsage};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -6,8 +7,8 @@ use crate::finalize::sse::encode_json_sse;
|
||||
use crate::finalize::standard::claude::stream::{ClaudeClientEmitter, ClaudeProviderState};
|
||||
use crate::finalize::standard::gemini::stream::{GeminiClientEmitter, GeminiProviderState};
|
||||
use crate::finalize::standard::openai::stream::{
|
||||
OpenAIChatClientEmitter, OpenAIChatProviderState, OpenAICliClientEmitter,
|
||||
OpenAICliProviderState,
|
||||
OpenAIChatClientEmitter, OpenAIChatProviderState, OpenAIResponsesClientEmitter,
|
||||
OpenAIResponsesProviderState,
|
||||
};
|
||||
use crate::finalize::standard::stream_core::common::{
|
||||
decode_json_data_line, CanonicalStreamEvent, CanonicalStreamFrame, CanonicalUsage,
|
||||
@@ -174,33 +175,39 @@ impl StreamingStandardTerminalObserver {
|
||||
if summary.model.is_none() {
|
||||
summary.model = Some(model);
|
||||
}
|
||||
if let CanonicalStreamEvent::Finish {
|
||||
finish_reason,
|
||||
usage,
|
||||
} = event
|
||||
{
|
||||
summary.finish_reason = finish_reason;
|
||||
summary.standardized_usage = usage.map(standardized_usage_from_canonical);
|
||||
summary.observed_finish = true;
|
||||
match event {
|
||||
CanonicalStreamEvent::UnknownEvent(_) => {
|
||||
summary.unknown_event_count = summary.unknown_event_count.saturating_add(1);
|
||||
}
|
||||
CanonicalStreamEvent::Finish {
|
||||
finish_reason,
|
||||
usage,
|
||||
} => {
|
||||
summary.finish_reason = finish_reason;
|
||||
summary.standardized_usage = usage.map(standardized_usage_from_canonical);
|
||||
summary.observed_finish = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ProviderStreamParser {
|
||||
OpenAIChat(OpenAIChatProviderState),
|
||||
OpenAICli(OpenAICliProviderState),
|
||||
OpenAIResponses(OpenAIResponsesProviderState),
|
||||
Claude(ClaudeProviderState),
|
||||
Gemini(GeminiProviderState),
|
||||
}
|
||||
|
||||
impl ProviderStreamParser {
|
||||
fn for_api_format(provider_api_format: &str) -> Option<Self> {
|
||||
Some(match provider_api_format {
|
||||
"openai:chat" => Self::OpenAIChat(OpenAIChatProviderState::default()),
|
||||
"openai:cli" | "openai:compact" => Self::OpenAICli(OpenAICliProviderState::default()),
|
||||
"claude:chat" | "claude:cli" => Self::Claude(ClaudeProviderState::default()),
|
||||
"gemini:chat" | "gemini:cli" => Self::Gemini(GeminiProviderState::default()),
|
||||
_ => return None,
|
||||
Some(match FormatId::parse(provider_api_format)? {
|
||||
FormatId::OpenAiChat => Self::OpenAIChat(OpenAIChatProviderState::default()),
|
||||
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact => {
|
||||
Self::OpenAIResponses(OpenAIResponsesProviderState::default())
|
||||
}
|
||||
FormatId::ClaudeMessages => Self::Claude(ClaudeProviderState::default()),
|
||||
FormatId::GeminiGenerateContent => Self::Gemini(GeminiProviderState::default()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -211,7 +218,7 @@ impl ProviderStreamParser {
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
match self {
|
||||
ProviderStreamParser::OpenAIChat(state) => state.push_line(report_context, line),
|
||||
ProviderStreamParser::OpenAICli(state) => state.push_line(report_context, line),
|
||||
ProviderStreamParser::OpenAIResponses(state) => state.push_line(report_context, line),
|
||||
ProviderStreamParser::Claude(state) => state.push_line(report_context, line),
|
||||
ProviderStreamParser::Gemini(state) => state.push_line(report_context, line),
|
||||
}
|
||||
@@ -223,7 +230,7 @@ impl ProviderStreamParser {
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
match self {
|
||||
ProviderStreamParser::OpenAIChat(state) => state.finish(report_context),
|
||||
ProviderStreamParser::OpenAICli(state) => state.finish(report_context),
|
||||
ProviderStreamParser::OpenAIResponses(state) => state.finish(report_context),
|
||||
ProviderStreamParser::Claude(state) => state.finish(report_context),
|
||||
ProviderStreamParser::Gemini(state) => state.finish(report_context),
|
||||
}
|
||||
@@ -232,7 +239,7 @@ impl ProviderStreamParser {
|
||||
|
||||
enum ClientStreamEmitter {
|
||||
OpenAIChat(OpenAIChatClientEmitter),
|
||||
OpenAICli(OpenAICliClientEmitter),
|
||||
OpenAIResponses(OpenAIResponsesClientEmitter),
|
||||
Claude(ClaudeClientEmitter),
|
||||
Gemini(GeminiClientEmitter),
|
||||
}
|
||||
@@ -268,6 +275,7 @@ fn standardized_usage_from_canonical(usage: CanonicalUsage) -> StandardizedUsage
|
||||
standardized.cache_creation_ephemeral_1h_tokens =
|
||||
usage.cache_creation_ephemeral_1h_tokens as i64;
|
||||
standardized.cache_read_tokens = usage.cache_read_tokens as i64;
|
||||
standardized.reasoning_tokens = usage.reasoning_tokens as i64;
|
||||
standardized.dimensions.insert(
|
||||
"total_tokens".to_string(),
|
||||
serde_json::json!(usage.total_tokens),
|
||||
@@ -277,19 +285,20 @@ fn standardized_usage_from_canonical(usage: CanonicalUsage) -> StandardizedUsage
|
||||
|
||||
impl ClientStreamEmitter {
|
||||
fn for_api_format(client_api_format: &str) -> Option<Self> {
|
||||
Some(match client_api_format {
|
||||
"openai:chat" => Self::OpenAIChat(OpenAIChatClientEmitter::default()),
|
||||
"openai:cli" | "openai:compact" => Self::OpenAICli(OpenAICliClientEmitter::default()),
|
||||
"claude:chat" | "claude:cli" => Self::Claude(ClaudeClientEmitter::default()),
|
||||
"gemini:chat" | "gemini:cli" => Self::Gemini(GeminiClientEmitter::default()),
|
||||
_ => return None,
|
||||
Some(match FormatId::parse(client_api_format)? {
|
||||
FormatId::OpenAiChat => Self::OpenAIChat(OpenAIChatClientEmitter::default()),
|
||||
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact => {
|
||||
Self::OpenAIResponses(OpenAIResponsesClientEmitter::default())
|
||||
}
|
||||
FormatId::ClaudeMessages => Self::Claude(ClaudeClientEmitter::default()),
|
||||
FormatId::GeminiGenerateContent => Self::Gemini(GeminiClientEmitter::default()),
|
||||
})
|
||||
}
|
||||
|
||||
fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
match self {
|
||||
ClientStreamEmitter::OpenAIChat(state) => state.emit(frame),
|
||||
ClientStreamEmitter::OpenAICli(state) => state.emit(frame),
|
||||
ClientStreamEmitter::OpenAIResponses(state) => state.emit(frame),
|
||||
ClientStreamEmitter::Claude(state) => state.emit(frame),
|
||||
ClientStreamEmitter::Gemini(state) => state.emit(frame),
|
||||
}
|
||||
@@ -298,7 +307,7 @@ impl ClientStreamEmitter {
|
||||
fn finish(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
match self {
|
||||
ClientStreamEmitter::OpenAIChat(state) => state.finish(),
|
||||
ClientStreamEmitter::OpenAICli(state) => state.finish(),
|
||||
ClientStreamEmitter::OpenAIResponses(state) => state.finish(),
|
||||
ClientStreamEmitter::Claude(state) => state.finish(),
|
||||
ClientStreamEmitter::Gemini(state) => state.finish(),
|
||||
}
|
||||
@@ -306,7 +315,7 @@ impl ClientStreamEmitter {
|
||||
|
||||
fn emit_error(&mut self, error_body: Value) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
match self {
|
||||
ClientStreamEmitter::OpenAICli(state) => state.emit_error(error_body),
|
||||
ClientStreamEmitter::OpenAIResponses(state) => state.emit_error(error_body),
|
||||
ClientStreamEmitter::Claude(_) => {
|
||||
let event = error_body.get("type").and_then(Value::as_str);
|
||||
encode_json_sse(event, &error_body)
|
||||
@@ -335,11 +344,12 @@ fn parse_provider_error(
|
||||
provider_api_format: &str,
|
||||
payload: &Value,
|
||||
) -> Option<(String, Option<String>, LocalCoreSyncErrorKind)> {
|
||||
match provider_api_format {
|
||||
"openai:chat" | "openai:cli" | "openai:compact" => parse_openai_error(payload),
|
||||
"claude:chat" | "claude:cli" => parse_claude_error(payload),
|
||||
"gemini:chat" | "gemini:cli" => parse_gemini_error(payload),
|
||||
_ => None,
|
||||
match FormatId::parse(provider_api_format)? {
|
||||
FormatId::OpenAiChat | FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact => {
|
||||
parse_openai_error(payload)
|
||||
}
|
||||
FormatId::ClaudeMessages => parse_claude_error(payload),
|
||||
FormatId::GeminiGenerateContent => parse_gemini_error(payload),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,7 +640,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_provider_errors_to_openai_cli_failed_events() {
|
||||
fn transforms_provider_errors_to_openai_responses_failed_events() {
|
||||
let cases = [
|
||||
(
|
||||
"openai:chat",
|
||||
@@ -675,7 +685,7 @@ mod tests {
|
||||
];
|
||||
|
||||
for (provider_api_format, line, message, err_type, code) in cases {
|
||||
let report_context = report_context(provider_api_format, "openai:cli");
|
||||
let report_context = report_context(provider_api_format, "openai:responses");
|
||||
let mut matrix = StreamingStandardFormatMatrix::default();
|
||||
let output = matrix
|
||||
.transform_line(&report_context, line)
|
||||
@@ -802,8 +812,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn terminal_observer_uses_explicit_provider_stream_event_api_format() {
|
||||
let mut report_context = report_context("openai:chat", "openai:cli");
|
||||
report_context["provider_stream_event_api_format"] = json!("openai:cli");
|
||||
let mut report_context = report_context("openai:chat", "openai:responses");
|
||||
report_context["provider_stream_event_api_format"] = json!("openai:responses");
|
||||
let mut observer = StreamingStandardTerminalObserver::default();
|
||||
|
||||
observer
|
||||
@@ -824,7 +834,7 @@ mod tests {
|
||||
},
|
||||
"output_tokens": 137,
|
||||
"output_tokens_details": {
|
||||
"reasoning_tokens": 0,
|
||||
"reasoning_tokens": 10,
|
||||
},
|
||||
"total_tokens": 163,
|
||||
},
|
||||
@@ -844,12 +854,13 @@ mod tests {
|
||||
|
||||
assert_eq!(usage.input_tokens, 26);
|
||||
assert_eq!(usage.output_tokens, 137);
|
||||
assert_eq!(usage.reasoning_tokens, 10);
|
||||
assert_eq!(usage.cache_read_tokens, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_observer_does_not_infer_provider_stream_event_api_format() {
|
||||
let report_context = report_context("openai:chat", "openai:cli");
|
||||
let report_context = report_context("openai:chat", "openai:responses");
|
||||
let mut observer = StreamingStandardTerminalObserver::default();
|
||||
|
||||
observer
|
||||
@@ -873,4 +884,36 @@ mod tests {
|
||||
"provider stream parser selection must come from report context, not event sniffing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_observer_counts_unknown_provider_stream_events() {
|
||||
let mut report_context = report_context("openai:chat", "openai:responses");
|
||||
report_context["provider_stream_event_api_format"] = json!("openai:responses");
|
||||
let mut observer = StreamingStandardTerminalObserver::default();
|
||||
|
||||
observer
|
||||
.push_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"type": "response.future.delta",
|
||||
"response": {
|
||||
"id": "resp_unknown_123",
|
||||
"model": "gpt-5.4",
|
||||
},
|
||||
"payload": {
|
||||
"kept": true,
|
||||
},
|
||||
})),
|
||||
)
|
||||
.expect("unknown stream event should be observed");
|
||||
|
||||
let summary = observer
|
||||
.latest_summary()
|
||||
.cloned()
|
||||
.expect("summary should exist");
|
||||
assert_eq!(summary.response_id.as_deref(), Some("resp_unknown_123"));
|
||||
assert_eq!(summary.model.as_deref(), Some("gpt-5.4"));
|
||||
assert_eq!(summary.unknown_event_count, 1);
|
||||
assert!(!summary.observed_finish);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,8 @@ fn is_standard_provider_api_format(api_format: &str) -> bool {
|
||||
matches!(
|
||||
api_format,
|
||||
"openai:chat"
|
||||
| "openai:responses"
|
||||
| "openai:responses:compact"
|
||||
| "openai:cli"
|
||||
| "openai:compact"
|
||||
| "claude:chat"
|
||||
@@ -101,7 +103,12 @@ fn is_standard_chat_client_api_format(api_format: &str) -> bool {
|
||||
fn is_standard_cli_client_api_format(api_format: &str) -> bool {
|
||||
matches!(
|
||||
api_format,
|
||||
"openai:cli" | "openai:compact" | "claude:cli" | "gemini:cli"
|
||||
"openai:responses"
|
||||
| "openai:responses:compact"
|
||||
| "openai:cli"
|
||||
| "openai:compact"
|
||||
| "claude:cli"
|
||||
| "gemini:cli"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,9 +26,10 @@ pub fn force_upstream_streaming_for_provider(
|
||||
provider_api_format: &str,
|
||||
) -> bool {
|
||||
provider_type.trim().eq_ignore_ascii_case("codex")
|
||||
&& provider_api_format
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("openai:cli")
|
||||
&& matches!(
|
||||
provider_api_format.trim().to_ascii_lowercase().as_str(),
|
||||
"openai:responses" | "openai:cli"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -57,7 +58,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forces_streaming_for_codex_openai_cli() {
|
||||
fn forces_streaming_for_codex_openai_responses() {
|
||||
assert!(force_upstream_streaming_for_provider(
|
||||
"codex",
|
||||
"openai:responses"
|
||||
));
|
||||
assert!(force_upstream_streaming_for_provider("codex", "openai:cli"));
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ use crate::contracts::{
|
||||
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
|
||||
OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
|
||||
OPENAI_COMPACT_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND,
|
||||
OPENAI_RESPONSES_STREAM_PLAN_KIND, OPENAI_RESPONSES_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_CONTENT_PLAN_KIND,
|
||||
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
||||
@@ -74,19 +76,19 @@ pub fn resolve_execution_runtime_stream_plan_kind(
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("cli")
|
||||
&& is_openai_responses_route_kind(route_kind)
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/responses"
|
||||
{
|
||||
return Some(OPENAI_CLI_STREAM_PLAN_KIND);
|
||||
return Some(OPENAI_RESPONSES_STREAM_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("compact")
|
||||
&& is_openai_responses_compact_route_kind(route_kind)
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/responses/compact"
|
||||
{
|
||||
return Some(OPENAI_COMPACT_STREAM_PLAN_KIND);
|
||||
return Some(OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
@@ -189,19 +191,19 @@ pub fn resolve_execution_runtime_sync_plan_kind(
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("cli")
|
||||
&& is_openai_responses_route_kind(route_kind)
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/responses"
|
||||
{
|
||||
return Some(OPENAI_CLI_SYNC_PLAN_KIND);
|
||||
return Some(OPENAI_RESPONSES_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("compact")
|
||||
&& is_openai_responses_compact_route_kind(route_kind)
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/responses/compact"
|
||||
{
|
||||
return Some(OPENAI_COMPACT_SYNC_PLAN_KIND);
|
||||
return Some(OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("claude")
|
||||
@@ -260,6 +262,14 @@ pub fn resolve_execution_runtime_sync_plan_kind(
|
||||
None
|
||||
}
|
||||
|
||||
fn is_openai_responses_route_kind(route_kind: Option<&str>) -> bool {
|
||||
matches!(route_kind, Some("responses") | Some("cli"))
|
||||
}
|
||||
|
||||
fn is_openai_responses_compact_route_kind(route_kind: Option<&str>) -> bool {
|
||||
matches!(route_kind, Some("responses:compact") | Some("compact"))
|
||||
}
|
||||
|
||||
pub fn is_matching_stream_request(
|
||||
plan_kind: &str,
|
||||
path: &str,
|
||||
@@ -268,6 +278,8 @@ pub fn is_matching_stream_request(
|
||||
match plan_kind {
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND
|
||||
| CLAUDE_CHAT_STREAM_PLAN_KIND
|
||||
| OPENAI_RESPONSES_STREAM_PLAN_KIND
|
||||
| OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND
|
||||
| OPENAI_CLI_STREAM_PLAN_KIND
|
||||
| OPENAI_COMPACT_STREAM_PLAN_KIND
|
||||
| CLAUDE_CLI_STREAM_PLAN_KIND
|
||||
@@ -287,6 +299,8 @@ pub fn supports_sync_scheduler_decision_kind(plan_kind: &str) -> bool {
|
||||
plan_kind,
|
||||
OPENAI_CHAT_SYNC_PLAN_KIND
|
||||
| OPENAI_IMAGE_SYNC_PLAN_KIND
|
||||
| OPENAI_RESPONSES_SYNC_PLAN_KIND
|
||||
| OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND
|
||||
| OPENAI_CLI_SYNC_PLAN_KIND
|
||||
| OPENAI_COMPACT_SYNC_PLAN_KIND
|
||||
| CLAUDE_CHAT_SYNC_PLAN_KIND
|
||||
@@ -312,6 +326,8 @@ pub fn supports_stream_scheduler_decision_kind(plan_kind: &str) -> bool {
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND
|
||||
| CLAUDE_CHAT_STREAM_PLAN_KIND
|
||||
| GEMINI_CHAT_STREAM_PLAN_KIND
|
||||
| OPENAI_RESPONSES_STREAM_PLAN_KIND
|
||||
| OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND
|
||||
| OPENAI_CLI_STREAM_PLAN_KIND
|
||||
| OPENAI_IMAGE_STREAM_PLAN_KIND
|
||||
| OPENAI_COMPACT_STREAM_PLAN_KIND
|
||||
@@ -333,7 +349,9 @@ mod tests {
|
||||
};
|
||||
use crate::contracts::{
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND,
|
||||
OPENAI_IMAGE_SYNC_PLAN_KIND,
|
||||
OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, OPENAI_RESPONSES_STREAM_PLAN_KIND,
|
||||
OPENAI_RESPONSES_SYNC_PLAN_KIND,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -360,6 +378,86 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_responses_plan_kinds() {
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("openai"),
|
||||
Some("responses"),
|
||||
&Method::POST,
|
||||
"/v1/responses",
|
||||
),
|
||||
Some(OPENAI_RESPONSES_SYNC_PLAN_KIND)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_stream_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("openai"),
|
||||
Some("responses"),
|
||||
&Method::POST,
|
||||
"/v1/responses",
|
||||
),
|
||||
Some(OPENAI_RESPONSES_STREAM_PLAN_KIND)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("openai"),
|
||||
Some("cli"),
|
||||
&Method::POST,
|
||||
"/v1/responses",
|
||||
),
|
||||
Some(OPENAI_RESPONSES_SYNC_PLAN_KIND)
|
||||
);
|
||||
assert!(supports_sync_scheduler_decision_kind(
|
||||
OPENAI_RESPONSES_SYNC_PLAN_KIND
|
||||
));
|
||||
assert!(supports_stream_scheduler_decision_kind(
|
||||
OPENAI_RESPONSES_STREAM_PLAN_KIND
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_responses_compact_plan_kinds() {
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("openai"),
|
||||
Some("responses:compact"),
|
||||
&Method::POST,
|
||||
"/v1/responses/compact",
|
||||
),
|
||||
Some(OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_stream_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("openai"),
|
||||
Some("responses:compact"),
|
||||
&Method::POST,
|
||||
"/v1/responses/compact",
|
||||
),
|
||||
Some(OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("openai"),
|
||||
Some("compact"),
|
||||
&Method::POST,
|
||||
"/v1/responses/compact",
|
||||
),
|
||||
Some(OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND)
|
||||
);
|
||||
assert!(supports_sync_scheduler_decision_kind(
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND
|
||||
));
|
||||
assert!(supports_stream_scheduler_decision_kind(
|
||||
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_matching_requires_openai_stream_flag() {
|
||||
assert!(!is_matching_stream_request(
|
||||
|
||||
@@ -25,18 +25,23 @@ const UUID_NAMESPACE_OID_BYTES: [u8; 16] = [
|
||||
0x6b, 0xa7, 0xb8, 0x12, 0x9d, 0xad, 0x11, 0xd1, 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8,
|
||||
];
|
||||
|
||||
fn is_codex_openai_cli_request(provider_type: &str, provider_api_format: &str) -> bool {
|
||||
fn is_codex_openai_responses_request(provider_type: &str, provider_api_format: &str) -> bool {
|
||||
provider_type.trim().eq_ignore_ascii_case("codex")
|
||||
&& matches!(
|
||||
provider_api_format.trim().to_ascii_lowercase().as_str(),
|
||||
"openai:cli" | "openai:compact" | "openai:image"
|
||||
"openai:responses"
|
||||
| "openai:responses:compact"
|
||||
| "openai:cli"
|
||||
| "openai:compact"
|
||||
| "openai:image"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_openai_compact_request(provider_api_format: &str) -> bool {
|
||||
provider_api_format
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("openai:compact")
|
||||
matches!(
|
||||
provider_api_format.trim().to_ascii_lowercase().as_str(),
|
||||
"openai:responses:compact" | "openai:compact"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_openai_image_request(provider_api_format: &str) -> bool {
|
||||
@@ -245,7 +250,7 @@ fn maybe_inject_codex_prompt_cache_key(
|
||||
provider_api_format: &str,
|
||||
user_api_key_id: Option<&str>,
|
||||
) {
|
||||
if !is_codex_openai_cli_request(provider_type, provider_api_format) {
|
||||
if !is_codex_openai_responses_request(provider_type, provider_api_format) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -273,7 +278,7 @@ fn maybe_inject_codex_prompt_cache_key(
|
||||
);
|
||||
}
|
||||
|
||||
pub fn apply_openai_compact_special_body_edits(
|
||||
pub fn apply_openai_responses_compact_special_body_edits(
|
||||
provider_request_body: &mut Value,
|
||||
provider_api_format: &str,
|
||||
) {
|
||||
@@ -289,14 +294,25 @@ pub fn apply_openai_compact_special_body_edits(
|
||||
body_object.remove("store");
|
||||
}
|
||||
|
||||
pub fn apply_codex_openai_cli_special_body_edits(
|
||||
#[deprecated(
|
||||
since = "0.1.0",
|
||||
note = "use apply_openai_responses_compact_special_body_edits"
|
||||
)]
|
||||
pub fn apply_openai_compact_special_body_edits(
|
||||
provider_request_body: &mut Value,
|
||||
provider_api_format: &str,
|
||||
) {
|
||||
apply_openai_responses_compact_special_body_edits(provider_request_body, provider_api_format);
|
||||
}
|
||||
|
||||
pub fn apply_codex_openai_responses_special_body_edits(
|
||||
provider_request_body: &mut Value,
|
||||
provider_type: &str,
|
||||
provider_api_format: &str,
|
||||
body_rules: Option<&Value>,
|
||||
user_api_key_id: Option<&str>,
|
||||
) {
|
||||
if !is_codex_openai_cli_request(provider_type, provider_api_format) {
|
||||
if !is_codex_openai_responses_request(provider_type, provider_api_format) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -347,7 +363,27 @@ pub fn apply_codex_openai_cli_special_body_edits(
|
||||
);
|
||||
}
|
||||
|
||||
pub fn apply_codex_openai_cli_special_headers(
|
||||
#[deprecated(
|
||||
since = "0.1.0",
|
||||
note = "use apply_codex_openai_responses_special_body_edits"
|
||||
)]
|
||||
pub fn apply_codex_openai_cli_special_body_edits(
|
||||
provider_request_body: &mut Value,
|
||||
provider_type: &str,
|
||||
provider_api_format: &str,
|
||||
body_rules: Option<&Value>,
|
||||
user_api_key_id: Option<&str>,
|
||||
) {
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
provider_request_body,
|
||||
provider_type,
|
||||
provider_api_format,
|
||||
body_rules,
|
||||
user_api_key_id,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn apply_codex_openai_responses_special_headers(
|
||||
provider_request_headers: &mut BTreeMap<String, String>,
|
||||
provider_request_body: &Value,
|
||||
original_headers: &http::HeaderMap,
|
||||
@@ -356,7 +392,7 @@ pub fn apply_codex_openai_cli_special_headers(
|
||||
request_id: Option<&str>,
|
||||
decrypted_auth_config_raw: Option<&str>,
|
||||
) {
|
||||
if !is_codex_openai_cli_request(provider_type, provider_api_format) {
|
||||
if !is_codex_openai_responses_request(provider_type, provider_api_format) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -408,10 +444,10 @@ pub fn apply_codex_openai_cli_special_headers(
|
||||
}
|
||||
}
|
||||
|
||||
if provider_api_format
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("openai:cli")
|
||||
&& !header_map_has_non_empty_value(original_headers, "conversation_id")
|
||||
if matches!(
|
||||
provider_api_format.trim().to_ascii_lowercase().as_str(),
|
||||
"openai:responses" | "openai:cli"
|
||||
) && !header_map_has_non_empty_value(original_headers, "conversation_id")
|
||||
&& !btree_map_has_non_empty_value(provider_request_headers, "conversation_id")
|
||||
{
|
||||
if let Some(short_session_id) = short_session_id.as_deref() {
|
||||
@@ -421,9 +457,35 @@ pub fn apply_codex_openai_cli_special_headers(
|
||||
}
|
||||
}
|
||||
|
||||
#[deprecated(
|
||||
since = "0.1.0",
|
||||
note = "use apply_codex_openai_responses_special_headers"
|
||||
)]
|
||||
pub fn apply_codex_openai_cli_special_headers(
|
||||
provider_request_headers: &mut BTreeMap<String, String>,
|
||||
provider_request_body: &Value,
|
||||
original_headers: &http::HeaderMap,
|
||||
provider_type: &str,
|
||||
provider_api_format: &str,
|
||||
request_id: Option<&str>,
|
||||
decrypted_auth_config_raw: Option<&str>,
|
||||
) {
|
||||
apply_codex_openai_responses_special_headers(
|
||||
provider_request_headers,
|
||||
provider_request_body,
|
||||
original_headers,
|
||||
provider_type,
|
||||
provider_api_format,
|
||||
request_id,
|
||||
decrypted_auth_config_raw,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{apply_codex_openai_cli_special_body_edits, CODEX_OPENAI_IMAGE_INTERNAL_MODEL};
|
||||
use super::{
|
||||
apply_codex_openai_responses_special_body_edits, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
@@ -439,7 +501,7 @@ mod tests {
|
||||
"tool_choice": "auto"
|
||||
});
|
||||
|
||||
apply_codex_openai_cli_special_body_edits(
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
"codex",
|
||||
"openai:image",
|
||||
@@ -493,7 +555,7 @@ mod tests {
|
||||
"tool_choice": "auto"
|
||||
});
|
||||
|
||||
apply_codex_openai_cli_special_body_edits(
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
"codex",
|
||||
"openai:image",
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use aether_ai_formats::registry::{convert_request, FormatContext};
|
||||
use aether_provider_transport::{
|
||||
apply_local_body_rules, build_transport_request_url, GatewayProviderTransportSnapshot,
|
||||
TransportRequestUrlParams,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{
|
||||
apply_openai_responses_compact_special_body_edits,
|
||||
codex::apply_codex_openai_responses_special_body_edits,
|
||||
normalize::build_local_openai_chat_request_body,
|
||||
};
|
||||
use crate::conversion::request::{
|
||||
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
|
||||
convert_openai_chat_request_to_openai_cli_request,
|
||||
convert_openai_chat_request_to_openai_responses_request,
|
||||
normalize_claude_request_to_openai_chat_request,
|
||||
normalize_gemini_request_to_openai_chat_request,
|
||||
normalize_openai_cli_request_to_openai_chat_request,
|
||||
};
|
||||
|
||||
use super::{
|
||||
apply_openai_compact_special_body_edits, codex::apply_codex_openai_cli_special_body_edits,
|
||||
normalize::build_local_openai_chat_request_body,
|
||||
normalize_openai_responses_request_to_openai_chat_request,
|
||||
};
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -31,29 +32,32 @@ pub fn build_standard_request_body(
|
||||
body_rules: Option<&Value>,
|
||||
user_api_key_id: Option<&str>,
|
||||
) -> Option<Value> {
|
||||
let canonical_request = normalize_standard_request_to_openai_chat_request_cow(
|
||||
body_json,
|
||||
let format_context = FormatContext::default()
|
||||
.with_mapped_model(mapped_model)
|
||||
.with_request_path(request_path)
|
||||
.with_upstream_stream(upstream_is_stream);
|
||||
let mut provider_request_body = convert_request(
|
||||
client_api_format,
|
||||
request_path,
|
||||
)?;
|
||||
let mut provider_request_body = build_standard_request_body_from_canonical(
|
||||
canonical_request.as_ref(),
|
||||
mapped_model,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
)?;
|
||||
body_json,
|
||||
&format_context,
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
|
||||
return None;
|
||||
}
|
||||
apply_codex_openai_cli_special_body_edits(
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
provider_type,
|
||||
provider_api_format,
|
||||
body_rules,
|
||||
user_api_key_id,
|
||||
);
|
||||
apply_openai_compact_special_body_edits(&mut provider_request_body, provider_api_format);
|
||||
apply_openai_responses_compact_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
provider_api_format,
|
||||
);
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
@@ -69,18 +73,22 @@ pub fn build_standard_request_body_from_canonical(
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
),
|
||||
"openai:cli" => convert_openai_chat_request_to_openai_cli_request(
|
||||
canonical_request,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
),
|
||||
"openai:compact" => convert_openai_chat_request_to_openai_cli_request(
|
||||
canonical_request,
|
||||
mapped_model,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
"openai:responses" | "openai:cli" => {
|
||||
convert_openai_chat_request_to_openai_responses_request(
|
||||
canonical_request,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)
|
||||
}
|
||||
"openai:responses:compact" | "openai:compact" => {
|
||||
convert_openai_chat_request_to_openai_responses_request(
|
||||
canonical_request,
|
||||
mapped_model,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
}
|
||||
"claude:chat" | "claude:cli" => convert_openai_chat_request_to_claude_request(
|
||||
canonical_request,
|
||||
mapped_model,
|
||||
@@ -115,8 +123,8 @@ fn normalize_standard_request_to_openai_chat_request_cow<'a>(
|
||||
) -> Option<Cow<'a, Value>> {
|
||||
match client_api_format.trim().to_ascii_lowercase().as_str() {
|
||||
"openai:chat" => Some(Cow::Borrowed(body_json)),
|
||||
"openai:cli" | "openai:compact" => {
|
||||
normalize_openai_cli_request_to_openai_chat_request(body_json).map(Cow::Owned)
|
||||
"openai:responses" | "openai:cli" | "openai:responses:compact" | "openai:compact" => {
|
||||
normalize_openai_responses_request_to_openai_chat_request(body_json).map(Cow::Owned)
|
||||
}
|
||||
"claude:chat" | "claude:cli" => {
|
||||
normalize_claude_request_to_openai_chat_request(body_json).map(Cow::Owned)
|
||||
@@ -149,7 +157,10 @@ pub fn build_standard_upstream_url(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_standard_request_body;
|
||||
use super::{
|
||||
build_standard_request_body, build_standard_request_body_from_canonical,
|
||||
normalize_standard_request_to_openai_chat_request,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const STANDARD_SURFACES: &[&str] = &[
|
||||
@@ -251,6 +262,80 @@ mod tests {
|
||||
])
|
||||
}
|
||||
|
||||
fn legacy_openai_responses_alias_request_body(
|
||||
request: &Value,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Value {
|
||||
let chat_canonical = normalize_standard_request_to_openai_chat_request(
|
||||
request,
|
||||
"openai:cli",
|
||||
"/v1/responses",
|
||||
)
|
||||
.expect("legacy openai responses alias normalization should succeed");
|
||||
build_standard_request_body_from_canonical(
|
||||
&chat_canonical,
|
||||
"mapped-model",
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
)
|
||||
.expect("legacy openai responses alias target conversion should succeed")
|
||||
}
|
||||
|
||||
fn legacy_openai_chat_request_body(
|
||||
request: &Value,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Value {
|
||||
build_standard_request_body_from_canonical(
|
||||
request,
|
||||
"mapped-model",
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
)
|
||||
.expect("legacy openai chat target conversion should succeed")
|
||||
}
|
||||
|
||||
fn legacy_claude_request_body(
|
||||
request: &Value,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Value {
|
||||
let chat_canonical = normalize_standard_request_to_openai_chat_request(
|
||||
request,
|
||||
"claude:chat",
|
||||
"/v1/messages",
|
||||
)
|
||||
.expect("legacy claude normalization should succeed");
|
||||
build_standard_request_body_from_canonical(
|
||||
&chat_canonical,
|
||||
"mapped-model",
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
)
|
||||
.expect("legacy claude target conversion should succeed")
|
||||
}
|
||||
|
||||
fn legacy_gemini_request_body(
|
||||
request: &Value,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Value {
|
||||
let chat_canonical = normalize_standard_request_to_openai_chat_request(
|
||||
request,
|
||||
"gemini:chat",
|
||||
"/v1beta/models/source-model:generateContent",
|
||||
)
|
||||
.expect("legacy gemini normalization should succeed");
|
||||
build_standard_request_body_from_canonical(
|
||||
&chat_canonical,
|
||||
"mapped-model",
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
)
|
||||
.expect("legacy gemini target conversion should succeed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_request_body_for_all_standard_surface_pairs_in_sync_and_stream_modes() {
|
||||
for client_api_format in STANDARD_SURFACES {
|
||||
@@ -285,7 +370,357 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_codex_body_rules_for_all_standard_sources_to_openai_cli() {
|
||||
fn openai_responses_request_uses_typed_canonical_without_changing_target_payloads() {
|
||||
let request = json!({
|
||||
"model": "gpt-5",
|
||||
"instructions": "Be exact.",
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "Inspect this"},
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/png;base64,iVBORw0KGgo=",
|
||||
"detail": "high"
|
||||
},
|
||||
{
|
||||
"type": "input_file",
|
||||
"file_data": "data:application/pdf;base64,JVBERi0x",
|
||||
"filename": "spec.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_123",
|
||||
"name": "lookup",
|
||||
"arguments": "{\"q\":\"rust\"}"
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_123",
|
||||
"output": "{\"ok\":true}"
|
||||
}
|
||||
],
|
||||
"max_output_tokens": 64,
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.9,
|
||||
"parallel_tool_calls": true,
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
"description": "Lookup data",
|
||||
"parameters": {"type": "object"}
|
||||
}],
|
||||
"tool_choice": {"type": "function", "name": "lookup"},
|
||||
"reasoning": {"effort": "high"},
|
||||
"text": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "answer", "schema": {"type": "object"}}
|
||||
},
|
||||
"verbosity": "low"
|
||||
},
|
||||
"metadata": {"trace": "abc"}
|
||||
});
|
||||
|
||||
for provider_api_format in STANDARD_SURFACES {
|
||||
for upstream_is_stream in [false, true] {
|
||||
let converted = build_standard_request_body(
|
||||
&request,
|
||||
"openai:cli",
|
||||
"mapped-model",
|
||||
"custom",
|
||||
provider_api_format,
|
||||
"/v1/responses",
|
||||
upstream_is_stream,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("typed canonical route should build");
|
||||
let legacy = legacy_openai_responses_alias_request_body(
|
||||
&request,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
);
|
||||
assert_eq!(
|
||||
converted, legacy,
|
||||
"typed canonical openai:cli -> {provider_api_format} changed payload with upstream_is_stream={upstream_is_stream}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_request_uses_typed_canonical_without_changing_target_payloads() {
|
||||
let request = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [
|
||||
{"role": "system", "content": "Be exact."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Inspect this"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,iVBORw0KGgo="}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"reasoning_parts": [{
|
||||
"type": "thinking",
|
||||
"thinking": "plan",
|
||||
"signature": "sig_123"
|
||||
}],
|
||||
"tool_calls": [{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup",
|
||||
"arguments": "{\"q\":\"rust\"}"
|
||||
}
|
||||
}]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_123",
|
||||
"content": {"ok": true}
|
||||
}
|
||||
],
|
||||
"max_completion_tokens": 64,
|
||||
"temperature": 0.2,
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup",
|
||||
"description": "Lookup data",
|
||||
"parameters": {"type": "object"}
|
||||
}
|
||||
}],
|
||||
"tool_choice": {"type": "function", "function": {"name": "lookup"}},
|
||||
"reasoning_effort": "medium",
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "answer", "schema": {"type": "object"}}
|
||||
}
|
||||
});
|
||||
|
||||
for provider_api_format in STANDARD_SURFACES {
|
||||
for upstream_is_stream in [false, true] {
|
||||
let converted = build_standard_request_body(
|
||||
&request,
|
||||
"openai:chat",
|
||||
"mapped-model",
|
||||
"custom",
|
||||
provider_api_format,
|
||||
"/v1/chat/completions",
|
||||
upstream_is_stream,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("typed canonical openai chat route should build");
|
||||
let legacy = legacy_openai_chat_request_body(
|
||||
&request,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
);
|
||||
assert_eq!(
|
||||
converted, legacy,
|
||||
"typed canonical openai:chat -> {provider_api_format} changed payload with upstream_is_stream={upstream_is_stream}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_request_uses_typed_canonical_without_changing_non_claude_target_payloads() {
|
||||
let request = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"system": "Be exact.",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Inspect this"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "iVBORw0KGgo="
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "document",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "application/pdf",
|
||||
"data": "JVBERi0x"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "plan",
|
||||
"signature": "sig_123"
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_123",
|
||||
"name": "lookup",
|
||||
"input": {"q": "rust"}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_123",
|
||||
"content": {"ok": true}
|
||||
}]
|
||||
}
|
||||
],
|
||||
"max_tokens": 64,
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.9,
|
||||
"tools": [{
|
||||
"name": "lookup",
|
||||
"description": "Lookup data",
|
||||
"input_schema": {"type": "object"}
|
||||
}],
|
||||
"tool_choice": {
|
||||
"type": "tool",
|
||||
"name": "lookup",
|
||||
"disable_parallel_tool_use": false
|
||||
},
|
||||
"metadata": {"trace": "abc"},
|
||||
"thinking": {"type": "enabled", "budget_tokens": 2048}
|
||||
});
|
||||
|
||||
for provider_api_format in [
|
||||
"openai:chat",
|
||||
"openai:cli",
|
||||
"openai:compact",
|
||||
"gemini:chat",
|
||||
"gemini:cli",
|
||||
] {
|
||||
for upstream_is_stream in [false, true] {
|
||||
let converted = build_standard_request_body(
|
||||
&request,
|
||||
"claude:chat",
|
||||
"mapped-model",
|
||||
"custom",
|
||||
provider_api_format,
|
||||
"/v1/messages",
|
||||
upstream_is_stream,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("typed canonical claude route should build");
|
||||
let legacy =
|
||||
legacy_claude_request_body(&request, provider_api_format, upstream_is_stream);
|
||||
assert_eq!(
|
||||
converted, legacy,
|
||||
"typed canonical claude:chat -> {provider_api_format} changed payload with upstream_is_stream={upstream_is_stream}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_request_uses_typed_canonical_without_changing_non_gemini_target_payloads() {
|
||||
let request = json!({
|
||||
"systemInstruction": {
|
||||
"parts": [{"text": "Be exact."}]
|
||||
},
|
||||
"contents": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"text": "Inspect this"},
|
||||
{"inlineData": {"mimeType": "image/png", "data": "iVBORw0KGgo="}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"text": "plan", "thought": true, "thoughtSignature": "sig_123"},
|
||||
{"functionCall": {"id": "call_123", "name": "lookup", "args": {"q": "rust"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [{
|
||||
"functionResponse": {
|
||||
"id": "call_123",
|
||||
"name": "lookup",
|
||||
"response": {"result": {"ok": true}}
|
||||
}
|
||||
}]
|
||||
}
|
||||
],
|
||||
"generationConfig": {
|
||||
"maxOutputTokens": 64,
|
||||
"temperature": 0.2,
|
||||
"thinkingConfig": {"includeThoughts": true, "thinkingBudget": 2048}
|
||||
},
|
||||
"tools": [{
|
||||
"functionDeclarations": [{
|
||||
"name": "lookup",
|
||||
"description": "Lookup data",
|
||||
"parameters": {"type": "object"}
|
||||
}]
|
||||
}],
|
||||
"toolConfig": {
|
||||
"functionCallingConfig": {
|
||||
"mode": "ANY",
|
||||
"allowedFunctionNames": ["lookup"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for provider_api_format in [
|
||||
"openai:chat",
|
||||
"openai:cli",
|
||||
"openai:compact",
|
||||
"claude:chat",
|
||||
"claude:cli",
|
||||
] {
|
||||
for upstream_is_stream in [false, true] {
|
||||
let converted = build_standard_request_body(
|
||||
&request,
|
||||
"gemini:chat",
|
||||
"mapped-model",
|
||||
"custom",
|
||||
provider_api_format,
|
||||
"/v1beta/models/source-model:generateContent",
|
||||
upstream_is_stream,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("typed canonical gemini route should build");
|
||||
let legacy =
|
||||
legacy_gemini_request_body(&request, provider_api_format, upstream_is_stream);
|
||||
assert_eq!(
|
||||
converted, legacy,
|
||||
"typed canonical gemini:chat -> {provider_api_format} changed payload with upstream_is_stream={upstream_is_stream}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_codex_body_rules_for_all_standard_sources_to_openai_responses() {
|
||||
let body_rules = codex_default_body_rules();
|
||||
|
||||
for client_api_format in STANDARD_SURFACES {
|
||||
|
||||
@@ -4,11 +4,17 @@ pub mod family;
|
||||
pub mod gemini;
|
||||
pub mod matrix;
|
||||
pub mod normalize;
|
||||
pub mod openai_cli;
|
||||
pub mod openai_responses;
|
||||
|
||||
#[allow(deprecated)]
|
||||
pub use codex::apply_openai_compact_special_body_edits;
|
||||
#[allow(deprecated)]
|
||||
pub use codex::{
|
||||
apply_codex_openai_cli_special_body_edits, apply_codex_openai_cli_special_headers,
|
||||
apply_openai_compact_special_body_edits, CODEX_OPENAI_IMAGE_DEFAULT_MODEL,
|
||||
};
|
||||
pub use codex::{
|
||||
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
|
||||
apply_openai_responses_compact_special_body_edits, CODEX_OPENAI_IMAGE_DEFAULT_MODEL,
|
||||
CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT, CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_MODEL,
|
||||
CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_PROMPT, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
|
||||
};
|
||||
@@ -18,6 +24,6 @@ pub use matrix::{
|
||||
normalize_standard_request_to_openai_chat_request,
|
||||
};
|
||||
pub use normalize::{
|
||||
build_cross_format_openai_chat_request_body, build_cross_format_openai_cli_request_body,
|
||||
build_local_openai_chat_request_body, build_local_openai_cli_request_body,
|
||||
build_cross_format_openai_chat_request_body, build_cross_format_openai_responses_request_body,
|
||||
build_local_openai_chat_request_body, build_local_openai_responses_request_body,
|
||||
};
|
||||
|
||||
@@ -2,8 +2,8 @@ use serde_json::{json, Value};
|
||||
|
||||
use crate::conversion::request::{
|
||||
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
|
||||
convert_openai_chat_request_to_openai_cli_request,
|
||||
normalize_openai_cli_request_to_openai_chat_request,
|
||||
convert_openai_chat_request_to_openai_responses_request,
|
||||
normalize_openai_responses_request_to_openai_chat_request,
|
||||
};
|
||||
use crate::conversion::{request_conversion_kind, RequestConversionKind};
|
||||
|
||||
@@ -56,8 +56,8 @@ pub fn build_cross_format_openai_chat_request_body(
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
),
|
||||
RequestConversionKind::ToOpenAIFamilyCli => {
|
||||
convert_openai_chat_request_to_openai_cli_request(
|
||||
RequestConversionKind::ToOpenAiResponses => {
|
||||
convert_openai_chat_request_to_openai_responses_request(
|
||||
body_json,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
@@ -68,7 +68,7 @@ pub fn build_cross_format_openai_chat_request_body(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_local_openai_cli_request_body(
|
||||
pub fn build_local_openai_responses_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
require_streaming: bool,
|
||||
@@ -86,14 +86,14 @@ pub fn build_local_openai_cli_request_body(
|
||||
Some(Value::Object(provider_request_body))
|
||||
}
|
||||
|
||||
pub fn build_cross_format_openai_cli_request_body(
|
||||
pub fn build_cross_format_openai_responses_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
let chat_like_request = normalize_openai_cli_request_to_openai_chat_request(body_json)?;
|
||||
let chat_like_request = normalize_openai_responses_request_to_openai_chat_request(body_json)?;
|
||||
let conversion_kind = request_conversion_kind(client_api_format, provider_api_format)?;
|
||||
match conversion_kind {
|
||||
RequestConversionKind::ToOpenAIChat => build_local_openai_chat_request_body(
|
||||
@@ -101,8 +101,8 @@ pub fn build_cross_format_openai_cli_request_body(
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
),
|
||||
RequestConversionKind::ToOpenAIFamilyCli => {
|
||||
convert_openai_chat_request_to_openai_cli_request(
|
||||
RequestConversionKind::ToOpenAiResponses => {
|
||||
convert_openai_chat_request_to_openai_responses_request(
|
||||
&chat_like_request,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
@@ -124,8 +124,10 @@ pub fn build_cross_format_openai_cli_request_body(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_local_openai_cli_request_body;
|
||||
use super::{build_cross_format_openai_cli_request_body, build_local_openai_chat_request_body};
|
||||
use super::build_local_openai_responses_request_body;
|
||||
use super::{
|
||||
build_cross_format_openai_responses_request_body, build_local_openai_chat_request_body,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn object_keys(value: &Value) -> Vec<&str> {
|
||||
@@ -138,20 +140,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_openai_chat_cross_format_request_body_from_openai_cli_source() {
|
||||
fn builds_openai_chat_cross_format_request_body_from_openai_responses_source() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"input": "hello",
|
||||
});
|
||||
|
||||
let provider_request_body = build_cross_format_openai_cli_request_body(
|
||||
let provider_request_body = build_cross_format_openai_responses_request_body(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
"openai:cli",
|
||||
"openai:responses",
|
||||
"openai:chat",
|
||||
false,
|
||||
)
|
||||
.expect("openai cli to openai chat body should build");
|
||||
.expect("openai responses to openai chat body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["messages"][0]["role"], "user");
|
||||
@@ -159,7 +161,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_cli_request_body_preserves_original_field_order() {
|
||||
fn local_openai_responses_request_body_preserves_original_field_order() {
|
||||
let body_json: Value = serde_json::from_str(
|
||||
r#"{
|
||||
"model": "gpt-5",
|
||||
@@ -171,8 +173,8 @@ mod tests {
|
||||
.expect("request json should parse");
|
||||
|
||||
let provider_request_body =
|
||||
build_local_openai_cli_request_body(&body_json, "gpt-5-upstream", false)
|
||||
.expect("openai cli body should build");
|
||||
build_local_openai_responses_request_body(&body_json, "gpt-5-upstream", false)
|
||||
.expect("openai responses body should build");
|
||||
|
||||
assert_eq!(
|
||||
object_keys(&provider_request_body),
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
use crate::contracts::{
|
||||
OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
|
||||
OPENAI_COMPACT_SYNC_PLAN_KIND,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LocalOpenAiCliSpec {
|
||||
pub api_format: &'static str,
|
||||
pub decision_kind: &'static str,
|
||||
pub report_kind: &'static str,
|
||||
pub compact: bool,
|
||||
pub require_streaming: bool,
|
||||
}
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalOpenAiCliSpec> {
|
||||
match plan_kind {
|
||||
OPENAI_CLI_SYNC_PLAN_KIND => Some(LocalOpenAiCliSpec {
|
||||
api_format: "openai:cli",
|
||||
decision_kind: OPENAI_CLI_SYNC_PLAN_KIND,
|
||||
report_kind: "openai_cli_sync_success",
|
||||
compact: false,
|
||||
require_streaming: false,
|
||||
}),
|
||||
OPENAI_COMPACT_SYNC_PLAN_KIND => Some(LocalOpenAiCliSpec {
|
||||
api_format: "openai:compact",
|
||||
decision_kind: OPENAI_COMPACT_SYNC_PLAN_KIND,
|
||||
report_kind: "openai_cli_sync_success",
|
||||
compact: true,
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalOpenAiCliSpec> {
|
||||
match plan_kind {
|
||||
OPENAI_CLI_STREAM_PLAN_KIND => Some(LocalOpenAiCliSpec {
|
||||
api_format: "openai:cli",
|
||||
decision_kind: OPENAI_CLI_STREAM_PLAN_KIND,
|
||||
report_kind: "openai_cli_stream_success",
|
||||
compact: false,
|
||||
require_streaming: true,
|
||||
}),
|
||||
OPENAI_COMPACT_STREAM_PLAN_KIND => Some(LocalOpenAiCliSpec {
|
||||
api_format: "openai:compact",
|
||||
decision_kind: OPENAI_COMPACT_STREAM_PLAN_KIND,
|
||||
report_kind: "openai_cli_stream_success",
|
||||
compact: true,
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_spec, resolve_sync_spec};
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_cli_sync_spec() {
|
||||
let spec = resolve_sync_spec("openai_cli_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:cli");
|
||||
assert_eq!(spec.report_kind, "openai_cli_sync_success");
|
||||
assert!(!spec.compact);
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_compact_stream_spec() {
|
||||
let spec = resolve_stream_spec("openai_compact_stream").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:compact");
|
||||
assert_eq!(spec.report_kind, "openai_cli_stream_success");
|
||||
assert!(spec.compact);
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use crate::contracts::{
|
||||
OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
|
||||
OPENAI_COMPACT_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_STREAM_PLAN_KIND,
|
||||
OPENAI_RESPONSES_STREAM_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_SYNC_PLAN_KIND,
|
||||
OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LocalOpenAiResponsesSpec {
|
||||
pub api_format: &'static str,
|
||||
pub decision_kind: &'static str,
|
||||
pub report_kind: &'static str,
|
||||
pub compact: bool,
|
||||
pub require_streaming: bool,
|
||||
}
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalOpenAiResponsesSpec> {
|
||||
match plan_kind {
|
||||
OPENAI_RESPONSES_SYNC_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
|
||||
api_format: "openai:responses",
|
||||
decision_kind: OPENAI_RESPONSES_SYNC_PLAN_KIND,
|
||||
report_kind: OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND,
|
||||
compact: false,
|
||||
require_streaming: false,
|
||||
}),
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
|
||||
api_format: "openai:responses:compact",
|
||||
decision_kind: OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND,
|
||||
report_kind: OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND,
|
||||
compact: true,
|
||||
require_streaming: false,
|
||||
}),
|
||||
OPENAI_CLI_SYNC_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
|
||||
api_format: "openai:responses",
|
||||
decision_kind: OPENAI_CLI_SYNC_PLAN_KIND,
|
||||
report_kind: "openai_cli_sync_success",
|
||||
compact: false,
|
||||
require_streaming: false,
|
||||
}),
|
||||
OPENAI_COMPACT_SYNC_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
|
||||
api_format: "openai:responses:compact",
|
||||
decision_kind: OPENAI_COMPACT_SYNC_PLAN_KIND,
|
||||
report_kind: OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND,
|
||||
compact: true,
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalOpenAiResponsesSpec> {
|
||||
match plan_kind {
|
||||
OPENAI_RESPONSES_STREAM_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
|
||||
api_format: "openai:responses",
|
||||
decision_kind: OPENAI_RESPONSES_STREAM_PLAN_KIND,
|
||||
report_kind: OPENAI_RESPONSES_STREAM_SUCCESS_REPORT_KIND,
|
||||
compact: false,
|
||||
require_streaming: true,
|
||||
}),
|
||||
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
|
||||
api_format: "openai:responses:compact",
|
||||
decision_kind: OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND,
|
||||
report_kind: OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND,
|
||||
compact: true,
|
||||
require_streaming: true,
|
||||
}),
|
||||
OPENAI_CLI_STREAM_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
|
||||
api_format: "openai:responses",
|
||||
decision_kind: OPENAI_CLI_STREAM_PLAN_KIND,
|
||||
report_kind: "openai_cli_stream_success",
|
||||
compact: false,
|
||||
require_streaming: true,
|
||||
}),
|
||||
OPENAI_COMPACT_STREAM_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
|
||||
api_format: "openai:responses:compact",
|
||||
decision_kind: OPENAI_COMPACT_STREAM_PLAN_KIND,
|
||||
report_kind: OPENAI_RESPONSES_STREAM_SUCCESS_REPORT_KIND,
|
||||
compact: true,
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_spec, resolve_sync_spec};
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_responses_sync_spec() {
|
||||
let spec = resolve_sync_spec("openai_responses_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:responses");
|
||||
assert_eq!(spec.report_kind, "openai_responses_sync_success");
|
||||
assert!(!spec.compact);
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_legacy_openai_cli_sync_spec() {
|
||||
let spec = resolve_sync_spec("openai_cli_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:responses");
|
||||
assert_eq!(spec.report_kind, "openai_cli_sync_success");
|
||||
assert!(!spec.compact);
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_compact_stream_spec() {
|
||||
let spec = resolve_stream_spec("openai_responses_compact_stream").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:responses:compact");
|
||||
assert_eq!(spec.report_kind, "openai_responses_compact_stream_success");
|
||||
assert!(spec.compact);
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_legacy_openai_compact_stream_spec() {
|
||||
let spec = resolve_stream_spec("openai_compact_stream").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:responses:compact");
|
||||
assert_eq!(spec.report_kind, "openai_responses_stream_success");
|
||||
assert!(spec.compact);
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
@@ -132,10 +132,16 @@ pub struct ExecutionStreamTerminalSummary {
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub observed_finish: bool,
|
||||
#[serde(default, skip_serializing_if = "is_zero_u64")]
|
||||
pub unknown_event_count: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parser_error: Option<String>,
|
||||
}
|
||||
|
||||
fn is_zero_u64(value: &u64) -> bool {
|
||||
*value == 0
|
||||
}
|
||||
|
||||
fn as_i64(value: &serde_json::Value, default: i64) -> i64 {
|
||||
value
|
||||
.as_i64()
|
||||
@@ -149,7 +155,7 @@ fn as_f64(value: &serde_json::Value, default: f64) -> f64 {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::StandardizedUsage;
|
||||
use super::{ExecutionStreamTerminalSummary, StandardizedUsage};
|
||||
|
||||
#[test]
|
||||
fn standardized_usage_prefers_more_complete_candidate() {
|
||||
@@ -193,4 +199,18 @@ mod tests {
|
||||
Some(serde_json::json!("value"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_terminal_summary_skips_zero_unknown_event_count() {
|
||||
let default_summary =
|
||||
serde_json::to_value(ExecutionStreamTerminalSummary::default()).expect("serialize");
|
||||
assert!(default_summary.get("unknown_event_count").is_none());
|
||||
|
||||
let summary = ExecutionStreamTerminalSummary {
|
||||
unknown_event_count: 2,
|
||||
..ExecutionStreamTerminalSummary::default()
|
||||
};
|
||||
let encoded = serde_json::to_value(summary).expect("serialize");
|
||||
assert_eq!(encoded["unknown_event_count"], 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,16 +48,27 @@ impl StoredMinimalCandidateSelectionRow {
|
||||
}
|
||||
|
||||
pub fn key_supports_api_format(&self, api_format: &str) -> bool {
|
||||
let target = api_format.trim();
|
||||
match self.key_api_formats.as_deref() {
|
||||
None => true,
|
||||
Some(formats) => formats
|
||||
.iter()
|
||||
.any(|value| value.eq_ignore_ascii_case(target)),
|
||||
.any(|value| api_format_matches(value, api_format)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_api_format(value: &str) -> String {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"openai:cli" => "openai:responses".to_string(),
|
||||
"openai:compact" => "openai:responses:compact".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn api_format_matches(left: &str, right: &str) -> bool {
|
||||
normalize_api_format(left) == normalize_api_format(right)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait MinimalCandidateSelectionReadRepository: Send + Sync {
|
||||
async fn list_for_exact_api_format(
|
||||
|
||||
@@ -39,7 +39,7 @@ impl MinimalCandidateSelectionReadRepository for InMemoryMinimalCandidateSelecti
|
||||
&& row.key_is_active
|
||||
&& row.model_is_active
|
||||
&& row.model_is_available
|
||||
&& row.endpoint_api_format.eq_ignore_ascii_case(api_format)
|
||||
&& api_format_matches(&row.endpoint_api_format, api_format)
|
||||
&& row.key_supports_api_format(api_format)
|
||||
})
|
||||
.cloned()
|
||||
@@ -69,6 +69,18 @@ impl MinimalCandidateSelectionReadRepository for InMemoryMinimalCandidateSelecti
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_api_format(value: &str) -> String {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"openai:cli" => "openai:responses".to_string(),
|
||||
"openai:compact" => "openai:responses:compact".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn api_format_matches(left: &str, right: &str) -> bool {
|
||||
normalize_api_format(left) == normalize_api_format(right)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::InMemoryMinimalCandidateSelectionReadRepository;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
use futures_util::{stream::TryStream, TryStreamExt};
|
||||
use sqlx::{PgPool, Row};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::{
|
||||
MinimalCandidateSelectionReadRepository, StoredMinimalCandidateSelectionRow,
|
||||
@@ -226,13 +227,19 @@ impl SqlxMinimalCandidateSelectionReadRepository {
|
||||
&self,
|
||||
api_format: &str,
|
||||
) -> Result<Vec<StoredMinimalCandidateSelectionRow>, DataLayerError> {
|
||||
Self::collect_query_rows(
|
||||
sqlx::query(LIST_FOR_EXACT_API_FORMAT_SQL)
|
||||
.bind(api_format)
|
||||
.fetch(&self.pool),
|
||||
map_candidate_selection_row,
|
||||
)
|
||||
.await
|
||||
let mut rows = Vec::new();
|
||||
for api_format in api_format_aliases(api_format) {
|
||||
rows.extend(
|
||||
Self::collect_query_rows(
|
||||
sqlx::query(LIST_FOR_EXACT_API_FORMAT_SQL)
|
||||
.bind(api_format)
|
||||
.fetch(&self.pool),
|
||||
map_candidate_selection_row,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
Ok(dedupe_candidate_selection_rows(rows))
|
||||
}
|
||||
|
||||
pub async fn list_for_exact_api_format_and_global_model(
|
||||
@@ -240,17 +247,48 @@ impl SqlxMinimalCandidateSelectionReadRepository {
|
||||
api_format: &str,
|
||||
global_model_name: &str,
|
||||
) -> Result<Vec<StoredMinimalCandidateSelectionRow>, DataLayerError> {
|
||||
Self::collect_query_rows(
|
||||
sqlx::query(LIST_FOR_EXACT_API_FORMAT_AND_GLOBAL_MODEL_SQL)
|
||||
.bind(api_format)
|
||||
.bind(global_model_name)
|
||||
.fetch(&self.pool),
|
||||
map_candidate_selection_row,
|
||||
)
|
||||
.await
|
||||
let mut rows = Vec::new();
|
||||
for api_format in api_format_aliases(api_format) {
|
||||
rows.extend(
|
||||
Self::collect_query_rows(
|
||||
sqlx::query(LIST_FOR_EXACT_API_FORMAT_AND_GLOBAL_MODEL_SQL)
|
||||
.bind(api_format)
|
||||
.bind(global_model_name)
|
||||
.fetch(&self.pool),
|
||||
map_candidate_selection_row,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
Ok(dedupe_candidate_selection_rows(rows))
|
||||
}
|
||||
}
|
||||
|
||||
fn api_format_aliases(api_format: &str) -> Vec<&str> {
|
||||
match api_format.trim().to_ascii_lowercase().as_str() {
|
||||
"openai:responses" => vec!["openai:responses", "openai:cli"],
|
||||
"openai:cli" => vec!["openai:responses", "openai:cli"],
|
||||
"openai:responses:compact" => vec!["openai:responses:compact", "openai:compact"],
|
||||
"openai:compact" => vec!["openai:responses:compact", "openai:compact"],
|
||||
_ => vec![api_format],
|
||||
}
|
||||
}
|
||||
|
||||
fn dedupe_candidate_selection_rows(
|
||||
rows: Vec<StoredMinimalCandidateSelectionRow>,
|
||||
) -> Vec<StoredMinimalCandidateSelectionRow> {
|
||||
let mut seen = BTreeSet::new();
|
||||
rows.into_iter()
|
||||
.filter(|row| {
|
||||
seen.insert((
|
||||
row.endpoint_id.clone(),
|
||||
row.key_id.clone(),
|
||||
row.model_id.clone(),
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MinimalCandidateSelectionReadRepository for SqlxMinimalCandidateSelectionReadRepository {
|
||||
async fn list_for_exact_api_format(
|
||||
|
||||
@@ -595,7 +595,7 @@ mod tests {
|
||||
latency_ms: Some(25),
|
||||
concurrent_requests: Some(2),
|
||||
extra_data: Some(json!({
|
||||
"provider_api_format": "openai:cli",
|
||||
"provider_api_format": "openai:responses",
|
||||
"provider_name": "updated",
|
||||
})),
|
||||
required_capabilities: None,
|
||||
@@ -621,7 +621,7 @@ mod tests {
|
||||
.extra_data
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("provider_api_format")),
|
||||
Some(&json!("openai:cli"))
|
||||
Some(&json!("openai:responses"))
|
||||
);
|
||||
assert_eq!(
|
||||
updated
|
||||
|
||||
@@ -7,7 +7,11 @@ use regex::Regex;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const MODEL_FETCH_FORMAT_PRIORITY: &[&[&str]] = &[
|
||||
&["openai:chat", "openai:cli", "openai:compact"],
|
||||
&[
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
"openai:responses:compact",
|
||||
],
|
||||
&["claude:chat", "claude:cli"],
|
||||
&["gemini:chat", "gemini:cli"],
|
||||
];
|
||||
@@ -180,9 +184,15 @@ pub fn selected_models_fetch_endpoints(
|
||||
if !key_formats.is_empty() && !key_formats.contains(&api_format) {
|
||||
continue;
|
||||
}
|
||||
by_format
|
||||
.entry(api_format)
|
||||
.or_insert_with(|| endpoint.clone());
|
||||
if let Some(existing) = by_format.get_mut(&api_format) {
|
||||
if endpoint.api_format.trim().eq_ignore_ascii_case(&api_format)
|
||||
&& !existing.api_format.trim().eq_ignore_ascii_case(&api_format)
|
||||
{
|
||||
*existing = endpoint.clone();
|
||||
}
|
||||
} else {
|
||||
by_format.insert(api_format, endpoint.clone());
|
||||
}
|
||||
}
|
||||
|
||||
MODEL_FETCH_FORMAT_PRIORITY
|
||||
@@ -209,8 +219,8 @@ pub fn endpoint_supports_rust_models_fetch(api_format: &str) -> bool {
|
||||
matches!(
|
||||
api_format.as_str(),
|
||||
"openai:chat"
|
||||
| "openai:cli"
|
||||
| "openai:compact"
|
||||
| "openai:responses"
|
||||
| "openai:responses:compact"
|
||||
| "claude:chat"
|
||||
| "claude:cli"
|
||||
| "gemini:chat"
|
||||
@@ -250,7 +260,7 @@ pub fn preset_models_for_provider(provider_type: &str) -> Option<Vec<Value>> {
|
||||
preset_model("claude-haiku-4-5-20251001", "anthropic", "Claude Haiku 4.5", "claude:cli"),
|
||||
],
|
||||
"codex" => vec![
|
||||
preset_model("gpt-5", "openai", "GPT-5", "openai:cli"),
|
||||
preset_model("gpt-5", "openai", "GPT-5", "openai:responses"),
|
||||
preset_model("gpt-image-1", "openai", "GPT Image 1", "openai:image"),
|
||||
preset_model("gpt-image-1.5", "openai", "GPT Image 1.5", "openai:image"),
|
||||
preset_model("gpt-image-1-mini", "openai", "GPT Image 1 Mini", "openai:image"),
|
||||
@@ -258,16 +268,26 @@ pub fn preset_models_for_provider(provider_type: &str) -> Option<Vec<Value>> {
|
||||
preset_model("chatgpt-image-latest", "openai", "ChatGPT Image Latest", "openai:image"),
|
||||
preset_model("dall-e-2", "openai", "DALL-E 2", "openai:image"),
|
||||
preset_model("dall-e-3", "openai", "DALL-E 3", "openai:image"),
|
||||
preset_model("gpt-5-codex", "openai", "GPT-5 Codex", "openai:cli"),
|
||||
preset_model("gpt-5-codex-mini", "openai", "GPT-5 Codex Mini", "openai:cli"),
|
||||
preset_model("gpt-5.1", "openai", "GPT-5.1", "openai:cli"),
|
||||
preset_model("gpt-5.1-codex", "openai", "GPT-5.1 Codex", "openai:cli"),
|
||||
preset_model("gpt-5.1-codex-mini", "openai", "GPT-5.1 Codex Mini", "openai:cli"),
|
||||
preset_model("gpt-5.1-codex-max", "openai", "GPT-5.1 Codex Max", "openai:cli"),
|
||||
preset_model("gpt-5.2", "openai", "GPT-5.2", "openai:cli"),
|
||||
preset_model("gpt-5.2-codex", "openai", "GPT-5.2 Codex", "openai:cli"),
|
||||
preset_model("gpt-5.3-codex", "openai", "GPT-5.3 Codex", "openai:cli"),
|
||||
preset_model("gpt-5.4", "openai", "GPT-5.4", "openai:cli"),
|
||||
preset_model("gpt-5-codex", "openai", "GPT-5 Codex", "openai:responses"),
|
||||
preset_model("gpt-5-codex-mini", "openai", "GPT-5 Codex Mini", "openai:responses"),
|
||||
preset_model("gpt-5.1", "openai", "GPT-5.1", "openai:responses"),
|
||||
preset_model("gpt-5.1-codex", "openai", "GPT-5.1 Codex", "openai:responses"),
|
||||
preset_model(
|
||||
"gpt-5.1-codex-mini",
|
||||
"openai",
|
||||
"GPT-5.1 Codex Mini",
|
||||
"openai:responses",
|
||||
),
|
||||
preset_model(
|
||||
"gpt-5.1-codex-max",
|
||||
"openai",
|
||||
"GPT-5.1 Codex Max",
|
||||
"openai:responses",
|
||||
),
|
||||
preset_model("gpt-5.2", "openai", "GPT-5.2", "openai:responses"),
|
||||
preset_model("gpt-5.2-codex", "openai", "GPT-5.2 Codex", "openai:responses"),
|
||||
preset_model("gpt-5.3-codex", "openai", "GPT-5.3 Codex", "openai:responses"),
|
||||
preset_model("gpt-5.4", "openai", "GPT-5.4", "openai:responses"),
|
||||
],
|
||||
_ => return None,
|
||||
};
|
||||
@@ -550,7 +570,11 @@ fn wildcard_matches(pattern: &str, model_id: &str) -> bool {
|
||||
}
|
||||
|
||||
fn normalize_api_format(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"openai:cli" => "openai:responses".to_string(),
|
||||
"openai:compact" => "openai:responses:compact".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -644,7 +668,7 @@ mod tests {
|
||||
fn aggregate_models_for_cache_merges_api_formats_and_sorts_by_model_id() {
|
||||
let aggregated = aggregate_models_for_cache(&[
|
||||
json!({"id":"zeta","api_formats":["openai:chat"]}),
|
||||
json!({"id":"alpha","api_formats":["openai:cli"]}),
|
||||
json!({"id":"alpha","api_formats":["openai:responses"]}),
|
||||
json!({"id":"alpha","api_formats":["openai:chat"]}),
|
||||
]);
|
||||
assert_eq!(aggregated.len(), 2);
|
||||
@@ -652,7 +676,7 @@ mod tests {
|
||||
assert_eq!(aggregated[1]["id"], "zeta");
|
||||
assert_eq!(
|
||||
aggregated[0]["api_formats"],
|
||||
json!(["openai:chat", "openai:cli"])
|
||||
json!(["openai:chat", "openai:responses"])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -679,10 +703,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_models_fetch_url_excludes_openai_responses() {
|
||||
fn build_models_fetch_url_supports_openai_responses() {
|
||||
assert_eq!(
|
||||
build_models_fetch_url("openai", "openai:responses", "https://example.com"),
|
||||
None
|
||||
Some((
|
||||
"https://example.com/v1/models".to_string(),
|
||||
"openai:responses".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -716,7 +743,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_models_fetch_endpoints_prefers_chat_and_excludes_responses() {
|
||||
fn selected_models_fetch_endpoints_prefers_chat_then_responses() {
|
||||
let key = sample_key("provider-1", "key-1", &["openai:chat", "openai:responses"]);
|
||||
let endpoints = vec![
|
||||
sample_endpoint(
|
||||
@@ -741,6 +768,25 @@ mod tests {
|
||||
let selected = selected_models_fetch_endpoints(&endpoints, &key);
|
||||
assert_eq!(selected.len(), 1);
|
||||
assert_eq!(selected[0].id, "endpoint-chat");
|
||||
|
||||
let key = sample_key("provider-1", "key-1", &["openai:responses"]);
|
||||
let endpoints = vec![
|
||||
sample_endpoint(
|
||||
"provider-1",
|
||||
"endpoint-cli",
|
||||
"openai:cli",
|
||||
"https://example.com",
|
||||
),
|
||||
sample_endpoint(
|
||||
"provider-1",
|
||||
"endpoint-responses",
|
||||
"openai:responses",
|
||||
"https://example.com",
|
||||
),
|
||||
];
|
||||
let selected = selected_models_fetch_endpoints(&endpoints, &key);
|
||||
assert_eq!(selected.len(), 1);
|
||||
assert_eq!(selected[0].id, "endpoint-responses");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,7 +19,7 @@ use serde_json::json;
|
||||
|
||||
use crate::build_models_fetch_url;
|
||||
|
||||
const OPENAI_CLI_USER_AGENT: &str = "openai-codex/1.0";
|
||||
const OPENAI_RESPONSES_USER_AGENT: &str = "openai-codex/1.0";
|
||||
const CLAUDE_CLI_USER_AGENT: &str = "claude-code/1.0.1";
|
||||
const GEMINI_CLI_USER_AGENT: &str = "GeminiCLI/0.1.5 (Windows; AMD64)";
|
||||
const CLAUDE_VERSION_HEADER: &str = "2023-06-01";
|
||||
@@ -383,8 +383,11 @@ fn apply_fetch_header_rules(
|
||||
fn standard_models_fetch_headers(api_format: &str) -> BTreeMap<String, String> {
|
||||
let api_format = api_format.trim().to_ascii_lowercase();
|
||||
match api_format.as_str() {
|
||||
"openai:cli" | "openai:compact" => {
|
||||
BTreeMap::from([("user-agent".to_string(), OPENAI_CLI_USER_AGENT.to_string())])
|
||||
"openai:responses" | "openai:responses:compact" | "openai:cli" | "openai:compact" => {
|
||||
BTreeMap::from([(
|
||||
"user-agent".to_string(),
|
||||
OPENAI_RESPONSES_USER_AGENT.to_string(),
|
||||
)])
|
||||
}
|
||||
"claude:chat" => BTreeMap::from([(
|
||||
"anthropic-version".to_string(),
|
||||
@@ -577,12 +580,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builds_openai_cli_models_fetch_plan_with_cli_user_agent() {
|
||||
async fn builds_openai_responses_models_fetch_plan_with_codex_user_agent() {
|
||||
let runtime = TestRuntime {
|
||||
oauth_auth: None,
|
||||
proxy: None,
|
||||
};
|
||||
let mut transport = sample_transport("openai", "openai:cli", "api_key");
|
||||
let mut transport = sample_transport("openai", "openai:responses", "api_key");
|
||||
transport.key.decrypted_auth_config = None;
|
||||
let plan = build_models_fetch_execution_plan(&runtime, &transport)
|
||||
.await
|
||||
@@ -600,12 +603,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builds_openai_compact_models_fetch_plan_with_bearer_authorization() {
|
||||
async fn builds_openai_responses_compact_models_fetch_plan_with_bearer_authorization() {
|
||||
let runtime = TestRuntime {
|
||||
oauth_auth: None,
|
||||
proxy: None,
|
||||
};
|
||||
let mut transport = sample_transport("openai", "openai:compact", "api_key");
|
||||
let mut transport = sample_transport("openai", "openai:responses:compact", "api_key");
|
||||
transport.key.decrypted_auth_config = None;
|
||||
let plan = build_models_fetch_execution_plan(&runtime, &transport)
|
||||
.await
|
||||
|
||||
@@ -7,6 +7,7 @@ repository.workspace = true
|
||||
description = "Provider transport core extracted from aether-gateway"
|
||||
|
||||
[dependencies]
|
||||
aether-ai-formats.workspace = true
|
||||
aether-contracts.workspace = true
|
||||
aether-crypto.workspace = true
|
||||
aether-data.workspace = true
|
||||
|
||||
@@ -148,12 +148,7 @@ fn local_same_format_transport_unsupported_reason(
|
||||
Some("key_inactive")
|
||||
};
|
||||
}
|
||||
if !transport
|
||||
.endpoint
|
||||
.api_format
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(api_format.trim())
|
||||
{
|
||||
if !same_api_format(&transport.endpoint.api_format, api_format) {
|
||||
return Some("transport_api_format_mismatch");
|
||||
}
|
||||
if !header_rules_are_locally_supported(transport.endpoint.header_rules.as_ref()) {
|
||||
@@ -203,3 +198,15 @@ fn local_same_format_transport_unsupported_reason(
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn same_api_format(left: &str, right: &str) -> bool {
|
||||
normalize_api_format_alias(left) == normalize_api_format_alias(right)
|
||||
}
|
||||
|
||||
fn normalize_api_format_alias(value: &str) -> String {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"openai:cli" => "openai:responses".to_string(),
|
||||
"openai:compact" => "openai:responses:compact".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,14 +75,14 @@ const CODEX_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTempla
|
||||
base_url: "https://chatgpt.com/backend-api/codex",
|
||||
endpoints: &[
|
||||
FixedProviderEndpointTemplate {
|
||||
item_key: "openai:cli",
|
||||
api_format: "openai:cli",
|
||||
item_key: "openai:responses",
|
||||
api_format: "openai:responses",
|
||||
custom_path: None,
|
||||
config_defaults: FORCE_STREAM_ENDPOINT_CONFIG_DEFAULTS,
|
||||
},
|
||||
FixedProviderEndpointTemplate {
|
||||
item_key: "openai:compact",
|
||||
api_format: "openai:compact",
|
||||
item_key: "openai:responses:compact",
|
||||
api_format: "openai:responses:compact",
|
||||
custom_path: None,
|
||||
config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS,
|
||||
},
|
||||
@@ -181,7 +181,11 @@ pub fn fixed_provider_endpoint_template_by_api_format(
|
||||
provider_type: &str,
|
||||
api_format: &str,
|
||||
) -> Option<&'static FixedProviderEndpointTemplate> {
|
||||
let normalized = api_format.trim();
|
||||
let normalized = match api_format.trim().to_ascii_lowercase().as_str() {
|
||||
"openai:cli" => "openai:responses",
|
||||
"openai:compact" => "openai:responses:compact",
|
||||
_ => api_format.trim(),
|
||||
};
|
||||
fixed_provider_template(provider_type)?
|
||||
.endpoints
|
||||
.iter()
|
||||
@@ -299,7 +303,11 @@ mod tests {
|
||||
.iter()
|
||||
.map(|item| item.api_format)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["openai:cli", "openai:compact", "openai:image"]
|
||||
vec![
|
||||
"openai:responses",
|
||||
"openai:responses:compact",
|
||||
"openai:image"
|
||||
]
|
||||
);
|
||||
|
||||
let image_template =
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::claude_code::build_claude_code_messages_url;
|
||||
use crate::snapshot::GatewayProviderTransportSnapshot;
|
||||
use crate::url::{
|
||||
build_claude_messages_url, build_gemini_content_url, build_openai_chat_url,
|
||||
build_openai_cli_url, build_passthrough_path_url,
|
||||
build_openai_responses_url, build_passthrough_path_url,
|
||||
};
|
||||
use crate::vertex::{
|
||||
build_vertex_api_key_gemini_content_url, resolve_local_vertex_api_key_query_auth,
|
||||
@@ -65,12 +65,12 @@ pub fn build_transport_request_url(
|
||||
&transport.endpoint.base_url,
|
||||
params.request_query,
|
||||
)),
|
||||
"openai:cli" => Some(build_openai_cli_url(
|
||||
"openai:responses" | "openai:cli" => Some(build_openai_responses_url(
|
||||
&transport.endpoint.base_url,
|
||||
params.request_query,
|
||||
false,
|
||||
)),
|
||||
"openai:compact" => Some(build_openai_cli_url(
|
||||
"openai:responses:compact" | "openai:compact" => Some(build_openai_responses_url(
|
||||
&transport.endpoint.base_url,
|
||||
params.request_query,
|
||||
true,
|
||||
@@ -346,6 +346,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_openai_responses_url_for_formal_format_name() {
|
||||
let transport = sample_transport(
|
||||
"openai",
|
||||
"openai:responses",
|
||||
"https://api.openai.example/v1",
|
||||
None,
|
||||
);
|
||||
|
||||
let url = build_transport_request_url(
|
||||
&transport,
|
||||
TransportRequestUrlParams {
|
||||
provider_api_format: "openai:responses",
|
||||
mapped_model: None,
|
||||
upstream_is_stream: false,
|
||||
request_query: Some("tenant=demo"),
|
||||
kiro_api_region: None,
|
||||
},
|
||||
)
|
||||
.expect("openai responses url");
|
||||
|
||||
assert_eq!(url, "https://api.openai.example/v1/responses?tenant=demo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expands_custom_path_templates_when_hook_does_not_apply() {
|
||||
let transport = sample_transport(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -308,7 +308,7 @@ mod tests {
|
||||
)
|
||||
.expect("key should build")
|
||||
.with_transport_fields(
|
||||
Some(serde_json::json!(["openai:chat", "openai:cli"])),
|
||||
Some(serde_json::json!(["openai:chat", "openai:responses"])),
|
||||
encrypted_api_key,
|
||||
Some(encrypted_auth_config),
|
||||
Some(serde_json::json!({"openai:chat": 0.8})),
|
||||
@@ -382,7 +382,10 @@ mod tests {
|
||||
name: "prod-key".to_string(),
|
||||
auth_type: "api_key".to_string(),
|
||||
is_active: true,
|
||||
api_formats: Some(vec!["openai:chat".to_string(), "openai:cli".to_string(),]),
|
||||
api_formats: Some(vec![
|
||||
"openai:chat".to_string(),
|
||||
"openai:responses".to_string(),
|
||||
]),
|
||||
allowed_models: Some(vec!["gpt-4.1".to_string(), "gpt-4.1-mini".to_string(),]),
|
||||
capabilities: Some(serde_json::json!({"cache_1h": true})),
|
||||
rate_multipliers: Some(serde_json::json!({"openai:chat": 0.8})),
|
||||
@@ -672,9 +675,9 @@ mod tests {
|
||||
let endpoint = StoredProviderCatalogEndpoint::new(
|
||||
"endpoint-safe-2".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"openai:cli".to_string(),
|
||||
"openai:responses".to_string(),
|
||||
Some("openai".to_string()),
|
||||
Some("cli".to_string()),
|
||||
Some("responses".to_string()),
|
||||
true,
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
@@ -707,7 +710,7 @@ mod tests {
|
||||
)
|
||||
.expect("key should build")
|
||||
.with_transport_fields(
|
||||
Some(serde_json::json!(["openai:cli"])),
|
||||
Some(serde_json::json!(["openai:responses"])),
|
||||
encrypted_api_key,
|
||||
Some(encrypted_auth_config),
|
||||
None,
|
||||
@@ -737,7 +740,7 @@ mod tests {
|
||||
);
|
||||
assert!(!supports_local_standard_transport_with_network(
|
||||
&snapshot,
|
||||
"openai:cli"
|
||||
"openai:responses"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -820,9 +823,9 @@ mod tests {
|
||||
let endpoint = StoredProviderCatalogEndpoint::new(
|
||||
"endpoint-safe-4".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"openai:cli".to_string(),
|
||||
"openai:responses".to_string(),
|
||||
Some("openai".to_string()),
|
||||
Some("cli".to_string()),
|
||||
Some("responses".to_string()),
|
||||
true,
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
@@ -862,7 +865,7 @@ mod tests {
|
||||
)
|
||||
.expect("key should build")
|
||||
.with_transport_fields(
|
||||
Some(serde_json::json!(["openai:cli"])),
|
||||
Some(serde_json::json!(["openai:responses"])),
|
||||
encrypted_api_key,
|
||||
Some(encrypted_auth_config),
|
||||
None,
|
||||
@@ -900,7 +903,7 @@ mod tests {
|
||||
);
|
||||
assert!(supports_local_standard_transport_with_network(
|
||||
&snapshot,
|
||||
"openai:cli"
|
||||
"openai:responses"
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@ pub fn build_openai_chat_url(upstream_base_url: &str, query: Option<&str>) -> St
|
||||
url
|
||||
}
|
||||
|
||||
pub fn build_openai_cli_url(upstream_base_url: &str, query: Option<&str>, compact: bool) -> String {
|
||||
pub fn build_openai_responses_url(
|
||||
upstream_base_url: &str,
|
||||
query: Option<&str>,
|
||||
compact: bool,
|
||||
) -> String {
|
||||
let (trimmed, base_query) = split_base_url_query(upstream_base_url);
|
||||
let trimmed = trimmed.trim_end_matches('/');
|
||||
let suffix = if compact {
|
||||
@@ -242,8 +246,8 @@ fn merge_query_string(
|
||||
mod tests {
|
||||
use super::{
|
||||
build_gemini_content_url, build_gemini_files_passthrough_url,
|
||||
build_gemini_video_predict_long_running_url, build_openai_chat_url, build_openai_cli_url,
|
||||
build_passthrough_path_url,
|
||||
build_gemini_video_predict_long_running_url, build_openai_chat_url,
|
||||
build_openai_responses_url, build_passthrough_path_url,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -258,13 +262,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_cli_url_preserves_codex_path_prefix() {
|
||||
fn openai_responses_url_preserves_codex_path_prefix() {
|
||||
assert_eq!(
|
||||
build_openai_cli_url("https://tiger.bookapi.cc/codex", None, false),
|
||||
build_openai_responses_url("https://tiger.bookapi.cc/codex", None, false),
|
||||
"https://tiger.bookapi.cc/codex/responses"
|
||||
);
|
||||
assert_eq!(
|
||||
build_openai_cli_url("https://tiger.bookapi.cc/codex?tenant=demo", None, true),
|
||||
build_openai_responses_url("https://tiger.bookapi.cc/codex?tenant=demo", None, true),
|
||||
"https://tiger.bookapi.cc/codex/responses/compact?tenant=demo"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -253,7 +253,11 @@ pub fn extract_global_priority_for_format(
|
||||
}
|
||||
|
||||
pub fn normalize_api_format(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"openai:cli" => "openai:responses".to_string(),
|
||||
"openai:compact" => "openai:responses:compact".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn row_has_candidate_model_name(
|
||||
@@ -273,7 +277,7 @@ fn row_has_candidate_model_name(
|
||||
}
|
||||
|
||||
fn api_format_matches(left: &str, right: &str) -> bool {
|
||||
left.trim().eq_ignore_ascii_case(right.trim())
|
||||
normalize_api_format(left) == normalize_api_format(right)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -732,7 +732,7 @@ mod tests {
|
||||
"endpoint_id": "endpoint-1",
|
||||
"key_id": "catalog-key-1",
|
||||
"client_api_format": "openai:chat",
|
||||
"provider_api_format": "openai:cli",
|
||||
"provider_api_format": "openai:responses",
|
||||
"header_rules": [
|
||||
{"op": "set", "name": "x-test", "value": "1"}
|
||||
],
|
||||
@@ -878,7 +878,7 @@ mod tests {
|
||||
"user_id": "user-1",
|
||||
"api_key_id": "api-key-1",
|
||||
"client_api_format": "openai:chat",
|
||||
"provider_api_format": "openai:cli",
|
||||
"provider_api_format": "openai:responses",
|
||||
"upstream_url": "https://example.com/v1/responses",
|
||||
"mapped_model": "gpt-5-upstream",
|
||||
"key_name": "primary"
|
||||
@@ -906,7 +906,7 @@ mod tests {
|
||||
.extra_data
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("provider_api_format")),
|
||||
Some(&json!("openai:cli"))
|
||||
Some(&json!("openai:responses"))
|
||||
);
|
||||
assert_eq!(
|
||||
record
|
||||
|
||||
@@ -85,13 +85,19 @@ pub fn infer_internal_finalize_signature(payload: &GatewaySyncReportRequest) ->
|
||||
return Some("openai:chat".to_string());
|
||||
}
|
||||
if report_kind.starts_with("openai_compact_") {
|
||||
return Some("openai:compact".to_string());
|
||||
return Some("openai:responses:compact".to_string());
|
||||
}
|
||||
if report_kind.starts_with("openai_responses_compact_") {
|
||||
return Some("openai:responses:compact".to_string());
|
||||
}
|
||||
if report_kind.starts_with("openai_responses_") {
|
||||
return Some("openai:responses".to_string());
|
||||
}
|
||||
if report_kind.starts_with("openai_image_") {
|
||||
return Some("openai:image".to_string());
|
||||
}
|
||||
if report_kind.starts_with("openai_cli_") {
|
||||
return Some("openai:cli".to_string());
|
||||
return Some("openai:responses".to_string());
|
||||
}
|
||||
if report_kind.starts_with("openai_video_") {
|
||||
return Some("openai:video".to_string());
|
||||
@@ -121,15 +127,15 @@ pub fn resolve_internal_finalize_route(signature: &str) -> Option<InternalFinali
|
||||
route_family: "openai",
|
||||
route_kind: "chat",
|
||||
}),
|
||||
"openai:cli" => Some(InternalFinalizeRoute {
|
||||
"openai:responses" | "openai:cli" => Some(InternalFinalizeRoute {
|
||||
public_path: "/v1/responses",
|
||||
route_family: "openai",
|
||||
route_kind: "cli",
|
||||
route_kind: "responses",
|
||||
}),
|
||||
"openai:compact" => Some(InternalFinalizeRoute {
|
||||
"openai:responses:compact" | "openai:compact" => Some(InternalFinalizeRoute {
|
||||
public_path: "/v1/responses/compact",
|
||||
route_family: "openai",
|
||||
route_kind: "compact",
|
||||
route_kind: "responses:compact",
|
||||
}),
|
||||
"openai:image" => Some(InternalFinalizeRoute {
|
||||
public_path: "/v1/images/generations",
|
||||
@@ -231,6 +237,10 @@ pub fn is_local_ai_sync_report_kind(report_kind: &str) -> bool {
|
||||
| "openai_chat_sync_error"
|
||||
| "claude_chat_sync_error"
|
||||
| "gemini_chat_sync_error"
|
||||
| "openai_responses_sync_success"
|
||||
| "openai_responses_compact_sync_success"
|
||||
| "openai_responses_sync_error"
|
||||
| "openai_responses_compact_sync_error"
|
||||
| "openai_cli_sync_success"
|
||||
| "openai_image_sync_success"
|
||||
| "claude_cli_sync_success"
|
||||
@@ -262,6 +272,8 @@ pub fn is_local_ai_stream_report_kind(report_kind: &str) -> bool {
|
||||
"openai_chat_stream_success"
|
||||
| "claude_chat_stream_success"
|
||||
| "gemini_chat_stream_success"
|
||||
| "openai_responses_stream_success"
|
||||
| "openai_responses_compact_stream_success"
|
||||
| "openai_cli_stream_success"
|
||||
| "claude_cli_stream_success"
|
||||
| "gemini_cli_stream_success"
|
||||
@@ -415,6 +427,12 @@ mod tests {
|
||||
assert!(is_local_ai_sync_report_kind(
|
||||
"openai_video_create_sync_success"
|
||||
));
|
||||
assert!(is_local_ai_sync_report_kind(
|
||||
"openai_responses_compact_sync_success"
|
||||
));
|
||||
assert!(is_local_ai_sync_report_kind(
|
||||
"openai_responses_compact_sync_error"
|
||||
));
|
||||
assert!(is_local_ai_sync_report_kind("openai_image_sync_success"));
|
||||
assert!(is_local_ai_sync_report_kind("gemini_files_delete_mapping"));
|
||||
assert!(!is_local_ai_sync_report_kind("unknown_sync_kind"));
|
||||
@@ -423,6 +441,9 @@ mod tests {
|
||||
#[test]
|
||||
fn classifies_local_ai_stream_report_kinds() {
|
||||
assert!(is_local_ai_stream_report_kind("openai_chat_stream_success"));
|
||||
assert!(is_local_ai_stream_report_kind(
|
||||
"openai_responses_compact_stream_success"
|
||||
));
|
||||
assert!(!is_local_ai_stream_report_kind("openai_chat_stream_error"));
|
||||
}
|
||||
|
||||
@@ -483,6 +504,13 @@ mod tests {
|
||||
Some("openai:image".to_string())
|
||||
);
|
||||
|
||||
let from_compact_report_kind =
|
||||
sample_sync_report_with_context("openai_responses_compact_sync_finalize", json!({}));
|
||||
assert_eq!(
|
||||
infer_internal_finalize_signature(&from_compact_report_kind),
|
||||
Some("openai:responses:compact".to_string())
|
||||
);
|
||||
|
||||
let unknown = sample_sync_report("unknown_sync_finalize", 200);
|
||||
assert_eq!(infer_internal_finalize_signature(&unknown), None);
|
||||
}
|
||||
@@ -490,13 +518,17 @@ mod tests {
|
||||
#[test]
|
||||
fn resolves_internal_finalize_route_for_supported_signatures() {
|
||||
assert_eq!(
|
||||
resolve_internal_finalize_route("openai:compact"),
|
||||
resolve_internal_finalize_route("openai:responses:compact"),
|
||||
Some(InternalFinalizeRoute {
|
||||
public_path: "/v1/responses/compact",
|
||||
route_family: "openai",
|
||||
route_kind: "compact",
|
||||
route_kind: "responses:compact",
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_internal_finalize_route("openai:compact"),
|
||||
resolve_internal_finalize_route("openai:responses:compact")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_internal_finalize_route("gemini:video"),
|
||||
Some(InternalFinalizeRoute {
|
||||
|
||||
@@ -291,7 +291,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}),
|
||||
"openai:cli",
|
||||
"openai:responses",
|
||||
);
|
||||
|
||||
assert_eq!(usage.input_tokens, 14);
|
||||
@@ -313,7 +313,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}),
|
||||
"openai:cli",
|
||||
"openai:responses",
|
||||
);
|
||||
|
||||
assert_eq!(usage.input_tokens, 52_600);
|
||||
@@ -392,7 +392,7 @@ mod tests {
|
||||
}
|
||||
]
|
||||
}),
|
||||
"openai:cli",
|
||||
"openai:responses",
|
||||
);
|
||||
|
||||
assert_eq!(usage.input_tokens, 9);
|
||||
|
||||
@@ -2448,7 +2448,7 @@ mod tests {
|
||||
})),
|
||||
stream: false,
|
||||
client_api_format: "claude:cli".to_string(),
|
||||
provider_api_format: "openai:cli".to_string(),
|
||||
provider_api_format: "openai:responses".to_string(),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
@@ -2515,7 +2515,7 @@ mod tests {
|
||||
body: RequestBody::from_json(json!({"model": "gpt-5.4"})),
|
||||
stream: true,
|
||||
client_api_format: "claude:cli".to_string(),
|
||||
provider_api_format: "openai:cli".to_string(),
|
||||
provider_api_format: "openai:responses".to_string(),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
@@ -2572,7 +2572,7 @@ mod tests {
|
||||
body: RequestBody::from_json(json!({"model": "gpt-5.4"})),
|
||||
stream: false,
|
||||
client_api_format: "claude:cli".to_string(),
|
||||
provider_api_format: "openai:cli".to_string(),
|
||||
provider_api_format: "openai:responses".to_string(),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
@@ -2583,7 +2583,7 @@ mod tests {
|
||||
report_kind: "claude_cli_sync_success".to_string(),
|
||||
report_context: Some(json!({
|
||||
"client_api_format": "claude:cli",
|
||||
"provider_api_format": "openai:cli",
|
||||
"provider_api_format": "openai:responses",
|
||||
"needs_conversion": true,
|
||||
"original_request_body": nested,
|
||||
"provider_request_body": {"input": "safe"}
|
||||
@@ -2635,7 +2635,7 @@ mod tests {
|
||||
},
|
||||
stream: true,
|
||||
client_api_format: "openai:chat".to_string(),
|
||||
provider_api_format: "openai:cli".to_string(),
|
||||
provider_api_format: "openai:responses".to_string(),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
@@ -2646,7 +2646,7 @@ mod tests {
|
||||
report_kind: "openai_chat_stream_success".to_string(),
|
||||
report_context: Some(json!({
|
||||
"client_api_format": "openai:chat",
|
||||
"provider_api_format": "openai:cli",
|
||||
"provider_api_format": "openai:responses",
|
||||
"needs_conversion": true
|
||||
})),
|
||||
status_code: 200,
|
||||
@@ -2730,7 +2730,7 @@ mod tests {
|
||||
},
|
||||
stream: true,
|
||||
client_api_format: "openai:chat".to_string(),
|
||||
provider_api_format: "openai:cli".to_string(),
|
||||
provider_api_format: "openai:responses".to_string(),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
@@ -2746,7 +2746,7 @@ mod tests {
|
||||
report_kind: "openai_chat_stream_success".to_string(),
|
||||
report_context: Some(json!({
|
||||
"client_api_format": "openai:chat",
|
||||
"provider_api_format": "openai:cli",
|
||||
"provider_api_format": "openai:responses",
|
||||
"needs_conversion": true
|
||||
})),
|
||||
status_code: 200,
|
||||
@@ -2761,6 +2761,7 @@ mod tests {
|
||||
response_id: Some("resp_summary_1".to_string()),
|
||||
model: Some("gpt-5.4".to_string()),
|
||||
observed_finish: true,
|
||||
unknown_event_count: 0,
|
||||
parser_error: None,
|
||||
}),
|
||||
telemetry: None,
|
||||
@@ -2799,8 +2800,8 @@ mod tests {
|
||||
body_ref: None,
|
||||
},
|
||||
stream: true,
|
||||
client_api_format: "openai:cli".to_string(),
|
||||
provider_api_format: "openai:cli".to_string(),
|
||||
client_api_format: "openai:responses".to_string(),
|
||||
provider_api_format: "openai:responses".to_string(),
|
||||
model_name: Some("gpt-5.5".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
@@ -2852,8 +2853,8 @@ mod tests {
|
||||
trace_id: "trace-stream-provider-chunks-usage-1".to_string(),
|
||||
report_kind: "openai_cli_stream_success".to_string(),
|
||||
report_context: Some(json!({
|
||||
"client_api_format": "openai:cli",
|
||||
"provider_api_format": "openai:cli",
|
||||
"client_api_format": "openai:responses",
|
||||
"provider_api_format": "openai:responses",
|
||||
})),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::new(),
|
||||
@@ -2869,6 +2870,7 @@ mod tests {
|
||||
response_id: Some("resp_123".to_string()),
|
||||
model: Some("gpt-5.5".to_string()),
|
||||
observed_finish: true,
|
||||
unknown_event_count: 0,
|
||||
parser_error: None,
|
||||
}),
|
||||
telemetry: None,
|
||||
@@ -2904,8 +2906,8 @@ mod tests {
|
||||
body_ref: None,
|
||||
},
|
||||
stream: true,
|
||||
client_api_format: "openai:cli".to_string(),
|
||||
provider_api_format: "openai:cli".to_string(),
|
||||
client_api_format: "openai:responses".to_string(),
|
||||
provider_api_format: "openai:responses".to_string(),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
@@ -2924,8 +2926,8 @@ mod tests {
|
||||
trace_id: "trace-stream-usage-2".to_string(),
|
||||
report_kind: "openai_cli_stream_success".to_string(),
|
||||
report_context: Some(json!({
|
||||
"client_api_format": "openai:cli",
|
||||
"provider_api_format": "openai:cli",
|
||||
"client_api_format": "openai:responses",
|
||||
"provider_api_format": "openai:responses",
|
||||
})),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::new(),
|
||||
@@ -3135,8 +3137,8 @@ mod tests {
|
||||
body_ref: None,
|
||||
},
|
||||
stream: false,
|
||||
client_api_format: "openai:cli".to_string(),
|
||||
provider_api_format: "openai:cli".to_string(),
|
||||
client_api_format: "openai:responses".to_string(),
|
||||
provider_api_format: "openai:responses".to_string(),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
@@ -3146,8 +3148,8 @@ mod tests {
|
||||
trace_id: "trace-sync-upstream-stream-1".to_string(),
|
||||
report_kind: "openai_cli_sync_success".to_string(),
|
||||
report_context: Some(json!({
|
||||
"client_api_format": "openai:cli",
|
||||
"provider_api_format": "openai:cli",
|
||||
"client_api_format": "openai:responses",
|
||||
"provider_api_format": "openai:responses",
|
||||
"upstream_is_stream": true
|
||||
})),
|
||||
status_code: 200,
|
||||
@@ -3315,8 +3317,8 @@ mod tests {
|
||||
body_ref: Some("blob://provider-request-1".to_string()),
|
||||
},
|
||||
stream: false,
|
||||
client_api_format: "openai:cli".to_string(),
|
||||
provider_api_format: "openai:cli".to_string(),
|
||||
client_api_format: "openai:responses".to_string(),
|
||||
provider_api_format: "openai:responses".to_string(),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
@@ -3326,8 +3328,8 @@ mod tests {
|
||||
trace_id: "trace-sync-body-ref-1".to_string(),
|
||||
report_kind: "openai_cli_sync_success".to_string(),
|
||||
report_context: Some(json!({
|
||||
"client_api_format": "openai:cli",
|
||||
"provider_api_format": "openai:cli",
|
||||
"client_api_format": "openai:responses",
|
||||
"provider_api_format": "openai:responses",
|
||||
"trace_id": "trace-sync-body-ref-1"
|
||||
})),
|
||||
status_code: 200,
|
||||
@@ -3379,7 +3381,7 @@ mod tests {
|
||||
},
|
||||
stream: true,
|
||||
client_api_format: "openai:chat".to_string(),
|
||||
provider_api_format: "openai:cli".to_string(),
|
||||
provider_api_format: "openai:responses".to_string(),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
@@ -3390,7 +3392,7 @@ mod tests {
|
||||
report_kind: "openai_chat_stream_success".to_string(),
|
||||
report_context: Some(json!({
|
||||
"client_api_format": "openai:chat",
|
||||
"provider_api_format": "openai:cli",
|
||||
"provider_api_format": "openai:responses",
|
||||
"trace_id": "trace-stream-bytes-1"
|
||||
})),
|
||||
status_code: 200,
|
||||
@@ -3464,8 +3466,8 @@ mod tests {
|
||||
body_ref: None,
|
||||
},
|
||||
stream: true,
|
||||
client_api_format: "openai:cli".to_string(),
|
||||
provider_api_format: "openai:cli".to_string(),
|
||||
client_api_format: "openai:responses".to_string(),
|
||||
provider_api_format: "openai:responses".to_string(),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
@@ -3475,8 +3477,8 @@ mod tests {
|
||||
trace_id: "trace-stream-usage-large-1".to_string(),
|
||||
report_kind: "openai_cli_stream_success".to_string(),
|
||||
report_context: Some(json!({
|
||||
"client_api_format": "openai:cli",
|
||||
"provider_api_format": "openai:cli",
|
||||
"client_api_format": "openai:responses",
|
||||
"provider_api_format": "openai:responses",
|
||||
})),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::new(),
|
||||
@@ -3531,7 +3533,7 @@ mod tests {
|
||||
})),
|
||||
stream: false,
|
||||
client_api_format: "claude:cli".to_string(),
|
||||
provider_api_format: "openai:cli".to_string(),
|
||||
provider_api_format: "openai:responses".to_string(),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
@@ -3542,7 +3544,7 @@ mod tests {
|
||||
report_kind: "claude_cli_sync_success".to_string(),
|
||||
report_context: Some(json!({
|
||||
"client_api_format": "claude:cli",
|
||||
"provider_api_format": "openai:cli",
|
||||
"provider_api_format": "openai:responses",
|
||||
"needs_conversion": true,
|
||||
"original_request_body": null,
|
||||
})),
|
||||
@@ -3585,7 +3587,7 @@ mod tests {
|
||||
body: RequestBody::from_json(json!({"model": "gpt-5.4"})),
|
||||
stream: false,
|
||||
client_api_format: "claude:cli".to_string(),
|
||||
provider_api_format: "openai:cli".to_string(),
|
||||
provider_api_format: "openai:responses".to_string(),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
@@ -3596,7 +3598,7 @@ mod tests {
|
||||
report_kind: "claude_cli_sync_success".to_string(),
|
||||
report_context: Some(json!({
|
||||
"client_api_format": "claude:cli",
|
||||
"provider_api_format": "openai:cli",
|
||||
"provider_api_format": "openai:responses",
|
||||
"needs_conversion": true,
|
||||
})),
|
||||
status_code: 200,
|
||||
|
||||
Reference in New Issue
Block a user