mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-11 03:39:49 +08:00
Fix Gemini CLI OpenAI conversion envelope
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::ai_serving::transport::gemini_cli::resolve_gemini_cli_project_id;
|
||||||
|
use crate::ai_serving::transport::{
|
||||||
|
build_gemini_cli_v1internal_request, GatewayProviderTransportSnapshot,
|
||||||
|
GeminiCliRequestEnvelopeSupport,
|
||||||
|
};
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
pub(crate) struct GeminiCliV1InternalPayload {
|
||||||
|
pub(crate) transport: Arc<GatewayProviderTransportSnapshot>,
|
||||||
|
pub(crate) body: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum GeminiCliV1InternalPayloadError {
|
||||||
|
ProjectUnavailable,
|
||||||
|
EnvelopeUnsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn build_gemini_cli_v1internal_payload(
|
||||||
|
state: &AppState,
|
||||||
|
transport: &Arc<GatewayProviderTransportSnapshot>,
|
||||||
|
trace_id: &str,
|
||||||
|
mapped_model: &str,
|
||||||
|
gemini_request_body: &Value,
|
||||||
|
) -> Result<GeminiCliV1InternalPayload, GeminiCliV1InternalPayloadError> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
.ok_or(GeminiCliV1InternalPayloadError::ProjectUnavailable)?;
|
||||||
|
|
||||||
|
let body = match build_gemini_cli_v1internal_request(
|
||||||
|
&project_id,
|
||||||
|
trace_id,
|
||||||
|
mapped_model,
|
||||||
|
gemini_request_body,
|
||||||
|
) {
|
||||||
|
GeminiCliRequestEnvelopeSupport::Supported(envelope) => envelope,
|
||||||
|
GeminiCliRequestEnvelopeSupport::Unsupported(_) => {
|
||||||
|
return Err(GeminiCliV1InternalPayloadError::EnvelopeUnsupported);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(GeminiCliV1InternalPayload {
|
||||||
|
transport: resolved_transport,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ mod candidate_transport_ranking_facts;
|
|||||||
mod common;
|
mod common;
|
||||||
mod decision;
|
mod decision;
|
||||||
mod decision_input;
|
mod decision_input;
|
||||||
|
mod gemini_cli;
|
||||||
mod materialization_policy;
|
mod materialization_policy;
|
||||||
mod passthrough;
|
mod passthrough;
|
||||||
mod plan_builders;
|
mod plan_builders;
|
||||||
|
|||||||
@@ -12,27 +12,28 @@ use crate::ai_serving::planner::common::{
|
|||||||
endpoint_config_forces_body_stream_field, enforce_provider_body_stream_policy,
|
endpoint_config_forces_body_stream_field, enforce_provider_body_stream_policy,
|
||||||
request_requires_body_stream_field, resolve_upstream_is_stream_for_provider,
|
request_requires_body_stream_field, resolve_upstream_is_stream_for_provider,
|
||||||
};
|
};
|
||||||
|
use crate::ai_serving::planner::gemini_cli::{
|
||||||
|
build_gemini_cli_v1internal_payload, GeminiCliV1InternalPayloadError,
|
||||||
|
};
|
||||||
use crate::ai_serving::planner::spec_metadata::local_standard_spec_metadata;
|
use crate::ai_serving::planner::spec_metadata::local_standard_spec_metadata;
|
||||||
use crate::ai_serving::planner::standard::{
|
use crate::ai_serving::planner::standard::{
|
||||||
apply_codex_openai_responses_special_headers, request_body_build_failure_extra_data,
|
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::{
|
use crate::ai_serving::transport::kiro::{
|
||||||
build_kiro_provider_headers, build_kiro_provider_request_body,
|
build_kiro_provider_headers, build_kiro_provider_request_body,
|
||||||
is_kiro_claude_messages_transport, KiroProviderHeadersInput, KiroRequestAuth,
|
is_kiro_claude_messages_transport, KiroProviderHeadersInput, KiroRequestAuth,
|
||||||
KIRO_ENVELOPE_NAME,
|
KIRO_ENVELOPE_NAME,
|
||||||
};
|
};
|
||||||
use crate::ai_serving::transport::{
|
use crate::ai_serving::transport::{
|
||||||
build_gemini_cli_v1internal_request, build_grok_browser_headers, build_grok_upstream_url,
|
build_grok_browser_headers, build_grok_upstream_url, build_kiro_cross_format_upstream_url,
|
||||||
build_kiro_cross_format_upstream_url, build_openai_image_headers,
|
build_openai_image_headers, build_openai_image_upstream_url,
|
||||||
build_openai_image_upstream_url, build_standard_provider_request_headers,
|
build_standard_provider_request_headers, build_windsurf_cascade_headers,
|
||||||
build_windsurf_cascade_headers, build_windsurf_cascade_request_body,
|
build_windsurf_cascade_request_body, build_windsurf_cascade_upstream_url,
|
||||||
build_windsurf_cascade_upstream_url, is_gemini_cli_provider_transport,
|
is_gemini_cli_provider_transport, is_windsurf_provider_transport,
|
||||||
is_windsurf_provider_transport,
|
|
||||||
local_windsurf_request_transport_unsupported_reason_with_network,
|
local_windsurf_request_transport_unsupported_reason_with_network,
|
||||||
openai_image_transport_unsupported_reason, resolve_grok_session_auth,
|
openai_image_transport_unsupported_reason, resolve_grok_session_auth,
|
||||||
resolve_openai_image_auth, GeminiCliRequestEnvelopeSupport, GrokHeaderInput,
|
resolve_openai_image_auth, GrokHeaderInput, ProviderOpenAiImageHeadersInput,
|
||||||
ProviderOpenAiImageHeadersInput, StandardProviderRequestHeadersInput, GEMINI_CLI_USER_AGENT,
|
StandardProviderRequestHeadersInput, GEMINI_CLI_USER_AGENT,
|
||||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, GROK_CHAT_PATH, WINDSURF_ENVELOPE_NAME,
|
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, GROK_CHAT_PATH, WINDSURF_ENVELOPE_NAME,
|
||||||
};
|
};
|
||||||
use crate::ai_serving::{
|
use crate::ai_serving::{
|
||||||
@@ -755,43 +756,30 @@ async fn build_gemini_cli_cross_format_payload_parts(
|
|||||||
) -> Option<LocalStandardCandidatePayloadParts> {
|
) -> Option<LocalStandardCandidatePayloadParts> {
|
||||||
let candidate = &attempt.eligible.candidate;
|
let candidate = &attempt.eligible.candidate;
|
||||||
let effective_headers = input.effective_headers(&parts.headers);
|
let effective_headers = input.effective_headers(&parts.headers);
|
||||||
let mut resolved_transport = Arc::clone(transport);
|
let resolved = match build_gemini_cli_v1internal_payload(
|
||||||
let project_id = match resolve_gemini_cli_project_id(&resolved_transport) {
|
state,
|
||||||
Some(project_id) => Some(project_id),
|
transport,
|
||||||
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,
|
trace_id,
|
||||||
&mapped_model,
|
&mapped_model,
|
||||||
&gemini_request_body,
|
&gemini_request_body,
|
||||||
) {
|
)
|
||||||
GeminiCliRequestEnvelopeSupport::Supported(envelope) => envelope,
|
.await
|
||||||
GeminiCliRequestEnvelopeSupport::Unsupported(_) => {
|
{
|
||||||
|
Ok(resolved) => resolved,
|
||||||
|
Err(GeminiCliV1InternalPayloadError::ProjectUnavailable) => {
|
||||||
|
mark_skipped_local_standard_candidate(
|
||||||
|
state,
|
||||||
|
input,
|
||||||
|
trace_id,
|
||||||
|
candidate,
|
||||||
|
attempt.candidate_index,
|
||||||
|
&attempt.candidate_id,
|
||||||
|
"transport_auth_unavailable",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Err(GeminiCliV1InternalPayloadError::EnvelopeUnsupported) => {
|
||||||
mark_skipped_local_standard_candidate_with_extra_data(
|
mark_skipped_local_standard_candidate_with_extra_data(
|
||||||
state,
|
state,
|
||||||
input,
|
input,
|
||||||
@@ -810,6 +798,8 @@ async fn build_gemini_cli_cross_format_payload_parts(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let provider_request_body = resolved.body;
|
||||||
|
let resolved_transport = resolved.transport;
|
||||||
|
|
||||||
let upstream_url = match crate::ai_serving::planner::standard::build_standard_upstream_url(
|
let upstream_url = match crate::ai_serving::planner::standard::build_standard_upstream_url(
|
||||||
parts,
|
parts,
|
||||||
|
|||||||
+412
-3
@@ -15,6 +15,9 @@ use crate::ai_serving::planner::common::{
|
|||||||
endpoint_config_forces_body_stream_field, enforce_provider_body_stream_policy,
|
endpoint_config_forces_body_stream_field, enforce_provider_body_stream_policy,
|
||||||
request_requires_body_stream_field, OPENAI_CHAT_STREAM_PLAN_KIND,
|
request_requires_body_stream_field, OPENAI_CHAT_STREAM_PLAN_KIND,
|
||||||
};
|
};
|
||||||
|
use crate::ai_serving::planner::gemini_cli::{
|
||||||
|
build_gemini_cli_v1internal_payload, GeminiCliV1InternalPayloadError,
|
||||||
|
};
|
||||||
use crate::ai_serving::planner::standard::{
|
use crate::ai_serving::planner::standard::{
|
||||||
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
|
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
|
||||||
build_cross_format_openai_chat_request_body, build_cross_format_openai_chat_upstream_url,
|
build_cross_format_openai_chat_request_body, build_cross_format_openai_chat_upstream_url,
|
||||||
@@ -37,9 +40,10 @@ use crate::ai_serving::transport::windsurf::{
|
|||||||
use crate::ai_serving::transport::{
|
use crate::ai_serving::transport::{
|
||||||
build_grok_browser_headers, build_grok_upstream_url, build_kiro_cross_format_upstream_url,
|
build_grok_browser_headers, build_grok_upstream_url, build_kiro_cross_format_upstream_url,
|
||||||
build_openai_image_headers, build_openai_image_upstream_url,
|
build_openai_image_headers, build_openai_image_upstream_url,
|
||||||
build_standard_provider_request_headers, openai_image_transport_unsupported_reason,
|
build_standard_provider_request_headers, is_gemini_cli_provider_transport,
|
||||||
resolve_openai_image_auth, GrokHeaderInput, ProviderOpenAiImageHeadersInput,
|
openai_image_transport_unsupported_reason, resolve_openai_image_auth, GrokHeaderInput,
|
||||||
StandardProviderRequestHeadersInput, GROK_CHAT_PATH,
|
ProviderOpenAiImageHeadersInput, StandardProviderRequestHeadersInput, GEMINI_CLI_USER_AGENT,
|
||||||
|
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, GROK_CHAT_PATH,
|
||||||
};
|
};
|
||||||
use crate::ai_serving::{
|
use crate::ai_serving::{
|
||||||
ai_local_execution_contract_for_formats, request_conversion_direct_auth,
|
ai_local_execution_contract_for_formats, request_conversion_direct_auth,
|
||||||
@@ -731,6 +735,30 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
|||||||
)
|
)
|
||||||
.await);
|
.await);
|
||||||
}
|
}
|
||||||
|
if provider_api_format == "gemini:generate_content"
|
||||||
|
&& is_gemini_cli_provider_transport(transport)
|
||||||
|
{
|
||||||
|
return Ok(build_gemini_cli_openai_chat_cross_format_payload_parts(
|
||||||
|
state,
|
||||||
|
parts,
|
||||||
|
trace_id,
|
||||||
|
body_json,
|
||||||
|
input,
|
||||||
|
eligible,
|
||||||
|
candidate_index,
|
||||||
|
candidate_id,
|
||||||
|
decision_kind,
|
||||||
|
transport,
|
||||||
|
provider_api_format.as_str(),
|
||||||
|
prepared_candidate.mapped_model,
|
||||||
|
prepared_candidate.auth_header,
|
||||||
|
prepared_candidate.auth_value,
|
||||||
|
provider_request_body,
|
||||||
|
upstream_is_stream,
|
||||||
|
redaction.redacted,
|
||||||
|
)
|
||||||
|
.await);
|
||||||
|
}
|
||||||
|
|
||||||
let Some(upstream_url) = build_cross_format_openai_chat_upstream_url(
|
let Some(upstream_url) = build_cross_format_openai_chat_upstream_url(
|
||||||
parts,
|
parts,
|
||||||
@@ -831,6 +859,175 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn build_gemini_cli_openai_chat_cross_format_payload_parts(
|
||||||
|
state: &AppState,
|
||||||
|
parts: &http::request::Parts,
|
||||||
|
trace_id: &str,
|
||||||
|
original_body_json: &serde_json::Value,
|
||||||
|
input: &LocalOpenAiChatDecisionInput,
|
||||||
|
eligible: &EligibleLocalExecutionCandidate,
|
||||||
|
candidate_index: u32,
|
||||||
|
candidate_id: &str,
|
||||||
|
decision_kind: &str,
|
||||||
|
transport: &Arc<GatewayProviderTransportSnapshot>,
|
||||||
|
provider_api_format: &str,
|
||||||
|
mapped_model: String,
|
||||||
|
auth_header: String,
|
||||||
|
auth_value: String,
|
||||||
|
gemini_request_body: Value,
|
||||||
|
upstream_is_stream: bool,
|
||||||
|
request_redacted: bool,
|
||||||
|
) -> Option<LocalOpenAiChatCandidatePayloadParts> {
|
||||||
|
let candidate = &eligible.candidate;
|
||||||
|
let effective_headers = input.effective_headers(&parts.headers);
|
||||||
|
let resolved = match build_gemini_cli_v1internal_payload(
|
||||||
|
state,
|
||||||
|
transport,
|
||||||
|
trace_id,
|
||||||
|
&mapped_model,
|
||||||
|
&gemini_request_body,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resolved) => resolved,
|
||||||
|
Err(GeminiCliV1InternalPayloadError::ProjectUnavailable) => {
|
||||||
|
mark_skipped_local_openai_chat_candidate(
|
||||||
|
state,
|
||||||
|
input,
|
||||||
|
trace_id,
|
||||||
|
candidate,
|
||||||
|
candidate_index,
|
||||||
|
candidate_id,
|
||||||
|
"transport_auth_unavailable",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Err(GeminiCliV1InternalPayloadError::EnvelopeUnsupported) => {
|
||||||
|
mark_skipped_local_openai_chat_candidate_with_extra_data(
|
||||||
|
state,
|
||||||
|
input,
|
||||||
|
trace_id,
|
||||||
|
candidate,
|
||||||
|
candidate_index,
|
||||||
|
candidate_id,
|
||||||
|
"provider_request_body_build_failed",
|
||||||
|
request_body_build_failure_extra_data(
|
||||||
|
original_body_json,
|
||||||
|
"openai:chat",
|
||||||
|
provider_api_format,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let provider_request_body = resolved.body;
|
||||||
|
let resolved_transport = resolved.transport;
|
||||||
|
|
||||||
|
let Some(upstream_url) = build_cross_format_openai_chat_upstream_url(
|
||||||
|
parts,
|
||||||
|
&resolved_transport,
|
||||||
|
&mapped_model,
|
||||||
|
provider_api_format,
|
||||||
|
upstream_is_stream,
|
||||||
|
) else {
|
||||||
|
mark_skipped_local_openai_chat_candidate_with_failure_diagnostic(
|
||||||
|
state,
|
||||||
|
input,
|
||||||
|
trace_id,
|
||||||
|
candidate,
|
||||||
|
candidate_index,
|
||||||
|
candidate_id,
|
||||||
|
"upstream_url_missing",
|
||||||
|
CandidateFailureDiagnostic::upstream_url_missing(
|
||||||
|
"openai:chat",
|
||||||
|
provider_api_format,
|
||||||
|
"openai_chat_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_openai_chat_candidate_with_failure_diagnostic(
|
||||||
|
state,
|
||||||
|
input,
|
||||||
|
trace_id,
|
||||||
|
candidate,
|
||||||
|
candidate_index,
|
||||||
|
candidate_id,
|
||||||
|
"transport_header_rules_apply_failed",
|
||||||
|
CandidateFailureDiagnostic::header_rules_apply_failed(
|
||||||
|
"openai:chat",
|
||||||
|
provider_api_format,
|
||||||
|
"openai_chat_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(),
|
||||||
|
);
|
||||||
|
request_identity_response_encoding_when_redacted(
|
||||||
|
&mut provider_request_headers,
|
||||||
|
request_redacted,
|
||||||
|
);
|
||||||
|
|
||||||
|
let resolved_report_kind = if decision_kind == OPENAI_CHAT_STREAM_PLAN_KIND {
|
||||||
|
"openai_chat_stream_success".to_string()
|
||||||
|
} else {
|
||||||
|
"openai_chat_sync_finalize".to_string()
|
||||||
|
};
|
||||||
|
let (execution_strategy, conversion_mode) =
|
||||||
|
ai_local_execution_contract_for_formats("openai:chat", provider_api_format);
|
||||||
|
|
||||||
|
Some(LocalOpenAiChatCandidatePayloadParts {
|
||||||
|
client_api_format: "openai:chat".to_string(),
|
||||||
|
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,
|
||||||
|
execution_strategy,
|
||||||
|
conversion_mode,
|
||||||
|
report_kind: resolved_report_kind,
|
||||||
|
envelope_name: Some(GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME),
|
||||||
|
transport: resolved_transport,
|
||||||
|
request_redacted,
|
||||||
|
transport_profile: None,
|
||||||
|
image_request_summary: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn resolve_openai_chat_to_openai_image_payload_parts(
|
async fn resolve_openai_chat_to_openai_image_payload_parts(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
@@ -1680,6 +1877,218 @@ fn redaction_mask_error_to_gateway_error(error: RedactionMaskError) -> GatewayEr
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use aether_provider_transport::snapshot::{
|
||||||
|
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||||
|
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||||
|
};
|
||||||
|
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||||
|
|
||||||
|
fn sample_auth_snapshot() -> crate::ai_serving::GatewayAuthApiKeySnapshot {
|
||||||
|
crate::ai_serving::GatewayAuthApiKeySnapshot {
|
||||||
|
user_id: "user-1".to_string(),
|
||||||
|
username: "alice".to_string(),
|
||||||
|
email: None,
|
||||||
|
user_role: "user".to_string(),
|
||||||
|
user_auth_source: "local".to_string(),
|
||||||
|
user_is_active: true,
|
||||||
|
user_is_deleted: false,
|
||||||
|
user_rate_limit: None,
|
||||||
|
user_allowed_providers: None,
|
||||||
|
user_allowed_api_formats: None,
|
||||||
|
user_allowed_models: None,
|
||||||
|
api_key_id: "api-key-1".to_string(),
|
||||||
|
api_key_name: Some("default".to_string()),
|
||||||
|
api_key_is_active: true,
|
||||||
|
api_key_is_locked: false,
|
||||||
|
api_key_is_standalone: false,
|
||||||
|
api_key_rate_limit: None,
|
||||||
|
api_key_concurrent_limit: None,
|
||||||
|
api_key_expires_at_unix_secs: None,
|
||||||
|
api_key_allowed_providers: None,
|
||||||
|
api_key_allowed_api_formats: None,
|
||||||
|
api_key_allowed_models: None,
|
||||||
|
api_key_ip_rules: None,
|
||||||
|
currently_usable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_input() -> LocalOpenAiChatDecisionInput {
|
||||||
|
LocalOpenAiChatDecisionInput {
|
||||||
|
auth_context: crate::ai_serving::ExecutionRuntimeAuthContext {
|
||||||
|
user_id: "user-1".to_string(),
|
||||||
|
api_key_id: "api-key-1".to_string(),
|
||||||
|
username: Some("alice".to_string()),
|
||||||
|
api_key_name: Some("default".to_string()),
|
||||||
|
balance_remaining: Some(10.0),
|
||||||
|
access_allowed: true,
|
||||||
|
api_key_is_standalone: false,
|
||||||
|
},
|
||||||
|
requested_model: "gemini-2.5-pro".to_string(),
|
||||||
|
auth_snapshot: sample_auth_snapshot(),
|
||||||
|
required_capabilities: None,
|
||||||
|
request_auth_channel: None,
|
||||||
|
client_session_affinity: None,
|
||||||
|
routing_policy: None,
|
||||||
|
routing_trace_seed: None,
|
||||||
|
routing_context: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_gemini_cli_transport() -> GatewayProviderTransportSnapshot {
|
||||||
|
GatewayProviderTransportSnapshot {
|
||||||
|
provider: GatewayProviderTransportProvider {
|
||||||
|
id: "provider-1".to_string(),
|
||||||
|
name: "gemini".to_string(),
|
||||||
|
provider_type: "gemini_cli".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:generate_content".to_string(),
|
||||||
|
api_family: Some("gemini".to_string()),
|
||||||
|
endpoint_kind: Some("generate_content".to_string()),
|
||||||
|
is_active: true,
|
||||||
|
base_url: "https://cloudcode-pa.googleapis.com".to_string(),
|
||||||
|
header_rules: None,
|
||||||
|
body_rules: None,
|
||||||
|
max_retries: None,
|
||||||
|
custom_path: Some("/v1internal:{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: "bearer".to_string(),
|
||||||
|
is_active: true,
|
||||||
|
api_formats: Some(vec!["gemini:generate_content".to_string()]),
|
||||||
|
auth_type_by_format: None,
|
||||||
|
allow_auth_channel_mismatch_formats: None,
|
||||||
|
allowed_models: None,
|
||||||
|
capabilities: None,
|
||||||
|
rate_multipliers: None,
|
||||||
|
global_priority_by_format: Some(json!({
|
||||||
|
"gemini:generate_content": 1,
|
||||||
|
})),
|
||||||
|
expires_at_unix_secs: None,
|
||||||
|
proxy: None,
|
||||||
|
fingerprint: None,
|
||||||
|
upstream_metadata: Some(json!({
|
||||||
|
"gemini_cli": {
|
||||||
|
"project_id": "test-project"
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
decrypted_api_key: "oauth-access-token".to_string(),
|
||||||
|
decrypted_auth_config: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_gemini_cli_eligible() -> EligibleLocalExecutionCandidate {
|
||||||
|
EligibleLocalExecutionCandidate {
|
||||||
|
kind: crate::ai_serving::planner::candidate_resolution::LocalExecutionCandidateKind::SingleKey,
|
||||||
|
candidate: SchedulerMinimalCandidateSelectionCandidate {
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
|
provider_name: "gemini".to_string(),
|
||||||
|
provider_type: "gemini_cli".to_string(),
|
||||||
|
provider_priority: 1,
|
||||||
|
endpoint_id: "endpoint-1".to_string(),
|
||||||
|
endpoint_api_format: "gemini:generate_content".to_string(),
|
||||||
|
key_id: "key-1".to_string(),
|
||||||
|
key_name: "key".to_string(),
|
||||||
|
key_auth_type: "bearer".to_string(),
|
||||||
|
key_internal_priority: 1,
|
||||||
|
key_global_priority_for_format: Some(1),
|
||||||
|
key_capabilities: None,
|
||||||
|
model_id: "model-1".to_string(),
|
||||||
|
global_model_id: "global-model-1".to_string(),
|
||||||
|
global_model_name: "gemini-2.5-pro".to_string(),
|
||||||
|
selected_provider_model_name: "gemini-2.5-pro".to_string(),
|
||||||
|
mapping_matched_model: None,
|
||||||
|
},
|
||||||
|
transport: Arc::new(sample_gemini_cli_transport()),
|
||||||
|
provider_api_format: "gemini:generate_content".to_string(),
|
||||||
|
orchestration: crate::orchestration::LocalExecutionCandidateMetadata::default(),
|
||||||
|
ranking: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn openai_chat_to_gemini_cli_wraps_cross_format_body_in_v1internal_envelope() {
|
||||||
|
let state = 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"}],
|
||||||
|
"generationConfig": {"temperature": 0.2},
|
||||||
|
"stream": true
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload = resolve_local_openai_chat_candidate_payload_parts(
|
||||||
|
&state,
|
||||||
|
&parts,
|
||||||
|
"trace-openai-chat-gemini-cli",
|
||||||
|
&body_json,
|
||||||
|
&sample_input(),
|
||||||
|
&sample_gemini_cli_eligible(),
|
||||||
|
0,
|
||||||
|
"candidate-0",
|
||||||
|
OPENAI_CHAT_STREAM_PLAN_KIND,
|
||||||
|
"openai_chat_stream_success",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("candidate resolution should not fail")
|
||||||
|
.expect("gemini_cli candidate should build a payload");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
payload.upstream_url,
|
||||||
|
"https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.envelope_name,
|
||||||
|
Some(GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload
|
||||||
|
.provider_request_headers
|
||||||
|
.get("user-agent")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some(GEMINI_CLI_USER_AGENT)
|
||||||
|
);
|
||||||
|
assert_eq!(payload.provider_request_body["model"], "gemini-2.5-pro");
|
||||||
|
assert_eq!(payload.provider_request_body["project"], "test-project");
|
||||||
|
assert_eq!(
|
||||||
|
payload.provider_request_body["user_prompt_id"],
|
||||||
|
"trace-openai-chat-gemini-cli"
|
||||||
|
);
|
||||||
|
assert!(payload.provider_request_body.get("contents").is_none());
|
||||||
|
assert!(payload
|
||||||
|
.provider_request_body
|
||||||
|
.get("generationConfig")
|
||||||
|
.is_none());
|
||||||
|
assert!(payload.provider_request_body["request"]
|
||||||
|
.get("contents")
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chatgpt_web_chat_image_bridge_body_uses_internal_web_shape() {
|
fn chatgpt_web_chat_image_bridge_body_uses_internal_web_shape() {
|
||||||
|
|||||||
+189
-3
@@ -14,6 +14,9 @@ use crate::ai_serving::planner::common::{
|
|||||||
endpoint_config_forces_body_stream_field, enforce_provider_body_stream_policy,
|
endpoint_config_forces_body_stream_field, enforce_provider_body_stream_policy,
|
||||||
request_requires_body_stream_field, resolve_upstream_is_stream_for_provider,
|
request_requires_body_stream_field, resolve_upstream_is_stream_for_provider,
|
||||||
};
|
};
|
||||||
|
use crate::ai_serving::planner::gemini_cli::{
|
||||||
|
build_gemini_cli_v1internal_payload, GeminiCliV1InternalPayloadError,
|
||||||
|
};
|
||||||
use crate::ai_serving::planner::spec_metadata::local_openai_responses_spec_metadata;
|
use crate::ai_serving::planner::spec_metadata::local_openai_responses_spec_metadata;
|
||||||
use crate::ai_serving::planner::standard::{
|
use crate::ai_serving::planner::standard::{
|
||||||
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
|
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
|
||||||
@@ -41,11 +44,12 @@ use crate::ai_serving::transport::{
|
|||||||
build_openai_image_headers, build_openai_image_upstream_url,
|
build_openai_image_headers, build_openai_image_upstream_url,
|
||||||
build_standard_provider_request_headers, build_windsurf_cascade_headers,
|
build_standard_provider_request_headers, build_windsurf_cascade_headers,
|
||||||
build_windsurf_cascade_request_body, build_windsurf_cascade_upstream_url,
|
build_windsurf_cascade_request_body, build_windsurf_cascade_upstream_url,
|
||||||
is_windsurf_provider_transport, local_standard_transport_unsupported_reason_with_network,
|
is_gemini_cli_provider_transport, is_windsurf_provider_transport,
|
||||||
|
local_standard_transport_unsupported_reason_with_network,
|
||||||
local_windsurf_request_transport_unsupported_reason_with_network,
|
local_windsurf_request_transport_unsupported_reason_with_network,
|
||||||
openai_image_transport_unsupported_reason, resolve_openai_image_auth, GrokHeaderInput,
|
openai_image_transport_unsupported_reason, resolve_openai_image_auth, GrokHeaderInput,
|
||||||
ProviderOpenAiImageHeadersInput, StandardProviderRequestHeadersInput, GROK_CHAT_PATH,
|
ProviderOpenAiImageHeadersInput, StandardProviderRequestHeadersInput, GEMINI_CLI_USER_AGENT,
|
||||||
WINDSURF_ENVELOPE_NAME,
|
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, GROK_CHAT_PATH, WINDSURF_ENVELOPE_NAME,
|
||||||
};
|
};
|
||||||
use crate::ai_serving::{
|
use crate::ai_serving::{
|
||||||
ai_local_execution_contract_for_formats, request_conversion_direct_auth,
|
ai_local_execution_contract_for_formats, request_conversion_direct_auth,
|
||||||
@@ -475,6 +479,29 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
if provider_api_format == "gemini:generate_content"
|
||||||
|
&& is_gemini_cli_provider_transport(transport)
|
||||||
|
{
|
||||||
|
return build_gemini_cli_openai_responses_payload_parts(
|
||||||
|
state,
|
||||||
|
parts,
|
||||||
|
trace_id,
|
||||||
|
body_json,
|
||||||
|
input,
|
||||||
|
eligible,
|
||||||
|
candidate_index,
|
||||||
|
candidate_id,
|
||||||
|
spec_metadata.api_format,
|
||||||
|
transport,
|
||||||
|
provider_api_format,
|
||||||
|
mapped_model,
|
||||||
|
auth_header,
|
||||||
|
auth_value,
|
||||||
|
provider_request_body,
|
||||||
|
upstream_is_stream,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
let Some(upstream_url) = (if is_grok && is_grok_text_provider_api_format(provider_api_format) {
|
let Some(upstream_url) = (if is_grok && is_grok_text_provider_api_format(provider_api_format) {
|
||||||
Some(build_grok_upstream_url(transport, GROK_CHAT_PATH))
|
Some(build_grok_upstream_url(transport, GROK_CHAT_PATH))
|
||||||
@@ -647,6 +674,165 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn build_gemini_cli_openai_responses_payload_parts(
|
||||||
|
state: &AppState,
|
||||||
|
parts: &http::request::Parts,
|
||||||
|
trace_id: &str,
|
||||||
|
original_body_json: &serde_json::Value,
|
||||||
|
input: &LocalOpenAiResponsesDecisionInput,
|
||||||
|
eligible: &EligibleLocalExecutionCandidate,
|
||||||
|
candidate_index: u32,
|
||||||
|
candidate_id: &str,
|
||||||
|
client_api_format: &str,
|
||||||
|
transport: &Arc<GatewayProviderTransportSnapshot>,
|
||||||
|
provider_api_format: &str,
|
||||||
|
mapped_model: String,
|
||||||
|
auth_header: String,
|
||||||
|
auth_value: String,
|
||||||
|
gemini_request_body: Value,
|
||||||
|
upstream_is_stream: bool,
|
||||||
|
) -> Option<LocalOpenAiResponsesCandidatePayloadParts> {
|
||||||
|
let candidate = &eligible.candidate;
|
||||||
|
let effective_headers = input.effective_headers(&parts.headers);
|
||||||
|
let resolved = match build_gemini_cli_v1internal_payload(
|
||||||
|
state,
|
||||||
|
transport,
|
||||||
|
trace_id,
|
||||||
|
&mapped_model,
|
||||||
|
&gemini_request_body,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resolved) => resolved,
|
||||||
|
Err(GeminiCliV1InternalPayloadError::ProjectUnavailable) => {
|
||||||
|
mark_skipped_local_openai_responses_candidate(
|
||||||
|
state,
|
||||||
|
input,
|
||||||
|
trace_id,
|
||||||
|
candidate,
|
||||||
|
candidate_index,
|
||||||
|
candidate_id,
|
||||||
|
"transport_auth_unavailable",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Err(GeminiCliV1InternalPayloadError::EnvelopeUnsupported) => {
|
||||||
|
mark_skipped_local_openai_responses_candidate_with_extra_data(
|
||||||
|
state,
|
||||||
|
input,
|
||||||
|
trace_id,
|
||||||
|
candidate,
|
||||||
|
candidate_index,
|
||||||
|
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 provider_request_body = resolved.body;
|
||||||
|
let resolved_transport = resolved.transport;
|
||||||
|
|
||||||
|
let Some(upstream_url) = build_cross_format_openai_responses_upstream_url(
|
||||||
|
parts,
|
||||||
|
&resolved_transport,
|
||||||
|
&mapped_model,
|
||||||
|
client_api_format,
|
||||||
|
provider_api_format,
|
||||||
|
upstream_is_stream,
|
||||||
|
) else {
|
||||||
|
mark_skipped_local_openai_responses_candidate_with_failure_diagnostic(
|
||||||
|
state,
|
||||||
|
input,
|
||||||
|
trace_id,
|
||||||
|
candidate,
|
||||||
|
candidate_index,
|
||||||
|
candidate_id,
|
||||||
|
"upstream_url_missing",
|
||||||
|
CandidateFailureDiagnostic::upstream_url_missing(
|
||||||
|
client_api_format,
|
||||||
|
provider_api_format,
|
||||||
|
"openai_responses_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_openai_responses_candidate_with_failure_diagnostic(
|
||||||
|
state,
|
||||||
|
input,
|
||||||
|
trace_id,
|
||||||
|
candidate,
|
||||||
|
candidate_index,
|
||||||
|
candidate_id,
|
||||||
|
"transport_header_rules_apply_failed",
|
||||||
|
CandidateFailureDiagnostic::header_rules_apply_failed(
|
||||||
|
client_api_format,
|
||||||
|
provider_api_format,
|
||||||
|
"openai_responses_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(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (execution_strategy, conversion_mode) =
|
||||||
|
ai_local_execution_contract_for_formats(client_api_format, provider_api_format);
|
||||||
|
|
||||||
|
Some(LocalOpenAiResponsesCandidatePayloadParts {
|
||||||
|
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,
|
||||||
|
execution_strategy,
|
||||||
|
conversion_mode,
|
||||||
|
is_antigravity: false,
|
||||||
|
envelope_name: Some(GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME),
|
||||||
|
upstream_is_stream,
|
||||||
|
transport: resolved_transport,
|
||||||
|
transport_profile: None,
|
||||||
|
image_request_summary: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn build_windsurf_openai_responses_payload_parts(
|
async fn build_windsurf_openai_responses_payload_parts(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
|||||||
Reference in New Issue
Block a user