mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +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:
@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::request::openai::map_thinking_budget_to_openai_reasoning_effort;
|
||||
use crate::formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort;
|
||||
|
||||
pub use crate::protocol::stream::{CanonicalStreamEvent, CanonicalStreamFrame};
|
||||
|
||||
@@ -232,7 +232,7 @@ pub enum CanonicalEmbeddingInput {
|
||||
}
|
||||
|
||||
impl CanonicalEmbeddingInput {
|
||||
fn is_empty(&self) -> bool {
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
match self {
|
||||
Self::String(value) => value.trim().is_empty(),
|
||||
Self::StringArray(values) => {
|
||||
@@ -243,7 +243,7 @@ impl CanonicalEmbeddingInput {
|
||||
}
|
||||
}
|
||||
|
||||
fn as_string_items(&self) -> Option<Vec<&str>> {
|
||||
pub(crate) fn as_string_items(&self) -> Option<Vec<&str>> {
|
||||
match self {
|
||||
Self::String(value) => Some(vec![value.as_str()]),
|
||||
Self::StringArray(values) => Some(values.iter().map(String::as_str).collect()),
|
||||
@@ -281,7 +281,7 @@ pub struct CanonicalRerankRequest {
|
||||
}
|
||||
|
||||
impl CanonicalRerankRequest {
|
||||
fn is_empty(&self) -> bool {
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.query.trim().is_empty()
|
||||
|| self.documents.is_empty()
|
||||
|| self.documents.iter().any(rerank_document_is_empty)
|
||||
@@ -385,15 +385,15 @@ pub struct CanonicalResponse {
|
||||
}
|
||||
|
||||
pub fn from_openai_chat_to_canonical_request(body_json: &Value) -> Option<CanonicalRequest> {
|
||||
crate::protocol::formats::openai_chat::request::from_raw(body_json)
|
||||
crate::formats::openai::chat::request::from_raw(body_json)
|
||||
}
|
||||
|
||||
pub fn canonical_to_openai_chat_request(canonical: &CanonicalRequest) -> Value {
|
||||
crate::protocol::formats::openai_chat::request::to_raw(canonical)
|
||||
crate::formats::openai::chat::request::to_raw(canonical)
|
||||
}
|
||||
|
||||
pub fn from_openai_responses_to_canonical_request(body_json: &Value) -> Option<CanonicalRequest> {
|
||||
crate::protocol::formats::openai_responses::request::from_raw(body_json)
|
||||
crate::formats::openai::responses::request::from_raw(body_json)
|
||||
}
|
||||
|
||||
pub(crate) fn canonical_to_openai_responses_request_with_profile(
|
||||
@@ -402,7 +402,7 @@ pub(crate) fn canonical_to_openai_responses_request_with_profile(
|
||||
upstream_is_stream: bool,
|
||||
compact: bool,
|
||||
) -> Option<Value> {
|
||||
crate::protocol::formats::openai_responses::request::to_raw(
|
||||
crate::formats::openai::responses::request::to_raw(
|
||||
canonical,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
@@ -431,7 +431,7 @@ pub fn canonical_to_openai_responses_compact_request(
|
||||
}
|
||||
|
||||
pub fn from_claude_to_canonical_request(body_json: &Value) -> Option<CanonicalRequest> {
|
||||
crate::protocol::formats::claude_messages::request::from_raw(body_json)
|
||||
crate::formats::claude::messages::request::from_raw(body_json)
|
||||
}
|
||||
|
||||
pub fn canonical_to_claude_request(
|
||||
@@ -439,18 +439,14 @@ pub fn canonical_to_claude_request(
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
crate::protocol::formats::claude_messages::request::to_raw(
|
||||
canonical,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
)
|
||||
crate::formats::claude::messages::request::to_raw(canonical, mapped_model, upstream_is_stream)
|
||||
}
|
||||
|
||||
pub fn from_gemini_to_canonical_request(
|
||||
body_json: &Value,
|
||||
request_path: &str,
|
||||
) -> Option<CanonicalRequest> {
|
||||
crate::protocol::formats::gemini_generate_content::request::from_raw(body_json, request_path)
|
||||
crate::formats::gemini::generate_content::request::from_raw(body_json, request_path)
|
||||
}
|
||||
|
||||
pub fn canonical_to_gemini_request(
|
||||
@@ -458,72 +454,59 @@ pub fn canonical_to_gemini_request(
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
crate::protocol::formats::gemini_generate_content::request::to_raw(
|
||||
crate::formats::gemini::generate_content::request::to_raw(
|
||||
canonical,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_embedding_to_canonical_request(
|
||||
body_json: &Value,
|
||||
namespace: &str,
|
||||
) -> Option<CanonicalRequest> {
|
||||
embedding_request_from_raw(body_json, namespace)
|
||||
match namespace {
|
||||
"openai" => crate::formats::openai::embedding::request::from_namespace(body_json, "openai"),
|
||||
"jina" => crate::formats::openai::embedding::request::from_namespace(body_json, "jina"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn canonical_to_embedding_request(
|
||||
canonical: &CanonicalRequest,
|
||||
mapped_model: &str,
|
||||
namespace: &str,
|
||||
) -> Option<Value> {
|
||||
let ctx = crate::formats::context::FormatContext::default().with_mapped_model(mapped_model);
|
||||
match namespace {
|
||||
"openai" => canonical_to_openai_embedding_request(canonical, mapped_model),
|
||||
"jina" => canonical_to_jina_embedding_request(canonical, mapped_model),
|
||||
"gemini" => canonical_to_gemini_embedding_request(canonical, mapped_model),
|
||||
"doubao" => canonical_to_doubao_embedding_request(canonical, mapped_model),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_rerank_to_canonical_request(
|
||||
body_json: &Value,
|
||||
namespace: &str,
|
||||
) -> Option<CanonicalRequest> {
|
||||
rerank_request_from_raw(body_json, namespace)
|
||||
}
|
||||
|
||||
pub(crate) fn canonical_to_rerank_request(
|
||||
canonical: &CanonicalRequest,
|
||||
mapped_model: &str,
|
||||
namespace: &str,
|
||||
) -> Option<Value> {
|
||||
match namespace {
|
||||
"openai" | "jina" => {
|
||||
canonical_to_openai_like_rerank_request(canonical, mapped_model, namespace)
|
||||
}
|
||||
"openai" => crate::formats::openai::embedding::request::to(canonical, &ctx),
|
||||
"jina" => crate::formats::jina::embedding::request::to(canonical, &ctx),
|
||||
"gemini" => crate::formats::gemini::embedding::request::to(canonical, &ctx),
|
||||
"doubao" => crate::formats::doubao::embedding::request::to(canonical, &ctx),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_openai_chat_to_canonical_response(body_json: &Value) -> Option<CanonicalResponse> {
|
||||
crate::protocol::formats::openai_chat::response::from_raw(body_json)
|
||||
crate::formats::openai::chat::response::from_raw(body_json)
|
||||
}
|
||||
|
||||
pub fn from_openai_responses_to_canonical_response(body_json: &Value) -> Option<CanonicalResponse> {
|
||||
crate::protocol::formats::openai_responses::response::from_raw(body_json)
|
||||
crate::formats::openai::responses::response::from_raw(body_json)
|
||||
}
|
||||
|
||||
pub fn from_claude_to_canonical_response(body_json: &Value) -> Option<CanonicalResponse> {
|
||||
crate::protocol::formats::claude_messages::response::from_raw(body_json)
|
||||
crate::formats::claude::messages::response::from_raw(body_json)
|
||||
}
|
||||
|
||||
pub fn from_gemini_to_canonical_response(body_json: &Value) -> Option<CanonicalResponse> {
|
||||
crate::protocol::formats::gemini_generate_content::response::from_raw(body_json)
|
||||
crate::formats::gemini::generate_content::response::from_raw(body_json)
|
||||
}
|
||||
|
||||
pub fn canonical_to_openai_chat_response(canonical: &CanonicalResponse) -> Value {
|
||||
crate::protocol::formats::openai_chat::response::to_raw(canonical)
|
||||
crate::formats::openai::chat::response::to_raw(canonical)
|
||||
}
|
||||
|
||||
pub(crate) fn canonical_blocks_to_openai_chat_message(content: &[CanonicalContentBlock]) -> Value {
|
||||
@@ -661,7 +644,7 @@ pub(crate) fn canonical_to_openai_responses_response_with_profile(
|
||||
report_context: &Value,
|
||||
compact: bool,
|
||||
) -> Value {
|
||||
crate::protocol::formats::openai_responses::response::to_raw(canonical, report_context, compact)
|
||||
crate::formats::openai::responses::response::to_raw(canonical, report_context, compact)
|
||||
}
|
||||
|
||||
pub fn canonical_to_openai_responses_response(
|
||||
@@ -679,21 +662,27 @@ pub fn canonical_to_openai_responses_compact_response(
|
||||
}
|
||||
|
||||
pub fn canonical_to_claude_response(canonical: &CanonicalResponse) -> Value {
|
||||
crate::protocol::formats::claude_messages::response::to_raw(canonical)
|
||||
crate::formats::claude::messages::response::to_raw(canonical)
|
||||
}
|
||||
|
||||
pub fn canonical_to_gemini_response(
|
||||
canonical: &CanonicalResponse,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
crate::protocol::formats::gemini_generate_content::response::to_raw(canonical, report_context)
|
||||
crate::formats::gemini::generate_content::response::to_raw(canonical, report_context)
|
||||
}
|
||||
|
||||
pub fn from_embedding_to_canonical_response(
|
||||
body_json: &Value,
|
||||
namespace: &str,
|
||||
) -> Option<CanonicalEmbeddingResponse> {
|
||||
embedding_response_from_raw(body_json, namespace)
|
||||
match namespace {
|
||||
"openai" => {
|
||||
crate::formats::openai::embedding::response::from_namespace(body_json, "openai")
|
||||
}
|
||||
"jina" => crate::formats::openai::embedding::response::from_namespace(body_json, "jina"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn canonical_to_embedding_response(
|
||||
@@ -701,7 +690,8 @@ pub fn canonical_to_embedding_response(
|
||||
namespace: &str,
|
||||
) -> Option<Value> {
|
||||
match namespace {
|
||||
"openai" | "jina" => Some(canonical_to_openai_embedding_response(canonical, namespace)),
|
||||
"openai" => crate::formats::openai::embedding::response::to(canonical),
|
||||
"jina" => crate::formats::jina::embedding::response::to(canonical),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -4273,140 +4263,6 @@ pub(crate) fn strip_claude_billing_header(text: &str) -> String {
|
||||
remainder.trim_start_matches('\n').trim().to_string()
|
||||
}
|
||||
|
||||
fn embedding_request_from_raw(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()
|
||||
})
|
||||
}
|
||||
|
||||
fn rerank_request_from_raw(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()
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_to_openai_like_rerank_request(
|
||||
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 rerank_document_is_empty(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::String(text) => text.trim().is_empty(),
|
||||
@@ -4419,265 +4275,6 @@ fn rerank_document_is_empty(value: &Value) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn mapped_rerank_model(canonical: &CanonicalRequest, mapped_model: &str) -> String {
|
||||
mapped_model
|
||||
.trim()
|
||||
.chars()
|
||||
.next()
|
||||
.map(|_| mapped_model.trim().to_string())
|
||||
.unwrap_or_else(|| canonical.model.clone())
|
||||
}
|
||||
|
||||
fn canonical_to_openai_embedding_request(
|
||||
canonical: &CanonicalRequest,
|
||||
mapped_model: &str,
|
||||
) -> Option<Value> {
|
||||
canonical_to_openai_like_embedding_request(canonical, mapped_model, "openai", false)
|
||||
}
|
||||
|
||||
fn canonical_to_jina_embedding_request(
|
||||
canonical: &CanonicalRequest,
|
||||
mapped_model: &str,
|
||||
) -> Option<Value> {
|
||||
canonical_to_openai_like_embedding_request(canonical, mapped_model, "jina", true)
|
||||
}
|
||||
|
||||
fn canonical_to_openai_like_embedding_request(
|
||||
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))
|
||||
}
|
||||
|
||||
fn canonical_to_gemini_embedding_request(
|
||||
canonical: &CanonicalRequest,
|
||||
mapped_model: &str,
|
||||
) -> Option<Value> {
|
||||
let embedding = canonical.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(canonical, mapped_model);
|
||||
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<_>>()
|
||||
}))
|
||||
}
|
||||
|
||||
fn canonical_to_doubao_embedding_request(
|
||||
canonical: &CanonicalRequest,
|
||||
mapped_model: &str,
|
||||
) -> Option<Value> {
|
||||
let embedding = canonical.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(canonical, mapped_model)),
|
||||
);
|
||||
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))
|
||||
}
|
||||
|
||||
fn embedding_response_from_raw(
|
||||
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"],
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_to_openai_embedding_response(
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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))])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
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 {}
|
||||
@@ -1,8 +0,0 @@
|
||||
//! Pairwise format conversion entry points.
|
||||
//!
|
||||
//! The public helpers in this module route through the registry, so request and
|
||||
//! response conversion still pass through the typed canonical IR before a target
|
||||
//! wire format is emitted.
|
||||
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
@@ -1,218 +0,0 @@
|
||||
//! 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::protocol::{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");
|
||||
}
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
//! 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::protocol::{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");
|
||||
}
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
//! 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,
|
||||
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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
@@ -1,191 +0,0 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::{
|
||||
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,
|
||||
},
|
||||
protocol::context::FormatContext,
|
||||
request::{
|
||||
model_directives::claude_model_uses_adaptive_effort,
|
||||
openai::{
|
||||
map_openai_reasoning_effort_to_claude_output,
|
||||
map_openai_reasoning_effort_to_thinking_budget,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
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,
|
||||
},
|
||||
protocol::context::FormatContext,
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
@@ -1,689 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::{
|
||||
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,
|
||||
},
|
||||
protocol::context::FormatContext,
|
||||
request::{
|
||||
model_directives::{gemini_model_uses_thinking_level, ReasoningEffort},
|
||||
openai::{
|
||||
map_openai_reasoning_effort_to_gemini_budget,
|
||||
map_thinking_budget_to_openai_reasoning_effort,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::{
|
||||
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,
|
||||
},
|
||||
protocol::context::FormatContext,
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
@@ -1,243 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
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,
|
||||
},
|
||||
protocol::context::FormatContext,
|
||||
};
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
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,
|
||||
},
|
||||
protocol::context::FormatContext,
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
@@ -1,513 +0,0 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::{
|
||||
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,
|
||||
},
|
||||
protocol::context::FormatContext,
|
||||
request::openai::map_thinking_budget_to_openai_reasoning_effort,
|
||||
};
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::{
|
||||
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,
|
||||
},
|
||||
protocol::context::FormatContext,
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,674 +0,0 @@
|
||||
use crate::{
|
||||
api_format_alias_matches,
|
||||
protocol::formats::{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"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,2 @@
|
||||
pub mod canonical;
|
||||
pub mod context;
|
||||
pub mod conversion;
|
||||
pub mod formats;
|
||||
pub mod matrix;
|
||||
pub mod registry;
|
||||
pub mod stream;
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
protocol::canonical::{
|
||||
canonical_to_embedding_request, canonical_to_rerank_request,
|
||||
from_embedding_to_canonical_request, from_rerank_to_canonical_request, CanonicalRequest,
|
||||
CanonicalResponse,
|
||||
},
|
||||
protocol::formats::{
|
||||
claude_messages, gemini_generate_content, openai_chat, openai_responses, FormatId,
|
||||
},
|
||||
};
|
||||
|
||||
pub use crate::protocol::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 => from_embedding_to_canonical_request(body, "openai"),
|
||||
FormatId::JinaEmbedding => from_embedding_to_canonical_request(body, "jina"),
|
||||
FormatId::OpenAiRerank => from_rerank_to_canonical_request(body, "openai"),
|
||||
FormatId::JinaRerank => from_rerank_to_canonical_request(body, "jina"),
|
||||
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 => canonical_to_embedding_request(
|
||||
&request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
"openai",
|
||||
),
|
||||
FormatId::JinaEmbedding => canonical_to_embedding_request(
|
||||
&request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
"jina",
|
||||
),
|
||||
FormatId::OpenAiRerank => canonical_to_rerank_request(
|
||||
&request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
"openai",
|
||||
),
|
||||
FormatId::JinaRerank => canonical_to_rerank_request(
|
||||
&request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
"jina",
|
||||
),
|
||||
FormatId::GeminiEmbedding => canonical_to_embedding_request(
|
||||
&request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
"gemini",
|
||||
),
|
||||
FormatId::DoubaoEmbedding => canonical_to_embedding_request(
|
||||
&request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
"doubao",
|
||||
),
|
||||
}
|
||||
.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::protocol::formats::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 protocol::formats::<format> adapters, found {forbidden}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user