mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
fix(provider): 将rust分支的gemini cli端点行为对齐到python分支 (#321)
* fix(provider): 对齐 Vertex/Gemini 上游发包与 Python master - provider-transport: 为 custom+aiplatform 推断 Vertex API key 上下文并统一 URL 构建顺序,复用共享 request_url 构建最终上游地址 - ai-pipeline/gateway: Vertex Gemini 路径改为仅使用 URL query key,不再向上游附带 x-goog-api-key header;同步对齐 standard/admin/test-connection/runtime miss 摘要中的最终 URL - gemini conversion: 按 Python master 输出 Gemini 请求体,补齐 system_instruction / generation_config / tool_config / function_declarations 形态,并移植 Gemini schema 清洗逻辑 - scheduler/executor: 将最终 upstream_url、mapped_model、key_name 写入候选 extra_data,运行时 miss 诊断优先展示真实展开后的上游 URL 便于服务器排障 * fix(provider): 修复 Vertex provider 测试与本地调度链路 * fix(provider): 对齐 Vertex 本地执行与 Rust CI
This commit is contained in:
@@ -8,6 +8,11 @@ use aether_provider_transport::policy::{
|
||||
local_openai_chat_transport_unsupported_reason,
|
||||
local_standard_transport_unsupported_reason_with_network,
|
||||
};
|
||||
use aether_provider_transport::vertex::{
|
||||
is_vertex_api_key_transport_context,
|
||||
local_vertex_api_key_gemini_transport_unsupported_reason_with_network,
|
||||
resolve_local_vertex_api_key_query_auth, VERTEX_API_KEY_QUERY_PARAM,
|
||||
};
|
||||
use aether_provider_transport::GatewayProviderTransportSnapshot;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -255,6 +260,9 @@ pub fn request_conversion_transport_unsupported_reason(
|
||||
"claude:cli" => {
|
||||
local_standard_transport_unsupported_reason_with_network(transport, "claude:cli")
|
||||
}
|
||||
"gemini:chat" | "gemini:cli" if is_vertex_api_key_transport_context(transport) => {
|
||||
local_vertex_api_key_gemini_transport_unsupported_reason_with_network(transport)
|
||||
}
|
||||
"gemini:chat" => {
|
||||
local_gemini_transport_unsupported_reason_with_network(transport, "gemini:chat")
|
||||
}
|
||||
@@ -279,7 +287,14 @@ pub fn request_conversion_direct_auth(
|
||||
"openai:chat" | "openai:cli" | "openai:compact" => {
|
||||
resolve_local_openai_bearer_auth(transport)
|
||||
}
|
||||
"gemini:chat" | "gemini:cli" => resolve_local_gemini_auth(transport),
|
||||
"gemini:chat" | "gemini:cli" => {
|
||||
if is_vertex_api_key_transport_context(transport) {
|
||||
resolve_local_vertex_api_key_query_auth(transport)
|
||||
.map(|auth| (VERTEX_API_KEY_QUERY_PARAM.to_string(), auth.value))
|
||||
} else {
|
||||
resolve_local_gemini_auth(transport)
|
||||
}
|
||||
}
|
||||
"claude:chat" | "claude:cli" => resolve_local_standard_auth(transport),
|
||||
_ => None,
|
||||
}
|
||||
@@ -729,4 +744,67 @@ mod tests {
|
||||
"openai:cli"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertex_gemini_transport_supports_cross_format_conversion_with_query_auth() {
|
||||
let transport = GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-vertex".to_string(),
|
||||
name: "vertex".to_string(),
|
||||
provider_type: "vertex_ai".to_string(),
|
||||
website: None,
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: true,
|
||||
concurrent_limit: None,
|
||||
max_retries: None,
|
||||
proxy: None,
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: None,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-vertex".to_string(),
|
||||
provider_id: "provider-vertex".to_string(),
|
||||
api_format: "gemini:chat".to_string(),
|
||||
api_family: Some("gemini".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
is_active: true,
|
||||
base_url: "https://aiplatform.googleapis.com".to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: None,
|
||||
custom_path: None,
|
||||
config: None,
|
||||
format_acceptance_config: None,
|
||||
proxy: None,
|
||||
},
|
||||
key: GatewayProviderTransportKey {
|
||||
id: "key-vertex".to_string(),
|
||||
provider_id: "provider-vertex".to_string(),
|
||||
name: "key".to_string(),
|
||||
auth_type: "api_key".to_string(),
|
||||
is_active: true,
|
||||
api_formats: Some(vec!["gemini:chat".to_string()]),
|
||||
allowed_models: None,
|
||||
capabilities: None,
|
||||
rate_multipliers: None,
|
||||
global_priority_by_format: None,
|
||||
expires_at_unix_secs: None,
|
||||
proxy: None,
|
||||
fingerprint: None,
|
||||
decrypted_api_key: "vertex-secret".to_string(),
|
||||
decrypted_auth_config: None,
|
||||
},
|
||||
};
|
||||
|
||||
assert!(request_conversion_transport_supported(
|
||||
&transport,
|
||||
RequestConversionKind::ToGeminiStandard
|
||||
));
|
||||
assert_eq!(
|
||||
request_conversion_direct_auth(&transport, RequestConversionKind::ToGeminiStandard),
|
||||
Some(("key".to_string(), "vertex-secret".to_string()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::planner::openai::{
|
||||
pub fn convert_openai_chat_request_to_gemini_request(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
_upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
let request = body_json.as_object()?;
|
||||
let mut system_segments = Vec::new();
|
||||
@@ -123,14 +123,16 @@ pub fn convert_openai_chat_request_to_gemini_request(
|
||||
}
|
||||
|
||||
let mut output = Map::new();
|
||||
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
|
||||
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(contents)),
|
||||
);
|
||||
if upstream_is_stream {
|
||||
output.insert("stream".to_string(), Value::Bool(true));
|
||||
}
|
||||
let system_text = system_segments
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
@@ -203,6 +205,8 @@ pub fn convert_openai_chat_request_to_gemini_request(
|
||||
.and_then(|json_schema| json_schema.get("schema"))
|
||||
.cloned()
|
||||
{
|
||||
let mut schema = schema;
|
||||
clean_gemini_schema(&mut schema);
|
||||
generation_config.insert("responseSchema".to_string(), schema);
|
||||
}
|
||||
}
|
||||
@@ -232,18 +236,17 @@ pub fn convert_openai_chat_request_to_gemini_request(
|
||||
}
|
||||
if let Some(extra_body) = request.get("extra_body").and_then(Value::as_object) {
|
||||
if let Some(google) = extra_body.get("google").and_then(Value::as_object) {
|
||||
if let Some(existing) = output
|
||||
.get_mut("generationConfig")
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
if let Some(response_modalities) = google.get("response_modalities").cloned() {
|
||||
existing.insert("responseModalities".to_string(), response_modalities);
|
||||
}
|
||||
if let Some(thinking_config) = google.get("thinking_config").cloned() {
|
||||
existing
|
||||
.entry("thinkingConfig".to_string())
|
||||
.or_insert(thinking_config);
|
||||
}
|
||||
let existing = output
|
||||
.entry("generationConfig".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()))
|
||||
.as_object_mut()?;
|
||||
if let Some(response_modalities) = google.get("response_modalities").cloned() {
|
||||
existing.insert("responseModalities".to_string(), response_modalities);
|
||||
}
|
||||
if let Some(thinking_config) = google.get("thinking_config").cloned() {
|
||||
existing
|
||||
.entry("thinkingConfig".to_string())
|
||||
.or_insert(thinking_config);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,6 +397,10 @@ fn convert_openai_tools_to_gemini(
|
||||
function
|
||||
.get("parameters")
|
||||
.cloned()
|
||||
.map(|mut schema| {
|
||||
clean_gemini_schema(&mut schema);
|
||||
schema
|
||||
})
|
||||
.unwrap_or_else(|| json!({})),
|
||||
);
|
||||
declarations.push(Value::Object(declaration));
|
||||
@@ -483,6 +490,517 @@ fn compact_gemini_contents(contents: Vec<Value>) -> Vec<Value> {
|
||||
compact
|
||||
}
|
||||
|
||||
const ALLOWED_SCHEMA_FIELDS: &[&str] = &[
|
||||
"type",
|
||||
"description",
|
||||
"properties",
|
||||
"required",
|
||||
"items",
|
||||
"enum",
|
||||
"title",
|
||||
];
|
||||
|
||||
const CONSTRAINT_FIELDS: &[(&str, &str)] = &[
|
||||
("minLength", "minLen"),
|
||||
("maxLength", "maxLen"),
|
||||
("pattern", "pattern"),
|
||||
("minimum", "min"),
|
||||
("maximum", "max"),
|
||||
("multipleOf", "multipleOf"),
|
||||
("exclusiveMinimum", "exclMin"),
|
||||
("exclusiveMaximum", "exclMax"),
|
||||
("minItems", "minItems"),
|
||||
("maxItems", "maxItems"),
|
||||
("format", "format"),
|
||||
];
|
||||
|
||||
fn clean_gemini_schema(value: &mut Value) {
|
||||
if !value.is_object() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut defs = Map::new();
|
||||
collect_all_defs(value, &mut defs);
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
object.remove("$defs");
|
||||
object.remove("definitions");
|
||||
}
|
||||
let mut seen = Vec::new();
|
||||
flatten_refs(value, &defs, &mut seen);
|
||||
clean_schema_recursive(value, true);
|
||||
}
|
||||
|
||||
fn collect_all_defs(value: &Value, defs: &mut Map<String, Value>) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for defs_key in ["$defs", "definitions"] {
|
||||
if let Some(Value::Object(inner_defs)) = object.get(defs_key) {
|
||||
for (key, inner) in inner_defs {
|
||||
defs.entry(key.clone()).or_insert_with(|| inner.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (key, inner) in object {
|
||||
if key != "$defs" && key != "definitions" {
|
||||
collect_all_defs(inner, defs);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_all_defs(item, defs);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten_refs(value: &mut Value, defs: &Map<String, Value>, seen: &mut Vec<String>) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
let ref_path = object
|
||||
.remove("$ref")
|
||||
.and_then(|value| value.as_str().map(ToOwned::to_owned));
|
||||
if let Some(ref_path) = ref_path {
|
||||
let ref_name = ref_path.rsplit('/').next().unwrap_or_default().to_string();
|
||||
if seen.iter().any(|value| value == &ref_name) {
|
||||
object
|
||||
.entry("type".to_string())
|
||||
.or_insert_with(|| Value::String("string".to_string()));
|
||||
append_schema_hint(object, &format!("(Circular $ref: {ref_path})"));
|
||||
return;
|
||||
}
|
||||
seen.push(ref_name.clone());
|
||||
if let Some(Value::Object(def_schema)) = defs.get(&ref_name) {
|
||||
for (key, inner) in def_schema {
|
||||
if !object.contains_key(key) {
|
||||
object.insert(key.clone(), inner.clone());
|
||||
}
|
||||
}
|
||||
flatten_refs(value, defs, seen);
|
||||
} else {
|
||||
object
|
||||
.entry("type".to_string())
|
||||
.or_insert_with(|| Value::String("string".to_string()));
|
||||
append_schema_hint(object, &format!("(Unresolved $ref: {ref_path})"));
|
||||
}
|
||||
seen.pop();
|
||||
return;
|
||||
}
|
||||
for inner in object.values_mut() {
|
||||
flatten_refs(inner, defs, seen);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
flatten_refs(item, defs, seen);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_schema_recursive(value: &mut Value, is_schema_node: bool) -> bool {
|
||||
let Some(object) = value.as_object_mut() else {
|
||||
if let Some(items) = value.as_array_mut() {
|
||||
for item in items {
|
||||
clean_schema_recursive(item, is_schema_node);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut is_nullable = false;
|
||||
merge_all_of(object);
|
||||
|
||||
if (object.get("type").and_then(Value::as_str) == Some("object")
|
||||
|| object.contains_key("properties"))
|
||||
&& object.contains_key("items")
|
||||
{
|
||||
let items = object.remove("items");
|
||||
if let Some(Value::Object(items)) = items {
|
||||
let props = object
|
||||
.entry("properties".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()))
|
||||
.as_object_mut();
|
||||
if let Some(props) = props {
|
||||
for (key, inner) in items {
|
||||
props.entry(key).or_insert(inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut nullable_keys = Vec::new();
|
||||
if let Some(Value::Object(props)) = object.get_mut("properties") {
|
||||
for (key, inner) in props.iter_mut() {
|
||||
if clean_schema_recursive(inner, true) {
|
||||
nullable_keys.push(key.clone());
|
||||
}
|
||||
}
|
||||
if !object.contains_key("type") {
|
||||
object.insert("type".to_string(), Value::String("object".to_string()));
|
||||
}
|
||||
}
|
||||
if !nullable_keys.is_empty() {
|
||||
if let Some(Value::Array(required)) = object.get_mut("required") {
|
||||
required.retain(|item| {
|
||||
item.as_str()
|
||||
.is_some_and(|value| !nullable_keys.iter().any(|candidate| candidate == value))
|
||||
});
|
||||
if required.is_empty() {
|
||||
object.remove("required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(items) = object.get_mut("items") {
|
||||
if items.is_object() {
|
||||
clean_schema_recursive(items, true);
|
||||
if !object.contains_key("type") {
|
||||
object.insert("type".to_string(), Value::String("array".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !object.contains_key("properties") && !object.contains_key("items") {
|
||||
for (key, inner) in object.iter_mut() {
|
||||
if !matches!(key.as_str(), "anyOf" | "oneOf" | "allOf" | "enum" | "type")
|
||||
&& (inner.is_object() || inner.is_array())
|
||||
{
|
||||
clean_schema_recursive(inner, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for combo_key in ["anyOf", "oneOf"] {
|
||||
if let Some(Value::Array(combo)) = object.get_mut(combo_key) {
|
||||
for branch in combo {
|
||||
if branch.is_object() {
|
||||
clean_schema_recursive(branch, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let should_merge_union = object.get("type").is_none()
|
||||
|| object.get("type").and_then(Value::as_str) == Some("object");
|
||||
if should_merge_union {
|
||||
let union = object
|
||||
.get("anyOf")
|
||||
.and_then(Value::as_array)
|
||||
.or_else(|| object.get("oneOf").and_then(Value::as_array))
|
||||
.cloned();
|
||||
if let Some(union) = union {
|
||||
let (best, all_types) = extract_best_schema_branch(&union);
|
||||
if let Some(Value::Object(best_object)) = best {
|
||||
for (key, inner) in best_object {
|
||||
if key == "properties" {
|
||||
let target = object
|
||||
.entry("properties".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()))
|
||||
.as_object_mut();
|
||||
if let (Some(target), Value::Object(props)) = (target, inner) {
|
||||
for (prop_key, prop_value) in props {
|
||||
target.entry(prop_key).or_insert(prop_value);
|
||||
}
|
||||
}
|
||||
} else if key == "required" {
|
||||
let target = object
|
||||
.entry("required".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()))
|
||||
.as_array_mut();
|
||||
if let (Some(target), Value::Array(required)) = (target, inner) {
|
||||
for required_value in required {
|
||||
if !target.iter().any(|value| value == &required_value) {
|
||||
target.push(required_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !object.contains_key(&key) {
|
||||
object.insert(key, inner);
|
||||
}
|
||||
}
|
||||
if all_types.len() > 1 {
|
||||
append_schema_hint(object, &format!("Accepts: {}", all_types.join(" | ")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
object.remove("anyOf");
|
||||
object.remove("oneOf");
|
||||
|
||||
let is_not_schema_payload = object.contains_key("functionCall")
|
||||
|| object.contains_key("functionResponse")
|
||||
|| object.contains_key("function_call")
|
||||
|| object.contains_key("function_response");
|
||||
let has_standard = object
|
||||
.keys()
|
||||
.any(|key| ALLOWED_SCHEMA_FIELDS.iter().any(|allowed| key == allowed));
|
||||
|
||||
if is_schema_node && !has_standard && !object.is_empty() && !is_not_schema_payload {
|
||||
let keys = object.keys().cloned().collect::<Vec<_>>();
|
||||
let mut new_props = Map::new();
|
||||
for key in keys {
|
||||
if let Some(inner) = object.remove(&key) {
|
||||
new_props.insert(key, inner);
|
||||
}
|
||||
}
|
||||
for inner in new_props.values_mut() {
|
||||
if inner.is_object() {
|
||||
clean_schema_recursive(inner, true);
|
||||
}
|
||||
}
|
||||
object.insert("type".to_string(), Value::String("object".to_string()));
|
||||
object.insert("properties".to_string(), Value::Object(new_props));
|
||||
}
|
||||
|
||||
let looks_like_schema = (is_schema_node || has_standard || object.contains_key("properties"))
|
||||
&& !is_not_schema_payload;
|
||||
if looks_like_schema {
|
||||
move_constraints_to_description(object);
|
||||
let keys_to_remove = object
|
||||
.keys()
|
||||
.filter(|key| !ALLOWED_SCHEMA_FIELDS.iter().any(|allowed| *key == allowed))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
for key in keys_to_remove {
|
||||
object.remove(&key);
|
||||
}
|
||||
|
||||
if object.get("type").and_then(Value::as_str) == Some("object")
|
||||
&& !object.contains_key("properties")
|
||||
{
|
||||
object.insert("properties".to_string(), Value::Object(Map::new()));
|
||||
}
|
||||
|
||||
let valid_keys = object
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.map(|props| props.keys().cloned().collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
if let Some(Value::Array(required)) = object.get_mut("required") {
|
||||
required.retain(|item| {
|
||||
item.as_str()
|
||||
.is_some_and(|value| valid_keys.iter().any(|candidate| candidate == value))
|
||||
});
|
||||
if required.is_empty() {
|
||||
object.remove("required");
|
||||
}
|
||||
}
|
||||
|
||||
if !object.contains_key("type") {
|
||||
let inferred_type = if object.contains_key("enum") {
|
||||
"string"
|
||||
} else if object.contains_key("properties") {
|
||||
"object"
|
||||
} else if object.contains_key("items") {
|
||||
"array"
|
||||
} else {
|
||||
"string"
|
||||
};
|
||||
object.insert("type".to_string(), Value::String(inferred_type.to_string()));
|
||||
}
|
||||
|
||||
let fallback_type = if object.contains_key("properties") {
|
||||
"object"
|
||||
} else if object.contains_key("items") {
|
||||
"array"
|
||||
} else {
|
||||
"string"
|
||||
};
|
||||
let selected_type = match object.get("type") {
|
||||
Some(Value::String(type_name)) => {
|
||||
let lower = type_name.to_ascii_lowercase();
|
||||
if lower == "null" {
|
||||
is_nullable = true;
|
||||
None
|
||||
} else {
|
||||
Some(lower)
|
||||
}
|
||||
}
|
||||
Some(Value::Array(types)) => {
|
||||
let mut selected = None;
|
||||
for item in types {
|
||||
if let Some(type_name) = item.as_str() {
|
||||
let lower = type_name.to_ascii_lowercase();
|
||||
if lower == "null" {
|
||||
is_nullable = true;
|
||||
} else if selected.is_none() {
|
||||
selected = Some(lower);
|
||||
}
|
||||
}
|
||||
}
|
||||
selected
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
object.insert(
|
||||
"type".to_string(),
|
||||
Value::String(selected_type.unwrap_or_else(|| fallback_type.to_string())),
|
||||
);
|
||||
|
||||
if is_nullable {
|
||||
append_schema_hint(object, "(nullable)");
|
||||
}
|
||||
|
||||
if let Some(Value::Array(items)) = object.get_mut("enum") {
|
||||
for item in items.iter_mut() {
|
||||
if !item.is_string() {
|
||||
*item = Value::String(match item {
|
||||
Value::Null => "null".to_string(),
|
||||
_ => item.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is_nullable
|
||||
}
|
||||
|
||||
fn merge_all_of(object: &mut Map<String, Value>) {
|
||||
let all_of = object.remove("allOf");
|
||||
let Some(Value::Array(all_of)) = all_of else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut merged_props = Map::new();
|
||||
let mut merged_required = Vec::new();
|
||||
let mut other_fields = Map::new();
|
||||
|
||||
for item in all_of {
|
||||
let Value::Object(item) = item else {
|
||||
continue;
|
||||
};
|
||||
if let Some(Value::Object(props)) = item.get("properties") {
|
||||
for (key, value) in props {
|
||||
merged_props.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
if let Some(Value::Array(required)) = item.get("required") {
|
||||
for value in required {
|
||||
if !merged_required.iter().any(|existing| existing == value) {
|
||||
merged_required.push(value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (key, value) in item {
|
||||
if !matches!(key.as_str(), "properties" | "required" | "allOf")
|
||||
&& !other_fields.contains_key(&key)
|
||||
{
|
||||
other_fields.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (key, value) in other_fields {
|
||||
object.entry(key).or_insert(value);
|
||||
}
|
||||
if !merged_props.is_empty() {
|
||||
let target = object
|
||||
.entry("properties".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()))
|
||||
.as_object_mut();
|
||||
if let Some(target) = target {
|
||||
for (key, value) in merged_props {
|
||||
target.entry(key).or_insert(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !merged_required.is_empty() {
|
||||
let target = object
|
||||
.entry("required".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()))
|
||||
.as_array_mut();
|
||||
if let Some(target) = target {
|
||||
for value in merged_required {
|
||||
if !target.iter().any(|existing| existing == &value) {
|
||||
target.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_best_schema_branch(union: &[Value]) -> (Option<Value>, Vec<String>) {
|
||||
let mut best = None;
|
||||
let mut best_score = -1;
|
||||
let mut all_types = Vec::new();
|
||||
|
||||
for item in union {
|
||||
let score = score_schema_branch(item);
|
||||
if let Some(type_name) = schema_type_name(item) {
|
||||
if !all_types.iter().any(|existing| existing == type_name) {
|
||||
all_types.push(type_name.to_string());
|
||||
}
|
||||
}
|
||||
if score > best_score {
|
||||
best_score = score;
|
||||
best = Some(item.clone());
|
||||
}
|
||||
}
|
||||
|
||||
(best, all_types)
|
||||
}
|
||||
|
||||
fn score_schema_branch(value: &Value) -> i32 {
|
||||
let Some(object) = value.as_object() else {
|
||||
return 0;
|
||||
};
|
||||
if object.contains_key("properties")
|
||||
|| object.get("type").and_then(Value::as_str) == Some("object")
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
if object.contains_key("items") || object.get("type").and_then(Value::as_str) == Some("array") {
|
||||
return 2;
|
||||
}
|
||||
if object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value != "null")
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
fn schema_type_name(value: &Value) -> Option<&str> {
|
||||
let object = value.as_object()?;
|
||||
object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| object.contains_key("properties").then_some("object"))
|
||||
.or_else(|| object.contains_key("items").then_some("array"))
|
||||
}
|
||||
|
||||
fn move_constraints_to_description(object: &mut Map<String, Value>) {
|
||||
let hints = CONSTRAINT_FIELDS
|
||||
.iter()
|
||||
.filter_map(|(field, label)| object.get(*field).map(|value| format!("{label}: {value}")))
|
||||
.collect::<Vec<_>>();
|
||||
if !hints.is_empty() {
|
||||
append_schema_hint(object, &format!("[Constraint: {}]", hints.join(", ")));
|
||||
}
|
||||
}
|
||||
|
||||
fn append_schema_hint(object: &mut Map<String, Value>, hint: &str) {
|
||||
let existing = object
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if existing.contains(hint) {
|
||||
return;
|
||||
}
|
||||
let next = if existing.trim().is_empty() {
|
||||
hint.to_string()
|
||||
} else {
|
||||
format!("{existing} {hint}")
|
||||
};
|
||||
object.insert("description".to_string(), Value::String(next));
|
||||
}
|
||||
|
||||
fn parse_data_url(value: &str) -> Option<(String, String)> {
|
||||
let rest = value.strip_prefix("data:")?;
|
||||
let (meta, data) = rest.split_once(",")?;
|
||||
@@ -572,6 +1090,7 @@ mod tests {
|
||||
convert_openai_chat_request_to_gemini_request(&request, "gemini-2.5-pro", false)
|
||||
.expect("request should convert");
|
||||
|
||||
assert_eq!(converted["model"], "gemini-2.5-pro");
|
||||
assert_eq!(converted["generationConfig"]["seed"], 7);
|
||||
assert_eq!(converted["tools"][0], json!({ "codeExecution": {} }));
|
||||
assert_eq!(converted["tools"][1], json!({ "googleSearch": {} }));
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use aether_provider_transport::url::{
|
||||
build_claude_messages_url, build_gemini_content_url, build_openai_chat_url,
|
||||
build_openai_cli_url, build_passthrough_path_url,
|
||||
use aether_provider_transport::{
|
||||
apply_local_body_rules, build_transport_request_url, GatewayProviderTransportSnapshot,
|
||||
TransportRequestUrlParams,
|
||||
};
|
||||
use aether_provider_transport::{apply_local_body_rules, GatewayProviderTransportSnapshot};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::conversion::request::{
|
||||
@@ -136,45 +135,16 @@ pub fn build_standard_upstream_url(
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<String> {
|
||||
let custom_path = transport
|
||||
.endpoint
|
||||
.custom_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
match custom_path {
|
||||
Some(path) => {
|
||||
build_passthrough_path_url(&transport.endpoint.base_url, path, parts.uri.query(), &[])
|
||||
}
|
||||
None => match provider_api_format.trim().to_ascii_lowercase().as_str() {
|
||||
"openai:chat" => Some(build_openai_chat_url(
|
||||
&transport.endpoint.base_url,
|
||||
parts.uri.query(),
|
||||
)),
|
||||
"openai:cli" => Some(build_openai_cli_url(
|
||||
&transport.endpoint.base_url,
|
||||
parts.uri.query(),
|
||||
false,
|
||||
)),
|
||||
"openai:compact" => Some(build_openai_cli_url(
|
||||
&transport.endpoint.base_url,
|
||||
parts.uri.query(),
|
||||
true,
|
||||
)),
|
||||
"claude:chat" | "claude:cli" => Some(build_claude_messages_url(
|
||||
&transport.endpoint.base_url,
|
||||
parts.uri.query(),
|
||||
)),
|
||||
"gemini:chat" | "gemini:cli" => build_gemini_content_url(
|
||||
&transport.endpoint.base_url,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
parts.uri.query(),
|
||||
),
|
||||
_ => None,
|
||||
build_transport_request_url(
|
||||
transport,
|
||||
TransportRequestUrlParams {
|
||||
provider_api_format,
|
||||
mapped_model: Some(mapped_model),
|
||||
upstream_is_stream,
|
||||
request_query: parts.uri.query(),
|
||||
kiro_api_region: None,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user