refactor(ai-formats): group formats by provider

Move protocol/request/response format modules under provider-oriented formats modules and update registry, transport, and architecture paths.
This commit is contained in:
fawney19
2026-05-08 15:40:24 +08:00
parent 84a84e3f31
commit 9a84a6ff6c
105 changed files with 1131 additions and 989 deletions

View File

@@ -0,0 +1,519 @@
use std::collections::BTreeMap;
use std::fmt::Write;
use aether_ai_formats::provider_compat::proxy::rules::body_rules_handle_path;
use serde_json::{json, Value};
use sha1::{Digest as Sha1Digest, Sha1};
use sha2::Sha256;
use uuid::Uuid;
const CODEX_PROMPT_CACHE_NAMESPACE_VERSION: &str = "v3";
const CODEX_DEFAULT_INSTRUCTIONS: &str = "You are ChatGPT.";
const CODEX_DEFAULT_USER_AGENT: &str =
"codex-tui/0.122.0 (Mac OS 15.2.0; arm64) vscode/2.6.11 (codex-tui; 0.122.0)";
const CODEX_DEFAULT_ORIGINATOR: &str = "codex-tui";
pub const CODEX_OPENAI_IMAGE_INTERNAL_MODEL: &str = "gpt-5.4-mini";
pub const CODEX_OPENAI_IMAGE_DEFAULT_MODEL: &str = "gpt-image-2";
pub const CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_MODEL: &str = "dall-e-2";
pub const CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT: &str = "png";
pub const CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_PROMPT: &str =
"Create a faithful variation of the provided image.";
const CODEX_IMAGE_TOOL_DEFAULT_SIZE: &str = "1024x1024";
const CODEX_IMAGE_TOOL_DEFAULT_QUALITY: &str = "high";
const CODEX_IMAGE_TOOL_DEFAULT_BACKGROUND: &str = "auto";
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_responses_request(provider_type: &str, provider_api_format: &str) -> bool {
provider_type.trim().eq_ignore_ascii_case("codex")
&& (aether_ai_formats::is_openai_responses_family_format(provider_api_format)
|| is_openai_image_request(provider_api_format))
}
fn is_openai_responses_compact_request(provider_api_format: &str) -> bool {
aether_ai_formats::is_openai_responses_compact_format(provider_api_format)
}
fn is_openai_image_request(provider_api_format: &str) -> bool {
provider_api_format
.trim()
.eq_ignore_ascii_case("openai:image")
}
fn apply_codex_openai_image_tool_overrides(body_object: &mut serde_json::Map<String, Value>) {
let mut tool = body_object
.get("tools")
.and_then(Value::as_array)
.and_then(|tools| tools.first())
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
tool.insert("type".to_string(), json!("image_generation"));
tool.entry("output_format".to_string())
.or_insert_with(|| json!(CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT));
let action = tool
.get("action")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("generate")
.to_string();
if !tool.contains_key("action") {
tool.insert("action".to_string(), json!("generate"));
}
if action == "generate" {
tool.entry("size".to_string())
.or_insert_with(|| json!(CODEX_IMAGE_TOOL_DEFAULT_SIZE));
tool.entry("quality".to_string())
.or_insert_with(|| json!(CODEX_IMAGE_TOOL_DEFAULT_QUALITY));
tool.entry("background".to_string())
.or_insert_with(|| json!(CODEX_IMAGE_TOOL_DEFAULT_BACKGROUND));
}
body_object.insert("tools".to_string(), json!([tool]));
body_object.insert(
"tool_choice".to_string(),
json!({
"type": "image_generation"
}),
);
}
fn codex_openai_image_has_prompt(body_object: &serde_json::Map<String, Value>) -> bool {
body_object
.get("input")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_object)
.filter_map(|item| item.get("content"))
.any(|content| match content {
Value::String(text) => !text.trim().is_empty(),
Value::Array(items) => items.iter().any(|item| {
item.as_object()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("input_text"))
.and_then(|item| item.get("text").and_then(Value::as_str))
.map(str::trim)
.is_some_and(|text| !text.is_empty())
}),
_ => false,
})
}
fn inject_codex_default_variation_prompt(body_object: &mut serde_json::Map<String, Value>) {
let Some(action) = body_object
.get("tools")
.and_then(Value::as_array)
.and_then(|tools| tools.first())
.and_then(Value::as_object)
.and_then(|tool| tool.get("action"))
.and_then(Value::as_str)
else {
return;
};
if action != "edit" || codex_openai_image_has_prompt(body_object) {
return;
}
let Some(input) = body_object.get_mut("input").and_then(Value::as_array_mut) else {
return;
};
let Some(first_message) = input.first_mut().and_then(Value::as_object_mut) else {
return;
};
let Some(content) = first_message
.get_mut("content")
.and_then(Value::as_array_mut)
else {
return;
};
content.insert(
0,
json!({
"type": "input_text",
"text": CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_PROMPT,
}),
);
}
fn build_stable_codex_prompt_cache_key(user_api_key_id: &str) -> Option<String> {
let normalized = user_api_key_id.trim();
if normalized.is_empty() {
return None;
}
let namespace = format!(
"aether:codex:prompt-cache:{CODEX_PROMPT_CACHE_NAMESPACE_VERSION}:user:{normalized}"
);
let mut hasher = Sha1::new();
hasher.update(UUID_NAMESPACE_OID_BYTES);
hasher.update(namespace.as_bytes());
let digest = hasher.finalize();
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&digest[..16]);
bytes[6] = (bytes[6] & 0x0f) | 0x50;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
Some(Uuid::from_bytes(bytes).to_string())
}
fn build_short_codex_header_id(seed: &str) -> Option<String> {
let normalized = seed.trim();
if normalized.is_empty() {
return None;
}
let digest = Sha256::digest(normalized.as_bytes());
let mut short_id = String::with_capacity(16);
for byte in digest.iter().take(8) {
let _ = write!(&mut short_id, "{byte:02x}");
}
Some(short_id)
}
fn header_map_has_non_empty_value(headers: &http::HeaderMap, header_name: &str) -> bool {
let target = header_name.trim().to_ascii_lowercase();
if target.is_empty() {
return false;
}
headers.iter().any(|(name, value)| {
if name.as_str().trim().to_ascii_lowercase() != target {
return false;
}
value
.to_str()
.ok()
.map(str::trim)
.map(|value| !value.is_empty())
.unwrap_or(false)
})
}
fn btree_map_has_non_empty_value(headers: &BTreeMap<String, String>, header_name: &str) -> bool {
let target = header_name.trim().to_ascii_lowercase();
if target.is_empty() {
return false;
}
headers
.iter()
.any(|(name, value)| name.trim().eq_ignore_ascii_case(&target) && !value.trim().is_empty())
}
fn extract_codex_account_id(decrypted_auth_config_raw: Option<&str>) -> Option<String> {
let raw = decrypted_auth_config_raw?.trim();
if raw.is_empty() {
return None;
}
serde_json::from_str::<Value>(raw).ok().and_then(|value| {
value
.get("account_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn maybe_insert_default_codex_header(
provider_request_headers: &mut BTreeMap<String, String>,
original_headers: &http::HeaderMap,
header_name: &str,
header_value: &str,
) {
if header_map_has_non_empty_value(original_headers, header_name)
|| btree_map_has_non_empty_value(provider_request_headers, header_name)
{
return;
}
provider_request_headers.insert(header_name.to_string(), header_value.to_string());
}
fn maybe_inject_codex_prompt_cache_key(
provider_request_body: &mut Value,
provider_type: &str,
provider_api_format: &str,
user_api_key_id: Option<&str>,
) {
if !is_codex_openai_responses_request(provider_type, provider_api_format) {
return;
}
let Some(body_object) = provider_request_body.as_object_mut() else {
return;
};
let existing = body_object
.get("prompt_cache_key")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if !existing.is_empty() {
return;
}
let Some(prompt_cache_key) = user_api_key_id.and_then(build_stable_codex_prompt_cache_key)
else {
return;
};
body_object.insert(
"prompt_cache_key".to_string(),
Value::String(prompt_cache_key),
);
}
pub fn apply_openai_responses_compact_special_body_edits(
provider_request_body: &mut Value,
provider_api_format: &str,
) {
if !is_openai_responses_compact_request(provider_api_format) {
return;
}
let Some(body_object) = provider_request_body.as_object_mut() else {
return;
};
// `/v1/responses/compact` does not accept `store`.
body_object.remove("store");
}
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_responses_request(provider_type, provider_api_format) {
return;
}
let Some(body_object) = provider_request_body.as_object_mut() else {
return;
};
if !body_rules_handle_path(body_rules, "max_output_tokens") {
body_object.remove("max_output_tokens");
}
if !body_rules_handle_path(body_rules, "temperature") {
body_object.remove("temperature");
}
if !body_rules_handle_path(body_rules, "top_p") {
body_object.remove("top_p");
}
if !body_rules_handle_path(body_rules, "metadata") {
body_object.remove("metadata");
}
if is_openai_responses_compact_request(provider_api_format) {
body_object.remove("store");
} else if !body_rules_handle_path(body_rules, "store") {
body_object.insert("store".to_string(), json!(false));
}
if !body_rules_handle_path(body_rules, "instructions")
&& !body_object.contains_key("instructions")
{
body_object.insert(
"instructions".to_string(),
json!(CODEX_DEFAULT_INSTRUCTIONS),
);
}
if is_openai_image_request(provider_api_format) {
body_object.insert(
"model".to_string(),
json!(CODEX_OPENAI_IMAGE_INTERNAL_MODEL),
);
body_object.insert("stream".to_string(), json!(true));
apply_codex_openai_image_tool_overrides(body_object);
inject_codex_default_variation_prompt(body_object);
}
maybe_inject_codex_prompt_cache_key(
provider_request_body,
provider_type,
provider_api_format,
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,
provider_type: &str,
provider_api_format: &str,
request_id: Option<&str>,
decrypted_auth_config_raw: Option<&str>,
) {
if !is_codex_openai_responses_request(provider_type, provider_api_format) {
return;
}
let prompt_cache_key = provider_request_body
.get("prompt_cache_key")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if !header_map_has_non_empty_value(original_headers, "chatgpt-account-id")
&& !btree_map_has_non_empty_value(provider_request_headers, "chatgpt-account-id")
{
if let Some(account_id) = extract_codex_account_id(decrypted_auth_config_raw) {
provider_request_headers.insert("chatgpt-account-id".to_string(), account_id);
}
}
if !header_map_has_non_empty_value(original_headers, "x-client-request-id")
&& !btree_map_has_non_empty_value(provider_request_headers, "x-client-request-id")
{
if let Some(request_id) = request_id.map(str::trim).filter(|value| !value.is_empty()) {
provider_request_headers
.insert("x-client-request-id".to_string(), request_id.to_string());
}
}
if !is_openai_image_request(provider_api_format) {
maybe_insert_default_codex_header(
provider_request_headers,
original_headers,
"user-agent",
CODEX_DEFAULT_USER_AGENT,
);
maybe_insert_default_codex_header(
provider_request_headers,
original_headers,
"originator",
CODEX_DEFAULT_ORIGINATOR,
);
}
let short_session_id = prompt_cache_key.and_then(build_short_codex_header_id);
if !header_map_has_non_empty_value(original_headers, "session_id")
&& !btree_map_has_non_empty_value(provider_request_headers, "session_id")
{
if let Some(short_session_id) = short_session_id.as_deref() {
provider_request_headers.insert("session_id".to_string(), short_session_id.to_string());
}
}
if aether_ai_formats::is_openai_responses_format(provider_api_format)
&& !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() {
provider_request_headers
.insert("conversation_id".to_string(), short_session_id.to_string());
}
}
}
#[cfg(test)]
mod tests {
use super::{
apply_codex_openai_responses_special_body_edits, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
};
use serde_json::json;
#[test]
fn codex_image_body_edits_force_tool_choice_and_default_generate_tool_fields() {
let mut provider_request_body = json!({
"input": [{
"role": "user",
"content": "generate image"
}],
"tools": [{
"type": "image_generation"
}],
"tool_choice": "auto"
});
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
"codex",
"openai:image",
None,
None,
);
assert_eq!(
provider_request_body["tools"][0]["size"],
json!("1024x1024")
);
assert_eq!(provider_request_body["tools"][0]["quality"], json!("high"));
assert_eq!(
provider_request_body["tools"][0]["background"],
json!("auto")
);
assert_eq!(
provider_request_body["tools"][0]["output_format"],
json!("png")
);
assert_eq!(
provider_request_body["tools"][0]["action"],
json!("generate")
);
assert_eq!(
provider_request_body["model"],
json!(CODEX_OPENAI_IMAGE_INTERNAL_MODEL)
);
assert_eq!(provider_request_body["stream"], json!(true));
assert_eq!(
provider_request_body["tool_choice"]["type"],
json!("image_generation")
);
}
#[test]
fn codex_image_body_edits_preserve_edit_action_without_generate_defaults() {
let mut provider_request_body = json!({
"tools": [{
"type": "image_generation",
"action": "edit",
"input_image_mask": { "image_url": "data:image/png;base64,mask" }
}],
"input": [{
"role": "user",
"content": [{
"type": "input_image",
"image_url": "data:image/png;base64,image"
}]
}],
"tool_choice": "auto"
});
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
"codex",
"openai:image",
None,
None,
);
assert_eq!(provider_request_body["tools"][0]["action"], json!("edit"));
assert!(provider_request_body["tools"][0].get("size").is_none());
assert!(provider_request_body["tools"][0].get("quality").is_none());
assert!(provider_request_body["tools"][0]
.get("background")
.is_none());
assert_eq!(
provider_request_body["tools"][0]["output_format"],
json!("png")
);
assert_eq!(
provider_request_body["input"][0]["content"][0]["text"],
json!("Create a faithful variation of the provided image.")
);
assert_eq!(
provider_request_body["tool_choice"]["type"],
json!("image_generation")
);
}
}

View File

@@ -0,0 +1,5 @@
pub mod codex;
pub mod request;
pub mod response;
pub mod spec;
pub mod stream;

View File

@@ -0,0 +1,513 @@
use serde_json::{json, Map, Value};
use crate::{
formats::context::FormatContext,
formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort,
protocol::canonical::{
canonical_response_format_to_openai, canonicalize_tool_arguments, media_data_or_url,
namespace_extension_object, openai_content_text, openai_extensions,
openai_response_format_to_canonical, openai_responses_extension,
openai_responses_generation_config, openai_responses_input_to_canonical_messages,
openai_responses_tool_choice_to_canonical, openai_responses_tools_to_canonical,
CanonicalContentBlock, CanonicalInstruction, CanonicalRequest, CanonicalRole,
CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
from_raw(body)
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
to_raw(
request,
ctx.mapped_model_or(request.model.as_str()),
ctx.upstream_is_stream,
false,
)
}
pub fn to_compact(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
to_raw(
request,
ctx.mapped_model_or(request.model.as_str()),
false,
true,
)
}
pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
let request = body_json.as_object()?;
let mut canonical = CanonicalRequest {
model: request
.get("model")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
..CanonicalRequest::default()
};
if let Some(instructions) = request.get("instructions") {
let text = openai_content_text(Some(instructions));
if !text.trim().is_empty() {
canonical.system = Some(text.clone());
canonical.instructions.push(CanonicalInstruction {
role: CanonicalRole::System,
text,
extensions: std::collections::BTreeMap::new(),
});
}
}
canonical.messages = openai_responses_input_to_canonical_messages(request.get("input"))?;
canonical.generation = openai_responses_generation_config(request);
canonical.tools = openai_responses_tools_to_canonical(request.get("tools"))?;
canonical.tool_choice = openai_responses_tool_choice_to_canonical(request.get("tool_choice"));
canonical.parallel_tool_calls = request.get("parallel_tool_calls").and_then(Value::as_bool);
canonical.metadata = request.get("metadata").cloned();
canonical.response_format = request
.get("text")
.and_then(Value::as_object)
.and_then(|text| text.get("format"))
.and_then(|format| openai_response_format_to_canonical(Some(format)));
if let Some(reasoning) = request.get("reasoning").and_then(Value::as_object) {
let mut extensions = std::collections::BTreeMap::new();
extensions.insert(
OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(),
Value::Object(reasoning.clone()),
);
canonical.thinking = Some(CanonicalThinkingConfig {
enabled: true,
budget_tokens: reasoning.get("budget_tokens").and_then(Value::as_u64),
extensions,
});
}
canonical.extensions = openai_extensions(
request,
&[
"model",
"instructions",
"input",
"max_output_tokens",
"temperature",
"top_p",
"metadata",
"tools",
"tool_choice",
"parallel_tool_calls",
"text",
"reasoning",
],
);
if let Some(raw) = canonical.extensions.remove("openai") {
canonical
.extensions
.insert(OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(), raw);
}
if let Some(verbosity) = request
.get("text")
.and_then(Value::as_object)
.and_then(|text| text.get("verbosity"))
.cloned()
{
let entry = canonical
.extensions
.entry(OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string())
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if let Some(object) = entry.as_object_mut() {
object.insert("verbosity".to_string(), verbosity);
}
}
Some(canonical)
}
pub fn to_raw(
canonical: &CanonicalRequest,
mapped_model: &str,
upstream_is_stream: bool,
compact: bool,
) -> Option<Value> {
let mut output = Map::new();
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
if let Some(instructions) = canonical_instructions_to_responses(canonical) {
output.insert("instructions".to_string(), instructions);
}
output.insert(
"input".to_string(),
Value::Array(canonical_messages_to_responses_input(canonical)?),
);
if upstream_is_stream && !compact {
output.insert("stream".to_string(), Value::Bool(true));
}
if let Some(max_tokens) = canonical.generation.max_tokens {
output.insert("max_output_tokens".to_string(), Value::from(max_tokens));
}
insert_number(&mut output, "temperature", canonical.generation.temperature);
insert_number(&mut output, "top_p", canonical.generation.top_p);
if let Some(top_logprobs) = canonical.generation.top_logprobs {
output.insert("top_logprobs".to_string(), Value::from(top_logprobs));
}
if let Some(value) = canonical.parallel_tool_calls {
output.insert("parallel_tool_calls".to_string(), Value::Bool(value));
}
if let Some(metadata) = canonical.metadata.clone() {
output.insert("metadata".to_string(), metadata);
}
if let Some(text_config) = canonical_text_config_to_responses(canonical) {
output.insert("text".to_string(), text_config);
}
if !canonical.tools.is_empty() {
output.insert(
"tools".to_string(),
Value::Array(canonical_tools_to_responses(canonical)),
);
}
if let Some(tool_choice) = canonical.tool_choice.as_ref() {
output.insert(
"tool_choice".to_string(),
canonical_tool_choice_to_responses(tool_choice),
);
}
if let Some(reasoning) = canonical
.thinking
.as_ref()
.and_then(reasoning_config_to_responses)
{
output.insert("reasoning".to_string(), reasoning);
}
output.extend(namespace_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
&output,
));
output.extend(namespace_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
&output,
));
output.remove("verbosity");
Some(Value::Object(output))
}
fn canonical_instructions_to_responses(canonical: &CanonicalRequest) -> Option<Value> {
let text = canonical
.instructions
.iter()
.map(|instruction| instruction.text.as_str())
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n");
if !text.trim().is_empty() {
return Some(Value::String(text));
}
canonical
.system
.as_ref()
.filter(|value| !value.trim().is_empty())
.cloned()
.map(Value::String)
}
fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option<Vec<Value>> {
let mut input = Vec::new();
for message in &canonical.messages {
let role = match message.role {
CanonicalRole::Assistant => "assistant",
CanonicalRole::Tool | CanonicalRole::User | CanonicalRole::Unknown => "user",
CanonicalRole::System | CanonicalRole::Developer => continue,
};
let mut content = Vec::new();
for block in &message.content {
match block {
CanonicalContentBlock::ToolUse {
id,
name,
input: arguments,
..
} => {
flush_responses_message(&mut input, role, &mut content);
input.push(json!({
"type": "function_call",
"call_id": id,
"name": name,
"arguments": canonicalize_tool_arguments(arguments),
}));
}
CanonicalContentBlock::ToolResult {
tool_use_id,
output,
content_text,
..
} => {
flush_responses_message(&mut input, role, &mut content);
input.push(json!({
"type": "function_call_output",
"call_id": tool_use_id,
"output": responses_tool_result_output(output.as_ref(), content_text.as_deref()),
}));
}
CanonicalContentBlock::Thinking { .. } => {}
other => {
if let Some(part) = canonical_block_to_responses_input_part(other, role) {
content.push(part);
}
}
}
}
flush_responses_message(&mut input, role, &mut content);
}
Some(input)
}
fn flush_responses_message(input: &mut Vec<Value>, role: &str, content: &mut Vec<Value>) {
if content.is_empty() {
return;
}
input.push(json!({
"type": "message",
"role": role,
"content": std::mem::take(content),
}));
}
fn canonical_block_to_responses_input_part(
block: &CanonicalContentBlock,
role: &str,
) -> Option<Value> {
match block {
CanonicalContentBlock::Text { text, .. } => {
if text.is_empty() {
return None;
}
Some(json!({
"type": if role == "assistant" { "output_text" } else { "input_text" },
"text": text,
}))
}
CanonicalContentBlock::Image {
data,
url,
media_type,
detail,
..
} => {
let mut item = Map::new();
item.insert(
"type".to_string(),
Value::String(if role == "assistant" {
"output_image".to_string()
} else {
"input_image".to_string()
}),
);
item.insert(
"image_url".to_string(),
Value::String(media_data_or_url(media_type, data, url)),
);
if let Some(detail) = detail {
item.insert("detail".to_string(), Value::String(detail.clone()));
}
Some(Value::Object(item))
}
CanonicalContentBlock::File {
data,
file_id,
file_url,
media_type,
filename,
..
} => {
let mut item = Map::new();
item.insert("type".to_string(), Value::String("input_file".to_string()));
if let Some(value) = file_id {
item.insert("file_id".to_string(), Value::String(value.clone()));
}
if data.is_some() || file_url.is_some() {
item.insert(
"file_data".to_string(),
Value::String(media_data_or_url(media_type, data, file_url)),
);
}
if let Some(value) = filename {
item.insert("filename".to_string(), Value::String(value.clone()));
}
(item.len() > 1).then_some(Value::Object(item))
}
CanonicalContentBlock::Audio { data, format, .. } => Some(json!({
"type": "input_audio",
"input_audio": {
"data": data.clone().unwrap_or_default(),
"format": format.clone().unwrap_or_else(|| "mp3".to_string()),
}
})),
CanonicalContentBlock::Unknown {
raw_type, payload, ..
} if raw_type == "refusal" => payload
.get("refusal")
.and_then(Value::as_str)
.filter(|text| !text.trim().is_empty())
.map(|text| json!({ "type": "refusal", "refusal": text })),
CanonicalContentBlock::Thinking { .. }
| CanonicalContentBlock::ToolUse { .. }
| CanonicalContentBlock::ToolResult { .. }
| CanonicalContentBlock::Unknown { .. } => None,
}
}
fn canonical_tools_to_responses(canonical: &CanonicalRequest) -> Vec<Value> {
let mut tools = canonical
.tools
.iter()
.map(canonical_tool_to_responses)
.collect::<Vec<_>>();
if let Some(extra_tools) = canonical
.extensions
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
.or_else(|| {
canonical
.extensions
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
})
.and_then(Value::as_object)
.and_then(|value| value.get("tools"))
.and_then(Value::as_array)
{
tools.extend(extra_tools.iter().cloned());
}
tools
}
fn reasoning_config_to_responses(thinking: &CanonicalThinkingConfig) -> Option<Value> {
openai_responses_extension(&thinking.extensions)
.cloned()
.or_else(|| {
thinking
.extensions
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
.cloned()
})
.or_else(|| {
thinking
.extensions
.get("openai")
.and_then(|value| value.get("reasoning_effort"))
.and_then(Value::as_str)
.map(|effort| {
json!({
"effort": openai_responses_reasoning_effort(effort),
})
})
})
.or_else(|| {
thinking.budget_tokens.map(|budget_tokens| {
json!({
"effort": map_thinking_budget_to_openai_reasoning_effort(budget_tokens),
})
})
})
}
fn openai_responses_reasoning_effort(effort: &str) -> &str {
match effort.trim().to_ascii_lowercase().as_str() {
"xhigh" | "max" => "xhigh",
"low" => "low",
"medium" => "medium",
"high" => "high",
_ => effort,
}
}
fn canonical_text_config_to_responses(canonical: &CanonicalRequest) -> Option<Value> {
let mut text = Map::new();
if let Some(response_format) = &canonical.response_format {
text.insert(
"format".to_string(),
canonical_response_format_to_openai(response_format),
);
}
if let Some(verbosity) = canonical
.extensions
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
.or_else(|| {
canonical
.extensions
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
})
.and_then(Value::as_object)
.and_then(|value| value.get("verbosity"))
.cloned()
{
text.insert("verbosity".to_string(), verbosity);
}
(!text.is_empty()).then_some(Value::Object(text))
}
fn canonical_tool_to_responses(tool: &CanonicalToolDefinition) -> Value {
if let Some(raw) = tool
.extensions
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
.or_else(|| {
tool.extensions
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
})
.filter(|value| {
value
.get("type")
.and_then(Value::as_str)
.is_some_and(|tool_type| {
tool_type == "custom" || tool_type.starts_with("web_search")
})
})
{
return raw.clone();
}
let mut out = Map::new();
out.insert("type".to_string(), Value::String("function".to_string()));
out.insert("name".to_string(), Value::String(tool.name.clone()));
if let Some(description) = &tool.description {
out.insert(
"description".to_string(),
Value::String(description.clone()),
);
}
if let Some(parameters) = &tool.parameters {
out.insert("parameters".to_string(), parameters.clone());
}
out.extend(namespace_extension_object(
&tool.extensions,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
&out,
));
Value::Object(out)
}
fn canonical_tool_choice_to_responses(choice: &CanonicalToolChoice) -> Value {
match choice {
CanonicalToolChoice::Auto => Value::String("auto".to_string()),
CanonicalToolChoice::None => Value::String("none".to_string()),
CanonicalToolChoice::Required => Value::String("required".to_string()),
CanonicalToolChoice::Tool { name } => json!({
"type": "function",
"name": name,
}),
}
}
fn responses_tool_result_output(output: Option<&Value>, content_text: Option<&str>) -> Value {
match output {
Some(Value::String(text)) => Value::String(text.clone()),
Some(value) => serde_json::to_string(value)
.map(Value::String)
.unwrap_or_else(|_| Value::String(String::new())),
None => Value::String(content_text.unwrap_or_default().to_string()),
}
}
fn insert_number(output: &mut Map<String, Value>, key: &str, value: Option<f64>) {
if let Some(value) = value.and_then(serde_json::Number::from_f64) {
output.insert(key.to_string(), Value::Number(value));
}
}

View File

@@ -0,0 +1,250 @@
use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use crate::{
formats::context::FormatContext,
protocol::canonical::{
canonical_content_block_to_openai_responses_part,
canonical_usage_to_openai_responses_usage, canonicalize_tool_arguments,
flush_openai_responses_message_item, namespace_extension_object,
openai_responses_extensions, openai_responses_output_to_canonical_blocks,
openai_usage_to_canonical, CanonicalContentBlock, CanonicalResponse,
CanonicalResponseOutput, CanonicalRole, CanonicalStopReason,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
from_raw(body)
}
pub fn to(response: &CanonicalResponse, ctx: &FormatContext) -> Option<Value> {
Some(to_raw(response, &ctx.report_context_value(), false))
}
pub fn to_compact(response: &CanonicalResponse, ctx: &FormatContext) -> Option<Value> {
Some(to_raw(response, &ctx.report_context_value(), true))
}
pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
let body = body_json.as_object()?;
if body.get("error").is_some_and(|error| !error.is_null())
|| body.get("status").and_then(Value::as_str) == Some("failed")
{
return None;
}
let content = openai_responses_output_to_canonical_blocks(body.get("output"))?;
let has_tool_use = content
.iter()
.any(|block| matches!(block, CanonicalContentBlock::ToolUse { .. }));
let stop_reason = if has_tool_use {
Some(CanonicalStopReason::ToolUse)
} else {
match body.get("status").and_then(Value::as_str) {
Some("incomplete") => Some(CanonicalStopReason::MaxTokens),
Some("failed") => Some(CanonicalStopReason::Unknown),
_ => Some(CanonicalStopReason::EndTurn),
}
};
Some(CanonicalResponse {
id: body
.get("id")
.and_then(Value::as_str)
.unwrap_or("resp-unknown")
.to_string(),
model: body
.get("model")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
outputs: vec![CanonicalResponseOutput {
index: 0,
role: CanonicalRole::Assistant,
content: content.clone(),
stop_reason: stop_reason.clone(),
extensions: BTreeMap::new(),
}],
content,
stop_reason,
usage: openai_usage_to_canonical(body.get("usage")),
extensions: openai_responses_extensions(
body,
&["id", "object", "model", "output", "usage", "status"],
),
})
}
pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: bool) -> Value {
let mut response = Map::new();
let response_id = canonical.id.replace("chatcmpl", "resp");
response.insert("id".to_string(), Value::String(response_id.clone()));
response.insert("object".to_string(), Value::String("response".to_string()));
response.insert("status".to_string(), Value::String("completed".to_string()));
response.insert("model".to_string(), Value::String(canonical.model.clone()));
let mut output = Vec::new();
let mut message_content = Vec::new();
let mut message_index = 0usize;
for block in &canonical.content {
match block {
CanonicalContentBlock::Text { .. }
| CanonicalContentBlock::Image { .. }
| CanonicalContentBlock::File { .. }
| CanonicalContentBlock::Audio { .. } => {
if let Some(part) = canonical_content_block_to_openai_responses_part(block) {
message_content.push(part);
}
}
CanonicalContentBlock::Thinking {
text,
encrypted_content,
..
} => {
flush_openai_responses_message_item(
&mut output,
&mut message_content,
&response_id,
&mut message_index,
);
let mut item = Map::new();
item.insert("type".to_string(), Value::String("reasoning".to_string()));
item.insert(
"id".to_string(),
Value::String(format!("{}_rs_{}", response_id, output.len())),
);
item.insert("status".to_string(), Value::String("completed".to_string()));
if let Some(encrypted_content) =
encrypted_content.as_ref().filter(|value| !value.is_empty())
{
item.insert(
"encrypted_content".to_string(),
Value::String(encrypted_content.clone()),
);
}
if !text.trim().is_empty() {
item.insert(
"summary".to_string(),
Value::Array(vec![json!({
"type": "summary_text",
"text": text,
})]),
);
}
output.push(Value::Object(item));
}
CanonicalContentBlock::ToolUse {
id, name, input, ..
} => {
flush_openai_responses_message_item(
&mut output,
&mut message_content,
&response_id,
&mut message_index,
);
output.push(json!({
"type": "function_call",
"id": id,
"call_id": id,
"name": name,
"arguments": canonicalize_tool_arguments(input),
}));
}
CanonicalContentBlock::ToolResult {
tool_use_id,
output: result_output,
content_text,
is_error,
..
} => {
flush_openai_responses_message_item(
&mut output,
&mut message_content,
&response_id,
&mut message_index,
);
let mut item = Map::new();
item.insert(
"type".to_string(),
Value::String("function_call_output".to_string()),
);
item.insert("call_id".to_string(), Value::String(tool_use_id.clone()));
item.insert(
"output".to_string(),
result_output
.clone()
.unwrap_or_else(|| Value::String(content_text.clone().unwrap_or_default())),
);
if *is_error {
item.insert("is_error".to_string(), Value::Bool(true));
}
output.push(Value::Object(item));
}
CanonicalContentBlock::Unknown {
raw_type, payload, ..
} if raw_type == "refusal" => {
if let Some(text) = payload.get("refusal").and_then(Value::as_str) {
if !text.trim().is_empty() {
message_content.push(json!({
"type": "refusal",
"refusal": text,
}));
}
}
}
CanonicalContentBlock::Unknown { .. } => {}
}
}
flush_openai_responses_message_item(
&mut output,
&mut message_content,
&response_id,
&mut message_index,
);
response.insert("output".to_string(), Value::Array(output));
if let Some(usage) = &canonical.usage {
response.insert(
"usage".to_string(),
canonical_usage_to_openai_responses_usage(usage),
);
}
if let Some(request_object) = report_context
.get("original_request_body")
.and_then(Value::as_object)
{
for key in [
"instructions",
"max_output_tokens",
"parallel_tool_calls",
"previous_response_id",
"reasoning",
"store",
"temperature",
"text",
"tool_choice",
"tools",
"top_p",
"truncation",
"user",
"metadata",
] {
if let Some(value) = request_object.get(key) {
response.insert(key.to_string(), value.clone());
}
}
if let Some(service_tier) = request_object.get("service_tier").cloned() {
response.insert("service_tier".to_string(), service_tier);
}
}
response.extend(namespace_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
&response,
));
response.extend(namespace_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
&response,
));
Value::Object(response)
}

View File

@@ -0,0 +1,78 @@
use crate::contracts::{
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,
}),
_ => 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,
}),
_ => 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_openai_responses_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);
}
}

View File

@@ -0,0 +1,3 @@
pub use crate::formats::openai::chat::stream::{
OpenAIResponsesClientEmitter, OpenAIResponsesProviderState,
};