refactor ai formats registry adapters

This commit is contained in:
fawney19
2026-04-26 22:30:11 +08:00
parent 5cd2244bc2
commit c10cd8240e
17 changed files with 344 additions and 166 deletions

View 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 {}

View File

@@ -1,2 +1,9 @@
//! Legacy pairwise conversion exports.
//!
//! Primary routing lives under `crate::formats::<wire_format>` and must pass
//! through canonical IR. This module remains so older pipeline/gateway call
//! sites and focused golden tests can keep their existing function names while
//! the cleanup proceeds.
pub mod request;
pub mod response;

View File

@@ -1,5 +1,16 @@
//! Format identity and per-wire-format adapters.
//!
//! Each child module owns the boundary between one external wire shape and
//! the canonical IR. Registry conversion is intentionally constrained to:
//! source format -> canonical -> target format.
use std::{fmt, str::FromStr};
pub mod claude_messages;
pub mod gemini_generate_content;
pub mod openai_chat;
pub mod openai_responses;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum FormatFamily {
OpenAi,

View File

@@ -0,0 +1,2 @@
pub mod request;
pub mod response;

View File

@@ -0,0 +1,18 @@
use serde_json::Value;
use crate::{
canonical::{canonical_to_claude_request, from_claude_to_canonical_request, CanonicalRequest},
context::FormatContext,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
from_claude_to_canonical_request(body)
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
canonical_to_claude_request(
request,
ctx.mapped_model_or(request.model.as_str()),
ctx.upstream_is_stream,
)
}

View File

@@ -0,0 +1,16 @@
use serde_json::Value;
use crate::{
canonical::{
canonical_to_claude_response, from_claude_to_canonical_response, CanonicalResponse,
},
context::FormatContext,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
from_claude_to_canonical_response(body)
}
pub fn to(response: &CanonicalResponse, _ctx: &FormatContext) -> Option<Value> {
Some(canonical_to_claude_response(response))
}

View File

@@ -0,0 +1,2 @@
pub mod request;
pub mod response;

View File

@@ -0,0 +1,18 @@
use serde_json::Value;
use crate::{
canonical::{canonical_to_gemini_request, from_gemini_to_canonical_request, CanonicalRequest},
context::FormatContext,
};
pub fn from(body: &Value, ctx: &FormatContext) -> Option<CanonicalRequest> {
from_gemini_to_canonical_request(body, ctx.request_path.as_deref().unwrap_or_default())
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
canonical_to_gemini_request(
request,
ctx.mapped_model_or(request.model.as_str()),
ctx.upstream_is_stream,
)
}

View File

@@ -0,0 +1,16 @@
use serde_json::Value;
use crate::{
canonical::{
canonical_to_gemini_response, from_gemini_to_canonical_response, CanonicalResponse,
},
context::FormatContext,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
from_gemini_to_canonical_response(body)
}
pub fn to(response: &CanonicalResponse, ctx: &FormatContext) -> Option<Value> {
canonical_to_gemini_response(response, &ctx.report_context_value())
}

View File

@@ -0,0 +1,2 @@
pub mod request;
pub mod response;

View File

@@ -0,0 +1,41 @@
use serde_json::{json, Value};
use crate::{
canonical::{
canonical_to_openai_chat_request, from_openai_chat_to_canonical_request, CanonicalRequest,
},
context::FormatContext,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
from_openai_chat_to_canonical_request(body)
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
let mut body = canonical_to_openai_chat_request(request);
force_stream_options(&mut body, ctx.upstream_is_stream);
Some(body)
}
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,
}),
);
}
}
}

View File

@@ -0,0 +1,29 @@
use serde_json::Value;
use crate::{
canonical::{
canonical_to_openai_chat_response, from_openai_chat_to_canonical_response,
CanonicalResponse,
},
context::FormatContext,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
from_openai_chat_to_canonical_response(body)
}
pub fn to(response: &CanonicalResponse, ctx: &FormatContext) -> Option<Value> {
let mut body = canonical_to_openai_chat_response(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)
}

View File

@@ -0,0 +1,2 @@
pub mod request;
pub mod response;

View File

@@ -0,0 +1,28 @@
use serde_json::Value;
use crate::{
canonical::{
canonical_to_openai_responses_compact_request, canonical_to_openai_responses_request,
from_openai_responses_to_canonical_request, CanonicalRequest,
},
context::FormatContext,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
from_openai_responses_to_canonical_request(body)
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
canonical_to_openai_responses_request(
request,
ctx.mapped_model_or(request.model.as_str()),
ctx.upstream_is_stream,
)
}
pub fn to_compact(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
canonical_to_openai_responses_compact_request(
request,
ctx.mapped_model_or(request.model.as_str()),
)
}

View File

@@ -0,0 +1,27 @@
use serde_json::Value;
use crate::{
canonical::{
canonical_to_openai_responses_compact_response, canonical_to_openai_responses_response,
from_openai_responses_to_canonical_response, CanonicalResponse,
},
context::FormatContext,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
from_openai_responses_to_canonical_response(body)
}
pub fn to(response: &CanonicalResponse, ctx: &FormatContext) -> Option<Value> {
Some(canonical_to_openai_responses_response(
response,
&ctx.report_context_value(),
))
}
pub fn to_compact(response: &CanonicalResponse, ctx: &FormatContext) -> Option<Value> {
Some(canonical_to_openai_responses_compact_response(
response,
&ctx.report_context_value(),
))
}

View File

@@ -1,4 +1,5 @@
pub mod canonical;
pub mod context;
pub mod conversion;
pub mod formats;
pub mod planner;
@@ -22,12 +23,11 @@ pub use canonical::{
CanonicalRole, CanonicalStopReason, CanonicalStreamEvent, CanonicalStreamFrame,
CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition, CanonicalUsage,
};
pub use context::{FormatContext, FormatError};
pub use formats::{
is_openai_responses_compact_format, is_openai_responses_family_format,
is_openai_responses_format, legacy_openai_format_alias_matches,
normalize_legacy_openai_format_alias, openai_format_storage_aliases, FormatFamily, FormatId,
FormatProfile,
};
pub use registry::{
build_stream_transcoder, convert_request, convert_response, FormatContext, FormatError,
};
pub use registry::{build_stream_transcoder, convert_request, convert_response};

View File

@@ -1,93 +1,11 @@
use std::{error::Error, fmt};
use serde_json::{json, Value};
use serde_json::Value;
use crate::{
canonical::{
canonical_to_claude_request, canonical_to_claude_response, canonical_to_gemini_request,
canonical_to_gemini_response, canonical_to_openai_chat_request,
canonical_to_openai_chat_response, canonical_to_openai_responses_compact_request,
canonical_to_openai_responses_compact_response, canonical_to_openai_responses_request,
canonical_to_openai_responses_response, from_claude_to_canonical_request,
from_claude_to_canonical_response, from_gemini_to_canonical_request,
from_gemini_to_canonical_response, from_openai_chat_to_canonical_request,
from_openai_chat_to_canonical_response, from_openai_responses_to_canonical_request,
from_openai_responses_to_canonical_response, CanonicalRequest, CanonicalResponse,
},
formats::FormatId,
canonical::{CanonicalRequest, CanonicalResponse},
formats::{claude_messages, gemini_generate_content, openai_chat, openai_responses, FormatId},
};
#[derive(Debug, Clone, Default)]
pub struct FormatContext {
pub mapped_model: Option<String>,
pub request_path: Option<String>,
pub upstream_is_stream: bool,
pub report_context: Option<Value>,
}
impl FormatContext {
pub fn with_mapped_model(mut self, mapped_model: impl Into<String>) -> Self {
self.mapped_model = Some(mapped_model.into());
self
}
pub fn with_request_path(mut self, request_path: impl Into<String>) -> Self {
self.request_path = Some(request_path.into());
self
}
pub fn with_upstream_stream(mut self, upstream_is_stream: bool) -> Self {
self.upstream_is_stream = upstream_is_stream;
self
}
pub fn with_report_context(mut self, report_context: Value) -> Self {
self.report_context = Some(report_context);
self
}
fn mapped_model_or<'a>(&'a self, fallback: &'a str) -> &'a str {
self.mapped_model
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or(fallback)
}
fn report_context_value(&self) -> Value {
self.report_context.clone().unwrap_or_else(|| {
json!({
"mapped_model": self.mapped_model,
})
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FormatError {
UnsupportedFormat(String),
RequestParseFailed { format: String },
RequestEmitFailed { format: String },
ResponseParseFailed { format: String },
ResponseEmitFailed { format: String },
}
impl fmt::Display for FormatError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnsupportedFormat(format) => write!(f, "unsupported AI format: {format}"),
Self::RequestParseFailed { format } => {
write!(f, "failed to parse {format} request")
}
Self::RequestEmitFailed { format } => write!(f, "failed to emit {format} request"),
Self::ResponseParseFailed { format } => {
write!(f, "failed to parse {format} response")
}
Self::ResponseEmitFailed { format } => write!(f, "failed to emit {format} response"),
}
}
}
impl Error for FormatError {}
pub use crate::context::{FormatContext, FormatError};
pub fn parse_request(
source_format: &str,
@@ -96,14 +14,12 @@ pub fn parse_request(
) -> Result<CanonicalRequest, FormatError> {
let source = parse_format(source_format)?;
match source {
FormatId::OpenAiChat => from_openai_chat_to_canonical_request(body),
FormatId::OpenAiChat => openai_chat::request::from(body, ctx),
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact => {
from_openai_responses_to_canonical_request(body)
}
FormatId::ClaudeMessages => from_claude_to_canonical_request(body),
FormatId::GeminiGenerateContent => {
from_gemini_to_canonical_request(body, ctx.request_path.as_deref().unwrap_or_default())
openai_responses::request::from(body, ctx)
}
FormatId::ClaudeMessages => claude_messages::request::from(body, ctx),
FormatId::GeminiGenerateContent => gemini_generate_content::request::from(body, ctx),
}
.ok_or_else(|| FormatError::RequestParseFailed {
format: source.as_str().to_string(),
@@ -124,25 +40,12 @@ pub fn emit_request(
{
request.model = mapped_model.to_string();
}
let mapped_model = ctx.mapped_model_or(request.model.as_str());
match target {
FormatId::OpenAiChat => {
let mut body = canonical_to_openai_chat_request(&request);
force_openai_chat_stream_options(&mut body, ctx.upstream_is_stream);
Some(body)
}
FormatId::OpenAiResponses => {
canonical_to_openai_responses_request(&request, mapped_model, ctx.upstream_is_stream)
}
FormatId::OpenAiResponsesCompact => {
canonical_to_openai_responses_compact_request(&request, mapped_model)
}
FormatId::ClaudeMessages => {
canonical_to_claude_request(&request, mapped_model, ctx.upstream_is_stream)
}
FormatId::GeminiGenerateContent => {
canonical_to_gemini_request(&request, mapped_model, ctx.upstream_is_stream)
}
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),
}
.ok_or_else(|| FormatError::RequestEmitFailed {
format: target.as_str().to_string(),
@@ -162,16 +65,16 @@ pub fn convert_request(
pub fn parse_response(
source_format: &str,
body: &Value,
_ctx: &FormatContext,
ctx: &FormatContext,
) -> Result<CanonicalResponse, FormatError> {
let source = parse_format(source_format)?;
match source {
FormatId::OpenAiChat => from_openai_chat_to_canonical_response(body),
FormatId::OpenAiChat => openai_chat::response::from(body, ctx),
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact => {
from_openai_responses_to_canonical_response(body)
openai_responses::response::from(body, ctx)
}
FormatId::ClaudeMessages => from_claude_to_canonical_response(body),
FormatId::GeminiGenerateContent => from_gemini_to_canonical_response(body),
FormatId::ClaudeMessages => claude_messages::response::from(body, ctx),
FormatId::GeminiGenerateContent => gemini_generate_content::response::from(body, ctx),
}
.ok_or_else(|| FormatError::ResponseParseFailed {
format: source.as_str().to_string(),
@@ -184,32 +87,12 @@ pub fn emit_response(
ctx: &FormatContext,
) -> Result<Value, FormatError> {
let target = parse_format(target_format)?;
let report_context = ctx.report_context_value();
match target {
FormatId::OpenAiChat => {
let mut response = canonical_to_openai_chat_response(response);
if response.get("service_tier").is_none() {
if let Some(service_tier) = report_context
.get("original_request_body")
.and_then(Value::as_object)
.and_then(|request| request.get("service_tier"))
.cloned()
{
response["service_tier"] = service_tier;
}
}
Some(response)
}
FormatId::OpenAiResponses => Some(canonical_to_openai_responses_response(
response,
&report_context,
)),
FormatId::OpenAiResponsesCompact => Some(canonical_to_openai_responses_compact_response(
response,
&report_context,
)),
FormatId::ClaudeMessages => Some(canonical_to_claude_response(response)),
FormatId::GeminiGenerateContent => canonical_to_gemini_response(response, &report_context),
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),
}
.ok_or_else(|| FormatError::ResponseEmitFailed {
format: target.as_str().to_string(),
@@ -256,29 +139,6 @@ fn parse_format(format: &str) -> Result<FormatId, FormatError> {
FormatId::parse(format).ok_or_else(|| FormatError::UnsupportedFormat(format.to_string()))
}
fn force_openai_chat_stream_options(body: &mut Value, upstream_is_stream: bool) {
if !upstream_is_stream {
return;
}
let Some(object) = body.as_object_mut() else {
return;
};
object.insert("stream".to_string(), Value::Bool(true));
match object.get_mut("stream_options") {
Some(Value::Object(stream_options)) => {
stream_options.insert("include_usage".to_string(), Value::Bool(true));
}
_ => {
object.insert(
"stream_options".to_string(),
json!({
"include_usage": true,
}),
);
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
@@ -309,4 +169,26 @@ mod tests {
assert_eq!(converted["input"][0]["type"], "message");
assert_eq!(converted["input"][0]["content"][0]["type"], "input_text");
}
#[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::<format> adapters, found {forbidden}"
);
}
}
}