mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
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:
@@ -0,0 +1,53 @@
|
||||
use crate::contracts::{CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND};
|
||||
use crate::formats::shared::family::{
|
||||
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
|
||||
};
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
CLAUDE_CHAT_SYNC_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "claude:messages",
|
||||
decision_kind: CLAUDE_CHAT_SYNC_PLAN_KIND,
|
||||
report_kind: "claude_chat_sync_finalize",
|
||||
family: LocalStandardSourceFamily::Standard,
|
||||
mode: LocalStandardSourceMode::Chat,
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
CLAUDE_CHAT_STREAM_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "claude:messages",
|
||||
decision_kind: CLAUDE_CHAT_STREAM_PLAN_KIND,
|
||||
report_kind: "claude_chat_stream_success",
|
||||
family: LocalStandardSourceFamily::Standard,
|
||||
mode: LocalStandardSourceMode::Chat,
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_spec, resolve_sync_spec};
|
||||
|
||||
#[test]
|
||||
fn resolves_claude_chat_sync_spec() {
|
||||
let spec = resolve_sync_spec("claude_chat_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "claude:messages");
|
||||
assert_eq!(spec.report_kind, "claude_chat_sync_finalize");
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_claude_chat_stream_spec() {
|
||||
let spec = resolve_stream_spec("claude_chat_stream").expect("spec");
|
||||
assert_eq!(spec.api_format, "claude:messages");
|
||||
assert_eq!(spec.report_kind, "claude_chat_stream_success");
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use crate::contracts::{CLAUDE_CLI_STREAM_PLAN_KIND, CLAUDE_CLI_SYNC_PLAN_KIND};
|
||||
use crate::formats::shared::family::{
|
||||
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
|
||||
};
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
CLAUDE_CLI_SYNC_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "claude:messages",
|
||||
decision_kind: CLAUDE_CLI_SYNC_PLAN_KIND,
|
||||
report_kind: "claude_cli_sync_finalize",
|
||||
family: LocalStandardSourceFamily::Standard,
|
||||
mode: LocalStandardSourceMode::Cli,
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
CLAUDE_CLI_STREAM_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "claude:messages",
|
||||
decision_kind: CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||
report_kind: "claude_cli_stream_success",
|
||||
family: LocalStandardSourceFamily::Standard,
|
||||
mode: LocalStandardSourceMode::Cli,
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_spec, resolve_sync_spec};
|
||||
|
||||
#[test]
|
||||
fn resolves_claude_cli_sync_spec() {
|
||||
let spec = resolve_sync_spec("claude_cli_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "claude:messages");
|
||||
assert_eq!(spec.report_kind, "claude_cli_sync_finalize");
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_claude_cli_stream_spec() {
|
||||
let spec = resolve_stream_spec("claude_cli_stream").expect("spec");
|
||||
assert_eq!(spec.api_format, "claude:messages");
|
||||
assert_eq!(spec.report_kind, "claude_cli_stream_success");
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
16
crates/aether-ai-formats/src/formats/claude/messages/mod.rs
Normal file
16
crates/aether-ai-formats/src/formats/claude/messages/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
pub mod chat_spec;
|
||||
pub mod cli_spec;
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
pub mod spec;
|
||||
pub mod stream;
|
||||
|
||||
use crate::formats::shared::family::LocalStandardSpec;
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
chat_spec::resolve_sync_spec(plan_kind).or_else(|| cli_spec::resolve_sync_spec(plan_kind))
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
chat_spec::resolve_stream_spec(plan_kind).or_else(|| cli_spec::resolve_stream_spec(plan_kind))
|
||||
}
|
||||
191
crates/aether-ai-formats/src/formats/claude/messages/request.rs
Normal file
191
crates/aether-ai-formats/src/formats/claude/messages/request.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::{
|
||||
formats::{
|
||||
context::FormatContext,
|
||||
openai::shared::{
|
||||
map_openai_reasoning_effort_to_claude_output,
|
||||
map_openai_reasoning_effort_to_thinking_budget,
|
||||
},
|
||||
shared::model_directives::claude_model_uses_adaptive_effort,
|
||||
},
|
||||
protocol::canonical::{
|
||||
canonical_extension_object_mut, canonical_instructions_to_claude_system,
|
||||
canonical_messages_to_claude, canonical_openai_reasoning_effort,
|
||||
canonical_tool_choice_to_claude, canonical_tools_to_claude, claude_extensions,
|
||||
claude_generation_config, claude_messages_to_canonical, claude_parallel_tool_calls,
|
||||
claude_system_to_canonical_instructions, claude_thinking_to_canonical,
|
||||
claude_tool_choice_to_canonical, claude_tools_to_canonical,
|
||||
compact_canonical_claude_messages, insert_f64, namespace_extension_object,
|
||||
CanonicalRequest,
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
};
|
||||
|
||||
canonical.instructions = claude_system_to_canonical_instructions(request.get("system"))?;
|
||||
let system_text = canonical
|
||||
.instructions
|
||||
.iter()
|
||||
.map(|instruction| instruction.text.as_str())
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
if !system_text.is_empty() {
|
||||
canonical.system = Some(system_text);
|
||||
}
|
||||
canonical.messages = claude_messages_to_canonical(request.get("messages"))?;
|
||||
canonical.generation = claude_generation_config(request);
|
||||
let (tools, builtin_tools, web_search_options) =
|
||||
claude_tools_to_canonical(request.get("tools"))?;
|
||||
canonical.tools = tools;
|
||||
canonical.tool_choice = claude_tool_choice_to_canonical(request.get("tool_choice"));
|
||||
canonical.parallel_tool_calls = claude_parallel_tool_calls(request.get("tool_choice"));
|
||||
canonical.metadata = request.get("metadata").cloned();
|
||||
canonical.thinking = claude_thinking_to_canonical(request);
|
||||
|
||||
canonical.extensions = claude_extensions(
|
||||
request,
|
||||
&[
|
||||
"model",
|
||||
"system",
|
||||
"messages",
|
||||
"max_tokens",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"top_k",
|
||||
"stop",
|
||||
"stop_sequences",
|
||||
"stream",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"metadata",
|
||||
"thinking",
|
||||
"output_config",
|
||||
],
|
||||
);
|
||||
if !builtin_tools.is_empty() {
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "claude")
|
||||
.insert("builtin_tools".to_string(), Value::Array(builtin_tools));
|
||||
}
|
||||
if let Some(web_search_options) = web_search_options {
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "openai")
|
||||
.insert("web_search_options".to_string(), web_search_options);
|
||||
}
|
||||
if let Some(output_config) = request.get("output_config").cloned() {
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "claude")
|
||||
.insert("output_config".to_string(), output_config);
|
||||
}
|
||||
Some(canonical)
|
||||
}
|
||||
|
||||
pub fn to_raw(
|
||||
canonical: &CanonicalRequest,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
let mut output = Map::new();
|
||||
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
|
||||
output.insert(
|
||||
"messages".to_string(),
|
||||
Value::Array(compact_canonical_claude_messages(
|
||||
canonical_messages_to_claude(canonical)?,
|
||||
)),
|
||||
);
|
||||
output.insert(
|
||||
"max_tokens".to_string(),
|
||||
Value::from(canonical.generation.max_tokens.unwrap_or(1024)),
|
||||
);
|
||||
if let Some(system) = canonical_instructions_to_claude_system(&canonical.instructions) {
|
||||
output.insert("system".to_string(), system);
|
||||
} else if let Some(system) = canonical
|
||||
.system
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
output.insert("system".to_string(), Value::String(system.clone()));
|
||||
}
|
||||
if upstream_is_stream {
|
||||
output.insert("stream".to_string(), Value::Bool(true));
|
||||
}
|
||||
insert_f64(&mut output, "temperature", canonical.generation.temperature);
|
||||
insert_f64(&mut output, "top_p", canonical.generation.top_p);
|
||||
if let Some(top_k) = canonical.generation.top_k {
|
||||
output.insert("top_k".to_string(), Value::from(top_k));
|
||||
}
|
||||
if let Some(stop_sequences) = &canonical.generation.stop_sequences {
|
||||
output.insert(
|
||||
"stop_sequences".to_string(),
|
||||
Value::Array(stop_sequences.iter().cloned().map(Value::String).collect()),
|
||||
);
|
||||
}
|
||||
let tools = canonical_tools_to_claude(canonical);
|
||||
if !tools.is_empty() {
|
||||
output.insert("tools".to_string(), Value::Array(tools));
|
||||
}
|
||||
if let Some(tool_choice) = canonical_tool_choice_to_claude(
|
||||
canonical.tool_choice.as_ref(),
|
||||
canonical.parallel_tool_calls,
|
||||
) {
|
||||
output.insert("tool_choice".to_string(), tool_choice);
|
||||
}
|
||||
if let Some(metadata) = canonical.metadata.clone() {
|
||||
output.insert("metadata".to_string(), metadata);
|
||||
}
|
||||
if let Some(thinking) = canonical.thinking.as_ref() {
|
||||
let openai_effort = canonical_openai_reasoning_effort(thinking);
|
||||
let budget_tokens = thinking
|
||||
.budget_tokens
|
||||
.or_else(|| openai_effort.and_then(map_openai_reasoning_effort_to_thinking_budget));
|
||||
let uses_adaptive = claude_model_uses_adaptive_effort(mapped_model)
|
||||
|| claude_model_uses_adaptive_effort(canonical.model.as_str());
|
||||
if thinking.enabled || budget_tokens.is_some() {
|
||||
let thinking_config = if uses_adaptive {
|
||||
json!({"type": "adaptive"})
|
||||
} else {
|
||||
json!({
|
||||
"type": "enabled",
|
||||
"budget_tokens": budget_tokens.unwrap_or(1024),
|
||||
})
|
||||
};
|
||||
output.insert("thinking".to_string(), thinking_config);
|
||||
}
|
||||
if let Some(output_effort) =
|
||||
openai_effort.and_then(map_openai_reasoning_effort_to_claude_output)
|
||||
{
|
||||
output.insert(
|
||||
"output_config".to_string(),
|
||||
json!({
|
||||
"effort": output_effort,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
output.extend(namespace_extension_object(
|
||||
&canonical.extensions,
|
||||
"claude",
|
||||
&output,
|
||||
));
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
formats::context::FormatContext,
|
||||
protocol::canonical::{
|
||||
canonical_blocks_to_claude, canonical_stop_reason_to_claude, canonical_usage_to_claude,
|
||||
claude_content_to_canonical_blocks, claude_extensions, claude_stop_reason_to_canonical,
|
||||
claude_usage_to_canonical, namespace_extension_object, CanonicalResponse,
|
||||
CanonicalResponseOutput, CanonicalRole,
|
||||
},
|
||||
};
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
|
||||
let body = body_json.as_object()?;
|
||||
if body.contains_key("error") || body.get("type").and_then(Value::as_str) == Some("error") {
|
||||
return None;
|
||||
}
|
||||
let content = claude_content_to_canonical_blocks(body.get("content"))?;
|
||||
let stop_reason =
|
||||
claude_stop_reason_to_canonical(body.get("stop_reason").and_then(Value::as_str));
|
||||
Some(CanonicalResponse {
|
||||
id: body
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("msg-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: claude_usage_to_canonical(body.get("usage")),
|
||||
extensions: claude_extensions(
|
||||
body,
|
||||
&[
|
||||
"id",
|
||||
"type",
|
||||
"role",
|
||||
"model",
|
||||
"content",
|
||||
"stop_reason",
|
||||
"stop_sequence",
|
||||
"usage",
|
||||
],
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_raw(canonical: &CanonicalResponse) -> Value {
|
||||
let mut content = canonical_blocks_to_claude(&canonical.content, CanonicalRole::Assistant)
|
||||
.unwrap_or_default();
|
||||
if content.is_empty() {
|
||||
content.push(json!({
|
||||
"type": "text",
|
||||
"text": "",
|
||||
}));
|
||||
}
|
||||
let mut response = json!({
|
||||
"id": canonical.id,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": canonical.model,
|
||||
"content": content,
|
||||
"stop_reason": canonical_stop_reason_to_claude(canonical.stop_reason.as_ref()),
|
||||
"usage": canonical.usage.as_ref().map(canonical_usage_to_claude).unwrap_or_else(|| json!({
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
})),
|
||||
});
|
||||
if let Some(object) = response.as_object_mut() {
|
||||
object.extend(namespace_extension_object(
|
||||
&canonical.extensions,
|
||||
"claude",
|
||||
object,
|
||||
));
|
||||
}
|
||||
response
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub use super::{chat_spec, cli_spec, resolve_stream_spec, resolve_sync_spec};
|
||||
1302
crates/aether-ai-formats/src/formats/claude/messages/stream.rs
Normal file
1302
crates/aether-ai-formats/src/formats/claude/messages/stream.rs
Normal file
File diff suppressed because it is too large
Load Diff
1
crates/aether-ai-formats/src/formats/claude/mod.rs
Normal file
1
crates/aether-ai-formats/src/formats/claude/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod messages;
|
||||
77
crates/aether-ai-formats/src/formats/context.rs
Normal file
77
crates/aether-ai-formats/src/formats/context.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[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
|
||||
}
|
||||
|
||||
pub(crate) 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)
|
||||
}
|
||||
|
||||
pub(crate) 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 {}
|
||||
2
crates/aether-ai-formats/src/formats/conversion/mod.rs
Normal file
2
crates/aether-ai-formats/src/formats/conversion/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
218
crates/aether-ai-formats/src/formats/conversion/request.rs
Normal file
218
crates/aether-ai-formats/src/formats/conversion/request.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
//! Pairwise request conversion helpers.
|
||||
//!
|
||||
//! These helpers keep the call sites readable while delegating wire-format
|
||||
//! parsing and emitting to `formats::<format>::request` through the registry's
|
||||
//! canonical IR path.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::formats::{context::FormatContext, registry};
|
||||
|
||||
pub fn convert_openai_chat_request_to_claude_request(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
registry::convert_request(
|
||||
"openai:chat",
|
||||
"claude:messages",
|
||||
body_json,
|
||||
&request_context(mapped_model, upstream_is_stream),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_openai_chat_request_to_gemini_request(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
registry::convert_request(
|
||||
"openai:chat",
|
||||
"gemini:generate_content",
|
||||
body_json,
|
||||
&request_context(mapped_model, upstream_is_stream),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_openai_chat_request_to_openai_responses_request(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
compact: bool,
|
||||
) -> Option<Value> {
|
||||
let target_format = if compact {
|
||||
"openai:responses:compact"
|
||||
} else {
|
||||
"openai:responses"
|
||||
};
|
||||
registry::convert_request(
|
||||
"openai:chat",
|
||||
target_format,
|
||||
body_json,
|
||||
&request_context(mapped_model, upstream_is_stream),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn normalize_openai_responses_request_to_openai_chat_request(
|
||||
body_json: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_request(
|
||||
"openai:responses",
|
||||
"openai:chat",
|
||||
body_json,
|
||||
&FormatContext::default(),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn normalize_claude_request_to_openai_chat_request(body_json: &Value) -> Option<Value> {
|
||||
registry::convert_request(
|
||||
"claude:messages",
|
||||
"openai:chat",
|
||||
body_json,
|
||||
&FormatContext::default(),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn normalize_gemini_request_to_openai_chat_request(
|
||||
body_json: &Value,
|
||||
request_path: &str,
|
||||
) -> Option<Value> {
|
||||
registry::convert_request(
|
||||
"gemini:generate_content",
|
||||
"openai:chat",
|
||||
body_json,
|
||||
&FormatContext::default().with_request_path(request_path),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn extract_openai_text_content(content: Option<&Value>) -> Option<String> {
|
||||
match content {
|
||||
None | Some(Value::Null) => Some(String::new()),
|
||||
Some(Value::String(text)) => Some(text.clone()),
|
||||
Some(Value::Array(parts)) => {
|
||||
let mut collected = Vec::new();
|
||||
for part in parts {
|
||||
let part_object = part.as_object()?;
|
||||
let part_type = part_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if matches!(part_type, "text" | "input_text") {
|
||||
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
|
||||
if !text.trim().is_empty() {
|
||||
collected.push(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(collected.join("\n"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_openai_tool_result_content(content: Option<&Value>) -> Value {
|
||||
match content {
|
||||
Some(Value::String(raw)) => {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
Value::String(String::new())
|
||||
} else {
|
||||
serde_json::from_str::<Value>(trimmed)
|
||||
.unwrap_or_else(|_| Value::String(raw.clone()))
|
||||
}
|
||||
}
|
||||
Some(Value::Array(parts)) => {
|
||||
let texts = parts
|
||||
.iter()
|
||||
.filter_map(|part| {
|
||||
part.as_object()
|
||||
.and_then(|object| object.get("text"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if texts.is_empty() {
|
||||
Value::Array(parts.clone())
|
||||
} else {
|
||||
Value::String(texts.join("\n"))
|
||||
}
|
||||
}
|
||||
Some(value) => value.clone(),
|
||||
None => Value::String(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_context(mapped_model: &str, upstream_is_stream: bool) -> FormatContext {
|
||||
FormatContext::default()
|
||||
.with_mapped_model(mapped_model)
|
||||
.with_upstream_stream(upstream_is_stream)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
convert_openai_chat_request_to_claude_request,
|
||||
convert_openai_chat_request_to_openai_responses_request,
|
||||
normalize_claude_request_to_openai_chat_request,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn pairwise_request_helper_routes_through_registry() {
|
||||
let body = json!({
|
||||
"model": "gpt-source",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
});
|
||||
|
||||
let converted = convert_openai_chat_request_to_openai_responses_request(
|
||||
&body,
|
||||
"gpt-target",
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.expect("responses request");
|
||||
|
||||
assert_eq!(converted["model"], "gpt-target");
|
||||
assert_eq!(converted["stream"], true);
|
||||
assert_eq!(converted["input"][0]["type"], "message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairwise_request_helper_keeps_claude_shape() {
|
||||
let body = json!({
|
||||
"model": "gpt-source",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
});
|
||||
|
||||
let converted =
|
||||
convert_openai_chat_request_to_claude_request(&body, "claude-target", false)
|
||||
.expect("claude request");
|
||||
|
||||
assert_eq!(converted["model"], "claude-target");
|
||||
assert_eq!(converted["messages"][0]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_normalizer_uses_format_adapter() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet",
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": "hello"}]}],
|
||||
"max_tokens": 128,
|
||||
});
|
||||
|
||||
let converted =
|
||||
normalize_claude_request_to_openai_chat_request(&body).expect("openai chat request");
|
||||
|
||||
assert_eq!(converted["model"], "claude-sonnet");
|
||||
assert_eq!(converted["messages"][0]["role"], "user");
|
||||
assert_eq!(converted["messages"][0]["content"], "hello");
|
||||
}
|
||||
}
|
||||
298
crates/aether-ai-formats/src/formats/conversion/response.rs
Normal file
298
crates/aether-ai-formats/src/formats/conversion/response.rs
Normal file
@@ -0,0 +1,298 @@
|
||||
//! Pairwise response conversion helpers.
|
||||
//!
|
||||
//! These helpers keep the call sites readable while delegating wire-format
|
||||
//! parsing and emitting to `formats::<format>::response` through the registry's
|
||||
//! canonical IR path.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::formats::{context::FormatContext, registry};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OpenAiResponsesResponseUsage {
|
||||
pub prompt_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
pub fn convert_claude_chat_response_to_openai_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"claude:messages",
|
||||
"openai:chat",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_gemini_chat_response_to_openai_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"gemini:generate_content",
|
||||
"openai:chat",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_openai_chat_response_to_claude_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"openai:chat",
|
||||
"claude:messages",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_openai_chat_response_to_gemini_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"openai:chat",
|
||||
"gemini:generate_content",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_openai_responses_response_to_openai_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"openai:responses",
|
||||
"openai:chat",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_openai_chat_response_to_openai_responses(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
compact: bool,
|
||||
) -> Option<Value> {
|
||||
let target_format = if compact {
|
||||
"openai:responses:compact"
|
||||
} else {
|
||||
"openai:responses"
|
||||
};
|
||||
registry::convert_response(
|
||||
"openai:chat",
|
||||
target_format,
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_claude_response_to_openai_responses(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"claude:messages",
|
||||
"openai:responses",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_gemini_response_to_openai_responses(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"gemini:generate_content",
|
||||
"openai:responses",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn build_openai_responses_response(
|
||||
response_id: &str,
|
||||
model: &str,
|
||||
text: &str,
|
||||
function_calls: Vec<Value>,
|
||||
prompt_tokens: u64,
|
||||
output_tokens: u64,
|
||||
total_tokens: u64,
|
||||
) -> Value {
|
||||
let content = if text.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![json!({
|
||||
"type": "output_text",
|
||||
"text": text,
|
||||
"annotations": []
|
||||
})]
|
||||
};
|
||||
build_openai_responses_response_with_content(
|
||||
response_id,
|
||||
model,
|
||||
content,
|
||||
Vec::new(),
|
||||
function_calls,
|
||||
OpenAiResponsesResponseUsage {
|
||||
prompt_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_openai_responses_response_with_reasoning(
|
||||
response_id: &str,
|
||||
model: &str,
|
||||
text: &str,
|
||||
reasoning_summaries: Vec<String>,
|
||||
function_calls: Vec<Value>,
|
||||
usage: OpenAiResponsesResponseUsage,
|
||||
) -> Value {
|
||||
let content = if text.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![json!({
|
||||
"type": "output_text",
|
||||
"text": text,
|
||||
"annotations": []
|
||||
})]
|
||||
};
|
||||
build_openai_responses_response_with_content(
|
||||
response_id,
|
||||
model,
|
||||
content,
|
||||
reasoning_summaries,
|
||||
function_calls,
|
||||
usage,
|
||||
)
|
||||
}
|
||||
|
||||
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: OpenAiResponsesResponseUsage,
|
||||
) -> Value {
|
||||
let mut output = Vec::new();
|
||||
for (index, summary) in reasoning_summaries.into_iter().enumerate() {
|
||||
let trimmed = summary.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
output.push(json!({
|
||||
"type": "reasoning",
|
||||
"id": format!("{response_id}_rs_{index}"),
|
||||
"status": "completed",
|
||||
"summary": [{
|
||||
"type": "summary_text",
|
||||
"text": trimmed,
|
||||
}]
|
||||
}));
|
||||
}
|
||||
if !content.is_empty() {
|
||||
output.push(json!({
|
||||
"type": "message",
|
||||
"id": format!("{response_id}_msg"),
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": content
|
||||
}));
|
||||
}
|
||||
output.extend(function_calls);
|
||||
json!({
|
||||
"id": response_id,
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"model": model,
|
||||
"output": output,
|
||||
"usage": {
|
||||
"input_tokens": usage.prompt_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
"total_tokens": usage.total_tokens,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn response_context(report_context: &Value) -> FormatContext {
|
||||
let mut context = FormatContext::default().with_report_context(report_context.clone());
|
||||
if let Some(model) = report_context
|
||||
.get("mapped_model")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
context = context.with_mapped_model(model);
|
||||
}
|
||||
context
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
convert_claude_chat_response_to_openai_chat,
|
||||
convert_openai_chat_response_to_openai_responses,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn pairwise_response_helper_routes_through_registry() {
|
||||
let body = json!({
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"model": "gpt-source",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "hello"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
|
||||
});
|
||||
|
||||
let converted = convert_openai_chat_response_to_openai_responses(&body, &json!({}), false)
|
||||
.expect("responses response");
|
||||
|
||||
assert_eq!(converted["object"], "response");
|
||||
assert_eq!(converted["output"][0]["type"], "message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairwise_response_helper_uses_report_context_model_fallback() {
|
||||
let body = json!({
|
||||
"id": "msg-test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "hello"}],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 1, "output_tokens": 2}
|
||||
});
|
||||
|
||||
let converted = convert_claude_chat_response_to_openai_chat(
|
||||
&body,
|
||||
&json!({"mapped_model": "gpt-target"}),
|
||||
)
|
||||
.expect("openai chat response");
|
||||
|
||||
assert_eq!(converted["model"], "gpt-target");
|
||||
assert_eq!(converted["choices"][0]["message"]["content"], "hello");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod request;
|
||||
@@ -0,0 +1,40 @@
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Map};
|
||||
|
||||
use crate::formats::context::FormatContext;
|
||||
use crate::formats::openai::embedding::request::mapped_embedding_model;
|
||||
use crate::protocol::canonical::{namespace_extension_object, CanonicalRequest};
|
||||
|
||||
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
|
||||
let embedding = request.embedding.as_ref()?;
|
||||
let items = embedding.input.as_string_items()?;
|
||||
if items.is_empty() || items.iter().any(|value| value.trim().is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let mut output = Map::new();
|
||||
output.insert(
|
||||
"model".to_string(),
|
||||
Value::String(mapped_embedding_model(
|
||||
request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
)),
|
||||
);
|
||||
output.insert(
|
||||
"input".to_string(),
|
||||
Value::Array(
|
||||
items
|
||||
.into_iter()
|
||||
.map(|text| json!({"type": "text", "text": text}))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
if let Some(dimensions) = embedding.dimensions {
|
||||
output.insert("dimensions".to_string(), Value::from(dimensions));
|
||||
}
|
||||
output.extend(namespace_extension_object(
|
||||
&embedding.extensions,
|
||||
"doubao",
|
||||
&output,
|
||||
));
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
1
crates/aether-ai-formats/src/formats/doubao/mod.rs
Normal file
1
crates/aether-ai-formats/src/formats/doubao/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod embedding;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod request;
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde_json::json;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::formats::context::FormatContext;
|
||||
use crate::formats::openai::embedding::request::mapped_embedding_model;
|
||||
use crate::protocol::canonical::CanonicalRequest;
|
||||
|
||||
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
|
||||
let embedding = request.embedding.as_ref()?;
|
||||
let items = embedding.input.as_string_items()?;
|
||||
if items.is_empty() || items.iter().any(|value| value.trim().is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let model = mapped_embedding_model(request, ctx.mapped_model_or(request.model.as_str()));
|
||||
if items.len() == 1 {
|
||||
return Some(json!({
|
||||
"model": model,
|
||||
"content": {
|
||||
"parts": [{"text": items[0]}]
|
||||
}
|
||||
}));
|
||||
}
|
||||
Some(json!({
|
||||
"model": model,
|
||||
"requests": items.into_iter().map(|text| {
|
||||
json!({
|
||||
"model": model,
|
||||
"content": {
|
||||
"parts": [{"text": text}]
|
||||
}
|
||||
})
|
||||
}).collect::<Vec<_>>()
|
||||
}))
|
||||
}
|
||||
1
crates/aether-ai-formats/src/formats/gemini/files/mod.rs
Normal file
1
crates/aether-ai-formats/src/formats/gemini/files/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod spec;
|
||||
69
crates/aether-ai-formats/src/formats/gemini/files/spec.rs
Normal file
69
crates/aether-ai-formats/src/formats/gemini/files/spec.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use crate::contracts::{
|
||||
GEMINI_FILES_DELETE_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND,
|
||||
GEMINI_FILES_LIST_PLAN_KIND, GEMINI_FILES_UPLOAD_PLAN_KIND,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LocalGeminiFilesSpec {
|
||||
pub decision_kind: &'static str,
|
||||
pub report_kind: Option<&'static str>,
|
||||
pub require_streaming: bool,
|
||||
}
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalGeminiFilesSpec> {
|
||||
match plan_kind {
|
||||
GEMINI_FILES_UPLOAD_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
||||
decision_kind: GEMINI_FILES_UPLOAD_PLAN_KIND,
|
||||
report_kind: Some("gemini_files_store_mapping"),
|
||||
require_streaming: false,
|
||||
}),
|
||||
GEMINI_FILES_LIST_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
||||
decision_kind: GEMINI_FILES_LIST_PLAN_KIND,
|
||||
report_kind: Some("gemini_files_store_mapping"),
|
||||
require_streaming: false,
|
||||
}),
|
||||
GEMINI_FILES_GET_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
||||
decision_kind: GEMINI_FILES_GET_PLAN_KIND,
|
||||
report_kind: Some("gemini_files_store_mapping"),
|
||||
require_streaming: false,
|
||||
}),
|
||||
GEMINI_FILES_DELETE_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
||||
decision_kind: GEMINI_FILES_DELETE_PLAN_KIND,
|
||||
report_kind: Some("gemini_files_delete_mapping"),
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalGeminiFilesSpec> {
|
||||
match plan_kind {
|
||||
GEMINI_FILES_DOWNLOAD_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
||||
decision_kind: GEMINI_FILES_DOWNLOAD_PLAN_KIND,
|
||||
report_kind: None,
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_spec, resolve_sync_spec};
|
||||
|
||||
#[test]
|
||||
fn resolves_sync_gemini_files_specs() {
|
||||
let spec = resolve_sync_spec("gemini_files_upload").expect("spec");
|
||||
assert_eq!(spec.decision_kind, "gemini_files_upload");
|
||||
assert_eq!(spec.report_kind, Some("gemini_files_store_mapping"));
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_stream_gemini_files_spec() {
|
||||
let spec = resolve_stream_spec("gemini_files_download").expect("spec");
|
||||
assert_eq!(spec.decision_kind, "gemini_files_download");
|
||||
assert_eq!(spec.report_kind, None);
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use crate::contracts::{GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND};
|
||||
use crate::formats::shared::family::{
|
||||
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
|
||||
};
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
GEMINI_CHAT_SYNC_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "gemini:generate_content",
|
||||
decision_kind: GEMINI_CHAT_SYNC_PLAN_KIND,
|
||||
report_kind: "gemini_chat_sync_finalize",
|
||||
family: LocalStandardSourceFamily::Gemini,
|
||||
mode: LocalStandardSourceMode::Chat,
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
GEMINI_CHAT_STREAM_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "gemini:generate_content",
|
||||
decision_kind: GEMINI_CHAT_STREAM_PLAN_KIND,
|
||||
report_kind: "gemini_chat_stream_success",
|
||||
family: LocalStandardSourceFamily::Gemini,
|
||||
mode: LocalStandardSourceMode::Chat,
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_spec, resolve_sync_spec};
|
||||
|
||||
#[test]
|
||||
fn resolves_gemini_chat_sync_spec() {
|
||||
let spec = resolve_sync_spec("gemini_chat_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "gemini:generate_content");
|
||||
assert_eq!(spec.report_kind, "gemini_chat_sync_finalize");
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_gemini_chat_stream_spec() {
|
||||
let spec = resolve_stream_spec("gemini_chat_stream").expect("spec");
|
||||
assert_eq!(spec.api_format, "gemini:generate_content");
|
||||
assert_eq!(spec.report_kind, "gemini_chat_stream_success");
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use crate::contracts::{GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND};
|
||||
use crate::formats::shared::family::{
|
||||
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
|
||||
};
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
GEMINI_CLI_SYNC_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "gemini:generate_content",
|
||||
decision_kind: GEMINI_CLI_SYNC_PLAN_KIND,
|
||||
report_kind: "gemini_cli_sync_finalize",
|
||||
family: LocalStandardSourceFamily::Gemini,
|
||||
mode: LocalStandardSourceMode::Cli,
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
GEMINI_CLI_STREAM_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "gemini:generate_content",
|
||||
decision_kind: GEMINI_CLI_STREAM_PLAN_KIND,
|
||||
report_kind: "gemini_cli_stream_success",
|
||||
family: LocalStandardSourceFamily::Gemini,
|
||||
mode: LocalStandardSourceMode::Cli,
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_spec, resolve_sync_spec};
|
||||
|
||||
#[test]
|
||||
fn resolves_gemini_cli_sync_spec() {
|
||||
let spec = resolve_sync_spec("gemini_cli_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "gemini:generate_content");
|
||||
assert_eq!(spec.report_kind, "gemini_cli_sync_finalize");
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_gemini_cli_stream_spec() {
|
||||
let spec = resolve_stream_spec("gemini_cli_stream").expect("spec");
|
||||
assert_eq!(spec.api_format, "gemini:generate_content");
|
||||
assert_eq!(spec.report_kind, "gemini_cli_stream_success");
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
pub mod chat_spec;
|
||||
pub mod cli_spec;
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
pub mod spec;
|
||||
pub mod stream;
|
||||
|
||||
use crate::formats::shared::family::LocalStandardSpec;
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
chat_spec::resolve_sync_spec(plan_kind).or_else(|| cli_spec::resolve_sync_spec(plan_kind))
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
chat_spec::resolve_stream_spec(plan_kind).or_else(|| cli_spec::resolve_stream_spec(plan_kind))
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::{
|
||||
formats::{
|
||||
context::FormatContext,
|
||||
openai::shared::{
|
||||
map_openai_reasoning_effort_to_gemini_budget,
|
||||
map_thinking_budget_to_openai_reasoning_effort,
|
||||
},
|
||||
shared::model_directives::{gemini_model_uses_thinking_level, ReasoningEffort},
|
||||
},
|
||||
protocol::canonical::{
|
||||
apply_gemini_request_extensions, canonical_extension_object_mut,
|
||||
canonical_openai_reasoning_effort, extract_gemini_model_from_path,
|
||||
gemini_contents_to_canonical_messages, gemini_extensions, gemini_generation_config,
|
||||
gemini_generation_config_extra, gemini_openai_extra_body,
|
||||
gemini_response_format_to_canonical, gemini_system_to_canonical_instructions,
|
||||
gemini_thinking_to_canonical, gemini_tool_choice_to_canonical, gemini_tools_to_canonical,
|
||||
gemini_value_by_case, CanonicalContentBlock, CanonicalMessage, CanonicalRequest,
|
||||
CanonicalResponseFormat, CanonicalRole, CanonicalToolChoice, CanonicalToolDefinition,
|
||||
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn from(body: &Value, ctx: &FormatContext) -> Option<CanonicalRequest> {
|
||||
from_raw(body, ctx.request_path.as_deref().unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
|
||||
to_raw(
|
||||
request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
ctx.upstream_is_stream,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn from_raw(body_json: &Value, request_path: &str) -> Option<CanonicalRequest> {
|
||||
let request = body_json.as_object()?;
|
||||
let mut canonical = CanonicalRequest {
|
||||
model: request
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| extract_gemini_model_from_path(request_path))
|
||||
.unwrap_or_default(),
|
||||
..CanonicalRequest::default()
|
||||
};
|
||||
|
||||
canonical.instructions = gemini_system_to_canonical_instructions(
|
||||
request
|
||||
.get("systemInstruction")
|
||||
.or_else(|| request.get("system_instruction")),
|
||||
)?;
|
||||
let system_text = canonical
|
||||
.instructions
|
||||
.iter()
|
||||
.map(|instruction| instruction.text.as_str())
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
if !system_text.is_empty() {
|
||||
canonical.system = Some(system_text);
|
||||
}
|
||||
canonical.messages = gemini_contents_to_canonical_messages(request.get("contents"))?;
|
||||
canonical.generation = gemini_generation_config(
|
||||
request
|
||||
.get("generationConfig")
|
||||
.or_else(|| request.get("generation_config")),
|
||||
);
|
||||
canonical.thinking = gemini_thinking_to_canonical(
|
||||
request
|
||||
.get("generationConfig")
|
||||
.or_else(|| request.get("generation_config")),
|
||||
);
|
||||
canonical.response_format = gemini_response_format_to_canonical(
|
||||
request
|
||||
.get("generationConfig")
|
||||
.or_else(|| request.get("generation_config")),
|
||||
);
|
||||
let (tools, builtin_tools, web_search_options, raw_tools) =
|
||||
gemini_tools_to_canonical(request.get("tools"))?;
|
||||
canonical.tools = tools;
|
||||
canonical.tool_choice = gemini_tool_choice_to_canonical(
|
||||
request
|
||||
.get("toolConfig")
|
||||
.or_else(|| request.get("tool_config")),
|
||||
);
|
||||
|
||||
canonical.extensions = gemini_extensions(
|
||||
request,
|
||||
&[
|
||||
"model",
|
||||
"systemInstruction",
|
||||
"system_instruction",
|
||||
"contents",
|
||||
"generationConfig",
|
||||
"generation_config",
|
||||
"tools",
|
||||
"toolConfig",
|
||||
"tool_config",
|
||||
"safetySettings",
|
||||
"safety_settings",
|
||||
"cachedContent",
|
||||
"cached_content",
|
||||
"stream",
|
||||
],
|
||||
);
|
||||
if let Some(generation_config) = request
|
||||
.get("generationConfig")
|
||||
.or_else(|| request.get("generation_config"))
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
let gemini_extension = canonical_extension_object_mut(&mut canonical.extensions, "gemini");
|
||||
if let Some(thinking_config) =
|
||||
gemini_value_by_case(generation_config, "thinkingConfig", "thinking_config").cloned()
|
||||
{
|
||||
gemini_extension.insert("thinking_config".to_string(), thinking_config);
|
||||
}
|
||||
if let Some(response_modalities) = gemini_value_by_case(
|
||||
generation_config,
|
||||
"responseModalities",
|
||||
"response_modalities",
|
||||
)
|
||||
.cloned()
|
||||
{
|
||||
gemini_extension.insert("response_modalities".to_string(), response_modalities);
|
||||
}
|
||||
let extra = gemini_generation_config_extra(generation_config);
|
||||
if !extra.is_empty() {
|
||||
gemini_extension.insert("generation_config_extra".to_string(), Value::Object(extra));
|
||||
}
|
||||
}
|
||||
if let Some(value) = request
|
||||
.get("safetySettings")
|
||||
.or_else(|| request.get("safety_settings"))
|
||||
.cloned()
|
||||
{
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
|
||||
.insert("safety_settings".to_string(), value);
|
||||
}
|
||||
if let Some(value) = request
|
||||
.get("cachedContent")
|
||||
.or_else(|| request.get("cached_content"))
|
||||
.cloned()
|
||||
{
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
|
||||
.insert("cached_content".to_string(), value);
|
||||
}
|
||||
if let Some(raw_tools) = raw_tools {
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
|
||||
.insert("raw_tools".to_string(), raw_tools);
|
||||
}
|
||||
if !builtin_tools.is_empty() {
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
|
||||
.insert("builtin_tools".to_string(), Value::Array(builtin_tools));
|
||||
}
|
||||
if let Some(tool_config) = request
|
||||
.get("toolConfig")
|
||||
.or_else(|| request.get("tool_config"))
|
||||
.cloned()
|
||||
{
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
|
||||
.insert("raw_tool_config".to_string(), tool_config);
|
||||
}
|
||||
if let Some(extra_body) = gemini_openai_extra_body(request) {
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "openai")
|
||||
.insert("extra_body".to_string(), extra_body);
|
||||
}
|
||||
if let Some(web_search_options) = web_search_options {
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "openai")
|
||||
.insert("web_search_options".to_string(), web_search_options);
|
||||
}
|
||||
Some(canonical)
|
||||
}
|
||||
|
||||
pub fn to_raw(
|
||||
canonical: &CanonicalRequest,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
let mut output = canonical_to_gemini_request_body(canonical, mapped_model, upstream_is_stream)?;
|
||||
apply_gemini_request_extensions(&mut output, &canonical.extensions)?;
|
||||
Some(output)
|
||||
}
|
||||
|
||||
fn canonical_to_gemini_request_body(
|
||||
canonical: &CanonicalRequest,
|
||||
mapped_model: &str,
|
||||
_upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
let mut output = Map::new();
|
||||
if !mapped_model.trim().is_empty() {
|
||||
output.insert(
|
||||
"model".to_string(),
|
||||
Value::String(mapped_model.trim().to_string()),
|
||||
);
|
||||
}
|
||||
output.insert(
|
||||
"contents".to_string(),
|
||||
Value::Array(compact_gemini_contents(
|
||||
canonical_messages_to_gemini_contents(&canonical.messages)?,
|
||||
)),
|
||||
);
|
||||
|
||||
if let Some(system_instruction) = canonical_system_instruction(canonical) {
|
||||
output.insert("systemInstruction".to_string(), system_instruction);
|
||||
}
|
||||
if let Some(generation_config) = canonical_generation_config_to_gemini(canonical, mapped_model)
|
||||
{
|
||||
output.insert("generationConfig".to_string(), generation_config);
|
||||
}
|
||||
if let Some(tools) = canonical_tools_to_gemini(canonical) {
|
||||
output.insert("tools".to_string(), tools);
|
||||
}
|
||||
if let Some(tool_config) = canonical_tool_choice_to_gemini(canonical.tool_choice.as_ref()) {
|
||||
output.insert("toolConfig".to_string(), tool_config);
|
||||
}
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
|
||||
fn canonical_system_instruction(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");
|
||||
let text = if text.trim().is_empty() {
|
||||
canonical.system.as_deref().unwrap_or_default().to_string()
|
||||
} else {
|
||||
text
|
||||
};
|
||||
(!text.trim().is_empty()).then(|| json!({ "parts": [{ "text": text }] }))
|
||||
}
|
||||
|
||||
fn canonical_messages_to_gemini_contents(messages: &[CanonicalMessage]) -> Option<Vec<Value>> {
|
||||
let mut contents = Vec::new();
|
||||
let mut tool_name_by_id = BTreeMap::new();
|
||||
for message in messages {
|
||||
let role = match message.role {
|
||||
CanonicalRole::Assistant => "model",
|
||||
CanonicalRole::System | CanonicalRole::Developer => continue,
|
||||
CanonicalRole::Tool | CanonicalRole::User | CanonicalRole::Unknown => "user",
|
||||
};
|
||||
let parts = canonical_blocks_to_gemini_parts(&message.content, &mut tool_name_by_id)?;
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
contents.push(json!({
|
||||
"role": role,
|
||||
"parts": parts,
|
||||
}));
|
||||
}
|
||||
Some(contents)
|
||||
}
|
||||
|
||||
fn canonical_blocks_to_gemini_parts(
|
||||
blocks: &[CanonicalContentBlock],
|
||||
tool_name_by_id: &mut BTreeMap<String, String>,
|
||||
) -> Option<Vec<Value>> {
|
||||
let mut parts = Vec::new();
|
||||
for block in blocks {
|
||||
if let Some(part) = canonical_block_to_gemini_part(block, tool_name_by_id)? {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
Some(parts)
|
||||
}
|
||||
|
||||
fn canonical_block_to_gemini_part(
|
||||
block: &CanonicalContentBlock,
|
||||
tool_name_by_id: &mut BTreeMap<String, String>,
|
||||
) -> Option<Option<Value>> {
|
||||
match block {
|
||||
CanonicalContentBlock::Text { text, .. } => Some(Some(json!({ "text": text }))),
|
||||
CanonicalContentBlock::Thinking {
|
||||
text, signature, ..
|
||||
} => {
|
||||
if text.trim().is_empty() {
|
||||
return Some(None);
|
||||
}
|
||||
let mut part = Map::new();
|
||||
part.insert("text".to_string(), Value::String(text.clone()));
|
||||
part.insert("thought".to_string(), Value::Bool(true));
|
||||
if let Some(signature) = signature.as_ref().filter(|value| !value.is_empty()) {
|
||||
part.insert(
|
||||
"thoughtSignature".to_string(),
|
||||
Value::String(signature.clone()),
|
||||
);
|
||||
}
|
||||
Some(Some(Value::Object(part)))
|
||||
}
|
||||
CanonicalContentBlock::Image {
|
||||
data,
|
||||
url,
|
||||
media_type,
|
||||
..
|
||||
} => Some(Some(canonical_media_to_gemini_part(
|
||||
media_type.as_deref().unwrap_or("image/png"),
|
||||
data.as_deref(),
|
||||
url.as_deref(),
|
||||
))),
|
||||
CanonicalContentBlock::File {
|
||||
data,
|
||||
file_url,
|
||||
media_type,
|
||||
..
|
||||
} => Some(Some(canonical_media_to_gemini_part(
|
||||
media_type.as_deref().unwrap_or("application/octet-stream"),
|
||||
data.as_deref(),
|
||||
file_url.as_deref(),
|
||||
))),
|
||||
CanonicalContentBlock::Audio {
|
||||
data, media_type, ..
|
||||
} => Some(data.as_ref().map(|data| {
|
||||
json!({
|
||||
"inlineData": {
|
||||
"mimeType": media_type.clone().unwrap_or_else(|| "audio/mpeg".to_string()),
|
||||
"data": data,
|
||||
}
|
||||
})
|
||||
})),
|
||||
CanonicalContentBlock::ToolUse {
|
||||
id, name, input, ..
|
||||
} => {
|
||||
tool_name_by_id.insert(id.clone(), name.clone());
|
||||
Some(Some(json!({
|
||||
"functionCall": {
|
||||
"id": id,
|
||||
"name": name,
|
||||
"args": gemini_function_args(input),
|
||||
}
|
||||
})))
|
||||
}
|
||||
CanonicalContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
name,
|
||||
output,
|
||||
content_text,
|
||||
..
|
||||
} => Some(Some(json!({
|
||||
"functionResponse": {
|
||||
"id": tool_use_id,
|
||||
"name": name.clone()
|
||||
.or_else(|| tool_name_by_id.get(tool_use_id).cloned())
|
||||
.unwrap_or_else(|| tool_use_id.clone()),
|
||||
"response": gemini_function_response(output.as_ref(), content_text.as_deref()),
|
||||
}
|
||||
}))),
|
||||
CanonicalContentBlock::Unknown { .. } => Some(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_media_to_gemini_part(
|
||||
media_type: &str,
|
||||
data: Option<&str>,
|
||||
url: Option<&str>,
|
||||
) -> Value {
|
||||
if let Some(data) = data.filter(|value| !value.is_empty()) {
|
||||
return json!({
|
||||
"inlineData": {
|
||||
"mimeType": media_type,
|
||||
"data": data,
|
||||
}
|
||||
});
|
||||
}
|
||||
json!({
|
||||
"fileData": {
|
||||
"mimeType": media_type,
|
||||
"fileUri": url.unwrap_or_default(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_generation_config_to_gemini(
|
||||
canonical: &CanonicalRequest,
|
||||
mapped_model: &str,
|
||||
) -> Option<Value> {
|
||||
let mut generation_config = Map::new();
|
||||
if let Some(value) = canonical.generation.max_tokens {
|
||||
generation_config.insert("maxOutputTokens".to_string(), Value::from(value));
|
||||
}
|
||||
insert_f64(
|
||||
&mut generation_config,
|
||||
"temperature",
|
||||
canonical.generation.temperature,
|
||||
);
|
||||
insert_f64(&mut generation_config, "topP", canonical.generation.top_p);
|
||||
if let Some(value) = canonical.generation.top_k {
|
||||
generation_config.insert("topK".to_string(), Value::from(value));
|
||||
}
|
||||
if let Some(value) = canonical.generation.n.filter(|value| *value > 1) {
|
||||
generation_config.insert("candidateCount".to_string(), Value::from(value));
|
||||
}
|
||||
if let Some(value) = canonical.generation.seed {
|
||||
generation_config.insert("seed".to_string(), Value::from(value));
|
||||
}
|
||||
if let Some(stop_sequences) = &canonical.generation.stop_sequences {
|
||||
generation_config.insert(
|
||||
"stopSequences".to_string(),
|
||||
Value::Array(stop_sequences.iter().cloned().map(Value::String).collect()),
|
||||
);
|
||||
}
|
||||
if let Some(response_format) = &canonical.response_format {
|
||||
apply_response_format_to_gemini_generation_config(&mut generation_config, response_format);
|
||||
}
|
||||
if let Some(thinking_config) = canonical.thinking.as_ref().and_then(|thinking| {
|
||||
thinking
|
||||
.extensions
|
||||
.get("gemini")
|
||||
.and_then(|value| value.get("thinking_config"))
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
let effort = canonical_openai_reasoning_effort(thinking);
|
||||
gemini_thinking_config_from_reasoning(mapped_model, effort, thinking.budget_tokens)
|
||||
})
|
||||
}) {
|
||||
generation_config.insert("thinkingConfig".to_string(), thinking_config);
|
||||
}
|
||||
(!generation_config.is_empty()).then_some(Value::Object(generation_config))
|
||||
}
|
||||
|
||||
fn gemini_thinking_config_from_reasoning(
|
||||
mapped_model: &str,
|
||||
effort: Option<&str>,
|
||||
budget_tokens: Option<u64>,
|
||||
) -> Option<Value> {
|
||||
if gemini_model_uses_thinking_level(mapped_model) {
|
||||
let level = effort
|
||||
.and_then(ReasoningEffort::parse)
|
||||
.or_else(|| {
|
||||
budget_tokens
|
||||
.map(map_thinking_budget_to_openai_reasoning_effort)
|
||||
.and_then(ReasoningEffort::parse)
|
||||
})
|
||||
.map(ReasoningEffort::as_gemini_level_value)?;
|
||||
return Some(json!({
|
||||
"includeThoughts": true,
|
||||
"thinkingLevel": level,
|
||||
}));
|
||||
}
|
||||
|
||||
let budget =
|
||||
budget_tokens.or_else(|| effort.and_then(map_openai_reasoning_effort_to_gemini_budget))?;
|
||||
Some(json!({
|
||||
"includeThoughts": true,
|
||||
"thinkingBudget": budget,
|
||||
}))
|
||||
}
|
||||
|
||||
fn apply_response_format_to_gemini_generation_config(
|
||||
generation_config: &mut Map<String, Value>,
|
||||
response_format: &CanonicalResponseFormat,
|
||||
) {
|
||||
match response_format.format_type.as_str() {
|
||||
"json_schema" => {
|
||||
generation_config.insert(
|
||||
"responseMimeType".to_string(),
|
||||
Value::String("application/json".to_string()),
|
||||
);
|
||||
if let Some(schema) = response_format
|
||||
.json_schema
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("schema"))
|
||||
.cloned()
|
||||
.or_else(|| response_format.json_schema.clone())
|
||||
{
|
||||
let mut schema = schema;
|
||||
clean_gemini_schema(&mut schema);
|
||||
generation_config.insert("responseSchema".to_string(), schema);
|
||||
}
|
||||
}
|
||||
"json_object" => {
|
||||
generation_config.insert(
|
||||
"responseMimeType".to_string(),
|
||||
Value::String("application/json".to_string()),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_tools_to_gemini(canonical: &CanonicalRequest) -> Option<Value> {
|
||||
let mut declarations = Vec::new();
|
||||
let mut tools = Vec::new();
|
||||
let mut google_search = canonical
|
||||
.extensions
|
||||
.get("openai")
|
||||
.and_then(Value::as_object)
|
||||
.is_some_and(|value| value.contains_key("web_search_options"));
|
||||
let mut code_execution = false;
|
||||
let mut url_context = false;
|
||||
|
||||
for tool in &canonical.tools {
|
||||
match normalize_gemini_builtin_tool_name(&tool.name) {
|
||||
Some("googleSearch") => {
|
||||
google_search = true;
|
||||
continue;
|
||||
}
|
||||
Some("codeExecution") => {
|
||||
code_execution = true;
|
||||
continue;
|
||||
}
|
||||
Some("urlContext") => {
|
||||
url_context = true;
|
||||
continue;
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => {}
|
||||
}
|
||||
if tool
|
||||
.extensions
|
||||
.get("openai_responses")
|
||||
.or_else(|| {
|
||||
tool.extensions
|
||||
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
|
||||
})
|
||||
.and_then(|value| value.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|tool_type| tool_type.starts_with("web_search"))
|
||||
{
|
||||
google_search = true;
|
||||
continue;
|
||||
}
|
||||
declarations.push(canonical_tool_to_gemini_declaration(tool));
|
||||
}
|
||||
if code_execution {
|
||||
tools.push(json!({ "codeExecution": {} }));
|
||||
}
|
||||
if google_search {
|
||||
tools.push(json!({ "googleSearch": {} }));
|
||||
}
|
||||
if url_context {
|
||||
tools.push(json!({ "urlContext": {} }));
|
||||
}
|
||||
if !declarations.is_empty() {
|
||||
tools.push(json!({ "functionDeclarations": declarations }));
|
||||
}
|
||||
if let Some(builtin_tools) = canonical
|
||||
.extensions
|
||||
.get("gemini")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|value| value.get("builtin_tools"))
|
||||
.and_then(Value::as_array)
|
||||
{
|
||||
tools.extend(builtin_tools.iter().cloned());
|
||||
}
|
||||
(!tools.is_empty()).then_some(Value::Array(tools))
|
||||
}
|
||||
|
||||
fn canonical_tool_to_gemini_declaration(tool: &CanonicalToolDefinition) -> Value {
|
||||
let mut declaration = Map::new();
|
||||
declaration.insert("name".to_string(), Value::String(tool.name.clone()));
|
||||
if let Some(description) = &tool.description {
|
||||
declaration.insert(
|
||||
"description".to_string(),
|
||||
Value::String(description.clone()),
|
||||
);
|
||||
}
|
||||
declaration.insert(
|
||||
"parameters".to_string(),
|
||||
tool.parameters
|
||||
.clone()
|
||||
.map(|mut schema| {
|
||||
clean_gemini_schema(&mut schema);
|
||||
schema
|
||||
})
|
||||
.unwrap_or_else(|| json!({})),
|
||||
);
|
||||
Value::Object(declaration)
|
||||
}
|
||||
|
||||
fn canonical_tool_choice_to_gemini(choice: Option<&CanonicalToolChoice>) -> Option<Value> {
|
||||
let choice = choice?;
|
||||
let mode = match choice {
|
||||
CanonicalToolChoice::Auto => "AUTO",
|
||||
CanonicalToolChoice::None => "NONE",
|
||||
CanonicalToolChoice::Required | CanonicalToolChoice::Tool { .. } => "ANY",
|
||||
};
|
||||
let mut function_calling_config = Map::new();
|
||||
function_calling_config.insert("mode".to_string(), Value::String(mode.to_string()));
|
||||
if let CanonicalToolChoice::Tool { name } = choice {
|
||||
function_calling_config.insert(
|
||||
"allowedFunctionNames".to_string(),
|
||||
Value::Array(vec![Value::String(name.clone())]),
|
||||
);
|
||||
}
|
||||
Some(json!({
|
||||
"functionCallingConfig": Value::Object(function_calling_config),
|
||||
}))
|
||||
}
|
||||
|
||||
fn gemini_function_args(input: &Value) -> Value {
|
||||
match input {
|
||||
Value::Object(_) => input.clone(),
|
||||
Value::Null => json!({}),
|
||||
other => json!({ "value": other.clone() }),
|
||||
}
|
||||
}
|
||||
|
||||
fn gemini_function_response(output: Option<&Value>, content_text: Option<&str>) -> Value {
|
||||
match output {
|
||||
Some(value) => json!({ "result": value }),
|
||||
None => json!({ "result": content_text.unwrap_or_default() }),
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_gemini_contents(contents: Vec<Value>) -> Vec<Value> {
|
||||
let mut compact: Vec<Value> = Vec::new();
|
||||
for content in contents {
|
||||
let role = content
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let parts = content
|
||||
.get("parts")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(last) = compact.last_mut() {
|
||||
let last_role = last.get("role").and_then(Value::as_str).unwrap_or_default();
|
||||
if last_role == role {
|
||||
if let Some(last_parts) = last
|
||||
.as_object_mut()
|
||||
.and_then(|object| object.get_mut("parts"))
|
||||
.and_then(Value::as_array_mut)
|
||||
{
|
||||
last_parts.extend(parts);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
compact.push(json!({
|
||||
"role": role,
|
||||
"parts": parts,
|
||||
}));
|
||||
}
|
||||
compact
|
||||
}
|
||||
|
||||
fn normalize_gemini_builtin_tool_name(name: &str) -> Option<&'static str> {
|
||||
match name
|
||||
.trim()
|
||||
.replace(['_', '-', ' '], "")
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"googlesearch" | "websearch" | "websearchpreview" => Some("googleSearch"),
|
||||
"codeexecution" => Some("codeExecution"),
|
||||
"urlcontext" => Some("urlContext"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_f64(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));
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_gemini_schema(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for inner in object.values_mut() {
|
||||
clean_gemini_schema(inner);
|
||||
}
|
||||
if object.get("type").and_then(Value::as_str) == Some("object")
|
||||
&& !object.contains_key("properties")
|
||||
{
|
||||
object.insert("properties".to_string(), Value::Object(Map::new()));
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
clean_gemini_schema(item);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::{
|
||||
formats::context::FormatContext,
|
||||
protocol::canonical::{
|
||||
canonical_extension_object_mut, gemini_extensions, gemini_part_to_canonical_block,
|
||||
gemini_stop_reason_to_canonical, gemini_usage_to_canonical, CanonicalContentBlock,
|
||||
CanonicalResponse, CanonicalResponseOutput, CanonicalRole, CanonicalStopReason,
|
||||
CanonicalUsage,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
|
||||
from_raw(body)
|
||||
}
|
||||
|
||||
pub fn to(response: &CanonicalResponse, ctx: &FormatContext) -> Option<Value> {
|
||||
to_raw(response, &ctx.report_context_value())
|
||||
}
|
||||
|
||||
pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
|
||||
let body = body_json.as_object()?;
|
||||
if body.contains_key("error") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let candidates = body.get("candidates")?.as_array()?;
|
||||
let mut outputs = Vec::new();
|
||||
for (fallback_index, candidate) in candidates.iter().enumerate() {
|
||||
let candidate_object = candidate.as_object()?;
|
||||
let parts = candidate_object
|
||||
.get("content")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|content| content.get("parts"))
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[]);
|
||||
let content = parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, part)| gemini_part_to_canonical_block(part, index))
|
||||
.collect::<Vec<_>>();
|
||||
let mut stop_reason = candidate_object
|
||||
.get("finishReason")
|
||||
.or_else(|| candidate_object.get("finish_reason"))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(gemini_stop_reason_to_canonical);
|
||||
if content
|
||||
.iter()
|
||||
.any(|block| matches!(block, CanonicalContentBlock::ToolUse { .. }))
|
||||
&& stop_reason
|
||||
.as_ref()
|
||||
.is_none_or(|reason| matches!(reason, CanonicalStopReason::EndTurn))
|
||||
{
|
||||
stop_reason = Some(CanonicalStopReason::ToolUse);
|
||||
}
|
||||
outputs.push(CanonicalResponseOutput {
|
||||
index: candidate_object
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| usize::try_from(value).ok())
|
||||
.unwrap_or(fallback_index),
|
||||
role: CanonicalRole::Assistant,
|
||||
content,
|
||||
stop_reason,
|
||||
extensions: Default::default(),
|
||||
});
|
||||
}
|
||||
let content = outputs
|
||||
.first()
|
||||
.map(|output| output.content.clone())
|
||||
.unwrap_or_default();
|
||||
let stop_reason = outputs
|
||||
.first()
|
||||
.and_then(|output| output.stop_reason.clone());
|
||||
|
||||
let mut canonical = CanonicalResponse {
|
||||
id: body
|
||||
.get("responseId")
|
||||
.or_else(|| body.get("_v1internal_response_id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("gemini-local-finalize")
|
||||
.to_string(),
|
||||
model: body
|
||||
.get("modelVersion")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
outputs,
|
||||
content,
|
||||
stop_reason,
|
||||
usage: gemini_usage_to_canonical(body.get("usageMetadata")),
|
||||
extensions: gemini_extensions(
|
||||
body,
|
||||
&[
|
||||
"responseId",
|
||||
"_v1internal_response_id",
|
||||
"modelVersion",
|
||||
"candidates",
|
||||
"usageMetadata",
|
||||
],
|
||||
),
|
||||
};
|
||||
if let Some(candidates) = body.get("candidates").cloned() {
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
|
||||
.insert("raw_candidates".to_string(), candidates);
|
||||
}
|
||||
Some(canonical)
|
||||
}
|
||||
|
||||
pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value) -> Option<Value> {
|
||||
let mut response = canonical_to_gemini_response(canonical, report_context)?;
|
||||
if let Some(object) = response.as_object_mut() {
|
||||
if let Some(gemini) = canonical
|
||||
.extensions
|
||||
.get("gemini")
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
for (key, value) in gemini {
|
||||
if key == "raw_candidates" || object.contains_key(key) {
|
||||
continue;
|
||||
}
|
||||
object.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(response)
|
||||
}
|
||||
|
||||
fn canonical_to_gemini_response(
|
||||
canonical: &CanonicalResponse,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
let outputs = if canonical.outputs.is_empty() {
|
||||
vec![CanonicalResponseOutput {
|
||||
index: 0,
|
||||
role: crate::protocol::canonical::CanonicalRole::Assistant,
|
||||
content: canonical.content.clone(),
|
||||
stop_reason: canonical.stop_reason.clone(),
|
||||
extensions: Default::default(),
|
||||
}]
|
||||
} else {
|
||||
canonical.outputs.clone()
|
||||
};
|
||||
let mut candidates = Vec::new();
|
||||
for output in outputs {
|
||||
let parts = canonical_blocks_to_gemini_parts(&output.content)?;
|
||||
candidates.push(json!({
|
||||
"index": output.index,
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": parts,
|
||||
},
|
||||
"finishReason": canonical_stop_reason_to_gemini(
|
||||
output.stop_reason.as_ref().or(canonical.stop_reason.as_ref())
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
let mut response = Map::new();
|
||||
response.insert(
|
||||
"responseId".to_string(),
|
||||
Value::String(if canonical.id.trim().is_empty() {
|
||||
"resp-local-finalize".to_string()
|
||||
} else {
|
||||
canonical.id.clone()
|
||||
}),
|
||||
);
|
||||
response.insert(
|
||||
"modelVersion".to_string(),
|
||||
Value::String(
|
||||
if canonical.model.trim().is_empty() || canonical.model == "unknown" {
|
||||
report_context
|
||||
.get("mapped_model")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
} else {
|
||||
canonical.model.clone()
|
||||
},
|
||||
),
|
||||
);
|
||||
response.insert("candidates".to_string(), Value::Array(candidates));
|
||||
if let Some(usage) = &canonical.usage {
|
||||
response.insert(
|
||||
"usageMetadata".to_string(),
|
||||
canonical_usage_to_gemini_usage_metadata(usage),
|
||||
);
|
||||
}
|
||||
Some(Value::Object(response))
|
||||
}
|
||||
|
||||
fn canonical_blocks_to_gemini_parts(blocks: &[CanonicalContentBlock]) -> Option<Vec<Value>> {
|
||||
let mut parts = Vec::new();
|
||||
for block in blocks {
|
||||
if let Some(part) = canonical_block_to_gemini_part(block)? {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
parts.push(json!({ "text": "" }));
|
||||
}
|
||||
Some(parts)
|
||||
}
|
||||
|
||||
fn canonical_block_to_gemini_part(block: &CanonicalContentBlock) -> Option<Option<Value>> {
|
||||
match block {
|
||||
CanonicalContentBlock::Text { text, .. } => Some(Some(json!({ "text": text }))),
|
||||
CanonicalContentBlock::Thinking {
|
||||
text, signature, ..
|
||||
} => {
|
||||
if text.trim().is_empty() {
|
||||
return Some(None);
|
||||
}
|
||||
let mut part = Map::new();
|
||||
part.insert("text".to_string(), Value::String(text.clone()));
|
||||
part.insert("thought".to_string(), Value::Bool(true));
|
||||
if let Some(signature) = signature.as_ref().filter(|value| !value.is_empty()) {
|
||||
part.insert(
|
||||
"thoughtSignature".to_string(),
|
||||
Value::String(signature.clone()),
|
||||
);
|
||||
}
|
||||
Some(Some(Value::Object(part)))
|
||||
}
|
||||
CanonicalContentBlock::ToolUse {
|
||||
id, name, input, ..
|
||||
} => Some(Some(json!({
|
||||
"functionCall": {
|
||||
"id": id,
|
||||
"name": name,
|
||||
"args": gemini_function_args(input),
|
||||
}
|
||||
}))),
|
||||
CanonicalContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
name,
|
||||
output,
|
||||
content_text,
|
||||
..
|
||||
} => Some(Some(json!({
|
||||
"functionResponse": {
|
||||
"id": tool_use_id,
|
||||
"name": name.clone().unwrap_or_else(|| tool_use_id.clone()),
|
||||
"response": gemini_function_response(output.as_ref(), content_text.as_deref()),
|
||||
}
|
||||
}))),
|
||||
CanonicalContentBlock::Image {
|
||||
data,
|
||||
url,
|
||||
media_type,
|
||||
..
|
||||
} => Some(Some(canonical_media_to_gemini_part(
|
||||
media_type.as_deref().unwrap_or("image/png"),
|
||||
data.as_deref(),
|
||||
url.as_deref(),
|
||||
))),
|
||||
CanonicalContentBlock::File {
|
||||
data,
|
||||
file_url,
|
||||
media_type,
|
||||
..
|
||||
} => Some(Some(canonical_media_to_gemini_part(
|
||||
media_type.as_deref().unwrap_or("application/octet-stream"),
|
||||
data.as_deref(),
|
||||
file_url.as_deref(),
|
||||
))),
|
||||
CanonicalContentBlock::Audio {
|
||||
data, media_type, ..
|
||||
} => Some(data.as_ref().map(|data| {
|
||||
json!({
|
||||
"inlineData": {
|
||||
"mimeType": media_type.clone().unwrap_or_else(|| "audio/mpeg".to_string()),
|
||||
"data": data,
|
||||
}
|
||||
})
|
||||
})),
|
||||
CanonicalContentBlock::Unknown { .. } => Some(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_media_to_gemini_part(
|
||||
media_type: &str,
|
||||
data: Option<&str>,
|
||||
url: Option<&str>,
|
||||
) -> Value {
|
||||
if let Some(data) = data.filter(|value| !value.is_empty()) {
|
||||
return json!({
|
||||
"inlineData": {
|
||||
"mimeType": media_type,
|
||||
"data": data,
|
||||
}
|
||||
});
|
||||
}
|
||||
json!({
|
||||
"fileData": {
|
||||
"mimeType": media_type,
|
||||
"fileUri": url.unwrap_or_default(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn gemini_function_args(input: &Value) -> Value {
|
||||
match input {
|
||||
Value::Object(_) => input.clone(),
|
||||
Value::Null => json!({}),
|
||||
other => json!({ "value": other.clone() }),
|
||||
}
|
||||
}
|
||||
|
||||
fn gemini_function_response(output: Option<&Value>, content_text: Option<&str>) -> Value {
|
||||
match output {
|
||||
Some(Value::Object(object)) => Value::Object(object.clone()),
|
||||
Some(value) => json!({ "result": value }),
|
||||
None => json!({ "result": content_text.unwrap_or_default() }),
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_stop_reason_to_gemini(reason: Option<&CanonicalStopReason>) -> Value {
|
||||
Value::String(
|
||||
match reason {
|
||||
Some(CanonicalStopReason::MaxTokens) => "MAX_TOKENS",
|
||||
Some(CanonicalStopReason::ContentFiltered) | Some(CanonicalStopReason::Refusal) => {
|
||||
"SAFETY"
|
||||
}
|
||||
Some(CanonicalStopReason::Unknown) => "OTHER",
|
||||
_ => "STOP",
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn canonical_usage_to_gemini_usage_metadata(usage: &CanonicalUsage) -> Value {
|
||||
let mut out = Map::new();
|
||||
out.insert(
|
||||
"promptTokenCount".to_string(),
|
||||
Value::from(usage.input_tokens),
|
||||
);
|
||||
out.insert(
|
||||
"candidatesTokenCount".to_string(),
|
||||
Value::from(usage.output_tokens.saturating_sub(usage.reasoning_tokens)),
|
||||
);
|
||||
out.insert(
|
||||
"totalTokenCount".to_string(),
|
||||
Value::from(usage.total_tokens),
|
||||
);
|
||||
if usage.reasoning_tokens > 0 {
|
||||
out.insert(
|
||||
"thoughtsTokenCount".to_string(),
|
||||
Value::from(usage.reasoning_tokens),
|
||||
);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub use super::{chat_spec, cli_spec, resolve_stream_spec, resolve_sync_spec};
|
||||
File diff suppressed because it is too large
Load Diff
4
crates/aether-ai-formats/src/formats/gemini/mod.rs
Normal file
4
crates/aether-ai-formats/src/formats/gemini/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod embedding;
|
||||
pub mod files;
|
||||
pub mod generate_content;
|
||||
pub mod video;
|
||||
1
crates/aether-ai-formats/src/formats/gemini/video/mod.rs
Normal file
1
crates/aether-ai-formats/src/formats/gemini/video/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod spec;
|
||||
27
crates/aether-ai-formats/src/formats/gemini/video/spec.rs
Normal file
27
crates/aether-ai-formats/src/formats/gemini/video/spec.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use crate::contracts::GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND;
|
||||
use crate::formats::shared::video::{LocalVideoCreateFamily, LocalVideoCreateSpec};
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalVideoCreateSpec> {
|
||||
match plan_kind {
|
||||
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND => Some(LocalVideoCreateSpec {
|
||||
api_format: "gemini:video",
|
||||
decision_kind: GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
||||
report_kind: "gemini_video_create_sync_finalize",
|
||||
family: LocalVideoCreateFamily::Gemini,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_sync_spec, LocalVideoCreateFamily};
|
||||
|
||||
#[test]
|
||||
fn resolves_gemini_video_create_spec() {
|
||||
let spec = resolve_sync_spec("gemini_video_create_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "gemini:video");
|
||||
assert_eq!(spec.family, LocalVideoCreateFamily::Gemini);
|
||||
assert_eq!(spec.report_kind, "gemini_video_create_sync_finalize");
|
||||
}
|
||||
}
|
||||
307
crates/aether-ai-formats/src/formats/id.rs
Normal file
307
crates/aether-ai-formats/src/formats/id.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
//! Format identity and aliases.
|
||||
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum FormatFamily {
|
||||
OpenAi,
|
||||
Claude,
|
||||
Gemini,
|
||||
Jina,
|
||||
Doubao,
|
||||
}
|
||||
|
||||
#[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,
|
||||
OpenAiEmbedding,
|
||||
OpenAiRerank,
|
||||
ClaudeMessages,
|
||||
GeminiGenerateContent,
|
||||
GeminiEmbedding,
|
||||
JinaEmbedding,
|
||||
JinaRerank,
|
||||
DoubaoEmbedding,
|
||||
}
|
||||
|
||||
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
|
||||
| Self::OpenAiEmbedding
|
||||
| Self::OpenAiRerank => FormatFamily::OpenAi,
|
||||
Self::ClaudeMessages => FormatFamily::Claude,
|
||||
Self::GeminiGenerateContent | Self::GeminiEmbedding => FormatFamily::Gemini,
|
||||
Self::JinaEmbedding | Self::JinaRerank => FormatFamily::Jina,
|
||||
Self::DoubaoEmbedding => FormatFamily::Doubao,
|
||||
}
|
||||
}
|
||||
|
||||
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::OpenAiEmbedding => "openai:embedding",
|
||||
Self::OpenAiRerank => "openai:rerank",
|
||||
Self::ClaudeMessages => "claude:messages",
|
||||
Self::GeminiGenerateContent => "gemini:generate_content",
|
||||
Self::GeminiEmbedding => "gemini:embedding",
|
||||
Self::JinaEmbedding => "jina:embedding",
|
||||
Self::JinaRerank => "jina:rerank",
|
||||
Self::DoubaoEmbedding => "doubao:embedding",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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" | "/v1/responses" => Ok(Self::OpenAiResponses),
|
||||
"openai:responses:compact" | "/v1/responses/compact" => {
|
||||
Ok(Self::OpenAiResponsesCompact)
|
||||
}
|
||||
"openai:embedding" | "/v1/embeddings" => Ok(Self::OpenAiEmbedding),
|
||||
"openai:rerank" | "/v1/rerank" => Ok(Self::OpenAiRerank),
|
||||
"claude:messages" | "/v1/messages" => Ok(Self::ClaudeMessages),
|
||||
"gemini:generate_content" => Ok(Self::GeminiGenerateContent),
|
||||
"gemini:embedding" => Ok(Self::GeminiEmbedding),
|
||||
"jina:embedding" | "/jina/v1/embeddings" => Ok(Self::JinaEmbedding),
|
||||
"jina:rerank" | "/jina/v1/rerank" => Ok(Self::JinaRerank),
|
||||
"doubao:embedding" => Ok(Self::DoubaoEmbedding),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_api_format_alias(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
pub fn api_format_alias_matches(left: &str, right: &str) -> bool {
|
||||
normalize_api_format_alias(left) == normalize_api_format_alias(right)
|
||||
}
|
||||
|
||||
pub fn api_format_storage_aliases(value: &str) -> Vec<String> {
|
||||
vec![normalize_api_format_alias(value)]
|
||||
}
|
||||
|
||||
pub fn is_openai_responses_format(value: &str) -> bool {
|
||||
normalize_api_format_alias(value) == "openai:responses"
|
||||
}
|
||||
|
||||
pub fn is_openai_responses_compact_format(value: &str) -> bool {
|
||||
normalize_api_format_alias(value) == "openai:responses:compact"
|
||||
}
|
||||
|
||||
pub fn is_openai_responses_family_format(value: &str) -> bool {
|
||||
matches!(
|
||||
normalize_api_format_alias(value).as_str(),
|
||||
"openai:responses" | "openai:responses:compact"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
api_format_alias_matches, api_format_storage_aliases, normalize_api_format_alias, FormatId,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn retired_api_formats_do_not_parse() {
|
||||
assert_eq!(FormatId::parse("openai:cli"), None);
|
||||
assert_eq!(FormatId::parse("openai:compact"), None);
|
||||
assert_eq!(FormatId::parse("claude:chat"), None);
|
||||
assert_eq!(FormatId::parse("claude:cli"), None);
|
||||
assert_eq!(FormatId::parse("gemini:chat"), None);
|
||||
assert_eq!(FormatId::parse("gemini:cli"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_embedding_api_formats() {
|
||||
assert_eq!(
|
||||
FormatId::parse("openai:embedding"),
|
||||
Some(FormatId::OpenAiEmbedding)
|
||||
);
|
||||
assert_eq!(
|
||||
FormatId::parse("/v1/embeddings"),
|
||||
Some(FormatId::OpenAiEmbedding)
|
||||
);
|
||||
assert_eq!(
|
||||
FormatId::parse("gemini:embedding"),
|
||||
Some(FormatId::GeminiEmbedding)
|
||||
);
|
||||
assert_eq!(
|
||||
FormatId::parse("jina:embedding"),
|
||||
Some(FormatId::JinaEmbedding)
|
||||
);
|
||||
assert_eq!(
|
||||
FormatId::parse("/jina/v1/embeddings"),
|
||||
Some(FormatId::JinaEmbedding)
|
||||
);
|
||||
assert_eq!(
|
||||
FormatId::parse("doubao:embedding"),
|
||||
Some(FormatId::DoubaoEmbedding)
|
||||
);
|
||||
assert_eq!(FormatId::OpenAiEmbedding.to_string(), "openai:embedding");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_format_ids_keep_provider_family_and_default_profile() {
|
||||
use super::{FormatFamily, FormatProfile};
|
||||
|
||||
for (format, family) in [
|
||||
(FormatId::OpenAiEmbedding, FormatFamily::OpenAi),
|
||||
(FormatId::GeminiEmbedding, FormatFamily::Gemini),
|
||||
(FormatId::JinaEmbedding, FormatFamily::Jina),
|
||||
(FormatId::DoubaoEmbedding, FormatFamily::Doubao),
|
||||
] {
|
||||
assert_eq!(format.family(), family);
|
||||
assert_eq!(format.profile(), FormatProfile::Default);
|
||||
assert_eq!(FormatId::parse(format.as_str()), Some(format));
|
||||
assert_eq!(format.to_string(), format.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_rerank_api_formats() {
|
||||
assert_eq!(
|
||||
FormatId::parse("openai:rerank"),
|
||||
Some(FormatId::OpenAiRerank)
|
||||
);
|
||||
assert_eq!(FormatId::parse("/v1/rerank"), Some(FormatId::OpenAiRerank));
|
||||
assert_eq!(FormatId::parse("jina:rerank"), Some(FormatId::JinaRerank));
|
||||
assert_eq!(
|
||||
FormatId::parse("/jina/v1/rerank"),
|
||||
Some(FormatId::JinaRerank)
|
||||
);
|
||||
assert_eq!(FormatId::OpenAiRerank.to_string(), "openai:rerank");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerank_format_ids_keep_provider_family_and_default_profile() {
|
||||
use super::{FormatFamily, FormatProfile};
|
||||
|
||||
for (format, family) in [
|
||||
(FormatId::OpenAiRerank, FormatFamily::OpenAi),
|
||||
(FormatId::JinaRerank, FormatFamily::Jina),
|
||||
] {
|
||||
assert_eq!(format.family(), family);
|
||||
assert_eq!(format.profile(), FormatProfile::Default);
|
||||
assert_eq!(FormatId::parse(format.as_str()), Some(format));
|
||||
assert_eq!(format.to_string(), format.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_embedding_format() {
|
||||
assert_eq!(FormatId::parse("embedding"), None);
|
||||
assert_eq!(FormatId::parse("openai:embeddings"), None);
|
||||
assert_eq!(FormatId::parse("claude:embedding"), None);
|
||||
assert_eq!(FormatId::parse("gemini:embed_content"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_api_format_aliases() {
|
||||
assert_eq!(
|
||||
normalize_api_format_alias(" OPENAI:RESPONSES "),
|
||||
"openai:responses"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_format_alias("OPENAI:RESPONSES:COMPACT"),
|
||||
"openai:responses:compact"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_format_alias("CLAUDE:MESSAGES"),
|
||||
"claude:messages"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_format_alias("GEMINI:GENERATE_CONTENT"),
|
||||
"gemini:generate_content"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_format_alias("OPENAI:EMBEDDING"),
|
||||
"openai:embedding"
|
||||
);
|
||||
assert_eq!(normalize_api_format_alias("openai:image"), "openai:image");
|
||||
assert_eq!(normalize_api_format_alias("openai:video"), "openai:video");
|
||||
assert_eq!(normalize_api_format_alias("gemini:video"), "gemini:video");
|
||||
assert_eq!(normalize_api_format_alias("gemini:files"), "gemini:files");
|
||||
assert!(!api_format_alias_matches("claude:cli", "claude:messages"));
|
||||
assert!(!api_format_alias_matches(
|
||||
"gemini:chat",
|
||||
"gemini:generate_content"
|
||||
));
|
||||
assert!(!api_format_alias_matches("openai:cli", "openai:responses"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_aliases_only_include_normalized_value() {
|
||||
assert_eq!(
|
||||
api_format_storage_aliases("openai:responses"),
|
||||
vec!["openai:responses".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
api_format_storage_aliases("openai:responses:compact"),
|
||||
vec!["openai:responses:compact".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
api_format_storage_aliases("claude:messages"),
|
||||
vec!["claude:messages".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
api_format_storage_aliases("gemini:generate_content"),
|
||||
vec!["gemini:generate_content".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
api_format_storage_aliases("openai:embedding"),
|
||||
vec!["openai:embedding".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
api_format_storage_aliases("gemini:embedding"),
|
||||
vec!["gemini:embedding".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
api_format_storage_aliases("jina:embedding"),
|
||||
vec!["jina:embedding".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
api_format_storage_aliases("doubao:embedding"),
|
||||
vec!["doubao:embedding".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
@@ -0,0 +1,17 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::formats::context::FormatContext;
|
||||
use crate::protocol::canonical::CanonicalRequest;
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
|
||||
crate::formats::openai::embedding::request::from_namespace(body, "jina")
|
||||
}
|
||||
|
||||
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
|
||||
crate::formats::openai::embedding::request::to_openai_like(
|
||||
request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
"jina",
|
||||
true,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::protocol::canonical::CanonicalEmbeddingResponse;
|
||||
|
||||
pub fn from(body: &Value) -> Option<CanonicalEmbeddingResponse> {
|
||||
crate::formats::openai::embedding::response::from_namespace(body, "jina")
|
||||
}
|
||||
|
||||
pub fn to(response: &CanonicalEmbeddingResponse) -> Option<Value> {
|
||||
Some(crate::formats::openai::embedding::response::to_openai_like(
|
||||
response, "jina",
|
||||
))
|
||||
}
|
||||
2
crates/aether-ai-formats/src/formats/jina/mod.rs
Normal file
2
crates/aether-ai-formats/src/formats/jina/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod embedding;
|
||||
pub mod rerank;
|
||||
1
crates/aether-ai-formats/src/formats/jina/rerank/mod.rs
Normal file
1
crates/aether-ai-formats/src/formats/jina/rerank/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod request;
|
||||
16
crates/aether-ai-formats/src/formats/jina/rerank/request.rs
Normal file
16
crates/aether-ai-formats/src/formats/jina/rerank/request.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::formats::context::FormatContext;
|
||||
use crate::protocol::canonical::CanonicalRequest;
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
|
||||
crate::formats::openai::rerank::request::from_namespace(body, "jina")
|
||||
}
|
||||
|
||||
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
|
||||
crate::formats::openai::rerank::request::to_openai_like(
|
||||
request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
"jina",
|
||||
)
|
||||
}
|
||||
674
crates/aether-ai-formats/src/formats/matrix.rs
Normal file
674
crates/aether-ai-formats/src/formats/matrix.rs
Normal file
@@ -0,0 +1,674 @@
|
||||
use crate::{
|
||||
api_format_alias_matches,
|
||||
formats::id::{is_openai_responses_compact_format, normalize_api_format_alias},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RequestConversionKind {
|
||||
ToOpenAIChat,
|
||||
ToOpenAiResponses,
|
||||
ToClaudeStandard,
|
||||
ToGeminiStandard,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SyncChatResponseConversionKind {
|
||||
ToOpenAIChat,
|
||||
ToClaudeChat,
|
||||
ToGeminiChat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SyncCliResponseConversionKind {
|
||||
ToOpenAiResponses,
|
||||
ToClaudeCli,
|
||||
ToGeminiCli,
|
||||
}
|
||||
|
||||
const NON_COMPACT_STANDARD_CANDIDATE_API_FORMATS: &[&str] = &[
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
"claude:messages",
|
||||
"gemini:generate_content",
|
||||
];
|
||||
const STANDARD_API_FORMAT_ORDER: &[&str] = &[
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
"claude:messages",
|
||||
"gemini:generate_content",
|
||||
];
|
||||
const EMBEDDING_CANDIDATE_API_FORMATS: &[&str] = &[
|
||||
"openai:embedding",
|
||||
"gemini:embedding",
|
||||
"jina:embedding",
|
||||
"doubao:embedding",
|
||||
];
|
||||
const RERANK_CANDIDATE_API_FORMATS: &[&str] = &["openai:rerank", "jina:rerank"];
|
||||
|
||||
pub fn request_candidate_api_format_preference(
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
) -> Option<(u8, u8)> {
|
||||
let client_api_format = normalize_api_format_alias(client_api_format);
|
||||
let provider_api_format = normalize_api_format_alias(provider_api_format);
|
||||
|
||||
if client_api_format == "openai:responses:compact" {
|
||||
return (provider_api_format == "openai:responses:compact").then_some((0, 0));
|
||||
}
|
||||
if is_embedding_api_format(client_api_format.as_str()) {
|
||||
return is_embedding_api_format(provider_api_format.as_str()).then_some((
|
||||
if client_api_format == provider_api_format {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
},
|
||||
embedding_api_format_priority(provider_api_format.as_str()),
|
||||
));
|
||||
}
|
||||
if is_rerank_api_format(client_api_format.as_str()) {
|
||||
return is_rerank_api_format(provider_api_format.as_str()).then_some((
|
||||
if client_api_format == provider_api_format {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
},
|
||||
rerank_api_format_priority(provider_api_format.as_str()),
|
||||
));
|
||||
}
|
||||
|
||||
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_api_format == provider_api_format {
|
||||
0
|
||||
} else if client_kind == provider_kind {
|
||||
1
|
||||
} else if client_family == provider_family {
|
||||
2
|
||||
} else {
|
||||
3
|
||||
};
|
||||
|
||||
Some((
|
||||
preference_bucket,
|
||||
standard_api_format_priority(provider_api_format.as_str()),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn request_candidate_api_formats(
|
||||
client_api_format: &str,
|
||||
_require_streaming: bool,
|
||||
) -> Vec<&'static str> {
|
||||
let client_api_format = normalize_api_format_alias(client_api_format);
|
||||
if client_api_format == "openai:responses:compact" {
|
||||
return vec!["openai:responses:compact"];
|
||||
}
|
||||
if is_embedding_api_format(client_api_format.as_str()) {
|
||||
let mut candidate_api_formats = EMBEDDING_CANDIDATE_API_FORMATS.to_vec();
|
||||
candidate_api_formats.sort_by_key(|provider_api_format| {
|
||||
request_candidate_api_format_preference(client_api_format.as_str(), provider_api_format)
|
||||
.unwrap_or((u8::MAX, u8::MAX))
|
||||
});
|
||||
return candidate_api_formats;
|
||||
}
|
||||
if is_rerank_api_format(client_api_format.as_str()) {
|
||||
let mut candidate_api_formats = RERANK_CANDIDATE_API_FORMATS.to_vec();
|
||||
candidate_api_formats.sort_by_key(|provider_api_format| {
|
||||
request_candidate_api_format_preference(client_api_format.as_str(), provider_api_format)
|
||||
.unwrap_or((u8::MAX, u8::MAX))
|
||||
});
|
||||
return candidate_api_formats;
|
||||
}
|
||||
if parse_non_compact_standard_api_format(client_api_format.as_str()).is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut candidate_api_formats = NON_COMPACT_STANDARD_CANDIDATE_API_FORMATS.to_vec();
|
||||
candidate_api_formats.sort_by_key(|provider_api_format| {
|
||||
request_candidate_api_format_preference(client_api_format.as_str(), provider_api_format)
|
||||
.unwrap_or((u8::MAX, u8::MAX))
|
||||
});
|
||||
candidate_api_formats
|
||||
}
|
||||
|
||||
pub fn request_conversion_kind(
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
) -> Option<RequestConversionKind> {
|
||||
let client_api_format = normalize_api_format_alias(client_api_format);
|
||||
let provider_api_format = normalize_api_format_alias(provider_api_format);
|
||||
if client_api_format == provider_api_format {
|
||||
return None;
|
||||
}
|
||||
if !is_standard_api_format(client_api_format.as_str())
|
||||
|| !is_standard_api_format(provider_api_format.as_str())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if is_openai_responses_compact_format(client_api_format.as_str())
|
||||
|| is_openai_responses_compact_format(provider_api_format.as_str())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
match provider_api_format.as_str() {
|
||||
"openai:chat" => Some(RequestConversionKind::ToOpenAIChat),
|
||||
"openai:responses" => Some(RequestConversionKind::ToOpenAiResponses),
|
||||
"claude:messages" => Some(RequestConversionKind::ToClaudeStandard),
|
||||
"gemini:generate_content" => Some(RequestConversionKind::ToGeminiStandard),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sync_chat_response_conversion_kind(
|
||||
provider_api_format: &str,
|
||||
client_api_format: &str,
|
||||
) -> Option<SyncChatResponseConversionKind> {
|
||||
let provider_api_format = normalize_api_format_alias(provider_api_format);
|
||||
let client_api_format = normalize_api_format_alias(client_api_format);
|
||||
if provider_api_format == client_api_format {
|
||||
return None;
|
||||
}
|
||||
if !is_standard_api_format(provider_api_format.as_str()) {
|
||||
return None;
|
||||
}
|
||||
request_conversion_kind(client_api_format.as_str(), provider_api_format.as_str())?;
|
||||
match client_api_format.as_str() {
|
||||
"openai:chat" => Some(SyncChatResponseConversionKind::ToOpenAIChat),
|
||||
"claude:messages" => Some(SyncChatResponseConversionKind::ToClaudeChat),
|
||||
"gemini:generate_content" => Some(SyncChatResponseConversionKind::ToGeminiChat),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sync_cli_response_conversion_kind(
|
||||
provider_api_format: &str,
|
||||
client_api_format: &str,
|
||||
) -> Option<SyncCliResponseConversionKind> {
|
||||
let provider_api_format = normalize_api_format_alias(provider_api_format);
|
||||
let client_api_format = normalize_api_format_alias(client_api_format);
|
||||
if provider_api_format == client_api_format {
|
||||
return None;
|
||||
}
|
||||
if !is_standard_api_format(provider_api_format.as_str()) {
|
||||
return None;
|
||||
}
|
||||
if !is_openai_responses_compact_format(client_api_format.as_str()) {
|
||||
request_conversion_kind(client_api_format.as_str(), provider_api_format.as_str())?;
|
||||
}
|
||||
match client_api_format.as_str() {
|
||||
"openai:responses" | "openai:responses:compact" => {
|
||||
Some(SyncCliResponseConversionKind::ToOpenAiResponses)
|
||||
}
|
||||
"claude:messages" => Some(SyncCliResponseConversionKind::ToClaudeCli),
|
||||
"gemini:generate_content" => Some(SyncCliResponseConversionKind::ToGeminiCli),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_conversion_requires_enable_flag(
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
) -> bool {
|
||||
let client_api_format = normalize_api_format_alias(client_api_format);
|
||||
let provider_api_format = normalize_api_format_alias(provider_api_format);
|
||||
match (
|
||||
api_data_format_id(client_api_format.as_str()),
|
||||
api_data_format_id(provider_api_format.as_str()),
|
||||
) {
|
||||
(Some(client_data_format), Some(provider_data_format)) => {
|
||||
client_data_format != provider_data_format
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_standard_api_format(api_format: &str) -> bool {
|
||||
matches!(
|
||||
normalize_api_format_alias(api_format).as_str(),
|
||||
"openai:chat"
|
||||
| "openai:responses"
|
||||
| "openai:responses:compact"
|
||||
| "claude:messages"
|
||||
| "gemini:generate_content"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_embedding_api_format(api_format: &str) -> bool {
|
||||
matches!(
|
||||
normalize_api_format_alias(api_format).as_str(),
|
||||
"openai:embedding" | "gemini:embedding" | "jina:embedding" | "doubao:embedding"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_rerank_api_format(api_format: &str) -> bool {
|
||||
matches!(
|
||||
normalize_api_format_alias(api_format).as_str(),
|
||||
"openai:rerank" | "jina:rerank"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn parse_non_compact_standard_api_format(
|
||||
api_format: &str,
|
||||
) -> Option<(&'static str, &'static str)> {
|
||||
match normalize_api_format_alias(api_format).as_str() {
|
||||
"openai:chat" => Some(("openai", "chat")),
|
||||
"openai:responses" => Some(("openai", "responses")),
|
||||
"claude:messages" => Some(("claude", "messages")),
|
||||
"gemini:generate_content" => Some(("gemini", "generate_content")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn api_data_format_id(api_format: &str) -> Option<&'static str> {
|
||||
match normalize_api_format_alias(api_format).as_str() {
|
||||
"claude:messages" => Some("claude"),
|
||||
"gemini:generate_content" => Some("gemini"),
|
||||
"openai:chat" => Some("openai_chat"),
|
||||
"openai:responses" | "openai:responses:compact" => Some("openai_responses"),
|
||||
"openai:embedding" | "gemini:embedding" | "jina:embedding" | "doubao:embedding" => {
|
||||
Some("embedding")
|
||||
}
|
||||
"openai:rerank" | "jina:rerank" => Some("rerank"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalized_same_standard_api_format(left: &str, right: &str) -> bool {
|
||||
api_format_alias_matches(left, right)
|
||||
}
|
||||
|
||||
fn standard_api_format_priority(api_format: &str) -> u8 {
|
||||
let api_format = normalize_api_format_alias(api_format);
|
||||
STANDARD_API_FORMAT_ORDER
|
||||
.iter()
|
||||
.position(|candidate| *candidate == api_format)
|
||||
.unwrap_or(STANDARD_API_FORMAT_ORDER.len()) as u8
|
||||
}
|
||||
|
||||
fn embedding_api_format_priority(api_format: &str) -> u8 {
|
||||
let api_format = normalize_api_format_alias(api_format);
|
||||
EMBEDDING_CANDIDATE_API_FORMATS
|
||||
.iter()
|
||||
.position(|candidate| *candidate == api_format)
|
||||
.unwrap_or(EMBEDDING_CANDIDATE_API_FORMATS.len()) as u8
|
||||
}
|
||||
|
||||
fn rerank_api_format_priority(api_format: &str) -> u8 {
|
||||
let api_format = normalize_api_format_alias(api_format);
|
||||
RERANK_CANDIDATE_API_FORMATS
|
||||
.iter()
|
||||
.position(|candidate| *candidate == api_format)
|
||||
.unwrap_or(RERANK_CANDIDATE_API_FORMATS.len()) as u8
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
api_data_format_id, is_embedding_api_format, is_rerank_api_format,
|
||||
request_candidate_api_format_preference, request_candidate_api_formats,
|
||||
request_conversion_kind, request_conversion_requires_enable_flag,
|
||||
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind,
|
||||
RequestConversionKind, SyncChatResponseConversionKind, SyncCliResponseConversionKind,
|
||||
};
|
||||
|
||||
fn expected_request_conversion_kind(provider_api_format: &str) -> RequestConversionKind {
|
||||
match provider_api_format {
|
||||
"openai:chat" => RequestConversionKind::ToOpenAIChat,
|
||||
"openai:responses" => RequestConversionKind::ToOpenAiResponses,
|
||||
"claude:messages" => RequestConversionKind::ToClaudeStandard,
|
||||
"gemini:generate_content" => RequestConversionKind::ToGeminiStandard,
|
||||
other => panic!("unexpected provider format {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_conversion_registry_supports_bidirectional_standard_matrix() {
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:chat", "openai:responses"),
|
||||
Some(RequestConversionKind::ToOpenAiResponses)
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:chat", "claude:messages"),
|
||||
Some(RequestConversionKind::ToClaudeStandard)
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:responses", "openai:chat"),
|
||||
Some(RequestConversionKind::ToOpenAIChat)
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:responses:compact", "gemini:generate_content"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("gemini:generate_content", "openai:responses:compact"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:chat", "openai:responses: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("gemini:generate_content", "claude:messages"),
|
||||
Some(RequestConversionKind::ToClaudeStandard)
|
||||
);
|
||||
assert_eq!(request_conversion_kind("claude:chat", "claude:cli"), None);
|
||||
assert_eq!(
|
||||
request_conversion_kind("claude:messages", "claude:messages"),
|
||||
None
|
||||
);
|
||||
|
||||
let formats = [
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
"claude:messages",
|
||||
"gemini:generate_content",
|
||||
];
|
||||
for client_api_format in formats {
|
||||
for provider_api_format in formats {
|
||||
let actual = request_conversion_kind(client_api_format, provider_api_format);
|
||||
if client_api_format == provider_api_format {
|
||||
assert_eq!(actual, None, "{client_api_format} -> {provider_api_format}");
|
||||
} else {
|
||||
assert_eq!(
|
||||
actual,
|
||||
Some(expected_request_conversion_kind(provider_api_format)),
|
||||
"{client_api_format} -> {provider_api_format}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_response_conversion_registry_supports_bidirectional_standard_matrix() {
|
||||
assert_eq!(
|
||||
sync_chat_response_conversion_kind("openai:chat", "claude:messages"),
|
||||
Some(SyncChatResponseConversionKind::ToClaudeChat)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_chat_response_conversion_kind("claude:messages", "gemini:generate_content"),
|
||||
Some(SyncChatResponseConversionKind::ToGeminiChat)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_chat_response_conversion_kind("gemini:generate_content", "openai:chat"),
|
||||
Some(SyncChatResponseConversionKind::ToOpenAIChat)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("openai:responses", "gemini:generate_content"),
|
||||
Some(SyncCliResponseConversionKind::ToGeminiCli)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("claude:messages", "openai:responses"),
|
||||
Some(SyncCliResponseConversionKind::ToOpenAiResponses)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("claude:messages", "openai:responses:compact"),
|
||||
Some(SyncCliResponseConversionKind::ToOpenAiResponses)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("openai:responses:compact", "claude:messages"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("gemini:generate_content", "claude:messages"),
|
||||
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]
|
||||
fn embedding_candidate_registry_excludes_chat_generation_formats() {
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:embedding", false),
|
||||
vec![
|
||||
"openai:embedding",
|
||||
"gemini:embedding",
|
||||
"jina:embedding",
|
||||
"doubao:embedding",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("jina:embedding", false),
|
||||
vec![
|
||||
"jina:embedding",
|
||||
"openai:embedding",
|
||||
"gemini:embedding",
|
||||
"doubao:embedding",
|
||||
]
|
||||
);
|
||||
assert!(!request_candidate_api_formats("openai:embedding", false).contains(&"openai:chat"));
|
||||
assert!(!request_candidate_api_formats("openai:embedding", false)
|
||||
.contains(&"gemini:generate_content"));
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:embedding", "jina:embedding"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:embedding", "openai:chat"),
|
||||
None
|
||||
);
|
||||
assert!(!request_conversion_requires_enable_flag(
|
||||
"openai:embedding",
|
||||
"jina:embedding"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_candidate_registry_covers_all_provider_orderings() {
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("gemini:embedding", true),
|
||||
vec![
|
||||
"gemini:embedding",
|
||||
"openai:embedding",
|
||||
"jina:embedding",
|
||||
"doubao:embedding",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("doubao:embedding", false),
|
||||
vec![
|
||||
"doubao:embedding",
|
||||
"openai:embedding",
|
||||
"gemini:embedding",
|
||||
"jina:embedding",
|
||||
]
|
||||
);
|
||||
|
||||
let embedding_formats = [
|
||||
"openai:embedding",
|
||||
"gemini:embedding",
|
||||
"jina:embedding",
|
||||
"doubao:embedding",
|
||||
];
|
||||
for client_api_format in embedding_formats {
|
||||
for provider_api_format in embedding_formats {
|
||||
assert!(
|
||||
request_candidate_api_format_preference(client_api_format, provider_api_format)
|
||||
.is_some(),
|
||||
"{client_api_format} should consider {provider_api_format} as embedding candidate"
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind(client_api_format, provider_api_format),
|
||||
None,
|
||||
"embedding pair should not use chat/generation conversion kind"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_candidate_registry_never_crosses_chat_generation_boundary() {
|
||||
let embedding_formats = [
|
||||
"openai:embedding",
|
||||
"gemini:embedding",
|
||||
"jina:embedding",
|
||||
"doubao:embedding",
|
||||
];
|
||||
let standard_formats = [
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
"claude:messages",
|
||||
"gemini:generate_content",
|
||||
];
|
||||
|
||||
for embedding_api_format in embedding_formats {
|
||||
assert!(is_embedding_api_format(embedding_api_format));
|
||||
assert_eq!(api_data_format_id(embedding_api_format), Some("embedding"));
|
||||
for standard_api_format in standard_formats {
|
||||
assert_eq!(
|
||||
request_candidate_api_format_preference(
|
||||
embedding_api_format,
|
||||
standard_api_format
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_format_preference(
|
||||
standard_api_format,
|
||||
embedding_api_format
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind(embedding_api_format, standard_api_format),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind(standard_api_format, embedding_api_format),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerank_candidate_registry_excludes_chat_and_embedding_formats() {
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:rerank", false),
|
||||
vec!["openai:rerank", "jina:rerank"]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("jina:rerank", false),
|
||||
vec!["jina:rerank", "openai:rerank"]
|
||||
);
|
||||
assert_eq!(api_data_format_id("openai:rerank"), Some("rerank"));
|
||||
assert!(is_rerank_api_format("jina:rerank"));
|
||||
assert!(!is_embedding_api_format("openai:rerank"));
|
||||
assert_eq!(
|
||||
request_candidate_api_format_preference("openai:rerank", "openai:embedding"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_format_preference("openai:rerank", "openai:chat"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:rerank", "jina:rerank"),
|
||||
None
|
||||
);
|
||||
assert!(!request_conversion_requires_enable_flag(
|
||||
"openai:rerank",
|
||||
"jina:rerank"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_candidate_registry_prefers_same_kind_before_same_family_fallbacks() {
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:chat", false),
|
||||
vec![
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
"claude:messages",
|
||||
"gemini:generate_content"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:responses", false),
|
||||
vec![
|
||||
"openai:responses",
|
||||
"openai:chat",
|
||||
"claude:messages",
|
||||
"gemini:generate_content"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("claude:messages", false),
|
||||
vec![
|
||||
"claude:messages",
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
"gemini:generate_content"
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
request_candidate_api_format_preference("claude:messages", "openai:chat")
|
||||
< request_candidate_api_format_preference("claude:messages", "openai:responses")
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:cli", false),
|
||||
Vec::<&'static str>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("claude:cli", false),
|
||||
Vec::<&'static str>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:compact", false),
|
||||
Vec::<&'static str>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_format_preference("claude:cli", "openai:responses"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_format_preference("claude:cli", "claude:chat"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_format_preference("claude:cli", "openai:chat"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_conversion_enable_flag_only_applies_to_real_data_format_conversions() {
|
||||
assert!(!request_conversion_requires_enable_flag(
|
||||
"claude:messages",
|
||||
"claude:messages"
|
||||
));
|
||||
assert!(request_conversion_requires_enable_flag(
|
||||
"claude:chat",
|
||||
"claude:cli"
|
||||
));
|
||||
assert!(request_conversion_requires_enable_flag(
|
||||
"openai:chat",
|
||||
"openai:responses"
|
||||
));
|
||||
assert!(request_conversion_requires_enable_flag(
|
||||
"claude:messages",
|
||||
"gemini:generate_content"
|
||||
));
|
||||
assert!(request_conversion_requires_enable_flag(
|
||||
"openai:compact",
|
||||
"claude:cli"
|
||||
));
|
||||
}
|
||||
}
|
||||
18
crates/aether-ai-formats/src/formats/mod.rs
Normal file
18
crates/aether-ai-formats/src/formats/mod.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
pub mod claude;
|
||||
pub mod context;
|
||||
pub mod conversion;
|
||||
pub mod doubao;
|
||||
pub mod gemini;
|
||||
pub mod id;
|
||||
pub mod jina;
|
||||
pub mod matrix;
|
||||
pub mod openai;
|
||||
pub mod registry;
|
||||
pub mod shared;
|
||||
|
||||
pub use context::{FormatContext, FormatError};
|
||||
pub use id::{
|
||||
api_format_alias_matches, api_format_storage_aliases, is_openai_responses_compact_format,
|
||||
is_openai_responses_family_format, is_openai_responses_format, normalize_api_format_alias,
|
||||
FormatFamily, FormatId, FormatProfile,
|
||||
};
|
||||
3
crates/aether-ai-formats/src/formats/openai/chat/mod.rs
Normal file
3
crates/aether-ai-formats/src/formats/openai/chat/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
pub mod stream;
|
||||
243
crates/aether-ai-formats/src/formats/openai/chat/request.rs
Normal file
243
crates/aether-ai-formats/src/formats/openai/chat/request.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
formats::context::FormatContext,
|
||||
protocol::canonical::{
|
||||
canonical_extension_object_mut, canonical_message_to_openai_chat,
|
||||
canonical_response_format_to_openai, canonical_tool_choice_to_openai,
|
||||
canonical_tool_to_openai, namespace_extension_object, openai_content_text,
|
||||
openai_extensions, openai_generation_config, openai_message_content_blocks,
|
||||
openai_response_format_to_canonical, openai_responses_extension, openai_role_to_canonical,
|
||||
openai_tool_choice_to_canonical, openai_tools_to_canonical, write_openai_generation_config,
|
||||
CanonicalInstruction, CanonicalRequest, CanonicalRole, CanonicalThinkingConfig,
|
||||
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> {
|
||||
let mut body = to_raw(request);
|
||||
force_stream_options(&mut body, ctx.upstream_is_stream);
|
||||
Some(body)
|
||||
}
|
||||
|
||||
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(messages) = request.get("messages").and_then(Value::as_array) {
|
||||
for message in messages {
|
||||
let message_object = message.as_object()?;
|
||||
let role = openai_role_to_canonical(
|
||||
message_object
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
if matches!(role, CanonicalRole::System | CanonicalRole::Developer) {
|
||||
let text = openai_content_text(message_object.get("content"));
|
||||
canonical.instructions.push(CanonicalInstruction {
|
||||
role,
|
||||
text: text.clone(),
|
||||
extensions: openai_extensions(message_object, &["role", "content"]),
|
||||
});
|
||||
if !text.trim().is_empty() {
|
||||
canonical.system = Some(match canonical.system.take() {
|
||||
Some(existing) if !existing.trim().is_empty() => {
|
||||
format!("{existing}\n\n{text}")
|
||||
}
|
||||
_ => text,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
canonical
|
||||
.messages
|
||||
.push(crate::protocol::canonical::CanonicalMessage {
|
||||
role,
|
||||
content: openai_message_content_blocks(message_object)?,
|
||||
extensions: openai_extensions(
|
||||
message_object,
|
||||
&["role", "content", "tool_calls", "tool_call_id"],
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
canonical.generation = openai_generation_config(request);
|
||||
canonical.tools = openai_tools_to_canonical(request.get("tools"))?;
|
||||
canonical.tool_choice = openai_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 = openai_response_format_to_canonical(request.get("response_format"));
|
||||
if let Some(reasoning_effort) = request.get("reasoning_effort").and_then(Value::as_str) {
|
||||
let mut extensions = std::collections::BTreeMap::new();
|
||||
extensions.insert(
|
||||
"openai".to_string(),
|
||||
json!({ "reasoning_effort": reasoning_effort }),
|
||||
);
|
||||
canonical.thinking = Some(CanonicalThinkingConfig {
|
||||
enabled: true,
|
||||
budget_tokens: None,
|
||||
extensions,
|
||||
});
|
||||
}
|
||||
canonical.extensions = openai_extensions(
|
||||
request,
|
||||
&[
|
||||
"model",
|
||||
"messages",
|
||||
"max_tokens",
|
||||
"max_completion_tokens",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"top_k",
|
||||
"stop",
|
||||
"stream",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"parallel_tool_calls",
|
||||
"metadata",
|
||||
"response_format",
|
||||
"reasoning_effort",
|
||||
"n",
|
||||
"presence_penalty",
|
||||
"frequency_penalty",
|
||||
"seed",
|
||||
"logprobs",
|
||||
"top_logprobs",
|
||||
],
|
||||
);
|
||||
if let Some(verbosity) = request.get("verbosity").cloned() {
|
||||
canonical_extension_object_mut(
|
||||
&mut canonical.extensions,
|
||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
|
||||
)
|
||||
.insert("verbosity".to_string(), verbosity);
|
||||
}
|
||||
Some(canonical)
|
||||
}
|
||||
|
||||
pub fn to_raw(canonical: &CanonicalRequest) -> Value {
|
||||
let mut output = serde_json::Map::new();
|
||||
if !canonical.model.trim().is_empty() {
|
||||
output.insert("model".to_string(), Value::String(canonical.model.clone()));
|
||||
}
|
||||
|
||||
let mut messages = Vec::new();
|
||||
for instruction in &canonical.instructions {
|
||||
let role = match instruction.role {
|
||||
CanonicalRole::Developer => "developer",
|
||||
_ => "system",
|
||||
};
|
||||
if !instruction.text.trim().is_empty() {
|
||||
messages.push(json!({
|
||||
"role": role,
|
||||
"content": instruction.text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
for message in &canonical.messages {
|
||||
messages.push(canonical_message_to_openai_chat(message));
|
||||
}
|
||||
output.insert("messages".to_string(), Value::Array(messages));
|
||||
|
||||
write_openai_generation_config(&mut output, &canonical.generation);
|
||||
if !canonical.tools.is_empty() {
|
||||
output.insert(
|
||||
"tools".to_string(),
|
||||
Value::Array(
|
||||
canonical
|
||||
.tools
|
||||
.iter()
|
||||
.map(canonical_tool_to_openai)
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
if let Some(tool_choice) = &canonical.tool_choice {
|
||||
output.insert(
|
||||
"tool_choice".to_string(),
|
||||
canonical_tool_choice_to_openai(tool_choice),
|
||||
);
|
||||
}
|
||||
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(response_format) = &canonical.response_format {
|
||||
output.insert(
|
||||
"response_format".to_string(),
|
||||
canonical_response_format_to_openai(response_format),
|
||||
);
|
||||
}
|
||||
if let Some(thinking) = &canonical.thinking {
|
||||
if let Some(reasoning_effort) = thinking
|
||||
.extensions
|
||||
.get("openai")
|
||||
.and_then(|value| value.get("reasoning_effort"))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
openai_responses_extension(&thinking.extensions)
|
||||
.and_then(|value| value.get("effort"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
{
|
||||
output.insert(
|
||||
"reasoning_effort".to_string(),
|
||||
Value::String(reasoning_effort.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
output.extend(namespace_extension_object(
|
||||
&canonical.extensions,
|
||||
"openai",
|
||||
&output,
|
||||
));
|
||||
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,
|
||||
));
|
||||
Value::Object(output)
|
||||
}
|
||||
|
||||
fn force_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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
185
crates/aether-ai-formats/src/formats/openai/chat/response.rs
Normal file
185
crates/aether-ai-formats/src/formats/openai/chat/response.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
formats::context::FormatContext,
|
||||
protocol::canonical::{
|
||||
canonical_blocks_to_openai_chat_message, canonical_stop_reason_to_openai,
|
||||
canonical_usage_to_openai, openai_extensions, openai_finish_reason_to_canonical,
|
||||
openai_message_content_blocks, openai_usage_to_canonical, CanonicalContentBlock,
|
||||
CanonicalResponse, CanonicalResponseOutput, CanonicalRole,
|
||||
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> {
|
||||
let mut body = to_raw(response);
|
||||
if body.get("service_tier").is_none() {
|
||||
if let Some(service_tier) = ctx
|
||||
.report_context_value()
|
||||
.get("original_request_body")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|request| request.get("service_tier"))
|
||||
.cloned()
|
||||
{
|
||||
body["service_tier"] = service_tier;
|
||||
}
|
||||
}
|
||||
Some(body)
|
||||
}
|
||||
|
||||
pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
|
||||
let body = body_json.as_object()?;
|
||||
if body.contains_key("error") {
|
||||
return None;
|
||||
}
|
||||
let mut outputs = Vec::new();
|
||||
for (fallback_index, choice_value) in body
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)?
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let choice = choice_value.as_object()?;
|
||||
let message = choice.get("message").and_then(Value::as_object)?;
|
||||
let mut content = openai_message_content_blocks(message)?;
|
||||
if !content
|
||||
.iter()
|
||||
.any(|block| matches!(block, CanonicalContentBlock::Thinking { .. }))
|
||||
{
|
||||
if let Some(reasoning_content) = message
|
||||
.get("reasoning_content")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
content.insert(
|
||||
0,
|
||||
CanonicalContentBlock::Thinking {
|
||||
text: reasoning_content.to_string(),
|
||||
signature: None,
|
||||
encrypted_content: None,
|
||||
extensions: BTreeMap::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let stop_reason =
|
||||
openai_finish_reason_to_canonical(choice.get("finish_reason").and_then(Value::as_str));
|
||||
outputs.push(CanonicalResponseOutput {
|
||||
index: choice
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as usize)
|
||||
.unwrap_or(fallback_index),
|
||||
role: CanonicalRole::Assistant,
|
||||
content,
|
||||
stop_reason,
|
||||
extensions: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
let first_output = outputs.first()?;
|
||||
let content = first_output.content.clone();
|
||||
let stop_reason = first_output.stop_reason.clone();
|
||||
Some(CanonicalResponse {
|
||||
id: body
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("chatcmpl-unknown")
|
||||
.to_string(),
|
||||
model: body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
outputs,
|
||||
content,
|
||||
stop_reason,
|
||||
usage: openai_usage_to_canonical(body.get("usage")),
|
||||
extensions: openai_extensions(
|
||||
body,
|
||||
&["id", "object", "model", "choices", "usage", "created"],
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_raw(canonical: &CanonicalResponse) -> Value {
|
||||
let outputs: Vec<CanonicalResponseOutput> = if canonical.outputs.is_empty() {
|
||||
vec![CanonicalResponseOutput {
|
||||
index: 0,
|
||||
role: CanonicalRole::Assistant,
|
||||
content: canonical.content.clone(),
|
||||
stop_reason: canonical.stop_reason.clone(),
|
||||
extensions: BTreeMap::new(),
|
||||
}]
|
||||
} else {
|
||||
canonical.outputs.clone()
|
||||
};
|
||||
let choices: Vec<Value> = outputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(fallback_index, output)| {
|
||||
json!({
|
||||
"index": output.index,
|
||||
"message": canonical_blocks_to_openai_chat_message(&output.content),
|
||||
"finish_reason": canonical_stop_reason_to_openai(output.stop_reason.as_ref()),
|
||||
})
|
||||
.as_object()
|
||||
.map(|choice| {
|
||||
let mut choice = choice.clone();
|
||||
if output.index == 0 && fallback_index != 0 {
|
||||
choice.insert("index".to_string(), Value::from(fallback_index as u64));
|
||||
}
|
||||
Value::Object(choice)
|
||||
})
|
||||
.unwrap_or_else(|| json!({}))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut response = json!({
|
||||
"id": canonical.id,
|
||||
"object": "chat.completion",
|
||||
"model": canonical.model,
|
||||
"choices": choices,
|
||||
"usage": canonical.usage.as_ref().map(canonical_usage_to_openai).unwrap_or_else(|| json!({
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
})),
|
||||
});
|
||||
if let Some(created_at) = canonical
|
||||
.extensions
|
||||
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
|
||||
.or_else(|| {
|
||||
canonical
|
||||
.extensions
|
||||
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
|
||||
})
|
||||
.and_then(|value| value.get("created_at"))
|
||||
.and_then(|value| {
|
||||
value
|
||||
.as_i64()
|
||||
.or_else(|| value.as_u64().map(|value| value as i64))
|
||||
})
|
||||
{
|
||||
response["created"] = Value::from(created_at);
|
||||
}
|
||||
if let Some(service_tier) = canonical
|
||||
.extensions
|
||||
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
|
||||
.or_else(|| {
|
||||
canonical
|
||||
.extensions
|
||||
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
|
||||
})
|
||||
.and_then(|value| value.get("service_tier"))
|
||||
.cloned()
|
||||
{
|
||||
response["service_tier"] = service_tier;
|
||||
}
|
||||
response
|
||||
}
|
||||
3099
crates/aether-ai-formats/src/formats/openai/chat/stream.rs
Normal file
3099
crates/aether-ai-formats/src/formats/openai/chat/stream.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
150
crates/aether-ai-formats/src/formats/openai/embedding/request.rs
Normal file
150
crates/aether-ai-formats/src/formats/openai/embedding/request.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
use serde_json::Map;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::formats::context::FormatContext;
|
||||
use crate::protocol::canonical::{
|
||||
namespace_extension_object, CanonicalEmbeddingInput, CanonicalEmbeddingRequest,
|
||||
CanonicalRequest,
|
||||
};
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
|
||||
from_namespace(body, "openai")
|
||||
}
|
||||
|
||||
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
|
||||
to_openai_like(
|
||||
request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
"openai",
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn from_namespace(body_json: &Value, namespace: &str) -> Option<CanonicalRequest> {
|
||||
let request = body_json.as_object()?;
|
||||
let model = request
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?
|
||||
.to_string();
|
||||
let input =
|
||||
serde_json::from_value::<CanonicalEmbeddingInput>(request.get("input")?.clone()).ok()?;
|
||||
if input.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let embedding = CanonicalEmbeddingRequest {
|
||||
input,
|
||||
encoding_format: request
|
||||
.get("encoding_format")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
dimensions: request.get("dimensions").and_then(Value::as_u64),
|
||||
task: request
|
||||
.get("task")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
user: request
|
||||
.get("user")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
extensions: namespace_extensions(
|
||||
namespace,
|
||||
request,
|
||||
&[
|
||||
"model",
|
||||
"input",
|
||||
"encoding_format",
|
||||
"dimensions",
|
||||
"task",
|
||||
"user",
|
||||
],
|
||||
),
|
||||
};
|
||||
|
||||
Some(CanonicalRequest {
|
||||
model,
|
||||
embedding: Some(embedding),
|
||||
..CanonicalRequest::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn to_openai_like(
|
||||
canonical: &CanonicalRequest,
|
||||
mapped_model: &str,
|
||||
namespace: &str,
|
||||
default_task: bool,
|
||||
) -> Option<Value> {
|
||||
let embedding = canonical.embedding.as_ref()?;
|
||||
if embedding.input.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut output = Map::new();
|
||||
output.insert(
|
||||
"model".to_string(),
|
||||
Value::String(mapped_embedding_model(canonical, mapped_model)),
|
||||
);
|
||||
output.insert(
|
||||
"input".to_string(),
|
||||
serde_json::to_value(&embedding.input).ok()?,
|
||||
);
|
||||
if let Some(value) = &embedding.encoding_format {
|
||||
output.insert("encoding_format".to_string(), Value::String(value.clone()));
|
||||
}
|
||||
if let Some(value) = embedding.dimensions {
|
||||
output.insert("dimensions".to_string(), Value::from(value));
|
||||
}
|
||||
if let Some(value) = &embedding.user {
|
||||
output.insert("user".to_string(), Value::String(value.clone()));
|
||||
}
|
||||
if let Some(task) = embedding
|
||||
.task
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
output.insert("task".to_string(), Value::String(task.clone()));
|
||||
} else if default_task {
|
||||
output.insert(
|
||||
"task".to_string(),
|
||||
Value::String("text-matching".to_string()),
|
||||
);
|
||||
}
|
||||
output.extend(namespace_extension_object(
|
||||
&embedding.extensions,
|
||||
namespace,
|
||||
&output,
|
||||
));
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
|
||||
pub(crate) fn mapped_embedding_model(canonical: &CanonicalRequest, mapped_model: &str) -> String {
|
||||
let mapped_model = mapped_model.trim();
|
||||
if mapped_model.is_empty() {
|
||||
canonical.model.clone()
|
||||
} else {
|
||||
mapped_model.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn namespace_extensions(
|
||||
namespace: &str,
|
||||
object: &Map<String, Value>,
|
||||
handled_keys: &[&str],
|
||||
) -> BTreeMap<String, Value> {
|
||||
let handled = handled_keys
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
let raw = object
|
||||
.iter()
|
||||
.filter(|(key, _)| !handled.contains(key.as_str()))
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect::<Map<String, Value>>();
|
||||
if raw.is_empty() {
|
||||
BTreeMap::new()
|
||||
} else {
|
||||
BTreeMap::from([(namespace.to_string(), Value::Object(raw))])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Map};
|
||||
|
||||
use crate::formats::openai::embedding::request::namespace_extensions;
|
||||
use crate::protocol::canonical::{
|
||||
canonical_usage_to_openai, namespace_extension_object, openai_usage_to_canonical,
|
||||
CanonicalEmbedding, CanonicalEmbeddingResponse,
|
||||
};
|
||||
|
||||
pub fn from(body: &Value) -> Option<CanonicalEmbeddingResponse> {
|
||||
from_namespace(body, "openai")
|
||||
}
|
||||
|
||||
pub fn to(response: &CanonicalEmbeddingResponse) -> Option<Value> {
|
||||
Some(to_openai_like(response, "openai"))
|
||||
}
|
||||
|
||||
pub(crate) fn from_namespace(
|
||||
body_json: &Value,
|
||||
namespace: &str,
|
||||
) -> Option<CanonicalEmbeddingResponse> {
|
||||
let body = body_json.as_object()?;
|
||||
if body.contains_key("error") {
|
||||
return None;
|
||||
}
|
||||
let data = body.get("data")?.as_array()?;
|
||||
let mut embeddings = Vec::new();
|
||||
for (fallback_index, item) in data.iter().enumerate() {
|
||||
let item_object = item.as_object()?;
|
||||
let values = item_object.get("embedding")?.as_array()?;
|
||||
let embedding = values
|
||||
.iter()
|
||||
.map(Value::as_f64)
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
embeddings.push(CanonicalEmbedding {
|
||||
index: item_object
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| usize::try_from(value).ok())
|
||||
.unwrap_or(fallback_index),
|
||||
embedding,
|
||||
extensions: namespace_extensions(
|
||||
namespace,
|
||||
item_object,
|
||||
&["object", "index", "embedding"],
|
||||
),
|
||||
});
|
||||
}
|
||||
Some(CanonicalEmbeddingResponse {
|
||||
id: body
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("embd-unknown")
|
||||
.to_string(),
|
||||
model: body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
embeddings,
|
||||
usage: openai_usage_to_canonical(body.get("usage")),
|
||||
extensions: namespace_extensions(
|
||||
namespace,
|
||||
body,
|
||||
&["id", "object", "model", "data", "usage"],
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn to_openai_like(canonical: &CanonicalEmbeddingResponse, namespace: &str) -> Value {
|
||||
let mut response = Map::new();
|
||||
response.insert("object".to_string(), Value::String("list".to_string()));
|
||||
if !canonical.model.trim().is_empty() && canonical.model != "unknown" {
|
||||
response.insert("model".to_string(), Value::String(canonical.model.clone()));
|
||||
}
|
||||
response.insert(
|
||||
"data".to_string(),
|
||||
Value::Array(
|
||||
canonical
|
||||
.embeddings
|
||||
.iter()
|
||||
.map(|embedding| {
|
||||
let mut item = Map::new();
|
||||
item.insert("object".to_string(), Value::String("embedding".to_string()));
|
||||
item.insert("index".to_string(), Value::from(embedding.index as u64));
|
||||
item.insert("embedding".to_string(), json!(embedding.embedding));
|
||||
item.extend(namespace_extension_object(
|
||||
&embedding.extensions,
|
||||
namespace,
|
||||
&item,
|
||||
));
|
||||
Value::Object(item)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
if let Some(usage) = &canonical.usage {
|
||||
response.insert("usage".to_string(), canonical_usage_to_openai(usage));
|
||||
}
|
||||
response.extend(namespace_extension_object(
|
||||
&canonical.extensions,
|
||||
namespace,
|
||||
&response,
|
||||
));
|
||||
Value::Object(response)
|
||||
}
|
||||
3
crates/aether-ai-formats/src/formats/openai/image/mod.rs
Normal file
3
crates/aether-ai-formats/src/formats/openai/image/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod request;
|
||||
pub mod spec;
|
||||
pub mod stream;
|
||||
1519
crates/aether-ai-formats/src/formats/openai/image/request.rs
Normal file
1519
crates/aether-ai-formats/src/formats/openai/image/request.rs
Normal file
File diff suppressed because it is too large
Load Diff
54
crates/aether-ai-formats/src/formats/openai/image/spec.rs
Normal file
54
crates/aether-ai-formats/src/formats/openai/image/spec.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use crate::contracts::{OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LocalOpenAiImageSpec {
|
||||
pub api_format: &'static str,
|
||||
pub decision_kind: &'static str,
|
||||
pub report_kind: &'static str,
|
||||
pub require_streaming: bool,
|
||||
}
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalOpenAiImageSpec> {
|
||||
match plan_kind {
|
||||
OPENAI_IMAGE_SYNC_PLAN_KIND => Some(LocalOpenAiImageSpec {
|
||||
api_format: "openai:image",
|
||||
decision_kind: OPENAI_IMAGE_SYNC_PLAN_KIND,
|
||||
report_kind: "openai_image_sync_finalize",
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalOpenAiImageSpec> {
|
||||
match plan_kind {
|
||||
OPENAI_IMAGE_STREAM_PLAN_KIND => Some(LocalOpenAiImageSpec {
|
||||
api_format: "openai:image",
|
||||
decision_kind: OPENAI_IMAGE_STREAM_PLAN_KIND,
|
||||
report_kind: "openai_image_stream_success",
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_spec, resolve_sync_spec};
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_image_sync_spec() {
|
||||
let spec = resolve_sync_spec("openai_image_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:image");
|
||||
assert_eq!(spec.report_kind, "openai_image_sync_finalize");
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_image_stream_spec() {
|
||||
let spec = resolve_stream_spec("openai_image_stream").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:image");
|
||||
assert_eq!(spec.report_kind, "openai_image_stream_success");
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
714
crates/aether-ai-formats/src/formats/openai/image/stream.rs
Normal file
714
crates/aether-ai-formats/src/formats/openai/image/stream.rs
Normal file
@@ -0,0 +1,714 @@
|
||||
use base64::Engine as _;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::contracts::OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND;
|
||||
use crate::formats::openai::responses::codex::CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT;
|
||||
use crate::formats::shared::sse::encode_json_sse;
|
||||
use crate::formats::shared::AiSurfaceFinalizeError;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OpenAiImageStreamState {
|
||||
buffered: Vec<u8>,
|
||||
latest_image: Option<OpenAiImageFrame>,
|
||||
emitted_partial_count: u64,
|
||||
saw_upstream_partial: bool,
|
||||
emitted_failure: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct OpenAiImageFrame {
|
||||
b64_json: String,
|
||||
}
|
||||
|
||||
impl OpenAiImageStreamState {
|
||||
pub fn push_chunk(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
chunk: &[u8],
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
self.buffered.extend_from_slice(chunk);
|
||||
let mut output = Vec::new();
|
||||
while let Some(block_end) = find_sse_block_end(&self.buffered) {
|
||||
let block = self.buffered.drain(..block_end).collect::<Vec<_>>();
|
||||
output.extend(self.transform_block(report_context, &block)?);
|
||||
drain_sse_separator(&mut self.buffered);
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub fn finish(&mut self, report_context: &Value) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
if self.buffered.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let block = std::mem::take(&mut self.buffered);
|
||||
self.transform_block(report_context, &block)
|
||||
}
|
||||
|
||||
fn transform_block(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
block: &[u8],
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
let text = std::str::from_utf8(block)
|
||||
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?;
|
||||
let mut event_name = None::<String>;
|
||||
let mut data_lines = Vec::new();
|
||||
for raw_line in text.lines() {
|
||||
let line = raw_line.trim_end_matches('\r');
|
||||
if let Some(value) = line.strip_prefix("event:") {
|
||||
event_name = Some(value.trim().to_string());
|
||||
} else if let Some(value) = line.strip_prefix("data:") {
|
||||
data_lines.push(value.trim().to_string());
|
||||
}
|
||||
}
|
||||
let data = data_lines.join("\n");
|
||||
if data.is_empty() || data == "[DONE]" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let event: Value = serde_json::from_str(&data)?;
|
||||
let event_type = event
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.or(event_name.as_deref())
|
||||
.unwrap_or_default();
|
||||
match event_type {
|
||||
"error" | "response.failed" => self.handle_failed(report_context, &event),
|
||||
"response.image_generation_call.partial_image" => {
|
||||
self.handle_image_generation_partial(report_context, &event)
|
||||
}
|
||||
"response.output_item.done" => self.handle_output_item_done(report_context, &event),
|
||||
"response.completed" => self.handle_completed(report_context, &event),
|
||||
_ => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_image_generation_partial(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
event: &Value,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
if self.emitted_failure {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if requested_partial_images(report_context) == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(result) = event
|
||||
.get("partial_image_b64")
|
||||
.or_else(|| event.get("b64_json"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let partial_image_index = event
|
||||
.get("partial_image_index")
|
||||
.or_else(|| event.get("output_index"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(self.emitted_partial_count);
|
||||
self.emitted_partial_count = self
|
||||
.emitted_partial_count
|
||||
.max(partial_image_index.saturating_add(1));
|
||||
self.saw_upstream_partial = true;
|
||||
self.latest_image = Some(OpenAiImageFrame {
|
||||
b64_json: result.to_string(),
|
||||
});
|
||||
|
||||
encode_json_sse(
|
||||
Some(image_partial_event_name(report_context)),
|
||||
&serde_json::json!({
|
||||
"type": image_partial_event_name(report_context),
|
||||
"b64_json": result,
|
||||
"partial_image_index": partial_image_index,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn handle_output_item_done(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
event: &Value,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
if self.emitted_failure {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(item) = event.get("item").and_then(Value::as_object) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
if item.get("type").and_then(Value::as_str) != Some("image_generation_call") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(result) = item.get("result").and_then(Value::as_str).map(str::trim) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
if result.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.latest_image = Some(OpenAiImageFrame {
|
||||
b64_json: result.to_string(),
|
||||
});
|
||||
|
||||
if requested_partial_images(report_context) == 0 || self.saw_upstream_partial {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let partial_image_index = event
|
||||
.get("output_index")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(self.emitted_partial_count);
|
||||
self.emitted_partial_count = partial_image_index.saturating_add(1);
|
||||
|
||||
encode_json_sse(
|
||||
Some(image_partial_event_name(report_context)),
|
||||
&serde_json::json!({
|
||||
"type": image_partial_event_name(report_context),
|
||||
"b64_json": result,
|
||||
"partial_image_index": partial_image_index,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn handle_completed(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
event: &Value,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
if self.emitted_failure {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if self.latest_image.is_none() {
|
||||
if let Some(result) = completed_response_image_result(event) {
|
||||
self.latest_image = Some(OpenAiImageFrame {
|
||||
b64_json: result.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let Some(latest_image) = self.latest_image.clone() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let usage = event
|
||||
.get("response")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|response| {
|
||||
response
|
||||
.get("tool_usage")
|
||||
.and_then(|value| value.get("image_gen"))
|
||||
.cloned()
|
||||
.or_else(|| response.get("usage").cloned())
|
||||
})
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
encode_json_sse(
|
||||
Some(image_completed_event_name(report_context)),
|
||||
&serde_json::json!({
|
||||
"type": image_completed_event_name(report_context),
|
||||
"b64_json": latest_image.b64_json,
|
||||
"usage": usage,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn handle_failed(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
event: &Value,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
if self.emitted_failure {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.emitted_failure = true;
|
||||
let error = image_failure_error(event);
|
||||
encode_json_sse(
|
||||
Some(image_failed_event_name(report_context)),
|
||||
&serde_json::json!({
|
||||
"type": image_failed_event_name(report_context),
|
||||
"error": error,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn image_failure_error(event: &Value) -> Value {
|
||||
let mut error = event
|
||||
.get("error")
|
||||
.or_else(|| event.get("response").and_then(|value| value.get("error")))
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
if !error.contains_key("message") {
|
||||
if let Some(message) = event
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
event
|
||||
.get("response")
|
||||
.and_then(|value| value.get("error"))
|
||||
.and_then(|value| value.get("message"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
error.insert("message".to_string(), Value::String(message.to_string()));
|
||||
}
|
||||
}
|
||||
if !error.contains_key("code") {
|
||||
if let Some(code) = event
|
||||
.get("code")
|
||||
.or_else(|| {
|
||||
event
|
||||
.get("response")
|
||||
.and_then(|value| value.get("error"))
|
||||
.and_then(|value| value.get("code"))
|
||||
})
|
||||
.cloned()
|
||||
{
|
||||
error.insert("code".to_string(), code);
|
||||
}
|
||||
}
|
||||
if !error.contains_key("type") {
|
||||
let inferred_type = error
|
||||
.get("code")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("upstream_error");
|
||||
error.insert("type".to_string(), Value::String(inferred_type.to_string()));
|
||||
}
|
||||
if !error.contains_key("message") {
|
||||
error.insert(
|
||||
"message".to_string(),
|
||||
Value::String("Image generation failed".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
Value::Object(error)
|
||||
}
|
||||
|
||||
fn completed_response_image_result(event: &Value) -> Option<&str> {
|
||||
event
|
||||
.get("response")
|
||||
.and_then(|value| value.get("output"))
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|item| item.get("type").and_then(Value::as_str) == Some("image_generation_call"))
|
||||
.filter_map(|item| item.get("result").and_then(Value::as_str))
|
||||
.map(str::trim)
|
||||
.find(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn requested_partial_images(report_context: &Value) -> u64 {
|
||||
report_context
|
||||
.get("image_request")
|
||||
.and_then(|value| value.get("partial_images"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn image_partial_event_name(report_context: &Value) -> &'static str {
|
||||
if image_request_operation(report_context) == Some("edit") {
|
||||
"image_edit.partial_image"
|
||||
} else {
|
||||
"image_generation.partial_image"
|
||||
}
|
||||
}
|
||||
|
||||
fn image_completed_event_name(report_context: &Value) -> &'static str {
|
||||
if image_request_operation(report_context) == Some("edit") {
|
||||
"image_edit.completed"
|
||||
} else {
|
||||
"image_generation.completed"
|
||||
}
|
||||
}
|
||||
|
||||
fn image_failed_event_name(report_context: &Value) -> &'static str {
|
||||
if image_request_operation(report_context) == Some("edit") {
|
||||
"image_edit.failed"
|
||||
} else {
|
||||
"image_generation.failed"
|
||||
}
|
||||
}
|
||||
|
||||
fn image_request_operation(report_context: &Value) -> Option<&str> {
|
||||
report_context
|
||||
.get("image_request")
|
||||
.and_then(|value| value.get("operation"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn find_sse_block_end(buffer: &[u8]) -> Option<usize> {
|
||||
buffer
|
||||
.windows(2)
|
||||
.position(|window| window == b"\n\n")
|
||||
.map(|index| index + 2)
|
||||
.or_else(|| {
|
||||
buffer
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.map(|index| index + 4)
|
||||
})
|
||||
}
|
||||
|
||||
fn drain_sse_separator(buffer: &mut Vec<u8>) {
|
||||
while matches!(buffer.first(), Some(b'\n' | b'\r')) {
|
||||
buffer.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OpenAiImageSyncFinalizeProduct {
|
||||
pub client_body_json: Value,
|
||||
pub provider_body_json: Value,
|
||||
}
|
||||
|
||||
pub fn maybe_build_openai_image_sync_finalize_product(
|
||||
report_kind: &str,
|
||||
status_code: u16,
|
||||
report_context: Option<&Value>,
|
||||
body_base64: Option<&str>,
|
||||
) -> Result<Option<OpenAiImageSyncFinalizeProduct>, AiSurfaceFinalizeError> {
|
||||
if report_kind != OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND || status_code >= 400 {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(report_context) = report_context else {
|
||||
return Ok(None);
|
||||
};
|
||||
if report_context
|
||||
.get("client_api_format")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
!= Some("openai:image")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(body_base64) = body_base64 else {
|
||||
return Ok(None);
|
||||
};
|
||||
let default_output_format = report_context
|
||||
.get("image_request")
|
||||
.and_then(|value| value.get("output_format"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT);
|
||||
let body_bytes = base64::engine::general_purpose::STANDARD.decode(body_base64)?;
|
||||
let text = std::str::from_utf8(&body_bytes)
|
||||
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?;
|
||||
|
||||
let mut created = None;
|
||||
let mut completed_response = None;
|
||||
let mut images = Vec::new();
|
||||
|
||||
for raw_block in text.split("\n\n") {
|
||||
let block = raw_block.trim();
|
||||
if block.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let data_line = block
|
||||
.lines()
|
||||
.find_map(|line| line.trim().strip_prefix("data:").map(str::trim));
|
||||
let Some(data_line) = data_line else {
|
||||
continue;
|
||||
};
|
||||
if data_line.is_empty() || data_line == "[DONE]" {
|
||||
continue;
|
||||
}
|
||||
let event: Value = serde_json::from_str(data_line)?;
|
||||
match event
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"response.created" => {
|
||||
created = event
|
||||
.get("response")
|
||||
.and_then(|value| value.get("created_at"))
|
||||
.and_then(Value::as_i64)
|
||||
.or(created);
|
||||
}
|
||||
"response.output_item.done" => {
|
||||
let Some(item) = event.get("item").and_then(Value::as_object) else {
|
||||
continue;
|
||||
};
|
||||
if item.get("type").and_then(Value::as_str) != Some("image_generation_call") {
|
||||
continue;
|
||||
}
|
||||
let Some(result) = item.get("result").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
images.push(serde_json::json!({
|
||||
"b64_json": result,
|
||||
"output_format": item.get("output_format").cloned().unwrap_or(Value::String(default_output_format.to_string())),
|
||||
"revised_prompt": item.get("revised_prompt").cloned().unwrap_or(Value::Null),
|
||||
}));
|
||||
}
|
||||
"response.completed" => {
|
||||
completed_response = event.get("response").and_then(Value::as_object).cloned();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if images.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let completed_response = completed_response.unwrap_or_default();
|
||||
let provider_usage = completed_response
|
||||
.get("tool_usage")
|
||||
.and_then(|value| value.get("image_gen"))
|
||||
.cloned()
|
||||
.or_else(|| completed_response.get("usage").cloned());
|
||||
let provider_body_json = serde_json::json!({
|
||||
"id": completed_response.get("id").cloned().unwrap_or(Value::Null),
|
||||
"object": "response",
|
||||
"model": completed_response.get("model").cloned().unwrap_or(Value::Null),
|
||||
"status": completed_response.get("status").cloned().unwrap_or(Value::String("completed".to_string())),
|
||||
"usage": provider_usage,
|
||||
"tool_usage": completed_response.get("tool_usage").cloned().unwrap_or(Value::Null),
|
||||
"output": images
|
||||
.iter()
|
||||
.map(|image| serde_json::json!({
|
||||
"type": "image_generation_call",
|
||||
"output_format": image.get("output_format").cloned().unwrap_or(Value::Null),
|
||||
"revised_prompt": image.get("revised_prompt").cloned().unwrap_or(Value::Null),
|
||||
}))
|
||||
.collect::<Vec<_>>(),
|
||||
});
|
||||
let client_images = images
|
||||
.iter()
|
||||
.map(|image| {
|
||||
let revised_prompt = image.get("revised_prompt").cloned().unwrap_or(Value::Null);
|
||||
let b64_json = image
|
||||
.get("b64_json")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
serde_json::json!({
|
||||
"b64_json": b64_json,
|
||||
"revised_prompt": revised_prompt,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let client_body_json = serde_json::json!({
|
||||
"created": created.unwrap_or_default(),
|
||||
"data": client_images,
|
||||
"usage": provider_body_json.get("usage").cloned().unwrap_or(Value::Null),
|
||||
});
|
||||
|
||||
Ok(Some(OpenAiImageSyncFinalizeProduct {
|
||||
client_body_json,
|
||||
provider_body_json,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use base64::Engine as _;
|
||||
use serde_json::json;
|
||||
|
||||
use super::{maybe_build_openai_image_sync_finalize_product, OpenAiImageStreamState};
|
||||
|
||||
fn utf8(bytes: Vec<u8>) -> String {
|
||||
String::from_utf8(bytes).expect("utf8 should decode")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_completed_event_for_generate() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "openai:image",
|
||||
"client_api_format": "openai:image",
|
||||
"needs_conversion": false,
|
||||
"image_request": {
|
||||
"operation": "generate"
|
||||
}
|
||||
});
|
||||
let mut rewriter = OpenAiImageStreamState::default();
|
||||
|
||||
let first = rewriter
|
||||
.push_chunk(
|
||||
&report_context,
|
||||
concat!(
|
||||
"event: response.output_item.done\n",
|
||||
"data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"id\":\"ig_123\",\"type\":\"image_generation_call\",\"result\":\"aGVsbG8=\"}}\n\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("rewrite should succeed");
|
||||
assert!(first.is_empty());
|
||||
|
||||
let second = rewriter
|
||||
.push_chunk(
|
||||
&report_context,
|
||||
concat!(
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"tool_usage\":{\"image_gen\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}}\n\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("rewrite should succeed");
|
||||
let output_text = utf8(second);
|
||||
assert!(output_text.contains("event: image_generation.completed"));
|
||||
assert!(output_text.contains("\"type\":\"image_generation.completed\""));
|
||||
assert!(output_text.contains("\"b64_json\":\"aGVsbG8=\""));
|
||||
assert!(output_text.contains("\"input_tokens\":1"));
|
||||
assert!(!output_text.contains("data: [DONE]"));
|
||||
assert!(rewriter
|
||||
.finish(&report_context)
|
||||
.expect("finish should succeed")
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_responses_partial_image_events() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "openai:image",
|
||||
"client_api_format": "openai:image",
|
||||
"needs_conversion": false,
|
||||
"image_request": {
|
||||
"operation": "generate",
|
||||
"partial_images": 1
|
||||
}
|
||||
});
|
||||
let mut rewriter = OpenAiImageStreamState::default();
|
||||
|
||||
let partial = rewriter
|
||||
.push_chunk(
|
||||
&report_context,
|
||||
concat!(
|
||||
"event: response.image_generation_call.partial_image\n",
|
||||
"data: {\"type\":\"response.image_generation_call.partial_image\",\"partial_image_index\":0,\"partial_image_b64\":\"cGFydGlhbA==\"}\n\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("rewrite should succeed");
|
||||
let partial_text = utf8(partial);
|
||||
assert!(partial_text.contains("event: image_generation.partial_image"));
|
||||
assert!(partial_text.contains("\"type\":\"image_generation.partial_image\""));
|
||||
assert!(partial_text.contains("\"b64_json\":\"cGFydGlhbA==\""));
|
||||
assert!(partial_text.contains("\"partial_image_index\":0"));
|
||||
assert!(!partial_text.contains("response.image_generation_call.partial_image"));
|
||||
|
||||
let done = rewriter
|
||||
.push_chunk(
|
||||
&report_context,
|
||||
concat!(
|
||||
"event: response.output_item.done\n",
|
||||
"data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"id\":\"ig_123\",\"type\":\"image_generation_call\",\"result\":\"ZmluYWw=\"}}\n\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("rewrite should succeed");
|
||||
assert!(done.is_empty());
|
||||
|
||||
let completed = rewriter
|
||||
.push_chunk(
|
||||
&report_context,
|
||||
concat!(
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":4,\"output_tokens\":5,\"total_tokens\":9}}}\n\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("rewrite should succeed");
|
||||
let completed_text = utf8(completed);
|
||||
assert!(completed_text.contains("event: image_generation.completed"));
|
||||
assert!(completed_text.contains("\"type\":\"image_generation.completed\""));
|
||||
assert!(completed_text.contains("\"b64_json\":\"ZmluYWw=\""));
|
||||
assert!(completed_text.contains("\"total_tokens\":9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_upstream_error_to_generation_failed_once() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "openai:image",
|
||||
"client_api_format": "openai:image",
|
||||
"needs_conversion": false,
|
||||
"image_request": {
|
||||
"operation": "generate"
|
||||
}
|
||||
});
|
||||
let mut rewriter = OpenAiImageStreamState::default();
|
||||
|
||||
let output = rewriter
|
||||
.push_chunk(
|
||||
&report_context,
|
||||
concat!(
|
||||
"event: error\n",
|
||||
"data: {\"type\":\"error\",\"error\":{\"type\":\"input-images\",\"code\":\"rate_limit_exceeded\",\"message\":\"Rate limit reached for gpt-image-2\",\"param\":null}}\n\n",
|
||||
"event: response.failed\n",
|
||||
"data: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"Rate limit reached for gpt-image-2\"}}}\n\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("rewrite should succeed");
|
||||
let output_text = utf8(output);
|
||||
assert!(output_text.contains("event: image_generation.failed"));
|
||||
assert_eq!(
|
||||
output_text
|
||||
.matches("event: image_generation.failed")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(output_text.contains("\"type\":\"image_generation.failed\""));
|
||||
assert!(output_text.contains("\"type\":\"input-images\""));
|
||||
assert!(output_text.contains("\"code\":\"rate_limit_exceeded\""));
|
||||
assert!(output_text.contains("\"message\":\"Rate limit reached for gpt-image-2\""));
|
||||
assert!(!output_text.contains("response.failed"));
|
||||
assert!(rewriter
|
||||
.finish(&report_context)
|
||||
.expect("finish should succeed")
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_finalize_product_maps_stream_response_to_client_and_provider_bodies() {
|
||||
let report_context = json!({
|
||||
"client_api_format": "openai:image",
|
||||
"provider_api_format": "openai:image",
|
||||
"image_request": {
|
||||
"operation": "generate",
|
||||
"output_format": "png"
|
||||
}
|
||||
});
|
||||
let body_base64 = base64::engine::general_purpose::STANDARD.encode(
|
||||
concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"created_at\":1776839946}}\n\n",
|
||||
"event: response.output_item.done\n",
|
||||
"data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"image_generation_call\",\"output_format\":\"png\",\"revised_prompt\":\"revised history prompt\",\"result\":\"aGVsbG8=\"}}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_img_123\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":171,\"output_tokens\":1372,\"total_tokens\":1543}}}}\n\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
|
||||
let product = maybe_build_openai_image_sync_finalize_product(
|
||||
"openai_image_sync_finalize",
|
||||
200,
|
||||
Some(&report_context),
|
||||
Some(&body_base64),
|
||||
)
|
||||
.expect("finalize should succeed")
|
||||
.expect("finalize should match");
|
||||
|
||||
assert_eq!(product.client_body_json["created"], 1776839946);
|
||||
assert_eq!(product.client_body_json["data"][0]["b64_json"], "aGVsbG8=");
|
||||
assert_eq!(
|
||||
product.client_body_json["data"][0]["revised_prompt"],
|
||||
"revised history prompt"
|
||||
);
|
||||
assert_eq!(product.client_body_json["usage"]["input_tokens"], 171);
|
||||
assert_eq!(product.provider_body_json["id"], "resp_img_123");
|
||||
assert_eq!(
|
||||
product.provider_body_json["output"][0]["output_format"],
|
||||
"png"
|
||||
);
|
||||
assert_eq!(
|
||||
product.provider_body_json["output"][0]["revised_prompt"],
|
||||
"revised history prompt"
|
||||
);
|
||||
}
|
||||
}
|
||||
7
crates/aether-ai-formats/src/formats/openai/mod.rs
Normal file
7
crates/aether-ai-formats/src/formats/openai/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod chat;
|
||||
pub mod embedding;
|
||||
pub mod image;
|
||||
pub mod rerank;
|
||||
pub mod responses;
|
||||
pub mod shared;
|
||||
pub mod video;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod request;
|
||||
113
crates/aether-ai-formats/src/formats/openai/rerank/request.rs
Normal file
113
crates/aether-ai-formats/src/formats/openai/rerank/request.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use serde_json::Map;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::formats::context::FormatContext;
|
||||
use crate::formats::openai::embedding::request::namespace_extensions;
|
||||
use crate::protocol::canonical::{
|
||||
namespace_extension_object, CanonicalRequest, CanonicalRerankRequest,
|
||||
};
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
|
||||
from_namespace(body, "openai")
|
||||
}
|
||||
|
||||
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
|
||||
to_openai_like(
|
||||
request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
"openai",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn from_namespace(body_json: &Value, namespace: &str) -> Option<CanonicalRequest> {
|
||||
let request = body_json.as_object()?;
|
||||
let model = request
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?
|
||||
.to_string();
|
||||
let query = request
|
||||
.get("query")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?
|
||||
.to_string();
|
||||
let documents = request.get("documents").and_then(Value::as_array)?.to_vec();
|
||||
let rerank = CanonicalRerankRequest {
|
||||
query,
|
||||
documents,
|
||||
top_n: request
|
||||
.get("top_n")
|
||||
.or_else(|| request.get("topN"))
|
||||
.and_then(Value::as_u64),
|
||||
return_documents: request
|
||||
.get("return_documents")
|
||||
.or_else(|| request.get("returnDocuments"))
|
||||
.and_then(Value::as_bool),
|
||||
extensions: namespace_extensions(
|
||||
namespace,
|
||||
request,
|
||||
&[
|
||||
"model",
|
||||
"query",
|
||||
"documents",
|
||||
"top_n",
|
||||
"topN",
|
||||
"return_documents",
|
||||
"returnDocuments",
|
||||
],
|
||||
),
|
||||
};
|
||||
if rerank.is_empty() || rerank.top_n == Some(0) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(CanonicalRequest {
|
||||
model,
|
||||
rerank: Some(rerank),
|
||||
..CanonicalRequest::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn to_openai_like(
|
||||
canonical: &CanonicalRequest,
|
||||
mapped_model: &str,
|
||||
namespace: &str,
|
||||
) -> Option<Value> {
|
||||
let rerank = canonical.rerank.as_ref()?;
|
||||
if rerank.is_empty() || rerank.top_n == Some(0) {
|
||||
return None;
|
||||
}
|
||||
let mut output = Map::new();
|
||||
output.insert(
|
||||
"model".to_string(),
|
||||
Value::String(mapped_rerank_model(canonical, mapped_model)),
|
||||
);
|
||||
output.insert("query".to_string(), Value::String(rerank.query.clone()));
|
||||
output.insert(
|
||||
"documents".to_string(),
|
||||
Value::Array(rerank.documents.clone()),
|
||||
);
|
||||
if let Some(value) = rerank.top_n {
|
||||
output.insert("top_n".to_string(), Value::from(value));
|
||||
}
|
||||
if let Some(value) = rerank.return_documents {
|
||||
output.insert("return_documents".to_string(), Value::Bool(value));
|
||||
}
|
||||
output.extend(namespace_extension_object(
|
||||
&rerank.extensions,
|
||||
namespace,
|
||||
&output,
|
||||
));
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
|
||||
fn mapped_rerank_model(canonical: &CanonicalRequest, mapped_model: &str) -> String {
|
||||
let mapped_model = mapped_model.trim();
|
||||
if mapped_model.is_empty() {
|
||||
canonical.model.clone()
|
||||
} else {
|
||||
mapped_model.to_string()
|
||||
}
|
||||
}
|
||||
519
crates/aether-ai-formats/src/formats/openai/responses/codex.rs
Normal file
519
crates/aether-ai-formats/src/formats/openai/responses/codex.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod codex;
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
pub mod spec;
|
||||
pub mod stream;
|
||||
513
crates/aether-ai-formats/src/formats/openai/responses/request.rs
Normal file
513
crates/aether-ai-formats/src/formats/openai/responses/request.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub use crate::formats::openai::chat::stream::{
|
||||
OpenAIResponsesClientEmitter, OpenAIResponsesProviderState,
|
||||
};
|
||||
94
crates/aether-ai-formats/src/formats/openai/shared.rs
Normal file
94
crates/aether-ai-formats/src/formats/openai/shared.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::formats::shared::model_directives::ReasoningEffort;
|
||||
|
||||
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> {
|
||||
ReasoningEffort::parse(value).map(ReasoningEffort::as_claude_output_value)
|
||||
}
|
||||
|
||||
pub fn map_openai_reasoning_effort_to_thinking_budget(value: &str) -> Option<u64> {
|
||||
ReasoningEffort::parse(value).map(ReasoningEffort::thinking_budget_tokens)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
1
crates/aether-ai-formats/src/formats/openai/video/mod.rs
Normal file
1
crates/aether-ai-formats/src/formats/openai/video/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod spec;
|
||||
27
crates/aether-ai-formats/src/formats/openai/video/spec.rs
Normal file
27
crates/aether-ai-formats/src/formats/openai/video/spec.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use crate::contracts::OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND;
|
||||
use crate::formats::shared::video::{LocalVideoCreateFamily, LocalVideoCreateSpec};
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalVideoCreateSpec> {
|
||||
match plan_kind {
|
||||
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND => Some(LocalVideoCreateSpec {
|
||||
api_format: "openai:video",
|
||||
decision_kind: OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
||||
report_kind: "openai_video_create_sync_finalize",
|
||||
family: LocalVideoCreateFamily::OpenAi,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_sync_spec, LocalVideoCreateFamily};
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_video_create_spec() {
|
||||
let spec = resolve_sync_spec("openai_video_create_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:video");
|
||||
assert_eq!(spec.family, LocalVideoCreateFamily::OpenAi);
|
||||
assert_eq!(spec.report_kind, "openai_video_create_sync_finalize");
|
||||
}
|
||||
}
|
||||
329
crates/aether-ai-formats/src/formats/registry.rs
Normal file
329
crates/aether-ai-formats/src/formats/registry.rs
Normal file
@@ -0,0 +1,329 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::formats::{
|
||||
claude::messages as claude_messages,
|
||||
doubao,
|
||||
gemini::{self, generate_content as gemini_generate_content},
|
||||
id::FormatId,
|
||||
jina,
|
||||
openai::{self, chat as openai_chat, responses as openai_responses},
|
||||
};
|
||||
use crate::protocol::canonical::{CanonicalRequest, CanonicalResponse};
|
||||
|
||||
pub use crate::formats::context::{FormatContext, 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 => openai_chat::request::from(body, ctx),
|
||||
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact => {
|
||||
openai_responses::request::from(body, ctx)
|
||||
}
|
||||
FormatId::ClaudeMessages => claude_messages::request::from(body, ctx),
|
||||
FormatId::GeminiGenerateContent => gemini_generate_content::request::from(body, ctx),
|
||||
FormatId::OpenAiEmbedding => openai::embedding::request::from(body, ctx),
|
||||
FormatId::JinaEmbedding => jina::embedding::request::from(body, ctx),
|
||||
FormatId::OpenAiRerank => openai::rerank::request::from(body, ctx),
|
||||
FormatId::JinaRerank => jina::rerank::request::from(body, ctx),
|
||||
FormatId::GeminiEmbedding | FormatId::DoubaoEmbedding => None,
|
||||
}
|
||||
.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();
|
||||
}
|
||||
match target {
|
||||
FormatId::OpenAiChat => openai_chat::request::to(&request, ctx),
|
||||
FormatId::OpenAiResponses => openai_responses::request::to(&request, ctx),
|
||||
FormatId::OpenAiResponsesCompact => openai_responses::request::to_compact(&request, ctx),
|
||||
FormatId::ClaudeMessages => claude_messages::request::to(&request, ctx),
|
||||
FormatId::GeminiGenerateContent => gemini_generate_content::request::to(&request, ctx),
|
||||
FormatId::OpenAiEmbedding => openai::embedding::request::to(&request, ctx),
|
||||
FormatId::JinaEmbedding => jina::embedding::request::to(&request, ctx),
|
||||
FormatId::OpenAiRerank => openai::rerank::request::to(&request, ctx),
|
||||
FormatId::JinaRerank => jina::rerank::request::to(&request, ctx),
|
||||
FormatId::GeminiEmbedding => gemini::embedding::request::to(&request, ctx),
|
||||
FormatId::DoubaoEmbedding => doubao::embedding::request::to(&request, ctx),
|
||||
}
|
||||
.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 => openai_chat::response::from(body, ctx),
|
||||
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact => {
|
||||
openai_responses::response::from(body, ctx)
|
||||
}
|
||||
FormatId::ClaudeMessages => claude_messages::response::from(body, ctx),
|
||||
FormatId::GeminiGenerateContent => gemini_generate_content::response::from(body, ctx),
|
||||
FormatId::OpenAiEmbedding
|
||||
| FormatId::JinaEmbedding
|
||||
| FormatId::OpenAiRerank
|
||||
| FormatId::JinaRerank
|
||||
| FormatId::GeminiEmbedding
|
||||
| FormatId::DoubaoEmbedding => None,
|
||||
}
|
||||
.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)?;
|
||||
match target {
|
||||
FormatId::OpenAiChat => openai_chat::response::to(response, ctx),
|
||||
FormatId::OpenAiResponses => openai_responses::response::to(response, ctx),
|
||||
FormatId::OpenAiResponsesCompact => openai_responses::response::to_compact(response, ctx),
|
||||
FormatId::ClaudeMessages => claude_messages::response::to(response, ctx),
|
||||
FormatId::GeminiGenerateContent => gemini_generate_content::response::to(response, ctx),
|
||||
FormatId::OpenAiEmbedding
|
||||
| FormatId::JinaEmbedding
|
||||
| FormatId::OpenAiRerank
|
||||
| FormatId::JinaRerank
|
||||
| FormatId::GeminiEmbedding
|
||||
| FormatId::DoubaoEmbedding => None,
|
||||
}
|
||||
.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()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{convert_request, FormatContext};
|
||||
use crate::formats::id::FormatId;
|
||||
|
||||
#[test]
|
||||
fn openai_cli_alias_is_not_a_primary_format() {
|
||||
assert_eq!(FormatId::parse("openai:cli"), None);
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_openai_embedding_to_jina_without_chat_fields() {
|
||||
let body = json!({
|
||||
"model": "text-embedding-3-small",
|
||||
"input": ["alpha", "beta"],
|
||||
"dimensions": 2
|
||||
});
|
||||
let ctx = FormatContext::default().with_mapped_model("jina-embeddings-v3");
|
||||
|
||||
let converted = convert_request("openai:embedding", "jina:embedding", &body, &ctx)
|
||||
.expect("embedding request conversion should succeed");
|
||||
|
||||
assert_eq!(converted["model"], "jina-embeddings-v3");
|
||||
assert_eq!(converted["task"], "text-matching");
|
||||
assert_eq!(converted["input"], json!(["alpha", "beta"]));
|
||||
assert!(converted.get("messages").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_openai_embedding_to_gemini_and_doubao_payload_shapes() {
|
||||
let body = json!({
|
||||
"model": "text-embedding-3-small",
|
||||
"input": ["alpha", "beta"],
|
||||
"dimensions": 2
|
||||
});
|
||||
|
||||
let gemini = convert_request(
|
||||
"openai:embedding",
|
||||
"gemini:embedding",
|
||||
&body,
|
||||
&FormatContext::default().with_mapped_model("gemini-embedding-001"),
|
||||
)
|
||||
.expect("gemini embedding conversion should succeed");
|
||||
assert_eq!(gemini["model"], "gemini-embedding-001");
|
||||
assert_eq!(
|
||||
gemini["requests"][0]["content"]["parts"][0]["text"],
|
||||
"alpha"
|
||||
);
|
||||
assert!(gemini.get("messages").is_none());
|
||||
|
||||
let doubao = convert_request(
|
||||
"openai:embedding",
|
||||
"doubao:embedding",
|
||||
&body,
|
||||
&FormatContext::default().with_mapped_model("doubao-embedding-vision"),
|
||||
)
|
||||
.expect("doubao embedding conversion should succeed");
|
||||
assert_eq!(doubao["model"], "doubao-embedding-vision");
|
||||
assert_eq!(doubao["input"][0], json!({"type": "text", "text": "alpha"}));
|
||||
assert!(doubao.get("messages").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_registry_keeps_gemini_and_doubao_emit_only() {
|
||||
let body = json!({
|
||||
"model": "gemini-embedding-001",
|
||||
"content": {"parts": [{"text": "alpha"}]}
|
||||
});
|
||||
let ctx = FormatContext::default();
|
||||
|
||||
assert!(convert_request("gemini:embedding", "openai:embedding", &body, &ctx).is_err());
|
||||
assert!(convert_request("doubao:embedding", "openai:embedding", &body, &ctx).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_registry_rejects_chat_payload_for_embedding_format() {
|
||||
let body = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
let ctx = FormatContext::default();
|
||||
|
||||
assert!(convert_request("openai:embedding", "jina:embedding", &body, &ctx).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_openai_rerank_to_jina_without_chat_fields() {
|
||||
let body = json!({
|
||||
"model": "rerank-source",
|
||||
"query": "best document",
|
||||
"documents": ["alpha", {"text": "beta"}],
|
||||
"top_n": 1,
|
||||
"return_documents": true
|
||||
});
|
||||
let ctx = FormatContext::default().with_mapped_model("jina-reranker-v2-base-multilingual");
|
||||
|
||||
let converted = convert_request("openai:rerank", "jina:rerank", &body, &ctx)
|
||||
.expect("rerank request conversion should succeed");
|
||||
|
||||
assert_eq!(converted["model"], "jina-reranker-v2-base-multilingual");
|
||||
assert_eq!(converted["query"], "best document");
|
||||
assert_eq!(converted["documents"], json!(["alpha", {"text": "beta"}]));
|
||||
assert_eq!(converted["top_n"], 1);
|
||||
assert_eq!(converted["return_documents"], true);
|
||||
assert!(converted.get("messages").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerank_registry_rejects_invalid_payloads() {
|
||||
let ctx = FormatContext::default();
|
||||
for body in [
|
||||
json!({"model": "rerank", "documents": ["alpha"]}),
|
||||
json!({"model": "rerank", "query": "q", "documents": []}),
|
||||
json!({"model": "rerank", "query": "q", "documents": [""]}),
|
||||
json!({"model": "rerank", "query": "q", "documents": ["alpha"], "top_n": 0}),
|
||||
] {
|
||||
assert!(convert_request("openai:rerank", "jina:rerank", &body, &ctx).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_does_not_call_wire_specific_canonical_functions_directly() {
|
||||
let implementation = include_str!("registry.rs")
|
||||
.split("#[cfg(test)]")
|
||||
.next()
|
||||
.expect("registry implementation should be readable");
|
||||
for forbidden in [
|
||||
"canonical_to_openai",
|
||||
"canonical_to_claude",
|
||||
"canonical_to_gemini",
|
||||
"from_openai_chat_to_canonical",
|
||||
"from_openai_responses_to_canonical",
|
||||
"from_claude_to_canonical",
|
||||
"from_gemini_to_canonical",
|
||||
] {
|
||||
assert!(
|
||||
!implementation.contains(forbidden),
|
||||
"registry should dispatch through formats::<provider>::<surface> adapters, found {forbidden}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
168
crates/aether-ai-formats/src/formats/shared/error_body.rs
Normal file
168
crates/aether-ai-formats/src/formats/shared/error_body.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum LocalCoreSyncErrorKind {
|
||||
InvalidRequest,
|
||||
Authentication,
|
||||
PermissionDenied,
|
||||
NotFound,
|
||||
RateLimit,
|
||||
ContextLengthExceeded,
|
||||
Overloaded,
|
||||
ServerError,
|
||||
}
|
||||
|
||||
pub fn is_core_error_finalize_kind(report_kind: &str) -> bool {
|
||||
core_error_default_client_api_format(report_kind).is_some()
|
||||
}
|
||||
|
||||
pub fn core_error_default_client_api_format(report_kind: &str) -> Option<&'static str> {
|
||||
crate::contracts::core_error_default_client_api_format(report_kind)
|
||||
}
|
||||
|
||||
pub fn core_error_background_report_kind(report_kind: &str) -> Option<&'static str> {
|
||||
crate::contracts::core_error_background_report_kind(report_kind)
|
||||
}
|
||||
|
||||
pub fn core_success_background_report_kind(report_kind: &str) -> Option<&'static str> {
|
||||
crate::contracts::core_success_background_report_kind(report_kind)
|
||||
}
|
||||
|
||||
pub fn build_core_error_body_for_client_format(
|
||||
client_api_format: &str,
|
||||
message: &str,
|
||||
code: Option<&str>,
|
||||
kind: LocalCoreSyncErrorKind,
|
||||
) -> Option<Value> {
|
||||
let mut error_object = Map::new();
|
||||
error_object.insert("message".to_string(), Value::String(message.to_string()));
|
||||
|
||||
match aether_ai_formats::normalize_api_format_alias(client_api_format).as_str() {
|
||||
"openai:chat" | "openai:responses" | "openai:responses:compact" => {
|
||||
error_object.insert(
|
||||
"type".to_string(),
|
||||
Value::String(map_local_sync_error_kind_to_openai_type(kind).to_string()),
|
||||
);
|
||||
if let Some(code) = code.filter(|value| !value.is_empty()) {
|
||||
error_object.insert("code".to_string(), Value::String(code.to_string()));
|
||||
}
|
||||
Some(Value::Object(Map::from_iter([(
|
||||
"error".to_string(),
|
||||
Value::Object(error_object),
|
||||
)])))
|
||||
}
|
||||
"claude:messages" => {
|
||||
error_object.insert(
|
||||
"type".to_string(),
|
||||
Value::String(map_local_sync_error_kind_to_claude_type(kind).to_string()),
|
||||
);
|
||||
if let Some(code) = code.filter(|value| !value.is_empty()) {
|
||||
error_object.insert("code".to_string(), Value::String(code.to_string()));
|
||||
}
|
||||
Some(Value::Object(Map::from_iter([
|
||||
("type".to_string(), Value::String("error".to_string())),
|
||||
("error".to_string(), Value::Object(error_object)),
|
||||
])))
|
||||
}
|
||||
"gemini:generate_content" => Some(Value::Object(Map::from_iter([(
|
||||
"error".to_string(),
|
||||
Value::Object(Map::from_iter([
|
||||
(
|
||||
"code".to_string(),
|
||||
Value::from(map_local_sync_error_kind_to_gemini_code(kind)),
|
||||
),
|
||||
("message".to_string(), Value::String(message.to_string())),
|
||||
(
|
||||
"status".to_string(),
|
||||
Value::String(map_local_sync_error_kind_to_gemini_status(kind).to_string()),
|
||||
),
|
||||
])),
|
||||
)]))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_local_sync_error_kind_to_openai_type(kind: LocalCoreSyncErrorKind) -> &'static str {
|
||||
match kind {
|
||||
LocalCoreSyncErrorKind::InvalidRequest => "invalid_request_error",
|
||||
LocalCoreSyncErrorKind::Authentication => "authentication_error",
|
||||
LocalCoreSyncErrorKind::PermissionDenied => "permission_error",
|
||||
LocalCoreSyncErrorKind::NotFound => "not_found_error",
|
||||
LocalCoreSyncErrorKind::RateLimit => "rate_limit_error",
|
||||
LocalCoreSyncErrorKind::ContextLengthExceeded => "context_length_exceeded",
|
||||
LocalCoreSyncErrorKind::Overloaded | LocalCoreSyncErrorKind::ServerError => "server_error",
|
||||
}
|
||||
}
|
||||
|
||||
fn map_local_sync_error_kind_to_claude_type(kind: LocalCoreSyncErrorKind) -> &'static str {
|
||||
match kind {
|
||||
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
|
||||
"invalid_request_error"
|
||||
}
|
||||
LocalCoreSyncErrorKind::Authentication => "authentication_error",
|
||||
LocalCoreSyncErrorKind::PermissionDenied => "permission_error",
|
||||
LocalCoreSyncErrorKind::NotFound => "not_found_error",
|
||||
LocalCoreSyncErrorKind::RateLimit => "rate_limit_error",
|
||||
LocalCoreSyncErrorKind::Overloaded | LocalCoreSyncErrorKind::ServerError => "api_error",
|
||||
}
|
||||
}
|
||||
|
||||
fn map_local_sync_error_kind_to_gemini_code(kind: LocalCoreSyncErrorKind) -> u16 {
|
||||
match kind {
|
||||
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
|
||||
400
|
||||
}
|
||||
LocalCoreSyncErrorKind::Authentication => 401,
|
||||
LocalCoreSyncErrorKind::PermissionDenied => 403,
|
||||
LocalCoreSyncErrorKind::NotFound => 404,
|
||||
LocalCoreSyncErrorKind::RateLimit => 429,
|
||||
LocalCoreSyncErrorKind::Overloaded => 503,
|
||||
LocalCoreSyncErrorKind::ServerError => 500,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_local_sync_error_kind_to_gemini_status(kind: LocalCoreSyncErrorKind) -> &'static str {
|
||||
match kind {
|
||||
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
|
||||
"INVALID_ARGUMENT"
|
||||
}
|
||||
LocalCoreSyncErrorKind::Authentication => "UNAUTHENTICATED",
|
||||
LocalCoreSyncErrorKind::PermissionDenied => "PERMISSION_DENIED",
|
||||
LocalCoreSyncErrorKind::NotFound => "NOT_FOUND",
|
||||
LocalCoreSyncErrorKind::RateLimit => "RESOURCE_EXHAUSTED",
|
||||
LocalCoreSyncErrorKind::Overloaded => "UNAVAILABLE",
|
||||
LocalCoreSyncErrorKind::ServerError => "INTERNAL",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_core_error_body_for_client_format, core_success_background_report_kind,
|
||||
is_core_error_finalize_kind, LocalCoreSyncErrorKind,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn builds_openai_core_error_body() {
|
||||
let body = build_core_error_body_for_client_format(
|
||||
"openai:chat",
|
||||
"bad request",
|
||||
Some("invalid_request"),
|
||||
LocalCoreSyncErrorKind::InvalidRequest,
|
||||
)
|
||||
.expect("body should build");
|
||||
|
||||
assert_eq!(body["error"]["message"], "bad request");
|
||||
assert_eq!(body["error"]["type"], "invalid_request_error");
|
||||
assert_eq!(body["error"]["code"], "invalid_request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_finalize_kind_and_success_mapping() {
|
||||
assert!(is_core_error_finalize_kind("openai_chat_sync_finalize"));
|
||||
assert_eq!(
|
||||
core_success_background_report_kind("openai_chat_sync_finalize"),
|
||||
Some("openai_chat_sync_success")
|
||||
);
|
||||
}
|
||||
}
|
||||
22
crates/aether-ai-formats/src/formats/shared/family.rs
Normal file
22
crates/aether-ai-formats/src/formats/shared/family.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LocalStandardSourceFamily {
|
||||
Standard,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LocalStandardSourceMode {
|
||||
Chat,
|
||||
Cli,
|
||||
Embedding,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LocalStandardSpec {
|
||||
pub api_format: &'static str,
|
||||
pub decision_kind: &'static str,
|
||||
pub report_kind: &'static str,
|
||||
pub family: LocalStandardSourceFamily,
|
||||
pub mode: LocalStandardSourceMode,
|
||||
pub require_streaming: bool,
|
||||
}
|
||||
54
crates/aether-ai-formats/src/formats/shared/mod.rs
Normal file
54
crates/aether-ai-formats/src/formats/shared/mod.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use std::fmt;
|
||||
|
||||
pub mod error_body;
|
||||
pub mod family;
|
||||
pub mod model_directives;
|
||||
pub mod passthrough;
|
||||
pub mod request;
|
||||
pub mod request_matrix;
|
||||
pub mod response;
|
||||
pub mod routing;
|
||||
pub mod sse;
|
||||
pub mod standard_matrix;
|
||||
pub mod standard_normalize;
|
||||
pub mod stream_core;
|
||||
pub mod stream_rewrite;
|
||||
pub mod sync_products;
|
||||
pub mod sync_to_stream;
|
||||
pub mod video;
|
||||
|
||||
pub use self::sse::{encode_done_sse, encode_json_sse, map_claude_stop_reason};
|
||||
pub use self::stream_core::{CanonicalStreamEvent, CanonicalStreamFrame};
|
||||
pub use self::stream_rewrite::{
|
||||
maybe_build_ai_surface_stream_rewriter, resolve_finalize_stream_rewrite_mode,
|
||||
AiSurfaceStreamRewriter, FinalizeStreamRewriteMode,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AiSurfaceFinalizeError(pub String);
|
||||
|
||||
impl AiSurfaceFinalizeError {
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self(message.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AiSurfaceFinalizeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "AI surface finalize error: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AiSurfaceFinalizeError {}
|
||||
|
||||
impl From<serde_json::Error> for AiSurfaceFinalizeError {
|
||||
fn from(source: serde_json::Error) -> Self {
|
||||
Self(source.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<base64::DecodeError> for AiSurfaceFinalizeError {
|
||||
fn from(source: base64::DecodeError) -> Self {
|
||||
Self(source.to_string())
|
||||
}
|
||||
}
|
||||
434
crates/aether-ai-formats/src/formats/shared/model_directives.rs
Normal file
434
crates/aether-ai-formats/src/formats/shared/model_directives.rs
Normal file
@@ -0,0 +1,434 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ModelDirective {
|
||||
pub base_model: String,
|
||||
pub overrides: Vec<ModelOverride>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ModelOverride {
|
||||
ReasoningEffort(ReasoningEffort),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReasoningEffort {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
XHigh,
|
||||
Max,
|
||||
}
|
||||
|
||||
impl ReasoningEffort {
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"low" => Some(Self::Low),
|
||||
"medium" => Some(Self::Medium),
|
||||
"high" => Some(Self::High),
|
||||
"xhigh" => Some(Self::XHigh),
|
||||
"max" => Some(Self::Max),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_openai_chat_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Low => "low",
|
||||
Self::Medium => "medium",
|
||||
Self::High => "high",
|
||||
Self::XHigh => "xhigh",
|
||||
Self::Max => "xhigh",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_openai_responses_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Low => "low",
|
||||
Self::Medium => "medium",
|
||||
Self::High => "high",
|
||||
Self::XHigh | Self::Max => "xhigh",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_claude_output_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Low => "low",
|
||||
Self::Medium => "medium",
|
||||
Self::High => "high",
|
||||
Self::XHigh => "xhigh",
|
||||
Self::Max => "max",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_gemini_level_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Low => "low",
|
||||
Self::Medium => "medium",
|
||||
Self::High | Self::XHigh | Self::Max => "high",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn thinking_budget_tokens(self) -> u64 {
|
||||
match self {
|
||||
Self::Low => 1280,
|
||||
Self::Medium => 2048,
|
||||
Self::High => 4096,
|
||||
Self::XHigh | Self::Max => 8192,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_model_directive(model: &str) -> Option<ModelDirective> {
|
||||
let model = model.trim();
|
||||
let (base_model, suffix) = model.rsplit_once('-')?;
|
||||
let base_model = base_model.trim();
|
||||
if base_model.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let reasoning_effort = ReasoningEffort::parse(suffix)?;
|
||||
Some(ModelDirective {
|
||||
base_model: base_model.to_string(),
|
||||
overrides: vec![ModelOverride::ReasoningEffort(reasoning_effort)],
|
||||
})
|
||||
}
|
||||
|
||||
pub fn model_directive_base_model(model: &str) -> Option<String> {
|
||||
parse_model_directive(model).map(|directive| directive.base_model)
|
||||
}
|
||||
|
||||
pub fn normalize_model_directive_model(model: &str) -> String {
|
||||
parse_model_directive(model)
|
||||
.map(|directive| directive.base_model)
|
||||
.unwrap_or_else(|| model.trim().to_string())
|
||||
}
|
||||
|
||||
pub fn apply_model_directive_overrides_from_request(
|
||||
provider_request_body: &mut Value,
|
||||
provider_api_format: &str,
|
||||
provider_model: &str,
|
||||
request_body: &Value,
|
||||
request_path: Option<&str>,
|
||||
) -> Option<ModelDirective> {
|
||||
let source_model = request_body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| request_path.and_then(extract_gemini_model_from_path))?;
|
||||
|
||||
apply_model_directive_overrides_from_model(
|
||||
provider_request_body,
|
||||
provider_api_format,
|
||||
provider_model,
|
||||
&source_model,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn apply_model_directive_overrides_from_model(
|
||||
provider_request_body: &mut Value,
|
||||
provider_api_format: &str,
|
||||
provider_model: &str,
|
||||
source_model: &str,
|
||||
) -> Option<ModelDirective> {
|
||||
let directive = parse_model_directive(source_model)?;
|
||||
for override_item in &directive.overrides {
|
||||
match override_item {
|
||||
ModelOverride::ReasoningEffort(effort) => {
|
||||
apply_reasoning_effort_override(
|
||||
provider_request_body,
|
||||
provider_api_format,
|
||||
provider_model,
|
||||
*effort,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(directive)
|
||||
}
|
||||
|
||||
pub fn apply_model_directive_mapping_patch(
|
||||
provider_request_body: &mut Value,
|
||||
patch: &Value,
|
||||
) -> Option<()> {
|
||||
deep_merge_json(provider_request_body, patch);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn deep_merge_json(target: &mut Value, patch: &Value) {
|
||||
match (target, patch) {
|
||||
(Value::Object(target_object), Value::Object(patch_object)) => {
|
||||
for (key, patch_value) in patch_object {
|
||||
match target_object.get_mut(key) {
|
||||
Some(target_value) => deep_merge_json(target_value, patch_value),
|
||||
None => {
|
||||
target_object.insert(key.clone(), patch_value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(target, patch) => {
|
||||
*target = patch.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_reasoning_effort_override(
|
||||
provider_request_body: &mut Value,
|
||||
provider_api_format: &str,
|
||||
provider_model: &str,
|
||||
effort: ReasoningEffort,
|
||||
) -> Option<()> {
|
||||
match crate::normalize_api_format_alias(provider_api_format).as_str() {
|
||||
"openai:chat" => set_object_string(
|
||||
provider_request_body,
|
||||
"reasoning_effort",
|
||||
effort.as_openai_chat_value(),
|
||||
),
|
||||
"openai:responses" | "openai:responses:compact" => {
|
||||
set_openai_responses_reasoning_effort(provider_request_body, effort)
|
||||
}
|
||||
"claude:messages" => {
|
||||
set_claude_reasoning_effort(provider_request_body, effort, provider_model)
|
||||
}
|
||||
"gemini:generate_content" => {
|
||||
set_gemini_reasoning_effort(provider_request_body, effort, provider_model)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_object_string(body: &mut Value, key: &str, value: &str) -> Option<()> {
|
||||
body.as_object_mut()?
|
||||
.insert(key.to_string(), Value::String(value.to_string()));
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn set_openai_responses_reasoning_effort(body: &mut Value, effort: ReasoningEffort) -> Option<()> {
|
||||
let body_object = body.as_object_mut()?;
|
||||
let reasoning = body_object
|
||||
.entry("reasoning".to_string())
|
||||
.or_insert_with(|| json!({}));
|
||||
if !reasoning.is_object() {
|
||||
*reasoning = json!({});
|
||||
}
|
||||
reasoning.as_object_mut()?.insert(
|
||||
"effort".to_string(),
|
||||
Value::String(effort.as_openai_responses_value().to_string()),
|
||||
);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn set_claude_reasoning_effort(
|
||||
body: &mut Value,
|
||||
effort: ReasoningEffort,
|
||||
provider_model: &str,
|
||||
) -> Option<()> {
|
||||
let body_object = body.as_object_mut()?;
|
||||
let output_config = body_object
|
||||
.entry("output_config".to_string())
|
||||
.or_insert_with(|| json!({}));
|
||||
if !output_config.is_object() {
|
||||
*output_config = json!({});
|
||||
}
|
||||
output_config.as_object_mut()?.insert(
|
||||
"effort".to_string(),
|
||||
Value::String(effort.as_claude_output_value().to_string()),
|
||||
);
|
||||
|
||||
let thinking = body_object
|
||||
.entry("thinking".to_string())
|
||||
.or_insert_with(|| json!({}));
|
||||
if !thinking.is_object() {
|
||||
*thinking = json!({});
|
||||
}
|
||||
let thinking = thinking.as_object_mut()?;
|
||||
if claude_model_uses_adaptive_effort(provider_model) {
|
||||
thinking.insert("type".to_string(), Value::String("adaptive".to_string()));
|
||||
thinking.remove("budget_tokens");
|
||||
} else {
|
||||
thinking.insert("type".to_string(), Value::String("enabled".to_string()));
|
||||
thinking.insert(
|
||||
"budget_tokens".to_string(),
|
||||
Value::from(effort.thinking_budget_tokens()),
|
||||
);
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn set_gemini_reasoning_effort(
|
||||
body: &mut Value,
|
||||
effort: ReasoningEffort,
|
||||
provider_model: &str,
|
||||
) -> Option<()> {
|
||||
let body_object = body.as_object_mut()?;
|
||||
let generation_key = if body_object.contains_key("generation_config")
|
||||
&& !body_object.contains_key("generationConfig")
|
||||
{
|
||||
"generation_config"
|
||||
} else {
|
||||
"generationConfig"
|
||||
};
|
||||
let generation_config = body_object
|
||||
.entry(generation_key.to_string())
|
||||
.or_insert_with(|| json!({}));
|
||||
if !generation_config.is_object() {
|
||||
*generation_config = json!({});
|
||||
}
|
||||
let generation_config = generation_config.as_object_mut()?;
|
||||
let thinking_key = if generation_config.contains_key("thinking_config")
|
||||
&& !generation_config.contains_key("thinkingConfig")
|
||||
{
|
||||
"thinking_config"
|
||||
} else {
|
||||
"thinkingConfig"
|
||||
};
|
||||
generation_config.insert(
|
||||
thinking_key.to_string(),
|
||||
gemini_reasoning_effort_config(effort, provider_model, thinking_key),
|
||||
);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn gemini_reasoning_effort_config(
|
||||
effort: ReasoningEffort,
|
||||
provider_model: &str,
|
||||
thinking_key: &str,
|
||||
) -> Value {
|
||||
if gemini_model_uses_thinking_level(provider_model) {
|
||||
if thinking_key == "thinking_config" {
|
||||
return json!({
|
||||
"include_thoughts": true,
|
||||
"thinking_level": effort.as_gemini_level_value(),
|
||||
});
|
||||
}
|
||||
return json!({
|
||||
"includeThoughts": true,
|
||||
"thinkingLevel": effort.as_gemini_level_value(),
|
||||
});
|
||||
}
|
||||
|
||||
if thinking_key == "thinking_config" {
|
||||
return json!({
|
||||
"include_thoughts": true,
|
||||
"thinking_budget": effort.thinking_budget_tokens(),
|
||||
});
|
||||
}
|
||||
json!({
|
||||
"includeThoughts": true,
|
||||
"thinkingBudget": effort.thinking_budget_tokens(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn claude_model_uses_adaptive_effort(model: &str) -> bool {
|
||||
let model = model.trim().to_ascii_lowercase().replace(['.', '_'], "-");
|
||||
model.contains("mythos")
|
||||
|| model.contains("opus-4-7")
|
||||
|| model.contains("opus-4-6")
|
||||
|| model.contains("sonnet-4-6")
|
||||
}
|
||||
|
||||
pub fn gemini_model_uses_thinking_level(model: &str) -> bool {
|
||||
model
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.split('/')
|
||||
.any(|part| part.starts_with("gemini-3"))
|
||||
}
|
||||
|
||||
pub fn extract_gemini_model_from_path(path: &str) -> Option<String> {
|
||||
let marker = "/models/";
|
||||
let start = path.find(marker)? + marker.len();
|
||||
let tail = &path[start..];
|
||||
let end = tail.find(':').unwrap_or(tail.len());
|
||||
let model = tail[..end].trim();
|
||||
(!model.is_empty()).then(|| model.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
apply_model_directive_overrides_from_model, parse_model_directive, ModelDirective,
|
||||
ModelOverride, ReasoningEffort,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn parses_supported_reasoning_effort_suffixes() {
|
||||
assert_eq!(
|
||||
parse_model_directive("gpt-5.4-xhigh"),
|
||||
Some(ModelDirective {
|
||||
base_model: "gpt-5.4".to_string(),
|
||||
overrides: vec![ModelOverride::ReasoningEffort(ReasoningEffort::XHigh)],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_model_directive("gpt-5.4-MAX"),
|
||||
Some(ModelDirective {
|
||||
base_model: "gpt-5.4".to_string(),
|
||||
overrides: vec![ModelOverride::ReasoningEffort(ReasoningEffort::Max)],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unknown_or_incomplete_suffixes() {
|
||||
assert_eq!(parse_model_directive("gpt-5.4-ultra"), None);
|
||||
assert_eq!(parse_model_directive("gpt-5.4"), None);
|
||||
assert_eq!(parse_model_directive("-high"), None);
|
||||
assert_eq!(parse_model_directive("gpt-5.4-high-json"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_reasoning_effort_to_provider_body_shapes() {
|
||||
let mut openai_chat = json!({"model": "gpt-5-upstream", "reasoning_effort": "low"});
|
||||
apply_model_directive_overrides_from_model(
|
||||
&mut openai_chat,
|
||||
"openai:chat",
|
||||
"gpt-5-upstream",
|
||||
"gpt-5.4-xhigh",
|
||||
)
|
||||
.expect("directive should apply");
|
||||
assert_eq!(openai_chat["reasoning_effort"], "xhigh");
|
||||
|
||||
let mut responses = json!({
|
||||
"model": "gpt-5-upstream",
|
||||
"reasoning": {"effort": "low", "summary": "auto"}
|
||||
});
|
||||
apply_model_directive_overrides_from_model(
|
||||
&mut responses,
|
||||
"openai:responses",
|
||||
"gpt-5-upstream",
|
||||
"gpt-5.4-max",
|
||||
)
|
||||
.expect("directive should apply");
|
||||
assert_eq!(responses["reasoning"]["effort"], "xhigh");
|
||||
assert_eq!(responses["reasoning"]["summary"], "auto");
|
||||
|
||||
let mut claude = json!({"model": "claude-sonnet-4-5"});
|
||||
apply_model_directive_overrides_from_model(
|
||||
&mut claude,
|
||||
"claude:messages",
|
||||
"claude-sonnet-4-5",
|
||||
"gpt-5.4-high",
|
||||
)
|
||||
.expect("directive should apply");
|
||||
assert_eq!(claude["thinking"]["budget_tokens"], 4096);
|
||||
|
||||
let mut gemini = json!({});
|
||||
apply_model_directive_overrides_from_model(
|
||||
&mut gemini,
|
||||
"gemini:generate_content",
|
||||
"gemini-2.5-pro",
|
||||
"gpt-5.4-medium",
|
||||
)
|
||||
.expect("directive should apply");
|
||||
assert_eq!(
|
||||
gemini["generationConfig"]["thinkingConfig"]["thinkingBudget"],
|
||||
2048
|
||||
);
|
||||
}
|
||||
}
|
||||
140
crates/aether-ai-formats/src/formats/shared/passthrough.rs
Normal file
140
crates/aether-ai-formats/src/formats/shared/passthrough.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use crate::contracts::{
|
||||
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, OPENAI_EMBEDDING_SYNC_PLAN_KIND,
|
||||
OPENAI_RERANK_SYNC_PLAN_KIND,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LocalSameFormatProviderFamily {
|
||||
Standard,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LocalSameFormatProviderSpec {
|
||||
pub api_format: &'static str,
|
||||
pub decision_kind: &'static str,
|
||||
pub report_kind: &'static str,
|
||||
pub family: LocalSameFormatProviderFamily,
|
||||
pub require_streaming: bool,
|
||||
}
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalSameFormatProviderSpec> {
|
||||
match plan_kind {
|
||||
CLAUDE_CHAT_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "claude:messages",
|
||||
decision_kind: CLAUDE_CHAT_SYNC_PLAN_KIND,
|
||||
report_kind: "claude_chat_sync_success",
|
||||
family: LocalSameFormatProviderFamily::Standard,
|
||||
require_streaming: false,
|
||||
}),
|
||||
CLAUDE_CLI_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "claude:messages",
|
||||
decision_kind: CLAUDE_CLI_SYNC_PLAN_KIND,
|
||||
report_kind: "claude_cli_sync_success",
|
||||
family: LocalSameFormatProviderFamily::Standard,
|
||||
require_streaming: false,
|
||||
}),
|
||||
GEMINI_CHAT_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "gemini:generate_content",
|
||||
decision_kind: GEMINI_CHAT_SYNC_PLAN_KIND,
|
||||
report_kind: "gemini_chat_sync_success",
|
||||
family: LocalSameFormatProviderFamily::Gemini,
|
||||
require_streaming: false,
|
||||
}),
|
||||
GEMINI_CLI_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "gemini:generate_content",
|
||||
decision_kind: GEMINI_CLI_SYNC_PLAN_KIND,
|
||||
report_kind: "gemini_cli_sync_success",
|
||||
family: LocalSameFormatProviderFamily::Gemini,
|
||||
require_streaming: false,
|
||||
}),
|
||||
OPENAI_EMBEDDING_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "openai:embedding",
|
||||
decision_kind: OPENAI_EMBEDDING_SYNC_PLAN_KIND,
|
||||
report_kind: "openai_embedding_sync_success",
|
||||
family: LocalSameFormatProviderFamily::Standard,
|
||||
require_streaming: false,
|
||||
}),
|
||||
OPENAI_RERANK_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "openai:rerank",
|
||||
decision_kind: OPENAI_RERANK_SYNC_PLAN_KIND,
|
||||
report_kind: "openai_rerank_sync_success",
|
||||
family: LocalSameFormatProviderFamily::Standard,
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalSameFormatProviderSpec> {
|
||||
match plan_kind {
|
||||
CLAUDE_CHAT_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "claude:messages",
|
||||
decision_kind: CLAUDE_CHAT_STREAM_PLAN_KIND,
|
||||
report_kind: "claude_chat_stream_success",
|
||||
family: LocalSameFormatProviderFamily::Standard,
|
||||
require_streaming: true,
|
||||
}),
|
||||
CLAUDE_CLI_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "claude:messages",
|
||||
decision_kind: CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||
report_kind: "claude_cli_stream_success",
|
||||
family: LocalSameFormatProviderFamily::Standard,
|
||||
require_streaming: true,
|
||||
}),
|
||||
GEMINI_CHAT_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "gemini:generate_content",
|
||||
decision_kind: GEMINI_CHAT_STREAM_PLAN_KIND,
|
||||
report_kind: "gemini_chat_stream_success",
|
||||
family: LocalSameFormatProviderFamily::Gemini,
|
||||
require_streaming: true,
|
||||
}),
|
||||
GEMINI_CLI_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "gemini:generate_content",
|
||||
decision_kind: GEMINI_CLI_STREAM_PLAN_KIND,
|
||||
report_kind: "gemini_cli_stream_success",
|
||||
family: LocalSameFormatProviderFamily::Gemini,
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_spec, resolve_sync_spec};
|
||||
|
||||
#[test]
|
||||
fn resolves_claude_sync_same_format_spec() {
|
||||
let spec = resolve_sync_spec("claude_chat_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "claude:messages");
|
||||
assert_eq!(spec.report_kind, "claude_chat_sync_success");
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_gemini_stream_same_format_spec() {
|
||||
let spec = resolve_stream_spec("gemini_cli_stream").expect("spec");
|
||||
assert_eq!(spec.api_format, "gemini:generate_content");
|
||||
assert_eq!(spec.report_kind, "gemini_cli_stream_success");
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_embedding_sync_same_format_spec() {
|
||||
let spec = resolve_sync_spec("openai_embedding_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:embedding");
|
||||
assert_eq!(spec.report_kind, "openai_embedding_sync_success");
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_rerank_sync_same_format_spec() {
|
||||
let spec = resolve_sync_spec("openai_rerank_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:rerank");
|
||||
assert_eq!(spec.report_kind, "openai_rerank_sync_success");
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
}
|
||||
80
crates/aether-ai-formats/src/formats/shared/request.rs
Normal file
80
crates/aether-ai-formats/src/formats/shared/request.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use base64::Engine as _;
|
||||
|
||||
pub fn parse_direct_request_body(
|
||||
is_json_request: bool,
|
||||
body_bytes: &[u8],
|
||||
) -> Option<(serde_json::Value, Option<String>)> {
|
||||
if is_json_request {
|
||||
if body_bytes.is_empty() {
|
||||
Some((serde_json::json!({}), None))
|
||||
} else {
|
||||
serde_json::from_slice::<serde_json::Value>(body_bytes)
|
||||
.ok()
|
||||
.map(|value| (value, None))
|
||||
}
|
||||
} else {
|
||||
Some((
|
||||
serde_json::json!({}),
|
||||
(!body_bytes.is_empty())
|
||||
.then(|| base64::engine::general_purpose::STANDARD.encode(body_bytes)),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn force_upstream_streaming_for_provider(
|
||||
provider_type: &str,
|
||||
provider_api_format: &str,
|
||||
) -> bool {
|
||||
provider_type.trim().eq_ignore_ascii_case("codex")
|
||||
&& aether_ai_formats::is_openai_responses_format(provider_api_format)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{force_upstream_streaming_for_provider, parse_direct_request_body};
|
||||
|
||||
#[test]
|
||||
fn parses_empty_json_body_as_empty_object() {
|
||||
assert_eq!(
|
||||
parse_direct_request_body(true, b""),
|
||||
Some((serde_json::json!({}), None))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_json_body() {
|
||||
assert_eq!(parse_direct_request_body(true, b"{invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_non_json_body_as_base64() {
|
||||
assert_eq!(
|
||||
parse_direct_request_body(false, b"hello"),
|
||||
Some((serde_json::json!({}), Some("aGVsbG8=".to_string())))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forces_streaming_for_codex_openai_responses() {
|
||||
assert!(force_upstream_streaming_for_provider(
|
||||
"codex",
|
||||
"openai:responses"
|
||||
));
|
||||
assert!(!force_upstream_streaming_for_provider(
|
||||
"codex",
|
||||
"openai:responses:compact"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_force_streaming_for_compact_or_other_provider_types() {
|
||||
assert!(!force_upstream_streaming_for_provider(
|
||||
"codex",
|
||||
"openai:responses:compact"
|
||||
));
|
||||
assert!(!force_upstream_streaming_for_provider(
|
||||
"openai",
|
||||
"openai:responses"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub use crate::formats::shared::standard_matrix::{
|
||||
build_standard_request_body_from_canonical,
|
||||
build_standard_request_body_from_canonical_with_model_directives,
|
||||
};
|
||||
326
crates/aether-ai-formats/src/formats/shared/response.rs
Normal file
326
crates/aether-ai-formats/src/formats/shared/response.rs
Normal file
@@ -0,0 +1,326 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::contracts::core_success_background_report_kind;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LocalSyncReportParts {
|
||||
pub trace_id: String,
|
||||
pub report_kind: String,
|
||||
pub report_context: Option<Value>,
|
||||
pub status_code: u16,
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub body_json: Option<Value>,
|
||||
pub client_body_json: Option<Value>,
|
||||
pub body_base64: Option<String>,
|
||||
}
|
||||
|
||||
pub fn build_generated_tool_call_id(index: usize) -> String {
|
||||
format!("call_auto_{index}")
|
||||
}
|
||||
|
||||
pub fn canonicalize_tool_arguments(value: Option<Value>) -> String {
|
||||
match value {
|
||||
Some(Value::String(text)) => text,
|
||||
Some(other) => serde_json::to_string(&other).unwrap_or_else(|_| "null".to_string()),
|
||||
None => "{}".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_empty_pages_from_tool_arguments(arguments: &str) -> String {
|
||||
let Ok(mut value) = serde_json::from_str::<Value>(arguments) else {
|
||||
return arguments.to_string();
|
||||
};
|
||||
let Some(object) = value.as_object_mut() else {
|
||||
return arguments.to_string();
|
||||
};
|
||||
if object.get("pages").and_then(Value::as_str) != Some("") {
|
||||
return arguments.to_string();
|
||||
}
|
||||
object.remove("pages");
|
||||
serde_json::to_string(&value).unwrap_or_else(|_| arguments.to_string())
|
||||
}
|
||||
|
||||
pub fn prepare_local_success_response_parts(
|
||||
headers: &BTreeMap<String, String>,
|
||||
body_json: &Value,
|
||||
) -> serde_json::Result<(Vec<u8>, BTreeMap<String, String>)> {
|
||||
prepare_local_success_response_parts_owned(headers.clone(), body_json)
|
||||
}
|
||||
|
||||
pub fn prepare_local_success_response_parts_owned(
|
||||
mut headers: BTreeMap<String, String>,
|
||||
body_json: &Value,
|
||||
) -> serde_json::Result<(Vec<u8>, BTreeMap<String, String>)> {
|
||||
headers.remove("content-encoding");
|
||||
headers.remove("content-length");
|
||||
headers.insert("content-type".to_string(), "application/json".to_string());
|
||||
let body_bytes = serde_json::to_vec(body_json)?;
|
||||
headers.insert("content-length".to_string(), body_bytes.len().to_string());
|
||||
Ok((body_bytes, headers))
|
||||
}
|
||||
|
||||
fn should_capture_client_sync_success_body(payload: &LocalSyncReportParts) -> bool {
|
||||
payload
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|context| context.get("upstream_is_stream"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn build_local_success_background_report(
|
||||
payload: &LocalSyncReportParts,
|
||||
body_json: Value,
|
||||
headers: BTreeMap<String, String>,
|
||||
) -> Option<LocalSyncReportParts> {
|
||||
let report_kind = core_success_background_report_kind(payload.report_kind.as_str())?;
|
||||
let upstream_is_stream = should_capture_client_sync_success_body(payload);
|
||||
let client_body_json = upstream_is_stream.then(|| body_json.clone());
|
||||
let provider_body_json = if upstream_is_stream {
|
||||
payload.body_json.clone()
|
||||
} else {
|
||||
Some(body_json)
|
||||
};
|
||||
let provider_body_base64 = if upstream_is_stream {
|
||||
payload.body_base64.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(LocalSyncReportParts {
|
||||
trace_id: payload.trace_id.clone(),
|
||||
report_kind: report_kind.to_string(),
|
||||
report_context: payload.report_context.clone(),
|
||||
status_code: payload.status_code,
|
||||
headers,
|
||||
body_json: provider_body_json,
|
||||
client_body_json,
|
||||
body_base64: provider_body_base64,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_local_success_conversion_background_report(
|
||||
payload: &LocalSyncReportParts,
|
||||
client_body_json: Value,
|
||||
provider_body_json: Value,
|
||||
) -> Option<LocalSyncReportParts> {
|
||||
let report_kind = core_success_background_report_kind(payload.report_kind.as_str())?;
|
||||
|
||||
Some(LocalSyncReportParts {
|
||||
trace_id: payload.trace_id.clone(),
|
||||
report_kind: report_kind.to_string(),
|
||||
report_context: payload.report_context.clone(),
|
||||
status_code: payload.status_code,
|
||||
headers: payload.headers.clone(),
|
||||
body_json: Some(provider_body_json),
|
||||
client_body_json: Some(client_body_json),
|
||||
body_base64: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use base64::Engine as _;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{
|
||||
build_generated_tool_call_id, build_local_success_background_report,
|
||||
build_local_success_conversion_background_report, canonicalize_tool_arguments,
|
||||
prepare_local_success_response_parts, prepare_local_success_response_parts_owned,
|
||||
remove_empty_pages_from_tool_arguments, LocalSyncReportParts,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn generated_tool_call_ids_are_stable() {
|
||||
assert_eq!(build_generated_tool_call_id(3), "call_auto_3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalizes_tool_arguments() {
|
||||
assert_eq!(
|
||||
canonicalize_tool_arguments(Some(serde_json::json!({"x": 1}))),
|
||||
"{\"x\":1}"
|
||||
);
|
||||
assert_eq!(canonicalize_tool_arguments(None), "{}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removes_empty_pages_from_tool_arguments() {
|
||||
assert_eq!(
|
||||
remove_empty_pages_from_tool_arguments(
|
||||
r#"{"file_path":"/tmp/a.txt","offset":1,"limit":20,"pages":""}"#
|
||||
),
|
||||
r#"{"file_path":"/tmp/a.txt","offset":1,"limit":20}"#
|
||||
);
|
||||
assert_eq!(
|
||||
remove_empty_pages_from_tool_arguments(r#"{"pages":"1-2"}"#),
|
||||
r#"{"pages":"1-2"}"#
|
||||
);
|
||||
assert_eq!(
|
||||
remove_empty_pages_from_tool_arguments(r#"{"pages":"#),
|
||||
r#"{"pages":"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_local_success_response_parts_normalizes_headers() {
|
||||
let headers = BTreeMap::from([
|
||||
("content-encoding".to_string(), "gzip".to_string()),
|
||||
("content-length".to_string(), "999".to_string()),
|
||||
("x-test".to_string(), "1".to_string()),
|
||||
]);
|
||||
let (body_bytes, normalized_headers) =
|
||||
prepare_local_success_response_parts(&headers, &serde_json::json!({"ok": true}))
|
||||
.expect("response parts should serialize");
|
||||
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&body_bytes).expect("json body"),
|
||||
serde_json::json!({"ok": true})
|
||||
);
|
||||
assert_eq!(
|
||||
normalized_headers.get("content-type").map(String::as_str),
|
||||
Some("application/json")
|
||||
);
|
||||
assert!(!normalized_headers.contains_key("content-encoding"));
|
||||
let expected_length = body_bytes.len().to_string();
|
||||
assert_eq!(
|
||||
normalized_headers.get("content-length").map(String::as_str),
|
||||
Some(expected_length.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
normalized_headers.get("x-test").map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_local_success_response_parts_owned_normalizes_headers() {
|
||||
let headers = BTreeMap::from([
|
||||
("content-encoding".to_string(), "gzip".to_string()),
|
||||
("content-length".to_string(), "999".to_string()),
|
||||
("x-test".to_string(), "1".to_string()),
|
||||
]);
|
||||
let (body_bytes, normalized_headers) =
|
||||
prepare_local_success_response_parts_owned(headers, &serde_json::json!({"ok": true}))
|
||||
.expect("response parts should serialize");
|
||||
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&body_bytes).expect("json body"),
|
||||
serde_json::json!({"ok": true})
|
||||
);
|
||||
assert_eq!(
|
||||
normalized_headers.get("content-type").map(String::as_str),
|
||||
Some("application/json")
|
||||
);
|
||||
assert!(!normalized_headers.contains_key("content-encoding"));
|
||||
let expected_length = body_bytes.len().to_string();
|
||||
assert_eq!(
|
||||
normalized_headers.get("content-length").map(String::as_str),
|
||||
Some(expected_length.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
normalized_headers.get("x-test").map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_local_success_background_report_maps_finalize_kind() {
|
||||
let payload = LocalSyncReportParts {
|
||||
trace_id: "trace-1".to_string(),
|
||||
report_kind: "openai_chat_sync_finalize".to_string(),
|
||||
report_context: Some(serde_json::json!({"request_id": "req-1"})),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::from([("x-test".to_string(), "1".to_string())]),
|
||||
body_json: None,
|
||||
client_body_json: None,
|
||||
body_base64: None,
|
||||
};
|
||||
|
||||
let report = build_local_success_background_report(
|
||||
&payload,
|
||||
serde_json::json!({"id": "resp-1"}),
|
||||
payload.headers.clone(),
|
||||
)
|
||||
.expect("success report should be built");
|
||||
|
||||
assert_eq!(report.report_kind, "openai_chat_sync_success");
|
||||
assert_eq!(report.body_json, Some(serde_json::json!({"id": "resp-1"})));
|
||||
assert_eq!(report.client_body_json, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_local_success_background_report_preserves_provider_stream_for_upstream_stream_sync() {
|
||||
let payload = LocalSyncReportParts {
|
||||
trace_id: "trace-1b".to_string(),
|
||||
report_kind: "openai_chat_sync_finalize".to_string(),
|
||||
report_context: Some(serde_json::json!({
|
||||
"request_id": "req-1b",
|
||||
"upstream_is_stream": true
|
||||
})),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::from([("content-type".to_string(), "text/event-stream".to_string())]),
|
||||
body_json: None,
|
||||
client_body_json: None,
|
||||
body_base64: Some(base64::engine::general_purpose::STANDARD.encode(
|
||||
concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp-1b\",\"object\":\"response\",\"status\":\"in_progress\",\"output\":[]}}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1b\",\"object\":\"response\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n",
|
||||
)
|
||||
)),
|
||||
};
|
||||
|
||||
let report = build_local_success_background_report(
|
||||
&payload,
|
||||
serde_json::json!({"id": "resp-1b"}),
|
||||
payload.headers.clone(),
|
||||
)
|
||||
.expect("success report should be built");
|
||||
|
||||
assert_eq!(report.body_json, None);
|
||||
assert_eq!(
|
||||
report.client_body_json,
|
||||
Some(serde_json::json!({"id": "resp-1b"}))
|
||||
);
|
||||
assert_eq!(report.body_base64, payload.body_base64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_local_success_conversion_background_report_maps_provider_body() {
|
||||
let payload = LocalSyncReportParts {
|
||||
trace_id: "trace-2".to_string(),
|
||||
report_kind: "openai_chat_sync_finalize".to_string(),
|
||||
report_context: Some(serde_json::json!({"request_id": "req-2"})),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
|
||||
body_json: None,
|
||||
client_body_json: None,
|
||||
body_base64: None,
|
||||
};
|
||||
|
||||
let report = build_local_success_conversion_background_report(
|
||||
&payload,
|
||||
serde_json::json!({"client": true}),
|
||||
serde_json::json!({"provider": true}),
|
||||
)
|
||||
.expect("conversion success report should be built");
|
||||
|
||||
assert_eq!(report.report_kind, "openai_chat_sync_success");
|
||||
assert_eq!(
|
||||
report.body_json,
|
||||
Some(serde_json::json!({"provider": true}))
|
||||
);
|
||||
assert_eq!(
|
||||
report.client_body_json,
|
||||
Some(serde_json::json!({"client": true}))
|
||||
);
|
||||
}
|
||||
}
|
||||
778
crates/aether-ai-formats/src/formats/shared/routing.rs
Normal file
778
crates/aether-ai-formats/src/formats/shared/routing.rs
Normal file
@@ -0,0 +1,778 @@
|
||||
use http::Method;
|
||||
|
||||
use crate::contracts::{
|
||||
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,
|
||||
GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND,
|
||||
GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
||||
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
|
||||
OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND,
|
||||
OPENAI_RERANK_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,
|
||||
};
|
||||
use crate::formats::openai::image::request::is_openai_image_stream_request;
|
||||
|
||||
pub fn resolve_execution_runtime_stream_plan_kind(
|
||||
route_class: Option<&str>,
|
||||
route_family: Option<&str>,
|
||||
route_kind: Option<&str>,
|
||||
request_auth_channel: Option<&str>,
|
||||
method: &Method,
|
||||
path: &str,
|
||||
) -> Option<&'static str> {
|
||||
if route_class != Some("ai_public") {
|
||||
return None;
|
||||
}
|
||||
|
||||
if route_family == Some("gemini")
|
||||
&& route_kind == Some("files")
|
||||
&& *method == Method::GET
|
||||
&& path.ends_with(":download")
|
||||
{
|
||||
return Some(GEMINI_FILES_DOWNLOAD_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("chat")
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/chat/completions"
|
||||
{
|
||||
return Some(OPENAI_CHAT_STREAM_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("claude")
|
||||
&& is_claude_messages_route_kind(route_kind)
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/messages"
|
||||
{
|
||||
return Some(resolve_claude_messages_plan_kind(
|
||||
request_auth_channel,
|
||||
CLAUDE_CHAT_STREAM_PLAN_KIND,
|
||||
CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||
));
|
||||
}
|
||||
|
||||
if route_family == Some("gemini")
|
||||
&& is_gemini_generate_content_route_kind(route_kind)
|
||||
&& *method == Method::POST
|
||||
&& path.ends_with(":streamGenerateContent")
|
||||
{
|
||||
return Some(resolve_gemini_generate_content_plan_kind(
|
||||
request_auth_channel,
|
||||
GEMINI_CHAT_STREAM_PLAN_KIND,
|
||||
GEMINI_CLI_STREAM_PLAN_KIND,
|
||||
));
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& is_openai_responses_route_kind(route_kind)
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/responses"
|
||||
{
|
||||
return Some(OPENAI_RESPONSES_STREAM_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& is_openai_responses_compact_route_kind(route_kind)
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/responses/compact"
|
||||
{
|
||||
return Some(OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("image")
|
||||
&& *method == Method::POST
|
||||
&& matches!(path, "/v1/images/generations" | "/v1/images/edits")
|
||||
{
|
||||
return Some(OPENAI_IMAGE_STREAM_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("video")
|
||||
&& *method == Method::GET
|
||||
&& path.ends_with("/content")
|
||||
{
|
||||
return Some(OPENAI_VIDEO_CONTENT_PLAN_KIND);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn resolve_execution_runtime_sync_plan_kind(
|
||||
route_class: Option<&str>,
|
||||
route_family: Option<&str>,
|
||||
route_kind: Option<&str>,
|
||||
request_auth_channel: Option<&str>,
|
||||
method: &Method,
|
||||
path: &str,
|
||||
) -> Option<&'static str> {
|
||||
if route_class != Some("ai_public") {
|
||||
return None;
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("video")
|
||||
&& *method == Method::POST
|
||||
&& path.starts_with("/v1/videos/")
|
||||
&& path.ends_with("/cancel")
|
||||
{
|
||||
return Some(OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("video")
|
||||
&& *method == Method::POST
|
||||
&& path.starts_with("/v1/videos/")
|
||||
&& path.ends_with("/remix")
|
||||
{
|
||||
return Some(OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("video")
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/videos"
|
||||
{
|
||||
return Some(OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("video")
|
||||
&& *method == Method::DELETE
|
||||
&& path.starts_with("/v1/videos/")
|
||||
{
|
||||
return Some(OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("gemini")
|
||||
&& route_kind == Some("video")
|
||||
&& *method == Method::POST
|
||||
&& path.ends_with(":cancel")
|
||||
{
|
||||
return Some(GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("gemini")
|
||||
&& route_kind == Some("video")
|
||||
&& *method == Method::POST
|
||||
&& path.ends_with(":predictLongRunning")
|
||||
{
|
||||
return Some(GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("chat")
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/chat/completions"
|
||||
{
|
||||
return Some(OPENAI_CHAT_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("embedding")
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/embeddings"
|
||||
{
|
||||
return Some(OPENAI_EMBEDDING_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("rerank")
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/rerank"
|
||||
{
|
||||
return Some(OPENAI_RERANK_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("image")
|
||||
&& *method == Method::POST
|
||||
&& matches!(
|
||||
path,
|
||||
"/v1/images/generations" | "/v1/images/edits" | "/v1/images/variations"
|
||||
)
|
||||
{
|
||||
return Some(OPENAI_IMAGE_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& is_openai_responses_route_kind(route_kind)
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/responses"
|
||||
{
|
||||
return Some(OPENAI_RESPONSES_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& is_openai_responses_compact_route_kind(route_kind)
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/responses/compact"
|
||||
{
|
||||
return Some(OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("claude")
|
||||
&& is_claude_messages_route_kind(route_kind)
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/messages"
|
||||
{
|
||||
return Some(resolve_claude_messages_plan_kind(
|
||||
request_auth_channel,
|
||||
CLAUDE_CHAT_SYNC_PLAN_KIND,
|
||||
CLAUDE_CLI_SYNC_PLAN_KIND,
|
||||
));
|
||||
}
|
||||
|
||||
if route_family == Some("gemini")
|
||||
&& is_gemini_generate_content_route_kind(route_kind)
|
||||
&& *method == Method::POST
|
||||
&& path.ends_with(":generateContent")
|
||||
{
|
||||
return Some(resolve_gemini_generate_content_plan_kind(
|
||||
request_auth_channel,
|
||||
GEMINI_CHAT_SYNC_PLAN_KIND,
|
||||
GEMINI_CLI_SYNC_PLAN_KIND,
|
||||
));
|
||||
}
|
||||
|
||||
if route_family == Some("gemini") && route_kind == Some("files") {
|
||||
if *method == Method::POST && path == "/upload/v1beta/files" {
|
||||
return Some(GEMINI_FILES_UPLOAD_PLAN_KIND);
|
||||
}
|
||||
if *method == Method::GET && path == "/v1beta/files" {
|
||||
return Some(GEMINI_FILES_LIST_PLAN_KIND);
|
||||
}
|
||||
if *method == Method::GET
|
||||
&& path.starts_with("/v1beta/files/")
|
||||
&& !path.ends_with(":download")
|
||||
{
|
||||
return Some(GEMINI_FILES_GET_PLAN_KIND);
|
||||
}
|
||||
if *method == Method::DELETE
|
||||
&& path.starts_with("/v1beta/files/")
|
||||
&& !path.ends_with(":download")
|
||||
{
|
||||
return Some(GEMINI_FILES_DELETE_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"))
|
||||
}
|
||||
|
||||
fn is_claude_messages_route_kind(route_kind: Option<&str>) -> bool {
|
||||
matches!(route_kind, Some("messages") | Some("chat"))
|
||||
}
|
||||
|
||||
fn is_gemini_generate_content_route_kind(route_kind: Option<&str>) -> bool {
|
||||
matches!(route_kind, Some("generate_content") | Some("chat"))
|
||||
}
|
||||
|
||||
fn resolve_claude_messages_plan_kind(
|
||||
request_auth_channel: Option<&str>,
|
||||
chat_plan_kind: &'static str,
|
||||
cli_plan_kind: &'static str,
|
||||
) -> &'static str {
|
||||
if request_auth_channel == Some("bearer_like") {
|
||||
cli_plan_kind
|
||||
} else {
|
||||
chat_plan_kind
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_gemini_generate_content_plan_kind(
|
||||
request_auth_channel: Option<&str>,
|
||||
chat_plan_kind: &'static str,
|
||||
cli_plan_kind: &'static str,
|
||||
) -> &'static str {
|
||||
if request_auth_channel == Some("bearer_like") {
|
||||
cli_plan_kind
|
||||
} else {
|
||||
chat_plan_kind
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_matching_stream_request(
|
||||
plan_kind: &str,
|
||||
path: &str,
|
||||
body_json: &serde_json::Value,
|
||||
) -> bool {
|
||||
match plan_kind {
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND
|
||||
| CLAUDE_CHAT_STREAM_PLAN_KIND
|
||||
| OPENAI_RESPONSES_STREAM_PLAN_KIND
|
||||
| OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND
|
||||
| CLAUDE_CLI_STREAM_PLAN_KIND
|
||||
| OPENAI_IMAGE_STREAM_PLAN_KIND => body_json
|
||||
.get("stream")
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false),
|
||||
GEMINI_CHAT_STREAM_PLAN_KIND | GEMINI_CLI_STREAM_PLAN_KIND => {
|
||||
path.ends_with(":streamGenerateContent")
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_matching_stream_http_request(
|
||||
plan_kind: &str,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
) -> bool {
|
||||
if plan_kind == OPENAI_IMAGE_STREAM_PLAN_KIND {
|
||||
return is_openai_image_stream_request(parts, body_json, body_base64);
|
||||
}
|
||||
|
||||
is_matching_stream_request(plan_kind, parts.uri.path(), body_json)
|
||||
}
|
||||
|
||||
pub fn supports_sync_execution_decision_kind(plan_kind: &str) -> bool {
|
||||
matches!(
|
||||
plan_kind,
|
||||
OPENAI_CHAT_SYNC_PLAN_KIND
|
||||
| OPENAI_EMBEDDING_SYNC_PLAN_KIND
|
||||
| OPENAI_RERANK_SYNC_PLAN_KIND
|
||||
| OPENAI_IMAGE_SYNC_PLAN_KIND
|
||||
| OPENAI_RESPONSES_SYNC_PLAN_KIND
|
||||
| OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND
|
||||
| CLAUDE_CHAT_SYNC_PLAN_KIND
|
||||
| CLAUDE_CLI_SYNC_PLAN_KIND
|
||||
| GEMINI_CHAT_SYNC_PLAN_KIND
|
||||
| GEMINI_CLI_SYNC_PLAN_KIND
|
||||
| GEMINI_FILES_UPLOAD_PLAN_KIND
|
||||
| OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND
|
||||
| OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND
|
||||
| OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND
|
||||
| OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND
|
||||
| GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND
|
||||
| GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND
|
||||
| GEMINI_FILES_GET_PLAN_KIND
|
||||
| GEMINI_FILES_LIST_PLAN_KIND
|
||||
| GEMINI_FILES_DELETE_PLAN_KIND
|
||||
)
|
||||
}
|
||||
|
||||
pub fn supports_stream_execution_decision_kind(plan_kind: &str) -> bool {
|
||||
matches!(
|
||||
plan_kind,
|
||||
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_IMAGE_STREAM_PLAN_KIND
|
||||
| CLAUDE_CLI_STREAM_PLAN_KIND
|
||||
| GEMINI_CLI_STREAM_PLAN_KIND
|
||||
| GEMINI_FILES_DOWNLOAD_PLAN_KIND
|
||||
| OPENAI_VIDEO_CONTENT_PLAN_KIND
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use base64::Engine as _;
|
||||
use http::Method;
|
||||
|
||||
use super::{
|
||||
is_matching_stream_http_request, is_matching_stream_request,
|
||||
resolve_execution_runtime_stream_plan_kind, resolve_execution_runtime_sync_plan_kind,
|
||||
supports_stream_execution_decision_kind, supports_sync_execution_decision_kind,
|
||||
};
|
||||
use crate::contracts::{
|
||||
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, OPENAI_CHAT_STREAM_PLAN_KIND,
|
||||
OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND,
|
||||
OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_RERANK_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]
|
||||
fn resolves_openai_chat_plan_kinds() {
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("openai"),
|
||||
Some("chat"),
|
||||
None,
|
||||
&Method::POST,
|
||||
"/v1/chat/completions",
|
||||
),
|
||||
Some(OPENAI_CHAT_SYNC_PLAN_KIND)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_stream_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("openai"),
|
||||
Some("chat"),
|
||||
None,
|
||||
&Method::POST,
|
||||
"/v1/chat/completions",
|
||||
),
|
||||
Some(OPENAI_CHAT_STREAM_PLAN_KIND)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_responses_plan_kinds() {
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("openai"),
|
||||
Some("responses"),
|
||||
None,
|
||||
&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"),
|
||||
None,
|
||||
&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"),
|
||||
None,
|
||||
&Method::POST,
|
||||
"/v1/responses",
|
||||
),
|
||||
Some(OPENAI_RESPONSES_SYNC_PLAN_KIND)
|
||||
);
|
||||
assert!(supports_sync_execution_decision_kind(
|
||||
OPENAI_RESPONSES_SYNC_PLAN_KIND
|
||||
));
|
||||
assert!(supports_stream_execution_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"),
|
||||
None,
|
||||
&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"),
|
||||
None,
|
||||
&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"),
|
||||
None,
|
||||
&Method::POST,
|
||||
"/v1/responses/compact",
|
||||
),
|
||||
Some(OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND)
|
||||
);
|
||||
assert!(supports_sync_execution_decision_kind(
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND
|
||||
));
|
||||
assert!(supports_stream_execution_decision_kind(
|
||||
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_claude_messages_plan_kinds_by_request_auth_channel() {
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("claude"),
|
||||
Some("messages"),
|
||||
Some("api_key"),
|
||||
&Method::POST,
|
||||
"/v1/messages",
|
||||
),
|
||||
Some(CLAUDE_CHAT_SYNC_PLAN_KIND)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_stream_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("claude"),
|
||||
Some("messages"),
|
||||
Some("api_key"),
|
||||
&Method::POST,
|
||||
"/v1/messages",
|
||||
),
|
||||
Some(CLAUDE_CHAT_STREAM_PLAN_KIND)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("claude"),
|
||||
Some("messages"),
|
||||
Some("bearer_like"),
|
||||
&Method::POST,
|
||||
"/v1/messages",
|
||||
),
|
||||
Some(CLAUDE_CLI_SYNC_PLAN_KIND)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_stream_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("claude"),
|
||||
Some("messages"),
|
||||
Some("bearer_like"),
|
||||
&Method::POST,
|
||||
"/v1/messages",
|
||||
),
|
||||
Some(CLAUDE_CLI_STREAM_PLAN_KIND)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_gemini_generate_content_plan_kinds_by_request_auth_channel() {
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("gemini"),
|
||||
Some("generate_content"),
|
||||
Some("api_key"),
|
||||
&Method::POST,
|
||||
"/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
),
|
||||
Some(GEMINI_CHAT_SYNC_PLAN_KIND)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_stream_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("gemini"),
|
||||
Some("generate_content"),
|
||||
Some("api_key"),
|
||||
&Method::POST,
|
||||
"/v1beta/models/gemini-2.5-pro:streamGenerateContent",
|
||||
),
|
||||
Some(GEMINI_CHAT_STREAM_PLAN_KIND)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("gemini"),
|
||||
Some("generate_content"),
|
||||
Some("bearer_like"),
|
||||
&Method::POST,
|
||||
"/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
),
|
||||
Some(GEMINI_CLI_SYNC_PLAN_KIND)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_stream_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("gemini"),
|
||||
Some("generate_content"),
|
||||
Some("bearer_like"),
|
||||
&Method::POST,
|
||||
"/v1beta/models/gemini-2.5-pro:streamGenerateContent",
|
||||
),
|
||||
Some(GEMINI_CLI_STREAM_PLAN_KIND)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_matching_requires_openai_stream_flag() {
|
||||
assert!(!is_matching_stream_request(
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND,
|
||||
"/v1/chat/completions",
|
||||
&serde_json::json!({"stream": false}),
|
||||
));
|
||||
assert!(is_matching_stream_request(
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND,
|
||||
"/v1/chat/completions",
|
||||
&serde_json::json!({"stream": true}),
|
||||
));
|
||||
assert!(supports_sync_execution_decision_kind(
|
||||
OPENAI_CHAT_SYNC_PLAN_KIND
|
||||
));
|
||||
assert!(supports_stream_execution_decision_kind(
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_image_sync_plan_kind() {
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("openai"),
|
||||
Some("image"),
|
||||
None,
|
||||
&Method::POST,
|
||||
"/v1/images/generations",
|
||||
),
|
||||
Some(OPENAI_IMAGE_SYNC_PLAN_KIND)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("openai"),
|
||||
Some("image"),
|
||||
None,
|
||||
&Method::POST,
|
||||
"/v1/images/edits",
|
||||
),
|
||||
Some(OPENAI_IMAGE_SYNC_PLAN_KIND)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("openai"),
|
||||
Some("image"),
|
||||
None,
|
||||
&Method::POST,
|
||||
"/v1/images/variations",
|
||||
),
|
||||
Some(OPENAI_IMAGE_SYNC_PLAN_KIND)
|
||||
);
|
||||
assert!(supports_sync_execution_decision_kind(
|
||||
OPENAI_IMAGE_SYNC_PLAN_KIND
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_embedding_sync_plan_kind() {
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("openai"),
|
||||
Some("embedding"),
|
||||
None,
|
||||
&Method::POST,
|
||||
"/v1/embeddings",
|
||||
),
|
||||
Some(OPENAI_EMBEDDING_SYNC_PLAN_KIND)
|
||||
);
|
||||
assert!(supports_sync_execution_decision_kind(
|
||||
OPENAI_EMBEDDING_SYNC_PLAN_KIND
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_rerank_sync_plan_kind() {
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("openai"),
|
||||
Some("rerank"),
|
||||
None,
|
||||
&Method::POST,
|
||||
"/v1/rerank",
|
||||
),
|
||||
Some(OPENAI_RERANK_SYNC_PLAN_KIND)
|
||||
);
|
||||
assert!(supports_sync_execution_decision_kind(
|
||||
OPENAI_RERANK_SYNC_PLAN_KIND
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_image_stream_plan_kind() {
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_stream_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("openai"),
|
||||
Some("image"),
|
||||
None,
|
||||
&Method::POST,
|
||||
"/v1/images/generations",
|
||||
),
|
||||
Some(OPENAI_IMAGE_STREAM_PLAN_KIND)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_stream_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("openai"),
|
||||
Some("image"),
|
||||
None,
|
||||
&Method::POST,
|
||||
"/v1/images/edits",
|
||||
),
|
||||
Some(OPENAI_IMAGE_STREAM_PLAN_KIND)
|
||||
);
|
||||
assert!(supports_stream_execution_decision_kind(
|
||||
OPENAI_IMAGE_STREAM_PLAN_KIND
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_matching_requires_openai_image_stream_flag() {
|
||||
assert!(!is_matching_stream_request(
|
||||
OPENAI_IMAGE_STREAM_PLAN_KIND,
|
||||
"/v1/images/generations",
|
||||
&serde_json::json!({"stream": false}),
|
||||
));
|
||||
assert!(is_matching_stream_request(
|
||||
OPENAI_IMAGE_STREAM_PLAN_KIND,
|
||||
"/v1/images/generations",
|
||||
&serde_json::json!({"stream": true}),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_stream_matching_detects_openai_image_multipart_stream_flag() {
|
||||
let request = http::Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/v1/images/edits")
|
||||
.header(
|
||||
http::header::CONTENT_TYPE,
|
||||
"multipart/form-data; boundary=image-stream-boundary",
|
||||
)
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (parts, _) = request.into_parts();
|
||||
let body = concat!(
|
||||
"--image-stream-boundary\r\n",
|
||||
"Content-Disposition: form-data; name=\"stream\"\r\n\r\n",
|
||||
"true\r\n",
|
||||
"--image-stream-boundary--\r\n"
|
||||
);
|
||||
let body_base64 = base64::engine::general_purpose::STANDARD.encode(body.as_bytes());
|
||||
|
||||
assert!(is_matching_stream_http_request(
|
||||
OPENAI_IMAGE_STREAM_PLAN_KIND,
|
||||
&parts,
|
||||
&serde_json::json!({}),
|
||||
Some(body_base64.as_str()),
|
||||
));
|
||||
}
|
||||
}
|
||||
41
crates/aether-ai-formats/src/formats/shared/sse.rs
Normal file
41
crates/aether-ai-formats/src/formats/shared/sse.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::formats::shared::AiSurfaceFinalizeError;
|
||||
|
||||
pub fn map_claude_stop_reason(
|
||||
stop_reason: Option<&str>,
|
||||
has_tool_calls: bool,
|
||||
) -> Option<&'static str> {
|
||||
let mapped = match stop_reason {
|
||||
Some("end_turn") | Some("stop_sequence") => Some("stop"),
|
||||
Some("max_tokens") => Some("length"),
|
||||
Some("tool_use") => Some("tool_calls"),
|
||||
Some("pause_turn") => Some("stop"),
|
||||
_ => None,
|
||||
};
|
||||
if has_tool_calls && mapped.is_none_or(|value| value == "stop") {
|
||||
Some("tool_calls")
|
||||
} else {
|
||||
mapped
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_done_sse() -> Vec<u8> {
|
||||
b"data: [DONE]\n\n".to_vec()
|
||||
}
|
||||
|
||||
pub fn encode_json_sse(
|
||||
event: Option<&str>,
|
||||
value: &Value,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(event) = event.filter(|value| !value.trim().is_empty()) {
|
||||
out.extend_from_slice(b"event: ");
|
||||
out.extend_from_slice(event.as_bytes());
|
||||
out.push(b'\n');
|
||||
}
|
||||
out.extend_from_slice(b"data: ");
|
||||
out.extend(serde_json::to_vec(value).map_err(AiSurfaceFinalizeError::from)?);
|
||||
out.extend_from_slice(b"\n\n");
|
||||
Ok(out)
|
||||
}
|
||||
1251
crates/aether-ai-formats/src/formats/shared/standard_matrix.rs
Normal file
1251
crates/aether-ai-formats/src/formats/shared/standard_matrix.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,433 @@
|
||||
use aether_ai_formats::formats::conversion::request::{
|
||||
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
|
||||
convert_openai_chat_request_to_openai_responses_request,
|
||||
normalize_openai_responses_request_to_openai_chat_request,
|
||||
};
|
||||
use aether_ai_formats::{request_conversion_kind, RequestConversionKind};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::formats::shared::model_directives::apply_model_directive_overrides_from_request;
|
||||
|
||||
pub fn build_local_openai_chat_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
build_local_openai_chat_request_body_with_model_directives(
|
||||
body_json,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_local_openai_chat_request_body_with_model_directives(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
let request_body_object = body_json.as_object()?;
|
||||
let mut provider_request_body = serde_json::Map::from_iter(
|
||||
request_body_object
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone())),
|
||||
);
|
||||
provider_request_body.insert("model".to_string(), Value::String(mapped_model.to_string()));
|
||||
if upstream_is_stream {
|
||||
provider_request_body.insert("stream".to_string(), Value::Bool(true));
|
||||
match provider_request_body.get_mut("stream_options") {
|
||||
Some(Value::Object(stream_options)) => {
|
||||
stream_options.insert("include_usage".to_string(), Value::Bool(true));
|
||||
}
|
||||
_ => {
|
||||
provider_request_body.insert(
|
||||
"stream_options".to_string(),
|
||||
json!({
|
||||
"include_usage": true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(with_model_directive_overrides(
|
||||
Value::Object(provider_request_body),
|
||||
"openai:chat",
|
||||
mapped_model,
|
||||
body_json,
|
||||
None,
|
||||
enable_model_directives,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_cross_format_openai_chat_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
body_json,
|
||||
mapped_model,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
let conversion_kind = request_conversion_kind("openai:chat", provider_api_format)?;
|
||||
let provider_request_body = match conversion_kind {
|
||||
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
|
||||
body_json,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
)?,
|
||||
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
|
||||
body_json,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
)?,
|
||||
RequestConversionKind::ToOpenAiResponses => {
|
||||
convert_openai_chat_request_to_openai_responses_request(
|
||||
body_json,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(with_model_directive_overrides(
|
||||
provider_request_body,
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
body_json,
|
||||
None,
|
||||
enable_model_directives,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_local_openai_responses_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
require_streaming: bool,
|
||||
) -> Option<Value> {
|
||||
build_local_openai_responses_request_body_with_model_directives(
|
||||
body_json,
|
||||
mapped_model,
|
||||
require_streaming,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_local_openai_responses_request_body_with_model_directives(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
require_streaming: bool,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
let request_body_object = body_json.as_object()?;
|
||||
let mut provider_request_body = serde_json::Map::from_iter(
|
||||
request_body_object
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone())),
|
||||
);
|
||||
provider_request_body.insert("model".to_string(), Value::String(mapped_model.to_string()));
|
||||
if require_streaming {
|
||||
provider_request_body.insert("stream".to_string(), Value::Bool(true));
|
||||
}
|
||||
Some(with_model_directive_overrides(
|
||||
Value::Object(provider_request_body),
|
||||
"openai:responses",
|
||||
mapped_model,
|
||||
body_json,
|
||||
None,
|
||||
enable_model_directives,
|
||||
))
|
||||
}
|
||||
|
||||
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> {
|
||||
build_cross_format_openai_responses_request_body_with_model_directives(
|
||||
body_json,
|
||||
mapped_model,
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_cross_format_openai_responses_request_body_with_model_directives(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
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)?;
|
||||
let provider_request_body = match conversion_kind {
|
||||
RequestConversionKind::ToOpenAIChat => {
|
||||
build_local_openai_chat_request_body_with_model_directives(
|
||||
&chat_like_request,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
enable_model_directives,
|
||||
)?
|
||||
}
|
||||
RequestConversionKind::ToOpenAiResponses => {
|
||||
convert_openai_chat_request_to_openai_responses_request(
|
||||
&chat_like_request,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)?
|
||||
}
|
||||
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
|
||||
&chat_like_request,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
)?,
|
||||
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
|
||||
&chat_like_request,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
)?,
|
||||
};
|
||||
Some(with_model_directive_overrides(
|
||||
provider_request_body,
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
body_json,
|
||||
None,
|
||||
enable_model_directives,
|
||||
))
|
||||
}
|
||||
|
||||
fn with_model_directive_overrides(
|
||||
mut provider_request_body: Value,
|
||||
provider_api_format: &str,
|
||||
provider_model: &str,
|
||||
request_body: &Value,
|
||||
request_path: Option<&str>,
|
||||
enable_model_directives: bool,
|
||||
) -> Value {
|
||||
if enable_model_directives {
|
||||
apply_model_directive_overrides_from_request(
|
||||
&mut provider_request_body,
|
||||
provider_api_format,
|
||||
provider_model,
|
||||
request_body,
|
||||
request_path,
|
||||
);
|
||||
}
|
||||
provider_request_body
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_local_openai_responses_request_body;
|
||||
use super::{
|
||||
build_cross_format_openai_chat_request_body_with_model_directives,
|
||||
build_cross_format_openai_responses_request_body, build_local_openai_chat_request_body,
|
||||
build_local_openai_chat_request_body_with_model_directives,
|
||||
build_local_openai_responses_request_body_with_model_directives,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn object_keys(value: &Value) -> Vec<&str> {
|
||||
value
|
||||
.as_object()
|
||||
.expect("json object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
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_responses_request_body(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
"openai:responses",
|
||||
"openai:chat",
|
||||
false,
|
||||
)
|
||||
.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");
|
||||
assert_eq!(provider_request_body["messages"][0]["content"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_responses_request_body_preserves_original_field_order() {
|
||||
let body_json: Value = serde_json::from_str(
|
||||
r#"{
|
||||
"model": "gpt-5",
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"input": [],
|
||||
"instructions": "Keep order"
|
||||
}"#,
|
||||
)
|
||||
.expect("request json should parse");
|
||||
|
||||
let provider_request_body =
|
||||
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),
|
||||
vec!["model", "include", "input", "instructions"]
|
||||
);
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_streaming_local_openai_chat_request_body_with_include_usage() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": "hello"
|
||||
}]
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_local_openai_chat_request_body(&body_json, "gpt-5-upstream", true)
|
||||
.expect("openai chat body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["stream"], true);
|
||||
assert_eq!(
|
||||
provider_request_body["stream_options"]["include_usage"],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_chat_request_body_applies_reasoning_effort_suffix() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5.4-xhigh",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"reasoning_effort": "low"
|
||||
});
|
||||
|
||||
let provider_request_body = build_local_openai_chat_request_body_with_model_directives(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.expect("openai chat body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["reasoning_effort"], "xhigh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_chat_request_body_leaves_model_directive_disabled_by_default() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5.4-xhigh",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"reasoning_effort": "low"
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_local_openai_chat_request_body(&body_json, "gpt-5-upstream", false)
|
||||
.expect("openai chat body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["reasoning_effort"], "low");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_responses_request_body_applies_reasoning_effort_suffix() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5.4-max",
|
||||
"input": "hello",
|
||||
"reasoning": {"effort": "low", "summary": "auto"}
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_local_openai_responses_request_body_with_model_directives(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.expect("openai responses body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["reasoning"]["summary"], "auto");
|
||||
assert_eq!(provider_request_body["reasoning"]["effort"], "xhigh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_format_request_body_applies_reasoning_effort_suffix() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5.4-high",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"reasoning_effort": "low"
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
&body_json,
|
||||
"claude-sonnet-4-5",
|
||||
"claude:messages",
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.expect("claude body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "claude-sonnet-4-5");
|
||||
assert_eq!(provider_request_body["output_config"]["effort"], "high");
|
||||
assert_eq!(provider_request_body["thinking"]["budget_tokens"], 4096);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_local_openai_chat_request_body_preserves_stream_options_while_forcing_include_usage(
|
||||
) {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": "hello"
|
||||
}],
|
||||
"stream_options": {
|
||||
"include_usage": false,
|
||||
"extra": "keep-me"
|
||||
}
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_local_openai_chat_request_body(&body_json, "gpt-5-upstream", true)
|
||||
.expect("openai chat body should build");
|
||||
|
||||
assert_eq!(
|
||||
provider_request_body["stream_options"]["include_usage"],
|
||||
true
|
||||
);
|
||||
assert_eq!(provider_request_body["stream_options"]["extra"], "keep-me");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
pub use aether_ai_formats::protocol::stream::{
|
||||
CanonicalContentPart, CanonicalStreamEvent, CanonicalStreamFrame, CanonicalUsage,
|
||||
};
|
||||
|
||||
pub fn decode_json_data_line(line: &[u8]) -> Option<Value> {
|
||||
let text = std::str::from_utf8(line).ok()?;
|
||||
let trimmed = text.trim_matches('\r').trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with(':') || trimmed.starts_with("event:") {
|
||||
return None;
|
||||
}
|
||||
let data_line = trimmed.strip_prefix("data:")?.trim();
|
||||
if data_line.is_empty() || data_line == "[DONE]" {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str(data_line).ok()
|
||||
}
|
||||
|
||||
pub fn resolve_identity(
|
||||
response_id: Option<&str>,
|
||||
model: Option<&str>,
|
||||
report_context: &Value,
|
||||
default_id: &str,
|
||||
) -> (String, String) {
|
||||
let id = response_id
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(default_id)
|
||||
.to_string();
|
||||
let model = model
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
(id, model)
|
||||
}
|
||||
|
||||
pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
|
||||
let usage = value?.as_object()?;
|
||||
let mut input_tokens = usage
|
||||
.get("input_tokens")
|
||||
.or_else(|| usage.get("prompt_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let output_tokens = usage
|
||||
.get("output_tokens")
|
||||
.or_else(|| usage.get("completion_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let cache_creation_tokens = usage
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.or_else(|| {
|
||||
usage
|
||||
.get("input_tokens_details")
|
||||
.or_else(|| usage.get("prompt_tokens_details"))
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|details| details.get("cached_creation_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let cache_read_tokens = usage
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.or_else(|| {
|
||||
usage
|
||||
.get("input_tokens_details")
|
||||
.or_else(|| usage.get("prompt_tokens_details"))
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|details| details.get("cached_tokens"))
|
||||
.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)
|
||||
.saturating_add(cache_creation_tokens)
|
||||
.saturating_add(cache_read_tokens),
|
||||
);
|
||||
if input_tokens == 0 && total_tokens > output_tokens {
|
||||
input_tokens = total_tokens.saturating_sub(output_tokens);
|
||||
}
|
||||
Some(CanonicalUsage {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
cache_creation_tokens,
|
||||
cache_read_tokens,
|
||||
reasoning_tokens,
|
||||
..CanonicalUsage::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn canonical_usage_from_claude_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
|
||||
let usage = value?.as_object()?;
|
||||
let input_tokens = usage
|
||||
.get("input_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let output_tokens = usage
|
||||
.get("output_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let cache_creation_ephemeral_5m_tokens = usage
|
||||
.get("cache_creation")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|value| value.get("ephemeral_5m_input_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let cache_creation_ephemeral_1h_tokens = usage
|
||||
.get("cache_creation")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|value| value.get("ephemeral_1h_input_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let cache_creation_tokens = usage
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(
|
||||
cache_creation_ephemeral_5m_tokens.saturating_add(cache_creation_ephemeral_1h_tokens),
|
||||
);
|
||||
let cache_read_tokens = usage
|
||||
.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,
|
||||
total_tokens: input_tokens
|
||||
.saturating_add(output_tokens)
|
||||
.saturating_add(cache_creation_tokens)
|
||||
.saturating_add(cache_read_tokens),
|
||||
cache_creation_tokens,
|
||||
cache_creation_ephemeral_5m_tokens,
|
||||
cache_creation_ephemeral_1h_tokens,
|
||||
cache_read_tokens,
|
||||
reasoning_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn canonical_usage_from_gemini_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
|
||||
let usage = value?.as_object()?;
|
||||
let input_tokens = usage
|
||||
.get("promptTokenCount")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let output_tokens = usage
|
||||
.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)
|
||||
.unwrap_or(0);
|
||||
let total_tokens = usage
|
||||
.get("totalTokenCount")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(
|
||||
input_tokens
|
||||
.saturating_add(output_tokens)
|
||||
.saturating_add(cache_read_tokens),
|
||||
);
|
||||
Some(CanonicalUsage {
|
||||
input_tokens,
|
||||
output_tokens: output_tokens.saturating_add(reasoning_tokens),
|
||||
total_tokens,
|
||||
cache_read_tokens,
|
||||
reasoning_tokens,
|
||||
..CanonicalUsage::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn normalize_openai_finish_reason(value: Option<&str>) -> Option<String> {
|
||||
match value {
|
||||
Some("function_call") => Some("tool_calls".to_string()),
|
||||
Some(other) if !other.trim().is_empty() => Some(other.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_openai_finish_reason_to_claude(value: Option<&str>) -> &'static str {
|
||||
match value {
|
||||
Some("length") => "max_tokens",
|
||||
Some("tool_calls") | Some("function_call") => "tool_use",
|
||||
Some("content_filter") => "content_filtered",
|
||||
_ => "end_turn",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_openai_finish_reason_to_gemini(value: Option<&str>) -> &'static str {
|
||||
match value {
|
||||
Some("length") => "MAX_TOKENS",
|
||||
Some("content_filter") => "SAFETY",
|
||||
_ => "STOP",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_json_arguments_value(arguments: &str) -> Option<Value> {
|
||||
let trimmed = arguments.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Some(Value::Object(Map::new()));
|
||||
}
|
||||
serde_json::from_str(trimmed).ok()
|
||||
}
|
||||
|
||||
pub fn build_openai_chat_chunk(
|
||||
id: &str,
|
||||
model: &str,
|
||||
text: String,
|
||||
tool_calls: Option<Vec<Value>>,
|
||||
finish_reason: Option<&str>,
|
||||
) -> Value {
|
||||
let mut delta = Map::new();
|
||||
delta.insert("role".to_string(), Value::String("assistant".to_string()));
|
||||
if !text.is_empty() {
|
||||
delta.insert("content".to_string(), Value::String(text));
|
||||
} else if tool_calls.is_none() {
|
||||
delta.insert("content".to_string(), Value::String(String::new()));
|
||||
}
|
||||
if let Some(tool_calls) = tool_calls {
|
||||
delta.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
||||
}
|
||||
|
||||
json!({
|
||||
"id": id,
|
||||
"object": "chat.completion.chunk",
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": Value::Object(delta),
|
||||
"finish_reason": finish_reason,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_openai_chat_role_chunk(id: &str, model: &str) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"object": "chat.completion.chunk",
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"role": "assistant"
|
||||
},
|
||||
"finish_reason": Value::Null
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_openai_chat_finish_chunk(id: &str, model: &str, finish_reason: Option<&str>) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"object": "chat.completion.chunk",
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {},
|
||||
"finish_reason": finish_reason,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_openai_chat_usage_chunk(
|
||||
id: &str,
|
||||
model: &str,
|
||||
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": usage,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,939 @@
|
||||
use aether_ai_formats::FormatId;
|
||||
use aether_contracts::{ExecutionStreamTerminalSummary, StandardizedUsage};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::formats::claude::messages::stream::{ClaudeClientEmitter, ClaudeProviderState};
|
||||
use crate::formats::gemini::generate_content::stream::{GeminiClientEmitter, GeminiProviderState};
|
||||
use crate::formats::openai::chat::stream::{
|
||||
OpenAIChatClientEmitter, OpenAIChatProviderState, OpenAIResponsesClientEmitter,
|
||||
OpenAIResponsesProviderState,
|
||||
};
|
||||
use crate::formats::shared::error_body::{
|
||||
build_core_error_body_for_client_format, LocalCoreSyncErrorKind,
|
||||
};
|
||||
use crate::formats::shared::sse::encode_json_sse;
|
||||
use crate::formats::shared::stream_core::common::{
|
||||
decode_json_data_line, CanonicalStreamEvent, CanonicalStreamFrame, CanonicalUsage,
|
||||
};
|
||||
use crate::formats::shared::AiSurfaceFinalizeError;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct StreamingStandardFormatMatrix {
|
||||
provider: Option<ProviderStreamParser>,
|
||||
client: Option<ClientStreamEmitter>,
|
||||
terminated: bool,
|
||||
}
|
||||
|
||||
impl StreamingStandardFormatMatrix {
|
||||
pub fn transform_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
if self.terminated {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.ensure_initialized(report_context);
|
||||
if let Some(error_body) = build_client_error_body_for_line(report_context, &line) {
|
||||
self.terminated = true;
|
||||
return self.emit_error(error_body);
|
||||
}
|
||||
let Some(provider) = self.provider.as_mut() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let frames = provider.push_line(report_context, line)?;
|
||||
self.emit_frames(frames)
|
||||
}
|
||||
|
||||
pub fn finish(&mut self, report_context: &Value) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
if self.terminated {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.ensure_initialized(report_context);
|
||||
let Some(provider) = self.provider.as_mut() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let frames = provider.finish(report_context)?;
|
||||
let mut out = self.emit_frames(frames)?;
|
||||
if let Some(client) = self.client.as_mut() {
|
||||
out.extend(client.finish()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn ensure_initialized(&mut self, report_context: &Value) {
|
||||
if self.provider.is_some() && self.client.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let provider_api_format = provider_api_format_for_context(report_context);
|
||||
let client_api_format = client_api_format_for_context(report_context);
|
||||
|
||||
self.provider = ProviderStreamParser::for_api_format(provider_api_format.as_str());
|
||||
self.client = ClientStreamEmitter::for_api_format(client_api_format.as_str());
|
||||
}
|
||||
|
||||
fn emit_frames(
|
||||
&mut self,
|
||||
frames: Vec<CanonicalStreamFrame>,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
let Some(client) = self.client.as_mut() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for frame in frames {
|
||||
out.extend(client.emit(frame)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn emit_error(&mut self, error_body: Value) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
let Some(client) = self.client.as_mut() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
client.emit_error(error_body)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct StreamingStandardTerminalObserver {
|
||||
provider: Option<ProviderStreamParser>,
|
||||
latest_summary: Option<ExecutionStreamTerminalSummary>,
|
||||
}
|
||||
|
||||
impl StreamingStandardTerminalObserver {
|
||||
pub fn push_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<(), AiSurfaceFinalizeError> {
|
||||
self.ensure_initialized(report_context);
|
||||
let Some(provider) = self.provider.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let frames = provider.push_line(report_context, line)?;
|
||||
self.observe_frames(frames);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn finish(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
) -> Result<Option<ExecutionStreamTerminalSummary>, AiSurfaceFinalizeError> {
|
||||
self.ensure_initialized(report_context);
|
||||
let Some(provider) = self.provider.as_mut() else {
|
||||
return Ok(self.latest_summary.clone());
|
||||
};
|
||||
let frames = provider.finish(report_context)?;
|
||||
self.observe_frames(frames);
|
||||
Ok(self.latest_summary.clone())
|
||||
}
|
||||
|
||||
pub fn disable_with_error(&mut self, parser_error: impl Into<String>) {
|
||||
let parser_error = parser_error.into();
|
||||
if let Some(summary) = self.latest_summary.as_mut() {
|
||||
if summary.parser_error.is_none() {
|
||||
summary.parser_error = Some(parser_error);
|
||||
}
|
||||
} else {
|
||||
self.latest_summary = Some(ExecutionStreamTerminalSummary {
|
||||
parser_error: Some(parser_error),
|
||||
..ExecutionStreamTerminalSummary::default()
|
||||
});
|
||||
}
|
||||
self.provider = None;
|
||||
}
|
||||
|
||||
pub fn latest_summary(&self) -> Option<&ExecutionStreamTerminalSummary> {
|
||||
self.latest_summary.as_ref()
|
||||
}
|
||||
|
||||
fn ensure_initialized(&mut self, report_context: &Value) {
|
||||
if self.provider.is_some() || self.latest_summary.is_some() {
|
||||
return;
|
||||
}
|
||||
let provider_api_format = provider_api_format_for_context(report_context);
|
||||
self.provider = ProviderStreamParser::for_api_format(provider_api_format.as_str());
|
||||
}
|
||||
|
||||
fn observe_frames(&mut self, frames: Vec<CanonicalStreamFrame>) {
|
||||
for frame in frames {
|
||||
self.observe_frame(frame);
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_frame(&mut self, frame: CanonicalStreamFrame) {
|
||||
let CanonicalStreamFrame { id, model, event } = frame;
|
||||
let summary = self
|
||||
.latest_summary
|
||||
.get_or_insert_with(|| ExecutionStreamTerminalSummary {
|
||||
response_id: Some(id.clone()),
|
||||
model: Some(model.clone()),
|
||||
..ExecutionStreamTerminalSummary::default()
|
||||
});
|
||||
if summary.response_id.is_none() {
|
||||
summary.response_id = Some(id);
|
||||
}
|
||||
if summary.model.is_none() {
|
||||
summary.model = Some(model);
|
||||
}
|
||||
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),
|
||||
OpenAIResponses(OpenAIResponsesProviderState),
|
||||
Claude(ClaudeProviderState),
|
||||
Gemini(GeminiProviderState),
|
||||
}
|
||||
|
||||
impl ProviderStreamParser {
|
||||
fn for_api_format(provider_api_format: &str) -> Option<Self> {
|
||||
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()),
|
||||
FormatId::OpenAiEmbedding
|
||||
| FormatId::OpenAiRerank
|
||||
| FormatId::GeminiEmbedding
|
||||
| FormatId::JinaEmbedding
|
||||
| FormatId::JinaRerank
|
||||
| FormatId::DoubaoEmbedding => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn push_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, AiSurfaceFinalizeError> {
|
||||
match self {
|
||||
ProviderStreamParser::OpenAIChat(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),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, AiSurfaceFinalizeError> {
|
||||
match self {
|
||||
ProviderStreamParser::OpenAIChat(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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ClientStreamEmitter {
|
||||
OpenAIChat(OpenAIChatClientEmitter),
|
||||
OpenAIResponses(OpenAIResponsesClientEmitter),
|
||||
Claude(ClaudeClientEmitter),
|
||||
Gemini(GeminiClientEmitter),
|
||||
}
|
||||
|
||||
fn provider_api_format_for_context(report_context: &Value) -> String {
|
||||
string_context_field(report_context, "provider_stream_event_api_format")
|
||||
.or_else(|| string_context_field(report_context, "provider_stream_api_format"))
|
||||
.or_else(|| string_context_field(report_context, "provider_api_format"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn string_context_field(report_context: &Value, key: &str) -> Option<String> {
|
||||
let value = report_context.get(key)?.as_str()?.trim();
|
||||
(!value.is_empty()).then(|| value.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn client_api_format_for_context(report_context: &Value) -> String {
|
||||
report_context
|
||||
.get("client_api_format")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn standardized_usage_from_canonical(usage: CanonicalUsage) -> StandardizedUsage {
|
||||
let mut standardized = StandardizedUsage::new();
|
||||
standardized.input_tokens = usage.input_tokens as i64;
|
||||
standardized.output_tokens = usage.output_tokens as i64;
|
||||
standardized.cache_creation_tokens = usage.cache_creation_tokens as i64;
|
||||
standardized.cache_creation_ephemeral_5m_tokens =
|
||||
usage.cache_creation_ephemeral_5m_tokens as i64;
|
||||
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),
|
||||
);
|
||||
standardized.normalize_cache_creation_breakdown()
|
||||
}
|
||||
|
||||
impl ClientStreamEmitter {
|
||||
fn for_api_format(client_api_format: &str) -> Option<Self> {
|
||||
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()),
|
||||
FormatId::OpenAiEmbedding
|
||||
| FormatId::OpenAiRerank
|
||||
| FormatId::GeminiEmbedding
|
||||
| FormatId::JinaEmbedding
|
||||
| FormatId::JinaRerank
|
||||
| FormatId::DoubaoEmbedding => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
match self {
|
||||
ClientStreamEmitter::OpenAIChat(state) => state.emit(frame),
|
||||
ClientStreamEmitter::OpenAIResponses(state) => state.emit(frame),
|
||||
ClientStreamEmitter::Claude(state) => state.emit(frame),
|
||||
ClientStreamEmitter::Gemini(state) => state.emit(frame),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
match self {
|
||||
ClientStreamEmitter::OpenAIChat(state) => state.finish(),
|
||||
ClientStreamEmitter::OpenAIResponses(state) => state.finish(),
|
||||
ClientStreamEmitter::Claude(state) => state.finish(),
|
||||
ClientStreamEmitter::Gemini(state) => state.finish(),
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_error(&mut self, error_body: Value) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
match self {
|
||||
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)
|
||||
}
|
||||
ClientStreamEmitter::OpenAIChat(_) | ClientStreamEmitter::Gemini(_) => {
|
||||
encode_json_sse(None, &error_body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_client_error_body_for_line(report_context: &Value, line: &[u8]) -> Option<Value> {
|
||||
let value = decode_json_data_line(line)?;
|
||||
let provider_api_format = provider_api_format_for_context(report_context);
|
||||
let client_api_format = report_context
|
||||
.get("client_api_format")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let (message, code, kind) = parse_provider_error(&provider_api_format, &value)?;
|
||||
build_core_error_body_for_client_format(&client_api_format, &message, code.as_deref(), kind)
|
||||
}
|
||||
|
||||
fn parse_provider_error(
|
||||
provider_api_format: &str,
|
||||
payload: &Value,
|
||||
) -> Option<(String, Option<String>, LocalCoreSyncErrorKind)> {
|
||||
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),
|
||||
FormatId::OpenAiEmbedding
|
||||
| FormatId::OpenAiRerank
|
||||
| FormatId::GeminiEmbedding
|
||||
| FormatId::JinaEmbedding
|
||||
| FormatId::JinaRerank
|
||||
| FormatId::DoubaoEmbedding => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_openai_error(payload: &Value) -> Option<(String, Option<String>, LocalCoreSyncErrorKind)> {
|
||||
let error = payload.get("error")?.as_object()?;
|
||||
let message = error.get("message").and_then(Value::as_str)?.to_string();
|
||||
let code = error
|
||||
.get("code")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
let kind = match error
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"invalid_request_error" => LocalCoreSyncErrorKind::InvalidRequest,
|
||||
"authentication_error" => LocalCoreSyncErrorKind::Authentication,
|
||||
"permission_error" => LocalCoreSyncErrorKind::PermissionDenied,
|
||||
"not_found_error" => LocalCoreSyncErrorKind::NotFound,
|
||||
"rate_limit_error" => LocalCoreSyncErrorKind::RateLimit,
|
||||
"context_length_exceeded" => LocalCoreSyncErrorKind::ContextLengthExceeded,
|
||||
"overloaded_error" => LocalCoreSyncErrorKind::Overloaded,
|
||||
_ => LocalCoreSyncErrorKind::ServerError,
|
||||
};
|
||||
Some((message, code, kind))
|
||||
}
|
||||
|
||||
fn parse_claude_error(payload: &Value) -> Option<(String, Option<String>, LocalCoreSyncErrorKind)> {
|
||||
let error = payload.get("error")?.as_object()?;
|
||||
let message = error.get("message").and_then(Value::as_str)?.to_string();
|
||||
let code = error
|
||||
.get("code")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
let kind = match error
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"invalid_request_error" => LocalCoreSyncErrorKind::InvalidRequest,
|
||||
"authentication_error" => LocalCoreSyncErrorKind::Authentication,
|
||||
"permission_error" => LocalCoreSyncErrorKind::PermissionDenied,
|
||||
"not_found_error" => LocalCoreSyncErrorKind::NotFound,
|
||||
"rate_limit_error" => LocalCoreSyncErrorKind::RateLimit,
|
||||
"overloaded_error" => LocalCoreSyncErrorKind::Overloaded,
|
||||
_ => LocalCoreSyncErrorKind::ServerError,
|
||||
};
|
||||
Some((message, code, kind))
|
||||
}
|
||||
|
||||
fn parse_gemini_error(payload: &Value) -> Option<(String, Option<String>, LocalCoreSyncErrorKind)> {
|
||||
let error = payload.get("error")?.as_object()?;
|
||||
let message = error.get("message").and_then(Value::as_str)?.to_string();
|
||||
let code = error.get("code").map(|value| match value {
|
||||
Value::String(text) => text.clone(),
|
||||
Value::Number(number) => number.to_string(),
|
||||
_ => String::new(),
|
||||
});
|
||||
let kind = match error
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"INVALID_ARGUMENT" => LocalCoreSyncErrorKind::InvalidRequest,
|
||||
"UNAUTHENTICATED" => LocalCoreSyncErrorKind::Authentication,
|
||||
"PERMISSION_DENIED" => LocalCoreSyncErrorKind::PermissionDenied,
|
||||
"NOT_FOUND" => LocalCoreSyncErrorKind::NotFound,
|
||||
"RESOURCE_EXHAUSTED" => LocalCoreSyncErrorKind::RateLimit,
|
||||
"UNAVAILABLE" => LocalCoreSyncErrorKind::Overloaded,
|
||||
_ => LocalCoreSyncErrorKind::ServerError,
|
||||
};
|
||||
let code = code.filter(|value| !value.is_empty());
|
||||
Some((message, code, kind))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{StreamingStandardFormatMatrix, StreamingStandardTerminalObserver};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn report_context(provider_api_format: &str, client_api_format: &str) -> Value {
|
||||
json!({
|
||||
"provider_api_format": provider_api_format,
|
||||
"client_api_format": client_api_format,
|
||||
"mapped_model": "test-model",
|
||||
})
|
||||
}
|
||||
|
||||
fn data_line(value: Value) -> Vec<u8> {
|
||||
format!("data: {}\n", value).into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_provider_errors_to_openai_chat_error_bodies() {
|
||||
let cases = [
|
||||
(
|
||||
"openai:chat",
|
||||
data_line(json!({
|
||||
"error": {
|
||||
"message": "bad request",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_request",
|
||||
}
|
||||
})),
|
||||
"\"message\":\"bad request\"",
|
||||
"\"type\":\"invalid_request_error\"",
|
||||
"\"code\":\"invalid_request\"",
|
||||
),
|
||||
(
|
||||
"claude:messages",
|
||||
data_line(json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"message": "slow down",
|
||||
"type": "rate_limit_error",
|
||||
"code": "rate_limit",
|
||||
}
|
||||
})),
|
||||
"\"message\":\"slow down\"",
|
||||
"\"type\":\"rate_limit_error\"",
|
||||
"\"code\":\"rate_limit\"",
|
||||
),
|
||||
(
|
||||
"gemini:generate_content",
|
||||
data_line(json!({
|
||||
"error": {
|
||||
"code": 429,
|
||||
"message": "quota exceeded",
|
||||
"status": "RESOURCE_EXHAUSTED",
|
||||
}
|
||||
})),
|
||||
"\"message\":\"quota exceeded\"",
|
||||
"\"type\":\"rate_limit_error\"",
|
||||
"\"code\":\"429\"",
|
||||
),
|
||||
];
|
||||
|
||||
for (provider_api_format, line, message, err_type, code) in cases {
|
||||
let report_context = report_context(provider_api_format, "openai:chat");
|
||||
let mut matrix = StreamingStandardFormatMatrix::default();
|
||||
let output = matrix
|
||||
.transform_line(&report_context, line)
|
||||
.expect("error should convert");
|
||||
let sse = String::from_utf8(output).expect("sse should be utf8");
|
||||
|
||||
assert!(sse.starts_with("data: {\"error\":"));
|
||||
assert!(!sse.contains("event: "));
|
||||
assert!(sse.contains(message));
|
||||
assert!(sse.contains(err_type));
|
||||
assert!(sse.contains(code));
|
||||
assert!(matrix
|
||||
.finish(&report_context)
|
||||
.expect("finish should succeed")
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_provider_errors_to_claude_error_events() {
|
||||
let cases = [
|
||||
(
|
||||
"openai:chat",
|
||||
data_line(json!({
|
||||
"error": {
|
||||
"message": "bad request",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_request",
|
||||
}
|
||||
})),
|
||||
"\"message\":\"bad request\"",
|
||||
"\"type\":\"invalid_request_error\"",
|
||||
"\"code\":\"invalid_request\"",
|
||||
),
|
||||
(
|
||||
"claude:messages",
|
||||
data_line(json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"message": "slow down",
|
||||
"type": "rate_limit_error",
|
||||
"code": "rate_limit",
|
||||
}
|
||||
})),
|
||||
"\"message\":\"slow down\"",
|
||||
"\"type\":\"rate_limit_error\"",
|
||||
"\"code\":\"rate_limit\"",
|
||||
),
|
||||
(
|
||||
"gemini:generate_content",
|
||||
data_line(json!({
|
||||
"error": {
|
||||
"code": 429,
|
||||
"message": "quota exceeded",
|
||||
"status": "RESOURCE_EXHAUSTED",
|
||||
}
|
||||
})),
|
||||
"\"message\":\"quota exceeded\"",
|
||||
"\"type\":\"rate_limit_error\"",
|
||||
"\"code\":\"429\"",
|
||||
),
|
||||
];
|
||||
|
||||
for (provider_api_format, line, message, err_type, code) in cases {
|
||||
let report_context = report_context(provider_api_format, "claude:messages");
|
||||
let mut matrix = StreamingStandardFormatMatrix::default();
|
||||
let output = matrix
|
||||
.transform_line(&report_context, line)
|
||||
.expect("error should convert");
|
||||
let sse = String::from_utf8(output).expect("sse should be utf8");
|
||||
|
||||
assert!(sse.starts_with("event: error\n"));
|
||||
assert!(sse.contains("data: {"));
|
||||
assert!(sse.contains("\"type\":\"error\""));
|
||||
assert!(sse.contains("\"error\":{"));
|
||||
assert!(sse.contains(message));
|
||||
assert!(sse.contains(err_type));
|
||||
assert!(sse.contains(code));
|
||||
assert!(matrix
|
||||
.finish(&report_context)
|
||||
.expect("finish should succeed")
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_provider_errors_to_gemini_error_bodies() {
|
||||
let cases = [
|
||||
(
|
||||
"openai:chat",
|
||||
data_line(json!({
|
||||
"error": {
|
||||
"message": "bad request",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_request",
|
||||
}
|
||||
})),
|
||||
"\"message\":\"bad request\"",
|
||||
"\"code\":400",
|
||||
"\"status\":\"INVALID_ARGUMENT\"",
|
||||
),
|
||||
(
|
||||
"claude:messages",
|
||||
data_line(json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"message": "slow down",
|
||||
"type": "rate_limit_error",
|
||||
"code": "rate_limit",
|
||||
}
|
||||
})),
|
||||
"\"message\":\"slow down\"",
|
||||
"\"code\":429",
|
||||
"\"status\":\"RESOURCE_EXHAUSTED\"",
|
||||
),
|
||||
(
|
||||
"gemini:generate_content",
|
||||
data_line(json!({
|
||||
"error": {
|
||||
"code": 429,
|
||||
"message": "quota exceeded",
|
||||
"status": "RESOURCE_EXHAUSTED",
|
||||
}
|
||||
})),
|
||||
"\"message\":\"quota exceeded\"",
|
||||
"\"code\":429",
|
||||
"\"status\":\"RESOURCE_EXHAUSTED\"",
|
||||
),
|
||||
];
|
||||
|
||||
for (provider_api_format, line, message, code, status) in cases {
|
||||
let report_context = report_context(provider_api_format, "gemini:generate_content");
|
||||
let mut matrix = StreamingStandardFormatMatrix::default();
|
||||
let output = matrix
|
||||
.transform_line(&report_context, line)
|
||||
.expect("error should convert");
|
||||
let sse = String::from_utf8(output).expect("sse should be utf8");
|
||||
|
||||
assert!(sse.starts_with("data: {\"error\":"));
|
||||
assert!(!sse.contains("event: "));
|
||||
assert!(sse.contains(message));
|
||||
assert!(sse.contains(code));
|
||||
assert!(sse.contains(status));
|
||||
assert!(matrix
|
||||
.finish(&report_context)
|
||||
.expect("finish should succeed")
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_provider_errors_to_openai_responses_failed_events() {
|
||||
let cases = [
|
||||
(
|
||||
"openai:chat",
|
||||
data_line(json!({
|
||||
"error": {
|
||||
"message": "bad request",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_request",
|
||||
}
|
||||
})),
|
||||
"\"message\":\"bad request\"",
|
||||
"\"type\":\"invalid_request_error\"",
|
||||
"\"code\":\"invalid_request\"",
|
||||
),
|
||||
(
|
||||
"claude:messages",
|
||||
data_line(json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"message": "slow down",
|
||||
"type": "rate_limit_error",
|
||||
"code": "rate_limit",
|
||||
}
|
||||
})),
|
||||
"\"message\":\"slow down\"",
|
||||
"\"type\":\"rate_limit_error\"",
|
||||
"\"code\":\"rate_limit\"",
|
||||
),
|
||||
(
|
||||
"gemini:generate_content",
|
||||
data_line(json!({
|
||||
"error": {
|
||||
"code": 429,
|
||||
"message": "quota exceeded",
|
||||
"status": "RESOURCE_EXHAUSTED",
|
||||
}
|
||||
})),
|
||||
"\"message\":\"quota exceeded\"",
|
||||
"\"type\":\"rate_limit_error\"",
|
||||
"\"code\":\"429\"",
|
||||
),
|
||||
];
|
||||
|
||||
for (provider_api_format, line, message, err_type, code) in cases {
|
||||
let report_context = report_context(provider_api_format, "openai:responses");
|
||||
let mut matrix = StreamingStandardFormatMatrix::default();
|
||||
let output = matrix
|
||||
.transform_line(&report_context, line)
|
||||
.expect("error should convert");
|
||||
let sse = String::from_utf8(output).expect("sse should be utf8");
|
||||
|
||||
assert!(sse.starts_with("event: response.failed\n"));
|
||||
assert!(sse.contains("\"sequence_number\":1"));
|
||||
assert!(sse.contains(message));
|
||||
assert!(sse.contains(err_type));
|
||||
assert!(sse.contains(code));
|
||||
assert!(matrix
|
||||
.finish(&report_context)
|
||||
.expect("finish should succeed")
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_gemini_inline_image_streams_to_claude_image_blocks() {
|
||||
let report_context = report_context("gemini:generate_content", "claude:messages");
|
||||
let mut matrix = StreamingStandardFormatMatrix::default();
|
||||
let output = matrix
|
||||
.transform_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"responseId": "resp_media_123",
|
||||
"modelVersion": "gemini-2.5-pro",
|
||||
"candidates": [{
|
||||
"index": 0,
|
||||
"content": {
|
||||
"parts": [
|
||||
{ "inlineData": { "mimeType": "image/png", "data": "iVBORw0KGgo=" } }
|
||||
]
|
||||
}
|
||||
}]
|
||||
})),
|
||||
)
|
||||
.expect("image chunk should rewrite");
|
||||
let sse = String::from_utf8(output).expect("sse should be utf8");
|
||||
|
||||
assert!(sse.contains("event: message_start"));
|
||||
assert!(sse.contains("\"type\":\"image\""));
|
||||
assert!(sse.contains("\"media_type\":\"image/png\""));
|
||||
assert!(sse.contains("\"data\":\"iVBORw0KGgo=\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_claude_image_blocks_to_gemini_inline_image_streams() {
|
||||
let report_context = report_context("claude:messages", "gemini:generate_content");
|
||||
let mut matrix = StreamingStandardFormatMatrix::default();
|
||||
let output = matrix
|
||||
.transform_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "iVBORw0KGgo="
|
||||
}
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect("image chunk should rewrite");
|
||||
let sse = String::from_utf8(output).expect("sse should be utf8");
|
||||
|
||||
assert!(
|
||||
sse.contains("\"inlineData\":{\"mimeType\":\"image/png\",\"data\":\"iVBORw0KGgo=\"}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_observer_preserves_claude_cache_usage() {
|
||||
let report_context = report_context("claude:messages", "openai:chat");
|
||||
let mut observer = StreamingStandardTerminalObserver::default();
|
||||
|
||||
observer
|
||||
.push_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": "msg_cache_123",
|
||||
"model": "claude-sonnet-4-5"
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect("message_start should parse");
|
||||
observer
|
||||
.push_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"type": "message_delta",
|
||||
"delta": {
|
||||
"stop_reason": "end_turn"
|
||||
},
|
||||
"usage": {
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 20,
|
||||
"cache_creation_input_tokens": 42262,
|
||||
"cache_read_input_tokens": 0
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect("message_delta should parse");
|
||||
|
||||
let summary = observer
|
||||
.latest_summary()
|
||||
.cloned()
|
||||
.expect("summary should exist");
|
||||
let usage = summary
|
||||
.standardized_usage
|
||||
.expect("standardized usage should exist");
|
||||
|
||||
assert_eq!(usage.input_tokens, 6);
|
||||
assert_eq!(usage.output_tokens, 20);
|
||||
assert_eq!(usage.cache_creation_tokens, 42_262);
|
||||
assert_eq!(usage.cache_read_tokens, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_observer_uses_explicit_provider_stream_event_api_format() {
|
||||
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.completed",
|
||||
"response": {
|
||||
"id": "resp_codex_123",
|
||||
"object": "response",
|
||||
"model": "gpt-5.5",
|
||||
"status": "completed",
|
||||
"output": [],
|
||||
"usage": {
|
||||
"input_tokens": 26,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 0,
|
||||
},
|
||||
"output_tokens": 137,
|
||||
"output_tokens_details": {
|
||||
"reasoning_tokens": 10,
|
||||
},
|
||||
"total_tokens": 163,
|
||||
},
|
||||
},
|
||||
"sequence_number": 139,
|
||||
})),
|
||||
)
|
||||
.expect("response.completed should parse");
|
||||
|
||||
let summary = observer
|
||||
.latest_summary()
|
||||
.cloned()
|
||||
.expect("summary should exist");
|
||||
let usage = summary
|
||||
.standardized_usage
|
||||
.expect("standardized usage should exist");
|
||||
|
||||
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:responses");
|
||||
let mut observer = StreamingStandardTerminalObserver::default();
|
||||
|
||||
observer
|
||||
.push_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"usage": {
|
||||
"input_tokens": 26,
|
||||
"output_tokens": 137,
|
||||
"total_tokens": 163,
|
||||
},
|
||||
},
|
||||
})),
|
||||
)
|
||||
.expect("line should be ignored by explicitly selected chat parser");
|
||||
|
||||
assert!(
|
||||
observer.latest_summary().is_none(),
|
||||
"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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod common;
|
||||
pub mod format_matrix;
|
||||
|
||||
pub use common::{CanonicalStreamEvent, CanonicalStreamFrame};
|
||||
pub use format_matrix::{StreamingStandardFormatMatrix, StreamingStandardTerminalObserver};
|
||||
356
crates/aether-ai-formats/src/formats/shared/stream_rewrite.rs
Normal file
356
crates/aether-ai-formats/src/formats/shared/stream_rewrite.rs
Normal file
@@ -0,0 +1,356 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::formats::openai::image::stream::OpenAiImageStreamState;
|
||||
use crate::formats::shared::stream_core::StreamingStandardFormatMatrix;
|
||||
use crate::formats::shared::AiSurfaceFinalizeError;
|
||||
use crate::provider_compat::kiro_stream::KiroToClaudeCliStreamState;
|
||||
use crate::provider_compat::private_envelope::transform_provider_private_stream_line;
|
||||
use crate::provider_compat::surfaces::{
|
||||
provider_adaptation_should_unwrap_stream_envelope, KIRO_ENVELOPE_NAME,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FinalizeStreamRewriteMode {
|
||||
EnvelopeUnwrap,
|
||||
OpenAiImage,
|
||||
Standard,
|
||||
KiroToClaudeCli,
|
||||
KiroToClaudeCliThenStandard,
|
||||
}
|
||||
|
||||
pub fn resolve_finalize_stream_rewrite_mode(
|
||||
report_context: &Value,
|
||||
) -> Option<FinalizeStreamRewriteMode> {
|
||||
let needs_conversion = report_context
|
||||
.get("needs_conversion")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let envelope_name = report_context
|
||||
.get("envelope_name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let provider_api_format = report_context
|
||||
.get("provider_api_format")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let client_api_format = report_context
|
||||
.get("client_api_format")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
if needs_conversion
|
||||
&& envelope_name.eq_ignore_ascii_case(KIRO_ENVELOPE_NAME)
|
||||
&& provider_api_format == "claude:messages"
|
||||
{
|
||||
return supports_standard_stream_rewrite(
|
||||
provider_api_format.as_str(),
|
||||
client_api_format.as_str(),
|
||||
)
|
||||
.then_some(FinalizeStreamRewriteMode::KiroToClaudeCliThenStandard);
|
||||
}
|
||||
|
||||
if needs_conversion {
|
||||
return supports_standard_stream_rewrite(
|
||||
provider_api_format.as_str(),
|
||||
client_api_format.as_str(),
|
||||
)
|
||||
.then_some(FinalizeStreamRewriteMode::Standard);
|
||||
}
|
||||
|
||||
if provider_api_format == "openai:image" && client_api_format == "openai:image" {
|
||||
return Some(FinalizeStreamRewriteMode::OpenAiImage);
|
||||
}
|
||||
|
||||
if envelope_name.eq_ignore_ascii_case(KIRO_ENVELOPE_NAME) {
|
||||
return (provider_api_format == "claude:messages"
|
||||
&& client_api_format == "claude:messages")
|
||||
.then_some(FinalizeStreamRewriteMode::KiroToClaudeCli);
|
||||
}
|
||||
|
||||
(provider_api_format == client_api_format
|
||||
&& provider_adaptation_should_unwrap_stream_envelope(
|
||||
envelope_name.as_str(),
|
||||
provider_api_format.as_str(),
|
||||
))
|
||||
.then_some(FinalizeStreamRewriteMode::EnvelopeUnwrap)
|
||||
}
|
||||
|
||||
enum AiSurfaceStreamRewriteState {
|
||||
EnvelopeUnwrap,
|
||||
OpenAiImage(Box<OpenAiImageStreamState>),
|
||||
Standard(Box<StreamingStandardFormatMatrix>),
|
||||
KiroToClaudeCli(Box<KiroToClaudeCliStreamState>),
|
||||
KiroToClaudeCliThenStandard {
|
||||
kiro: Box<KiroToClaudeCliStreamState>,
|
||||
standard: Box<StreamingStandardFormatMatrix>,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct AiSurfaceStreamRewriter<'a> {
|
||||
report_context: &'a Value,
|
||||
buffered: Vec<u8>,
|
||||
state: AiSurfaceStreamRewriteState,
|
||||
}
|
||||
|
||||
pub fn maybe_build_ai_surface_stream_rewriter<'a>(
|
||||
report_context: Option<&'a Value>,
|
||||
) -> Option<AiSurfaceStreamRewriter<'a>> {
|
||||
let report_context = report_context?;
|
||||
let state = match resolve_finalize_stream_rewrite_mode(report_context)? {
|
||||
FinalizeStreamRewriteMode::EnvelopeUnwrap => AiSurfaceStreamRewriteState::EnvelopeUnwrap,
|
||||
FinalizeStreamRewriteMode::OpenAiImage => {
|
||||
AiSurfaceStreamRewriteState::OpenAiImage(Box::<OpenAiImageStreamState>::default())
|
||||
}
|
||||
FinalizeStreamRewriteMode::Standard => {
|
||||
AiSurfaceStreamRewriteState::Standard(Box::<StreamingStandardFormatMatrix>::default())
|
||||
}
|
||||
FinalizeStreamRewriteMode::KiroToClaudeCli => AiSurfaceStreamRewriteState::KiroToClaudeCli(
|
||||
Box::new(KiroToClaudeCliStreamState::new(report_context)),
|
||||
),
|
||||
FinalizeStreamRewriteMode::KiroToClaudeCliThenStandard => {
|
||||
AiSurfaceStreamRewriteState::KiroToClaudeCliThenStandard {
|
||||
kiro: Box::new(KiroToClaudeCliStreamState::new(report_context)),
|
||||
standard: Box::<StreamingStandardFormatMatrix>::default(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Some(AiSurfaceStreamRewriter {
|
||||
report_context,
|
||||
buffered: Vec::new(),
|
||||
state,
|
||||
})
|
||||
}
|
||||
|
||||
impl AiSurfaceStreamRewriter<'_> {
|
||||
pub fn push_chunk(&mut self, chunk: &[u8]) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
match &mut self.state {
|
||||
AiSurfaceStreamRewriteState::OpenAiImage(state) => {
|
||||
state.push_chunk(self.report_context, chunk)
|
||||
}
|
||||
AiSurfaceStreamRewriteState::KiroToClaudeCli(state) => {
|
||||
state.push_chunk(self.report_context, chunk)
|
||||
}
|
||||
AiSurfaceStreamRewriteState::KiroToClaudeCliThenStandard { kiro, standard } => {
|
||||
let claude_bytes = kiro.push_chunk(self.report_context, chunk)?;
|
||||
transform_standard_bytes(standard, self.report_context, claude_bytes)
|
||||
}
|
||||
AiSurfaceStreamRewriteState::EnvelopeUnwrap
|
||||
| AiSurfaceStreamRewriteState::Standard(_) => {
|
||||
self.buffered.extend_from_slice(chunk);
|
||||
let mut output = Vec::new();
|
||||
while let Some(line_end) = self.buffered.iter().position(|byte| *byte == b'\n') {
|
||||
let line = self.buffered.drain(..=line_end).collect::<Vec<_>>();
|
||||
output.extend(self.transform_line(line)?);
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
match &mut self.state {
|
||||
AiSurfaceStreamRewriteState::OpenAiImage(state) => state.finish(self.report_context),
|
||||
AiSurfaceStreamRewriteState::KiroToClaudeCli(state) => {
|
||||
state.finish(self.report_context)
|
||||
}
|
||||
AiSurfaceStreamRewriteState::KiroToClaudeCliThenStandard { kiro, standard } => {
|
||||
let mut output = transform_standard_bytes(
|
||||
standard,
|
||||
self.report_context,
|
||||
kiro.finish(self.report_context)?,
|
||||
)?;
|
||||
output.extend(standard.finish(self.report_context)?);
|
||||
Ok(output)
|
||||
}
|
||||
AiSurfaceStreamRewriteState::EnvelopeUnwrap
|
||||
| AiSurfaceStreamRewriteState::Standard(_) => {
|
||||
if self.buffered.is_empty() {
|
||||
if let AiSurfaceStreamRewriteState::Standard(state) = &mut self.state {
|
||||
return state.finish(self.report_context);
|
||||
}
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let line = std::mem::take(&mut self.buffered);
|
||||
let mut output = self.transform_line(line)?;
|
||||
if let AiSurfaceStreamRewriteState::Standard(state) = &mut self.state {
|
||||
output.extend(state.finish(self.report_context)?);
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn transform_line(&mut self, line: Vec<u8>) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
match &mut self.state {
|
||||
AiSurfaceStreamRewriteState::EnvelopeUnwrap => {
|
||||
transform_provider_private_stream_line(self.report_context, line)
|
||||
.map_err(AiSurfaceFinalizeError::from)
|
||||
}
|
||||
AiSurfaceStreamRewriteState::Standard(state) => {
|
||||
transform_standard_line(state, self.report_context, line)
|
||||
}
|
||||
AiSurfaceStreamRewriteState::OpenAiImage(_)
|
||||
| AiSurfaceStreamRewriteState::KiroToClaudeCli(_)
|
||||
| AiSurfaceStreamRewriteState::KiroToClaudeCliThenStandard { .. } => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn transform_standard_bytes(
|
||||
standard: &mut StreamingStandardFormatMatrix,
|
||||
report_context: &Value,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
if bytes.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut output = Vec::new();
|
||||
for line in bytes.split_inclusive(|byte| *byte == b'\n') {
|
||||
output.extend(transform_standard_line(
|
||||
standard,
|
||||
report_context,
|
||||
line.to_vec(),
|
||||
)?);
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn transform_standard_line(
|
||||
standard: &mut StreamingStandardFormatMatrix,
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
let line = if should_unwrap_envelope(report_context) {
|
||||
transform_provider_private_stream_line(report_context, line)?
|
||||
} else {
|
||||
line
|
||||
};
|
||||
if line.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
standard.transform_line(report_context, line)
|
||||
}
|
||||
|
||||
fn should_unwrap_envelope(report_context: &Value) -> bool {
|
||||
let envelope_name = report_context
|
||||
.get("envelope_name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let provider_api_format = report_context
|
||||
.get("provider_api_format")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
provider_adaptation_should_unwrap_stream_envelope(envelope_name, provider_api_format)
|
||||
}
|
||||
|
||||
fn supports_standard_stream_rewrite(provider_api_format: &str, client_api_format: &str) -> bool {
|
||||
is_standard_provider_api_format(provider_api_format)
|
||||
&& (is_standard_chat_client_api_format(client_api_format)
|
||||
|| is_standard_cli_client_api_format(client_api_format))
|
||||
}
|
||||
|
||||
fn is_standard_provider_api_format(api_format: &str) -> bool {
|
||||
matches!(
|
||||
aether_ai_formats::normalize_api_format_alias(api_format).as_str(),
|
||||
"openai:chat"
|
||||
| "openai:responses"
|
||||
| "openai:responses:compact"
|
||||
| "claude:messages"
|
||||
| "gemini:generate_content"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_standard_chat_client_api_format(api_format: &str) -> bool {
|
||||
matches!(
|
||||
api_format,
|
||||
"openai:chat" | "claude:messages" | "gemini:generate_content"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_standard_cli_client_api_format(api_format: &str) -> bool {
|
||||
matches!(
|
||||
aether_ai_formats::normalize_api_format_alias(api_format).as_str(),
|
||||
"openai:responses"
|
||||
| "openai:responses:compact"
|
||||
| "claude:messages"
|
||||
| "gemini:generate_content"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{resolve_finalize_stream_rewrite_mode, FinalizeStreamRewriteMode};
|
||||
|
||||
#[test]
|
||||
fn resolves_standard_mode_for_cross_format_standard_streams() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "claude:messages",
|
||||
"client_api_format": "openai:chat",
|
||||
"needs_conversion": true,
|
||||
});
|
||||
assert_eq!(
|
||||
resolve_finalize_stream_rewrite_mode(&report_context),
|
||||
Some(FinalizeStreamRewriteMode::Standard)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_envelope_unwrap_for_same_format_private_envelopes() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "gemini:generate_content",
|
||||
"client_api_format": "gemini:generate_content",
|
||||
"envelope_name": "antigravity:v1internal",
|
||||
"needs_conversion": false,
|
||||
});
|
||||
assert_eq!(
|
||||
resolve_finalize_stream_rewrite_mode(&report_context),
|
||||
Some(FinalizeStreamRewriteMode::EnvelopeUnwrap)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_kiro_same_format_streams_to_kiro_mode() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "claude:messages",
|
||||
"client_api_format": "claude:messages",
|
||||
"envelope_name": "kiro:generateAssistantResponse",
|
||||
"needs_conversion": false,
|
||||
});
|
||||
assert_eq!(
|
||||
resolve_finalize_stream_rewrite_mode(&report_context),
|
||||
Some(FinalizeStreamRewriteMode::KiroToClaudeCli)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_non_conversion_streams() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "openai:chat",
|
||||
"client_api_format": "openai:chat",
|
||||
"needs_conversion": false,
|
||||
});
|
||||
assert_eq!(resolve_finalize_stream_rewrite_mode(&report_context), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_image_mode_for_same_format_image_streams() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "openai:image",
|
||||
"client_api_format": "openai:image",
|
||||
"needs_conversion": false,
|
||||
});
|
||||
assert_eq!(
|
||||
resolve_finalize_stream_rewrite_mode(&report_context),
|
||||
Some(FinalizeStreamRewriteMode::OpenAiImage)
|
||||
);
|
||||
}
|
||||
}
|
||||
4446
crates/aether-ai-formats/src/formats/shared/sync_products.rs
Normal file
4446
crates/aether-ai-formats/src/formats/shared/sync_products.rs
Normal file
File diff suppressed because it is too large
Load Diff
569
crates/aether-ai-formats/src/formats/shared/sync_to_stream.rs
Normal file
569
crates/aether-ai-formats/src/formats/shared/sync_to_stream.rs
Normal file
@@ -0,0 +1,569 @@
|
||||
use aether_ai_formats::formats::conversion::response::{
|
||||
convert_claude_response_to_openai_responses, convert_gemini_response_to_openai_responses,
|
||||
convert_openai_chat_response_to_openai_responses,
|
||||
};
|
||||
use aether_contracts::{ExecutionStreamTerminalSummary, StandardizedUsage};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::formats::claude::messages::stream::ClaudeClientEmitter;
|
||||
use crate::formats::gemini::generate_content::stream::GeminiClientEmitter;
|
||||
use crate::formats::openai::chat::stream::{
|
||||
OpenAIChatClientEmitter, OpenAIResponsesClientEmitter, OpenAIResponsesProviderState,
|
||||
};
|
||||
use crate::formats::shared::sse::encode_json_sse;
|
||||
use crate::formats::shared::stream_core::CanonicalStreamFrame;
|
||||
use crate::formats::shared::AiSurfaceFinalizeError;
|
||||
|
||||
pub struct SyncToStreamBridgeOutcome {
|
||||
pub sse_body: Vec<u8>,
|
||||
pub terminal_summary: Option<ExecutionStreamTerminalSummary>,
|
||||
}
|
||||
|
||||
pub fn maybe_bridge_standard_sync_json_to_stream(
|
||||
provider_body_json: &Value,
|
||||
provider_api_format: &str,
|
||||
client_api_format: &str,
|
||||
report_context: Option<&Value>,
|
||||
) -> Result<Option<SyncToStreamBridgeOutcome>, AiSurfaceFinalizeError> {
|
||||
let provider_api_format = normalize_api_format(provider_api_format);
|
||||
let client_api_format = normalize_api_format(client_api_format);
|
||||
if provider_api_format == "openai:image" && client_api_format == "openai:image" {
|
||||
return maybe_bridge_openai_image_sync_json_to_stream(provider_body_json, report_context);
|
||||
}
|
||||
if !is_standard_api_format(provider_api_format.as_str())
|
||||
|| !is_standard_api_format(client_api_format.as_str())
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let bridge_context = build_bridge_report_context(
|
||||
report_context,
|
||||
provider_api_format.as_str(),
|
||||
client_api_format.as_str(),
|
||||
);
|
||||
let Some(openai_responses_response) = convert_provider_sync_response_to_openai_responses(
|
||||
provider_body_json,
|
||||
provider_api_format.as_str(),
|
||||
&bridge_context,
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let terminal_summary =
|
||||
build_terminal_summary_from_openai_responses_response(&openai_responses_response);
|
||||
let canonical_frames = build_canonical_frames_from_openai_responses_response(
|
||||
&openai_responses_response,
|
||||
&bridge_context,
|
||||
)?;
|
||||
let sse_body =
|
||||
emit_client_stream_from_canonical_frames(canonical_frames, client_api_format.as_str())?;
|
||||
|
||||
Ok(Some(SyncToStreamBridgeOutcome {
|
||||
sse_body,
|
||||
terminal_summary,
|
||||
}))
|
||||
}
|
||||
|
||||
fn maybe_bridge_openai_image_sync_json_to_stream(
|
||||
provider_body_json: &Value,
|
||||
report_context: Option<&Value>,
|
||||
) -> Result<Option<SyncToStreamBridgeOutcome>, AiSurfaceFinalizeError> {
|
||||
let Some(response) = provider_body_json.as_object() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(image) = response
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_object)
|
||||
.find_map(extract_openai_image_sync_b64_json)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let usage = response.get("usage").cloned().unwrap_or(Value::Null);
|
||||
let event_name = openai_image_completed_event_name(report_context);
|
||||
let sse_body = encode_json_sse(
|
||||
Some(event_name),
|
||||
&json!({
|
||||
"type": event_name,
|
||||
"b64_json": image,
|
||||
"usage": usage,
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(Some(SyncToStreamBridgeOutcome {
|
||||
sse_body,
|
||||
terminal_summary: Some(ExecutionStreamTerminalSummary {
|
||||
standardized_usage: response
|
||||
.get("usage")
|
||||
.and_then(standardized_usage_from_openai_usage),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: response
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
model: response
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| image_bridge_model(report_context)),
|
||||
observed_finish: true,
|
||||
unknown_event_count: 0,
|
||||
parser_error: None,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
fn normalize_api_format(value: &str) -> String {
|
||||
aether_ai_formats::normalize_api_format_alias(value)
|
||||
}
|
||||
|
||||
fn is_standard_api_format(value: &str) -> bool {
|
||||
matches!(
|
||||
value,
|
||||
"openai:chat"
|
||||
| "openai:responses"
|
||||
| "openai:responses:compact"
|
||||
| "claude:messages"
|
||||
| "gemini:generate_content"
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_openai_image_sync_b64_json(item: &serde_json::Map<String, Value>) -> Option<String> {
|
||||
item.get("b64_json")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
item.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(extract_base64_from_data_url)
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_base64_from_data_url(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
let (metadata, payload) = trimmed.split_once(',')?;
|
||||
if !metadata.starts_with("data:") || !metadata.ends_with(";base64") {
|
||||
return None;
|
||||
}
|
||||
(!payload.trim().is_empty()).then(|| payload.trim().to_string())
|
||||
}
|
||||
|
||||
fn openai_image_completed_event_name(report_context: Option<&Value>) -> &'static str {
|
||||
if openai_image_request_operation(report_context) == Some("edit") {
|
||||
"image_edit.completed"
|
||||
} else {
|
||||
"image_generation.completed"
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_image_request_operation(report_context: Option<&Value>) -> Option<&str> {
|
||||
report_context
|
||||
.and_then(|value| value.get("image_request"))
|
||||
.and_then(|value| value.get("operation"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn image_bridge_model(report_context: Option<&Value>) -> Option<String> {
|
||||
report_context.and_then(|context| {
|
||||
context
|
||||
.get("mapped_model")
|
||||
.or_else(|| context.get("model"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_bridge_report_context(
|
||||
report_context: Option<&Value>,
|
||||
provider_api_format: &str,
|
||||
client_api_format: &str,
|
||||
) -> Value {
|
||||
let mut context = report_context
|
||||
.cloned()
|
||||
.filter(Value::is_object)
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let object = context
|
||||
.as_object_mut()
|
||||
.expect("bridge report context should stay object");
|
||||
object
|
||||
.entry("provider_api_format".to_string())
|
||||
.or_insert_with(|| Value::String(provider_api_format.to_string()));
|
||||
object
|
||||
.entry("client_api_format".to_string())
|
||||
.or_insert_with(|| Value::String(client_api_format.to_string()));
|
||||
context
|
||||
}
|
||||
|
||||
fn convert_provider_sync_response_to_openai_responses(
|
||||
provider_body_json: &Value,
|
||||
provider_api_format: &str,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
match provider_api_format {
|
||||
"openai:responses" | "openai:responses:compact" => Some(provider_body_json.clone()),
|
||||
"openai:chat" => convert_openai_chat_response_to_openai_responses(
|
||||
provider_body_json,
|
||||
report_context,
|
||||
false,
|
||||
),
|
||||
"claude:messages" => {
|
||||
convert_claude_response_to_openai_responses(provider_body_json, report_context)
|
||||
}
|
||||
"gemini:generate_content" => {
|
||||
convert_gemini_response_to_openai_responses(provider_body_json, report_context)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_canonical_frames_from_openai_responses_response(
|
||||
openai_responses_response: &Value,
|
||||
report_context: &Value,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, AiSurfaceFinalizeError> {
|
||||
let mut state = OpenAIResponsesProviderState::default();
|
||||
let line = format!(
|
||||
"data: {}\n",
|
||||
serde_json::to_string(&json!({
|
||||
"type": "response.completed",
|
||||
"response": openai_responses_response,
|
||||
}))
|
||||
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?
|
||||
);
|
||||
let mut frames = state
|
||||
.push_line(report_context, line.into_bytes())
|
||||
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?;
|
||||
frames.extend(
|
||||
state
|
||||
.finish(report_context)
|
||||
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
|
||||
);
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
fn emit_client_stream_from_canonical_frames(
|
||||
canonical_frames: Vec<CanonicalStreamFrame>,
|
||||
client_api_format: &str,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
match client_api_format {
|
||||
"openai:chat" => {
|
||||
let mut emitter = OpenAIChatClientEmitter::default();
|
||||
emit_with_openai_chat_emitter(&mut emitter, canonical_frames)
|
||||
}
|
||||
"openai:responses" | "openai:responses:compact" => {
|
||||
let mut emitter = OpenAIResponsesClientEmitter::default();
|
||||
emit_with_openai_responses_emitter(&mut emitter, canonical_frames)
|
||||
}
|
||||
"claude:messages" => {
|
||||
let mut emitter = ClaudeClientEmitter::default();
|
||||
emit_with_claude_emitter(&mut emitter, canonical_frames)
|
||||
}
|
||||
"gemini:generate_content" => {
|
||||
let mut emitter = GeminiClientEmitter::default();
|
||||
emit_with_gemini_emitter(&mut emitter, canonical_frames)
|
||||
}
|
||||
_ => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_with_openai_chat_emitter(
|
||||
emitter: &mut OpenAIChatClientEmitter,
|
||||
canonical_frames: Vec<CanonicalStreamFrame>,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
let mut output = Vec::new();
|
||||
for frame in canonical_frames {
|
||||
output.extend(
|
||||
emitter
|
||||
.emit(frame)
|
||||
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
|
||||
);
|
||||
}
|
||||
output.extend(
|
||||
emitter
|
||||
.finish()
|
||||
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
|
||||
);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn emit_with_openai_responses_emitter(
|
||||
emitter: &mut OpenAIResponsesClientEmitter,
|
||||
canonical_frames: Vec<CanonicalStreamFrame>,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
let mut output = Vec::new();
|
||||
for frame in canonical_frames {
|
||||
output.extend(
|
||||
emitter
|
||||
.emit(frame)
|
||||
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
|
||||
);
|
||||
}
|
||||
output.extend(
|
||||
emitter
|
||||
.finish()
|
||||
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
|
||||
);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn emit_with_claude_emitter(
|
||||
emitter: &mut ClaudeClientEmitter,
|
||||
canonical_frames: Vec<CanonicalStreamFrame>,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
let mut output = Vec::new();
|
||||
for frame in canonical_frames {
|
||||
output.extend(
|
||||
emitter
|
||||
.emit(frame)
|
||||
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
|
||||
);
|
||||
}
|
||||
output.extend(
|
||||
emitter
|
||||
.finish()
|
||||
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
|
||||
);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn emit_with_gemini_emitter(
|
||||
emitter: &mut GeminiClientEmitter,
|
||||
canonical_frames: Vec<CanonicalStreamFrame>,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
let mut output = Vec::new();
|
||||
for frame in canonical_frames {
|
||||
output.extend(
|
||||
emitter
|
||||
.emit(frame)
|
||||
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
|
||||
);
|
||||
}
|
||||
output.extend(
|
||||
emitter
|
||||
.finish()
|
||||
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
|
||||
);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn build_terminal_summary_from_openai_responses_response(
|
||||
openai_responses_response: &Value,
|
||||
) -> Option<ExecutionStreamTerminalSummary> {
|
||||
let response = openai_responses_response.as_object()?;
|
||||
let response_id = response
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
let model = response
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
let finish_reason = response
|
||||
.get("output")
|
||||
.and_then(Value::as_array)
|
||||
.map(|output| resolve_openai_responses_finish_reason(output))
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
let standardized_usage = response
|
||||
.get("usage")
|
||||
.and_then(standardized_usage_from_openai_usage);
|
||||
Some(ExecutionStreamTerminalSummary {
|
||||
standardized_usage,
|
||||
finish_reason,
|
||||
response_id,
|
||||
model,
|
||||
observed_finish: true,
|
||||
unknown_event_count: 0,
|
||||
parser_error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_openai_responses_finish_reason(output: &[Value]) -> String {
|
||||
let has_tool_calls = output.iter().filter_map(Value::as_object).any(|item| {
|
||||
item.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value == "function_call")
|
||||
});
|
||||
if has_tool_calls {
|
||||
"tool_calls".to_string()
|
||||
} else {
|
||||
"stop".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn standardized_usage_from_openai_usage(value: &Value) -> Option<StandardizedUsage> {
|
||||
let usage = value.as_object()?;
|
||||
let mut input_tokens = usage
|
||||
.get("input_tokens")
|
||||
.or_else(|| usage.get("prompt_tokens"))
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0);
|
||||
let output_tokens = usage
|
||||
.get("output_tokens")
|
||||
.or_else(|| usage.get("completion_tokens"))
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0);
|
||||
let cache_creation_tokens = usage
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(Value::as_i64)
|
||||
.or_else(|| {
|
||||
usage
|
||||
.get("input_tokens_details")
|
||||
.or_else(|| usage.get("prompt_tokens_details"))
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|details| details.get("cached_creation_tokens"))
|
||||
.and_then(Value::as_i64)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let cache_read_tokens = usage
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(Value::as_i64)
|
||||
.or_else(|| {
|
||||
usage
|
||||
.get("input_tokens_details")
|
||||
.or_else(|| usage.get("prompt_tokens_details"))
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|details| details.get("cached_tokens"))
|
||||
.and_then(Value::as_i64)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let total_tokens = usage.get("total_tokens").and_then(Value::as_i64).unwrap_or(
|
||||
input_tokens
|
||||
.saturating_add(output_tokens)
|
||||
.saturating_add(cache_creation_tokens)
|
||||
.saturating_add(cache_read_tokens),
|
||||
);
|
||||
if input_tokens == 0 && total_tokens > output_tokens {
|
||||
input_tokens = total_tokens.saturating_sub(output_tokens);
|
||||
}
|
||||
let mut standardized_usage = StandardizedUsage::new();
|
||||
standardized_usage.input_tokens = input_tokens;
|
||||
standardized_usage.output_tokens = output_tokens;
|
||||
standardized_usage.cache_creation_tokens = cache_creation_tokens;
|
||||
standardized_usage.cache_read_tokens = cache_read_tokens;
|
||||
standardized_usage
|
||||
.dimensions
|
||||
.insert("total_tokens".to_string(), json!(total_tokens));
|
||||
Some(standardized_usage.normalize_cache_creation_breakdown())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{maybe_bridge_standard_sync_json_to_stream, standardized_usage_from_openai_usage};
|
||||
|
||||
fn utf8(bytes: Vec<u8>) -> String {
|
||||
String::from_utf8(bytes).expect("utf8 should decode")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_sync_usage_derives_missing_input_tokens_from_total() {
|
||||
let usage = standardized_usage_from_openai_usage(&json!({
|
||||
"output_tokens": 177,
|
||||
"total_tokens": 20_612,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 19_840,
|
||||
},
|
||||
}))
|
||||
.expect("usage should parse");
|
||||
|
||||
assert_eq!(usage.input_tokens, 20_435);
|
||||
assert_eq!(usage.output_tokens, 177);
|
||||
assert_eq!(usage.cache_read_tokens, 19_840);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridges_openai_image_sync_json_to_generation_completed_sse() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "openai:image",
|
||||
"client_api_format": "openai:image",
|
||||
"mapped_model": "gpt-image-1",
|
||||
"image_request": {
|
||||
"operation": "generate"
|
||||
}
|
||||
});
|
||||
let outcome = maybe_bridge_standard_sync_json_to_stream(
|
||||
&json!({
|
||||
"created": 1776971267,
|
||||
"data": [{
|
||||
"b64_json": "aGVsbG8="
|
||||
}],
|
||||
"usage": {
|
||||
"total_tokens": 100,
|
||||
"input_tokens": 50,
|
||||
"output_tokens": 50,
|
||||
"input_tokens_details": {
|
||||
"text_tokens": 10,
|
||||
"image_tokens": 40
|
||||
}
|
||||
}
|
||||
}),
|
||||
"openai:image",
|
||||
"openai:image",
|
||||
Some(&report_context),
|
||||
)
|
||||
.expect("bridge should succeed")
|
||||
.expect("bridge should produce sse");
|
||||
|
||||
let output = utf8(outcome.sse_body);
|
||||
assert!(output.contains("event: image_generation.completed"));
|
||||
assert!(output.contains("\"type\":\"image_generation.completed\""));
|
||||
assert!(output.contains("\"b64_json\":\"aGVsbG8=\""));
|
||||
assert!(output.contains("\"total_tokens\":100"));
|
||||
|
||||
let summary = outcome
|
||||
.terminal_summary
|
||||
.expect("terminal summary should exist");
|
||||
assert_eq!(summary.model.as_deref(), Some("gpt-image-1"));
|
||||
assert_eq!(summary.finish_reason.as_deref(), Some("stop"));
|
||||
assert_eq!(
|
||||
summary
|
||||
.standardized_usage
|
||||
.as_ref()
|
||||
.and_then(|usage| usage.dimensions.get("total_tokens"))
|
||||
.cloned(),
|
||||
Some(json!(100))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridges_openai_image_sync_data_url_to_edit_completed_sse() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "openai:image",
|
||||
"client_api_format": "openai:image",
|
||||
"image_request": {
|
||||
"operation": "edit"
|
||||
}
|
||||
});
|
||||
let outcome = maybe_bridge_standard_sync_json_to_stream(
|
||||
&json!({
|
||||
"created": 1776971267,
|
||||
"data": [{
|
||||
"url": "data:image/webp;base64,d29ybGQ="
|
||||
}],
|
||||
"usage": {
|
||||
"total_tokens": 9,
|
||||
"input_tokens": 4,
|
||||
"output_tokens": 5
|
||||
}
|
||||
}),
|
||||
"openai:image",
|
||||
"openai:image",
|
||||
Some(&report_context),
|
||||
)
|
||||
.expect("bridge should succeed")
|
||||
.expect("bridge should produce sse");
|
||||
|
||||
let output = utf8(outcome.sse_body);
|
||||
assert!(output.contains("event: image_edit.completed"));
|
||||
assert!(output.contains("\"type\":\"image_edit.completed\""));
|
||||
assert!(output.contains("\"b64_json\":\"d29ybGQ=\""));
|
||||
assert!(output.contains("\"total_tokens\":9"));
|
||||
}
|
||||
}
|
||||
34
crates/aether-ai-formats/src/formats/shared/video.rs
Normal file
34
crates/aether-ai-formats/src/formats/shared/video.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LocalVideoCreateFamily {
|
||||
OpenAi,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LocalVideoCreateSpec {
|
||||
pub api_format: &'static str,
|
||||
pub decision_kind: &'static str,
|
||||
pub report_kind: &'static str,
|
||||
pub family: LocalVideoCreateFamily,
|
||||
}
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalVideoCreateSpec> {
|
||||
crate::formats::openai::video::spec::resolve_sync_spec(plan_kind)
|
||||
.or_else(|| crate::formats::gemini::video::spec::resolve_sync_spec(plan_kind))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_sync_spec, LocalVideoCreateFamily};
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_and_gemini_video_create_specs() {
|
||||
let openai = resolve_sync_spec("openai_video_create_sync").expect("openai spec");
|
||||
assert_eq!(openai.api_format, "openai:video");
|
||||
assert_eq!(openai.family, LocalVideoCreateFamily::OpenAi);
|
||||
|
||||
let gemini = resolve_sync_spec("gemini_video_create_sync").expect("gemini spec");
|
||||
assert_eq!(gemini.api_format, "gemini:video");
|
||||
assert_eq!(gemini.family, LocalVideoCreateFamily::Gemini);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user