Merge remote-tracking branch 'origin/pr/593'

This commit is contained in:
elky
2026-06-02 21:40:59 +08:00
56 changed files with 1605 additions and 42 deletions
@@ -0,0 +1,2 @@
pub mod request;
pub mod response;
@@ -0,0 +1,256 @@
use serde_json::{Map, Value};
use crate::formats::context::FormatContext;
use crate::formats::openai::embedding::request::mapped_embedding_model;
use crate::protocol::canonical::{
CanonicalEmbeddingContent, CanonicalEmbeddingInput, CanonicalRequest,
};
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
let embedding = request.embedding.as_ref()?;
let contents = embedding_input_to_contents(&embedding.input)?;
if contents.is_empty() {
return None;
}
let mut output = Map::new();
output.insert(
"model".to_string(),
Value::String(mapped_embedding_model(
request,
ctx.mapped_model_or(request.model.as_str()),
)),
);
output.insert(
"input".to_string(),
Value::Object(Map::from_iter([(
"contents".to_string(),
Value::Array(contents),
)])),
);
let mut parameters = embedding.parameters.clone().unwrap_or_default();
if let Some(dimensions) = embedding.dimensions {
parameters
.entry("dimension".to_string())
.or_insert_with(|| Value::from(dimensions));
}
if !parameters.is_empty() {
output.insert("parameters".to_string(), Value::Object(parameters));
}
Some(Value::Object(output))
}
fn embedding_input_to_contents(input: &CanonicalEmbeddingInput) -> Option<Vec<Value>> {
match input {
CanonicalEmbeddingInput::String(text) => {
non_empty_text_content(text).map(|content| vec![content])
}
CanonicalEmbeddingInput::StringArray(items) => items
.iter()
.map(|text| non_empty_text_content(text))
.collect(),
CanonicalEmbeddingInput::Multimodal(items) => {
items.iter().map(multimodal_content_to_value).collect()
}
CanonicalEmbeddingInput::TokenArray(_) | CanonicalEmbeddingInput::TokenArrayArray(_) => {
None
}
}
}
fn non_empty_text_content(text: &str) -> Option<Value> {
let text = text.trim();
if text.is_empty() {
None
} else {
Some(Value::Object(Map::from_iter([(
"text".to_string(),
Value::String(text.to_string()),
)])))
}
}
fn multimodal_content_to_value(content: &CanonicalEmbeddingContent) -> Option<Value> {
if content.is_empty() {
return None;
}
let mut object = Map::new();
if let Some(text) = content
.text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
object.insert("text".to_string(), Value::String(text.to_string()));
}
if let Some(image) = content
.image
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
object.insert("image".to_string(), Value::String(image.to_string()));
}
if let Some(video) = content
.video
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
object.insert("video".to_string(), Value::String(video.to_string()));
}
if let Some(multi_images) = content
.multi_images
.as_ref()
.filter(|values| !values.is_empty() && values.iter().all(|value| !value.trim().is_empty()))
{
object.insert(
"multi_images".to_string(),
Value::Array(
multi_images
.iter()
.map(|value| Value::String(value.trim().to_string()))
.collect(),
),
);
}
if object.is_empty() {
None
} else {
Some(Value::Object(object))
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use super::to;
use crate::formats::context::FormatContext;
use crate::protocol::canonical::{
CanonicalEmbeddingContent, CanonicalEmbeddingInput, CanonicalEmbeddingRequest,
CanonicalRequest,
};
fn canonical_embedding(input: CanonicalEmbeddingInput) -> CanonicalRequest {
CanonicalRequest {
model: "text-embedding-3-small".to_string(),
embedding: Some(CanonicalEmbeddingRequest {
input,
encoding_format: None,
dimensions: None,
task: None,
user: None,
parameters: None,
extensions: BTreeMap::new(),
}),
..CanonicalRequest::default()
}
}
#[test]
fn text_input_uses_dashscope_contents() {
let request = canonical_embedding(CanonicalEmbeddingInput::StringArray(vec![
"alpha".to_string(),
"beta".to_string(),
]));
let body = to(
&request,
&FormatContext::default().with_mapped_model("qwen3-vl-embedding"),
)
.expect("aliyun request");
assert_eq!(body["model"], "qwen3-vl-embedding");
assert_eq!(
body["input"]["contents"],
json!([{ "text": "alpha" }, { "text": "beta" }])
);
}
#[test]
fn multimodal_input_and_parameters_use_dashscope_contract() {
let mut request = canonical_embedding(CanonicalEmbeddingInput::Multimodal(vec![
CanonicalEmbeddingContent {
text: Some("white running shoes".to_string()),
image: None,
video: None,
multi_images: None,
},
CanonicalEmbeddingContent {
text: None,
image: Some("https://example.com/shoe.png".to_string()),
video: None,
multi_images: None,
},
CanonicalEmbeddingContent {
text: None,
image: None,
video: None,
multi_images: Some(vec![
"https://example.com/a.png".to_string(),
"https://example.com/b.png".to_string(),
]),
},
]));
let embedding = request.embedding.as_mut().expect("embedding request");
embedding.dimensions = Some(1024);
embedding.parameters = Some(Map::from_iter([
("enable_fusion".to_string(), Value::Bool(true)),
("res_level".to_string(), Value::from(2_u64)),
("max_video_frames".to_string(), Value::from(64_u64)),
]));
let body = to(
&request,
&FormatContext::default().with_mapped_model("qwen3-vl-embedding"),
)
.expect("aliyun request");
assert_eq!(
body["input"]["contents"],
json!([
{ "text": "white running shoes" },
{ "image": "https://example.com/shoe.png" },
{ "multi_images": ["https://example.com/a.png", "https://example.com/b.png"] }
])
);
assert_eq!(body["parameters"]["dimension"], 1024);
assert_eq!(body["parameters"]["enable_fusion"], true);
assert_eq!(body["parameters"]["res_level"], 2);
assert_eq!(body["parameters"]["max_video_frames"], 64);
}
#[test]
fn parameter_dimension_wins_over_openai_dimensions() {
let mut request = canonical_embedding(CanonicalEmbeddingInput::String("alpha".to_string()));
let embedding = request.embedding.as_mut().expect("embedding request");
embedding.dimensions = Some(1024);
embedding.parameters = Some(Map::from_iter([(
"dimension".to_string(),
Value::from(512_u64),
)]));
let body = to(
&request,
&FormatContext::default().with_mapped_model("qwen3-vl-embedding"),
)
.expect("aliyun request");
assert_eq!(body["parameters"]["dimension"], 512);
}
#[test]
fn token_arrays_are_not_convertible() {
let request = canonical_embedding(CanonicalEmbeddingInput::TokenArray(vec![1, 2, 3]));
assert!(to(
&request,
&FormatContext::default().with_mapped_model("qwen3-vl-embedding"),
)
.is_none());
}
}
@@ -0,0 +1,134 @@
use std::collections::BTreeMap;
use serde_json::{Map, Value};
use crate::formats::openai::embedding::request::namespace_extensions;
use crate::protocol::canonical::{CanonicalEmbedding, CanonicalEmbeddingResponse, CanonicalUsage};
pub fn from(body_json: &Value) -> Option<CanonicalEmbeddingResponse> {
let body = body_json.as_object()?;
if body.contains_key("error") || body.contains_key("code") && body.contains_key("message") {
return None;
}
let data = body
.get("output")?
.as_object()?
.get("embeddings")?
.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<_>>>()?;
let mut extensions =
namespace_extensions("aliyun", item_object, &["index", "embedding", "type"]);
if let Some(value) = item_object.get("type").cloned() {
extensions.insert(
"openai".to_string(),
Value::Object(Map::from_iter([("type".to_string(), value)])),
);
}
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,
});
}
let request_id = body.get("request_id").and_then(Value::as_str);
let mut extensions =
namespace_extensions("aliyun", body, &["output", "usage", "request_id", "model"]);
if let Some(request_id) = request_id {
extensions.insert(
"openai".to_string(),
Value::Object(Map::from_iter([(
"request_id".to_string(),
Value::String(request_id.to_string()),
)])),
);
}
Some(CanonicalEmbeddingResponse {
id: request_id.unwrap_or("aliyun-request-unknown").to_string(),
model: body
.get("model")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
embeddings,
usage: aliyun_usage_to_canonical(body.get("usage")),
extensions,
})
}
fn aliyun_usage_to_canonical(value: Option<&Value>) -> Option<CanonicalUsage> {
let usage = value?.as_object()?;
let input_tokens = usage
.get("input_tokens")
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.get("output_tokens")
.and_then(Value::as_u64)
.unwrap_or(0);
Some(CanonicalUsage {
input_tokens,
output_tokens,
total_tokens: usage
.get("total_tokens")
.and_then(Value::as_u64)
.unwrap_or(input_tokens.saturating_add(output_tokens)),
extensions: BTreeMap::from([("aliyun".to_string(), Value::Object(usage.clone()))]),
..CanonicalUsage::default()
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::from;
use crate::formats::openai::embedding::response::to as to_openai;
#[test]
fn parses_dashscope_embeddings_to_openai_compatible_shape() {
let body = json!({
"output": {
"embeddings": [
{
"index": 0,
"embedding": [0.1, 0.2, 0.3],
"type": "fused"
}
]
},
"usage": {
"input_tokens": 432,
"input_tokens_details": {
"image_tokens": 402,
"text_tokens": 30
},
"output_tokens": 1,
"total_tokens": 433
},
"request_id": "aliyun-request-1"
});
let canonical = from(&body).expect("aliyun response");
let emitted = to_openai(&canonical).expect("openai response");
assert_eq!(emitted["request_id"], "aliyun-request-1");
assert_eq!(emitted["data"][0]["embedding"], json!([0.1, 0.2, 0.3]));
assert_eq!(emitted["data"][0]["type"], "fused");
assert_eq!(emitted["usage"]["prompt_tokens"], 432);
assert_eq!(emitted["usage"]["completion_tokens"], 1);
assert_eq!(emitted["usage"]["total_tokens"], 433);
}
}
@@ -0,0 +1 @@
pub mod embedding;
@@ -113,6 +113,7 @@ mod tests {
dimensions: None,
task: None,
user: None,
parameters: None,
extensions: BTreeMap::new(),
}),
..CanonicalRequest::default()
+41 -2
View File
@@ -9,6 +9,7 @@ pub enum FormatFamily {
Gemini,
Jina,
Doubao,
Aliyun,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -30,6 +31,7 @@ pub enum FormatId {
JinaEmbedding,
JinaRerank,
DoubaoEmbedding,
AliyunMultimodalEmbedding,
}
impl FormatId {
@@ -52,6 +54,7 @@ impl FormatId {
Self::GeminiGenerateContent | Self::GeminiEmbedding => FormatFamily::Gemini,
Self::JinaEmbedding | Self::JinaRerank => FormatFamily::Jina,
Self::DoubaoEmbedding => FormatFamily::Doubao,
Self::AliyunMultimodalEmbedding => FormatFamily::Aliyun,
}
}
@@ -75,6 +78,7 @@ impl FormatId {
Self::JinaEmbedding => "jina:embedding",
Self::JinaRerank => "jina:rerank",
Self::DoubaoEmbedding => "doubao:embedding",
Self::AliyunMultimodalEmbedding => "aliyun:multimodal_embedding",
}
}
}
@@ -103,13 +107,22 @@ impl FromStr for FormatId {
"jina:embedding" | "/jina/v1/embeddings" => Ok(Self::JinaEmbedding),
"jina:rerank" | "/jina/v1/rerank" => Ok(Self::JinaRerank),
"doubao:embedding" => Ok(Self::DoubaoEmbedding),
"aliyun:multimodal_embedding"
| "aliyun_embedding"
| "aliyun_multimodal_embedding"
| "dashscope:multimodal_embedding"
| "dashscope_embedding"
| "dashscope_multimodal_embedding" => Ok(Self::AliyunMultimodalEmbedding),
_ => Err(()),
}
}
}
pub fn normalize_api_format_alias(value: &str) -> String {
value.trim().to_ascii_lowercase()
let normalized = value.trim().to_ascii_lowercase();
FormatId::parse(&normalized)
.map(|format| format.as_str().to_string())
.unwrap_or(normalized)
}
pub fn api_format_alias_matches(left: &str, right: &str) -> bool {
@@ -117,7 +130,13 @@ pub fn api_format_alias_matches(left: &str, right: &str) -> bool {
}
pub fn api_format_storage_aliases(value: &str) -> Vec<String> {
vec![normalize_api_format_alias(value)]
match FormatId::parse(value).map(FormatId::canonical) {
Some(FormatId::AliyunMultimodalEmbedding) => vec![
"aliyun:multimodal_embedding".to_string(),
"dashscope:multimodal_embedding".to_string(),
],
_ => vec![normalize_api_format_alias(value)],
}
}
pub fn is_openai_responses_format(value: &str) -> bool {
@@ -185,6 +204,18 @@ mod tests {
FormatId::parse("doubao:embedding"),
Some(FormatId::DoubaoEmbedding)
);
assert_eq!(
FormatId::parse("aliyun:multimodal_embedding").map(|format| format.to_string()),
Some("aliyun:multimodal_embedding".to_string())
);
assert_eq!(
FormatId::parse("dashscope:multimodal_embedding").map(|format| format.to_string()),
Some("aliyun:multimodal_embedding".to_string())
);
assert_eq!(
FormatId::parse("dashscope_embedding").map(|format| format.to_string()),
Some("aliyun:multimodal_embedding".to_string())
);
assert_eq!(FormatId::OpenAiEmbedding.to_string(), "openai:embedding");
}
@@ -197,6 +228,7 @@ mod tests {
(FormatId::GeminiEmbedding, FormatFamily::Gemini),
(FormatId::JinaEmbedding, FormatFamily::Jina),
(FormatId::DoubaoEmbedding, FormatFamily::Doubao),
(FormatId::AliyunMultimodalEmbedding, FormatFamily::Aliyun),
] {
assert_eq!(format.family(), family);
assert_eq!(format.profile(), FormatProfile::Default);
@@ -315,6 +347,13 @@ mod tests {
api_format_storage_aliases("doubao:embedding"),
vec!["doubao:embedding".to_string()]
);
assert_eq!(
api_format_storage_aliases("dashscope:multimodal_embedding"),
vec![
"aliyun:multimodal_embedding".to_string(),
"dashscope:multimodal_embedding".to_string(),
]
);
}
#[test]
+27 -4
View File
@@ -42,6 +42,7 @@ const EMBEDDING_CANDIDATE_API_FORMATS: &[&str] = &[
"gemini:embedding",
"jina:embedding",
"doubao:embedding",
"aliyun:multimodal_embedding",
];
const RERANK_CANDIDATE_API_FORMATS: &[&str] = &["openai:rerank", "jina:rerank"];
@@ -238,7 +239,11 @@ pub fn is_standard_api_format(api_format: &str) -> bool {
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"
"openai:embedding"
| "gemini:embedding"
| "jina:embedding"
| "doubao:embedding"
| "aliyun:multimodal_embedding"
)
}
@@ -267,9 +272,11 @@ pub fn api_data_format_id(api_format: &str) -> Option<&'static str> {
"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:embedding"
| "gemini:embedding"
| "jina:embedding"
| "doubao:embedding"
| "aliyun:multimodal_embedding" => Some("embedding"),
"openai:rerank" | "jina:rerank" => Some("rerank"),
_ => None,
}
@@ -442,6 +449,7 @@ mod tests {
"gemini:embedding",
"jina:embedding",
"doubao:embedding",
"aliyun:multimodal_embedding",
]
);
assert_eq!(
@@ -451,6 +459,7 @@ mod tests {
"openai:embedding",
"gemini:embedding",
"doubao:embedding",
"aliyun:multimodal_embedding",
]
);
assert!(!request_candidate_api_formats("openai:embedding", false).contains(&"openai:chat"));
@@ -479,6 +488,7 @@ mod tests {
"openai:embedding",
"jina:embedding",
"doubao:embedding",
"aliyun:multimodal_embedding",
]
);
assert_eq!(
@@ -488,6 +498,17 @@ mod tests {
"openai:embedding",
"gemini:embedding",
"jina:embedding",
"aliyun:multimodal_embedding",
]
);
assert_eq!(
request_candidate_api_formats("aliyun:multimodal_embedding", false),
vec![
"aliyun:multimodal_embedding",
"openai:embedding",
"gemini:embedding",
"jina:embedding",
"doubao:embedding",
]
);
@@ -496,6 +517,7 @@ mod tests {
"gemini:embedding",
"jina:embedding",
"doubao:embedding",
"aliyun:multimodal_embedding",
];
for client_api_format in embedding_formats {
for provider_api_format in embedding_formats {
@@ -520,6 +542,7 @@ mod tests {
"gemini:embedding",
"jina:embedding",
"doubao:embedding",
"aliyun:multimodal_embedding",
];
let standard_formats = [
"openai:chat",
@@ -1,3 +1,4 @@
pub mod aliyun;
pub mod claude;
pub mod context;
pub mod conversion;
@@ -50,6 +50,10 @@ pub(crate) fn from_namespace(body_json: &Value, namespace: &str) -> Option<Canon
.get("user")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
parameters: request
.get("parameters")
.and_then(Value::as_object)
.cloned(),
extensions: namespace_extensions(
namespace,
request,
@@ -60,6 +64,7 @@ pub(crate) fn from_namespace(body_json: &Value, namespace: &str) -> Option<Canon
"dimensions",
"task",
"user",
"parameters",
],
),
};
@@ -81,6 +86,9 @@ pub(crate) fn to_openai_like(
if embedding.input.is_empty() {
return None;
}
if matches!(&embedding.input, CanonicalEmbeddingInput::Multimodal(_)) {
return None;
}
let mut output = Map::new();
output.insert(
"model".to_string(),
@@ -99,6 +107,9 @@ pub(crate) fn to_openai_like(
if let Some(value) = &embedding.user {
output.insert("user".to_string(), Value::String(value.clone()));
}
if let Some(value) = &embedding.parameters {
output.insert("parameters".to_string(), Value::Object(value.clone()));
}
if let Some(task) = embedding
.task
.as_ref()
@@ -1,6 +1,7 @@
use serde_json::Value;
use crate::formats::{
aliyun,
claude::messages as claude_messages,
doubao,
gemini::{self, generate_content as gemini_generate_content},
@@ -29,7 +30,9 @@ pub fn parse_request(
FormatId::JinaEmbedding => jina::embedding::request::from(body, ctx),
FormatId::OpenAiRerank => openai::rerank::request::from(body, ctx),
FormatId::JinaRerank => jina::rerank::request::from(body, ctx),
FormatId::GeminiEmbedding | FormatId::DoubaoEmbedding => None,
FormatId::GeminiEmbedding
| FormatId::DoubaoEmbedding
| FormatId::AliyunMultimodalEmbedding => None,
}
.ok_or_else(|| FormatError::RequestParseFailed {
format: source.as_str().to_string(),
@@ -62,6 +65,7 @@ pub fn emit_request(
FormatId::JinaRerank => jina::rerank::request::to(&request, ctx),
FormatId::GeminiEmbedding => gemini::embedding::request::to(&request, ctx),
FormatId::DoubaoEmbedding => doubao::embedding::request::to(&request, ctx),
FormatId::AliyunMultimodalEmbedding => aliyun::embedding::request::to(&request, ctx),
}
.ok_or_else(|| FormatError::RequestEmitFailed {
format: target.as_str().to_string(),
@@ -96,7 +100,8 @@ pub fn parse_response(
| FormatId::OpenAiRerank
| FormatId::JinaRerank
| FormatId::GeminiEmbedding
| FormatId::DoubaoEmbedding => None,
| FormatId::DoubaoEmbedding
| FormatId::AliyunMultimodalEmbedding => None,
}
.ok_or_else(|| FormatError::ResponseParseFailed {
format: source.as_str().to_string(),
@@ -120,7 +125,8 @@ pub fn emit_response(
| FormatId::OpenAiRerank
| FormatId::JinaRerank
| FormatId::GeminiEmbedding
| FormatId::DoubaoEmbedding => None,
| FormatId::DoubaoEmbedding
| FormatId::AliyunMultimodalEmbedding => None,
}
.ok_or_else(|| FormatError::ResponseEmitFailed {
format: target.as_str().to_string(),
@@ -252,6 +258,119 @@ mod tests {
assert!(doubao.get("messages").is_none());
}
#[test]
fn converts_openai_embedding_to_aliyun_multimodal_payload_shape() {
let body = json!({
"model": "text-embedding-3-small",
"input": [
{"text": "white running shoes"},
{"image": "https://example.com/shoe.png"},
{"multi_images": ["https://example.com/a.png", "https://example.com/b.png"]}
],
"dimensions": 1024,
"parameters": {
"enable_fusion": true,
"res_level": 2,
"max_video_frames": 64
}
});
let converted = convert_request(
"openai:embedding",
"aliyun:multimodal_embedding",
&body,
&FormatContext::default().with_mapped_model("qwen3-vl-embedding"),
)
.expect("aliyun multimodal embedding conversion should succeed");
assert_eq!(converted["model"], "qwen3-vl-embedding");
assert_eq!(converted["input"]["contents"], body["input"]);
assert_eq!(converted["parameters"]["dimension"], 1024);
assert_eq!(converted["parameters"]["enable_fusion"], true);
assert_eq!(converted["parameters"]["res_level"], 2);
assert_eq!(converted["parameters"]["max_video_frames"], 64);
assert!(converted.get("messages").is_none());
}
#[test]
fn aliyun_embedding_conversion_rejects_token_arrays() {
let body = json!({
"model": "text-embedding-3-small",
"input": [1, 2, 3]
});
assert!(convert_request(
"openai:embedding",
"aliyun:multimodal_embedding",
&body,
&FormatContext::default().with_mapped_model("qwen3-vl-embedding"),
)
.is_err());
}
#[test]
fn multimodal_embedding_conversion_is_aliyun_only() {
let body = json!({
"model": "qwen3-vl-embedding",
"input": [
{"text": "white running shoes"},
{"image": "https://example.com/shoe.png"}
]
});
let ctx = FormatContext::default().with_mapped_model("qwen3-vl-embedding");
assert!(convert_request("openai:embedding", "openai:embedding", &body, &ctx).is_err());
assert!(convert_request("openai:embedding", "jina:embedding", &body, &ctx).is_err());
assert!(convert_request("openai:embedding", "gemini:embedding", &body, &ctx).is_err());
assert!(convert_request("openai:embedding", "doubao:embedding", &body, &ctx).is_err());
assert!(convert_request(
"openai:embedding",
"aliyun:multimodal_embedding",
&body,
&ctx
)
.is_ok());
}
#[test]
fn parses_aliyun_embedding_response_to_openai_shape() {
let body = json!({
"output": {
"embeddings": [
{
"index": 0,
"embedding": [0.1, 0.2, 0.3],
"type": "fused"
}
]
},
"usage": {
"input_tokens": 432,
"input_tokens_details": {
"image_tokens": 402,
"text_tokens": 30
},
"output_tokens": 1,
"total_tokens": 433
},
"request_id": "aliyun-request-1"
});
let canonical =
crate::protocol::canonical::from_embedding_to_canonical_response(&body, "aliyun")
.expect("aliyun embedding response should parse");
let emitted =
crate::protocol::canonical::canonical_to_embedding_response(&canonical, "openai")
.expect("openai embedding response should emit");
assert_eq!(emitted["request_id"], "aliyun-request-1");
assert_eq!(emitted["data"][0]["embedding"], json!([0.1, 0.2, 0.3]));
assert_eq!(emitted["data"][0]["type"], "fused");
assert_eq!(emitted["usage"]["prompt_tokens"], 432);
assert_eq!(emitted["usage"]["completion_tokens"], 1);
assert_eq!(emitted["usage"]["total_tokens"], 433);
}
#[test]
fn embedding_registry_keeps_gemini_and_doubao_emit_only() {
let body = json!({
@@ -260,7 +260,8 @@ impl ProviderStreamParser {
| FormatId::GeminiEmbedding
| FormatId::JinaEmbedding
| FormatId::JinaRerank
| FormatId::DoubaoEmbedding => return None,
| FormatId::DoubaoEmbedding
| FormatId::AliyunMultimodalEmbedding => return None,
})
}
@@ -350,7 +351,8 @@ impl ClientStreamEmitter {
| FormatId::GeminiEmbedding
| FormatId::JinaEmbedding
| FormatId::JinaRerank
| FormatId::DoubaoEmbedding => return None,
| FormatId::DoubaoEmbedding
| FormatId::AliyunMultimodalEmbedding => return None,
})
}
@@ -414,7 +416,8 @@ fn parse_provider_error(
| FormatId::GeminiEmbedding
| FormatId::JinaEmbedding
| FormatId::JinaRerank
| FormatId::DoubaoEmbedding => None,
| FormatId::DoubaoEmbedding
| FormatId::AliyunMultimodalEmbedding => None,
}
}
@@ -439,6 +439,7 @@ fn embedding_response_namespace_for_api_format(api_format: &str) -> Option<&'sta
"openai:embedding" => Some("openai"),
"jina:embedding" => Some("jina"),
"gemini:embedding" => Some("gemini"),
"aliyun:multimodal_embedding" => Some("aliyun"),
_ => None,
}
}
+5 -5
View File
@@ -43,9 +43,9 @@ pub use protocol::canonical::{
from_gemini_to_canonical_response, from_openai_chat_to_canonical_request,
from_openai_chat_to_canonical_response, from_openai_responses_to_canonical_request,
from_openai_responses_to_canonical_response, CanonicalContentBlock, CanonicalEmbedding,
CanonicalEmbeddingInput, CanonicalEmbeddingRequest, CanonicalEmbeddingResponse,
CanonicalGenerationConfig, CanonicalInstruction, CanonicalMessage, CanonicalRequest,
CanonicalResponse, CanonicalResponseFormat, CanonicalResponseOutput, CanonicalRole,
CanonicalStopReason, CanonicalStreamEvent, CanonicalStreamFrame, CanonicalThinkingConfig,
CanonicalToolChoice, CanonicalToolDefinition, CanonicalUsage,
CanonicalEmbeddingContent, CanonicalEmbeddingInput, CanonicalEmbeddingRequest,
CanonicalEmbeddingResponse, CanonicalGenerationConfig, CanonicalInstruction, CanonicalMessage,
CanonicalRequest, CanonicalResponse, CanonicalResponseFormat, CanonicalResponseOutput,
CanonicalRole, CanonicalStopReason, CanonicalStreamEvent, CanonicalStreamFrame,
CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition, CanonicalUsage,
};
@@ -246,6 +246,19 @@ pub enum CanonicalEmbeddingInput {
StringArray(Vec<String>),
TokenArray(Vec<i64>),
TokenArrayArray(Vec<Vec<i64>>),
Multimodal(Vec<CanonicalEmbeddingContent>),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CanonicalEmbeddingContent {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub video: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub multi_images: Option<Vec<String>>,
}
impl CanonicalEmbeddingInput {
@@ -257,6 +270,9 @@ impl CanonicalEmbeddingInput {
}
Self::TokenArray(values) => values.is_empty(),
Self::TokenArrayArray(values) => values.is_empty() || values.iter().any(Vec::is_empty),
Self::Multimodal(values) => {
values.is_empty() || values.iter().any(CanonicalEmbeddingContent::is_empty)
}
}
}
@@ -264,11 +280,47 @@ impl CanonicalEmbeddingInput {
match self {
Self::String(value) => Some(vec![value.as_str()]),
Self::StringArray(values) => Some(values.iter().map(String::as_str).collect()),
Self::TokenArray(_) | Self::TokenArrayArray(_) => None,
Self::TokenArray(_) | Self::TokenArrayArray(_) | Self::Multimodal(_) => None,
}
}
}
impl CanonicalEmbeddingContent {
pub(crate) fn is_empty(&self) -> bool {
let text_empty = self
.text
.as_ref()
.is_some_and(|value| value.trim().is_empty());
let image_empty = self
.image
.as_ref()
.is_some_and(|value| value.trim().is_empty());
let video_empty = self
.video
.as_ref()
.is_some_and(|value| value.trim().is_empty());
let multi_images_empty = self.multi_images.as_ref().is_some_and(|values| {
values.is_empty() || values.iter().any(|value| value.trim().is_empty())
});
let has_any = self
.text
.as_ref()
.is_some_and(|value| !value.trim().is_empty())
|| self
.image
.as_ref()
.is_some_and(|value| !value.trim().is_empty())
|| self
.video
.as_ref()
.is_some_and(|value| !value.trim().is_empty())
|| self.multi_images.as_ref().is_some_and(|values| {
!values.is_empty() && values.iter().all(|value| !value.trim().is_empty())
});
!has_any || text_empty || image_empty || video_empty || multi_images_empty
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CanonicalEmbeddingRequest {
pub input: CanonicalEmbeddingInput,
@@ -280,6 +332,8 @@ pub struct CanonicalEmbeddingRequest {
pub task: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parameters: Option<Map<String, Value>>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extensions: BTreeMap<String, Value>,
}
@@ -502,6 +556,7 @@ pub(crate) fn canonical_to_embedding_request(
"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),
"aliyun" => crate::formats::aliyun::embedding::request::to(canonical, &ctx),
_ => None,
}
}
@@ -699,6 +754,7 @@ pub fn from_embedding_to_canonical_response(
}
"jina" => crate::formats::openai::embedding::response::from_namespace(body_json, "jina"),
"gemini" => crate::formats::gemini::embedding::response::from(body_json),
"aliyun" => crate::formats::aliyun::embedding::response::from(body_json),
_ => None,
}
}
@@ -5244,8 +5300,8 @@ mod tests {
from_gemini_to_canonical_request, from_gemini_to_canonical_response,
from_openai_chat_to_canonical_request, from_openai_chat_to_canonical_response,
from_openai_responses_to_canonical_request, from_openai_responses_to_canonical_response,
CanonicalContentBlock, CanonicalEmbedding, CanonicalEmbeddingInput,
CanonicalEmbeddingRequest, CanonicalRole, CanonicalUsage,
CanonicalContentBlock, CanonicalEmbedding, CanonicalEmbeddingContent,
CanonicalEmbeddingInput, CanonicalEmbeddingRequest, CanonicalRole, CanonicalUsage,
};
use serde_json::{json, Value};
@@ -5301,6 +5357,44 @@ mod tests {
"nested token array",
CanonicalEmbeddingInput::TokenArrayArray(vec![vec![1, 2], vec![3, 4]]),
),
(
json!([
{"text": "white running shoes"},
{"image": "https://example.com/shoe.png"},
{"video": "https://example.com/demo.mp4"},
{"multi_images": ["https://example.com/a.png", "https://example.com/b.png"]}
]),
"multimodal array",
CanonicalEmbeddingInput::Multimodal(vec![
CanonicalEmbeddingContent {
text: Some("white running shoes".to_string()),
image: None,
video: None,
multi_images: None,
},
CanonicalEmbeddingContent {
text: None,
image: Some("https://example.com/shoe.png".to_string()),
video: None,
multi_images: None,
},
CanonicalEmbeddingContent {
text: None,
image: None,
video: Some("https://example.com/demo.mp4".to_string()),
multi_images: None,
},
CanonicalEmbeddingContent {
text: None,
image: None,
video: None,
multi_images: Some(vec![
"https://example.com/a.png".to_string(),
"https://example.com/b.png".to_string(),
]),
},
]),
),
];
for (input, label, expected_input) in cases {
@@ -5327,6 +5421,9 @@ mod tests {
json!({"model": "text-embedding-3-small", "input": []}),
json!({"model": "text-embedding-3-small", "input": [1, "two"]}),
json!({"model": "text-embedding-3-small", "input": [[1], []]}),
json!({"model": "text-embedding-3-small", "input": [{"image": " "}]}),
json!({"model": "text-embedding-3-small", "input": [{"multi_images": []}]}),
json!({"model": "text-embedding-3-small", "input": ["hello", {"image": "https://example.com/a.png"}]}),
json!({"model": "", "input": "hello"}),
json!({"input": "hello"}),
json!({"model": "text-embedding-3-small", "messages": []}),
@@ -5396,6 +5493,7 @@ mod tests {
dimensions: Some(2),
task: None,
user: None,
parameters: None,
extensions: Default::default(),
}),
..Default::default()
@@ -5441,6 +5539,7 @@ mod tests {
dimensions: Some(1536),
task: Some("retrieval.passage".to_string()),
user: Some("user-1".to_string()),
parameters: None,
extensions: Default::default(),
}),
..Default::default()
@@ -5493,6 +5592,7 @@ mod tests {
dimensions: None,
task: None,
user: None,
parameters: None,
extensions: Default::default(),
}),
..Default::default()