refactor: 拆分 gateway 单体为独立 crate,新增 systemd 部署方案

将 gateway 内部的 model-fetch、provider-transport、scheduler-core、
usage-runtime、video-tasks-core 模块提取为独立 crate;重构 gateway
内部模块结构(state/router/cache/data/query 等);移除大量遗留模块
文件;新增 systemd 二进制部署骨架及相关文档;更新前端 usage 相关
API 和组件。
This commit is contained in:
fawney19
2026-04-05 20:23:16 +08:00
parent cbc811f6ce
commit 763ff03a7b
777 changed files with 42659 additions and 21469 deletions

92
Cargo.lock generated
View File

@@ -23,9 +23,13 @@ dependencies = [
name = "aether-billing" name = "aether-billing"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"aether-data",
"aether-usage-runtime",
"async-trait",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio",
] ]
[[package]] [[package]]
@@ -69,6 +73,7 @@ dependencies = [
"redis", "redis",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"sqlx", "sqlx",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
@@ -86,7 +91,12 @@ dependencies = [
"aether-crypto", "aether-crypto",
"aether-data", "aether-data",
"aether-http", "aether-http",
"aether-model-fetch",
"aether-provider-transport",
"aether-runtime", "aether-runtime",
"aether-scheduler-core",
"aether-usage-runtime",
"aether-video-tasks-core",
"aether-wallet", "aether-wallet",
"async-stream", "async-stream",
"async-trait", "async-trait",
@@ -118,6 +128,7 @@ dependencies = [
"tower", "tower",
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber",
"url", "url",
"uuid", "uuid",
"webpki-roots 0.26.11", "webpki-roots 0.26.11",
@@ -131,6 +142,44 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "aether-model-fetch"
version = "0.1.0"
dependencies = [
"aether-contracts",
"aether-data",
"aether-provider-transport",
"aether-scheduler-core",
"async-trait",
"regex",
"serde_json",
"tokio",
"uuid",
]
[[package]]
name = "aether-provider-transport"
version = "0.1.0"
dependencies = [
"aether-contracts",
"aether-crypto",
"aether-data",
"aether-video-tasks-core",
"async-trait",
"axum",
"http",
"regex",
"reqwest",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.18",
"tokio",
"tracing",
"url",
"uuid",
]
[[package]] [[package]]
name = "aether-proxy" name = "aether-proxy"
version = "0.2.5" version = "0.2.5"
@@ -179,8 +228,11 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-stream", "async-stream",
"axum", "axum",
"chrono",
"futures-util", "futures-util",
"redis", "redis",
"serde_json",
"sha2",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
@@ -189,6 +241,19 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "aether-scheduler-core"
version = "0.1.0"
dependencies = [
"aether-contracts",
"aether-data",
"aether-wallet",
"regex",
"serde",
"serde_json",
"sha2",
]
[[package]] [[package]]
name = "aether-testkit" name = "aether-testkit"
version = "0.1.0" version = "0.1.0"
@@ -211,6 +276,33 @@ dependencies = [
"tokio-tungstenite 0.28.0", "tokio-tungstenite 0.28.0",
] ]
[[package]]
name = "aether-usage-runtime"
version = "0.1.0"
dependencies = [
"aether-contracts",
"aether-data",
"async-trait",
"base64",
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]]
name = "aether-video-tasks-core"
version = "0.1.0"
dependencies = [
"aether-contracts",
"aether-data",
"async-trait",
"serde",
"serde_json",
"url",
"uuid",
]
[[package]] [[package]]
name = "aether-wallet" name = "aether-wallet"
version = "0.1.0" version = "0.1.0"

View File

@@ -7,6 +7,11 @@ members = [
"crates/aether-crypto", "crates/aether-crypto",
"crates/aether-contracts", "crates/aether-contracts",
"crates/aether-data", "crates/aether-data",
"crates/aether-model-fetch",
"crates/aether-provider-transport",
"crates/aether-scheduler-core",
"crates/aether-usage-runtime",
"crates/aether-video-tasks-core",
"apps/aether-gateway", "apps/aether-gateway",
"crates/aether-http", "crates/aether-http",
"crates/aether-runtime", "crates/aether-runtime",
@@ -26,6 +31,11 @@ aether-wallet = { path = "crates/aether-wallet" }
aether-crypto = { path = "crates/aether-crypto" } aether-crypto = { path = "crates/aether-crypto" }
aether-contracts = { path = "crates/aether-contracts" } aether-contracts = { path = "crates/aether-contracts" }
aether-data = { path = "crates/aether-data" } aether-data = { path = "crates/aether-data" }
aether-model-fetch = { path = "crates/aether-model-fetch" }
aether-provider-transport = { path = "crates/aether-provider-transport" }
aether-scheduler-core = { path = "crates/aether-scheduler-core" }
aether-usage-runtime = { path = "crates/aether-usage-runtime" }
aether-video-tasks-core = { path = "crates/aether-video-tasks-core" }
aether-gateway = { path = "apps/aether-gateway" } aether-gateway = { path = "apps/aether-gateway" }
aether-http = { path = "crates/aether-http" } aether-http = { path = "crates/aether-http" }
aether-runtime = { path = "crates/aether-runtime" } aether-runtime = { path = "crates/aether-runtime" }

View File

@@ -68,6 +68,29 @@ git pull
./deploy.sh ./deploy.sh
``` ```
### Systemd二进制部署
如果你希望 `aether-gateway` 作为宿主机服务运行Docker 只保留 `Postgres` / `Redis`,可以使用仓库内置的 systemd 部署骨架:
```bash
# 1. 构建 release 二进制和前端
cargo build --release -p aether-gateway
(cd frontend && npm ci && npm run build)
# 2. 准备环境变量
sudo mkdir -p /etc/aether
sudo cp deploy/systemd/aether-gateway.env.example /etc/aether/aether-gateway.env
# 安装脚本会拒绝 change-me / change-this 这类示例占位值
# 3. 启动数据服务Docker only
docker compose --env-file /etc/aether/aether-gateway.env -f deploy/docker-compose.data.yml up -d
# 4. 安装并启动 aether-gateway systemd 服务
sudo deploy/systemd/install-systemd.sh --env-file /etc/aether/aether-gateway.env
```
详细步骤见 [docs/deploy/systemd.md](docs/deploy/systemd.md)
### 本地开发 ### 本地开发
```bash ```bash
@@ -110,28 +133,22 @@ Aether Proxy 是配套的正向代理节点,部署在海外 VPS 上,为墙
## 环境变量 ## 环境变量
### 必需配置 部署建议直接参考对应示例文件:
| 变量 | 说明 | - Docker Compose根目录 [`.env.example`](.env.example)
|------|------| - systemd 二进制部署:[deploy/systemd/aether-gateway.env.example](deploy/systemd/aether-gateway.env.example)
| `DB_PASSWORD` | PostgreSQL 数据库密码 |
| `REDIS_PASSWORD` | Redis 密码 |
| `JWT_SECRET_KEY` | JWT 签名密钥(使用 `generate_keys.py` 生成) |
| `ENCRYPTION_KEY` | API Key 加密密钥(更换后需重新配置 Provider Key |
| `ADMIN_EMAIL` | 初始管理员邮箱 |
| `ADMIN_USERNAME` | 初始管理员用户名 |
| `ADMIN_PASSWORD` | 初始管理员密码 |
### 可选配置 当前主链路真正要关注的是这组变量:
| 变量 | 默认值 | 说明 | - `DATABASE_URL` / `REDIS_URL``aether-gateway` 直接读取的共享后端连接串
|------|--------|------| - `JWT_SECRET_KEY` / `ENCRYPTION_KEY`:认证和敏感数据加密所需密钥
| `APP_PORT` | 8084 | 应用公开端口;本地开发开启 Rust frontdoor 时即 `aether-gateway` 监听端口 | - `PAYMENT_CALLBACK_SECRET`:支付回调公开入口的共享密钥;未配置时相关路由保持禁用
| `API_KEY_PREFIX` | sk | API Key 前缀 | - `AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=single-node|multi-node`
| `LOG_LEVEL` | INFO | 日志级别 (DEBUG/INFO/WARNING/ERROR) | - `AETHER_GATEWAY_NODE_ROLE=all|frontdoor|background`
| `GUNICORN_WORKERS` | 2 | Gunicorn 工作进程数 | - `RUST_LOG`Rust 日志过滤,而不是旧的 `LOG_LEVEL`
| `DB_PORT` | 5432 | PostgreSQL 端口 | - 如果使用仓库内置的数据栈 compose再额外配置 `DB_PASSWORD` / `REDIS_PASSWORD`
| `REDIS_PORT` | 6379 | Redis 端口 |
systemd 的 `.env` 必须保持简单 `KEY=VALUE` 形式,不要写 `export``${VAR}` 或命令替换。
## Q&A ## Q&A
@@ -150,37 +167,22 @@ Aether Proxy 是配套的正向代理节点,部署在海外 VPS 上,为墙
**有备份的情况(推荐):** **有备份的情况(推荐):**
```bash ```bash
# 1. 停止应用 # Docker Compose:
docker compose stop app # 1. 切回旧镜像 tag / digest
# 2. 恢复 Postgres 备份
# 3. 再启动 app
# 2. 恢复数据库(先清空再导入) # systemd:
docker compose exec -T postgres psql -U postgres -c "DROP DATABASE aether; CREATE DATABASE aether;" # 1. 把 /opt/aether/current 切回旧 release
gunzip < backup_xxx.sql.gz | docker compose exec -T postgres psql -U postgres -d aether # 2. systemctl restart aether-gateway
# 3. 如果升级包含数据库结构变更,再恢复 Postgres 备份
# 3. 拉取旧版本镜像并重启
# 方式一:使用具体版本 tag如果有发布版本号
# 将 docker-compose.yml 中 image 从 ghcr.io/fawney19/aether:latest 改为指定版本
# 方式二:使用之前记录的镜像 digest
# 将 image 改为 ghcr.io/fawney19/aether@sha256:xxxxx
docker compose up -d app
``` ```
> 可以在升级前通过 `docker inspect ghcr.io/fawney19/aether:latest --format '{{index .RepoDigests 0}}'` 记录当前镜像 digest方便回滚时使用。 > 可以在升级前通过 `docker inspect ghcr.io/fawney19/aether:latest --format '{{index .RepoDigests 0}}'` 记录当前镜像 digest方便回滚时使用。
**没有备份的情况:** **没有备份的情况:**
```bash 当前不应该再依赖旧的 `alembic downgrade` 路线。`aether-gateway` 启动时执行的是 Rust / `sqlx` 迁移;如果本次发布带来了不可逆的数据结构变化,没有备份就不能保证安全回滚。因此升级前强烈建议先备份 `Postgres`
# 1. 用当前容器回退数据库迁移(回退 1 步,按需调整数字)
docker compose exec app alembic downgrade -1
# 2. 查看回退后的版本确认正确
docker compose exec app alembic current
# 3. 切回旧镜像并重启(同上方式修改 docker-compose.yml 中的 image
docker compose up -d app
```
> 注意:没有备份的回滚依赖 alembic downgrade如果迁移涉及不可逆的数据变更如删除列可能无法完全恢复数据。因此强烈建议升级前备份。
--- ---

View File

@@ -13,7 +13,12 @@ aether-contracts.workspace = true
aether-crypto.workspace = true aether-crypto.workspace = true
aether-data.workspace = true aether-data.workspace = true
aether-http.workspace = true aether-http.workspace = true
aether-model-fetch.workspace = true
aether-provider-transport.workspace = true
aether-scheduler-core.workspace = true
aether-runtime.workspace = true aether-runtime.workspace = true
aether-usage-runtime.workspace = true
aether-video-tasks-core.workspace = true
aether-wallet.workspace = true aether-wallet.workspace = true
async-stream.workspace = true async-stream.workspace = true
async-trait.workspace = true async-trait.workspace = true
@@ -48,3 +53,6 @@ tracing.workspace = true
url.workspace = true url.workspace = true
uuid.workspace = true uuid.workspace = true
webpki-roots.workspace = true webpki-roots.workspace = true
[dev-dependencies]
tracing-subscriber.workspace = true

View File

@@ -64,13 +64,13 @@ enum FrameParseError {
Invalid(String), Invalid(String),
} }
#[path = "kiro_stream/stream/decoder.rs"] #[path = "stream/decoder.rs"]
mod decoder; mod decoder;
#[path = "kiro_stream/stream/state.rs"] #[path = "stream/state.rs"]
mod state; mod state;
#[path = "kiro_stream/stream/util.rs"] #[path = "stream/util.rs"]
mod util; mod util;
#[cfg(test)] #[cfg(test)]
#[path = "kiro_stream/stream/tests.rs"] #[path = "stream/tests.rs"]
mod tests; mod tests;

View File

@@ -1,7 +1,7 @@
use serde_json::{json, Value}; use serde_json::{json, Value};
use uuid::Uuid; use uuid::Uuid;
use crate::gateway::GatewayError; use crate::GatewayError;
use super::util::{ use super::util::{
encode_events, estimate_tokens, find_real_thinking_end_tag, encode_events, estimate_tokens, find_real_thinking_end_tag,

View File

@@ -1,6 +1,6 @@
use serde_json::Value; use serde_json::Value;
use crate::gateway::GatewayError; use crate::GatewayError;
use super::QUOTE_CHARS; use super::QUOTE_CHARS;

View File

@@ -1,8 +1,8 @@
pub(crate) mod kiro_stream; pub(crate) mod kiro;
pub(crate) mod private_envelope; pub(crate) mod private_envelope;
pub(crate) mod surfaces; pub(crate) mod surfaces;
pub(crate) use kiro_stream::KiroToClaudeCliStreamState; pub(crate) use kiro::KiroToClaudeCliStreamState;
pub(crate) use private_envelope::{ pub(crate) use private_envelope::{
maybe_build_provider_private_stream_normalizer, maybe_build_provider_private_stream_normalizer,
maybe_normalize_provider_private_sync_report_payload, maybe_normalize_provider_private_sync_report_payload,

View File

@@ -3,13 +3,15 @@ use std::collections::BTreeMap;
use base64::Engine as _; use base64::Engine as _;
use serde_json::Value; use serde_json::Value;
use crate::gateway::ai_pipeline::adaptation::surfaces::{ use crate::ai_pipeline::adaptation::surfaces::{
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_descriptor_for_envelope, provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_descriptor_for_envelope,
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::gateway::ai_pipeline::runtime::{KiroToClaudeCliStreamState, KIRO_ENVELOPE_NAME}; use crate::ai_pipeline::runtime::adapters::kiro::{
use crate::gateway::{GatewayError, GatewaySyncReportRequest}; KiroToClaudeCliStreamState, KIRO_ENVELOPE_NAME,
};
use crate::{usage::GatewaySyncReportRequest, GatewayError};
enum ProviderPrivateStreamNormalizeMode { enum ProviderPrivateStreamNormalizeMode {
EnvelopeUnwrap, EnvelopeUnwrap,

View File

@@ -1,4 +1,4 @@
use crate::gateway::ai_pipeline::runtime::adapters::{ use crate::ai_pipeline::runtime::adapters::{
antigravity::ANTIGRAVITY_PROVIDER_TYPE, antigravity::ANTIGRAVITY_PROVIDER_TYPE,
kiro::{KIRO_ENVELOPE_NAME, PROVIDER_TYPE as KIRO_PROVIDER_TYPE}, kiro::{KIRO_ENVELOPE_NAME, PROVIDER_TYPE as KIRO_PROVIDER_TYPE},
}; };
@@ -140,12 +140,10 @@ pub(crate) fn provider_adaptation_should_unwrap_stream_envelope(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,
provider_adaptation_anchor_api_format,
provider_adaptation_requires_eventstream_accept, provider_adaptation_requires_eventstream_accept,
provider_adaptation_should_unwrap_stream_envelope, provider_adaptation_should_unwrap_stream_envelope, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME, GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME,
KIRO_ENVELOPE_NAME,
}; };
#[test] #[test]

View File

@@ -4,8 +4,10 @@ use aether_contracts::{ExecutionPlan, ExecutionTimeouts, ProxySnapshot};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::warn; use tracing::warn;
use crate::gateway::headers::collect_control_headers; use crate::control::GatewayControlAuthContext;
use crate::gateway::{AppState, GatewayControlAuthContext, GatewayControlDecision, GatewayError}; use crate::control::GatewayControlDecision;
use crate::headers::collect_control_headers;
use crate::{AppState, GatewayError};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub(crate) struct GatewayControlPlanRequest { pub(crate) struct GatewayControlPlanRequest {
@@ -140,7 +142,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::gateway::resolve_execution_runtime_auth_context( let auth_context = crate::control::resolve_execution_runtime_auth_context(
state, state,
decision, decision,
&parts.headers, &parts.headers,

View File

@@ -1,8 +1,8 @@
use serde_json::{Map, Value}; use serde_json::{Map, Value};
#[cfg(test)] #[cfg(test)]
use crate::gateway::ai_pipeline::contracts::core_success_background_report_kind as contract_core_success_background_report_kind; use crate::ai_pipeline::contracts::core_success_background_report_kind as contract_core_success_background_report_kind;
use crate::gateway::ai_pipeline::contracts::{ use crate::ai_pipeline::contracts::{
core_error_background_report_kind as contract_core_error_background_report_kind, core_error_background_report_kind as contract_core_error_background_report_kind,
core_error_default_client_api_format as contract_core_error_default_client_api_format, core_error_default_client_api_format as contract_core_error_default_client_api_format,
}; };

View File

@@ -1,7 +1,11 @@
use crate::gateway::provider_transport::{ use crate::provider_transport::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,
supports_local_gemini_transport_with_network, supports_local_openai_chat_transport, };
supports_local_standard_transport_with_network, GatewayProviderTransportSnapshot, use crate::provider_transport::policy::{
supports_local_openai_chat_transport, supports_local_standard_transport_with_network,
};
use crate::provider_transport::{
supports_local_gemini_transport_with_network, GatewayProviderTransportSnapshot,
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]

View File

@@ -3,7 +3,7 @@ use uuid::Uuid;
use super::super::to_openai_chat::{extract_openai_text_content, parse_openai_tool_result_content}; use super::super::to_openai_chat::{extract_openai_text_content, parse_openai_tool_result_content};
use super::shared::parse_openai_tool_arguments; use super::shared::parse_openai_tool_arguments;
use crate::gateway::ai_pipeline::planner::standard::{ use crate::ai_pipeline::planner::standard::{
copy_request_number_field, map_openai_reasoning_effort_to_claude_output, copy_request_number_field, map_openai_reasoning_effort_to_claude_output,
parse_openai_stop_sequences, resolve_openai_chat_max_tokens, parse_openai_stop_sequences, resolve_openai_chat_max_tokens,
}; };

View File

@@ -5,7 +5,7 @@ use uuid::Uuid;
use super::super::to_openai_chat::{extract_openai_text_content, parse_openai_tool_result_content}; use super::super::to_openai_chat::{extract_openai_text_content, parse_openai_tool_result_content};
use super::shared::parse_openai_tool_arguments; use super::shared::parse_openai_tool_arguments;
use crate::gateway::ai_pipeline::planner::standard::{ use crate::ai_pipeline::planner::standard::{
copy_request_number_field_as, map_openai_reasoning_effort_to_gemini_budget, copy_request_number_field_as, map_openai_reasoning_effort_to_gemini_budget,
parse_openai_stop_sequences, value_as_u64, parse_openai_stop_sequences, value_as_u64,
}; };

View File

@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
use super::super::to_openai_chat::extract_openai_text_content; use super::super::to_openai_chat::extract_openai_text_content;
use crate::gateway::ai_pipeline::planner::standard::copy_request_number_field; use crate::ai_pipeline::planner::standard::copy_request_number_field;
pub(crate) fn convert_openai_chat_request_to_openai_cli_request( pub(crate) fn convert_openai_chat_request_to_openai_cli_request(
body_json: &Value, body_json: &Value,

View File

@@ -4,13 +4,14 @@ use axum::body::Body;
use axum::http::Response; use axum::http::Response;
use serde_json::Value; use serde_json::Value;
use crate::gateway::ai_pipeline::contracts::core_success_background_report_kind; pub(crate) use crate::ai_pipeline::adaptation::private_envelope::{
pub(crate) use crate::gateway::ai_pipeline::runtime::{
normalize_provider_private_response_value as unwrap_local_finalize_response_value, normalize_provider_private_response_value as unwrap_local_finalize_response_value,
provider_private_response_allows_sync_finalize as local_finalize_allows_envelope, provider_private_response_allows_sync_finalize as local_finalize_allows_envelope,
}; };
use crate::gateway::api::response::build_client_response_from_parts; use crate::ai_pipeline::contracts::core_success_background_report_kind;
use crate::gateway::{GatewayControlDecision, GatewayError, GatewaySyncReportRequest}; use crate::api::response::build_client_response_from_parts;
use crate::control::GatewayControlDecision;
use crate::{usage::GatewaySyncReportRequest, GatewayError};
pub(crate) struct LocalCoreSyncFinalizeOutcome { pub(crate) struct LocalCoreSyncFinalizeOutcome {
pub(crate) response: Response<Body>, pub(crate) response: Response<Body>,

View File

@@ -2,7 +2,8 @@ use axum::body::Body;
use axum::http::Response; use axum::http::Response;
use serde_json::Value; use serde_json::Value;
use crate::gateway::{GatewayControlDecision, GatewayError, GatewaySyncReportRequest}; use crate::control::GatewayControlDecision;
use crate::{usage::GatewaySyncReportRequest, GatewayError};
#[path = "stream_rewrite.rs"] #[path = "stream_rewrite.rs"]
pub(crate) mod stream; pub(crate) mod stream;

View File

@@ -1,30 +1,14 @@
use serde_json::Value; use serde_json::Value;
use crate::gateway::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::gateway::ai_pipeline::finalize::sse::{encode_done_sse, encode_json_sse}; use crate::ai_pipeline::finalize::standard::StreamingStandardConversionState;
use crate::gateway::ai_pipeline::finalize::standard::{ use crate::ai_pipeline::runtime::adapters::kiro::KiroToClaudeCliStreamState;
BufferedCliConversionStreamState, BufferedStandardConversionStreamState, use crate::GatewayError;
ClaudeToOpenAIChatStreamState, GeminiToOpenAIChatStreamState, OpenAICliToOpenAIChatStreamState,
};
use crate::gateway::ai_pipeline::runtime::KiroToClaudeCliStreamState;
use crate::gateway::GatewayError;
use super::sync::{
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
convert_claude_cli_response_to_openai_cli, convert_gemini_cli_response_to_openai_cli,
};
enum RewriteMode { enum RewriteMode {
EnvelopeUnwrap, EnvelopeUnwrap,
ClaudeToOpenAIChat(ClaudeToOpenAIChatStreamState), Standard(StreamingStandardConversionState),
GeminiToOpenAIChat(GeminiToOpenAIChatStreamState),
OpenAICliToOpenAIChat(OpenAICliToOpenAIChatStreamState),
ClaudeToOpenAICli(BufferedCliConversionStreamState),
GeminiToOpenAICli(BufferedCliConversionStreamState),
AntigravityGeminiToOpenAIChat(GeminiToOpenAIChatStreamState),
AntigravityGeminiToOpenAICli(BufferedCliConversionStreamState),
KiroToClaudeCli(KiroToClaudeCliStreamState), KiroToClaudeCli(KiroToClaudeCliStreamState),
StandardChat(BufferedStandardConversionStreamState),
StandardCli(BufferedStandardConversionStreamState),
} }
pub(crate) struct LocalStreamRewriter { pub(crate) struct LocalStreamRewriter {
@@ -67,48 +51,44 @@ pub(crate) fn maybe_build_local_stream_rewriter(
client_api_format.as_str(), client_api_format.as_str(),
) { ) {
("", "claude:chat", "openai:chat") => { ("", "claude:chat", "openai:chat") => {
RewriteMode::ClaudeToOpenAIChat(ClaudeToOpenAIChatStreamState::default()) RewriteMode::Standard(StreamingStandardConversionState::default())
} }
("", "gemini:chat", "openai:chat") => { ("", "gemini:chat", "openai:chat") => {
RewriteMode::GeminiToOpenAIChat(GeminiToOpenAIChatStreamState::default()) RewriteMode::Standard(StreamingStandardConversionState::default())
} }
("", "openai:cli", "openai:chat") | ("", "openai:compact", "openai:chat") => { ("", "openai:cli", "openai:chat") | ("", "openai:compact", "openai:chat") => {
RewriteMode::OpenAICliToOpenAIChat(OpenAICliToOpenAIChatStreamState::default()) RewriteMode::Standard(StreamingStandardConversionState::default())
} }
("", "claude:cli", "openai:cli") => { ("", "claude:cli", "openai:cli") => {
RewriteMode::ClaudeToOpenAICli(BufferedCliConversionStreamState::default()) RewriteMode::Standard(StreamingStandardConversionState::default())
} }
("", "claude:cli", "openai:compact") => { ("", "claude:cli", "openai:compact") => {
RewriteMode::ClaudeToOpenAICli(BufferedCliConversionStreamState::default()) RewriteMode::Standard(StreamingStandardConversionState::default())
} }
("", "gemini:cli", "openai:cli") => { ("", "gemini:cli", "openai:cli") => {
RewriteMode::GeminiToOpenAICli(BufferedCliConversionStreamState::default()) RewriteMode::Standard(StreamingStandardConversionState::default())
} }
("", "gemini:cli", "openai:compact") => { ("", "gemini:cli", "openai:compact") => {
RewriteMode::GeminiToOpenAICli(BufferedCliConversionStreamState::default()) RewriteMode::Standard(StreamingStandardConversionState::default())
} }
("antigravity:v1internal", "gemini:chat", "openai:chat") => { ("antigravity:v1internal", "gemini:chat", "openai:chat") => {
RewriteMode::AntigravityGeminiToOpenAIChat(GeminiToOpenAIChatStreamState::default()) RewriteMode::Standard(StreamingStandardConversionState::default())
} }
("antigravity:v1internal", "gemini:cli", "openai:cli") => { ("antigravity:v1internal", "gemini:cli", "openai:cli") => {
RewriteMode::AntigravityGeminiToOpenAICli( RewriteMode::Standard(StreamingStandardConversionState::default())
BufferedCliConversionStreamState::default(),
)
} }
("antigravity:v1internal", "gemini:cli", "openai:compact") => { ("antigravity:v1internal", "gemini:cli", "openai:compact") => {
RewriteMode::AntigravityGeminiToOpenAICli( RewriteMode::Standard(StreamingStandardConversionState::default())
BufferedCliConversionStreamState::default(),
)
} }
_ if is_standard_chat_client_api_format(client_api_format.as_str()) _ if is_standard_chat_client_api_format(client_api_format.as_str())
&& is_standard_provider_api_format(provider_api_format.as_str()) => && is_standard_provider_api_format(provider_api_format.as_str()) =>
{ {
RewriteMode::StandardChat(BufferedStandardConversionStreamState::default()) RewriteMode::Standard(StreamingStandardConversionState::default())
} }
_ if is_standard_cli_client_api_format(client_api_format.as_str()) _ if is_standard_cli_client_api_format(client_api_format.as_str())
&& is_standard_provider_api_format(provider_api_format.as_str()) => && is_standard_provider_api_format(provider_api_format.as_str()) =>
{ {
RewriteMode::StandardCli(BufferedStandardConversionStreamState::default()) RewriteMode::Standard(StreamingStandardConversionState::default())
} }
_ => return None, _ => return None,
} }
@@ -168,44 +148,8 @@ impl LocalStreamRewriter {
} }
if self.buffered.is_empty() { if self.buffered.is_empty() {
match &mut self.mode { match &mut self.mode {
RewriteMode::ClaudeToOpenAIChat(state) => return Ok(state.finish()), RewriteMode::Standard(state) => return state.finish(&self.report_context),
RewriteMode::GeminiToOpenAIChat(state) => {
return state.finish(&self.report_context);
}
RewriteMode::OpenAICliToOpenAIChat(state) => {
return state.finish(&self.report_context);
}
RewriteMode::ClaudeToOpenAICli(state) => {
return state.finish(
&self.report_context,
aggregate_claude_stream_sync_response,
convert_claude_cli_response_to_openai_cli,
);
}
RewriteMode::GeminiToOpenAICli(state) => {
return state.finish(
&self.report_context,
aggregate_gemini_stream_sync_response,
convert_gemini_cli_response_to_openai_cli,
);
}
RewriteMode::AntigravityGeminiToOpenAIChat(state) => {
return state.finish(&self.report_context);
}
RewriteMode::AntigravityGeminiToOpenAICli(state) => {
return state.finish(
&self.report_context,
aggregate_gemini_stream_sync_response,
convert_gemini_cli_response_to_openai_cli,
);
}
RewriteMode::KiroToClaudeCli(_) => {} RewriteMode::KiroToClaudeCli(_) => {}
RewriteMode::StandardChat(state) => {
return state.finish_as_chat(&self.report_context)
}
RewriteMode::StandardCli(state) => {
return state.finish_as_cli(&self.report_context)
}
RewriteMode::EnvelopeUnwrap => {} RewriteMode::EnvelopeUnwrap => {}
} }
return Ok(Vec::new()); return Ok(Vec::new());
@@ -213,46 +157,10 @@ impl LocalStreamRewriter {
let line = std::mem::take(&mut self.buffered); let line = std::mem::take(&mut self.buffered);
let mut output = self.transform_line(line)?; let mut output = self.transform_line(line)?;
match &mut self.mode { match &mut self.mode {
RewriteMode::ClaudeToOpenAIChat(state) => { RewriteMode::Standard(state) => {
output.extend(state.finish());
}
RewriteMode::GeminiToOpenAIChat(state) => {
output.extend(state.finish(&self.report_context)?); output.extend(state.finish(&self.report_context)?);
} }
RewriteMode::OpenAICliToOpenAIChat(state) => {
output.extend(state.finish(&self.report_context)?);
}
RewriteMode::ClaudeToOpenAICli(state) => {
output.extend(state.finish(
&self.report_context,
aggregate_claude_stream_sync_response,
convert_claude_cli_response_to_openai_cli,
)?);
}
RewriteMode::GeminiToOpenAICli(state) => {
output.extend(state.finish(
&self.report_context,
aggregate_gemini_stream_sync_response,
convert_gemini_cli_response_to_openai_cli,
)?);
}
RewriteMode::AntigravityGeminiToOpenAIChat(state) => {
output.extend(state.finish(&self.report_context)?);
}
RewriteMode::AntigravityGeminiToOpenAICli(state) => {
output.extend(state.finish(
&self.report_context,
aggregate_gemini_stream_sync_response,
convert_gemini_cli_response_to_openai_cli,
)?);
}
RewriteMode::KiroToClaudeCli(_) => {} RewriteMode::KiroToClaudeCli(_) => {}
RewriteMode::StandardChat(state) => {
output.extend(state.finish_as_chat(&self.report_context)?);
}
RewriteMode::StandardCli(state) => {
output.extend(state.finish_as_cli(&self.report_context)?);
}
RewriteMode::EnvelopeUnwrap => {} RewriteMode::EnvelopeUnwrap => {}
} }
Ok(output) Ok(output)
@@ -261,37 +169,7 @@ impl LocalStreamRewriter {
fn transform_line(&mut self, line: Vec<u8>) -> Result<Vec<u8>, GatewayError> { fn transform_line(&mut self, line: Vec<u8>) -> Result<Vec<u8>, GatewayError> {
match &mut self.mode { match &mut self.mode {
RewriteMode::EnvelopeUnwrap => transform_envelope_line(&self.report_context, line), RewriteMode::EnvelopeUnwrap => transform_envelope_line(&self.report_context, line),
RewriteMode::ClaudeToOpenAIChat(state) => { RewriteMode::Standard(state) => state.transform_line(&self.report_context, line),
state.transform_line(&self.report_context, line)
}
RewriteMode::GeminiToOpenAIChat(state) => {
state.transform_line(&self.report_context, line)
}
RewriteMode::OpenAICliToOpenAIChat(state) => {
state.transform_line(&self.report_context, line)
}
RewriteMode::ClaudeToOpenAICli(state) | RewriteMode::GeminiToOpenAICli(state) => {
state.transform_line(line)
}
RewriteMode::AntigravityGeminiToOpenAIChat(state) => {
let unwrapped = transform_envelope_line(&self.report_context, line)?;
if unwrapped.is_empty() {
Ok(Vec::new())
} else {
state.transform_line(&self.report_context, unwrapped)
}
}
RewriteMode::AntigravityGeminiToOpenAICli(state) => {
let unwrapped = transform_envelope_line(&self.report_context, line)?;
if unwrapped.is_empty() {
Ok(Vec::new())
} else {
state.transform_line(unwrapped)
}
}
RewriteMode::StandardChat(state) | RewriteMode::StandardCli(state) => {
state.transform_line(&self.report_context, line)
}
RewriteMode::KiroToClaudeCli(_) => Ok(Vec::new()), RewriteMode::KiroToClaudeCli(_) => Ok(Vec::new()),
} }
} }

View File

@@ -5,19 +5,20 @@ use axum::http::Response;
use base64::Engine as _; use base64::Engine as _;
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
use crate::gateway::ai_pipeline::conversion::{ use crate::ai_pipeline::conversion::{
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind, sync_chat_response_conversion_kind, sync_cli_response_conversion_kind,
}; };
use crate::gateway::api::response::build_client_response_from_parts; use crate::api::response::build_client_response_from_parts;
use crate::gateway::{GatewayControlDecision, GatewayError, GatewaySyncReportRequest}; use crate::control::GatewayControlDecision;
use crate::{usage::GatewaySyncReportRequest, GatewayError};
pub(crate) use crate::gateway::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, parse_stream_json_events, unwrap_local_finalize_response_value,
LocalCoreSyncFinalizeOutcome, LocalCoreSyncFinalizeOutcome,
}; };
pub(crate) use crate::gateway::ai_pipeline::finalize::standard::{ pub(crate) use crate::ai_pipeline::finalize::standard::{
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, aggregate_standard_chat_stream_sync_response, aggregate_standard_cli_stream_sync_response,
@@ -45,7 +46,7 @@ pub(crate) fn maybe_build_local_core_sync_finalize_response(
payload: &GatewaySyncReportRequest, payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> { ) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
let Some(normalized_payload) = let Some(normalized_payload) =
crate::gateway::ai_pipeline::adaptation::private_envelope::maybe_normalize_provider_private_sync_report_payload(payload)? crate::ai_pipeline::adaptation::private_envelope::maybe_normalize_provider_private_sync_report_payload(payload)?
else { else {
return Ok(None); return Ok(None);
}; };

View File

@@ -1,10 +1,10 @@
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::gateway::api::response::{ pub(crate) use crate::api::response::{
build_client_response, build_client_response_from_parts, build_client_response, build_client_response_from_parts,
}; };
pub(crate) use crate::gateway::execution_runtime::maybe_build_local_sync_finalize_response; 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,

View File

@@ -1,6 +1,6 @@
use serde_json::Value; use serde_json::Value;
use crate::gateway::GatewayError; use crate::GatewayError;
pub(crate) fn map_claude_stop_reason( pub(crate) fn map_claude_stop_reason(
stop_reason: Option<&str>, stop_reason: Option<&str>,

View File

@@ -1,16 +0,0 @@
use serde_json::Value;
use crate::gateway::ai_pipeline::finalize::common::LocalCoreSyncFinalizeOutcome;
use crate::gateway::{GatewayControlDecision, GatewayError, GatewaySyncReportRequest};
pub(crate) fn aggregate_claude_stream_sync_response(body: &[u8]) -> Option<Value> {
super::claude_chat::aggregate_claude_stream_sync_response(body)
}
pub(crate) fn maybe_build_local_claude_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
super::claude_chat::maybe_build_local_claude_stream_sync_response(trace_id, decision, payload)
}

View File

@@ -1,12 +0,0 @@
use crate::gateway::ai_pipeline::finalize::common::LocalCoreSyncFinalizeOutcome;
use crate::gateway::{GatewayControlDecision, GatewayError, GatewaySyncReportRequest};
pub(crate) fn maybe_build_local_claude_cli_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
super::claude_cli::maybe_build_local_claude_cli_stream_sync_response(
trace_id, decision, payload,
)
}

View File

@@ -0,0 +1,9 @@
pub(super) mod stream;
pub(super) mod sync;
pub(crate) use sync::{
aggregate_claude_stream_sync_response, convert_claude_chat_response_to_openai_chat,
convert_claude_cli_response_to_openai_cli, 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,
};

View File

@@ -0,0 +1,531 @@
use std::collections::BTreeMap;
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,
},
})
}
}

View File

@@ -3,12 +3,13 @@ use std::collections::BTreeMap;
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
use crate::gateway::ai_pipeline::finalize::common::{ use crate::ai_pipeline::finalize::common::{
build_generated_tool_call_id, build_local_success_outcome, canonicalize_tool_arguments, 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, local_finalize_allows_envelope, parse_stream_json_events, unwrap_local_finalize_response_value,
LocalCoreSyncFinalizeOutcome, LocalCoreSyncFinalizeOutcome,
}; };
use crate::gateway::{GatewayControlDecision, GatewayError, GatewaySyncReportRequest}; use crate::control::GatewayControlDecision;
use crate::{usage::GatewaySyncReportRequest, GatewayError};
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct ClaudeContentBlockState { struct ClaudeContentBlockState {

View File

@@ -1,15 +1,16 @@
use base64::Engine as _; use base64::Engine as _;
use super::aggregate_claude_stream_sync_response; use super::chat::aggregate_claude_stream_sync_response;
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::gateway::ai_pipeline::finalize::common::{ 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, build_generated_tool_call_id, build_local_success_outcome, canonicalize_tool_arguments,
local_finalize_allows_envelope, unwrap_local_finalize_response_value, local_finalize_allows_envelope, unwrap_local_finalize_response_value,
LocalCoreSyncFinalizeOutcome, LocalCoreSyncFinalizeOutcome,
}; };
use crate::gateway::ai_pipeline::finalize::standard::build_openai_cli_response; use crate::control::GatewayControlDecision;
use crate::gateway::{GatewayControlDecision, GatewayError, GatewaySyncReportRequest}; use crate::{usage::GatewaySyncReportRequest, GatewayError};
pub(crate) fn maybe_build_local_claude_cli_stream_sync_response( pub(crate) fn maybe_build_local_claude_cli_stream_sync_response(
trace_id: &str, trace_id: &str,

View File

@@ -0,0 +1,11 @@
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,
};

View File

@@ -1,16 +0,0 @@
use serde_json::Value;
use crate::gateway::ai_pipeline::finalize::common::LocalCoreSyncFinalizeOutcome;
use crate::gateway::{GatewayControlDecision, GatewayError, GatewaySyncReportRequest};
pub(crate) fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> {
super::gemini_chat::aggregate_gemini_stream_sync_response(body)
}
pub(crate) fn maybe_build_local_gemini_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
super::gemini_chat::maybe_build_local_gemini_stream_sync_response(trace_id, decision, payload)
}

View File

@@ -1,12 +0,0 @@
use crate::gateway::ai_pipeline::finalize::common::LocalCoreSyncFinalizeOutcome;
use crate::gateway::{GatewayControlDecision, GatewayError, GatewaySyncReportRequest};
pub(crate) fn maybe_build_local_gemini_cli_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
super::gemini_cli::maybe_build_local_gemini_cli_stream_sync_response(
trace_id, decision, payload,
)
}

View File

@@ -0,0 +1,9 @@
pub(super) mod stream;
pub(super) mod sync;
pub(crate) use sync::{
aggregate_gemini_stream_sync_response, convert_gemini_chat_response_to_openai_chat,
convert_gemini_cli_response_to_openai_cli, 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,
};

View File

@@ -0,0 +1,404 @@
use std::collections::BTreeMap;
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)
}
}

View File

@@ -3,12 +3,13 @@ use std::collections::BTreeMap;
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
use crate::gateway::ai_pipeline::finalize::common::{ use crate::ai_pipeline::finalize::common::{
build_generated_tool_call_id, build_local_success_outcome, canonicalize_tool_arguments, 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, local_finalize_allows_envelope, parse_stream_json_events, unwrap_local_finalize_response_value,
LocalCoreSyncFinalizeOutcome, LocalCoreSyncFinalizeOutcome,
}; };
use crate::gateway::{GatewayControlDecision, GatewayError, GatewaySyncReportRequest}; use crate::control::GatewayControlDecision;
use crate::{usage::GatewaySyncReportRequest, GatewayError};
pub(crate) fn maybe_build_local_gemini_stream_sync_response( pub(crate) fn maybe_build_local_gemini_stream_sync_response(
trace_id: &str, trace_id: &str,

View File

@@ -1,15 +1,16 @@
use base64::Engine as _; use base64::Engine as _;
use super::aggregate_gemini_stream_sync_response; use super::chat::aggregate_gemini_stream_sync_response;
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::gateway::ai_pipeline::finalize::common::{ 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, build_generated_tool_call_id, build_local_success_outcome, canonicalize_tool_arguments,
local_finalize_allows_envelope, unwrap_local_finalize_response_value, local_finalize_allows_envelope, unwrap_local_finalize_response_value,
LocalCoreSyncFinalizeOutcome, LocalCoreSyncFinalizeOutcome,
}; };
use crate::gateway::ai_pipeline::finalize::standard::build_openai_cli_response; use crate::control::GatewayControlDecision;
use crate::gateway::{GatewayControlDecision, GatewayError, GatewaySyncReportRequest}; use crate::{usage::GatewaySyncReportRequest, GatewayError};
pub(crate) fn maybe_build_local_gemini_cli_stream_sync_response( pub(crate) fn maybe_build_local_gemini_cli_stream_sync_response(
trace_id: &str, trace_id: &str,

View File

@@ -0,0 +1,11 @@
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,
};

View File

@@ -1,75 +1,20 @@
//! Standard finalize surface for standard contract sync/stream compilation. //! Standard finalize surface for standard contract sync/stream compilation.
use std::collections::BTreeMap;
use serde_json::Value; use serde_json::Value;
use serde_json::{json, Map};
use crate::gateway::{GatewayControlDecision, GatewayError, GatewaySyncReportRequest}; mod claude;
mod gemini;
mod openai;
#[path = "stream_core/mod.rs"]
mod stream;
#[path = "claude/chat.rs"] pub(crate) use crate::ai_pipeline::conversion::response::{
mod claude_chat; build_openai_cli_response, convert_openai_chat_response_to_openai_cli,
#[path = "claude/chat_stream.rs"]
mod claude_chat_stream;
#[path = "claude/cli.rs"]
mod claude_cli;
#[path = "claude/cli_stream.rs"]
mod claude_cli_stream;
#[path = "gemini/chat.rs"]
mod gemini_chat;
#[path = "gemini/chat_stream.rs"]
mod gemini_chat_stream;
#[path = "gemini/cli.rs"]
mod gemini_cli;
#[path = "gemini/cli_stream.rs"]
mod gemini_cli_stream;
#[path = "openai/chat.rs"]
mod openai_chat;
#[path = "openai/chat_stream.rs"]
mod openai_chat_stream;
#[path = "openai/cli.rs"]
mod openai_cli;
#[path = "openai/cli_stream.rs"]
mod openai_cli_stream;
#[path = "stream_common.rs"]
mod stream_common;
pub(crate) use crate::gateway::ai_pipeline::conversion::response::{
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,
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,
}; };
pub(crate) use claude_chat::maybe_build_local_claude_sync_response; pub(crate) use claude::*;
pub(crate) use claude_chat_stream::{ pub(crate) use gemini::*;
aggregate_claude_stream_sync_response, maybe_build_local_claude_stream_sync_response, pub(crate) use openai::*;
}; pub(crate) use stream::*;
pub(crate) use claude_cli_stream::maybe_build_local_claude_cli_stream_sync_response;
pub(crate) use gemini_chat::maybe_build_local_gemini_sync_response;
pub(crate) use gemini_chat_stream::{
aggregate_gemini_stream_sync_response, maybe_build_local_gemini_stream_sync_response,
};
pub(crate) use gemini_cli_stream::maybe_build_local_gemini_cli_stream_sync_response;
pub(crate) use openai_chat::{
aggregate_openai_chat_stream_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,
};
pub(crate) use openai_chat_stream::{
ClaudeToOpenAIChatStreamState, GeminiToOpenAIChatStreamState, OpenAICliToOpenAIChatStreamState,
};
pub(crate) use openai_cli::{
aggregate_openai_cli_stream_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) use openai_cli_stream::BufferedCliConversionStreamState;
pub(crate) use stream_common::BufferedStandardConversionStreamState;
pub(crate) fn aggregate_standard_chat_stream_sync_response( pub(crate) fn aggregate_standard_chat_stream_sync_response(
body: &[u8], body: &[u8],

View File

@@ -1,499 +0,0 @@
use serde_json::{json, Map, Value};
use crate::gateway::ai_pipeline::finalize::sse::{
encode_done_sse, encode_json_sse, map_claude_stop_reason,
};
use crate::gateway::GatewayError;
use super::{
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
convert_openai_cli_response_to_openai_chat,
};
#[derive(Default)]
pub(crate) struct ClaudeToOpenAIChatStreamState {
raw: Vec<u8>,
message_id: Option<String>,
model: Option<String>,
}
#[derive(Default)]
pub(crate) struct GeminiToOpenAIChatStreamState {
raw: Vec<u8>,
}
#[derive(Default)]
pub(crate) struct OpenAICliToOpenAIChatStreamState {
raw: Vec<u8>,
}
#[derive(Default)]
struct ClaudeToolCallState {
id: String,
name: String,
arguments: String,
}
fn canonicalize_arguments(value: Option<Value>) -> String {
match value {
Some(Value::String(text)) => text,
Some(other) => serde_json::to_string(&other).unwrap_or_else(|_| "null".to_string()),
None => "{}".to_string(),
}
}
fn claude_tool_calls(content: &[Value]) -> Option<Vec<Value>> {
let mut tool_calls = Vec::new();
for (index, block) in content.iter().enumerate() {
let Some(block) = block.as_object() else {
continue;
};
if block.get("type").and_then(Value::as_str).unwrap_or("text") != "tool_use" {
continue;
}
let state = ClaudeToolCallState {
id: block
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("tool_call")
.to_string(),
name: block
.get("name")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
arguments: canonicalize_arguments(block.get("input").cloned()),
};
tool_calls.push(json!({
"index": index,
"id": state.id,
"type": "function",
"function": {
"name": state.name,
"arguments": state.arguments,
}
}));
}
if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
}
}
fn gemini_tool_calls(parts: &[Value]) -> Option<Vec<Value>> {
let mut tool_calls = Vec::new();
for (index, part) in parts.iter().enumerate() {
let Some(part) = part.as_object() else {
continue;
};
let Some(function_call) = part.get("functionCall").and_then(Value::as_object) else {
continue;
};
let name = function_call
.get("name")
.and_then(Value::as_str)
.unwrap_or("unknown");
let id = function_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("call_{name}_{index}"));
tool_calls.push(json!({
"index": index,
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": canonicalize_arguments(function_call.get("args").cloned()),
}
}));
}
if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
}
}
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,
}]
})
}
fn claude_identity<'a>(
state: &'a ClaudeToOpenAIChatStreamState,
report_context: &'a Value,
) -> (&'a str, &'a str) {
let id = state
.message_id
.as_deref()
.unwrap_or("chatcmpl-local-stream");
let model = state
.model
.as_deref()
.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");
(id, model)
}
fn convert_claude_aggregated_to_openai_chunk(
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();
for block in content {
let block = block.as_object()?;
if block.get("type").and_then(Value::as_str).unwrap_or("text") == "text" {
if let Some(piece) = block.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
}
let tool_calls = claude_tool_calls(content);
let finish_reason = map_claude_stop_reason(
body.get("stop_reason").and_then(Value::as_str),
tool_calls.is_some(),
);
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-stream");
Some(build_openai_chat_chunk(
id,
model,
text,
tool_calls,
finish_reason,
))
}
fn convert_gemini_aggregated_to_openai_chunk(
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();
for part in parts {
let part = part.as_object()?;
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
let tool_calls = gemini_tool_calls(parts);
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"),
_ => None,
};
if tool_calls.is_some() && finish_reason.is_none_or(|value| value == "stop") {
finish_reason = Some("tool_calls");
}
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)
.or_else(|| body.get("_v1internal_response_id").and_then(Value::as_str))
.unwrap_or("chatcmpl-local-stream");
Some(build_openai_chat_chunk(
id,
model,
text,
tool_calls,
finish_reason,
))
}
impl ClaudeToOpenAIChatStreamState {
pub(crate) fn transform_line(
&mut self,
report_context: &Value,
line: Vec<u8>,
) -> Result<Vec<u8>, GatewayError> {
self.raw.extend_from_slice(&line);
let Ok(text) = std::str::from_utf8(&line) else {
return Ok(Vec::new());
};
let trimmed = text.trim_matches('\r').trim();
if trimmed.is_empty() {
if self
.raw
.windows(b"\"type\":\"message_stop\"".len())
.any(|window| window == b"\"type\":\"message_stop\"")
{
return Ok(encode_done_sse());
}
return Ok(Vec::new());
}
let Some(data_line) = trimmed.strip_prefix("data:") else {
return Ok(Vec::new());
};
let data_line = data_line.trim();
if data_line.is_empty() || data_line == "[DONE]" {
return Ok(Vec::new());
}
let value: Value = match serde_json::from_str(data_line) {
Ok(value) => value,
Err(_) => return Ok(Vec::new()),
};
match value
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"message_start" => {
if let Some(message) = value.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);
}
let (id, model) = claude_identity(self, report_context);
encode_json_sse(
None,
&json!({
"id": id,
"object": "chat.completion.chunk",
"model": model,
"choices": [{
"index": 0,
"delta": {
"role": "assistant"
},
"finish_reason": Value::Null
}]
}),
)
}
"content_block_delta" => {
let Some(delta) = value.get("delta").and_then(Value::as_object) else {
return Ok(Vec::new());
};
if delta.get("type").and_then(Value::as_str) != Some("text_delta") {
return Ok(Vec::new());
}
let Some(piece) = delta.get("text").and_then(Value::as_str) else {
return Ok(Vec::new());
};
let (id, model) = claude_identity(self, report_context);
encode_json_sse(
None,
&build_openai_chat_chunk(id, model, piece.to_string(), None, None),
)
}
"content_block_start" => {
let Some(block) = value.get("content_block").and_then(Value::as_object) else {
return Ok(Vec::new());
};
if block.get("type").and_then(Value::as_str) != Some("tool_use") {
return Ok(Vec::new());
}
let call = json!({
"index": value.get("index").and_then(Value::as_u64).unwrap_or(0),
"id": block
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("tool_call"),
"type": "function",
"function": {
"name": block.get("name").and_then(Value::as_str).unwrap_or("unknown"),
"arguments": canonicalize_arguments(block.get("input").cloned()),
}
});
let (id, model) = claude_identity(self, report_context);
encode_json_sse(
None,
&build_openai_chat_chunk(id, model, String::new(), Some(vec![call]), None),
)
}
"message_delta" => {
let Some(delta) = value.get("delta").and_then(Value::as_object) else {
return Ok(Vec::new());
};
let Some(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"),
) else {
return Ok(Vec::new());
};
let (id, model) = claude_identity(self, report_context);
encode_json_sse(
None,
&json!({
"id": id,
"object": "chat.completion.chunk",
"model": model,
"choices": [{
"index": 0,
"delta": {},
"finish_reason": finish_reason
}]
}),
)
}
_ => Ok(Vec::new()),
}
}
pub(crate) fn finish(&mut self) -> Vec<u8> {
if self.raw.is_empty() {
return Vec::new();
}
let aggregated = aggregate_claude_stream_sync_response(&self.raw);
self.raw.clear();
let Some(aggregated) = aggregated else {
return Vec::new();
};
let Some(chunk) = convert_claude_aggregated_to_openai_chunk(&aggregated, &Value::Null)
else {
return Vec::new();
};
let mut out = encode_json_sse(None, &chunk).unwrap_or_default();
out.extend(encode_done_sse());
out
}
}
impl GeminiToOpenAIChatStreamState {
pub(crate) fn transform_line(
&mut self,
_report_context: &Value,
line: Vec<u8>,
) -> Result<Vec<u8>, GatewayError> {
self.raw.extend_from_slice(&line);
Ok(Vec::new())
}
pub(crate) fn finish(&mut self, report_context: &Value) -> Result<Vec<u8>, GatewayError> {
if self.raw.is_empty() {
return Ok(Vec::new());
}
let aggregated = aggregate_gemini_stream_sync_response(&self.raw);
self.raw.clear();
let Some(aggregated) = aggregated else {
return Ok(Vec::new());
};
let Some(chunk) = convert_gemini_aggregated_to_openai_chunk(&aggregated, report_context)
else {
return Ok(Vec::new());
};
let mut out = encode_json_sse(None, &chunk)?;
out.extend(encode_done_sse());
Ok(out)
}
}
impl OpenAICliToOpenAIChatStreamState {
pub(crate) fn transform_line(
&mut self,
_report_context: &Value,
line: Vec<u8>,
) -> Result<Vec<u8>, GatewayError> {
self.raw.extend_from_slice(&line);
Ok(Vec::new())
}
pub(crate) fn finish(&mut self, report_context: &Value) -> Result<Vec<u8>, GatewayError> {
if self.raw.is_empty() {
return Ok(Vec::new());
}
let aggregated = crate::gateway::ai_pipeline::finalize::standard::aggregate_openai_cli_stream_sync_response(&self.raw);
self.raw.clear();
let Some(aggregated) = aggregated else {
return Ok(Vec::new());
};
let Some(chat_response) =
convert_openai_cli_response_to_openai_chat(&aggregated, report_context)
else {
return Ok(Vec::new());
};
let Some(chat_object) = chat_response.as_object() else {
return Ok(Vec::new());
};
let Some(choice) = chat_object
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(Value::as_object)
else {
return Ok(Vec::new());
};
let Some(message) = choice.get("message").and_then(Value::as_object) else {
return Ok(Vec::new());
};
let content = message
.get("content")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let tool_calls = message.get("tool_calls").and_then(Value::as_array).cloned();
let finish_reason = choice.get("finish_reason").and_then(Value::as_str);
let id = chat_object
.get("id")
.and_then(Value::as_str)
.unwrap_or("chatcmpl-local-stream");
let model = chat_object
.get("model")
.and_then(Value::as_str)
.unwrap_or("unknown");
let chunk = build_openai_chat_chunk(id, model, content, tool_calls, finish_reason);
let mut out = encode_json_sse(None, &chunk)?;
out.extend(encode_done_sse());
Ok(out)
}
}

View File

@@ -1,44 +0,0 @@
use serde_json::{json, Value};
use crate::gateway::ai_pipeline::finalize::sse::encode_json_sse;
use crate::gateway::GatewayError;
#[derive(Default)]
pub(crate) struct BufferedCliConversionStreamState {
raw: Vec<u8>,
}
impl BufferedCliConversionStreamState {
pub(crate) fn transform_line(&mut self, line: Vec<u8>) -> Result<Vec<u8>, GatewayError> {
self.raw.extend_from_slice(&line);
Ok(Vec::new())
}
pub(crate) fn finish<AggregateFn, ConvertFn>(
&mut self,
report_context: &Value,
aggregate: AggregateFn,
convert: ConvertFn,
) -> Result<Vec<u8>, GatewayError>
where
AggregateFn: Fn(&[u8]) -> Option<Value>,
ConvertFn: Fn(&Value, &Value) -> Option<Value>,
{
if self.raw.is_empty() {
return Ok(Vec::new());
}
let aggregated = aggregate(&self.raw);
self.raw.clear();
let Some(aggregated) = aggregated else {
return Ok(Vec::new());
};
let Some(response) = convert(&aggregated, report_context) else {
return Ok(Vec::new());
};
let event = json!({
"type": "response.completed",
"response": response,
});
encode_json_sse(Some("response.completed"), &event)
}
}

View File

@@ -0,0 +1,14 @@
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,
};

View File

@@ -0,0 +1,962 @@
use std::collections::BTreeMap;
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, 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,
},
})
}
}

View File

@@ -3,19 +3,21 @@ use std::collections::BTreeMap;
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
use super::{ use super::cli::aggregate_openai_cli_stream_sync_response;
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response, use crate::ai_pipeline::conversion::response::{
aggregate_openai_cli_stream_sync_response, convert_claude_chat_response_to_openai_chat, convert_claude_chat_response_to_openai_chat, convert_gemini_chat_response_to_openai_chat,
convert_gemini_chat_response_to_openai_chat,
}; };
use crate::gateway::ai_pipeline::conversion::sync_chat_response_conversion_kind; use crate::ai_pipeline::conversion::sync_chat_response_conversion_kind;
use crate::gateway::ai_pipeline::finalize::common::{ 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, unwrap_local_finalize_response_value, local_finalize_allows_envelope, unwrap_local_finalize_response_value,
LocalCoreSyncFinalizeOutcome, LocalCoreSyncFinalizeOutcome,
}; };
use crate::gateway::{GatewayControlDecision, GatewayError, GatewaySyncReportRequest}; 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)] #[derive(Debug, Default)]
struct OpenAIChatChoiceState { struct OpenAIChatChoiceState {

View File

@@ -2,17 +2,19 @@ use base64::Engine as _;
use serde_json::{json, Value}; use serde_json::{json, Value};
use super::{ use crate::ai_pipeline::conversion::response::{
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
convert_claude_cli_response_to_openai_cli, convert_gemini_cli_response_to_openai_cli, convert_claude_cli_response_to_openai_cli, convert_gemini_cli_response_to_openai_cli,
}; };
use crate::gateway::ai_pipeline::conversion::sync_cli_response_conversion_kind; use crate::ai_pipeline::conversion::sync_cli_response_conversion_kind;
use crate::gateway::ai_pipeline::finalize::common::{ use crate::ai_pipeline::finalize::common::{
build_local_success_outcome, build_local_success_outcome_with_conversion_report, build_local_success_outcome, build_local_success_outcome_with_conversion_report,
canonicalize_tool_arguments, local_finalize_allows_envelope, canonicalize_tool_arguments, local_finalize_allows_envelope,
unwrap_local_finalize_response_value, LocalCoreSyncFinalizeOutcome, unwrap_local_finalize_response_value, LocalCoreSyncFinalizeOutcome,
}; };
use crate::gateway::{GatewayControlDecision, GatewayError, GatewaySyncReportRequest}; 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( pub(crate) fn maybe_build_local_openai_cli_stream_sync_response(
trace_id: &str, trace_id: &str,

View File

@@ -0,0 +1,16 @@
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,
};

View File

@@ -1,347 +0,0 @@
use serde_json::{json, Value};
use crate::gateway::ai_pipeline::adaptation::private_envelope::transform_provider_private_stream_line as transform_envelope_line;
use crate::gateway::ai_pipeline::adaptation::surfaces::provider_adaptation_should_unwrap_stream_envelope;
use crate::gateway::ai_pipeline::finalize::sse::{encode_done_sse, encode_json_sse};
use crate::gateway::ai_pipeline::finalize::standard::{
aggregate_standard_chat_stream_sync_response, aggregate_standard_cli_stream_sync_response,
convert_standard_chat_response, convert_standard_cli_response,
};
use crate::gateway::GatewayError;
#[derive(Default)]
pub(crate) struct BufferedStandardConversionStreamState {
raw: Vec<u8>,
}
impl BufferedStandardConversionStreamState {
pub(crate) fn transform_line(
&mut self,
report_context: &Value,
line: Vec<u8>,
) -> Result<Vec<u8>, GatewayError> {
if should_unwrap_envelope(report_context) {
self.raw
.extend(transform_envelope_line(report_context, line)?);
} else {
self.raw.extend_from_slice(&line);
}
Ok(Vec::new())
}
pub(crate) fn finish_as_chat(
&mut self,
report_context: &Value,
) -> Result<Vec<u8>, GatewayError> {
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 self.raw.is_empty() {
return Ok(Vec::new());
}
let aggregated =
aggregate_standard_chat_stream_sync_response(&self.raw, provider_api_format.as_str());
self.raw.clear();
let Some(aggregated) = aggregated else {
return Ok(Vec::new());
};
let Some(converted) = convert_standard_chat_response(
&aggregated,
provider_api_format.as_str(),
client_api_format.as_str(),
report_context,
) else {
return Ok(Vec::new());
};
emit_chat_stream_for_client_format(&converted, client_api_format.as_str())
}
pub(crate) fn finish_as_cli(
&mut self,
report_context: &Value,
) -> Result<Vec<u8>, GatewayError> {
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 self.raw.is_empty() {
return Ok(Vec::new());
}
let aggregated =
aggregate_standard_cli_stream_sync_response(&self.raw, provider_api_format.as_str());
self.raw.clear();
let Some(aggregated) = aggregated else {
return Ok(Vec::new());
};
let Some(converted) = convert_standard_cli_response(
&aggregated,
provider_api_format.as_str(),
client_api_format.as_str(),
report_context,
) else {
return Ok(Vec::new());
};
emit_cli_stream_for_client_format(&converted, client_api_format.as_str())
}
}
fn should_unwrap_envelope(report_context: &Value) -> bool {
let envelope_name = report_context
.get("envelope_name")
.and_then(Value::as_str)
.unwrap_or_default();
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default();
provider_adaptation_should_unwrap_stream_envelope(envelope_name, provider_api_format)
}
fn emit_chat_stream_for_client_format(
response_body: &Value,
client_api_format: &str,
) -> Result<Vec<u8>, GatewayError> {
match client_api_format {
"openai:chat" => emit_openai_chat_stream(response_body),
"claude:chat" | "claude:cli" => emit_claude_message_stream(response_body),
"gemini:chat" | "gemini:cli" => encode_json_sse(None, response_body),
_ => Ok(Vec::new()),
}
}
fn emit_cli_stream_for_client_format(
response_body: &Value,
client_api_format: &str,
) -> Result<Vec<u8>, GatewayError> {
match client_api_format {
"openai:cli" | "openai:compact" => encode_json_sse(
Some("response.completed"),
&json!({
"type": "response.completed",
"response": response_body,
}),
),
"claude:cli" => emit_claude_message_stream(response_body),
"gemini:cli" => encode_json_sse(None, response_body),
_ => Ok(Vec::new()),
}
}
fn emit_openai_chat_stream(response_body: &Value) -> Result<Vec<u8>, GatewayError> {
let body = match response_body.as_object() {
Some(body) => body,
None => return Ok(Vec::new()),
};
let choice = match body
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(Value::as_object)
{
Some(choice) => choice,
None => return Ok(Vec::new()),
};
let message = match choice.get("message").and_then(Value::as_object) {
Some(message) => message,
None => return Ok(Vec::new()),
};
let content = match extract_openai_chat_content_text(message.get("content")) {
Some(content) => content,
None => return Ok(Vec::new()),
};
let mut delta = serde_json::Map::new();
delta.insert("role".to_string(), Value::String("assistant".to_string()));
if !content.is_empty() {
delta.insert("content".to_string(), Value::String(content));
} else if message.get("tool_calls").is_none() {
delta.insert("content".to_string(), Value::String(String::new()));
}
if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) {
delta.insert("tool_calls".to_string(), Value::Array(tool_calls.clone()));
}
let chunk = json!({
"id": body.get("id").cloned().unwrap_or_else(|| Value::String("chatcmpl-local-stream".to_string())),
"object": "chat.completion.chunk",
"model": body.get("model").cloned().unwrap_or_else(|| Value::String("unknown".to_string())),
"choices": [{
"index": choice.get("index").cloned().unwrap_or_else(|| Value::from(0_u64)),
"delta": Value::Object(delta),
"finish_reason": choice.get("finish_reason").cloned().unwrap_or(Value::Null),
}]
});
let mut out = encode_json_sse(None, &chunk)?;
out.extend(encode_done_sse());
Ok(out)
}
fn emit_claude_message_stream(response_body: &Value) -> Result<Vec<u8>, GatewayError> {
let body = match response_body.as_object() {
Some(body) => body,
None => return Ok(Vec::new()),
};
let message_id = body
.get("id")
.cloned()
.unwrap_or_else(|| Value::String("msg-local-stream".to_string()));
let model = body
.get("model")
.cloned()
.unwrap_or_else(|| Value::String("unknown".to_string()));
let content_blocks = match body.get("content").and_then(Value::as_array) {
Some(content) => content,
None => return Ok(Vec::new()),
};
let mut out = encode_json_sse(
Some("message_start"),
&json!({
"type": "message_start",
"message": {
"id": message_id,
"type": "message",
"role": "assistant",
"model": model,
"content": [],
"stop_reason": Value::Null,
"stop_sequence": Value::Null,
}
}),
)?;
for (index, block) in content_blocks.iter().enumerate() {
let Some(block_object) = block.as_object() else {
continue;
};
match block_object
.get("type")
.and_then(Value::as_str)
.unwrap_or("text")
{
"text" => {
out.extend(encode_json_sse(
Some("content_block_start"),
&json!({
"type": "content_block_start",
"index": index,
"content_block": {
"type": "text",
"text": "",
}
}),
)?);
if let Some(text) = block_object.get("text").and_then(Value::as_str) {
if !text.is_empty() {
out.extend(encode_json_sse(
Some("content_block_delta"),
&json!({
"type": "content_block_delta",
"index": index,
"delta": {
"type": "text_delta",
"text": text,
}
}),
)?);
}
}
out.extend(encode_json_sse(
Some("content_block_stop"),
&json!({
"type": "content_block_stop",
"index": index,
}),
)?);
}
"tool_use" => {
out.extend(encode_json_sse(
Some("content_block_start"),
&json!({
"type": "content_block_start",
"index": index,
"content_block": block_object,
}),
)?);
out.extend(encode_json_sse(
Some("content_block_stop"),
&json!({
"type": "content_block_stop",
"index": index,
}),
)?);
}
_ => {}
}
}
let mut delta = serde_json::Map::new();
delta.insert(
"stop_reason".to_string(),
body.get("stop_reason").cloned().unwrap_or(Value::Null),
);
if let Some(stop_sequence) = body.get("stop_sequence").cloned() {
delta.insert("stop_sequence".to_string(), stop_sequence);
}
let mut message_delta = serde_json::Map::new();
message_delta.insert(
"type".to_string(),
Value::String("message_delta".to_string()),
);
message_delta.insert("delta".to_string(), Value::Object(delta));
if let Some(usage) = body.get("usage").cloned() {
message_delta.insert("usage".to_string(), usage);
}
out.extend(encode_json_sse(
Some("message_delta"),
&Value::Object(message_delta),
)?);
out.extend(encode_json_sse(
Some("message_stop"),
&json!({
"type": "message_stop",
}),
)?);
Ok(out)
}
fn extract_openai_chat_content_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,
}
}

View File

@@ -0,0 +1,222 @@
use serde_json::{json, Map, Value};
#[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,
}]
})
}

View File

@@ -0,0 +1,100 @@
//! Standard finalize streaming conversion helpers.
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;
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;

View File

@@ -0,0 +1,97 @@
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::surfaces::provider_adaptation_should_unwrap_stream_envelope;
use crate::GatewayError;
use super::common::CanonicalStreamFrame;
use super::{ClientStreamEmitter, ProviderStreamParser};
#[derive(Default)]
pub(crate) struct StreamingStandardConversionState {
provider: Option<ProviderStreamParser>,
client: Option<ClientStreamEmitter>,
}
impl StreamingStandardConversionState {
pub(crate) fn transform_line(
&mut self,
report_context: &Value,
line: Vec<u8>,
) -> Result<Vec<u8>, GatewayError> {
self.ensure_initialized(report_context)?;
let line = if should_unwrap_envelope(report_context) {
transform_envelope_line(report_context, line)?
} else {
line
};
if line.is_empty() {
return Ok(Vec::new());
}
let Some(provider) = self.provider.as_mut() else {
return Ok(Vec::new());
};
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> {
self.ensure_initialized(report_context)?;
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)
}
}
fn should_unwrap_envelope(report_context: &Value) -> bool {
let envelope_name = report_context
.get("envelope_name")
.and_then(Value::as_str)
.unwrap_or_default();
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default();
provider_adaptation_should_unwrap_stream_envelope(envelope_name, provider_api_format)
}

View File

@@ -2,6 +2,10 @@ use serde_json::json;
use super::maybe_build_local_stream_rewriter; use super::maybe_build_local_stream_rewriter;
fn utf8(bytes: Vec<u8>) -> String {
String::from_utf8(bytes).expect("utf8 should decode")
}
#[test] #[test]
fn antigravity_stream_rewriter_unwraps_and_injects_tool_ids() { fn antigravity_stream_rewriter_unwraps_and_injects_tool_ids() {
let report_context = json!({ let report_context = json!({
@@ -136,20 +140,22 @@ fn gemini_to_openai_chat_stream_rewriter_buffers_and_converts_text() {
b"data: {\"responseId\":\"resp_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello \"}],\"role\":\"model\"},\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\"}\n\n", b"data: {\"responseId\":\"resp_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello \"}],\"role\":\"model\"},\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\"}\n\n",
) )
.expect("rewrite should succeed"); .expect("rewrite should succeed");
assert!(first.is_empty()); let first_text = utf8(first);
assert!(first_text.contains("\"object\":\"chat.completion.chunk\""));
assert!(first_text.contains("\"role\":\"assistant\""));
assert!(first_text.contains("\"content\":\"Hello \""));
assert!(!first_text.contains("data: [DONE]"));
let second = rewriter let second = rewriter
.push_chunk( .push_chunk(
b"data: {\"responseId\":\"resp_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Gemini\"}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\"}\n\n", b"data: {\"responseId\":\"resp_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Gemini\"}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\"}\n\n",
) )
.expect("rewrite should succeed"); .expect("rewrite should succeed");
assert!(second.is_empty()); let output_text = utf8(second);
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed"))
.expect("utf8 should decode");
assert!(output_text.contains("\"object\":\"chat.completion.chunk\"")); assert!(output_text.contains("\"object\":\"chat.completion.chunk\""));
assert!(output_text.contains("\"role\":\"assistant\""));
assert!(output_text.contains("\"content\":\"Gemini\"")); assert!(output_text.contains("\"content\":\"Gemini\""));
assert!(output_text.contains("\"finish_reason\":\"stop\"")); assert!(output_text.contains("\"finish_reason\":\"stop\""));
assert!(output_text.contains("data: [DONE]")); assert!(output_text.contains("data: [DONE]"));
assert!(rewriter.finish().expect("finish should succeed").is_empty());
} }
#[test] #[test]
@@ -167,9 +173,7 @@ fn gemini_to_openai_chat_stream_rewriter_buffers_and_converts_function_call() {
b"data: {\"responseId\":\"resp_tool_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Need a tool.\"},{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\"}\n\n", b"data: {\"responseId\":\"resp_tool_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Need a tool.\"},{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\"}\n\n",
) )
.expect("rewrite should succeed"); .expect("rewrite should succeed");
assert!(output.is_empty()); let output_text = utf8(output);
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed"))
.expect("utf8 should decode");
assert!(output_text.contains("\"object\":\"chat.completion.chunk\"")); assert!(output_text.contains("\"object\":\"chat.completion.chunk\""));
assert!(output_text.contains("\"role\":\"assistant\"")); assert!(output_text.contains("\"role\":\"assistant\""));
assert!(output_text.contains("\"content\":\"Need a tool.\"")); assert!(output_text.contains("\"content\":\"Need a tool.\""));
@@ -178,10 +182,64 @@ fn gemini_to_openai_chat_stream_rewriter_buffers_and_converts_function_call() {
assert!(output_text.contains("\\\"city\\\":\\\"SF\\\"")); assert!(output_text.contains("\\\"city\\\":\\\"SF\\\""));
assert!(output_text.contains("\"finish_reason\":\"tool_calls\"")); assert!(output_text.contains("\"finish_reason\":\"tool_calls\""));
assert!(output_text.contains("data: [DONE]")); assert!(output_text.contains("data: [DONE]"));
assert!(rewriter.finish().expect("finish should succeed").is_empty());
} }
#[test] #[test]
fn openai_cli_to_openai_chat_stream_rewriter_buffers_and_converts_completed_event() { fn openai_cli_to_openai_chat_stream_rewriter_converts_text_deltas_immediately() {
let report_context = json!({
"provider_api_format": "openai:cli",
"client_api_format": "openai:chat",
"needs_conversion": true,
"mapped_model": "gpt-5.4",
});
let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let created = rewriter
.push_chunk(
concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_cli_stream_123\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"in_progress\"}}\n\n"
)
.as_bytes(),
)
.expect("rewrite should succeed");
let created_text = String::from_utf8(created).expect("utf8 should decode");
assert!(created_text.contains("\"object\":\"chat.completion.chunk\""));
assert!(created_text.contains("\"role\":\"assistant\""));
assert!(!created_text.contains("data: [DONE]"));
let delta = rewriter
.push_chunk(
concat!(
"event: response.output_text.delta\n",
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"Hello Codex\"}\n\n"
)
.as_bytes(),
)
.expect("rewrite should succeed");
let delta_text = String::from_utf8(delta).expect("utf8 should decode");
assert!(delta_text.contains("\"object\":\"chat.completion.chunk\""));
assert!(delta_text.contains("\"content\":\"Hello Codex\""));
assert!(!delta_text.contains("data: [DONE]"));
let completed = rewriter
.push_chunk(
concat!(
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_cli_stream_123\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"id\":\"msg_cli_stream_123\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello Codex\",\"annotations\":[]}]}],\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}\n\n"
)
.as_bytes(),
)
.expect("rewrite should succeed");
let completed_text = String::from_utf8(completed).expect("utf8 should decode");
assert!(completed_text.contains("\"finish_reason\":\"stop\""));
assert!(completed_text.contains("data: [DONE]"));
assert!(rewriter.finish().expect("finish should succeed").is_empty());
}
#[test]
fn openai_cli_to_openai_chat_stream_rewriter_converts_completed_event_without_buffering() {
let report_context = json!({ let report_context = json!({
"provider_api_format": "openai:cli", "provider_api_format": "openai:cli",
"client_api_format": "openai:chat", "client_api_format": "openai:chat",
@@ -199,14 +257,13 @@ fn openai_cli_to_openai_chat_stream_rewriter_buffers_and_converts_completed_even
.as_bytes(), .as_bytes(),
) )
.expect("rewrite should succeed"); .expect("rewrite should succeed");
assert!(output.is_empty()); let output_text = String::from_utf8(output).expect("utf8 should decode");
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed"))
.expect("utf8 should decode");
assert!(output_text.contains("\"object\":\"chat.completion.chunk\"")); assert!(output_text.contains("\"object\":\"chat.completion.chunk\""));
assert!(output_text.contains("\"role\":\"assistant\"")); assert!(output_text.contains("\"role\":\"assistant\""));
assert!(output_text.contains("\"content\":\"Hello Codex\"")); assert!(output_text.contains("\"content\":\"Hello Codex\""));
assert!(output_text.contains("\"finish_reason\":\"stop\"")); assert!(output_text.contains("\"finish_reason\":\"stop\""));
assert!(output_text.contains("data: [DONE]")); assert!(output_text.contains("data: [DONE]"));
assert!(rewriter.finish().expect("finish should succeed").is_empty());
} }
#[test] #[test]
@@ -226,13 +283,14 @@ fn antigravity_gemini_to_openai_chat_stream_rewriter_unwraps_and_converts_functi
b"data: {\"response\":{\"responseId\":\"resp_antigravity_chat_tool_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Need a tool.\"},{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"claude-sonnet-4-5\"},\"responseId\":\"resp_antigravity_chat_tool_123\"}\n\n", b"data: {\"response\":{\"responseId\":\"resp_antigravity_chat_tool_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Need a tool.\"},{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"claude-sonnet-4-5\"},\"responseId\":\"resp_antigravity_chat_tool_123\"}\n\n",
) )
.expect("rewrite should succeed"); .expect("rewrite should succeed");
assert!(output.is_empty()); let output_text = utf8(output);
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed"))
.expect("utf8 should decode");
assert!(output_text.contains("\"object\":\"chat.completion.chunk\"")); assert!(output_text.contains("\"object\":\"chat.completion.chunk\""));
assert!(output_text.contains("\"content\":\"Need a tool.\""));
assert!(output_text.contains("\"tool_calls\"")); assert!(output_text.contains("\"tool_calls\""));
assert!(output_text.contains("\"name\":\"get_weather\"")); assert!(output_text.contains("\"name\":\"get_weather\""));
assert!(output_text.contains("\"finish_reason\":\"tool_calls\"")); assert!(output_text.contains("\"finish_reason\":\"tool_calls\""));
assert!(output_text.contains("data: [DONE]"));
assert!(rewriter.finish().expect("finish should succeed").is_empty());
} }
#[test] #[test]
@@ -251,12 +309,15 @@ fn antigravity_gemini_to_openai_cli_stream_rewriter_unwraps_and_converts_functio
b"data: {\"response\":{\"responseId\":\"resp_antigravity_cli_tool_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Need a tool.\"},{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"claude-sonnet-4-5\",\"usageMetadata\":{\"promptTokenCount\":2,\"candidatesTokenCount\":3,\"totalTokenCount\":5}},\"responseId\":\"resp_antigravity_cli_tool_123\"}\n\n", b"data: {\"response\":{\"responseId\":\"resp_antigravity_cli_tool_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Need a tool.\"},{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"claude-sonnet-4-5\",\"usageMetadata\":{\"promptTokenCount\":2,\"candidatesTokenCount\":3,\"totalTokenCount\":5}},\"responseId\":\"resp_antigravity_cli_tool_123\"}\n\n",
) )
.expect("rewrite should succeed"); .expect("rewrite should succeed");
assert!(output.is_empty()); let output_text = utf8(output);
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed")) assert!(output_text.contains("event: response.created"));
.expect("utf8 should decode"); assert!(output_text.contains("event: response.output_text.delta"));
assert!(output_text.contains("event: response.output_item.added"));
assert!(output_text.contains("event: response.function_call_arguments.delta"));
assert!(output_text.contains("event: response.completed")); assert!(output_text.contains("event: response.completed"));
assert!(output_text.contains("\"type\":\"function_call\"")); assert!(output_text.contains("\"type\":\"function_call\""));
assert!(output_text.contains("\"name\":\"get_weather\"")); assert!(output_text.contains("\"name\":\"get_weather\""));
assert!(rewriter.finish().expect("finish should succeed").is_empty());
} }
#[test] #[test]
@@ -274,20 +335,24 @@ fn gemini_to_openai_cli_stream_rewriter_buffers_and_converts_to_completed_event(
b"data: {\"responseId\":\"resp_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello \"}],\"role\":\"model\"},\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\"}\n\n", b"data: {\"responseId\":\"resp_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello \"}],\"role\":\"model\"},\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\"}\n\n",
) )
.expect("rewrite should succeed"); .expect("rewrite should succeed");
assert!(first.is_empty()); let first_text = utf8(first);
assert!(first_text.contains("event: response.created"));
assert!(first_text.contains("event: response.output_text.delta"));
assert!(first_text.contains("\"delta\":\"Hello \""));
let second = rewriter let second = rewriter
.push_chunk( .push_chunk(
b"data: {\"responseId\":\"resp_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Gemini CLI\"}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\",\"usageMetadata\":{\"promptTokenCount\":2,\"candidatesTokenCount\":3,\"totalTokenCount\":5}}\n\n", b"data: {\"responseId\":\"resp_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Gemini CLI\"}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\",\"usageMetadata\":{\"promptTokenCount\":2,\"candidatesTokenCount\":3,\"totalTokenCount\":5}}\n\n",
) )
.expect("rewrite should succeed"); .expect("rewrite should succeed");
assert!(second.is_empty()); let output_text = utf8(second);
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed")) assert!(output_text.contains("event: response.output_text.delta"));
.expect("utf8 should decode");
assert!(output_text.contains("event: response.completed")); assert!(output_text.contains("event: response.completed"));
assert!(output_text.contains("\"type\":\"response.completed\"")); assert!(output_text.contains("\"type\":\"response.completed\""));
assert!(output_text.contains("\"object\":\"response\"")); assert!(output_text.contains("\"object\":\"response\""));
assert!(output_text.contains("\"text\":\"Gemini CLI\"")); assert!(output_text.contains("\"delta\":\"Gemini CLI\""));
assert!(output_text.contains("\"text\":\"Hello Gemini CLI\""));
assert!(output_text.contains("\"total_tokens\":5")); assert!(output_text.contains("\"total_tokens\":5"));
assert!(rewriter.finish().expect("finish should succeed").is_empty());
} }
#[test] #[test]
@@ -317,14 +382,15 @@ fn claude_to_openai_cli_stream_rewriter_buffers_and_converts_to_completed_event(
.as_bytes(), .as_bytes(),
) )
.expect("rewrite should succeed"); .expect("rewrite should succeed");
assert!(output.is_empty()); let output_text = utf8(output);
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed")) assert!(output_text.contains("event: response.created"));
.expect("utf8 should decode"); assert!(output_text.contains("event: response.output_text.delta"));
assert!(output_text.contains("event: response.completed")); assert!(output_text.contains("event: response.completed"));
assert!(output_text.contains("\"type\":\"response.completed\"")); assert!(output_text.contains("\"type\":\"response.completed\""));
assert!(output_text.contains("\"object\":\"response\"")); assert!(output_text.contains("\"object\":\"response\""));
assert!(output_text.contains("\"text\":\"Hello Claude CLI\"")); assert!(output_text.contains("\"text\":\"Hello Claude CLI\""));
assert!(output_text.contains("\"total_tokens\":5")); assert!(output_text.contains("\"total_tokens\":5"));
assert!(rewriter.finish().expect("finish should succeed").is_empty());
} }
#[test] #[test]
@@ -358,15 +424,19 @@ fn claude_to_openai_cli_stream_rewriter_converts_tool_use_to_function_call() {
.as_bytes(), .as_bytes(),
) )
.expect("rewrite should succeed"); .expect("rewrite should succeed");
assert!(output.is_empty()); let output_text = utf8(output);
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed")) assert!(output_text.contains("event: response.created"));
.expect("utf8 should decode"); assert!(output_text.contains("event: response.output_text.delta"));
assert!(output_text.contains("\"delta\":\"Running tool.\""));
assert!(output_text.contains("event: response.output_item.added"));
assert!(output_text.contains("event: response.function_call_arguments.delta"));
assert!(output_text.contains("event: response.completed")); assert!(output_text.contains("event: response.completed"));
assert!(output_text.contains("\"type\":\"response.completed\"")); assert!(output_text.contains("\"type\":\"response.completed\""));
assert!(output_text.contains("\"type\":\"function_call\"")); assert!(output_text.contains("\"type\":\"function_call\""));
assert!(output_text.contains("\"call_id\":\"tool_123\"")); assert!(output_text.contains("\"call_id\":\"tool_123\""));
assert!(output_text.contains("\"name\":\"read_file\"")); assert!(output_text.contains("\"name\":\"read_file\""));
assert!(output_text.contains("\\\"path\\\":\\\"/tmp/test.txt\\\"")); assert!(output_text.contains("\\\"path\\\":\\\"/tmp/test.txt\\\""));
assert!(rewriter.finish().expect("finish should succeed").is_empty());
} }
#[test] #[test]
@@ -384,14 +454,16 @@ fn gemini_to_openai_cli_stream_rewriter_converts_function_call_to_completed_even
b"data: {\"responseId\":\"resp_tool_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Need a tool.\"},{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"location\":\"Tokyo\"}}}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\",\"usageMetadata\":{\"promptTokenCount\":2,\"candidatesTokenCount\":3,\"totalTokenCount\":5}}\n\n", b"data: {\"responseId\":\"resp_tool_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Need a tool.\"},{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"location\":\"Tokyo\"}}}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\",\"usageMetadata\":{\"promptTokenCount\":2,\"candidatesTokenCount\":3,\"totalTokenCount\":5}}\n\n",
) )
.expect("rewrite should succeed"); .expect("rewrite should succeed");
assert!(output.is_empty()); let output_text = utf8(output);
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed")) assert!(output_text.contains("event: response.created"));
.expect("utf8 should decode"); assert!(output_text.contains("event: response.output_item.added"));
assert!(output_text.contains("event: response.function_call_arguments.delta"));
assert!(output_text.contains("event: response.completed")); assert!(output_text.contains("event: response.completed"));
assert!(output_text.contains("\"type\":\"response.completed\"")); assert!(output_text.contains("\"type\":\"response.completed\""));
assert!(output_text.contains("\"type\":\"function_call\"")); assert!(output_text.contains("\"type\":\"function_call\""));
assert!(output_text.contains("\"name\":\"get_weather\"")); assert!(output_text.contains("\"name\":\"get_weather\""));
assert!(output_text.contains("\\\"location\\\":\\\"Tokyo\\\"")); assert!(output_text.contains("\\\"location\\\":\\\"Tokyo\\\""));
assert!(rewriter.finish().expect("finish should succeed").is_empty());
} }
#[test] #[test]
@@ -409,14 +481,16 @@ fn gemini_to_openai_compact_stream_rewriter_converts_function_call_to_completed_
b"data: {\"responseId\":\"resp_tool_compact_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Need a tool.\"},{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"location\":\"Tokyo\"}}}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\",\"usageMetadata\":{\"promptTokenCount\":2,\"candidatesTokenCount\":3,\"totalTokenCount\":5}}\n\n", b"data: {\"responseId\":\"resp_tool_compact_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Need a tool.\"},{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"location\":\"Tokyo\"}}}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\",\"usageMetadata\":{\"promptTokenCount\":2,\"candidatesTokenCount\":3,\"totalTokenCount\":5}}\n\n",
) )
.expect("rewrite should succeed"); .expect("rewrite should succeed");
assert!(output.is_empty()); let output_text = utf8(output);
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed")) assert!(output_text.contains("event: response.created"));
.expect("utf8 should decode"); assert!(output_text.contains("event: response.output_item.added"));
assert!(output_text.contains("event: response.function_call_arguments.delta"));
assert!(output_text.contains("event: response.completed")); assert!(output_text.contains("event: response.completed"));
assert!(output_text.contains("\"type\":\"response.completed\"")); assert!(output_text.contains("\"type\":\"response.completed\""));
assert!(output_text.contains("\"type\":\"function_call\"")); assert!(output_text.contains("\"type\":\"function_call\""));
assert!(output_text.contains("\"name\":\"get_weather\"")); assert!(output_text.contains("\"name\":\"get_weather\""));
assert!(output_text.contains("\\\"location\\\":\\\"Tokyo\\\"")); assert!(output_text.contains("\\\"location\\\":\\\"Tokyo\\\""));
assert!(rewriter.finish().expect("finish should succeed").is_empty());
} }
#[test] #[test]
@@ -429,23 +503,32 @@ fn openai_chat_to_claude_chat_stream_rewriter_converts_via_standard_matrix() {
}); });
let mut rewriter = let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist"); maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let output = rewriter let first = rewriter
.push_chunk(
"data: {\"id\":\"chatcmpl_std_claude_123\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hello Claude\"},\"finish_reason\":null}]}\n\n"
.as_bytes(),
)
.expect("rewrite should succeed");
let first_text = utf8(first);
assert!(first_text.contains("event: message_start"));
assert!(first_text.contains("event: content_block_start"));
assert!(first_text.contains("event: content_block_delta"));
assert!(first_text.contains("\"text\":\"Hello Claude\""));
let second = rewriter
.push_chunk( .push_chunk(
concat!( concat!(
"data: {\"id\":\"chatcmpl_std_claude_123\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hello Claude\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl_std_claude_123\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n", "data: {\"id\":\"chatcmpl_std_claude_123\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n",
"data: [DONE]\n\n" "data: [DONE]\n\n"
) )
.as_bytes(), .as_bytes(),
) )
.expect("rewrite should succeed"); .expect("rewrite should succeed");
assert!(output.is_empty()); let output_text = utf8(second);
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed")) assert!(output_text.contains("event: content_block_stop"));
.expect("utf8 should decode"); assert!(output_text.contains("event: message_delta"));
assert!(output_text.contains("event: message_start"));
assert!(output_text.contains("event: content_block_delta"));
assert!(output_text.contains("\"text\":\"Hello Claude\""));
assert!(output_text.contains("event: message_stop")); assert!(output_text.contains("event: message_stop"));
assert!(rewriter.finish().expect("finish should succeed").is_empty());
} }
#[test] #[test]
@@ -458,20 +541,28 @@ fn openai_chat_to_gemini_cli_stream_rewriter_converts_via_standard_matrix() {
}); });
let mut rewriter = let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist"); maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let output = rewriter let first = rewriter
.push_chunk(
"data: {\"id\":\"chatcmpl_std_gemini_cli_123\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hello Gemini CLI\"},\"finish_reason\":null}]}\n\n"
.as_bytes(),
)
.expect("rewrite should succeed");
let first_text = utf8(first);
assert!(first_text.contains("\"responseId\":\"chatcmpl_std_gemini_cli_123\""));
assert!(first_text.contains("\"candidates\""));
assert!(first_text.contains("\"text\":\"Hello Gemini CLI\""));
let second = rewriter
.push_chunk( .push_chunk(
concat!( concat!(
"data: {\"id\":\"chatcmpl_std_gemini_cli_123\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hello Gemini CLI\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl_std_gemini_cli_123\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":3,\"total_tokens\":5}}\n\n", "data: {\"id\":\"chatcmpl_std_gemini_cli_123\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":3,\"total_tokens\":5}}\n\n",
"data: [DONE]\n\n" "data: [DONE]\n\n"
) )
.as_bytes(), .as_bytes(),
) )
.expect("rewrite should succeed"); .expect("rewrite should succeed");
assert!(output.is_empty()); let output_text = utf8(second);
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed")) assert!(output_text.contains("\"finishReason\":\"STOP\""));
.expect("utf8 should decode"); assert!(output_text.contains("\"totalTokenCount\":5"));
assert!(output_text.contains("\"responseId\":\"chatcmpl_std_gemini_cli_123\"")); assert!(rewriter.finish().expect("finish should succeed").is_empty());
assert!(output_text.contains("\"candidates\""));
assert!(output_text.contains("\"text\":\"Hello Gemini CLI\""));
} }

View File

@@ -10,7 +10,8 @@ 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::gateway::{GatewayControlDecision, GatewaySyncReportRequest}; use crate::control::GatewayControlDecision;
use crate::usage::GatewaySyncReportRequest;
fn test_decision() -> GatewayControlDecision { fn test_decision() -> GatewayControlDecision {
GatewayControlDecision { GatewayControlDecision {

View File

@@ -1,8 +1,8 @@
use tracing::warn; use tracing::warn;
use crate::gateway::provider_transport::resolve_transport_proxy_snapshot; use crate::provider_transport::resolve_transport_proxy_snapshot;
use crate::gateway::scheduler::GatewayMinimalCandidateSelectionCandidate; use crate::scheduler::GatewayMinimalCandidateSelectionCandidate;
use crate::gateway::AppState; use crate::AppState;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum TunnelOwnerAffinityBucket { enum TunnelOwnerAffinityBucket {
@@ -43,6 +43,8 @@ async fn resolve_candidate_tunnel_owner_affinity(
Ok(None) => return TunnelOwnerAffinityBucket::Neutral, Ok(None) => return TunnelOwnerAffinityBucket::Neutral,
Err(error) => { Err(error) => {
warn!( warn!(
event_name = "candidate_affinity_transport_load_failed",
log_type = "event",
provider_id = %candidate.provider_id, provider_id = %candidate.provider_id,
endpoint_id = %candidate.endpoint_id, endpoint_id = %candidate.endpoint_id,
key_id = %candidate.key_id, key_id = %candidate.key_id,
@@ -84,6 +86,8 @@ async fn resolve_candidate_tunnel_owner_affinity(
Ok(None) => TunnelOwnerAffinityBucket::Neutral, Ok(None) => TunnelOwnerAffinityBucket::Neutral,
Err(error) => { Err(error) => {
warn!( warn!(
event_name = "candidate_affinity_tunnel_owner_lookup_failed",
log_type = "event",
node_id = node_id, node_id = node_id,
error = %error, error = %error,
"failed to load tunnel attachment owner while evaluating scheduler affinity" "failed to load tunnel attachment owner while evaluating scheduler affinity"
@@ -104,10 +108,10 @@ mod tests {
use serde_json::json; use serde_json::json;
use super::{ use super::{
prefer_local_tunnel_owner_candidates, GatewayMinimalCandidateSelectionCandidate, AppState, prefer_local_tunnel_owner_candidates, AppState, GatewayMinimalCandidateSelectionCandidate,
}; };
use crate::gateway::tunnel::TunnelAttachmentRecord; use crate::data::GatewayDataState;
use crate::gateway::GatewayDataState; use crate::tunnel::TunnelAttachmentRecord;
fn sample_candidate( fn sample_candidate(
endpoint_id: &str, endpoint_id: &str,

View File

@@ -1,7 +1,7 @@
use axum::body::Bytes; use axum::body::Bytes;
use base64::Engine as _; use base64::Engine as _;
pub(crate) use crate::gateway::ai_pipeline::contracts::{ pub(crate) use crate::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,7 +15,7 @@ pub(crate) use crate::gateway::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::gateway::headers::is_json_request; 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,

View File

@@ -1,4 +1,4 @@
use crate::gateway::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,
GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND, GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND, GEMINI_CLI_STREAM_PLAN_KIND,
@@ -10,7 +10,7 @@ use crate::gateway::ai_pipeline::planner::common::{
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::gateway::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_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,
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,
@@ -18,9 +18,10 @@ use crate::gateway::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::gateway::{ use crate::control::GatewayControlAuthContext;
AppState, GatewayControlAuthContext, GatewayControlDecision, GatewayControlPlanResponse, use crate::control::GatewayControlDecision;
GatewayControlSyncDecisionResponse, GatewayError, use crate::{
AppState, GatewayControlPlanResponse, GatewayControlSyncDecisionResponse, GatewayError,
}; };
pub(crate) async fn maybe_build_sync_plan_payload_impl( pub(crate) async fn maybe_build_sync_plan_payload_impl(

View File

@@ -5,18 +5,25 @@ mod sync;
pub(crate) use self::control_plan::{ pub(crate) use self::control_plan::{
maybe_build_stream_plan_payload_impl, maybe_build_sync_plan_payload_impl, maybe_build_stream_plan_payload_impl, maybe_build_sync_plan_payload_impl,
}; };
pub(crate) use self::stream::maybe_build_stream_decision_payload_impl as maybe_build_stream_decision_payload; pub(crate) use self::stream::maybe_build_stream_decision_payload;
pub(crate) use self::sync::maybe_build_sync_decision_payload_impl as maybe_build_sync_decision_payload; pub(crate) use self::sync::maybe_build_sync_decision_payload;
pub(crate) use super::{ pub(crate) use super::passthrough::{
maybe_build_stream_local_decision_payload,
maybe_build_stream_local_gemini_files_decision_payload,
maybe_build_stream_local_openai_cli_decision_payload,
maybe_build_stream_local_same_format_provider_decision_payload, maybe_build_stream_local_same_format_provider_decision_payload,
maybe_build_stream_local_standard_decision_payload, maybe_build_sync_local_decision_payload,
maybe_build_sync_local_gemini_files_decision_payload,
maybe_build_sync_local_openai_cli_decision_payload,
maybe_build_sync_local_same_format_provider_decision_payload, maybe_build_sync_local_same_format_provider_decision_payload,
maybe_build_sync_local_standard_decision_payload, };
maybe_build_sync_local_video_decision_payload, resolve_stream_plan_kind, pub(crate) use super::specialized::{
resolve_sync_plan_kind, maybe_build_stream_local_gemini_files_decision_payload,
maybe_build_sync_local_gemini_files_decision_payload,
maybe_build_sync_local_video_decision_payload,
};
pub(crate) use super::standard::{
maybe_build_stream_local_decision_payload,
maybe_build_stream_local_openai_cli_decision_payload,
maybe_build_stream_local_standard_decision_payload, maybe_build_sync_local_decision_payload,
maybe_build_sync_local_openai_cli_decision_payload,
maybe_build_sync_local_standard_decision_payload,
};
pub(crate) use crate::scheduler::{
resolve_execution_runtime_stream_plan_kind as resolve_stream_plan_kind,
resolve_execution_runtime_sync_plan_kind as resolve_sync_plan_kind,
}; };

View File

@@ -1,16 +1,16 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use crate::gateway::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::gateway::scheduler::{ use crate::control::GatewayControlDecision;
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::gateway::{ use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError,
};
pub(crate) async fn maybe_build_stream_decision_payload_impl( pub(crate) async fn maybe_build_stream_decision_payload(
state: &AppState, state: &AppState,
parts: &http::request::Parts, parts: &http::request::Parts,
trace_id: &str, trace_id: &str,
@@ -101,20 +101,20 @@ async fn maybe_build_local_video_task_content_stream_decision_payload(
return Ok(None); return Ok(None);
}; };
let crate::gateway::video_tasks::LocalVideoTaskContentAction::StreamPlan(plan) = action else { let crate::video_tasks::LocalVideoTaskContentAction::StreamPlan(plan) = action else {
return Ok(None); return Ok(None);
}; };
let provider_contract = plan.provider_api_format.clone(); let provider_contract = plan.provider_api_format.clone();
let client_contract = plan.client_api_format.clone(); let client_contract = plan.client_api_format.clone();
let execution_strategy = if plan.provider_api_format == plan.client_api_format { let execution_strategy = if plan.provider_api_format == plan.client_api_format {
crate::gateway::ExecutionStrategy::LocalSameFormat ExecutionStrategy::LocalSameFormat
} else { } else {
crate::gateway::ExecutionStrategy::LocalCrossFormat ExecutionStrategy::LocalCrossFormat
}; };
let conversion_mode = if plan.provider_api_format == plan.client_api_format { let conversion_mode = if plan.provider_api_format == plan.client_api_format {
crate::gateway::ConversionMode::None ConversionMode::None
} else { } else {
crate::gateway::ConversionMode::Bidirectional ConversionMode::Bidirectional
}; };
Ok(Some(GatewayControlSyncDecisionResponse { Ok(Some(GatewayControlSyncDecisionResponse {

View File

@@ -2,19 +2,19 @@ use std::collections::BTreeMap;
use url::Url; use url::Url;
use crate::gateway::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::gateway::scheduler::resolve_execution_runtime_sync_plan_kind; use crate::control::resolve_execution_runtime_auth_context;
use crate::gateway::{ use crate::control::GatewayControlDecision;
resolve_execution_runtime_auth_context, AppState, GatewayControlDecision, use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
GatewayControlSyncDecisionResponse, GatewayError, use crate::scheduler::resolve_execution_runtime_sync_plan_kind;
}; use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
pub(crate) async fn maybe_build_sync_decision_payload_impl( pub(crate) async fn maybe_build_sync_decision_payload(
state: &AppState, state: &AppState,
parts: &http::request::Parts, parts: &http::request::Parts,
trace_id: &str, trace_id: &str,
@@ -144,15 +144,15 @@ async fn maybe_build_local_video_task_follow_up_sync_decision_payload(
let auth_pair = extract_auth_header_pair(&follow_up.plan.headers); let auth_pair = extract_auth_header_pair(&follow_up.plan.headers);
let execution_strategy = let execution_strategy =
if follow_up.plan.provider_api_format == follow_up.plan.client_api_format { if follow_up.plan.provider_api_format == follow_up.plan.client_api_format {
crate::gateway::ExecutionStrategy::LocalSameFormat ExecutionStrategy::LocalSameFormat
} else { } else {
crate::gateway::ExecutionStrategy::LocalCrossFormat ExecutionStrategy::LocalCrossFormat
}; };
let conversion_mode = if follow_up.plan.provider_api_format == follow_up.plan.client_api_format let conversion_mode = if follow_up.plan.provider_api_format == follow_up.plan.client_api_format
{ {
crate::gateway::ConversionMode::None ConversionMode::None
} else { } else {
crate::gateway::ConversionMode::Bidirectional ConversionMode::Bidirectional
}; };
Ok(Some(GatewayControlSyncDecisionResponse { Ok(Some(GatewayControlSyncDecisionResponse {

View File

@@ -1,4 +1,8 @@
use crate::gateway::{AppState, GatewayControlDecision, GatewayError}; use crate::ai_pipeline::contracts::{
GatewayControlPlanResponse, GatewayControlSyncDecisionResponse,
};
use crate::control::GatewayControlDecision;
use crate::{AppState, GatewayError};
pub(crate) mod candidate_affinity; pub(crate) mod candidate_affinity;
pub(crate) mod common; pub(crate) mod common;
@@ -8,55 +12,6 @@ pub(crate) mod plan_builders;
pub(crate) mod specialized; pub(crate) mod specialized;
pub(crate) mod standard; pub(crate) mod standard;
pub(crate) use self::candidate_affinity::prefer_local_tunnel_owner_candidates;
pub(crate) use self::common::{
parse_direct_request_body, 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_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_ACTION,
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, 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 crate::gateway::ai_pipeline::contracts::{
build_gateway_plan_request, generic_decision_missing_exact_provider_request,
GatewayControlPlanRequest, GatewayControlPlanResponse, GatewayControlSyncDecisionResponse,
};
pub(crate) use crate::gateway::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, extract_openai_text_content,
normalize_openai_cli_request_to_openai_chat_request, parse_openai_tool_result_content,
};
pub(crate) use crate::gateway::scheduler::{
is_matching_stream_request,
resolve_execution_runtime_stream_plan_kind as resolve_stream_plan_kind,
resolve_execution_runtime_sync_plan_kind as resolve_sync_plan_kind,
};
pub(crate) use passthrough::{
maybe_build_stream_local_same_format_provider_decision_payload,
maybe_build_sync_local_same_format_provider_decision_payload,
};
pub(crate) use specialized::{
maybe_build_stream_local_gemini_files_decision_payload,
maybe_build_sync_local_gemini_files_decision_payload,
maybe_build_sync_local_video_decision_payload,
};
pub(crate) use standard::{
copy_request_number_field, copy_request_number_field_as,
map_openai_reasoning_effort_to_claude_output, map_openai_reasoning_effort_to_gemini_budget,
maybe_build_stream_local_decision_payload,
maybe_build_stream_local_openai_cli_decision_payload,
maybe_build_stream_local_standard_decision_payload, maybe_build_sync_local_decision_payload,
maybe_build_sync_local_openai_cli_decision_payload,
maybe_build_sync_local_standard_decision_payload, parse_openai_stop_sequences,
resolve_openai_chat_max_tokens, value_as_u64,
};
pub(crate) async fn maybe_build_sync_decision_payload( pub(crate) async fn maybe_build_sync_decision_payload(
state: &AppState, state: &AppState,
parts: &http::request::Parts, parts: &http::request::Parts,

View File

@@ -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::gateway::provider_transport::provider_type_supports_local_same_format_transport; pub(crate) use crate::provider_transport::provider_types::provider_type_supports_local_same_format_transport;

View File

@@ -1,7 +1,7 @@
use aether_contracts::{ExecutionPlan, RequestBody}; use aether_contracts::{ExecutionPlan, RequestBody};
use super::{augment_sync_report_context, LocalStreamPlanAndReport, LocalSyncPlanAndReport}; use super::{augment_sync_report_context, LocalStreamPlanAndReport, LocalSyncPlanAndReport};
use crate::gateway::{GatewayControlSyncDecisionResponse, GatewayError}; use crate::{GatewayControlSyncDecisionResponse, GatewayError};
pub(crate) fn build_passthrough_sync_plan_from_decision( pub(crate) fn build_passthrough_sync_plan_from_decision(
parts: &http::request::Parts, parts: &http::request::Parts,

View File

@@ -8,41 +8,57 @@ use serde_json::{json, Value};
use tracing::warn; use tracing::warn;
use uuid::Uuid; use uuid::Uuid;
use crate::gateway::ai_pipeline::planner::plan_builders::{ use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
LocalStreamPlanAndReport, LocalSyncPlanAndReport, use crate::ai_pipeline::planner::common::{
};
use crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates;
use crate::gateway::ai_pipeline::planner::{
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION, EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
}; };
use crate::gateway::headers::collect_control_headers; use crate::ai_pipeline::planner::plan_builders::{
use crate::gateway::provider_transport::{ LocalStreamPlanAndReport, LocalSyncPlanAndReport,
apply_local_body_rules, apply_local_header_rules, build_antigravity_safe_v1internal_request,
build_antigravity_static_identity_headers, build_antigravity_v1internal_url,
build_claude_code_messages_url, build_claude_code_passthrough_headers,
build_claude_messages_url, build_gemini_content_url,
build_kiro_generate_assistant_response_url, build_kiro_provider_headers,
build_kiro_provider_request_body, build_openai_passthrough_headers, build_passthrough_headers,
build_passthrough_path_url, build_vertex_api_key_gemini_content_url,
classify_local_antigravity_request_support, ensure_upstream_auth_header,
resolve_local_gemini_auth, resolve_local_standard_auth,
resolve_local_vertex_api_key_query_auth, resolve_transport_execution_timeouts,
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
sanitize_claude_code_request_body, supports_local_claude_code_transport_with_network,
supports_local_gemini_transport_with_network,
supports_local_kiro_request_transport_with_network,
supports_local_standard_transport_with_network,
supports_local_vertex_api_key_gemini_transport_with_network, AntigravityEnvelopeRequestType,
AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport, AntigravityRequestUrlAction,
LocalResolvedOAuthRequestAuth, KIRO_ENVELOPE_NAME,
}; };
use crate::gateway::scheduler::{ use crate::control::GatewayControlDecision;
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_v1internal_url, classify_local_antigravity_request_support,
AntigravityEnvelopeRequestType, AntigravityRequestEnvelopeSupport,
AntigravityRequestSideSupport, AntigravityRequestUrlAction,
};
use crate::provider_transport::auth::{
build_openai_passthrough_headers, resolve_local_gemini_auth, resolve_local_standard_auth,
};
use crate::provider_transport::claude_code::{
build_claude_code_messages_url, build_claude_code_passthrough_headers,
sanitize_claude_code_request_body, supports_local_claude_code_transport_with_network,
};
use crate::provider_transport::kiro::{
build_kiro_generate_assistant_response_url, build_kiro_provider_headers,
build_kiro_provider_request_body, supports_local_kiro_request_transport_with_network,
KIRO_ENVELOPE_NAME,
};
use crate::provider_transport::policy::{
supports_local_gemini_transport_with_network, supports_local_standard_transport_with_network,
};
use crate::provider_transport::url::{
build_claude_messages_url, build_gemini_content_url, build_passthrough_path_url,
};
use crate::provider_transport::vertex::{
build_vertex_api_key_gemini_content_url, resolve_local_vertex_api_key_query_auth,
supports_local_vertex_api_key_gemini_transport_with_network,
};
use crate::provider_transport::{
apply_local_body_rules, apply_local_header_rules, build_passthrough_headers,
ensure_upstream_auth_header, resolve_transport_execution_timeouts,
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, current_unix_secs, list_selectable_candidates, record_local_request_candidate_status,
GatewayMinimalCandidateSelectionCandidate, GatewayMinimalCandidateSelectionCandidate,
}; };
use crate::gateway::{ use crate::{
append_execution_contract_fields_to_value, AppState, ConversionMode, ExecutionStrategy, append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse,
GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError, GatewayError,
}; };
pub(crate) mod family; pub(crate) mod family;

View File

@@ -1,6 +1,5 @@
use crate::gateway::{ use crate::control::GatewayControlDecision;
AppState, GatewayControlDecision, 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};
use super::candidates::{ use super::candidates::{

View File

@@ -3,11 +3,11 @@ use serde_json::json;
use tracing::warn; use tracing::warn;
use uuid::Uuid; use uuid::Uuid;
use crate::gateway::scheduler::{current_unix_secs, list_selectable_candidates}; use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
use crate::gateway::{ use crate::control::GatewayControlDecision;
append_execution_contract_fields_to_value, AppState, ConversionMode, ExecutionStrategy, use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
GatewayControlDecision, GatewayError, use crate::scheduler::{current_unix_secs, list_selectable_candidates};
}; use crate::{append_execution_contract_fields_to_value, AppState, GatewayError};
use super::types::{ use super::types::{
LocalSameFormatProviderCandidateAttempt, LocalSameFormatProviderDecisionInput, LocalSameFormatProviderCandidateAttempt, LocalSameFormatProviderDecisionInput,
@@ -83,10 +83,7 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
current_unix_secs(), current_unix_secs(),
) )
.await?; .await?;
let candidates = crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates( let candidates = prefer_local_tunnel_owner_candidates(state, candidates).await;
state, candidates,
)
.await;
let created_at_unix_secs = current_unix_secs(); let created_at_unix_secs = current_unix_secs();
let mut attempts = Vec::with_capacity(candidates.len()); let mut attempts = Vec::with_capacity(candidates.len());

View File

@@ -4,28 +4,39 @@ use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestC
use serde_json::json; use serde_json::json;
use tracing::warn; use tracing::warn;
use crate::gateway::headers::collect_control_headers; use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
use crate::gateway::provider_transport::{ use crate::headers::collect_control_headers;
apply_local_header_rules, build_antigravity_safe_v1internal_request, use crate::provider_transport::antigravity::{
build_antigravity_static_identity_headers, build_claude_code_passthrough_headers, build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
build_openai_passthrough_headers, build_passthrough_headers, classify_local_antigravity_request_support, AntigravityEnvelopeRequestType,
classify_local_antigravity_request_support, ensure_upstream_auth_header,
resolve_local_gemini_auth, resolve_local_standard_auth,
resolve_local_vertex_api_key_query_auth, resolve_transport_execution_timeouts,
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
supports_local_claude_code_transport_with_network,
supports_local_gemini_transport_with_network,
supports_local_kiro_request_transport_with_network,
supports_local_standard_transport_with_network,
supports_local_vertex_api_key_gemini_transport_with_network, AntigravityEnvelopeRequestType,
AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport, AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport,
LocalResolvedOAuthRequestAuth, KIRO_ENVELOPE_NAME,
}; };
use crate::gateway::scheduler::{current_unix_secs, GatewayMinimalCandidateSelectionCandidate}; use crate::provider_transport::auth::{
use crate::gateway::{ build_openai_passthrough_headers, resolve_local_gemini_auth, resolve_local_standard_auth,
append_execution_contract_fields_to_value, AppState, ConversionMode, ExecutionStrategy, };
GatewayControlSyncDecisionResponse, EXECUTION_RUNTIME_STREAM_DECISION_ACTION, use crate::provider_transport::claude_code::{
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, build_claude_code_passthrough_headers, supports_local_claude_code_transport_with_network,
};
use crate::provider_transport::kiro::{
build_kiro_provider_headers, supports_local_kiro_request_transport_with_network,
KIRO_ENVELOPE_NAME,
};
use crate::provider_transport::policy::{
supports_local_gemini_transport_with_network, supports_local_standard_transport_with_network,
};
use crate::provider_transport::vertex::{
resolve_local_vertex_api_key_query_auth,
supports_local_vertex_api_key_gemini_transport_with_network,
};
use crate::provider_transport::{
apply_local_header_rules, build_passthrough_headers, ensure_upstream_auth_header,
resolve_transport_execution_timeouts, resolve_transport_proxy_snapshot_with_tunnel_affinity,
resolve_transport_tls_profile, LocalResolvedOAuthRequestAuth,
};
use crate::scheduler::{current_unix_secs, GatewayMinimalCandidateSelectionCandidate};
use crate::{
append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse,
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
}; };
use super::types::{ use super::types::{
@@ -351,7 +362,7 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
}; };
let Some(provider_request_headers) = (if let Some(kiro_auth) = kiro_auth.as_ref() { let Some(provider_request_headers) = (if let Some(kiro_auth) = kiro_auth.as_ref() {
crate::gateway::provider_transport::build_kiro_provider_headers( build_kiro_provider_headers(
&parts.headers, &parts.headers,
&provider_request_body, &provider_request_body,
body_json, body_json,
@@ -445,6 +456,7 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
"provider_id": candidate.provider_id, "provider_id": candidate.provider_id,
"endpoint_id": candidate.endpoint_id, "endpoint_id": candidate.endpoint_id,
"key_id": candidate.key_id, "key_id": candidate.key_id,
"key_name": candidate.key_name,
"provider_api_format": spec.api_format, "provider_api_format": spec.api_format,
"client_api_format": spec.api_format, "client_api_format": spec.api_format,
"mapped_model": mapped_model, "mapped_model": mapped_model,

View File

@@ -15,14 +15,14 @@ pub(crate) struct LocalSameFormatProviderSpec {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct LocalSameFormatProviderDecisionInput { pub(crate) struct LocalSameFormatProviderDecisionInput {
pub(crate) auth_context: crate::gateway::GatewayControlAuthContext, pub(crate) auth_context: crate::control::GatewayControlAuthContext,
pub(crate) requested_model: String, pub(crate) requested_model: String,
pub(crate) auth_snapshot: crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot, pub(crate) auth_snapshot: crate::data::auth::GatewayAuthApiKeySnapshot,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct LocalSameFormatProviderCandidateAttempt { pub(crate) struct LocalSameFormatProviderCandidateAttempt {
pub(crate) candidate: crate::gateway::scheduler::GatewayMinimalCandidateSelectionCandidate, pub(crate) candidate: crate::scheduler::GatewayMinimalCandidateSelectionCandidate,
pub(crate) candidate_index: u32, pub(crate) candidate_index: u32,
pub(crate) candidate_id: String, pub(crate) candidate_id: String,
} }

View File

@@ -7,15 +7,15 @@ use super::{
GatewayError, LocalSameFormatProviderFamily, LocalSameFormatProviderSpec, GatewayError, LocalSameFormatProviderFamily, LocalSameFormatProviderSpec,
LocalStreamPlanAndReport, LocalSyncPlanAndReport, LocalStreamPlanAndReport, LocalSyncPlanAndReport,
}; };
use crate::gateway::ai_pipeline::planner::plan_builders::{ use crate::ai_pipeline::planner::common::{
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
build_standard_stream_plan_from_decision, build_standard_sync_plan_from_decision,
};
use crate::gateway::ai_pipeline::planner::{
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, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_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_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
}; };
use crate::ai_pipeline::planner::plan_builders::{
build_gemini_stream_plan_from_decision, build_gemini_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> { pub(crate) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalSameFormatProviderSpec> {
match plan_kind { match plan_kind {

View File

@@ -18,7 +18,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::gateway::provider_transport::KiroRequestAuth>, kiro_auth: Option<&crate::provider_transport::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 +60,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::gateway::provider_transport::GatewayProviderTransportSnapshot, transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
mapped_model: &str, mapped_model: &str,
spec: LocalSameFormatProviderSpec, spec: LocalSameFormatProviderSpec,
upstream_is_stream: bool, upstream_is_stream: bool,
kiro_auth: Option<&crate::gateway::provider_transport::KiroRequestAuth>, kiro_auth: Option<&crate::provider_transport::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(

View File

@@ -2,8 +2,8 @@ use std::collections::BTreeMap;
use aether_contracts::ExecutionPlan; use aether_contracts::ExecutionPlan;
pub(crate) use crate::gateway::ai_pipeline::planner::generic_decision_missing_exact_provider_request; pub(crate) use crate::ai_pipeline::contracts::generic_decision_missing_exact_provider_request;
use crate::gateway::{GatewayControlSyncDecisionResponse, GatewayError}; use crate::{GatewayControlSyncDecisionResponse, GatewayError};
pub(crate) struct LocalSyncPlanAndReport { pub(crate) struct LocalSyncPlanAndReport {
pub(crate) plan: ExecutionPlan, pub(crate) plan: ExecutionPlan,

View File

@@ -5,30 +5,32 @@ use serde_json::json;
use tracing::warn; use tracing::warn;
use uuid::Uuid; use uuid::Uuid;
use crate::gateway::ai_pipeline::planner::plan_builders::{ use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
build_passthrough_stream_plan_from_decision, build_passthrough_sync_plan_from_decision, use crate::ai_pipeline::planner::common::{
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates;
use crate::gateway::ai_pipeline::planner::{
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_DELETE_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND,
GEMINI_FILES_LIST_PLAN_KIND, GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND, GEMINI_FILES_UPLOAD_PLAN_KIND,
}; };
use crate::gateway::headers::collect_control_headers; use crate::ai_pipeline::planner::plan_builders::{
use crate::gateway::provider_transport::{ build_passthrough_stream_plan_from_decision, build_passthrough_sync_plan_from_decision,
apply_local_body_rules, apply_local_header_rules, build_gemini_files_passthrough_url, LocalStreamPlanAndReport, LocalSyncPlanAndReport,
build_passthrough_headers_with_auth, resolve_local_gemini_auth,
resolve_transport_execution_timeouts, resolve_transport_proxy_snapshot_with_tunnel_affinity,
resolve_transport_tls_profile, supports_local_gemini_transport_with_network,
}; };
use crate::gateway::scheduler::{ use crate::control::GatewayControlDecision;
use crate::headers::collect_control_headers;
use crate::provider_transport::auth::{
build_passthrough_headers_with_auth, resolve_local_gemini_auth,
};
use crate::provider_transport::policy::supports_local_gemini_transport_with_network;
use crate::provider_transport::url::build_gemini_files_passthrough_url;
use crate::provider_transport::{
apply_local_body_rules, apply_local_header_rules, resolve_transport_execution_timeouts,
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
};
use crate::scheduler::{
current_unix_secs, list_selectable_candidates_for_required_capability_without_requested_model, current_unix_secs, list_selectable_candidates_for_required_capability_without_requested_model,
record_local_request_candidate_status, GatewayMinimalCandidateSelectionCandidate, record_local_request_candidate_status, GatewayMinimalCandidateSelectionCandidate,
}; };
use crate::gateway::{ use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError,
};
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";
@@ -43,8 +45,8 @@ struct LocalGeminiFilesSpec {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct LocalGeminiFilesDecisionInput { struct LocalGeminiFilesDecisionInput {
auth_context: crate::gateway::GatewayControlAuthContext, auth_context: crate::control::GatewayControlAuthContext,
auth_snapshot: crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot, auth_snapshot: crate::data::auth::GatewayAuthApiKeySnapshot,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -663,11 +665,15 @@ async fn maybe_build_local_gemini_files_decision_payload_for_candidate(
}, },
decision_kind: Some(spec.decision_kind.to_string()), decision_kind: Some(spec.decision_kind.to_string()),
execution_strategy: Some( execution_strategy: Some(
crate::gateway::ExecutionStrategy::LocalSameFormat crate::execution_runtime::ExecutionStrategy::LocalSameFormat
.as_str()
.to_string(),
),
conversion_mode: Some(
crate::execution_runtime::ConversionMode::None
.as_str() .as_str()
.to_string(), .to_string(),
), ),
conversion_mode: Some(crate::gateway::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()),

View File

@@ -5,29 +5,34 @@ use serde_json::{json, Value};
use tracing::warn; use tracing::warn;
use uuid::Uuid; use uuid::Uuid;
use crate::gateway::ai_pipeline::planner::plan_builders::{ use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
build_passthrough_sync_plan_from_decision, LocalSyncPlanAndReport, use crate::ai_pipeline::planner::common::{
};
use crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates;
use crate::gateway::ai_pipeline::planner::{
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND,
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
}; };
use crate::gateway::headers::collect_control_headers; use crate::ai_pipeline::planner::plan_builders::{
use crate::gateway::provider_transport::{ build_passthrough_sync_plan_from_decision, LocalSyncPlanAndReport,
apply_local_body_rules, apply_local_header_rules, build_gemini_video_predict_long_running_url, };
build_passthrough_headers_with_auth, build_passthrough_path_url, resolve_local_gemini_auth, use crate::control::GatewayControlDecision;
resolve_local_openai_chat_auth, resolve_transport_execution_timeouts, use crate::headers::collect_control_headers;
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile, use crate::provider_transport::auth::{
build_passthrough_headers_with_auth, resolve_local_gemini_auth, resolve_local_openai_chat_auth,
};
use crate::provider_transport::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::gateway::scheduler::{ use crate::provider_transport::url::{
build_gemini_video_predict_long_running_url, build_passthrough_path_url,
};
use crate::provider_transport::{
apply_local_body_rules, apply_local_header_rules, resolve_transport_execution_timeouts,
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
};
use crate::scheduler::{
current_unix_secs, list_selectable_candidates, record_local_request_candidate_status, current_unix_secs, list_selectable_candidates, record_local_request_candidate_status,
GatewayMinimalCandidateSelectionCandidate, GatewayMinimalCandidateSelectionCandidate,
}; };
use crate::gateway::{ use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LocalVideoCreateFamily { enum LocalVideoCreateFamily {
@@ -45,9 +50,9 @@ struct LocalVideoCreateSpec {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct LocalVideoCreateDecisionInput { struct LocalVideoCreateDecisionInput {
auth_context: crate::gateway::GatewayControlAuthContext, auth_context: crate::control::GatewayControlAuthContext,
requested_model: String, requested_model: String,
auth_snapshot: crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot, auth_snapshot: crate::data::auth::GatewayAuthApiKeySnapshot,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -457,11 +462,15 @@ async fn maybe_build_local_video_create_decision_payload_for_candidate(
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(
crate::gateway::ExecutionStrategy::LocalSameFormat crate::execution_runtime::ExecutionStrategy::LocalSameFormat
.as_str()
.to_string(),
),
conversion_mode: Some(
crate::execution_runtime::ConversionMode::None
.as_str() .as_str()
.to_string(), .to_string(),
), ),
conversion_mode: Some(crate::gateway::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()),
@@ -543,7 +552,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::gateway::provider_transport::GatewayProviderTransportSnapshot, transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
mapped_model: &str, mapped_model: &str,
family: LocalVideoCreateFamily, family: LocalVideoCreateFamily,
) -> Option<String> { ) -> Option<String> {

View File

@@ -1,4 +1,4 @@
use crate::gateway::ai_pipeline::planner::{ use crate::ai_pipeline::planner::common::{
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND,
}; };

View File

@@ -1,4 +1,4 @@
use crate::gateway::ai_pipeline::planner::{ use crate::ai_pipeline::planner::common::{
CLAUDE_CLI_STREAM_PLAN_KIND, CLAUDE_CLI_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND, CLAUDE_CLI_SYNC_PLAN_KIND,
}; };

View File

@@ -1,11 +1,10 @@
use crate::gateway::{ use crate::control::GatewayControlDecision;
AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError, use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
};
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::gateway::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) mod chat;
pub(crate) mod cli; pub(crate) mod cli;

View File

@@ -1,13 +1,12 @@
use tracing::warn; use tracing::warn;
use crate::gateway::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::gateway::{ use crate::control::GatewayControlDecision;
AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError, use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
};
use super::candidates::{ use super::candidates::{
materialize_local_standard_candidate_attempts, resolve_local_standard_decision_input, materialize_local_standard_candidate_attempts, resolve_local_standard_decision_input,

View File

@@ -5,13 +5,13 @@ use serde_json::json;
use tracing::warn; use tracing::warn;
use uuid::Uuid; use uuid::Uuid;
use crate::gateway::scheduler::{ use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
use crate::control::GatewayControlDecision;
use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
use crate::scheduler::{
current_unix_secs, list_selectable_candidates, GatewayMinimalCandidateSelectionCandidate, current_unix_secs, list_selectable_candidates, GatewayMinimalCandidateSelectionCandidate,
}; };
use crate::gateway::{ use crate::{append_execution_contract_fields_to_value, AppState, GatewayError};
append_execution_contract_fields_to_value, AppState, ConversionMode, ExecutionStrategy,
GatewayControlDecision, GatewayError,
};
use super::types::{ use super::types::{
LocalStandardCandidateAttempt, LocalStandardDecisionInput, LocalStandardSourceFamily, LocalStandardCandidateAttempt, LocalStandardDecisionInput, LocalStandardSourceFamily,
@@ -117,10 +117,7 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
} }
} }
} }
let candidates = crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates( let candidates = prefer_local_tunnel_owner_candidates(state, candidates).await;
state, candidates,
)
.await;
let created_at_unix_secs = current_unix_secs(); let created_at_unix_secs = current_unix_secs();
let mut attempts = Vec::with_capacity(candidates.len()); let mut attempts = Vec::with_capacity(candidates.len());
@@ -132,7 +129,7 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
} else { } else {
ExecutionStrategy::LocalCrossFormat ExecutionStrategy::LocalCrossFormat
}; };
let conversion_mode = if crate::gateway::ai_pipeline::conversion::request_conversion_kind( let conversion_mode = if crate::ai_pipeline::conversion::request_conversion_kind(
spec.api_format, spec.api_format,
provider_api_format.as_str(), provider_api_format.as_str(),
) )
@@ -213,7 +210,7 @@ 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::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot, auth_snapshot: &crate::data::auth::GatewayAuthApiKeySnapshot,
requested_model: &str, requested_model: &str,
candidate: &GatewayMinimalCandidateSelectionCandidate, candidate: &GatewayMinimalCandidateSelectionCandidate,
) -> bool { ) -> bool {

View File

@@ -4,17 +4,20 @@ use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestC
use serde_json::json; use serde_json::json;
use tracing::warn; use tracing::warn;
use crate::gateway::headers::collect_control_headers; use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
use crate::gateway::provider_transport::{ use crate::headers::collect_control_headers;
apply_local_header_rules, build_openai_passthrough_headers, ensure_upstream_auth_header, use crate::provider_transport::auth::{
resolve_transport_execution_timeouts, resolve_transport_proxy_snapshot_with_tunnel_affinity, build_openai_passthrough_headers, ensure_upstream_auth_header,
resolve_transport_tls_profile, LocalResolvedOAuthRequestAuth,
}; };
use crate::gateway::scheduler::current_unix_secs; use crate::provider_transport::{
use crate::gateway::{ apply_local_header_rules, resolve_transport_execution_timeouts,
append_execution_contract_fields_to_value, AppState, ConversionMode, ExecutionStrategy, resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
GatewayControlSyncDecisionResponse, EXECUTION_RUNTIME_STREAM_DECISION_ACTION, LocalResolvedOAuthRequestAuth,
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, };
use crate::scheduler::current_unix_secs;
use crate::{
append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse,
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
}; };
use super::types::{LocalStandardCandidateAttempt, LocalStandardDecisionInput, LocalStandardSpec}; use super::types::{LocalStandardCandidateAttempt, LocalStandardDecisionInput, LocalStandardSpec};
@@ -34,7 +37,7 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
candidate_id, candidate_id,
} = 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 Some(conversion_kind) = crate::gateway::ai_pipeline::conversion::request_conversion_kind( let Some(conversion_kind) = crate::ai_pipeline::conversion::request_conversion_kind(
spec.api_format, spec.api_format,
provider_api_format.as_str(), provider_api_format.as_str(),
) else { ) else {
@@ -87,7 +90,7 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
} }
}; };
if !crate::gateway::ai_pipeline::conversion::request_conversion_transport_supported( if !crate::ai_pipeline::conversion::request_conversion_transport_supported(
&transport, &transport,
conversion_kind, conversion_kind,
) { ) {
@@ -104,7 +107,7 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
return None; return None;
} }
let resolved_auth = crate::gateway::ai_pipeline::conversion::request_conversion_direct_auth( let resolved_auth = crate::ai_pipeline::conversion::request_conversion_direct_auth(
&transport, &transport,
conversion_kind, conversion_kind,
); );
@@ -158,7 +161,7 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
} }
let provider_request_body = let provider_request_body =
match crate::gateway::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,
@@ -184,7 +187,7 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
}; };
let upstream_url = let upstream_url =
match crate::gateway::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,
@@ -290,6 +293,7 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
"provider_id": candidate.provider_id, "provider_id": candidate.provider_id,
"endpoint_id": candidate.endpoint_id, "endpoint_id": candidate.endpoint_id,
"key_id": candidate.key_id, "key_id": candidate.key_id,
"key_name": candidate.key_name,
"provider_api_format": provider_api_format, "provider_api_format": provider_api_format,
"client_api_format": spec.api_format, "client_api_format": spec.api_format,
"mapped_model": mapped_model, "mapped_model": mapped_model,
@@ -315,7 +319,7 @@ 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::gateway::scheduler::GatewayMinimalCandidateSelectionCandidate, candidate: &crate::scheduler::GatewayMinimalCandidateSelectionCandidate,
candidate_index: u32, candidate_index: u32,
candidate_id: &str, candidate_id: &str,
skip_reason: &'static str, skip_reason: &'static str,

View File

@@ -22,14 +22,14 @@ pub(crate) struct LocalStandardSpec {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(super) struct LocalStandardDecisionInput { pub(super) struct LocalStandardDecisionInput {
pub(super) auth_context: crate::gateway::GatewayControlAuthContext, pub(super) auth_context: crate::control::GatewayControlAuthContext,
pub(super) requested_model: String, pub(super) requested_model: String,
pub(super) auth_snapshot: crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot, pub(super) auth_snapshot: crate::data::auth::GatewayAuthApiKeySnapshot,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(super) struct LocalStandardCandidateAttempt { pub(super) struct LocalStandardCandidateAttempt {
pub(super) candidate: crate::gateway::scheduler::GatewayMinimalCandidateSelectionCandidate, pub(super) candidate: crate::scheduler::GatewayMinimalCandidateSelectionCandidate,
pub(super) candidate_index: u32, pub(super) candidate_index: u32,
pub(super) candidate_id: String, pub(super) candidate_id: String,
} }

View File

@@ -1,4 +1,4 @@
use crate::gateway::ai_pipeline::planner::{ use crate::ai_pipeline::planner::common::{
GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
}; };

View File

@@ -1,4 +1,4 @@
use crate::gateway::ai_pipeline::planner::{ use crate::ai_pipeline::planner::common::{
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND, GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
}; };

View File

@@ -1,11 +1,10 @@
use crate::gateway::{ use crate::control::GatewayControlDecision;
AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError, use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
};
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::gateway::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) mod chat;
pub(crate) mod cli; pub(crate) mod cli;

View File

@@ -4,8 +4,8 @@ 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::gateway::provider_transport::ensure_upstream_auth_header; use crate::provider_transport::ensure_upstream_auth_header;
use crate::gateway::{GatewayControlSyncDecisionResponse, GatewayError}; use crate::{GatewayControlSyncDecisionResponse, GatewayError};
pub(crate) fn build_gemini_sync_plan_from_decision( pub(crate) fn build_gemini_sync_plan_from_decision(
_parts: &http::request::Parts, _parts: &http::request::Parts,

View File

@@ -6,15 +6,16 @@ use super::{
claude::normalize_claude_request_to_openai_chat_request, claude::normalize_claude_request_to_openai_chat_request,
gemini::normalize_gemini_request_to_openai_chat_request, gemini::normalize_gemini_request_to_openai_chat_request,
}; };
use crate::gateway::ai_pipeline::conversion::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_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,
normalize_openai_cli_request_to_openai_chat_request, normalize_openai_cli_request_to_openai_chat_request,
}; };
use crate::gateway::ai_pipeline::conversion::{request_conversion_kind, RequestConversionKind}; use crate::ai_pipeline::conversion::{request_conversion_kind, RequestConversionKind};
use crate::gateway::provider_transport::{ use crate::provider_transport::apply_local_body_rules;
apply_local_body_rules, build_claude_messages_url, build_gemini_content_url, use crate::provider_transport::url::{
build_openai_chat_url, build_openai_cli_url, build_passthrough_path_url, build_claude_messages_url, build_gemini_content_url, build_openai_chat_url,
build_openai_cli_url, build_passthrough_path_url,
}; };
pub(crate) fn build_standard_request_body( pub(crate) fn build_standard_request_body(
@@ -72,7 +73,7 @@ pub(crate) fn build_standard_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::gateway::provider_transport::GatewayProviderTransportSnapshot, transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
mapped_model: &str, mapped_model: &str,
provider_api_format: &str, provider_api_format: &str,
upstream_is_stream: bool, upstream_is_stream: bool,

View File

@@ -3,17 +3,15 @@
//! 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::gateway::{ use crate::control::GatewayControlDecision;
AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError, use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
};
pub(crate) mod claude; pub(crate) mod claude;
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_chat; pub(crate) mod openai;
pub(crate) mod openai_cli;
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,
@@ -25,22 +23,20 @@ pub(crate) use self::normalize::{
build_local_openai_chat_request_body, build_local_openai_chat_upstream_url, build_local_openai_chat_request_body, build_local_openai_chat_upstream_url,
build_local_openai_cli_request_body, build_local_openai_cli_upstream_url, build_local_openai_cli_request_body, build_local_openai_cli_upstream_url,
}; };
pub(crate) use self::openai_chat::{ pub(crate) use self::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, maybe_build_stream_local_decision_payload,
parse_openai_stop_sequences, resolve_openai_chat_max_tokens, value_as_u64, maybe_build_stream_local_openai_cli_decision_payload, maybe_build_sync_local_decision_payload,
maybe_build_sync_local_openai_cli_decision_payload, parse_openai_stop_sequences,
resolve_openai_chat_max_tokens, value_as_u64,
}; };
pub(crate) use self::openai_cli::{ pub(crate) use crate::ai_pipeline::conversion::request::{
maybe_build_stream_local_openai_cli_decision_payload,
maybe_build_sync_local_openai_cli_decision_payload,
};
pub(crate) use crate::gateway::ai_pipeline::conversion::request::{
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, extract_openai_text_content, 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, normalize_openai_cli_request_to_openai_chat_request, parse_openai_tool_result_content,
}; };
pub(crate) use crate::gateway::ai_pipeline::conversion::{ pub(crate) use crate::ai_pipeline::conversion::{
build_core_error_body_for_client_format, request_conversion_kind, build_core_error_body_for_client_format, request_conversion_kind,
request_conversion_transport_supported, sync_chat_response_conversion_kind, request_conversion_transport_supported, sync_chat_response_conversion_kind,
sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind, sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,

View File

@@ -3,16 +3,19 @@ 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::gateway::ai_pipeline::conversion::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_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_cli_request, extract_openai_text_content, 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, normalize_openai_cli_request_to_openai_chat_request, parse_openai_tool_result_content,
}; };
use crate::gateway::ai_pipeline::conversion::{request_conversion_kind, RequestConversionKind}; use crate::ai_pipeline::conversion::{request_conversion_kind, RequestConversionKind};
use crate::gateway::provider_transport::{ use crate::provider_transport::antigravity::{
apply_local_body_rules, build_antigravity_v1internal_url, build_claude_messages_url, build_antigravity_v1internal_url, AntigravityRequestUrlAction,
build_gemini_content_url, build_openai_chat_url, build_openai_cli_url, };
build_passthrough_path_url, AntigravityRequestUrlAction, 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_openai_cli_url, build_passthrough_path_url,
}; };
pub(crate) fn build_local_openai_chat_request_body( pub(crate) fn build_local_openai_chat_request_body(
@@ -40,7 +43,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::gateway::provider_transport::GatewayProviderTransportSnapshot, transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
) -> Option<String> { ) -> Option<String> {
let custom_path = transport let custom_path = transport
.endpoint .endpoint
@@ -101,7 +104,7 @@ pub(crate) fn build_cross_format_openai_chat_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::gateway::provider_transport::GatewayProviderTransportSnapshot, transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
mapped_model: &str, mapped_model: &str,
provider_api_format: &str, provider_api_format: &str,
upstream_is_stream: bool, upstream_is_stream: bool,
@@ -214,7 +217,7 @@ pub(crate) fn build_cross_format_openai_cli_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::gateway::provider_transport::GatewayProviderTransportSnapshot, transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
compact: bool, compact: bool,
) -> Option<String> { ) -> Option<String> {
let custom_path = transport let custom_path = transport
@@ -238,7 +241,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::gateway::provider_transport::GatewayProviderTransportSnapshot, transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
mapped_model: &str, mapped_model: &str,
client_api_format: &str, client_api_format: &str,
provider_api_format: &str, provider_api_format: &str,

View File

@@ -5,43 +5,46 @@ use serde_json::json;
use tracing::warn; use tracing::warn;
use uuid::Uuid; use uuid::Uuid;
use crate::gateway::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::gateway::ai_pipeline::planner::plan_builders::{ use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
LocalStreamPlanAndReport, LocalSyncPlanAndReport, use crate::ai_pipeline::planner::common::{
};
use crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates;
use crate::gateway::ai_pipeline::planner::{
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::gateway::headers::collect_control_headers; use crate::ai_pipeline::planner::plan_builders::{
use crate::gateway::provider_transport::{ LocalStreamPlanAndReport, LocalSyncPlanAndReport,
apply_local_header_rules, build_openai_passthrough_headers, ensure_upstream_auth_header,
resolve_local_openai_chat_auth, resolve_transport_execution_timeouts,
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
supports_local_openai_chat_transport, LocalResolvedOAuthRequestAuth,
}; };
use crate::gateway::scheduler::{ use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
use crate::headers::collect_control_headers;
use crate::provider_transport::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::provider_transport::{
apply_local_header_rules, resolve_transport_execution_timeouts,
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
LocalResolvedOAuthRequestAuth,
};
use crate::scheduler::{
record_local_request_candidate_status, GatewayMinimalCandidateSelectionCandidate, record_local_request_candidate_status, GatewayMinimalCandidateSelectionCandidate,
}; };
use crate::gateway::{ use crate::{
append_execution_contract_fields_to_value, AppState, ConversionMode, ExecutionStrategy, append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse,
GatewayControlSyncDecisionResponse,
}; };
use super::plans::current_unix_secs; use super::plans::current_unix_secs;
use crate::gateway::ai_pipeline::planner::standard::{ use crate::ai_pipeline::planner::standard::{
build_cross_format_openai_chat_request_body, build_cross_format_openai_chat_upstream_url, build_cross_format_openai_chat_request_body, build_cross_format_openai_chat_upstream_url,
build_local_openai_chat_request_body, build_local_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::gateway::GatewayControlAuthContext, pub(super) auth_context: crate::control::GatewayControlAuthContext,
pub(super) requested_model: String, pub(super) requested_model: String,
pub(super) auth_snapshot: crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot, pub(super) auth_snapshot: crate::data::auth::GatewayAuthApiKeySnapshot,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -174,7 +177,7 @@ async fn build_same_format_local_openai_chat_decision_payload_for_candidate(
decision_kind: &str, decision_kind: &str,
report_kind: &str, report_kind: &str,
upstream_is_stream: bool, upstream_is_stream: bool,
transport: &crate::gateway::provider_transport::GatewayProviderTransportSnapshot, transport: &crate::provider_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(
@@ -362,6 +365,7 @@ async fn build_same_format_local_openai_chat_decision_payload_for_candidate(
"provider_id": candidate.provider_id, "provider_id": candidate.provider_id,
"endpoint_id": candidate.endpoint_id, "endpoint_id": candidate.endpoint_id,
"key_id": candidate.key_id, "key_id": candidate.key_id,
"key_name": candidate.key_name,
"provider_api_format": "openai:chat", "provider_api_format": "openai:chat",
"client_api_format": "openai:chat", "client_api_format": "openai:chat",
"mapped_model": mapped_model, "mapped_model": mapped_model,
@@ -395,7 +399,7 @@ async fn build_cross_format_local_openai_chat_decision_payload_for_candidate(
candidate_id: &str, candidate_id: &str,
decision_kind: &str, decision_kind: &str,
upstream_is_stream: bool, upstream_is_stream: bool,
transport: &crate::gateway::provider_transport::GatewayProviderTransportSnapshot, transport: &crate::provider_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();
@@ -606,6 +610,7 @@ async fn build_cross_format_local_openai_chat_decision_payload_for_candidate(
"provider_id": candidate.provider_id, "provider_id": candidate.provider_id,
"endpoint_id": candidate.endpoint_id, "endpoint_id": candidate.endpoint_id,
"key_id": candidate.key_id, "key_id": candidate.key_id,
"key_name": candidate.key_name,
"provider_api_format": provider_api_format, "provider_api_format": provider_api_format,
"client_api_format": "openai:chat", "client_api_format": "openai:chat",
"mapped_model": mapped_model, "mapped_model": mapped_model,

View File

@@ -1,23 +1,15 @@
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use tracing::warn; use tracing::warn;
use crate::gateway::ai_pipeline::planner::{ 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::gateway::{ use crate::control::GatewayControlDecision;
AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError, use crate::{
LocalExecutionRuntimeMissDiagnostic, AppState, GatewayControlSyncDecisionResponse, GatewayError, LocalExecutionRuntimeMissDiagnostic,
}; };
use crate::gateway::ai_pipeline::planner::standard::{
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,
parse_openai_tool_result_content,
};
#[path = "openai/chat/decision.rs"]
mod decision; mod decision;
#[path = "openai/chat/plans.rs"]
mod plans; mod plans;
use self::decision::{ use self::decision::{
@@ -39,7 +31,7 @@ pub(crate) async fn build_local_openai_chat_sync_plan_and_reports_for_kind(
body_json: &serde_json::Value, body_json: &serde_json::Value,
plan_kind: &str, plan_kind: &str,
) -> Result< ) -> Result<
Vec<crate::gateway::ai_pipeline::planner::plan_builders::LocalSyncPlanAndReport>, Vec<crate::ai_pipeline::planner::plan_builders::LocalSyncPlanAndReport>,
GatewayError, GatewayError,
> { > {
build_local_openai_chat_sync_plan_and_reports( build_local_openai_chat_sync_plan_and_reports(
@@ -56,7 +48,7 @@ pub(crate) async fn build_local_openai_chat_stream_plan_and_reports_for_kind(
body_json: &serde_json::Value, body_json: &serde_json::Value,
plan_kind: &str, plan_kind: &str,
) -> Result< ) -> Result<
Vec<crate::gateway::ai_pipeline::planner::plan_builders::LocalStreamPlanAndReport>, Vec<crate::ai_pipeline::planner::plan_builders::LocalStreamPlanAndReport>,
GatewayError, GatewayError,
> { > {
build_local_openai_chat_stream_plan_and_reports( build_local_openai_chat_stream_plan_and_reports(
@@ -73,6 +65,17 @@ pub(crate) fn set_local_openai_chat_execution_exhausted_diagnostic(
body_json: &serde_json::Value, body_json: &serde_json::Value,
plan_count: usize, plan_count: usize,
) { ) {
warn!(
event_name = "local_openai_chat_candidates_exhausted",
log_type = "event",
trace_id = %trace_id,
plan_kind,
route_class = decision.route_class.as_deref().unwrap_or("passthrough"),
route_family = decision.route_family.as_deref().unwrap_or("unknown"),
candidate_count = plan_count,
model = body_json.get("model").and_then(|value| value.as_str()).unwrap_or(""),
"gateway local openai chat execution exhausted all candidates"
);
state.set_local_execution_runtime_miss_diagnostic( state.set_local_execution_runtime_miss_diagnostic(
trace_id, trace_id,
LocalExecutionRuntimeMissDiagnostic { LocalExecutionRuntimeMissDiagnostic {
@@ -111,6 +114,8 @@ pub(crate) async fn maybe_build_sync_local_decision_payload(
Ok(candidates) => candidates, Ok(candidates) => candidates,
Err(err) => { Err(err) => {
warn!( warn!(
event_name = "local_openai_chat_scheduler_selection_failed",
log_type = "event",
trace_id = %trace_id, trace_id = %trace_id,
error = ?err, error = ?err,
"gateway local openai chat sync decision scheduler selection failed" "gateway local openai chat sync decision scheduler selection failed"
@@ -167,6 +172,8 @@ pub(crate) async fn maybe_build_stream_local_decision_payload(
Ok(candidates) => candidates, Ok(candidates) => candidates,
Err(err) => { Err(err) => {
warn!( warn!(
event_name = "local_openai_chat_scheduler_selection_failed",
log_type = "event",
trace_id = %trace_id, trace_id = %trace_id,
error = ?err, error = ?err,
"gateway local openai chat stream decision scheduler selection failed" "gateway local openai chat stream decision scheduler selection failed"
@@ -198,6 +205,7 @@ 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>> { pub(crate) fn parse_openai_stop_sequences(stop: Option<&Value>) -> Option<Vec<Value>> {
match stop { match stop {
Some(Value::String(value)) if !value.trim().is_empty() => { Some(Value::String(value)) if !value.trim().is_empty() => {

View File

@@ -8,15 +8,15 @@ 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::gateway::ai_pipeline::planner::plan_builders::{ use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
use crate::ai_pipeline::planner::common::{
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
};
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::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates; use crate::scheduler::{
use crate::gateway::ai_pipeline::planner::{
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
};
use crate::gateway::scheduler::{
list_selectable_candidates, GatewayMinimalCandidateSelectionCandidate, list_selectable_candidates, GatewayMinimalCandidateSelectionCandidate,
}; };
@@ -352,7 +352,7 @@ 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::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot, auth_snapshot: &crate::data::auth::GatewayAuthApiKeySnapshot,
requested_model: &str, requested_model: &str,
candidate: &GatewayMinimalCandidateSelectionCandidate, candidate: &GatewayMinimalCandidateSelectionCandidate,
) -> bool { ) -> bool {

View File

@@ -5,38 +5,45 @@ use serde_json::{json, Value};
use tracing::warn; use tracing::warn;
use uuid::Uuid; use uuid::Uuid;
use crate::gateway::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::gateway::ai_pipeline::planner::plan_builders::{ use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
use crate::ai_pipeline::planner::common::{
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
};
use crate::ai_pipeline::planner::plan_builders::{
LocalStreamPlanAndReport, LocalSyncPlanAndReport, LocalStreamPlanAndReport, LocalSyncPlanAndReport,
}; };
use crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates; use crate::ai_pipeline::planner::standard::{
use crate::gateway::ai_pipeline::planner::standard::{
build_cross_format_openai_cli_request_body, build_cross_format_openai_cli_upstream_url, build_cross_format_openai_cli_request_body, build_cross_format_openai_cli_upstream_url,
build_local_openai_cli_request_body, build_local_openai_cli_upstream_url, build_local_openai_cli_request_body, build_local_openai_cli_upstream_url,
}; };
use crate::gateway::ai_pipeline::planner::{ use crate::control::GatewayControlDecision;
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION, use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
}; use crate::headers::collect_control_headers;
use crate::gateway::headers::collect_control_headers; use crate::provider_transport::antigravity::{
use crate::gateway::provider_transport::{ build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
apply_local_body_rules, apply_local_header_rules, build_antigravity_safe_v1internal_request, classify_local_antigravity_request_support, AntigravityEnvelopeRequestType,
build_antigravity_static_identity_headers, build_openai_passthrough_headers,
classify_local_antigravity_request_support, ensure_upstream_auth_header,
resolve_local_gemini_auth, resolve_local_standard_auth, resolve_transport_execution_timeouts,
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
supports_local_standard_transport_with_network, AntigravityEnvelopeRequestType,
AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport, AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport,
};
use crate::provider_transport::auth::{
build_openai_passthrough_headers, ensure_upstream_auth_header, resolve_local_gemini_auth,
resolve_local_standard_auth,
};
use crate::provider_transport::policy::supports_local_standard_transport_with_network;
use crate::provider_transport::{
apply_local_body_rules, apply_local_header_rules, resolve_transport_execution_timeouts,
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
LocalResolvedOAuthRequestAuth, LocalResolvedOAuthRequestAuth,
}; };
use crate::gateway::scheduler::{ use crate::scheduler::{
current_unix_secs, list_selectable_candidates, record_local_request_candidate_status, current_unix_secs, list_selectable_candidates, record_local_request_candidate_status,
GatewayMinimalCandidateSelectionCandidate, GatewayMinimalCandidateSelectionCandidate,
}; };
use crate::gateway::{ use crate::{
append_execution_contract_fields_to_value, AppState, ConversionMode, ExecutionStrategy, append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse,
GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError, GatewayError,
}; };
const ANTIGRAVITY_ENVELOPE_NAME: &str = "antigravity:v1internal"; const ANTIGRAVITY_ENVELOPE_NAME: &str = "antigravity:v1internal";
@@ -52,9 +59,9 @@ pub(super) struct LocalOpenAiCliSpec {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(super) struct LocalOpenAiCliDecisionInput { pub(super) struct LocalOpenAiCliDecisionInput {
pub(super) auth_context: crate::gateway::GatewayControlAuthContext, pub(super) auth_context: crate::control::GatewayControlAuthContext,
pub(super) requested_model: String, pub(super) requested_model: String,
pub(super) auth_snapshot: crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot, pub(super) auth_snapshot: crate::data::auth::GatewayAuthApiKeySnapshot,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -247,7 +254,7 @@ 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::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot, auth_snapshot: &crate::data::auth::GatewayAuthApiKeySnapshot,
requested_model: &str, requested_model: &str,
candidate: &GatewayMinimalCandidateSelectionCandidate, candidate: &GatewayMinimalCandidateSelectionCandidate,
) -> bool { ) -> bool {
@@ -639,6 +646,7 @@ pub(super) async fn maybe_build_local_openai_cli_decision_payload_for_candidate(
"provider_id": candidate.provider_id, "provider_id": candidate.provider_id,
"endpoint_id": candidate.endpoint_id, "endpoint_id": candidate.endpoint_id,
"key_id": candidate.key_id, "key_id": candidate.key_id,
"key_name": candidate.key_name,
"provider_api_format": provider_api_format, "provider_api_format": provider_api_format,
"client_api_format": spec.api_format, "client_api_format": spec.api_format,
"mapped_model": mapped_model, "mapped_model": mapped_model,

View File

@@ -1,13 +1,10 @@
use crate::gateway::ai_pipeline::planner::plan_builders::{ use crate::ai_pipeline::planner::plan_builders::{
LocalStreamPlanAndReport, LocalSyncPlanAndReport, LocalStreamPlanAndReport, LocalSyncPlanAndReport,
}; };
use crate::gateway::{ use crate::control::GatewayControlDecision;
AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError, use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
};
#[path = "openai/cli/decision.rs"]
mod decision; mod decision;
#[path = "openai/cli/plans.rs"]
mod plans; mod plans;
use self::decision::{ use self::decision::{

View File

@@ -5,15 +5,16 @@ 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::gateway::ai_pipeline::planner::plan_builders::{ use crate::ai_pipeline::planner::common::{
build_openai_cli_stream_plan_from_decision, build_openai_cli_sync_plan_from_decision,
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::ai_pipeline::planner::{
OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND, OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
OPENAI_COMPACT_SYNC_PLAN_KIND, OPENAI_COMPACT_SYNC_PLAN_KIND,
}; };
use crate::gateway::{AppState, GatewayControlDecision, GatewayError}; use crate::ai_pipeline::planner::plan_builders::{
build_openai_cli_stream_plan_from_decision, build_openai_cli_sync_plan_from_decision,
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::control::GatewayControlDecision;
use crate::{AppState, GatewayError};
pub(super) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalOpenAiCliSpec> { pub(super) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalOpenAiCliSpec> {
match plan_kind { match plan_kind {

View File

@@ -0,0 +1,13 @@
pub(crate) mod chat;
pub(crate) mod cli;
pub(crate) use chat::{
copy_request_number_field, copy_request_number_field_as,
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,
};
pub(crate) use cli::{
maybe_build_stream_local_openai_cli_decision_payload,
maybe_build_sync_local_openai_cli_decision_payload,
};

View File

@@ -5,11 +5,11 @@ use super::{
GatewayControlSyncDecisionResponse, GatewayError, LocalStreamPlanAndReport, GatewayControlSyncDecisionResponse, GatewayError, LocalStreamPlanAndReport,
LocalSyncPlanAndReport, LocalSyncPlanAndReport,
}; };
use crate::gateway::ai_pipeline::adaptation::surfaces::provider_adaptation_requires_eventstream_accept; use crate::ai_pipeline::adaptation::surfaces::provider_adaptation_requires_eventstream_accept;
use crate::gateway::provider_transport::{ use crate::provider_transport::auth::{
build_openai_chat_url, build_openai_cli_url, build_openai_passthrough_headers, build_openai_passthrough_headers, ensure_upstream_auth_header,
ensure_upstream_auth_header,
}; };
use crate::provider_transport::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,

View File

@@ -1,10 +1,10 @@
use aether_contracts::{ExecutionPlan, RequestBody}; use aether_contracts::{ExecutionPlan, RequestBody};
use super::{augment_sync_report_context, LocalStreamPlanAndReport, LocalSyncPlanAndReport}; use super::{augment_sync_report_context, LocalStreamPlanAndReport, LocalSyncPlanAndReport};
use crate::gateway::ai_pipeline::adaptation::surfaces::provider_adaptation_requires_eventstream_accept; use crate::ai_pipeline::adaptation::surfaces::provider_adaptation_requires_eventstream_accept;
use crate::gateway::ai_pipeline::planner::generic_decision_missing_exact_provider_request; use crate::ai_pipeline::contracts::generic_decision_missing_exact_provider_request;
use crate::gateway::provider_transport::ensure_upstream_auth_header; use crate::provider_transport::ensure_upstream_auth_header;
use crate::gateway::{GatewayControlSyncDecisionResponse, GatewayError}; use crate::{GatewayControlSyncDecisionResponse, GatewayError};
pub(crate) fn build_standard_sync_plan_from_decision( pub(crate) fn build_standard_sync_plan_from_decision(
_parts: &http::request::Parts, _parts: &http::request::Parts,

View File

@@ -2,7 +2,7 @@ use std::collections::BTreeMap;
use serde_json::Value; use serde_json::Value;
use crate::gateway::provider_transport::snapshot::GatewayProviderTransportSnapshot; use crate::provider_transport::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";

View File

@@ -1,25 +1,13 @@
#![allow(dead_code, unused_imports)] #![allow(dead_code, unused_imports)]
mod auth; pub(crate) use crate::provider_transport::antigravity::{
mod policy; build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
mod request; build_antigravity_v1internal_url, classify_antigravity_safe_request_body,
mod url; classify_local_antigravity_request_support, AntigravityEnvelopeRequestType,
pub(crate) use auth::{
build_antigravity_static_identity_headers, resolve_local_antigravity_request_auth,
AntigravityRequestAuth, AntigravityRequestAuthSupport, AntigravityRequestAuthUnsupportedReason, AntigravityRequestAuth, AntigravityRequestAuthSupport, AntigravityRequestAuthUnsupportedReason,
AntigravityRequestEnvelopeSupport, AntigravityRequestEnvelopeUnsupportedReason,
AntigravityRequestSideSpec, AntigravityRequestSideSupport,
AntigravityRequestSideUnsupportedReason, AntigravityRequestUrlAction,
ANTIGRAVITY_PROVIDER_TYPE, ANTIGRAVITY_REQUEST_USER_AGENT, ANTIGRAVITY_PROVIDER_TYPE, ANTIGRAVITY_REQUEST_USER_AGENT,
};
pub(crate) use policy::{
classify_local_antigravity_request_support, AntigravityRequestSideSpec,
AntigravityRequestSideSupport, AntigravityRequestSideUnsupportedReason,
};
pub(crate) use request::{
build_antigravity_safe_v1internal_request, classify_antigravity_safe_request_body,
AntigravityEnvelopeRequestType, AntigravityRequestEnvelopeSupport,
AntigravityRequestEnvelopeUnsupportedReason,
};
pub(crate) use url::{
build_antigravity_v1internal_url, AntigravityRequestUrlAction,
ANTIGRAVITY_V1INTERNAL_PATH_TEMPLATE, ANTIGRAVITY_V1INTERNAL_PATH_TEMPLATE,
}; };

View File

@@ -8,7 +8,7 @@ use super::request::{
classify_antigravity_safe_request_body, AntigravityEnvelopeRequestType, classify_antigravity_safe_request_body, AntigravityEnvelopeRequestType,
AntigravityRequestEnvelopeUnsupportedReason, AntigravityRequestEnvelopeUnsupportedReason,
}; };
use crate::gateway::provider_transport::snapshot::GatewayProviderTransportSnapshot; use crate::provider_transport::snapshot::GatewayProviderTransportSnapshot;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AntigravityRequestSideSpec { pub(crate) struct AntigravityRequestSideSpec {

View File

@@ -1 +1 @@
pub(crate) use crate::gateway::provider_transport::auth::resolve_local_standard_auth; pub(crate) use crate::provider_transport::auth::resolve_local_standard_auth;

View File

@@ -1 +1 @@
pub(crate) use crate::gateway::provider_transport::policy::supports_local_standard_transport_with_network; pub(crate) use crate::provider_transport::policy::supports_local_standard_transport_with_network;

View File

@@ -1 +1 @@
pub(crate) use crate::gateway::provider_transport::auth::build_passthrough_headers_with_auth; pub(crate) use crate::provider_transport::auth::build_passthrough_headers_with_auth;

Some files were not shown because too many files have changed in this diff Show More