mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +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:
@@ -48,6 +48,26 @@ pub(crate) use self::pure::*;
|
|||||||
pub(crate) use crate::control::GatewayControlDecision;
|
pub(crate) use crate::control::GatewayControlDecision;
|
||||||
pub(crate) use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
|
pub(crate) use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
|
||||||
|
|
||||||
|
pub(crate) fn build_provider_transport_request_url(
|
||||||
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
|
provider_api_format: &str,
|
||||||
|
mapped_model: Option<&str>,
|
||||||
|
upstream_is_stream: bool,
|
||||||
|
request_query: Option<&str>,
|
||||||
|
kiro_api_region: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
crate::provider_transport::build_transport_request_url(
|
||||||
|
transport,
|
||||||
|
crate::provider_transport::TransportRequestUrlParams {
|
||||||
|
provider_api_format,
|
||||||
|
mapped_model,
|
||||||
|
upstream_is_stream,
|
||||||
|
request_query,
|
||||||
|
kiro_api_region,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn resolve_execution_runtime_auth_context(
|
pub(crate) async fn resolve_execution_runtime_auth_context(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ use crate::ai_pipeline::transport::policy::{
|
|||||||
local_gemini_transport_unsupported_reason_with_network,
|
local_gemini_transport_unsupported_reason_with_network,
|
||||||
local_standard_transport_unsupported_reason_with_network,
|
local_standard_transport_unsupported_reason_with_network,
|
||||||
};
|
};
|
||||||
use crate::ai_pipeline::transport::vertex::local_vertex_api_key_gemini_transport_unsupported_reason_with_network;
|
use crate::ai_pipeline::transport::vertex::{
|
||||||
|
is_vertex_api_key_transport_context,
|
||||||
|
local_vertex_api_key_gemini_transport_unsupported_reason_with_network,
|
||||||
|
};
|
||||||
use crate::ai_pipeline::GatewayProviderTransportSnapshot;
|
use crate::ai_pipeline::GatewayProviderTransportSnapshot;
|
||||||
|
|
||||||
use super::super::LocalSameFormatProviderFamily;
|
use super::super::LocalSameFormatProviderFamily;
|
||||||
@@ -34,11 +37,7 @@ pub(super) fn classify_same_format_provider_request_behavior(
|
|||||||
.provider_type
|
.provider_type
|
||||||
.trim()
|
.trim()
|
||||||
.eq_ignore_ascii_case("claude_code");
|
.eq_ignore_ascii_case("claude_code");
|
||||||
let is_vertex = transport
|
let is_vertex = is_vertex_api_key_transport_context(transport);
|
||||||
.provider
|
|
||||||
.provider_type
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case("vertex_ai");
|
|
||||||
let is_kiro = transport
|
let is_kiro = transport
|
||||||
.provider
|
.provider
|
||||||
.provider_type
|
.provider_type
|
||||||
|
|||||||
@@ -1,16 +1,6 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use url::form_urlencoded;
|
|
||||||
|
|
||||||
use crate::ai_pipeline::GatewayProviderTransportSnapshot;
|
use crate::ai_pipeline::GatewayProviderTransportSnapshot;
|
||||||
|
|
||||||
use super::super::{
|
use super::super::LocalSameFormatProviderSpec;
|
||||||
build_antigravity_v1internal_url, build_claude_code_messages_url, build_claude_messages_url,
|
|
||||||
build_gemini_content_url, build_kiro_generate_assistant_response_url,
|
|
||||||
build_passthrough_path_url, build_vertex_api_key_gemini_content_url,
|
|
||||||
resolve_local_vertex_api_key_query_auth, AntigravityRequestUrlAction,
|
|
||||||
LocalSameFormatProviderFamily, LocalSameFormatProviderSpec,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub(crate) fn build_same_format_upstream_url(
|
pub(crate) fn build_same_format_upstream_url(
|
||||||
parts: &http::request::Parts,
|
parts: &http::request::Parts,
|
||||||
@@ -20,119 +10,16 @@ pub(crate) fn build_same_format_upstream_url(
|
|||||||
upstream_is_stream: bool,
|
upstream_is_stream: bool,
|
||||||
kiro_auth: Option<&crate::ai_pipeline::transport::kiro::KiroRequestAuth>,
|
kiro_auth: Option<&crate::ai_pipeline::transport::kiro::KiroRequestAuth>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
if let Some(kiro_auth) = kiro_auth {
|
maybe_add_gemini_stream_alt_sse(crate::ai_pipeline::build_provider_transport_request_url(
|
||||||
return build_kiro_generate_assistant_response_url(
|
transport,
|
||||||
&transport.endpoint.base_url,
|
spec.api_format,
|
||||||
parts.uri.query(),
|
Some(mapped_model),
|
||||||
Some(kiro_auth.auth_config.effective_api_region()),
|
upstream_is_stream,
|
||||||
);
|
parts.uri.query(),
|
||||||
}
|
kiro_auth.map(|auth| auth.auth_config.effective_api_region()),
|
||||||
if transport
|
))
|
||||||
.provider
|
|
||||||
.provider_type
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case("claude_code")
|
|
||||||
{
|
|
||||||
return Some(build_claude_code_messages_url(
|
|
||||||
&transport.endpoint.base_url,
|
|
||||||
parts.uri.query(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if transport
|
|
||||||
.provider
|
|
||||||
.provider_type
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case("vertex_ai")
|
|
||||||
{
|
|
||||||
let auth = resolve_local_vertex_api_key_query_auth(transport)?;
|
|
||||||
return build_vertex_api_key_gemini_content_url(
|
|
||||||
mapped_model,
|
|
||||||
upstream_is_stream,
|
|
||||||
&auth.value,
|
|
||||||
parts.uri.query(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if transport
|
|
||||||
.provider
|
|
||||||
.provider_type
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case("antigravity")
|
|
||||||
{
|
|
||||||
let query = parts.uri.query().map(|query| {
|
|
||||||
form_urlencoded::parse(query.as_bytes())
|
|
||||||
.into_owned()
|
|
||||||
.collect::<BTreeMap<String, String>>()
|
|
||||||
});
|
|
||||||
return build_antigravity_v1internal_url(
|
|
||||||
&transport.endpoint.base_url,
|
|
||||||
if upstream_is_stream {
|
|
||||||
AntigravityRequestUrlAction::StreamGenerateContent
|
|
||||||
} else {
|
|
||||||
AntigravityRequestUrlAction::GenerateContent
|
|
||||||
},
|
|
||||||
query.as_ref(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let custom_path = transport
|
|
||||||
.endpoint
|
|
||||||
.custom_path
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
|
|
||||||
if let Some(path) = custom_path {
|
|
||||||
let blocked_keys = match spec.family {
|
|
||||||
LocalSameFormatProviderFamily::Standard => &[][..],
|
|
||||||
LocalSameFormatProviderFamily::Gemini => &["key"][..],
|
|
||||||
};
|
|
||||||
let url = build_passthrough_path_url(
|
|
||||||
&transport.endpoint.base_url,
|
|
||||||
path,
|
|
||||||
parts.uri.query(),
|
|
||||||
blocked_keys,
|
|
||||||
)?;
|
|
||||||
return Some(maybe_add_gemini_stream_alt_sse(url, spec));
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = match spec.family {
|
|
||||||
LocalSameFormatProviderFamily::Standard => Some(build_claude_messages_url(
|
|
||||||
&transport.endpoint.base_url,
|
|
||||||
parts.uri.query(),
|
|
||||||
)),
|
|
||||||
LocalSameFormatProviderFamily::Gemini => build_gemini_content_url(
|
|
||||||
&transport.endpoint.base_url,
|
|
||||||
mapped_model,
|
|
||||||
spec.require_streaming,
|
|
||||||
parts.uri.query(),
|
|
||||||
),
|
|
||||||
}?;
|
|
||||||
|
|
||||||
Some(maybe_add_gemini_stream_alt_sse(url, spec))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn maybe_add_gemini_stream_alt_sse(
|
fn maybe_add_gemini_stream_alt_sse(url: Option<String>) -> Option<String> {
|
||||||
upstream_url: String,
|
url
|
||||||
spec: LocalSameFormatProviderSpec,
|
|
||||||
) -> String {
|
|
||||||
if spec.family != LocalSameFormatProviderFamily::Gemini || !spec.require_streaming {
|
|
||||||
return upstream_url;
|
|
||||||
}
|
|
||||||
|
|
||||||
let has_alt = upstream_url
|
|
||||||
.split_once('?')
|
|
||||||
.map(|(_, query)| {
|
|
||||||
form_urlencoded::parse(query.as_bytes())
|
|
||||||
.any(|(key, _)| key.as_ref().eq_ignore_ascii_case("alt"))
|
|
||||||
})
|
|
||||||
.unwrap_or(false);
|
|
||||||
if has_alt {
|
|
||||||
return upstream_url;
|
|
||||||
}
|
|
||||||
|
|
||||||
if upstream_url.contains('?') {
|
|
||||||
format!("{upstream_url}&alt=sse")
|
|
||||||
} else {
|
|
||||||
format!("{upstream_url}?alt=sse")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use crate::ai_pipeline::transport::apply_local_header_rules;
|
|||||||
use crate::ai_pipeline::transport::auth::{
|
use crate::ai_pipeline::transport::auth::{
|
||||||
build_claude_passthrough_headers, build_openai_passthrough_headers, ensure_upstream_auth_header,
|
build_claude_passthrough_headers, build_openai_passthrough_headers, ensure_upstream_auth_header,
|
||||||
};
|
};
|
||||||
|
use crate::ai_pipeline::transport::vertex::uses_vertex_api_key_query_auth;
|
||||||
use crate::ai_pipeline::GatewayProviderTransportSnapshot;
|
use crate::ai_pipeline::GatewayProviderTransportSnapshot;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
@@ -155,6 +156,7 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let uses_vertex_query_auth = uses_vertex_api_key_query_auth(transport, provider_api_format);
|
||||||
|
|
||||||
let mut provider_request_headers = if provider_api_format.starts_with("claude:") {
|
let mut provider_request_headers = if provider_api_format.starts_with("claude:") {
|
||||||
build_claude_passthrough_headers(
|
build_claude_passthrough_headers(
|
||||||
@@ -173,10 +175,15 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
|
|||||||
Some("application/json"),
|
Some("application/json"),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
let protected_headers = if uses_vertex_query_auth {
|
||||||
|
&["content-type"][..]
|
||||||
|
} else {
|
||||||
|
&[prepared_candidate.auth_header.as_str(), "content-type"][..]
|
||||||
|
};
|
||||||
if !apply_local_header_rules(
|
if !apply_local_header_rules(
|
||||||
&mut provider_request_headers,
|
&mut provider_request_headers,
|
||||||
transport.endpoint.header_rules.as_ref(),
|
transport.endpoint.header_rules.as_ref(),
|
||||||
&[&prepared_candidate.auth_header, "content-type"],
|
protected_headers,
|
||||||
&provider_request_body,
|
&provider_request_body,
|
||||||
Some(body_json),
|
Some(body_json),
|
||||||
) {
|
) {
|
||||||
@@ -201,11 +208,20 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
|
|||||||
Some(trace_id),
|
Some(trace_id),
|
||||||
transport.key.decrypted_auth_config.as_deref(),
|
transport.key.decrypted_auth_config.as_deref(),
|
||||||
);
|
);
|
||||||
ensure_upstream_auth_header(
|
let (auth_header, auth_value) = if uses_vertex_query_auth {
|
||||||
&mut provider_request_headers,
|
provider_request_headers.remove("x-goog-api-key");
|
||||||
&prepared_candidate.auth_header,
|
(String::new(), String::new())
|
||||||
&prepared_candidate.auth_value,
|
} else {
|
||||||
);
|
ensure_upstream_auth_header(
|
||||||
|
&mut provider_request_headers,
|
||||||
|
&prepared_candidate.auth_header,
|
||||||
|
&prepared_candidate.auth_value,
|
||||||
|
);
|
||||||
|
(
|
||||||
|
prepared_candidate.auth_header.clone(),
|
||||||
|
prepared_candidate.auth_value.clone(),
|
||||||
|
)
|
||||||
|
};
|
||||||
if upstream_is_stream {
|
if upstream_is_stream {
|
||||||
provider_request_headers
|
provider_request_headers
|
||||||
.entry("accept".to_string())
|
.entry("accept".to_string())
|
||||||
@@ -213,8 +229,8 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Some(LocalStandardCandidatePayloadParts {
|
Some(LocalStandardCandidatePayloadParts {
|
||||||
auth_header: prepared_candidate.auth_header,
|
auth_header,
|
||||||
auth_value: prepared_candidate.auth_value,
|
auth_value,
|
||||||
mapped_model: prepared_candidate.mapped_model,
|
mapped_model: prepared_candidate.mapped_model,
|
||||||
provider_api_format: provider_api_format.to_string(),
|
provider_api_format: provider_api_format.to_string(),
|
||||||
provider_request_body,
|
provider_request_body,
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use serde_json::Value;
|
|||||||
use crate::ai_pipeline::conversion::{request_conversion_kind, RequestConversionKind};
|
use crate::ai_pipeline::conversion::{request_conversion_kind, RequestConversionKind};
|
||||||
use crate::ai_pipeline::transport::apply_local_body_rules;
|
use crate::ai_pipeline::transport::apply_local_body_rules;
|
||||||
use crate::ai_pipeline::transport::url::{
|
use crate::ai_pipeline::transport::url::{
|
||||||
build_claude_messages_url, build_gemini_content_url, build_openai_chat_url,
|
build_claude_messages_url, build_openai_chat_url, build_openai_cli_url,
|
||||||
build_openai_cli_url, build_passthrough_path_url,
|
build_passthrough_path_url,
|
||||||
};
|
};
|
||||||
use crate::ai_pipeline::{
|
use crate::ai_pipeline::{
|
||||||
apply_codex_openai_cli_special_body_edits, apply_openai_compact_special_body_edits,
|
apply_codex_openai_cli_special_body_edits, apply_openai_compact_special_body_edits,
|
||||||
@@ -102,12 +102,16 @@ pub(crate) fn build_cross_format_openai_chat_upstream_url(
|
|||||||
&transport.endpoint.base_url,
|
&transport.endpoint.base_url,
|
||||||
parts.uri.query(),
|
parts.uri.query(),
|
||||||
)),
|
)),
|
||||||
RequestConversionKind::ToGeminiStandard => build_gemini_content_url(
|
RequestConversionKind::ToGeminiStandard => {
|
||||||
&transport.endpoint.base_url,
|
crate::ai_pipeline::build_provider_transport_request_url(
|
||||||
mapped_model,
|
transport,
|
||||||
upstream_is_stream,
|
provider_api_format,
|
||||||
parts.uri.query(),
|
Some(mapped_model),
|
||||||
),
|
upstream_is_stream,
|
||||||
|
parts.uri.query(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
RequestConversionKind::ToOpenAIFamilyCli => Some(build_openai_cli_url(
|
RequestConversionKind::ToOpenAIFamilyCli => Some(build_openai_cli_url(
|
||||||
&transport.endpoint.base_url,
|
&transport.endpoint.base_url,
|
||||||
parts.uri.query(),
|
parts.uri.query(),
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use crate::ai_pipeline::transport::antigravity::{
|
|||||||
};
|
};
|
||||||
use crate::ai_pipeline::transport::apply_local_body_rules;
|
use crate::ai_pipeline::transport::apply_local_body_rules;
|
||||||
use crate::ai_pipeline::transport::url::{
|
use crate::ai_pipeline::transport::url::{
|
||||||
build_claude_messages_url, build_gemini_content_url, build_openai_chat_url,
|
build_claude_messages_url, build_openai_chat_url, build_openai_cli_url,
|
||||||
build_openai_cli_url, build_passthrough_path_url,
|
build_passthrough_path_url,
|
||||||
};
|
};
|
||||||
use crate::ai_pipeline::{
|
use crate::ai_pipeline::{
|
||||||
apply_codex_openai_cli_special_body_edits, apply_openai_compact_special_body_edits,
|
apply_codex_openai_cli_special_body_edits, apply_openai_compact_special_body_edits,
|
||||||
@@ -155,12 +155,16 @@ pub(crate) fn build_cross_format_openai_cli_upstream_url(
|
|||||||
&transport.endpoint.base_url,
|
&transport.endpoint.base_url,
|
||||||
parts.uri.query(),
|
parts.uri.query(),
|
||||||
)),
|
)),
|
||||||
RequestConversionKind::ToGeminiStandard => build_gemini_content_url(
|
RequestConversionKind::ToGeminiStandard => {
|
||||||
&transport.endpoint.base_url,
|
crate::ai_pipeline::build_provider_transport_request_url(
|
||||||
mapped_model,
|
transport,
|
||||||
upstream_is_stream,
|
provider_api_format,
|
||||||
parts.uri.query(),
|
Some(mapped_model),
|
||||||
),
|
upstream_is_stream,
|
||||||
|
parts.uri.query(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use crate::ai_pipeline::transport::auth::{
|
|||||||
resolve_local_openai_bearer_auth,
|
resolve_local_openai_bearer_auth,
|
||||||
};
|
};
|
||||||
use crate::ai_pipeline::transport::local_openai_chat_transport_unsupported_reason;
|
use crate::ai_pipeline::transport::local_openai_chat_transport_unsupported_reason;
|
||||||
|
use crate::ai_pipeline::transport::vertex::uses_vertex_api_key_query_auth;
|
||||||
use crate::ai_pipeline::{ConversionMode, ExecutionStrategy, GatewayProviderTransportSnapshot};
|
use crate::ai_pipeline::{ConversionMode, ExecutionStrategy, GatewayProviderTransportSnapshot};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
@@ -301,6 +302,8 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
|||||||
.await;
|
.await;
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
|
let uses_vertex_query_auth =
|
||||||
|
uses_vertex_api_key_query_auth(transport, provider_api_format.as_str());
|
||||||
|
|
||||||
let mut provider_request_headers = if provider_api_format.starts_with("claude:") {
|
let mut provider_request_headers = if provider_api_format.starts_with("claude:") {
|
||||||
build_claude_passthrough_headers(
|
build_claude_passthrough_headers(
|
||||||
@@ -319,10 +322,15 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
|||||||
Some("application/json"),
|
Some("application/json"),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
let protected_headers = if uses_vertex_query_auth {
|
||||||
|
&["content-type"][..]
|
||||||
|
} else {
|
||||||
|
&[prepared_candidate.auth_header.as_str(), "content-type"][..]
|
||||||
|
};
|
||||||
if !apply_local_header_rules(
|
if !apply_local_header_rules(
|
||||||
&mut provider_request_headers,
|
&mut provider_request_headers,
|
||||||
transport.endpoint.header_rules.as_ref(),
|
transport.endpoint.header_rules.as_ref(),
|
||||||
&[&prepared_candidate.auth_header, "content-type"],
|
protected_headers,
|
||||||
&provider_request_body,
|
&provider_request_body,
|
||||||
Some(body_json),
|
Some(body_json),
|
||||||
) {
|
) {
|
||||||
@@ -347,11 +355,20 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
|||||||
Some(trace_id),
|
Some(trace_id),
|
||||||
transport.key.decrypted_auth_config.as_deref(),
|
transport.key.decrypted_auth_config.as_deref(),
|
||||||
);
|
);
|
||||||
ensure_upstream_auth_header(
|
let (auth_header, auth_value) = if uses_vertex_query_auth {
|
||||||
&mut provider_request_headers,
|
provider_request_headers.remove("x-goog-api-key");
|
||||||
&prepared_candidate.auth_header,
|
(String::new(), String::new())
|
||||||
&prepared_candidate.auth_value,
|
} else {
|
||||||
);
|
ensure_upstream_auth_header(
|
||||||
|
&mut provider_request_headers,
|
||||||
|
&prepared_candidate.auth_header,
|
||||||
|
&prepared_candidate.auth_value,
|
||||||
|
);
|
||||||
|
(
|
||||||
|
prepared_candidate.auth_header.clone(),
|
||||||
|
prepared_candidate.auth_value.clone(),
|
||||||
|
)
|
||||||
|
};
|
||||||
if upstream_is_stream {
|
if upstream_is_stream {
|
||||||
provider_request_headers
|
provider_request_headers
|
||||||
.entry("accept".to_string())
|
.entry("accept".to_string())
|
||||||
@@ -365,8 +382,8 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
|||||||
};
|
};
|
||||||
|
|
||||||
Some(LocalOpenAiChatCandidatePayloadParts {
|
Some(LocalOpenAiChatCandidatePayloadParts {
|
||||||
auth_header: prepared_candidate.auth_header,
|
auth_header,
|
||||||
auth_value: prepared_candidate.auth_value,
|
auth_value,
|
||||||
mapped_model: prepared_candidate.mapped_model,
|
mapped_model: prepared_candidate.mapped_model,
|
||||||
provider_api_format,
|
provider_api_format,
|
||||||
provider_request_body,
|
provider_request_body,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ use crate::ai_pipeline::transport::auth::{
|
|||||||
resolve_local_openai_bearer_auth, resolve_local_standard_auth,
|
resolve_local_openai_bearer_auth, resolve_local_standard_auth,
|
||||||
};
|
};
|
||||||
use crate::ai_pipeline::transport::local_standard_transport_unsupported_reason_with_network;
|
use crate::ai_pipeline::transport::local_standard_transport_unsupported_reason_with_network;
|
||||||
|
use crate::ai_pipeline::transport::vertex::uses_vertex_api_key_query_auth;
|
||||||
use crate::ai_pipeline::{ConversionMode, ExecutionStrategy};
|
use crate::ai_pipeline::{ConversionMode, ExecutionStrategy};
|
||||||
use crate::ai_pipeline::{GatewayProviderTransportSnapshot, PlannerAppState};
|
use crate::ai_pipeline::{GatewayProviderTransportSnapshot, PlannerAppState};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
@@ -267,6 +268,7 @@ pub(crate) async fn resolve_local_openai_cli_candidate_payload_parts(
|
|||||||
.await;
|
.await;
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
|
let uses_vertex_query_auth = uses_vertex_api_key_query_auth(transport, provider_api_format);
|
||||||
|
|
||||||
let extra_headers = antigravity_auth
|
let extra_headers = antigravity_auth
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -297,10 +299,15 @@ pub(crate) async fn resolve_local_openai_cli_candidate_payload_parts(
|
|||||||
Some("application/json"),
|
Some("application/json"),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
let protected_headers = if uses_vertex_query_auth {
|
||||||
|
&["content-type"][..]
|
||||||
|
} else {
|
||||||
|
&[auth_header.as_str(), "content-type"][..]
|
||||||
|
};
|
||||||
if !apply_local_header_rules(
|
if !apply_local_header_rules(
|
||||||
&mut provider_request_headers,
|
&mut provider_request_headers,
|
||||||
transport.endpoint.header_rules.as_ref(),
|
transport.endpoint.header_rules.as_ref(),
|
||||||
&[&auth_header, "content-type"],
|
protected_headers,
|
||||||
&provider_request_body,
|
&provider_request_body,
|
||||||
Some(body_json),
|
Some(body_json),
|
||||||
) {
|
) {
|
||||||
@@ -325,7 +332,13 @@ pub(crate) async fn resolve_local_openai_cli_candidate_payload_parts(
|
|||||||
Some(trace_id),
|
Some(trace_id),
|
||||||
transport.key.decrypted_auth_config.as_deref(),
|
transport.key.decrypted_auth_config.as_deref(),
|
||||||
);
|
);
|
||||||
ensure_upstream_auth_header(&mut provider_request_headers, &auth_header, &auth_value);
|
let (auth_header, auth_value) = if uses_vertex_query_auth {
|
||||||
|
provider_request_headers.remove("x-goog-api-key");
|
||||||
|
(String::new(), String::new())
|
||||||
|
} else {
|
||||||
|
ensure_upstream_auth_header(&mut provider_request_headers, &auth_header, &auth_value);
|
||||||
|
(auth_header, auth_value)
|
||||||
|
};
|
||||||
if upstream_is_stream {
|
if upstream_is_stream {
|
||||||
provider_request_headers
|
provider_request_headers
|
||||||
.entry("accept".to_string())
|
.entry("accept".to_string())
|
||||||
|
|||||||
@@ -31,12 +31,11 @@ pub(crate) fn build_openai_chat_stream_plan_from_decision(
|
|||||||
let Some(key_id) = take_non_empty_string(&mut payload.key_id) else {
|
let Some(key_id) = take_non_empty_string(&mut payload.key_id) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let Some(auth_header) = take_non_empty_string(&mut payload.auth_header) else {
|
let auth_header = take_non_empty_string(&mut payload.auth_header);
|
||||||
|
let auth_value = take_non_empty_string(&mut payload.auth_value);
|
||||||
|
if auth_header.is_some() != auth_value.is_some() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
}
|
||||||
let Some(auth_value) = take_non_empty_string(&mut payload.auth_value) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(provider_api_format) = take_non_empty_string(&mut payload.provider_api_format) else {
|
let Some(provider_api_format) = take_non_empty_string(&mut payload.provider_api_format) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
@@ -86,27 +85,37 @@ pub(crate) fn build_openai_chat_stream_plan_from_decision(
|
|||||||
let existing_provider_request_headers = std::mem::take(&mut payload.provider_request_headers);
|
let existing_provider_request_headers = std::mem::take(&mut payload.provider_request_headers);
|
||||||
let extra_headers = std::mem::take(&mut payload.extra_headers);
|
let extra_headers = std::mem::take(&mut payload.extra_headers);
|
||||||
let mut provider_request_headers = if existing_provider_request_headers.is_empty() {
|
let mut provider_request_headers = if existing_provider_request_headers.is_empty() {
|
||||||
if provider_api_format == client_api_format {
|
if let (Some(auth_header), Some(auth_value)) =
|
||||||
build_complete_passthrough_headers_with_auth(
|
(auth_header.as_deref(), auth_value.as_deref())
|
||||||
&parts.headers,
|
{
|
||||||
&auth_header,
|
if provider_api_format == client_api_format {
|
||||||
&auth_value,
|
build_complete_passthrough_headers_with_auth(
|
||||||
&extra_headers,
|
&parts.headers,
|
||||||
payload.content_type.as_deref(),
|
auth_header,
|
||||||
)
|
auth_value,
|
||||||
} else if provider_api_format.starts_with("claude:") {
|
&extra_headers,
|
||||||
build_claude_passthrough_headers(
|
payload.content_type.as_deref(),
|
||||||
&parts.headers,
|
)
|
||||||
&auth_header,
|
} else if provider_api_format.starts_with("claude:") {
|
||||||
&auth_value,
|
build_claude_passthrough_headers(
|
||||||
&extra_headers,
|
&parts.headers,
|
||||||
payload.content_type.as_deref(),
|
auth_header,
|
||||||
)
|
auth_value,
|
||||||
|
&extra_headers,
|
||||||
|
payload.content_type.as_deref(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
build_openai_passthrough_headers(
|
||||||
|
&parts.headers,
|
||||||
|
auth_header,
|
||||||
|
auth_value,
|
||||||
|
&extra_headers,
|
||||||
|
payload.content_type.as_deref(),
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
build_openai_passthrough_headers(
|
crate::ai_pipeline::transport::auth::build_passthrough_headers(
|
||||||
&parts.headers,
|
&parts.headers,
|
||||||
&auth_header,
|
|
||||||
&auth_value,
|
|
||||||
&extra_headers,
|
&extra_headers,
|
||||||
payload.content_type.as_deref(),
|
payload.content_type.as_deref(),
|
||||||
)
|
)
|
||||||
@@ -114,7 +123,9 @@ pub(crate) fn build_openai_chat_stream_plan_from_decision(
|
|||||||
} else {
|
} else {
|
||||||
existing_provider_request_headers
|
existing_provider_request_headers
|
||||||
};
|
};
|
||||||
ensure_upstream_auth_header(&mut provider_request_headers, &auth_header, &auth_value);
|
if let (Some(auth_header), Some(auth_value)) = (auth_header.as_deref(), auth_value.as_deref()) {
|
||||||
|
ensure_upstream_auth_header(&mut provider_request_headers, auth_header, auth_value);
|
||||||
|
}
|
||||||
provider_request_headers.insert("accept".to_string(), "text/event-stream".to_string());
|
provider_request_headers.insert("accept".to_string(), "text/event-stream".to_string());
|
||||||
let content_type = payload
|
let content_type = payload
|
||||||
.content_type
|
.content_type
|
||||||
|
|||||||
@@ -30,12 +30,11 @@ pub(crate) fn build_openai_chat_sync_plan_from_decision(
|
|||||||
let Some(key_id) = take_non_empty_string(&mut payload.key_id) else {
|
let Some(key_id) = take_non_empty_string(&mut payload.key_id) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let Some(auth_header) = take_non_empty_string(&mut payload.auth_header) else {
|
let auth_header = take_non_empty_string(&mut payload.auth_header);
|
||||||
|
let auth_value = take_non_empty_string(&mut payload.auth_value);
|
||||||
|
if auth_header.is_some() != auth_value.is_some() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
}
|
||||||
let Some(auth_value) = take_non_empty_string(&mut payload.auth_value) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(provider_api_format) = take_non_empty_string(&mut payload.provider_api_format) else {
|
let Some(provider_api_format) = take_non_empty_string(&mut payload.provider_api_format) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
@@ -86,27 +85,37 @@ pub(crate) fn build_openai_chat_sync_plan_from_decision(
|
|||||||
let existing_provider_request_headers = std::mem::take(&mut payload.provider_request_headers);
|
let existing_provider_request_headers = std::mem::take(&mut payload.provider_request_headers);
|
||||||
let extra_headers = std::mem::take(&mut payload.extra_headers);
|
let extra_headers = std::mem::take(&mut payload.extra_headers);
|
||||||
let mut provider_request_headers = if existing_provider_request_headers.is_empty() {
|
let mut provider_request_headers = if existing_provider_request_headers.is_empty() {
|
||||||
if provider_api_format == client_api_format {
|
if let (Some(auth_header), Some(auth_value)) =
|
||||||
build_complete_passthrough_headers_with_auth(
|
(auth_header.as_deref(), auth_value.as_deref())
|
||||||
&parts.headers,
|
{
|
||||||
&auth_header,
|
if provider_api_format == client_api_format {
|
||||||
&auth_value,
|
build_complete_passthrough_headers_with_auth(
|
||||||
&extra_headers,
|
&parts.headers,
|
||||||
payload.content_type.as_deref(),
|
auth_header,
|
||||||
)
|
auth_value,
|
||||||
} else if provider_api_format.starts_with("claude:") {
|
&extra_headers,
|
||||||
build_claude_passthrough_headers(
|
payload.content_type.as_deref(),
|
||||||
&parts.headers,
|
)
|
||||||
&auth_header,
|
} else if provider_api_format.starts_with("claude:") {
|
||||||
&auth_value,
|
build_claude_passthrough_headers(
|
||||||
&extra_headers,
|
&parts.headers,
|
||||||
payload.content_type.as_deref(),
|
auth_header,
|
||||||
)
|
auth_value,
|
||||||
|
&extra_headers,
|
||||||
|
payload.content_type.as_deref(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
build_openai_passthrough_headers(
|
||||||
|
&parts.headers,
|
||||||
|
auth_header,
|
||||||
|
auth_value,
|
||||||
|
&extra_headers,
|
||||||
|
payload.content_type.as_deref(),
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
build_openai_passthrough_headers(
|
crate::ai_pipeline::transport::auth::build_passthrough_headers(
|
||||||
&parts.headers,
|
&parts.headers,
|
||||||
&auth_header,
|
|
||||||
&auth_value,
|
|
||||||
&extra_headers,
|
&extra_headers,
|
||||||
payload.content_type.as_deref(),
|
payload.content_type.as_deref(),
|
||||||
)
|
)
|
||||||
@@ -114,7 +123,9 @@ pub(crate) fn build_openai_chat_sync_plan_from_decision(
|
|||||||
} else {
|
} else {
|
||||||
existing_provider_request_headers
|
existing_provider_request_headers
|
||||||
};
|
};
|
||||||
ensure_upstream_auth_header(&mut provider_request_headers, &auth_header, &auth_value);
|
if let (Some(auth_header), Some(auth_value)) = (auth_header.as_deref(), auth_value.as_deref()) {
|
||||||
|
ensure_upstream_auth_header(&mut provider_request_headers, auth_header, auth_value);
|
||||||
|
}
|
||||||
if payload.upstream_is_stream {
|
if payload.upstream_is_stream {
|
||||||
provider_request_headers
|
provider_request_headers
|
||||||
.entry("accept".to_string())
|
.entry("accept".to_string())
|
||||||
|
|||||||
@@ -619,8 +619,9 @@ async fn load_runtime_miss_candidate_contexts(
|
|||||||
&candidate,
|
&candidate,
|
||||||
"selected_provider_model_name",
|
"selected_provider_model_name",
|
||||||
),
|
),
|
||||||
endpoint_url: endpoint
|
endpoint_url: endpoint.and_then(|value| {
|
||||||
.and_then(|value| build_runtime_miss_candidate_endpoint_url(value, decision)),
|
build_runtime_miss_candidate_endpoint_url(&candidate, value, decision)
|
||||||
|
}),
|
||||||
candidate,
|
candidate,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -671,9 +672,14 @@ fn candidate_extra_data_string(candidate: &StoredRequestCandidate, key: &str) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_runtime_miss_candidate_endpoint_url(
|
fn build_runtime_miss_candidate_endpoint_url(
|
||||||
|
candidate: &StoredRequestCandidate,
|
||||||
endpoint: &StoredProviderCatalogEndpoint,
|
endpoint: &StoredProviderCatalogEndpoint,
|
||||||
decision: Option<&GatewayControlDecision>,
|
decision: Option<&GatewayControlDecision>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
|
if let Some(upstream_url) = candidate_extra_data_string(candidate, "upstream_url") {
|
||||||
|
return Some(upstream_url);
|
||||||
|
}
|
||||||
|
|
||||||
let path = endpoint
|
let path = endpoint
|
||||||
.custom_path
|
.custom_path
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
|||||||
@@ -308,7 +308,11 @@ fn provider_query_transport_supports_standard_test_execution(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
"gemini:chat" | "gemini:cli" => {
|
"gemini:chat" | "gemini:cli" => {
|
||||||
state.supports_local_gemini_transport_with_network(transport, api_format)
|
if crate::provider_transport::is_vertex_api_key_transport_context(transport) {
|
||||||
|
aether_provider_transport::vertex::supports_local_vertex_api_key_gemini_transport_with_network(transport)
|
||||||
|
} else {
|
||||||
|
state.supports_local_gemini_transport_with_network(transport, api_format)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
@@ -1018,6 +1022,13 @@ async fn provider_query_execute_standard_test_candidate(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let uses_vertex_query_auth =
|
||||||
|
crate::provider_transport::uses_vertex_api_key_query_auth(&transport, provider_api_format);
|
||||||
|
let vertex_query_auth = if uses_vertex_query_auth {
|
||||||
|
aether_provider_transport::vertex::resolve_local_vertex_api_key_query_auth(&transport)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let oauth_auth = match provider_api_format {
|
let oauth_auth = match provider_api_format {
|
||||||
"openai:chat" | "openai:cli" | "claude:chat" | "claude:cli" | "gemini:chat"
|
"openai:chat" | "openai:cli" | "claude:chat" | "claude:cli" | "gemini:chat"
|
||||||
| "gemini:cli" => state.resolve_local_oauth_header_auth(&transport).await?,
|
| "gemini:cli" => state.resolve_local_oauth_header_auth(&transport).await?,
|
||||||
@@ -1031,14 +1042,24 @@ async fn provider_query_execute_standard_test_candidate(
|
|||||||
"claude:chat" | "claude:cli" => {
|
"claude:chat" | "claude:cli" => {
|
||||||
crate::provider_transport::auth::resolve_local_standard_auth(&transport).or(oauth_auth)
|
crate::provider_transport::auth::resolve_local_standard_auth(&transport).or(oauth_auth)
|
||||||
}
|
}
|
||||||
"gemini:chat" | "gemini:cli" => state.resolve_local_gemini_auth(&transport).or(oauth_auth),
|
"gemini:chat" | "gemini:cli" => {
|
||||||
|
if uses_vertex_query_auth {
|
||||||
|
oauth_auth
|
||||||
|
} else {
|
||||||
|
state.resolve_local_gemini_auth(&transport).or(oauth_auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let Some((auth_header, auth_value)) = auth else {
|
let (auth_header, auth_value) = match auth {
|
||||||
return Ok(provider_query_skipped_execution_outcome(
|
Some((auth_header, auth_value)) => (Some(auth_header), Some(auth_value)),
|
||||||
provider_request_body,
|
None if uses_vertex_query_auth && vertex_query_auth.is_some() => (None, None),
|
||||||
format!("Provider auth is unavailable for {provider_api_format}"),
|
None => {
|
||||||
));
|
return Ok(provider_query_skipped_execution_outcome(
|
||||||
|
provider_request_body,
|
||||||
|
format!("Provider auth is unavailable for {provider_api_format}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut synthetic_request = http::Request::builder()
|
let mut synthetic_request = http::Request::builder()
|
||||||
@@ -1048,91 +1069,16 @@ async fn provider_query_execute_standard_test_candidate(
|
|||||||
*synthetic_request.headers_mut() = provider_query_extract_request_headers(payload);
|
*synthetic_request.headers_mut() = provider_query_extract_request_headers(payload);
|
||||||
let (parts, _) = synthetic_request.into_parts();
|
let (parts, _) = synthetic_request.into_parts();
|
||||||
|
|
||||||
let request_url = match provider_api_format {
|
let request_url = crate::provider_transport::build_transport_request_url(
|
||||||
"openai:chat" => {
|
&transport,
|
||||||
let custom_path = transport
|
crate::provider_transport::TransportRequestUrlParams {
|
||||||
.endpoint
|
provider_api_format,
|
||||||
.custom_path
|
mapped_model: Some(candidate.effective_model.as_str()),
|
||||||
.as_deref()
|
upstream_is_stream: false,
|
||||||
.map(str::trim)
|
request_query: parts.uri.query(),
|
||||||
.filter(|value| !value.is_empty());
|
kiro_api_region: None,
|
||||||
match custom_path {
|
},
|
||||||
Some(path) => state.build_passthrough_path_url(
|
);
|
||||||
&transport.endpoint.base_url,
|
|
||||||
path,
|
|
||||||
parts.uri.query(),
|
|
||||||
&[],
|
|
||||||
),
|
|
||||||
None => Some(
|
|
||||||
state.build_openai_chat_url(&transport.endpoint.base_url, parts.uri.query()),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"claude:chat" | "claude:cli" => {
|
|
||||||
let custom_path = transport
|
|
||||||
.endpoint
|
|
||||||
.custom_path
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
match custom_path {
|
|
||||||
Some(path) => state.build_passthrough_path_url(
|
|
||||||
&transport.endpoint.base_url,
|
|
||||||
path,
|
|
||||||
parts.uri.query(),
|
|
||||||
&[],
|
|
||||||
),
|
|
||||||
None => Some(
|
|
||||||
state
|
|
||||||
.build_claude_messages_url(&transport.endpoint.base_url, parts.uri.query()),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"gemini:chat" | "gemini:cli" => {
|
|
||||||
let custom_path = transport
|
|
||||||
.endpoint
|
|
||||||
.custom_path
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
match custom_path {
|
|
||||||
Some(path) => state.build_passthrough_path_url(
|
|
||||||
&transport.endpoint.base_url,
|
|
||||||
path,
|
|
||||||
parts.uri.query(),
|
|
||||||
&["key"],
|
|
||||||
),
|
|
||||||
None => state.build_gemini_content_url(
|
|
||||||
&transport.endpoint.base_url,
|
|
||||||
&candidate.effective_model,
|
|
||||||
false,
|
|
||||||
parts.uri.query(),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"openai:cli" => {
|
|
||||||
let custom_path = transport
|
|
||||||
.endpoint
|
|
||||||
.custom_path
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
match custom_path {
|
|
||||||
Some(path) => state.build_passthrough_path_url(
|
|
||||||
&transport.endpoint.base_url,
|
|
||||||
path,
|
|
||||||
parts.uri.query(),
|
|
||||||
&[],
|
|
||||||
),
|
|
||||||
None => Some(build_openai_cli_url(
|
|
||||||
&transport.endpoint.base_url,
|
|
||||||
parts.uri.query(),
|
|
||||||
false,
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
let Some(request_url) = request_url else {
|
let Some(request_url) = request_url else {
|
||||||
return Ok(provider_query_skipped_execution_outcome(
|
return Ok(provider_query_skipped_execution_outcome(
|
||||||
provider_request_body,
|
provider_request_body,
|
||||||
@@ -1144,8 +1090,8 @@ async fn provider_query_execute_standard_test_candidate(
|
|||||||
"claude:chat" | "claude:cli" => {
|
"claude:chat" | "claude:cli" => {
|
||||||
crate::provider_transport::auth::build_claude_passthrough_headers(
|
crate::provider_transport::auth::build_claude_passthrough_headers(
|
||||||
&parts.headers,
|
&parts.headers,
|
||||||
&auth_header,
|
auth_header.as_deref().unwrap_or_default(),
|
||||||
&auth_value,
|
auth_value.as_deref().unwrap_or_default(),
|
||||||
&BTreeMap::new(),
|
&BTreeMap::new(),
|
||||||
Some("application/json"),
|
Some("application/json"),
|
||||||
)
|
)
|
||||||
@@ -1153,26 +1099,41 @@ async fn provider_query_execute_standard_test_candidate(
|
|||||||
"openai:cli" => {
|
"openai:cli" => {
|
||||||
crate::provider_transport::auth::build_complete_passthrough_headers_with_auth(
|
crate::provider_transport::auth::build_complete_passthrough_headers_with_auth(
|
||||||
&parts.headers,
|
&parts.headers,
|
||||||
&auth_header,
|
auth_header.as_deref().unwrap_or_default(),
|
||||||
&auth_value,
|
auth_value.as_deref().unwrap_or_default(),
|
||||||
&BTreeMap::new(),
|
&BTreeMap::new(),
|
||||||
Some("application/json"),
|
Some("application/json"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_ => state.build_passthrough_headers_with_auth(
|
_ => match (auth_header.as_deref(), auth_value.as_deref()) {
|
||||||
&parts.headers,
|
(Some(auth_header), Some(auth_value)) => state.build_passthrough_headers_with_auth(
|
||||||
&auth_header,
|
&parts.headers,
|
||||||
&auth_value,
|
auth_header,
|
||||||
&BTreeMap::new(),
|
auth_value,
|
||||||
),
|
&BTreeMap::new(),
|
||||||
|
),
|
||||||
|
_ => crate::provider_transport::auth::build_passthrough_headers(
|
||||||
|
&parts.headers,
|
||||||
|
&BTreeMap::new(),
|
||||||
|
Some("application/json"),
|
||||||
|
),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
if uses_vertex_query_auth {
|
||||||
|
request_headers.remove("x-goog-api-key");
|
||||||
|
}
|
||||||
request_headers
|
request_headers
|
||||||
.entry("content-type".to_string())
|
.entry("content-type".to_string())
|
||||||
.or_insert_with(|| "application/json".to_string());
|
.or_insert_with(|| "application/json".to_string());
|
||||||
|
let protected_headers = if uses_vertex_query_auth {
|
||||||
|
vec!["content-type"]
|
||||||
|
} else {
|
||||||
|
vec![auth_header.as_deref().unwrap_or_default(), "content-type"]
|
||||||
|
};
|
||||||
if !state.apply_local_header_rules(
|
if !state.apply_local_header_rules(
|
||||||
&mut request_headers,
|
&mut request_headers,
|
||||||
transport.endpoint.header_rules.as_ref(),
|
transport.endpoint.header_rules.as_ref(),
|
||||||
&[auth_header.as_str(), "content-type"],
|
&protected_headers,
|
||||||
&provider_request_body,
|
&provider_request_body,
|
||||||
Some(&request_body),
|
Some(&request_body),
|
||||||
) {
|
) {
|
||||||
@@ -1200,11 +1161,17 @@ async fn provider_query_execute_standard_test_candidate(
|
|||||||
transport.key.decrypted_auth_config.as_deref(),
|
transport.key.decrypted_auth_config.as_deref(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
crate::provider_transport::ensure_upstream_auth_header(
|
if !uses_vertex_query_auth {
|
||||||
&mut request_headers,
|
if let (Some(auth_header), Some(auth_value)) =
|
||||||
&auth_header,
|
(auth_header.as_deref(), auth_value.as_deref())
|
||||||
&auth_value,
|
{
|
||||||
);
|
crate::provider_transport::ensure_upstream_auth_header(
|
||||||
|
&mut request_headers,
|
||||||
|
auth_header,
|
||||||
|
auth_value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let plan = ExecutionPlan {
|
let plan = ExecutionPlan {
|
||||||
request_id: trace_id.to_string(),
|
request_id: trace_id.to_string(),
|
||||||
|
|||||||
@@ -219,50 +219,21 @@ pub(super) async fn maybe_build_local_test_connection_route_response(
|
|||||||
let Some((auth_header, auth_value)) = auth else {
|
let Some((auth_header, auth_value)) = auth else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
|
let uses_vertex_query_auth = crate::provider_transport::uses_vertex_api_key_query_auth(
|
||||||
|
&transport,
|
||||||
|
format_value.as_str(),
|
||||||
|
);
|
||||||
|
|
||||||
let upstream_url = {
|
let upstream_url = crate::provider_transport::build_transport_request_url(
|
||||||
let custom_path = transport
|
&transport,
|
||||||
.endpoint
|
crate::provider_transport::TransportRequestUrlParams {
|
||||||
.custom_path
|
provider_api_format: format_value.as_str(),
|
||||||
.as_deref()
|
mapped_model: Some(model.as_str()),
|
||||||
.map(str::trim)
|
upstream_is_stream: false,
|
||||||
.filter(|value| !value.is_empty());
|
request_query: None,
|
||||||
match (format_value.as_str(), custom_path) {
|
kiro_api_region: None,
|
||||||
("openai:chat", Some(path)) | ("claude:chat", Some(path)) => {
|
},
|
||||||
crate::provider_transport::url::build_passthrough_path_url(
|
);
|
||||||
&transport.endpoint.base_url,
|
|
||||||
path,
|
|
||||||
None,
|
|
||||||
&[],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
("gemini:chat", Some(path)) => {
|
|
||||||
crate::provider_transport::url::build_passthrough_path_url(
|
|
||||||
&transport.endpoint.base_url,
|
|
||||||
path,
|
|
||||||
None,
|
|
||||||
&["key"],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
("openai:chat", None) => Some(crate::provider_transport::url::build_openai_chat_url(
|
|
||||||
&transport.endpoint.base_url,
|
|
||||||
None,
|
|
||||||
)),
|
|
||||||
("claude:chat", None) => {
|
|
||||||
Some(crate::provider_transport::url::build_claude_messages_url(
|
|
||||||
&transport.endpoint.base_url,
|
|
||||||
None,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
("gemini:chat", None) => crate::provider_transport::url::build_gemini_content_url(
|
|
||||||
&transport.endpoint.base_url,
|
|
||||||
&model,
|
|
||||||
false,
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let Some(upstream_url) = upstream_url else {
|
let Some(upstream_url) = upstream_url else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
@@ -271,20 +242,30 @@ pub(super) async fn maybe_build_local_test_connection_route_response(
|
|||||||
("content-type".to_string(), "application/json".to_string()),
|
("content-type".to_string(), "application/json".to_string()),
|
||||||
(auth_header.clone(), auth_value.clone()),
|
(auth_header.clone(), auth_value.clone()),
|
||||||
]);
|
]);
|
||||||
|
if uses_vertex_query_auth {
|
||||||
|
provider_request_headers.remove("x-goog-api-key");
|
||||||
|
}
|
||||||
|
let protected_headers = if uses_vertex_query_auth {
|
||||||
|
&["content-type"][..]
|
||||||
|
} else {
|
||||||
|
&[auth_header.as_str(), "content-type"][..]
|
||||||
|
};
|
||||||
if !crate::provider_transport::apply_local_header_rules(
|
if !crate::provider_transport::apply_local_header_rules(
|
||||||
&mut provider_request_headers,
|
&mut provider_request_headers,
|
||||||
transport.endpoint.header_rules.as_ref(),
|
transport.endpoint.header_rules.as_ref(),
|
||||||
&[auth_header.as_str(), "content-type"],
|
protected_headers,
|
||||||
&provider_request_body,
|
&provider_request_body,
|
||||||
None,
|
None,
|
||||||
) {
|
) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
crate::provider_transport::ensure_upstream_auth_header(
|
if !uses_vertex_query_auth {
|
||||||
&mut provider_request_headers,
|
crate::provider_transport::ensure_upstream_auth_header(
|
||||||
&auth_header,
|
&mut provider_request_headers,
|
||||||
&auth_value,
|
&auth_header,
|
||||||
);
|
&auth_value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let mut upstream_request = state.client.post(&upstream_url);
|
let mut upstream_request = state.client.post(&upstream_url);
|
||||||
for (name, value) in &provider_request_headers {
|
for (name, value) in &provider_request_headers {
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ use aether_provider_transport::policy::{
|
|||||||
local_openai_chat_transport_unsupported_reason,
|
local_openai_chat_transport_unsupported_reason,
|
||||||
local_standard_transport_unsupported_reason_with_network,
|
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;
|
use aether_provider_transport::GatewayProviderTransportSnapshot;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -255,6 +260,9 @@ pub fn request_conversion_transport_unsupported_reason(
|
|||||||
"claude:cli" => {
|
"claude:cli" => {
|
||||||
local_standard_transport_unsupported_reason_with_network(transport, "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" => {
|
"gemini:chat" => {
|
||||||
local_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" => {
|
"openai:chat" | "openai:cli" | "openai:compact" => {
|
||||||
resolve_local_openai_bearer_auth(transport)
|
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),
|
"claude:chat" | "claude:cli" => resolve_local_standard_auth(transport),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -729,4 +744,67 @@ mod tests {
|
|||||||
"openai:cli"
|
"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(
|
pub fn convert_openai_chat_request_to_gemini_request(
|
||||||
body_json: &Value,
|
body_json: &Value,
|
||||||
mapped_model: &str,
|
mapped_model: &str,
|
||||||
upstream_is_stream: bool,
|
_upstream_is_stream: bool,
|
||||||
) -> Option<Value> {
|
) -> Option<Value> {
|
||||||
let request = body_json.as_object()?;
|
let request = body_json.as_object()?;
|
||||||
let mut system_segments = Vec::new();
|
let mut system_segments = Vec::new();
|
||||||
@@ -123,14 +123,16 @@ pub fn convert_openai_chat_request_to_gemini_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut output = Map::new();
|
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(
|
output.insert(
|
||||||
"contents".to_string(),
|
"contents".to_string(),
|
||||||
Value::Array(compact_gemini_contents(contents)),
|
Value::Array(compact_gemini_contents(contents)),
|
||||||
);
|
);
|
||||||
if upstream_is_stream {
|
|
||||||
output.insert("stream".to_string(), Value::Bool(true));
|
|
||||||
}
|
|
||||||
let system_text = system_segments
|
let system_text = system_segments
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|value| !value.trim().is_empty())
|
.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"))
|
.and_then(|json_schema| json_schema.get("schema"))
|
||||||
.cloned()
|
.cloned()
|
||||||
{
|
{
|
||||||
|
let mut schema = schema;
|
||||||
|
clean_gemini_schema(&mut schema);
|
||||||
generation_config.insert("responseSchema".to_string(), 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(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(google) = extra_body.get("google").and_then(Value::as_object) {
|
||||||
if let Some(existing) = output
|
let existing = output
|
||||||
.get_mut("generationConfig")
|
.entry("generationConfig".to_string())
|
||||||
.and_then(Value::as_object_mut)
|
.or_insert_with(|| Value::Object(Map::new()))
|
||||||
{
|
.as_object_mut()?;
|
||||||
if let Some(response_modalities) = google.get("response_modalities").cloned() {
|
if let Some(response_modalities) = google.get("response_modalities").cloned() {
|
||||||
existing.insert("responseModalities".to_string(), response_modalities);
|
existing.insert("responseModalities".to_string(), response_modalities);
|
||||||
}
|
}
|
||||||
if let Some(thinking_config) = google.get("thinking_config").cloned() {
|
if let Some(thinking_config) = google.get("thinking_config").cloned() {
|
||||||
existing
|
existing
|
||||||
.entry("thinkingConfig".to_string())
|
.entry("thinkingConfig".to_string())
|
||||||
.or_insert(thinking_config);
|
.or_insert(thinking_config);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,6 +397,10 @@ fn convert_openai_tools_to_gemini(
|
|||||||
function
|
function
|
||||||
.get("parameters")
|
.get("parameters")
|
||||||
.cloned()
|
.cloned()
|
||||||
|
.map(|mut schema| {
|
||||||
|
clean_gemini_schema(&mut schema);
|
||||||
|
schema
|
||||||
|
})
|
||||||
.unwrap_or_else(|| json!({})),
|
.unwrap_or_else(|| json!({})),
|
||||||
);
|
);
|
||||||
declarations.push(Value::Object(declaration));
|
declarations.push(Value::Object(declaration));
|
||||||
@@ -483,6 +490,517 @@ fn compact_gemini_contents(contents: Vec<Value>) -> Vec<Value> {
|
|||||||
compact
|
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)> {
|
fn parse_data_url(value: &str) -> Option<(String, String)> {
|
||||||
let rest = value.strip_prefix("data:")?;
|
let rest = value.strip_prefix("data:")?;
|
||||||
let (meta, data) = rest.split_once(",")?;
|
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)
|
convert_openai_chat_request_to_gemini_request(&request, "gemini-2.5-pro", false)
|
||||||
.expect("request should convert");
|
.expect("request should convert");
|
||||||
|
|
||||||
|
assert_eq!(converted["model"], "gemini-2.5-pro");
|
||||||
assert_eq!(converted["generationConfig"]["seed"], 7);
|
assert_eq!(converted["generationConfig"]["seed"], 7);
|
||||||
assert_eq!(converted["tools"][0], json!({ "codeExecution": {} }));
|
assert_eq!(converted["tools"][0], json!({ "codeExecution": {} }));
|
||||||
assert_eq!(converted["tools"][1], json!({ "googleSearch": {} }));
|
assert_eq!(converted["tools"][1], json!({ "googleSearch": {} }));
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
|
|
||||||
use aether_provider_transport::url::{
|
use aether_provider_transport::{
|
||||||
build_claude_messages_url, build_gemini_content_url, build_openai_chat_url,
|
apply_local_body_rules, build_transport_request_url, GatewayProviderTransportSnapshot,
|
||||||
build_openai_cli_url, build_passthrough_path_url,
|
TransportRequestUrlParams,
|
||||||
};
|
};
|
||||||
use aether_provider_transport::{apply_local_body_rules, GatewayProviderTransportSnapshot};
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::conversion::request::{
|
use crate::conversion::request::{
|
||||||
@@ -136,45 +135,16 @@ pub fn build_standard_upstream_url(
|
|||||||
provider_api_format: &str,
|
provider_api_format: &str,
|
||||||
upstream_is_stream: bool,
|
upstream_is_stream: bool,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let custom_path = transport
|
build_transport_request_url(
|
||||||
.endpoint
|
transport,
|
||||||
.custom_path
|
TransportRequestUrlParams {
|
||||||
.as_deref()
|
provider_api_format,
|
||||||
.map(str::trim)
|
mapped_model: Some(mapped_model),
|
||||||
.filter(|value| !value.is_empty());
|
upstream_is_stream,
|
||||||
|
request_query: parts.uri.query(),
|
||||||
match custom_path {
|
kiro_api_region: None,
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
use aether_contracts::{ExecutionPlan, ExecutionResult, RequestBody};
|
use aether_contracts::{ExecutionPlan, ExecutionResult, RequestBody};
|
||||||
use aether_provider_transport::{
|
use aether_provider_transport::{
|
||||||
resolve_transport_execution_timeouts, resolve_transport_tls_profile,
|
is_vertex_api_key_transport_context, resolve_transport_execution_timeouts,
|
||||||
GatewayProviderTransportSnapshot,
|
resolve_transport_tls_profile, GatewayProviderTransportSnapshot,
|
||||||
};
|
};
|
||||||
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
|
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
@@ -61,6 +61,10 @@ pub async fn fetch_models_from_transports(
|
|||||||
return Ok(build_success_outcome(models, None, true));
|
return Ok(build_success_outcome(models, None, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if transports.iter().any(is_vertex_api_key_transport_context) {
|
||||||
|
return fetch_vertex_models(runtime, transports).await;
|
||||||
|
}
|
||||||
|
|
||||||
match provider_type.as_str() {
|
match provider_type.as_str() {
|
||||||
"antigravity" => fetch_antigravity_models(runtime, first_transport).await,
|
"antigravity" => fetch_antigravity_models(runtime, first_transport).await,
|
||||||
"vertex_ai" => fetch_vertex_models(runtime, transports).await,
|
"vertex_ai" => fetch_vertex_models(runtime, transports).await,
|
||||||
@@ -1061,3 +1065,146 @@ impl OutcomeExt for ModelsFetchOutcome {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use aether_contracts::{ExecutionResult, ResponseBody};
|
||||||
|
use aether_provider_transport::snapshot::{
|
||||||
|
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||||
|
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||||
|
};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::fetch_models_from_transports;
|
||||||
|
use crate::transport::ModelFetchTransportRuntime;
|
||||||
|
|
||||||
|
struct TestRuntime {
|
||||||
|
executed_urls: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ModelFetchTransportRuntime for TestRuntime {
|
||||||
|
async fn resolve_local_oauth_request_auth(
|
||||||
|
&self,
|
||||||
|
_transport: &GatewayProviderTransportSnapshot,
|
||||||
|
) -> Result<Option<aether_provider_transport::LocalResolvedOAuthRequestAuth>, String>
|
||||||
|
{
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_model_fetch_proxy(
|
||||||
|
&self,
|
||||||
|
_transport: &GatewayProviderTransportSnapshot,
|
||||||
|
) -> Option<aether_contracts::ProxySnapshot> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_model_fetch_execution_plan(
|
||||||
|
&self,
|
||||||
|
plan: &aether_contracts::ExecutionPlan,
|
||||||
|
) -> Result<ExecutionResult, String> {
|
||||||
|
self.executed_urls
|
||||||
|
.lock()
|
||||||
|
.expect("executed_urls lock")
|
||||||
|
.push(plan.url.clone());
|
||||||
|
Ok(ExecutionResult {
|
||||||
|
request_id: plan.request_id.clone(),
|
||||||
|
candidate_id: plan.candidate_id.clone(),
|
||||||
|
status_code: 200,
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
body: Some(ResponseBody {
|
||||||
|
json_body: Some(json!({
|
||||||
|
"models": [{
|
||||||
|
"name": "publishers/google/models/gemini-3.1-pro-preview"
|
||||||
|
}]
|
||||||
|
})),
|
||||||
|
body_bytes_b64: None,
|
||||||
|
}),
|
||||||
|
telemetry: None,
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_custom_aiplatform_transport() -> GatewayProviderTransportSnapshot {
|
||||||
|
GatewayProviderTransportSnapshot {
|
||||||
|
provider: GatewayProviderTransportProvider {
|
||||||
|
id: "provider-1".to_string(),
|
||||||
|
name: "Vertex".to_string(),
|
||||||
|
provider_type: "custom".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-1".to_string(),
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
|
api_format: "gemini:cli".to_string(),
|
||||||
|
api_family: Some("gemini".to_string()),
|
||||||
|
endpoint_kind: Some("cli".to_string()),
|
||||||
|
is_active: true,
|
||||||
|
base_url: "https://aiplatform.googleapis.com".to_string(),
|
||||||
|
header_rules: None,
|
||||||
|
body_rules: None,
|
||||||
|
max_retries: None,
|
||||||
|
custom_path: Some("/v1/publishers/google/models/{model}:{action}".to_string()),
|
||||||
|
config: None,
|
||||||
|
format_acceptance_config: None,
|
||||||
|
proxy: None,
|
||||||
|
},
|
||||||
|
key: GatewayProviderTransportKey {
|
||||||
|
id: "key-1".to_string(),
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
|
name: "key".to_string(),
|
||||||
|
auth_type: "api_key".to_string(),
|
||||||
|
is_active: true,
|
||||||
|
api_formats: Some(vec!["gemini:cli".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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn custom_aiplatform_transport_uses_vertex_models_fetch_path_and_normalizes_chat_format()
|
||||||
|
{
|
||||||
|
let executed_urls = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let runtime = TestRuntime {
|
||||||
|
executed_urls: Arc::clone(&executed_urls),
|
||||||
|
};
|
||||||
|
let outcome =
|
||||||
|
fetch_models_from_transports(&runtime, &[sample_custom_aiplatform_transport()])
|
||||||
|
.await
|
||||||
|
.expect("models fetch should succeed");
|
||||||
|
|
||||||
|
let urls = executed_urls.lock().expect("executed_urls lock");
|
||||||
|
assert_eq!(
|
||||||
|
urls.as_slice(),
|
||||||
|
&["https://aiplatform.googleapis.com/v1/publishers/google/models?key=vertex-secret&pageSize=100"]
|
||||||
|
);
|
||||||
|
assert_eq!(outcome.fetched_model_ids, vec!["gemini-3.1-pro-preview"]);
|
||||||
|
assert_eq!(outcome.cached_models.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.cached_models[0]["api_formats"][0].as_str(),
|
||||||
|
Some("gemini:chat")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ mod network;
|
|||||||
pub mod oauth_refresh;
|
pub mod oauth_refresh;
|
||||||
pub mod policy;
|
pub mod policy;
|
||||||
pub mod provider_types;
|
pub mod provider_types;
|
||||||
|
mod request_url;
|
||||||
pub mod rules;
|
pub mod rules;
|
||||||
pub mod snapshot;
|
pub mod snapshot;
|
||||||
pub mod url;
|
pub mod url;
|
||||||
@@ -40,6 +41,7 @@ pub use policy::{
|
|||||||
local_standard_transport_unsupported_reason_with_network, supports_local_gemini_transport,
|
local_standard_transport_unsupported_reason_with_network, supports_local_gemini_transport,
|
||||||
supports_local_gemini_transport_with_network, supports_local_standard_transport,
|
supports_local_gemini_transport_with_network, supports_local_standard_transport,
|
||||||
};
|
};
|
||||||
|
pub use request_url::{build_transport_request_url, TransportRequestUrlParams};
|
||||||
pub use rules::{
|
pub use rules::{
|
||||||
apply_local_body_rules, apply_local_header_rules, body_rules_are_locally_supported,
|
apply_local_body_rules, apply_local_header_rules, body_rules_are_locally_supported,
|
||||||
body_rules_handle_path, header_rules_are_locally_supported,
|
body_rules_handle_path, header_rules_are_locally_supported,
|
||||||
@@ -48,6 +50,7 @@ pub use snapshot::{
|
|||||||
read_provider_transport_snapshot, GatewayProviderTransportSnapshot,
|
read_provider_transport_snapshot, GatewayProviderTransportSnapshot,
|
||||||
ProviderTransportSnapshotSource,
|
ProviderTransportSnapshotSource,
|
||||||
};
|
};
|
||||||
|
pub use vertex::{is_vertex_api_key_transport_context, uses_vertex_api_key_query_auth};
|
||||||
pub use video::{
|
pub use video::{
|
||||||
reconstruct_local_video_task_snapshot, resolve_local_video_task_transport,
|
reconstruct_local_video_task_snapshot, resolve_local_video_task_transport,
|
||||||
VideoTaskTransportSnapshotLookup,
|
VideoTaskTransportSnapshotLookup,
|
||||||
|
|||||||
399
crates/aether-provider-transport/src/request_url.rs
Normal file
399
crates/aether-provider-transport/src/request_url.rs
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
use url::form_urlencoded;
|
||||||
|
|
||||||
|
use crate::antigravity::{build_antigravity_v1internal_url, AntigravityRequestUrlAction};
|
||||||
|
use crate::claude_code::build_claude_code_messages_url;
|
||||||
|
use crate::snapshot::GatewayProviderTransportSnapshot;
|
||||||
|
use crate::url::{
|
||||||
|
build_claude_messages_url, build_gemini_content_url, build_openai_chat_url,
|
||||||
|
build_openai_cli_url, build_passthrough_path_url,
|
||||||
|
};
|
||||||
|
use crate::vertex::{
|
||||||
|
build_vertex_api_key_gemini_content_url, resolve_local_vertex_api_key_query_auth,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct TransportRequestUrlParams<'a> {
|
||||||
|
pub provider_api_format: &'a str,
|
||||||
|
pub mapped_model: Option<&'a str>,
|
||||||
|
pub upstream_is_stream: bool,
|
||||||
|
pub request_query: Option<&'a str>,
|
||||||
|
pub kiro_api_region: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_transport_request_url(
|
||||||
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
|
params: TransportRequestUrlParams<'_>,
|
||||||
|
) -> Option<String> {
|
||||||
|
if let Some(url) = build_transport_hook_url(transport, params) {
|
||||||
|
return Some(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
let provider_api_format = params.provider_api_format.trim().to_ascii_lowercase();
|
||||||
|
let custom_path = transport
|
||||||
|
.endpoint
|
||||||
|
.custom_path
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(|path| expand_custom_path_template(path, build_path_params(params)));
|
||||||
|
|
||||||
|
if let Some(path) = custom_path.as_deref() {
|
||||||
|
let blocked_keys = if provider_api_format.starts_with("gemini:") {
|
||||||
|
&["key"][..]
|
||||||
|
} else {
|
||||||
|
&[][..]
|
||||||
|
};
|
||||||
|
let url = build_passthrough_path_url(
|
||||||
|
&transport.endpoint.base_url,
|
||||||
|
path,
|
||||||
|
params.request_query,
|
||||||
|
blocked_keys,
|
||||||
|
)?;
|
||||||
|
return Some(maybe_add_gemini_stream_alt_sse(
|
||||||
|
url,
|
||||||
|
&provider_api_format,
|
||||||
|
params.upstream_is_stream,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = match provider_api_format.as_str() {
|
||||||
|
"openai:chat" => Some(build_openai_chat_url(
|
||||||
|
&transport.endpoint.base_url,
|
||||||
|
params.request_query,
|
||||||
|
)),
|
||||||
|
"openai:cli" => Some(build_openai_cli_url(
|
||||||
|
&transport.endpoint.base_url,
|
||||||
|
params.request_query,
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
"openai:compact" => Some(build_openai_cli_url(
|
||||||
|
&transport.endpoint.base_url,
|
||||||
|
params.request_query,
|
||||||
|
true,
|
||||||
|
)),
|
||||||
|
"claude:chat" | "claude:cli" => Some(build_claude_messages_url(
|
||||||
|
&transport.endpoint.base_url,
|
||||||
|
params.request_query,
|
||||||
|
)),
|
||||||
|
"gemini:chat" | "gemini:cli" => build_gemini_content_url(
|
||||||
|
&transport.endpoint.base_url,
|
||||||
|
params.mapped_model?,
|
||||||
|
params.upstream_is_stream,
|
||||||
|
params.request_query,
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
}?;
|
||||||
|
|
||||||
|
Some(maybe_add_gemini_stream_alt_sse(
|
||||||
|
url,
|
||||||
|
&provider_api_format,
|
||||||
|
params.upstream_is_stream,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_transport_hook_url(
|
||||||
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
|
params: TransportRequestUrlParams<'_>,
|
||||||
|
) -> Option<String> {
|
||||||
|
if let Some(api_region) = params.kiro_api_region {
|
||||||
|
return crate::kiro::build_kiro_generate_assistant_response_url(
|
||||||
|
&transport.endpoint.base_url,
|
||||||
|
params.request_query,
|
||||||
|
Some(api_region),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if transport
|
||||||
|
.provider
|
||||||
|
.provider_type
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("claude_code")
|
||||||
|
{
|
||||||
|
return Some(build_claude_code_messages_url(
|
||||||
|
&transport.endpoint.base_url,
|
||||||
|
params.request_query,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if params
|
||||||
|
.provider_api_format
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.starts_with("gemini:")
|
||||||
|
{
|
||||||
|
if let Some(auth) = resolve_local_vertex_api_key_query_auth(transport) {
|
||||||
|
return build_vertex_api_key_gemini_content_url(
|
||||||
|
params.mapped_model?,
|
||||||
|
params.upstream_is_stream,
|
||||||
|
&auth.value,
|
||||||
|
params.request_query,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if transport
|
||||||
|
.provider
|
||||||
|
.provider_type
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("antigravity")
|
||||||
|
{
|
||||||
|
let query = params.request_query.map(|raw| {
|
||||||
|
form_urlencoded::parse(raw.as_bytes())
|
||||||
|
.into_owned()
|
||||||
|
.collect::<BTreeMap<String, String>>()
|
||||||
|
});
|
||||||
|
return build_antigravity_v1internal_url(
|
||||||
|
&transport.endpoint.base_url,
|
||||||
|
if params.upstream_is_stream {
|
||||||
|
AntigravityRequestUrlAction::StreamGenerateContent
|
||||||
|
} else {
|
||||||
|
AntigravityRequestUrlAction::GenerateContent
|
||||||
|
},
|
||||||
|
query.as_ref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_path_params(params: TransportRequestUrlParams<'_>) -> BTreeMap<&'static str, &str> {
|
||||||
|
let mut path_params = BTreeMap::new();
|
||||||
|
if let Some(model) = params
|
||||||
|
.mapped_model
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
path_params.insert("model", model);
|
||||||
|
}
|
||||||
|
if params
|
||||||
|
.provider_api_format
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.starts_with("gemini:")
|
||||||
|
{
|
||||||
|
path_params.insert(
|
||||||
|
"action",
|
||||||
|
if params.upstream_is_stream {
|
||||||
|
"streamGenerateContent"
|
||||||
|
} else {
|
||||||
|
"generateContent"
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
path_params
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_custom_path_template(path: &str, params: BTreeMap<&'static str, &str>) -> String {
|
||||||
|
if params.is_empty() {
|
||||||
|
return path.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let regex = custom_path_template_regex();
|
||||||
|
let mut missing_key = false;
|
||||||
|
let replaced = regex.replace_all(path, |captures: ®ex::Captures<'_>| {
|
||||||
|
let key = captures
|
||||||
|
.get(1)
|
||||||
|
.map(|value| value.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
match params.get(key).copied() {
|
||||||
|
Some(value) => value.to_string(),
|
||||||
|
None => {
|
||||||
|
missing_key = true;
|
||||||
|
captures
|
||||||
|
.get(0)
|
||||||
|
.map(|value| value.as_str().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if missing_key {
|
||||||
|
path.to_string()
|
||||||
|
} else {
|
||||||
|
replaced.into_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maybe_add_gemini_stream_alt_sse(
|
||||||
|
upstream_url: String,
|
||||||
|
provider_api_format: &str,
|
||||||
|
upstream_is_stream: bool,
|
||||||
|
) -> String {
|
||||||
|
if !provider_api_format.starts_with("gemini:") || !upstream_is_stream {
|
||||||
|
return upstream_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_alt = upstream_url
|
||||||
|
.split_once('?')
|
||||||
|
.map(|(_, query)| {
|
||||||
|
form_urlencoded::parse(query.as_bytes())
|
||||||
|
.any(|(key, _)| key.as_ref().eq_ignore_ascii_case("alt"))
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
if has_alt {
|
||||||
|
return upstream_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if upstream_url.contains('?') {
|
||||||
|
format!("{upstream_url}&alt=sse")
|
||||||
|
} else {
|
||||||
|
format!("{upstream_url}?alt=sse")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn custom_path_template_regex() -> &'static Regex {
|
||||||
|
static REGEX: OnceLock<Regex> = OnceLock::new();
|
||||||
|
REGEX.get_or_init(|| {
|
||||||
|
Regex::new(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||||
|
.expect("custom_path template regex should compile")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{build_transport_request_url, TransportRequestUrlParams};
|
||||||
|
use crate::snapshot::{
|
||||||
|
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||||
|
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn sample_transport(
|
||||||
|
provider_type: &str,
|
||||||
|
api_format: &str,
|
||||||
|
base_url: &str,
|
||||||
|
custom_path: Option<&str>,
|
||||||
|
) -> GatewayProviderTransportSnapshot {
|
||||||
|
GatewayProviderTransportSnapshot {
|
||||||
|
provider: GatewayProviderTransportProvider {
|
||||||
|
id: "provider-1".to_string(),
|
||||||
|
name: "provider".to_string(),
|
||||||
|
provider_type: provider_type.to_string(),
|
||||||
|
website: None,
|
||||||
|
is_active: true,
|
||||||
|
keep_priority_on_conversion: false,
|
||||||
|
enable_format_conversion: false,
|
||||||
|
concurrent_limit: None,
|
||||||
|
max_retries: None,
|
||||||
|
proxy: None,
|
||||||
|
request_timeout_secs: None,
|
||||||
|
stream_first_byte_timeout_secs: None,
|
||||||
|
config: None,
|
||||||
|
},
|
||||||
|
endpoint: GatewayProviderTransportEndpoint {
|
||||||
|
id: "endpoint-1".to_string(),
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
|
api_format: api_format.to_string(),
|
||||||
|
api_family: None,
|
||||||
|
endpoint_kind: None,
|
||||||
|
is_active: true,
|
||||||
|
base_url: base_url.to_string(),
|
||||||
|
header_rules: None,
|
||||||
|
body_rules: None,
|
||||||
|
max_retries: None,
|
||||||
|
custom_path: custom_path.map(ToOwned::to_owned),
|
||||||
|
config: None,
|
||||||
|
format_acceptance_config: None,
|
||||||
|
proxy: None,
|
||||||
|
},
|
||||||
|
key: GatewayProviderTransportKey {
|
||||||
|
id: "key-1".to_string(),
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
|
name: "key".to_string(),
|
||||||
|
auth_type: "api_key".to_string(),
|
||||||
|
is_active: true,
|
||||||
|
api_formats: None,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uses_vertex_hook_before_custom_path_for_custom_aiplatform_transport() {
|
||||||
|
let transport = sample_transport(
|
||||||
|
"custom",
|
||||||
|
"gemini:cli",
|
||||||
|
"https://aiplatform.googleapis.com",
|
||||||
|
Some("/custom/{model}:{action}"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let url = build_transport_request_url(
|
||||||
|
&transport,
|
||||||
|
TransportRequestUrlParams {
|
||||||
|
provider_api_format: "gemini:cli",
|
||||||
|
mapped_model: Some("gemini-3.1-pro-preview"),
|
||||||
|
upstream_is_stream: true,
|
||||||
|
request_query: Some("foo=bar"),
|
||||||
|
kiro_api_region: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("vertex hook url");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
"https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.1-pro-preview:streamGenerateContent?alt=sse&foo=bar&key=vertex-secret"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expands_custom_path_templates_when_hook_does_not_apply() {
|
||||||
|
let transport = sample_transport(
|
||||||
|
"custom",
|
||||||
|
"gemini:chat",
|
||||||
|
"https://generativelanguage.googleapis.com",
|
||||||
|
Some("/v1beta/models/{model}:{action}"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let url = build_transport_request_url(
|
||||||
|
&transport,
|
||||||
|
TransportRequestUrlParams {
|
||||||
|
provider_api_format: "gemini:chat",
|
||||||
|
mapped_model: Some("gemini-2.5-pro"),
|
||||||
|
upstream_is_stream: false,
|
||||||
|
request_query: Some("key=client-key&foo=bar"),
|
||||||
|
kiro_api_region: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("expanded custom path url");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent?foo=bar"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_original_custom_path_when_template_params_are_missing() {
|
||||||
|
let transport = sample_transport(
|
||||||
|
"custom",
|
||||||
|
"claude:chat",
|
||||||
|
"https://api.example.com",
|
||||||
|
Some("/v1/messages/{model}"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let url = build_transport_request_url(
|
||||||
|
&transport,
|
||||||
|
TransportRequestUrlParams {
|
||||||
|
provider_api_format: "claude:chat",
|
||||||
|
mapped_model: None,
|
||||||
|
upstream_is_stream: false,
|
||||||
|
request_query: None,
|
||||||
|
kiro_api_region: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("fallback custom path url");
|
||||||
|
|
||||||
|
assert_eq!(url, "https://api.example.com/v1/messages/{model}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
mod auth;
|
mod auth;
|
||||||
|
mod context;
|
||||||
mod policy;
|
mod policy;
|
||||||
mod url;
|
mod url;
|
||||||
|
|
||||||
pub use auth::{
|
pub use auth::{
|
||||||
resolve_local_vertex_api_key_query_auth, VertexApiKeyQueryAuth, VERTEX_API_KEY_QUERY_PARAM,
|
resolve_local_vertex_api_key_query_auth, VertexApiKeyQueryAuth, VERTEX_API_KEY_QUERY_PARAM,
|
||||||
};
|
};
|
||||||
|
pub use context::{
|
||||||
|
is_vertex_api_key_transport_context, looks_like_vertex_ai_host, uses_vertex_api_key_query_auth,
|
||||||
|
};
|
||||||
pub use policy::{
|
pub use policy::{
|
||||||
local_vertex_api_key_gemini_transport_unsupported_reason_with_network,
|
local_vertex_api_key_gemini_transport_unsupported_reason_with_network,
|
||||||
supports_local_vertex_api_key_gemini_transport,
|
supports_local_vertex_api_key_gemini_transport,
|
||||||
|
|||||||
@@ -11,12 +11,7 @@ pub struct VertexApiKeyQueryAuth {
|
|||||||
pub fn resolve_local_vertex_api_key_query_auth(
|
pub fn resolve_local_vertex_api_key_query_auth(
|
||||||
transport: &GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
) -> Option<VertexApiKeyQueryAuth> {
|
) -> Option<VertexApiKeyQueryAuth> {
|
||||||
if !transport
|
if !super::is_vertex_api_key_transport_context(transport) {
|
||||||
.provider
|
|
||||||
.provider_type
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case(super::PROVIDER_TYPE)
|
|
||||||
{
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,4 +122,15 @@ mod tests {
|
|||||||
transport.key.decrypted_auth_config = Some("{\"project_id\":\"demo-project\"}".to_string());
|
transport.key.decrypted_auth_config = Some("{\"project_id\":\"demo-project\"}".to_string());
|
||||||
assert!(resolve_local_vertex_api_key_query_auth(&transport).is_none());
|
assert!(resolve_local_vertex_api_key_query_auth(&transport).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_query_auth_for_custom_aiplatform_transport() {
|
||||||
|
let mut transport = sample_transport();
|
||||||
|
transport.provider.provider_type = "custom".to_string();
|
||||||
|
transport.endpoint.api_format = "gemini:cli".to_string();
|
||||||
|
|
||||||
|
let auth = resolve_local_vertex_api_key_query_auth(&transport)
|
||||||
|
.expect("custom aiplatform transport should resolve");
|
||||||
|
assert_eq!(auth.value, "vertex-secret");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
163
crates/aether-provider-transport/src/vertex/context.rs
Normal file
163
crates/aether-provider-transport/src/vertex/context.rs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use super::super::snapshot::GatewayProviderTransportSnapshot;
|
||||||
|
|
||||||
|
const VERTEX_AI_HOST: &str = "aiplatform.googleapis.com";
|
||||||
|
|
||||||
|
pub fn looks_like_vertex_ai_host(base_url: &str) -> bool {
|
||||||
|
let trimmed = base_url.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(parsed) = Url::parse(trimmed) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(host) = parsed
|
||||||
|
.host_str()
|
||||||
|
.map(|value| value.trim().to_ascii_lowercase())
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
host == VERTEX_AI_HOST
|
||||||
|
|| host.ends_with(&format!(".{VERTEX_AI_HOST}"))
|
||||||
|
|| host.ends_with(&format!("-{VERTEX_AI_HOST}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_vertex_api_key_transport_context(transport: &GatewayProviderTransportSnapshot) -> bool {
|
||||||
|
if transport
|
||||||
|
.provider
|
||||||
|
.provider_type
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case(super::PROVIDER_TYPE)
|
||||||
|
{
|
||||||
|
return transport
|
||||||
|
.key
|
||||||
|
.auth_type
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("api_key");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !looks_like_vertex_ai_host(&transport.endpoint.base_url) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let endpoint_api_format = transport.endpoint.api_format.trim().to_ascii_lowercase();
|
||||||
|
if !endpoint_api_format.starts_with("gemini:") && !endpoint_api_format.starts_with("claude:") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
transport
|
||||||
|
.key
|
||||||
|
.auth_type
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("api_key")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn uses_vertex_api_key_query_auth(
|
||||||
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
|
provider_api_format: &str,
|
||||||
|
) -> bool {
|
||||||
|
is_vertex_api_key_transport_context(transport)
|
||||||
|
&& provider_api_format
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.starts_with("gemini:")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
is_vertex_api_key_transport_context, looks_like_vertex_ai_host,
|
||||||
|
uses_vertex_api_key_query_auth,
|
||||||
|
};
|
||||||
|
use crate::snapshot::{
|
||||||
|
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||||
|
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn sample_transport() -> GatewayProviderTransportSnapshot {
|
||||||
|
GatewayProviderTransportSnapshot {
|
||||||
|
provider: GatewayProviderTransportProvider {
|
||||||
|
id: "provider-1".to_string(),
|
||||||
|
name: "Vertex".to_string(),
|
||||||
|
provider_type: "custom".to_string(),
|
||||||
|
website: None,
|
||||||
|
is_active: true,
|
||||||
|
keep_priority_on_conversion: false,
|
||||||
|
enable_format_conversion: false,
|
||||||
|
concurrent_limit: None,
|
||||||
|
max_retries: None,
|
||||||
|
proxy: None,
|
||||||
|
request_timeout_secs: None,
|
||||||
|
stream_first_byte_timeout_secs: None,
|
||||||
|
config: None,
|
||||||
|
},
|
||||||
|
endpoint: GatewayProviderTransportEndpoint {
|
||||||
|
id: "endpoint-1".to_string(),
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
|
api_format: "gemini:cli".to_string(),
|
||||||
|
api_family: Some("gemini".to_string()),
|
||||||
|
endpoint_kind: Some("cli".to_string()),
|
||||||
|
is_active: true,
|
||||||
|
base_url: "https://aiplatform.googleapis.com".to_string(),
|
||||||
|
header_rules: None,
|
||||||
|
body_rules: None,
|
||||||
|
max_retries: None,
|
||||||
|
custom_path: Some("/v1/publishers/google/models/{model}:{action}".to_string()),
|
||||||
|
config: None,
|
||||||
|
format_acceptance_config: None,
|
||||||
|
proxy: None,
|
||||||
|
},
|
||||||
|
key: GatewayProviderTransportKey {
|
||||||
|
id: "key-1".to_string(),
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
|
name: "key".to_string(),
|
||||||
|
auth_type: "api_key".to_string(),
|
||||||
|
is_active: true,
|
||||||
|
api_formats: Some(vec!["gemini:cli".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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_vertex_host() {
|
||||||
|
assert!(looks_like_vertex_ai_host(
|
||||||
|
"https://aiplatform.googleapis.com"
|
||||||
|
));
|
||||||
|
assert!(looks_like_vertex_ai_host(
|
||||||
|
"https://us-central1-aiplatform.googleapis.com"
|
||||||
|
));
|
||||||
|
assert!(!looks_like_vertex_ai_host("https://example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn infers_vertex_api_key_context_for_custom_aiplatform_transport() {
|
||||||
|
assert!(is_vertex_api_key_transport_context(&sample_transport()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_api_key_custom_aiplatform_transport() {
|
||||||
|
let mut transport = sample_transport();
|
||||||
|
transport.key.auth_type = "bearer".to_string();
|
||||||
|
assert!(!is_vertex_api_key_transport_context(&transport));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_vertex_query_auth_usage_for_gemini_formats() {
|
||||||
|
let transport = sample_transport();
|
||||||
|
assert!(uses_vertex_api_key_query_auth(&transport, "gemini:cli"));
|
||||||
|
assert!(uses_vertex_api_key_query_auth(&transport, "gemini:chat"));
|
||||||
|
assert!(!uses_vertex_api_key_query_auth(&transport, "claude:chat"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,15 @@ use super::super::{
|
|||||||
};
|
};
|
||||||
use super::auth::resolve_local_vertex_api_key_query_auth;
|
use super::auth::resolve_local_vertex_api_key_query_auth;
|
||||||
|
|
||||||
|
fn is_vertex_transport_family(transport: &GatewayProviderTransportSnapshot) -> bool {
|
||||||
|
transport
|
||||||
|
.provider
|
||||||
|
.provider_type
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case(super::PROVIDER_TYPE)
|
||||||
|
|| super::looks_like_vertex_ai_host(&transport.endpoint.base_url)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn local_vertex_api_key_gemini_transport_unsupported_reason_with_network(
|
pub fn local_vertex_api_key_gemini_transport_unsupported_reason_with_network(
|
||||||
transport: &GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
) -> Option<&'static str> {
|
) -> Option<&'static str> {
|
||||||
@@ -18,19 +27,21 @@ pub fn local_vertex_api_key_gemini_transport_unsupported_reason_with_network(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if !transport
|
if !transport
|
||||||
.provider
|
.endpoint
|
||||||
.provider_type
|
.api_format
|
||||||
.trim()
|
.trim()
|
||||||
.eq_ignore_ascii_case(super::PROVIDER_TYPE)
|
.eq_ignore_ascii_case("gemini:chat")
|
||||||
{
|
&& !transport
|
||||||
return Some("transport_provider_type_unsupported");
|
.endpoint
|
||||||
}
|
.api_format
|
||||||
let endpoint_api_format = transport.endpoint.api_format.trim();
|
.trim()
|
||||||
if !endpoint_api_format.eq_ignore_ascii_case("gemini:chat")
|
.eq_ignore_ascii_case("gemini:cli")
|
||||||
&& !endpoint_api_format.eq_ignore_ascii_case("gemini:cli")
|
|
||||||
{
|
{
|
||||||
return Some("transport_api_format_mismatch");
|
return Some("transport_api_format_mismatch");
|
||||||
}
|
}
|
||||||
|
if !is_vertex_transport_family(transport) {
|
||||||
|
return Some("transport_provider_type_unsupported");
|
||||||
|
}
|
||||||
if !header_rules_are_locally_supported(transport.endpoint.header_rules.as_ref()) {
|
if !header_rules_are_locally_supported(transport.endpoint.header_rules.as_ref()) {
|
||||||
return Some("transport_header_rules_unsupported");
|
return Some("transport_header_rules_unsupported");
|
||||||
}
|
}
|
||||||
@@ -87,18 +98,21 @@ fn supports_local_vertex_api_key_same_format_transport(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if !transport
|
if !transport
|
||||||
.provider
|
.endpoint
|
||||||
.provider_type
|
.api_format
|
||||||
.trim()
|
.trim()
|
||||||
.eq_ignore_ascii_case(super::PROVIDER_TYPE)
|
.eq_ignore_ascii_case(api_formats[0])
|
||||||
|
&& !api_formats.iter().any(|api_format| {
|
||||||
|
transport
|
||||||
|
.endpoint
|
||||||
|
.api_format
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case(api_format)
|
||||||
|
})
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let endpoint_api_format = transport.endpoint.api_format.trim();
|
if !super::is_vertex_api_key_transport_context(transport) {
|
||||||
if !api_formats
|
|
||||||
.iter()
|
|
||||||
.any(|api_format| endpoint_api_format.eq_ignore_ascii_case(api_format))
|
|
||||||
{
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if !header_rules_are_locally_supported(transport.endpoint.header_rules.as_ref())
|
if !header_rules_are_locally_supported(transport.endpoint.header_rules.as_ref())
|
||||||
@@ -223,6 +237,14 @@ mod tests {
|
|||||||
assert!(supports_local_vertex_api_key_gemini_transport(&transport));
|
assert!(supports_local_vertex_api_key_gemini_transport(&transport));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supports_custom_aiplatform_gemini_cli_subset() {
|
||||||
|
let mut transport = sample_transport();
|
||||||
|
transport.provider.provider_type = "custom".to_string();
|
||||||
|
transport.endpoint.api_format = "gemini:cli".to_string();
|
||||||
|
assert!(supports_local_vertex_api_key_gemini_transport(&transport));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_vertex_service_account_subset() {
|
fn rejects_vertex_service_account_subset() {
|
||||||
let mut transport = sample_transport();
|
let mut transport = sample_transport();
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ pub struct SchedulerRequestCandidateReportContext {
|
|||||||
pub key_id: Option<String>,
|
pub key_id: Option<String>,
|
||||||
pub client_api_format: Option<String>,
|
pub client_api_format: Option<String>,
|
||||||
pub provider_api_format: Option<String>,
|
pub provider_api_format: Option<String>,
|
||||||
|
pub upstream_url: Option<String>,
|
||||||
|
pub mapped_model: Option<String>,
|
||||||
|
pub key_name: Option<String>,
|
||||||
pub proxy: Option<Value>,
|
pub proxy: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +106,9 @@ pub fn parse_request_candidate_report_context(
|
|||||||
key_id: string_field(report_context, "key_id"),
|
key_id: string_field(report_context, "key_id"),
|
||||||
client_api_format: string_field(report_context, "client_api_format"),
|
client_api_format: string_field(report_context, "client_api_format"),
|
||||||
provider_api_format: string_field(report_context, "provider_api_format"),
|
provider_api_format: string_field(report_context, "provider_api_format"),
|
||||||
|
upstream_url: string_field(report_context, "upstream_url"),
|
||||||
|
mapped_model: string_field(report_context, "mapped_model"),
|
||||||
|
key_name: string_field(report_context, "key_name"),
|
||||||
proxy: report_context
|
proxy: report_context
|
||||||
.get("proxy")
|
.get("proxy")
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -129,11 +135,20 @@ pub fn resolve_report_request_candidate_slot(
|
|||||||
key_id,
|
key_id,
|
||||||
client_api_format,
|
client_api_format,
|
||||||
provider_api_format,
|
provider_api_format,
|
||||||
|
upstream_url,
|
||||||
|
mapped_model,
|
||||||
|
key_name,
|
||||||
proxy,
|
proxy,
|
||||||
} = metadata;
|
} = metadata;
|
||||||
let request_id = request_id?;
|
let request_id = request_id?;
|
||||||
let synthesized_extra_data =
|
let synthesized_extra_data = build_report_candidate_extra_data(
|
||||||
build_report_candidate_extra_data(client_api_format, provider_api_format, proxy);
|
client_api_format,
|
||||||
|
provider_api_format,
|
||||||
|
upstream_url,
|
||||||
|
mapped_model,
|
||||||
|
key_name,
|
||||||
|
proxy,
|
||||||
|
);
|
||||||
let created_at_unix_ms = matched_candidate
|
let created_at_unix_ms = matched_candidate
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|candidate| candidate.created_at_unix_ms)
|
.map(|candidate| candidate.created_at_unix_ms)
|
||||||
@@ -489,9 +504,12 @@ fn next_candidate_index(candidates: &[StoredRequestCandidate]) -> u32 {
|
|||||||
fn build_report_candidate_extra_data(
|
fn build_report_candidate_extra_data(
|
||||||
client_api_format: Option<String>,
|
client_api_format: Option<String>,
|
||||||
provider_api_format: Option<String>,
|
provider_api_format: Option<String>,
|
||||||
|
upstream_url: Option<String>,
|
||||||
|
mapped_model: Option<String>,
|
||||||
|
key_name: Option<String>,
|
||||||
proxy: Option<Value>,
|
proxy: Option<Value>,
|
||||||
) -> Option<Value> {
|
) -> Option<Value> {
|
||||||
let mut extra_data = Map::with_capacity(5);
|
let mut extra_data = Map::with_capacity(8);
|
||||||
extra_data.insert("gateway_execution_runtime".to_string(), Value::Bool(true));
|
extra_data.insert("gateway_execution_runtime".to_string(), Value::Bool(true));
|
||||||
extra_data.insert("phase".to_string(), Value::String("3c_trial".to_string()));
|
extra_data.insert("phase".to_string(), Value::String("3c_trial".to_string()));
|
||||||
if let Some(client_api_format) = client_api_format {
|
if let Some(client_api_format) = client_api_format {
|
||||||
@@ -506,6 +524,15 @@ fn build_report_candidate_extra_data(
|
|||||||
Value::String(provider_api_format),
|
Value::String(provider_api_format),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let Some(upstream_url) = upstream_url {
|
||||||
|
extra_data.insert("upstream_url".to_string(), Value::String(upstream_url));
|
||||||
|
}
|
||||||
|
if let Some(mapped_model) = mapped_model {
|
||||||
|
extra_data.insert("mapped_model".to_string(), Value::String(mapped_model));
|
||||||
|
}
|
||||||
|
if let Some(key_name) = key_name {
|
||||||
|
extra_data.insert("key_name".to_string(), Value::String(key_name));
|
||||||
|
}
|
||||||
if let Some(proxy) = proxy {
|
if let Some(proxy) = proxy {
|
||||||
extra_data.insert("proxy".to_string(), proxy);
|
extra_data.insert("proxy".to_string(), proxy);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,6 +190,10 @@ export interface TestModelRequest {
|
|||||||
endpoint_id?: string
|
endpoint_id?: string
|
||||||
message?: string
|
message?: string
|
||||||
api_format?: string
|
api_format?: string
|
||||||
|
request_headers?: Record<string, unknown>
|
||||||
|
request_body?: Record<string, unknown>
|
||||||
|
request_id?: string
|
||||||
|
concurrency?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TestModelResponse {
|
export interface TestModelResponse {
|
||||||
@@ -210,9 +214,13 @@ export interface TestModelResponse {
|
|||||||
model?: string
|
model?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testModel(data: TestModelRequest): Promise<TestModelResponse> {
|
export async function testModel(
|
||||||
|
data: TestModelRequest,
|
||||||
|
options: { signal?: AbortSignal } = {},
|
||||||
|
): Promise<TestModelResponse> {
|
||||||
const response = await client.post('/api/admin/provider-query/test-model', data, {
|
const response = await client.post('/api/admin/provider-query/test-model', data, {
|
||||||
timeout: 10 * 60 * 1000,
|
timeout: 10 * 60 * 1000,
|
||||||
|
signal: options.signal,
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { ref, onBeforeUnmount } from 'vue'
|
|||||||
import { isAxiosError } from 'axios'
|
import { isAxiosError } from 'axios'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
import {
|
import {
|
||||||
|
testModel,
|
||||||
testModelFailover,
|
testModelFailover,
|
||||||
|
type TestAttemptDetail,
|
||||||
|
type TestModelResponse,
|
||||||
type TestModelFailoverResponse,
|
type TestModelFailoverResponse,
|
||||||
} from '@/api/endpoints/providers'
|
} from '@/api/endpoints/providers'
|
||||||
import { requestTraceApi, type RequestTrace } from '@/api/requestTrace'
|
import { requestTraceApi, type RequestTrace } from '@/api/requestTrace'
|
||||||
@@ -14,6 +17,7 @@ export interface StartTestParams {
|
|||||||
displayLabel: string
|
displayLabel: string
|
||||||
apiFormat?: string
|
apiFormat?: string
|
||||||
endpointId?: string
|
endpointId?: string
|
||||||
|
endpointBaseUrl?: string
|
||||||
message?: string
|
message?: string
|
||||||
requestHeaders?: Record<string, unknown>
|
requestHeaders?: Record<string, unknown>
|
||||||
requestBody?: Record<string, unknown>
|
requestBody?: Record<string, unknown>
|
||||||
@@ -33,6 +37,7 @@ export interface UseModelTestOptions {
|
|||||||
export function useModelTest(options: UseModelTestOptions) {
|
export function useModelTest(options: UseModelTestOptions) {
|
||||||
const { providerId, pollInterval = 800 } = options
|
const { providerId, pollInterval = 800 } = options
|
||||||
const { success: showSuccess, error: showError } = useToast()
|
const { success: showSuccess, error: showError } = useToast()
|
||||||
|
const LOCAL_FAILOVER_UNCONFIGURED_MESSAGE = 'Rust local provider-query failover simulation is not configured'
|
||||||
|
|
||||||
const testing = ref(false)
|
const testing = ref(false)
|
||||||
const testMode = ref<'global' | 'direct'>('global')
|
const testMode = ref<'global' | 'direct'>('global')
|
||||||
@@ -61,6 +66,78 @@ export function useModelTest(options: UseModelTestOptions) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDirectTestResult(
|
||||||
|
params: StartTestParams,
|
||||||
|
result: TestModelResponse,
|
||||||
|
): TestModelFailoverResponse {
|
||||||
|
const responsePayload = result.data?.response
|
||||||
|
const failureMessage = typeof result.error === 'string' && result.error.trim()
|
||||||
|
? result.error.trim()
|
||||||
|
: (
|
||||||
|
typeof responsePayload?.error === 'string'
|
||||||
|
? responsePayload.error
|
||||||
|
: responsePayload?.error?.message
|
||||||
|
) || null
|
||||||
|
const syntheticAttempt: TestAttemptDetail = {
|
||||||
|
candidate_index: 1,
|
||||||
|
endpoint_api_format: params.apiFormat || '-',
|
||||||
|
endpoint_base_url: params.endpointBaseUrl || '',
|
||||||
|
key_name: null,
|
||||||
|
key_id: '',
|
||||||
|
auth_type: '',
|
||||||
|
effective_model: result.model || params.modelName,
|
||||||
|
status: result.success ? 'success' : 'failed',
|
||||||
|
skip_reason: null,
|
||||||
|
error_message: result.success ? null : failureMessage,
|
||||||
|
status_code: responsePayload?.status_code ?? null,
|
||||||
|
latency_ms: null,
|
||||||
|
request_url: null,
|
||||||
|
request_headers: (params.requestHeaders as Record<string, unknown> | undefined) ?? null,
|
||||||
|
request_body: params.requestBody ?? null,
|
||||||
|
response_headers: null,
|
||||||
|
response_body: (responsePayload as Record<string, unknown> | undefined)
|
||||||
|
?? (result.data as Record<string, unknown> | undefined)
|
||||||
|
?? null,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
model: result.model || params.modelName,
|
||||||
|
provider: result.provider || { id: providerId(), name: providerId() },
|
||||||
|
attempts: [syntheticAttempt],
|
||||||
|
total_candidates: 1,
|
||||||
|
total_attempts: 1,
|
||||||
|
data: (result.data as Record<string, unknown> | undefined) ?? null,
|
||||||
|
error: failureMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDirectTest(
|
||||||
|
params: StartTestParams,
|
||||||
|
reqId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<TestModelFailoverResponse> {
|
||||||
|
return normalizeDirectTestResult(params, await testModel({
|
||||||
|
provider_id: providerId(),
|
||||||
|
model_name: params.modelName,
|
||||||
|
api_format: params.apiFormat,
|
||||||
|
endpoint_id: params.endpointId,
|
||||||
|
...(normalizedMessage(params.message) ? { message: normalizedMessage(params.message) } : {}),
|
||||||
|
...(params.requestHeaders ? { request_headers: params.requestHeaders } : {}),
|
||||||
|
...(params.requestBody ? { request_body: params.requestBody } : {}),
|
||||||
|
request_id: reqId,
|
||||||
|
concurrency: params.concurrency,
|
||||||
|
}, {
|
||||||
|
signal,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedMessage(message?: string): string | undefined {
|
||||||
|
return typeof message === 'string' && message.trim()
|
||||||
|
? message.trim()
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
async function pollTestTrace(reqId: string, token: number) {
|
async function pollTestTrace(reqId: string, token: number) {
|
||||||
try {
|
try {
|
||||||
const trace = await requestTraceApi.getRequestTrace(reqId, { attemptedOnly: false })
|
const trace = await requestTraceApi.getRequestTrace(reqId, { attemptedOnly: false })
|
||||||
@@ -136,25 +213,33 @@ export function useModelTest(options: UseModelTestOptions) {
|
|||||||
startPolling(reqId)
|
startPolling(reqId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const normalizedMessage = typeof params.message === 'string' && params.message.trim()
|
const message = normalizedMessage(params.message)
|
||||||
? params.message.trim()
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const result = await testModelFailover({
|
let result = params.mode === 'direct'
|
||||||
provider_id: providerId(),
|
? await runDirectTest(params, reqId, abortController.signal)
|
||||||
mode: params.mode,
|
: await testModelFailover({
|
||||||
model_name: params.modelName,
|
provider_id: providerId(),
|
||||||
failover_models: [params.modelName],
|
mode: params.mode,
|
||||||
api_format: params.apiFormat,
|
model_name: params.modelName,
|
||||||
endpoint_id: params.endpointId,
|
failover_models: [params.modelName],
|
||||||
...(normalizedMessage ? { message: normalizedMessage } : {}),
|
api_format: params.apiFormat,
|
||||||
...(params.requestHeaders ? { request_headers: params.requestHeaders } : {}),
|
endpoint_id: params.endpointId,
|
||||||
...(params.requestBody ? { request_body: params.requestBody } : {}),
|
...(message ? { message } : {}),
|
||||||
request_id: reqId,
|
...(params.requestHeaders ? { request_headers: params.requestHeaders } : {}),
|
||||||
concurrency: params.concurrency,
|
...(params.requestBody ? { request_body: params.requestBody } : {}),
|
||||||
}, {
|
request_id: reqId,
|
||||||
signal: abortController.signal,
|
concurrency: params.concurrency,
|
||||||
})
|
}, {
|
||||||
|
signal: abortController.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (
|
||||||
|
params.mode === 'global'
|
||||||
|
&& !result.success
|
||||||
|
&& result.error === LOCAL_FAILOVER_UNCONFIGURED_MESSAGE
|
||||||
|
) {
|
||||||
|
result = await runDirectTest(params, reqId, abortController.signal)
|
||||||
|
}
|
||||||
|
|
||||||
const keepTraceContext = resultHasTraceContext(result)
|
const keepTraceContext = resultHasTraceContext(result)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|||||||
@@ -392,28 +392,40 @@ function filterAvailableApiFormats(formats: string[]): string[] {
|
|||||||
return formats.filter(format => availableFormatSet.has(normalizeApiFormat(format)))
|
return formats.filter(format => availableFormatSet.has(normalizeApiFormat(format)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSelectableApiFormats(authType = form.value.auth_type): string[] {
|
||||||
|
const sorted = sortApiFormats(props.availableApiFormats)
|
||||||
|
if (props.providerType !== 'vertex_ai') {
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = getVertexAllowedFormatsByAuth(authType)
|
||||||
|
return sorted.filter(fmt => allowed.has(normalizeApiFormat(fmt)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeApiFormats(formats: string[], authType = form.value.auth_type): string[] {
|
||||||
|
const selectable = new Set(getSelectableApiFormats(authType).map(normalizeApiFormat))
|
||||||
|
if (selectable.size === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return formats.filter(format => selectable.has(normalizeApiFormat(format)))
|
||||||
|
}
|
||||||
|
|
||||||
function getDefaultApiFormats(): string[] {
|
function getDefaultApiFormats(): string[] {
|
||||||
const endpointFormat = props.endpoint?.api_format
|
const endpointFormat = props.endpoint?.api_format
|
||||||
if (endpointFormat) {
|
if (endpointFormat) {
|
||||||
const endpointFormats = filterAvailableApiFormats([endpointFormat])
|
const endpointFormats = sanitizeApiFormats([endpointFormat])
|
||||||
if (endpointFormats.length > 0) {
|
if (endpointFormats.length > 0) {
|
||||||
return endpointFormats
|
return endpointFormats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstAvailableFormat = sortApiFormats(props.availableApiFormats)[0]
|
const firstAvailableFormat = getSelectableApiFormats()[0]
|
||||||
return firstAvailableFormat ? [firstAvailableFormat] : []
|
return firstAvailableFormat ? [firstAvailableFormat] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按 provider/auth_type 过滤后的可用 API 格式列表
|
// 按 provider/auth_type 过滤后的可用 API 格式列表
|
||||||
const visibleApiFormats = computed(() => {
|
const visibleApiFormats = computed(() => getSelectableApiFormats())
|
||||||
const sorted = sortApiFormats(props.availableApiFormats)
|
|
||||||
if (props.providerType !== 'vertex_ai') {
|
|
||||||
return sorted
|
|
||||||
}
|
|
||||||
const allowed = getVertexAllowedFormatsByAuth(form.value.auth_type)
|
|
||||||
return sorted.filter(fmt => allowed.has(normalizeApiFormat(fmt)))
|
|
||||||
})
|
|
||||||
|
|
||||||
const showAuthTypeSelector = computed(() => props.providerType === 'vertex_ai')
|
const showAuthTypeSelector = computed(() => props.providerType === 'vertex_ai')
|
||||||
|
|
||||||
@@ -517,11 +529,7 @@ const form = ref({
|
|||||||
watch(
|
watch(
|
||||||
[() => form.value.auth_type, () => props.providerType, () => props.availableApiFormats],
|
[() => form.value.auth_type, () => props.providerType, () => props.availableApiFormats],
|
||||||
() => {
|
() => {
|
||||||
if (props.providerType !== 'vertex_ai') {
|
const filtered = sanitizeApiFormats(form.value.api_formats)
|
||||||
return
|
|
||||||
}
|
|
||||||
const allowed = getVertexAllowedFormatsByAuth(form.value.auth_type)
|
|
||||||
const filtered = form.value.api_formats.filter(fmt => allowed.has(normalizeApiFormat(fmt)))
|
|
||||||
if (filtered.length !== form.value.api_formats.length) {
|
if (filtered.length !== form.value.api_formats.length) {
|
||||||
form.value.api_formats = [...filtered]
|
form.value.api_formats = [...filtered]
|
||||||
}
|
}
|
||||||
@@ -536,7 +544,7 @@ watch(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = filterAvailableApiFormats(form.value.api_formats)
|
const filtered = sanitizeApiFormats(form.value.api_formats)
|
||||||
if (filtered.length !== form.value.api_formats.length) {
|
if (filtered.length !== form.value.api_formats.length) {
|
||||||
form.value.api_formats = [...filtered]
|
form.value.api_formats = [...filtered]
|
||||||
return
|
return
|
||||||
@@ -639,7 +647,10 @@ function loadKeyData() {
|
|||||||
auth_type: props.editingKey.auth_type === 'service_account' ? 'service_account' : 'api_key',
|
auth_type: props.editingKey.auth_type === 'service_account' ? 'service_account' : 'api_key',
|
||||||
auth_config_text: '', // auth_config 不返回给前端,编辑时需要重新输入
|
auth_config_text: '', // auth_config 不返回给前端,编辑时需要重新输入
|
||||||
api_formats: props.editingKey.api_formats?.length > 0
|
api_formats: props.editingKey.api_formats?.length > 0
|
||||||
? filterAvailableApiFormats(props.editingKey.api_formats)
|
? sanitizeApiFormats(
|
||||||
|
props.editingKey.api_formats,
|
||||||
|
props.editingKey.auth_type === 'service_account' ? 'service_account' : 'api_key'
|
||||||
|
)
|
||||||
: [], // 编辑模式下保持原有选择,不默认全选
|
: [], // 编辑模式下保持原有选择,不默认全选
|
||||||
rate_multipliers: { ...(props.editingKey.rate_multipliers || {}) },
|
rate_multipliers: { ...(props.editingKey.rate_multipliers || {}) },
|
||||||
internal_priority: props.editingKey.internal_priority ?? 10,
|
internal_priority: props.editingKey.internal_priority ?? 10,
|
||||||
@@ -731,6 +742,8 @@ async function handleSave() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
form.value.api_formats = sanitizeApiFormats(form.value.api_formats)
|
||||||
|
|
||||||
// 验证至少选择一个 API 格式
|
// 验证至少选择一个 API 格式
|
||||||
if (form.value.api_formats.length === 0) {
|
if (form.value.api_formats.length === 0) {
|
||||||
showError('请至少选择一个 API 格式', '验证失败')
|
showError('请至少选择一个 API 格式', '验证失败')
|
||||||
|
|||||||
@@ -732,6 +732,7 @@ async function handleStartMappingTest() {
|
|||||||
displayLabel: `[${endpoint.api_format}] 映射 "${testingModelName.value}"`,
|
displayLabel: `[${endpoint.api_format}] 映射 "${testingModelName.value}"`,
|
||||||
apiFormat: endpoint.api_format,
|
apiFormat: endpoint.api_format,
|
||||||
endpointId: endpoint.id,
|
endpointId: endpoint.id,
|
||||||
|
endpointBaseUrl: endpoint.base_url,
|
||||||
requestHeaders,
|
requestHeaders,
|
||||||
requestBody,
|
requestBody,
|
||||||
concurrency: isPoolManagedProvider.value ? POOL_TEST_CONCURRENCY : SINGLE_TEST_CONCURRENCY,
|
concurrency: isPoolManagedProvider.value ? POOL_TEST_CONCURRENCY : SINGLE_TEST_CONCURRENCY,
|
||||||
|
|||||||
@@ -517,6 +517,7 @@ async function handleStartPendingTest() {
|
|||||||
displayLabel: `${endpointPrefix}${modelName}`,
|
displayLabel: `${endpointPrefix}${modelName}`,
|
||||||
apiFormat: endpoint.api_format,
|
apiFormat: endpoint.api_format,
|
||||||
endpointId: endpoint.id,
|
endpointId: endpoint.id,
|
||||||
|
endpointBaseUrl: endpoint.base_url,
|
||||||
requestHeaders,
|
requestHeaders,
|
||||||
requestBody,
|
requestBody,
|
||||||
concurrency: isPoolManagedProvider.value ? POOL_TEST_CONCURRENCY : SINGLE_TEST_CONCURRENCY,
|
concurrency: isPoolManagedProvider.value ? POOL_TEST_CONCURRENCY : SINGLE_TEST_CONCURRENCY,
|
||||||
|
|||||||
Reference in New Issue
Block a user