mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-13 22:50:19 +08:00
refactor(workspace): enforce layered crate boundaries
This commit is contained in:
+133
-16
@@ -138,7 +138,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
run: cargo clippy --workspace --exclude aether-gateway --exclude aether-data --all-targets -- -D warnings
|
run: cargo clippy --workspace --exclude aether-gateway --exclude aether-data --exclude aether-integration-tests --all-targets -- -D warnings
|
||||||
|
|
||||||
- name: Show sccache stats
|
- name: Show sccache stats
|
||||||
if: always()
|
if: always()
|
||||||
@@ -252,6 +252,45 @@ jobs:
|
|||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
run: sccache --show-stats
|
run: sccache --show-stats
|
||||||
|
|
||||||
|
check_data_features:
|
||||||
|
name: Check (Data Feature - ${{ matrix.feature }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
feature:
|
||||||
|
- postgres
|
||||||
|
- mysql
|
||||||
|
- sqlite
|
||||||
|
- all-drivers
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
shared-key: rust-ci-${{ runner.os }}
|
||||||
|
workspaces: . -> target
|
||||||
|
|
||||||
|
- name: Setup sccache
|
||||||
|
uses: mozilla-actions/sccache-action@v0.0.9
|
||||||
|
|
||||||
|
- name: Check selected data driver
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
run: cargo check -p aether-data --no-default-features --features ${{ matrix.feature }}
|
||||||
|
|
||||||
|
- name: Show sccache stats
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
run: sccache --show-stats
|
||||||
|
|
||||||
test_rest:
|
test_rest:
|
||||||
name: Test (Workspace Rest)
|
name: Test (Workspace Rest)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -280,7 +319,79 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
run: cargo nextest run --workspace --exclude aether-gateway --exclude aether-data
|
run: cargo nextest run --workspace --exclude aether-gateway --exclude aether-data --exclude aether-integration-tests
|
||||||
|
|
||||||
|
- name: Show sccache stats
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
run: sccache --show-stats
|
||||||
|
|
||||||
|
test_data_adapters:
|
||||||
|
name: Test (Data Adapter - ${{ matrix.package }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
package:
|
||||||
|
- aether-data-postgres
|
||||||
|
- aether-data-mysql
|
||||||
|
- aether-data-sqlite
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
shared-key: rust-ci-${{ runner.os }}
|
||||||
|
workspaces: . -> target
|
||||||
|
|
||||||
|
- name: Setup sccache
|
||||||
|
uses: mozilla-actions/sccache-action@v0.0.9
|
||||||
|
|
||||||
|
- name: Install nextest
|
||||||
|
uses: taiki-e/install-action@nextest
|
||||||
|
|
||||||
|
- name: Test adapter
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
run: cargo nextest run -p ${{ matrix.package }}
|
||||||
|
|
||||||
|
- name: Show sccache stats
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
run: sccache --show-stats
|
||||||
|
|
||||||
|
check_integration_scenarios:
|
||||||
|
name: Test (Integration Scenarios)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
shared-key: rust-ci-${{ runner.os }}
|
||||||
|
workspaces: . -> target
|
||||||
|
|
||||||
|
- name: Setup sccache
|
||||||
|
uses: mozilla-actions/sccache-action@v0.0.9
|
||||||
|
|
||||||
|
- name: Test scenario binaries
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
run: cargo test -p aether-integration-tests --bins
|
||||||
|
|
||||||
- name: Show sccache stats
|
- name: Show sccache stats
|
||||||
if: always()
|
if: always()
|
||||||
@@ -295,14 +406,20 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- test_gateway
|
- test_gateway
|
||||||
- test_data
|
- test_data
|
||||||
|
- check_data_features
|
||||||
- test_rest
|
- test_rest
|
||||||
|
- test_data_adapters
|
||||||
|
- check_integration_scenarios
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
steps:
|
steps:
|
||||||
- name: Verify test jobs
|
- name: Verify test jobs
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ needs.test_gateway.result }}" != "success" ] || \
|
if [ "${{ needs.test_gateway.result }}" != "success" ] || \
|
||||||
[ "${{ needs.test_data.result }}" != "success" ] || \
|
[ "${{ needs.test_data.result }}" != "success" ] || \
|
||||||
[ "${{ needs.test_rest.result }}" != "success" ]; then
|
[ "${{ needs.check_data_features.result }}" != "success" ] || \
|
||||||
|
[ "${{ needs.test_rest.result }}" != "success" ] || \
|
||||||
|
[ "${{ needs.test_data_adapters.result }}" != "success" ] || \
|
||||||
|
[ "${{ needs.check_integration_scenarios.result }}" != "success" ]; then
|
||||||
echo "Tests failed"
|
echo "Tests failed"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -332,7 +449,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
run: cargo test -p aether-data sqlite --lib
|
run: cargo test -p aether-data --all-features sqlite --lib
|
||||||
|
|
||||||
- name: Show sccache stats
|
- name: Show sccache stats
|
||||||
if: always()
|
if: always()
|
||||||
@@ -381,28 +498,28 @@ jobs:
|
|||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
AETHER_TEST_POSTGRES_URL: postgres://aether:aether@127.0.0.1:5432/aether_test
|
AETHER_TEST_POSTGRES_URL: postgres://aether:aether@127.0.0.1:5432/aether_test
|
||||||
run: cargo test -p aether-data postgres_migrations_create_core_config_tables_when_url_is_set --lib -- --nocapture
|
run: cargo test -p aether-data --all-features postgres_migrations_create_core_config_tables_when_url_is_set --lib -- --nocapture
|
||||||
|
|
||||||
- name: Run Postgres provider metadata migration smoke test
|
- name: Run Postgres provider metadata migration smoke test
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
AETHER_TEST_POSTGRES_URL: postgres://aether:aether@127.0.0.1:5432/aether_test
|
AETHER_TEST_POSTGRES_URL: postgres://aether:aether@127.0.0.1:5432/aether_test
|
||||||
run: cargo test -p aether-data postgres_provider_upstream_metadata_migration_preserves_json_when_url_is_set --lib -- --nocapture
|
run: cargo test -p aether-data --all-features postgres_provider_upstream_metadata_migration_preserves_json_when_url_is_set --lib -- --nocapture
|
||||||
|
|
||||||
- name: Run Postgres core export smoke test
|
- name: Run Postgres core export smoke test
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
AETHER_TEST_POSTGRES_URL: postgres://aether:aether@127.0.0.1:5432/aether_test
|
AETHER_TEST_POSTGRES_URL: postgres://aether:aether@127.0.0.1:5432/aether_test
|
||||||
run: cargo test -p aether-data postgres_core_export_reads_migrated_database_rows_when_url_is_set --lib -- --nocapture
|
run: cargo test -p aether-data --all-features postgres_core_export_reads_migrated_database_rows_when_url_is_set --lib -- --nocapture
|
||||||
|
|
||||||
- name: Run SQLite-to-Postgres import smoke test
|
- name: Run SQLite-to-Postgres import smoke test
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
AETHER_TEST_POSTGRES_URL: postgres://aether:aether@127.0.0.1:5432/aether_test
|
AETHER_TEST_POSTGRES_URL: postgres://aether:aether@127.0.0.1:5432/aether_test
|
||||||
run: cargo test -p aether-data sqlite_core_export_reads_migrated_database_rows --lib -- --nocapture
|
run: cargo test -p aether-data --all-features sqlite_core_export_reads_migrated_database_rows --lib -- --nocapture
|
||||||
|
|
||||||
- name: Show sccache stats
|
- name: Show sccache stats
|
||||||
if: always()
|
if: always()
|
||||||
@@ -452,56 +569,56 @@ jobs:
|
|||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
AETHER_TEST_MYSQL_URL: mysql://aether:aether@127.0.0.1:3306/aether_test
|
AETHER_TEST_MYSQL_URL: mysql://aether:aether@127.0.0.1:3306/aether_test
|
||||||
run: cargo test -p aether-data mysql_migrations_create_core_config_tables_when_url_is_set --lib -- --nocapture
|
run: cargo test -p aether-data --all-features mysql_migrations_create_core_config_tables_when_url_is_set --lib -- --nocapture
|
||||||
|
|
||||||
- name: Run MySQL usage write smoke test
|
- name: Run MySQL usage write smoke test
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
AETHER_TEST_MYSQL_URL: mysql://aether:aether@127.0.0.1:3306/aether_test
|
AETHER_TEST_MYSQL_URL: mysql://aether:aether@127.0.0.1:3306/aether_test
|
||||||
run: cargo test -p aether-data mysql_usage_write_repository_upserts_when_url_is_set --lib -- --nocapture
|
run: cargo test -p aether-data-mysql mysql_usage_write_repository_upserts_when_url_is_set --lib -- --nocapture
|
||||||
|
|
||||||
- name: Run MySQL usage read smoke test
|
- name: Run MySQL usage read smoke test
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
AETHER_TEST_MYSQL_URL: mysql://aether:aether@127.0.0.1:3306/aether_test
|
AETHER_TEST_MYSQL_URL: mysql://aether:aether@127.0.0.1:3306/aether_test
|
||||||
run: cargo test -p aether-data mysql_usage_read_repository_reads_usage_contract_views_when_url_is_set --lib -- --nocapture
|
run: cargo test -p aether-data-mysql mysql_usage_read_repository_reads_usage_contract_views_when_url_is_set --lib -- --nocapture
|
||||||
|
|
||||||
- name: Run MySQL provider catalog smoke test
|
- name: Run MySQL provider catalog smoke test
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
AETHER_TEST_MYSQL_URL: mysql://aether:aether@127.0.0.1:3306/aether_test
|
AETHER_TEST_MYSQL_URL: mysql://aether:aether@127.0.0.1:3306/aether_test
|
||||||
run: cargo test -p aether-data mysql_provider_catalog_repository_round_trips_when_url_is_set --lib -- --nocapture
|
run: cargo test -p aether-data-mysql mysql_provider_catalog_repository_round_trips_when_url_is_set --lib -- --nocapture
|
||||||
|
|
||||||
- name: Run MySQL core export smoke test
|
- name: Run MySQL core export smoke test
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
AETHER_TEST_MYSQL_URL: mysql://aether:aether@127.0.0.1:3306/aether_test
|
AETHER_TEST_MYSQL_URL: mysql://aether:aether@127.0.0.1:3306/aether_test
|
||||||
run: cargo test -p aether-data mysql_core_export_reads_migrated_database_rows_when_url_is_set --lib -- --nocapture
|
run: cargo test -p aether-data --all-features mysql_core_export_reads_migrated_database_rows_when_url_is_set --lib -- --nocapture
|
||||||
|
|
||||||
- name: Run MySQL wallet read smoke test
|
- name: Run MySQL wallet read smoke test
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
AETHER_TEST_MYSQL_URL: mysql://aether:aether@127.0.0.1:3306/aether_test
|
AETHER_TEST_MYSQL_URL: mysql://aether:aether@127.0.0.1:3306/aether_test
|
||||||
run: cargo test -p aether-data mysql_wallet_read_repository_reads_wallet_contract_views --lib -- --nocapture
|
run: cargo test -p aether-data-mysql mysql_wallet_read_repository_reads_wallet_contract_views --lib -- --nocapture
|
||||||
|
|
||||||
- name: Run MySQL wallet daily usage aggregation smoke test
|
- name: Run MySQL wallet daily usage aggregation smoke test
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
AETHER_TEST_MYSQL_URL: mysql://aether:aether@127.0.0.1:3306/aether_test
|
AETHER_TEST_MYSQL_URL: mysql://aether:aether@127.0.0.1:3306/aether_test
|
||||||
run: cargo test -p aether-data mysql_wallet_daily_usage_aggregation_uses_settlement_wallets_when_url_is_set --lib -- --nocapture
|
run: cargo test -p aether-data --all-features mysql_wallet_daily_usage_aggregation_uses_settlement_wallets_when_url_is_set --lib -- --nocapture
|
||||||
|
|
||||||
- name: Run MySQL stats aggregation smoke test
|
- name: Run MySQL stats aggregation smoke test
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
AETHER_TEST_MYSQL_URL: mysql://aether:aether@127.0.0.1:3306/aether_test
|
AETHER_TEST_MYSQL_URL: mysql://aether:aether@127.0.0.1:3306/aether_test
|
||||||
run: cargo test -p aether-data mysql_stats_aggregation_runs_after_mysql_migrations_when_url_is_set --lib -- --nocapture
|
run: cargo test -p aether-data --all-features mysql_stats_aggregation_runs_after_mysql_migrations_when_url_is_set --lib -- --nocapture
|
||||||
|
|
||||||
- name: Show sccache stats
|
- name: Show sccache stats
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
Generated
+209
-17
@@ -69,6 +69,14 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aether-admission-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aether-ai-formats"
|
name = "aether-ai-formats"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -155,7 +163,9 @@ dependencies = [
|
|||||||
"aether-ai-formats",
|
"aether-ai-formats",
|
||||||
"aether-cache",
|
"aether-cache",
|
||||||
"aether-data-contracts",
|
"aether-data-contracts",
|
||||||
"aether-data-query",
|
"aether-data-mysql",
|
||||||
|
"aether-data-postgres",
|
||||||
|
"aether-data-sqlite",
|
||||||
"aether-wallet",
|
"aether-wallet",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -183,7 +193,47 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aether-data-mysql"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aether-ai-formats",
|
||||||
|
"aether-data-contracts",
|
||||||
|
"aether-data-query",
|
||||||
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
|
"chrono-tz",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"sqlx",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aether-data-postgres"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aether-ai-formats",
|
||||||
|
"aether-data-contracts",
|
||||||
|
"aether-data-query",
|
||||||
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
|
"chrono-tz",
|
||||||
|
"flate2",
|
||||||
|
"futures-util",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"sqlx",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -203,6 +253,25 @@ dependencies = [
|
|||||||
"toml",
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aether-data-sqlite"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aether-ai-formats",
|
||||||
|
"aether-data-contracts",
|
||||||
|
"aether-data-query",
|
||||||
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
|
"chrono-tz",
|
||||||
|
"flate2",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"sqlx",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aether-dispatch-core"
|
name = "aether-dispatch-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -229,6 +298,11 @@ dependencies = [
|
|||||||
"aether-data",
|
"aether-data",
|
||||||
"aether-data-contracts",
|
"aether-data-contracts",
|
||||||
"aether-dispatch-core",
|
"aether-dispatch-core",
|
||||||
|
"aether-gateway-control",
|
||||||
|
"aether-gateway-execution",
|
||||||
|
"aether-gateway-frontdoor",
|
||||||
|
"aether-gateway-tunnel",
|
||||||
|
"aether-gateway-workers",
|
||||||
"aether-http",
|
"aether-http",
|
||||||
"aether-model-fetch",
|
"aether-model-fetch",
|
||||||
"aether-oauth",
|
"aether-oauth",
|
||||||
@@ -240,7 +314,7 @@ dependencies = [
|
|||||||
"aether-runtime-state",
|
"aether-runtime-state",
|
||||||
"aether-scheduler-core",
|
"aether-scheduler-core",
|
||||||
"aether-task-runtime",
|
"aether-task-runtime",
|
||||||
"aether-testkit",
|
"aether-test-support",
|
||||||
"aether-usage-runtime",
|
"aether-usage-runtime",
|
||||||
"aether-video-tasks-core",
|
"aether-video-tasks-core",
|
||||||
"aether-wallet",
|
"aether-wallet",
|
||||||
@@ -295,6 +369,63 @@ dependencies = [
|
|||||||
"zstd",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aether-gateway-control"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aether-gateway-execution"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aether-contracts",
|
||||||
|
"bytes",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aether-gateway-frontdoor"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aether-ai-formats",
|
||||||
|
"axum",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"tower",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aether-gateway-tunnel"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aether-admission-core",
|
||||||
|
"aether-contracts",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aether-gateway-workers"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aether-runtime-state",
|
||||||
|
"aether-task-runtime",
|
||||||
|
"aether-test-support",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aether-http"
|
name = "aether-http"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -303,6 +434,48 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aether-integration-tests"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aether-contracts",
|
||||||
|
"aether-data",
|
||||||
|
"aether-data-contracts",
|
||||||
|
"aether-gateway",
|
||||||
|
"aether-runtime-state",
|
||||||
|
"aether-testkit",
|
||||||
|
"async-stream",
|
||||||
|
"axum",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"sqlx",
|
||||||
|
"tokio",
|
||||||
|
"tokio-tungstenite 0.28.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aether-loadtools"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aether-http",
|
||||||
|
"aether-runtime",
|
||||||
|
"aether-runtime-state",
|
||||||
|
"aether-test-support",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"libc",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sysinfo",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aether-model-fetch"
|
name = "aether-model-fetch"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -348,6 +521,14 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aether-provider-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aether-provider-pool"
|
name = "aether-provider-pool"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -447,11 +628,20 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aether-task-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aether-task-runtime"
|
name = "aether-task-runtime"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aether-runtime",
|
"aether-runtime",
|
||||||
|
"aether-task-core",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -459,32 +649,25 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aether-test-support"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aether-testkit"
|
name = "aether-testkit"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aether-contracts",
|
|
||||||
"aether-data",
|
"aether-data",
|
||||||
"aether-data-contracts",
|
|
||||||
"aether-gateway",
|
"aether-gateway",
|
||||||
"aether-http",
|
"aether-loadtools",
|
||||||
"aether-runtime",
|
"aether-runtime",
|
||||||
"aether-runtime-state",
|
"aether-runtime-state",
|
||||||
"async-stream",
|
|
||||||
"axum",
|
"axum",
|
||||||
"bytes",
|
|
||||||
"futures-util",
|
|
||||||
"http",
|
|
||||||
"libc",
|
|
||||||
"reqwest",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"sha2",
|
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"sysinfo",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.28.0",
|
|
||||||
"uuid",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -493,6 +676,7 @@ version = "0.3.16"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aether-contracts",
|
"aether-contracts",
|
||||||
"aether-gateway",
|
"aether-gateway",
|
||||||
|
"aether-gateway-tunnel",
|
||||||
"aether-http",
|
"aether-http",
|
||||||
"aether-runtime",
|
"aether-runtime",
|
||||||
"aether-runtime-state",
|
"aether-runtime-state",
|
||||||
@@ -531,6 +715,14 @@ dependencies = [
|
|||||||
"webpki-roots 0.26.11",
|
"webpki-roots 0.26.11",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aether-usage-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aether-usage-runtime"
|
name = "aether-usage-runtime"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
+57
-27
@@ -1,34 +1,49 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"apps/aether-tunnel",
|
"apps/aether-tunnel",
|
||||||
"crates/aether-ai-formats",
|
"crates/aether-ai/formats",
|
||||||
"crates/aether-admin",
|
"crates/aether-admin",
|
||||||
"crates/aether-ai-serving",
|
"crates/aether-admission-core",
|
||||||
|
"crates/aether-ai/serving",
|
||||||
"crates/aether-pool-core",
|
"crates/aether-pool-core",
|
||||||
"crates/aether-provider-pool",
|
"crates/aether-provider/core",
|
||||||
|
"crates/aether-provider/pool",
|
||||||
"crates/aether-routing-core",
|
"crates/aether-routing-core",
|
||||||
"crates/aether-data-contracts",
|
"crates/aether-data/contracts",
|
||||||
"crates/aether-data-query",
|
"crates/aether-data/adapters/postgres",
|
||||||
"crates/aether-data-schema",
|
"crates/aether-data/adapters/mysql",
|
||||||
|
"crates/aether-data/adapters/sqlite",
|
||||||
|
"crates/aether-data/query",
|
||||||
|
"crates/aether-data/schema",
|
||||||
"crates/aether-dispatch-core",
|
"crates/aether-dispatch-core",
|
||||||
"crates/aether-cache",
|
"crates/aether-cache",
|
||||||
"crates/aether-billing",
|
"crates/aether-billing",
|
||||||
"crates/aether-wallet",
|
"crates/aether-wallet",
|
||||||
"crates/aether-crypto",
|
"crates/aether-crypto",
|
||||||
"crates/aether-contracts",
|
"crates/aether-contracts",
|
||||||
"crates/aether-data",
|
"crates/aether-data/runtime",
|
||||||
"crates/aether-model-fetch",
|
"crates/aether-model-fetch",
|
||||||
"crates/aether-oauth",
|
"crates/aether-oauth",
|
||||||
"crates/aether-provider-transport",
|
"crates/aether-provider/transport",
|
||||||
"crates/aether-scheduler-core",
|
"crates/aether-scheduler-core",
|
||||||
"crates/aether-runtime-state",
|
"crates/aether-runtime/state",
|
||||||
"crates/aether-task-runtime",
|
"crates/aether-task/runtime",
|
||||||
"crates/aether-usage-runtime",
|
"crates/aether-task/core",
|
||||||
|
"crates/aether-gateway/frontdoor",
|
||||||
|
"crates/aether-gateway/control",
|
||||||
|
"crates/aether-gateway/execution",
|
||||||
|
"crates/aether-gateway/workers",
|
||||||
|
"crates/aether-gateway/tunnel",
|
||||||
|
"crates/aether-testing/loadtools",
|
||||||
|
"crates/aether-testing/integration",
|
||||||
|
"crates/aether-usage/core",
|
||||||
|
"crates/aether-testing/support",
|
||||||
|
"crates/aether-usage/runtime",
|
||||||
"crates/aether-video-tasks-core",
|
"crates/aether-video-tasks-core",
|
||||||
"apps/aether-gateway",
|
"apps/aether-gateway",
|
||||||
"crates/aether-http",
|
"crates/aether-http",
|
||||||
"crates/aether-runtime",
|
"crates/aether-runtime/base",
|
||||||
"crates/aether-testkit",
|
"crates/aether-testing/testkit",
|
||||||
]
|
]
|
||||||
default-members = [
|
default-members = [
|
||||||
"apps/aether-gateway",
|
"apps/aether-gateway",
|
||||||
@@ -42,33 +57,48 @@ repository = "https://github.com/fawney19/Aether.git"
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
aether-admin = { path = "crates/aether-admin" }
|
aether-admin = { path = "crates/aether-admin" }
|
||||||
aether-ai-formats = { path = "crates/aether-ai-formats" }
|
aether-admission-core = { path = "crates/aether-admission-core" }
|
||||||
aether-ai-serving = { path = "crates/aether-ai-serving" }
|
aether-ai-formats = { path = "crates/aether-ai/formats" }
|
||||||
|
aether-ai-serving = { path = "crates/aether-ai/serving" }
|
||||||
aether-pool-core = { path = "crates/aether-pool-core" }
|
aether-pool-core = { path = "crates/aether-pool-core" }
|
||||||
aether-provider-pool = { path = "crates/aether-provider-pool" }
|
aether-provider-core = { path = "crates/aether-provider/core" }
|
||||||
|
aether-provider-pool = { path = "crates/aether-provider/pool" }
|
||||||
aether-routing-core = { path = "crates/aether-routing-core" }
|
aether-routing-core = { path = "crates/aether-routing-core" }
|
||||||
aether-data-contracts = { path = "crates/aether-data-contracts" }
|
aether-data-contracts = { path = "crates/aether-data/contracts" }
|
||||||
aether-data-query = { path = "crates/aether-data-query" }
|
aether-data-postgres = { path = "crates/aether-data/adapters/postgres" }
|
||||||
aether-data-schema = { path = "crates/aether-data-schema" }
|
aether-data-mysql = { path = "crates/aether-data/adapters/mysql" }
|
||||||
|
aether-data-sqlite = { path = "crates/aether-data/adapters/sqlite" }
|
||||||
|
aether-data-query = { path = "crates/aether-data/query" }
|
||||||
|
aether-data-schema = { path = "crates/aether-data/schema" }
|
||||||
aether-dispatch-core = { path = "crates/aether-dispatch-core" }
|
aether-dispatch-core = { path = "crates/aether-dispatch-core" }
|
||||||
aether-cache = { path = "crates/aether-cache" }
|
aether-cache = { path = "crates/aether-cache" }
|
||||||
aether-billing = { path = "crates/aether-billing" }
|
aether-billing = { path = "crates/aether-billing" }
|
||||||
aether-wallet = { path = "crates/aether-wallet" }
|
aether-wallet = { path = "crates/aether-wallet" }
|
||||||
aether-crypto = { path = "crates/aether-crypto" }
|
aether-crypto = { path = "crates/aether-crypto" }
|
||||||
aether-contracts = { path = "crates/aether-contracts" }
|
aether-contracts = { path = "crates/aether-contracts" }
|
||||||
aether-data = { path = "crates/aether-data" }
|
aether-data = { path = "crates/aether-data/runtime" }
|
||||||
aether-model-fetch = { path = "crates/aether-model-fetch" }
|
aether-model-fetch = { path = "crates/aether-model-fetch" }
|
||||||
aether-oauth = { path = "crates/aether-oauth" }
|
aether-oauth = { path = "crates/aether-oauth" }
|
||||||
aether-provider-transport = { path = "crates/aether-provider-transport" }
|
aether-provider-transport = { path = "crates/aether-provider/transport" }
|
||||||
aether-scheduler-core = { path = "crates/aether-scheduler-core" }
|
aether-scheduler-core = { path = "crates/aether-scheduler-core" }
|
||||||
aether-runtime-state = { path = "crates/aether-runtime-state" }
|
aether-runtime-state = { path = "crates/aether-runtime/state" }
|
||||||
aether-task-runtime = { path = "crates/aether-task-runtime" }
|
aether-task-runtime = { path = "crates/aether-task/runtime" }
|
||||||
aether-usage-runtime = { path = "crates/aether-usage-runtime" }
|
aether-task-core = { path = "crates/aether-task/core" }
|
||||||
|
aether-gateway-frontdoor = { path = "crates/aether-gateway/frontdoor" }
|
||||||
|
aether-gateway-control = { path = "crates/aether-gateway/control" }
|
||||||
|
aether-gateway-execution = { path = "crates/aether-gateway/execution" }
|
||||||
|
aether-gateway-workers = { path = "crates/aether-gateway/workers" }
|
||||||
|
aether-gateway-tunnel = { path = "crates/aether-gateway/tunnel" }
|
||||||
|
aether-loadtools = { path = "crates/aether-testing/loadtools" }
|
||||||
|
aether-integration-tests = { path = "crates/aether-testing/integration" }
|
||||||
|
aether-test-support = { path = "crates/aether-testing/support" }
|
||||||
|
aether-usage-core = { path = "crates/aether-usage/core" }
|
||||||
|
aether-usage-runtime = { path = "crates/aether-usage/runtime" }
|
||||||
aether-video-tasks-core = { path = "crates/aether-video-tasks-core" }
|
aether-video-tasks-core = { path = "crates/aether-video-tasks-core" }
|
||||||
aether-gateway = { path = "apps/aether-gateway" }
|
aether-gateway = { path = "apps/aether-gateway" }
|
||||||
aether-http = { path = "crates/aether-http" }
|
aether-http = { path = "crates/aether-http" }
|
||||||
aether-runtime = { path = "crates/aether-runtime" }
|
aether-runtime = { path = "crates/aether-runtime/base" }
|
||||||
aether-testkit = { path = "crates/aether-testkit" }
|
aether-testkit = { path = "crates/aether-testing/testkit" }
|
||||||
aes = "0.8"
|
aes = "0.8"
|
||||||
aes-gcm = "0.10"
|
aes-gcm = "0.10"
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
@@ -97,7 +127,7 @@ serde_path_to_error = "0.1"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
socket2 = "0.6"
|
socket2 = "0.6"
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
sqlx = { version = "0.8", default-features = false, features = ["postgres", "mysql", "sqlite", "runtime-tokio-rustls", "chrono"] }
|
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "chrono"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
||||||
tokio-util = { version = "0.7", features = ["codec", "io-util"] }
|
tokio-util = { version = "0.7", features = ["codec", "io-util"] }
|
||||||
|
|||||||
@@ -24,10 +24,15 @@ aether-billing.workspace = true
|
|||||||
aether-cache.workspace = true
|
aether-cache.workspace = true
|
||||||
aether-contracts.workspace = true
|
aether-contracts.workspace = true
|
||||||
aether-crypto.workspace = true
|
aether-crypto.workspace = true
|
||||||
aether-data.workspace = true
|
aether-data = { workspace = true, features = ["all-drivers"] }
|
||||||
aether-data-contracts.workspace = true
|
aether-data-contracts.workspace = true
|
||||||
aether-dispatch-core.workspace = true
|
aether-dispatch-core.workspace = true
|
||||||
|
aether-gateway-frontdoor.workspace = true
|
||||||
|
aether-gateway-control.workspace = true
|
||||||
|
aether-gateway-execution.workspace = true
|
||||||
aether-http.workspace = true
|
aether-http.workspace = true
|
||||||
|
aether-gateway-workers.workspace = true
|
||||||
|
aether-gateway-tunnel.workspace = true
|
||||||
aether-model-fetch.workspace = true
|
aether-model-fetch.workspace = true
|
||||||
aether-oauth.workspace = true
|
aether-oauth.workspace = true
|
||||||
aether-pool-core.workspace = true
|
aether-pool-core.workspace = true
|
||||||
@@ -74,7 +79,7 @@ sha1 = "0.10"
|
|||||||
sha2 = { workspace = true, features = ["oid"] }
|
sha2 = { workspace = true, features = ["oid"] }
|
||||||
socket2.workspace = true
|
socket2.workspace = true
|
||||||
tar.workspace = true
|
tar.workspace = true
|
||||||
sqlx.workspace = true
|
sqlx = { workspace = true, features = ["postgres", "mysql", "sqlite", "migrate"] }
|
||||||
sysinfo = "0.32"
|
sysinfo = "0.32"
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
@@ -94,5 +99,5 @@ tikv-jemallocator = { version = "0.6", optional = true }
|
|||||||
tikv-jemalloc-sys = { version = "0.6", optional = true }
|
tikv-jemalloc-sys = { version = "0.6", optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
aether-testkit.workspace = true
|
aether-test-support.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|||||||
@@ -423,7 +423,7 @@ mod tests {
|
|||||||
.json_body
|
.json_body
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|body| body.get("stream").is_none()));
|
.is_some_and(|body| body.get("stream").is_none()));
|
||||||
assert!(built.plan.headers.get("accept").is_none());
|
assert!(!built.plan.headers.contains_key("accept"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -144,34 +144,38 @@ pub(crate) fn spawn_video_task_poller(state: AppState) -> Option<JoinHandle<()>>
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(tokio::spawn(async move {
|
Some(crate::task_runtime::spawn_singleton_worker(
|
||||||
let mut interval = tokio::time::interval(config.interval);
|
state,
|
||||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
crate::task_runtime::TASK_KEY_VIDEO_TASK_POLLER,
|
||||||
interval.tick().await;
|
move |state| async move {
|
||||||
let mut deferred_since = None;
|
let mut interval = tokio::time::interval(config.interval);
|
||||||
loop {
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
if state
|
let mut deferred_since = None;
|
||||||
.data
|
loop {
|
||||||
.should_defer_maintenance_for_database_pool_pressure(&mut deferred_since)
|
interval.tick().await;
|
||||||
{
|
if state
|
||||||
debug!(
|
.data
|
||||||
event_name = "video_task_poller_deferred",
|
.should_defer_maintenance_for_database_pool_pressure(&mut deferred_since)
|
||||||
log_type = "event",
|
{
|
||||||
"gateway video task poller deferred because database pool has no idle reserve"
|
debug!(
|
||||||
);
|
event_name = "video_task_poller_deferred",
|
||||||
continue;
|
log_type = "event",
|
||||||
|
"gateway video task poller deferred because database pool has no idle reserve"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(err) = poll_video_tasks_once(&state, config.batch_size).await {
|
||||||
|
warn!(
|
||||||
|
event_name = "video_task_poller_tick_failed",
|
||||||
|
log_type = "event",
|
||||||
|
error = ?err,
|
||||||
|
"gateway video task poller tick failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Err(err) = poll_video_tasks_once(&state, config.batch_size).await {
|
},
|
||||||
warn!(
|
))
|
||||||
event_name = "video_task_poller_tick_failed",
|
|
||||||
log_type = "event",
|
|
||||||
error = ?err,
|
|
||||||
"gateway video task poller tick failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_video_task_refresh_attempt(
|
async fn fetch_video_task_refresh_attempt(
|
||||||
|
|||||||
@@ -20,17 +20,21 @@ pub(crate) fn spawn_s3_backup_worker(app: AppState) -> Option<JoinHandle<()>> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(tokio::spawn(async move {
|
Some(crate::task_runtime::spawn_singleton_worker(
|
||||||
let mut interval = tokio::time::interval(S3_BACKUP_WORKER_INTERVAL);
|
app,
|
||||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
S3_BACKUP_WORKER_TASK_KEY,
|
||||||
interval.tick().await;
|
|app| async move {
|
||||||
loop {
|
let mut interval = tokio::time::interval(S3_BACKUP_WORKER_INTERVAL);
|
||||||
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
if let Err(error) = run_s3_backup_schedule_tick(&app, Utc::now()).await {
|
loop {
|
||||||
warn!(error = ?error, "S3 backup schedule tick failed");
|
interval.tick().await;
|
||||||
|
if let Err(error) = run_s3_backup_schedule_tick(&app, Utc::now()).await {
|
||||||
|
warn!(error = ?error, "S3 backup schedule tick failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_s3_backup_schedule_tick(
|
async fn run_s3_backup_schedule_tick(
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ async fn estimate_execution_plan_cost_upper_bound_usd(
|
|||||||
);
|
);
|
||||||
let ttl = state.frontdoor_runtime_guards.auth_capacity_cache_ttl;
|
let ttl = state.frontdoor_runtime_guards.auth_capacity_cache_ttl;
|
||||||
if ttl.is_zero() {
|
if ttl.is_zero() {
|
||||||
|
let _permit = state.acquire_auth_snapshot_load_gate().await?;
|
||||||
return calculate_execution_plan_cost_upper_bound(
|
return calculate_execution_plan_cost_upper_bound(
|
||||||
state,
|
state,
|
||||||
plan,
|
plan,
|
||||||
@@ -230,6 +231,7 @@ async fn estimate_execution_plan_cost_upper_bound_usd(
|
|||||||
state
|
state
|
||||||
.auth_request_cost_upper_bound_cache
|
.auth_request_cost_upper_bound_cache
|
||||||
.get_or_load(cache_key, ttl, || async {
|
.get_or_load(cache_key, ttl, || async {
|
||||||
|
let _permit = state.acquire_auth_snapshot_load_gate().await?;
|
||||||
calculate_execution_plan_cost_upper_bound(
|
calculate_execution_plan_cost_upper_bound(
|
||||||
state,
|
state,
|
||||||
plan,
|
plan,
|
||||||
@@ -499,9 +501,16 @@ async fn request_model_resolves_to_allowed_model(
|
|||||||
.model_directive_policy
|
.model_directive_policy
|
||||||
.resolve_reasoning(&api_format, Some(requested_model));
|
.resolve_reasoning(&api_format, Some(requested_model));
|
||||||
let routing_model = resolution.base_model().unwrap_or(requested_model);
|
let routing_model = resolution.base_model().unwrap_or(requested_model);
|
||||||
let rows = state
|
let rows = {
|
||||||
.list_minimal_candidate_selection_rows_for_api_format(&api_format)
|
// Model alias authorization runs before candidate planning, so its database read must
|
||||||
.await?;
|
// participate in the same foreground DB admission budget as the rest of auth. Keep
|
||||||
|
// the permit scoped to this one read; callers do not hold this gate, and releasing it
|
||||||
|
// here avoids carrying a DB permit through pure filtering or subsequent formats.
|
||||||
|
let _permit = state.acquire_auth_snapshot_load_gate().await?;
|
||||||
|
state
|
||||||
|
.list_minimal_candidate_selection_rows_for_api_format(&api_format)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
let matching_rows = rows
|
let matching_rows = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|row| {
|
.filter(|row| {
|
||||||
@@ -553,6 +562,7 @@ mod tests {
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use aether_contracts::{ExecutionPlan, RequestBody};
|
use aether_contracts::{ExecutionPlan, RequestBody};
|
||||||
use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelectionReadRepository;
|
use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelectionReadRepository;
|
||||||
@@ -564,6 +574,7 @@ mod tests {
|
|||||||
StoredMinimalCandidateSelectionRow, StoredProviderModelMapping,
|
StoredMinimalCandidateSelectionRow, StoredProviderModelMapping,
|
||||||
};
|
};
|
||||||
use aether_data_contracts::DataLayerError;
|
use aether_data_contracts::DataLayerError;
|
||||||
|
use aether_runtime::ConcurrencyGate;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use axum::body::Bytes;
|
use axum::body::Bytes;
|
||||||
use axum::http::{HeaderMap, Uri};
|
use axum::http::{HeaderMap, Uri};
|
||||||
@@ -896,6 +907,44 @@ mod tests {
|
|||||||
assert_eq!(rejection, None);
|
assert_eq!(rejection, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn model_alias_resolution_waits_for_auth_database_gate() {
|
||||||
|
let mut state = state_with_model_mapping();
|
||||||
|
state.auth_snapshot_load_gate = Some(Arc::new(ConcurrencyGate::new(
|
||||||
|
"test_auth_model_resolution",
|
||||||
|
1,
|
||||||
|
)));
|
||||||
|
let held = state
|
||||||
|
.acquire_auth_snapshot_load_gate()
|
||||||
|
.await
|
||||||
|
.expect("auth gate acquisition should succeed")
|
||||||
|
.expect("auth gate should be configured");
|
||||||
|
let decision = decision_with_allowed_models(vec!["gpt-5".to_string()]);
|
||||||
|
let uri: Uri = "/v1/chat/completions".parse().expect("uri should parse");
|
||||||
|
let headers = json_headers();
|
||||||
|
let body = Bytes::from_static(br#"{"model":"gpt-5.2","messages":[]}"#);
|
||||||
|
|
||||||
|
let blocked = tokio::time::timeout(
|
||||||
|
Duration::from_millis(25),
|
||||||
|
request_model_local_rejection(&state, Some(&decision), &uri, &headers, &body),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
blocked.is_err(),
|
||||||
|
"model alias candidate reads must wait for the auth DB gate"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(held);
|
||||||
|
let rejection = tokio::time::timeout(
|
||||||
|
Duration::from_secs(1),
|
||||||
|
request_model_local_rejection(&state, Some(&decision), &uri, &headers, &body),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("model alias resolution should resume after releasing the auth gate")
|
||||||
|
.expect("model rejection should resolve");
|
||||||
|
assert_eq!(rejection, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn model_rejection_allows_cross_format_provider_mapping_to_allowed_global_model() {
|
async fn model_rejection_allows_cross_format_provider_mapping_to_allowed_global_model() {
|
||||||
let mut row = sample_row_for_api_format("gemini:generate_content");
|
let mut row = sample_row_for_api_format("gemini:generate_content");
|
||||||
|
|||||||
@@ -1,59 +1,11 @@
|
|||||||
use axum::http::Uri;
|
use axum::http::Uri;
|
||||||
|
|
||||||
use crate::headers::header_value_str;
|
|
||||||
use crate::{AppState, GatewayError};
|
use crate::{AppState, GatewayError};
|
||||||
|
|
||||||
use super::{resolve_control_route, GatewayControlDecision};
|
use super::{resolve_control_route, GatewayControlDecision};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
pub(crate) type GatewayPublicRequestContext =
|
||||||
pub(crate) struct GatewayPublicRequestContext {
|
aether_gateway_control::PublicRequestContext<GatewayControlDecision>;
|
||||||
pub(crate) trace_id: String,
|
|
||||||
pub(crate) request_method: http::Method,
|
|
||||||
pub(crate) request_path: String,
|
|
||||||
pub(crate) request_query_string: Option<String>,
|
|
||||||
pub(crate) request_content_type: Option<String>,
|
|
||||||
pub(crate) host_header: Option<String>,
|
|
||||||
pub(crate) control_decision: Option<GatewayControlDecision>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GatewayPublicRequestContext {
|
|
||||||
pub(crate) fn from_request_parts(
|
|
||||||
trace_id: impl Into<String>,
|
|
||||||
method: &http::Method,
|
|
||||||
uri: &Uri,
|
|
||||||
headers: &http::HeaderMap,
|
|
||||||
control_decision: Option<GatewayControlDecision>,
|
|
||||||
) -> Self {
|
|
||||||
let request_path = if uri.path().starts_with('/') {
|
|
||||||
uri.path().to_string()
|
|
||||||
} else {
|
|
||||||
format!("/{}", uri.path())
|
|
||||||
};
|
|
||||||
let request_query_string = uri.query().map(ToOwned::to_owned);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
trace_id: trace_id.into(),
|
|
||||||
request_method: method.clone(),
|
|
||||||
request_path,
|
|
||||||
request_query_string,
|
|
||||||
request_content_type: header_value_str(headers, http::header::CONTENT_TYPE.as_str()),
|
|
||||||
host_header: header_value_str(headers, http::header::HOST.as_str()),
|
|
||||||
control_decision,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn request_path_and_query(&self) -> String {
|
|
||||||
if let Some(query) = self
|
|
||||||
.request_query_string
|
|
||||||
.as_deref()
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
{
|
|
||||||
format!("{}?{query}", self.request_path)
|
|
||||||
} else {
|
|
||||||
self.request_path.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn resolve_public_request_context(
|
pub(crate) async fn resolve_public_request_context(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
|||||||
@@ -89,4 +89,158 @@ impl GatewayDataConfig {
|
|||||||
postgres: self.postgres.clone(),
|
postgres: self.postgres.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn split_runtime_pools(&self) -> (Self, Option<Self>) {
|
||||||
|
let configured_background_max =
|
||||||
|
std::env::var("AETHER_GATEWAY_BACKGROUND_DB_MAX_CONNECTIONS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.trim().parse::<u32>().ok());
|
||||||
|
self.split_runtime_pools_with_background_max(configured_background_max)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the database capacity reserved for isolated background work, when enabled.
|
||||||
|
pub fn background_database_config(&self) -> Option<SqlDatabaseConfig> {
|
||||||
|
self.split_runtime_pools()
|
||||||
|
.1
|
||||||
|
.and_then(|config| config.database)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_runtime_pools_with_background_max(
|
||||||
|
&self,
|
||||||
|
configured_background_max: Option<u32>,
|
||||||
|
) -> (Self, Option<Self>) {
|
||||||
|
let Some(database) = self.database.as_ref() else {
|
||||||
|
return (self.clone(), None);
|
||||||
|
};
|
||||||
|
let total_max = database.pool.max_connections;
|
||||||
|
if total_max < 2
|
||||||
|
|| configured_background_max == Some(0)
|
||||||
|
|| is_private_sqlite_memory_database(database)
|
||||||
|
{
|
||||||
|
return (self.clone(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let default_background_max = (total_max / 5).clamp(1, 8);
|
||||||
|
let background_max = configured_background_max
|
||||||
|
.unwrap_or(default_background_max)
|
||||||
|
.clamp(1, total_max.saturating_sub(1));
|
||||||
|
let foreground_max = total_max.saturating_sub(background_max).max(1);
|
||||||
|
let total_min = database.pool.min_connections.min(total_max);
|
||||||
|
let background_min = u32::from(total_min > 1).min(background_max);
|
||||||
|
// The configured minimum protects foreground readiness. Isolating background work must
|
||||||
|
// not take one of those warm foreground connections away; the background pool receives
|
||||||
|
// its own single warm connection while the combined hard maximum remains unchanged.
|
||||||
|
let foreground_min = total_min.min(foreground_max);
|
||||||
|
|
||||||
|
(
|
||||||
|
self.with_pool_limits(foreground_min, foreground_max),
|
||||||
|
Some(self.with_pool_limits(background_min, background_max)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_pool_limits(&self, min_connections: u32, max_connections: u32) -> Self {
|
||||||
|
let mut config = self.clone();
|
||||||
|
if let Some(database) = config.database.as_mut() {
|
||||||
|
database.pool.min_connections = min_connections.min(max_connections);
|
||||||
|
database.pool.max_connections = max_connections;
|
||||||
|
}
|
||||||
|
if let Some(postgres) = config.postgres.as_mut() {
|
||||||
|
postgres.min_connections = min_connections.min(max_connections);
|
||||||
|
postgres.max_connections = max_connections;
|
||||||
|
}
|
||||||
|
config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_private_sqlite_memory_database(database: &aether_data::SqlDatabaseConfig) -> bool {
|
||||||
|
database.driver == aether_data::DatabaseDriver::Sqlite
|
||||||
|
&& matches!(database.url.trim(), "sqlite::memory:" | "sqlite://:memory:")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::GatewayDataConfig;
|
||||||
|
use aether_data::{DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_pool_split_preserves_total_connection_budget() {
|
||||||
|
let config = GatewayDataConfig::from_database_config(
|
||||||
|
SqlDatabaseConfig::new(
|
||||||
|
DatabaseDriver::Postgres,
|
||||||
|
"postgres://localhost/aether",
|
||||||
|
SqlPoolConfig {
|
||||||
|
min_connections: 4,
|
||||||
|
max_connections: 20,
|
||||||
|
..SqlPoolConfig::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("database config should be valid"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (foreground, background) = config.split_runtime_pools_with_background_max(Some(4));
|
||||||
|
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, 16);
|
||||||
|
assert_eq!(background.pool.max_connections, 4);
|
||||||
|
assert_eq!(
|
||||||
|
foreground.pool.max_connections + background.pool.max_connections,
|
||||||
|
20
|
||||||
|
);
|
||||||
|
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();
|
||||||
|
database.pool.max_connections = 1;
|
||||||
|
let config = GatewayDataConfig::from_database_config(database);
|
||||||
|
assert!(config
|
||||||
|
.split_runtime_pools_with_background_max(Some(1))
|
||||||
|
.1
|
||||||
|
.is_none());
|
||||||
|
|
||||||
|
let mut database = SqlDatabaseConfig::sqlite_default();
|
||||||
|
database.pool.max_connections = 8;
|
||||||
|
let config = GatewayDataConfig::from_database_config(database);
|
||||||
|
assert!(config
|
||||||
|
.split_runtime_pools_with_background_max(Some(0))
|
||||||
|
.1
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_pool_split_keeps_private_sqlite_memory_database_in_one_pool() {
|
||||||
|
for url in ["sqlite::memory:", "sqlite://:memory:"] {
|
||||||
|
let config = GatewayDataConfig::from_database_config(
|
||||||
|
SqlDatabaseConfig::new(
|
||||||
|
DatabaseDriver::Sqlite,
|
||||||
|
url,
|
||||||
|
SqlPoolConfig {
|
||||||
|
min_connections: 1,
|
||||||
|
max_connections: 8,
|
||||||
|
..SqlPoolConfig::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("sqlite memory database config should be valid"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (foreground, background) = config.split_runtime_pools_with_background_max(Some(2));
|
||||||
|
|
||||||
|
assert!(background.is_none(), "private SQLite URL {url} was split");
|
||||||
|
assert_eq!(
|
||||||
|
foreground
|
||||||
|
.database()
|
||||||
|
.expect("foreground database")
|
||||||
|
.pool
|
||||||
|
.max_connections,
|
||||||
|
8
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1818,24 +1818,11 @@ impl GatewayDataState {
|
|||||||
.effective_user_groups_for_user(&snapshot.user_id)
|
.effective_user_groups_for_user(&snapshot.user_id)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let allowed_providers =
|
let GatewayUserEffectiveListPolicies {
|
||||||
resolve_effective_list_policy(None, "unrestricted", &groups, |group| {
|
allowed_providers,
|
||||||
(
|
allowed_api_formats,
|
||||||
&group.allowed_providers_mode,
|
allowed_models,
|
||||||
group.allowed_providers.clone(),
|
} = resolve_group_effective_list_policies(&groups);
|
||||||
)
|
|
||||||
});
|
|
||||||
let allowed_api_formats =
|
|
||||||
resolve_effective_api_format_policy(None, "unrestricted", &groups, |group| {
|
|
||||||
(
|
|
||||||
&group.allowed_api_formats_mode,
|
|
||||||
group.allowed_api_formats.clone(),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
let allowed_models =
|
|
||||||
resolve_effective_list_policy(None, "unrestricted", &groups, |group| {
|
|
||||||
(&group.allowed_models_mode, group.allowed_models.clone())
|
|
||||||
});
|
|
||||||
let user_rate_limit = resolve_effective_rate_limit_policy(None, "system", &groups);
|
let user_rate_limit = resolve_effective_rate_limit_policy(None, "system", &groups);
|
||||||
snapshot.user_allowed_providers = allowed_providers;
|
snapshot.user_allowed_providers = allowed_providers;
|
||||||
snapshot.user_allowed_api_formats = allowed_api_formats;
|
snapshot.user_allowed_api_formats = allowed_api_formats;
|
||||||
@@ -1860,36 +1847,7 @@ impl GatewayDataState {
|
|||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
Ok(GatewayUserEffectiveListPolicies {
|
Ok(resolve_group_effective_list_policies(&groups))
|
||||||
allowed_providers: resolve_effective_list_policy(
|
|
||||||
user.allowed_providers.clone(),
|
|
||||||
&user.allowed_providers_mode,
|
|
||||||
&groups,
|
|
||||||
|group| {
|
|
||||||
(
|
|
||||||
&group.allowed_providers_mode,
|
|
||||||
group.allowed_providers.clone(),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
allowed_api_formats: resolve_effective_api_format_policy(
|
|
||||||
user.allowed_api_formats.clone(),
|
|
||||||
&user.allowed_api_formats_mode,
|
|
||||||
&groups,
|
|
||||||
|group| {
|
|
||||||
(
|
|
||||||
&group.allowed_api_formats_mode,
|
|
||||||
group.allowed_api_formats.clone(),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
allowed_models: resolve_effective_list_policy(
|
|
||||||
user.allowed_models.clone(),
|
|
||||||
&user.allowed_models_mode,
|
|
||||||
&groups,
|
|
||||||
|group| (&group.allowed_models_mode, group.allowed_models.clone()),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn effective_user_groups_for_user(
|
async fn effective_user_groups_for_user(
|
||||||
@@ -1979,6 +1937,35 @@ fn apply_admin_unrestricted_auth_snapshot(snapshot: &mut StoredAuthApiKeySnapsho
|
|||||||
snapshot.api_key_concurrent_limit = None;
|
snapshot.api_key_concurrent_limit = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-user list policy columns are retained only for legacy import/export compatibility.
|
||||||
|
// Runtime authorization and user-facing catalogs must both treat group policies as authoritative.
|
||||||
|
fn resolve_group_effective_list_policies(
|
||||||
|
groups: &[aether_data::repository::users::StoredUserGroup],
|
||||||
|
) -> GatewayUserEffectiveListPolicies {
|
||||||
|
GatewayUserEffectiveListPolicies {
|
||||||
|
allowed_providers: resolve_effective_list_policy(None, "unrestricted", groups, |group| {
|
||||||
|
(
|
||||||
|
&group.allowed_providers_mode,
|
||||||
|
group.allowed_providers.clone(),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
allowed_api_formats: resolve_effective_api_format_policy(
|
||||||
|
None,
|
||||||
|
"unrestricted",
|
||||||
|
groups,
|
||||||
|
|group| {
|
||||||
|
(
|
||||||
|
&group.allowed_api_formats_mode,
|
||||||
|
group.allowed_api_formats.clone(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
allowed_models: resolve_effective_list_policy(None, "unrestricted", groups, |group| {
|
||||||
|
(&group.allowed_models_mode, group.allowed_models.clone())
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_effective_list_policy(
|
fn resolve_effective_list_policy(
|
||||||
user_values: Option<Vec<String>>,
|
user_values: Option<Vec<String>>,
|
||||||
user_mode: &str,
|
user_mode: &str,
|
||||||
@@ -2592,8 +2579,9 @@ mod tests {
|
|||||||
Some("hash-user".to_string()),
|
Some("hash-user".to_string()),
|
||||||
snapshot,
|
snapshot,
|
||||||
)]));
|
)]));
|
||||||
|
let user = sample_auth_user("user-1", "user");
|
||||||
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
||||||
sample_auth_user("user-1", "user"),
|
user.clone()
|
||||||
]));
|
]));
|
||||||
let group = user_repository
|
let group = user_repository
|
||||||
.create_user_group(UpsertUserGroupRecord {
|
.create_user_group(UpsertUserGroupRecord {
|
||||||
@@ -2638,6 +2626,23 @@ mod tests {
|
|||||||
Some(&["claude-sonnet-4-5".to_string()][..])
|
Some(&["claude-sonnet-4-5".to_string()][..])
|
||||||
);
|
);
|
||||||
assert_eq!(resolved.user_rate_limit, Some(30));
|
assert_eq!(resolved.user_rate_limit, Some(30));
|
||||||
|
|
||||||
|
let catalog_policies = state
|
||||||
|
.resolve_user_effective_list_policies(&user)
|
||||||
|
.await
|
||||||
|
.expect("catalog policies should resolve");
|
||||||
|
assert_eq!(
|
||||||
|
catalog_policies.allowed_providers.as_deref(),
|
||||||
|
resolved.effective_allowed_providers()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
catalog_policies.allowed_api_formats.as_deref(),
|
||||||
|
resolved.effective_allowed_api_formats()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
catalog_policies.allowed_models.as_deref(),
|
||||||
|
resolved.effective_allowed_models()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,8 @@ use aether_data_contracts::repository::usage::{
|
|||||||
PendingUsageCleanupSummary, ProviderApiKeyWindowUsageRequest,
|
PendingUsageCleanupSummary, ProviderApiKeyWindowUsageRequest,
|
||||||
StoredProviderApiKeyWindowUsageSummary, StoredUsageDailySummary, UsageAuditListQuery,
|
StoredProviderApiKeyWindowUsageSummary, StoredUsageDailySummary, UsageAuditListQuery,
|
||||||
UsageCleanupExecutionMode, UsageCleanupSummary, UsageCleanupTargets, UsageCleanupWindow,
|
UsageCleanupExecutionMode, UsageCleanupSummary, UsageCleanupTargets, UsageCleanupWindow,
|
||||||
UsageCounterFlushSummary, UsageCounterHealthSnapshot, UsageDailyHeatmapQuery,
|
UsageCounterFlushSummary, UsageCounterHealthSnapshot, UsageCounterPendingHealthSnapshot,
|
||||||
|
UsageDailyHeatmapQuery,
|
||||||
};
|
};
|
||||||
use aether_runtime_state::RuntimeQueueStore;
|
use aether_runtime_state::RuntimeQueueStore;
|
||||||
use aether_video_tasks_core::read_data_backed_video_task_response;
|
use aether_video_tasks_core::read_data_backed_video_task_response;
|
||||||
@@ -152,6 +153,13 @@ impl GatewayDataState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn warm_database_pool(&self) -> Result<(), DataLayerError> {
|
||||||
|
match &self.backends {
|
||||||
|
Some(backends) => backends.warm_database_pool().await,
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn pending_database_backfills(
|
pub(crate) async fn pending_database_backfills(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<
|
) -> Result<
|
||||||
@@ -198,15 +206,22 @@ impl GatewayDataState {
|
|||||||
pub(crate) fn database_pool_summary_under_maintenance_pressure(
|
pub(crate) fn database_pool_summary_under_maintenance_pressure(
|
||||||
summary: &aether_data::DatabasePoolSummary,
|
summary: &aether_data::DatabasePoolSummary,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
summary.checked_out > 0 && summary.idle <= Self::maintenance_pool_idle_reserve(summary)
|
summary.checked_out > 0
|
||||||
|
&& Self::database_pool_available_capacity(summary)
|
||||||
|
<= Self::maintenance_pool_idle_reserve(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn database_pool_summary_under_usage_worker_pressure(
|
pub(crate) fn database_pool_summary_under_usage_worker_pressure(
|
||||||
summary: &aether_data::DatabasePoolSummary,
|
summary: &aether_data::DatabasePoolSummary,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
summary.checked_out > 0
|
summary.checked_out > 0
|
||||||
&& (summary.checked_out >= summary.max_connections as usize
|
&& Self::database_pool_available_capacity(summary)
|
||||||
|| summary.idle <= Self::usage_worker_pool_idle_reserve(summary))
|
<= Self::usage_worker_pool_idle_reserve(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn database_pool_available_capacity(summary: &aether_data::DatabasePoolSummary) -> usize {
|
||||||
|
let unopened = (summary.max_connections as usize).saturating_sub(summary.pool_size);
|
||||||
|
summary.idle.saturating_add(unopened)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn maintenance_pool_idle_reserve(
|
pub(crate) fn maintenance_pool_idle_reserve(
|
||||||
@@ -1353,6 +1368,15 @@ impl GatewayDataState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn read_usage_counter_pending_health(
|
||||||
|
&self,
|
||||||
|
) -> Result<UsageCounterPendingHealthSnapshot, DataLayerError> {
|
||||||
|
match &self.usage_reader {
|
||||||
|
Some(repository) => repository.read_usage_counter_pending_health().await,
|
||||||
|
None => Ok(UsageCounterPendingHealthSnapshot::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn summarize_usage_totals_by_user_ids(
|
pub(crate) async fn summarize_usage_totals_by_user_ids(
|
||||||
&self,
|
&self,
|
||||||
user_ids: &[String],
|
user_ids: &[String],
|
||||||
@@ -1439,6 +1463,22 @@ impl GatewayDataState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn summarize_dashboard_stats(
|
||||||
|
&self,
|
||||||
|
query: &aether_data_contracts::repository::usage::UsageDashboardSummaryQuery,
|
||||||
|
) -> Result<
|
||||||
|
aether_data_contracts::repository::usage::StoredUsageDashboardStatsSummary,
|
||||||
|
DataLayerError,
|
||||||
|
> {
|
||||||
|
match &self.usage_reader {
|
||||||
|
Some(repository) => repository.summarize_dashboard_stats(query).await,
|
||||||
|
None => Ok(
|
||||||
|
aether_data_contracts::repository::usage::StoredUsageDashboardStatsSummary::default(
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn list_dashboard_daily_breakdown(
|
pub(crate) async fn list_dashboard_daily_breakdown(
|
||||||
&self,
|
&self,
|
||||||
query: &aether_data_contracts::repository::usage::UsageDashboardDailyBreakdownQuery,
|
query: &aether_data_contracts::repository::usage::UsageDashboardDailyBreakdownQuery,
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ fn disabled_gateway_data_state_has_no_backends() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn maintenance_pool_pressure_keeps_idle_reserve_for_foreground_work() {
|
fn maintenance_pool_pressure_keeps_idle_reserve_for_foreground_work() {
|
||||||
let pressured = aether_data::DatabasePoolSummary {
|
let pool_can_still_grow = aether_data::DatabasePoolSummary {
|
||||||
driver: DatabaseDriver::Postgres,
|
driver: DatabaseDriver::Postgres,
|
||||||
checked_out: 6,
|
checked_out: 6,
|
||||||
pool_size: 6,
|
pool_size: 6,
|
||||||
@@ -62,7 +62,9 @@ fn maintenance_pool_pressure_keeps_idle_reserve_for_foreground_work() {
|
|||||||
max_connections: 20,
|
max_connections: 20,
|
||||||
usage_rate: 30.0,
|
usage_rate: 30.0,
|
||||||
};
|
};
|
||||||
assert!(GatewayDataState::database_pool_summary_under_maintenance_pressure(&pressured));
|
assert!(
|
||||||
|
!GatewayDataState::database_pool_summary_under_maintenance_pressure(&pool_can_still_grow)
|
||||||
|
);
|
||||||
|
|
||||||
let reserve_idle_left = aether_data::DatabasePoolSummary {
|
let reserve_idle_left = aether_data::DatabasePoolSummary {
|
||||||
driver: DatabaseDriver::Postgres,
|
driver: DatabaseDriver::Postgres,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
pub(crate) const MAX_ERROR_BODY_BYTES: usize = 16_384;
|
//! Compatibility facade for execution resource limits.
|
||||||
pub(crate) const MAX_STREAM_PREFETCH_FRAMES: usize = 5;
|
|
||||||
pub(crate) const MAX_STREAM_PREFETCH_BYTES: usize = 16_384;
|
pub(crate) use aether_gateway_execution::{
|
||||||
|
MAX_ERROR_BODY_BYTES, MAX_STREAM_PREFETCH_BYTES, MAX_STREAM_PREFETCH_FRAMES,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//! Compatibility facade for execution stream framing.
|
||||||
|
|
||||||
use std::io::Error as IoError;
|
use std::io::Error as IoError;
|
||||||
|
|
||||||
use aether_contracts::StreamFrame;
|
use aether_contracts::StreamFrame;
|
||||||
@@ -6,36 +8,10 @@ use axum::body::Bytes;
|
|||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
|
|
||||||
pub(crate) fn encode_stream_frame_ndjson(frame: &StreamFrame) -> Result<Bytes, IoError> {
|
pub(crate) fn encode_stream_frame_ndjson(frame: &StreamFrame) -> Result<Bytes, IoError> {
|
||||||
let mut raw = serde_json::to_vec(frame).map_err(|err| IoError::other(err.to_string()))?;
|
aether_gateway_execution::stream::encode_stream_frame_ndjson(frame)
|
||||||
raw.push(b'\n');
|
|
||||||
Ok(Bytes::from(raw))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn decode_stream_frame_ndjson(line: &[u8]) -> Result<StreamFrame, GatewayError> {
|
pub(crate) fn decode_stream_frame_ndjson(line: &[u8]) -> Result<StreamFrame, GatewayError> {
|
||||||
serde_json::from_slice(line).map_err(|err| GatewayError::Internal(err.to_string()))
|
aether_gateway_execution::stream::decode_stream_frame_ndjson(line)
|
||||||
}
|
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use aether_contracts::{StreamFramePayload, StreamFrameType};
|
|
||||||
|
|
||||||
use super::{decode_stream_frame_ndjson, encode_stream_frame_ndjson};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ndjson_round_trip_preserves_frame() {
|
|
||||||
let frame = aether_contracts::StreamFrame {
|
|
||||||
frame_type: StreamFrameType::Headers,
|
|
||||||
payload: StreamFramePayload::Headers {
|
|
||||||
status_code: 200,
|
|
||||||
headers: BTreeMap::from([("content-type".into(), "text/event-stream".into())]),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let raw = encode_stream_frame_ndjson(&frame).expect("frame should encode");
|
|
||||||
let decoded =
|
|
||||||
decode_stream_frame_ndjson(raw.trim_ascii_end()).expect("frame should decode");
|
|
||||||
assert_eq!(decoded, frame);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,6 +259,11 @@ pub(super) async fn maybe_build_local_admin_stats_analytics_response(
|
|||||||
"has_format_conversion",
|
"has_format_conversion",
|
||||||
),
|
),
|
||||||
slow_threshold_ms,
|
slow_threshold_ms,
|
||||||
|
include_timeline: query_param_optional_bool(
|
||||||
|
request_context.query_string(),
|
||||||
|
"include_timeline",
|
||||||
|
)
|
||||||
|
.unwrap_or(true),
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
return Ok(Some(build_admin_stats_provider_performance_response(
|
return Ok(Some(build_admin_stats_provider_performance_response(
|
||||||
|
|||||||
@@ -600,7 +600,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use aether_runtime_state::{RedisClientConfig, RuntimeState, RuntimeStateConfig};
|
use aether_runtime_state::{RedisClientConfig, RuntimeState, RuntimeStateConfig};
|
||||||
use aether_testkit::ManagedRedisServer;
|
use aether_test_support::ManagedRedisServer;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
async fn start_managed_redis_or_skip() -> Option<ManagedRedisServer> {
|
async fn start_managed_redis_or_skip() -> Option<ManagedRedisServer> {
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ use crate::api::response::build_local_http_error_response;
|
|||||||
use crate::control::GatewayPublicRequestContext;
|
use crate::control::GatewayPublicRequestContext;
|
||||||
use crate::headers::RequestBodyNormalizationError;
|
use crate::headers::RequestBodyNormalizationError;
|
||||||
use crate::{AppState, GatewayError};
|
use crate::{AppState, GatewayError};
|
||||||
use axum::body::{to_bytes, Body, Bytes};
|
use aether_gateway_frontdoor::{BodyBufferError, BodyBufferPolicy as FrontdoorBodyBufferPolicy};
|
||||||
|
use axum::body::{Body, Bytes};
|
||||||
use axum::http::{self, Response};
|
use axum::http::{self, Response};
|
||||||
use std::error::Error as StdError;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Duration;
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
@@ -16,23 +16,22 @@ const REQUEST_BODY_READ_FAILED_DETAIL: &str = "Failed to read request body";
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(super) struct RequestBodyBufferPolicy {
|
pub(super) struct RequestBodyBufferPolicy {
|
||||||
max_bytes: u64,
|
inner: FrontdoorBodyBufferPolicy,
|
||||||
read_timeout: Duration,
|
|
||||||
queue_timeout: Duration,
|
|
||||||
budget_bytes: usize,
|
|
||||||
budget: Arc<Semaphore>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RequestBodyBufferPolicy {
|
impl RequestBodyBufferPolicy {
|
||||||
pub(super) fn from_state(state: &AppState) -> Self {
|
pub(super) fn from_state(state: &AppState) -> Self {
|
||||||
Self {
|
Self {
|
||||||
max_bytes: crate::headers::max_request_body_bytes(),
|
inner: FrontdoorBodyBufferPolicy::with_permit_bytes(
|
||||||
read_timeout: state.frontdoor_runtime_guards.request_body_read_timeout,
|
crate::headers::max_request_body_bytes(),
|
||||||
queue_timeout: state.frontdoor_runtime_guards.internal_gate_queue_budget,
|
state.frontdoor_runtime_guards.request_body_read_timeout,
|
||||||
budget_bytes: state
|
state.frontdoor_runtime_guards.internal_gate_queue_budget,
|
||||||
.frontdoor_runtime_guards
|
state
|
||||||
.request_body_buffer_budget_bytes,
|
.frontdoor_runtime_guards
|
||||||
budget: Arc::clone(&state.request_body_buffer_budget),
|
.request_body_buffer_budget_bytes,
|
||||||
|
crate::state::REQUEST_BODY_BUFFER_PERMIT_BYTES,
|
||||||
|
Arc::clone(&state.request_body_buffer_budget),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,14 +41,17 @@ impl RequestBodyBufferPolicy {
|
|||||||
.unwrap_or(usize::MAX)
|
.unwrap_or(usize::MAX)
|
||||||
.max(crate::state::REQUEST_BODY_BUFFER_PERMIT_BYTES);
|
.max(crate::state::REQUEST_BODY_BUFFER_PERMIT_BYTES);
|
||||||
Self {
|
Self {
|
||||||
max_bytes,
|
inner: FrontdoorBodyBufferPolicy::with_permit_bytes(
|
||||||
read_timeout,
|
max_bytes,
|
||||||
queue_timeout: read_timeout,
|
read_timeout,
|
||||||
budget_bytes,
|
read_timeout,
|
||||||
budget: Arc::new(Semaphore::new(
|
budget_bytes,
|
||||||
budget_bytes.saturating_add(crate::state::REQUEST_BODY_BUFFER_PERMIT_BYTES - 1)
|
crate::state::REQUEST_BODY_BUFFER_PERMIT_BYTES,
|
||||||
/ crate::state::REQUEST_BODY_BUFFER_PERMIT_BYTES,
|
Arc::new(Semaphore::new(
|
||||||
)),
|
budget_bytes.saturating_add(crate::state::REQUEST_BODY_BUFFER_PERMIT_BYTES - 1)
|
||||||
|
/ crate::state::REQUEST_BODY_BUFFER_PERMIT_BYTES,
|
||||||
|
)),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,13 +64,35 @@ impl RequestBodyBufferPolicy {
|
|||||||
budget: Arc<Semaphore>,
|
budget: Arc<Semaphore>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
max_bytes,
|
inner: FrontdoorBodyBufferPolicy::with_permit_bytes(
|
||||||
read_timeout,
|
max_bytes,
|
||||||
queue_timeout,
|
read_timeout,
|
||||||
budget_bytes,
|
queue_timeout,
|
||||||
budget,
|
budget_bytes,
|
||||||
|
crate::state::REQUEST_BODY_BUFFER_PERMIT_BYTES,
|
||||||
|
budget,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn max_bytes(&self) -> u64 {
|
||||||
|
self.inner.max_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn budget_bytes(&self) -> usize {
|
||||||
|
self.inner.budget_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_timeout(&self) -> Duration {
|
||||||
|
self.inner.read_timeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reserve(
|
||||||
|
&self,
|
||||||
|
headers: &http::HeaderMap,
|
||||||
|
) -> Result<aether_gateway_frontdoor::BodyBufferReservation, BodyBufferError> {
|
||||||
|
self.inner.reserve(headers).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -135,30 +159,23 @@ impl RequestBodyBufferError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_body_buffer_reservation_bytes(headers: &http::HeaderMap, max_bytes: u64) -> usize {
|
impl From<BodyBufferError> for RequestBodyBufferError {
|
||||||
let max_bytes = usize::try_from(max_bytes).unwrap_or(usize::MAX);
|
fn from(error: BodyBufferError) -> Self {
|
||||||
let encoded = headers
|
match error {
|
||||||
.get(http::header::CONTENT_ENCODING)
|
BodyBufferError::TooLarge { limit_bytes } => Self::TooLarge { limit_bytes },
|
||||||
.and_then(|value| value.to_str().ok())
|
BodyBufferError::Overloaded {
|
||||||
.map(str::trim)
|
requested_bytes,
|
||||||
.is_some_and(|value| !value.is_empty() && !value.eq_ignore_ascii_case("identity"));
|
budget_bytes,
|
||||||
if encoded {
|
timeout_ms,
|
||||||
return max_bytes;
|
} => Self::Overloaded {
|
||||||
|
requested_bytes,
|
||||||
|
budget_bytes,
|
||||||
|
timeout_ms,
|
||||||
|
},
|
||||||
|
BodyBufferError::Timeout { timeout_ms } => Self::Timeout { timeout_ms },
|
||||||
|
BodyBufferError::ReadFailed { message } => Self::ReadFailed { message },
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers
|
|
||||||
.get(http::header::CONTENT_LENGTH)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.and_then(|value| value.trim().parse::<usize>().ok())
|
|
||||||
.map(|value| value.min(max_bytes))
|
|
||||||
.unwrap_or(max_bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn request_body_buffer_reservation_permits(reservation_bytes: usize) -> u32 {
|
|
||||||
let permits = reservation_bytes
|
|
||||||
.max(1)
|
|
||||||
.saturating_add(crate::state::REQUEST_BODY_BUFFER_PERMIT_BYTES - 1)
|
|
||||||
/ crate::state::REQUEST_BODY_BUFFER_PERMIT_BYTES;
|
|
||||||
u32::try_from(permits).unwrap_or(u32::MAX).max(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn buffer_and_normalize_request_body(
|
pub(super) async fn buffer_and_normalize_request_body(
|
||||||
@@ -171,33 +188,12 @@ pub(super) async fn buffer_and_normalize_request_body(
|
|||||||
phase: &'static str,
|
phase: &'static str,
|
||||||
policy: RequestBodyBufferPolicy,
|
policy: RequestBodyBufferPolicy,
|
||||||
) -> Result<Bytes, RequestBodyBufferError> {
|
) -> Result<Bytes, RequestBodyBufferError> {
|
||||||
if let Err(err) =
|
let reservation = policy
|
||||||
crate::headers::check_request_content_length_with_limit(headers, policy.max_bytes)
|
.reserve(headers)
|
||||||
{
|
.await
|
||||||
return Err(RequestBodyBufferError::Normalization(err));
|
.map_err(RequestBodyBufferError::from)?;
|
||||||
}
|
let reservation_bytes = reservation.requested_bytes();
|
||||||
|
|
||||||
let reservation_bytes = request_body_buffer_reservation_bytes(headers, policy.max_bytes);
|
|
||||||
let reservation_permits = request_body_buffer_reservation_permits(reservation_bytes);
|
|
||||||
let queue_timeout_ms = policy.queue_timeout.as_millis() as u64;
|
|
||||||
let _budget_permit = match tokio::time::timeout(
|
|
||||||
policy.queue_timeout,
|
|
||||||
Arc::clone(&policy.budget).acquire_many_owned(reservation_permits),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Ok(permit)) => permit,
|
|
||||||
Ok(Err(_)) | Err(_) => {
|
|
||||||
return Err(RequestBodyBufferError::Overloaded {
|
|
||||||
requested_bytes: reservation_bytes,
|
|
||||||
budget_bytes: policy.budget_bytes,
|
|
||||||
timeout_ms: queue_timeout_ms,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let read_started_at = Instant::now();
|
|
||||||
let timeout_ms = policy.read_timeout.as_millis() as u64;
|
|
||||||
info!(
|
info!(
|
||||||
event_name = "frontdoor_request_body_buffer_started",
|
event_name = "frontdoor_request_body_buffer_started",
|
||||||
log_type = "event",
|
log_type = "event",
|
||||||
@@ -205,45 +201,27 @@ pub(super) async fn buffer_and_normalize_request_body(
|
|||||||
method = %method,
|
method = %method,
|
||||||
path = %path_and_query,
|
path = %path_and_query,
|
||||||
phase,
|
phase,
|
||||||
max_body_bytes = policy.max_bytes,
|
max_body_bytes = policy.max_bytes(),
|
||||||
reserved_body_bytes = reservation_bytes,
|
reserved_body_bytes = reservation_bytes,
|
||||||
body_buffer_budget_bytes = policy.budget_bytes,
|
body_buffer_budget_bytes = policy.budget_bytes(),
|
||||||
timeout_ms,
|
timeout_ms = policy.read_timeout().as_millis() as u64,
|
||||||
"gateway started buffering request body"
|
"gateway started buffering request body"
|
||||||
);
|
);
|
||||||
|
|
||||||
let body_limit = usize::try_from(policy.max_bytes).unwrap_or(usize::MAX);
|
let buffered = reservation
|
||||||
let body = match tokio::time::timeout(
|
.collect(request_body.take().expect(body_owner_expectation))
|
||||||
policy.read_timeout,
|
.await
|
||||||
to_bytes(
|
.map_err(RequestBodyBufferError::from)?;
|
||||||
request_body.take().expect(body_owner_expectation),
|
let elapsed_ms = buffered.elapsed().as_millis() as u64;
|
||||||
body_limit,
|
let normalized = buffered
|
||||||
),
|
.try_map(|body| {
|
||||||
)
|
crate::headers::normalize_request_body_headers_and_bytes_with_limit(
|
||||||
.await
|
headers,
|
||||||
{
|
body,
|
||||||
Ok(Ok(body)) => body,
|
policy.max_bytes(),
|
||||||
Ok(Err(err)) if request_body_collection_exceeded_limit(&err) => {
|
)
|
||||||
return Err(RequestBodyBufferError::TooLarge {
|
})
|
||||||
limit_bytes: policy.max_bytes,
|
.map_err(RequestBodyBufferError::Normalization)?;
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(Err(err)) => {
|
|
||||||
return Err(RequestBodyBufferError::ReadFailed {
|
|
||||||
message: err.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
return Err(RequestBodyBufferError::Timeout { timeout_ms });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let normalized = crate::headers::normalize_request_body_headers_and_bytes_with_limit(
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
policy.max_bytes,
|
|
||||||
)
|
|
||||||
.map_err(RequestBodyBufferError::Normalization)?;
|
|
||||||
info!(
|
info!(
|
||||||
event_name = "frontdoor_request_body_buffer_completed",
|
event_name = "frontdoor_request_body_buffer_completed",
|
||||||
log_type = "event",
|
log_type = "event",
|
||||||
@@ -252,23 +230,12 @@ pub(super) async fn buffer_and_normalize_request_body(
|
|||||||
path = %path_and_query,
|
path = %path_and_query,
|
||||||
phase,
|
phase,
|
||||||
body_bytes = normalized.len(),
|
body_bytes = normalized.len(),
|
||||||
elapsed_ms = read_started_at.elapsed().as_millis() as u64,
|
elapsed_ms,
|
||||||
"gateway completed buffering request body"
|
"gateway completed buffering request body"
|
||||||
);
|
);
|
||||||
Ok(normalized)
|
Ok(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_body_collection_exceeded_limit(error: &(dyn StdError + 'static)) -> bool {
|
|
||||||
let mut current = Some(error);
|
|
||||||
while let Some(error) = current {
|
|
||||||
if error.to_string().contains("length limit exceeded") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
current = error.source();
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn build_request_body_buffer_error_response(
|
pub(super) fn build_request_body_buffer_error_response(
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
request_context: &GatewayPublicRequestContext,
|
request_context: &GatewayPublicRequestContext,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use super::{
|
|||||||
};
|
};
|
||||||
use aether_data_contracts::repository::usage::{
|
use aether_data_contracts::repository::usage::{
|
||||||
StoredUsageCostSavingsSummary, StoredUsageDashboardDailyBreakdownRow,
|
StoredUsageCostSavingsSummary, StoredUsageDashboardDailyBreakdownRow,
|
||||||
StoredUsageDashboardSummary, UsageAuditAggregationGroupBy, UsageAuditAggregationQuery,
|
StoredUsageDashboardStatsSummary, StoredUsageDashboardSummary, UsageAuditAggregationGroupBy,
|
||||||
UsageCostSavingsSummaryQuery, UsageDashboardDailyBreakdownQuery,
|
UsageAuditAggregationQuery, UsageDashboardDailyBreakdownQuery,
|
||||||
UsageDashboardProviderCountsQuery, UsageDashboardSummaryQuery,
|
UsageDashboardProviderCountsQuery, UsageDashboardSummaryQuery,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -550,6 +550,39 @@ async fn dashboard_summary_for_range(
|
|||||||
dashboard_summary_for_range_raw(state, range, user_id, error_context).await
|
dashboard_summary_for_range_raw(state, range, user_id, error_context).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn dashboard_stats_for_range(
|
||||||
|
state: &AppState,
|
||||||
|
range: DashboardDateRange,
|
||||||
|
user_id: Option<&str>,
|
||||||
|
error_context: &str,
|
||||||
|
) -> Result<StoredUsageDashboardStatsSummary, Response<Body>> {
|
||||||
|
let Some((created_from_unix_secs, created_until_unix_secs)) =
|
||||||
|
dashboard_range_bounds_unix(range)
|
||||||
|
else {
|
||||||
|
return Err(build_auth_error_response(
|
||||||
|
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("{error_context}: invalid time range"),
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
match state
|
||||||
|
.summarize_dashboard_stats(&UsageDashboardSummaryQuery {
|
||||||
|
created_from_unix_secs,
|
||||||
|
created_until_unix_secs,
|
||||||
|
user_id: user_id.map(ToOwned::to_owned),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(value) => Ok(value),
|
||||||
|
Err(err) => Err(build_auth_error_response(
|
||||||
|
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("{error_context}: {err:?}"),
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn dashboard_daily_breakdown_for_range(
|
async fn dashboard_daily_breakdown_for_range(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
range: DashboardDateRange,
|
range: DashboardDateRange,
|
||||||
@@ -855,28 +888,6 @@ fn dashboard_cache_savings_usd(summary: &StoredUsageCostSavingsSummary) -> f64 {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn dashboard_load_cache_savings(
|
|
||||||
state: &AppState,
|
|
||||||
range: DashboardDateRange,
|
|
||||||
user_id: Option<&str>,
|
|
||||||
) -> Result<f64, GatewayError> {
|
|
||||||
let Some((created_from_unix_secs, created_until_unix_secs)) =
|
|
||||||
dashboard_range_bounds_unix(range)
|
|
||||||
else {
|
|
||||||
return Ok(0.0);
|
|
||||||
};
|
|
||||||
let summary = state
|
|
||||||
.summarize_usage_cost_savings(&UsageCostSavingsSummaryQuery {
|
|
||||||
created_from_unix_secs,
|
|
||||||
created_until_unix_secs,
|
|
||||||
user_id: user_id.map(ToOwned::to_owned),
|
|
||||||
provider_name: None,
|
|
||||||
model: None,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
Ok(dashboard_cache_savings_usd(&summary))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn handle_dashboard_stats_get(
|
pub(super) async fn handle_dashboard_stats_get(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
request_context: &GatewayPublicRequestContext,
|
request_context: &GatewayPublicRequestContext,
|
||||||
@@ -902,7 +913,7 @@ pub(super) async fn handle_dashboard_stats_get(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
let cache_key = format!("stats:{cache_identity}:{query_string}");
|
let cache_key = format!("stats:{cache_identity}:{query_string}");
|
||||||
let cache_ttl = std::time::Duration::from_secs(15);
|
let cache_ttl = std::time::Duration::from_secs(30);
|
||||||
|
|
||||||
if let Some(cached) = state.dashboard_response_cache.get(&cache_key, cache_ttl) {
|
if let Some(cached) = state.dashboard_response_cache.get(&cache_key, cache_ttl) {
|
||||||
return Response::builder()
|
return Response::builder()
|
||||||
@@ -924,31 +935,63 @@ pub(super) async fn handle_dashboard_stats_get(
|
|||||||
tz_offset_minutes: summary_range.tz_offset_minutes,
|
tz_offset_minutes: summary_range.tz_offset_minutes,
|
||||||
};
|
};
|
||||||
let user_filter = (!is_admin).then_some(auth.user.id.as_str());
|
let user_filter = (!is_admin).then_some(auth.user.id.as_str());
|
||||||
let period_summary = match dashboard_summary_for_range(
|
let (period_totals, today_totals, admin_cost_savings) = if is_admin {
|
||||||
state,
|
let (period_result, today_result) = tokio::join!(
|
||||||
summary_range,
|
dashboard_stats_for_range(
|
||||||
user_filter,
|
state,
|
||||||
"dashboard stats lookup failed",
|
summary_range,
|
||||||
)
|
user_filter,
|
||||||
.await
|
"dashboard stats lookup failed",
|
||||||
{
|
),
|
||||||
Ok(value) => value,
|
dashboard_stats_for_range(
|
||||||
Err(response) => return response,
|
state,
|
||||||
|
today_range,
|
||||||
|
user_filter,
|
||||||
|
"dashboard today stats lookup failed",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let period = match period_result {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(response) => return response,
|
||||||
|
};
|
||||||
|
let today = match today_result {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(response) => return response,
|
||||||
|
};
|
||||||
|
(
|
||||||
|
dashboard_usage_totals_from_summary(&period.usage),
|
||||||
|
dashboard_usage_totals_from_summary(&today.usage),
|
||||||
|
Some((period.cost_savings, today.cost_savings)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let period_summary = match dashboard_summary_for_range(
|
||||||
|
state,
|
||||||
|
summary_range,
|
||||||
|
user_filter,
|
||||||
|
"dashboard stats lookup failed",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(response) => return response,
|
||||||
|
};
|
||||||
|
let today_summary = match dashboard_summary_for_range(
|
||||||
|
state,
|
||||||
|
today_range,
|
||||||
|
user_filter,
|
||||||
|
"dashboard today stats lookup failed",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(response) => return response,
|
||||||
|
};
|
||||||
|
(
|
||||||
|
dashboard_usage_totals_from_summary(&period_summary),
|
||||||
|
dashboard_usage_totals_from_summary(&today_summary),
|
||||||
|
None,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
let today_summary = match dashboard_summary_for_range(
|
|
||||||
state,
|
|
||||||
today_range,
|
|
||||||
user_filter,
|
|
||||||
"dashboard today stats lookup failed",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
|
|
||||||
let period_totals = dashboard_usage_totals_from_summary(&period_summary);
|
|
||||||
let today_totals = dashboard_usage_totals_from_summary(&today_summary);
|
|
||||||
|
|
||||||
let api_key_counts = match dashboard_load_api_key_counts(state, is_admin, &auth.user.id).await {
|
let api_key_counts = match dashboard_load_api_key_counts(state, is_admin, &auth.user.id).await {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
@@ -1030,28 +1073,10 @@ pub(super) async fn handle_dashboard_stats_get(
|
|||||||
/ today_totals.requests as f64
|
/ today_totals.requests as f64
|
||||||
* 100.0
|
* 100.0
|
||||||
};
|
};
|
||||||
let today_cost_savings =
|
let (period_cost_savings_summary, today_cost_savings_summary) =
|
||||||
match dashboard_load_cache_savings(state, today_range, user_filter).await {
|
admin_cost_savings.unwrap_or_default();
|
||||||
Ok(value) => value,
|
let today_cost_savings = dashboard_cache_savings_usd(&today_cost_savings_summary);
|
||||||
Err(err) => {
|
let period_cost_savings = dashboard_cache_savings_usd(&period_cost_savings_summary);
|
||||||
return build_auth_error_response(
|
|
||||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("dashboard today cache savings lookup failed: {err:?}"),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let period_cost_savings =
|
|
||||||
match dashboard_load_cache_savings(state, summary_range, user_filter).await {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(err) => {
|
|
||||||
return build_auth_error_response(
|
|
||||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("dashboard cache savings lookup failed: {err:?}"),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let stats = json!([
|
let stats = json!([
|
||||||
{
|
{
|
||||||
"name": "今日请求 / 费用",
|
"name": "今日请求 / 费用",
|
||||||
@@ -1248,7 +1273,7 @@ pub(super) async fn handle_dashboard_daily_stats_get(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
let cache_key = format!("daily:{cache_identity}:{query_string}");
|
let cache_key = format!("daily:{cache_identity}:{query_string}");
|
||||||
let cache_ttl = std::time::Duration::from_secs(30);
|
let cache_ttl = std::time::Duration::from_secs(60);
|
||||||
|
|
||||||
if let Some(cached) = state.dashboard_response_cache.get(&cache_key, cache_ttl) {
|
if let Some(cached) = state.dashboard_response_cache.get(&cache_key, cache_ttl) {
|
||||||
return Response::builder()
|
return Response::builder()
|
||||||
|
|||||||
@@ -1,74 +1,3 @@
|
|||||||
pub(crate) fn short_request_id(value: &str) -> String {
|
//! Compatibility facade for request identifier formatting.
|
||||||
let trimmed = value.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return "-".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
if trimmed.chars().count() <= 12 {
|
pub(crate) use aether_gateway_frontdoor::short_request_id;
|
||||||
return trimmed.to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
if looks_like_uuid(trimmed) {
|
|
||||||
return trimmed.chars().take(8).collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
let prefix: String = trimmed.chars().take(6).collect();
|
|
||||||
let suffix: String = trimmed
|
|
||||||
.chars()
|
|
||||||
.rev()
|
|
||||||
.take(4)
|
|
||||||
.collect::<String>()
|
|
||||||
.chars()
|
|
||||||
.rev()
|
|
||||||
.collect();
|
|
||||||
format!("{prefix}...{suffix}")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn looks_like_uuid(value: &str) -> bool {
|
|
||||||
let bytes = value.as_bytes();
|
|
||||||
if bytes.len() != 36 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (index, byte) in bytes.iter().enumerate() {
|
|
||||||
let is_hyphen = matches!(index, 8 | 13 | 18 | 23);
|
|
||||||
if is_hyphen {
|
|
||||||
if *byte != b'-' {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !byte.is_ascii_hexdigit() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::short_request_id;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shortens_uuid_like_request_ids_to_prefix() {
|
|
||||||
assert_eq!(
|
|
||||||
short_request_id("d07e1e94-41b8-409f-a18a-27993ae7ecb1"),
|
|
||||||
"d07e1e94"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shortens_long_named_request_ids_to_prefix_and_suffix() {
|
|
||||||
assert_eq!(
|
|
||||||
short_request_id("trace-openai-cli-stream-sync-direct-123"),
|
|
||||||
"trace-...-123"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_short_request_ids() {
|
|
||||||
assert_eq!(short_request_id("req-123"), "req-123");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ impl NodeRoleArg {
|
|||||||
const fn spawns_background_tasks(self) -> bool {
|
const fn spawns_background_tasks(self) -> bool {
|
||||||
matches!(self, Self::All | Self::Background)
|
matches!(self, Self::All | Self::Background)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fn isolates_background_database(self) -> bool {
|
||||||
|
matches!(self, Self::All)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
||||||
@@ -358,6 +362,18 @@ fn usage_queue_workers_for_request_concurrency(request_concurrency: usize) -> us
|
|||||||
workers.clamp(AUTO_USAGE_QUEUE_WORKERS_MIN, MAX_USAGE_QUEUE_WORKERS)
|
workers.clamp(AUTO_USAGE_QUEUE_WORKERS_MIN, MAX_USAGE_QUEUE_WORKERS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn usage_database_config_for_role<'a>(
|
||||||
|
node_role: NodeRoleArg,
|
||||||
|
database: Option<&'a SqlDatabaseConfig>,
|
||||||
|
isolated_background_database: Option<&'a SqlDatabaseConfig>,
|
||||||
|
) -> Option<&'a SqlDatabaseConfig> {
|
||||||
|
if node_role.isolates_background_database() {
|
||||||
|
isolated_background_database.or(database)
|
||||||
|
} else {
|
||||||
|
database
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn usage_queue_worker_database_cap(
|
fn usage_queue_worker_database_cap(
|
||||||
node_role: NodeRoleArg,
|
node_role: NodeRoleArg,
|
||||||
database: Option<&SqlDatabaseConfig>,
|
database: Option<&SqlDatabaseConfig>,
|
||||||
@@ -1279,7 +1295,7 @@ struct Args {
|
|||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
env = "AETHER_RUNTIME_COMMAND_TIMEOUT_MS",
|
env = "AETHER_RUNTIME_COMMAND_TIMEOUT_MS",
|
||||||
default_value_t = 1_000
|
default_value_t = 2_000
|
||||||
)]
|
)]
|
||||||
runtime_command_timeout_ms: u64,
|
runtime_command_timeout_ms: u64,
|
||||||
|
|
||||||
@@ -1710,6 +1726,18 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
runtime_redis_url.as_deref(),
|
runtime_redis_url.as_deref(),
|
||||||
runtime_backend,
|
runtime_backend,
|
||||||
)?;
|
)?;
|
||||||
|
let data_config = args.data.to_config();
|
||||||
|
let isolate_background_database = args.node_role.isolates_background_database();
|
||||||
|
let background_database_config = if isolate_background_database {
|
||||||
|
data_config.background_database_config()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let usage_database_config = usage_database_config_for_role(
|
||||||
|
args.node_role,
|
||||||
|
data_config.database(),
|
||||||
|
background_database_config.as_ref(),
|
||||||
|
);
|
||||||
let request_concurrency_limit = args
|
let request_concurrency_limit = args
|
||||||
.max_in_flight_requests
|
.max_in_flight_requests
|
||||||
.filter(|limit| *limit > 0)
|
.filter(|limit| *limit > 0)
|
||||||
@@ -1728,16 +1756,16 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
args.node_role,
|
args.node_role,
|
||||||
Some(request_concurrency_limit),
|
Some(request_concurrency_limit),
|
||||||
args.distributed_request_limit,
|
args.distributed_request_limit,
|
||||||
sql_database_config.as_ref(),
|
usage_database_config,
|
||||||
);
|
);
|
||||||
let usage_queue_worker_max_count = args.usage.effective_queue_worker_max_count(
|
let usage_queue_worker_max_count = args.usage.effective_queue_worker_max_count(
|
||||||
args.node_role,
|
args.node_role,
|
||||||
sql_database_config.as_ref(),
|
usage_database_config,
|
||||||
usage_queue_workers,
|
usage_queue_workers,
|
||||||
);
|
);
|
||||||
let usage_worker_record_concurrency_limit = args
|
let usage_worker_record_concurrency_limit = args
|
||||||
.usage
|
.usage
|
||||||
.effective_worker_record_concurrency_limit(args.node_role, sql_database_config.as_ref());
|
.effective_worker_record_concurrency_limit(args.node_role, usage_database_config);
|
||||||
let usage_config = args.usage.to_config(
|
let usage_config = args.usage.to_config(
|
||||||
usage_queue_workers,
|
usage_queue_workers,
|
||||||
usage_queue_worker_max_count,
|
usage_queue_worker_max_count,
|
||||||
@@ -1745,7 +1773,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
);
|
);
|
||||||
let usage_blocking_stream_lanes = args.usage.runtime_state_blocking_stream_lanes(
|
let usage_blocking_stream_lanes = args.usage.runtime_state_blocking_stream_lanes(
|
||||||
args.node_role,
|
args.node_role,
|
||||||
sql_database_config.as_ref(),
|
usage_database_config,
|
||||||
usage_config.worker_max_count,
|
usage_config.worker_max_count,
|
||||||
);
|
);
|
||||||
let runtime_state = Arc::new(
|
let runtime_state = Arc::new(
|
||||||
@@ -1757,7 +1785,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.await
|
.await
|
||||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err.to_string()))?,
|
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err.to_string()))?,
|
||||||
);
|
);
|
||||||
let data_config = args.data.to_config();
|
|
||||||
let rate_limit_config = if matches!(args.deployment_topology, DeploymentTopologyArg::MultiNode)
|
let rate_limit_config = if matches!(args.deployment_topology, DeploymentTopologyArg::MultiNode)
|
||||||
{
|
{
|
||||||
args.rate_limit.config().with_local_fallback(false)
|
args.rate_limit.config().with_local_fallback(false)
|
||||||
@@ -1859,7 +1886,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
let mut state = AppState::new()?
|
let mut state = AppState::new()?
|
||||||
.with_runtime_state(runtime_state)
|
.with_runtime_state(runtime_state)
|
||||||
.with_data_config(data_config)?
|
.with_data_config_and_background_isolation(data_config, isolate_background_database)?
|
||||||
.with_usage_runtime_config(usage_config)?
|
.with_usage_runtime_config(usage_config)?
|
||||||
.with_video_task_truth_source_mode(args.video_task_truth_source_mode.into());
|
.with_video_task_truth_source_mode(args.video_task_truth_source_mode.into());
|
||||||
if let Some(cors_config) = args.frontdoor.cors_config() {
|
if let Some(cors_config) = args.frontdoor.cors_config() {
|
||||||
@@ -1927,6 +1954,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
"aether-gateway data layer configured"
|
"aether-gateway data layer configured"
|
||||||
);
|
);
|
||||||
prepare_database_startup_requirements(&state, args.auto_prepare_database).await?;
|
prepare_database_startup_requirements(&state, args.auto_prepare_database).await?;
|
||||||
|
state.warm_database_pools().await?;
|
||||||
let reset_stale_proxy_nodes = state.reset_stale_proxy_node_tunnel_statuses().await?;
|
let reset_stale_proxy_nodes = state.reset_stale_proxy_node_tunnel_statuses().await?;
|
||||||
if reset_stale_proxy_nodes > 0 {
|
if reset_stale_proxy_nodes > 0 {
|
||||||
info!(
|
info!(
|
||||||
@@ -1986,6 +2014,13 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
);
|
);
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
if state.prewarm_metric_snapshot().await {
|
||||||
|
info!("gateway metric snapshot prewarmed");
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
"gateway metric snapshot prewarm did not complete; continuing with fail-open metrics"
|
||||||
|
);
|
||||||
|
}
|
||||||
let listen_backlog = gateway_listen_backlog(args.listen_backlog);
|
let listen_backlog = gateway_listen_backlog(args.listen_backlog);
|
||||||
let listener_shards = gateway_listener_shards(args.listener_shards);
|
let listener_shards = gateway_listener_shards(args.listener_shards);
|
||||||
let listeners = gateway_listeners(bind_addr, listen_backlog, listener_shards)?;
|
let listeners = gateway_listeners(bind_addr, listen_backlog, listener_shards)?;
|
||||||
@@ -2378,15 +2413,15 @@ mod tests {
|
|||||||
automatic_gateway_request_concurrency_for_parallelism, automatic_sql_pool_config,
|
automatic_gateway_request_concurrency_for_parallelism, automatic_sql_pool_config,
|
||||||
automatic_sql_pool_config_for_parallelism, automatic_usage_queue_workers_for_parallelism,
|
automatic_sql_pool_config_for_parallelism, automatic_usage_queue_workers_for_parallelism,
|
||||||
ensure_database_backfills_are_current, ensure_database_schema_is_current,
|
ensure_database_backfills_are_current, ensure_database_schema_is_current,
|
||||||
pending_backfills_error, pending_schema_error, resolve_healthcheck_url, Args,
|
pending_backfills_error, pending_schema_error, resolve_healthcheck_url,
|
||||||
DatabaseDriverArg, DeploymentTopologyArg, GatewayDataArgs, GatewayFrontdoorArgs,
|
usage_database_config_for_role, Args, DatabaseDriverArg, DeploymentTopologyArg,
|
||||||
GatewayLogDestinationArg, GatewayLogFormatArg, GatewayLogRotationArg, GatewayLoggingArgs,
|
GatewayDataArgs, GatewayFrontdoorArgs, GatewayLogDestinationArg, GatewayLogFormatArg,
|
||||||
GatewayRateLimitArgs, GatewayUsageArgs, NodeRoleArg, RuntimeBackendArg,
|
GatewayLogRotationArg, GatewayLoggingArgs, GatewayRateLimitArgs, GatewayUsageArgs,
|
||||||
VideoTaskTruthSourceArg, DEFAULT_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS,
|
NodeRoleArg, RuntimeBackendArg, VideoTaskTruthSourceArg,
|
||||||
DEFAULT_GATEWAY_LISTENER_SHARDS, DEFAULT_GATEWAY_LISTEN_BACKLOG,
|
DEFAULT_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS, DEFAULT_GATEWAY_LISTENER_SHARDS,
|
||||||
MAX_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS, MAX_GATEWAY_LISTENER_SHARDS,
|
DEFAULT_GATEWAY_LISTEN_BACKLOG, MAX_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS,
|
||||||
MAX_GATEWAY_LISTEN_BACKLOG, MIN_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS,
|
MAX_GATEWAY_LISTENER_SHARDS, MAX_GATEWAY_LISTEN_BACKLOG,
|
||||||
MIN_GATEWAY_LISTEN_BACKLOG,
|
MIN_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS, MIN_GATEWAY_LISTEN_BACKLOG,
|
||||||
};
|
};
|
||||||
use aether_data::{DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig};
|
use aether_data::{DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig};
|
||||||
use aether_gateway::AppState;
|
use aether_gateway::AppState;
|
||||||
@@ -2695,6 +2730,36 @@ mod tests {
|
|||||||
assert_eq!(many_cpu.max_connections, 100);
|
assert_eq!(many_cpu.max_connections, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gateway_database_pool_isolation_and_usage_capacity_follow_role() {
|
||||||
|
assert!(NodeRoleArg::All.isolates_background_database());
|
||||||
|
assert!(!NodeRoleArg::Frontdoor.isolates_background_database());
|
||||||
|
assert!(!NodeRoleArg::Background.isolates_background_database());
|
||||||
|
|
||||||
|
let database = test_database(DatabaseDriver::Postgres, 20);
|
||||||
|
let isolated_background = test_database(DatabaseDriver::Postgres, 4);
|
||||||
|
assert_eq!(
|
||||||
|
usage_database_config_for_role(
|
||||||
|
NodeRoleArg::All,
|
||||||
|
Some(&database),
|
||||||
|
Some(&isolated_background),
|
||||||
|
)
|
||||||
|
.expect("all-role usage database")
|
||||||
|
.pool
|
||||||
|
.max_connections,
|
||||||
|
4
|
||||||
|
);
|
||||||
|
for role in [NodeRoleArg::Frontdoor, NodeRoleArg::Background] {
|
||||||
|
assert_eq!(
|
||||||
|
usage_database_config_for_role(role, Some(&database), Some(&isolated_background),)
|
||||||
|
.expect("single-pool role usage database")
|
||||||
|
.pool
|
||||||
|
.max_connections,
|
||||||
|
20
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gateway_usage_queue_workers_manual_override_wins_and_is_capped() {
|
fn gateway_usage_queue_workers_manual_override_wins_and_is_capped() {
|
||||||
let mut args = test_args();
|
let mut args = test_args();
|
||||||
|
|||||||
@@ -761,33 +761,38 @@ pub(crate) fn spawn_account_self_check_worker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let config = AccountSelfCheckWorkerConfig::from_env();
|
let config = AccountSelfCheckWorkerConfig::from_env();
|
||||||
Some(tokio::spawn(async move {
|
Some(crate::task_runtime::spawn_singleton_worker(
|
||||||
let mut interval = tokio::time::interval(config.scan_interval);
|
state,
|
||||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
crate::task_runtime::TASK_KEY_ACCOUNT_SELF_CHECK,
|
||||||
interval.tick().await;
|
move |state| async move {
|
||||||
let mut deferred_since = None;
|
let mut interval = tokio::time::interval(config.scan_interval);
|
||||||
loop {
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
if state
|
let mut deferred_since = None;
|
||||||
.data
|
loop {
|
||||||
.should_defer_maintenance_for_database_pool_pressure(&mut deferred_since)
|
interval.tick().await;
|
||||||
{
|
if state
|
||||||
debug!(
|
.data
|
||||||
event_name = "maintenance_worker_deferred",
|
.should_defer_maintenance_for_database_pool_pressure(&mut deferred_since)
|
||||||
log_type = "ops",
|
{
|
||||||
worker = "account_self_check",
|
debug!(
|
||||||
"gateway account self-check deferred because database pool has no idle reserve"
|
event_name = "maintenance_worker_deferred",
|
||||||
);
|
log_type = "ops",
|
||||||
continue;
|
worker = "account_self_check",
|
||||||
|
"gateway account self-check deferred because database pool has no idle reserve"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(err) = perform_account_self_check_once_with_config(&state, config).await
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
error = ?err,
|
||||||
|
"gateway account self-check worker tick failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Err(err) = perform_account_self_check_once_with_config(&state, config).await {
|
},
|
||||||
warn!(
|
))
|
||||||
error = ?err,
|
|
||||||
"gateway account self-check worker tick failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1701,20 +1701,24 @@ pub(crate) fn spawn_pool_quota_probe_worker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let config = PoolQuotaProbeWorkerConfig::from_env();
|
let config = PoolQuotaProbeWorkerConfig::from_env();
|
||||||
Some(tokio::spawn(async move {
|
Some(crate::task_runtime::spawn_singleton_worker(
|
||||||
let mut interval = tokio::time::interval(config.scan_interval);
|
state,
|
||||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
crate::task_runtime::TASK_KEY_POOL_QUOTA_PROBE,
|
||||||
interval.tick().await;
|
move |state| async move {
|
||||||
loop {
|
let mut interval = tokio::time::interval(config.scan_interval);
|
||||||
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
if let Err(err) = perform_pool_quota_probe_once_with_config(&state, config).await {
|
loop {
|
||||||
warn!(
|
interval.tick().await;
|
||||||
error = ?err,
|
if let Err(err) = perform_pool_quota_probe_once_with_config(&state, config).await {
|
||||||
"gateway pool quota probe worker tick failed"
|
warn!(
|
||||||
);
|
error = ?err,
|
||||||
|
"gateway pool quota probe worker tick failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -401,50 +401,54 @@ pub(crate) fn spawn_pool_score_rebuild_worker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let config = PoolScoreRebuildWorkerConfig::from_env();
|
let config = PoolScoreRebuildWorkerConfig::from_env();
|
||||||
Some(tokio::spawn(async move {
|
Some(crate::task_runtime::spawn_singleton_worker(
|
||||||
if let Err(err) = perform_pool_score_rebuild_once_with_config(&state, config).await {
|
state,
|
||||||
warn!(
|
crate::task_runtime::TASK_KEY_POOL_SCORE_REBUILD,
|
||||||
error = ?err,
|
move |state| async move {
|
||||||
"gateway pool score rebuild initial tick failed"
|
if let Err(err) = perform_pool_score_rebuild_once_with_config(&state, config).await {
|
||||||
);
|
warn!(
|
||||||
}
|
error = ?err,
|
||||||
let mut interval = tokio::time::interval(config.interval);
|
"gateway pool score rebuild initial tick failed"
|
||||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
|
||||||
let mut deferred_since = None;
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
if state
|
|
||||||
.data
|
|
||||||
.should_defer_maintenance_for_database_pool_pressure(&mut deferred_since)
|
|
||||||
{
|
|
||||||
debug!(
|
|
||||||
event_name = "maintenance_worker_deferred",
|
|
||||||
log_type = "ops",
|
|
||||||
worker = "pool_score_rebuild",
|
|
||||||
"gateway pool score rebuild deferred because database pool has no idle reserve"
|
|
||||||
);
|
);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
match perform_pool_score_rebuild_once_with_config(&state, config).await {
|
let mut interval = tokio::time::interval(config.interval);
|
||||||
Ok(summary) if summary.scores_upserted > 0 => {
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
info!(
|
let mut deferred_since = None;
|
||||||
providers_checked = summary.providers_checked,
|
loop {
|
||||||
providers_scored = summary.providers_scored,
|
interval.tick().await;
|
||||||
keys_seen = summary.keys_seen,
|
if state
|
||||||
scores_upserted = summary.scores_upserted,
|
.data
|
||||||
"gateway pool score rebuild completed"
|
.should_defer_maintenance_for_database_pool_pressure(&mut deferred_since)
|
||||||
|
{
|
||||||
|
debug!(
|
||||||
|
event_name = "maintenance_worker_deferred",
|
||||||
|
log_type = "ops",
|
||||||
|
worker = "pool_score_rebuild",
|
||||||
|
"gateway pool score rebuild deferred because database pool has no idle reserve"
|
||||||
);
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
Ok(_) => {}
|
match perform_pool_score_rebuild_once_with_config(&state, config).await {
|
||||||
Err(err) => {
|
Ok(summary) if summary.scores_upserted > 0 => {
|
||||||
warn!(
|
info!(
|
||||||
error = ?err,
|
providers_checked = summary.providers_checked,
|
||||||
"gateway pool score rebuild worker tick failed"
|
providers_scored = summary.providers_scored,
|
||||||
);
|
keys_seen = summary.keys_seen,
|
||||||
|
scores_upserted = summary.scores_upserted,
|
||||||
|
"gateway pool score rebuild completed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
error = ?err,
|
||||||
|
"gateway pool score rebuild worker tick failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ pub(super) async fn run_proxy_upgrade_rollout_once(state: &AppState) -> Result<(
|
|||||||
for probe in probes {
|
for probe in probes {
|
||||||
match state
|
match state
|
||||||
.tunnel
|
.tunnel
|
||||||
.probe_node_url(&probe.node_id, &probe.url, probe.timeout_secs)
|
.probe_node_url_routed(state, &probe.node_id, &probe.url, probe.timeout_secs)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(status) if (200..300).contains(&status) => {
|
Ok(status) if (200..300).contains(&status) => {
|
||||||
|
|||||||
@@ -39,24 +39,34 @@ use super::{
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn spawn_audit_cleanup_worker_skips_when_postgres_unavailable() {
|
async fn spawn_audit_cleanup_worker_skips_when_postgres_unavailable() {
|
||||||
assert!(spawn_audit_cleanup_worker(Arc::new(GatewayDataState::disabled())).is_none());
|
let state = AppState::new()
|
||||||
|
.expect("gateway state should build")
|
||||||
|
.with_data_state_for_tests(GatewayDataState::disabled());
|
||||||
|
assert!(spawn_audit_cleanup_worker(state).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn spawn_db_maintenance_worker_skips_when_database_maintenance_unavailable() {
|
async fn spawn_db_maintenance_worker_skips_when_database_maintenance_unavailable() {
|
||||||
assert!(spawn_db_maintenance_worker(Arc::new(GatewayDataState::disabled())).is_none());
|
let state = AppState::new()
|
||||||
|
.expect("gateway state should build")
|
||||||
|
.with_data_state_for_tests(GatewayDataState::disabled());
|
||||||
|
assert!(spawn_db_maintenance_worker(state).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn spawn_pending_cleanup_worker_skips_when_usage_writer_unavailable() {
|
async fn spawn_pending_cleanup_worker_skips_when_usage_writer_unavailable() {
|
||||||
assert!(spawn_pending_cleanup_worker(Arc::new(GatewayDataState::disabled())).is_none());
|
let state = AppState::new()
|
||||||
|
.expect("gateway state should build")
|
||||||
|
.with_data_state_for_tests(GatewayDataState::disabled());
|
||||||
|
assert!(spawn_pending_cleanup_worker(state).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn spawn_proxy_node_stale_cleanup_worker_skips_when_proxy_nodes_unavailable() {
|
async fn spawn_proxy_node_stale_cleanup_worker_skips_when_proxy_nodes_unavailable() {
|
||||||
assert!(
|
let state = AppState::new()
|
||||||
spawn_proxy_node_stale_cleanup_worker(Arc::new(GatewayDataState::disabled())).is_none()
|
.expect("gateway state should build")
|
||||||
);
|
.with_data_state_for_tests(GatewayDataState::disabled());
|
||||||
|
assert!(spawn_proxy_node_stale_cleanup_worker(state).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -95,7 +105,10 @@ async fn spawn_proxy_upgrade_rollout_worker_skips_when_system_config_unavailable
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn spawn_pool_monitor_worker_skips_when_postgres_unavailable() {
|
async fn spawn_pool_monitor_worker_skips_when_postgres_unavailable() {
|
||||||
assert!(spawn_pool_monitor_worker(Arc::new(GatewayDataState::disabled())).is_none());
|
let state = AppState::new()
|
||||||
|
.expect("gateway state should build")
|
||||||
|
.with_data_state_for_tests(GatewayDataState::disabled());
|
||||||
|
assert!(spawn_pool_monitor_worker(state).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -559,28 +572,35 @@ async fn proxy_upgrade_rollout_active_probe_advances_next_wave_after_version_con
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn spawn_stats_aggregation_worker_skips_when_stats_daily_backend_unavailable() {
|
async fn spawn_stats_aggregation_worker_skips_when_stats_daily_backend_unavailable() {
|
||||||
assert!(spawn_stats_aggregation_worker(Arc::new(GatewayDataState::disabled())).is_none());
|
let state = AppState::new()
|
||||||
|
.expect("gateway state should build")
|
||||||
|
.with_data_state_for_tests(GatewayDataState::disabled());
|
||||||
|
assert!(spawn_stats_aggregation_worker(state).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn spawn_stats_hourly_aggregation_worker_skips_when_stats_hourly_backend_unavailable() {
|
async fn spawn_stats_hourly_aggregation_worker_skips_when_stats_hourly_backend_unavailable() {
|
||||||
assert!(
|
let state = AppState::new()
|
||||||
spawn_stats_hourly_aggregation_worker(Arc::new(GatewayDataState::disabled())).is_none()
|
.expect("gateway state should build")
|
||||||
);
|
.with_data_state_for_tests(GatewayDataState::disabled());
|
||||||
|
assert!(spawn_stats_hourly_aggregation_worker(state).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn spawn_usage_cleanup_worker_skips_when_usage_writer_unavailable() {
|
async fn spawn_usage_cleanup_worker_skips_when_usage_writer_unavailable() {
|
||||||
assert!(spawn_usage_cleanup_worker(Arc::new(GatewayDataState::disabled())).is_none());
|
let state = AppState::new()
|
||||||
|
.expect("gateway state should build")
|
||||||
|
.with_data_state_for_tests(GatewayDataState::disabled());
|
||||||
|
assert!(spawn_usage_cleanup_worker(state).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn spawn_wallet_daily_usage_aggregation_worker_skips_when_wallet_daily_usage_backend_unavailable(
|
async fn spawn_wallet_daily_usage_aggregation_worker_skips_when_wallet_daily_usage_backend_unavailable(
|
||||||
) {
|
) {
|
||||||
assert!(
|
let state = AppState::new()
|
||||||
spawn_wallet_daily_usage_aggregation_worker(Arc::new(GatewayDataState::disabled()))
|
.expect("gateway state should build")
|
||||||
.is_none()
|
.with_data_state_for_tests(GatewayDataState::disabled());
|
||||||
);
|
assert!(spawn_wallet_daily_usage_aggregation_worker(state).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,670 +1,6 @@
|
|||||||
use std::time::Instant;
|
//! Compatibility facade for frontdoor access logging.
|
||||||
|
|
||||||
use axum::body::Body;
|
pub(crate) use aether_gateway_frontdoor::{
|
||||||
use axum::extract::Request;
|
access_log_middleware, sanitize_access_log_path, should_downgrade_access_log,
|
||||||
use axum::http::header::{HeaderName, HeaderValue};
|
GatewayRequestAcceptedAt, RequestLogEmitted,
|
||||||
use axum::http::Method;
|
|
||||||
use axum::middleware::Next;
|
|
||||||
use axum::response::Response;
|
|
||||||
use tracing::{info, trace, warn};
|
|
||||||
|
|
||||||
use crate::ai_serving::api::sanitize_request_path_and_query;
|
|
||||||
use crate::constants::{
|
|
||||||
CONTROL_REQUEST_ID_HEADER, CONTROL_ROUTE_CLASS_HEADER, EXECUTION_PATH_HEADER, TRACE_ID_HEADER,
|
|
||||||
};
|
};
|
||||||
use crate::headers::extract_or_generate_trace_id;
|
|
||||||
use crate::log_ids::short_request_id;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub(crate) struct RequestLogEmitted;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub(crate) struct GatewayRequestAcceptedAt(pub(crate) Instant);
|
|
||||||
|
|
||||||
fn is_usage_detail_path(path: &str) -> bool {
|
|
||||||
let Some(detail_id) = path.strip_prefix("/api/admin/usage/") else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
!detail_id.is_empty()
|
|
||||||
&& !detail_id.contains('/')
|
|
||||||
&& !matches!(detail_id, "active" | "records" | "stats" | "heatmap")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn should_downgrade_access_log(method: &Method, path: &str) -> bool {
|
|
||||||
if method != Method::GET {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let normalized_path = path.split('?').next().unwrap_or(path);
|
|
||||||
matches!(
|
|
||||||
normalized_path,
|
|
||||||
"/api/admin/usage/active"
|
|
||||||
| "/api/users/me/usage/active"
|
|
||||||
| "/api/admin/usage/records"
|
|
||||||
| "/api/admin/usage/stats"
|
|
||||||
| "/api/admin/usage/aggregation/stats"
|
|
||||||
| "/api/admin/usage/heatmap"
|
|
||||||
| "/api/admin/usage/cache-affinity/interval-timeline"
|
|
||||||
| "/api/admin/usage/cache-affinity/ttl-analysis"
|
|
||||||
| "/api/admin/usage/cache-affinity/hit-analysis"
|
|
||||||
| "/api/admin/users"
|
|
||||||
| "/api/admin/monitoring/cache/stats"
|
|
||||||
| "/api/admin/monitoring/cache/model-mapping/stats"
|
|
||||||
| "/api/admin/monitoring/cache/config"
|
|
||||||
| "/api/admin/monitoring/cache/redis-keys"
|
|
||||||
| "/api/admin/monitoring/cache/affinities"
|
|
||||||
) || is_usage_detail_path(normalized_path)
|
|
||||||
|| normalized_path.starts_with("/api/admin/monitoring/trace/")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn sanitize_access_log_path(path: &str) -> String {
|
|
||||||
sanitize_request_path_and_query(path, None).unwrap_or_else(|| "/".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn access_log_middleware(mut request: Request<Body>, next: Next) -> Response {
|
|
||||||
let started_at = Instant::now();
|
|
||||||
request
|
|
||||||
.extensions_mut()
|
|
||||||
.insert(GatewayRequestAcceptedAt(started_at));
|
|
||||||
let method = request.method().clone();
|
|
||||||
let raw_path = request
|
|
||||||
.uri()
|
|
||||||
.path_and_query()
|
|
||||||
.map(|value| value.as_str().to_string())
|
|
||||||
.unwrap_or_else(|| "/".to_string());
|
|
||||||
let path = sanitize_access_log_path(&raw_path);
|
|
||||||
let trace_id = extract_or_generate_trace_id(request.headers());
|
|
||||||
if !request.headers().contains_key(TRACE_ID_HEADER) {
|
|
||||||
request.headers_mut().insert(
|
|
||||||
HeaderName::from_static(TRACE_ID_HEADER),
|
|
||||||
HeaderValue::from_str(&trace_id).expect("trace id should be a valid header value"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
trace!(
|
|
||||||
event_name = "http_request_started",
|
|
||||||
log_type = "access",
|
|
||||||
status = "started",
|
|
||||||
trace_id = %trace_id,
|
|
||||||
request_id = "-",
|
|
||||||
method = %method,
|
|
||||||
path = %path,
|
|
||||||
route_class = "pending",
|
|
||||||
execution_path = "pending",
|
|
||||||
"gateway request started"
|
|
||||||
);
|
|
||||||
let mut response = next.run(request).await;
|
|
||||||
if !response.headers().contains_key(TRACE_ID_HEADER) {
|
|
||||||
response.headers_mut().insert(
|
|
||||||
HeaderName::from_static(TRACE_ID_HEADER),
|
|
||||||
HeaderValue::from_str(&trace_id).expect("trace id should be a valid header value"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if response.extensions().get::<RequestLogEmitted>().is_none() {
|
|
||||||
let route_class = response
|
|
||||||
.headers()
|
|
||||||
.get(CONTROL_ROUTE_CLASS_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.unwrap_or("local");
|
|
||||||
let execution_path = response
|
|
||||||
.headers()
|
|
||||||
.get(EXECUTION_PATH_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.unwrap_or("local_route");
|
|
||||||
let request_id = response
|
|
||||||
.headers()
|
|
||||||
.get(CONTROL_REQUEST_ID_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
.unwrap_or("-");
|
|
||||||
let request_id = short_request_id(request_id);
|
|
||||||
let status_code = response.status().as_u16();
|
|
||||||
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
|
||||||
if response.status().is_server_error() {
|
|
||||||
warn!(
|
|
||||||
event_name = "http_request_failed",
|
|
||||||
log_type = "access",
|
|
||||||
status = "failed",
|
|
||||||
status_code,
|
|
||||||
trace_id = %trace_id,
|
|
||||||
request_id,
|
|
||||||
method = %method,
|
|
||||||
path = %path,
|
|
||||||
route_class,
|
|
||||||
execution_path,
|
|
||||||
elapsed_ms,
|
|
||||||
"gateway request failed"
|
|
||||||
);
|
|
||||||
} else if should_downgrade_access_log(&method, &path) {
|
|
||||||
trace!(
|
|
||||||
event_name = "http_request_completed",
|
|
||||||
log_type = "access",
|
|
||||||
status = "completed",
|
|
||||||
status_code,
|
|
||||||
trace_id = %trace_id,
|
|
||||||
request_id,
|
|
||||||
method = %method,
|
|
||||||
path = %path,
|
|
||||||
route_class,
|
|
||||||
execution_path,
|
|
||||||
elapsed_ms,
|
|
||||||
"gateway completed request"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
info!(
|
|
||||||
event_name = "http_request_completed",
|
|
||||||
log_type = "access",
|
|
||||||
status = "completed",
|
|
||||||
status_code,
|
|
||||||
trace_id = %trace_id,
|
|
||||||
request_id,
|
|
||||||
method = %method,
|
|
||||||
path = %path,
|
|
||||||
route_class,
|
|
||||||
execution_path,
|
|
||||||
elapsed_ms,
|
|
||||||
"gateway completed request"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
response
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{access_log_middleware, sanitize_access_log_path, should_downgrade_access_log};
|
|
||||||
use crate::constants::{
|
|
||||||
CONTROL_REQUEST_ID_HEADER, CONTROL_ROUTE_CLASS_HEADER, EXECUTION_PATH_HEADER,
|
|
||||||
TRACE_ID_HEADER,
|
|
||||||
};
|
|
||||||
use axum::body::Body;
|
|
||||||
use axum::http::{Method, Request, Response, StatusCode};
|
|
||||||
use axum::routing::get;
|
|
||||||
use axum::Router;
|
|
||||||
use bytes::Bytes;
|
|
||||||
use futures_util::stream;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use tower::ServiceExt;
|
|
||||||
use tracing_subscriber::filter::LevelFilter;
|
|
||||||
use tracing_subscriber::prelude::*;
|
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
|
||||||
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
|
|
||||||
|
|
||||||
struct SharedBufferWriter(Arc<Mutex<Vec<u8>>>);
|
|
||||||
|
|
||||||
impl SharedBuffer {
|
|
||||||
fn lines(&self) -> Vec<serde_json::Value> {
|
|
||||||
String::from_utf8(self.0.lock().expect("buffer should lock").clone())
|
|
||||||
.expect("buffer should contain valid utf-8")
|
|
||||||
.lines()
|
|
||||||
.filter(|line| !line.trim().is_empty())
|
|
||||||
.map(|line| serde_json::from_str(line).expect("json log line should parse"))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::io::Write for SharedBufferWriter {
|
|
||||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
|
||||||
self.0
|
|
||||||
.lock()
|
|
||||||
.expect("buffer should lock")
|
|
||||||
.extend_from_slice(buf);
|
|
||||||
Ok(buf.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flush(&mut self) -> std::io::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> tracing_subscriber::fmt::writer::MakeWriter<'a> for SharedBuffer {
|
|
||||||
type Writer = SharedBufferWriter;
|
|
||||||
|
|
||||||
fn make_writer(&'a self) -> Self::Writer {
|
|
||||||
SharedBufferWriter(Arc::clone(&self.0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn access_log_path_redacts_credential_query_values() {
|
|
||||||
assert_eq!(
|
|
||||||
sanitize_access_log_path(
|
|
||||||
"/v1beta/models/gemini-3-flash-preview:generateContent?key=secret&alt=sse&pageSize=10&token=hidden"
|
|
||||||
),
|
|
||||||
"/v1beta/models/gemini-3-flash-preview:generateContent?alt=sse&pageSize=10"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
|
||||||
async fn access_log_emits_sanitized_path() {
|
|
||||||
let writer = SharedBuffer::default();
|
|
||||||
let subscriber = tracing_subscriber::registry().with(
|
|
||||||
tracing_subscriber::fmt::layer()
|
|
||||||
.json()
|
|
||||||
.flatten_event(true)
|
|
||||||
.with_current_span(false)
|
|
||||||
.with_span_list(false)
|
|
||||||
.with_writer(writer.clone())
|
|
||||||
.with_filter(LevelFilter::INFO),
|
|
||||||
);
|
|
||||||
let dispatch = tracing::Dispatch::new(subscriber);
|
|
||||||
let _guard = tracing::dispatcher::set_default(&dispatch);
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
.route(
|
|
||||||
"/v1beta/models/gemini-3-flash-preview:generateContent",
|
|
||||||
get(|| async { Response::new(Body::empty()) }),
|
|
||||||
)
|
|
||||||
.layer(axum::middleware::from_fn(access_log_middleware));
|
|
||||||
|
|
||||||
let _response = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.uri("/v1beta/models/gemini-3-flash-preview:generateContent?key=secret&alt=sse")
|
|
||||||
.body(Body::empty())
|
|
||||||
.expect("request should build"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("request should succeed");
|
|
||||||
|
|
||||||
let logs = writer.lines();
|
|
||||||
assert_eq!(logs.len(), 1);
|
|
||||||
assert_eq!(
|
|
||||||
logs[0]["path"],
|
|
||||||
"/v1beta/models/gemini-3-flash-preview:generateContent?alt=sse"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
|
||||||
async fn access_log_emits_completed_events_by_default() {
|
|
||||||
let writer = SharedBuffer::default();
|
|
||||||
let subscriber = tracing_subscriber::registry().with(
|
|
||||||
tracing_subscriber::fmt::layer()
|
|
||||||
.json()
|
|
||||||
.flatten_event(true)
|
|
||||||
.with_current_span(false)
|
|
||||||
.with_span_list(false)
|
|
||||||
.with_writer(writer.clone())
|
|
||||||
.with_filter(LevelFilter::INFO),
|
|
||||||
);
|
|
||||||
let dispatch = tracing::Dispatch::new(subscriber);
|
|
||||||
let _guard = tracing::dispatcher::set_default(&dispatch);
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
.route(
|
|
||||||
"/ok",
|
|
||||||
get(|| async {
|
|
||||||
let mut response = Response::new(Body::empty());
|
|
||||||
response.headers_mut().insert(
|
|
||||||
CONTROL_ROUTE_CLASS_HEADER,
|
|
||||||
"local".parse().expect("header should parse"),
|
|
||||||
);
|
|
||||||
response.headers_mut().insert(
|
|
||||||
EXECUTION_PATH_HEADER,
|
|
||||||
"local_route".parse().expect("header should parse"),
|
|
||||||
);
|
|
||||||
response.headers_mut().insert(
|
|
||||||
CONTROL_REQUEST_ID_HEADER,
|
|
||||||
"req-123".parse().expect("header should parse"),
|
|
||||||
);
|
|
||||||
response
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.layer(axum::middleware::from_fn(access_log_middleware));
|
|
||||||
|
|
||||||
let response = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.uri("/ok")
|
|
||||||
.body(Body::empty())
|
|
||||||
.expect("request should build"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("request should succeed");
|
|
||||||
|
|
||||||
assert!(response.headers().contains_key(TRACE_ID_HEADER));
|
|
||||||
|
|
||||||
let logs = writer.lines();
|
|
||||||
assert_eq!(logs.len(), 1);
|
|
||||||
assert_eq!(logs[0]["event_name"], "http_request_completed");
|
|
||||||
assert_eq!(logs[0]["status"], "completed");
|
|
||||||
assert_eq!(logs[0]["status_code"], 200);
|
|
||||||
assert_eq!(logs[0]["request_id"], "req-123");
|
|
||||||
assert_eq!(logs[0]["route_class"], "local");
|
|
||||||
assert_eq!(logs[0]["execution_path"], "local_route");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
|
||||||
async fn access_log_propagates_generated_trace_id_to_downstream_handler() {
|
|
||||||
let app = Router::new()
|
|
||||||
.route(
|
|
||||||
"/trace",
|
|
||||||
get(|headers: http::HeaderMap| async move {
|
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header(
|
|
||||||
"x-seen-trace-id",
|
|
||||||
headers
|
|
||||||
.get(TRACE_ID_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.unwrap_or("-"),
|
|
||||||
)
|
|
||||||
.body(Body::empty())
|
|
||||||
.expect("response should build")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.layer(axum::middleware::from_fn(access_log_middleware));
|
|
||||||
|
|
||||||
let response = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.uri("/trace")
|
|
||||||
.body(Body::empty())
|
|
||||||
.expect("request should build"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("request should succeed");
|
|
||||||
|
|
||||||
let response_trace_id = response
|
|
||||||
.headers()
|
|
||||||
.get(TRACE_ID_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.expect("response trace id should exist")
|
|
||||||
.to_string();
|
|
||||||
let seen_trace_id = response
|
|
||||||
.headers()
|
|
||||||
.get("x-seen-trace-id")
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.expect("downstream seen trace id should exist")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
assert_eq!(seen_trace_id, response_trace_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
|
||||||
async fn access_log_shortens_long_request_ids() {
|
|
||||||
let writer = SharedBuffer::default();
|
|
||||||
let subscriber = tracing_subscriber::registry().with(
|
|
||||||
tracing_subscriber::fmt::layer()
|
|
||||||
.json()
|
|
||||||
.flatten_event(true)
|
|
||||||
.with_current_span(false)
|
|
||||||
.with_span_list(false)
|
|
||||||
.with_writer(writer.clone())
|
|
||||||
.with_filter(LevelFilter::INFO),
|
|
||||||
);
|
|
||||||
let dispatch = tracing::Dispatch::new(subscriber);
|
|
||||||
let _guard = tracing::dispatcher::set_default(&dispatch);
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
.route(
|
|
||||||
"/ok",
|
|
||||||
get(|| async {
|
|
||||||
let mut response = Response::new(Body::empty());
|
|
||||||
response.headers_mut().insert(
|
|
||||||
CONTROL_ROUTE_CLASS_HEADER,
|
|
||||||
"local".parse().expect("header should parse"),
|
|
||||||
);
|
|
||||||
response.headers_mut().insert(
|
|
||||||
EXECUTION_PATH_HEADER,
|
|
||||||
"local_route".parse().expect("header should parse"),
|
|
||||||
);
|
|
||||||
response.headers_mut().insert(
|
|
||||||
CONTROL_REQUEST_ID_HEADER,
|
|
||||||
"d07e1e94-41b8-409f-a18a-27993ae7ecb1"
|
|
||||||
.parse()
|
|
||||||
.expect("header should parse"),
|
|
||||||
);
|
|
||||||
response
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.layer(axum::middleware::from_fn(access_log_middleware));
|
|
||||||
|
|
||||||
let _response = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.uri("/ok")
|
|
||||||
.body(Body::empty())
|
|
||||||
.expect("request should build"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("request should succeed");
|
|
||||||
|
|
||||||
let logs = writer.lines();
|
|
||||||
assert_eq!(logs[0]["request_id"], "d07e1e94");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
|
||||||
async fn access_log_emits_failed_events_by_default_for_server_errors() {
|
|
||||||
let writer = SharedBuffer::default();
|
|
||||||
let subscriber = tracing_subscriber::registry().with(
|
|
||||||
tracing_subscriber::fmt::layer()
|
|
||||||
.json()
|
|
||||||
.flatten_event(true)
|
|
||||||
.with_current_span(false)
|
|
||||||
.with_span_list(false)
|
|
||||||
.with_writer(writer.clone())
|
|
||||||
.with_filter(LevelFilter::INFO),
|
|
||||||
);
|
|
||||||
let dispatch = tracing::Dispatch::new(subscriber);
|
|
||||||
let _guard = tracing::dispatcher::set_default(&dispatch);
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
.route(
|
|
||||||
"/fail",
|
|
||||||
get(|| async {
|
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::BAD_GATEWAY)
|
|
||||||
.header(CONTROL_ROUTE_CLASS_HEADER, "passthrough")
|
|
||||||
.header(EXECUTION_PATH_HEADER, "execution_runtime_sync")
|
|
||||||
.body(Body::empty())
|
|
||||||
.expect("response should build")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.layer(axum::middleware::from_fn(access_log_middleware));
|
|
||||||
|
|
||||||
let _response = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.uri("/fail")
|
|
||||||
.body(Body::empty())
|
|
||||||
.expect("request should build"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("request should succeed");
|
|
||||||
|
|
||||||
let logs = writer.lines();
|
|
||||||
assert_eq!(logs.len(), 1);
|
|
||||||
assert_eq!(logs[0]["event_name"], "http_request_failed");
|
|
||||||
assert_eq!(logs[0]["status"], "failed");
|
|
||||||
assert_eq!(logs[0]["status_code"], 502);
|
|
||||||
assert_eq!(logs[0]["route_class"], "passthrough");
|
|
||||||
assert_eq!(logs[0]["execution_path"], "execution_runtime_sync");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
|
||||||
async fn access_log_treats_client_errors_as_completed_events() {
|
|
||||||
let writer = SharedBuffer::default();
|
|
||||||
let subscriber = tracing_subscriber::registry().with(
|
|
||||||
tracing_subscriber::fmt::layer()
|
|
||||||
.json()
|
|
||||||
.flatten_event(true)
|
|
||||||
.with_current_span(false)
|
|
||||||
.with_span_list(false)
|
|
||||||
.with_writer(writer.clone())
|
|
||||||
.with_filter(LevelFilter::INFO),
|
|
||||||
);
|
|
||||||
let dispatch = tracing::Dispatch::new(subscriber);
|
|
||||||
let _guard = tracing::dispatcher::set_default(&dispatch);
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
.route(
|
|
||||||
"/missing",
|
|
||||||
get(|| async {
|
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::UNAUTHORIZED)
|
|
||||||
.header(CONTROL_ROUTE_CLASS_HEADER, "auth")
|
|
||||||
.header(EXECUTION_PATH_HEADER, "local_auth_denied")
|
|
||||||
.body(Body::empty())
|
|
||||||
.expect("response should build")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.layer(axum::middleware::from_fn(access_log_middleware));
|
|
||||||
|
|
||||||
let _response = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.uri("/missing")
|
|
||||||
.body(Body::empty())
|
|
||||||
.expect("request should build"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("request should succeed");
|
|
||||||
|
|
||||||
let logs = writer.lines();
|
|
||||||
assert_eq!(logs.len(), 1);
|
|
||||||
assert_eq!(logs[0]["event_name"], "http_request_completed");
|
|
||||||
assert_eq!(logs[0]["status"], "completed");
|
|
||||||
assert_eq!(logs[0]["status_code"], 401);
|
|
||||||
assert_eq!(logs[0]["route_class"], "auth");
|
|
||||||
assert_eq!(logs[0]["execution_path"], "local_auth_denied");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
|
||||||
async fn access_log_emits_completed_events_for_streaming_responses() {
|
|
||||||
let writer = SharedBuffer::default();
|
|
||||||
let subscriber = tracing_subscriber::registry().with(
|
|
||||||
tracing_subscriber::fmt::layer()
|
|
||||||
.json()
|
|
||||||
.flatten_event(true)
|
|
||||||
.with_current_span(false)
|
|
||||||
.with_span_list(false)
|
|
||||||
.with_writer(writer.clone())
|
|
||||||
.with_filter(LevelFilter::INFO),
|
|
||||||
);
|
|
||||||
let dispatch = tracing::Dispatch::new(subscriber);
|
|
||||||
let _guard = tracing::dispatcher::set_default(&dispatch);
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
.route(
|
|
||||||
"/stream",
|
|
||||||
get(|| async {
|
|
||||||
let body = Body::from_stream(stream::iter(vec![
|
|
||||||
Ok::<Bytes, std::convert::Infallible>(Bytes::from("chunk-1")),
|
|
||||||
Ok::<Bytes, std::convert::Infallible>(Bytes::from("chunk-2")),
|
|
||||||
]));
|
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header(CONTROL_ROUTE_CLASS_HEADER, "ai_public")
|
|
||||||
.header(EXECUTION_PATH_HEADER, "execution_runtime_stream")
|
|
||||||
.header(CONTROL_REQUEST_ID_HEADER, "req-stream")
|
|
||||||
.body(body)
|
|
||||||
.expect("response should build")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.layer(axum::middleware::from_fn(access_log_middleware));
|
|
||||||
|
|
||||||
let response = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.uri("/stream")
|
|
||||||
.body(Body::empty())
|
|
||||||
.expect("request should build"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("request should succeed");
|
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
let logs = writer.lines();
|
|
||||||
assert_eq!(logs.len(), 1);
|
|
||||||
assert_eq!(logs[0]["event_name"], "http_request_completed");
|
|
||||||
assert_eq!(logs[0]["status_code"], 200);
|
|
||||||
assert_eq!(logs[0]["request_id"], "req-stream");
|
|
||||||
assert_eq!(logs[0]["route_class"], "ai_public");
|
|
||||||
assert_eq!(logs[0]["execution_path"], "execution_runtime_stream");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
|
||||||
async fn access_log_downgrades_usage_active_polling_to_trace() {
|
|
||||||
let writer = SharedBuffer::default();
|
|
||||||
let subscriber = tracing_subscriber::registry().with(
|
|
||||||
tracing_subscriber::fmt::layer()
|
|
||||||
.json()
|
|
||||||
.flatten_event(true)
|
|
||||||
.with_current_span(false)
|
|
||||||
.with_span_list(false)
|
|
||||||
.with_writer(writer.clone())
|
|
||||||
.with_filter(LevelFilter::TRACE),
|
|
||||||
);
|
|
||||||
let dispatch = tracing::Dispatch::new(subscriber);
|
|
||||||
let _guard = tracing::dispatcher::set_default(&dispatch);
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
.route(
|
|
||||||
"/api/admin/usage/active",
|
|
||||||
get(|| async {
|
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header(CONTROL_ROUTE_CLASS_HEADER, "admin_proxy")
|
|
||||||
.header(EXECUTION_PATH_HEADER, "public_proxy_passthrough")
|
|
||||||
.body(Body::empty())
|
|
||||||
.expect("response should build")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.layer(axum::middleware::from_fn(access_log_middleware));
|
|
||||||
|
|
||||||
let _response = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.uri("/api/admin/usage/active?ids=req-1")
|
|
||||||
.body(Body::empty())
|
|
||||||
.expect("request should build"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("request should succeed");
|
|
||||||
|
|
||||||
let logs = writer.lines();
|
|
||||||
assert_eq!(logs.len(), 2);
|
|
||||||
assert_eq!(logs[0]["level"], "TRACE");
|
|
||||||
assert_eq!(logs[0]["event_name"], "http_request_started");
|
|
||||||
assert_eq!(logs[1]["level"], "TRACE");
|
|
||||||
assert_eq!(logs[1]["event_name"], "http_request_completed");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn access_log_marks_usage_active_paths_as_high_frequency() {
|
|
||||||
assert!(should_downgrade_access_log(
|
|
||||||
&Method::GET,
|
|
||||||
"/api/admin/usage/active"
|
|
||||||
));
|
|
||||||
assert!(should_downgrade_access_log(
|
|
||||||
&Method::GET,
|
|
||||||
"/api/admin/usage/active?ids=req-1"
|
|
||||||
));
|
|
||||||
assert!(should_downgrade_access_log(
|
|
||||||
&Method::GET,
|
|
||||||
"/api/users/me/usage/active"
|
|
||||||
));
|
|
||||||
assert!(should_downgrade_access_log(
|
|
||||||
&Method::GET,
|
|
||||||
"/api/admin/usage/records?limit=20"
|
|
||||||
));
|
|
||||||
assert!(should_downgrade_access_log(
|
|
||||||
&Method::GET,
|
|
||||||
"/api/admin/usage/123e4567-e89b-12d3-a456-426614174000?include_bodies=false"
|
|
||||||
));
|
|
||||||
assert!(should_downgrade_access_log(
|
|
||||||
&Method::GET,
|
|
||||||
"/api/admin/monitoring/trace/req-123?attempted_only=false"
|
|
||||||
));
|
|
||||||
assert!(should_downgrade_access_log(
|
|
||||||
&Method::GET,
|
|
||||||
"/api/admin/monitoring/cache/stats"
|
|
||||||
));
|
|
||||||
assert!(!should_downgrade_access_log(
|
|
||||||
&Method::DELETE,
|
|
||||||
"/api/admin/monitoring/cache/affinity/provider/key/model/openai:responses"
|
|
||||||
));
|
|
||||||
assert!(!should_downgrade_access_log(&Method::GET, "/v1/responses"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
mod access_log;
|
mod access_log;
|
||||||
mod frontdoor_cors;
|
mod frontdoor_cors;
|
||||||
mod strip_cf_headers;
|
|
||||||
|
|
||||||
pub(crate) use access_log::{
|
pub(crate) use access_log::{
|
||||||
access_log_middleware, sanitize_access_log_path, should_downgrade_access_log,
|
access_log_middleware, sanitize_access_log_path, should_downgrade_access_log,
|
||||||
GatewayRequestAcceptedAt, RequestLogEmitted,
|
GatewayRequestAcceptedAt, RequestLogEmitted,
|
||||||
};
|
};
|
||||||
|
pub use aether_gateway_frontdoor::strip_cf_headers_middleware;
|
||||||
|
pub(crate) use aether_gateway_frontdoor::{apply_cf_header_stripping, CfConnectingIp};
|
||||||
pub(crate) use frontdoor_cors::frontdoor_cors_middleware;
|
pub(crate) use frontdoor_cors::frontdoor_cors_middleware;
|
||||||
pub use strip_cf_headers::strip_cf_headers_middleware;
|
|
||||||
pub(crate) use strip_cf_headers::{apply_cf_header_stripping, CfConnectingIp};
|
|
||||||
|
|||||||
@@ -34,31 +34,35 @@ pub(crate) fn spawn_model_fetch_worker(state: AppState) -> Option<tokio::task::J
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(tokio::spawn(async move {
|
Some(crate::task_runtime::spawn_singleton_worker(
|
||||||
if model_fetch_startup_enabled() {
|
state,
|
||||||
let startup_delay = model_fetch_startup_delay_seconds();
|
crate::task_runtime::TASK_KEY_MODEL_FETCH_WORKER,
|
||||||
if startup_delay > 0 {
|
|state| async move {
|
||||||
tokio::time::sleep(Duration::from_secs(startup_delay)).await;
|
if model_fetch_startup_enabled() {
|
||||||
|
let startup_delay = model_fetch_startup_delay_seconds();
|
||||||
|
if startup_delay > 0 {
|
||||||
|
tokio::time::sleep(Duration::from_secs(startup_delay)).await;
|
||||||
|
}
|
||||||
|
if let Err(err) = run_model_fetch_cycle(&state, "startup").await {
|
||||||
|
warn!(error = ?err, "gateway model fetch startup failed");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
info!("gateway model fetch startup disabled");
|
||||||
}
|
}
|
||||||
if let Err(err) = run_model_fetch_cycle(&state, "startup").await {
|
|
||||||
warn!(error = ?err, "gateway model fetch startup failed");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
info!("gateway model fetch startup disabled");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(
|
let mut interval = tokio::time::interval(Duration::from_secs(
|
||||||
model_fetch_interval_minutes().saturating_mul(60),
|
model_fetch_interval_minutes().saturating_mul(60),
|
||||||
));
|
));
|
||||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
interval.tick().await;
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
if let Err(err) = run_model_fetch_cycle(&state, "tick").await {
|
loop {
|
||||||
warn!(error = ?err, "gateway model fetch tick failed");
|
interval.tick().await;
|
||||||
|
if let Err(err) = run_model_fetch_cycle(&state, "tick").await {
|
||||||
|
warn!(error = ?err, "gateway model fetch tick failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn perform_model_fetch_once(
|
pub(crate) async fn perform_model_fetch_once(
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ pub(crate) const SCHEDULER_AFFINITY_EPOCH_REPORT_FIELD: &str = "scheduler_affini
|
|||||||
pub(crate) const POOL_KEY_LEASE_KEY_REPORT_FIELD: &str = "pool_key_lease_key";
|
pub(crate) const POOL_KEY_LEASE_KEY_REPORT_FIELD: &str = "pool_key_lease_key";
|
||||||
pub(crate) const POOL_KEY_LEASE_OWNER_REPORT_FIELD: &str = "pool_key_lease_owner";
|
pub(crate) const POOL_KEY_LEASE_OWNER_REPORT_FIELD: &str = "pool_key_lease_owner";
|
||||||
pub(crate) const POOL_KEY_LEASE_TOKEN_REPORT_FIELD: &str = "pool_key_lease_token";
|
pub(crate) const POOL_KEY_LEASE_TOKEN_REPORT_FIELD: &str = "pool_key_lease_token";
|
||||||
|
pub(crate) const POOL_KEY_LEASE_FENCING_REPORT_FIELD: &str = "pool_key_lease_fencing_token";
|
||||||
pub(crate) const POOL_KEY_LEASE_TTL_MS_REPORT_FIELD: &str = "pool_key_lease_ttl_ms";
|
pub(crate) const POOL_KEY_LEASE_TTL_MS_REPORT_FIELD: &str = "pool_key_lease_ttl_ms";
|
||||||
|
|
||||||
pub(crate) fn attempt_identity_from_report_context(
|
pub(crate) fn attempt_identity_from_report_context(
|
||||||
@@ -92,6 +93,10 @@ pub(crate) fn insert_pool_key_lease_report_context_fields(
|
|||||||
POOL_KEY_LEASE_TOKEN_REPORT_FIELD.to_string(),
|
POOL_KEY_LEASE_TOKEN_REPORT_FIELD.to_string(),
|
||||||
Value::String(lease.token.clone()),
|
Value::String(lease.token.clone()),
|
||||||
);
|
);
|
||||||
|
extra_fields.insert(
|
||||||
|
POOL_KEY_LEASE_FENCING_REPORT_FIELD.to_string(),
|
||||||
|
Value::Number(lease.fencing_token.into()),
|
||||||
|
);
|
||||||
extra_fields.insert(
|
extra_fields.insert(
|
||||||
POOL_KEY_LEASE_TTL_MS_REPORT_FIELD.to_string(),
|
POOL_KEY_LEASE_TTL_MS_REPORT_FIELD.to_string(),
|
||||||
Value::Number(lease.ttl_ms.into()),
|
Value::Number(lease.ttl_ms.into()),
|
||||||
@@ -119,11 +124,17 @@ fn pool_key_lease_from_report_context(report_context: Option<&Value>) -> Option<
|
|||||||
.get(POOL_KEY_LEASE_TTL_MS_REPORT_FIELD)
|
.get(POOL_KEY_LEASE_TTL_MS_REPORT_FIELD)
|
||||||
.and_then(Value::as_u64)
|
.and_then(Value::as_u64)
|
||||||
.filter(|value| *value > 0)?;
|
.filter(|value| *value > 0)?;
|
||||||
|
let fencing_token = report_context
|
||||||
|
.get(POOL_KEY_LEASE_FENCING_REPORT_FIELD)
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.filter(|value| *value > 0)
|
||||||
|
.unwrap_or(1);
|
||||||
|
|
||||||
Some(RuntimeLockLease {
|
Some(RuntimeLockLease {
|
||||||
key: key.to_string(),
|
key: key.to_string(),
|
||||||
owner: owner.to_string(),
|
owner: owner.to_string(),
|
||||||
token: token.to_string(),
|
token: token.to_string(),
|
||||||
|
fencing_token,
|
||||||
ttl_ms,
|
ttl_ms,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -485,6 +496,7 @@ mod tests {
|
|||||||
"pool_key_lease_key": "ap:provider-1:lease:key-1",
|
"pool_key_lease_key": "ap:provider-1:lease:key-1",
|
||||||
"pool_key_lease_owner": "gateway-1",
|
"pool_key_lease_owner": "gateway-1",
|
||||||
"pool_key_lease_token": "gateway-1:token-1",
|
"pool_key_lease_token": "gateway-1:token-1",
|
||||||
|
"pool_key_lease_fencing_token": 7,
|
||||||
"pool_key_lease_ttl_ms": 900000,
|
"pool_key_lease_ttl_ms": 900000,
|
||||||
})));
|
})));
|
||||||
|
|
||||||
@@ -497,6 +509,7 @@ mod tests {
|
|||||||
key: "ap:provider-1:lease:key-1".to_string(),
|
key: "ap:provider-1:lease:key-1".to_string(),
|
||||||
owner: "gateway-1".to_string(),
|
owner: "gateway-1".to_string(),
|
||||||
token: "gateway-1:token-1".to_string(),
|
token: "gateway-1:token-1".to_string(),
|
||||||
|
fencing_token: 7,
|
||||||
ttl_ms: 900000,
|
ttl_ms: 900000,
|
||||||
}),
|
}),
|
||||||
scheduler_affinity_epoch: None,
|
scheduler_affinity_epoch: None,
|
||||||
|
|||||||
@@ -1283,7 +1283,7 @@ mod tests {
|
|||||||
use aether_data_contracts::repository::provider_catalog::{
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||||
};
|
};
|
||||||
use aether_testkit::ManagedRedisServer;
|
use aether_test_support::ManagedRedisServer;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
|
|||||||
@@ -4169,7 +4169,7 @@ mod tests {
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use aether_runtime_state::{RedisClientConfig, RuntimeState};
|
use aether_runtime_state::{RedisClientConfig, RuntimeState};
|
||||||
use aether_testkit::ManagedRedisServer;
|
use aether_test_support::ManagedRedisServer;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
fn assert_debug_surface_hides_values(debug: &str, originals: &[&str], sentinels: &[String]) {
|
fn assert_debug_surface_hides_values(debug: &str, originals: &[&str], sentinels: &[String]) {
|
||||||
|
|||||||
@@ -2,15 +2,16 @@ use std::collections::HashMap;
|
|||||||
use std::sync::atomic::AtomicU64;
|
use std::sync::atomic::AtomicU64;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::Mutex as StdMutex;
|
use std::sync::Mutex as StdMutex;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use aether_data::repository::users::StoredUserGroup;
|
use aether_data::repository::users::StoredUserGroup;
|
||||||
use aether_data_contracts::repository::billing::UserDailyQuotaAvailabilityRecord;
|
use aether_data_contracts::repository::billing::UserDailyQuotaAvailabilityRecord;
|
||||||
use aether_data_contracts::repository::quota::StoredProviderQuotaSnapshot;
|
use aether_data_contracts::repository::quota::StoredProviderQuotaSnapshot;
|
||||||
|
use aether_data_contracts::repository::usage::UsageCounterHealthSnapshot;
|
||||||
use aether_runtime::ConcurrencyGate;
|
use aether_runtime::ConcurrencyGate;
|
||||||
use aether_runtime_state::{RuntimeSemaphore, RuntimeState};
|
use aether_runtime_state::{RuntimeSemaphore, RuntimeState};
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use tokio::sync::{Mutex as TokioMutex, Semaphore};
|
use tokio::sync::{Mutex as TokioMutex, RwLock as TokioRwLock, Semaphore};
|
||||||
|
|
||||||
use super::super::async_task::{VideoTaskPollerConfig, VideoTaskService};
|
use super::super::async_task::{VideoTaskPollerConfig, VideoTaskService};
|
||||||
use super::super::cache::{
|
use super::super::cache::{
|
||||||
@@ -106,6 +107,8 @@ pub(crate) struct FrontdoorRuntimeGuardConfig {
|
|||||||
pub(crate) upstream_target_gate_limit: Option<usize>,
|
pub(crate) upstream_target_gate_limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) const METRIC_SNAPSHOT_TTL: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
impl FrontdoorRuntimeGuardConfig {
|
impl FrontdoorRuntimeGuardConfig {
|
||||||
pub(crate) fn from_env() -> Self {
|
pub(crate) fn from_env() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -364,6 +367,8 @@ pub struct AppState {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) execution_runtime_sync_override: Option<TestExecutionRuntimeSyncOverride>,
|
pub(crate) execution_runtime_sync_override: Option<TestExecutionRuntimeSyncOverride>,
|
||||||
pub(crate) data: Arc<GatewayDataState>,
|
pub(crate) data: Arc<GatewayDataState>,
|
||||||
|
pub(crate) background_data: Arc<GatewayDataState>,
|
||||||
|
pub(crate) background_data_isolated: bool,
|
||||||
pub(crate) runtime_state: Arc<RuntimeState>,
|
pub(crate) runtime_state: Arc<RuntimeState>,
|
||||||
pub(crate) usage_runtime: Arc<usage::UsageRuntime>,
|
pub(crate) usage_runtime: Arc<usage::UsageRuntime>,
|
||||||
pub(crate) video_tasks: Arc<VideoTaskService>,
|
pub(crate) video_tasks: Arc<VideoTaskService>,
|
||||||
@@ -412,6 +417,13 @@ pub struct AppState {
|
|||||||
pub(crate) usage_counter_flush_metrics: Arc<UsageCounterFlushRuntimeMetrics>,
|
pub(crate) usage_counter_flush_metrics: Arc<UsageCounterFlushRuntimeMetrics>,
|
||||||
pub(crate) task_supervisor_metrics: TaskSupervisorMetrics,
|
pub(crate) task_supervisor_metrics: TaskSupervisorMetrics,
|
||||||
pub(crate) process_resource_monitor: Arc<crate::process_metrics::GatewayProcessResourceMonitor>,
|
pub(crate) process_resource_monitor: Arc<crate::process_metrics::GatewayProcessResourceMonitor>,
|
||||||
|
pub(crate) metric_snapshot:
|
||||||
|
Arc<TokioRwLock<Option<(Instant, Vec<aether_runtime::MetricSample>)>>>,
|
||||||
|
pub(crate) metric_snapshot_refresh: Arc<TokioMutex<()>>,
|
||||||
|
pub(crate) usage_counter_exact_health_metric_snapshot:
|
||||||
|
Arc<TokioRwLock<Option<(Instant, UsageCounterHealthSnapshot)>>>,
|
||||||
|
pub(crate) usage_counter_exact_health_metric_last_attempt: Arc<StdMutex<Option<Instant>>>,
|
||||||
|
pub(crate) usage_counter_exact_health_metric_refresh: Arc<TokioMutex<()>>,
|
||||||
pub(crate) request_candidate_queue: Option<Arc<RequestCandidateQueueRuntime>>,
|
pub(crate) request_candidate_queue: Option<Arc<RequestCandidateQueueRuntime>>,
|
||||||
pub(crate) frontdoor_cors: Option<Arc<FrontdoorCorsConfig>>,
|
pub(crate) frontdoor_cors: Option<Arc<FrontdoorCorsConfig>>,
|
||||||
pub(crate) frontdoor_user_rpm: Arc<FrontdoorUserRpmLimiter>,
|
pub(crate) frontdoor_user_rpm: Arc<FrontdoorUserRpmLimiter>,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ const AUTH_API_KEY_SNAPSHOT_RUNTIME_CACHE_TTL: Duration = Duration::from_secs(30
|
|||||||
use super::super::super::{AUTH_API_KEY_LAST_USED_MAX_ENTRIES, AUTH_API_KEY_LAST_USED_TTL};
|
use super::super::super::{AUTH_API_KEY_LAST_USED_MAX_ENTRIES, AUTH_API_KEY_LAST_USED_TTL};
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
async fn acquire_auth_snapshot_load_gate(
|
pub(crate) async fn acquire_auth_snapshot_load_gate(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<Option<aether_runtime::ConcurrencyPermit>, GatewayError> {
|
) -> Result<Option<aether_runtime::ConcurrencyPermit>, GatewayError> {
|
||||||
let Some(gate) = self.auth_snapshot_load_gate.as_ref() else {
|
let Some(gate) = self.auth_snapshot_load_gate.as_ref() else {
|
||||||
|
|||||||
@@ -525,12 +525,27 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
let ttl = self.frontdoor_runtime_guards.auth_capacity_cache_ttl;
|
let ttl = self.frontdoor_runtime_guards.auth_capacity_cache_ttl;
|
||||||
if ttl.is_zero() {
|
if ttl.is_zero() {
|
||||||
return self.find_user_daily_quota_availability(user_id).await;
|
return self
|
||||||
|
.find_user_daily_quota_availability_for_auth_uncached(user_id)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
self.auth_daily_quota_availability_cache
|
self.auth_daily_quota_availability_cache
|
||||||
.get_or_load(user_id.to_string(), ttl, || async move {
|
.get_or_load(user_id.to_string(), ttl, || async move {
|
||||||
|
let _permit = self.acquire_auth_snapshot_load_gate().await?;
|
||||||
self.find_user_daily_quota_availability(user_id).await
|
self.find_user_daily_quota_availability(user_id).await
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn find_user_daily_quota_availability_for_auth_uncached(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Option<UserDailyQuotaAvailabilityRecord>, GatewayError> {
|
||||||
|
let user_id = user_id.trim();
|
||||||
|
if user_id.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let _permit = self.acquire_auth_snapshot_load_gate().await?;
|
||||||
|
self.find_user_daily_quota_availability(user_id).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,16 @@ impl AppState {
|
|||||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn summarize_dashboard_stats(
|
||||||
|
&self,
|
||||||
|
query: &usage::UsageDashboardSummaryQuery,
|
||||||
|
) -> Result<usage::StoredUsageDashboardStatsSummary, GatewayError> {
|
||||||
|
self.data
|
||||||
|
.summarize_dashboard_stats(query)
|
||||||
|
.await
|
||||||
|
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn list_dashboard_daily_breakdown(
|
pub(crate) async fn list_dashboard_daily_breakdown(
|
||||||
&self,
|
&self,
|
||||||
query: &usage::UsageDashboardDailyBreakdownQuery,
|
query: &usage::UsageDashboardDailyBreakdownQuery,
|
||||||
|
|||||||
@@ -73,15 +73,16 @@ impl AppState {
|
|||||||
|
|
||||||
let ttl = self.frontdoor_runtime_guards.auth_capacity_cache_ttl;
|
let ttl = self.frontdoor_runtime_guards.auth_capacity_cache_ttl;
|
||||||
if ttl.is_zero() {
|
if ttl.is_zero() {
|
||||||
return self.find_wallet(lookup).await;
|
return self
|
||||||
|
.read_wallet_snapshot_for_auth_uncached(user_id, api_key_id, api_key_is_standalone)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.auth_wallet_snapshot_cache
|
self.auth_wallet_snapshot_cache
|
||||||
.get_or_load(
|
.get_or_load(cache_key, ttl, || async move {
|
||||||
cache_key,
|
let _permit = self.acquire_auth_snapshot_load_gate().await?;
|
||||||
ttl,
|
self.find_wallet(lookup).await
|
||||||
|| async move { self.find_wallet(lookup).await },
|
})
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +118,7 @@ impl AppState {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let _permit = self.acquire_auth_snapshot_load_gate().await?;
|
||||||
self.find_wallet(lookup).await
|
self.find_wallet(lookup).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -609,7 +609,7 @@ mod tests {
|
|||||||
settings
|
settings
|
||||||
.api_format_suffixes("openai:chat")
|
.api_format_suffixes("openai:chat")
|
||||||
.expect("chat suffixes")
|
.expect("chat suffixes")
|
||||||
.into_iter()
|
.iter()
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec!["low", "max"]
|
vec!["low", "max"]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use aether_runtime::task::spawn_named;
|
|||||||
use aether_task_runtime::{RetryPolicy, TaskDefinition, TaskKind};
|
use aether_task_runtime::{RetryPolicy, TaskDefinition, TaskKind};
|
||||||
pub(crate) use aether_task_runtime::{TaskSupervisor, TaskSupervisorMetrics};
|
pub(crate) use aether_task_runtime::{TaskSupervisor, TaskSupervisorMetrics};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -26,6 +27,7 @@ pub(crate) const TASK_KEY_MODEL_FETCH_WORKER: &str = "model.fetch.worker";
|
|||||||
pub(crate) const TASK_KEY_PROVIDER_QUOTA_RESET: &str = "provider.quota.reset.worker";
|
pub(crate) const TASK_KEY_PROVIDER_QUOTA_RESET: &str = "provider.quota.reset.worker";
|
||||||
pub(crate) const TASK_KEY_ACCOUNT_SELF_CHECK: &str = "account.self_check.worker";
|
pub(crate) const TASK_KEY_ACCOUNT_SELF_CHECK: &str = "account.self_check.worker";
|
||||||
pub(crate) const TASK_KEY_POOL_SCORE_REBUILD: &str = "pool.score.rebuild.worker";
|
pub(crate) const TASK_KEY_POOL_SCORE_REBUILD: &str = "pool.score.rebuild.worker";
|
||||||
|
pub(crate) const TASK_KEY_POOL_QUOTA_PROBE: &str = "pool.quota.probe.worker";
|
||||||
pub(crate) const TASK_KEY_POOL_MONITOR: &str = "pool.monitor.worker";
|
pub(crate) const TASK_KEY_POOL_MONITOR: &str = "pool.monitor.worker";
|
||||||
pub(crate) const TASK_KEY_AUDIT_CLEANUP: &str = "maintenance.audit.cleanup";
|
pub(crate) const TASK_KEY_AUDIT_CLEANUP: &str = "maintenance.audit.cleanup";
|
||||||
pub(crate) const TASK_KEY_DB_MAINTENANCE: &str = "maintenance.database";
|
pub(crate) const TASK_KEY_DB_MAINTENANCE: &str = "maintenance.database";
|
||||||
@@ -52,6 +54,58 @@ const PROVIDER_DELETE_LOCK_TTL_SECS: u64 = 60 * 60 * 6;
|
|||||||
|
|
||||||
const RETRY_ONCE: RetryPolicy = RetryPolicy { max_attempts: 1 };
|
const RETRY_ONCE: RetryPolicy = RetryPolicy { max_attempts: 1 };
|
||||||
const RETRY_THREE: RetryPolicy = RetryPolicy { max_attempts: 3 };
|
const RETRY_THREE: RetryPolicy = RetryPolicy { max_attempts: 3 };
|
||||||
|
const BACKGROUND_TASK_RUN_ID_MAX_BYTES: usize = 64;
|
||||||
|
const WORKER_BOOT_RUN_ID_HASH_HEX_BYTES: usize = 20;
|
||||||
|
|
||||||
|
fn build_worker_boot_run_id(task_key: &str, instance_id: &str) -> String {
|
||||||
|
let full_run_id = format!("boot:{task_key}:{instance_id}");
|
||||||
|
if full_run_id.len() <= BACKGROUND_TASK_RUN_ID_MAX_BYTES {
|
||||||
|
return full_run_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
let digest_hex = format!("{:x}", Sha256::digest(full_run_id.as_bytes()));
|
||||||
|
let suffix = &digest_hex[..WORKER_BOOT_RUN_ID_HASH_HEX_BYTES];
|
||||||
|
let mut prefix_bytes = BACKGROUND_TASK_RUN_ID_MAX_BYTES - 1 - WORKER_BOOT_RUN_ID_HASH_HEX_BYTES;
|
||||||
|
while !full_run_id.is_char_boundary(prefix_bytes) {
|
||||||
|
prefix_bytes -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
format!("{}~{suffix}", &full_run_id[..prefix_bytes])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn spawn_singleton_worker<F, Fut>(
|
||||||
|
app: AppState,
|
||||||
|
task_key: &'static str,
|
||||||
|
worker: F,
|
||||||
|
) -> JoinHandle<()>
|
||||||
|
where
|
||||||
|
F: Fn(AppState) -> Fut + Send + 'static,
|
||||||
|
Fut: Future<Output = ()> + Send + 'static,
|
||||||
|
{
|
||||||
|
spawn_singleton_worker_with_context(app, task_key, move |app, _context| worker(app))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn spawn_singleton_worker_with_context<F, Fut>(
|
||||||
|
app: AppState,
|
||||||
|
task_key: &'static str,
|
||||||
|
worker: F,
|
||||||
|
) -> JoinHandle<()>
|
||||||
|
where
|
||||||
|
F: Fn(AppState, aether_gateway_workers::SingletonWorkerContext) -> Fut + Send + 'static,
|
||||||
|
Fut: Future<Output = ()> + Send + 'static,
|
||||||
|
{
|
||||||
|
let runtime_state = app.runtime_state.clone();
|
||||||
|
let metrics = app.task_supervisor_metrics.clone();
|
||||||
|
let owner = app.tunnel.local_instance_id().to_string();
|
||||||
|
aether_gateway_workers::spawn_singleton_worker_with_context(
|
||||||
|
runtime_state,
|
||||||
|
metrics,
|
||||||
|
owner,
|
||||||
|
task_key,
|
||||||
|
aether_gateway_workers::SingletonWorkerConfig::default(),
|
||||||
|
move |context| worker(app.clone(), context),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const TASK_DEFINITIONS: &[TaskDefinition] = &[
|
const TASK_DEFINITIONS: &[TaskDefinition] = &[
|
||||||
TaskDefinition::new(
|
TaskDefinition::new(
|
||||||
@@ -142,6 +196,14 @@ const TASK_DEFINITIONS: &[TaskDefinition] = &[
|
|||||||
true,
|
true,
|
||||||
RETRY_ONCE,
|
RETRY_ONCE,
|
||||||
),
|
),
|
||||||
|
TaskDefinition::new(
|
||||||
|
TASK_KEY_POOL_QUOTA_PROBE,
|
||||||
|
TaskKind::Scheduled,
|
||||||
|
"interval",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
RETRY_ONCE,
|
||||||
|
),
|
||||||
TaskDefinition::new(
|
TaskDefinition::new(
|
||||||
TASK_KEY_POOL_MONITOR,
|
TASK_KEY_POOL_MONITOR,
|
||||||
TaskKind::Scheduled,
|
TaskKind::Scheduled,
|
||||||
@@ -448,7 +510,7 @@ pub(crate) fn spawn_record_worker_boot(
|
|||||||
) -> JoinHandle<()> {
|
) -> JoinHandle<()> {
|
||||||
spawn_named("task-runtime-record-worker-boot", async move {
|
spawn_named("task-runtime-record-worker-boot", async move {
|
||||||
let now = now_unix_secs();
|
let now = now_unix_secs();
|
||||||
let run_id = format!("boot:{}:{}", task_key, app.tunnel.local_instance_id());
|
let run_id = build_worker_boot_run_id(task_key, app.tunnel.local_instance_id());
|
||||||
let run = UpsertBackgroundTaskRun {
|
let run = UpsertBackgroundTaskRun {
|
||||||
id: run_id.clone(),
|
id: run_id.clone(),
|
||||||
task_key: task_key.to_string(),
|
task_key: task_key.to_string(),
|
||||||
@@ -719,3 +781,56 @@ pub(crate) async fn submit_provider_delete_task(
|
|||||||
|
|
||||||
Ok(Some(task_id))
|
Ok(Some(task_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod worker_boot_run_id_tests {
|
||||||
|
use super::{build_worker_boot_run_id, BACKGROUND_TASK_RUN_ID_MAX_BYTES};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_boot_run_id_preserves_short_legacy_format() {
|
||||||
|
assert_eq!(
|
||||||
|
build_worker_boot_run_id("usage.queue.worker", "gateway-1"),
|
||||||
|
"boot:usage.queue.worker:gateway-1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_boot_run_id_preserves_exact_database_limit() {
|
||||||
|
let task_key = "task";
|
||||||
|
let instance_id = "i".repeat(BACKGROUND_TASK_RUN_ID_MAX_BYTES - "boot:task:".len());
|
||||||
|
let run_id = build_worker_boot_run_id(task_key, &instance_id);
|
||||||
|
|
||||||
|
assert_eq!(run_id.len(), BACKGROUND_TASK_RUN_ID_MAX_BYTES);
|
||||||
|
assert_eq!(run_id, format!("boot:{task_key}:{instance_id}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_boot_run_id_compacts_oversized_values_deterministically() {
|
||||||
|
let instance_id = "gateway-instance-".repeat(8);
|
||||||
|
let first = build_worker_boot_run_id("usage.queue.worker", &instance_id);
|
||||||
|
let second = build_worker_boot_run_id("usage.queue.worker", &instance_id);
|
||||||
|
|
||||||
|
assert!(first.len() <= BACKGROUND_TASK_RUN_ID_MAX_BYTES);
|
||||||
|
assert_eq!(first, second);
|
||||||
|
assert!(first.starts_with("boot:usage.queue.worker:"));
|
||||||
|
assert!(first.contains('~'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_boot_run_id_hash_distinguishes_shared_long_prefixes() {
|
||||||
|
let shared_prefix = "gateway-instance-with-a-very-long-shared-prefix-".repeat(2);
|
||||||
|
let first = build_worker_boot_run_id("usage.queue.worker", &format!("{shared_prefix}a"));
|
||||||
|
let second = build_worker_boot_run_id("usage.queue.worker", &format!("{shared_prefix}b"));
|
||||||
|
|
||||||
|
assert_ne!(first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_boot_run_id_handles_unicode_at_truncation_boundary() {
|
||||||
|
let run_id = build_worker_boot_run_id("usage.queue.worker", &"网关实例".repeat(16));
|
||||||
|
|
||||||
|
assert!(run_id.len() <= BACKGROUND_TASK_RUN_ID_MAX_BYTES);
|
||||||
|
assert!(run_id.is_char_boundary(run_id.len()));
|
||||||
|
assert!(run_id.starts_with("boot:usage.queue.worker:"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -661,7 +661,7 @@ fn admin_monitoring_snapshots_stay_app_local() {
|
|||||||
"monitoring/resilience/snapshot.rs should define AdminMonitoringResilienceSnapshot locally"
|
"monitoring/resilience/snapshot.rs should define AdminMonitoringResilienceSnapshot locally"
|
||||||
);
|
);
|
||||||
|
|
||||||
let data_system = read_workspace_file("crates/aether-data/src/repository/system.rs");
|
let data_system = read_workspace_file("crates/aether-data/runtime/src/repository/system.rs");
|
||||||
assert!(
|
assert!(
|
||||||
!data_system.contains("AdminMonitoringCacheSnapshot")
|
!data_system.contains("AdminMonitoringCacheSnapshot")
|
||||||
&& !data_system.contains("AdminMonitoringResilienceSnapshot"),
|
&& !data_system.contains("AdminMonitoringResilienceSnapshot"),
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ fn admin_provider_root_stays_thin() {
|
|||||||
|
|
||||||
let ops_mod = read_workspace_file("apps/aether-gateway/src/handlers/admin/provider/ops/mod.rs");
|
let ops_mod = read_workspace_file("apps/aether-gateway/src/handlers/admin/provider/ops/mod.rs");
|
||||||
assert!(
|
assert!(
|
||||||
!ops_mod.contains("pub(crate) use self::providers::admin_provider_ops_local_action_response;"),
|
!ops_mod
|
||||||
|
.contains("pub(crate) use self::providers::admin_provider_ops_local_action_response;"),
|
||||||
"handlers/admin/provider/ops/mod.rs should not re-export admin_provider_ops_local_action_response"
|
"handlers/admin/provider/ops/mod.rs should not re-export admin_provider_ops_local_action_response"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ fn admin_provider_oauth_complete_dispatch_remains_thin() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn postgres_provider_cleanup_preserves_usage_history() {
|
fn postgres_provider_cleanup_preserves_usage_history() {
|
||||||
let postgres_provider_catalog =
|
let postgres_provider_catalog =
|
||||||
read_workspace_file("crates/aether-data/src/repository/provider_catalog/postgres.rs");
|
read_workspace_file("crates/aether-data/adapters/postgres/src/provider_catalog.rs");
|
||||||
|
|
||||||
for forbidden in [
|
for forbidden in [
|
||||||
"UPDATE usage SET provider_id = NULL",
|
"UPDATE usage SET provider_id = NULL",
|
||||||
@@ -137,9 +138,9 @@ fn postgres_provider_cleanup_preserves_usage_history() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn provider_cleanup_keeps_common_backends_in_sync() {
|
fn provider_cleanup_keeps_common_backends_in_sync() {
|
||||||
for path in [
|
for path in [
|
||||||
"crates/aether-data/src/repository/provider_catalog/postgres.rs",
|
"crates/aether-data/adapters/postgres/src/provider_catalog.rs",
|
||||||
"crates/aether-data/src/repository/provider_catalog/mysql.rs",
|
"crates/aether-data/adapters/mysql/src/provider_catalog.rs",
|
||||||
"crates/aether-data/src/repository/provider_catalog/sqlite.rs",
|
"crates/aether-data/adapters/sqlite/src/provider_catalog.rs",
|
||||||
] {
|
] {
|
||||||
let source = read_workspace_file(path);
|
let source = read_workspace_file(path);
|
||||||
for required in [
|
for required in [
|
||||||
@@ -370,7 +371,9 @@ fn admin_provider_ops_routes_directoryized() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
routes_mod.contains("pub(crate) async fn maybe_build_local_admin_provider_ops_providers_response("),
|
routes_mod.contains(
|
||||||
|
"pub(crate) async fn maybe_build_local_admin_provider_ops_providers_response("
|
||||||
|
),
|
||||||
"handlers/admin/provider/ops/providers/routes/mod.rs should keep the provider ops entry seam"
|
"handlers/admin/provider/ops/providers/routes/mod.rs should keep the provider ops entry seam"
|
||||||
);
|
);
|
||||||
for forbidden in [
|
for forbidden in [
|
||||||
@@ -386,7 +389,9 @@ fn admin_provider_ops_routes_directoryized() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
!workspace_file_exists("apps/aether-gateway/src/handlers/admin/provider/ops/providers/routes.rs"),
|
!workspace_file_exists(
|
||||||
|
"apps/aether-gateway/src/handlers/admin/provider/ops/providers/routes.rs"
|
||||||
|
),
|
||||||
"handlers/admin/provider/ops/providers/routes.rs should be removed once routes are directoryized"
|
"handlers/admin/provider/ops/providers/routes.rs should be removed once routes are directoryized"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -667,8 +672,10 @@ fn admin_provider_query_and_strategy_use_specific_local_owners() {
|
|||||||
let strategy_routes =
|
let strategy_routes =
|
||||||
read_workspace_file("apps/aether-gateway/src/handlers/admin/provider/strategy/routes.rs");
|
read_workspace_file("apps/aether-gateway/src/handlers/admin/provider/strategy/routes.rs");
|
||||||
assert!(
|
assert!(
|
||||||
strategy_routes.contains("state\n .maybe_build_admin_provider_strategy_route_response(")
|
strategy_routes
|
||||||
|| strategy_routes.contains("state.maybe_build_admin_provider_strategy_route_response("),
|
.contains("state\n .maybe_build_admin_provider_strategy_route_response(")
|
||||||
|
|| strategy_routes
|
||||||
|
.contains("state.maybe_build_admin_provider_strategy_route_response("),
|
||||||
"handlers/admin/provider/strategy/routes.rs should delegate to request/provider route owner"
|
"handlers/admin/provider/strategy/routes.rs should delegate to request/provider route owner"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -686,11 +693,15 @@ fn admin_provider_query_and_strategy_use_specific_local_owners() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("apps/aether-gateway/src/handlers/admin/provider/strategy/responses.rs"),
|
workspace_file_exists(
|
||||||
|
"apps/aether-gateway/src/handlers/admin/provider/strategy/responses.rs"
|
||||||
|
),
|
||||||
"handlers/admin/provider/strategy/responses.rs should own strategy route-level shared responses"
|
"handlers/admin/provider/strategy/responses.rs should own strategy route-level shared responses"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!workspace_file_exists("apps/aether-gateway/src/handlers/admin/provider/strategy/shared.rs"),
|
!workspace_file_exists(
|
||||||
|
"apps/aether-gateway/src/handlers/admin/provider/strategy/shared.rs"
|
||||||
|
),
|
||||||
"handlers/admin/provider/strategy/shared.rs should be removed once the local shared hub is narrowed"
|
"handlers/admin/provider/strategy/shared.rs should be removed once the local shared hub is narrowed"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1180,7 +1191,7 @@ fn admin_provider_write_uses_specific_local_owners() {
|
|||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
endpoint_keys_mutations.contains(pattern),
|
endpoint_keys_mutations.contains(pattern),
|
||||||
"handlers/admin/provider/endpoint_keys/mutations/mod.rs should expose explicit mutation owner {pattern}"
|
"handlers/admin/provider/endpoint_keys/mutations/mod.rs should expose explicit mutation owner {pattern}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1299,7 +1310,9 @@ fn admin_provider_ops_actions_mod_stays_thin() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
!workspace_file_exists("apps/aether-gateway/src/handlers/admin/provider/ops/providers/actions.rs"),
|
!workspace_file_exists(
|
||||||
|
"apps/aether-gateway/src/handlers/admin/provider/ops/providers/actions.rs"
|
||||||
|
),
|
||||||
"handlers/admin/provider/ops/providers/actions.rs should be removed once actions logic is directoryized"
|
"handlers/admin/provider/ops/providers/actions.rs should be removed once actions logic is directoryized"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1811,7 +1824,8 @@ fn admin_provider_oauth_quota_mod_stays_thin() {
|
|||||||
"handlers/admin/provider/oauth/quota/shared.rs should delegate quota metadata provider detection to aether-provider-pool"
|
"handlers/admin/provider/oauth/quota/shared.rs should delegate quota metadata provider detection to aether-provider-pool"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!quota_shared.contains("[\"codex\", \"kiro\", \"antigravity\", \"gemini_cli\", \"chatgpt_web\"]"),
|
!quota_shared
|
||||||
|
.contains("[\"codex\", \"kiro\", \"antigravity\", \"gemini_cli\", \"chatgpt_web\"]"),
|
||||||
"handlers/admin/provider/oauth/quota/shared.rs should not hardcode quota metadata provider list"
|
"handlers/admin/provider/oauth/quota/shared.rs should not hardcode quota metadata provider list"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1873,9 +1887,8 @@ fn admin_provider_oauth_quota_mod_stays_thin() {
|
|||||||
"apps/aether-gateway/src/handlers/admin/provider/oauth/quota/codex/invalid.rs",
|
"apps/aether-gateway/src/handlers/admin/provider/oauth/quota/codex/invalid.rs",
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
quota_codex_invalid.contains(
|
quota_codex_invalid.contains("use crate::handlers::admin::provider::shared::payloads::{")
|
||||||
"use crate::handlers::admin::provider::shared::payloads::{"
|
|| quota_codex_invalid.contains("use aether_admin::provider::quota"),
|
||||||
) || quota_codex_invalid.contains("use aether_admin::provider::quota"),
|
|
||||||
"handlers/admin/provider/oauth/quota/codex/invalid.rs should either own codex invalid-state helpers locally or delegate to aether-admin"
|
"handlers/admin/provider/oauth/quota/codex/invalid.rs should either own codex invalid-state helpers locally or delegate to aether-admin"
|
||||||
);
|
);
|
||||||
let quota_codex_plan = read_workspace_file(
|
let quota_codex_plan = read_workspace_file(
|
||||||
@@ -1949,7 +1962,8 @@ fn admin_provider_oauth_quota_mod_stays_thin() {
|
|||||||
"handlers/admin/provider/oauth/quota/antigravity.rs should import common quota helpers from shared.rs"
|
"handlers/admin/provider/oauth/quota/antigravity.rs should import common quota helpers from shared.rs"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
quota_antigravity.contains("use aether_provider_pool::build_antigravity_pool_quota_request;"),
|
quota_antigravity
|
||||||
|
.contains("use aether_provider_pool::build_antigravity_pool_quota_request;"),
|
||||||
"handlers/admin/provider/oauth/quota/antigravity.rs should delegate antigravity quota request construction to aether-provider-pool"
|
"handlers/admin/provider/oauth/quota/antigravity.rs should delegate antigravity quota request construction to aether-provider-pool"
|
||||||
);
|
);
|
||||||
let quota_chatgpt_web = read_workspace_file(
|
let quota_chatgpt_web = read_workspace_file(
|
||||||
@@ -2130,12 +2144,14 @@ fn admin_provider_oauth_dispatch_batch_mod_stays_thin() {
|
|||||||
"apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/kiro_import.rs",
|
"apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/kiro_import.rs",
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
batch_kiro_import.contains("pub(super) async fn execute_admin_provider_oauth_kiro_batch_import("),
|
batch_kiro_import
|
||||||
|
.contains("pub(super) async fn execute_admin_provider_oauth_kiro_batch_import("),
|
||||||
"handlers/admin/provider/oauth/dispatch/batch/kiro_import.rs should own the kiro batch execution owner"
|
"handlers/admin/provider/oauth/dispatch/batch/kiro_import.rs should own the kiro batch execution owner"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
batch_kiro_import.contains("build_kiro_batch_import_key_name(")
|
batch_kiro_import.contains("build_kiro_batch_import_key_name(")
|
||||||
|| batch_kiro_import.contains("aether_admin::provider::oauth::build_kiro_batch_import_key_name"),
|
|| batch_kiro_import
|
||||||
|
.contains("aether_admin::provider::oauth::build_kiro_batch_import_key_name"),
|
||||||
"handlers/admin/provider/oauth/dispatch/batch/kiro_import.rs should either own or delegate the kiro key-name builder"
|
"handlers/admin/provider/oauth/dispatch/batch/kiro_import.rs should either own or delegate the kiro key-name builder"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -2162,7 +2178,8 @@ fn admin_provider_oauth_dispatch_batch_mod_stays_thin() {
|
|||||||
"apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/orchestration.rs",
|
"apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/orchestration.rs",
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
batch_orchestration.contains("pub(in super::super) async fn handle_admin_provider_oauth_batch_import("),
|
batch_orchestration
|
||||||
|
.contains("pub(in super::super) async fn handle_admin_provider_oauth_batch_import("),
|
||||||
"handlers/admin/provider/oauth/dispatch/batch/orchestration.rs should own the direct batch import route"
|
"handlers/admin/provider/oauth/dispatch/batch/orchestration.rs should own the direct batch import route"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2333,7 +2350,9 @@ fn admin_provider_oauth_device_mod_stays_thin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!workspace_file_exists("apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/device.rs"),
|
!workspace_file_exists(
|
||||||
|
"apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/device.rs"
|
||||||
|
),
|
||||||
"handlers/admin/provider/oauth/dispatch/device.rs should be removed once device dispatch is directoryized"
|
"handlers/admin/provider/oauth/dispatch/device.rs should be removed once device dispatch is directoryized"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,12 +103,15 @@ fn admin_wrapped_state_owns_api_key_and_proxy_capabilities() {
|
|||||||
"{path} should use AdminAppState encryption capability instead of raw state.app() encryption"
|
"{path} should use AdminAppState encryption capability instead of raw state.app() encryption"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!contents.contains("decrypt_catalog_secret_with_fallbacks(state.app().encryption_key(),"),
|
!contents
|
||||||
|
.contains("decrypt_catalog_secret_with_fallbacks(state.app().encryption_key(),"),
|
||||||
"{path} should use AdminAppState decryption capability instead of raw state.app().encryption_key()"
|
"{path} should use AdminAppState decryption capability instead of raw state.app().encryption_key()"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!contents.contains("resolve_transport_proxy_snapshot_with_tunnel_affinity(\n state.app(),")
|
!contents.contains(
|
||||||
&& !contents.contains("resolve_transport_proxy_snapshot_with_tunnel_affinity(state.app(),"),
|
"resolve_transport_proxy_snapshot_with_tunnel_affinity(\n state.app(),"
|
||||||
|
) && !contents
|
||||||
|
.contains("resolve_transport_proxy_snapshot_with_tunnel_affinity(state.app(),"),
|
||||||
"{path} should use AdminAppState proxy capability instead of raw state.app() transport proxy resolution"
|
"{path} should use AdminAppState proxy capability instead of raw state.app() transport proxy resolution"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -682,7 +685,8 @@ fn crate_root_exposes_real_admin_and_ai_serving_facades() {
|
|||||||
|
|
||||||
let ai_serving_api_mod = read_workspace_file("apps/aether-gateway/src/ai_serving/api.rs");
|
let ai_serving_api_mod = read_workspace_file("apps/aether-gateway/src/ai_serving/api.rs");
|
||||||
assert!(
|
assert!(
|
||||||
ai_serving_api_mod.contains("use crate::ai_serving::{is_json_request, GatewayControlDecision};"),
|
ai_serving_api_mod
|
||||||
|
.contains("use crate::ai_serving::{is_json_request, GatewayControlDecision};"),
|
||||||
"ai_serving/api.rs should depend on the crate-facing ai_serving seam instead of deep internal modules"
|
"ai_serving/api.rs should depend on the crate-facing ai_serving seam instead of deep internal modules"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -695,7 +699,7 @@ fn gateway_ai_serving_api_module_delegates_pure_ownership_to_format_crate() {
|
|||||||
"ai_serving/api.rs should re-export pure ownership through aether_ai_formats::api"
|
"ai_serving/api.rs should re-export pure ownership through aether_ai_formats::api"
|
||||||
);
|
);
|
||||||
|
|
||||||
let format_crate_api = read_workspace_file("crates/aether-ai-formats/src/api.rs");
|
let format_crate_api = read_workspace_file("crates/aether-ai/formats/src/api.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub use crate::contracts::{",
|
"pub use crate::contracts::{",
|
||||||
"pub use crate::provider_compat::kiro_stream::{",
|
"pub use crate::provider_compat::kiro_stream::{",
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ fn ai_serving_target_structure_removes_legacy_pipeline_boundary() {
|
|||||||
let mut violations = Vec::new();
|
let mut violations = Vec::new();
|
||||||
for root in [
|
for root in [
|
||||||
"apps/aether-gateway/src",
|
"apps/aether-gateway/src",
|
||||||
"crates/aether-ai-serving/src",
|
"crates/aether-ai/serving/src",
|
||||||
"crates/aether-ai-formats/src",
|
"crates/aether-ai/formats/src",
|
||||||
] {
|
] {
|
||||||
for file in collect_workspace_rust_files(root) {
|
for file in collect_workspace_rust_files(root) {
|
||||||
let relative = file
|
let relative = file
|
||||||
@@ -75,7 +75,7 @@ fn ai_serving_target_structure_removes_legacy_pipeline_boundary() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_crate_owns_attempt_loop_without_gateway_runtime_deps() {
|
fn ai_serving_crate_owns_attempt_loop_without_gateway_runtime_deps() {
|
||||||
let serving_manifest = read_workspace_file("crates/aether-ai-serving/Cargo.toml");
|
let serving_manifest = read_workspace_file("crates/aether-ai/serving/Cargo.toml");
|
||||||
for forbidden in ["axum", "sqlx", "redis", "aether-gateway"] {
|
for forbidden in ["axum", "sqlx", "redis", "aether-gateway"] {
|
||||||
assert!(
|
assert!(
|
||||||
!serving_manifest.contains(forbidden),
|
!serving_manifest.contains(forbidden),
|
||||||
@@ -84,7 +84,7 @@ fn ai_serving_crate_owns_attempt_loop_without_gateway_runtime_deps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut violations = Vec::new();
|
let mut violations = Vec::new();
|
||||||
for file in collect_workspace_rust_files("crates/aether-ai-serving/src") {
|
for file in collect_workspace_rust_files("crates/aether-ai/serving/src") {
|
||||||
let source = std::fs::read_to_string(&file).expect("source file should be readable");
|
let source = std::fs::read_to_string(&file).expect("source file should be readable");
|
||||||
let hits = ["AppState", "axum::", "sqlx::", "redis::"]
|
let hits = ["AppState", "axum::", "sqlx::", "redis::"]
|
||||||
.iter()
|
.iter()
|
||||||
@@ -101,7 +101,7 @@ fn ai_serving_crate_owns_attempt_loop_without_gateway_runtime_deps() {
|
|||||||
violations.join("\n")
|
violations.join("\n")
|
||||||
);
|
);
|
||||||
|
|
||||||
let serving_attempt_loop = read_workspace_file("crates/aether-ai-serving/src/attempt_loop.rs");
|
let serving_attempt_loop = read_workspace_file("crates/aether-ai/serving/src/attempt_loop.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub trait AiExecutionAttempt",
|
"pub trait AiExecutionAttempt",
|
||||||
"pub trait AiAttemptLoopPort",
|
"pub trait AiAttemptLoopPort",
|
||||||
@@ -129,7 +129,7 @@ fn ai_serving_crate_owns_attempt_loop_without_gateway_runtime_deps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let serving_decision_path =
|
let serving_decision_path =
|
||||||
read_workspace_file("crates/aether-ai-serving/src/decision_path.rs");
|
read_workspace_file("crates/aether-ai/serving/src/decision_path.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub enum AiSyncDecisionStep",
|
"pub enum AiSyncDecisionStep",
|
||||||
"pub enum AiStreamDecisionStep",
|
"pub enum AiStreamDecisionStep",
|
||||||
@@ -168,7 +168,7 @@ fn ai_serving_crate_owns_attempt_loop_without_gateway_runtime_deps() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let serving_plan_payload = read_workspace_file("crates/aether-ai-serving/src/plan_payload.rs");
|
let serving_plan_payload = read_workspace_file("crates/aether-ai/serving/src/plan_payload.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub fn build_ai_sync_execution_plan_payload",
|
"pub fn build_ai_sync_execution_plan_payload",
|
||||||
"pub fn build_ai_stream_execution_plan_payload",
|
"pub fn build_ai_stream_execution_plan_payload",
|
||||||
@@ -204,7 +204,7 @@ fn ai_serving_crate_owns_attempt_loop_without_gateway_runtime_deps() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let serving_attempt_plan = read_workspace_file("crates/aether-ai-serving/src/attempt_plan.rs");
|
let serving_attempt_plan = read_workspace_file("crates/aether-ai/serving/src/attempt_plan.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub fn build_ai_execution_decision_from_plan",
|
"pub fn build_ai_execution_decision_from_plan",
|
||||||
"pub fn infer_ai_upstream_base_url",
|
"pub fn infer_ai_upstream_base_url",
|
||||||
@@ -238,7 +238,7 @@ fn ai_serving_crate_owns_attempt_loop_without_gateway_runtime_deps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let serving_candidate_materialization =
|
let serving_candidate_materialization =
|
||||||
read_workspace_file("crates/aether-ai-serving/src/candidate_materialization.rs");
|
read_workspace_file("crates/aether-ai/serving/src/candidate_materialization.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub trait AiCandidateMaterializationPort",
|
"pub trait AiCandidateMaterializationPort",
|
||||||
"pub async fn run_ai_candidate_materialization",
|
"pub async fn run_ai_candidate_materialization",
|
||||||
@@ -270,7 +270,7 @@ fn ai_serving_crate_owns_attempt_loop_without_gateway_runtime_deps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let serving_candidate_preselection =
|
let serving_candidate_preselection =
|
||||||
read_workspace_file("crates/aether-ai-serving/src/candidate_preselection.rs");
|
read_workspace_file("crates/aether-ai/serving/src/candidate_preselection.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub trait AiCandidatePreselectionPort",
|
"pub trait AiCandidatePreselectionPort",
|
||||||
"pub async fn run_ai_candidate_preselection",
|
"pub async fn run_ai_candidate_preselection",
|
||||||
@@ -290,7 +290,7 @@ fn ai_serving_crate_owns_attempt_loop_without_gateway_runtime_deps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let serving_candidate_ranking =
|
let serving_candidate_ranking =
|
||||||
read_workspace_file("crates/aether-ai-serving/src/candidate_ranking.rs");
|
read_workspace_file("crates/aether-ai/serving/src/candidate_ranking.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub trait AiCandidateRankingPort",
|
"pub trait AiCandidateRankingPort",
|
||||||
"pub async fn run_ai_candidate_ranking",
|
"pub async fn run_ai_candidate_ranking",
|
||||||
@@ -312,7 +312,7 @@ fn ai_serving_crate_owns_attempt_loop_without_gateway_runtime_deps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let serving_candidate_resolution =
|
let serving_candidate_resolution =
|
||||||
read_workspace_file("crates/aether-ai-serving/src/candidate_resolution.rs");
|
read_workspace_file("crates/aether-ai/serving/src/candidate_resolution.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub trait AiCandidateResolutionPort",
|
"pub trait AiCandidateResolutionPort",
|
||||||
"pub async fn run_ai_candidate_resolution",
|
"pub async fn run_ai_candidate_resolution",
|
||||||
@@ -370,7 +370,7 @@ fn ai_serving_crate_owns_attempt_loop_without_gateway_runtime_deps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let provider_transport_conversion =
|
let provider_transport_conversion =
|
||||||
read_workspace_file("crates/aether-provider-transport/src/conversion.rs");
|
read_workspace_file("crates/aether-provider/transport/src/conversion.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub struct CandidateTransportPolicyFacts",
|
"pub struct CandidateTransportPolicyFacts",
|
||||||
"pub fn candidate_common_transport_skip_reason(",
|
"pub fn candidate_common_transport_skip_reason(",
|
||||||
@@ -445,7 +445,7 @@ fn ai_serving_crate_owns_attempt_loop_without_gateway_runtime_deps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let serving_execution_path =
|
let serving_execution_path =
|
||||||
read_workspace_file("crates/aether-ai-serving/src/execution_path.rs");
|
read_workspace_file("crates/aether-ai/serving/src/execution_path.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub enum AiSyncExecutionStep",
|
"pub enum AiSyncExecutionStep",
|
||||||
"pub enum AiStreamExecutionStep",
|
"pub enum AiStreamExecutionStep",
|
||||||
@@ -488,7 +488,7 @@ fn ai_serving_crate_owns_attempt_loop_without_gateway_runtime_deps() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_internal_dtos_use_ai_execution_names() {
|
fn ai_serving_internal_dtos_use_ai_execution_names() {
|
||||||
let serving_dto = read_workspace_file("crates/aether-ai-serving/src/dto.rs");
|
let serving_dto = read_workspace_file("crates/aether-ai/serving/src/dto.rs");
|
||||||
for expected in [
|
for expected in [
|
||||||
"pub struct AiExecutionDecision",
|
"pub struct AiExecutionDecision",
|
||||||
"pub struct AiExecutionPlanPayload",
|
"pub struct AiExecutionPlanPayload",
|
||||||
@@ -525,8 +525,8 @@ fn ai_serving_internal_dtos_use_ai_execution_names() {
|
|||||||
"apps/aether-gateway/src/ai_serving",
|
"apps/aether-gateway/src/ai_serving",
|
||||||
"apps/aether-gateway/src/executor",
|
"apps/aether-gateway/src/executor",
|
||||||
"apps/aether-gateway/src/execution_runtime",
|
"apps/aether-gateway/src/execution_runtime",
|
||||||
"crates/aether-ai-serving/src",
|
"crates/aether-ai/serving/src",
|
||||||
"crates/aether-ai-formats/src",
|
"crates/aether-ai/formats/src",
|
||||||
] {
|
] {
|
||||||
for file in collect_workspace_rust_files(root) {
|
for file in collect_workspace_rust_files(root) {
|
||||||
let relative = file
|
let relative = file
|
||||||
@@ -560,7 +560,7 @@ fn ai_serving_internal_dtos_use_ai_execution_names() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ai_format_crate_stays_free_of_gateway_runtime_deps() {
|
fn ai_format_crate_stays_free_of_gateway_runtime_deps() {
|
||||||
for manifest_path in ["crates/aether-ai-formats/Cargo.toml"] {
|
for manifest_path in ["crates/aether-ai/formats/Cargo.toml"] {
|
||||||
let manifest = read_workspace_file(manifest_path);
|
let manifest = read_workspace_file(manifest_path);
|
||||||
for forbidden in [
|
for forbidden in [
|
||||||
"axum",
|
"axum",
|
||||||
@@ -578,7 +578,7 @@ fn ai_format_crate_stays_free_of_gateway_runtime_deps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut violations = Vec::new();
|
let mut violations = Vec::new();
|
||||||
for root in ["crates/aether-ai-formats/src"] {
|
for root in ["crates/aether-ai/formats/src"] {
|
||||||
for file in collect_workspace_rust_files(root) {
|
for file in collect_workspace_rust_files(root) {
|
||||||
let source = std::fs::read_to_string(&file).expect("source file should be readable");
|
let source = std::fs::read_to_string(&file).expect("source file should be readable");
|
||||||
let hits = [
|
let hits = [
|
||||||
@@ -608,7 +608,7 @@ fn ai_format_crate_stays_free_of_gateway_runtime_deps() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aether_runtime_stays_free_of_ai_serving_policy() {
|
fn aether_runtime_stays_free_of_ai_serving_policy() {
|
||||||
let runtime_manifest = read_workspace_file("crates/aether-runtime/Cargo.toml");
|
let runtime_manifest = read_workspace_file("crates/aether-runtime/base/Cargo.toml");
|
||||||
for forbidden in [
|
for forbidden in [
|
||||||
"aether-ai-serving",
|
"aether-ai-serving",
|
||||||
"aether-ai-formats",
|
"aether-ai-formats",
|
||||||
@@ -622,7 +622,7 @@ fn aether_runtime_stays_free_of_ai_serving_policy() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut violations = Vec::new();
|
let mut violations = Vec::new();
|
||||||
for file in collect_workspace_rust_files("crates/aether-runtime/src") {
|
for file in collect_workspace_rust_files("crates/aether-runtime/base/src") {
|
||||||
let source = std::fs::read_to_string(&file).expect("source file should be readable");
|
let source = std::fs::read_to_string(&file).expect("source file should be readable");
|
||||||
let hits = [
|
let hits = [
|
||||||
"aether_ai_serving",
|
"aether_ai_serving",
|
||||||
@@ -824,7 +824,7 @@ fn ai_serving_routes_control_and_execution_deps_through_facades() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let serving_attempt_plan = read_workspace_file("crates/aether-ai-serving/src/attempt_plan.rs");
|
let serving_attempt_plan = read_workspace_file("crates/aether-ai/serving/src/attempt_plan.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub fn take_ai_decision_plan_core(",
|
"pub fn take_ai_decision_plan_core(",
|
||||||
"pub fn take_ai_upstream_auth_pair(",
|
"pub fn take_ai_upstream_auth_pair(",
|
||||||
@@ -871,13 +871,15 @@ fn ai_serving_routes_control_and_execution_deps_through_facades() {
|
|||||||
let gateway_finalize_common =
|
let gateway_finalize_common =
|
||||||
read_workspace_file("apps/aether-gateway/src/ai_serving/finalize/common.rs");
|
read_workspace_file("apps/aether-gateway/src/ai_serving/finalize/common.rs");
|
||||||
assert!(
|
assert!(
|
||||||
gateway_finalize_common
|
gateway_finalize_common.contains(
|
||||||
.contains("prepare_local_success_response_parts as prepare_local_success_response_parts_impl"),
|
"prepare_local_success_response_parts as prepare_local_success_response_parts_impl"
|
||||||
|
),
|
||||||
"finalize/common.rs should delegate success response-part normalization to the format crate"
|
"finalize/common.rs should delegate success response-part normalization to the format crate"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
gateway_finalize_common
|
gateway_finalize_common.contains(
|
||||||
.contains("build_local_success_background_report as build_local_success_background_report_impl"),
|
"build_local_success_background_report as build_local_success_background_report_impl"
|
||||||
|
),
|
||||||
"finalize/common.rs should delegate pure success background-report construction to the format crate"
|
"finalize/common.rs should delegate pure success background-report construction to the format crate"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1009,7 +1011,7 @@ fn ai_serving_routes_provider_transport_deps_through_facade() {
|
|||||||
"ai_serving/runtime should stay removed after facade cleanup"
|
"ai_serving/runtime should stay removed after facade cleanup"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!workspace_file_exists("crates/aether-ai-formats/src/transport.rs"),
|
!workspace_file_exists("crates/aether-ai/formats/src/transport.rs"),
|
||||||
"aether-ai-formats should not expose a provider transport bridge"
|
"aether-ai-formats should not expose a provider transport bridge"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1384,7 +1386,7 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let serving_lib = read_workspace_file("crates/aether-ai-serving/src/lib.rs");
|
let serving_lib = read_workspace_file("crates/aether-ai/serving/src/lib.rs");
|
||||||
for forbidden in ["pub mod pool_scheduler;", "pub mod pool_scores;"] {
|
for forbidden in ["pub mod pool_scheduler;", "pub mod pool_scores;"] {
|
||||||
assert!(
|
assert!(
|
||||||
!serving_lib.contains(forbidden),
|
!serving_lib.contains(forbidden),
|
||||||
@@ -1392,7 +1394,7 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let provider_pool_lib = read_workspace_file("crates/aether-provider-pool/src/lib.rs");
|
let provider_pool_lib = read_workspace_file("crates/aether-provider/pool/src/lib.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"mod capability;",
|
"mod capability;",
|
||||||
"mod plan;",
|
"mod plan;",
|
||||||
@@ -1410,7 +1412,7 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let provider_pool_provider = read_workspace_file("crates/aether-provider-pool/src/provider.rs");
|
let provider_pool_provider = read_workspace_file("crates/aether-provider/pool/src/provider.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub trait ProviderPoolAdapter",
|
"pub trait ProviderPoolAdapter",
|
||||||
"ProviderPoolMemberInput",
|
"ProviderPoolMemberInput",
|
||||||
@@ -1423,7 +1425,7 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let provider_pool_service = read_workspace_file("crates/aether-provider-pool/src/service.rs");
|
let provider_pool_service = read_workspace_file("crates/aether-provider/pool/src/service.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub struct ProviderPoolService",
|
"pub struct ProviderPoolService",
|
||||||
"with_builtin_adapters",
|
"with_builtin_adapters",
|
||||||
@@ -1449,7 +1451,7 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let provider_pool_providers =
|
let provider_pool_providers =
|
||||||
read_workspace_file("crates/aether-provider-pool/src/providers/mod.rs");
|
read_workspace_file("crates/aether-provider/pool/src/providers/mod.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub mod default;",
|
"pub mod default;",
|
||||||
"pub mod unsupported;",
|
"pub mod unsupported;",
|
||||||
@@ -1466,15 +1468,15 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() {
|
|||||||
}
|
}
|
||||||
for (path, patterns) in [
|
for (path, patterns) in [
|
||||||
(
|
(
|
||||||
"crates/aether-provider-pool/src/providers/default.rs",
|
"crates/aether-provider/pool/src/providers/default.rs",
|
||||||
vec!["DefaultProviderPoolAdapter"],
|
vec!["DefaultProviderPoolAdapter"],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"crates/aether-provider-pool/src/providers/antigravity.rs",
|
"crates/aether-provider/pool/src/providers/antigravity.rs",
|
||||||
vec!["AntigravityProviderPoolAdapter"],
|
vec!["AntigravityProviderPoolAdapter"],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"crates/aether-provider-pool/src/providers/codex.rs",
|
"crates/aether-provider/pool/src/providers/codex.rs",
|
||||||
vec![
|
vec![
|
||||||
"CodexProviderPoolAdapter",
|
"CodexProviderPoolAdapter",
|
||||||
"recent_refresh",
|
"recent_refresh",
|
||||||
@@ -1482,7 +1484,7 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"crates/aether-provider-pool/src/providers/gemini_cli.rs",
|
"crates/aether-provider/pool/src/providers/gemini_cli.rs",
|
||||||
vec![
|
vec![
|
||||||
"GeminiCliProviderPoolAdapter",
|
"GeminiCliProviderPoolAdapter",
|
||||||
"build_gemini_cli_pool_quota_request",
|
"build_gemini_cli_pool_quota_request",
|
||||||
@@ -1490,11 +1492,11 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"crates/aether-provider-pool/src/providers/kiro.rs",
|
"crates/aether-provider/pool/src/providers/kiro.rs",
|
||||||
vec!["KiroProviderPoolAdapter", "quota_exhausted_from_bucket"],
|
vec!["KiroProviderPoolAdapter", "quota_exhausted_from_bucket"],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"crates/aether-provider-pool/src/providers/chatgpt_web.rs",
|
"crates/aether-provider/pool/src/providers/chatgpt_web.rs",
|
||||||
vec![
|
vec![
|
||||||
"ChatGptWebProviderPoolAdapter",
|
"ChatGptWebProviderPoolAdapter",
|
||||||
"build_chatgpt_web_pool_quota_request",
|
"build_chatgpt_web_pool_quota_request",
|
||||||
@@ -1504,7 +1506,7 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"crates/aether-provider-pool/src/providers/unsupported.rs",
|
"crates/aether-provider/pool/src/providers/unsupported.rs",
|
||||||
vec![
|
vec![
|
||||||
"UnsupportedQuotaProviderPoolAdapter",
|
"UnsupportedQuotaProviderPoolAdapter",
|
||||||
"CLAUDE_CODE_PROVIDER_POOL_ADAPTER",
|
"CLAUDE_CODE_PROVIDER_POOL_ADAPTER",
|
||||||
@@ -1521,7 +1523,7 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let provider_pool_plan = read_workspace_file("crates/aether-provider-pool/src/plan.rs");
|
let provider_pool_plan = read_workspace_file("crates/aether-provider/pool/src/plan.rs");
|
||||||
for pattern in ["normalize_provider_plan_tier", "derive_plan_tier"] {
|
for pattern in ["normalize_provider_plan_tier", "derive_plan_tier"] {
|
||||||
assert!(
|
assert!(
|
||||||
provider_pool_plan.contains(pattern),
|
provider_pool_plan.contains(pattern),
|
||||||
@@ -1529,7 +1531,7 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let provider_pool_quota = read_workspace_file("crates/aether-provider-pool/src/quota.rs");
|
let provider_pool_quota = read_workspace_file("crates/aether-provider/pool/src/quota.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"provider_pool_key_account_quota_exhausted",
|
"provider_pool_key_account_quota_exhausted",
|
||||||
"provider_pool_member_quota_snapshot",
|
"provider_pool_member_quota_snapshot",
|
||||||
@@ -1544,7 +1546,7 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let provider_pool_presets = read_workspace_file("crates/aether-provider-pool/src/presets.rs");
|
let provider_pool_presets = read_workspace_file("crates/aether-provider/pool/src/presets.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"normalize_provider_scheduling_presets",
|
"normalize_provider_scheduling_presets",
|
||||||
"build_admin_pool_scheduling_presets_payload",
|
"build_admin_pool_scheduling_presets_payload",
|
||||||
@@ -1561,7 +1563,7 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() {
|
|||||||
"plan_priority_score(",
|
"plan_priority_score(",
|
||||||
] {
|
] {
|
||||||
let mut violations = Vec::new();
|
let mut violations = Vec::new();
|
||||||
for file in collect_workspace_rust_files("crates/aether-provider-pool/src") {
|
for file in collect_workspace_rust_files("crates/aether-provider/pool/src") {
|
||||||
let source = std::fs::read_to_string(&file).expect("source file should be readable");
|
let source = std::fs::read_to_string(&file).expect("source file should be readable");
|
||||||
if source.contains(forbidden) {
|
if source.contains(forbidden) {
|
||||||
violations.push(file.display().to_string());
|
violations.push(file.display().to_string());
|
||||||
@@ -1578,7 +1580,7 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_candidate_preparation_owns_shared_auth_and_mapped_model_resolution() {
|
fn ai_serving_candidate_preparation_owns_shared_auth_and_mapped_model_resolution() {
|
||||||
let serving_candidate_preparation =
|
let serving_candidate_preparation =
|
||||||
read_workspace_file("crates/aether-ai-serving/src/candidate_preparation.rs");
|
read_workspace_file("crates/aether-ai/serving/src/candidate_preparation.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub struct AiPreparedHeaderAuthenticatedCandidate",
|
"pub struct AiPreparedHeaderAuthenticatedCandidate",
|
||||||
"pub fn prepare_ai_header_authenticated_candidate(",
|
"pub fn prepare_ai_header_authenticated_candidate(",
|
||||||
@@ -1592,7 +1594,7 @@ fn ai_serving_candidate_preparation_owns_shared_auth_and_mapped_model_resolution
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let serving_lib = read_workspace_file("crates/aether-ai-serving/src/lib.rs");
|
let serving_lib = read_workspace_file("crates/aether-ai/serving/src/lib.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub mod candidate_preparation;",
|
"pub mod candidate_preparation;",
|
||||||
"prepare_ai_header_authenticated_candidate",
|
"prepare_ai_header_authenticated_candidate",
|
||||||
@@ -1690,7 +1692,7 @@ fn ai_serving_candidate_materialization_owns_affinity_and_candidate_runtime_pers
|
|||||||
);
|
);
|
||||||
|
|
||||||
let serving_candidate_persistence =
|
let serving_candidate_persistence =
|
||||||
read_workspace_file("crates/aether-ai-serving/src/candidate_persistence.rs");
|
read_workspace_file("crates/aether-ai/serving/src/candidate_persistence.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub trait AiAvailableCandidatePersistencePort",
|
"pub trait AiAvailableCandidatePersistencePort",
|
||||||
"pub async fn run_ai_available_candidate_persistence",
|
"pub async fn run_ai_available_candidate_persistence",
|
||||||
@@ -1837,7 +1839,7 @@ fn ai_serving_materialization_policy_owns_local_candidate_persistence_modes() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let serving_candidate_persistence_policy =
|
let serving_candidate_persistence_policy =
|
||||||
read_workspace_file("crates/aether-ai-serving/src/candidate_persistence_policy.rs");
|
read_workspace_file("crates/aether-ai/serving/src/candidate_persistence_policy.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub enum AiCandidatePersistencePolicyKind {",
|
"pub enum AiCandidatePersistencePolicyKind {",
|
||||||
"pub struct AiCandidatePersistencePolicySpec {",
|
"pub struct AiCandidatePersistencePolicySpec {",
|
||||||
@@ -1913,9 +1915,9 @@ fn ai_serving_candidate_metadata_owns_local_execution_candidate_extra_data_shape
|
|||||||
let candidate_metadata =
|
let candidate_metadata =
|
||||||
read_workspace_file("apps/aether-gateway/src/ai_serving/planner/candidate_metadata.rs");
|
read_workspace_file("apps/aether-gateway/src/ai_serving/planner/candidate_metadata.rs");
|
||||||
let serving_ranking_metadata =
|
let serving_ranking_metadata =
|
||||||
read_workspace_file("crates/aether-ai-serving/src/ranking_metadata.rs");
|
read_workspace_file("crates/aether-ai/serving/src/ranking_metadata.rs");
|
||||||
let serving_candidate_metadata =
|
let serving_candidate_metadata =
|
||||||
read_workspace_file("crates/aether-ai-serving/src/candidate_metadata.rs");
|
read_workspace_file("crates/aether-ai/serving/src/candidate_metadata.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub struct AiCandidateMetadataParts<'a> {",
|
"pub struct AiCandidateMetadataParts<'a> {",
|
||||||
"pub fn build_ai_candidate_metadata(",
|
"pub fn build_ai_candidate_metadata(",
|
||||||
@@ -2015,7 +2017,7 @@ fn ai_serving_runtime_miss_owns_local_execution_miss_state_machine() {
|
|||||||
"planner/mod.rs should wire runtime_miss helper module"
|
"planner/mod.rs should wire runtime_miss helper module"
|
||||||
);
|
);
|
||||||
|
|
||||||
let serving_runtime_miss = read_workspace_file("crates/aether-ai-serving/src/runtime_miss.rs");
|
let serving_runtime_miss = read_workspace_file("crates/aether-ai/serving/src/runtime_miss.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub trait AiRuntimeMissDiagnosticPort",
|
"pub trait AiRuntimeMissDiagnosticPort",
|
||||||
"pub trait AiRuntimeMissDiagnosticFields",
|
"pub trait AiRuntimeMissDiagnosticFields",
|
||||||
@@ -2264,7 +2266,7 @@ fn ai_serving_video_routes_request_preparation_through_request_payload_seams() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let provider_transport_video =
|
let provider_transport_video =
|
||||||
read_workspace_file("crates/aether-provider-transport/src/video/mod.rs");
|
read_workspace_file("crates/aether-provider/transport/src/video/mod.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub enum ProviderVideoCreateFamily",
|
"pub enum ProviderVideoCreateFamily",
|
||||||
"pub fn video_create_transport_unsupported_reason(",
|
"pub fn video_create_transport_unsupported_reason(",
|
||||||
@@ -2339,7 +2341,7 @@ fn ai_serving_files_routes_request_preparation_through_request_payload_seams() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let provider_transport_files =
|
let provider_transport_files =
|
||||||
read_workspace_file("crates/aether-provider-transport/src/gemini_files/mod.rs");
|
read_workspace_file("crates/aether-provider/transport/src/gemini_files/mod.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub fn gemini_files_transport_unsupported_reason(",
|
"pub fn gemini_files_transport_unsupported_reason(",
|
||||||
"pub fn resolve_gemini_files_auth(",
|
"pub fn resolve_gemini_files_auth(",
|
||||||
@@ -2409,7 +2411,7 @@ fn ai_serving_image_routes_split_surface_normalization_and_transport_policy() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let surface_image =
|
let surface_image =
|
||||||
read_workspace_file("crates/aether-ai-formats/src/formats/openai/image/request.rs");
|
read_workspace_file("crates/aether-ai/formats/src/formats/openai/image/request.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub enum OpenAiImageOperation",
|
"pub enum OpenAiImageOperation",
|
||||||
"pub fn is_openai_image_stream_request(",
|
"pub fn is_openai_image_stream_request(",
|
||||||
@@ -2425,7 +2427,7 @@ fn ai_serving_image_routes_split_surface_normalization_and_transport_policy() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let provider_transport_image =
|
let provider_transport_image =
|
||||||
read_workspace_file("crates/aether-provider-transport/src/openai_image/mod.rs");
|
read_workspace_file("crates/aether-provider/transport/src/openai_image/mod.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub fn openai_image_transport_unsupported_reason(",
|
"pub fn openai_image_transport_unsupported_reason(",
|
||||||
"pub fn resolve_openai_image_auth(",
|
"pub fn resolve_openai_image_auth(",
|
||||||
@@ -2605,7 +2607,7 @@ fn ai_serving_payload_metadata_owns_local_execution_decision_response_shape() {
|
|||||||
"gateway payload_metadata.rs should be removed after serving extraction"
|
"gateway payload_metadata.rs should be removed after serving extraction"
|
||||||
);
|
);
|
||||||
|
|
||||||
let decision_payload = read_workspace_file("crates/aether-ai-serving/src/decision_payload.rs");
|
let decision_payload = read_workspace_file("crates/aether-ai/serving/src/decision_payload.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub struct AiExecutionDecisionResponseParts {",
|
"pub struct AiExecutionDecisionResponseParts {",
|
||||||
"pub fn build_ai_execution_decision_response(",
|
"pub fn build_ai_execution_decision_response(",
|
||||||
@@ -2651,7 +2653,7 @@ fn ai_serving_owns_pure_planner_diagnostics_and_execution_labels() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let serving_failure_diagnostic =
|
let serving_failure_diagnostic =
|
||||||
read_workspace_file("crates/aether-ai-serving/src/failure_diagnostic.rs");
|
read_workspace_file("crates/aether-ai/serving/src/failure_diagnostic.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub enum CandidateFailureDiagnosticKind {",
|
"pub enum CandidateFailureDiagnosticKind {",
|
||||||
"pub struct CandidateFailureDiagnostic {",
|
"pub struct CandidateFailureDiagnostic {",
|
||||||
@@ -2667,7 +2669,7 @@ fn ai_serving_owns_pure_planner_diagnostics_and_execution_labels() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let serving_request_body_diagnostics =
|
let serving_request_body_diagnostics =
|
||||||
read_workspace_file("crates/aether-ai-serving/src/request_body_diagnostics.rs");
|
read_workspace_file("crates/aether-ai/serving/src/request_body_diagnostics.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub fn request_body_build_failure_extra_data(",
|
"pub fn request_body_build_failure_extra_data(",
|
||||||
"pub fn same_format_provider_request_body_failure_extra_data(",
|
"pub fn same_format_provider_request_body_failure_extra_data(",
|
||||||
@@ -2692,7 +2694,7 @@ fn ai_serving_owns_pure_planner_diagnostics_and_execution_labels() {
|
|||||||
"gateway standard planner should consume request-body diagnostics from aether-ai-serving"
|
"gateway standard planner should consume request-body diagnostics from aether-ai-serving"
|
||||||
);
|
);
|
||||||
|
|
||||||
let serving_dto = read_workspace_file("crates/aether-ai-serving/src/dto.rs");
|
let serving_dto = read_workspace_file("crates/aether-ai/serving/src/dto.rs");
|
||||||
for pattern in ["pub enum ExecutionStrategy", "pub enum ConversionMode"] {
|
for pattern in ["pub enum ExecutionStrategy", "pub enum ConversionMode"] {
|
||||||
assert!(
|
assert!(
|
||||||
serving_dto.contains(pattern),
|
serving_dto.contains(pattern),
|
||||||
@@ -2726,7 +2728,7 @@ fn ai_serving_report_context_owns_local_execution_context_shape() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let serving_report_context =
|
let serving_report_context =
|
||||||
read_workspace_file("crates/aether-ai-serving/src/report_context.rs");
|
read_workspace_file("crates/aether-ai/serving/src/report_context.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub struct AiExecutionReportContextParts<'a> {",
|
"pub struct AiExecutionReportContextParts<'a> {",
|
||||||
"pub fn build_ai_execution_report_context(",
|
"pub fn build_ai_execution_report_context(",
|
||||||
@@ -2878,7 +2880,7 @@ fn ai_serving_standard_attempts_consume_eligible_local_candidates_without_transp
|
|||||||
}
|
}
|
||||||
|
|
||||||
let provider_transport_standard =
|
let provider_transport_standard =
|
||||||
read_workspace_file("crates/aether-provider-transport/src/standard/mod.rs");
|
read_workspace_file("crates/aether-provider/transport/src/standard/mod.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub struct StandardProviderRequestHeadersInput",
|
"pub struct StandardProviderRequestHeadersInput",
|
||||||
"pub struct StandardProviderRequestHeaders",
|
"pub struct StandardProviderRequestHeaders",
|
||||||
@@ -2898,7 +2900,7 @@ fn ai_serving_standard_attempts_consume_eligible_local_candidates_without_transp
|
|||||||
}
|
}
|
||||||
|
|
||||||
let provider_transport_request_url =
|
let provider_transport_request_url =
|
||||||
read_workspace_file("crates/aether-provider-transport/src/request_url/mod.rs");
|
read_workspace_file("crates/aether-provider/transport/src/request_url/mod.rs");
|
||||||
assert!(
|
assert!(
|
||||||
provider_transport_request_url.contains("pub fn build_kiro_cross_format_upstream_url("),
|
provider_transport_request_url.contains("pub fn build_kiro_cross_format_upstream_url("),
|
||||||
"provider-transport request_url.rs should own Kiro cross-format URL hook"
|
"provider-transport request_url.rs should own Kiro cross-format URL hook"
|
||||||
@@ -2960,7 +2962,7 @@ fn ai_serving_standard_plan_builders_delegate_fallback_transport_policy() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let provider_transport_standard =
|
let provider_transport_standard =
|
||||||
read_workspace_file("crates/aether-provider-transport/src/standard/mod.rs");
|
read_workspace_file("crates/aether-provider/transport/src/standard/mod.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub enum StandardPlanFallbackAcceptPolicy",
|
"pub enum StandardPlanFallbackAcceptPolicy",
|
||||||
"pub struct StandardPlanFallbackHeadersInput",
|
"pub struct StandardPlanFallbackHeadersInput",
|
||||||
@@ -3063,7 +3065,7 @@ fn ai_serving_spec_metadata_owns_family_requested_model_and_plan_builder_routing
|
|||||||
|
|
||||||
let spec_metadata =
|
let spec_metadata =
|
||||||
read_workspace_file("apps/aether-gateway/src/ai_serving/planner/spec_metadata.rs");
|
read_workspace_file("apps/aether-gateway/src/ai_serving/planner/spec_metadata.rs");
|
||||||
let serving_surface_spec = read_workspace_file("crates/aether-ai-serving/src/surface_spec.rs");
|
let serving_surface_spec = read_workspace_file("crates/aether-ai/serving/src/surface_spec.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub enum AiRequestedModelFamily {",
|
"pub enum AiRequestedModelFamily {",
|
||||||
"pub struct AiExecutionSurfaceSpecMetadata {",
|
"pub struct AiExecutionSurfaceSpecMetadata {",
|
||||||
@@ -3228,7 +3230,7 @@ fn ai_serving_same_format_provider_request_policy_owns_provider_type_behavior()
|
|||||||
"apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request/policy.rs",
|
"apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request/policy.rs",
|
||||||
);
|
);
|
||||||
let provider_transport_policy =
|
let provider_transport_policy =
|
||||||
read_workspace_file("crates/aether-provider-transport/src/same_format_provider/mod.rs");
|
read_workspace_file("crates/aether-provider/transport/src/same_format_provider/mod.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub struct SameFormatProviderRequestBehavior {",
|
"pub struct SameFormatProviderRequestBehavior {",
|
||||||
"pub struct SameFormatProviderRequestBodyInput",
|
"pub struct SameFormatProviderRequestBodyInput",
|
||||||
@@ -3314,7 +3316,7 @@ fn ai_serving_decision_inputs_share_authenticated_input_helper() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let serving_decision_input =
|
let serving_decision_input =
|
||||||
read_workspace_file("crates/aether-ai-serving/src/decision_input.rs");
|
read_workspace_file("crates/aether-ai/serving/src/decision_input.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub trait AiAuthenticatedDecisionInputPort",
|
"pub trait AiAuthenticatedDecisionInputPort",
|
||||||
"pub async fn run_ai_authenticated_decision_input",
|
"pub async fn run_ai_authenticated_decision_input",
|
||||||
@@ -3488,7 +3490,8 @@ fn ai_serving_leaf_planner_owners_route_contract_specs_through_gateway_seams() {
|
|||||||
"apps/aether-gateway/src/ai_serving/planner/specialized/video/support.rs",
|
"apps/aether-gateway/src/ai_serving/planner/specialized/video/support.rs",
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
specialized_video_support.contains("use super::{LocalVideoCreateFamily, LocalVideoCreateSpec};"),
|
specialized_video_support
|
||||||
|
.contains("use super::{LocalVideoCreateFamily, LocalVideoCreateSpec};"),
|
||||||
"planner/specialized/video/support.rs should use local video seams for LocalVideoCreate* types"
|
"planner/specialized/video/support.rs should use local video seams for LocalVideoCreate* types"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3496,10 +3499,10 @@ fn ai_serving_leaf_planner_owners_route_contract_specs_through_gateway_seams() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_m5_moves_contracts_and_route_logic_into_format_crate() {
|
fn ai_serving_m5_moves_contracts_and_route_logic_into_format_crate() {
|
||||||
for path in [
|
for path in [
|
||||||
"crates/aether-ai-formats/src/contracts/actions.rs",
|
"crates/aether-ai/formats/src/contracts/actions.rs",
|
||||||
"crates/aether-ai-formats/src/contracts/plan_kinds.rs",
|
"crates/aether-ai/formats/src/contracts/plan_kinds.rs",
|
||||||
"crates/aether-ai-formats/src/contracts/report_kinds.rs",
|
"crates/aether-ai/formats/src/contracts/report_kinds.rs",
|
||||||
"crates/aether-ai-formats/src/formats/shared/routing.rs",
|
"crates/aether-ai/formats/src/formats/shared/routing.rs",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists(path),
|
workspace_file_exists(path),
|
||||||
@@ -3561,7 +3564,7 @@ fn ai_serving_m5_moves_contracts_and_route_logic_into_format_crate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let surface_route =
|
let surface_route =
|
||||||
read_workspace_file("crates/aether-ai-formats/src/formats/shared/routing.rs");
|
read_workspace_file("crates/aether-ai/formats/src/formats/shared/routing.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub fn is_matching_stream_http_request(",
|
"pub fn is_matching_stream_http_request(",
|
||||||
"is_openai_image_stream_request(parts, body_json, body_base64)",
|
"is_openai_image_stream_request(parts, body_json, body_base64)",
|
||||||
@@ -3614,12 +3617,12 @@ fn ai_serving_m5_moves_contracts_and_route_logic_into_format_crate() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_m5_moves_kiro_stream_helpers_into_format_crate() {
|
fn ai_serving_m5_moves_kiro_stream_helpers_into_format_crate() {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/provider_compat/kiro_stream.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/provider_compat/kiro_stream.rs"),
|
||||||
"crates/aether-ai-formats/src/provider_compat/kiro_stream.rs should exist after kiro helper extraction"
|
"crates/aether-ai/formats/src/provider_compat/kiro_stream.rs should exist after kiro helper extraction"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/provider_compat/kiro_stream/state.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/provider_compat/kiro_stream/state.rs"),
|
||||||
"crates/aether-ai-formats/src/provider_compat/kiro_stream/state.rs should own the Kiro stream state machine"
|
"crates/aether-ai/formats/src/provider_compat/kiro_stream/state.rs should own the Kiro stream state machine"
|
||||||
);
|
);
|
||||||
|
|
||||||
for path in [
|
for path in [
|
||||||
@@ -3633,7 +3636,7 @@ fn ai_serving_m5_moves_kiro_stream_helpers_into_format_crate() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let surface_api = read_workspace_file("crates/aether-ai-formats/src/api.rs");
|
let surface_api = read_workspace_file("crates/aether-ai/formats/src/api.rs");
|
||||||
assert!(
|
assert!(
|
||||||
surface_api.contains("KiroToClaudeCliStreamState"),
|
surface_api.contains("KiroToClaudeCliStreamState"),
|
||||||
"aether-ai-formats api should export KiroToClaudeCliStreamState"
|
"aether-ai-formats api should export KiroToClaudeCliStreamState"
|
||||||
@@ -3670,7 +3673,7 @@ fn ai_serving_m5_moves_kiro_stream_helpers_into_format_crate() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_private_envelope_stream_normalizer_is_owned_by_format_crate() {
|
fn ai_serving_private_envelope_stream_normalizer_is_owned_by_format_crate() {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/provider_compat/private_envelope.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/provider_compat/private_envelope.rs"),
|
||||||
"surface private envelope adapter should exist"
|
"surface private envelope adapter should exist"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -3687,7 +3690,7 @@ fn ai_serving_private_envelope_stream_normalizer_is_owned_by_format_crate() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let surface_private_envelope =
|
let surface_private_envelope =
|
||||||
read_workspace_file("crates/aether-ai-formats/src/provider_compat/private_envelope.rs");
|
read_workspace_file("crates/aether-ai/formats/src/provider_compat/private_envelope.rs");
|
||||||
for expected in [
|
for expected in [
|
||||||
"pub struct ProviderPrivateStreamNormalizer",
|
"pub struct ProviderPrivateStreamNormalizer",
|
||||||
"pub fn maybe_build_provider_private_stream_normalizer",
|
"pub fn maybe_build_provider_private_stream_normalizer",
|
||||||
@@ -3810,11 +3813,11 @@ fn ai_serving_error_body_is_owned_by_format_finalize_module() {
|
|||||||
"ai_serving/conversion/error.rs should stay removed"
|
"ai_serving/conversion/error.rs should stay removed"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/shared/error_body.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/shared/error_body.rs"),
|
||||||
"format error response-body helpers should live under finalize/error_body.rs"
|
"format error response-body helpers should live under finalize/error_body.rs"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!workspace_file_exists("crates/aether-ai-formats/src/formats/conversion/error.rs"),
|
!workspace_file_exists("crates/aether-ai/formats/src/formats/conversion/error.rs"),
|
||||||
"aether-ai-formats should not keep error response-body helpers under conversion"
|
"aether-ai-formats should not keep error response-body helpers under conversion"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -3839,7 +3842,7 @@ fn ai_serving_error_body_is_owned_by_format_finalize_module() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_conversion_request_is_owned_by_format_crate() {
|
fn ai_serving_conversion_request_is_owned_by_format_crate() {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/conversion/request.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/conversion/request.rs"),
|
||||||
"request conversion should live in aether-ai-formats"
|
"request conversion should live in aether-ai-formats"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -3864,7 +3867,7 @@ fn ai_serving_conversion_request_is_owned_by_format_crate() {
|
|||||||
"gateway ai_serving/mod.rs should not keep request re-export shell after root-seam consolidation"
|
"gateway ai_serving/mod.rs should not keep request re-export shell after root-seam consolidation"
|
||||||
);
|
);
|
||||||
|
|
||||||
let surface_api = read_workspace_file("crates/aether-ai-formats/src/api.rs");
|
let surface_api = read_workspace_file("crates/aether-ai/formats/src/api.rs");
|
||||||
assert!(
|
assert!(
|
||||||
surface_api.contains("pub use aether_ai_formats::formats::conversion::request::{"),
|
surface_api.contains("pub use aether_ai_formats::formats::conversion::request::{"),
|
||||||
"format API facade should re-export request conversion directly from aether-ai-formats"
|
"format API facade should re-export request conversion directly from aether-ai-formats"
|
||||||
@@ -3874,7 +3877,7 @@ fn ai_serving_conversion_request_is_owned_by_format_crate() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_conversion_response_is_owned_by_format_crate() {
|
fn ai_serving_conversion_response_is_owned_by_format_crate() {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/conversion/response.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/conversion/response.rs"),
|
||||||
"response conversion should live in aether-ai-formats"
|
"response conversion should live in aether-ai-formats"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -3899,7 +3902,7 @@ fn ai_serving_conversion_response_is_owned_by_format_crate() {
|
|||||||
"gateway ai_serving/mod.rs should not keep response re-export shell after root-seam consolidation"
|
"gateway ai_serving/mod.rs should not keep response re-export shell after root-seam consolidation"
|
||||||
);
|
);
|
||||||
|
|
||||||
let surface_api = read_workspace_file("crates/aether-ai-formats/src/api.rs");
|
let surface_api = read_workspace_file("crates/aether-ai/formats/src/api.rs");
|
||||||
assert!(
|
assert!(
|
||||||
surface_api.contains("pub use aether_ai_formats::formats::conversion::response::{"),
|
surface_api.contains("pub use aether_ai_formats::formats::conversion::response::{"),
|
||||||
"format API facade should re-export response conversion directly from aether-ai-formats"
|
"format API facade should re-export response conversion directly from aether-ai-formats"
|
||||||
@@ -3909,17 +3912,17 @@ fn ai_serving_conversion_response_is_owned_by_format_crate() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_format_crate_owns_conversion_and_surface_facade() {
|
fn ai_format_crate_owns_conversion_and_surface_facade() {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/conversion"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/conversion"),
|
||||||
"aether-ai-formats should own the conversion directory"
|
"aether-ai-formats should own the conversion directory"
|
||||||
);
|
);
|
||||||
|
|
||||||
let surface_lib = read_workspace_file("crates/aether-ai-formats/src/lib.rs");
|
let surface_lib = read_workspace_file("crates/aether-ai/formats/src/lib.rs");
|
||||||
assert!(
|
assert!(
|
||||||
surface_lib.contains("pub mod protocol;"),
|
surface_lib.contains("pub mod protocol;"),
|
||||||
"aether-ai-formats lib.rs should expose the protocol module"
|
"aether-ai-formats lib.rs should expose the protocol module"
|
||||||
);
|
);
|
||||||
|
|
||||||
let surface_api = read_workspace_file("crates/aether-ai-formats/src/api.rs");
|
let surface_api = read_workspace_file("crates/aether-ai/formats/src/api.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub use aether_ai_formats::{",
|
"pub use aether_ai_formats::{",
|
||||||
"pub use aether_ai_formats::formats::conversion::request::{",
|
"pub use aether_ai_formats::formats::conversion::request::{",
|
||||||
@@ -4003,12 +4006,12 @@ fn ai_serving_finalize_standard_sync_response_converters_are_owned_by_format_cra
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_finalize_stream_engine_is_owned_by_format_crate() {
|
fn ai_serving_finalize_stream_engine_is_owned_by_format_crate() {
|
||||||
for path in [
|
for path in [
|
||||||
"crates/aether-ai-formats/src/formats/shared/sse.rs",
|
"crates/aether-ai/formats/src/formats/shared/sse.rs",
|
||||||
"crates/aether-ai-formats/src/formats/shared/stream_core/common.rs",
|
"crates/aether-ai/formats/src/formats/shared/stream_core/common.rs",
|
||||||
"crates/aether-ai-formats/src/formats/shared/stream_core/format_matrix.rs",
|
"crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs",
|
||||||
"crates/aether-ai-formats/src/formats/openai/chat/stream.rs",
|
"crates/aether-ai/formats/src/formats/openai/chat/stream.rs",
|
||||||
"crates/aether-ai-formats/src/formats/claude/messages/stream.rs",
|
"crates/aether-ai/formats/src/formats/claude/messages/stream.rs",
|
||||||
"crates/aether-ai-formats/src/formats/gemini/generate_content/stream.rs",
|
"crates/aether-ai/formats/src/formats/gemini/generate_content/stream.rs",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists(path),
|
workspace_file_exists(path),
|
||||||
@@ -4044,7 +4047,7 @@ fn ai_serving_finalize_stream_engine_is_owned_by_format_crate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let surface_format_matrix = read_workspace_file(
|
let surface_format_matrix = read_workspace_file(
|
||||||
"crates/aether-ai-formats/src/formats/shared/stream_core/format_matrix.rs",
|
"crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs",
|
||||||
);
|
);
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub struct StreamingStandardFormatMatrix",
|
"pub struct StreamingStandardFormatMatrix",
|
||||||
@@ -4068,16 +4071,16 @@ fn ai_serving_finalize_stream_engine_is_owned_by_format_crate() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_finalize_standard_sync_products_are_owned_by_format_crate() {
|
fn ai_serving_finalize_standard_sync_products_are_owned_by_format_crate() {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/shared/sync_products.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/shared/sync_products.rs"),
|
||||||
"finalize sync_products should live in aether-ai-formats"
|
"finalize sync_products should live in aether-ai-formats"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/shared/sync_to_stream.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/shared/sync_to_stream.rs"),
|
||||||
"finalize sync-to-stream bridge should live in aether-ai-formats"
|
"finalize sync-to-stream bridge should live in aether-ai-formats"
|
||||||
);
|
);
|
||||||
|
|
||||||
let surface_sync_products =
|
let surface_sync_products =
|
||||||
read_workspace_file("crates/aether-ai-formats/src/formats/shared/sync_products.rs");
|
read_workspace_file("crates/aether-ai/formats/src/formats/shared/sync_products.rs");
|
||||||
for expected in [
|
for expected in [
|
||||||
"pub fn maybe_build_standard_cross_format_sync_product_from_normalized_payload(",
|
"pub fn maybe_build_standard_cross_format_sync_product_from_normalized_payload(",
|
||||||
"pub fn maybe_build_standard_same_format_sync_body_from_normalized_payload(",
|
"pub fn maybe_build_standard_same_format_sync_body_from_normalized_payload(",
|
||||||
@@ -4167,9 +4170,8 @@ fn ai_serving_finalize_standard_sync_products_are_owned_by_format_crate() {
|
|||||||
"apps/aether-gateway/src/ai_serving/finalize/internal/sync_finalize.rs",
|
"apps/aether-gateway/src/ai_serving/finalize/internal/sync_finalize.rs",
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
gateway_internal_sync.contains(
|
gateway_internal_sync
|
||||||
"maybe_build_standard_sync_finalize_product_from_normalized_payload"
|
.contains("maybe_build_standard_sync_finalize_product_from_normalized_payload"),
|
||||||
),
|
|
||||||
"gateway internal/sync_finalize.rs should delegate normalized standard sync finalize dispatch to aether-ai-formats"
|
"gateway internal/sync_finalize.rs should delegate normalized standard sync finalize dispatch to aether-ai-formats"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -4204,7 +4206,7 @@ fn ai_serving_finalize_standard_sync_products_are_owned_by_format_crate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let surface_openai_image_stream =
|
let surface_openai_image_stream =
|
||||||
read_workspace_file("crates/aether-ai-formats/src/formats/openai/image/stream.rs");
|
read_workspace_file("crates/aether-ai/formats/src/formats/openai/image/stream.rs");
|
||||||
for expected in [
|
for expected in [
|
||||||
"pub fn maybe_build_openai_image_sync_finalize_product(",
|
"pub fn maybe_build_openai_image_sync_finalize_product(",
|
||||||
"pub struct OpenAiImageSyncFinalizeProduct",
|
"pub struct OpenAiImageSyncFinalizeProduct",
|
||||||
@@ -4219,7 +4221,7 @@ fn ai_serving_finalize_standard_sync_products_are_owned_by_format_crate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let surface_sync_to_stream =
|
let surface_sync_to_stream =
|
||||||
read_workspace_file("crates/aether-ai-formats/src/formats/shared/sync_to_stream.rs");
|
read_workspace_file("crates/aether-ai/formats/src/formats/shared/sync_to_stream.rs");
|
||||||
for expected in [
|
for expected in [
|
||||||
"pub fn maybe_bridge_standard_sync_json_to_stream(",
|
"pub fn maybe_bridge_standard_sync_json_to_stream(",
|
||||||
"pub struct SyncToStreamBridgeOutcome",
|
"pub struct SyncToStreamBridgeOutcome",
|
||||||
@@ -4256,11 +4258,11 @@ fn ai_serving_finalize_standard_sync_products_are_owned_by_format_crate() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_finalize_stream_rewrite_matrix_is_owned_by_format_crate() {
|
fn ai_serving_finalize_stream_rewrite_matrix_is_owned_by_format_crate() {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/shared/stream_rewrite.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/shared/stream_rewrite.rs"),
|
||||||
"finalize stream rewrite matrix should live in aether-ai-formats"
|
"finalize stream rewrite matrix should live in aether-ai-formats"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/openai/image/stream.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/openai/image/stream.rs"),
|
||||||
"OpenAI image stream rewrite state should live in aether-ai-formats"
|
"OpenAI image stream rewrite state should live in aether-ai-formats"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4309,7 +4311,7 @@ fn ai_serving_finalize_stream_rewrite_matrix_is_owned_by_format_crate() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_planner_common_parser_is_owned_by_format_crate() {
|
fn ai_serving_planner_common_parser_is_owned_by_format_crate() {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/shared/request.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/shared/request.rs"),
|
||||||
"planner/common pure parser should exist in aether-ai-formats"
|
"planner/common pure parser should exist in aether-ai-formats"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4325,8 +4327,9 @@ fn ai_serving_planner_common_parser_is_owned_by_format_crate() {
|
|||||||
"gateway planner/common.rs should delegate body parsing through the ai_serving root seam"
|
"gateway planner/common.rs should delegate body parsing through the ai_serving root seam"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
gateway_common_runtime
|
gateway_common_runtime.contains(
|
||||||
.contains("force_upstream_streaming_for_provider as force_upstream_streaming_for_provider_impl"),
|
"force_upstream_streaming_for_provider as force_upstream_streaming_for_provider_impl"
|
||||||
|
),
|
||||||
"gateway planner/common.rs should delegate upstream streaming policy through the ai_serving root seam"
|
"gateway planner/common.rs should delegate upstream streaming policy through the ai_serving root seam"
|
||||||
);
|
);
|
||||||
for forbidden in [
|
for forbidden in [
|
||||||
@@ -4404,7 +4407,8 @@ fn ai_serving_planner_common_parser_is_owned_by_format_crate() {
|
|||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
openai_chat_diagnostic.contains("set_local_runtime_miss_diagnostic_reason(")
|
openai_chat_diagnostic.contains("set_local_runtime_miss_diagnostic_reason(")
|
||||||
|| openai_chat_diagnostic.contains("set_local_runtime_candidate_evaluation_diagnostic("),
|
|| openai_chat_diagnostic
|
||||||
|
.contains("set_local_runtime_candidate_evaluation_diagnostic("),
|
||||||
"openai chat diagnostic.rs should delegate miss diagnostic handling through planner/runtime_miss.rs"
|
"openai chat diagnostic.rs should delegate miss diagnostic handling through planner/runtime_miss.rs"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -4415,7 +4419,7 @@ fn ai_serving_planner_common_parser_is_owned_by_format_crate() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_root_owns_shared_gemini_request_path_parser() {
|
fn ai_serving_root_owns_shared_gemini_request_path_parser() {
|
||||||
let serving_surface_spec = read_workspace_file("crates/aether-ai-serving/src/surface_spec.rs");
|
let serving_surface_spec = read_workspace_file("crates/aether-ai/serving/src/surface_spec.rs");
|
||||||
assert!(
|
assert!(
|
||||||
serving_surface_spec.contains("pub fn extract_ai_gemini_model_from_path("),
|
serving_surface_spec.contains("pub fn extract_ai_gemini_model_from_path("),
|
||||||
"aether-ai-serving should own shared gemini request-path parsing"
|
"aether-ai-serving should own shared gemini request-path parsing"
|
||||||
@@ -4423,9 +4427,8 @@ fn ai_serving_root_owns_shared_gemini_request_path_parser() {
|
|||||||
|
|
||||||
let ai_serving_mod = read_workspace_file("apps/aether-gateway/src/ai_serving/mod.rs");
|
let ai_serving_mod = read_workspace_file("apps/aether-gateway/src/ai_serving/mod.rs");
|
||||||
assert!(
|
assert!(
|
||||||
ai_serving_mod.contains(
|
ai_serving_mod
|
||||||
"extract_ai_gemini_model_from_path as extract_gemini_model_from_path"
|
.contains("extract_ai_gemini_model_from_path as extract_gemini_model_from_path"),
|
||||||
),
|
|
||||||
"ai_serving/mod.rs should expose shared gemini request-path parsing through the serving seam"
|
"ai_serving/mod.rs should expose shared gemini request-path parsing through the serving seam"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4452,7 +4455,7 @@ fn ai_serving_root_owns_shared_gemini_request_path_parser() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_planner_standard_normalize_is_owned_by_format_crate() {
|
fn ai_serving_planner_standard_normalize_is_owned_by_format_crate() {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/shared/standard_normalize.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/shared/standard_normalize.rs"),
|
||||||
"planner/standard/normalize should live in aether-ai-formats"
|
"planner/standard/normalize should live in aether-ai-formats"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4515,7 +4518,7 @@ fn ai_serving_planner_standard_normalize_is_owned_by_format_crate() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_openai_helpers_are_owned_by_format_crate() {
|
fn ai_serving_openai_helpers_are_owned_by_format_crate() {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/openai/shared.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/openai/shared.rs"),
|
||||||
"planner/openai helper owner should exist in aether-ai-formats"
|
"planner/openai helper owner should exist in aether-ai-formats"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4548,17 +4551,17 @@ fn ai_serving_openai_helpers_are_owned_by_format_crate() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_standard_matrix_delegates_format_conversion_to_format_crate() {
|
fn ai_serving_standard_matrix_delegates_format_conversion_to_format_crate() {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/shared/request_matrix.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/shared/request_matrix.rs"),
|
||||||
"planner/matrix facade should live in aether-ai-formats"
|
"planner/matrix facade should live in aether-ai-formats"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/shared/standard_matrix.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/shared/standard_matrix.rs"),
|
||||||
"format standard request-body planner should live in aether-ai-formats"
|
"format standard request-body planner should live in aether-ai-formats"
|
||||||
);
|
);
|
||||||
for path in [
|
for path in [
|
||||||
"crates/aether-ai-formats/src/protocol/canonical.rs",
|
"crates/aether-ai/formats/src/protocol/canonical.rs",
|
||||||
"crates/aether-ai-formats/src/formats/matrix.rs",
|
"crates/aether-ai/formats/src/formats/matrix.rs",
|
||||||
"crates/aether-ai-formats/src/formats/registry.rs",
|
"crates/aether-ai/formats/src/formats/registry.rs",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists(path),
|
workspace_file_exists(path),
|
||||||
@@ -4566,7 +4569,7 @@ fn ai_serving_standard_matrix_delegates_format_conversion_to_format_crate() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let surface_matrix =
|
let surface_matrix =
|
||||||
read_workspace_file("crates/aether-ai-formats/src/formats/shared/standard_matrix.rs");
|
read_workspace_file("crates/aether-ai/formats/src/formats/shared/standard_matrix.rs");
|
||||||
assert!(
|
assert!(
|
||||||
surface_matrix.contains("use aether_ai_formats::formats::registry::{")
|
surface_matrix.contains("use aether_ai_formats::formats::registry::{")
|
||||||
&& surface_matrix.contains("convert_request")
|
&& surface_matrix.contains("convert_request")
|
||||||
@@ -4618,26 +4621,26 @@ fn ai_serving_standard_matrix_delegates_format_conversion_to_format_crate() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_standard_family_specs_are_owned_by_format_crate() {
|
fn ai_serving_standard_family_specs_are_owned_by_format_crate() {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/shared/family.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/shared/family.rs"),
|
||||||
"planner/standard/family pure spec owner should live in aether-ai-formats"
|
"planner/standard/family pure spec owner should live in aether-ai-formats"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/claude/messages/chat_spec.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/claude/messages/chat_spec.rs"),
|
||||||
"planner/standard/claude/chat pure spec resolver should live in aether-ai-formats"
|
"planner/standard/claude/chat pure spec resolver should live in aether-ai-formats"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/claude/messages/cli_spec.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/claude/messages/cli_spec.rs"),
|
||||||
"planner/standard/claude/cli pure spec resolver should live in aether-ai-formats"
|
"planner/standard/claude/cli pure spec resolver should live in aether-ai-formats"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists(
|
workspace_file_exists(
|
||||||
"crates/aether-ai-formats/src/formats/gemini/generate_content/chat_spec.rs"
|
"crates/aether-ai/formats/src/formats/gemini/generate_content/chat_spec.rs"
|
||||||
),
|
),
|
||||||
"planner/standard/gemini/chat pure spec resolver should live in aether-ai-formats"
|
"planner/standard/gemini/chat pure spec resolver should live in aether-ai-formats"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists(
|
workspace_file_exists(
|
||||||
"crates/aether-ai-formats/src/formats/gemini/generate_content/cli_spec.rs"
|
"crates/aether-ai/formats/src/formats/gemini/generate_content/cli_spec.rs"
|
||||||
),
|
),
|
||||||
"planner/standard/gemini/cli pure spec resolver should live in aether-ai-formats"
|
"planner/standard/gemini/cli pure spec resolver should live in aether-ai-formats"
|
||||||
);
|
);
|
||||||
@@ -4705,7 +4708,7 @@ fn ai_serving_standard_family_specs_are_owned_by_format_crate() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_same_format_provider_specs_are_owned_by_format_crate() {
|
fn ai_serving_same_format_provider_specs_are_owned_by_format_crate() {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/shared/passthrough.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/shared/passthrough.rs"),
|
||||||
"planner/passthrough/provider pure spec owner should live in aether-ai-formats"
|
"planner/passthrough/provider pure spec owner should live in aether-ai-formats"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4756,7 +4759,7 @@ fn ai_serving_same_format_provider_specs_are_owned_by_format_crate() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_passthrough_provider_specs_are_owned_by_format_crate() {
|
fn ai_serving_passthrough_provider_specs_are_owned_by_format_crate() {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/shared/passthrough.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/shared/passthrough.rs"),
|
||||||
"planner/passthrough/provider pure spec owner should live in aether-ai-formats"
|
"planner/passthrough/provider pure spec owner should live in aether-ai-formats"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4801,7 +4804,7 @@ fn ai_serving_passthrough_provider_specs_are_owned_by_format_crate() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_specialized_files_specs_are_owned_by_format_crate() {
|
fn ai_serving_specialized_files_specs_are_owned_by_format_crate() {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/gemini/files/spec.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/gemini/files/spec.rs"),
|
||||||
"planner/specialized/files pure spec owner should live in aether-ai-formats"
|
"planner/specialized/files pure spec owner should live in aether-ai-formats"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4831,12 +4834,12 @@ fn ai_serving_specialized_files_specs_are_owned_by_format_crate() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_specialized_video_specs_are_owned_by_format_crate() {
|
fn ai_serving_specialized_video_specs_are_owned_by_format_crate() {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/shared/video.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/shared/video.rs"),
|
||||||
"planner/specialized/video shared spec seam should live in aether-ai-formats"
|
"planner/specialized/video shared spec seam should live in aether-ai-formats"
|
||||||
);
|
);
|
||||||
for path in [
|
for path in [
|
||||||
"crates/aether-ai-formats/src/formats/openai/video/spec.rs",
|
"crates/aether-ai/formats/src/formats/openai/video/spec.rs",
|
||||||
"crates/aether-ai-formats/src/formats/gemini/video/spec.rs",
|
"crates/aether-ai/formats/src/formats/gemini/video/spec.rs",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists(path),
|
workspace_file_exists(path),
|
||||||
@@ -4868,7 +4871,7 @@ fn ai_serving_specialized_video_specs_are_owned_by_format_crate() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_openai_responses_specs_are_owned_by_format_crate() {
|
fn ai_serving_openai_responses_specs_are_owned_by_format_crate() {
|
||||||
assert!(
|
assert!(
|
||||||
workspace_file_exists("crates/aether-ai-formats/src/formats/openai/responses/spec.rs"),
|
workspace_file_exists("crates/aether-ai/formats/src/formats/openai/responses/spec.rs"),
|
||||||
"planner/standard/openai_responses pure spec owner should live in aether-ai-formats"
|
"planner/standard/openai_responses pure spec owner should live in aether-ai-formats"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4908,9 +4911,9 @@ fn ai_serving_openai_responses_specs_are_owned_by_format_crate() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ai_serving_legacy_api_format_names_stay_out_of_primary_paths() {
|
fn ai_serving_legacy_api_format_names_stay_out_of_primary_paths() {
|
||||||
for path in [
|
for path in [
|
||||||
"crates/aether-ai-formats/src/contracts/plan_kinds.rs",
|
"crates/aether-ai/formats/src/contracts/plan_kinds.rs",
|
||||||
"crates/aether-ai-formats/src/formats/shared/routing.rs",
|
"crates/aether-ai/formats/src/formats/shared/routing.rs",
|
||||||
"crates/aether-ai-formats/src/formats/openai/responses/spec.rs",
|
"crates/aether-ai/formats/src/formats/openai/responses/spec.rs",
|
||||||
"apps/aether-gateway/src/ai_serving/planner/decision/control_plan.rs",
|
"apps/aether-gateway/src/ai_serving/planner/decision/control_plan.rs",
|
||||||
"apps/aether-gateway/src/execution_runtime/fallback.rs",
|
"apps/aether-gateway/src/execution_runtime/fallback.rs",
|
||||||
] {
|
] {
|
||||||
@@ -4934,7 +4937,7 @@ fn ai_serving_legacy_api_format_names_stay_out_of_primary_paths() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let registry = read_workspace_file("crates/aether-ai-formats/src/formats/registry.rs");
|
let registry = read_workspace_file("crates/aether-ai/formats/src/formats/registry.rs");
|
||||||
let implementation = registry
|
let implementation = registry
|
||||||
.split("#[cfg(test)]")
|
.split("#[cfg(test)]")
|
||||||
.next()
|
.next()
|
||||||
@@ -4969,12 +4972,12 @@ fn retired_api_format_occurrences_are_whitelisted() {
|
|||||||
"apps/aether-gateway/src/handlers/admin/provider/write/normalize.rs",
|
"apps/aether-gateway/src/handlers/admin/provider/write/normalize.rs",
|
||||||
"apps/aether-gateway/src/handlers/admin/request/system/import.rs",
|
"apps/aether-gateway/src/handlers/admin/request/system/import.rs",
|
||||||
"apps/aether-gateway/src/tests/control/admin/system_import.rs",
|
"apps/aether-gateway/src/tests/control/admin/system_import.rs",
|
||||||
"crates/aether-ai-formats/src/formats/id.rs",
|
"crates/aether-ai/formats/src/formats/id.rs",
|
||||||
"crates/aether-ai-formats/src/formats/matrix.rs",
|
"crates/aether-ai/formats/src/formats/matrix.rs",
|
||||||
"crates/aether-ai-formats/src/formats/registry.rs",
|
"crates/aether-ai/formats/src/formats/registry.rs",
|
||||||
"crates/aether-data/src/migrate.rs",
|
"crates/aether-data/runtime/src/migrate.rs",
|
||||||
"crates/aether-data/src/lifecycle/migrate/tests.rs",
|
"crates/aether-data/runtime/src/lifecycle/migrate/tests.rs",
|
||||||
"crates/aether-usage-runtime/src/report.rs",
|
"crates/aether-usage/runtime/src/report.rs",
|
||||||
"frontend/src/api/endpoints/types/__tests__/api-format.spec.ts",
|
"frontend/src/api/endpoints/types/__tests__/api-format.spec.ts",
|
||||||
];
|
];
|
||||||
let allowed = allowed_paths
|
let allowed = allowed_paths
|
||||||
|
|||||||
@@ -119,6 +119,23 @@ pub(super) fn workspace_file_exists(root_relative_path: &str) -> bool {
|
|||||||
.exists()
|
.exists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn workspace_files_with_extension(
|
||||||
|
root_relative_path: &str,
|
||||||
|
extension: &str,
|
||||||
|
) -> Vec<PathBuf> {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../..")
|
||||||
|
.join(root_relative_path);
|
||||||
|
let mut files = fs::read_dir(root)
|
||||||
|
.expect("workspace directory should be readable")
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|path| path.extension().and_then(|value| value.to_str()) == Some(extension))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
files.sort();
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn collect_workspace_rust_files(root_relative_path: &str) -> Vec<PathBuf> {
|
pub(super) fn collect_workspace_rust_files(root_relative_path: &str) -> Vec<PathBuf> {
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
.join("../..")
|
.join("../..")
|
||||||
@@ -179,3 +196,4 @@ mod ai_serving;
|
|||||||
mod runtime_and_security;
|
mod runtime_and_security;
|
||||||
mod sql_and_data;
|
mod sql_and_data;
|
||||||
mod usage;
|
mod usage;
|
||||||
|
mod workspace_tiers;
|
||||||
|
|||||||
@@ -161,11 +161,11 @@ fn runtime_state_owns_redis_runtime_boundaries() {
|
|||||||
"crates/aether-admin/src",
|
"crates/aether-admin/src",
|
||||||
"crates/aether-billing/src",
|
"crates/aether-billing/src",
|
||||||
"crates/aether-model-fetch/src",
|
"crates/aether-model-fetch/src",
|
||||||
"crates/aether-provider-pool/src",
|
"crates/aether-provider/pool/src",
|
||||||
"crates/aether-runtime/src",
|
"crates/aether-runtime/base/src",
|
||||||
"crates/aether-task-runtime/src",
|
"crates/aether-task/runtime/src",
|
||||||
"crates/aether-usage-runtime/src",
|
"crates/aether-usage/runtime/src",
|
||||||
"crates/aether-provider-transport/src",
|
"crates/aether-provider/transport/src",
|
||||||
"crates/aether-wallet/src",
|
"crates/aether-wallet/src",
|
||||||
] {
|
] {
|
||||||
for path in collect_workspace_rust_files(root) {
|
for path in collect_workspace_rust_files(root) {
|
||||||
@@ -199,11 +199,11 @@ fn runtime_state_owns_redis_runtime_boundaries() {
|
|||||||
"crates/aether-admin/Cargo.toml",
|
"crates/aether-admin/Cargo.toml",
|
||||||
"crates/aether-billing/Cargo.toml",
|
"crates/aether-billing/Cargo.toml",
|
||||||
"crates/aether-model-fetch/Cargo.toml",
|
"crates/aether-model-fetch/Cargo.toml",
|
||||||
"crates/aether-provider-pool/Cargo.toml",
|
"crates/aether-provider/pool/Cargo.toml",
|
||||||
"crates/aether-provider-transport/Cargo.toml",
|
"crates/aether-provider/transport/Cargo.toml",
|
||||||
"crates/aether-runtime/Cargo.toml",
|
"crates/aether-runtime/base/Cargo.toml",
|
||||||
"crates/aether-task-runtime/Cargo.toml",
|
"crates/aether-task/runtime/Cargo.toml",
|
||||||
"crates/aether-usage-runtime/Cargo.toml",
|
"crates/aether-usage/runtime/Cargo.toml",
|
||||||
"crates/aether-wallet/Cargo.toml",
|
"crates/aether-wallet/Cargo.toml",
|
||||||
] {
|
] {
|
||||||
let cargo = read_workspace_file(manifest);
|
let cargo = read_workspace_file(manifest);
|
||||||
@@ -220,7 +220,7 @@ fn runtime_state_owns_redis_runtime_boundaries() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut runtime_state_violations = Vec::new();
|
let mut runtime_state_violations = Vec::new();
|
||||||
for path in collect_workspace_rust_files("crates/aether-runtime-state/src") {
|
for path in collect_workspace_rust_files("crates/aether-runtime/state/src") {
|
||||||
if path
|
if path
|
||||||
.components()
|
.components()
|
||||||
.any(|component| component.as_os_str() == "redis")
|
.any(|component| component.as_os_str() == "redis")
|
||||||
@@ -244,13 +244,13 @@ fn runtime_state_owns_redis_runtime_boundaries() {
|
|||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
runtime_state_violations.is_empty(),
|
runtime_state_violations.is_empty(),
|
||||||
"only crates/aether-runtime-state/src/redis may depend on the redis crate directly:\n{}",
|
"only crates/aether-runtime/state/src/redis may depend on the redis crate directly:\n{}",
|
||||||
runtime_state_violations.join("\n")
|
runtime_state_violations.join("\n")
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut runtime_connection_violations = Vec::new();
|
let mut runtime_connection_violations = Vec::new();
|
||||||
for path in collect_workspace_rust_files("crates/aether-runtime-state/src") {
|
for path in collect_workspace_rust_files("crates/aether-runtime/state/src") {
|
||||||
if path.ends_with("crates/aether-runtime-state/src/redis/client.rs") {
|
if path.ends_with("crates/aether-runtime/state/src/redis/client.rs") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let source = production_workspace_source(&path);
|
let source = production_workspace_source(&path);
|
||||||
@@ -267,17 +267,17 @@ fn runtime_state_owns_redis_runtime_boundaries() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aether_data_stays_free_of_redis_runtime_backends() {
|
fn aether_data_stays_free_of_redis_runtime_backends() {
|
||||||
let cargo = read_workspace_file("crates/aether-data/Cargo.toml");
|
let cargo = read_workspace_file("crates/aether-data/runtime/Cargo.toml");
|
||||||
assert!(
|
assert!(
|
||||||
!cargo.contains("redis.workspace"),
|
!cargo.contains("redis.workspace"),
|
||||||
"aether-data should not depend on redis; runtime Redis belongs to aether-runtime-state"
|
"aether-data should not depend on redis; runtime Redis belongs to aether-runtime-state"
|
||||||
);
|
);
|
||||||
|
|
||||||
for removed_path in [
|
for removed_path in [
|
||||||
"crates/aether-data/src/backend/redis.rs",
|
"crates/aether-data/runtime/src/backend/redis.rs",
|
||||||
"crates/aether-data/src/backend/locks.rs",
|
"crates/aether-data/runtime/src/backend/locks.rs",
|
||||||
"crates/aether-data/src/backend/workers.rs",
|
"crates/aether-data/runtime/src/backend/workers.rs",
|
||||||
"crates/aether-data/src/driver/redis/mod.rs",
|
"crates/aether-data/runtime/src/driver/redis/mod.rs",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
!workspace_file_exists(removed_path),
|
!workspace_file_exists(removed_path),
|
||||||
@@ -285,7 +285,7 @@ fn aether_data_stays_free_of_redis_runtime_backends() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for path in collect_workspace_rust_files("crates/aether-data/src") {
|
for path in collect_workspace_rust_files("crates/aether-data/runtime/src") {
|
||||||
let source = production_workspace_source(&path);
|
let source = production_workspace_source(&path);
|
||||||
for forbidden in [
|
for forbidden in [
|
||||||
"pub mod redis",
|
"pub mod redis",
|
||||||
@@ -328,7 +328,7 @@ fn gateway_request_candidate_trace_type_is_owned_by_aether_data_contracts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let candidate_types =
|
let candidate_types =
|
||||||
read_workspace_file("crates/aether-data-contracts/src/repository/candidates/types.rs");
|
read_workspace_file("crates/aether-data/contracts/src/repository/candidates/types.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub enum RequestCandidateFinalStatus",
|
"pub enum RequestCandidateFinalStatus",
|
||||||
"pub struct RequestCandidateTrace",
|
"pub struct RequestCandidateTrace",
|
||||||
@@ -366,7 +366,7 @@ fn gateway_decision_trace_type_is_owned_by_aether_data_contracts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let candidate_types =
|
let candidate_types =
|
||||||
read_workspace_file("crates/aether-data-contracts/src/repository/candidates/types.rs");
|
read_workspace_file("crates/aether-data/contracts/src/repository/candidates/types.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub struct DecisionTraceCandidate",
|
"pub struct DecisionTraceCandidate",
|
||||||
"pub struct DecisionTrace",
|
"pub struct DecisionTrace",
|
||||||
@@ -911,7 +911,8 @@ fn gateway_request_audit_bundle_type_is_owned_by_aether_data() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let audit_types = read_workspace_file("crates/aether-data/src/repository/audit.rs");
|
let audit_types =
|
||||||
|
read_workspace_file("crates/aether-data/runtime/src/repository/audit/types.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub struct RequestAuditBundle",
|
"pub struct RequestAuditBundle",
|
||||||
"pub trait RequestAuditReader",
|
"pub trait RequestAuditReader",
|
||||||
@@ -1434,7 +1435,7 @@ fn provider_transport_cache_helpers_live_in_shared_crate() {
|
|||||||
"state/mod.rs should import refresh detection from shared provider transport"
|
"state/mod.rs should import refresh detection from shared provider transport"
|
||||||
);
|
);
|
||||||
|
|
||||||
let transport_cache = read_workspace_file("crates/aether-provider-transport/src/cache.rs");
|
let transport_cache = read_workspace_file("crates/aether-provider/transport/src/cache.rs");
|
||||||
for pattern in [
|
for pattern in [
|
||||||
"pub struct ProviderTransportSnapshotCacheKey",
|
"pub struct ProviderTransportSnapshotCacheKey",
|
||||||
"pub fn provider_transport_snapshot_looks_refreshed(",
|
"pub fn provider_transport_snapshot_looks_refreshed(",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,349 @@
|
|||||||
|
use super::{collect_workspace_rust_files, read_workspace_file};
|
||||||
|
|
||||||
|
fn assert_manifest_excludes(manifest_path: &str, forbidden: &[&str]) {
|
||||||
|
let manifest = read_workspace_file(manifest_path);
|
||||||
|
let violations = forbidden
|
||||||
|
.iter()
|
||||||
|
.filter(|dependency| manifest.contains(**dependency))
|
||||||
|
.copied()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(
|
||||||
|
violations.is_empty(),
|
||||||
|
"{manifest_path} crosses its dependency tier through: {}",
|
||||||
|
violations.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pure_policy_crates_do_not_depend_on_runtime_adapters() {
|
||||||
|
let pure_manifests = [
|
||||||
|
"crates/aether-admission-core/Cargo.toml",
|
||||||
|
"crates/aether-provider/core/Cargo.toml",
|
||||||
|
"crates/aether-task/core/Cargo.toml",
|
||||||
|
"crates/aether-usage/core/Cargo.toml",
|
||||||
|
];
|
||||||
|
let forbidden = [
|
||||||
|
"axum",
|
||||||
|
"sqlx",
|
||||||
|
"redis",
|
||||||
|
"reqwest",
|
||||||
|
"wreq",
|
||||||
|
"tokio",
|
||||||
|
"aether-data =",
|
||||||
|
"aether-gateway",
|
||||||
|
];
|
||||||
|
|
||||||
|
for manifest in pure_manifests {
|
||||||
|
assert_manifest_excludes(manifest, &forbidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn database_adapters_are_independent_driver_boundaries() {
|
||||||
|
let adapters = [
|
||||||
|
(
|
||||||
|
"crates/aether-data/adapters/postgres/Cargo.toml",
|
||||||
|
"features = [\"postgres\"",
|
||||||
|
["features = [\"mysql\"", "features = [\"sqlite\""],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"crates/aether-data/adapters/mysql/Cargo.toml",
|
||||||
|
"features = [\"mysql\"",
|
||||||
|
["features = [\"postgres\"", "features = [\"sqlite\""],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"crates/aether-data/adapters/sqlite/Cargo.toml",
|
||||||
|
"features = [\"sqlite\"",
|
||||||
|
["features = [\"postgres\"", "features = [\"mysql\""],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (manifest_path, expected_driver, other_drivers) in adapters {
|
||||||
|
let manifest = read_workspace_file(manifest_path);
|
||||||
|
assert!(manifest.contains("aether-data-contracts.workspace = true"));
|
||||||
|
assert!(manifest.contains(expected_driver));
|
||||||
|
assert_manifest_excludes(
|
||||||
|
manifest_path,
|
||||||
|
&[other_drivers[0], other_drivers[1], "aether-gateway", "axum"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn data_facade_preserves_legacy_driver_paths_without_owning_driver_code() {
|
||||||
|
for (path, adapter) in [
|
||||||
|
(
|
||||||
|
"crates/aether-data/runtime/src/driver/postgres.rs",
|
||||||
|
"aether_data_postgres",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"crates/aether-data/runtime/src/driver/mysql.rs",
|
||||||
|
"aether_data_mysql",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"crates/aether-data/runtime/src/driver/sqlite.rs",
|
||||||
|
"aether_data_sqlite",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let source = read_workspace_file(path);
|
||||||
|
assert!(
|
||||||
|
source.contains(&format!("pub use {adapter}::*;")),
|
||||||
|
"{path} should remain a thin compatibility facade"
|
||||||
|
);
|
||||||
|
assert!(!source.contains("sqlx::"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gateway_runtime_components_keep_focused_dependency_surfaces() {
|
||||||
|
assert_manifest_excludes(
|
||||||
|
"crates/aether-gateway/frontdoor/Cargo.toml",
|
||||||
|
&[
|
||||||
|
"aether-data",
|
||||||
|
"aether-provider-transport",
|
||||||
|
"aether-gateway-workers",
|
||||||
|
"sqlx",
|
||||||
|
"redis",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_manifest_excludes(
|
||||||
|
"crates/aether-gateway/workers/Cargo.toml",
|
||||||
|
&[
|
||||||
|
"axum",
|
||||||
|
"aether-gateway-frontdoor",
|
||||||
|
"aether-provider-transport",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_manifest_excludes(
|
||||||
|
"crates/aether-gateway/execution/Cargo.toml",
|
||||||
|
&[
|
||||||
|
"axum",
|
||||||
|
"sqlx",
|
||||||
|
"redis",
|
||||||
|
"aether-data",
|
||||||
|
"aether-gateway-frontdoor",
|
||||||
|
"aether-gateway-workers",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_manifest_excludes(
|
||||||
|
"crates/aether-gateway/control/Cargo.toml",
|
||||||
|
&[
|
||||||
|
"sqlx",
|
||||||
|
"redis",
|
||||||
|
"reqwest",
|
||||||
|
"aether-data",
|
||||||
|
"aether-provider-transport",
|
||||||
|
"aether-gateway-workers",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_manifest_excludes(
|
||||||
|
"crates/aether-gateway/tunnel/Cargo.toml",
|
||||||
|
&[
|
||||||
|
"axum",
|
||||||
|
"sqlx",
|
||||||
|
"redis",
|
||||||
|
"reqwest",
|
||||||
|
"wreq",
|
||||||
|
"aether-data",
|
||||||
|
"aether-provider-transport",
|
||||||
|
"aether-gateway-workers",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_manifest_excludes(
|
||||||
|
"crates/aether-testing/loadtools/Cargo.toml",
|
||||||
|
&[
|
||||||
|
"aether-gateway",
|
||||||
|
"aether-testkit",
|
||||||
|
"aether-data",
|
||||||
|
"axum",
|
||||||
|
"redis",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tunnel_binary_uses_shared_tunnel_boundary_without_gateway_runtime_dependency() {
|
||||||
|
let manifest = read_workspace_file("apps/aether-tunnel/Cargo.toml");
|
||||||
|
let dependencies = manifest
|
||||||
|
.split_once("[dependencies]")
|
||||||
|
.expect("tunnel manifest should declare dependencies")
|
||||||
|
.1
|
||||||
|
.split("[dev-dependencies]")
|
||||||
|
.next()
|
||||||
|
.expect("normal dependency section should exist");
|
||||||
|
|
||||||
|
assert!(dependencies.contains("aether-gateway-tunnel.workspace = true"));
|
||||||
|
assert!(!dependencies.contains("aether-gateway.workspace = true"));
|
||||||
|
|
||||||
|
let protocol_facade = read_workspace_file("apps/aether-tunnel/src/tunnel/protocol.rs");
|
||||||
|
assert!(protocol_facade.contains("aether_gateway_tunnel::protocol::*"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn data_facade_defaults_to_postgres_and_gateway_selects_all_drivers_explicitly() {
|
||||||
|
let data_manifest = read_workspace_file("crates/aether-data/runtime/Cargo.toml");
|
||||||
|
assert!(data_manifest.contains("default = [\"postgres\"]"));
|
||||||
|
for dependency in [
|
||||||
|
"aether-data-postgres = { workspace = true, optional = true }",
|
||||||
|
"aether-data-mysql = { workspace = true, optional = true }",
|
||||||
|
"aether-data-sqlite = { workspace = true, optional = true }",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
data_manifest.contains(dependency),
|
||||||
|
"aether-data should keep {dependency} optional"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!data_manifest.contains("features = [\"postgres\", \"mysql\", \"sqlite\"\"]"),
|
||||||
|
"aether-data must not unconditionally enable every sqlx driver"
|
||||||
|
);
|
||||||
|
|
||||||
|
let gateway_manifest = read_workspace_file("apps/aether-gateway/Cargo.toml");
|
||||||
|
assert!(gateway_manifest
|
||||||
|
.contains("aether-data = { workspace = true, features = [\"all-drivers\"] }"));
|
||||||
|
|
||||||
|
let data_lib = read_workspace_file("crates/aether-data/runtime/src/lib.rs");
|
||||||
|
for backend in ["PostgresBackend", "MysqlBackend", "SqliteBackend"] {
|
||||||
|
assert!(
|
||||||
|
data_lib.contains(&format!("pub use backend::{backend};")),
|
||||||
|
"aether-data should expose enabled backends symmetrically at its facade root"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn data_query_helpers_belong_to_adapters_not_the_runtime_facade() {
|
||||||
|
let data_manifest = read_workspace_file("crates/aether-data/runtime/Cargo.toml");
|
||||||
|
assert!(
|
||||||
|
!data_manifest.contains("aether-data-query.workspace = true"),
|
||||||
|
"aether-data should not keep a direct query-helper dependency after SQL repositories move to adapters"
|
||||||
|
);
|
||||||
|
|
||||||
|
for adapter_manifest in [
|
||||||
|
"crates/aether-data/adapters/postgres/Cargo.toml",
|
||||||
|
"crates/aether-data/adapters/mysql/Cargo.toml",
|
||||||
|
"crates/aether-data/adapters/sqlite/Cargo.toml",
|
||||||
|
] {
|
||||||
|
let manifest = read_workspace_file(adapter_manifest);
|
||||||
|
assert!(
|
||||||
|
manifest.contains("aether-data-query.workspace = true"),
|
||||||
|
"{adapter_manifest} should own its query-helper dependency"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let query_helpers = read_workspace_file("crates/aether-data/query/src/lib.rs");
|
||||||
|
for dialect in ["Postgres", "MySql", "Sqlite"] {
|
||||||
|
assert!(
|
||||||
|
query_helpers.contains(dialect),
|
||||||
|
"aether-data-query should render the {dialect} dialect"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sql_adapters_centralize_error_mapping_boilerplate() {
|
||||||
|
for (adapter, driver) in [
|
||||||
|
("aether-data-mysql", "mysql"),
|
||||||
|
("aether-data-sqlite", "sqlite"),
|
||||||
|
] {
|
||||||
|
let root = format!("crates/aether-data/adapters/{driver}/src");
|
||||||
|
let files = collect_workspace_rust_files(&root);
|
||||||
|
let trait_owners = files
|
||||||
|
.iter()
|
||||||
|
.filter(|path| {
|
||||||
|
std::fs::read_to_string(path)
|
||||||
|
.expect("adapter source should be readable")
|
||||||
|
.contains("trait SqlResultExt<T>")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
trait_owners.len(),
|
||||||
|
1,
|
||||||
|
"{adapter} should have exactly one SqlResultExt owner, found: {trait_owners:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
trait_owners[0].file_name().and_then(|name| name.to_str()),
|
||||||
|
Some("error.rs"),
|
||||||
|
"{adapter} should keep SQL error mapping in src/error.rs"
|
||||||
|
);
|
||||||
|
|
||||||
|
let lib = read_workspace_file(&format!("crates/aether-data/adapters/{driver}/src/lib.rs"));
|
||||||
|
assert!(lib.contains("mod error;"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gateway_tunnel_protocol_path_is_a_thin_compatibility_facade() {
|
||||||
|
let source = read_workspace_file("apps/aether-gateway/src/tunnel/embedded/protocol.rs");
|
||||||
|
assert_eq!(
|
||||||
|
source.trim(),
|
||||||
|
"pub use aether_gateway_tunnel::embedded::protocol::*;"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frontdoor_owns_bounded_request_body_buffering() {
|
||||||
|
let frontdoor = read_workspace_file("crates/aether-gateway/frontdoor/src/body.rs");
|
||||||
|
assert!(frontdoor.contains("acquire_many_owned"));
|
||||||
|
assert!(frontdoor.contains("to_bytes(body, body_limit)"));
|
||||||
|
assert!(frontdoor.contains("BodyBufferReservation"));
|
||||||
|
|
||||||
|
let gateway = read_workspace_file("apps/aether-gateway/src/handlers/proxy/body_buffer.rs");
|
||||||
|
assert!(gateway.contains("FrontdoorBodyBufferPolicy"));
|
||||||
|
assert!(!gateway.contains("acquire_many_owned"));
|
||||||
|
assert!(!gateway.contains("request_body_collection_exceeded_limit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn benchmark_binaries_are_outside_the_reusable_testkit() {
|
||||||
|
assert!(
|
||||||
|
collect_workspace_rust_files("crates/aether-testing/testkit/src/bin").is_empty(),
|
||||||
|
"aether-testkit must not own benchmark binaries"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!collect_workspace_rust_files("crates/aether-testing/loadtools/src/bin").is_empty(),
|
||||||
|
"standalone load tools should live in aether-loadtools"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!collect_workspace_rust_files("crates/aether-testing/integration/src/bin").is_empty(),
|
||||||
|
"gateway-backed scenarios should live in aether-integration-tests"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn testkit_gateway_harness_is_opt_in() {
|
||||||
|
let testkit_manifest = read_workspace_file("crates/aether-testing/testkit/Cargo.toml");
|
||||||
|
assert!(
|
||||||
|
testkit_manifest.contains("default = []"),
|
||||||
|
"aether-testkit should keep the default feature set dependency-light"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
testkit_manifest
|
||||||
|
.contains("gateway = [\"dep:aether-gateway\", \"dep:aether-runtime-state\"]"),
|
||||||
|
"gateway harnesses should be behind the explicit gateway feature"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
testkit_manifest.contains("postgres = [\"dep:aether-data\", \"dep:sqlx\"]"),
|
||||||
|
"Postgres schema helpers should be behind the explicit postgres feature"
|
||||||
|
);
|
||||||
|
assert!(testkit_manifest.contains(
|
||||||
|
"aether-gateway = { workspace = true, features = [\"testkit\"], optional = true }"
|
||||||
|
));
|
||||||
|
|
||||||
|
let testkit_lib = read_workspace_file("crates/aether-testing/testkit/src/lib.rs");
|
||||||
|
for module in ["execution_runtime", "gateway", "tunnel"] {
|
||||||
|
assert!(
|
||||||
|
testkit_lib.contains(&format!("#[cfg(feature = \"gateway\")]\nmod {module};")),
|
||||||
|
"aether-testkit::{module} should be feature-gated"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
testkit_lib.contains("#[cfg(feature = \"postgres\")]\nmod postgres;"),
|
||||||
|
"the Postgres helper should be feature-gated"
|
||||||
|
);
|
||||||
|
|
||||||
|
let integration_manifest = read_workspace_file("crates/aether-testing/integration/Cargo.toml");
|
||||||
|
assert!(integration_manifest
|
||||||
|
.contains("aether-testkit = { workspace = true, features = [\"gateway\", \"postgres\"] }"));
|
||||||
|
}
|
||||||
@@ -310,15 +310,15 @@ fn gateway_exposes_request_concurrency_metrics() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn gateway_exposes_request_concurrency_metrics_impl() {
|
async fn gateway_exposes_request_concurrency_metrics_impl() {
|
||||||
let gateway = build_router_with_state(
|
let state = AppState::new()
|
||||||
AppState::new()
|
.expect("gateway state should build")
|
||||||
.expect("gateway state should build")
|
.with_request_concurrency_limit(3)
|
||||||
.with_request_concurrency_limit(3)
|
.with_distributed_request_concurrency_gate(memory_runtime_semaphore(
|
||||||
.with_distributed_request_concurrency_gate(memory_runtime_semaphore(
|
"gateway_requests_distributed",
|
||||||
"gateway_requests_distributed",
|
5,
|
||||||
5,
|
));
|
||||||
)),
|
assert!(state.prewarm_metric_snapshot().await);
|
||||||
);
|
let gateway = build_router_with_state(state);
|
||||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
@@ -437,6 +437,7 @@ async fn gateway_exposes_fallback_metrics_impl() {
|
|||||||
Some(EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS),
|
Some(EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS),
|
||||||
GatewayFallbackReason::LocalExecutionPathRequired,
|
GatewayFallbackReason::LocalExecutionPathRequired,
|
||||||
);
|
);
|
||||||
|
assert!(state.prewarm_metric_snapshot().await);
|
||||||
let gateway = build_router_with_state(state);
|
let gateway = build_router_with_state(state);
|
||||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadReposi
|
|||||||
use aether_data::repository::proxy_nodes::InMemoryProxyNodeRepository;
|
use aether_data::repository::proxy_nodes::InMemoryProxyNodeRepository;
|
||||||
use aether_data_contracts::repository::provider_catalog::ProviderCatalogReadRepository;
|
use aether_data_contracts::repository::provider_catalog::ProviderCatalogReadRepository;
|
||||||
use aether_runtime_state::{RedisClientConfig, RuntimeState};
|
use aether_runtime_state::{RedisClientConfig, RuntimeState};
|
||||||
use aether_testkit::ManagedRedisServer;
|
use aether_test_support::ManagedRedisServer;
|
||||||
use axum::body::to_bytes;
|
use axum::body::to_bytes;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::routing::{any, get, post};
|
use axum::routing::{any, get, post};
|
||||||
|
|||||||
@@ -1009,6 +1009,26 @@ async fn gateway_handles_admin_stats_provider_performance_locally_with_trusted_a
|
|||||||
payload["timeline"][1]["avg_first_byte_time_ms"],
|
payload["timeline"][1]["avg_first_byte_time_ms"],
|
||||||
serde_json::Value::Null
|
serde_json::Value::Null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let without_timeline_response = admin_request(reqwest::Client::new().get(format!(
|
||||||
|
"{gateway_url}/api/admin/stats/performance/providers?start_date=2024-03-21&end_date=2024-03-21&granularity=hour&limit=2&tz_offset_minutes=0&include_timeline=false"
|
||||||
|
)))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request without timeline should succeed");
|
||||||
|
assert_eq!(without_timeline_response.status(), StatusCode::OK);
|
||||||
|
let without_timeline_payload: serde_json::Value = without_timeline_response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("json body without timeline should parse");
|
||||||
|
assert_eq!(without_timeline_payload["summary"], payload["summary"]);
|
||||||
|
assert_eq!(without_timeline_payload["providers"], payload["providers"]);
|
||||||
|
assert_eq!(
|
||||||
|
without_timeline_payload["timeline"]
|
||||||
|
.as_array()
|
||||||
|
.map(Vec::len),
|
||||||
|
Some(0)
|
||||||
|
);
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
|
|||||||
@@ -8240,7 +8240,9 @@ async fn gateway_returns_service_unavailable_for_users_me_management_token_write
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_handles_users_me_providers_locally_without_proxying_upstream() {
|
async fn gateway_handles_users_me_providers_locally_without_proxying_upstream() {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let user = sample_auth_user(now);
|
let mut user = sample_auth_user(now);
|
||||||
|
user.allowed_providers = Some(vec!["claude".to_string()]);
|
||||||
|
user.allowed_providers_mode = "specific".to_string();
|
||||||
let access_token = build_test_auth_token(
|
let access_token = build_test_auth_token(
|
||||||
"access",
|
"access",
|
||||||
serde_json::Map::from_iter([
|
serde_json::Map::from_iter([
|
||||||
@@ -8300,6 +8302,27 @@ async fn gateway_handles_users_me_providers_locally_without_proxying_upstream()
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
|
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
|
||||||
|
let group = user_repository
|
||||||
|
.create_user_group(UpsertUserGroupRecord {
|
||||||
|
name: "OpenAI only".to_string(),
|
||||||
|
description: None,
|
||||||
|
priority: 0,
|
||||||
|
allowed_providers: Some(vec!["openai".to_string()]),
|
||||||
|
allowed_providers_mode: "specific".to_string(),
|
||||||
|
allowed_api_formats: None,
|
||||||
|
allowed_api_formats_mode: "unrestricted".to_string(),
|
||||||
|
allowed_models: None,
|
||||||
|
allowed_models_mode: "unrestricted".to_string(),
|
||||||
|
rate_limit: None,
|
||||||
|
rate_limit_mode: "system".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("group should create")
|
||||||
|
.expect("group should exist");
|
||||||
|
user_repository
|
||||||
|
.add_user_to_group(&group.id, "user-auth-1")
|
||||||
|
.await
|
||||||
|
.expect("group membership should create");
|
||||||
|
|
||||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||||
start_auth_gateway_with_builder(|| {
|
start_auth_gateway_with_builder(|| {
|
||||||
@@ -9697,13 +9720,13 @@ async fn gateway_handles_users_me_available_models_locally_without_proxying_upst
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_filters_users_me_available_models_by_group_policy_and_hides_model_mappings() {
|
async fn gateway_refreshes_users_me_available_models_after_group_assignment() {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let mut user = sample_auth_user(now);
|
let mut user = sample_auth_user(now);
|
||||||
user.allowed_providers = None;
|
user.allowed_providers = None;
|
||||||
user.allowed_providers_mode = "unrestricted".to_string();
|
user.allowed_providers_mode = "unrestricted".to_string();
|
||||||
user.allowed_models = None;
|
// Keep the legacy gpt-5 personal policy from sample_auth_user. Group policies are the
|
||||||
user.allowed_models_mode = "unrestricted".to_string();
|
// authority now, so this stale field must not narrow the user catalog.
|
||||||
let access_token = build_test_auth_token(
|
let access_token = build_test_auth_token(
|
||||||
"access",
|
"access",
|
||||||
serde_json::Map::from_iter([
|
serde_json::Map::from_iter([
|
||||||
@@ -9754,31 +9777,25 @@ async fn gateway_filters_users_me_available_models_by_group_policy_and_hides_mod
|
|||||||
.await
|
.await
|
||||||
.expect("group should create")
|
.expect("group should create")
|
||||||
.expect("group should exist");
|
.expect("group should exist");
|
||||||
user_repository
|
let data_state =
|
||||||
.add_user_to_group(&group.id, "user-auth-1")
|
crate::data::GatewayDataState::with_global_model_reader_for_tests(global_model_repository)
|
||||||
.await
|
|
||||||
.expect("group membership should create");
|
|
||||||
|
|
||||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
|
||||||
start_auth_gateway_with_builder(|| {
|
|
||||||
let data_state = crate::data::GatewayDataState::with_global_model_reader_for_tests(
|
|
||||||
global_model_repository,
|
|
||||||
)
|
|
||||||
.with_user_reader(Arc::clone(&user_repository));
|
.with_user_reader(Arc::clone(&user_repository));
|
||||||
AppState::new()
|
let state = AppState::new()
|
||||||
.expect("gateway should build")
|
.expect("gateway should build")
|
||||||
.with_data_state_for_tests(data_state)
|
.with_data_state_for_tests(data_state)
|
||||||
.with_auth_sessions_for_tests([sample_auth_session(
|
.with_auth_sessions_for_tests([sample_auth_session(
|
||||||
"user-auth-1",
|
"user-auth-1",
|
||||||
"session-users-me-group-models",
|
"session-users-me-group-models",
|
||||||
"device-users-me-group-models",
|
"device-users-me-group-models",
|
||||||
"refresh-token-placeholder",
|
"refresh-token-placeholder",
|
||||||
now,
|
now,
|
||||||
)])
|
)]);
|
||||||
})
|
let mutation_state = state.clone();
|
||||||
.await;
|
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||||
|
start_auth_gateway_with_builder(|| state).await;
|
||||||
|
|
||||||
let response = reqwest::Client::new()
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
.get(format!("{gateway_url}/api/users/me/available-models"))
|
.get(format!("{gateway_url}/api/users/me/available-models"))
|
||||||
.header("authorization", format!("Bearer {access_token}"))
|
.header("authorization", format!("Bearer {access_token}"))
|
||||||
.header("x-client-device-id", "device-users-me-group-models")
|
.header("x-client-device-id", "device-users-me-group-models")
|
||||||
@@ -9787,6 +9804,24 @@ async fn gateway_filters_users_me_available_models_by_group_policy_and_hides_mod
|
|||||||
.await
|
.await
|
||||||
.expect("request should succeed");
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(payload["total"], 2);
|
||||||
|
|
||||||
|
mutation_state
|
||||||
|
.replace_user_groups_for_user("user-auth-1", std::slice::from_ref(&group.id))
|
||||||
|
.await
|
||||||
|
.expect("group membership should create");
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(format!("{gateway_url}/api/users/me/available-models"))
|
||||||
|
.header("authorization", format!("Bearer {access_token}"))
|
||||||
|
.header("x-client-device-id", "device-users-me-group-models")
|
||||||
|
.header("user-agent", "AetherTest/1.0")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed after group assignment");
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
let models = payload["models"]
|
let models = payload["models"]
|
||||||
@@ -9926,6 +9961,27 @@ async fn gateway_returns_service_unavailable_for_users_me_available_models_witho
|
|||||||
.expect("active global model ref should build")]),
|
.expect("active global model ref should build")]),
|
||||||
);
|
);
|
||||||
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
|
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
|
||||||
|
let group = user_repository
|
||||||
|
.create_user_group(UpsertUserGroupRecord {
|
||||||
|
name: "OpenAI provider only".to_string(),
|
||||||
|
description: None,
|
||||||
|
priority: 0,
|
||||||
|
allowed_providers: Some(vec!["openai".to_string()]),
|
||||||
|
allowed_providers_mode: "specific".to_string(),
|
||||||
|
allowed_api_formats: None,
|
||||||
|
allowed_api_formats_mode: "unrestricted".to_string(),
|
||||||
|
allowed_models: None,
|
||||||
|
allowed_models_mode: "unrestricted".to_string(),
|
||||||
|
rate_limit: None,
|
||||||
|
rate_limit_mode: "system".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("group should create")
|
||||||
|
.expect("group should exist");
|
||||||
|
user_repository
|
||||||
|
.add_user_to_group(&group.id, "user-auth-1")
|
||||||
|
.await
|
||||||
|
.expect("group membership should create");
|
||||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||||
start_auth_gateway_with_builder(|| {
|
start_auth_gateway_with_builder(|| {
|
||||||
let data_state = crate::data::GatewayDataState::with_global_model_reader_for_tests(
|
let data_state = crate::data::GatewayDataState::with_global_model_reader_for_tests(
|
||||||
|
|||||||
@@ -159,6 +159,15 @@ fn relay_header_timeout(meta: &protocol::RequestMeta) -> Duration {
|
|||||||
Duration::from_millis(resolve_tunnel_request_timeouts(meta).first_byte_ms)
|
Duration::from_millis(resolve_tunnel_request_timeouts(meta).first_byte_ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_rollout_probe_request(headers: &HeaderMap, forwarded_by_gateway: bool) -> bool {
|
||||||
|
forwarded_by_gateway
|
||||||
|
&& headers
|
||||||
|
.get(crate::tunnel::TUNNEL_RELAY_ROLLOUT_PROBE_HEADER)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(str::trim)
|
||||||
|
.is_some_and(|value| value == crate::tunnel::TUNNEL_RELAY_ROLLOUT_PROBE_VALUE)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn relay_request(
|
pub async fn relay_request(
|
||||||
Path(node_id): Path<String>,
|
Path(node_id): Path<String>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -171,6 +180,7 @@ pub async fn relay_request(
|
|||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.is_some_and(|value| !value.is_empty());
|
.is_some_and(|value| !value.is_empty());
|
||||||
|
let rollout_probe = is_rollout_probe_request(request.headers(), forwarded_by_gateway);
|
||||||
if !addr.ip().is_loopback() && !forwarded_by_gateway {
|
if !addr.ip().is_loopback() && !forwarded_by_gateway {
|
||||||
return tunnel_error_response(
|
return tunnel_error_response(
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
@@ -348,12 +358,16 @@ pub async fn relay_request(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(error) = record_proxy_upgrade_traffic_success(state.data.as_ref(), &node_id).await {
|
if !rollout_probe {
|
||||||
warn!(
|
if let Err(error) =
|
||||||
node_id = %node_id,
|
record_proxy_upgrade_traffic_success(state.data.as_ref(), &node_id).await
|
||||||
error = %error,
|
{
|
||||||
"failed to record proxy upgrade traffic confirmation"
|
warn!(
|
||||||
);
|
node_id = %node_id,
|
||||||
|
error = %error,
|
||||||
|
"failed to record proxy upgrade traffic confirmation"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(mut body_rx) = stream.take_body_receiver() else {
|
let Some(mut body_rx) = stream.take_body_receiver() else {
|
||||||
@@ -462,8 +476,8 @@ mod tests {
|
|||||||
use super::super::hub::ProxyConn;
|
use super::super::hub::ProxyConn;
|
||||||
use super::super::{protocol, AppState, ConnConfig, ControlPlaneClient};
|
use super::super::{protocol, AppState, ConnConfig, ControlPlaneClient};
|
||||||
use super::{
|
use super::{
|
||||||
relay_header_timeout, relay_request, Body, Request, SocketAddr, StatusCode,
|
is_rollout_probe_request, relay_header_timeout, relay_request, Body, HeaderMap, Request,
|
||||||
TUNNEL_ERROR_HEADER,
|
SocketAddr, StatusCode, TUNNEL_ERROR_HEADER,
|
||||||
};
|
};
|
||||||
use crate::data::GatewayDataState;
|
use crate::data::GatewayDataState;
|
||||||
use crate::maintenance::start_proxy_upgrade_rollout;
|
use crate::maintenance::start_proxy_upgrade_rollout;
|
||||||
@@ -482,6 +496,20 @@ mod tests {
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rollout_probe_marker_is_only_trusted_from_a_forwarding_gateway() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
crate::tunnel::TUNNEL_RELAY_ROLLOUT_PROBE_HEADER,
|
||||||
|
crate::tunnel::TUNNEL_RELAY_ROLLOUT_PROBE_VALUE
|
||||||
|
.parse()
|
||||||
|
.expect("probe marker should be a valid header"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!is_rollout_probe_request(&headers, false));
|
||||||
|
assert!(is_rollout_probe_request(&headers, true));
|
||||||
|
}
|
||||||
|
|
||||||
fn test_app_state() -> AppState {
|
fn test_app_state() -> AppState {
|
||||||
AppState::new(
|
AppState::new(
|
||||||
ControlPlaneClient::disabled(),
|
ControlPlaneClient::disabled(),
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ mod proxy_conn;
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use aether_gateway_tunnel::{
|
||||||
|
resolve_proxy_max_streams, resolve_proxy_node_name, resolve_proxy_protocol_version,
|
||||||
|
};
|
||||||
use aether_runtime::{
|
use aether_runtime::{
|
||||||
hold_admission_permit_until, prometheus_response, service_up_sample, AdmissionPermit,
|
hold_admission_permit_until, prometheus_response, service_up_sample, AdmissionPermit,
|
||||||
ConcurrencyError, ConcurrencyGate, ConcurrencySnapshot, MetricKind, MetricLabel, MetricSample,
|
ConcurrencyError, ConcurrencyGate, ConcurrencySnapshot, MetricKind, MetricLabel, MetricSample,
|
||||||
@@ -17,7 +20,6 @@ use axum::http::HeaderMap;
|
|||||||
use axum::response::{IntoResponse, Json};
|
use axum::response::{IntoResponse, Json};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use base64::Engine as _;
|
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
@@ -335,121 +337,3 @@ pub async fn ws_proxy(
|
|||||||
})
|
})
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_proxy_max_streams(headers: &HeaderMap, fallback: usize) -> usize {
|
|
||||||
headers
|
|
||||||
.get("x-tunnel-max-streams")
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.and_then(|value| value.parse::<usize>().ok())
|
|
||||||
.unwrap_or(fallback)
|
|
||||||
.clamp(1, 2048)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_proxy_node_name(headers: &HeaderMap, node_id: &str) -> String {
|
|
||||||
if let Some(decoded) = headers
|
|
||||||
.get(aether_contracts::tunnel::TUNNEL_NODE_NAME_B64_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.and_then(|value| {
|
|
||||||
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
|
||||||
.decode(value.trim())
|
|
||||||
.ok()
|
|
||||||
})
|
|
||||||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
|
||||||
.map(|value| value.trim().to_string())
|
|
||||||
.filter(|value| !value.is_empty() && value.chars().count() <= 100)
|
|
||||||
{
|
|
||||||
return decoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
headers
|
|
||||||
.get("x-node-name")
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.unwrap_or(node_id)
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_proxy_protocol_version(headers: &HeaderMap) -> u8 {
|
|
||||||
headers
|
|
||||||
.get(aether_contracts::tunnel::TUNNEL_PROTOCOL_VERSION_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.and_then(|value| value.parse::<u8>().ok())
|
|
||||||
.filter(|value| *value >= 1)
|
|
||||||
.map(|value| value.min(aether_contracts::tunnel::CURRENT_TUNNEL_PROTOCOL_VERSION))
|
|
||||||
.unwrap_or(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use axum::http::{HeaderMap, HeaderValue};
|
|
||||||
use base64::Engine as _;
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
resolve_proxy_max_streams, resolve_proxy_node_name, resolve_proxy_protocol_version,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn proxy_max_streams_honors_small_advertised_capacity() {
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert("x-tunnel-max-streams", HeaderValue::from_static("8"));
|
|
||||||
|
|
||||||
assert_eq!(resolve_proxy_max_streams(&headers, 128), 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn proxy_max_streams_caps_unreasonably_large_capacity() {
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert("x-tunnel-max-streams", HeaderValue::from_static("9999"));
|
|
||||||
|
|
||||||
assert_eq!(resolve_proxy_max_streams(&headers, 128), 2048);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn proxy_protocol_version_defaults_to_v1_when_header_missing() {
|
|
||||||
let headers = HeaderMap::new();
|
|
||||||
assert_eq!(resolve_proxy_protocol_version(&headers), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn proxy_protocol_version_reads_advertised_version() {
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
aether_contracts::tunnel::TUNNEL_PROTOCOL_VERSION_HEADER,
|
|
||||||
HeaderValue::from_static("2"),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(resolve_proxy_protocol_version(&headers), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn proxy_node_name_reads_legacy_ascii_header() {
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert("x-node-name", HeaderValue::from_static("edge-1"));
|
|
||||||
|
|
||||||
assert_eq!(resolve_proxy_node_name(&headers, "node-1"), "edge-1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn proxy_node_name_decodes_base64_header() {
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode("日本节点");
|
|
||||||
headers.insert(
|
|
||||||
aether_contracts::tunnel::TUNNEL_NODE_NAME_B64_HEADER,
|
|
||||||
HeaderValue::from_str(&encoded).expect("encoded header value should parse"),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(resolve_proxy_node_name(&headers, "node-1"), "日本节点");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn proxy_node_name_falls_back_to_node_id_for_invalid_base64() {
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
aether_contracts::tunnel::TUNNEL_NODE_NAME_B64_HEADER,
|
|
||||||
HeaderValue::from_static("not valid"),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(resolve_proxy_node_name(&headers, "node-1"), "node-1");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,23 +1 @@
|
|||||||
use bytes::Bytes;
|
pub use aether_gateway_tunnel::embedded::protocol::*;
|
||||||
|
|
||||||
pub use aether_contracts::tunnel::{
|
|
||||||
decode_payload, encode_connection_close, encode_frame, encode_goaway, encode_goaway_v3,
|
|
||||||
encode_hello, encode_load_report, encode_ping, encode_pong, encode_reset_stream,
|
|
||||||
encode_settings, encode_stream_error, encode_window_update, frame_payload_by_header,
|
|
||||||
ConnectionClosePayload, FrameHeader, GoAwayPayload, HelloPayload, LoadReportPayload,
|
|
||||||
RequestMeta, ResetStreamPayload, ResponseMeta, SettingsPayload, WindowUpdatePayload,
|
|
||||||
CONNECTION_CLOSE, FLAG_END_STREAM, FLAG_GZIP_COMPRESSED, GOAWAY, HEADER_SIZE, HEARTBEAT_ACK,
|
|
||||||
HEARTBEAT_DATA, HELLO, LOAD_REPORT, PING, PONG, REQUEST_BODY, REQUEST_HEADERS, RESET_STREAM,
|
|
||||||
RESPONSE_BODY, RESPONSE_HEADERS, SETTINGS, STREAM_END, STREAM_ERROR, WINDOW_UPDATE,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn compress_payload(payload: &[u8]) -> Result<(Vec<u8>, u8), std::io::Error> {
|
|
||||||
let (compressed, flags) =
|
|
||||||
aether_contracts::tunnel::compress_payload(Bytes::copy_from_slice(payload));
|
|
||||||
Ok((compressed.to_vec(), flags))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn raw_payload(payload: &[u8]) -> (Vec<u8>, u8) {
|
|
||||||
let (payload, flags) = aether_contracts::tunnel::raw_payload(Bytes::copy_from_slice(payload));
|
|
||||||
(payload.to_vec(), flags)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use aether_contracts::tunnel::{
|
|||||||
use aether_data::repository::proxy_nodes::{
|
use aether_data::repository::proxy_nodes::{
|
||||||
ProxyNodeHeartbeatMutation, ProxyNodeTunnelStatusMutation, StoredProxyNode,
|
ProxyNodeHeartbeatMutation, ProxyNodeTunnelStatusMutation, StoredProxyNode,
|
||||||
};
|
};
|
||||||
|
use aether_gateway_tunnel::EmbeddedTunnelDefaults;
|
||||||
use aether_runtime::MetricSample;
|
use aether_runtime::MetricSample;
|
||||||
use aether_runtime_state::{MemoryRuntimeStateConfig, RuntimeState};
|
use aether_runtime_state::{MemoryRuntimeStateConfig, RuntimeState};
|
||||||
use async_stream::stream;
|
use async_stream::stream;
|
||||||
@@ -36,6 +37,11 @@ use super::error::GatewayError;
|
|||||||
use super::headers::{extract_or_generate_trace_id, should_skip_request_header};
|
use super::headers::{extract_or_generate_trace_id, should_skip_request_header};
|
||||||
use super::AppState;
|
use super::AppState;
|
||||||
|
|
||||||
|
pub(crate) use aether_gateway_tunnel::{
|
||||||
|
is_tunnel_heartbeat_path, is_tunnel_node_status_path, TunnelAttachmentRecord,
|
||||||
|
DEFAULT_OWNER_RELAY_BODY_LIMIT_BYTES, DEFAULT_TUNNEL_PROBE_BODY_LIMIT_BYTES, PROXY_TUNNEL_PATH,
|
||||||
|
TUNNEL_HEARTBEAT_PATH, TUNNEL_NODE_STATUS_PATH, TUNNEL_RELAY_PATH_PATTERN, TUNNEL_ROUTE_FAMILY,
|
||||||
|
};
|
||||||
pub(crate) use embedded::DirectRelayResponse;
|
pub(crate) use embedded::DirectRelayResponse;
|
||||||
pub(crate) use embedded::ProxyConn as TunnelProxyConn;
|
pub(crate) use embedded::ProxyConn as TunnelProxyConn;
|
||||||
pub use embedded::{
|
pub use embedded::{
|
||||||
@@ -44,24 +50,14 @@ pub use embedded::{
|
|||||||
ControlPlaneClient as TunnelControlPlaneClient,
|
ControlPlaneClient as TunnelControlPlaneClient,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) const PROXY_TUNNEL_PATH: &str = "/api/internal/proxy-tunnel";
|
|
||||||
pub(crate) const TUNNEL_HEARTBEAT_PATH: &str = "/api/internal/tunnel/heartbeat";
|
|
||||||
pub(crate) const TUNNEL_NODE_STATUS_PATH: &str = "/api/internal/tunnel/node-status";
|
|
||||||
pub(crate) const TUNNEL_RELAY_PATH_PATTERN: &str = "/api/internal/tunnel/relay/{node_id}";
|
|
||||||
pub(crate) const TUNNEL_ROUTE_FAMILY: &str = "tunnel_manage";
|
|
||||||
|
|
||||||
const DEFAULT_PROXY_IDLE_TIMEOUT_MS: u64 = 0;
|
|
||||||
const DEFAULT_PING_INTERVAL_MS: u64 = 15_000;
|
|
||||||
const DEFAULT_MAX_STREAMS: usize = 2048;
|
|
||||||
const DEFAULT_OUTBOUND_QUEUE_CAPACITY: usize = 512;
|
|
||||||
const DEFAULT_ATTACHMENT_TTL_SECS: u64 = 90;
|
const DEFAULT_ATTACHMENT_TTL_SECS: u64 = 90;
|
||||||
const DEFAULT_OWNER_RELAY_BODY_LIMIT_BYTES: usize = 5_242_880;
|
|
||||||
const DEFAULT_TUNNEL_PROBE_BODY_LIMIT_BYTES: usize = 64 * 1024;
|
|
||||||
const TUNNEL_ATTACHMENT_KEY_PREFIX: &str = "tunnel.attachments.";
|
const TUNNEL_ATTACHMENT_KEY_PREFIX: &str = "tunnel.attachments.";
|
||||||
const TUNNEL_ATTACHMENT_REDIS_KEY_PREFIX: &str = "tunnel:attachments:";
|
const TUNNEL_ATTACHMENT_REDIS_KEY_PREFIX: &str = "tunnel:attachments:";
|
||||||
const TUNNEL_INSTANCE_ID_ENV: &str = "AETHER_GATEWAY_INSTANCE_ID";
|
const TUNNEL_INSTANCE_ID_ENV: &str = "AETHER_GATEWAY_INSTANCE_ID";
|
||||||
const TUNNEL_RELAY_BASE_URL_ENV: &str = "AETHER_TUNNEL_RELAY_BASE_URL";
|
const TUNNEL_RELAY_BASE_URL_ENV: &str = "AETHER_TUNNEL_RELAY_BASE_URL";
|
||||||
const TUNNEL_ATTACHMENT_TTL_ENV: &str = "AETHER_TUNNEL_ATTACHMENT_TTL_SECS";
|
const TUNNEL_ATTACHMENT_TTL_ENV: &str = "AETHER_TUNNEL_ATTACHMENT_TTL_SECS";
|
||||||
|
pub(crate) const TUNNEL_RELAY_ROLLOUT_PROBE_HEADER: &str = "x-aether-tunnel-rollout-probe";
|
||||||
|
pub(crate) const TUNNEL_RELAY_ROLLOUT_PROBE_VALUE: &str = "1";
|
||||||
|
|
||||||
pub(crate) async fn send_owner_forward_request(
|
pub(crate) async fn send_owner_forward_request(
|
||||||
request: reqwest::RequestBuilder,
|
request: reqwest::RequestBuilder,
|
||||||
@@ -118,14 +114,6 @@ pub(crate) struct TunnelInstanceIdentity {
|
|||||||
attachment_ttl_secs: u64,
|
attachment_ttl_secs: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub(crate) struct TunnelAttachmentRecord {
|
|
||||||
pub(crate) gateway_instance_id: String,
|
|
||||||
pub(crate) relay_base_url: String,
|
|
||||||
pub(crate) conn_count: usize,
|
|
||||||
pub(crate) observed_at_unix_secs: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct TunnelAttachmentDirectory {
|
pub(crate) struct TunnelAttachmentDirectory {
|
||||||
identity: Arc<TunnelInstanceIdentity>,
|
identity: Arc<TunnelInstanceIdentity>,
|
||||||
@@ -263,11 +251,7 @@ impl TunnelAttachmentDirectory {
|
|||||||
let Some(record) = self.read_attachment_record(data, node_id).await? else {
|
let Some(record) = self.read_attachment_record(data, node_id).await? else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let is_expired = record
|
if !record.is_routable(current_unix_secs(), self.identity.attachment_ttl_secs) {
|
||||||
.observed_at_unix_secs
|
|
||||||
.saturating_add(self.identity.attachment_ttl_secs)
|
|
||||||
< current_unix_secs();
|
|
||||||
if is_expired || record.relay_base_url.trim().is_empty() {
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
Ok(Some(record))
|
Ok(Some(record))
|
||||||
@@ -281,7 +265,7 @@ impl TunnelAttachmentDirectory {
|
|||||||
let Some(record) = self.read_attachment_record(data, node_id).await? else {
|
let Some(record) = self.read_attachment_record(data, node_id).await? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
if record.gateway_instance_id == self.identity.instance_id {
|
if record.is_owned_by(&self.identity.instance_id) {
|
||||||
self.delete_attachment_record(data, node_id).await?;
|
self.delete_attachment_record(data, node_id).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -462,15 +446,16 @@ impl EmbeddedTunnelState {
|
|||||||
data: Arc<GatewayDataState>,
|
data: Arc<GatewayDataState>,
|
||||||
attachment_directory: TunnelAttachmentDirectory,
|
attachment_directory: TunnelAttachmentDirectory,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let defaults = EmbeddedTunnelDefaults::default();
|
||||||
Self {
|
Self {
|
||||||
inner: TunnelAppState::new(
|
inner: TunnelAppState::new(
|
||||||
build_embedded_control_plane(Arc::clone(&data), attachment_directory.clone()),
|
build_embedded_control_plane(Arc::clone(&data), attachment_directory.clone()),
|
||||||
ConnConfig {
|
ConnConfig {
|
||||||
ping_interval: Duration::from_millis(DEFAULT_PING_INTERVAL_MS),
|
ping_interval: defaults.ping_interval,
|
||||||
idle_timeout: Duration::from_millis(DEFAULT_PROXY_IDLE_TIMEOUT_MS),
|
idle_timeout: defaults.proxy_idle_timeout,
|
||||||
outbound_queue_capacity: DEFAULT_OUTBOUND_QUEUE_CAPACITY,
|
outbound_queue_capacity: defaults.outbound_queue_capacity,
|
||||||
},
|
},
|
||||||
DEFAULT_MAX_STREAMS,
|
defaults.max_streams,
|
||||||
)
|
)
|
||||||
.with_data(data),
|
.with_data(data),
|
||||||
attachment_directory,
|
attachment_directory,
|
||||||
@@ -531,6 +516,53 @@ impl EmbeddedTunnelState {
|
|||||||
.status)
|
.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn probe_node_url_routed(
|
||||||
|
&self,
|
||||||
|
state: &AppState,
|
||||||
|
node_id: &str,
|
||||||
|
url: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<u16, String> {
|
||||||
|
if self.has_local_proxy(node_id) {
|
||||||
|
return self.probe_node_url(node_id, url, timeout_secs).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(owner) = self
|
||||||
|
.lookup_attachment_owner(state.data.as_ref(), node_id)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return self.probe_node_url(node_id, url, timeout_secs).await;
|
||||||
|
};
|
||||||
|
if owner.gateway_instance_id == self.local_instance_id() {
|
||||||
|
self.clear_local_attachment_if_stale(state.data.as_ref(), node_id)
|
||||||
|
.await?;
|
||||||
|
return self.probe_node_url(node_id, url, timeout_secs).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeout_secs = timeout_secs.clamp(5, 60);
|
||||||
|
let owner_url = build_owner_relay_url(&owner.relay_base_url, node_id)
|
||||||
|
.map_err(|error| format!("invalid owner tunnel probe URL: {error:?}"))?;
|
||||||
|
let payload = encode_tunnel_relay_envelope(&build_tunnel_probe_meta(url, timeout_secs))?;
|
||||||
|
let response = state
|
||||||
|
.owner_forward_client
|
||||||
|
.post(owner_url)
|
||||||
|
.header(TUNNEL_RELAY_FORWARDED_BY_HEADER, self.local_instance_id())
|
||||||
|
.header(
|
||||||
|
TUNNEL_RELAY_OWNER_INSTANCE_HEADER,
|
||||||
|
owner.gateway_instance_id.as_str(),
|
||||||
|
)
|
||||||
|
.header(
|
||||||
|
TUNNEL_RELAY_ROLLOUT_PROBE_HEADER,
|
||||||
|
TUNNEL_RELAY_ROLLOUT_PROBE_VALUE,
|
||||||
|
)
|
||||||
|
.timeout(Duration::from_secs(timeout_secs))
|
||||||
|
.body(payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("owner tunnel probe failed: {error}"))?;
|
||||||
|
Ok(response.status().as_u16())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn probe_node_url_with_response(
|
pub(crate) async fn probe_node_url_with_response(
|
||||||
&self,
|
&self,
|
||||||
node_id: &str,
|
node_id: &str,
|
||||||
@@ -538,21 +570,7 @@ impl EmbeddedTunnelState {
|
|||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
) -> Result<TunnelProbeResponse, String> {
|
) -> Result<TunnelProbeResponse, String> {
|
||||||
let timeout_secs = timeout_secs.clamp(5, 60);
|
let timeout_secs = timeout_secs.clamp(5, 60);
|
||||||
let meta = tunnel_protocol::RequestMeta {
|
let meta = build_tunnel_probe_meta(url, timeout_secs);
|
||||||
provider_id: None,
|
|
||||||
endpoint_id: None,
|
|
||||||
key_id: None,
|
|
||||||
method: "GET".to_string(),
|
|
||||||
url: url.trim().to_string(),
|
|
||||||
headers: HashMap::new(),
|
|
||||||
stream: false,
|
|
||||||
request_timeout_ms: None,
|
|
||||||
stream_first_byte_timeout_ms: None,
|
|
||||||
timeout: timeout_secs,
|
|
||||||
follow_redirects: Some(false),
|
|
||||||
http1_only: false,
|
|
||||||
transport_profile: None,
|
|
||||||
};
|
|
||||||
let stream = self.inner.hub.open_local_stream(node_id, &meta).await?;
|
let stream = self.inner.hub.open_local_stream(node_id, &meta).await?;
|
||||||
let stream_id = stream.id;
|
let stream_id = stream.id;
|
||||||
let result = async {
|
let result = async {
|
||||||
@@ -623,6 +641,35 @@ impl EmbeddedTunnelState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_tunnel_probe_meta(url: &str, timeout_secs: u64) -> tunnel_protocol::RequestMeta {
|
||||||
|
tunnel_protocol::RequestMeta {
|
||||||
|
provider_id: None,
|
||||||
|
endpoint_id: None,
|
||||||
|
key_id: None,
|
||||||
|
method: "GET".to_string(),
|
||||||
|
url: url.trim().to_string(),
|
||||||
|
headers: HashMap::new(),
|
||||||
|
stream: false,
|
||||||
|
request_timeout_ms: None,
|
||||||
|
stream_first_byte_timeout_ms: None,
|
||||||
|
timeout: timeout_secs,
|
||||||
|
follow_redirects: Some(false),
|
||||||
|
http1_only: false,
|
||||||
|
transport_profile: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_tunnel_relay_envelope(meta: &tunnel_protocol::RequestMeta) -> Result<Vec<u8>, String> {
|
||||||
|
let meta = serde_json::to_vec(meta)
|
||||||
|
.map_err(|error| format!("failed to encode tunnel probe metadata: {error}"))?;
|
||||||
|
let meta_len = u32::try_from(meta.len())
|
||||||
|
.map_err(|_| "tunnel probe metadata exceeds relay envelope limit".to_string())?;
|
||||||
|
let mut payload = Vec::with_capacity(4usize.saturating_add(meta.len()));
|
||||||
|
payload.extend_from_slice(&meta_len.to_be_bytes());
|
||||||
|
payload.extend_from_slice(&meta);
|
||||||
|
Ok(payload)
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for EmbeddedTunnelState {
|
impl Default for EmbeddedTunnelState {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
@@ -631,11 +678,15 @@ impl Default for EmbeddedTunnelState {
|
|||||||
|
|
||||||
impl fmt::Debug for EmbeddedTunnelState {
|
impl fmt::Debug for EmbeddedTunnelState {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let defaults = EmbeddedTunnelDefaults::default();
|
||||||
f.debug_struct("EmbeddedTunnelState")
|
f.debug_struct("EmbeddedTunnelState")
|
||||||
.field("proxy_idle_timeout_ms", &DEFAULT_PROXY_IDLE_TIMEOUT_MS)
|
.field(
|
||||||
.field("ping_interval_ms", &DEFAULT_PING_INTERVAL_MS)
|
"proxy_idle_timeout_ms",
|
||||||
.field("max_streams", &DEFAULT_MAX_STREAMS)
|
&defaults.proxy_idle_timeout.as_millis(),
|
||||||
.field("outbound_queue_capacity", &DEFAULT_OUTBOUND_QUEUE_CAPACITY)
|
)
|
||||||
|
.field("ping_interval_ms", &defaults.ping_interval.as_millis())
|
||||||
|
.field("max_streams", &defaults.max_streams)
|
||||||
|
.field("outbound_queue_capacity", &defaults.outbound_queue_capacity)
|
||||||
.field(
|
.field(
|
||||||
"instance_id",
|
"instance_id",
|
||||||
&self.attachment_directory.local_instance_id(),
|
&self.attachment_directory.local_instance_id(),
|
||||||
@@ -714,14 +765,6 @@ pub(crate) async fn relay_request(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn is_tunnel_heartbeat_path(path: &str) -> bool {
|
|
||||||
path == TUNNEL_HEARTBEAT_PATH
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn is_tunnel_node_status_path(path: &str) -> bool {
|
|
||||||
path == TUNNEL_NODE_STATUS_PATH
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_embedded_control_plane(
|
fn build_embedded_control_plane(
|
||||||
data: Arc<GatewayDataState>,
|
data: Arc<GatewayDataState>,
|
||||||
attachment_directory: TunnelAttachmentDirectory,
|
attachment_directory: TunnelAttachmentDirectory,
|
||||||
@@ -1134,17 +1177,25 @@ fn parse_embedded_tunnel_heartbeat_request(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
apply_embedded_tunnel_heartbeat, apply_embedded_tunnel_node_status, current_unix_secs,
|
apply_embedded_tunnel_heartbeat, apply_embedded_tunnel_node_status,
|
||||||
prepare_owner_relay_request_body, tunnel_attachment_key, GatewayDataState,
|
build_tunnel_probe_meta, current_unix_secs, encode_tunnel_relay_envelope,
|
||||||
|
prepare_owner_relay_request_body, tunnel_attachment_key, AppState, GatewayDataState,
|
||||||
TunnelAttachmentDirectory, TunnelAttachmentRecord,
|
TunnelAttachmentDirectory, TunnelAttachmentRecord,
|
||||||
};
|
};
|
||||||
|
use aether_contracts::tunnel::{
|
||||||
|
try_decode_tunnel_relay_request_meta, TUNNEL_RELAY_FORWARDED_BY_HEADER,
|
||||||
|
TUNNEL_RELAY_OWNER_INSTANCE_HEADER,
|
||||||
|
};
|
||||||
use aether_data::repository::proxy_nodes::{
|
use aether_data::repository::proxy_nodes::{
|
||||||
InMemoryProxyNodeRepository, ProxyNodeReadRepository, StoredProxyNode,
|
InMemoryProxyNodeRepository, ProxyNodeReadRepository, StoredProxyNode,
|
||||||
};
|
};
|
||||||
use axum::body::Body;
|
use axum::body::{Body, Bytes};
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use axum::routing::post;
|
||||||
|
use axum::Router;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
fn sample_proxy_node(node_id: &str) -> StoredProxyNode {
|
fn sample_proxy_node(node_id: &str) -> StoredProxyNode {
|
||||||
StoredProxyNode::new(
|
StoredProxyNode::new(
|
||||||
@@ -1183,6 +1234,99 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn routed_tunnel_probe_builds_a_valid_owner_relay_envelope() {
|
||||||
|
let meta = build_tunnel_probe_meta("https://probe.example/health", 7);
|
||||||
|
let envelope = encode_tunnel_relay_envelope(&meta).expect("probe should encode");
|
||||||
|
let (decoded, body_offset) = try_decode_tunnel_relay_request_meta(&envelope)
|
||||||
|
.expect("probe envelope should decode")
|
||||||
|
.expect("probe envelope should contain complete metadata");
|
||||||
|
|
||||||
|
assert_eq!(decoded.method, "GET");
|
||||||
|
assert_eq!(decoded.url, "https://probe.example/health");
|
||||||
|
assert_eq!(decoded.timeout, 7);
|
||||||
|
assert_eq!(body_offset, envelope.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn routed_tunnel_probe_forwards_to_the_attachment_owner() {
|
||||||
|
let captured = Arc::new(Mutex::new(None::<(HeaderMap, Bytes)>));
|
||||||
|
let captured_for_route = Arc::clone(&captured);
|
||||||
|
let app = Router::new().route(
|
||||||
|
"/api/internal/tunnel/relay/{node_id}",
|
||||||
|
post(move |headers: HeaderMap, body: Bytes| {
|
||||||
|
let captured = Arc::clone(&captured_for_route);
|
||||||
|
async move {
|
||||||
|
*captured.lock().expect("capture lock") = Some((headers, body));
|
||||||
|
StatusCode::NO_CONTENT
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.expect("owner listener should bind");
|
||||||
|
let owner_base_url = format!("http://{}", listener.local_addr().expect("owner address"));
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.await
|
||||||
|
.expect("owner server should run");
|
||||||
|
});
|
||||||
|
|
||||||
|
let owner = TunnelAttachmentRecord {
|
||||||
|
gateway_instance_id: "gateway-b".to_string(),
|
||||||
|
relay_base_url: owner_base_url,
|
||||||
|
conn_count: 1,
|
||||||
|
observed_at_unix_secs: current_unix_secs(),
|
||||||
|
};
|
||||||
|
let data = GatewayDataState::disabled().with_system_config_values_for_tests(vec![(
|
||||||
|
tunnel_attachment_key("node-remote"),
|
||||||
|
serde_json::to_value(owner).expect("owner record should serialize"),
|
||||||
|
)]);
|
||||||
|
let state = AppState::new()
|
||||||
|
.expect("app state should build")
|
||||||
|
.with_data_state_for_tests(data)
|
||||||
|
.with_tunnel_identity("gateway-a", Some("http://gateway-a.internal"));
|
||||||
|
|
||||||
|
let status = state
|
||||||
|
.tunnel
|
||||||
|
.probe_node_url_routed(&state, "node-remote", "https://probe.example/health", 5)
|
||||||
|
.await
|
||||||
|
.expect("remote owner probe should succeed");
|
||||||
|
assert_eq!(status, StatusCode::NO_CONTENT.as_u16());
|
||||||
|
|
||||||
|
let (headers, body) = captured
|
||||||
|
.lock()
|
||||||
|
.expect("capture lock")
|
||||||
|
.take()
|
||||||
|
.expect("owner should receive the probe");
|
||||||
|
assert_eq!(
|
||||||
|
headers
|
||||||
|
.get(TUNNEL_RELAY_FORWARDED_BY_HEADER)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("gateway-a")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers
|
||||||
|
.get(TUNNEL_RELAY_OWNER_INSTANCE_HEADER)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("gateway-b")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers
|
||||||
|
.get(super::TUNNEL_RELAY_ROLLOUT_PROBE_HEADER)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some(super::TUNNEL_RELAY_ROLLOUT_PROBE_VALUE)
|
||||||
|
);
|
||||||
|
let (meta, body_offset) = try_decode_tunnel_relay_request_meta(&body)
|
||||||
|
.expect("owner probe envelope should decode")
|
||||||
|
.expect("owner probe metadata should be complete");
|
||||||
|
assert_eq!(meta.url, "https://probe.example/health");
|
||||||
|
assert_eq!(body_offset, body.len());
|
||||||
|
|
||||||
|
server.abort();
|
||||||
|
let _ = server.await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn owner_relay_body_preparation_rejects_invalid_metadata() {
|
async fn owner_relay_body_preparation_rejects_invalid_metadata() {
|
||||||
let mut envelope = Vec::new();
|
let mut envelope = Vec::new();
|
||||||
|
|||||||
@@ -55,11 +55,16 @@ async fn resolve_wallet_auth_gate_with_cache(
|
|||||||
None => WalletAccessDecision::wallet_unavailable(None),
|
None => WalletAccessDecision::wallet_unavailable(None),
|
||||||
};
|
};
|
||||||
if !auth_snapshot.api_key_is_standalone {
|
if !auth_snapshot.api_key_is_standalone {
|
||||||
if let Some(quota) = state
|
let quota = if use_cache {
|
||||||
.find_user_daily_quota_availability(&auth_snapshot.user_id)
|
state
|
||||||
.await?
|
.find_user_daily_quota_availability_for_auth(&auth_snapshot.user_id)
|
||||||
.filter(|quota| quota.has_active_daily_quota)
|
.await?
|
||||||
{
|
} else {
|
||||||
|
state
|
||||||
|
.find_user_daily_quota_availability_for_auth_uncached(&auth_snapshot.user_id)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
if let Some(quota) = quota.filter(|quota| quota.has_active_daily_quota) {
|
||||||
let has_remaining_quota = quota.remaining_usd > DAILY_QUOTA_EPSILON_USD;
|
let has_remaining_quota = quota.remaining_usd > DAILY_QUOTA_EPSILON_USD;
|
||||||
if decision.failure == Some(WalletAccessFailure::BalanceDenied) && has_remaining_quota {
|
if decision.failure == Some(WalletAccessFailure::BalanceDenied) && has_remaining_quota {
|
||||||
return Ok(Some(WalletAccessDecision::allowed(Some(
|
return Ok(Some(WalletAccessDecision::allowed(Some(
|
||||||
@@ -106,6 +111,7 @@ fn map_wallet_snapshot(snapshot: &StoredWalletSnapshot) -> WalletSnapshot {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use aether_data::repository::usage::InMemoryUsageReadRepository;
|
use aether_data::repository::usage::InMemoryUsageReadRepository;
|
||||||
use aether_data::repository::wallet::{InMemoryWalletRepository, StoredWalletSnapshot};
|
use aether_data::repository::wallet::{InMemoryWalletRepository, StoredWalletSnapshot};
|
||||||
@@ -113,6 +119,7 @@ mod tests {
|
|||||||
BillingReadRepository, StoredBillingModelContext, UserDailyQuotaAvailabilityRecord,
|
BillingReadRepository, StoredBillingModelContext, UserDailyQuotaAvailabilityRecord,
|
||||||
};
|
};
|
||||||
use aether_data_contracts::DataLayerError;
|
use aether_data_contracts::DataLayerError;
|
||||||
|
use aether_runtime::ConcurrencyGate;
|
||||||
use aether_wallet::{WalletAccessFailure, WalletLimitMode, WalletSnapshot, WalletStatus};
|
use aether_wallet::{WalletAccessFailure, WalletLimitMode, WalletSnapshot, WalletStatus};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
@@ -257,6 +264,41 @@ mod tests {
|
|||||||
assert_eq!(decision.remaining, Some(4.0));
|
assert_eq!(decision.remaining, Some(4.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn disabled_auth_capacity_cache_still_gates_wallet_reads() {
|
||||||
|
let mut state = state_with_wallet_and_quota(empty_user_wallet(), None);
|
||||||
|
let mut guard_config = (*state.frontdoor_runtime_guards).clone();
|
||||||
|
guard_config.auth_capacity_cache_ttl = Duration::ZERO;
|
||||||
|
state = state.with_frontdoor_runtime_guard_config_for_tests(guard_config);
|
||||||
|
state.auth_snapshot_load_gate =
|
||||||
|
Some(Arc::new(ConcurrencyGate::new("test_auth_wallet_load", 1)));
|
||||||
|
let held = state
|
||||||
|
.acquire_auth_snapshot_load_gate()
|
||||||
|
.await
|
||||||
|
.expect("auth gate acquisition should succeed")
|
||||||
|
.expect("auth gate should be configured");
|
||||||
|
|
||||||
|
let blocked = tokio::time::timeout(
|
||||||
|
Duration::from_millis(25),
|
||||||
|
state.read_wallet_snapshot_for_auth("user-1", "api-key-1", false),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
blocked.is_err(),
|
||||||
|
"zero-TTL wallet reads must wait for the auth DB gate"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(held);
|
||||||
|
let wallet = tokio::time::timeout(
|
||||||
|
Duration::from_secs(1),
|
||||||
|
state.read_wallet_snapshot_for_auth("user-1", "api-key-1", false),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("wallet read should resume after releasing the auth gate")
|
||||||
|
.expect("wallet read should succeed");
|
||||||
|
assert!(wallet.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn admin_wallet_recharge_invalidates_cached_auth_capacity_state() {
|
async fn admin_wallet_recharge_invalidates_cached_auth_capacity_state() {
|
||||||
let wallet = empty_user_wallet();
|
let wallet = empty_user_wallet();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::data::GatewayDataState;
|
use crate::data::GatewayDataState;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
const QUOTA_RESET_INTERVAL: Duration = Duration::from_secs(60 * 60);
|
const QUOTA_RESET_INTERVAL: Duration = Duration::from_secs(60 * 60);
|
||||||
|
|
||||||
@@ -18,26 +18,31 @@ pub(crate) async fn reset_due_provider_quotas_once(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn spawn_provider_quota_reset_worker(
|
pub(crate) fn spawn_provider_quota_reset_worker(
|
||||||
data: Arc<GatewayDataState>,
|
app: AppState,
|
||||||
) -> Option<tokio::task::JoinHandle<()>> {
|
) -> Option<tokio::task::JoinHandle<()>> {
|
||||||
if !data.has_provider_quota_writer() {
|
if !app.data.has_provider_quota_writer() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(tokio::spawn(async move {
|
Some(crate::task_runtime::spawn_singleton_worker(
|
||||||
if let Err(err) = reset_due_provider_quotas_once(&data).await {
|
app,
|
||||||
warn!(error = %err, "gateway provider quota reset startup failed");
|
crate::task_runtime::TASK_KEY_PROVIDER_QUOTA_RESET,
|
||||||
}
|
|app| async move {
|
||||||
let mut interval = tokio::time::interval(QUOTA_RESET_INTERVAL);
|
let data = app.data;
|
||||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
|
||||||
interval.tick().await;
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
if let Err(err) = reset_due_provider_quotas_once(&data).await {
|
if let Err(err) = reset_due_provider_quotas_once(&data).await {
|
||||||
warn!(error = %err, "gateway provider quota reset tick failed");
|
warn!(error = %err, "gateway provider quota reset startup failed");
|
||||||
}
|
}
|
||||||
}
|
let mut interval = tokio::time::interval(QUOTA_RESET_INTERVAL);
|
||||||
}))
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
|
interval.tick().await;
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if let Err(err) = reset_due_provider_quotas_once(&data).await {
|
||||||
|
warn!(error = %err, "gateway provider quota reset tick failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -52,6 +57,7 @@ mod tests {
|
|||||||
|
|
||||||
use super::{reset_due_provider_quotas_once, spawn_provider_quota_reset_worker};
|
use super::{reset_due_provider_quotas_once, spawn_provider_quota_reset_worker};
|
||||||
use crate::data::GatewayDataState;
|
use crate::data::GatewayDataState;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn resets_due_provider_quotas_from_runtime() {
|
async fn resets_due_provider_quotas_from_runtime() {
|
||||||
@@ -98,10 +104,12 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("quota should build"),
|
.expect("quota should build"),
|
||||||
]));
|
]));
|
||||||
let data = Arc::new(GatewayDataState::with_provider_quota_repository_for_tests(
|
let state = AppState::new()
|
||||||
repository.clone(),
|
.expect("gateway state should build")
|
||||||
));
|
.with_data_state_for_tests(GatewayDataState::with_provider_quota_repository_for_tests(
|
||||||
let handle = spawn_provider_quota_reset_worker(data).expect("worker should spawn");
|
repository.clone(),
|
||||||
|
));
|
||||||
|
let handle = spawn_provider_quota_reset_worker(state).expect("worker should spawn");
|
||||||
|
|
||||||
let stored = tokio::time::timeout(Duration::from_secs(1), async {
|
let stored = tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ description = "Tunnel agent for Aether"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
aether-contracts.workspace = true
|
aether-contracts.workspace = true
|
||||||
|
aether-gateway-tunnel.workspace = true
|
||||||
aether-http.workspace = true
|
aether-http.workspace = true
|
||||||
aether-runtime.workspace = true
|
aether-runtime.workspace = true
|
||||||
aether-runtime-state.workspace = true
|
aether-runtime-state.workspace = true
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
pub use aether_contracts::tunnel::*;
|
pub use aether_gateway_tunnel::protocol::*;
|
||||||
|
|||||||
@@ -1779,22 +1779,26 @@ pub fn admin_system_config_default_value(key: &str) -> Option<serde_json::Value>
|
|||||||
pub fn build_admin_system_configs_payload(
|
pub fn build_admin_system_configs_payload(
|
||||||
entries: &[StoredSystemConfigEntry],
|
entries: &[StoredSystemConfigEntry],
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
let has_request_record_level = entries
|
let canonical_keys = entries
|
||||||
.iter()
|
.iter()
|
||||||
.any(|entry| entry.key == REQUEST_RECORD_LEVEL_KEY);
|
.filter_map(|entry| {
|
||||||
|
let normalized = normalize_admin_system_config_key(&entry.key);
|
||||||
|
entry
|
||||||
|
.key
|
||||||
|
.eq_ignore_ascii_case(&normalized)
|
||||||
|
.then(|| normalized.to_ascii_lowercase())
|
||||||
|
})
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
json!(entries
|
json!(entries
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|entry| {
|
.filter_map(|entry| {
|
||||||
if entry.key == LEGACY_REQUEST_LOG_LEVEL_KEY && has_request_record_level {
|
let normalized_key = normalize_admin_system_config_key(&entry.key);
|
||||||
|
let is_legacy = !entry.key.eq_ignore_ascii_case(&normalized_key);
|
||||||
|
if is_legacy && canonical_keys.contains(&normalized_key.to_ascii_lowercase()) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let key = if entry.key == LEGACY_REQUEST_LOG_LEVEL_KEY {
|
|
||||||
REQUEST_RECORD_LEVEL_KEY
|
|
||||||
} else {
|
|
||||||
entry.key.as_str()
|
|
||||||
};
|
|
||||||
Some(build_admin_system_config_list_item(
|
Some(build_admin_system_config_list_item(
|
||||||
key,
|
&normalized_key,
|
||||||
&entry.value,
|
&entry.value,
|
||||||
entry.description.as_deref(),
|
entry.description.as_deref(),
|
||||||
entry.updated_at_unix_secs,
|
entry.updated_at_unix_secs,
|
||||||
@@ -3419,6 +3423,41 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn system_config_list_normalizes_legacy_keys_and_prefers_canonical_rows() {
|
||||||
|
let entries = vec![
|
||||||
|
StoredSystemConfigEntry {
|
||||||
|
key: "module.important_notification.server_chan_send_key".to_string(),
|
||||||
|
value: json!("legacy-secret"),
|
||||||
|
description: None,
|
||||||
|
updated_at_unix_secs: None,
|
||||||
|
},
|
||||||
|
StoredSystemConfigEntry {
|
||||||
|
key: "module.server_chan_push.send_key".to_string(),
|
||||||
|
value: json!("canonical-secret"),
|
||||||
|
description: None,
|
||||||
|
updated_at_unix_secs: None,
|
||||||
|
},
|
||||||
|
StoredSystemConfigEntry {
|
||||||
|
key: "module.notification_email.enabled".to_string(),
|
||||||
|
value: json!(true),
|
||||||
|
description: None,
|
||||||
|
updated_at_unix_secs: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let payload = build_admin_system_configs_payload(&entries);
|
||||||
|
let rows = payload.as_array().expect("config list should be an array");
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
assert!(rows.iter().any(|row| {
|
||||||
|
row["key"] == json!("module.server_chan_push.send_key") && row["is_set"] == json!(true)
|
||||||
|
}));
|
||||||
|
assert!(rows.iter().any(|row| {
|
||||||
|
row["key"] == json!("module.important_notification.enabled")
|
||||||
|
&& row["value"] == json!(true)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn notification_service_items_are_normalized() {
|
fn notification_service_items_are_normalized() {
|
||||||
let update = parse_admin_system_config_update(
|
let update = parse_admin_system_config_update(
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "aether-admission-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
description = "Pure resource admission policy and budget contracts for Aether"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum DbClass {
|
||||||
|
None,
|
||||||
|
ForegroundRead,
|
||||||
|
ForegroundWrite,
|
||||||
|
Background,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum RedisLane {
|
||||||
|
None,
|
||||||
|
Fast,
|
||||||
|
Stream,
|
||||||
|
Admin,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ResourceClass {
|
||||||
|
Interactive,
|
||||||
|
Streaming,
|
||||||
|
Upload,
|
||||||
|
Background,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ResourceBudget {
|
||||||
|
pub request_permits: usize,
|
||||||
|
pub body_bytes: usize,
|
||||||
|
pub db_class: DbClass,
|
||||||
|
pub redis_lane: RedisLane,
|
||||||
|
pub upstream_permits: usize,
|
||||||
|
pub stream_permits: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResourceBudget {
|
||||||
|
pub const fn interactive() -> Self {
|
||||||
|
Self {
|
||||||
|
request_permits: 1,
|
||||||
|
body_bytes: 0,
|
||||||
|
db_class: DbClass::ForegroundRead,
|
||||||
|
redis_lane: RedisLane::Fast,
|
||||||
|
upstream_permits: 1,
|
||||||
|
stream_permits: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn streaming() -> Self {
|
||||||
|
Self {
|
||||||
|
request_permits: 1,
|
||||||
|
body_bytes: 0,
|
||||||
|
db_class: DbClass::ForegroundWrite,
|
||||||
|
redis_lane: RedisLane::Fast,
|
||||||
|
upstream_permits: 1,
|
||||||
|
stream_permits: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn for_class(class: ResourceClass) -> Self {
|
||||||
|
match class {
|
||||||
|
ResourceClass::Interactive => Self::interactive(),
|
||||||
|
ResourceClass::Streaming => Self::streaming(),
|
||||||
|
ResourceClass::Upload => Self {
|
||||||
|
body_bytes: 64 * 1024 * 1024,
|
||||||
|
..Self::interactive()
|
||||||
|
},
|
||||||
|
ResourceClass::Background => Self {
|
||||||
|
request_permits: 1,
|
||||||
|
body_bytes: 0,
|
||||||
|
db_class: DbClass::Background,
|
||||||
|
redis_lane: RedisLane::Admin,
|
||||||
|
upstream_permits: 0,
|
||||||
|
stream_permits: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<(), AdmissionConfigError> {
|
||||||
|
if self.request_permits == 0 {
|
||||||
|
return Err(AdmissionConfigError::ZeroRequestPermits);
|
||||||
|
}
|
||||||
|
if self.upstream_permits == 0 && self.stream_permits > 0 {
|
||||||
|
return Err(AdmissionConfigError::StreamWithoutUpstream);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||||
|
pub enum AdmissionConfigError {
|
||||||
|
#[error("admission budget must reserve at least one request permit")]
|
||||||
|
ZeroRequestPermits,
|
||||||
|
#[error("stream permits require an upstream permit")]
|
||||||
|
StreamWithoutUpstream,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::ResourceBudget;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn streaming_budget_has_valid_upstream_shape() {
|
||||||
|
assert!(ResourceBudget::streaming().validate().is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//! Transport-independent admission contracts.
|
||||||
|
//!
|
||||||
|
//! This crate deliberately contains no Tokio, HTTP client, database, or Redis
|
||||||
|
//! dependency. Runtime adapters can implement the decisions without pulling
|
||||||
|
//! the gateway's infrastructure graph into domain crates.
|
||||||
|
|
||||||
|
mod budget;
|
||||||
|
mod metrics;
|
||||||
|
mod permit;
|
||||||
|
mod policy;
|
||||||
|
|
||||||
|
pub use budget::{AdmissionConfigError, DbClass, RedisLane, ResourceBudget, ResourceClass};
|
||||||
|
pub use metrics::AdmissionMetricsSnapshot;
|
||||||
|
pub use permit::{PermitKind, RequestPermitSet};
|
||||||
|
pub use policy::{
|
||||||
|
AdmissionDecision, AdmissionPolicy, AdmissionRejectReason, AdmissionRequest,
|
||||||
|
DefaultAdmissionPolicy,
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct AdmissionMetricsSnapshot {
|
||||||
|
pub admitted_total: u64,
|
||||||
|
pub rejected_total: u64,
|
||||||
|
pub saturated_total: u64,
|
||||||
|
pub queue_deadline_exceeded_total: u64,
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PermitKind {
|
||||||
|
Request,
|
||||||
|
Body,
|
||||||
|
Planning,
|
||||||
|
Database,
|
||||||
|
Upstream,
|
||||||
|
Stream,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RequestPermitSet<Permit> {
|
||||||
|
pub request: Permit,
|
||||||
|
pub body: Option<Permit>,
|
||||||
|
pub planning: Option<Permit>,
|
||||||
|
pub db: Option<Permit>,
|
||||||
|
pub upstream: Option<Permit>,
|
||||||
|
pub stream: Option<Permit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Permit> RequestPermitSet<Permit> {
|
||||||
|
pub fn new(request: Permit) -> Self {
|
||||||
|
Self {
|
||||||
|
request,
|
||||||
|
body: None,
|
||||||
|
planning: None,
|
||||||
|
db: None,
|
||||||
|
upstream: None,
|
||||||
|
stream: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
use crate::{ResourceBudget, ResourceClass};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct AdmissionRequest<'a> {
|
||||||
|
pub trace_id: &'a str,
|
||||||
|
pub class: ResourceClass,
|
||||||
|
pub body_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AdmissionDecision {
|
||||||
|
Admit(ResourceBudget),
|
||||||
|
Reject(AdmissionRejectReason),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AdmissionRejectReason {
|
||||||
|
InvalidRequest,
|
||||||
|
BodyTooLarge,
|
||||||
|
ResourceSaturated,
|
||||||
|
QueueDeadlineExceeded,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait AdmissionPolicy: Send + Sync {
|
||||||
|
fn decide(&self, request: AdmissionRequest<'_>) -> AdmissionDecision;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub struct DefaultAdmissionPolicy;
|
||||||
|
|
||||||
|
impl AdmissionPolicy for DefaultAdmissionPolicy {
|
||||||
|
fn decide(&self, request: AdmissionRequest<'_>) -> AdmissionDecision {
|
||||||
|
if request.trace_id.trim().is_empty() {
|
||||||
|
return AdmissionDecision::Reject(AdmissionRejectReason::InvalidRequest);
|
||||||
|
}
|
||||||
|
let budget = ResourceBudget::for_class(request.class);
|
||||||
|
if budget.body_bytes > 0 && request.body_bytes > budget.body_bytes {
|
||||||
|
return AdmissionDecision::Reject(AdmissionRejectReason::BodyTooLarge);
|
||||||
|
}
|
||||||
|
AdmissionDecision::Admit(budget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
AdmissionDecision, AdmissionPolicy, AdmissionRejectReason, AdmissionRequest,
|
||||||
|
DefaultAdmissionPolicy,
|
||||||
|
};
|
||||||
|
use crate::ResourceClass;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_policy_rejects_empty_trace_ids() {
|
||||||
|
let decision = DefaultAdmissionPolicy.decide(AdmissionRequest {
|
||||||
|
trace_id: " ",
|
||||||
|
class: ResourceClass::Interactive,
|
||||||
|
body_bytes: 0,
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
decision,
|
||||||
|
AdmissionDecision::Reject(AdmissionRejectReason::InvalidRequest)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user