mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
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:
57
Cargo.lock
generated
57
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
10
Cargo.toml
10
Cargo.toml
@@ -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" }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -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)
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
target/
|
|
||||||
.git/
|
|
||||||
.DS_Store
|
|
||||||
2012
aether-hub/Cargo.lock
generated
2012
aether-hub/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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` 固定版本)。
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
161
apps/aether-gateway/examples/tunnel_runtime_harness.rs
Normal file
161
apps/aether-gateway/examples/tunnel_runtime_harness.rs
Normal 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(())
|
||||||
|
}
|
||||||
165
apps/aether-gateway/src/ai_pipeline/conversion/error.rs
Normal file
165
apps/aether-gateway/src/ai_pipeline/conversion/error.rs
Normal 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/aether-gateway/src/ai_pipeline/conversion/mod.rs
Normal file
17
apps/aether-gateway/src/ai_pipeline/conversion/mod.rs
Normal 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,
|
||||||
|
};
|
||||||
204
apps/aether-gateway/src/ai_pipeline/conversion/registry.rs
Normal file
204
apps/aether-gateway/src/ai_pipeline/conversion/registry.rs
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!({})),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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())),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
38
apps/aether-gateway/src/ai_pipeline/finalize/internal/mod.rs
Normal file
38
apps/aether-gateway/src/ai_pipeline/finalize/internal/mod.rs
Normal 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)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
13
apps/aether-gateway/src/ai_pipeline/finalize/mod.rs
Normal file
13
apps/aether-gateway/src/ai_pipeline/finalize/mod.rs
Normal 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;
|
||||||
@@ -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: ");
|
||||||
@@ -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() {
|
||||||
@@ -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,
|
||||||
@@ -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()),
|
||||||
@@ -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");
|
||||||
|
|
||||||
141
apps/aether-gateway/src/ai_pipeline/finalize/standard/mod.rs
Normal file
141
apps/aether-gateway/src/ai_pipeline/finalize/standard/mod.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
347
apps/aether-gateway/src/ai_pipeline/finalize/standard/stream.rs
Normal file
347
apps/aether-gateway/src/ai_pipeline/finalize/standard/stream.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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\""));
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
6
apps/aether-gateway/src/ai_pipeline/mod.rs
Normal file
6
apps/aether-gateway/src/ai_pipeline/mod.rs
Normal 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;
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
57
apps/aether-gateway/src/ai_pipeline/planner/common.rs
Normal file
57
apps/aether-gateway/src/ai_pipeline/planner/common.rs
Normal 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)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
162
apps/aether-gateway/src/ai_pipeline/planner/contracts.rs
Normal file
162
apps/aether-gateway/src/ai_pipeline/planner/contracts.rs
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/aether-gateway/src/ai_pipeline/planner/decision/mod.rs
Normal file
22
apps/aether-gateway/src/ai_pipeline/planner/decision/mod.rs
Normal 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,
|
||||||
|
};
|
||||||
156
apps/aether-gateway/src/ai_pipeline/planner/decision/stream.rs
Normal file
156
apps/aether-gateway/src/ai_pipeline/planner/decision/stream.rs
Normal 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(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
222
apps/aether-gateway/src/ai_pipeline/planner/decision/sync.rs
Normal file
222
apps/aether-gateway/src/ai_pipeline/planner/decision/sync.rs
Normal 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)
|
||||||
|
}
|
||||||
132
apps/aether-gateway/src/ai_pipeline/planner/family_core.rs
Normal file
132
apps/aether-gateway/src/ai_pipeline/planner/family_core.rs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/aether-gateway/src/ai_pipeline/planner/local_path.rs
Normal file
25
apps/aether-gateway/src/ai_pipeline/planner/local_path.rs
Normal 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
|
||||||
|
}
|
||||||
134
apps/aether-gateway/src/ai_pipeline/planner/mod.rs
Normal file
134
apps/aether-gateway/src/ai_pipeline/planner/mod.rs
Normal 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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
65
apps/aether-gateway/src/ai_pipeline/planner/plan_builders.rs
Normal file
65
apps/aether-gateway/src/ai_pipeline/planner/plan_builders.rs
Normal 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)))
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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());
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user