feat(embedding): 接入阿里云多模态向量端点

This commit is contained in:
Entropy.Xu
2026-05-28 16:05:36 +08:00
parent 535039c29e
commit 0f6d4b9146
56 changed files with 1605 additions and 42 deletions
@@ -184,7 +184,11 @@ pub fn request_pair_transport_unsupported_reason(
)
}
}
"openai:embedding" | "jina:embedding" | "doubao:embedding" | "openai:rerank"
"openai:embedding"
| "jina:embedding"
| "doubao:embedding"
| "aliyun:multimodal_embedding"
| "openai:rerank"
| "jina:rerank" => local_standard_transport_unsupported_reason_with_network(
transport,
provider_api_format.as_str(),
@@ -221,6 +225,7 @@ fn request_direct_auth_for_provider_format(
| "openai:embedding"
| "jina:embedding"
| "doubao:embedding"
| "aliyun:multimodal_embedding"
| "openai:rerank"
| "jina:rerank" => resolve_local_openai_bearer_auth(transport),
"gemini:generate_content" | "gemini:embedding" => {
@@ -225,7 +225,7 @@ fn endpoint_kind_allows_embedding(endpoint_kind: Option<&str>) -> bool {
.map(|value| {
matches!(
value.to_ascii_lowercase().as_str(),
"embedding" | "embeddings"
"embedding" | "embeddings" | "multimodal_embedding" | "multimodal_embeddings"
)
})
.unwrap_or(true)
@@ -348,10 +348,13 @@ mod tests {
("jina", "jina:embedding"),
("doubao", "doubao:embedding"),
("volcengine", "doubao:embedding"),
("aliyun", "aliyun:multimodal_embedding"),
("dashscope", "aliyun:multimodal_embedding"),
("custom", "openai:embedding"),
("custom", "gemini:embedding"),
("custom", "jina:embedding"),
("custom", "doubao:embedding"),
("custom", "aliyun:multimodal_embedding"),
] {
let transport = sample_transport(provider_type, api_format, Some("embedding"));
assert_eq!(
@@ -84,6 +84,7 @@ pub enum ProviderLocalEmbeddingSupport {
Gemini,
Jina,
Doubao,
Aliyun,
}
impl ProviderLocalEmbeddingSupport {
@@ -99,11 +100,13 @@ impl ProviderLocalEmbeddingSupport {
| "jina:embedding"
| "jina:rerank"
| "doubao:embedding"
| "aliyun:multimodal_embedding"
),
Self::OpenAi => matches!(api_format.as_str(), "openai:embedding" | "openai:rerank"),
Self::Gemini => api_format == "gemini:embedding",
Self::Jina => matches!(api_format.as_str(), "jina:embedding" | "jina:rerank"),
Self::Doubao => api_format == "doubao:embedding",
Self::Aliyun => api_format == "aliyun:multimodal_embedding",
}
}
}
@@ -187,6 +190,10 @@ const DOUBAO_RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy {
local_embedding_support: ProviderLocalEmbeddingSupport::Doubao,
..STANDARD_RUNTIME_POLICY
};
const ALIYUN_RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy {
local_embedding_support: ProviderLocalEmbeddingSupport::Aliyun,
..STANDARD_RUNTIME_POLICY
};
const CLAUDE_CODE_RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy {
fixed_provider: true,
@@ -465,6 +472,7 @@ pub fn provider_runtime_policy(provider_type: &str) -> ProviderRuntimePolicy {
"gemini" | "google" => GEMINI_RUNTIME_POLICY,
"jina" => JINA_RUNTIME_POLICY,
"doubao" | "volcengine" => DOUBAO_RUNTIME_POLICY,
"aliyun" | "dashscope" => ALIYUN_RUNTIME_POLICY,
_ => STANDARD_RUNTIME_POLICY,
}
}
@@ -846,6 +854,8 @@ mod tests {
("jina", "jina:embedding"),
("doubao", "doubao:embedding"),
("volcengine", "doubao:embedding"),
("aliyun", "aliyun:multimodal_embedding"),
("dashscope", "aliyun:multimodal_embedding"),
] {
assert!(
provider_type_supports_local_embedding_transport(provider_type, api_format),
@@ -859,6 +869,8 @@ mod tests {
("vertex_ai", "openai:embedding"),
("jina", "doubao:embedding"),
("doubao", "jina:embedding"),
("aliyun", "openai:embedding"),
("openai", "aliyun:multimodal_embedding"),
("claude_code", "openai:embedding"),
("openai", "openai:chat"),
] {
@@ -125,6 +125,10 @@ fn build_transport_request_url_inner(
"openai:embedding" | "jina:embedding" => {
build_provider_embedding_v1_url(&transport.endpoint.base_url, params.request_query)
}
"aliyun:multimodal_embedding" => build_aliyun_multimodal_embedding_url(
&transport.endpoint.base_url,
params.request_query,
),
"openai:rerank" | "jina:rerank" => {
build_provider_rerank_v1_url(&transport.endpoint.base_url, params.request_query)
}
@@ -386,6 +390,18 @@ fn build_provider_embedding_v1_url(upstream_base_url: &str, query: Option<&str>)
build_provider_v1_url(upstream_base_url, "/embeddings", "/v1/embeddings", query)
}
fn build_aliyun_multimodal_embedding_url(
upstream_base_url: &str,
query: Option<&str>,
) -> Option<String> {
build_passthrough_path_url(
upstream_base_url,
"/api/v1/services/embeddings/multimodal-embedding/multimodal-embedding",
query,
&[],
)
}
fn build_provider_rerank_v1_url(upstream_base_url: &str, query: Option<&str>) -> Option<String> {
build_provider_v1_url(upstream_base_url, "/rerank", "/v1/rerank", query)
}
@@ -936,6 +952,12 @@ mod tests {
"https://ark.volces.example/api/v3",
None,
);
let aliyun = sample_transport(
"aliyun",
"aliyun:multimodal_embedding",
"https://dashscope.aliyuncs.com",
None,
);
assert_eq!(
build_transport_request_url(
@@ -995,6 +1017,20 @@ mod tests {
.as_deref(),
Some("https://ark.volces.example/api/v3/embeddings")
);
assert_eq!(
build_transport_request_url(
&aliyun,
TransportRequestUrlParams {
provider_api_format: "aliyun:multimodal_embedding",
mapped_model: Some("qwen3-vl-embedding"),
upstream_is_stream: false,
request_query: None,
kiro_api_region: None,
},
)
.as_deref(),
Some("https://dashscope.aliyuncs.com/api/v1/services/embeddings/multimodal-embedding/multimodal-embedding")
);
}
#[test]
@@ -159,6 +159,14 @@ pub fn build_same_format_provider_request_body(
);
}
if embedding_multimodal_input_requires_aliyun_provider(
input.client_api_format,
input.provider_api_format,
input.body_json,
) {
return None;
}
let mut provider_request_body = if aether_ai_formats::api_format_alias_matches(
input.client_api_format,
input.provider_api_format,
@@ -235,6 +243,31 @@ pub fn build_same_format_provider_request_body(
Some(provider_request_body)
}
fn embedding_multimodal_input_requires_aliyun_provider(
client_api_format: &str,
provider_api_format: &str,
body_json: &Value,
) -> bool {
aether_ai_formats::is_embedding_api_format(client_api_format)
&& embedding_input_is_multimodal(body_json.get("input"))
&& aether_ai_formats::normalize_api_format_alias(provider_api_format)
!= "aliyun:multimodal_embedding"
}
fn embedding_input_is_multimodal(value: Option<&Value>) -> bool {
value
.and_then(Value::as_array)
.is_some_and(|items| !items.is_empty() && items.iter().all(embedding_content_is_multimodal))
}
fn embedding_content_is_multimodal(value: &Value) -> bool {
value.as_object().is_some_and(|object| {
["text", "image", "video", "multi_images"]
.iter()
.any(|key| object.contains_key(*key))
})
}
fn strip_gemini_function_response_ids(value: &mut Value) {
match value {
Value::Object(object) => {
@@ -728,6 +761,33 @@ mod tests {
assert_eq!(body.get("stream"), Some(&json!(true)));
}
#[test]
fn same_format_embedding_body_rejects_multimodal_for_openai_like_provider() {
let body = build_same_format_provider_request_body(SameFormatProviderRequestBodyInput {
body_json: &json!({
"model": "qwen3-vl-embedding",
"input": [
{"text": "white running shoes"},
{"image": "https://example.com/shoe.png"}
]
}),
mapped_model: "openai-qwen-fallback",
client_api_format: "openai:embedding",
provider_api_format: "openai:embedding",
source_model: Some("qwen3-vl-embedding"),
family: SameFormatProviderFamily::Standard,
body_rules: None,
request_headers: None,
upstream_is_stream: false,
force_body_stream_field: false,
kiro_auth_config: None,
is_claude_code: false,
enable_model_directives: false,
});
assert!(body.is_none());
}
#[test]
fn same_format_standard_body_overrides_client_stream_for_non_stream_upstream() {
let body = build_same_format_provider_request_body(SameFormatProviderRequestBodyInput {