Merge remote-tracking branch 'origin/main'

This commit is contained in:
fawney
2026-09-02 22:08:36 +08:00
461 changed files with 82784 additions and 3546 deletions
+1 -1
View File
@@ -72,7 +72,7 @@ ADMIN_USERNAME=admin123456
# docker compose 下 app 启动前自动执行 pending migration/backfill(默认 true
# AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
# PostgreSQL 连接池配置(默认按 CPU 自动计算;正式高并发环境可显式预算)
# PostgreSQL 连接池配置(默认每核 4 条、总池至少 32 条且最多 100 条;多实例部署应显式分配每实例预算)
# AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS=12
# AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS=80
# AETHER_GATEWAY_MAX_IN_FLIGHT_REQUESTS=2048
+613
View File
@@ -0,0 +1,613 @@
name: Nightly Release
on:
# 02:17 Asia/Shanghai (18:17 UTC) every day.
schedule:
- cron: '17 18 * * *'
workflow_dispatch:
# Checks and builds only need read access. Publishing jobs opt into write access
# below so a failed build cannot modify the existing nightly release.
permissions:
actions: read
contents: read
# A rolling tag and image are shared by scheduled and manually retried runs.
# Keep GitHub Release immutability disabled for this repository: the tag and
# assets intentionally move after each successful daily build.
concurrency:
group: nightly-main
cancel-in-progress: false
env:
CARGO_INCREMENTAL: '0'
CARGO_PROFILE_DEV_DEBUG: '0'
CARGO_PROFILE_TEST_DEBUG: '0'
CARGO_TERM_COLOR: always
RUST_BACKTRACE: '1'
GHCR_IMAGE: ghcr.io/fawney19/aether
jobs:
source:
name: Resolve main snapshot
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
sha: ${{ steps.snapshot.outputs.sha }}
short_sha: ${{ steps.snapshot.outputs.short_sha }}
date: ${{ steps.snapshot.outputs.date }}
steps:
- name: Require main branch
id: snapshot
shell: bash
run: |
set -euo pipefail
if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then
echo "Nightly releases must run from refs/heads/main (got ${GITHUB_REF})." >&2
exit 1
fi
sha="${GITHUB_SHA}"
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
echo "short_sha=${sha:0:7}" >> "${GITHUB_OUTPUT}"
echo "date=$(date -u +'%Y-%m-%d')" >> "${GITHUB_OUTPUT}"
echo "Building main at ${sha}."
# Keep the scheduled backend coverage in one place so it cannot drift from PR CI.
rust_ci:
name: Rust CI
needs: source
uses: ./.github/workflows/rust-ci.yml
rust_extended:
name: Rust extended checks
needs: source
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.source.outputs.sha }}
- name: Install pinned Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
- name: Show Rust toolchain
run: rustc -Vv
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: nightly-rust-1.95-${{ runner.os }}
workspaces: . -> target
- name: Setup sccache
uses: mozilla-actions/sccache-action@v0.0.9
- name: Check all workspace targets
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: 'true'
run: cargo check --workspace --all-targets --all-features --locked
- name: Run workspace doctests
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: 'true'
run: cargo test --workspace --all-features --doc --locked
- name: Show sccache stats
if: always()
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: 'true'
run: sccache --show-stats
frontend:
name: Frontend checks and build
needs: source
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.source.outputs.sha }}
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '22'
cache: npm
cache-dependency-path: |
frontend/package-lock.json
aether-vscodex/web/package-lock.json
# The frontend prebuild synchronizes the embedded VSCodex UI by running
# its build from a separate package. Install that package explicitly so
# vue-tsc can resolve vite/client, vitest/globals, and node types in a
# clean runner.
- name: Install VSCodex web dependencies
working-directory: aether-vscodex/web
run: npm ci
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Lint
working-directory: frontend
run: npx --no-install eslint .
- name: Type-check
working-directory: frontend
run: npm run type-check
- name: Run unit tests
working-directory: frontend
run: npm run test:run
- name: Build nightly frontend
working-directory: frontend
env:
AETHER_BUILD_VERSION: nightly-${{ needs.source.outputs.short_sha }}
AETHER_VERSION: nightly
run: npm run build
- name: Upload frontend artifact
uses: actions/upload-artifact@v5
with:
name: nightly-frontend-dist
path: frontend/dist/
if-no-files-found: error
overwrite: true
retention-days: 7
repository_health:
name: Repository health checks
needs: source
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.source.outputs.sha }}
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '22'
- name: Check generated format coverage matrix
run: python3 docs/api/generate_format_field_coverage.py --check
- name: Test pressure report checker
run: node --test tools/pressure/check_gateway_stage_report.test.js
checks:
name: Nightly check gate
runs-on: ubuntu-latest
if: ${{ always() }}
needs:
- source
- rust_ci
- rust_extended
- frontend
- repository_health
steps:
- name: Verify check jobs
shell: bash
run: |
set -euo pipefail
failed=0
echo "source=${{ needs.source.result }}"
echo "rust_ci=${{ needs.rust_ci.result }}"
echo "rust_extended=${{ needs.rust_extended.result }}"
echo "frontend=${{ needs.frontend.result }}"
echo "repository_health=${{ needs.repository_health.result }}"
for result in \
"${{ needs.source.result }}" \
"${{ needs.rust_ci.result }}" \
"${{ needs.rust_extended.result }}" \
"${{ needs.frontend.result }}" \
"${{ needs.repository_health.result }}"; do
if [[ "${result}" != "success" ]]; then
failed=1
fi
done
if [[ "${failed}" -ne 0 ]]; then
echo 'One or more nightly checks failed or were cancelled.' >&2
exit 1
fi
build:
name: Build ${{ matrix.name }}
needs: [source, checks]
if: ${{ needs.checks.result == 'success' }}
runs-on: ${{ matrix.os }}
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
include:
- name: linux-amd64
target: x86_64-unknown-linux-musl
platform: linux
arch: amd64
os: ubuntu-latest
use_cross: true
- name: linux-arm64
target: aarch64-unknown-linux-musl
platform: linux
arch: arm64
os: ubuntu-latest
use_cross: true
- name: macos-amd64
target: x86_64-apple-darwin
platform: macos
arch: amd64
os: macos-15-intel
use_cross: false
- name: macos-arm64
target: aarch64-apple-darwin
platform: macos
arch: arm64
os: macos-15
use_cross: false
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.source.outputs.sha }}
- name: Install pinned Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
targets: ${{ matrix.target }}
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: nightly-release-${{ matrix.target }}
workspaces: . -> target
- name: Install cross
if: matrix.use_cross
uses: taiki-e/install-action@cross
- name: Build release binary
env:
AETHER_BUILD_VERSION: nightly-${{ needs.source.outputs.short_sha }}
AETHER_VERSION: nightly
AETHER_BUILD_TYPE: release
CARGO_TERM_COLOR: always
shell: bash
run: |
if [[ "${{ matrix.use_cross }}" == "true" ]]; then
cross build --release --locked -p aether-gateway --target "${{ matrix.target }}"
else
cargo build --release --locked -p aether-gateway --target "${{ matrix.target }}"
fi
- name: Upload binary artifact
uses: actions/upload-artifact@v5
with:
name: nightly-gateway-${{ matrix.platform }}-${{ matrix.arch }}
path: target/${{ matrix.target }}/release/aether-gateway
if-no-files-found: error
overwrite: true
retention-days: 7
docker:
name: Publish nightly GHCR image
needs: [source, checks, build]
if: ${{ needs.checks.result == 'success' && needs.build.result == 'success' }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
packages: write
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.source.outputs.sha }}
- name: Download Linux binaries and frontend
uses: actions/download-artifact@v5
with:
pattern: nightly-*
path: artifacts
merge-multiple: false
- name: Prepare Docker build context
shell: bash
run: |
set -euo pipefail
mkdir -p dist/frontend
cp artifacts/nightly-gateway-linux-amd64/aether-gateway dist/aether-gateway-amd64
cp artifacts/nightly-gateway-linux-arm64/aether-gateway dist/aether-gateway-arm64
chmod 0755 dist/aether-gateway-amd64 dist/aether-gateway-arm64
cp -R artifacts/nightly-frontend-dist/. dist/frontend/
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push nightly image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.app
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ env.GHCR_IMAGE }}:nightly
${{ env.GHCR_IMAGE }}:nightly-${{ needs.source.outputs.sha }}
labels: |
org.opencontainers.image.title=Aether
org.opencontainers.image.version=nightly
org.opencontainers.image.revision=${{ needs.source.outputs.sha }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
package:
name: Package nightly archives
needs: [source, checks, build]
if: ${{ needs.checks.result == 'success' && needs.build.result == 'success' }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.source.outputs.sha }}
- name: Download nightly artifacts
uses: actions/download-artifact@v5
with:
pattern: nightly-*
path: artifacts
merge-multiple: false
- name: Build nightly release packages
shell: bash
env:
SOURCE_REF: ${{ needs.source.outputs.sha }}
run: |
set -euo pipefail
VERSION="nightly"
mkdir -p package release-assets
for platform in linux macos; do
for arch in amd64 arm64; do
bundle="aether-${VERSION}-${platform}-${arch}"
root="package/${bundle}"
mkdir -p "${root}/bin" "${root}/frontend"
install -m 0755 \
"artifacts/nightly-gateway-${platform}-${arch}/aether-gateway" \
"${root}/bin/aether-gateway"
cp -R artifacts/nightly-frontend-dist/. "${root}/frontend/"
sed \
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
install.sh > "${root}/install.sh"
chmod 0755 "${root}/install.sh"
install -m 0755 update.sh "${root}/update.sh"
install -m 0644 docker-compose.yml "${root}/docker-compose.yml"
install -m 0644 docker-compose.single-node.yml "${root}/docker-compose.single-node.yml"
install -m 0644 .env.example "${root}/.env.example"
install -m 0755 generate_keys.sh "${root}/generate_keys.sh"
install -m 0644 README.md "${root}/README.md"
install -m 0644 LICENSE "${root}/LICENSE"
tar -C package -czf "release-assets/${bundle}.tar.gz" "${bundle}"
done
done
sed \
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
install.sh > release-assets/install.sh
chmod 0755 release-assets/install.sh
(cd release-assets && sha256sum *.tar.gz > SHA256SUMS)
test "$(find release-assets -maxdepth 1 -name '*.tar.gz' | wc -l)" -eq 4
test "$(wc -l < release-assets/SHA256SUMS)" -eq 4
(cd release-assets && sha256sum -c SHA256SUMS)
for archive in release-assets/*.tar.gz; do
tar -tzf "${archive}" >/dev/null
done
- name: Upload nightly package artifact
uses: actions/upload-artifact@v5
with:
name: nightly-release-assets
path: release-assets/*
if-no-files-found: error
overwrite: true
retention-days: 7
github_release:
name: Publish nightly GitHub Release
needs: [source, checks, docker, package]
if: ${{ needs.checks.result == 'success' && needs.docker.result == 'success' && needs.package.result == 'success' }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: write
steps:
- name: Download nightly package artifact
uses: actions/download-artifact@v5
with:
name: nightly-release-assets
path: release-assets
- name: Update rolling nightly release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
RELEASE_TAG: nightly
SOURCE_SHA: ${{ needs.source.outputs.sha }}
SOURCE_SHORT_SHA: ${{ needs.source.outputs.short_sha }}
RELEASE_DATE: ${{ needs.source.outputs.date }}
run: |
set -euo pipefail
release_title="Aether Nightly ${RELEASE_DATE} (${SOURCE_SHORT_SHA})"
notes_file="${RUNNER_TEMP}/nightly-release-notes.md"
cat > "${notes_file}" <<EOF
## Aether nightly
This rolling prerelease was built from [main commit ${SOURCE_SHORT_SHA}](https://github.com/${REPOSITORY}/commit/${SOURCE_SHA}).
- Source branch: main
- Source commit: ${SOURCE_SHA}
- Build date (UTC): ${RELEASE_DATE}
- Container image: ${GHCR_IMAGE}:nightly
- Commit image: ${GHCR_IMAGE}:nightly-${SOURCE_SHA}
The nightly tag and assets are replaced by the next successful daily build.
EOF
# Create a draft on the first run. Later runs repair the same rolling
# release on retry if any upload or metadata update is interrupted.
if ! gh release view "${RELEASE_TAG}" --repo "${REPOSITORY}" >/dev/null 2>&1; then
gh release create "${RELEASE_TAG}" \
--repo "${REPOSITORY}" \
--draft \
--prerelease \
--latest=false \
--target "${SOURCE_SHA}" \
--title "${release_title}" \
--notes-file "${notes_file}"
fi
# Upload archives first, then the checksum/installer metadata. This
# keeps a failed upload from leaving a checksum that describes files
# which have not reached the Release yet.
gh release upload "${RELEASE_TAG}" release-assets/*.tar.gz \
--repo "${REPOSITORY}" \
--clobber
gh release upload "${RELEASE_TAG}" \
release-assets/SHA256SUMS \
release-assets/install.sh \
--repo "${REPOSITORY}" \
--clobber
# target_commitish does not move an existing git tag. Move the ref
# only after the complete asset set is available.
if gh api "repos/${REPOSITORY}/git/ref/tags/${RELEASE_TAG}" >/dev/null 2>&1; then
gh api -X PATCH "repos/${REPOSITORY}/git/refs/tags/${RELEASE_TAG}" \
-f "sha=${SOURCE_SHA}" \
-F 'force=true' >/dev/null
else
gh api -X POST "repos/${REPOSITORY}/git/refs" \
-f "ref=refs/tags/${RELEASE_TAG}" \
-f "sha=${SOURCE_SHA}" >/dev/null
fi
gh release edit "${RELEASE_TAG}" \
--repo "${REPOSITORY}" \
--draft=false \
--prerelease \
--latest=false \
--target "${SOURCE_SHA}" \
--title "${release_title}" \
--notes-file "${notes_file}"
expected_assets=(
aether-nightly-linux-amd64.tar.gz
aether-nightly-linux-arm64.tar.gz
aether-nightly-macos-amd64.tar.gz
aether-nightly-macos-arm64.tar.gz
SHA256SUMS
install.sh
)
asset_names="$(gh release view "${RELEASE_TAG}" --repo "${REPOSITORY}" --json assets --jq '.assets[].name')"
for expected_asset in "${expected_assets[@]}"; do
if ! grep -Fxq "${expected_asset}" <<<"${asset_names}"; then
echo "Published release is missing asset ${expected_asset}." >&2
exit 1
fi
done
resolved_sha=""
for attempt in {1..10}; do
resolved_sha="$(gh api "repos/${REPOSITORY}/commits/${RELEASE_TAG}" --jq '.sha' 2>/dev/null || true)"
if [[ "${resolved_sha}" == "${SOURCE_SHA}" ]]; then
break
fi
sleep 2
done
if [[ "${resolved_sha}" != "${SOURCE_SHA}" ]]; then
echo "nightly tag resolved to ${resolved_sha}, expected ${SOURCE_SHA}." >&2
exit 1
fi
release_state="$(gh release view "${RELEASE_TAG}" --repo "${REPOSITORY}" --json isDraft,isPrerelease --jq '[.isDraft, .isPrerelease] | @tsv')"
if [[ "${release_state}" != $'false\ttrue' ]]; then
echo "nightly release has unexpected state: ${release_state}" >&2
exit 1
fi
echo "Published ${RELEASE_TAG} for ${SOURCE_SHA}."
summary:
name: Nightly summary
runs-on: ubuntu-latest
if: ${{ always() }}
needs:
- source
- rust_ci
- rust_extended
- frontend
- repository_health
- checks
- build
- docker
- package
- github_release
steps:
- name: Verify nightly pipeline
shell: bash
run: |
set -euo pipefail
failed=0
for entry in \
"source=${{ needs.source.result }}" \
"rust_ci=${{ needs.rust_ci.result }}" \
"rust_extended=${{ needs.rust_extended.result }}" \
"frontend=${{ needs.frontend.result }}" \
"repository_health=${{ needs.repository_health.result }}" \
"checks=${{ needs.checks.result }}" \
"build=${{ needs.build.result }}" \
"docker=${{ needs.docker.result }}" \
"package=${{ needs.package.result }}" \
"github_release=${{ needs.github_release.result }}"; do
echo "${entry}"
if [[ "${entry#*=}" != "success" ]]; then
failed=1
fi
done
if [[ "${failed}" -ne 0 ]]; then
echo 'Nightly pipeline did not publish a new release.' >&2
exit 1
fi
+11 -5
View File
@@ -1,6 +1,7 @@
name: Rust CI
on:
workflow_call:
push:
branches:
- master
@@ -11,6 +12,7 @@ on:
- "crates/**"
- "apps/**"
- ".github/workflows/rust-ci.yml"
- ".github/workflows/nightly.yml"
pull_request:
paths:
- "Cargo.toml"
@@ -18,11 +20,15 @@ on:
- "crates/**"
- "apps/**"
- ".github/workflows/rust-ci.yml"
- ".github/workflows/nightly.yml"
concurrency:
group: rust-ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
group: rust-ci-${{ github.event_name }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_INCREMENTAL: 0
CARGO_PROFILE_DEV_DEBUG: 0
@@ -200,13 +206,13 @@ jobs:
RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
run: cargo nextest run -p aether-gateway --lib
- name: Test bin
- name: Test bins
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
RUST_MIN_STACK: "16777216"
RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
run: cargo nextest run -p aether-gateway --bin aether-gateway
run: cargo nextest run -p aether-gateway --bins
- name: Show sccache stats
if: always()
@@ -387,11 +393,11 @@ jobs:
- name: Setup sccache
uses: mozilla-actions/sccache-action@v0.0.9
- name: Test scenario binaries
- name: Test scenario binaries and end-to-end suites
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: cargo test -p aether-integration-tests --bins
run: cargo test -p aether-integration-tests --bins --tests
- name: Show sccache stats
if: always()
Generated
+4
View File
@@ -325,6 +325,7 @@ dependencies = [
"axum",
"base64 0.22.1",
"bcrypt",
"brotli",
"bytes",
"chrono",
"chrono-tz",
@@ -346,6 +347,7 @@ dependencies = [
"reqwest",
"rsa",
"rustls 0.23.37",
"semver",
"serde",
"serde_json",
"sha1",
@@ -442,6 +444,7 @@ name = "aether-integration-tests"
version = "0.1.0"
dependencies = [
"aether-contracts",
"aether-crypto",
"aether-data",
"aether-data-contracts",
"aether-gateway",
@@ -458,6 +461,7 @@ dependencies = [
"sqlx",
"tokio",
"tokio-tungstenite 0.28.0",
"uuid",
]
[[package]]
+2 -1
View File
@@ -106,6 +106,7 @@ async-trait = "0.1"
axum = "0.8"
base64 = "0.22"
bcrypt = "0.16"
brotli = "8"
bytes = "1"
cbc = "0.1"
chrono = { version = "0.4", features = ["serde"] }
@@ -135,7 +136,7 @@ tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal
tokio-util = { version = "0.7", features = ["codec", "io-util"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
uuid = { version = "1", features = ["serde", "v4", "v5"] }
uuid = { version = "1", features = ["serde", "v4", "v5", "v7"] }
webpki-roots = "0.26"
wreq = { version = "6.0.0-rc.28", default-features = false, features = ["json", "stream", "socks", "webpki-roots", "ws"] }
wreq-util = "3.0.0-rc.10"
+15 -1
View File
@@ -113,6 +113,18 @@ cd Aether
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash
```
### Nightly(每日 main 构建)
Nightly workflow 每天从 `main` 的固定 commit 构建并发布滚动的 GitHub Release `nightly`,同时推送多架构 GHCR 镜像 `ghcr.io/fawney19/aether:nightly`。Nightly 是预发布版本,适合验证最新代码,不保证与正式版相同的稳定性。滚动 Release 需要仓库保持关闭 GitHub Release immutability。
安装最新 nightlyLinux systemd / macOS launchd + SQLite):
```bash
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash -s -- --channel nightly
```
Docker Compose 用户可在部署目录的 `.env` 中设置 `APP_IMAGE=ghcr.io/fawney19/aether:nightly`,然后运行 `./update.sh` 获取下一次 nightly。二进制方式可重新执行上述安装命令升级;当前管理后台的在线更新列表只跟踪正式版/RC/Beta,不会自动提示下一次 nightly。
## 本地开发
依赖 Docker、Rust toolchain、Node.js 和 make。
@@ -143,12 +155,14 @@ Aether Tunnel 是配套的正向代理节点,部署在海外 VPS 上,为墙
- Embeddings: [OpenAI compatible `POST /v1/embeddings`](docs/api/embeddings.md)
- Rerank: [OpenAI/Jina compatible `POST /v1/rerank`](docs/api/rerank.md)
- Responses WebSocket mode: [protocol and Aether behavior](docs/WebSocket-Mode.md)
- WebSocket probes: [Codex](docs/operations/codex-responses-websocket-probe.md) · [OpenAI Responses](docs/operations/openai-responses-websocket-probe.md)
## 环境变量
- `APP_PORT``aether-gateway` 唯一监听端口,固定绑定 `0.0.0.0:${APP_PORT}`
- `DATABASE_URL`:数据库连接串;SQLite 例如 `sqlite:///opt/aether/data/aether.db`Postgres 例如 `postgresql://postgres:aether@postgres:5432/aether`
- `AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS` / `AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS`:数据库连接池手动覆盖值;未配置时会自动推导,SQLite 固定 `1/1`Postgres/MySQL 按 CPU 核心数计算并默认封顶 `100`
- `AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS` / `AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS`:数据库连接池手动覆盖值;未配置时 SQLite 固定 `1/1`Postgres/MySQL 按每核 `4` 条自动推导,总池范围为 `32-100`。该预算按进程计算,多实例部署应按数据库连接上限显式分配
- `AETHER_GATEWAY_MAX_IN_FLIGHT_REQUESTS`:单实例请求并发上限;未配置时按 CPU 自动推导(基础范围 `512-65536`),低文件描述符预算时会进一步下调
- `AETHER_GATEWAY_REQUEST_BODY_BUFFER_BUDGET_MB`:单实例同时读取和解压请求体的加权内存预算,默认 `256MB`
- `AETHER_GATEWAY_REQUEST_BODY_READ_TIMEOUT_MS`:请求体完整读取超时,默认 `120000ms`
+2
View File
@@ -52,6 +52,7 @@ async-trait.workspace = true
axum = { version = "0.8", features = ["ws"] }
base64.workspace = true
bcrypt.workspace = true
brotli.workspace = true
bytes.workspace = true
chrono.workspace = true
chrono-tz.workspace = true
@@ -75,6 +76,7 @@ rsa = "0.9.10"
rustls.workspace = true
serde.workspace = true
serde_json.workspace = true
semver.workspace = true
sha1 = "0.10"
sha2 = { workspace = true, features = ["oid"] }
socket2.workspace = true
+14 -10
View File
@@ -50,15 +50,16 @@ pub(crate) use aether_ai_formats::api::{
resolve_claude_stream_spec, resolve_claude_sync_spec, resolve_gemini_stream_spec,
resolve_gemini_sync_spec, resolve_local_image_stream_spec, resolve_local_image_sync_spec,
resolve_local_same_format_stream_spec, resolve_local_same_format_sync_spec,
resolve_openai_embedding_sync_spec, sanitize_request_path_and_query, AiControlPlanRequest,
CanonicalContentPart, CanonicalStreamEvent, CanonicalStreamFrame, ClaudeClientEmitter,
ExecutionRuntimeAuthContext, LocalCoreSyncErrorKind, LocalOpenAiImageSpec,
LocalSameFormatProviderFamily, LocalSameFormatProviderSpec, LocalStandardSourceFamily,
LocalStandardSourceMode, LocalStandardSpec, OpenAIChatClientEmitter,
OpenAIResponsesClientEmitter, StreamingStandardTerminalObserver, CLAUDE_CHAT_STREAM_PLAN_KIND,
CLAUDE_CLI_STREAM_PLAN_KIND, EXECUTION_RUNTIME_STREAM_DECISION_ACTION,
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_CHAT_STREAM_PLAN_KIND,
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_EMBEDDING_SYNC_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND,
resolve_openai_embedding_sync_spec, sanitize_request_path_and_query,
sanitize_request_query_string, AiControlPlanRequest, CanonicalContentPart,
CanonicalStreamEvent, CanonicalStreamFrame, ClaudeClientEmitter, ExecutionRuntimeAuthContext,
LocalCoreSyncErrorKind, LocalOpenAiImageSpec, LocalSameFormatProviderFamily,
LocalSameFormatProviderSpec, LocalStandardSourceFamily, LocalStandardSourceMode,
LocalStandardSpec, OpenAIChatClientEmitter, OpenAIResponsesClientEmitter,
StreamingStandardTerminalObserver, CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_EMBEDDING_SYNC_PLAN_KIND,
GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_INTERACTIONS_STREAM_PLAN_KIND,
GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND,
OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND,
OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND,
@@ -68,7 +69,10 @@ pub(crate) use aether_ai_formats::api::{
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
};
pub(crate) use aether_ai_formats::protocol::stream::CanonicalUsage as StreamingCanonicalUsage;
pub(crate) use aether_ai_formats::CODEX_RESPONSES_LITE_HEADER;
/// Codex client identity headers re-exported for out-of-crate probe binaries,
/// which must reach `aether_ai_formats` through this seam.
pub use aether_ai_formats::{CODEX_CLIENT_ORIGINATOR, CODEX_CLIENT_USER_AGENT};
pub(crate) use aether_ai_formats::{CODEX_RESPONSES_LITE_HEADER, UPSTREAM_IS_STREAM_KEY};
pub(crate) fn parse_direct_request_body(
parts: &http::request::Parts,
@@ -0,0 +1,249 @@
use std::sync::{Arc, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use aether_provider_transport::CodexFingerprintConvergenceContext;
use http::{request::Parts, HeaderMap};
use serde_json::Value;
use uuid::Uuid;
use crate::client_session_affinity::codex_request_signals_from_request;
#[derive(Debug, Clone)]
pub(crate) struct CodexFingerprintContextSlot(Arc<OnceLock<CodexFingerprintConvergenceContext>>);
impl Default for CodexFingerprintContextSlot {
fn default() -> Self {
Self(Arc::new(OnceLock::new()))
}
}
impl CodexFingerprintContextSlot {
fn resolve(
&self,
headers: &HeaderMap,
body_json: &Value,
) -> CodexFingerprintConvergenceContext {
self.0
.get_or_init(|| {
build_codex_fingerprint_context(headers, body_json, Uuid::now_v7().to_string())
})
.clone()
}
}
pub(crate) fn resolve_codex_fingerprint_context(
parts: &Parts,
body_json: &Value,
) -> CodexFingerprintConvergenceContext {
if let Some(context) = parts
.extensions
.get::<CodexFingerprintConvergenceContext>()
.cloned()
{
return context;
}
if let Some(slot) = parts.extensions.get::<CodexFingerprintContextSlot>() {
return slot.resolve(&parts.headers, body_json);
}
build_codex_fingerprint_context(&parts.headers, body_json, Uuid::now_v7().to_string())
}
pub(crate) fn install_codex_fingerprint_context_slot(parts: &mut Parts) {
if parts
.extensions
.get::<CodexFingerprintConvergenceContext>()
.is_none()
&& parts
.extensions
.get::<CodexFingerprintContextSlot>()
.is_none()
{
parts
.extensions
.insert(CodexFingerprintContextSlot::default());
}
}
pub(crate) fn ensure_codex_fingerprint_context(
parts: &mut Parts,
body_json: &Value,
) -> CodexFingerprintConvergenceContext {
let context = resolve_codex_fingerprint_context(parts, body_json);
if parts
.extensions
.get::<CodexFingerprintConvergenceContext>()
.is_none()
{
parts.extensions.remove::<CodexFingerprintContextSlot>();
parts.extensions.insert(context.clone());
}
context
}
pub(crate) fn attach_codex_logical_turn_context(
parts: &mut Parts,
body_json: &Value,
logical_turn_id: &str,
) -> CodexFingerprintConvergenceContext {
let context =
build_codex_fingerprint_context(&parts.headers, body_json, logical_turn_id.to_string());
parts.extensions.remove::<CodexFingerprintContextSlot>();
parts.extensions.insert(context.clone());
context
}
pub(crate) fn restore_codex_logical_turn_context(
parts: &mut Parts,
context: &CodexFingerprintConvergenceContext,
) {
parts.extensions.remove::<CodexFingerprintContextSlot>();
parts.extensions.insert(context.clone());
}
fn build_codex_fingerprint_context(
headers: &HeaderMap,
body_json: &Value,
logical_turn_id: String,
) -> CodexFingerprintConvergenceContext {
let signals = codex_request_signals_from_request(headers, Some(body_json));
let mut context =
CodexFingerprintConvergenceContext::new(logical_turn_id, current_unix_millis());
if let Some(turn_id) = signals.turn_id {
context = context.with_original_turn_id(turn_id);
}
if let Some(session_id) = signals.thread_id.or(signals.session_id) {
context = context.with_original_client_session_id(session_id);
}
if let Some(prompt_cache_key) = signals.prompt_cache_key {
context = context.with_original_prompt_cache_key(prompt_cache_key);
}
context
}
fn current_unix_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.try_into()
.unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
use http::HeaderValue;
use serde_json::json;
use super::*;
#[test]
fn request_signals_are_captured_once_for_the_logical_turn() {
let request = http::Request::builder()
.header("thread-id", "header-thread")
.body(())
.expect("request should build");
let (mut parts, _) = request.into_parts();
let body = json!({
"prompt_cache_key": "client-cache",
"client_metadata": {
"turn_id": "client-turn",
"thread_id": "body-thread"
}
});
let context = attach_codex_logical_turn_context(&mut parts, &body, "logical-turn");
assert_eq!(context.logical_turn_id(), "logical-turn");
assert_eq!(context.original_turn_id(), Some("client-turn"));
assert_eq!(context.original_client_session_id(), Some("header-thread"));
assert_eq!(context.original_prompt_cache_key(), Some("client-cache"));
assert_eq!(
parts.extensions.get::<CodexFingerprintConvergenceContext>(),
Some(&context)
);
}
#[test]
fn restored_context_wins_over_retry_request_signals() {
let original = CodexFingerprintConvergenceContext::new("logical-turn", 1234)
.with_original_turn_id("original-turn")
.with_original_client_session_id("original-thread")
.with_original_prompt_cache_key("original-cache");
let request = http::Request::builder()
.body(())
.expect("request should build");
let (mut parts, _) = request.into_parts();
parts
.headers
.insert("thread-id", HeaderValue::from_static("retry-thread"));
restore_codex_logical_turn_context(&mut parts, &original);
let resolved = resolve_codex_fingerprint_context(
&parts,
&json!({
"prompt_cache_key": "retry-cache",
"client_metadata": {"turn_id": "retry-turn"}
}),
);
assert_eq!(resolved, original);
assert_eq!(resolved.turn_started_at_unix_ms(), 1234);
}
#[test]
fn generated_context_is_persisted_for_http_replanning() {
let request = http::Request::builder()
.header("session-id", "client-session")
.body(())
.expect("request should build");
let (mut parts, _) = request.into_parts();
let body = json!({
"prompt_cache_key": "client-cache",
"client_metadata": {"turn_id": "client-turn"}
});
let first = ensure_codex_fingerprint_context(&mut parts, &body);
let second = resolve_codex_fingerprint_context(
&parts,
&json!({
"prompt_cache_key": "retry-cache",
"client_metadata": {"turn_id": "retry-turn"}
}),
);
assert_eq!(second, first);
assert_eq!(second.original_turn_id(), Some("client-turn"));
assert_eq!(second.original_prompt_cache_key(), Some("client-cache"));
}
#[test]
fn installed_slot_reuses_context_across_cloned_parts() {
let request = http::Request::builder()
.body(())
.expect("request should build");
let (mut parts, _) = request.into_parts();
install_codex_fingerprint_context_slot(&mut parts);
let cloned_parts = parts.clone();
let first = resolve_codex_fingerprint_context(
&parts,
&json!({
"prompt_cache_key": "first-cache",
"client_metadata": {"turn_id": "first-turn"}
}),
);
let second = resolve_codex_fingerprint_context(
&cloned_parts,
&json!({
"prompt_cache_key": "second-cache",
"client_metadata": {"turn_id": "second-turn"}
}),
);
assert_eq!(second, first);
assert_eq!(second.original_turn_id(), Some("first-turn"));
assert_eq!(second.original_prompt_cache_key(), Some("first-cache"));
}
}
+10 -5
View File
@@ -1,5 +1,6 @@
mod adaptation;
pub(crate) mod api;
pub(crate) mod codex_context;
mod finalize;
mod planner;
mod pure;
@@ -23,6 +24,7 @@ pub(crate) use self::finalize::internal::{
maybe_build_sync_finalize_outcome, maybe_compile_sync_finalize_response,
SyncToStreamBridgeOutcome,
};
pub(crate) use self::planner::openai_responses_reasoning_replay_policy;
pub(crate) use self::planner::{
apply_local_runtime_candidate_terminal_reason, build_gemini_stream_plan_from_decision,
build_gemini_sync_plan_from_decision, build_local_gemini_files_stream_attempt_source_for_kind,
@@ -52,17 +54,20 @@ pub(crate) use self::planner::{
build_standard_family_sync_plan_and_reports, build_standard_stream_plan_from_decision,
build_standard_sync_plan_from_decision, candidate_auth_channel_skip_reason,
codex_model_capabilities_for_transport, extract_pool_sticky_session_token,
maybe_build_stream_decision_payload, maybe_build_stream_plan_payload,
maybe_build_sync_decision_payload, maybe_build_sync_plan_payload,
planner_is_matching_stream_request, provider_key_pool_score_id, provider_key_pool_score_scope,
read_candidate_transport_snapshot, record_local_runtime_candidate_skip_reason,
maybe_build_pinned_stream_local_same_format_provider_decision_payload,
maybe_build_responses_websocket_decision, maybe_build_stream_decision_payload,
maybe_build_stream_plan_payload, maybe_build_sync_decision_payload,
maybe_build_sync_plan_payload, planner_is_matching_stream_request, provider_key_pool_score_id,
provider_key_pool_score_scope, read_candidate_transport_snapshot,
record_local_runtime_candidate_skip_reason, resolve_provider_chat_pii_redaction,
resolve_tunnel_scheduler_affinity_context, resolve_upstream_is_stream_for_provider,
set_local_openai_chat_execution_exhausted_diagnostic,
set_local_openai_image_execution_exhausted_diagnostic, validate_final_openai_provider_request,
CandidateFailureDiagnostic, CandidateFailureDiagnosticKind, EligibleLocalExecutionCandidate,
GatewayAuthApiKeySnapshot, GatewayProviderTransportSnapshot, LocalExecutionAttemptSource,
LocalExecutionCandidateKind, LocalResolvedOAuthRequestAuth, PlannerAppState,
SkippedLocalExecutionCandidate,
ResponsesWebSocketBodyNormalization, ResponsesWebSocketDecision,
ResponsesWebSocketPinnedCandidate, SkippedLocalExecutionCandidate,
};
pub(crate) use self::pure::*;
pub(crate) use self::response_history::{
@@ -47,13 +47,12 @@ use crate::cache::{
use crate::clock::current_unix_ms;
use crate::dispatch::refs::dispatch_ref_for_local_candidate;
use crate::handlers::shared::provider_pool::admin_provider_pool_config_from_config_value;
use crate::orchestration::{local_attempt_slot_count, ExecutionAttemptIdentity};
use crate::orchestration::{ExecutionAttemptIdentity, POOL_KEY_RETRY_INDEX_STRIDE};
use crate::scheduler::candidate::is_auth_api_key_concurrency_limit_skip_reason;
use crate::scheduler::config::SchedulerSchedulingMode;
use crate::stage_metrics::observe_gateway_stage_ms;
use crate::{AppState, GatewayError};
const POOL_KEY_RETRY_INDEX_STRIDE: u32 = 100;
const AUTH_API_KEY_CONCURRENCY_WAIT_BUDGET: Duration = Duration::from_millis(100);
const AUTH_API_KEY_CONCURRENCY_RETRY_DELAY: Duration = Duration::from_millis(10);
@@ -481,10 +480,6 @@ where
type ExtraData = Value;
type Error = Infallible;
fn attempt_slot_count(&self, candidate: &Self::Candidate) -> u32 {
local_attempt_slot_count(&candidate.transport)
}
fn build_extra_data(&self, candidate: &Self::Candidate) -> Option<Self::ExtraData> {
available_candidate_extra_data_with_dispatch_ref(candidate, &self.build_extra_data)
}
@@ -1610,7 +1605,8 @@ async fn persist_available_local_execution_candidate_at_index<F>(
where
F: Fn(&EligibleLocalExecutionCandidate) -> Option<Value> + Send + Sync,
{
let attempt_slots = local_attempt_slot_count(&candidate.transport).max(1);
// Exactly one attempt is materialized per candidate; same-key retries are
// derived lazily by the attempt loop after a failure.
let extra_data = ai_candidate_extra_data_with_ranking(
available_candidate_base_extra_data_with_dispatch_ref(&candidate, build_extra_data),
candidate.ranking.as_ref(),
@@ -1625,53 +1621,34 @@ where
Some(candidate_index),
extra_data,
);
let should_persist = should_persist_available_local_candidate(&candidate);
let mut attempts = Vec::with_capacity(attempt_slots as usize);
let mut owned_candidate = Some(candidate);
let retry_index = effective_retry_index(0, candidate.orchestration.pool_key_index);
let generated_candidate_id = Uuid::new_v4().to_string();
let candidate_id = if should_persist_available_local_candidate(&candidate) {
state
.persist_available_local_candidate(
trace_id,
context.user_id,
context.api_key_id,
&candidate.candidate,
candidate_index,
retry_index,
generated_candidate_id.as_str(),
context.required_capabilities,
extra_data,
current_unix_ms(),
context.error_context,
)
.await
} else {
generated_candidate_id
};
for retry_index in 0..attempt_slots {
let candidate_ref = owned_candidate
.as_ref()
.expect("candidate should remain available until final retry");
let generated_candidate_id = Uuid::new_v4().to_string();
let candidate_id = if should_persist {
state
.persist_available_local_candidate(
trace_id,
context.user_id,
context.api_key_id,
&candidate_ref.candidate,
candidate_index,
effective_retry_index(retry_index, candidate_ref.orchestration.pool_key_index),
generated_candidate_id.as_str(),
context.required_capabilities,
extra_data.clone(),
current_unix_ms(),
context.error_context,
)
.await
} else {
generated_candidate_id
};
let candidate = if retry_index + 1 == attempt_slots {
owned_candidate
.take()
.expect("final retry should consume owned candidate")
} else {
candidate_ref.clone()
};
let retry_index =
effective_retry_index(retry_index, candidate.orchestration.pool_key_index);
attempts.push(LocalExecutionCandidateAttempt {
eligible: candidate,
candidate_index,
retry_index,
candidate_id,
});
}
attempts
vec![LocalExecutionCandidateAttempt {
eligible: candidate,
candidate_index,
retry_index,
candidate_id,
}]
}
fn available_candidate_extra_data_with_dispatch_ref<F>(
@@ -1840,6 +1817,7 @@ fn routing_trace_for_candidate(
CandidateKind::Provider => Some(candidate.key_id.clone()),
CandidateKind::PoolGroup => None,
},
api_format: Some(candidate.endpoint_api_format.clone()),
provider_priority: candidate.provider_priority,
key_priority: candidate
.key_global_priority_for_format
@@ -1923,32 +1901,15 @@ fn build_unpersisted_local_execution_candidate_attempts(
candidate: EligibleLocalExecutionCandidate,
candidate_index: u32,
) -> VecDeque<LocalExecutionCandidateAttempt> {
let attempt_slots = local_attempt_slot_count(&candidate.transport).max(1);
let mut attempts = VecDeque::with_capacity(attempt_slots as usize);
let mut owned_candidate = Some(candidate);
for retry_index in 0..attempt_slots {
let candidate = if retry_index + 1 == attempt_slots {
owned_candidate
.take()
.expect("final retry should consume owned candidate")
} else {
owned_candidate
.as_ref()
.expect("candidate should remain available until final retry")
.clone()
};
let retry_index =
effective_retry_index(retry_index, candidate.orchestration.pool_key_index);
attempts.push_back(LocalExecutionCandidateAttempt {
eligible: candidate,
candidate_index,
retry_index,
candidate_id: Uuid::new_v4().to_string(),
});
}
attempts
// One attempt per candidate; same-key retries are derived lazily by the
// attempt loop after a failure.
let retry_index = effective_retry_index(0, candidate.orchestration.pool_key_index);
VecDeque::from([LocalExecutionCandidateAttempt {
eligible: candidate,
candidate_index,
retry_index,
candidate_id: Uuid::new_v4().to_string(),
}])
}
async fn persist_pool_group_exhaustion_skipped_candidate(
@@ -2276,6 +2237,8 @@ mod tests {
pool_key_index,
pool_key_lease: None,
scheduler_affinity_epoch: None,
// These tests cover persistence shape, not same-key retries.
sticky_key_attempts: Some(1),
},
ranking: None,
}
@@ -3,7 +3,7 @@ use aether_ai_serving::{
AiCandidateRankingPort, AiRankableCandidateParts, AiRankingContextConfig,
AiRankingSchedulingMode,
};
use aether_routing_core::{ResolvedRoutingPolicy, RoutingSchedulingMode, RoutingSetPriorityMode};
use aether_routing_core::ResolvedRoutingPolicy;
use async_trait::async_trait;
use tokio::sync::Mutex;
use tracing::warn;
@@ -184,35 +184,16 @@ fn ai_ranking_scheduling_mode(mode: SchedulerSchedulingMode) -> AiRankingSchedul
}
}
/// Ordering config for a request. A resolved routing policy is authoritative
/// and is never merged with legacy system-config values; without a policy the
/// effective default (system-default routing group, then legacy keys) applies.
pub(crate) async fn scheduler_ordering_config_for_routing_policy(
state: PlannerAppState<'_>,
routing_policy: Option<&ResolvedRoutingPolicy>,
) -> SchedulerOrderingConfig {
let system_config = read_scheduler_ordering_config_or_default(state).await;
match routing_policy {
Some(policy) => {
let mut config = scheduler_ordering_config_from_routing_policy(policy);
config.keep_priority_on_conversion |= system_config.keep_priority_on_conversion;
config
}
None => system_config,
}
}
fn scheduler_ordering_config_from_routing_policy(
policy: &ResolvedRoutingPolicy,
) -> SchedulerOrderingConfig {
SchedulerOrderingConfig {
priority_mode: match policy.priority_mode {
RoutingSetPriorityMode::Provider => SchedulerPriorityMode::Provider,
RoutingSetPriorityMode::GlobalKey => SchedulerPriorityMode::GlobalKey,
},
scheduling_mode: match policy.scheduling_mode {
RoutingSchedulingMode::FixedOrder => SchedulerSchedulingMode::FixedOrder,
RoutingSchedulingMode::CacheAffinity => SchedulerSchedulingMode::CacheAffinity,
RoutingSchedulingMode::LoadBalance => SchedulerSchedulingMode::LoadBalance,
},
keep_priority_on_conversion: policy.keep_priority_on_conversion,
Some(policy) => SchedulerOrderingConfig::from_routing_policy(policy),
None => read_scheduler_ordering_config_or_default(state).await,
}
}
@@ -231,14 +212,26 @@ fn routing_overlaid_candidate(
let overlaid_key_priority = match kind {
LocalExecutionCandidateKind::SingleKey => policy
.ranking_overlay
.key_priority_overrides
.get(candidate.key_id.as_str()),
.key_priority_override_matching_format(candidate.key_id.as_str(), |format| {
crate::ai_serving::api_format_alias_matches(
format,
candidate.endpoint_api_format.as_str(),
)
})
.or_else(|| {
policy
.ranking_overlay
.key_priority_overrides
.get(candidate.key_id.as_str())
.copied()
}),
LocalExecutionCandidateKind::PoolGroup => policy
.ranking_overlay
.pool_priority_overrides
.get(candidate.provider_id.as_str()),
.get(candidate.provider_id.as_str())
.copied(),
};
if let Some(overlaid_key_priority) = overlaid_key_priority.copied() {
if let Some(overlaid_key_priority) = overlaid_key_priority {
overlaid.key_internal_priority = overlaid_key_priority;
overlaid.key_global_priority_for_format = Some(overlaid_key_priority);
}
@@ -378,6 +371,7 @@ mod tests {
priority_mode: aether_routing_core::RoutingSetPriorityMode::Provider,
scheduling_mode: aether_routing_core::RoutingSchedulingMode::CacheAffinity,
keep_priority_on_conversion: false,
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
ranking_overlay: aether_routing_core::RankingOverlay::default(),
mutation_plan: Default::default(),
pool_policy_overrides: BTreeMap::new(),
@@ -396,7 +390,7 @@ mod tests {
}
#[tokio::test]
async fn routing_policy_inherits_global_conversion_priority_override() {
async fn routing_policy_ignores_legacy_global_conversion_priority_override() {
let data_state = GatewayDataState::default().with_system_config_values_for_tests([(
"keep_priority_on_conversion".to_string(),
json!(true),
@@ -413,6 +407,7 @@ mod tests {
priority_mode: aether_routing_core::RoutingSetPriorityMode::Provider,
scheduling_mode: aether_routing_core::RoutingSchedulingMode::FixedOrder,
keep_priority_on_conversion: false,
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
ranking_overlay: Default::default(),
mutation_plan: Default::default(),
pool_policy_overrides: Default::default(),
@@ -429,7 +424,10 @@ mod tests {
ordering.scheduling_mode,
crate::scheduler::config::SchedulerSchedulingMode::FixedOrder
);
assert!(ordering.keep_priority_on_conversion);
assert!(
!ordering.keep_priority_on_conversion,
"a resolved routing policy must not inherit the legacy system-config flag"
);
}
#[test]
@@ -447,6 +445,7 @@ mod tests {
priority_mode: aether_routing_core::RoutingSetPriorityMode::GlobalKey,
scheduling_mode: aether_routing_core::RoutingSchedulingMode::CacheAffinity,
keep_priority_on_conversion: false,
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
ranking_overlay: aether_routing_core::RankingOverlay {
pool_priority_overrides: BTreeMap::from([("provider-1".to_string(), 4)]),
key_priority_overrides: BTreeMap::from([("representative-key".to_string(), 1)]),
@@ -23,7 +23,9 @@ use crate::ai_serving::{
use crate::orchestration::LocalExecutionCandidateMetadata;
use crate::stage_metrics::observe_gateway_stage_ms;
use super::candidate_ranking::rank_eligible_local_execution_candidates;
use super::candidate_ranking::{
rank_eligible_local_execution_candidates, scheduler_ordering_config_for_routing_policy,
};
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct EligibleLocalExecutionCandidate {
@@ -378,8 +380,18 @@ async fn resolve_and_rank_local_execution_candidates_with_pool_expansion(
"candidate_resolution_core",
started_at.elapsed().as_millis() as u64,
);
let sticky_key_attempts = if outcome.eligible_candidates.is_empty() {
None
} else {
Some(
scheduler_ordering_config_for_routing_policy(state, routing_policy)
.await
.sticky_key_attempts,
)
};
for candidate in &mut outcome.eligible_candidates {
candidate.orchestration.scheduler_affinity_epoch = Some(scheduler_affinity_epoch);
candidate.orchestration.sticky_key_attempts = sticky_key_attempts;
}
(outcome.eligible_candidates, outcome.skipped_candidates)
}
@@ -86,6 +86,52 @@ impl GatewayLocalCandidatePreselectionPort<'_> {
}
}
/// A Responses compaction request carries the OpenAI-only `compaction_trigger`
/// control item. It must stay on an OpenAI Responses endpoint: treating it as
/// an ordinary cross-format request would make Gemini/Claude candidates look
/// eligible and defer the inevitable lossy-conversion failure until payload
/// construction.
fn request_candidate_api_formats_for_operation(
client_api_format: &str,
require_streaming: bool,
request_operation: Option<&str>,
) -> Vec<String> {
let candidate_api_formats =
crate::ai_serving::request_candidate_api_formats(client_api_format, require_streaming)
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>();
restrict_candidate_api_formats_for_operation(
client_api_format,
request_operation,
candidate_api_formats,
)
}
fn restrict_candidate_api_formats_for_operation(
client_api_format: &str,
request_operation: Option<&str>,
candidate_api_formats: Vec<String>,
) -> Vec<String> {
let is_responses_compaction = request_operation.is_some_and(|operation| {
operation.eq_ignore_ascii_case(crate::ai_serving::OPENAI_RESPONSES_OPERATION_COMPACT)
});
let is_standard_responses_client =
crate::ai_serving::normalize_api_format_alias(client_api_format) == "openai:responses";
if !(is_responses_compaction && is_standard_responses_client) {
return candidate_api_formats;
}
candidate_api_formats
.into_iter()
.filter(|candidate_api_format| {
crate::ai_serving::normalize_api_format_alias(candidate_api_format)
== "openai:responses"
})
.collect()
}
#[async_trait]
impl AiCandidatePreselectionPort for GatewayLocalCandidatePreselectionPort<'_> {
type Candidate = SchedulerMinimalCandidateSelectionCandidate;
@@ -128,6 +174,8 @@ impl AiCandidatePreselectionPort for GatewayLocalCandidatePreselectionPort<'_> {
self.ranking_seed,
false,
self.request_operation,
self.routing_policy
.map(SchedulerOrderingConfig::from_routing_policy),
)
.await?;
@@ -219,11 +267,11 @@ pub(crate) async fn preselect_local_execution_candidates_with_serving(
>,
GatewayError,
> {
let candidate_api_formats =
crate::ai_serving::request_candidate_api_formats(client_api_format, require_streaming)
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>();
let candidate_api_formats = request_candidate_api_formats_for_operation(
client_api_format,
require_streaming,
request_operation,
);
preselect_local_execution_candidates_for_api_formats_with_serving(
state,
model_directive_policy,
@@ -264,6 +312,11 @@ pub(crate) async fn preselect_local_execution_candidates_for_api_formats_with_se
>,
GatewayError,
> {
let candidate_api_formats = restrict_candidate_api_formats_for_operation(
client_api_format,
request_operation,
candidate_api_formats,
);
let model_directive_routing_models = resolve_model_directive_routing_models(
model_directive_policy,
&candidate_api_formats,
@@ -362,11 +415,11 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
allow_priority_page_cache: bool,
trace_id: Option<&str>,
) -> Self {
let candidate_api_formats =
crate::ai_serving::request_candidate_api_formats(client_api_format, require_streaming)
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>();
let candidate_api_formats = request_candidate_api_formats_for_operation(
client_api_format,
require_streaming,
request_operation,
);
let model_directive_routing_models = resolve_model_directive_routing_models(
model_directive_policy,
&candidate_api_formats,
@@ -1240,6 +1293,9 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
.then_some(self.client_session_affinity.as_ref())
.flatten(),
self.ranking_seed,
self.routing_policy
.as_ref()
.map(SchedulerOrderingConfig::from_routing_policy),
)
.await?;
let skipped_candidates = skipped_candidates
@@ -1437,6 +1493,31 @@ mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
#[test]
fn compaction_operation_excludes_non_responses_provider_formats() {
assert_eq!(
request_candidate_api_formats_for_operation("openai:responses", true, Some("compact"),),
vec!["openai:responses"]
);
assert_eq!(
request_candidate_api_formats_for_operation("openai:responses", true, None),
vec![
"openai:responses",
"openai:chat",
"claude:messages",
"gemini:generate_content"
]
);
assert_eq!(
request_candidate_api_formats_for_operation(
"openai:responses:compact",
false,
Some("compact"),
),
vec!["openai:responses:compact"]
);
}
#[derive(Default)]
struct EmptyFallbackCountingRepository {
fallback_reads: AtomicUsize,
@@ -1808,6 +1889,7 @@ mod tests {
priority_mode: aether_routing_core::RoutingSetPriorityMode::Provider,
scheduling_mode: aether_routing_core::RoutingSchedulingMode::FixedOrder,
keep_priority_on_conversion: false,
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
ranking_overlay: Default::default(),
mutation_plan: Default::default(),
pool_policy_overrides: Default::default(),
@@ -1871,6 +1953,7 @@ mod tests {
priority_mode: aether_routing_core::RoutingSetPriorityMode::Provider,
scheduling_mode: aether_routing_core::RoutingSchedulingMode::FixedOrder,
keep_priority_on_conversion: false,
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
ranking_overlay: Default::default(),
mutation_plan: Default::default(),
pool_policy_overrides: Default::default(),
@@ -2581,14 +2664,16 @@ mod tests {
candidate_repository,
)
.with_encryption_key_for_tests("development-key")
// Legacy keys deliberately disagree with the routing policy: the
// resolved policy must be the only source of scheduler ordering.
.with_system_config_values_for_tests([
(
"scheduling_mode".to_string(),
serde_json::json!("fixed_order"),
serde_json::json!("cache_affinity"),
),
(
"keep_priority_on_conversion".to_string(),
serde_json::json!(true),
serde_json::json!(false),
),
]);
let app = AppState::new()
@@ -2605,7 +2690,8 @@ mod tests {
resolved_model: "gpt-5.4-mini".to_string(),
priority_mode: aether_routing_core::RoutingSetPriorityMode::Provider,
scheduling_mode: aether_routing_core::RoutingSchedulingMode::FixedOrder,
keep_priority_on_conversion: false,
keep_priority_on_conversion: true,
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
ranking_overlay: Default::default(),
mutation_plan: Default::default(),
pool_policy_overrides: Default::default(),
@@ -13,6 +13,7 @@ use http::{HeaderMap, HeaderName, HeaderValue};
use serde_json::{json, Value};
use crate::ai_serving::planner::common::extract_standard_requested_model;
use crate::ai_serving::transport::CodexFingerprintConvergenceContext;
use crate::ai_serving::{
ClientSurface, ExecutionRuntimeAuthContext, GatewayAuthApiKeySnapshot,
GatewayCredentialCarrier, GatewayProviderTransportSnapshot, PlannerAppState,
@@ -55,6 +56,7 @@ pub(crate) struct LocalRequestedModelDecisionInput {
pub(crate) client_surface: Option<ClientSurface>,
pub(crate) gateway_credential_carrier: Option<GatewayCredentialCarrier>,
pub(crate) client_session_affinity: Option<ClientSessionAffinity>,
pub(crate) codex_fingerprint_context: Option<CodexFingerprintConvergenceContext>,
pub(crate) routing_policy: Option<ResolvedRoutingPolicy>,
pub(crate) routing_trace_seed: Option<RoutingDecisionTrace>,
pub(crate) routing_context: Option<LocalRoutingRequestContext>,
@@ -100,6 +102,22 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision(
input: &LocalRequestedModelDecisionInput,
decision: &mut AiExecutionDecision,
transport: Option<&GatewayProviderTransportSnapshot>,
) -> Result<(), GatewayError> {
apply_provider_request_routing_policy_to_decision_with_websocket_mode(
input, decision, transport, false,
)
}
/// Applies provider-request routing mutations while retaining the transport
/// boundary of a pinned Responses WebSocket continuation. Routing rules may
/// mutate the body and therefore require a second provider-contract pass; the
/// pass must use the same explicit continuation mode as the first pass rather
/// than guessing from JSON fields.
pub(crate) fn apply_provider_request_routing_policy_to_decision_with_websocket_mode(
input: &LocalRequestedModelDecisionInput,
decision: &mut AiExecutionDecision,
transport: Option<&GatewayProviderTransportSnapshot>,
websocket_continuation: bool,
) -> Result<(), GatewayError> {
let provider_api_format = decision
.provider_api_format
@@ -150,6 +168,12 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision(
provider_api_format.as_str(),
);
}
apply_codex_fingerprint_convergence_to_decision(
input,
decision,
transport,
provider_api_format.as_str(),
);
return Ok(());
};
let provider_body_rules = decision
@@ -207,6 +231,12 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision(
provider_api_format.as_str(),
);
}
apply_codex_fingerprint_convergence_to_decision(
input,
decision,
transport,
provider_api_format.as_str(),
);
return Ok(());
}
if original_provider_request_body.is_none() && !policy.mutation_plan.body_patch.is_empty() {
@@ -244,9 +274,8 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision(
input.requested_model.as_str(),
)
});
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities(
&mut provider_request_body,
crate::ai_serving::OpenAiProviderRequestFinalization {
{
let finalization = crate::ai_serving::OpenAiProviderRequestFinalization {
source_api_format: context.client_api_format.as_str(),
provider_api_format: provider_api_format.as_str(),
provider_type: provider_type.as_str(),
@@ -257,9 +286,32 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision(
require_body_stream_field: original_provider_request_body
.as_ref()
.is_some_and(|body| body.get("stream").is_some()),
},
model_capabilities.as_ref(),
)
};
let reasoning_replay_policy = transport
.map(|transport| {
crate::ai_serving::openai_responses_reasoning_replay_policy(
transport.provider.provider_type.as_str(),
transport.endpoint.base_url.as_str(),
provider_model.as_str(),
)
})
.unwrap_or_default();
if websocket_continuation {
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy_for_websocket_continuation(
&mut provider_request_body,
finalization,
model_capabilities.as_ref(),
reasoning_replay_policy,
)
} else {
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy(
&mut provider_request_body,
finalization,
model_capabilities.as_ref(),
reasoning_replay_policy,
)
}
}
.map_err(|violation| GatewayError::Client {
status: StatusCode::BAD_REQUEST,
message: format!("routing provider_request violates provider contract: {violation:?}"),
@@ -305,13 +357,51 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision(
if original_provider_request_body.is_some() {
decision.provider_request_body = Some(provider_request_body);
}
apply_codex_fingerprint_convergence_to_decision(
input,
decision,
transport,
provider_api_format.as_str(),
);
update_report_context_provider_request_mutation(decision, &policy);
Ok(())
}
fn apply_codex_fingerprint_convergence_to_decision(
input: &LocalRequestedModelDecisionInput,
decision: &mut AiExecutionDecision,
transport: Option<&GatewayProviderTransportSnapshot>,
provider_api_format: &str,
) {
let (Some(transport), Some(provider_request_body)) =
(transport, decision.provider_request_body.as_mut())
else {
return;
};
let Some(context) = input.codex_fingerprint_context.as_ref() else {
return;
};
let applied = crate::ai_serving::transport::apply_codex_fingerprint_convergence_with_context(
transport,
provider_api_format,
context,
&mut decision.provider_request_headers,
provider_request_body,
);
if applied {
decision.prompt_cache_key = provider_request_body
.get("prompt_cache_key")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
}
}
struct GatewayAuthenticatedDecisionInputPort<'a> {
state: PlannerAppState<'a>,
now_unix_secs: u64,
auth_snapshot_override: Option<GatewayAuthApiKeySnapshot>,
model_directive_policy: &'a crate::system_features::ModelDirectivePolicySnapshot,
model_directive_base_model: Option<String>,
}
@@ -328,6 +418,17 @@ impl AiAuthenticatedDecisionInputPort for GatewayAuthenticatedDecisionInputPort<
&self,
auth_context: &Self::AuthContext,
) -> Result<Option<Self::AuthSnapshot>, Self::Error> {
if let Some(snapshot) = self.auth_snapshot_override.as_ref() {
if snapshot.user_id != auth_context.user_id
|| snapshot.api_key_id != auth_context.api_key_id
{
return Err(GatewayError::Internal(
"WebSocket auth snapshot identity does not match its control decision"
.to_string(),
));
}
return Ok(Some(snapshot.clone()));
}
self.state
.read_auth_api_key_snapshot(
&auth_context.user_id,
@@ -383,6 +484,7 @@ pub(crate) fn build_local_requested_model_decision_input(
client_surface: None,
gateway_credential_carrier: None,
client_session_affinity: None,
codex_fingerprint_context: None,
routing_policy: None,
routing_trace_seed: None,
routing_context: None,
@@ -397,6 +499,8 @@ pub(crate) async fn attach_routing_policy_to_local_requested_model_input(
body_json: &Value,
client_api_format: &str,
) -> Result<(), GatewayError> {
input.codex_fingerprint_context =
Some(crate::ai_serving::codex_context::resolve_codex_fingerprint_context(parts, body_json));
let explicit_group = routing_header_value_str(&parts.headers, ROUTING_GROUP_HEADER);
let selected_group = match state.routing_group_read_repository() {
Some(repository) => {
@@ -708,6 +812,27 @@ pub(crate) async fn resolve_local_authenticated_decision_input(
requested_model_api_format: Option<&str>,
explicit_required_capabilities: Option<&serde_json::Value>,
model_directive_policy: &crate::system_features::ModelDirectivePolicySnapshot,
) -> Result<Option<ResolvedLocalDecisionAuthInput>, GatewayError> {
resolve_local_authenticated_decision_input_with_snapshot(
state,
auth_context,
None,
requested_model,
requested_model_api_format,
explicit_required_capabilities,
model_directive_policy,
)
.await
}
pub(crate) async fn resolve_local_authenticated_decision_input_with_snapshot(
state: &AppState,
auth_context: ExecutionRuntimeAuthContext,
auth_snapshot_override: Option<GatewayAuthApiKeySnapshot>,
requested_model: Option<&str>,
requested_model_api_format: Option<&str>,
explicit_required_capabilities: Option<&serde_json::Value>,
model_directive_policy: &crate::system_features::ModelDirectivePolicySnapshot,
) -> Result<Option<ResolvedLocalDecisionAuthInput>, GatewayError> {
let model_directive_base_model = match (requested_model, requested_model_api_format) {
(Some(model), Some(api_format)) => model_directive_policy
@@ -719,6 +844,7 @@ pub(crate) async fn resolve_local_authenticated_decision_input(
let port = GatewayAuthenticatedDecisionInputPort {
state: PlannerAppState::new(state),
now_unix_secs: current_unix_secs(),
auth_snapshot_override,
model_directive_policy,
model_directive_base_model,
};
@@ -947,6 +1073,7 @@ fn ensure_report_context_routing_trace(
endpoint_id: decision.endpoint_id.clone().unwrap_or_default(),
model_id,
key_id,
api_format: decision.provider_api_format.clone(),
provider_priority,
key_priority,
},
@@ -1024,6 +1151,52 @@ mod tests {
}
}
#[tokio::test]
async fn explicit_auth_snapshot_override_does_not_fall_back_to_the_planner_cache() {
// AppState::new has no auth snapshot repository. Without the explicit
// override this resolver returns None; a WebSocket strong snapshot must
// therefore be the exact value used to build the planner input.
let state = AppState::new().expect("test state should build");
let mut strong_snapshot = sample_auth_snapshot();
strong_snapshot.api_key_allowed_models = Some(vec!["gpt-live-only".to_string()]);
let resolved = resolve_local_authenticated_decision_input_with_snapshot(
&state,
sample_auth_context(),
Some(strong_snapshot.clone()),
Some("gpt-live-only"),
Some("openai:responses"),
None,
&Default::default(),
)
.await
.expect("snapshot override should resolve")
.expect("the explicit snapshot should replace the missing cached value");
assert_eq!(resolved.auth_snapshot, strong_snapshot);
}
#[tokio::test]
async fn explicit_auth_snapshot_override_rejects_an_identity_mismatch() {
let state = AppState::new().expect("test state should build");
let mut wrong_snapshot = sample_auth_snapshot();
wrong_snapshot.api_key_id = "another-key".to_string();
let error = resolve_local_authenticated_decision_input_with_snapshot(
&state,
sample_auth_context(),
Some(wrong_snapshot),
Some("gpt-live-only"),
Some("openai:responses"),
None,
&Default::default(),
)
.await
.expect_err("a snapshot for another API key must never be injected");
assert!(matches!(error, GatewayError::Internal(message) if message.contains("identity")));
}
#[tokio::test]
async fn explicit_routing_attachment_authorizes_and_caches_per_principal() {
let repository = Arc::new(InMemoryRoutingGroupRepository::default());
@@ -1154,6 +1327,7 @@ mod tests {
client_surface: None,
gateway_credential_carrier: None,
client_session_affinity: None,
codex_fingerprint_context: None,
routing_policy: None,
routing_trace_seed: None,
model_directive_policy: Default::default(),
@@ -1312,6 +1486,57 @@ mod tests {
}
}
fn sample_codex_fingerprint_transport() -> GatewayProviderTransportSnapshot {
let mut transport = sample_codex_transport_with_card();
transport.provider.config = Some(json!({
"codex": {"fingerprint_convergence_enabled": true}
}));
transport.endpoint.api_format = "openai:responses".to_string();
transport.endpoint.endpoint_kind = Some("responses".to_string());
transport.key.api_formats = Some(vec!["openai:responses".to_string()]);
transport.key.decrypted_auth_config =
Some(json!({"account_id": "account-codex-1"}).to_string());
transport
}
fn sample_codex_fingerprint_decision() -> AiExecutionDecision {
let prompt_cache_key = "172c39e6-c0a0-5a70-8b63-e0f8e0d185a3";
let mut decision = sample_decision();
decision.provider_type = Some("codex".to_string());
decision.provider_api_format = Some("openai:responses".to_string());
decision.client_api_format = Some("openai:responses".to_string());
decision.provider_request_headers.extend([
("session-id".to_string(), "spoofed-session".to_string()),
("thread-id".to_string(), "spoofed-thread".to_string()),
(
"x-codex-turn-metadata".to_string(),
json!({
"installation_id": "spoofed-installation",
"session_id": "spoofed-session",
"thread_source": "cli"
})
.to_string(),
),
]);
decision.provider_request_body = Some(json!({
"model": "gpt-5",
"input": [],
"metadata": {},
"prompt_cache_key": prompt_cache_key,
"client_metadata": {
"session_id": "spoofed-session",
"thread_id": "spoofed-thread",
"caller": "sdk",
"x-codex-turn-metadata": json!({
"installation_id": "spoofed-installation",
"session_id": "spoofed-session",
"sandbox": "workspace-write"
}).to_string()
}
}));
decision
}
fn set_provider_request_rules(
input: &mut LocalRequestedModelDecisionInput,
allowed_models: &[&str],
@@ -1351,6 +1576,7 @@ mod tests {
client_surface: None,
gateway_credential_carrier: None,
client_session_affinity: None,
codex_fingerprint_context: None,
routing_policy: None,
routing_trace_seed: None,
model_directive_policy: Default::default(),
@@ -1420,6 +1646,7 @@ mod tests {
client_surface: None,
gateway_credential_carrier: None,
client_session_affinity: None,
codex_fingerprint_context: None,
routing_policy: None,
routing_trace_seed: None,
routing_context: None,
@@ -1488,6 +1715,134 @@ mod tests {
);
}
#[test]
fn codex_fingerprint_convergence_runs_at_every_provider_routing_success_exit() {
let transport = sample_codex_fingerprint_transport();
let mut no_context = sample_decision_input();
no_context.routing_context = None;
let mut empty_mutation = sample_decision_input();
empty_mutation
.routing_context
.as_mut()
.expect("routing context")
.group_config_json = json!({
"allowed_models": ["gpt-5"],
"rules": []
});
let mut with_mutation = sample_decision_input();
for input in [&mut no_context, &mut empty_mutation, &mut with_mutation] {
input.codex_fingerprint_context = Some(
CodexFingerprintConvergenceContext::new(
uuid::Uuid::new_v4().to_string(),
1_756_668_000_000,
)
.with_original_client_session_id("client-session-1".to_string()),
);
}
let mut stable_identity = None;
let mut turn_ids = std::collections::BTreeSet::new();
for (exit_name, input) in [
("no_context", no_context),
("empty_mutation", empty_mutation),
("with_mutation", with_mutation),
] {
let mut decision = sample_codex_fingerprint_decision();
apply_provider_request_routing_policy_to_decision(
&input,
&mut decision,
Some(&transport),
)
.unwrap_or_else(|error| panic!("{exit_name} should converge: {error:?}"));
let session_id = decision.provider_request_headers["session-id"].clone();
let thread_id = decision.provider_request_headers["thread-id"].clone();
let installation_id =
decision.provider_request_headers["x-codex-installation-id"].clone();
let window_id = decision.provider_request_headers["x-codex-window-id"].clone();
assert_eq!(decision.provider_request_headers["session_id"], session_id);
assert_eq!(
decision.provider_request_headers["x-client-request-id"],
thread_id
);
assert_eq!(window_id, format!("{thread_id}:0"));
assert_eq!(
uuid::Uuid::parse_str(&session_id)
.expect("session UUID")
.get_version_num(),
4
);
assert_eq!(
uuid::Uuid::parse_str(&thread_id)
.expect("thread UUID")
.get_version_num(),
4
);
let body = decision
.provider_request_body
.as_ref()
.expect("request body");
assert_eq!(
decision.prompt_cache_key.as_deref(),
body.get("prompt_cache_key").and_then(Value::as_str)
);
assert_eq!(
body["prompt_cache_key"],
"172c39e6-c0a0-5a70-8b63-e0f8e0d185a3"
);
assert_eq!(body["client_metadata"]["session_id"], session_id);
assert_eq!(body["client_metadata"]["thread_id"], thread_id);
assert_eq!(body["client_metadata"]["caller"], "sdk");
assert_eq!(
body["client_metadata"]["x-codex-installation-id"],
installation_id
);
assert_eq!(body["client_metadata"]["x-codex-window-id"], window_id);
let header_metadata: Value =
serde_json::from_str(&decision.provider_request_headers["x-codex-turn-metadata"])
.expect("header turn metadata");
let body_metadata: Value = serde_json::from_str(
body["client_metadata"]["x-codex-turn-metadata"]
.as_str()
.expect("embedded turn metadata"),
)
.expect("embedded turn metadata JSON");
assert_eq!(header_metadata["thread_source"], "cli");
assert_eq!(body_metadata["sandbox"], "workspace-write");
assert_eq!(
header_metadata["turn_id"],
body["client_metadata"]["turn_id"]
);
assert_eq!(body_metadata["turn_id"], body["client_metadata"]["turn_id"]);
assert_eq!(
header_metadata["turn_started_at_unix_ms"],
body_metadata["turn_started_at_unix_ms"]
);
let turn_id = body["client_metadata"]["turn_id"]
.as_str()
.expect("turn ID")
.to_string();
assert_eq!(
uuid::Uuid::parse_str(&turn_id)
.expect("turn UUID")
.get_version_num(),
7
);
turn_ids.insert(turn_id);
let identity = (installation_id, session_id, thread_id);
if let Some(expected) = stable_identity.as_ref() {
assert_eq!(&identity, expected, "identity changed at {exit_name}");
} else {
stable_identity = Some(identity);
}
}
assert_eq!(turn_ids.len(), 3, "each request needs a fresh turn ID");
}
#[test]
fn provider_request_routing_policy_cannot_restore_credentials_or_aether_internal_headers() {
for header_name in [
@@ -1637,6 +1992,7 @@ mod tests {
let body = decision.provider_request_body.as_ref().expect("body");
assert!(body.get("prompt_cache_key").is_none());
assert!(decision.prompt_cache_key.is_none());
assert!(body.get("client_metadata").is_none());
assert!(!decision.provider_request_headers.contains_key("session-id"));
assert!(!decision.provider_request_headers.contains_key("thread-id"));
@@ -38,6 +38,7 @@ pub(crate) use self::common::resolve_upstream_is_stream_for_provider;
pub(crate) use self::passthrough::{
build_local_same_format_stream_attempt_source, build_local_same_format_stream_plan_and_reports,
build_local_same_format_sync_attempt_source, build_local_same_format_sync_plan_and_reports,
maybe_build_pinned_stream_local_same_format_provider_decision_payload,
};
pub(crate) use self::plan_builders::{
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
@@ -49,6 +50,7 @@ pub(crate) use self::plan_builders::{
pub(crate) use self::pool_scores::{
build_provider_key_pool_score_upsert, provider_key_pool_score_id, provider_key_pool_score_scope,
};
pub(crate) use self::redaction::resolve_provider_chat_pii_redaction;
pub(crate) use self::request_gzip::resolve_transport_request_encoding_policy;
pub(crate) use self::route::is_matching_stream_request as planner_is_matching_stream_request;
pub(crate) use self::runtime_miss::{
@@ -80,8 +82,10 @@ pub(crate) use self::standard::{
build_local_stream_plan_and_reports as build_standard_family_stream_plan_and_reports,
build_local_sync_attempt_source as build_standard_family_sync_attempt_source,
build_local_sync_plan_and_reports as build_standard_family_sync_plan_and_reports,
codex_model_capabilities_for_transport, set_local_openai_chat_execution_exhausted_diagnostic,
validate_final_openai_provider_request,
codex_model_capabilities_for_transport, maybe_build_responses_websocket_decision,
openai_responses_reasoning_replay_policy, set_local_openai_chat_execution_exhausted_diagnostic,
validate_final_openai_provider_request, ResponsesWebSocketBodyNormalization,
ResponsesWebSocketDecision, ResponsesWebSocketPinnedCandidate,
};
pub(crate) use self::state::{
GatewayAuthApiKeySnapshot, GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth,
@@ -8,6 +8,7 @@ pub(crate) use self::provider::{
build_local_sync_attempt_source as build_local_same_format_sync_attempt_source,
build_local_sync_plan_and_reports as build_local_same_format_sync_plan_and_reports,
maybe_build_local_same_format_provider_decision_payload_for_candidate,
maybe_build_pinned_stream_local_same_format_provider_decision_payload,
maybe_build_stream_local_same_format_provider_decision_payload,
maybe_build_sync_local_same_format_provider_decision_payload,
};
@@ -69,6 +69,7 @@ pub(crate) use self::family::{
LocalSameFormatProviderFamily, LocalSameFormatProviderSpec,
};
pub(crate) use self::family::{
maybe_build_pinned_stream_local_same_format_provider_decision_payload,
maybe_build_stream_local_same_format_provider_decision_payload,
maybe_build_sync_local_same_format_provider_decision_payload,
};
@@ -89,6 +89,21 @@ pub(crate) async fn maybe_build_stream_local_same_format_provider_decision_paylo
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<AiExecutionDecision>, GatewayError> {
maybe_build_pinned_stream_local_same_format_provider_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind, None,
)
.await
}
pub(crate) async fn maybe_build_pinned_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,
pinned_candidate: Option<(&str, &str, &str)>,
) -> Result<Option<AiExecutionDecision>, GatewayError> {
let Some(spec) = resolve_stream_spec(plan_kind) else {
return Ok(None);
@@ -141,6 +156,18 @@ pub(crate) async fn maybe_build_stream_local_same_format_provider_decision_paylo
)
.await?
{
if pinned_candidate.is_some_and(|(provider_id, endpoint_id, key_id)| {
payload.provider_id.as_deref() != Some(provider_id)
|| payload.endpoint_id.as_deref() != Some(endpoint_id)
|| payload.key_id.as_deref() != Some(key_id)
}) {
crate::orchestration::release_pool_key_lease_from_report_context(
state,
payload.report_context.as_ref(),
)
.await;
continue;
}
return Ok(Some(payload));
}
}
@@ -26,6 +26,7 @@ use crate::ai_serving::{
};
use crate::client_session_affinity::client_session_affinity_from_api_request;
use crate::clock::current_unix_secs;
use crate::scheduler::config::SchedulerOrderingConfig;
use crate::{AppState, GatewayError};
use super::{
@@ -140,6 +141,10 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
current_unix_secs(),
false,
spec.operation.map(|operation| operation.as_str()),
input
.routing_policy
.as_ref()
.map(SchedulerOrderingConfig::from_routing_policy),
)
.await?;
let outcome = materialize_local_execution_candidates_with_serving(
@@ -167,20 +172,21 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
.collect(),
LocalCandidateResolutionMode::Standard,
|eligible| {
let provider_api_format = eligible.provider_api_format.clone();
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
spec_metadata.api_format,
spec_metadata.api_format,
&provider_api_format,
);
Some(build_local_execution_candidate_contract_metadata(
LocalExecutionCandidateMetadataParts {
eligible,
provider_api_format: spec_metadata.api_format,
provider_api_format: provider_api_format.as_str(),
client_api_format: spec_metadata.api_format,
extra_fields: serde_json::Map::new(),
},
execution_strategy,
conversion_mode,
spec_metadata.api_format,
provider_api_format.as_str(),
))
},
|mut skipped_candidate| {
@@ -245,6 +251,10 @@ pub(crate) async fn build_local_same_format_provider_candidate_attempt_source<'a
current_unix_secs(),
false,
spec.operation.map(|operation| operation.as_str()),
input
.routing_policy
.as_ref()
.map(SchedulerOrderingConfig::from_routing_policy),
)
.await?;
@@ -273,20 +283,21 @@ pub(crate) async fn build_local_same_format_provider_candidate_attempt_source<'a
.collect(),
LocalCandidateResolutionMode::Standard,
|eligible| {
let provider_api_format = eligible.provider_api_format.clone();
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
spec_metadata.api_format,
spec_metadata.api_format,
&provider_api_format,
);
Some(build_local_execution_candidate_contract_metadata(
LocalExecutionCandidateMetadataParts {
eligible,
provider_api_format: spec_metadata.api_format,
provider_api_format: provider_api_format.as_str(),
client_api_format: spec_metadata.api_format,
extra_fields: serde_json::Map::new(),
},
execution_strategy,
conversion_mode,
spec_metadata.api_format,
provider_api_format.as_str(),
))
},
|mut skipped_candidate| {
@@ -4,6 +4,7 @@ mod payload;
mod request;
pub(crate) use self::build::{
maybe_build_pinned_stream_local_same_format_provider_decision_payload,
maybe_build_stream_local_same_format_provider_decision_payload,
maybe_build_sync_local_same_format_provider_decision_payload,
};
@@ -55,8 +55,6 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
..
} = &attempt;
let candidate = &eligible.candidate;
let (execution_strategy, conversion_mode) =
ai_local_execution_contract_for_formats(spec_metadata.api_format, spec_metadata.api_format);
let Some(resolved) = resolve_local_same_format_provider_candidate_payload_parts(
state, parts, trace_id, body_json, input, &attempt, spec,
)
@@ -164,6 +162,10 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
}
}
let provider_api_format = resolved.provider_api_format.clone();
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
spec_metadata.api_format,
provider_api_format.as_str(),
);
let effective_headers = input.effective_headers(&parts.headers);
let report_context = append_local_failover_policy_to_value(
append_execution_contract_fields_to_value(
@@ -201,13 +203,17 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
client_session_affinity: input.client_session_affinity.as_ref(),
routing_policy: input.routing_policy.as_ref(),
scheduler_affinity_epoch: eligible.orchestration.scheduler_affinity_epoch,
sticky_key_attempts: eligible.orchestration.sticky_key_attempts,
client_requested_stream: body_json
.get("stream")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
upstream_is_stream: resolved.upstream_is_stream,
has_envelope: resolved.is_kiro || resolved.is_antigravity || resolved.is_gemini_cli,
needs_conversion: false,
needs_conversion: matches!(
conversion_mode,
crate::ai_serving::ConversionMode::Bidirectional
),
extra_fields,
}),
execution_strategy,
@@ -42,7 +42,7 @@ use super::{
};
use crate::ai_serving::planner::standard::{
codex_model_capabilities_for_transport, openai_provider_request_contract_failure_extra_data,
same_format_provider_request_body_failure_extra_data,
openai_responses_reasoning_replay_policy, same_format_provider_request_body_failure_extra_data,
};
pub(crate) fn resolve_same_format_provider_transport_unsupported_reason_for_trace(
@@ -180,12 +180,18 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
}
};
let effective_headers = input.effective_headers(&parts.headers);
let reasoning_replay_policy = openai_responses_reasoning_replay_policy(
prepared.transport.provider.provider_type.as_str(),
prepared.transport.endpoint.base_url.as_str(),
prepared.mapped_model.as_str(),
);
let redaction = resolve_provider_chat_pii_redaction(
state,
parts,
body_json,
&input.auth_context,
spec.api_format,
reasoning_replay_policy,
&attempt.candidate_id,
)
.await?;
@@ -205,6 +211,7 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
prepared.kiro_auth.as_ref(),
prepared.is_claude_code,
false,
reasoning_replay_policy,
)
else {
mark_skipped_local_same_format_provider_candidate_with_extra_data(
@@ -269,7 +276,7 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
source_model,
);
if let Err(violation) =
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities(
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy(
&mut base_provider_request_body,
crate::ai_serving::OpenAiProviderRequestFinalization {
source_api_format: spec.api_format,
@@ -285,6 +292,7 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
),
},
codex_model_capabilities.as_ref(),
reasoning_replay_policy,
)
{
mark_skipped_local_same_format_provider_candidate_with_extra_data(
@@ -3,7 +3,7 @@ use serde_json::Value;
use super::super::LocalSameFormatProviderSpec;
use crate::ai_serving::transport::{
build_same_format_provider_request_body as build_same_format_provider_request_body_impl,
build_same_format_provider_request_body_with_compatibility_report as build_same_format_provider_request_body_with_compatibility_report_impl,
build_same_format_provider_request_body_with_compatibility_report_and_reasoning_replay_policy as build_same_format_provider_request_body_with_compatibility_report_impl,
SameFormatProviderFamily, SameFormatProviderRequestBodyInput,
SameFormatProviderRequestBodyOutput,
};
@@ -50,6 +50,7 @@ pub(crate) fn build_same_format_provider_request_body_with_compatibility_report(
kiro_auth: Option<&crate::ai_serving::transport::kiro::KiroRequestAuth>,
is_claude_code: bool,
enable_model_directives: bool,
reasoning_replay_policy: crate::ai_serving::OpenAiResponsesReasoningReplayPolicy,
) -> Option<SameFormatProviderRequestBodyOutput> {
build_same_format_provider_request_body_with_compatibility_report_impl(
SameFormatProviderRequestBodyInput {
@@ -67,6 +68,7 @@ pub(crate) fn build_same_format_provider_request_body_with_compatibility_report(
is_claude_code,
enable_model_directives,
},
reasoning_replay_policy,
)
}
@@ -61,12 +61,41 @@ pub(crate) fn request_identity_response_encoding_when_redacted(
}
}
/// Removes credential-bearing URL components before attaching an upstream URL
/// to a diagnostic event. Endpoint query parameters remain untouched on the
/// wire, but they can contain API keys or signed tokens and must not reach
/// logs.
pub(crate) fn sanitize_upstream_url_for_log(raw: &str) -> String {
if let Ok(mut url) = url::Url::parse(raw) {
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
return "<invalid-upstream-url>".to_string();
}
let _ = url.set_username("");
let _ = url.set_password(None);
url.set_query(None);
url.set_fragment(None);
return url.to_string();
}
let suffix_offset = raw
.char_indices()
.find_map(|(offset, character)| matches!(character, '?' | '#').then_some(offset))
.unwrap_or(raw.len());
let path = &raw[..suffix_offset];
if path.starts_with('/') && !path.starts_with("//") && !path.contains('@') {
path.to_string()
} else {
"<invalid-upstream-url>".to_string()
}
}
pub(crate) async fn resolve_provider_chat_pii_redaction<'a>(
state: &AppState,
parts: &http::request::Parts,
body_json: &'a Value,
auth_context: &ExecutionRuntimeAuthContext,
client_api_format: &str,
reasoning_replay_policy: crate::ai_serving::OpenAiResponsesReasoningReplayPolicy,
candidate_id: &str,
) -> Result<ProviderRequestRedaction<'a>, GatewayError> {
let Some(format) = ChatPiiRedactionRequestFormat::from_api_format(client_api_format) else {
@@ -75,7 +104,7 @@ pub(crate) async fn resolve_provider_chat_pii_redaction<'a>(
let Some(slot) = parts.extensions.get::<RedactionSessionSlot>() else {
return Ok(ProviderRequestRedaction::disabled(body_json));
};
let request_cache_key = request_redaction_cache_key(format, body_json);
let request_cache_key = request_redaction_cache_key(format, reasoning_replay_policy, body_json);
if let Some(cached) = slot.cached_request_redaction(&request_cache_key) {
crate::stage_metrics::record_chat_pii_redaction_request_cache_hit();
observe_gateway_stage_ms("chat_pii_redaction_request_cache_hit", 0);
@@ -132,7 +161,7 @@ pub(crate) async fn resolve_provider_chat_pii_redaction<'a>(
body_json,
format,
build_redaction_session_config(hmac_key, &runtime_config, now_unix_secs),
MaskChatRequestOptions::runtime(),
MaskChatRequestOptions::runtime().with_reasoning_replay_policy(reasoning_replay_policy),
Some(&cache),
)
.await
@@ -165,8 +194,16 @@ pub(crate) async fn resolve_provider_chat_pii_redaction<'a>(
})
}
fn request_redaction_cache_key(format: ChatPiiRedactionRequestFormat, body_json: &Value) -> String {
format!("{format:?}:{:p}", body_json)
fn request_redaction_cache_key(
format: ChatPiiRedactionRequestFormat,
reasoning_replay_policy: crate::ai_serving::OpenAiResponsesReasoningReplayPolicy,
body_json: &Value,
) -> String {
// A request may be attempted against providers with different replay
// contracts. Reusing a cached DeepSeek opaque decision for an ordinary
// Responses candidate (or vice versa) would either bypass masking or
// corrupt provider-owned continuation state.
format!("{format:?}:{reasoning_replay_policy:?}:{:p}", body_json)
}
fn provider_redaction_from_cached<'a>(
@@ -216,14 +253,47 @@ async fn resolve_chat_pii_redaction_feature_settings(
}
fn redaction_mask_error_to_gateway_error(error: RedactionMaskError) -> GatewayError {
match error {}
match error {
RedactionMaskError::SensitiveOpaqueReasoningState => {
warn!("gateway rejected provider-bound reasoning state containing sensitive text");
GatewayError::Client {
status: http::StatusCode::BAD_REQUEST,
message: "provider-bound reasoning state contains sensitive text and cannot be safely replayed while chat PII redaction is enabled".to_string(),
}
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::ChatPiiRedactionFeatureSettings;
use super::{sanitize_upstream_url_for_log, ChatPiiRedactionFeatureSettings};
#[test]
fn upstream_url_log_projection_removes_all_credential_carriers() {
assert_eq!(
sanitize_upstream_url_for_log(
"https://user:password@api.example.test/v1/responses?api-version=2026-08-01&token=secret#fragment"
),
"https://api.example.test/v1/responses"
);
assert_eq!(
sanitize_upstream_url_for_log("/v1/responses?key=secret#fragment"),
"/v1/responses"
);
for invalid in [
"https://user:secret@invalid host/v1/responses",
"//user:secret@api.example.test/v1/responses?token=hidden",
"not-a-url?token=hidden",
"data:text/plain,secret",
] {
assert_eq!(
sanitize_upstream_url_for_log(invalid),
"<invalid-upstream-url>"
);
}
}
#[test]
fn chat_pii_redaction_feature_settings_only_control_enablement() {
@@ -4,7 +4,7 @@ use aether_ai_serving::{
build_ai_execution_report_context,
insert_provider_stream_event_api_format as insert_ai_provider_stream_event_api_format,
provider_stream_event_api_format_for_provider_type as ai_provider_stream_event_api_format_for_provider_type,
AiExecutionReportContextParts, AiRequestOrigin,
AiExecutionReportContextParts, AiRequestOrigin, STICKY_KEY_ATTEMPTS_REPORT_FIELD,
};
use aether_routing_core::ResolvedRoutingPolicy;
use aether_runtime_state::RuntimeLockLease;
@@ -59,6 +59,9 @@ pub(crate) struct LocalExecutionReportContextParts<'a> {
pub(crate) client_session_affinity: Option<&'a ClientSessionAffinity>,
pub(crate) routing_policy: Option<&'a ResolvedRoutingPolicy>,
pub(crate) scheduler_affinity_epoch: Option<u64>,
/// Routing policy sticky-key attempt budget; read back by the attempt
/// loop to derive same-key retries lazily.
pub(crate) sticky_key_attempts: Option<u32>,
pub(crate) client_requested_stream: bool,
pub(crate) upstream_is_stream: bool,
pub(crate) has_envelope: bool,
@@ -124,6 +127,12 @@ pub(crate) fn build_local_execution_report_context(
Value::Number(epoch.into()),
);
}
if let Some(sticky_key_attempts) = parts.sticky_key_attempts {
extra_fields.insert(
STICKY_KEY_ATTEMPTS_REPORT_FIELD.to_string(),
Value::Number(sticky_key_attempts.into()),
);
}
insert_request_path_fields(
&mut extra_fields,
parts.request_path,
@@ -330,6 +339,7 @@ mod tests {
client_session_affinity: Some(&client_session_affinity),
routing_policy: None,
scheduler_affinity_epoch: None,
sticky_key_attempts: None,
client_requested_stream: false,
upstream_is_stream: false,
has_envelope: false,
@@ -413,6 +423,7 @@ mod tests {
client_session_affinity: None,
routing_policy: None,
scheduler_affinity_epoch: None,
sticky_key_attempts: None,
client_requested_stream: false,
upstream_is_stream: true,
has_envelope: false,
@@ -480,6 +491,7 @@ mod tests {
client_session_affinity: None,
routing_policy: None,
scheduler_affinity_epoch: None,
sticky_key_attempts: None,
client_requested_stream: false,
upstream_is_stream: false,
has_envelope: false,
@@ -109,6 +109,7 @@ pub(super) async fn maybe_build_local_gemini_files_decision_payload_for_candidat
client_session_affinity: input.client_session_affinity.as_ref(),
routing_policy: input.routing_policy.as_ref(),
scheduler_affinity_epoch: eligible.orchestration.scheduler_affinity_epoch,
sticky_key_attempts: eligible.orchestration.sticky_key_attempts,
client_requested_stream: spec_metadata.require_streaming,
upstream_is_stream: spec_metadata.require_streaming,
has_envelope: false,
@@ -26,6 +26,7 @@ use crate::ai_serving::{
};
use crate::client_session_affinity::client_session_affinity_from_parts;
use crate::clock::current_unix_secs;
use crate::scheduler::config::SchedulerOrderingConfig;
use crate::{AppState, GatewayError};
pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalGeminiFilesCandidateAttempt;
@@ -108,6 +109,10 @@ pub(super) async fn materialize_local_gemini_files_candidate_attempts(
Some(&input.auth_snapshot),
input.client_session_affinity.as_ref(),
current_unix_secs(),
input
.routing_policy
.as_ref()
.map(SchedulerOrderingConfig::from_routing_policy),
)
.await?;
let outcome = materialize_local_execution_candidates_with_serving(
@@ -181,6 +186,10 @@ pub(super) async fn build_local_gemini_files_candidate_attempt_source<'a>(
Some(&input.auth_snapshot),
input.client_session_affinity.as_ref(),
current_unix_secs(),
input
.routing_policy
.as_ref()
.map(SchedulerOrderingConfig::from_routing_policy),
)
.await?;
Ok(build_local_execution_candidate_attempt_source_with_serving(
@@ -122,6 +122,7 @@ pub(super) async fn maybe_build_local_openai_image_decision_payload_for_candidat
client_session_affinity: input.client_session_affinity.as_ref(),
routing_policy: input.routing_policy.as_ref(),
scheduler_affinity_epoch: eligible.orchestration.scheduler_affinity_epoch,
sticky_key_attempts: eligible.orchestration.sticky_key_attempts,
client_requested_stream: spec_metadata.require_streaming,
upstream_is_stream,
has_envelope: false,
@@ -27,6 +27,7 @@ use crate::ai_serving::{
};
use crate::client_session_affinity::client_session_affinity_from_parts;
use crate::clock::current_unix_secs;
use crate::scheduler::config::SchedulerOrderingConfig;
use crate::{AppState, GatewayError};
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
@@ -127,6 +128,10 @@ pub(super) async fn list_local_openai_image_candidate_attempts(
input.client_session_affinity.as_ref(),
current_unix_secs(),
false,
input
.routing_policy
.as_ref()
.map(SchedulerOrderingConfig::from_routing_policy),
)
.await
{
@@ -201,6 +206,10 @@ pub(super) async fn build_local_openai_image_candidate_attempt_source<'a>(
input.client_session_affinity.as_ref(),
current_unix_secs(),
false,
input
.routing_policy
.as_ref()
.map(SchedulerOrderingConfig::from_routing_policy),
)
.await
{
@@ -90,6 +90,7 @@ pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidat
client_session_affinity: input.client_session_affinity.as_ref(),
routing_policy: input.routing_policy.as_ref(),
scheduler_affinity_epoch: eligible.orchestration.scheduler_affinity_epoch,
sticky_key_attempts: eligible.orchestration.sticky_key_attempts,
client_requested_stream: false,
upstream_is_stream: false,
has_envelope: false,
@@ -29,6 +29,7 @@ use crate::ai_serving::{
};
use crate::client_session_affinity::client_session_affinity_from_parts;
use crate::clock::current_unix_secs;
use crate::scheduler::config::SchedulerOrderingConfig;
use crate::{AppState, GatewayError};
pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalVideoCreateCandidateAttempt;
@@ -133,6 +134,10 @@ pub(super) async fn list_local_video_create_candidate_attempts(
input.client_session_affinity.as_ref(),
current_unix_secs(),
false,
input
.routing_policy
.as_ref()
.map(SchedulerOrderingConfig::from_routing_policy),
)
.await
{
@@ -190,6 +195,10 @@ pub(super) async fn build_local_video_create_candidate_attempt_source<'a>(
input.client_session_affinity.as_ref(),
current_unix_secs(),
false,
input
.routing_policy
.as_ref()
.map(SchedulerOrderingConfig::from_routing_policy),
)
.await
{
@@ -9,10 +9,37 @@ pub(crate) fn is_deepseek_provider(provider_type: &str, base_url: &str) -> bool
return true;
}
let host = base_url_host(base_url);
let Some(host) = base_url_host(base_url) else {
return false;
};
host == "deepseek.com" || host.ends_with(".deepseek.com")
}
fn is_deepseek_model(provider_model: &str) -> bool {
let provider_model = provider_model.trim().to_ascii_lowercase();
let leaf = provider_model
.rsplit(['/', ':'])
.next()
.unwrap_or(provider_model.as_str());
leaf == "deepseek" || leaf.starts_with("deepseek-") || leaf.starts_with("deepseek_")
}
fn is_deepseek_upstream(provider_type: &str, base_url: &str, provider_model: &str) -> bool {
is_deepseek_provider(provider_type, base_url) || is_deepseek_model(provider_model)
}
pub(crate) fn openai_responses_reasoning_replay_policy(
provider_type: &str,
base_url: &str,
provider_model: &str,
) -> crate::ai_serving::OpenAiResponsesReasoningReplayPolicy {
if is_deepseek_upstream(provider_type, base_url, provider_model) {
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque
} else {
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds
}
}
pub(crate) fn apply_deepseek_tool_call_thinking_compat(
provider_request_body: &mut Value,
provider_type: &str,
@@ -20,7 +47,11 @@ pub(crate) fn apply_deepseek_tool_call_thinking_compat(
provider_api_format: &str,
original_request_body: Option<&Value>,
) {
if !is_deepseek_provider(provider_type, base_url) {
let provider_model = provider_request_body
.get("model")
.and_then(Value::as_str)
.unwrap_or_default();
if !is_deepseek_upstream(provider_type, base_url, provider_model) {
return;
}
@@ -36,24 +67,33 @@ pub(crate) fn apply_deepseek_tool_call_thinking_compat(
}
}
fn base_url_host(base_url: &str) -> String {
let lower = base_url.trim().to_ascii_lowercase();
let without_scheme = lower
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(lower.as_str());
let without_userinfo = without_scheme
.rsplit_once('@')
.map(|(_, host)| host)
.unwrap_or(without_scheme);
without_userinfo
.split(['/', '?', '#'])
.next()
.unwrap_or_default()
.split(':')
.next()
.unwrap_or_default()
.to_string()
fn base_url_host(base_url: &str) -> Option<String> {
let base_url = base_url.trim();
if base_url.is_empty() {
return None;
}
// Provider configuration historically accepted both absolute URLs and a
// bare authority/path. Use a real URL parser for both forms: hand-parsing
// userinfo with `rsplit_once('@')` can mistake an `@` in the path or query
// for the authority delimiter and classify an attacker-controlled host as
// `api.deepseek.com`.
if let Ok(parsed) = url::Url::parse(base_url) {
if let Some(host) = parsed
.host_str()
.filter(|_| matches!(parsed.scheme(), "http" | "https" | "ws" | "wss"))
{
return Some(host.to_ascii_lowercase());
}
if base_url.contains("://") {
return None;
}
}
url::Url::parse(&format!("https://{base_url}"))
.ok()?
.host_str()
.map(str::to_ascii_lowercase)
}
fn source_disables_thinking(
@@ -241,7 +281,10 @@ fn is_claude_thinking_block(block: &Value) -> bool {
mod tests {
use serde_json::json;
use super::{apply_deepseek_tool_call_thinking_compat, is_deepseek_provider};
use super::{
apply_deepseek_tool_call_thinking_compat, is_deepseek_provider,
openai_responses_reasoning_replay_policy,
};
#[test]
fn detects_deepseek_provider_by_type_or_host() {
@@ -253,10 +296,151 @@ mod tests {
"custom",
"https://api.deepseek.com/v1"
));
assert!(is_deepseek_provider("custom", "api.deepseek.com/v1"));
assert!(is_deepseek_provider("custom", "api.deepseek.com:443/v1"));
assert!(!is_deepseek_provider(
"custom",
"https://example.com/deepseek"
));
assert!(!is_deepseek_provider(
"custom",
"https://api.deepseek.com.evil.example/v1"
));
assert!(!is_deepseek_provider(
"custom",
"https://api.deepseek.com@evil.example/v1"
));
assert!(!is_deepseek_provider(
"custom",
"https://evil.example/path@api.deepseek.com/v1"
));
assert!(!is_deepseek_provider(
"custom",
"https://evil.example/?relay=@api.deepseek.com"
));
assert!(!is_deepseek_provider("custom", "ftp://api.deepseek.com/v1"));
assert_eq!(
openai_responses_reasoning_replay_policy(
"custom",
"https://api.deepseek.com/v1",
"deepseek-v4-flash",
),
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque
);
assert_eq!(
openai_responses_reasoning_replay_policy(
"openai",
"https://api.openai.com/v1",
"gpt-5.6-sol",
),
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds
);
assert_eq!(
openai_responses_reasoning_replay_policy(
"custom",
"https://api.b.ai/v1",
"deepseek-v4-flash",
),
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque
);
assert_eq!(
openai_responses_reasoning_replay_policy(
"custom",
"https://api.b.ai/v1",
"not-deepseek-compatible",
),
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds
);
}
#[test]
fn custom_deepseek_host_preserves_production_shaped_opaque_reasoning_replay() {
let reasoning_items = (0..66)
.map(|index| {
json!({
"type": "reasoning",
"encrypted_content": format!("550e8400-e29b-41d4-a716-{index:012}"),
"content": [{
"type": "reasoning_text",
"text": format!("opaque DeepSeek reasoning {index}")
}]
})
})
.collect::<Vec<_>>();
let request = json!({
"model": "deepseek-v4-flash",
"input": reasoning_items.clone(),
"future_request_field": {"preserve": true}
});
let replay_policy = openai_responses_reasoning_replay_policy(
"custom",
"https://api.deepseek.com/v1",
"deepseek-v4-flash",
);
let mut provider_body = crate::ai_serving::build_standard_request_body_with_model_directives_and_request_headers_and_reasoning_replay_policy(
&request,
"openai:responses",
"deepseek-v4-flash",
"custom",
"openai:responses",
"/v1/responses",
false,
None,
None,
None,
false,
replay_policy,
)
.expect("custom DeepSeek Responses body should build");
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy(
&mut provider_body,
crate::ai_serving::OpenAiProviderRequestFinalization {
source_api_format: "openai:responses",
provider_api_format: "openai:responses",
provider_type: "custom",
provider_model: "deepseek-v4-flash",
source_model: "deepseek-v4-flash",
body_rules: None,
upstream_is_stream: false,
require_body_stream_field: false,
},
None,
replay_policy,
)
.expect("custom DeepSeek finalization should accept opaque reasoning replay");
assert_eq!(provider_body["input"].as_array().map(Vec::len), Some(66));
assert_eq!(provider_body["future_request_field"]["preserve"], true);
let mut deepseek = json!({"input": reasoning_items.clone()});
let mut openai = json!({"input": reasoning_items});
assert_eq!(
crate::ai_serving::strip_incompatible_openai_responses_reasoning_items_with_policy(
&mut deepseek,
"openai:responses",
openai_responses_reasoning_replay_policy(
"custom",
"https://api.deepseek.com/v1",
"deepseek-v4-flash",
),
),
0
);
assert_eq!(deepseek["input"].as_array().map(Vec::len), Some(66));
assert_eq!(
crate::ai_serving::strip_incompatible_openai_responses_reasoning_items_with_policy(
&mut openai,
"openai:responses",
openai_responses_reasoning_replay_policy(
"openai",
"https://api.openai.com/v1",
"gpt-5.6-sol",
),
),
66
);
assert_eq!(openai["input"].as_array().map(Vec::len), Some(0));
}
#[test]
@@ -286,6 +470,52 @@ mod tests {
assert_eq!(body["messages"][1]["reasoning_content"], "");
}
#[test]
fn custom_relay_deepseek_model_adds_chat_thinking_compat() {
let mut body = json!({
"model": "deepseek-v4-flash",
"messages": [
{"role": "user", "content": "inspect the repository"},
{"role": "assistant", "content": null, "tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "inspect", "arguments": "{}"}
}]},
{"role": "tool", "tool_call_id": "call_1", "content": "done"}
]
});
apply_deepseek_tool_call_thinking_compat(
&mut body,
"custom",
"https://api.b.ai/v1",
"openai:chat",
None,
);
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["messages"][1]["reasoning_content"], "");
}
#[test]
fn custom_relay_non_deepseek_model_is_not_rewritten() {
let original = json!({
"model": "not-deepseek-compatible",
"messages": [{"role": "assistant", "content": "done"}]
});
let mut body = original.clone();
apply_deepseek_tool_call_thinking_compat(
&mut body,
"custom",
"https://api.b.ai/v1",
"openai:chat",
None,
);
assert_eq!(body, original);
}
#[test]
fn openai_chat_deepseek_honors_disabled_thinking() {
let original = json!({"reasoning_effort": "none"});
@@ -142,6 +142,7 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
client_session_affinity: input.client_session_affinity.as_ref(),
routing_policy: input.routing_policy.as_ref(),
scheduler_affinity_epoch: eligible.orchestration.scheduler_affinity_epoch,
sticky_key_attempts: eligible.orchestration.sticky_key_attempts,
client_requested_stream: body_json
.get("stream")
.and_then(serde_json::Value::as_bool)
@@ -377,6 +378,7 @@ mod tests {
client_surface: None,
gateway_credential_carrier: None,
client_session_affinity: None,
codex_fingerprint_context: None,
routing_policy: None,
routing_trace_seed: None,
routing_context: None,
@@ -23,8 +23,8 @@ use crate::ai_serving::planner::spec_metadata::local_standard_spec_metadata;
use crate::ai_serving::planner::standard::{
apply_codex_openai_special_headers, apply_deepseek_tool_call_thinking_compat,
codex_model_capabilities_for_transport, is_deepseek_provider,
openai_provider_request_contract_failure_extra_data, request_body_build_failure_extra_data,
request_conversion_failure_extra_data,
openai_provider_request_contract_failure_extra_data, openai_responses_reasoning_replay_policy,
request_body_build_failure_extra_data, request_conversion_failure_extra_data,
};
use crate::ai_serving::transport::kiro::{
build_kiro_provider_headers, build_kiro_provider_request_body,
@@ -364,6 +364,7 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
body_json,
&input.auth_context,
spec_metadata.api_format,
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds,
&attempt.candidate_id,
)
.await?;
@@ -593,18 +594,24 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
input.auth_context.api_key_id.as_str(),
)
.await?;
let reasoning_replay_policy = openai_responses_reasoning_replay_policy(
transport.provider.provider_type.as_str(),
transport.endpoint.base_url.as_str(),
prepared_candidate.mapped_model.as_str(),
);
let redaction = resolve_provider_chat_pii_redaction(
state,
parts,
body_json,
&input.auth_context,
spec_metadata.api_format,
reasoning_replay_policy,
&attempt.candidate_id,
)
.await?;
let body_json = redaction.body_json.as_ref();
let mut provider_request_body =
match crate::ai_serving::planner::standard::build_standard_request_body_with_model_directives_and_request_headers(
match crate::ai_serving::planner::standard::build_standard_request_body_with_model_directives_and_request_headers_and_reasoning_replay_policy(
body_json,
spec_metadata.api_format,
&prepared_candidate.mapped_model,
@@ -620,6 +627,7 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
Some(input.auth_context.api_key_id.as_str()),
Some(effective_headers),
false,
reasoning_replay_policy,
) {
Some(body) => body,
None => {
@@ -750,7 +758,7 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
source_model,
);
if let Err(violation) =
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities(
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy(
&mut provider_request_body,
crate::ai_serving::OpenAiProviderRequestFinalization {
source_api_format: spec_metadata.api_format,
@@ -766,6 +774,7 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
),
},
codex_model_capabilities.as_ref(),
reasoning_replay_policy,
)
{
mark_skipped_local_standard_candidate_with_extra_data(
@@ -1314,21 +1323,19 @@ async fn resolve_local_gemini_image_to_openai_image_candidate_payload_parts(
.as_object_mut()?
.insert("stream".to_string(), Value::Bool(true));
}
provider_request_body = project_openai_image_api_request_body(
&provider_request_body,
&prepared_candidate.mapped_model,
converted.operation,
crate::image_capabilities::openai_image_provider_max_generation_count_for_model(
transport.provider.provider_type.as_str(),
Some(prepared_candidate.mapped_model.as_str()),
),
)?;
if is_codex {
provider_request_body = project_codex_openai_image_api_request_body(
provider_request_body = if is_codex {
project_codex_openai_image_api_request_body(&provider_request_body, converted.operation)?
} else {
project_openai_image_api_request_body(
&provider_request_body,
&prepared_candidate.mapped_model,
converted.operation,
)?;
}
crate::image_capabilities::openai_image_provider_max_generation_count_for_model(
transport.provider.provider_type.as_str(),
Some(prepared_candidate.mapped_model.as_str()),
),
)?
};
let request_path = match converted.operation {
OpenAiImageOperation::Generate => "/v1/images/generations",
OpenAiImageOperation::Edit => "/v1/images/edits",
@@ -18,7 +18,10 @@ pub(crate) use self::codex::{
apply_codex_openai_responses_special_body_edits, apply_codex_openai_special_headers,
codex_model_capabilities_for_transport,
};
pub(crate) use self::deepseek::{apply_deepseek_tool_call_thinking_compat, is_deepseek_provider};
pub(crate) use self::deepseek::{
apply_deepseek_tool_call_thinking_compat, is_deepseek_provider,
openai_responses_reasoning_replay_policy,
};
pub(crate) use self::family::{
build_local_stream_attempt_source, build_local_stream_plan_and_reports,
build_local_sync_attempt_source, build_local_sync_plan_and_reports,
@@ -30,6 +33,7 @@ pub(crate) use self::normalize::{
build_cross_format_openai_responses_upstream_url, build_local_openai_chat_request_body,
build_local_openai_chat_upstream_url, build_local_openai_responses_request_body,
build_local_openai_responses_request_body_with_codex_model_capabilities,
build_local_openai_responses_request_body_with_codex_model_capabilities_for_websocket_continuation,
build_local_openai_responses_upstream_url, validate_final_openai_provider_request,
};
pub(crate) use self::openai::{
@@ -42,13 +46,15 @@ pub(crate) use self::openai::{
build_local_openai_responses_sync_attempt_source_for_kind,
build_local_openai_responses_sync_plan_and_reports_for_kind, 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,
map_openai_reasoning_effort_to_gemini_budget, maybe_build_responses_websocket_decision,
maybe_build_stream_local_decision_payload,
maybe_build_stream_local_openai_responses_decision_payload,
maybe_build_sync_local_decision_payload,
maybe_build_sync_local_openai_embedding_decision_payload,
maybe_build_sync_local_openai_responses_decision_payload, parse_openai_stop_sequences,
resolve_openai_chat_max_tokens, set_local_openai_chat_execution_exhausted_diagnostic,
value_as_u64,
value_as_u64, ResponsesWebSocketBodyNormalization, ResponsesWebSocketDecision,
ResponsesWebSocketPinnedCandidate,
};
pub(crate) use crate::ai_serving::normalize_standard_request_to_openai_chat_request;
pub(crate) use crate::ai_serving::{
@@ -59,7 +65,7 @@ pub(crate) use crate::ai_serving::{
};
pub(crate) use crate::ai_serving::{
build_standard_request_body, build_standard_request_body_with_model_directives,
build_standard_request_body_with_model_directives_and_request_headers,
build_standard_request_body_with_model_directives_and_request_headers_and_reasoning_replay_policy,
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_responses_request, extract_openai_text_content,
normalize_openai_responses_request_to_openai_chat_request, parse_openai_tool_result_content,
@@ -15,6 +15,7 @@ pub(crate) use self::responses::{
build_cross_format_openai_responses_request_body_with_codex_model_capabilities,
build_cross_format_openai_responses_upstream_url, build_local_openai_responses_request_body,
build_local_openai_responses_request_body_with_codex_model_capabilities,
build_local_openai_responses_request_body_with_codex_model_capabilities_for_websocket_continuation,
build_local_openai_responses_upstream_url,
};
pub(super) use crate::ai_serving::planner::common::{
@@ -50,6 +50,69 @@ pub(crate) fn build_local_openai_responses_request_body_with_codex_model_capabil
request_headers: &http::HeaderMap,
model_capabilities: Option<&crate::ai_serving::CodexResponsesModelCapabilities>,
enable_model_directives: bool,
) -> Option<Value> {
build_local_openai_responses_request_body_with_codex_model_capabilities_and_websocket_mode(
body_json,
mapped_model,
require_streaming,
force_body_stream_field,
provider_type,
provider_api_format,
body_rules,
request_headers,
model_capabilities,
enable_model_directives,
false,
)
}
/// Builds a Responses body for a pinned WebSocket continuation.
///
/// This is intentionally an additive variant of the ordinary HTTP builder.
/// The WebSocket framing layer, rather than a JSON-body heuristic, tells the
/// Codex compatibility pass that `previous_response_id` is transport state and
/// that Responses Lite `tools`/`instructions` must not be materialized into a
/// second historical input prefix.
pub(crate) fn build_local_openai_responses_request_body_with_codex_model_capabilities_for_websocket_continuation(
body_json: &Value,
mapped_model: &str,
require_streaming: bool,
force_body_stream_field: bool,
provider_type: &str,
provider_api_format: &str,
body_rules: Option<&Value>,
request_headers: &http::HeaderMap,
model_capabilities: Option<&crate::ai_serving::CodexResponsesModelCapabilities>,
enable_model_directives: bool,
) -> Option<Value> {
build_local_openai_responses_request_body_with_codex_model_capabilities_and_websocket_mode(
body_json,
mapped_model,
require_streaming,
force_body_stream_field,
provider_type,
provider_api_format,
body_rules,
request_headers,
model_capabilities,
enable_model_directives,
true,
)
}
#[allow(clippy::too_many_arguments)]
fn build_local_openai_responses_request_body_with_codex_model_capabilities_and_websocket_mode(
body_json: &Value,
mapped_model: &str,
require_streaming: bool,
force_body_stream_field: bool,
provider_type: &str,
provider_api_format: &str,
body_rules: Option<&Value>,
request_headers: &http::HeaderMap,
model_capabilities: Option<&crate::ai_serving::CodexResponsesModelCapabilities>,
enable_model_directives: bool,
websocket_continuation: bool,
) -> Option<Value> {
let provider_request_body = surface_build_local_openai_responses_request_body(
body_json,
@@ -68,23 +131,31 @@ pub(crate) fn build_local_openai_responses_request_body_with_codex_model_capabil
.get("model")
.and_then(Value::as_str)
.unwrap_or(mapped_model);
crate::ai_serving::apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities(
&mut provider_request_body,
provider_type,
provider_api_format,
mapped_model,
source_model,
model_capabilities,
body_rules,
);
if websocket_continuation {
crate::ai_serving::apply_codex_openai_responses_websocket_continuation_body_edits_with_source_model_and_capabilities(
&mut provider_request_body,
provider_type,
provider_api_format,
mapped_model,
source_model,
model_capabilities,
body_rules,
);
} else {
crate::ai_serving::apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities(
&mut provider_request_body,
provider_type,
provider_api_format,
mapped_model,
source_model,
model_capabilities,
body_rules,
);
}
apply_openai_responses_compact_special_body_edits(
&mut provider_request_body,
provider_api_format,
);
crate::ai_serving::strip_incompatible_openai_responses_reasoning_items(
&mut provider_request_body,
provider_api_format,
);
enforce_provider_body_stream_policy(
&mut provider_request_body,
provider_api_format,
@@ -176,10 +247,6 @@ pub(crate) fn build_cross_format_openai_responses_request_body_with_codex_model_
&mut provider_request_body,
provider_api_format,
);
crate::ai_serving::strip_incompatible_openai_responses_reasoning_items(
&mut provider_request_body,
provider_api_format,
);
enforce_provider_body_stream_policy(
&mut provider_request_body,
provider_api_format,
@@ -102,6 +102,59 @@ fn builds_openai_chat_cross_format_request_body_from_openai_responses_source() {
assert_eq!(provider_request_body["messages"][0]["content"], "hello");
}
#[test]
fn maps_openai_responses_additional_tools_without_message_name() {
let body_json = json!({
"model": "gpt-5",
"input": [
{
"type": "additional_tools",
"role": "developer",
"tools": [{
"type": "function",
"name": "get_weather",
"description": "Get the weather",
"parameters": {
"type": "object",
"properties": {}
}
}]
},
{
"role": "user",
"content": "What is the weather?"
}
]
});
let provider_request_body = build_cross_format_openai_responses_request_body(
&body_json,
"gpt-5-upstream",
"openai:responses",
"openai:chat",
false,
false,
"openai",
None,
None,
&http::HeaderMap::new(),
false,
)
.expect("Responses additional tools should map to a Chat request body");
assert_eq!(
provider_request_body["messages"].as_array().map(Vec::len),
Some(1)
);
assert_eq!(provider_request_body["messages"][0]["role"], "user");
assert!(provider_request_body["messages"][0].get("name").is_none());
assert_eq!(provider_request_body["tools"][0]["type"], "function");
assert_eq!(
provider_request_body["tools"][0]["function"]["name"],
"get_weather"
);
}
#[test]
fn local_openai_responses_wrapper_preserves_body_order_after_edits() {
let body_json: Value = serde_json::from_str(
@@ -153,7 +206,7 @@ fn local_openai_responses_wrapper_preserves_body_order_after_edits() {
}
#[test]
fn local_openai_responses_wrapper_strips_foreign_reasoning_item_ids() {
fn local_openai_responses_wrapper_defers_reasoning_replay_filtering() {
let body_json = json!({
"model": "gpt-5.4",
"input": [
@@ -184,9 +237,14 @@ fn local_openai_responses_wrapper_strips_foreign_reasoning_item_ids() {
let input = provider_request_body["input"]
.as_array()
.expect("input array");
assert_eq!(input.len(), 2);
// This provider-agnostic normalization layer cannot decide whether an
// id-less/foreign reasoning item is opaque state required by DeepSeek.
// The provider-aware finalization pass applies the strict or DeepSeek
// replay policy once the selected upstream base URL is known.
assert_eq!(input.len(), 3);
assert_eq!(input[0]["id"], "rs_provider_123");
assert_eq!(input[1]["type"], "message");
assert_eq!(input[1]["id"], "item_72d3bd8d367d01977ace23f1");
assert_eq!(input[2]["type"], "message");
}
#[test]
@@ -195,6 +195,7 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
client_session_affinity: input.client_session_affinity.as_ref(),
routing_policy: input.routing_policy.as_ref(),
scheduler_affinity_epoch: eligible.orchestration.scheduler_affinity_epoch,
sticky_key_attempts: eligible.orchestration.sticky_key_attempts,
client_requested_stream: body_json
.get("stream")
.and_then(serde_json::Value::as_bool)
@@ -29,8 +29,8 @@ use crate::ai_serving::planner::standard::{
apply_deepseek_tool_call_thinking_compat, build_cross_format_openai_chat_request_body,
build_cross_format_openai_chat_upstream_url, build_local_openai_chat_request_body,
build_local_openai_chat_upstream_url, codex_model_capabilities_for_transport,
openai_provider_request_contract_failure_extra_data, request_body_build_failure_extra_data,
request_conversion_failure_extra_data,
openai_provider_request_contract_failure_extra_data, openai_responses_reasoning_replay_policy,
request_body_build_failure_extra_data, request_conversion_failure_extra_data,
};
use crate::ai_serving::transport::antigravity::is_antigravity_provider_transport;
use crate::ai_serving::transport::auth::resolve_local_openai_bearer_auth;
@@ -140,7 +140,7 @@ fn finalize_openai_chat_provider_request_body(
mapped_model,
source_model,
);
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities(
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy(
provider_request_body,
crate::ai_serving::OpenAiProviderRequestFinalization {
source_api_format: "openai:chat",
@@ -156,6 +156,11 @@ fn finalize_openai_chat_provider_request_body(
),
},
codex_model_capabilities.as_ref(),
openai_responses_reasoning_replay_policy(
transport.provider.provider_type.as_str(),
transport.endpoint.base_url.as_str(),
mapped_model,
),
)
.err()
.map(|violation| {
@@ -206,6 +211,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
body_json,
&input.auth_context,
"openai:chat",
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds,
candidate_id,
)
.await?;
@@ -1417,38 +1423,20 @@ async fn resolve_openai_chat_to_openai_image_payload_parts(
return Ok(None);
};
if !is_chatgpt_web {
let Some(projected) = project_openai_image_api_request_body(
&provider_request_body,
&prepared_candidate.mapped_model,
operation,
crate::image_capabilities::openai_image_provider_max_generation_count_for_model(
transport.provider.provider_type.as_str(),
Some(prepared_candidate.mapped_model.as_str()),
),
) else {
mark_skipped_local_openai_chat_candidate_with_extra_data(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"provider_request_body_build_failed",
request_body_build_failure_extra_data(
body_json,
"openai:chat",
provider_api_format,
let projected = if is_codex {
project_codex_openai_image_api_request_body(&provider_request_body, operation)
} else {
project_openai_image_api_request_body(
&provider_request_body,
&prepared_candidate.mapped_model,
operation,
crate::image_capabilities::openai_image_provider_max_generation_count_for_model(
transport.provider.provider_type.as_str(),
Some(prepared_candidate.mapped_model.as_str()),
),
)
.await;
return Ok(None);
};
provider_request_body = projected;
}
if is_codex {
let Some(projected) =
project_codex_openai_image_api_request_body(&provider_request_body, operation)
else {
let Some(projected) = projected else {
mark_skipped_local_openai_chat_candidate_with_extra_data(
state,
input,
@@ -2195,6 +2183,7 @@ mod tests {
client_surface: None,
gateway_credential_carrier: None,
client_session_affinity: None,
codex_fingerprint_context: None,
routing_policy: None,
routing_trace_seed: None,
routing_context: None,
@@ -2364,6 +2353,23 @@ mod tests {
eligible
}
fn sample_custom_deepseek_responses_transport() -> GatewayProviderTransportSnapshot {
let mut transport = sample_gemini_cli_transport();
transport.provider.name = "deepseek".to_string();
transport.provider.provider_type = "custom".to_string();
transport.endpoint.api_format = "openai:responses".to_string();
transport.endpoint.api_family = Some("openai".to_string());
transport.endpoint.endpoint_kind = Some("responses".to_string());
transport.endpoint.base_url = "https://api.deepseek.com/v1".to_string();
transport.endpoint.custom_path = None;
transport.key.api_formats = Some(vec!["openai:responses".to_string()]);
transport.key.auth_type = "bearer".to_string();
transport.key.decrypted_api_key = "test-api-key".to_string();
transport.key.decrypted_auth_config = None;
transport.key.upstream_metadata = None;
transport
}
fn sample_custom_directive_input() -> LocalOpenAiChatDecisionInput {
let mut input = sample_input();
input.requested_model = "gpt-5.6-sol-high".to_string();
@@ -2389,6 +2395,58 @@ mod tests {
input
}
#[test]
fn responses_shaped_chat_request_preserves_deepseek_opaque_reasoning_at_finalization() {
let reasoning_items = (0..66)
.map(|index| {
json!({
"type": "reasoning",
"encrypted_content": format!("opaque-deepseek-state-{index}"),
"content": [{
"type": "reasoning_text",
"text": format!("provider thinking state {index}")
}],
"future_capability": {"preserve": true}
})
})
.collect::<Vec<_>>();
let original_body = json!({
"model": "deepseek-v4-flash",
"input": reasoning_items
});
let transport = sample_custom_deepseek_responses_transport();
let mut provider_body = build_cross_format_openai_chat_request_body(
&original_body,
"deepseek-v4-flash",
"custom",
"openai:responses",
false,
false,
None,
None,
&http::HeaderMap::new(),
false,
)
.expect("Responses-shaped chat request should build");
assert!(finalize_openai_chat_provider_request_body(
&mut provider_body,
None,
"openai:responses",
false,
false,
&original_body,
&transport,
"deepseek-v4-flash",
)
.is_none());
let input = provider_body["input"].as_array().expect("provider input");
assert_eq!(input.len(), 66);
assert_eq!(input[0]["type"], "reasoning");
assert_eq!(input[0]["content"][0]["type"], "reasoning_text");
assert_eq!(input[0]["future_capability"]["preserve"], true);
}
fn sample_alias_max_directive_input() -> LocalOpenAiChatDecisionInput {
let mut input = sample_input();
input.requested_model = "deployment-alias-max".to_string();
@@ -23,6 +23,8 @@ pub(crate) use responses::{
build_local_openai_responses_stream_plan_and_reports_for_kind,
build_local_openai_responses_sync_attempt_source_for_kind,
build_local_openai_responses_sync_plan_and_reports_for_kind,
maybe_build_responses_websocket_decision,
maybe_build_stream_local_openai_responses_decision_payload,
maybe_build_sync_local_openai_responses_decision_payload,
maybe_build_sync_local_openai_responses_decision_payload, ResponsesWebSocketBodyNormalization,
ResponsesWebSocketDecision, ResponsesWebSocketPinnedCandidate,
};
@@ -10,6 +10,7 @@ use super::super::{
AiStreamAttempt,
};
use crate::ai_serving::planner::common::enforce_provider_body_stream_policy;
use crate::ai_serving::planner::redaction::sanitize_upstream_url_for_log;
use crate::ai_serving::provider_adaptation_requires_eventstream_accept;
use crate::ai_serving::transport::{
build_standard_plan_fallback_headers, build_standard_plan_fallback_openai_chat_url,
@@ -233,6 +234,19 @@ pub(crate) fn build_openai_responses_stream_plan_from_decision(
},
);
let log_downstream_query = parts
.uri
.query()
.and_then(crate::ai_serving::api::sanitize_request_query_string);
let log_decision_upstream_base_url = payload
.upstream_base_url
.as_deref()
.map(sanitize_upstream_url_for_log);
let log_decision_upstream_url = payload
.upstream_url
.as_deref()
.map(sanitize_upstream_url_for_log);
let log_plan_url = sanitize_upstream_url_for_log(plan.url.as_str());
debug!(
event_name = "local_openai_responses_stream_plan_built",
log_type = "debug",
@@ -242,11 +256,11 @@ pub(crate) fn build_openai_responses_stream_plan_from_decision(
endpoint_id = %plan.endpoint_id,
key_id = %plan.key_id,
downstream_path = %parts.uri.path(),
downstream_query = ?parts.uri.query(),
downstream_query = ?log_downstream_query,
url_source,
decision_upstream_base_url = ?payload.upstream_base_url,
decision_upstream_url = ?payload.upstream_url,
plan_url = %plan.url,
decision_upstream_base_url = ?log_decision_upstream_base_url,
decision_upstream_url = ?log_decision_upstream_url,
plan_url = %log_plan_url,
client_api_format = %plan.client_api_format,
provider_api_format = %plan.provider_api_format,
upstream_is_stream = effective_upstream_is_stream,
@@ -10,6 +10,7 @@ use super::super::{
AiSyncAttempt,
};
use crate::ai_serving::planner::common::enforce_provider_body_stream_policy;
use crate::ai_serving::planner::redaction::sanitize_upstream_url_for_log;
use crate::ai_serving::transport::{
build_standard_plan_fallback_headers, build_standard_plan_fallback_openai_chat_url,
build_standard_plan_fallback_openai_responses_url, StandardPlanFallbackAcceptPolicy,
@@ -200,6 +201,19 @@ pub(crate) fn build_openai_responses_sync_plan_from_decision(
},
);
let log_downstream_query = parts
.uri
.query()
.and_then(crate::ai_serving::api::sanitize_request_query_string);
let log_decision_upstream_base_url = payload
.upstream_base_url
.as_deref()
.map(sanitize_upstream_url_for_log);
let log_decision_upstream_url = payload
.upstream_url
.as_deref()
.map(sanitize_upstream_url_for_log);
let log_plan_url = sanitize_upstream_url_for_log(plan.url.as_str());
debug!(
event_name = "local_openai_responses_sync_plan_built",
log_type = "debug",
@@ -209,11 +223,11 @@ pub(crate) fn build_openai_responses_sync_plan_from_decision(
endpoint_id = %plan.endpoint_id,
key_id = %plan.key_id,
downstream_path = %parts.uri.path(),
downstream_query = ?parts.uri.query(),
downstream_query = ?log_downstream_query,
url_source,
decision_upstream_base_url = ?payload.upstream_base_url,
decision_upstream_url = ?payload.upstream_url,
plan_url = %plan.url,
decision_upstream_base_url = ?log_decision_upstream_base_url,
decision_upstream_url = ?log_decision_upstream_url,
plan_url = %log_plan_url,
client_api_format = %plan.client_api_format,
provider_api_format = %plan.provider_api_format,
upstream_is_stream = payload.upstream_is_stream,
@@ -5,11 +5,16 @@ mod request;
#[path = "decision/support.rs"]
mod support;
pub(super) use self::payload::maybe_build_local_openai_responses_decision_payload_for_candidate;
pub(super) use self::payload::{
maybe_build_local_openai_responses_decision_payload_for_candidate,
maybe_build_local_openai_responses_decision_payload_for_candidate_with_websocket_mode,
};
pub(super) use self::support::{
build_local_openai_responses_candidate_attempt_source,
materialize_local_openai_responses_candidate_attempts,
resolve_local_openai_responses_decision_input, LocalOpenAiResponsesCandidateAttempt,
LocalOpenAiResponsesCandidateAttemptSource, LocalOpenAiResponsesDecisionInput,
resolve_local_openai_responses_decision_input,
resolve_local_openai_responses_decision_input_with_snapshot,
LocalOpenAiResponsesCandidateAttempt, LocalOpenAiResponsesCandidateAttemptSource,
LocalOpenAiResponsesDecisionInput,
};
pub(super) use crate::ai_serving::LocalOpenAiResponsesSpec;
@@ -2,7 +2,8 @@ use serde_json::json;
use tracing::debug;
use crate::ai_serving::build_request_trace_proxy_value;
use crate::ai_serving::planner::decision_input::apply_provider_request_routing_policy_to_decision;
use crate::ai_serving::planner::decision_input::apply_provider_request_routing_policy_to_decision_with_websocket_mode;
use crate::ai_serving::planner::redaction::sanitize_upstream_url_for_log;
use crate::ai_serving::planner::report_context::{
build_local_execution_report_context, insert_native_client_envelope_name,
insert_provider_stream_event_api_format, LocalExecutionReportContextParts,
@@ -20,7 +21,10 @@ use crate::{
AiExecutionDecision, AppState, GatewayError,
};
use super::request::resolve_local_openai_responses_candidate_payload_parts;
use super::request::{
resolve_local_openai_responses_candidate_payload_parts,
resolve_local_openai_responses_candidate_payload_parts_with_websocket_mode,
};
use super::support::{LocalOpenAiResponsesCandidateAttempt, LocalOpenAiResponsesDecisionInput};
use super::LocalOpenAiResponsesSpec;
@@ -32,6 +36,26 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
input: &LocalOpenAiResponsesDecisionInput,
attempt: LocalOpenAiResponsesCandidateAttempt,
spec: LocalOpenAiResponsesSpec,
) -> Result<Option<AiExecutionDecision>, GatewayError> {
maybe_build_local_openai_responses_decision_payload_for_candidate_with_websocket_mode(
state, parts, trace_id, body_json, input, attempt, spec, false,
)
.await
}
/// Builds a candidate payload for a pinned WebSocket turn without changing
/// the ordinary HTTP/plan-builder path. The explicit mode is carried all the
/// way to body normalization because a JSON `type` field is not a reliable
/// transport discriminator once body rules and conversions have run.
pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_candidate_with_websocket_mode(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
body_json: &serde_json::Value,
input: &LocalOpenAiResponsesDecisionInput,
attempt: LocalOpenAiResponsesCandidateAttempt,
spec: LocalOpenAiResponsesSpec,
websocket_continuation: bool,
) -> Result<Option<AiExecutionDecision>, GatewayError> {
let spec_metadata = local_openai_responses_spec_metadata(spec);
let attempt_identity = attempt.attempt_identity();
@@ -41,19 +65,35 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
candidate_id,
..
} = attempt;
let Some(resolved) = resolve_local_openai_responses_candidate_payload_parts(
state,
parts,
trace_id,
body_json,
input,
&eligible,
candidate_index,
&candidate_id,
spec,
)
.await?
else {
let resolved = if websocket_continuation {
resolve_local_openai_responses_candidate_payload_parts_with_websocket_mode(
state,
parts,
trace_id,
body_json,
input,
&eligible,
candidate_index,
&candidate_id,
spec,
true,
)
.await?
} else {
resolve_local_openai_responses_candidate_payload_parts(
state,
parts,
trace_id,
body_json,
input,
&eligible,
candidate_index,
&candidate_id,
spec,
)
.await?
};
let Some(resolved) = resolved else {
return Ok(None);
};
let candidate = &eligible.candidate;
@@ -144,6 +184,7 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
client_session_affinity: input.client_session_affinity.as_ref(),
routing_policy: input.routing_policy.as_ref(),
scheduler_affinity_epoch: eligible.orchestration.scheduler_affinity_epoch,
sticky_key_attempts: eligible.orchestration.sticky_key_attempts,
client_requested_stream: body_json
.get("stream")
.and_then(serde_json::Value::as_bool)
@@ -164,6 +205,12 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
&resolved.transport,
);
let log_base_url = sanitize_upstream_url_for_log(resolved.transport.endpoint.base_url.as_str());
let log_request_query = parts
.uri
.query()
.and_then(crate::ai_serving::api::sanitize_request_query_string);
let log_upstream_url = sanitize_upstream_url_for_log(resolved.upstream_url.as_str());
debug!(
event_name = "local_openai_responses_decision_payload_built",
log_type = "debug",
@@ -180,9 +227,9 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
client_api_format = spec_metadata.api_format,
provider_api_format = %resolved.provider_api_format,
request_path = %parts.uri.path(),
request_query = ?parts.uri.query(),
upstream_base_url = %resolved.transport.endpoint.base_url,
upstream_url = %resolved.upstream_url,
request_query = ?log_request_query,
upstream_base_url = %log_base_url,
upstream_url = %log_upstream_url,
upstream_is_stream = resolved.upstream_is_stream,
has_envelope = resolved.envelope_name.is_some(),
"gateway built local openai responses decision payload"
@@ -243,10 +290,11 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
report_context: Some(report_context),
auth_context: input.auth_context.clone(),
});
apply_provider_request_routing_policy_to_decision(
apply_provider_request_routing_policy_to_decision_with_websocket_mode(
input,
&mut decision,
Some(transport.as_ref()),
websocket_continuation,
)?;
Ok(Some(decision))
}
@@ -24,6 +24,7 @@ use crate::ai_serving::planner::gemini_cli::{
};
use crate::ai_serving::planner::redaction::{
request_identity_response_encoding_when_redacted, resolve_provider_chat_pii_redaction,
sanitize_upstream_url_for_log,
};
use crate::ai_serving::planner::spec_metadata::local_openai_responses_spec_metadata;
use crate::ai_serving::planner::standard::{
@@ -31,9 +32,10 @@ use crate::ai_serving::planner::standard::{
build_cross_format_openai_responses_request_body_with_codex_model_capabilities,
build_cross_format_openai_responses_upstream_url,
build_local_openai_responses_request_body_with_codex_model_capabilities,
build_local_openai_responses_request_body_with_codex_model_capabilities_for_websocket_continuation,
build_local_openai_responses_upstream_url, codex_model_capabilities_for_transport,
openai_provider_request_contract_failure_extra_data, request_body_build_failure_extra_data,
request_conversion_failure_extra_data,
openai_provider_request_contract_failure_extra_data, openai_responses_reasoning_replay_policy,
request_body_build_failure_extra_data, request_conversion_failure_extra_data,
};
use crate::ai_serving::transport::antigravity::is_antigravity_provider_transport;
use crate::ai_serving::transport::auth::{
@@ -203,6 +205,34 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
candidate_index: u32,
candidate_id: &str,
spec: LocalOpenAiResponsesSpec,
) -> Result<Option<LocalOpenAiResponsesCandidatePayloadParts>, GatewayError> {
resolve_local_openai_responses_candidate_payload_parts_with_websocket_mode(
state,
parts,
trace_id,
body_json,
input,
eligible,
candidate_index,
candidate_id,
spec,
false,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts_with_websocket_mode(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
body_json: &serde_json::Value,
input: &LocalOpenAiResponsesDecisionInput,
eligible: &EligibleLocalExecutionCandidate,
candidate_index: u32,
candidate_id: &str,
spec: LocalOpenAiResponsesSpec,
websocket_continuation: bool,
) -> Result<Option<LocalOpenAiResponsesCandidatePayloadParts>, GatewayError> {
let spec_metadata = local_openai_responses_spec_metadata(spec);
let client_api_format = spec_metadata.api_format.trim().to_ascii_lowercase();
@@ -405,12 +435,18 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
input.auth_context.api_key_id.as_str(),
)
.await?;
let reasoning_replay_policy = openai_responses_reasoning_replay_policy(
transport.provider.provider_type.as_str(),
transport.endpoint.base_url.as_str(),
mapped_model.as_str(),
);
let redaction = resolve_provider_chat_pii_redaction(
state,
parts,
body_json,
&input.auth_context,
spec_metadata.api_format,
reasoning_replay_policy,
candidate_id,
)
.await?;
@@ -437,41 +473,42 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
mapped_model.as_str(),
source_model,
);
let Some(mut base_provider_request_body) =
(if is_grok && is_grok_text_provider_api_format(provider_api_format) {
build_local_openai_responses_request_body_with_codex_model_capabilities(
body_json,
&mapped_model,
upstream_is_stream,
force_body_stream_field,
transport.provider.provider_type.as_str(),
spec_metadata.api_format,
transport.endpoint.body_rules.as_ref(),
effective_headers,
codex_model_capabilities.as_ref(),
false,
)
} else if needs_bidirectional_conversion {
build_cross_format_openai_responses_request_body_with_codex_model_capabilities(
body_json,
&mapped_model,
spec_metadata.api_format,
provider_api_format,
upstream_is_stream,
force_body_stream_field,
transport.provider.provider_type.as_str(),
if is_kiro_claude_cli || is_windsurf_cascade {
None
} else {
transport.endpoint.body_rules.as_ref()
},
effective_headers,
Some(input.auth_context.api_key_id.as_str()),
codex_model_capabilities.as_ref(),
false,
)
} else {
build_local_openai_responses_request_body_with_codex_model_capabilities(
let Some(mut base_provider_request_body) = (if is_grok
&& is_grok_text_provider_api_format(provider_api_format)
{
build_local_openai_responses_request_body_with_codex_model_capabilities(
body_json,
&mapped_model,
upstream_is_stream,
force_body_stream_field,
transport.provider.provider_type.as_str(),
spec_metadata.api_format,
transport.endpoint.body_rules.as_ref(),
effective_headers,
codex_model_capabilities.as_ref(),
false,
)
} else if needs_bidirectional_conversion {
build_cross_format_openai_responses_request_body_with_codex_model_capabilities(
body_json,
&mapped_model,
spec_metadata.api_format,
provider_api_format,
upstream_is_stream,
force_body_stream_field,
transport.provider.provider_type.as_str(),
if is_kiro_claude_cli || is_windsurf_cascade {
None
} else {
transport.endpoint.body_rules.as_ref()
},
effective_headers,
Some(input.auth_context.api_key_id.as_str()),
codex_model_capabilities.as_ref(),
false,
)
} else if websocket_continuation {
build_local_openai_responses_request_body_with_codex_model_capabilities_for_websocket_continuation(
body_json,
&mapped_model,
upstream_is_stream,
@@ -487,8 +524,24 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
codex_model_capabilities.as_ref(),
false,
)
})
else {
} else {
build_local_openai_responses_request_body_with_codex_model_capabilities(
body_json,
&mapped_model,
upstream_is_stream,
force_body_stream_field,
transport.provider.provider_type.as_str(),
provider_api_format,
if is_kiro_claude_cli || is_windsurf_cascade {
None
} else {
transport.endpoint.body_rules.as_ref()
},
effective_headers,
codex_model_capabilities.as_ref(),
false,
)
}) else {
mark_skipped_local_openai_responses_candidate_with_extra_data(
state,
input,
@@ -531,25 +584,35 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
provider_api_format,
Some(body_json),
);
if let Err(violation) =
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities(
let finalization = crate::ai_serving::OpenAiProviderRequestFinalization {
source_api_format: spec_metadata.api_format,
provider_api_format,
provider_type: transport.provider.provider_type.as_str(),
provider_model: mapped_model.as_str(),
source_model,
body_rules: transport.endpoint.body_rules.as_ref(),
upstream_is_stream,
require_body_stream_field: request_requires_body_stream_field(
body_json,
force_body_stream_field,
),
};
let finalization_result = if websocket_continuation {
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy_for_websocket_continuation(
&mut base_provider_request_body,
crate::ai_serving::OpenAiProviderRequestFinalization {
source_api_format: spec_metadata.api_format,
provider_api_format,
provider_type: transport.provider.provider_type.as_str(),
provider_model: mapped_model.as_str(),
source_model,
body_rules: transport.endpoint.body_rules.as_ref(),
upstream_is_stream,
require_body_stream_field: request_requires_body_stream_field(
body_json,
force_body_stream_field,
),
},
finalization,
codex_model_capabilities.as_ref(),
reasoning_replay_policy,
)
{
} else {
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy(
&mut base_provider_request_body,
finalization,
codex_model_capabilities.as_ref(),
reasoning_replay_policy,
)
};
if let Err(violation) = finalization_result {
mark_skipped_local_openai_responses_candidate_with_extra_data(
state,
input,
@@ -804,6 +867,17 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
let (execution_strategy, conversion_mode) =
ai_local_execution_contract_for_formats(spec_metadata.api_format, provider_api_format);
let log_base_url = sanitize_upstream_url_for_log(transport.endpoint.base_url.as_str());
let log_custom_path = transport
.endpoint
.custom_path
.as_deref()
.map(sanitize_upstream_url_for_log);
let log_request_query = parts
.uri
.query()
.and_then(crate::ai_serving::api::sanitize_request_query_string);
let log_upstream_url = sanitize_upstream_url_for_log(upstream_url.as_str());
debug!(
event_name = "local_openai_responses_upstream_url_resolved",
@@ -819,12 +893,12 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
provider_api_format = %provider_api_format,
execution_strategy = execution_strategy.as_str(),
conversion_mode = conversion_mode.as_str(),
base_url = %transport.endpoint.base_url,
custom_path = ?transport.endpoint.custom_path,
base_url = %log_base_url,
custom_path = ?log_custom_path,
request_path = %parts.uri.path(),
request_query = ?parts.uri.query(),
request_query = ?log_request_query,
mapped_model = %mapped_model,
upstream_url = %upstream_url,
upstream_url = %log_upstream_url,
upstream_is_stream,
"gateway resolved local openai responses upstream url"
);
@@ -1395,7 +1469,10 @@ async fn resolve_openai_responses_to_openai_image_payload_parts(
return None;
};
let operation = openai_image_operation_from_summary(&image_request_summary)?;
if !is_chatgpt_web {
if is_codex {
provider_request_body =
project_codex_openai_image_api_request_body(&provider_request_body, operation)?;
} else if !is_chatgpt_web {
provider_request_body = project_openai_image_api_request_body(
&provider_request_body,
&prepared_candidate.mapped_model,
@@ -1406,10 +1483,6 @@ async fn resolve_openai_responses_to_openai_image_payload_parts(
),
)?;
}
if is_codex {
provider_request_body =
project_codex_openai_image_api_request_body(&provider_request_body, operation)?;
}
let upstream_url = if is_chatgpt_web {
chatgpt_web_image_internal_url(&transport.endpoint.base_url)
@@ -1938,6 +2011,7 @@ async fn build_kiro_openai_responses_payload_parts(
};
let (execution_strategy, conversion_mode) =
ai_local_execution_contract_for_formats(client_api_format, provider_api_format);
let log_upstream_url = sanitize_upstream_url_for_log(upstream_url.as_str());
debug!(
event_name = "local_openai_responses_kiro_upstream_url_resolved",
@@ -1953,7 +2027,7 @@ async fn build_kiro_openai_responses_payload_parts(
provider_api_format = %provider_api_format,
execution_strategy = execution_strategy.as_str(),
conversion_mode = conversion_mode.as_str(),
upstream_url = %upstream_url,
upstream_url = %log_upstream_url,
upstream_is_stream,
"gateway resolved local openai responses kiro upstream url"
);
@@ -22,6 +22,7 @@ use crate::ai_serving::planner::common::extract_standard_requested_model;
use crate::ai_serving::planner::decision_input::{
attach_routing_policy_to_local_requested_model_input,
build_local_requested_model_decision_input, resolve_local_authenticated_decision_input,
resolve_local_authenticated_decision_input_with_snapshot,
};
use crate::ai_serving::planner::materialization_policy::{
build_local_candidate_persistence_policy, LocalCandidatePersistencePolicyKind,
@@ -32,7 +33,8 @@ use crate::ai_serving::planner::CandidateFailureDiagnostic;
use crate::ai_serving::{
ai_local_execution_contract_for_formats, extract_pool_sticky_session_token,
openai_responses_request_operation, resolve_local_decision_execution_runtime_auth_context,
ExecutionRuntimeAuthContext, GatewayControlDecision, PlannerAppState,
ExecutionRuntimeAuthContext, GatewayAuthApiKeySnapshot, GatewayControlDecision,
PlannerAppState,
};
use crate::client_session_affinity::client_session_affinity_from_parts;
use crate::{AppState, GatewayError};
@@ -51,6 +53,21 @@ pub(crate) async fn resolve_local_openai_responses_decision_input(
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<LocalOpenAiResponsesDecisionInput>, GatewayError> {
resolve_local_openai_responses_decision_input_with_snapshot(
state, parts, trace_id, decision, body_json, plan_kind, None,
)
.await
}
pub(crate) async fn resolve_local_openai_responses_decision_input_with_snapshot(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
auth_snapshot_override: Option<&GatewayAuthApiKeySnapshot>,
) -> Result<Option<LocalOpenAiResponsesDecisionInput>, GatewayError> {
let Some(auth_context) = resolve_local_decision_execution_runtime_auth_context(decision) else {
warn!(
@@ -87,16 +104,28 @@ pub(crate) async fn resolve_local_openai_responses_decision_input(
return Ok(None);
};
let resolved_input = match resolve_local_authenticated_decision_input(
state,
auth_context.clone(),
Some(requested_model.as_str()),
decision.auth_endpoint_signature.as_deref(),
None,
&decision.model_directive_policy,
)
.await
{
let resolved_input = match if let Some(auth_snapshot) = auth_snapshot_override {
resolve_local_authenticated_decision_input_with_snapshot(
state,
auth_context.clone(),
Some(auth_snapshot.clone()),
Some(requested_model.as_str()),
decision.auth_endpoint_signature.as_deref(),
None,
&decision.model_directive_policy,
)
.await
} else {
resolve_local_authenticated_decision_input(
state,
auth_context.clone(),
Some(requested_model.as_str()),
decision.auth_endpoint_signature.as_deref(),
None,
&decision.model_directive_policy,
)
.await
} {
Ok(Some(resolved_input)) => resolved_input,
Ok(None) => {
warn!(
@@ -1,6 +1,63 @@
use crate::ai_serving::planner::common::endpoint_config_forces_body_stream_field;
use crate::ai_serving::planner::plan_builders::{AiStreamAttempt, AiSyncAttempt};
use crate::ai_serving::planner::spec_metadata::local_openai_responses_spec_metadata;
use crate::ai_serving::planner::standard::codex::codex_model_capabilities_for_transport;
use crate::ai_serving::planner::standard::normalize::{
build_local_openai_responses_request_body_with_codex_model_capabilities,
build_local_openai_responses_request_body_with_codex_model_capabilities_for_websocket_continuation,
};
use crate::ai_serving::planner::standard::openai_responses_reasoning_replay_policy;
use crate::ai_serving::GatewayControlDecision;
use crate::orchestration::{
codex_quota_breaker_blocks_candidate, log_codex_quota_breaker_check_failure,
responses_websocket_adapter, ResponsesWebSocketAdapter,
};
use crate::{AiExecutionDecision, AppState, GatewayError};
use aether_runtime_state::RuntimeLockLease;
use std::collections::BTreeSet;
/// Releases a scheduler pool-key lease if WebSocket planning is cancelled
/// after candidate selection but before ownership reaches the turn lifecycle.
struct ResponsesWebSocketPlanningLeaseGuard {
state: AppState,
lease: Option<RuntimeLockLease>,
}
impl ResponsesWebSocketPlanningLeaseGuard {
fn new(state: &AppState, lease: Option<&RuntimeLockLease>) -> Self {
Self {
state: state.clone(),
lease: lease.cloned(),
}
}
async fn release(mut self) {
// Keep the lease armed across the await. If the owner task is aborted
// or reaches its hard deadline while the runtime backend is stalled,
// Drop can still hand cleanup to a detached owner.
if release_responses_websocket_planning_lease(&self.state, self.lease.as_ref()).await {
self.lease = None;
}
}
fn disarm(&mut self) {
self.lease = None;
}
}
impl Drop for ResponsesWebSocketPlanningLeaseGuard {
fn drop(&mut self) {
let Some(lease) = self.lease.take() else {
return;
};
let state = self.state.clone();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let _ = release_responses_websocket_planning_lease(&state, Some(&lease)).await;
});
}
}
}
mod decision;
mod plans;
@@ -8,7 +65,9 @@ mod plans;
use self::decision::{
build_local_openai_responses_candidate_attempt_source,
maybe_build_local_openai_responses_decision_payload_for_candidate,
maybe_build_local_openai_responses_decision_payload_for_candidate_with_websocket_mode,
resolve_local_openai_responses_decision_input,
resolve_local_openai_responses_decision_input_with_snapshot,
};
use self::plans::{
build_local_stream_attempt_source, build_local_stream_plan_and_reports,
@@ -165,3 +224,837 @@ pub(crate) async fn maybe_build_stream_local_openai_responses_decision_payload(
Ok(None)
}
/// One eligible upstream plus the adapter that is allowed to speak to it.
///
/// The adapter is selected from the provider-scoped capability before the
/// decision leaves the planner. This prevents a public Responses socket from
/// choosing an arbitrary provider protocol after scheduling has completed.
pub(crate) struct ResponsesWebSocketDecision {
pub(crate) execution: AiExecutionDecision,
pub(crate) adapter: ResponsesWebSocketAdapter,
pub(crate) normalization: ResponsesWebSocketBodyNormalization,
/// Effective key auth after applying the endpoint API-format override.
/// Protocol companions such as Codex Live must not infer this from a URL
/// or from the presence of one particular generated header.
pub(crate) effective_auth_type: String,
}
/// The scheduler identity a continuation is allowed to reuse.
///
/// A `previous_response_id` chain cannot move to another provider connection,
/// but it still has to pass the current scheduler runtime checks on every
/// turn. The planner uses this identity as a filter rather than selecting an
/// arbitrary eligible replacement.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ResponsesWebSocketPinnedCandidate {
provider_id: String,
endpoint_id: String,
key_id: String,
}
impl ResponsesWebSocketPinnedCandidate {
pub(crate) fn new(provider_id: &str, endpoint_id: &str, key_id: &str) -> Option<Self> {
Some(Self {
provider_id: non_empty_decision_identity(Some(provider_id))?,
endpoint_id: non_empty_decision_identity(Some(endpoint_id))?,
key_id: non_empty_decision_identity(Some(key_id))?,
})
}
pub(crate) fn from_decision(decision: &AiExecutionDecision) -> Option<Self> {
Self::new(
decision.provider_id.as_deref()?,
decision.endpoint_id.as_deref()?,
decision.key_id.as_deref()?,
)
}
pub(crate) fn provider_id(&self) -> &str {
self.provider_id.as_str()
}
pub(crate) fn endpoint_id(&self) -> &str {
self.endpoint_id.as_str()
}
pub(crate) fn key_id(&self) -> &str {
self.key_id.as_str()
}
fn matches(
&self,
candidate: &aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate,
) -> bool {
candidate.provider_id == self.provider_id
&& candidate.endpoint_id == self.endpoint_id
&& candidate.key_id == self.key_id
}
}
fn non_empty_decision_identity(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
/// Everything needed to re-run provider-body normalization for the candidate a
/// socket is already bound to.
///
/// A continuation turn (`previous_response_id` on the bound upstream) cannot
/// re-enter the planner, because planning selects a candidate and a different
/// key would break the response chain. Without this, such turns reached the
/// provider with only their `model` rewritten — skipping model directives,
/// endpoint body rules, and the Codex body contract that turn 1 received.
///
/// This value holds cloned scalars and JSON only: no candidate, no pool key
/// lease, no `AppState`. It cannot influence selection.
#[derive(Debug, Clone)]
pub(crate) struct ResponsesWebSocketBodyNormalization {
provider_type: String,
provider_api_format: String,
client_api_format: String,
mapped_model: String,
requested_model: String,
upstream_is_stream: bool,
force_body_stream_field: bool,
body_rules: Option<serde_json::Value>,
request_headers: http::HeaderMap,
codex_model_capabilities: Option<crate::ai_serving::CodexResponsesModelCapabilities>,
reasoning_replay_policy: crate::ai_serving::OpenAiResponsesReasoningReplayPolicy,
model_directive_patch: Option<serde_json::Value>,
}
impl ResponsesWebSocketBodyNormalization {
/// Builds a normalizer for a plain `openai:responses` upstream with no
/// endpoint body rules, directives or Codex capabilities, so relay tests can
/// construct a bound connection without standing up a provider snapshot.
#[cfg(test)]
pub(crate) fn for_tests(mapped_model: &str) -> Self {
Self {
provider_type: "openai".to_string(),
provider_api_format: "openai:responses".to_string(),
client_api_format: "openai:responses".to_string(),
mapped_model: mapped_model.to_string(),
requested_model: mapped_model.to_string(),
upstream_is_stream: true,
force_body_stream_field: false,
body_rules: None,
request_headers: http::HeaderMap::new(),
codex_model_capabilities: None,
reasoning_replay_policy:
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds,
model_directive_patch: None,
}
}
#[cfg(test)]
pub(crate) fn with_provider_type_for_tests(mut self, provider_type: &str) -> Self {
self.provider_type = provider_type.to_string();
self
}
#[cfg(test)]
pub(crate) fn with_body_rules_for_tests(mut self, body_rules: serde_json::Value) -> Self {
self.body_rules = Some(body_rules);
self
}
#[cfg(test)]
pub(crate) fn with_reasoning_replay_policy_for_tests(
mut self,
reasoning_replay_policy: crate::ai_serving::OpenAiResponsesReasoningReplayPolicy,
) -> Self {
self.reasoning_replay_policy = reasoning_replay_policy;
self
}
#[cfg(test)]
pub(crate) fn with_model_directive_patch_for_tests(mut self, patch: serde_json::Value) -> Self {
self.model_directive_patch = Some(patch);
self
}
pub(crate) fn uses_codex_responses_lite(&self) -> bool {
if !self.provider_type.trim().eq_ignore_ascii_case("codex")
|| !crate::ai_serving::is_openai_responses_family_format(
self.provider_api_format.as_str(),
)
{
return false;
}
self.codex_model_capabilities
.clone()
.unwrap_or_else(|| {
crate::ai_serving::resolve_codex_responses_model_capabilities(
self.mapped_model.as_str(),
self.requested_model.as_str(),
None,
)
})
.use_responses_lite
}
pub(crate) fn reasoning_replay_policy(
&self,
) -> crate::ai_serving::OpenAiResponsesReasoningReplayPolicy {
self.reasoning_replay_policy
}
/// Returns whether an enabled endpoint body rule that applies to this
/// request owns the final value of a non-lineage WebSocket framing field.
///
/// Codex's HTTP-shaped normalization intentionally removes or rewrites a
/// few WebSocket-only fields. The framing layer may restore a value from
/// the raw client event only when an administrator rule did not handle
/// that path; otherwise the restore would silently undo the endpoint
/// policy after all request finalization had completed. Opaque lineage
/// (`previous_response_id`) is deliberately excluded by the framing layer:
/// its final value must remain the authenticated client value.
pub(crate) fn body_rules_handle_websocket_field(
&self,
client_event: &serde_json::Value,
field: &str,
) -> bool {
let Some(mut body_before_rules) =
crate::ai_serving::build_local_openai_responses_request_body_with_model_directives(
client_event,
self.mapped_model.as_str(),
self.upstream_is_stream,
false,
)
else {
// Normalization will reject the same malformed event. Keep the
// framing pass fail closed if this method is ever called alone.
return true;
};
crate::ai_serving::transport::rules::apply_local_body_rules_with_request_headers_and_track_path(
&mut body_before_rules,
self.body_rules.as_ref(),
Some(client_event),
Some(&self.request_headers),
field,
)
.unwrap_or(true)
}
pub(crate) fn has_same_responses_lite_static_contract(&self, other: &Self) -> bool {
self.provider_type
.trim()
.eq_ignore_ascii_case(other.provider_type.trim())
&& crate::ai_serving::api_format_alias_matches(
self.provider_api_format.as_str(),
other.provider_api_format.as_str(),
)
&& self.mapped_model == other.mapped_model
&& self.requested_model == other.requested_model
&& self.body_rules == other.body_rules
&& self.codex_model_capabilities == other.codex_model_capabilities
&& self.model_directive_patch == other.model_directive_patch
&& self.uses_codex_responses_lite() == other.uses_codex_responses_lite()
}
/// Produces a versioned digest of the complete body-normalization
/// contract. A continuation registry stores only this digest so a new
/// socket can fail closed when endpoint rules, model capabilities or
/// header-dependent normalization has changed, without persisting request
/// headers or other sensitive configuration.
pub(crate) fn continuation_fingerprint(&self) -> [u8; 32] {
use sha2::Digest as _;
let mut digest = sha2::Sha256::new();
digest.update(b"aether-responses-websocket-normalization-v1");
update_normalization_string_digest(&mut digest, self.provider_type.as_str());
update_normalization_string_digest(&mut digest, self.provider_api_format.as_str());
update_normalization_string_digest(&mut digest, self.client_api_format.as_str());
update_normalization_string_digest(&mut digest, self.mapped_model.as_str());
update_normalization_string_digest(&mut digest, self.requested_model.as_str());
digest.update([
u8::from(self.upstream_is_stream),
u8::from(self.force_body_stream_field),
]);
update_normalization_optional_json_digest(&mut digest, self.body_rules.as_ref());
update_normalization_body_rule_headers_digest(
&mut digest,
&self.request_headers,
self.body_rules.as_ref(),
);
update_normalization_codex_capabilities_digest(
&mut digest,
self.codex_model_capabilities.as_ref(),
);
digest.update([match self.reasoning_replay_policy {
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds => 0,
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque => 1,
}]);
update_normalization_optional_json_digest(&mut digest, self.model_directive_patch.as_ref());
digest.finalize().into()
}
/// Applies the same body transformations the planner applied on the turn
/// that bound this upstream.
///
/// Mirrors the same-format branch of
/// `resolve_local_openai_responses_candidate_payload_parts`. The
/// cross-format, Kiro, Windsurf and Antigravity branches are unreachable
/// here: the WebSocket planner only returns candidates whose provider API
/// format is `openai:responses`.
///
/// Returns `None` when normalization fails. The WebSocket caller rejects
/// that turn rather than sending an unnormalized event that bypasses body
/// rules or replays a Responses Lite static prefix.
pub(crate) fn normalize_response_create(
&self,
client_event: &serde_json::Value,
) -> Option<serde_json::Value> {
use crate::ai_serving::planner::common::{
enforce_provider_body_stream_policy, request_requires_body_stream_field,
};
let source_model = client_event
.get("model")
.and_then(serde_json::Value::as_str)
.unwrap_or(self.requested_model.as_str());
// The first response.create on a socket is a normal Responses request.
// Only a non-empty previous_response_id denotes a continuation whose
// stored history already contains the synthetic Responses Lite
// tools/instructions prefix. Keep this discriminator explicit instead
// of applying continuation edits to every socket turn.
let websocket_continuation = client_event
.get("previous_response_id")
.and_then(serde_json::Value::as_str)
.is_some_and(|value| !value.trim().is_empty());
let require_body_stream_field =
request_requires_body_stream_field(client_event, self.force_body_stream_field);
let mut body = if websocket_continuation {
build_local_openai_responses_request_body_with_codex_model_capabilities_for_websocket_continuation(
client_event,
&self.mapped_model,
self.upstream_is_stream,
self.force_body_stream_field,
self.provider_type.as_str(),
self.provider_api_format.as_str(),
self.body_rules.as_ref(),
&self.request_headers,
self.codex_model_capabilities.as_ref(),
false,
)
} else {
build_local_openai_responses_request_body_with_codex_model_capabilities(
client_event,
&self.mapped_model,
self.upstream_is_stream,
self.force_body_stream_field,
self.provider_type.as_str(),
self.provider_api_format.as_str(),
self.body_rules.as_ref(),
&self.request_headers,
self.codex_model_capabilities.as_ref(),
false,
)
}?;
if let Some(patch) = self.model_directive_patch.as_ref() {
crate::ai_serving::apply_model_directive_mapping_patch(&mut body, patch);
// The patch is a deep merge and may reintroduce `stream`.
enforce_provider_body_stream_policy(
&mut body,
self.provider_api_format.as_str(),
self.upstream_is_stream,
require_body_stream_field,
);
}
let finalization = crate::ai_serving::OpenAiProviderRequestFinalization {
source_api_format: self.client_api_format.as_str(),
provider_api_format: self.provider_api_format.as_str(),
provider_type: self.provider_type.as_str(),
provider_model: self.mapped_model.as_str(),
source_model,
body_rules: self.body_rules.as_ref(),
upstream_is_stream: self.upstream_is_stream,
require_body_stream_field,
};
let finalized = if websocket_continuation {
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy_for_websocket_continuation(
&mut body,
finalization,
self.codex_model_capabilities.as_ref(),
self.reasoning_replay_policy,
)
} else {
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy(
&mut body,
finalization,
self.codex_model_capabilities.as_ref(),
self.reasoning_replay_policy,
)
};
finalized.ok()?;
Some(body)
}
}
fn update_normalization_bytes_digest(digest: &mut sha2::Sha256, value: &[u8]) {
use sha2::Digest as _;
digest.update((value.len() as u64).to_be_bytes());
digest.update(value);
}
fn update_normalization_string_digest(digest: &mut sha2::Sha256, value: &str) {
update_normalization_bytes_digest(digest, value.as_bytes());
}
fn update_normalization_optional_string_digest(digest: &mut sha2::Sha256, value: Option<&str>) {
use sha2::Digest as _;
match value {
Some(value) => {
digest.update([1]);
update_normalization_string_digest(digest, value);
}
None => digest.update([0]),
}
}
fn update_normalization_string_vec_digest(digest: &mut sha2::Sha256, values: &[String]) {
use sha2::Digest as _;
digest.update((values.len() as u64).to_be_bytes());
for value in values {
update_normalization_string_digest(digest, value);
}
}
fn update_normalization_optional_json_digest(
digest: &mut sha2::Sha256,
value: Option<&serde_json::Value>,
) {
use sha2::Digest as _;
match value {
Some(value) => {
digest.update([1]);
update_normalization_json_digest(digest, value);
}
None => digest.update([0]),
}
}
fn update_normalization_json_digest(digest: &mut sha2::Sha256, value: &serde_json::Value) {
use serde_json::Value;
use sha2::Digest as _;
match value {
Value::Null => digest.update(b"n"),
Value::Bool(value) => digest.update(if *value { b"t" } else { b"f" }),
Value::Number(value) => {
digest.update(b"d");
update_normalization_string_digest(digest, value.to_string().as_str());
}
Value::String(value) => {
digest.update(b"s");
update_normalization_string_digest(digest, value);
}
Value::Array(values) => {
digest.update(b"[");
digest.update((values.len() as u64).to_be_bytes());
for value in values {
update_normalization_json_digest(digest, value);
}
digest.update(b"]");
}
Value::Object(values) => {
digest.update(b"{");
digest.update((values.len() as u64).to_be_bytes());
let mut keys = values.keys().collect::<Vec<_>>();
keys.sort_unstable();
for key in keys {
update_normalization_string_digest(digest, key);
update_normalization_json_digest(digest, &values[key]);
}
digest.update(b"}");
}
}
}
fn update_normalization_body_rule_headers_digest(
digest: &mut sha2::Sha256,
headers: &http::HeaderMap,
body_rules: Option<&serde_json::Value>,
) {
use sha2::Digest as _;
let dependencies =
crate::ai_serving::transport::rules::body_rules_request_header_dependencies(body_rules);
digest.update((dependencies.len() as u64).to_be_bytes());
for name in dependencies {
update_normalization_string_digest(digest, name.as_str());
let value = headers
.get(name.as_str())
.and_then(|value| value.to_str().ok())
.map(str::trim);
update_normalization_optional_string_digest(digest, value);
}
}
fn update_normalization_codex_capabilities_digest(
digest: &mut sha2::Sha256,
capabilities: Option<&crate::ai_serving::CodexResponsesModelCapabilities>,
) {
use sha2::Digest as _;
let Some(capabilities) = capabilities else {
digest.update([0]);
return;
};
digest.update([1]);
digest.update([
u8::from(capabilities.use_responses_lite),
u8::from(capabilities.supports_reasoning_summary_parameter),
u8::from(capabilities.supports_parallel_tool_calls),
u8::from(capabilities.support_verbosity),
]);
update_normalization_optional_string_digest(
digest,
capabilities.default_reasoning_effort.as_deref(),
);
update_normalization_optional_string_digest(
digest,
capabilities.default_reasoning_summary.as_deref(),
);
update_normalization_string_vec_digest(digest, &capabilities.supported_reasoning_efforts);
update_normalization_optional_string_digest(digest, capabilities.default_verbosity.as_deref());
update_normalization_string_vec_digest(digest, &capabilities.supported_service_tiers);
}
#[cfg(test)]
mod continuation_fingerprint_tests {
use http::HeaderValue;
use serde_json::json;
use super::ResponsesWebSocketBodyNormalization;
use crate::ai_serving::OpenAiResponsesReasoningReplayPolicy;
#[test]
fn normalization_fingerprint_is_stable_for_json_object_key_order() {
let first = ResponsesWebSocketBodyNormalization::for_tests("provider-model")
.with_model_directive_patch_for_tests(json!({"reasoning": {"effort": "high"}, "x": 1}));
let second = ResponsesWebSocketBodyNormalization::for_tests("provider-model")
.with_model_directive_patch_for_tests(json!({"x": 1, "reasoning": {"effort": "high"}}));
assert_eq!(
first.continuation_fingerprint(),
second.continuation_fingerprint()
);
}
#[test]
fn normalization_fingerprint_changes_with_effective_contract() {
let base = ResponsesWebSocketBodyNormalization::for_tests("provider-model");
let changed_policy = base.clone().with_reasoning_replay_policy_for_tests(
OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque,
);
assert_ne!(
base.continuation_fingerprint(),
changed_policy.continuation_fingerprint()
);
let changed_patch = base
.clone()
.with_model_directive_patch_for_tests(json!({"reasoning": {"effort": "low"}}));
assert_ne!(
base.continuation_fingerprint(),
changed_patch.continuation_fingerprint()
);
}
#[test]
fn normalization_fingerprint_ignores_unrelated_volatile_request_headers() {
let body_rules = json!([{
"action": "set",
"path": "store",
"value": false,
"condition": {
"source": "request_headers",
"path": "x-contract",
"op": "eq",
"value": "enabled"
}
}]);
let mut first = ResponsesWebSocketBodyNormalization::for_tests("provider-model")
.with_body_rules_for_tests(body_rules);
first
.request_headers
.insert("x-contract", HeaderValue::from_static("enabled"));
first
.request_headers
.insert("x-request-id", HeaderValue::from_static("request-1"));
first
.request_headers
.insert("cf-ray", HeaderValue::from_static("edge-1"));
let mut second = first.clone();
second
.request_headers
.insert("x-request-id", HeaderValue::from_static("request-2"));
second
.request_headers
.insert("cf-ray", HeaderValue::from_static("edge-2"));
assert_eq!(
first.continuation_fingerprint(),
second.continuation_fingerprint(),
"headers that no body-rule condition reads must not invalidate a persisted continuation"
);
}
#[test]
fn normalization_fingerprint_tracks_headers_used_by_body_rule_conditions() {
let body_rules = json!([{
"action": "set",
"path": "store",
"value": false,
"condition": {
"source": "request_headers",
"path": "X-Contract",
"op": "eq",
"value": "enabled"
}
}]);
let mut first = ResponsesWebSocketBodyNormalization::for_tests("provider-model")
.with_body_rules_for_tests(body_rules.clone());
first
.request_headers
.insert("x-contract", HeaderValue::from_static("enabled"));
let mut second = ResponsesWebSocketBodyNormalization::for_tests("provider-model")
.with_body_rules_for_tests(body_rules);
second
.request_headers
.insert("x-contract", HeaderValue::from_static("disabled"));
assert_ne!(
first.continuation_fingerprint(),
second.continuation_fingerprint(),
"a header that controls an effective body-rule condition remains part of the contract"
);
}
}
/// Builds one upstream decision for a Responses WebSocket turn. The session
/// reuses this decision for same-model turns and invokes the planner again when
/// a later `response.create` changes the public model.
pub(crate) async fn maybe_build_responses_websocket_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
auth_snapshot: Option<&crate::ai_serving::GatewayAuthApiKeySnapshot>,
body_json: &serde_json::Value,
excluded_key_ids: Option<&BTreeSet<String>>,
excluded_codex_account_ids: Option<&BTreeSet<String>>,
pinned_candidate: Option<&ResponsesWebSocketPinnedCandidate>,
) -> Result<Option<ResponsesWebSocketDecision>, GatewayError> {
let Some(spec) = resolve_stream_spec(crate::ai_serving::OPENAI_RESPONSES_STREAM_PLAN_KIND)
else {
return Ok(None);
};
let Some(input) = resolve_local_openai_responses_decision_input_with_snapshot(
state,
parts,
trace_id,
decision,
body_json,
spec.decision_kind,
auth_snapshot,
)
.await?
else {
return Ok(None);
};
// The continuation discriminator belongs to the WebSocket protocol, not
// provider body rules/redaction. Capture it before the planner creates its
// effective body so a rule cannot accidentally turn a valid chain into a
// first-turn Lite normalization pass.
let websocket_continuation = body_json
.get("previous_response_id")
.and_then(serde_json::Value::as_str)
.is_some_and(|value| !value.trim().is_empty());
let body_json = input.effective_body_json(body_json);
let (mut source, _) = build_local_openai_responses_candidate_attempt_source(
state, trace_id, &input, body_json, spec,
)
.await?;
while let Some(attempt) = source.next_attempt().await? {
// `next_attempt` may return with a distributed pool-key lease. Arm a
// guard before the first await so owner-task timeout/cancellation
// cannot strand that lease until its server-side TTL expires.
let mut planning_lease = ResponsesWebSocketPlanningLeaseGuard::new(
state,
attempt.eligible.orchestration.pool_key_lease.as_ref(),
);
if pinned_candidate.is_some_and(|pinned| !pinned.matches(&attempt.eligible.candidate)) {
planning_lease.release().await;
continue;
}
if excluded_key_ids
.is_some_and(|key_ids| key_ids.contains(attempt.eligible.candidate.key_id.as_str()))
{
planning_lease.release().await;
continue;
}
let Some(adapter) = responses_websocket_adapter(
&attempt.eligible.transport.provider.provider_type,
attempt.eligible.transport.provider.config.as_ref(),
) else {
planning_lease.release().await;
continue;
};
// Captured before `attempt` is consumed so a later continuation turn can
// reproduce this candidate's body normalization without re-planning.
let transport = std::sync::Arc::clone(&attempt.eligible.transport);
let effective_auth_type =
aether_provider_transport::auth::resolve_local_auth_type_for_transport_format(
transport.as_ref(),
);
let candidate_provider_api_format = attempt.eligible.provider_api_format.clone();
let payload = match maybe_build_local_openai_responses_decision_payload_for_candidate_with_websocket_mode(
state,
parts,
trace_id,
body_json,
&input,
attempt,
spec,
websocket_continuation,
)
.await
{
Ok(Some(payload)) => payload,
Ok(None) => {
planning_lease.release().await;
continue;
}
Err(error) => {
planning_lease.release().await;
return Err(error);
}
};
if payload
.provider_type
.as_deref()
.is_some_and(|value| value.trim().eq_ignore_ascii_case("codex"))
&& crate::orchestration::codex_account_id_from_headers(
&payload.provider_request_headers,
)
.is_some_and(|account_id| {
excluded_codex_account_ids
.is_some_and(|account_ids| account_ids.contains(account_id))
})
{
planning_lease.release().await;
continue;
}
match codex_quota_breaker_blocks_candidate(
state,
payload.provider_type.as_deref(),
payload.key_id.as_deref(),
&payload.provider_request_headers,
)
.await
{
Ok(true) => {
planning_lease.release().await;
continue;
}
Ok(false) => {}
Err(error) => log_codex_quota_breaker_check_failure(&error),
}
if payload
.provider_type
.as_deref()
.is_some_and(|value| adapter.supports_provider_type(value))
&& payload.provider_api_format.as_deref().is_some_and(|value| {
crate::ai_serving::normalize_api_format_alias(value) == "openai:responses"
})
{
let mapped_model = payload.mapped_model.clone().unwrap_or_default();
let source_model = body_json
.get("model")
.and_then(serde_json::Value::as_str)
.unwrap_or(input.requested_model.as_str());
let normalization = ResponsesWebSocketBodyNormalization {
provider_type: transport.provider.provider_type.clone(),
provider_api_format: candidate_provider_api_format.clone(),
client_api_format: local_openai_responses_spec_metadata(spec)
.api_format
.to_string(),
requested_model: input.requested_model.clone(),
upstream_is_stream: payload.upstream_is_stream,
force_body_stream_field: endpoint_config_forces_body_stream_field(
transport.endpoint.config.as_ref(),
),
body_rules: transport.endpoint.body_rules.clone(),
request_headers: input.effective_headers(&parts.headers).clone(),
codex_model_capabilities: codex_model_capabilities_for_transport(
&transport,
candidate_provider_api_format.as_str(),
mapped_model.as_str(),
source_model,
),
reasoning_replay_policy: openai_responses_reasoning_replay_policy(
transport.provider.provider_type.as_str(),
transport.endpoint.base_url.as_str(),
mapped_model.as_str(),
),
model_directive_patch: input
.model_directive_policy
.resolve_reasoning(
candidate_provider_api_format.as_str(),
Some(&input.requested_model),
)
.mapping_patch_for_mapped_model(mapped_model.as_str())
.ok()
.flatten(),
mapped_model,
};
let decision = ResponsesWebSocketDecision {
execution: payload,
adapter,
normalization,
effective_auth_type,
};
// The decision report context now carries the lease identity. The
// WebSocket ownership layer takes over before any further await.
planning_lease.disarm();
return Ok(Some(decision));
}
planning_lease.release().await;
}
Ok(None)
}
async fn release_responses_websocket_planning_lease(
state: &AppState,
lease: Option<&RuntimeLockLease>,
) -> bool {
let Some(lease) = lease else {
return true;
};
match crate::handlers::shared::provider_pool::release_admin_provider_pool_key_lease(
state.runtime_state.as_ref(),
lease,
)
.await
{
Ok(_) => true,
Err(error) => {
tracing::warn!(
error = ?error,
"gateway Responses WebSocket planner failed to release an unused pool key lease"
);
false
}
}
}
@@ -8,9 +8,14 @@ use crate::constants::{
API_KEY_CONCURRENCY_WAIT_POLL_INTERVAL_MS, API_KEY_CONCURRENCY_WAIT_TIMEOUT_MS,
};
use crate::scheduler::candidate::SchedulerSkippedCandidate;
use crate::scheduler::config::SchedulerOrderingConfig;
use crate::GatewayError;
impl<'a> PlannerAppState<'a> {
/// `ordering_config` is the request's routing-policy derived scheduler
/// config (see `SchedulerOrderingConfig::from_routing_policy`). `None`
/// falls back to the runtime default.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn list_selectable_candidates(
self,
api_format: &str,
@@ -21,6 +26,7 @@ impl<'a> PlannerAppState<'a> {
client_session_affinity: Option<&ClientSessionAffinity>,
now_unix_secs: u64,
enable_model_directives: bool,
ordering_config: Option<SchedulerOrderingConfig>,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
crate::scheduler::candidate::list_selectable_candidates(
self.app().data.as_ref(),
@@ -33,10 +39,12 @@ impl<'a> PlannerAppState<'a> {
client_session_affinity,
now_unix_secs,
enable_model_directives,
ordering_config,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn list_selectable_candidates_with_skip_reasons(
self,
api_format: &str,
@@ -47,6 +55,7 @@ impl<'a> PlannerAppState<'a> {
client_session_affinity: Option<&ClientSessionAffinity>,
now_unix_secs: u64,
enable_model_directives: bool,
ordering_config: Option<SchedulerOrderingConfig>,
) -> Result<
(
Vec<SchedulerMinimalCandidateSelectionCandidate>,
@@ -64,10 +73,12 @@ impl<'a> PlannerAppState<'a> {
now_unix_secs,
enable_model_directives,
None,
ordering_config,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn list_selectable_candidates_with_skip_reasons_for_request_operation(
self,
api_format: &str,
@@ -79,6 +90,7 @@ impl<'a> PlannerAppState<'a> {
now_unix_secs: u64,
enable_model_directives: bool,
request_operation: Option<&str>,
ordering_config: Option<SchedulerOrderingConfig>,
) -> Result<
(
Vec<SchedulerMinimalCandidateSelectionCandidate>,
@@ -103,6 +115,7 @@ impl<'a> PlannerAppState<'a> {
attempt_now_unix_secs,
enable_model_directives,
request_operation,
ordering_config,
)
.await?;
@@ -123,6 +136,7 @@ impl<'a> PlannerAppState<'a> {
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn list_selectable_enumerated_candidates_with_skip_reasons(
self,
api_format: &str,
@@ -132,6 +146,7 @@ impl<'a> PlannerAppState<'a> {
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
client_session_affinity: Option<&ClientSessionAffinity>,
now_unix_secs: u64,
ordering_config: Option<SchedulerOrderingConfig>,
) -> Result<
(
Vec<SchedulerMinimalCandidateSelectionCandidate>,
@@ -148,10 +163,12 @@ impl<'a> PlannerAppState<'a> {
auth_snapshot,
client_session_affinity,
now_unix_secs,
ordering_config,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn list_selectable_candidates_for_required_capability_without_requested_model(
self,
candidate_api_format: &str,
@@ -160,6 +177,7 @@ impl<'a> PlannerAppState<'a> {
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
client_session_affinity: Option<&ClientSessionAffinity>,
now_unix_secs: u64,
ordering_config: Option<SchedulerOrderingConfig>,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
let wait_timeout = Duration::from_millis(API_KEY_CONCURRENCY_WAIT_TIMEOUT_MS);
let wait_interval = Duration::from_millis(API_KEY_CONCURRENCY_WAIT_POLL_INTERVAL_MS.max(1));
@@ -176,6 +194,7 @@ impl<'a> PlannerAppState<'a> {
auth_snapshot,
client_session_affinity,
attempt_now_unix_secs,
ordering_config,
)
.await?;
+27 -21
View File
@@ -9,6 +9,7 @@ pub(crate) use aether_ai_formats::api::{
apply_codex_openai_responses_lite_header_with_capabilities,
apply_codex_openai_responses_special_body_edits,
apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities,
apply_codex_openai_responses_websocket_continuation_body_edits_with_source_model_and_capabilities,
apply_codex_openai_special_headers, apply_model_directive_mapping_patch,
apply_model_directive_overrides_from_model, apply_model_directive_overrides_from_request,
apply_openai_responses_compact_special_body_edits, build_chatgpt_web_image_request_body,
@@ -37,6 +38,7 @@ pub(crate) use aether_ai_formats::api::{
build_standard_request_body_from_canonical_with_model_directives,
build_standard_request_body_with_model_directives,
build_standard_request_body_with_model_directives_and_request_headers,
build_standard_request_body_with_model_directives_and_request_headers_and_reasoning_replay_policy,
calculate_kiro_context_input_tokens, canonicalize_tool_arguments,
convert_claude_chat_response_to_openai_chat, convert_claude_response_to_openai_responses,
convert_gemini_chat_response_to_openai_chat, convert_gemini_response_to_openai_responses,
@@ -54,6 +56,8 @@ pub(crate) use aether_ai_formats::api::{
enforce_request_body_stream_field, estimate_kiro_tokens, extract_openai_text_content,
finalize_openai_provider_request,
finalize_openai_provider_request_with_codex_model_capabilities,
finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy,
finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy_for_websocket_continuation,
find_kiro_real_thinking_end_tag, find_kiro_real_thinking_end_tag_at_buffer_end,
find_kiro_real_thinking_start_tag, forbid_upstream_streaming_for_provider,
force_upstream_streaming_for_provider, gemini_request_is_image_generation,
@@ -82,10 +86,10 @@ pub(crate) use aether_ai_formats::api::{
parse_codex_auth_identity, parse_direct_request_body, parse_model_directive,
parse_model_directive_with_suffixes, parse_openai_stop_sequences,
parse_openai_tool_result_content, prepare_local_success_response_parts,
prepare_local_success_response_parts_owned, project_codex_openai_image_api_request_body,
project_openai_image_api_request_body, provider_adaptation_allows_sync_finalize_envelope,
provider_adaptation_anchor_api_format, provider_adaptation_descriptor_for_envelope,
provider_adaptation_descriptor_for_provider_type,
prepare_local_success_response_parts_owned, project_codex_catalog_model_card,
project_codex_openai_image_api_request_body, project_openai_image_api_request_body,
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,
provider_adaptation_descriptor_for_envelope, provider_adaptation_descriptor_for_provider_type,
provider_adaptation_requires_eventstream_accept,
provider_adaptation_should_unwrap_stream_envelope,
provider_private_response_allows_sync_finalize, record_converted_response_history,
@@ -123,9 +127,9 @@ pub(crate) use aether_ai_formats::api::{
OpenAIResponsesProviderState, OpenAiImageNormalizeOptions, OpenAiImageOperation,
OpenAiImageRequestForGemini, OpenAiImageResponseFormat, OpenAiImageStreamState,
OpenAiImageSyncFinalizeProduct, OpenAiProviderRequestFinalization,
ProviderAdaptationDescriptor, ProviderAdaptationSurface, ProviderPrivateStreamNormalizer,
ReasoningEffort, RequestConversionKind, ResponseHistoryRecord, ServiceTier,
StandardCrossFormatSyncProduct, StandardSyncFinalizeNormalizedProduct,
OpenAiResponsesReasoningReplayPolicy, ProviderAdaptationDescriptor, ProviderAdaptationSurface,
ProviderPrivateStreamNormalizer, ReasoningEffort, RequestConversionKind, ResponseHistoryRecord,
ServiceTier, StandardCrossFormatSyncProduct, StandardSyncFinalizeNormalizedProduct,
StreamingStandardFormatMatrix, SyncChatResponseConversionKind, SyncCliResponseConversionKind,
SyncToStreamBridgeOutcome, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME, CLAUDE_CHAT_STREAM_PLAN_KIND,
CLAUDE_CHAT_STREAM_SUCCESS_REPORT_KIND, CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND,
@@ -134,14 +138,14 @@ pub(crate) use aether_ai_formats::api::{
CLAUDE_CLI_STREAM_SUCCESS_REPORT_KIND, CLAUDE_CLI_SYNC_ERROR_REPORT_KIND,
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND, CLAUDE_CLI_SYNC_PLAN_KIND,
CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND, CLAUDE_COUNT_TOKENS_SYNC_PLAN_KIND,
CODEX_OPENAI_IMAGE_DEFAULT_MODEL, CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT,
CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_MODEL, CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_PROMPT,
CODEX_OPENAI_IMAGE_INTERNAL_MODEL, EXECUTION_RUNTIME_STREAM_ACTION,
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_ACTION,
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_CHAT_STREAM_PLAN_KIND,
GEMINI_CHAT_STREAM_SUCCESS_REPORT_KIND, GEMINI_CHAT_SYNC_ERROR_REPORT_KIND,
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND, GEMINI_CLI_STREAM_PLAN_KIND,
CODEX_LIVE_STREAM_PLAN_KIND, CODEX_OPENAI_IMAGE_DEFAULT_MODEL,
CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT, CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_MODEL,
CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_PROMPT, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
EXECUTION_RUNTIME_STREAM_ACTION, EXECUTION_RUNTIME_STREAM_DECISION_ACTION,
EXECUTION_RUNTIME_SYNC_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_STREAM_SUCCESS_REPORT_KIND,
GEMINI_CHAT_SYNC_ERROR_REPORT_KIND, GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND,
GEMINI_CHAT_SYNC_PLAN_KIND, GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND, GEMINI_CLI_STREAM_PLAN_KIND,
GEMINI_CLI_STREAM_SUCCESS_REPORT_KIND, GEMINI_CLI_SYNC_ERROR_REPORT_KIND,
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND, GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME,
@@ -156,8 +160,8 @@ pub(crate) use aether_ai_formats::api::{
OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND,
OPENAI_IMAGE_STREAM_SUCCESS_REPORT_KIND, OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND,
OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND,
OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND,
OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND,
OPENAI_REALTIME_STREAM_PLAN_KIND, OPENAI_RERANK_SYNC_PLAN_KIND,
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND,
OPENAI_RESPONSES_COMPACT_SYNC_ERROR_REPORT_KIND,
OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND,
OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_STREAM_PLAN_KIND,
@@ -171,10 +175,12 @@ pub(crate) use aether_ai_formats::api::{
};
pub(crate) use aether_ai_formats::{
api_format_defaults_to_client_error_failover, api_format_defaults_to_non_stream,
api_format_permission_covers, intersect_api_format_allowed_lists, is_embedding_api_format,
is_rerank_api_format, openai_responses_request_operation,
openai_responses_synthetic_reasoning_item_id,
strip_incompatible_openai_responses_reasoning_items, ApiOperation, ClientSurface,
api_format_permission_covers, codex_responses_lite_tool_is_client_executed,
intersect_api_format_allowed_lists, is_embedding_api_format, is_rerank_api_format,
openai_responses_request_operation, openai_responses_synthetic_reasoning_item_id,
strip_incompatible_openai_responses_reasoning_items,
strip_incompatible_openai_responses_reasoning_items_with_policy, ApiOperation, ClientSurface,
CODEX_CLIENT_VERSION, OPENAI_RESPONSES_OPERATION_COMPACT,
};
pub(crate) fn plan_kind_matches_api_operation(
@@ -59,7 +59,8 @@ pub(crate) mod windsurf {
}
pub(crate) use aether_provider_transport::{
append_transport_diagnostics_to_value, apply_local_auth_config_header_overrides,
append_transport_diagnostics_to_value, apply_codex_fingerprint_convergence,
apply_codex_fingerprint_convergence_with_context, apply_local_auth_config_header_overrides,
apply_local_body_rules, apply_local_body_rules_with_request_headers, apply_local_header_rules,
apply_local_header_rules_with_request_headers, apply_standard_provider_request_body_rules,
apply_standard_provider_request_body_rules_with_request_headers,
@@ -74,6 +75,7 @@ pub(crate) use aether_provider_transport::{
build_request_trace_proxy_value, build_same_format_provider_headers,
build_same_format_provider_request_body,
build_same_format_provider_request_body_with_compatibility_report,
build_same_format_provider_request_body_with_compatibility_report_and_reasoning_replay_policy,
build_same_format_provider_upstream_url, build_standard_plan_fallback_headers,
build_standard_plan_fallback_openai_chat_url,
build_standard_plan_fallback_openai_responses_url, build_standard_provider_request_headers,
@@ -105,8 +107,9 @@ pub(crate) use aether_provider_transport::{
supports_local_generic_oauth_request_auth_resolution,
supports_local_oauth_request_auth_resolution, transport_proxy_is_locally_supported,
transport_supports_api_operation, video_create_transport_unsupported_reason,
AnthropicCompatibilityProfile, CandidateTransportPolicyFacts, GatewayProviderTransportSnapshot,
GeminiCliRequestAuth, GeminiCliRequestAuthSupport, GeminiCliRequestAuthUnsupportedReason,
AnthropicCompatibilityProfile, CandidateTransportPolicyFacts,
CodexFingerprintConvergenceContext, GatewayProviderTransportSnapshot, GeminiCliRequestAuth,
GeminiCliRequestAuthSupport, GeminiCliRequestAuthUnsupportedReason,
GeminiCliRequestEnvelopeSupport, GeminiFilesHeadersInput, GeminiFilesRequestBodyError,
GeminiFilesRequestBodyParts, GrokHeaderInput, LocalResolvedOAuthRequestAuth,
ProviderOpenAiImageHeadersInput, ProviderVideoCreateFamily, ProviderVideoCreateHeadersInput,
+4
View File
@@ -5,9 +5,11 @@ pub(crate) fn normalized_signature(api_format: &str) -> Option<&'static str> {
"openai:rerank" => Some("openai:rerank"),
"openai:responses" => Some("openai:responses"),
"openai:responses:compact" => Some("openai:responses:compact"),
"openai:realtime" => Some("openai:realtime"),
"openai:search" => Some("openai:search"),
"openai:image" => Some("openai:image"),
"openai:video" => Some("openai:video"),
"codex:live" => Some("codex:live"),
_ => None,
}
}
@@ -19,9 +21,11 @@ pub(crate) fn local_path(api_format: &str) -> Option<&'static str> {
"openai:rerank" => Some("/v1/rerank"),
"openai:responses" => Some("/v1/responses"),
"openai:responses:compact" => Some("/v1/responses/compact"),
"openai:realtime" => Some("/v1/realtime"),
"openai:search" => Some("/v1/alpha/search"),
"openai:image" => Some("/v1/images/generations"),
"openai:video" => Some("/v1/videos"),
"codex:live" => Some("/v1/live"),
_ => None,
}
}
+145 -6
View File
@@ -1,13 +1,20 @@
use std::net::SocketAddr;
use axum::body::Body;
use axum::extract::Request;
use axum::http::{header, HeaderValue, Response, StatusCode};
use axum::routing::{any, post};
use axum::extract::ws::WebSocketUpgrade;
use axum::extract::{ConnectInfo, Request, State};
use axum::http::{header, HeaderMap, HeaderValue, Response, StatusCode, Uri};
use axum::routing::{any, get, post};
use axum::Router;
use super::{aliyun, claude, doubao, gemini, jina, openai};
use crate::api::response::build_local_http_error_response_with_request_path;
use crate::headers::extract_or_generate_trace_id;
use crate::{handlers::proxy::proxy_request, state::AppState, GatewayError};
use crate::{
handlers::proxy::{live_websocket, proxy_request, realtime_websocket, responses_websocket},
state::AppState,
GatewayError,
};
// Router registration patterns live here so AI public ingress has a single mount registry.
// They intentionally stay separate from manifest-facing route inventories in constants.rs,
@@ -18,6 +25,8 @@ const AI_POST_ROUTE_PATTERNS: &[&str] = &[
"/v1/rerank",
"/v1/responses",
"/v1/responses/compact",
"/v1/live",
"/v1/realtime/calls",
"/v1/alpha/search",
"/v1/images/generations",
"/v1/images/edits",
@@ -51,8 +60,16 @@ const AI_ANY_ROUTE_PATTERNS: &[&str] = &[
pub(crate) fn mount_ai_routes(mut router: Router<AppState>) -> Router<AppState> {
for path in AI_POST_ROUTE_PATTERNS {
router = router.route(path, post(proxy_request));
router = if *path == "/v1/responses" {
router.route(path, get(responses_websocket).post(proxy_request))
} else if *path == "/v1/live" {
router.route(path, get(live_websocket).post(proxy_request))
} else {
router.route(path, post(proxy_request))
};
}
router = router.route("/v1/live/{call_id}", get(live_websocket));
router = router.route("/v1/realtime", get(dispatch_realtime_websocket));
for path in CLAUDE_POST_ROUTE_PATTERNS {
router = router.route(
path,
@@ -65,6 +82,61 @@ pub(crate) fn mount_ai_routes(mut router: Router<AppState>) -> Router<AppState>
router
}
async fn dispatch_realtime_websocket(
State(state): State<AppState>,
ConnectInfo(remote_addr): ConnectInfo<SocketAddr>,
ws: WebSocketUpgrade,
headers: HeaderMap,
uri: Uri,
) -> Result<Response<Body>, GatewayError> {
if realtime_query_is_codex_live(uri.query(), &headers) {
live_websocket(State(state), ConnectInfo(remote_addr), ws, headers, uri).await
} else {
realtime_websocket(State(state), ConnectInfo(remote_addr), ws, headers, uri).await
}
}
fn realtime_query_is_codex_live(query: Option<&str>, headers: &HeaderMap) -> bool {
let mut has_call_id = false;
let mut has_live_intent = false;
let mut duplicate_or_conflicting_intent = false;
for (key, value) in url::form_urlencoded::parse(query.unwrap_or_default().as_bytes()) {
if key.eq_ignore_ascii_case("call_id") {
has_call_id = true;
} else if key.eq_ignore_ascii_case("intent") {
if has_live_intent || !value.eq_ignore_ascii_case("quicksilver") {
duplicate_or_conflicting_intent = true;
}
has_live_intent = true;
}
}
if has_live_intent {
return !duplicate_or_conflicting_intent;
}
// `call_id` is part of the ordinary OpenAI Realtime WebRTC sideband
// contract too. Without Codex's explicit v1 intent it must remain on the
// generic Realtime handler instead of being authorized as `codex:live`.
if has_call_id {
return false;
}
// Realtime v2 has no intent selector. A malformed or conflicting intent
// must not be reclassified as v2 merely because a Codex originator is
// present.
let has_model = url::form_urlencoded::parse(query.unwrap_or_default().as_bytes())
.any(|(key, value)| key.eq_ignore_ascii_case("model") && !value.trim().is_empty());
let Some(originator) = crate::headers::header_value_str(headers, "originator") else {
return false;
};
has_model
&& originator.split_whitespace().next().is_some_and(|value| {
value.eq_ignore_ascii_case("codex_cli_rs")
|| value.to_ascii_lowercase().starts_with("codex_cli_rs/")
|| value.eq_ignore_ascii_case("codex_work_desktop")
|| value.eq_ignore_ascii_case("codex_work_web")
|| value.eq_ignore_ascii_case("codex_work_mobile")
})
}
async fn claude_method_not_allowed(request: Request) -> Result<Response<Body>, GatewayError> {
let trace_id = extract_or_generate_trace_id(request.headers());
let mut response = build_local_http_error_response_with_request_path(
@@ -120,7 +192,72 @@ pub(crate) fn admin_default_body_rules_for_signature(
#[cfg(test)]
mod tests {
use super::{admin_endpoint_signature_parts, public_api_format_local_path};
use axum::http::{HeaderMap, HeaderValue};
use super::{
admin_endpoint_signature_parts, public_api_format_local_path, realtime_query_is_codex_live,
AI_POST_ROUTE_PATTERNS,
};
#[test]
fn registers_and_dispatches_realtime_live_aliases() {
assert!(AI_POST_ROUTE_PATTERNS.contains(&"/v1/realtime/calls"));
let no_headers = HeaderMap::new();
assert!(realtime_query_is_codex_live(
Some("intent=quicksilver&call_id=rtc_opaque"),
&no_headers
));
assert!(realtime_query_is_codex_live(
Some("intent=quicksilver&c%61ll_id=rtc_encoded"),
&no_headers
));
assert!(!realtime_query_is_codex_live(
Some("c%61ll_id=rtc_encoded"),
&no_headers
));
assert!(!realtime_query_is_codex_live(None, &no_headers));
assert!(!realtime_query_is_codex_live(Some("call_id="), &no_headers));
assert!(!realtime_query_is_codex_live(
Some("call_id=%20"),
&no_headers
));
assert!(realtime_query_is_codex_live(
Some("intent=quicksilver&model=gpt-realtime-1.5"),
&no_headers
));
assert!(!realtime_query_is_codex_live(
Some("model=gpt-realtime-1.5"),
&no_headers
));
assert!(!realtime_query_is_codex_live(
Some("intent=other&model=gpt-realtime-1.5"),
&no_headers
));
assert!(!realtime_query_is_codex_live(
Some("intent=quicksilver&intent=other&model=gpt-realtime-1.5"),
&no_headers
));
let mut codex_v2_headers = HeaderMap::new();
codex_v2_headers.insert("originator", HeaderValue::from_static("codex_work_desktop"));
assert!(realtime_query_is_codex_live(
Some("model=gpt-live-1-codex"),
&codex_v2_headers
));
assert!(!realtime_query_is_codex_live(
Some("call_id=rtc_ordinary&model=gpt-live-1-codex"),
&codex_v2_headers
));
assert!(!realtime_query_is_codex_live(
Some("call_id=rtc_one&call_id=rtc_two"),
&codex_v2_headers
));
let mut ordinary_headers = HeaderMap::new();
ordinary_headers.insert("originator", HeaderValue::from_static("openai-python"));
assert!(!realtime_query_is_codex_live(
Some("model=gpt-realtime-1.5"),
&ordinary_headers
));
}
#[test]
fn supports_data_api_endpoint_signatures_and_public_paths() {
@@ -148,6 +285,8 @@ mod tests {
),
("openai:rerank", "openai", "rerank", "/v1/rerank"),
("openai:search", "openai", "search", "/v1/alpha/search"),
("openai:realtime", "openai", "realtime", "/v1/realtime"),
("codex:live", "codex", "live", "/v1/live"),
("jina:rerank", "jina", "rerank", "/v1/rerank"),
] {
assert_eq!(
+34
View File
@@ -50,12 +50,40 @@ pub(crate) async fn health(State(state): State<AppState>) -> impl IntoResponse {
"rejected": snapshot.rejected,
})
});
let websocket_connection_concurrency =
state
.websocket_connection_concurrency_snapshot()
.map(|snapshot| {
json!({
"limit": snapshot.limit,
"in_flight": snapshot.in_flight,
"available_permits": snapshot.available_permits,
"high_watermark": snapshot.high_watermark,
"rejected": snapshot.rejected,
})
});
let distributed_websocket_connection_concurrency = state
.distributed_websocket_connection_concurrency_snapshot()
.await
.ok()
.flatten()
.map(|snapshot| {
json!({
"limit": snapshot.limit,
"in_flight": snapshot.in_flight,
"available_permits": snapshot.available_permits,
"high_watermark": snapshot.high_watermark,
"rejected": snapshot.rejected,
})
});
Json(json!({
"status": "ok",
"component": "aether-gateway",
"control_api_enabled": true,
"request_concurrency": request_concurrency,
"distributed_request_concurrency": distributed_request_concurrency,
"websocket_connection_concurrency": websocket_connection_concurrency,
"distributed_websocket_connection_concurrency": distributed_websocket_connection_concurrency,
}))
}
@@ -113,6 +141,12 @@ pub(crate) async fn frontdoor_manifest(State(state): State<AppState>) -> impl In
"execution_runtime_configured": state.execution_runtime_configured(),
"request_concurrency_enabled": state.request_concurrency_snapshot().is_some(),
"distributed_request_concurrency_enabled": state.distributed_request_gate.is_some(),
"websocket_connection_concurrency_enabled": state
.websocket_connection_concurrency_snapshot()
.is_some(),
"distributed_websocket_connection_concurrency_enabled": state
.distributed_websocket_connection_gate
.is_some(),
"frontdoor_cors_enabled": cors_enabled,
"frontdoor_cors_allow_credentials": cors_allow_credentials,
"frontdoor_cors_allowed_origins": cors_allowed_origins,
+3 -15
View File
@@ -85,29 +85,17 @@ async fn read_last_backup_slot(app: &AppState) -> Result<Option<String>, Gateway
#[cfg(test)]
mod tests {
use crate::backup::schedule::{BackupSchedule, BackupScheduleUnit};
use crate::task_runtime::{task_definition, TASK_KEY_SYSTEM_S3_BACKUP};
#[test]
fn backup_worker_skips_already_recorded_slot() {
let schedule = BackupSchedule {
unit: BackupScheduleUnit::Days,
interval: 1,
minute: 0,
hour: 3,
weekday: 1,
month_day: 1,
};
let now = chrono::DateTime::parse_from_rfc3339("2026-05-24T03:00:30+08:00")
.unwrap()
.with_timezone(&chrono::Utc);
let slot = schedule.due_slot(now).expect("slot should be due");
let slot = "days:2026-05-23T19:00:00Z";
assert!(super::should_start_scheduled_backup(
Some("days:2026-05-22T19:00:00Z"),
&slot
slot
));
assert!(!super::should_start_scheduled_backup(Some(&slot), &slot));
assert!(!super::should_start_scheduled_backup(Some(slot), slot));
}
#[test]
@@ -0,0 +1,124 @@
//! Credential-safe compatibility probe for the Codex Responses WebSocket path.
//!
//! This binary preserves the established Codex CLI and environment contract.
//! The common Responses WebSocket flow lives in `support/responses_ws_probe`;
//! this profile owns only Codex authentication and header requirements.
#[path = "support/responses_ws_probe.rs"]
mod responses_ws_probe;
use aether_gateway::{CODEX_CLIENT_ORIGINATOR, CODEX_CLIENT_USER_AGENT};
use clap::Parser;
use http::header::{AUTHORIZATION, USER_AGENT};
use http::{HeaderMap, HeaderName, HeaderValue};
use responses_ws_probe::{
bearer_authorization_value, required_env, resolve_probe_url, run_profile_probe, turn_timeout,
ProbeArgs, ProbeConfig, ProbeFailure, ResponsesWebSocketProbeProfile,
};
const ACCESS_TOKEN_ENV: &str = "AETHER_CODEX_WS_PROBE_ACCESS_TOKEN";
const ACCOUNT_ID_ENV: &str = "AETHER_CODEX_WS_PROBE_ACCOUNT_ID";
const MODEL_ENV: &str = "AETHER_CODEX_WS_PROBE_MODEL";
const URL_ENV: &str = "AETHER_CODEX_WS_PROBE_URL";
#[derive(Parser)]
#[command(
name = "aether-codex-ws-probe",
about = "Verify a Codex Responses WebSocket endpoint without exposing credentials"
)]
struct Args {
/// WebSocket endpoint. If omitted, AETHER_CODEX_WS_PROBE_URL is used.
#[arg(long)]
url: Option<String>,
/// Per-turn receive timeout in seconds.
#[arg(long, default_value_t = 20, value_parser = clap::value_parser!(u64).range(1..=120))]
timeout_secs: u64,
}
impl From<Args> for ProbeArgs {
fn from(args: Args) -> Self {
Self {
url: args.url,
timeout_secs: args.timeout_secs,
}
}
}
struct CodexResponsesProbeProfile;
impl ResponsesWebSocketProbeProfile for CodexResponsesProbeProfile {
fn build_config(args: &ProbeArgs) -> Result<ProbeConfig, ProbeFailure> {
let url = resolve_probe_url(args, URL_ENV, None)?;
let access_token = required_env(ACCESS_TOKEN_ENV)?;
let account_id = required_env(ACCOUNT_ID_ENV)?;
let model = required_env(MODEL_ENV)?;
Ok(ProbeConfig::new(
url,
model,
turn_timeout(args),
handshake_headers(&access_token, &account_id)?,
Self::sent_header_names(),
))
}
fn sent_header_names() -> Vec<&'static str> {
vec![
"authorization",
"chatgpt-account-id",
"user-agent",
"originator",
]
}
}
fn handshake_headers(access_token: &str, account_id: &str) -> Result<HeaderMap, ProbeFailure> {
let account_id =
HeaderValue::from_str(account_id).map_err(|_| ProbeFailure::MissingConfiguration)?;
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, bearer_authorization_value(access_token)?);
headers.insert(HeaderName::from_static("chatgpt-account-id"), account_id);
headers.insert(
USER_AGENT,
HeaderValue::from_static(CODEX_CLIENT_USER_AGENT),
);
headers.insert(
HeaderName::from_static("originator"),
HeaderValue::from_static(CODEX_CLIENT_ORIGINATOR),
);
Ok(headers)
}
#[tokio::main]
async fn main() {
let exit_code = run_profile_probe::<CodexResponsesProbeProfile>(Args::parse().into()).await;
if exit_code != 0 {
std::process::exit(exit_code);
}
}
#[cfg(test)]
mod tests {
use http::header::{AUTHORIZATION, USER_AGENT};
use super::{handshake_headers, CodexResponsesProbeProfile, ResponsesWebSocketProbeProfile};
#[test]
fn codex_profile_keeps_its_required_handshake_headers() {
let headers =
handshake_headers("test-token", "test-account").expect("headers should build");
assert!(headers.contains_key(AUTHORIZATION));
assert!(headers.contains_key("chatgpt-account-id"));
assert!(headers.contains_key(USER_AGENT));
assert!(headers.contains_key("originator"));
assert_eq!(
CodexResponsesProbeProfile::sent_header_names(),
vec![
"authorization",
"chatgpt-account-id",
"user-agent",
"originator",
]
);
}
}
@@ -0,0 +1,104 @@
//! Credential-safe compatibility probe for the official OpenAI Responses
//! WebSocket endpoint.
//!
//! This profile uses standard API-key Bearer authentication and shares the
//! protocol flow with the Codex probe without inheriting Codex-specific
//! account headers or quota assumptions.
#[path = "support/responses_ws_probe.rs"]
mod responses_ws_probe;
use clap::Parser;
use http::header::AUTHORIZATION;
use http::HeaderMap;
use responses_ws_probe::{
bearer_authorization_value, required_env, resolve_probe_url, run_profile_probe, turn_timeout,
ProbeArgs, ProbeConfig, ProbeFailure, ResponsesWebSocketProbeProfile,
};
const API_KEY_ENV: &str = "AETHER_OPENAI_WS_PROBE_API_KEY";
const MODEL_ENV: &str = "AETHER_OPENAI_WS_PROBE_MODEL";
const URL_ENV: &str = "AETHER_OPENAI_WS_PROBE_URL";
const DEFAULT_URL: &str = "wss://api.openai.com/v1/responses";
#[derive(Parser)]
#[command(
name = "aether-openai-responses-ws-probe",
about = "Verify an OpenAI Responses WebSocket endpoint without exposing credentials"
)]
struct Args {
/// WebSocket endpoint. If omitted, AETHER_OPENAI_WS_PROBE_URL or the
/// official OpenAI endpoint is used.
#[arg(long)]
url: Option<String>,
/// Per-turn receive timeout in seconds.
#[arg(long, default_value_t = 20, value_parser = clap::value_parser!(u64).range(1..=120))]
timeout_secs: u64,
}
impl From<Args> for ProbeArgs {
fn from(args: Args) -> Self {
Self {
url: args.url,
timeout_secs: args.timeout_secs,
}
}
}
struct OpenAiResponsesProbeProfile;
impl ResponsesWebSocketProbeProfile for OpenAiResponsesProbeProfile {
fn build_config(args: &ProbeArgs) -> Result<ProbeConfig, ProbeFailure> {
let url = resolve_probe_url(args, URL_ENV, Some(DEFAULT_URL))?;
let api_key = required_env(API_KEY_ENV)?;
let model = required_env(MODEL_ENV)?;
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, bearer_authorization_value(&api_key)?);
Ok(ProbeConfig::new(
url,
model,
turn_timeout(args),
headers,
Self::sent_header_names(),
))
}
fn sent_header_names() -> Vec<&'static str> {
vec!["authorization"]
}
}
#[tokio::main]
async fn main() {
let exit_code = run_profile_probe::<OpenAiResponsesProbeProfile>(Args::parse().into()).await;
if exit_code != 0 {
std::process::exit(exit_code);
}
}
#[cfg(test)]
mod tests {
use http::header::AUTHORIZATION;
use super::{
bearer_authorization_value, responses_ws_probe::parse_probe_url,
OpenAiResponsesProbeProfile, ResponsesWebSocketProbeProfile, DEFAULT_URL,
};
#[test]
fn openai_profile_exposes_only_standard_bearer_authentication() {
let authorization = bearer_authorization_value("test-key").expect("header should build");
assert_eq!(authorization.to_str().ok(), Some("Bearer test-key"));
assert_eq!(
OpenAiResponsesProbeProfile::sent_header_names(),
vec![AUTHORIZATION.as_str()]
);
}
#[test]
fn openai_profile_uses_the_official_responses_websocket_endpoint_by_default() {
let url = parse_probe_url(DEFAULT_URL).expect("default OpenAI endpoint should be valid");
assert_eq!(url.as_str(), DEFAULT_URL);
}
}
@@ -0,0 +1,567 @@
//! Shared, credential-safe engine for Responses WebSocket compatibility probes.
//!
//! Provider profiles own their environment variables and handshake headers.
//! This module owns the common Responses WebSocket contract: two sequential
//! `response.create` warmups, continuation with `previous_response_id`, safe
//! event observation, and a redacted JSON report.
use std::env;
use std::time::{Duration, Instant};
use http::{HeaderMap, HeaderValue};
use serde::Serialize;
use serde_json::{json, Value};
use url::Url;
use wreq::ws::message::Message as WreqWsMessage;
const MAX_FRAME_SIZE: usize = 1 << 20;
const MAX_EVENTS_PER_TURN: usize = 16;
pub(crate) struct ProbeArgs {
pub(crate) url: Option<String>,
pub(crate) timeout_secs: u64,
}
pub(crate) struct ProbeConfig {
url: Url,
model: String,
turn_timeout: Duration,
handshake_headers: HeaderMap,
sent_header_names: Vec<&'static str>,
}
impl ProbeConfig {
pub(crate) fn new(
url: Url,
model: String,
turn_timeout: Duration,
handshake_headers: HeaderMap,
sent_header_names: Vec<&'static str>,
) -> Self {
Self {
url,
model,
turn_timeout,
handshake_headers,
sent_header_names,
}
}
}
/// A profile retains provider-specific authentication and configuration while
/// reusing one Responses protocol probe engine.
pub(crate) trait ResponsesWebSocketProbeProfile {
fn build_config(args: &ProbeArgs) -> Result<ProbeConfig, ProbeFailure>;
fn sent_header_names() -> Vec<&'static str>;
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum ProbeFailure {
MissingConfiguration,
InvalidEndpoint,
ClientBuild,
Handshake,
Upgrade,
Send,
ReceiveTimeout,
Receive,
RemoteError,
MissingResponseId,
UnexpectedFrame,
}
impl ProbeFailure {
const fn code(self) -> &'static str {
match self {
Self::MissingConfiguration => "missing_configuration",
Self::InvalidEndpoint => "invalid_endpoint",
Self::ClientBuild => "client_build_failed",
Self::Handshake => "handshake_failed",
Self::Upgrade => "upgrade_failed",
Self::Send => "send_failed",
Self::ReceiveTimeout => "receive_timeout",
Self::Receive => "receive_failed",
Self::RemoteError => "upstream_error_event",
Self::MissingResponseId => "response_id_not_observed",
Self::UnexpectedFrame => "unexpected_frame",
}
}
}
#[derive(Serialize)]
struct ProbeReport {
status: &'static str,
target_host: Option<String>,
handshake_status: Option<u16>,
sent_header_names: Vec<&'static str>,
received_header_names: Vec<String>,
observed_event_types: Vec<String>,
continuation_confirmed: bool,
elapsed_ms: u64,
error: Option<&'static str>,
}
impl ProbeReport {
fn failed(
config: Option<&ProbeConfig>,
sent_header_names: Vec<&'static str>,
started_at: Instant,
error: ProbeFailure,
) -> Self {
Self {
status: "failed",
target_host: config.and_then(target_host),
handshake_status: None,
sent_header_names,
received_header_names: Vec::new(),
observed_event_types: Vec::new(),
continuation_confirmed: false,
elapsed_ms: started_at.elapsed().as_millis() as u64,
error: Some(error.code()),
}
}
}
/// Runs a profile and returns the process exit code after emitting exactly one
/// credential-safe JSON report.
pub(crate) async fn run_profile_probe<P: ResponsesWebSocketProbeProfile>(args: ProbeArgs) -> i32 {
let started_at = Instant::now();
let config = match P::build_config(&args) {
Ok(config) => config,
Err(error) => {
print_report(&ProbeReport::failed(
None,
P::sent_header_names(),
started_at,
error,
));
return 2;
}
};
match run_probe(&config, started_at).await {
Ok(report) => {
print_report(&report);
0
}
Err(error) => {
print_report(&ProbeReport::failed(
Some(&config),
config.sent_header_names.clone(),
started_at,
error,
));
1
}
}
}
pub(crate) fn required_env(name: &str) -> Result<String, ProbeFailure> {
env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or(ProbeFailure::MissingConfiguration)
}
pub(crate) fn resolve_probe_url(
args: &ProbeArgs,
url_env: &str,
default_url: Option<&str>,
) -> Result<Url, ProbeFailure> {
let raw_url = args
.url
.as_deref()
.map(str::to_owned)
.or_else(|| env::var(url_env).ok())
.or_else(|| default_url.map(str::to_owned));
let Some(raw_url) = raw_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Err(ProbeFailure::MissingConfiguration);
};
parse_probe_url(raw_url)
}
pub(crate) fn parse_probe_url(raw: &str) -> Result<Url, ProbeFailure> {
let url = Url::parse(raw).map_err(|_| ProbeFailure::InvalidEndpoint)?;
if !matches!(url.scheme(), "ws" | "wss")
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(ProbeFailure::InvalidEndpoint);
}
Ok(url)
}
pub(crate) fn bearer_authorization_value(token: &str) -> Result<HeaderValue, ProbeFailure> {
HeaderValue::from_str(format!("Bearer {token}").as_str())
.map_err(|_| ProbeFailure::MissingConfiguration)
}
pub(crate) const fn turn_timeout(args: &ProbeArgs) -> Duration {
Duration::from_secs(args.timeout_secs)
}
async fn run_probe(config: &ProbeConfig, started_at: Instant) -> Result<ProbeReport, ProbeFailure> {
let client = wreq::Client::builder()
.connect_timeout(config.turn_timeout)
.timeout(config.turn_timeout)
.build()
.map_err(|_| ProbeFailure::ClientBuild)?;
let response = client
.websocket(config.url.as_str())
.headers(config.handshake_headers.clone())
.max_frame_size(MAX_FRAME_SIZE)
.max_message_size(MAX_FRAME_SIZE)
.send()
.await
.map_err(|_| ProbeFailure::Handshake)?;
let handshake_status = response.status().as_u16();
let received_header_names = response
.headers()
.keys()
.map(|name| name.as_str().to_string())
.collect();
let mut socket = response
.into_websocket()
.await
.map_err(|_| ProbeFailure::Upgrade)?;
let mut observed_event_types = Vec::new();
send_warmup(&mut socket, &config.model, None).await?;
let first_response_id =
receive_completed_response_id(&mut socket, config.turn_timeout, &mut observed_event_types)
.await?;
send_warmup(&mut socket, &config.model, Some(&first_response_id)).await?;
let _second_response_id =
receive_completed_response_id(&mut socket, config.turn_timeout, &mut observed_event_types)
.await?;
Ok(ProbeReport {
status: "passed",
target_host: target_host(config),
handshake_status: Some(handshake_status),
sent_header_names: config.sent_header_names.clone(),
received_header_names,
observed_event_types,
continuation_confirmed: true,
elapsed_ms: started_at.elapsed().as_millis() as u64,
error: None,
})
}
fn target_host(config: &ProbeConfig) -> Option<String> {
config.url.host_str().map(|host| match config.url.port() {
Some(port) => format!("{host}:{port}"),
None => host.to_string(),
})
}
async fn send_warmup(
socket: &mut wreq::ws::WebSocket,
model: &str,
previous_response_id: Option<&str>,
) -> Result<(), ProbeFailure> {
let mut event = json!({
"type": "response.create",
"model": model,
"store": false,
"generate": false,
"input": [],
"tools": [],
});
if let Some(previous_response_id) = previous_response_id {
event["previous_response_id"] = Value::String(previous_response_id.to_string());
}
socket
.send(WreqWsMessage::text(event.to_string()))
.await
.map_err(|_| ProbeFailure::Send)
}
async fn receive_completed_response_id(
socket: &mut wreq::ws::WebSocket,
timeout: Duration,
observed_event_types: &mut Vec<String>,
) -> Result<String, ProbeFailure> {
let mut response_id = None;
for _ in 0..MAX_EVENTS_PER_TURN {
let message = tokio::time::timeout(timeout, socket.recv())
.await
.map_err(|_| ProbeFailure::ReceiveTimeout)?
.ok_or(ProbeFailure::MissingResponseId)?
.map_err(|_| ProbeFailure::Receive)?;
match message {
WreqWsMessage::Text(text) => {
let event: Value = serde_json::from_str(text.as_str())
.map_err(|_| ProbeFailure::UnexpectedFrame)?;
let event_type = event
.get("type")
.and_then(Value::as_str)
.map(safe_event_label)
.unwrap_or_else(|| "unknown".to_string());
let is_remote_error = event_type == "error";
let is_completed = event_type == "response.completed";
observed_event_types.push(event_type);
if is_remote_error {
return Err(ProbeFailure::RemoteError);
}
if let Some(observed_response_id) = event
.pointer("/response/id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
response_id = Some(observed_response_id.to_string());
}
if is_completed {
return response_id.ok_or(ProbeFailure::MissingResponseId);
}
}
WreqWsMessage::Ping(_) | WreqWsMessage::Pong(_) => continue,
WreqWsMessage::Close(_) => return Err(ProbeFailure::MissingResponseId),
_ => return Err(ProbeFailure::UnexpectedFrame),
}
}
Err(ProbeFailure::MissingResponseId)
}
fn safe_event_label(value: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty()
|| trimmed.len() > 80
|| !trimmed
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
{
return "unknown".to_string();
}
trimmed.to_string()
}
fn print_report(report: &ProbeReport) {
match serde_json::to_string(report) {
Ok(json) => println!("{json}"),
Err(_) => println!("{{\"status\":\"failed\",\"error\":\"report_serialization_failed\"}}"),
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::State;
use axum::http::header::AUTHORIZATION;
use axum::http::{HeaderMap, HeaderValue};
use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
use futures_util::{SinkExt, StreamExt};
use serde_json::Value;
use tokio::sync::{oneshot, Mutex};
use super::{parse_probe_url, run_probe, ProbeConfig};
#[derive(Default)]
struct MockState {
observed: Mutex<Option<oneshot::Sender<ObservedClientMessages>>>,
}
struct ObservedClientMessages {
authorization_present: bool,
profile_header_present: bool,
second_before_first_completion: bool,
first: Value,
second: Value,
}
#[tokio::test]
async fn probe_confirms_sequential_response_continuation_without_exposing_values() {
let (url, observed, server) = spawn_mock_server().await;
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_static("Bearer test-token-that-must-not-be-reported"),
);
headers.insert(
"x-aether-probe-profile",
HeaderValue::from_static("test-profile-id"),
);
let config = ProbeConfig::new(
parse_probe_url(url.as_str()).expect("mock URL should be valid"),
"gpt-test".to_string(),
Duration::from_secs(2),
headers,
vec!["authorization", "x-aether-probe-profile"],
);
let report = run_probe(&config, Instant::now())
.await
.expect("probe should complete against mock server");
let client_messages = observed.await.expect("mock should observe client messages");
server.abort();
assert_eq!(report.status, "passed");
assert!(report.continuation_confirmed);
assert!(report
.observed_event_types
.contains(&"response.created".to_string()));
assert!(report
.observed_event_types
.contains(&"response.completed".to_string()));
assert!(client_messages.authorization_present);
assert!(client_messages.profile_header_present);
assert!(!client_messages.second_before_first_completion);
assert_eq!(client_messages.first["type"], "response.create");
assert_eq!(client_messages.first["generate"], false);
assert_eq!(client_messages.first["store"], false);
assert_eq!(client_messages.second["previous_response_id"], "resp-first");
let report_json = serde_json::to_string(&report).expect("report should serialize");
assert!(!report_json.contains("test-token-that-must-not-be-reported"));
assert!(!report_json.contains("test-profile-id"));
assert!(!report_json.contains("resp-first"));
}
#[test]
fn probe_url_rejects_credentials_and_query_strings() {
assert!(parse_probe_url("wss://example.test/v1/responses").is_ok());
assert!(parse_probe_url("https://example.test/v1/responses").is_err());
assert!(parse_probe_url("wss://token@example.test/v1/responses").is_err());
assert!(parse_probe_url("wss://example.test/v1/responses?token=secret").is_err());
}
async fn spawn_mock_server() -> (
String,
oneshot::Receiver<ObservedClientMessages>,
tokio::task::JoinHandle<()>,
) {
let (observed_tx, observed_rx) = oneshot::channel();
let state = Arc::new(MockState {
observed: Mutex::new(Some(observed_tx)),
});
let app = Router::new()
.route("/v1/responses", get(mock_websocket))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("mock listener should bind");
let address = listener
.local_addr()
.expect("mock listener should expose address");
let server = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("mock server should run");
});
(format!("ws://{address}/v1/responses"), observed_rx, server)
}
async fn mock_websocket(
ws: WebSocketUpgrade,
State(state): State<Arc<MockState>>,
headers: HeaderMap,
) -> impl IntoResponse {
let authorization_present = headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.starts_with("Bearer "));
let profile_header_present = headers.contains_key("x-aether-probe-profile");
ws.on_upgrade(move |socket| async move {
serve_mock_socket(socket, state, authorization_present, profile_header_present).await;
})
}
async fn serve_mock_socket(
socket: WebSocket,
state: Arc<MockState>,
authorization_present: bool,
profile_header_present: bool,
) {
let (mut sender, mut receiver) = socket.split();
let first = receive_json(&mut receiver).await;
let _ = sender
.send(Message::Text(
serde_json::json!({
"type": "response.created",
"response": {"id": "resp-first"}
})
.to_string()
.into(),
))
.await;
let early_second = tokio::select! {
message = receiver.next() => Some(message),
_ = tokio::time::sleep(Duration::from_millis(50)) => None,
};
let second_before_first_completion = early_second.is_some();
let _ = sender
.send(Message::Text(
serde_json::json!({
"type": "response.completed",
"response": {"id": "resp-first", "status": "completed"}
})
.to_string()
.into(),
))
.await;
let second = match early_second {
Some(Some(Ok(Message::Text(text)))) => {
serde_json::from_str(text.as_str()).expect("early client message should be JSON")
}
Some(Some(Ok(_))) => panic!("expected text continuation message"),
Some(Some(Err(error))) => panic!("client message should be valid: {error}"),
Some(None) => panic!("client closed before continuation"),
None => receive_json(&mut receiver).await,
};
let _ = sender
.send(Message::Text(
serde_json::json!({
"type": "response.created",
"response": {"id": "resp-second"}
})
.to_string()
.into(),
))
.await;
let _ = sender
.send(Message::Text(
serde_json::json!({
"type": "response.completed",
"response": {"id": "resp-second", "status": "completed"}
})
.to_string()
.into(),
))
.await;
if let Some(observed) = state.observed.lock().await.take() {
let _ = observed.send(ObservedClientMessages {
authorization_present,
profile_header_present,
second_before_first_completion,
first,
second,
});
}
}
async fn receive_json(receiver: &mut futures_util::stream::SplitStream<WebSocket>) -> Value {
let message = receiver
.next()
.await
.expect("client should send a message")
.expect("client message should be valid");
let Message::Text(text) = message else {
panic!("expected text message");
};
serde_json::from_str(text.as_str()).expect("client message should be JSON")
}
}
-10
View File
@@ -1274,7 +1274,6 @@ mod tests {
let first_cache = Arc::clone(&cache);
let first_key = key.clone();
let first_calls = Arc::clone(&calls);
let first_started = Instant::now();
let first = tokio::spawn(async move {
first_cache
.get_or_load_once_stale_while_refreshing::<(), _, _>(
@@ -1292,7 +1291,6 @@ mod tests {
});
let follower_cache = Arc::clone(&cache);
let follower_started = Instant::now();
let follower_calls = Arc::clone(&calls);
let follower = tokio::spawn(async move {
follower_cache
@@ -1310,15 +1308,7 @@ mod tests {
});
assert_eq!(first.await.unwrap().unwrap(), Some(1));
assert!(
first_started.elapsed() < Duration::from_millis(80),
"stale value should not wait for request-path refresh"
);
assert_eq!(follower.await.unwrap().unwrap(), Some(1));
assert!(
follower_started.elapsed() < Duration::from_millis(80),
"follower should return stale value without waiting for refresh"
);
assert_eq!(calls.load(Ordering::Acquire), 0);
}
@@ -23,6 +23,21 @@ pub(crate) struct ClientSessionScope {
pub(crate) source: ClientSessionSignalSource,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct CodexRequestSignals {
pub(crate) session_id: Option<String>,
pub(crate) thread_id: Option<String>,
pub(crate) turn_id: Option<String>,
pub(crate) prompt_cache_key: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct CodexTurnMetadataSignals {
session_id: Option<String>,
thread_id: Option<String>,
turn_id: Option<String>,
}
impl ClientSessionScope {
fn new(
client_family: impl Into<String>,
@@ -136,6 +151,13 @@ pub(crate) fn client_session_scope_from_request(
.or_else(|| extract_scope_from_other_specific_adapters(&request, client_family.as_str()))
}
pub(crate) fn codex_request_signals_from_request(
headers: &http::HeaderMap,
body_json: Option<&Value>,
) -> CodexRequestSignals {
extract_codex_request_signals(&ClientSessionRequest { headers, body_json })
}
fn codex_search_session_scope(request: &ClientSessionRequest<'_>) -> Option<ClientSessionScope> {
let session_id = request
.body_json?
@@ -408,29 +430,7 @@ impl ClientSessionScopeAdapter for CodexSessionScopeAdapter {
}
fn extract_scope(&self, request: &ClientSessionRequest<'_>) -> Option<ClientSessionScope> {
header_value_str(request.headers, "session-id")
.or_else(|| header_value_str(request.headers, "thread-id"))
.or_else(|| header_value_str(request.headers, "session_id"))
.or_else(|| header_value_str(request.headers, "conversation_id"))
.map(|root_session| {
ClientSessionScope::new(
self.family(),
root_session,
None,
header_value_str(request.headers, "chatgpt-account-id"),
ClientSessionSignalSource::Header,
)
})
.or_else(|| {
let body_session = GenericSessionScopeAdapter.extract_scope(request)?;
Some(ClientSessionScope::new(
self.family(),
body_session.session_id,
body_session.agent_id,
header_value_str(request.headers, "chatgpt-account-id"),
body_session.source,
))
})
codex_request_session_scope_from_request(request)
}
}
@@ -778,6 +778,162 @@ fn explicit_aether_session_scope(
))
}
fn extract_codex_request_signals(request: &ClientSessionRequest<'_>) -> CodexRequestSignals {
let body_client_metadata = request
.body_json
.and_then(|body| body.get("client_metadata"))
.and_then(Value::as_object);
let body_turn_metadata = codex_turn_metadata_signals(
body_client_metadata.and_then(|metadata| metadata.get("x-codex-turn-metadata")),
);
let header_turn_metadata = header_value_str(request.headers, "x-codex-turn-metadata")
.map(|raw| parse_codex_turn_metadata(&raw))
.unwrap_or_default();
let native_thread_id = header_value_str(request.headers, "thread-id")
.or_else(|| {
body_client_metadata
.and_then(|metadata| value_at_map_path(metadata, "thread_id"))
.map(ToOwned::to_owned)
})
.or_else(|| body_turn_metadata.thread_id.clone());
let turn_id = body_client_metadata
.and_then(|metadata| value_at_map_path(metadata, "turn_id"))
.map(ToOwned::to_owned)
.or_else(|| body_turn_metadata.turn_id.clone())
.or_else(|| {
request
.body_json
.and_then(|body| value_at_path(body, &["turn_id"]))
.map(ToOwned::to_owned)
})
.or(header_turn_metadata.turn_id);
let prompt_cache_key = request
.body_json
.and_then(|body| value_at_path(body, &["prompt_cache_key"]))
.map(ToOwned::to_owned);
let session_id =
codex_request_session_scope(request, &body_turn_metadata).map(|scope| scope.session_id);
let thread_id = native_thread_id.or_else(|| session_id.clone());
CodexRequestSignals {
session_id,
thread_id,
turn_id,
prompt_cache_key,
}
}
fn codex_request_session_scope_from_request(
request: &ClientSessionRequest<'_>,
) -> Option<ClientSessionScope> {
let body_turn_metadata = codex_turn_metadata_signals(
request
.body_json
.and_then(|body| body.get("client_metadata"))
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("x-codex-turn-metadata")),
);
codex_request_session_scope(request, &body_turn_metadata)
}
fn codex_request_session_scope(
request: &ClientSessionRequest<'_>,
body_turn_metadata: &CodexTurnMetadataSignals,
) -> Option<ClientSessionScope> {
if let Some(scope) = explicit_aether_session_scope(request, CodexSessionScopeAdapter.family()) {
return Some(scope);
}
if let Some(root_session) = header_value_str(request.headers, "session-id")
.or_else(|| header_value_str(request.headers, "thread-id"))
.or_else(|| header_value_str(request.headers, "session_id"))
.or_else(|| header_value_str(request.headers, "conversation_id"))
.or_else(|| header_value_str(request.headers, "x-session-id"))
{
return Some(codex_session_scope(
request,
root_session,
None,
ClientSessionSignalSource::Header,
));
}
let body_client_metadata = request
.body_json
.and_then(|body| body.get("client_metadata"))
.and_then(Value::as_object);
if let Some(root_session) = body_client_metadata
.and_then(|metadata| value_at_map_path(metadata, "session_id"))
.or_else(|| {
body_client_metadata.and_then(|metadata| value_at_map_path(metadata, "thread_id"))
})
.map(ToOwned::to_owned)
.or_else(|| body_turn_metadata.session_id.clone())
.or_else(|| body_turn_metadata.thread_id.clone())
{
return Some(codex_session_scope(
request,
root_session,
None,
ClientSessionSignalSource::Body,
));
}
let generic = GenericSessionScopeAdapter.extract_scope(request)?;
Some(codex_session_scope(
request,
generic.session_id,
generic.agent_id,
generic.source,
))
}
fn codex_session_scope(
request: &ClientSessionRequest<'_>,
session_id: String,
agent_id: Option<String>,
source: ClientSessionSignalSource,
) -> ClientSessionScope {
ClientSessionScope::new(
CodexSessionScopeAdapter.family(),
session_id,
agent_id,
header_value_str(request.headers, "chatgpt-account-id"),
source,
)
}
fn codex_turn_metadata_signals(value: Option<&Value>) -> CodexTurnMetadataSignals {
match value {
Some(Value::Object(metadata)) => codex_turn_metadata_signals_from_map(metadata),
Some(Value::String(raw)) => parse_codex_turn_metadata(raw),
_ => CodexTurnMetadataSignals::default(),
}
}
fn parse_codex_turn_metadata(raw: &str) -> CodexTurnMetadataSignals {
serde_json::from_str::<Map<String, Value>>(raw)
.map(|metadata| codex_turn_metadata_signals_from_map(&metadata))
.unwrap_or_default()
}
fn codex_turn_metadata_signals_from_map(metadata: &Map<String, Value>) -> CodexTurnMetadataSignals {
CodexTurnMetadataSignals {
session_id: value_at_map_path(metadata, "session_id").map(ToOwned::to_owned),
thread_id: value_at_map_path(metadata, "thread_id").map(ToOwned::to_owned),
turn_id: value_at_map_path(metadata, "turn_id").map(ToOwned::to_owned),
}
}
fn value_at_map_path<'a>(object: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
object
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn normalize_session_key(
account_hint: Option<&str>,
root_session: &str,
@@ -831,12 +987,25 @@ mod tests {
client_session_affinity_from_api_request,
client_session_affinity_from_report_context_value, client_session_affinity_from_request,
client_session_affinity_report_context_value, client_session_scope_from_request,
ClientSessionSignalSource, AETHER_AGENT_ID_HEADER, AETHER_SESSION_ID_HEADER,
codex_request_signals_from_request, ClientSessionSignalSource, AETHER_AGENT_ID_HEADER,
AETHER_SESSION_ID_HEADER,
};
use aether_scheduler_core::ClientSessionAffinity;
use http::{HeaderMap, HeaderValue};
use http::{HeaderMap, HeaderName, HeaderValue};
use serde_json::json;
fn request_headers(values: &[(&str, &str)]) -> HeaderMap {
values
.iter()
.map(|(name, value)| {
(
HeaderName::from_bytes(name.as_bytes()).expect("valid test header name"),
HeaderValue::from_bytes(value.as_bytes()).expect("valid test header value"),
)
})
.collect()
}
#[test]
fn unknown_adapter_extracts_body_session_and_agent() {
let body = json!({
@@ -933,6 +1102,276 @@ mod tests {
);
}
#[test]
fn codex_request_signals_apply_session_precedence() {
let cases = vec![
(
request_headers(&[
(AETHER_SESSION_ID_HEADER, "aether-session"),
("session-id", "header-session"),
]),
json!({"client_metadata": {"session_id": "body-session"}}),
"aether-session",
ClientSessionSignalSource::ExplicitAetherHeader,
),
(
request_headers(&[
("session-id", "header-session"),
("thread-id", "header-thread"),
("session_id", "header-session-underscore"),
("conversation_id", "header-conversation"),
]),
json!({"client_metadata": {"session_id": "body-session"}}),
"header-session",
ClientSessionSignalSource::Header,
),
(
request_headers(&[
("thread-id", "header-thread"),
("session_id", "header-session-underscore"),
("conversation_id", "header-conversation"),
]),
json!({"client_metadata": {"session_id": "body-session"}}),
"header-thread",
ClientSessionSignalSource::Header,
),
(
request_headers(&[
("session_id", "header-session-underscore"),
("conversation_id", "header-conversation"),
]),
json!({"client_metadata": {"session_id": "body-session"}}),
"header-session-underscore",
ClientSessionSignalSource::Header,
),
(
request_headers(&[("conversation_id", "header-conversation")]),
json!({"client_metadata": {"session_id": "body-session"}}),
"header-conversation",
ClientSessionSignalSource::Header,
),
(
HeaderMap::new(),
json!({
"prompt_cache_key": "prompt-cache",
"client_metadata": {
"session_id": "body-session",
"thread_id": "body-thread",
"x-codex-turn-metadata": {
"session_id": "nested-session",
"thread_id": "nested-thread"
}
}
}),
"body-session",
ClientSessionSignalSource::Body,
),
(
HeaderMap::new(),
json!({
"prompt_cache_key": "prompt-cache",
"client_metadata": {
"thread_id": "body-thread",
"x-codex-turn-metadata": {"session_id": "nested-session"}
}
}),
"body-thread",
ClientSessionSignalSource::Body,
),
(
HeaderMap::new(),
json!({
"prompt_cache_key": "prompt-cache",
"client_metadata": {
"x-codex-turn-metadata": {
"session_id": "nested-session",
"thread_id": "nested-thread"
}
}
}),
"nested-session",
ClientSessionSignalSource::Body,
),
(
HeaderMap::new(),
json!({
"prompt_cache_key": "prompt-cache",
"client_metadata": {
"x-codex-turn-metadata": json!({
"thread_id": "nested-thread"
}).to_string()
}
}),
"nested-thread",
ClientSessionSignalSource::Body,
),
(
HeaderMap::new(),
json!({
"prompt_cache_key": "prompt-cache",
"conversation_id": "generic-conversation"
}),
"prompt-cache",
ClientSessionSignalSource::Body,
),
(
HeaderMap::new(),
json!({"metadata": {"session_id": "generic-session"}}),
"generic-session",
ClientSessionSignalSource::Body,
),
];
for (headers, body, expected_session_id, expected_source) in cases {
let signals = codex_request_signals_from_request(&headers, Some(&body));
assert_eq!(signals.session_id.as_deref(), Some(expected_session_id));
let mut codex_headers = headers;
codex_headers.insert(
http::header::USER_AGENT,
HeaderValue::from_static("codex_cli_rs/0.144.1"),
);
let scope = client_session_scope_from_request(&codex_headers, Some(&body))
.expect("Codex scope should reuse the native signal precedence");
assert_eq!(scope.client_family, "codex");
assert_eq!(scope.session_id, expected_session_id);
assert_eq!(scope.source, expected_source);
}
}
#[test]
fn codex_request_signals_extract_thread_and_prompt_cache_independently() {
let body = json!({
"prompt_cache_key": "prompt-cache",
"client_metadata": {
"thread_id": "body-thread",
"x-codex-turn-metadata": {"thread_id": "nested-thread"}
}
});
let headers = request_headers(&[("thread-id", "header-thread")]);
let header_signals = codex_request_signals_from_request(&headers, Some(&body));
assert_eq!(header_signals.thread_id.as_deref(), Some("header-thread"));
assert_eq!(
header_signals.prompt_cache_key.as_deref(),
Some("prompt-cache")
);
let body_signals = codex_request_signals_from_request(&HeaderMap::new(), Some(&body));
assert_eq!(body_signals.thread_id.as_deref(), Some("body-thread"));
let nested_body = json!({
"client_metadata": {
"x-codex-turn-metadata": json!({
"thread_id": "nested-thread"
}).to_string()
}
});
let nested_signals =
codex_request_signals_from_request(&HeaderMap::new(), Some(&nested_body));
assert_eq!(nested_signals.thread_id.as_deref(), Some("nested-thread"));
let session_only_body = json!({"client_metadata": {"session_id": "body-session"}});
let session_only_signals =
codex_request_signals_from_request(&HeaderMap::new(), Some(&session_only_body));
assert_eq!(
session_only_signals.thread_id.as_deref(),
Some("body-session")
);
}
#[test]
fn codex_request_signals_use_live_session_header() {
let headers = request_headers(&[("x-session-id", "live-session")]);
let signals = codex_request_signals_from_request(&headers, None);
assert_eq!(signals.session_id.as_deref(), Some("live-session"));
assert_eq!(signals.thread_id.as_deref(), Some("live-session"));
}
#[test]
fn codex_request_signals_prefer_responses_session_header_over_live_session_header() {
let headers = request_headers(&[
("session-id", "responses-session"),
("x-session-id", "live-session"),
]);
let signals = codex_request_signals_from_request(&headers, None);
assert_eq!(signals.session_id.as_deref(), Some("responses-session"));
assert_eq!(signals.thread_id.as_deref(), Some("responses-session"));
}
#[test]
fn codex_request_signals_apply_turn_precedence() {
let headers = request_headers(&[("x-codex-turn-metadata", r#"{"turn_id":"header-turn"}"#)]);
let direct_body = json!({
"turn_id": "top-level-turn",
"client_metadata": {
"turn_id": "body-turn",
"x-codex-turn-metadata": {"turn_id": "nested-turn"}
}
});
assert_eq!(
codex_request_signals_from_request(&headers, Some(&direct_body))
.turn_id
.as_deref(),
Some("body-turn")
);
let nested_object_body = json!({
"turn_id": "top-level-turn",
"client_metadata": {
"x-codex-turn-metadata": {"turn_id": "nested-object-turn"}
}
});
assert_eq!(
codex_request_signals_from_request(&headers, Some(&nested_object_body))
.turn_id
.as_deref(),
Some("nested-object-turn")
);
let nested_string_body = json!({
"turn_id": "top-level-turn",
"client_metadata": {
"x-codex-turn-metadata": json!({
"turn_id": "nested-string-turn"
}).to_string()
}
});
assert_eq!(
codex_request_signals_from_request(&headers, Some(&nested_string_body))
.turn_id
.as_deref(),
Some("nested-string-turn")
);
let top_level_body = json!({
"turn_id": "top-level-turn",
"client_metadata": {"x-codex-turn-metadata": "not-json"}
});
assert_eq!(
codex_request_signals_from_request(&headers, Some(&top_level_body))
.turn_id
.as_deref(),
Some("top-level-turn")
);
assert_eq!(
codex_request_signals_from_request(&headers, None)
.turn_id
.as_deref(),
Some("header-turn")
);
}
#[test]
fn codex_request_signals_ignore_client_request_id() {
let headers = request_headers(&[("x-client-request-id", "request-only-id")]);
let signals =
codex_request_signals_from_request(&headers, Some(&json!({"model": "gpt-5"})));
assert_eq!(signals, super::CodexRequestSignals::default());
}
#[test]
fn report_context_round_trips_normalized_session_affinity() {
let affinity = ClientSessionAffinity::new(
+4
View File
@@ -125,6 +125,10 @@ pub(crate) const RUST_FRONTDOOR_OWNED_ROUTE_PATTERNS: &[&str] = &[
"/v1/messages/count_tokens",
"/v1/responses",
"/v1/responses/compact",
"/v1/realtime",
"/v1/realtime/calls",
"/v1/live",
"/v1/live/{call_id}",
"/v1/alpha/search",
"/v1/models/{model}:generateContent",
"/v1/models/{model}:streamGenerateContent",
@@ -177,6 +177,19 @@ fn extract_trusted_auth_headers(headers: &http::HeaderMap) -> Option<GatewayTrus
})
}
#[cfg(not(test))]
pub(super) fn extract_trusted_admin_headers(
_headers: &http::HeaderMap,
) -> Option<GatewayTrustedAdminHeaders> {
// The public gateway has no authenticated upstream that is allowed to
// assert an administrator principal. `x-aether-gateway` is also emitted
// on public responses, so it cannot serve as proof that these headers were
// produced by a trusted hop. Production requests must authenticate with a
// real admin session or management bearer token instead.
None
}
#[cfg(test)]
pub(super) fn extract_trusted_admin_headers(
headers: &http::HeaderMap,
) -> Option<GatewayTrustedAdminHeaders> {
@@ -230,7 +243,7 @@ fn select_primary_credential(
if signature.starts_with("claude:") {
return select_claude_messages_credential(bundle);
}
if signature.starts_with("openai:") {
if signature.starts_with("openai:") || signature.starts_with("codex:") {
return select_openai_credential(bundle);
}
if signature.starts_with("aether:") {
@@ -478,6 +491,25 @@ mod tests {
);
}
#[test]
fn selects_codex_live_bearer_as_provider_api_key() {
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::AUTHORIZATION,
"Bearer sk-codex-live".parse().unwrap(),
);
let extracted =
extract_request_credentials(&headers, &uri("/v1/live?model=gpt-live"), "codex:live");
assert_eq!(
extracted.primary,
Some(GatewayPrimaryCredential::ProviderApiKey {
raw: "sk-codex-live".to_string(),
carrier: GatewayCredentialCarrier::AuthorizationBearer,
})
);
}
#[test]
fn prefers_claude_chat_x_api_key_over_bearer() {
let mut headers = http::HeaderMap::new();
+34 -5
View File
@@ -205,8 +205,8 @@ async fn available_balance_capacity_usd(
.as_ref()
.is_some_and(|wallet| wallet.limit_mode.eq_ignore_ascii_case("unlimited"));
Ok(match quota.as_ref() {
Some(quota) if !quota.allow_wallet_overage => Some(quota.remaining_usd.max(0.0)),
Some(_) if wallet_is_unlimited => None,
Some(quota) if !quota.allow_wallet_overage => Some(quota.remaining_usd.max(0.0)),
Some(quota) => Some(quota.remaining_usd.max(0.0) + wallet_available_usd.unwrap_or(0.0)),
None if wallet_is_unlimited => None,
None => wallet_available_usd,
@@ -832,9 +832,10 @@ mod tests {
use serde_json::json;
use super::{
execution_plan_balance_capacity_rejection, execution_plan_cost_upper_bound_cache_key,
max_output_tokens_from_request, openai_request_input_is_self_contained,
output_choice_count_upper_bound, request_model_local_rejection, GatewayLocalAuthRejection,
available_balance_capacity_usd, execution_plan_balance_capacity_rejection,
execution_plan_cost_upper_bound_cache_key, max_output_tokens_from_request,
openai_request_input_is_self_contained, output_choice_count_upper_bound,
request_model_local_rejection, GatewayLocalAuthRejection,
};
use crate::control::{GatewayControlAuthContext, GatewayControlDecision};
use crate::data::GatewayDataState;
@@ -939,6 +940,14 @@ mod tests {
fn state_with_quota_and_wallet(
quota: UserDailyQuotaAvailabilityRecord,
context: StoredBillingModelContext,
) -> AppState {
state_with_quota_context_and_wallet(quota, context, sample_wallet("user-1", 30.0))
}
fn state_with_quota_context_and_wallet(
quota: UserDailyQuotaAvailabilityRecord,
context: StoredBillingModelContext,
wallet: StoredWalletSnapshot,
) -> AppState {
let candidate_repository =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
@@ -952,7 +961,7 @@ mod tests {
AppState::new()
.expect("state should build")
.with_data_state_for_tests(data)
.with_auth_wallets_for_tests(vec![sample_wallet("user-1", 30.0)])
.with_auth_wallets_for_tests(vec![wallet])
}
fn state_with_model_mapping() -> AppState {
@@ -1350,6 +1359,26 @@ mod tests {
}
}
#[tokio::test]
async fn unlimited_wallet_capacity_ignores_exhausted_non_overage_quota() {
let context = billing_context_with_pricing(None, None, None, None);
let mut wallet = sample_wallet("user-1", 0.0);
wallet.limit_mode = "unlimited".to_string();
let state =
state_with_quota_context_and_wallet(quota_availability(0.0, false), context, wallet);
let decision = decision_with_allowed_models(vec!["gpt-5".to_string()]);
let auth_context = decision
.auth_context
.as_ref()
.expect("decision should include auth context");
let capacity = available_balance_capacity_usd(&state, auth_context)
.await
.expect("capacity should resolve");
assert_eq!(capacity, None);
}
#[tokio::test]
async fn positive_balance_does_not_allow_historical_invalid_processing_pricing() {
let context = billing_context_with_pricing(
+3 -2
View File
@@ -11,8 +11,9 @@ pub(crate) use gate::{
should_buffer_request_for_local_auth, trusted_auth_local_rejection, GatewayLocalAuthRejection,
};
pub(crate) use resolution::{
refresh_execution_runtime_auth_context, resolve_execution_runtime_auth_context,
GatewayAdminPrincipalContext, GatewayControlAuthContext,
refresh_execution_runtime_auth_context, refresh_execution_runtime_auth_context_with_snapshot,
resolve_execution_runtime_auth_context, GatewayAdminPrincipalContext,
GatewayControlAuthContext,
};
pub(super) use resolution::{resolve_control_decision_auth, ControlDecisionAuthResolution};
pub(crate) use types::GatewayCredentialCarrier;
@@ -725,20 +725,47 @@ pub(crate) async fn refresh_execution_runtime_auth_context(
auth_context: GatewayControlAuthContext,
auth_endpoint_signature: Option<&str>,
) -> Result<GatewayControlAuthContext, GatewayError> {
refresh_execution_runtime_auth_context_with_snapshot(
state,
auth_context,
auth_endpoint_signature,
)
.await
.map(|(auth_context, _)| auth_context)
}
/// Strongly refreshes the long-lived execution authorization context and
/// returns the exact API-key snapshot that produced it.
///
/// WebSocket turns need both values: using the refreshed context for RPM and
/// balance checks while letting the planner independently read its normal
/// cache can authorize a different provider/model snapshot for up to the cache
/// TTL. Ordinary HTTP callers keep using [`refresh_execution_runtime_auth_context`].
pub(crate) async fn refresh_execution_runtime_auth_context_with_snapshot(
state: &AppState,
auth_context: GatewayControlAuthContext,
auth_endpoint_signature: Option<&str>,
) -> Result<
(
GatewayControlAuthContext,
Option<crate::ai_serving::GatewayAuthApiKeySnapshot>,
),
GatewayError,
> {
if auth_context.local_rejection.is_some() || !auth_context.access_allowed {
return Ok(auth_context);
return Ok((auth_context, None));
}
let Some(auth_endpoint_signature) = auth_endpoint_signature
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(auth_context);
return Ok((auth_context, None));
};
if !state.has_auth_api_key_reader()
|| auth_context.user_id.trim().is_empty()
|| auth_context.api_key_id.trim().is_empty()
{
return Ok(auth_context);
return Ok((auth_context, None));
}
let snapshot = {
@@ -758,19 +785,20 @@ pub(crate) async fn refresh_execution_runtime_auth_context(
denied.access_allowed = false;
denied.local_rejection = Some(GatewayLocalAuthRejection::InvalidApiKey);
denied.balance_remaining = None;
return Ok(denied);
return Ok((denied, None));
};
let wallet_access = resolve_wallet_auth_gate_uncached(state, &snapshot).await?;
Ok(build_data_backed_auth_context(
let refreshed = build_data_backed_auth_context(
state,
snapshot,
snapshot.clone(),
auth_endpoint_signature,
Some(true),
auth_context.balance_remaining,
wallet_access,
)
.await)
.await;
Ok((refreshed, Some(snapshot)))
}
fn put_cached_auth_context(
+5 -4
View File
@@ -9,10 +9,11 @@ mod route;
pub(crate) use auth::{
execution_plan_balance_capacity_rejection, extract_requested_model,
refresh_execution_runtime_auth_context, request_model_local_rejection,
resolve_execution_runtime_auth_context, should_buffer_request_for_local_auth,
trusted_auth_local_rejection, GatewayAdminPrincipalContext, GatewayControlAuthContext,
GatewayCredentialCarrier, GatewayLocalAuthRejection,
refresh_execution_runtime_auth_context, refresh_execution_runtime_auth_context_with_snapshot,
request_model_local_rejection, resolve_execution_runtime_auth_context,
should_buffer_request_for_local_auth, trusted_auth_local_rejection,
GatewayAdminPrincipalContext, GatewayControlAuthContext, GatewayCredentialCarrier,
GatewayLocalAuthRejection,
};
pub(crate) use execute::{allows_control_execute_emergency, maybe_execute_via_control};
pub(crate) use management_token_permissions::{
@@ -797,6 +797,18 @@ pub(super) fn classify_admin_operations_family_route(
"admin:users",
false,
))
} else if method == http::Method::DELETE
&& normalized_path_no_trailing.starts_with("/api/admin/users/")
&& normalized_path_no_trailing.contains("/billing/entitlements/")
&& normalized_path_no_trailing.matches('/').count() == 7
{
Some(classified(
"admin_proxy",
"users_manage",
"revoke_user_billing_entitlement",
"admin:users",
false,
))
} else if method == http::Method::GET
&& normalized_path.starts_with("/api/admin/users/")
&& normalized_path.ends_with("/sessions")
+327 -1
View File
@@ -7,6 +7,7 @@ use crate::ai_serving::ApiOperation;
pub(super) fn classify_ai_public_route(
method: &http::Method,
normalized_path: &str,
query: Option<&str>,
headers: &http::HeaderMap,
) -> Option<ClassifiedRoute> {
if let Some(route) = classify_antigravity_v1internal_route(method, normalized_path) {
@@ -35,7 +36,39 @@ pub(super) fn classify_ai_public_route(
"openai:rerank",
true,
))
} else if method == http::Method::POST
} else if (method == http::Method::POST && normalized_path == "/v1/live")
|| (method == http::Method::POST
&& normalized_path == "/v1/realtime/calls"
&& realtime_query_has_codex_live_intent(query))
|| (method == http::Method::GET
&& ((normalized_path == "/v1/live" || normalized_path.starts_with("/v1/live/"))
|| (normalized_path == "/v1/realtime"
&& (realtime_query_has_codex_live_intent(query)
|| realtime_query_is_codex_v2(query, headers))))
&& is_websocket_upgrade_request(headers))
{
// Codex Live has an independent wire contract and permission surface;
// it must never be authorized as an OpenAI Responses request.
Some(classified("ai_public", "codex", "live", "codex:live", true))
} else if method == http::Method::GET
&& normalized_path == "/v1/realtime"
&& is_websocket_upgrade_request(headers)
{
// Ordinary OpenAI Realtime WebSockets retain their independent
// permission surface. The GA WebRTC call-creation endpoint is not
// handled by this WebSocket-only implementation; only Codex AVAS is
// accepted above when it carries the explicit quicksilver intent.
Some(classified(
"ai_public",
"openai",
"realtime",
"openai:realtime",
true,
))
} else if (method == http::Method::POST
|| (method == http::Method::GET
&& normalized_path == "/v1/responses"
&& is_websocket_upgrade_request(headers)))
&& matches!(normalized_path, "/v1/responses" | "/v1/responses/compact")
{
if normalized_path.ends_with("/compact") {
@@ -185,6 +218,56 @@ pub(super) fn classify_ai_public_route(
}
}
/// Codex's standalone realtime WebSocket is distinguished from the ordinary
/// OpenAI Realtime API by its `intent=quicksilver` selector. Keep this check
/// deliberately narrow: `call_id` is also used by ordinary OpenAI Realtime
/// sideband sockets, and neither it nor a model query may select the
/// `codex:live` permission surface on its own.
fn realtime_query_has_codex_live_intent(query: Option<&str>) -> bool {
let mut seen = false;
for (key, value) in url::form_urlencoded::parse(query.unwrap_or_default().as_bytes()) {
if key.eq_ignore_ascii_case("intent") {
if seen || !value.eq_ignore_ascii_case("quicksilver") {
return false;
}
seen = true;
}
}
seen
}
/// Codex realtime v2 intentionally omits the v1 `intent=quicksilver` query
/// marker. Its default headers still carry the stable Codex originator, so
/// use that identity plus a model query to select the Codex Live permission
/// surface without stealing ordinary OpenAI Realtime sockets.
fn realtime_query_is_codex_v2(query: Option<&str>, headers: &http::HeaderMap) -> bool {
let mut has_model = false;
for (key, value) in url::form_urlencoded::parse(query.unwrap_or_default().as_bytes()) {
// V2 has no intent marker. If one is present, let the normal
// Realtime/V1 classifier handle it instead of silently treating a
// conflicting request as Codex V2.
if key.eq_ignore_ascii_case("intent") || key.eq_ignore_ascii_case("call_id") {
return false;
}
if key.eq_ignore_ascii_case("model") && !value.trim().is_empty() {
has_model = true;
}
}
if !has_model {
return false;
}
let Some(originator) = crate::headers::header_value_str(headers, "originator") else {
return false;
};
originator.split_whitespace().next().is_some_and(|value| {
value.eq_ignore_ascii_case("codex_cli_rs")
|| value.to_ascii_lowercase().starts_with("codex_cli_rs/")
|| value.eq_ignore_ascii_case("codex_work_desktop")
|| value.eq_ignore_ascii_case("codex_work_web")
|| value.eq_ignore_ascii_case("codex_work_mobile")
})
}
fn claude_request_auth_channel(headers: &http::HeaderMap) -> &'static str {
if crate::headers::header_value_str(headers, "x-api-key").is_some()
|| crate::headers::header_value_str(headers, "api-key").is_some()
@@ -199,6 +282,24 @@ fn claude_request_auth_channel(headers: &http::HeaderMap) -> &'static str {
}
}
fn is_websocket_upgrade_request(headers: &http::HeaderMap) -> bool {
let has_upgrade_connection = headers
.get(http::header::CONNECTION)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| {
value
.split(',')
.map(str::trim)
.any(|value| value.eq_ignore_ascii_case("upgrade"))
});
let has_websocket_upgrade = headers
.get(http::header::UPGRADE)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.eq_ignore_ascii_case("websocket"));
has_upgrade_connection && has_websocket_upgrade
}
fn is_gemini_operation_method(method: &http::Method, normalized_path: &str) -> bool {
method == http::Method::GET
|| (method == http::Method::POST && normalized_path.ends_with(":cancel"))
@@ -242,3 +343,228 @@ fn classify_antigravity_v1internal_route(
execution_runtime_candidate,
))
}
#[cfg(test)]
mod tests {
use axum::http::header::{CONNECTION, UPGRADE};
use axum::http::{HeaderMap, HeaderValue, Method};
use super::classify_ai_public_route;
#[test]
fn classifies_websocket_upgrade_on_responses_route() {
let mut headers = HeaderMap::new();
headers.insert(CONNECTION, HeaderValue::from_static("keep-alive, Upgrade"));
headers.insert(UPGRADE, HeaderValue::from_static("websocket"));
let route = classify_ai_public_route(&Method::GET, "/v1/responses", None, &headers)
.expect("Responses WebSocket should be an AI public route");
assert_eq!(route.route_class, "ai_public");
assert_eq!(route.route_family, "openai");
assert_eq!(route.route_kind, "responses");
assert_eq!(route.auth_endpoint_signature, "openai:responses");
}
#[test]
fn does_not_classify_plain_get_as_responses_websocket() {
assert!(
classify_ai_public_route(&Method::GET, "/v1/responses", None, &HeaderMap::new())
.is_none()
);
}
#[test]
fn classifies_only_websocket_upgrade_on_realtime_route() {
let mut headers = HeaderMap::new();
headers.insert(CONNECTION, HeaderValue::from_static("keep-alive, Upgrade"));
headers.insert(UPGRADE, HeaderValue::from_static("websocket"));
let route = classify_ai_public_route(
&Method::GET,
"/v1/realtime",
Some("model=gpt-realtime-2.1"),
&headers,
)
.expect("Realtime WebSocket should be an AI public route");
assert_eq!(route.route_class, "ai_public");
assert_eq!(route.route_family, "openai");
assert_eq!(route.route_kind, "realtime");
assert_eq!(route.auth_endpoint_signature, "openai:realtime");
assert!(route.execution_runtime_candidate);
let mut codex_v2_headers = headers.clone();
codex_v2_headers.insert("originator", HeaderValue::from_static("codex_work_desktop"));
let codex_v2 = classify_ai_public_route(
&Method::GET,
"/v1/realtime",
Some("model=gpt-live-1-codex"),
&codex_v2_headers,
)
.expect("Codex realtime v2 should use the Live route");
assert_eq!(codex_v2.route_family, "codex");
assert_eq!(codex_v2.auth_endpoint_signature, "codex:live");
let mut codex_cli_headers = headers.clone();
codex_cli_headers.insert("originator", HeaderValue::from_static("codex_cli_rs"));
let codex_cli_v2 = classify_ai_public_route(
&Method::GET,
"/v1/realtime",
Some("model=gpt-live-1-codex"),
&codex_cli_headers,
)
.expect("Codex CLI realtime v2 should use the Live route");
assert_eq!(codex_cli_v2.auth_endpoint_signature, "codex:live");
let mut ordinary_headers = headers.clone();
ordinary_headers.insert("originator", HeaderValue::from_static("openai-python"));
let ordinary = classify_ai_public_route(
&Method::GET,
"/v1/realtime",
Some("model=gpt-realtime-2.1"),
&ordinary_headers,
)
.expect("ordinary Realtime should remain available");
assert_eq!(ordinary.auth_endpoint_signature, "openai:realtime");
assert!(classify_ai_public_route(
&Method::GET,
"/v1/realtime",
Some("model=gpt-realtime-2.1"),
&HeaderMap::new(),
)
.is_none());
assert!(classify_ai_public_route(&Method::POST, "/v1/realtime", None, &headers).is_none());
}
#[test]
fn classifies_live_http_and_websocket_routes_as_codex_live() {
let legacy_post =
classify_ai_public_route(&Method::POST, "/v1/live", None, &HeaderMap::new())
.expect("legacy Live call creation should be an AI public route");
assert_eq!(legacy_post.route_family, "codex");
assert_eq!(legacy_post.route_kind, "live");
assert_eq!(legacy_post.auth_endpoint_signature, "codex:live");
let avas_post = classify_ai_public_route(
&Method::POST,
"/v1/realtime/calls",
Some("intent=quicksilver&architecture=avas"),
&HeaderMap::new(),
)
.expect("Codex AVAS call creation should be an AI public route");
assert_eq!(avas_post.route_family, "codex");
assert_eq!(avas_post.route_kind, "live");
assert_eq!(avas_post.auth_endpoint_signature, "codex:live");
// The same endpoint is part of the ordinary OpenAI Realtime API, but
// Aether's OpenAI Realtime implementation currently supports direct
// WebSockets only. A request without Codex's explicit AVAS intent must
// neither be captured by the Codex Live planner nor be advertised as a
// supported ordinary call-create request.
for query in [None, Some("model=gpt-realtime"), Some("architecture=avas")] {
assert!(classify_ai_public_route(
&Method::POST,
"/v1/realtime/calls",
query,
&HeaderMap::new(),
)
.is_none());
}
for query in [
Some("intent=other&architecture=avas"),
Some("intent=quicksilver&intent=other&architecture=avas"),
] {
assert!(classify_ai_public_route(
&Method::POST,
"/v1/realtime/calls",
query,
&HeaderMap::new(),
)
.is_none());
}
let mut headers = HeaderMap::new();
headers.insert(CONNECTION, HeaderValue::from_static("Upgrade"));
headers.insert(UPGRADE, HeaderValue::from_static("websocket"));
for path in ["/v1/live", "/v1/live/rtc_opaque"] {
let route = classify_ai_public_route(&Method::GET, path, None, &headers)
.expect("Live WebSocket should be an AI public route");
assert_eq!(route.route_family, "codex");
assert_eq!(route.route_kind, "live");
assert_eq!(route.auth_endpoint_signature, "codex:live");
}
assert!(classify_ai_public_route(
&Method::GET,
"/v1/live/rtc_opaque",
None,
&HeaderMap::new(),
)
.is_none());
let sideband = classify_ai_public_route(
&Method::GET,
"/v1/realtime",
Some("intent=quicksilver&call_id=rtc_opaque"),
&headers,
)
.expect("Realtime sideband WebSocket should be a Codex Live route");
assert_eq!(sideband.route_family, "codex");
assert_eq!(sideband.route_kind, "live");
assert_eq!(sideband.auth_endpoint_signature, "codex:live");
for query in [None, Some("model=gpt-realtime")] {
let route = classify_ai_public_route(&Method::GET, "/v1/realtime", query, &headers)
.expect("Realtime WebSocket without a call_id key should remain OpenAI Realtime");
assert_eq!(route.auth_endpoint_signature, "openai:realtime");
}
let codex_direct = classify_ai_public_route(
&Method::GET,
"/v1/realtime",
Some("intent=quicksilver&model=gpt-realtime-1.5"),
&headers,
)
.expect("Codex direct realtime WebSocket should use the Live route");
assert_eq!(codex_direct.route_family, "codex");
assert_eq!(codex_direct.route_kind, "live");
assert_eq!(codex_direct.auth_endpoint_signature, "codex:live");
for query in [
Some("intent=other&model=gpt-realtime-1.5"),
Some("intent=quicksilver&intent=other&model=gpt-realtime-1.5"),
] {
let route = classify_ai_public_route(&Method::GET, "/v1/realtime", query, &headers)
.expect("non-Codex realtime intent should remain OpenAI Realtime");
assert_eq!(route.auth_endpoint_signature, "openai:realtime");
}
// `call_id` is shared by ordinary OpenAI Realtime WebRTC sideband
// sockets. It must not select Codex Live without Codex's explicit
// `intent=quicksilver` signal.
for query in [
Some("call_id=rtc_ordinary"),
Some("c%61ll_id=rtc_encoded"),
Some("call_id="),
Some("call_id=%20"),
Some("call_id=rtc_one&call_id=rtc_two"),
Some("call_id=rtc_ordinary&model=gpt-realtime-1.5"),
] {
let route = classify_ai_public_route(&Method::GET, "/v1/realtime", query, &headers)
.expect("ordinary Realtime sideband should remain routable");
assert_eq!(route.route_family, "openai");
assert_eq!(route.route_kind, "realtime");
assert_eq!(route.auth_endpoint_signature, "openai:realtime");
}
let codex_sideband_with_encoded_call_id = classify_ai_public_route(
&Method::GET,
"/v1/realtime",
Some("intent=quicksilver&c%61ll_id=rtc_encoded"),
&headers,
)
.expect("encoded call_id must not hide Codex's explicit Live intent");
assert_eq!(
codex_sideband_with_encoded_call_id.auth_endpoint_signature,
"codex:live"
);
}
}
+2 -3
View File
@@ -212,7 +212,7 @@ pub(crate) fn classify_control_route(
.or_else(|| oauth::classify_oauth_route(method, &normalized_path))
.or_else(|| admin::classify_admin_route(method, &normalized_path))
.or_else(|| internal::classify_internal_route(method, &normalized_path))
.or_else(|| ai::classify_ai_public_route(method, &normalized_path, headers))?;
.or_else(|| ai::classify_ai_public_route(method, &normalized_path, uri.query(), headers))?;
let mut decision = classified.into_decision(normalized_path);
if let Some(signature) = decision.auth_endpoint_signature.as_deref() {
@@ -247,8 +247,7 @@ pub(super) fn detect_public_models_auth_signature(uri: &Uri, headers: &http::Hea
let has_codex_client_version = uri.path() == "/v1/models"
&& uri.query().is_some_and(|query| {
url::form_urlencoded::parse(query.as_bytes())
.any(|(key, value)| key == "client_version" && !value.trim().is_empty())
url::form_urlencoded::parse(query.as_bytes()).any(|(key, _)| key == "client_version")
});
if has_codex_client_version {
return "openai:responses".to_string();
@@ -103,6 +103,22 @@ fn classifies_admin_user_billing_routes_as_admin_proxy_route() {
Some("admin:users")
);
let revoke_uri: Uri = "/api/admin/users/user-1/billing/entitlements/entitlement-1"
.parse()
.expect("uri should parse");
let revoke = classify_control_route(&http::Method::DELETE, &revoke_uri, &headers)
.expect("route should classify");
assert_eq!(revoke.route_class.as_deref(), Some("admin_proxy"));
assert_eq!(revoke.route_family.as_deref(), Some("users_manage"));
assert_eq!(
revoke.route_kind.as_deref(),
Some("revoke_user_billing_entitlement")
);
assert_eq!(
revoke.auth_endpoint_signature.as_deref(),
Some("admin:users")
);
let context = GatewayPublicRequestContext::from_request_parts(
"trace-user-billing-grant",
&http::Method::POST,
@@ -108,6 +108,32 @@ fn classifies_openai_chat_and_responses_separately_from_embedding() {
assert_ne!(responses.route_kind.as_deref(), Some("embedding"));
}
#[test]
fn classifies_openai_realtime_only_for_websocket_upgrades() {
let websocket_headers = headers(&[
("authorization", "Bearer sk-test"),
("connection", "keep-alive, Upgrade"),
("upgrade", "websocket"),
]);
let uri: Uri = "/v1/realtime?model=gpt-realtime"
.parse()
.expect("uri should parse");
let decision = classify_control_route(&http::Method::GET, &uri, &websocket_headers)
.expect("Realtime WebSocket route should classify");
assert_eq!(decision.route_family.as_deref(), Some("openai"));
assert_eq!(decision.route_kind.as_deref(), Some("realtime"));
assert_eq!(
decision.auth_endpoint_signature.as_deref(),
Some("openai:realtime")
);
assert!(decision.is_execution_runtime_candidate());
let plain_headers = headers(&[("authorization", "Bearer sk-test")]);
assert!(classify_control_route(&http::Method::GET, &uri, &plain_headers).is_none());
assert!(classify_control_route(&http::Method::POST, &uri, &websocket_headers).is_none());
}
#[test]
fn classifies_openai_image_generation_and_edit_but_not_variation() {
let headers = headers(&[("authorization", "Bearer sk-test")]);
@@ -39,7 +39,7 @@ fn classifies_codex_models_list_with_responses_auth_signature() {
}
#[test]
fn empty_codex_client_version_keeps_standard_openai_models_signature() {
fn empty_codex_client_version_uses_responses_signature_for_bounded_fallback() {
let headers = headers(&[("authorization", "Bearer sk-test")]);
let uri: Uri = "/v1/models?client_version="
.parse()
@@ -49,7 +49,7 @@ fn empty_codex_client_version_keeps_standard_openai_models_signature() {
assert_eq!(
decision.auth_endpoint_signature.as_deref(),
Some("openai:chat")
Some("openai:responses")
);
}
+29
View File
@@ -195,6 +195,35 @@ mod tests {
assert_eq!(background.pool.min_connections, 1);
}
#[test]
fn runtime_pool_split_gives_default_small_server_more_foreground_capacity() {
let config = GatewayDataConfig::from_database_config(
SqlDatabaseConfig::new(
DatabaseDriver::Postgres,
"postgres://localhost/aether",
SqlPoolConfig {
min_connections: 4,
max_connections: 32,
..SqlPoolConfig::default()
},
)
.expect("database config should be valid"),
);
let (foreground, background) = config.split_runtime_pools_with_background_max(None);
let foreground = foreground.database().expect("foreground database");
let background = background
.expect("background database config")
.database()
.expect("background database")
.clone();
assert_eq!(foreground.pool.max_connections, 26);
assert_eq!(background.pool.max_connections, 6);
assert_eq!(foreground.pool.min_connections, 4);
assert_eq!(background.pool.min_connections, 1);
}
#[test]
fn runtime_pool_split_can_be_disabled_or_degrade_for_single_connection() {
let mut database = SqlDatabaseConfig::sqlite_default();
+32 -8
View File
@@ -1,14 +1,14 @@
use super::{
ApiKeyLastUsedDelta, DataLayerError, GatewayDataState, GeminiFileMappingListQuery,
GeminiFileMappingStats, ProviderCatalogKeyAdaptiveStateUpdate,
ProviderCatalogKeyHealthStateUpdate, ProviderCatalogKeyListQuery,
ProviderCatalogKeyOAuthCredentialCasDelete, ProviderCatalogKeyOAuthRuntimeStateCasUpdate,
ProviderCatalogKeyRuntimeMetadataUpdate, ProviderCatalogKeyStatusSnapshotUpdate,
PublicHealthStatusCount, PublicHealthTimelineBucket, StoredGeminiFileMapping,
StoredGeminiFileMappingListPage, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
StoredProviderCatalogKeyMaintenanceSummary, StoredProviderCatalogKeyPage,
StoredProviderCatalogKeyStats, StoredProviderCatalogProvider, StoredRequestCandidate,
UpsertGeminiFileMappingRecord, UpsertRequestCandidateRecord,
ProviderCatalogKeyAdminCasUpdate, ProviderCatalogKeyHealthStateUpdate,
ProviderCatalogKeyListQuery, ProviderCatalogKeyOAuthCredentialCasDelete,
ProviderCatalogKeyOAuthRuntimeStateCasUpdate, ProviderCatalogKeyRuntimeMetadataUpdate,
ProviderCatalogKeyStatusSnapshotUpdate, PublicHealthStatusCount, PublicHealthTimelineBucket,
StoredGeminiFileMapping, StoredGeminiFileMappingListPage, StoredProviderCatalogEndpoint,
StoredProviderCatalogKey, StoredProviderCatalogKeyMaintenanceSummary,
StoredProviderCatalogKeyPage, StoredProviderCatalogKeyStats, StoredProviderCatalogProvider,
StoredRequestCandidate, UpsertGeminiFileMappingRecord, UpsertRequestCandidateRecord,
};
impl GatewayDataState {
@@ -282,6 +282,16 @@ impl GatewayDataState {
}
}
pub(crate) async fn list_provider_catalog_keys_by_ids_strong(
&self,
key_ids: &[String],
) -> Result<Vec<StoredProviderCatalogKey>, DataLayerError> {
match &self.provider_catalog_reader {
Some(repository) => repository.list_keys_by_ids_strong(key_ids).await,
None => Ok(Vec::new()),
}
}
pub(crate) async fn list_provider_catalog_keys_by_provider_ids(
&self,
provider_ids: &[String],
@@ -561,6 +571,20 @@ impl GatewayDataState {
Ok(updated)
}
pub(crate) async fn compare_and_update_provider_catalog_key_admin_state(
&self,
update: &ProviderCatalogKeyAdminCasUpdate,
) -> Result<bool, DataLayerError> {
let updated = match &self.provider_catalog_writer {
Some(repository) => repository.compare_and_update_key_admin_state(update).await,
None => Ok(false),
}?;
// Clear on both success and conflict so a retry cannot reuse the stale
// credential snapshot that lost the CAS.
self.clear_provider_catalog_cache();
Ok(updated)
}
pub(crate) async fn update_provider_catalog_keys(
&self,
keys: &[StoredProviderCatalogKey],
+7 -7
View File
@@ -121,13 +121,13 @@ use aether_data_contracts::repository::pool_scores::{
UpsertPoolMemberScore,
};
use aether_data_contracts::repository::provider_catalog::{
ProviderCatalogKeyAdaptiveStateUpdate, ProviderCatalogKeyHealthStateUpdate,
ProviderCatalogKeyListQuery, ProviderCatalogKeyOAuthCredentialCasDelete,
ProviderCatalogKeyOAuthRuntimeStateCasUpdate, ProviderCatalogKeyRuntimeMetadataUpdate,
ProviderCatalogKeyStatusSnapshotUpdate, ProviderCatalogReadRepository,
ProviderCatalogWriteRepository, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
StoredProviderCatalogKeyMaintenanceSummary, StoredProviderCatalogKeyPage,
StoredProviderCatalogKeyStats, StoredProviderCatalogProvider,
ProviderCatalogKeyAdaptiveStateUpdate, ProviderCatalogKeyAdminCasUpdate,
ProviderCatalogKeyHealthStateUpdate, ProviderCatalogKeyListQuery,
ProviderCatalogKeyOAuthCredentialCasDelete, ProviderCatalogKeyOAuthRuntimeStateCasUpdate,
ProviderCatalogKeyRuntimeMetadataUpdate, ProviderCatalogKeyStatusSnapshotUpdate,
ProviderCatalogReadRepository, ProviderCatalogWriteRepository, StoredProviderCatalogEndpoint,
StoredProviderCatalogKey, StoredProviderCatalogKeyMaintenanceSummary,
StoredProviderCatalogKeyPage, StoredProviderCatalogKeyStats, StoredProviderCatalogProvider,
};
use aether_data_contracts::repository::quota::{
ProviderQuotaReadRepository, ProviderQuotaWriteRepository, StoredProviderQuotaSnapshot,
@@ -334,6 +334,13 @@ impl ProviderCatalogReadRepository for CachedProviderCatalogReadRepository {
}
}
async fn list_keys_by_ids_strong(
&self,
key_ids: &[String],
) -> Result<Vec<StoredProviderCatalogKey>, DataLayerError> {
self.inner.list_keys_by_ids_strong(key_ids).await
}
async fn list_keys_by_provider_ids(
&self,
provider_ids: &[String],
@@ -538,6 +545,7 @@ fn normalize_ids(ids: &[String]) -> Vec<String> {
mod tests {
use super::*;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data_contracts::repository::provider_catalog::ProviderCatalogWriteRepository;
fn cache() -> CachedProviderCatalogReadRepository {
CachedProviderCatalogReadRepository::new(Arc::new(
@@ -555,6 +563,55 @@ mod tests {
.expect("provider should be valid")
}
#[tokio::test]
async fn provider_catalog_strong_key_read_bypasses_fresh_cached_generation() {
let old_metadata = serde_json::json!({
"codex": {"credential_generation": "old"}
});
let mut key = StoredProviderCatalogKey::new(
"key-1".to_string(),
"provider-1".to_string(),
"key-1".to_string(),
"oauth".to_string(),
None,
true,
)
.expect("key should be valid");
key.upstream_metadata = Some(old_metadata.clone());
let inner = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider("provider-1")],
Vec::new(),
vec![key],
));
let cache = CachedProviderCatalogReadRepository::new(inner.clone());
let key_ids = vec!["key-1".to_string()];
let first = cache
.list_keys_by_ids(&key_ids)
.await
.expect("initial key read should succeed");
assert_eq!(first[0].upstream_metadata.as_ref(), Some(&old_metadata));
let new_metadata = serde_json::json!({
"codex": {"credential_generation": "new"}
});
assert!(inner
.upsert_key_upstream_metadata_namespace("key-1", "codex", &new_metadata["codex"], None,)
.await
.expect("inner metadata update should succeed"));
let cached = cache
.list_keys_by_ids(&key_ids)
.await
.expect("cached key read should succeed");
assert_eq!(cached[0].upstream_metadata.as_ref(), Some(&old_metadata));
let strong = cache
.list_keys_by_ids_strong(&key_ids)
.await
.expect("strong key read should succeed");
assert_eq!(strong[0].upstream_metadata.as_ref(), Some(&new_metadata));
}
#[tokio::test]
async fn provider_catalog_follower_observes_completion_before_first_poll() {
let cache = cache();
@@ -2578,6 +2578,21 @@ impl GatewayDataState {
}
}
pub(crate) async fn revoke_user_plan_entitlement(
&self,
user_id: &str,
entitlement_id: &str,
) -> Result<AdminBillingMutationOutcome<()>, DataLayerError> {
match &self.billing_reader {
Some(repository) => {
repository
.revoke_user_plan_entitlement(user_id, entitlement_id)
.await
}
None => Ok(AdminBillingMutationOutcome::Unavailable),
}
}
pub(crate) async fn find_user_daily_quota_availability(
&self,
user_id: &str,
@@ -105,6 +105,7 @@ async fn schedule_pool_page_candidates(
candidates: Vec<EligibleLocalExecutionCandidate>,
sticky_session_token: Option<&str>,
effective_pool_config: Option<&AdminProviderPoolConfig>,
provider_model_name: Option<&str>,
) -> (
Vec<EligibleLocalExecutionCandidate>,
Vec<SkippedLocalExecutionCandidate>,
@@ -128,7 +129,8 @@ async fn schedule_pool_page_candidates(
entry.1.insert(candidate.candidate.key_id.clone());
}
let key_context_by_id = read_pool_catalog_key_contexts_by_id(state, &candidates).await;
let key_context_by_id =
read_pool_catalog_key_contexts_by_id(state, &candidates, provider_model_name).await;
let mut runtime_by_provider = BTreeMap::new();
let mut pool_config_by_provider = BTreeMap::new();
@@ -596,11 +598,17 @@ impl<'a> PoolKeyCursor<'a> {
return;
};
self.exhaustion_skip_recorded = true;
record_local_runtime_candidate_skip_reason(
self.state.app(),
trace_id,
self.runtime_miss_pool_exhaustion_skip_reason(),
);
if self.skip_reason_counts.is_empty() {
record_local_runtime_candidate_skip_reason(
self.state.app(),
trace_id,
"pool_group_exhausted",
);
return;
}
for reason in self.skip_reason_counts.keys() {
record_local_runtime_candidate_skip_reason(self.state.app(), trace_id, reason);
}
}
fn runtime_miss_pool_exhaustion_skip_reason(&self) -> &'static str {
@@ -746,19 +754,28 @@ impl<'a> PoolKeyCursor<'a> {
return None;
}
};
let api_format = self.group.candidate.endpoint_api_format.as_str();
rows.sort_by(|left, right| {
let left_priority = self
.routing_overlay
.as_ref()
.map_or(left.key_internal_priority, |overlay| {
overlay.key_priority(&left.key_id, left.key_internal_priority)
});
let right_priority = self
.routing_overlay
.as_ref()
.map_or(right.key_internal_priority, |overlay| {
overlay.key_priority(&right.key_id, right.key_internal_priority)
});
let left_priority = self.routing_overlay.as_ref().map_or(
left.key_internal_priority,
|overlay| {
overlay.key_priority_for_format(
&left.key_id,
api_format,
left.key_internal_priority,
)
},
);
let right_priority = self.routing_overlay.as_ref().map_or(
right.key_internal_priority,
|overlay| {
overlay.key_priority_for_format(
&right.key_id,
api_format,
right.key_internal_priority,
)
},
);
left_priority
.cmp(&right_priority)
.then(left.key_id.cmp(&right.key_id))
@@ -1034,6 +1051,7 @@ impl<'a> PoolKeyCursor<'a> {
candidates,
self.sticky_session_token.as_deref(),
self.effective_pool_config.as_ref(),
Some(self.group.candidate.selected_provider_model_name.as_str()),
)
.await;
self.record_skipped_candidates(&skipped);
@@ -1406,6 +1424,7 @@ fn pool_candidate_from_catalog_key(
async fn read_pool_catalog_key_contexts_by_id(
state: PlannerAppState<'_>,
candidates: &[EligibleLocalExecutionCandidate],
provider_model_name: Option<&str>,
) -> BTreeMap<String, PoolCatalogKeyContext> {
let mut key_ids = Vec::new();
let mut provider_type_by_key_id = BTreeMap::<String, String>::new();
@@ -1451,7 +1470,13 @@ async fn read_pool_catalog_key_contexts_by_id(
.unwrap_or_default();
(
key.id.clone(),
build_pool_catalog_key_context(state, &provider_pool_service, &key, provider_type),
build_pool_catalog_key_context(
state,
&provider_pool_service,
&key,
provider_type,
provider_model_name,
),
)
})
.collect()
@@ -1462,6 +1487,7 @@ fn build_pool_catalog_key_context(
provider_pool_service: &ProviderPoolService,
key: &StoredProviderCatalogKey,
provider_type: &str,
provider_model_name: Option<&str>,
) -> PoolCatalogKeyContext {
let (health_score, _, _, _, _) = provider_key_health_summary(key);
let health_score = key
@@ -1480,8 +1506,12 @@ fn build_pool_catalog_key_context(
.filter(|value| value.is_finite() && *value >= 0.0);
let auth_config = parse_catalog_auth_config_json(state.app(), key);
let mut signals =
provider_pool_service.member_signals(provider_type, key, auth_config.as_ref());
let mut signals = provider_pool_service.member_signals(
provider_type,
key,
auth_config.as_ref(),
provider_model_name,
);
signals.account_blocked |= admin_provider_pool_pure::admin_pool_key_is_known_banned(key);
signals.account_blocked |=
pool_key_requires_reauth_for_scheduling(key, current_unix_ms().saturating_div(1000));
@@ -1826,17 +1856,17 @@ fn pool_key_candidate_order_for_group(
})
.collect::<Vec<_>>();
let active_presets = ProviderPoolService::with_builtin_adapters()
.normalize_scheduling_presets(group.transport.provider.provider_type.as_str(), &presets)
.into_iter()
.map(|preset| preset.preset)
.collect::<Vec<_>>();
.normalize_scheduling_presets(group.transport.provider.provider_type.as_str(), &presets);
if let Some(distribution_mode) = active_presets
.iter()
.find(|preset| pool_distribution_mode_preset(preset.as_str()))
.map(String::as_str)
.find(|preset| pool_distribution_mode_preset(preset.preset.as_str()))
{
return match distribution_mode {
"cache_affinity" => StoredPoolKeyCandidateOrder::CacheAffinity,
return match distribution_mode.preset.as_str() {
"cache_affinity" => match distribution_mode.mode.as_deref() {
Some("lru") => StoredPoolKeyCandidateOrder::Lru,
Some("single_account") => StoredPoolKeyCandidateOrder::SingleAccount,
_ => StoredPoolKeyCandidateOrder::CacheAffinity,
},
"load_balance" => StoredPoolKeyCandidateOrder::LoadBalance {
seed: pool_sort_seed(),
},
@@ -1921,11 +1951,13 @@ fn apply_pool_orchestration(
orchestration: PoolCandidateOrchestration,
) -> EligibleLocalExecutionCandidate {
let scheduler_affinity_epoch = candidate.orchestration.scheduler_affinity_epoch;
let sticky_key_attempts = candidate.orchestration.sticky_key_attempts;
candidate.orchestration = LocalExecutionCandidateMetadata {
candidate_group_id: orchestration.candidate_group_id,
pool_key_index: orchestration.pool_key_index,
pool_key_lease: None,
scheduler_affinity_epoch,
sticky_key_attempts,
};
candidate
}
@@ -2129,6 +2161,7 @@ mod tests {
pool_key_index: Some(0),
pool_key_lease: None,
scheduler_affinity_epoch: None,
sticky_key_attempts: None,
}
);
assert_eq!(reordered[1].orchestration.pool_key_index, Some(1));
@@ -2146,12 +2179,13 @@ mod tests {
pool_key_index: None,
pool_key_lease: None,
scheduler_affinity_epoch: None,
sticky_key_attempts: None,
}
);
}
#[test]
fn pool_scheduler_promotes_sticky_hit_before_other_sorted_keys() {
fn pool_scheduler_promotes_sticky_hit_before_lru_secondary_order() {
let key_a = sample_eligible_candidate(
"provider-pool",
"endpoint-1",
@@ -2159,7 +2193,11 @@ mod tests {
10,
Some(json!({
"pool_advanced": {
"scheduling_presets": [{"preset": "cache_affinity", "enabled": true}]
"scheduling_presets": [{
"preset": "cache_affinity",
"enabled": true,
"mode": "lru"
}]
}
})),
);
@@ -2170,7 +2208,11 @@ mod tests {
10,
Some(json!({
"pool_advanced": {
"scheduling_presets": [{"preset": "cache_affinity", "enabled": true}]
"scheduling_presets": [{
"preset": "cache_affinity",
"enabled": true,
"mode": "lru"
}]
}
})),
);
@@ -2204,6 +2246,37 @@ mod tests {
);
}
#[test]
fn cache_affinity_secondary_modes_select_distinct_candidate_orders() {
for (mode, expected) in [
("single_account", StoredPoolKeyCandidateOrder::SingleAccount),
("lru", StoredPoolKeyCandidateOrder::Lru),
] {
let group = sample_eligible_candidate(
"provider-pool",
"endpoint-1",
"key-a",
10,
Some(json!({
"pool_advanced": {
"scheduling_presets": [{
"preset": "cache_affinity",
"enabled": true,
"mode": mode
}]
}
})),
);
let config = pool_config_for_candidate(&group).expect("pool config should parse");
assert!(admin_provider_pool_cache_affinity_enabled(&config));
assert_eq!(
pool_key_candidate_order_for_group(&group, Some(&config)),
expected
);
}
}
#[test]
fn pool_scheduler_ignores_sticky_hit_without_cache_affinity() {
let key_a = sample_eligible_candidate(
@@ -3191,8 +3264,12 @@ mod tests {
.take_local_execution_runtime_miss_diagnostic(trace_id)
.expect("runtime miss diagnostic should exist");
assert_eq!(diagnostic.reason, "all_candidates_skipped");
assert_eq!(diagnostic.skipped_candidate_count, Some(1));
assert_eq!(diagnostic.skipped_candidate_count, Some(2));
assert_eq!(diagnostic.skip_reasons.get("pool_cooldown"), Some(&1));
assert_eq!(
diagnostic.skip_reasons.get("transport_snapshot_missing"),
Some(&1)
);
}
#[tokio::test]
@@ -4426,6 +4503,7 @@ mod tests {
&ProviderPoolService::with_builtin_adapters(),
&key,
"codex",
None,
);
assert_eq!(context.plan_tier.as_deref(), Some("team"));
@@ -4471,6 +4549,7 @@ mod tests {
&ProviderPoolService::with_builtin_adapters(),
&key,
"codex",
None,
);
assert!(!context.quota_exhausted);
@@ -4491,6 +4570,7 @@ mod tests {
&ProviderPoolService::with_builtin_adapters(),
&key,
"codex",
None,
);
assert!(context.quota_exhausted);
@@ -4521,11 +4601,59 @@ mod tests {
&ProviderPoolService::with_builtin_adapters(),
&key,
"antigravity",
None,
);
assert!(context.quota_exhausted);
}
#[test]
fn pool_catalog_context_scopes_antigravity_exhaustion_to_requested_model() {
let mut key = sample_catalog_oauth_key("key-antigravity-model-quota");
key.status_snapshot = Some(json!({
"quota": {
"version": 2,
"provider_type": "antigravity",
"exhausted": false,
"windows": [
{
"code": "model:gemini-3.1-pro-high",
"scope": "model",
"model": "gemini-3.1-pro-high",
"used_ratio": 1.0,
"is_exhausted": true
},
{
"code": "model:gemini-3-flash-agent",
"scope": "model",
"model": "gemini-3-flash-agent",
"used_ratio": 0.1,
"is_exhausted": false
}
]
}
}));
let app = app_state_with_catalog_key(key.clone());
let exhausted = build_pool_catalog_key_context(
PlannerAppState::new(&app),
&ProviderPoolService::with_builtin_adapters(),
&key,
"antigravity",
Some("gemini-3.1-pro-high"),
);
let available = build_pool_catalog_key_context(
PlannerAppState::new(&app),
&ProviderPoolService::with_builtin_adapters(),
&key,
"antigravity",
Some("gemini-3-flash-agent"),
);
assert!(exhausted.quota_exhausted);
assert!(!available.quota_exhausted);
}
#[test]
fn pool_catalog_context_marks_known_banned_account_from_metadata() {
let mut key = sample_catalog_oauth_key("key-account-banned");
@@ -4542,6 +4670,7 @@ mod tests {
&ProviderPoolService::with_builtin_adapters(),
&key,
"codex",
None,
);
assert!(context.account_blocked);
@@ -4947,6 +5076,7 @@ mod tests {
priority_mode: RoutingSetPriorityMode::Provider,
scheduling_mode: RoutingSchedulingMode::CacheAffinity,
keep_priority_on_conversion: false,
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
ranking_overlay: RankingOverlay {
allowed_keys: key_ids.into_iter().map(str::to_string).collect(),
..RankingOverlay::default()
+1
View File
@@ -208,6 +208,7 @@ mod tests {
pool_key_index: None,
pool_key_lease: None,
scheduler_affinity_epoch: None,
sticky_key_attempts: None,
},
ranking: None,
}
@@ -0,0 +1,62 @@
//! Shared admission helpers for local upstream execution.
//!
//! The stream candidate loop and long-lived WebSocket turns both need to
//! participate in the same gateway-wide upstream execution gate. Keep the
//! provider abstraction here so tests can supply an isolated gate while
//! production callers use `AppState` directly.
use std::time::Duration;
use aether_runtime::{ConcurrencyGate, ConcurrencyPermit};
use tokio::time::timeout;
use crate::stage_metrics::observe_gateway_stage_ms;
use crate::{AppState, GatewayError};
pub(crate) const UPSTREAM_EXECUTION_GATE_NAME: &str = "gateway_upstream_execution";
pub(crate) trait UpstreamExecutionGateProvider {
fn upstream_execution_gate(&self) -> Option<&ConcurrencyGate>;
fn upstream_execution_gate_queue_budget(&self) -> Duration;
}
impl UpstreamExecutionGateProvider for AppState {
fn upstream_execution_gate(&self) -> Option<&ConcurrencyGate> {
self.upstream_execution_gate.as_deref()
}
fn upstream_execution_gate_queue_budget(&self) -> Duration {
self.frontdoor_runtime_guards.internal_gate_queue_budget
}
}
/// Acquires the shared gateway-wide upstream execution permit.
///
/// A missing gate is an intentional configuration (unlimited), so callers
/// receive `Ok(None)`. Saturation keeps the existing candidate-level
/// `AdmissionTimeout` contract used by the HTTP stream path.
pub(crate) async fn acquire_upstream_execution_gate(
state: &(impl UpstreamExecutionGateProvider + ?Sized),
trace_id: &str,
) -> Result<Option<ConcurrencyPermit>, GatewayError> {
let Some(gate) = state.upstream_execution_gate() else {
return Ok(None);
};
let budget = state.upstream_execution_gate_queue_budget();
let gate_wait_started_at = std::time::Instant::now();
match timeout(budget, gate.acquire()).await {
Ok(Ok(permit)) => {
observe_gateway_stage_ms(
"upstream_execution_gate_wait",
gate_wait_started_at.elapsed().as_millis() as u64,
);
Ok(Some(permit))
}
Ok(Err(err)) => Err(GatewayError::Internal(err.to_string())),
Err(_) => Err(GatewayError::AdmissionTimeout {
trace_id: trace_id.to_string(),
gate: UPSTREAM_EXECUTION_GATE_NAME,
queue_budget_ms: budget.as_millis() as u64,
}),
}
}
File diff suppressed because it is too large Load Diff
@@ -2573,6 +2573,7 @@ fn json_execution_result(
candidate_id: plan.candidate_id.clone(),
status_code,
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
response_observation: None,
body: Some(ResponseBody {
json_body: Some(body),
body_bytes_b64: None,
@@ -2615,6 +2616,7 @@ fn bytes_execution_result(
candidate_id: plan.candidate_id.clone(),
status_code,
headers,
response_observation: None,
body: Some(ResponseBody {
json_body: None,
body_bytes_b64: Some(base64::engine::general_purpose::STANDARD.encode(body)),
@@ -2637,6 +2639,7 @@ fn execution_result_frame_stream(
payload: StreamFramePayload::Headers {
status_code: result.status_code,
headers: result.headers.clone(),
response_observation: result.response_observation.clone(),
},
},
StreamFrame {
@@ -480,6 +480,7 @@ mod tests {
candidate_id: None,
status_code: 502,
headers: Default::default(),
response_observation: None,
body: None,
telemetry: None,
error: None,
@@ -519,6 +520,7 @@ mod tests {
candidate_id: None,
status_code: 502,
headers: Default::default(),
response_observation: None,
body: None,
telemetry: None,
error: None,
@@ -583,6 +585,7 @@ mod tests {
candidate_id: None,
status_code: 429,
headers: Default::default(),
response_observation: None,
body: None,
telemetry: None,
error: None,
@@ -614,6 +617,7 @@ mod tests {
candidate_id: None,
status_code: 401,
headers: Default::default(),
response_observation: None,
body: None,
telemetry: None,
error: None,
@@ -645,6 +649,7 @@ mod tests {
candidate_id: None,
status_code: 502,
headers: Default::default(),
response_observation: None,
body: None,
telemetry: None,
error: Some(ExecutionError {
@@ -708,6 +713,7 @@ mod tests {
candidate_id: None,
status_code: 404,
headers: Default::default(),
response_observation: None,
body: None,
telemetry: None,
error: None,
@@ -900,6 +906,7 @@ mod tests {
candidate_id: None,
status_code: 200,
headers: Default::default(),
response_observation: None,
body: None,
telemetry: None,
error: None,
@@ -1022,6 +1029,7 @@ mod tests {
candidate_id: None,
status_code: 429,
headers: Default::default(),
response_observation: None,
body: None,
telemetry: None,
error: None,
@@ -1068,6 +1076,7 @@ mod tests {
candidate_id: None,
status_code: 429,
headers: Default::default(),
response_observation: None,
body: None,
telemetry: None,
error: None,
@@ -1174,6 +1183,7 @@ mod tests {
candidate_id: None,
status_code: 200,
headers: Default::default(),
response_observation: None,
body: None,
telemetry: None,
error: None,
@@ -1211,6 +1221,7 @@ mod tests {
candidate_id: None,
status_code: 400,
headers: Default::default(),
response_observation: None,
body: None,
telemetry: None,
error: None,
@@ -1259,6 +1270,7 @@ mod tests {
candidate_id: None,
status_code: 429,
headers: Default::default(),
response_observation: None,
body: None,
telemetry: None,
error: None,
@@ -841,6 +841,7 @@ fn encode_grok_headers_frame(
payload: StreamFramePayload::Headers {
status_code,
headers,
response_observation: None,
},
})
}
@@ -2157,6 +2158,7 @@ fn grok_execution_result(
candidate_id: plan.candidate_id.clone(),
status_code,
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
response_observation: None,
body: Some(ResponseBody {
json_body: Some(body_json),
body_bytes_b64: None,
@@ -2220,6 +2222,7 @@ fn grok_collected_frame_stream(
"application/json".to_string()
},
)]),
response_observation: None,
},
},
StreamFrame {
@@ -279,6 +279,7 @@ fn raw_response_frame_stream(
payload: StreamFramePayload::Headers {
status_code,
headers,
response_observation: None,
},
},
StreamFrame {
@@ -1449,6 +1450,7 @@ mod tests {
candidate_id: None,
status_code: 200,
headers: BTreeMap::new(),
response_observation: None,
body: Some(aether_contracts::ResponseBody {
json_body: Some(json!({
"jsonrpc": "2.0",
@@ -3,6 +3,8 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
pub(crate) mod admission;
pub(crate) mod attempt_lifecycle;
mod chatgpt_web_image;
mod constants;
mod fallback;
@@ -23,6 +25,9 @@ pub(crate) mod transport;
mod transport_failure;
mod windsurf;
pub(crate) use self::admission::{
acquire_upstream_execution_gate, UpstreamExecutionGateProvider, UPSTREAM_EXECUTION_GATE_NAME,
};
pub(crate) use self::chatgpt_web_image::maybe_execute_chatgpt_web_image_sync;
pub(crate) use self::constants::{
MAX_ERROR_BODY_BYTES, MAX_STREAM_PREFETCH_BYTES, MAX_STREAM_PREFETCH_FRAMES,
@@ -2,10 +2,10 @@ use aether_contracts::ExecutionPlan;
use tracing::warn;
use crate::orchestration::{
oauth_status_may_be_invalid as status_may_be_oauth_invalid,
local_failover_error_message, oauth_status_may_be_invalid as status_may_be_oauth_invalid,
oauth_status_proves_access_token_invalid as status_proves_access_token_invalid,
};
use crate::state::AgentIdentityAuthConfigFence;
use crate::state::{AgentIdentityAuthConfigFence, CodexRuntimeOAuthObservation};
use crate::{provider_transport::LocalOAuthRefreshError, AppState};
pub(crate) async fn refresh_oauth_plan_auth_for_retry(
@@ -14,6 +14,9 @@ pub(crate) async fn refresh_oauth_plan_auth_for_retry(
status_code: u16,
response_text: Option<&str>,
trace_id: &str,
report_context: Option<&serde_json::Value>,
request_started_at_unix_ms: Option<u64>,
request_order_id: Option<&str>,
) -> bool {
if !status_may_be_oauth_invalid(status_code, response_text) {
return false;
@@ -109,15 +112,49 @@ pub(crate) async fn refresh_oauth_plan_auth_for_retry(
body_excerpt,
..
}) if matches!(refresh_status_code, 400 | 401 | 403) => {
if let Err(err) = state
.persist_local_oauth_refresh_failure_state(
&transport,
refresh_status_code,
body_excerpt.as_str(),
access_token_invalid_proven,
)
.await
{
let observed_credential_generation =
report_context_string(report_context, "codex_credential_generation");
let runtime_invalid_message = local_failover_error_message(response_text);
let runtime_invalid_reason =
aether_admin::provider::quota::codex_runtime_invalid_reason(
status_code,
runtime_invalid_message.as_deref(),
);
let persist_result = match (request_started_at_unix_ms, request_order_id) {
(Some(request_started_at_unix_ms), Some(request_order_id))
if transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("codex") =>
{
state
.persist_local_oauth_refresh_failure_state_observed(
&transport,
refresh_status_code,
body_excerpt.as_str(),
access_token_invalid_proven,
CodexRuntimeOAuthObservation {
request_started_at_unix_ms,
request_order_id,
observed_credential_generation,
runtime_invalid_reason: runtime_invalid_reason.as_deref(),
},
)
.await
}
_ => {
state
.persist_local_oauth_refresh_failure_state(
&transport,
refresh_status_code,
body_excerpt.as_str(),
access_token_invalid_proven,
)
.await
}
};
if let Err(err) = persist_result {
warn!(
event_name = "local_oauth_retry_refresh_failure_persist_failed",
log_type = "ops",
@@ -161,6 +198,17 @@ pub(crate) async fn refresh_oauth_plan_auth_for_retry(
}
}
fn report_context_string<'a>(
report_context: Option<&'a serde_json::Value>,
field: &str,
) -> Option<&'a str> {
report_context
.and_then(|context| context.get(field))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn execution_plan_authorization(plan: &ExecutionPlan) -> Option<&str> {
plan.headers
.iter()
@@ -209,6 +257,7 @@ mod tests {
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data_contracts::repository::provider_catalog::{
ProviderCatalogKeyAdminCasUpdate, ProviderCatalogKeyOAuthCredentialFence,
ProviderCatalogReadRepository, ProviderCatalogWriteRepository,
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
@@ -312,7 +361,7 @@ mod tests {
}
#[tokio::test]
async fn auto_removes_codex_key_after_request_proven_terminal_refresh_failure() {
async fn retains_codex_key_after_request_proven_terminal_refresh_failure() {
let token_hits = Arc::new(Mutex::new(0usize));
let token_hits_clone = Arc::clone(&token_hits);
let token_server = Router::new().route(
@@ -458,16 +507,34 @@ mod tests {
401,
Some(r#"{"error":"oauth_token_invalid"}"#),
"trace-oauth-retry",
None,
Some(1_000),
Some("01900000-0000-7000-8000-000000000010"),
)
.await;
assert!(!retried);
assert_eq!(*token_hits.lock().expect("mutex should lock"), 1);
let keys = provider_catalog_repository
let stored_key = provider_catalog_repository
.list_keys_by_ids(&["key-codex-oauth-retry".to_string()])
.await
.expect("keys should read");
assert!(keys.is_empty());
.expect("keys should read")
.into_iter()
.next()
.expect("request-scoped refresh failure should retain the key");
let invalid_reason = stored_key
.oauth_invalid_reason
.as_deref()
.expect("combined invalid reason should persist");
assert!(invalid_reason.contains("[OAUTH_EXPIRED]"));
assert!(invalid_reason.contains("[REFRESH_FAILED]"));
assert_eq!(
stored_key
.upstream_metadata
.as_ref()
.and_then(|metadata| metadata.pointer("/codex/oauth_state_request_id")),
Some(&json!("01900000-0000-7000-8000-000000000010"))
);
token_handle.abort();
}
@@ -619,6 +686,9 @@ mod tests {
401,
Some(r#"{"error":"invalid_token"}"#),
"trace-claude-oauth-fence-first",
None,
None,
None,
)
.await
);
@@ -647,6 +717,9 @@ mod tests {
401,
Some(r#"{"error":"invalid_token"}"#),
"trace-claude-oauth-fence-stale",
None,
None,
None,
)
.await
);
@@ -665,6 +738,7 @@ mod tests {
.expect("Claude key should load")
.pop()
.expect("Claude key should exist");
let expected_admin_replacement = admin_replacement.clone();
admin_replacement.encrypted_api_key = Some(
encrypt_python_fernet_plaintext(
DEVELOPMENT_ENCRYPTION_KEY,
@@ -673,10 +747,23 @@ mod tests {
.expect("admin access token should encrypt"),
);
admin_replacement.expires_at_unix_secs = Some(4_102_444_800);
provider_catalog_repository
.update_key(&admin_replacement)
assert!(provider_catalog_repository
.compare_and_update_key_admin_state(&ProviderCatalogKeyAdminCasUpdate {
expected_encrypted_auth_config: expected_admin_replacement
.encrypted_auth_config
.clone(),
expected_credential: ProviderCatalogKeyOAuthCredentialFence {
encrypted_api_key: expected_admin_replacement.encrypted_api_key.clone(),
auth_type: expected_admin_replacement.auth_type.clone(),
provider_id: expected_admin_replacement.provider_id.clone(),
provider_type: "claude_code".to_string(),
},
key: admin_replacement,
codex_rotation: None,
reset_oauth_runtime: true,
})
.await
.expect("admin replacement should persist");
.expect("admin replacement CAS should run"));
let admin_result = state
.force_local_oauth_refresh_entry(&stale_transport)
@@ -10,6 +10,10 @@ use crate::{AppState, GatewayError};
const RESPONSE_HEADER_RULES_KEY: &str = "response_header_rules";
const RESPONSE_HEADER_RULES_CAMEL_KEY: &str = "responseHeaderRules";
const PROVIDER_RESPONSE_HEADERS_CONTEXT_KEY: &str = "provider_response_headers";
const PROVIDER_REQUEST_STARTED_AT_UNIX_MS_CONTEXT_KEY: &str = "provider_request_started_at_unix_ms";
const PROVIDER_REQUEST_ORDER_ID_CONTEXT_KEY: &str = "provider_request_order_id";
const PROVIDER_RESPONSE_HEADERS_OBSERVED_AT_UNIX_MS_CONTEXT_KEY: &str =
"provider_response_headers_observed_at_unix_ms";
const RESPONSE_HEADER_RULE_PROTECTED_KEYS: &[&str] = &["content-length"];
const RESPONSE_HEADER_RULES_CACHE_TTL: Duration = Duration::from_secs(5);
@@ -98,6 +102,9 @@ pub(crate) async fn apply_endpoint_response_header_rules(
pub(crate) fn attach_provider_response_headers_to_report_context(
report_context: Option<Value>,
provider_headers: &BTreeMap<String, String>,
provider_request_started_at_unix_ms: u64,
provider_response_headers_observed_at_unix_ms: u64,
provider_request_order_id: &str,
) -> Option<Value> {
let provider_headers = serde_json::to_value(provider_headers).ok()?;
let mut object = match report_context {
@@ -105,9 +112,99 @@ pub(crate) fn attach_provider_response_headers_to_report_context(
Some(other) => Map::from_iter([("seed".to_string(), other)]),
None => Map::new(),
};
object.insert(
PROVIDER_RESPONSE_HEADERS_CONTEXT_KEY.to_string(),
provider_headers,
);
let observation_is_absent = !object.contains_key(PROVIDER_RESPONSE_HEADERS_CONTEXT_KEY)
&& !object.contains_key(PROVIDER_REQUEST_STARTED_AT_UNIX_MS_CONTEXT_KEY)
&& !object.contains_key(PROVIDER_RESPONSE_HEADERS_OBSERVED_AT_UNIX_MS_CONTEXT_KEY)
&& !object.contains_key(PROVIDER_REQUEST_ORDER_ID_CONTEXT_KEY);
if observation_is_absent {
object.insert(
PROVIDER_RESPONSE_HEADERS_CONTEXT_KEY.to_string(),
provider_headers,
);
object.insert(
PROVIDER_REQUEST_STARTED_AT_UNIX_MS_CONTEXT_KEY.to_string(),
Value::from(provider_request_started_at_unix_ms),
);
object.insert(
PROVIDER_RESPONSE_HEADERS_OBSERVED_AT_UNIX_MS_CONTEXT_KEY.to_string(),
Value::from(provider_response_headers_observed_at_unix_ms),
);
object.insert(
PROVIDER_REQUEST_ORDER_ID_CONTEXT_KEY.to_string(),
Value::from(provider_request_order_id),
);
}
Some(Value::Object(object))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn provider_response_observation_is_first_write_wins() {
let first_headers =
BTreeMap::from([("x-codex-primary-used-percent".to_string(), "10".to_string())]);
let second_headers =
BTreeMap::from([("x-codex-primary-used-percent".to_string(), "20".to_string())]);
let report_context = attach_provider_response_headers_to_report_context(
Some(json!("seed-value")),
&first_headers,
100,
200,
"observation-1",
);
let report_context = attach_provider_response_headers_to_report_context(
report_context,
&second_headers,
300,
400,
"observation-2",
)
.expect("report context should exist");
assert_eq!(report_context["seed"], json!("seed-value"));
assert_eq!(
report_context["provider_response_headers"]["x-codex-primary-used-percent"],
json!("10")
);
assert_eq!(
report_context["provider_request_started_at_unix_ms"],
json!(100)
);
assert_eq!(
report_context["provider_response_headers_observed_at_unix_ms"],
json!(200)
);
assert_eq!(
report_context["provider_request_order_id"],
json!("observation-1")
);
}
#[test]
fn provider_response_observation_does_not_complete_a_partial_triplet() {
let report_context = attach_provider_response_headers_to_report_context(
Some(json!({"provider_response_headers": {"x-existing": "1"}})),
&BTreeMap::from([("x-new".to_string(), "2".to_string())]),
300,
400,
"observation-2",
)
.expect("report context should exist");
assert_eq!(
report_context["provider_response_headers"]["x-existing"],
json!("1")
);
assert!(report_context
.get("provider_request_started_at_unix_ms")
.is_none());
assert!(report_context
.get("provider_response_headers_observed_at_unix_ms")
.is_none());
assert!(report_context.get("provider_request_order_id").is_none());
}
}
@@ -5,6 +5,7 @@ use serde_json::Value;
use crate::execution_runtime::MAX_STREAM_PREFETCH_BYTES;
const ANTHROPIC_PRECOMMIT_MAX_WAIT: Duration = Duration::from_millis(750);
const GEMINI_PRECOMMIT_MAX_WAIT: Duration = Duration::from_millis(750);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum StreamCommitPolicy {
@@ -14,6 +15,10 @@ pub(super) enum StreamCommitPolicy {
max_bytes: usize,
max_wait: Duration,
},
FirstGeminiSemanticEvent {
max_bytes: usize,
max_wait: Duration,
},
}
impl StreamCommitPolicy {
@@ -51,6 +56,12 @@ impl StreamCommitPolicy {
max_wait: ANTHROPIC_PRECOMMIT_MAX_WAIT,
};
}
if provider_api_format.eq_ignore_ascii_case("gemini:generate_content") {
return Self::FirstGeminiSemanticEvent {
max_bytes: MAX_STREAM_PREFETCH_BYTES,
max_wait: GEMINI_PRECOMMIT_MAX_WAIT,
};
}
return Self::ResponseHeaders;
}
@@ -78,12 +89,16 @@ impl StreamCommitPolicy {
}
pub(super) const fn requires_bounded_frame_wait(self) -> bool {
matches!(self, Self::FirstAnthropicSemanticEvent { .. })
matches!(
self,
Self::FirstAnthropicSemanticEvent { .. } | Self::FirstGeminiSemanticEvent { .. }
)
}
pub(super) const fn max_precommit_wait(self) -> Option<Duration> {
match self {
Self::FirstAnthropicSemanticEvent { max_wait, .. } => Some(max_wait),
Self::FirstAnthropicSemanticEvent { max_wait, .. }
| Self::FirstGeminiSemanticEvent { max_wait, .. } => Some(max_wait),
Self::ResponseHeaders | Self::FirstClassifiedBody => None,
}
}
@@ -91,6 +106,10 @@ impl StreamCommitPolicy {
pub(super) const fn is_native_anthropic(self) -> bool {
matches!(self, Self::FirstAnthropicSemanticEvent { .. })
}
pub(super) const fn is_gemini(self) -> bool {
matches!(self, Self::FirstGeminiSemanticEvent { .. })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -113,6 +132,7 @@ pub(super) struct StreamCommitGate {
state: StreamCommitState,
observed_bytes: usize,
anthropic: AnthropicSsePrecommitInspector,
gemini: GeminiSsePrecommitInspector,
}
impl StreamCommitGate {
@@ -127,6 +147,7 @@ impl StreamCommitGate {
state,
observed_bytes: 0,
anthropic: AnthropicSsePrecommitInspector::default(),
gemini: GeminiSsePrecommitInspector::default(),
}
}
@@ -143,21 +164,32 @@ impl StreamCommitGate {
return StreamPrecommitObservation::Commit;
}
let StreamCommitPolicy::FirstAnthropicSemanticEvent { max_bytes, .. } = self.policy else {
return StreamPrecommitObservation::Pending;
let (max_bytes, observation) = match self.policy {
StreamCommitPolicy::FirstAnthropicSemanticEvent { max_bytes, .. } => {
(max_bytes, self.anthropic.observe(chunk, max_bytes))
}
StreamCommitPolicy::FirstGeminiSemanticEvent { max_bytes, .. } => {
(max_bytes, self.gemini.observe(chunk, max_bytes))
}
StreamCommitPolicy::ResponseHeaders | StreamCommitPolicy::FirstClassifiedBody => {
return StreamPrecommitObservation::Pending;
}
};
self.observed_bytes = self.observed_bytes.saturating_add(chunk.len());
match self.anthropic.observe(chunk, max_bytes) {
AnthropicSseObservation::Pending => {}
AnthropicSseObservation::SemanticEvent => {
match observation {
SemanticSseObservation::Pending => {}
SemanticSseObservation::SemanticEvent => {
self.state = StreamCommitState::Committed;
return StreamPrecommitObservation::Commit;
}
AnthropicSseObservation::Error(body_json) => {
SemanticSseObservation::Error {
status_code,
body_json,
} => {
self.state = StreamCommitState::Terminal;
return StreamPrecommitObservation::UpstreamError {
status_code: anthropic_error_status_code(&body_json),
status_code,
body_json,
};
}
@@ -179,10 +211,10 @@ impl StreamCommitGate {
}
#[derive(Debug)]
enum AnthropicSseObservation {
enum SemanticSseObservation {
Pending,
SemanticEvent,
Error(Value),
Error { status_code: u16, body_json: Value },
}
#[derive(Debug, Default)]
@@ -191,7 +223,7 @@ struct AnthropicSsePrecommitInspector {
}
impl AnthropicSsePrecommitInspector {
fn observe(&mut self, chunk: &[u8], max_bytes: usize) -> AnthropicSseObservation {
fn observe(&mut self, chunk: &[u8], max_bytes: usize) -> SemanticSseObservation {
let remaining = max_bytes.saturating_sub(self.buffered.len());
let truncated = chunk.len() > remaining;
self.buffered
@@ -201,15 +233,44 @@ impl AnthropicSsePrecommitInspector {
let record = self.buffered[..record_end].to_vec();
self.buffered.drain(..record_end + separator_len);
match classify_anthropic_sse_record(&record) {
AnthropicSseObservation::Pending => {}
SemanticSseObservation::Pending => {}
decision => return decision,
}
}
if truncated {
AnthropicSseObservation::SemanticEvent
SemanticSseObservation::SemanticEvent
} else {
AnthropicSseObservation::Pending
SemanticSseObservation::Pending
}
}
}
#[derive(Debug, Default)]
struct GeminiSsePrecommitInspector {
buffered: Vec<u8>,
}
impl GeminiSsePrecommitInspector {
fn observe(&mut self, chunk: &[u8], max_bytes: usize) -> SemanticSseObservation {
let remaining = max_bytes.saturating_sub(self.buffered.len());
let truncated = chunk.len() > remaining;
self.buffered
.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
while let Some((record_end, separator_len)) = find_sse_record_boundary(&self.buffered) {
let record = self.buffered[..record_end].to_vec();
self.buffered.drain(..record_end + separator_len);
match classify_gemini_sse_record(&record) {
SemanticSseObservation::Pending => {}
decision => return decision,
}
}
if truncated {
SemanticSseObservation::SemanticEvent
} else {
SemanticSseObservation::Pending
}
}
}
@@ -249,9 +310,9 @@ fn next_sse_line_ending(buffer: &[u8], start: usize) -> Option<(usize, usize)> {
Some((index, ending_len))
}
fn classify_anthropic_sse_record(record: &[u8]) -> AnthropicSseObservation {
fn classify_anthropic_sse_record(record: &[u8]) -> SemanticSseObservation {
let Ok(record) = std::str::from_utf8(record) else {
return AnthropicSseObservation::Pending;
return SemanticSseObservation::Pending;
};
let normalized_record = record.replace("\r\n", "\n").replace('\r', "\n");
let mut event_type = None;
@@ -275,15 +336,18 @@ fn classify_anthropic_sse_record(record: &[u8]) -> AnthropicSseObservation {
}
}
if data.trim().is_empty() {
return AnthropicSseObservation::Pending;
return SemanticSseObservation::Pending;
}
let Ok(body_json) = serde_json::from_str::<Value>(data.trim()) else {
return AnthropicSseObservation::Pending;
return SemanticSseObservation::Pending;
};
let payload_type = body_json.get("type").and_then(Value::as_str).map(str::trim);
if event_type == Some("error") || payload_type == Some("error") {
return AnthropicSseObservation::Error(body_json);
return SemanticSseObservation::Error {
status_code: anthropic_error_status_code(&body_json),
body_json,
};
}
let semantic_type = match (event_type, payload_type) {
@@ -292,12 +356,120 @@ fn classify_anthropic_sse_record(record: &[u8]) -> AnthropicSseObservation {
_ => None,
};
if semantic_type.is_some_and(is_anthropic_semantic_event_type) {
AnthropicSseObservation::SemanticEvent
SemanticSseObservation::SemanticEvent
} else {
AnthropicSseObservation::Pending
SemanticSseObservation::Pending
}
}
fn classify_gemini_sse_record(record: &[u8]) -> SemanticSseObservation {
let Ok(record) = std::str::from_utf8(record) else {
return SemanticSseObservation::Pending;
};
let data = record
.replace("\r\n", "\n")
.replace('\r', "\n")
.lines()
.filter_map(|line| line.strip_prefix("data:").map(str::trim_start))
.collect::<Vec<_>>()
.join("\n");
if data.trim().is_empty() {
return SemanticSseObservation::Pending;
}
if data.trim() == "[DONE]" {
return SemanticSseObservation::SemanticEvent;
}
let Ok(body_json) = serde_json::from_str::<Value>(data.trim()) else {
return SemanticSseObservation::Pending;
};
let response = body_json.get("response").unwrap_or(&body_json);
let Some(candidates) = response.get("candidates").and_then(Value::as_array) else {
return SemanticSseObservation::Pending;
};
for candidate in candidates {
let finish_reason = candidate
.get("finishReason")
.or_else(|| candidate.get("finish_reason"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(finish_reason) = finish_reason.filter(|reason| {
matches!(
*reason,
"MALFORMED_FUNCTION_CALL"
| "UNEXPECTED_TOOL_CALL"
| "TOO_MANY_TOOL_CALLS"
| "MISSING_THOUGHT_SIGNATURE"
| "MALFORMED_RESPONSE"
)
}) {
let message = candidate
.get("finishMessage")
.or_else(|| candidate.get("finish_message"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("Gemini stream ended with {finish_reason}"));
return SemanticSseObservation::Error {
status_code: 502,
body_json: serde_json::json!({
"error": {
"type": "upstream_gemini_finish_error",
"code": finish_reason,
"message": message,
"upstream_status": 200
}
}),
};
}
if finish_reason.is_some() {
return SemanticSseObservation::SemanticEvent;
}
let Some(parts) = candidate
.get("content")
.and_then(|content| content.get("parts"))
.and_then(Value::as_array)
else {
continue;
};
if parts.iter().any(gemini_part_is_client_semantic) {
return SemanticSseObservation::SemanticEvent;
}
}
SemanticSseObservation::Pending
}
fn gemini_part_is_client_semantic(part: &Value) -> bool {
let Some(part) = part.as_object() else {
return true;
};
if part
.keys()
.any(|key| !matches!(key.as_str(), "text" | "thought" | "thoughtSignature"))
{
return true;
}
if part.get("thought").and_then(Value::as_bool) == Some(true) {
return false;
}
if part.keys().all(|key| key == "thoughtSignature") {
return false;
}
if part
.get("text")
.and_then(Value::as_str)
.is_some_and(|text| !text.is_empty())
{
return true;
}
false
}
fn is_anthropic_semantic_event_type(event_type: &str) -> bool {
matches!(
event_type,
@@ -346,6 +518,13 @@ mod tests {
}
}
fn gemini_policy() -> StreamCommitPolicy {
StreamCommitPolicy::FirstGeminiSemanticEvent {
max_bytes: 16_384,
max_wait: Duration::from_millis(750),
}
}
#[test]
fn policy_selects_bounded_anthropic_gate_only_for_native_same_format_sse() {
let native = StreamCommitPolicy::for_response(
@@ -384,6 +563,109 @@ mod tests {
.commits_on_response_headers());
}
#[test]
fn policy_selects_bounded_gemini_gate_for_event_streams() {
let policy = StreamCommitPolicy::for_response(
true,
Some("text/event-stream"),
"gemini:generate_content",
"openai:responses",
false,
true,
false,
);
assert!(policy.is_gemini());
assert!(policy.requires_bounded_frame_wait());
assert_eq!(
policy.max_precommit_wait(),
Some(Duration::from_millis(750))
);
}
#[test]
fn gemini_gate_waits_through_thought_and_commits_on_text() {
let mut gate = StreamCommitGate::new(gemini_policy());
let thought = b"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"thought\":true,\"text\":\"checking\"}]}}]}}\n\n";
let text = b"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"answer\"}]}}]}}\n\n";
assert_eq!(
gate.observe_provider_bytes(thought),
StreamPrecommitObservation::Pending
);
assert_eq!(
gate.observe_provider_bytes(text),
StreamPrecommitObservation::Commit
);
assert_eq!(gate.state(), StreamCommitState::Committed);
}
#[test]
fn gemini_gate_commits_on_function_call_even_with_thought_marker() {
let mut gate = StreamCommitGate::new(gemini_policy());
let tool_call = b"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"thought\":true,\"functionCall\":{\"name\":\"validate\",\"args\":{}}}]}}]}}\n\n";
assert_eq!(
gate.observe_provider_bytes(tool_call),
StreamPrecommitObservation::Commit
);
assert_eq!(gate.state(), StreamCommitState::Committed);
}
#[test]
fn gemini_gate_rejects_malformed_function_call_before_commit() {
let mut gate = StreamCommitGate::new(gemini_policy());
let thought = b"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"thought\":true,\"text\":\"calling\"}]}}]}}\n\n";
let malformed = b"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"thoughtSignature\":\"signature\",\"text\":\"\"}]},\"finishReason\":\"MALFORMED_FUNCTION_CALL\",\"finishMessage\":\"Malformed function call: Function call is empty - no input to parse.\"}]}}\n\n";
assert_eq!(
gate.observe_provider_bytes(thought),
StreamPrecommitObservation::Pending
);
let StreamPrecommitObservation::UpstreamError {
status_code,
body_json,
} = gate.observe_provider_bytes(malformed)
else {
panic!("malformed Gemini function call should fail before stream commit");
};
assert_eq!(status_code, 502);
assert_eq!(body_json["error"]["code"], "MALFORMED_FUNCTION_CALL");
assert_eq!(
body_json["error"]["message"],
"Malformed function call: Function call is empty - no input to parse."
);
assert_eq!(gate.state(), StreamCommitState::Terminal);
}
#[test]
fn gemini_gate_detects_malformed_function_call_across_chunk_boundaries() {
let malformed = b"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"thoughtSignature\":\"signature\",\"text\":\"\"}]},\"finishReason\":\"MALFORMED_FUNCTION_CALL\",\"finishMessage\":\"empty call\"}]}}\r\n\r\n";
for split in 1..malformed.len() {
let mut gate = StreamCommitGate::new(gemini_policy());
let first_observation = gate.observe_provider_bytes(&malformed[..split]);
if !matches!(
first_observation,
StreamPrecommitObservation::UpstreamError {
status_code: 502,
..
}
) {
assert_eq!(first_observation, StreamPrecommitObservation::Pending);
assert!(matches!(
gate.observe_provider_bytes(&malformed[split..]),
StreamPrecommitObservation::UpstreamError {
status_code: 502,
..
}
));
}
assert_eq!(gate.state(), StreamCommitState::Terminal);
}
}
#[test]
fn gate_detects_anthropic_error_across_every_chunk_boundary() {
let event = b"event: error\r\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"busy\"}}\r\n\r\n";
@@ -11,8 +11,8 @@ use std::time::{Duration, Instant};
use aether_ai_serving::{AiAttemptExecutionOutcome, AiAttemptRetryScope};
use aether_contracts::{
ExecutionPlan, ExecutionStreamTerminalSummary, ExecutionTelemetry, StandardizedUsage,
StreamFrame, StreamFramePayload,
ExecutionPlan, ExecutionResponseObservation, ExecutionStreamTerminalSummary,
ExecutionTelemetry, StandardizedUsage, StreamFrame, StreamFramePayload,
};
use aether_data_contracts::repository::candidates::{
RequestCandidateStatus, UpsertRequestCandidateRecord,
@@ -64,8 +64,14 @@ use crate::ai_serving::api::{
extract_provider_private_stream_error_body, maybe_bridge_standard_sync_json_to_stream,
maybe_build_provider_private_stream_normalizer, maybe_build_stream_response_rewriter,
normalize_provider_private_report_context, StreamingStandardTerminalObserver,
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND,
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_INTERACTIONS_STREAM_PLAN_KIND,
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND,
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_STREAM_PLAN_KIND,
UPSTREAM_IS_STREAM_KEY,
};
use crate::ai_serving::is_openai_responses_family_format;
use crate::ai_serving::record_local_runtime_candidate_skip_reason;
use crate::api::response::{
attach_control_metadata_headers, build_client_response, build_client_response_from_parts,
};
@@ -112,15 +118,16 @@ use crate::execution_runtime::{
use crate::log_ids::short_request_id;
use crate::orchestration::{
apply_local_execution_effect, build_local_error_flow_metadata, classify_failure_disposition,
cyber_continue_failover_enabled, trace_upstream_response_body, with_error_flow_report_context,
cyber_continue_failover_enabled, spawn_local_oauth_success_effect,
trace_upstream_response_body, with_error_flow_report_context,
with_upstream_response_report_context, FailureDisposition, FailureTokenAction,
LocalAdaptiveRateLimitEffect, LocalAdaptiveSuccessEffect, LocalAttemptFailureEffect,
LocalExecutionEffect, LocalExecutionEffectContext, LocalFailoverAnalysis,
LocalHealthFailureEffect, LocalHealthSuccessEffect, LocalOAuthInvalidationEffect,
LocalPoolErrorEffect,
LocalOAuthSuccessEffect, LocalPoolErrorEffect,
};
use crate::provider_pool_demand::{
acquire_provider_pool_in_flight_guard, ProviderPoolInFlightGuard,
acquire_provider_pool_execution_guard, ProviderPoolInFlightAdmission, ProviderPoolInFlightGuard,
};
use crate::request_candidate_runtime::{
ensure_execution_request_candidate_slot, persist_local_request_candidate_status_record,
@@ -143,7 +150,6 @@ use crate::{
AppState, GatewayError, GEMINI_FILES_DOWNLOAD_PLAN_KIND, OPENAI_VIDEO_CONTENT_PLAN_KIND,
};
const OPENAI_IMAGE_STREAM_PLAN_KIND: &str = "openai_image_stream";
const SSE_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15);
const SSE_KEEPALIVE_BYTES: &[u8] = b": aether-keepalive\n\n";
const SSE_CONTROL_FILTER_MAX_BUFFER_BYTES: usize = 1024 * 1024;
@@ -1249,6 +1255,9 @@ async fn execute_in_process_stream_with_oauth_retry(
retry_status_code,
response_text.as_deref(),
trace_id,
report_context,
Some(execution.response_observation.request_started_at_unix_ms),
Some(&execution.response_observation.request_order_id),
)
.await
{
@@ -2818,6 +2827,7 @@ async fn execute_stream_from_direct_passthrough(
stream_precommit_committed: _,
response,
started_at: upstream_started_at,
response_observation,
stream_first_byte_timeout,
upstream_target_permit,
} = execution;
@@ -2834,8 +2844,23 @@ async fn execute_stream_from_direct_passthrough(
let request_id = plan.request_id.clone();
let candidate_id = plan.candidate_id.clone();
let request_id_for_log = short_request_id(request_id.as_str());
let mut report_context =
attach_provider_response_headers_to_report_context(report_context, &headers);
let mut report_context = attach_provider_response_headers_to_report_context(
report_context,
&headers,
response_observation.request_started_at_unix_ms,
response_observation.response_headers_observed_at_unix_ms,
&response_observation.request_order_id,
);
spawn_local_oauth_success_effect(
state.clone(),
&plan,
report_context.as_ref(),
LocalOAuthSuccessEffect {
status_code,
request_started_at_unix_ms: Some(response_observation.request_started_at_unix_ms),
request_order_id: Some(&response_observation.request_order_id),
},
);
if status_code == 200 {
seed_kiro_simulated_cache_enabled(state, &plan, &mut report_context).await;
if kiro_simulated_cache_enabled_from_report_context(report_context.as_ref()) {
@@ -3748,6 +3773,46 @@ async fn execute_execution_runtime_stream_inner(
plan_kind,
report_context.as_ref(),
);
let candidate_started_unix_secs = current_request_candidate_unix_ms();
let provider_in_flight_started_at = Instant::now();
let mut provider_pool_in_flight_guard =
match acquire_provider_pool_execution_guard(state, &plan).await? {
ProviderPoolInFlightAdmission::Acquired(guard) => guard,
ProviderPoolInFlightAdmission::Saturated { limit } => {
record_local_runtime_candidate_skip_reason(
state,
trace_id,
"provider_key_concurrency_limit_reached",
);
if let Some(retry_scope) = retry_scope_out.as_deref_mut() {
*retry_scope = AiAttemptRetryScope::Candidate;
}
if let Some(snapshot) = request_candidate_status_snapshot.as_ref() {
record_local_request_candidate_status_snapshot(
state,
snapshot,
SchedulerRequestCandidateStatusUpdate {
status: RequestCandidateStatus::Skipped,
status_code: Some(http::StatusCode::TOO_MANY_REQUESTS.as_u16()),
error_type: Some("provider_key_concurrency_limit_reached".to_string()),
error_message: Some(format!(
"provider key concurrency limit reached: {limit}"
)),
latency_ms: Some(0),
started_at_unix_ms: Some(candidate_started_unix_secs),
finished_at_unix_ms: Some(candidate_started_unix_secs),
},
)
.await;
}
return Ok(None);
}
};
observe_gateway_stage_trace_ms(
&mut stage_trace,
"stream_provider_in_flight",
provider_in_flight_started_at.elapsed().as_millis() as u64,
);
// Inline passthrough records its lifecycle seed after upstream headers are
// available. Avoid constructing a throwaway seed on the common path.
let mut lifecycle_seed = (!defer_stream_pending_for_direct_inline)
@@ -3757,7 +3822,6 @@ async fn execute_execution_runtime_stream_inner(
record_stream_pending_lifecycle(state, seed, &mut stage_trace).await;
lifecycle_pending_recorded = true;
}
let candidate_started_unix_secs = current_request_candidate_unix_ms();
if let Some(snapshot) = request_candidate_status_snapshot.clone() {
record_local_request_candidate_status_snapshot(
state,
@@ -3786,20 +3850,6 @@ async fn execute_execution_runtime_stream_inner(
.and_then(|context| context.candidate_index)
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string());
let provider_in_flight_started_at = Instant::now();
let mut provider_pool_in_flight_guard = acquire_provider_pool_in_flight_guard(
state.runtime_state.clone(),
&plan.provider_id,
plan.request_id.as_str(),
plan.candidate_id.as_deref(),
key_id.as_str(),
)
.await;
observe_gateway_stage_trace_ms(
&mut stage_trace,
"stream_provider_in_flight",
provider_in_flight_started_at.elapsed().as_millis() as u64,
);
match maybe_execute_grok_stream(&plan, report_context.as_ref()).await {
Ok(Some(grok_stream)) => {
return execute_stream_from_frame_stream_with_retry_scope(
@@ -3819,6 +3869,7 @@ async fn execute_execution_runtime_stream_inner(
provider_pool_in_flight_guard.take(),
retry_scope_out.as_deref_mut(),
retry_fallback_out.as_deref_mut(),
None,
)
.await;
}
@@ -3891,6 +3942,7 @@ async fn execute_execution_runtime_stream_inner(
provider_pool_in_flight_guard.take(),
retry_scope_out.as_deref_mut(),
retry_fallback_out.as_deref_mut(),
None,
)
.await;
}
@@ -3963,6 +4015,7 @@ async fn execute_execution_runtime_stream_inner(
provider_pool_in_flight_guard.take(),
retry_scope_out.as_deref_mut(),
retry_fallback_out.as_deref_mut(),
None,
)
.await;
}
@@ -4035,6 +4088,7 @@ async fn execute_execution_runtime_stream_inner(
provider_pool_in_flight_guard.take(),
retry_scope_out.as_deref_mut(),
retry_fallback_out.as_deref_mut(),
None,
)
.await;
}
@@ -4192,6 +4246,15 @@ async fn execute_execution_runtime_stream_inner(
record_stream_pending_lifecycle(state, seed, &mut stage_trace).await;
lifecycle_pending_recorded = true;
}
let report_context = attach_provider_response_headers_to_report_context(
report_context,
&execution.headers,
execution.response_observation.request_started_at_unix_ms,
execution
.response_observation
.response_headers_observed_at_unix_ms,
&execution.response_observation.request_order_id,
);
let stream_precommit_committed = execution.stream_precommit_committed;
let frame_stream = build_direct_execution_frame_stream(execution).boxed();
return execute_stream_from_frame_stream_with_retry_scope(
@@ -4211,6 +4274,7 @@ async fn execute_execution_runtime_stream_inner(
provider_pool_in_flight_guard.take(),
retry_scope_out,
retry_fallback_out,
None,
)
.await;
}
@@ -4327,6 +4391,15 @@ async fn execute_execution_runtime_stream_inner(
record_stream_pending_lifecycle(state, seed, &mut stage_trace).await;
lifecycle_pending_recorded = true;
}
let report_context = attach_provider_response_headers_to_report_context(
report_context,
&execution.headers,
execution.response_observation.request_started_at_unix_ms,
execution
.response_observation
.response_headers_observed_at_unix_ms,
&execution.response_observation.request_order_id,
);
let stream_precommit_committed = execution.stream_precommit_committed;
let frame_stream = build_direct_execution_frame_stream(execution).boxed();
return execute_stream_from_frame_stream_with_retry_scope(
@@ -4346,10 +4419,13 @@ async fn execute_execution_runtime_stream_inner(
provider_pool_in_flight_guard.take(),
retry_scope_out.as_deref_mut(),
retry_fallback_out.as_deref_mut(),
None,
)
.await;
}
let remote_request_started_at_unix_ms = current_request_candidate_unix_ms();
let remote_request_order_id = uuid::Uuid::now_v7().to_string();
let response = match post_stream_plan_to_remote_execution_runtime(
state,
remote_execution_runtime_base_url,
@@ -4431,6 +4507,12 @@ async fn execute_execution_runtime_stream_inner(
)?));
}
let remote_response_observed_at_unix_ms = current_request_candidate_unix_ms();
let remote_fallback_observation = ExecutionResponseObservation {
request_started_at_unix_ms: remote_request_started_at_unix_ms,
response_headers_observed_at_unix_ms: remote_response_observed_at_unix_ms,
request_order_id: remote_request_order_id,
};
let frame_stream = response
.bytes_stream()
.map_err(|err| IoError::other(err.to_string()))
@@ -4452,6 +4534,7 @@ async fn execute_execution_runtime_stream_inner(
provider_pool_in_flight_guard.take(),
retry_scope_out.as_deref_mut(),
retry_fallback_out.as_deref_mut(),
Some(remote_fallback_observation),
)
.await;
}
@@ -4471,13 +4554,86 @@ fn decode_stream_data_chunk(
fn response_headers_indicate_sse(headers: &BTreeMap<String, String>) -> bool {
headers
.get("content-type")
.map(String::as_str)
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("content-type"))
.map(|(_, value)| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some_and(|value| value.to_ascii_lowercase().contains("text/event-stream"))
}
fn report_context_upstream_is_stream(report_context: Option<&Value>) -> bool {
report_context
.and_then(|value| value.get(UPSTREAM_IS_STREAM_KEY))
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn response_headers_have_octet_stream_content_type(headers: &BTreeMap<String, String>) -> bool {
headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("content-type"))
.map(|(_, value)| value.as_str())
.and_then(|value| value.split(';').next())
.map(str::trim)
.is_some_and(|value| value.eq_ignore_ascii_case("application/octet-stream"))
}
fn response_headers_have_only_identity_content_encoding(
headers: &BTreeMap<String, String>,
) -> bool {
headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("content-encoding"))
.map(|(_, value)| value.as_str())
.is_none_or(|value| {
value
.split(',')
.map(str::trim)
.all(|coding| coding.is_empty() || coding.eq_ignore_ascii_case("identity"))
})
}
fn plan_kind_uses_text_event_stream(plan_kind: &str) -> bool {
matches!(
plan_kind,
OPENAI_CHAT_STREAM_PLAN_KIND
| OPENAI_RESPONSES_STREAM_PLAN_KIND
| OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND
| OPENAI_IMAGE_STREAM_PLAN_KIND
| CLAUDE_CHAT_STREAM_PLAN_KIND
| CLAUDE_CLI_STREAM_PLAN_KIND
| GEMINI_CHAT_STREAM_PLAN_KIND
| GEMINI_CLI_STREAM_PLAN_KIND
| GEMINI_INTERACTIONS_STREAM_PLAN_KIND
)
}
fn should_normalize_declared_stream_response_headers(
plan_kind: &str,
status_code: u16,
headers: &BTreeMap<String, String>,
report_context: Option<&Value>,
) -> bool {
plan_kind_uses_text_event_stream(plan_kind)
&& (200..300).contains(&status_code)
&& report_context_upstream_is_stream(report_context)
&& response_headers_have_octet_stream_content_type(headers)
&& response_headers_have_only_identity_content_encoding(headers)
&& !headers
.keys()
.any(|name| name.eq_ignore_ascii_case("content-length"))
}
fn normalize_declared_stream_response_headers(headers: &mut BTreeMap<String, String>) {
headers.retain(|name, _| {
!name.eq_ignore_ascii_case("content-encoding")
&& !name.eq_ignore_ascii_case("content-length")
&& !name.eq_ignore_ascii_case("content-type")
});
headers.insert("content-type".to_string(), "text/event-stream".to_string());
}
fn parse_prefetched_sync_json_body(body: &[u8]) -> Option<Value> {
let stripped = strip_utf8_bom_and_ws(body);
serde_json::from_slice::<Value>(stripped).ok()
@@ -5481,6 +5637,7 @@ async fn execute_stream_from_frame_stream(
in_flight_guard,
None,
None,
None,
)
.await
}
@@ -5503,6 +5660,7 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
in_flight_guard: Option<ProviderPoolInFlightGuard>,
mut retry_scope_out: Option<&mut AiAttemptRetryScope>,
mut retry_fallback_out: Option<&mut Option<Response<Body>>>,
fallback_response_observation: Option<ExecutionResponseObservation>,
) -> Result<Option<Response<Body>>, GatewayError> {
let request_id = plan.request_id.as_str();
let request_id_for_log = short_request_id(request_id);
@@ -5535,14 +5693,37 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
let StreamFramePayload::Headers {
status_code,
mut headers,
response_observation,
} = first_frame.payload
else {
return Err(GatewayError::Internal(
"execution runtime stream must start with headers frame".to_string(),
));
};
let mut report_context =
attach_provider_response_headers_to_report_context(report_context, &headers);
let response_observation = response_observation
.or(fallback_response_observation)
.unwrap_or(ExecutionResponseObservation {
request_started_at_unix_ms: candidate_started_unix_secs,
response_headers_observed_at_unix_ms: current_request_candidate_unix_ms(),
request_order_id: uuid::Uuid::now_v7().to_string(),
});
let mut report_context = attach_provider_response_headers_to_report_context(
report_context,
&headers,
response_observation.request_started_at_unix_ms,
response_observation.response_headers_observed_at_unix_ms,
&response_observation.request_order_id,
);
spawn_local_oauth_success_effect(
state.clone(),
&plan,
report_context.as_ref(),
LocalOAuthSuccessEffect {
status_code,
request_started_at_unix_ms: Some(response_observation.request_started_at_unix_ms),
request_order_id: Some(&response_observation.request_order_id),
},
);
if status_code == 200 {
seed_kiro_simulated_cache_enabled(state, &plan, &mut report_context).await;
if kiro_simulated_cache_enabled_from_report_context(report_context.as_ref()) {
@@ -5964,6 +6145,32 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
headers.insert("content-type".to_string(), "text/event-stream".to_string());
}
let upstream_content_type = upstream_headers.get("content-type").map(String::as_str);
let normalized_declared_stream_headers = private_stream_normalizer.is_none()
&& local_stream_rewriter.is_none()
&& should_normalize_declared_stream_response_headers(
plan_kind,
status_code,
&upstream_headers,
report_context.as_ref(),
);
if normalized_declared_stream_headers {
normalize_declared_stream_response_headers(&mut headers);
debug!(
event_name = "execution_runtime_stream_content_type_corrected",
log_type = "debug",
trace_id = %trace_id,
request_id = %request_id_for_log,
candidate_id = ?candidate_id,
plan_kind,
provider_name,
endpoint_id = %plan.endpoint_id,
key_id = %plan.key_id,
model_name,
candidate_index = candidate_index.as_str(),
upstream_content_type = upstream_content_type.unwrap_or("-"),
"gateway normalized declared upstream stream response headers for the client"
);
}
let prefetch_for_cyber_failover =
is_openai_responses_family_format(plan.provider_api_format.as_str())
&& cyber_continue_failover_enabled(state).await;
@@ -6289,7 +6496,9 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
}
}
let inspection = if stream_commit_policy.is_native_anthropic() {
let inspection = if stream_commit_policy.is_native_anthropic()
|| stream_commit_policy.is_gemini()
{
StreamPrefetchInspection::NeedMore
} else {
inspect_prefetched_stream_body(
@@ -6651,8 +6860,9 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
let native_anthropic_stream_for_report = stream_commit_policy.is_native_anthropic();
let plan_for_report = plan;
let emit_passthrough_sse_terminal_error = (skip_direct_finalize_prefetch
|| stream_commit_policy.is_native_anthropic())
&& response_headers_indicate_sse(&upstream_headers)
|| stream_commit_policy.is_native_anthropic()
|| normalized_declared_stream_headers)
&& (response_headers_indicate_sse(&upstream_headers) || normalized_declared_stream_headers)
&& !is_openai_image_stream_for_report;
let plan_kind_for_report = plan_kind.to_string();
let stream_started_at_for_report = stream_started_at;
@@ -8014,20 +8224,23 @@ mod tests {
execute_execution_runtime_stream, execute_in_process_stream_with_oauth_retry,
execute_stream_from_frame_stream, execute_stream_from_frame_stream_with_retry_scope,
maybe_apply_kiro_prompt_cache_usage_to_stream_summary, merge_stream_terminal_summary,
parse_direct_passthrough_mode, prefetch_direct_stream_error_body,
prefetched_openai_responses_body_has_output_boundary,
normalize_declared_stream_response_headers, parse_direct_passthrough_mode,
prefetch_direct_stream_error_body, prefetched_openai_responses_body_has_output_boundary,
record_sync_terminal_usage_with_handoff,
record_sync_terminal_usage_with_handoff_after_spawn,
resolve_provider_stream_error_status_code, select_direct_anthropic_prefetch_wait,
should_limit_direct_finalize_prefetch, should_probe_success_failover_before_stream,
should_skip_direct_finalize_prefetch, stream_chunk_contains_sse_done,
stream_requires_observed_terminal_event, stream_terminal_summary_missing_observed_finish,
should_limit_direct_finalize_prefetch, should_normalize_declared_stream_response_headers,
should_probe_success_failover_before_stream, should_skip_direct_finalize_prefetch,
stream_chunk_contains_sse_done, stream_requires_observed_terminal_event,
stream_terminal_summary_missing_observed_finish,
stream_terminal_summary_missing_observed_finish_with_requirement,
stream_terminal_summary_represents_failure_with_requirement,
ClientVisibleStreamCompletionTracker, DirectPassthroughFinalizer,
DirectPassthroughFinalizerCore, DirectPassthroughInlineBodyState, DirectPassthroughMode,
PostStopFrameReadBudget, PostStopLimitedStreamReader, ProviderStreamErrorInspection,
ANTHROPIC_POST_STOP_DRAIN_MAX_BYTES, POST_STOP_MAX_EMPTY_CHUNKS_PER_POLL,
ANTHROPIC_POST_STOP_DRAIN_MAX_BYTES, GEMINI_FILES_DOWNLOAD_PLAN_KIND,
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_RESPONSES_STREAM_PLAN_KIND,
POST_STOP_MAX_EMPTY_CHUNKS_PER_POLL,
};
use crate::control::GatewayControlDecision;
use crate::stage_metrics::RequestStageTrace;
@@ -8310,6 +8523,7 @@ mod tests {
"content-type".to_string(),
"text/event-stream".to_string(),
)]),
response_observation: None,
},
}));
yield Ok::<Bytes, std::io::Error>(ndjson_frame(StreamFrame {
@@ -8389,6 +8603,7 @@ mod tests {
"content-type".to_string(),
"text/event-stream".to_string(),
)]),
response_observation: None,
},
}));
yield Ok::<Bytes, std::io::Error>(ndjson_frame(StreamFrame {
@@ -8431,6 +8646,7 @@ mod tests {
None,
Some(&mut retry_scope),
None,
None,
)
.await
.expect("prefetch transport execution should resolve");
@@ -8480,6 +8696,7 @@ mod tests {
"content-type".to_string(),
"text/event-stream".to_string(),
)]),
response_observation: None,
},
}));
yield Ok::<Bytes, std::io::Error>(ndjson_frame(StreamFrame {
@@ -8522,6 +8739,7 @@ mod tests {
None,
Some(&mut retry_scope),
None,
None,
)
.await
.expect("prefetch HTTP status execution should resolve");
@@ -8567,6 +8785,36 @@ mod tests {
}
}
fn antigravity_gemini_stream_plan(request_id: &str) -> ExecutionPlan {
ExecutionPlan {
request_id: request_id.to_string(),
candidate_id: Some(format!("candidate-{request_id}")),
provider_name: Some("antigravity".to_string()),
provider_id: format!("provider-{request_id}"),
endpoint_id: format!("endpoint-{request_id}"),
key_id: format!("key-{request_id}"),
method: "POST".to_string(),
url: "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent".to_string(),
headers: BTreeMap::from([
("content-type".to_string(), "application/json".to_string()),
("accept".to_string(), "text/event-stream".to_string()),
]),
content_type: Some("application/json".to_string()),
content_encoding: None,
body: RequestBody::from_json(json!({
"model": "gemini-3.7-flash-tiered",
"contents": [{"role": "user", "parts": [{"text": "validate"}]}]
})),
stream: true,
client_api_format: "openai:responses".to_string(),
provider_api_format: "gemini:generate_content".to_string(),
model_name: Some("gemini-3.7-flash-tiered".to_string()),
proxy: None,
transport_profile: None,
timeouts: None,
}
}
struct StreamDropFlag(Arc<AtomicBool>);
impl Drop for StreamDropFlag {
@@ -8680,6 +8928,7 @@ mod tests {
"content-type".to_string(),
"text/event-stream".to_string(),
)]),
response_observation: None,
},
}));
for chunk in chunks {
@@ -8725,6 +8974,7 @@ mod tests {
None,
Some(&mut retry_scope),
Some(&mut fallback_response),
None,
)
.await
.expect("native Anthropic stream execution should succeed");
@@ -9364,6 +9614,7 @@ mod tests {
"content-type".to_string(),
"text/event-stream".to_string(),
)]),
response_observation: None,
},
}));
yield Ok::<Bytes, std::io::Error>(ndjson_frame(StreamFrame {
@@ -9413,6 +9664,7 @@ mod tests {
None,
None,
None,
None,
),
)
.await
@@ -9850,6 +10102,7 @@ mod tests {
"content-type".to_string(),
"text/event-stream".to_string(),
)]),
response_observation: None,
},
}));
}
@@ -10310,6 +10563,92 @@ mod tests {
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn malformed_antigravity_function_call_retries_before_stream_commit() {
let request_id = "req-antigravity-malformed-function-call";
let plan = antigravity_gemini_stream_plan(request_id);
let provider_catalog = provider_catalog_for_plan(
&plan,
Some(json!({
"failover_rules": {
"continue_status_codes": [502]
}
})),
);
let data_state = crate::data::GatewayDataState::with_provider_transport_reader_for_tests(
Arc::new(provider_catalog),
"development-key",
);
let state = AppState::new()
.expect("app state should build")
.with_data_state_for_tests(data_state);
let frame_stream = stream! {
yield Ok::<Bytes, std::io::Error>(ndjson_frame(StreamFrame {
frame_type: StreamFrameType::Headers,
payload: StreamFramePayload::Headers {
status_code: 200,
headers: BTreeMap::from([(
"content-type".to_string(),
"text/event-stream".to_string(),
)]),
response_observation: None,
},
}));
for chunk in [
r#"data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thought":true,"text":"Validating the document."}]} }],"modelVersion":"gemini-3.7-flash-tiered"}}
"#,
r#"data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"signature","text":""}]},"finishReason":"MALFORMED_FUNCTION_CALL","finishMessage":"Malformed function call: Function call is empty - no input to parse."}],"modelVersion":"gemini-3.7-flash-tiered"}}
"#,
] {
yield Ok::<Bytes, std::io::Error>(ndjson_frame(StreamFrame {
frame_type: StreamFrameType::Data,
payload: StreamFramePayload::Data {
chunk_b64: None,
text: Some(chunk.to_string()),
},
}));
}
yield Ok::<Bytes, std::io::Error>(ndjson_frame(StreamFrame::eof()));
}
.boxed();
let mut retry_scope = AiAttemptRetryScope::Provider;
let response = execute_stream_from_frame_stream_with_retry_scope(
&state,
plan,
"trace-antigravity-malformed-function-call",
&test_decision(),
OPENAI_RESPONSES_STREAM_PLAN_KIND,
Some("openai_responses_stream_success".to_string()),
Some(json!({
"request_id": request_id,
"candidate_id": format!("candidate-{request_id}"),
"candidate_index": 0,
"retry_index": 0,
"provider_api_format": "gemini:generate_content",
"client_api_format": "openai:responses",
"needs_conversion": true
})),
crate::clock::current_unix_ms(),
Instant::now(),
RequestStageTrace::from_env(),
true,
frame_stream,
false,
None,
Some(&mut retry_scope),
None,
None,
)
.await
.expect("malformed Antigravity stream should resolve through failover");
assert!(response.is_none());
assert_eq!(retry_scope, AiAttemptRetryScope::Candidate);
}
fn tunnel_proxy_snapshot(base_url: String) -> aether_contracts::ProxySnapshot {
aether_contracts::ProxySnapshot {
enabled: Some(true),
@@ -11532,6 +11871,7 @@ mod tests {
"content-type".to_string(),
"text/event-stream".to_string(),
)]),
response_observation: None,
},
}));
yield Ok::<Bytes, std::io::Error>(ndjson_frame(StreamFrame {
@@ -11660,6 +12000,7 @@ mod tests {
"content-type".to_string(),
"text/event-stream".to_string(),
)]),
response_observation: None,
},
}));
yield Ok::<Bytes, std::io::Error>(ndjson_frame(StreamFrame {
@@ -11969,6 +12310,109 @@ mod tests {
));
}
#[test]
fn declared_stream_response_headers_are_normalized_without_body_inspection() {
let mut headers = BTreeMap::from([
(
"Content-Type".to_string(),
"Application/Octet-Stream; charset=binary".to_string(),
),
("Content-Encoding".to_string(), "identity".to_string()),
("x-upstream-header".to_string(), "preserved".to_string()),
]);
assert!(should_normalize_declared_stream_response_headers(
OPENAI_CHAT_STREAM_PLAN_KIND,
200,
&headers,
Some(&json!({"upstream_is_stream": true})),
));
headers.insert("Content-Length".to_string(), "4096".to_string());
normalize_declared_stream_response_headers(&mut headers);
assert_eq!(
headers.get("content-type").map(String::as_str),
Some("text/event-stream")
);
assert!(!headers
.keys()
.any(|name| name.eq_ignore_ascii_case("content-encoding")));
assert!(!headers
.keys()
.any(|name| name.eq_ignore_ascii_case("content-length")));
assert_eq!(
headers.get("x-upstream-header").map(String::as_str),
Some("preserved")
);
}
#[test]
fn declared_stream_header_normalization_requires_success_and_stream_context() {
let headers = BTreeMap::from([(
"content-type".to_string(),
"application/octet-stream".to_string(),
)]);
assert!(!should_normalize_declared_stream_response_headers(
OPENAI_CHAT_STREAM_PLAN_KIND,
500,
&headers,
Some(&json!({"upstream_is_stream": true})),
));
assert!(!should_normalize_declared_stream_response_headers(
OPENAI_CHAT_STREAM_PLAN_KIND,
200,
&headers,
Some(&json!({"upstream_is_stream": false})),
));
assert!(!should_normalize_declared_stream_response_headers(
OPENAI_CHAT_STREAM_PLAN_KIND,
200,
&BTreeMap::from([("content-type".to_string(), "text/event-stream".to_string(),)]),
Some(&json!({"upstream_is_stream": true})),
));
assert!(!should_normalize_declared_stream_response_headers(
OPENAI_CHAT_STREAM_PLAN_KIND,
200,
&BTreeMap::from([("content-type".to_string(), "application/json".to_string(),)]),
Some(&json!({"upstream_is_stream": true})),
));
assert!(!should_normalize_declared_stream_response_headers(
OPENAI_CHAT_STREAM_PLAN_KIND,
200,
&BTreeMap::from([("content-type".to_string(), "text/plain".to_string(),)]),
Some(&json!({"upstream_is_stream": true})),
));
assert!(!should_normalize_declared_stream_response_headers(
OPENAI_CHAT_STREAM_PLAN_KIND,
200,
&BTreeMap::from([
(
"content-type".to_string(),
"application/octet-stream".to_string(),
),
("content-encoding".to_string(), "gzip".to_string()),
]),
Some(&json!({"upstream_is_stream": true})),
));
assert!(!should_normalize_declared_stream_response_headers(
OPENAI_CHAT_STREAM_PLAN_KIND,
200,
&BTreeMap::from([
(
"content-type".to_string(),
"application/octet-stream".to_string(),
),
("content-length".to_string(), "128".to_string()),
]),
Some(&json!({"upstream_is_stream": true})),
));
assert!(!should_normalize_declared_stream_response_headers(
GEMINI_FILES_DOWNLOAD_PLAN_KIND,
200,
&headers,
Some(&json!({"upstream_is_stream": true})),
));
}
#[test]
fn skips_prefetch_for_event_streams_even_when_cross_format_or_rewritten() {
assert!(should_skip_direct_finalize_prefetch(
@@ -12384,6 +12828,7 @@ mod tests {
"content-type".to_string(),
"text/event-stream".to_string(),
)]),
response_observation: None,
},
}));
yield Ok::<Bytes, std::io::Error>(ndjson_frame(StreamFrame {
@@ -13700,6 +14145,7 @@ mod tests {
Some(json!({
"provider_api_format": "openai:responses",
"client_api_format": "openai:responses",
"upstream_is_stream": true,
})),
)
.await
@@ -4,8 +4,9 @@ use std::io::Error as IoError;
use std::time::{Duration, Instant};
use aether_contracts::{
ExecutionError, ExecutionErrorKind, ExecutionPhase, ExecutionStreamTerminalSummary,
ExecutionTelemetry, StreamFrame, StreamFramePayload, StreamFrameType,
ExecutionError, ExecutionErrorKind, ExecutionPhase, ExecutionResponseObservation,
ExecutionStreamTerminalSummary, ExecutionTelemetry, StreamFrame, StreamFramePayload,
StreamFrameType,
};
use async_stream::stream;
use axum::body::Bytes;
@@ -44,6 +45,7 @@ pub(crate) fn build_direct_execution_frame_stream(
stream_precommit_committed: _,
response,
started_at,
response_observation,
stream_first_byte_timeout,
upstream_target_permit,
} = execution;
@@ -108,7 +110,11 @@ pub(crate) fn build_direct_execution_frame_stream(
}
}
match encode_headers_frame(status_code, response_headers) {
match encode_headers_frame(
status_code,
response_headers,
&response_observation,
) {
Ok(frame) => yield Ok(frame),
Err(err) => {
yield Err(err);
@@ -153,7 +159,11 @@ pub(crate) fn build_direct_execution_frame_stream(
upstream_bytes,
first_byte_timeout,
}) => {
match encode_headers_frame(status_code, original_headers) {
match encode_headers_frame(
status_code,
original_headers,
&response_observation,
) {
Ok(frame) => yield Ok(frame),
Err(err) => {
yield Err(err);
@@ -192,7 +202,11 @@ pub(crate) fn build_direct_execution_frame_stream(
return;
}
match encode_headers_frame(status_code, headers) {
match encode_headers_frame(
status_code,
headers,
&response_observation,
) {
Ok(frame) => yield Ok(frame),
Err(err) => {
yield Err(err);
@@ -611,12 +625,14 @@ pub(crate) fn build_direct_execution_frame_stream(
fn encode_headers_frame(
status_code: u16,
headers: BTreeMap<String, String>,
response_observation: &ExecutionResponseObservation,
) -> Result<Bytes, IoError> {
encode_stream_frame_ndjson(&StreamFrame {
frame_type: StreamFrameType::Headers,
payload: StreamFramePayload::Headers {
status_code,
headers,
response_observation: Some(response_observation.clone()),
},
})
}
@@ -1606,43 +1622,47 @@ mod tests {
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let server = tokio::spawn(async move {
let app = Router::new().route(
"/responses",
post(|| async {
let body = serde_json::json!({
"id": "resp_sync_bridge_123",
"object": "response",
"model": "gpt-5.4",
"status": "completed",
"output": [{
"type": "message",
"id": "msg_sync_bridge_123",
"role": "assistant",
"content": [{
"type": "output_text",
"text": "Hello from buffered JSON stream",
"annotations": []
}]
}],
"usage": {
"input_tokens": 1,
"output_tokens": 2,
"total_tokens": 3
}
});
let mut response = axum::http::Response::new(Body::from(
serde_json::to_vec(&body).expect("json should encode"),
));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
response
}),
);
axum::serve(listener, app)
let (mut socket, _) = listener.accept().await.expect("client should connect");
let mut request = [0_u8; 4096];
let _ = socket
.read(&mut request)
.await
.expect("server should start");
.expect("request should read");
let body = serde_json::to_vec(&serde_json::json!({
"id": "resp_sync_bridge_123",
"object": "response",
"model": "gpt-5.4",
"status": "completed",
"output": [{
"type": "message",
"id": "msg_sync_bridge_123",
"role": "assistant",
"content": [{
"type": "output_text",
"text": "Hello from buffered JSON stream",
"annotations": []
}]
}],
"usage": {
"input_tokens": 1,
"output_tokens": 2,
"total_tokens": 3
}
}))
.expect("json should encode");
socket
.write_all(
format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n",
body.len()
)
.as_bytes(),
)
.await
.expect("headers should write");
socket.flush().await.expect("headers should flush");
tokio::time::sleep(Duration::from_millis(75)).await;
socket.write_all(&body).await.expect("body should write");
});
let runtime = DirectSyncExecutionRuntime::new();
@@ -1678,6 +1698,12 @@ mod tests {
})
.await
.expect("stream execution should succeed");
let expected_observation = execution.response_observation.clone();
assert!(
expected_observation.response_headers_observed_at_unix_ms
>= expected_observation.request_started_at_unix_ms
);
assert!(!expected_observation.request_order_id.is_empty());
let frames = build_direct_execution_frame_stream(execution)
.map(|item| item.expect("frame should encode"))
@@ -1691,6 +1717,10 @@ mod tests {
let header_frame: Value =
serde_json::from_str(&frames[0]).expect("headers frame should parse");
let encoded_observation: aether_contracts::ExecutionResponseObservation =
serde_json::from_value(header_frame["payload"]["response_observation"].clone())
.expect("headers frame should retain the response observation");
assert_eq!(encoded_observation, expected_observation);
assert_eq!(
header_frame
.get("payload")
@@ -5,8 +5,8 @@ use std::time::{Duration, Instant};
use aether_ai_serving::{AiAttemptExecutionOutcome, AiAttemptRetryScope, UPSTREAM_IS_STREAM_KEY};
use aether_contracts::{
ExecutionError, ExecutionErrorKind, ExecutionPhase, ExecutionPlan, ExecutionResult,
ExecutionTelemetry,
ExecutionError, ExecutionErrorKind, ExecutionPhase, ExecutionPlan,
ExecutionResponseObservation, ExecutionResult, ExecutionTelemetry,
};
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
use aether_scheduler_core::{
@@ -34,6 +34,7 @@ use crate::ai_serving::api::{
implicit_sync_finalize_report_kind, maybe_build_sync_finalize_outcome, LocalCoreSyncErrorKind,
LocalCoreSyncFinalizeOutcome,
};
use crate::ai_serving::record_local_runtime_candidate_skip_reason;
use crate::api::response::{
attach_control_metadata_headers, build_client_response, build_client_response_from_parts,
build_client_response_from_parts_with_mutator,
@@ -55,8 +56,9 @@ use crate::execution_runtime::submission::{
resolve_local_sync_error_status_code, submit_local_core_error_or_sync_finalize,
};
use crate::execution_runtime::transport::{
append_upstream_response_body_chunk, build_execution_response_body, build_request_body,
collect_response_headers, decode_response_body_bytes, execution_response_body_mode,
append_upstream_response_body_chunk_with_limit, build_execution_response_body,
build_request_body, collect_response_headers, decode_response_body_bytes_with_limit,
execution_plan_response_body_limit_bytes, execution_response_body_mode,
format_hyper_error_chain, format_upstream_request_error, format_wreq_upstream_request_error,
response_body_is_json, send_request, DirectHttpResponse, DirectSyncExecutionRuntime,
ExecutionRuntimeTransportError,
@@ -70,13 +72,16 @@ use crate::execution_runtime::{
};
use crate::log_ids::short_request_id;
use crate::orchestration::{
apply_local_execution_effect, build_local_error_flow_metadata, trace_upstream_response_body,
with_error_flow_report_context, with_upstream_response_report_context,
LocalAdaptiveRateLimitEffect, LocalAdaptiveSuccessEffect, LocalAttemptFailureEffect,
LocalExecutionEffect, LocalExecutionEffectContext, LocalHealthFailureEffect,
LocalHealthSuccessEffect, LocalOAuthInvalidationEffect, LocalPoolErrorEffect,
apply_local_execution_effect, build_local_error_flow_metadata,
spawn_local_oauth_success_effect, trace_upstream_response_body, with_error_flow_report_context,
with_upstream_response_report_context, LocalAdaptiveRateLimitEffect,
LocalAdaptiveSuccessEffect, LocalAttemptFailureEffect, LocalExecutionEffect,
LocalExecutionEffectContext, LocalHealthFailureEffect, LocalHealthSuccessEffect,
LocalOAuthInvalidationEffect, LocalOAuthSuccessEffect, LocalPoolErrorEffect,
};
use crate::provider_pool_demand::{
acquire_provider_pool_execution_guard, ProviderPoolInFlightAdmission,
};
use crate::provider_pool_demand::acquire_provider_pool_in_flight_guard;
use crate::request_candidate_runtime::{
ensure_execution_request_candidate_slot, record_local_request_candidate_extra_data,
record_local_request_candidate_status, record_local_request_candidate_status_snapshot,
@@ -1379,7 +1384,19 @@ async fn execute_direct_sync_runtime_candidate(
candidate_started_unix_ms,
event.status_code,
event.ttfb_ms,
)
);
spawn_local_oauth_success_effect(
state_for_response_started.clone(),
plan,
report_context,
LocalOAuthSuccessEffect {
status_code: event.status_code,
request_started_at_unix_ms: Some(
event.response_observation.request_started_at_unix_ms,
),
request_order_id: Some(&event.response_observation.request_order_id),
},
);
})
.await
.map_err(SyncExecutionFailure::from_transport);
@@ -1478,17 +1495,31 @@ async fn execute_openai_image_sync_upstream_sse_candidate(
progress_snapshot: Option<Arc<Mutex<OpenAiImageSyncProgressSnapshot>>>,
) -> Result<ExecutionResult, SyncExecutionFailure> {
let request_body = build_request_body(plan).map_err(SyncExecutionFailure::from_transport)?;
let response_body_limit_bytes = execution_plan_response_body_limit_bytes(plan);
let started_at = Instant::now();
let mut progress =
OpenAiImageSyncProgressRecorder::new(state, plan, report_context, progress_snapshot);
progress.record_connecting().await;
let request_started_at_unix_ms = current_request_candidate_unix_ms();
let request_order_id = uuid::Uuid::now_v7().to_string();
let response = send_request(plan, request_body)
.await
.map_err(SyncExecutionFailure::from_transport)?;
let ttfb_ms = started_at.elapsed().as_millis() as u64;
let response_headers_observed_at_unix_ms = current_request_candidate_unix_ms();
let status_code = response.status_code();
let headers = response.headers();
spawn_local_oauth_success_effect(
state.clone(),
plan,
report_context,
LocalOAuthSuccessEffect {
status_code,
request_started_at_unix_ms: Some(request_started_at_unix_ms),
request_order_id: Some(&request_order_id),
},
);
progress.record_response_started(status_code, ttfb_ms).await;
let mut body_bytes = Vec::new();
@@ -1503,8 +1534,12 @@ async fn execute_openai_image_sync_upstream_sse_candidate(
),
)
})?;
append_upstream_response_body_chunk(&mut body_bytes, &chunk)
.map_err(SyncExecutionFailure::from_transport)?;
append_upstream_response_body_chunk_with_limit(
&mut body_bytes,
&chunk,
response_body_limit_bytes,
)
.map_err(SyncExecutionFailure::from_transport)?;
let elapsed_ms = started_at.elapsed().as_millis() as u64;
progress
.observe_chunk(&chunk, status_code, elapsed_ms)
@@ -1521,8 +1556,12 @@ async fn execute_openai_image_sync_upstream_sse_candidate(
)),
)
})?;
append_upstream_response_body_chunk(&mut body_bytes, &chunk)
.map_err(SyncExecutionFailure::from_transport)?;
append_upstream_response_body_chunk_with_limit(
&mut body_bytes,
&chunk,
response_body_limit_bytes,
)
.map_err(SyncExecutionFailure::from_transport)?;
let elapsed_ms = started_at.elapsed().as_millis() as u64;
progress
.observe_chunk(&chunk, status_code, elapsed_ms)
@@ -1539,8 +1578,12 @@ async fn execute_openai_image_sync_upstream_sse_candidate(
),
)
})?;
append_upstream_response_body_chunk(&mut body_bytes, &chunk)
.map_err(SyncExecutionFailure::from_transport)?;
append_upstream_response_body_chunk_with_limit(
&mut body_bytes,
&chunk,
response_body_limit_bytes,
)
.map_err(SyncExecutionFailure::from_transport)?;
let elapsed_ms = started_at.elapsed().as_millis() as u64;
progress
.observe_chunk(&chunk, status_code, elapsed_ms)
@@ -1549,8 +1592,9 @@ async fn execute_openai_image_sync_upstream_sse_candidate(
}
}
let decoded_body_bytes = decode_response_body_bytes(&headers, &body_bytes)
.map_err(SyncExecutionFailure::from_transport)?;
let decoded_body_bytes =
decode_response_body_bytes_with_limit(&headers, &body_bytes, response_body_limit_bytes)
.map_err(SyncExecutionFailure::from_transport)?;
let elapsed_ms = started_at.elapsed().as_millis() as u64;
let upstream_bytes = body_bytes.len() as u64;
progress.finish(status_code, elapsed_ms).await;
@@ -1569,6 +1613,11 @@ async fn execute_openai_image_sync_upstream_sse_candidate(
candidate_id: plan.candidate_id.clone(),
status_code,
headers,
response_observation: Some(ExecutionResponseObservation {
request_started_at_unix_ms,
response_headers_observed_at_unix_ms,
request_order_id,
}),
body,
telemetry: Some(ExecutionTelemetry {
ttfb_ms: Some(ttfb_ms),
@@ -1949,6 +1998,37 @@ async fn execute_execution_runtime_sync_impl(
.unwrap_or_else(|| "-".to_string());
let candidate_started_at = Instant::now();
let candidate_started_unix_secs = current_request_candidate_unix_ms();
let _provider_pool_in_flight_guard = match acquire_provider_pool_execution_guard(state, &plan)
.await?
{
ProviderPoolInFlightAdmission::Acquired(guard) => guard,
ProviderPoolInFlightAdmission::Saturated { limit } => {
record_local_runtime_candidate_skip_reason(
state,
trace_id,
"provider_key_concurrency_limit_reached",
);
if let Some(retry_scope) = retry_scope_out.as_deref_mut() {
*retry_scope = AiAttemptRetryScope::Candidate;
}
record_local_request_candidate_status(
state,
&plan,
report_context.as_ref(),
SchedulerRequestCandidateStatusUpdate {
status: RequestCandidateStatus::Skipped,
status_code: Some(StatusCode::TOO_MANY_REQUESTS.as_u16()),
error_type: Some("provider_key_concurrency_limit_reached".to_string()),
error_message: Some(format!("provider key concurrency limit reached: {limit}")),
latency_ms: Some(0),
started_at_unix_ms: Some(candidate_started_unix_secs),
finished_at_unix_ms: Some(candidate_started_unix_secs),
},
)
.await;
return Ok(None);
}
};
let lifecycle_seed = build_lifecycle_usage_seed(&plan, report_context.as_ref());
let usage_data = state.usage_lifecycle_data_state().as_ref().clone();
state
@@ -1978,14 +2058,6 @@ async fn execute_execution_runtime_sync_impl(
candidate_started_at,
);
let result = (async {
let _provider_pool_in_flight_guard = acquire_provider_pool_in_flight_guard(
state.runtime_state.clone(),
&plan.provider_id,
plan_request_id.as_str(),
plan_candidate_id.as_deref(),
key_id.as_str(),
)
.await;
record_sync_execution_active(
state,
&plan,
@@ -2461,6 +2533,16 @@ async fn execute_execution_runtime_sync_impl(
};
let mut candidate_first_byte_elapsed_ms =
calibrated_sync_candidate_first_byte_elapsed_ms(candidate_started_at, &result);
let initial_response_observed_at_unix_ms = current_request_candidate_unix_ms();
let mut provider_response_observation =
result
.response_observation
.clone()
.unwrap_or(ExecutionResponseObservation {
request_started_at_unix_ms: candidate_started_unix_secs,
response_headers_observed_at_unix_ms: initial_response_observed_at_unix_ms,
request_order_id: uuid::Uuid::now_v7().to_string(),
});
let mut oauth_retry_attempted = false;
let (
result_error_type,
@@ -2473,6 +2555,18 @@ async fn execute_execution_runtime_sync_impl(
local_failover_response_text,
local_failover_analysis,
) = loop {
spawn_local_oauth_success_effect(
state.clone(),
&plan,
report_context.as_ref(),
LocalOAuthSuccessEffect {
status_code: result.status_code,
request_started_at_unix_ms: Some(
provider_response_observation.request_started_at_unix_ms,
),
request_order_id: Some(&provider_response_observation.request_order_id),
},
);
let result_latency_ms = result
.telemetry
.as_ref()
@@ -2534,10 +2628,15 @@ async fn execute_execution_runtime_sync_impl(
result.status_code,
local_failover_response_text.as_deref(),
trace_id,
report_context.as_ref(),
Some(provider_response_observation.request_started_at_unix_ms),
Some(&provider_response_observation.request_order_id),
)
.await
{
oauth_retry_attempted = true;
let retry_started_at_unix_ms = current_request_candidate_unix_ms();
let retry_request_order_id = uuid::Uuid::now_v7().to_string();
match crate::execution_runtime::execute_execution_runtime_sync_plan(
state,
Some(trace_id),
@@ -2546,6 +2645,16 @@ async fn execute_execution_runtime_sync_impl(
.await
{
Ok(retry_result) => {
let retry_response_observed_at_unix_ms = current_request_candidate_unix_ms();
provider_response_observation = retry_result
.response_observation
.clone()
.unwrap_or(ExecutionResponseObservation {
request_started_at_unix_ms: retry_started_at_unix_ms,
response_headers_observed_at_unix_ms:
retry_response_observed_at_unix_ms,
request_order_id: retry_request_order_id,
});
candidate_first_byte_elapsed_ms =
calibrated_sync_candidate_first_byte_elapsed_ms(
candidate_started_at,
@@ -2594,6 +2703,13 @@ async fn execute_execution_runtime_sync_impl(
local_failover_analysis,
);
};
let mut report_context = attach_provider_response_headers_to_report_context(
report_context,
&headers,
provider_response_observation.request_started_at_unix_ms,
provider_response_observation.response_headers_observed_at_unix_ms,
&provider_response_observation.request_order_id,
);
if result.status_code >= 400 {
apply_local_execution_effect(
state,
@@ -2739,8 +2855,6 @@ async fn execute_execution_runtime_sync_impl(
}
let status_code = result.status_code;
let has_body_bytes = body_base64.is_some();
let mut report_context =
attach_provider_response_headers_to_report_context(report_context, &headers);
if (200..300).contains(&status_code) {
seed_kiro_sync_simulated_cache_enabled(state, &plan, &mut report_context).await;
if kiro_simulated_cache_enabled_from_report_context(report_context.as_ref()) {
@@ -3191,7 +3305,14 @@ fn maybe_build_implicit_sync_finalize_outcome(
body_base64: &Option<String>,
telemetry: &Option<ExecutionTelemetry>,
) -> Result<Option<ImplicitSyncFinalizeOutcome>, GatewayError> {
if status_code >= 400 || body_json.is_some() || body_base64.is_none() {
let needs_conversion = report_context
.as_ref()
.and_then(|value| value.get("needs_conversion"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let has_captured_stream_body = body_json.is_none() && body_base64.is_some();
let has_cross_format_sync_body = needs_conversion && body_json.is_some();
if status_code >= 400 || (!has_captured_stream_body && !has_cross_format_sync_body) {
return Ok(None);
}
@@ -3231,6 +3352,8 @@ async fn execute_sync_via_remote_execution_runtime(
candidate_started_unix_secs: u64,
candidate_started_at: Instant,
) -> Result<RemoteSyncFallbackOutcome, GatewayError> {
let remote_request_started_at_unix_ms = current_request_candidate_unix_ms();
let remote_request_order_id = uuid::Uuid::now_v7().to_string();
let response = match post_sync_plan_to_remote_execution_runtime(
state,
remote_execution_runtime_base_url,
@@ -3299,11 +3422,19 @@ async fn execute_sync_via_remote_execution_runtime(
));
}
response
.json()
let remote_response_observed_at_unix_ms = current_request_candidate_unix_ms();
let mut result = response
.json::<ExecutionResult>()
.await
.map(RemoteSyncFallbackOutcome::Executed)
.map_err(|err| GatewayError::Internal(err.to_string()))
.map_err(|err| GatewayError::Internal(err.to_string()))?;
result
.response_observation
.get_or_insert(ExecutionResponseObservation {
request_started_at_unix_ms: remote_request_started_at_unix_ms,
response_headers_observed_at_unix_ms: remote_response_observed_at_unix_ms,
request_order_id: remote_request_order_id,
});
Ok(RemoteSyncFallbackOutcome::Executed(result))
}
#[cfg(test)]
@@ -3363,6 +3494,137 @@ mod tests {
.with_execution_runtime_candidate(true)
}
#[tokio::test]
async fn implicit_sync_finalize_converts_chat_json_to_namespaced_responses() {
let decision = GatewayControlDecision::synthetic(
"/v1/responses",
Some("ai_public".to_string()),
Some("openai".to_string()),
Some("responses".to_string()),
Some("openai:responses".to_string()),
)
.with_execution_runtime_candidate(true);
let report_context = Some(json!({
"provider_api_format": "openai:chat",
"client_api_format": "openai:responses",
"needs_conversion": true,
"mapped_model": "qwen-upstream",
"original_request_body": {
"model": "qwen",
"tools": [{
"type": "namespace",
"name": "mcp__vulnerability_report",
"description": "reporting tools",
"tools": [{
"type": "function",
"name": "vulnerability_report",
"description": "write the confirmed report",
"parameters": {
"type": "object",
"properties": {
"report_path": {"type": "string"}
},
"required": ["report_path"]
},
"strict": true
}]
}]
}
}));
let provider_body = Some(json!({
"id": "chatcmpl_namespace_sync",
"object": "chat.completion",
"created": 1_777_777_777,
"model": "qwen-upstream",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_report_1",
"type": "function",
"function": {
"name": "vulnerability_report",
"arguments": "{\"report_path\":\"reports/sql-001-c1.md\"}"
}
}]
},
"finish_reason": "tool_calls"
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 4,
"total_tokens": 14
}
}));
let implicit = maybe_build_implicit_sync_finalize_outcome(
"trace-namespace-sync",
&decision,
"openai_responses_sync",
&report_context,
StatusCode::OK.as_u16(),
&BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
&provider_body,
&None,
&None,
)
.expect("cross-format sync JSON finalize should not error")
.expect("cross-format sync JSON should be finalized");
let response_body = axum::body::to_bytes(implicit.outcome.response.into_body(), usize::MAX)
.await
.expect("response body should read");
let response_json: Value =
serde_json::from_slice(&response_body).expect("response body should be JSON");
assert_eq!(response_json["object"], "response");
assert!(response_json.get("choices").is_none());
assert_eq!(response_json["output"][0]["type"], "function_call");
assert_eq!(response_json["output"][0]["name"], "vulnerability_report");
assert_eq!(
response_json["output"][0]["namespace"],
"mcp__vulnerability_report"
);
assert_eq!(response_json["output"][0]["call_id"], "call_report_1");
}
#[test]
fn implicit_sync_finalize_leaves_same_format_json_on_passthrough_path() {
let report_context = Some(json!({
"provider_api_format": "openai:responses",
"client_api_format": "openai:responses",
"needs_conversion": false
}));
let body_json = Some(json!({
"id": "resp_same_format",
"object": "response",
"status": "completed",
"output": []
}));
let outcome = maybe_build_implicit_sync_finalize_outcome(
"trace-same-format-sync",
&GatewayControlDecision::synthetic(
"/v1/responses",
Some("ai_public".to_string()),
Some("openai".to_string()),
Some("responses".to_string()),
Some("openai:responses".to_string()),
),
"openai_responses_sync",
&report_context,
StatusCode::OK.as_u16(),
&BTreeMap::new(),
&body_json,
&None,
&None,
)
.expect("same-format sync JSON guard should not error");
assert!(outcome.is_none());
}
#[tokio::test]
async fn oversized_upstream_response_builds_claude_502_retry_fallback() {
let mut plan = test_openai_image_plan(false);
@@ -9,18 +9,19 @@ use std::sync::{Arc, LazyLock, Mutex as StdMutex, OnceLock, RwLock as StdRwLock}
use std::time::{Duration, Instant};
use aether_contracts::{
ExecutionPlan, ExecutionResponseBodyMode, ExecutionResult, ExecutionTelemetry, ProxySnapshot,
ResolvedTransportProfile, ResponseBody, EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER,
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER, EXECUTION_REQUEST_HTTP1_ONLY_HEADER,
EXECUTION_RESPONSE_BODY_MODE_HEADER, TRANSPORT_BACKEND_BROWSER_WREQ,
TRANSPORT_BACKEND_REQWEST_RUSTLS, TRANSPORT_HTTP_MODE_H2C_PRIOR_KNOWLEDGE,
TRANSPORT_HTTP_MODE_HTTP1_ONLY,
ExecutionPlan, ExecutionResponseBodyMode, ExecutionResponseObservation, ExecutionResult,
ExecutionTelemetry, ProxySnapshot, ResolvedTransportProfile, ResponseBody,
EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER, EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER,
EXECUTION_REQUEST_HTTP1_ONLY_HEADER, EXECUTION_RESPONSE_BODY_MODE_HEADER,
TRANSPORT_BACKEND_BROWSER_WREQ, TRANSPORT_BACKEND_REQWEST_RUSTLS,
TRANSPORT_HTTP_MODE_H2C_PRIOR_KNOWLEDGE, TRANSPORT_HTTP_MODE_HTTP1_ONLY,
};
use aether_data::repository::proxy_nodes::ProxyNodeTrafficMutation;
use aether_http::{apply_http_client_config, HttpClientConfig};
use aether_runtime::{MetricKind, MetricSample};
use axum::body::Bytes;
use base64::Engine as _;
use brotli::Decompressor as BrotliDecoder;
use flate2::read::{DeflateDecoder, GzDecoder};
use flate2::write::GzEncoder;
use flate2::Compression;
@@ -62,6 +63,10 @@ const DEFAULT_STREAM_FIRST_BYTE_TIMEOUT_MS: u64 = 30_000;
const DEFAULT_NON_STREAM_TOTAL_TIMEOUT_MS: u64 = 300_000;
const DEFAULT_CODEX_COMPACT_TOTAL_TIMEOUT_MS: u64 = 1_200_000;
const MIN_TUNNEL_TIMEOUT_SECS: u64 = 1;
const EXECUTION_RESPONSE_BODY_LIMIT_HEADER: &str = "x-aether-execution-response-body-limit-bytes";
const DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES: usize = 8 * 1024 * 1024;
const MIN_SCOPED_RESPONSE_BODY_LIMIT_BYTES: usize = 64 * 1024;
const MAX_SCOPED_RESPONSE_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024;
const DIRECT_REQWEST_H2_CLIENT_SHARDS_ENV: &str = "AETHER_GATEWAY_DIRECT_REQWEST_H2_CLIENT_SHARDS";
const DIRECT_REQWEST_CLIENT_SHARDS_ENV: &str = "AETHER_GATEWAY_DIRECT_REQWEST_CLIENT_SHARDS";
const DIRECT_REQWEST_H2_TARGET_STREAMS_PER_CLIENT_ENV: &str =
@@ -607,6 +612,56 @@ impl std::fmt::Display for UpstreamResponseBodyPhase {
}
}
pub(crate) fn with_upstream_response_body_limit(
plan: &ExecutionPlan,
limit_bytes: usize,
) -> ExecutionPlan {
let mut bounded_plan = plan.clone();
bounded_plan
.headers
.retain(|name, _| !name.eq_ignore_ascii_case(EXECUTION_RESPONSE_BODY_LIMIT_HEADER));
bounded_plan.headers.insert(
EXECUTION_RESPONSE_BODY_LIMIT_HEADER.to_string(),
normalize_scoped_response_body_limit(limit_bytes)
.unwrap_or(DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES)
.to_string(),
);
bounded_plan
}
pub(crate) fn execution_plan_response_body_limit_bytes(plan: &ExecutionPlan) -> usize {
effective_response_body_limit_bytes(
execution_transport_header_value(&plan.headers, EXECUTION_RESPONSE_BODY_LIMIT_HEADER),
crate::headers::max_internal_buffered_body_bytes(),
)
}
fn effective_response_body_limit_bytes(
raw_scoped_limit: Option<&str>,
global_limit: usize,
) -> usize {
let Some(raw_scoped_limit) = raw_scoped_limit else {
return global_limit;
};
parse_scoped_response_body_limit(raw_scoped_limit)
.unwrap_or(DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES)
.min(global_limit)
}
fn parse_scoped_response_body_limit(value: &str) -> Option<usize> {
let raw_limit = value.trim().parse::<u64>().ok()?;
usize::try_from(raw_limit)
.ok()
.and_then(normalize_scoped_response_body_limit)
}
fn normalize_scoped_response_body_limit(limit_bytes: usize) -> Option<usize> {
(limit_bytes > 0).then_some(limit_bytes.clamp(
MIN_SCOPED_RESPONSE_BODY_LIMIT_BYTES,
MAX_SCOPED_RESPONSE_BODY_LIMIT_BYTES,
))
}
pub(crate) fn append_upstream_response_body_chunk(
body: &mut Vec<u8>,
chunk: &[u8],
@@ -618,7 +673,7 @@ pub(crate) fn append_upstream_response_body_chunk(
)
}
fn append_upstream_response_body_chunk_with_limit(
pub(crate) fn append_upstream_response_body_chunk_with_limit(
body: &mut Vec<u8>,
chunk: &[u8],
limit_bytes: usize,
@@ -691,6 +746,7 @@ pub(crate) struct DirectUpstreamStreamExecution {
pub(crate) stream_precommit_committed: bool,
pub(crate) response: DirectUpstreamResponse,
pub(crate) started_at: Instant,
pub(crate) response_observation: ExecutionResponseObservation,
pub(crate) stream_first_byte_timeout: Option<Duration>,
pub(crate) upstream_target_permit: Option<UpstreamTargetAdmissionPermit>,
}
@@ -699,6 +755,7 @@ pub(crate) struct DirectUpstreamStreamExecution {
pub(crate) struct DirectSyncResponseStarted {
pub(crate) status_code: u16,
pub(crate) ttfb_ms: u64,
pub(crate) response_observation: ExecutionResponseObservation,
}
impl DirectSyncExecutionRuntime {
@@ -722,20 +779,35 @@ impl DirectSyncExecutionRuntime {
F: FnOnce(DirectSyncResponseStarted),
{
let body_bytes = build_request_body(plan)?;
let response_body_limit_bytes = execution_plan_response_body_limit_bytes(plan);
let started_at = Instant::now();
let request_started_at_unix_ms = crate::clock::current_unix_ms();
let request_order_id = uuid::Uuid::now_v7().to_string();
with_non_stream_total_timeout(plan, async move {
let response = send_request_inner(plan, body_bytes, false).await?;
let ttfb_ms = started_at.elapsed().as_millis() as u64;
let response_headers_observed_at_unix_ms = crate::clock::current_unix_ms();
let status_code = response.status_code();
let headers = response.headers();
let response_observation = ExecutionResponseObservation {
request_started_at_unix_ms,
response_headers_observed_at_unix_ms,
request_order_id,
};
on_response_started(DirectSyncResponseStarted {
status_code,
ttfb_ms,
response_observation: response_observation.clone(),
});
let (body_bytes, stream_ttfb_ms) =
response.bytes_with_stream_timeout(plan, started_at).await?;
let decoded_body_bytes = decode_response_body_bytes(&headers, &body_bytes)?;
let (body_bytes, stream_ttfb_ms) = response
.bytes_with_stream_timeout(plan, started_at, response_body_limit_bytes)
.await?;
let decoded_body_bytes = decode_response_body_bytes_with_limit(
&headers,
&body_bytes,
response_body_limit_bytes,
)?;
let elapsed_ms = started_at.elapsed().as_millis() as u64;
let upstream_bytes = body_bytes.len() as u64;
@@ -752,6 +824,7 @@ impl DirectSyncExecutionRuntime {
candidate_id: plan.candidate_id.clone(),
status_code,
headers,
response_observation: Some(response_observation),
body,
telemetry: Some(ExecutionTelemetry {
ttfb_ms: stream_ttfb_ms.or(Some(ttfb_ms)),
@@ -776,6 +849,8 @@ impl DirectSyncExecutionRuntime {
);
let started_at = Instant::now();
let request_started_at_unix_ms = crate::clock::current_unix_ms();
let request_order_id = uuid::Uuid::now_v7().to_string();
let response = send_request(plan, body_bytes).await?;
observe_gateway_stage_ms(
"direct_send_headers",
@@ -783,6 +858,7 @@ impl DirectSyncExecutionRuntime {
);
let status_code = response.status_code();
let headers = response.headers();
let response_headers_observed_at_unix_ms = crate::clock::current_unix_ms();
let stream_summary_report_context = build_stream_summary_report_context(plan);
@@ -797,6 +873,11 @@ impl DirectSyncExecutionRuntime {
stream_precommit_committed: false,
response: response.into_direct_upstream_response(),
started_at,
response_observation: ExecutionResponseObservation {
request_started_at_unix_ms,
response_headers_observed_at_unix_ms,
request_order_id,
},
stream_first_byte_timeout: resolve_stream_first_byte_timeout(plan),
upstream_target_permit: None,
})
@@ -834,7 +915,7 @@ pub(crate) async fn execute_sync_plan_with_report_context(
}
if resolve_local_tunnel_node_id(state, plan.proxy.as_ref()).is_some() {
return execute_sync_plan_via_local_tunnel(state, plan)
return execute_sync_plan_via_local_tunnel(state, plan, report_context)
.await
.map_err(|err| GatewayError::Internal(err.to_string()));
}
@@ -857,7 +938,24 @@ pub(crate) async fn execute_sync_plan_with_report_context(
Ok(None) => {}
Err(err) => return Err(GatewayError::Internal(err.to_string())),
}
match DirectSyncExecutionRuntime::new().execute_sync(plan).await {
let state_for_response_started = state.clone();
match DirectSyncExecutionRuntime::new()
.execute_sync_with_response_started(plan, move |event| {
crate::orchestration::spawn_local_oauth_success_effect(
state_for_response_started,
plan,
report_context,
crate::orchestration::LocalOAuthSuccessEffect {
status_code: event.status_code,
request_started_at_unix_ms: Some(
event.response_observation.request_started_at_unix_ms,
),
request_order_id: Some(&event.response_observation.request_order_id),
},
);
})
.await
{
Ok(result) => {
record_manual_proxy_request_outcome(state, plan, result.status_code).await;
Ok(result)
@@ -889,6 +987,8 @@ pub(crate) async fn execute_stream_plan_via_local_tunnel(
plan.body.body_bytes_b64.is_some(),
)?;
let started_at = Instant::now();
let request_started_at_unix_ms = crate::clock::current_unix_ms();
let request_order_id = uuid::Uuid::now_v7().to_string();
let response = state
.tunnel
.open_direct_relay_stream(
@@ -900,6 +1000,7 @@ pub(crate) async fn execute_stream_plan_via_local_tunnel(
.map_err(ExecutionRuntimeTransportError::RelayError)?;
let status_code = response.status();
let headers = collect_tunnel_response_headers(response.headers());
let response_headers_observed_at_unix_ms = crate::clock::current_unix_ms();
Ok(Some(DirectUpstreamStreamExecution {
request_id: plan.request_id.clone(),
@@ -912,6 +1013,11 @@ pub(crate) async fn execute_stream_plan_via_local_tunnel(
stream_precommit_committed: false,
response: DirectUpstreamResponse::LocalTunnel(response),
started_at,
response_observation: ExecutionResponseObservation {
request_started_at_unix_ms,
response_headers_observed_at_unix_ms,
request_order_id,
},
stream_first_byte_timeout: resolve_stream_first_byte_timeout(plan),
upstream_target_permit: None,
}))
@@ -991,13 +1097,19 @@ fn manual_proxy_node_id(proxy: Option<&ProxySnapshot>) -> Option<String> {
async fn execute_sync_plan_via_local_tunnel(
state: &AppState,
plan: &ExecutionPlan,
report_context: Option<&serde_json::Value>,
) -> Result<ExecutionResult, ExecutionRuntimeTransportError> {
with_non_stream_total_timeout(plan, execute_sync_plan_via_local_tunnel_inner(state, plan)).await
with_non_stream_total_timeout(
plan,
execute_sync_plan_via_local_tunnel_inner(state, plan, report_context),
)
.await
}
async fn execute_sync_plan_via_local_tunnel_inner(
state: &AppState,
plan: &ExecutionPlan,
report_context: Option<&serde_json::Value>,
) -> Result<ExecutionResult, ExecutionRuntimeTransportError> {
let node_id = resolve_local_tunnel_node_id(state, plan.proxy.as_ref()).ok_or_else(|| {
ExecutionRuntimeTransportError::RelayError("local tunnel node unavailable".to_string())
@@ -1007,6 +1119,7 @@ async fn execute_sync_plan_via_local_tunnel_inner(
}
let body_bytes = build_request_body(plan)?;
let response_body_limit_bytes = execution_plan_response_body_limit_bytes(plan);
let transport_controls = resolve_execution_transport_controls(&plan.headers);
let headers = build_request_headers(
&plan.headers,
@@ -1030,6 +1143,8 @@ async fn execute_sync_plan_via_local_tunnel_inner(
"gateway execution runtime local tunnel request prepared"
);
let started_at = Instant::now();
let request_started_at_unix_ms = crate::clock::current_unix_ms();
let request_order_id = uuid::Uuid::now_v7().to_string();
let mut response = state
.tunnel
.open_direct_relay_stream(
@@ -1040,12 +1155,30 @@ async fn execute_sync_plan_via_local_tunnel_inner(
.await
.map_err(ExecutionRuntimeTransportError::RelayError)?;
let ttfb_ms = started_at.elapsed().as_millis() as u64;
let response_headers_observed_at_unix_ms = crate::clock::current_unix_ms();
let status_code = response.status();
let headers = collect_tunnel_response_headers(response.headers());
let response_observation = ExecutionResponseObservation {
request_started_at_unix_ms,
response_headers_observed_at_unix_ms,
request_order_id,
};
crate::orchestration::spawn_local_oauth_success_effect(
state.clone(),
plan,
report_context,
crate::orchestration::LocalOAuthSuccessEffect {
status_code,
request_started_at_unix_ms: Some(response_observation.request_started_at_unix_ms),
request_order_id: Some(&response_observation.request_order_id),
},
);
let proxy_timing = execution_header_for_log(&headers, "x-proxy-timing").unwrap_or("-");
let (body_bytes, stream_ttfb_ms) =
collect_local_tunnel_response_body(response, plan, started_at).await?;
let decoded_body_bytes = decode_response_body_bytes(&headers, &body_bytes)?;
collect_local_tunnel_response_body(response, plan, started_at, response_body_limit_bytes)
.await?;
let decoded_body_bytes =
decode_response_body_bytes_with_limit(&headers, &body_bytes, response_body_limit_bytes)?;
let elapsed_ms = started_at.elapsed().as_millis() as u64;
let upstream_bytes = body_bytes.len() as u64;
if status_code >= 400 {
@@ -1095,6 +1228,7 @@ async fn execute_sync_plan_via_local_tunnel_inner(
candidate_id: plan.candidate_id.clone(),
status_code,
headers,
response_observation: Some(response_observation),
body,
telemetry: Some(ExecutionTelemetry {
ttfb_ms: stream_ttfb_ms.or(Some(ttfb_ms)),
@@ -1109,6 +1243,7 @@ async fn collect_local_tunnel_response_body(
mut response: tunnel::DirectRelayResponse,
plan: &ExecutionPlan,
started_at: Instant,
response_body_limit_bytes: usize,
) -> Result<(Vec<u8>, Option<u64>), ExecutionRuntimeTransportError> {
let mut body_bytes = Vec::new();
let mut first_byte_ms = None;
@@ -1131,7 +1266,11 @@ async fn collect_local_tunnel_response_body(
if plan.stream && first_byte_ms.is_none() && !chunk.is_empty() {
first_byte_ms = Some(started_at.elapsed().as_millis() as u64);
}
append_upstream_response_body_chunk(&mut body_bytes, &chunk)?;
append_upstream_response_body_chunk_with_limit(
&mut body_bytes,
&chunk,
response_body_limit_bytes,
)?;
}
Ok((body_bytes, first_byte_ms))
@@ -1292,20 +1431,28 @@ impl DirectHttpResponse {
}
pub(crate) async fn bytes(self) -> Result<Bytes, ExecutionRuntimeTransportError> {
self.bytes_with_limit(crate::headers::max_internal_buffered_body_bytes())
.await
}
async fn bytes_with_limit(
self,
response_body_limit_bytes: usize,
) -> Result<Bytes, ExecutionRuntimeTransportError> {
let started_at = Instant::now();
match self {
DirectHttpResponse::Reqwest(response) => {
collect_reqwest_stream_body(response, started_at, None)
collect_reqwest_stream_body(response, started_at, None, response_body_limit_bytes)
.await
.map(|(body, _)| body)
}
DirectHttpResponse::HyperH2c(response) => {
collect_hyper_stream_body(response, started_at, None)
collect_hyper_stream_body(response, started_at, None, response_body_limit_bytes)
.await
.map(|(body, _)| body)
}
DirectHttpResponse::BrowserWreq(response) => {
collect_wreq_stream_body(response, started_at, None)
collect_wreq_stream_body(response, started_at, None, response_body_limit_bytes)
.await
.map(|(body, _)| body)
}
@@ -1316,21 +1463,43 @@ impl DirectHttpResponse {
self,
plan: &ExecutionPlan,
started_at: Instant,
response_body_limit_bytes: usize,
) -> Result<(Bytes, Option<u64>), ExecutionRuntimeTransportError> {
if !plan.stream {
return self.bytes().await.map(|bytes| (bytes, None));
return self
.bytes_with_limit(response_body_limit_bytes)
.await
.map(|bytes| (bytes, None));
}
let first_byte_timeout = resolve_stream_first_byte_timeout(plan);
match self {
DirectHttpResponse::Reqwest(response) => {
collect_reqwest_stream_body(response, started_at, first_byte_timeout).await
collect_reqwest_stream_body(
response,
started_at,
first_byte_timeout,
response_body_limit_bytes,
)
.await
}
DirectHttpResponse::HyperH2c(response) => {
collect_hyper_stream_body(response, started_at, first_byte_timeout).await
collect_hyper_stream_body(
response,
started_at,
first_byte_timeout,
response_body_limit_bytes,
)
.await
}
DirectHttpResponse::BrowserWreq(response) => {
collect_wreq_stream_body(response, started_at, first_byte_timeout).await
collect_wreq_stream_body(
response,
started_at,
first_byte_timeout,
response_body_limit_bytes,
)
.await
}
}
}
@@ -1376,6 +1545,7 @@ async fn collect_reqwest_stream_body(
response: reqwest::Response,
started_at: Instant,
first_byte_timeout: Option<Duration>,
response_body_limit_bytes: usize,
) -> Result<(Bytes, Option<u64>), ExecutionRuntimeTransportError> {
let mut stream = response.bytes_stream();
let mut body_bytes = Vec::new();
@@ -1396,7 +1566,11 @@ async fn collect_reqwest_stream_body(
if first_byte_ms.is_none() && !chunk.is_empty() {
first_byte_ms = Some(started_at.elapsed().as_millis() as u64);
}
append_upstream_response_body_chunk(&mut body_bytes, &chunk)?;
append_upstream_response_body_chunk_with_limit(
&mut body_bytes,
&chunk,
response_body_limit_bytes,
)?;
}
Ok((Bytes::from(body_bytes), first_byte_ms))
@@ -1406,6 +1580,7 @@ async fn collect_hyper_stream_body(
response: hyper::Response<HyperIncomingBody>,
started_at: Instant,
first_byte_timeout: Option<Duration>,
response_body_limit_bytes: usize,
) -> Result<(Bytes, Option<u64>), ExecutionRuntimeTransportError> {
let mut stream = response.into_body().into_data_stream();
let mut body_bytes = Vec::new();
@@ -1426,7 +1601,11 @@ async fn collect_hyper_stream_body(
if first_byte_ms.is_none() && !chunk.is_empty() {
first_byte_ms = Some(started_at.elapsed().as_millis() as u64);
}
append_upstream_response_body_chunk(&mut body_bytes, &chunk)?;
append_upstream_response_body_chunk_with_limit(
&mut body_bytes,
&chunk,
response_body_limit_bytes,
)?;
}
Ok((Bytes::from(body_bytes), first_byte_ms))
@@ -1436,6 +1615,7 @@ async fn collect_wreq_stream_body(
response: wreq::Response,
started_at: Instant,
first_byte_timeout: Option<Duration>,
response_body_limit_bytes: usize,
) -> Result<(Bytes, Option<u64>), ExecutionRuntimeTransportError> {
let mut stream = response.bytes_stream();
let mut body_bytes = Vec::new();
@@ -1456,7 +1636,11 @@ async fn collect_wreq_stream_body(
if first_byte_ms.is_none() && !chunk.is_empty() {
first_byte_ms = Some(started_at.elapsed().as_millis() as u64);
}
append_upstream_response_body_chunk(&mut body_bytes, &chunk)?;
append_upstream_response_body_chunk_with_limit(
&mut body_bytes,
&chunk,
response_body_limit_bytes,
)?;
}
Ok((Bytes::from(body_bytes), first_byte_ms))
@@ -2308,10 +2492,31 @@ async fn send_via_tunnel_relay(
error_kind = %kind,
"gateway execution runtime tunnel relay returned relay error"
);
let message = response
.text()
.await
.unwrap_or_else(|_| format!("hub relay error: {kind}"));
let response_headers = collect_response_headers(response.headers());
let response_body_limit_bytes = execution_plan_response_body_limit_bytes(plan);
let (wire_body, _) =
collect_reqwest_stream_body(response, Instant::now(), None, response_body_limit_bytes)
.await
.map_err(|error| {
ExecutionRuntimeTransportError::RelayError(format!(
"hub relay error: {kind}: bounded error body read failed: {error}"
))
})?;
let decoded_body = decode_response_body_bytes_with_limit(
&response_headers,
&wire_body,
response_body_limit_bytes,
)
.map_err(|error| {
ExecutionRuntimeTransportError::RelayError(format!(
"hub relay error: {kind}: bounded error body decode failed: {error}"
))
})?;
let message = if decoded_body.is_empty() {
format!("hub relay error: {kind}")
} else {
String::from_utf8_lossy(decoded_body.as_ref()).into_owned()
};
return Err(ExecutionRuntimeTransportError::RelayError(message));
}
@@ -3833,6 +4038,7 @@ pub(crate) fn build_request_headers(
|| normalized_key == EXECUTION_REQUEST_HTTP1_ONLY_HEADER
|| normalized_key == EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER
|| normalized_key == EXECUTION_RESPONSE_BODY_MODE_HEADER
|| normalized_key == EXECUTION_RESPONSE_BODY_LIMIT_HEADER
{
continue;
}
@@ -3981,7 +4187,7 @@ pub(crate) fn decode_response_body_bytes<'a>(
)
}
fn decode_response_body_bytes_with_limit<'a>(
pub(crate) fn decode_response_body_bytes_with_limit<'a>(
headers: &BTreeMap<String, String>,
body_bytes: &'a [u8],
limit_bytes: usize,
@@ -4003,6 +4209,11 @@ fn decode_response_body_bytes_with_limit<'a>(
read_upstream_response_decoder_with_limit("deflate", &mut decoder, limit_bytes)
.map(Cow::Owned)
}
Some("br") => {
let mut decoder = BrotliDecoder::new(body_bytes, 4_096);
read_upstream_response_decoder_with_limit("br", &mut decoder, limit_bytes)
.map(Cow::Owned)
}
_ => Ok(Cow::Borrowed(body_bytes)),
}
}
@@ -4122,12 +4333,16 @@ mod tests {
use super::{
append_upstream_response_body_chunk_with_limit, build_browser_wreq_client, build_client,
build_direct_tunnel_request_meta, build_execution_response_body, build_request_headers,
decode_response_body_bytes_with_limit, execute_sync_plan, execution_response_body_mode,
decode_response_body_bytes_with_limit, effective_response_body_limit_bytes,
execute_sync_plan, execution_plan_response_body_limit_bytes, execution_response_body_mode,
record_manual_proxy_request_failure, record_manual_proxy_request_outcome,
record_manual_proxy_request_success, record_manual_proxy_stream_error,
resolve_execution_transport_controls, resolve_non_stream_total_timeout,
resolve_stream_first_byte_timeout, response_body_is_json, DirectSyncExecutionRuntime,
resolve_stream_first_byte_timeout, response_body_is_json,
with_upstream_response_body_limit, DirectSyncExecutionRuntime,
ExecutionRuntimeTransportError, ExecutionTransportControls, UpstreamResponseBodyPhase,
DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES, EXECUTION_RESPONSE_BODY_LIMIT_HEADER,
MAX_SCOPED_RESPONSE_BODY_LIMIT_BYTES, MIN_SCOPED_RESPONSE_BODY_LIMIT_BYTES,
};
use crate::constants::{
EXECUTION_RUNTIME_LOOP_GUARD_HEADER, EXECUTION_RUNTIME_LOOP_GUARD_VIA_TOKEN,
@@ -4182,6 +4397,162 @@ mod tests {
assert!(!materialized.contains_key("x-aether-future-control"));
}
#[test]
fn scoped_response_body_limit_injection_preserves_transport_profile_and_extra() {
let mut plan = tunnel_timeout_plan(false);
let original_profile = ResolvedTransportProfile {
profile_id: "existing-profile".into(),
backend: TRANSPORT_BACKEND_BROWSER_WREQ.into(),
http_mode: TRANSPORT_HTTP_MODE_HTTP1_ONLY.into(),
pool_scope: "provider".into(),
header_fingerprint: Some(json!({"user_agent": "existing"})),
extra: Some(json!({"existing": {"nested": true}})),
};
plan.transport_profile = Some(original_profile.clone());
let bounded_plan =
with_upstream_response_body_limit(&plan, DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES);
assert_eq!(plan.transport_profile, Some(original_profile.clone()));
assert_eq!(bounded_plan.transport_profile, Some(original_profile));
assert_eq!(
bounded_plan
.headers
.get(EXECUTION_RESPONSE_BODY_LIMIT_HEADER)
.and_then(|value| value.parse::<usize>().ok()),
Some(DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES)
);
assert_eq!(
execution_plan_response_body_limit_bytes(&bounded_plan),
DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES
);
let unprofiled_plan = tunnel_timeout_plan(false);
let bounded_unprofiled_plan = with_upstream_response_body_limit(
&unprofiled_plan,
DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES,
);
assert!(unprofiled_plan.transport_profile.is_none());
assert!(bounded_unprofiled_plan.transport_profile.is_none());
assert_eq!(
execution_plan_response_body_limit_bytes(&bounded_unprofiled_plan),
DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES
);
let mut shadowed_plan = tunnel_timeout_plan(false);
shadowed_plan.headers.insert(
EXECUTION_RESPONSE_BODY_LIMIT_HEADER.to_ascii_uppercase(),
"65536".to_string(),
);
let bounded_shadowed_plan = with_upstream_response_body_limit(
&shadowed_plan,
DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES,
);
assert_eq!(
bounded_shadowed_plan
.headers
.keys()
.filter(|name| name.eq_ignore_ascii_case(EXECUTION_RESPONSE_BODY_LIMIT_HEADER))
.count(),
1
);
}
#[test]
fn scoped_response_body_limit_parsing_rejects_invalid_values_and_clamps_bounds() {
let scoped_plan = |raw_limit: &str| {
let mut plan = tunnel_timeout_plan(false);
plan.headers.insert(
EXECUTION_RESPONSE_BODY_LIMIT_HEADER.to_string(),
raw_limit.to_string(),
);
plan
};
for invalid in ["0", "-1", "1.5", "", "invalid"] {
assert_eq!(
execution_plan_response_body_limit_bytes(&scoped_plan(invalid)),
DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES
);
}
assert_eq!(
execution_plan_response_body_limit_bytes(&scoped_plan("1")),
MIN_SCOPED_RESPONSE_BODY_LIMIT_BYTES
);
assert_eq!(
execution_plan_response_body_limit_bytes(&scoped_plan(
&(MAX_SCOPED_RESPONSE_BODY_LIMIT_BYTES as u64 + 1).to_string()
)),
MAX_SCOPED_RESPONSE_BODY_LIMIT_BYTES
);
assert_eq!(
execution_plan_response_body_limit_bytes(&scoped_plan("1048576")),
1_048_576
);
assert_eq!(
effective_response_body_limit_bytes(
Some(&(DEFAULT_SCOPED_RESPONSE_BODY_LIMIT_BYTES * 2).to_string()),
1024 * 1024,
),
1024 * 1024,
"a scoped limit must never raise the operator's global cap"
);
}
#[test]
fn scoped_response_body_wire_limit_rejects_overflow() {
let bounded_plan = with_upstream_response_body_limit(
&tunnel_timeout_plan(false),
MIN_SCOPED_RESPONSE_BODY_LIMIT_BYTES,
);
let limit_bytes = execution_plan_response_body_limit_bytes(&bounded_plan);
let mut body = vec![b'x'; limit_bytes];
let error =
append_upstream_response_body_chunk_with_limit(&mut body, b"overflow", limit_bytes)
.expect_err("wire body above the plan-scoped limit should fail");
assert!(matches!(
error,
ExecutionRuntimeTransportError::UpstreamResponseTooLarge {
phase: UpstreamResponseBodyPhase::Wire,
limit_bytes: MIN_SCOPED_RESPONSE_BODY_LIMIT_BYTES,
}
));
}
#[test]
fn scoped_response_body_limit_rejects_gzip_bomb_after_wire_check() {
let bounded_plan = with_upstream_response_body_limit(
&tunnel_timeout_plan(false),
MIN_SCOPED_RESPONSE_BODY_LIMIT_BYTES,
);
let limit_bytes = execution_plan_response_body_limit_bytes(&bounded_plan);
let payload = vec![b'x'; limit_bytes + 1];
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder
.write_all(&payload)
.expect("gzip payload should encode");
let encoded = encoder.finish().expect("gzip payload should finish");
assert!(encoded.len() < limit_bytes);
let mut wire_body = Vec::new();
append_upstream_response_body_chunk_with_limit(&mut wire_body, &encoded, limit_bytes)
.expect("compressed wire body should fit within the plan-scoped limit");
let headers = BTreeMap::from([("content-encoding".to_string(), "gzip".to_string())]);
let error = decode_response_body_bytes_with_limit(&headers, &wire_body, limit_bytes)
.expect_err("decoded body above the plan-scoped limit should fail");
assert!(matches!(
error,
ExecutionRuntimeTransportError::UpstreamResponseTooLarge {
phase: UpstreamResponseBodyPhase::Decoded,
limit_bytes: MIN_SCOPED_RESPONSE_BODY_LIMIT_BYTES,
}
));
}
#[test]
fn upstream_response_wire_limit_allows_exact_body_and_rejects_next_byte() {
let mut body = Vec::new();
@@ -5484,6 +5855,100 @@ mod tests {
);
}
#[tokio::test]
async fn direct_sync_execution_runtime_preserves_gemini_tool_config_on_wire() {
let listener = crate::test_support::bind_loopback_listener()
.await
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let captured_body = Arc::new(Mutex::new(None));
let captured_body_for_handler = Arc::clone(&captured_body);
let app = Router::new().route(
"/generate",
post(move |body: Bytes| {
let captured_body = Arc::clone(&captured_body_for_handler);
async move {
*captured_body
.lock()
.expect("capture lock should not be poisoned") = Some(body.to_vec());
Json(json!({"ok": true}))
}
}),
);
let server = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("test server should run");
});
let result = DirectSyncExecutionRuntime::new()
.execute_sync(&ExecutionPlan {
request_id: "req-gemini-tool-config-wire".into(),
candidate_id: Some("cand-gemini-tool-config-wire".into()),
provider_name: Some("google".into()),
provider_id: "prov-gemini-tool-config-wire".into(),
endpoint_id: "ep-gemini-tool-config-wire".into(),
key_id: "key-gemini-tool-config-wire".into(),
method: "POST".into(),
url: format!("http://{addr}/generate"),
headers: BTreeMap::from([("content-type".into(), "application/json".into())]),
content_type: Some("application/json".into()),
content_encoding: None,
body: RequestBody::from_json(json!({
"model": "gemini-3-flash-preview",
"contents": [{
"role": "user",
"parts": [{"text": "Search, then save the result."}]
}],
"tools": [
{"googleSearch": {}},
{"functionDeclarations": [{
"name": "save_result",
"parameters": {
"type": "OBJECT",
"properties": {"result": {"type": "STRING"}}
}
}]}
],
"toolConfig": {
"includeServerSideToolInvocations": true,
"functionCallingConfig": {"mode": "ANY"}
}
})),
stream: false,
client_api_format: "openai:responses".into(),
provider_api_format: "gemini:generate_content".into(),
model_name: Some("gemini-3-flash-preview".into()),
proxy: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(LOCAL_HTTP_SUCCESS_TIMEOUT_MS),
..ExecutionTimeouts::default()
}),
})
.await
.expect("sync execution should succeed");
server.abort();
assert_eq!(result.status_code, 200);
let body = captured_body
.lock()
.expect("capture lock should not be poisoned")
.take()
.and_then(|body| serde_json::from_slice::<serde_json::Value>(&body).ok())
.expect("upstream should receive a JSON body");
assert_eq!(
body["toolConfig"]["includeServerSideToolInvocations"],
json!(true)
);
assert_eq!(body["toolConfig"]["functionCallingConfig"]["mode"], "ANY");
assert!(body["toolConfig"]
.get("include_server_side_tool_invocations")
.is_none());
}
#[tokio::test]
async fn direct_sync_execution_runtime_applies_non_stream_total_timeout_to_body() {
let listener = crate::test_support::bind_loopback_listener()
@@ -5605,6 +6070,8 @@ mod tests {
)
.await
.expect("headers should write");
socket.flush().await.expect("headers should flush");
tokio::time::sleep(std::time::Duration::from_millis(40)).await;
socket
.write_all(b"b\r\ndata: one\n\n\r\n")
.await
@@ -5634,12 +6101,34 @@ mod tests {
let body = result
.body
.clone()
.and_then(|body| body.body_bytes_b64)
.and_then(|body| base64::engine::general_purpose::STANDARD.decode(body).ok())
.expect("stream body should be captured as bytes");
let body = String::from_utf8(body).expect("stream body should be utf8");
assert!(body.contains("data: one"));
assert!(body.contains("data: two"));
let observation = result
.response_observation
.expect("stream sync execution should preserve header observation");
let telemetry = result
.telemetry
.expect("stream sync execution should include telemetry");
let ttfb_ms = telemetry
.ttfb_ms
.expect("stream sync execution should measure the first body byte");
assert!(
observation.response_headers_observed_at_unix_ms
>= observation.request_started_at_unix_ms
);
assert!(
observation
.response_headers_observed_at_unix_ms
.saturating_sub(observation.request_started_at_unix_ms)
< ttfb_ms,
"header observation must not be derived from body-byte ttfb"
);
assert!(!observation.request_order_id.is_empty());
}
#[tokio::test]
@@ -266,6 +266,7 @@ pub(crate) async fn maybe_execute_windsurf_sync(
candidate_id: prepared.candidate_id,
status_code: 200,
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
response_observation: None,
body: Some(ResponseBody {
json_body: Some(body_json),
body_bytes_b64: None,
@@ -527,6 +528,7 @@ fn build_windsurf_stream_frame_stream(
("cache-control".to_string(), "no-cache".to_string()),
("content-type".to_string(), "text/event-stream".to_string()),
]),
response_observation: None,
},
});
@@ -20,9 +20,11 @@ use crate::ai_serving::LocalExecutionAttemptSource;
use crate::clock::current_unix_ms;
use crate::control::GatewayControlDecision;
use crate::execution_runtime::{
build_transport_error_stop_response, execute_execution_runtime_stream_with_retry_scope,
acquire_upstream_execution_gate, build_transport_error_stop_response,
execute_execution_runtime_stream_with_retry_scope,
execute_execution_runtime_sync_with_retry_scope,
mark_stream_candidate_watchdog_terminal_started, StreamCandidateWatchdogProgress,
UpstreamExecutionGateProvider, UPSTREAM_EXECUTION_GATE_NAME,
};
use crate::executor::{
build_local_execution_exhaustion, mark_deferred_upstream_response, LocalExecutionRequestOutcome,
@@ -43,7 +45,6 @@ use crate::stage_metrics::observe_gateway_stage_ms;
use crate::{AppState, GatewayError};
const DEFAULT_STREAM_FIRST_BYTE_WATCHDOG_TIMEOUT_MS: u64 = 30_000;
const UPSTREAM_EXECUTION_GATE_NAME: &str = "gateway_upstream_execution";
const UPSTREAM_TARGET_GATE_NAME: &str = "gateway_upstream_target";
const UPSTREAM_EXECUTION_GATE_HOLD_STREAM_RESPONSE_ENV: &str =
"AETHER_GATEWAY_UPSTREAM_EXECUTION_GATE_HOLD_STREAM_RESPONSE";
@@ -251,6 +252,10 @@ where
Ok(())
}
async fn next_same_key_retry(&self, attempt: &T) -> Result<Option<T>, Self::Error> {
Ok(crate::orchestration::next_same_key_retry_attempt(attempt))
}
async fn record_attempt_failed(&self, attempt: &T) -> Result<(), Self::Error> {
record_provider_transfer_attempt_failed(
self.state,
@@ -787,18 +792,31 @@ where
{
let mut last_attempted = None;
let mut fallback_response = None;
// A same-key retry derived after a candidate-scoped failure runs before
// the source is asked for the next candidate.
let mut pending_same_key_retry: Option<Attempt> = None;
loop {
let next_started_at = std::time::Instant::now();
let next_attempt =
next_execution_attempt_with_timeout(source, trace_id, plan_kind, planning_timeout)
let attempt = match pending_same_key_retry.take() {
Some(attempt) => attempt,
None => {
let next_started_at = std::time::Instant::now();
let next_attempt = next_execution_attempt_with_timeout(
source,
trace_id,
plan_kind,
planning_timeout,
)
.await?;
observe_gateway_stage_ms(
"stream_candidate_next",
next_started_at.elapsed().as_millis() as u64,
);
let Some(attempt) = next_attempt else {
break;
observe_gateway_stage_ms(
"stream_candidate_next",
next_started_at.elapsed().as_millis() as u64,
);
let Some(attempt) = next_attempt else {
break;
};
attempt
}
};
if port.should_skip_attempt(&attempt).await? {
let provider_id = attempt.execution_plan().provider_id.clone();
@@ -838,6 +856,9 @@ where
if attempt_fallback_response.is_some() {
fallback_response = attempt_fallback_response;
}
if scope == AiAttemptRetryScope::Candidate {
pending_same_key_retry = port.next_same_key_retry(&attempt).await?;
}
apply_attempt_retry_scope(source, &attempt, scope).await?;
}
}
@@ -951,6 +972,10 @@ where
Ok(())
}
async fn next_same_key_retry(&self, attempt: &T) -> Result<Option<T>, Self::Error> {
Ok(crate::orchestration::next_same_key_retry_attempt(attempt))
}
async fn record_attempt_failed(&self, attempt: &T) -> Result<(), Self::Error> {
record_provider_transfer_attempt_failed(
self.state,
@@ -1612,47 +1637,6 @@ fn hold_response_upstream_execution_permit(
Response::from_parts(parts, Body::from_stream(stream))
}
trait UpstreamExecutionGateProvider {
fn upstream_execution_gate(&self) -> Option<&aether_runtime::ConcurrencyGate>;
fn upstream_execution_gate_queue_budget(&self) -> Duration;
}
impl UpstreamExecutionGateProvider for AppState {
fn upstream_execution_gate(&self) -> Option<&aether_runtime::ConcurrencyGate> {
self.upstream_execution_gate.as_deref()
}
fn upstream_execution_gate_queue_budget(&self) -> Duration {
self.frontdoor_runtime_guards.internal_gate_queue_budget
}
}
async fn acquire_upstream_execution_gate(
state: &(impl UpstreamExecutionGateProvider + ?Sized),
trace_id: &str,
) -> Result<Option<ConcurrencyPermit>, GatewayError> {
let Some(gate) = state.upstream_execution_gate() else {
return Ok(None);
};
let budget = state.upstream_execution_gate_queue_budget();
let gate_wait_started_at = std::time::Instant::now();
match timeout(budget, gate.acquire()).await {
Ok(Ok(permit)) => {
observe_gateway_stage_ms(
"upstream_execution_gate_wait",
gate_wait_started_at.elapsed().as_millis() as u64,
);
Ok(Some(permit))
}
Ok(Err(err)) => Err(GatewayError::Internal(err.to_string())),
Err(_) => Err(GatewayError::AdmissionTimeout {
trace_id: trace_id.to_string(),
gate: UPSTREAM_EXECUTION_GATE_NAME,
queue_budget_ms: budget.as_millis() as u64,
}),
}
}
pub(crate) async fn mark_unused_local_candidate_items<T, FPlan, FContext>(
state: &AppState,
remaining: Vec<T>,
@@ -1663,6 +1663,22 @@ mod tests {
candidate_index: u32,
endpoint_id: &str,
candidate_id: &str,
) -> AiSyncAttempt {
test_openai_image_heartbeat_attempt_with_sticky_key_attempts(
candidate_index,
endpoint_id,
candidate_id,
1,
)
}
/// `sticky_key_attempts` is pinned so these tests exercise candidate
/// failover; the default same-key retry is covered separately.
fn test_openai_image_heartbeat_attempt_with_sticky_key_attempts(
candidate_index: u32,
endpoint_id: &str,
candidate_id: &str,
sticky_key_attempts: u32,
) -> AiSyncAttempt {
AiSyncAttempt {
plan: test_openai_image_heartbeat_plan(endpoint_id, candidate_id),
@@ -1670,6 +1686,7 @@ mod tests {
report_context: Some(json!({
"candidate_index": candidate_index,
"retry_index": 0,
"sticky_key_attempts": sticky_key_attempts,
})),
}
}
@@ -1687,6 +1704,7 @@ mod tests {
CONTENT_TYPE.as_str().to_string(),
"application/json".to_string(),
)]),
response_observation: None,
body: Some(aether_contracts::ResponseBody {
json_body: Some(body_json),
body_bytes_b64: None,
@@ -1827,6 +1845,9 @@ mod tests {
report_context: Some(json!({
"candidate_index": candidate_index,
"retry_index": 0,
// Pin to a single attempt so this helper exercises candidate
// failover rather than the default same-key retry.
"sticky_key_attempts": 1,
"client_api_format": client_api_format,
"provider_api_format": client_api_format,
})),
@@ -1979,6 +2000,90 @@ mod tests {
assert_eq!(body, json!({"data": [{"b64_json": "second-candidate"}]}));
}
#[tokio::test]
async fn openai_image_sync_heartbeat_retries_sticky_key_lazily_before_failover() {
let seen_plans = Arc::new(std::sync::Mutex::new(Vec::<(String, Option<String>)>::new()));
let seen_plans_for_override = Arc::clone(&seen_plans);
let state = AppState::new()
.expect("state should build")
.with_execution_runtime_sync_override_for_tests(move |plan| {
seen_plans_for_override
.lock()
.expect("mutex should lock")
.push((plan.endpoint_id.clone(), plan.candidate_id.clone()));
if plan.endpoint_id == "endpoint-retry" {
Ok(test_openai_image_execution_result(
plan,
StatusCode::TOO_MANY_REQUESTS.as_u16(),
json!({"error": {"message": "retry this candidate"}}),
))
} else {
Ok(test_openai_image_execution_result(
plan,
StatusCode::OK.as_u16(),
json!({"data": [{"b64_json": "second-candidate"}]}),
))
}
});
// Three total attempts on the sticky key; only one attempt is
// materialized up front, the other two are derived after each failure.
let attempts = vec![
test_openai_image_heartbeat_attempt_with_sticky_key_attempts(
0,
"endpoint-retry",
"candidate-retry",
3,
),
test_openai_image_heartbeat_attempt_with_sticky_key_attempts(
1,
"endpoint-success",
"candidate-success",
3,
),
];
let outcome = execute_openai_image_sync_heartbeat_attempts(
state,
"/v1/images/generations".to_string(),
"trace-image-heartbeat-sticky-retry".to_string(),
test_openai_image_heartbeat_decision(),
TEST_OPENAI_IMAGE_SYNC_PLAN_KIND.to_string(),
attempts,
ProviderTransferTracker::default(),
Instant::now(),
)
.await
.expect("heartbeat attempts should execute");
let LocalExecutionRequestOutcome::Responded(response) = outcome else {
panic!("second candidate should return a response");
};
let bytes = openai_image_sync_heartbeat_response_body_bytes(response).await;
let body: Value = serde_json::from_slice(&bytes).expect("body should decode");
let seen_plans = seen_plans.lock().expect("mutex should lock").clone();
assert_eq!(
seen_plans
.iter()
.map(|(endpoint_id, _)| endpoint_id.as_str())
.collect::<Vec<_>>(),
[
"endpoint-retry",
"endpoint-retry",
"endpoint-retry",
"endpoint-success"
]
);
let sticky_candidate_ids = seen_plans[..3]
.iter()
.map(|(_, candidate_id)| candidate_id.clone())
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
sticky_candidate_ids.len(),
3,
"each derived same-key retry must carry a fresh candidate id"
);
assert_eq!(body, json!({"data": [{"b64_json": "second-candidate"}]}));
}
#[tokio::test]
async fn openai_image_sync_heartbeat_honors_provider_transfer_limit() {
let call_count = Arc::new(AtomicUsize::new(0));
@@ -2012,6 +2117,7 @@ mod tests {
attempt.report_context = Some(json!({
"candidate_index": index,
"retry_index": 0,
"sticky_key_attempts": 1,
"local_failover_policy": {
"max_transfer_count": 1,
"max_transfer_timeout_seconds": 0
@@ -111,6 +111,22 @@ impl LocalExecutionRuntimeMissContext {
})
}
pub(crate) fn all_candidates_skipped_for_reasons(&self, reasons: &[&str]) -> bool {
if reasons.is_empty() || self.candidate_contexts.is_empty() {
return false;
}
self.candidate_contexts.iter().all(|candidate| {
candidate.candidate.status == RequestCandidateStatus::Skipped
&& candidate
.candidate
.skip_reason
.as_deref()
.map(str::trim)
.is_some_and(|value| reasons.contains(&value))
})
}
pub(crate) fn candidate_summary(&self) -> Option<String> {
const MAX_ITEMS: usize = 5;
@@ -41,12 +41,16 @@ pub(crate) fn frontdoor_self_loop_public_ai_path(path: &str) -> bool {
| "/v1/rerank"
| "/v1/responses"
| "/v1/responses/compact"
| "/v1/realtime"
| "/v1/realtime/calls"
| "/v1/live"
| "/v1/alpha/search"
| "/v1beta/files"
| "/upload/v1beta/files"
| "/v1beta/operations"
| "/v1/videos"
) || path.starts_with("/v1/videos/")
) || path.starts_with("/v1/live/")
|| path.starts_with("/v1/videos/")
|| path.starts_with("/v1beta/files/")
|| path.starts_with("/v1beta/operations/")
|| path.starts_with("/v1internal:")
@@ -141,3 +145,24 @@ fn normalize_host_for_frontdoor_loop_guard(host: &str) -> String {
fn is_loopbackish_host(host: &str) -> bool {
matches!(host, "localhost" | "127.0.0.1" | "::1" | "0.0.0.0" | "::")
}
#[cfg(test)]
mod tests {
use super::{
frontdoor_self_loop_public_ai_path, gateway_frontdoor_self_loop_guard_matches_with_port,
};
#[test]
fn realtime_is_protected_from_frontdoor_self_loops() {
assert!(frontdoor_self_loop_public_ai_path("/v1/realtime"));
assert!(frontdoor_self_loop_public_ai_path("/v1/realtime/calls"));
assert!(gateway_frontdoor_self_loop_guard_matches_with_port(
8084,
"ws://127.0.0.1:8084/v1/realtime?model=gpt-realtime"
));
assert!(gateway_frontdoor_self_loop_guard_matches_with_port(
8084,
"wss://localhost:8084/v1/realtime?model=gpt-realtime"
));
}
}
@@ -67,27 +67,14 @@ pub(crate) async fn build_admin_global_model_routing_payload(
.push(key);
}
let scheduling_mode = state
.read_system_config_json_value("scheduling_mode")
// Effective default scheduling: system-default routing group first, then
// legacy system-config keys.
let ordering_config = crate::scheduler::config::read_scheduler_ordering_config(state.app())
.await
.ok()
.flatten()
.and_then(|value| value.as_str().map(ToOwned::to_owned))
.unwrap_or_else(|| "cache_affinity".to_string());
let priority_mode = state
.read_system_config_json_value("provider_priority_mode")
.await
.ok()
.flatten()
.and_then(|value| value.as_str().map(ToOwned::to_owned))
.unwrap_or_else(|| "provider".to_string());
let keep_priority_on_conversion = state
.read_system_config_json_value("keep_priority_on_conversion")
.await
.ok()
.flatten()
.and_then(|value| value.as_bool())
.unwrap_or(false);
.unwrap_or_default();
let scheduling_mode = ordering_config.scheduling_mode_str().to_string();
let priority_mode = ordering_config.priority_mode_str().to_string();
let keep_priority_on_conversion = ordering_config.keep_priority_on_conversion;
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())

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