mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
refactor: 大规模模块拆分与代码精简,新增 ai-pipeline/data-contracts 独立 crate
- 新增 aether-ai-pipeline 和 aether-data-contracts crate,将 pipeline 逻辑与数据契约从 gateway 中解耦 - 重构 admin handlers:拆分单体模块为 auth/billing/endpoint/features/model/observability/provider/system 等独立子模块 - 合并 chat/cli 重复代码路径:精简 conversion、finalize、planner 中的 sync/chat/cli 分支 - 重构 scheduler/executor/data 层,引入 facade 模式降低模块间耦合 - 移除冗余的 intent 模块,将 plan_fallback/policy/stream_path/sync_path 迁移至 executor - 前端适配:调整 admin API 调用和 provider 模型测试对话框
This commit is contained in:
35
Cargo.lock
generated
35
Cargo.lock
generated
@@ -19,11 +19,22 @@ dependencies = [
|
|||||||
"cpufeatures",
|
"cpufeatures",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aether-ai-pipeline"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aether-provider-transport",
|
||||||
|
"base64",
|
||||||
|
"http",
|
||||||
|
"serde_json",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aether-billing"
|
name = "aether-billing"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aether-data",
|
"aether-data-contracts",
|
||||||
"aether-usage-runtime",
|
"aether-usage-runtime",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -66,6 +77,7 @@ name = "aether-data"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aether-cache",
|
"aether-cache",
|
||||||
|
"aether-data-contracts",
|
||||||
"aether-wallet",
|
"aether-wallet",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -81,15 +93,27 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aether-data-contracts"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aether-gateway"
|
name = "aether-gateway"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aether-ai-pipeline",
|
||||||
"aether-billing",
|
"aether-billing",
|
||||||
"aether-cache",
|
"aether-cache",
|
||||||
"aether-contracts",
|
"aether-contracts",
|
||||||
"aether-crypto",
|
"aether-crypto",
|
||||||
"aether-data",
|
"aether-data",
|
||||||
|
"aether-data-contracts",
|
||||||
"aether-http",
|
"aether-http",
|
||||||
"aether-model-fetch",
|
"aether-model-fetch",
|
||||||
"aether-provider-transport",
|
"aether-provider-transport",
|
||||||
@@ -120,6 +144,7 @@ dependencies = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -147,7 +172,7 @@ name = "aether-model-fetch"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aether-contracts",
|
"aether-contracts",
|
||||||
"aether-data",
|
"aether-data-contracts",
|
||||||
"aether-provider-transport",
|
"aether-provider-transport",
|
||||||
"aether-scheduler-core",
|
"aether-scheduler-core",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -164,6 +189,7 @@ dependencies = [
|
|||||||
"aether-contracts",
|
"aether-contracts",
|
||||||
"aether-crypto",
|
"aether-crypto",
|
||||||
"aether-data",
|
"aether-data",
|
||||||
|
"aether-data-contracts",
|
||||||
"aether-video-tasks-core",
|
"aether-video-tasks-core",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -246,7 +272,7 @@ name = "aether-scheduler-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aether-contracts",
|
"aether-contracts",
|
||||||
"aether-data",
|
"aether-data-contracts",
|
||||||
"aether-wallet",
|
"aether-wallet",
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -282,6 +308,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aether-contracts",
|
"aether-contracts",
|
||||||
"aether-data",
|
"aether-data",
|
||||||
|
"aether-data-contracts",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -295,7 +322,7 @@ name = "aether-video-tasks-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aether-contracts",
|
"aether-contracts",
|
||||||
"aether-data",
|
"aether-data-contracts",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"apps/aether-proxy",
|
"apps/aether-proxy",
|
||||||
|
"crates/aether-ai-pipeline",
|
||||||
|
"crates/aether-data-contracts",
|
||||||
"crates/aether-cache",
|
"crates/aether-cache",
|
||||||
"crates/aether-billing",
|
"crates/aether-billing",
|
||||||
"crates/aether-wallet",
|
"crates/aether-wallet",
|
||||||
@@ -25,6 +27,8 @@ license = "LicenseRef-Aether-NonCommercial"
|
|||||||
repository = "https://github.com/fawney19/Aether.git"
|
repository = "https://github.com/fawney19/Aether.git"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
aether-ai-pipeline = { path = "crates/aether-ai-pipeline" }
|
||||||
|
aether-data-contracts = { path = "crates/aether-data-contracts" }
|
||||||
aether-cache = { path = "crates/aether-cache" }
|
aether-cache = { path = "crates/aether-cache" }
|
||||||
aether-billing = { path = "crates/aether-billing" }
|
aether-billing = { path = "crates/aether-billing" }
|
||||||
aether-wallet = { path = "crates/aether-wallet" }
|
aether-wallet = { path = "crates/aether-wallet" }
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ repository.workspace = true
|
|||||||
description = "Rust ingress gateway for Aether phase 3a transparent proxy"
|
description = "Rust ingress gateway for Aether phase 3a transparent proxy"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
aether-ai-pipeline.workspace = true
|
||||||
aether-billing.workspace = true
|
aether-billing.workspace = true
|
||||||
aether-cache.workspace = true
|
aether-cache.workspace = true
|
||||||
aether-contracts.workspace = true
|
aether-contracts.workspace = true
|
||||||
aether-crypto.workspace = true
|
aether-crypto.workspace = true
|
||||||
aether-data.workspace = true
|
aether-data.workspace = true
|
||||||
|
aether-data-contracts.workspace = true
|
||||||
aether-http.workspace = true
|
aether-http.workspace = true
|
||||||
aether-model-fetch.workspace = true
|
aether-model-fetch.workspace = true
|
||||||
aether-provider-transport.workspace = true
|
aether-provider-transport.workspace = true
|
||||||
@@ -42,6 +44,7 @@ reqwest.workspace = true
|
|||||||
rustls.workspace = true
|
rustls.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
sha1 = "0.10"
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ use crate::ai_pipeline::adaptation::surfaces::{
|
|||||||
provider_adaptation_should_unwrap_stream_envelope, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
provider_adaptation_should_unwrap_stream_envelope, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
||||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME,
|
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME,
|
||||||
};
|
};
|
||||||
use crate::ai_pipeline::runtime::adapters::kiro::{
|
use crate::ai_pipeline::runtime::adapters::kiro::{KiroToClaudeCliStreamState, KIRO_ENVELOPE_NAME};
|
||||||
KiroToClaudeCliStreamState, KIRO_ENVELOPE_NAME,
|
|
||||||
};
|
|
||||||
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
||||||
|
|
||||||
enum ProviderPrivateStreamNormalizeMode {
|
enum ProviderPrivateStreamNormalizeMode {
|
||||||
|
|||||||
@@ -1,190 +1,8 @@
|
|||||||
use crate::ai_pipeline::runtime::adapters::{
|
pub(crate) use aether_ai_pipeline::adaptation::surfaces::{
|
||||||
antigravity::ANTIGRAVITY_PROVIDER_TYPE,
|
|
||||||
kiro::{KIRO_ENVELOPE_NAME, PROVIDER_TYPE as KIRO_PROVIDER_TYPE},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub(crate) const ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME: &str = "antigravity:v1internal";
|
|
||||||
pub(crate) const GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME: &str = "gemini_cli:v1internal";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub(crate) enum ProviderAdaptationSurface {
|
|
||||||
AntigravityGeminiChat,
|
|
||||||
AntigravityGeminiCli,
|
|
||||||
GeminiCliV1Internal,
|
|
||||||
KiroClaudeCli,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub(crate) struct ProviderAdaptationDescriptor {
|
|
||||||
pub(crate) surface: ProviderAdaptationSurface,
|
|
||||||
pub(crate) provider_type: Option<&'static str>,
|
|
||||||
pub(crate) envelope_name: &'static str,
|
|
||||||
pub(crate) anchor_api_format: &'static str,
|
|
||||||
pub(crate) supports_request_bridge: bool,
|
|
||||||
pub(crate) supports_sync_finalize_bridge: bool,
|
|
||||||
pub(crate) supports_stream_bridge: bool,
|
|
||||||
pub(crate) requires_eventstream_accept: bool,
|
|
||||||
pub(crate) unwraps_response_envelope: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROVIDER_ADAPTATION_SURFACES: &[ProviderAdaptationDescriptor] = &[
|
|
||||||
ProviderAdaptationDescriptor {
|
|
||||||
surface: ProviderAdaptationSurface::AntigravityGeminiChat,
|
|
||||||
provider_type: Some(ANTIGRAVITY_PROVIDER_TYPE),
|
|
||||||
envelope_name: ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
|
||||||
anchor_api_format: "gemini:chat",
|
|
||||||
supports_request_bridge: true,
|
|
||||||
supports_sync_finalize_bridge: true,
|
|
||||||
supports_stream_bridge: true,
|
|
||||||
requires_eventstream_accept: false,
|
|
||||||
unwraps_response_envelope: true,
|
|
||||||
},
|
|
||||||
ProviderAdaptationDescriptor {
|
|
||||||
surface: ProviderAdaptationSurface::AntigravityGeminiCli,
|
|
||||||
provider_type: Some(ANTIGRAVITY_PROVIDER_TYPE),
|
|
||||||
envelope_name: ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
|
||||||
anchor_api_format: "gemini:cli",
|
|
||||||
supports_request_bridge: true,
|
|
||||||
supports_sync_finalize_bridge: true,
|
|
||||||
supports_stream_bridge: true,
|
|
||||||
requires_eventstream_accept: false,
|
|
||||||
unwraps_response_envelope: true,
|
|
||||||
},
|
|
||||||
ProviderAdaptationDescriptor {
|
|
||||||
surface: ProviderAdaptationSurface::GeminiCliV1Internal,
|
|
||||||
provider_type: None,
|
|
||||||
envelope_name: GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME,
|
|
||||||
anchor_api_format: "gemini:cli",
|
|
||||||
supports_request_bridge: false,
|
|
||||||
supports_sync_finalize_bridge: true,
|
|
||||||
supports_stream_bridge: true,
|
|
||||||
requires_eventstream_accept: false,
|
|
||||||
unwraps_response_envelope: true,
|
|
||||||
},
|
|
||||||
ProviderAdaptationDescriptor {
|
|
||||||
surface: ProviderAdaptationSurface::KiroClaudeCli,
|
|
||||||
provider_type: Some(KIRO_PROVIDER_TYPE),
|
|
||||||
envelope_name: KIRO_ENVELOPE_NAME,
|
|
||||||
anchor_api_format: "claude:cli",
|
|
||||||
supports_request_bridge: true,
|
|
||||||
supports_sync_finalize_bridge: true,
|
|
||||||
supports_stream_bridge: true,
|
|
||||||
requires_eventstream_accept: true,
|
|
||||||
unwraps_response_envelope: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
pub(crate) fn provider_adaptation_descriptor_for_envelope(
|
|
||||||
envelope_name: &str,
|
|
||||||
provider_api_format: &str,
|
|
||||||
) -> Option<&'static ProviderAdaptationDescriptor> {
|
|
||||||
let envelope_name = envelope_name.trim();
|
|
||||||
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
|
|
||||||
PROVIDER_ADAPTATION_SURFACES.iter().find(|descriptor| {
|
|
||||||
descriptor.envelope_name.eq_ignore_ascii_case(envelope_name)
|
|
||||||
&& descriptor
|
|
||||||
.anchor_api_format
|
|
||||||
.eq_ignore_ascii_case(provider_api_format.as_str())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn provider_adaptation_descriptor_for_provider_type(
|
|
||||||
provider_type: &str,
|
|
||||||
provider_api_format: &str,
|
|
||||||
) -> Option<&'static ProviderAdaptationDescriptor> {
|
|
||||||
let provider_type = provider_type.trim();
|
|
||||||
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
|
|
||||||
PROVIDER_ADAPTATION_SURFACES.iter().find(|descriptor| {
|
|
||||||
descriptor
|
|
||||||
.provider_type
|
|
||||||
.is_some_and(|value| value.eq_ignore_ascii_case(provider_type))
|
|
||||||
&& descriptor
|
|
||||||
.anchor_api_format
|
|
||||||
.eq_ignore_ascii_case(provider_api_format.as_str())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn provider_adaptation_anchor_api_format(
|
|
||||||
envelope_name: &str,
|
|
||||||
provider_api_format: &str,
|
|
||||||
) -> Option<&'static str> {
|
|
||||||
provider_adaptation_descriptor_for_envelope(envelope_name, provider_api_format)
|
|
||||||
.map(|descriptor| descriptor.anchor_api_format)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn provider_adaptation_allows_sync_finalize_envelope(
|
|
||||||
envelope_name: &str,
|
|
||||||
provider_api_format: &str,
|
|
||||||
) -> bool {
|
|
||||||
provider_adaptation_descriptor_for_envelope(envelope_name, provider_api_format)
|
|
||||||
.is_some_and(|descriptor| descriptor.supports_sync_finalize_bridge)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn provider_adaptation_requires_eventstream_accept(
|
|
||||||
envelope_name: Option<&str>,
|
|
||||||
provider_api_format: &str,
|
|
||||||
) -> bool {
|
|
||||||
envelope_name
|
|
||||||
.and_then(|value| provider_adaptation_descriptor_for_envelope(value, provider_api_format))
|
|
||||||
.is_some_and(|descriptor| descriptor.requires_eventstream_accept)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn provider_adaptation_should_unwrap_stream_envelope(
|
|
||||||
envelope_name: &str,
|
|
||||||
provider_api_format: &str,
|
|
||||||
) -> bool {
|
|
||||||
provider_adaptation_descriptor_for_envelope(envelope_name, provider_api_format)
|
|
||||||
.is_some_and(|descriptor| descriptor.unwraps_response_envelope)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{
|
|
||||||
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,
|
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,
|
||||||
|
provider_adaptation_descriptor_for_envelope, provider_adaptation_descriptor_for_provider_type,
|
||||||
provider_adaptation_requires_eventstream_accept,
|
provider_adaptation_requires_eventstream_accept,
|
||||||
provider_adaptation_should_unwrap_stream_envelope, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
provider_adaptation_should_unwrap_stream_envelope, ProviderAdaptationDescriptor,
|
||||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME,
|
ProviderAdaptationSurface, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
||||||
|
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolves_private_surface_anchor_contracts() {
|
|
||||||
assert_eq!(
|
|
||||||
provider_adaptation_anchor_api_format(
|
|
||||||
ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
|
||||||
"gemini:cli"
|
|
||||||
),
|
|
||||||
Some("gemini:cli")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
provider_adaptation_anchor_api_format(
|
|
||||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME,
|
|
||||||
"gemini:cli"
|
|
||||||
),
|
|
||||||
Some("gemini:cli")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
provider_adaptation_anchor_api_format(KIRO_ENVELOPE_NAME, "claude:cli"),
|
|
||||||
Some("claude:cli")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn exposes_private_surface_capabilities() {
|
|
||||||
assert!(provider_adaptation_allows_sync_finalize_envelope(
|
|
||||||
ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
|
||||||
"gemini:chat"
|
|
||||||
));
|
|
||||||
assert!(provider_adaptation_should_unwrap_stream_envelope(
|
|
||||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME,
|
|
||||||
"gemini:cli"
|
|
||||||
));
|
|
||||||
assert!(provider_adaptation_requires_eventstream_accept(
|
|
||||||
Some(KIRO_ENVELOPE_NAME),
|
|
||||||
"claude:cli"
|
|
||||||
));
|
|
||||||
assert!(!provider_adaptation_requires_eventstream_accept(
|
|
||||||
Some(ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME),
|
|
||||||
"gemini:cli"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
pub(crate) const EXECUTION_RUNTIME_SYNC_ACTION: &str = "execution_runtime_sync";
|
|
||||||
pub(crate) const EXECUTION_RUNTIME_SYNC_DECISION_ACTION: &str = "execution_runtime_sync_decision";
|
|
||||||
pub(crate) const EXECUTION_RUNTIME_STREAM_ACTION: &str = "execution_runtime_stream";
|
|
||||||
pub(crate) const EXECUTION_RUNTIME_STREAM_DECISION_ACTION: &str =
|
|
||||||
"execution_runtime_stream_decision";
|
|
||||||
@@ -4,9 +4,10 @@ use aether_contracts::{ExecutionPlan, ExecutionTimeouts, ProxySnapshot};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::control::GatewayControlAuthContext;
|
use crate::ai_pipeline::control_facade::{
|
||||||
use crate::control::GatewayControlDecision;
|
collect_control_headers, resolve_execution_runtime_auth_context, GatewayControlAuthContext,
|
||||||
use crate::headers::collect_control_headers;
|
GatewayControlDecision,
|
||||||
|
};
|
||||||
use crate::{AppState, GatewayError};
|
use crate::{AppState, GatewayError};
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -142,7 +143,7 @@ pub(crate) async fn build_gateway_plan_request(
|
|||||||
body_json: serde_json::Value,
|
body_json: serde_json::Value,
|
||||||
body_base64: Option<String>,
|
body_base64: Option<String>,
|
||||||
) -> Result<GatewayControlPlanRequest, GatewayError> {
|
) -> Result<GatewayControlPlanRequest, GatewayError> {
|
||||||
let auth_context = crate::control::resolve_execution_runtime_auth_context(
|
let auth_context = resolve_execution_runtime_auth_context(
|
||||||
state,
|
state,
|
||||||
decision,
|
decision,
|
||||||
&parts.headers,
|
&parts.headers,
|
||||||
|
|||||||
@@ -1,43 +1,38 @@
|
|||||||
pub(crate) mod actions;
|
|
||||||
pub(crate) mod control_payloads;
|
pub(crate) mod control_payloads;
|
||||||
pub(crate) mod plan_kinds;
|
|
||||||
pub(crate) mod report_kinds;
|
|
||||||
|
|
||||||
pub(crate) use actions::{
|
pub(crate) use aether_ai_pipeline::contracts::{
|
||||||
EXECUTION_RUNTIME_STREAM_ACTION, EXECUTION_RUNTIME_STREAM_DECISION_ACTION,
|
core_error_background_report_kind, core_error_default_client_api_format,
|
||||||
EXECUTION_RUNTIME_SYNC_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
|
core_success_background_report_kind, implicit_sync_finalize_report_kind,
|
||||||
|
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_STREAM_SUCCESS_REPORT_KIND,
|
||||||
|
CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND, CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND,
|
||||||
|
CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||||
|
CLAUDE_CLI_STREAM_SUCCESS_REPORT_KIND, CLAUDE_CLI_SYNC_ERROR_REPORT_KIND,
|
||||||
|
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND, CLAUDE_CLI_SYNC_PLAN_KIND,
|
||||||
|
CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND, EXECUTION_RUNTIME_STREAM_ACTION,
|
||||||
|
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_ACTION,
|
||||||
|
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_CHAT_STREAM_PLAN_KIND,
|
||||||
|
GEMINI_CHAT_STREAM_SUCCESS_REPORT_KIND, GEMINI_CHAT_SYNC_ERROR_REPORT_KIND,
|
||||||
|
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
|
||||||
|
GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND, GEMINI_CLI_STREAM_PLAN_KIND,
|
||||||
|
GEMINI_CLI_STREAM_SUCCESS_REPORT_KIND, GEMINI_CLI_SYNC_ERROR_REPORT_KIND,
|
||||||
|
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
|
||||||
|
GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND, GEMINI_FILES_DELETE_PLAN_KIND,
|
||||||
|
GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND,
|
||||||
|
GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
||||||
|
GEMINI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND, GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
||||||
|
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_STREAM_SUCCESS_REPORT_KIND,
|
||||||
|
OPENAI_CHAT_SYNC_ERROR_REPORT_KIND, OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND,
|
||||||
|
OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND, OPENAI_CLI_STREAM_PLAN_KIND,
|
||||||
|
OPENAI_CLI_STREAM_SUCCESS_REPORT_KIND, OPENAI_CLI_SYNC_ERROR_REPORT_KIND,
|
||||||
|
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND, OPENAI_CLI_SYNC_PLAN_KIND,
|
||||||
|
OPENAI_CLI_SYNC_SUCCESS_REPORT_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
|
||||||
|
OPENAI_COMPACT_SYNC_ERROR_REPORT_KIND, OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND,
|
||||||
|
OPENAI_COMPACT_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
||||||
|
OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND,
|
||||||
|
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND,
|
||||||
|
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
||||||
};
|
};
|
||||||
pub(crate) use control_payloads::{
|
pub(crate) use control_payloads::{
|
||||||
build_gateway_plan_request, generic_decision_missing_exact_provider_request,
|
build_gateway_plan_request, generic_decision_missing_exact_provider_request,
|
||||||
GatewayControlPlanRequest, GatewayControlPlanResponse, GatewayControlSyncDecisionResponse,
|
GatewayControlPlanRequest, GatewayControlPlanResponse, GatewayControlSyncDecisionResponse,
|
||||||
};
|
};
|
||||||
pub(crate) use plan_kinds::{
|
|
||||||
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
|
||||||
CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
|
|
||||||
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND, GEMINI_FILES_DELETE_PLAN_KIND,
|
|
||||||
GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND,
|
|
||||||
GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
|
||||||
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
|
|
||||||
OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
|
|
||||||
OPENAI_COMPACT_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
|
||||||
OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
|
||||||
OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
|
||||||
};
|
|
||||||
pub(crate) use report_kinds::{
|
|
||||||
core_error_background_report_kind, core_error_default_client_api_format,
|
|
||||||
core_success_background_report_kind, implicit_sync_finalize_report_kind,
|
|
||||||
CLAUDE_CHAT_STREAM_SUCCESS_REPORT_KIND, CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND,
|
|
||||||
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND, CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND,
|
|
||||||
CLAUDE_CLI_STREAM_SUCCESS_REPORT_KIND, CLAUDE_CLI_SYNC_ERROR_REPORT_KIND,
|
|
||||||
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND, CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND,
|
|
||||||
GEMINI_CHAT_STREAM_SUCCESS_REPORT_KIND, GEMINI_CHAT_SYNC_ERROR_REPORT_KIND,
|
|
||||||
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND, GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND,
|
|
||||||
GEMINI_CLI_STREAM_SUCCESS_REPORT_KIND, GEMINI_CLI_SYNC_ERROR_REPORT_KIND,
|
|
||||||
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND, GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND,
|
|
||||||
GEMINI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND, OPENAI_CHAT_STREAM_SUCCESS_REPORT_KIND,
|
|
||||||
OPENAI_CHAT_SYNC_ERROR_REPORT_KIND, OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND,
|
|
||||||
OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND, OPENAI_CLI_STREAM_SUCCESS_REPORT_KIND,
|
|
||||||
OPENAI_CLI_SYNC_ERROR_REPORT_KIND, OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND,
|
|
||||||
OPENAI_CLI_SYNC_SUCCESS_REPORT_KIND, OPENAI_COMPACT_SYNC_ERROR_REPORT_KIND,
|
|
||||||
OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND, OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
pub(crate) const GEMINI_FILES_GET_PLAN_KIND: &str = "gemini_files_get";
|
|
||||||
pub(crate) const GEMINI_FILES_UPLOAD_PLAN_KIND: &str = "gemini_files_upload";
|
|
||||||
pub(crate) const GEMINI_FILES_LIST_PLAN_KIND: &str = "gemini_files_list";
|
|
||||||
pub(crate) const GEMINI_FILES_DELETE_PLAN_KIND: &str = "gemini_files_delete";
|
|
||||||
pub(crate) const GEMINI_FILES_DOWNLOAD_PLAN_KIND: &str = "gemini_files_download";
|
|
||||||
pub(crate) const OPENAI_VIDEO_CONTENT_PLAN_KIND: &str = "openai_video_content";
|
|
||||||
pub(crate) const OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND: &str = "openai_video_cancel_sync";
|
|
||||||
pub(crate) const OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND: &str = "openai_video_remix_sync";
|
|
||||||
pub(crate) const OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND: &str = "openai_video_delete_sync";
|
|
||||||
pub(crate) const GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND: &str = "gemini_video_create_sync";
|
|
||||||
pub(crate) const GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND: &str = "gemini_video_cancel_sync";
|
|
||||||
pub(crate) const OPENAI_CHAT_STREAM_PLAN_KIND: &str = "openai_chat_stream";
|
|
||||||
pub(crate) const CLAUDE_CHAT_STREAM_PLAN_KIND: &str = "claude_chat_stream";
|
|
||||||
pub(crate) const GEMINI_CHAT_STREAM_PLAN_KIND: &str = "gemini_chat_stream";
|
|
||||||
pub(crate) const OPENAI_CLI_STREAM_PLAN_KIND: &str = "openai_cli_stream";
|
|
||||||
pub(crate) const OPENAI_COMPACT_STREAM_PLAN_KIND: &str = "openai_compact_stream";
|
|
||||||
pub(crate) const CLAUDE_CLI_STREAM_PLAN_KIND: &str = "claude_cli_stream";
|
|
||||||
pub(crate) const GEMINI_CLI_STREAM_PLAN_KIND: &str = "gemini_cli_stream";
|
|
||||||
pub(crate) const OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND: &str = "openai_video_create_sync";
|
|
||||||
pub(crate) const OPENAI_CHAT_SYNC_PLAN_KIND: &str = "openai_chat_sync";
|
|
||||||
pub(crate) const OPENAI_CLI_SYNC_PLAN_KIND: &str = "openai_cli_sync";
|
|
||||||
pub(crate) const OPENAI_COMPACT_SYNC_PLAN_KIND: &str = "openai_compact_sync";
|
|
||||||
pub(crate) const CLAUDE_CHAT_SYNC_PLAN_KIND: &str = "claude_chat_sync";
|
|
||||||
pub(crate) const GEMINI_CHAT_SYNC_PLAN_KIND: &str = "gemini_chat_sync";
|
|
||||||
pub(crate) const CLAUDE_CLI_SYNC_PLAN_KIND: &str = "claude_cli_sync";
|
|
||||||
pub(crate) const GEMINI_CLI_SYNC_PLAN_KIND: &str = "gemini_cli_sync";
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
use super::plan_kinds::{
|
|
||||||
CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
|
|
||||||
GEMINI_CLI_SYNC_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND,
|
|
||||||
OPENAI_COMPACT_SYNC_PLAN_KIND,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub(crate) const OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND: &str = "openai_chat_sync_finalize";
|
|
||||||
pub(crate) const CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND: &str = "claude_chat_sync_finalize";
|
|
||||||
pub(crate) const GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND: &str = "gemini_chat_sync_finalize";
|
|
||||||
pub(crate) const OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND: &str = "openai_cli_sync_finalize";
|
|
||||||
pub(crate) const OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND: &str = "openai_compact_sync_finalize";
|
|
||||||
pub(crate) const CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND: &str = "claude_cli_sync_finalize";
|
|
||||||
pub(crate) const GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND: &str = "gemini_cli_sync_finalize";
|
|
||||||
pub(crate) const OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND: &str =
|
|
||||||
"openai_video_create_sync_finalize";
|
|
||||||
pub(crate) const GEMINI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND: &str =
|
|
||||||
"gemini_video_create_sync_finalize";
|
|
||||||
|
|
||||||
pub(crate) const OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "openai_chat_sync_success";
|
|
||||||
pub(crate) const CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "claude_chat_sync_success";
|
|
||||||
pub(crate) const GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "gemini_chat_sync_success";
|
|
||||||
pub(crate) const OPENAI_CLI_SYNC_SUCCESS_REPORT_KIND: &str = "openai_cli_sync_success";
|
|
||||||
pub(crate) const CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND: &str = "claude_cli_sync_success";
|
|
||||||
pub(crate) const GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND: &str = "gemini_cli_sync_success";
|
|
||||||
|
|
||||||
pub(crate) const OPENAI_CHAT_STREAM_SUCCESS_REPORT_KIND: &str = "openai_chat_stream_success";
|
|
||||||
pub(crate) const CLAUDE_CHAT_STREAM_SUCCESS_REPORT_KIND: &str = "claude_chat_stream_success";
|
|
||||||
pub(crate) const GEMINI_CHAT_STREAM_SUCCESS_REPORT_KIND: &str = "gemini_chat_stream_success";
|
|
||||||
pub(crate) const OPENAI_CLI_STREAM_SUCCESS_REPORT_KIND: &str = "openai_cli_stream_success";
|
|
||||||
pub(crate) const CLAUDE_CLI_STREAM_SUCCESS_REPORT_KIND: &str = "claude_cli_stream_success";
|
|
||||||
pub(crate) const GEMINI_CLI_STREAM_SUCCESS_REPORT_KIND: &str = "gemini_cli_stream_success";
|
|
||||||
|
|
||||||
pub(crate) const OPENAI_CHAT_SYNC_ERROR_REPORT_KIND: &str = "openai_chat_sync_error";
|
|
||||||
pub(crate) const CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND: &str = "claude_chat_sync_error";
|
|
||||||
pub(crate) const GEMINI_CHAT_SYNC_ERROR_REPORT_KIND: &str = "gemini_chat_sync_error";
|
|
||||||
pub(crate) const OPENAI_CLI_SYNC_ERROR_REPORT_KIND: &str = "openai_cli_sync_error";
|
|
||||||
pub(crate) const OPENAI_COMPACT_SYNC_ERROR_REPORT_KIND: &str = "openai_compact_sync_error";
|
|
||||||
pub(crate) const CLAUDE_CLI_SYNC_ERROR_REPORT_KIND: &str = "claude_cli_sync_error";
|
|
||||||
pub(crate) const GEMINI_CLI_SYNC_ERROR_REPORT_KIND: &str = "gemini_cli_sync_error";
|
|
||||||
|
|
||||||
pub(crate) fn implicit_sync_finalize_report_kind(plan_kind: &str) -> Option<&'static str> {
|
|
||||||
match plan_kind {
|
|
||||||
OPENAI_CHAT_SYNC_PLAN_KIND => Some(OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND),
|
|
||||||
CLAUDE_CHAT_SYNC_PLAN_KIND => Some(CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND),
|
|
||||||
GEMINI_CHAT_SYNC_PLAN_KIND => Some(GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND),
|
|
||||||
OPENAI_CLI_SYNC_PLAN_KIND => Some(OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND),
|
|
||||||
OPENAI_COMPACT_SYNC_PLAN_KIND => Some(OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND),
|
|
||||||
CLAUDE_CLI_SYNC_PLAN_KIND => Some(CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND),
|
|
||||||
GEMINI_CLI_SYNC_PLAN_KIND => Some(GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn core_error_default_client_api_format(report_kind: &str) -> Option<&'static str> {
|
|
||||||
match report_kind {
|
|
||||||
OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("openai:chat"),
|
|
||||||
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("claude:chat"),
|
|
||||||
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("gemini:chat"),
|
|
||||||
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND => Some("openai:cli"),
|
|
||||||
OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => Some("openai:compact"),
|
|
||||||
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND => Some("claude:cli"),
|
|
||||||
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND => Some("gemini:cli"),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn core_error_background_report_kind(report_kind: &str) -> Option<&'static str> {
|
|
||||||
match report_kind {
|
|
||||||
OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_CHAT_SYNC_ERROR_REPORT_KIND),
|
|
||||||
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND),
|
|
||||||
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CHAT_SYNC_ERROR_REPORT_KIND),
|
|
||||||
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_CLI_SYNC_ERROR_REPORT_KIND),
|
|
||||||
OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_COMPACT_SYNC_ERROR_REPORT_KIND),
|
|
||||||
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CLI_SYNC_ERROR_REPORT_KIND),
|
|
||||||
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CLI_SYNC_ERROR_REPORT_KIND),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn core_success_background_report_kind(report_kind: &str) -> Option<&'static str> {
|
|
||||||
match report_kind {
|
|
||||||
OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND),
|
|
||||||
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND),
|
|
||||||
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND),
|
|
||||||
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND | OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => {
|
|
||||||
Some(OPENAI_CLI_SYNC_SUCCESS_REPORT_KIND)
|
|
||||||
}
|
|
||||||
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND),
|
|
||||||
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
26
apps/aether-gateway/src/ai_pipeline/control_facade.rs
Normal file
26
apps/aether-gateway/src/ai_pipeline/control_facade.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use axum::http::Uri;
|
||||||
|
|
||||||
|
use crate::{AppState, GatewayError};
|
||||||
|
|
||||||
|
pub(crate) use crate::control::{GatewayControlAuthContext, GatewayControlDecision};
|
||||||
|
|
||||||
|
pub(crate) async fn resolve_execution_runtime_auth_context(
|
||||||
|
state: &AppState,
|
||||||
|
decision: &GatewayControlDecision,
|
||||||
|
headers: &http::HeaderMap,
|
||||||
|
uri: &Uri,
|
||||||
|
trace_id: &str,
|
||||||
|
) -> Result<Option<GatewayControlAuthContext>, GatewayError> {
|
||||||
|
crate::control::resolve_execution_runtime_auth_context(state, decision, headers, uri, trace_id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn collect_control_headers(
|
||||||
|
headers: &http::HeaderMap,
|
||||||
|
) -> std::collections::BTreeMap<String, String> {
|
||||||
|
crate::headers::collect_control_headers(headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_json_request(headers: &http::HeaderMap) -> bool {
|
||||||
|
crate::headers::is_json_request(headers)
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
pub(crate) mod error;
|
|
||||||
pub(crate) mod registry;
|
pub(crate) mod registry;
|
||||||
pub(crate) mod request;
|
pub(crate) mod request;
|
||||||
pub(crate) mod response;
|
pub(crate) mod response;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use error::core_success_background_report_kind;
|
pub(crate) use aether_ai_pipeline::conversion::core_success_background_report_kind;
|
||||||
pub(crate) use error::{
|
pub(crate) use aether_ai_pipeline::conversion::{
|
||||||
build_core_error_body_for_client_format, core_error_background_report_kind,
|
build_core_error_body_for_client_format, core_error_background_report_kind,
|
||||||
core_error_default_client_api_format, is_core_error_finalize_kind, LocalCoreSyncErrorKind,
|
core_error_default_client_api_format, is_core_error_finalize_kind, LocalCoreSyncErrorKind,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,60 +1,17 @@
|
|||||||
use crate::provider_transport::auth::{
|
use crate::ai_pipeline::provider_transport_facade::auth::{
|
||||||
resolve_local_gemini_auth, resolve_local_openai_chat_auth, resolve_local_standard_auth,
|
resolve_local_gemini_auth, resolve_local_openai_chat_auth, resolve_local_standard_auth,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::policy::{
|
use crate::ai_pipeline::provider_transport_facade::policy::{
|
||||||
supports_local_openai_chat_transport, supports_local_standard_transport_with_network,
|
supports_local_openai_chat_transport, supports_local_standard_transport_with_network,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::{
|
use crate::ai_pipeline::provider_transport_facade::{
|
||||||
supports_local_gemini_transport_with_network, GatewayProviderTransportSnapshot,
|
supports_local_gemini_transport_with_network, GatewayProviderTransportSnapshot,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
pub(crate) use aether_ai_pipeline::conversion::{
|
||||||
pub(crate) enum RequestConversionKind {
|
request_conversion_kind, sync_chat_response_conversion_kind, sync_cli_response_conversion_kind,
|
||||||
ToOpenAIChat,
|
RequestConversionKind, SyncChatResponseConversionKind, SyncCliResponseConversionKind,
|
||||||
ToOpenAIFamilyCli,
|
};
|
||||||
ToOpenAICompact,
|
|
||||||
ToClaudeStandard,
|
|
||||||
ToGeminiStandard,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub(crate) enum SyncChatResponseConversionKind {
|
|
||||||
ToOpenAIChat,
|
|
||||||
ToClaudeChat,
|
|
||||||
ToGeminiChat,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub(crate) enum SyncCliResponseConversionKind {
|
|
||||||
ToOpenAIFamilyCli,
|
|
||||||
ToClaudeCli,
|
|
||||||
ToGeminiCli,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn request_conversion_kind(
|
|
||||||
client_api_format: &str,
|
|
||||||
provider_api_format: &str,
|
|
||||||
) -> Option<RequestConversionKind> {
|
|
||||||
let client_api_format = client_api_format.trim().to_ascii_lowercase();
|
|
||||||
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
|
|
||||||
if client_api_format == provider_api_format {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if !is_standard_api_format(client_api_format.as_str())
|
|
||||||
|| !is_standard_api_format(provider_api_format.as_str())
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
match provider_api_format.as_str() {
|
|
||||||
"openai:chat" => Some(RequestConversionKind::ToOpenAIChat),
|
|
||||||
"openai:cli" => Some(RequestConversionKind::ToOpenAIFamilyCli),
|
|
||||||
"openai:compact" => Some(RequestConversionKind::ToOpenAICompact),
|
|
||||||
"claude:chat" | "claude:cli" => Some(RequestConversionKind::ToClaudeStandard),
|
|
||||||
"gemini:chat" | "gemini:cli" => Some(RequestConversionKind::ToGeminiStandard),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn request_conversion_transport_supported(
|
pub(crate) fn request_conversion_transport_supported(
|
||||||
transport: &GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
@@ -100,59 +57,6 @@ pub(crate) fn request_conversion_direct_auth(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn sync_chat_response_conversion_kind(
|
|
||||||
provider_api_format: &str,
|
|
||||||
client_api_format: &str,
|
|
||||||
) -> Option<SyncChatResponseConversionKind> {
|
|
||||||
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
|
|
||||||
let client_api_format = client_api_format.trim().to_ascii_lowercase();
|
|
||||||
if provider_api_format == client_api_format {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if !is_standard_api_format(provider_api_format.as_str()) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
match client_api_format.as_str() {
|
|
||||||
"openai:chat" => Some(SyncChatResponseConversionKind::ToOpenAIChat),
|
|
||||||
"claude:chat" => Some(SyncChatResponseConversionKind::ToClaudeChat),
|
|
||||||
"gemini:chat" => Some(SyncChatResponseConversionKind::ToGeminiChat),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn sync_cli_response_conversion_kind(
|
|
||||||
provider_api_format: &str,
|
|
||||||
client_api_format: &str,
|
|
||||||
) -> Option<SyncCliResponseConversionKind> {
|
|
||||||
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
|
|
||||||
let client_api_format = client_api_format.trim().to_ascii_lowercase();
|
|
||||||
if provider_api_format == client_api_format {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if !is_standard_api_format(provider_api_format.as_str()) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
match client_api_format.as_str() {
|
|
||||||
"openai:cli" | "openai:compact" => Some(SyncCliResponseConversionKind::ToOpenAIFamilyCli),
|
|
||||||
"claude:cli" => Some(SyncCliResponseConversionKind::ToClaudeCli),
|
|
||||||
"gemini:cli" => Some(SyncCliResponseConversionKind::ToGeminiCli),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_standard_api_format(api_format: &str) -> bool {
|
|
||||||
matches!(
|
|
||||||
api_format,
|
|
||||||
"openai:chat"
|
|
||||||
| "openai:cli"
|
|
||||||
| "openai:compact"
|
|
||||||
| "claude:chat"
|
|
||||||
| "claude:cli"
|
|
||||||
| "gemini:chat"
|
|
||||||
| "gemini:cli"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
mod claude;
|
|
||||||
mod gemini;
|
|
||||||
mod openai_cli;
|
|
||||||
mod shared;
|
|
||||||
|
|
||||||
pub(crate) use claude::convert_openai_chat_request_to_claude_request;
|
|
||||||
pub(crate) use gemini::convert_openai_chat_request_to_gemini_request;
|
|
||||||
pub(crate) use openai_cli::convert_openai_chat_request_to_openai_cli_request;
|
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
mod from_openai_chat;
|
pub(crate) use aether_ai_pipeline::conversion::request::{
|
||||||
mod to_openai_chat;
|
|
||||||
|
|
||||||
pub(crate) use from_openai_chat::{
|
|
||||||
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
|
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
|
||||||
convert_openai_chat_request_to_openai_cli_request,
|
convert_openai_chat_request_to_openai_cli_request, extract_openai_text_content,
|
||||||
};
|
normalize_claude_request_to_openai_chat_request,
|
||||||
pub(crate) use to_openai_chat::{
|
|
||||||
extract_openai_text_content, normalize_claude_request_to_openai_chat_request,
|
|
||||||
normalize_gemini_request_to_openai_chat_request,
|
normalize_gemini_request_to_openai_chat_request,
|
||||||
normalize_openai_cli_request_to_openai_chat_request, parse_openai_tool_result_content,
|
normalize_openai_cli_request_to_openai_chat_request, parse_openai_tool_result_content,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
mod claude;
|
|
||||||
mod gemini;
|
|
||||||
mod openai_cli;
|
|
||||||
mod shared;
|
|
||||||
|
|
||||||
pub(crate) use claude::normalize_claude_request_to_openai_chat_request;
|
|
||||||
pub(crate) use gemini::normalize_gemini_request_to_openai_chat_request;
|
|
||||||
pub(crate) use openai_cli::normalize_openai_cli_request_to_openai_chat_request;
|
|
||||||
pub(crate) use shared::{extract_openai_text_content, parse_openai_tool_result_content};
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
mod claude_chat;
|
|
||||||
mod gemini_chat;
|
|
||||||
mod openai_cli;
|
|
||||||
mod shared;
|
|
||||||
|
|
||||||
pub(crate) use claude_chat::convert_openai_chat_response_to_claude_chat;
|
|
||||||
pub(crate) use gemini_chat::convert_openai_chat_response_to_gemini_chat;
|
|
||||||
pub(crate) use openai_cli::convert_openai_chat_response_to_openai_cli;
|
|
||||||
pub(crate) use shared::build_openai_cli_response;
|
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
mod from_openai_chat;
|
pub(crate) use aether_ai_pipeline::conversion::response::{
|
||||||
mod to_openai_chat;
|
build_openai_cli_response, convert_claude_chat_response_to_openai_chat,
|
||||||
|
convert_claude_cli_response_to_openai_cli, convert_gemini_chat_response_to_openai_chat,
|
||||||
pub(crate) use from_openai_chat::{
|
convert_gemini_cli_response_to_openai_cli, convert_openai_chat_response_to_claude_chat,
|
||||||
build_openai_cli_response, convert_openai_chat_response_to_claude_chat,
|
|
||||||
convert_openai_chat_response_to_gemini_chat, convert_openai_chat_response_to_openai_cli,
|
convert_openai_chat_response_to_gemini_chat, convert_openai_chat_response_to_openai_cli,
|
||||||
};
|
|
||||||
pub(crate) use to_openai_chat::{
|
|
||||||
convert_claude_chat_response_to_openai_chat, convert_claude_cli_response_to_openai_cli,
|
|
||||||
convert_gemini_chat_response_to_openai_chat, convert_gemini_cli_response_to_openai_cli,
|
|
||||||
convert_openai_cli_response_to_openai_chat,
|
convert_openai_cli_response_to_openai_chat,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
mod claude_chat;
|
|
||||||
mod claude_cli;
|
|
||||||
mod gemini_chat;
|
|
||||||
mod gemini_cli;
|
|
||||||
mod openai_cli;
|
|
||||||
mod shared;
|
|
||||||
|
|
||||||
pub(crate) use claude_chat::convert_claude_chat_response_to_openai_chat;
|
|
||||||
pub(crate) use claude_cli::convert_claude_cli_response_to_openai_cli;
|
|
||||||
pub(crate) use gemini_chat::convert_gemini_chat_response_to_openai_chat;
|
|
||||||
pub(crate) use gemini_cli::convert_gemini_cli_response_to_openai_cli;
|
|
||||||
pub(crate) use openai_cli::convert_openai_cli_response_to_openai_chat;
|
|
||||||
14
apps/aether-gateway/src/ai_pipeline/execution_facade.rs
Normal file
14
apps/aether-gateway/src/ai_pipeline/execution_facade.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::Response;
|
||||||
|
|
||||||
|
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
||||||
|
|
||||||
|
pub(crate) use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
|
||||||
|
|
||||||
|
pub(crate) fn maybe_build_local_sync_finalize_response(
|
||||||
|
trace_id: &str,
|
||||||
|
decision: &crate::ai_pipeline::control_facade::GatewayControlDecision,
|
||||||
|
payload: &GatewaySyncReportRequest,
|
||||||
|
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||||
|
crate::execution_runtime::maybe_build_local_sync_finalize_response(trace_id, decision, payload)
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ pub(crate) use crate::ai_pipeline::adaptation::private_envelope::{
|
|||||||
provider_private_response_allows_sync_finalize as local_finalize_allows_envelope,
|
provider_private_response_allows_sync_finalize as local_finalize_allows_envelope,
|
||||||
};
|
};
|
||||||
use crate::ai_pipeline::contracts::core_success_background_report_kind;
|
use crate::ai_pipeline::contracts::core_success_background_report_kind;
|
||||||
|
use crate::ai_pipeline::control_facade::GatewayControlDecision;
|
||||||
use crate::api::response::build_client_response_from_parts;
|
use crate::api::response::build_client_response_from_parts;
|
||||||
use crate::control::GatewayControlDecision;
|
|
||||||
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
||||||
|
|
||||||
pub(crate) struct LocalCoreSyncFinalizeOutcome {
|
pub(crate) struct LocalCoreSyncFinalizeOutcome {
|
||||||
@@ -141,58 +141,3 @@ pub(crate) fn canonicalize_tool_arguments(value: Option<Value>) -> String {
|
|||||||
pub(crate) fn build_generated_tool_call_id(index: usize) -> String {
|
pub(crate) fn build_generated_tool_call_id(index: usize) -> String {
|
||||||
format!("call_auto_{index}")
|
format!("call_auto_{index}")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn parse_stream_json_events(body: &[u8]) -> Option<Vec<Value>> {
|
|
||||||
let text = std::str::from_utf8(body).ok()?;
|
|
||||||
let trimmed = text.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return Some(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
if trimmed.starts_with('[') {
|
|
||||||
let array_value: Value = serde_json::from_str(trimmed).ok()?;
|
|
||||||
let array = array_value.as_array()?;
|
|
||||||
return Some(
|
|
||||||
array
|
|
||||||
.iter()
|
|
||||||
.filter(|value| value.is_object())
|
|
||||||
.cloned()
|
|
||||||
.collect(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut events = Vec::new();
|
|
||||||
let mut current_event_type: Option<String> = None;
|
|
||||||
|
|
||||||
for raw_line in text.lines() {
|
|
||||||
let line = raw_line.trim_matches('\r').trim();
|
|
||||||
if line.is_empty() || line.starts_with(':') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(event_name) = line.strip_prefix("event:") {
|
|
||||||
current_event_type = Some(event_name.trim().to_string());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let data_line = if let Some(rest) = line.strip_prefix("data:") {
|
|
||||||
rest.trim()
|
|
||||||
} else {
|
|
||||||
line
|
|
||||||
};
|
|
||||||
if data_line.is_empty() || data_line == "[DONE]" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut event: Value = serde_json::from_str(data_line).ok()?;
|
|
||||||
if let Some(event_object) = event.as_object_mut() {
|
|
||||||
if !event_object.contains_key("type") {
|
|
||||||
if let Some(event_name) = current_event_type.take() {
|
|
||||||
event_object.insert("type".to_string(), Value::String(event_name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
events.push(event);
|
|
||||||
current_event_type = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(events)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use axum::body::Body;
|
|||||||
use axum::http::Response;
|
use axum::http::Response;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::ai_pipeline::control_facade::GatewayControlDecision;
|
||||||
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
||||||
|
|
||||||
#[path = "stream_rewrite.rs"]
|
#[path = "stream_rewrite.rs"]
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ use crate::ai_pipeline::adaptation::private_envelope::transform_provider_private
|
|||||||
use crate::ai_pipeline::finalize::standard::StreamingStandardConversionState;
|
use crate::ai_pipeline::finalize::standard::StreamingStandardConversionState;
|
||||||
use crate::ai_pipeline::runtime::adapters::kiro::KiroToClaudeCliStreamState;
|
use crate::ai_pipeline::runtime::adapters::kiro::KiroToClaudeCliStreamState;
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
|
use aether_ai_pipeline::finalize::{
|
||||||
|
resolve_finalize_stream_rewrite_mode, FinalizeStreamRewriteMode,
|
||||||
|
};
|
||||||
|
|
||||||
enum RewriteMode {
|
enum RewriteMode {
|
||||||
EnvelopeUnwrap,
|
EnvelopeUnwrap,
|
||||||
@@ -21,103 +24,13 @@ pub(crate) fn maybe_build_local_stream_rewriter(
|
|||||||
report_context: Option<&Value>,
|
report_context: Option<&Value>,
|
||||||
) -> Option<LocalStreamRewriter> {
|
) -> Option<LocalStreamRewriter> {
|
||||||
let report_context = report_context?;
|
let report_context = report_context?;
|
||||||
let needs_conversion = report_context
|
let mode = match resolve_finalize_stream_rewrite_mode(report_context)? {
|
||||||
.get("needs_conversion")
|
FinalizeStreamRewriteMode::EnvelopeUnwrap => RewriteMode::EnvelopeUnwrap,
|
||||||
.and_then(Value::as_bool)
|
FinalizeStreamRewriteMode::Standard => {
|
||||||
.unwrap_or(false);
|
|
||||||
let envelope_name = report_context
|
|
||||||
.get("envelope_name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
|
|
||||||
let mode = if needs_conversion {
|
|
||||||
match (
|
|
||||||
envelope_name.as_str(),
|
|
||||||
provider_api_format.as_str(),
|
|
||||||
client_api_format.as_str(),
|
|
||||||
) {
|
|
||||||
("", "claude:chat", "openai:chat") => {
|
|
||||||
RewriteMode::Standard(StreamingStandardConversionState::default())
|
RewriteMode::Standard(StreamingStandardConversionState::default())
|
||||||
}
|
}
|
||||||
("", "gemini:chat", "openai:chat") => {
|
FinalizeStreamRewriteMode::KiroToClaudeCli => {
|
||||||
RewriteMode::Standard(StreamingStandardConversionState::default())
|
|
||||||
}
|
|
||||||
("", "openai:cli", "openai:chat") | ("", "openai:compact", "openai:chat") => {
|
|
||||||
RewriteMode::Standard(StreamingStandardConversionState::default())
|
|
||||||
}
|
|
||||||
("", "claude:cli", "openai:cli") => {
|
|
||||||
RewriteMode::Standard(StreamingStandardConversionState::default())
|
|
||||||
}
|
|
||||||
("", "claude:cli", "openai:compact") => {
|
|
||||||
RewriteMode::Standard(StreamingStandardConversionState::default())
|
|
||||||
}
|
|
||||||
("", "gemini:cli", "openai:cli") => {
|
|
||||||
RewriteMode::Standard(StreamingStandardConversionState::default())
|
|
||||||
}
|
|
||||||
("", "gemini:cli", "openai:compact") => {
|
|
||||||
RewriteMode::Standard(StreamingStandardConversionState::default())
|
|
||||||
}
|
|
||||||
("antigravity:v1internal", "gemini:chat", "openai:chat") => {
|
|
||||||
RewriteMode::Standard(StreamingStandardConversionState::default())
|
|
||||||
}
|
|
||||||
("antigravity:v1internal", "gemini:cli", "openai:cli") => {
|
|
||||||
RewriteMode::Standard(StreamingStandardConversionState::default())
|
|
||||||
}
|
|
||||||
("antigravity:v1internal", "gemini:cli", "openai:compact") => {
|
|
||||||
RewriteMode::Standard(StreamingStandardConversionState::default())
|
|
||||||
}
|
|
||||||
_ if is_standard_chat_client_api_format(client_api_format.as_str())
|
|
||||||
&& is_standard_provider_api_format(provider_api_format.as_str()) =>
|
|
||||||
{
|
|
||||||
RewriteMode::Standard(StreamingStandardConversionState::default())
|
|
||||||
}
|
|
||||||
_ if is_standard_cli_client_api_format(client_api_format.as_str())
|
|
||||||
&& is_standard_provider_api_format(provider_api_format.as_str()) =>
|
|
||||||
{
|
|
||||||
RewriteMode::Standard(StreamingStandardConversionState::default())
|
|
||||||
}
|
|
||||||
_ => return None,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
match envelope_name.as_str() {
|
|
||||||
"antigravity:v1internal" => {
|
|
||||||
if provider_api_format == client_api_format
|
|
||||||
&& matches!(provider_api_format.as_str(), "gemini:chat" | "gemini:cli")
|
|
||||||
{
|
|
||||||
RewriteMode::EnvelopeUnwrap
|
|
||||||
} else {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"gemini_cli:v1internal" => {
|
|
||||||
if provider_api_format == "gemini:cli" && client_api_format == "gemini:cli" {
|
|
||||||
RewriteMode::EnvelopeUnwrap
|
|
||||||
} else {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"kiro:generateassistantresponse" => {
|
|
||||||
if provider_api_format == "claude:cli" && client_api_format == "claude:cli" {
|
|
||||||
RewriteMode::KiroToClaudeCli(KiroToClaudeCliStreamState::new(report_context))
|
RewriteMode::KiroToClaudeCli(KiroToClaudeCliStreamState::new(report_context))
|
||||||
} else {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => return None,
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,30 +88,6 @@ impl LocalStreamRewriter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_standard_provider_api_format(api_format: &str) -> bool {
|
|
||||||
matches!(
|
|
||||||
api_format,
|
|
||||||
"openai:chat"
|
|
||||||
| "openai:cli"
|
|
||||||
| "openai:compact"
|
|
||||||
| "claude:chat"
|
|
||||||
| "claude:cli"
|
|
||||||
| "gemini:chat"
|
|
||||||
| "gemini:cli"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_standard_chat_client_api_format(api_format: &str) -> bool {
|
|
||||||
matches!(api_format, "openai:chat" | "claude:chat" | "gemini:chat")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_standard_cli_client_api_format(api_format: &str) -> bool {
|
|
||||||
matches!(
|
|
||||||
api_format,
|
|
||||||
"openai:cli" | "openai:compact" | "claude:cli" | "gemini:cli"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "../tests_stream.rs"]
|
#[path = "../tests_stream.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -1,43 +1,23 @@
|
|||||||
use std::collections::BTreeMap;
|
use crate::ai_pipeline::control_facade::GatewayControlDecision;
|
||||||
|
|
||||||
use axum::body::Body;
|
|
||||||
use axum::http::Response;
|
|
||||||
use base64::Engine as _;
|
|
||||||
use serde_json::{json, Map, Value};
|
|
||||||
|
|
||||||
use crate::ai_pipeline::conversion::{
|
|
||||||
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind,
|
|
||||||
};
|
|
||||||
use crate::api::response::build_client_response_from_parts;
|
|
||||||
use crate::control::GatewayControlDecision;
|
|
||||||
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
||||||
|
|
||||||
|
pub(crate) use crate::ai_pipeline::conversion::response::{
|
||||||
|
convert_claude_chat_response_to_openai_chat, convert_claude_cli_response_to_openai_cli,
|
||||||
|
convert_gemini_chat_response_to_openai_chat, convert_gemini_cli_response_to_openai_cli,
|
||||||
|
};
|
||||||
pub(crate) use crate::ai_pipeline::finalize::common::{
|
pub(crate) use crate::ai_pipeline::finalize::common::{
|
||||||
build_generated_tool_call_id, build_local_success_outcome,
|
build_generated_tool_call_id, build_local_success_outcome,
|
||||||
build_local_success_outcome_with_conversion_report, canonicalize_tool_arguments,
|
build_local_success_outcome_with_conversion_report, canonicalize_tool_arguments,
|
||||||
local_finalize_allows_envelope, parse_stream_json_events, unwrap_local_finalize_response_value,
|
local_finalize_allows_envelope, unwrap_local_finalize_response_value,
|
||||||
LocalCoreSyncFinalizeOutcome,
|
LocalCoreSyncFinalizeOutcome,
|
||||||
};
|
};
|
||||||
pub(crate) use crate::ai_pipeline::finalize::standard::{
|
pub(crate) use crate::ai_pipeline::finalize::standard::{
|
||||||
|
maybe_build_standard_sync_finalize_product_from_normalized_payload,
|
||||||
|
StandardSyncFinalizeNormalizedProduct,
|
||||||
|
};
|
||||||
|
pub(crate) use aether_ai_pipeline::finalize::sync_products::{
|
||||||
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
|
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
|
||||||
aggregate_openai_chat_stream_sync_response, aggregate_openai_cli_stream_sync_response,
|
aggregate_openai_chat_stream_sync_response, aggregate_openai_cli_stream_sync_response,
|
||||||
aggregate_standard_chat_stream_sync_response, aggregate_standard_cli_stream_sync_response,
|
|
||||||
convert_claude_chat_response_to_openai_chat, convert_claude_cli_response_to_openai_cli,
|
|
||||||
convert_gemini_chat_response_to_openai_chat, convert_gemini_cli_response_to_openai_cli,
|
|
||||||
convert_openai_chat_response_to_claude_chat, convert_openai_chat_response_to_gemini_chat,
|
|
||||||
convert_openai_chat_response_to_openai_cli, convert_openai_cli_response_to_openai_chat,
|
|
||||||
convert_standard_chat_response, convert_standard_cli_response,
|
|
||||||
maybe_build_local_claude_cli_stream_sync_response,
|
|
||||||
maybe_build_local_claude_stream_sync_response, maybe_build_local_claude_sync_response,
|
|
||||||
maybe_build_local_gemini_cli_stream_sync_response,
|
|
||||||
maybe_build_local_gemini_stream_sync_response, maybe_build_local_gemini_sync_response,
|
|
||||||
maybe_build_local_openai_chat_cross_format_stream_sync_response,
|
|
||||||
maybe_build_local_openai_chat_cross_format_sync_response,
|
|
||||||
maybe_build_local_openai_chat_stream_sync_response,
|
|
||||||
maybe_build_local_openai_chat_sync_response,
|
|
||||||
maybe_build_local_openai_cli_cross_format_stream_sync_response,
|
|
||||||
maybe_build_local_openai_cli_cross_format_sync_response,
|
|
||||||
maybe_build_local_openai_cli_stream_sync_response,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) fn maybe_build_local_core_sync_finalize_response(
|
pub(crate) fn maybe_build_local_core_sync_finalize_response(
|
||||||
@@ -51,325 +31,50 @@ pub(crate) fn maybe_build_local_core_sync_finalize_response(
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let payload = &normalized_payload;
|
let payload = &normalized_payload;
|
||||||
if let Some(response) =
|
|
||||||
maybe_build_local_openai_chat_stream_sync_response(trace_id, decision, payload)?
|
|
||||||
{
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
if let Some(response) =
|
|
||||||
maybe_build_local_openai_chat_sync_response(trace_id, decision, payload)?
|
|
||||||
{
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
if let Some(response) = maybe_build_local_openai_chat_cross_format_stream_sync_response(
|
|
||||||
trace_id, decision, payload,
|
|
||||||
)? {
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
if let Some(response) =
|
|
||||||
maybe_build_local_openai_cli_stream_sync_response(trace_id, decision, payload)?
|
|
||||||
{
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
if let Some(response) =
|
|
||||||
maybe_build_local_openai_cli_cross_format_stream_sync_response(trace_id, decision, payload)?
|
|
||||||
{
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
if let Some(response) =
|
|
||||||
maybe_build_local_claude_cli_stream_sync_response(trace_id, decision, payload)?
|
|
||||||
{
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
if let Some(response) =
|
|
||||||
maybe_build_local_gemini_cli_stream_sync_response(trace_id, decision, payload)?
|
|
||||||
{
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
if let Some(response) =
|
|
||||||
maybe_build_local_claude_stream_sync_response(trace_id, decision, payload)?
|
|
||||||
{
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
if let Some(response) = maybe_build_local_claude_sync_response(trace_id, decision, payload)? {
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
if let Some(response) =
|
|
||||||
maybe_build_local_gemini_stream_sync_response(trace_id, decision, payload)?
|
|
||||||
{
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
if let Some(response) = maybe_build_local_gemini_sync_response(trace_id, decision, payload)? {
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
if let Some(response) =
|
|
||||||
maybe_build_local_openai_chat_cross_format_sync_response(trace_id, decision, payload)?
|
|
||||||
{
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
if let Some(response) =
|
|
||||||
maybe_build_local_openai_cli_cross_format_sync_response(trace_id, decision, payload)?
|
|
||||||
{
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
if let Some(response) = maybe_build_local_standard_chat_cross_format_stream_sync_response(
|
|
||||||
trace_id, decision, payload,
|
|
||||||
)? {
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
if let Some(response) =
|
|
||||||
maybe_build_local_standard_chat_cross_format_sync_response(trace_id, decision, payload)?
|
|
||||||
{
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
if let Some(response) = maybe_build_local_standard_cli_cross_format_stream_sync_response(
|
|
||||||
trace_id, decision, payload,
|
|
||||||
)? {
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
if let Some(response) =
|
|
||||||
maybe_build_local_standard_cli_cross_format_sync_response(trace_id, decision, payload)?
|
|
||||||
{
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn maybe_build_local_standard_chat_cross_format_stream_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if !matches!(
|
|
||||||
payload.report_kind.as_str(),
|
|
||||||
"openai_chat_sync_finalize" | "claude_chat_sync_finalize" | "gemini_chat_sync_finalize"
|
|
||||||
) || payload.status_code >= 400
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
let Some(report_context) = payload.report_context.as_ref() else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let provider_api_format = report_context
|
if !local_finalize_allows_envelope(report_context) {
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if !local_finalize_allows_envelope(report_context)
|
|
||||||
|| sync_chat_response_conversion_kind(&provider_api_format, &client_api_format).is_none()
|
|
||||||
{
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let Some(body_base64) = payload.body_base64.as_deref() else {
|
let Some(product) = maybe_build_standard_sync_finalize_product_from_normalized_payload(
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let body_bytes = base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(body_base64)
|
|
||||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
|
||||||
let Some(aggregated) =
|
|
||||||
aggregate_standard_chat_stream_sync_response(&body_bytes, &provider_api_format)
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(aggregated) = unwrap_local_finalize_response_value(aggregated, report_context)? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(converted) = convert_standard_chat_response(
|
|
||||||
&aggregated,
|
|
||||||
&provider_api_format,
|
|
||||||
&client_api_format,
|
|
||||||
report_context,
|
|
||||||
) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome_with_conversion_report(
|
|
||||||
trace_id, decision, payload, converted, aggregated,
|
|
||||||
)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn maybe_build_local_standard_chat_cross_format_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if !matches!(
|
|
||||||
payload.report_kind.as_str(),
|
payload.report_kind.as_str(),
|
||||||
"openai_chat_sync_finalize" | "claude_chat_sync_finalize" | "gemini_chat_sync_finalize"
|
payload.status_code,
|
||||||
) || payload.status_code >= 400
|
Some(report_context),
|
||||||
{
|
payload.body_json.as_ref(),
|
||||||
return Ok(None);
|
payload.body_base64.as_deref(),
|
||||||
}
|
)
|
||||||
|
.map_err(GatewayError::from)?
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if !local_finalize_allows_envelope(report_context)
|
|
||||||
|| sync_chat_response_conversion_kind(&provider_api_format, &client_api_format).is_none()
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let Some(body_json) = payload.body_json.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
|
|
||||||
else {
|
else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let Some(converted) = convert_standard_chat_response(
|
|
||||||
&body_json,
|
|
||||||
&provider_api_format,
|
|
||||||
&client_api_format,
|
|
||||||
report_context,
|
|
||||||
) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome_with_conversion_report(
|
match product {
|
||||||
trace_id, decision, payload, converted, body_json,
|
StandardSyncFinalizeNormalizedProduct::SuccessBody(body_json) => {
|
||||||
)?))
|
let Some(body_json) = unwrap_local_finalize_response_value(body_json, report_context)?
|
||||||
}
|
|
||||||
|
|
||||||
fn maybe_build_local_standard_cli_cross_format_stream_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if !matches!(
|
|
||||||
payload.report_kind.as_str(),
|
|
||||||
"openai_cli_sync_finalize"
|
|
||||||
| "openai_compact_sync_finalize"
|
|
||||||
| "claude_cli_sync_finalize"
|
|
||||||
| "gemini_cli_sync_finalize"
|
|
||||||
) || payload.status_code >= 400
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if !local_finalize_allows_envelope(report_context)
|
|
||||||
|| sync_cli_response_conversion_kind(&provider_api_format, &client_api_format).is_none()
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let Some(body_base64) = payload.body_base64.as_deref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let body_bytes = base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(body_base64)
|
|
||||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
|
||||||
let Some(aggregated) =
|
|
||||||
aggregate_standard_cli_stream_sync_response(&body_bytes, &provider_api_format)
|
|
||||||
else {
|
else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let Some(aggregated) = unwrap_local_finalize_response_value(aggregated, report_context)? else {
|
Ok(Some(build_local_success_outcome(
|
||||||
return Ok(None);
|
trace_id, decision, payload, body_json,
|
||||||
};
|
|
||||||
let Some(converted) = convert_standard_cli_response(
|
|
||||||
&aggregated,
|
|
||||||
&provider_api_format,
|
|
||||||
&client_api_format,
|
|
||||||
report_context,
|
|
||||||
) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome_with_conversion_report(
|
|
||||||
trace_id, decision, payload, converted, aggregated,
|
|
||||||
)?))
|
)?))
|
||||||
}
|
}
|
||||||
|
StandardSyncFinalizeNormalizedProduct::CrossFormat(product) => {
|
||||||
fn maybe_build_local_standard_cli_cross_format_sync_response(
|
let Some(provider_body_json) =
|
||||||
trace_id: &str,
|
unwrap_local_finalize_response_value(product.provider_body_json, report_context)?
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if !matches!(
|
|
||||||
payload.report_kind.as_str(),
|
|
||||||
"openai_cli_sync_finalize"
|
|
||||||
| "openai_compact_sync_finalize"
|
|
||||||
| "claude_cli_sync_finalize"
|
|
||||||
| "gemini_cli_sync_finalize"
|
|
||||||
) || payload.status_code >= 400
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if !local_finalize_allows_envelope(report_context)
|
|
||||||
|| sync_cli_response_conversion_kind(&provider_api_format, &client_api_format).is_none()
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let Some(body_json) = payload.body_json.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
|
|
||||||
else {
|
else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let Some(converted) = convert_standard_cli_response(
|
|
||||||
&body_json,
|
|
||||||
&provider_api_format,
|
|
||||||
&client_api_format,
|
|
||||||
report_context,
|
|
||||||
) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome_with_conversion_report(
|
Ok(Some(build_local_success_outcome_with_conversion_report(
|
||||||
trace_id, decision, payload, converted, body_json,
|
trace_id,
|
||||||
|
decision,
|
||||||
|
payload,
|
||||||
|
product.client_body_json,
|
||||||
|
provider_body_json,
|
||||||
)?))
|
)?))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "../tests_sync.rs"]
|
#[path = "../tests_sync.rs"]
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
pub(crate) mod common;
|
pub(crate) mod common;
|
||||||
pub(crate) mod sse;
|
pub(crate) mod sse;
|
||||||
pub(crate) mod standard;
|
pub(crate) mod standard;
|
||||||
pub(crate) use crate::api::response::{
|
pub(crate) use crate::ai_pipeline::execution_facade::maybe_build_local_sync_finalize_response;
|
||||||
build_client_response, build_client_response_from_parts,
|
pub(crate) use crate::api::response::{build_client_response, build_client_response_from_parts};
|
||||||
};
|
|
||||||
pub(crate) use crate::execution_runtime::maybe_build_local_sync_finalize_response;
|
|
||||||
pub(crate) use common::build_local_success_outcome;
|
pub(crate) use common::build_local_success_outcome;
|
||||||
pub(crate) use internal::{
|
pub(crate) use internal::{
|
||||||
maybe_build_stream_response_rewriter, maybe_build_sync_finalize_outcome,
|
maybe_build_stream_response_rewriter, maybe_build_sync_finalize_outcome,
|
||||||
|
|||||||
@@ -1,38 +1,14 @@
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
|
use aether_ai_pipeline::finalize::{self, PipelineFinalizeError};
|
||||||
|
|
||||||
pub(crate) fn map_claude_stop_reason(
|
pub(crate) use finalize::sse::{encode_done_sse, map_claude_stop_reason};
|
||||||
stop_reason: Option<&str>,
|
|
||||||
has_tool_calls: bool,
|
|
||||||
) -> Option<&'static str> {
|
|
||||||
let mapped = match stop_reason {
|
|
||||||
Some("end_turn") | Some("stop_sequence") => Some("stop"),
|
|
||||||
Some("max_tokens") => Some("length"),
|
|
||||||
Some("tool_use") => Some("tool_calls"),
|
|
||||||
Some("pause_turn") => Some("stop"),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
if has_tool_calls && mapped.is_none_or(|value| value == "stop") {
|
|
||||||
Some("tool_calls")
|
|
||||||
} else {
|
|
||||||
mapped
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn encode_done_sse() -> Vec<u8> {
|
fn map_error(err: PipelineFinalizeError) -> GatewayError {
|
||||||
b"data: [DONE]\n\n".to_vec()
|
err.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn encode_json_sse(event: Option<&str>, value: &Value) -> Result<Vec<u8>, GatewayError> {
|
pub(crate) fn encode_json_sse(event: Option<&str>, value: &Value) -> Result<Vec<u8>, GatewayError> {
|
||||||
let mut out = Vec::new();
|
finalize::sse::encode_json_sse(event, value).map_err(map_error)
|
||||||
if let Some(event) = event.filter(|value| !value.trim().is_empty()) {
|
|
||||||
out.extend_from_slice(b"event: ");
|
|
||||||
out.extend_from_slice(event.as_bytes());
|
|
||||||
out.push(b'\n');
|
|
||||||
}
|
|
||||||
out.extend_from_slice(b"data: ");
|
|
||||||
out.extend(serde_json::to_vec(value).map_err(|err| GatewayError::Internal(err.to_string()))?);
|
|
||||||
out.extend_from_slice(b"\n\n");
|
|
||||||
Ok(out)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
pub(super) mod stream;
|
pub(super) mod stream;
|
||||||
pub(super) mod sync;
|
|
||||||
|
|
||||||
pub(crate) use sync::{
|
pub(crate) use crate::ai_pipeline::conversion::response::{
|
||||||
aggregate_claude_stream_sync_response, convert_claude_chat_response_to_openai_chat,
|
convert_claude_chat_response_to_openai_chat, convert_claude_cli_response_to_openai_cli,
|
||||||
convert_claude_cli_response_to_openai_cli, convert_openai_chat_response_to_claude_chat,
|
convert_openai_chat_response_to_claude_chat,
|
||||||
maybe_build_local_claude_cli_stream_sync_response,
|
|
||||||
maybe_build_local_claude_stream_sync_response, maybe_build_local_claude_sync_response,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,531 +1,3 @@
|
|||||||
use std::collections::BTreeMap;
|
pub(crate) use aether_ai_pipeline::finalize::standard::claude::stream::{
|
||||||
|
ClaudeClientEmitter, ClaudeProviderState,
|
||||||
use serde_json::{json, Map, Value};
|
|
||||||
|
|
||||||
use crate::ai_pipeline::finalize::common::{
|
|
||||||
build_generated_tool_call_id, canonicalize_tool_arguments,
|
|
||||||
};
|
};
|
||||||
use crate::ai_pipeline::finalize::sse::{
|
|
||||||
encode_done_sse, encode_json_sse, map_claude_stop_reason,
|
|
||||||
};
|
|
||||||
use crate::GatewayError;
|
|
||||||
|
|
||||||
use crate::ai_pipeline::finalize::standard::stream::common::*;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct ClaudeProviderToolState {
|
|
||||||
call_id: String,
|
|
||||||
name: String,
|
|
||||||
started_emitted: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub(crate) struct ClaudeProviderState {
|
|
||||||
message_id: Option<String>,
|
|
||||||
model: Option<String>,
|
|
||||||
started: bool,
|
|
||||||
finished: bool,
|
|
||||||
tool_calls: BTreeMap<usize, ClaudeProviderToolState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClaudeProviderState {
|
|
||||||
fn identity(&self, report_context: &Value) -> (String, String) {
|
|
||||||
resolve_identity(
|
|
||||||
self.message_id.as_deref(),
|
|
||||||
self.model.as_deref(),
|
|
||||||
report_context,
|
|
||||||
"msg-local-stream",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_started(&mut self, report_context: &Value, out: &mut Vec<CanonicalStreamFrame>) {
|
|
||||||
if self.started {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::Start,
|
|
||||||
});
|
|
||||||
self.started = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn push_line(
|
|
||||||
&mut self,
|
|
||||||
report_context: &Value,
|
|
||||||
line: Vec<u8>,
|
|
||||||
) -> Result<Vec<CanonicalStreamFrame>, GatewayError> {
|
|
||||||
let Some(value) = decode_json_data_line(&line) else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
let Some(event_object) = value.as_object() else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
let mut out = Vec::new();
|
|
||||||
match event_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
{
|
|
||||||
"message_start" => {
|
|
||||||
if let Some(message) = event_object.get("message").and_then(Value::as_object) {
|
|
||||||
self.message_id = message
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned);
|
|
||||||
self.model = message
|
|
||||||
.get("model")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned);
|
|
||||||
}
|
|
||||||
self.ensure_started(report_context, &mut out);
|
|
||||||
}
|
|
||||||
"content_block_delta" => {
|
|
||||||
let index = event_object
|
|
||||||
.get("index")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|value| value as usize)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let Some(delta) = event_object.get("delta").and_then(Value::as_object) else {
|
|
||||||
return Ok(out);
|
|
||||||
};
|
|
||||||
match delta
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
{
|
|
||||||
"text_delta" => {
|
|
||||||
let Some(piece) = delta.get("text").and_then(Value::as_str) else {
|
|
||||||
return Ok(out);
|
|
||||||
};
|
|
||||||
if piece.is_empty() {
|
|
||||||
return Ok(out);
|
|
||||||
}
|
|
||||||
self.ensure_started(report_context, &mut out);
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::TextDelta(piece.to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"input_json_delta" => {
|
|
||||||
let Some(partial_json) = delta.get("partial_json").and_then(Value::as_str)
|
|
||||||
else {
|
|
||||||
return Ok(out);
|
|
||||||
};
|
|
||||||
if partial_json.is_empty() {
|
|
||||||
return Ok(out);
|
|
||||||
}
|
|
||||||
self.ensure_started(report_context, &mut out);
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
let tool_state = self.tool_calls.entry(index).or_default();
|
|
||||||
if !tool_state.started_emitted {
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id: id.clone(),
|
|
||||||
model: model.clone(),
|
|
||||||
event: CanonicalStreamEvent::ToolCallStart {
|
|
||||||
index,
|
|
||||||
call_id: if tool_state.call_id.is_empty() {
|
|
||||||
build_generated_tool_call_id(index)
|
|
||||||
} else {
|
|
||||||
tool_state.call_id.clone()
|
|
||||||
},
|
|
||||||
name: if tool_state.name.is_empty() {
|
|
||||||
"unknown".to_string()
|
|
||||||
} else {
|
|
||||||
tool_state.name.clone()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
tool_state.started_emitted = true;
|
|
||||||
}
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
|
||||||
index,
|
|
||||||
arguments: partial_json.to_string(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"content_block_start" => {
|
|
||||||
let index = event_object
|
|
||||||
.get("index")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|value| value as usize)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let Some(block) = event_object.get("content_block").and_then(Value::as_object)
|
|
||||||
else {
|
|
||||||
return Ok(out);
|
|
||||||
};
|
|
||||||
let block_type = block
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if block_type == "text" {
|
|
||||||
let Some(text) = block.get("text").and_then(Value::as_str) else {
|
|
||||||
return Ok(out);
|
|
||||||
};
|
|
||||||
if text.is_empty() {
|
|
||||||
return Ok(out);
|
|
||||||
}
|
|
||||||
self.ensure_started(report_context, &mut out);
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::TextDelta(text.to_string()),
|
|
||||||
});
|
|
||||||
return Ok(out);
|
|
||||||
}
|
|
||||||
if block_type != "tool_use" {
|
|
||||||
return Ok(out);
|
|
||||||
}
|
|
||||||
self.ensure_started(report_context, &mut out);
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
let tool_state = self.tool_calls.entry(index).or_default();
|
|
||||||
tool_state.call_id = block
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_else(|| tool_state.call_id.as_str())
|
|
||||||
.to_string();
|
|
||||||
tool_state.name = block
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_else(|| tool_state.name.as_str())
|
|
||||||
.to_string();
|
|
||||||
if !tool_state.started_emitted {
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id: id.clone(),
|
|
||||||
model: model.clone(),
|
|
||||||
event: CanonicalStreamEvent::ToolCallStart {
|
|
||||||
index,
|
|
||||||
call_id: if tool_state.call_id.is_empty() {
|
|
||||||
build_generated_tool_call_id(index)
|
|
||||||
} else {
|
|
||||||
tool_state.call_id.clone()
|
|
||||||
},
|
|
||||||
name: if tool_state.name.is_empty() {
|
|
||||||
"unknown".to_string()
|
|
||||||
} else {
|
|
||||||
tool_state.name.clone()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
tool_state.started_emitted = true;
|
|
||||||
}
|
|
||||||
let arguments = canonicalize_tool_arguments(block.get("input").cloned());
|
|
||||||
if !arguments.is_empty() && arguments != "{}" {
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"message_delta" => {
|
|
||||||
self.ensure_started(report_context, &mut out);
|
|
||||||
let Some(delta) = event_object.get("delta").and_then(Value::as_object) else {
|
|
||||||
return Ok(out);
|
|
||||||
};
|
|
||||||
let finish_reason = map_claude_stop_reason(
|
|
||||||
delta.get("stop_reason").and_then(Value::as_str),
|
|
||||||
delta.get("stop_reason").and_then(Value::as_str) == Some("tool_use"),
|
|
||||||
)
|
|
||||||
.map(ToOwned::to_owned);
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::Finish {
|
|
||||||
finish_reason,
|
|
||||||
usage: canonical_usage_from_claude_usage(event_object.get("usage")),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
self.finished = true;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn finish(
|
|
||||||
&mut self,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Result<Vec<CanonicalStreamFrame>, GatewayError> {
|
|
||||||
if !self.started || self.finished {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
self.finished = true;
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
Ok(vec![CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::Finish {
|
|
||||||
finish_reason: None,
|
|
||||||
usage: None,
|
|
||||||
},
|
|
||||||
}])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ClaudeOpenBlock {
|
|
||||||
Text {
|
|
||||||
block_index: usize,
|
|
||||||
},
|
|
||||||
Tool {
|
|
||||||
tool_index: usize,
|
|
||||||
block_index: usize,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub(crate) struct ClaudeClientEmitter {
|
|
||||||
message_id: Option<String>,
|
|
||||||
model: Option<String>,
|
|
||||||
started: bool,
|
|
||||||
finished: bool,
|
|
||||||
next_block_index: usize,
|
|
||||||
open_block: Option<ClaudeOpenBlock>,
|
|
||||||
tool_block_indices: BTreeMap<usize, usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClaudeClientEmitter {
|
|
||||||
fn update_identity(&mut self, frame: &CanonicalStreamFrame) {
|
|
||||||
self.message_id = Some(frame.id.clone());
|
|
||||||
self.model = Some(frame.model.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_started(&mut self) -> Result<Vec<u8>, GatewayError> {
|
|
||||||
if self.started {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
self.started = true;
|
|
||||||
encode_json_sse(
|
|
||||||
Some("message_start"),
|
|
||||||
&json!({
|
|
||||||
"type": "message_start",
|
|
||||||
"message": {
|
|
||||||
"id": self.message_id.as_deref().unwrap_or("msg-local-stream"),
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"model": self.model.as_deref().unwrap_or("unknown"),
|
|
||||||
"content": [],
|
|
||||||
"stop_reason": Value::Null,
|
|
||||||
"stop_sequence": Value::Null,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close_open_block(&mut self) -> Result<Vec<u8>, GatewayError> {
|
|
||||||
let Some(open_block) = self.open_block.take() else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
let block_index = match open_block {
|
|
||||||
ClaudeOpenBlock::Text { block_index } => block_index,
|
|
||||||
ClaudeOpenBlock::Tool { block_index, .. } => block_index,
|
|
||||||
};
|
|
||||||
encode_json_sse(
|
|
||||||
Some("content_block_stop"),
|
|
||||||
&json!({
|
|
||||||
"type": "content_block_stop",
|
|
||||||
"index": block_index,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_text_block(&mut self) -> Result<Vec<u8>, GatewayError> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
if let Some(ClaudeOpenBlock::Text { .. }) = self.open_block {
|
|
||||||
return Ok(out);
|
|
||||||
}
|
|
||||||
out.extend(self.close_open_block()?);
|
|
||||||
let block_index = self.next_block_index;
|
|
||||||
self.next_block_index += 1;
|
|
||||||
self.open_block = Some(ClaudeOpenBlock::Text { block_index });
|
|
||||||
out.extend(encode_json_sse(
|
|
||||||
Some("content_block_start"),
|
|
||||||
&json!({
|
|
||||||
"type": "content_block_start",
|
|
||||||
"index": block_index,
|
|
||||||
"content_block": {
|
|
||||||
"type": "text",
|
|
||||||
"text": "",
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)?);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_tool_block(
|
|
||||||
&mut self,
|
|
||||||
tool_index: usize,
|
|
||||||
call_id: &str,
|
|
||||||
name: &str,
|
|
||||||
) -> Result<Vec<u8>, GatewayError> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
if let Some(ClaudeOpenBlock::Tool {
|
|
||||||
tool_index: current_tool_index,
|
|
||||||
..
|
|
||||||
}) = self.open_block
|
|
||||||
{
|
|
||||||
if current_tool_index == tool_index {
|
|
||||||
return Ok(out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.extend(self.close_open_block()?);
|
|
||||||
let block_index = self
|
|
||||||
.tool_block_indices
|
|
||||||
.get(&tool_index)
|
|
||||||
.copied()
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
let block_index = self.next_block_index;
|
|
||||||
self.next_block_index += 1;
|
|
||||||
self.tool_block_indices.insert(tool_index, block_index);
|
|
||||||
block_index
|
|
||||||
});
|
|
||||||
self.open_block = Some(ClaudeOpenBlock::Tool {
|
|
||||||
tool_index,
|
|
||||||
block_index,
|
|
||||||
});
|
|
||||||
out.extend(encode_json_sse(
|
|
||||||
Some("content_block_start"),
|
|
||||||
&json!({
|
|
||||||
"type": "content_block_start",
|
|
||||||
"index": block_index,
|
|
||||||
"content_block": {
|
|
||||||
"type": "tool_use",
|
|
||||||
"id": call_id,
|
|
||||||
"name": name,
|
|
||||||
"input": {},
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)?);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, GatewayError> {
|
|
||||||
self.update_identity(&frame);
|
|
||||||
match frame.event {
|
|
||||||
CanonicalStreamEvent::Start => self.ensure_started(),
|
|
||||||
CanonicalStreamEvent::TextDelta(text) => {
|
|
||||||
let mut out = self.ensure_started()?;
|
|
||||||
out.extend(self.ensure_text_block()?);
|
|
||||||
let block_index = match self.open_block {
|
|
||||||
Some(ClaudeOpenBlock::Text { block_index }) => block_index,
|
|
||||||
_ => return Ok(out),
|
|
||||||
};
|
|
||||||
out.extend(encode_json_sse(
|
|
||||||
Some("content_block_delta"),
|
|
||||||
&json!({
|
|
||||||
"type": "content_block_delta",
|
|
||||||
"index": block_index,
|
|
||||||
"delta": {
|
|
||||||
"type": "text_delta",
|
|
||||||
"text": text,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)?);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
CanonicalStreamEvent::ToolCallStart {
|
|
||||||
index,
|
|
||||||
call_id,
|
|
||||||
name,
|
|
||||||
} => {
|
|
||||||
let mut out = self.ensure_started()?;
|
|
||||||
out.extend(self.ensure_tool_block(index, &call_id, &name)?);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
|
||||||
let mut out = self.ensure_started()?;
|
|
||||||
let call_id = format!("tool_{index}");
|
|
||||||
out.extend(self.ensure_tool_block(index, &call_id, "unknown")?);
|
|
||||||
let block_index = match self.open_block {
|
|
||||||
Some(ClaudeOpenBlock::Tool { block_index, .. }) => block_index,
|
|
||||||
_ => return Ok(out),
|
|
||||||
};
|
|
||||||
out.extend(encode_json_sse(
|
|
||||||
Some("content_block_delta"),
|
|
||||||
&json!({
|
|
||||||
"type": "content_block_delta",
|
|
||||||
"index": block_index,
|
|
||||||
"delta": {
|
|
||||||
"type": "input_json_delta",
|
|
||||||
"partial_json": arguments,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)?);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
CanonicalStreamEvent::Finish {
|
|
||||||
finish_reason,
|
|
||||||
usage,
|
|
||||||
} => {
|
|
||||||
if self.finished {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
let mut out = self.ensure_started()?;
|
|
||||||
out.extend(self.close_open_block()?);
|
|
||||||
let mut payload = Map::new();
|
|
||||||
payload.insert(
|
|
||||||
"type".to_string(),
|
|
||||||
Value::String("message_delta".to_string()),
|
|
||||||
);
|
|
||||||
payload.insert(
|
|
||||||
"delta".to_string(),
|
|
||||||
json!({
|
|
||||||
"stop_reason": map_openai_finish_reason_to_claude(
|
|
||||||
finish_reason.as_deref()
|
|
||||||
),
|
|
||||||
"stop_sequence": Value::Null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if let Some(usage) = usage {
|
|
||||||
payload.insert(
|
|
||||||
"usage".to_string(),
|
|
||||||
json!({
|
|
||||||
"input_tokens": usage.input_tokens,
|
|
||||||
"output_tokens": usage.output_tokens,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
out.extend(encode_json_sse(
|
|
||||||
Some("message_delta"),
|
|
||||||
&Value::Object(payload),
|
|
||||||
)?);
|
|
||||||
out.extend(encode_json_sse(
|
|
||||||
Some("message_stop"),
|
|
||||||
&json!({
|
|
||||||
"type": "message_stop",
|
|
||||||
}),
|
|
||||||
)?);
|
|
||||||
self.finished = true;
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn finish(&mut self) -> Result<Vec<u8>, GatewayError> {
|
|
||||||
if !self.started || self.finished {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
self.emit(CanonicalStreamFrame {
|
|
||||||
id: self
|
|
||||||
.message_id
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "msg-local-stream".to_string()),
|
|
||||||
model: self.model.clone().unwrap_or_else(|| "unknown".to_string()),
|
|
||||||
event: CanonicalStreamEvent::Finish {
|
|
||||||
finish_reason: None,
|
|
||||||
usage: None,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,490 +0,0 @@
|
|||||||
use base64::Engine as _;
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use serde_json::{json, Map, Value};
|
|
||||||
|
|
||||||
use crate::ai_pipeline::finalize::common::{
|
|
||||||
build_generated_tool_call_id, build_local_success_outcome, canonicalize_tool_arguments,
|
|
||||||
local_finalize_allows_envelope, parse_stream_json_events, unwrap_local_finalize_response_value,
|
|
||||||
LocalCoreSyncFinalizeOutcome,
|
|
||||||
};
|
|
||||||
use crate::control::GatewayControlDecision;
|
|
||||||
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct ClaudeContentBlockState {
|
|
||||||
object: Map<String, Value>,
|
|
||||||
text: String,
|
|
||||||
partial_json: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn maybe_build_local_claude_stream_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if payload.report_kind != "claude_chat_sync_finalize" || payload.status_code >= 400 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let needs_conversion = report_context
|
|
||||||
.get("needs_conversion")
|
|
||||||
.and_then(Value::as_bool)
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !local_finalize_allows_envelope(report_context) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
if provider_api_format != "claude:chat"
|
|
||||||
|| client_api_format != "claude:chat"
|
|
||||||
|| needs_conversion
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(body_base64) = payload.body_base64.as_deref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let body_bytes = base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(body_base64)
|
|
||||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
|
||||||
let body_json = match aggregate_claude_stream_sync_response(&body_bytes) {
|
|
||||||
Some(body_json) => body_json,
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
let Some(body_json) = unwrap_local_finalize_response_value(body_json, report_context)? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome(
|
|
||||||
trace_id, decision, payload, body_json,
|
|
||||||
)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn maybe_build_local_claude_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if payload.report_kind != "claude_chat_sync_finalize" || payload.status_code >= 400 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let needs_conversion = report_context
|
|
||||||
.get("needs_conversion")
|
|
||||||
.and_then(Value::as_bool)
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !local_finalize_allows_envelope(report_context) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
if provider_api_format != "claude:chat"
|
|
||||||
|| client_api_format != "claude:chat"
|
|
||||||
|| needs_conversion
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(body_json) = payload.body_json.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome(
|
|
||||||
trace_id, decision, payload, body_json,
|
|
||||||
)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn convert_claude_chat_response_to_openai_chat(
|
|
||||||
body_json: &Value,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let body = body_json.as_object()?;
|
|
||||||
let content = body.get("content")?.as_array()?;
|
|
||||||
let mut text = String::new();
|
|
||||||
let mut tool_calls = Vec::new();
|
|
||||||
for (index, block) in content.iter().enumerate() {
|
|
||||||
let block = block.as_object()?;
|
|
||||||
match block.get("type")?.as_str()? {
|
|
||||||
"text" => {
|
|
||||||
text.push_str(block.get("text")?.as_str()?);
|
|
||||||
}
|
|
||||||
"tool_use" => {
|
|
||||||
let tool_name = block.get("name")?.as_str()?;
|
|
||||||
let tool_id = block
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
|
||||||
let arguments = canonicalize_tool_arguments(block.get("input").cloned());
|
|
||||||
tool_calls.push(json!({
|
|
||||||
"id": tool_id,
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": tool_name,
|
|
||||||
"arguments": arguments,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
_ => return None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut finish_reason = match body.get("stop_reason").and_then(Value::as_str) {
|
|
||||||
Some("end_turn") | Some("stop_sequence") => Some("stop"),
|
|
||||||
Some("max_tokens") => Some("length"),
|
|
||||||
Some("tool_use") => Some("tool_calls"),
|
|
||||||
Some(other) if !other.is_empty() => Some(other),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
if !tool_calls.is_empty() && finish_reason.is_none_or(|reason| reason == "stop") {
|
|
||||||
finish_reason = Some("tool_calls");
|
|
||||||
}
|
|
||||||
let usage = body.get("usage").and_then(Value::as_object);
|
|
||||||
let prompt_tokens = usage
|
|
||||||
.and_then(|value| value.get("input_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let completion_tokens = usage
|
|
||||||
.and_then(|value| value.get("output_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let total_tokens = prompt_tokens + completion_tokens;
|
|
||||||
let model = body
|
|
||||||
.get("model")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
|
||||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let id = body
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("chatcmpl-local-finalize");
|
|
||||||
let message_content = if text.is_empty() && !tool_calls.is_empty() {
|
|
||||||
Value::Null
|
|
||||||
} else {
|
|
||||||
Value::String(text)
|
|
||||||
};
|
|
||||||
let mut message = Map::new();
|
|
||||||
message.insert("role".to_string(), Value::String("assistant".to_string()));
|
|
||||||
message.insert("content".to_string(), message_content);
|
|
||||||
if !tool_calls.is_empty() {
|
|
||||||
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
|
||||||
}
|
|
||||||
Some(json!({
|
|
||||||
"id": id,
|
|
||||||
"object": "chat.completion",
|
|
||||||
"model": model,
|
|
||||||
"choices": [{
|
|
||||||
"index": 0,
|
|
||||||
"message": Value::Object(message),
|
|
||||||
"finish_reason": finish_reason,
|
|
||||||
}],
|
|
||||||
"usage": {
|
|
||||||
"prompt_tokens": prompt_tokens,
|
|
||||||
"completion_tokens": completion_tokens,
|
|
||||||
"total_tokens": total_tokens,
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn convert_openai_chat_response_to_claude_chat(
|
|
||||||
body_json: &Value,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let body = body_json.as_object()?;
|
|
||||||
let choices = body.get("choices")?.as_array()?;
|
|
||||||
let first_choice = choices.first()?.as_object()?;
|
|
||||||
let message = first_choice.get("message")?.as_object()?;
|
|
||||||
let mut content = Vec::new();
|
|
||||||
|
|
||||||
if let Some(text) = extract_openai_assistant_text(message.get("content")) {
|
|
||||||
if !text.trim().is_empty() {
|
|
||||||
content.push(json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": text,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
|
|
||||||
for (index, tool_call) in tool_call_values.iter().enumerate() {
|
|
||||||
let tool_call = tool_call.as_object()?;
|
|
||||||
let function = tool_call.get("function")?.as_object()?;
|
|
||||||
let tool_name = function
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let tool_id = tool_call
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
|
||||||
let input = parse_openai_function_arguments(function.get("arguments"))?;
|
|
||||||
content.push(json!({
|
|
||||||
"type": "tool_use",
|
|
||||||
"id": tool_id,
|
|
||||||
"name": tool_name,
|
|
||||||
"input": input,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if content.is_empty() {
|
|
||||||
content.push(json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": "",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
let stop_reason = match first_choice.get("finish_reason").and_then(Value::as_str) {
|
|
||||||
Some("stop") | None => "end_turn",
|
|
||||||
Some("length") => "max_tokens",
|
|
||||||
Some("tool_calls") | Some("function_call") => "tool_use",
|
|
||||||
Some("content_filter") => "content_filtered",
|
|
||||||
Some(other) => other,
|
|
||||||
};
|
|
||||||
let usage = body.get("usage").and_then(Value::as_object);
|
|
||||||
let input_tokens = usage
|
|
||||||
.and_then(|value| value.get("prompt_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let output_tokens = usage
|
|
||||||
.and_then(|value| value.get("completion_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let model = body
|
|
||||||
.get("model")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
|
||||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let id = body
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("msg-local-finalize");
|
|
||||||
|
|
||||||
Some(json!({
|
|
||||||
"id": id,
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"model": model,
|
|
||||||
"content": content,
|
|
||||||
"stop_reason": stop_reason,
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": input_tokens,
|
|
||||||
"output_tokens": output_tokens,
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_openai_assistant_text(content: Option<&Value>) -> Option<String> {
|
|
||||||
match content? {
|
|
||||||
Value::Null => Some(String::new()),
|
|
||||||
Value::String(text) => Some(text.clone()),
|
|
||||||
Value::Array(parts) => {
|
|
||||||
let mut text = String::new();
|
|
||||||
for part in parts {
|
|
||||||
let part = part.as_object()?;
|
|
||||||
let part_type = part
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if matches!(part_type.as_str(), "text" | "output_text") {
|
|
||||||
if let Some(piece) = part.get("text").and_then(Value::as_str) {
|
|
||||||
text.push_str(piece);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(text)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_openai_function_arguments(arguments: Option<&Value>) -> Option<Value> {
|
|
||||||
match arguments.cloned().unwrap_or(Value::Object(Map::new())) {
|
|
||||||
Value::String(text) => serde_json::from_str(&text)
|
|
||||||
.ok()
|
|
||||||
.or(Some(Value::String(text))),
|
|
||||||
other => Some(other),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn aggregate_claude_stream_sync_response(body: &[u8]) -> Option<Value> {
|
|
||||||
let events = parse_stream_json_events(body)?;
|
|
||||||
if events.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut message_object: Option<Map<String, Value>> = None;
|
|
||||||
let mut content_blocks: BTreeMap<usize, ClaudeContentBlockState> = BTreeMap::new();
|
|
||||||
let mut usage: Option<Value> = None;
|
|
||||||
let mut saw_message_start = false;
|
|
||||||
|
|
||||||
for event in events {
|
|
||||||
let event_object = event.as_object()?;
|
|
||||||
let event_type = event_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
match event_type {
|
|
||||||
"message_start" => {
|
|
||||||
let mut message = event_object.get("message")?.as_object()?.clone();
|
|
||||||
usage = message.remove("usage");
|
|
||||||
message_object = Some(message);
|
|
||||||
saw_message_start = true;
|
|
||||||
}
|
|
||||||
"content_block_start" => {
|
|
||||||
let index = event_object
|
|
||||||
.get("index")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|value| value as usize)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let object = event_object
|
|
||||||
.get("content_block")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
content_blocks.insert(
|
|
||||||
index,
|
|
||||||
ClaudeContentBlockState {
|
|
||||||
object,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"content_block_delta" => {
|
|
||||||
let index = event_object
|
|
||||||
.get("index")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|value| value as usize)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let state = content_blocks.entry(index).or_default();
|
|
||||||
let Some(delta) = event_object.get("delta").and_then(Value::as_object) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
match delta
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
{
|
|
||||||
"text_delta" => {
|
|
||||||
if let Some(text) = delta.get("text").and_then(Value::as_str) {
|
|
||||||
state.text.push_str(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"input_json_delta" => {
|
|
||||||
if let Some(partial_json) =
|
|
||||||
delta.get("partial_json").and_then(Value::as_str)
|
|
||||||
{
|
|
||||||
state.partial_json.push_str(partial_json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"message_delta" => {
|
|
||||||
if let Some(message) = message_object.as_mut() {
|
|
||||||
if let Some(delta) = event_object.get("delta").and_then(Value::as_object) {
|
|
||||||
if let Some(stop_reason) = delta.get("stop_reason") {
|
|
||||||
message.insert("stop_reason".to_string(), stop_reason.clone());
|
|
||||||
}
|
|
||||||
if let Some(stop_sequence) = delta.get("stop_sequence") {
|
|
||||||
message.insert("stop_sequence".to_string(), stop_sequence.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(delta_usage) = event_object.get("usage") {
|
|
||||||
usage = Some(delta_usage.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"message_stop" => {}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !saw_message_start {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut message = message_object?;
|
|
||||||
let mut content = Vec::with_capacity(content_blocks.len());
|
|
||||||
for (_index, state) in content_blocks {
|
|
||||||
let mut block = state.object;
|
|
||||||
let block_type = block
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("text")
|
|
||||||
.to_string();
|
|
||||||
match block_type.as_str() {
|
|
||||||
"text" => {
|
|
||||||
block.insert(
|
|
||||||
"text".to_string(),
|
|
||||||
Value::String(if state.text.is_empty() {
|
|
||||||
block
|
|
||||||
.get("text")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string()
|
|
||||||
} else {
|
|
||||||
state.text
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"tool_use" => {
|
|
||||||
if !state.partial_json.is_empty() {
|
|
||||||
let input = serde_json::from_str::<Value>(&state.partial_json)
|
|
||||||
.unwrap_or(Value::String(state.partial_json));
|
|
||||||
block.insert("input".to_string(), input);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if !state.text.is_empty() {
|
|
||||||
block.insert("text".to_string(), Value::String(state.text));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
content.push(Value::Object(block));
|
|
||||||
}
|
|
||||||
message.insert("content".to_string(), Value::Array(content));
|
|
||||||
if let Some(usage_value) = usage {
|
|
||||||
message.insert("usage".to_string(), usage_value);
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(Value::Object(message))
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
use base64::Engine as _;
|
|
||||||
|
|
||||||
use super::chat::aggregate_claude_stream_sync_response;
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
use crate::ai_pipeline::conversion::response::build_openai_cli_response;
|
|
||||||
use crate::ai_pipeline::finalize::common::{
|
|
||||||
build_generated_tool_call_id, build_local_success_outcome, canonicalize_tool_arguments,
|
|
||||||
local_finalize_allows_envelope, unwrap_local_finalize_response_value,
|
|
||||||
LocalCoreSyncFinalizeOutcome,
|
|
||||||
};
|
|
||||||
use crate::control::GatewayControlDecision;
|
|
||||||
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
|
||||||
|
|
||||||
pub(crate) fn maybe_build_local_claude_cli_stream_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if payload.report_kind != "claude_cli_sync_finalize" || payload.status_code >= 400 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let needs_conversion = report_context
|
|
||||||
.get("needs_conversion")
|
|
||||||
.and_then(Value::as_bool)
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !local_finalize_allows_envelope(report_context) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
if provider_api_format != "claude:cli" || client_api_format != "claude:cli" || needs_conversion
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(body_base64) = payload.body_base64.as_deref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let body_bytes = base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(body_base64)
|
|
||||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
|
||||||
let body_json =
|
|
||||||
match aggregate_provider_claude_cli_stream_sync_response(&body_bytes, report_context)? {
|
|
||||||
Some(body_json) => body_json,
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
let Some(body_json) = unwrap_local_finalize_response_value(body_json, report_context)? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome(
|
|
||||||
trace_id, decision, payload, body_json,
|
|
||||||
)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn aggregate_provider_claude_cli_stream_sync_response(
|
|
||||||
body_bytes: &[u8],
|
|
||||||
_report_context: &Value,
|
|
||||||
) -> Result<Option<Value>, GatewayError> {
|
|
||||||
Ok(aggregate_claude_stream_sync_response(body_bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn convert_claude_cli_response_to_openai_cli(
|
|
||||||
body_json: &Value,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let body = body_json.as_object()?;
|
|
||||||
let content = body.get("content")?.as_array()?;
|
|
||||||
let mut text = String::new();
|
|
||||||
let mut function_calls = Vec::new();
|
|
||||||
for (index, block) in content.iter().enumerate() {
|
|
||||||
let block = block.as_object()?;
|
|
||||||
match block.get("type")?.as_str()? {
|
|
||||||
"text" => {
|
|
||||||
text.push_str(block.get("text")?.as_str()?);
|
|
||||||
}
|
|
||||||
"tool_use" => {
|
|
||||||
let tool_name = block.get("name")?.as_str()?;
|
|
||||||
let call_id = block
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
|
||||||
let arguments = canonicalize_tool_arguments(block.get("input").cloned());
|
|
||||||
function_calls.push(json!({
|
|
||||||
"type": "function_call",
|
|
||||||
"call_id": call_id,
|
|
||||||
"name": tool_name,
|
|
||||||
"arguments": arguments,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
_ => return None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let usage = body.get("usage").and_then(Value::as_object);
|
|
||||||
let prompt_tokens = usage
|
|
||||||
.and_then(|value| value.get("input_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let output_tokens = usage
|
|
||||||
.and_then(|value| value.get("output_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let total_tokens = prompt_tokens + output_tokens;
|
|
||||||
let model = body
|
|
||||||
.get("model")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
|
||||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let response_id = body
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("resp-local-finalize");
|
|
||||||
|
|
||||||
Some(build_openai_cli_response(
|
|
||||||
response_id,
|
|
||||||
model,
|
|
||||||
&text,
|
|
||||||
function_calls,
|
|
||||||
prompt_tokens,
|
|
||||||
output_tokens,
|
|
||||||
total_tokens,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
mod chat;
|
|
||||||
mod cli;
|
|
||||||
|
|
||||||
pub(crate) use chat::{
|
|
||||||
aggregate_claude_stream_sync_response, convert_claude_chat_response_to_openai_chat,
|
|
||||||
convert_openai_chat_response_to_claude_chat, maybe_build_local_claude_stream_sync_response,
|
|
||||||
maybe_build_local_claude_sync_response,
|
|
||||||
};
|
|
||||||
pub(crate) use cli::{
|
|
||||||
convert_claude_cli_response_to_openai_cli, maybe_build_local_claude_cli_stream_sync_response,
|
|
||||||
};
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
pub(super) mod stream;
|
pub(super) mod stream;
|
||||||
pub(super) mod sync;
|
|
||||||
|
|
||||||
pub(crate) use sync::{
|
pub(crate) use crate::ai_pipeline::conversion::response::{
|
||||||
aggregate_gemini_stream_sync_response, convert_gemini_chat_response_to_openai_chat,
|
convert_gemini_chat_response_to_openai_chat, convert_gemini_cli_response_to_openai_cli,
|
||||||
convert_gemini_cli_response_to_openai_cli, convert_openai_chat_response_to_gemini_chat,
|
convert_openai_chat_response_to_gemini_chat,
|
||||||
maybe_build_local_gemini_cli_stream_sync_response,
|
|
||||||
maybe_build_local_gemini_stream_sync_response, maybe_build_local_gemini_sync_response,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,404 +1,3 @@
|
|||||||
use std::collections::BTreeMap;
|
pub(crate) use aether_ai_pipeline::finalize::standard::gemini::stream::{
|
||||||
|
GeminiClientEmitter, GeminiProviderState,
|
||||||
use serde_json::{json, Map, Value};
|
|
||||||
|
|
||||||
use crate::ai_pipeline::finalize::common::{
|
|
||||||
build_generated_tool_call_id, canonicalize_tool_arguments,
|
|
||||||
};
|
};
|
||||||
use crate::ai_pipeline::finalize::sse::encode_json_sse;
|
|
||||||
use crate::GatewayError;
|
|
||||||
|
|
||||||
use crate::ai_pipeline::finalize::standard::stream::common::*;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct GeminiProviderToolState {
|
|
||||||
call_id: String,
|
|
||||||
name: String,
|
|
||||||
arguments: String,
|
|
||||||
started_emitted: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub(crate) struct GeminiProviderState {
|
|
||||||
response_id: Option<String>,
|
|
||||||
model: Option<String>,
|
|
||||||
started: bool,
|
|
||||||
finished: bool,
|
|
||||||
text_parts: BTreeMap<usize, String>,
|
|
||||||
tool_calls: BTreeMap<usize, GeminiProviderToolState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GeminiProviderState {
|
|
||||||
fn identity(&self, report_context: &Value) -> (String, String) {
|
|
||||||
resolve_identity(
|
|
||||||
self.response_id.as_deref(),
|
|
||||||
self.model.as_deref(),
|
|
||||||
report_context,
|
|
||||||
"resp-local-stream",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_started(&mut self, report_context: &Value, out: &mut Vec<CanonicalStreamFrame>) {
|
|
||||||
if self.started {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::Start,
|
|
||||||
});
|
|
||||||
self.started = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn push_line(
|
|
||||||
&mut self,
|
|
||||||
report_context: &Value,
|
|
||||||
line: Vec<u8>,
|
|
||||||
) -> Result<Vec<CanonicalStreamFrame>, GatewayError> {
|
|
||||||
let Some(value) = decode_json_data_line(&line) else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
let Some(raw_event_object) = value.as_object() else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
if let Some(id) = raw_event_object.get("responseId").and_then(Value::as_str) {
|
|
||||||
self.response_id = Some(id.to_string());
|
|
||||||
}
|
|
||||||
let event_object = raw_event_object
|
|
||||||
.get("response")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.filter(|response| response.contains_key("candidates"))
|
|
||||||
.unwrap_or(raw_event_object);
|
|
||||||
if let Some(id) = event_object.get("responseId").and_then(Value::as_str) {
|
|
||||||
self.response_id = Some(id.to_string());
|
|
||||||
}
|
|
||||||
if let Some(version) = event_object.get("modelVersion").and_then(Value::as_str) {
|
|
||||||
self.model = Some(version.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let Some(candidates) = event_object.get("candidates").and_then(Value::as_array) else {
|
|
||||||
return Ok(out);
|
|
||||||
};
|
|
||||||
|
|
||||||
for candidate in candidates {
|
|
||||||
let Some(candidate_object) = candidate.as_object() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(content) = candidate_object.get("content").and_then(Value::as_object) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(parts) = content.get("parts").and_then(Value::as_array) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if !parts.is_empty() {
|
|
||||||
self.ensure_started(report_context, &mut out);
|
|
||||||
}
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
for (index, part) in parts.iter().enumerate() {
|
|
||||||
let Some(part_object) = part.as_object() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
|
|
||||||
let previous = self.text_parts.entry(index).or_default();
|
|
||||||
let delta = if text.starts_with(previous.as_str()) {
|
|
||||||
text[previous.len()..].to_string()
|
|
||||||
} else if previous.as_str() == text {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
text.to_string()
|
|
||||||
};
|
|
||||||
*previous = text.to_string();
|
|
||||||
if !delta.is_empty() {
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id: id.clone(),
|
|
||||||
model: model.clone(),
|
|
||||||
event: CanonicalStreamEvent::TextDelta(delta),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(function_call) =
|
|
||||||
part_object.get("functionCall").and_then(Value::as_object)
|
|
||||||
else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let tool_state = self.tool_calls.entry(index).or_default();
|
|
||||||
tool_state.call_id = function_call
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_else(|| tool_state.call_id.as_str())
|
|
||||||
.to_string();
|
|
||||||
tool_state.name = function_call
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_else(|| tool_state.name.as_str())
|
|
||||||
.to_string();
|
|
||||||
if !tool_state.started_emitted {
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id: id.clone(),
|
|
||||||
model: model.clone(),
|
|
||||||
event: CanonicalStreamEvent::ToolCallStart {
|
|
||||||
index,
|
|
||||||
call_id: if tool_state.call_id.is_empty() {
|
|
||||||
build_generated_tool_call_id(index)
|
|
||||||
} else {
|
|
||||||
tool_state.call_id.clone()
|
|
||||||
},
|
|
||||||
name: if tool_state.name.is_empty() {
|
|
||||||
"unknown".to_string()
|
|
||||||
} else {
|
|
||||||
tool_state.name.clone()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
tool_state.started_emitted = true;
|
|
||||||
}
|
|
||||||
let arguments = canonicalize_tool_arguments(function_call.get("args").cloned());
|
|
||||||
let delta = if arguments.starts_with(&tool_state.arguments) {
|
|
||||||
arguments[tool_state.arguments.len()..].to_string()
|
|
||||||
} else if tool_state.arguments == arguments {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
arguments.clone()
|
|
||||||
};
|
|
||||||
tool_state.arguments = arguments;
|
|
||||||
if !delta.is_empty() {
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id: id.clone(),
|
|
||||||
model: model.clone(),
|
|
||||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
|
||||||
index,
|
|
||||||
arguments: delta,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(finish_reason) =
|
|
||||||
candidate_object.get("finishReason").and_then(Value::as_str)
|
|
||||||
{
|
|
||||||
let has_tool_calls = !self.tool_calls.is_empty();
|
|
||||||
let mut finish_reason = normalize_openai_finish_reason(match finish_reason {
|
|
||||||
"STOP" => Some("stop"),
|
|
||||||
"MAX_TOKENS" => Some("length"),
|
|
||||||
"SAFETY" => Some("content_filter"),
|
|
||||||
other => Some(other),
|
|
||||||
});
|
|
||||||
if has_tool_calls && finish_reason.as_deref().is_none_or(|value| value == "stop") {
|
|
||||||
finish_reason = Some("tool_calls".to_string());
|
|
||||||
}
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::Finish {
|
|
||||||
finish_reason,
|
|
||||||
usage: canonical_usage_from_gemini_usage(event_object.get("usageMetadata")),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
self.finished = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn finish(
|
|
||||||
&mut self,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Result<Vec<CanonicalStreamFrame>, GatewayError> {
|
|
||||||
if !self.started || self.finished {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
self.finished = true;
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
Ok(vec![CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::Finish {
|
|
||||||
finish_reason: None,
|
|
||||||
usage: None,
|
|
||||||
},
|
|
||||||
}])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct GeminiClientToolState {
|
|
||||||
call_id: String,
|
|
||||||
name: String,
|
|
||||||
arguments: String,
|
|
||||||
emitted: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub(crate) struct GeminiClientEmitter {
|
|
||||||
response_id: Option<String>,
|
|
||||||
model: Option<String>,
|
|
||||||
finished: bool,
|
|
||||||
tool_calls: BTreeMap<usize, GeminiClientToolState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GeminiClientEmitter {
|
|
||||||
fn update_identity(&mut self, frame: &CanonicalStreamFrame) {
|
|
||||||
self.response_id = Some(frame.id.clone());
|
|
||||||
self.model = Some(frame.model.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_candidate(
|
|
||||||
&self,
|
|
||||||
parts: Vec<Value>,
|
|
||||||
finish_reason: Option<&str>,
|
|
||||||
usage: Option<CanonicalUsage>,
|
|
||||||
) -> Result<Vec<u8>, GatewayError> {
|
|
||||||
let mut candidate = Map::new();
|
|
||||||
candidate.insert(
|
|
||||||
"content".to_string(),
|
|
||||||
json!({
|
|
||||||
"role": "model",
|
|
||||||
"parts": parts,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
candidate.insert("index".to_string(), Value::from(0_u64));
|
|
||||||
if let Some(finish_reason) = finish_reason {
|
|
||||||
candidate.insert(
|
|
||||||
"finishReason".to_string(),
|
|
||||||
Value::String(map_openai_finish_reason_to_gemini(Some(finish_reason)).to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let mut response = Map::new();
|
|
||||||
response.insert(
|
|
||||||
"responseId".to_string(),
|
|
||||||
Value::String(
|
|
||||||
self.response_id
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "resp-local-stream".to_string()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
response.insert(
|
|
||||||
"modelVersion".to_string(),
|
|
||||||
Value::String(self.model.clone().unwrap_or_else(|| "unknown".to_string())),
|
|
||||||
);
|
|
||||||
response.insert(
|
|
||||||
"candidates".to_string(),
|
|
||||||
Value::Array(vec![Value::Object(candidate)]),
|
|
||||||
);
|
|
||||||
if let Some(usage) = usage {
|
|
||||||
response.insert(
|
|
||||||
"usageMetadata".to_string(),
|
|
||||||
json!({
|
|
||||||
"promptTokenCount": usage.input_tokens,
|
|
||||||
"candidatesTokenCount": usage.output_tokens,
|
|
||||||
"totalTokenCount": usage.total_tokens,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
encode_json_sse(None, &Value::Object(response))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flush_pending_tool_calls(&mut self) -> Result<Vec<u8>, GatewayError> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let mut pending = Vec::new();
|
|
||||||
for (index, tool_call) in &mut self.tool_calls {
|
|
||||||
if tool_call.emitted {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let args_value = parse_json_arguments_value(&tool_call.arguments)
|
|
||||||
.unwrap_or_else(|| Value::Object(Map::new()));
|
|
||||||
tool_call.emitted = true;
|
|
||||||
pending.push(json!({
|
|
||||||
"functionCall": {
|
|
||||||
"id": if tool_call.call_id.is_empty() {
|
|
||||||
build_generated_tool_call_id(*index)
|
|
||||||
} else {
|
|
||||||
tool_call.call_id.clone()
|
|
||||||
},
|
|
||||||
"name": if tool_call.name.is_empty() {
|
|
||||||
"unknown".to_string()
|
|
||||||
} else {
|
|
||||||
tool_call.name.clone()
|
|
||||||
},
|
|
||||||
"args": args_value,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
for part in pending {
|
|
||||||
out.extend(self.emit_candidate(vec![part], None, None)?);
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, GatewayError> {
|
|
||||||
self.update_identity(&frame);
|
|
||||||
match frame.event {
|
|
||||||
CanonicalStreamEvent::Start => Ok(Vec::new()),
|
|
||||||
CanonicalStreamEvent::TextDelta(text) => {
|
|
||||||
self.emit_candidate(vec![json!({ "text": text })], None, None)
|
|
||||||
}
|
|
||||||
CanonicalStreamEvent::ToolCallStart {
|
|
||||||
index,
|
|
||||||
call_id,
|
|
||||||
name,
|
|
||||||
} => {
|
|
||||||
let state = self.tool_calls.entry(index).or_default();
|
|
||||||
state.call_id = call_id;
|
|
||||||
state.name = name;
|
|
||||||
Ok(Vec::new())
|
|
||||||
}
|
|
||||||
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
|
||||||
let emitted_part = {
|
|
||||||
let state = self.tool_calls.entry(index).or_default();
|
|
||||||
state.arguments.push_str(&arguments);
|
|
||||||
if state.emitted {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
let args_value = parse_json_arguments_value(&state.arguments);
|
|
||||||
args_value.map(|args_value| {
|
|
||||||
state.emitted = true;
|
|
||||||
json!({
|
|
||||||
"functionCall": {
|
|
||||||
"id": if state.call_id.is_empty() {
|
|
||||||
build_generated_tool_call_id(index)
|
|
||||||
} else {
|
|
||||||
state.call_id.clone()
|
|
||||||
},
|
|
||||||
"name": if state.name.is_empty() {
|
|
||||||
"unknown".to_string()
|
|
||||||
} else {
|
|
||||||
state.name.clone()
|
|
||||||
},
|
|
||||||
"args": args_value,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let Some(part) = emitted_part else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
self.emit_candidate(vec![part], None, None)
|
|
||||||
}
|
|
||||||
CanonicalStreamEvent::Finish {
|
|
||||||
finish_reason,
|
|
||||||
usage,
|
|
||||||
} => {
|
|
||||||
if self.finished {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
let mut out = self.flush_pending_tool_calls()?;
|
|
||||||
out.extend(self.emit_candidate(vec![], finish_reason.as_deref(), usage)?);
|
|
||||||
self.finished = true;
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn finish(&mut self) -> Result<Vec<u8>, GatewayError> {
|
|
||||||
if self.finished {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
let out = self.flush_pending_tool_calls()?;
|
|
||||||
self.finished = true;
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,439 +0,0 @@
|
|||||||
use base64::Engine as _;
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use serde_json::{json, Map, Value};
|
|
||||||
|
|
||||||
use crate::ai_pipeline::finalize::common::{
|
|
||||||
build_generated_tool_call_id, build_local_success_outcome, canonicalize_tool_arguments,
|
|
||||||
local_finalize_allows_envelope, parse_stream_json_events, unwrap_local_finalize_response_value,
|
|
||||||
LocalCoreSyncFinalizeOutcome,
|
|
||||||
};
|
|
||||||
use crate::control::GatewayControlDecision;
|
|
||||||
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
|
||||||
|
|
||||||
pub(crate) fn maybe_build_local_gemini_stream_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if payload.report_kind != "gemini_chat_sync_finalize" || payload.status_code >= 400 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let needs_conversion = report_context
|
|
||||||
.get("needs_conversion")
|
|
||||||
.and_then(Value::as_bool)
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !local_finalize_allows_envelope(report_context) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
if provider_api_format != "gemini:chat"
|
|
||||||
|| client_api_format != "gemini:chat"
|
|
||||||
|| needs_conversion
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(body_base64) = payload.body_base64.as_deref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let body_bytes = base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(body_base64)
|
|
||||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
|
||||||
let body_json = match aggregate_gemini_stream_sync_response(&body_bytes) {
|
|
||||||
Some(body_json) => body_json,
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
let Some(body_json) = unwrap_local_finalize_response_value(body_json, report_context)? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome(
|
|
||||||
trace_id, decision, payload, body_json,
|
|
||||||
)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn maybe_build_local_gemini_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if payload.report_kind != "gemini_chat_sync_finalize" || payload.status_code >= 400 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let needs_conversion = report_context
|
|
||||||
.get("needs_conversion")
|
|
||||||
.and_then(Value::as_bool)
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !local_finalize_allows_envelope(report_context) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
if provider_api_format != "gemini:chat"
|
|
||||||
|| client_api_format != "gemini:chat"
|
|
||||||
|| needs_conversion
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(body_json) = payload.body_json.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome(
|
|
||||||
trace_id, decision, payload, body_json,
|
|
||||||
)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn convert_gemini_chat_response_to_openai_chat(
|
|
||||||
body_json: &Value,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let body = body_json.as_object()?;
|
|
||||||
let candidates = body.get("candidates")?.as_array()?;
|
|
||||||
let first_candidate = candidates.first()?.as_object()?;
|
|
||||||
let content = first_candidate.get("content")?.as_object()?;
|
|
||||||
let parts = content.get("parts")?.as_array()?;
|
|
||||||
let mut text = String::new();
|
|
||||||
let mut tool_calls = Vec::new();
|
|
||||||
for (index, part) in parts.iter().enumerate() {
|
|
||||||
let part = part.as_object()?;
|
|
||||||
if let Some(piece) = part.get("text").and_then(Value::as_str) {
|
|
||||||
text.push_str(piece);
|
|
||||||
} else if let Some(function_call) = part.get("functionCall").and_then(Value::as_object) {
|
|
||||||
let tool_name = function_call.get("name")?.as_str()?;
|
|
||||||
let tool_id = function_call
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
|
||||||
let arguments = canonicalize_tool_arguments(function_call.get("args").cloned());
|
|
||||||
tool_calls.push(json!({
|
|
||||||
"id": tool_id,
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": tool_name,
|
|
||||||
"arguments": arguments,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut finish_reason = match first_candidate.get("finishReason").and_then(Value::as_str) {
|
|
||||||
Some("STOP") => Some("stop"),
|
|
||||||
Some("MAX_TOKENS") => Some("length"),
|
|
||||||
Some("SAFETY") => Some("content_filter"),
|
|
||||||
Some(other) if !other.is_empty() => Some(other),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
if !tool_calls.is_empty() && finish_reason.is_none_or(|reason| reason == "stop") {
|
|
||||||
finish_reason = Some("tool_calls");
|
|
||||||
}
|
|
||||||
let usage = body.get("usageMetadata").and_then(Value::as_object);
|
|
||||||
let prompt_tokens = usage
|
|
||||||
.and_then(|value| value.get("promptTokenCount"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let completion_tokens = usage
|
|
||||||
.and_then(|value| value.get("candidatesTokenCount"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let total_tokens = usage
|
|
||||||
.and_then(|value| value.get("totalTokenCount"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(prompt_tokens + completion_tokens);
|
|
||||||
let model = body
|
|
||||||
.get("modelVersion")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
|
||||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let id = body
|
|
||||||
.get("responseId")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("chatcmpl-local-finalize");
|
|
||||||
let message_content = if text.is_empty() && !tool_calls.is_empty() {
|
|
||||||
Value::Null
|
|
||||||
} else {
|
|
||||||
Value::String(text)
|
|
||||||
};
|
|
||||||
let mut message = Map::new();
|
|
||||||
message.insert("role".to_string(), Value::String("assistant".to_string()));
|
|
||||||
message.insert("content".to_string(), message_content);
|
|
||||||
if !tool_calls.is_empty() {
|
|
||||||
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
|
||||||
}
|
|
||||||
Some(json!({
|
|
||||||
"id": id,
|
|
||||||
"object": "chat.completion",
|
|
||||||
"model": model,
|
|
||||||
"choices": [{
|
|
||||||
"index": first_candidate.get("index").and_then(Value::as_u64).unwrap_or(0),
|
|
||||||
"message": Value::Object(message),
|
|
||||||
"finish_reason": finish_reason,
|
|
||||||
}],
|
|
||||||
"usage": {
|
|
||||||
"prompt_tokens": prompt_tokens,
|
|
||||||
"completion_tokens": completion_tokens,
|
|
||||||
"total_tokens": total_tokens,
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn convert_openai_chat_response_to_gemini_chat(
|
|
||||||
body_json: &Value,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let body = body_json.as_object()?;
|
|
||||||
let choices = body.get("choices")?.as_array()?;
|
|
||||||
let first_choice = choices.first()?.as_object()?;
|
|
||||||
let message = first_choice.get("message")?.as_object()?;
|
|
||||||
let mut parts = Vec::new();
|
|
||||||
|
|
||||||
if let Some(text) = extract_openai_assistant_text(message.get("content")) {
|
|
||||||
if !text.trim().is_empty() {
|
|
||||||
parts.push(json!({ "text": text }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
|
|
||||||
for (index, tool_call) in tool_call_values.iter().enumerate() {
|
|
||||||
let tool_call = tool_call.as_object()?;
|
|
||||||
let function = tool_call.get("function")?.as_object()?;
|
|
||||||
let tool_name = function
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let call_id = tool_call
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
|
||||||
parts.push(json!({
|
|
||||||
"functionCall": {
|
|
||||||
"id": call_id,
|
|
||||||
"name": tool_name,
|
|
||||||
"args": parse_openai_function_arguments(function.get("arguments"))?,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if parts.is_empty() {
|
|
||||||
parts.push(json!({ "text": "" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
let usage = body.get("usage").and_then(Value::as_object);
|
|
||||||
let prompt_tokens = usage
|
|
||||||
.and_then(|value| value.get("prompt_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let completion_tokens = usage
|
|
||||||
.and_then(|value| value.get("completion_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let total_tokens = usage
|
|
||||||
.and_then(|value| value.get("total_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(prompt_tokens + completion_tokens);
|
|
||||||
let mut finish_reason = match first_choice.get("finish_reason").and_then(Value::as_str) {
|
|
||||||
Some("stop") | None => "STOP",
|
|
||||||
Some("length") => "MAX_TOKENS",
|
|
||||||
Some("content_filter") => "SAFETY",
|
|
||||||
Some("tool_calls") | Some("function_call") => "STOP",
|
|
||||||
Some(other) => other,
|
|
||||||
};
|
|
||||||
if parts.iter().any(|part| part.get("functionCall").is_some()) {
|
|
||||||
finish_reason = "STOP";
|
|
||||||
}
|
|
||||||
let model = body
|
|
||||||
.get("model")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
|
||||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let response_id = body
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("resp-local-finalize");
|
|
||||||
|
|
||||||
Some(json!({
|
|
||||||
"responseId": response_id,
|
|
||||||
"modelVersion": model,
|
|
||||||
"candidates": [{
|
|
||||||
"content": {
|
|
||||||
"role": "model",
|
|
||||||
"parts": parts,
|
|
||||||
},
|
|
||||||
"finishReason": finish_reason,
|
|
||||||
"index": 0,
|
|
||||||
}],
|
|
||||||
"usageMetadata": {
|
|
||||||
"promptTokenCount": prompt_tokens,
|
|
||||||
"candidatesTokenCount": completion_tokens,
|
|
||||||
"totalTokenCount": total_tokens,
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_openai_assistant_text(content: Option<&Value>) -> Option<String> {
|
|
||||||
match content? {
|
|
||||||
Value::Null => Some(String::new()),
|
|
||||||
Value::String(text) => Some(text.clone()),
|
|
||||||
Value::Array(parts) => {
|
|
||||||
let mut text = String::new();
|
|
||||||
for part in parts {
|
|
||||||
let part = part.as_object()?;
|
|
||||||
let part_type = part
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if matches!(part_type.as_str(), "text" | "output_text") {
|
|
||||||
if let Some(piece) = part.get("text").and_then(Value::as_str) {
|
|
||||||
text.push_str(piece);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(text)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_openai_function_arguments(arguments: Option<&Value>) -> Option<Value> {
|
|
||||||
match arguments.cloned().unwrap_or(Value::Object(Map::new())) {
|
|
||||||
Value::String(text) => serde_json::from_str(&text)
|
|
||||||
.ok()
|
|
||||||
.or(Some(Value::String(text))),
|
|
||||||
other => Some(other),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> {
|
|
||||||
let events = parse_stream_json_events(body)?;
|
|
||||||
if events.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut candidates: BTreeMap<usize, Value> = BTreeMap::new();
|
|
||||||
let mut response_id: Option<Value> = None;
|
|
||||||
let mut private_response_id: Option<Value> = None;
|
|
||||||
let mut model_version: Option<Value> = None;
|
|
||||||
let mut usage_metadata: Option<Value> = None;
|
|
||||||
let mut prompt_feedback: Option<Value> = None;
|
|
||||||
let mut saw_candidate = false;
|
|
||||||
|
|
||||||
for event in events {
|
|
||||||
let raw_event_object = event.as_object()?;
|
|
||||||
if let Some(id) = raw_event_object.get("responseId") {
|
|
||||||
response_id = Some(id.clone());
|
|
||||||
}
|
|
||||||
if let Some(id) = raw_event_object.get("_v1internal_response_id") {
|
|
||||||
private_response_id = Some(id.clone());
|
|
||||||
}
|
|
||||||
let event_object = if let Some(response) = raw_event_object
|
|
||||||
.get("response")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.filter(|response| response.contains_key("candidates"))
|
|
||||||
{
|
|
||||||
response
|
|
||||||
} else {
|
|
||||||
raw_event_object
|
|
||||||
};
|
|
||||||
if let Some(id) = event_object.get("responseId") {
|
|
||||||
response_id = Some(id.clone());
|
|
||||||
}
|
|
||||||
if let Some(id) = event_object.get("_v1internal_response_id") {
|
|
||||||
private_response_id = Some(id.clone());
|
|
||||||
}
|
|
||||||
if let Some(version) = event_object.get("modelVersion") {
|
|
||||||
model_version = Some(version.clone());
|
|
||||||
}
|
|
||||||
if let Some(usage) = event_object.get("usageMetadata") {
|
|
||||||
usage_metadata = Some(usage.clone());
|
|
||||||
}
|
|
||||||
if let Some(prompt) = event_object.get("promptFeedback") {
|
|
||||||
prompt_feedback = Some(prompt.clone());
|
|
||||||
}
|
|
||||||
let Some(event_candidates) = event_object.get("candidates").and_then(Value::as_array)
|
|
||||||
else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for candidate in event_candidates {
|
|
||||||
let Some(candidate_object) = candidate.as_object() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let index = candidate_object
|
|
||||||
.get("index")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|value| value as usize)
|
|
||||||
.unwrap_or(0);
|
|
||||||
candidates.insert(index, Value::Object(candidate_object.clone()));
|
|
||||||
saw_candidate = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !saw_candidate {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut response = Map::new();
|
|
||||||
if let Some(response_id) = response_id {
|
|
||||||
response.insert("responseId".to_string(), response_id);
|
|
||||||
}
|
|
||||||
if let Some(private_response_id) = private_response_id {
|
|
||||||
response.insert("_v1internal_response_id".to_string(), private_response_id);
|
|
||||||
}
|
|
||||||
response.insert(
|
|
||||||
"candidates".to_string(),
|
|
||||||
Value::Array(candidates.into_values().collect()),
|
|
||||||
);
|
|
||||||
if let Some(version) = model_version {
|
|
||||||
response.insert("modelVersion".to_string(), version);
|
|
||||||
}
|
|
||||||
if let Some(usage) = usage_metadata {
|
|
||||||
response.insert("usageMetadata".to_string(), usage);
|
|
||||||
}
|
|
||||||
if let Some(prompt) = prompt_feedback {
|
|
||||||
response.insert("promptFeedback".to_string(), prompt);
|
|
||||||
}
|
|
||||||
Some(Value::Object(response))
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
use base64::Engine as _;
|
|
||||||
|
|
||||||
use super::chat::aggregate_gemini_stream_sync_response;
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
use crate::ai_pipeline::conversion::response::build_openai_cli_response;
|
|
||||||
use crate::ai_pipeline::finalize::common::{
|
|
||||||
build_generated_tool_call_id, build_local_success_outcome, canonicalize_tool_arguments,
|
|
||||||
local_finalize_allows_envelope, unwrap_local_finalize_response_value,
|
|
||||||
LocalCoreSyncFinalizeOutcome,
|
|
||||||
};
|
|
||||||
use crate::control::GatewayControlDecision;
|
|
||||||
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
|
||||||
|
|
||||||
pub(crate) fn maybe_build_local_gemini_cli_stream_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if payload.report_kind != "gemini_cli_sync_finalize" || payload.status_code >= 400 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let needs_conversion = report_context
|
|
||||||
.get("needs_conversion")
|
|
||||||
.and_then(Value::as_bool)
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !local_finalize_allows_envelope(report_context) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
if provider_api_format != "gemini:cli" || client_api_format != "gemini:cli" || needs_conversion
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(body_base64) = payload.body_base64.as_deref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let body_bytes = base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(body_base64)
|
|
||||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
|
||||||
let body_json = match aggregate_gemini_stream_sync_response(&body_bytes) {
|
|
||||||
Some(body_json) => body_json,
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
let Some(body_json) = unwrap_local_finalize_response_value(body_json, report_context)? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome(
|
|
||||||
trace_id, decision, payload, body_json,
|
|
||||||
)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn convert_gemini_cli_response_to_openai_cli(
|
|
||||||
body_json: &Value,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let body = body_json.as_object()?;
|
|
||||||
let candidates = body.get("candidates")?.as_array()?;
|
|
||||||
let first_candidate = candidates.first()?.as_object()?;
|
|
||||||
let content = first_candidate.get("content")?.as_object()?;
|
|
||||||
let parts = content.get("parts")?.as_array()?;
|
|
||||||
let mut text = String::new();
|
|
||||||
let mut function_calls = Vec::new();
|
|
||||||
for (index, part) in parts.iter().enumerate() {
|
|
||||||
let part = part.as_object()?;
|
|
||||||
if let Some(piece) = part.get("text").and_then(Value::as_str) {
|
|
||||||
text.push_str(piece);
|
|
||||||
} else if let Some(function_call) = part.get("functionCall").and_then(Value::as_object) {
|
|
||||||
let tool_name = function_call.get("name")?.as_str()?;
|
|
||||||
let call_id = function_call
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
|
||||||
let arguments = canonicalize_tool_arguments(function_call.get("args").cloned());
|
|
||||||
function_calls.push(json!({
|
|
||||||
"type": "function_call",
|
|
||||||
"call_id": call_id,
|
|
||||||
"name": tool_name,
|
|
||||||
"arguments": arguments,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let usage = body.get("usageMetadata").and_then(Value::as_object);
|
|
||||||
let prompt_tokens = usage
|
|
||||||
.and_then(|value| value.get("promptTokenCount"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let output_tokens = usage
|
|
||||||
.map(|value| {
|
|
||||||
value
|
|
||||||
.get("candidatesTokenCount")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0)
|
|
||||||
+ value
|
|
||||||
.get("thoughtsTokenCount")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0)
|
|
||||||
})
|
|
||||||
.unwrap_or(0);
|
|
||||||
let total_tokens = usage
|
|
||||||
.and_then(|value| value.get("totalTokenCount"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(prompt_tokens + output_tokens);
|
|
||||||
let model = body
|
|
||||||
.get("modelVersion")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
|
||||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let response_id = body
|
|
||||||
.get("responseId")
|
|
||||||
.or_else(|| body.get("_v1internal_response_id"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("resp-local-finalize");
|
|
||||||
|
|
||||||
Some(build_openai_cli_response(
|
|
||||||
response_id,
|
|
||||||
model,
|
|
||||||
&text,
|
|
||||||
function_calls,
|
|
||||||
prompt_tokens,
|
|
||||||
output_tokens,
|
|
||||||
total_tokens,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
mod chat;
|
|
||||||
mod cli;
|
|
||||||
|
|
||||||
pub(crate) use chat::{
|
|
||||||
aggregate_gemini_stream_sync_response, convert_gemini_chat_response_to_openai_chat,
|
|
||||||
convert_openai_chat_response_to_gemini_chat, maybe_build_local_gemini_stream_sync_response,
|
|
||||||
maybe_build_local_gemini_sync_response,
|
|
||||||
};
|
|
||||||
pub(crate) use cli::{
|
|
||||||
convert_gemini_cli_response_to_openai_cli, maybe_build_local_gemini_cli_stream_sync_response,
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
//! Standard finalize surface for standard contract sync/stream compilation.
|
//! Standard finalize surface for standard contract sync/stream compilation.
|
||||||
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
mod claude;
|
mod claude;
|
||||||
mod gemini;
|
mod gemini;
|
||||||
mod openai;
|
mod openai;
|
||||||
@@ -10,87 +8,18 @@ mod stream;
|
|||||||
|
|
||||||
pub(crate) use crate::ai_pipeline::conversion::response::{
|
pub(crate) use crate::ai_pipeline::conversion::response::{
|
||||||
build_openai_cli_response, convert_openai_chat_response_to_openai_cli,
|
build_openai_cli_response, convert_openai_chat_response_to_openai_cli,
|
||||||
|
convert_openai_cli_response_to_openai_chat,
|
||||||
|
};
|
||||||
|
pub(crate) use aether_ai_pipeline::finalize::sync_products::{
|
||||||
|
aggregate_standard_chat_stream_sync_response, aggregate_standard_cli_stream_sync_response,
|
||||||
|
convert_standard_chat_response, convert_standard_cli_response,
|
||||||
|
maybe_build_openai_chat_cross_format_sync_product_from_normalized_payload,
|
||||||
|
maybe_build_openai_cli_cross_format_sync_product_from_normalized_payload,
|
||||||
|
maybe_build_openai_cli_same_family_sync_body_from_normalized_payload,
|
||||||
|
maybe_build_standard_cross_format_sync_product,
|
||||||
|
maybe_build_standard_cross_format_sync_product_from_normalized_payload,
|
||||||
|
maybe_build_standard_same_format_sync_body_from_normalized_payload,
|
||||||
|
maybe_build_standard_sync_finalize_product_from_normalized_payload,
|
||||||
|
StandardCrossFormatSyncProduct, StandardSyncFinalizeNormalizedProduct,
|
||||||
};
|
};
|
||||||
pub(crate) use claude::*;
|
|
||||||
pub(crate) use gemini::*;
|
|
||||||
pub(crate) use openai::*;
|
|
||||||
pub(crate) use stream::*;
|
pub(crate) use stream::*;
|
||||||
|
|
||||||
pub(crate) fn aggregate_standard_chat_stream_sync_response(
|
|
||||||
body: &[u8],
|
|
||||||
provider_api_format: &str,
|
|
||||||
) -> Option<Value> {
|
|
||||||
match provider_api_format.trim().to_ascii_lowercase().as_str() {
|
|
||||||
"openai:chat" => aggregate_openai_chat_stream_sync_response(body),
|
|
||||||
"openai:cli" | "openai:compact" => aggregate_openai_cli_stream_sync_response(body),
|
|
||||||
"claude:chat" | "claude:cli" => aggregate_claude_stream_sync_response(body),
|
|
||||||
"gemini:chat" | "gemini:cli" => aggregate_gemini_stream_sync_response(body),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn convert_standard_chat_response(
|
|
||||||
body_json: &Value,
|
|
||||||
provider_api_format: &str,
|
|
||||||
client_api_format: &str,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let canonical = match provider_api_format.trim().to_ascii_lowercase().as_str() {
|
|
||||||
"openai:chat" => body_json.clone(),
|
|
||||||
"openai:cli" | "openai:compact" => {
|
|
||||||
convert_openai_cli_response_to_openai_chat(body_json, report_context)?
|
|
||||||
}
|
|
||||||
"claude:chat" | "claude:cli" => {
|
|
||||||
convert_claude_chat_response_to_openai_chat(body_json, report_context)?
|
|
||||||
}
|
|
||||||
"gemini:chat" | "gemini:cli" => {
|
|
||||||
convert_gemini_chat_response_to_openai_chat(body_json, report_context)?
|
|
||||||
}
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
|
|
||||||
match client_api_format.trim().to_ascii_lowercase().as_str() {
|
|
||||||
"openai:chat" => Some(canonical),
|
|
||||||
"claude:chat" => convert_openai_chat_response_to_claude_chat(&canonical, report_context),
|
|
||||||
"gemini:chat" => convert_openai_chat_response_to_gemini_chat(&canonical, report_context),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn aggregate_standard_cli_stream_sync_response(
|
|
||||||
body: &[u8],
|
|
||||||
provider_api_format: &str,
|
|
||||||
) -> Option<Value> {
|
|
||||||
aggregate_standard_chat_stream_sync_response(body, provider_api_format)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn convert_standard_cli_response(
|
|
||||||
body_json: &Value,
|
|
||||||
provider_api_format: &str,
|
|
||||||
client_api_format: &str,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let canonical = match provider_api_format.trim().to_ascii_lowercase().as_str() {
|
|
||||||
"openai:cli" | "openai:compact" => {
|
|
||||||
convert_openai_cli_response_to_openai_chat(body_json, report_context)?
|
|
||||||
}
|
|
||||||
_ => convert_standard_chat_response(
|
|
||||||
body_json,
|
|
||||||
provider_api_format,
|
|
||||||
"openai:chat",
|
|
||||||
report_context,
|
|
||||||
)?,
|
|
||||||
};
|
|
||||||
|
|
||||||
match client_api_format.trim().to_ascii_lowercase().as_str() {
|
|
||||||
"openai:cli" => {
|
|
||||||
convert_openai_chat_response_to_openai_cli(&canonical, report_context, false)
|
|
||||||
}
|
|
||||||
"openai:compact" => {
|
|
||||||
convert_openai_chat_response_to_openai_cli(&canonical, report_context, true)
|
|
||||||
}
|
|
||||||
"claude:cli" => convert_openai_chat_response_to_claude_chat(&canonical, report_context),
|
|
||||||
"gemini:cli" => convert_openai_chat_response_to_gemini_chat(&canonical, report_context),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,14 +1 @@
|
|||||||
pub(super) mod stream;
|
pub(super) mod stream;
|
||||||
pub(super) mod sync;
|
|
||||||
|
|
||||||
pub(crate) use sync::{
|
|
||||||
aggregate_openai_chat_stream_sync_response, aggregate_openai_cli_stream_sync_response,
|
|
||||||
build_openai_cli_response, convert_openai_cli_response_to_openai_chat,
|
|
||||||
maybe_build_local_openai_chat_cross_format_stream_sync_response,
|
|
||||||
maybe_build_local_openai_chat_cross_format_sync_response,
|
|
||||||
maybe_build_local_openai_chat_stream_sync_response,
|
|
||||||
maybe_build_local_openai_chat_sync_response,
|
|
||||||
maybe_build_local_openai_cli_cross_format_stream_sync_response,
|
|
||||||
maybe_build_local_openai_cli_cross_format_sync_response,
|
|
||||||
maybe_build_local_openai_cli_stream_sync_response,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,962 +1,4 @@
|
|||||||
use std::collections::BTreeMap;
|
pub(crate) use aether_ai_pipeline::finalize::standard::openai::stream::{
|
||||||
|
OpenAIChatClientEmitter, OpenAIChatProviderState, OpenAICliClientEmitter,
|
||||||
use serde_json::{json, Value};
|
OpenAICliProviderState,
|
||||||
|
|
||||||
use crate::ai_pipeline::conversion::response::build_openai_cli_response;
|
|
||||||
use crate::ai_pipeline::finalize::common::{
|
|
||||||
build_generated_tool_call_id, canonicalize_tool_arguments,
|
|
||||||
};
|
};
|
||||||
use crate::ai_pipeline::finalize::sse::{encode_done_sse, encode_json_sse};
|
|
||||||
use crate::GatewayError;
|
|
||||||
|
|
||||||
use crate::ai_pipeline::finalize::standard::stream::common::*;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct OpenAIChatProviderToolState {
|
|
||||||
id: Option<String>,
|
|
||||||
name: Option<String>,
|
|
||||||
started_emitted: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub(crate) struct OpenAIChatProviderState {
|
|
||||||
response_id: Option<String>,
|
|
||||||
model: Option<String>,
|
|
||||||
started: bool,
|
|
||||||
finished: bool,
|
|
||||||
tool_calls: BTreeMap<usize, OpenAIChatProviderToolState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct OpenAICliProviderToolState {
|
|
||||||
call_id: String,
|
|
||||||
name: String,
|
|
||||||
arguments: String,
|
|
||||||
started_emitted: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub(crate) struct OpenAICliProviderState {
|
|
||||||
response_id: Option<String>,
|
|
||||||
model: Option<String>,
|
|
||||||
started: bool,
|
|
||||||
finished: bool,
|
|
||||||
text: String,
|
|
||||||
tool_calls: BTreeMap<usize, OpenAICliProviderToolState>,
|
|
||||||
tool_index_by_key: BTreeMap<String, usize>,
|
|
||||||
last_tool_index: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OpenAIChatProviderState {
|
|
||||||
fn identity(&self, report_context: &Value) -> (String, String) {
|
|
||||||
resolve_identity(
|
|
||||||
self.response_id.as_deref(),
|
|
||||||
self.model.as_deref(),
|
|
||||||
report_context,
|
|
||||||
"chatcmpl-local-stream",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_started(&mut self, report_context: &Value, out: &mut Vec<CanonicalStreamFrame>) {
|
|
||||||
if self.started {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::Start,
|
|
||||||
});
|
|
||||||
self.started = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn push_line(
|
|
||||||
&mut self,
|
|
||||||
report_context: &Value,
|
|
||||||
line: Vec<u8>,
|
|
||||||
) -> Result<Vec<CanonicalStreamFrame>, GatewayError> {
|
|
||||||
let Some(value) = decode_json_data_line(&line) else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
let Some(chunk_object) = value.as_object() else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
self.response_id = chunk_object
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.or_else(|| self.response_id.clone());
|
|
||||||
self.model = chunk_object
|
|
||||||
.get("model")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.or_else(|| self.model.clone());
|
|
||||||
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let Some(chunk_choices) = chunk_object.get("choices").and_then(Value::as_array) else {
|
|
||||||
return Ok(out);
|
|
||||||
};
|
|
||||||
for chunk_choice in chunk_choices {
|
|
||||||
let Some(choice_object) = chunk_choice.as_object() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(delta) = choice_object.get("delta").and_then(Value::as_object) else {
|
|
||||||
if let Some(finish_reason) = normalize_openai_finish_reason(
|
|
||||||
choice_object.get("finish_reason").and_then(Value::as_str),
|
|
||||||
) {
|
|
||||||
self.ensure_started(report_context, &mut out);
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::Finish {
|
|
||||||
finish_reason: Some(finish_reason),
|
|
||||||
usage: canonical_usage_from_openai_usage(chunk_object.get("usage")),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
self.finished = true;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
if delta.get("role").and_then(Value::as_str) == Some("assistant") {
|
|
||||||
self.ensure_started(report_context, &mut out);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(content) = delta.get("content").and_then(Value::as_str) {
|
|
||||||
if !content.is_empty() {
|
|
||||||
self.ensure_started(report_context, &mut out);
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::TextDelta(content.to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
|
|
||||||
self.ensure_started(report_context, &mut out);
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
for tool_call in tool_calls {
|
|
||||||
let Some(tool_call_object) = tool_call.as_object() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let index = tool_call_object
|
|
||||||
.get("index")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|value| value as usize)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let state = self.tool_calls.entry(index).or_default();
|
|
||||||
if let Some(call_id) = tool_call_object.get("id").and_then(Value::as_str) {
|
|
||||||
state.id = Some(call_id.to_string());
|
|
||||||
}
|
|
||||||
if let Some(function) =
|
|
||||||
tool_call_object.get("function").and_then(Value::as_object)
|
|
||||||
{
|
|
||||||
if let Some(name) = function.get("name").and_then(Value::as_str) {
|
|
||||||
state.name = Some(name.to_string());
|
|
||||||
}
|
|
||||||
if !state.started_emitted && (state.id.is_some() || state.name.is_some()) {
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id: id.clone(),
|
|
||||||
model: model.clone(),
|
|
||||||
event: CanonicalStreamEvent::ToolCallStart {
|
|
||||||
index,
|
|
||||||
call_id: state
|
|
||||||
.id
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| build_generated_tool_call_id(index)),
|
|
||||||
name: state
|
|
||||||
.name
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "unknown".to_string()),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
state.started_emitted = true;
|
|
||||||
}
|
|
||||||
if let Some(arguments) = function.get("arguments").and_then(Value::as_str) {
|
|
||||||
if !arguments.is_empty() {
|
|
||||||
if !state.started_emitted {
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id: id.clone(),
|
|
||||||
model: model.clone(),
|
|
||||||
event: CanonicalStreamEvent::ToolCallStart {
|
|
||||||
index,
|
|
||||||
call_id: state.id.clone().unwrap_or_else(|| {
|
|
||||||
build_generated_tool_call_id(index)
|
|
||||||
}),
|
|
||||||
name: state
|
|
||||||
.name
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "unknown".to_string()),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
state.started_emitted = true;
|
|
||||||
}
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id: id.clone(),
|
|
||||||
model: model.clone(),
|
|
||||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
|
||||||
index,
|
|
||||||
arguments: arguments.to_string(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(finish_reason) = normalize_openai_finish_reason(
|
|
||||||
choice_object.get("finish_reason").and_then(Value::as_str),
|
|
||||||
) {
|
|
||||||
self.ensure_started(report_context, &mut out);
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::Finish {
|
|
||||||
finish_reason: Some(finish_reason),
|
|
||||||
usage: canonical_usage_from_openai_usage(chunk_object.get("usage")),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
self.finished = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn finish(
|
|
||||||
&mut self,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Result<Vec<CanonicalStreamFrame>, GatewayError> {
|
|
||||||
if !self.started || self.finished {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
self.finished = true;
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
Ok(vec![CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::Finish {
|
|
||||||
finish_reason: None,
|
|
||||||
usage: None,
|
|
||||||
},
|
|
||||||
}])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OpenAICliProviderState {
|
|
||||||
fn identity(&self, report_context: &Value) -> (String, String) {
|
|
||||||
resolve_identity(
|
|
||||||
self.response_id.as_deref(),
|
|
||||||
self.model.as_deref(),
|
|
||||||
report_context,
|
|
||||||
"resp-local-stream",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_started(&mut self, report_context: &Value, out: &mut Vec<CanonicalStreamFrame>) {
|
|
||||||
if self.started {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::Start,
|
|
||||||
});
|
|
||||||
self.started = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tool_index_for_key(&mut self, key: Option<String>, output_index: Option<usize>) -> usize {
|
|
||||||
if let Some(output_index) = output_index {
|
|
||||||
if let Some(key) = key.as_ref() {
|
|
||||||
self.tool_index_by_key
|
|
||||||
.entry(key.clone())
|
|
||||||
.or_insert(output_index);
|
|
||||||
}
|
|
||||||
self.last_tool_index = Some(output_index);
|
|
||||||
return output_index;
|
|
||||||
}
|
|
||||||
if let Some(key) = key.as_ref() {
|
|
||||||
if let Some(index) = self.tool_index_by_key.get(key).copied() {
|
|
||||||
self.last_tool_index = Some(index);
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let index = self.last_tool_index.unwrap_or(self.tool_calls.len());
|
|
||||||
if let Some(key) = key {
|
|
||||||
self.tool_index_by_key.insert(key, index);
|
|
||||||
}
|
|
||||||
self.last_tool_index = Some(index);
|
|
||||||
index
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn push_line(
|
|
||||||
&mut self,
|
|
||||||
report_context: &Value,
|
|
||||||
line: Vec<u8>,
|
|
||||||
) -> Result<Vec<CanonicalStreamFrame>, GatewayError> {
|
|
||||||
let Some(value) = decode_json_data_line(&line) else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
let mut out = Vec::new();
|
|
||||||
if let Some(response) = value.get("response").and_then(Value::as_object) {
|
|
||||||
self.response_id = response
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.or_else(|| self.response_id.clone());
|
|
||||||
self.model = response
|
|
||||||
.get("model")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.or_else(|| self.model.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
match value
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
{
|
|
||||||
"response.created" => {
|
|
||||||
self.ensure_started(report_context, &mut out);
|
|
||||||
}
|
|
||||||
"response.output_text.delta" => {
|
|
||||||
let piece = match value.get("delta") {
|
|
||||||
Some(Value::String(text)) => text.clone(),
|
|
||||||
Some(Value::Object(delta)) => delta
|
|
||||||
.get("text")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string(),
|
|
||||||
_ => String::new(),
|
|
||||||
};
|
|
||||||
if !piece.is_empty() {
|
|
||||||
self.ensure_started(report_context, &mut out);
|
|
||||||
self.text.push_str(&piece);
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::TextDelta(piece),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"response.output_item.added" => {
|
|
||||||
let Some(item) = value.get("item").and_then(Value::as_object) else {
|
|
||||||
return Ok(out);
|
|
||||||
};
|
|
||||||
if item.get("type").and_then(Value::as_str) != Some("function_call") {
|
|
||||||
return Ok(out);
|
|
||||||
}
|
|
||||||
self.ensure_started(report_context, &mut out);
|
|
||||||
let key = item
|
|
||||||
.get("call_id")
|
|
||||||
.or_else(|| item.get("id"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned);
|
|
||||||
let output_index = value
|
|
||||||
.get("output_index")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|value| value as usize);
|
|
||||||
let index = self.tool_index_for_key(key.clone(), output_index);
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
let state = self.tool_calls.entry(index).or_default();
|
|
||||||
state.call_id = item
|
|
||||||
.get("call_id")
|
|
||||||
.or_else(|| item.get("id"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_else(|| state.call_id.as_str())
|
|
||||||
.to_string();
|
|
||||||
state.name = item
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_else(|| state.name.as_str())
|
|
||||||
.to_string();
|
|
||||||
if !state.started_emitted {
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id: id.clone(),
|
|
||||||
model: model.clone(),
|
|
||||||
event: CanonicalStreamEvent::ToolCallStart {
|
|
||||||
index,
|
|
||||||
call_id: if state.call_id.is_empty() {
|
|
||||||
build_generated_tool_call_id(index)
|
|
||||||
} else {
|
|
||||||
state.call_id.clone()
|
|
||||||
},
|
|
||||||
name: if state.name.is_empty() {
|
|
||||||
"unknown".to_string()
|
|
||||||
} else {
|
|
||||||
state.name.clone()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
state.started_emitted = true;
|
|
||||||
}
|
|
||||||
if let Some(arguments) = item.get("arguments").and_then(Value::as_str) {
|
|
||||||
if !arguments.is_empty() {
|
|
||||||
state.arguments.push_str(arguments);
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
|
||||||
index,
|
|
||||||
arguments: arguments.to_string(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"response.function_call_arguments.delta" => {
|
|
||||||
let delta = value
|
|
||||||
.get("delta")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if delta.is_empty() {
|
|
||||||
return Ok(out);
|
|
||||||
}
|
|
||||||
self.ensure_started(report_context, &mut out);
|
|
||||||
let key = value
|
|
||||||
.get("item_id")
|
|
||||||
.or_else(|| value.get("call_id"))
|
|
||||||
.or_else(|| value.get("id"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned);
|
|
||||||
let output_index = value
|
|
||||||
.get("output_index")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|value| value as usize);
|
|
||||||
let index = self.tool_index_for_key(key, output_index);
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
let state = self.tool_calls.entry(index).or_default();
|
|
||||||
if !state.started_emitted {
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id: id.clone(),
|
|
||||||
model: model.clone(),
|
|
||||||
event: CanonicalStreamEvent::ToolCallStart {
|
|
||||||
index,
|
|
||||||
call_id: if state.call_id.is_empty() {
|
|
||||||
build_generated_tool_call_id(index)
|
|
||||||
} else {
|
|
||||||
state.call_id.clone()
|
|
||||||
},
|
|
||||||
name: if state.name.is_empty() {
|
|
||||||
"unknown".to_string()
|
|
||||||
} else {
|
|
||||||
state.name.clone()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
state.started_emitted = true;
|
|
||||||
}
|
|
||||||
state.arguments.push_str(delta);
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
|
||||||
index,
|
|
||||||
arguments: delta.to_string(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"response.completed" => {
|
|
||||||
let Some(response) = value.get("response").and_then(Value::as_object) else {
|
|
||||||
return Ok(out);
|
|
||||||
};
|
|
||||||
self.ensure_started(report_context, &mut out);
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
|
|
||||||
for raw_item in response
|
|
||||||
.get("output")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
{
|
|
||||||
let Some(item) = raw_item.as_object() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
|
|
||||||
"message" => {
|
|
||||||
let mut completed_text = String::new();
|
|
||||||
for raw_content in item
|
|
||||||
.get("content")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
{
|
|
||||||
let Some(content) = raw_content.as_object() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if content.get("type").and_then(Value::as_str)
|
|
||||||
== Some("output_text")
|
|
||||||
{
|
|
||||||
if let Some(text) = content.get("text").and_then(Value::as_str)
|
|
||||||
{
|
|
||||||
completed_text.push_str(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let missing = if completed_text.starts_with(&self.text) {
|
|
||||||
completed_text[self.text.len()..].to_string()
|
|
||||||
} else if self.text == completed_text {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
completed_text.clone()
|
|
||||||
};
|
|
||||||
if !missing.is_empty() {
|
|
||||||
self.text.push_str(&missing);
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id: id.clone(),
|
|
||||||
model: model.clone(),
|
|
||||||
event: CanonicalStreamEvent::TextDelta(missing),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"function_call" => {
|
|
||||||
let key = item
|
|
||||||
.get("call_id")
|
|
||||||
.or_else(|| item.get("id"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned);
|
|
||||||
let index = self.tool_index_for_key(key, None);
|
|
||||||
let state = self.tool_calls.entry(index).or_default();
|
|
||||||
state.call_id = item
|
|
||||||
.get("call_id")
|
|
||||||
.or_else(|| item.get("id"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_else(|| state.call_id.as_str())
|
|
||||||
.to_string();
|
|
||||||
state.name = item
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_else(|| state.name.as_str())
|
|
||||||
.to_string();
|
|
||||||
if !state.started_emitted {
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id: id.clone(),
|
|
||||||
model: model.clone(),
|
|
||||||
event: CanonicalStreamEvent::ToolCallStart {
|
|
||||||
index,
|
|
||||||
call_id: if state.call_id.is_empty() {
|
|
||||||
build_generated_tool_call_id(index)
|
|
||||||
} else {
|
|
||||||
state.call_id.clone()
|
|
||||||
},
|
|
||||||
name: if state.name.is_empty() {
|
|
||||||
"unknown".to_string()
|
|
||||||
} else {
|
|
||||||
state.name.clone()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
state.started_emitted = true;
|
|
||||||
}
|
|
||||||
let completed_arguments = item
|
|
||||||
.get("arguments")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string();
|
|
||||||
let missing = if completed_arguments.starts_with(&state.arguments) {
|
|
||||||
completed_arguments[state.arguments.len()..].to_string()
|
|
||||||
} else if state.arguments == completed_arguments {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
completed_arguments.clone()
|
|
||||||
};
|
|
||||||
if !missing.is_empty() {
|
|
||||||
state.arguments.push_str(&missing);
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id: id.clone(),
|
|
||||||
model: model.clone(),
|
|
||||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
|
||||||
index,
|
|
||||||
arguments: missing,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let finish_reason = if self.tool_calls.is_empty() {
|
|
||||||
Some("stop".to_string())
|
|
||||||
} else {
|
|
||||||
Some("tool_calls".to_string())
|
|
||||||
};
|
|
||||||
out.push(CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::Finish {
|
|
||||||
finish_reason,
|
|
||||||
usage: canonical_usage_from_openai_usage(response.get("usage")),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
self.finished = true;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn finish(
|
|
||||||
&mut self,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Result<Vec<CanonicalStreamFrame>, GatewayError> {
|
|
||||||
if !self.started || self.finished {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
self.finished = true;
|
|
||||||
let (id, model) = self.identity(report_context);
|
|
||||||
let finish_reason = if self.tool_calls.is_empty() {
|
|
||||||
Some("stop".to_string())
|
|
||||||
} else {
|
|
||||||
Some("tool_calls".to_string())
|
|
||||||
};
|
|
||||||
Ok(vec![CanonicalStreamFrame {
|
|
||||||
id,
|
|
||||||
model,
|
|
||||||
event: CanonicalStreamEvent::Finish {
|
|
||||||
finish_reason,
|
|
||||||
usage: None,
|
|
||||||
},
|
|
||||||
}])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub(crate) struct OpenAIChatClientEmitter {
|
|
||||||
response_id: Option<String>,
|
|
||||||
model: Option<String>,
|
|
||||||
started: bool,
|
|
||||||
finished: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct OpenAICliClientToolState {
|
|
||||||
call_id: String,
|
|
||||||
name: String,
|
|
||||||
arguments: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub(crate) struct OpenAICliClientEmitter {
|
|
||||||
response_id: Option<String>,
|
|
||||||
model: Option<String>,
|
|
||||||
started: bool,
|
|
||||||
finished: bool,
|
|
||||||
text: String,
|
|
||||||
tool_calls: BTreeMap<usize, OpenAICliClientToolState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OpenAIChatClientEmitter {
|
|
||||||
fn update_identity(&mut self, frame: &CanonicalStreamFrame) {
|
|
||||||
self.response_id = Some(frame.id.clone());
|
|
||||||
self.model = Some(frame.model.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_started(&mut self) -> Result<Vec<u8>, GatewayError> {
|
|
||||||
if self.started {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
self.started = true;
|
|
||||||
Ok(encode_json_sse(
|
|
||||||
None,
|
|
||||||
&build_openai_chat_role_chunk(
|
|
||||||
self.response_id
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("chatcmpl-local-stream"),
|
|
||||||
self.model.as_deref().unwrap_or("unknown"),
|
|
||||||
),
|
|
||||||
)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, GatewayError> {
|
|
||||||
self.update_identity(&frame);
|
|
||||||
match frame.event {
|
|
||||||
CanonicalStreamEvent::Start => self.ensure_started(),
|
|
||||||
CanonicalStreamEvent::TextDelta(text) => {
|
|
||||||
let mut out = self.ensure_started()?;
|
|
||||||
out.extend(encode_json_sse(
|
|
||||||
None,
|
|
||||||
&build_openai_chat_chunk(
|
|
||||||
self.response_id
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("chatcmpl-local-stream"),
|
|
||||||
self.model.as_deref().unwrap_or("unknown"),
|
|
||||||
text,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
)?);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
CanonicalStreamEvent::ToolCallStart {
|
|
||||||
index,
|
|
||||||
call_id,
|
|
||||||
name,
|
|
||||||
} => {
|
|
||||||
let mut out = self.ensure_started()?;
|
|
||||||
out.extend(encode_json_sse(
|
|
||||||
None,
|
|
||||||
&build_openai_chat_chunk(
|
|
||||||
self.response_id
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("chatcmpl-local-stream"),
|
|
||||||
self.model.as_deref().unwrap_or("unknown"),
|
|
||||||
String::new(),
|
|
||||||
Some(vec![json!({
|
|
||||||
"index": index,
|
|
||||||
"id": call_id,
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": name,
|
|
||||||
"arguments": "",
|
|
||||||
}
|
|
||||||
})]),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
)?);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
|
||||||
let mut out = self.ensure_started()?;
|
|
||||||
out.extend(encode_json_sse(
|
|
||||||
None,
|
|
||||||
&json!({
|
|
||||||
"id": self.response_id
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("chatcmpl-local-stream"),
|
|
||||||
"object": "chat.completion.chunk",
|
|
||||||
"model": self.model.as_deref().unwrap_or("unknown"),
|
|
||||||
"choices": [{
|
|
||||||
"index": 0,
|
|
||||||
"delta": {
|
|
||||||
"tool_calls": [{
|
|
||||||
"index": index,
|
|
||||||
"function": {
|
|
||||||
"arguments": arguments,
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
"finish_reason": Value::Null
|
|
||||||
}]
|
|
||||||
}),
|
|
||||||
)?);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
CanonicalStreamEvent::Finish { finish_reason, .. } => {
|
|
||||||
if self.finished {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
let mut out = self.ensure_started()?;
|
|
||||||
out.extend(encode_json_sse(
|
|
||||||
None,
|
|
||||||
&build_openai_chat_finish_chunk(
|
|
||||||
self.response_id
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("chatcmpl-local-stream"),
|
|
||||||
self.model.as_deref().unwrap_or("unknown"),
|
|
||||||
finish_reason.as_deref(),
|
|
||||||
),
|
|
||||||
)?);
|
|
||||||
out.extend(encode_done_sse());
|
|
||||||
self.finished = true;
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn finish(&mut self) -> Result<Vec<u8>, GatewayError> {
|
|
||||||
if !self.started || self.finished {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
let out = encode_json_sse(
|
|
||||||
None,
|
|
||||||
&build_openai_chat_finish_chunk(
|
|
||||||
self.response_id
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("chatcmpl-local-stream"),
|
|
||||||
self.model.as_deref().unwrap_or("unknown"),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
)?;
|
|
||||||
self.finished = true;
|
|
||||||
let mut bytes = out;
|
|
||||||
bytes.extend(encode_done_sse());
|
|
||||||
Ok(bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OpenAICliClientEmitter {
|
|
||||||
fn update_identity(&mut self, frame: &CanonicalStreamFrame) {
|
|
||||||
self.response_id = Some(frame.id.clone().replace("chatcmpl", "resp"));
|
|
||||||
self.model = Some(frame.model.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_started(&mut self) -> Result<Vec<u8>, GatewayError> {
|
|
||||||
if self.started {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
self.started = true;
|
|
||||||
encode_json_sse(
|
|
||||||
Some("response.created"),
|
|
||||||
&json!({
|
|
||||||
"type": "response.created",
|
|
||||||
"response": {
|
|
||||||
"id": self.response_id.as_deref().unwrap_or("resp-local-stream"),
|
|
||||||
"object": "response",
|
|
||||||
"model": self.model.as_deref().unwrap_or("unknown"),
|
|
||||||
"status": "in_progress",
|
|
||||||
"output": [],
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn function_output_index(&self, index: usize) -> usize {
|
|
||||||
if self.text.is_empty() {
|
|
||||||
index
|
|
||||||
} else {
|
|
||||||
index + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, GatewayError> {
|
|
||||||
self.update_identity(&frame);
|
|
||||||
match frame.event {
|
|
||||||
CanonicalStreamEvent::Start => self.ensure_started(),
|
|
||||||
CanonicalStreamEvent::TextDelta(text) => {
|
|
||||||
let mut out = self.ensure_started()?;
|
|
||||||
self.text.push_str(&text);
|
|
||||||
out.extend(encode_json_sse(
|
|
||||||
Some("response.output_text.delta"),
|
|
||||||
&json!({
|
|
||||||
"type": "response.output_text.delta",
|
|
||||||
"output_index": 0,
|
|
||||||
"content_index": 0,
|
|
||||||
"delta": text,
|
|
||||||
}),
|
|
||||||
)?);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
CanonicalStreamEvent::ToolCallStart {
|
|
||||||
index,
|
|
||||||
call_id,
|
|
||||||
name,
|
|
||||||
} => {
|
|
||||||
let mut out = self.ensure_started()?;
|
|
||||||
let output_index = self.function_output_index(index);
|
|
||||||
let state = self.tool_calls.entry(index).or_default();
|
|
||||||
state.call_id = call_id.clone();
|
|
||||||
state.name = name.clone();
|
|
||||||
out.extend(encode_json_sse(
|
|
||||||
Some("response.output_item.added"),
|
|
||||||
&json!({
|
|
||||||
"type": "response.output_item.added",
|
|
||||||
"output_index": output_index,
|
|
||||||
"item": {
|
|
||||||
"type": "function_call",
|
|
||||||
"id": call_id,
|
|
||||||
"call_id": state.call_id,
|
|
||||||
"name": state.name,
|
|
||||||
"arguments": "",
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)?);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
|
||||||
let mut out = self.ensure_started()?;
|
|
||||||
let output_index = self.function_output_index(index);
|
|
||||||
let state = self.tool_calls.entry(index).or_default();
|
|
||||||
state.arguments.push_str(&arguments);
|
|
||||||
out.extend(encode_json_sse(
|
|
||||||
Some("response.function_call_arguments.delta"),
|
|
||||||
&json!({
|
|
||||||
"type": "response.function_call_arguments.delta",
|
|
||||||
"output_index": output_index,
|
|
||||||
"item_id": if state.call_id.is_empty() {
|
|
||||||
build_generated_tool_call_id(index)
|
|
||||||
} else {
|
|
||||||
state.call_id.clone()
|
|
||||||
},
|
|
||||||
"delta": arguments,
|
|
||||||
}),
|
|
||||||
)?);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
CanonicalStreamEvent::Finish { usage, .. } => {
|
|
||||||
if self.finished {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
let mut out = self.ensure_started()?;
|
|
||||||
let usage = usage.unwrap_or_default();
|
|
||||||
let function_calls = self
|
|
||||||
.tool_calls
|
|
||||||
.iter()
|
|
||||||
.map(|(index, state)| {
|
|
||||||
json!({
|
|
||||||
"type": "function_call",
|
|
||||||
"id": if state.call_id.is_empty() {
|
|
||||||
build_generated_tool_call_id(*index)
|
|
||||||
} else {
|
|
||||||
state.call_id.clone()
|
|
||||||
},
|
|
||||||
"call_id": if state.call_id.is_empty() {
|
|
||||||
build_generated_tool_call_id(*index)
|
|
||||||
} else {
|
|
||||||
state.call_id.clone()
|
|
||||||
},
|
|
||||||
"name": if state.name.is_empty() {
|
|
||||||
"unknown".to_string()
|
|
||||||
} else {
|
|
||||||
state.name.clone()
|
|
||||||
},
|
|
||||||
"arguments": state.arguments.clone(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
out.extend(encode_json_sse(
|
|
||||||
Some("response.completed"),
|
|
||||||
&json!({
|
|
||||||
"type": "response.completed",
|
|
||||||
"response": build_openai_cli_response(
|
|
||||||
self.response_id.as_deref().unwrap_or("resp-local-stream"),
|
|
||||||
self.model.as_deref().unwrap_or("unknown"),
|
|
||||||
&self.text,
|
|
||||||
function_calls,
|
|
||||||
usage.input_tokens,
|
|
||||||
usage.output_tokens,
|
|
||||||
usage.total_tokens,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
)?);
|
|
||||||
self.finished = true;
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn finish(&mut self) -> Result<Vec<u8>, GatewayError> {
|
|
||||||
if !self.started || self.finished {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
self.emit(CanonicalStreamFrame {
|
|
||||||
id: self
|
|
||||||
.response_id
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "resp-local-stream".to_string()),
|
|
||||||
model: self.model.clone().unwrap_or_else(|| "unknown".to_string()),
|
|
||||||
event: CanonicalStreamEvent::Finish {
|
|
||||||
finish_reason: None,
|
|
||||||
usage: None,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,583 +0,0 @@
|
|||||||
use base64::Engine as _;
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use serde_json::{json, Map, Value};
|
|
||||||
|
|
||||||
use super::cli::aggregate_openai_cli_stream_sync_response;
|
|
||||||
use crate::ai_pipeline::conversion::response::{
|
|
||||||
convert_claude_chat_response_to_openai_chat, convert_gemini_chat_response_to_openai_chat,
|
|
||||||
};
|
|
||||||
use crate::ai_pipeline::conversion::sync_chat_response_conversion_kind;
|
|
||||||
use crate::ai_pipeline::finalize::common::{
|
|
||||||
build_generated_tool_call_id, build_local_success_outcome,
|
|
||||||
build_local_success_outcome_with_conversion_report, canonicalize_tool_arguments,
|
|
||||||
local_finalize_allows_envelope, unwrap_local_finalize_response_value,
|
|
||||||
LocalCoreSyncFinalizeOutcome,
|
|
||||||
};
|
|
||||||
use crate::ai_pipeline::finalize::standard::claude::aggregate_claude_stream_sync_response;
|
|
||||||
use crate::ai_pipeline::finalize::standard::gemini::aggregate_gemini_stream_sync_response;
|
|
||||||
use crate::control::GatewayControlDecision;
|
|
||||||
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct OpenAIChatChoiceState {
|
|
||||||
role: Option<String>,
|
|
||||||
content: String,
|
|
||||||
finish_reason: Option<String>,
|
|
||||||
tool_calls: BTreeMap<usize, OpenAIChatToolCallState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct OpenAIChatToolCallState {
|
|
||||||
id: Option<String>,
|
|
||||||
tool_type: Option<String>,
|
|
||||||
function_name: Option<String>,
|
|
||||||
function_arguments: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn maybe_build_local_openai_chat_stream_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if payload.report_kind != "openai_chat_sync_finalize" || payload.status_code >= 400 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let needs_conversion = report_context
|
|
||||||
.get("needs_conversion")
|
|
||||||
.and_then(Value::as_bool)
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !local_finalize_allows_envelope(report_context) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
if provider_api_format != "openai:chat"
|
|
||||||
|| client_api_format != "openai:chat"
|
|
||||||
|| needs_conversion
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(body_base64) = payload.body_base64.as_deref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let body_bytes = base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(body_base64)
|
|
||||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
|
||||||
let body_json = match aggregate_openai_chat_stream_sync_response(&body_bytes) {
|
|
||||||
Some(body_json) => body_json,
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
let Some(body_json) = unwrap_local_finalize_response_value(body_json, report_context)? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome(
|
|
||||||
trace_id, decision, payload, body_json,
|
|
||||||
)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn maybe_build_local_openai_chat_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if payload.report_kind != "openai_chat_sync_finalize" || payload.status_code >= 400 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let needs_conversion = report_context
|
|
||||||
.get("needs_conversion")
|
|
||||||
.and_then(Value::as_bool)
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !local_finalize_allows_envelope(report_context) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
if provider_api_format != "openai:chat"
|
|
||||||
|| client_api_format != "openai:chat"
|
|
||||||
|| needs_conversion
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(body_json) = payload.body_json.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome(
|
|
||||||
trace_id, decision, payload, body_json,
|
|
||||||
)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn maybe_build_local_openai_chat_cross_format_stream_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if payload.report_kind != "openai_chat_sync_finalize" || payload.status_code >= 400 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if client_api_format != "openai:chat" || !local_finalize_allows_envelope(report_context) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let Some(conversion_kind) =
|
|
||||||
sync_chat_response_conversion_kind(&provider_api_format, &client_api_format)
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(body_base64) = payload.body_base64.as_deref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let body_bytes = base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(body_base64)
|
|
||||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
|
||||||
let aggregated = match provider_api_format.as_str() {
|
|
||||||
"claude:chat" | "claude:cli" => aggregate_claude_stream_sync_response(&body_bytes),
|
|
||||||
"gemini:chat" | "gemini:cli" => aggregate_gemini_stream_sync_response(&body_bytes),
|
|
||||||
"openai:cli" | "openai:compact" => aggregate_openai_cli_stream_sync_response(&body_bytes),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
let Some(aggregated) = aggregated else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(aggregated) = unwrap_local_finalize_response_value(aggregated, report_context)? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let converted = match provider_api_format.as_str() {
|
|
||||||
"claude:chat" | "claude:cli" => {
|
|
||||||
convert_claude_chat_response_to_openai_chat(&aggregated, report_context)
|
|
||||||
}
|
|
||||||
"gemini:chat" | "gemini:cli" => {
|
|
||||||
convert_gemini_chat_response_to_openai_chat(&aggregated, report_context)
|
|
||||||
}
|
|
||||||
"openai:cli" | "openai:compact" => {
|
|
||||||
convert_openai_cli_response_to_openai_chat(&aggregated, report_context)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
let Some(converted) = converted else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome_with_conversion_report(
|
|
||||||
trace_id, decision, payload, converted, aggregated,
|
|
||||||
)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn maybe_build_local_openai_chat_cross_format_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if payload.report_kind != "openai_chat_sync_finalize" || payload.status_code >= 400 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if client_api_format != "openai:chat" || !local_finalize_allows_envelope(report_context) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let Some(conversion_kind) =
|
|
||||||
sync_chat_response_conversion_kind(&provider_api_format, &client_api_format)
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(body_json) = payload.body_json.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let converted = match provider_api_format.as_str() {
|
|
||||||
"claude:chat" | "claude:cli" => {
|
|
||||||
convert_claude_chat_response_to_openai_chat(&body_json, report_context)
|
|
||||||
}
|
|
||||||
"gemini:chat" | "gemini:cli" => {
|
|
||||||
convert_gemini_chat_response_to_openai_chat(&body_json, report_context)
|
|
||||||
}
|
|
||||||
"openai:cli" | "openai:compact" => {
|
|
||||||
convert_openai_cli_response_to_openai_chat(&body_json, report_context)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
let Some(converted) = converted else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome_with_conversion_report(
|
|
||||||
trace_id, decision, payload, converted, body_json,
|
|
||||||
)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn convert_openai_cli_response_to_openai_chat(
|
|
||||||
body_json: &Value,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let body = body_json.as_object()?;
|
|
||||||
let mut text = String::new();
|
|
||||||
let mut tool_calls = Vec::new();
|
|
||||||
|
|
||||||
if let Some(output_items) = body.get("output").and_then(Value::as_array) {
|
|
||||||
for (index, item) in output_items.iter().enumerate() {
|
|
||||||
let item_object = item.as_object()?;
|
|
||||||
let item_type = item_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
match item_type.as_str() {
|
|
||||||
"message" => {
|
|
||||||
if let Some(content) = item_object.get("content").and_then(Value::as_array) {
|
|
||||||
for part in content {
|
|
||||||
let part_object = part.as_object()?;
|
|
||||||
let part_type = part_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if matches!(part_type.as_str(), "output_text" | "text") {
|
|
||||||
if let Some(piece) = part_object.get("text").and_then(Value::as_str)
|
|
||||||
{
|
|
||||||
text.push_str(piece);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"function_call" => {
|
|
||||||
let tool_name = item_object
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let tool_id = item_object
|
|
||||||
.get("call_id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.or_else(|| {
|
|
||||||
item_object
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
})
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
|
||||||
tool_calls.push(json!({
|
|
||||||
"id": tool_id,
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": tool_name,
|
|
||||||
"arguments": canonicalize_tool_arguments(item_object.get("arguments").cloned()),
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
"output_text" | "text" => {
|
|
||||||
if let Some(piece) = item_object.get("text").and_then(Value::as_str) {
|
|
||||||
text.push_str(piece);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let finish_reason = if tool_calls.is_empty() {
|
|
||||||
Some("stop")
|
|
||||||
} else {
|
|
||||||
Some("tool_calls")
|
|
||||||
};
|
|
||||||
let model = body
|
|
||||||
.get("model")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
|
||||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let id = body
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("chatcmpl-local-openai-cli");
|
|
||||||
|
|
||||||
let usage = body.get("usage").and_then(Value::as_object);
|
|
||||||
let prompt_tokens = usage
|
|
||||||
.and_then(|value| value.get("input_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let completion_tokens = usage
|
|
||||||
.and_then(|value| value.get("output_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let total_tokens = usage
|
|
||||||
.and_then(|value| value.get("total_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(prompt_tokens + completion_tokens);
|
|
||||||
|
|
||||||
let mut message = Map::new();
|
|
||||||
message.insert("role".to_string(), Value::String("assistant".to_string()));
|
|
||||||
if text.is_empty() && !tool_calls.is_empty() {
|
|
||||||
message.insert("content".to_string(), Value::Null);
|
|
||||||
} else {
|
|
||||||
message.insert("content".to_string(), Value::String(text));
|
|
||||||
}
|
|
||||||
if !tool_calls.is_empty() {
|
|
||||||
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(json!({
|
|
||||||
"id": id,
|
|
||||||
"object": "chat.completion",
|
|
||||||
"model": model,
|
|
||||||
"choices": [{
|
|
||||||
"index": 0,
|
|
||||||
"message": Value::Object(message),
|
|
||||||
"finish_reason": finish_reason,
|
|
||||||
}],
|
|
||||||
"usage": {
|
|
||||||
"prompt_tokens": prompt_tokens,
|
|
||||||
"completion_tokens": completion_tokens,
|
|
||||||
"total_tokens": total_tokens,
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn aggregate_openai_chat_stream_sync_response(body: &[u8]) -> Option<Value> {
|
|
||||||
let text = std::str::from_utf8(body).ok()?;
|
|
||||||
let mut response_id: Option<String> = None;
|
|
||||||
let mut model: Option<String> = None;
|
|
||||||
let mut created: Option<u64> = None;
|
|
||||||
let mut usage: Option<Value> = None;
|
|
||||||
let mut choices: BTreeMap<usize, OpenAIChatChoiceState> = BTreeMap::new();
|
|
||||||
let mut saw_chunk = false;
|
|
||||||
|
|
||||||
for raw_line in text.lines() {
|
|
||||||
let line = raw_line.trim_matches('\r').trim();
|
|
||||||
if line.is_empty() || line.starts_with(':') || line.starts_with("event:") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(data_line) = line.strip_prefix("data:") else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let data_line = data_line.trim();
|
|
||||||
if data_line.is_empty() || data_line == "[DONE]" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let chunk: Value = serde_json::from_str(data_line).ok()?;
|
|
||||||
let chunk_object = chunk.as_object()?;
|
|
||||||
saw_chunk = true;
|
|
||||||
|
|
||||||
if response_id.is_none() {
|
|
||||||
response_id = chunk_object
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned);
|
|
||||||
}
|
|
||||||
if model.is_none() {
|
|
||||||
model = chunk_object
|
|
||||||
.get("model")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned);
|
|
||||||
}
|
|
||||||
if created.is_none() {
|
|
||||||
created = chunk_object.get("created").and_then(Value::as_u64);
|
|
||||||
}
|
|
||||||
if let Some(u) = chunk_object.get("usage") {
|
|
||||||
usage = Some(u.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(chunk_choices) = chunk_object.get("choices").and_then(Value::as_array) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for chunk_choice in chunk_choices {
|
|
||||||
let Some(choice_object) = chunk_choice.as_object() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(index) = choice_object
|
|
||||||
.get("index")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|value| value as usize)
|
|
||||||
else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let state = choices.entry(index).or_default();
|
|
||||||
if let Some(finish_reason) = choice_object.get("finish_reason").and_then(Value::as_str)
|
|
||||||
{
|
|
||||||
state.finish_reason = Some(finish_reason.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(delta) = choice_object.get("delta").and_then(Value::as_object) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if let Some(role) = delta.get("role").and_then(Value::as_str) {
|
|
||||||
state.role = Some(role.to_string());
|
|
||||||
}
|
|
||||||
if let Some(content) = delta.get("content").and_then(Value::as_str) {
|
|
||||||
state.content.push_str(content);
|
|
||||||
}
|
|
||||||
if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
|
|
||||||
for tool_call in tool_calls {
|
|
||||||
let Some(tool_call_object) = tool_call.as_object() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let tool_index = tool_call_object
|
|
||||||
.get("index")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|value| value as usize)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let tool_state = state.tool_calls.entry(tool_index).or_default();
|
|
||||||
if let Some(id) = tool_call_object.get("id").and_then(Value::as_str) {
|
|
||||||
tool_state.id = Some(id.to_string());
|
|
||||||
}
|
|
||||||
if let Some(tool_type) = tool_call_object.get("type").and_then(Value::as_str) {
|
|
||||||
tool_state.tool_type = Some(tool_type.to_string());
|
|
||||||
}
|
|
||||||
if let Some(function) =
|
|
||||||
tool_call_object.get("function").and_then(Value::as_object)
|
|
||||||
{
|
|
||||||
if let Some(name) = function.get("name").and_then(Value::as_str) {
|
|
||||||
tool_state.function_name = Some(name.to_string());
|
|
||||||
}
|
|
||||||
if let Some(arguments) = function.get("arguments").and_then(Value::as_str) {
|
|
||||||
tool_state.function_arguments.push_str(arguments);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !saw_chunk {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut response_object = Map::new();
|
|
||||||
response_object.insert(
|
|
||||||
"id".to_string(),
|
|
||||||
Value::String(response_id.unwrap_or_else(|| "chatcmpl-local-finalize".to_string())),
|
|
||||||
);
|
|
||||||
response_object.insert(
|
|
||||||
"object".to_string(),
|
|
||||||
Value::String("chat.completion".to_string()),
|
|
||||||
);
|
|
||||||
if let Some(created) = created {
|
|
||||||
response_object.insert("created".to_string(), Value::Number(created.into()));
|
|
||||||
}
|
|
||||||
if let Some(model) = model {
|
|
||||||
response_object.insert("model".to_string(), Value::String(model));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut response_choices = Vec::with_capacity(choices.len());
|
|
||||||
for (index, state) in choices {
|
|
||||||
let mut message = Map::new();
|
|
||||||
message.insert(
|
|
||||||
"role".to_string(),
|
|
||||||
Value::String(state.role.unwrap_or_else(|| "assistant".to_string())),
|
|
||||||
);
|
|
||||||
if state.tool_calls.is_empty() {
|
|
||||||
message.insert("content".to_string(), Value::String(state.content));
|
|
||||||
} else {
|
|
||||||
if state.content.is_empty() {
|
|
||||||
message.insert("content".to_string(), Value::Null);
|
|
||||||
} else {
|
|
||||||
message.insert("content".to_string(), Value::String(state.content));
|
|
||||||
}
|
|
||||||
let tool_calls = state
|
|
||||||
.tool_calls
|
|
||||||
.into_iter()
|
|
||||||
.map(|(tool_index, tool_state)| {
|
|
||||||
json!({
|
|
||||||
"index": tool_index,
|
|
||||||
"id": tool_state.id,
|
|
||||||
"type": tool_state.tool_type.unwrap_or_else(|| "function".to_string()),
|
|
||||||
"function": {
|
|
||||||
"name": tool_state.function_name,
|
|
||||||
"arguments": tool_state.function_arguments,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
|
||||||
}
|
|
||||||
|
|
||||||
response_choices.push(json!({
|
|
||||||
"index": index,
|
|
||||||
"message": Value::Object(message),
|
|
||||||
"finish_reason": state.finish_reason,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
response_object.insert("choices".to_string(), Value::Array(response_choices));
|
|
||||||
if let Some(usage) = usage {
|
|
||||||
response_object.insert("usage".to_string(), usage);
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(Value::Object(response_object))
|
|
||||||
}
|
|
||||||
@@ -1,696 +0,0 @@
|
|||||||
use base64::Engine as _;
|
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
use crate::ai_pipeline::conversion::response::{
|
|
||||||
convert_claude_cli_response_to_openai_cli, convert_gemini_cli_response_to_openai_cli,
|
|
||||||
};
|
|
||||||
use crate::ai_pipeline::conversion::sync_cli_response_conversion_kind;
|
|
||||||
use crate::ai_pipeline::finalize::common::{
|
|
||||||
build_local_success_outcome, build_local_success_outcome_with_conversion_report,
|
|
||||||
canonicalize_tool_arguments, local_finalize_allows_envelope,
|
|
||||||
unwrap_local_finalize_response_value, LocalCoreSyncFinalizeOutcome,
|
|
||||||
};
|
|
||||||
use crate::ai_pipeline::finalize::standard::claude::aggregate_claude_stream_sync_response;
|
|
||||||
use crate::ai_pipeline::finalize::standard::gemini::aggregate_gemini_stream_sync_response;
|
|
||||||
use crate::control::GatewayControlDecision;
|
|
||||||
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
|
||||||
|
|
||||||
pub(crate) fn maybe_build_local_openai_cli_stream_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if let Some(response) =
|
|
||||||
maybe_build_local_openai_cli_direct_stream_sync_response(trace_id, decision, payload)?
|
|
||||||
{
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
if let Some(response) = maybe_build_local_openai_cli_openai_family_stream_sync_response(
|
|
||||||
trace_id, decision, payload,
|
|
||||||
)? {
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
maybe_build_local_openai_cli_direct_sync_response(trace_id, decision, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn maybe_build_local_openai_cli_cross_format_stream_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if let Some(response) =
|
|
||||||
maybe_build_local_openai_cli_antigravity_cross_format_stream_sync_response(
|
|
||||||
trace_id, decision, payload,
|
|
||||||
)?
|
|
||||||
{
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !matches!(
|
|
||||||
payload.report_kind.as_str(),
|
|
||||||
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
|
|
||||||
) || payload.status_code >= 400
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let _has_envelope = report_context
|
|
||||||
.get("has_envelope")
|
|
||||||
.and_then(Value::as_bool)
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !matches!(client_api_format.as_str(), "openai:cli" | "openai:compact")
|
|
||||||
|| !local_finalize_allows_envelope(report_context)
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let Some(conversion_kind) =
|
|
||||||
sync_cli_response_conversion_kind(&provider_api_format, &client_api_format)
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(body_base64) = payload.body_base64.as_deref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let body_bytes = base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(body_base64)
|
|
||||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
|
||||||
let aggregated = match provider_api_format.as_str() {
|
|
||||||
"openai:cli" | "openai:compact" => aggregate_openai_cli_stream_sync_response(&body_bytes),
|
|
||||||
"claude:chat" | "claude:cli" => aggregate_claude_stream_sync_response(&body_bytes),
|
|
||||||
"gemini:chat" | "gemini:cli" => aggregate_gemini_stream_sync_response(&body_bytes),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
let Some(aggregated) = aggregated else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(aggregated) = unwrap_local_finalize_response_value(aggregated, report_context)? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let converted = match provider_api_format.as_str() {
|
|
||||||
"openai:cli" | "openai:compact" => Some(aggregated.clone()),
|
|
||||||
"claude:chat" | "claude:cli" => {
|
|
||||||
convert_claude_cli_response_to_openai_cli(&aggregated, report_context)
|
|
||||||
}
|
|
||||||
"gemini:chat" | "gemini:cli" => {
|
|
||||||
convert_gemini_cli_response_to_openai_cli(&aggregated, report_context)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
let Some(converted) = converted else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome_with_conversion_report(
|
|
||||||
trace_id, decision, payload, converted, aggregated,
|
|
||||||
)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn maybe_build_local_openai_cli_cross_format_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if let Some(response) = maybe_build_local_openai_cli_antigravity_cross_format_sync_response(
|
|
||||||
trace_id, decision, payload,
|
|
||||||
)? {
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !matches!(
|
|
||||||
payload.report_kind.as_str(),
|
|
||||||
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
|
|
||||||
) || payload.status_code >= 400
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if !matches!(client_api_format.as_str(), "openai:cli" | "openai:compact")
|
|
||||||
|| !local_finalize_allows_envelope(report_context)
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let Some(conversion_kind) =
|
|
||||||
sync_cli_response_conversion_kind(&provider_api_format, &client_api_format)
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(body_json) = payload.body_json.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let converted = match provider_api_format.as_str() {
|
|
||||||
"openai:cli" | "openai:compact" => Some(body_json.clone()),
|
|
||||||
"claude:chat" | "claude:cli" => {
|
|
||||||
convert_claude_cli_response_to_openai_cli(&body_json, report_context)
|
|
||||||
}
|
|
||||||
"gemini:chat" | "gemini:cli" => {
|
|
||||||
convert_gemini_cli_response_to_openai_cli(&body_json, report_context)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
let Some(converted) = converted else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome_with_conversion_report(
|
|
||||||
trace_id, decision, payload, converted, body_json,
|
|
||||||
)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn maybe_build_local_openai_cli_direct_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if !matches!(
|
|
||||||
payload.report_kind.as_str(),
|
|
||||||
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
|
|
||||||
) || payload.status_code >= 400
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
|
|
||||||
if !local_finalize_allows_envelope(report_context)
|
|
||||||
|| !is_openai_cli_family_api_format(provider_api_format.as_str())
|
|
||||||
|| !is_openai_cli_family_api_format(client_api_format.as_str())
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(body_json) = payload.body_json.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome(
|
|
||||||
trace_id, decision, payload, body_json,
|
|
||||||
)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn maybe_build_local_openai_cli_direct_stream_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if !matches!(
|
|
||||||
payload.report_kind.as_str(),
|
|
||||||
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
|
|
||||||
) || payload.status_code >= 400
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let needs_conversion = report_context
|
|
||||||
.get("needs_conversion")
|
|
||||||
.and_then(Value::as_bool)
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !local_finalize_allows_envelope(report_context) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
if !matches!(
|
|
||||||
provider_api_format.as_str(),
|
|
||||||
"openai:cli" | "openai:compact"
|
|
||||||
) || provider_api_format != client_api_format
|
|
||||||
|| needs_conversion
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(body_base64) = payload.body_base64.as_deref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let body_bytes = base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(body_base64)
|
|
||||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
|
||||||
let body_json = match aggregate_openai_cli_stream_sync_response(&body_bytes) {
|
|
||||||
Some(body_json) => body_json,
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
let Some(body_json) = unwrap_local_finalize_response_value(body_json, report_context)? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome(
|
|
||||||
trace_id, decision, payload, body_json,
|
|
||||||
)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn maybe_build_local_openai_cli_openai_family_stream_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if !matches!(
|
|
||||||
payload.report_kind.as_str(),
|
|
||||||
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
|
|
||||||
) || payload.status_code >= 400
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if !local_finalize_allows_envelope(report_context)
|
|
||||||
|| !is_openai_cli_family_api_format(provider_api_format.as_str())
|
|
||||||
|| !is_openai_cli_family_api_format(client_api_format.as_str())
|
|
||||||
|| provider_api_format == client_api_format
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(body_base64) = payload.body_base64.as_deref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let body_bytes = base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(body_base64)
|
|
||||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
|
||||||
let Some(body_json) = aggregate_openai_cli_stream_sync_response(&body_bytes) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(body_json) = unwrap_local_finalize_response_value(body_json, report_context)? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome(
|
|
||||||
trace_id, decision, payload, body_json,
|
|
||||||
)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn maybe_build_local_openai_cli_antigravity_cross_format_stream_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if !matches!(
|
|
||||||
payload.report_kind.as_str(),
|
|
||||||
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
|
|
||||||
) || payload.status_code >= 400
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if provider_api_format != "gemini:cli"
|
|
||||||
|| !is_openai_cli_family_api_format(client_api_format.as_str())
|
|
||||||
|| !is_antigravity_v1internal_envelope(report_context)
|
|
||||||
|| !local_finalize_allows_envelope(report_context)
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(body_base64) = payload.body_base64.as_deref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let body_bytes = base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(body_base64)
|
|
||||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
|
||||||
let Some(aggregated) = aggregate_gemini_stream_sync_response(&body_bytes) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(provider_body_json) =
|
|
||||||
unwrap_cli_conversion_response_value(aggregated, report_context)?
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(converted) =
|
|
||||||
convert_gemini_cli_response_to_openai_cli(&provider_body_json, report_context)
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome_with_conversion_report(
|
|
||||||
trace_id,
|
|
||||||
decision,
|
|
||||||
payload,
|
|
||||||
converted,
|
|
||||||
provider_body_json,
|
|
||||||
)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn maybe_build_local_openai_cli_antigravity_cross_format_sync_response(
|
|
||||||
trace_id: &str,
|
|
||||||
decision: &GatewayControlDecision,
|
|
||||||
payload: &GatewaySyncReportRequest,
|
|
||||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
|
||||||
if !matches!(
|
|
||||||
payload.report_kind.as_str(),
|
|
||||||
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
|
|
||||||
) || payload.status_code >= 400
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(report_context) = payload.report_context.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if provider_api_format != "gemini:cli"
|
|
||||||
|| !is_openai_cli_family_api_format(client_api_format.as_str())
|
|
||||||
|| !is_antigravity_v1internal_envelope(report_context)
|
|
||||||
|| !local_finalize_allows_envelope(report_context)
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(body_json) = payload.body_json.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(provider_body_json) =
|
|
||||||
unwrap_cli_conversion_response_value(body_json.clone(), report_context)?
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(converted) =
|
|
||||||
convert_gemini_cli_response_to_openai_cli(&provider_body_json, report_context)
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(build_local_success_outcome_with_conversion_report(
|
|
||||||
trace_id,
|
|
||||||
decision,
|
|
||||||
payload,
|
|
||||||
converted,
|
|
||||||
provider_body_json,
|
|
||||||
)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unwrap_cli_conversion_response_value(
|
|
||||||
data: Value,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Result<Option<Value>, GatewayError> {
|
|
||||||
if !is_antigravity_v1internal_envelope(report_context) {
|
|
||||||
return unwrap_local_finalize_response_value(data, report_context);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut unwrapped = if let Some(response) = data
|
|
||||||
.get("response")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.filter(|response| !response.contains_key("response"))
|
|
||||||
{
|
|
||||||
let mut response = response.clone();
|
|
||||||
if let Some(response_id) = data.get("responseId").cloned() {
|
|
||||||
response
|
|
||||||
.entry("responseId".to_string())
|
|
||||||
.or_insert(response_id);
|
|
||||||
}
|
|
||||||
Value::Object(response)
|
|
||||||
} else {
|
|
||||||
data
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(object) = unwrapped.as_object_mut() {
|
|
||||||
if !object.contains_key("responseId") {
|
|
||||||
if let Some(response_id) = object.get("_v1internal_response_id").cloned() {
|
|
||||||
object.insert("responseId".to_string(), response_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Some(unwrapped))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_antigravity_v1internal_envelope(report_context: &Value) -> bool {
|
|
||||||
report_context
|
|
||||||
.get("has_envelope")
|
|
||||||
.and_then(Value::as_bool)
|
|
||||||
.unwrap_or(false)
|
|
||||||
&& report_context
|
|
||||||
.get("envelope_name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(|value| value.eq_ignore_ascii_case("antigravity:v1internal"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_openai_cli_family_api_format(api_format: &str) -> bool {
|
|
||||||
matches!(api_format, "openai:cli" | "openai:compact")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn build_openai_cli_response(
|
|
||||||
response_id: &str,
|
|
||||||
model: &str,
|
|
||||||
text: &str,
|
|
||||||
function_calls: Vec<Value>,
|
|
||||||
prompt_tokens: u64,
|
|
||||||
output_tokens: u64,
|
|
||||||
total_tokens: u64,
|
|
||||||
) -> Value {
|
|
||||||
let mut output = Vec::new();
|
|
||||||
if !text.is_empty() {
|
|
||||||
output.push(json!({
|
|
||||||
"type": "message",
|
|
||||||
"id": format!("{response_id}_msg"),
|
|
||||||
"role": "assistant",
|
|
||||||
"status": "completed",
|
|
||||||
"content": [{
|
|
||||||
"type": "output_text",
|
|
||||||
"text": text,
|
|
||||||
"annotations": []
|
|
||||||
}]
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
output.extend(function_calls);
|
|
||||||
json!({
|
|
||||||
"id": response_id,
|
|
||||||
"object": "response",
|
|
||||||
"status": "completed",
|
|
||||||
"model": model,
|
|
||||||
"output": output,
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": prompt_tokens,
|
|
||||||
"output_tokens": output_tokens,
|
|
||||||
"total_tokens": total_tokens,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn convert_openai_chat_response_to_openai_cli(
|
|
||||||
body_json: &Value,
|
|
||||||
report_context: &Value,
|
|
||||||
compact: bool,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let body = body_json.as_object()?;
|
|
||||||
let choices = body.get("choices")?.as_array()?;
|
|
||||||
let first_choice = choices.first()?.as_object()?;
|
|
||||||
let message = first_choice.get("message")?.as_object()?;
|
|
||||||
let mut text = String::new();
|
|
||||||
match message.get("content") {
|
|
||||||
Some(Value::String(value)) => text.push_str(value),
|
|
||||||
Some(Value::Array(parts)) => {
|
|
||||||
for part in parts {
|
|
||||||
let part = part.as_object()?;
|
|
||||||
let part_type = part
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if matches!(part_type.as_str(), "text" | "output_text") {
|
|
||||||
if let Some(piece) = part.get("text").and_then(Value::as_str) {
|
|
||||||
text.push_str(piece);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Value::Null) | None => {}
|
|
||||||
_ => return None,
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut function_calls = Vec::new();
|
|
||||||
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
|
|
||||||
for tool_call in tool_call_values {
|
|
||||||
let tool_call = tool_call.as_object()?;
|
|
||||||
let function = tool_call.get("function")?.as_object()?;
|
|
||||||
let tool_name = function
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
function_calls.push(json!({
|
|
||||||
"type": "function_call",
|
|
||||||
"id": tool_call.get("id").cloned().unwrap_or(Value::Null),
|
|
||||||
"call_id": tool_call.get("id").cloned().unwrap_or(Value::Null),
|
|
||||||
"name": tool_name,
|
|
||||||
"arguments": canonicalize_tool_arguments(function.get("arguments").cloned()),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let usage = body.get("usage").and_then(Value::as_object);
|
|
||||||
let prompt_tokens = usage
|
|
||||||
.and_then(|value| value.get("prompt_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let output_tokens = usage
|
|
||||||
.and_then(|value| value.get("completion_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let total_tokens = usage
|
|
||||||
.and_then(|value| value.get("total_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(prompt_tokens + output_tokens);
|
|
||||||
let response_id = if compact {
|
|
||||||
body.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(|value| value.replace("chatcmpl", "resp"))
|
|
||||||
.unwrap_or_else(|| "resp-local-finalize".to_string())
|
|
||||||
} else {
|
|
||||||
body.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(|value| value.replace("chatcmpl", "resp"))
|
|
||||||
.unwrap_or_else(|| "resp-local-finalize".to_string())
|
|
||||||
};
|
|
||||||
let model = body
|
|
||||||
.get("model")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
|
||||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
|
|
||||||
Some(build_openai_cli_response(
|
|
||||||
&response_id,
|
|
||||||
model,
|
|
||||||
&text,
|
|
||||||
function_calls,
|
|
||||||
prompt_tokens,
|
|
||||||
output_tokens,
|
|
||||||
total_tokens,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn aggregate_openai_cli_stream_sync_response(body: &[u8]) -> Option<Value> {
|
|
||||||
let text = std::str::from_utf8(body).ok()?;
|
|
||||||
|
|
||||||
for raw_line in text.lines() {
|
|
||||||
let line = raw_line.trim_matches('\r').trim();
|
|
||||||
if line.is_empty() || line.starts_with(':') || line.starts_with("event:") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(data_line) = line.strip_prefix("data:") else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let data_line = data_line.trim();
|
|
||||||
if data_line.is_empty() || data_line == "[DONE]" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let event: Value = serde_json::from_str(data_line).ok()?;
|
|
||||||
let event_type = event
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if event_type == "response.completed" {
|
|
||||||
let response = event.get("response")?.as_object()?.clone();
|
|
||||||
return Some(Value::Object(response));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
mod chat;
|
|
||||||
mod cli;
|
|
||||||
|
|
||||||
pub(crate) use chat::{
|
|
||||||
aggregate_openai_chat_stream_sync_response, convert_openai_cli_response_to_openai_chat,
|
|
||||||
maybe_build_local_openai_chat_cross_format_stream_sync_response,
|
|
||||||
maybe_build_local_openai_chat_cross_format_sync_response,
|
|
||||||
maybe_build_local_openai_chat_stream_sync_response,
|
|
||||||
maybe_build_local_openai_chat_sync_response,
|
|
||||||
};
|
|
||||||
pub(crate) use cli::{
|
|
||||||
aggregate_openai_cli_stream_sync_response, build_openai_cli_response,
|
|
||||||
maybe_build_local_openai_cli_cross_format_stream_sync_response,
|
|
||||||
maybe_build_local_openai_cli_cross_format_sync_response,
|
|
||||||
maybe_build_local_openai_cli_stream_sync_response,
|
|
||||||
};
|
|
||||||
@@ -1,222 +1 @@
|
|||||||
use serde_json::{json, Map, Value};
|
pub(crate) use aether_ai_pipeline::finalize::standard::stream_core::common::*;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub(crate) struct CanonicalUsage {
|
|
||||||
pub(crate) input_tokens: u64,
|
|
||||||
pub(crate) output_tokens: u64,
|
|
||||||
pub(crate) total_tokens: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub(crate) enum CanonicalStreamEvent {
|
|
||||||
Start,
|
|
||||||
TextDelta(String),
|
|
||||||
ToolCallStart {
|
|
||||||
index: usize,
|
|
||||||
call_id: String,
|
|
||||||
name: String,
|
|
||||||
},
|
|
||||||
ToolCallArgumentsDelta {
|
|
||||||
index: usize,
|
|
||||||
arguments: String,
|
|
||||||
},
|
|
||||||
Finish {
|
|
||||||
finish_reason: Option<String>,
|
|
||||||
usage: Option<CanonicalUsage>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub(crate) struct CanonicalStreamFrame {
|
|
||||||
pub(crate) id: String,
|
|
||||||
pub(crate) model: String,
|
|
||||||
pub(crate) event: CanonicalStreamEvent,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn decode_json_data_line(line: &[u8]) -> Option<Value> {
|
|
||||||
let text = std::str::from_utf8(line).ok()?;
|
|
||||||
let trimmed = text.trim_matches('\r').trim();
|
|
||||||
if trimmed.is_empty() || trimmed.starts_with(':') || trimmed.starts_with("event:") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let data_line = trimmed.strip_prefix("data:")?.trim();
|
|
||||||
if data_line.is_empty() || data_line == "[DONE]" {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
serde_json::from_str(data_line).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn resolve_identity(
|
|
||||||
response_id: Option<&str>,
|
|
||||||
model: Option<&str>,
|
|
||||||
report_context: &Value,
|
|
||||||
default_id: &str,
|
|
||||||
) -> (String, String) {
|
|
||||||
let id = response_id
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.unwrap_or(default_id)
|
|
||||||
.to_string();
|
|
||||||
let model = model
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
|
||||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string();
|
|
||||||
(id, model)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
|
|
||||||
let usage = value?.as_object()?;
|
|
||||||
let input_tokens = usage
|
|
||||||
.get("input_tokens")
|
|
||||||
.or_else(|| usage.get("prompt_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let output_tokens = usage
|
|
||||||
.get("output_tokens")
|
|
||||||
.or_else(|| usage.get("completion_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let total_tokens = usage
|
|
||||||
.get("total_tokens")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(input_tokens + output_tokens);
|
|
||||||
Some(CanonicalUsage {
|
|
||||||
input_tokens,
|
|
||||||
output_tokens,
|
|
||||||
total_tokens,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn canonical_usage_from_claude_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
|
|
||||||
let usage = value?.as_object()?;
|
|
||||||
let input_tokens = usage
|
|
||||||
.get("input_tokens")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let output_tokens = usage
|
|
||||||
.get("output_tokens")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
Some(CanonicalUsage {
|
|
||||||
input_tokens,
|
|
||||||
output_tokens,
|
|
||||||
total_tokens: input_tokens + output_tokens,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn canonical_usage_from_gemini_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
|
|
||||||
let usage = value?.as_object()?;
|
|
||||||
let input_tokens = usage
|
|
||||||
.get("promptTokenCount")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let output_tokens = usage
|
|
||||||
.get("candidatesTokenCount")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let total_tokens = usage
|
|
||||||
.get("totalTokenCount")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(input_tokens + output_tokens);
|
|
||||||
Some(CanonicalUsage {
|
|
||||||
input_tokens,
|
|
||||||
output_tokens,
|
|
||||||
total_tokens,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn normalize_openai_finish_reason(value: Option<&str>) -> Option<String> {
|
|
||||||
match value {
|
|
||||||
Some("function_call") => Some("tool_calls".to_string()),
|
|
||||||
Some(other) if !other.trim().is_empty() => Some(other.to_string()),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn map_openai_finish_reason_to_claude(value: Option<&str>) -> &'static str {
|
|
||||||
match value {
|
|
||||||
Some("length") => "max_tokens",
|
|
||||||
Some("tool_calls") | Some("function_call") => "tool_use",
|
|
||||||
Some("content_filter") => "content_filtered",
|
|
||||||
_ => "end_turn",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn map_openai_finish_reason_to_gemini(value: Option<&str>) -> &'static str {
|
|
||||||
match value {
|
|
||||||
Some("length") => "MAX_TOKENS",
|
|
||||||
Some("content_filter") => "SAFETY",
|
|
||||||
_ => "STOP",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn parse_json_arguments_value(arguments: &str) -> Option<Value> {
|
|
||||||
let trimmed = arguments.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return Some(Value::Object(Map::new()));
|
|
||||||
}
|
|
||||||
serde_json::from_str(trimmed).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn build_openai_chat_chunk(
|
|
||||||
id: &str,
|
|
||||||
model: &str,
|
|
||||||
text: String,
|
|
||||||
tool_calls: Option<Vec<Value>>,
|
|
||||||
finish_reason: Option<&str>,
|
|
||||||
) -> Value {
|
|
||||||
let mut delta = Map::new();
|
|
||||||
delta.insert("role".to_string(), Value::String("assistant".to_string()));
|
|
||||||
if !text.is_empty() {
|
|
||||||
delta.insert("content".to_string(), Value::String(text));
|
|
||||||
} else if tool_calls.is_none() {
|
|
||||||
delta.insert("content".to_string(), Value::String(String::new()));
|
|
||||||
}
|
|
||||||
if let Some(tool_calls) = tool_calls {
|
|
||||||
delta.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
|
||||||
}
|
|
||||||
|
|
||||||
json!({
|
|
||||||
"id": id,
|
|
||||||
"object": "chat.completion.chunk",
|
|
||||||
"model": model,
|
|
||||||
"choices": [{
|
|
||||||
"index": 0,
|
|
||||||
"delta": Value::Object(delta),
|
|
||||||
"finish_reason": finish_reason,
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn build_openai_chat_role_chunk(id: &str, model: &str) -> Value {
|
|
||||||
json!({
|
|
||||||
"id": id,
|
|
||||||
"object": "chat.completion.chunk",
|
|
||||||
"model": model,
|
|
||||||
"choices": [{
|
|
||||||
"index": 0,
|
|
||||||
"delta": {
|
|
||||||
"role": "assistant"
|
|
||||||
},
|
|
||||||
"finish_reason": Value::Null
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn build_openai_chat_finish_chunk(
|
|
||||||
id: &str,
|
|
||||||
model: &str,
|
|
||||||
finish_reason: Option<&str>,
|
|
||||||
) -> Value {
|
|
||||||
json!({
|
|
||||||
"id": id,
|
|
||||||
"object": "chat.completion.chunk",
|
|
||||||
"model": model,
|
|
||||||
"choices": [{
|
|
||||||
"index": 0,
|
|
||||||
"delta": {},
|
|
||||||
"finish_reason": finish_reason,
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,100 +1,7 @@
|
|||||||
//! Standard finalize streaming conversion helpers.
|
//! Standard finalize streaming conversion helpers.
|
||||||
|
pub use aether_ai_pipeline::finalize::standard::stream_core::CanonicalStreamFrame;
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
use crate::GatewayError;
|
|
||||||
|
|
||||||
use super::claude::stream::{ClaudeClientEmitter, ClaudeProviderState};
|
|
||||||
use super::gemini::stream::{GeminiClientEmitter, GeminiProviderState};
|
|
||||||
use super::openai::stream::{
|
|
||||||
OpenAIChatClientEmitter, OpenAIChatProviderState, OpenAICliClientEmitter,
|
|
||||||
OpenAICliProviderState,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub(crate) mod common;
|
pub(crate) mod common;
|
||||||
mod orchestrator;
|
mod orchestrator;
|
||||||
|
|
||||||
use common::CanonicalStreamFrame;
|
|
||||||
|
|
||||||
pub(crate) enum ProviderStreamParser {
|
|
||||||
OpenAIChat(OpenAIChatProviderState),
|
|
||||||
OpenAICli(OpenAICliProviderState),
|
|
||||||
Claude(ClaudeProviderState),
|
|
||||||
Gemini(GeminiProviderState),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProviderStreamParser {
|
|
||||||
pub(crate) fn for_api_format(provider_api_format: &str) -> Option<Self> {
|
|
||||||
Some(match provider_api_format {
|
|
||||||
"openai:chat" => Self::OpenAIChat(OpenAIChatProviderState::default()),
|
|
||||||
"openai:cli" | "openai:compact" => Self::OpenAICli(OpenAICliProviderState::default()),
|
|
||||||
"claude:chat" | "claude:cli" => Self::Claude(ClaudeProviderState::default()),
|
|
||||||
"gemini:chat" | "gemini:cli" => Self::Gemini(GeminiProviderState::default()),
|
|
||||||
_ => return None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn push_line(
|
|
||||||
&mut self,
|
|
||||||
report_context: &Value,
|
|
||||||
line: Vec<u8>,
|
|
||||||
) -> Result<Vec<CanonicalStreamFrame>, GatewayError> {
|
|
||||||
match self {
|
|
||||||
ProviderStreamParser::OpenAIChat(state) => state.push_line(report_context, line),
|
|
||||||
ProviderStreamParser::OpenAICli(state) => state.push_line(report_context, line),
|
|
||||||
ProviderStreamParser::Claude(state) => state.push_line(report_context, line),
|
|
||||||
ProviderStreamParser::Gemini(state) => state.push_line(report_context, line),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn finish(
|
|
||||||
&mut self,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Result<Vec<CanonicalStreamFrame>, GatewayError> {
|
|
||||||
match self {
|
|
||||||
ProviderStreamParser::OpenAIChat(state) => state.finish(report_context),
|
|
||||||
ProviderStreamParser::OpenAICli(state) => state.finish(report_context),
|
|
||||||
ProviderStreamParser::Claude(state) => state.finish(report_context),
|
|
||||||
ProviderStreamParser::Gemini(state) => state.finish(report_context),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) enum ClientStreamEmitter {
|
|
||||||
OpenAIChat(OpenAIChatClientEmitter),
|
|
||||||
OpenAICli(OpenAICliClientEmitter),
|
|
||||||
Claude(ClaudeClientEmitter),
|
|
||||||
Gemini(GeminiClientEmitter),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClientStreamEmitter {
|
|
||||||
pub(crate) fn for_api_format(client_api_format: &str) -> Option<Self> {
|
|
||||||
Some(match client_api_format {
|
|
||||||
"openai:chat" => Self::OpenAIChat(OpenAIChatClientEmitter::default()),
|
|
||||||
"openai:cli" | "openai:compact" => Self::OpenAICli(OpenAICliClientEmitter::default()),
|
|
||||||
"claude:chat" | "claude:cli" => Self::Claude(ClaudeClientEmitter::default()),
|
|
||||||
"gemini:chat" | "gemini:cli" => Self::Gemini(GeminiClientEmitter::default()),
|
|
||||||
_ => return None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, GatewayError> {
|
|
||||||
match self {
|
|
||||||
ClientStreamEmitter::OpenAIChat(state) => state.emit(frame),
|
|
||||||
ClientStreamEmitter::OpenAICli(state) => state.emit(frame),
|
|
||||||
ClientStreamEmitter::Claude(state) => state.emit(frame),
|
|
||||||
ClientStreamEmitter::Gemini(state) => state.emit(frame),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn finish(&mut self) -> Result<Vec<u8>, GatewayError> {
|
|
||||||
match self {
|
|
||||||
ClientStreamEmitter::OpenAIChat(state) => state.finish(),
|
|
||||||
ClientStreamEmitter::OpenAICli(state) => state.finish(),
|
|
||||||
ClientStreamEmitter::Claude(state) => state.finish(),
|
|
||||||
ClientStreamEmitter::Gemini(state) => state.finish(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) use orchestrator::StreamingStandardConversionState;
|
pub(crate) use orchestrator::StreamingStandardConversionState;
|
||||||
|
|||||||
@@ -3,14 +3,11 @@ use serde_json::Value;
|
|||||||
use crate::ai_pipeline::adaptation::private_envelope::transform_provider_private_stream_line as transform_envelope_line;
|
use crate::ai_pipeline::adaptation::private_envelope::transform_provider_private_stream_line as transform_envelope_line;
|
||||||
use crate::ai_pipeline::adaptation::surfaces::provider_adaptation_should_unwrap_stream_envelope;
|
use crate::ai_pipeline::adaptation::surfaces::provider_adaptation_should_unwrap_stream_envelope;
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
|
use aether_ai_pipeline::finalize::standard::stream_core::StreamingStandardFormatMatrix;
|
||||||
use super::common::CanonicalStreamFrame;
|
|
||||||
use super::{ClientStreamEmitter, ProviderStreamParser};
|
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub(crate) struct StreamingStandardConversionState {
|
pub(crate) struct StreamingStandardConversionState {
|
||||||
provider: Option<ProviderStreamParser>,
|
matrix: StreamingStandardFormatMatrix,
|
||||||
client: Option<ClientStreamEmitter>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StreamingStandardConversionState {
|
impl StreamingStandardConversionState {
|
||||||
@@ -19,7 +16,6 @@ impl StreamingStandardConversionState {
|
|||||||
report_context: &Value,
|
report_context: &Value,
|
||||||
line: Vec<u8>,
|
line: Vec<u8>,
|
||||||
) -> Result<Vec<u8>, GatewayError> {
|
) -> Result<Vec<u8>, GatewayError> {
|
||||||
self.ensure_initialized(report_context)?;
|
|
||||||
let line = if should_unwrap_envelope(report_context) {
|
let line = if should_unwrap_envelope(report_context) {
|
||||||
transform_envelope_line(report_context, line)?
|
transform_envelope_line(report_context, line)?
|
||||||
} else {
|
} else {
|
||||||
@@ -28,59 +24,13 @@ impl StreamingStandardConversionState {
|
|||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let Some(provider) = self.provider.as_mut() else {
|
self.matrix
|
||||||
return Ok(Vec::new());
|
.transform_line(report_context, line)
|
||||||
};
|
.map_err(Into::into)
|
||||||
let frames = provider.push_line(report_context, line)?;
|
|
||||||
self.emit_frames(frames)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn finish(&mut self, report_context: &Value) -> Result<Vec<u8>, GatewayError> {
|
pub(crate) fn finish(&mut self, report_context: &Value) -> Result<Vec<u8>, GatewayError> {
|
||||||
self.ensure_initialized(report_context)?;
|
self.matrix.finish(report_context).map_err(Into::into)
|
||||||
let Some(provider) = self.provider.as_mut() else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
let frames = provider.finish(report_context)?;
|
|
||||||
let mut out = self.emit_frames(frames)?;
|
|
||||||
if let Some(client) = self.client.as_mut() {
|
|
||||||
out.extend(client.finish()?);
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_initialized(&mut self, report_context: &Value) -> Result<(), GatewayError> {
|
|
||||||
if self.provider.is_some() && self.client.is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let provider_api_format = report_context
|
|
||||||
.get("provider_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let client_api_format = report_context
|
|
||||||
.get("client_api_format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
|
|
||||||
self.provider = ProviderStreamParser::for_api_format(provider_api_format.as_str());
|
|
||||||
self.client = ClientStreamEmitter::for_api_format(client_api_format.as_str());
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_frames(&mut self, frames: Vec<CanonicalStreamFrame>) -> Result<Vec<u8>, GatewayError> {
|
|
||||||
let Some(client) = self.client.as_mut() else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for frame in frames {
|
|
||||||
out.extend(client.emit(frame)?);
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ use super::{
|
|||||||
convert_gemini_chat_response_to_openai_chat, convert_gemini_cli_response_to_openai_cli,
|
convert_gemini_chat_response_to_openai_chat, convert_gemini_cli_response_to_openai_cli,
|
||||||
maybe_build_local_core_sync_finalize_response,
|
maybe_build_local_core_sync_finalize_response,
|
||||||
};
|
};
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::ai_pipeline::control_facade::GatewayControlDecision;
|
||||||
|
use crate::ai_pipeline::conversion::response::{
|
||||||
|
convert_openai_chat_response_to_openai_cli, convert_openai_cli_response_to_openai_chat,
|
||||||
|
};
|
||||||
use crate::usage::GatewaySyncReportRequest;
|
use crate::usage::GatewaySyncReportRequest;
|
||||||
|
|
||||||
fn test_decision() -> GatewayControlDecision {
|
fn test_decision() -> GatewayControlDecision {
|
||||||
@@ -368,6 +371,39 @@ fn converts_claude_chat_tool_use_to_openai_chat_tool_calls() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_claude_chat_thinking_block_to_openai_reasoning_content() {
|
||||||
|
let result = convert_claude_chat_response_to_openai_chat(
|
||||||
|
&json!({
|
||||||
|
"id": "msg_think_123",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": "claude-sonnet-4-upstream",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "Need to reason first."},
|
||||||
|
{"type": "text", "text": "Final answer"}
|
||||||
|
],
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 5,
|
||||||
|
"output_tokens": 7
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
&json!({
|
||||||
|
"client_api_format": "openai:chat",
|
||||||
|
"provider_api_format": "claude:chat",
|
||||||
|
"model": "gpt-5"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("result should exist");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result["choices"][0]["message"]["reasoning_content"],
|
||||||
|
"Need to reason first."
|
||||||
|
);
|
||||||
|
assert_eq!(result["choices"][0]["message"]["content"], "Final answer");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn converts_gemini_chat_response_to_openai_chat_response() {
|
fn converts_gemini_chat_response_to_openai_chat_response() {
|
||||||
let result = convert_gemini_chat_response_to_openai_chat(
|
let result = convert_gemini_chat_response_to_openai_chat(
|
||||||
@@ -481,6 +517,223 @@ fn converts_gemini_chat_function_call_to_openai_chat_tool_calls() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_gemini_chat_thought_part_to_openai_reasoning_content() {
|
||||||
|
let result = convert_gemini_chat_response_to_openai_chat(
|
||||||
|
&json!({
|
||||||
|
"responseId": "resp_think_123",
|
||||||
|
"candidates": [{
|
||||||
|
"content": {
|
||||||
|
"parts": [
|
||||||
|
{"text": "Internal reasoning.", "thought": true},
|
||||||
|
{"text": "Visible answer"}
|
||||||
|
],
|
||||||
|
"role": "model"
|
||||||
|
},
|
||||||
|
"finishReason": "STOP",
|
||||||
|
"index": 0
|
||||||
|
}],
|
||||||
|
"modelVersion": "gemini-2.5-pro-upstream",
|
||||||
|
"usageMetadata": {
|
||||||
|
"promptTokenCount": 1,
|
||||||
|
"candidatesTokenCount": 2,
|
||||||
|
"totalTokenCount": 3
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
&json!({
|
||||||
|
"client_api_format": "openai:chat",
|
||||||
|
"provider_api_format": "gemini:chat",
|
||||||
|
"model": "gpt-5"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("result should exist");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result["choices"][0]["message"]["reasoning_content"],
|
||||||
|
"Internal reasoning."
|
||||||
|
);
|
||||||
|
assert_eq!(result["choices"][0]["message"]["content"], "Visible answer");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_gemini_chat_inline_data_to_openai_chat_image_part() {
|
||||||
|
let result = convert_gemini_chat_response_to_openai_chat(
|
||||||
|
&json!({
|
||||||
|
"responseId": "resp_img_123",
|
||||||
|
"candidates": [{
|
||||||
|
"content": {
|
||||||
|
"parts": [{
|
||||||
|
"inlineData": {
|
||||||
|
"mimeType": "image/png",
|
||||||
|
"data": "iVBORw0KGgo="
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
"role": "model"
|
||||||
|
},
|
||||||
|
"finishReason": "STOP",
|
||||||
|
"index": 0
|
||||||
|
}],
|
||||||
|
"modelVersion": "gemini-2.5-pro-upstream",
|
||||||
|
"usageMetadata": {
|
||||||
|
"promptTokenCount": 1,
|
||||||
|
"candidatesTokenCount": 2,
|
||||||
|
"totalTokenCount": 3
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
&json!({
|
||||||
|
"client_api_format": "openai:chat",
|
||||||
|
"provider_api_format": "gemini:chat",
|
||||||
|
"model": "gpt-5"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("result should exist");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result["choices"][0]["message"]["content"],
|
||||||
|
json!([{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": "data:image/png;base64,iVBORw0KGgo="
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_openai_cli_reasoning_item_to_openai_chat_reasoning_content() {
|
||||||
|
let result = convert_openai_cli_response_to_openai_chat(
|
||||||
|
&json!({
|
||||||
|
"id": "resp_reason_123",
|
||||||
|
"object": "response",
|
||||||
|
"model": "gpt-5",
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"type": "reasoning",
|
||||||
|
"id": "rs_1",
|
||||||
|
"summary": [{
|
||||||
|
"type": "summary_text",
|
||||||
|
"text": "Thinking summary."
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"id": "msg_1",
|
||||||
|
"role": "assistant",
|
||||||
|
"status": "completed",
|
||||||
|
"content": [{
|
||||||
|
"type": "output_text",
|
||||||
|
"text": "Final answer",
|
||||||
|
"annotations": []
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 3,
|
||||||
|
"output_tokens": 5,
|
||||||
|
"total_tokens": 8
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
&json!({
|
||||||
|
"client_api_format": "openai:chat",
|
||||||
|
"provider_api_format": "openai:cli",
|
||||||
|
"model": "gpt-5"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("result should exist");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result["choices"][0]["message"]["reasoning_content"],
|
||||||
|
"Thinking summary."
|
||||||
|
);
|
||||||
|
assert_eq!(result["choices"][0]["message"]["content"], "Final answer");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_openai_cli_output_image_to_openai_chat_image_part() {
|
||||||
|
let result = convert_openai_cli_response_to_openai_chat(
|
||||||
|
&json!({
|
||||||
|
"id": "resp_img_cli_123",
|
||||||
|
"object": "response",
|
||||||
|
"model": "gpt-5",
|
||||||
|
"output": [{
|
||||||
|
"type": "message",
|
||||||
|
"id": "msg_1",
|
||||||
|
"role": "assistant",
|
||||||
|
"status": "completed",
|
||||||
|
"content": [{
|
||||||
|
"type": "output_image",
|
||||||
|
"image_url": "data:image/png;base64,iVBORw0KGgo="
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 3,
|
||||||
|
"output_tokens": 5,
|
||||||
|
"total_tokens": 8
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
&json!({
|
||||||
|
"client_api_format": "openai:chat",
|
||||||
|
"provider_api_format": "openai:cli",
|
||||||
|
"model": "gpt-5"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("result should exist");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result["choices"][0]["message"]["content"],
|
||||||
|
json!([{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": "data:image/png;base64,iVBORw0KGgo="
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_openai_chat_image_part_to_openai_cli_output_image() {
|
||||||
|
let result = convert_openai_chat_response_to_openai_cli(
|
||||||
|
&json!({
|
||||||
|
"id": "chatcmpl_img_123",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"model": "gpt-5",
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": "data:image/png;base64,iVBORw0KGgo="
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 3,
|
||||||
|
"completion_tokens": 5,
|
||||||
|
"total_tokens": 8
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
&json!({
|
||||||
|
"client_api_format": "openai:cli",
|
||||||
|
"provider_api_format": "openai:chat",
|
||||||
|
"model": "gpt-5"
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("result should exist");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result["output"][0]["content"],
|
||||||
|
json!([{
|
||||||
|
"type": "output_image",
|
||||||
|
"image_url": "data:image/png;base64,iVBORw0KGgo="
|
||||||
|
}])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn converts_claude_cli_response_to_openai_cli_response() {
|
fn converts_claude_cli_response_to_openai_cli_response() {
|
||||||
let result = convert_claude_cli_response_to_openai_cli(
|
let result = convert_claude_cli_response_to_openai_cli(
|
||||||
@@ -721,6 +974,49 @@ fn converts_gemini_cli_function_call_to_openai_cli_function_call() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_gemini_cli_inline_data_to_openai_cli_output_image() {
|
||||||
|
let result = convert_gemini_cli_response_to_openai_cli(
|
||||||
|
&json!({
|
||||||
|
"responseId": "resp_cli_img_123",
|
||||||
|
"candidates": [{
|
||||||
|
"content": {
|
||||||
|
"parts": [{
|
||||||
|
"inlineData": {
|
||||||
|
"mimeType": "image/png",
|
||||||
|
"data": "iVBORw0KGgo="
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
"role": "model"
|
||||||
|
},
|
||||||
|
"finishReason": "STOP",
|
||||||
|
"index": 0
|
||||||
|
}],
|
||||||
|
"modelVersion": "gemini-cli-upstream",
|
||||||
|
"usageMetadata": {
|
||||||
|
"promptTokenCount": 3,
|
||||||
|
"candidatesTokenCount": 5,
|
||||||
|
"thoughtsTokenCount": 2,
|
||||||
|
"totalTokenCount": 10
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
&json!({
|
||||||
|
"client_api_format": "openai:cli",
|
||||||
|
"provider_api_format": "gemini:cli",
|
||||||
|
"model": "gpt-5"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("result should exist");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result["output"][0]["content"],
|
||||||
|
json!([{
|
||||||
|
"type": "output_image",
|
||||||
|
"image_url": "data:image/png;base64,iVBORw0KGgo="
|
||||||
|
}])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_finalize_handles_openai_compact_cross_format_sync_response() {
|
fn local_finalize_handles_openai_compact_cross_format_sync_response() {
|
||||||
let payload = GatewaySyncReportRequest {
|
let payload = GatewaySyncReportRequest {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
pub(crate) mod adaptation;
|
pub(crate) mod adaptation;
|
||||||
pub(crate) mod contracts;
|
pub(crate) mod contracts;
|
||||||
|
pub(crate) mod control_facade;
|
||||||
pub(crate) mod conversion;
|
pub(crate) mod conversion;
|
||||||
|
pub(crate) mod execution_facade;
|
||||||
pub(crate) mod finalize;
|
pub(crate) mod finalize;
|
||||||
pub(crate) mod planner;
|
pub(crate) mod planner;
|
||||||
|
pub(crate) mod provider_transport_facade;
|
||||||
pub(crate) mod runtime;
|
pub(crate) mod runtime;
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
pub(crate) use crate::data::auth::GatewayAuthApiKeySnapshot;
|
||||||
|
use crate::{AppState, GatewayError};
|
||||||
|
|
||||||
|
pub(crate) async fn read_auth_api_key_snapshot(
|
||||||
|
state: &AppState,
|
||||||
|
user_id: &str,
|
||||||
|
api_key_id: &str,
|
||||||
|
now_unix_secs: u64,
|
||||||
|
) -> Result<Option<GatewayAuthApiKeySnapshot>, GatewayError> {
|
||||||
|
state
|
||||||
|
.data
|
||||||
|
.read_auth_api_key_snapshot(user_id, api_key_id, now_unix_secs)
|
||||||
|
.await
|
||||||
|
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::provider_transport::resolve_transport_proxy_snapshot;
|
use crate::ai_pipeline::planner::transport_facade::read_provider_transport_snapshot;
|
||||||
use crate::scheduler::GatewayMinimalCandidateSelectionCandidate;
|
use crate::ai_pipeline::provider_transport_facade::resolve_transport_proxy_snapshot;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
enum TunnelOwnerAffinityBucket {
|
enum TunnelOwnerAffinityBucket {
|
||||||
@@ -13,8 +14,8 @@ enum TunnelOwnerAffinityBucket {
|
|||||||
|
|
||||||
pub(crate) async fn prefer_local_tunnel_owner_candidates(
|
pub(crate) async fn prefer_local_tunnel_owner_candidates(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
candidates: Vec<GatewayMinimalCandidateSelectionCandidate>,
|
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||||
) -> Vec<GatewayMinimalCandidateSelectionCandidate> {
|
) -> Vec<SchedulerMinimalCandidateSelectionCandidate> {
|
||||||
let mut ranked = Vec::with_capacity(candidates.len());
|
let mut ranked = Vec::with_capacity(candidates.len());
|
||||||
for (original_index, candidate) in candidates.into_iter().enumerate() {
|
for (original_index, candidate) in candidates.into_iter().enumerate() {
|
||||||
let bucket = resolve_candidate_tunnel_owner_affinity(state, &candidate).await;
|
let bucket = resolve_candidate_tunnel_owner_affinity(state, &candidate).await;
|
||||||
@@ -29,10 +30,10 @@ pub(crate) async fn prefer_local_tunnel_owner_candidates(
|
|||||||
|
|
||||||
async fn resolve_candidate_tunnel_owner_affinity(
|
async fn resolve_candidate_tunnel_owner_affinity(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
candidate: &GatewayMinimalCandidateSelectionCandidate,
|
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||||
) -> TunnelOwnerAffinityBucket {
|
) -> TunnelOwnerAffinityBucket {
|
||||||
let transport = match state
|
let transport = match read_provider_transport_snapshot(
|
||||||
.read_provider_transport_snapshot(
|
state,
|
||||||
&candidate.provider_id,
|
&candidate.provider_id,
|
||||||
&candidate.endpoint_id,
|
&candidate.endpoint_id,
|
||||||
&candidate.key_id,
|
&candidate.key_id,
|
||||||
@@ -101,14 +102,14 @@ async fn resolve_candidate_tunnel_owner_affinity(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use aether_data::repository::provider_catalog::{
|
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||||
InMemoryProviderCatalogReadRepository, StoredProviderCatalogEndpoint,
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
prefer_local_tunnel_owner_candidates, AppState, GatewayMinimalCandidateSelectionCandidate,
|
prefer_local_tunnel_owner_candidates, AppState, SchedulerMinimalCandidateSelectionCandidate,
|
||||||
};
|
};
|
||||||
use crate::data::GatewayDataState;
|
use crate::data::GatewayDataState;
|
||||||
use crate::tunnel::TunnelAttachmentRecord;
|
use crate::tunnel::TunnelAttachmentRecord;
|
||||||
@@ -116,8 +117,8 @@ mod tests {
|
|||||||
fn sample_candidate(
|
fn sample_candidate(
|
||||||
endpoint_id: &str,
|
endpoint_id: &str,
|
||||||
key_id: &str,
|
key_id: &str,
|
||||||
) -> GatewayMinimalCandidateSelectionCandidate {
|
) -> SchedulerMinimalCandidateSelectionCandidate {
|
||||||
GatewayMinimalCandidateSelectionCandidate {
|
SchedulerMinimalCandidateSelectionCandidate {
|
||||||
provider_id: "provider-1".to_string(),
|
provider_id: "provider-1".to_string(),
|
||||||
provider_name: "provider-1".to_string(),
|
provider_name: "provider-1".to_string(),
|
||||||
provider_type: "custom".to_string(),
|
provider_type: "custom".to_string(),
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{AppState, GatewayError};
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) async fn persist_available_local_candidate(
|
||||||
|
state: &AppState,
|
||||||
|
trace_id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
api_key_id: &str,
|
||||||
|
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||||
|
candidate_index: u32,
|
||||||
|
candidate_id: &str,
|
||||||
|
extra_data: Option<Value>,
|
||||||
|
created_at_unix_secs: u64,
|
||||||
|
error_context: &'static str,
|
||||||
|
) -> String {
|
||||||
|
crate::request_candidate_runtime::persist_available_local_candidate(
|
||||||
|
state,
|
||||||
|
trace_id,
|
||||||
|
user_id,
|
||||||
|
api_key_id,
|
||||||
|
candidate,
|
||||||
|
candidate_index,
|
||||||
|
candidate_id,
|
||||||
|
extra_data,
|
||||||
|
created_at_unix_secs,
|
||||||
|
error_context,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) async fn persist_skipped_local_candidate(
|
||||||
|
state: &AppState,
|
||||||
|
trace_id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
api_key_id: &str,
|
||||||
|
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||||
|
candidate_index: u32,
|
||||||
|
candidate_id: &str,
|
||||||
|
skip_reason: &str,
|
||||||
|
finished_at_unix_secs: u64,
|
||||||
|
error_context: &'static str,
|
||||||
|
) {
|
||||||
|
crate::request_candidate_runtime::persist_skipped_local_candidate(
|
||||||
|
state,
|
||||||
|
trace_id,
|
||||||
|
user_id,
|
||||||
|
api_key_id,
|
||||||
|
candidate,
|
||||||
|
candidate_index,
|
||||||
|
candidate_id,
|
||||||
|
skip_reason,
|
||||||
|
finished_at_unix_secs,
|
||||||
|
error_context,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use axum::body::Bytes;
|
use axum::body::Bytes;
|
||||||
use base64::Engine as _;
|
|
||||||
|
|
||||||
pub(crate) use crate::ai_pipeline::contracts::{
|
use crate::ai_pipeline::control_facade::is_json_request;
|
||||||
|
pub(crate) use aether_ai_pipeline::contracts::{
|
||||||
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||||
CLAUDE_CLI_SYNC_PLAN_KIND, EXECUTION_RUNTIME_STREAM_ACTION,
|
CLAUDE_CLI_SYNC_PLAN_KIND, EXECUTION_RUNTIME_STREAM_ACTION,
|
||||||
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_ACTION,
|
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_ACTION,
|
||||||
@@ -15,25 +15,45 @@ pub(crate) use crate::ai_pipeline::contracts::{
|
|||||||
OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
||||||
OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
||||||
};
|
};
|
||||||
use crate::headers::is_json_request;
|
|
||||||
|
|
||||||
pub(crate) fn parse_direct_request_body(
|
pub(crate) fn parse_direct_request_body(
|
||||||
parts: &http::request::Parts,
|
parts: &http::request::Parts,
|
||||||
body_bytes: &Bytes,
|
body_bytes: &Bytes,
|
||||||
) -> Option<(serde_json::Value, Option<String>)> {
|
) -> Option<(serde_json::Value, Option<String>)> {
|
||||||
if is_json_request(&parts.headers) {
|
aether_ai_pipeline::planner::common::parse_direct_request_body(
|
||||||
if body_bytes.is_empty() {
|
is_json_request(&parts.headers),
|
||||||
Some((serde_json::json!({}), None))
|
body_bytes.as_ref(),
|
||||||
} else {
|
)
|
||||||
serde_json::from_slice::<serde_json::Value>(body_bytes)
|
|
||||||
.ok()
|
|
||||||
.map(|value| (value, None))
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Some((
|
pub(crate) fn force_upstream_streaming_for_provider(
|
||||||
serde_json::json!({}),
|
provider_type: &str,
|
||||||
(!body_bytes.is_empty())
|
provider_api_format: &str,
|
||||||
.then(|| base64::engine::general_purpose::STANDARD.encode(body_bytes)),
|
) -> bool {
|
||||||
))
|
provider_type.trim().eq_ignore_ascii_case("codex")
|
||||||
|
&& provider_api_format
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("openai:cli")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::force_upstream_streaming_for_provider;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forces_streaming_for_codex_openai_cli() {
|
||||||
|
assert!(force_upstream_streaming_for_provider("codex", "openai:cli"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_force_streaming_for_compact_or_other_provider_types() {
|
||||||
|
assert!(!force_upstream_streaming_for_provider(
|
||||||
|
"codex",
|
||||||
|
"openai:compact"
|
||||||
|
));
|
||||||
|
assert!(!force_upstream_streaming_for_provider(
|
||||||
|
"openai",
|
||||||
|
"openai:cli"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::ai_pipeline::control_facade::{GatewayControlAuthContext, GatewayControlDecision};
|
||||||
use crate::ai_pipeline::planner::common::{
|
use crate::ai_pipeline::planner::common::{
|
||||||
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||||
CLAUDE_CLI_SYNC_PLAN_KIND, EXECUTION_RUNTIME_STREAM_ACTION, EXECUTION_RUNTIME_SYNC_ACTION,
|
CLAUDE_CLI_SYNC_PLAN_KIND, EXECUTION_RUNTIME_STREAM_ACTION, EXECUTION_RUNTIME_SYNC_ACTION,
|
||||||
@@ -18,8 +19,6 @@ use crate::ai_pipeline::planner::plan_builders::{
|
|||||||
build_standard_stream_plan_from_decision, build_standard_sync_plan_from_decision,
|
build_standard_stream_plan_from_decision, build_standard_sync_plan_from_decision,
|
||||||
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
||||||
};
|
};
|
||||||
use crate::control::GatewayControlAuthContext;
|
|
||||||
use crate::control::GatewayControlDecision;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AppState, GatewayControlPlanResponse, GatewayControlSyncDecisionResponse, GatewayError,
|
AppState, GatewayControlPlanResponse, GatewayControlSyncDecisionResponse, GatewayError,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ pub(crate) use super::standard::{
|
|||||||
maybe_build_sync_local_openai_cli_decision_payload,
|
maybe_build_sync_local_openai_cli_decision_payload,
|
||||||
maybe_build_sync_local_standard_decision_payload,
|
maybe_build_sync_local_standard_decision_payload,
|
||||||
};
|
};
|
||||||
pub(crate) use crate::scheduler::{
|
pub(crate) use crate::ai_pipeline::planner::{
|
||||||
resolve_execution_runtime_stream_plan_kind as resolve_stream_plan_kind,
|
resolve_execution_runtime_stream_plan_kind as resolve_stream_plan_kind,
|
||||||
resolve_execution_runtime_sync_plan_kind as resolve_sync_plan_kind,
|
resolve_execution_runtime_sync_plan_kind as resolve_sync_plan_kind,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::ai_pipeline::control_facade::GatewayControlDecision;
|
||||||
|
use crate::ai_pipeline::execution_facade::{ConversionMode, ExecutionStrategy};
|
||||||
use crate::ai_pipeline::planner::common::{
|
use crate::ai_pipeline::planner::common::{
|
||||||
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, OPENAI_VIDEO_CONTENT_PLAN_KIND,
|
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, OPENAI_VIDEO_CONTENT_PLAN_KIND,
|
||||||
};
|
};
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::ai_pipeline::planner::{
|
||||||
use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
|
|
||||||
use crate::scheduler::{
|
|
||||||
is_matching_stream_request, resolve_execution_runtime_stream_plan_kind,
|
is_matching_stream_request, resolve_execution_runtime_stream_plan_kind,
|
||||||
};
|
};
|
||||||
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ use std::collections::BTreeMap;
|
|||||||
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::ai_pipeline::control_facade::{
|
||||||
|
resolve_execution_runtime_auth_context, GatewayControlDecision,
|
||||||
|
};
|
||||||
|
use crate::ai_pipeline::execution_facade::{ConversionMode, ExecutionStrategy};
|
||||||
use crate::ai_pipeline::planner::common::{
|
use crate::ai_pipeline::planner::common::{
|
||||||
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_FILES_DELETE_PLAN_KIND,
|
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_FILES_DELETE_PLAN_KIND,
|
||||||
GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
||||||
OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND,
|
OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND,
|
||||||
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
||||||
};
|
};
|
||||||
use crate::control::resolve_execution_runtime_auth_context;
|
use crate::ai_pipeline::planner::resolve_execution_runtime_sync_plan_kind;
|
||||||
use crate::control::GatewayControlDecision;
|
|
||||||
use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
|
|
||||||
use crate::scheduler::resolve_execution_runtime_sync_plan_kind;
|
|
||||||
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
||||||
|
|
||||||
pub(crate) async fn maybe_build_sync_decision_payload(
|
pub(crate) async fn maybe_build_sync_decision_payload(
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
use aether_contracts::ExecutionPlan;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
pub(crate) async fn mark_unused_local_candidate_items<T, FPlan, FContext>(
|
||||||
|
state: &AppState,
|
||||||
|
remaining: Vec<T>,
|
||||||
|
plan: FPlan,
|
||||||
|
report_context: FContext,
|
||||||
|
) where
|
||||||
|
FPlan: Fn(&T) -> &ExecutionPlan,
|
||||||
|
FContext: Fn(&T) -> Option<&Value>,
|
||||||
|
{
|
||||||
|
crate::executor::mark_unused_local_candidate_items(state, remaining, plan, report_context).await
|
||||||
|
}
|
||||||
@@ -1,16 +1,28 @@
|
|||||||
use crate::ai_pipeline::contracts::{
|
use crate::ai_pipeline::contracts::{
|
||||||
GatewayControlPlanResponse, GatewayControlSyncDecisionResponse,
|
GatewayControlPlanResponse, GatewayControlSyncDecisionResponse,
|
||||||
};
|
};
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::ai_pipeline::control_facade::GatewayControlDecision;
|
||||||
use crate::{AppState, GatewayError};
|
use crate::{AppState, GatewayError};
|
||||||
|
|
||||||
|
pub(crate) mod auth_snapshot_facade;
|
||||||
pub(crate) mod candidate_affinity;
|
pub(crate) mod candidate_affinity;
|
||||||
|
pub(crate) mod candidate_runtime_facade;
|
||||||
pub(crate) mod common;
|
pub(crate) mod common;
|
||||||
mod decision;
|
mod decision;
|
||||||
|
pub(crate) mod executor_facade;
|
||||||
pub(crate) mod passthrough;
|
pub(crate) mod passthrough;
|
||||||
pub(crate) mod plan_builders;
|
pub(crate) mod plan_builders;
|
||||||
|
mod route;
|
||||||
|
pub(crate) mod scheduler_facade;
|
||||||
pub(crate) mod specialized;
|
pub(crate) mod specialized;
|
||||||
pub(crate) mod standard;
|
pub(crate) mod standard;
|
||||||
|
pub(crate) mod transport_facade;
|
||||||
|
|
||||||
|
pub(crate) use self::route::{
|
||||||
|
is_matching_stream_request, resolve_execution_runtime_stream_plan_kind,
|
||||||
|
resolve_execution_runtime_sync_plan_kind, supports_stream_scheduler_decision_kind,
|
||||||
|
supports_sync_scheduler_decision_kind,
|
||||||
|
};
|
||||||
|
|
||||||
pub(crate) async fn maybe_build_sync_decision_payload(
|
pub(crate) async fn maybe_build_sync_decision_payload(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ pub(crate) use self::provider::{
|
|||||||
maybe_build_stream_local_same_format_provider_decision_payload,
|
maybe_build_stream_local_same_format_provider_decision_payload,
|
||||||
maybe_build_sync_local_same_format_provider_decision_payload,
|
maybe_build_sync_local_same_format_provider_decision_payload,
|
||||||
};
|
};
|
||||||
pub(crate) use crate::provider_transport::provider_types::provider_type_supports_local_same_format_transport;
|
pub(crate) use crate::ai_pipeline::provider_transport_facade::provider_types::provider_type_supports_local_same_format_transport;
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ use axum::http::Response;
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use url::form_urlencoded;
|
use url::form_urlencoded;
|
||||||
|
|
||||||
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
|
use aether_data_contracts::repository::candidates::{
|
||||||
|
RequestCandidateStatus, UpsertRequestCandidateRecord,
|
||||||
|
};
|
||||||
|
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::ai_pipeline::control_facade::{collect_control_headers, GatewayControlDecision};
|
||||||
|
use crate::ai_pipeline::execution_facade::{ConversionMode, ExecutionStrategy};
|
||||||
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
||||||
use crate::ai_pipeline::planner::common::{
|
use crate::ai_pipeline::planner::common::{
|
||||||
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
|
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
|
||||||
@@ -15,47 +20,41 @@ use crate::ai_pipeline::planner::common::{
|
|||||||
use crate::ai_pipeline::planner::plan_builders::{
|
use crate::ai_pipeline::planner::plan_builders::{
|
||||||
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
||||||
};
|
};
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::ai_pipeline::provider_transport_facade::antigravity::{
|
||||||
use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
|
|
||||||
use crate::headers::collect_control_headers;
|
|
||||||
use crate::provider_transport::antigravity::{
|
|
||||||
build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
|
build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
|
||||||
build_antigravity_v1internal_url, classify_local_antigravity_request_support,
|
build_antigravity_v1internal_url, classify_local_antigravity_request_support,
|
||||||
AntigravityEnvelopeRequestType, AntigravityRequestEnvelopeSupport,
|
AntigravityEnvelopeRequestType, AntigravityRequestEnvelopeSupport,
|
||||||
AntigravityRequestSideSupport, AntigravityRequestUrlAction,
|
AntigravityRequestSideSupport, AntigravityRequestUrlAction,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::auth::{
|
use crate::ai_pipeline::provider_transport_facade::auth::{
|
||||||
build_openai_passthrough_headers, resolve_local_gemini_auth, resolve_local_standard_auth,
|
build_openai_passthrough_headers, resolve_local_gemini_auth, resolve_local_standard_auth,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::claude_code::{
|
use crate::ai_pipeline::provider_transport_facade::claude_code::{
|
||||||
build_claude_code_messages_url, build_claude_code_passthrough_headers,
|
build_claude_code_messages_url, build_claude_code_passthrough_headers,
|
||||||
sanitize_claude_code_request_body, supports_local_claude_code_transport_with_network,
|
sanitize_claude_code_request_body, supports_local_claude_code_transport_with_network,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::kiro::{
|
use crate::ai_pipeline::provider_transport_facade::kiro::{
|
||||||
build_kiro_generate_assistant_response_url, build_kiro_provider_headers,
|
build_kiro_generate_assistant_response_url, build_kiro_provider_headers,
|
||||||
build_kiro_provider_request_body, supports_local_kiro_request_transport_with_network,
|
build_kiro_provider_request_body, supports_local_kiro_request_transport_with_network,
|
||||||
KIRO_ENVELOPE_NAME,
|
KIRO_ENVELOPE_NAME,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::policy::{
|
use crate::ai_pipeline::provider_transport_facade::policy::{
|
||||||
supports_local_gemini_transport_with_network, supports_local_standard_transport_with_network,
|
supports_local_gemini_transport_with_network, supports_local_standard_transport_with_network,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::url::{
|
use crate::ai_pipeline::provider_transport_facade::url::{
|
||||||
build_claude_messages_url, build_gemini_content_url, build_passthrough_path_url,
|
build_claude_messages_url, build_gemini_content_url, build_passthrough_path_url,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::vertex::{
|
use crate::ai_pipeline::provider_transport_facade::vertex::{
|
||||||
build_vertex_api_key_gemini_content_url, resolve_local_vertex_api_key_query_auth,
|
build_vertex_api_key_gemini_content_url, resolve_local_vertex_api_key_query_auth,
|
||||||
supports_local_vertex_api_key_gemini_transport_with_network,
|
supports_local_vertex_api_key_gemini_transport_with_network,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::{
|
use crate::ai_pipeline::provider_transport_facade::{
|
||||||
apply_local_body_rules, apply_local_header_rules, build_passthrough_headers,
|
apply_local_body_rules, apply_local_header_rules, build_passthrough_headers,
|
||||||
ensure_upstream_auth_header, resolve_transport_execution_timeouts,
|
ensure_upstream_auth_header, resolve_transport_execution_timeouts,
|
||||||
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
|
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
|
||||||
LocalResolvedOAuthRequestAuth,
|
LocalResolvedOAuthRequestAuth,
|
||||||
};
|
};
|
||||||
use crate::scheduler::{
|
use crate::clock::current_unix_secs;
|
||||||
current_unix_secs, list_selectable_candidates, record_local_request_candidate_status,
|
|
||||||
GatewayMinimalCandidateSelectionCandidate,
|
|
||||||
};
|
|
||||||
use crate::{
|
use crate::{
|
||||||
append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse,
|
append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse,
|
||||||
GatewayError,
|
GatewayError,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::control::GatewayControlDecision;
|
use crate::ai_pipeline::control_facade::GatewayControlDecision;
|
||||||
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
||||||
|
|
||||||
use super::super::plans::{resolve_stream_spec, resolve_sync_spec};
|
use super::super::plans::{resolve_stream_spec, resolve_sync_spec};
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::ai_pipeline::control_facade::GatewayControlDecision;
|
||||||
|
use crate::ai_pipeline::execution_facade::{ConversionMode, ExecutionStrategy};
|
||||||
|
use crate::ai_pipeline::planner::auth_snapshot_facade::read_auth_api_key_snapshot;
|
||||||
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::ai_pipeline::planner::candidate_runtime_facade::persist_available_local_candidate;
|
||||||
use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
|
use crate::ai_pipeline::planner::scheduler_facade::list_selectable_candidates;
|
||||||
use crate::scheduler::{current_unix_secs, list_selectable_candidates};
|
use crate::clock::current_unix_secs;
|
||||||
use crate::{append_execution_contract_fields_to_value, AppState, GatewayError};
|
use crate::{append_execution_contract_fields_to_value, AppState, GatewayError};
|
||||||
|
|
||||||
use super::types::{
|
use super::types::{
|
||||||
@@ -40,8 +42,8 @@ pub(crate) async fn resolve_local_same_format_provider_decision_input(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let auth_snapshot = match state
|
let auth_snapshot = match read_auth_api_key_snapshot(
|
||||||
.read_auth_api_key_snapshot(
|
state,
|
||||||
&auth_context.user_id,
|
&auth_context.user_id,
|
||||||
&auth_context.api_key_id,
|
&auth_context.api_key_id,
|
||||||
current_unix_secs(),
|
current_unix_secs(),
|
||||||
@@ -107,47 +109,19 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
|
|||||||
spec.api_format,
|
spec.api_format,
|
||||||
);
|
);
|
||||||
|
|
||||||
let candidate_id = match state
|
let candidate_id = persist_available_local_candidate(
|
||||||
.upsert_request_candidate(UpsertRequestCandidateRecord {
|
state,
|
||||||
id: generated_candidate_id.clone(),
|
trace_id,
|
||||||
request_id: trace_id.to_string(),
|
&input.auth_context.user_id,
|
||||||
user_id: Some(input.auth_context.user_id.clone()),
|
&input.auth_context.api_key_id,
|
||||||
api_key_id: Some(input.auth_context.api_key_id.clone()),
|
&candidate,
|
||||||
username: None,
|
candidate_index as u32,
|
||||||
api_key_name: None,
|
&generated_candidate_id,
|
||||||
candidate_index: candidate_index as u32,
|
Some(extra_data),
|
||||||
retry_index: 0,
|
created_at_unix_secs,
|
||||||
provider_id: Some(candidate.provider_id.clone()),
|
"gateway local same-format decision request candidate upsert failed",
|
||||||
endpoint_id: Some(candidate.endpoint_id.clone()),
|
)
|
||||||
key_id: Some(candidate.key_id.clone()),
|
.await;
|
||||||
status: RequestCandidateStatus::Available,
|
|
||||||
skip_reason: None,
|
|
||||||
is_cached: Some(false),
|
|
||||||
status_code: None,
|
|
||||||
error_type: None,
|
|
||||||
error_message: None,
|
|
||||||
latency_ms: None,
|
|
||||||
concurrent_requests: None,
|
|
||||||
extra_data: Some(extra_data),
|
|
||||||
required_capabilities: candidate.key_capabilities.clone(),
|
|
||||||
created_at_unix_secs: Some(created_at_unix_secs),
|
|
||||||
started_at_unix_secs: None,
|
|
||||||
finished_at_unix_secs: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Some(stored)) => stored.id,
|
|
||||||
Ok(None) => generated_candidate_id.clone(),
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
trace_id = %trace_id,
|
|
||||||
api_format = spec.api_format,
|
|
||||||
error = ?err,
|
|
||||||
"gateway local same-format decision request candidate upsert failed"
|
|
||||||
);
|
|
||||||
generated_candidate_id.clone()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
attempts.push(LocalSameFormatProviderCandidateAttempt {
|
attempts.push(LocalSameFormatProviderCandidateAttempt {
|
||||||
candidate,
|
candidate,
|
||||||
|
|||||||
@@ -1,43 +1,48 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
|
use crate::ai_pipeline::control_facade::collect_control_headers;
|
||||||
use crate::headers::collect_control_headers;
|
use crate::ai_pipeline::execution_facade::{ConversionMode, ExecutionStrategy};
|
||||||
use crate::provider_transport::antigravity::{
|
use crate::ai_pipeline::planner::candidate_runtime_facade::persist_skipped_local_candidate;
|
||||||
|
use crate::ai_pipeline::planner::transport_facade::{
|
||||||
|
read_provider_transport_snapshot, resolve_local_oauth_request_auth,
|
||||||
|
LocalResolvedOAuthRequestAuth,
|
||||||
|
};
|
||||||
|
use crate::ai_pipeline::provider_transport_facade::antigravity::{
|
||||||
build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
|
build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
|
||||||
classify_local_antigravity_request_support, AntigravityEnvelopeRequestType,
|
classify_local_antigravity_request_support, AntigravityEnvelopeRequestType,
|
||||||
AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport,
|
AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::auth::{
|
use crate::ai_pipeline::provider_transport_facade::auth::{
|
||||||
build_openai_passthrough_headers, resolve_local_gemini_auth, resolve_local_standard_auth,
|
build_openai_passthrough_headers, resolve_local_gemini_auth, resolve_local_standard_auth,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::claude_code::{
|
use crate::ai_pipeline::provider_transport_facade::claude_code::{
|
||||||
build_claude_code_passthrough_headers, supports_local_claude_code_transport_with_network,
|
build_claude_code_passthrough_headers, supports_local_claude_code_transport_with_network,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::kiro::{
|
use crate::ai_pipeline::provider_transport_facade::kiro::{
|
||||||
build_kiro_provider_headers, supports_local_kiro_request_transport_with_network,
|
build_kiro_provider_headers, supports_local_kiro_request_transport_with_network,
|
||||||
KIRO_ENVELOPE_NAME,
|
KIRO_ENVELOPE_NAME,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::policy::{
|
use crate::ai_pipeline::provider_transport_facade::policy::{
|
||||||
supports_local_gemini_transport_with_network, supports_local_standard_transport_with_network,
|
supports_local_gemini_transport_with_network, supports_local_standard_transport_with_network,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::vertex::{
|
use crate::ai_pipeline::provider_transport_facade::vertex::{
|
||||||
resolve_local_vertex_api_key_query_auth,
|
resolve_local_vertex_api_key_query_auth,
|
||||||
supports_local_vertex_api_key_gemini_transport_with_network,
|
supports_local_vertex_api_key_gemini_transport_with_network,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::{
|
use crate::ai_pipeline::provider_transport_facade::{
|
||||||
apply_local_header_rules, build_passthrough_headers, ensure_upstream_auth_header,
|
apply_local_header_rules, build_passthrough_headers, ensure_upstream_auth_header,
|
||||||
resolve_transport_execution_timeouts, resolve_transport_proxy_snapshot_with_tunnel_affinity,
|
resolve_transport_execution_timeouts, resolve_transport_proxy_snapshot_with_tunnel_affinity,
|
||||||
resolve_transport_tls_profile, LocalResolvedOAuthRequestAuth,
|
resolve_transport_tls_profile,
|
||||||
};
|
};
|
||||||
use crate::scheduler::{current_unix_secs, GatewayMinimalCandidateSelectionCandidate};
|
use crate::clock::current_unix_secs;
|
||||||
use crate::{
|
use crate::{
|
||||||
append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse,
|
append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse,
|
||||||
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
|
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
|
||||||
};
|
};
|
||||||
|
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||||
|
|
||||||
use super::types::{
|
use super::types::{
|
||||||
LocalSameFormatProviderCandidateAttempt, LocalSameFormatProviderDecisionInput,
|
LocalSameFormatProviderCandidateAttempt, LocalSameFormatProviderDecisionInput,
|
||||||
@@ -59,8 +64,8 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
|
|||||||
candidate_id,
|
candidate_id,
|
||||||
} = attempt;
|
} = attempt;
|
||||||
|
|
||||||
let transport = match state
|
let transport = match read_provider_transport_snapshot(
|
||||||
.read_provider_transport_snapshot(
|
state,
|
||||||
&candidate.provider_id,
|
&candidate.provider_id,
|
||||||
&candidate.endpoint_id,
|
&candidate.endpoint_id,
|
||||||
&candidate.key_id,
|
&candidate.key_id,
|
||||||
@@ -169,7 +174,7 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
|
|||||||
&& !is_vertex
|
&& !is_vertex
|
||||||
&& resolve_local_gemini_auth(&transport).is_none();
|
&& resolve_local_gemini_auth(&transport).is_none();
|
||||||
let oauth_auth = if should_try_oauth_auth {
|
let oauth_auth = if should_try_oauth_auth {
|
||||||
match state.resolve_local_oauth_request_auth(&transport).await {
|
match resolve_local_oauth_request_auth(state, &transport).await {
|
||||||
Ok(Some(LocalResolvedOAuthRequestAuth::Kiro(auth))) => {
|
Ok(Some(LocalResolvedOAuthRequestAuth::Kiro(auth))) => {
|
||||||
Some(LocalResolvedOAuthRequestAuth::Kiro(auth))
|
Some(LocalResolvedOAuthRequestAuth::Kiro(auth))
|
||||||
}
|
}
|
||||||
@@ -528,46 +533,22 @@ pub(super) async fn mark_skipped_local_same_format_provider_candidate(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
input: &LocalSameFormatProviderDecisionInput,
|
input: &LocalSameFormatProviderDecisionInput,
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
candidate: &GatewayMinimalCandidateSelectionCandidate,
|
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||||
candidate_index: u32,
|
candidate_index: u32,
|
||||||
candidate_id: &str,
|
candidate_id: &str,
|
||||||
skip_reason: &'static str,
|
skip_reason: &'static str,
|
||||||
) {
|
) {
|
||||||
if let Err(err) = state
|
persist_skipped_local_candidate(
|
||||||
.upsert_request_candidate(UpsertRequestCandidateRecord {
|
state,
|
||||||
id: candidate_id.to_string(),
|
trace_id,
|
||||||
request_id: trace_id.to_string(),
|
&input.auth_context.user_id,
|
||||||
user_id: Some(input.auth_context.user_id.clone()),
|
&input.auth_context.api_key_id,
|
||||||
api_key_id: Some(input.auth_context.api_key_id.clone()),
|
candidate,
|
||||||
username: None,
|
|
||||||
api_key_name: None,
|
|
||||||
candidate_index,
|
candidate_index,
|
||||||
retry_index: 0,
|
candidate_id,
|
||||||
provider_id: Some(candidate.provider_id.clone()),
|
|
||||||
endpoint_id: Some(candidate.endpoint_id.clone()),
|
|
||||||
key_id: Some(candidate.key_id.clone()),
|
|
||||||
status: RequestCandidateStatus::Skipped,
|
|
||||||
skip_reason: Some(skip_reason.to_string()),
|
|
||||||
is_cached: Some(false),
|
|
||||||
status_code: None,
|
|
||||||
error_type: None,
|
|
||||||
error_message: None,
|
|
||||||
latency_ms: None,
|
|
||||||
concurrent_requests: None,
|
|
||||||
extra_data: None,
|
|
||||||
required_capabilities: candidate.key_capabilities.clone(),
|
|
||||||
created_at_unix_secs: None,
|
|
||||||
started_at_unix_secs: None,
|
|
||||||
finished_at_unix_secs: Some(current_unix_secs()),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
trace_id = %trace_id,
|
|
||||||
candidate_id = %candidate_id,
|
|
||||||
skip_reason,
|
skip_reason,
|
||||||
error = ?err,
|
current_unix_secs(),
|
||||||
"gateway local same-format decision failed to persist skipped candidate"
|
"gateway local same-format decision failed to persist skipped candidate",
|
||||||
);
|
)
|
||||||
}
|
.await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,20 @@
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
use crate::ai_pipeline::control_facade::GatewayControlAuthContext;
|
||||||
pub(crate) enum LocalSameFormatProviderFamily {
|
use crate::ai_pipeline::planner::auth_snapshot_facade::GatewayAuthApiKeySnapshot;
|
||||||
Standard,
|
|
||||||
Gemini,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
pub(crate) use aether_ai_pipeline::planner::passthrough::provider::{
|
||||||
pub(crate) struct LocalSameFormatProviderSpec {
|
LocalSameFormatProviderFamily, LocalSameFormatProviderSpec,
|
||||||
pub(crate) api_format: &'static str,
|
};
|
||||||
pub(crate) decision_kind: &'static str,
|
|
||||||
pub(crate) report_kind: &'static str,
|
|
||||||
pub(crate) family: LocalSameFormatProviderFamily,
|
|
||||||
pub(crate) require_streaming: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct LocalSameFormatProviderDecisionInput {
|
pub(crate) struct LocalSameFormatProviderDecisionInput {
|
||||||
pub(crate) auth_context: crate::control::GatewayControlAuthContext,
|
pub(crate) auth_context: GatewayControlAuthContext,
|
||||||
pub(crate) requested_model: String,
|
pub(crate) requested_model: String,
|
||||||
pub(crate) auth_snapshot: crate::data::auth::GatewayAuthApiKeySnapshot,
|
pub(crate) auth_snapshot: GatewayAuthApiKeySnapshot,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct LocalSameFormatProviderCandidateAttempt {
|
pub(crate) struct LocalSameFormatProviderCandidateAttempt {
|
||||||
pub(crate) candidate: crate::scheduler::GatewayMinimalCandidateSelectionCandidate,
|
pub(crate) candidate: aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate,
|
||||||
pub(crate) candidate_index: u32,
|
pub(crate) candidate_index: u32,
|
||||||
pub(crate) candidate_id: String,
|
pub(crate) candidate_id: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
|
pub(crate) use aether_ai_pipeline::planner::passthrough::provider::{
|
||||||
|
resolve_stream_spec, resolve_sync_spec,
|
||||||
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
materialize_local_same_format_provider_candidate_attempts,
|
materialize_local_same_format_provider_candidate_attempts,
|
||||||
maybe_build_local_same_format_provider_decision_payload_for_candidate,
|
maybe_build_local_same_format_provider_decision_payload_for_candidate,
|
||||||
@@ -7,84 +11,11 @@ use super::{
|
|||||||
GatewayError, LocalSameFormatProviderFamily, LocalSameFormatProviderSpec,
|
GatewayError, LocalSameFormatProviderFamily, LocalSameFormatProviderSpec,
|
||||||
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
||||||
};
|
};
|
||||||
use crate::ai_pipeline::planner::common::{
|
|
||||||
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
|
||||||
CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
|
|
||||||
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
|
|
||||||
};
|
|
||||||
use crate::ai_pipeline::planner::plan_builders::{
|
use crate::ai_pipeline::planner::plan_builders::{
|
||||||
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
|
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
|
||||||
build_standard_stream_plan_from_decision, build_standard_sync_plan_from_decision,
|
build_standard_stream_plan_from_decision, build_standard_sync_plan_from_decision,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalSameFormatProviderSpec> {
|
|
||||||
match plan_kind {
|
|
||||||
CLAUDE_CHAT_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
|
||||||
api_format: "claude:chat",
|
|
||||||
decision_kind: CLAUDE_CHAT_SYNC_PLAN_KIND,
|
|
||||||
report_kind: "claude_chat_sync_success",
|
|
||||||
family: LocalSameFormatProviderFamily::Standard,
|
|
||||||
require_streaming: false,
|
|
||||||
}),
|
|
||||||
CLAUDE_CLI_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
|
||||||
api_format: "claude:cli",
|
|
||||||
decision_kind: CLAUDE_CLI_SYNC_PLAN_KIND,
|
|
||||||
report_kind: "claude_cli_sync_success",
|
|
||||||
family: LocalSameFormatProviderFamily::Standard,
|
|
||||||
require_streaming: false,
|
|
||||||
}),
|
|
||||||
GEMINI_CHAT_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
|
||||||
api_format: "gemini:chat",
|
|
||||||
decision_kind: GEMINI_CHAT_SYNC_PLAN_KIND,
|
|
||||||
report_kind: "gemini_chat_sync_success",
|
|
||||||
family: LocalSameFormatProviderFamily::Gemini,
|
|
||||||
require_streaming: false,
|
|
||||||
}),
|
|
||||||
GEMINI_CLI_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
|
||||||
api_format: "gemini:cli",
|
|
||||||
decision_kind: GEMINI_CLI_SYNC_PLAN_KIND,
|
|
||||||
report_kind: "gemini_cli_sync_success",
|
|
||||||
family: LocalSameFormatProviderFamily::Gemini,
|
|
||||||
require_streaming: false,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn resolve_stream_spec(plan_kind: &str) -> Option<LocalSameFormatProviderSpec> {
|
|
||||||
match plan_kind {
|
|
||||||
CLAUDE_CHAT_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
|
||||||
api_format: "claude:chat",
|
|
||||||
decision_kind: CLAUDE_CHAT_STREAM_PLAN_KIND,
|
|
||||||
report_kind: "claude_chat_stream_success",
|
|
||||||
family: LocalSameFormatProviderFamily::Standard,
|
|
||||||
require_streaming: true,
|
|
||||||
}),
|
|
||||||
CLAUDE_CLI_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
|
||||||
api_format: "claude:cli",
|
|
||||||
decision_kind: CLAUDE_CLI_STREAM_PLAN_KIND,
|
|
||||||
report_kind: "claude_cli_stream_success",
|
|
||||||
family: LocalSameFormatProviderFamily::Standard,
|
|
||||||
require_streaming: true,
|
|
||||||
}),
|
|
||||||
GEMINI_CHAT_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
|
||||||
api_format: "gemini:chat",
|
|
||||||
decision_kind: GEMINI_CHAT_STREAM_PLAN_KIND,
|
|
||||||
report_kind: "gemini_chat_stream_success",
|
|
||||||
family: LocalSameFormatProviderFamily::Gemini,
|
|
||||||
require_streaming: true,
|
|
||||||
}),
|
|
||||||
GEMINI_CLI_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
|
||||||
api_format: "gemini:cli",
|
|
||||||
decision_kind: GEMINI_CLI_STREAM_PLAN_KIND,
|
|
||||||
report_kind: "gemini_cli_stream_success",
|
|
||||||
family: LocalSameFormatProviderFamily::Gemini,
|
|
||||||
require_streaming: true,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn build_local_sync_plan_and_reports(
|
pub(crate) async fn build_local_sync_plan_and_reports(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
parts: &http::request::Parts,
|
parts: &http::request::Parts,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ use std::collections::BTreeMap;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use url::form_urlencoded;
|
use url::form_urlencoded;
|
||||||
|
|
||||||
|
use crate::ai_pipeline::planner::transport_facade::GatewayProviderTransportSnapshot;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
apply_local_body_rules, build_antigravity_v1internal_url, build_claude_code_messages_url,
|
apply_local_body_rules, build_antigravity_v1internal_url, build_claude_code_messages_url,
|
||||||
build_claude_messages_url, build_gemini_content_url,
|
build_claude_messages_url, build_gemini_content_url,
|
||||||
@@ -18,7 +20,7 @@ pub(super) fn build_same_format_provider_request_body(
|
|||||||
spec: LocalSameFormatProviderSpec,
|
spec: LocalSameFormatProviderSpec,
|
||||||
body_rules: Option<&Value>,
|
body_rules: Option<&Value>,
|
||||||
upstream_is_stream: bool,
|
upstream_is_stream: bool,
|
||||||
kiro_auth: Option<&crate::provider_transport::kiro::KiroRequestAuth>,
|
kiro_auth: Option<&crate::ai_pipeline::provider_transport_facade::kiro::KiroRequestAuth>,
|
||||||
is_claude_code: bool,
|
is_claude_code: bool,
|
||||||
) -> Option<Value> {
|
) -> Option<Value> {
|
||||||
if let Some(kiro_auth) = kiro_auth {
|
if let Some(kiro_auth) = kiro_auth {
|
||||||
@@ -60,11 +62,11 @@ pub(super) fn build_same_format_provider_request_body(
|
|||||||
|
|
||||||
pub(super) fn build_same_format_upstream_url(
|
pub(super) fn build_same_format_upstream_url(
|
||||||
parts: &http::request::Parts,
|
parts: &http::request::Parts,
|
||||||
transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
mapped_model: &str,
|
mapped_model: &str,
|
||||||
spec: LocalSameFormatProviderSpec,
|
spec: LocalSameFormatProviderSpec,
|
||||||
upstream_is_stream: bool,
|
upstream_is_stream: bool,
|
||||||
kiro_auth: Option<&crate::provider_transport::kiro::KiroRequestAuth>,
|
kiro_auth: Option<&crate::ai_pipeline::provider_transport_facade::kiro::KiroRequestAuth>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
if let Some(kiro_auth) = kiro_auth {
|
if let Some(kiro_auth) = kiro_auth {
|
||||||
return build_kiro_generate_assistant_response_url(
|
return build_kiro_generate_assistant_response_url(
|
||||||
|
|||||||
119
apps/aether-gateway/src/ai_pipeline/planner/route.rs
Normal file
119
apps/aether-gateway/src/ai_pipeline/planner/route.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
use crate::ai_pipeline::control_facade::GatewayControlDecision;
|
||||||
|
|
||||||
|
pub(crate) fn resolve_execution_runtime_stream_plan_kind(
|
||||||
|
parts: &http::request::Parts,
|
||||||
|
decision: &GatewayControlDecision,
|
||||||
|
) -> Option<&'static str> {
|
||||||
|
aether_ai_pipeline::planner::route::resolve_execution_runtime_stream_plan_kind(
|
||||||
|
decision.route_class.as_deref(),
|
||||||
|
decision.route_family.as_deref(),
|
||||||
|
decision.route_kind.as_deref(),
|
||||||
|
&parts.method,
|
||||||
|
parts.uri.path(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_execution_runtime_sync_plan_kind(
|
||||||
|
parts: &http::request::Parts,
|
||||||
|
decision: &GatewayControlDecision,
|
||||||
|
) -> Option<&'static str> {
|
||||||
|
aether_ai_pipeline::planner::route::resolve_execution_runtime_sync_plan_kind(
|
||||||
|
decision.route_class.as_deref(),
|
||||||
|
decision.route_family.as_deref(),
|
||||||
|
decision.route_kind.as_deref(),
|
||||||
|
&parts.method,
|
||||||
|
parts.uri.path(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_matching_stream_request(
|
||||||
|
plan_kind: &str,
|
||||||
|
parts: &http::request::Parts,
|
||||||
|
body_json: &serde_json::Value,
|
||||||
|
) -> bool {
|
||||||
|
aether_ai_pipeline::planner::route::is_matching_stream_request(
|
||||||
|
plan_kind,
|
||||||
|
parts.uri.path(),
|
||||||
|
body_json,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn supports_sync_scheduler_decision_kind(plan_kind: &str) -> bool {
|
||||||
|
aether_ai_pipeline::planner::route::supports_sync_scheduler_decision_kind(plan_kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn supports_stream_scheduler_decision_kind(plan_kind: &str) -> bool {
|
||||||
|
aether_ai_pipeline::planner::route::supports_stream_scheduler_decision_kind(plan_kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use axum::http::{Method, Request};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
is_matching_stream_request, resolve_execution_runtime_stream_plan_kind,
|
||||||
|
resolve_execution_runtime_sync_plan_kind, supports_stream_scheduler_decision_kind,
|
||||||
|
supports_sync_scheduler_decision_kind,
|
||||||
|
};
|
||||||
|
use crate::ai_pipeline::control_facade::GatewayControlDecision;
|
||||||
|
|
||||||
|
fn sample_decision(route_family: &str, route_kind: &str) -> GatewayControlDecision {
|
||||||
|
GatewayControlDecision {
|
||||||
|
public_path: "/".to_string(),
|
||||||
|
public_query_string: None,
|
||||||
|
route_class: Some("ai_public".to_string()),
|
||||||
|
route_family: Some(route_family.to_string()),
|
||||||
|
route_kind: Some(route_kind.to_string()),
|
||||||
|
auth_context: None,
|
||||||
|
admin_principal: None,
|
||||||
|
auth_endpoint_signature: None,
|
||||||
|
execution_runtime_candidate: true,
|
||||||
|
local_auth_rejection: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_openai_chat_plan_kinds_via_pipeline_crate() {
|
||||||
|
let request = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri("/v1/chat/completions")
|
||||||
|
.body(())
|
||||||
|
.expect("request should build");
|
||||||
|
let (parts, _) = request.into_parts();
|
||||||
|
let decision = sample_decision("openai", "chat");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolve_execution_runtime_sync_plan_kind(&parts, &decision),
|
||||||
|
Some("openai_chat_sync")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_execution_runtime_stream_plan_kind(&parts, &decision),
|
||||||
|
Some("openai_chat_stream")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_matching_uses_pipeline_route_logic() {
|
||||||
|
let request = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri("/v1/chat/completions")
|
||||||
|
.body(())
|
||||||
|
.expect("request should build");
|
||||||
|
let (parts, _) = request.into_parts();
|
||||||
|
|
||||||
|
assert!(!is_matching_stream_request(
|
||||||
|
"openai_chat_stream",
|
||||||
|
&parts,
|
||||||
|
&serde_json::json!({"stream": false}),
|
||||||
|
));
|
||||||
|
assert!(is_matching_stream_request(
|
||||||
|
"openai_chat_stream",
|
||||||
|
&parts,
|
||||||
|
&serde_json::json!({"stream": true}),
|
||||||
|
));
|
||||||
|
assert!(supports_sync_scheduler_decision_kind("openai_chat_sync"));
|
||||||
|
assert!(supports_stream_scheduler_decision_kind(
|
||||||
|
"openai_chat_stream"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||||
|
|
||||||
|
use crate::ai_pipeline::planner::auth_snapshot_facade::GatewayAuthApiKeySnapshot;
|
||||||
|
use crate::{AppState, GatewayError};
|
||||||
|
|
||||||
|
pub(crate) async fn list_selectable_candidates(
|
||||||
|
state: &AppState,
|
||||||
|
api_format: &str,
|
||||||
|
global_model_name: &str,
|
||||||
|
require_streaming: bool,
|
||||||
|
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||||
|
now_unix_secs: u64,
|
||||||
|
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
|
||||||
|
crate::scheduler::candidate::list_selectable_candidates(
|
||||||
|
state.data.as_ref(),
|
||||||
|
state,
|
||||||
|
api_format,
|
||||||
|
global_model_name,
|
||||||
|
require_streaming,
|
||||||
|
auth_snapshot,
|
||||||
|
now_unix_secs,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn list_selectable_candidates_for_required_capability_without_requested_model(
|
||||||
|
state: &AppState,
|
||||||
|
candidate_api_format: &str,
|
||||||
|
required_capability: &str,
|
||||||
|
require_streaming: bool,
|
||||||
|
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||||
|
now_unix_secs: u64,
|
||||||
|
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
|
||||||
|
crate::scheduler::candidate::list_selectable_candidates_for_required_capability_without_requested_model(
|
||||||
|
state.data.as_ref(),
|
||||||
|
state,
|
||||||
|
candidate_api_format,
|
||||||
|
required_capability,
|
||||||
|
require_streaming,
|
||||||
|
auth_snapshot,
|
||||||
|
now_unix_secs,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
@@ -1,57 +1,60 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
|
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::ai_pipeline::control_facade::{
|
||||||
|
collect_control_headers, GatewayControlAuthContext, GatewayControlDecision,
|
||||||
|
};
|
||||||
|
use crate::ai_pipeline::execution_facade::{ConversionMode, ExecutionStrategy};
|
||||||
|
use crate::ai_pipeline::planner::auth_snapshot_facade::{
|
||||||
|
read_auth_api_key_snapshot, GatewayAuthApiKeySnapshot,
|
||||||
|
};
|
||||||
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
||||||
|
use crate::ai_pipeline::planner::candidate_runtime_facade::{
|
||||||
|
persist_available_local_candidate, persist_skipped_local_candidate,
|
||||||
|
};
|
||||||
use crate::ai_pipeline::planner::common::{
|
use crate::ai_pipeline::planner::common::{
|
||||||
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
|
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
|
||||||
GEMINI_FILES_DELETE_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND,
|
|
||||||
GEMINI_FILES_LIST_PLAN_KIND, GEMINI_FILES_UPLOAD_PLAN_KIND,
|
|
||||||
};
|
};
|
||||||
|
use crate::ai_pipeline::planner::executor_facade::mark_unused_local_candidate_items;
|
||||||
use crate::ai_pipeline::planner::plan_builders::{
|
use crate::ai_pipeline::planner::plan_builders::{
|
||||||
build_passthrough_stream_plan_from_decision, build_passthrough_sync_plan_from_decision,
|
build_passthrough_stream_plan_from_decision, build_passthrough_sync_plan_from_decision,
|
||||||
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
||||||
};
|
};
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::ai_pipeline::planner::scheduler_facade::list_selectable_candidates_for_required_capability_without_requested_model;
|
||||||
use crate::headers::collect_control_headers;
|
use crate::ai_pipeline::planner::transport_facade::read_provider_transport_snapshot;
|
||||||
use crate::provider_transport::auth::{
|
use crate::ai_pipeline::provider_transport_facade::auth::{
|
||||||
build_passthrough_headers_with_auth, resolve_local_gemini_auth,
|
build_passthrough_headers_with_auth, resolve_local_gemini_auth,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::policy::supports_local_gemini_transport_with_network;
|
use crate::ai_pipeline::provider_transport_facade::policy::supports_local_gemini_transport_with_network;
|
||||||
use crate::provider_transport::url::build_gemini_files_passthrough_url;
|
use crate::ai_pipeline::provider_transport_facade::url::build_gemini_files_passthrough_url;
|
||||||
use crate::provider_transport::{
|
use crate::ai_pipeline::provider_transport_facade::{
|
||||||
apply_local_body_rules, apply_local_header_rules, resolve_transport_execution_timeouts,
|
apply_local_body_rules, apply_local_header_rules, resolve_transport_execution_timeouts,
|
||||||
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
|
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
|
||||||
};
|
};
|
||||||
use crate::scheduler::{
|
use crate::clock::current_unix_secs;
|
||||||
current_unix_secs, list_selectable_candidates_for_required_capability_without_requested_model,
|
|
||||||
record_local_request_candidate_status, GatewayMinimalCandidateSelectionCandidate,
|
|
||||||
};
|
|
||||||
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
||||||
|
use aether_ai_pipeline::contracts::GEMINI_FILES_UPLOAD_PLAN_KIND;
|
||||||
|
use aether_ai_pipeline::planner::specialized::files::{
|
||||||
|
resolve_stream_spec, resolve_sync_spec, LocalGeminiFilesSpec,
|
||||||
|
};
|
||||||
|
|
||||||
const GEMINI_FILES_CANDIDATE_API_FORMAT: &str = "gemini:chat";
|
const GEMINI_FILES_CANDIDATE_API_FORMAT: &str = "gemini:chat";
|
||||||
const GEMINI_FILES_CLIENT_API_FORMAT: &str = "gemini:files";
|
const GEMINI_FILES_CLIENT_API_FORMAT: &str = "gemini:files";
|
||||||
const GEMINI_FILES_REQUIRED_CAPABILITY: &str = "gemini_files";
|
const GEMINI_FILES_REQUIRED_CAPABILITY: &str = "gemini_files";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
struct LocalGeminiFilesSpec {
|
|
||||||
decision_kind: &'static str,
|
|
||||||
report_kind: Option<&'static str>,
|
|
||||||
require_streaming: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct LocalGeminiFilesDecisionInput {
|
struct LocalGeminiFilesDecisionInput {
|
||||||
auth_context: crate::control::GatewayControlAuthContext,
|
auth_context: GatewayControlAuthContext,
|
||||||
auth_snapshot: crate::data::auth::GatewayAuthApiKeySnapshot,
|
auth_snapshot: GatewayAuthApiKeySnapshot,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct LocalGeminiFilesCandidateAttempt {
|
struct LocalGeminiFilesCandidateAttempt {
|
||||||
candidate: GatewayMinimalCandidateSelectionCandidate,
|
candidate: SchedulerMinimalCandidateSelectionCandidate,
|
||||||
candidate_index: u32,
|
candidate_index: u32,
|
||||||
candidate_id: String,
|
candidate_id: String,
|
||||||
}
|
}
|
||||||
@@ -181,43 +184,6 @@ pub(crate) async fn maybe_build_stream_local_gemini_files_decision_payload(
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_sync_spec(plan_kind: &str) -> Option<LocalGeminiFilesSpec> {
|
|
||||||
match plan_kind {
|
|
||||||
GEMINI_FILES_UPLOAD_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
|
||||||
decision_kind: GEMINI_FILES_UPLOAD_PLAN_KIND,
|
|
||||||
report_kind: Some("gemini_files_store_mapping"),
|
|
||||||
require_streaming: false,
|
|
||||||
}),
|
|
||||||
GEMINI_FILES_LIST_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
|
||||||
decision_kind: GEMINI_FILES_LIST_PLAN_KIND,
|
|
||||||
report_kind: Some("gemini_files_store_mapping"),
|
|
||||||
require_streaming: false,
|
|
||||||
}),
|
|
||||||
GEMINI_FILES_GET_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
|
||||||
decision_kind: GEMINI_FILES_GET_PLAN_KIND,
|
|
||||||
report_kind: Some("gemini_files_store_mapping"),
|
|
||||||
require_streaming: false,
|
|
||||||
}),
|
|
||||||
GEMINI_FILES_DELETE_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
|
||||||
decision_kind: GEMINI_FILES_DELETE_PLAN_KIND,
|
|
||||||
report_kind: Some("gemini_files_delete_mapping"),
|
|
||||||
require_streaming: false,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_stream_spec(plan_kind: &str) -> Option<LocalGeminiFilesSpec> {
|
|
||||||
match plan_kind {
|
|
||||||
GEMINI_FILES_DOWNLOAD_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
|
||||||
decision_kind: GEMINI_FILES_DOWNLOAD_PLAN_KIND,
|
|
||||||
report_kind: None,
|
|
||||||
require_streaming: true,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn build_local_sync_plan_and_reports(
|
async fn build_local_sync_plan_and_reports(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
parts: &http::request::Parts,
|
parts: &http::request::Parts,
|
||||||
@@ -333,8 +299,8 @@ async fn resolve_local_gemini_files_decision_input(
|
|||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
|
|
||||||
let auth_snapshot = match state
|
let auth_snapshot = match read_auth_api_key_snapshot(
|
||||||
.read_auth_api_key_snapshot(
|
state,
|
||||||
&auth_context.user_id,
|
&auth_context.user_id,
|
||||||
&auth_context.api_key_id,
|
&auth_context.api_key_id,
|
||||||
current_unix_secs(),
|
current_unix_secs(),
|
||||||
@@ -392,46 +358,19 @@ async fn materialize_local_gemini_files_candidate_attempts(
|
|||||||
"key_name": candidate.key_name.clone(),
|
"key_name": candidate.key_name.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let candidate_id = match state
|
let candidate_id = persist_available_local_candidate(
|
||||||
.upsert_request_candidate(UpsertRequestCandidateRecord {
|
state,
|
||||||
id: generated_candidate_id.clone(),
|
trace_id,
|
||||||
request_id: trace_id.to_string(),
|
&input.auth_context.user_id,
|
||||||
user_id: Some(input.auth_context.user_id.clone()),
|
&input.auth_context.api_key_id,
|
||||||
api_key_id: Some(input.auth_context.api_key_id.clone()),
|
&candidate,
|
||||||
username: None,
|
candidate_index as u32,
|
||||||
api_key_name: None,
|
&generated_candidate_id,
|
||||||
candidate_index: candidate_index as u32,
|
Some(extra_data),
|
||||||
retry_index: 0,
|
created_at_unix_secs,
|
||||||
provider_id: Some(candidate.provider_id.clone()),
|
"gateway local gemini files request candidate upsert failed",
|
||||||
endpoint_id: Some(candidate.endpoint_id.clone()),
|
)
|
||||||
key_id: Some(candidate.key_id.clone()),
|
.await;
|
||||||
status: RequestCandidateStatus::Available,
|
|
||||||
skip_reason: None,
|
|
||||||
is_cached: Some(false),
|
|
||||||
status_code: None,
|
|
||||||
error_type: None,
|
|
||||||
error_message: None,
|
|
||||||
latency_ms: None,
|
|
||||||
concurrent_requests: None,
|
|
||||||
extra_data: Some(extra_data),
|
|
||||||
required_capabilities: candidate.key_capabilities.clone(),
|
|
||||||
created_at_unix_secs: Some(created_at_unix_secs),
|
|
||||||
started_at_unix_secs: None,
|
|
||||||
finished_at_unix_secs: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Some(stored)) => stored.id,
|
|
||||||
Ok(None) => generated_candidate_id.clone(),
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
trace_id = %trace_id,
|
|
||||||
error = ?err,
|
|
||||||
"gateway local gemini files request candidate upsert failed"
|
|
||||||
);
|
|
||||||
generated_candidate_id.clone()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
attempts.push(LocalGeminiFilesCandidateAttempt {
|
attempts.push(LocalGeminiFilesCandidateAttempt {
|
||||||
candidate,
|
candidate,
|
||||||
@@ -460,8 +399,8 @@ async fn maybe_build_local_gemini_files_decision_payload_for_candidate(
|
|||||||
candidate_id,
|
candidate_id,
|
||||||
} = attempt;
|
} = attempt;
|
||||||
|
|
||||||
let transport = match state
|
let transport = match read_provider_transport_snapshot(
|
||||||
.read_provider_transport_snapshot(
|
state,
|
||||||
&candidate.provider_id,
|
&candidate.provider_id,
|
||||||
&candidate.endpoint_id,
|
&candidate.endpoint_id,
|
||||||
&candidate.key_id,
|
&candidate.key_id,
|
||||||
@@ -664,16 +603,8 @@ async fn maybe_build_local_gemini_files_decision_payload_for_candidate(
|
|||||||
EXECUTION_RUNTIME_SYNC_DECISION_ACTION.to_string()
|
EXECUTION_RUNTIME_SYNC_DECISION_ACTION.to_string()
|
||||||
},
|
},
|
||||||
decision_kind: Some(spec.decision_kind.to_string()),
|
decision_kind: Some(spec.decision_kind.to_string()),
|
||||||
execution_strategy: Some(
|
execution_strategy: Some(ExecutionStrategy::LocalSameFormat.as_str().to_string()),
|
||||||
crate::execution_runtime::ExecutionStrategy::LocalSameFormat
|
conversion_mode: Some(ConversionMode::None.as_str().to_string()),
|
||||||
.as_str()
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
conversion_mode: Some(
|
|
||||||
crate::execution_runtime::ConversionMode::None
|
|
||||||
.as_str()
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
request_id: Some(trace_id.to_string()),
|
request_id: Some(trace_id.to_string()),
|
||||||
candidate_id: Some(candidate_id.clone()),
|
candidate_id: Some(candidate_id.clone()),
|
||||||
provider_name: Some(transport.provider.name.clone()),
|
provider_name: Some(transport.provider.name.clone()),
|
||||||
@@ -737,70 +668,38 @@ async fn mark_skipped_local_gemini_files_candidate(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
input: &LocalGeminiFilesDecisionInput,
|
input: &LocalGeminiFilesDecisionInput,
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
candidate: &GatewayMinimalCandidateSelectionCandidate,
|
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||||
candidate_index: u32,
|
candidate_index: u32,
|
||||||
candidate_id: &str,
|
candidate_id: &str,
|
||||||
skip_reason: &'static str,
|
skip_reason: &'static str,
|
||||||
) {
|
) {
|
||||||
if let Err(err) = state
|
persist_skipped_local_candidate(
|
||||||
.upsert_request_candidate(UpsertRequestCandidateRecord {
|
state,
|
||||||
id: candidate_id.to_string(),
|
trace_id,
|
||||||
request_id: trace_id.to_string(),
|
&input.auth_context.user_id,
|
||||||
user_id: Some(input.auth_context.user_id.clone()),
|
&input.auth_context.api_key_id,
|
||||||
api_key_id: Some(input.auth_context.api_key_id.clone()),
|
candidate,
|
||||||
username: None,
|
|
||||||
api_key_name: None,
|
|
||||||
candidate_index,
|
candidate_index,
|
||||||
retry_index: 0,
|
candidate_id,
|
||||||
provider_id: Some(candidate.provider_id.clone()),
|
|
||||||
endpoint_id: Some(candidate.endpoint_id.clone()),
|
|
||||||
key_id: Some(candidate.key_id.clone()),
|
|
||||||
status: RequestCandidateStatus::Skipped,
|
|
||||||
skip_reason: Some(skip_reason.to_string()),
|
|
||||||
is_cached: Some(false),
|
|
||||||
status_code: None,
|
|
||||||
error_type: None,
|
|
||||||
error_message: None,
|
|
||||||
latency_ms: None,
|
|
||||||
concurrent_requests: None,
|
|
||||||
extra_data: None,
|
|
||||||
required_capabilities: candidate.key_capabilities.clone(),
|
|
||||||
created_at_unix_secs: None,
|
|
||||||
started_at_unix_secs: None,
|
|
||||||
finished_at_unix_secs: Some(current_unix_secs()),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
trace_id = %trace_id,
|
|
||||||
candidate_id = %candidate_id,
|
|
||||||
skip_reason,
|
skip_reason,
|
||||||
error = ?err,
|
current_unix_secs(),
|
||||||
"gateway local gemini files failed to persist skipped candidate"
|
"gateway local gemini files failed to persist skipped candidate",
|
||||||
);
|
)
|
||||||
}
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn mark_unused_local_files_candidates<T>(state: &AppState, remaining: Vec<T>)
|
async fn mark_unused_local_files_candidates<T>(state: &AppState, remaining: Vec<T>)
|
||||||
where
|
where
|
||||||
T: LocalGeminiFilesPlanAndReport,
|
T: LocalGeminiFilesPlanAndReport,
|
||||||
{
|
{
|
||||||
for plan_and_report in remaining {
|
mark_unused_local_candidate_items(
|
||||||
record_local_request_candidate_status(
|
|
||||||
state,
|
state,
|
||||||
plan_and_report.plan(),
|
remaining,
|
||||||
plan_and_report.report_context(),
|
|item| item.plan(),
|
||||||
RequestCandidateStatus::Unused,
|
|item| item.report_context(),
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
trait LocalGeminiFilesPlanAndReport {
|
trait LocalGeminiFilesPlanAndReport {
|
||||||
fn plan(&self) -> &aether_contracts::ExecutionPlan;
|
fn plan(&self) -> &aether_contracts::ExecutionPlan;
|
||||||
|
|||||||
@@ -1,63 +1,59 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
|
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
use crate::ai_pipeline::control_facade::{
|
||||||
use crate::ai_pipeline::planner::common::{
|
collect_control_headers, GatewayControlAuthContext, GatewayControlDecision,
|
||||||
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
|
||||||
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
|
||||||
};
|
};
|
||||||
|
use crate::ai_pipeline::execution_facade::{ConversionMode, ExecutionStrategy};
|
||||||
|
use crate::ai_pipeline::planner::auth_snapshot_facade::{
|
||||||
|
read_auth_api_key_snapshot, GatewayAuthApiKeySnapshot,
|
||||||
|
};
|
||||||
|
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
||||||
|
use crate::ai_pipeline::planner::candidate_runtime_facade::{
|
||||||
|
persist_available_local_candidate, persist_skipped_local_candidate,
|
||||||
|
};
|
||||||
|
use crate::ai_pipeline::planner::common::EXECUTION_RUNTIME_SYNC_DECISION_ACTION;
|
||||||
|
use crate::ai_pipeline::planner::executor_facade::mark_unused_local_candidate_items;
|
||||||
use crate::ai_pipeline::planner::plan_builders::{
|
use crate::ai_pipeline::planner::plan_builders::{
|
||||||
build_passthrough_sync_plan_from_decision, LocalSyncPlanAndReport,
|
build_passthrough_sync_plan_from_decision, LocalSyncPlanAndReport,
|
||||||
};
|
};
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::ai_pipeline::planner::scheduler_facade::list_selectable_candidates;
|
||||||
use crate::headers::collect_control_headers;
|
use crate::ai_pipeline::planner::transport_facade::{
|
||||||
use crate::provider_transport::auth::{
|
read_provider_transport_snapshot, GatewayProviderTransportSnapshot,
|
||||||
|
};
|
||||||
|
use crate::ai_pipeline::provider_transport_facade::auth::{
|
||||||
build_passthrough_headers_with_auth, resolve_local_gemini_auth, resolve_local_openai_chat_auth,
|
build_passthrough_headers_with_auth, resolve_local_gemini_auth, resolve_local_openai_chat_auth,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::policy::{
|
use crate::ai_pipeline::provider_transport_facade::policy::{
|
||||||
supports_local_gemini_transport_with_network, supports_local_standard_transport_with_network,
|
supports_local_gemini_transport_with_network, supports_local_standard_transport_with_network,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::url::{
|
use crate::ai_pipeline::provider_transport_facade::url::{
|
||||||
build_gemini_video_predict_long_running_url, build_passthrough_path_url,
|
build_gemini_video_predict_long_running_url, build_passthrough_path_url,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::{
|
use crate::ai_pipeline::provider_transport_facade::{
|
||||||
apply_local_body_rules, apply_local_header_rules, resolve_transport_execution_timeouts,
|
apply_local_body_rules, apply_local_header_rules, resolve_transport_execution_timeouts,
|
||||||
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
|
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
|
||||||
};
|
};
|
||||||
use crate::scheduler::{
|
use crate::clock::current_unix_secs;
|
||||||
current_unix_secs, list_selectable_candidates, record_local_request_candidate_status,
|
|
||||||
GatewayMinimalCandidateSelectionCandidate,
|
|
||||||
};
|
|
||||||
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
||||||
|
use aether_ai_pipeline::planner::specialized::video::{
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
resolve_sync_spec, LocalVideoCreateFamily, LocalVideoCreateSpec,
|
||||||
enum LocalVideoCreateFamily {
|
};
|
||||||
OpenAi,
|
|
||||||
Gemini,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
struct LocalVideoCreateSpec {
|
|
||||||
api_format: &'static str,
|
|
||||||
decision_kind: &'static str,
|
|
||||||
report_kind: &'static str,
|
|
||||||
family: LocalVideoCreateFamily,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct LocalVideoCreateDecisionInput {
|
struct LocalVideoCreateDecisionInput {
|
||||||
auth_context: crate::control::GatewayControlAuthContext,
|
auth_context: GatewayControlAuthContext,
|
||||||
requested_model: String,
|
requested_model: String,
|
||||||
auth_snapshot: crate::data::auth::GatewayAuthApiKeySnapshot,
|
auth_snapshot: GatewayAuthApiKeySnapshot,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct LocalVideoCreateCandidateAttempt {
|
struct LocalVideoCreateCandidateAttempt {
|
||||||
candidate: GatewayMinimalCandidateSelectionCandidate,
|
candidate: SchedulerMinimalCandidateSelectionCandidate,
|
||||||
candidate_index: u32,
|
candidate_index: u32,
|
||||||
candidate_id: String,
|
candidate_id: String,
|
||||||
}
|
}
|
||||||
@@ -141,24 +137,6 @@ pub(crate) async fn maybe_build_sync_local_video_decision_payload(
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_sync_spec(plan_kind: &str) -> Option<LocalVideoCreateSpec> {
|
|
||||||
match plan_kind {
|
|
||||||
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND => Some(LocalVideoCreateSpec {
|
|
||||||
api_format: "openai:video",
|
|
||||||
decision_kind: OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
|
||||||
report_kind: "openai_video_create_sync_finalize",
|
|
||||||
family: LocalVideoCreateFamily::OpenAi,
|
|
||||||
}),
|
|
||||||
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND => Some(LocalVideoCreateSpec {
|
|
||||||
api_format: "gemini:video",
|
|
||||||
decision_kind: GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
|
||||||
report_kind: "gemini_video_create_sync_finalize",
|
|
||||||
family: LocalVideoCreateFamily::Gemini,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn build_local_sync_plan_and_reports(
|
async fn build_local_sync_plan_and_reports(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
parts: &http::request::Parts,
|
parts: &http::request::Parts,
|
||||||
@@ -257,8 +235,8 @@ async fn resolve_local_video_create_decision_input(
|
|||||||
LocalVideoCreateFamily::Gemini => extract_gemini_video_model_from_path(parts.uri.path())?,
|
LocalVideoCreateFamily::Gemini => extract_gemini_video_model_from_path(parts.uri.path())?,
|
||||||
};
|
};
|
||||||
|
|
||||||
let auth_snapshot = match state
|
let auth_snapshot = match read_auth_api_key_snapshot(
|
||||||
.read_auth_api_key_snapshot(
|
state,
|
||||||
&auth_context.user_id,
|
&auth_context.user_id,
|
||||||
&auth_context.api_key_id,
|
&auth_context.api_key_id,
|
||||||
current_unix_secs(),
|
current_unix_secs(),
|
||||||
@@ -299,8 +277,8 @@ async fn maybe_build_local_video_create_decision_payload_for_candidate(
|
|||||||
candidate_index,
|
candidate_index,
|
||||||
candidate_id,
|
candidate_id,
|
||||||
} = attempt;
|
} = attempt;
|
||||||
let transport = match state
|
let transport = match read_provider_transport_snapshot(
|
||||||
.read_provider_transport_snapshot(
|
state,
|
||||||
&candidate.provider_id,
|
&candidate.provider_id,
|
||||||
&candidate.endpoint_id,
|
&candidate.endpoint_id,
|
||||||
&candidate.key_id,
|
&candidate.key_id,
|
||||||
@@ -461,16 +439,8 @@ async fn maybe_build_local_video_create_decision_payload_for_candidate(
|
|||||||
Some(GatewayControlSyncDecisionResponse {
|
Some(GatewayControlSyncDecisionResponse {
|
||||||
action: EXECUTION_RUNTIME_SYNC_DECISION_ACTION.to_string(),
|
action: EXECUTION_RUNTIME_SYNC_DECISION_ACTION.to_string(),
|
||||||
decision_kind: Some(spec.decision_kind.to_string()),
|
decision_kind: Some(spec.decision_kind.to_string()),
|
||||||
execution_strategy: Some(
|
execution_strategy: Some(ExecutionStrategy::LocalSameFormat.as_str().to_string()),
|
||||||
crate::execution_runtime::ExecutionStrategy::LocalSameFormat
|
conversion_mode: Some(ConversionMode::None.as_str().to_string()),
|
||||||
.as_str()
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
conversion_mode: Some(
|
|
||||||
crate::execution_runtime::ConversionMode::None
|
|
||||||
.as_str()
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
request_id: Some(trace_id.to_string()),
|
request_id: Some(trace_id.to_string()),
|
||||||
candidate_id: Some(candidate_id.clone()),
|
candidate_id: Some(candidate_id.clone()),
|
||||||
provider_name: Some(transport.provider.name.clone()),
|
provider_name: Some(transport.provider.name.clone()),
|
||||||
@@ -552,7 +522,7 @@ fn build_provider_request_body(
|
|||||||
|
|
||||||
fn build_video_upstream_url(
|
fn build_video_upstream_url(
|
||||||
parts: &http::request::Parts,
|
parts: &http::request::Parts,
|
||||||
transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
mapped_model: &str,
|
mapped_model: &str,
|
||||||
family: LocalVideoCreateFamily,
|
family: LocalVideoCreateFamily,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
@@ -595,7 +565,7 @@ async fn materialize_local_video_create_candidate_attempts(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
input: &LocalVideoCreateDecisionInput,
|
input: &LocalVideoCreateDecisionInput,
|
||||||
candidates: Vec<GatewayMinimalCandidateSelectionCandidate>,
|
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||||
api_format: &str,
|
api_format: &str,
|
||||||
) -> Vec<LocalVideoCreateCandidateAttempt> {
|
) -> Vec<LocalVideoCreateCandidateAttempt> {
|
||||||
let candidates = prefer_local_tunnel_owner_candidates(state, candidates).await;
|
let candidates = prefer_local_tunnel_owner_candidates(state, candidates).await;
|
||||||
@@ -616,47 +586,19 @@ async fn materialize_local_video_create_candidate_attempts(
|
|||||||
"key_name": candidate.key_name.clone(),
|
"key_name": candidate.key_name.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let candidate_id = match state
|
let candidate_id = persist_available_local_candidate(
|
||||||
.upsert_request_candidate(UpsertRequestCandidateRecord {
|
state,
|
||||||
id: generated_candidate_id.clone(),
|
trace_id,
|
||||||
request_id: trace_id.to_string(),
|
&input.auth_context.user_id,
|
||||||
user_id: Some(input.auth_context.user_id.clone()),
|
&input.auth_context.api_key_id,
|
||||||
api_key_id: Some(input.auth_context.api_key_id.clone()),
|
&candidate,
|
||||||
username: None,
|
candidate_index as u32,
|
||||||
api_key_name: None,
|
&generated_candidate_id,
|
||||||
candidate_index: candidate_index as u32,
|
Some(extra_data),
|
||||||
retry_index: 0,
|
created_at_unix_secs,
|
||||||
provider_id: Some(candidate.provider_id.clone()),
|
"gateway local video decision request candidate upsert failed",
|
||||||
endpoint_id: Some(candidate.endpoint_id.clone()),
|
)
|
||||||
key_id: Some(candidate.key_id.clone()),
|
.await;
|
||||||
status: RequestCandidateStatus::Available,
|
|
||||||
skip_reason: None,
|
|
||||||
is_cached: Some(false),
|
|
||||||
status_code: None,
|
|
||||||
error_type: None,
|
|
||||||
error_message: None,
|
|
||||||
latency_ms: None,
|
|
||||||
concurrent_requests: None,
|
|
||||||
extra_data: Some(extra_data),
|
|
||||||
required_capabilities: candidate.key_capabilities.clone(),
|
|
||||||
created_at_unix_secs: Some(created_at_unix_secs),
|
|
||||||
started_at_unix_secs: None,
|
|
||||||
finished_at_unix_secs: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Some(stored)) => stored.id,
|
|
||||||
Ok(None) => generated_candidate_id.clone(),
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
trace_id = %trace_id,
|
|
||||||
decision_api_format = api_format,
|
|
||||||
error = ?err,
|
|
||||||
"gateway local video decision request candidate upsert failed"
|
|
||||||
);
|
|
||||||
generated_candidate_id.clone()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
attempts.push(LocalVideoCreateCandidateAttempt {
|
attempts.push(LocalVideoCreateCandidateAttempt {
|
||||||
candidate,
|
candidate,
|
||||||
@@ -672,71 +614,38 @@ async fn mark_skipped_local_video_candidate(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
input: &LocalVideoCreateDecisionInput,
|
input: &LocalVideoCreateDecisionInput,
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
candidate: &GatewayMinimalCandidateSelectionCandidate,
|
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||||
candidate_index: u32,
|
candidate_index: u32,
|
||||||
candidate_id: &str,
|
candidate_id: &str,
|
||||||
skip_reason: &'static str,
|
skip_reason: &'static str,
|
||||||
) {
|
) {
|
||||||
let terminal_unix_secs = current_unix_secs();
|
persist_skipped_local_candidate(
|
||||||
if let Err(err) = state
|
state,
|
||||||
.upsert_request_candidate(UpsertRequestCandidateRecord {
|
trace_id,
|
||||||
id: candidate_id.to_string(),
|
&input.auth_context.user_id,
|
||||||
request_id: trace_id.to_string(),
|
&input.auth_context.api_key_id,
|
||||||
user_id: Some(input.auth_context.user_id.clone()),
|
candidate,
|
||||||
api_key_id: Some(input.auth_context.api_key_id.clone()),
|
|
||||||
username: None,
|
|
||||||
api_key_name: None,
|
|
||||||
candidate_index,
|
candidate_index,
|
||||||
retry_index: 0,
|
candidate_id,
|
||||||
provider_id: Some(candidate.provider_id.clone()),
|
|
||||||
endpoint_id: Some(candidate.endpoint_id.clone()),
|
|
||||||
key_id: Some(candidate.key_id.clone()),
|
|
||||||
status: RequestCandidateStatus::Skipped,
|
|
||||||
skip_reason: Some(skip_reason.to_string()),
|
|
||||||
is_cached: Some(false),
|
|
||||||
status_code: None,
|
|
||||||
error_type: None,
|
|
||||||
error_message: None,
|
|
||||||
latency_ms: None,
|
|
||||||
concurrent_requests: None,
|
|
||||||
extra_data: None,
|
|
||||||
required_capabilities: candidate.key_capabilities.clone(),
|
|
||||||
created_at_unix_secs: None,
|
|
||||||
started_at_unix_secs: None,
|
|
||||||
finished_at_unix_secs: Some(terminal_unix_secs),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
trace_id = %trace_id,
|
|
||||||
candidate_id = %candidate_id,
|
|
||||||
skip_reason,
|
skip_reason,
|
||||||
error = ?err,
|
current_unix_secs(),
|
||||||
"gateway local video decision failed to persist skipped candidate"
|
"gateway local video decision failed to persist skipped candidate",
|
||||||
);
|
)
|
||||||
}
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn mark_unused_local_video_candidates(
|
async fn mark_unused_local_video_candidates(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
remaining: Vec<LocalSyncPlanAndReport>,
|
remaining: Vec<LocalSyncPlanAndReport>,
|
||||||
) {
|
) {
|
||||||
for plan_and_report in remaining {
|
mark_unused_local_candidate_items(
|
||||||
record_local_request_candidate_status(
|
|
||||||
state,
|
state,
|
||||||
&plan_and_report.plan,
|
remaining,
|
||||||
plan_and_report.report_context.as_ref(),
|
|item| &item.plan,
|
||||||
RequestCandidateStatus::Unused,
|
|item| item.report_context.as_ref(),
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_gemini_video_model_from_path(path: &str) -> Option<String> {
|
fn extract_gemini_video_model_from_path(path: &str) -> Option<String> {
|
||||||
let suffix = path.strip_prefix("/v1beta/models/")?;
|
let suffix = path.strip_prefix("/v1beta/models/")?;
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
use crate::ai_pipeline::planner::common::{
|
|
||||||
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::super::family::{LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec};
|
|
||||||
|
|
||||||
pub(crate) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
|
||||||
match plan_kind {
|
|
||||||
CLAUDE_CHAT_SYNC_PLAN_KIND => Some(LocalStandardSpec {
|
|
||||||
api_format: "claude:chat",
|
|
||||||
decision_kind: CLAUDE_CHAT_SYNC_PLAN_KIND,
|
|
||||||
report_kind: "claude_chat_sync_finalize",
|
|
||||||
family: LocalStandardSourceFamily::Standard,
|
|
||||||
mode: LocalStandardSourceMode::Chat,
|
|
||||||
require_streaming: false,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
|
||||||
match plan_kind {
|
|
||||||
CLAUDE_CHAT_STREAM_PLAN_KIND => Some(LocalStandardSpec {
|
|
||||||
api_format: "claude:chat",
|
|
||||||
decision_kind: CLAUDE_CHAT_STREAM_PLAN_KIND,
|
|
||||||
report_kind: "claude_chat_stream_success",
|
|
||||||
family: LocalStandardSourceFamily::Standard,
|
|
||||||
mode: LocalStandardSourceMode::Chat,
|
|
||||||
require_streaming: true,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
use crate::ai_pipeline::planner::common::{
|
|
||||||
CLAUDE_CLI_STREAM_PLAN_KIND, CLAUDE_CLI_SYNC_PLAN_KIND,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::super::family::{LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec};
|
|
||||||
|
|
||||||
pub(crate) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
|
||||||
match plan_kind {
|
|
||||||
CLAUDE_CLI_SYNC_PLAN_KIND => Some(LocalStandardSpec {
|
|
||||||
api_format: "claude:cli",
|
|
||||||
decision_kind: CLAUDE_CLI_SYNC_PLAN_KIND,
|
|
||||||
report_kind: "claude_cli_sync_finalize",
|
|
||||||
family: LocalStandardSourceFamily::Standard,
|
|
||||||
mode: LocalStandardSourceMode::Cli,
|
|
||||||
require_streaming: false,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
|
||||||
match plan_kind {
|
|
||||||
CLAUDE_CLI_STREAM_PLAN_KIND => Some(LocalStandardSpec {
|
|
||||||
api_format: "claude:cli",
|
|
||||||
decision_kind: CLAUDE_CLI_STREAM_PLAN_KIND,
|
|
||||||
report_kind: "claude_cli_stream_success",
|
|
||||||
family: LocalStandardSourceFamily::Standard,
|
|
||||||
mode: LocalStandardSourceMode::Cli,
|
|
||||||
require_streaming: true,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
use crate::control::GatewayControlDecision;
|
use crate::ai_pipeline::control_facade::GatewayControlDecision;
|
||||||
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
||||||
|
|
||||||
|
use aether_ai_pipeline::planner::standard::claude::{
|
||||||
|
resolve_stream_spec as resolve_pipeline_stream_spec,
|
||||||
|
resolve_sync_spec as resolve_pipeline_sync_spec,
|
||||||
|
};
|
||||||
|
|
||||||
use super::family::{
|
use super::family::{
|
||||||
maybe_build_stream_via_standard_family_payload, maybe_build_sync_via_standard_family_payload,
|
maybe_build_stream_via_standard_family_payload, maybe_build_sync_via_standard_family_payload,
|
||||||
};
|
};
|
||||||
pub(crate) use crate::ai_pipeline::conversion::request::normalize_claude_request_to_openai_chat_request;
|
pub(crate) use crate::ai_pipeline::conversion::request::normalize_claude_request_to_openai_chat_request;
|
||||||
|
|
||||||
pub(crate) mod chat;
|
pub(crate) fn resolve_sync_spec(plan_kind: &str) -> Option<super::family::LocalStandardSpec> {
|
||||||
pub(crate) mod cli;
|
resolve_pipeline_sync_spec(plan_kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_stream_spec(plan_kind: &str) -> Option<super::family::LocalStandardSpec> {
|
||||||
|
resolve_pipeline_stream_spec(plan_kind)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn maybe_build_sync_local_claude_decision_payload(
|
pub(crate) async fn maybe_build_sync_local_claude_decision_payload(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
@@ -24,9 +34,7 @@ pub(crate) async fn maybe_build_sync_local_claude_decision_payload(
|
|||||||
decision,
|
decision,
|
||||||
body_json,
|
body_json,
|
||||||
plan_kind,
|
plan_kind,
|
||||||
|plan_kind| {
|
resolve_sync_spec,
|
||||||
chat::resolve_sync_spec(plan_kind).or_else(|| cli::resolve_sync_spec(plan_kind))
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -46,9 +54,7 @@ pub(crate) async fn maybe_build_stream_local_claude_decision_payload(
|
|||||||
decision,
|
decision,
|
||||||
body_json,
|
body_json,
|
||||||
plan_kind,
|
plan_kind,
|
||||||
|plan_kind| {
|
resolve_stream_spec,
|
||||||
chat::resolve_stream_spec(plan_kind).or_else(|| cli::resolve_stream_spec(plan_kind))
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
433
apps/aether-gateway/src/ai_pipeline/planner/standard/codex.rs
Normal file
433
apps/aether-gateway/src/ai_pipeline/planner/standard/codex.rs
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
use crate::ai_pipeline::provider_transport_facade::body_rules_handle_path;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use sha1::{Digest as Sha1Digest, Sha1};
|
||||||
|
use sha2::{Digest as Sha2Digest, Sha256};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const CODEX_PROMPT_CACHE_NAMESPACE_VERSION: &str = "v3";
|
||||||
|
const UUID_NAMESPACE_OID_BYTES: [u8; 16] = [
|
||||||
|
0x6b, 0xa7, 0xb8, 0x12, 0x9d, 0xad, 0x11, 0xd1, 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8,
|
||||||
|
];
|
||||||
|
|
||||||
|
fn is_codex_openai_cli_request(provider_type: &str, provider_api_format: &str) -> bool {
|
||||||
|
provider_type.trim().eq_ignore_ascii_case("codex")
|
||||||
|
&& matches!(
|
||||||
|
provider_api_format.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"openai:cli" | "openai:compact"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_stable_codex_prompt_cache_key(user_api_key_id: &str) -> Option<String> {
|
||||||
|
let normalized = user_api_key_id.trim();
|
||||||
|
if normalized.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let namespace = format!(
|
||||||
|
"aether:codex:prompt-cache:{CODEX_PROMPT_CACHE_NAMESPACE_VERSION}:user:{normalized}"
|
||||||
|
);
|
||||||
|
let mut hasher = Sha1::new();
|
||||||
|
hasher.update(UUID_NAMESPACE_OID_BYTES);
|
||||||
|
hasher.update(namespace.as_bytes());
|
||||||
|
|
||||||
|
let digest = hasher.finalize();
|
||||||
|
let mut bytes = [0u8; 16];
|
||||||
|
bytes.copy_from_slice(&digest[..16]);
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x50;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
Some(Uuid::from_bytes(bytes).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maybe_inject_codex_prompt_cache_key(
|
||||||
|
provider_request_body: &mut Value,
|
||||||
|
provider_type: &str,
|
||||||
|
provider_api_format: &str,
|
||||||
|
user_api_key_id: Option<&str>,
|
||||||
|
) {
|
||||||
|
if !is_codex_openai_cli_request(provider_type, provider_api_format) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(body_object) = provider_request_body.as_object_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let existing = body_object
|
||||||
|
.get("prompt_cache_key")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !existing.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(prompt_cache_key) = user_api_key_id.and_then(build_stable_codex_prompt_cache_key)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
body_object.insert(
|
||||||
|
"prompt_cache_key".to_string(),
|
||||||
|
Value::String(prompt_cache_key),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_short_codex_header_id(seed: &str) -> Option<String> {
|
||||||
|
let normalized = seed.trim();
|
||||||
|
if normalized.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let digest = Sha256::digest(normalized.as_bytes());
|
||||||
|
let mut short_id = String::with_capacity(16);
|
||||||
|
for byte in digest.iter().take(8) {
|
||||||
|
let _ = write!(&mut short_id, "{byte:02x}");
|
||||||
|
}
|
||||||
|
Some(short_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_map_has_non_empty_value(headers: &http::HeaderMap, header_name: &str) -> bool {
|
||||||
|
let target = header_name.trim().to_ascii_lowercase();
|
||||||
|
if target.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.iter().any(|(name, value)| {
|
||||||
|
if name.as_str().trim().to_ascii_lowercase() != target {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
value
|
||||||
|
.to_str()
|
||||||
|
.ok()
|
||||||
|
.map(str::trim)
|
||||||
|
.map(|value| !value.is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_codex_account_id(decrypted_auth_config_raw: Option<&str>) -> Option<String> {
|
||||||
|
let raw = decrypted_auth_config_raw?.trim();
|
||||||
|
if raw.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_str::<Value>(raw).ok().and_then(|value| {
|
||||||
|
value
|
||||||
|
.get("account_id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_codex_openai_cli_special_headers(
|
||||||
|
provider_request_headers: &mut BTreeMap<String, String>,
|
||||||
|
provider_request_body: &Value,
|
||||||
|
original_headers: &http::HeaderMap,
|
||||||
|
provider_type: &str,
|
||||||
|
provider_api_format: &str,
|
||||||
|
request_id: Option<&str>,
|
||||||
|
decrypted_auth_config_raw: Option<&str>,
|
||||||
|
) {
|
||||||
|
if !is_codex_openai_cli_request(provider_type, provider_api_format) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(account_id) = extract_codex_account_id(decrypted_auth_config_raw) {
|
||||||
|
provider_request_headers.insert("chatgpt-account-id".to_string(), account_id);
|
||||||
|
}
|
||||||
|
if !provider_request_headers
|
||||||
|
.get("x-client-request-id")
|
||||||
|
.map(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
if let Some(request_id) = request_id.map(str::trim).filter(|value| !value.is_empty()) {
|
||||||
|
provider_request_headers
|
||||||
|
.insert("x-client-request-id".to_string(), request_id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt_cache_key = provider_request_body
|
||||||
|
.get("prompt_cache_key")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
let Some(short_id) = prompt_cache_key.and_then(build_short_codex_header_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !header_map_has_non_empty_value(original_headers, "session_id") {
|
||||||
|
provider_request_headers.insert("session_id".to_string(), short_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider_api_format.trim().to_ascii_lowercase() != "openai:compact"
|
||||||
|
&& !header_map_has_non_empty_value(original_headers, "conversation_id")
|
||||||
|
{
|
||||||
|
provider_request_headers.insert("conversation_id".to_string(), short_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_codex_openai_cli_special_body_edits(
|
||||||
|
provider_request_body: &mut Value,
|
||||||
|
provider_type: &str,
|
||||||
|
provider_api_format: &str,
|
||||||
|
body_rules: Option<&Value>,
|
||||||
|
user_api_key_id: Option<&str>,
|
||||||
|
) {
|
||||||
|
if !is_codex_openai_cli_request(provider_type, provider_api_format) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(body_object) = provider_request_body.as_object_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !body_rules_handle_path(body_rules, "max_output_tokens") {
|
||||||
|
body_object.remove("max_output_tokens");
|
||||||
|
}
|
||||||
|
if !body_rules_handle_path(body_rules, "temperature") {
|
||||||
|
body_object.remove("temperature");
|
||||||
|
}
|
||||||
|
if !body_rules_handle_path(body_rules, "top_p") {
|
||||||
|
body_object.remove("top_p");
|
||||||
|
}
|
||||||
|
if !body_rules_handle_path(body_rules, "metadata") {
|
||||||
|
body_object.remove("metadata");
|
||||||
|
}
|
||||||
|
if !body_rules_handle_path(body_rules, "store") {
|
||||||
|
body_object.insert("store".to_string(), json!(false));
|
||||||
|
}
|
||||||
|
if !body_rules_handle_path(body_rules, "instructions")
|
||||||
|
&& !body_object.contains_key("instructions")
|
||||||
|
{
|
||||||
|
body_object.insert("instructions".to_string(), json!("You are GPT-5."));
|
||||||
|
}
|
||||||
|
|
||||||
|
maybe_inject_codex_prompt_cache_key(
|
||||||
|
provider_request_body,
|
||||||
|
provider_type,
|
||||||
|
provider_api_format,
|
||||||
|
user_api_key_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
apply_codex_openai_cli_special_body_edits, apply_codex_openai_cli_special_headers,
|
||||||
|
};
|
||||||
|
use http::{HeaderMap, HeaderValue};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn applies_codex_defaults_when_body_rules_do_not_handle_fields() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "gpt-5",
|
||||||
|
"max_output_tokens": 128,
|
||||||
|
"temperature": 0.3,
|
||||||
|
"top_p": 0.9,
|
||||||
|
"metadata": {"client": "desktop"},
|
||||||
|
"store": true
|
||||||
|
});
|
||||||
|
|
||||||
|
apply_codex_openai_cli_special_body_edits(&mut body, "codex", "openai:cli", None, None);
|
||||||
|
|
||||||
|
assert!(body.get("max_output_tokens").is_none());
|
||||||
|
assert!(body.get("temperature").is_none());
|
||||||
|
assert!(body.get("top_p").is_none());
|
||||||
|
assert!(body.get("metadata").is_none());
|
||||||
|
assert_eq!(body["store"], false);
|
||||||
|
assert_eq!(body["instructions"], "You are GPT-5.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defers_to_user_body_rules_for_handled_fields() {
|
||||||
|
let body_rules = json!([
|
||||||
|
{"action":"set","path":"store","value":true},
|
||||||
|
{"action":"set","path":"instructions","value":"Keep custom"},
|
||||||
|
{"action":"set","path":"metadata","value":{"client":"desktop","mode":"custom"}},
|
||||||
|
{"action":"set","path":"top_p","value":0.5}
|
||||||
|
]);
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "gpt-5",
|
||||||
|
"max_output_tokens": 128,
|
||||||
|
"metadata": {"client": "desktop", "mode": "custom"},
|
||||||
|
"store": true,
|
||||||
|
"instructions": "Keep custom",
|
||||||
|
"top_p": 0.5
|
||||||
|
});
|
||||||
|
|
||||||
|
apply_codex_openai_cli_special_body_edits(
|
||||||
|
&mut body,
|
||||||
|
"codex",
|
||||||
|
"openai:compact",
|
||||||
|
Some(&body_rules),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(body.get("max_output_tokens").is_none());
|
||||||
|
assert_eq!(body["store"], true);
|
||||||
|
assert_eq!(body["instructions"], "Keep custom");
|
||||||
|
assert_eq!(body["metadata"]["mode"], "custom");
|
||||||
|
assert_eq!(body["top_p"], 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn injects_stable_prompt_cache_key_for_codex_requests() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "gpt-5",
|
||||||
|
"input": "hello",
|
||||||
|
});
|
||||||
|
|
||||||
|
apply_codex_openai_cli_special_body_edits(
|
||||||
|
&mut body,
|
||||||
|
"codex",
|
||||||
|
"openai:cli",
|
||||||
|
None,
|
||||||
|
Some("key-123"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
body["prompt_cache_key"],
|
||||||
|
"172c39e6-c0a0-5a70-8b63-e0f8e0d185a3"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_existing_prompt_cache_key_for_codex_requests() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "gpt-5",
|
||||||
|
"input": "hello",
|
||||||
|
"prompt_cache_key": "existing-key",
|
||||||
|
});
|
||||||
|
|
||||||
|
apply_codex_openai_cli_special_body_edits(
|
||||||
|
&mut body,
|
||||||
|
"codex",
|
||||||
|
"openai:cli",
|
||||||
|
None,
|
||||||
|
Some("key-123"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(body["prompt_cache_key"], "existing-key");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn injects_chatgpt_account_id_and_session_headers_for_codex_requests() {
|
||||||
|
let mut headers = BTreeMap::new();
|
||||||
|
let body = json!({
|
||||||
|
"model": "gpt-5",
|
||||||
|
"prompt_cache_key": "172c39e6-c0a0-5a70-8b63-e0f8e0d185a3",
|
||||||
|
});
|
||||||
|
|
||||||
|
apply_codex_openai_cli_special_headers(
|
||||||
|
&mut headers,
|
||||||
|
&body,
|
||||||
|
&HeaderMap::new(),
|
||||||
|
"codex",
|
||||||
|
"openai:cli",
|
||||||
|
Some("trace-codex-123"),
|
||||||
|
Some(r#"{"account_id":"acc-123"}"#),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("chatgpt-account-id"),
|
||||||
|
Some(&"acc-123".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("x-client-request-id"),
|
||||||
|
Some(&"trace-codex-123".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("session_id"),
|
||||||
|
Some(&"ab5ecce4f0d110fe".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("conversation_id"),
|
||||||
|
Some(&"ab5ecce4f0d110fe".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn respects_existing_codex_request_and_session_headers() {
|
||||||
|
let mut headers = BTreeMap::new();
|
||||||
|
headers.insert(
|
||||||
|
"x-client-request-id".to_string(),
|
||||||
|
"kept-by-rule-request".to_string(),
|
||||||
|
);
|
||||||
|
headers.insert("session_id".to_string(), "kept-by-rule".to_string());
|
||||||
|
let body = json!({
|
||||||
|
"model": "gpt-5",
|
||||||
|
"prompt_cache_key": "172c39e6-c0a0-5a70-8b63-e0f8e0d185a3",
|
||||||
|
});
|
||||||
|
let mut original_headers = HeaderMap::new();
|
||||||
|
original_headers.insert(
|
||||||
|
"x-client-request-id",
|
||||||
|
HeaderValue::from_static("user-specified-request"),
|
||||||
|
);
|
||||||
|
original_headers.insert(
|
||||||
|
"session_id",
|
||||||
|
HeaderValue::from_static("user-specified-session"),
|
||||||
|
);
|
||||||
|
original_headers.insert(
|
||||||
|
"conversation_id",
|
||||||
|
HeaderValue::from_static("user-specified-conversation"),
|
||||||
|
);
|
||||||
|
|
||||||
|
apply_codex_openai_cli_special_headers(
|
||||||
|
&mut headers,
|
||||||
|
&body,
|
||||||
|
&original_headers,
|
||||||
|
"codex",
|
||||||
|
"openai:cli",
|
||||||
|
Some("trace-codex-123"),
|
||||||
|
Some(r#"{"account_id":"acc-123"}"#),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("x-client-request-id"),
|
||||||
|
Some(&"kept-by-rule-request".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(headers.get("session_id"), Some(&"kept-by-rule".to_string()));
|
||||||
|
assert!(headers.get("conversation_id").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_conversation_id_for_compact_codex_requests() {
|
||||||
|
let mut headers = BTreeMap::new();
|
||||||
|
let body = json!({
|
||||||
|
"model": "gpt-5",
|
||||||
|
"prompt_cache_key": "172c39e6-c0a0-5a70-8b63-e0f8e0d185a3",
|
||||||
|
});
|
||||||
|
|
||||||
|
apply_codex_openai_cli_special_headers(
|
||||||
|
&mut headers,
|
||||||
|
&body,
|
||||||
|
&HeaderMap::new(),
|
||||||
|
"codex",
|
||||||
|
"openai:compact",
|
||||||
|
Some("trace-codex-compact-123"),
|
||||||
|
Some(r#"{"account_id":"acc-123"}"#),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("chatgpt-account-id"),
|
||||||
|
Some(&"acc-123".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("x-client-request-id"),
|
||||||
|
Some(&"trace-codex-compact-123".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("session_id"),
|
||||||
|
Some(&"ab5ecce4f0d110fe".to_string())
|
||||||
|
);
|
||||||
|
assert!(headers.get("conversation_id").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::ai_pipeline::control_facade::GatewayControlDecision;
|
||||||
use crate::ai_pipeline::planner::plan_builders::{
|
use crate::ai_pipeline::planner::plan_builders::{
|
||||||
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
|
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
|
||||||
build_standard_stream_plan_from_decision, build_standard_sync_plan_from_decision,
|
build_standard_stream_plan_from_decision, build_standard_sync_plan_from_decision,
|
||||||
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
||||||
};
|
};
|
||||||
use crate::control::GatewayControlDecision;
|
|
||||||
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
||||||
|
|
||||||
use super::candidates::{
|
use super::candidates::{
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
|
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
use crate::ai_pipeline::control_facade::GatewayControlDecision;
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::ai_pipeline::execution_facade::{ConversionMode, ExecutionStrategy};
|
||||||
use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
|
use crate::ai_pipeline::planner::auth_snapshot_facade::{
|
||||||
use crate::scheduler::{
|
read_auth_api_key_snapshot, GatewayAuthApiKeySnapshot,
|
||||||
current_unix_secs, list_selectable_candidates, GatewayMinimalCandidateSelectionCandidate,
|
|
||||||
};
|
};
|
||||||
|
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
||||||
|
use crate::ai_pipeline::planner::candidate_runtime_facade::persist_available_local_candidate;
|
||||||
|
use crate::ai_pipeline::planner::scheduler_facade::list_selectable_candidates;
|
||||||
|
use crate::clock::current_unix_secs;
|
||||||
use crate::{append_execution_contract_fields_to_value, AppState, GatewayError};
|
use crate::{append_execution_contract_fields_to_value, AppState, GatewayError};
|
||||||
|
|
||||||
use super::types::{
|
use super::types::{
|
||||||
@@ -42,8 +45,8 @@ pub(super) async fn resolve_local_standard_decision_input(
|
|||||||
LocalStandardSourceFamily::Gemini => extract_gemini_model_from_path(parts.uri.path())?,
|
LocalStandardSourceFamily::Gemini => extract_gemini_model_from_path(parts.uri.path())?,
|
||||||
};
|
};
|
||||||
|
|
||||||
let auth_snapshot = match state
|
let auth_snapshot = match read_auth_api_key_snapshot(
|
||||||
.read_auth_api_key_snapshot(
|
state,
|
||||||
&auth_context.user_id,
|
&auth_context.user_id,
|
||||||
&auth_context.api_key_id,
|
&auth_context.api_key_id,
|
||||||
current_unix_secs(),
|
current_unix_secs(),
|
||||||
@@ -157,47 +160,19 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
|
|||||||
candidate.endpoint_api_format.as_str(),
|
candidate.endpoint_api_format.as_str(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let stored_candidate_id = match state
|
let stored_candidate_id = persist_available_local_candidate(
|
||||||
.upsert_request_candidate(UpsertRequestCandidateRecord {
|
state,
|
||||||
id: candidate_id.clone(),
|
trace_id,
|
||||||
request_id: trace_id.to_string(),
|
&input.auth_context.user_id,
|
||||||
user_id: Some(input.auth_context.user_id.clone()),
|
&input.auth_context.api_key_id,
|
||||||
api_key_id: Some(input.auth_context.api_key_id.clone()),
|
&candidate,
|
||||||
username: None,
|
candidate_index as u32,
|
||||||
api_key_name: None,
|
&candidate_id,
|
||||||
candidate_index: candidate_index as u32,
|
Some(extra_data),
|
||||||
retry_index: 0,
|
created_at_unix_secs,
|
||||||
provider_id: Some(candidate.provider_id.clone()),
|
"gateway local standard decision request candidate upsert failed",
|
||||||
endpoint_id: Some(candidate.endpoint_id.clone()),
|
)
|
||||||
key_id: Some(candidate.key_id.clone()),
|
.await;
|
||||||
status: RequestCandidateStatus::Available,
|
|
||||||
skip_reason: None,
|
|
||||||
is_cached: Some(false),
|
|
||||||
status_code: None,
|
|
||||||
error_type: None,
|
|
||||||
error_message: None,
|
|
||||||
latency_ms: None,
|
|
||||||
concurrent_requests: None,
|
|
||||||
extra_data: Some(extra_data),
|
|
||||||
required_capabilities: candidate.key_capabilities.clone(),
|
|
||||||
created_at_unix_secs: Some(created_at_unix_secs),
|
|
||||||
started_at_unix_secs: None,
|
|
||||||
finished_at_unix_secs: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Some(stored)) => stored.id,
|
|
||||||
Ok(None) => candidate_id.clone(),
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
trace_id = %trace_id,
|
|
||||||
api_format = spec.api_format,
|
|
||||||
error = ?err,
|
|
||||||
"gateway local standard decision request candidate upsert failed"
|
|
||||||
);
|
|
||||||
candidate_id.clone()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
attempts.push(LocalStandardCandidateAttempt {
|
attempts.push(LocalStandardCandidateAttempt {
|
||||||
candidate,
|
candidate,
|
||||||
@@ -210,9 +185,9 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn auth_snapshot_allows_cross_format_candidate(
|
fn auth_snapshot_allows_cross_format_candidate(
|
||||||
auth_snapshot: &crate::data::auth::GatewayAuthApiKeySnapshot,
|
auth_snapshot: &GatewayAuthApiKeySnapshot,
|
||||||
requested_model: &str,
|
requested_model: &str,
|
||||||
candidate: &GatewayMinimalCandidateSelectionCandidate,
|
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if let Some(allowed_providers) = auth_snapshot.effective_allowed_providers() {
|
if let Some(allowed_providers) = auth_snapshot.effective_allowed_providers() {
|
||||||
let provider_allowed = allowed_providers.iter().any(|value| {
|
let provider_allowed = allowed_providers.iter().any(|value| {
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
|
use crate::ai_pipeline::control_facade::collect_control_headers;
|
||||||
use crate::headers::collect_control_headers;
|
use crate::ai_pipeline::execution_facade::{ConversionMode, ExecutionStrategy};
|
||||||
use crate::provider_transport::auth::{
|
use crate::ai_pipeline::planner::candidate_runtime_facade::persist_skipped_local_candidate;
|
||||||
build_openai_passthrough_headers, ensure_upstream_auth_header,
|
use crate::ai_pipeline::planner::common::force_upstream_streaming_for_provider;
|
||||||
};
|
use crate::ai_pipeline::planner::standard::apply_codex_openai_cli_special_headers;
|
||||||
use crate::provider_transport::{
|
use crate::ai_pipeline::planner::transport_facade::{
|
||||||
apply_local_header_rules, resolve_transport_execution_timeouts,
|
read_provider_transport_snapshot, resolve_local_oauth_request_auth,
|
||||||
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
|
|
||||||
LocalResolvedOAuthRequestAuth,
|
LocalResolvedOAuthRequestAuth,
|
||||||
};
|
};
|
||||||
use crate::scheduler::current_unix_secs;
|
use crate::ai_pipeline::provider_transport_facade::auth::{
|
||||||
|
build_openai_passthrough_headers, ensure_upstream_auth_header,
|
||||||
|
};
|
||||||
|
use crate::ai_pipeline::provider_transport_facade::{
|
||||||
|
apply_local_header_rules, resolve_transport_execution_timeouts,
|
||||||
|
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
|
||||||
|
};
|
||||||
|
use crate::clock::current_unix_secs;
|
||||||
use crate::{
|
use crate::{
|
||||||
append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse,
|
append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse,
|
||||||
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
|
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
|
||||||
@@ -47,8 +52,8 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
|
|||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
|
|
||||||
let transport = match state
|
let transport = match read_provider_transport_snapshot(
|
||||||
.read_provider_transport_snapshot(
|
state,
|
||||||
&candidate.provider_id,
|
&candidate.provider_id,
|
||||||
&candidate.endpoint_id,
|
&candidate.endpoint_id,
|
||||||
&candidate.key_id,
|
&candidate.key_id,
|
||||||
@@ -107,12 +112,10 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolved_auth = crate::ai_pipeline::conversion::request_conversion_direct_auth(
|
let resolved_auth =
|
||||||
&transport,
|
crate::ai_pipeline::conversion::request_conversion_direct_auth(&transport, conversion_kind);
|
||||||
conversion_kind,
|
|
||||||
);
|
|
||||||
let oauth_auth = if resolved_auth.is_none() {
|
let oauth_auth = if resolved_auth.is_none() {
|
||||||
match state.resolve_local_oauth_request_auth(&transport).await {
|
match resolve_local_oauth_request_auth(state, &transport).await {
|
||||||
Ok(Some(LocalResolvedOAuthRequestAuth::Header { name, value })) => Some((name, value)),
|
Ok(Some(LocalResolvedOAuthRequestAuth::Header { name, value })) => Some((name, value)),
|
||||||
Ok(Some(LocalResolvedOAuthRequestAuth::Kiro(_))) => None,
|
Ok(Some(LocalResolvedOAuthRequestAuth::Kiro(_))) => None,
|
||||||
Ok(None) => None,
|
Ok(None) => None,
|
||||||
@@ -160,15 +163,22 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let upstream_is_stream = spec.require_streaming
|
||||||
|
|| force_upstream_streaming_for_provider(
|
||||||
|
transport.provider.provider_type.as_str(),
|
||||||
|
provider_api_format.as_str(),
|
||||||
|
);
|
||||||
let provider_request_body =
|
let provider_request_body =
|
||||||
match crate::ai_pipeline::planner::standard::build_standard_request_body(
|
match crate::ai_pipeline::planner::standard::build_standard_request_body(
|
||||||
body_json,
|
body_json,
|
||||||
spec.api_format,
|
spec.api_format,
|
||||||
&mapped_model,
|
&mapped_model,
|
||||||
|
transport.provider.provider_type.as_str(),
|
||||||
provider_api_format.as_str(),
|
provider_api_format.as_str(),
|
||||||
parts.uri.path(),
|
parts.uri.path(),
|
||||||
spec.require_streaming,
|
upstream_is_stream,
|
||||||
transport.endpoint.body_rules.as_ref(),
|
transport.endpoint.body_rules.as_ref(),
|
||||||
|
Some(input.auth_context.api_key_id.as_str()),
|
||||||
) {
|
) {
|
||||||
Some(body) => body,
|
Some(body) => body,
|
||||||
None => {
|
None => {
|
||||||
@@ -186,13 +196,12 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let upstream_url =
|
let upstream_url = match crate::ai_pipeline::planner::standard::build_standard_upstream_url(
|
||||||
match crate::ai_pipeline::planner::standard::build_standard_upstream_url(
|
|
||||||
parts,
|
parts,
|
||||||
&transport,
|
&transport,
|
||||||
&mapped_model,
|
&mapped_model,
|
||||||
provider_api_format.as_str(),
|
provider_api_format.as_str(),
|
||||||
spec.require_streaming,
|
upstream_is_stream,
|
||||||
) {
|
) {
|
||||||
Some(url) => url,
|
Some(url) => url,
|
||||||
None => {
|
None => {
|
||||||
@@ -236,8 +245,17 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
|
|||||||
.await;
|
.await;
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
apply_codex_openai_cli_special_headers(
|
||||||
|
&mut provider_request_headers,
|
||||||
|
&provider_request_body,
|
||||||
|
&parts.headers,
|
||||||
|
transport.provider.provider_type.as_str(),
|
||||||
|
provider_api_format.as_str(),
|
||||||
|
Some(trace_id),
|
||||||
|
transport.key.decrypted_auth_config.as_deref(),
|
||||||
|
);
|
||||||
ensure_upstream_auth_header(&mut provider_request_headers, &auth_header, &auth_value);
|
ensure_upstream_auth_header(&mut provider_request_headers, &auth_header, &auth_value);
|
||||||
if spec.require_streaming {
|
if upstream_is_stream {
|
||||||
provider_request_headers
|
provider_request_headers
|
||||||
.entry("accept".to_string())
|
.entry("accept".to_string())
|
||||||
.or_insert_with(|| "text/event-stream".to_string());
|
.or_insert_with(|| "text/event-stream".to_string());
|
||||||
@@ -278,7 +296,7 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
|
|||||||
proxy: resolve_transport_proxy_snapshot_with_tunnel_affinity(state, &transport).await,
|
proxy: resolve_transport_proxy_snapshot_with_tunnel_affinity(state, &transport).await,
|
||||||
tls_profile: resolve_transport_tls_profile(&transport),
|
tls_profile: resolve_transport_tls_profile(&transport),
|
||||||
timeouts: resolve_transport_execution_timeouts(&transport),
|
timeouts: resolve_transport_execution_timeouts(&transport),
|
||||||
upstream_is_stream: spec.require_streaming,
|
upstream_is_stream,
|
||||||
report_kind: Some(spec.report_kind.to_string()),
|
report_kind: Some(spec.report_kind.to_string()),
|
||||||
report_context: Some(append_execution_contract_fields_to_value(
|
report_context: Some(append_execution_contract_fields_to_value(
|
||||||
json!({
|
json!({
|
||||||
@@ -319,46 +337,22 @@ pub(super) async fn mark_skipped_local_standard_candidate(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
input: &LocalStandardDecisionInput,
|
input: &LocalStandardDecisionInput,
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
candidate: &crate::scheduler::GatewayMinimalCandidateSelectionCandidate,
|
candidate: &aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate,
|
||||||
candidate_index: u32,
|
candidate_index: u32,
|
||||||
candidate_id: &str,
|
candidate_id: &str,
|
||||||
skip_reason: &'static str,
|
skip_reason: &'static str,
|
||||||
) {
|
) {
|
||||||
if let Err(err) = state
|
persist_skipped_local_candidate(
|
||||||
.upsert_request_candidate(UpsertRequestCandidateRecord {
|
state,
|
||||||
id: candidate_id.to_string(),
|
trace_id,
|
||||||
request_id: trace_id.to_string(),
|
&input.auth_context.user_id,
|
||||||
user_id: Some(input.auth_context.user_id.clone()),
|
&input.auth_context.api_key_id,
|
||||||
api_key_id: Some(input.auth_context.api_key_id.clone()),
|
candidate,
|
||||||
username: None,
|
|
||||||
api_key_name: None,
|
|
||||||
candidate_index,
|
candidate_index,
|
||||||
retry_index: 0,
|
candidate_id,
|
||||||
provider_id: Some(candidate.provider_id.clone()),
|
|
||||||
endpoint_id: Some(candidate.endpoint_id.clone()),
|
|
||||||
key_id: Some(candidate.key_id.clone()),
|
|
||||||
status: RequestCandidateStatus::Skipped,
|
|
||||||
skip_reason: Some(skip_reason.to_string()),
|
|
||||||
is_cached: Some(false),
|
|
||||||
status_code: None,
|
|
||||||
error_type: None,
|
|
||||||
error_message: None,
|
|
||||||
latency_ms: None,
|
|
||||||
concurrent_requests: None,
|
|
||||||
extra_data: None,
|
|
||||||
required_capabilities: candidate.key_capabilities.clone(),
|
|
||||||
created_at_unix_secs: None,
|
|
||||||
started_at_unix_secs: None,
|
|
||||||
finished_at_unix_secs: Some(current_unix_secs()),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
trace_id = %trace_id,
|
|
||||||
candidate_id = %candidate_id,
|
|
||||||
skip_reason,
|
skip_reason,
|
||||||
error = ?err,
|
current_unix_secs(),
|
||||||
"gateway local standard decision failed to persist skipped candidate"
|
"gateway local standard decision failed to persist skipped candidate",
|
||||||
);
|
)
|
||||||
}
|
.await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,20 @@
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
use crate::ai_pipeline::control_facade::GatewayControlAuthContext;
|
||||||
pub(crate) enum LocalStandardSourceFamily {
|
use crate::ai_pipeline::planner::auth_snapshot_facade::GatewayAuthApiKeySnapshot;
|
||||||
Standard,
|
|
||||||
Gemini,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
pub(crate) use aether_ai_pipeline::planner::standard::family::{
|
||||||
pub(crate) enum LocalStandardSourceMode {
|
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
|
||||||
Chat,
|
};
|
||||||
Cli,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub(crate) struct LocalStandardSpec {
|
|
||||||
pub(crate) api_format: &'static str,
|
|
||||||
pub(crate) decision_kind: &'static str,
|
|
||||||
pub(crate) report_kind: &'static str,
|
|
||||||
pub(crate) family: LocalStandardSourceFamily,
|
|
||||||
pub(crate) mode: LocalStandardSourceMode,
|
|
||||||
pub(crate) require_streaming: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(super) struct LocalStandardDecisionInput {
|
pub(super) struct LocalStandardDecisionInput {
|
||||||
pub(super) auth_context: crate::control::GatewayControlAuthContext,
|
pub(super) auth_context: GatewayControlAuthContext,
|
||||||
pub(super) requested_model: String,
|
pub(super) requested_model: String,
|
||||||
pub(super) auth_snapshot: crate::data::auth::GatewayAuthApiKeySnapshot,
|
pub(super) auth_snapshot: GatewayAuthApiKeySnapshot,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(super) struct LocalStandardCandidateAttempt {
|
pub(super) struct LocalStandardCandidateAttempt {
|
||||||
pub(super) candidate: crate::scheduler::GatewayMinimalCandidateSelectionCandidate,
|
pub(super) candidate: aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate,
|
||||||
pub(super) candidate_index: u32,
|
pub(super) candidate_index: u32,
|
||||||
pub(super) candidate_id: String,
|
pub(super) candidate_id: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
use crate::ai_pipeline::planner::common::{
|
|
||||||
GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::super::family::{LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec};
|
|
||||||
|
|
||||||
pub(crate) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
|
||||||
match plan_kind {
|
|
||||||
GEMINI_CHAT_SYNC_PLAN_KIND => Some(LocalStandardSpec {
|
|
||||||
api_format: "gemini:chat",
|
|
||||||
decision_kind: GEMINI_CHAT_SYNC_PLAN_KIND,
|
|
||||||
report_kind: "gemini_chat_sync_finalize",
|
|
||||||
family: LocalStandardSourceFamily::Gemini,
|
|
||||||
mode: LocalStandardSourceMode::Chat,
|
|
||||||
require_streaming: false,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
|
||||||
match plan_kind {
|
|
||||||
GEMINI_CHAT_STREAM_PLAN_KIND => Some(LocalStandardSpec {
|
|
||||||
api_format: "gemini:chat",
|
|
||||||
decision_kind: GEMINI_CHAT_STREAM_PLAN_KIND,
|
|
||||||
report_kind: "gemini_chat_stream_success",
|
|
||||||
family: LocalStandardSourceFamily::Gemini,
|
|
||||||
mode: LocalStandardSourceMode::Chat,
|
|
||||||
require_streaming: true,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
use crate::ai_pipeline::planner::common::{
|
|
||||||
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::super::family::{LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec};
|
|
||||||
|
|
||||||
pub(crate) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
|
||||||
match plan_kind {
|
|
||||||
GEMINI_CLI_SYNC_PLAN_KIND => Some(LocalStandardSpec {
|
|
||||||
api_format: "gemini:cli",
|
|
||||||
decision_kind: GEMINI_CLI_SYNC_PLAN_KIND,
|
|
||||||
report_kind: "gemini_cli_sync_finalize",
|
|
||||||
family: LocalStandardSourceFamily::Gemini,
|
|
||||||
mode: LocalStandardSourceMode::Cli,
|
|
||||||
require_streaming: false,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
|
||||||
match plan_kind {
|
|
||||||
GEMINI_CLI_STREAM_PLAN_KIND => Some(LocalStandardSpec {
|
|
||||||
api_format: "gemini:cli",
|
|
||||||
decision_kind: GEMINI_CLI_STREAM_PLAN_KIND,
|
|
||||||
report_kind: "gemini_cli_stream_success",
|
|
||||||
family: LocalStandardSourceFamily::Gemini,
|
|
||||||
mode: LocalStandardSourceMode::Cli,
|
|
||||||
require_streaming: true,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
use crate::control::GatewayControlDecision;
|
use crate::ai_pipeline::control_facade::GatewayControlDecision;
|
||||||
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
||||||
|
|
||||||
|
use aether_ai_pipeline::planner::standard::gemini::{
|
||||||
|
resolve_stream_spec as resolve_pipeline_stream_spec,
|
||||||
|
resolve_sync_spec as resolve_pipeline_sync_spec,
|
||||||
|
};
|
||||||
|
|
||||||
use super::family::{
|
use super::family::{
|
||||||
maybe_build_stream_via_standard_family_payload, maybe_build_sync_via_standard_family_payload,
|
maybe_build_stream_via_standard_family_payload, maybe_build_sync_via_standard_family_payload,
|
||||||
};
|
};
|
||||||
pub(crate) use crate::ai_pipeline::conversion::request::normalize_gemini_request_to_openai_chat_request;
|
pub(crate) use crate::ai_pipeline::conversion::request::normalize_gemini_request_to_openai_chat_request;
|
||||||
|
|
||||||
pub(crate) mod chat;
|
pub(crate) fn resolve_sync_spec(plan_kind: &str) -> Option<super::family::LocalStandardSpec> {
|
||||||
pub(crate) mod cli;
|
resolve_pipeline_sync_spec(plan_kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_stream_spec(plan_kind: &str) -> Option<super::family::LocalStandardSpec> {
|
||||||
|
resolve_pipeline_stream_spec(plan_kind)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn maybe_build_sync_local_gemini_decision_payload(
|
pub(crate) async fn maybe_build_sync_local_gemini_decision_payload(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
@@ -24,9 +34,7 @@ pub(crate) async fn maybe_build_sync_local_gemini_decision_payload(
|
|||||||
decision,
|
decision,
|
||||||
body_json,
|
body_json,
|
||||||
plan_kind,
|
plan_kind,
|
||||||
|plan_kind| {
|
resolve_sync_spec,
|
||||||
chat::resolve_sync_spec(plan_kind).or_else(|| cli::resolve_sync_spec(plan_kind))
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -46,9 +54,7 @@ pub(crate) async fn maybe_build_stream_local_gemini_decision_payload(
|
|||||||
decision,
|
decision,
|
||||||
body_json,
|
body_json,
|
||||||
plan_kind,
|
plan_kind,
|
||||||
|plan_kind| {
|
resolve_stream_spec,
|
||||||
chat::resolve_stream_spec(plan_kind).or_else(|| cli::resolve_stream_spec(plan_kind))
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use super::{
|
|||||||
augment_sync_report_context, generic_decision_missing_exact_provider_request,
|
augment_sync_report_context, generic_decision_missing_exact_provider_request,
|
||||||
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::ensure_upstream_auth_header;
|
use crate::ai_pipeline::provider_transport_facade::ensure_upstream_auth_header;
|
||||||
use crate::{GatewayControlSyncDecisionResponse, GatewayError};
|
use crate::{GatewayControlSyncDecisionResponse, GatewayError};
|
||||||
|
|
||||||
pub(crate) fn build_gemini_sync_plan_from_decision(
|
pub(crate) fn build_gemini_sync_plan_from_decision(
|
||||||
|
|||||||
@@ -1,79 +1,59 @@
|
|||||||
use std::collections::BTreeMap;
|
use super::codex::apply_codex_openai_cli_special_body_edits;
|
||||||
|
use crate::ai_pipeline::planner::transport_facade::GatewayProviderTransportSnapshot;
|
||||||
use serde_json::{json, Map, Value};
|
use crate::ai_pipeline::provider_transport_facade::apply_local_body_rules;
|
||||||
|
use crate::ai_pipeline::provider_transport_facade::url::{
|
||||||
use super::{
|
|
||||||
claude::normalize_claude_request_to_openai_chat_request,
|
|
||||||
gemini::normalize_gemini_request_to_openai_chat_request,
|
|
||||||
};
|
|
||||||
use crate::ai_pipeline::conversion::request::{
|
|
||||||
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
|
|
||||||
convert_openai_chat_request_to_openai_cli_request,
|
|
||||||
normalize_openai_cli_request_to_openai_chat_request,
|
|
||||||
};
|
|
||||||
use crate::ai_pipeline::conversion::{request_conversion_kind, RequestConversionKind};
|
|
||||||
use crate::provider_transport::apply_local_body_rules;
|
|
||||||
use crate::provider_transport::url::{
|
|
||||||
build_claude_messages_url, build_gemini_content_url, build_openai_chat_url,
|
build_claude_messages_url, build_gemini_content_url, build_openai_chat_url,
|
||||||
build_openai_cli_url, build_passthrough_path_url,
|
build_openai_cli_url, build_passthrough_path_url,
|
||||||
};
|
};
|
||||||
|
use aether_ai_pipeline::planner::matrix::build_standard_request_body_from_canonical;
|
||||||
|
pub(crate) use aether_ai_pipeline::planner::standard::normalize_standard_request_to_openai_chat_request;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
pub(crate) fn build_standard_request_body(
|
pub(crate) fn build_standard_request_body(
|
||||||
body_json: &Value,
|
body_json: &Value,
|
||||||
client_api_format: &str,
|
client_api_format: &str,
|
||||||
mapped_model: &str,
|
mapped_model: &str,
|
||||||
|
provider_type: &str,
|
||||||
provider_api_format: &str,
|
provider_api_format: &str,
|
||||||
request_path: &str,
|
request_path: &str,
|
||||||
upstream_is_stream: bool,
|
upstream_is_stream: bool,
|
||||||
body_rules: Option<&Value>,
|
body_rules: Option<&Value>,
|
||||||
|
user_api_key_id: Option<&str>,
|
||||||
) -> Option<Value> {
|
) -> Option<Value> {
|
||||||
let canonical_request = normalize_standard_request_to_openai_chat_request(
|
let canonical_request = normalize_standard_request_to_openai_chat_request(
|
||||||
body_json,
|
body_json,
|
||||||
client_api_format,
|
client_api_format,
|
||||||
request_path,
|
request_path,
|
||||||
)?;
|
)?;
|
||||||
let conversion_kind = request_conversion_kind(client_api_format, provider_api_format)?;
|
if cfg!(test) {
|
||||||
let mut provider_request_body = match conversion_kind {
|
println!("canonical_request: {canonical_request:#?}");
|
||||||
RequestConversionKind::ToOpenAIChat => {
|
|
||||||
build_openai_chat_request_body(&canonical_request, mapped_model, upstream_is_stream)?
|
|
||||||
}
|
}
|
||||||
RequestConversionKind::ToOpenAIFamilyCli => {
|
let mut provider_request_body = build_standard_request_body_from_canonical(
|
||||||
convert_openai_chat_request_to_openai_cli_request(
|
|
||||||
&canonical_request,
|
&canonical_request,
|
||||||
mapped_model,
|
mapped_model,
|
||||||
|
provider_api_format,
|
||||||
upstream_is_stream,
|
upstream_is_stream,
|
||||||
false,
|
)?;
|
||||||
)?
|
if cfg!(test) {
|
||||||
|
println!("provider_request_body before rules: {provider_request_body:#?}");
|
||||||
}
|
}
|
||||||
RequestConversionKind::ToOpenAICompact => {
|
|
||||||
convert_openai_chat_request_to_openai_cli_request(
|
|
||||||
&canonical_request,
|
|
||||||
mapped_model,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
)?
|
|
||||||
}
|
|
||||||
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
|
|
||||||
&canonical_request,
|
|
||||||
mapped_model,
|
|
||||||
upstream_is_stream,
|
|
||||||
)?,
|
|
||||||
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
|
|
||||||
&canonical_request,
|
|
||||||
mapped_model,
|
|
||||||
upstream_is_stream,
|
|
||||||
)?,
|
|
||||||
};
|
|
||||||
|
|
||||||
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
|
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
apply_codex_openai_cli_special_body_edits(
|
||||||
|
&mut provider_request_body,
|
||||||
|
provider_type,
|
||||||
|
provider_api_format,
|
||||||
|
body_rules,
|
||||||
|
user_api_key_id,
|
||||||
|
);
|
||||||
Some(provider_request_body)
|
Some(provider_request_body)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_standard_upstream_url(
|
pub(crate) fn build_standard_upstream_url(
|
||||||
parts: &http::request::Parts,
|
parts: &http::request::Parts,
|
||||||
transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
mapped_model: &str,
|
mapped_model: &str,
|
||||||
provider_api_format: &str,
|
provider_api_format: &str,
|
||||||
upstream_is_stream: bool,
|
upstream_is_stream: bool,
|
||||||
@@ -119,42 +99,6 @@ pub(crate) fn build_standard_upstream_url(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn normalize_standard_request_to_openai_chat_request(
|
|
||||||
body_json: &Value,
|
|
||||||
client_api_format: &str,
|
|
||||||
request_path: &str,
|
|
||||||
) -> Option<Value> {
|
|
||||||
match client_api_format.trim().to_ascii_lowercase().as_str() {
|
|
||||||
"openai:chat" => Some(body_json.clone()),
|
|
||||||
"openai:cli" | "openai:compact" => {
|
|
||||||
normalize_openai_cli_request_to_openai_chat_request(body_json)
|
|
||||||
}
|
|
||||||
"claude:chat" | "claude:cli" => normalize_claude_request_to_openai_chat_request(body_json),
|
|
||||||
"gemini:chat" | "gemini:cli" => {
|
|
||||||
normalize_gemini_request_to_openai_chat_request(body_json, request_path)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_openai_chat_request_body(
|
|
||||||
body_json: &Value,
|
|
||||||
mapped_model: &str,
|
|
||||||
upstream_is_stream: bool,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let request_body_object = body_json.as_object()?;
|
|
||||||
let mut provider_request_body = serde_json::Map::from_iter(
|
|
||||||
request_body_object
|
|
||||||
.iter()
|
|
||||||
.map(|(key, value)| (key.clone(), value.clone())),
|
|
||||||
);
|
|
||||||
provider_request_body.insert("model".to_string(), Value::String(mapped_model.to_string()));
|
|
||||||
if upstream_is_stream {
|
|
||||||
provider_request_body.insert("stream".to_string(), Value::Bool(true));
|
|
||||||
}
|
|
||||||
Some(Value::Object(provider_request_body))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::build_standard_request_body;
|
use super::build_standard_request_body;
|
||||||
@@ -178,10 +122,12 @@ mod tests {
|
|||||||
&request,
|
&request,
|
||||||
"claude:chat",
|
"claude:chat",
|
||||||
"gpt-5",
|
"gpt-5",
|
||||||
|
"openai",
|
||||||
"openai:chat",
|
"openai:chat",
|
||||||
"/v1/messages",
|
"/v1/messages",
|
||||||
false,
|
false,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("claude chat should convert to openai chat");
|
.expect("claude chat should convert to openai chat");
|
||||||
|
|
||||||
@@ -210,10 +156,12 @@ mod tests {
|
|||||||
&request,
|
&request,
|
||||||
"gemini:chat",
|
"gemini:chat",
|
||||||
"claude-sonnet-4-5",
|
"claude-sonnet-4-5",
|
||||||
|
"anthropic",
|
||||||
"claude:chat",
|
"claude:chat",
|
||||||
"/v1beta/models/gemini-2.5-pro:generateContent",
|
"/v1beta/models/gemini-2.5-pro:generateContent",
|
||||||
false,
|
false,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("gemini chat should convert to claude chat");
|
.expect("gemini chat should convert to claude chat");
|
||||||
|
|
||||||
@@ -244,10 +192,12 @@ mod tests {
|
|||||||
&request,
|
&request,
|
||||||
"claude:cli",
|
"claude:cli",
|
||||||
"gemini-2.5-pro",
|
"gemini-2.5-pro",
|
||||||
|
"google",
|
||||||
"gemini:cli",
|
"gemini:cli",
|
||||||
"/v1/messages",
|
"/v1/messages",
|
||||||
false,
|
false,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("claude cli should convert to gemini cli");
|
.expect("claude cli should convert to gemini cli");
|
||||||
|
|
||||||
@@ -257,4 +207,133 @@ mod tests {
|
|||||||
"Need CLI output"
|
"Need CLI output"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_openai_cli_request_from_claude_cli_source_with_forced_stream() {
|
||||||
|
let request = json!({
|
||||||
|
"model": "claude-sonnet-4-5",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": "Need OpenAI CLI output"}]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"max_tokens": 64
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted = build_standard_request_body(
|
||||||
|
&request,
|
||||||
|
"claude:cli",
|
||||||
|
"gpt-5",
|
||||||
|
"openai",
|
||||||
|
"openai:cli",
|
||||||
|
"/v1/messages",
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("claude cli should convert to openai cli");
|
||||||
|
|
||||||
|
assert_eq!(converted["model"], "gpt-5");
|
||||||
|
assert_eq!(converted["input"][0]["role"], "user");
|
||||||
|
assert_eq!(converted["input"][0]["content"][0]["type"], "input_text");
|
||||||
|
assert_eq!(
|
||||||
|
converted["input"][0]["content"][0]["text"],
|
||||||
|
"Need OpenAI CLI output"
|
||||||
|
);
|
||||||
|
assert_eq!(converted["stream"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_metadata_for_codex_openai_cli_requests() {
|
||||||
|
let request = json!({
|
||||||
|
"model": "claude-sonnet-4-5",
|
||||||
|
"metadata": {"trace_id": "abc"},
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": "Need OpenAI CLI output"}]
|
||||||
|
}],
|
||||||
|
"max_tokens": 64
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted = build_standard_request_body(
|
||||||
|
&request,
|
||||||
|
"claude:cli",
|
||||||
|
"gpt-5",
|
||||||
|
"codex",
|
||||||
|
"openai:cli",
|
||||||
|
"/v1/messages",
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("claude cli should convert to codex cli");
|
||||||
|
|
||||||
|
assert!(converted.get("metadata").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn applies_codex_defaults_unless_body_rules_handle_the_field() {
|
||||||
|
let request = json!({
|
||||||
|
"model": "claude-sonnet-4-5",
|
||||||
|
"metadata": {"trace_id": "abc"},
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": "Need OpenAI CLI output"}]
|
||||||
|
}],
|
||||||
|
"max_tokens": 64
|
||||||
|
});
|
||||||
|
let body_rules = json!([
|
||||||
|
{"action":"set","path":"store","value":true},
|
||||||
|
{"action":"set","path":"instructions","value":"Custom instructions"},
|
||||||
|
{"action":"set","path":"metadata","value":{"trace_id":"keep-me"}}
|
||||||
|
]);
|
||||||
|
|
||||||
|
let converted = build_standard_request_body(
|
||||||
|
&request,
|
||||||
|
"claude:cli",
|
||||||
|
"gpt-5",
|
||||||
|
"codex",
|
||||||
|
"openai:cli",
|
||||||
|
"/v1/messages",
|
||||||
|
true,
|
||||||
|
Some(&body_rules),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("claude cli should convert to codex cli");
|
||||||
|
|
||||||
|
assert_eq!(converted["store"], true);
|
||||||
|
assert_eq!(converted["instructions"], "Custom instructions");
|
||||||
|
assert_eq!(converted["metadata"]["trace_id"], "keep-me");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn injects_codex_prompt_cache_key_for_standard_requests() {
|
||||||
|
let request = json!({
|
||||||
|
"model": "claude-sonnet-4-5",
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": "Need OpenAI CLI output"}]
|
||||||
|
}],
|
||||||
|
"max_tokens": 64
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted = build_standard_request_body(
|
||||||
|
&request,
|
||||||
|
"claude:cli",
|
||||||
|
"gpt-5",
|
||||||
|
"codex",
|
||||||
|
"openai:cli",
|
||||||
|
"/v1/messages",
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
Some("key-123"),
|
||||||
|
)
|
||||||
|
.expect("claude cli should convert to codex cli");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
converted["prompt_cache_key"],
|
||||||
|
"172c39e6-c0a0-5a70-8b63-e0f8e0d185a3"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,18 @@
|
|||||||
//! This groups the standard planning surface in one place:
|
//! This groups the standard planning surface in one place:
|
||||||
//! request-side conversion, matrix registry, and decision payload builders.
|
//! request-side conversion, matrix registry, and decision payload builders.
|
||||||
|
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::ai_pipeline::control_facade::GatewayControlDecision;
|
||||||
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
||||||
|
|
||||||
pub(crate) mod claude;
|
pub(crate) mod claude;
|
||||||
|
mod codex;
|
||||||
pub(crate) mod family;
|
pub(crate) mod family;
|
||||||
pub(crate) mod gemini;
|
pub(crate) mod gemini;
|
||||||
mod matrix;
|
mod matrix;
|
||||||
mod normalize;
|
mod normalize;
|
||||||
pub(crate) mod openai;
|
pub(crate) mod openai;
|
||||||
|
|
||||||
|
pub(crate) use self::codex::apply_codex_openai_cli_special_headers;
|
||||||
pub(crate) use self::matrix::{
|
pub(crate) use self::matrix::{
|
||||||
build_standard_request_body, build_standard_upstream_url,
|
build_standard_request_body, build_standard_upstream_url,
|
||||||
normalize_standard_request_to_openai_chat_request,
|
normalize_standard_request_to_openai_chat_request,
|
||||||
|
|||||||
@@ -3,20 +3,23 @@ use std::collections::BTreeMap;
|
|||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use url::form_urlencoded;
|
use url::form_urlencoded;
|
||||||
|
|
||||||
use crate::ai_pipeline::conversion::request::{
|
use super::codex::apply_codex_openai_cli_special_body_edits;
|
||||||
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
|
|
||||||
convert_openai_chat_request_to_openai_cli_request, extract_openai_text_content,
|
|
||||||
normalize_openai_cli_request_to_openai_chat_request, parse_openai_tool_result_content,
|
|
||||||
};
|
|
||||||
use crate::ai_pipeline::conversion::{request_conversion_kind, RequestConversionKind};
|
use crate::ai_pipeline::conversion::{request_conversion_kind, RequestConversionKind};
|
||||||
use crate::provider_transport::antigravity::{
|
use crate::ai_pipeline::planner::transport_facade::GatewayProviderTransportSnapshot;
|
||||||
|
use crate::ai_pipeline::provider_transport_facade::antigravity::{
|
||||||
build_antigravity_v1internal_url, AntigravityRequestUrlAction,
|
build_antigravity_v1internal_url, AntigravityRequestUrlAction,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::apply_local_body_rules;
|
use crate::ai_pipeline::provider_transport_facade::apply_local_body_rules;
|
||||||
use crate::provider_transport::url::{
|
use crate::ai_pipeline::provider_transport_facade::url::{
|
||||||
build_claude_messages_url, build_gemini_content_url, build_openai_chat_url,
|
build_claude_messages_url, build_gemini_content_url, build_openai_chat_url,
|
||||||
build_openai_cli_url, build_passthrough_path_url,
|
build_openai_cli_url, build_passthrough_path_url,
|
||||||
};
|
};
|
||||||
|
use aether_ai_pipeline::planner::standard::normalize::{
|
||||||
|
build_cross_format_openai_chat_request_body as pipeline_build_cross_format_openai_chat_request_body,
|
||||||
|
build_cross_format_openai_cli_request_body as pipeline_build_cross_format_openai_cli_request_body,
|
||||||
|
build_local_openai_chat_request_body as pipeline_build_local_openai_chat_request_body,
|
||||||
|
build_local_openai_cli_request_body as pipeline_build_local_openai_cli_request_body,
|
||||||
|
};
|
||||||
|
|
||||||
pub(crate) fn build_local_openai_chat_request_body(
|
pub(crate) fn build_local_openai_chat_request_body(
|
||||||
body_json: &Value,
|
body_json: &Value,
|
||||||
@@ -24,17 +27,8 @@ pub(crate) fn build_local_openai_chat_request_body(
|
|||||||
upstream_is_stream: bool,
|
upstream_is_stream: bool,
|
||||||
body_rules: Option<&Value>,
|
body_rules: Option<&Value>,
|
||||||
) -> Option<Value> {
|
) -> Option<Value> {
|
||||||
let request_body_object = body_json.as_object()?;
|
let mut provider_request_body =
|
||||||
let mut provider_request_body = serde_json::Map::from_iter(
|
pipeline_build_local_openai_chat_request_body(body_json, mapped_model, upstream_is_stream)?;
|
||||||
request_body_object
|
|
||||||
.iter()
|
|
||||||
.map(|(key, value)| (key.clone(), value.clone())),
|
|
||||||
);
|
|
||||||
provider_request_body.insert("model".to_string(), Value::String(mapped_model.to_string()));
|
|
||||||
if upstream_is_stream {
|
|
||||||
provider_request_body.insert("stream".to_string(), Value::Bool(true));
|
|
||||||
}
|
|
||||||
let mut provider_request_body = Value::Object(provider_request_body);
|
|
||||||
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
|
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -43,7 +37,7 @@ pub(crate) fn build_local_openai_chat_request_body(
|
|||||||
|
|
||||||
pub(crate) fn build_local_openai_chat_upstream_url(
|
pub(crate) fn build_local_openai_chat_upstream_url(
|
||||||
parts: &http::request::Parts,
|
parts: &http::request::Parts,
|
||||||
transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let custom_path = transport
|
let custom_path = transport
|
||||||
.endpoint
|
.endpoint
|
||||||
@@ -66,45 +60,34 @@ pub(crate) fn build_local_openai_chat_upstream_url(
|
|||||||
pub(crate) fn build_cross_format_openai_chat_request_body(
|
pub(crate) fn build_cross_format_openai_chat_request_body(
|
||||||
body_json: &Value,
|
body_json: &Value,
|
||||||
mapped_model: &str,
|
mapped_model: &str,
|
||||||
|
provider_type: &str,
|
||||||
provider_api_format: &str,
|
provider_api_format: &str,
|
||||||
upstream_is_stream: bool,
|
upstream_is_stream: bool,
|
||||||
body_rules: Option<&Value>,
|
body_rules: Option<&Value>,
|
||||||
|
user_api_key_id: Option<&str>,
|
||||||
) -> Option<Value> {
|
) -> Option<Value> {
|
||||||
let conversion_kind = request_conversion_kind("openai:chat", provider_api_format)?;
|
let mut provider_request_body = pipeline_build_cross_format_openai_chat_request_body(
|
||||||
let mut provider_request_body = match conversion_kind {
|
|
||||||
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
|
|
||||||
body_json,
|
body_json,
|
||||||
mapped_model,
|
mapped_model,
|
||||||
|
provider_api_format,
|
||||||
upstream_is_stream,
|
upstream_is_stream,
|
||||||
)?,
|
)?;
|
||||||
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
|
|
||||||
body_json,
|
|
||||||
mapped_model,
|
|
||||||
upstream_is_stream,
|
|
||||||
)?,
|
|
||||||
RequestConversionKind::ToOpenAIFamilyCli => {
|
|
||||||
convert_openai_chat_request_to_openai_cli_request(
|
|
||||||
body_json,
|
|
||||||
mapped_model,
|
|
||||||
upstream_is_stream,
|
|
||||||
false,
|
|
||||||
)?
|
|
||||||
}
|
|
||||||
RequestConversionKind::ToOpenAICompact => {
|
|
||||||
convert_openai_chat_request_to_openai_cli_request(body_json, mapped_model, false, true)?
|
|
||||||
}
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
|
|
||||||
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
|
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
apply_codex_openai_cli_special_body_edits(
|
||||||
|
&mut provider_request_body,
|
||||||
|
provider_type,
|
||||||
|
provider_api_format,
|
||||||
|
body_rules,
|
||||||
|
user_api_key_id,
|
||||||
|
);
|
||||||
Some(provider_request_body)
|
Some(provider_request_body)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_cross_format_openai_chat_upstream_url(
|
pub(crate) fn build_cross_format_openai_chat_upstream_url(
|
||||||
parts: &http::request::Parts,
|
parts: &http::request::Parts,
|
||||||
transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
mapped_model: &str,
|
mapped_model: &str,
|
||||||
provider_api_format: &str,
|
provider_api_format: &str,
|
||||||
upstream_is_stream: bool,
|
upstream_is_stream: bool,
|
||||||
@@ -151,22 +134,23 @@ pub(crate) fn build_local_openai_cli_request_body(
|
|||||||
body_json: &Value,
|
body_json: &Value,
|
||||||
mapped_model: &str,
|
mapped_model: &str,
|
||||||
require_streaming: bool,
|
require_streaming: bool,
|
||||||
|
provider_type: &str,
|
||||||
|
provider_api_format: &str,
|
||||||
body_rules: Option<&Value>,
|
body_rules: Option<&Value>,
|
||||||
|
user_api_key_id: Option<&str>,
|
||||||
) -> Option<Value> {
|
) -> Option<Value> {
|
||||||
let request_body_object = body_json.as_object()?;
|
let mut provider_request_body =
|
||||||
let mut provider_request_body = serde_json::Map::from_iter(
|
pipeline_build_local_openai_cli_request_body(body_json, mapped_model, require_streaming)?;
|
||||||
request_body_object
|
|
||||||
.iter()
|
|
||||||
.map(|(key, value)| (key.clone(), value.clone())),
|
|
||||||
);
|
|
||||||
provider_request_body.insert("model".to_string(), Value::String(mapped_model.to_string()));
|
|
||||||
if require_streaming {
|
|
||||||
provider_request_body.insert("stream".to_string(), Value::Bool(true));
|
|
||||||
}
|
|
||||||
let mut provider_request_body = Value::Object(provider_request_body);
|
|
||||||
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
|
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
apply_codex_openai_cli_special_body_edits(
|
||||||
|
&mut provider_request_body,
|
||||||
|
provider_type,
|
||||||
|
provider_api_format,
|
||||||
|
body_rules,
|
||||||
|
user_api_key_id,
|
||||||
|
);
|
||||||
Some(provider_request_body)
|
Some(provider_request_body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,48 +160,33 @@ pub(crate) fn build_cross_format_openai_cli_request_body(
|
|||||||
client_api_format: &str,
|
client_api_format: &str,
|
||||||
provider_api_format: &str,
|
provider_api_format: &str,
|
||||||
upstream_is_stream: bool,
|
upstream_is_stream: bool,
|
||||||
|
provider_type: &str,
|
||||||
body_rules: Option<&Value>,
|
body_rules: Option<&Value>,
|
||||||
|
user_api_key_id: Option<&str>,
|
||||||
) -> Option<Value> {
|
) -> Option<Value> {
|
||||||
let chat_like_request = normalize_openai_cli_request_to_openai_chat_request(body_json)?;
|
let mut provider_request_body = pipeline_build_cross_format_openai_cli_request_body(
|
||||||
let conversion_kind = request_conversion_kind(client_api_format, provider_api_format)?;
|
body_json,
|
||||||
let mut provider_request_body = match conversion_kind {
|
|
||||||
RequestConversionKind::ToOpenAIFamilyCli => {
|
|
||||||
convert_openai_chat_request_to_openai_cli_request(
|
|
||||||
&chat_like_request,
|
|
||||||
mapped_model,
|
mapped_model,
|
||||||
|
client_api_format,
|
||||||
|
provider_api_format,
|
||||||
upstream_is_stream,
|
upstream_is_stream,
|
||||||
false,
|
)?;
|
||||||
)?
|
|
||||||
}
|
|
||||||
RequestConversionKind::ToOpenAICompact => {
|
|
||||||
convert_openai_chat_request_to_openai_cli_request(
|
|
||||||
&chat_like_request,
|
|
||||||
mapped_model,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
)?
|
|
||||||
}
|
|
||||||
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
|
|
||||||
&chat_like_request,
|
|
||||||
mapped_model,
|
|
||||||
upstream_is_stream,
|
|
||||||
)?,
|
|
||||||
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
|
|
||||||
&chat_like_request,
|
|
||||||
mapped_model,
|
|
||||||
upstream_is_stream,
|
|
||||||
)?,
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
|
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
apply_codex_openai_cli_special_body_edits(
|
||||||
|
&mut provider_request_body,
|
||||||
|
provider_type,
|
||||||
|
provider_api_format,
|
||||||
|
body_rules,
|
||||||
|
user_api_key_id,
|
||||||
|
);
|
||||||
Some(provider_request_body)
|
Some(provider_request_body)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_local_openai_cli_upstream_url(
|
pub(crate) fn build_local_openai_cli_upstream_url(
|
||||||
parts: &http::request::Parts,
|
parts: &http::request::Parts,
|
||||||
transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
compact: bool,
|
compact: bool,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let custom_path = transport
|
let custom_path = transport
|
||||||
@@ -241,7 +210,7 @@ pub(crate) fn build_local_openai_cli_upstream_url(
|
|||||||
|
|
||||||
pub(crate) fn build_cross_format_openai_cli_upstream_url(
|
pub(crate) fn build_cross_format_openai_cli_upstream_url(
|
||||||
parts: &http::request::Parts,
|
parts: &http::request::Parts,
|
||||||
transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
mapped_model: &str,
|
mapped_model: &str,
|
||||||
client_api_format: &str,
|
client_api_format: &str,
|
||||||
provider_api_format: &str,
|
provider_api_format: &str,
|
||||||
@@ -325,6 +294,8 @@ mod tests {
|
|||||||
"openai:compact",
|
"openai:compact",
|
||||||
"openai:cli",
|
"openai:cli",
|
||||||
false,
|
false,
|
||||||
|
"openai",
|
||||||
|
None,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.expect("compact to openai cli body should build");
|
.expect("compact to openai cli body should build");
|
||||||
@@ -333,4 +304,119 @@ mod tests {
|
|||||||
assert_eq!(provider_request_body["input"][0]["type"], "message");
|
assert_eq!(provider_request_body["input"][0]["type"], "message");
|
||||||
assert_eq!(provider_request_body["input"][0]["role"], "user");
|
assert_eq!(provider_request_body["input"][0]["role"], "user");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_metadata_for_codex_openai_cli_requests() {
|
||||||
|
let body_json = json!({
|
||||||
|
"model": "claude-sonnet-4-5",
|
||||||
|
"metadata": {"trace_id": "abc"},
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": "hello"}]
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
let provider_request_body = build_cross_format_openai_cli_request_body(
|
||||||
|
&body_json,
|
||||||
|
"gpt-5-upstream",
|
||||||
|
"claude:cli",
|
||||||
|
"openai:cli",
|
||||||
|
true,
|
||||||
|
"codex",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("claude cli to codex request should build");
|
||||||
|
|
||||||
|
assert!(provider_request_body.get("metadata").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn applies_codex_defaults_unless_body_rules_handle_the_field() {
|
||||||
|
let body_json = json!({
|
||||||
|
"model": "claude-sonnet-4-5",
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": "hello"}]
|
||||||
|
}],
|
||||||
|
"metadata": {"trace_id": "abc"},
|
||||||
|
"store": true
|
||||||
|
});
|
||||||
|
let body_rules = json!([
|
||||||
|
{"action":"set","path":"store","value":true},
|
||||||
|
{"action":"set","path":"instructions","value":"Custom instructions"},
|
||||||
|
{"action":"set","path":"metadata","value":{"trace_id":"keep-me"}}
|
||||||
|
]);
|
||||||
|
|
||||||
|
let provider_request_body = build_cross_format_openai_cli_request_body(
|
||||||
|
&body_json,
|
||||||
|
"gpt-5-upstream",
|
||||||
|
"claude:cli",
|
||||||
|
"openai:cli",
|
||||||
|
true,
|
||||||
|
"codex",
|
||||||
|
Some(&body_rules),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("claude cli to codex request should build");
|
||||||
|
|
||||||
|
assert_eq!(provider_request_body["store"], true);
|
||||||
|
assert_eq!(provider_request_body["instructions"], "Custom instructions");
|
||||||
|
assert_eq!(provider_request_body["metadata"]["trace_id"], "keep-me");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn injects_codex_prompt_cache_key_for_openai_cli_cross_format_requests() {
|
||||||
|
let body_json = json!({
|
||||||
|
"model": "claude-sonnet-4-5",
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": "hello"}]
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
let provider_request_body = build_cross_format_openai_cli_request_body(
|
||||||
|
&body_json,
|
||||||
|
"gpt-5-upstream",
|
||||||
|
"claude:cli",
|
||||||
|
"openai:cli",
|
||||||
|
true,
|
||||||
|
"codex",
|
||||||
|
None,
|
||||||
|
Some("key-123"),
|
||||||
|
)
|
||||||
|
.expect("claude cli to codex request should build");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
provider_request_body["prompt_cache_key"],
|
||||||
|
"172c39e6-c0a0-5a70-8b63-e0f8e0d185a3"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn injects_codex_prompt_cache_key_for_openai_chat_cross_format_requests() {
|
||||||
|
let body_json = json!({
|
||||||
|
"model": "gpt-5",
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": "hello"
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
let provider_request_body = super::build_cross_format_openai_chat_request_body(
|
||||||
|
&body_json,
|
||||||
|
"gpt-5-upstream",
|
||||||
|
"codex",
|
||||||
|
"openai:cli",
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
Some("key-123"),
|
||||||
|
)
|
||||||
|
.expect("openai chat to codex request should build");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
provider_request_body["prompt_cache_key"],
|
||||||
|
"172c39e6-c0a0-5a70-8b63-e0f8e0d185a3"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,61 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::ai_pipeline::control_facade::{collect_control_headers, GatewayControlAuthContext};
|
||||||
use crate::ai_pipeline::conversion::{
|
use crate::ai_pipeline::conversion::{
|
||||||
request_conversion_direct_auth, request_conversion_kind, request_conversion_transport_supported,
|
request_conversion_direct_auth, request_conversion_kind, request_conversion_transport_supported,
|
||||||
};
|
};
|
||||||
|
use crate::ai_pipeline::execution_facade::{ConversionMode, ExecutionStrategy};
|
||||||
|
use crate::ai_pipeline::planner::auth_snapshot_facade::GatewayAuthApiKeySnapshot;
|
||||||
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
||||||
|
use crate::ai_pipeline::planner::candidate_runtime_facade::{
|
||||||
|
persist_available_local_candidate, persist_skipped_local_candidate,
|
||||||
|
};
|
||||||
use crate::ai_pipeline::planner::common::{
|
use crate::ai_pipeline::planner::common::{
|
||||||
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
|
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
|
||||||
OPENAI_CHAT_STREAM_PLAN_KIND,
|
OPENAI_CHAT_STREAM_PLAN_KIND,
|
||||||
};
|
};
|
||||||
|
use crate::ai_pipeline::planner::executor_facade::mark_unused_local_candidate_items;
|
||||||
use crate::ai_pipeline::planner::plan_builders::{
|
use crate::ai_pipeline::planner::plan_builders::{
|
||||||
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
||||||
};
|
};
|
||||||
use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
|
use crate::ai_pipeline::planner::transport_facade::{
|
||||||
use crate::headers::collect_control_headers;
|
read_provider_transport_snapshot, resolve_local_oauth_request_auth,
|
||||||
use crate::provider_transport::auth::{
|
GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth,
|
||||||
|
};
|
||||||
|
use crate::ai_pipeline::provider_transport_facade::auth::{
|
||||||
build_openai_passthrough_headers, ensure_upstream_auth_header, resolve_local_openai_chat_auth,
|
build_openai_passthrough_headers, ensure_upstream_auth_header, resolve_local_openai_chat_auth,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::policy::supports_local_openai_chat_transport;
|
use crate::ai_pipeline::provider_transport_facade::policy::supports_local_openai_chat_transport;
|
||||||
use crate::provider_transport::{
|
use crate::ai_pipeline::provider_transport_facade::{
|
||||||
apply_local_header_rules, resolve_transport_execution_timeouts,
|
apply_local_header_rules, resolve_transport_execution_timeouts,
|
||||||
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
|
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
|
||||||
LocalResolvedOAuthRequestAuth,
|
|
||||||
};
|
|
||||||
use crate::scheduler::{
|
|
||||||
record_local_request_candidate_status, GatewayMinimalCandidateSelectionCandidate,
|
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse,
|
append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse,
|
||||||
};
|
};
|
||||||
|
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||||
|
|
||||||
use super::plans::current_unix_secs;
|
use super::plans::current_unix_secs;
|
||||||
use crate::ai_pipeline::planner::standard::{
|
use crate::ai_pipeline::planner::standard::{
|
||||||
build_cross_format_openai_chat_request_body, build_cross_format_openai_chat_upstream_url,
|
apply_codex_openai_cli_special_headers, build_cross_format_openai_chat_request_body,
|
||||||
build_local_openai_chat_request_body, build_local_openai_chat_upstream_url,
|
build_cross_format_openai_chat_upstream_url, build_local_openai_chat_request_body,
|
||||||
|
build_local_openai_chat_upstream_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(super) struct LocalOpenAiChatDecisionInput {
|
pub(super) struct LocalOpenAiChatDecisionInput {
|
||||||
pub(super) auth_context: crate::control::GatewayControlAuthContext,
|
pub(super) auth_context: GatewayControlAuthContext,
|
||||||
pub(super) requested_model: String,
|
pub(super) requested_model: String,
|
||||||
pub(super) auth_snapshot: crate::data::auth::GatewayAuthApiKeySnapshot,
|
pub(super) auth_snapshot: GatewayAuthApiKeySnapshot,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(super) struct LocalOpenAiChatCandidateAttempt {
|
pub(super) struct LocalOpenAiChatCandidateAttempt {
|
||||||
pub(super) candidate: GatewayMinimalCandidateSelectionCandidate,
|
pub(super) candidate: SchedulerMinimalCandidateSelectionCandidate,
|
||||||
pub(super) candidate_index: u32,
|
pub(super) candidate_index: u32,
|
||||||
pub(super) candidate_id: String,
|
pub(super) candidate_id: String,
|
||||||
}
|
}
|
||||||
@@ -70,8 +76,8 @@ pub(super) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
|
|||||||
candidate_index,
|
candidate_index,
|
||||||
candidate_id,
|
candidate_id,
|
||||||
} = attempt;
|
} = attempt;
|
||||||
let transport = match state
|
let transport = match read_provider_transport_snapshot(
|
||||||
.read_provider_transport_snapshot(
|
state,
|
||||||
&candidate.provider_id,
|
&candidate.provider_id,
|
||||||
&candidate.endpoint_id,
|
&candidate.endpoint_id,
|
||||||
&candidate.key_id,
|
&candidate.key_id,
|
||||||
@@ -171,13 +177,13 @@ async fn build_same_format_local_openai_chat_decision_payload_for_candidate(
|
|||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
body_json: &serde_json::Value,
|
body_json: &serde_json::Value,
|
||||||
input: &LocalOpenAiChatDecisionInput,
|
input: &LocalOpenAiChatDecisionInput,
|
||||||
candidate: &GatewayMinimalCandidateSelectionCandidate,
|
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||||
candidate_index: u32,
|
candidate_index: u32,
|
||||||
candidate_id: &str,
|
candidate_id: &str,
|
||||||
decision_kind: &str,
|
decision_kind: &str,
|
||||||
report_kind: &str,
|
report_kind: &str,
|
||||||
upstream_is_stream: bool,
|
upstream_is_stream: bool,
|
||||||
transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
) -> Option<GatewayControlSyncDecisionResponse> {
|
) -> Option<GatewayControlSyncDecisionResponse> {
|
||||||
if !supports_local_openai_chat_transport(transport) {
|
if !supports_local_openai_chat_transport(transport) {
|
||||||
mark_skipped_local_openai_chat_candidate(
|
mark_skipped_local_openai_chat_candidate(
|
||||||
@@ -194,7 +200,7 @@ async fn build_same_format_local_openai_chat_decision_payload_for_candidate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let oauth_auth = if resolve_local_openai_chat_auth(transport).is_none() {
|
let oauth_auth = if resolve_local_openai_chat_auth(transport).is_none() {
|
||||||
match state.resolve_local_oauth_request_auth(transport).await {
|
match resolve_local_oauth_request_auth(state, transport).await {
|
||||||
Ok(Some(LocalResolvedOAuthRequestAuth::Header { name, value })) => Some((name, value)),
|
Ok(Some(LocalResolvedOAuthRequestAuth::Header { name, value })) => Some((name, value)),
|
||||||
Ok(Some(LocalResolvedOAuthRequestAuth::Kiro(_))) => None,
|
Ok(Some(LocalResolvedOAuthRequestAuth::Kiro(_))) => None,
|
||||||
Ok(None) => None,
|
Ok(None) => None,
|
||||||
@@ -300,6 +306,15 @@ async fn build_same_format_local_openai_chat_decision_payload_for_candidate(
|
|||||||
.await;
|
.await;
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
apply_codex_openai_cli_special_headers(
|
||||||
|
&mut provider_request_headers,
|
||||||
|
&provider_request_body,
|
||||||
|
&parts.headers,
|
||||||
|
transport.provider.provider_type.as_str(),
|
||||||
|
transport.endpoint.api_format.as_str(),
|
||||||
|
Some(trace_id),
|
||||||
|
transport.key.decrypted_auth_config.as_deref(),
|
||||||
|
);
|
||||||
ensure_upstream_auth_header(&mut provider_request_headers, &auth_header, &auth_value);
|
ensure_upstream_auth_header(&mut provider_request_headers, &auth_header, &auth_value);
|
||||||
if upstream_is_stream {
|
if upstream_is_stream {
|
||||||
provider_request_headers
|
provider_request_headers
|
||||||
@@ -394,12 +409,12 @@ async fn build_cross_format_local_openai_chat_decision_payload_for_candidate(
|
|||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
body_json: &serde_json::Value,
|
body_json: &serde_json::Value,
|
||||||
input: &LocalOpenAiChatDecisionInput,
|
input: &LocalOpenAiChatDecisionInput,
|
||||||
candidate: &GatewayMinimalCandidateSelectionCandidate,
|
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||||
candidate_index: u32,
|
candidate_index: u32,
|
||||||
candidate_id: &str,
|
candidate_id: &str,
|
||||||
decision_kind: &str,
|
decision_kind: &str,
|
||||||
upstream_is_stream: bool,
|
upstream_is_stream: bool,
|
||||||
transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
provider_api_format: &str,
|
provider_api_format: &str,
|
||||||
) -> Option<GatewayControlSyncDecisionResponse> {
|
) -> Option<GatewayControlSyncDecisionResponse> {
|
||||||
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
|
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
|
||||||
@@ -425,7 +440,7 @@ async fn build_cross_format_local_openai_chat_decision_payload_for_candidate(
|
|||||||
|
|
||||||
let resolve_auth = request_conversion_direct_auth(transport, conversion_kind);
|
let resolve_auth = request_conversion_direct_auth(transport, conversion_kind);
|
||||||
let oauth_auth = if resolve_auth.is_none() {
|
let oauth_auth = if resolve_auth.is_none() {
|
||||||
match state.resolve_local_oauth_request_auth(transport).await {
|
match resolve_local_oauth_request_auth(state, transport).await {
|
||||||
Ok(Some(LocalResolvedOAuthRequestAuth::Header { name, value })) => Some((name, value)),
|
Ok(Some(LocalResolvedOAuthRequestAuth::Header { name, value })) => Some((name, value)),
|
||||||
Ok(Some(LocalResolvedOAuthRequestAuth::Kiro(_))) => None,
|
Ok(Some(LocalResolvedOAuthRequestAuth::Kiro(_))) => None,
|
||||||
Ok(None) => None,
|
Ok(None) => None,
|
||||||
@@ -476,9 +491,11 @@ async fn build_cross_format_local_openai_chat_decision_payload_for_candidate(
|
|||||||
let Some(provider_request_body) = build_cross_format_openai_chat_request_body(
|
let Some(provider_request_body) = build_cross_format_openai_chat_request_body(
|
||||||
body_json,
|
body_json,
|
||||||
&mapped_model,
|
&mapped_model,
|
||||||
|
transport.provider.provider_type.as_str(),
|
||||||
provider_api_format.as_str(),
|
provider_api_format.as_str(),
|
||||||
upstream_is_stream,
|
upstream_is_stream,
|
||||||
transport.endpoint.body_rules.as_ref(),
|
transport.endpoint.body_rules.as_ref(),
|
||||||
|
Some(input.auth_context.api_key_id.as_str()),
|
||||||
) else {
|
) else {
|
||||||
mark_skipped_local_openai_chat_candidate(
|
mark_skipped_local_openai_chat_candidate(
|
||||||
state,
|
state,
|
||||||
@@ -539,6 +556,15 @@ async fn build_cross_format_local_openai_chat_decision_payload_for_candidate(
|
|||||||
.await;
|
.await;
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
apply_codex_openai_cli_special_headers(
|
||||||
|
&mut provider_request_headers,
|
||||||
|
&provider_request_body,
|
||||||
|
&parts.headers,
|
||||||
|
transport.provider.provider_type.as_str(),
|
||||||
|
provider_api_format.as_str(),
|
||||||
|
Some(trace_id),
|
||||||
|
transport.key.decrypted_auth_config.as_deref(),
|
||||||
|
);
|
||||||
ensure_upstream_auth_header(&mut provider_request_headers, &auth_header, &auth_value);
|
ensure_upstream_auth_header(&mut provider_request_headers, &auth_header, &auth_value);
|
||||||
if upstream_is_stream {
|
if upstream_is_stream {
|
||||||
provider_request_headers
|
provider_request_headers
|
||||||
@@ -636,7 +662,7 @@ pub(super) async fn mark_skipped_local_openai_chat_candidate(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
input: &LocalOpenAiChatDecisionInput,
|
input: &LocalOpenAiChatDecisionInput,
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
candidate: &GatewayMinimalCandidateSelectionCandidate,
|
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||||
candidate_index: u32,
|
candidate_index: u32,
|
||||||
candidate_id: &str,
|
candidate_id: &str,
|
||||||
skip_reason: &'static str,
|
skip_reason: &'static str,
|
||||||
@@ -649,50 +675,26 @@ pub(super) async fn mark_skipped_local_openai_chat_candidate(
|
|||||||
*diagnostic.skipped_candidate_count.get_or_insert(0) += 1;
|
*diagnostic.skipped_candidate_count.get_or_insert(0) += 1;
|
||||||
});
|
});
|
||||||
let terminal_unix_secs = current_unix_secs();
|
let terminal_unix_secs = current_unix_secs();
|
||||||
if let Err(err) = state
|
persist_skipped_local_candidate(
|
||||||
.upsert_request_candidate(UpsertRequestCandidateRecord {
|
state,
|
||||||
id: candidate_id.to_string(),
|
trace_id,
|
||||||
request_id: trace_id.to_string(),
|
&input.auth_context.user_id,
|
||||||
user_id: Some(input.auth_context.user_id.clone()),
|
&input.auth_context.api_key_id,
|
||||||
api_key_id: Some(input.auth_context.api_key_id.clone()),
|
candidate,
|
||||||
username: None,
|
|
||||||
api_key_name: None,
|
|
||||||
candidate_index,
|
candidate_index,
|
||||||
retry_index: 0,
|
candidate_id,
|
||||||
provider_id: Some(candidate.provider_id.clone()),
|
|
||||||
endpoint_id: Some(candidate.endpoint_id.clone()),
|
|
||||||
key_id: Some(candidate.key_id.clone()),
|
|
||||||
status: RequestCandidateStatus::Skipped,
|
|
||||||
skip_reason: Some(skip_reason.to_string()),
|
|
||||||
is_cached: Some(false),
|
|
||||||
status_code: None,
|
|
||||||
error_type: None,
|
|
||||||
error_message: None,
|
|
||||||
latency_ms: None,
|
|
||||||
concurrent_requests: None,
|
|
||||||
extra_data: None,
|
|
||||||
required_capabilities: candidate.key_capabilities.clone(),
|
|
||||||
created_at_unix_secs: None,
|
|
||||||
started_at_unix_secs: None,
|
|
||||||
finished_at_unix_secs: Some(terminal_unix_secs),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
trace_id = %trace_id,
|
|
||||||
candidate_id = %candidate_id,
|
|
||||||
skip_reason,
|
skip_reason,
|
||||||
error = ?err,
|
terminal_unix_secs,
|
||||||
"gateway local openai chat decision failed to persist skipped candidate"
|
"gateway local openai chat decision failed to persist skipped candidate",
|
||||||
);
|
)
|
||||||
}
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn materialize_local_openai_chat_candidate_attempts(
|
pub(super) async fn materialize_local_openai_chat_candidate_attempts(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
input: &LocalOpenAiChatDecisionInput,
|
input: &LocalOpenAiChatDecisionInput,
|
||||||
candidates: Vec<GatewayMinimalCandidateSelectionCandidate>,
|
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||||
) -> Vec<LocalOpenAiChatCandidateAttempt> {
|
) -> Vec<LocalOpenAiChatCandidateAttempt> {
|
||||||
let candidates = prefer_local_tunnel_owner_candidates(state, candidates).await;
|
let candidates = prefer_local_tunnel_owner_candidates(state, candidates).await;
|
||||||
let created_at_unix_secs = current_unix_secs();
|
let created_at_unix_secs = current_unix_secs();
|
||||||
@@ -727,46 +729,19 @@ pub(super) async fn materialize_local_openai_chat_candidate_attempts(
|
|||||||
candidate.endpoint_api_format.trim(),
|
candidate.endpoint_api_format.trim(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let candidate_id = match state
|
let candidate_id = persist_available_local_candidate(
|
||||||
.upsert_request_candidate(UpsertRequestCandidateRecord {
|
state,
|
||||||
id: generated_candidate_id.clone(),
|
trace_id,
|
||||||
request_id: trace_id.to_string(),
|
&input.auth_context.user_id,
|
||||||
user_id: Some(input.auth_context.user_id.clone()),
|
&input.auth_context.api_key_id,
|
||||||
api_key_id: Some(input.auth_context.api_key_id.clone()),
|
&candidate,
|
||||||
username: None,
|
candidate_index as u32,
|
||||||
api_key_name: None,
|
&generated_candidate_id,
|
||||||
candidate_index: candidate_index as u32,
|
Some(extra_data),
|
||||||
retry_index: 0,
|
created_at_unix_secs,
|
||||||
provider_id: Some(candidate.provider_id.clone()),
|
"gateway local openai chat decision request candidate upsert failed",
|
||||||
endpoint_id: Some(candidate.endpoint_id.clone()),
|
)
|
||||||
key_id: Some(candidate.key_id.clone()),
|
.await;
|
||||||
status: RequestCandidateStatus::Available,
|
|
||||||
skip_reason: None,
|
|
||||||
is_cached: Some(false),
|
|
||||||
status_code: None,
|
|
||||||
error_type: None,
|
|
||||||
error_message: None,
|
|
||||||
latency_ms: None,
|
|
||||||
concurrent_requests: None,
|
|
||||||
extra_data: Some(extra_data),
|
|
||||||
required_capabilities: candidate.key_capabilities.clone(),
|
|
||||||
created_at_unix_secs: Some(created_at_unix_secs),
|
|
||||||
started_at_unix_secs: None,
|
|
||||||
finished_at_unix_secs: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Some(stored)) => stored.id,
|
|
||||||
Ok(None) => generated_candidate_id.clone(),
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
trace_id = %trace_id,
|
|
||||||
error = ?err,
|
|
||||||
"gateway local openai chat decision request candidate upsert failed"
|
|
||||||
);
|
|
||||||
generated_candidate_id.clone()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
attempts.push(LocalOpenAiChatCandidateAttempt {
|
attempts.push(LocalOpenAiChatCandidateAttempt {
|
||||||
candidate,
|
candidate,
|
||||||
@@ -782,22 +757,14 @@ pub(super) async fn mark_unused_local_openai_chat_candidates<T>(state: &AppState
|
|||||||
where
|
where
|
||||||
T: LocalOpenAiChatPlanAndReport,
|
T: LocalOpenAiChatPlanAndReport,
|
||||||
{
|
{
|
||||||
for plan_and_report in remaining {
|
mark_unused_local_candidate_items(
|
||||||
record_local_request_candidate_status(
|
|
||||||
state,
|
state,
|
||||||
plan_and_report.plan(),
|
remaining,
|
||||||
plan_and_report.report_context(),
|
|item| item.plan(),
|
||||||
RequestCandidateStatus::Unused,
|
|item| item.report_context(),
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) trait LocalOpenAiChatPlanAndReport {
|
pub(super) trait LocalOpenAiChatPlanAndReport {
|
||||||
fn plan(&self) -> &aether_contracts::ExecutionPlan;
|
fn plan(&self) -> &aether_contracts::ExecutionPlan;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use serde_json::{Map, Value};
|
use serde_json::Value;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::ai_pipeline::control_facade::GatewayControlDecision;
|
||||||
use crate::ai_pipeline::planner::common::{
|
use crate::ai_pipeline::planner::common::{
|
||||||
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
|
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
|
||||||
};
|
};
|
||||||
use crate::control::GatewayControlDecision;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AppState, GatewayControlSyncDecisionResponse, GatewayError, LocalExecutionRuntimeMissDiagnostic,
|
AppState, GatewayControlSyncDecisionResponse, GatewayError, LocalExecutionRuntimeMissDiagnostic,
|
||||||
};
|
};
|
||||||
@@ -30,10 +30,7 @@ pub(crate) async fn build_local_openai_chat_sync_plan_and_reports_for_kind(
|
|||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
body_json: &serde_json::Value,
|
body_json: &serde_json::Value,
|
||||||
plan_kind: &str,
|
plan_kind: &str,
|
||||||
) -> Result<
|
) -> Result<Vec<crate::ai_pipeline::planner::plan_builders::LocalSyncPlanAndReport>, GatewayError> {
|
||||||
Vec<crate::ai_pipeline::planner::plan_builders::LocalSyncPlanAndReport>,
|
|
||||||
GatewayError,
|
|
||||||
> {
|
|
||||||
build_local_openai_chat_sync_plan_and_reports(
|
build_local_openai_chat_sync_plan_and_reports(
|
||||||
state, parts, trace_id, decision, body_json, plan_kind,
|
state, parts, trace_id, decision, body_json, plan_kind,
|
||||||
)
|
)
|
||||||
@@ -47,10 +44,8 @@ pub(crate) async fn build_local_openai_chat_stream_plan_and_reports_for_kind(
|
|||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
body_json: &serde_json::Value,
|
body_json: &serde_json::Value,
|
||||||
plan_kind: &str,
|
plan_kind: &str,
|
||||||
) -> Result<
|
) -> Result<Vec<crate::ai_pipeline::planner::plan_builders::LocalStreamPlanAndReport>, GatewayError>
|
||||||
Vec<crate::ai_pipeline::planner::plan_builders::LocalStreamPlanAndReport>,
|
{
|
||||||
GatewayError,
|
|
||||||
> {
|
|
||||||
build_local_openai_chat_stream_plan_and_reports(
|
build_local_openai_chat_stream_plan_and_reports(
|
||||||
state, parts, trace_id, decision, body_json, plan_kind,
|
state, parts, trace_id, decision, body_json, plan_kind,
|
||||||
)
|
)
|
||||||
@@ -205,76 +200,3 @@ pub(crate) async fn maybe_build_stream_local_decision_payload(
|
|||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn parse_openai_stop_sequences(stop: Option<&Value>) -> Option<Vec<Value>> {
|
|
||||||
match stop {
|
|
||||||
Some(Value::String(value)) if !value.trim().is_empty() => {
|
|
||||||
Some(vec![Value::String(value.clone())])
|
|
||||||
}
|
|
||||||
Some(Value::Array(values)) => Some(
|
|
||||||
values
|
|
||||||
.iter()
|
|
||||||
.filter_map(|value| value.as_str())
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(|value| Value::String(value.to_string()))
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
)
|
|
||||||
.filter(|values| !values.is_empty()),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn resolve_openai_chat_max_tokens(request: &Map<String, Value>) -> u64 {
|
|
||||||
request
|
|
||||||
.get("max_completion_tokens")
|
|
||||||
.and_then(value_as_u64)
|
|
||||||
.or_else(|| request.get("max_tokens").and_then(value_as_u64))
|
|
||||||
.unwrap_or(4096)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn value_as_u64(value: &Value) -> Option<u64> {
|
|
||||||
value
|
|
||||||
.as_u64()
|
|
||||||
.or_else(|| value.as_i64().and_then(|value| u64::try_from(value).ok()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn copy_request_number_field(
|
|
||||||
request: &Map<String, Value>,
|
|
||||||
target: &mut Map<String, Value>,
|
|
||||||
key: &str,
|
|
||||||
) {
|
|
||||||
copy_request_number_field_as(request, target, key, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn copy_request_number_field_as(
|
|
||||||
request: &Map<String, Value>,
|
|
||||||
target: &mut Map<String, Value>,
|
|
||||||
source_key: &str,
|
|
||||||
target_key: &str,
|
|
||||||
) {
|
|
||||||
if let Some(value) = request.get(source_key).cloned() {
|
|
||||||
if value.is_number() {
|
|
||||||
target.insert(target_key.to_string(), value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn map_openai_reasoning_effort_to_claude_output(value: &str) -> Option<&'static str> {
|
|
||||||
match value.trim().to_ascii_lowercase().as_str() {
|
|
||||||
"low" => Some("low"),
|
|
||||||
"medium" => Some("medium"),
|
|
||||||
"high" | "xhigh" => Some("high"),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn map_openai_reasoning_effort_to_gemini_budget(value: &str) -> Option<u64> {
|
|
||||||
match value.trim().to_ascii_lowercase().as_str() {
|
|
||||||
"low" => Some(1024),
|
|
||||||
"medium" => Some(4096),
|
|
||||||
"high" => Some(8192),
|
|
||||||
"xhigh" => Some(16_384),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -8,6 +9,9 @@ use super::{
|
|||||||
maybe_build_local_openai_chat_decision_payload_for_candidate, AppState, GatewayControlDecision,
|
maybe_build_local_openai_chat_decision_payload_for_candidate, AppState, GatewayControlDecision,
|
||||||
GatewayError, LocalExecutionRuntimeMissDiagnostic, LocalOpenAiChatDecisionInput,
|
GatewayError, LocalExecutionRuntimeMissDiagnostic, LocalOpenAiChatDecisionInput,
|
||||||
};
|
};
|
||||||
|
use crate::ai_pipeline::planner::auth_snapshot_facade::{
|
||||||
|
read_auth_api_key_snapshot, GatewayAuthApiKeySnapshot,
|
||||||
|
};
|
||||||
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
||||||
use crate::ai_pipeline::planner::common::{
|
use crate::ai_pipeline::planner::common::{
|
||||||
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
|
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
|
||||||
@@ -16,9 +20,7 @@ use crate::ai_pipeline::planner::plan_builders::{
|
|||||||
build_openai_chat_stream_plan_from_decision, build_openai_chat_sync_plan_from_decision,
|
build_openai_chat_stream_plan_from_decision, build_openai_chat_sync_plan_from_decision,
|
||||||
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
||||||
};
|
};
|
||||||
use crate::scheduler::{
|
use crate::ai_pipeline::planner::scheduler_facade::list_selectable_candidates;
|
||||||
list_selectable_candidates, GatewayMinimalCandidateSelectionCandidate,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub(super) fn build_local_openai_chat_miss_diagnostic(
|
pub(super) fn build_local_openai_chat_miss_diagnostic(
|
||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
@@ -292,7 +294,7 @@ pub(super) async fn list_local_openai_chat_candidates(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
input: &LocalOpenAiChatDecisionInput,
|
input: &LocalOpenAiChatDecisionInput,
|
||||||
require_streaming: bool,
|
require_streaming: bool,
|
||||||
) -> Result<Vec<GatewayMinimalCandidateSelectionCandidate>, GatewayError> {
|
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
|
||||||
let now_unix_secs = current_unix_secs();
|
let now_unix_secs = current_unix_secs();
|
||||||
let mut combined = Vec::new();
|
let mut combined = Vec::new();
|
||||||
let mut seen = BTreeSet::new();
|
let mut seen = BTreeSet::new();
|
||||||
@@ -352,9 +354,9 @@ pub(super) async fn list_local_openai_chat_candidates(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn auth_snapshot_allows_cross_format_openai_chat_candidate(
|
fn auth_snapshot_allows_cross_format_openai_chat_candidate(
|
||||||
auth_snapshot: &crate::data::auth::GatewayAuthApiKeySnapshot,
|
auth_snapshot: &GatewayAuthApiKeySnapshot,
|
||||||
requested_model: &str,
|
requested_model: &str,
|
||||||
candidate: &GatewayMinimalCandidateSelectionCandidate,
|
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if let Some(allowed_providers) = auth_snapshot.effective_allowed_providers() {
|
if let Some(allowed_providers) = auth_snapshot.effective_allowed_providers() {
|
||||||
let provider_allowed = allowed_providers.iter().any(|value| {
|
let provider_allowed = allowed_providers.iter().any(|value| {
|
||||||
@@ -438,8 +440,8 @@ pub(super) async fn resolve_local_openai_chat_decision_input(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let now_unix_secs = current_unix_secs();
|
let now_unix_secs = current_unix_secs();
|
||||||
let auth_snapshot = match state
|
let auth_snapshot = match read_auth_api_key_snapshot(
|
||||||
.read_auth_api_key_snapshot(
|
state,
|
||||||
&auth_context.user_id,
|
&auth_context.user_id,
|
||||||
&auth_context.api_key_id,
|
&auth_context.api_key_id,
|
||||||
now_unix_secs,
|
now_unix_secs,
|
||||||
|
|||||||
@@ -1,46 +1,57 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
|
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::ai_pipeline::control_facade::{
|
||||||
|
collect_control_headers, GatewayControlAuthContext, GatewayControlDecision,
|
||||||
|
};
|
||||||
use crate::ai_pipeline::conversion::{
|
use crate::ai_pipeline::conversion::{
|
||||||
request_conversion_direct_auth, request_conversion_kind, request_conversion_transport_supported,
|
request_conversion_direct_auth, request_conversion_kind, request_conversion_transport_supported,
|
||||||
};
|
};
|
||||||
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
use crate::ai_pipeline::execution_facade::{ConversionMode, ExecutionStrategy};
|
||||||
use crate::ai_pipeline::planner::common::{
|
use crate::ai_pipeline::planner::auth_snapshot_facade::{
|
||||||
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
|
read_auth_api_key_snapshot, GatewayAuthApiKeySnapshot,
|
||||||
};
|
};
|
||||||
|
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
||||||
|
use crate::ai_pipeline::planner::candidate_runtime_facade::{
|
||||||
|
persist_available_local_candidate, persist_skipped_local_candidate,
|
||||||
|
};
|
||||||
|
use crate::ai_pipeline::planner::common::{
|
||||||
|
force_upstream_streaming_for_provider, EXECUTION_RUNTIME_STREAM_DECISION_ACTION,
|
||||||
|
EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
|
||||||
|
};
|
||||||
|
use crate::ai_pipeline::planner::executor_facade::mark_unused_local_candidate_items;
|
||||||
use crate::ai_pipeline::planner::plan_builders::{
|
use crate::ai_pipeline::planner::plan_builders::{
|
||||||
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
||||||
};
|
};
|
||||||
|
use crate::ai_pipeline::planner::scheduler_facade::list_selectable_candidates;
|
||||||
use crate::ai_pipeline::planner::standard::{
|
use crate::ai_pipeline::planner::standard::{
|
||||||
build_cross_format_openai_cli_request_body, build_cross_format_openai_cli_upstream_url,
|
apply_codex_openai_cli_special_headers, build_cross_format_openai_cli_request_body,
|
||||||
build_local_openai_cli_request_body, build_local_openai_cli_upstream_url,
|
build_cross_format_openai_cli_upstream_url, build_local_openai_cli_request_body,
|
||||||
|
build_local_openai_cli_upstream_url,
|
||||||
};
|
};
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::ai_pipeline::planner::transport_facade::{
|
||||||
use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
|
read_provider_transport_snapshot, resolve_local_oauth_request_auth,
|
||||||
use crate::headers::collect_control_headers;
|
LocalResolvedOAuthRequestAuth,
|
||||||
use crate::provider_transport::antigravity::{
|
};
|
||||||
|
use crate::ai_pipeline::provider_transport_facade::antigravity::{
|
||||||
build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
|
build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
|
||||||
classify_local_antigravity_request_support, AntigravityEnvelopeRequestType,
|
classify_local_antigravity_request_support, AntigravityEnvelopeRequestType,
|
||||||
AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport,
|
AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::auth::{
|
use crate::ai_pipeline::provider_transport_facade::auth::{
|
||||||
build_openai_passthrough_headers, ensure_upstream_auth_header, resolve_local_gemini_auth,
|
build_openai_passthrough_headers, ensure_upstream_auth_header, resolve_local_gemini_auth,
|
||||||
resolve_local_standard_auth,
|
resolve_local_standard_auth,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::policy::supports_local_standard_transport_with_network;
|
use crate::ai_pipeline::provider_transport_facade::policy::supports_local_standard_transport_with_network;
|
||||||
use crate::provider_transport::{
|
use crate::ai_pipeline::provider_transport_facade::{
|
||||||
apply_local_body_rules, apply_local_header_rules, resolve_transport_execution_timeouts,
|
apply_local_body_rules, apply_local_header_rules, resolve_transport_execution_timeouts,
|
||||||
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
|
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
|
||||||
LocalResolvedOAuthRequestAuth,
|
|
||||||
};
|
|
||||||
use crate::scheduler::{
|
|
||||||
current_unix_secs, list_selectable_candidates, record_local_request_candidate_status,
|
|
||||||
GatewayMinimalCandidateSelectionCandidate,
|
|
||||||
};
|
};
|
||||||
|
use crate::clock::current_unix_secs;
|
||||||
use crate::{
|
use crate::{
|
||||||
append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse,
|
append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse,
|
||||||
GatewayError,
|
GatewayError,
|
||||||
@@ -48,25 +59,18 @@ use crate::{
|
|||||||
|
|
||||||
const ANTIGRAVITY_ENVELOPE_NAME: &str = "antigravity:v1internal";
|
const ANTIGRAVITY_ENVELOPE_NAME: &str = "antigravity:v1internal";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
pub(super) use aether_ai_pipeline::planner::standard::openai_cli::LocalOpenAiCliSpec;
|
||||||
pub(super) struct LocalOpenAiCliSpec {
|
|
||||||
pub(super) api_format: &'static str,
|
|
||||||
pub(super) decision_kind: &'static str,
|
|
||||||
pub(super) report_kind: &'static str,
|
|
||||||
pub(super) compact: bool,
|
|
||||||
pub(super) require_streaming: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(super) struct LocalOpenAiCliDecisionInput {
|
pub(super) struct LocalOpenAiCliDecisionInput {
|
||||||
pub(super) auth_context: crate::control::GatewayControlAuthContext,
|
pub(super) auth_context: GatewayControlAuthContext,
|
||||||
pub(super) requested_model: String,
|
pub(super) requested_model: String,
|
||||||
pub(super) auth_snapshot: crate::data::auth::GatewayAuthApiKeySnapshot,
|
pub(super) auth_snapshot: GatewayAuthApiKeySnapshot,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(super) struct LocalOpenAiCliCandidateAttempt {
|
pub(super) struct LocalOpenAiCliCandidateAttempt {
|
||||||
pub(super) candidate: GatewayMinimalCandidateSelectionCandidate,
|
pub(super) candidate: SchedulerMinimalCandidateSelectionCandidate,
|
||||||
pub(super) candidate_index: u32,
|
pub(super) candidate_index: u32,
|
||||||
pub(super) candidate_id: String,
|
pub(super) candidate_id: String,
|
||||||
}
|
}
|
||||||
@@ -90,8 +94,8 @@ pub(super) async fn resolve_local_openai_cli_decision_input(
|
|||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.map(ToOwned::to_owned)?;
|
.map(ToOwned::to_owned)?;
|
||||||
|
|
||||||
let auth_snapshot = match state
|
let auth_snapshot = match read_auth_api_key_snapshot(
|
||||||
.read_auth_api_key_snapshot(
|
state,
|
||||||
&auth_context.user_id,
|
&auth_context.user_id,
|
||||||
&auth_context.api_key_id,
|
&auth_context.api_key_id,
|
||||||
current_unix_secs(),
|
current_unix_secs(),
|
||||||
@@ -201,47 +205,19 @@ pub(super) async fn materialize_local_openai_cli_candidate_attempts(
|
|||||||
candidate.endpoint_api_format.as_str(),
|
candidate.endpoint_api_format.as_str(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let candidate_id = match state
|
let candidate_id = persist_available_local_candidate(
|
||||||
.upsert_request_candidate(UpsertRequestCandidateRecord {
|
state,
|
||||||
id: generated_candidate_id.clone(),
|
trace_id,
|
||||||
request_id: trace_id.to_string(),
|
&input.auth_context.user_id,
|
||||||
user_id: Some(input.auth_context.user_id.clone()),
|
&input.auth_context.api_key_id,
|
||||||
api_key_id: Some(input.auth_context.api_key_id.clone()),
|
&candidate,
|
||||||
username: None,
|
candidate_index as u32,
|
||||||
api_key_name: None,
|
&generated_candidate_id,
|
||||||
candidate_index: candidate_index as u32,
|
Some(extra_data),
|
||||||
retry_index: 0,
|
created_at_unix_secs,
|
||||||
provider_id: Some(candidate.provider_id.clone()),
|
"gateway local openai cli decision request candidate upsert failed",
|
||||||
endpoint_id: Some(candidate.endpoint_id.clone()),
|
)
|
||||||
key_id: Some(candidate.key_id.clone()),
|
.await;
|
||||||
status: RequestCandidateStatus::Available,
|
|
||||||
skip_reason: None,
|
|
||||||
is_cached: Some(false),
|
|
||||||
status_code: None,
|
|
||||||
error_type: None,
|
|
||||||
error_message: None,
|
|
||||||
latency_ms: None,
|
|
||||||
concurrent_requests: None,
|
|
||||||
extra_data: Some(extra_data),
|
|
||||||
required_capabilities: candidate.key_capabilities.clone(),
|
|
||||||
created_at_unix_secs: Some(created_at_unix_secs),
|
|
||||||
started_at_unix_secs: None,
|
|
||||||
finished_at_unix_secs: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Some(stored)) => stored.id,
|
|
||||||
Ok(None) => generated_candidate_id.clone(),
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
trace_id = %trace_id,
|
|
||||||
api_format = spec.api_format,
|
|
||||||
error = ?err,
|
|
||||||
"gateway local openai cli decision request candidate upsert failed"
|
|
||||||
);
|
|
||||||
generated_candidate_id.clone()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
attempts.push(LocalOpenAiCliCandidateAttempt {
|
attempts.push(LocalOpenAiCliCandidateAttempt {
|
||||||
candidate,
|
candidate,
|
||||||
@@ -254,9 +230,9 @@ pub(super) async fn materialize_local_openai_cli_candidate_attempts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn auth_snapshot_allows_cross_format_openai_cli_candidate(
|
fn auth_snapshot_allows_cross_format_openai_cli_candidate(
|
||||||
auth_snapshot: &crate::data::auth::GatewayAuthApiKeySnapshot,
|
auth_snapshot: &GatewayAuthApiKeySnapshot,
|
||||||
requested_model: &str,
|
requested_model: &str,
|
||||||
candidate: &GatewayMinimalCandidateSelectionCandidate,
|
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if let Some(allowed_providers) = auth_snapshot.effective_allowed_providers() {
|
if let Some(allowed_providers) = auth_snapshot.effective_allowed_providers() {
|
||||||
let provider_allowed = allowed_providers.iter().any(|value| {
|
let provider_allowed = allowed_providers.iter().any(|value| {
|
||||||
@@ -300,8 +276,8 @@ pub(super) async fn maybe_build_local_openai_cli_decision_payload_for_candidate(
|
|||||||
} = attempt;
|
} = attempt;
|
||||||
let provider_api_format = candidate.endpoint_api_format.trim().to_ascii_lowercase();
|
let provider_api_format = candidate.endpoint_api_format.trim().to_ascii_lowercase();
|
||||||
|
|
||||||
let transport = match state
|
let transport = match read_provider_transport_snapshot(
|
||||||
.read_provider_transport_snapshot(
|
state,
|
||||||
&candidate.provider_id,
|
&candidate.provider_id,
|
||||||
&candidate.endpoint_id,
|
&candidate.endpoint_id,
|
||||||
&candidate.key_id,
|
&candidate.key_id,
|
||||||
@@ -385,7 +361,7 @@ pub(super) async fn maybe_build_local_openai_cli_decision_payload_for_candidate(
|
|||||||
conversion_kind.and_then(|kind| request_conversion_direct_auth(&transport, kind))
|
conversion_kind.and_then(|kind| request_conversion_direct_auth(&transport, kind))
|
||||||
};
|
};
|
||||||
let oauth_auth = if resolved_auth.is_none() {
|
let oauth_auth = if resolved_auth.is_none() {
|
||||||
match state.resolve_local_oauth_request_auth(&transport).await {
|
match resolve_local_oauth_request_auth(state, &transport).await {
|
||||||
Ok(Some(LocalResolvedOAuthRequestAuth::Header { name, value })) => Some((name, value)),
|
Ok(Some(LocalResolvedOAuthRequestAuth::Header { name, value })) => Some((name, value)),
|
||||||
Ok(Some(LocalResolvedOAuthRequestAuth::Kiro(_))) => None,
|
Ok(Some(LocalResolvedOAuthRequestAuth::Kiro(_))) => None,
|
||||||
Ok(None) => None,
|
Ok(None) => None,
|
||||||
@@ -434,21 +410,32 @@ pub(super) async fn maybe_build_local_openai_cli_decision_payload_for_candidate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let needs_bidirectional_conversion = !same_format && conversion_kind.is_some();
|
let needs_bidirectional_conversion = !same_format && conversion_kind.is_some();
|
||||||
|
let upstream_is_stream = spec.require_streaming
|
||||||
|
|| is_antigravity
|
||||||
|
|| force_upstream_streaming_for_provider(
|
||||||
|
transport.provider.provider_type.as_str(),
|
||||||
|
provider_api_format.as_str(),
|
||||||
|
);
|
||||||
let Some(base_provider_request_body) = (if needs_bidirectional_conversion {
|
let Some(base_provider_request_body) = (if needs_bidirectional_conversion {
|
||||||
build_cross_format_openai_cli_request_body(
|
build_cross_format_openai_cli_request_body(
|
||||||
body_json,
|
body_json,
|
||||||
&mapped_model,
|
&mapped_model,
|
||||||
spec.api_format,
|
spec.api_format,
|
||||||
provider_api_format.as_str(),
|
provider_api_format.as_str(),
|
||||||
spec.require_streaming,
|
upstream_is_stream,
|
||||||
|
transport.provider.provider_type.as_str(),
|
||||||
transport.endpoint.body_rules.as_ref(),
|
transport.endpoint.body_rules.as_ref(),
|
||||||
|
Some(input.auth_context.api_key_id.as_str()),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
build_local_openai_cli_request_body(
|
build_local_openai_cli_request_body(
|
||||||
body_json,
|
body_json,
|
||||||
&mapped_model,
|
&mapped_model,
|
||||||
spec.require_streaming,
|
upstream_is_stream,
|
||||||
|
transport.provider.provider_type.as_str(),
|
||||||
|
provider_api_format.as_str(),
|
||||||
transport.endpoint.body_rules.as_ref(),
|
transport.endpoint.body_rules.as_ref(),
|
||||||
|
Some(input.auth_context.api_key_id.as_str()),
|
||||||
)
|
)
|
||||||
}) else {
|
}) else {
|
||||||
mark_skipped_local_openai_cli_candidate(
|
mark_skipped_local_openai_cli_candidate(
|
||||||
@@ -513,7 +500,6 @@ pub(super) async fn maybe_build_local_openai_cli_decision_payload_for_candidate(
|
|||||||
} else {
|
} else {
|
||||||
base_provider_request_body
|
base_provider_request_body
|
||||||
};
|
};
|
||||||
let upstream_is_stream = spec.require_streaming || is_antigravity;
|
|
||||||
|
|
||||||
let Some(upstream_url) = (if needs_bidirectional_conversion {
|
let Some(upstream_url) = (if needs_bidirectional_conversion {
|
||||||
build_cross_format_openai_cli_upstream_url(
|
build_cross_format_openai_cli_upstream_url(
|
||||||
@@ -572,6 +558,15 @@ pub(super) async fn maybe_build_local_openai_cli_decision_payload_for_candidate(
|
|||||||
.await;
|
.await;
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
apply_codex_openai_cli_special_headers(
|
||||||
|
&mut provider_request_headers,
|
||||||
|
&provider_request_body,
|
||||||
|
&parts.headers,
|
||||||
|
transport.provider.provider_type.as_str(),
|
||||||
|
provider_api_format.as_str(),
|
||||||
|
Some(trace_id),
|
||||||
|
transport.key.decrypted_auth_config.as_deref(),
|
||||||
|
);
|
||||||
ensure_upstream_auth_header(&mut provider_request_headers, &auth_header, &auth_value);
|
ensure_upstream_auth_header(&mut provider_request_headers, &auth_header, &auth_value);
|
||||||
if upstream_is_stream {
|
if upstream_is_stream {
|
||||||
provider_request_headers
|
provider_request_headers
|
||||||
@@ -685,70 +680,38 @@ async fn mark_skipped_local_openai_cli_candidate(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
input: &LocalOpenAiCliDecisionInput,
|
input: &LocalOpenAiCliDecisionInput,
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
candidate: &GatewayMinimalCandidateSelectionCandidate,
|
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||||
candidate_index: u32,
|
candidate_index: u32,
|
||||||
candidate_id: &str,
|
candidate_id: &str,
|
||||||
skip_reason: &'static str,
|
skip_reason: &'static str,
|
||||||
) {
|
) {
|
||||||
if let Err(err) = state
|
persist_skipped_local_candidate(
|
||||||
.upsert_request_candidate(UpsertRequestCandidateRecord {
|
state,
|
||||||
id: candidate_id.to_string(),
|
trace_id,
|
||||||
request_id: trace_id.to_string(),
|
&input.auth_context.user_id,
|
||||||
user_id: Some(input.auth_context.user_id.clone()),
|
&input.auth_context.api_key_id,
|
||||||
api_key_id: Some(input.auth_context.api_key_id.clone()),
|
candidate,
|
||||||
username: None,
|
|
||||||
api_key_name: None,
|
|
||||||
candidate_index,
|
candidate_index,
|
||||||
retry_index: 0,
|
candidate_id,
|
||||||
provider_id: Some(candidate.provider_id.clone()),
|
|
||||||
endpoint_id: Some(candidate.endpoint_id.clone()),
|
|
||||||
key_id: Some(candidate.key_id.clone()),
|
|
||||||
status: RequestCandidateStatus::Skipped,
|
|
||||||
skip_reason: Some(skip_reason.to_string()),
|
|
||||||
is_cached: Some(false),
|
|
||||||
status_code: None,
|
|
||||||
error_type: None,
|
|
||||||
error_message: None,
|
|
||||||
latency_ms: None,
|
|
||||||
concurrent_requests: None,
|
|
||||||
extra_data: None,
|
|
||||||
required_capabilities: candidate.key_capabilities.clone(),
|
|
||||||
created_at_unix_secs: None,
|
|
||||||
started_at_unix_secs: None,
|
|
||||||
finished_at_unix_secs: Some(current_unix_secs()),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
trace_id = %trace_id,
|
|
||||||
candidate_id = %candidate_id,
|
|
||||||
skip_reason,
|
skip_reason,
|
||||||
error = ?err,
|
current_unix_secs(),
|
||||||
"gateway local openai cli decision failed to persist skipped candidate"
|
"gateway local openai cli decision failed to persist skipped candidate",
|
||||||
);
|
)
|
||||||
}
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn mark_unused_local_openai_cli_candidates<T>(state: &AppState, remaining: Vec<T>)
|
pub(super) async fn mark_unused_local_openai_cli_candidates<T>(state: &AppState, remaining: Vec<T>)
|
||||||
where
|
where
|
||||||
T: LocalOpenAiCliPlanAndReport,
|
T: LocalOpenAiCliPlanAndReport,
|
||||||
{
|
{
|
||||||
for plan_and_report in remaining {
|
mark_unused_local_candidate_items(
|
||||||
record_local_request_candidate_status(
|
|
||||||
state,
|
state,
|
||||||
plan_and_report.plan(),
|
remaining,
|
||||||
plan_and_report.report_context(),
|
|item| item.plan(),
|
||||||
RequestCandidateStatus::Unused,
|
|item| item.report_context(),
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) trait LocalOpenAiCliPlanAndReport {
|
pub(super) trait LocalOpenAiCliPlanAndReport {
|
||||||
fn plan(&self) -> &aether_contracts::ExecutionPlan;
|
fn plan(&self) -> &aether_contracts::ExecutionPlan;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
use crate::ai_pipeline::control_facade::GatewayControlDecision;
|
||||||
use crate::ai_pipeline::planner::plan_builders::{
|
use crate::ai_pipeline::planner::plan_builders::{
|
||||||
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
||||||
};
|
};
|
||||||
use crate::control::GatewayControlDecision;
|
|
||||||
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
||||||
|
|
||||||
mod decision;
|
mod decision;
|
||||||
|
|||||||
@@ -5,56 +5,15 @@ use super::decision::{
|
|||||||
maybe_build_local_openai_cli_decision_payload_for_candidate,
|
maybe_build_local_openai_cli_decision_payload_for_candidate,
|
||||||
resolve_local_openai_cli_decision_input, LocalOpenAiCliSpec,
|
resolve_local_openai_cli_decision_input, LocalOpenAiCliSpec,
|
||||||
};
|
};
|
||||||
use crate::ai_pipeline::planner::common::{
|
use crate::ai_pipeline::control_facade::GatewayControlDecision;
|
||||||
OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
|
|
||||||
OPENAI_COMPACT_SYNC_PLAN_KIND,
|
|
||||||
};
|
|
||||||
use crate::ai_pipeline::planner::plan_builders::{
|
use crate::ai_pipeline::planner::plan_builders::{
|
||||||
build_openai_cli_stream_plan_from_decision, build_openai_cli_sync_plan_from_decision,
|
build_openai_cli_stream_plan_from_decision, build_openai_cli_sync_plan_from_decision,
|
||||||
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
||||||
};
|
};
|
||||||
use crate::control::GatewayControlDecision;
|
|
||||||
use crate::{AppState, GatewayError};
|
use crate::{AppState, GatewayError};
|
||||||
|
pub(super) use aether_ai_pipeline::planner::standard::openai_cli::{
|
||||||
pub(super) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalOpenAiCliSpec> {
|
resolve_stream_spec, resolve_sync_spec,
|
||||||
match plan_kind {
|
};
|
||||||
OPENAI_CLI_SYNC_PLAN_KIND => Some(LocalOpenAiCliSpec {
|
|
||||||
api_format: "openai:cli",
|
|
||||||
decision_kind: OPENAI_CLI_SYNC_PLAN_KIND,
|
|
||||||
report_kind: "openai_cli_sync_success",
|
|
||||||
compact: false,
|
|
||||||
require_streaming: false,
|
|
||||||
}),
|
|
||||||
OPENAI_COMPACT_SYNC_PLAN_KIND => Some(LocalOpenAiCliSpec {
|
|
||||||
api_format: "openai:compact",
|
|
||||||
decision_kind: OPENAI_COMPACT_SYNC_PLAN_KIND,
|
|
||||||
report_kind: "openai_cli_sync_success",
|
|
||||||
compact: true,
|
|
||||||
require_streaming: false,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn resolve_stream_spec(plan_kind: &str) -> Option<LocalOpenAiCliSpec> {
|
|
||||||
match plan_kind {
|
|
||||||
OPENAI_CLI_STREAM_PLAN_KIND => Some(LocalOpenAiCliSpec {
|
|
||||||
api_format: "openai:cli",
|
|
||||||
decision_kind: OPENAI_CLI_STREAM_PLAN_KIND,
|
|
||||||
report_kind: "openai_cli_stream_success",
|
|
||||||
compact: false,
|
|
||||||
require_streaming: true,
|
|
||||||
}),
|
|
||||||
OPENAI_COMPACT_STREAM_PLAN_KIND => Some(LocalOpenAiCliSpec {
|
|
||||||
api_format: "openai:compact",
|
|
||||||
decision_kind: OPENAI_COMPACT_STREAM_PLAN_KIND,
|
|
||||||
report_kind: "openai_cli_stream_success",
|
|
||||||
compact: true,
|
|
||||||
require_streaming: true,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn build_local_sync_plan_and_reports(
|
pub(super) async fn build_local_sync_plan_and_reports(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
pub(crate) mod chat;
|
pub(crate) mod chat;
|
||||||
pub(crate) mod cli;
|
pub(crate) mod cli;
|
||||||
|
|
||||||
pub(crate) use chat::{
|
pub(crate) use aether_ai_pipeline::planner::openai::{
|
||||||
copy_request_number_field, copy_request_number_field_as,
|
copy_request_number_field, copy_request_number_field_as,
|
||||||
map_openai_reasoning_effort_to_claude_output, map_openai_reasoning_effort_to_gemini_budget,
|
map_openai_reasoning_effort_to_claude_output, map_openai_reasoning_effort_to_gemini_budget,
|
||||||
maybe_build_stream_local_decision_payload, maybe_build_sync_local_decision_payload,
|
|
||||||
parse_openai_stop_sequences, resolve_openai_chat_max_tokens, value_as_u64,
|
parse_openai_stop_sequences, resolve_openai_chat_max_tokens, value_as_u64,
|
||||||
};
|
};
|
||||||
|
pub(crate) use chat::{
|
||||||
|
maybe_build_stream_local_decision_payload, maybe_build_sync_local_decision_payload,
|
||||||
|
};
|
||||||
pub(crate) use cli::{
|
pub(crate) use cli::{
|
||||||
maybe_build_stream_local_openai_cli_decision_payload,
|
maybe_build_stream_local_openai_cli_decision_payload,
|
||||||
maybe_build_sync_local_openai_cli_decision_payload,
|
maybe_build_sync_local_openai_cli_decision_payload,
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ use super::{
|
|||||||
LocalSyncPlanAndReport,
|
LocalSyncPlanAndReport,
|
||||||
};
|
};
|
||||||
use crate::ai_pipeline::adaptation::surfaces::provider_adaptation_requires_eventstream_accept;
|
use crate::ai_pipeline::adaptation::surfaces::provider_adaptation_requires_eventstream_accept;
|
||||||
use crate::provider_transport::auth::{
|
use crate::ai_pipeline::provider_transport_facade::auth::{
|
||||||
build_openai_passthrough_headers, ensure_upstream_auth_header,
|
build_openai_passthrough_headers, ensure_upstream_auth_header,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::url::{build_openai_chat_url, build_openai_cli_url};
|
use crate::ai_pipeline::provider_transport_facade::url::{
|
||||||
|
build_openai_chat_url, build_openai_cli_url,
|
||||||
|
};
|
||||||
|
|
||||||
pub(crate) fn build_openai_chat_sync_plan_from_decision(
|
pub(crate) fn build_openai_chat_sync_plan_from_decision(
|
||||||
parts: &http::request::Parts,
|
parts: &http::request::Parts,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use aether_contracts::{ExecutionPlan, RequestBody};
|
|||||||
use super::{augment_sync_report_context, LocalStreamPlanAndReport, LocalSyncPlanAndReport};
|
use super::{augment_sync_report_context, LocalStreamPlanAndReport, LocalSyncPlanAndReport};
|
||||||
use crate::ai_pipeline::adaptation::surfaces::provider_adaptation_requires_eventstream_accept;
|
use crate::ai_pipeline::adaptation::surfaces::provider_adaptation_requires_eventstream_accept;
|
||||||
use crate::ai_pipeline::contracts::generic_decision_missing_exact_provider_request;
|
use crate::ai_pipeline::contracts::generic_decision_missing_exact_provider_request;
|
||||||
use crate::provider_transport::ensure_upstream_auth_header;
|
use crate::ai_pipeline::provider_transport_facade::ensure_upstream_auth_header;
|
||||||
use crate::{GatewayControlSyncDecisionResponse, GatewayError};
|
use crate::{GatewayControlSyncDecisionResponse, GatewayError};
|
||||||
|
|
||||||
pub(crate) fn build_standard_sync_plan_from_decision(
|
pub(crate) fn build_standard_sync_plan_from_decision(
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
pub(crate) use crate::ai_pipeline::provider_transport_facade::{
|
||||||
|
GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth,
|
||||||
|
};
|
||||||
|
use crate::{AppState, GatewayError};
|
||||||
|
|
||||||
|
pub(crate) async fn read_provider_transport_snapshot(
|
||||||
|
state: &AppState,
|
||||||
|
provider_id: &str,
|
||||||
|
endpoint_id: &str,
|
||||||
|
key_id: &str,
|
||||||
|
) -> Result<Option<GatewayProviderTransportSnapshot>, GatewayError> {
|
||||||
|
state
|
||||||
|
.read_provider_transport_snapshot(provider_id, endpoint_id, key_id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn resolve_local_oauth_request_auth(
|
||||||
|
state: &AppState,
|
||||||
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
|
) -> Result<Option<LocalResolvedOAuthRequestAuth>, GatewayError> {
|
||||||
|
state.resolve_local_oauth_request_auth(transport).await
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
pub(crate) mod antigravity {
|
||||||
|
pub(crate) use crate::provider_transport::antigravity::*;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) mod auth {
|
||||||
|
pub(crate) use crate::provider_transport::auth::*;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) mod claude_code {
|
||||||
|
pub(crate) use crate::provider_transport::claude_code::*;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) mod kiro {
|
||||||
|
pub(crate) use crate::provider_transport::kiro::*;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) mod oauth_refresh {
|
||||||
|
pub(crate) use crate::provider_transport::oauth_refresh::*;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) mod policy {
|
||||||
|
pub(crate) use crate::provider_transport::policy::*;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) mod provider_types {
|
||||||
|
pub(crate) use crate::provider_transport::provider_types::*;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) mod rules {
|
||||||
|
pub(crate) use crate::provider_transport::rules::*;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) mod snapshot {
|
||||||
|
pub(crate) use crate::provider_transport::snapshot::*;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) mod url {
|
||||||
|
pub(crate) use crate::provider_transport::url::*;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) mod vertex {
|
||||||
|
pub(crate) use crate::provider_transport::vertex::*;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) use crate::provider_transport::{
|
||||||
|
apply_local_body_rules, apply_local_header_rules, body_rules_handle_path,
|
||||||
|
build_passthrough_headers, ensure_upstream_auth_header, resolve_transport_execution_timeouts,
|
||||||
|
resolve_transport_proxy_snapshot, resolve_transport_proxy_snapshot_with_tunnel_affinity,
|
||||||
|
resolve_transport_tls_profile, should_skip_upstream_passthrough_header,
|
||||||
|
supports_local_gemini_transport_with_network,
|
||||||
|
supports_local_generic_oauth_request_auth_resolution,
|
||||||
|
supports_local_oauth_request_auth_resolution, GatewayProviderTransportSnapshot,
|
||||||
|
LocalResolvedOAuthRequestAuth,
|
||||||
|
};
|
||||||
@@ -2,7 +2,7 @@ use std::collections::BTreeMap;
|
|||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::provider_transport::snapshot::GatewayProviderTransportSnapshot;
|
use crate::ai_pipeline::provider_transport_facade::snapshot::GatewayProviderTransportSnapshot;
|
||||||
|
|
||||||
pub(crate) const ANTIGRAVITY_PROVIDER_TYPE: &str = "antigravity";
|
pub(crate) const ANTIGRAVITY_PROVIDER_TYPE: &str = "antigravity";
|
||||||
pub(crate) const ANTIGRAVITY_REQUEST_USER_AGENT: &str = "antigravity";
|
pub(crate) const ANTIGRAVITY_REQUEST_USER_AGENT: &str = "antigravity";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#![allow(dead_code, unused_imports)]
|
#![allow(dead_code, unused_imports)]
|
||||||
|
|
||||||
pub(crate) use crate::provider_transport::antigravity::{
|
pub(crate) use crate::ai_pipeline::provider_transport_facade::antigravity::{
|
||||||
build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
|
build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
|
||||||
build_antigravity_v1internal_url, classify_antigravity_safe_request_body,
|
build_antigravity_v1internal_url, classify_antigravity_safe_request_body,
|
||||||
classify_local_antigravity_request_support, AntigravityEnvelopeRequestType,
|
classify_local_antigravity_request_support, AntigravityEnvelopeRequestType,
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use super::auth::{
|
use super::auth::{
|
||||||
resolve_local_antigravity_request_auth, AntigravityRequestAuth, AntigravityRequestAuthSupport,
|
ANTIGRAVITY_PROVIDER_TYPE, AntigravityRequestAuth, AntigravityRequestAuthSupport,
|
||||||
AntigravityRequestAuthUnsupportedReason, ANTIGRAVITY_PROVIDER_TYPE,
|
AntigravityRequestAuthUnsupportedReason, resolve_local_antigravity_request_auth,
|
||||||
};
|
};
|
||||||
use super::request::{
|
use super::request::{
|
||||||
classify_antigravity_safe_request_body, AntigravityEnvelopeRequestType,
|
AntigravityEnvelopeRequestType, AntigravityRequestEnvelopeUnsupportedReason,
|
||||||
AntigravityRequestEnvelopeUnsupportedReason,
|
classify_antigravity_safe_request_body,
|
||||||
};
|
};
|
||||||
use crate::provider_transport::snapshot::GatewayProviderTransportSnapshot;
|
use crate::ai_pipeline::provider_transport_facade::snapshot::GatewayProviderTransportSnapshot;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) struct AntigravityRequestSideSpec {
|
pub(crate) struct AntigravityRequestSideSpec {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
pub(crate) use crate::provider_transport::auth::resolve_local_standard_auth;
|
pub(crate) use crate::ai_pipeline::provider_transport_facade::auth::resolve_local_standard_auth;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
pub(crate) use crate::provider_transport::policy::supports_local_standard_transport_with_network;
|
pub(crate) use crate::ai_pipeline::provider_transport_facade::policy::supports_local_standard_transport_with_network;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user