mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-13 06:30:20 +08:00
Fix Gemini CLI standard conversion envelope
This commit is contained in:
@@ -487,6 +487,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_gemini_cli_attempt(candidate_index: u32) -> LocalExecutionCandidateAttempt {
|
||||
let mut transport = sample_transport("gemini:generate_content", "endpoint-gemini-cli");
|
||||
transport.provider.provider_type = "gemini_cli".to_string();
|
||||
transport.provider.name = "gemini".to_string();
|
||||
transport.endpoint.base_url = "https://cloudcode-pa.googleapis.com".to_string();
|
||||
transport.endpoint.custom_path = Some("/v1internal:{action}".to_string());
|
||||
transport.endpoint.endpoint_kind = Some("generate_content".to_string());
|
||||
transport.key.auth_type = "bearer".to_string();
|
||||
transport.key.api_formats = Some(vec!["gemini:generate_content".to_string()]);
|
||||
transport.key.global_priority_by_format = Some(json!({
|
||||
"gemini:generate_content": 1,
|
||||
}));
|
||||
transport.key.upstream_metadata = Some(json!({
|
||||
"gemini_cli": {
|
||||
"project_id": "test-project"
|
||||
}
|
||||
}));
|
||||
|
||||
let mut candidate = sample_candidate("gemini:generate_content", "endpoint-gemini-cli");
|
||||
candidate.provider_name = "gemini".to_string();
|
||||
candidate.provider_type = "gemini_cli".to_string();
|
||||
candidate.key_auth_type = "bearer".to_string();
|
||||
candidate.selected_provider_model_name = "gemini-2.5-pro".to_string();
|
||||
candidate.global_model_name = "gemini-2.5-pro".to_string();
|
||||
|
||||
LocalExecutionCandidateAttempt {
|
||||
eligible: EligibleLocalExecutionCandidate {
|
||||
kind: LocalExecutionCandidateKind::SingleKey,
|
||||
candidate,
|
||||
transport: Arc::new(transport),
|
||||
provider_api_format: "gemini:generate_content".to_string(),
|
||||
orchestration: LocalExecutionCandidateMetadata::default(),
|
||||
ranking: None,
|
||||
},
|
||||
candidate_index,
|
||||
retry_index: 0,
|
||||
candidate_id: format!("candidate-{candidate_index}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_stream_spec() -> LocalStandardSpec {
|
||||
LocalStandardSpec {
|
||||
api_format: "claude:messages",
|
||||
@@ -578,4 +618,90 @@ mod tests {
|
||||
Some("bidirectional")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standard_family_wraps_gemini_cli_cross_format_body_in_v1internal_envelope() {
|
||||
let state = crate::AppState::new().expect("state should build");
|
||||
let request = http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/chat/completions")
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (parts, _) = request.into_parts();
|
||||
let body_json = json!({
|
||||
"model": "gemini-2.5-pro",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"temperature": 0.2,
|
||||
"stream": true
|
||||
});
|
||||
let mut input = sample_input();
|
||||
input.requested_model = "gemini-2.5-pro".to_string();
|
||||
let spec = LocalStandardSpec {
|
||||
api_format: "openai:chat",
|
||||
decision_kind: "openai_chat_stream",
|
||||
report_kind: "openai_chat_stream_success",
|
||||
family: LocalStandardSourceFamily::Standard,
|
||||
mode: LocalStandardSourceMode::Chat,
|
||||
require_streaming: true,
|
||||
};
|
||||
|
||||
let payload = maybe_build_local_standard_decision_payload_for_candidate(
|
||||
&state,
|
||||
&parts,
|
||||
"trace-gemini-cli-cross-format",
|
||||
&body_json,
|
||||
&input,
|
||||
sample_gemini_cli_attempt(0),
|
||||
spec,
|
||||
)
|
||||
.await
|
||||
.expect("cross-format candidate should not fail routing mutation")
|
||||
.expect("gemini_cli candidate should build a payload");
|
||||
|
||||
assert_eq!(
|
||||
payload.upstream_url.as_deref(),
|
||||
Some("https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse")
|
||||
);
|
||||
assert_eq!(
|
||||
payload.execution_strategy.as_deref(),
|
||||
Some("local_cross_format")
|
||||
);
|
||||
assert_eq!(
|
||||
payload.provider_api_format.as_deref(),
|
||||
Some("gemini:generate_content")
|
||||
);
|
||||
assert_eq!(
|
||||
payload
|
||||
.provider_request_headers
|
||||
.get("user-agent")
|
||||
.map(String::as_str),
|
||||
Some(crate::ai_serving::transport::GEMINI_CLI_USER_AGENT)
|
||||
);
|
||||
|
||||
let provider_body = payload
|
||||
.provider_request_body
|
||||
.as_ref()
|
||||
.expect("provider request body should be present");
|
||||
assert_eq!(provider_body["model"], "gemini-2.5-pro");
|
||||
assert_eq!(provider_body["project"], "test-project");
|
||||
assert_eq!(
|
||||
provider_body["user_prompt_id"],
|
||||
"trace-gemini-cli-cross-format"
|
||||
);
|
||||
assert!(provider_body.get("contents").is_none());
|
||||
assert!(provider_body.get("generationConfig").is_none());
|
||||
assert!(provider_body["request"].get("contents").is_some());
|
||||
|
||||
let report_context = payload
|
||||
.report_context
|
||||
.as_ref()
|
||||
.expect("report context should be present");
|
||||
assert_eq!(
|
||||
report_context
|
||||
.get("envelope_name")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some(crate::ai_serving::transport::GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,21 +16,24 @@ use crate::ai_serving::planner::spec_metadata::local_standard_spec_metadata;
|
||||
use crate::ai_serving::planner::standard::{
|
||||
apply_codex_openai_responses_special_headers, request_body_build_failure_extra_data,
|
||||
};
|
||||
use crate::ai_serving::transport::gemini_cli::resolve_gemini_cli_project_id;
|
||||
use crate::ai_serving::transport::kiro::{
|
||||
build_kiro_provider_headers, build_kiro_provider_request_body,
|
||||
is_kiro_claude_messages_transport, KiroProviderHeadersInput, KiroRequestAuth,
|
||||
KIRO_ENVELOPE_NAME,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
build_grok_browser_headers, build_grok_upstream_url, build_kiro_cross_format_upstream_url,
|
||||
build_openai_image_headers, build_openai_image_upstream_url,
|
||||
build_standard_provider_request_headers, build_windsurf_cascade_headers,
|
||||
build_windsurf_cascade_request_body, build_windsurf_cascade_upstream_url,
|
||||
build_gemini_cli_v1internal_request, build_grok_browser_headers, build_grok_upstream_url,
|
||||
build_kiro_cross_format_upstream_url, build_openai_image_headers,
|
||||
build_openai_image_upstream_url, build_standard_provider_request_headers,
|
||||
build_windsurf_cascade_headers, build_windsurf_cascade_request_body,
|
||||
build_windsurf_cascade_upstream_url, is_gemini_cli_provider_transport,
|
||||
is_windsurf_provider_transport,
|
||||
local_windsurf_request_transport_unsupported_reason_with_network,
|
||||
openai_image_transport_unsupported_reason, resolve_grok_session_auth,
|
||||
resolve_openai_image_auth, GrokHeaderInput, ProviderOpenAiImageHeadersInput,
|
||||
StandardProviderRequestHeadersInput, GROK_CHAT_PATH, WINDSURF_ENVELOPE_NAME,
|
||||
resolve_openai_image_auth, GeminiCliRequestEnvelopeSupport, GrokHeaderInput,
|
||||
ProviderOpenAiImageHeadersInput, StandardProviderRequestHeadersInput, GEMINI_CLI_USER_AGENT,
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, GROK_CHAT_PATH, WINDSURF_ENVELOPE_NAME,
|
||||
};
|
||||
use crate::ai_serving::{
|
||||
build_openai_image_request_body_from_gemini_image_request, gemini_request_is_image_generation,
|
||||
@@ -611,6 +614,30 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
|
||||
.await;
|
||||
}
|
||||
|
||||
let normalized_provider_api_format =
|
||||
crate::ai_serving::normalize_api_format_alias(provider_api_format);
|
||||
if normalized_provider_api_format == "gemini:generate_content"
|
||||
&& is_gemini_cli_provider_transport(transport)
|
||||
{
|
||||
return build_gemini_cli_cross_format_payload_parts(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
body_json,
|
||||
input,
|
||||
attempt,
|
||||
transport,
|
||||
spec_metadata.api_format,
|
||||
provider_api_format,
|
||||
prepared_candidate.mapped_model,
|
||||
prepared_candidate.auth_header,
|
||||
prepared_candidate.auth_value,
|
||||
provider_request_body,
|
||||
upstream_is_stream,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let upstream_url = match crate::ai_serving::planner::standard::build_standard_upstream_url(
|
||||
parts,
|
||||
transport,
|
||||
@@ -709,6 +736,171 @@ fn apply_transport_request_body_semantics(
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn build_gemini_cli_cross_format_payload_parts(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
original_body_json: &serde_json::Value,
|
||||
input: &LocalStandardDecisionInput,
|
||||
attempt: &LocalStandardCandidateAttempt,
|
||||
transport: &Arc<GatewayProviderTransportSnapshot>,
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
mapped_model: String,
|
||||
auth_header: String,
|
||||
auth_value: String,
|
||||
gemini_request_body: Value,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<LocalStandardCandidatePayloadParts> {
|
||||
let candidate = &attempt.eligible.candidate;
|
||||
let effective_headers = input.effective_headers(&parts.headers);
|
||||
let mut resolved_transport = Arc::clone(transport);
|
||||
let project_id = match resolve_gemini_cli_project_id(&resolved_transport) {
|
||||
Some(project_id) => Some(project_id),
|
||||
None => match state
|
||||
.hydrate_gemini_cli_project_metadata_for_transport(&resolved_transport)
|
||||
.await
|
||||
{
|
||||
Some(hydrated) => {
|
||||
let project_id = resolve_gemini_cli_project_id(&hydrated);
|
||||
resolved_transport = Arc::new(hydrated);
|
||||
project_id
|
||||
}
|
||||
None => None,
|
||||
},
|
||||
};
|
||||
let Some(project_id) = project_id else {
|
||||
mark_skipped_local_standard_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"transport_auth_unavailable",
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
|
||||
let provider_request_body = match build_gemini_cli_v1internal_request(
|
||||
&project_id,
|
||||
trace_id,
|
||||
&mapped_model,
|
||||
&gemini_request_body,
|
||||
) {
|
||||
GeminiCliRequestEnvelopeSupport::Supported(envelope) => envelope,
|
||||
GeminiCliRequestEnvelopeSupport::Unsupported(_) => {
|
||||
mark_skipped_local_standard_candidate_with_extra_data(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"provider_request_body_build_failed",
|
||||
request_body_build_failure_extra_data(
|
||||
original_body_json,
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let upstream_url = match crate::ai_serving::planner::standard::build_standard_upstream_url(
|
||||
parts,
|
||||
&resolved_transport,
|
||||
&mapped_model,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
Some(&provider_request_body),
|
||||
) {
|
||||
Some(url) => url,
|
||||
None => {
|
||||
mark_skipped_local_standard_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"upstream_url_missing",
|
||||
CandidateFailureDiagnostic::upstream_url_missing(
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
"standard_family_gemini_cli_url",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let extra_headers =
|
||||
BTreeMap::from([("user-agent".to_string(), GEMINI_CLI_USER_AGENT.to_string())]);
|
||||
let Some(resolved_headers) =
|
||||
build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
|
||||
transport: &resolved_transport,
|
||||
provider_api_format,
|
||||
same_format: false,
|
||||
headers: effective_headers,
|
||||
auth_header: &auth_header,
|
||||
auth_value: &auth_value,
|
||||
extra_headers: &extra_headers,
|
||||
header_rules: resolved_transport.endpoint.header_rules.as_ref(),
|
||||
provider_request_body: &provider_request_body,
|
||||
original_request_body: original_body_json,
|
||||
upstream_is_stream,
|
||||
})
|
||||
else {
|
||||
mark_skipped_local_standard_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"transport_header_rules_apply_failed",
|
||||
CandidateFailureDiagnostic::header_rules_apply_failed(
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
"standard_family_gemini_cli_headers",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut provider_request_headers = resolved_headers.headers;
|
||||
apply_codex_openai_responses_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&provider_request_body,
|
||||
effective_headers,
|
||||
resolved_transport.provider.provider_type.as_str(),
|
||||
provider_api_format,
|
||||
Some(trace_id),
|
||||
resolved_transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
|
||||
Some(LocalStandardCandidatePayloadParts {
|
||||
auth_header: resolved_headers.auth_header,
|
||||
auth_value: resolved_headers.auth_value,
|
||||
mapped_model,
|
||||
provider_api_format: provider_api_format.to_string(),
|
||||
provider_request_body,
|
||||
provider_request_headers,
|
||||
upstream_url,
|
||||
upstream_is_stream,
|
||||
envelope_name: Some(GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME),
|
||||
transport: resolved_transport,
|
||||
transport_profile: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn build_windsurf_cross_format_payload_parts(
|
||||
state: &AppState,
|
||||
|
||||
Reference in New Issue
Block a user