refactor: 移除独立 hub/proxy/executor/gateway crate,统一为 gateway tunnel 架构

- 删除 aether-hub、aether-proxy 独立项目及其 Dockerfile/配置
- 删除 crates/aether-executor 和 crates/aether-gateway 全部模块
- 新增 apps/ 目录作为应用入口
- 将 hub 概念重构为 gateway tunnel transport
- 将 executor 重构为 execution runtime
- 新增 tunnel.rs 合约定义和 testkit tunnel/execution_runtime 模块
- 更新 Python 服务层和测试适配新架构命名
This commit is contained in:
fawney19
2026-04-03 14:59:58 +08:00
parent ddf18fed9a
commit 8f26e1a31f
983 changed files with 103098 additions and 105837 deletions

57
Cargo.lock generated
View File

@@ -36,6 +36,8 @@ version = "0.1.0"
name = "aether-contracts" name = "aether-contracts"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"bytes",
"flate2",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.18", "thiserror 2.0.18",
@@ -74,31 +76,6 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "aether-executor"
version = "0.1.0"
dependencies = [
"aether-contracts",
"aether-http",
"aether-runtime",
"async-stream",
"axum",
"base64",
"bytes",
"clap",
"flate2",
"futures-util",
"http",
"reqwest",
"rustls",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"webpki-roots 0.26.11",
]
[[package]] [[package]]
name = "aether-gateway" name = "aether-gateway"
version = "0.1.0" version = "0.1.0"
@@ -120,11 +97,13 @@ dependencies = [
"chrono", "chrono",
"chrono-tz", "chrono-tz",
"clap", "clap",
"dashmap",
"flate2", "flate2",
"futures-util", "futures-util",
"hmac", "hmac",
"http", "http",
"ldap3", "ldap3",
"parking_lot",
"redis", "redis",
"regex", "regex",
"reqwest", "reqwest",
@@ -150,37 +129,17 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "aether-hub"
version = "0.2.0"
dependencies = [
"aether-http",
"aether-runtime",
"async-stream",
"axum",
"bytes",
"clap",
"dashmap",
"flate2",
"futures-util",
"http-body-util",
"parking_lot",
"reqwest",
"serde",
"serde_json",
"tokio",
"tokio-tungstenite 0.28.0",
"tracing",
]
[[package]] [[package]]
name = "aether-proxy" name = "aether-proxy"
version = "0.2.5" version = "0.2.5"
dependencies = [ dependencies = [
"aether-contracts",
"aether-gateway",
"aether-http", "aether-http",
"aether-runtime", "aether-runtime",
"anyhow", "anyhow",
"arc-swap", "arc-swap",
"axum",
"base64", "base64",
"bytes", "bytes",
"clap", "clap",
@@ -234,10 +193,8 @@ version = "0.1.0"
dependencies = [ dependencies = [
"aether-contracts", "aether-contracts",
"aether-data", "aether-data",
"aether-executor",
"aether-gateway", "aether-gateway",
"aether-http", "aether-http",
"aether-hub",
"aether-runtime", "aether-runtime",
"async-stream", "async-stream",
"axum", "axum",

View File

@@ -1,15 +1,13 @@
[workspace] [workspace]
members = [ members = [
"aether-hub", "apps/aether-proxy",
"aether-proxy",
"crates/aether-cache", "crates/aether-cache",
"crates/aether-billing", "crates/aether-billing",
"crates/aether-wallet", "crates/aether-wallet",
"crates/aether-crypto", "crates/aether-crypto",
"crates/aether-contracts", "crates/aether-contracts",
"crates/aether-data", "crates/aether-data",
"crates/aether-executor", "apps/aether-gateway",
"crates/aether-gateway",
"crates/aether-http", "crates/aether-http",
"crates/aether-runtime", "crates/aether-runtime",
"crates/aether-testkit", "crates/aether-testkit",
@@ -22,15 +20,13 @@ license = "LicenseRef-Aether-NonCommercial"
repository = "https://github.com/fawney19/Aether.git" repository = "https://github.com/fawney19/Aether.git"
[workspace.dependencies] [workspace.dependencies]
aether-hub = { path = "aether-hub" }
aether-cache = { path = "crates/aether-cache" } aether-cache = { path = "crates/aether-cache" }
aether-billing = { path = "crates/aether-billing" } aether-billing = { path = "crates/aether-billing" }
aether-wallet = { path = "crates/aether-wallet" } aether-wallet = { path = "crates/aether-wallet" }
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-executor = { path = "crates/aether-executor" } aether-gateway = { path = "apps/aether-gateway" }
aether-gateway = { path = "crates/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" }
aether-testkit = { path = "crates/aether-testkit" } aether-testkit = { path = "crates/aether-testkit" }

View File

@@ -9,15 +9,26 @@ WORKDIR /app
COPY frontend/ ./frontend/ COPY frontend/ ./frontend/
RUN cd frontend && npm run build RUN cd frontend && npm run build
FROM rust:1.86-slim AS gateway-builder
WORKDIR /build
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
build-essential \
pkg-config \
perl
COPY Cargo.toml Cargo.lock ./
COPY apps/ ./apps/
COPY crates/ ./crates/
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/build/target \
cargo build --release --locked -p aether-gateway && \
cp target/release/aether-gateway /tmp/aether-gateway
# ==================== 运行时镜像 ==================== # ==================== 运行时镜像 ====================
FROM python:3.13-slim FROM python:3.13-slim
WORKDIR /app WORKDIR /app
ARG HUB_RELEASE_REPO=fawney19/Aether
ARG HUB_TAG
ARG TARGETARCH
ARG GITHUB_TOKEN
# 运行时依赖(无 gcc/nodejs/npm使用 BuildKit 缓存加速) # 运行时依赖(无 gcc/nodejs/npm使用 BuildKit 缓存加速)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \
@@ -37,37 +48,7 @@ COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/pytho
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/ COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/ COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/ COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
# Hub 预编译二进制(构建时从 GitHub Release 下载) COPY --from=gateway-builder /tmp/aether-gateway /usr/local/bin/aether-gateway
# GITHUB_TOKEN 可选:未认证 API 限流 60 次/小时,认证后 5000 次/小时
RUN set -eux; \
auth_header=""; \
if [ -n "${GITHUB_TOKEN:-}" ]; then \
auth_header="Authorization: token ${GITHUB_TOKEN}"; \
fi; \
tag="${HUB_TAG:-}"; \
if [ -z "$tag" ]; then \
tag="$(curl -sL ${auth_header:+-H "$auth_header"} "https://api.github.com/repos/${HUB_RELEASE_REPO}/releases" | python3 -c "import json,sys;print(next((r['tag_name'] for r in json.load(sys.stdin) if r.get('tag_name','').startswith('hub-v') and not r.get('draft') and not r.get('prerelease')),''))")"; \
fi; \
if [ -z "$tag" ]; then \
echo "Failed to resolve hub release tag"; \
exit 1; \
fi; \
arch="${TARGETARCH:-}"; \
if [ -z "$arch" ]; then \
arch="$(dpkg --print-architecture)"; \
fi; \
case "$arch" in \
amd64|arm64) ;; \
x86_64) arch="amd64" ;; \
aarch64) arch="arm64" ;; \
*) echo "Unsupported architecture: $arch"; exit 1 ;; \
esac; \
echo "Using Hub release tag: $tag"; \
url="https://github.com/${HUB_RELEASE_REPO}/releases/download/${tag}/aether-hub-linux-${arch}.tar.gz"; \
curl -L --fail -o /tmp/aether-hub.tar.gz "$url"; \
tar xzf /tmp/aether-hub.tar.gz -C /usr/local/bin; \
chmod +x /usr/local/bin/aether-hub; \
rm -f /tmp/aether-hub.tar.gz
# 从 builder 阶段复制前端构建产物 # 从 builder 阶段复制前端构建产物
COPY --from=builder /app/frontend/dist /usr/share/nginx/html COPY --from=builder /app/frontend/dist /usr/share/nginx/html
RUN chmod -R 755 /usr/share/nginx/html RUN chmod -R 755 /usr/share/nginx/html
@@ -119,9 +100,9 @@ RUN printf '%s\n' \
' return 404;' \ ' return 404;' \
' }' \ ' }' \
'' \ '' \
' # WebSocket 隧道端点(aether-proxy tunnel 模式)' \ ' # WebSocket 隧道端点(gateway-owned tunnel 模式)' \
' location = /api/internal/proxy-tunnel {' \ ' location = /api/internal/proxy-tunnel {' \
' proxy_pass http://127.0.0.1:8085/proxy;' \ ' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
' proxy_http_version 1.1;' \ ' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \ ' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $real_ip;' \ ' proxy_set_header X-Real-IP $real_ip;' \
@@ -237,14 +218,14 @@ RUN printf '%s\n' \
'pidfile=/var/run/supervisord.pid' \ 'pidfile=/var/run/supervisord.pid' \
'' \ '' \
'[program:nginx]' \ '[program:nginx]' \
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/8084/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \ 'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/%(ENV_PORT)s/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
'autostart=true' \ 'autostart=true' \
'autorestart=true' \ 'autorestart=true' \
'stdout_logfile=/var/log/nginx/access.log' \ 'stdout_logfile=/var/log/nginx/access.log' \
'stderr_logfile=/var/log/nginx/error.log' \ 'stderr_logfile=/var/log/nginx/error.log' \
'' \ '' \
'[program:app]' \ '[program:gateway]' \
'command=/bin/bash -c "MAX_REQUESTS_JITTER=$((${MAX_REQUESTS:-50000}/20)); exec gunicorn src.main:app -c gunicorn_conf.py --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 127.0.0.1:8084 --max-requests ${MAX_REQUESTS:-50000} --max-requests-jitter $MAX_REQUESTS_JITTER --access-logfile - --error-logfile - --log-level info"' \ 'command=/usr/local/bin/aether-gateway --bind 127.0.0.1:%(ENV_PORT)s --upstream http://127.0.0.1:%(ENV_LEGACY_APP_PORT)s' \
'directory=/app' \ 'directory=/app' \
'autostart=true' \ 'autostart=true' \
'autorestart=true' \ 'autorestart=true' \
@@ -252,16 +233,18 @@ RUN printf '%s\n' \
'stdout_logfile_maxbytes=0' \ 'stdout_logfile_maxbytes=0' \
'stderr_logfile=/dev/stderr' \ 'stderr_logfile=/dev/stderr' \
'stderr_logfile_maxbytes=0' \ 'stderr_logfile_maxbytes=0' \
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true,LD_PRELOAD=/usr/local/lib/libjemalloc.so.2,MALLOC_CONF="background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000"' \ 'environment=RUST_LOG=aether_gateway=info' \
'' \ '' \
'[program:tunnel-hub]' \ '[program:app]' \
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085' \ 'command=/bin/bash -c "MAX_REQUESTS_JITTER=$((${MAX_REQUESTS:-50000}/20)); exec gunicorn src.main:app -c gunicorn_conf.py --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 127.0.0.1:%(ENV_LEGACY_APP_PORT)s --max-requests ${MAX_REQUESTS:-50000} --max-requests-jitter $MAX_REQUESTS_JITTER --access-logfile - --error-logfile - --log-level info"' \
'directory=/app' \
'autostart=true' \ 'autostart=true' \
'autorestart=true' \ 'autorestart=true' \
'stdout_logfile=/dev/stdout' \ 'stdout_logfile=/dev/stdout' \
'stdout_logfile_maxbytes=0' \ 'stdout_logfile_maxbytes=0' \
'stderr_logfile=/dev/stderr' \ 'stderr_logfile=/dev/stderr' \
'stderr_logfile_maxbytes=0' > /etc/supervisor/conf.d/supervisord.conf 'stderr_logfile_maxbytes=0' \
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true,LD_PRELOAD=/usr/local/lib/libjemalloc.so.2,MALLOC_CONF="background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000"' > /etc/supervisor/conf.d/supervisord.conf
# 创建目录 # 创建目录
RUN mkdir -p /var/log/supervisor /app/logs /app/data RUN mkdir -p /var/log/supervisor /app/logs /app/data
# 入口脚本(启动前执行迁移) # 入口脚本(启动前执行迁移)
@@ -276,6 +259,7 @@ ENV PYTHONUNBUFFERED=1 \
LD_PRELOAD=/usr/local/lib/libjemalloc.so.2 \ LD_PRELOAD=/usr/local/lib/libjemalloc.so.2 \
MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000 \ MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000 \
PORT=8084 \ PORT=8084 \
LEGACY_APP_PORT=18084 \
GUNICORN_WORKERS=2 \ GUNICORN_WORKERS=2 \
MAX_REQUESTS=4000 MAX_REQUESTS=4000
EXPOSE 80 EXPOSE 80

View File

@@ -11,21 +11,28 @@ WORKDIR /app
COPY frontend/ ./frontend/ COPY frontend/ ./frontend/
RUN cd frontend && npm run build RUN cd frontend && npm run build
FROM rust:1.86-slim AS gateway-builder
WORKDIR /build
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
apt-get update && apt-get install -y --no-install-recommends \
build-essential \
pkg-config \
perl
COPY Cargo.toml Cargo.lock ./
COPY apps/ ./apps/
COPY crates/ ./crates/
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/build/target \
cargo build --release --locked -p aether-gateway && \
cp target/release/aether-gateway /tmp/aether-gateway
# ==================== 运行时镜像 ==================== # ==================== 运行时镜像 ====================
FROM python:3.13-slim FROM python:3.13-slim
WORKDIR /app WORKDIR /app
ARG HUB_RELEASE_REPO=fawney19/Aether
ARG HUB_TAG
ARG TARGETARCH
ARG GITHUB_TOKEN
# GitHub 下载镜像前缀,国内构建时传入可用的镜像加速地址
# 用法: --build-arg GITHUB_MIRROR=https://ghfast.top
# 或: --build-arg GITHUB_MIRROR=https://gh-proxy.com
# 或: --build-arg GITHUB_MIRROR=https://mirror.ghproxy.com
ARG GITHUB_MIRROR
# 运行时依赖(使用清华镜像源 + BuildKit 缓存加速) # 运行时依赖(使用清华镜像源 + BuildKit 缓存加速)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \
@@ -48,45 +55,7 @@ COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/pytho
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/ COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/ COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/ COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
COPY --from=gateway-builder /tmp/aether-gateway /usr/local/bin/aether-gateway
# Hub 预编译二进制
# 国内构建: --build-arg GITHUB_MIRROR=https://ghfast.top 即可走镜像下载
# GITHUB_TOKEN 可选:未认证 API 限流 60 次/小时,认证后 5000 次/小时
RUN set -eux; \
arch="${TARGETARCH:-}"; \
if [ -z "$arch" ]; then \
arch="$(dpkg --print-architecture)"; \
fi; \
case "$arch" in \
amd64|arm64) ;; \
x86_64) arch="amd64" ;; \
aarch64) arch="arm64" ;; \
*) echo "Unsupported architecture: $arch"; exit 1 ;; \
esac; \
auth_header=""; \
if [ -n "${GITHUB_TOKEN:-}" ]; then \
auth_header="Authorization: token ${GITHUB_TOKEN}"; \
fi; \
tag="${HUB_TAG:-}"; \
if [ -z "$tag" ]; then \
tag="$(curl -sL ${auth_header:+-H "$auth_header"} "https://api.github.com/repos/${HUB_RELEASE_REPO}/releases" | python3 -c "import json,sys;print(next((r['tag_name'] for r in json.load(sys.stdin) if r.get('tag_name','').startswith('hub-v') and not r.get('draft') and not r.get('prerelease')),''))")"; \
fi; \
if [ -z "$tag" ]; then \
echo "Failed to resolve hub release tag"; \
exit 1; \
fi; \
echo "Using Hub release tag: $tag"; \
origin_url="https://github.com/${HUB_RELEASE_REPO}/releases/download/${tag}/aether-hub-linux-${arch}.tar.gz"; \
if [ -n "${GITHUB_MIRROR:-}" ]; then \
url="${GITHUB_MIRROR}/https://github.com/${HUB_RELEASE_REPO}/releases/download/${tag}/aether-hub-linux-${arch}.tar.gz"; \
echo "Using mirror: ${GITHUB_MIRROR}"; \
else \
url="$origin_url"; \
fi; \
curl -L --fail -o /tmp/aether-hub.tar.gz "$url"; \
tar xzf /tmp/aether-hub.tar.gz -C /usr/local/bin; \
chmod +x /usr/local/bin/aether-hub; \
rm -f /tmp/aether-hub.tar.gz
# 从 builder 阶段复制前端构建产物 # 从 builder 阶段复制前端构建产物
COPY --from=builder /app/frontend/dist /usr/share/nginx/html COPY --from=builder /app/frontend/dist /usr/share/nginx/html
@@ -141,9 +110,9 @@ RUN printf '%s\n' \
' return 404;' \ ' return 404;' \
' }' \ ' }' \
'' \ '' \
' # WebSocket 隧道端点(aether-proxy tunnel 模式)' \ ' # WebSocket 隧道端点(gateway-owned tunnel 模式)' \
' location = /api/internal/proxy-tunnel {' \ ' location = /api/internal/proxy-tunnel {' \
' proxy_pass http://127.0.0.1:8085/proxy;' \ ' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
' proxy_http_version 1.1;' \ ' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \ ' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $real_ip;' \ ' proxy_set_header X-Real-IP $real_ip;' \
@@ -260,14 +229,14 @@ RUN printf '%s\n' \
'pidfile=/var/run/supervisord.pid' \ 'pidfile=/var/run/supervisord.pid' \
'' \ '' \
'[program:nginx]' \ '[program:nginx]' \
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/${PORT:-8084}/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \ 'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/%(ENV_PORT)s/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
'autostart=true' \ 'autostart=true' \
'autorestart=true' \ 'autorestart=true' \
'stdout_logfile=/var/log/nginx/access.log' \ 'stdout_logfile=/var/log/nginx/access.log' \
'stderr_logfile=/var/log/nginx/error.log' \ 'stderr_logfile=/var/log/nginx/error.log' \
'' \ '' \
'[program:app]' \ '[program:gateway]' \
'command=/bin/bash -c "MAX_REQUESTS_JITTER=$((${MAX_REQUESTS:-50000}/20)); exec gunicorn src.main:app -c gunicorn_conf.py --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --max-requests ${MAX_REQUESTS:-50000} --max-requests-jitter $MAX_REQUESTS_JITTER --access-logfile - --error-logfile - --log-level info"' \ 'command=/usr/local/bin/aether-gateway --bind 127.0.0.1:%(ENV_PORT)s --upstream http://127.0.0.1:%(ENV_LEGACY_APP_PORT)s' \
'directory=/app' \ 'directory=/app' \
'autostart=true' \ 'autostart=true' \
'autorestart=true' \ 'autorestart=true' \
@@ -275,16 +244,18 @@ RUN printf '%s\n' \
'stdout_logfile_maxbytes=0' \ 'stdout_logfile_maxbytes=0' \
'stderr_logfile=/dev/stderr' \ 'stderr_logfile=/dev/stderr' \
'stderr_logfile_maxbytes=0' \ 'stderr_logfile_maxbytes=0' \
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true,LD_PRELOAD=/usr/local/lib/libjemalloc.so.2,MALLOC_CONF="background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000"' \ 'environment=RUST_LOG=aether_gateway=info' \
'' \ '' \
'[program:tunnel-hub]' \ '[program:app]' \
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085' \ 'command=/bin/bash -c "MAX_REQUESTS_JITTER=$((${MAX_REQUESTS:-50000}/20)); exec gunicorn src.main:app -c gunicorn_conf.py --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 127.0.0.1:%(ENV_LEGACY_APP_PORT)s --max-requests ${MAX_REQUESTS:-50000} --max-requests-jitter $MAX_REQUESTS_JITTER --access-logfile - --error-logfile - --log-level info"' \
'directory=/app' \
'autostart=true' \ 'autostart=true' \
'autorestart=true' \ 'autorestart=true' \
'stdout_logfile=/dev/stdout' \ 'stdout_logfile=/dev/stdout' \
'stdout_logfile_maxbytes=0' \ 'stdout_logfile_maxbytes=0' \
'stderr_logfile=/dev/stderr' \ 'stderr_logfile=/dev/stderr' \
'stderr_logfile_maxbytes=0' > /etc/supervisor/conf.d/supervisord.conf 'stderr_logfile_maxbytes=0' \
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true,LD_PRELOAD=/usr/local/lib/libjemalloc.so.2,MALLOC_CONF="background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000"' > /etc/supervisor/conf.d/supervisord.conf
# 创建目录 # 创建目录
RUN mkdir -p /var/log/supervisor /app/logs /app/data RUN mkdir -p /var/log/supervisor /app/logs /app/data
@@ -302,6 +273,7 @@ ENV PYTHONUNBUFFERED=1 \
LD_PRELOAD=/usr/local/lib/libjemalloc.so.2 \ LD_PRELOAD=/usr/local/lib/libjemalloc.so.2 \
MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000 \ MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000 \
PORT=8084 \ PORT=8084 \
LEGACY_APP_PORT=18084 \
GUNICORN_WORKERS=2 \ GUNICORN_WORKERS=2 \
MAX_REQUESTS=4000 MAX_REQUESTS=4000

View File

@@ -87,48 +87,35 @@ cd frontend && npm install && npm run dev
| 角色 | 本地地址 | 说明 | | 角色 | 本地地址 | 说明 |
|------|----------|------| |------|----------|------|
| Rust frontdoor | `http://localhost:8084` | `aether-gateway`本地唯一公开入口浏览器、CLI、SDK 都应优先连这里 | | Rust frontdoor | `http://localhost:8084` | `aether-gateway`本地唯一公开入口浏览器、CLI、SDK 都应优先连这里 |
| Rust executor sidecar | `http://127.0.0.1:5219` | `aether-executor`,供 gateway 直连 |
本地默认链路是: 本地默认链路是:
```text ```text
client -> rust frontdoor (aether-gateway) -> aether-executor client -> rust frontdoor (aether-gateway) -> execution_runtime/provider transport
``` ```
其中: 其中:
- `aether-gateway` 负责公开入口、健康检查,以及当前已迁到 Rust 的 frontdoor/control/background 路径。 - `aether-gateway` 负责公开入口、健康检查、格式转换、本地执行 runtime,以及当前已迁到 Rust 的 frontdoor/control/background 路径。
- `./dev.sh` 不再启动 Python 宿主;未下沉到 Rust 的 legacy 路由会直接失败,除非你手动提供 `AETHER_GATEWAY_UPSTREAM` / `AETHER_GATEWAY_CONTROL_URL` - `./dev.sh` 不再启动 Python 宿主;未下沉到 Rust 的 legacy 路由会直接失败,除非你手动提供 `AETHER_GATEWAY_UPSTREAM`
- `./dev.sh` 默认把 `AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE` 设为 `rust-authoritative`,避免本地还依赖 Python sync report 语义。 - `./dev.sh` 默认把 `AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE` 设为 `rust-authoritative`,避免本地还依赖 Python sync report 语义。
如果你需要把 gateway 指到一个外部 legacy 宿主,可显式设置: 如果你需要把 gateway 指到一个外部 legacy 宿主,可显式设置:
```bash ```bash
AETHER_GATEWAY_UPSTREAM=http://127.0.0.1:18084 \ AETHER_GATEWAY_UPSTREAM=http://127.0.0.1:18084 \
AETHER_GATEWAY_CONTROL_URL=http://127.0.0.1:18084 \
./dev.sh ./dev.sh
``` ```
如果不提供上述变量,`./dev.sh` 会把 legacy upstream 指到一个不可用的本地地址,让未迁移路径快速失败,而不是悄悄回退到 Python。 如果不提供上述变量,`./dev.sh` 会把 legacy upstream 指到一个不可用的本地地址,让未迁移路径快速失败,而不是悄悄回退到 Python。
`./dev.sh` 会固定把 `aether-executor` 切到 TCP 模式,并自动把
`AETHER_GATEWAY_EXECUTOR_URL` 指向 `EXECUTOR_BASE_URL`
这样 `aether-gateway` 可以直接命中 `aether-executor` 的窄路径直连逻辑。
如果只想单独验证 Rust executor 本地链路,可以运行:
```bash
cargo build -p aether-executor
uv run python tests/e2e_rust_executor.py
```
## Aether Proxy (可选) ## Aether Proxy (可选)
Aether Proxy 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。或者部署在其他服务器为指定的提供商、账号、Key使用不同的节点访问。支持 TUI 向导一键配置、systemd 服务管理、TLS 加密、DNS 缓存及连接池调优。 Aether Proxy 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。或者部署在其他服务器为指定的提供商、账号、Key使用不同的节点访问。支持 TUI 向导一键配置、systemd 服务管理、TLS 加密、DNS 缓存及连接池调优。
- Docker Compose 部署或下载预编译二进制直接运行 - Docker Compose 部署或下载预编译二进制直接运行
- 通过 `aether-proxy setup` 完成交互式配置,自动注册为系统服务 - 通过 `aether-proxy setup` 完成交互式配置,自动注册为系统服务
- 详细文档见 [aether-proxy/README.md](aether-proxy/README.md) - 详细文档见 [apps/aether-proxy/README.md](apps/aether-proxy/README.md)
## 环境变量 ## 环境变量

View File

@@ -1,3 +0,0 @@
target/
.git/
.DS_Store

2012
aether-hub/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +0,0 @@
[package]
name = "aether-hub"
version = "0.2.0"
edition = "2021"
description = "Tunnel Hub for Aether - frame router between workers and proxies"
[dependencies]
aether-http.workspace = true
aether-runtime.workspace = true
tokio = { version = "1", features = ["full"] }
axum = { version = "0.8", features = ["ws"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
clap = { version = "4", features = ["derive", "env"] }
dashmap = "6"
parking_lot = "0.12"
flate2 = "1"
futures-util = "0.3"
bytes = "1"
async-stream = "0.3"
http-body-util = "0.1"
reqwest.workspace = true
[dev-dependencies]
tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] }

View File

@@ -1,34 +0,0 @@
# syntax=docker/dockerfile:1
FROM rust:1.85-slim AS builder
WORKDIR /build/aether-hub
# 可选:配置国内 Cargo 镜像源(本地构建时传 --build-arg CARGO_MIRROR=1
ARG CARGO_MIRROR
RUN if [ -n "$CARGO_MIRROR" ]; then \
printf '[source.crates-io]\nreplace-with = "tuna"\n\n[source.tuna]\nregistry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"\n' \
> /usr/local/cargo/config.toml; \
fi
# 先构建依赖层,最大化后续代码变更时的缓存命中
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && printf 'fn main() {}\n' > src/main.rs
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/build/aether-hub/target,sharing=locked \
cargo build --release --locked
RUN rm -rf src
COPY src ./src
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/build/aether-hub/target,sharing=locked \
cargo build --release --locked && \
cp target/release/aether-hub /tmp/aether-hub
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/*
COPY --from=builder /tmp/aether-hub /usr/local/bin/aether-hub
EXPOSE 8085
ENTRYPOINT ["/usr/local/bin/aether-hub"]
CMD ["--bind", "0.0.0.0:8085"]

View File

@@ -1,38 +0,0 @@
# aether-hub
`aether-hub` 是 Tunnel Hub 服务,负责在 proxy 与 worker 之间路由帧。
已集成在Docker镜像中, 无需单独部署。
## 部署端指定 Hub 版本并构建
```bash
cd /path/to/Aether
./deploy.sh --hub-tag hub-v0.1.0
```
不指定 `--hub-tag` 时,`./deploy.sh` 会自动解析最新 `hub-v*` release并在构建 app 镜像时从 GitHub Release 下载对应架构的 Hub 二进制。
## build.sh 模式说明
- 默认是 `binary` 模式(`cross` 构建二进制)。
- `--upload <hub-vX.Y.Z>` 会把构建产物上传到 GitHub Release。
-`--image` 后进入镜像模式(`docker buildx`,可选)。
常用参数:
- `--tag <tag>`: 镜像 tag
- `--image-name <name>`: 镜像名(默认 `ghcr.io/fawney19/aether-hub`
- `--platforms <list>`: 例如 `linux/amd64,linux/arm64`
- `--push`: 推送镜像
- `--load`: 加载到本地 Docker单平台
- `--latest`: 额外打 `latest` tag
## 运行时参数
- `TUNNEL_HUB_WORKER_IDLE_TIMEOUT`worker 心跳空闲超时,默认 `60`
- `TUNNEL_HUB_OUTBOUND_QUEUE_CAPACITY`:单连接出站队列容量,默认 `128`;队列打满时会把连接视为拥塞并主动关闭,避免 Hub 内存无限增长
## 与部署脚本关系
- `./deploy.sh`: 本地构建部署(会本地构建 app/base并在构建 app 时从 GitHub Release 下载 Hub可用 `--hub-tag` 固定版本)。

View File

@@ -1,272 +0,0 @@
#!/bin/bash
# aether-hub 构建脚本
#
# 支持两种模式:
# 1) binary 模式(默认): 构建多架构二进制并可上传 GitHub Release
# 2) image 模式: 构建并推送/加载 Docker 镜像(推荐生产发布用)
#
# 示例:
# # binary 模式(兼容旧行为)
# ./build.sh
# ./build.sh amd64
# ./build.sh --upload hub-v0.1.0
#
# # image 模式(多架构推送)
# ./build.sh --image --tag v0.2.5 --push --latest
# ./build.sh --image --tag sha-abc123 --image-name ghcr.io/fawney19/aether-hub --push
# ./build.sh --image --tag local-test --platforms linux/amd64 --load
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DIST_DIR="$SCRIPT_DIR/dist"
# -------------------------------
# Defaults
# -------------------------------
MODE="binary" # binary | image
# binary mode options
UPLOAD=false
UPLOAD_TAG=""
BINARY_TARGETS=""
# image mode options
IMAGE_NAME="${IMAGE_NAME:-ghcr.io/fawney19/aether-hub}"
IMAGE_TAG=""
IMAGE_PLATFORMS="linux/amd64,linux/arm64"
IMAGE_PUSH=false
IMAGE_LOAD=false
IMAGE_LATEST=false
usage() {
cat <<'EOF'
用法:
./build.sh [binary-args]
./build.sh --image [image-args]
binary 模式(默认):
amd64|arm64 仅构建指定架构(可重复)
--upload <hub-vX.Y.Z> 上传到 GitHub Release需要 gh CLI
image 模式:
--image 启用镜像模式
--tag <tag> 镜像 tag默认自动从 git describe 推导)
--image-name <name> 镜像名(默认 ghcr.io/fawney19/aether-hub
--platforms <list> 平台列表,逗号分隔(默认 linux/amd64,linux/arm64
--push 推送镜像到仓库
--load 加载到本地 Docker仅单平台
--latest 额外打 latest tag
通用:
-h, --help 显示帮助
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--image)
MODE="image"
shift
;;
--tag)
IMAGE_TAG="${2:-}"
shift 2
;;
--image-name)
IMAGE_NAME="${2:-}"
shift 2
;;
--platforms)
IMAGE_PLATFORMS="${2:-}"
shift 2
;;
--push)
IMAGE_PUSH=true
shift
;;
--load)
IMAGE_LOAD=true
shift
;;
--latest)
IMAGE_LATEST=true
shift
;;
--upload)
UPLOAD=true
UPLOAD_TAG="${2:-}"
shift 2
;;
amd64|arm64)
BINARY_TARGETS="$BINARY_TARGETS $1"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "❌ 未知参数: $1"
usage
exit 1
;;
esac
done
build_binary() {
if [ -z "$BINARY_TARGETS" ]; then
BINARY_TARGETS="amd64 arm64"
fi
if ! command -v cross >/dev/null 2>&1; then
echo "❌ 需要安装 cross: cargo install cross --git https://github.com/cross-rs/cross"
exit 1
fi
mkdir -p "$DIST_DIR"
echo "🔨 开始构建 aether-hub 二进制..."
echo " 目标平台: $BINARY_TARGETS"
echo ""
ARTIFACTS=""
for arch in $BINARY_TARGETS; do
case "$arch" in
amd64) target="x86_64-unknown-linux-gnu" ;;
arm64) target="aarch64-unknown-linux-gnu" ;;
*) echo "❌ 未知架构: $arch"; exit 1 ;;
esac
echo ">>> 构建 $arch ($target)..."
cd "$SCRIPT_DIR"
cross build --release --target "$target" --locked
BIN="target/$target/release/aether-hub"
if [ ! -f "$BIN" ]; then
echo "❌ 未找到二进制文件: $BIN"
exit 1
fi
ARCHIVE="$DIST_DIR/aether-hub-linux-$arch.tar.gz"
tar czf "$ARCHIVE" -C "target/$target/release" aether-hub
ARTIFACTS="$ARTIFACTS $ARCHIVE"
SIZE=$(du -h "$ARCHIVE" | cut -f1)
echo "$arch 构建完成: $ARCHIVE ($SIZE)"
echo ""
done
cd "$DIST_DIR"
shasum -a 256 aether-hub-*.tar.gz > SHA256SUMS.txt
echo "📋 SHA256 校验和:"
cat SHA256SUMS.txt
echo ""
if [ "$UPLOAD" = true ]; then
if [ -z "$UPLOAD_TAG" ]; then
echo "❌ --upload 需要指定 tag例如: ./build.sh --upload hub-v0.1.0"
exit 1
fi
if ! command -v gh >/dev/null 2>&1; then
echo "❌ 需要安装 GitHub CLI: brew install gh"
exit 1
fi
echo "📦 上传到 GitHub Release: $UPLOAD_TAG"
cd "$PROJECT_DIR"
if ! git rev-parse "$UPLOAD_TAG" >/dev/null 2>&1; then
git tag "$UPLOAD_TAG"
git push origin "$UPLOAD_TAG"
fi
gh release create "$UPLOAD_TAG" \
--title "aether-hub ${UPLOAD_TAG#hub-}" \
--generate-notes \
$ARTIFACTS \
"$DIST_DIR/SHA256SUMS.txt"
echo "✅ 上传完成!"
fi
echo "🎉 binary 模式完成!"
}
build_image() {
if ! command -v docker >/dev/null 2>&1; then
echo "❌ 未找到 docker请先安装 Docker"
exit 1
fi
if ! docker buildx version >/dev/null 2>&1; then
echo "❌ 未找到 docker buildx请先启用 buildx"
exit 1
fi
if [ "$IMAGE_PUSH" = true ] && [ "$IMAGE_LOAD" = true ]; then
echo "❌ --push 与 --load 不能同时使用"
exit 1
fi
if [ "$IMAGE_PUSH" = false ] && [ "$IMAGE_LOAD" = false ]; then
# image 模式默认走 push符合发布场景
IMAGE_PUSH=true
fi
if [ -z "$IMAGE_TAG" ]; then
IMAGE_TAG=$(git -C "$PROJECT_DIR" describe --tags --always 2>/dev/null | sed 's/^v//')
if [ -z "$IMAGE_TAG" ]; then
IMAGE_TAG=$(date +%Y%m%d%H%M%S)
fi
fi
if [ "$IMAGE_LOAD" = true ] && [[ "$IMAGE_PLATFORMS" == *,* ]]; then
echo "❌ --load 仅支持单平台,请用 --platforms linux/amd64或 arm64"
exit 1
fi
local ref="${IMAGE_NAME}:${IMAGE_TAG}"
local cmd=(docker buildx build
--platform "$IMAGE_PLATFORMS"
-f "$SCRIPT_DIR/Dockerfile"
-t "$ref"
)
if [ "$IMAGE_LATEST" = true ]; then
cmd+=(-t "${IMAGE_NAME}:latest")
fi
if [ "$IMAGE_PUSH" = true ]; then
cmd+=(--push)
else
cmd+=(--load)
fi
cmd+=("$SCRIPT_DIR")
echo "🔨 开始构建 aether-hub 镜像..."
echo " image: $ref"
echo " platforms: $IMAGE_PLATFORMS"
echo " mode: $([ "$IMAGE_PUSH" = true ] && echo push || echo load)"
echo ""
"${cmd[@]}"
if [ "$IMAGE_PUSH" = true ]; then
echo "✅ 镜像已推送: $ref"
if [ "$IMAGE_LATEST" = true ]; then
echo "✅ 镜像已推送: ${IMAGE_NAME}:latest"
fi
else
echo "✅ 镜像已加载到本地: $ref"
fi
echo "🎉 image 模式完成!"
}
if [ "$MODE" = "image" ]; then
build_image
else
build_binary
fi

View File

@@ -1,88 +0,0 @@
use aether_http::{build_http_client, HttpClientConfig};
use reqwest::Client;
#[derive(Clone)]
pub struct ControlPlaneClient {
client: Option<Client>,
base_url: String,
}
impl ControlPlaneClient {
pub fn new(base_url: String) -> Self {
let client = build_http_client(&HttpClientConfig {
request_timeout_ms: Some(10_000),
user_agent: Some("aether-hub/control-plane".to_string()),
..HttpClientConfig::default()
})
.ok();
Self { client, base_url }
}
pub fn disabled() -> Self {
Self {
client: None,
base_url: String::new(),
}
}
pub async fn heartbeat_ack(&self, payload: &[u8]) -> Result<Vec<u8>, String> {
let Some(client) = &self.client else {
return Ok(b"{}".to_vec());
};
let url = format!(
"{}/api/internal/hub/heartbeat",
self.base_url.trim_end_matches('/')
);
let response = client
.post(&url)
.header("content-type", "application/json")
.body(payload.to_vec())
.send()
.await
.map_err(|e| format!("heartbeat callback request failed: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"heartbeat callback failed with status {}",
response.status()
));
}
response
.bytes()
.await
.map(|bytes| bytes.to_vec())
.map_err(|e| format!("heartbeat callback body read failed: {e}"))
}
pub async fn push_node_status(
&self,
node_id: &str,
connected: bool,
conn_count: usize,
) -> Result<(), String> {
let Some(client) = &self.client else {
return Ok(());
};
let url = format!(
"{}/api/internal/hub/node-status",
self.base_url.trim_end_matches('/')
);
let response = client
.post(&url)
.json(&serde_json::json!({
"node_id": node_id,
"connected": connected,
"conn_count": conn_count,
}))
.send()
.await
.map_err(|e| format!("node-status callback request failed: {e}"))?;
if response.status().is_success() {
Ok(())
} else {
Err(format!(
"node-status callback failed with status {}",
response.status()
))
}
}
}

View File

@@ -1,250 +0,0 @@
use std::net::SocketAddr;
use std::time::Duration;
use aether_hub::{build_router_with_state, AppState, ConnConfig, ControlPlaneClient};
use aether_runtime::{
init_service_runtime, DistributedConcurrencyGate, RedisDistributedConcurrencyConfig,
ServiceRuntimeConfig,
};
use clap::Parser;
use tracing::info;
#[derive(Parser, Debug)]
#[command(name = "aether-hub", about = "Tunnel Hub for Aether")]
struct Args {
#[arg(long, default_value = "0.0.0.0:8085", env = "TUNNEL_HUB_BIND")]
bind: String,
#[arg(long, default_value_t = 0, env = "TUNNEL_HUB_PROXY_IDLE_TIMEOUT")]
proxy_idle_timeout: u64,
#[arg(long, default_value_t = 15, env = "TUNNEL_HUB_PING_INTERVAL")]
ping_interval: u64,
#[arg(long, default_value_t = 2048, env = "TUNNEL_HUB_MAX_STREAMS")]
max_streams: usize,
#[arg(
long,
default_value_t = 128,
env = "TUNNEL_HUB_OUTBOUND_QUEUE_CAPACITY"
)]
outbound_queue_capacity: usize,
#[arg(
long,
default_value = "http://127.0.0.1:8084",
env = "TUNNEL_HUB_APP_BASE_URL"
)]
app_base_url: String,
#[arg(long, env = "TUNNEL_HUB_MAX_IN_FLIGHT_REQUESTS")]
max_in_flight_requests: Option<usize>,
#[arg(long, env = "TUNNEL_HUB_DISTRIBUTED_REQUEST_LIMIT")]
distributed_request_limit: Option<usize>,
#[arg(long, env = "TUNNEL_HUB_DISTRIBUTED_REQUEST_REDIS_URL")]
distributed_request_redis_url: Option<String>,
#[arg(long, env = "TUNNEL_HUB_DISTRIBUTED_REQUEST_REDIS_KEY_PREFIX")]
distributed_request_redis_key_prefix: Option<String>,
#[arg(
long,
env = "TUNNEL_HUB_DISTRIBUTED_REQUEST_LEASE_TTL_MS",
default_value_t = 30_000
)]
distributed_request_lease_ttl_ms: u64,
#[arg(
long,
env = "TUNNEL_HUB_DISTRIBUTED_REQUEST_RENEW_INTERVAL_MS",
default_value_t = 10_000
)]
distributed_request_renew_interval_ms: u64,
#[arg(
long,
env = "TUNNEL_HUB_DISTRIBUTED_REQUEST_COMMAND_TIMEOUT_MS",
default_value_t = 1_000
)]
distributed_request_command_timeout_ms: u64,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
init_service_runtime(ServiceRuntimeConfig::new("aether-hub", "aether_hub=info"))?;
let args = Args::parse();
let outbound_queue_capacity = args.outbound_queue_capacity.clamp(8, 4096);
let ping_interval = Duration::from_secs(args.ping_interval);
let mut state = AppState::new(
ControlPlaneClient::new(args.app_base_url),
ConnConfig {
ping_interval,
idle_timeout: Duration::from_secs(args.proxy_idle_timeout),
outbound_queue_capacity,
},
args.max_streams,
)
.with_request_concurrency_limit(args.max_in_flight_requests);
if let Some(limit) = args.distributed_request_limit.filter(|limit| *limit > 0) {
let redis_url = args
.distributed_request_redis_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"TUNNEL_HUB_DISTRIBUTED_REQUEST_REDIS_URL is required when distributed request limit is enabled",
)
})?;
state = state.with_distributed_request_gate(DistributedConcurrencyGate::new_redis(
"hub_requests_distributed",
limit,
RedisDistributedConcurrencyConfig {
url: redis_url.to_string(),
key_prefix: args
.distributed_request_redis_key_prefix
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
lease_ttl_ms: args.distributed_request_lease_ttl_ms.max(1),
renew_interval_ms: args.distributed_request_renew_interval_ms.max(1),
command_timeout_ms: Some(args.distributed_request_command_timeout_ms.max(1)),
},
)?);
}
let app = build_router_with_state(state);
let listener = tokio::net::TcpListener::bind(&args.bind).await?;
info!(bind = %args.bind, "aether-hub started");
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{HeaderValue, StatusCode};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
async fn start_server(app: axum::Router) -> (String, tokio::task::JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.expect("server should run");
});
(format!("http://{addr}"), handle)
}
#[tokio::test]
async fn hub_exposes_metrics_endpoint() {
let app = build_router_with_state(AppState::new(
ControlPlaneClient::disabled(),
ConnConfig {
ping_interval: Duration::from_secs(15),
idle_timeout: Duration::from_secs(0),
outbound_queue_capacity: 128,
},
128,
));
let (base_url, handle) = start_server(app).await;
let response = reqwest::Client::new()
.get(format!("{base_url}/metrics"))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), axum::http::StatusCode::OK);
assert_eq!(
response
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()),
Some("text/plain; version=0.0.4; charset=utf-8")
);
let body = response.text().await.expect("body should read");
assert!(body.contains("service_up{service=\"aether-hub\"} 1"));
assert!(body.contains("hub_proxy_connections 0"));
assert!(body.contains("hub_active_streams 0"));
handle.abort();
}
#[tokio::test]
async fn hub_rejects_second_proxy_connection_with_distributed_overload() {
let distributed_gate =
DistributedConcurrencyGate::new_in_memory("hub_requests_distributed", 1);
let app_a = build_router_with_state(
AppState::new(
ControlPlaneClient::disabled(),
ConnConfig {
ping_interval: Duration::from_secs(15),
idle_timeout: Duration::from_secs(0),
outbound_queue_capacity: 128,
},
128,
)
.with_distributed_request_gate(distributed_gate.clone()),
);
let app_b = build_router_with_state(
AppState::new(
ControlPlaneClient::disabled(),
ConnConfig {
ping_interval: Duration::from_secs(15),
idle_timeout: Duration::from_secs(0),
outbound_queue_capacity: 128,
},
128,
)
.with_distributed_request_gate(distributed_gate),
);
let (base_a, handle_a) = start_server(app_a).await;
let (base_b, handle_b) = start_server(app_b).await;
let request_a = format!("{}/proxy", base_a.replace("http://", "ws://"))
.into_client_request()
.expect("request should build");
let mut request_a = request_a;
request_a
.headers_mut()
.insert("x-node-id", HeaderValue::from_static("node-a"));
let (socket, _) = tokio_tungstenite::connect_async(request_a)
.await
.expect("first websocket should connect");
let request_b = format!("{}/proxy", base_b.replace("http://", "ws://"))
.into_client_request()
.expect("request should build");
let mut request_b = request_b;
request_b
.headers_mut()
.insert("x-node-id", HeaderValue::from_static("node-b"));
let error = tokio_tungstenite::connect_async(request_b)
.await
.expect_err("second websocket should be rejected");
match error {
tokio_tungstenite::tungstenite::Error::Http(response) => {
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
other => panic!("unexpected websocket error: {other}"),
}
drop(socket);
handle_a.abort();
handle_b.abort();
}
}

View File

@@ -1,176 +0,0 @@
/// Tunnel binary frame protocol
///
/// Frame format (10-byte header + payload):
/// | stream_id (4B) | msg_type (1B) | flags (1B) | payload_len (4B) | payload (NB) |
use std::io::Read;
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use flate2::Compression;
pub const HEADER_SIZE: usize = 10;
// Message types
pub const REQUEST_HEADERS: u8 = 0x01;
pub const REQUEST_BODY: u8 = 0x02;
pub const RESPONSE_HEADERS: u8 = 0x03;
pub const RESPONSE_BODY: u8 = 0x04;
pub const STREAM_END: u8 = 0x05;
pub const STREAM_ERROR: u8 = 0x06;
pub const PING: u8 = 0x10;
pub const PONG: u8 = 0x11;
pub const GOAWAY: u8 = 0x12;
pub const HEARTBEAT_DATA: u8 = 0x13;
pub const HEARTBEAT_ACK: u8 = 0x14;
// Flags
pub const FLAG_END_STREAM: u8 = 0x01;
pub const FLAG_GZIP_COMPRESSED: u8 = 0x02;
#[derive(Debug, Clone, Copy)]
pub struct FrameHeader {
pub stream_id: u32,
pub msg_type: u8,
pub flags: u8,
pub payload_len: u32,
}
impl FrameHeader {
/// Parse frame header from raw bytes (must be >= HEADER_SIZE)
#[inline]
pub fn parse(data: &[u8]) -> Option<Self> {
if data.len() < HEADER_SIZE {
return None;
}
Some(Self {
stream_id: u32::from_be_bytes([data[0], data[1], data[2], data[3]]),
msg_type: data[4],
flags: data[5],
payload_len: u32::from_be_bytes([data[6], data[7], data[8], data[9]]),
})
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RequestMeta {
pub method: String,
pub url: String,
pub headers: std::collections::HashMap<String, String>,
#[serde(default = "default_timeout", deserialize_with = "deserialize_timeout")]
pub timeout: u64,
}
fn default_timeout() -> u64 {
60
}
fn deserialize_timeout<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum TimeoutValue {
Int(u64),
Float(f64),
}
match <TimeoutValue as serde::Deserialize>::deserialize(deserializer)? {
TimeoutValue::Int(v) => Ok(v),
TimeoutValue::Float(v) => {
if !v.is_finite() || v < 0.0 {
return Err(serde::de::Error::custom(
"timeout must be a non-negative finite number",
));
}
if v.fract() != 0.0 {
return Err(serde::de::Error::custom("timeout must be integer seconds"));
}
if v > (u64::MAX as f64) {
return Err(serde::de::Error::custom("timeout is too large"));
}
Ok(v as u64)
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ResponseMeta {
pub status: u16,
pub headers: Vec<(String, String)>,
}
pub fn encode_frame(stream_id: u32, msg_type: u8, flags: u8, payload: &[u8]) -> Vec<u8> {
let mut buf = Vec::with_capacity(HEADER_SIZE + payload.len());
buf.extend_from_slice(&stream_id.to_be_bytes());
buf.push(msg_type);
buf.push(flags);
buf.extend_from_slice(&(payload.len() as u32).to_be_bytes());
buf.extend_from_slice(payload);
buf
}
/// Encode a STREAM_ERROR frame for a given stream_id with an error message
pub fn encode_stream_error(stream_id: u32, msg: &str) -> Vec<u8> {
encode_frame(stream_id, STREAM_ERROR, 0, msg.as_bytes())
}
/// Encode a PING frame (stream_id=0)
pub fn encode_ping() -> Vec<u8> {
encode_frame(0, PING, 0, &[])
}
/// Encode a PONG frame (stream_id=0, echo payload)
pub fn encode_pong(payload: &[u8]) -> Vec<u8> {
encode_frame(0, PONG, 0, payload)
}
/// Encode a GOAWAY frame (stream_id=0)
pub fn encode_goaway() -> Vec<u8> {
encode_frame(0, GOAWAY, 0, &[])
}
#[inline]
pub fn frame_payload_by_header<'a>(data: &'a [u8], header: &FrameHeader) -> Option<&'a [u8]> {
let payload_len = header.payload_len as usize;
let end = HEADER_SIZE.checked_add(payload_len)?;
if data.len() < end {
return None;
}
Some(&data[HEADER_SIZE..end])
}
pub fn decode_payload(data: &[u8], header: &FrameHeader) -> Result<Vec<u8>, String> {
let payload = frame_payload_by_header(data, header)
.ok_or_else(|| "incomplete frame payload".to_string())?;
if header.flags & FLAG_GZIP_COMPRESSED != 0 {
let mut decoder = GzDecoder::new(payload);
let mut decoded = Vec::new();
decoder
.read_to_end(&mut decoded)
.map_err(|e| format!("failed to decompress payload: {e}"))?;
Ok(decoded)
} else {
Ok(payload.to_vec())
}
}
pub fn compress_payload(payload: &[u8]) -> Result<(Vec<u8>, u8), std::io::Error> {
maybe_recompress_payload(payload, true)
}
fn maybe_recompress_payload(
payload: &[u8],
prefer_gzip: bool,
) -> Result<(Vec<u8>, u8), std::io::Error> {
if !prefer_gzip {
return Ok((payload.to_vec(), 0));
}
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
std::io::Write::write_all(&mut encoder, payload)?;
let compressed = encoder.finish()?;
if compressed.len() < payload.len() {
Ok((compressed, FLAG_GZIP_COMPRESSED))
} else {
Ok((payload.to_vec(), 0))
}
}

View File

@@ -1,236 +0,0 @@
pub mod client;
pub mod dispatcher;
pub mod heartbeat;
pub mod protocol;
pub mod stream_handler;
pub mod writer;
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::sync::watch;
use tracing::{error, info};
use crate::state::{AppState, ServerContext};
/// If a tunnel stays connected at least this long, treat the next disconnect
/// as a non-failure and reset reconnect backoff.
const STABLE_SESSION_RESET_AFTER: Duration = Duration::from_secs(30);
/// Startup staggering step per secondary connection, used to avoid
/// simultaneous bursts when a pool of tunnels starts together.
const STARTUP_STAGGER_STEP_MS: u64 = 150;
/// Upper bound for startup staggering.
const MAX_STARTUP_STAGGER_MS: u64 = 1_500;
/// Keep a tiny floor for repeated reconnects; first retry is still immediate.
const MIN_RECONNECT_DELAY_MS: u64 = 50;
/// Even under sustained failures, keep probing frequently so recovery is fast
/// once cross-border network quality improves.
const RECONNECT_PROBE_MAX_DELAY_MS: u64 = 3_000;
/// Run the tunnel mode main loop (connect, dispatch, reconnect).
///
/// `conn_idx` identifies which connection in the pool this is (0-based).
/// Only connection 0 sends heartbeats to avoid resetting shared metrics.
pub async fn run(
state: &Arc<AppState>,
server: &Arc<ServerContext>,
conn_idx: usize,
mut shutdown: watch::Receiver<bool>,
) {
info!(server = %server.server_label, conn = conn_idx, "starting tunnel");
let reconnect_salt = compute_connection_salt(server, conn_idx);
let startup_delay = compute_startup_stagger(conn_idx, reconnect_salt);
if !startup_delay.is_zero() {
info!(
server = %server.server_label,
conn = conn_idx,
delay_ms = startup_delay.as_millis(),
"startup stagger before first connect"
);
tokio::select! {
_ = tokio::time::sleep(startup_delay) => {}
_ = shutdown.changed() => {
info!(server = %server.server_label, conn = conn_idx, "shutdown requested during startup stagger");
return;
}
}
}
let mut consecutive_failures: u32 = 0;
loop {
let started_at = Instant::now();
match client::connect_and_run(state, server, conn_idx, &mut shutdown).await {
Ok(client::TunnelOutcome::Shutdown) => {
info!(server = %server.server_label, conn = conn_idx, "tunnel shut down gracefully");
return;
}
Ok(client::TunnelOutcome::Disconnected) => {
info!(server = %server.server_label, conn = conn_idx, "tunnel disconnected, reconnecting");
}
Err(e) => {
error!(server = %server.server_label, conn = conn_idx, error = %e, "tunnel connection error, reconnecting");
}
}
if *shutdown.borrow() {
info!(server = %server.server_label, conn = conn_idx, "shutdown requested, not reconnecting");
return;
}
// Reset backoff after a stable session to keep recovery snappy when
// failures are only occasional.
let connected_for = started_at.elapsed();
if connected_for >= STABLE_SESSION_RESET_AFTER {
consecutive_failures = 0;
} else {
consecutive_failures = consecutive_failures.saturating_add(1);
}
let reconnect_delay = compute_reconnect_delay(
state.config.tunnel_reconnect_base_ms,
state.config.tunnel_reconnect_max_ms,
consecutive_failures,
reconnect_salt,
);
info!(
server = %server.server_label,
conn = conn_idx,
failures = consecutive_failures,
delay_ms = reconnect_delay.as_millis(),
"waiting before reconnect"
);
tokio::select! {
_ = tokio::time::sleep(reconnect_delay) => {}
_ = shutdown.changed() => {
info!(server = %server.server_label, conn = conn_idx, "shutdown requested during reconnect wait");
return;
}
}
}
}
fn compute_connection_salt(server: &ServerContext, conn_idx: usize) -> u64 {
// FNV-1a style hash over server label + connection index.
let mut h: u64 = 0xcbf29ce484222325;
for &b in server.server_label.as_bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h ^= conn_idx as u64;
mix_u64(h)
}
fn compute_startup_stagger(conn_idx: usize, salt: u64) -> Duration {
if conn_idx == 0 {
return Duration::ZERO;
}
let base = (conn_idx as u64).saturating_mul(STARTUP_STAGGER_STEP_MS);
let jitter = mix_u64(salt) % 301; // 0..=300ms
Duration::from_millis((base + jitter).min(MAX_STARTUP_STAGGER_MS))
}
fn compute_reconnect_delay(
base_ms: u64,
max_ms: u64,
consecutive_failures: u32,
salt: u64,
) -> Duration {
// First retry should be immediate to maximize recovery speed on transient
// blips (the user's primary expectation in poor networks).
if consecutive_failures <= 1 {
return Duration::ZERO;
}
// Keep a sane minimum for repeated failures.
let base_ms = base_ms.max(MIN_RECONNECT_DELAY_MS);
let max_ms = max_ms.max(base_ms);
let cap_ms = compute_reconnect_cap_ms(base_ms, max_ms, consecutive_failures)
.min(RECONNECT_PROBE_MAX_DELAY_MS.max(base_ms));
// Equal-jitter: randomize in [cap/2, cap], preventing synchronized reconnect
// storms while keeping reconnect latency bounded.
if cap_ms <= 1 {
return Duration::from_millis(cap_ms);
}
let half = cap_ms / 2;
let span = cap_ms - half;
let now_nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
let mixed = mix_u64(now_nanos ^ salt);
let jitter = if span == 0 { 0 } else { mixed % (span + 1) };
Duration::from_millis(half + jitter)
}
fn compute_reconnect_cap_ms(base_ms: u64, max_ms: u64, consecutive_failures: u32) -> u64 {
if consecutive_failures <= 1 {
return base_ms.min(max_ms);
}
let shift = (consecutive_failures - 1).min(31);
let factor = 1u64 << shift;
base_ms.saturating_mul(factor).min(max_ms)
}
fn mix_u64(mut x: u64) -> u64 {
// SplitMix64 finalizer - cheap bit mixing for pseudo-random jitter.
x ^= x >> 30;
x = x.wrapping_mul(0xbf58476d1ce4e5b9);
x ^= x >> 27;
x = x.wrapping_mul(0x94d049bb133111eb);
x ^ (x >> 31)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{
compute_reconnect_cap_ms, compute_reconnect_delay, compute_startup_stagger,
MAX_STARTUP_STAGGER_MS, RECONNECT_PROBE_MAX_DELAY_MS, STARTUP_STAGGER_STEP_MS,
};
#[test]
fn reconnect_cap_grows_exponentially_and_caps() {
let base = 500;
let max = 30_000;
assert_eq!(compute_reconnect_cap_ms(base, max, 0), 500);
assert_eq!(compute_reconnect_cap_ms(base, max, 1), 500);
assert_eq!(compute_reconnect_cap_ms(base, max, 2), 1_000);
assert_eq!(compute_reconnect_cap_ms(base, max, 3), 2_000);
assert_eq!(compute_reconnect_cap_ms(base, max, 4), 4_000);
assert_eq!(compute_reconnect_cap_ms(base, max, 5), 8_000);
assert_eq!(compute_reconnect_cap_ms(base, max, 6), 16_000);
assert_eq!(compute_reconnect_cap_ms(base, max, 7), 30_000);
assert_eq!(compute_reconnect_cap_ms(base, max, 20), 30_000);
}
#[test]
fn startup_stagger_is_zero_for_primary_and_bounded_for_secondary() {
assert_eq!(compute_startup_stagger(0, 42), Duration::ZERO);
let d1 = compute_startup_stagger(1, 42);
let d2 = compute_startup_stagger(2, 42);
assert!(d1 >= Duration::from_millis(STARTUP_STAGGER_STEP_MS));
assert!(d1 <= Duration::from_millis(MAX_STARTUP_STAGGER_MS));
assert!(d2 >= Duration::from_millis(STARTUP_STAGGER_STEP_MS * 2));
assert!(d2 <= Duration::from_millis(MAX_STARTUP_STAGGER_MS));
}
#[test]
fn reconnect_delay_is_immediate_on_first_failure() {
assert_eq!(compute_reconnect_delay(700, 45_000, 1, 123), Duration::ZERO);
}
#[test]
fn reconnect_delay_stays_within_probe_ceiling_after_many_failures() {
let d = compute_reconnect_delay(500, 45_000, 100, 12345);
assert!(d <= Duration::from_millis(RECONNECT_PROBE_MAX_DELAY_MS));
}
}

View File

@@ -17,18 +17,20 @@ aether-runtime.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
axum = { version = "0.8" } axum = { version = "0.8", features = ["ws"] }
base64.workspace = true base64.workspace = true
bcrypt.workspace = true bcrypt.workspace = true
bytes.workspace = true bytes.workspace = true
chrono.workspace = true chrono.workspace = true
chrono-tz.workspace = true chrono-tz.workspace = true
clap = { version = "4", features = ["derive", "env"] } clap = { version = "4", features = ["derive", "env"] }
dashmap = "6"
flate2.workspace = true flate2.workspace = true
futures-util.workspace = true futures-util.workspace = true
hmac.workspace = true hmac.workspace = true
http.workspace = true http.workspace = true
ldap3 = "0.11" ldap3 = "0.11"
parking_lot = "0.12"
regex.workspace = true regex.workspace = true
redis.workspace = true redis.workspace = true
reqwest.workspace = true reqwest.workspace = true

View File

@@ -3,57 +3,71 @@ use std::path::PathBuf;
use clap::Parser; use clap::Parser;
use tracing::info; use tracing::info;
use aether_executor::server; use aether_gateway::{serve_execution_runtime_tcp, serve_execution_runtime_unix};
use aether_runtime::{ use aether_runtime::{
init_service_runtime, DistributedConcurrencyGate, RedisDistributedConcurrencyConfig, init_service_runtime, DistributedConcurrencyGate, RedisDistributedConcurrencyConfig,
ServiceRuntimeConfig, ServiceRuntimeConfig,
}; };
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(name = "aether-executor", about = "Internal Rust executor for Aether")] #[command(
name = "execution-runtime-harness",
about = "Internal execution runtime harness for Aether tests"
)]
struct Args { struct Args {
#[arg(long, env = "AETHER_EXECUTOR_TRANSPORT", default_value = "unix_socket")] #[arg(
long,
env = "AETHER_EXECUTION_RUNTIME_TRANSPORT",
default_value = "unix_socket"
)]
transport: String, transport: String,
#[arg(long, env = "AETHER_EXECUTOR_BIND", default_value = "127.0.0.1:5219")] #[arg(
long,
env = "AETHER_EXECUTION_RUNTIME_BIND",
default_value = "127.0.0.1:5219"
)]
bind: String, bind: String,
#[arg( #[arg(
long, long,
env = "AETHER_EXECUTOR_UNIX_SOCKET", env = "AETHER_EXECUTION_RUNTIME_UNIX_SOCKET",
default_value = "/tmp/aether-executor.sock" default_value = "/tmp/aether-execution-runtime.sock"
)] )]
unix_socket: PathBuf, unix_socket: PathBuf,
#[arg(long, env = "AETHER_EXECUTOR_MAX_IN_FLIGHT_REQUESTS")] #[arg(long, env = "AETHER_EXECUTION_RUNTIME_MAX_IN_FLIGHT_REQUESTS")]
max_in_flight_requests: Option<usize>, max_in_flight_requests: Option<usize>,
#[arg(long, env = "AETHER_EXECUTOR_DISTRIBUTED_REQUEST_LIMIT")] #[arg(long, env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_LIMIT")]
distributed_request_limit: Option<usize>, distributed_request_limit: Option<usize>,
#[arg(long, env = "AETHER_EXECUTOR_DISTRIBUTED_REQUEST_REDIS_URL")] #[arg(long, env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_REDIS_URL")]
distributed_request_redis_url: Option<String>, distributed_request_redis_url: Option<String>,
#[arg(long, env = "AETHER_EXECUTOR_DISTRIBUTED_REQUEST_REDIS_KEY_PREFIX")] #[arg(
long,
env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_REDIS_KEY_PREFIX"
)]
distributed_request_redis_key_prefix: Option<String>, distributed_request_redis_key_prefix: Option<String>,
#[arg( #[arg(
long, long,
env = "AETHER_EXECUTOR_DISTRIBUTED_REQUEST_LEASE_TTL_MS", env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_LEASE_TTL_MS",
default_value_t = 30_000 default_value_t = 30_000
)] )]
distributed_request_lease_ttl_ms: u64, distributed_request_lease_ttl_ms: u64,
#[arg( #[arg(
long, long,
env = "AETHER_EXECUTOR_DISTRIBUTED_REQUEST_RENEW_INTERVAL_MS", env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_RENEW_INTERVAL_MS",
default_value_t = 10_000 default_value_t = 10_000
)] )]
distributed_request_renew_interval_ms: u64, distributed_request_renew_interval_ms: u64,
#[arg( #[arg(
long, long,
env = "AETHER_EXECUTOR_DISTRIBUTED_REQUEST_COMMAND_TIMEOUT_MS", env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_COMMAND_TIMEOUT_MS",
default_value_t = 1_000 default_value_t = 1_000
)] )]
distributed_request_command_timeout_ms: u64, distributed_request_command_timeout_ms: u64,
@@ -64,8 +78,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
init_service_runtime(ServiceRuntimeConfig::new( init_service_runtime(ServiceRuntimeConfig::new(
"aether-executor", "aether-execution-runtime-harness",
"aether_executor=info", "aether_gateway=info",
))?; ))?;
let args = Args::parse(); let args = Args::parse();
@@ -77,13 +91,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.ok_or_else(|| { .ok_or_else(|| {
std::io::Error::new( std::io::Error::new(
std::io::ErrorKind::InvalidInput, std::io::ErrorKind::InvalidInput,
"AETHER_EXECUTOR_DISTRIBUTED_REQUEST_REDIS_URL is required when distributed request limit is enabled", "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_REDIS_URL is required when distributed request limit is enabled",
) )
})?; })?;
Some(DistributedConcurrencyGate::new_redis( Some(DistributedConcurrencyGate::new_redis(
"executor_requests_distributed", "execution_runtime_requests_distributed",
limit, limit,
RedisDistributedConcurrencyConfig { RedisDistributedConcurrencyConfig {
url: redis_url.to_string(), url: redis_url.to_string(),
@@ -101,10 +115,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
None => None, None => None,
}; };
match args.transport.trim().to_ascii_lowercase().as_str() { match args.transport.trim().to_ascii_lowercase().as_str() {
"unix_socket" | "unix" | "uds" => { "unix_socket" | "unix" | "uds" => {
info!(socket = %args.unix_socket.display(), "aether-executor started"); info!(
server::serve_unix( socket = %args.unix_socket.display(),
"aether execution-runtime harness started"
);
serve_execution_runtime_unix(
&args.unix_socket, &args.unix_socket,
args.max_in_flight_requests, args.max_in_flight_requests,
distributed_request_gate.clone(), distributed_request_gate.clone(),
@@ -116,9 +134,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
bind = %args.bind, bind = %args.bind,
max_in_flight_requests = args.max_in_flight_requests.unwrap_or_default(), max_in_flight_requests = args.max_in_flight_requests.unwrap_or_default(),
distributed_request_limit = args.distributed_request_limit.unwrap_or_default(), distributed_request_limit = args.distributed_request_limit.unwrap_or_default(),
"aether-executor started" "aether execution-runtime harness started"
); );
server::serve_tcp( serve_execution_runtime_tcp(
&args.bind, &args.bind,
args.max_in_flight_requests, args.max_in_flight_requests,
distributed_request_gate, distributed_request_gate,
@@ -126,7 +144,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.await?; .await?;
} }
other => { other => {
return Err(format!("unsupported executor transport: {other}").into()); return Err(format!("unsupported execution runtime transport: {other}").into());
} }
} }

View File

@@ -0,0 +1,161 @@
use std::net::SocketAddr;
use std::time::Duration;
use aether_gateway::{
build_tunnel_runtime_router_with_state, TunnelConnConfig, TunnelControlPlaneClient,
TunnelRuntimeState,
};
use aether_runtime::{
init_service_runtime, DistributedConcurrencyGate, RedisDistributedConcurrencyConfig,
ServiceRuntimeConfig,
};
use clap::Parser;
use tracing::info;
#[derive(Parser, Debug)]
#[command(
name = "aether-tunnel-runtime-harness",
about = "Standalone tunnel relay harness backed by aether-gateway tunnel runtime"
)]
struct Args {
#[arg(
long,
default_value = "0.0.0.0:8085",
env = "AETHER_TUNNEL_STANDALONE_BIND"
)]
bind: String,
#[arg(
long,
default_value_t = 0,
env = "AETHER_TUNNEL_STANDALONE_PROXY_IDLE_TIMEOUT"
)]
proxy_idle_timeout: u64,
#[arg(
long,
default_value_t = 15,
env = "AETHER_TUNNEL_STANDALONE_PING_INTERVAL"
)]
ping_interval: u64,
#[arg(
long,
default_value_t = 2048,
env = "AETHER_TUNNEL_STANDALONE_MAX_STREAMS"
)]
max_streams: usize,
#[arg(
long,
default_value_t = 128,
env = "AETHER_TUNNEL_STANDALONE_OUTBOUND_QUEUE_CAPACITY"
)]
outbound_queue_capacity: usize,
#[arg(
long,
default_value = "http://127.0.0.1:8084",
env = "AETHER_TUNNEL_STANDALONE_APP_BASE_URL"
)]
app_base_url: String,
#[arg(long, env = "AETHER_TUNNEL_STANDALONE_MAX_IN_FLIGHT_REQUESTS")]
max_in_flight_requests: Option<usize>,
#[arg(long, env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_LIMIT")]
distributed_request_limit: Option<usize>,
#[arg(long, env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_REDIS_URL")]
distributed_request_redis_url: Option<String>,
#[arg(
long,
env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_REDIS_KEY_PREFIX"
)]
distributed_request_redis_key_prefix: Option<String>,
#[arg(
long,
env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_LEASE_TTL_MS",
default_value_t = 30_000
)]
distributed_request_lease_ttl_ms: u64,
#[arg(
long,
env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_RENEW_INTERVAL_MS",
default_value_t = 10_000
)]
distributed_request_renew_interval_ms: u64,
#[arg(
long,
env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_COMMAND_TIMEOUT_MS",
default_value_t = 1_000
)]
distributed_request_command_timeout_ms: u64,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
init_service_runtime(ServiceRuntimeConfig::new(
"aether-tunnel-standalone",
"aether_gateway=info",
))?;
let args = Args::parse();
let outbound_queue_capacity = args.outbound_queue_capacity.clamp(8, 4096);
let ping_interval = Duration::from_secs(args.ping_interval);
let mut state = TunnelRuntimeState::new(
TunnelControlPlaneClient::new(args.app_base_url),
TunnelConnConfig {
ping_interval,
idle_timeout: Duration::from_secs(args.proxy_idle_timeout),
outbound_queue_capacity,
},
args.max_streams,
)
.with_request_concurrency_limit(args.max_in_flight_requests);
if let Some(limit) = args.distributed_request_limit.filter(|limit| *limit > 0) {
let redis_url = args
.distributed_request_redis_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_REDIS_URL is required when distributed request limit is enabled",
)
})?;
state = state.with_distributed_request_gate(DistributedConcurrencyGate::new_redis(
"tunnel_requests_distributed",
limit,
RedisDistributedConcurrencyConfig {
url: redis_url.to_string(),
key_prefix: args
.distributed_request_redis_key_prefix
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
lease_ttl_ms: args.distributed_request_lease_ttl_ms.max(1),
renew_interval_ms: args.distributed_request_renew_interval_ms.max(1),
command_timeout_ms: Some(args.distributed_request_command_timeout_ms.max(1)),
},
)?);
}
let app = build_tunnel_runtime_router_with_state(state);
let listener = tokio::net::TcpListener::bind(&args.bind).await?;
info!(bind = %args.bind, "tunnel runtime harness started");
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;
Ok(())
}

View File

@@ -0,0 +1,165 @@
use serde_json::{Map, Value};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum LocalCoreSyncErrorKind {
InvalidRequest,
Authentication,
PermissionDenied,
NotFound,
RateLimit,
ContextLengthExceeded,
Overloaded,
ServerError,
}
pub(crate) fn is_core_error_finalize_kind(report_kind: &str) -> bool {
core_error_default_client_api_format(report_kind).is_some()
}
pub(crate) fn core_error_default_client_api_format(report_kind: &str) -> Option<&'static str> {
match report_kind {
"openai_chat_sync_finalize" => Some("openai:chat"),
"claude_chat_sync_finalize" => Some("claude:chat"),
"gemini_chat_sync_finalize" => Some("gemini:chat"),
"openai_cli_sync_finalize" => Some("openai:cli"),
"openai_compact_sync_finalize" => Some("openai:compact"),
"claude_cli_sync_finalize" => Some("claude:cli"),
"gemini_cli_sync_finalize" => Some("gemini:cli"),
_ => None,
}
}
pub(crate) fn core_error_background_report_kind(report_kind: &str) -> Option<&'static str> {
match report_kind {
"openai_chat_sync_finalize" => Some("openai_chat_sync_error"),
"claude_chat_sync_finalize" => Some("claude_chat_sync_error"),
"gemini_chat_sync_finalize" => Some("gemini_chat_sync_error"),
"openai_cli_sync_finalize" => Some("openai_cli_sync_error"),
"openai_compact_sync_finalize" => Some("openai_compact_sync_error"),
"claude_cli_sync_finalize" => Some("claude_cli_sync_error"),
"gemini_cli_sync_finalize" => Some("gemini_cli_sync_error"),
_ => None,
}
}
#[cfg(test)]
pub(crate) fn core_success_background_report_kind(report_kind: &str) -> Option<&'static str> {
match report_kind {
"openai_chat_sync_finalize" => Some("openai_chat_sync_success"),
"claude_chat_sync_finalize" => Some("claude_chat_sync_success"),
"gemini_chat_sync_finalize" => Some("gemini_chat_sync_success"),
"openai_cli_sync_finalize" | "openai_compact_sync_finalize" => {
Some("openai_cli_sync_success")
}
"claude_cli_sync_finalize" => Some("claude_cli_sync_success"),
"gemini_cli_sync_finalize" => Some("gemini_cli_sync_success"),
_ => None,
}
}
pub(crate) fn build_core_error_body_for_client_format(
client_api_format: &str,
message: &str,
code: Option<&str>,
kind: LocalCoreSyncErrorKind,
) -> Option<Value> {
let mut error_object = Map::new();
error_object.insert("message".to_string(), Value::String(message.to_string()));
match client_api_format.trim().to_ascii_lowercase().as_str() {
"openai:chat" | "openai:cli" | "openai:compact" => {
error_object.insert(
"type".to_string(),
Value::String(map_local_sync_error_kind_to_openai_type(kind).to_string()),
);
if let Some(code) = code.filter(|value| !value.is_empty()) {
error_object.insert("code".to_string(), Value::String(code.to_string()));
}
Some(Value::Object(Map::from_iter([(
"error".to_string(),
Value::Object(error_object),
)])))
}
"claude:chat" | "claude:cli" => {
error_object.insert(
"type".to_string(),
Value::String(map_local_sync_error_kind_to_claude_type(kind).to_string()),
);
if let Some(code) = code.filter(|value| !value.is_empty()) {
error_object.insert("code".to_string(), Value::String(code.to_string()));
}
Some(Value::Object(Map::from_iter([
("type".to_string(), Value::String("error".to_string())),
("error".to_string(), Value::Object(error_object)),
])))
}
"gemini:chat" | "gemini:cli" => Some(Value::Object(Map::from_iter([(
"error".to_string(),
Value::Object(Map::from_iter([
(
"code".to_string(),
Value::from(map_local_sync_error_kind_to_gemini_code(kind)),
),
("message".to_string(), Value::String(message.to_string())),
(
"status".to_string(),
Value::String(map_local_sync_error_kind_to_gemini_status(kind).to_string()),
),
])),
)]))),
_ => None,
}
}
fn map_local_sync_error_kind_to_openai_type(kind: LocalCoreSyncErrorKind) -> &'static str {
match kind {
LocalCoreSyncErrorKind::InvalidRequest => "invalid_request_error",
LocalCoreSyncErrorKind::Authentication => "authentication_error",
LocalCoreSyncErrorKind::PermissionDenied => "permission_error",
LocalCoreSyncErrorKind::NotFound => "not_found_error",
LocalCoreSyncErrorKind::RateLimit => "rate_limit_error",
LocalCoreSyncErrorKind::ContextLengthExceeded => "context_length_exceeded",
LocalCoreSyncErrorKind::Overloaded | LocalCoreSyncErrorKind::ServerError => "server_error",
}
}
fn map_local_sync_error_kind_to_claude_type(kind: LocalCoreSyncErrorKind) -> &'static str {
match kind {
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
"invalid_request_error"
}
LocalCoreSyncErrorKind::Authentication => "authentication_error",
LocalCoreSyncErrorKind::PermissionDenied => "permission_error",
LocalCoreSyncErrorKind::NotFound => "not_found_error",
LocalCoreSyncErrorKind::RateLimit => "rate_limit_error",
LocalCoreSyncErrorKind::Overloaded | LocalCoreSyncErrorKind::ServerError => "api_error",
}
}
fn map_local_sync_error_kind_to_gemini_code(kind: LocalCoreSyncErrorKind) -> u16 {
match kind {
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
400
}
LocalCoreSyncErrorKind::Authentication => 401,
LocalCoreSyncErrorKind::PermissionDenied => 403,
LocalCoreSyncErrorKind::NotFound => 404,
LocalCoreSyncErrorKind::RateLimit => 429,
LocalCoreSyncErrorKind::Overloaded => 503,
LocalCoreSyncErrorKind::ServerError => 500,
}
}
fn map_local_sync_error_kind_to_gemini_status(kind: LocalCoreSyncErrorKind) -> &'static str {
match kind {
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
"INVALID_ARGUMENT"
}
LocalCoreSyncErrorKind::Authentication => "UNAUTHENTICATED",
LocalCoreSyncErrorKind::PermissionDenied => "PERMISSION_DENIED",
LocalCoreSyncErrorKind::NotFound => "NOT_FOUND",
LocalCoreSyncErrorKind::RateLimit => "RESOURCE_EXHAUSTED",
LocalCoreSyncErrorKind::Overloaded => "UNAVAILABLE",
LocalCoreSyncErrorKind::ServerError => "INTERNAL",
}
}

View File

@@ -0,0 +1,17 @@
pub(crate) mod error;
pub(crate) mod registry;
pub(crate) mod request;
pub(crate) mod response;
#[cfg(test)]
pub(crate) use error::core_success_background_report_kind;
pub(crate) use error::{
build_core_error_body_for_client_format, core_error_background_report_kind,
core_error_default_client_api_format, is_core_error_finalize_kind, LocalCoreSyncErrorKind,
};
pub(crate) use registry::{
request_conversion_direct_auth, request_conversion_kind,
request_conversion_transport_supported, sync_chat_response_conversion_kind,
sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,
SyncCliResponseConversionKind,
};

View File

@@ -0,0 +1,204 @@
use crate::gateway::provider_transport::{
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,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RequestConversionKind {
ToOpenAIChat,
ToOpenAIFamilyCli,
ToOpenAICompact,
ToClaudeStandard,
ToGeminiStandard,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SyncChatResponseConversionKind {
ToOpenAIChat,
ToClaudeChat,
ToGeminiChat,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SyncCliResponseConversionKind {
ToOpenAIFamilyCli,
ToClaudeCli,
ToGeminiCli,
}
pub(crate) fn request_conversion_kind(
client_api_format: &str,
provider_api_format: &str,
) -> Option<RequestConversionKind> {
let client_api_format = client_api_format.trim().to_ascii_lowercase();
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
if client_api_format == provider_api_format {
return None;
}
if !is_standard_api_format(client_api_format.as_str())
|| !is_standard_api_format(provider_api_format.as_str())
{
return None;
}
match provider_api_format.as_str() {
"openai:chat" => Some(RequestConversionKind::ToOpenAIChat),
"openai:cli" => Some(RequestConversionKind::ToOpenAIFamilyCli),
"openai:compact" => Some(RequestConversionKind::ToOpenAICompact),
"claude:chat" | "claude:cli" => Some(RequestConversionKind::ToClaudeStandard),
"gemini:chat" | "gemini:cli" => Some(RequestConversionKind::ToGeminiStandard),
_ => None,
}
}
pub(crate) fn request_conversion_transport_supported(
transport: &GatewayProviderTransportSnapshot,
_kind: RequestConversionKind,
) -> bool {
match transport
.endpoint
.api_format
.trim()
.to_ascii_lowercase()
.as_str()
{
"openai:chat" => supports_local_openai_chat_transport(transport),
"openai:cli" => supports_local_standard_transport_with_network(transport, "openai:cli"),
"openai:compact" => {
supports_local_standard_transport_with_network(transport, "openai:compact")
}
"claude:chat" => supports_local_standard_transport_with_network(transport, "claude:chat"),
"claude:cli" => supports_local_standard_transport_with_network(transport, "claude:cli"),
"gemini:chat" => supports_local_gemini_transport_with_network(transport, "gemini:chat"),
"gemini:cli" => supports_local_gemini_transport_with_network(transport, "gemini:cli"),
_ => false,
}
}
pub(crate) fn request_conversion_direct_auth(
transport: &GatewayProviderTransportSnapshot,
_kind: RequestConversionKind,
) -> Option<(String, String)> {
match transport
.endpoint
.api_format
.trim()
.to_ascii_lowercase()
.as_str()
{
"openai:chat" => resolve_local_openai_chat_auth(transport),
"gemini:chat" | "gemini:cli" => resolve_local_gemini_auth(transport),
"openai:cli" | "openai:compact" | "claude:chat" | "claude:cli" => {
resolve_local_standard_auth(transport)
}
_ => None,
}
}
pub(crate) fn sync_chat_response_conversion_kind(
provider_api_format: &str,
client_api_format: &str,
) -> Option<SyncChatResponseConversionKind> {
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
let client_api_format = client_api_format.trim().to_ascii_lowercase();
if provider_api_format == client_api_format {
return None;
}
if !is_standard_api_format(provider_api_format.as_str()) {
return None;
}
match client_api_format.as_str() {
"openai:chat" => Some(SyncChatResponseConversionKind::ToOpenAIChat),
"claude:chat" => Some(SyncChatResponseConversionKind::ToClaudeChat),
"gemini:chat" => Some(SyncChatResponseConversionKind::ToGeminiChat),
_ => None,
}
}
pub(crate) fn sync_cli_response_conversion_kind(
provider_api_format: &str,
client_api_format: &str,
) -> Option<SyncCliResponseConversionKind> {
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
let client_api_format = client_api_format.trim().to_ascii_lowercase();
if provider_api_format == client_api_format {
return None;
}
if !is_standard_api_format(provider_api_format.as_str()) {
return None;
}
match client_api_format.as_str() {
"openai:cli" | "openai:compact" => Some(SyncCliResponseConversionKind::ToOpenAIFamilyCli),
"claude:cli" => Some(SyncCliResponseConversionKind::ToClaudeCli),
"gemini:cli" => Some(SyncCliResponseConversionKind::ToGeminiCli),
_ => None,
}
}
fn is_standard_api_format(api_format: &str) -> bool {
matches!(
api_format,
"openai:chat"
| "openai:cli"
| "openai:compact"
| "claude:chat"
| "claude:cli"
| "gemini:chat"
| "gemini:cli"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_conversion_registry_supports_bidirectional_standard_matrix() {
assert_eq!(
request_conversion_kind("claude:chat", "openai:chat"),
Some(RequestConversionKind::ToOpenAIChat)
);
assert_eq!(
request_conversion_kind("gemini:chat", "claude:chat"),
Some(RequestConversionKind::ToClaudeStandard)
);
assert_eq!(
request_conversion_kind("gemini:cli", "openai:compact"),
Some(RequestConversionKind::ToOpenAICompact)
);
assert_eq!(
request_conversion_kind("openai:compact", "gemini:cli"),
Some(RequestConversionKind::ToGeminiStandard)
);
assert_eq!(request_conversion_kind("claude:chat", "claude:chat"), None);
}
#[test]
fn sync_response_conversion_registry_supports_bidirectional_standard_matrix() {
assert_eq!(
sync_chat_response_conversion_kind("openai:chat", "claude:chat"),
Some(SyncChatResponseConversionKind::ToClaudeChat)
);
assert_eq!(
sync_chat_response_conversion_kind("claude:chat", "gemini:chat"),
Some(SyncChatResponseConversionKind::ToGeminiChat)
);
assert_eq!(
sync_chat_response_conversion_kind("gemini:chat", "openai:chat"),
Some(SyncChatResponseConversionKind::ToOpenAIChat)
);
assert_eq!(
sync_cli_response_conversion_kind("openai:cli", "gemini:cli"),
Some(SyncCliResponseConversionKind::ToGeminiCli)
);
assert_eq!(
sync_cli_response_conversion_kind("claude:cli", "openai:compact"),
Some(SyncCliResponseConversionKind::ToOpenAIFamilyCli)
);
assert_eq!(
sync_cli_response_conversion_kind("gemini:cli", "claude:cli"),
Some(SyncCliResponseConversionKind::ToClaudeCli)
);
}
}

View File

@@ -0,0 +1,363 @@
use serde_json::{json, Map, Value};
use uuid::Uuid;
use super::shared::parse_openai_tool_arguments;
use super::super::to_openai_chat::{extract_openai_text_content, parse_openai_tool_result_content};
use crate::gateway::ai_pipeline::planner::standard::{
copy_request_number_field, map_openai_reasoning_effort_to_claude_output,
parse_openai_stop_sequences, resolve_openai_chat_max_tokens,
};
pub(crate) fn convert_openai_chat_request_to_claude_request(
body_json: &Value,
mapped_model: &str,
upstream_is_stream: bool,
) -> Option<Value> {
let request = body_json.as_object()?;
let mut system_segments = Vec::new();
let mut messages = Vec::new();
if let Some(message_values) = request.get("messages").and_then(Value::as_array) {
for message in message_values {
let message_object = message.as_object()?;
let role = message_object
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match role.as_str() {
"system" | "developer" => {
let text = extract_openai_text_content(message_object.get("content"))?;
if !text.trim().is_empty() {
system_segments.push(text);
}
}
"user" => {
let blocks =
convert_openai_content_to_claude_blocks(message_object.get("content"), true)?;
if !blocks.is_empty() {
messages.push(build_claude_message("user", blocks));
}
}
"assistant" => {
let mut blocks = convert_openai_content_to_claude_blocks(
message_object.get("content"),
false,
)?;
if let Some(tool_calls) =
message_object.get("tool_calls").and_then(Value::as_array)
{
for tool_call in tool_calls {
let tool_call_object = tool_call.as_object()?;
let function = tool_call_object.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let tool_call_id = tool_call_object
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("toolu_{}", Uuid::new_v4().simple()));
let tool_input =
parse_openai_tool_arguments(function.get("arguments"))?;
blocks.push(json!({
"type": "tool_use",
"id": tool_call_id,
"name": tool_name,
"input": tool_input,
}));
}
}
if !blocks.is_empty() {
messages.push(build_claude_message("assistant", blocks));
}
}
"tool" => {
let tool_use_id = message_object
.get("tool_call_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let tool_result =
parse_openai_tool_result_content(message_object.get("content"));
messages.push(json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": tool_result,
"is_error": false,
}],
}));
}
_ => {}
}
}
}
let mut output = Map::new();
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
output.insert(
"messages".to_string(),
Value::Array(compact_claude_messages(messages)),
);
output.insert(
"max_tokens".to_string(),
Value::from(resolve_openai_chat_max_tokens(request)),
);
let system_text = system_segments
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n");
if !system_text.is_empty() {
output.insert("system".to_string(), Value::String(system_text));
}
if upstream_is_stream {
output.insert("stream".to_string(), Value::Bool(true));
}
copy_request_number_field(request, &mut output, "temperature");
copy_request_number_field(request, &mut output, "top_p");
copy_request_number_field(request, &mut output, "top_k");
if let Some(stop_sequences) = parse_openai_stop_sequences(request.get("stop")) {
output.insert("stop_sequences".to_string(), Value::Array(stop_sequences));
}
if let Some(tools) = convert_openai_tools_to_claude(request.get("tools")) {
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(tool_choice) = convert_openai_tool_choice_to_claude(request.get("tool_choice")) {
output.insert("tool_choice".to_string(), tool_choice);
}
if let Some(metadata) = request.get("metadata").cloned() {
output.insert("metadata".to_string(), metadata);
}
if let Some(reasoning_effort) = request.get("reasoning_effort").and_then(Value::as_str) {
if let Some(output_effort) = map_openai_reasoning_effort_to_claude_output(reasoning_effort)
{
output.insert(
"output_config".to_string(),
json!({ "effort": output_effort }),
);
}
}
Some(Value::Object(output))
}
fn convert_openai_content_to_claude_blocks(
content: Option<&Value>,
allow_images: bool,
) -> Option<Vec<Value>> {
match content {
None | Some(Value::Null) => Some(Vec::new()),
Some(Value::String(text)) => {
let trimmed = text.trim();
if trimmed.is_empty() {
Some(Vec::new())
} else {
Some(vec![json!({ "type": "text", "text": text })])
}
}
Some(Value::Array(parts)) => {
let mut blocks = Vec::new();
for part in parts {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
match part_type {
"text" | "input_text" => {
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
blocks.push(json!({ "type": "text", "text": text }));
}
}
}
"image_url" | "input_image" if allow_images => {
let url = part_object
.get("image_url")
.and_then(|value| {
value.as_str().map(ToOwned::to_owned).or_else(|| {
value
.as_object()
.and_then(|object| object.get("url"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
})
.filter(|value| !value.trim().is_empty())?;
blocks.push(json!({
"type": "image",
"source": {
"type": "url",
"url": url,
}
}));
}
_ => {}
}
}
Some(blocks)
}
_ => None,
}
}
fn convert_openai_tools_to_claude(tools: Option<&Value>) -> Option<Vec<Value>> {
let tool_values = tools?.as_array()?;
let mut converted = Vec::new();
for tool in tool_values {
let tool_object = tool.as_object()?;
if tool_object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value != "function")
{
continue;
}
let function = tool_object.get("function")?.as_object()?;
let name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut converted_tool = Map::new();
converted_tool.insert("name".to_string(), Value::String(name.to_string()));
if let Some(description) = function.get("description").cloned() {
converted_tool.insert("description".to_string(), description);
}
converted_tool.insert(
"input_schema".to_string(),
function
.get("parameters")
.cloned()
.unwrap_or_else(|| json!({})),
);
converted.push(Value::Object(converted_tool));
}
(!converted.is_empty()).then_some(converted)
}
fn convert_openai_tool_choice_to_claude(tool_choice: Option<&Value>) -> Option<Value> {
let tool_choice = tool_choice?;
match tool_choice {
Value::String(value) => match value.trim().to_ascii_lowercase().as_str() {
"none" => Some(json!({ "type": "none" })),
"required" => Some(json!({ "type": "any" })),
"auto" => Some(json!({ "type": "auto" })),
_ => None,
},
Value::Object(object) => {
let function_name = object
.get("function")
.and_then(Value::as_object)
.and_then(|function| function.get("name"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
Some(json!({
"type": "tool",
"name": function_name,
}))
}
_ => None,
}
}
fn compact_claude_messages(messages: Vec<Value>) -> Vec<Value> {
let mut compact: Vec<Value> = Vec::new();
for message in messages {
let role = message
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if let Some(last) = compact.last_mut() {
let last_role = last
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if last_role == role {
merge_claude_message_content(last, message);
continue;
}
}
compact.push(message);
}
if compact
.first()
.and_then(|value| value.get("role"))
.and_then(Value::as_str)
.is_some_and(|value| value == "assistant")
{
compact.insert(0, json!({ "role": "user", "content": "" }));
}
compact
}
fn merge_claude_message_content(target: &mut Value, message: Value) {
let Some(target_object) = target.as_object_mut() else {
return;
};
let incoming_content = message.get("content").cloned().unwrap_or(Value::Null);
let merged_blocks = extract_claude_content_blocks(target_object.get("content"))
.into_iter()
.chain(extract_claude_content_blocks(Some(&incoming_content)))
.collect::<Vec<_>>();
target_object.insert(
"content".to_string(),
simplify_claude_content(merged_blocks),
);
}
fn build_claude_message(role: &str, blocks: Vec<Value>) -> Value {
json!({
"role": role,
"content": simplify_claude_content(blocks),
})
}
fn simplify_claude_content(blocks: Vec<Value>) -> Value {
if blocks.is_empty() {
return Value::String(String::new());
}
let mut text_values = Vec::new();
for block in &blocks {
let Some(block_object) = block.as_object() else {
return Value::Array(blocks);
};
if block_object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value == "text")
{
if let Some(text) = block_object.get("text").and_then(Value::as_str) {
text_values.push(text.to_string());
continue;
}
}
return Value::Array(blocks);
}
Value::String(text_values.join("\n"))
}
fn extract_claude_content_blocks(content: Option<&Value>) -> Vec<Value> {
match content {
Some(Value::String(text)) if !text.is_empty() => vec![json!({
"type": "text",
"text": text,
})],
Some(Value::Array(blocks)) => blocks.clone(),
_ => Vec::new(),
}
}

View File

@@ -0,0 +1,351 @@
use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use uuid::Uuid;
use super::shared::parse_openai_tool_arguments;
use super::super::to_openai_chat::{extract_openai_text_content, parse_openai_tool_result_content};
use crate::gateway::ai_pipeline::planner::standard::{
copy_request_number_field_as, map_openai_reasoning_effort_to_gemini_budget,
parse_openai_stop_sequences, value_as_u64,
};
pub(crate) fn convert_openai_chat_request_to_gemini_request(
body_json: &Value,
mapped_model: &str,
upstream_is_stream: bool,
) -> Option<Value> {
let request = body_json.as_object()?;
let mut system_segments = Vec::new();
let mut tool_name_by_id = BTreeMap::new();
let mut contents = Vec::new();
if let Some(message_values) = request.get("messages").and_then(Value::as_array) {
for message in message_values {
let message_object = message.as_object()?;
let role = message_object
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match role.as_str() {
"system" | "developer" => {
let text = extract_openai_text_content(message_object.get("content"))?;
if !text.trim().is_empty() {
system_segments.push(text);
}
}
"user" => {
let parts =
convert_openai_content_to_gemini_parts(message_object.get("content"), true)?;
if !parts.is_empty() {
contents.push(json!({
"role": "user",
"parts": parts,
}));
}
}
"assistant" => {
let mut parts = convert_openai_content_to_gemini_parts(
message_object.get("content"),
false,
)?;
if let Some(tool_calls) =
message_object.get("tool_calls").and_then(Value::as_array)
{
for tool_call in tool_calls {
let tool_call_object = tool_call.as_object()?;
let function = tool_call_object.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let tool_call_id = tool_call_object
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("toolu_{}", Uuid::new_v4().simple()));
let tool_input =
parse_openai_tool_arguments(function.get("arguments"))?;
tool_name_by_id.insert(tool_call_id.clone(), tool_name.clone());
parts.push(json!({
"functionCall": {
"name": tool_name,
"args": tool_input,
"id": tool_call_id,
}
}));
}
}
if !parts.is_empty() {
contents.push(json!({
"role": "model",
"parts": parts,
}));
}
}
"tool" => {
let tool_use_id = message_object
.get("tool_call_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let tool_name = tool_name_by_id
.get(&tool_use_id)
.cloned()
.unwrap_or_else(|| tool_use_id.clone());
let tool_result =
parse_openai_tool_result_content(message_object.get("content"));
contents.push(json!({
"role": "user",
"parts": [{
"functionResponse": {
"name": tool_name,
"id": tool_use_id,
"response": {
"result": tool_result,
},
}
}],
}));
}
_ => {}
}
}
}
let mut output = Map::new();
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
output.insert(
"contents".to_string(),
Value::Array(compact_gemini_contents(contents)),
);
if upstream_is_stream {
output.insert("stream".to_string(), Value::Bool(true));
}
let system_text = system_segments
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n");
if !system_text.is_empty() {
output.insert(
"systemInstruction".to_string(),
json!({ "parts": [{ "text": system_text }] }),
);
}
let mut generation_config = Map::new();
if let Some(max_tokens) = request
.get("max_completion_tokens")
.and_then(value_as_u64)
.or_else(|| request.get("max_tokens").and_then(value_as_u64))
{
generation_config.insert("maxOutputTokens".to_string(), Value::from(max_tokens));
}
copy_request_number_field_as(
request,
&mut generation_config,
"temperature",
"temperature",
);
copy_request_number_field_as(request, &mut generation_config, "top_p", "topP");
copy_request_number_field_as(request, &mut generation_config, "top_k", "topK");
if let Some(stop_sequences) = parse_openai_stop_sequences(request.get("stop")) {
generation_config.insert("stopSequences".to_string(), Value::Array(stop_sequences));
}
if let Some(reasoning_effort) = request.get("reasoning_effort").and_then(Value::as_str) {
if let Some(thinking_budget) =
map_openai_reasoning_effort_to_gemini_budget(reasoning_effort)
{
generation_config.insert(
"thinkingConfig".to_string(),
json!({
"includeThoughts": true,
"thinkingBudget": thinking_budget,
}),
);
}
}
if !generation_config.is_empty() {
output.insert(
"generationConfig".to_string(),
Value::Object(generation_config),
);
}
if let Some(tools) = convert_openai_tools_to_gemini(request.get("tools")) {
output.insert("tools".to_string(), tools);
}
if let Some(tool_config) = convert_openai_tool_choice_to_gemini(request.get("tool_choice")) {
output.insert("toolConfig".to_string(), tool_config);
}
if let Some(extra_body) = request.get("extra_body").and_then(Value::as_object) {
if let Some(google) = extra_body.get("google").and_then(Value::as_object) {
if let Some(existing) = output
.get_mut("generationConfig")
.and_then(Value::as_object_mut)
{
if let Some(response_modalities) = google.get("response_modalities").cloned() {
existing.insert("responseModalities".to_string(), response_modalities);
}
if let Some(thinking_config) = google.get("thinking_config").cloned() {
existing
.entry("thinkingConfig".to_string())
.or_insert(thinking_config);
}
}
}
}
Some(Value::Object(output))
}
fn convert_openai_content_to_gemini_parts(
content: Option<&Value>,
allow_images: bool,
) -> Option<Vec<Value>> {
match content {
None | Some(Value::Null) => Some(Vec::new()),
Some(Value::String(text)) => {
let trimmed = text.trim();
if trimmed.is_empty() {
Some(Vec::new())
} else {
Some(vec![json!({ "text": text })])
}
}
Some(Value::Array(parts)) => {
let mut converted = Vec::new();
for part in parts {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
match part_type {
"text" | "input_text" => {
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
converted.push(json!({ "text": text }));
}
}
}
"image_url" | "input_image" if allow_images => return None,
_ => {}
}
}
Some(converted)
}
_ => None,
}
}
fn convert_openai_tools_to_gemini(tools: Option<&Value>) -> Option<Value> {
let tool_values = tools?.as_array()?;
let mut declarations = Vec::new();
for tool in tool_values {
let tool_object = tool.as_object()?;
if tool_object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value != "function")
{
continue;
}
let function = tool_object.get("function")?.as_object()?;
let name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut declaration = Map::new();
declaration.insert("name".to_string(), Value::String(name.to_string()));
if let Some(description) = function.get("description").cloned() {
declaration.insert("description".to_string(), description);
}
declaration.insert(
"parameters".to_string(),
function
.get("parameters")
.cloned()
.unwrap_or_else(|| json!({})),
);
declarations.push(Value::Object(declaration));
}
(!declarations.is_empty()).then(|| json!([{ "functionDeclarations": declarations }]))
}
fn convert_openai_tool_choice_to_gemini(tool_choice: Option<&Value>) -> Option<Value> {
let tool_choice = tool_choice?;
match tool_choice {
Value::String(value) => {
let mode = match value.trim().to_ascii_lowercase().as_str() {
"none" => "NONE",
"required" => "ANY",
"auto" => "AUTO",
_ => return None,
};
Some(json!({
"functionCallingConfig": {
"mode": mode,
}
}))
}
Value::Object(object) => {
let function_name = object
.get("function")
.and_then(Value::as_object)
.and_then(|function| function.get("name"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
Some(json!({
"functionCallingConfig": {
"mode": "ANY",
"allowedFunctionNames": [function_name],
}
}))
}
_ => None,
}
}
fn compact_gemini_contents(contents: Vec<Value>) -> Vec<Value> {
let mut compact: Vec<Value> = Vec::new();
for content in contents {
let role = content
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let parts = content
.get("parts")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if parts.is_empty() {
continue;
}
if let Some(last) = compact.last_mut() {
let last_role = last
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if last_role == role {
if let Some(last_parts) = last.get_mut("parts").and_then(Value::as_array_mut) {
last_parts.extend(parts);
}
continue;
}
}
compact.push(content);
}
compact
}

View File

@@ -0,0 +1,8 @@
mod claude;
mod gemini;
mod openai_cli;
mod shared;
pub(crate) use claude::convert_openai_chat_request_to_claude_request;
pub(crate) use gemini::convert_openai_chat_request_to_gemini_request;
pub(crate) use openai_cli::convert_openai_chat_request_to_openai_cli_request;

View File

@@ -0,0 +1,413 @@
use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use super::super::to_openai_chat::extract_openai_text_content;
use crate::gateway::ai_pipeline::planner::standard::copy_request_number_field;
pub(crate) fn convert_openai_chat_request_to_openai_cli_request(
body_json: &Value,
mapped_model: &str,
upstream_is_stream: bool,
compact: bool,
) -> Option<Value> {
let request = body_json.as_object()?;
let mut instructions = Vec::new();
let mut input_items = Vec::new();
let mut next_generated_tool_call_index = 0usize;
let mut tool_call_id_aliases = BTreeMap::new();
if let Some(message_values) = request.get("messages").and_then(Value::as_array) {
for message in message_values {
let message_object = message.as_object()?;
let role = message_object
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match role.as_str() {
"system" | "developer" => {
let text = extract_openai_text_content(message_object.get("content"))?;
if !text.trim().is_empty() {
instructions.push(text);
}
}
"user" | "assistant" => {
let content_items = convert_openai_content_to_openai_cli_items(
message_object.get("content"),
role.as_str(),
)?;
if !content_items.is_empty() {
input_items.push(json!({
"type": "message",
"role": role,
"content": content_items,
}));
}
if role == "assistant" {
if let Some(tool_calls) =
message_object.get("tool_calls").and_then(Value::as_array)
{
for tool_call in tool_calls {
let tool_call_object = tool_call.as_object()?;
let function = tool_call_object.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let raw_call_id = tool_call_object
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
let call_id = if raw_call_id.is_empty() {
let generated =
format!("call_auto_{next_generated_tool_call_index}");
next_generated_tool_call_index += 1;
generated
} else {
raw_call_id.to_string()
};
if !raw_call_id.is_empty() && raw_call_id != call_id {
tool_call_id_aliases
.insert(raw_call_id.to_string(), call_id.clone());
}
let arguments = function
.get("arguments")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.unwrap_or_else(|| "{}".to_string());
input_items.push(json!({
"type": "function_call",
"call_id": call_id,
"name": tool_name,
"arguments": arguments,
}));
}
}
}
}
"tool" => {
let raw_tool_call_id = message_object
.get("tool_call_id")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
let tool_call_id = if raw_tool_call_id.is_empty() {
let generated = format!("call_auto_{next_generated_tool_call_index}");
next_generated_tool_call_index += 1;
generated
} else {
tool_call_id_aliases
.get(raw_tool_call_id)
.cloned()
.unwrap_or_else(|| raw_tool_call_id.to_string())
};
let output = match message_object.get("content") {
Some(Value::String(text)) => text.clone(),
Some(other) => serde_json::to_string(other).ok()?,
None => String::new(),
};
input_items.push(json!({
"type": "function_call_output",
"call_id": tool_call_id,
"output": output,
}));
}
_ => {}
}
}
}
let mut output = Map::new();
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
if !instructions.is_empty() {
output.insert(
"instructions".to_string(),
Value::String(
instructions
.into_iter()
.filter(|value: &String| !value.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n"),
),
);
}
output.insert("input".to_string(), Value::Array(input_items));
if upstream_is_stream && !compact {
output.insert("stream".to_string(), Value::Bool(true));
}
if let Some(max_tokens) = request.get("max_tokens").and_then(Value::as_u64) {
output.insert("max_output_tokens".to_string(), Value::from(max_tokens));
}
copy_request_number_field(request, &mut output, "temperature");
copy_request_number_field(request, &mut output, "top_p");
copy_request_integer_field(request, &mut output, "top_logprobs");
copy_request_bool_field(request, &mut output, "parallel_tool_calls");
for passthrough_key in [
"prompt_cache_key",
"service_tier",
"metadata",
"store",
"previous_response_id",
"truncation",
"reasoning",
"stop",
] {
if let Some(value) = request.get(passthrough_key) {
output.insert(passthrough_key.to_string(), value.clone());
}
}
if let Some(text) = build_openai_cli_text_config_from_openai_chat_request(request) {
output.insert("text".to_string(), Value::Object(text));
}
if let Some(tools) = build_openai_cli_tools_from_openai_chat_request(request) {
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(tool_choice) = build_openai_cli_tool_choice_from_openai_chat_request(request) {
output.insert("tool_choice".to_string(), tool_choice);
}
Some(Value::Object(output))
}
fn convert_openai_content_to_openai_cli_items(
content: Option<&Value>,
role: &str,
) -> Option<Vec<Value>> {
let Some(content) = content else {
return Some(Vec::new());
};
match content {
Value::String(text) => {
if text.is_empty() {
Some(Vec::new())
} else {
Some(vec![json!({
"type": if role == "assistant" { "output_text" } else { "input_text" },
"text": text,
})])
}
}
Value::Array(parts) => {
let mut items = Vec::new();
for part in parts {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or("text")
.trim()
.to_ascii_lowercase();
match part_type.as_str() {
"text" | "input_text" | "output_text" => {
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
if !text.is_empty() {
items.push(json!({
"type": if role == "assistant" { "output_text" } else { "input_text" },
"text": text,
}));
}
}
}
"image_url" => {
let image_url = part_object
.get("image_url")
.and_then(Value::as_object)
.and_then(|value| value.get("url"))
.and_then(Value::as_str)
.or_else(|| part_object.get("image_url").and_then(Value::as_str))?;
items.push(json!({
"type": if role == "assistant" { "output_image" } else { "input_image" },
"image_url": image_url,
}));
}
"input_image" | "output_image" => {
let image_url = part_object
.get("image_url")
.and_then(Value::as_str)
.or_else(|| part_object.get("url").and_then(Value::as_str))?;
items.push(json!({
"type": if role == "assistant" { "output_image" } else { "input_image" },
"image_url": image_url,
}));
}
_ => {}
}
}
Some(items)
}
_ => None,
}
}
fn build_openai_cli_text_config_from_openai_chat_request(
request: &Map<String, Value>,
) -> Option<Map<String, Value>> {
let mut text = Map::new();
if let Some(response_format) = request.get("response_format") {
text.insert("format".to_string(), response_format.clone());
}
if let Some(verbosity) = request.get("verbosity") {
text.insert("verbosity".to_string(), verbosity.clone());
}
(!text.is_empty()).then_some(text)
}
fn build_openai_cli_tools_from_openai_chat_request(
request: &Map<String, Value>,
) -> Option<Vec<Value>> {
let mut tools = Vec::new();
if let Some(tool_values) = request.get("tools").and_then(Value::as_array) {
for tool in tool_values {
let tool_object = tool.as_object()?;
let tool_type = tool_object
.get("type")
.and_then(Value::as_str)
.unwrap_or("function")
.trim()
.to_ascii_lowercase();
match tool_type.as_str() {
"function" => {
let function = tool_object.get("function")?.as_object()?;
let name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut rebuilt = Map::new();
rebuilt.insert("type".to_string(), Value::String("function".to_string()));
rebuilt.insert("name".to_string(), Value::String(name.to_string()));
if let Some(description) = function.get("description") {
rebuilt.insert("description".to_string(), description.clone());
}
if let Some(parameters) = function.get("parameters") {
rebuilt.insert("parameters".to_string(), parameters.clone());
}
tools.push(Value::Object(rebuilt));
}
"custom" => {
let custom = tool_object.get("custom").and_then(Value::as_object)?;
let mut rebuilt = Map::new();
rebuilt.insert("type".to_string(), Value::String("custom".to_string()));
if let Some(name) = custom.get("name") {
rebuilt.insert("name".to_string(), name.clone());
}
if let Some(description) = custom.get("description") {
rebuilt.insert("description".to_string(), description.clone());
}
if let Some(format) = custom.get("format") {
rebuilt.insert("format".to_string(), format.clone());
}
tools.push(Value::Object(rebuilt));
}
_ => tools.push(tool.clone()),
}
}
}
if let Some(web_search_options) = request.get("web_search_options").and_then(Value::as_object) {
let mut tool = Map::new();
tool.insert("type".to_string(), Value::String("web_search".to_string()));
if let Some(user_location) = web_search_options
.get("user_location")
.and_then(Value::as_object)
{
if user_location.get("type").and_then(Value::as_str) == Some("approximate") {
if let Some(approximate) =
user_location.get("approximate").and_then(Value::as_object)
{
let mut flattened = Map::new();
flattened.insert("type".to_string(), Value::String("approximate".to_string()));
if let Some(country) = approximate.get("country") {
flattened.insert("country".to_string(), country.clone());
}
if let Some(city) = approximate.get("city") {
flattened.insert("city".to_string(), city.clone());
}
tool.insert("user_location".to_string(), Value::Object(flattened));
}
}
}
if let Some(search_context_size) = web_search_options.get("search_context_size") {
tool.insert(
"search_context_size".to_string(),
search_context_size.clone(),
);
}
tools.push(Value::Object(tool));
}
(!tools.is_empty()).then_some(tools)
}
fn build_openai_cli_tool_choice_from_openai_chat_request(
request: &Map<String, Value>,
) -> Option<Value> {
let tool_choice = request.get("tool_choice")?;
match tool_choice {
Value::String(value) => Some(Value::String(value.clone())),
Value::Object(object) => {
let choice_type = object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match choice_type.as_str() {
"function" => {
let function = object.get("function").and_then(Value::as_object)?;
let name = function.get("name")?.as_str()?;
Some(json!({
"type": "function",
"name": name,
}))
}
"custom" => {
let custom = object.get("custom").and_then(Value::as_object)?;
let name = custom.get("name")?.as_str()?;
Some(json!({
"type": "custom",
"name": name,
}))
}
"allowed_tools" => {
let allowed_tools = object.get("allowed_tools").and_then(Value::as_object)?;
Some(json!({
"type": "allowed_tools",
"mode": allowed_tools.get("mode").cloned().unwrap_or_else(|| Value::String("auto".to_string())),
"tools": allowed_tools.get("tools").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
}))
}
_ => Some(tool_choice.clone()),
}
}
_ => Some(tool_choice.clone()),
}
}
fn copy_request_integer_field(
request: &Map<String, Value>,
output: &mut Map<String, Value>,
field: &str,
) {
if let Some(value) = request.get(field).and_then(Value::as_i64) {
output.insert(field.to_string(), Value::from(value));
}
}
fn copy_request_bool_field(
request: &Map<String, Value>,
output: &mut Map<String, Value>,
field: &str,
) {
if let Some(value) = request.get(field).and_then(Value::as_bool) {
output.insert(field.to_string(), Value::Bool(value));
}
}

View File

@@ -0,0 +1,21 @@
use serde_json::{json, Value};
pub(super) fn parse_openai_tool_arguments(arguments: Option<&Value>) -> Option<Value> {
match arguments {
Some(Value::Object(object)) => Some(Value::Object(object.clone())),
Some(Value::String(raw)) => {
let trimmed = raw.trim();
if trimmed.is_empty() {
Some(json!({}))
} else {
match serde_json::from_str::<Value>(trimmed) {
Ok(Value::Object(object)) => Some(Value::Object(object)),
Ok(other) => Some(json!({ "input": other })),
Err(_) => Some(json!({ "input": trimmed })),
}
}
}
Some(other) => Some(json!({ "input": other })),
None => Some(json!({})),
}
}

View File

@@ -0,0 +1,12 @@
mod from_openai_chat;
mod to_openai_chat;
pub(crate) use from_openai_chat::{
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_cli_request,
};
pub(crate) use to_openai_chat::{
extract_openai_text_content, normalize_claude_request_to_openai_chat_request,
normalize_gemini_request_to_openai_chat_request,
normalize_openai_cli_request_to_openai_chat_request, parse_openai_tool_result_content,
};

View File

@@ -0,0 +1,313 @@
use serde_json::{json, Map, Value};
use uuid::Uuid;
use super::shared::canonical_json_string;
pub(crate) fn normalize_claude_request_to_openai_chat_request(body_json: &Value) -> Option<Value> {
let request = body_json.as_object()?;
let mut output = Map::new();
if let Some(model) = request.get("model") {
output.insert("model".to_string(), model.clone());
}
let mut messages = Vec::new();
if let Some(system_text) = extract_claude_system_text(request.get("system")) {
if !system_text.trim().is_empty() {
messages.push(json!({
"role": "system",
"content": system_text,
}));
}
}
if let Some(message_values) = request.get("messages").and_then(Value::as_array) {
for message in message_values {
let message_object = message.as_object()?;
let role = message_object
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match role.as_str() {
"user" => {
let mut text_segments = Vec::new();
if let Some(content) = message_object.get("content") {
for block in normalize_claude_content_blocks(content)? {
match block {
ClaudeNormalizedBlock::Text(text) => {
if !text.trim().is_empty() {
text_segments.push(text);
}
}
ClaudeNormalizedBlock::ToolResult {
tool_use_id,
content,
} => {
messages.push(json!({
"role": "tool",
"tool_call_id": tool_use_id,
"content": content,
}));
}
ClaudeNormalizedBlock::ToolUse { .. } => {}
}
}
}
let text = text_segments.join("\n\n");
if !text.trim().is_empty() {
messages.push(json!({
"role": "user",
"content": text,
}));
}
}
"assistant" => {
let mut text_segments = Vec::new();
let mut tool_calls = Vec::new();
if let Some(content) = message_object.get("content") {
for block in normalize_claude_content_blocks(content)? {
match block {
ClaudeNormalizedBlock::Text(text) => {
if !text.trim().is_empty() {
text_segments.push(text);
}
}
ClaudeNormalizedBlock::ToolUse { id, name, input } => {
tool_calls.push(json!({
"id": id.unwrap_or_else(|| format!("toolu_{}", Uuid::new_v4().simple())),
"type": "function",
"function": {
"name": name,
"arguments": canonical_json_string(input.unwrap_or(Value::Object(Map::new()))),
}
}));
}
ClaudeNormalizedBlock::ToolResult { .. } => {}
}
}
}
let mut assistant = Map::new();
assistant.insert("role".to_string(), Value::String("assistant".to_string()));
assistant.insert(
"content".to_string(),
if text_segments.is_empty() && !tool_calls.is_empty() {
Value::Null
} else {
Value::String(text_segments.join("\n\n"))
},
);
if !tool_calls.is_empty() {
assistant.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
messages.push(Value::Object(assistant));
}
_ => {}
}
}
}
output.insert("messages".to_string(), Value::Array(messages));
if let Some(max_tokens) = request.get("max_tokens").cloned() {
output.insert("max_completion_tokens".to_string(), max_tokens);
}
for passthrough_key in ["temperature", "top_p", "metadata", "stop", "stream"] {
if let Some(value) = request.get(passthrough_key) {
output.insert(passthrough_key.to_string(), value.clone());
}
}
if let Some(tools) = normalize_claude_tools_to_openai(request.get("tools"))? {
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(tool_choice) = normalize_claude_tool_choice_to_openai(request.get("tool_choice"))? {
output.insert("tool_choice".to_string(), tool_choice);
}
Some(Value::Object(output))
}
#[derive(Debug)]
enum ClaudeNormalizedBlock {
Text(String),
ToolUse {
id: Option<String>,
name: String,
input: Option<Value>,
},
ToolResult {
tool_use_id: String,
content: Value,
},
}
fn normalize_claude_content_blocks(content: &Value) -> Option<Vec<ClaudeNormalizedBlock>> {
match content {
Value::String(text) => Some(vec![ClaudeNormalizedBlock::Text(text.clone())]),
Value::Array(blocks) => {
let mut normalized = Vec::new();
for block in blocks {
let block = block.as_object()?;
match block.get("type")?.as_str()? {
"text" | "thinking" => {
let text = block
.get("text")
.and_then(Value::as_str)
.unwrap_or_default();
normalized.push(ClaudeNormalizedBlock::Text(text.to_string()));
}
"tool_use" => {
let name = block
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
normalized.push(ClaudeNormalizedBlock::ToolUse {
id: block
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
name,
input: block.get("input").cloned(),
});
}
"tool_result" => {
let tool_use_id = block
.get("tool_use_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let content = block.get("content").cloned().unwrap_or(Value::Null);
normalized.push(ClaudeNormalizedBlock::ToolResult {
tool_use_id,
content,
});
}
_ => {}
}
}
Some(normalized)
}
_ => None,
}
}
fn extract_claude_system_text(system: Option<&Value>) -> Option<String> {
let system = system?;
let text = match system {
Value::String(text) => text.clone(),
Value::Array(blocks) => {
let mut segments = Vec::new();
for block in blocks {
let block = block.as_object()?;
if block.get("type").and_then(Value::as_str).unwrap_or("text") == "text" {
let text = block
.get("text")
.and_then(Value::as_str)
.unwrap_or_default();
if !text.trim().is_empty() {
segments.push(text.to_string());
}
}
}
segments.join("\n\n")
}
_ => return None,
};
Some(strip_claude_billing_header(&text))
}
fn strip_claude_billing_header(text: &str) -> String {
let trimmed = text.trim();
let prefix = "x-anthropic-billing-header:";
if !trimmed.to_ascii_lowercase().starts_with(prefix) {
return trimmed.to_string();
}
let remainder = trimmed
.split_once('\n')
.map(|(_, rest)| rest.trim_start())
.unwrap_or_default();
remainder.trim_start_matches('\n').trim().to_string()
}
fn normalize_claude_tools_to_openai(tools: Option<&Value>) -> Option<Option<Vec<Value>>> {
let Some(tools) = tools else {
return Some(None);
};
let tools = tools.as_array()?;
let mut normalized = Vec::new();
for tool in tools {
let tool = tool.as_object()?;
let name = tool
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut function = Map::new();
function.insert("name".to_string(), Value::String(name.to_string()));
if let Some(description) = tool.get("description").and_then(Value::as_str) {
if !description.trim().is_empty() {
function.insert(
"description".to_string(),
Value::String(description.trim().to_string()),
);
}
}
function.insert(
"parameters".to_string(),
tool.get("input_schema")
.cloned()
.unwrap_or_else(|| json!({"type": "object"})),
);
normalized.push(json!({
"type": "function",
"function": Value::Object(function),
}));
}
Some(Some(normalized))
}
fn normalize_claude_tool_choice_to_openai(tool_choice: Option<&Value>) -> Option<Option<Value>> {
let Some(tool_choice) = tool_choice else {
return Some(None);
};
match tool_choice {
Value::String(value) => match value.trim().to_ascii_lowercase().as_str() {
"auto" => Some(Some(Value::String("auto".to_string()))),
"any" => Some(Some(Value::String("required".to_string()))),
"none" => Some(Some(Value::String("none".to_string()))),
_ => Some(None),
},
Value::Object(value) => {
if let Some(name) = value.get("name").and_then(Value::as_str) {
return Some(Some(json!({
"type": "function",
"function": { "name": name }
})));
}
let kind = value
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
match kind.trim().to_ascii_lowercase().as_str() {
"auto" => Some(Some(Value::String("auto".to_string()))),
"any" => Some(Some(Value::String("required".to_string()))),
"none" => Some(Some(Value::String("none".to_string()))),
"tool" => value
.get("name")
.and_then(Value::as_str)
.map(|name| {
Some(json!({
"type": "function",
"function": { "name": name }
}))
})
.or(Some(None)),
_ => Some(None),
}
}
_ => Some(None),
}
}

View File

@@ -0,0 +1,292 @@
use serde_json::{json, Map, Value};
use super::shared::canonical_json_string;
pub(crate) fn normalize_gemini_request_to_openai_chat_request(
body_json: &Value,
request_path: &str,
) -> Option<Value> {
let request = body_json.as_object()?;
let mut output = Map::new();
if let Some(model) = request
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
output.insert("model".to_string(), Value::String(model.to_string()));
} else if let Some(model) = extract_gemini_model_from_path(request_path) {
output.insert("model".to_string(), Value::String(model));
}
let mut messages = Vec::new();
if let Some(system_text) = extract_gemini_system_text(
request
.get("systemInstruction")
.or_else(|| request.get("system_instruction")),
) {
if !system_text.trim().is_empty() {
messages.push(json!({
"role": "system",
"content": system_text,
}));
}
}
if let Some(contents) = request.get("contents").and_then(Value::as_array) {
for content in contents {
let content_object = content.as_object()?;
let role = content_object
.get("role")
.and_then(Value::as_str)
.unwrap_or("user")
.trim()
.to_ascii_lowercase();
let parts = content_object.get("parts").and_then(Value::as_array)?;
match role.as_str() {
"model" => {
let mut text_segments = Vec::new();
let mut tool_calls = Vec::new();
for (index, part) in parts.iter().enumerate() {
let part = part.as_object()?;
if let Some(text) = part.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
text_segments.push(text.to_string());
}
} else if let Some(function_call) =
part.get("functionCall").and_then(Value::as_object)
{
let name = function_call
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let id = function_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("toolu_{}_{}", name, index));
tool_calls.push(json!({
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": canonical_json_string(function_call.get("args").cloned().unwrap_or(Value::Object(Map::new()))),
}
}));
}
}
let mut assistant = Map::new();
assistant.insert("role".to_string(), Value::String("assistant".to_string()));
assistant.insert(
"content".to_string(),
if text_segments.is_empty() && !tool_calls.is_empty() {
Value::Null
} else {
Value::String(text_segments.join("\n\n"))
},
);
if !tool_calls.is_empty() {
assistant.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
messages.push(Value::Object(assistant));
}
_ => {
let mut text_segments = Vec::new();
for part in parts {
let part = part.as_object()?;
if let Some(text) = part.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
text_segments.push(text.to_string());
}
} else if let Some(function_response) =
part.get("functionResponse").and_then(Value::as_object)
{
let name = function_response
.get("name")
.and_then(Value::as_str)
.unwrap_or("tool");
let response_value = function_response
.get("response")
.cloned()
.unwrap_or(Value::Object(Map::new()));
let tool_call_id = function_response
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("toolu_{}", name));
messages.push(json!({
"role": "tool",
"tool_call_id": tool_call_id,
"content": response_value,
}));
}
}
let text = text_segments.join("\n\n");
if !text.trim().is_empty() {
messages.push(json!({
"role": "user",
"content": text,
}));
}
}
}
}
}
output.insert("messages".to_string(), Value::Array(messages));
let generation_config = request
.get("generationConfig")
.or_else(|| request.get("generation_config"))
.and_then(Value::as_object);
if let Some(generation_config) = generation_config {
if let Some(value) = generation_config.get("maxOutputTokens").cloned() {
output.insert("max_completion_tokens".to_string(), value);
}
if let Some(value) = generation_config.get("temperature").cloned() {
output.insert("temperature".to_string(), value);
}
if let Some(value) = generation_config.get("topP").cloned() {
output.insert("top_p".to_string(), value);
}
if let Some(value) = generation_config.get("candidateCount").cloned() {
output.insert("n".to_string(), value);
}
if let Some(value) = generation_config.get("stopSequences").cloned() {
output.insert("stop".to_string(), value);
}
}
if let Some(value) = request.get("stream").cloned() {
output.insert("stream".to_string(), value);
}
if let Some(tools) = normalize_gemini_tools_to_openai(request.get("tools"))? {
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(tool_choice) = normalize_gemini_tool_choice_to_openai(request.get("toolConfig"))? {
output.insert("tool_choice".to_string(), tool_choice);
}
Some(Value::Object(output))
}
fn extract_gemini_system_text(system_instruction: Option<&Value>) -> Option<String> {
let system_instruction = system_instruction?;
match system_instruction {
Value::String(text) => Some(text.trim().to_string()),
Value::Object(object) => {
let parts = object.get("parts")?.as_array()?;
let mut segments = Vec::new();
for part in parts {
let part = part.as_object()?;
if let Some(text) = part.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
segments.push(text.to_string());
}
}
}
Some(segments.join("\n\n"))
}
_ => None,
}
}
fn normalize_gemini_tools_to_openai(tools: Option<&Value>) -> Option<Option<Vec<Value>>> {
let Some(tools) = tools else {
return Some(None);
};
let tools = tools.as_array()?;
let mut normalized = Vec::new();
for tool in tools {
let tool = tool.as_object()?;
let declarations = tool
.get("functionDeclarations")
.or_else(|| tool.get("function_declarations"))
.and_then(Value::as_array);
let Some(declarations) = declarations else {
continue;
};
for declaration in declarations {
let declaration = declaration.as_object()?;
let name = declaration
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut function = Map::new();
function.insert("name".to_string(), Value::String(name.to_string()));
if let Some(description) = declaration.get("description").and_then(Value::as_str) {
if !description.trim().is_empty() {
function.insert(
"description".to_string(),
Value::String(description.trim().to_string()),
);
}
}
function.insert(
"parameters".to_string(),
declaration
.get("parameters")
.cloned()
.unwrap_or_else(|| json!({"type": "object"})),
);
normalized.push(json!({
"type": "function",
"function": Value::Object(function),
}));
}
}
Some(Some(normalized))
}
fn normalize_gemini_tool_choice_to_openai(tool_config: Option<&Value>) -> Option<Option<Value>> {
let Some(tool_config) = tool_config else {
return Some(None);
};
let tool_config = tool_config.as_object()?;
let function_config = tool_config
.get("functionCallingConfig")
.or_else(|| tool_config.get("function_calling_config"))
.and_then(Value::as_object)?;
let mode = function_config
.get("mode")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_uppercase();
match mode.as_str() {
"NONE" => Some(Some(Value::String("none".to_string()))),
"AUTO" => Some(Some(Value::String("auto".to_string()))),
"ANY" | "REQUIRED" => Some(Some(Value::String("required".to_string()))),
_ => {
if let Some(name) = function_config
.get("allowedFunctionNames")
.or_else(|| function_config.get("allowed_function_names"))
.and_then(Value::as_array)
.and_then(|values| values.first())
.and_then(Value::as_str)
{
Some(Some(json!({
"type": "function",
"function": { "name": name }
})))
} else {
Some(None)
}
}
}
}
fn extract_gemini_model_from_path(path: &str) -> Option<String> {
let marker = "/models/";
let start = path.find(marker)? + marker.len();
let tail = &path[start..];
let end = tail.find(':').unwrap_or(tail.len());
let model = tail[..end].trim();
if model.is_empty() {
None
} else {
Some(model.to_string())
}
}

View File

@@ -0,0 +1,9 @@
mod claude;
mod gemini;
mod openai_cli;
mod shared;
pub(crate) use claude::normalize_claude_request_to_openai_chat_request;
pub(crate) use gemini::normalize_gemini_request_to_openai_chat_request;
pub(crate) use openai_cli::normalize_openai_cli_request_to_openai_chat_request;
pub(crate) use shared::{extract_openai_text_content, parse_openai_tool_result_content};

View File

@@ -0,0 +1,310 @@
use serde_json::{json, Map, Value};
use super::shared::{extract_openai_text_content, parse_openai_tool_result_content};
pub(crate) fn normalize_openai_cli_request_to_openai_chat_request(
body_json: &Value,
) -> Option<Value> {
let request = body_json.as_object()?;
let mut output = Map::new();
if let Some(model) = request.get("model") {
output.insert("model".to_string(), model.clone());
}
let mut messages = Vec::new();
if let Some(instructions) = request.get("instructions") {
let text = extract_openai_text_content(Some(instructions))?;
if !text.trim().is_empty() {
messages.push(json!({
"role": "system",
"content": text,
}));
}
}
messages.extend(normalize_openai_cli_input_to_openai_chat_messages(
request.get("input"),
)?);
output.insert("messages".to_string(), Value::Array(messages));
if let Some(max_output_tokens) = request.get("max_output_tokens").cloned() {
output.insert("max_completion_tokens".to_string(), max_output_tokens);
}
for passthrough_key in [
"temperature",
"top_p",
"metadata",
"store",
"previous_response_id",
"service_tier",
"reasoning",
"stop",
"stream",
] {
if let Some(value) = request.get(passthrough_key) {
output.insert(passthrough_key.to_string(), value.clone());
}
}
if let Some(tools) = normalize_openai_cli_tools_to_openai_chat(request.get("tools"))? {
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(tool_choice) =
normalize_openai_cli_tool_choice_to_openai_chat(request.get("tool_choice"))?
{
output.insert("tool_choice".to_string(), tool_choice);
}
Some(Value::Object(output))
}
fn normalize_openai_cli_input_to_openai_chat_messages(input: Option<&Value>) -> Option<Vec<Value>> {
let Some(input) = input else {
return Some(Vec::new());
};
match input {
Value::Null => Some(Vec::new()),
Value::String(text) => {
if text.trim().is_empty() {
Some(Vec::new())
} else {
Some(vec![json!({
"role": "user",
"content": text,
})])
}
}
Value::Array(items) => {
let mut messages = Vec::new();
let mut next_generated_tool_call_index = 0usize;
for item in items {
if let Some(item_text) = item.as_str() {
if !item_text.trim().is_empty() {
messages.push(json!({
"role": "user",
"content": item_text,
}));
}
continue;
}
let item_object = item.as_object()?;
let item_type = item_object
.get("type")
.and_then(Value::as_str)
.unwrap_or("message")
.trim()
.to_ascii_lowercase();
match item_type.as_str() {
"message" => {
let role = item_object
.get("role")
.and_then(Value::as_str)
.unwrap_or("user")
.trim()
.to_ascii_lowercase();
if role == "system" || role == "developer" {
let text = extract_openai_text_content(item_object.get("content"))?;
if !text.trim().is_empty() {
messages.push(json!({
"role": "system",
"content": text,
}));
}
continue;
}
let normalized_content =
normalize_openai_cli_message_content(item_object.get("content"))?;
messages.push(json!({
"role": role,
"content": normalized_content,
}));
}
"function_call" => {
let tool_name = item_object
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let call_id = item_object
.get("call_id")
.or_else(|| item_object.get("id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
let generated =
format!("call_auto_{next_generated_tool_call_index}");
next_generated_tool_call_index += 1;
generated
});
let arguments = item_object
.get("arguments")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.unwrap_or_else(|| "{}".to_string());
messages.push(json!({
"role": "assistant",
"content": Value::Array(Vec::new()),
"tool_calls": [{
"id": call_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": arguments,
}
}]
}));
}
"function_call_output" => {
let tool_call_id = item_object
.get("call_id")
.or_else(|| item_object.get("tool_call_id"))
.or_else(|| item_object.get("id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
let generated =
format!("call_auto_{next_generated_tool_call_index}");
next_generated_tool_call_index += 1;
generated
});
messages.push(json!({
"role": "tool",
"tool_call_id": tool_call_id,
"content": parse_openai_tool_result_content(item_object.get("output")),
}));
}
_ => {}
}
}
Some(messages)
}
_ => None,
}
}
fn normalize_openai_cli_message_content(content: Option<&Value>) -> Option<Value> {
let Some(content) = content else {
return Some(Value::Array(Vec::new()));
};
match content {
Value::String(text) => Some(Value::String(text.clone())),
Value::Array(parts) => {
let mut normalized = Vec::new();
for part in parts {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match part_type.as_str() {
"input_text" | "output_text" | "text" => {
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
normalized.push(json!({
"type": "text",
"text": text,
}));
}
}
"input_image" | "output_image" | "image_url" => {
let image_url = part_object
.get("image_url")
.and_then(|value| {
value.as_str().map(ToOwned::to_owned).or_else(|| {
value
.as_object()
.and_then(|object| object.get("url"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
})
.or_else(|| {
part_object
.get("url")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})?;
normalized.push(json!({
"type": "input_image",
"image_url": image_url,
}));
}
_ => {}
}
}
Some(Value::Array(normalized))
}
_ => Some(content.clone()),
}
}
fn normalize_openai_cli_tools_to_openai_chat(tools: Option<&Value>) -> Option<Option<Vec<Value>>> {
let Some(Value::Array(tool_values)) = tools else {
return Some(None);
};
let mut normalized = Vec::new();
for tool in tool_values {
let tool_object = tool.as_object()?;
let tool_type = tool_object
.get("type")
.and_then(Value::as_str)
.unwrap_or("function")
.trim()
.to_ascii_lowercase();
if tool_object.get("function").is_some() || tool_type != "function" {
normalized.push(tool.clone());
continue;
}
let name = tool_object
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut function = Map::new();
function.insert("name".to_string(), Value::String(name.to_string()));
if let Some(description) = tool_object.get("description") {
function.insert("description".to_string(), description.clone());
}
if let Some(parameters) = tool_object.get("parameters") {
function.insert("parameters".to_string(), parameters.clone());
}
normalized.push(json!({
"type": "function",
"function": function,
}));
}
Some((!normalized.is_empty()).then_some(normalized))
}
fn normalize_openai_cli_tool_choice_to_openai_chat(
tool_choice: Option<&Value>,
) -> Option<Option<Value>> {
let Some(tool_choice) = tool_choice else {
return Some(None);
};
match tool_choice {
Value::Object(object)
if object.get("function").is_none()
&& object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value.eq_ignore_ascii_case("function")) =>
{
let name = object
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
Some(Some(json!({
"type": "function",
"function": {
"name": name,
}
})))
}
_ => Some(Some(tool_choice.clone())),
}
}

View File

@@ -0,0 +1,66 @@
use serde_json::Value;
pub(crate) fn extract_openai_text_content(content: Option<&Value>) -> Option<String> {
match content {
None | Some(Value::Null) => Some(String::new()),
Some(Value::String(text)) => Some(text.clone()),
Some(Value::Array(parts)) => {
let mut collected = Vec::new();
for part in parts {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
if matches!(part_type, "text" | "input_text") {
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
collected.push(text.to_string());
}
}
}
}
Some(collected.join("\n"))
}
_ => None,
}
}
pub(crate) fn parse_openai_tool_result_content(content: Option<&Value>) -> Value {
match content {
Some(Value::String(raw)) => {
let trimmed = raw.trim();
if trimmed.is_empty() {
Value::String(String::new())
} else {
serde_json::from_str::<Value>(trimmed)
.unwrap_or_else(|_| Value::String(raw.clone()))
}
}
Some(Value::Array(parts)) => {
let texts = parts
.iter()
.filter_map(|part| {
part.as_object()
.and_then(|object| object.get("text"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
.collect::<Vec<_>>();
if texts.is_empty() {
Value::Array(parts.clone())
} else {
Value::String(texts.join("\n"))
}
}
Some(value) => value.clone(),
None => Value::String(String::new()),
}
}
pub(super) fn canonical_json_string(value: Value) -> String {
match value {
Value::String(text) => text,
other => serde_json::to_string(&other).unwrap_or_else(|_| "null".to_string()),
}
}

View File

@@ -0,0 +1,95 @@
use serde_json::{json, Value};
use super::shared::{
build_generated_tool_call_id, extract_openai_assistant_text, parse_openai_function_arguments,
};
pub(crate) fn convert_openai_chat_response_to_claude_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let choices = body.get("choices")?.as_array()?;
let first_choice = choices.first()?.as_object()?;
let message = first_choice.get("message")?.as_object()?;
let mut content = Vec::new();
if let Some(text) = extract_openai_assistant_text(message.get("content")) {
if !text.trim().is_empty() {
content.push(json!({
"type": "text",
"text": text,
}));
}
}
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
for (index, tool_call) in tool_call_values.iter().enumerate() {
let tool_call = tool_call.as_object()?;
let function = tool_call.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let tool_id = tool_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let input = parse_openai_function_arguments(function.get("arguments"))?;
content.push(json!({
"type": "tool_use",
"id": tool_id,
"name": tool_name,
"input": input,
}));
}
}
if content.is_empty() {
content.push(json!({
"type": "text",
"text": "",
}));
}
let stop_reason = match first_choice.get("finish_reason").and_then(Value::as_str) {
Some("stop") | None => "end_turn",
Some("length") => "max_tokens",
Some("tool_calls") | Some("function_call") => "tool_use",
Some("content_filter") => "content_filtered",
Some(other) => other,
};
let usage = body.get("usage").and_then(Value::as_object);
let input_tokens = usage
.and_then(|value| value.get("prompt_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.and_then(|value| value.get("completion_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("msg-local-finalize");
Some(json!({
"id": id,
"type": "message",
"role": "assistant",
"model": model,
"content": content,
"stop_reason": stop_reason,
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
}
}))
}

View File

@@ -0,0 +1,101 @@
use serde_json::{json, Value};
use super::shared::{
build_generated_tool_call_id, extract_openai_assistant_text, parse_openai_function_arguments,
};
pub(crate) fn convert_openai_chat_response_to_gemini_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let choices = body.get("choices")?.as_array()?;
let first_choice = choices.first()?.as_object()?;
let message = first_choice.get("message")?.as_object()?;
let mut parts = Vec::new();
if let Some(text) = extract_openai_assistant_text(message.get("content")) {
if !text.trim().is_empty() {
parts.push(json!({ "text": text }));
}
}
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
for (index, tool_call) in tool_call_values.iter().enumerate() {
let tool_call = tool_call.as_object()?;
let function = tool_call.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let call_id = tool_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
parts.push(json!({
"functionCall": {
"id": call_id,
"name": tool_name,
"args": parse_openai_function_arguments(function.get("arguments"))?,
}
}));
}
}
if parts.is_empty() {
parts.push(json!({ "text": "" }));
}
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("prompt_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let completion_tokens = usage
.and_then(|value| value.get("completion_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("total_tokens"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + completion_tokens);
let mut finish_reason = match first_choice.get("finish_reason").and_then(Value::as_str) {
Some("stop") | None => "STOP",
Some("length") => "MAX_TOKENS",
Some("content_filter") => "SAFETY",
Some("tool_calls") | Some("function_call") => "STOP",
Some(other) => other,
};
if parts.iter().any(|part| part.get("functionCall").is_some()) {
finish_reason = "STOP";
}
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let response_id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("resp-local-finalize");
Some(json!({
"responseId": response_id,
"modelVersion": model,
"candidates": [{
"content": {
"role": "model",
"parts": parts,
},
"finishReason": finish_reason,
"index": 0,
}],
"usageMetadata": {
"promptTokenCount": prompt_tokens,
"candidatesTokenCount": completion_tokens,
"totalTokenCount": total_tokens,
}
}))
}

View File

@@ -0,0 +1,9 @@
mod claude_chat;
mod gemini_chat;
mod openai_cli;
mod shared;
pub(crate) use claude_chat::convert_openai_chat_response_to_claude_chat;
pub(crate) use gemini_chat::convert_openai_chat_response_to_gemini_chat;
pub(crate) use openai_cli::convert_openai_chat_response_to_openai_cli;
pub(crate) use shared::build_openai_cli_response;

View File

@@ -0,0 +1,99 @@
use serde_json::{json, Value};
use super::shared::{
build_openai_cli_response, canonicalize_tool_arguments,
};
pub(crate) fn convert_openai_chat_response_to_openai_cli(
body_json: &Value,
report_context: &Value,
compact: bool,
) -> Option<Value> {
let body = body_json.as_object()?;
let choices = body.get("choices")?.as_array()?;
let first_choice = choices.first()?.as_object()?;
let message = first_choice.get("message")?.as_object()?;
let mut text = String::new();
match message.get("content") {
Some(Value::String(value)) => text.push_str(value),
Some(Value::Array(parts)) => {
for part in parts {
let part = part.as_object()?;
let part_type = part
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(part_type.as_str(), "text" | "output_text") {
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
}
}
Some(Value::Null) | None => {}
_ => return None,
}
let mut function_calls = Vec::new();
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
for tool_call in tool_call_values {
let tool_call = tool_call.as_object()?;
let function = tool_call.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
function_calls.push(json!({
"type": "function_call",
"id": tool_call.get("id").cloned().unwrap_or(Value::Null),
"call_id": tool_call.get("id").cloned().unwrap_or(Value::Null),
"name": tool_name,
"arguments": canonicalize_tool_arguments(function.get("arguments").cloned()),
}));
}
}
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("prompt_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.and_then(|value| value.get("completion_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("total_tokens"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + output_tokens);
let response_id = if compact {
body.get("id")
.and_then(Value::as_str)
.map(|value| value.replace("chatcmpl", "resp"))
.unwrap_or_else(|| "resp-local-finalize".to_string())
} else {
body.get("id")
.and_then(Value::as_str)
.map(|value| value.replace("chatcmpl", "resp"))
.unwrap_or_else(|| "resp-local-finalize".to_string())
};
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
Some(build_openai_cli_response(
&response_id,
model,
&text,
function_calls,
prompt_tokens,
output_tokens,
total_tokens,
))
}

View File

@@ -0,0 +1,86 @@
use serde_json::{json, Map, Value};
pub(crate) fn build_openai_cli_response(
response_id: &str,
model: &str,
text: &str,
function_calls: Vec<Value>,
prompt_tokens: u64,
output_tokens: u64,
total_tokens: u64,
) -> Value {
let mut output = Vec::new();
if !text.is_empty() {
output.push(json!({
"type": "message",
"id": format!("{response_id}_msg"),
"role": "assistant",
"status": "completed",
"content": [{
"type": "output_text",
"text": text,
"annotations": []
}]
}));
}
output.extend(function_calls);
json!({
"id": response_id,
"object": "response",
"status": "completed",
"model": model,
"output": output,
"usage": {
"input_tokens": prompt_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
}
})
}
pub(super) fn extract_openai_assistant_text(content: Option<&Value>) -> Option<String> {
match content? {
Value::Null => Some(String::new()),
Value::String(text) => Some(text.clone()),
Value::Array(parts) => {
let mut text = String::new();
for part in parts {
let part = part.as_object()?;
let part_type = part
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(part_type.as_str(), "text" | "output_text") {
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
}
Some(text)
}
_ => None,
}
}
pub(super) fn parse_openai_function_arguments(arguments: Option<&Value>) -> Option<Value> {
match arguments.cloned().unwrap_or(Value::Object(Map::new())) {
Value::String(text) => serde_json::from_str(&text)
.ok()
.or(Some(Value::String(text))),
other => Some(other),
}
}
pub(super) fn build_generated_tool_call_id(index: usize) -> String {
format!("call_auto_{index}")
}
pub(super) fn canonicalize_tool_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(),
}
}

View File

@@ -0,0 +1,12 @@
mod from_openai_chat;
mod to_openai_chat;
pub(crate) use from_openai_chat::{
build_openai_cli_response, convert_openai_chat_response_to_claude_chat,
convert_openai_chat_response_to_gemini_chat, convert_openai_chat_response_to_openai_cli,
};
pub(crate) use to_openai_chat::{
convert_claude_chat_response_to_openai_chat, convert_claude_cli_response_to_openai_cli,
convert_gemini_chat_response_to_openai_chat, convert_gemini_cli_response_to_openai_cli,
convert_openai_cli_response_to_openai_chat,
};

View File

@@ -0,0 +1,96 @@
use serde_json::{json, Map, Value};
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
pub(crate) fn convert_claude_chat_response_to_openai_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let content = body.get("content")?.as_array()?;
let mut text = String::new();
let mut tool_calls = Vec::new();
for (index, block) in content.iter().enumerate() {
let block = block.as_object()?;
match block.get("type")?.as_str()? {
"text" => {
text.push_str(block.get("text")?.as_str()?);
}
"tool_use" => {
let tool_name = block.get("name")?.as_str()?;
let tool_id = block
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let arguments = canonicalize_tool_arguments(block.get("input").cloned());
tool_calls.push(json!({
"id": tool_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": arguments,
}
}));
}
_ => return None,
}
}
let mut finish_reason = match body.get("stop_reason").and_then(Value::as_str) {
Some("end_turn") | Some("stop_sequence") => Some("stop"),
Some("max_tokens") => Some("length"),
Some("tool_use") => Some("tool_calls"),
Some(other) if !other.is_empty() => Some(other),
_ => None,
};
if !tool_calls.is_empty() && finish_reason.is_none_or(|reason| reason == "stop") {
finish_reason = Some("tool_calls");
}
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("input_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let completion_tokens = usage
.and_then(|value| value.get("output_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = prompt_tokens + completion_tokens;
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("chatcmpl-local-finalize");
let message_content = if text.is_empty() && !tool_calls.is_empty() {
Value::Null
} else {
Value::String(text)
};
let mut message = Map::new();
message.insert("role".to_string(), Value::String("assistant".to_string()));
message.insert("content".to_string(), message_content);
if !tool_calls.is_empty() {
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
Some(json!({
"id": id,
"object": "chat.completion",
"model": model,
"choices": [{
"index": 0,
"message": Value::Object(message),
"finish_reason": finish_reason,
}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
}))
}

View File

@@ -0,0 +1,70 @@
use serde_json::{json, Value};
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
use super::super::from_openai_chat::build_openai_cli_response;
pub(crate) fn convert_claude_cli_response_to_openai_cli(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let content = body.get("content")?.as_array()?;
let mut text = String::new();
let mut function_calls = Vec::new();
for (index, block) in content.iter().enumerate() {
let block = block.as_object()?;
match block.get("type")?.as_str()? {
"text" => {
text.push_str(block.get("text")?.as_str()?);
}
"tool_use" => {
let tool_name = block.get("name")?.as_str()?;
let call_id = block
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let arguments = canonicalize_tool_arguments(block.get("input").cloned());
function_calls.push(json!({
"type": "function_call",
"call_id": call_id,
"name": tool_name,
"arguments": arguments,
}));
}
_ => return None,
}
}
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("input_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.and_then(|value| value.get("output_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = prompt_tokens + output_tokens;
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let response_id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("resp-local-finalize");
Some(build_openai_cli_response(
response_id,
model,
&text,
function_calls,
prompt_tokens,
output_tokens,
total_tokens,
))
}

View File

@@ -0,0 +1,100 @@
use serde_json::{json, Map, Value};
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
pub(crate) fn convert_gemini_chat_response_to_openai_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let candidates = body.get("candidates")?.as_array()?;
let first_candidate = candidates.first()?.as_object()?;
let content = first_candidate.get("content")?.as_object()?;
let parts = content.get("parts")?.as_array()?;
let mut text = String::new();
let mut tool_calls = Vec::new();
for (index, part) in parts.iter().enumerate() {
let part = part.as_object()?;
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
} else if let Some(function_call) = part.get("functionCall").and_then(Value::as_object) {
let tool_name = function_call.get("name")?.as_str()?;
let tool_id = function_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let arguments = canonicalize_tool_arguments(function_call.get("args").cloned());
tool_calls.push(json!({
"id": tool_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": arguments,
}
}));
} else {
return None;
}
}
let mut finish_reason = match first_candidate.get("finishReason").and_then(Value::as_str) {
Some("STOP") => Some("stop"),
Some("MAX_TOKENS") => Some("length"),
Some("SAFETY") => Some("content_filter"),
Some(other) if !other.is_empty() => Some(other),
_ => None,
};
if !tool_calls.is_empty() && finish_reason.is_none_or(|reason| reason == "stop") {
finish_reason = Some("tool_calls");
}
let usage = body.get("usageMetadata").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("promptTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(0);
let completion_tokens = usage
.and_then(|value| value.get("candidatesTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("totalTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + completion_tokens);
let model = body
.get("modelVersion")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let id = body
.get("responseId")
.and_then(Value::as_str)
.unwrap_or("chatcmpl-local-finalize");
let message_content = if text.is_empty() && !tool_calls.is_empty() {
Value::Null
} else {
Value::String(text)
};
let mut message = Map::new();
message.insert("role".to_string(), Value::String("assistant".to_string()));
message.insert("content".to_string(), message_content);
if !tool_calls.is_empty() {
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
Some(json!({
"id": id,
"object": "chat.completion",
"model": model,
"choices": [{
"index": first_candidate.get("index").and_then(Value::as_u64).unwrap_or(0),
"message": Value::Object(message),
"finish_reason": finish_reason,
}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
}))
}

View File

@@ -0,0 +1,83 @@
use serde_json::{json, Value};
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
use super::super::from_openai_chat::build_openai_cli_response;
pub(crate) fn convert_gemini_cli_response_to_openai_cli(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let candidates = body.get("candidates")?.as_array()?;
let first_candidate = candidates.first()?.as_object()?;
let content = first_candidate.get("content")?.as_object()?;
let parts = content.get("parts")?.as_array()?;
let mut text = String::new();
let mut function_calls = Vec::new();
for (index, part) in parts.iter().enumerate() {
let part = part.as_object()?;
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
} else if let Some(function_call) = part.get("functionCall").and_then(Value::as_object) {
let tool_name = function_call.get("name")?.as_str()?;
let call_id = function_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let arguments = canonicalize_tool_arguments(function_call.get("args").cloned());
function_calls.push(json!({
"type": "function_call",
"call_id": call_id,
"name": tool_name,
"arguments": arguments,
}));
} else {
return None;
}
}
let usage = body.get("usageMetadata").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("promptTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.map(|value| {
value
.get("candidatesTokenCount")
.and_then(Value::as_u64)
.unwrap_or(0)
+ value
.get("thoughtsTokenCount")
.and_then(Value::as_u64)
.unwrap_or(0)
})
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("totalTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + output_tokens);
let model = body
.get("modelVersion")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let response_id = body
.get("responseId")
.or_else(|| body.get("_v1internal_response_id"))
.and_then(Value::as_str)
.unwrap_or("resp-local-finalize");
Some(build_openai_cli_response(
response_id,
model,
&text,
function_calls,
prompt_tokens,
output_tokens,
total_tokens,
))
}

View File

@@ -0,0 +1,12 @@
mod claude_chat;
mod claude_cli;
mod gemini_chat;
mod gemini_cli;
mod openai_cli;
mod shared;
pub(crate) use claude_chat::convert_claude_chat_response_to_openai_chat;
pub(crate) use claude_cli::convert_claude_cli_response_to_openai_cli;
pub(crate) use gemini_chat::convert_gemini_chat_response_to_openai_chat;
pub(crate) use gemini_cli::convert_gemini_cli_response_to_openai_cli;
pub(crate) use openai_cli::convert_openai_cli_response_to_openai_chat;

View File

@@ -0,0 +1,135 @@
use serde_json::{json, Map, Value};
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
pub(crate) fn convert_openai_cli_response_to_openai_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let mut text = String::new();
let mut tool_calls = Vec::new();
if let Some(output_items) = body.get("output").and_then(Value::as_array) {
for (index, item) in output_items.iter().enumerate() {
let item_object = item.as_object()?;
let item_type = item_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match item_type.as_str() {
"message" => {
if let Some(content) = item_object.get("content").and_then(Value::as_array) {
for part in content {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(part_type.as_str(), "output_text" | "text") {
if let Some(piece) = part_object.get("text").and_then(Value::as_str)
{
text.push_str(piece);
}
}
}
}
}
"function_call" => {
let tool_name = item_object
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let tool_id = item_object
.get("call_id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.or_else(|| {
item_object
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
})
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
tool_calls.push(json!({
"id": tool_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": canonicalize_tool_arguments(item_object.get("arguments").cloned()),
}
}));
}
"output_text" | "text" => {
if let Some(piece) = item_object.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
_ => {}
}
}
}
let finish_reason = if tool_calls.is_empty() {
Some("stop")
} else {
Some("tool_calls")
};
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("chatcmpl-local-openai-cli");
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("input_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let completion_tokens = usage
.and_then(|value| value.get("output_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("total_tokens"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + completion_tokens);
let mut message = Map::new();
message.insert("role".to_string(), Value::String("assistant".to_string()));
if text.is_empty() && !tool_calls.is_empty() {
message.insert("content".to_string(), Value::Null);
} else {
message.insert("content".to_string(), Value::String(text));
}
if !tool_calls.is_empty() {
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
Some(json!({
"id": id,
"object": "chat.completion",
"model": model,
"choices": [{
"index": 0,
"message": Value::Object(message),
"finish_reason": finish_reason,
}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
}))
}

View File

@@ -0,0 +1,13 @@
use serde_json::Value;
pub(super) fn build_generated_tool_call_id(index: usize) -> String {
format!("call_auto_{index}")
}
pub(super) fn canonicalize_tool_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(),
}
}

View File

@@ -1,156 +1,24 @@
use super::*; use std::collections::BTreeMap;
use axum::body::Body;
use axum::http::Response;
use serde_json::Value;
pub(crate) use crate::gateway::ai_pipeline::runtime::{
normalize_provider_private_response_value as unwrap_local_finalize_response_value,
provider_private_response_allows_sync_finalize as local_finalize_allows_envelope,
};
use crate::gateway::{
build_client_response_from_parts, GatewayControlDecision, GatewayError,
GatewaySyncReportRequest,
};
pub(crate) struct LocalCoreSyncFinalizeOutcome { pub(crate) struct LocalCoreSyncFinalizeOutcome {
pub(crate) response: Response<Body>, pub(crate) response: Response<Body>,
pub(crate) background_report: Option<GatewaySyncReportRequest>, pub(crate) background_report: Option<GatewaySyncReportRequest>,
} }
pub(super) fn local_finalize_allows_envelope(report_context: &Value) -> bool { pub(crate) fn build_local_success_outcome(
let has_envelope = report_context
.get("has_envelope")
.and_then(Value::as_bool)
.unwrap_or(false);
if !has_envelope {
return true;
}
matches!(
report_context.get("envelope_name").and_then(Value::as_str),
Some("gemini_cli:v1internal")
| Some("antigravity:v1internal")
| Some("claude:cli")
| Some("kiro:generateAssistantResponse")
)
}
fn local_finalize_response_model(report_context: &Value) -> &str {
report_context
.get("mapped_model")
.and_then(Value::as_str)
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or_default()
}
fn inject_antigravity_claude_tool_ids_response(response: &mut Value, model: &str) {
if !model.to_ascii_lowercase().contains("claude") {
return;
}
let Some(candidates) = response.get_mut("candidates").and_then(Value::as_array_mut) else {
return;
};
for candidate in candidates {
let Some(parts) = candidate
.get_mut("content")
.and_then(Value::as_object_mut)
.and_then(|content| content.get_mut("parts"))
.and_then(Value::as_array_mut)
else {
continue;
};
let mut name_counters: BTreeMap<String, usize> = BTreeMap::new();
for part in parts {
let function_call = if let Some(function_call) =
part.get_mut("functionCall").and_then(Value::as_object_mut)
{
function_call
} else if let Some(function_call) =
part.get_mut("function_call").and_then(Value::as_object_mut)
{
function_call
} else {
continue;
};
let has_id = function_call
.get("id")
.and_then(Value::as_str)
.is_some_and(|value| !value.is_empty());
if has_id {
continue;
}
let function_name = function_call
.get("name")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("unknown")
.to_string();
let count = name_counters.entry(function_name.clone()).or_insert(0);
function_call.insert(
"id".to_string(),
Value::String(format!("call_{function_name}_{count}")),
);
*count += 1;
}
}
}
fn postprocess_local_finalize_response_value(data: &mut Value, report_context: &Value) {
if !matches!(
report_context.get("envelope_name").and_then(Value::as_str),
Some("antigravity:v1internal")
) {
return;
}
if let Some(object) = data.as_object_mut() {
if !object.contains_key("_v1internal_response_id") {
if let Some(response_id) = object.remove("responseId") {
object.insert("_v1internal_response_id".to_string(), response_id);
}
}
}
inject_antigravity_claude_tool_ids_response(
data,
local_finalize_response_model(report_context),
);
}
pub(crate) fn unwrap_local_finalize_response_value(
data: Value,
report_context: &Value,
) -> Result<Option<Value>, GatewayError> {
if !report_context
.get("has_envelope")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Ok(Some(data));
}
let mut unwrapped = match report_context.get("envelope_name").and_then(Value::as_str) {
Some("claude:cli") | Some("kiro:generateAssistantResponse") => data,
Some("gemini_cli:v1internal") => {
if let Some(response) = data
.get("response")
.and_then(Value::as_object)
.filter(|response| !response.contains_key("response"))
{
Value::Object(response.clone())
} else {
data
}
}
Some("antigravity:v1internal") => {
if let Some(response) = data
.get("response")
.and_then(Value::as_object)
.filter(|response| !response.contains_key("response"))
{
let mut unwrapped = response.clone();
if let Some(response_id) = data.get("responseId").cloned() {
unwrapped.insert("_v1internal_response_id".to_string(), response_id);
}
Value::Object(unwrapped)
} else {
data
}
}
_ => return Ok(None),
};
postprocess_local_finalize_response_value(&mut unwrapped, report_context);
Ok(Some(unwrapped))
}
pub(super) fn build_local_success_outcome(
trace_id: &str, trace_id: &str,
decision: &GatewayControlDecision, decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest, payload: &GatewaySyncReportRequest,
@@ -169,7 +37,7 @@ pub(super) fn build_local_success_outcome(
) )
} }
pub(super) fn build_local_success_outcome_with_report( pub(crate) fn build_local_success_outcome_with_report(
trace_id: &str, trace_id: &str,
decision: &GatewayControlDecision, decision: &GatewayControlDecision,
status_code: u16, status_code: u16,
@@ -196,7 +64,7 @@ pub(super) fn build_local_success_outcome_with_report(
}) })
} }
pub(super) fn build_local_success_outcome_with_conversion_report( pub(crate) fn build_local_success_outcome_with_conversion_report(
trace_id: &str, trace_id: &str,
decision: &GatewayControlDecision, decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest, payload: &GatewaySyncReportRequest,
@@ -284,7 +152,7 @@ pub(crate) fn build_generated_tool_call_id(index: usize) -> String {
format!("call_auto_{index}") format!("call_auto_{index}")
} }
pub(super) fn parse_stream_json_events(body: &[u8]) -> Option<Vec<Value>> { pub(crate) fn parse_stream_json_events(body: &[u8]) -> Option<Vec<Value>> {
let text = std::str::from_utf8(body).ok()?; let text = std::str::from_utf8(body).ok()?;
let trimmed = text.trim(); let trimmed = text.trim();
if trimmed.is_empty() { if trimmed.is_empty() {

View File

@@ -0,0 +1,38 @@
use axum::body::Body;
use axum::http::Response;
use serde_json::Value;
use crate::gateway::{GatewayControlDecision, GatewayError, GatewaySyncReportRequest};
#[path = "stream_rewrite.rs"]
pub(crate) mod stream;
#[path = "sync_finalize.rs"]
pub(crate) mod sync;
pub(crate) use stream::LocalStreamRewriter;
pub(crate) use sync::LocalCoreSyncFinalizeOutcome;
pub(crate) fn maybe_build_sync_finalize_outcome(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
sync::maybe_build_local_core_sync_finalize_response(trace_id, decision, payload)
}
pub(crate) fn maybe_compile_sync_finalize_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<Response<Body>>, GatewayError> {
Ok(
maybe_build_sync_finalize_outcome(trace_id, decision, payload)?
.map(|outcome| outcome.response),
)
}
pub(crate) fn maybe_build_stream_response_rewriter(
report_context: Option<&Value>,
) -> Option<LocalStreamRewriter> {
stream::maybe_build_local_stream_rewriter(report_context)
}

View File

@@ -1,44 +1,39 @@
use serde_json::Value; use serde_json::Value;
use crate::gateway::ai_pipeline::finalize::standard::{
BufferedCliConversionStreamState, BufferedStandardConversionStreamState,
ClaudeToOpenAIChatStreamState, GeminiToOpenAIChatStreamState, OpenAICliToOpenAIChatStreamState,
};
use crate::gateway::ai_pipeline::finalize::sse::{encode_done_sse, encode_json_sse};
use crate::gateway::ai_pipeline::private_response::transform_provider_private_stream_line as transform_envelope_line;
use crate::gateway::ai_pipeline::runtime::KiroToClaudeCliStreamState;
use crate::gateway::GatewayError; use crate::gateway::GatewayError;
use super::kiro_stream::KiroToClaudeCliStreamState; use super::sync::{
use super::local_finalize::{
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_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,
}; };
#[path = "local_stream/chat.rs"]
mod chat;
#[path = "local_stream/cli.rs"]
mod cli;
#[path = "local_stream/common.rs"]
mod common;
#[path = "local_stream/envelope.rs"]
mod envelope;
use chat::{ClaudeToOpenAIChatStreamState, GeminiToOpenAIChatStreamState};
use cli::BufferedCliConversionStreamState;
use envelope::transform_envelope_line;
enum RewriteMode { enum RewriteMode {
EnvelopeUnwrap, EnvelopeUnwrap,
ClaudeToOpenAIChat(ClaudeToOpenAIChatStreamState), ClaudeToOpenAIChat(ClaudeToOpenAIChatStreamState),
GeminiToOpenAIChat(GeminiToOpenAIChatStreamState), GeminiToOpenAIChat(GeminiToOpenAIChatStreamState),
OpenAICliToOpenAIChat(OpenAICliToOpenAIChatStreamState),
ClaudeToOpenAICli(BufferedCliConversionStreamState), ClaudeToOpenAICli(BufferedCliConversionStreamState),
GeminiToOpenAICli(BufferedCliConversionStreamState), GeminiToOpenAICli(BufferedCliConversionStreamState),
AntigravityGeminiToOpenAIChat(GeminiToOpenAIChatStreamState), AntigravityGeminiToOpenAIChat(GeminiToOpenAIChatStreamState),
AntigravityGeminiToOpenAICli(BufferedCliConversionStreamState), AntigravityGeminiToOpenAICli(BufferedCliConversionStreamState),
KiroToClaudeCli(KiroToClaudeCliStreamState), KiroToClaudeCli(KiroToClaudeCliStreamState),
StandardChat(BufferedStandardConversionStreamState),
StandardCli(BufferedStandardConversionStreamState),
} }
pub(super) struct LocalStreamRewriter { pub(crate) struct LocalStreamRewriter {
report_context: Value, report_context: Value,
buffered: Vec<u8>, buffered: Vec<u8>,
mode: RewriteMode, mode: RewriteMode,
} }
pub(super) fn maybe_build_local_stream_rewriter( pub(crate) fn maybe_build_local_stream_rewriter(
report_context: Option<&Value>, report_context: Option<&Value>,
) -> Option<LocalStreamRewriter> { ) -> Option<LocalStreamRewriter> {
let report_context = report_context?; let report_context = report_context?;
@@ -77,6 +72,9 @@ pub(super) fn maybe_build_local_stream_rewriter(
("", "gemini:chat", "openai:chat") => { ("", "gemini:chat", "openai:chat") => {
RewriteMode::GeminiToOpenAIChat(GeminiToOpenAIChatStreamState::default()) RewriteMode::GeminiToOpenAIChat(GeminiToOpenAIChatStreamState::default())
} }
("", "openai:cli", "openai:chat") | ("", "openai:compact", "openai:chat") => {
RewriteMode::OpenAICliToOpenAIChat(OpenAICliToOpenAIChatStreamState::default())
}
("", "claude:cli", "openai:cli") => { ("", "claude:cli", "openai:cli") => {
RewriteMode::ClaudeToOpenAICli(BufferedCliConversionStreamState::default()) RewriteMode::ClaudeToOpenAICli(BufferedCliConversionStreamState::default())
} }
@@ -102,6 +100,16 @@ pub(super) fn maybe_build_local_stream_rewriter(
BufferedCliConversionStreamState::default(), BufferedCliConversionStreamState::default(),
) )
} }
_ if is_standard_chat_client_api_format(client_api_format.as_str())
&& is_standard_provider_api_format(provider_api_format.as_str()) =>
{
RewriteMode::StandardChat(BufferedStandardConversionStreamState::default())
}
_ if is_standard_cli_client_api_format(client_api_format.as_str())
&& is_standard_provider_api_format(provider_api_format.as_str()) =>
{
RewriteMode::StandardCli(BufferedStandardConversionStreamState::default())
}
_ => return None, _ => return None,
} }
} else { } else {
@@ -141,7 +149,7 @@ pub(super) fn maybe_build_local_stream_rewriter(
} }
impl LocalStreamRewriter { impl LocalStreamRewriter {
pub(super) fn push_chunk(&mut self, chunk: &[u8]) -> Result<Vec<u8>, GatewayError> { pub(crate) fn push_chunk(&mut self, chunk: &[u8]) -> Result<Vec<u8>, GatewayError> {
if let RewriteMode::KiroToClaudeCli(state) = &mut self.mode { if let RewriteMode::KiroToClaudeCli(state) = &mut self.mode {
return state.push_chunk(&self.report_context, chunk); return state.push_chunk(&self.report_context, chunk);
} }
@@ -154,7 +162,7 @@ impl LocalStreamRewriter {
Ok(output) Ok(output)
} }
pub(super) fn finish(&mut self) -> Result<Vec<u8>, GatewayError> { pub(crate) fn finish(&mut self) -> Result<Vec<u8>, GatewayError> {
if let RewriteMode::KiroToClaudeCli(state) = &mut self.mode { if let RewriteMode::KiroToClaudeCli(state) = &mut self.mode {
return state.finish(&self.report_context); return state.finish(&self.report_context);
} }
@@ -164,6 +172,9 @@ impl LocalStreamRewriter {
RewriteMode::GeminiToOpenAIChat(state) => { RewriteMode::GeminiToOpenAIChat(state) => {
return state.finish(&self.report_context); return state.finish(&self.report_context);
} }
RewriteMode::OpenAICliToOpenAIChat(state) => {
return state.finish(&self.report_context);
}
RewriteMode::ClaudeToOpenAICli(state) => { RewriteMode::ClaudeToOpenAICli(state) => {
return state.finish( return state.finish(
&self.report_context, &self.report_context,
@@ -189,6 +200,12 @@ impl LocalStreamRewriter {
); );
} }
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());
@@ -202,6 +219,9 @@ impl LocalStreamRewriter {
RewriteMode::GeminiToOpenAIChat(state) => { 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) => { RewriteMode::ClaudeToOpenAICli(state) => {
output.extend(state.finish( output.extend(state.finish(
&self.report_context, &self.report_context,
@@ -227,6 +247,12 @@ impl LocalStreamRewriter {
)?); )?);
} }
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)
@@ -241,6 +267,9 @@ impl LocalStreamRewriter {
RewriteMode::GeminiToOpenAIChat(state) => { RewriteMode::GeminiToOpenAIChat(state) => {
state.transform_line(&self.report_context, line) state.transform_line(&self.report_context, line)
} }
RewriteMode::OpenAICliToOpenAIChat(state) => {
state.transform_line(&self.report_context, line)
}
RewriteMode::ClaudeToOpenAICli(state) | RewriteMode::GeminiToOpenAICli(state) => { RewriteMode::ClaudeToOpenAICli(state) | RewriteMode::GeminiToOpenAICli(state) => {
state.transform_line(line) state.transform_line(line)
} }
@@ -260,11 +289,38 @@ impl LocalStreamRewriter {
state.transform_line(unwrapped) 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()),
} }
} }
} }
fn is_standard_provider_api_format(api_format: &str) -> bool {
matches!(
api_format,
"openai:chat"
| "openai:cli"
| "openai:compact"
| "claude:chat"
| "claude:cli"
| "gemini:chat"
| "gemini:cli"
)
}
fn is_standard_chat_client_api_format(api_format: &str) -> bool {
matches!(api_format, "openai:chat" | "claude:chat" | "gemini:chat")
}
fn is_standard_cli_client_api_format(api_format: &str) -> bool {
matches!(
api_format,
"openai:cli" | "openai:compact" | "claude:cli" | "gemini:cli"
)
}
#[cfg(test)] #[cfg(test)]
#[path = "local_stream/tests.rs"] #[path = "../tests_stream.rs"]
mod tests; mod tests;

View File

@@ -0,0 +1,377 @@
use std::collections::BTreeMap;
use axum::body::Body;
use axum::http::Response;
use base64::Engine as _;
use serde_json::{json, Map, Value};
use crate::gateway::ai_pipeline::conversion::{
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind,
};
use crate::gateway::{
build_client_response_from_parts, GatewayControlDecision, GatewayError,
GatewaySyncReportRequest,
};
pub(crate) use crate::gateway::ai_pipeline::finalize::common::{
build_generated_tool_call_id, build_local_success_outcome,
build_local_success_outcome_with_conversion_report, canonicalize_tool_arguments,
local_finalize_allows_envelope, parse_stream_json_events, unwrap_local_finalize_response_value,
LocalCoreSyncFinalizeOutcome,
};
pub(crate) use crate::gateway::ai_pipeline::finalize::standard::{
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
aggregate_openai_chat_stream_sync_response, aggregate_openai_cli_stream_sync_response,
aggregate_standard_chat_stream_sync_response, aggregate_standard_cli_stream_sync_response,
convert_claude_chat_response_to_openai_chat, convert_claude_cli_response_to_openai_cli,
convert_gemini_chat_response_to_openai_chat, convert_gemini_cli_response_to_openai_cli,
convert_openai_chat_response_to_claude_chat, convert_openai_chat_response_to_gemini_chat,
convert_openai_chat_response_to_openai_cli, convert_openai_cli_response_to_openai_chat,
convert_standard_chat_response, convert_standard_cli_response,
maybe_build_local_claude_cli_stream_sync_response,
maybe_build_local_claude_stream_sync_response, maybe_build_local_claude_sync_response,
maybe_build_local_gemini_cli_stream_sync_response,
maybe_build_local_gemini_stream_sync_response, maybe_build_local_gemini_sync_response,
maybe_build_local_openai_chat_cross_format_stream_sync_response,
maybe_build_local_openai_chat_cross_format_sync_response,
maybe_build_local_openai_chat_stream_sync_response,
maybe_build_local_openai_chat_sync_response,
maybe_build_local_openai_cli_cross_format_stream_sync_response,
maybe_build_local_openai_cli_cross_format_sync_response,
maybe_build_local_openai_cli_stream_sync_response,
};
pub(crate) fn maybe_build_local_core_sync_finalize_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
let Some(normalized_payload) =
crate::gateway::ai_pipeline::private_response::maybe_normalize_provider_private_sync_report_payload(payload)?
else {
return Ok(None);
};
let payload = &normalized_payload;
if let Some(response) =
maybe_build_local_openai_chat_stream_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_openai_chat_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) = maybe_build_local_openai_chat_cross_format_stream_sync_response(
trace_id, decision, payload,
)? {
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_openai_cli_stream_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_openai_cli_cross_format_stream_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_claude_cli_stream_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_gemini_cli_stream_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_claude_stream_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) = maybe_build_local_claude_sync_response(trace_id, decision, payload)? {
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_gemini_stream_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) = maybe_build_local_gemini_sync_response(trace_id, decision, payload)? {
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_openai_chat_cross_format_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_openai_cli_cross_format_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) = maybe_build_local_standard_chat_cross_format_stream_sync_response(
trace_id, decision, payload,
)? {
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_standard_chat_cross_format_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) = maybe_build_local_standard_cli_cross_format_stream_sync_response(
trace_id, decision, payload,
)? {
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_standard_cli_cross_format_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
Ok(None)
}
fn maybe_build_local_standard_chat_cross_format_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if !matches!(
payload.report_kind.as_str(),
"openai_chat_sync_finalize" | "claude_chat_sync_finalize" | "gemini_chat_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let client_api_format = report_context
.get("client_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if !local_finalize_allows_envelope(report_context)
|| sync_chat_response_conversion_kind(&provider_api_format, &client_api_format).is_none()
{
return Ok(None);
}
let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None);
};
let body_bytes = base64::engine::general_purpose::STANDARD
.decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let Some(aggregated) =
aggregate_standard_chat_stream_sync_response(&body_bytes, &provider_api_format)
else {
return Ok(None);
};
let Some(aggregated) = unwrap_local_finalize_response_value(aggregated, report_context)? else {
return Ok(None);
};
let Some(converted) = convert_standard_chat_response(
&aggregated,
&provider_api_format,
&client_api_format,
report_context,
) else {
return Ok(None);
};
Ok(Some(build_local_success_outcome_with_conversion_report(
trace_id, decision, payload, converted, aggregated,
)?))
}
fn maybe_build_local_standard_chat_cross_format_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if !matches!(
payload.report_kind.as_str(),
"openai_chat_sync_finalize" | "claude_chat_sync_finalize" | "gemini_chat_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let client_api_format = report_context
.get("client_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if !local_finalize_allows_envelope(report_context)
|| sync_chat_response_conversion_kind(&provider_api_format, &client_api_format).is_none()
{
return Ok(None);
}
let Some(body_json) = payload.body_json.as_ref() else {
return Ok(None);
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
else {
return Ok(None);
};
let Some(converted) = convert_standard_chat_response(
&body_json,
&provider_api_format,
&client_api_format,
report_context,
) else {
return Ok(None);
};
Ok(Some(build_local_success_outcome_with_conversion_report(
trace_id, decision, payload, converted, body_json,
)?))
}
fn maybe_build_local_standard_cli_cross_format_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if !matches!(
payload.report_kind.as_str(),
"openai_cli_sync_finalize"
| "openai_compact_sync_finalize"
| "claude_cli_sync_finalize"
| "gemini_cli_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let client_api_format = report_context
.get("client_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if !local_finalize_allows_envelope(report_context)
|| sync_cli_response_conversion_kind(&provider_api_format, &client_api_format).is_none()
{
return Ok(None);
}
let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None);
};
let body_bytes = base64::engine::general_purpose::STANDARD
.decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let Some(aggregated) =
aggregate_standard_cli_stream_sync_response(&body_bytes, &provider_api_format)
else {
return Ok(None);
};
let Some(aggregated) = unwrap_local_finalize_response_value(aggregated, report_context)? else {
return Ok(None);
};
let Some(converted) = convert_standard_cli_response(
&aggregated,
&provider_api_format,
&client_api_format,
report_context,
) else {
return Ok(None);
};
Ok(Some(build_local_success_outcome_with_conversion_report(
trace_id, decision, payload, converted, aggregated,
)?))
}
fn maybe_build_local_standard_cli_cross_format_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if !matches!(
payload.report_kind.as_str(),
"openai_cli_sync_finalize"
| "openai_compact_sync_finalize"
| "claude_cli_sync_finalize"
| "gemini_cli_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let client_api_format = report_context
.get("client_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if !local_finalize_allows_envelope(report_context)
|| sync_cli_response_conversion_kind(&provider_api_format, &client_api_format).is_none()
{
return Ok(None);
}
let Some(body_json) = payload.body_json.as_ref() else {
return Ok(None);
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
else {
return Ok(None);
};
let Some(converted) = convert_standard_cli_response(
&body_json,
&provider_api_format,
&client_api_format,
report_context,
) else {
return Ok(None);
};
Ok(Some(build_local_success_outcome_with_conversion_report(
trace_id, decision, payload, converted, body_json,
)?))
}
#[cfg(test)]
#[path = "../tests_sync.rs"]
mod tests;

View File

@@ -0,0 +1,13 @@
pub(crate) mod common;
pub(crate) mod sse;
pub(crate) mod standard;
pub(crate) use common::build_local_success_outcome;
pub(crate) use internal::{
maybe_build_stream_response_rewriter, maybe_build_sync_finalize_outcome,
maybe_compile_sync_finalize_response, LocalCoreSyncFinalizeOutcome,
};
pub(crate) use crate::gateway::build_client_response;
pub(crate) use crate::gateway::build_client_response_from_parts;
pub(crate) use crate::gateway::execution_runtime::maybe_build_local_sync_finalize_response;
pub(crate) mod internal;

View File

@@ -2,7 +2,7 @@ use serde_json::Value;
use crate::gateway::GatewayError; use crate::gateway::GatewayError;
pub(super) fn map_claude_stop_reason( pub(crate) fn map_claude_stop_reason(
stop_reason: Option<&str>, stop_reason: Option<&str>,
has_tool_calls: bool, has_tool_calls: bool,
) -> Option<&'static str> { ) -> Option<&'static str> {
@@ -20,11 +20,11 @@ pub(super) fn map_claude_stop_reason(
} }
} }
pub(super) fn encode_done_sse() -> Vec<u8> { pub(crate) fn encode_done_sse() -> Vec<u8> {
b"data: [DONE]\n\n".to_vec() b"data: [DONE]\n\n".to_vec()
} }
pub(super) fn encode_json_sse(event: Option<&str>, value: &Value) -> Result<Vec<u8>, GatewayError> { pub(crate) fn encode_json_sse(event: Option<&str>, value: &Value) -> Result<Vec<u8>, GatewayError> {
let mut out = Vec::new(); let mut out = Vec::new();
if let Some(event) = event.filter(|value| !value.trim().is_empty()) { if let Some(event) = event.filter(|value| !value.trim().is_empty()) {
out.extend_from_slice(b"event: "); out.extend_from_slice(b"event: ");

View File

@@ -1,11 +1,11 @@
use base64::Engine as _; use base64::Engine as _;
use super::super::common::{ use super::*;
use crate::gateway::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 super::super::*;
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct ClaudeContentBlockState { struct ClaudeContentBlockState {
@@ -215,6 +215,131 @@ pub(crate) fn convert_claude_chat_response_to_openai_chat(
})) }))
} }
pub(crate) fn convert_openai_chat_response_to_claude_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let choices = body.get("choices")?.as_array()?;
let first_choice = choices.first()?.as_object()?;
let message = first_choice.get("message")?.as_object()?;
let mut content = Vec::new();
if let Some(text) = extract_openai_assistant_text(message.get("content")) {
if !text.trim().is_empty() {
content.push(json!({
"type": "text",
"text": text,
}));
}
}
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
for (index, tool_call) in tool_call_values.iter().enumerate() {
let tool_call = tool_call.as_object()?;
let function = tool_call.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let tool_id = tool_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let input = parse_openai_function_arguments(function.get("arguments"))?;
content.push(json!({
"type": "tool_use",
"id": tool_id,
"name": tool_name,
"input": input,
}));
}
}
if content.is_empty() {
content.push(json!({
"type": "text",
"text": "",
}));
}
let stop_reason = match first_choice.get("finish_reason").and_then(Value::as_str) {
Some("stop") | None => "end_turn",
Some("length") => "max_tokens",
Some("tool_calls") | Some("function_call") => "tool_use",
Some("content_filter") => "content_filtered",
Some(other) => other,
};
let usage = body.get("usage").and_then(Value::as_object);
let input_tokens = usage
.and_then(|value| value.get("prompt_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.and_then(|value| value.get("completion_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("msg-local-finalize");
Some(json!({
"id": id,
"type": "message",
"role": "assistant",
"model": model,
"content": content,
"stop_reason": stop_reason,
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
}
}))
}
fn extract_openai_assistant_text(content: Option<&Value>) -> Option<String> {
match content? {
Value::Null => Some(String::new()),
Value::String(text) => Some(text.clone()),
Value::Array(parts) => {
let mut text = String::new();
for part in parts {
let part = part.as_object()?;
let part_type = part
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(part_type.as_str(), "text" | "output_text") {
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
}
Some(text)
}
_ => None,
}
}
fn parse_openai_function_arguments(arguments: Option<&Value>) -> Option<Value> {
match arguments.cloned().unwrap_or(Value::Object(Map::new())) {
Value::String(text) => serde_json::from_str(&text)
.ok()
.or(Some(Value::String(text))),
other => Some(other),
}
}
pub(crate) fn aggregate_claude_stream_sync_response(body: &[u8]) -> Option<Value> { pub(crate) fn aggregate_claude_stream_sync_response(body: &[u8]) -> Option<Value> {
let events = parse_stream_json_events(body)?; let events = parse_stream_json_events(body)?;
if events.is_empty() { if events.is_empty() {

View File

@@ -1,15 +1,13 @@
use base64::Engine as _; use base64::Engine as _;
use crate::gateway::kiro_stream::KiroToClaudeCliStreamState; use super::aggregate_claude_stream_sync_response;
use super::*;
use super::super::chat::aggregate_claude_stream_sync_response; use crate::gateway::ai_pipeline::finalize::common::{
use super::super::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, unwrap_local_finalize_response_value,
LocalCoreSyncFinalizeOutcome, LocalCoreSyncFinalizeOutcome,
}; };
use super::super::*; use crate::gateway::ai_pipeline::finalize::standard::build_openai_cli_response;
use super::openai::build_openai_cli_response;
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,
@@ -69,36 +67,11 @@ pub(crate) fn maybe_build_local_claude_cli_stream_sync_response(
fn aggregate_provider_claude_cli_stream_sync_response( fn aggregate_provider_claude_cli_stream_sync_response(
body_bytes: &[u8], body_bytes: &[u8],
report_context: &Value, _report_context: &Value,
) -> Result<Option<Value>, GatewayError> { ) -> Result<Option<Value>, GatewayError> {
if report_context
.get("envelope_name")
.and_then(Value::as_str)
.is_some_and(|value| value.eq_ignore_ascii_case("kiro:generateAssistantResponse"))
{
let mut rewriter = KiroToClaudeCliStreamState::new(report_context);
let mut rewritten = rewriter.push_chunk(report_context, body_bytes)?;
rewritten.extend(rewriter.finish(report_context)?);
if rewritten_stream_contains_error_event(&rewritten) {
return Ok(None);
}
return Ok(aggregate_claude_stream_sync_response(&rewritten));
}
Ok(aggregate_claude_stream_sync_response(body_bytes)) Ok(aggregate_claude_stream_sync_response(body_bytes))
} }
fn rewritten_stream_contains_error_event(body: &[u8]) -> bool {
parse_stream_json_events(body).is_some_and(|events| {
events.iter().any(|event| {
event
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value.eq_ignore_ascii_case("error"))
})
})
}
pub(crate) fn convert_claude_cli_response_to_openai_cli( pub(crate) fn convert_claude_cli_response_to_openai_cli(
body_json: &Value, body_json: &Value,
report_context: &Value, report_context: &Value,

View File

@@ -1,11 +1,11 @@
use base64::Engine as _; use base64::Engine as _;
use super::super::common::{ use super::*;
use crate::gateway::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 super::super::*;
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,
@@ -212,6 +212,137 @@ pub(crate) fn convert_gemini_chat_response_to_openai_chat(
})) }))
} }
pub(crate) fn convert_openai_chat_response_to_gemini_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let choices = body.get("choices")?.as_array()?;
let first_choice = choices.first()?.as_object()?;
let message = first_choice.get("message")?.as_object()?;
let mut parts = Vec::new();
if let Some(text) = extract_openai_assistant_text(message.get("content")) {
if !text.trim().is_empty() {
parts.push(json!({ "text": text }));
}
}
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
for (index, tool_call) in tool_call_values.iter().enumerate() {
let tool_call = tool_call.as_object()?;
let function = tool_call.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let call_id = tool_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
parts.push(json!({
"functionCall": {
"id": call_id,
"name": tool_name,
"args": parse_openai_function_arguments(function.get("arguments"))?,
}
}));
}
}
if parts.is_empty() {
parts.push(json!({ "text": "" }));
}
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("prompt_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let completion_tokens = usage
.and_then(|value| value.get("completion_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("total_tokens"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + completion_tokens);
let mut finish_reason = match first_choice.get("finish_reason").and_then(Value::as_str) {
Some("stop") | None => "STOP",
Some("length") => "MAX_TOKENS",
Some("content_filter") => "SAFETY",
Some("tool_calls") | Some("function_call") => "STOP",
Some(other) => other,
};
if parts.iter().any(|part| part.get("functionCall").is_some()) {
finish_reason = "STOP";
}
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let response_id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("resp-local-finalize");
Some(json!({
"responseId": response_id,
"modelVersion": model,
"candidates": [{
"content": {
"role": "model",
"parts": parts,
},
"finishReason": finish_reason,
"index": 0,
}],
"usageMetadata": {
"promptTokenCount": prompt_tokens,
"candidatesTokenCount": completion_tokens,
"totalTokenCount": total_tokens,
}
}))
}
fn extract_openai_assistant_text(content: Option<&Value>) -> Option<String> {
match content? {
Value::Null => Some(String::new()),
Value::String(text) => Some(text.clone()),
Value::Array(parts) => {
let mut text = String::new();
for part in parts {
let part = part.as_object()?;
let part_type = part
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(part_type.as_str(), "text" | "output_text") {
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
}
Some(text)
}
_ => None,
}
}
fn parse_openai_function_arguments(arguments: Option<&Value>) -> Option<Value> {
match arguments.cloned().unwrap_or(Value::Object(Map::new())) {
Value::String(text) => serde_json::from_str(&text)
.ok()
.or(Some(Value::String(text))),
other => Some(other),
}
}
pub(crate) fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> { pub(crate) fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> {
let events = parse_stream_json_events(body)?; let events = parse_stream_json_events(body)?;
if events.is_empty() { if events.is_empty() {
@@ -220,6 +351,7 @@ pub(crate) fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value
let mut candidates: BTreeMap<usize, Value> = BTreeMap::new(); let mut candidates: BTreeMap<usize, Value> = BTreeMap::new();
let mut response_id: Option<Value> = None; let mut response_id: Option<Value> = None;
let mut private_response_id: Option<Value> = None;
let mut model_version: Option<Value> = None; let mut model_version: Option<Value> = None;
let mut usage_metadata: Option<Value> = None; let mut usage_metadata: Option<Value> = None;
let mut prompt_feedback: Option<Value> = None; let mut prompt_feedback: Option<Value> = None;
@@ -230,6 +362,9 @@ pub(crate) fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value
if let Some(id) = raw_event_object.get("responseId") { if let Some(id) = raw_event_object.get("responseId") {
response_id = Some(id.clone()); response_id = Some(id.clone());
} }
if let Some(id) = raw_event_object.get("_v1internal_response_id") {
private_response_id = Some(id.clone());
}
let event_object = if let Some(response) = raw_event_object let event_object = if let Some(response) = raw_event_object
.get("response") .get("response")
.and_then(Value::as_object) .and_then(Value::as_object)
@@ -242,6 +377,9 @@ pub(crate) fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value
if let Some(id) = event_object.get("responseId") { if let Some(id) = event_object.get("responseId") {
response_id = Some(id.clone()); response_id = Some(id.clone());
} }
if let Some(id) = event_object.get("_v1internal_response_id") {
private_response_id = Some(id.clone());
}
if let Some(version) = event_object.get("modelVersion") { if let Some(version) = event_object.get("modelVersion") {
model_version = Some(version.clone()); model_version = Some(version.clone());
} }
@@ -277,6 +415,9 @@ pub(crate) fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value
if let Some(response_id) = response_id { if let Some(response_id) = response_id {
response.insert("responseId".to_string(), response_id); response.insert("responseId".to_string(), response_id);
} }
if let Some(private_response_id) = private_response_id {
response.insert("_v1internal_response_id".to_string(), private_response_id);
}
response.insert( response.insert(
"candidates".to_string(), "candidates".to_string(),
Value::Array(candidates.into_values().collect()), Value::Array(candidates.into_values().collect()),

View File

@@ -1,13 +1,13 @@
use base64::Engine as _; use base64::Engine as _;
use super::super::chat::aggregate_gemini_stream_sync_response; use super::aggregate_gemini_stream_sync_response;
use super::super::common::{ use super::*;
use crate::gateway::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 super::super::*; use crate::gateway::ai_pipeline::finalize::standard::build_openai_cli_response;
use super::openai::build_openai_cli_response;
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,
@@ -128,6 +128,7 @@ pub(crate) fn convert_gemini_cli_response_to_openai_cli(
.unwrap_or("unknown"); .unwrap_or("unknown");
let response_id = body let response_id = body
.get("responseId") .get("responseId")
.or_else(|| body.get("_v1internal_response_id"))
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("resp-local-finalize"); .unwrap_or("resp-local-finalize");

View File

@@ -0,0 +1,141 @@
//! Standard finalize surface for standard contract sync/stream compilation.
use std::collections::BTreeMap;
use serde_json::Value;
use serde_json::{json, Map};
use crate::gateway::{GatewayControlDecision, GatewayError, GatewaySyncReportRequest};
#[path = "claude/chat.rs"]
mod claude_chat;
#[path = "claude/cli.rs"]
mod claude_cli;
#[path = "gemini/chat.rs"]
mod gemini_chat;
#[path = "gemini/cli.rs"]
mod gemini_cli;
#[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.rs"]
mod stream;
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::{
aggregate_claude_stream_sync_response, maybe_build_local_claude_stream_sync_response,
maybe_build_local_claude_sync_response,
};
pub(crate) use claude_cli::maybe_build_local_claude_cli_stream_sync_response;
pub(crate) use gemini_chat::{
aggregate_gemini_stream_sync_response, maybe_build_local_gemini_stream_sync_response,
maybe_build_local_gemini_sync_response,
};
pub(crate) use gemini_cli::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::BufferedStandardConversionStreamState;
pub(crate) fn aggregate_standard_chat_stream_sync_response(
body: &[u8],
provider_api_format: &str,
) -> Option<Value> {
match provider_api_format.trim().to_ascii_lowercase().as_str() {
"openai:chat" => aggregate_openai_chat_stream_sync_response(body),
"openai:cli" | "openai:compact" => aggregate_openai_cli_stream_sync_response(body),
"claude:chat" | "claude:cli" => aggregate_claude_stream_sync_response(body),
"gemini:chat" | "gemini:cli" => aggregate_gemini_stream_sync_response(body),
_ => None,
}
}
pub(crate) fn convert_standard_chat_response(
body_json: &Value,
provider_api_format: &str,
client_api_format: &str,
report_context: &Value,
) -> Option<Value> {
let canonical = match provider_api_format.trim().to_ascii_lowercase().as_str() {
"openai:chat" => body_json.clone(),
"openai:cli" | "openai:compact" => {
convert_openai_cli_response_to_openai_chat(body_json, report_context)?
}
"claude:chat" | "claude:cli" => {
convert_claude_chat_response_to_openai_chat(body_json, report_context)?
}
"gemini:chat" | "gemini:cli" => {
convert_gemini_chat_response_to_openai_chat(body_json, report_context)?
}
_ => return None,
};
match client_api_format.trim().to_ascii_lowercase().as_str() {
"openai:chat" => Some(canonical),
"claude:chat" => convert_openai_chat_response_to_claude_chat(&canonical, report_context),
"gemini:chat" => convert_openai_chat_response_to_gemini_chat(&canonical, report_context),
_ => None,
}
}
pub(crate) fn aggregate_standard_cli_stream_sync_response(
body: &[u8],
provider_api_format: &str,
) -> Option<Value> {
aggregate_standard_chat_stream_sync_response(body, provider_api_format)
}
pub(crate) fn convert_standard_cli_response(
body_json: &Value,
provider_api_format: &str,
client_api_format: &str,
report_context: &Value,
) -> Option<Value> {
let canonical = match provider_api_format.trim().to_ascii_lowercase().as_str() {
"openai:cli" | "openai:compact" => {
convert_openai_cli_response_to_openai_chat(body_json, report_context)?
}
_ => convert_standard_chat_response(
body_json,
provider_api_format,
"openai:chat",
report_context,
)?,
};
match client_api_format.trim().to_ascii_lowercase().as_str() {
"openai:cli" => {
convert_openai_chat_response_to_openai_cli(&canonical, report_context, false)
}
"openai:compact" => {
convert_openai_chat_response_to_openai_cli(&canonical, report_context, true)
}
"claude:cli" => convert_openai_chat_response_to_claude_chat(&canonical, report_context),
"gemini:cli" => convert_openai_chat_response_to_gemini_chat(&canonical, report_context),
_ => None,
}
}

View File

@@ -1,15 +1,18 @@
use base64::Engine as _; use base64::Engine as _;
use super::super::common::{ use super::*;
build_local_success_outcome, build_local_success_outcome_with_conversion_report, use super::{
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
aggregate_openai_cli_stream_sync_response, convert_claude_chat_response_to_openai_chat,
convert_gemini_chat_response_to_openai_chat,
};
use crate::gateway::ai_pipeline::finalize::common::{
build_generated_tool_call_id, build_local_success_outcome,
build_local_success_outcome_with_conversion_report, canonicalize_tool_arguments,
local_finalize_allows_envelope, unwrap_local_finalize_response_value, local_finalize_allows_envelope, unwrap_local_finalize_response_value,
LocalCoreSyncFinalizeOutcome, LocalCoreSyncFinalizeOutcome,
}; };
use super::super::*; use crate::gateway::ai_pipeline::conversion::sync_chat_response_conversion_kind;
use super::{
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
convert_claude_chat_response_to_openai_chat, convert_gemini_chat_response_to_openai_chat,
};
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct OpenAIChatChoiceState { struct OpenAIChatChoiceState {
@@ -162,6 +165,11 @@ pub(crate) fn maybe_build_local_openai_chat_cross_format_stream_sync_response(
if client_api_format != "openai:chat" || !local_finalize_allows_envelope(report_context) { if client_api_format != "openai:chat" || !local_finalize_allows_envelope(report_context) {
return Ok(None); return Ok(None);
} }
let Some(conversion_kind) =
sync_chat_response_conversion_kind(&provider_api_format, &client_api_format)
else {
return Ok(None);
};
let Some(body_base64) = payload.body_base64.as_deref() else { let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None); return Ok(None);
@@ -170,8 +178,9 @@ pub(crate) fn maybe_build_local_openai_chat_cross_format_stream_sync_response(
.decode(body_base64) .decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?; .map_err(|err| GatewayError::Internal(err.to_string()))?;
let aggregated = match provider_api_format.as_str() { let aggregated = match provider_api_format.as_str() {
"claude:chat" => aggregate_claude_stream_sync_response(&body_bytes), "claude:chat" | "claude:cli" => aggregate_claude_stream_sync_response(&body_bytes),
"gemini:chat" => aggregate_gemini_stream_sync_response(&body_bytes), "gemini:chat" | "gemini:cli" => aggregate_gemini_stream_sync_response(&body_bytes),
"openai:cli" | "openai:compact" => aggregate_openai_cli_stream_sync_response(&body_bytes),
_ => None, _ => None,
}; };
let Some(aggregated) = aggregated else { let Some(aggregated) = aggregated else {
@@ -181,8 +190,15 @@ pub(crate) fn maybe_build_local_openai_chat_cross_format_stream_sync_response(
return Ok(None); return Ok(None);
}; };
let converted = match provider_api_format.as_str() { let converted = match provider_api_format.as_str() {
"claude:chat" => convert_claude_chat_response_to_openai_chat(&aggregated, report_context), "claude:chat" | "claude:cli" => {
"gemini:chat" => convert_gemini_chat_response_to_openai_chat(&aggregated, report_context), convert_claude_chat_response_to_openai_chat(&aggregated, report_context)
}
"gemini:chat" | "gemini:cli" => {
convert_gemini_chat_response_to_openai_chat(&aggregated, report_context)
}
"openai:cli" | "openai:compact" => {
convert_openai_cli_response_to_openai_chat(&aggregated, report_context)
}
_ => None, _ => None,
}; };
let Some(converted) = converted else { let Some(converted) = converted else {
@@ -221,6 +237,11 @@ pub(crate) fn maybe_build_local_openai_chat_cross_format_sync_response(
if client_api_format != "openai:chat" || !local_finalize_allows_envelope(report_context) { if client_api_format != "openai:chat" || !local_finalize_allows_envelope(report_context) {
return Ok(None); return Ok(None);
} }
let Some(conversion_kind) =
sync_chat_response_conversion_kind(&provider_api_format, &client_api_format)
else {
return Ok(None);
};
let Some(body_json) = payload.body_json.as_ref() else { let Some(body_json) = payload.body_json.as_ref() else {
return Ok(None); return Ok(None);
@@ -230,8 +251,15 @@ pub(crate) fn maybe_build_local_openai_chat_cross_format_sync_response(
return Ok(None); return Ok(None);
}; };
let converted = match provider_api_format.as_str() { let converted = match provider_api_format.as_str() {
"claude:chat" => convert_claude_chat_response_to_openai_chat(&body_json, report_context), "claude:chat" | "claude:cli" => {
"gemini:chat" => convert_gemini_chat_response_to_openai_chat(&body_json, report_context), convert_claude_chat_response_to_openai_chat(&body_json, report_context)
}
"gemini:chat" | "gemini:cli" => {
convert_gemini_chat_response_to_openai_chat(&body_json, report_context)
}
"openai:cli" | "openai:compact" => {
convert_openai_cli_response_to_openai_chat(&body_json, report_context)
}
_ => None, _ => None,
}; };
let Some(converted) = converted else { let Some(converted) = converted else {
@@ -243,6 +271,138 @@ pub(crate) fn maybe_build_local_openai_chat_cross_format_sync_response(
)?)) )?))
} }
pub(crate) fn convert_openai_cli_response_to_openai_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let mut text = String::new();
let mut tool_calls = Vec::new();
if let Some(output_items) = body.get("output").and_then(Value::as_array) {
for (index, item) in output_items.iter().enumerate() {
let item_object = item.as_object()?;
let item_type = item_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match item_type.as_str() {
"message" => {
if let Some(content) = item_object.get("content").and_then(Value::as_array) {
for part in content {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(part_type.as_str(), "output_text" | "text") {
if let Some(piece) = part_object.get("text").and_then(Value::as_str)
{
text.push_str(piece);
}
}
}
}
}
"function_call" => {
let tool_name = item_object
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let tool_id = item_object
.get("call_id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.or_else(|| {
item_object
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
})
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
tool_calls.push(json!({
"id": tool_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": canonicalize_tool_arguments(item_object.get("arguments").cloned()),
}
}));
}
"output_text" | "text" => {
if let Some(piece) = item_object.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
_ => {}
}
}
}
let finish_reason = if tool_calls.is_empty() {
Some("stop")
} else {
Some("tool_calls")
};
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("chatcmpl-local-openai-cli");
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("input_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let completion_tokens = usage
.and_then(|value| value.get("output_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("total_tokens"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + completion_tokens);
let mut message = Map::new();
message.insert("role".to_string(), Value::String("assistant".to_string()));
if text.is_empty() && !tool_calls.is_empty() {
message.insert("content".to_string(), Value::Null);
} else {
message.insert("content".to_string(), Value::String(text));
}
if !tool_calls.is_empty() {
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
Some(json!({
"id": id,
"object": "chat.completion",
"model": model,
"choices": [{
"index": 0,
"message": Value::Object(message),
"finish_reason": finish_reason,
}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
}))
}
pub(crate) fn aggregate_openai_chat_stream_sync_response(body: &[u8]) -> Option<Value> { pub(crate) fn aggregate_openai_chat_stream_sync_response(body: &[u8]) -> Option<Value> {
let text = std::str::from_utf8(body).ok()?; let text = std::str::from_utf8(body).ok()?;
let mut response_id: Option<String> = None; let mut response_id: Option<String> = None;

View File

@@ -1,19 +1,29 @@
use serde_json::{json, Map, Value}; 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 crate::gateway::GatewayError;
use super::common::{encode_done_sse, encode_json_sse, map_claude_stop_reason}; use super::{
use super::*; aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
convert_openai_cli_response_to_openai_chat,
};
#[derive(Default)] #[derive(Default)]
pub(super) struct ClaudeToOpenAIChatStreamState { pub(crate) struct ClaudeToOpenAIChatStreamState {
raw: Vec<u8>, raw: Vec<u8>,
message_id: Option<String>, message_id: Option<String>,
model: Option<String>, model: Option<String>,
} }
#[derive(Default)] #[derive(Default)]
pub(super) struct GeminiToOpenAIChatStreamState { pub(crate) struct GeminiToOpenAIChatStreamState {
raw: Vec<u8>,
}
#[derive(Default)]
pub(crate) struct OpenAICliToOpenAIChatStreamState {
raw: Vec<u8>, raw: Vec<u8>,
} }
@@ -243,7 +253,7 @@ fn convert_gemini_aggregated_to_openai_chunk(
} }
impl ClaudeToOpenAIChatStreamState { impl ClaudeToOpenAIChatStreamState {
pub(super) fn transform_line( pub(crate) fn transform_line(
&mut self, &mut self,
report_context: &Value, report_context: &Value,
line: Vec<u8>, line: Vec<u8>,
@@ -379,7 +389,7 @@ impl ClaudeToOpenAIChatStreamState {
} }
} }
pub(super) fn finish(&mut self) -> Vec<u8> { pub(crate) fn finish(&mut self) -> Vec<u8> {
if self.raw.is_empty() { if self.raw.is_empty() {
return Vec::new(); return Vec::new();
} }
@@ -399,7 +409,7 @@ impl ClaudeToOpenAIChatStreamState {
} }
impl GeminiToOpenAIChatStreamState { impl GeminiToOpenAIChatStreamState {
pub(super) fn transform_line( pub(crate) fn transform_line(
&mut self, &mut self,
_report_context: &Value, _report_context: &Value,
line: Vec<u8>, line: Vec<u8>,
@@ -408,7 +418,7 @@ impl GeminiToOpenAIChatStreamState {
Ok(Vec::new()) Ok(Vec::new())
} }
pub(super) fn finish(&mut self, report_context: &Value) -> Result<Vec<u8>, GatewayError> { pub(crate) fn finish(&mut self, report_context: &Value) -> Result<Vec<u8>, GatewayError> {
if self.raw.is_empty() { if self.raw.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
@@ -426,3 +436,64 @@ impl GeminiToOpenAIChatStreamState {
Ok(out) 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

@@ -0,0 +1,692 @@
use base64::Engine as _;
use super::*;
use super::{
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
convert_claude_cli_response_to_openai_cli, convert_gemini_cli_response_to_openai_cli,
};
use crate::gateway::ai_pipeline::finalize::common::{
build_local_success_outcome, build_local_success_outcome_with_conversion_report,
canonicalize_tool_arguments, local_finalize_allows_envelope,
unwrap_local_finalize_response_value, LocalCoreSyncFinalizeOutcome,
};
use crate::gateway::ai_pipeline::conversion::sync_cli_response_conversion_kind;
pub(crate) fn maybe_build_local_openai_cli_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if let Some(response) =
maybe_build_local_openai_cli_direct_stream_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) = maybe_build_local_openai_cli_openai_family_stream_sync_response(
trace_id, decision, payload,
)? {
return Ok(Some(response));
}
maybe_build_local_openai_cli_direct_sync_response(trace_id, decision, payload)
}
pub(crate) fn maybe_build_local_openai_cli_cross_format_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if let Some(response) =
maybe_build_local_openai_cli_antigravity_cross_format_stream_sync_response(
trace_id, decision, payload,
)?
{
return Ok(Some(response));
}
if !matches!(
payload.report_kind.as_str(),
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let client_api_format = report_context
.get("client_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let _has_envelope = report_context
.get("has_envelope")
.and_then(Value::as_bool)
.unwrap_or(false);
if !matches!(client_api_format.as_str(), "openai:cli" | "openai:compact")
|| !local_finalize_allows_envelope(report_context)
{
return Ok(None);
}
let Some(conversion_kind) =
sync_cli_response_conversion_kind(&provider_api_format, &client_api_format)
else {
return Ok(None);
};
let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None);
};
let body_bytes = base64::engine::general_purpose::STANDARD
.decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let aggregated = match provider_api_format.as_str() {
"openai:cli" | "openai:compact" => aggregate_openai_cli_stream_sync_response(&body_bytes),
"claude:chat" | "claude:cli" => aggregate_claude_stream_sync_response(&body_bytes),
"gemini:chat" | "gemini:cli" => aggregate_gemini_stream_sync_response(&body_bytes),
_ => None,
};
let Some(aggregated) = aggregated else {
return Ok(None);
};
let Some(aggregated) = unwrap_local_finalize_response_value(aggregated, report_context)? else {
return Ok(None);
};
let converted = match provider_api_format.as_str() {
"openai:cli" | "openai:compact" => Some(aggregated.clone()),
"claude:chat" | "claude:cli" => {
convert_claude_cli_response_to_openai_cli(&aggregated, report_context)
}
"gemini:chat" | "gemini:cli" => {
convert_gemini_cli_response_to_openai_cli(&aggregated, report_context)
}
_ => None,
};
let Some(converted) = converted else {
return Ok(None);
};
Ok(Some(build_local_success_outcome_with_conversion_report(
trace_id, decision, payload, converted, aggregated,
)?))
}
pub(crate) fn maybe_build_local_openai_cli_cross_format_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if let Some(response) = maybe_build_local_openai_cli_antigravity_cross_format_sync_response(
trace_id, decision, payload,
)? {
return Ok(Some(response));
}
if !matches!(
payload.report_kind.as_str(),
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let client_api_format = report_context
.get("client_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if !matches!(client_api_format.as_str(), "openai:cli" | "openai:compact")
|| !local_finalize_allows_envelope(report_context)
{
return Ok(None);
}
let Some(conversion_kind) =
sync_cli_response_conversion_kind(&provider_api_format, &client_api_format)
else {
return Ok(None);
};
let Some(body_json) = payload.body_json.as_ref() else {
return Ok(None);
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
else {
return Ok(None);
};
let converted = match provider_api_format.as_str() {
"openai:cli" | "openai:compact" => Some(body_json.clone()),
"claude:chat" | "claude:cli" => {
convert_claude_cli_response_to_openai_cli(&body_json, report_context)
}
"gemini:chat" | "gemini:cli" => {
convert_gemini_cli_response_to_openai_cli(&body_json, report_context)
}
_ => None,
};
let Some(converted) = converted else {
return Ok(None);
};
Ok(Some(build_local_success_outcome_with_conversion_report(
trace_id, decision, payload, converted, body_json,
)?))
}
fn maybe_build_local_openai_cli_direct_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if !matches!(
payload.report_kind.as_str(),
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let client_api_format = report_context
.get("client_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if !local_finalize_allows_envelope(report_context)
|| !is_openai_cli_family_api_format(provider_api_format.as_str())
|| !is_openai_cli_family_api_format(client_api_format.as_str())
{
return Ok(None);
}
let Some(body_json) = payload.body_json.as_ref() else {
return Ok(None);
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
else {
return Ok(None);
};
Ok(Some(build_local_success_outcome(
trace_id, decision, payload, body_json,
)?))
}
fn maybe_build_local_openai_cli_direct_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if !matches!(
payload.report_kind.as_str(),
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let client_api_format = report_context
.get("client_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let needs_conversion = report_context
.get("needs_conversion")
.and_then(Value::as_bool)
.unwrap_or(false);
if !local_finalize_allows_envelope(report_context) {
return Ok(None);
}
if !matches!(
provider_api_format.as_str(),
"openai:cli" | "openai:compact"
) || provider_api_format != client_api_format
|| needs_conversion
{
return Ok(None);
}
let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None);
};
let body_bytes = base64::engine::general_purpose::STANDARD
.decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let body_json = match aggregate_openai_cli_stream_sync_response(&body_bytes) {
Some(body_json) => body_json,
None => return Ok(None),
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json, report_context)? else {
return Ok(None);
};
Ok(Some(build_local_success_outcome(
trace_id, decision, payload, body_json,
)?))
}
fn maybe_build_local_openai_cli_openai_family_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if !matches!(
payload.report_kind.as_str(),
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let client_api_format = report_context
.get("client_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if !local_finalize_allows_envelope(report_context)
|| !is_openai_cli_family_api_format(provider_api_format.as_str())
|| !is_openai_cli_family_api_format(client_api_format.as_str())
|| provider_api_format == client_api_format
{
return Ok(None);
}
let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None);
};
let body_bytes = base64::engine::general_purpose::STANDARD
.decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let Some(body_json) = aggregate_openai_cli_stream_sync_response(&body_bytes) else {
return Ok(None);
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json, report_context)? else {
return Ok(None);
};
Ok(Some(build_local_success_outcome(
trace_id, decision, payload, body_json,
)?))
}
fn maybe_build_local_openai_cli_antigravity_cross_format_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if !matches!(
payload.report_kind.as_str(),
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let client_api_format = report_context
.get("client_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if provider_api_format != "gemini:cli"
|| !is_openai_cli_family_api_format(client_api_format.as_str())
|| !is_antigravity_v1internal_envelope(report_context)
|| !local_finalize_allows_envelope(report_context)
{
return Ok(None);
}
let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None);
};
let body_bytes = base64::engine::general_purpose::STANDARD
.decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let Some(aggregated) = aggregate_gemini_stream_sync_response(&body_bytes) else {
return Ok(None);
};
let Some(provider_body_json) =
unwrap_cli_conversion_response_value(aggregated, report_context)?
else {
return Ok(None);
};
let Some(converted) =
convert_gemini_cli_response_to_openai_cli(&provider_body_json, report_context)
else {
return Ok(None);
};
Ok(Some(build_local_success_outcome_with_conversion_report(
trace_id,
decision,
payload,
converted,
provider_body_json,
)?))
}
fn maybe_build_local_openai_cli_antigravity_cross_format_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if !matches!(
payload.report_kind.as_str(),
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let client_api_format = report_context
.get("client_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if provider_api_format != "gemini:cli"
|| !is_openai_cli_family_api_format(client_api_format.as_str())
|| !is_antigravity_v1internal_envelope(report_context)
|| !local_finalize_allows_envelope(report_context)
{
return Ok(None);
}
let Some(body_json) = payload.body_json.as_ref() else {
return Ok(None);
};
let Some(provider_body_json) =
unwrap_cli_conversion_response_value(body_json.clone(), report_context)?
else {
return Ok(None);
};
let Some(converted) =
convert_gemini_cli_response_to_openai_cli(&provider_body_json, report_context)
else {
return Ok(None);
};
Ok(Some(build_local_success_outcome_with_conversion_report(
trace_id,
decision,
payload,
converted,
provider_body_json,
)?))
}
fn unwrap_cli_conversion_response_value(
data: Value,
report_context: &Value,
) -> Result<Option<Value>, GatewayError> {
if !is_antigravity_v1internal_envelope(report_context) {
return unwrap_local_finalize_response_value(data, report_context);
}
let mut unwrapped = if let Some(response) = data
.get("response")
.and_then(Value::as_object)
.filter(|response| !response.contains_key("response"))
{
let mut response = response.clone();
if let Some(response_id) = data.get("responseId").cloned() {
response
.entry("responseId".to_string())
.or_insert(response_id);
}
Value::Object(response)
} else {
data
};
if let Some(object) = unwrapped.as_object_mut() {
if !object.contains_key("responseId") {
if let Some(response_id) = object.get("_v1internal_response_id").cloned() {
object.insert("responseId".to_string(), response_id);
}
}
}
Ok(Some(unwrapped))
}
fn is_antigravity_v1internal_envelope(report_context: &Value) -> bool {
report_context
.get("has_envelope")
.and_then(Value::as_bool)
.unwrap_or(false)
&& report_context
.get("envelope_name")
.and_then(Value::as_str)
.is_some_and(|value| value.eq_ignore_ascii_case("antigravity:v1internal"))
}
fn is_openai_cli_family_api_format(api_format: &str) -> bool {
matches!(api_format, "openai:cli" | "openai:compact")
}
pub(crate) fn build_openai_cli_response(
response_id: &str,
model: &str,
text: &str,
function_calls: Vec<Value>,
prompt_tokens: u64,
output_tokens: u64,
total_tokens: u64,
) -> Value {
let mut output = Vec::new();
if !text.is_empty() {
output.push(json!({
"type": "message",
"id": format!("{response_id}_msg"),
"role": "assistant",
"status": "completed",
"content": [{
"type": "output_text",
"text": text,
"annotations": []
}]
}));
}
output.extend(function_calls);
json!({
"id": response_id,
"object": "response",
"status": "completed",
"model": model,
"output": output,
"usage": {
"input_tokens": prompt_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
}
})
}
pub(crate) fn convert_openai_chat_response_to_openai_cli(
body_json: &Value,
report_context: &Value,
compact: bool,
) -> Option<Value> {
let body = body_json.as_object()?;
let choices = body.get("choices")?.as_array()?;
let first_choice = choices.first()?.as_object()?;
let message = first_choice.get("message")?.as_object()?;
let mut text = String::new();
match message.get("content") {
Some(Value::String(value)) => text.push_str(value),
Some(Value::Array(parts)) => {
for part in parts {
let part = part.as_object()?;
let part_type = part
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(part_type.as_str(), "text" | "output_text") {
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
}
}
Some(Value::Null) | None => {}
_ => return None,
}
let mut function_calls = Vec::new();
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
for tool_call in tool_call_values {
let tool_call = tool_call.as_object()?;
let function = tool_call.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
function_calls.push(json!({
"type": "function_call",
"id": tool_call.get("id").cloned().unwrap_or(Value::Null),
"call_id": tool_call.get("id").cloned().unwrap_or(Value::Null),
"name": tool_name,
"arguments": canonicalize_tool_arguments(function.get("arguments").cloned()),
}));
}
}
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("prompt_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.and_then(|value| value.get("completion_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("total_tokens"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + output_tokens);
let response_id = if compact {
body.get("id")
.and_then(Value::as_str)
.map(|value| value.replace("chatcmpl", "resp"))
.unwrap_or_else(|| "resp-local-finalize".to_string())
} else {
body.get("id")
.and_then(Value::as_str)
.map(|value| value.replace("chatcmpl", "resp"))
.unwrap_or_else(|| "resp-local-finalize".to_string())
};
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
Some(build_openai_cli_response(
&response_id,
model,
&text,
function_calls,
prompt_tokens,
output_tokens,
total_tokens,
))
}
pub(crate) fn aggregate_openai_cli_stream_sync_response(body: &[u8]) -> Option<Value> {
let text = std::str::from_utf8(body).ok()?;
for raw_line in text.lines() {
let line = raw_line.trim_matches('\r').trim();
if line.is_empty() || line.starts_with(':') || line.starts_with("event:") {
continue;
}
let Some(data_line) = line.strip_prefix("data:") else {
continue;
};
let data_line = data_line.trim();
if data_line.is_empty() || data_line == "[DONE]" {
continue;
}
let event: Value = serde_json::from_str(data_line).ok()?;
let event_type = event
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
if event_type == "response.completed" {
let response = event.get("response")?.as_object()?.clone();
return Some(Value::Object(response));
}
}
None
}

View File

@@ -1,21 +1,20 @@
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::gateway::ai_pipeline::finalize::sse::encode_json_sse;
use crate::gateway::GatewayError; use crate::gateway::GatewayError;
use super::common::encode_json_sse;
#[derive(Default)] #[derive(Default)]
pub(super) struct BufferedCliConversionStreamState { pub(crate) struct BufferedCliConversionStreamState {
raw: Vec<u8>, raw: Vec<u8>,
} }
impl BufferedCliConversionStreamState { impl BufferedCliConversionStreamState {
pub(super) fn transform_line(&mut self, line: Vec<u8>) -> Result<Vec<u8>, GatewayError> { pub(crate) fn transform_line(&mut self, line: Vec<u8>) -> Result<Vec<u8>, GatewayError> {
self.raw.extend_from_slice(&line); self.raw.extend_from_slice(&line);
Ok(Vec::new()) Ok(Vec::new())
} }
pub(super) fn finish<AggregateFn, ConvertFn>( pub(crate) fn finish<AggregateFn, ConvertFn>(
&mut self, &mut self,
report_context: &Value, report_context: &Value,
aggregate: AggregateFn, aggregate: AggregateFn,

View File

@@ -0,0 +1,347 @@
use serde_json::{json, Value};
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::ai_pipeline::finalize::sse::{encode_done_sse, encode_json_sse};
use crate::gateway::ai_pipeline::private_response::transform_provider_private_stream_line as transform_envelope_line;
use crate::gateway::ai_pipeline::private_surfaces::provider_adaptation_should_unwrap_stream_envelope;
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

@@ -180,6 +180,35 @@ fn gemini_to_openai_chat_stream_rewriter_buffers_and_converts_function_call() {
assert!(output_text.contains("data: [DONE]")); assert!(output_text.contains("data: [DONE]"));
} }
#[test]
fn openai_cli_to_openai_chat_stream_rewriter_buffers_and_converts_completed_event() {
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 output = 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");
assert!(output.is_empty());
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("\"role\":\"assistant\""));
assert!(output_text.contains("\"content\":\"Hello Codex\""));
assert!(output_text.contains("\"finish_reason\":\"stop\""));
assert!(output_text.contains("data: [DONE]"));
}
#[test] #[test]
fn antigravity_gemini_to_openai_chat_stream_rewriter_unwraps_and_converts_function_call() { fn antigravity_gemini_to_openai_chat_stream_rewriter_unwraps_and_converts_function_call() {
let report_context = json!({ let report_context = json!({
@@ -389,3 +418,60 @@ fn gemini_to_openai_compact_stream_rewriter_converts_function_call_to_completed_
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\\\""));
} }
#[test]
fn openai_chat_to_claude_chat_stream_rewriter_converts_via_standard_matrix() {
let report_context = json!({
"provider_api_format": "openai:chat",
"client_api_format": "claude:chat",
"needs_conversion": true,
"mapped_model": "gpt-5",
});
let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let output = rewriter
.push_chunk(
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: [DONE]\n\n"
)
.as_bytes(),
)
.expect("rewrite should succeed");
assert!(output.is_empty());
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed"))
.expect("utf8 should decode");
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"));
}
#[test]
fn openai_chat_to_gemini_cli_stream_rewriter_converts_via_standard_matrix() {
let report_context = json!({
"provider_api_format": "openai:chat",
"client_api_format": "gemini:cli",
"needs_conversion": true,
"mapped_model": "gpt-5",
});
let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let output = rewriter
.push_chunk(
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: [DONE]\n\n"
)
.as_bytes(),
)
.expect("rewrite should succeed");
assert!(output.is_empty());
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed"))
.expect("utf8 should decode");
assert!(output_text.contains("\"responseId\":\"chatcmpl_std_gemini_cli_123\""));
assert!(output_text.contains("\"candidates\""));
assert!(output_text.contains("\"text\":\"Hello Gemini CLI\""));
}

View File

@@ -4,19 +4,13 @@ use base64::Engine as _;
use serde_json::json; use serde_json::json;
use super::{ use super::{
chat::{ 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, convert_claude_chat_response_to_openai_chat, convert_claude_chat_response_to_openai_chat, convert_claude_cli_response_to_openai_cli,
convert_gemini_chat_response_to_openai_chat, convert_gemini_chat_response_to_openai_chat, convert_gemini_cli_response_to_openai_cli,
},
cli::{
aggregate_openai_cli_stream_sync_response, convert_claude_cli_response_to_openai_cli,
convert_gemini_cli_response_to_openai_cli,
},
maybe_build_local_core_sync_finalize_response, maybe_build_local_core_sync_finalize_response,
}; };
use crate::gateway::executor::GatewaySyncReportRequest; use crate::gateway::{GatewayControlDecision, GatewaySyncReportRequest};
use crate::gateway::GatewayControlDecision;
fn test_decision() -> GatewayControlDecision { fn test_decision() -> GatewayControlDecision {
GatewayControlDecision { GatewayControlDecision {
@@ -26,7 +20,7 @@ fn test_decision() -> GatewayControlDecision {
route_family: Some("openai".to_string()), route_family: Some("openai".to_string()),
route_kind: Some("compact".to_string()), route_kind: Some("compact".to_string()),
auth_endpoint_signature: Some("openai:compact".to_string()), auth_endpoint_signature: Some("openai:compact".to_string()),
executor_candidate: true, execution_runtime_candidate: true,
auth_context: None, auth_context: None,
admin_principal: None, admin_principal: None,
local_auth_rejection: None, local_auth_rejection: None,
@@ -898,6 +892,144 @@ fn local_finalize_handles_openai_cli_openai_family_sync_response_even_when_conve
); );
} }
#[test]
fn local_finalize_handles_openai_chat_stream_response_from_openai_chat() {
let body = concat!(
"data: {\"id\":\"chatcmpl_stream_direct_123\",\"object\":\"chat.completion.chunk\",\"created\":1,",
"\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl_stream_direct_123\",\"object\":\"chat.completion.chunk\",",
"\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" world\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl_stream_direct_123\",\"object\":\"chat.completion.chunk\",",
"\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
"data: [DONE]\n\n",
);
let payload = GatewaySyncReportRequest {
trace_id: "trace-openai-chat-stream-sync-123".to_string(),
report_kind: "openai_chat_sync_finalize".to_string(),
report_context: Some(json!({
"client_api_format": "openai:chat",
"provider_api_format": "openai:chat",
"model": "gpt-5",
"mapped_model": "gpt-5",
"needs_conversion": false,
"has_envelope": false,
})),
status_code: 200,
headers: BTreeMap::from([("content-type".to_string(), "text/event-stream".to_string())]),
body_json: None,
client_body_json: None,
body_base64: Some(base64::engine::general_purpose::STANDARD.encode(body.as_bytes())),
telemetry: None,
};
let outcome = maybe_build_local_core_sync_finalize_response(
"trace-openai-chat-stream-sync-123",
&test_decision(),
&payload,
)
.expect("local finalize should succeed")
.expect("local finalize should match");
let report = outcome
.background_report
.expect("stream finalize should downgrade to success report");
assert_eq!(report.report_kind, "openai_chat_sync_success");
let provider_body = report.body_json.expect("provider body should exist");
assert_eq!(provider_body["object"], "chat.completion");
assert_eq!(
provider_body["choices"][0]["message"]["content"],
"Hello world"
);
}
#[test]
fn local_finalize_handles_openai_cli_cross_format_stream_response_from_gemini() {
let body = concat!(
"data: {\"responseId\":\"resp_cli_stream_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello \"}],\"role\":\"model\"},\"index\":0}],\"modelVersion\":\"gemini-2.5-pro-upstream\"}\n\n",
"data: {\"responseId\":\"resp_cli_stream_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello Gemini CLI\"}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"gemini-2.5-pro-upstream\",\"usageMetadata\":{\"promptTokenCount\":2,\"candidatesTokenCount\":3,\"totalTokenCount\":5}}\n\n",
);
let payload = GatewaySyncReportRequest {
trace_id: "trace-openai-cli-xfmt-stream-123".to_string(),
report_kind: "openai_cli_sync_finalize".to_string(),
report_context: Some(json!({
"client_api_format": "openai:cli",
"provider_api_format": "gemini:cli",
"model": "gpt-5",
"mapped_model": "gemini-2.5-pro-upstream",
"needs_conversion": true,
"has_envelope": false,
})),
status_code: 200,
headers: BTreeMap::from([("content-type".to_string(), "text/event-stream".to_string())]),
body_json: None,
client_body_json: None,
body_base64: Some(base64::engine::general_purpose::STANDARD.encode(body.as_bytes())),
telemetry: None,
};
let outcome = maybe_build_local_core_sync_finalize_response(
"trace-openai-cli-xfmt-stream-123",
&test_decision(),
&payload,
)
.expect("local finalize should succeed")
.expect("local finalize should match");
let report = outcome
.background_report
.expect("cross-format stream finalize should downgrade to success report");
assert_eq!(report.report_kind, "openai_cli_sync_success");
let client_body = report.client_body_json.expect("client body should exist");
assert_eq!(client_body["object"], "response");
assert_eq!(
client_body["output"][0]["content"][0]["text"],
"Hello Gemini CLI"
);
}
#[test]
fn local_finalize_handles_openai_compact_openai_family_stream_response_even_when_conversion_flagged(
) {
let body = concat!(
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_cli_family_stream_123\",\"object\":\"response\",\"model\":\"gpt-5\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}\n\n",
);
let payload = GatewaySyncReportRequest {
trace_id: "trace-openai-compact-family-stream-123".to_string(),
report_kind: "openai_compact_sync_finalize".to_string(),
report_context: Some(json!({
"client_api_format": "openai:compact",
"provider_api_format": "openai:cli",
"model": "gpt-5",
"mapped_model": "gpt-5",
"needs_conversion": true,
"has_envelope": false,
})),
status_code: 200,
headers: BTreeMap::from([("content-type".to_string(), "text/event-stream".to_string())]),
body_json: None,
client_body_json: None,
body_base64: Some(base64::engine::general_purpose::STANDARD.encode(body.as_bytes())),
telemetry: None,
};
let outcome = maybe_build_local_core_sync_finalize_response(
"trace-openai-compact-family-stream-123",
&test_decision(),
&payload,
)
.expect("local finalize should succeed")
.expect("local finalize should match");
let report = outcome
.background_report
.expect("same-family stream finalize should downgrade to success report");
assert_eq!(report.report_kind, "openai_cli_sync_success");
let provider_body = report.body_json.expect("provider body should exist");
assert_eq!(provider_body["object"], "response");
assert_eq!(provider_body["status"], "completed");
}
#[test] #[test]
fn local_finalize_handles_openai_chat_cross_format_sync_response_from_claude() { fn local_finalize_handles_openai_chat_cross_format_sync_response_from_claude() {
let payload = GatewaySyncReportRequest { let payload = GatewaySyncReportRequest {
@@ -1017,6 +1149,229 @@ fn local_finalize_handles_openai_chat_cross_format_sync_response_from_gemini() {
assert_eq!(client_body["usage"]["total_tokens"], 3); assert_eq!(client_body["usage"]["total_tokens"], 3);
} }
#[test]
fn local_finalize_handles_openai_chat_cross_format_sync_response_from_openai_cli() {
let payload = GatewaySyncReportRequest {
trace_id: "trace-openai-chat-xfmt-openai-cli-sync-123".to_string(),
report_kind: "openai_chat_sync_finalize".to_string(),
report_context: Some(json!({
"client_api_format": "openai:chat",
"provider_api_format": "openai:cli",
"model": "gpt-5.4",
"mapped_model": "gpt-5.4",
"needs_conversion": true,
"has_envelope": false,
})),
status_code: 200,
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
body_json: Some(json!({
"id": "resp_cli_direct_123",
"object": "response",
"status": "completed",
"model": "gpt-5.4",
"output": [
{
"type": "message",
"id": "msg_cli_direct_123",
"role": "assistant",
"status": "completed",
"content": [{
"type": "output_text",
"text": "Hello CLI",
"annotations": []
}]
},
{
"type": "function_call",
"id": "fc_cli_direct_123",
"call_id": "call_weather_123",
"name": "get_weather",
"arguments": "{\"city\":\"SF\"}"
}
],
"usage": {
"input_tokens": 2,
"output_tokens": 3,
"total_tokens": 5
}
})),
client_body_json: None,
body_base64: None,
telemetry: None,
};
let outcome = maybe_build_local_core_sync_finalize_response(
"trace-openai-chat-xfmt-openai-cli-sync-123",
&test_decision(),
&payload,
)
.expect("local finalize should succeed")
.expect("local finalize should match");
let report = outcome
.background_report
.expect("cross-format finalize should downgrade to success report");
assert_eq!(report.report_kind, "openai_chat_sync_success");
assert_eq!(
report.body_json.expect("provider body should exist")["id"],
"resp_cli_direct_123"
);
let client_body = report.client_body_json.expect("client body should exist");
assert_eq!(client_body["object"], "chat.completion");
assert_eq!(client_body["choices"][0]["message"]["content"], "Hello CLI");
assert_eq!(
client_body["choices"][0]["message"]["tool_calls"][0]["id"],
"call_weather_123"
);
assert_eq!(
client_body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
"get_weather"
);
assert_eq!(
client_body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
"{\"city\":\"SF\"}"
);
assert_eq!(client_body["choices"][0]["finish_reason"], "tool_calls");
assert_eq!(client_body["usage"]["completion_tokens"], 3);
assert_eq!(client_body["usage"]["total_tokens"], 5);
}
#[test]
fn local_finalize_handles_claude_chat_cross_format_sync_response_from_openai_chat() {
let payload = GatewaySyncReportRequest {
trace_id: "trace-claude-chat-xfmt-openai-sync-123".to_string(),
report_kind: "claude_chat_sync_finalize".to_string(),
report_context: Some(json!({
"client_api_format": "claude:chat",
"provider_api_format": "openai:chat",
"model": "claude-sonnet-4-5",
"mapped_model": "gpt-5",
"needs_conversion": true,
"has_envelope": false,
})),
status_code: 200,
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
body_json: Some(json!({
"id": "chatcmpl_openai_to_claude_123",
"object": "chat.completion",
"model": "gpt-5",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello OpenAI to Claude"
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 2,
"completion_tokens": 3,
"total_tokens": 5
}
})),
client_body_json: None,
body_base64: None,
telemetry: None,
};
let outcome = maybe_build_local_core_sync_finalize_response(
"trace-claude-chat-xfmt-openai-sync-123",
&GatewayControlDecision {
public_path: "/v1/messages".to_string(),
public_query_string: None,
route_class: Some("ai_public".to_string()),
route_family: Some("claude".to_string()),
route_kind: Some("chat".to_string()),
auth_endpoint_signature: Some("claude:chat".to_string()),
execution_runtime_candidate: true,
auth_context: None,
admin_principal: None,
local_auth_rejection: None,
},
&payload,
)
.expect("local finalize should succeed")
.expect("local finalize should match");
let report = outcome
.background_report
.expect("cross-format finalize should downgrade to success report");
assert_eq!(report.report_kind, "claude_chat_sync_success");
let client_body = report.client_body_json.expect("client body should exist");
assert_eq!(client_body["type"], "message");
assert_eq!(client_body["role"], "assistant");
assert_eq!(client_body["content"][0]["text"], "Hello OpenAI to Claude");
assert_eq!(client_body["usage"]["input_tokens"], 2);
assert_eq!(client_body["usage"]["output_tokens"], 3);
}
#[test]
fn local_finalize_handles_gemini_cli_cross_format_sync_response_from_claude_cli() {
let payload = GatewaySyncReportRequest {
trace_id: "trace-gemini-cli-xfmt-claude-sync-123".to_string(),
report_kind: "gemini_cli_sync_finalize".to_string(),
report_context: Some(json!({
"client_api_format": "gemini:cli",
"provider_api_format": "claude:cli",
"model": "gemini-cli",
"mapped_model": "claude-code",
"needs_conversion": true,
"has_envelope": false,
})),
status_code: 200,
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
body_json: Some(json!({
"id": "msg_claude_cli_to_gemini_123",
"type": "message",
"model": "claude-code",
"role": "assistant",
"content": [{
"type": "text",
"text": "Hello Claude CLI to Gemini"
}],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 4,
"output_tokens": 6
}
})),
client_body_json: None,
body_base64: None,
telemetry: None,
};
let outcome = maybe_build_local_core_sync_finalize_response(
"trace-gemini-cli-xfmt-claude-sync-123",
&GatewayControlDecision {
public_path: "/v1beta/models/gemini-cli:generateContent".to_string(),
public_query_string: None,
route_class: Some("ai_public".to_string()),
route_family: Some("gemini".to_string()),
route_kind: Some("cli".to_string()),
auth_endpoint_signature: Some("gemini:cli".to_string()),
execution_runtime_candidate: true,
auth_context: None,
admin_principal: None,
local_auth_rejection: None,
},
&payload,
)
.expect("local finalize should succeed")
.expect("local finalize should match");
let report = outcome
.background_report
.expect("cross-format finalize should downgrade to success report");
assert_eq!(report.report_kind, "gemini_cli_sync_success");
let client_body = report.client_body_json.expect("client body should exist");
assert_eq!(
client_body["candidates"][0]["content"]["parts"][0]["text"],
"Hello Claude CLI to Gemini"
);
assert_eq!(client_body["usageMetadata"]["promptTokenCount"], 4);
assert_eq!(client_body["usageMetadata"]["candidatesTokenCount"], 6);
}
#[test] #[test]
fn local_finalize_handles_kiro_claude_cli_stream_tool_use_response() { fn local_finalize_handles_kiro_claude_cli_stream_tool_use_response() {
let payload = build_kiro_claude_cli_sync_finalize_payload( let payload = build_kiro_claude_cli_sync_finalize_payload(

View File

@@ -0,0 +1,6 @@
pub(crate) mod conversion;
pub(crate) mod finalize;
pub(crate) mod planner;
pub(crate) mod private_response;
pub(crate) mod private_surfaces;
pub(crate) mod runtime;

View File

@@ -0,0 +1,294 @@
use tracing::warn;
use crate::gateway::provider_transport::resolve_transport_proxy_snapshot;
use crate::gateway::scheduler::GatewayMinimalCandidateSelectionCandidate;
use crate::gateway::AppState;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum TunnelOwnerAffinityBucket {
LocalTunnel = 0,
Neutral = 1,
RemoteTunnel = 2,
}
pub(crate) async fn prefer_local_tunnel_owner_candidates(
state: &AppState,
candidates: Vec<GatewayMinimalCandidateSelectionCandidate>,
) -> Vec<GatewayMinimalCandidateSelectionCandidate> {
let mut ranked = Vec::with_capacity(candidates.len());
for (original_index, candidate) in candidates.into_iter().enumerate() {
let bucket = resolve_candidate_tunnel_owner_affinity(state, &candidate).await;
ranked.push((bucket, original_index, candidate));
}
ranked.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
ranked
.into_iter()
.map(|(_, _, candidate)| candidate)
.collect()
}
async fn resolve_candidate_tunnel_owner_affinity(
state: &AppState,
candidate: &GatewayMinimalCandidateSelectionCandidate,
) -> TunnelOwnerAffinityBucket {
let transport = match state
.read_provider_transport_snapshot(
&candidate.provider_id,
&candidate.endpoint_id,
&candidate.key_id,
)
.await
{
Ok(Some(transport)) => transport,
Ok(None) => return TunnelOwnerAffinityBucket::Neutral,
Err(error) => {
warn!(
provider_id = %candidate.provider_id,
endpoint_id = %candidate.endpoint_id,
key_id = %candidate.key_id,
error = ?error,
"failed to load provider transport while evaluating tunnel owner affinity"
);
return TunnelOwnerAffinityBucket::Neutral;
}
};
let Some(proxy) = resolve_transport_proxy_snapshot(&transport) else {
return TunnelOwnerAffinityBucket::Neutral;
};
if proxy.enabled == Some(false) {
return TunnelOwnerAffinityBucket::Neutral;
}
let Some(node_id) = proxy
.node_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return TunnelOwnerAffinityBucket::Neutral;
};
if state.tunnel.has_local_proxy(node_id) {
return TunnelOwnerAffinityBucket::LocalTunnel;
}
match state
.tunnel
.lookup_attachment_owner(state.data.as_ref(), node_id)
.await
{
Ok(Some(owner)) if owner.gateway_instance_id == state.tunnel.local_instance_id() => {
TunnelOwnerAffinityBucket::LocalTunnel
}
Ok(Some(_)) => TunnelOwnerAffinityBucket::RemoteTunnel,
Ok(None) => TunnelOwnerAffinityBucket::Neutral,
Err(error) => {
warn!(
node_id = node_id,
error = %error,
"failed to load tunnel attachment owner while evaluating scheduler affinity"
);
TunnelOwnerAffinityBucket::Neutral
}
}
}
#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use aether_data::repository::provider_catalog::{
InMemoryProviderCatalogReadRepository, StoredProviderCatalogEndpoint,
StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use serde_json::json;
use super::*;
use crate::gateway::tunnel::TunnelAttachmentRecord;
use crate::gateway::GatewayDataState;
fn sample_candidate(
endpoint_id: &str,
key_id: &str,
) -> GatewayMinimalCandidateSelectionCandidate {
GatewayMinimalCandidateSelectionCandidate {
provider_id: "provider-1".to_string(),
provider_name: "provider-1".to_string(),
provider_type: "custom".to_string(),
provider_priority: 0,
endpoint_id: endpoint_id.to_string(),
endpoint_api_format: "openai:chat".to_string(),
key_id: key_id.to_string(),
key_name: key_id.to_string(),
key_auth_type: "api_key".to_string(),
key_internal_priority: 0,
key_global_priority_for_format: Some(0),
key_capabilities: None,
model_id: "model-1".to_string(),
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-4.1".to_string(),
selected_provider_model_name: "gpt-4.1".to_string(),
mapping_matched_model: None,
}
}
fn sample_provider() -> StoredProviderCatalogProvider {
StoredProviderCatalogProvider::new(
"provider-1".to_string(),
"provider-1".to_string(),
Some("https://provider.example".to_string()),
"custom".to_string(),
)
.expect("provider should build")
.with_transport_fields(true, false, false, None, None, None, None, None, None)
}
fn sample_endpoint(id: &str) -> StoredProviderCatalogEndpoint {
StoredProviderCatalogEndpoint::new(
id.to_string(),
"provider-1".to_string(),
"openai:chat".to_string(),
Some("openai".to_string()),
Some("chat".to_string()),
true,
)
.expect("endpoint should build")
.with_transport_fields(
"https://api.provider.example".to_string(),
None,
None,
None,
None,
None,
None,
None,
)
.expect("endpoint transport should build")
}
fn sample_key(id: &str, node_id: &str) -> StoredProviderCatalogKey {
StoredProviderCatalogKey::new(
id.to_string(),
"provider-1".to_string(),
id.to_string(),
"api_key".to_string(),
None,
true,
)
.expect("key should build")
.with_transport_fields(
Some(json!(["openai:chat"])),
"plain-upstream-key".to_string(),
None,
None,
Some(json!({"openai:chat": 1})),
None,
None,
Some(json!({
"enabled": true,
"mode": "tunnel",
"node_id": node_id,
})),
None,
)
.expect("key transport should build")
}
fn tunnel_attachment_key(node_id: &str) -> String {
format!("tunnel.attachments.{node_id}")
}
fn current_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[tokio::test]
async fn prefers_local_tunnel_owner_candidates_before_remote_tunnel_candidates() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider()],
vec![
sample_endpoint("endpoint-remote"),
sample_endpoint("endpoint-local"),
],
vec![
sample_key("key-remote", "node-remote"),
sample_key("key-local", "node-local"),
],
);
let observed_at_unix_secs = current_unix_secs();
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
"development-key",
)
.with_system_config_values_for_tests(vec![
(
tunnel_attachment_key("node-remote"),
serde_json::to_value(TunnelAttachmentRecord {
gateway_instance_id: "gateway-b".to_string(),
relay_base_url: "http://gateway-b:8080".to_string(),
conn_count: 1,
observed_at_unix_secs,
})
.expect("remote attachment should serialize"),
),
(
tunnel_attachment_key("node-local"),
serde_json::to_value(TunnelAttachmentRecord {
gateway_instance_id: "gateway-a".to_string(),
relay_base_url: "http://gateway-a:8080".to_string(),
conn_count: 1,
observed_at_unix_secs,
})
.expect("local attachment should serialize"),
),
]);
let state = AppState::new("http://127.0.0.1:1")
.expect("state should build")
.with_data_state_for_tests(data_state)
.with_tunnel_identity_for_tests("gateway-a", Some("http://gateway-a:8080"));
let reordered = prefer_local_tunnel_owner_candidates(
&state,
vec![
sample_candidate("endpoint-remote", "key-remote"),
sample_candidate("endpoint-local", "key-local"),
],
)
.await;
assert_eq!(reordered[0].endpoint_id, "endpoint-local");
assert_eq!(reordered[1].endpoint_id, "endpoint-remote");
}
#[tokio::test]
async fn leaves_candidate_order_unchanged_when_transport_has_no_tunnel_proxy() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider()],
vec![sample_endpoint("endpoint-a"), sample_endpoint("endpoint-b")],
vec![sample_key("key-a", ""), sample_key("key-b", "")],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
"development-key",
);
let state = AppState::new("http://127.0.0.1:1")
.expect("state should build")
.with_data_state_for_tests(data_state)
.with_tunnel_identity_for_tests("gateway-a", Some("http://gateway-a:8080"));
let reordered = prefer_local_tunnel_owner_candidates(
&state,
vec![
sample_candidate("endpoint-a", "key-a"),
sample_candidate("endpoint-b", "key-b"),
],
)
.await;
assert_eq!(reordered[0].endpoint_id, "endpoint-a");
assert_eq!(reordered[1].endpoint_id, "endpoint-b");
}
}

View File

@@ -0,0 +1,57 @@
use axum::body::Bytes;
use base64::Engine as _;
use crate::gateway::headers::is_json_request;
pub(crate) const GEMINI_FILES_GET_PLAN_KIND: &str = "gemini_files_get";
pub(crate) const GEMINI_FILES_UPLOAD_PLAN_KIND: &str = "gemini_files_upload";
pub(crate) const GEMINI_FILES_LIST_PLAN_KIND: &str = "gemini_files_list";
pub(crate) const GEMINI_FILES_DELETE_PLAN_KIND: &str = "gemini_files_delete";
pub(crate) const GEMINI_FILES_DOWNLOAD_PLAN_KIND: &str = "gemini_files_download";
pub(crate) const OPENAI_VIDEO_CONTENT_PLAN_KIND: &str = "openai_video_content";
pub(crate) const OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND: &str = "openai_video_cancel_sync";
pub(crate) const OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND: &str = "openai_video_remix_sync";
pub(crate) const OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND: &str = "openai_video_delete_sync";
pub(crate) const GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND: &str = "gemini_video_create_sync";
pub(crate) const GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND: &str = "gemini_video_cancel_sync";
pub(crate) const OPENAI_CHAT_STREAM_PLAN_KIND: &str = "openai_chat_stream";
pub(crate) const CLAUDE_CHAT_STREAM_PLAN_KIND: &str = "claude_chat_stream";
pub(crate) const GEMINI_CHAT_STREAM_PLAN_KIND: &str = "gemini_chat_stream";
pub(crate) const OPENAI_CLI_STREAM_PLAN_KIND: &str = "openai_cli_stream";
pub(crate) const OPENAI_COMPACT_STREAM_PLAN_KIND: &str = "openai_compact_stream";
pub(crate) const CLAUDE_CLI_STREAM_PLAN_KIND: &str = "claude_cli_stream";
pub(crate) const GEMINI_CLI_STREAM_PLAN_KIND: &str = "gemini_cli_stream";
pub(crate) const OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND: &str = "openai_video_create_sync";
pub(crate) const OPENAI_CHAT_SYNC_PLAN_KIND: &str = "openai_chat_sync";
pub(crate) const OPENAI_CLI_SYNC_PLAN_KIND: &str = "openai_cli_sync";
pub(crate) const OPENAI_COMPACT_SYNC_PLAN_KIND: &str = "openai_compact_sync";
pub(crate) const CLAUDE_CHAT_SYNC_PLAN_KIND: &str = "claude_chat_sync";
pub(crate) const GEMINI_CHAT_SYNC_PLAN_KIND: &str = "gemini_chat_sync";
pub(crate) const CLAUDE_CLI_SYNC_PLAN_KIND: &str = "claude_cli_sync";
pub(crate) const GEMINI_CLI_SYNC_PLAN_KIND: &str = "gemini_cli_sync";
pub(crate) const EXECUTION_RUNTIME_SYNC_ACTION: &str = "execution_runtime_sync";
pub(crate) const EXECUTION_RUNTIME_SYNC_DECISION_ACTION: &str = "execution_runtime_sync_decision";
pub(crate) const EXECUTION_RUNTIME_STREAM_ACTION: &str = "execution_runtime_stream";
pub(crate) const EXECUTION_RUNTIME_STREAM_DECISION_ACTION: &str =
"execution_runtime_stream_decision";
pub(crate) fn parse_direct_request_body(
parts: &http::request::Parts,
body_bytes: &Bytes,
) -> Option<(serde_json::Value, Option<String>)> {
if is_json_request(&parts.headers) {
if body_bytes.is_empty() {
Some((serde_json::json!({}), None))
} else {
serde_json::from_slice::<serde_json::Value>(body_bytes)
.ok()
.map(|value| (value, None))
}
} else {
Some((
serde_json::json!({}),
(!body_bytes.is_empty())
.then(|| base64::engine::general_purpose::STANDARD.encode(body_bytes)),
))
}
}

View File

@@ -0,0 +1,162 @@
use std::collections::BTreeMap;
use aether_contracts::{ExecutionPlan, ExecutionTimeouts, ProxySnapshot};
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::gateway::headers::collect_control_headers;
use crate::gateway::{AppState, GatewayControlAuthContext, GatewayControlDecision, GatewayError};
#[derive(Debug, Serialize)]
pub(crate) struct GatewayControlPlanRequest {
pub(crate) trace_id: String,
pub(crate) method: String,
pub(crate) path: String,
pub(crate) query_string: Option<String>,
pub(crate) headers: BTreeMap<String, String>,
pub(crate) body_json: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) body_base64: Option<String>,
pub(crate) auth_context: Option<GatewayControlAuthContext>,
}
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct GatewayControlPlanResponse {
pub(crate) action: String,
#[serde(default)]
pub(crate) plan_kind: Option<String>,
#[serde(default)]
pub(crate) plan: Option<ExecutionPlan>,
#[serde(default)]
pub(crate) report_kind: Option<String>,
#[serde(default)]
pub(crate) report_context: Option<serde_json::Value>,
#[serde(default)]
pub(crate) auth_context: Option<GatewayControlAuthContext>,
}
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct GatewayControlSyncDecisionResponse {
pub(crate) action: String,
#[serde(default)]
pub(crate) decision_kind: Option<String>,
#[serde(default)]
pub(crate) execution_strategy: Option<String>,
#[serde(default)]
pub(crate) conversion_mode: Option<String>,
#[serde(default)]
pub(crate) request_id: Option<String>,
#[serde(default)]
pub(crate) candidate_id: Option<String>,
#[serde(default)]
pub(crate) provider_name: Option<String>,
#[serde(default)]
pub(crate) provider_id: Option<String>,
#[serde(default)]
pub(crate) endpoint_id: Option<String>,
#[serde(default)]
pub(crate) key_id: Option<String>,
#[serde(default)]
pub(crate) upstream_base_url: Option<String>,
#[serde(default)]
pub(crate) upstream_url: Option<String>,
#[serde(default)]
pub(crate) provider_request_method: Option<String>,
#[serde(default)]
pub(crate) auth_header: Option<String>,
#[serde(default)]
pub(crate) auth_value: Option<String>,
#[serde(default)]
pub(crate) provider_api_format: Option<String>,
#[serde(default)]
pub(crate) client_api_format: Option<String>,
#[serde(default)]
pub(crate) provider_contract: Option<String>,
#[serde(default)]
pub(crate) client_contract: Option<String>,
#[serde(default)]
pub(crate) model_name: Option<String>,
#[serde(default)]
pub(crate) mapped_model: Option<String>,
#[serde(default)]
pub(crate) prompt_cache_key: Option<String>,
#[serde(default)]
pub(crate) extra_headers: BTreeMap<String, String>,
#[serde(default)]
pub(crate) provider_request_headers: BTreeMap<String, String>,
#[serde(default)]
pub(crate) provider_request_body: Option<serde_json::Value>,
#[serde(default)]
pub(crate) provider_request_body_base64: Option<String>,
#[serde(default)]
pub(crate) content_type: Option<String>,
#[serde(default)]
pub(crate) proxy: Option<ProxySnapshot>,
#[serde(default)]
pub(crate) tls_profile: Option<String>,
#[serde(default)]
pub(crate) timeouts: Option<ExecutionTimeouts>,
#[serde(default)]
pub(crate) upstream_is_stream: bool,
#[serde(default)]
pub(crate) report_kind: Option<String>,
#[serde(default)]
pub(crate) report_context: Option<serde_json::Value>,
#[serde(default)]
pub(crate) auth_context: Option<GatewayControlAuthContext>,
}
fn decision_has_exact_provider_request(payload: &GatewayControlSyncDecisionResponse) -> bool {
!payload.provider_request_headers.is_empty()
&& (payload.provider_request_body.is_some()
|| payload
.provider_request_body_base64
.as_ref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false))
}
pub(crate) fn generic_decision_missing_exact_provider_request(
payload: &GatewayControlSyncDecisionResponse,
) -> bool {
if decision_has_exact_provider_request(payload) {
return false;
}
warn!(
decision_kind = payload.decision_kind.as_deref().unwrap_or_default(),
provider_api_format = payload.provider_api_format.as_deref().unwrap_or_default(),
client_api_format = payload.client_api_format.as_deref().unwrap_or_default(),
"gateway generic decision missing exact provider request; falling back to plan"
);
true
}
pub(crate) async fn build_gateway_plan_request(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: serde_json::Value,
body_base64: Option<String>,
) -> Result<GatewayControlPlanRequest, GatewayError> {
let auth_context = crate::gateway::resolve_execution_runtime_auth_context(
state,
decision,
&parts.headers,
&parts.uri,
trace_id,
)
.await?;
Ok(GatewayControlPlanRequest {
trace_id: trace_id.to_string(),
method: parts.method.to_string(),
path: parts.uri.path().to_string(),
query_string: parts.uri.query().map(ToOwned::to_owned),
headers: collect_control_headers(&parts.headers),
body_json,
body_base64,
auth_context,
})
}

View File

@@ -0,0 +1,175 @@
use crate::gateway::ai_pipeline::planner::common::{
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,
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_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,
};
use crate::gateway::ai_pipeline::planner::plan_builders::{
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_cli_stream_plan_from_decision, build_openai_cli_sync_plan_from_decision,
build_passthrough_stream_plan_from_decision, build_passthrough_sync_plan_from_decision,
build_standard_stream_plan_from_decision, build_standard_sync_plan_from_decision,
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::{
AppState, GatewayControlAuthContext, GatewayControlDecision, GatewayControlPlanResponse,
GatewayControlSyncDecisionResponse, GatewayError,
};
pub(crate) async fn maybe_build_sync_plan_payload_impl(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
body_base64: Option<&str>,
body_is_empty: bool,
) -> Result<Option<GatewayControlPlanResponse>, GatewayError> {
let Some(plan_kind) = super::resolve_sync_plan_kind(parts, decision) else {
return Ok(None);
};
let Some(payload) = super::maybe_build_sync_decision_payload(
state,
parts,
trace_id,
decision,
body_json,
body_base64,
body_is_empty,
)
.await?
else {
return Ok(None);
};
build_sync_plan_payload_from_decision(parts, body_json, plan_kind, payload)
}
pub(crate) async fn maybe_build_stream_plan_payload_impl(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
) -> Result<Option<GatewayControlPlanResponse>, GatewayError> {
let Some(plan_kind) = super::resolve_stream_plan_kind(parts, decision) else {
return Ok(None);
};
let Some(payload) =
super::maybe_build_stream_decision_payload(state, parts, trace_id, decision, body_json)
.await?
else {
return Ok(None);
};
build_stream_plan_payload_from_decision(parts, body_json, plan_kind, payload)
}
fn build_sync_plan_payload_from_decision(
parts: &http::request::Parts,
body_json: &serde_json::Value,
plan_kind: &str,
payload: GatewayControlSyncDecisionResponse,
) -> Result<Option<GatewayControlPlanResponse>, GatewayError> {
let auth_context = payload.auth_context.clone();
let plan_and_report = match plan_kind {
OPENAI_CHAT_SYNC_PLAN_KIND => {
build_openai_chat_sync_plan_from_decision(parts, body_json, payload)?
}
OPENAI_CLI_SYNC_PLAN_KIND => {
build_openai_cli_sync_plan_from_decision(parts, body_json, payload, false)?
}
OPENAI_COMPACT_SYNC_PLAN_KIND => {
build_openai_cli_sync_plan_from_decision(parts, body_json, payload, true)?
}
CLAUDE_CHAT_SYNC_PLAN_KIND | CLAUDE_CLI_SYNC_PLAN_KIND => {
build_standard_sync_plan_from_decision(parts, body_json, payload)?
}
GEMINI_CHAT_SYNC_PLAN_KIND | GEMINI_CLI_SYNC_PLAN_KIND => {
build_gemini_sync_plan_from_decision(parts, body_json, payload)?
}
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND
| OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND
| OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND
| OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND
| GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND
| GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND
| GEMINI_FILES_LIST_PLAN_KIND
| GEMINI_FILES_GET_PLAN_KIND
| GEMINI_FILES_DELETE_PLAN_KIND => {
build_passthrough_sync_plan_from_decision(parts, payload)?
}
_ => None,
};
Ok(plan_and_report.map(|value| build_sync_plan_response(plan_kind, value, auth_context)))
}
fn build_stream_plan_payload_from_decision(
parts: &http::request::Parts,
body_json: &serde_json::Value,
plan_kind: &str,
payload: GatewayControlSyncDecisionResponse,
) -> Result<Option<GatewayControlPlanResponse>, GatewayError> {
let auth_context = payload.auth_context.clone();
let plan_and_report = match plan_kind {
OPENAI_CHAT_STREAM_PLAN_KIND => {
build_openai_chat_stream_plan_from_decision(parts, body_json, payload)?
}
OPENAI_CLI_STREAM_PLAN_KIND => {
build_openai_cli_stream_plan_from_decision(parts, body_json, payload, false)?
}
OPENAI_COMPACT_STREAM_PLAN_KIND => {
build_openai_cli_stream_plan_from_decision(parts, body_json, payload, true)?
}
CLAUDE_CHAT_STREAM_PLAN_KIND | CLAUDE_CLI_STREAM_PLAN_KIND => {
build_standard_stream_plan_from_decision(parts, body_json, payload, true)?
}
GEMINI_CHAT_STREAM_PLAN_KIND | GEMINI_CLI_STREAM_PLAN_KIND => {
build_gemini_stream_plan_from_decision(parts, body_json, payload)?
}
OPENAI_VIDEO_CONTENT_PLAN_KIND | GEMINI_FILES_DOWNLOAD_PLAN_KIND => {
build_passthrough_stream_plan_from_decision(parts, payload)?
}
_ => None,
};
Ok(plan_and_report.map(|value| build_stream_plan_response(plan_kind, value, auth_context)))
}
fn build_sync_plan_response(
plan_kind: &str,
value: LocalSyncPlanAndReport,
auth_context: Option<GatewayControlAuthContext>,
) -> GatewayControlPlanResponse {
GatewayControlPlanResponse {
action: EXECUTION_RUNTIME_SYNC_ACTION.to_string(),
plan_kind: Some(plan_kind.to_string()),
plan: Some(value.plan),
report_kind: value.report_kind,
report_context: value.report_context,
auth_context,
}
}
fn build_stream_plan_response(
plan_kind: &str,
value: LocalStreamPlanAndReport,
auth_context: Option<GatewayControlAuthContext>,
) -> GatewayControlPlanResponse {
GatewayControlPlanResponse {
action: EXECUTION_RUNTIME_STREAM_ACTION.to_string(),
plan_kind: Some(plan_kind.to_string()),
plan: Some(value.plan),
report_kind: value.report_kind,
report_context: value.report_context,
auth_context,
}
}

View File

@@ -0,0 +1,22 @@
mod control_plan;
mod stream;
mod sync;
pub(crate) use self::control_plan::{
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::sync::maybe_build_sync_decision_payload_impl as maybe_build_sync_decision_payload;
pub(crate) use super::{
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_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_standard_decision_payload,
maybe_build_sync_local_video_decision_payload, resolve_stream_plan_kind,
resolve_sync_plan_kind,
};

View File

@@ -0,0 +1,156 @@
use std::collections::BTreeMap;
use crate::gateway::ai_pipeline::planner::common::{
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, OPENAI_VIDEO_CONTENT_PLAN_KIND,
};
use crate::gateway::scheduler::{
is_matching_stream_request, resolve_execution_runtime_stream_plan_kind,
};
use crate::gateway::{
AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError,
};
pub(crate) async fn maybe_build_stream_decision_payload_impl(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
let Some(plan_kind) = resolve_execution_runtime_stream_plan_kind(parts, decision) else {
return Ok(None);
};
if !is_matching_stream_request(plan_kind, parts, body_json) {
return Ok(None);
}
if let Some(payload) = maybe_build_local_video_task_content_stream_decision_payload(
state, parts, trace_id, decision, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_stream_local_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_stream_local_openai_cli_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_stream_local_standard_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_stream_local_same_format_provider_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_stream_local_gemini_files_decision_payload(
state, parts, trace_id, decision, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
Ok(None)
}
async fn maybe_build_local_video_task_content_stream_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
if plan_kind != OPENAI_VIDEO_CONTENT_PLAN_KIND
|| decision.route_family.as_deref() != Some("openai")
{
return Ok(None);
}
let _ = state
.hydrate_video_task_for_route(decision.route_family.as_deref(), parts.uri.path())
.await?;
let Some(action) = state.video_tasks.prepare_openai_content_stream_action(
parts.uri.path(),
parts.uri.query(),
trace_id,
) else {
return Ok(None);
};
let crate::gateway::video_tasks::LocalVideoTaskContentAction::StreamPlan(plan) = action else {
return Ok(None);
};
let provider_contract = plan.provider_api_format.clone();
let client_contract = plan.client_api_format.clone();
let execution_strategy = if plan.provider_api_format == plan.client_api_format {
crate::gateway::ExecutionStrategy::LocalSameFormat
} else {
crate::gateway::ExecutionStrategy::LocalCrossFormat
};
let conversion_mode = if plan.provider_api_format == plan.client_api_format {
crate::gateway::ConversionMode::None
} else {
crate::gateway::ConversionMode::Bidirectional
};
Ok(Some(GatewayControlSyncDecisionResponse {
action: EXECUTION_RUNTIME_STREAM_DECISION_ACTION.to_string(),
decision_kind: Some(plan_kind.to_string()),
execution_strategy: Some(execution_strategy.as_str().to_string()),
conversion_mode: Some(conversion_mode.as_str().to_string()),
request_id: Some(plan.request_id),
candidate_id: plan.candidate_id,
provider_name: plan.provider_name,
provider_id: Some(plan.provider_id),
endpoint_id: Some(plan.endpoint_id),
key_id: Some(plan.key_id),
upstream_base_url: None,
upstream_url: Some(plan.url),
provider_request_method: Some(plan.method),
auth_header: None,
auth_value: None,
provider_api_format: Some(plan.provider_api_format),
client_api_format: Some(plan.client_api_format),
provider_contract: Some(provider_contract),
client_contract: Some(client_contract),
model_name: plan.model_name,
mapped_model: None,
prompt_cache_key: None,
extra_headers: BTreeMap::new(),
provider_request_headers: plan.headers,
provider_request_body: None,
provider_request_body_base64: None,
content_type: plan.content_type,
proxy: plan.proxy,
tls_profile: plan.tls_profile,
timeouts: plan.timeouts,
upstream_is_stream: true,
report_kind: None,
report_context: None,
auth_context: decision.auth_context.clone(),
}))
}

View File

@@ -0,0 +1,222 @@
use std::collections::BTreeMap;
use url::Url;
use crate::gateway::ai_pipeline::planner::common::{
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,
OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND,
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
};
use crate::gateway::scheduler::resolve_execution_runtime_sync_plan_kind;
use crate::gateway::{
resolve_execution_runtime_auth_context, AppState, GatewayControlDecision,
GatewayControlSyncDecisionResponse, GatewayError,
};
pub(crate) async fn maybe_build_sync_decision_payload_impl(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
body_base64: Option<&str>,
body_is_empty: bool,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
let Some(plan_kind) = resolve_execution_runtime_sync_plan_kind(parts, decision) else {
return Ok(None);
};
if let Some(payload) = maybe_build_local_video_task_follow_up_sync_decision_payload(
state, parts, body_json, trace_id, decision, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_sync_local_video_decision_payload(
state, parts, body_json, trace_id, decision, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_sync_local_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_sync_local_openai_cli_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_sync_local_standard_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_sync_local_same_format_provider_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if matches!(
plan_kind,
GEMINI_FILES_LIST_PLAN_KIND | GEMINI_FILES_GET_PLAN_KIND | GEMINI_FILES_DELETE_PLAN_KIND
) {
if let Some(payload) = super::maybe_build_sync_local_gemini_files_decision_payload(
state,
parts,
body_json,
body_base64,
body_is_empty,
trace_id,
decision,
plan_kind,
)
.await?
{
return Ok(Some(payload));
}
}
Ok(None)
}
async fn maybe_build_local_video_task_follow_up_sync_decision_payload(
state: &AppState,
parts: &http::request::Parts,
body_json: &serde_json::Value,
trace_id: &str,
decision: &GatewayControlDecision,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
if !matches!(
plan_kind,
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND
| OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND
| OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND
| GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND
) {
return Ok(None);
}
let _ = state
.hydrate_video_task_for_route(decision.route_family.as_deref(), parts.uri.path())
.await?;
let auth_context = resolve_execution_runtime_auth_context(
state,
decision,
&parts.headers,
&parts.uri,
trace_id,
)
.await?;
let Some(auth_context) = auth_context else {
return Ok(None);
};
let Some(follow_up) = state.video_tasks.prepare_follow_up_sync_plan(
plan_kind,
parts.uri.path(),
Some(body_json),
Some(&auth_context),
trace_id,
) else {
return Ok(None);
};
let auth_pair = extract_auth_header_pair(&follow_up.plan.headers);
let execution_strategy =
if follow_up.plan.provider_api_format == follow_up.plan.client_api_format {
crate::gateway::ExecutionStrategy::LocalSameFormat
} else {
crate::gateway::ExecutionStrategy::LocalCrossFormat
};
let conversion_mode = if follow_up.plan.provider_api_format == follow_up.plan.client_api_format
{
crate::gateway::ConversionMode::None
} else {
crate::gateway::ConversionMode::Bidirectional
};
Ok(Some(GatewayControlSyncDecisionResponse {
action: EXECUTION_RUNTIME_SYNC_DECISION_ACTION.to_string(),
decision_kind: Some(plan_kind.to_string()),
execution_strategy: Some(execution_strategy.as_str().to_string()),
conversion_mode: Some(conversion_mode.as_str().to_string()),
request_id: Some(trace_id.to_string()),
candidate_id: follow_up.plan.candidate_id.clone(),
provider_name: follow_up.plan.provider_name.clone(),
provider_id: Some(follow_up.plan.provider_id.clone()),
endpoint_id: Some(follow_up.plan.endpoint_id.clone()),
key_id: Some(follow_up.plan.key_id.clone()),
upstream_base_url: infer_upstream_base_url(&follow_up.plan.url),
upstream_url: Some(follow_up.plan.url.clone()),
provider_request_method: Some(follow_up.plan.method.clone()),
auth_header: auth_pair.as_ref().map(|(name, _)| name.clone()),
auth_value: auth_pair.as_ref().map(|(_, value)| value.clone()),
provider_api_format: Some(follow_up.plan.provider_api_format.clone()),
client_api_format: Some(follow_up.plan.client_api_format.clone()),
provider_contract: Some(follow_up.plan.provider_api_format.clone()),
client_contract: Some(follow_up.plan.client_api_format.clone()),
model_name: follow_up.plan.model_name.clone(),
mapped_model: None,
prompt_cache_key: None,
extra_headers: BTreeMap::new(),
provider_request_headers: follow_up.plan.headers.clone(),
provider_request_body: follow_up.plan.body.json_body.clone(),
provider_request_body_base64: follow_up.plan.body.body_bytes_b64.clone(),
content_type: follow_up.plan.content_type.clone(),
proxy: follow_up.plan.proxy.clone(),
tls_profile: follow_up.plan.tls_profile.clone(),
timeouts: follow_up.plan.timeouts.clone(),
upstream_is_stream: false,
report_kind: follow_up.report_kind,
report_context: follow_up.report_context,
auth_context: Some(auth_context),
}))
}
fn extract_auth_header_pair(headers: &BTreeMap<String, String>) -> Option<(String, String)> {
[
"authorization",
"x-api-key",
"api-key",
"x-goog-api-key",
"proxy-authorization",
]
.into_iter()
.find_map(|name| {
headers
.iter()
.find(|(header_name, _)| header_name.eq_ignore_ascii_case(name))
.map(|(header_name, value)| (header_name.clone(), value.clone()))
})
}
fn infer_upstream_base_url(upstream_url: &str) -> Option<String> {
let parsed = Url::parse(upstream_url).ok()?;
let host = parsed.host_str()?;
let mut base = format!("{}://{}", parsed.scheme(), host);
if let Some(port) = parsed.port() {
base.push(':');
base.push_str(port.to_string().as_str());
}
Some(base)
}

View File

@@ -0,0 +1,132 @@
use axum::body::Body;
use axum::http::Response;
use crate::gateway::ai_pipeline::planner::plan_builders::{
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::request_candidates::record_local_request_candidate_status;
use crate::gateway::{
execute_execution_runtime_stream, execute_execution_runtime_sync, AppState,
GatewayControlDecision, GatewayError,
};
pub(crate) trait LocalPlanAndReport {
fn plan(&self) -> &aether_contracts::ExecutionPlan;
fn report_kind(&self) -> Option<String>;
fn report_context(&self) -> Option<serde_json::Value>;
}
impl LocalPlanAndReport for LocalSyncPlanAndReport {
fn plan(&self) -> &aether_contracts::ExecutionPlan {
&self.plan
}
fn report_kind(&self) -> Option<String> {
self.report_kind.clone()
}
fn report_context(&self) -> Option<serde_json::Value> {
self.report_context.clone()
}
}
impl LocalPlanAndReport for LocalStreamPlanAndReport {
fn plan(&self) -> &aether_contracts::ExecutionPlan {
&self.plan
}
fn report_kind(&self) -> Option<String> {
self.report_kind.clone()
}
fn report_context(&self) -> Option<serde_json::Value> {
self.report_context.clone()
}
}
pub(crate) async fn execute_sync_plan_and_reports<T>(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
plan_kind: &str,
plan_and_reports: Vec<T>,
) -> Result<Option<Response<Body>>, GatewayError>
where
T: LocalPlanAndReport,
{
let mut remaining = plan_and_reports.into_iter();
while let Some(plan_and_report) = remaining.next() {
if let Some(response) = execute_execution_runtime_sync(
state,
parts.uri.path(),
plan_and_report.plan().clone(),
trace_id,
decision,
plan_kind,
plan_and_report.report_kind(),
plan_and_report.report_context(),
)
.await?
{
mark_unused_local_candidates(state, remaining.collect()).await;
return Ok(Some(response));
}
}
Ok(None)
}
pub(crate) async fn execute_stream_plan_and_reports<T>(
state: &AppState,
trace_id: &str,
decision: &GatewayControlDecision,
plan_kind: &str,
plan_and_reports: Vec<T>,
) -> Result<Option<Response<Body>>, GatewayError>
where
T: LocalPlanAndReport,
{
let mut remaining = plan_and_reports.into_iter();
while let Some(plan_and_report) = remaining.next() {
if let Some(response) = execute_execution_runtime_stream(
state,
plan_and_report.plan().clone(),
trace_id,
decision,
plan_kind,
plan_and_report.report_kind(),
plan_and_report.report_context(),
)
.await?
{
mark_unused_local_candidates(state, remaining.collect()).await;
return Ok(Some(response));
}
}
Ok(None)
}
pub(crate) async fn mark_unused_local_candidates<T>(state: &AppState, remaining: Vec<T>)
where
T: LocalPlanAndReport,
{
for plan_and_report in remaining {
record_local_request_candidate_status(
state,
plan_and_report.plan(),
plan_and_report.report_context().as_ref(),
aether_data::repository::candidates::RequestCandidateStatus::Unused,
None,
None,
None,
None,
None,
None,
)
.await;
}
}

View File

@@ -0,0 +1,25 @@
use axum::body::{Body, Bytes};
use axum::http::Response;
use crate::gateway::intent;
use crate::gateway::{AppState, GatewayControlDecision, GatewayError};
pub(crate) async fn maybe_execute_sync_local_path(
state: &AppState,
parts: &http::request::Parts,
body_bytes: &Bytes,
trace_id: &str,
decision: &GatewayControlDecision,
) -> Result<Option<Response<Body>>, GatewayError> {
intent::maybe_execute_via_sync_intent_path(state, parts, body_bytes, trace_id, decision).await
}
pub(crate) async fn maybe_execute_stream_local_path(
state: &AppState,
parts: &http::request::Parts,
body_bytes: &Bytes,
trace_id: &str,
decision: &GatewayControlDecision,
) -> Result<Option<Response<Body>>, GatewayError> {
intent::maybe_execute_via_stream_intent_path(state, parts, body_bytes, trace_id, decision).await
}

View File

@@ -0,0 +1,134 @@
use crate::gateway::{AppState, GatewayControlDecision, GatewayError};
pub(crate) mod candidate_affinity;
pub(crate) mod common;
pub(crate) mod contracts;
mod decision;
pub(crate) mod family_core;
pub(crate) mod local_path;
pub(crate) mod passthrough;
pub(crate) mod plan_builders;
pub(crate) mod specialized;
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 self::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,
maybe_execute_stream_via_local_same_format_provider_decision,
maybe_execute_sync_via_local_same_format_provider_decision,
};
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,
maybe_execute_stream_via_local_gemini_files_decision,
maybe_execute_sync_via_local_gemini_files_decision,
maybe_execute_sync_via_local_video_decision,
};
pub(crate) use local_path::{maybe_execute_stream_local_path, maybe_execute_sync_local_path};
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, maybe_execute_stream_via_local_decision,
maybe_execute_stream_via_local_openai_cli_decision,
maybe_execute_stream_via_local_standard_decision, maybe_execute_sync_via_local_decision,
maybe_execute_sync_via_local_openai_cli_decision,
maybe_execute_sync_via_local_standard_decision, parse_openai_stop_sequences,
resolve_openai_chat_max_tokens, value_as_u64,
};
pub(crate) async fn maybe_build_sync_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
body_base64: Option<&str>,
body_is_empty: bool,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
decision::maybe_build_sync_decision_payload(
state,
parts,
trace_id,
decision,
body_json,
body_base64,
body_is_empty,
)
.await
}
pub(crate) async fn maybe_build_stream_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
decision::maybe_build_stream_decision_payload(state, parts, trace_id, decision, body_json).await
}
pub(crate) async fn maybe_build_sync_plan_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
body_base64: Option<&str>,
body_is_empty: bool,
) -> Result<Option<GatewayControlPlanResponse>, GatewayError> {
decision::maybe_build_sync_plan_payload_impl(
state,
parts,
trace_id,
decision,
body_json,
body_base64,
body_is_empty,
)
.await
}
pub(crate) async fn maybe_build_stream_plan_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
) -> Result<Option<GatewayControlPlanResponse>, GatewayError> {
decision::maybe_build_stream_plan_payload_impl(state, parts, trace_id, decision, body_json)
.await
}

View File

@@ -0,0 +1,11 @@
//! Requests that can stay in the same public/provider contract family.
mod provider;
pub(crate) use self::provider::{
maybe_build_stream_local_same_format_provider_decision_payload,
maybe_build_sync_local_same_format_provider_decision_payload,
maybe_execute_stream_via_local_same_format_provider_decision,
maybe_execute_sync_via_local_same_format_provider_decision,
};
pub(crate) use crate::gateway::provider_transport::provider_type_supports_local_same_format_transport;

View File

@@ -0,0 +1,233 @@
use aether_contracts::{ExecutionPlan, RequestBody};
use super::{augment_sync_report_context, LocalStreamPlanAndReport, LocalSyncPlanAndReport};
use crate::gateway::{GatewayControlSyncDecisionResponse, GatewayError};
pub(crate) fn build_passthrough_sync_plan_from_decision(
parts: &http::request::Parts,
payload: GatewayControlSyncDecisionResponse,
) -> Result<Option<LocalSyncPlanAndReport>, GatewayError> {
let Some(request_id) = payload
.request_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_id) = payload
.provider_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(endpoint_id) = payload
.endpoint_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(key_id) = payload
.key_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_api_format) = payload
.provider_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(client_api_format) = payload
.client_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(upstream_url) = payload
.upstream_url
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let (request_body, provider_request_body_for_report) = resolve_passthrough_sync_request_body(
payload.provider_request_body.clone(),
payload.provider_request_body_base64.clone(),
);
let plan = ExecutionPlan {
request_id,
candidate_id: payload.candidate_id.clone(),
provider_name: payload.provider_name.clone(),
provider_id,
endpoint_id,
key_id,
method: payload
.provider_request_method
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| parts.method.to_string()),
url: upstream_url,
headers: payload.provider_request_headers.clone(),
content_type: payload.content_type.clone().or_else(|| {
payload
.provider_request_headers
.get("content-type")
.cloned()
}),
content_encoding: None,
body: request_body,
stream: false,
client_api_format,
provider_api_format,
model_name: payload.model_name.clone(),
proxy: payload.proxy.clone(),
tls_profile: payload.tls_profile.clone(),
timeouts: payload.timeouts.clone(),
};
let report_context = augment_sync_report_context(
payload.report_context,
&plan.headers,
&provider_request_body_for_report,
)?;
Ok(Some(LocalSyncPlanAndReport {
plan,
report_kind: payload.report_kind,
report_context,
}))
}
pub(crate) fn build_passthrough_stream_plan_from_decision(
parts: &http::request::Parts,
payload: GatewayControlSyncDecisionResponse,
) -> Result<Option<LocalStreamPlanAndReport>, GatewayError> {
let Some(request_id) = payload
.request_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_id) = payload
.provider_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(endpoint_id) = payload
.endpoint_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(key_id) = payload
.key_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_api_format) = payload
.provider_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(client_api_format) = payload
.client_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(upstream_url) = payload
.upstream_url
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let plan = ExecutionPlan {
request_id,
candidate_id: payload.candidate_id.clone(),
provider_name: payload.provider_name.clone(),
provider_id,
endpoint_id,
key_id,
method: parts.method.to_string(),
url: upstream_url,
headers: payload.provider_request_headers.clone(),
content_type: payload.content_type.clone().or_else(|| {
payload
.provider_request_headers
.get("content-type")
.cloned()
}),
content_encoding: None,
body: RequestBody {
json_body: None,
body_bytes_b64: None,
body_ref: None,
},
stream: true,
client_api_format,
provider_api_format,
model_name: payload.model_name.clone(),
proxy: payload.proxy.clone(),
tls_profile: payload.tls_profile.clone(),
timeouts: payload.timeouts.clone(),
};
Ok(Some(LocalStreamPlanAndReport {
plan,
report_kind: payload.report_kind,
report_context: payload.report_context,
}))
}
fn resolve_passthrough_sync_request_body(
provider_request_body: Option<serde_json::Value>,
provider_request_body_base64: Option<String>,
) -> (RequestBody, serde_json::Value) {
if let Some(body_bytes_b64) = provider_request_body_base64
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
{
return (
RequestBody {
json_body: None,
body_bytes_b64: Some(body_bytes_b64.clone()),
body_ref: None,
},
serde_json::json!({"body_bytes_b64": body_bytes_b64}),
);
}
match provider_request_body.unwrap_or(serde_json::Value::Null) {
serde_json::Value::Null => (
RequestBody {
json_body: None,
body_bytes_b64: None,
body_ref: None,
},
serde_json::Value::Null,
),
other => {
let report_body = other.clone();
(RequestBody::from_json(other), report_body)
}
}
}

View File

@@ -0,0 +1,76 @@
use axum::body::Body;
use axum::http::Response;
use std::collections::BTreeMap;
use url::form_urlencoded;
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
use serde_json::{json, Value};
use tracing::warn;
use uuid::Uuid;
use crate::gateway::headers::collect_control_headers;
use crate::gateway::provider_transport::{
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::request_candidates::{
current_unix_secs, record_local_request_candidate_status,
};
use crate::gateway::ai_pipeline::planner::plan_builders::{
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates;
use crate::gateway::scheduler::{
list_selectable_candidates, GatewayMinimalCandidateSelectionCandidate,
};
use crate::gateway::{
append_execution_contract_fields_to_value, execute_execution_runtime_stream,
execute_execution_runtime_sync, AppState, ConversionMode, ExecutionStrategy,
GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError,
};
use crate::gateway::ai_pipeline::planner::{
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
};
mod family;
mod plans;
mod request;
pub(super) use self::family::{
materialize_local_same_format_provider_candidate_attempts,
maybe_build_local_same_format_provider_decision_payload_for_candidate,
resolve_local_same_format_provider_decision_input, LocalSameFormatProviderFamily,
LocalSameFormatProviderSpec,
};
pub(crate) use self::family::{
maybe_build_stream_local_same_format_provider_decision_payload,
maybe_build_sync_local_same_format_provider_decision_payload,
maybe_execute_stream_via_local_same_format_provider_decision,
maybe_execute_sync_via_local_same_format_provider_decision,
};
use self::plans::{
build_local_stream_plan_and_reports, build_local_sync_plan_and_reports, resolve_stream_spec,
resolve_sync_spec,
};
use self::request::{
build_same_format_provider_request_body, build_same_format_upstream_url,
extract_gemini_model_from_path,
};
const ANTIGRAVITY_ENVELOPE_NAME: &str = "antigravity:v1internal";

View File

@@ -0,0 +1,163 @@
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
use serde_json::json;
use tracing::warn;
use uuid::Uuid;
use crate::gateway::request_candidates::current_unix_secs;
use crate::gateway::scheduler::list_selectable_candidates;
use crate::gateway::{
append_execution_contract_fields_to_value, AppState, ConversionMode, ExecutionStrategy,
GatewayControlDecision, GatewayError,
};
use super::types::{
LocalSameFormatProviderCandidateAttempt, LocalSameFormatProviderDecisionInput,
LocalSameFormatProviderFamily, LocalSameFormatProviderSpec,
};
pub(crate) async fn resolve_local_same_format_provider_decision_input(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
spec: LocalSameFormatProviderSpec,
) -> Option<LocalSameFormatProviderDecisionInput> {
let Some(auth_context) = decision.auth_context.clone().filter(|auth_context| {
!auth_context.user_id.trim().is_empty() && !auth_context.api_key_id.trim().is_empty()
}) else {
return None;
};
let requested_model = match spec.family {
LocalSameFormatProviderFamily::Standard => body_json
.get("model")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)?,
LocalSameFormatProviderFamily::Gemini => {
super::super::request::extract_gemini_model_from_path(parts.uri.path())?
}
};
let auth_snapshot = match state
.read_auth_api_key_snapshot(
&auth_context.user_id,
&auth_context.api_key_id,
current_unix_secs(),
)
.await
{
Ok(Some(snapshot)) => snapshot,
Ok(None) => return None,
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local same-format decision auth snapshot read failed"
);
return None;
}
};
Some(LocalSameFormatProviderDecisionInput {
auth_context,
requested_model,
auth_snapshot,
})
}
pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
state: &AppState,
trace_id: &str,
input: &LocalSameFormatProviderDecisionInput,
spec: LocalSameFormatProviderSpec,
) -> Result<Vec<LocalSameFormatProviderCandidateAttempt>, GatewayError> {
let candidates = list_selectable_candidates(
state,
spec.api_format,
&input.requested_model,
spec.require_streaming,
Some(&input.auth_snapshot),
current_unix_secs(),
)
.await?;
let candidates =
crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates(state, candidates)
.await;
let created_at_unix_secs = current_unix_secs();
let mut attempts = Vec::with_capacity(candidates.len());
for (candidate_index, candidate) in candidates.into_iter().enumerate() {
let generated_candidate_id = Uuid::new_v4().to_string();
let extra_data = append_execution_contract_fields_to_value(
json!({
"provider_api_format": spec.api_format,
"client_api_format": spec.api_format,
"global_model_id": candidate.global_model_id.clone(),
"global_model_name": candidate.global_model_name.clone(),
"model_id": candidate.model_id.clone(),
"selected_provider_model_name": candidate.selected_provider_model_name.clone(),
"mapping_matched_model": candidate.mapping_matched_model.clone(),
"provider_name": candidate.provider_name.clone(),
"key_name": candidate.key_name.clone(),
}),
ExecutionStrategy::LocalSameFormat,
ConversionMode::None,
spec.api_format,
spec.api_format,
);
let candidate_id = match state
.upsert_request_candidate(UpsertRequestCandidateRecord {
id: generated_candidate_id.clone(),
request_id: trace_id.to_string(),
user_id: Some(input.auth_context.user_id.clone()),
api_key_id: Some(input.auth_context.api_key_id.clone()),
username: None,
api_key_name: None,
candidate_index: candidate_index as u32,
retry_index: 0,
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
status: RequestCandidateStatus::Available,
skip_reason: None,
is_cached: Some(false),
status_code: None,
error_type: None,
error_message: None,
latency_ms: None,
concurrent_requests: None,
extra_data: Some(extra_data),
required_capabilities: candidate.key_capabilities.clone(),
created_at_unix_secs: Some(created_at_unix_secs),
started_at_unix_secs: None,
finished_at_unix_secs: None,
})
.await
{
Ok(Some(stored)) => stored.id,
Ok(None) => generated_candidate_id.clone(),
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local same-format decision request candidate upsert failed"
);
generated_candidate_id.clone()
}
};
attempts.push(LocalSameFormatProviderCandidateAttempt {
candidate,
candidate_index: candidate_index as u32,
candidate_id,
});
}
Ok(attempts)
}

View File

@@ -0,0 +1,141 @@
use axum::body::Body;
use axum::http::Response;
use crate::gateway::ai_pipeline::planner::family_core::{
execute_stream_plan_and_reports, execute_sync_plan_and_reports,
};
use crate::gateway::ai_pipeline::planner::plan_builders::{
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::{AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError};
use super::super::plans::{
build_local_stream_plan_and_reports, build_local_sync_plan_and_reports, resolve_stream_spec,
resolve_sync_spec,
};
use super::candidates::{
materialize_local_same_format_provider_candidate_attempts,
resolve_local_same_format_provider_decision_input,
};
use super::payload::maybe_build_local_same_format_provider_decision_payload_for_candidate;
pub(crate) async fn maybe_execute_sync_via_local_same_format_provider_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(spec) = resolve_sync_spec(plan_kind) else {
return Ok(None);
};
let plan_and_reports =
build_local_sync_plan_and_reports(state, parts, trace_id, decision, body_json, spec)
.await?;
if plan_and_reports.is_empty() {
return Ok(None);
}
execute_sync_plan_and_reports(state, parts, trace_id, decision, plan_kind, plan_and_reports)
.await
}
pub(crate) async fn maybe_execute_stream_via_local_same_format_provider_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(spec) = resolve_stream_spec(plan_kind) else {
return Ok(None);
};
let plan_and_reports =
build_local_stream_plan_and_reports(state, parts, trace_id, decision, body_json, spec)
.await?;
if plan_and_reports.is_empty() {
return Ok(None);
}
execute_stream_plan_and_reports(state, trace_id, decision, plan_kind, plan_and_reports).await
}
pub(crate) async fn maybe_build_sync_local_same_format_provider_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
let Some(spec) = resolve_sync_spec(plan_kind) else {
return Ok(None);
};
let Some(input) = resolve_local_same_format_provider_decision_input(
state, parts, trace_id, decision, body_json, spec,
)
.await
else {
return Ok(None);
};
let attempts =
materialize_local_same_format_provider_candidate_attempts(state, trace_id, &input, spec)
.await?;
for attempt in attempts {
if let Some(payload) =
maybe_build_local_same_format_provider_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
{
return Ok(Some(payload));
}
}
Ok(None)
}
pub(crate) async fn maybe_build_stream_local_same_format_provider_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
let Some(spec) = resolve_stream_spec(plan_kind) else {
return Ok(None);
};
let Some(input) = resolve_local_same_format_provider_decision_input(
state, parts, trace_id, decision, body_json, spec,
)
.await
else {
return Ok(None);
};
let attempts =
materialize_local_same_format_provider_candidate_attempts(state, trace_id, &input, spec)
.await?;
for attempt in attempts {
if let Some(payload) =
maybe_build_local_same_format_provider_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
{
return Ok(Some(payload));
}
}
Ok(None)
}

View File

@@ -0,0 +1,17 @@
mod candidates;
mod execute;
mod payload;
mod types;
pub(crate) use self::candidates::{
materialize_local_same_format_provider_candidate_attempts,
resolve_local_same_format_provider_decision_input,
};
pub(crate) use self::execute::{
maybe_build_stream_local_same_format_provider_decision_payload,
maybe_build_sync_local_same_format_provider_decision_payload,
maybe_execute_stream_via_local_same_format_provider_decision,
maybe_execute_sync_via_local_same_format_provider_decision,
};
pub(crate) use self::payload::maybe_build_local_same_format_provider_decision_payload_for_candidate;
pub(crate) use self::types::{LocalSameFormatProviderFamily, LocalSameFormatProviderSpec};

View File

@@ -0,0 +1,562 @@
use std::collections::BTreeMap;
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
use serde_json::json;
use tracing::warn;
use crate::gateway::headers::collect_control_headers;
use crate::gateway::provider_transport::{
apply_local_header_rules, build_antigravity_safe_v1internal_request,
build_antigravity_static_identity_headers, build_claude_code_passthrough_headers,
build_openai_passthrough_headers, build_passthrough_headers,
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,
LocalResolvedOAuthRequestAuth, KIRO_ENVELOPE_NAME,
};
use crate::gateway::request_candidates::current_unix_secs;
use crate::gateway::scheduler::GatewayMinimalCandidateSelectionCandidate;
use crate::gateway::{
append_execution_contract_fields_to_value, AppState, ConversionMode, ExecutionStrategy,
GatewayControlSyncDecisionResponse, EXECUTION_RUNTIME_STREAM_DECISION_ACTION,
EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
};
use super::types::{
LocalSameFormatProviderCandidateAttempt, LocalSameFormatProviderDecisionInput,
LocalSameFormatProviderFamily, LocalSameFormatProviderSpec,
};
pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_candidate(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
body_json: &serde_json::Value,
input: &LocalSameFormatProviderDecisionInput,
attempt: LocalSameFormatProviderCandidateAttempt,
spec: LocalSameFormatProviderSpec,
) -> Option<GatewayControlSyncDecisionResponse> {
let LocalSameFormatProviderCandidateAttempt {
candidate,
candidate_index,
candidate_id,
} = attempt;
let transport = match state
.read_provider_transport_snapshot(
&candidate.provider_id,
&candidate.endpoint_id,
&candidate.key_id,
)
.await
{
Ok(Some(snapshot)) => snapshot,
Ok(None) => {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_snapshot_missing",
)
.await;
return None;
}
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local same-format decision provider transport read failed"
);
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_snapshot_read_failed",
)
.await;
return None;
}
};
let is_antigravity = transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("antigravity");
let is_claude_code = transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("claude_code");
let is_vertex = transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("vertex_ai");
let transport_supported = match spec.family {
_ if transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("kiro") =>
{
supports_local_kiro_request_transport_with_network(&transport)
}
_ if is_antigravity => true,
_ if is_claude_code => {
supports_local_claude_code_transport_with_network(&transport, spec.api_format)
}
_ if is_vertex => supports_local_vertex_api_key_gemini_transport_with_network(&transport),
LocalSameFormatProviderFamily::Standard => {
supports_local_standard_transport_with_network(&transport, spec.api_format)
}
LocalSameFormatProviderFamily::Gemini => {
supports_local_gemini_transport_with_network(&transport, spec.api_format)
}
};
if !transport_supported {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_unsupported",
)
.await;
return None;
}
let is_kiro = transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("kiro");
let vertex_query_auth = if is_vertex {
resolve_local_vertex_api_key_query_auth(&transport)
} else {
None
};
let should_try_oauth_auth = is_kiro
|| matches!(spec.family, LocalSameFormatProviderFamily::Standard)
&& resolve_local_standard_auth(&transport).is_none()
|| matches!(spec.family, LocalSameFormatProviderFamily::Gemini)
&& !is_vertex
&& resolve_local_gemini_auth(&transport).is_none();
let oauth_auth = if should_try_oauth_auth {
match state.resolve_local_oauth_request_auth(&transport).await {
Ok(Some(LocalResolvedOAuthRequestAuth::Kiro(auth))) => {
Some(LocalResolvedOAuthRequestAuth::Kiro(auth))
}
Ok(Some(LocalResolvedOAuthRequestAuth::Header { name, value })) => {
Some(LocalResolvedOAuthRequestAuth::Header { name, value })
}
Ok(None) => None,
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
provider_type = %transport.provider.provider_type,
error = ?err,
"gateway local same-format oauth auth resolution failed"
);
None
}
}
} else {
None
};
let kiro_auth = match oauth_auth.as_ref() {
Some(LocalResolvedOAuthRequestAuth::Kiro(auth)) => Some(auth),
_ => None,
};
let auth = if let Some(auth) = kiro_auth.as_ref() {
Some((auth.name.to_string(), auth.value.clone()))
} else if let Some(LocalResolvedOAuthRequestAuth::Header { name, value }) = oauth_auth.as_ref()
{
Some((name.clone(), value.clone()))
} else if is_vertex {
None
} else {
match spec.family {
LocalSameFormatProviderFamily::Standard => resolve_local_standard_auth(&transport),
LocalSameFormatProviderFamily::Gemini => resolve_local_gemini_auth(&transport),
}
};
let (auth_header, auth_value) = match auth {
Some((name, value)) => (Some(name), Some(value)),
None if is_vertex && vertex_query_auth.is_some() => (None, None),
None => {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_auth_unavailable",
)
.await;
return None;
}
};
if is_vertex && vertex_query_auth.is_none() {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_auth_unavailable",
)
.await;
return None;
}
let mapped_model = candidate.selected_provider_model_name.trim().to_string();
if mapped_model.is_empty() {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"mapped_model_missing",
)
.await;
return None;
}
let Some(base_provider_request_body) =
super::super::request::build_same_format_provider_request_body(
body_json,
&mapped_model,
spec,
transport.endpoint.body_rules.as_ref(),
is_kiro || is_antigravity || spec.require_streaming,
kiro_auth,
is_claude_code,
)
else {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"provider_request_body_missing",
)
.await;
return None;
};
let antigravity_auth = if is_antigravity {
match classify_local_antigravity_request_support(
&transport,
&base_provider_request_body,
AntigravityEnvelopeRequestType::Agent,
) {
AntigravityRequestSideSupport::Supported(spec) => Some(spec.auth),
AntigravityRequestSideSupport::Unsupported(_) => {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_unsupported",
)
.await;
return None;
}
}
} else {
None
};
let provider_request_body = if let Some(antigravity_auth) = antigravity_auth.as_ref() {
match build_antigravity_safe_v1internal_request(
antigravity_auth,
trace_id,
&mapped_model,
&base_provider_request_body,
AntigravityEnvelopeRequestType::Agent,
) {
AntigravityRequestEnvelopeSupport::Supported(envelope) => envelope,
AntigravityRequestEnvelopeSupport::Unsupported(_) => {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"provider_request_body_missing",
)
.await;
return None;
}
}
} else {
base_provider_request_body
};
let upstream_is_stream = is_kiro || is_antigravity || spec.require_streaming;
let report_kind = if is_kiro && !spec.require_streaming {
"claude_cli_sync_finalize"
} else if is_antigravity && !spec.require_streaming {
match spec.api_format {
"gemini:chat" => "gemini_chat_sync_finalize",
"gemini:cli" => "gemini_cli_sync_finalize",
_ => spec.report_kind,
}
} else {
spec.report_kind
};
let Some(upstream_url) = super::super::request::build_same_format_upstream_url(
parts,
&transport,
&mapped_model,
spec,
upstream_is_stream,
kiro_auth,
) else {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"upstream_url_missing",
)
.await;
return None;
};
let Some(provider_request_headers) = (if let Some(kiro_auth) = kiro_auth.as_ref() {
crate::gateway::provider_transport::build_kiro_provider_headers(
&parts.headers,
&provider_request_body,
body_json,
transport.endpoint.header_rules.as_ref(),
auth_header.as_deref().unwrap_or_default(),
auth_value.as_deref().unwrap_or_default(),
&kiro_auth.auth_config,
kiro_auth.machine_id.as_str(),
)
} else {
let extra_headers = antigravity_auth
.as_ref()
.map(build_antigravity_static_identity_headers)
.unwrap_or_default();
let mut provider_request_headers = if is_claude_code {
build_claude_code_passthrough_headers(
&parts.headers,
auth_header.as_deref().unwrap_or_default(),
auth_value.as_deref().unwrap_or_default(),
&extra_headers,
upstream_is_stream,
transport.key.fingerprint.as_ref(),
)
} else if is_vertex {
build_passthrough_headers(&parts.headers, &extra_headers, Some("application/json"))
} else {
build_openai_passthrough_headers(
&parts.headers,
auth_header.as_deref().unwrap_or_default(),
auth_value.as_deref().unwrap_or_default(),
&extra_headers,
Some("application/json"),
)
};
let protected_headers = auth_header
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(|value| vec![value, "content-type"])
.unwrap_or_else(|| vec!["content-type"]);
if !apply_local_header_rules(
&mut provider_request_headers,
transport.endpoint.header_rules.as_ref(),
&protected_headers,
&provider_request_body,
Some(body_json),
) {
None
} else {
if let (Some(auth_header), Some(auth_value)) =
(auth_header.as_deref(), auth_value.as_deref())
{
ensure_upstream_auth_header(&mut provider_request_headers, auth_header, auth_value);
}
if upstream_is_stream {
provider_request_headers
.insert("accept".to_string(), "text/event-stream".to_string());
}
Some(provider_request_headers)
}
}) else {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_header_rules_apply_failed",
)
.await;
return None;
};
let prompt_cache_key = provider_request_body
.get("prompt_cache_key")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let proxy = resolve_transport_proxy_snapshot_with_tunnel_affinity(state, &transport).await;
let tls_profile = resolve_transport_tls_profile(&transport);
let report_context = append_execution_contract_fields_to_value(
json!({
"user_id": input.auth_context.user_id,
"api_key_id": input.auth_context.api_key_id,
"request_id": trace_id,
"candidate_id": candidate_id,
"candidate_index": candidate_index,
"retry_index": 0,
"model": input.requested_model,
"provider_name": transport.provider.name,
"provider_id": candidate.provider_id,
"endpoint_id": candidate.endpoint_id,
"key_id": candidate.key_id,
"provider_api_format": spec.api_format,
"client_api_format": spec.api_format,
"mapped_model": mapped_model,
"upstream_url": upstream_url,
"provider_request_method": serde_json::Value::Null,
"provider_request_headers": provider_request_headers,
"provider_request_body": provider_request_body,
"original_headers": collect_control_headers(&parts.headers),
"original_request_body": body_json,
"has_envelope": is_kiro || is_antigravity,
"envelope_name": if is_kiro {
Some(KIRO_ENVELOPE_NAME)
} else if is_antigravity {
Some(super::super::ANTIGRAVITY_ENVELOPE_NAME)
} else {
None
},
"needs_conversion": false,
}),
ExecutionStrategy::LocalSameFormat,
ConversionMode::None,
spec.api_format,
spec.api_format,
);
Some(GatewayControlSyncDecisionResponse {
action: if spec.require_streaming {
EXECUTION_RUNTIME_STREAM_DECISION_ACTION.to_string()
} else {
EXECUTION_RUNTIME_SYNC_DECISION_ACTION.to_string()
},
decision_kind: Some(spec.decision_kind.to_string()),
execution_strategy: Some(ExecutionStrategy::LocalSameFormat.as_str().to_string()),
conversion_mode: Some(ConversionMode::None.as_str().to_string()),
request_id: Some(trace_id.to_string()),
candidate_id: Some(candidate_id.clone()),
provider_name: Some(transport.provider.name.clone()),
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
upstream_base_url: Some(transport.endpoint.base_url.clone()),
upstream_url: Some(upstream_url.clone()),
provider_request_method: None,
auth_header,
auth_value,
provider_api_format: Some(spec.api_format.to_string()),
client_api_format: Some(spec.api_format.to_string()),
provider_contract: Some(spec.api_format.to_string()),
client_contract: Some(spec.api_format.to_string()),
model_name: Some(input.requested_model.clone()),
mapped_model: Some(mapped_model.clone()),
prompt_cache_key,
extra_headers: BTreeMap::new(),
provider_request_headers: provider_request_headers.clone(),
provider_request_body: Some(provider_request_body.clone()),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
proxy,
tls_profile,
timeouts: resolve_transport_execution_timeouts(&transport),
upstream_is_stream,
report_kind: Some(report_kind.to_string()),
report_context: Some(report_context),
auth_context: Some(input.auth_context.clone()),
})
}
pub(super) async fn mark_skipped_local_same_format_provider_candidate(
state: &AppState,
input: &LocalSameFormatProviderDecisionInput,
trace_id: &str,
candidate: &GatewayMinimalCandidateSelectionCandidate,
candidate_index: u32,
candidate_id: &str,
skip_reason: &'static str,
) {
if let Err(err) = state
.upsert_request_candidate(UpsertRequestCandidateRecord {
id: candidate_id.to_string(),
request_id: trace_id.to_string(),
user_id: Some(input.auth_context.user_id.clone()),
api_key_id: Some(input.auth_context.api_key_id.clone()),
username: None,
api_key_name: None,
candidate_index,
retry_index: 0,
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
status: RequestCandidateStatus::Skipped,
skip_reason: Some(skip_reason.to_string()),
is_cached: Some(false),
status_code: None,
error_type: None,
error_message: None,
latency_ms: None,
concurrent_requests: None,
extra_data: None,
required_capabilities: candidate.key_capabilities.clone(),
created_at_unix_secs: None,
started_at_unix_secs: None,
finished_at_unix_secs: Some(current_unix_secs()),
})
.await
{
warn!(
trace_id = %trace_id,
candidate_id = %candidate_id,
skip_reason,
error = ?err,
"gateway local same-format decision failed to persist skipped candidate"
);
}
}

View File

@@ -0,0 +1,28 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LocalSameFormatProviderFamily {
Standard,
Gemini,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct LocalSameFormatProviderSpec {
pub(crate) api_format: &'static str,
pub(crate) decision_kind: &'static str,
pub(crate) report_kind: &'static str,
pub(crate) family: LocalSameFormatProviderFamily,
pub(crate) require_streaming: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct LocalSameFormatProviderDecisionInput {
pub(crate) auth_context: crate::gateway::GatewayControlAuthContext,
pub(crate) requested_model: String,
pub(crate) auth_snapshot: crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot,
}
#[derive(Debug, Clone)]
pub(crate) struct LocalSameFormatProviderCandidateAttempt {
pub(crate) candidate: crate::gateway::scheduler::GatewayMinimalCandidateSelectionCandidate,
pub(crate) candidate_index: u32,
pub(crate) candidate_id: String,
}

View File

@@ -0,0 +1,198 @@
use tracing::warn;
use super::{
materialize_local_same_format_provider_candidate_attempts,
maybe_build_local_same_format_provider_decision_payload_for_candidate,
resolve_local_same_format_provider_decision_input, AppState, GatewayControlDecision,
GatewayError, LocalSameFormatProviderFamily, LocalSameFormatProviderSpec,
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::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,
};
use crate::gateway::ai_pipeline::planner::{
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
};
pub(super) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalSameFormatProviderSpec> {
match plan_kind {
CLAUDE_CHAT_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "claude:chat",
decision_kind: CLAUDE_CHAT_SYNC_PLAN_KIND,
report_kind: "claude_chat_sync_success",
family: LocalSameFormatProviderFamily::Standard,
require_streaming: false,
}),
CLAUDE_CLI_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "claude:cli",
decision_kind: CLAUDE_CLI_SYNC_PLAN_KIND,
report_kind: "claude_cli_sync_success",
family: LocalSameFormatProviderFamily::Standard,
require_streaming: false,
}),
GEMINI_CHAT_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "gemini:chat",
decision_kind: GEMINI_CHAT_SYNC_PLAN_KIND,
report_kind: "gemini_chat_sync_success",
family: LocalSameFormatProviderFamily::Gemini,
require_streaming: false,
}),
GEMINI_CLI_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "gemini:cli",
decision_kind: GEMINI_CLI_SYNC_PLAN_KIND,
report_kind: "gemini_cli_sync_success",
family: LocalSameFormatProviderFamily::Gemini,
require_streaming: false,
}),
_ => None,
}
}
pub(super) fn resolve_stream_spec(plan_kind: &str) -> Option<LocalSameFormatProviderSpec> {
match plan_kind {
CLAUDE_CHAT_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "claude:chat",
decision_kind: CLAUDE_CHAT_STREAM_PLAN_KIND,
report_kind: "claude_chat_stream_success",
family: LocalSameFormatProviderFamily::Standard,
require_streaming: true,
}),
CLAUDE_CLI_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "claude:cli",
decision_kind: CLAUDE_CLI_STREAM_PLAN_KIND,
report_kind: "claude_cli_stream_success",
family: LocalSameFormatProviderFamily::Standard,
require_streaming: true,
}),
GEMINI_CHAT_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "gemini:chat",
decision_kind: GEMINI_CHAT_STREAM_PLAN_KIND,
report_kind: "gemini_chat_stream_success",
family: LocalSameFormatProviderFamily::Gemini,
require_streaming: true,
}),
GEMINI_CLI_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "gemini:cli",
decision_kind: GEMINI_CLI_STREAM_PLAN_KIND,
report_kind: "gemini_cli_stream_success",
family: LocalSameFormatProviderFamily::Gemini,
require_streaming: true,
}),
_ => None,
}
}
pub(super) async fn build_local_sync_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
spec: LocalSameFormatProviderSpec,
) -> Result<Vec<LocalSyncPlanAndReport>, GatewayError> {
let Some(input) = resolve_local_same_format_provider_decision_input(
state, parts, trace_id, decision, body_json, spec,
)
.await
else {
return Ok(Vec::new());
};
let attempts =
materialize_local_same_format_provider_candidate_attempts(state, trace_id, &input, spec)
.await?;
let mut plans = Vec::new();
for attempt in attempts {
let Some(payload) = maybe_build_local_same_format_provider_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
else {
continue;
};
let built = match spec.family {
LocalSameFormatProviderFamily::Standard => {
build_standard_sync_plan_from_decision(parts, body_json, payload)
}
LocalSameFormatProviderFamily::Gemini => {
build_gemini_sync_plan_from_decision(parts, body_json, payload)
}
};
match built {
Ok(Some(value)) => plans.push(value),
Ok(None) => {}
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local same-format sync decision plan build failed"
);
}
}
}
Ok(plans)
}
pub(super) async fn build_local_stream_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
spec: LocalSameFormatProviderSpec,
) -> Result<Vec<LocalStreamPlanAndReport>, GatewayError> {
let Some(input) = resolve_local_same_format_provider_decision_input(
state, parts, trace_id, decision, body_json, spec,
)
.await
else {
return Ok(Vec::new());
};
let attempts =
materialize_local_same_format_provider_candidate_attempts(state, trace_id, &input, spec)
.await?;
let mut plans = Vec::new();
for attempt in attempts {
let Some(payload) = maybe_build_local_same_format_provider_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
else {
continue;
};
let built = match spec.family {
LocalSameFormatProviderFamily::Standard => {
build_standard_stream_plan_from_decision(parts, body_json, payload, false)
}
LocalSameFormatProviderFamily::Gemini => {
build_gemini_stream_plan_from_decision(parts, body_json, payload)
}
};
match built {
Ok(Some(value)) => plans.push(value),
Ok(None) => {}
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local same-format stream decision plan build failed"
);
}
}
}
Ok(plans)
}

View File

@@ -0,0 +1,186 @@
use super::*;
pub(super) fn build_same_format_provider_request_body(
body_json: &Value,
mapped_model: &str,
spec: LocalSameFormatProviderSpec,
body_rules: Option<&Value>,
upstream_is_stream: bool,
kiro_auth: Option<&crate::gateway::provider_transport::KiroRequestAuth>,
is_claude_code: bool,
) -> Option<Value> {
if let Some(kiro_auth) = kiro_auth {
return build_kiro_provider_request_body(
body_json,
mapped_model,
&kiro_auth.auth_config,
body_rules,
);
}
let request_body_object = body_json.as_object()?;
let mut provider_request_body = serde_json::Map::from_iter(
request_body_object
.iter()
.map(|(key, value)| (key.clone(), value.clone())),
);
match spec.family {
LocalSameFormatProviderFamily::Standard => {
provider_request_body
.insert("model".to_string(), Value::String(mapped_model.to_string()));
if upstream_is_stream {
provider_request_body.insert("stream".to_string(), Value::Bool(true));
}
}
LocalSameFormatProviderFamily::Gemini => {
provider_request_body.remove("model");
}
}
let mut provider_request_body = Value::Object(provider_request_body);
if is_claude_code {
sanitize_claude_code_request_body(&mut provider_request_body);
}
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
return None;
}
Some(provider_request_body)
}
pub(super) fn build_same_format_upstream_url(
parts: &http::request::Parts,
transport: &crate::gateway::provider_transport::GatewayProviderTransportSnapshot,
mapped_model: &str,
spec: LocalSameFormatProviderSpec,
upstream_is_stream: bool,
kiro_auth: Option<&crate::gateway::provider_transport::KiroRequestAuth>,
) -> Option<String> {
if let Some(kiro_auth) = kiro_auth {
return build_kiro_generate_assistant_response_url(
&transport.endpoint.base_url,
parts.uri.query(),
Some(kiro_auth.auth_config.effective_api_region()),
);
}
if transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("claude_code")
{
return Some(build_claude_code_messages_url(
&transport.endpoint.base_url,
parts.uri.query(),
));
}
if transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("vertex_ai")
{
let auth = resolve_local_vertex_api_key_query_auth(transport)?;
return build_vertex_api_key_gemini_content_url(
mapped_model,
upstream_is_stream,
&auth.value,
parts.uri.query(),
);
}
if transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("antigravity")
{
let query = parts.uri.query().map(|query| {
form_urlencoded::parse(query.as_bytes())
.into_owned()
.collect::<BTreeMap<String, String>>()
});
return build_antigravity_v1internal_url(
&transport.endpoint.base_url,
if upstream_is_stream {
AntigravityRequestUrlAction::StreamGenerateContent
} else {
AntigravityRequestUrlAction::GenerateContent
},
query.as_ref(),
);
}
let custom_path = transport
.endpoint
.custom_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(path) = custom_path {
let blocked_keys = match spec.family {
LocalSameFormatProviderFamily::Standard => &[][..],
LocalSameFormatProviderFamily::Gemini => &["key"][..],
};
let url = build_passthrough_path_url(
&transport.endpoint.base_url,
path,
parts.uri.query(),
blocked_keys,
)?;
return Some(maybe_add_gemini_stream_alt_sse(url, spec));
}
let url = match spec.family {
LocalSameFormatProviderFamily::Standard => Some(build_claude_messages_url(
&transport.endpoint.base_url,
parts.uri.query(),
)),
LocalSameFormatProviderFamily::Gemini => build_gemini_content_url(
&transport.endpoint.base_url,
mapped_model,
spec.require_streaming,
parts.uri.query(),
),
}?;
Some(maybe_add_gemini_stream_alt_sse(url, spec))
}
pub(super) fn extract_gemini_model_from_path(path: &str) -> Option<String> {
let (_, suffix) = path.split_once("/models/")?;
let model = suffix
.split_once(':')
.map(|(value, _)| value)
.unwrap_or(suffix);
let model = model.trim();
if model.is_empty() {
None
} else {
Some(model.to_string())
}
}
fn maybe_add_gemini_stream_alt_sse(
upstream_url: String,
spec: LocalSameFormatProviderSpec,
) -> String {
if spec.family != LocalSameFormatProviderFamily::Gemini || !spec.require_streaming {
return upstream_url;
}
let has_alt = upstream_url
.split_once('?')
.map(|(_, query)| {
form_urlencoded::parse(query.as_bytes())
.any(|(key, _)| key.as_ref().eq_ignore_ascii_case("alt"))
})
.unwrap_or(false);
if has_alt {
return upstream_url;
}
if upstream_url.contains('?') {
format!("{upstream_url}&alt=sse")
} else {
format!("{upstream_url}?alt=sse")
}
}

View File

@@ -0,0 +1,65 @@
use std::collections::BTreeMap;
use aether_contracts::ExecutionPlan;
pub(crate) use crate::gateway::ai_pipeline::planner::generic_decision_missing_exact_provider_request;
use crate::gateway::{GatewayControlSyncDecisionResponse, GatewayError};
pub(crate) struct LocalSyncPlanAndReport {
pub(crate) plan: ExecutionPlan,
pub(crate) report_kind: Option<String>,
pub(crate) report_context: Option<serde_json::Value>,
}
pub(crate) struct LocalStreamPlanAndReport {
pub(crate) plan: ExecutionPlan,
pub(crate) report_kind: Option<String>,
pub(crate) report_context: Option<serde_json::Value>,
}
#[path = "standard/gemini/plan_builders.rs"]
mod gemini_builders;
#[path = "standard/openai/plan_builders.rs"]
mod openai_builders;
#[path = "passthrough/plan_builders.rs"]
mod passthrough_builders;
#[path = "standard/plan_builders.rs"]
mod standard_builders;
pub(crate) use gemini_builders::{
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
};
pub(crate) use openai_builders::{
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,
};
pub(crate) use passthrough_builders::{
build_passthrough_stream_plan_from_decision, build_passthrough_sync_plan_from_decision,
};
pub(crate) use standard_builders::{
build_standard_stream_plan_from_decision, build_standard_sync_plan_from_decision,
};
pub(super) fn augment_sync_report_context(
report_context: Option<serde_json::Value>,
provider_request_headers: &BTreeMap<String, String>,
provider_request_body: &serde_json::Value,
) -> Result<Option<serde_json::Value>, GatewayError> {
let mut report_context = match report_context {
Some(serde_json::Value::Object(map)) => map,
Some(_) => serde_json::Map::new(),
None => serde_json::Map::new(),
};
report_context.insert(
"provider_request_headers".to_string(),
serde_json::to_value(provider_request_headers)
.map_err(|err| GatewayError::Internal(err.to_string()))?,
);
report_context.insert(
"provider_request_body".to_string(),
provider_request_body.clone(),
);
Ok(Some(serde_json::Value::Object(report_context)))
}

View File

@@ -7,32 +7,33 @@ use serde_json::json;
use tracing::warn; use tracing::warn;
use uuid::Uuid; use uuid::Uuid;
use crate::gateway::executor::request_candidates::{
current_unix_secs, record_local_request_candidate_status,
};
use crate::gateway::headers::collect_control_headers; use crate::gateway::headers::collect_control_headers;
use crate::gateway::provider_transport::{ use crate::gateway::provider_transport::{
apply_local_body_rules, apply_local_header_rules, build_gemini_files_passthrough_url, apply_local_body_rules, apply_local_header_rules, build_gemini_files_passthrough_url,
build_passthrough_headers_with_auth, resolve_local_gemini_auth, build_passthrough_headers_with_auth, resolve_local_gemini_auth,
resolve_transport_execution_timeouts, resolve_transport_proxy_snapshot, resolve_transport_execution_timeouts, resolve_transport_proxy_snapshot_with_tunnel_affinity,
resolve_transport_tls_profile, supports_local_gemini_transport_with_network, resolve_transport_tls_profile, supports_local_gemini_transport_with_network,
}; };
use crate::gateway::request_candidates::{
current_unix_secs, record_local_request_candidate_status,
};
use crate::gateway::ai_pipeline::planner::plan_builders::{
build_passthrough_stream_plan_from_decision, build_passthrough_sync_plan_from_decision,
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates;
use crate::gateway::scheduler::{ use crate::gateway::scheduler::{
list_selectable_candidates_for_required_capability_without_requested_model, list_selectable_candidates_for_required_capability_without_requested_model,
GatewayMinimalCandidateSelectionCandidate, GatewayMinimalCandidateSelectionCandidate,
}; };
use crate::gateway::{AppState, GatewayControlDecision, GatewayError}; use crate::gateway::{
execute_execution_runtime_stream, execute_execution_runtime_sync, AppState,
use super::super::plan_builders::{ GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError,
build_passthrough_stream_plan_from_decision, build_passthrough_sync_plan_from_decision,
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
}; };
use super::super::stream::execute_executor_stream; use crate::gateway::ai_pipeline::planner::{
use super::super::sync::execute_executor_sync; EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
use super::super::{ GEMINI_FILES_DELETE_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND,
GatewayControlSyncDecisionResponse, EXECUTOR_STREAM_DECISION_ACTION, GEMINI_FILES_LIST_PLAN_KIND, GEMINI_FILES_UPLOAD_PLAN_KIND,
EXECUTOR_SYNC_DECISION_ACTION, GEMINI_FILES_DELETE_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND,
GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND, GEMINI_FILES_UPLOAD_PLAN_KIND,
}; };
const GEMINI_FILES_CANDIDATE_API_FORMAT: &str = "gemini:chat"; const GEMINI_FILES_CANDIDATE_API_FORMAT: &str = "gemini:chat";
@@ -49,7 +50,7 @@ struct LocalGeminiFilesSpec {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct LocalGeminiFilesDecisionInput { struct LocalGeminiFilesDecisionInput {
auth_context: crate::gateway::GatewayControlAuthContext, auth_context: crate::gateway::GatewayControlAuthContext,
auth_snapshot: crate::gateway::data::StoredGatewayAuthApiKeySnapshot, auth_snapshot: crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -59,10 +60,8 @@ struct LocalGeminiFilesCandidateAttempt {
candidate_id: String, candidate_id: String,
} }
pub(in crate::gateway::executor) async fn maybe_execute_sync_via_local_gemini_files_decision( pub(crate) async fn maybe_execute_sync_via_local_gemini_files_decision(
state: &AppState, state: &AppState,
control_base_url: &str,
executor_base_url: &str,
parts: &http::request::Parts, parts: &http::request::Parts,
body_json: &serde_json::Value, body_json: &serde_json::Value,
body_base64: Option<&str>, body_base64: Option<&str>,
@@ -92,10 +91,8 @@ pub(in crate::gateway::executor) async fn maybe_execute_sync_via_local_gemini_fi
let mut remaining = plan_and_reports.into_iter(); let mut remaining = plan_and_reports.into_iter();
while let Some(plan_and_report) = remaining.next() { while let Some(plan_and_report) = remaining.next() {
if let Some(response) = execute_executor_sync( if let Some(response) = execute_execution_runtime_sync(
state, state,
control_base_url,
executor_base_url,
parts.uri.path(), parts.uri.path(),
plan_and_report.plan, plan_and_report.plan,
trace_id, trace_id,
@@ -114,10 +111,8 @@ pub(in crate::gateway::executor) async fn maybe_execute_sync_via_local_gemini_fi
Ok(None) Ok(None)
} }
pub(in crate::gateway::executor) async fn maybe_execute_stream_via_local_gemini_files_decision( pub(crate) async fn maybe_execute_stream_via_local_gemini_files_decision(
state: &AppState, state: &AppState,
control_base_url: &str,
executor_base_url: &str,
parts: &http::request::Parts, parts: &http::request::Parts,
trace_id: &str, trace_id: &str,
decision: &GatewayControlDecision, decision: &GatewayControlDecision,
@@ -135,10 +130,8 @@ pub(in crate::gateway::executor) async fn maybe_execute_stream_via_local_gemini_
let mut remaining = plan_and_reports.into_iter(); let mut remaining = plan_and_reports.into_iter();
while let Some(plan_and_report) = remaining.next() { while let Some(plan_and_report) = remaining.next() {
if let Some(response) = execute_executor_stream( if let Some(response) = execute_execution_runtime_stream(
state, state,
control_base_url,
executor_base_url,
plan_and_report.plan, plan_and_report.plan,
trace_id, trace_id,
decision, decision,
@@ -156,7 +149,7 @@ pub(in crate::gateway::executor) async fn maybe_execute_stream_via_local_gemini_
Ok(None) Ok(None)
} }
pub(in crate::gateway::executor) async fn maybe_build_sync_local_gemini_files_decision_payload( pub(crate) async fn maybe_build_sync_local_gemini_files_decision_payload(
state: &AppState, state: &AppState,
parts: &http::request::Parts, parts: &http::request::Parts,
body_json: &serde_json::Value, body_json: &serde_json::Value,
@@ -199,7 +192,7 @@ pub(in crate::gateway::executor) async fn maybe_build_sync_local_gemini_files_de
Ok(None) Ok(None)
} }
pub(in crate::gateway::executor) async fn maybe_build_stream_local_gemini_files_decision_payload( pub(crate) async fn maybe_build_stream_local_gemini_files_decision_payload(
state: &AppState, state: &AppState,
parts: &http::request::Parts, parts: &http::request::Parts,
trace_id: &str, trace_id: &str,
@@ -432,6 +425,7 @@ async fn materialize_local_gemini_files_candidate_attempts(
current_unix_secs(), current_unix_secs(),
) )
.await?; .await?;
let candidates = prefer_local_tunnel_owner_candidates(state, candidates).await;
let created_at_unix_secs = current_unix_secs(); let created_at_unix_secs = current_unix_secs();
let mut attempts = Vec::with_capacity(candidates.len()); let mut attempts = Vec::with_capacity(candidates.len());
@@ -712,16 +706,22 @@ async fn maybe_build_local_gemini_files_decision_payload_for_candidate(
.trim_start_matches("/v1beta/") .trim_start_matches("/v1beta/")
.trim() .trim()
.to_string(); .to_string();
let proxy = resolve_transport_proxy_snapshot(&transport); let proxy = resolve_transport_proxy_snapshot_with_tunnel_affinity(state, &transport).await;
let tls_profile = resolve_transport_tls_profile(&transport); let tls_profile = resolve_transport_tls_profile(&transport);
Some(GatewayControlSyncDecisionResponse { Some(GatewayControlSyncDecisionResponse {
action: if spec.require_streaming { action: if spec.require_streaming {
EXECUTOR_STREAM_DECISION_ACTION.to_string() EXECUTION_RUNTIME_STREAM_DECISION_ACTION.to_string()
} else { } else {
EXECUTOR_SYNC_DECISION_ACTION.to_string() EXECUTION_RUNTIME_SYNC_DECISION_ACTION.to_string()
}, },
decision_kind: Some(spec.decision_kind.to_string()), decision_kind: Some(spec.decision_kind.to_string()),
execution_strategy: Some(
crate::gateway::ExecutionStrategy::LocalSameFormat
.as_str()
.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()),
@@ -735,6 +735,8 @@ async fn maybe_build_local_gemini_files_decision_payload_for_candidate(
auth_value: Some(auth_value), auth_value: Some(auth_value),
provider_api_format: Some(GEMINI_FILES_CLIENT_API_FORMAT.to_string()), provider_api_format: Some(GEMINI_FILES_CLIENT_API_FORMAT.to_string()),
client_api_format: Some(GEMINI_FILES_CLIENT_API_FORMAT.to_string()), client_api_format: Some(GEMINI_FILES_CLIENT_API_FORMAT.to_string()),
provider_contract: Some(GEMINI_FILES_CLIENT_API_FORMAT.to_string()),
client_contract: Some(GEMINI_FILES_CLIENT_API_FORMAT.to_string()),
model_name: Some("gemini-files".to_string()), model_name: Some("gemini-files".to_string()),
mapped_model: Some(candidate.selected_provider_model_name.clone()), mapped_model: Some(candidate.selected_provider_model_name.clone()),
prompt_cache_key: None, prompt_cache_key: None,

View File

@@ -0,0 +1,14 @@
//! Non-matrix AI surfaces such as files and video.
mod files;
mod video;
pub(crate) use self::files::{
maybe_build_stream_local_gemini_files_decision_payload,
maybe_build_sync_local_gemini_files_decision_payload,
maybe_execute_stream_via_local_gemini_files_decision,
maybe_execute_sync_via_local_gemini_files_decision,
};
pub(crate) use self::video::{
maybe_build_sync_local_video_decision_payload, maybe_execute_sync_via_local_video_decision,
};

View File

@@ -7,29 +7,31 @@ use serde_json::{json, Value};
use tracing::warn; use tracing::warn;
use uuid::Uuid; use uuid::Uuid;
use crate::gateway::executor::request_candidates::{
current_unix_secs, record_local_request_candidate_status,
};
use crate::gateway::headers::collect_control_headers; use crate::gateway::headers::collect_control_headers;
use crate::gateway::provider_transport::{ use crate::gateway::provider_transport::{
apply_local_body_rules, apply_local_header_rules, build_gemini_video_predict_long_running_url, 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, build_passthrough_headers_with_auth, build_passthrough_path_url, resolve_local_gemini_auth,
resolve_local_openai_chat_auth, resolve_transport_execution_timeouts, resolve_local_openai_chat_auth, resolve_transport_execution_timeouts,
resolve_transport_proxy_snapshot, resolve_transport_tls_profile, resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
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::request_candidates::{
current_unix_secs, record_local_request_candidate_status,
};
use crate::gateway::ai_pipeline::planner::plan_builders::{
build_passthrough_sync_plan_from_decision, LocalSyncPlanAndReport,
};
use crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates;
use crate::gateway::scheduler::{ use crate::gateway::scheduler::{
list_selectable_candidates, GatewayMinimalCandidateSelectionCandidate, list_selectable_candidates, GatewayMinimalCandidateSelectionCandidate,
}; };
use crate::gateway::{AppState, GatewayControlDecision, GatewayError}; use crate::gateway::{
execute_execution_runtime_sync, AppState, GatewayControlDecision,
use super::super::plan_builders::{ GatewayControlSyncDecisionResponse, GatewayError,
build_passthrough_sync_plan_from_decision, LocalSyncPlanAndReport,
}; };
use super::super::sync::execute_executor_sync; use crate::gateway::ai_pipeline::planner::{
use super::super::{ EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND,
GatewayControlSyncDecisionResponse, EXECUTOR_SYNC_DECISION_ACTION, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -50,7 +52,7 @@ struct LocalVideoCreateSpec {
struct LocalVideoCreateDecisionInput { struct LocalVideoCreateDecisionInput {
auth_context: crate::gateway::GatewayControlAuthContext, auth_context: crate::gateway::GatewayControlAuthContext,
requested_model: String, requested_model: String,
auth_snapshot: crate::gateway::data::StoredGatewayAuthApiKeySnapshot, auth_snapshot: crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -60,10 +62,8 @@ struct LocalVideoCreateCandidateAttempt {
candidate_id: String, candidate_id: String,
} }
pub(in crate::gateway::executor) async fn maybe_execute_sync_via_local_video_decision( pub(crate) async fn maybe_execute_sync_via_local_video_decision(
state: &AppState, state: &AppState,
control_base_url: &str,
executor_base_url: &str,
parts: &http::request::Parts, parts: &http::request::Parts,
body_json: &serde_json::Value, body_json: &serde_json::Value,
trace_id: &str, trace_id: &str,
@@ -83,10 +83,8 @@ pub(in crate::gateway::executor) async fn maybe_execute_sync_via_local_video_dec
let mut remaining = plan_and_reports.into_iter(); let mut remaining = plan_and_reports.into_iter();
while let Some(plan_and_report) = remaining.next() { while let Some(plan_and_report) = remaining.next() {
if let Some(response) = execute_executor_sync( if let Some(response) = execute_execution_runtime_sync(
state, state,
control_base_url,
executor_base_url,
parts.uri.path(), parts.uri.path(),
plan_and_report.plan, plan_and_report.plan,
trace_id, trace_id,
@@ -105,7 +103,7 @@ pub(in crate::gateway::executor) async fn maybe_execute_sync_via_local_video_dec
Ok(None) Ok(None)
} }
pub(in crate::gateway::executor) async fn maybe_build_sync_local_video_decision_payload( pub(crate) async fn maybe_build_sync_local_video_decision_payload(
state: &AppState, state: &AppState,
parts: &http::request::Parts, parts: &http::request::Parts,
body_json: &serde_json::Value, body_json: &serde_json::Value,
@@ -483,12 +481,18 @@ async fn maybe_build_local_video_create_decision_payload_for_candidate(
.await; .await;
return None; return None;
} }
let proxy = resolve_transport_proxy_snapshot(&transport); let proxy = resolve_transport_proxy_snapshot_with_tunnel_affinity(state, &transport).await;
let tls_profile = resolve_transport_tls_profile(&transport); let tls_profile = resolve_transport_tls_profile(&transport);
Some(GatewayControlSyncDecisionResponse { Some(GatewayControlSyncDecisionResponse {
action: EXECUTOR_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(
crate::gateway::ExecutionStrategy::LocalSameFormat
.as_str()
.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()),
@@ -502,6 +506,8 @@ async fn maybe_build_local_video_create_decision_payload_for_candidate(
auth_value: Some(auth_value), auth_value: Some(auth_value),
provider_api_format: Some(spec.api_format.to_string()), provider_api_format: Some(spec.api_format.to_string()),
client_api_format: Some(spec.api_format.to_string()), client_api_format: Some(spec.api_format.to_string()),
provider_contract: Some(spec.api_format.to_string()),
client_contract: Some(spec.api_format.to_string()),
model_name: Some(input.requested_model.clone()), model_name: Some(input.requested_model.clone()),
mapped_model: Some(mapped_model.clone()), mapped_model: Some(mapped_model.clone()),
prompt_cache_key: None, prompt_cache_key: None,
@@ -614,6 +620,7 @@ async fn materialize_local_video_create_candidate_attempts(
candidates: Vec<GatewayMinimalCandidateSelectionCandidate>, candidates: Vec<GatewayMinimalCandidateSelectionCandidate>,
api_format: &str, api_format: &str,
) -> Vec<LocalVideoCreateCandidateAttempt> { ) -> Vec<LocalVideoCreateCandidateAttempt> {
let candidates = prefer_local_tunnel_owner_candidates(state, candidates).await;
let created_at_unix_secs = current_unix_secs(); let created_at_unix_secs = current_unix_secs();
let mut attempts = Vec::with_capacity(candidates.len()); let mut attempts = Vec::with_capacity(candidates.len());

View File

@@ -0,0 +1,33 @@
use crate::gateway::ai_pipeline::planner::{
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND,
};
use super::super::family::{LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec};
pub(super) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
CLAUDE_CHAT_SYNC_PLAN_KIND => Some(LocalStandardSpec {
api_format: "claude:chat",
decision_kind: CLAUDE_CHAT_SYNC_PLAN_KIND,
report_kind: "claude_chat_sync_finalize",
family: LocalStandardSourceFamily::Standard,
mode: LocalStandardSourceMode::Chat,
require_streaming: false,
}),
_ => None,
}
}
pub(super) fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
CLAUDE_CHAT_STREAM_PLAN_KIND => Some(LocalStandardSpec {
api_format: "claude:chat",
decision_kind: CLAUDE_CHAT_STREAM_PLAN_KIND,
report_kind: "claude_chat_stream_success",
family: LocalStandardSourceFamily::Standard,
mode: LocalStandardSourceMode::Chat,
require_streaming: true,
}),
_ => None,
}
}

View File

@@ -0,0 +1,33 @@
use crate::gateway::ai_pipeline::planner::{
CLAUDE_CLI_STREAM_PLAN_KIND, CLAUDE_CLI_SYNC_PLAN_KIND,
};
use super::super::family::{LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec};
pub(super) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
CLAUDE_CLI_SYNC_PLAN_KIND => Some(LocalStandardSpec {
api_format: "claude:cli",
decision_kind: CLAUDE_CLI_SYNC_PLAN_KIND,
report_kind: "claude_cli_sync_finalize",
family: LocalStandardSourceFamily::Standard,
mode: LocalStandardSourceMode::Cli,
require_streaming: false,
}),
_ => None,
}
}
pub(super) fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
CLAUDE_CLI_STREAM_PLAN_KIND => Some(LocalStandardSpec {
api_format: "claude:cli",
decision_kind: CLAUDE_CLI_STREAM_PLAN_KIND,
report_kind: "claude_cli_stream_success",
family: LocalStandardSourceFamily::Standard,
mode: LocalStandardSourceMode::Cli,
require_streaming: true,
}),
_ => None,
}
}

View File

@@ -0,0 +1,104 @@
use axum::body::Body;
use axum::http::Response;
use crate::gateway::{
AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError,
};
use super::family::{
maybe_build_stream_via_standard_family_payload, maybe_build_sync_via_standard_family_payload,
maybe_execute_stream_via_standard_family_decision,
maybe_execute_sync_via_standard_family_decision,
};
pub(crate) use crate::gateway::ai_pipeline::conversion::request::normalize_claude_request_to_openai_chat_request;
mod chat;
mod cli;
pub(crate) async fn maybe_execute_sync_via_local_claude_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
maybe_execute_sync_via_standard_family_decision(
state,
parts,
trace_id,
decision,
body_json,
plan_kind,
|plan_kind| {
chat::resolve_sync_spec(plan_kind).or_else(|| cli::resolve_sync_spec(plan_kind))
},
)
.await
}
pub(crate) async fn maybe_execute_stream_via_local_claude_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
maybe_execute_stream_via_standard_family_decision(
state,
parts,
trace_id,
decision,
body_json,
plan_kind,
|plan_kind| {
chat::resolve_stream_spec(plan_kind).or_else(|| cli::resolve_stream_spec(plan_kind))
},
)
.await
}
pub(crate) async fn maybe_build_sync_local_claude_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
maybe_build_sync_via_standard_family_payload(
state,
parts,
trace_id,
decision,
body_json,
plan_kind,
|plan_kind| {
chat::resolve_sync_spec(plan_kind).or_else(|| cli::resolve_sync_spec(plan_kind))
},
)
.await
}
pub(crate) async fn maybe_build_stream_local_claude_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
maybe_build_stream_via_standard_family_payload(
state,
parts,
trace_id,
decision,
body_json,
plan_kind,
|plan_kind| {
chat::resolve_stream_spec(plan_kind).or_else(|| cli::resolve_stream_spec(plan_kind))
},
)
.await
}

View File

@@ -0,0 +1,271 @@
use std::collections::BTreeSet;
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
use serde_json::json;
use tracing::warn;
use uuid::Uuid;
use crate::gateway::request_candidates::current_unix_secs;
use crate::gateway::scheduler::{
list_selectable_candidates, GatewayMinimalCandidateSelectionCandidate,
};
use crate::gateway::{
append_execution_contract_fields_to_value, AppState, ConversionMode, ExecutionStrategy,
GatewayControlDecision, GatewayError,
};
use super::types::{
LocalStandardCandidateAttempt, LocalStandardDecisionInput, LocalStandardSourceFamily,
LocalStandardSourceMode, LocalStandardSpec,
};
pub(super) async fn resolve_local_standard_decision_input(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
spec: LocalStandardSpec,
) -> Option<LocalStandardDecisionInput> {
let Some(auth_context) = decision.auth_context.clone().filter(|auth_context| {
!auth_context.user_id.trim().is_empty() && !auth_context.api_key_id.trim().is_empty()
}) else {
return None;
};
let requested_model = match spec.family {
LocalStandardSourceFamily::Standard => body_json
.get("model")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)?,
LocalStandardSourceFamily::Gemini => extract_gemini_model_from_path(parts.uri.path())?,
};
let auth_snapshot = match state
.read_auth_api_key_snapshot(
&auth_context.user_id,
&auth_context.api_key_id,
current_unix_secs(),
)
.await
{
Ok(Some(snapshot)) => snapshot,
Ok(None) => return None,
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local standard decision auth snapshot read failed"
);
return None;
}
};
Some(LocalStandardDecisionInput {
auth_context,
requested_model,
auth_snapshot,
})
}
pub(super) async fn materialize_local_standard_candidate_attempts(
state: &AppState,
trace_id: &str,
input: &LocalStandardDecisionInput,
spec: LocalStandardSpec,
) -> Result<Vec<LocalStandardCandidateAttempt>, GatewayError> {
let mut seen_candidates = BTreeSet::new();
let mut candidates = Vec::new();
for candidate_api_format in candidate_api_formats_for_spec(spec) {
let auth_snapshot = if *candidate_api_format == spec.api_format {
Some(&input.auth_snapshot)
} else {
None
};
let mut selected_candidates = list_selectable_candidates(
state,
candidate_api_format,
&input.requested_model,
spec.require_streaming,
auth_snapshot,
current_unix_secs(),
)
.await?;
if auth_snapshot.is_none() {
selected_candidates.retain(|candidate| {
auth_snapshot_allows_cross_format_candidate(
&input.auth_snapshot,
&input.requested_model,
candidate,
)
});
}
for candidate in selected_candidates {
let candidate_key = format!(
"{}:{}:{}:{}:{}:{}",
candidate.provider_id,
candidate.endpoint_id,
candidate.key_id,
candidate.model_id,
candidate.selected_provider_model_name,
candidate.endpoint_api_format,
);
if seen_candidates.insert(candidate_key) {
candidates.push(candidate);
}
}
}
let candidates =
crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates(state, candidates)
.await;
let created_at_unix_secs = current_unix_secs();
let mut attempts = Vec::with_capacity(candidates.len());
for (candidate_index, candidate) in candidates.into_iter().enumerate() {
let candidate_id = Uuid::new_v4().to_string();
let provider_api_format = candidate.endpoint_api_format.trim().to_ascii_lowercase();
let execution_strategy = if provider_api_format == spec.api_format {
ExecutionStrategy::LocalSameFormat
} else {
ExecutionStrategy::LocalCrossFormat
};
let conversion_mode = if crate::gateway::ai_pipeline::conversion::request_conversion_kind(
spec.api_format,
provider_api_format.as_str(),
)
.is_some()
{
ConversionMode::Bidirectional
} else {
ConversionMode::None
};
let extra_data = append_execution_contract_fields_to_value(
json!({
"provider_api_format": provider_api_format,
"client_api_format": spec.api_format,
"global_model_id": candidate.global_model_id.clone(),
"global_model_name": candidate.global_model_name.clone(),
"model_id": candidate.model_id.clone(),
"selected_provider_model_name": candidate.selected_provider_model_name.clone(),
"mapping_matched_model": candidate.mapping_matched_model.clone(),
"provider_name": candidate.provider_name.clone(),
"key_name": candidate.key_name.clone(),
}),
execution_strategy,
conversion_mode,
spec.api_format,
candidate.endpoint_api_format.as_str(),
);
let stored_candidate_id = match state
.upsert_request_candidate(UpsertRequestCandidateRecord {
id: candidate_id.clone(),
request_id: trace_id.to_string(),
user_id: Some(input.auth_context.user_id.clone()),
api_key_id: Some(input.auth_context.api_key_id.clone()),
username: None,
api_key_name: None,
candidate_index: candidate_index as u32,
retry_index: 0,
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
status: RequestCandidateStatus::Available,
skip_reason: None,
is_cached: Some(false),
status_code: None,
error_type: None,
error_message: None,
latency_ms: None,
concurrent_requests: None,
extra_data: Some(extra_data),
required_capabilities: candidate.key_capabilities.clone(),
created_at_unix_secs: Some(created_at_unix_secs),
started_at_unix_secs: None,
finished_at_unix_secs: None,
})
.await
{
Ok(Some(stored)) => stored.id,
Ok(None) => candidate_id.clone(),
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local standard decision request candidate upsert failed"
);
candidate_id.clone()
}
};
attempts.push(LocalStandardCandidateAttempt {
candidate,
candidate_index: candidate_index as u32,
candidate_id: stored_candidate_id,
});
}
Ok(attempts)
}
fn auth_snapshot_allows_cross_format_candidate(
auth_snapshot: &crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot,
requested_model: &str,
candidate: &GatewayMinimalCandidateSelectionCandidate,
) -> bool {
if let Some(allowed_providers) = auth_snapshot.effective_allowed_providers() {
let provider_allowed = allowed_providers.iter().any(|value| {
value
.trim()
.eq_ignore_ascii_case(candidate.provider_id.trim())
|| value
.trim()
.eq_ignore_ascii_case(candidate.provider_name.trim())
});
if !provider_allowed {
return false;
}
}
if let Some(allowed_models) = auth_snapshot.effective_allowed_models() {
let model_allowed = allowed_models
.iter()
.any(|value| value == requested_model || value == &candidate.global_model_name);
if !model_allowed {
return false;
}
}
true
}
fn candidate_api_formats_for_spec(spec: LocalStandardSpec) -> &'static [&'static str] {
match spec.mode {
LocalStandardSourceMode::Chat | LocalStandardSourceMode::Cli => &[
"openai:chat",
"openai:cli",
"openai:compact",
"claude:chat",
"claude:cli",
"gemini:chat",
"gemini:cli",
],
}
}
fn extract_gemini_model_from_path(path: &str) -> Option<String> {
let marker = "/models/";
let start = path.find(marker)? + marker.len();
let tail = &path[start..];
let end = tail.find(':').unwrap_or(tail.len());
let model = tail[..end].trim();
if model.is_empty() {
None
} else {
Some(model.to_string())
}
}

View File

@@ -0,0 +1,236 @@
use axum::body::Body;
use axum::http::Response;
use tracing::warn;
use crate::gateway::ai_pipeline::planner::family_core::{
execute_stream_plan_and_reports, execute_sync_plan_and_reports,
};
use crate::gateway::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,
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::{AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError};
use super::candidates::{
materialize_local_standard_candidate_attempts, resolve_local_standard_decision_input,
};
use super::payload::maybe_build_local_standard_decision_payload_for_candidate;
use super::types::{LocalStandardSourceFamily, LocalStandardSpec};
pub(crate) async fn maybe_execute_sync_via_standard_family_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
resolve_sync_spec: fn(&str) -> Option<LocalStandardSpec>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(spec) = resolve_sync_spec(plan_kind) else {
return Ok(None);
};
let plan_and_reports =
build_local_sync_plan_and_reports(state, parts, trace_id, decision, body_json, spec)
.await?;
if plan_and_reports.is_empty() {
return Ok(None);
}
execute_sync_plan_and_reports(state, parts, trace_id, decision, plan_kind, plan_and_reports)
.await
}
pub(crate) async fn maybe_execute_stream_via_standard_family_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
resolve_stream_spec: fn(&str) -> Option<LocalStandardSpec>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(spec) = resolve_stream_spec(plan_kind) else {
return Ok(None);
};
let plan_and_reports =
build_local_stream_plan_and_reports(state, parts, trace_id, decision, body_json, spec)
.await?;
if plan_and_reports.is_empty() {
return Ok(None);
}
execute_stream_plan_and_reports(state, trace_id, decision, plan_kind, plan_and_reports).await
}
pub(crate) async fn maybe_build_sync_via_standard_family_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
resolve_sync_spec: fn(&str) -> Option<LocalStandardSpec>,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
let Some(spec) = resolve_sync_spec(plan_kind) else {
return Ok(None);
};
let Some(input) =
resolve_local_standard_decision_input(state, parts, trace_id, decision, body_json, spec)
.await
else {
return Ok(None);
};
let attempts =
materialize_local_standard_candidate_attempts(state, trace_id, &input, spec).await?;
for attempt in attempts {
if let Some(payload) = maybe_build_local_standard_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
{
return Ok(Some(payload));
}
}
Ok(None)
}
pub(crate) async fn maybe_build_stream_via_standard_family_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
resolve_stream_spec: fn(&str) -> Option<LocalStandardSpec>,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
let Some(spec) = resolve_stream_spec(plan_kind) else {
return Ok(None);
};
let Some(input) =
resolve_local_standard_decision_input(state, parts, trace_id, decision, body_json, spec)
.await
else {
return Ok(None);
};
let attempts =
materialize_local_standard_candidate_attempts(state, trace_id, &input, spec).await?;
for attempt in attempts {
if let Some(payload) = maybe_build_local_standard_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
{
return Ok(Some(payload));
}
}
Ok(None)
}
async fn build_local_sync_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
spec: LocalStandardSpec,
) -> Result<Vec<LocalSyncPlanAndReport>, GatewayError> {
let Some(input) =
resolve_local_standard_decision_input(state, parts, trace_id, decision, body_json, spec)
.await
else {
return Ok(Vec::new());
};
let attempts =
materialize_local_standard_candidate_attempts(state, trace_id, &input, spec).await?;
let mut plans = Vec::new();
for attempt in attempts {
let Some(payload) = maybe_build_local_standard_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
else {
continue;
};
let built = match spec.family {
LocalStandardSourceFamily::Standard => {
build_standard_sync_plan_from_decision(parts, body_json, payload)
}
LocalStandardSourceFamily::Gemini => {
build_gemini_sync_plan_from_decision(parts, body_json, payload)
}
};
match built {
Ok(Some(value)) => plans.push(value),
Ok(None) => {}
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local standard sync plan build failed"
);
}
}
}
Ok(plans)
}
async fn build_local_stream_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
spec: LocalStandardSpec,
) -> Result<Vec<LocalStreamPlanAndReport>, GatewayError> {
let Some(input) =
resolve_local_standard_decision_input(state, parts, trace_id, decision, body_json, spec)
.await
else {
return Ok(Vec::new());
};
let attempts =
materialize_local_standard_candidate_attempts(state, trace_id, &input, spec).await?;
let mut plans = Vec::new();
for attempt in attempts {
let Some(payload) = maybe_build_local_standard_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
else {
continue;
};
let built = match spec.family {
LocalStandardSourceFamily::Standard => {
build_standard_stream_plan_from_decision(parts, body_json, payload, false)
}
LocalStandardSourceFamily::Gemini => {
build_gemini_stream_plan_from_decision(parts, body_json, payload)
}
};
match built {
Ok(Some(value)) => plans.push(value),
Ok(None) => {}
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local standard stream plan build failed"
);
}
}
}
Ok(plans)
}

View File

@@ -0,0 +1,13 @@
mod candidates;
mod execute;
mod payload;
mod types;
pub(crate) use self::execute::{
maybe_build_stream_via_standard_family_payload, maybe_build_sync_via_standard_family_payload,
maybe_execute_stream_via_standard_family_decision,
maybe_execute_sync_via_standard_family_decision,
};
pub(crate) use self::types::{
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
};

View File

@@ -0,0 +1,360 @@
use std::collections::BTreeMap;
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
use serde_json::json;
use tracing::warn;
use crate::gateway::headers::collect_control_headers;
use crate::gateway::provider_transport::{
apply_local_header_rules, build_openai_passthrough_headers, ensure_upstream_auth_header,
resolve_transport_execution_timeouts, resolve_transport_proxy_snapshot_with_tunnel_affinity,
resolve_transport_tls_profile, LocalResolvedOAuthRequestAuth,
};
use crate::gateway::request_candidates::current_unix_secs;
use crate::gateway::{
append_execution_contract_fields_to_value, AppState, ConversionMode, ExecutionStrategy,
GatewayControlSyncDecisionResponse, EXECUTION_RUNTIME_STREAM_DECISION_ACTION,
EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
};
use super::types::{LocalStandardCandidateAttempt, LocalStandardDecisionInput, LocalStandardSpec};
pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
body_json: &serde_json::Value,
input: &LocalStandardDecisionInput,
attempt: LocalStandardCandidateAttempt,
spec: LocalStandardSpec,
) -> Option<GatewayControlSyncDecisionResponse> {
let LocalStandardCandidateAttempt {
candidate,
candidate_index,
candidate_id,
} = attempt;
let provider_api_format = candidate.endpoint_api_format.trim().to_ascii_lowercase();
let Some(conversion_kind) = crate::gateway::ai_pipeline::conversion::request_conversion_kind(
spec.api_format,
provider_api_format.as_str(),
) else {
if provider_api_format == spec.api_format {
return None;
}
return None;
};
let transport = match state
.read_provider_transport_snapshot(
&candidate.provider_id,
&candidate.endpoint_id,
&candidate.key_id,
)
.await
{
Ok(Some(snapshot)) => snapshot,
Ok(None) => {
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_snapshot_missing",
)
.await;
return None;
}
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local standard decision provider transport read failed"
);
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_snapshot_read_failed",
)
.await;
return None;
}
};
if !crate::gateway::ai_pipeline::conversion::request_conversion_transport_supported(
&transport,
conversion_kind,
) {
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_unsupported",
)
.await;
return None;
}
let resolved_auth = crate::gateway::ai_pipeline::conversion::request_conversion_direct_auth(
&transport,
conversion_kind,
);
let oauth_auth = if resolved_auth.is_none() {
match state.resolve_local_oauth_request_auth(&transport).await {
Ok(Some(LocalResolvedOAuthRequestAuth::Header { name, value })) => Some((name, value)),
Ok(Some(LocalResolvedOAuthRequestAuth::Kiro(_))) => None,
Ok(None) => None,
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
provider_type = %transport.provider.provider_type,
error = ?err,
"gateway local standard oauth auth resolution failed"
);
None
}
}
} else {
None
};
let Some((auth_header, auth_value)) = resolved_auth.or(oauth_auth) else {
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_auth_unavailable",
)
.await;
return None;
};
let mapped_model = candidate.selected_provider_model_name.trim().to_string();
if mapped_model.is_empty() {
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"mapped_model_missing",
)
.await;
return None;
}
let provider_request_body =
match crate::gateway::ai_pipeline::planner::standard::build_standard_request_body(
body_json,
spec.api_format,
&mapped_model,
provider_api_format.as_str(),
parts.uri.path(),
spec.require_streaming,
transport.endpoint.body_rules.as_ref(),
) {
Some(body) => body,
None => {
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"provider_request_body_missing",
)
.await;
return None;
}
};
let upstream_url =
match crate::gateway::ai_pipeline::planner::standard::build_standard_upstream_url(
parts,
&transport,
&mapped_model,
provider_api_format.as_str(),
spec.require_streaming,
) {
Some(url) => url,
None => {
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"upstream_url_missing",
)
.await;
return None;
}
};
let mut provider_request_headers = build_openai_passthrough_headers(
&parts.headers,
&auth_header,
&auth_value,
&BTreeMap::new(),
Some("application/json"),
);
if !apply_local_header_rules(
&mut provider_request_headers,
transport.endpoint.header_rules.as_ref(),
&[&auth_header, "content-type"],
&provider_request_body,
Some(body_json),
) {
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_header_rules_apply_failed",
)
.await;
return None;
}
ensure_upstream_auth_header(&mut provider_request_headers, &auth_header, &auth_value);
if spec.require_streaming {
provider_request_headers
.entry("accept".to_string())
.or_insert_with(|| "text/event-stream".to_string());
}
Some(GatewayControlSyncDecisionResponse {
action: if spec.require_streaming {
EXECUTION_RUNTIME_STREAM_DECISION_ACTION.to_string()
} else {
EXECUTION_RUNTIME_SYNC_DECISION_ACTION.to_string()
},
decision_kind: Some(spec.decision_kind.to_string()),
execution_strategy: Some(ExecutionStrategy::LocalCrossFormat.as_str().to_string()),
conversion_mode: Some(ConversionMode::Bidirectional.as_str().to_string()),
request_id: Some(trace_id.to_string()),
candidate_id: Some(candidate_id.clone()),
provider_name: Some(candidate.provider_name.clone()),
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
upstream_base_url: Some(transport.endpoint.base_url.clone()),
upstream_url: Some(upstream_url.clone()),
provider_request_method: None,
auth_header: Some(auth_header),
auth_value: Some(auth_value),
provider_api_format: Some(provider_api_format.clone()),
client_api_format: Some(spec.api_format.to_string()),
provider_contract: Some(provider_api_format.clone()),
client_contract: Some(spec.api_format.to_string()),
model_name: Some(input.requested_model.clone()),
mapped_model: Some(mapped_model.clone()),
prompt_cache_key: None,
extra_headers: BTreeMap::new(),
provider_request_headers: provider_request_headers.clone(),
provider_request_body: Some(provider_request_body.clone()),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
proxy: resolve_transport_proxy_snapshot_with_tunnel_affinity(state, &transport).await,
tls_profile: resolve_transport_tls_profile(&transport),
timeouts: resolve_transport_execution_timeouts(&transport),
upstream_is_stream: spec.require_streaming,
report_kind: Some(spec.report_kind.to_string()),
report_context: Some(append_execution_contract_fields_to_value(
json!({
"user_id": input.auth_context.user_id,
"api_key_id": input.auth_context.api_key_id,
"request_id": trace_id,
"candidate_id": candidate_id,
"candidate_index": candidate_index,
"retry_index": 0,
"model": input.requested_model,
"provider_name": candidate.provider_name,
"provider_id": candidate.provider_id,
"endpoint_id": candidate.endpoint_id,
"key_id": candidate.key_id,
"provider_api_format": provider_api_format,
"client_api_format": spec.api_format,
"mapped_model": mapped_model,
"upstream_url": upstream_url,
"provider_request_method": serde_json::Value::Null,
"provider_request_headers": provider_request_headers,
"provider_request_body": provider_request_body,
"original_headers": collect_control_headers(&parts.headers),
"original_request_body": body_json,
"has_envelope": false,
"needs_conversion": true,
}),
ExecutionStrategy::LocalCrossFormat,
ConversionMode::Bidirectional,
spec.api_format,
candidate.endpoint_api_format.as_str(),
)),
auth_context: Some(input.auth_context.clone()),
})
}
pub(super) async fn mark_skipped_local_standard_candidate(
state: &AppState,
input: &LocalStandardDecisionInput,
trace_id: &str,
candidate: &crate::gateway::scheduler::GatewayMinimalCandidateSelectionCandidate,
candidate_index: u32,
candidate_id: &str,
skip_reason: &'static str,
) {
if let Err(err) = state
.upsert_request_candidate(UpsertRequestCandidateRecord {
id: candidate_id.to_string(),
request_id: trace_id.to_string(),
user_id: Some(input.auth_context.user_id.clone()),
api_key_id: Some(input.auth_context.api_key_id.clone()),
username: None,
api_key_name: None,
candidate_index,
retry_index: 0,
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
status: RequestCandidateStatus::Skipped,
skip_reason: Some(skip_reason.to_string()),
is_cached: Some(false),
status_code: None,
error_type: None,
error_message: None,
latency_ms: None,
concurrent_requests: None,
extra_data: None,
required_capabilities: candidate.key_capabilities.clone(),
created_at_unix_secs: None,
started_at_unix_secs: None,
finished_at_unix_secs: Some(current_unix_secs()),
})
.await
{
warn!(
trace_id = %trace_id,
candidate_id = %candidate_id,
skip_reason,
error = ?err,
"gateway local standard decision failed to persist skipped candidate"
);
}
}

View File

@@ -0,0 +1,35 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LocalStandardSourceFamily {
Standard,
Gemini,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LocalStandardSourceMode {
Chat,
Cli,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct LocalStandardSpec {
pub(crate) api_format: &'static str,
pub(crate) decision_kind: &'static str,
pub(crate) report_kind: &'static str,
pub(crate) family: LocalStandardSourceFamily,
pub(crate) mode: LocalStandardSourceMode,
pub(crate) require_streaming: bool,
}
#[derive(Debug, Clone)]
pub(super) struct LocalStandardDecisionInput {
pub(super) auth_context: crate::gateway::GatewayControlAuthContext,
pub(super) requested_model: String,
pub(super) auth_snapshot: crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot,
}
#[derive(Debug, Clone)]
pub(super) struct LocalStandardCandidateAttempt {
pub(super) candidate: crate::gateway::scheduler::GatewayMinimalCandidateSelectionCandidate,
pub(super) candidate_index: u32,
pub(super) candidate_id: String,
}

View File

@@ -0,0 +1,33 @@
use crate::gateway::ai_pipeline::planner::{
GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
};
use super::super::family::{LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec};
pub(super) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
GEMINI_CHAT_SYNC_PLAN_KIND => Some(LocalStandardSpec {
api_format: "gemini:chat",
decision_kind: GEMINI_CHAT_SYNC_PLAN_KIND,
report_kind: "gemini_chat_sync_finalize",
family: LocalStandardSourceFamily::Gemini,
mode: LocalStandardSourceMode::Chat,
require_streaming: false,
}),
_ => None,
}
}
pub(super) fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
GEMINI_CHAT_STREAM_PLAN_KIND => Some(LocalStandardSpec {
api_format: "gemini:chat",
decision_kind: GEMINI_CHAT_STREAM_PLAN_KIND,
report_kind: "gemini_chat_stream_success",
family: LocalStandardSourceFamily::Gemini,
mode: LocalStandardSourceMode::Chat,
require_streaming: true,
}),
_ => None,
}
}

View File

@@ -0,0 +1,33 @@
use crate::gateway::ai_pipeline::planner::{
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
};
use super::super::family::{LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec};
pub(super) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
GEMINI_CLI_SYNC_PLAN_KIND => Some(LocalStandardSpec {
api_format: "gemini:cli",
decision_kind: GEMINI_CLI_SYNC_PLAN_KIND,
report_kind: "gemini_cli_sync_finalize",
family: LocalStandardSourceFamily::Gemini,
mode: LocalStandardSourceMode::Cli,
require_streaming: false,
}),
_ => None,
}
}
pub(super) fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
GEMINI_CLI_STREAM_PLAN_KIND => Some(LocalStandardSpec {
api_format: "gemini:cli",
decision_kind: GEMINI_CLI_STREAM_PLAN_KIND,
report_kind: "gemini_cli_stream_success",
family: LocalStandardSourceFamily::Gemini,
mode: LocalStandardSourceMode::Cli,
require_streaming: true,
}),
_ => None,
}
}

View File

@@ -0,0 +1,104 @@
use axum::body::Body;
use axum::http::Response;
use crate::gateway::{
AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError,
};
use super::family::{
maybe_build_stream_via_standard_family_payload, maybe_build_sync_via_standard_family_payload,
maybe_execute_stream_via_standard_family_decision,
maybe_execute_sync_via_standard_family_decision,
};
pub(crate) use crate::gateway::ai_pipeline::conversion::request::normalize_gemini_request_to_openai_chat_request;
mod chat;
mod cli;
pub(crate) async fn maybe_execute_sync_via_local_gemini_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
maybe_execute_sync_via_standard_family_decision(
state,
parts,
trace_id,
decision,
body_json,
plan_kind,
|plan_kind| {
chat::resolve_sync_spec(plan_kind).or_else(|| cli::resolve_sync_spec(plan_kind))
},
)
.await
}
pub(crate) async fn maybe_execute_stream_via_local_gemini_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
maybe_execute_stream_via_standard_family_decision(
state,
parts,
trace_id,
decision,
body_json,
plan_kind,
|plan_kind| {
chat::resolve_stream_spec(plan_kind).or_else(|| cli::resolve_stream_spec(plan_kind))
},
)
.await
}
pub(crate) async fn maybe_build_sync_local_gemini_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
maybe_build_sync_via_standard_family_payload(
state,
parts,
trace_id,
decision,
body_json,
plan_kind,
|plan_kind| {
chat::resolve_sync_spec(plan_kind).or_else(|| cli::resolve_sync_spec(plan_kind))
},
)
.await
}
pub(crate) async fn maybe_build_stream_local_gemini_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
maybe_build_stream_via_standard_family_payload(
state,
parts,
trace_id,
decision,
body_json,
plan_kind,
|plan_kind| {
chat::resolve_stream_spec(plan_kind).or_else(|| cli::resolve_stream_spec(plan_kind))
},
)
.await
}

View File

@@ -0,0 +1,242 @@
use aether_contracts::{ExecutionPlan, RequestBody};
use super::{
augment_sync_report_context, generic_decision_missing_exact_provider_request,
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::provider_transport::ensure_upstream_auth_header;
use crate::gateway::{GatewayControlSyncDecisionResponse, GatewayError};
pub(crate) fn build_gemini_sync_plan_from_decision(
_parts: &http::request::Parts,
_body_json: &serde_json::Value,
payload: GatewayControlSyncDecisionResponse,
) -> Result<Option<LocalSyncPlanAndReport>, GatewayError> {
if generic_decision_missing_exact_provider_request(&payload) {
return Ok(None);
}
let Some(request_id) = payload
.request_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_id) = payload
.provider_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(endpoint_id) = payload
.endpoint_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(key_id) = payload
.key_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(url) = payload
.upstream_url
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let auth_header = payload
.auth_header
.clone()
.filter(|value| !value.trim().is_empty());
let auth_value = payload
.auth_value
.clone()
.filter(|value| !value.trim().is_empty());
if auth_header.is_some() != auth_value.is_some() {
return Ok(None);
}
let Some(provider_api_format) = payload
.provider_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(client_api_format) = payload
.client_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_request_body_value) = payload.provider_request_body.clone() else {
return Ok(None);
};
let mut provider_request_headers = payload.provider_request_headers.clone();
if let (Some(auth_header), Some(auth_value)) = (auth_header.as_deref(), auth_value.as_deref()) {
ensure_upstream_auth_header(&mut provider_request_headers, auth_header, auth_value);
}
if payload.upstream_is_stream {
provider_request_headers
.entry("accept".to_string())
.or_insert_with(|| "text/event-stream".to_string());
}
let plan = ExecutionPlan {
request_id,
candidate_id: payload.candidate_id.clone(),
provider_name: payload.provider_name.clone(),
provider_id,
endpoint_id,
key_id,
method: "POST".to_string(),
url,
headers: std::mem::take(&mut provider_request_headers),
content_type: payload
.content_type
.clone()
.or_else(|| Some("application/json".to_string())),
content_encoding: None,
body: RequestBody::from_json(provider_request_body_value.clone()),
stream: payload.upstream_is_stream,
client_api_format,
provider_api_format,
model_name: payload.model_name.clone(),
proxy: payload.proxy.clone(),
tls_profile: payload.tls_profile.clone(),
timeouts: payload.timeouts.clone(),
};
let report_context = augment_sync_report_context(
payload.report_context,
&plan.headers,
&provider_request_body_value,
)?;
Ok(Some(LocalSyncPlanAndReport {
plan,
report_kind: payload.report_kind,
report_context,
}))
}
pub(crate) fn build_gemini_stream_plan_from_decision(
_parts: &http::request::Parts,
_body_json: &serde_json::Value,
payload: GatewayControlSyncDecisionResponse,
) -> Result<Option<LocalStreamPlanAndReport>, GatewayError> {
if generic_decision_missing_exact_provider_request(&payload) {
return Ok(None);
}
let Some(request_id) = payload
.request_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_id) = payload
.provider_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(endpoint_id) = payload
.endpoint_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(key_id) = payload
.key_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(url) = payload
.upstream_url
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let auth_header = payload
.auth_header
.clone()
.filter(|value| !value.trim().is_empty());
let auth_value = payload
.auth_value
.clone()
.filter(|value| !value.trim().is_empty());
if auth_header.is_some() != auth_value.is_some() {
return Ok(None);
}
let Some(provider_api_format) = payload
.provider_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(client_api_format) = payload
.client_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_request_body_value) = payload.provider_request_body.clone() else {
return Ok(None);
};
let mut provider_request_headers = payload.provider_request_headers.clone();
if let (Some(auth_header), Some(auth_value)) = (auth_header.as_deref(), auth_value.as_deref()) {
ensure_upstream_auth_header(&mut provider_request_headers, auth_header, auth_value);
}
provider_request_headers.insert("accept".to_string(), "text/event-stream".to_string());
let plan = ExecutionPlan {
request_id,
candidate_id: payload.candidate_id.clone(),
provider_name: payload.provider_name.clone(),
provider_id,
endpoint_id,
key_id,
method: "POST".to_string(),
url,
headers: std::mem::take(&mut provider_request_headers),
content_type: payload
.content_type
.clone()
.or_else(|| Some("application/json".to_string())),
content_encoding: None,
body: RequestBody::from_json(provider_request_body_value.clone()),
stream: true,
client_api_format,
provider_api_format,
model_name: payload.model_name.clone(),
proxy: payload.proxy.clone(),
tls_profile: payload.tls_profile.clone(),
timeouts: payload.timeouts.clone(),
};
let report_context = augment_sync_report_context(
payload.report_context,
&plan.headers,
&provider_request_body_value,
)?;
Ok(Some(LocalStreamPlanAndReport {
plan,
report_kind: payload.report_kind,
report_context,
}))
}

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