mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-11 03:39:49 +08:00
Merge origin/main into fix/gemini-cli-v1internal
This commit is contained in:
@@ -5,6 +5,7 @@ use super::auth::{AntigravityRequestAuth, ANTIGRAVITY_REQUEST_USER_AGENT};
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AntigravityEnvelopeRequestType {
|
||||
Agent,
|
||||
Checkpoint,
|
||||
EndpointTest,
|
||||
}
|
||||
|
||||
@@ -12,6 +13,7 @@ impl AntigravityEnvelopeRequestType {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Agent => "agent",
|
||||
Self::Checkpoint => "checkpoint",
|
||||
Self::EndpointTest => "endpoint_test",
|
||||
}
|
||||
}
|
||||
@@ -29,7 +31,6 @@ pub enum AntigravityRequestEnvelopeUnsupportedReason {
|
||||
MissingContents,
|
||||
MissingRequestId,
|
||||
MissingModel,
|
||||
ComplexEnvelopeTransform,
|
||||
}
|
||||
|
||||
pub fn classify_antigravity_safe_request_body(
|
||||
@@ -38,12 +39,9 @@ pub fn classify_antigravity_safe_request_body(
|
||||
let Value::Object(map) = request_body else {
|
||||
return Err(AntigravityRequestEnvelopeUnsupportedReason::NonObjectBody);
|
||||
};
|
||||
if !map.contains_key("contents") {
|
||||
if !map.contains_key("contents") && existing_v1internal_request_object(map).is_none() {
|
||||
return Err(AntigravityRequestEnvelopeUnsupportedReason::MissingContents);
|
||||
}
|
||||
if contains_blocked_request_features(request_body) {
|
||||
return Err(AntigravityRequestEnvelopeUnsupportedReason::ComplexEnvelopeTransform);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -75,6 +73,27 @@ pub fn build_antigravity_safe_v1internal_request(
|
||||
);
|
||||
};
|
||||
|
||||
if let Some(existing_request) = existing_v1internal_request_object(source) {
|
||||
let mut inner_request: Map<String, Value> = existing_request.clone();
|
||||
inner_request.remove("model");
|
||||
inner_request.remove("safetySettings");
|
||||
inner_request.remove("safety_settings");
|
||||
let request_id = non_empty_string_field(source, "requestId").unwrap_or(request_id);
|
||||
let user_agent =
|
||||
non_empty_string_field(source, "userAgent").unwrap_or(ANTIGRAVITY_REQUEST_USER_AGENT);
|
||||
let request_type =
|
||||
existing_v1internal_request_type(source).unwrap_or_else(|| request_type.as_str());
|
||||
|
||||
return AntigravityRequestEnvelopeSupport::Supported(serde_json::json!({
|
||||
"project": auth.project_id,
|
||||
"requestId": request_id,
|
||||
"request": Value::Object(inner_request),
|
||||
"model": model,
|
||||
"userAgent": user_agent,
|
||||
"requestType": request_type,
|
||||
}));
|
||||
}
|
||||
|
||||
let mut inner_request: Map<String, Value> = source.clone();
|
||||
inner_request.remove("model");
|
||||
inner_request.remove("safetySettings");
|
||||
@@ -90,31 +109,258 @@ pub fn build_antigravity_safe_v1internal_request(
|
||||
}))
|
||||
}
|
||||
|
||||
fn contains_blocked_request_features(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Object(map) => map.iter().any(|(key, inner)| {
|
||||
is_blocked_request_key(key.as_str()) || contains_blocked_request_features(inner)
|
||||
}),
|
||||
Value::Array(items) => items.iter().any(contains_blocked_request_features),
|
||||
_ => false,
|
||||
fn existing_v1internal_request_object(source: &Map<String, Value>) -> Option<&Map<String, Value>> {
|
||||
source
|
||||
.get("request")
|
||||
.and_then(Value::as_object)
|
||||
.filter(|request| request.contains_key("contents"))
|
||||
}
|
||||
|
||||
fn non_empty_string_field<'a>(source: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
|
||||
source
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn existing_v1internal_request_type(source: &Map<String, Value>) -> Option<&str> {
|
||||
match non_empty_string_field(source, "requestType")? {
|
||||
"agent" => Some("agent"),
|
||||
"checkpoint" => Some("checkpoint"),
|
||||
"endpoint_test" => Some("endpoint_test"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_blocked_request_key(key: &str) -> bool {
|
||||
matches!(
|
||||
key.trim(),
|
||||
"systemInstruction"
|
||||
| "system_instruction"
|
||||
| "tools"
|
||||
| "toolConfig"
|
||||
| "tool_config"
|
||||
| "thinkingConfig"
|
||||
| "thinking_config"
|
||||
| "imageConfig"
|
||||
| "image_config"
|
||||
| "functionCall"
|
||||
| "function_call"
|
||||
| "functionResponse"
|
||||
| "function_response"
|
||||
)
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
build_antigravity_safe_v1internal_request, classify_antigravity_safe_request_body,
|
||||
AntigravityEnvelopeRequestType, AntigravityRequestAuth, AntigravityRequestEnvelopeSupport,
|
||||
};
|
||||
|
||||
fn sample_auth() -> AntigravityRequestAuth {
|
||||
AntigravityRequestAuth {
|
||||
project_id: "project-ant-123".to_string(),
|
||||
client_version: None,
|
||||
session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_agent_request_preserves_antigravity_agent_fields() {
|
||||
let request_body = json!({
|
||||
"model": "client-side-model-should-not-be-nested",
|
||||
"contents": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{ "text": "Reply with OK only." }
|
||||
]
|
||||
}
|
||||
],
|
||||
"systemInstruction": {
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{ "text": "Antigravity agent system prompt" }
|
||||
]
|
||||
},
|
||||
"generationConfig": {
|
||||
"maxOutputTokens": 8192,
|
||||
"thinkingConfig": {
|
||||
"includeThoughts": true,
|
||||
"thinkingBudget": 4000
|
||||
}
|
||||
},
|
||||
"toolConfig": {
|
||||
"functionCallingConfig": {
|
||||
"mode": "VALIDATED"
|
||||
}
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
"functionDeclarations": [
|
||||
{
|
||||
"name": "run_command",
|
||||
"description": "Run a command",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cmd": { "type": "string" }
|
||||
},
|
||||
"required": ["cmd"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"labels": {
|
||||
"trajectory_id": "trajectory-123",
|
||||
"used_claude": "false"
|
||||
},
|
||||
"sessionId": "session-ant-123",
|
||||
"safetySettings": [
|
||||
{ "category": "HARM_CATEGORY_UNSPECIFIED" }
|
||||
]
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
classify_antigravity_safe_request_body(&request_body),
|
||||
Ok(())
|
||||
);
|
||||
|
||||
let envelope = match build_antigravity_safe_v1internal_request(
|
||||
&sample_auth(),
|
||||
"request-ant-agent-123",
|
||||
"gemini-3.5-flash-low",
|
||||
&request_body,
|
||||
AntigravityEnvelopeRequestType::Agent,
|
||||
) {
|
||||
AntigravityRequestEnvelopeSupport::Supported(envelope) => envelope,
|
||||
AntigravityRequestEnvelopeSupport::Unsupported(reason) => {
|
||||
panic!("real agent envelope should be supported: {reason:?}")
|
||||
}
|
||||
};
|
||||
|
||||
assert_eq!(envelope["project"], "project-ant-123");
|
||||
assert_eq!(envelope["requestId"], "request-ant-agent-123");
|
||||
assert_eq!(envelope["model"], "gemini-3.5-flash-low");
|
||||
assert_eq!(envelope["userAgent"], "antigravity");
|
||||
assert_eq!(envelope["requestType"], "agent");
|
||||
assert!(envelope["request"].get("model").is_none());
|
||||
assert!(envelope["request"].get("safetySettings").is_none());
|
||||
assert_eq!(
|
||||
envelope["request"]["systemInstruction"]["parts"][0]["text"],
|
||||
"Antigravity agent system prompt"
|
||||
);
|
||||
assert_eq!(
|
||||
envelope["request"]["generationConfig"]["thinkingConfig"]["thinkingBudget"],
|
||||
4000
|
||||
);
|
||||
assert_eq!(
|
||||
envelope["request"]["toolConfig"]["functionCallingConfig"]["mode"],
|
||||
"VALIDATED"
|
||||
);
|
||||
assert_eq!(
|
||||
envelope["request"]["tools"][0]["functionDeclarations"][0]["name"],
|
||||
"run_command"
|
||||
);
|
||||
assert_eq!(
|
||||
envelope["request"]["labels"]["trajectory_id"],
|
||||
"trajectory-123"
|
||||
);
|
||||
assert_eq!(envelope["request"]["sessionId"], "session-ant-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkpoint_request_type_builds_checkpoint_envelope() {
|
||||
let request_body = json!({
|
||||
"contents": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{ "text": "checkpoint context" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"generationConfig": {
|
||||
"maxOutputTokens": 8192,
|
||||
"thinkingConfig": {
|
||||
"includeThoughts": true,
|
||||
"thinkingBudget": 4000
|
||||
}
|
||||
},
|
||||
"toolConfig": {
|
||||
"functionCallingConfig": {
|
||||
"mode": "NONE"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let envelope = match build_antigravity_safe_v1internal_request(
|
||||
&sample_auth(),
|
||||
"request-ant-checkpoint-123",
|
||||
"gemini-3.5-flash-low",
|
||||
&request_body,
|
||||
AntigravityEnvelopeRequestType::Checkpoint,
|
||||
) {
|
||||
AntigravityRequestEnvelopeSupport::Supported(envelope) => envelope,
|
||||
AntigravityRequestEnvelopeSupport::Unsupported(reason) => {
|
||||
panic!("checkpoint envelope should be supported: {reason:?}")
|
||||
}
|
||||
};
|
||||
|
||||
assert_eq!(envelope["requestType"], "checkpoint");
|
||||
assert_eq!(
|
||||
envelope["request"]["toolConfig"]["functionCallingConfig"]["mode"],
|
||||
"NONE"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_v1internal_envelope_is_not_double_wrapped() {
|
||||
let request_body = json!({
|
||||
"project": "client-side-project",
|
||||
"requestId": "client-request-id-123",
|
||||
"model": "gemini-3.5-flash-low",
|
||||
"userAgent": "antigravity",
|
||||
"requestType": "checkpoint",
|
||||
"request": {
|
||||
"contents": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{ "text": "checkpoint context" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"generationConfig": {
|
||||
"thinkingConfig": {
|
||||
"includeThoughts": true
|
||||
}
|
||||
},
|
||||
"toolConfig": {
|
||||
"functionCallingConfig": {
|
||||
"mode": "NONE"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
classify_antigravity_safe_request_body(&request_body),
|
||||
Ok(())
|
||||
);
|
||||
|
||||
let envelope = match build_antigravity_safe_v1internal_request(
|
||||
&sample_auth(),
|
||||
"trace-request-id-should-not-overwrite-client-id",
|
||||
"mapped-antigravity-model",
|
||||
&request_body,
|
||||
AntigravityEnvelopeRequestType::Agent,
|
||||
) {
|
||||
AntigravityRequestEnvelopeSupport::Supported(envelope) => envelope,
|
||||
AntigravityRequestEnvelopeSupport::Unsupported(reason) => {
|
||||
panic!("existing v1internal envelope should be supported: {reason:?}")
|
||||
}
|
||||
};
|
||||
|
||||
assert_eq!(envelope["project"], "project-ant-123");
|
||||
assert_eq!(envelope["requestId"], "client-request-id-123");
|
||||
assert_eq!(envelope["model"], "mapped-antigravity-model");
|
||||
assert_eq!(envelope["userAgent"], "antigravity");
|
||||
assert_eq!(envelope["requestType"], "checkpoint");
|
||||
assert!(envelope["request"].get("request").is_none());
|
||||
assert_eq!(
|
||||
envelope["request"]["contents"][0]["parts"][0]["text"],
|
||||
"checkpoint context"
|
||||
);
|
||||
assert_eq!(
|
||||
envelope["request"]["toolConfig"]["functionCallingConfig"]["mode"],
|
||||
"NONE"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use super::snapshot::GatewayProviderTransportSnapshot;
|
||||
const TUNNEL_BASE_URL_EXTRA_KEY: &str = "tunnel_base_url";
|
||||
const TUNNEL_OWNER_INSTANCE_ID_EXTRA_KEY: &str = "tunnel_owner_instance_id";
|
||||
const TUNNEL_OWNER_OBSERVED_AT_EXTRA_KEY: &str = "tunnel_owner_observed_at_unix_secs";
|
||||
const DEFAULT_PROVIDER_STREAM_FIRST_BYTE_TIMEOUT_SECS: f64 = 30.0;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TransportTunnelAttachmentOwner {
|
||||
@@ -32,28 +33,27 @@ pub trait TransportTunnelAffinityLookup: Send + Sync {
|
||||
pub fn resolve_transport_execution_timeouts(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<ExecutionTimeouts> {
|
||||
let total_ms = transport
|
||||
.provider
|
||||
.request_timeout_secs
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
.map(|value| (value * 1000.0).round() as u64);
|
||||
let first_byte_ms = transport
|
||||
.provider
|
||||
.stream_first_byte_timeout_secs
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
.map(|value| (value * 1000.0).round() as u64);
|
||||
|
||||
if total_ms.is_none() && first_byte_ms.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ExecutionTimeouts {
|
||||
total_ms,
|
||||
first_byte_ms,
|
||||
total_ms: transport
|
||||
.provider
|
||||
.request_timeout_secs
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
.map(timeout_secs_to_ms),
|
||||
first_byte_ms: Some(timeout_secs_to_ms(
|
||||
transport
|
||||
.provider
|
||||
.stream_first_byte_timeout_secs
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
.unwrap_or(DEFAULT_PROVIDER_STREAM_FIRST_BYTE_TIMEOUT_SECS),
|
||||
)),
|
||||
..ExecutionTimeouts::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn timeout_secs_to_ms(secs: f64) -> u64 {
|
||||
((secs * 1000.0).round() as u64).max(1)
|
||||
}
|
||||
|
||||
pub fn resolve_transport_proxy_snapshot(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<ProxySnapshot> {
|
||||
@@ -338,7 +338,8 @@ mod tests {
|
||||
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||
};
|
||||
use super::{
|
||||
resolve_transport_profile, resolve_transport_profile_id, resolve_transport_proxy_snapshot,
|
||||
resolve_transport_execution_timeouts, resolve_transport_profile,
|
||||
resolve_transport_profile_id, resolve_transport_proxy_snapshot,
|
||||
resolve_transport_proxy_snapshot_with_tunnel_affinity, transport_profile_is_configured,
|
||||
transport_proxy_is_locally_supported, TransportTunnelAffinityLookup,
|
||||
TransportTunnelAttachmentOwner,
|
||||
@@ -429,6 +430,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_execution_timeouts_use_provider_defaults_when_unset() {
|
||||
let transport = sample_transport();
|
||||
|
||||
let timeouts = resolve_transport_execution_timeouts(&transport)
|
||||
.expect("default provider timeouts should resolve");
|
||||
|
||||
assert_eq!(timeouts.total_ms, None);
|
||||
assert_eq!(timeouts.first_byte_ms, Some(30_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_execution_timeouts_preserve_configured_values_independently() {
|
||||
let mut transport = sample_transport();
|
||||
transport.provider.request_timeout_secs = Some(12.0);
|
||||
|
||||
let timeouts = resolve_transport_execution_timeouts(&transport)
|
||||
.expect("provider timeouts should resolve");
|
||||
|
||||
assert_eq!(timeouts.total_ms, Some(12_000));
|
||||
assert_eq!(timeouts.first_byte_ms, Some(30_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_execution_timeouts_preserve_configured_first_byte_value() {
|
||||
let mut transport = sample_transport();
|
||||
transport.provider.stream_first_byte_timeout_secs = Some(7.5);
|
||||
|
||||
let timeouts = resolve_transport_execution_timeouts(&transport)
|
||||
.expect("provider timeouts should resolve");
|
||||
|
||||
assert_eq!(timeouts.total_ms, None);
|
||||
assert_eq!(timeouts.first_byte_ms, Some(7_500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_transport_proxy_with_key_precedence() {
|
||||
let snapshot = resolve_transport_proxy_snapshot(&sample_transport())
|
||||
|
||||
@@ -16,8 +16,8 @@ use crate::gemini_cli::{
|
||||
use crate::snapshot::GatewayProviderTransportSnapshot;
|
||||
use crate::url::{
|
||||
build_claude_messages_url, build_gemini_content_url, build_openai_chat_url,
|
||||
build_openai_responses_url, build_passthrough_path_url,
|
||||
google_openai_compat_base_includes_api_root, normalize_gemini_content_action_path,
|
||||
build_openai_responses_url, build_passthrough_path_url, normalize_gemini_content_action_path,
|
||||
openai_compatible_base_includes_api_root,
|
||||
};
|
||||
use crate::vertex::{
|
||||
build_vertex_api_key_gemini_content_url, build_vertex_api_key_gemini_embedding_url,
|
||||
@@ -427,7 +427,7 @@ fn build_provider_v1_url(
|
||||
.unwrap_or_else(|| upstream_base_url.trim())
|
||||
.trim_end_matches('/');
|
||||
let path = if base_without_query.ends_with("/v1")
|
||||
|| google_openai_compat_base_includes_api_root(base_without_query)
|
||||
|| openai_compatible_base_includes_api_root(base_without_query)
|
||||
{
|
||||
v1_path
|
||||
} else {
|
||||
@@ -1224,7 +1224,7 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.as_deref(),
|
||||
Some("https://api.openai.example/root/v1/embeddings?tenant=request&trace=1")
|
||||
Some("https://api.openai.example/root/embeddings?tenant=request&trace=1")
|
||||
);
|
||||
assert_eq!(
|
||||
build_transport_request_url(
|
||||
|
||||
@@ -7,12 +7,11 @@ use url::Url;
|
||||
pub fn build_openai_chat_url(upstream_base_url: &str, query: Option<&str>) -> String {
|
||||
let (trimmed, base_query) = split_base_url_query(upstream_base_url);
|
||||
let trimmed = trimmed.trim_end_matches('/');
|
||||
let mut url =
|
||||
if trimmed.ends_with("/v1") || google_openai_compat_base_includes_api_root(trimmed) {
|
||||
format!("{trimmed}/chat/completions")
|
||||
} else {
|
||||
format!("{trimmed}/v1/chat/completions")
|
||||
};
|
||||
let mut url = if openai_compatible_base_includes_api_root(trimmed) {
|
||||
format!("{trimmed}/chat/completions")
|
||||
} else {
|
||||
format!("{trimmed}/v1/chat/completions")
|
||||
};
|
||||
append_merged_query(&mut url, base_query, None, query, &[]);
|
||||
url
|
||||
}
|
||||
@@ -31,7 +30,7 @@ pub fn build_openai_responses_url(
|
||||
};
|
||||
let mut url = if is_codex_cli_backend_url(trimmed)
|
||||
|| trimmed.ends_with("/codex")
|
||||
|| trimmed.ends_with("/v1")
|
||||
|| openai_compatible_base_includes_api_root(trimmed)
|
||||
{
|
||||
format!("{trimmed}{suffix}")
|
||||
} else {
|
||||
@@ -51,7 +50,7 @@ pub fn build_openai_image_url(
|
||||
let suffix = openai_image_path_suffix(request_path);
|
||||
let mut url = if openai_image_base_includes_operation_path(trimmed) {
|
||||
trimmed.to_string()
|
||||
} else if trimmed.ends_with("/v1") || google_openai_compat_base_includes_api_root(trimmed) {
|
||||
} else if openai_compatible_base_includes_api_root(trimmed) {
|
||||
format!("{trimmed}{suffix}")
|
||||
} else {
|
||||
format!("{trimmed}/v1{suffix}")
|
||||
@@ -200,6 +199,46 @@ pub fn build_passthrough_path_url(
|
||||
Some(url)
|
||||
}
|
||||
|
||||
pub fn build_bigmodel_coding_models_url(upstream_base_url: &str) -> Option<String> {
|
||||
let (trimmed_base_url, base_query) = split_base_url_query(upstream_base_url);
|
||||
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
|
||||
if trimmed_base_url.is_empty() || !bigmodel_coding_models_base_is_supported(trimmed_base_url) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let path = Url::parse(trimmed_base_url)
|
||||
.ok()
|
||||
.map(|url| url.path().trim_end_matches('/').to_string())
|
||||
.unwrap_or_else(|| trimmed_base_url.trim_end_matches('/').to_string());
|
||||
let mut url = if path.ends_with("/models") {
|
||||
trimmed_base_url.to_string()
|
||||
} else {
|
||||
format!("{trimmed_base_url}/models")
|
||||
};
|
||||
append_merged_query(&mut url, base_query, None, None, &[]);
|
||||
Some(url)
|
||||
}
|
||||
|
||||
pub fn build_openai_compatible_models_url(upstream_base_url: &str) -> Option<String> {
|
||||
if let Some(url) = build_bigmodel_coding_models_url(upstream_base_url) {
|
||||
return Some(url);
|
||||
}
|
||||
|
||||
let (trimmed_base_url, base_query) = split_base_url_query(upstream_base_url);
|
||||
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
|
||||
if trimmed_base_url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut url = if openai_compatible_base_includes_api_root(trimmed_base_url) {
|
||||
format!("{trimmed_base_url}/models")
|
||||
} else {
|
||||
format!("{trimmed_base_url}/v1/models")
|
||||
};
|
||||
append_merged_query(&mut url, base_query, None, None, &[]);
|
||||
Some(url)
|
||||
}
|
||||
|
||||
pub fn build_gemini_files_passthrough_url(
|
||||
upstream_base_url: &str,
|
||||
path: &str,
|
||||
@@ -254,6 +293,61 @@ pub(crate) fn google_openai_compat_base_includes_api_root(base_url: &str) -> boo
|
||||
false
|
||||
}
|
||||
|
||||
pub fn openai_compatible_base_includes_api_root(base_url: &str) -> bool {
|
||||
let trimmed = base_url.trim().trim_end_matches('/');
|
||||
trimmed.ends_with("/v1")
|
||||
|| google_openai_compat_base_includes_api_root(trimmed)
|
||||
|| bigmodel_coding_base_includes_api_root(trimmed)
|
||||
|| openai_compatible_base_includes_unversioned_api_root(trimmed)
|
||||
}
|
||||
|
||||
pub fn v1_compatible_base_includes_api_root(base_url: &str) -> bool {
|
||||
let trimmed = base_url.trim().trim_end_matches('/');
|
||||
trimmed.ends_with("/v1") || openai_compatible_base_includes_unversioned_api_root(trimmed)
|
||||
}
|
||||
|
||||
pub fn openai_compatible_base_includes_unversioned_api_root(base_url: &str) -> bool {
|
||||
let trimmed = base_url.trim().trim_end_matches('/');
|
||||
let path = Url::parse(trimmed)
|
||||
.ok()
|
||||
.map(|url| url.path().trim_end_matches('/').to_ascii_lowercase())
|
||||
.unwrap_or_else(|| {
|
||||
trimmed
|
||||
.split_once('/')
|
||||
.map(|(_, path)| format!("/{path}"))
|
||||
.unwrap_or_default()
|
||||
.trim_end_matches('/')
|
||||
.to_ascii_lowercase()
|
||||
});
|
||||
!path.is_empty()
|
||||
}
|
||||
|
||||
fn bigmodel_coding_base_includes_api_root(base_url: &str) -> bool {
|
||||
let Ok(parsed) = Url::parse(base_url.trim()) else {
|
||||
return false;
|
||||
};
|
||||
let Some(host) = parsed.host_str().map(|value| value.to_ascii_lowercase()) else {
|
||||
return false;
|
||||
};
|
||||
host == "open.bigmodel.cn" && parsed.path().trim_end_matches('/') == "/api/coding/paas/v4"
|
||||
}
|
||||
|
||||
fn bigmodel_coding_models_base_is_supported(base_url: &str) -> bool {
|
||||
let Ok(parsed) = Url::parse(base_url.trim()) else {
|
||||
return false;
|
||||
};
|
||||
let Some(host) = parsed.host_str().map(|value| value.to_ascii_lowercase()) else {
|
||||
return false;
|
||||
};
|
||||
if host != "open.bigmodel.cn" {
|
||||
return false;
|
||||
}
|
||||
matches!(
|
||||
parsed.path().trim_end_matches('/'),
|
||||
"/api/coding/paas/v4" | "/api/coding/paas/v4/models"
|
||||
)
|
||||
}
|
||||
|
||||
fn looks_like_vertex_ai_host(host: &str) -> bool {
|
||||
const VERTEX_AI_HOST: &str = "aiplatform.googleapis.com";
|
||||
host == VERTEX_AI_HOST
|
||||
@@ -343,8 +437,9 @@ fn merge_query_string(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_gemini_content_url, build_gemini_files_passthrough_url,
|
||||
build_gemini_video_predict_long_running_url, build_openai_chat_url, build_openai_image_url,
|
||||
build_bigmodel_coding_models_url, build_claude_messages_url, build_gemini_content_url,
|
||||
build_gemini_files_passthrough_url, build_gemini_video_predict_long_running_url,
|
||||
build_openai_chat_url, build_openai_compatible_models_url, build_openai_image_url,
|
||||
build_openai_responses_url, build_passthrough_path_url,
|
||||
normalize_gemini_content_action_path,
|
||||
};
|
||||
@@ -378,6 +473,102 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_urls_preserve_bigmodel_coding_api_root() {
|
||||
assert_eq!(
|
||||
build_openai_chat_url(
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4",
|
||||
Some("trace=1")
|
||||
),
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions?trace=1"
|
||||
);
|
||||
assert_eq!(
|
||||
build_openai_responses_url("https://open.bigmodel.cn/api/coding/paas/v4", None, false),
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/responses"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_urls_preserve_unversioned_api_root() {
|
||||
assert_eq!(
|
||||
build_openai_chat_url("https://proxy.example.com/api", Some("trace=1")),
|
||||
"https://proxy.example.com/api/chat/completions?trace=1"
|
||||
);
|
||||
assert_eq!(
|
||||
build_openai_chat_url("https://proxy.example.com/openai", None),
|
||||
"https://proxy.example.com/openai/chat/completions"
|
||||
);
|
||||
assert_eq!(
|
||||
build_openai_chat_url("https://proxy.example.com", None),
|
||||
"https://proxy.example.com/v1/chat/completions"
|
||||
);
|
||||
assert_eq!(
|
||||
build_openai_responses_url("https://proxy.example.com/api", None, false),
|
||||
"https://proxy.example.com/api/responses"
|
||||
);
|
||||
assert_eq!(
|
||||
build_openai_image_url(
|
||||
"https://proxy.example.com/api",
|
||||
Some("/v1/images/generations"),
|
||||
None
|
||||
),
|
||||
"https://proxy.example.com/api/images/generations"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_messages_url_preserves_v1_and_unversioned_api_roots() {
|
||||
assert_eq!(
|
||||
build_claude_messages_url("https://api.anthropic.example/v1", Some("trace=1")),
|
||||
"https://api.anthropic.example/v1/messages?trace=1"
|
||||
);
|
||||
assert_eq!(
|
||||
build_claude_messages_url("https://proxy.example.com/api", None),
|
||||
"https://proxy.example.com/api/v1/messages"
|
||||
);
|
||||
assert_eq!(
|
||||
build_claude_messages_url("https://proxy.example.com/anthropic", None),
|
||||
"https://proxy.example.com/anthropic/v1/messages"
|
||||
);
|
||||
assert_eq!(
|
||||
build_claude_messages_url("https://api.anthropic.example", None),
|
||||
"https://api.anthropic.example/v1/messages"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bigmodel_coding_models_url_uses_models_resource() {
|
||||
assert_eq!(
|
||||
build_bigmodel_coding_models_url(
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4?tenant=demo"
|
||||
)
|
||||
.as_deref(),
|
||||
Some("https://open.bigmodel.cn/api/coding/paas/v4/models?tenant=demo")
|
||||
);
|
||||
assert_eq!(
|
||||
build_bigmodel_coding_models_url("https://open.bigmodel.cn/api/coding/paas/v4/models")
|
||||
.as_deref(),
|
||||
Some("https://open.bigmodel.cn/api/coding/paas/v4/models")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_compatible_models_url_preserves_unversioned_api_root() {
|
||||
assert_eq!(
|
||||
build_openai_compatible_models_url("https://proxy.example.com/api?tenant=demo")
|
||||
.as_deref(),
|
||||
Some("https://proxy.example.com/api/models?tenant=demo")
|
||||
);
|
||||
assert_eq!(
|
||||
build_openai_compatible_models_url("https://proxy.example.com/openai").as_deref(),
|
||||
Some("https://proxy.example.com/openai/models")
|
||||
);
|
||||
assert_eq!(
|
||||
build_openai_compatible_models_url("https://proxy.example.com").as_deref(),
|
||||
Some("https://proxy.example.com/v1/models")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_url_preserves_codex_path_prefix() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user