mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 16:05:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b22788746 | ||
|
|
e973fc516e | ||
|
|
12ecbf574a | ||
|
|
443bd6ad17 | ||
|
|
c795098a87 | ||
|
|
1f0009341d | ||
|
|
1359eaa894 | ||
|
|
74318af570 | ||
|
|
ddc10d7c1a | ||
|
|
6bc2189e6f | ||
|
|
b206ee8271 | ||
|
|
70ff923033 | ||
|
|
a2fbc073a4 | ||
|
|
9944041dc8 | ||
|
|
18a04f1213 | ||
|
|
35988d0eb7 | ||
|
|
6673b6bd1e | ||
|
|
2a7fd08e9e | ||
|
|
7f69e81176 | ||
|
|
93dc417b79 | ||
|
|
f905e4b3fb | ||
|
|
ed3e23887b | ||
|
|
91f35d88cf | ||
|
|
f465a337e8 | ||
|
|
37465e2872 | ||
|
|
70c18dadd6 | ||
|
|
881a0eaef6 | ||
|
|
95f7a5a636 | ||
|
|
871406ab24 | ||
|
|
38a1cc6933 | ||
|
|
d38e0d4296 | ||
|
|
d12d75a037 | ||
|
|
a2922b410e | ||
|
|
d303efc6f7 | ||
|
|
dd17332c31 | ||
|
|
47d08786ad | ||
|
|
3434ad5e72 | ||
|
|
e91ec88a1f | ||
|
|
6f7a50b1b6 | ||
|
|
db0c294e83 | ||
|
|
d8181e8e91 | ||
|
|
af134cf488 | ||
|
|
d918149c2f | ||
|
|
aa6bf6497f | ||
|
|
074c07b5ca | ||
|
|
f70f46e87e | ||
|
|
c21633655e | ||
|
|
1bafbb3864 | ||
|
|
353dccedc4 | ||
|
|
697f1870a0 | ||
|
|
a404c2e09e | ||
|
|
a6cfd124b0 | ||
|
|
4441b577c3 | ||
|
|
a704fbc3f3 | ||
|
|
dab221cdb9 | ||
|
|
4f434ec525 | ||
|
|
2585d4cd7d | ||
|
|
93a34b0f56 | ||
|
|
aa3a07209a | ||
|
|
c2332bc8d0 | ||
|
|
8f242e2875 | ||
|
|
51e9981bee | ||
|
|
65fbbcb3ff | ||
|
|
bd086a7e3c | ||
|
|
3554ba13db | ||
|
|
bcccebc9fa | ||
|
|
d67b46a9e2 | ||
|
|
20c6d33cf4 | ||
|
|
cf0e84c548 | ||
|
|
2332af9a08 | ||
|
|
024fc9fcf0 | ||
|
|
4220f4e095 | ||
|
|
a709acb149 | ||
|
|
466842acd8 | ||
|
|
b91f28f95b |
@@ -0,0 +1,242 @@
|
||||
name: Verify container
|
||||
description: Scan and validate a built image with the deployment Compose configuration
|
||||
inputs:
|
||||
image:
|
||||
description: Local image tag or immutable registry digest to validate
|
||||
required: true
|
||||
platform:
|
||||
description: Native runner image platform
|
||||
required: true
|
||||
artifact-name:
|
||||
description: Unique diagnostics artifact name within the workflow run
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Define isolated runtime values
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ inputs.image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
admin_password="$(openssl rand -hex 24)"
|
||||
postgres_password="$(openssl rand -hex 24)"
|
||||
redis_password="$(openssl rand -hex 24)"
|
||||
echo "::add-mask::$admin_password"
|
||||
echo "::add-mask::$postgres_password"
|
||||
echo "::add-mask::$redis_password"
|
||||
{
|
||||
echo "CPR_IMAGE=$IMAGE"
|
||||
echo "COMPOSE_PROJECT_NAME=cpr-check-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${RUNNER_ARCH,,}"
|
||||
echo "DIAGNOSTICS_DIR=$RUNNER_TEMP/codex-proxy-rs-diagnostics"
|
||||
echo "CI_ADMIN_PASSWORD=$admin_password"
|
||||
echo "CI_POSTGRES_PASSWORD=$postgres_password"
|
||||
echo "CI_REDIS_PASSWORD=$redis_password"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Scan container image
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
env:
|
||||
TRIVY_PLATFORM: ${{ inputs.platform }}
|
||||
TRIVY_QUIET: 'true'
|
||||
TRIVY_SKIP_VERSION_CHECK: 'true'
|
||||
with:
|
||||
image-ref: ${{ inputs.image }}
|
||||
version: v0.72.0
|
||||
format: json
|
||||
output: trivy-results.json
|
||||
severity: HIGH,CRITICAL
|
||||
vuln-type: os,library
|
||||
scanners: vuln
|
||||
ignore-unfixed: true
|
||||
exit-code: '0'
|
||||
|
||||
- name: Generate Trivy SARIF
|
||||
shell: bash
|
||||
run: trivy convert --format sarif --output trivy-results.sarif trivy-results.json
|
||||
|
||||
- name: Upload Trivy SARIF
|
||||
if: >-
|
||||
${{ always() && hashFiles('trivy-results.sarif') != '' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: runtime-container-${{ inputs.platform }}
|
||||
|
||||
- name: Enforce Trivy vulnerability gate
|
||||
shell: bash
|
||||
run: trivy convert --format table --severity HIGH,CRITICAL --exit-code 1 trivy-results.json
|
||||
|
||||
- name: Prepare isolated runtime
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 0077
|
||||
# Bind roots hand off ownership to the image-specific database users.
|
||||
# Sticky directories keep that initialization compatible with user-namespaced runners.
|
||||
install -d -m 1777 .runtime/postgres .runtime/redis
|
||||
mkdir -p "$DIAGNOSTICS_DIR"
|
||||
sudo install -d -m 0770 -o "$(id -u)" -g 10001 \
|
||||
.runtime/data .runtime/logs
|
||||
ruby <<'RUBY'
|
||||
path = "deploy/config.example.yaml"
|
||||
content = File.read(path)
|
||||
replacements = {
|
||||
"password: &postgres_password ''" => "password: &postgres_password '#{ENV.fetch("CI_POSTGRES_PASSWORD")}'",
|
||||
"password: &redis_password ''" => "password: &redis_password '#{ENV.fetch("CI_REDIS_PASSWORD")}'",
|
||||
"default_password: ''" => "default_password: '#{ENV.fetch("CI_ADMIN_PASSWORD")}'",
|
||||
}
|
||||
replacements.each do |placeholder, configured|
|
||||
raise "missing config placeholder: #{placeholder}" unless content.sub!(placeholder, configured)
|
||||
end
|
||||
File.write("deploy/config.yaml", content)
|
||||
RUBY
|
||||
sudo chown "$(id -u):10001" deploy/config.yaml
|
||||
chmod 0640 deploy/config.yaml
|
||||
|
||||
- name: Validate Docker Compose
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml config --quiet
|
||||
|
||||
- name: Configure Redis host memory overcommit
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo sysctl -w vm.overcommit_memory=1
|
||||
test "$(sysctl -n vm.overcommit_memory)" = 1
|
||||
|
||||
- name: Verify PostgreSQL and Redis volume persistence
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml)
|
||||
"${compose[@]}" up -d --no-build --wait postgres redis
|
||||
test "$("${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -At -U codex_proxy -d codex_proxy -c "SHOW TimeZone;" | tr -d '\r')" = Asia/Shanghai
|
||||
"${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \
|
||||
"CREATE TABLE cpr_ci_persistence_probe (value text PRIMARY KEY); INSERT INTO cpr_ci_persistence_probe VALUES ('persisted');"
|
||||
"${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \
|
||||
redis-cli SET cpr:ci:persistence-probe persisted
|
||||
"${compose[@]}" up -d --no-build --wait --force-recreate postgres redis
|
||||
test "$("${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -At -U codex_proxy -d codex_proxy -c \
|
||||
"SELECT value FROM cpr_ci_persistence_probe;")" = persisted
|
||||
test "$("${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \
|
||||
redis-cli --raw GET cpr:ci:persistence-probe | tr -d '\r')" = persisted
|
||||
"${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \
|
||||
"DROP TABLE cpr_ci_persistence_probe;"
|
||||
"${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \
|
||||
redis-cli DEL cpr:ci:persistence-probe
|
||||
|
||||
- name: Start and verify application
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml)
|
||||
"${compose[@]}" up -d --no-build --wait codex-proxy-rs
|
||||
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
|
||||
"http://127.0.0.1:8080/healthz")"
|
||||
test "$status" = 204
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs id -u | tr -d '\r')" = 10001
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs id -g | tr -d '\r')" = 10001
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs date +%z | tr -d '\r')" = +0800
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs cat /etc/timezone | tr -d '\r')" = Asia/Shanghai
|
||||
"${compose[@]}" exec -T codex-proxy-rs sh -c \
|
||||
'touch /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write && rm /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write'
|
||||
|
||||
- name: Verify clean logs and graceful shutdown
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml)
|
||||
"${compose[@]}" stop --timeout 30 codex-proxy-rs
|
||||
container_id="$("${compose[@]}" ps -a -q codex-proxy-rs)"
|
||||
test -n "$container_id"
|
||||
test "$(docker inspect --format '{{.State.ExitCode}}' "$container_id")" = 0
|
||||
"${compose[@]}" logs --no-color --timestamps codex-proxy-rs \
|
||||
> "$DIAGNOSTICS_DIR/application.log"
|
||||
"${compose[@]}" logs --no-color --timestamps postgres \
|
||||
> "$DIAGNOSTICS_DIR/postgres.log"
|
||||
"${compose[@]}" logs --no-color --timestamps redis \
|
||||
> "$DIAGNOSTICS_DIR/redis.log"
|
||||
if grep --extended-regexp --ignore-case \
|
||||
'"level"[[:space:]]*:[[:space:]]*"(warn|error|fatal|panic)"|(^|[^[:alnum:]_])panic([^[:alnum:]_]|$)' \
|
||||
"$DIAGNOSTICS_DIR/application.log" \
|
||||
|| grep --extended-regexp --ignore-case \
|
||||
'(^|[[:space:]])(warning|error|fatal|panic):' \
|
||||
"$DIAGNOSTICS_DIR/postgres.log" \
|
||||
|| grep --extended-regexp --ignore-case \
|
||||
'[0-9]+:[A-Z][[:space:]].*[[:space:]]#[[:space:]]|(^|[|[:space:]])(warning|error|fatal|panic)(:|[[:space:]])' \
|
||||
"$DIAGNOSTICS_DIR/redis.log"; then
|
||||
echo "container logs contain a warning or error severity" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Collect container diagnostics
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p "$DIAGNOSTICS_DIR"
|
||||
raw_dir="$(mktemp -d)"
|
||||
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml)
|
||||
"${compose[@]}" config > "$raw_dir/compose-config.yaml" 2>&1
|
||||
"${compose[@]}" ps -a > "$raw_dir/compose-ps.txt" 2>&1
|
||||
"${compose[@]}" logs --no-color --timestamps > "$raw_dir/compose.log" 2>&1
|
||||
mapfile -t container_ids < <("${compose[@]}" ps -a -q)
|
||||
if [ "${#container_ids[@]}" -gt 0 ]; then
|
||||
docker inspect "${container_ids[@]}" > "$raw_dir/docker-inspect.json" 2>&1
|
||||
fi
|
||||
RAW_DIR="$raw_dir" OUTPUT_DIR="$DIAGNOSTICS_DIR" ruby <<'RUBY'
|
||||
raw_dir = ENV.fetch("RAW_DIR")
|
||||
output_dir = ENV.fetch("OUTPUT_DIR")
|
||||
secrets = [
|
||||
ENV.fetch("CI_ADMIN_PASSWORD"),
|
||||
ENV.fetch("CI_POSTGRES_PASSWORD"),
|
||||
ENV.fetch("CI_REDIS_PASSWORD"),
|
||||
]
|
||||
encoded_secrets = secrets.map do |secret|
|
||||
secret.encode("UTF-8").bytes.map do |byte|
|
||||
character = byte.chr
|
||||
character.match?(/[A-Za-z0-9]/) ? character : format("%%%02X", byte)
|
||||
end.join
|
||||
end
|
||||
|
||||
Dir.children(raw_dir).each do |name|
|
||||
content = File.binread(File.join(raw_dir, name))
|
||||
(secrets + encoded_secrets).each do |secret|
|
||||
content = content.gsub(secret, "<redacted>")
|
||||
end
|
||||
File.binwrite(File.join(output_dir, name), content)
|
||||
end
|
||||
RUBY
|
||||
rm -rf "$raw_dir"
|
||||
cp trivy-results.json trivy-results.sarif "$DIAGNOSTICS_DIR/" 2>/dev/null || true
|
||||
|
||||
- name: Upload container diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ inputs.artifact-name }}
|
||||
path: ${{ runner.temp }}/codex-proxy-rs-diagnostics
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: Remove isolated runtime
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml down --remove-orphans || true
|
||||
sudo rm -rf "$GITHUB_WORKSPACE/.runtime" "$GITHUB_WORKSPACE/deploy/config.yaml"
|
||||
@@ -17,36 +17,44 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
COMPOSE_PROJECT_NAME: cpr-ci-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
CPR_IMAGE: codex-proxy-rs:ci-${{ inputs.ref }}
|
||||
TRIVY_QUIET: 'true'
|
||||
TRIVY_SKIP_VERSION_CHECK: 'true'
|
||||
steps:
|
||||
- name: Define isolated runtime values
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
admin_password="$(openssl rand -hex 24)"
|
||||
postgres_password="$(openssl rand -hex 24)"
|
||||
redis_password="$(openssl rand -hex 24)"
|
||||
echo "::add-mask::$admin_password"
|
||||
echo "::add-mask::$postgres_password"
|
||||
echo "::add-mask::$redis_password"
|
||||
{
|
||||
echo "DIAGNOSTICS_DIR=$RUNNER_TEMP/codex-proxy-rs-diagnostics"
|
||||
echo "CI_ADMIN_PASSWORD=$admin_password"
|
||||
echo "CI_POSTGRES_PASSWORD=$postgres_password"
|
||||
echo "CI_REDIS_PASSWORD=$redis_password"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Cache Cargo mounts
|
||||
id: cargo-cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ runner.temp }}/container-cargo
|
||||
key: container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/**/Cargo.toml', 'deploy/Dockerfile') }}-${{ inputs.ref }}
|
||||
restore-keys: |
|
||||
container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/**/Cargo.toml', 'deploy/Dockerfile') }}-
|
||||
container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
# The GHA layer exporter does not persist RUN --mount=type=cache contents.
|
||||
# Extraction runs before actions/cache saves its directories in post steps.
|
||||
- name: Restore Cargo mounts to BuildKit
|
||||
uses: reproducible-containers/buildkit-cache-dance@5422eac04292c961a382e0f584ea0f03ad9da723 # v3.4.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
scratch-dir: ${{ runner.temp }}/container-cache-dance
|
||||
cache-map: |
|
||||
{
|
||||
"${{ runner.temp }}/container-cargo/registry": "/usr/local/cargo/registry",
|
||||
"${{ runner.temp }}/container-cargo/git": "/usr/local/cargo/git",
|
||||
"${{ runner.temp }}/container-cargo/target": "/app/backend/target"
|
||||
}
|
||||
skip-extraction: ${{ steps.cargo-cache.outputs.cache-hit }}
|
||||
|
||||
- name: Build runtime image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
@@ -61,209 +69,9 @@ jobs:
|
||||
cache-from: type=gha,scope=runtime-ci
|
||||
cache-to: type=gha,mode=max,scope=runtime-ci
|
||||
|
||||
- name: Generate Trivy SARIF
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
- name: Verify runtime image
|
||||
uses: ./.github/actions/verify-container
|
||||
with:
|
||||
image-ref: ${{ env.CPR_IMAGE }}
|
||||
version: v0.72.0
|
||||
format: sarif
|
||||
output: trivy-results.sarif
|
||||
severity: HIGH,CRITICAL
|
||||
vuln-type: os,library
|
||||
scanners: vuln
|
||||
ignore-unfixed: true
|
||||
exit-code: '0'
|
||||
|
||||
- name: Upload Trivy SARIF
|
||||
if: >-
|
||||
${{ hashFiles('trivy-results.sarif') != '' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: runtime-container
|
||||
|
||||
- name: Enforce Trivy vulnerability gate
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: ${{ env.CPR_IMAGE }}
|
||||
version: v0.72.0
|
||||
format: table
|
||||
severity: HIGH,CRITICAL
|
||||
vuln-type: os,library
|
||||
scanners: vuln
|
||||
ignore-unfixed: true
|
||||
exit-code: '1'
|
||||
|
||||
- name: Prepare isolated runtime
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 0077
|
||||
# Bind roots hand off ownership to the image-specific database users.
|
||||
# Sticky directories keep that initialization compatible with user-namespaced runners.
|
||||
install -d -m 1777 .runtime/postgres .runtime/redis
|
||||
mkdir -p "$DIAGNOSTICS_DIR"
|
||||
sudo install -d -m 0770 -o "$(id -u)" -g 10001 \
|
||||
.runtime/data .runtime/logs
|
||||
ruby <<'RUBY'
|
||||
path = "deploy/config.example.yaml"
|
||||
content = File.read(path)
|
||||
replacements = {
|
||||
"password: &postgres_password ''" => "password: &postgres_password '#{ENV.fetch("CI_POSTGRES_PASSWORD")}'",
|
||||
"password: &redis_password ''" => "password: &redis_password '#{ENV.fetch("CI_REDIS_PASSWORD")}'",
|
||||
"default_password: ''" => "default_password: '#{ENV.fetch("CI_ADMIN_PASSWORD")}'",
|
||||
}
|
||||
replacements.each do |placeholder, configured|
|
||||
raise "missing config placeholder: #{placeholder}" unless content.sub!(placeholder, configured)
|
||||
end
|
||||
File.write("deploy/config.yaml", content)
|
||||
RUBY
|
||||
sudo chown "$(id -u):10001" deploy/config.yaml
|
||||
chmod 0640 deploy/config.yaml
|
||||
|
||||
- name: Validate Docker Compose
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml config --quiet
|
||||
|
||||
- name: Configure Redis host memory overcommit
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo sysctl -w vm.overcommit_memory=1
|
||||
test "$(sysctl -n vm.overcommit_memory)" = 1
|
||||
|
||||
- name: Verify PostgreSQL and Redis volume persistence
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml)
|
||||
"${compose[@]}" up -d --no-build --wait postgres redis
|
||||
test "$("${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -At -U codex_proxy -d codex_proxy -c "SHOW TimeZone;" | tr -d '\r')" = Asia/Shanghai
|
||||
"${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \
|
||||
"CREATE TABLE cpr_ci_persistence_probe (value text PRIMARY KEY); INSERT INTO cpr_ci_persistence_probe VALUES ('persisted');"
|
||||
"${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \
|
||||
redis-cli SET cpr:ci:persistence-probe persisted
|
||||
"${compose[@]}" up -d --no-build --wait --force-recreate postgres redis
|
||||
test "$("${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -At -U codex_proxy -d codex_proxy -c \
|
||||
"SELECT value FROM cpr_ci_persistence_probe;")" = persisted
|
||||
test "$("${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \
|
||||
redis-cli --raw GET cpr:ci:persistence-probe | tr -d '\r')" = persisted
|
||||
"${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \
|
||||
"DROP TABLE cpr_ci_persistence_probe;"
|
||||
"${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \
|
||||
redis-cli DEL cpr:ci:persistence-probe
|
||||
|
||||
- name: Start and verify application
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml)
|
||||
"${compose[@]}" up -d --no-build --wait codex-proxy-rs
|
||||
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
|
||||
"http://127.0.0.1:8080/healthz")"
|
||||
test "$status" = 204
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs id -u | tr -d '\r')" = 10001
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs id -g | tr -d '\r')" = 10001
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs date +%z | tr -d '\r')" = +0800
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs cat /etc/timezone | tr -d '\r')" = Asia/Shanghai
|
||||
"${compose[@]}" exec -T codex-proxy-rs sh -c \
|
||||
'touch /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write && rm /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write'
|
||||
|
||||
- name: Verify clean logs and graceful shutdown
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml)
|
||||
"${compose[@]}" stop --timeout 30 codex-proxy-rs
|
||||
container_id="$("${compose[@]}" ps -a -q codex-proxy-rs)"
|
||||
test -n "$container_id"
|
||||
test "$(docker inspect --format '{{.State.ExitCode}}' "$container_id")" = 0
|
||||
"${compose[@]}" logs --no-color --timestamps codex-proxy-rs \
|
||||
> "$DIAGNOSTICS_DIR/application.log"
|
||||
"${compose[@]}" logs --no-color --timestamps postgres \
|
||||
> "$DIAGNOSTICS_DIR/postgres.log"
|
||||
"${compose[@]}" logs --no-color --timestamps redis \
|
||||
> "$DIAGNOSTICS_DIR/redis.log"
|
||||
if grep --extended-regexp --ignore-case \
|
||||
'"level"[[:space:]]*:[[:space:]]*"(warn|error|fatal|panic)"|(^|[^[:alnum:]_])panic([^[:alnum:]_]|$)' \
|
||||
"$DIAGNOSTICS_DIR/application.log" \
|
||||
|| grep --extended-regexp --ignore-case \
|
||||
'(^|[[:space:]])(warning|error|fatal|panic):' \
|
||||
"$DIAGNOSTICS_DIR/postgres.log" \
|
||||
|| grep --extended-regexp --ignore-case \
|
||||
'[0-9]+:[A-Z][[:space:]].*[[:space:]]#[[:space:]]|(^|[|[:space:]])(warning|error|fatal|panic)(:|[[:space:]])' \
|
||||
"$DIAGNOSTICS_DIR/redis.log"; then
|
||||
echo "container logs contain a warning or error severity" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Collect container diagnostics
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p "$DIAGNOSTICS_DIR"
|
||||
raw_dir="$(mktemp -d)"
|
||||
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml)
|
||||
"${compose[@]}" config > "$raw_dir/compose-config.yaml" 2>&1
|
||||
"${compose[@]}" ps -a > "$raw_dir/compose-ps.txt" 2>&1
|
||||
"${compose[@]}" logs --no-color --timestamps > "$raw_dir/compose.log" 2>&1
|
||||
mapfile -t container_ids < <("${compose[@]}" ps -a -q)
|
||||
if [ "${#container_ids[@]}" -gt 0 ]; then
|
||||
docker inspect "${container_ids[@]}" > "$raw_dir/docker-inspect.json" 2>&1
|
||||
fi
|
||||
RAW_DIR="$raw_dir" OUTPUT_DIR="$DIAGNOSTICS_DIR" ruby <<'RUBY'
|
||||
raw_dir = ENV.fetch("RAW_DIR")
|
||||
output_dir = ENV.fetch("OUTPUT_DIR")
|
||||
secrets = [
|
||||
ENV.fetch("CI_ADMIN_PASSWORD"),
|
||||
ENV.fetch("CI_POSTGRES_PASSWORD"),
|
||||
ENV.fetch("CI_REDIS_PASSWORD"),
|
||||
]
|
||||
encoded_secrets = secrets.map do |secret|
|
||||
secret.encode("UTF-8").bytes.map do |byte|
|
||||
character = byte.chr
|
||||
character.match?(/[A-Za-z0-9]/) ? character : format("%%%02X", byte)
|
||||
end.join
|
||||
end
|
||||
|
||||
Dir.children(raw_dir).each do |name|
|
||||
content = File.binread(File.join(raw_dir, name))
|
||||
(secrets + encoded_secrets).each do |secret|
|
||||
content = content.gsub(secret, "<redacted>")
|
||||
end
|
||||
File.binwrite(File.join(output_dir, name), content)
|
||||
end
|
||||
RUBY
|
||||
rm -rf "$raw_dir"
|
||||
cp trivy-results.sarif "$DIAGNOSTICS_DIR/" 2>/dev/null || true
|
||||
|
||||
- name: Upload container diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/codex-proxy-rs-diagnostics
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: Remove isolated runtime
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml down --remove-orphans || true
|
||||
sudo rm -rf "$GITHUB_WORKSPACE/.runtime" "$GITHUB_WORKSPACE/deploy/config.yaml"
|
||||
image: ${{ env.CPR_IMAGE }}
|
||||
platform: linux/amd64
|
||||
artifact-name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}-amd64
|
||||
|
||||
@@ -33,6 +33,8 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Rust 1.97 编译 AWS SDK 调试信息需要更大的 codegen 线程栈。
|
||||
RUST_MIN_STACK: 16777216
|
||||
CPR_TEST_DATABASE_URL: postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy
|
||||
CPR_TEST_REDIS_URL: redis://127.0.0.1:6379
|
||||
services:
|
||||
|
||||
@@ -50,6 +50,7 @@ jobs:
|
||||
- 'deploy/**'
|
||||
- '.dockerignore'
|
||||
- 'release/version.yaml'
|
||||
- '.github/actions/**'
|
||||
- '.github/workflows/_container.yml'
|
||||
- '.github/workflows/ci.yml'
|
||||
|
||||
|
||||
@@ -163,15 +163,6 @@ jobs:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
upload_frontend: true
|
||||
|
||||
container:
|
||||
needs: [release-info]
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
uses: ./.github/workflows/_container.yml
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
build-docker-binary:
|
||||
name: build-docker-binary (${{ matrix.os }}-${{ matrix.arch }})
|
||||
needs: release-info
|
||||
@@ -194,8 +185,9 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
backend/target
|
||||
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
|
||||
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}-${{ needs.release-info.outputs.git_sha }}
|
||||
restore-keys: |
|
||||
cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
|
||||
cargo-v2-${{ runner.os }}-${{ matrix.target }}-
|
||||
|
||||
- name: Build binary
|
||||
@@ -259,8 +251,9 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
backend/target
|
||||
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
|
||||
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}-${{ needs.release-info.outputs.git_sha }}
|
||||
restore-keys: |
|
||||
cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
|
||||
cargo-v2-${{ runner.os }}-${{ matrix.target }}-
|
||||
|
||||
- name: Build binary
|
||||
@@ -408,6 +401,7 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
security-events: write
|
||||
env:
|
||||
TRIVY_QUIET: 'true'
|
||||
TRIVY_SKIP_VERSION_CHECK: 'true'
|
||||
@@ -491,22 +485,34 @@ jobs:
|
||||
org.opencontainers.image.source=https://github.com/zyycn/codex-proxy-rs
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ needs.release-info.outputs.git_sha }}-${{ matrix.arch }}
|
||||
|
||||
- name: Scan exact app image digest
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
env:
|
||||
TRIVY_PLATFORM: ${{ matrix.dockerPlatform }}
|
||||
- name: Verify exact app image digest
|
||||
uses: ./.github/actions/verify-container
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
|
||||
version: v0.72.0
|
||||
format: table
|
||||
severity: HIGH,CRITICAL
|
||||
vuln-type: os,library
|
||||
scanners: vuln
|
||||
ignore-unfixed: true
|
||||
exit-code: '1'
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
|
||||
platform: ${{ matrix.dockerPlatform }}
|
||||
artifact-name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.arch }}
|
||||
|
||||
- name: Record verified image digest
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
IMAGE_ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
|
||||
mkdir -p dist/digests
|
||||
printf '%s\n' "$IMAGE_DIGEST" > "dist/digests/linux-${IMAGE_ARCH}.txt"
|
||||
|
||||
- name: Upload verified image digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: image-digest-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: dist/digests/*.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
publish:
|
||||
needs: [release-info, package-assets, build-docker-image, container]
|
||||
needs: [release-info, package-assets, build-docker-image]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
@@ -527,6 +533,13 @@ jobs:
|
||||
name: release-assets
|
||||
path: dist/release
|
||||
|
||||
- name: Download verified image digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: image-digest-*
|
||||
path: dist/digests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
@@ -558,7 +571,12 @@ jobs:
|
||||
platform="${platform//[[:space:]]/}"
|
||||
[ -n "${platform}" ] || continue
|
||||
arch="${platform##*/}"
|
||||
sources+=("${image}:sha-${GIT_SHA}-${arch}")
|
||||
digest="$(cat "dist/digests/linux-${arch}.txt")"
|
||||
if [[ ! "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]; then
|
||||
echo "invalid verified image digest for ${platform}" >&2
|
||||
exit 1
|
||||
fi
|
||||
sources+=("${image}@${digest}")
|
||||
done
|
||||
|
||||
if [ "${#sources[@]}" -eq 0 ]; then
|
||||
@@ -626,7 +644,7 @@ jobs:
|
||||
cosign sign --yes "${images[@]}"
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||
with:
|
||||
tag_name: ${{ needs.release-info.outputs.tag }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -5,192 +5,129 @@
|
||||
|
||||
# Codex Proxy RS
|
||||
|
||||
**基于 Rust 的多 Provider 透明 AI 网关**
|
||||
面向 Codex 的自托管多账号 AI 网关。
|
||||
|
||||
[](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml)
|
||||
[](https://github.com/zyycn/codex-proxy-rs/releases)
|
||||
[](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
|
||||
[](https://opensource.org/license/mit)
|
||||
[](LICENSE)
|
||||
|
||||
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [运行维护](#运行维护) · [部署](deploy/README.md) · [接口](docs/api.md) · [架构](docs/architecture.md)
|
||||
[功能](#功能) · [快速开始](#快速开始) · [客户端接入](#客户端接入) · [文档](#文档) · [社区](#社区) · [许可证](#许可证)
|
||||
|
||||
</div>
|
||||
|
||||
> [!NOTE]
|
||||
> 数据面支持 OpenAI Responses、Images 和模型目录协议,不提供 `/v1/chat/completions`。
|
||||
将 OpenAI、xAI 账号接入同一个服务,通过网页管理账号、分配客户端密钥和查看使用情况。
|
||||
客户端使用统一的访问地址与代理密钥,上游账号凭据由服务端管理。
|
||||
|
||||
## 功能
|
||||
|
||||
| 领域 | 当前能力 |
|
||||
| --- | --- |
|
||||
| 数据面 | Responses JSON、SSE、WebSocket(含 Codex 子代理语义)、Images 生成/编辑与模型目录 |
|
||||
| Provider | 固定 `openai`、`xai` 两个 Provider,各自拥有 OAuth、credential、额度、目录与 transport |
|
||||
| 路由 | Client Key 限定账号分组;按模型能力编译 Provider 候选;支持全局精确模型映射、会话亲和和安全 fallback |
|
||||
| 透明边界 | OpenAI 保留未知请求字段及字段顺序,并原样转发 SSE、WebSocket 与 Images 业务字节;xAI 在 Provider 内转换协议 |
|
||||
| 账号 | OAuth、OpenAI AT/RT 与账号文件导入,credential/额度刷新、账号恢复及主动额度重置卡 |
|
||||
| 管理 | 账号与分组、Client Key、运行设置、用量/错误观测、S3/R2 备份、系统更新与回滚 |
|
||||
| 计量 | Token、图片、费用、延迟、账号与 Provider 归因,并记录上游响应确认的实际 `service_tier` |
|
||||
- **账号管理**:OAuth 授权、账号导入、凭据刷新与额度查看;支持独立出站代理及 sub2api OpenAI 账号代理导入。
|
||||
- **访问控制**:通过账号分组分配密钥权限,设置日/周 USD 限额、并发和请求频率。
|
||||
- **客户端接入**:支持 Codex CLI、桌面端与 Responses API 客户端,提供 HTTP 流式响应和 WebSocket。
|
||||
- **请求诊断**:查看请求记录、用量、费用、延迟和错误详情。
|
||||
- **部署运维**:Docker Compose 部署、S3/R2 数据库备份、同大版本在线更新与回滚。
|
||||
|
||||
架构边界、状态所有权和请求不变量见 [架构文档](docs/architecture.md)。
|
||||
图片生成、编辑和 Codex 独立搜索由 OpenAI 账号提供;模型与功能的可用性取决于上游账号权限。
|
||||
|
||||
> [!NOTE]
|
||||
> 本项目提供 Responses API,不支持 `/v1/chat/completions`。接入前请确认客户端支持 Responses 协议。
|
||||
|
||||
## 快速开始
|
||||
|
||||
需要 Docker Engine 与 Docker Compose Plugin。
|
||||
使用 Docker Compose 部署发布镜像 `ghcr.io/zyycn/codex-proxy-rs:latest`,同时启动 PostgreSQL 和 Redis。
|
||||
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
|
||||
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置。
|
||||
|
||||
### 1. 准备配置和目录
|
||||
### 1. 下载部署文件并配置
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zyycn/codex-proxy-rs.git
|
||||
mkdir -p codex-proxy-rs/deploy
|
||||
cd codex-proxy-rs
|
||||
|
||||
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/compose.yaml \
|
||||
-o deploy/compose.yaml
|
||||
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/config.example.yaml \
|
||||
-o deploy/config.example.yaml
|
||||
|
||||
mkdir -p .runtime/data .runtime/logs
|
||||
install -d -m 0750 .runtime/postgres .runtime/redis
|
||||
cp deploy/config.example.yaml deploy/config.yaml
|
||||
sudo chown "$(id -u):10001" deploy/config.yaml
|
||||
chmod 0640 deploy/config.yaml
|
||||
```
|
||||
|
||||
分别生成 PostgreSQL 与 Redis 密码:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 24
|
||||
openssl rand -hex 24
|
||||
```
|
||||
|
||||
把两个结果分别写入 `deploy/config.yaml`,再设置管理员初始密码:
|
||||
|
||||
| 配置 | 约束 |
|
||||
| --- | --- |
|
||||
| `store.database.password` | 48 位十六进制 |
|
||||
| `store.redis.password` | 48 位十六进制 |
|
||||
| `admin.default_password` | 至少 12 位,不能包含 `$` |
|
||||
|
||||
PostgreSQL、Redis 和应用从同一份 `config.yaml` 读取基础设施密码,不需要额外导出环境变量。
|
||||
管理员密码只在首次创建管理员时生效。
|
||||
|
||||
Linux 需要允许容器组写入运行目录:
|
||||
|
||||
```bash
|
||||
sudo chown -R "$(id -u):10001" .runtime/data .runtime/logs
|
||||
chmod 0770 .runtime/data .runtime/logs
|
||||
```
|
||||
|
||||
### 2. 启动
|
||||
分别生成数据库和 Redis 密码:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 24
|
||||
openssl rand -hex 24
|
||||
```
|
||||
|
||||
编辑 `deploy/config.yaml`,填好以下三项:
|
||||
|
||||
| 配置项 | 填写内容 |
|
||||
| --- | --- |
|
||||
| `store.database.password` | 第一个生成的 48 位十六进制密码 |
|
||||
| `store.redis.password` | 第二个生成的 48 位十六进制密码 |
|
||||
| `admin.default_password` | 管理员初始密码,至少 12 位,不能包含 `$` |
|
||||
|
||||
不需要创建 `.env`。管理员初始密码只在首次创建账号时使用。
|
||||
|
||||
### 2. 启动服务
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/compose.yaml config --quiet
|
||||
docker compose -f deploy/compose.yaml pull
|
||||
docker compose -f deploy/compose.yaml up -d --no-build
|
||||
docker compose -f deploy/compose.yaml up -d --no-build --wait
|
||||
curl -i http://127.0.0.1:8080/healthz
|
||||
```
|
||||
|
||||
`204 No Content` 表示应用、PostgreSQL 与 Redis 均可用。管理端地址为
|
||||
`http://127.0.0.1:8080`。
|
||||
健康检查返回 `204 No Content` 后,打开 `http://127.0.0.1:8080`,
|
||||
使用 `admin@cpr.local` 和刚设置的管理员密码登录。
|
||||
|
||||
### 3. 初始化网关
|
||||
默认地址只能在服务器本机访问。从其他设备使用时,需要配置
|
||||
[HTTPS 反向代理](deploy/README.md#公网访问)。
|
||||
|
||||
1. 使用 `admin@cpr.local` 与初始密码登录。
|
||||
2. 导入或授权 Provider 账号:OpenAI 支持 OAuth、逐行 AT/RT 与 OAuth JSON;
|
||||
xAI 支持 OAuth 和账号 JSON。
|
||||
3. 创建账号分组并按需配置全局模型映射。未命中的模型名原样交给候选 Provider。
|
||||
4. 创建 `sk_...` Client Key,选择可用账号分组,并设置速率和并发限制;空分组范围表示全部账号。
|
||||
### 3. 添加账号与客户端密钥
|
||||
|
||||
OpenAI RT 导入会先向官方 token endpoint 换取 AT;仅 AT 的账号不具备自动续期能力,
|
||||
能否参与调度仍取决于 token 中可验证的身份与后续上游事实。账号导入和首次 OAuth 创建会尽力读取一次额度,
|
||||
额度上游暂时失败不会回滚已提交的账号。
|
||||
|
||||
OpenAI 账号的主动重置卡只在用户打开相应弹窗或手工刷新时查询。消费成功后管理端重新查询卡片与额度;
|
||||
系统不会通过修改本地 `reset_at` 伪造重置。完整账号合同见 [账号接口](docs/api.md#5-账号)。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> xAI 使用 OAuth session,不支持把 xAI API Key 作为上游 credential。
|
||||
|
||||
完整的权限、密码轮换、备份和升级规则见 [部署文档](deploy/README.md)。
|
||||
1. 在「账号」中添加 OpenAI 或 xAI 账号,完成授权或导入。
|
||||
2. 按需建立账号分组,再创建客户端密钥并选择可用分组。**不选分组表示可使用全部账号**。
|
||||
3. 打开密钥的「使用密钥」,复制客户端配置。
|
||||
|
||||
## 客户端接入
|
||||
|
||||
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入。
|
||||
合并到客户端配置后重启 Codex。完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)。
|
||||
|
||||
**其他 Responses API 客户端**:填写以下信息。
|
||||
|
||||
| 配置 | 值 |
|
||||
| --- | --- |
|
||||
| Base URL | `http://127.0.0.1:8080/v1` |
|
||||
| API Key | 管理端创建的 `sk_...` |
|
||||
| 鉴权 | `Authorization: Bearer <client-api-key>` |
|
||||
| Base URL | `http://127.0.0.1:8080/v1`;远程接入使用服务器的 HTTPS 地址 |
|
||||
| API Key | 管理端创建的 `sk_...` 客户端密钥 |
|
||||
|
||||
可用模型以该密钥查询到的模型列表为准:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8080/v1/models \
|
||||
-H 'Authorization: Bearer <client-api-key>'
|
||||
|
||||
curl http://127.0.0.1:8080/v1/responses \
|
||||
-H 'Authorization: Bearer <client-api-key>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "<model-id>",
|
||||
"input": "Reply with pong.",
|
||||
"stream": false
|
||||
}'
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>公开数据面路由</summary>
|
||||
|
||||
| 路由 | 用途 |
|
||||
| --- | --- |
|
||||
| `POST /v1/responses` | JSON 响应或 SSE Responses 流 |
|
||||
| `GET /v1/responses` | Responses WebSocket 升级 |
|
||||
| `POST /v1/images/generations` | OpenAI Images 生成 JSON 透明转发 |
|
||||
| `POST /v1/images/edits` | OpenAI Images 编辑 JSON 透明转发 |
|
||||
| `GET /v1/models` | 当前 Client Key 账号范围内的模型列表 |
|
||||
| `GET /v1/models/{model_id}` | OpenAI 兼容模型详情 |
|
||||
|
||||
所有 `/v1/*` 路由都需要 Client Key。Images 是 OpenAI Provider 自有端点,不读取模型字段,也不参与
|
||||
文本模型映射。
|
||||
|
||||
</details>
|
||||
|
||||
完整的客户端与管理端路由、鉴权、请求字段和 mutation 语义见 [接口文档](docs/api.md)。
|
||||
|
||||
## 运行维护
|
||||
|
||||
```bash
|
||||
# 同一大版本内升级发布镜像
|
||||
docker compose -f deploy/compose.yaml pull codex-proxy-rs
|
||||
docker compose -f deploy/compose.yaml up -d --no-build
|
||||
|
||||
# 查看日志
|
||||
docker compose -f deploy/compose.yaml logs -f codex-proxy-rs
|
||||
|
||||
# 从当前源码构建
|
||||
docker compose -f deploy/compose.yaml build codex-proxy-rs
|
||||
docker compose -f deploy/compose.yaml up -d
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `.runtime/` 保存 PostgreSQL、Redis、日志、OpenAI 会话锚点和在线更新状态。删除该目录会永久清除
|
||||
> 本地运行状态。
|
||||
|
||||
> [!WARNING]
|
||||
> 不支持跨大版本在线升级。升级到新的大版本时请使用全新的 `.runtime/` 数据目录,并重新导入或授权
|
||||
> Provider 账号与 Client Key。
|
||||
|
||||
Compose 默认只绑定 `127.0.0.1`。公网接入应使用 HTTPS 反向代理,转发 WebSocket upgrade 与真实客户端
|
||||
IP;不要暴露 PostgreSQL 或 Redis。当前运行拓扑为单副本,不能通过复制应用容器扩容。
|
||||
|
||||
## 维护与发布
|
||||
|
||||
仓库版本只能从干净、已同步上游的发布分支发布:
|
||||
|
||||
```bash
|
||||
release/publish <version>
|
||||
```
|
||||
|
||||
脚本依赖已登录的 GitHub CLI(`gh auth login`),会更新 `release/version.yaml`、创建约定提交和带注释的
|
||||
`v<version>` tag,原子推送分支与 tag,再触发 Release 构建。源码提交、仓库发版和运行实例升级是三个
|
||||
独立状态;脚本不会登录服务器或改变任何运行实例。
|
||||
|
||||
## 文档
|
||||
|
||||
- [部署与运维](deploy/README.md)
|
||||
- [客户端与管理端接口](docs/api.md)
|
||||
- [客户端接入与生图](deploy/README.md#客户端配置)
|
||||
- [部署、备份与恢复](deploy/README.md)
|
||||
- [API 参考](docs/api.md)
|
||||
- [系统架构](docs/architecture.md)
|
||||
- [数据库迁移规则](backend/migrations/README.md)
|
||||
- [Release](https://github.com/zyycn/codex-proxy-rs/releases)
|
||||
- [容器镜像](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
|
||||
- [管理端主题](docs/theme.md)
|
||||
- [数据库迁移](backend/migrations/README.md)
|
||||
|
||||
## 社区
|
||||
|
||||
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 [Apache License 2.0](LICENSE) 开源。
|
||||
|
||||
Generated
+62
-54
@@ -154,9 +154,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.17.1"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted 0.7.1",
|
||||
@@ -165,9 +165,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.42.0"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444"
|
||||
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
@@ -195,7 +195,7 @@ dependencies = [
|
||||
"bytes-utils",
|
||||
"fastrand",
|
||||
"http 0.2.12",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"http-body 0.4.6",
|
||||
"http-body 1.0.1",
|
||||
"percent-encoding",
|
||||
@@ -231,7 +231,7 @@ dependencies = [
|
||||
"hex",
|
||||
"hmac",
|
||||
"http 0.2.12",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"lru",
|
||||
"percent-encoding",
|
||||
@@ -257,7 +257,7 @@ dependencies = [
|
||||
"hex",
|
||||
"hmac",
|
||||
"http 0.2.12",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"percent-encoding",
|
||||
"sha2 0.11.0",
|
||||
"time",
|
||||
@@ -286,7 +286,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"crc-fast",
|
||||
"hex",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"md-5",
|
||||
@@ -320,7 +320,7 @@ dependencies = [
|
||||
"bytes-utils",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"percent-encoding",
|
||||
@@ -339,7 +339,7 @@ dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"h2",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
@@ -389,7 +389,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"http 0.2.12",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"http-body 0.4.6",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
@@ -401,16 +401,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime-api"
|
||||
version = "1.15.0"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "954c563ce84507722d2679f07a35d21b9c6466b3872d513020d0281fc8112ac9"
|
||||
checksum = "9c054752dd9e4dc73d0b75748c99ac2d0feafbf2f25c7b0516f03a3534161223"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api-macros",
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
"http 0.2.12",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -436,21 +436,21 @@ checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-types"
|
||||
version = "1.6.2"
|
||||
version = "1.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fce83ce9abbb198d25bc7131e468d0f9fe1257125e58c39f3f9fc9f5098c9647"
|
||||
checksum = "8f94d16e797ec62cd999fc9d5942b48fa7050c3093ddadff48e4d7528d16fcb9"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
"bytes-utils",
|
||||
"futures-core",
|
||||
"http 0.2.12",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"http-body 0.4.6",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
@@ -503,7 +503,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
@@ -535,7 +535,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
@@ -1191,7 +1191,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1258,9 +1258,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
@@ -1467,7 +1467,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"tower",
|
||||
"tower-http 0.7.0",
|
||||
"tower-http 0.7.1",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
@@ -1478,14 +1478,17 @@ name = "gateway-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"futures",
|
||||
"futures-timer",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -1533,6 +1536,7 @@ dependencies = [
|
||||
name = "gateway-store"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"aws-credential-types",
|
||||
"aws-sdk-s3",
|
||||
@@ -1638,7 +1642,7 @@ dependencies = [
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"indexmap",
|
||||
"slab",
|
||||
"tokio",
|
||||
@@ -1686,7 +1690,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"headers-core",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"sha1 0.10.7",
|
||||
@@ -1698,7 +1702,7 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
|
||||
dependencies = [
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1760,9 +1764,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.2"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
@@ -1786,7 +1790,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1797,7 +1801,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"pin-project-lite",
|
||||
]
|
||||
@@ -1833,16 +1837,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.1"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||
checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
@@ -1859,7 +1863,7 @@ version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
@@ -1874,14 +1878,13 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
source = "git+https://github.com/hyperium/hyper-util?rev=191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6#191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
@@ -2210,9 +2213,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
@@ -2593,6 +2596,7 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"aws-lc-rs",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
@@ -2604,7 +2608,9 @@ dependencies = [
|
||||
"gateway-protocol",
|
||||
"getrandom 0.4.3",
|
||||
"hex",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"reqwest",
|
||||
"roxmltree",
|
||||
"rustls",
|
||||
@@ -2619,8 +2625,10 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-tungstenite 0.28.0",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tungstenite 0.27.0",
|
||||
@@ -2729,7 +2737,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2910,7 +2918,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
@@ -2999,7 +3007,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3701,7 +3709,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3911,7 +3919,7 @@ dependencies = [
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
@@ -3922,15 +3930,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233"
|
||||
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
@@ -4062,7 +4070,7 @@ dependencies = [
|
||||
"data-encoding",
|
||||
"flate2",
|
||||
"headers",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.4",
|
||||
@@ -4081,7 +4089,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.4",
|
||||
@@ -4331,9 +4339,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.8"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||
checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -4535,7 +4543,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"deadpool",
|
||||
"futures",
|
||||
"http 1.4.2",
|
||||
"http 1.4.0",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
|
||||
+5
-1
@@ -15,7 +15,7 @@ resolver = "3"
|
||||
[workspace.package]
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
license = "Apache-2.0"
|
||||
publish = false
|
||||
rust-version = "1.97"
|
||||
|
||||
@@ -38,3 +38,7 @@ strip = "symbols"
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[patch.crates-io]
|
||||
# Upstream SOCKS IPv6 fix; remove once a release includes hyperium/hyper-util#302.
|
||||
hyper-util = { git = "https://github.com/hyperium/hyper-util", rev = "191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6" }
|
||||
|
||||
@@ -26,7 +26,7 @@ tokio = { version = "1.52.3", default-features = false, features = ["rt-multi-th
|
||||
[dev-dependencies]
|
||||
config = { version = "0.15.23", default-features = false, features = ["yaml"] }
|
||||
serde_json = "1"
|
||||
syn = { version = "3.0.3", features = ["full"] }
|
||||
syn = { version = "3.0.3", features = ["full", "visit"] }
|
||||
tempfile = "3.27.0"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -87,6 +87,7 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
|
||||
let mut probes = store.health_probes();
|
||||
probes.extend(core.health_probes());
|
||||
probes.push(host.logging_health_probe());
|
||||
let api = gateway_api::initialize(
|
||||
api,
|
||||
core.execution_service(),
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::{
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use syn::Item;
|
||||
use syn::{Item, visit::Visit};
|
||||
|
||||
/// workspace 成员冻结清单;新增 crate 必须同步扩展本文件的依赖规则。
|
||||
pub(super) const WORKSPACE_MEMBERS: &[&str] = &[
|
||||
@@ -61,6 +61,82 @@ fn gateway_core_depends_on_no_http_db_redis_or_provider_crate() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_value_owners_do_not_depend_on_execution_or_routing() {
|
||||
let root = backend_root().join("crates/gateway-core/src");
|
||||
for relative in super::rust_files(&root) {
|
||||
let allowed: Option<&[&str]> = match relative.to_str().expect("source path") {
|
||||
"validation.rs" => Some(&[]),
|
||||
"identity.rs" => Some(&["validation"]),
|
||||
"upstream.rs" => Some(&["validation"]),
|
||||
"event.rs" => Some(&["metering", "operation", "upstream", "validation"]),
|
||||
"account/store.rs" => Some(&["account", "error", "identity", "validation"]),
|
||||
path if path.starts_with("policy/") => Some(&["account", "policy", "validation"]),
|
||||
path if path.starts_with("account/") => Some(&["account", "identity", "validation"]),
|
||||
_ => None,
|
||||
};
|
||||
let Some(allowed) = allowed else { continue };
|
||||
let source = fs::read_to_string(root.join(&relative)).expect("read core source");
|
||||
let syntax = syn::parse_file(&source).expect("parse core source");
|
||||
let mut references = CrateReferences::default();
|
||||
references.visit_file(&syntax);
|
||||
for dependency in references.0 {
|
||||
assert!(
|
||||
allowed.contains(&dependency.as_str()),
|
||||
"{} must not depend on crate::{dependency}",
|
||||
relative.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CrateReferences(BTreeSet<String>);
|
||||
|
||||
impl<'ast> Visit<'ast> for CrateReferences {
|
||||
fn visit_path(&mut self, path: &'ast syn::Path) {
|
||||
let mut segments = path.segments.iter();
|
||||
if segments
|
||||
.next()
|
||||
.is_some_and(|segment| segment.ident == "crate")
|
||||
&& let Some(owner) = segments.next()
|
||||
{
|
||||
self.0.insert(owner.ident.to_string());
|
||||
}
|
||||
syn::visit::visit_path(self, path);
|
||||
}
|
||||
|
||||
fn visit_item_use(&mut self, item: &'ast syn::ItemUse) {
|
||||
self.use_tree(&item.tree, false);
|
||||
}
|
||||
}
|
||||
|
||||
impl CrateReferences {
|
||||
fn use_tree(&mut self, tree: &syn::UseTree, crate_root: bool) {
|
||||
match tree {
|
||||
syn::UseTree::Path(path) if crate_root => {
|
||||
self.0.insert(path.ident.to_string());
|
||||
}
|
||||
syn::UseTree::Name(name) if crate_root => {
|
||||
self.0.insert(name.ident.to_string());
|
||||
}
|
||||
syn::UseTree::Rename(rename) if crate_root => {
|
||||
self.0.insert(rename.ident.to_string());
|
||||
}
|
||||
syn::UseTree::Path(path) => self.use_tree(&path.tree, path.ident == "crate"),
|
||||
syn::UseTree::Group(group) => {
|
||||
for item in &group.items {
|
||||
self.use_tree(item, crate_root);
|
||||
}
|
||||
}
|
||||
syn::UseTree::Glob(_) if crate_root => {
|
||||
self.0.insert("*".to_owned());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_protocol_has_no_workspace_dependencies() {
|
||||
for name in dependency_names("crates/gateway-protocol") {
|
||||
@@ -156,6 +232,46 @@ const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
|
||||
#[test]
|
||||
fn workspace_internal_dependency_edges_match_frozen_dag() {
|
||||
let metadata = cargo_metadata_json();
|
||||
let actual = workspace_runtime_dependency_edges(&metadata);
|
||||
let mut expected: Vec<(String, String)> = ALLOWED_INTERNAL_EDGES
|
||||
.iter()
|
||||
.map(|(from, to)| ((*from).to_owned(), (*to).to_owned()))
|
||||
.collect();
|
||||
expected.sort();
|
||||
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"workspace internal dependency edges diverged from the frozen DAG"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_dependency_edges_exclude_dev_build_and_external_dependencies() {
|
||||
let metadata = serde_json::json!({
|
||||
"packages": [
|
||||
{
|
||||
"name": "gateway-api",
|
||||
"dependencies": [
|
||||
{ "name": "gateway-core", "kind": null },
|
||||
{ "name": "gateway-store", "kind": "dev" },
|
||||
{ "name": "gateway-host", "kind": "build" },
|
||||
{ "name": "serde", "kind": null }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "external-package",
|
||||
"dependencies": [{ "name": "gateway-core", "kind": null }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
workspace_runtime_dependency_edges(&metadata),
|
||||
vec![("gateway-api".to_owned(), "gateway-core".to_owned())]
|
||||
);
|
||||
}
|
||||
|
||||
fn workspace_runtime_dependency_edges(metadata: &serde_json::Value) -> Vec<(String, String)> {
|
||||
let packages = metadata["packages"]
|
||||
.as_array()
|
||||
.expect("cargo metadata packages");
|
||||
@@ -173,31 +289,14 @@ fn workspace_internal_dependency_edges_match_frozen_dag() {
|
||||
if !internal_names.contains(dep_name) {
|
||||
continue;
|
||||
}
|
||||
let is_runtime_edge = dependency["dep_kinds"]
|
||||
.as_array()
|
||||
.map(|kinds| {
|
||||
kinds
|
||||
.iter()
|
||||
.any(|kind| kind["kind"].as_str() == Some("normal"))
|
||||
})
|
||||
.unwrap_or(true);
|
||||
if is_runtime_edge {
|
||||
if dependency.get("kind").expect("dependency kind").is_null() {
|
||||
actual.push((name.to_owned(), dep_name.to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut expected: Vec<(String, String)> = ALLOWED_INTERNAL_EDGES
|
||||
.iter()
|
||||
.map(|(from, to)| ((*from).to_owned(), (*to).to_owned()))
|
||||
.collect();
|
||||
actual.sort();
|
||||
expected.sort();
|
||||
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"workspace internal dependency edges diverged from the frozen DAG"
|
||||
);
|
||||
actual
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -143,10 +143,7 @@ fn config_loader_should_reject_removed_fingerprint_section() {
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_codex_cli_version() {
|
||||
assert_rejected(valid_config().replace(
|
||||
"codex_version: '0.151.0-alpha.7.2'",
|
||||
"codex_version: 'latest'",
|
||||
));
|
||||
assert_rejected(valid_config().replace("codex_version: '0.153.4'", "codex_version: 'latest'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -165,7 +162,7 @@ fn config_loader_should_reject_zero_server_port() {
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_desktop_profile_fields() {
|
||||
assert_rejected(valid_config().replace("desktop_build: '7377'", "desktop_build: 'build'"));
|
||||
assert_rejected(valid_config().replace("desktop_build: '8109'", "desktop_build: 'build'"));
|
||||
}
|
||||
|
||||
fn assert_rejected(config: String) {
|
||||
|
||||
@@ -22,6 +22,7 @@ gateway-core = { path = "../gateway-core" }
|
||||
hex = "0.4.3"
|
||||
rand_core = { version = "0.6.4", features = ["getrandom"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
|
||||
secrecy = { version = "0.10.3", features = ["serde"] }
|
||||
subtle = "2.6.1"
|
||||
thiserror = "2.0.18"
|
||||
@@ -32,7 +33,6 @@ chrono-tz = "0.10"
|
||||
tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
|
||||
sha2 = "0.11"
|
||||
tempfile = "3"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt"] }
|
||||
|
||||
@@ -304,6 +304,7 @@ pub async fn initialize(
|
||||
client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)),
|
||||
observability: Arc::new(DefaultObservabilityService::new(
|
||||
store.observability(),
|
||||
store.accounts(),
|
||||
store.settings(),
|
||||
registry,
|
||||
)),
|
||||
|
||||
@@ -18,6 +18,15 @@ pub use gateway_core::account::{
|
||||
QuotaState, resolve_account_status,
|
||||
};
|
||||
|
||||
/// 导入时统一应用的账号调度与分组设置;缺省时保留原有导入语义。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountImportSettings {
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
}
|
||||
|
||||
/// 账号列表排序字段。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AccountSortField {
|
||||
@@ -89,6 +98,7 @@ pub struct AccountRecord {
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub credential_state: CredentialState,
|
||||
pub credential_observed_at: DateTime<Utc>,
|
||||
pub quota: QuotaState,
|
||||
@@ -211,6 +221,7 @@ pub struct UpdateAccount {
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
pub outbound_proxy: Option<Option<gateway_core::account::OutboundProxy>>,
|
||||
}
|
||||
|
||||
/// 账号更新结果。
|
||||
@@ -228,6 +239,7 @@ pub struct BatchUpdateAccounts {
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
pub outbound_proxy: Option<Option<gateway_core::account::OutboundProxy>>,
|
||||
}
|
||||
|
||||
/// 批量账号更新结果。
|
||||
|
||||
@@ -4,6 +4,12 @@ use chrono::{DateTime, Utc};
|
||||
|
||||
use super::{MutationActor, MutationContext, Revision};
|
||||
|
||||
/// 会话审计使用的稳定管理员标识。
|
||||
#[must_use]
|
||||
pub fn admin_session_actor_ref(admin_user_id: &str) -> String {
|
||||
format!("admin:{admin_user_id}")
|
||||
}
|
||||
|
||||
/// 已认证的管理主体。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AdminPrincipal {
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::{fmt, num::NonZeroU16};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use gateway_core::{
|
||||
engine::budget::{ClientBudgetLimits, ClientBudgetStatus},
|
||||
policy::{ClientApiKeyId, RateLimits},
|
||||
routing::{AccountGroupId, ProviderKind},
|
||||
};
|
||||
@@ -93,6 +94,7 @@ pub struct ClientKeyRecord {
|
||||
pub prefix: String,
|
||||
pub enabled: bool,
|
||||
pub limits: RateLimits,
|
||||
pub budget: ClientBudgetStatus,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
@@ -146,6 +148,7 @@ pub struct CreateClientKey {
|
||||
pub label: Option<String>,
|
||||
pub group_ids: Vec<AccountGroupId>,
|
||||
pub limits: RateLimits,
|
||||
pub budget: ClientBudgetLimits,
|
||||
}
|
||||
|
||||
/// 管理用例生成 ID 与明文后的持久化命令。
|
||||
@@ -156,6 +159,7 @@ pub struct NewClientKey {
|
||||
pub label: Option<String>,
|
||||
pub group_ids: Vec<AccountGroupId>,
|
||||
pub limits: RateLimits,
|
||||
pub budget: ClientBudgetLimits,
|
||||
pub plaintext: String,
|
||||
}
|
||||
|
||||
@@ -179,6 +183,8 @@ pub struct UpdateClientKey {
|
||||
pub label: Option<String>,
|
||||
pub group_ids: Vec<AccountGroupId>,
|
||||
pub limits: RateLimits,
|
||||
pub daily_limit_usd: Option<gateway_core::metering::Decimal>,
|
||||
pub weekly_limit_usd: Option<gateway_core::metering::Decimal>,
|
||||
}
|
||||
|
||||
/// 修改 Client Key 启用状态。
|
||||
@@ -217,3 +223,19 @@ pub struct ClientKeyMutation {
|
||||
pub record: Option<ClientKeyRecord>,
|
||||
pub id: ClientApiKeyId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnresolvedClientCharge {
|
||||
pub request_id: String,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReconcileClientCharge {
|
||||
pub key_id: ClientApiKeyId,
|
||||
pub request_id: String,
|
||||
pub amount_usd: gateway_core::metering::Decimal,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
@@ -471,6 +471,8 @@ pub struct DashboardAccountUsage {
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub plan_type: Option<String>,
|
||||
/// Admin 按 Provider 补齐的套餐展示名称。
|
||||
pub plan_type_display: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub input_tokens: Option<u64>,
|
||||
@@ -491,6 +493,8 @@ pub struct DashboardAccountUsage {
|
||||
///
|
||||
/// `None` 表示上游未提供可比较的百分比,不应伪造为零。
|
||||
pub quota_used_percent: Option<f64>,
|
||||
/// Provider 返回的代表窗口;缺失时保留未知语义。
|
||||
pub quota_window: Option<super::provider_credentials::ProviderQuotaWindow>,
|
||||
pub models: Vec<DashboardAccountModelUsage>,
|
||||
}
|
||||
|
||||
@@ -702,6 +706,8 @@ pub struct UsageAttempt {
|
||||
/// 一条请求及其全部尝试。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageDetail {
|
||||
pub trace: Option<serde_json::Value>,
|
||||
pub related_requests: Vec<serde_json::Value>,
|
||||
pub request: UsageRecord,
|
||||
pub attempts: Vec<UsageAttempt>,
|
||||
}
|
||||
@@ -783,7 +789,6 @@ pub struct OpsError {
|
||||
pub latency_ms: Option<u64>,
|
||||
pub message: String,
|
||||
pub raw_upstream_error: Option<String>,
|
||||
pub occurrence_count: u32,
|
||||
pub client_ip: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
|
||||
@@ -10,11 +10,15 @@ use gateway_core::{
|
||||
account::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
|
||||
routing::{ProviderKind, UpstreamModelId},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
AdminError, MutationActor, MutationContext, PageSize, Revision,
|
||||
accounts::{AccountRecord, AccountSummary, AccountUsage, CredentialState},
|
||||
accounts::{
|
||||
AccountImportSettings, AccountRecord, AccountSummary, AccountUsage, CredentialState,
|
||||
},
|
||||
};
|
||||
|
||||
/// Provider-owned JSON;公共层只搬运且 Debug 不输出值。
|
||||
@@ -104,6 +108,7 @@ pub struct CredentialDetails {
|
||||
|
||||
/// Provider 正式文档批量导入命令。
|
||||
pub struct ImportCredentials {
|
||||
pub settings: Option<AccountImportSettings>,
|
||||
pub context: MutationContext,
|
||||
pub document: ProviderDocument,
|
||||
}
|
||||
@@ -142,6 +147,7 @@ impl fmt::Debug for PrepareCredentialImport {
|
||||
/// Provider 已验证、可由 Store 原子创建的一份 credential。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PreparedCredentialCreate {
|
||||
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub account_id: ProviderAccountId,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub name: String,
|
||||
@@ -169,6 +175,7 @@ pub struct PreparedCredentialImport {
|
||||
/// Admin 交给 Store 的导入事务命令。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CredentialImportCommit {
|
||||
pub settings: Option<AccountImportSettings>,
|
||||
pub prepared: PreparedCredentialImport,
|
||||
}
|
||||
|
||||
@@ -247,6 +254,7 @@ pub struct PendingAuthorizationMutation {
|
||||
provider_kind: ProviderKind,
|
||||
target: AuthorizationMutationTarget,
|
||||
owner_binding: AuthorizationOwnerBinding,
|
||||
outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
}
|
||||
|
||||
impl PendingAuthorizationMutation {
|
||||
@@ -260,6 +268,7 @@ impl PendingAuthorizationMutation {
|
||||
provider_kind,
|
||||
target,
|
||||
owner_binding,
|
||||
outbound_proxy: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +286,130 @@ impl PendingAuthorizationMutation {
|
||||
pub const fn owner_binding(&self) -> &AuthorizationOwnerBinding {
|
||||
&self.owner_binding
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_outbound_proxy(
|
||||
mut self,
|
||||
proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
) -> Self {
|
||||
self.outbound_proxy = proxy;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn outbound_proxy(&self) -> Option<&gateway_core::account::OutboundProxy> {
|
||||
self.outbound_proxy.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl PendingAuthorizationMutation {
|
||||
/// 编码 v1 中立事务字段;版本号的位置和 Provider 私有外层文档由 adapter 保持。
|
||||
#[must_use]
|
||||
pub fn to_storage_v1(&self) -> Map<String, Value> {
|
||||
let target = match &self.target {
|
||||
AuthorizationMutationTarget::Create { name } => {
|
||||
serde_json::json!({"kind": "create", "name": name})
|
||||
}
|
||||
AuthorizationMutationTarget::Reauthorize { account_id } => {
|
||||
serde_json::json!({"kind": "reauthorize", "account_id": account_id.to_string()})
|
||||
}
|
||||
};
|
||||
let owner = match self.owner_binding.owner() {
|
||||
AuthorizationOwner::AdminSession { admin_user_id } => {
|
||||
serde_json::json!({"kind": "admin_session", "admin_user_id": admin_user_id})
|
||||
}
|
||||
AuthorizationOwner::AdminApiKey => serde_json::json!({"kind": "admin_api_key"}),
|
||||
AuthorizationOwner::System => serde_json::json!({"kind": "system"}),
|
||||
};
|
||||
let mut document = Map::from_iter([
|
||||
(
|
||||
"provider_kind".to_owned(),
|
||||
Value::String(self.provider_kind.as_str().to_owned()),
|
||||
),
|
||||
("target".to_owned(), target),
|
||||
("owner".to_owned(), owner),
|
||||
(
|
||||
"started_request_id".to_owned(),
|
||||
Value::String(self.owner_binding.started_request_id().to_owned()),
|
||||
),
|
||||
]);
|
||||
if let Some(proxy) = &self.outbound_proxy {
|
||||
document.insert(
|
||||
"outbound_proxy_url".to_owned(),
|
||||
Value::String(proxy.expose_url().to_owned()),
|
||||
);
|
||||
}
|
||||
document
|
||||
}
|
||||
|
||||
/// 恢复 adapter 已确认版本为 v1 的中立事务字段。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 文档字段、Provider kind 或账号身份非法时返回错误,不携带原始 pending 内容。
|
||||
pub fn from_storage_v1(value: Value) -> Result<Self, AdminError> {
|
||||
let invalid = || AdminError::invalid("invalid pending authorization mutation");
|
||||
let document: StoredAuthorizationMutationV1 =
|
||||
serde_json::from_value(value).map_err(|_| invalid())?;
|
||||
let provider_kind = ProviderKind::new(document.provider_kind).map_err(|_| invalid())?;
|
||||
let target = match document.target {
|
||||
StoredAuthorizationTargetV1::Create { name } => {
|
||||
AuthorizationMutationTarget::Create { name }
|
||||
}
|
||||
StoredAuthorizationTargetV1::Reauthorize { account_id } => {
|
||||
AuthorizationMutationTarget::Reauthorize {
|
||||
account_id: ProviderAccountId::new(account_id).map_err(|_| invalid())?,
|
||||
}
|
||||
}
|
||||
};
|
||||
let owner = match document.owner {
|
||||
StoredAuthorizationOwnerV1::AdminSession { admin_user_id } => {
|
||||
AuthorizationOwner::AdminSession { admin_user_id }
|
||||
}
|
||||
StoredAuthorizationOwnerV1::AdminApiKey => AuthorizationOwner::AdminApiKey,
|
||||
StoredAuthorizationOwnerV1::System => AuthorizationOwner::System,
|
||||
};
|
||||
let proxy = document
|
||||
.outbound_proxy_url
|
||||
.as_deref()
|
||||
.map(gateway_core::account::OutboundProxy::parse)
|
||||
.transpose()
|
||||
.map_err(|_| invalid())?;
|
||||
Ok(Self::new(
|
||||
provider_kind,
|
||||
target,
|
||||
AuthorizationOwnerBinding {
|
||||
owner,
|
||||
started_request_id: document.started_request_id,
|
||||
},
|
||||
)
|
||||
.with_outbound_proxy(proxy))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct StoredAuthorizationMutationV1 {
|
||||
provider_kind: String,
|
||||
target: StoredAuthorizationTargetV1,
|
||||
owner: StoredAuthorizationOwnerV1,
|
||||
started_request_id: String,
|
||||
#[serde(default)]
|
||||
outbound_proxy_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
enum StoredAuthorizationTargetV1 {
|
||||
Create { name: String },
|
||||
Reauthorize { account_id: String },
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
enum StoredAuthorizationOwnerV1 {
|
||||
AdminSession { admin_user_id: String },
|
||||
AdminApiKey,
|
||||
System,
|
||||
}
|
||||
|
||||
/// 启动 Provider OAuth Authorization Code 流程。
|
||||
@@ -285,6 +418,7 @@ pub struct StartAuthorization {
|
||||
pub context: MutationContext,
|
||||
pub name: String,
|
||||
pub reauthorization: Option<ProviderAccountId>,
|
||||
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
}
|
||||
|
||||
/// OAuth 流程启动结果。
|
||||
@@ -350,6 +484,7 @@ pub enum AuthorizationCredentialCommit {
|
||||
/// Admin 交给 Store 的 OAuth 原子事务命令。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AuthorizationCommit {
|
||||
pub settings: Option<AccountImportSettings>,
|
||||
pub pending: PendingAuthorizationMutation,
|
||||
pub credential: AuthorizationCredentialCommit,
|
||||
}
|
||||
@@ -384,7 +519,10 @@ impl PreparedAuthorizationCommit {
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn into_commit(self) -> AuthorizationCommitSettlement {
|
||||
pub(crate) fn into_commit(
|
||||
self,
|
||||
settings: Option<AccountImportSettings>,
|
||||
) -> AuthorizationCommitSettlement {
|
||||
let (credential, guard) = match self.credential {
|
||||
PreparedAuthorizationCredential::Create(credential) => {
|
||||
(AuthorizationCredentialCommit::Create(credential), None)
|
||||
@@ -399,6 +537,7 @@ impl PreparedAuthorizationCommit {
|
||||
};
|
||||
AuthorizationCommitSettlement {
|
||||
command: AuthorizationCommit {
|
||||
settings,
|
||||
pending: self.pending,
|
||||
credential,
|
||||
},
|
||||
@@ -412,7 +551,7 @@ impl PreparedAuthorizationCommit {
|
||||
credential_guard,
|
||||
authorization_guard,
|
||||
..
|
||||
} = self.into_commit();
|
||||
} = self.into_commit(None);
|
||||
drop(credential_guard);
|
||||
if let Some(guard) = authorization_guard {
|
||||
guard.abort().await?;
|
||||
@@ -424,6 +563,7 @@ impl PreparedAuthorizationCommit {
|
||||
/// 完成 Provider OAuth 流程。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct CompleteAuthorization {
|
||||
pub settings: Option<AccountImportSettings>,
|
||||
pub context: MutationContext,
|
||||
pub flow_id: String,
|
||||
pub callback_url: String,
|
||||
@@ -621,6 +761,8 @@ pub struct ProviderQuotaRequest {
|
||||
/// Provider 已解析的 quota 结果及其不透明差异字段。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderQuota {
|
||||
/// 上游额度响应明确提供的套餐,可用于补全账号展示。
|
||||
pub plan_type: Option<String>,
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
pub refresh_token_expires_at: Option<DateTime<Utc>>,
|
||||
pub windows: Vec<ProviderQuotaWindow>,
|
||||
@@ -629,6 +771,14 @@ pub struct ProviderQuota {
|
||||
pub provider_data: Option<ProviderDocument>,
|
||||
}
|
||||
|
||||
/// 空值和 `unknown` 代表未提供套餐;新的套餐标识仍按明确值保留。
|
||||
pub(crate) fn explicit_plan_type(value: Option<&str>) -> Option<&str> {
|
||||
value.filter(|value| {
|
||||
let value = value.trim();
|
||||
!value.is_empty() && !value.eq_ignore_ascii_case("unknown")
|
||||
})
|
||||
}
|
||||
|
||||
/// Provider 官方个人资料中的累计摘要。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderProfileStatisticsSummary {
|
||||
@@ -743,6 +893,13 @@ pub struct ProviderResetCreditResult {
|
||||
}
|
||||
|
||||
impl ProviderQuota {
|
||||
/// 保留账号已有的套餐子类型,仅在缺失时使用上游额度快照补全。
|
||||
pub(crate) fn fill_missing_plan_type(&self, account_plan_type: &mut Option<String>) {
|
||||
if explicit_plan_type(account_plan_type.as_deref()).is_none() {
|
||||
*account_plan_type = explicit_plan_type(self.plan_type.as_deref()).map(str::to_owned);
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回账号展开面板使用的当前额度窗口用量。
|
||||
///
|
||||
/// Provider 已提供账号级本地用量时,固定窗口和无固定重置点的滚动窗口
|
||||
@@ -776,6 +933,20 @@ impl ProviderQuota {
|
||||
.map(|(_, used_percent)| used_percent)
|
||||
}
|
||||
|
||||
/// 选择 Dashboard 展示的真实窗口;没有百分比时仍保留 Provider 的滚动窗口语义。
|
||||
#[must_use]
|
||||
pub fn representative_window(&self) -> Option<&ProviderQuotaWindow> {
|
||||
self.representative_used_window()
|
||||
.map(|(index, _)| &self.windows[index])
|
||||
.or_else(|| {
|
||||
self.windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by_key(|(index, window)| (quota_usage_priority(window), *index))
|
||||
.map(|(_, window)| window)
|
||||
})
|
||||
}
|
||||
|
||||
/// 将已确认的账号级额度耗尽事实投影到展示窗口。
|
||||
///
|
||||
/// Provider 已指出具体触顶窗口时只归一化这些窗口;否则归一化 Dashboard
|
||||
@@ -908,6 +1079,8 @@ impl fmt::Debug for ProviderExport {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountDirectoryItem {
|
||||
pub account: AccountRecord,
|
||||
/// Provider 提供的套餐展示名称;未识别到套餐时为空。
|
||||
pub plan_type_display: Option<String>,
|
||||
pub projection: gateway_core::account::AccountStatusProjection,
|
||||
pub usage: Option<super::accounts::AccountUsage>,
|
||||
pub quota: ProviderQuota,
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::model::provider_credentials::{
|
||||
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
|
||||
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderResetCreditResult, ProviderResetCredits,
|
||||
ProviderResetCreditResult, ProviderResetCredits, explicit_plan_type,
|
||||
};
|
||||
|
||||
/// Provider 管理失败的稳定分类。
|
||||
@@ -40,11 +40,13 @@ pub enum ProviderAdminErrorKind {
|
||||
///
|
||||
/// `message` 是 Provider 局部诊断,可能包含原始上游正文;通用管理用例不得自动把它作为公开文案。
|
||||
/// 只有明确拥有原始诊断合同的调用方才能读取,`Debug` 始终只记录是否存在。
|
||||
/// `public_message` 则是明确标记为可公开的静态提示,不允许携带动态上游材料。
|
||||
#[derive(Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("provider admin operation failed: {kind:?}")]
|
||||
pub struct ProviderAdminError {
|
||||
kind: ProviderAdminErrorKind,
|
||||
message: Option<String>,
|
||||
public_message: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProviderAdminError {
|
||||
@@ -53,6 +55,7 @@ impl std::fmt::Debug for ProviderAdminError {
|
||||
.debug_struct("ProviderAdminError")
|
||||
.field("kind", &self.kind)
|
||||
.field("message", &self.message.as_ref().map(|_| "<redacted>"))
|
||||
.field("public_message", &self.public_message)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -63,6 +66,7 @@ impl ProviderAdminError {
|
||||
Self {
|
||||
kind,
|
||||
message: None,
|
||||
public_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +76,18 @@ impl ProviderAdminError {
|
||||
self
|
||||
}
|
||||
|
||||
/// Provider 明确允许公开的静态提示;不得承载上游响应或凭据材料。
|
||||
#[must_use]
|
||||
pub const fn with_public_message(mut self, message: &'static str) -> Self {
|
||||
self.public_message = Some(message);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn public_message(&self) -> Option<&'static str> {
|
||||
self.public_message
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> ProviderAdminErrorKind {
|
||||
self.kind
|
||||
@@ -91,6 +107,11 @@ impl ProviderAdminError {
|
||||
pub trait ProviderAdmin: Send + Sync {
|
||||
fn provider_kind(&self) -> &ProviderKind;
|
||||
|
||||
/// 将原始套餐值投影为展示名称;默认保留未知 Provider 的原始名称。
|
||||
fn plan_type_display(&self, plan_type: &str) -> String {
|
||||
plan_type.to_owned()
|
||||
}
|
||||
|
||||
/// 账号已经由控制面提交为不可调度状态,释放 Provider 持有的账号级运行时资源。
|
||||
///
|
||||
/// 无账号级运行时资源的 Provider 不需要执行额外操作。该通知发生在 Store 事务
|
||||
@@ -230,6 +251,22 @@ impl ProviderAdminRegistry {
|
||||
.ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 账号页和 Dashboard 共用的套餐展示投影,不修改持久化的原始套餐值。
|
||||
pub(crate) fn plan_type_display(
|
||||
&self,
|
||||
provider_kind: &str,
|
||||
plan_type: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let plan_type = explicit_plan_type(plan_type)?.trim();
|
||||
let provider = ProviderKind::new(provider_kind.to_owned())
|
||||
.ok()
|
||||
.and_then(|kind| self.providers.get(&kind));
|
||||
Some(provider.map_or_else(
|
||||
|| plan_type.to_owned(),
|
||||
|provider| provider.plan_type_display(plan_type),
|
||||
))
|
||||
}
|
||||
|
||||
/// 返回所有已注册 Provider 的 Dashboard 上游身份画像。
|
||||
pub fn dashboard_wire_profiles(&self) -> Vec<DashboardWireProfile> {
|
||||
self.providers
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::stream::BoxStream;
|
||||
|
||||
use super::backup::BackupStorePorts;
|
||||
use crate::model::{
|
||||
@@ -219,6 +220,29 @@ pub trait AuthStore: Send + Sync {
|
||||
/// Client API Key 管理写入。
|
||||
#[async_trait]
|
||||
pub trait ClientKeyStore: Send + Sync {
|
||||
async fn unresolved_charges(
|
||||
&self,
|
||||
_id: &gateway_core::policy::ClientApiKeyId,
|
||||
) -> AdminStoreResult<Vec<crate::model::client_keys::UnresolvedClientCharge>> {
|
||||
Err(AdminStoreError::new(
|
||||
AdminStoreErrorKind::Unavailable,
|
||||
"client budget",
|
||||
"unsupported",
|
||||
))
|
||||
}
|
||||
|
||||
async fn reconcile_charge(
|
||||
&self,
|
||||
_command: crate::model::client_keys::ReconcileClientCharge,
|
||||
_context: &MutationContext,
|
||||
) -> AdminStoreResult<()> {
|
||||
Err(AdminStoreError::new(
|
||||
AdminStoreErrorKind::Unavailable,
|
||||
"client budget",
|
||||
"unsupported",
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage>;
|
||||
|
||||
async fn reveal_client_key(
|
||||
@@ -289,6 +313,10 @@ pub trait AccountGroupStore: Send + Sync {
|
||||
) -> AdminStoreResult<AccountGroupMutation>;
|
||||
}
|
||||
|
||||
/// 逐条读取的已计算费用事实;消费结束或丢弃时释放查询资源。
|
||||
pub type UsageCalculatedBillingStream<'a> =
|
||||
BoxStream<'a, AdminStoreResult<UsageCalculatedBillingFact>>;
|
||||
|
||||
/// 用量、趋势、诊断与运维错误的只读能力。
|
||||
#[async_trait]
|
||||
pub trait ObservabilityStore: Send + Sync {
|
||||
@@ -318,12 +346,13 @@ pub trait ObservabilityStore: Send + Sync {
|
||||
filter: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<RequestMetricPoint>>;
|
||||
|
||||
/// 返回可由 Provider 重新校验的已计算费用事实,用于恢复标准费用趋势。
|
||||
async fn usage_calculated_billing_facts(
|
||||
/// 流式返回可由 Provider 重新校验的已计算费用事实,不保证顺序。
|
||||
/// 查询及解码错误由流返回;调用方应逐条聚合,避免收集整个区间。
|
||||
fn usage_calculated_billing_facts(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<UsageCalculatedBillingFact>>;
|
||||
) -> UsageCalculatedBillingStream<'_>;
|
||||
|
||||
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage>;
|
||||
|
||||
|
||||
@@ -269,13 +269,23 @@ impl DefaultAccountsService {
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider quota"))?;
|
||||
let mut stored = if refresh_quota {
|
||||
self.load_account(account_id).await?
|
||||
} else {
|
||||
stored
|
||||
};
|
||||
self.attach_quota_local_usage(
|
||||
std::slice::from_ref(&stored),
|
||||
std::slice::from_mut(&mut quota),
|
||||
)
|
||||
.await?;
|
||||
let usage = quota.representative_window_usage().cloned();
|
||||
quota.fill_missing_plan_type(&mut stored.account.plan_type);
|
||||
Ok(AccountDirectoryItem {
|
||||
plan_type_display: self.providers.plan_type_display(
|
||||
stored.account.provider_kind.as_str(),
|
||||
stored.account.plan_type.as_deref(),
|
||||
),
|
||||
projection: stored.projection,
|
||||
usage,
|
||||
account: stored.account,
|
||||
@@ -361,9 +371,14 @@ impl AccountsService for DefaultAccountsService {
|
||||
.items
|
||||
.into_iter()
|
||||
.zip(quotas)
|
||||
.map(|(item, quota)| {
|
||||
.map(|(mut item, quota)| {
|
||||
let usage = quota.representative_window_usage().cloned();
|
||||
quota.fill_missing_plan_type(&mut item.account.plan_type);
|
||||
AccountDirectoryItem {
|
||||
plan_type_display: self.providers.plan_type_display(
|
||||
item.account.provider_kind.as_str(),
|
||||
item.account.plan_type.as_deref(),
|
||||
),
|
||||
usage,
|
||||
account: item.account,
|
||||
projection: item.projection,
|
||||
@@ -730,6 +745,7 @@ fn map_reset_credits_error_after_refresh(
|
||||
/// 账号目录中单个账号 quota 读取失败时使用的空额度投影。
|
||||
fn empty_quota() -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: Vec::new(),
|
||||
|
||||
@@ -69,7 +69,7 @@ impl DefaultAuthService {
|
||||
id: format!("audit_{}", Uuid::now_v7().simple()),
|
||||
actor_kind: AuditActorKind::AdminSession,
|
||||
actor_admin_user_id: Some(self.default_admin_user_id.clone()),
|
||||
actor_ref: format!("admin:{}", self.default_admin_user_id),
|
||||
actor_ref: crate::model::auth::admin_session_actor_ref(&self.default_admin_user_id),
|
||||
request_id: None,
|
||||
action: action.to_owned(),
|
||||
entity_kind: "admin_session".to_owned(),
|
||||
@@ -183,7 +183,7 @@ impl AuthService for DefaultAuthService {
|
||||
if let Some(session) = session {
|
||||
let mut event = self.auth_audit("admin.logout", Utc::now());
|
||||
event.actor_admin_user_id = Some(session.admin_user_id.clone());
|
||||
event.actor_ref = format!("admin:{}", session.admin_user_id);
|
||||
event.actor_ref = crate::model::auth::admin_session_actor_ref(&session.admin_user_id);
|
||||
event.entity_ref = session.admin_user_id;
|
||||
self.store
|
||||
.append_audit_event(event)
|
||||
|
||||
@@ -326,7 +326,9 @@ impl DefaultBackupService {
|
||||
crate::model::MutationActor::System => AuditActorKind::System,
|
||||
};
|
||||
let actor_ref = match &context.actor {
|
||||
crate::model::MutationActor::AdminSession { admin_user_id } => admin_user_id.clone(),
|
||||
crate::model::MutationActor::AdminSession { admin_user_id } => {
|
||||
crate::model::auth::admin_session_actor_ref(admin_user_id)
|
||||
}
|
||||
crate::model::MutationActor::AdminApiKey => "admin_api_key".to_owned(),
|
||||
crate::model::MutationActor::System => "system".to_owned(),
|
||||
};
|
||||
|
||||
@@ -26,6 +26,19 @@ use super::{map_store_error, publish_committed};
|
||||
/// API 消费的 Client Key 管理服务。
|
||||
#[async_trait]
|
||||
pub trait ClientKeyService: Send + Sync {
|
||||
async fn unresolved_charges(
|
||||
&self,
|
||||
_id: &ClientApiKeyId,
|
||||
) -> Result<Vec<crate::model::client_keys::UnresolvedClientCharge>, AdminError> {
|
||||
Err(AdminError::unavailable("client budget unavailable"))
|
||||
}
|
||||
async fn reconcile_charge(
|
||||
&self,
|
||||
_context: &MutationContext,
|
||||
_command: crate::model::client_keys::ReconcileClientCharge,
|
||||
) -> Result<(), AdminError> {
|
||||
Err(AdminError::unavailable("client budget unavailable"))
|
||||
}
|
||||
async fn list(&self, query: ClientKeyListQuery) -> Result<ClientKeyPage, AdminError>;
|
||||
async fn reveal(&self, id: &ClientApiKeyId) -> Result<ClientKeySecret, AdminError>;
|
||||
async fn create(
|
||||
@@ -64,6 +77,34 @@ impl DefaultClientKeyService {
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyService for DefaultClientKeyService {
|
||||
async fn unresolved_charges(
|
||||
&self,
|
||||
id: &ClientApiKeyId,
|
||||
) -> Result<Vec<crate::model::client_keys::UnresolvedClientCharge>, AdminError> {
|
||||
self.store
|
||||
.unresolved_charges(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client budget"))
|
||||
}
|
||||
|
||||
async fn reconcile_charge(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: crate::model::client_keys::ReconcileClientCharge,
|
||||
) -> Result<(), AdminError> {
|
||||
if command.request_id.is_empty()
|
||||
|| command.request_id.len() > 128
|
||||
|| command.reason.trim().is_empty()
|
||||
|| command.reason.len() > 1024
|
||||
|| command.reason.chars().any(char::is_control)
|
||||
{
|
||||
return Err(AdminError::invalid("invalid charge reconciliation"));
|
||||
}
|
||||
self.store
|
||||
.reconcile_charge(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client budget"))
|
||||
}
|
||||
async fn list(&self, query: ClientKeyListQuery) -> Result<ClientKeyPage, AdminError> {
|
||||
validate_cursor(&query)?;
|
||||
self.store
|
||||
@@ -99,6 +140,7 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
label: command.label,
|
||||
group_ids: command.group_ids,
|
||||
limits: command.limits,
|
||||
budget: command.budget,
|
||||
plaintext: plaintext.clone(),
|
||||
},
|
||||
context,
|
||||
|
||||
@@ -83,7 +83,7 @@ fn map_provider_error(
|
||||
AdminErrorKind::Internal => "服务内部错误",
|
||||
_ => "Provider 操作失败",
|
||||
};
|
||||
AdminError::new(kind, message)
|
||||
AdminError::new(kind, error.public_message().unwrap_or(message))
|
||||
}
|
||||
|
||||
async fn publish_committed(
|
||||
@@ -115,9 +115,13 @@ async fn pending_authorization(
|
||||
command: &StartAuthorization,
|
||||
resource: &'static str,
|
||||
) -> Result<PendingAuthorizationMutation, AdminError> {
|
||||
let mut proxy = command.outbound_proxy.clone();
|
||||
let target = match &command.reauthorization {
|
||||
Some(account_id) => {
|
||||
required_credential(accounts, provider_kind, account_id, resource).await?;
|
||||
proxy = required_credential(accounts, provider_kind, account_id, resource)
|
||||
.await?
|
||||
.credential
|
||||
.outbound_proxy;
|
||||
AuthorizationMutationTarget::Reauthorize {
|
||||
account_id: account_id.clone(),
|
||||
}
|
||||
@@ -132,7 +136,8 @@ async fn pending_authorization(
|
||||
crate::model::provider_credentials::AuthorizationOwnerBinding::from_context(
|
||||
&command.context,
|
||||
),
|
||||
))
|
||||
)
|
||||
.with_outbound_proxy(proxy))
|
||||
}
|
||||
|
||||
fn validate_prepared_import(
|
||||
@@ -217,14 +222,24 @@ async fn validate_authorization_commit(
|
||||
async fn commit_authorization(
|
||||
accounts: &dyn AccountStore,
|
||||
prepared: PreparedAuthorizationCommit,
|
||||
settings: Option<crate::model::accounts::AccountImportSettings>,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
if settings.is_some()
|
||||
&& matches!(
|
||||
&prepared.credential,
|
||||
PreparedAuthorizationCredential::Reauthorize(_)
|
||||
)
|
||||
{
|
||||
prepared.abort().await?;
|
||||
return Err(AdminError::invalid("重新授权不能修改账号设置"));
|
||||
}
|
||||
let crate::model::provider_credentials::AuthorizationCommitSettlement {
|
||||
command,
|
||||
credential_guard,
|
||||
authorization_guard,
|
||||
} = prepared.into_commit();
|
||||
} = prepared.into_commit(settings);
|
||||
match accounts.commit_authorization(command, context).await {
|
||||
Ok(result) => {
|
||||
if let Some(guard) = credential_guard {
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::{
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Duration, Timelike as _, Utc};
|
||||
use futures::TryStreamExt;
|
||||
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
|
||||
|
||||
use crate::{
|
||||
@@ -29,7 +30,7 @@ use crate::{
|
||||
},
|
||||
ports::{
|
||||
provider::{ProviderAdminErrorKind, ProviderAdminRegistry},
|
||||
store::{ObservabilityStore, SettingsStore},
|
||||
store::{AccountStore, ObservabilityStore, SettingsStore, UsageCalculatedBillingStream},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -126,6 +127,7 @@ pub trait ObservabilityService: Send + Sync {
|
||||
|
||||
pub(crate) struct DefaultObservabilityService {
|
||||
store: Arc<dyn ObservabilityStore>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
dashboard_queries: DashboardQueryCache,
|
||||
@@ -135,11 +137,13 @@ impl DefaultObservabilityService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
store: Arc<dyn ObservabilityStore>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
accounts,
|
||||
settings,
|
||||
providers,
|
||||
dashboard_queries: DashboardQueryCache::default(),
|
||||
@@ -160,6 +164,11 @@ impl DefaultObservabilityService {
|
||||
self.enrich_list_billing(&mut observation.recent_requests);
|
||||
self.enrich_dashboard_quotas(&mut observation.account_usage)
|
||||
.await;
|
||||
for account in &mut observation.account_usage {
|
||||
account.plan_type_display = self
|
||||
.providers
|
||||
.plan_type_display(&account.provider_kind, account.plan_type.as_deref());
|
||||
}
|
||||
let today_start = china_day_start(observation.range.end);
|
||||
let yesterday_start = today_start - Duration::days(1);
|
||||
let today =
|
||||
@@ -301,13 +310,21 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> Result<UsageInsights, AdminError> {
|
||||
let (overview, trend, billing_facts) = futures::try_join!(
|
||||
self.store.usage_summary(range, filter.clone()),
|
||||
self.store.usage_trend(range, filter.clone()),
|
||||
self.store.usage_calculated_billing_facts(range, filter),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "usage insights"))?;
|
||||
let standard_costs = recover_standard_costs(&self.providers, billing_facts)?;
|
||||
let aggregates = async {
|
||||
futures::try_join!(
|
||||
self.store.usage_summary(range, filter.clone()),
|
||||
self.store.usage_trend(range, filter.clone()),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "usage insights"))
|
||||
};
|
||||
let ((overview, trend), standard_costs) = futures::try_join!(
|
||||
aggregates,
|
||||
recover_standard_costs(
|
||||
&self.providers,
|
||||
self.store
|
||||
.usage_calculated_billing_facts(range, filter.clone()),
|
||||
),
|
||||
)?;
|
||||
build_usage_insights(overview, trend, standard_costs)
|
||||
}
|
||||
|
||||
@@ -590,12 +607,16 @@ struct UsageCostScenarios {
|
||||
no_cache: ScenarioCosts,
|
||||
}
|
||||
|
||||
fn recover_standard_costs(
|
||||
async fn recover_standard_costs(
|
||||
providers: &ProviderAdminRegistry,
|
||||
facts: Vec<UsageCalculatedBillingFact>,
|
||||
mut facts: UsageCalculatedBillingStream<'_>,
|
||||
) -> Result<UsageCostScenarios, AdminError> {
|
||||
let mut scenarios = UsageCostScenarios::default();
|
||||
for fact in facts {
|
||||
while let Some(fact) = facts
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage billing facts"))?
|
||||
{
|
||||
let Ok(provider_kind) = ProviderKind::new(fact.provider_kind.clone()) else {
|
||||
continue;
|
||||
};
|
||||
@@ -708,8 +729,31 @@ fn usd_cost(costs: &[CurrencyCost]) -> Option<DecimalAmount> {
|
||||
|
||||
impl DefaultObservabilityService {
|
||||
async fn enrich_dashboard_quotas(&self, accounts: &mut [DashboardAccountUsage]) {
|
||||
if accounts.is_empty() {
|
||||
return;
|
||||
}
|
||||
let now = Utc::now();
|
||||
let ids = accounts
|
||||
.iter()
|
||||
.map(|account| account.account_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let mut rolling_usage = self
|
||||
.accounts
|
||||
.load_account_usage(
|
||||
TimeRange {
|
||||
start: now - Duration::hours(24),
|
||||
end: now,
|
||||
},
|
||||
&ids,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|usage| (usage.account_id.clone(), usage))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let providers = self.providers.clone();
|
||||
let quota_reads = accounts.iter().map(|account| {
|
||||
let rolling_usage = rolling_usage.remove(&account.account_id);
|
||||
let account_id = ProviderAccountId::new(account.account_id.clone()).ok();
|
||||
let provider = ProviderKind::new(account.provider_kind.clone())
|
||||
.ok()
|
||||
@@ -722,16 +766,20 @@ impl DefaultObservabilityService {
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id,
|
||||
refresh: false,
|
||||
rolling_usage: None,
|
||||
rolling_usage,
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|quota| quota.representative_used_percent())
|
||||
}
|
||||
});
|
||||
let quota_used = futures::future::join_all(quota_reads).await;
|
||||
for (account, used_percent) in accounts.iter_mut().zip(quota_used) {
|
||||
account.quota_used_percent = used_percent;
|
||||
let quotas = futures::future::join_all(quota_reads).await;
|
||||
for (account, quota) in accounts.iter_mut().zip(quotas) {
|
||||
if let Some(mut quota) = quota {
|
||||
quota.fill_missing_plan_type(&mut account.plan_type);
|
||||
quota.apply_limit_reached_display();
|
||||
account.quota_used_percent = quota.representative_used_percent();
|
||||
account.quota_window = quota.representative_window().cloned();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +142,13 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
)?;
|
||||
let result = self
|
||||
.accounts
|
||||
.commit_credential_import(CredentialImportCommit { prepared }, &context)
|
||||
.commit_credential_import(
|
||||
CredentialImportCommit {
|
||||
prepared,
|
||||
settings: command.settings,
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "OpenAI credential import"))?;
|
||||
self.provider
|
||||
@@ -173,9 +179,10 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
|
||||
async fn complete_authorization(
|
||||
&self,
|
||||
command: CompleteAuthorization,
|
||||
mut command: CompleteAuthorization,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let context = command.context.clone();
|
||||
let settings = command.settings.take();
|
||||
let prepared = self
|
||||
.provider
|
||||
.complete_authorization(command)
|
||||
@@ -191,6 +198,7 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
let result = commit_authorization(
|
||||
self.accounts.as_ref(),
|
||||
prepared,
|
||||
settings,
|
||||
&context,
|
||||
"OpenAI authorization",
|
||||
)
|
||||
|
||||
@@ -95,7 +95,13 @@ impl XaiService for DefaultXaiService {
|
||||
)?;
|
||||
let result = self
|
||||
.accounts
|
||||
.commit_credential_import(CredentialImportCommit { prepared }, &context)
|
||||
.commit_credential_import(
|
||||
CredentialImportCommit {
|
||||
prepared,
|
||||
settings: command.settings,
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "xAI credential import"))?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
@@ -132,9 +138,10 @@ impl XaiService for DefaultXaiService {
|
||||
|
||||
async fn complete_authorization(
|
||||
&self,
|
||||
command: CompleteAuthorization,
|
||||
mut command: CompleteAuthorization,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let context = command.context.clone();
|
||||
let settings = command.settings.take();
|
||||
let prepared = self
|
||||
.provider
|
||||
.complete_authorization(command)
|
||||
@@ -150,6 +157,7 @@ impl XaiService for DefaultXaiService {
|
||||
let result = commit_authorization(
|
||||
self.accounts.as_ref(),
|
||||
prepared,
|
||||
settings,
|
||||
&context,
|
||||
"xAI authorization",
|
||||
)
|
||||
|
||||
@@ -121,6 +121,7 @@ fn provider_document_debug_should_not_expose_opaque_material() {
|
||||
#[test]
|
||||
fn representative_quota_should_prefer_short_window_and_highest_usage() {
|
||||
let quota = ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![
|
||||
@@ -141,6 +142,7 @@ fn representative_quota_should_prefer_account_wide_window_over_model_specific_wi
|
||||
let mut model_specific = quota_window("shortTerm", Some(18_000), Some(64.0));
|
||||
model_specific.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
|
||||
let quota = ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![
|
||||
@@ -177,6 +179,7 @@ fn representative_window_usage_should_accept_account_wide_rolling_window_without
|
||||
models: Vec::new(),
|
||||
};
|
||||
let quota = ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![ProviderQuotaWindow {
|
||||
@@ -204,6 +207,7 @@ fn representative_window_usage_should_accept_account_wide_rolling_window_without
|
||||
#[test]
|
||||
fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() {
|
||||
let mut quota = ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![
|
||||
@@ -231,6 +235,7 @@ fn exhausted_quota_should_preserve_the_provider_identified_reached_window() {
|
||||
let mut reached = quota_window("monthly", Some(2_592_000), Some(98.0));
|
||||
reached.limit_reached = true;
|
||||
let mut quota = ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![reached, quota_window("shortTerm", Some(18_000), Some(95.0))],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//! 模型模块测试:镜像 src/model/ 中带逻辑的模块。
|
||||
|
||||
mod backup;
|
||||
mod provider_credentials;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
use gateway_admin::model::provider_credentials::{
|
||||
AuthorizationMutationTarget, AuthorizationOwnerBinding, PendingAuthorizationMutation,
|
||||
ProviderQuota, ProviderQuotaWindow, ProviderQuotaWindowRole, QuotaLocalUsageAttribution,
|
||||
};
|
||||
use gateway_admin::model::{MutationActor, MutationContext};
|
||||
use gateway_core::account::ProviderAccountId;
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn dashboard_quota_should_preserve_unknown_and_actual_window_facts() {
|
||||
let mut quota = ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: Vec::new(),
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
};
|
||||
assert!(quota.representative_window().is_none());
|
||||
assert!(quota.representative_used_percent().is_none());
|
||||
|
||||
let rolling = ProviderQuotaWindow {
|
||||
key: "provider_rolling".to_owned(),
|
||||
group: "requests".to_owned(),
|
||||
label: "Provider rolling window".to_owned(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
role: Some(ProviderQuotaWindowRole::Primary),
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(86_400),
|
||||
used_percent: None,
|
||||
reset_at: None,
|
||||
limit_reached: false,
|
||||
local_usage: None,
|
||||
provider_data: None,
|
||||
};
|
||||
quota.windows.push(rolling.clone());
|
||||
assert_eq!(quota.representative_window(), Some(&rolling));
|
||||
assert!(quota.representative_used_percent().is_none());
|
||||
|
||||
quota.windows[0].used_percent = Some(99.6);
|
||||
quota.apply_limit_reached_display();
|
||||
let actual = quota.representative_window().expect("actual window");
|
||||
assert_eq!(actual.used_percent, Some(99.6));
|
||||
assert!(
|
||||
!actual.limit_reached,
|
||||
"display rounding cannot exhaust quota"
|
||||
);
|
||||
assert_eq!(actual.window_seconds, Some(86_400));
|
||||
assert_eq!(actual.reset_at, None);
|
||||
|
||||
quota.windows.insert(
|
||||
0,
|
||||
ProviderQuotaWindow {
|
||||
key: "model_specific".to_owned(),
|
||||
used_percent: Some(100.0),
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::Unavailable,
|
||||
..rolling
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
quota
|
||||
.representative_window()
|
||||
.map(|window| window.key.as_str()),
|
||||
Some("provider_rolling")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_mutation_v1_should_round_trip_all_targets_and_owners() {
|
||||
for provider in ["openai", "xai"] {
|
||||
for target in [
|
||||
AuthorizationMutationTarget::Create {
|
||||
name: "account".to_owned(),
|
||||
},
|
||||
AuthorizationMutationTarget::Reauthorize {
|
||||
account_id: ProviderAccountId::new("acct_1").expect("account id"),
|
||||
},
|
||||
] {
|
||||
for actor in [
|
||||
MutationActor::AdminSession {
|
||||
admin_user_id: "admin-1".to_owned(),
|
||||
},
|
||||
MutationActor::AdminApiKey,
|
||||
MutationActor::System,
|
||||
] {
|
||||
let expected = PendingAuthorizationMutation::new(
|
||||
ProviderKind::new(provider).expect("provider"),
|
||||
target.clone(),
|
||||
AuthorizationOwnerBinding::from_context(&MutationContext {
|
||||
actor,
|
||||
request_id: "request-1".to_owned(),
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
PendingAuthorizationMutation::from_storage_v1(Value::Object(
|
||||
expected.to_storage_v1()
|
||||
)),
|
||||
Ok(expected),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_mutation_v1_should_keep_existing_storage_fields_and_reject_invalid_documents() {
|
||||
let legacy = json!({
|
||||
"provider_kind": "openai",
|
||||
"target": { "kind": "reauthorize", "account_id": "acct_1" },
|
||||
"owner": { "kind": "admin_session", "admin_user_id": "admin-1" },
|
||||
"started_request_id": "request-1"
|
||||
});
|
||||
let restored =
|
||||
PendingAuthorizationMutation::from_storage_v1(legacy.clone()).expect("v1 document");
|
||||
assert_eq!(Value::Object(restored.to_storage_v1()), legacy);
|
||||
for (pointer, invalid) in [
|
||||
("/provider_kind", json!("")),
|
||||
("/target/account_id", json!("")),
|
||||
("/target/kind", json!("other")),
|
||||
("/owner/kind", json!("other")),
|
||||
("/started_request_id", json!(null)),
|
||||
] {
|
||||
let mut document = legacy.clone();
|
||||
*document.pointer_mut(pointer).expect("field") = invalid;
|
||||
assert!(
|
||||
PendingAuthorizationMutation::from_storage_v1(document).is_err(),
|
||||
"{pointer}"
|
||||
);
|
||||
}
|
||||
let mut document = legacy;
|
||||
document["schema_version"] = json!(2);
|
||||
assert!(PendingAuthorizationMutation::from_storage_v1(document).is_err());
|
||||
}
|
||||
@@ -72,6 +72,7 @@ pub(super) struct FakeProviderAdmin {
|
||||
import_account_ids: Mutex<Vec<String>>,
|
||||
quota_requests: Mutex<Vec<ProviderQuotaRequest>>,
|
||||
quota: Mutex<ProviderQuota>,
|
||||
quota_refresh_account: Mutex<Option<(Arc<FakeAccountStore>, AccountRecord)>>,
|
||||
current_credential_revision: Mutex<Revision>,
|
||||
reset_credit_commands: Mutex<Vec<ConsumeProviderResetCredit>>,
|
||||
}
|
||||
@@ -89,6 +90,7 @@ impl FakeProviderAdmin {
|
||||
import_account_ids: Mutex::new(vec!["acct_prepared".to_owned()]),
|
||||
quota_requests: Mutex::new(Vec::new()),
|
||||
quota: Mutex::new(empty_quota()),
|
||||
quota_refresh_account: Mutex::new(None),
|
||||
current_credential_revision: Mutex::new(revision(1)),
|
||||
reset_credit_commands: Mutex::new(Vec::new()),
|
||||
})
|
||||
@@ -103,6 +105,18 @@ impl FakeProviderAdmin {
|
||||
Some(ProviderAdminError::new(kind).with_message(message));
|
||||
}
|
||||
|
||||
pub(super) fn fail_next_with_public_message(
|
||||
&self,
|
||||
kind: ProviderAdminErrorKind,
|
||||
message: &'static str,
|
||||
) {
|
||||
*self.failure.lock().expect("provider failure") = Some(
|
||||
ProviderAdminError::new(kind)
|
||||
.with_message("upstream body containing a secret token")
|
||||
.with_public_message(message),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn fail_next_quota(&self, kind: ProviderAdminErrorKind) {
|
||||
*self.quota_failure.lock().expect("provider quota failure") = Some(kind);
|
||||
}
|
||||
@@ -199,6 +213,10 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
fn plan_type_display(&self, plan_type: &str) -> String {
|
||||
format!("{} display: {plan_type}", self.kind)
|
||||
}
|
||||
|
||||
async fn account_unavailable(&self, _: &ProviderAccountId) {
|
||||
self.record("provider.account_unavailable");
|
||||
}
|
||||
@@ -346,6 +364,14 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
) -> Result<ProviderQuota, ProviderAdminError> {
|
||||
if request.refresh {
|
||||
self.record("provider.quota");
|
||||
if let Some((store, account)) = self
|
||||
.quota_refresh_account
|
||||
.lock()
|
||||
.expect("quota refresh account")
|
||||
.take()
|
||||
{
|
||||
store.set_accounts(vec![account]);
|
||||
}
|
||||
}
|
||||
self.quota_requests
|
||||
.lock()
|
||||
@@ -415,6 +441,7 @@ pub(super) struct FakeAccountStore {
|
||||
account_after_probe: Mutex<Option<AccountRecord>>,
|
||||
fail_commit: Mutex<bool>,
|
||||
audit_requests: Mutex<Vec<String>>,
|
||||
import_settings: Mutex<Vec<Option<gateway_admin::model::accounts::AccountImportSettings>>>,
|
||||
quota_window_usage: Mutex<Vec<AccountUsageWindowResult>>,
|
||||
quota_window_queries: Mutex<Vec<AccountUsageWindowQuery>>,
|
||||
}
|
||||
@@ -431,6 +458,7 @@ impl FakeAccountStore {
|
||||
account_after_probe: Mutex::new(None),
|
||||
fail_commit: Mutex::new(false),
|
||||
audit_requests: Mutex::new(Vec::new()),
|
||||
import_settings: Mutex::new(Vec::new()),
|
||||
quota_window_usage: Mutex::new(Vec::new()),
|
||||
quota_window_queries: Mutex::new(Vec::new()),
|
||||
})
|
||||
@@ -440,6 +468,15 @@ impl FakeAccountStore {
|
||||
*self.fail_commit.lock().expect("store failure") = true;
|
||||
}
|
||||
|
||||
pub(super) fn import_settings(
|
||||
&self,
|
||||
) -> Vec<Option<gateway_admin::model::accounts::AccountImportSettings>> {
|
||||
self.import_settings
|
||||
.lock()
|
||||
.expect("import settings")
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(super) fn audit_requests(&self) -> Vec<String> {
|
||||
self.audit_requests.lock().expect("audit requests").clone()
|
||||
}
|
||||
@@ -646,6 +683,10 @@ impl AccountStore for FakeAccountStore {
|
||||
command: CredentialImportCommit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialImportResult> {
|
||||
self.import_settings
|
||||
.lock()
|
||||
.expect("import settings")
|
||||
.push(command.settings);
|
||||
self.record("store.commit_import");
|
||||
self.record_context(context);
|
||||
self.require_commit()?;
|
||||
@@ -665,6 +706,10 @@ impl AccountStore for FakeAccountStore {
|
||||
command: AuthorizationCommit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult> {
|
||||
self.import_settings
|
||||
.lock()
|
||||
.expect("import settings")
|
||||
.push(command.settings);
|
||||
self.record("store.commit_authorization");
|
||||
self.record_context(context);
|
||||
self.require_commit()?;
|
||||
@@ -1098,6 +1143,47 @@ async fn accounts_refresh_should_keep_guard_through_store_commit() {
|
||||
assert_eq!(store.audit_requests(), ["refresh-request"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_quota_refresh_should_return_the_updated_account_status() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let mut account = account_record("openai");
|
||||
account.quota = QuotaState::exhausted(
|
||||
QuotaEvidence::UsageLimitReached,
|
||||
std::time::SystemTime::now(),
|
||||
None,
|
||||
);
|
||||
let store = FakeAccountStore::with_account(account.clone(), events.clone());
|
||||
account.quota = QuotaState::allowed(std::time::SystemTime::now());
|
||||
*provider
|
||||
.quota_refresh_account
|
||||
.lock()
|
||||
.expect("quota refresh account") = Some((store.clone(), account));
|
||||
|
||||
let result = accounts_service(provider, store)
|
||||
.await
|
||||
.accounts()
|
||||
.quota(
|
||||
&ProviderAccountId::new("acct_test").expect("account ID"),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("refresh quota");
|
||||
|
||||
assert_eq!(
|
||||
result.account.quota.access(),
|
||||
gateway_core::account::QuotaAccessState::Allowed
|
||||
);
|
||||
assert_eq!(
|
||||
result.projection.status,
|
||||
gateway_admin::model::accounts::AccountStatus::Normal
|
||||
);
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
["store.load_account", "provider.quota", "store.load_account"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_recover_should_commit_facts_then_return_normal_account() {
|
||||
let events = events();
|
||||
@@ -1162,6 +1248,7 @@ async fn accounts_update_should_commit_then_release_disabled_account_and_publish
|
||||
.update(
|
||||
&context("update-request"),
|
||||
UpdateAccount {
|
||||
outbound_proxy: None,
|
||||
account_id: "acct_test".to_owned(),
|
||||
enabled: false,
|
||||
concurrency_limit: None,
|
||||
@@ -1198,6 +1285,7 @@ async fn accounts_update_should_not_notify_provider_when_store_commit_fails() {
|
||||
.update(
|
||||
&context("update-failure"),
|
||||
UpdateAccount {
|
||||
outbound_proxy: None,
|
||||
account_id: "acct_test".to_owned(),
|
||||
enabled: false,
|
||||
concurrency_limit: None,
|
||||
@@ -1239,6 +1327,7 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
|
||||
.batch_update(
|
||||
&context("batch-update-request"),
|
||||
BatchUpdateAccounts {
|
||||
outbound_proxy: None,
|
||||
account_ids: vec!["acct_openai".to_owned(), "acct_xai".to_owned()],
|
||||
enabled: false,
|
||||
concurrency_limit: None,
|
||||
@@ -1269,9 +1358,11 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
|
||||
#[tokio::test]
|
||||
async fn accounts_list_should_return_complete_directory_semantics() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
let page = accounts_service(provider, store)
|
||||
.await
|
||||
let mut stored = account_record("openai");
|
||||
stored.plan_type = Some(" self_serve_business_prolite ".to_owned());
|
||||
let store = FakeAccountStore::with_account(stored, events());
|
||||
let services = accounts_service(provider, store).await;
|
||||
let page = services
|
||||
.accounts()
|
||||
.list(AccountListQuery {
|
||||
page: 1,
|
||||
@@ -1293,6 +1384,83 @@ async fn accounts_list_should_return_complete_directory_semantics() {
|
||||
account.projection.status,
|
||||
gateway_admin::model::accounts::AccountStatus::Normal
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
account.account.plan_type.as_deref(),
|
||||
account.plan_type_display.as_deref(),
|
||||
),
|
||||
(
|
||||
Some(" self_serve_business_prolite "),
|
||||
Some("openai display: self_serve_business_prolite"),
|
||||
)
|
||||
);
|
||||
|
||||
let detail = services
|
||||
.accounts()
|
||||
.quota(
|
||||
&ProviderAccountId::new("acct_test").expect("account ID"),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect("account detail");
|
||||
assert_eq!(detail.plan_type_display, account.plan_type_display);
|
||||
assert_eq!(detail.account.plan_type, account.account.plan_type);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_should_fill_missing_plan_from_quota_without_overriding_known_subtypes() {
|
||||
for (stored_plan, quota_plan, expected) in [
|
||||
(None, Some("free"), Some("free")),
|
||||
(Some(" "), Some("free"), Some("free")),
|
||||
(Some("unknown"), Some("free"), Some("free")),
|
||||
(
|
||||
Some("self_serve_business_prolite"),
|
||||
Some("team"),
|
||||
Some("self_serve_business_prolite"),
|
||||
),
|
||||
(None, None, None),
|
||||
] {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
provider.set_quota(ProviderQuota {
|
||||
plan_type: quota_plan.map(str::to_owned),
|
||||
..empty_quota()
|
||||
});
|
||||
let mut stored = account_record("openai");
|
||||
stored.plan_type = stored_plan.map(str::to_owned);
|
||||
let store = FakeAccountStore::with_account(stored, events());
|
||||
let services = accounts_service(provider, store).await;
|
||||
let page = services
|
||||
.accounts()
|
||||
.list(AccountListQuery {
|
||||
page: 1,
|
||||
page_size: gateway_admin::model::PageSize::new(20).expect("page size"),
|
||||
provider_kind: None,
|
||||
group_filter: None,
|
||||
search: None,
|
||||
status: None,
|
||||
sort: None,
|
||||
})
|
||||
.await
|
||||
.expect("account list");
|
||||
let account = page.items.first().expect("account item");
|
||||
assert_eq!(account.account.plan_type.as_deref(), expected);
|
||||
assert_eq!(
|
||||
account.plan_type_display,
|
||||
expected.map(|plan| format!("openai display: {plan}"))
|
||||
);
|
||||
for refresh in [false, true] {
|
||||
let detail = services
|
||||
.accounts()
|
||||
.quota(
|
||||
&ProviderAccountId::new("acct_test").expect("account ID"),
|
||||
refresh,
|
||||
)
|
||||
.await
|
||||
.expect("account quota detail");
|
||||
assert_eq!(detail.account.plan_type.as_deref(), expected);
|
||||
assert_eq!(detail.plan_type_display, account.plan_type_display);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1300,6 +1468,7 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
|
||||
let events = events();
|
||||
let openai = FakeProviderAdmin::new("openai", events.clone());
|
||||
openai.set_quota(ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: Some(Utc::now()),
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![ProviderQuotaWindow {
|
||||
@@ -1431,6 +1600,7 @@ async fn accounts_list_should_map_unknown_credential_to_error_not_normal() {
|
||||
async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
provider.set_quota(ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: Some(Utc::now()),
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![ProviderQuotaWindow {
|
||||
@@ -1478,6 +1648,7 @@ async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view()
|
||||
async fn accounts_list_should_not_derive_exhaustion_from_provider_quota_view() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
provider.set_quota(ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: Some(Utc::now()),
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![ProviderQuotaWindow {
|
||||
@@ -1526,6 +1697,7 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let reset_at = Utc::now() + TimeDelta::hours(1);
|
||||
provider.set_quota(ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: Some(Utc::now()),
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![ProviderQuotaWindow {
|
||||
@@ -1592,6 +1764,7 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let reset_at = Utc::now() + TimeDelta::days(7);
|
||||
provider.set_quota(ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: Some(Utc::now()),
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![
|
||||
@@ -1846,6 +2019,7 @@ pub(super) fn document() -> ProviderDocument {
|
||||
|
||||
fn empty_quota() -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: Vec::new(),
|
||||
@@ -1880,6 +2054,7 @@ fn quota_local_usage(account_id: &str, total_tokens: u64) -> AccountUsage {
|
||||
pub(super) fn account_record(kind: &str) -> AccountRecord {
|
||||
let now = Utc::now();
|
||||
AccountRecord {
|
||||
outbound_proxy: None,
|
||||
id: "acct_test".to_owned(),
|
||||
provider_kind: ProviderKind::new(kind).expect("provider kind"),
|
||||
groups: Vec::new(),
|
||||
@@ -1921,6 +2096,7 @@ fn prepared_create_with_id(
|
||||
) -> PreparedCredentialCreate {
|
||||
let now = Utc::now();
|
||||
PreparedCredentialCreate {
|
||||
outbound_proxy: None,
|
||||
account_id: ProviderAccountId::new(account_id).expect("prepared account ID"),
|
||||
provider_kind,
|
||||
name: name.to_owned(),
|
||||
@@ -2047,3 +2223,17 @@ fn store_unavailable() -> AdminStoreError {
|
||||
fn unsupported() -> ProviderAdminError {
|
||||
ProviderAdminError::new(ProviderAdminErrorKind::Unsupported)
|
||||
}
|
||||
|
||||
pub(super) fn import_settings() -> gateway_admin::model::accounts::AccountImportSettings {
|
||||
gateway_admin::model::accounts::AccountImportSettings {
|
||||
enabled: false,
|
||||
concurrency_limit: Some(
|
||||
gateway_core::account::AccountConcurrencyLimit::new(3).expect("concurrency"),
|
||||
),
|
||||
weight: gateway_core::account::AccountWeight::new(7).expect("weight"),
|
||||
group_ids: vec![
|
||||
gateway_core::routing::AccountGroupId::new("grp_00000000000000000000000000000091")
|
||||
.expect("group ID"),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,13 +536,14 @@ impl ObservabilityStore for UnavailableStore {
|
||||
Err(unavailable("usage trend"))
|
||||
}
|
||||
|
||||
async fn usage_calculated_billing_facts(
|
||||
fn usage_calculated_billing_facts(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<gateway_admin::model::observability::UsageCalculatedBillingFact>>
|
||||
{
|
||||
Err(unavailable("usage billing facts"))
|
||||
) -> gateway_admin::ports::store::UsageCalculatedBillingStream<'_> {
|
||||
Box::pin(futures::stream::once(async {
|
||||
Err(unavailable("usage billing facts"))
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_usage_records(&self, _: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::{
|
||||
str::FromStr as _,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration as StdDuration,
|
||||
};
|
||||
@@ -537,10 +537,41 @@ async fn usage_records_should_enrich_provider_reported_totals_when_pricing_match
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_insights_should_reject_partial_costs_when_billing_stream_fails() {
|
||||
let now = Utc::now();
|
||||
let range = observation_range(now);
|
||||
let store = Arc::new(FixtureObservabilityStore::new(range));
|
||||
store.replace_calculated_billing_facts(vec![UsageCalculatedBillingFact {
|
||||
bucket_start: quarter_hour_start(now),
|
||||
provider_kind: "openai".to_owned(),
|
||||
upstream_model_id: "gpt-5.5".to_owned(),
|
||||
service_tier: None,
|
||||
input_tokens: Some(800),
|
||||
output_tokens: Some(200),
|
||||
cached_tokens: Some(0),
|
||||
cache_write_tokens: Some(0),
|
||||
total: CurrencyCost {
|
||||
currency: "USD".to_owned(),
|
||||
amount: "1.25".parse().expect("cost"),
|
||||
},
|
||||
}]);
|
||||
store.billing_stream_fails.store(true, Ordering::SeqCst);
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
assert!(
|
||||
services
|
||||
.observability()
|
||||
.usage_insights(range, UsageFilter::default())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
struct FixtureObservabilityStore {
|
||||
trend: Mutex<Vec<RequestMetricPoint>>,
|
||||
overview: Mutex<UsageOverview>,
|
||||
calculated_billing_facts: Mutex<Vec<UsageCalculatedBillingFact>>,
|
||||
billing_stream_fails: AtomicBool,
|
||||
diagnostics: Mutex<Vec<DiagnosticObservation>>,
|
||||
runtime_slots: Mutex<Option<DashboardRuntimeSlots>>,
|
||||
summary_observed_at: Mutex<Option<DateTime<Utc>>>,
|
||||
@@ -561,6 +592,7 @@ impl FixtureObservabilityStore {
|
||||
providers: Vec::new(),
|
||||
}),
|
||||
calculated_billing_facts: Mutex::new(Vec::new()),
|
||||
billing_stream_fails: AtomicBool::new(false),
|
||||
diagnostics: Mutex::new(Vec::new()),
|
||||
runtime_slots: Mutex::new(None),
|
||||
summary_observed_at: Mutex::new(None),
|
||||
@@ -663,16 +695,23 @@ impl ObservabilityStore for FixtureObservabilityStore {
|
||||
Ok(self.trend.lock().expect("trend").clone())
|
||||
}
|
||||
|
||||
async fn usage_calculated_billing_facts(
|
||||
fn usage_calculated_billing_facts(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<UsageCalculatedBillingFact>> {
|
||||
Ok(self
|
||||
) -> gateway_admin::ports::store::UsageCalculatedBillingStream<'_> {
|
||||
let facts = self
|
||||
.calculated_billing_facts
|
||||
.lock()
|
||||
.expect("calculated billing facts")
|
||||
.clone())
|
||||
.clone();
|
||||
let error = self
|
||||
.billing_stream_fails
|
||||
.load(Ordering::SeqCst)
|
||||
.then(|| Err(super::unavailable("billing stream failed")));
|
||||
Box::pin(futures::stream::iter(
|
||||
facts.into_iter().map(Ok).chain(error),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
|
||||
@@ -50,6 +50,7 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
|
||||
services
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
settings: Some(super::accounts::import_settings()),
|
||||
context: context("import-openai"),
|
||||
document: document(),
|
||||
})
|
||||
@@ -75,9 +76,51 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
|
||||
rolling_usage: None,
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
store.import_settings(),
|
||||
[Some(super::accounts::import_settings())]
|
||||
);
|
||||
assert_eq!(store.audit_requests(), ["import-openai"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_import_should_expose_only_explicit_public_errors_without_committing() {
|
||||
use gateway_admin::model::AdminErrorKind;
|
||||
|
||||
for (provider_kind, admin_kind) in [
|
||||
(ProviderAdminErrorKind::Invalid, AdminErrorKind::Invalid),
|
||||
(
|
||||
ProviderAdminErrorKind::Unavailable,
|
||||
AdminErrorKind::Unavailable,
|
||||
),
|
||||
(
|
||||
ProviderAdminErrorKind::BadGateway,
|
||||
AdminErrorKind::BadGateway,
|
||||
),
|
||||
] {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
provider
|
||||
.fail_next_with_public_message(provider_kind, "Codex PAT 验证失败,请检查令牌后重试");
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = service(provider, store.clone()).await;
|
||||
let error = services
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
settings: None,
|
||||
context: context("import-openai-pat-failure"),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
.expect_err("validation must fail before committing accounts");
|
||||
assert_eq!(error.kind(), admin_kind);
|
||||
assert_eq!(error.to_string(), "Codex PAT 验证失败,请检查令牌后重试");
|
||||
assert!(!format!("{error:?}").contains("secret token"));
|
||||
assert_eq!(recorded(&events), ["provider.prepare_import"]);
|
||||
assert!(store.audit_requests().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_import_should_refresh_quota_for_every_imported_account() {
|
||||
let events = events();
|
||||
@@ -89,6 +132,7 @@ async fn openai_import_should_refresh_quota_for_every_imported_account() {
|
||||
let result = services
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
settings: None,
|
||||
context: context("import-openai-batch"),
|
||||
document: document(),
|
||||
})
|
||||
@@ -132,6 +176,7 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() {
|
||||
let result = services
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
settings: None,
|
||||
context: context("import-openai-quota-failure"),
|
||||
document: document(),
|
||||
})
|
||||
@@ -157,6 +202,7 @@ async fn openai_authorization_create_should_observe_initial_quota() {
|
||||
services
|
||||
.openai()
|
||||
.start_authorization(StartAuthorization {
|
||||
outbound_proxy: None,
|
||||
context: context("oauth-start-openai-create"),
|
||||
name: "new OpenAI credential".to_owned(),
|
||||
reauthorization: None,
|
||||
@@ -166,6 +212,7 @@ async fn openai_authorization_create_should_observe_initial_quota() {
|
||||
services
|
||||
.openai()
|
||||
.complete_authorization(CompleteAuthorization {
|
||||
settings: Some(super::accounts::import_settings()),
|
||||
context: context("oauth-start-openai-create"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
@@ -193,6 +240,10 @@ async fn openai_authorization_create_should_observe_initial_quota() {
|
||||
rolling_usage: None,
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
store.import_settings(),
|
||||
[Some(super::accounts::import_settings())]
|
||||
);
|
||||
assert_eq!(store.audit_requests(), ["oauth-start-openai-create"]);
|
||||
}
|
||||
|
||||
@@ -207,6 +258,7 @@ async fn openai_authorization_create_should_remain_successful_when_initial_quota
|
||||
services
|
||||
.openai()
|
||||
.start_authorization(StartAuthorization {
|
||||
outbound_proxy: None,
|
||||
context: context("oauth-start-openai-quota-failure"),
|
||||
name: "new OpenAI credential".to_owned(),
|
||||
reauthorization: None,
|
||||
@@ -216,6 +268,7 @@ async fn openai_authorization_create_should_remain_successful_when_initial_quota
|
||||
let result = services
|
||||
.openai()
|
||||
.complete_authorization(CompleteAuthorization {
|
||||
settings: None,
|
||||
context: context("oauth-start-openai-quota-failure"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
@@ -238,6 +291,7 @@ async fn openai_authorization_store_failure_should_release_claim_for_retry() {
|
||||
store.fail_next_commit();
|
||||
let services = service(provider.clone(), store).await;
|
||||
let command = || CompleteAuthorization {
|
||||
settings: None,
|
||||
context: context("oauth-store-retry"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
@@ -246,6 +300,7 @@ async fn openai_authorization_store_failure_should_release_claim_for_retry() {
|
||||
services
|
||||
.openai()
|
||||
.start_authorization(StartAuthorization {
|
||||
outbound_proxy: None,
|
||||
context: context("oauth-store-retry"),
|
||||
name: "new OpenAI credential".to_owned(),
|
||||
reauthorization: None,
|
||||
@@ -293,6 +348,7 @@ async fn openai_import_provider_error_should_not_touch_store_transaction() {
|
||||
services
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
settings: None,
|
||||
context: context("import-openai-error"),
|
||||
document: document(),
|
||||
})
|
||||
@@ -311,6 +367,7 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances
|
||||
services
|
||||
.openai()
|
||||
.start_authorization(StartAuthorization {
|
||||
outbound_proxy: None,
|
||||
context: context("oauth-start-openai"),
|
||||
name: "reauthorize".to_owned(),
|
||||
reauthorization: Some(ProviderAccountId::new("acct_test").expect("account ID")),
|
||||
@@ -336,6 +393,7 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances
|
||||
let result = services
|
||||
.openai()
|
||||
.complete_authorization(CompleteAuthorization {
|
||||
settings: None,
|
||||
context: context("oauth-complete-openai"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
@@ -387,3 +445,34 @@ fn deletion(account_id: &str) -> CredentialDeletion {
|
||||
account_ids: vec![ProviderAccountId::new(account_id).expect("account ID")],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reauthorization_with_import_settings_releases_claim_without_committing() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = service(provider, store.clone()).await;
|
||||
services
|
||||
.openai()
|
||||
.start_authorization(StartAuthorization {
|
||||
outbound_proxy: None,
|
||||
context: context("reauthorization-settings"),
|
||||
name: "existing account".to_owned(),
|
||||
reauthorization: Some(ProviderAccountId::new("acct_test").expect("account ID")),
|
||||
})
|
||||
.await
|
||||
.expect("start reauthorization");
|
||||
let error = services
|
||||
.openai()
|
||||
.complete_authorization(CompleteAuthorization {
|
||||
settings: Some(super::accounts::import_settings()),
|
||||
context: context("reauthorization-settings"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
})
|
||||
.await
|
||||
.expect_err("reauthorization must preserve settings");
|
||||
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Invalid);
|
||||
assert!(!recorded(&events).contains(&"store.commit_authorization"));
|
||||
assert!(store.import_settings().is_empty());
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ async fn xai_import_should_prepare_before_atomic_store_commit() {
|
||||
services
|
||||
.xai()
|
||||
.import_document(ImportCredentials {
|
||||
settings: Some(super::accounts::import_settings()),
|
||||
context: context("import-xai"),
|
||||
document: document(),
|
||||
})
|
||||
@@ -42,6 +43,10 @@ async fn xai_import_should_prepare_before_atomic_store_commit() {
|
||||
rolling_usage: None,
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
store.import_settings(),
|
||||
[Some(super::accounts::import_settings())]
|
||||
);
|
||||
assert_eq!(store.audit_requests(), ["import-xai"]);
|
||||
}
|
||||
|
||||
@@ -56,6 +61,7 @@ async fn xai_import_should_refresh_quota_for_every_imported_account() {
|
||||
services
|
||||
.xai()
|
||||
.import_document(ImportCredentials {
|
||||
settings: None,
|
||||
context: context("import-xai-batch"),
|
||||
document: document(),
|
||||
})
|
||||
@@ -90,6 +96,7 @@ async fn xai_import_should_remain_successful_when_quota_refresh_fails() {
|
||||
let result = services
|
||||
.xai()
|
||||
.import_document(ImportCredentials {
|
||||
settings: None,
|
||||
context: context("import-xai-quota-failure"),
|
||||
document: document(),
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name = "gateway-api"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
license = "Apache-2.0"
|
||||
publish = false
|
||||
rust-version = "1.97"
|
||||
autotests = false
|
||||
|
||||
@@ -18,16 +18,51 @@ impl AccountProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// 导入统一设置,复用编辑账号的调度和分组约束。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountImportSettingsRequest {
|
||||
pub enabled: bool,
|
||||
#[serde(deserialize_with = "deserialize_required_nullable")]
|
||||
pub concurrency_limit: Option<u64>,
|
||||
pub weight: u64,
|
||||
pub group_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl AccountImportSettingsRequest {
|
||||
fn validate(&self) -> Result<(), WireValidationError> {
|
||||
parse_concurrency_limit(self.concurrency_limit)?;
|
||||
parse_account_weight(self.weight)?;
|
||||
validate_wire_group_ids(&self.group_ids)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn into_settings(
|
||||
self,
|
||||
) -> Result<gateway_admin::model::accounts::AccountImportSettings, WireValidationError> {
|
||||
Ok(gateway_admin::model::accounts::AccountImportSettings {
|
||||
enabled: self.enabled,
|
||||
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
|
||||
weight: parse_account_weight(self.weight)?,
|
||||
group_ids: validate_wire_group_ids(&self.group_ids)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider-owned 账号导入请求;公共 API 不解释 `data` 内部字段。
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountImportRequest {
|
||||
pub settings: Option<AccountImportSettingsRequest>,
|
||||
pub provider: String,
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
impl AccountImportRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
if let Some(settings) = &self.settings {
|
||||
settings.validate()?;
|
||||
}
|
||||
AccountProvider::parse(&self.provider)?;
|
||||
if !self.data.is_object()
|
||||
|| serde_json::to_vec(&self.data)
|
||||
@@ -47,6 +82,10 @@ impl AccountImportRequest {
|
||||
Ok((
|
||||
provider,
|
||||
ImportCredentials {
|
||||
settings: self
|
||||
.settings
|
||||
.map(AccountImportSettingsRequest::into_settings)
|
||||
.transpose()?,
|
||||
context,
|
||||
document: provider_document(self.data, "data")?,
|
||||
},
|
||||
@@ -60,6 +99,7 @@ pub struct StartAccountAuthorizationRequest {
|
||||
pub provider: String,
|
||||
pub name: String,
|
||||
pub account_id: Option<String>,
|
||||
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
|
||||
}
|
||||
|
||||
impl StartAccountAuthorizationRequest {
|
||||
@@ -89,6 +129,7 @@ impl StartAccountAuthorizationRequest {
|
||||
context,
|
||||
name: self.name,
|
||||
reauthorization,
|
||||
outbound_proxy: self.outbound_proxy_url.and_then(|value| value.0),
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -97,6 +138,7 @@ impl StartAccountAuthorizationRequest {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CompleteAccountAuthorizationRequest {
|
||||
pub settings: Option<AccountImportSettingsRequest>,
|
||||
pub provider: String,
|
||||
pub flow_id: String,
|
||||
pub callback_url: String,
|
||||
@@ -104,6 +146,9 @@ pub struct CompleteAccountAuthorizationRequest {
|
||||
|
||||
impl CompleteAccountAuthorizationRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
if let Some(settings) = &self.settings {
|
||||
settings.validate()?;
|
||||
}
|
||||
let provider = AccountProvider::parse(&self.provider)?;
|
||||
match provider {
|
||||
AccountProvider::OpenAi => {
|
||||
@@ -128,6 +173,10 @@ impl CompleteAccountAuthorizationRequest {
|
||||
Ok((
|
||||
provider,
|
||||
CompleteAuthorization {
|
||||
settings: self
|
||||
.settings
|
||||
.map(AccountImportSettingsRequest::into_settings)
|
||||
.transpose()?,
|
||||
context,
|
||||
flow_id: self.flow_id,
|
||||
callback_url: self.callback_url,
|
||||
@@ -139,6 +188,7 @@ impl CompleteAccountAuthorizationRequest {
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateAccountRequest {
|
||||
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
|
||||
pub account_id: String,
|
||||
pub enabled: bool,
|
||||
#[serde(deserialize_with = "deserialize_required_nullable")]
|
||||
@@ -159,6 +209,7 @@ impl UpdateAccountRequest {
|
||||
pub(super) fn into_command(self) -> Result<UpdateAccount, WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok(UpdateAccount {
|
||||
outbound_proxy: self.outbound_proxy_url.map(|value| value.0),
|
||||
account_id: self.account_id,
|
||||
enabled: self.enabled,
|
||||
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
|
||||
|
||||
@@ -70,6 +70,7 @@ mod wire;
|
||||
|
||||
pub use credentials::*;
|
||||
pub use handlers::{profile_avatar_response, router};
|
||||
pub(super) use presenter::quota_window_view;
|
||||
pub use wire::*;
|
||||
|
||||
use credentials::{
|
||||
|
||||
@@ -59,6 +59,7 @@ pub(super) fn account_models_data(result: ProviderModels) -> AccountModelsData {
|
||||
pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> AccountView {
|
||||
let AccountDirectoryItem {
|
||||
account,
|
||||
plan_type_display,
|
||||
projection,
|
||||
usage,
|
||||
quota,
|
||||
@@ -89,6 +90,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
|
||||
user_id: account.upstream_user_id,
|
||||
label: None,
|
||||
plan_type: account.plan_type,
|
||||
plan_type_display: plan_type_display.unwrap_or_else(|| "未知套餐".to_owned()),
|
||||
authentication_kind: account.authentication_kind,
|
||||
has_refresh_token: account.has_refresh_token,
|
||||
status,
|
||||
@@ -99,6 +101,10 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
|
||||
enabled: account.enabled,
|
||||
concurrency_limit: account.concurrency_limit.map(|limit| limit.get()),
|
||||
weight: account.weight.get(),
|
||||
outbound_proxy_endpoint: account
|
||||
.outbound_proxy
|
||||
.as_ref()
|
||||
.map(|proxy| proxy.endpoint()),
|
||||
access_token_expires_at: expires_at,
|
||||
access_token_expires_at_display: account
|
||||
.access_token_expires_at
|
||||
@@ -140,7 +146,7 @@ pub(super) fn account_quota_view(
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView {
|
||||
pub(crate) fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView {
|
||||
let ProviderQuotaWindow {
|
||||
key,
|
||||
group,
|
||||
@@ -230,6 +236,8 @@ pub(super) fn account_usage_view(
|
||||
output_tokens_display: display_optional_tokens(usage.output_tokens),
|
||||
cached_tokens: usage.cached_tokens,
|
||||
cached_tokens_display: display_optional_tokens(usage.cached_tokens),
|
||||
reasoning_tokens: usage.reasoning_tokens,
|
||||
reasoning_tokens_display: display_optional_tokens(usage.reasoning_tokens),
|
||||
image_input_tokens: usage.image_input_tokens,
|
||||
image_input_tokens_display: display_optional_tokens(usage.image_input_tokens),
|
||||
image_output_tokens: usage.image_output_tokens,
|
||||
@@ -347,6 +355,8 @@ pub(super) fn empty_account_usage() -> AccountUsageView {
|
||||
output_tokens_display: "—".to_owned(),
|
||||
cached_tokens: None,
|
||||
cached_tokens_display: "—".to_owned(),
|
||||
reasoning_tokens: None,
|
||||
reasoning_tokens_display: "—".to_owned(),
|
||||
image_input_tokens: None,
|
||||
image_input_tokens_display: "—".to_owned(),
|
||||
image_output_tokens: None,
|
||||
|
||||
@@ -2,6 +2,21 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccountProxyUpdate(pub Option<gateway_core::account::OutboundProxy>);
|
||||
|
||||
impl<'de> Deserialize<'de> for AccountProxyUpdate {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
if value.is_empty() {
|
||||
return Ok(Self(None));
|
||||
}
|
||||
gateway_core::account::OutboundProxy::parse(&value)
|
||||
.map(|proxy| Self(Some(proxy)))
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号列表查询参数。
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
@@ -19,6 +34,7 @@ pub struct ListQuery {
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct BatchUpdateAccountsRequest {
|
||||
pub outbound_proxy_url: Option<AccountProxyUpdate>,
|
||||
pub account_ids: Vec<String>,
|
||||
pub enabled: bool,
|
||||
#[serde(deserialize_with = "deserialize_required_nullable")]
|
||||
@@ -68,6 +84,7 @@ impl BatchUpdateAccountsRequest {
|
||||
pub(super) fn into_command(self) -> Result<BatchUpdateAccounts, WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok(BatchUpdateAccounts {
|
||||
outbound_proxy: self.outbound_proxy_url.map(|value| value.0),
|
||||
account_ids: self.account_ids,
|
||||
enabled: self.enabled,
|
||||
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
|
||||
@@ -192,6 +209,7 @@ pub struct AccountSummaryView {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountView {
|
||||
pub outbound_proxy_endpoint: Option<String>,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub provider: String,
|
||||
@@ -202,6 +220,8 @@ pub struct AccountView {
|
||||
pub user_id: Option<String>,
|
||||
pub label: Option<String>,
|
||||
pub plan_type: Option<String>,
|
||||
/// 后端生成的套餐展示名称;缺失套餐时为“未知套餐”。
|
||||
pub plan_type_display: String,
|
||||
pub authentication_kind: String,
|
||||
pub has_refresh_token: bool,
|
||||
pub status: String,
|
||||
@@ -276,6 +296,8 @@ pub struct AccountUsageView {
|
||||
pub output_tokens_display: String,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cached_tokens_display: String,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub reasoning_tokens_display: String,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_input_tokens_display: String,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
|
||||
@@ -11,6 +11,8 @@ use gateway_admin::model::client_keys::{
|
||||
CreatedClientKey, DeleteClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
|
||||
};
|
||||
use gateway_core::{
|
||||
engine::budget::ClientBudgetLimits,
|
||||
metering::Decimal,
|
||||
policy::{ClientApiKeyId, RateLimits},
|
||||
routing::AccountGroupId,
|
||||
};
|
||||
@@ -33,6 +35,15 @@ const MAX_CURSOR_BYTES: usize = 512;
|
||||
const MAX_SEARCH_BYTES: usize = 256;
|
||||
const DEFAULT_PAGE_SIZE: u16 = 50;
|
||||
|
||||
fn parse_budget(
|
||||
value: Option<String>,
|
||||
field: &'static str,
|
||||
) -> Result<Option<Decimal>, WireValidationError> {
|
||||
value
|
||||
.map(|value| value.parse().map_err(|_| WireValidationError::new(field)))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Client Key 列表查询。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
@@ -140,6 +151,8 @@ pub struct CreateClientKeyRequest {
|
||||
group_ids: Vec<String>,
|
||||
max_concurrency: u64,
|
||||
requests_per_minute: u64,
|
||||
daily_limit_usd: Option<String>,
|
||||
weekly_limit_usd: Option<String>,
|
||||
}
|
||||
|
||||
impl CreateClientKeyRequest {
|
||||
@@ -154,6 +167,11 @@ impl CreateClientKeyRequest {
|
||||
name: self.name,
|
||||
label: self.label,
|
||||
group_ids,
|
||||
budget: ClientBudgetLimits {
|
||||
daily_usd: parse_budget(self.daily_limit_usd, "dailyLimitUsd")?.unwrap_or_default(),
|
||||
weekly_usd: parse_budget(self.weekly_limit_usd, "weeklyLimitUsd")?
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
limits: RateLimits {
|
||||
max_concurrency: self.max_concurrency,
|
||||
requests_per_minute: self.requests_per_minute,
|
||||
@@ -172,6 +190,8 @@ pub struct UpdateClientKeyRequest {
|
||||
group_ids: Vec<String>,
|
||||
max_concurrency: u64,
|
||||
requests_per_minute: u64,
|
||||
daily_limit_usd: Option<String>,
|
||||
weekly_limit_usd: Option<String>,
|
||||
}
|
||||
|
||||
impl UpdateClientKeyRequest {
|
||||
@@ -188,6 +208,8 @@ impl UpdateClientKeyRequest {
|
||||
name: self.name,
|
||||
label: self.label,
|
||||
group_ids,
|
||||
daily_limit_usd: parse_budget(self.daily_limit_usd, "dailyLimitUsd")?,
|
||||
weekly_limit_usd: parse_budget(self.weekly_limit_usd, "weeklyLimitUsd")?,
|
||||
limits: RateLimits {
|
||||
max_concurrency: self.max_concurrency,
|
||||
requests_per_minute: self.requests_per_minute,
|
||||
@@ -247,6 +269,13 @@ pub struct ClientKeyView {
|
||||
enabled: bool,
|
||||
max_concurrency: u64,
|
||||
requests_per_minute: u64,
|
||||
daily_limit_usd: String,
|
||||
weekly_limit_usd: String,
|
||||
daily_used_usd: String,
|
||||
weekly_used_usd: String,
|
||||
daily_resets_at: Option<DateTime<Utc>>,
|
||||
weekly_resets_at: Option<DateTime<Utc>>,
|
||||
unresolved_requests: u64,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
last_used_at: Option<DateTime<Utc>>,
|
||||
@@ -292,6 +321,13 @@ impl From<ClientKeyRecord> for ClientKeyView {
|
||||
enabled: record.enabled,
|
||||
max_concurrency: record.limits.max_concurrency,
|
||||
requests_per_minute: record.limits.requests_per_minute,
|
||||
daily_limit_usd: record.budget.limits.daily_usd.canonical(),
|
||||
weekly_limit_usd: record.budget.limits.weekly_usd.canonical(),
|
||||
daily_used_usd: record.budget.daily_used_usd.canonical(),
|
||||
weekly_used_usd: record.budget.weekly_used_usd.canonical(),
|
||||
daily_resets_at: record.budget.daily_resets_at.map(DateTime::from),
|
||||
weekly_resets_at: record.budget.weekly_resets_at.map(DateTime::from),
|
||||
unresolved_requests: record.budget.unresolved_requests,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
last_used_at: record.last_used_at,
|
||||
@@ -643,6 +679,14 @@ where
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/client-keys", get(list_client_keys::<S>))
|
||||
.route(
|
||||
"/api/admin/client-keys/unresolved-charges",
|
||||
get(unresolved_client_charges::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/client-keys/reconcile-charge",
|
||||
post(reconcile_client_charge::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/client-keys/create",
|
||||
post(create_client_key::<S>),
|
||||
@@ -666,6 +710,66 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct ReconcileClientChargeRequest {
|
||||
id: String,
|
||||
request_id: String,
|
||||
amount_usd: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
async fn unresolved_client_charges<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<ClientKeyIdQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let id = query.into_domain_id().map_err(map_wire_error)?;
|
||||
let items = state.admin_services().client_keys().unresolved_charges(&id).await.map_err(map_service_error)?
|
||||
.into_iter().map(|event| serde_json::json!({
|
||||
"requestId": event.request_id, "startedAt": event.started_at.to_rfc3339(),
|
||||
"completedAt": event.completed_at.map(|time| time.to_rfc3339()), "state": event.state,
|
||||
})).collect::<Vec<_>>();
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(serde_json::json!({"items": items})),
|
||||
))
|
||||
}
|
||||
|
||||
async fn reconcile_client_charge<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(payload): AdminJson<ReconcileClientChargeRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let key_id = client_key_id(payload.id, "id").map_err(map_wire_error)?;
|
||||
let amount_usd = payload
|
||||
.amount_usd
|
||||
.parse::<Decimal>()
|
||||
.map_err(|_| map_wire_error(WireValidationError::new("amountUsd")))?;
|
||||
let command = gateway_admin::model::client_keys::ReconcileClientCharge {
|
||||
key_id,
|
||||
request_id: payload.request_id,
|
||||
amount_usd,
|
||||
reason: payload.reason,
|
||||
};
|
||||
state
|
||||
.admin_services()
|
||||
.client_keys()
|
||||
.reconcile_charge(&auth.context().mutation_context(), command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(serde_json::json!({"reconciled": true})),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_client_keys<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
|
||||
@@ -213,13 +213,17 @@ fn capitalize_first(value: &str) -> String {
|
||||
|
||||
pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<BillingView> {
|
||||
match billing? {
|
||||
domain::UsageBilling::Total { total, .. } => Some(BillingView {
|
||||
domain::UsageBilling::Total { source, total } => Some(BillingView {
|
||||
input_amount_display: "—".to_owned(),
|
||||
output_amount_display: "—".to_owned(),
|
||||
cache_read_amount_display: "—".to_owned(),
|
||||
cache_write_amount_display: "—".to_owned(),
|
||||
standard_amount_display: "—".to_owned(),
|
||||
total_amount_display: format_money(total),
|
||||
total_amount_display: if source == "calculated" {
|
||||
format!("≈ {}", format_money(total))
|
||||
} else {
|
||||
format_money(total)
|
||||
},
|
||||
input_price_display: "—".to_owned(),
|
||||
output_price_display: "—".to_owned(),
|
||||
cache_read_price_display: "—".to_owned(),
|
||||
@@ -505,6 +509,8 @@ pub(crate) fn usage_attempt_view(attempt: domain::UsageAttempt) -> UsageAttemptV
|
||||
|
||||
pub(crate) fn usage_detail_view(detail: domain::UsageDetail) -> UsageRecordDetailView {
|
||||
UsageRecordDetailView {
|
||||
trace: detail.trace,
|
||||
related_requests: detail.related_requests,
|
||||
request: usage_record_view(detail.request),
|
||||
attempts: detail
|
||||
.attempts
|
||||
@@ -560,7 +566,6 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
|
||||
failure_class: error.failure_kind,
|
||||
upstream_send_state: error.upstream_send_state,
|
||||
provider_error_code: error.provider_error_code,
|
||||
occurrence_count: error.occurrence_count,
|
||||
response_id: error.client_response_id,
|
||||
upstream_request_id: error.upstream_request_id,
|
||||
latency_ms: error.latency_ms,
|
||||
@@ -864,6 +869,28 @@ pub(crate) fn dashboard_view(
|
||||
} = observation;
|
||||
let mut account_usage_views = Vec::with_capacity(account_usage.len());
|
||||
for credential in account_usage {
|
||||
let local_requests = credential
|
||||
.quota_window
|
||||
.as_ref()
|
||||
.filter(|window| window.used_percent.is_none())
|
||||
.and_then(|window| window.local_usage.as_ref())
|
||||
.map(|usage| usage.request_count);
|
||||
let (metric_label, metric_value) = match local_requests {
|
||||
Some(count) => (
|
||||
"次数".to_owned(),
|
||||
if count > 0 {
|
||||
format_compact_number(count)
|
||||
} else {
|
||||
"—".to_owned()
|
||||
},
|
||||
),
|
||||
None => (
|
||||
"今日 Token".to_owned(),
|
||||
credential
|
||||
.total_tokens
|
||||
.map_or_else(|| "—".to_owned(), format_compact_number),
|
||||
),
|
||||
};
|
||||
account_usage_views.push(DashboardAccountUsageView {
|
||||
id: credential.account_id.clone(),
|
||||
provider: credential.provider_kind.clone(),
|
||||
@@ -873,6 +900,10 @@ pub(crate) fn dashboard_view(
|
||||
.clone()
|
||||
.unwrap_or_else(|| credential.name.clone()),
|
||||
plan_type: credential.plan_type.clone(),
|
||||
plan_type_display: credential
|
||||
.plan_type_display
|
||||
.clone()
|
||||
.unwrap_or_else(|| "未知套餐".to_owned()),
|
||||
tokens: credential
|
||||
.total_tokens
|
||||
.map_or_else(|| "—".to_owned(), format_compact_number),
|
||||
@@ -886,6 +917,11 @@ pub(crate) fn dashboard_view(
|
||||
})
|
||||
.collect(),
|
||||
quota_used_percent: credential.quota_used_percent,
|
||||
usage_window: credential
|
||||
.quota_window
|
||||
.map(crate::admin::accounts::quota_window_view),
|
||||
metric_label,
|
||||
metric_value,
|
||||
last_used: relative_time(credential.last_used_at, range.end),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -247,6 +247,8 @@ pub struct UsageAttemptView {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageRecordDetailView {
|
||||
pub trace: Option<serde_json::Value>,
|
||||
pub related_requests: Vec<serde_json::Value>,
|
||||
#[serde(flatten)]
|
||||
pub request: UsageRecordView,
|
||||
pub attempts: Vec<UsageAttemptView>,
|
||||
@@ -371,10 +373,14 @@ pub struct DashboardAccountUsageView {
|
||||
pub authentication_kind: String,
|
||||
pub email: String,
|
||||
pub plan_type: Option<String>,
|
||||
pub plan_type_display: String,
|
||||
pub tokens: String,
|
||||
pub request_count: u64,
|
||||
pub request_buckets: Vec<DashboardAccountRequestBucketView>,
|
||||
pub quota_used_percent: Option<f64>,
|
||||
pub usage_window: Option<crate::admin::accounts::AccountQuotaWindowView>,
|
||||
pub metric_label: String,
|
||||
pub metric_value: String,
|
||||
pub last_used: String,
|
||||
}
|
||||
|
||||
@@ -778,7 +784,6 @@ pub struct OpsErrorView {
|
||||
pub failure_class: String,
|
||||
pub upstream_send_state: Option<String>,
|
||||
pub provider_error_code: Option<String>,
|
||||
pub occurrence_count: u32,
|
||||
pub response_id: Option<String>,
|
||||
pub upstream_request_id: Option<String>,
|
||||
pub latency_ms: Option<u64>,
|
||||
|
||||
@@ -180,13 +180,16 @@ pub fn initialize(
|
||||
"request",
|
||||
request_id = %request_id,
|
||||
method = %request.method().as_str(),
|
||||
uri = %request
|
||||
.uri()
|
||||
.path_and_query()
|
||||
.map(axum::http::uri::PathAndQuery::as_str)
|
||||
.unwrap_or_default(),
|
||||
uri = %request.uri().path(),
|
||||
)
|
||||
}
|
||||
})
|
||||
.on_request(|_request: &Request<_>, _span: &tracing::Span| {
|
||||
tracing::info!(target: "request_trace", stage = "http.received", "HTTP request received");
|
||||
})
|
||||
.on_response(|response: &axum::http::Response<_>, latency: Duration, _span: &tracing::Span| {
|
||||
tracing::info!(target: "request_trace", stage = "http.response", status = response.status().as_u16(),
|
||||
headers_ms = latency.as_millis(), "HTTP response headers ready");
|
||||
});
|
||||
let router = router
|
||||
.layer(PropagateRequestIdLayer::new(request_id_header.clone()))
|
||||
|
||||
@@ -143,13 +143,27 @@ pub fn gateway_error_from_engine(error: &EngineError) -> GatewayError {
|
||||
/// Gateway 错误的 OpenAI HTTP 表达。
|
||||
pub fn gateway_error_response(error: &GatewayError) -> Response {
|
||||
let (status, default_type, default_code) = gateway_error_contract(error.kind());
|
||||
openai_error_response(
|
||||
let mut response = openai_error_response(
|
||||
status,
|
||||
error.client_message(),
|
||||
error.client_error_type().unwrap_or(default_type),
|
||||
error.client_error_code().unwrap_or(default_code),
|
||||
)
|
||||
.into_response()
|
||||
.into_response();
|
||||
if let Some(delay) = error.retry_after()
|
||||
&& let Ok(value) = HeaderValue::from_str(
|
||||
&delay
|
||||
.as_secs()
|
||||
.saturating_add(u64::from(delay.subsec_nanos() > 0))
|
||||
.max(1)
|
||||
.to_string(),
|
||||
)
|
||||
{
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(axum::http::header::RETRY_AFTER, value);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
/// 在下游尚未提交时优先交付 Provider 的原始 HTTP 失败响应。
|
||||
|
||||
@@ -10,14 +10,14 @@ use axum::{
|
||||
};
|
||||
use gateway_core::error::{GatewayError, GatewayErrorKind};
|
||||
use gateway_core::operation::{ImageRequest, ImageRequestKind, Operation, RawJsonPayload};
|
||||
use serde_json::{Map, Value};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ApiState;
|
||||
use crate::openai::{
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
endpoint::collect_raw_json_response,
|
||||
error::gateway_error_response,
|
||||
provider_endpoint::collect_raw_json_response,
|
||||
responses::request_client_context,
|
||||
responses::{OpenAiRequestHeaders, request_client_context},
|
||||
};
|
||||
|
||||
const OPENAI_PROTOCOL: &str = "openai";
|
||||
@@ -95,7 +95,7 @@ fn image_operation(
|
||||
headers: &HeaderMap,
|
||||
kind: ImageRequestKind,
|
||||
) -> Result<Operation, GatewayError> {
|
||||
let mut context = Map::new();
|
||||
let mut context = OpenAiRequestHeaders::from_headers(headers).session_context();
|
||||
if let Some(turn_id) = headers
|
||||
.get("x-codex-image-turn-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! OpenAI 客户端协议 adapter。
|
||||
|
||||
pub mod auth;
|
||||
mod endpoint;
|
||||
pub mod error;
|
||||
pub mod images;
|
||||
pub mod models;
|
||||
mod provider_endpoint;
|
||||
pub mod responses;
|
||||
pub mod router;
|
||||
pub mod search;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::convert::Infallible;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
@@ -13,9 +14,10 @@ use axum::{
|
||||
},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use futures::stream;
|
||||
use gateway_core::engine::CommitRequirement;
|
||||
use futures::{StreamExt, stream};
|
||||
use gateway_core::diagnostics::TraceContext;
|
||||
use gateway_core::engine::execution::{ClientTransport, ExecutionSession, StartedExecution};
|
||||
use gateway_core::engine::{CommitRequirement, EngineError};
|
||||
use gateway_core::error::{GatewayError, GatewayErrorKind};
|
||||
use gateway_core::event::{ProviderEvent, ProviderResponseHeader};
|
||||
use gateway_core::lifecycle::ConnectionGuard;
|
||||
@@ -35,10 +37,13 @@ use super::{
|
||||
request::decode_request_with_headers,
|
||||
};
|
||||
|
||||
const SSE_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// `POST /v1/responses`。
|
||||
pub(crate) async fn responses(
|
||||
State(state): State<ApiState>,
|
||||
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
|
||||
ingress_id: Option<Extension<tower_http::request_id::RequestId>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
@@ -83,15 +88,25 @@ pub(crate) async fn responses(
|
||||
Ok(started) => started,
|
||||
Err(error) => return gateway_error_response(&error),
|
||||
};
|
||||
let trace = started.session.trace();
|
||||
trace.headers("client.request", serde_json::json!({
|
||||
"ingressRequestId": ingress_id.as_ref().and_then(|Extension(id)| id.header_value().to_str().ok()),
|
||||
}), headers.iter().map(|(name, value)| (name.as_str(), value.as_bytes())));
|
||||
trace.capture("client.request.body", &body);
|
||||
let request_id_header = HeaderValue::from_str(started.request_id.as_str()).ok();
|
||||
let StartedExecution {
|
||||
stream, session, ..
|
||||
} = started;
|
||||
if stream {
|
||||
let mut response = if stream {
|
||||
stream_execution_response(session, connection_guard).await
|
||||
} else {
|
||||
drop(connection_guard);
|
||||
collect_execution_response(session).await
|
||||
};
|
||||
if let Some(id) = request_id_header {
|
||||
response.headers_mut().insert("x-gateway-request-id", id);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
/// 从 socket 与标准转发头提取旧 Usage 页面使用的诊断事实。
|
||||
@@ -152,7 +167,8 @@ pub async fn collect_execution_response(session: Box<dyn ExecutionSession>) -> R
|
||||
let events = match session.collect_uncommitted().await {
|
||||
Ok(events) => events,
|
||||
Err(error) => {
|
||||
let response = engine_error_response(&error);
|
||||
let response_headers = session.response_headers().to_vec();
|
||||
let response = engine_error_response_with_headers(&error, &response_headers);
|
||||
return execution.record_response_status(response).await;
|
||||
}
|
||||
};
|
||||
@@ -168,6 +184,11 @@ pub async fn collect_execution_response(session: Box<dyn ExecutionSession>) -> R
|
||||
}
|
||||
};
|
||||
let response_headers = session.response_headers().to_vec();
|
||||
session.trace().record(
|
||||
"downstream.encoded",
|
||||
serde_json::json!({"transport": "http_json", "bytes": encoded.len()}),
|
||||
);
|
||||
session.trace().dump("downstream.body", &encoded);
|
||||
let response = json_body_response(encoded, &response_headers);
|
||||
|
||||
let Some(session) = execution.session_mut() else {
|
||||
@@ -178,7 +199,7 @@ pub async fn collect_execution_response(session: Box<dyn ExecutionSession>) -> R
|
||||
.await
|
||||
{
|
||||
execution.cancel_and_finalize().await;
|
||||
let response = engine_error_response(&error);
|
||||
let response = engine_error_response_with_headers(&error, &response_headers);
|
||||
return execution.record_response_status(response).await;
|
||||
}
|
||||
if !session.is_finalized() {
|
||||
@@ -246,6 +267,13 @@ fn apply_response_headers(
|
||||
response
|
||||
}
|
||||
|
||||
fn engine_error_response_with_headers(
|
||||
error: &EngineError,
|
||||
response_headers: &[ProviderResponseHeader],
|
||||
) -> Response {
|
||||
apply_response_headers(engine_error_response(error), response_headers)
|
||||
}
|
||||
|
||||
/// 编码首个 SSE frame 后提交下游,再持续驱动同一执行会话。
|
||||
pub async fn stream_execution_response(
|
||||
session: Box<dyn ExecutionSession>,
|
||||
@@ -267,7 +295,8 @@ pub async fn stream_execution_response(
|
||||
return response;
|
||||
}
|
||||
Err(error) => {
|
||||
let response = engine_error_response(&error);
|
||||
let response_headers = session.response_headers().to_vec();
|
||||
let response = engine_error_response_with_headers(&error, &response_headers);
|
||||
return execution.record_response_status(response).await;
|
||||
}
|
||||
};
|
||||
@@ -299,7 +328,7 @@ pub async fn stream_execution_response(
|
||||
.await
|
||||
{
|
||||
execution.cancel_and_finalize().await;
|
||||
let response = engine_error_response(&error);
|
||||
let response = engine_error_response_with_headers(&error, &response_headers);
|
||||
return execution.record_response_status(response).await;
|
||||
}
|
||||
let Some(session) = execution.into_session() else {
|
||||
@@ -309,9 +338,11 @@ pub async fn stream_execution_response(
|
||||
if state.encoder.is_completed() {
|
||||
state.finish_completed(Vec::new()).await;
|
||||
}
|
||||
let body = Body::from_stream(stream::unfold(state, |mut state| async move {
|
||||
let output = Box::pin(stream::unfold(state, |mut state| async move {
|
||||
loop {
|
||||
if let Some(chunk) = state.pending.pop_front() {
|
||||
state.trace.dump("downstream.chunk", &chunk);
|
||||
state.handed_off_bytes += chunk.len() as u64;
|
||||
return Some((Ok::<Bytes, Infallible>(chunk), state));
|
||||
}
|
||||
if state.output_finished {
|
||||
@@ -320,19 +351,35 @@ pub async fn stream_execution_response(
|
||||
state.advance().await;
|
||||
}
|
||||
}));
|
||||
// 保活只轮询已固定的输出 stream;不能取消并重建 advance/next_event future,
|
||||
// 否则一次心跳就可能丢失正在等待的上游事件或执行终态清理。
|
||||
let body = Body::from_stream(stream::unfold(output, |mut output| async move {
|
||||
let chunk = tokio::select! {
|
||||
biased;
|
||||
chunk = output.next() => chunk?,
|
||||
() = tokio::time::sleep(SSE_KEEPALIVE_INTERVAL) => {
|
||||
Ok(Bytes::from_static(b": keep-alive\n\n"))
|
||||
}
|
||||
};
|
||||
Some((chunk, output))
|
||||
}));
|
||||
event_stream_response(body, &response_headers)
|
||||
}
|
||||
|
||||
fn event_stream_response(body: Body, response_headers: &[ProviderResponseHeader]) -> Response {
|
||||
let mut response = Response::new(body);
|
||||
let mut response = apply_response_headers(Response::new(body), response_headers);
|
||||
*response.status_mut() = StatusCode::OK;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
|
||||
response.headers_mut().insert(
|
||||
CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache, no-transform"),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||
apply_response_headers(response, response_headers)
|
||||
.insert("x-accel-buffering", HeaderValue::from_static("no"));
|
||||
response
|
||||
}
|
||||
|
||||
fn internal_gateway_response(message: &'static str) -> Response {
|
||||
@@ -394,12 +441,18 @@ impl Drop for PendingExecution {
|
||||
if session.is_finalized() {
|
||||
return;
|
||||
}
|
||||
session.trace().record(
|
||||
"downstream.cancelled",
|
||||
serde_json::json!({"reason": "response_guard_dropped"}),
|
||||
);
|
||||
session.cancel();
|
||||
detach_finalize(session);
|
||||
}
|
||||
}
|
||||
|
||||
struct ResponsesStreamState {
|
||||
trace: TraceContext,
|
||||
handed_off_bytes: u64,
|
||||
session: Option<Box<dyn ExecutionSession>>,
|
||||
encoder: OpenAiResponsesEncoder,
|
||||
pending: VecDeque<Bytes>,
|
||||
@@ -416,6 +469,8 @@ impl ResponsesStreamState {
|
||||
connection_guard: Option<Box<dyn ConnectionGuard>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
trace: session.trace(),
|
||||
handed_off_bytes: 0,
|
||||
session: Some(session),
|
||||
encoder,
|
||||
pending: initial_frames.into_iter().collect(),
|
||||
@@ -571,6 +626,11 @@ impl ResponsesStreamState {
|
||||
|
||||
impl Drop for ResponsesStreamState {
|
||||
fn drop(&mut self) {
|
||||
self.trace.record("downstream.body.closed", serde_json::json!({
|
||||
"handedOffBytes": self.handed_off_bytes,
|
||||
"pendingChunks": self.pending.len(),
|
||||
"outputFinished": self.output_finished, "executionTerminal": self.execution_terminal,
|
||||
}));
|
||||
if self.execution_terminal {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const OPENAI_PROTOCOL: &str = "openai";
|
||||
const OPENAI_SUBAGENT_KEY: &str = "x-openai-subagent";
|
||||
const CODEX_TURN_METADATA_KEY: &str = "x-codex-turn-metadata";
|
||||
const PASSTHROUGH_HEADERS_CONTEXT_KEY: &str = "opaque_request_headers";
|
||||
const DOWNSTREAM_WEBSOCKET_CONNECTION_ID_CONTEXT_KEY: &str = "downstream_websocket_connection_id";
|
||||
|
||||
/// Responses 请求进入共享解码内核时的下游传输来源。
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -35,6 +36,7 @@ pub struct OpenAiRequestHeaders {
|
||||
include_timing_metrics: Option<String>,
|
||||
codex_window_id: Option<String>,
|
||||
parent_thread_id: Option<String>,
|
||||
downstream_websocket_connection_id: Option<String>,
|
||||
|
||||
conversation_id: Option<String>,
|
||||
session_id: Option<String>,
|
||||
@@ -60,6 +62,7 @@ impl OpenAiRequestHeaders {
|
||||
include_timing_metrics: header_string(headers, "x-responsesapi-include-timing-metrics"),
|
||||
codex_window_id: header_string(headers, "x-codex-window-id"),
|
||||
parent_thread_id: header_string(headers, "x-codex-parent-thread-id"),
|
||||
downstream_websocket_connection_id: None,
|
||||
conversation_id: header_string(headers, "conversation-id")
|
||||
.or_else(|| header_string(headers, "conversation_id")),
|
||||
session_id: header_string(headers, "session-id")
|
||||
@@ -74,12 +77,31 @@ impl OpenAiRequestHeaders {
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定代理为当前下游 WebSocket 分配的连接身份。
|
||||
#[must_use]
|
||||
pub(super) fn with_downstream_websocket_connection_id(
|
||||
mut self,
|
||||
connection_id: impl Into<String>,
|
||||
) -> Self {
|
||||
self.downstream_websocket_connection_id = Some(connection_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
fn apply_subagent(&self, body: &mut Map<String, Value>) {
|
||||
if let Some(subagent) = self.subagent.as_deref() {
|
||||
inject_subagent_metadata(body, subagent);
|
||||
}
|
||||
}
|
||||
|
||||
/// 独立端点只消费会话身份与 turn metadata,不携带 Responses 的连接状态。
|
||||
pub(crate) fn session_context(&self) -> Map<String, Value> {
|
||||
let mut context = Map::new();
|
||||
insert_protocol_context(&mut context, "session_id", self.session_id.as_ref());
|
||||
insert_protocol_context(&mut context, "thread_id", self.thread_id.as_ref());
|
||||
insert_protocol_context(&mut context, "turn_metadata", self.turn_metadata.as_ref());
|
||||
context
|
||||
}
|
||||
|
||||
fn protocol_context(
|
||||
&self,
|
||||
use_websocket: Option<bool>,
|
||||
@@ -109,6 +131,11 @@ impl OpenAiRequestHeaders {
|
||||
"parent_thread_id",
|
||||
self.parent_thread_id.as_ref(),
|
||||
);
|
||||
insert_protocol_context(
|
||||
&mut context,
|
||||
DOWNSTREAM_WEBSOCKET_CONNECTION_ID_CONTEXT_KEY,
|
||||
self.downstream_websocket_connection_id.as_ref(),
|
||||
);
|
||||
insert_protocol_context(
|
||||
&mut context,
|
||||
"conversation_id",
|
||||
@@ -465,6 +492,8 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
|
||||
// 下游鉴权和账号 cookie 绝不能成为上游账号身份。
|
||||
| "authorization"
|
||||
| "x-api-key"
|
||||
// Codex 的服务端托管认证标记只用于客户端能力判断,不代表上游身份。
|
||||
| "x-openai-actor-authorization"
|
||||
| "cookie"
|
||||
| "cookie2"
|
||||
| "chatgpt-account-id"
|
||||
|
||||
@@ -23,6 +23,7 @@ const OUTBOUND_COMMAND_BUFFER: usize = 32;
|
||||
const INBOUND_EVENT_BUFFER: usize = 32;
|
||||
const DOWNSTREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
||||
const CONNECTION_MAX_AGE: Duration = Duration::from_secs(60 * 60);
|
||||
const DOWNSTREAM_PING_INTERVAL: Duration = Duration::from_secs(25);
|
||||
|
||||
/// WebSocket pump 的写入与生命周期预算。
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -171,9 +172,27 @@ struct ConnectionStats {
|
||||
command_backpressure_count: AtomicU64,
|
||||
ping_received_count: AtomicU64,
|
||||
pong_written_count: AtomicU64,
|
||||
ping_written_count: AtomicU64,
|
||||
pong_received_count: AtomicU64,
|
||||
last_read_ms: AtomicU64,
|
||||
last_write_ms: AtomicU64,
|
||||
}
|
||||
|
||||
impl ConnectionStats {
|
||||
fn record_read(&self, opened_at: Instant) {
|
||||
self.last_read_ms.store(
|
||||
u64::try_from(opened_at.elapsed().as_millis()).unwrap_or(u64::MAX),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
|
||||
fn record_write(&self, opened_at: Instant) {
|
||||
self.last_write_ms.store(
|
||||
u64::try_from(opened_at.elapsed().as_millis()).unwrap_or(u64::MAX),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
|
||||
fn observe_command_queue(&self, sender: &mpsc::Sender<ConnectionCommand>) {
|
||||
let queued = OUTBOUND_COMMAND_BUFFER
|
||||
.saturating_sub(sender.capacity())
|
||||
@@ -195,6 +214,7 @@ pub struct ResponsesWebSocketConnection {
|
||||
expired: Arc<AtomicBool>,
|
||||
commands: Option<mpsc::Sender<ConnectionCommand>>,
|
||||
incoming: mpsc::Receiver<ConnectionEvent>,
|
||||
exited: oneshot::Receiver<PumpExitReason>,
|
||||
pump_task: Option<JoinHandle<()>>,
|
||||
stats: Arc<ConnectionStats>,
|
||||
config: ConnectionConfig,
|
||||
@@ -231,7 +251,16 @@ impl ResponsesWebSocketConnection {
|
||||
|
||||
/// 等待下一个需要业务层处理的客户端事件。
|
||||
pub async fn next_event(&mut self) -> Option<ConnectionEvent> {
|
||||
let event = self.incoming.recv().await;
|
||||
if let Some(reason) = self.exit_reason {
|
||||
return Some(ConnectionEvent::Exited(reason));
|
||||
}
|
||||
let event = tokio::select! {
|
||||
biased;
|
||||
reason = &mut self.exited => {
|
||||
Some(ConnectionEvent::Exited(reason.unwrap_or(PumpExitReason::PumpStopped)))
|
||||
}
|
||||
event = self.incoming.recv() => event,
|
||||
};
|
||||
if let Some(ConnectionEvent::Exited(reason)) = event.as_ref() {
|
||||
self.exit_reason.get_or_insert(*reason);
|
||||
} else if event.is_none() {
|
||||
@@ -240,6 +269,18 @@ impl ResponsesWebSocketConnection {
|
||||
event
|
||||
}
|
||||
|
||||
/// 等待连接退出,不消费留给后续串行请求的业务帧。
|
||||
pub async fn wait_for_exit(&mut self) -> PumpExitReason {
|
||||
if let Some(reason) = self.exit_reason {
|
||||
return reason;
|
||||
}
|
||||
let reason = (&mut self.exited)
|
||||
.await
|
||||
.unwrap_or(PumpExitReason::PumpStopped);
|
||||
self.exit_reason = Some(reason);
|
||||
reason
|
||||
}
|
||||
|
||||
/// 串行写入文本帧,并等待 pump 确认 transport 写入结果。
|
||||
///
|
||||
/// # Errors
|
||||
@@ -378,10 +419,15 @@ impl ResponsesWebSocketConnection {
|
||||
}
|
||||
|
||||
pub(super) fn log_summary(&self, request_count: u64) {
|
||||
let connection_age_ms = self.age().as_millis();
|
||||
let read_idle_ms = connection_age_ms
|
||||
.saturating_sub(u128::from(self.stats.last_read_ms.load(Ordering::Relaxed)));
|
||||
let write_idle_ms = connection_age_ms
|
||||
.saturating_sub(u128::from(self.stats.last_write_ms.load(Ordering::Relaxed)));
|
||||
tracing::info!(
|
||||
websocket_connection_id = %self.connection_id,
|
||||
request_count,
|
||||
connection_age_ms = self.age().as_millis(),
|
||||
connection_age_ms,
|
||||
pump_exit_reason = self
|
||||
.exit_reason
|
||||
.map_or("unknown", PumpExitReason::as_str),
|
||||
@@ -395,6 +441,10 @@ impl ResponsesWebSocketConnection {
|
||||
.load(Ordering::Relaxed),
|
||||
ping_received_count = self.stats.ping_received_count.load(Ordering::Relaxed),
|
||||
pong_written_count = self.stats.pong_written_count.load(Ordering::Relaxed),
|
||||
ping_written_count = self.stats.ping_written_count.load(Ordering::Relaxed),
|
||||
pong_received_count = self.stats.pong_received_count.load(Ordering::Relaxed),
|
||||
read_idle_ms,
|
||||
write_idle_ms,
|
||||
"Responses WebSocket disconnected"
|
||||
);
|
||||
}
|
||||
@@ -425,10 +475,12 @@ where
|
||||
let stats = Arc::new(ConnectionStats::default());
|
||||
let (command_tx, command_rx) = mpsc::channel(OUTBOUND_COMMAND_BUFFER);
|
||||
let (incoming_tx, incoming_rx) = mpsc::channel(INBOUND_EVENT_BUFFER);
|
||||
let (exit_tx, exit_rx) = oneshot::channel();
|
||||
let pump_task = tokio::spawn(run_pump(
|
||||
socket,
|
||||
command_rx,
|
||||
incoming_tx,
|
||||
exit_tx,
|
||||
Arc::clone(&connection_id),
|
||||
cancellation,
|
||||
opened_at,
|
||||
@@ -442,6 +494,7 @@ where
|
||||
expired,
|
||||
commands: Some(command_tx),
|
||||
incoming: incoming_rx,
|
||||
exited: exit_rx,
|
||||
pump_task: Some(pump_task),
|
||||
stats,
|
||||
config,
|
||||
@@ -454,6 +507,7 @@ async fn run_pump<S, E>(
|
||||
mut socket: S,
|
||||
mut commands: mpsc::Receiver<ConnectionCommand>,
|
||||
incoming: mpsc::Sender<ConnectionEvent>,
|
||||
exited: oneshot::Sender<PumpExitReason>,
|
||||
connection_id: Arc<str>,
|
||||
cancellation: CancellationToken,
|
||||
opened_at: Instant,
|
||||
@@ -467,6 +521,12 @@ async fn run_pump<S, E>(
|
||||
let deadline = tokio::time::sleep_until(opened_at + config.max_age);
|
||||
tokio::pin!(deadline);
|
||||
let mut deadline_elapsed = false;
|
||||
let mut heartbeat = tokio::time::interval_at(
|
||||
opened_at + DOWNSTREAM_PING_INTERVAL,
|
||||
DOWNSTREAM_PING_INTERVAL,
|
||||
);
|
||||
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut ping_sequence = 0_u64;
|
||||
let reason = loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
@@ -474,8 +534,13 @@ async fn run_pump<S, E>(
|
||||
() = &mut deadline, if !deadline_elapsed => {
|
||||
deadline_elapsed = true;
|
||||
expired.store(true, Ordering::Release);
|
||||
if let Err(reason) = emit_incoming(&incoming, ConnectionEvent::Expired) {
|
||||
break reason;
|
||||
// 满队列本身已能唤醒空闲协调层;到期标志仍阻止启动下一轮。
|
||||
// 不能因无法追加 Expired 通知而中断正在收尾的响应。
|
||||
match incoming.try_send(ConnectionEvent::Expired) {
|
||||
Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||
break PumpExitReason::CoordinatorDropped;
|
||||
}
|
||||
}
|
||||
}
|
||||
command = commands.recv() => {
|
||||
@@ -500,6 +565,9 @@ async fn run_pump<S, E>(
|
||||
ConnectionWriteError::Transport { message }
|
||||
});
|
||||
let failed = result.is_err();
|
||||
if !failed {
|
||||
stats.record_write(opened_at);
|
||||
}
|
||||
let _ = acknowledged.send(result);
|
||||
if failed {
|
||||
break PumpExitReason::WriteError;
|
||||
@@ -508,49 +576,84 @@ async fn run_pump<S, E>(
|
||||
break PumpExitReason::ServerClose;
|
||||
}
|
||||
}
|
||||
message = socket.next() => match message {
|
||||
Some(Ok(Message::Text(payload))) => {
|
||||
if let Err(reason) = emit_incoming(&incoming, ConnectionEvent::Text(payload.to_string())) {
|
||||
break reason;
|
||||
// 官方 Codex WsStream 在独立 pump 中回复 Pong,且不将控制帧交给业务流。
|
||||
// 这里只为下游链路保活,不设置 Pong deadline,也不延长业务请求预算。
|
||||
_ = heartbeat.tick() => {
|
||||
ping_sequence = ping_sequence.wrapping_add(1);
|
||||
let ping = Message::Ping(ping_sequence.to_be_bytes().to_vec().into());
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
() = cancellation.cancelled() => break PumpExitReason::LifecycleShutdown,
|
||||
result = timeout(config.write_timeout, socket.send(ping)) => result,
|
||||
};
|
||||
match result {
|
||||
Ok(Ok(())) => {
|
||||
stats.ping_written_count.fetch_add(1, Ordering::Relaxed);
|
||||
stats.record_write(opened_at);
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Binary(_))) => {
|
||||
if let Err(reason) = emit_incoming(&incoming, ConnectionEvent::Binary) {
|
||||
break reason;
|
||||
Ok(Err(error)) => {
|
||||
tracing::info!(
|
||||
websocket_connection_id = %connection_id,
|
||||
error = %error,
|
||||
"Responses WebSocket Ping write failed"
|
||||
);
|
||||
break PumpExitReason::WriteError;
|
||||
}
|
||||
Err(_) => break PumpExitReason::WriteTimeout,
|
||||
}
|
||||
Some(Ok(Message::Ping(payload))) => {
|
||||
stats.ping_received_count.fetch_add(1, Ordering::Relaxed);
|
||||
match timeout(config.write_timeout, socket.send(Message::Pong(payload))).await {
|
||||
Ok(Ok(())) => {
|
||||
stats.pong_written_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
message = socket.next() => {
|
||||
if matches!(&message, Some(Ok(_))) {
|
||||
stats.record_read(opened_at);
|
||||
}
|
||||
match message {
|
||||
Some(Ok(Message::Text(payload))) => {
|
||||
if let Err(reason) = emit_incoming(&incoming, ConnectionEvent::Text(payload.to_string())) {
|
||||
break reason;
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
tracing::info!(
|
||||
websocket_connection_id = %connection_id,
|
||||
error = %error,
|
||||
"Responses WebSocket Pong write failed"
|
||||
);
|
||||
break PumpExitReason::WriteError;
|
||||
}
|
||||
Err(_) => break PumpExitReason::WriteTimeout,
|
||||
}
|
||||
Some(Ok(Message::Binary(_))) => {
|
||||
if let Err(reason) = emit_incoming(&incoming, ConnectionEvent::Binary) {
|
||||
break reason;
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Ping(payload))) => {
|
||||
stats.ping_received_count.fetch_add(1, Ordering::Relaxed);
|
||||
match timeout(config.write_timeout, socket.send(Message::Pong(payload))).await {
|
||||
Ok(Ok(())) => {
|
||||
stats.pong_written_count.fetch_add(1, Ordering::Relaxed);
|
||||
stats.record_write(opened_at);
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
tracing::info!(
|
||||
websocket_connection_id = %connection_id,
|
||||
error = %error,
|
||||
"Responses WebSocket Pong write failed"
|
||||
);
|
||||
break PumpExitReason::WriteError;
|
||||
}
|
||||
Err(_) => break PumpExitReason::WriteTimeout,
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Pong(_))) => {
|
||||
stats.pong_received_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Some(Ok(Message::Close(_))) => break PumpExitReason::ClientClose,
|
||||
Some(Err(error)) => {
|
||||
tracing::info!(
|
||||
websocket_connection_id = %connection_id,
|
||||
error = %error,
|
||||
"Responses WebSocket receive failed"
|
||||
);
|
||||
break PumpExitReason::ReadError;
|
||||
}
|
||||
None => break PumpExitReason::PeerEof,
|
||||
}
|
||||
Some(Ok(Message::Pong(_))) => {}
|
||||
Some(Ok(Message::Close(_))) => break PumpExitReason::ClientClose,
|
||||
Some(Err(error)) => {
|
||||
tracing::info!(
|
||||
websocket_connection_id = %connection_id,
|
||||
error = %error,
|
||||
"Responses WebSocket receive failed"
|
||||
);
|
||||
break PumpExitReason::ReadError;
|
||||
}
|
||||
None => break PumpExitReason::PeerEof,
|
||||
},
|
||||
}
|
||||
};
|
||||
let _ = incoming.try_send(ConnectionEvent::Exited(reason));
|
||||
// 退出不能排在待执行请求之后,也不能因业务队列已满而丢失。
|
||||
let _ = exited.send(reason);
|
||||
tracing::debug!(
|
||||
websocket_connection_id = %connection_id,
|
||||
pump_exit_reason = reason.as_str(),
|
||||
|
||||
@@ -13,12 +13,10 @@ use crate::openai::error::{gateway_error_contract, gateway_error_from_engine};
|
||||
|
||||
use super::{
|
||||
super::{DecodedResponsesRequest, OpenAiResponsesEncoder, PendingExecution, ProtocolErrorBody},
|
||||
connection::{ConnectionEvent, FramePhase, ResponsesWebSocketConnection, WriteContext},
|
||||
connection::{FramePhase, ResponsesWebSocketConnection, WriteContext},
|
||||
protocol::{error_event, response_metadata_event},
|
||||
};
|
||||
|
||||
const ACTIVE_RESPONSE_VIOLATION: &str = "Only one response.create may be active per connection";
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum ForwardOutcome {
|
||||
Continue,
|
||||
@@ -66,29 +64,20 @@ pub(super) async fn forward_execution(
|
||||
);
|
||||
return send_gateway_error(connection, &error, &request_id).await;
|
||||
}
|
||||
let first = loop {
|
||||
match next_active_input(connection, &mut execution).await {
|
||||
ActiveInput::Event(Ok(Some(event))) => break event,
|
||||
ActiveInput::Event(Ok(None)) => {
|
||||
let error = GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway response ended before its first event",
|
||||
);
|
||||
return send_gateway_error(connection, &error, &request_id).await;
|
||||
}
|
||||
ActiveInput::Event(Err(error)) => {
|
||||
let error = gateway_error_from_engine(&error);
|
||||
return send_gateway_error(connection, &error, &request_id).await;
|
||||
}
|
||||
ActiveInput::Control => continue,
|
||||
ActiveInput::Disconnect => return ForwardOutcome::Disconnect,
|
||||
ActiveInput::ProtocolViolation => {
|
||||
connection
|
||||
.close_policy(ACTIVE_RESPONSE_VIOLATION, Some(&request_id))
|
||||
.await;
|
||||
return ForwardOutcome::Disconnect;
|
||||
}
|
||||
let first = match next_active_input(connection, &mut execution).await {
|
||||
ActiveInput::Event(Ok(Some(event))) => event,
|
||||
ActiveInput::Event(Ok(None)) => {
|
||||
let error = GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway response ended before its first event",
|
||||
);
|
||||
return send_gateway_error(connection, &error, &request_id).await;
|
||||
}
|
||||
ActiveInput::Event(Err(error)) => {
|
||||
return send_initial_engine_error(connection, &mut execution, &error, &request_id)
|
||||
.await;
|
||||
}
|
||||
ActiveInput::Disconnect => return ForwardOutcome::Disconnect,
|
||||
};
|
||||
let requirement = first.commit_requirement();
|
||||
let mut first = first.into_provider_events();
|
||||
@@ -259,18 +248,35 @@ pub(super) async fn forward_execution(
|
||||
let error = gateway_error_from_engine(&error);
|
||||
return send_gateway_error(connection, &error, &request_id).await;
|
||||
}
|
||||
ActiveInput::Control => {}
|
||||
ActiveInput::Disconnect => return ForwardOutcome::Disconnect,
|
||||
ActiveInput::ProtocolViolation => {
|
||||
connection
|
||||
.close_policy(ACTIVE_RESPONSE_VIOLATION, Some(&request_id))
|
||||
.await;
|
||||
return ForwardOutcome::Disconnect;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_initial_engine_error(
|
||||
connection: &mut ResponsesWebSocketConnection,
|
||||
execution: &mut PendingExecution,
|
||||
error: &EngineError,
|
||||
request_id: &Arc<str>,
|
||||
) -> ForwardOutcome {
|
||||
let response_headers = execution
|
||||
.session_mut()
|
||||
.map(|session| session.response_headers().to_vec())
|
||||
.unwrap_or_default();
|
||||
if !response_headers.is_empty()
|
||||
&& !send_metadata(
|
||||
connection,
|
||||
request_id,
|
||||
response_metadata_event(request_id, &response_headers),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return ForwardOutcome::Disconnect;
|
||||
}
|
||||
let error = gateway_error_from_engine(error);
|
||||
send_gateway_error(connection, &error, request_id).await
|
||||
}
|
||||
|
||||
fn commit_connection_replay(
|
||||
replay: &mut ConnectionReplaySnapshot,
|
||||
encoder: &OpenAiResponsesEncoder,
|
||||
@@ -286,50 +292,39 @@ async fn confirm_completed_execution(
|
||||
execution: &mut PendingExecution,
|
||||
request_id: &Arc<str>,
|
||||
) -> Result<(), ForwardOutcome> {
|
||||
loop {
|
||||
match next_active_input(connection, execution).await {
|
||||
ActiveInput::Event(Ok(None))
|
||||
if execution
|
||||
.session_mut()
|
||||
.is_some_and(|session| session.is_finalized()) =>
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
ActiveInput::Event(Ok(None)) => {
|
||||
let error = GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway response was not finalized after its terminal event",
|
||||
);
|
||||
return Err(send_gateway_error(connection, &error, request_id).await);
|
||||
}
|
||||
ActiveInput::Event(Ok(Some(_))) => {
|
||||
let error = GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway response continued after its terminal event",
|
||||
);
|
||||
return Err(send_gateway_error(connection, &error, request_id).await);
|
||||
}
|
||||
ActiveInput::Event(Err(error)) => {
|
||||
let error = gateway_error_from_engine(&error);
|
||||
return Err(send_gateway_error(connection, &error, request_id).await);
|
||||
}
|
||||
ActiveInput::Control => {}
|
||||
ActiveInput::Disconnect => return Err(ForwardOutcome::Disconnect),
|
||||
ActiveInput::ProtocolViolation => {
|
||||
connection
|
||||
.close_policy(ACTIVE_RESPONSE_VIOLATION, Some(request_id))
|
||||
.await;
|
||||
return Err(ForwardOutcome::Disconnect);
|
||||
}
|
||||
match next_active_input(connection, execution).await {
|
||||
ActiveInput::Event(Ok(None))
|
||||
if execution
|
||||
.session_mut()
|
||||
.is_some_and(|session| session.is_finalized()) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
ActiveInput::Event(Ok(None)) => {
|
||||
let error = GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway response was not finalized after its terminal event",
|
||||
);
|
||||
Err(send_gateway_error(connection, &error, request_id).await)
|
||||
}
|
||||
ActiveInput::Event(Ok(Some(_))) => {
|
||||
let error = GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway response continued after its terminal event",
|
||||
);
|
||||
Err(send_gateway_error(connection, &error, request_id).await)
|
||||
}
|
||||
ActiveInput::Event(Err(error)) => {
|
||||
let error = gateway_error_from_engine(&error);
|
||||
Err(send_gateway_error(connection, &error, request_id).await)
|
||||
}
|
||||
ActiveInput::Disconnect => Err(ForwardOutcome::Disconnect),
|
||||
}
|
||||
}
|
||||
|
||||
enum ActiveInput {
|
||||
Event(Result<Option<CoordinatedEvent>, EngineError>),
|
||||
Control,
|
||||
Disconnect,
|
||||
ProtocolViolation,
|
||||
}
|
||||
|
||||
async fn next_active_input(
|
||||
@@ -339,15 +334,17 @@ async fn next_active_input(
|
||||
let Some(session) = execution.session_mut() else {
|
||||
return ActiveInput::Disconnect;
|
||||
};
|
||||
// 与 Codex stream_request 的连接锁一致:本轮结束前不消费下一条请求。
|
||||
// pump 继续接收有界业务帧和处理 Ping/Pong;退出通过独立通知取消本轮。
|
||||
tokio::select! {
|
||||
event = session.next_event() => ActiveInput::Event(event),
|
||||
message = connection.next_event() => match message {
|
||||
Some(ConnectionEvent::Expired) => ActiveInput::Control,
|
||||
Some(ConnectionEvent::Text(_) | ConnectionEvent::Binary) => {
|
||||
ActiveInput::ProtocolViolation
|
||||
}
|
||||
Some(ConnectionEvent::Exited(_)) | None => ActiveInput::Disconnect,
|
||||
biased;
|
||||
_ = connection.wait_for_exit() => {
|
||||
session.trace().record("downstream.cancelled", serde_json::json!({
|
||||
"reason": "websocket_connection_exit", "connectionId": connection.id(),
|
||||
}));
|
||||
ActiveInput::Disconnect
|
||||
},
|
||||
event = session.next_event() => ActiveInput::Event(event),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,6 +456,7 @@ fn log_terminal_write_success(
|
||||
provider_terminal_to_write: std::time::Duration,
|
||||
) {
|
||||
tracing::info!(
|
||||
target: "request_trace", stage = "downstream.terminal.written",
|
||||
websocket_connection_id = connection.id(),
|
||||
request_id = %request_id,
|
||||
provider_terminal_to_terminal_write_ms = provider_terminal_to_write.as_millis(),
|
||||
@@ -473,6 +471,7 @@ fn log_terminal_write_failure(
|
||||
provider_terminal_to_write: std::time::Duration,
|
||||
) {
|
||||
tracing::info!(
|
||||
target: "request_trace", stage = "downstream.terminal.write_failed",
|
||||
websocket_connection_id = connection.id(),
|
||||
request_id = %request_id,
|
||||
provider_terminal_to_terminal_write_ms = provider_terminal_to_write.as_millis(),
|
||||
|
||||
@@ -19,6 +19,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use gateway_core::{
|
||||
diagnostics::{TraceContext, diagnostic_json},
|
||||
engine::execution::{AuthenticatedClient, ClientTransport},
|
||||
lifecycle::{ConnectionGuard, ConnectionLifecycle},
|
||||
};
|
||||
@@ -60,12 +61,14 @@ pub(crate) async fn responses_websocket(
|
||||
&headers,
|
||||
connect_info.map(|Extension(ConnectInfo(address))| address),
|
||||
);
|
||||
let request_headers = OpenAiRequestHeaders::from_headers(&headers);
|
||||
ResponsesWebSocketAdapter::new(service).upgrade_with_client_context(
|
||||
websocket,
|
||||
client,
|
||||
client_ip,
|
||||
user_agent,
|
||||
OpenAiRequestHeaders::from_headers(&headers),
|
||||
request_headers,
|
||||
headers,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,15 +92,24 @@ impl ResponsesWebSocketAdapter {
|
||||
client_ip: Option<IpAddr>,
|
||||
user_agent: Option<String>,
|
||||
request_headers: OpenAiRequestHeaders,
|
||||
raw_headers: HeaderMap,
|
||||
) -> Response {
|
||||
let connection_guard = match self.service.try_register_connection() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => return runtime_unavailable_response().into_response(),
|
||||
};
|
||||
let connection_id = self.service.next_request_id().replacen("req_", "ws_", 1);
|
||||
TraceContext::new(&connection_id).headers(
|
||||
"client.connection.headers",
|
||||
serde_json::json!({"transport": "websocket", "method": "GET", "path": "/v1/responses"}),
|
||||
raw_headers
|
||||
.iter()
|
||||
.map(|(name, value)| (name.as_str(), value.as_bytes())),
|
||||
);
|
||||
let session = ResponsesWebSocketSession {
|
||||
service: self.service.clone(),
|
||||
client,
|
||||
connection_id: self.service.next_request_id().replacen("req_", "ws_", 1),
|
||||
connection_id,
|
||||
client_ip,
|
||||
user_agent,
|
||||
request_headers,
|
||||
@@ -143,6 +155,8 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
|
||||
);
|
||||
let _connection_guard = connection_guard;
|
||||
let cancellation = lifecycle.cancellation();
|
||||
let request_headers =
|
||||
request_headers.with_downstream_websocket_connection_id(connection_id.clone());
|
||||
let mut connection = ResponsesWebSocketConnection::new(socket, connection_id, cancellation);
|
||||
let mut request_count = 0_u64;
|
||||
let mut replay = ConnectionReplaySnapshot::default();
|
||||
@@ -172,11 +186,12 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
|
||||
let decoded = match decode_response_create_with_context(&payload, &request_headers) {
|
||||
Ok(decoded) => decoded.with_client_context(client_ip, user_agent.clone()),
|
||||
Err(error) => {
|
||||
tracing::info!(
|
||||
websocket_connection_id = connection.id(),
|
||||
request_id = %correlation_id,
|
||||
error = %error,
|
||||
"Responses WebSocket request rejected"
|
||||
trace_rejected_request(
|
||||
&correlation_id,
|
||||
connection.id(),
|
||||
&payload,
|
||||
"decode",
|
||||
&error.to_string(),
|
||||
);
|
||||
if send_protocol_error(
|
||||
&mut connection,
|
||||
@@ -195,6 +210,13 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
|
||||
let decoded = replay.prepare(decoded);
|
||||
// deadline 与 Text 可能同时就绪;在任何上游执行开始前再次封住该竞争窗口。
|
||||
if connection.is_expired() {
|
||||
trace_rejected_request(
|
||||
&correlation_id,
|
||||
connection.id(),
|
||||
&payload,
|
||||
"connection_expired",
|
||||
"",
|
||||
);
|
||||
expire_connection(&mut connection).await;
|
||||
break;
|
||||
}
|
||||
@@ -209,6 +231,13 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
|
||||
{
|
||||
Ok(started) => started,
|
||||
Err(error) => {
|
||||
trace_rejected_request(
|
||||
&correlation_id,
|
||||
connection.id(),
|
||||
&payload,
|
||||
"start",
|
||||
&error.to_string(),
|
||||
);
|
||||
if send_gateway_error(&mut connection, &error, &correlation_id).await
|
||||
== ForwardOutcome::Disconnect
|
||||
{
|
||||
@@ -217,6 +246,14 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
|
||||
continue;
|
||||
}
|
||||
};
|
||||
started.session.trace().record("client.connection", serde_json::json!({
|
||||
"transport": "websocket", "connectionId": connection.id(), "correlationId": correlation_id.as_ref(),
|
||||
"requestIndex": request_count,
|
||||
}));
|
||||
started
|
||||
.session
|
||||
.trace()
|
||||
.capture("client.request.body", payload.as_bytes());
|
||||
|
||||
if forward_execution(&mut connection, started, &mut replay).await
|
||||
== ForwardOutcome::Disconnect
|
||||
@@ -232,6 +269,28 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
|
||||
connection.log_summary(request_count);
|
||||
}
|
||||
|
||||
// 尚未建立模型请求的拒绝也保留原文入口,用返回给客户端的 correlation ID 检索。
|
||||
fn trace_rejected_request(
|
||||
correlation_id: &str,
|
||||
connection_id: &str,
|
||||
payload: &str,
|
||||
phase: &'static str,
|
||||
error: &str,
|
||||
) {
|
||||
let trace = TraceContext::new(correlation_id);
|
||||
trace.record(
|
||||
"client.connection",
|
||||
serde_json::json!({"connectionId": connection_id}),
|
||||
);
|
||||
trace.capture("client.request.body", payload.as_bytes());
|
||||
trace.record(
|
||||
"client.request.rejected",
|
||||
serde_json::json!({
|
||||
"phase": phase, "detail": diagnostic_json(&serde_json::json!({"message": error})),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async fn expire_connection(connection: &mut ResponsesWebSocketConnection) {
|
||||
if connection
|
||||
.send_text(
|
||||
|
||||
@@ -10,18 +10,16 @@ use axum::{
|
||||
};
|
||||
use gateway_core::error::{GatewayError, GatewayErrorKind};
|
||||
use gateway_core::operation::{Operation, RawJsonPayload, StandaloneSearchRequest};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::ApiState;
|
||||
use crate::openai::{
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
endpoint::collect_raw_json_response,
|
||||
error::gateway_error_response,
|
||||
provider_endpoint::collect_raw_json_response,
|
||||
responses::request_client_context,
|
||||
responses::{OpenAiRequestHeaders, request_client_context},
|
||||
};
|
||||
|
||||
const OPENAI_PROTOCOL: &str = "openai";
|
||||
const TURN_METADATA_CONTEXT_KEY: &str = "turn_metadata";
|
||||
|
||||
/// `POST /v1/alpha/search`。
|
||||
pub(crate) async fn standalone_search(
|
||||
@@ -54,16 +52,7 @@ pub(crate) async fn standalone_search(
|
||||
}
|
||||
|
||||
fn search_operation(body: Bytes, headers: &HeaderMap) -> Result<Operation, GatewayError> {
|
||||
let mut context = Map::new();
|
||||
if let Some(turn_metadata) = headers
|
||||
.get("x-codex-turn-metadata")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
context.insert(
|
||||
TURN_METADATA_CONTEXT_KEY.to_owned(),
|
||||
Value::String(turn_metadata.to_owned()),
|
||||
);
|
||||
}
|
||||
let context = OpenAiRequestHeaders::from_headers(headers).session_context();
|
||||
let payload = RawJsonPayload::new(OPENAI_PROTOCOL, body)
|
||||
.map_err(|_| {
|
||||
GatewayError::new(
|
||||
|
||||
@@ -348,6 +348,8 @@ mod response {
|
||||
output_tokens_display: "-".to_owned(),
|
||||
cached_tokens: None,
|
||||
cached_tokens_display: "-".to_owned(),
|
||||
reasoning_tokens: None,
|
||||
reasoning_tokens_display: "-".to_owned(),
|
||||
image_input_tokens: None,
|
||||
image_input_tokens_display: "-".to_owned(),
|
||||
image_output_tokens: None,
|
||||
@@ -374,6 +376,8 @@ mod response {
|
||||
let value = serde_json::to_value(view).expect("serialize account usage");
|
||||
assert!(value["inputTokens"].is_null());
|
||||
assert!(value["totalTokens"].is_null());
|
||||
assert!(value["reasoningTokens"].is_null());
|
||||
assert_eq!(value["reasoningTokensDisplay"], "-");
|
||||
assert_eq!(value["createdTokensDisplay"], "-");
|
||||
}
|
||||
|
||||
@@ -752,3 +756,60 @@ mod actions {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod import_settings {
|
||||
use gateway_api::admin::accounts::{AccountImportRequest, CompleteAccountAuthorizationRequest};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn import_and_oauth_apply_the_same_settings_validation() {
|
||||
for (field, value) in [
|
||||
("concurrencyLimit", json!(0)),
|
||||
("concurrencyLimit", json!(4_294_967_296_u64)),
|
||||
("weight", json!(0)),
|
||||
("weight", json!(101)),
|
||||
("groupIds", json!(["invalid-group"])),
|
||||
] {
|
||||
let mut settings =
|
||||
json!({"enabled": false, "concurrencyLimit": null, "weight": 1, "groupIds": []});
|
||||
settings[field] = value;
|
||||
let import: AccountImportRequest = serde_json::from_value(
|
||||
json!({"provider": "openai", "data": {}, "settings": settings}),
|
||||
)
|
||||
.expect("import request");
|
||||
let oauth: CompleteAccountAuthorizationRequest = serde_json::from_value(json!({"provider": "xai", "flowId": "flow-test", "callbackUrl": "code", "settings": settings})).expect("OAuth request");
|
||||
assert_eq!(
|
||||
import.validate().expect_err("invalid settings").field(),
|
||||
field
|
||||
);
|
||||
assert_eq!(
|
||||
oauth.validate().expect_err("invalid settings").field(),
|
||||
field
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_settings_require_a_complete_explicit_configuration() {
|
||||
let settings =
|
||||
json!({"enabled": false, "concurrencyLimit": null, "weight": 100, "groupIds": []});
|
||||
let request: AccountImportRequest =
|
||||
serde_json::from_value(json!({"provider": "openai", "data": {}, "settings": settings}))
|
||||
.expect("settings");
|
||||
assert!(request.validate().is_ok());
|
||||
for field in ["enabled", "concurrencyLimit", "weight", "groupIds"] {
|
||||
let mut incomplete = settings.clone();
|
||||
incomplete
|
||||
.as_object_mut()
|
||||
.expect("settings object")
|
||||
.remove(field);
|
||||
assert!(
|
||||
serde_json::from_value::<AccountImportRequest>(
|
||||
json!({"provider": "openai", "data": {}, "settings": incomplete})
|
||||
)
|
||||
.is_err(),
|
||||
"missing {field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,84 @@ use serde_json::json;
|
||||
|
||||
use super::{AdminTestFixture, AdminTestState};
|
||||
|
||||
#[test]
|
||||
fn budget_inputs_preserve_decimal_precision_and_omitted_updates() {
|
||||
let payload = json!({"name": "budget", "groupIds": [], "maxConcurrency": 3,
|
||||
"requestsPerMinute": 0, "dailyLimitUsd": "0.1234567891", "weeklyLimitUsd": "15"});
|
||||
let command = serde_json::from_value::<CreateClientKeyRequest>(payload.clone())
|
||||
.unwrap()
|
||||
.into_command()
|
||||
.unwrap();
|
||||
assert_eq!(command.budget.daily_usd.canonical(), "0.1234567891");
|
||||
assert_eq!(command.budget.weekly_usd.canonical(), "15");
|
||||
assert_eq!(command.limits.max_concurrency, 3);
|
||||
for field in ["dailyLimitUsd", "weeklyLimitUsd"] {
|
||||
for invalid in ["-1", "NaN", "1e3", "10000000000", "0.00000000001", ""] {
|
||||
let mut payload = payload.clone();
|
||||
payload[field] = json!(invalid);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<CreateClientKeyRequest>(payload)
|
||||
.unwrap()
|
||||
.into_command()
|
||||
.unwrap_err()
|
||||
.field(),
|
||||
field
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut update = payload;
|
||||
update["id"] = json!("key_budget");
|
||||
update.as_object_mut().unwrap().remove("dailyLimitUsd");
|
||||
update["weeklyLimitUsd"] = json!("0");
|
||||
let command = serde_json::from_value::<UpdateClientKeyRequest>(update)
|
||||
.unwrap()
|
||||
.into_command()
|
||||
.unwrap();
|
||||
assert_eq!(command.daily_limit_usd, None);
|
||||
assert_eq!(
|
||||
command.weekly_limit_usd,
|
||||
Some(gateway_core::metering::Decimal::ZERO)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn budget_reconciliation_routes_require_an_admin_session() {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use tower::ServiceExt as _;
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
let router = client_keys::router::<AdminTestState>().with_state(fixture.state());
|
||||
for (method, path, body) in [
|
||||
(
|
||||
"GET",
|
||||
"/api/admin/client-keys/unresolved-charges?id=key_1",
|
||||
"",
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"/api/admin/client-keys/reconcile-charge",
|
||||
r#"{"id":"key_1","requestId":"req_charge","amountUsd":"0","reason":"verified"}"#,
|
||||
),
|
||||
] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.header("content-type", "application/json")
|
||||
.header("x-request-id", "req_auth_budget")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
|
||||
let unknown = serde_json::from_value::<ListClientKeysQuery>(json!({ "other": true }));
|
||||
@@ -207,6 +285,7 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
|
||||
.single()
|
||||
.expect("valid time");
|
||||
let view = ClientKeyView::from(gateway_admin::model::client_keys::ClientKeyRecord {
|
||||
budget: Default::default(),
|
||||
id: gateway_core::policy::ClientApiKeyId::new("key_visible").expect("Client Key ID"),
|
||||
name: "visible".to_owned(),
|
||||
label: None,
|
||||
@@ -238,6 +317,10 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
|
||||
assert_eq!(list["items"][0]["requestsPerMinute"], 60);
|
||||
assert!(list["items"][0].get("tokensPerMinute").is_none());
|
||||
assert!(list["items"][0].get("policyJson").is_none());
|
||||
assert_eq!(list["items"][0]["dailyLimitUsd"], "0");
|
||||
assert_eq!(list["items"][0]["weeklyLimitUsd"], "0");
|
||||
assert_eq!(list["items"][0]["dailyUsedUsd"], "0");
|
||||
assert_eq!(list["items"][0]["unresolvedRequests"], 0);
|
||||
assert_eq!(
|
||||
DateTime::parse_from_rfc3339(
|
||||
list["items"][0]["lastUsedAt"]
|
||||
|
||||
@@ -640,6 +640,7 @@ impl ClientKeyStore for MemoryClientKeyStore {
|
||||
let now = Utc::now();
|
||||
Ok(Some(ClientKeySecret::new(
|
||||
ClientKeyRecord {
|
||||
budget: Default::default(),
|
||||
id: id.clone(),
|
||||
name: "revealed".to_owned(),
|
||||
label: None,
|
||||
@@ -875,13 +876,14 @@ impl ObservabilityStore for UnusedStore {
|
||||
Err(unavailable("usage trend"))
|
||||
}
|
||||
|
||||
async fn usage_calculated_billing_facts(
|
||||
fn usage_calculated_billing_facts(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<gateway_admin::model::observability::UsageCalculatedBillingFact>>
|
||||
{
|
||||
Err(unavailable("usage billing facts"))
|
||||
) -> gateway_admin::ports::store::UsageCalculatedBillingStream<'_> {
|
||||
Box::pin(futures::stream::once(async {
|
||||
Err(unavailable("usage billing facts"))
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
|
||||
@@ -64,6 +64,7 @@ fn dashboard_account_usage_should_keep_daily_request_timeline() {
|
||||
authentication_kind: "oauth".to_owned(),
|
||||
email: "account@example.com".to_owned(),
|
||||
plan_type: Some("free".to_owned()),
|
||||
plan_type_display: "Free".to_owned(),
|
||||
tokens: "—".to_owned(),
|
||||
request_count: 3,
|
||||
request_buckets: vec![DashboardAccountRequestBucketView {
|
||||
@@ -71,6 +72,9 @@ fn dashboard_account_usage_should_keep_daily_request_timeline() {
|
||||
request_count: 3,
|
||||
}],
|
||||
quota_used_percent: None,
|
||||
usage_window: None,
|
||||
metric_label: "次数".to_owned(),
|
||||
metric_value: "3".to_owned(),
|
||||
last_used: "刚刚".to_owned(),
|
||||
})
|
||||
.expect("dashboard account usage");
|
||||
@@ -426,6 +430,8 @@ async fn usage_detail_should_keep_attempt_snapshot_contract() {
|
||||
.lock()
|
||||
.expect("usage detail")
|
||||
.replace(UsageDetail {
|
||||
trace: None,
|
||||
related_requests: Vec::new(),
|
||||
request: usage_record_with_account(
|
||||
"req_detail",
|
||||
"acct_snap_a",
|
||||
@@ -563,7 +569,6 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
|
||||
raw_upstream_error: Some(
|
||||
r#"{"error":{"code":"upstream","message":"raw upstream marker"}}"#.to_owned(),
|
||||
),
|
||||
occurrence_count: 1,
|
||||
client_ip: Some("203.0.113.8".to_owned()),
|
||||
user_agent: Some("codex-cli/0.144.0".to_owned()),
|
||||
reasoning_effort: Some("medium".to_owned()),
|
||||
@@ -915,6 +920,22 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
compact: true,
|
||||
started_at: Utc::now(),
|
||||
});
|
||||
{
|
||||
let mut records = fixture.usage_records.lock().expect("usage records");
|
||||
for source in ["calculated", "provider_reported"] {
|
||||
let mut image = records[0].clone();
|
||||
image.id = format!("image_{source}");
|
||||
image.endpoint = "/v1/images/generations".to_owned();
|
||||
image.provider_kind = Some("openai".to_owned());
|
||||
image.requested_model_id = Some("gpt-image-2".to_owned());
|
||||
image.upstream_model_id = None;
|
||||
image.billing = Some(UsageBilling::Total {
|
||||
source: source.to_owned(),
|
||||
total: usd("0.00696"),
|
||||
});
|
||||
records.push(image);
|
||||
}
|
||||
}
|
||||
let response = observability::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
@@ -1012,4 +1033,12 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
})
|
||||
);
|
||||
assert!(value["data"]["items"][0].get("metadata").is_none());
|
||||
assert_eq!(
|
||||
value["data"]["items"][1]["billing"]["totalAmountDisplay"],
|
||||
"≈ $0.007"
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["items"][2]["billing"]["totalAmountDisplay"],
|
||||
"$0.007"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ fn source_tree_should_match_frozen_machine_manifest() {
|
||||
"src/openai/images/mod.rs",
|
||||
"src/openai/mod.rs",
|
||||
"src/openai/models.rs",
|
||||
"src/openai/provider_endpoint.rs",
|
||||
"src/openai/endpoint.rs",
|
||||
"src/openai/responses/error.rs",
|
||||
"src/openai/responses/http.rs",
|
||||
"src/openai/responses/mod.rs",
|
||||
@@ -107,7 +107,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
|
||||
"tests/openai/images/mod.rs",
|
||||
"tests/openai/mod.rs",
|
||||
"tests/openai/models.rs",
|
||||
"tests/openai/provider_endpoint.rs",
|
||||
"tests/openai/endpoint.rs",
|
||||
"tests/openai/responses/http.rs",
|
||||
"tests/openai/responses/mod.rs",
|
||||
"tests/openai/responses/websocket/connection.rs",
|
||||
|
||||
@@ -13,6 +13,32 @@ fn bearer_client_api_key_should_reject_missing_authorization() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actor_authorization_marker_should_not_replace_client_api_key() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-openai-actor-authorization",
|
||||
HeaderValue::from_static("proxy-managed"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
bearer_client_api_key(&headers),
|
||||
Err(ClientApiKeyAuthError::MissingAuthorization)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actor_authorization_marker_should_preserve_existing_bearer_authentication() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-openai-actor-authorization",
|
||||
HeaderValue::from_static("proxy-managed"),
|
||||
);
|
||||
headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer sk_client"));
|
||||
|
||||
assert_eq!(bearer_client_api_key(&headers), Ok("sk_client"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_client_api_key_should_reject_non_utf8_authorization() {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
@@ -11,6 +11,21 @@ use gateway_api::openai::error::{
|
||||
openai_error_response,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_budget_errors_preserve_limit_code_and_retry_after() {
|
||||
for code in ["key_daily_budget_exceeded", "key_weekly_budget_exceeded"] {
|
||||
let error = GatewayError::new(GatewayErrorKind::RateLimited, "key budget exhausted")
|
||||
.with_client_code(code)
|
||||
.with_retry_after(std::time::Duration::from_millis(1501));
|
||||
let response = gateway_error_response(&error);
|
||||
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
assert_eq!(response.headers()["retry-after"], "2");
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.unwrap()).unwrap();
|
||||
assert_eq!(body["error"]["code"], code);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_request_error_should_map_to_openai_bad_request() {
|
||||
assert_eq!(
|
||||
@@ -433,3 +448,151 @@ async fn continuation_recovery_should_preserve_the_official_retry_signal() {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
mod model_routing {
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::engine::execution::{
|
||||
DefaultExecutionService, ProviderCircuitDecision, ProviderCircuitError, ProviderCircuitPort,
|
||||
};
|
||||
use gateway_core::engine::provider::ProviderRegistry;
|
||||
use gateway_core::routing::{ProviderKind, RuntimeSnapshot};
|
||||
use gateway_core::runtime::RuntimeSnapshotHandle;
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::openai::{
|
||||
IgnoredClientApiKeyUsage, UnusedAdmissions, UnusedContinuation, UnusedExecutionStore,
|
||||
};
|
||||
|
||||
struct TestCircuits {
|
||||
blocked: bool,
|
||||
}
|
||||
|
||||
impl ProviderCircuitPort for TestCircuits {
|
||||
fn decision<'a>(
|
||||
&'a self,
|
||||
_: &'a ProviderKind,
|
||||
) -> BoxFuture<'a, Result<ProviderCircuitDecision, ProviderCircuitError>> {
|
||||
Box::pin(async move {
|
||||
Ok(if self.blocked {
|
||||
ProviderCircuitDecision::BlockedUntil(
|
||||
SystemTime::now() + Duration::from_secs(30),
|
||||
)
|
||||
} else {
|
||||
ProviderCircuitDecision::Allow
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn observe_failure<'a>(
|
||||
&'a self,
|
||||
_: &'a ProviderKind,
|
||||
) -> BoxFuture<'a, Result<(), ProviderCircuitError>> {
|
||||
Box::pin(async { unreachable!("routing rejection must not execute a provider") })
|
||||
}
|
||||
|
||||
fn observe_success<'a>(
|
||||
&'a self,
|
||||
_: &'a ProviderKind,
|
||||
) -> BoxFuture<'a, Result<(), ProviderCircuitError>> {
|
||||
Box::pin(async { unreachable!("routing rejection must not execute a provider") })
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_model(
|
||||
snapshot: RuntimeSnapshot,
|
||||
model: &str,
|
||||
stream: bool,
|
||||
blocked: bool,
|
||||
) -> (StatusCode, Value) {
|
||||
let execution = DefaultExecutionService::new(
|
||||
RuntimeSnapshotHandle::new(snapshot),
|
||||
Arc::new(UnusedExecutionStore),
|
||||
ProviderRegistry::default(),
|
||||
Arc::new(UnusedAdmissions),
|
||||
Arc::new(TestCircuits { blocked }),
|
||||
Arc::new(UnusedContinuation),
|
||||
Arc::new(IgnoredClientApiKeyUsage),
|
||||
);
|
||||
let response = crate::openai::api_router(Arc::new(execution))
|
||||
.await
|
||||
.oneshot(
|
||||
Request::post("/v1/responses")
|
||||
.header("authorization", "Bearer sk_model_routing")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({"model": model, "input": "hello", "stream": stream}).to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), 16 * 1024)
|
||||
.await
|
||||
.expect("error body");
|
||||
(status, serde_json::from_slice(&body).expect("error JSON"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_models_should_return_404_with_a_specific_explanation() {
|
||||
for (model, mappings, message) in [
|
||||
(
|
||||
"missing-model",
|
||||
BTreeMap::new(),
|
||||
"the requested model was not found in the provider catalogs available to this API key; check the model name",
|
||||
),
|
||||
(
|
||||
"model-a",
|
||||
BTreeMap::from([("model-a".to_owned(), "missing-target".to_owned())]),
|
||||
"the requested model maps to an upstream model that was not found in the provider catalogs available to this API key; check the configured model mapping",
|
||||
),
|
||||
] {
|
||||
for stream in [false, true] {
|
||||
let snapshot = crate::openai::snapshot("sk_model_routing", "openai")
|
||||
.with_model_mappings(mappings.clone());
|
||||
let response = request_model(snapshot, model, stream, false).await;
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
json!({"error": {
|
||||
"type": "invalid_request_error",
|
||||
"code": "model_not_found",
|
||||
"message": message,
|
||||
}}),
|
||||
),
|
||||
"model={model}, stream={stream}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_existing_model_with_a_blocked_provider_should_remain_service_unavailable() {
|
||||
let response = request_model(
|
||||
crate::openai::snapshot("sk_model_routing", "openai"),
|
||||
"model-a",
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
(response.0, response.1["error"]["code"].as_str()),
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Some("no_available_provider")
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +260,8 @@ async fn image_routes_should_not_decode_bodies_and_should_preserve_both_directio
|
||||
.header(AUTHORIZATION, "Bearer sk_images_test")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-codex-image-turn-id", "turn_image_route")
|
||||
.header("session-id", "root-image-session")
|
||||
.header("thread-id", "child-image-thread")
|
||||
.body(Body::from(body.to_vec()))
|
||||
.expect("image request"),
|
||||
)
|
||||
@@ -286,7 +288,7 @@ async fn image_routes_should_not_decode_bodies_and_should_preserve_both_directio
|
||||
assert_eq!(captured.body.as_ref(), *body);
|
||||
assert_eq!(
|
||||
captured.context,
|
||||
json!({"image_turn_id": "turn_image_route"})
|
||||
json!({"image_turn_id": "turn_image_route", "session_id": "root-image-session", "thread_id": "child-image-thread"})
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
mod auth;
|
||||
mod endpoint;
|
||||
mod error;
|
||||
mod images;
|
||||
mod models;
|
||||
mod provider_endpoint;
|
||||
mod responses;
|
||||
mod router;
|
||||
mod search;
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
@@ -14,6 +15,7 @@ use axum::{
|
||||
},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use futures::StreamExt;
|
||||
use futures::future::{BoxFuture, pending};
|
||||
use gateway_core::engine::execution::{
|
||||
AuthenticatedClient, ClientAuthenticationError, ExecutionService, ExecutionSession,
|
||||
@@ -79,6 +81,7 @@ impl Trace {
|
||||
|
||||
enum NextStep {
|
||||
Event(CoordinatedEvent),
|
||||
DelayedEvent(Duration, CoordinatedEvent),
|
||||
Error(EngineError),
|
||||
FinalizeCancelled,
|
||||
FinalizeSuccess,
|
||||
@@ -280,6 +283,12 @@ impl ExecutionSession for FakeSession {
|
||||
self.trace.push("next_event");
|
||||
Ok(Some(event))
|
||||
}
|
||||
NextStep::DelayedEvent(delay, event) => {
|
||||
self.trace.push("wait_event");
|
||||
tokio::time::sleep(delay).await;
|
||||
self.trace.push("next_event");
|
||||
Ok(Some(event))
|
||||
}
|
||||
NextStep::Error(error) => {
|
||||
self.trace.push("next_error");
|
||||
self.finalized = true;
|
||||
@@ -709,6 +718,154 @@ async fn streaming_encodes_first_frame_before_commit_and_http_delivery() {
|
||||
std::mem::forget(response);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn streaming_should_keep_alive_during_silent_compaction_without_restarting_execution() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
let session = FakeSession::streaming(
|
||||
Arc::clone(&trace),
|
||||
vec![
|
||||
NextStep::Event(delivery(started(), CommitRequirement::CommitBeforeDelivery)),
|
||||
NextStep::DelayedEvent(
|
||||
Duration::from_secs(240),
|
||||
delivery(completed(), CommitRequirement::AlreadyCommitted),
|
||||
),
|
||||
NextStep::FinalizeSuccess,
|
||||
],
|
||||
);
|
||||
let response = stream_execution_response(Box::new(session), None).await;
|
||||
let mut body = response.into_body().into_data_stream();
|
||||
let first = body.next().await.expect("first frame").expect("body bytes");
|
||||
assert!(String::from_utf8_lossy(&first).contains("response.created"));
|
||||
let start = tokio::time::Instant::now();
|
||||
|
||||
for heartbeat in 1..16 {
|
||||
let chunk = tokio::time::timeout(Duration::from_secs(20), body.next())
|
||||
.await
|
||||
.expect("silent upstream must not leave the downstream idle")
|
||||
.expect("heartbeat frame")
|
||||
.expect("heartbeat bytes");
|
||||
assert_eq!(chunk, Bytes::from_static(b": keep-alive\n\n"));
|
||||
assert_eq!(start.elapsed(), Duration::from_secs(15 * heartbeat));
|
||||
}
|
||||
|
||||
let terminal = body
|
||||
.next()
|
||||
.await
|
||||
.expect("terminal frame")
|
||||
.expect("body bytes");
|
||||
assert!(String::from_utf8_lossy(&terminal).contains("response.completed"));
|
||||
assert_eq!(start.elapsed(), Duration::from_secs(240));
|
||||
assert_eq!(
|
||||
body.next().await.expect("done frame").expect("body bytes"),
|
||||
Bytes::from_static(b"data: [DONE]\n\n")
|
||||
);
|
||||
assert!(body.next().await.is_none());
|
||||
assert_eq!(
|
||||
trace.snapshot(),
|
||||
vec![
|
||||
"next_event",
|
||||
"commit",
|
||||
"wait_event",
|
||||
"next_event",
|
||||
"next_end"
|
||||
]
|
||||
);
|
||||
assert!(!trace.is_cancelled());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn dropping_stream_during_keepalive_should_cancel_and_finalize_execution() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
let session = FakeSession::streaming(
|
||||
Arc::clone(&trace),
|
||||
vec![
|
||||
NextStep::Event(delivery(started(), CommitRequirement::CommitBeforeDelivery)),
|
||||
NextStep::DelayedEvent(
|
||||
Duration::from_secs(240),
|
||||
delivery(completed(), CommitRequirement::AlreadyCommitted),
|
||||
),
|
||||
NextStep::FinalizeCancelled,
|
||||
],
|
||||
);
|
||||
let response = stream_execution_response(Box::new(session), None).await;
|
||||
let mut body = response.into_body().into_data_stream();
|
||||
let _ = body.next().await.expect("first frame").expect("body bytes");
|
||||
let heartbeat = tokio::time::timeout(Duration::from_secs(20), body.next())
|
||||
.await
|
||||
.expect("heartbeat before client disconnect")
|
||||
.expect("heartbeat frame")
|
||||
.expect("heartbeat bytes");
|
||||
assert_eq!(heartbeat, Bytes::from_static(b": keep-alive\n\n"));
|
||||
|
||||
drop(body);
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
assert!(trace.is_cancelled());
|
||||
assert_eq!(
|
||||
trace.snapshot(),
|
||||
vec!["next_event", "commit", "wait_event", "cancel_finalize"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn streaming_keepalive_should_wait_for_first_event_before_committing_http_status() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
let session = FakeSession::streaming(
|
||||
Arc::clone(&trace),
|
||||
vec![
|
||||
NextStep::DelayedEvent(
|
||||
Duration::from_secs(45),
|
||||
delivery(started(), CommitRequirement::CommitBeforeDelivery),
|
||||
),
|
||||
NextStep::FinalizeCancelled,
|
||||
],
|
||||
);
|
||||
let response = tokio::spawn(stream_execution_response(Box::new(session), None));
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(Duration::from_secs(30)).await;
|
||||
|
||||
assert!(!response.is_finished());
|
||||
assert!(trace.client_statuses().is_empty());
|
||||
let response = response.await.expect("response task");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(trace.snapshot(), vec!["wait_event", "next_event", "commit"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_response_should_disable_proxy_buffering_and_cache_transformation() {
|
||||
let session = FakeSession::streaming(
|
||||
Arc::new(Trace::default()),
|
||||
vec![
|
||||
NextStep::Event(delivery(started(), CommitRequirement::CommitBeforeDelivery)),
|
||||
NextStep::FinalizeCancelled,
|
||||
],
|
||||
)
|
||||
.with_response_headers(vec![
|
||||
ProviderResponseHeader::new("x-accel-buffering", Bytes::from_static(b"yes")),
|
||||
ProviderResponseHeader::new("cache-control", Bytes::from_static(b"public, max-age=60")),
|
||||
]);
|
||||
|
||||
let response = stream_execution_response(Box::new(session), None).await;
|
||||
|
||||
assert_eq!(response.headers()["x-accel-buffering"], "no");
|
||||
assert_eq!(
|
||||
response.headers()["cache-control"],
|
||||
"no-cache, no-transform"
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get_all("x-accel-buffering")
|
||||
.iter()
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers().get_all("cache-control").iter().count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_response_should_append_ordinary_headers_and_skip_unrepresentable_or_sensitive_ones()
|
||||
{
|
||||
@@ -893,6 +1050,41 @@ async fn streaming_rate_limit_before_first_frame_should_persist_the_returned_429
|
||||
assert_eq!(trace.client_statuses(), vec![429]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_failure_before_first_frame_should_return_safe_observed_headers() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
let session = FakeSession::streaming(
|
||||
Arc::clone(&trace),
|
||||
vec![NextStep::Error(EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::Unavailable,
|
||||
UpstreamSendState::Ambiguous,
|
||||
)))],
|
||||
)
|
||||
.with_response_headers(vec![
|
||||
ProviderResponseHeader::new(
|
||||
"x-codex-turn-state",
|
||||
Bytes::from_static(b"turn-state-before-close"),
|
||||
),
|
||||
ProviderResponseHeader::new(
|
||||
"authorization",
|
||||
Bytes::from_static(b"must-not-cross-boundary"),
|
||||
),
|
||||
]);
|
||||
|
||||
let response = stream_execution_response(Box::new(session), None).await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-codex-turn-state")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("turn-state-before-close")
|
||||
);
|
||||
assert!(response.headers().get("authorization").is_none());
|
||||
assert_eq!(trace.client_statuses(), vec![502]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_upstream_http_failure_before_first_frame_should_preserve_raw_response() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
@@ -979,11 +1171,29 @@ async fn buffered_upstream_http_failure_should_preserve_raw_response() {
|
||||
raw_body.clone(),
|
||||
));
|
||||
let session = FakeSession::buffered(Arc::clone(&trace), vec![started(), completed()])
|
||||
.with_collect_error(EngineError::Provider(error));
|
||||
.with_collect_error(EngineError::Provider(error))
|
||||
.with_response_headers(vec![
|
||||
ProviderResponseHeader::new(
|
||||
"x-codex-turn-state",
|
||||
Bytes::from_static(b"turn-state-before-close"),
|
||||
),
|
||||
ProviderResponseHeader::new(
|
||||
"authorization",
|
||||
Bytes::from_static(b"must-not-cross-boundary"),
|
||||
),
|
||||
]);
|
||||
|
||||
let response = collect_execution_response(Box::new(session)).await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-codex-turn-state")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("turn-state-before-close")
|
||||
);
|
||||
assert!(response.headers().get("authorization").is_none());
|
||||
assert_eq!(
|
||||
to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
|
||||
@@ -281,6 +281,10 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
|
||||
headers.insert("content-length", HeaderValue::from_static("999"));
|
||||
headers.insert("authorization", HeaderValue::from_static("Bearer client"));
|
||||
headers.insert("x-api-key", HeaderValue::from_static("client-key"));
|
||||
headers.insert(
|
||||
"x-openai-actor-authorization",
|
||||
HeaderValue::from_static("proxy-managed"),
|
||||
);
|
||||
headers.insert("cookie", HeaderValue::from_static("client=cookie"));
|
||||
headers.insert(
|
||||
"chatgpt-account-id",
|
||||
@@ -348,6 +352,7 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
|
||||
"content-length",
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"x-openai-actor-authorization",
|
||||
"cookie",
|
||||
"chatgpt-account-id",
|
||||
"chatgpt-project-id",
|
||||
|
||||
@@ -2,8 +2,8 @@ use std::{
|
||||
fmt,
|
||||
pin::Pin,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
},
|
||||
task::{Context, Poll},
|
||||
time::{Duration, SystemTime},
|
||||
@@ -25,6 +25,7 @@ use gateway_core::{
|
||||
error::GatewayError,
|
||||
event::{GatewayEvent, ProtocolWireEvent, ProviderEvent, ProviderResponseHeader, ResponseMeta},
|
||||
lifecycle::CancellationToken,
|
||||
operation::Operation,
|
||||
routing::PublicModelId,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -32,7 +33,7 @@ use tokio::sync::mpsc::{
|
||||
UnboundedReceiver, UnboundedSender, error::TryRecvError, unbounded_channel,
|
||||
};
|
||||
use tokio_tungstenite::{
|
||||
connect_async,
|
||||
MaybeTlsStream, WebSocketStream, connect_async,
|
||||
tungstenite::{
|
||||
Message as ClientMessage, client::IntoClientRequest, protocol::frame::coding::CloseCode,
|
||||
},
|
||||
@@ -44,27 +45,33 @@ use crate::openai::{api_router, authenticated_client};
|
||||
#[derive(Default)]
|
||||
struct CrossingLimitTrace {
|
||||
release_terminal: tokio::sync::Notify,
|
||||
started_emitted: AtomicBool,
|
||||
terminal_emitted: AtomicBool,
|
||||
starts: AtomicUsize,
|
||||
inputs: Mutex<Vec<Value>>,
|
||||
cancelled: AtomicBool,
|
||||
cancellation_observed: tokio::sync::Notify,
|
||||
finalized: AtomicBool,
|
||||
}
|
||||
|
||||
struct CrossingLimitSession {
|
||||
trace: Arc<CrossingLimitTrace>,
|
||||
started_emitted: bool,
|
||||
terminal_emitted: bool,
|
||||
finalized: bool,
|
||||
}
|
||||
|
||||
impl ExecutionSession for CrossingLimitSession {
|
||||
fn next_event(&mut self) -> BoxFuture<'_, Result<Option<CoordinatedEvent>, EngineError>> {
|
||||
Box::pin(async move {
|
||||
if !self.trace.started_emitted.swap(true, Ordering::AcqRel) {
|
||||
if !self.started_emitted {
|
||||
self.started_emitted = true;
|
||||
return Ok(Some(crossing_limit_started_batch()));
|
||||
}
|
||||
if !self.trace.terminal_emitted.load(Ordering::Acquire) {
|
||||
if !self.terminal_emitted {
|
||||
self.trace.release_terminal.notified().await;
|
||||
if !self.trace.terminal_emitted.swap(true, Ordering::AcqRel) {
|
||||
return Ok(Some(crossing_limit_terminal_batch()));
|
||||
}
|
||||
self.terminal_emitted = true;
|
||||
return Ok(Some(crossing_limit_terminal_batch()));
|
||||
}
|
||||
self.finalized = true;
|
||||
self.trace.finalized.store(true, Ordering::Release);
|
||||
Ok(None)
|
||||
})
|
||||
@@ -87,10 +94,13 @@ impl ExecutionSession for CrossingLimitSession {
|
||||
}
|
||||
|
||||
fn is_finalized(&self) -> bool {
|
||||
self.trace.finalized.load(Ordering::Acquire)
|
||||
self.finalized
|
||||
}
|
||||
|
||||
fn cancel(&self) {}
|
||||
fn cancel(&self) {
|
||||
self.trace.cancelled.store(true, Ordering::Release);
|
||||
self.trace.cancellation_observed.notify_one();
|
||||
}
|
||||
|
||||
fn detach_finalize(self: Box<Self>) -> BoxFuture<'static, ()> {
|
||||
Box::pin(async move {
|
||||
@@ -127,6 +137,14 @@ impl ExecutionService for CrossingLimitExecution {
|
||||
request: StartExecution,
|
||||
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
|
||||
Box::pin(async move {
|
||||
self.trace.starts.fetch_add(1, Ordering::AcqRel);
|
||||
if let Operation::Generate(generate) = &request.operation {
|
||||
self.trace
|
||||
.inputs
|
||||
.lock()
|
||||
.expect("input trace lock")
|
||||
.push(generate.protocol_payload().body()["input"].clone());
|
||||
}
|
||||
Ok(StartedExecution {
|
||||
request_id: gateway_core::engine::ModelRequestId::new("req_ws_crossing_limit")
|
||||
.expect("request id"),
|
||||
@@ -134,6 +152,9 @@ impl ExecutionService for CrossingLimitExecution {
|
||||
stream: request.metadata.stream,
|
||||
session: Box::new(CrossingLimitSession {
|
||||
trace: Arc::clone(&self.trace),
|
||||
started_emitted: false,
|
||||
terminal_emitted: false,
|
||||
finalized: false,
|
||||
}),
|
||||
})
|
||||
})
|
||||
@@ -192,7 +213,7 @@ fn crossing_limit_terminal_batch() -> CoordinatedEvent {
|
||||
}
|
||||
|
||||
use gateway_api::openai::responses::websocket::connection::{
|
||||
ConnectionConfig, ConnectionEvent, ConnectionWriteError, FramePhase,
|
||||
ConnectionConfig, ConnectionEvent, ConnectionWriteError, FramePhase, PumpExitReason,
|
||||
ResponsesWebSocketConnection, WriteContext, spawn_connection,
|
||||
};
|
||||
|
||||
@@ -255,12 +276,14 @@ struct PumpHarness {
|
||||
incoming: UnboundedSender<Result<Message, TestSocketError>>,
|
||||
written: UnboundedReceiver<Message>,
|
||||
dropped: Arc<AtomicBool>,
|
||||
cancellation: CancellationToken,
|
||||
}
|
||||
|
||||
fn test_connection(stall_writes: bool) -> PumpHarness {
|
||||
let (incoming_tx, incoming_rx) = unbounded_channel();
|
||||
let (written_tx, written_rx) = unbounded_channel();
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let cancellation = CancellationToken::new();
|
||||
let socket = TestSocket {
|
||||
incoming: incoming_rx,
|
||||
written: written_tx,
|
||||
@@ -270,7 +293,7 @@ fn test_connection(stall_writes: bool) -> PumpHarness {
|
||||
let connection = spawn_connection(
|
||||
socket,
|
||||
Arc::from("ws_test"),
|
||||
CancellationToken::new(),
|
||||
cancellation.clone(),
|
||||
ConnectionConfig::PRODUCTION,
|
||||
);
|
||||
PumpHarness {
|
||||
@@ -278,6 +301,7 @@ fn test_connection(stall_writes: bool) -> PumpHarness {
|
||||
incoming: incoming_tx,
|
||||
written: written_rx,
|
||||
dropped,
|
||||
cancellation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,18 +341,107 @@ async fn pump_consumes_pong_without_forwarding_a_business_event() {
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn pump_does_not_emit_an_active_ping() {
|
||||
async fn waiting_for_exit_should_preserve_queued_business_frames() {
|
||||
let PumpHarness {
|
||||
connection,
|
||||
incoming: _incoming,
|
||||
mut written,
|
||||
mut connection,
|
||||
incoming,
|
||||
..
|
||||
} = test_connection(false);
|
||||
tokio::time::advance(Duration::from_secs(5 * 60)).await;
|
||||
tokio::task::yield_now().await;
|
||||
for payload in ["first", "second"] {
|
||||
incoming
|
||||
.send(Ok(Message::Text(payload.into())))
|
||||
.expect("queue text");
|
||||
}
|
||||
|
||||
assert!(matches!(written.try_recv(), Err(TryRecvError::Empty)));
|
||||
drop(connection);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_secs(1), connection.wait_for_exit())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
for expected in ["first", "second"] {
|
||||
assert!(
|
||||
matches!(connection.next_event().await, Some(ConnectionEvent::Text(payload)) if payload == expected)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_close_should_take_priority_over_queued_business_frames() {
|
||||
let PumpHarness {
|
||||
mut connection,
|
||||
incoming,
|
||||
..
|
||||
} = test_connection(false);
|
||||
incoming
|
||||
.send(Ok(Message::Text("queued".into())))
|
||||
.expect("queue text");
|
||||
incoming
|
||||
.send(Ok(Message::Close(None)))
|
||||
.expect("close socket");
|
||||
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(1), connection.wait_for_exit())
|
||||
.await
|
||||
.expect("observe close"),
|
||||
PumpExitReason::ClientClose
|
||||
);
|
||||
assert!(matches!(
|
||||
connection.next_event().await,
|
||||
Some(ConnectionEvent::Exited(PumpExitReason::ClientClose))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_business_queue_should_not_block_lifecycle_shutdown() {
|
||||
let PumpHarness {
|
||||
mut connection,
|
||||
incoming,
|
||||
mut written,
|
||||
cancellation,
|
||||
..
|
||||
} = test_connection(false);
|
||||
for _ in 0..32 {
|
||||
incoming
|
||||
.send(Ok(Message::Text("queued".into())))
|
||||
.expect("queue text");
|
||||
}
|
||||
incoming
|
||||
.send(Ok(Message::Ping(vec![1].into())))
|
||||
.expect("send Ping after queue fills");
|
||||
assert!(matches!(written.recv().await, Some(Message::Pong(_))));
|
||||
cancellation.cancel();
|
||||
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(1), connection.wait_for_exit())
|
||||
.await
|
||||
.expect("observe shutdown"),
|
||||
PumpExitReason::LifecycleShutdown
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inbound_overload_should_close_without_executing_queued_frames() {
|
||||
let PumpHarness {
|
||||
mut connection,
|
||||
incoming,
|
||||
..
|
||||
} = test_connection(false);
|
||||
for _ in 0..33 {
|
||||
incoming
|
||||
.send(Ok(Message::Text("queued".into())))
|
||||
.expect("queue text");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(1), connection.wait_for_exit())
|
||||
.await
|
||||
.expect("observe overload"),
|
||||
PumpExitReason::InboundOverload
|
||||
);
|
||||
assert!(matches!(
|
||||
connection.next_event().await,
|
||||
Some(ConnectionEvent::Exited(PumpExitReason::InboundOverload))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -371,6 +484,7 @@ async fn stalled_write_times_out_at_three_hundred_seconds_and_aborts_the_pump()
|
||||
incoming: _incoming,
|
||||
written: _written,
|
||||
dropped,
|
||||
..
|
||||
} = test_connection(true);
|
||||
let request_id = Arc::<str>::from("req_test");
|
||||
|
||||
@@ -413,6 +527,7 @@ async fn dropping_the_connection_aborts_and_drops_the_socket_owner() {
|
||||
incoming: _incoming,
|
||||
written: _written,
|
||||
dropped,
|
||||
..
|
||||
} = test_connection(false);
|
||||
|
||||
drop(connection);
|
||||
@@ -428,6 +543,7 @@ async fn idle_connection_reaches_the_official_limit_without_starting_an_executio
|
||||
client: authenticated_client("sk_ws_atomic"),
|
||||
trace: Arc::clone(&trace),
|
||||
response_headers: Vec::new(),
|
||||
fail_before_first_event: false,
|
||||
});
|
||||
let app = api_router(execution).await;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
@@ -451,13 +567,17 @@ async fn idle_connection_reaches_the_official_limit_without_starting_an_executio
|
||||
|
||||
tokio::time::advance(Duration::from_secs(60 * 60)).await;
|
||||
tokio::task::yield_now().await;
|
||||
let message = tokio::time::timeout(Duration::from_secs(1), socket.next())
|
||||
.await
|
||||
.expect("connection limit response timeout")
|
||||
.expect("connection remains available for the limit error")
|
||||
.expect("valid WebSocket frame");
|
||||
let ClientMessage::Text(text) = message else {
|
||||
panic!("connection limit must be a text error event");
|
||||
let text = loop {
|
||||
let message = tokio::time::timeout(Duration::from_secs(1), socket.next())
|
||||
.await
|
||||
.expect("connection limit response timeout")
|
||||
.expect("connection remains available for the limit error")
|
||||
.expect("valid WebSocket frame");
|
||||
match message {
|
||||
ClientMessage::Text(text) => break text,
|
||||
ClientMessage::Ping(_) => {}
|
||||
other => panic!("unexpected frame before connection limit: {other:?}"),
|
||||
}
|
||||
};
|
||||
let value = serde_json::from_str::<Value>(&text).expect("connection limit JSON");
|
||||
|
||||
@@ -475,7 +595,8 @@ async fn idle_connection_reaches_the_official_limit_without_starting_an_executio
|
||||
);
|
||||
assert_eq!(trace.starts.load(Ordering::Acquire), 0);
|
||||
|
||||
socket.close(None).await.expect("close WebSocket");
|
||||
// 服务端已因生命周期到期关闭;不再向关闭中的连接写入。
|
||||
drop(socket);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
@@ -539,6 +660,27 @@ async fn active_response_crossing_the_limit_writes_terminal_before_the_limit_err
|
||||
}
|
||||
}
|
||||
|
||||
for _ in 0..32 {
|
||||
socket
|
||||
.send(ClientMessage::Text(
|
||||
json!({"type": "response.create", "model": "model-a", "input": "queued after limit"})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.expect("fill request queue before expiry");
|
||||
}
|
||||
socket
|
||||
.send(ClientMessage::Ping(vec![1].into()))
|
||||
.await
|
||||
.expect("send Ping after queued request");
|
||||
let pong = tokio::time::timeout(Duration::from_secs(1), socket.next())
|
||||
.await
|
||||
.expect("Pong timeout")
|
||||
.expect("connection remains open")
|
||||
.expect("valid frame");
|
||||
assert!(matches!(pong, ClientMessage::Pong(_)));
|
||||
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_secs(60 * 60)).await;
|
||||
tokio::task::yield_now().await;
|
||||
@@ -567,15 +709,21 @@ async fn active_response_crossing_the_limit_writes_terminal_before_the_limit_err
|
||||
|
||||
assert_eq!(event_types, ["response.completed", "error"]);
|
||||
assert!(trace.finalized.load(Ordering::Acquire));
|
||||
assert_eq!(trace.starts.load(Ordering::Acquire), 1);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn second_text_frame_during_an_active_response_keeps_the_single_response_contract() {
|
||||
type TestClientSocket = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
async fn start_active_response() -> (
|
||||
Arc<CrossingLimitTrace>,
|
||||
TestClientSocket,
|
||||
tokio::task::JoinHandle<()>,
|
||||
) {
|
||||
let trace = Arc::new(CrossingLimitTrace::default());
|
||||
let execution = Arc::new(CrossingLimitExecution {
|
||||
client: authenticated_client("sk_ws_crossing_limit"),
|
||||
trace,
|
||||
trace: Arc::clone(&trace),
|
||||
});
|
||||
let app = api_router(execution).await;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
@@ -607,7 +755,7 @@ async fn second_text_frame_during_an_active_response_keeps_the_single_response_c
|
||||
.into(),
|
||||
);
|
||||
socket
|
||||
.send(response_create.clone())
|
||||
.send(response_create)
|
||||
.await
|
||||
.expect("send first response.create");
|
||||
loop {
|
||||
@@ -625,19 +773,300 @@ async fn second_text_frame_during_an_active_response_keeps_the_single_response_c
|
||||
}
|
||||
}
|
||||
|
||||
(trace, socket, server)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requests_received_during_an_active_response_should_execute_in_order() {
|
||||
let (trace, mut socket, server) = start_active_response().await;
|
||||
for input in ["second", "third"] {
|
||||
socket
|
||||
.send(ClientMessage::Text(
|
||||
json!({"type": "response.create", "model": "model-a", "input": input})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.expect("send queued response.create");
|
||||
}
|
||||
socket
|
||||
.send(response_create)
|
||||
.send(ClientMessage::Ping(vec![1, 2, 3].into()))
|
||||
.await
|
||||
.expect("send overlapping response.create");
|
||||
.expect("send Ping after queued requests");
|
||||
let message = tokio::time::timeout(Duration::from_secs(2), socket.next())
|
||||
.await
|
||||
.expect("Pong timeout while response is active")
|
||||
.expect("WebSocket remains open")
|
||||
.expect("valid WebSocket frame");
|
||||
assert!(matches!(message, ClientMessage::Pong(_)));
|
||||
assert_eq!(trace.starts.load(Ordering::Acquire), 1);
|
||||
|
||||
for completed_count in 1..=3 {
|
||||
trace.release_terminal.notify_one();
|
||||
let expected_types = if completed_count < 3 {
|
||||
vec![
|
||||
"response.completed",
|
||||
"response.metadata",
|
||||
"response.created",
|
||||
]
|
||||
} else {
|
||||
vec!["response.completed"]
|
||||
};
|
||||
let mut event_types = Vec::new();
|
||||
for _ in &expected_types {
|
||||
let message = tokio::time::timeout(Duration::from_secs(2), socket.next())
|
||||
.await
|
||||
.expect("serial response timeout")
|
||||
.expect("WebSocket remains open")
|
||||
.expect("valid WebSocket frame");
|
||||
let ClientMessage::Text(text) = message else {
|
||||
panic!("unexpected frame during serial execution: {message:?}");
|
||||
};
|
||||
let event: Value = serde_json::from_str(&text).expect("response event JSON");
|
||||
event_types.push(event["type"].as_str().expect("event type").to_owned());
|
||||
}
|
||||
assert_eq!(event_types, expected_types);
|
||||
assert_eq!(
|
||||
trace.starts.load(Ordering::Acquire),
|
||||
(completed_count + 1).min(3)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
*trace.inputs.lock().expect("input trace lock"),
|
||||
vec![json!("hello"), json!("second"), json!("third")]
|
||||
);
|
||||
assert!(!trace.cancelled.load(Ordering::Acquire));
|
||||
socket.close(None).await.expect("close WebSocket");
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_unknown_message_should_be_rejected_before_the_next_valid_request() {
|
||||
let (trace, mut socket, server) = start_active_response().await;
|
||||
for payload in [
|
||||
json!({"type": "response.future"}),
|
||||
json!({"type": "response.create", "model": "model-a", "input": "next"}),
|
||||
] {
|
||||
socket
|
||||
.send(ClientMessage::Text(payload.to_string().into()))
|
||||
.await
|
||||
.expect("queue frame");
|
||||
}
|
||||
trace.release_terminal.notify_one();
|
||||
|
||||
let mut event_types = Vec::new();
|
||||
for _ in 0..4 {
|
||||
let message = tokio::time::timeout(Duration::from_secs(2), socket.next())
|
||||
.await
|
||||
.expect("response timeout")
|
||||
.expect("connection remains open")
|
||||
.expect("valid frame");
|
||||
let ClientMessage::Text(text) = message else {
|
||||
panic!("unexpected frame: {message:?}");
|
||||
};
|
||||
let event: Value = serde_json::from_str(&text).expect("event JSON");
|
||||
event_types.push(event["type"].as_str().expect("event type").to_owned());
|
||||
}
|
||||
assert_eq!(
|
||||
event_types,
|
||||
[
|
||||
"response.completed",
|
||||
"error",
|
||||
"response.metadata",
|
||||
"response.created"
|
||||
]
|
||||
);
|
||||
assert_eq!(trace.starts.load(Ordering::Acquire), 2);
|
||||
assert_eq!(
|
||||
*trace.inputs.lock().expect("input trace lock"),
|
||||
vec![json!("hello"), json!("next")]
|
||||
);
|
||||
socket.close(None).await.expect("close WebSocket");
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_binary_frame_should_be_rejected_after_the_active_response_finishes() {
|
||||
let (trace, mut socket, server) = start_active_response().await;
|
||||
socket
|
||||
.send(ClientMessage::Binary(b"{}".to_vec().into()))
|
||||
.await
|
||||
.expect("queue binary frame");
|
||||
trace.release_terminal.notify_one();
|
||||
|
||||
let message = tokio::time::timeout(Duration::from_secs(2), socket.next())
|
||||
.await
|
||||
.expect("terminal timeout")
|
||||
.expect("connection remains open")
|
||||
.expect("valid frame");
|
||||
let ClientMessage::Text(text) = message else {
|
||||
panic!("active response did not finish before binary rejection: {message:?}");
|
||||
};
|
||||
let event: Value = serde_json::from_str(&text).expect("event JSON");
|
||||
assert_eq!(event["type"], "response.completed");
|
||||
let message = tokio::time::timeout(Duration::from_secs(2), socket.next())
|
||||
.await
|
||||
.expect("policy close timeout")
|
||||
.expect("WebSocket returns a policy close")
|
||||
.expect("valid WebSocket frame");
|
||||
let ClientMessage::Close(Some(frame)) = message else {
|
||||
panic!("overlapping response.create must close the connection");
|
||||
.expect("connection closes")
|
||||
.expect("valid frame");
|
||||
assert!(
|
||||
matches!(message, ClientMessage::Close(Some(frame)) if frame.code == CloseCode::Policy)
|
||||
);
|
||||
assert_eq!(trace.starts.load(Ordering::Acquire), 1);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_close_should_cancel_active_execution_without_starting_queued_requests() {
|
||||
let (trace, mut socket, server) = start_active_response().await;
|
||||
socket
|
||||
.send(ClientMessage::Text(
|
||||
json!({"type": "response.create", "model": "model-a", "input": "queued"})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.expect("queue request");
|
||||
socket.close(None).await.expect("close WebSocket");
|
||||
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
trace.cancellation_observed.notified(),
|
||||
)
|
||||
.await
|
||||
.expect("cancel active execution promptly");
|
||||
assert_eq!(trace.starts.load(Ordering::Acquire), 1);
|
||||
assert!(trace.cancelled.load(Ordering::Acquire));
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn idle_pump_sends_heartbeat_without_bursting_after_a_delay() {
|
||||
let PumpHarness {
|
||||
connection,
|
||||
incoming: _incoming,
|
||||
mut written,
|
||||
..
|
||||
} = test_connection(false);
|
||||
tokio::task::yield_now().await;
|
||||
assert!(matches!(written.try_recv(), Err(TryRecvError::Empty)));
|
||||
|
||||
tokio::time::advance(Duration::from_secs(25)).await;
|
||||
let first = written.recv().await.expect("first heartbeat");
|
||||
assert!(matches!(first, Message::Ping(_)));
|
||||
|
||||
tokio::time::advance(Duration::from_secs(120)).await;
|
||||
let second = written.recv().await.expect("heartbeat after delay");
|
||||
assert!(matches!(&second, Message::Ping(_)));
|
||||
assert_ne!(first, second);
|
||||
tokio::task::yield_now().await;
|
||||
assert!(matches!(written.try_recv(), Err(TryRecvError::Empty)));
|
||||
drop(connection);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn missing_pongs_do_not_cancel_the_connection_or_block_business_frames() {
|
||||
let PumpHarness {
|
||||
mut connection,
|
||||
incoming,
|
||||
mut written,
|
||||
..
|
||||
} = test_connection(false);
|
||||
for _ in 0..13 {
|
||||
tokio::time::advance(Duration::from_secs(25)).await;
|
||||
assert!(matches!(written.recv().await, Some(Message::Ping(_))));
|
||||
}
|
||||
|
||||
incoming
|
||||
.send(Ok(Message::Text("still connected".into())))
|
||||
.expect("send business frame after 325 seconds without Pong");
|
||||
assert!(matches!(
|
||||
connection.next_event().await,
|
||||
Some(ConnectionEvent::Text(text)) if text == "still connected"
|
||||
));
|
||||
incoming.send(Ok(Message::Close(None))).expect("close peer");
|
||||
assert_eq!(
|
||||
connection.wait_for_exit().await,
|
||||
PumpExitReason::ClientClose
|
||||
);
|
||||
tokio::time::advance(Duration::from_secs(25)).await;
|
||||
assert!(written.recv().await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn stalled_heartbeat_can_be_cancelled_without_waiting_for_write_timeout() {
|
||||
let PumpHarness {
|
||||
mut connection,
|
||||
incoming: _incoming,
|
||||
written: _written,
|
||||
dropped,
|
||||
cancellation,
|
||||
} = test_connection(true);
|
||||
tokio::time::advance(Duration::from_secs(25)).await;
|
||||
tokio::task::yield_now().await;
|
||||
cancellation.cancel();
|
||||
let reason = tokio::time::timeout(Duration::from_secs(1), connection.wait_for_exit())
|
||||
.await
|
||||
.expect("cancel a blocked heartbeat promptly");
|
||||
assert_eq!(reason, PumpExitReason::LifecycleShutdown);
|
||||
assert!(dropped.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn stalled_heartbeat_obeys_the_existing_write_timeout() {
|
||||
let PumpHarness {
|
||||
mut connection,
|
||||
incoming: _incoming,
|
||||
written: _written,
|
||||
dropped,
|
||||
..
|
||||
} = test_connection(true);
|
||||
let started = tokio::time::Instant::now();
|
||||
assert_eq!(
|
||||
connection.wait_for_exit().await,
|
||||
PumpExitReason::WriteTimeout
|
||||
);
|
||||
assert_eq!(started.elapsed(), Duration::from_secs(25 + 300));
|
||||
assert!(dropped.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_response_receives_heartbeats_during_two_hundred_seconds_of_silence() {
|
||||
let (trace, mut socket, server) = start_active_response().await;
|
||||
for _ in 0..8 {
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_secs(25)).await;
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::resume();
|
||||
let message = tokio::time::timeout(Duration::from_secs(2), socket.next())
|
||||
.await
|
||||
.expect("heartbeat while the upstream response is pending")
|
||||
.expect("connection remains open")
|
||||
.expect("valid heartbeat frame");
|
||||
let ClientMessage::Ping(payload) = message else {
|
||||
panic!("expected heartbeat while business stream is silent: {message:?}");
|
||||
};
|
||||
// 与官方 WsStream 一样,在业务响应未完成时回复同 payload 的 Pong。
|
||||
socket
|
||||
.send(ClientMessage::Pong(payload))
|
||||
.await
|
||||
.expect("reply to heartbeat");
|
||||
}
|
||||
assert_eq!(trace.starts.load(Ordering::Acquire), 1);
|
||||
assert!(!trace.cancelled.load(Ordering::Acquire));
|
||||
|
||||
trace.release_terminal.notify_one();
|
||||
let message = tokio::time::timeout(Duration::from_secs(2), socket.next())
|
||||
.await
|
||||
.expect("terminal response after heartbeats")
|
||||
.expect("connection remains open")
|
||||
.expect("valid terminal frame");
|
||||
let ClientMessage::Text(text) = message else {
|
||||
panic!("expected completed response: {message:?}");
|
||||
};
|
||||
|
||||
assert_eq!(frame.code, CloseCode::Policy);
|
||||
let value: Value = serde_json::from_str(&text).expect("terminal JSON");
|
||||
assert_eq!(value["type"], "response.completed");
|
||||
assert_eq!(trace.starts.load(Ordering::Acquire), 1);
|
||||
socket.close(None).await.expect("close WebSocket");
|
||||
server.abort();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ mod connection;
|
||||
mod protocol;
|
||||
|
||||
use std::sync::{
|
||||
Arc,
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
};
|
||||
use std::time::{Duration, SystemTime};
|
||||
@@ -280,17 +280,26 @@ struct AtomicFailureTrace {
|
||||
next_calls: AtomicUsize,
|
||||
committed: AtomicBool,
|
||||
finalized: AtomicBool,
|
||||
downstream_websocket_connection_ids: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
struct AtomicFailureSession {
|
||||
trace: Arc<AtomicFailureTrace>,
|
||||
response_headers: Vec<ProviderResponseHeader>,
|
||||
fail_before_first_event: bool,
|
||||
}
|
||||
|
||||
impl ExecutionSession for AtomicFailureSession {
|
||||
fn next_event(&mut self) -> BoxFuture<'_, Result<Option<CoordinatedEvent>, EngineError>> {
|
||||
Box::pin(async move {
|
||||
match self.trace.next_calls.fetch_add(1, Ordering::AcqRel) {
|
||||
let next_call = self.trace.next_calls.fetch_add(1, Ordering::AcqRel);
|
||||
if self.fail_before_first_event && next_call == 0 {
|
||||
return Err(EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::Unavailable,
|
||||
UpstreamSendState::Ambiguous,
|
||||
)));
|
||||
}
|
||||
match next_call {
|
||||
0 => Ok(Some(atomic_failure_batch())),
|
||||
1 => {
|
||||
self.trace.finalized.store(true, Ordering::Release);
|
||||
@@ -340,6 +349,7 @@ struct AtomicFailureExecution {
|
||||
client: AuthenticatedClient,
|
||||
trace: Arc<AtomicFailureTrace>,
|
||||
response_headers: Vec<ProviderResponseHeader>,
|
||||
fail_before_first_event: bool,
|
||||
}
|
||||
|
||||
impl ExecutionService for AtomicFailureExecution {
|
||||
@@ -365,6 +375,19 @@ impl ExecutionService for AtomicFailureExecution {
|
||||
request: StartExecution,
|
||||
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
|
||||
self.trace.starts.fetch_add(1, Ordering::AcqRel);
|
||||
if let Operation::Generate(generate) = &request.operation
|
||||
&& let Some(connection_id) = generate
|
||||
.protocol_payload()
|
||||
.context()
|
||||
.get("downstream_websocket_connection_id")
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
self.trace
|
||||
.downstream_websocket_connection_ids
|
||||
.lock()
|
||||
.expect("downstream connection trace lock")
|
||||
.push(connection_id.to_owned());
|
||||
}
|
||||
Box::pin(async move {
|
||||
Ok(StartedExecution {
|
||||
request_id: gateway_core::engine::ModelRequestId::new("req_ws_atomic")
|
||||
@@ -374,6 +397,7 @@ impl ExecutionService for AtomicFailureExecution {
|
||||
session: Box::new(AtomicFailureSession {
|
||||
trace: Arc::clone(&self.trace),
|
||||
response_headers: self.response_headers.clone(),
|
||||
fail_before_first_event: self.fail_before_first_event,
|
||||
}),
|
||||
})
|
||||
})
|
||||
@@ -439,6 +463,7 @@ async fn websocket_atomic_upstream_failure_batch_should_be_forwarded_once() {
|
||||
client: authenticated_client("sk_ws_atomic"),
|
||||
trace: Arc::clone(&trace),
|
||||
response_headers: Vec::new(),
|
||||
fail_before_first_event: false,
|
||||
});
|
||||
let app = api_router(execution).await;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
@@ -510,6 +535,14 @@ async fn websocket_atomic_upstream_failure_batch_should_be_forwarded_once() {
|
||||
assert!(trace.committed.load(Ordering::Acquire));
|
||||
assert_eq!(trace.next_calls.load(Ordering::Acquire), 2);
|
||||
assert!(trace.finalized.load(Ordering::Acquire));
|
||||
{
|
||||
let downstream_connection_ids = trace
|
||||
.downstream_websocket_connection_ids
|
||||
.lock()
|
||||
.expect("downstream connection trace lock");
|
||||
assert_eq!(downstream_connection_ids.len(), 1);
|
||||
assert!(downstream_connection_ids[0].starts_with("ws_"));
|
||||
}
|
||||
|
||||
socket.close(None).await.expect("close WebSocket");
|
||||
server.abort();
|
||||
@@ -540,6 +573,7 @@ async fn websocket_response_metadata_should_preserve_ordinary_headers_without_bl
|
||||
ProviderResponseHeader::new("content-type", Bytes::from_static(b"application/private")),
|
||||
ProviderResponseHeader::new("x-request-id", Bytes::from_static(b"req_upstream")),
|
||||
],
|
||||
fail_before_first_event: false,
|
||||
});
|
||||
let app = api_router(execution).await;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
@@ -624,6 +658,91 @@ async fn websocket_response_metadata_should_preserve_ordinary_headers_without_bl
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_failure_before_first_event_should_return_observed_metadata_then_error() {
|
||||
let trace = Arc::new(AtomicFailureTrace::default());
|
||||
let execution = Arc::new(AtomicFailureExecution {
|
||||
client: authenticated_client("sk_ws_atomic"),
|
||||
trace: Arc::clone(&trace),
|
||||
response_headers: vec![
|
||||
ProviderResponseHeader::new(
|
||||
"x-codex-turn-state",
|
||||
Bytes::from_static(b"turn-state-before-close"),
|
||||
),
|
||||
ProviderResponseHeader::new(
|
||||
"authorization",
|
||||
Bytes::from_static(b"must-not-cross-boundary"),
|
||||
),
|
||||
],
|
||||
fail_before_first_event: true,
|
||||
});
|
||||
let app = api_router(execution).await;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind WebSocket test server");
|
||||
let address = listener.local_addr().expect("test server address");
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("serve WebSocket test router");
|
||||
});
|
||||
let mut request = format!("ws://{address}/v1/responses")
|
||||
.into_client_request()
|
||||
.expect("WebSocket request");
|
||||
request.headers_mut().insert(
|
||||
AUTHORIZATION,
|
||||
"Bearer sk_ws_atomic".parse().expect("authorization"),
|
||||
);
|
||||
let (mut socket, response) = connect_async(request).await.expect("upgrade WebSocket");
|
||||
assert_eq!(response.status(), StatusCode::SWITCHING_PROTOCOLS);
|
||||
socket
|
||||
.send(ClientMessage::Text(
|
||||
json!({
|
||||
"type": "response.create",
|
||||
"model": "model-a",
|
||||
"input": "hello"
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.expect("send response.create");
|
||||
|
||||
let mut messages = Vec::<Value>::new();
|
||||
while messages.len() < 2 {
|
||||
let message = tokio::time::timeout(Duration::from_secs(2), socket.next())
|
||||
.await
|
||||
.expect("WebSocket response timeout")
|
||||
.expect("WebSocket remains open")
|
||||
.expect("valid WebSocket frame");
|
||||
let ClientMessage::Text(text) = message else {
|
||||
continue;
|
||||
};
|
||||
messages.push(serde_json::from_str(&text).expect("JSON WebSocket event"));
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
messages
|
||||
.iter()
|
||||
.filter_map(|message| message.get("type").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["response.metadata", "error"]
|
||||
);
|
||||
let headers = messages[0]
|
||||
.get("headers")
|
||||
.and_then(Value::as_object)
|
||||
.expect("response metadata headers");
|
||||
assert_eq!(
|
||||
headers.get("x-codex-turn-state"),
|
||||
Some(&json!("turn-state-before-close"))
|
||||
);
|
||||
assert!(!headers.contains_key("authorization"));
|
||||
assert!(!trace.committed.load(Ordering::Acquire));
|
||||
|
||||
socket.close(None).await.expect("close WebSocket");
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_response_create_should_not_have_a_private_16_mib_frame_limit() {
|
||||
let trace = Arc::new(AtomicFailureTrace::default());
|
||||
@@ -631,6 +750,7 @@ async fn websocket_response_create_should_not_have_a_private_16_mib_frame_limit(
|
||||
client: authenticated_client("sk_ws_atomic"),
|
||||
trace,
|
||||
response_headers: Vec::new(),
|
||||
fail_before_first_event: false,
|
||||
});
|
||||
let app = api_router(execution).await;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
|
||||
@@ -22,7 +22,7 @@ use gateway_core::upstream::UpstreamSendState;
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::provider_endpoint::BufferedJsonSession;
|
||||
use super::endpoint::BufferedJsonSession;
|
||||
use super::{api_router, authenticated_client};
|
||||
|
||||
const SEARCH_RESPONSE: &[u8] = br#"{ "encrypted_output":"ciphertext", "output":"search result", "results":[{"type":"text_result","ref_id":"turn0search0","future":9007199254740993}] }"#;
|
||||
@@ -162,6 +162,8 @@ async fn search_route_should_preserve_request_and_success_response_bytes() {
|
||||
.header(AUTHORIZATION, "Bearer sk_search_test")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-codex-turn-metadata", turn_metadata)
|
||||
.header("session-id", "root-session")
|
||||
.header("thread-id", "child-thread")
|
||||
.body(Body::from(request_body.to_vec()))
|
||||
.expect("search request"),
|
||||
)
|
||||
@@ -184,7 +186,10 @@ async fn search_route_should_preserve_request_and_success_response_bytes() {
|
||||
assert_eq!(captured[0].endpoint, "/v1/alpha/search");
|
||||
assert_eq!(captured[0].transport, ClientTransport::HttpJson);
|
||||
assert_eq!(captured[0].body.as_ref(), request_body);
|
||||
assert_eq!(captured[0].context, json!({"turn_metadata": turn_metadata}));
|
||||
assert_eq!(
|
||||
captured[0].context,
|
||||
json!({"turn_metadata": turn_metadata, "session_id": "root-session", "thread_id": "child-thread"})
|
||||
);
|
||||
assert_eq!(
|
||||
*execution
|
||||
.committed_statuses
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name = "gateway-core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
license = "Apache-2.0"
|
||||
publish = false
|
||||
rust-version = "1.97"
|
||||
autotests = false
|
||||
@@ -13,15 +13,18 @@ path = "tests/main.rs"
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1.89"
|
||||
base64 = "0.23.0"
|
||||
bytes = "1.11.1"
|
||||
futures = "0.3.32"
|
||||
futures-timer = "3.0.3"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
|
||||
sha2 = "0.11.0"
|
||||
semver = "1.0.27"
|
||||
thiserror = "2.0.18"
|
||||
tracing = "0.1.44"
|
||||
uuid = { version = "1.23.3", features = ["v7"] }
|
||||
url = "2.5.8"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
mod error;
|
||||
mod model;
|
||||
mod proxy;
|
||||
pub use proxy::{InvalidOutboundProxy, OutboundProxy};
|
||||
pub mod scope;
|
||||
mod selection;
|
||||
mod store;
|
||||
|
||||
pub use error::CredentialError;
|
||||
pub use model::*;
|
||||
pub use selection::*;
|
||||
pub(crate) use selection::{SMART_SCORE_TOLERANCE, smart_score};
|
||||
pub use store::ProviderAccountStore;
|
||||
|
||||
@@ -6,8 +6,8 @@ use std::time::{Duration, SystemTime};
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::error::{IdentifierError, validate_text};
|
||||
use crate::routing::ProviderKind;
|
||||
use crate::identity::ProviderKind;
|
||||
use crate::validation::{IdentifierError, validate_text};
|
||||
|
||||
use super::CredentialError;
|
||||
|
||||
@@ -654,7 +654,7 @@ const fn status_projection(status: AccountStatus) -> AccountStatusProjection {
|
||||
}
|
||||
}
|
||||
|
||||
/// 不含 secret 的账号持久事实。
|
||||
/// 账号持久事实;代理认证信息只通过显式 secret accessor 读取。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderAccount {
|
||||
id: ProviderAccountId,
|
||||
@@ -676,6 +676,7 @@ pub struct ProviderAccount {
|
||||
access_token_expires_at: Option<SystemTime>,
|
||||
next_refresh_at: Option<SystemTime>,
|
||||
has_refresh_token: bool,
|
||||
outbound_proxy: Option<super::OutboundProxy>,
|
||||
}
|
||||
|
||||
impl ProviderAccount {
|
||||
@@ -710,6 +711,7 @@ impl ProviderAccount {
|
||||
access_token_expires_at,
|
||||
next_refresh_at: None,
|
||||
has_refresh_token: false,
|
||||
outbound_proxy: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,6 +728,17 @@ impl ProviderAccount {
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_outbound_proxy(mut self, proxy: Option<super::OutboundProxy>) -> Self {
|
||||
self.outbound_proxy = proxy;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn outbound_proxy(&self) -> Option<&super::OutboundProxy> {
|
||||
self.outbound_proxy.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_account_facts(
|
||||
mut self,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Explicit account egress. Credentials never appear in Debug or ordinary admin projections.
|
||||
|
||||
use std::fmt;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct OutboundProxy(Url);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("invalid outbound proxy; expected http, https, socks5 or socks5h URL with a host and port")]
|
||||
pub struct InvalidOutboundProxy;
|
||||
|
||||
impl OutboundProxy {
|
||||
pub fn parse(value: &str) -> Result<Self, InvalidOutboundProxy> {
|
||||
if value.len() > 4096 || value.chars().any(char::is_control) {
|
||||
return Err(InvalidOutboundProxy);
|
||||
}
|
||||
let url = Url::parse(value).map_err(|_| InvalidOutboundProxy)?;
|
||||
if !matches!(url.scheme(), "http" | "https" | "socks5" | "socks5h")
|
||||
|| url.host_str().is_none()
|
||||
|| url.port_or_known_default().is_none_or(|port| port == 0)
|
||||
|| !matches!(url.path(), "" | "/")
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return Err(InvalidOutboundProxy);
|
||||
}
|
||||
Ok(Self(url))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn expose_url(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn endpoint(&self) -> String {
|
||||
let mut url = self.0.clone();
|
||||
let _ = url.set_username("");
|
||||
let _ = url.set_password(None);
|
||||
url.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for OutboundProxy {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("OutboundProxy(<redacted>)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! 请求认证时冻结的账号范围与目录,不依赖路由选择器。
|
||||
|
||||
use super::ProviderAccountId;
|
||||
use crate::identity::ProviderKind;
|
||||
use crate::validation::{IdentifierError, RoutingError};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fmt,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
/// `account_groups.id` 的核心值对象。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct AccountGroupId(String);
|
||||
|
||||
impl AccountGroupId {
|
||||
/// 校验并创建账号分组 ID。
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, IdentifierError> {
|
||||
let value = value.into();
|
||||
let Some(suffix) = value.strip_prefix("grp_") else {
|
||||
return Err(IdentifierError::MissingPrefix { expected: "grp_" });
|
||||
};
|
||||
if suffix.len() != 32
|
||||
|| !suffix
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
{
|
||||
return Err(IdentifierError::InvalidFormat);
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AccountGroupId {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 快照中一个账号的 Provider 与分组归属。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeAccount {
|
||||
provider_kind: ProviderKind,
|
||||
group_ids: Arc<BTreeSet<AccountGroupId>>,
|
||||
}
|
||||
|
||||
impl RuntimeAccount {
|
||||
#[must_use]
|
||||
pub fn new(provider_kind: ProviderKind, group_ids: BTreeSet<AccountGroupId>) -> Self {
|
||||
Self {
|
||||
provider_kind,
|
||||
group_ids: Arc::new(group_ids),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn provider_kind(&self) -> &ProviderKind {
|
||||
&self.provider_kind
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn group_ids(&self) -> &BTreeSet<AccountGroupId> {
|
||||
&self.group_ids
|
||||
}
|
||||
}
|
||||
|
||||
/// 全快照共享的账号、Provider 与分组反向索引。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct RuntimeAccountDirectory {
|
||||
accounts: BTreeMap<ProviderAccountId, RuntimeAccount>,
|
||||
providers_with_accounts: BTreeSet<ProviderKind>,
|
||||
providers_by_group: BTreeMap<AccountGroupId, BTreeSet<ProviderKind>>,
|
||||
}
|
||||
|
||||
impl RuntimeAccountDirectory {
|
||||
#[must_use]
|
||||
pub fn new(accounts: BTreeMap<ProviderAccountId, RuntimeAccount>) -> Self {
|
||||
let mut providers_with_accounts = BTreeSet::new();
|
||||
let mut providers_by_group = BTreeMap::<AccountGroupId, BTreeSet<ProviderKind>>::new();
|
||||
for account in accounts.values() {
|
||||
providers_with_accounts.insert(account.provider_kind.clone());
|
||||
for group_id in account.group_ids.iter() {
|
||||
providers_by_group
|
||||
.entry(group_id.clone())
|
||||
.or_default()
|
||||
.insert(account.provider_kind.clone());
|
||||
}
|
||||
}
|
||||
Self {
|
||||
accounts,
|
||||
providers_with_accounts,
|
||||
providers_by_group,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn account(&self, account_id: &ProviderAccountId) -> Option<&RuntimeAccount> {
|
||||
self.accounts.get(account_id)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn providers_with_accounts(&self) -> &BTreeSet<ProviderKind> {
|
||||
&self.providers_with_accounts
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn providers_for_groups<'a>(
|
||||
&self,
|
||||
group_ids: impl IntoIterator<Item = &'a AccountGroupId>,
|
||||
) -> BTreeSet<ProviderKind> {
|
||||
group_ids
|
||||
.into_iter()
|
||||
.filter_map(|group_id| self.providers_by_group.get(group_id))
|
||||
.flatten()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史请求保存的账号范围种类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AccountRoutingScopeKind {
|
||||
All,
|
||||
Groups,
|
||||
}
|
||||
|
||||
impl AccountRoutingScopeKind {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::All => "all",
|
||||
Self::Groups => "groups",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求开始时冻结的分组 ID 与名称。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RoutingGroupSnapshot {
|
||||
id: AccountGroupId,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl RoutingGroupSnapshot {
|
||||
#[must_use]
|
||||
pub fn new(id: AccountGroupId, name: String) -> Self {
|
||||
Self { id, name }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> &AccountGroupId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求历史所需的完整、稳定账号范围快照。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountRoutingSnapshot {
|
||||
kind: AccountRoutingScopeKind,
|
||||
groups: Arc<[RoutingGroupSnapshot]>,
|
||||
}
|
||||
|
||||
impl AccountRoutingSnapshot {
|
||||
#[must_use]
|
||||
pub fn all() -> Self {
|
||||
Self {
|
||||
kind: AccountRoutingScopeKind::All,
|
||||
groups: Arc::from([]),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn groups(groups: Vec<RoutingGroupSnapshot>) -> Self {
|
||||
Self {
|
||||
kind: AccountRoutingScopeKind::Groups,
|
||||
groups: Arc::from(groups),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> AccountRoutingScopeKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn groups_snapshot(&self) -> &[RoutingGroupSnapshot] {
|
||||
&self.groups
|
||||
}
|
||||
}
|
||||
|
||||
/// Key 持久 binding 编译出的账号权限。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ClientRoutingScope {
|
||||
AllAccounts,
|
||||
Restricted {
|
||||
bound_groups: Arc<[RoutingGroupSnapshot]>,
|
||||
enabled_group_ids: Arc<BTreeSet<AccountGroupId>>,
|
||||
provider_kinds: Arc<BTreeSet<ProviderKind>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ClientRoutingScope {
|
||||
#[must_use]
|
||||
pub fn all_accounts() -> Self {
|
||||
Self::AllAccounts
|
||||
}
|
||||
|
||||
pub fn restricted(
|
||||
bound_groups: Vec<RoutingGroupSnapshot>,
|
||||
enabled_group_ids: BTreeSet<AccountGroupId>,
|
||||
provider_kinds: BTreeSet<ProviderKind>,
|
||||
) -> Result<Self, RoutingError> {
|
||||
if bound_groups.is_empty() {
|
||||
return Err(RoutingError::InvalidAccountScope);
|
||||
}
|
||||
Ok(Self::Restricted {
|
||||
bound_groups: Arc::from(bound_groups),
|
||||
enabled_group_ids: Arc::new(enabled_group_ids),
|
||||
provider_kinds: Arc::new(provider_kinds),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次认证随 RuntimeSnapshot 冻结的账号目录与 Key 权限。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FrozenAccountScope {
|
||||
directory: Arc<RuntimeAccountDirectory>,
|
||||
client_scope: ClientRoutingScope,
|
||||
}
|
||||
|
||||
impl FrozenAccountScope {
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
directory: Arc<RuntimeAccountDirectory>,
|
||||
client_scope: ClientRoutingScope,
|
||||
) -> Self {
|
||||
Self {
|
||||
directory,
|
||||
client_scope,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn allows(&self, account_id: &ProviderAccountId) -> bool {
|
||||
let Some(account) = self.directory.account(account_id) else {
|
||||
return false;
|
||||
};
|
||||
match &self.client_scope {
|
||||
ClientRoutingScope::AllAccounts => true,
|
||||
ClientRoutingScope::Restricted {
|
||||
enabled_group_ids, ..
|
||||
} => account
|
||||
.group_ids()
|
||||
.iter()
|
||||
.any(|group_id| enabled_group_ids.contains(group_id)),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn provider_kinds(&self) -> &BTreeSet<ProviderKind> {
|
||||
match &self.client_scope {
|
||||
ClientRoutingScope::AllAccounts => self.directory.providers_with_accounts(),
|
||||
ClientRoutingScope::Restricted { provider_kinds, .. } => provider_kinds,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn routing_snapshot(&self) -> AccountRoutingSnapshot {
|
||||
match &self.client_scope {
|
||||
ClientRoutingScope::AllAccounts => AccountRoutingSnapshot::all(),
|
||||
ClientRoutingScope::Restricted { bound_groups, .. } => {
|
||||
AccountRoutingSnapshot::groups(bound_groups.to_vec())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn directory(&self) -> &Arc<RuntimeAccountDirectory> {
|
||||
&self.directory
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, RwLock};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use crate::routing::ProviderKind;
|
||||
use crate::identity::ProviderKind;
|
||||
|
||||
use super::{AccountStatus, ProviderAccount, ProviderAccountId};
|
||||
|
||||
@@ -87,6 +87,7 @@ pub struct AccountRuntimeSignals {
|
||||
pub in_flight: u32,
|
||||
pub last_started_at: Option<SystemTime>,
|
||||
pub quota_reset_at: Option<SystemTime>,
|
||||
/// Provider 归一化的剩余额度基点:0 耗尽,10_000 全部可用。
|
||||
pub quota_remaining_rank: Option<u64>,
|
||||
pub rate_limited_until: Option<SystemTime>,
|
||||
pub failure_rate_basis_points: Option<u16>,
|
||||
@@ -323,6 +324,7 @@ pub struct AccountQuotaSignals {
|
||||
}
|
||||
|
||||
impl AccountQuotaSignals {
|
||||
/// `remaining_rank` 使用剩余额度基点,范围为 0..=10_000。
|
||||
#[must_use]
|
||||
pub const fn new(reset_at: Option<SystemTime>, remaining_rank: Option<u64>) -> Self {
|
||||
Self {
|
||||
@@ -386,9 +388,11 @@ pub struct AccountSelectionContext {
|
||||
pub now: SystemTime,
|
||||
pub excluded_accounts: BTreeSet<ProviderAccountId>,
|
||||
pub preferred_account: Option<ProviderAccountId>,
|
||||
/// 优先账号可调度时,是否优先于其它账号的持久权重。
|
||||
pub preferred_account_overrides_weight: bool,
|
||||
pub round_robin_cursor: u64,
|
||||
pub eligibility: AccountEligibilityPolicy,
|
||||
pub account_scope: Option<std::sync::Arc<crate::routing::FrozenAccountScope>>,
|
||||
pub account_scope: Option<std::sync::Arc<crate::account::scope::FrozenAccountScope>>,
|
||||
}
|
||||
|
||||
/// 选择账号时是否执行本地调度资格投影。
|
||||
@@ -516,7 +520,9 @@ impl AccountSelector {
|
||||
{
|
||||
Some(candidate) => match self.scheduling_blocker(candidate, context) {
|
||||
Some(blocker) => PreferredAccountSelection::Blocked(blocker),
|
||||
None if candidate.account.weight() < highest_weight => {
|
||||
None if !context.preferred_account_overrides_weight
|
||||
&& candidate.account.weight() < highest_weight =>
|
||||
{
|
||||
PreferredAccountSelection::Blocked(AccountSchedulingBlocker::LowerWeight)
|
||||
}
|
||||
None => {
|
||||
@@ -584,7 +590,7 @@ impl AccountSelector {
|
||||
})
|
||||
}
|
||||
|
||||
fn scheduling_blocker(
|
||||
pub(crate) fn scheduling_blocker(
|
||||
&self,
|
||||
candidate: &AccountCandidate,
|
||||
context: &AccountSelectionContext,
|
||||
@@ -636,6 +642,10 @@ const SMART_LOAD_WEIGHT: f64 = 1.0;
|
||||
const SMART_QUOTA_WEIGHT: f64 = 0.8;
|
||||
const SMART_FAILURE_WEIGHT: f64 = 1.0;
|
||||
const SMART_LATENCY_WEIGHT: f64 = 0.5;
|
||||
// 容忍 5 个百分点的单项负载/失败率差异,避免微小信号波动独占新会话。
|
||||
pub(crate) const SMART_SCORE_TOLERANCE: f64 = 0.05;
|
||||
// 首输出 10 秒时延迟得分减半;固定尺度不随其他候选账号变化。
|
||||
const SMART_LATENCY_HALF_SCORE_MS: f64 = 10_000.0;
|
||||
|
||||
fn capacity_utilization(candidate: &AccountCandidate, default_concurrency: NonZeroU32) -> f64 {
|
||||
f64::from(candidate.signals.in_flight)
|
||||
@@ -647,94 +657,32 @@ fn capacity_utilization(candidate: &AccountCandidate, default_concurrency: NonZe
|
||||
)
|
||||
}
|
||||
|
||||
struct SmartNormalization {
|
||||
max_capacity_utilization: f64,
|
||||
min_quota: Option<u64>,
|
||||
max_quota: Option<u64>,
|
||||
min_latency_ms: Option<u64>,
|
||||
max_latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl SmartNormalization {
|
||||
fn from_candidates(candidates: &[&AccountCandidate], default_concurrency: NonZeroU32) -> Self {
|
||||
let max_capacity_utilization = candidates
|
||||
.iter()
|
||||
.map(|candidate| capacity_utilization(candidate, default_concurrency))
|
||||
.reduce(f64::max)
|
||||
.unwrap_or_default();
|
||||
let min_quota = candidates
|
||||
.iter()
|
||||
.filter_map(|candidate| candidate.signals.quota_remaining_rank)
|
||||
.min();
|
||||
let max_quota = candidates
|
||||
.iter()
|
||||
.filter_map(|candidate| candidate.signals.quota_remaining_rank)
|
||||
.max();
|
||||
let min_latency_ms = candidates
|
||||
.iter()
|
||||
.filter_map(|candidate| candidate.signals.first_output_latency_ms)
|
||||
.filter(|latency| *latency > 0)
|
||||
.min();
|
||||
let max_latency_ms = candidates
|
||||
.iter()
|
||||
.filter_map(|candidate| candidate.signals.first_output_latency_ms)
|
||||
.filter(|latency| *latency > 0)
|
||||
.max();
|
||||
Self {
|
||||
max_capacity_utilization,
|
||||
min_quota,
|
||||
max_quota,
|
||||
min_latency_ms,
|
||||
max_latency_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn select_smart_candidate<'a>(
|
||||
candidates: &[&'a AccountCandidate],
|
||||
default_concurrency: NonZeroU32,
|
||||
cursor: u64,
|
||||
) -> Option<&'a AccountCandidate> {
|
||||
let normalization = SmartNormalization::from_candidates(candidates, default_concurrency);
|
||||
let mut ranked = candidates
|
||||
.iter()
|
||||
.map(|candidate| {
|
||||
(
|
||||
*candidate,
|
||||
smart_score(candidate, default_concurrency, &normalization),
|
||||
)
|
||||
})
|
||||
.map(|candidate| (*candidate, smart_score(candidate, default_concurrency)))
|
||||
.collect::<Vec<_>>();
|
||||
ranked.sort_by(|(left, left_score), (right, right_score)| {
|
||||
right_score
|
||||
.total_cmp(left_score)
|
||||
.then_with(|| left.account.id().cmp(right.account.id()))
|
||||
});
|
||||
let best_score = ranked.first()?.1.to_bits();
|
||||
let tied = ranked
|
||||
let best_score = ranked
|
||||
.iter()
|
||||
.take_while(|(_, score)| score.to_bits() == best_score)
|
||||
.count();
|
||||
let index = cursor as usize % tied;
|
||||
.map(|(_, score)| *score)
|
||||
.max_by(f64::total_cmp)?;
|
||||
ranked.retain(|(_, score)| best_score - score <= SMART_SCORE_TOLERANCE);
|
||||
// 轮换顺序保持稳定,避免分数轻微交错与 cursor 同步后仍反复命中同一账号。
|
||||
ranked.sort_unstable_by(|(left, _), (right, _)| left.account.id().cmp(right.account.id()));
|
||||
let index = (cursor % ranked.len() as u64) as usize;
|
||||
Some(ranked[index].0)
|
||||
}
|
||||
|
||||
fn smart_score(
|
||||
candidate: &AccountCandidate,
|
||||
default_concurrency: NonZeroU32,
|
||||
normalization: &SmartNormalization,
|
||||
) -> f64 {
|
||||
let load = lower_ratio_is_better(
|
||||
capacity_utilization(candidate, default_concurrency),
|
||||
normalization.max_capacity_utilization,
|
||||
);
|
||||
let quota = candidate.signals.quota_remaining_rank.map_or(0.5, |quota| {
|
||||
higher_is_better(
|
||||
quota,
|
||||
normalization.min_quota.unwrap_or(quota),
|
||||
normalization.max_quota.unwrap_or(quota),
|
||||
)
|
||||
});
|
||||
pub(crate) fn smart_score(candidate: &AccountCandidate, default_concurrency: NonZeroU32) -> f64 {
|
||||
let load = 1.0 - capacity_utilization(candidate, default_concurrency).clamp(0.0, 1.0);
|
||||
let quota = candidate
|
||||
.signals
|
||||
.quota_remaining_rank
|
||||
.map_or(0.5, |quota| quota.min(10_000) as f64 / 10_000.0);
|
||||
let failure = 1.0
|
||||
- f64::from(
|
||||
candidate
|
||||
@@ -748,11 +696,7 @@ fn smart_score(
|
||||
.first_output_latency_ms
|
||||
.filter(|latency| *latency > 0)
|
||||
.map_or(1.0, |latency| {
|
||||
lower_is_better(
|
||||
latency,
|
||||
normalization.min_latency_ms.unwrap_or(latency),
|
||||
normalization.max_latency_ms.unwrap_or(latency),
|
||||
)
|
||||
SMART_LATENCY_HALF_SCORE_MS / (SMART_LATENCY_HALF_SCORE_MS + latency as f64)
|
||||
});
|
||||
|
||||
SMART_LOAD_WEIGHT * load
|
||||
@@ -760,24 +704,3 @@ fn smart_score(
|
||||
+ SMART_FAILURE_WEIGHT * failure
|
||||
+ SMART_LATENCY_WEIGHT * latency
|
||||
}
|
||||
|
||||
fn lower_ratio_is_better(value: f64, maximum: f64) -> f64 {
|
||||
if maximum <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
1.0 - (value / maximum).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn lower_is_better(value: u64, minimum: u64, maximum: u64) -> f64 {
|
||||
if maximum <= minimum {
|
||||
return 1.0;
|
||||
}
|
||||
1.0 - (value.saturating_sub(minimum) as f64 / (maximum - minimum) as f64).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn higher_is_better(value: u64, minimum: u64, maximum: u64) -> f64 {
|
||||
if maximum <= minimum {
|
||||
return 1.0;
|
||||
}
|
||||
(value.saturating_sub(minimum) as f64 / (maximum - minimum) as f64).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::StoreError;
|
||||
use crate::routing::ProviderKind;
|
||||
use crate::identity::ProviderKind;
|
||||
|
||||
use super::{
|
||||
AccountStateChange, CredentialCasOutcome, CredentialCasUpdate, CredentialRevision,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! 诊断内容的统一筛选边界。未知字符串只保留长度和摘要。
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// 原始字节的长度与 SHA-256,不复制或保留正文。
|
||||
#[must_use]
|
||||
pub fn body_fingerprint(bytes: &[u8]) -> Value {
|
||||
let digest = Sha256::digest(bytes);
|
||||
let hex: String = digest
|
||||
.iter()
|
||||
.flat_map(|byte| {
|
||||
let alphabet = b"0123456789abcdef";
|
||||
[
|
||||
char::from(alphabet[usize::from(byte >> 4)]),
|
||||
char::from(alphabet[usize::from(byte & 15)]),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
json!({"bytes": bytes.len(), "sha256": hex})
|
||||
}
|
||||
|
||||
/// 保留可诊断的协议结构;凭据永不进入普通日志或数据库。
|
||||
#[must_use]
|
||||
pub fn diagnostic_json(value: &Value) -> Value {
|
||||
capture(value, "", 0, &mut 96)
|
||||
}
|
||||
|
||||
fn capture(value: &Value, key: &str, depth: usize, budget: &mut usize) -> Value {
|
||||
if *budget == 0 || depth > 6 {
|
||||
return json!({"omitted": true});
|
||||
}
|
||||
*budget -= 1;
|
||||
let key = key.to_ascii_lowercase();
|
||||
if secret_key(&key) {
|
||||
return Value::String("<redacted>".to_owned());
|
||||
}
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
let mut result = Map::new();
|
||||
for (name, value) in object.iter().take(48) {
|
||||
if *budget == 0 {
|
||||
break;
|
||||
}
|
||||
result.insert(bounded(name, 96), capture(value, name, depth + 1, budget));
|
||||
}
|
||||
if result.len() < object.len() {
|
||||
result.insert(
|
||||
"_omittedFields".to_owned(),
|
||||
json!(object.len() - result.len()),
|
||||
);
|
||||
}
|
||||
Value::Object(result)
|
||||
}
|
||||
Value::Array(items) => json!({
|
||||
"length": items.len(),
|
||||
"sample": items.iter().take(4)
|
||||
.map(|item| capture(item, key.as_str(), depth + 1, budget))
|
||||
.collect::<Vec<_>>()
|
||||
}),
|
||||
Value::String(text)
|
||||
if safe_header(&key) || safe_value_key(&key) && protocol_label(text) =>
|
||||
{
|
||||
Value::String(bounded(text, 256))
|
||||
}
|
||||
Value::String(text) => body_fingerprint(text.as_bytes()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求/响应头保持多值;安全 trace ID 原样保存,未知值只保存摘要。
|
||||
#[must_use]
|
||||
pub fn diagnostic_headers<'a>(headers: impl IntoIterator<Item = (&'a str, &'a str)>) -> Value {
|
||||
let mut result = Map::new();
|
||||
for (name, value) in headers.into_iter().take(64) {
|
||||
let name = bounded(&name.to_ascii_lowercase(), 96);
|
||||
let value = if secret_key(&name) {
|
||||
Value::String("<redacted>".to_owned())
|
||||
} else if safe_header(&name) {
|
||||
Value::String(bounded(value, 256))
|
||||
} else {
|
||||
body_fingerprint(value.as_bytes())
|
||||
};
|
||||
let values = result
|
||||
.entry(name)
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if let Value::Array(values) = values {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
Value::Object(result)
|
||||
}
|
||||
|
||||
fn secret_key(key: &str) -> bool {
|
||||
key.contains("authorization")
|
||||
|| key.contains("cookie")
|
||||
|| key.contains("api_key")
|
||||
|| key.contains("api-key")
|
||||
|| key.contains("token") && !key.ends_with("tokens")
|
||||
|| key.contains("password")
|
||||
|| key.contains("secret")
|
||||
|| key.contains("attestation")
|
||||
|| matches!(key, "x-oai-is" | "x-oai-is-update")
|
||||
}
|
||||
|
||||
fn safe_header(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
"x-oai-request-id"
|
||||
| "x-client-request-id"
|
||||
| "x-request-id"
|
||||
| "request-id"
|
||||
| "openai-request-id"
|
||||
| "x-openai-request-id"
|
||||
| "cf-ray"
|
||||
| "traceparent"
|
||||
| "tracestate"
|
||||
| "content-type"
|
||||
| "content-length"
|
||||
| "content-encoding"
|
||||
| "retry-after"
|
||||
| "openai-processing-ms"
|
||||
| "x-processing-ms"
|
||||
| "date"
|
||||
| "server"
|
||||
| "sec-websocket-extensions"
|
||||
| "x-codex-allowed"
|
||||
| "x-codex-limit-reached"
|
||||
| "x-codex-active-limit"
|
||||
| "x-models-etag"
|
||||
)
|
||||
}
|
||||
|
||||
fn safe_value_key(key: &str) -> bool {
|
||||
safe_header(key)
|
||||
|| matches!(
|
||||
key,
|
||||
"type" | "event" | "status" | "code" | "model" | "role" | "object" | "service_tier"
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn bounded(value: &str, limit: usize) -> String {
|
||||
value
|
||||
.chars()
|
||||
.take(limit)
|
||||
.map(|c| if c.is_control() { ' ' } else { c })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Event names/codes are protocol labels, never arbitrary upstream text.
|
||||
pub(super) fn protocol_label(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':'))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! 有界、请求局部的诊断证据;不参与路由、重试或客户端协议决策。
|
||||
|
||||
mod capture;
|
||||
mod selection;
|
||||
mod stream;
|
||||
mod trace;
|
||||
|
||||
pub use capture::{body_fingerprint, diagnostic_headers, diagnostic_json};
|
||||
pub use stream::{StreamCapture, StreamFormat};
|
||||
pub use trace::TraceContext;
|
||||
@@ -0,0 +1,73 @@
|
||||
//! 复用选号时的输入,保留有界的候选评分与阻断原因。
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::TraceContext;
|
||||
use crate::account::{
|
||||
AccountCandidate, AccountSelection, AccountSelectionContext, AccountSelector, RotationStrategy,
|
||||
SMART_SCORE_TOLERANCE, smart_score,
|
||||
};
|
||||
|
||||
// 单个 trace 事件最多 4 KiB;优先保留实际选中的账号。
|
||||
const MAX_CANDIDATES: usize = 6;
|
||||
|
||||
impl TraceContext {
|
||||
/// 记录实际选号输入与结果;只复用评分规则,不参与调度决策。
|
||||
pub fn account_selection(
|
||||
&self,
|
||||
candidates: &[AccountCandidate],
|
||||
context: &AccountSelectionContext,
|
||||
selection: Option<&AccountSelection<'_>>,
|
||||
) {
|
||||
if !self.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let selected = selection.as_ref().map(|s| s.candidate());
|
||||
let selected_id = selected.map(|c| c.account.id());
|
||||
let smart = context.policy.strategy() == RotationStrategy::Smart;
|
||||
let observations = selected
|
||||
.into_iter()
|
||||
.chain(
|
||||
candidates
|
||||
.iter()
|
||||
.filter(|c| Some(c.account.id()) != selected_id),
|
||||
)
|
||||
.take(MAX_CANDIDATES)
|
||||
.map(|candidate| {
|
||||
let signals = &candidate.signals;
|
||||
json!({
|
||||
"accountId": candidate.account.id().as_str(),
|
||||
"weight": candidate.account.weight().get(),
|
||||
"blocker": AccountSelector.scheduling_blocker(candidate, context)
|
||||
.map(|blocker| format!("{blocker:?}")),
|
||||
"inFlight": signals.in_flight,
|
||||
"concurrencyLimit": candidate.account
|
||||
.effective_concurrency(context.policy.max_concurrent_per_account()).get(),
|
||||
"quotaRemainingBasisPoints": signals.quota_remaining_rank,
|
||||
"quotaResetAtUnixMs": signals.quota_reset_at.and_then(|reset|
|
||||
reset.duration_since(std::time::UNIX_EPOCH).ok()
|
||||
).map(|duration| duration.as_millis()),
|
||||
"failureRateBasisPoints": signals.failure_rate_basis_points,
|
||||
"firstOutputLatencyMs": signals.first_output_latency_ms,
|
||||
"smartScore": smart.then(|| smart_score(
|
||||
candidate, context.policy.max_concurrent_per_account()
|
||||
)),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
self.record(
|
||||
"account.selection",
|
||||
json!({
|
||||
"strategy": context.policy.strategy().as_str(),
|
||||
"selectedAccountId": selected_id.map(|id| id.as_str()),
|
||||
"preferredAccountId": context.preferred_account.as_ref().map(|id| id.as_str()),
|
||||
"preferredResult": selection.as_ref().map(|s| format!("{:?}", s.preferred())),
|
||||
"roundRobinCursor": context.round_robin_cursor,
|
||||
"smartScoreTolerance": smart.then_some(SMART_SCORE_TOLERANCE),
|
||||
"candidateCount": candidates.len(),
|
||||
"omittedCandidates": candidates.len().saturating_sub(observations.len()),
|
||||
"candidates": observations,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! 仅用于观测的有界流分帧;超过限额时明确记缺口,不影响实际协议解析。
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::TraceContext;
|
||||
|
||||
const MAX_FRAME_BYTES: usize = 128 * 1024;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum StreamFormat {
|
||||
Sse,
|
||||
JsonLines,
|
||||
}
|
||||
|
||||
/// 同一 exchange 持有一个 observer,跨 HTTP chunk 拼接完整事件。
|
||||
pub struct StreamCapture {
|
||||
trace: TraceContext,
|
||||
format: StreamFormat,
|
||||
line: Vec<u8>,
|
||||
data: Vec<u8>,
|
||||
event_name: Option<String>,
|
||||
previous_cr: bool,
|
||||
first_line: bool,
|
||||
skipping_line: bool,
|
||||
skipping_event: bool,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl StreamCapture {
|
||||
#[must_use]
|
||||
pub fn new(trace: TraceContext, format: StreamFormat) -> Self {
|
||||
Self {
|
||||
trace,
|
||||
format,
|
||||
line: Vec::new(),
|
||||
data: Vec::new(),
|
||||
event_name: None,
|
||||
previous_cr: false,
|
||||
first_line: true,
|
||||
skipping_line: false,
|
||||
skipping_event: false,
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, bytes: &[u8]) {
|
||||
self.trace.dump("upstream.chunk", bytes);
|
||||
for byte in bytes {
|
||||
if self.previous_cr && *byte == b'\n' {
|
||||
self.previous_cr = false;
|
||||
continue;
|
||||
}
|
||||
self.previous_cr = *byte == b'\r';
|
||||
if matches!(*byte, b'\n' | b'\r') {
|
||||
if !self.skipping_line {
|
||||
self.line();
|
||||
}
|
||||
self.line.clear();
|
||||
self.skipping_line = false;
|
||||
} else if !self.skipping_line {
|
||||
if self.line.len() < MAX_FRAME_BYTES {
|
||||
self.line.push(*byte);
|
||||
} else {
|
||||
self.trace.record(
|
||||
"capture.gap",
|
||||
json!({"reason": "line_size_limit", "limitBytes": MAX_FRAME_BYTES}),
|
||||
);
|
||||
self.line.clear();
|
||||
self.skipping_line = true;
|
||||
self.skipping_event = matches!(self.format, StreamFormat::Sse);
|
||||
self.data.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) {
|
||||
if self.finished {
|
||||
return;
|
||||
}
|
||||
let partial_frame = !self.line.is_empty()
|
||||
|| !self.data.is_empty()
|
||||
|| self.skipping_event
|
||||
|| self.skipping_line;
|
||||
if !self.line.is_empty() && !self.skipping_line {
|
||||
self.line();
|
||||
}
|
||||
if !self.data.is_empty() && !self.skipping_event {
|
||||
self.trace.capture_named_event(
|
||||
"upstream.event",
|
||||
&self.data,
|
||||
self.event_name.as_deref(),
|
||||
);
|
||||
}
|
||||
self.trace
|
||||
.record("upstream.eof", json!({"partialFrame": partial_frame}));
|
||||
self.finished = true;
|
||||
}
|
||||
|
||||
fn line(&mut self) {
|
||||
let line = if self.first_line {
|
||||
self.first_line = false;
|
||||
self.line
|
||||
.strip_prefix(b"\xef\xbb\xbf")
|
||||
.unwrap_or(&self.line)
|
||||
} else {
|
||||
&self.line
|
||||
};
|
||||
if matches!(self.format, StreamFormat::JsonLines) {
|
||||
if !line.is_empty() {
|
||||
self.trace.capture_event("upstream.event", line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if line.is_empty() {
|
||||
if !self.data.is_empty() && !self.skipping_event {
|
||||
self.trace.capture_named_event(
|
||||
"upstream.event",
|
||||
&self.data,
|
||||
self.event_name.as_deref(),
|
||||
);
|
||||
}
|
||||
self.data.clear();
|
||||
self.event_name = None;
|
||||
self.skipping_event = false;
|
||||
} else if let Some(name) = line.strip_prefix(b"event:") {
|
||||
self.event_name = Some(super::capture::bounded(
|
||||
String::from_utf8_lossy(name).trim(),
|
||||
128,
|
||||
));
|
||||
} else if let Some(data) = line.strip_prefix(b"data:") {
|
||||
let data = data.strip_prefix(b" ").unwrap_or(data);
|
||||
if self.skipping_event {
|
||||
return;
|
||||
}
|
||||
if self.data.len() + data.len() + 1 > MAX_FRAME_BYTES {
|
||||
self.trace.record(
|
||||
"capture.gap",
|
||||
json!({"reason": "event_size_limit", "limitBytes": MAX_FRAME_BYTES}),
|
||||
);
|
||||
self.data.clear();
|
||||
self.skipping_event = true;
|
||||
} else {
|
||||
if !self.data.is_empty() {
|
||||
self.data.push(b'\n');
|
||||
}
|
||||
self.data.extend_from_slice(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StreamCapture {
|
||||
fn drop(&mut self) {
|
||||
if !self.finished {
|
||||
self.trace.record(
|
||||
"upstream.reader.dropped",
|
||||
json!({
|
||||
"partialBytes": self.line.len() + self.data.len(),
|
||||
"captureIncomplete": self.skipping_event || self.skipping_line,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! 首部与最近事件同时保留,流式 delta 合并,所有淘汰均有计数。
|
||||
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
sync::{Arc, Mutex},
|
||||
time::{Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::capture::bounded;
|
||||
use super::{body_fingerprint, diagnostic_headers, diagnostic_json};
|
||||
|
||||
const MAX_EVENTS: usize = 128;
|
||||
const HEAD_EVENTS: usize = 8;
|
||||
const MAX_DATA_BYTES: usize = 4096;
|
||||
const MAX_BUFFER_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// 显式传给异步任务的关联上下文;默认值禁用捕获,避免全局请求映射。
|
||||
#[derive(Clone, Default)]
|
||||
pub struct TraceContext {
|
||||
state: Option<Arc<Mutex<TraceState>>>,
|
||||
attempt_index: u32,
|
||||
exchange_id: Option<u64>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TraceContext {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("TraceContext")
|
||||
.field("attempt_index", &self.attempt_index)
|
||||
.field("exchange_id", &self.exchange_id)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
struct TraceState {
|
||||
request_id: String,
|
||||
started_at_ms: u128,
|
||||
started: Instant,
|
||||
sequence: u64,
|
||||
exchanges: u64,
|
||||
wire_frames: u64,
|
||||
wire_bytes: u64,
|
||||
dropped_events: u64,
|
||||
bytes: usize,
|
||||
events: VecDeque<TraceEvent>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TraceEvent {
|
||||
sequence: u64,
|
||||
last_sequence: u64,
|
||||
elapsed_ms: u64,
|
||||
last_elapsed_ms: u64,
|
||||
attempt_index: u32,
|
||||
exchange_id: Option<u64>,
|
||||
stage: &'static str,
|
||||
count: u64,
|
||||
data: Value,
|
||||
#[serde(skip)]
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl TraceContext {
|
||||
/// 是否已启用请求诊断,供调用方避免构造不会保存的事实。
|
||||
#[must_use]
|
||||
pub const fn is_enabled(&self) -> bool {
|
||||
self.state.is_some()
|
||||
}
|
||||
|
||||
/// 一个模型请求只创建一次,所有 attempt 共享同一有界时间线。
|
||||
#[must_use]
|
||||
pub fn new(request_id: &str) -> Self {
|
||||
Self {
|
||||
state: Some(Arc::new(Mutex::new(TraceState {
|
||||
request_id: bounded(request_id, 128),
|
||||
started_at_ms: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis(),
|
||||
started: Instant::now(),
|
||||
sequence: 0,
|
||||
exchanges: 0,
|
||||
wire_frames: 0,
|
||||
wire_bytes: 0,
|
||||
dropped_events: 0,
|
||||
bytes: 0,
|
||||
events: VecDeque::new(),
|
||||
}))),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn attempt(&self, attempt_index: u32) -> Self {
|
||||
Self {
|
||||
state: self.state.clone(),
|
||||
attempt_index,
|
||||
exchange_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个 attempt 中的每次实际 transport exchange 也有独立编号。
|
||||
#[must_use]
|
||||
pub fn exchange(&self, transport: &'static str) -> Self {
|
||||
let mut context = self.clone();
|
||||
if let Some(state) = &self.state {
|
||||
let mut state = state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.exchanges += 1;
|
||||
context.exchange_id = Some(state.exchanges);
|
||||
}
|
||||
context.record("upstream.exchange.started", json!({"transport": transport}));
|
||||
context
|
||||
}
|
||||
|
||||
/// details 只允许调用方构造的诊断事实;不接收原始请求/响应正文。
|
||||
pub fn record(&self, stage: &'static str, data: Value) {
|
||||
self.push(stage, data, false);
|
||||
}
|
||||
|
||||
/// 同一个头部边界只采集一次:默认脱敏,显式开启 dump 时另存完整字节。
|
||||
/// facts 必须为调用方构造的安全对象;原始头值只进入独立 dump。
|
||||
pub fn headers<'a>(
|
||||
&self,
|
||||
stage: &'static str,
|
||||
mut facts: Value,
|
||||
headers: impl IntoIterator<Item = (&'a str, &'a [u8])>,
|
||||
) {
|
||||
if self.state.is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(facts_object) = facts.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
let headers: Vec<_> = headers.into_iter().collect();
|
||||
let summary = diagnostic_headers(
|
||||
headers
|
||||
.iter()
|
||||
.map(|(name, value)| (*name, std::str::from_utf8(value).unwrap_or("<binary>"))),
|
||||
);
|
||||
facts_object.insert("headers".to_owned(), summary);
|
||||
self.record(stage, facts);
|
||||
if tracing::enabled!(target: "request_dump", tracing::Level::INFO) {
|
||||
let values: Vec<_> = headers
|
||||
.iter()
|
||||
.map(|(name, value)| json!({"name": name, "valueBase64": STANDARD.encode(value)}))
|
||||
.collect();
|
||||
if let Ok(bytes) = serde_json::to_vec(&json!({"headers": values})) {
|
||||
self.dump(stage, &bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 入站 JSON 先记录再解析业务语义,包括未知事件和 metadata。
|
||||
/// 原文仅在现有 request_dump 开关打开时写入其独立文件。
|
||||
pub fn capture(&self, stage: &'static str, bytes: &[u8]) {
|
||||
self.dump(stage, bytes);
|
||||
self.capture_event(stage, bytes);
|
||||
}
|
||||
|
||||
/// StreamCapture 已转储原始 chunk 时,只提取完整事件的安全摘要。
|
||||
pub fn capture_event(&self, stage: &'static str, bytes: &[u8]) {
|
||||
self.capture_named_event(stage, bytes, None);
|
||||
}
|
||||
|
||||
pub fn capture_named_event(&self, stage: &'static str, bytes: &[u8], name: Option<&str>) {
|
||||
if self.state.is_none() {
|
||||
return;
|
||||
}
|
||||
let fingerprint = body_fingerprint(bytes);
|
||||
if bytes.len() > 128 * 1024 {
|
||||
self.record("capture.gap", json!({"reason": "json_inspection_size_limit", "stage": stage, "body": fingerprint}));
|
||||
return;
|
||||
}
|
||||
let parsed = serde_json::from_slice::<Value>(bytes).ok();
|
||||
let event_type = parsed
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.or(name)
|
||||
.filter(|kind| super::capture::protocol_label(kind));
|
||||
let delta = event_type.is_some_and(|kind| kind.ends_with(".delta"));
|
||||
let data = json!({
|
||||
"body": fingerprint,
|
||||
"eventType": event_type.map(|kind| bounded(kind, 128)),
|
||||
"jsonValid": parsed.is_some(),
|
||||
"metadata": parsed.as_ref().filter(|_| !delta).map(diagnostic_json),
|
||||
});
|
||||
self.push(stage, data, delta);
|
||||
}
|
||||
|
||||
/// 只记录业务解析边界;metadata 已由 transport 在解析前捕获。
|
||||
pub fn wire_event(&self, protocol: &str, event_type: Option<&str>) {
|
||||
let event_type = event_type.filter(|kind| super::capture::protocol_label(kind));
|
||||
let delta = event_type.is_some_and(|kind| kind.ends_with(".delta"));
|
||||
self.push(
|
||||
"provider.event",
|
||||
json!({
|
||||
"protocol": protocol,
|
||||
"eventType": event_type.map(|kind| bounded(kind, 128)),
|
||||
}),
|
||||
delta,
|
||||
);
|
||||
}
|
||||
|
||||
/// 完整报文捕获与摘要使用相同 request / attempt / exchange ID。
|
||||
pub fn dump(&self, stage: &'static str, bytes: &[u8]) {
|
||||
if !tracing::enabled!(target: "request_dump", tracing::Level::INFO) {
|
||||
return;
|
||||
}
|
||||
if let Some(state) = &self.state {
|
||||
let mut state = state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.wire_frames += 1;
|
||||
state.wire_bytes = state.wire_bytes.saturating_add(bytes.len() as u64);
|
||||
let wire_sequence = state.wire_frames;
|
||||
let chunk_count = bytes.len().div_ceil(32 * 1024).max(1);
|
||||
// Each log entry stays bounded even for images or very large WebSocket frames.
|
||||
for chunk_index in 0..chunk_count {
|
||||
let start = chunk_index * 32 * 1024;
|
||||
let chunk = &bytes[start..bytes.len().min(start + 32 * 1024)];
|
||||
tracing::info!(target: "request_dump", request_id = %state.request_id,
|
||||
attempt_index = self.attempt_index, exchange_id = self.exchange_id,
|
||||
stage, wire_sequence, chunk_index, chunk_count,
|
||||
body_bytes = bytes.len(), body_base64 = %STANDARD.encode(chunk),
|
||||
contains_sensitive_data = true, "diagnostic wire capture");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 与请求终态原子持久化;旧记录没有此字段,不伪造历史时间线。
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> Option<Value> {
|
||||
let state = self
|
||||
.state
|
||||
.as_ref()?
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
Some(json!({
|
||||
"schemaVersion": 1, "requestId": state.request_id,
|
||||
"startedAtUnixMs": state.started_at_ms, "totalEvents": state.sequence,
|
||||
"droppedEvents": state.dropped_events, "maxEvents": MAX_EVENTS,
|
||||
"captureMode": "sanitized", "events": state.events,
|
||||
"wireDumpEnabled": tracing::enabled!(target: "request_dump", tracing::Level::INFO),
|
||||
"wireFrames": state.wire_frames, "wireBytes": state.wire_bytes,
|
||||
"maxBufferBytes": MAX_BUFFER_BYTES, "headEvents": HEAD_EVENTS,
|
||||
"snapshotBoundary": "execution_finalization",
|
||||
}))
|
||||
}
|
||||
|
||||
fn push(&self, stage: &'static str, mut data: Value, coalesce: bool) {
|
||||
let Some(state) = &self.state else { return };
|
||||
let mut encoded = serde_json::to_vec(&data).unwrap_or_default();
|
||||
if encoded.len() > MAX_DATA_BYTES {
|
||||
data = json!({"truncated": true, "summary": body_fingerprint(&encoded),
|
||||
"eventType": data.get("eventType"), "body": data.get("body"),
|
||||
"jsonValid": data.get("jsonValid"),
|
||||
});
|
||||
encoded = serde_json::to_vec(&data).unwrap_or_default();
|
||||
}
|
||||
let mut state = state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.sequence += 1;
|
||||
let sequence = state.sequence;
|
||||
let elapsed_ms = u64::try_from(state.started.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
if coalesce
|
||||
&& let Some(last) = state.events.iter_mut().rev().find(|event| {
|
||||
event.stage == stage
|
||||
&& event.attempt_index == self.attempt_index
|
||||
&& event.exchange_id == self.exchange_id
|
||||
})
|
||||
&& last.stage == stage
|
||||
&& last.attempt_index == self.attempt_index
|
||||
&& last.exchange_id == self.exchange_id
|
||||
&& last.data.get("eventType") == data.get("eventType")
|
||||
{
|
||||
last.count += 1;
|
||||
last.last_sequence = sequence;
|
||||
last.last_elapsed_ms = elapsed_ms;
|
||||
return;
|
||||
}
|
||||
// 普通日志只输出有界安全事实;进程意外退出时仍可按 ID 检索已发生阶段。
|
||||
tracing::info!(target: "request_trace", request_id = %state.request_id,
|
||||
attempt_index = self.attempt_index, exchange_id = self.exchange_id,
|
||||
sequence, elapsed_ms, stage, data = %data, "request trace");
|
||||
let bytes = encoded.len() + 256;
|
||||
state.bytes += bytes;
|
||||
state.events.push_back(TraceEvent {
|
||||
sequence,
|
||||
last_sequence: sequence,
|
||||
elapsed_ms,
|
||||
last_elapsed_ms: elapsed_ms,
|
||||
attempt_index: self.attempt_index,
|
||||
exchange_id: self.exchange_id,
|
||||
stage,
|
||||
count: 1,
|
||||
data,
|
||||
bytes,
|
||||
});
|
||||
while state.events.len() > MAX_EVENTS || state.bytes > MAX_BUFFER_BYTES {
|
||||
// Preserve failures and retry decisions ahead of routine middle-of-stream events.
|
||||
// Head/tail and important stages compete under the same hard memory limit.
|
||||
let index = (HEAD_EVENTS..state.events.len().saturating_sub(8))
|
||||
.find(|index| {
|
||||
!matches!(
|
||||
state.events[*index].stage,
|
||||
"attempt.started"
|
||||
| "account.selection"
|
||||
| "account.selected"
|
||||
| "attempt.failed"
|
||||
| "retry.decided"
|
||||
| "upstream.close"
|
||||
| "upstream.read.failed"
|
||||
| "capture.gap"
|
||||
| "request.finished"
|
||||
)
|
||||
})
|
||||
.unwrap_or(if state.events.len() > HEAD_EVENTS {
|
||||
HEAD_EVENTS
|
||||
} else {
|
||||
0
|
||||
});
|
||||
if let Some(event) = state.events.remove(index) {
|
||||
state.bytes = state.bytes.saturating_sub(event.bytes);
|
||||
state.dropped_events += event.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TraceState {
|
||||
fn drop(&mut self) {
|
||||
tracing::info!(target: "request_trace", request_id = %self.request_id,
|
||||
stage = "request.trace.closed", total_events = self.sequence,
|
||||
dropped_events = self.dropped_events, wire_frames = self.wire_frames,
|
||||
wire_bytes = self.wire_bytes, "request diagnostic capture closed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Durable per-key USD budgets, independent of best-effort request observations.
|
||||
|
||||
use std::time::SystemTime;
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
|
||||
use crate::{error::GatewayError, metering::Decimal, policy::ClientApiKeyId};
|
||||
|
||||
use super::ModelRequestId;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct ClientBudgetLimits {
|
||||
pub daily_usd: Decimal,
|
||||
pub weekly_usd: Decimal,
|
||||
}
|
||||
|
||||
impl ClientBudgetLimits {
|
||||
#[must_use]
|
||||
pub fn is_limited(self) -> bool {
|
||||
self.daily_usd != Decimal::ZERO || self.weekly_usd != Decimal::ZERO
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ClientBudgetStatus {
|
||||
pub limits: ClientBudgetLimits,
|
||||
pub daily_used_usd: Decimal,
|
||||
pub weekly_used_usd: Decimal,
|
||||
pub daily_resets_at: Option<SystemTime>,
|
||||
pub weekly_resets_at: Option<SystemTime>,
|
||||
pub unresolved_requests: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientBudgetAdmission {
|
||||
pub key_id: ClientApiKeyId,
|
||||
pub request_id: ModelRequestId,
|
||||
pub deadline_at: SystemTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientBudgetCharge {
|
||||
pub request_id: ModelRequestId,
|
||||
/// None means the request may have incurred an unknown charge, never zero.
|
||||
pub amount_usd: Option<Decimal>,
|
||||
pub completed_at: SystemTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, thiserror::Error)]
|
||||
#[error("client budget store is unavailable")]
|
||||
pub struct ClientBudgetError;
|
||||
|
||||
pub trait ClientBudgetPort: Send + Sync {
|
||||
/// Persist a pending charge before any upstream work; read the current policy atomically.
|
||||
fn admit(&self, request: ClientBudgetAdmission) -> BoxFuture<'_, Result<(), GatewayError>>;
|
||||
|
||||
/// Idempotent by gateway request ID. Failed writes leave the durable pending charge intact.
|
||||
fn settle(&self, charge: ClientBudgetCharge) -> BoxFuture<'_, Result<(), ClientBudgetError>>;
|
||||
}
|
||||
@@ -8,9 +8,9 @@ use std::fmt;
|
||||
use futures::future::BoxFuture;
|
||||
|
||||
use crate::account::ProviderAccountId;
|
||||
use crate::identity::ProviderKind;
|
||||
use crate::operation::ProviderSessionState;
|
||||
use crate::policy::ClientApiKeyId;
|
||||
use crate::routing::ProviderKind;
|
||||
|
||||
/// 客户端或 Provider 传递的 opaque response handle。
|
||||
///
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
//! 唯一的账号重试、发送与下游 commit barrier owner。
|
||||
|
||||
use crate::diagnostics::TraceContext;
|
||||
use serde_json::json;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::future::Future;
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use super::observation::ResponseObservation;
|
||||
use crate::engine::continuation::{
|
||||
ContinuationBinding, NativeContinuationPin, NativeContinuationScope, PreviousResponseId,
|
||||
};
|
||||
|
||||
use crate::engine::provider::{Provider, ProviderCallMetadata, ProviderRequest, ProviderStream};
|
||||
use crate::engine::{
|
||||
AccountAttemptContext, AttemptContext, AttemptRecord, AttemptTransport, AttemptTrigger,
|
||||
CommitRequirement, ContinuationAttempt, CoordinatedEvent, EngineError, ExecutionOutcome,
|
||||
ExecutionStore, GatewayEngine, IntermediateFailure, ModelRequestFailureObservation,
|
||||
ModelRequestFinalization, ModelRequestId, ModelRequestTimings, NewModelRequest,
|
||||
ProviderAccountStateOwner, ProviderAttemptOutcome, RequestAttemptContext, UpstreamSendState,
|
||||
ModelRequestFinalization, ModelRequestId, NewModelRequest, ProviderAccountStateOwner,
|
||||
ProviderAttemptOutcome, RequestAttemptContext, UpstreamSendState,
|
||||
};
|
||||
use crate::error::{
|
||||
ContinuationRecoveryDisposition, GatewayError, GatewayErrorKind, ProviderError,
|
||||
@@ -25,7 +30,7 @@ use crate::event::{
|
||||
GatewayEvent, ProviderEvent, ProviderResponseHeader, ProviderResponseObservation,
|
||||
};
|
||||
use crate::lifecycle::CancellationToken;
|
||||
use crate::metering::{CostEstimate, CostSource, Usage};
|
||||
use crate::metering::Decimal;
|
||||
use crate::operation::{Operation, ProviderSessionState};
|
||||
use crate::routing::RoutingPlan;
|
||||
use futures::future::Fuse;
|
||||
@@ -143,12 +148,29 @@ where
|
||||
.position(|candidate| candidate.provider() == pin.provider())
|
||||
.ok_or(EngineError::ContinuationPinMismatch)
|
||||
})?;
|
||||
let trace = TraceContext::new(request_id.as_str());
|
||||
trace.record(
|
||||
"request.routed",
|
||||
json!({
|
||||
"endpoint": request.endpoint, "protocol": request.protocol,
|
||||
"clientTransport": request.client_transport,
|
||||
"model": request.requested_model.as_ref().map(|model| model.as_str()),
|
||||
"configRevision": format!("{:?}", request.config_revision),
|
||||
"admissionMs": request.admission_decision_ms,
|
||||
"continuation": format!("{continuation_attempt:?}"),
|
||||
"candidateCount": plan.candidates().len(),
|
||||
}),
|
||||
);
|
||||
let image_generation_requested = operation.image_generation_requested();
|
||||
let mut session = ResponseExecutionSession {
|
||||
engine: Arc::clone(&self.engine),
|
||||
request_id,
|
||||
client_api_key_ref,
|
||||
timing_started_at,
|
||||
observation: ResponseObservation::new(timing_started_at),
|
||||
budget_prior_attempts_usd: Some(Decimal::ZERO),
|
||||
budget_attempt_send_state: UpstreamSendState::NotSent,
|
||||
budget_attempt_already_counted: false,
|
||||
trace,
|
||||
deadline,
|
||||
deadline_timer: Delay::new(
|
||||
deadline
|
||||
@@ -179,12 +201,8 @@ where
|
||||
delivery_pending: false,
|
||||
upstream_complete: false,
|
||||
finalized: false,
|
||||
usage: Usage::new(),
|
||||
finalized_at: None,
|
||||
image_generation_requested,
|
||||
cost: CostEstimate::unavailable(),
|
||||
timings: ModelRequestTimings::default(),
|
||||
client_response_id: None,
|
||||
upstream_response_id: None,
|
||||
last_retryable_failure: None,
|
||||
last_retryable_failure_events: Vec::new(),
|
||||
provider_attempt_outcomes: Vec::new(),
|
||||
@@ -239,7 +257,11 @@ pub struct ResponseExecutionSession<S: ?Sized> {
|
||||
engine: Arc<GatewayEngine<S>>,
|
||||
request_id: ModelRequestId,
|
||||
client_api_key_ref: crate::policy::ClientApiKeyId,
|
||||
timing_started_at: Instant,
|
||||
observation: ResponseObservation,
|
||||
budget_prior_attempts_usd: Option<Decimal>,
|
||||
budget_attempt_send_state: UpstreamSendState,
|
||||
budget_attempt_already_counted: bool,
|
||||
trace: TraceContext,
|
||||
deadline: SystemTime,
|
||||
/// 会话级 deadline 计时器;deadline 固定,帧循环内复用而非逐事件新建。
|
||||
deadline_timer: Fuse<Delay>,
|
||||
@@ -274,12 +296,8 @@ pub struct ResponseExecutionSession<S: ?Sized> {
|
||||
delivery_pending: bool,
|
||||
upstream_complete: bool,
|
||||
finalized: bool,
|
||||
usage: Usage,
|
||||
finalized_at: Option<SystemTime>,
|
||||
image_generation_requested: bool,
|
||||
cost: CostEstimate,
|
||||
timings: ModelRequestTimings,
|
||||
client_response_id: Option<String>,
|
||||
upstream_response_id: Option<String>,
|
||||
/// 最近一次为无感恢复而被丢弃的原始上游错误;只在后续空选路时成为终态。
|
||||
last_retryable_failure: Option<ProviderError>,
|
||||
/// 与 `last_retryable_failure` 同属一个 attempt 的原始失败批次。
|
||||
@@ -298,6 +316,11 @@ impl<S: ?Sized> ResponseExecutionSession<S>
|
||||
where
|
||||
S: ExecutionStore,
|
||||
{
|
||||
/// 当前请求共享的诊断上下文。
|
||||
pub fn trace(&self) -> TraceContext {
|
||||
self.trace.clone()
|
||||
}
|
||||
|
||||
/// 读取下一条 canonical event;首条未提交事件会携带 commit 要求。
|
||||
///
|
||||
/// # Errors
|
||||
@@ -405,6 +428,10 @@ where
|
||||
return Err(EngineError::InvalidDeliveryState);
|
||||
}
|
||||
let committed_at = SystemTime::now();
|
||||
self.trace.record(
|
||||
"downstream.committed",
|
||||
json!({"status": client_status_code}),
|
||||
);
|
||||
if self.request_persisted {
|
||||
best_effort_store_write(
|
||||
"mark_downstream_committed",
|
||||
@@ -457,6 +484,34 @@ where
|
||||
self.finalized
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn budget_charge(&self) -> super::budget::ClientBudgetCharge {
|
||||
let amount_usd = self
|
||||
.budget_prior_attempts_usd
|
||||
.zip(self.budget_attempt_usd())
|
||||
.and_then(|(prior, current)| prior.checked_add(current));
|
||||
super::budget::ClientBudgetCharge {
|
||||
request_id: self.request_id.clone(),
|
||||
amount_usd,
|
||||
completed_at: self.finalized_at.unwrap_or_else(SystemTime::now),
|
||||
}
|
||||
}
|
||||
|
||||
fn budget_attempt_usd(&self) -> Option<Decimal> {
|
||||
if self.budget_attempt_already_counted {
|
||||
return Some(Decimal::ZERO);
|
||||
}
|
||||
self.observation
|
||||
.cost
|
||||
.total()
|
||||
.filter(|money| money.currency().as_str() == "USD")
|
||||
.map(crate::metering::Money::amount)
|
||||
.or_else(|| {
|
||||
(self.budget_attempt_send_state == UpstreamSendState::NotSent)
|
||||
.then_some(Decimal::ZERO)
|
||||
})
|
||||
}
|
||||
|
||||
/// 返回截至当前已完成的实际上游调用结果。
|
||||
///
|
||||
/// 调用方可保存已消费下标;该切片在会话生命周期内只追加、不改写。
|
||||
@@ -498,7 +553,7 @@ where
|
||||
return None;
|
||||
}
|
||||
let account = current.metadata.provider_account_id().clone();
|
||||
let response_id = self.upstream_response_id.as_deref()?;
|
||||
let response_id = self.observation.upstream_response_id.as_deref()?;
|
||||
let previous_response_id = PreviousResponseId::new(response_id.to_owned());
|
||||
let upstream_response_id = PreviousResponseId::new(response_id.to_owned());
|
||||
Some(
|
||||
@@ -570,11 +625,19 @@ where
|
||||
return Err(EngineError::Deadline);
|
||||
}
|
||||
PollBoundary::Item(Some(Ok(mut event))) => {
|
||||
if let Some(wire) = event.wire_event() {
|
||||
self.trace
|
||||
.attempt(self.attempts)
|
||||
.wire_event(wire.protocol(), wire.event_type());
|
||||
}
|
||||
if let Some(state) = event.session_update() {
|
||||
self.observe_session_update(state);
|
||||
}
|
||||
if let Some(observation) = event.take_observation() {
|
||||
self.observe_response(observation);
|
||||
}
|
||||
for fact in event.canonical_facts() {
|
||||
self.observe_response_identity(fact);
|
||||
self.observation.observe_identity(fact);
|
||||
}
|
||||
for fact in event.canonical_facts() {
|
||||
self.observe_event(fact).await;
|
||||
@@ -686,7 +749,8 @@ where
|
||||
}));
|
||||
let context = AttemptContext::new(
|
||||
RequestAttemptContext::new(self.request_id.clone(), self.client_api_key_ref.clone())
|
||||
.with_timing_started_at(self.timing_started_at),
|
||||
.with_timing_started_at(self.observation.timing_started_at)
|
||||
.with_trace(self.trace.clone()),
|
||||
next_attempt,
|
||||
self.deadline,
|
||||
self.plan.account_selection_policy(),
|
||||
@@ -709,7 +773,21 @@ where
|
||||
.ok_or_else(|| EngineError::ProviderNotRegistered {
|
||||
provider: candidate.provider().as_str().to_owned(),
|
||||
})?;
|
||||
let attempt_trace = self.trace.attempt(next_attempt.get());
|
||||
attempt_trace.record(
|
||||
"attempt.started",
|
||||
json!({
|
||||
"provider": candidate.provider().as_str(),
|
||||
"model": candidate.upstream_model().map(|model| model.as_str()),
|
||||
"transportPolicy": format!("{attempt_transport:?}"),
|
||||
"continuation": format!("{:?}", self.continuation_attempt),
|
||||
"pinnedAccount": pinned_account.as_ref().map(|id| id.as_str()),
|
||||
"transportRecovery": is_transport_recovery,
|
||||
}),
|
||||
);
|
||||
let provider_request = ProviderRequest::new(self.operation.clone(), candidate.clone());
|
||||
// Cancellation while a provider is opening cannot prove the request was never sent.
|
||||
self.budget_attempt_send_state = UpstreamSendState::Ambiguous;
|
||||
let stream = match poll_provider(
|
||||
provider,
|
||||
provider_request,
|
||||
@@ -733,6 +811,8 @@ where
|
||||
ProviderBoundary::Result(result) => match *result {
|
||||
Ok(stream) => stream,
|
||||
Err(error) => {
|
||||
self.budget_attempt_send_state = error.send_state();
|
||||
record_trace_error(&attempt_trace, &error);
|
||||
if self.prepare_unavailable_native_continuation_replay(&error) {
|
||||
return Ok(Some(PullOutcome::AttemptDiscarded));
|
||||
}
|
||||
@@ -760,6 +840,7 @@ where
|
||||
let mut events = std::mem::take(&mut self.last_retryable_failure_events);
|
||||
if !events.is_empty() {
|
||||
self.observe_atomic_terminal_events(&mut events);
|
||||
self.budget_attempt_already_counted = true;
|
||||
return Ok(Some(PullOutcome::TerminalFailure {
|
||||
events,
|
||||
error: last_failure,
|
||||
@@ -804,6 +885,11 @@ where
|
||||
}
|
||||
|
||||
let metadata = stream.metadata().clone();
|
||||
attempt_trace.record("account.selected", json!({
|
||||
"provider": metadata.provider().as_str(), "accountId": metadata.provider_account_id().as_str(),
|
||||
"transport": metadata.transport().as_str(),
|
||||
"selectionMs": metadata.selection_observation().map(|o| o.account_selection_wait_ms()),
|
||||
}));
|
||||
let selection_observation = metadata.selection_observation();
|
||||
let capacity = selection_observation.and_then(|observation| observation.capacity());
|
||||
if !self.account_selection.is_diagnostic()
|
||||
@@ -948,30 +1034,24 @@ where
|
||||
}
|
||||
|
||||
async fn observe_event(&mut self, event: &GatewayEvent) {
|
||||
self.observe_event_facts(event);
|
||||
self.observation.observe_event(event);
|
||||
self.mark_send_observed().await;
|
||||
}
|
||||
|
||||
fn observe_event_facts(&mut self, event: &GatewayEvent) {
|
||||
let elapsed = elapsed_ms(self.timing_started_at);
|
||||
observe_event_timing(&mut self.timings, event, elapsed);
|
||||
if let GatewayEvent::Usage(observed) = event {
|
||||
self.usage.merge(observed);
|
||||
}
|
||||
if let GatewayEvent::CalculatedCost(observed) = event
|
||||
&& self.cost.source() != CostSource::ProviderReported
|
||||
{
|
||||
self.cost = observed.into_estimate();
|
||||
}
|
||||
if let GatewayEvent::ProviderCost(observed) = event {
|
||||
self.cost = observed.into_estimate();
|
||||
}
|
||||
}
|
||||
|
||||
async fn observe_wire_event(&mut self) {
|
||||
self.mark_send_observed().await;
|
||||
}
|
||||
|
||||
fn observe_session_update(&mut self, state: &ProviderSessionState) {
|
||||
let Some(current) = self.current.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if state.provider() != current.metadata.provider().as_str() {
|
||||
return;
|
||||
}
|
||||
self.operation.set_provider_session_state(state.clone());
|
||||
}
|
||||
|
||||
async fn mark_send_observed(&mut self) {
|
||||
let Some(current) = self.current.as_mut() else {
|
||||
return;
|
||||
@@ -988,6 +1068,7 @@ where
|
||||
.await;
|
||||
}
|
||||
current.send_observed = true;
|
||||
self.budget_attempt_send_state = UpstreamSendState::Sent;
|
||||
self.send_state_watermark = UpstreamSendState::Sent;
|
||||
}
|
||||
}
|
||||
@@ -1003,44 +1084,10 @@ where
|
||||
{
|
||||
return;
|
||||
}
|
||||
let observed = observation.timings();
|
||||
if let Some(value) = observed.transport_decision_wait_ms {
|
||||
self.timings.transport_decision_wait_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.connect_ms {
|
||||
self.timings.connect_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.headers_ms {
|
||||
self.timings.headers_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.first_event_ms {
|
||||
self.timings.first_event_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.first_reasoning_ms {
|
||||
self.timings.first_reasoning_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.first_text_ms {
|
||||
self.timings.first_text_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.first_token_ms {
|
||||
self.timings.first_token_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.provider_processing_ms {
|
||||
self.timings.provider_processing_ms = Some(value);
|
||||
}
|
||||
self.observation.observe_response(&observation);
|
||||
current.response_observation = Some(observation);
|
||||
}
|
||||
|
||||
fn observe_response_identity(&mut self, event: &GatewayEvent) {
|
||||
let metadata = match event {
|
||||
GatewayEvent::Started(metadata) | GatewayEvent::Completed(metadata) => metadata,
|
||||
_ => return,
|
||||
};
|
||||
let response_id = metadata.response_id().to_owned();
|
||||
self.client_response_id = Some(response_id.clone());
|
||||
self.upstream_response_id = Some(response_id);
|
||||
}
|
||||
|
||||
/// 失败可重试时要求丢弃本 attempt;预算耗尽时可返回最后一批原始失败事件。
|
||||
async fn handle_stream_error(
|
||||
&mut self,
|
||||
@@ -1048,6 +1095,7 @@ where
|
||||
) -> Result<StreamErrorOutcome, EngineError> {
|
||||
// 原始 wire 只活在 request-local 决策状态;clone、attempt 记录与持久化终态
|
||||
// 均只接触已剥离的稳定错误字段。
|
||||
record_trace_error(&self.trace.attempt(self.attempts), &error);
|
||||
let mut atomic_client_events = error.take_atomic_client_events();
|
||||
let current = self.current.take().ok_or(EngineError::NoActiveAttempt)?;
|
||||
self.record_provider_failure(current.metadata.provider().clone(), error.kind());
|
||||
@@ -1060,6 +1108,7 @@ where
|
||||
error.send_state()
|
||||
};
|
||||
let send_state = self.raise_send_watermark(attempt_send_state);
|
||||
self.budget_attempt_send_state = attempt_send_state;
|
||||
if self.request_persisted {
|
||||
best_effort_store_write(
|
||||
"mark_send_state",
|
||||
@@ -1128,7 +1177,20 @@ where
|
||||
|| account_rotation_retry
|
||||
|| transport_recovery.is_some();
|
||||
|
||||
self.trace.attempt(current.index.get()).record("retry.decided", json!({
|
||||
"retryable": retryable, "continuationRetry": continuation_retry,
|
||||
"sameAccountRetry": same_account_retry, "accountRotationRetry": account_rotation_retry,
|
||||
"ordinaryRetry": ordinary_retry, "transportRecovery": transport_recovery.is_some(),
|
||||
"delayMs": transport_recovery.map(|(_, delay)| duration_ms(delay)),
|
||||
"downstreamCommitted": self.downstream_committed_at.is_some(),
|
||||
"sendState": format!("{attempt_send_state:?}"),
|
||||
}));
|
||||
if retryable {
|
||||
for event in &atomic_client_events {
|
||||
for fact in event.canonical_facts() {
|
||||
self.observation.observe_event(fact);
|
||||
}
|
||||
}
|
||||
// 普通 clone 只保留稳定事实;原始 wire/HTTP response 由 request-local
|
||||
// 所有权保留到下一次 attempt 成功,或最终空选路时返回客户端。
|
||||
let persistence_error = self.request_persisted.then(|| error.clone());
|
||||
@@ -1203,8 +1265,8 @@ where
|
||||
self.observe_response(observation);
|
||||
}
|
||||
for fact in event.canonical_facts() {
|
||||
self.observe_response_identity(fact);
|
||||
self.observe_event_facts(fact);
|
||||
self.observation.observe_identity(fact);
|
||||
self.observation.observe_event(fact);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1303,18 +1365,14 @@ where
|
||||
}
|
||||
|
||||
fn reset_uncommitted_observations(&mut self) {
|
||||
self.usage = Usage::new();
|
||||
self.cost = CostEstimate::unavailable();
|
||||
self.client_response_id = None;
|
||||
self.upstream_response_id = None;
|
||||
self.timings.transport_decision_wait_ms = None;
|
||||
self.timings.connect_ms = None;
|
||||
self.timings.headers_ms = None;
|
||||
self.timings.first_event_ms = None;
|
||||
self.timings.first_reasoning_ms = None;
|
||||
self.timings.first_text_ms = None;
|
||||
self.timings.first_token_ms = None;
|
||||
self.timings.provider_processing_ms = None;
|
||||
// Response facts are attempt-local; the key must also pay for discarded attempts.
|
||||
self.budget_prior_attempts_usd = self
|
||||
.budget_prior_attempts_usd
|
||||
.zip(self.budget_attempt_usd())
|
||||
.and_then(|(prior, current)| prior.checked_add(current));
|
||||
self.budget_attempt_send_state = UpstreamSendState::NotSent;
|
||||
self.budget_attempt_already_counted = false;
|
||||
self.observation.reset_for_attempt();
|
||||
self.upstream_complete = false;
|
||||
}
|
||||
|
||||
@@ -1323,7 +1381,8 @@ where
|
||||
return Ok(());
|
||||
}
|
||||
let completed_at = SystemTime::now();
|
||||
self.timings.latency_ms = Some(elapsed_ms(self.timing_started_at));
|
||||
self.finalized_at = Some(completed_at);
|
||||
self.observation.finish();
|
||||
let upstream_request_id = self.current.as_ref().and_then(|current| {
|
||||
current
|
||||
.response_observation
|
||||
@@ -1349,6 +1408,10 @@ where
|
||||
self.current_transport_observation();
|
||||
let service_tier = self.current_service_tier();
|
||||
let provider_metadata_json = self.current_provider_metadata_json();
|
||||
self.trace.record(
|
||||
"request.finished",
|
||||
json!({"outcome": "succeeded", "attempts": self.attempts}),
|
||||
);
|
||||
if self.request_persisted {
|
||||
best_effort_store_write(
|
||||
"finalize_model_request",
|
||||
@@ -1363,23 +1426,24 @@ where
|
||||
downstream_committed_at: self.downstream_committed_at,
|
||||
client_status_code: self.client_status_code,
|
||||
upstream_status_code,
|
||||
client_response_id: self.client_response_id.clone(),
|
||||
client_response_id: self.observation.client_response_id.clone(),
|
||||
upstream_request_id,
|
||||
upstream_response_id: self.upstream_response_id.clone(),
|
||||
upstream_response_id: self.observation.upstream_response_id.clone(),
|
||||
upstream_transport,
|
||||
http_version,
|
||||
websocket_pool,
|
||||
service_tier,
|
||||
provider_metadata_json,
|
||||
diagnostic_trace_json: self.trace.snapshot().map(|value| value.to_string()),
|
||||
error: None,
|
||||
provider_error_code: None,
|
||||
raw_upstream_error: None,
|
||||
failure_observation: ModelRequestFailureObservation::default(),
|
||||
retry_after_ms: None,
|
||||
usage: self.usage.clone(),
|
||||
usage: self.observation.usage.clone(),
|
||||
image_generation_succeeded: self.image_generation_succeeded(),
|
||||
cost: self.cost.clone(),
|
||||
timings: self.timings.clone(),
|
||||
cost: self.observation.cost.clone(),
|
||||
timings: self.observation.timings.clone(),
|
||||
completed_at,
|
||||
}),
|
||||
)
|
||||
@@ -1489,12 +1553,21 @@ where
|
||||
if self.finalized {
|
||||
return Ok(());
|
||||
}
|
||||
self.trace.record(
|
||||
"request.finished",
|
||||
json!({
|
||||
"outcome": format!("{:?}", finalization.outcome),
|
||||
"errorKind": finalization.error.kind().as_str(),
|
||||
"sendState": format!("{:?}", finalization.send_state), "attempts": self.attempts,
|
||||
}),
|
||||
);
|
||||
let completed_at = SystemTime::now();
|
||||
self.finalized_at = Some(completed_at);
|
||||
if !self.request_persisted {
|
||||
self.finalized = true;
|
||||
return Ok(());
|
||||
}
|
||||
let completed_at = SystemTime::now();
|
||||
self.timings.latency_ms = Some(elapsed_ms(self.timing_started_at));
|
||||
self.observation.finish();
|
||||
let upstream_request_id = self.current.as_ref().and_then(|current| {
|
||||
current
|
||||
.response_observation
|
||||
@@ -1535,23 +1608,24 @@ where
|
||||
upstream_status_code: finalization
|
||||
.upstream_status_code
|
||||
.or(observed_status_code),
|
||||
client_response_id: self.client_response_id.clone(),
|
||||
client_response_id: self.observation.client_response_id.clone(),
|
||||
upstream_request_id,
|
||||
upstream_response_id: self.upstream_response_id.clone(),
|
||||
upstream_response_id: self.observation.upstream_response_id.clone(),
|
||||
upstream_transport,
|
||||
http_version,
|
||||
websocket_pool,
|
||||
service_tier,
|
||||
provider_metadata_json,
|
||||
diagnostic_trace_json: self.trace.snapshot().map(|value| value.to_string()),
|
||||
error: Some(finalization.error),
|
||||
provider_error_code: finalization.provider_error_code,
|
||||
raw_upstream_error: finalization.raw_upstream_error,
|
||||
failure_observation: finalization.observation,
|
||||
retry_after_ms: finalization.retry_after_ms,
|
||||
usage: self.usage.clone(),
|
||||
usage: self.observation.usage.clone(),
|
||||
image_generation_succeeded: self.image_generation_succeeded(),
|
||||
cost: self.cost.clone(),
|
||||
timings: self.timings.clone(),
|
||||
cost: self.observation.cost.clone(),
|
||||
timings: self.observation.timings.clone(),
|
||||
completed_at,
|
||||
}),
|
||||
)
|
||||
@@ -1599,7 +1673,11 @@ where
|
||||
if matches!(self.operation, Operation::GenerateImage(_)) {
|
||||
self.upstream_complete
|
||||
} else {
|
||||
self.usage.image_output_tokens.unwrap_or_default() > 0
|
||||
self.observation
|
||||
.usage
|
||||
.image_output_tokens
|
||||
.unwrap_or_default()
|
||||
> 0
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1637,7 +1715,7 @@ where
|
||||
|
||||
fn record_provider_failure(
|
||||
&mut self,
|
||||
provider_kind: crate::routing::ProviderKind,
|
||||
provider_kind: crate::identity::ProviderKind,
|
||||
error_kind: ProviderErrorKind,
|
||||
) {
|
||||
self.provider_attempt_outcomes
|
||||
@@ -1826,31 +1904,21 @@ const fn escalate_send_state(a: UpstreamSendState, b: UpstreamSendState) -> Upst
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_event_timing(timings: &mut ModelRequestTimings, event: &GatewayEvent, elapsed_ms: u64) {
|
||||
timings.first_event_ms.get_or_insert(elapsed_ms);
|
||||
match event {
|
||||
GatewayEvent::ReasoningDelta(_) => {
|
||||
timings.first_reasoning_ms.get_or_insert(elapsed_ms);
|
||||
timings.first_token_ms.get_or_insert(elapsed_ms);
|
||||
}
|
||||
GatewayEvent::TextDelta(_) => {
|
||||
timings.first_text_ms.get_or_insert(elapsed_ms);
|
||||
timings.first_token_ms.get_or_insert(elapsed_ms);
|
||||
}
|
||||
// `response.output_item.added` 会先投影一个空参数的 tool delta;它只是结构帧,
|
||||
// 不能抢在真实工具参数之前成为首个可消费 token。
|
||||
GatewayEvent::ToolCallDelta(delta) if !delta.arguments_delta.is_empty() => {
|
||||
timings.first_token_ms.get_or_insert(elapsed_ms);
|
||||
}
|
||||
GatewayEvent::CalculatedCost(_) | GatewayEvent::ProviderCost(_) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_ms(started_at: Instant) -> u64 {
|
||||
duration_ms(started_at.elapsed())
|
||||
}
|
||||
|
||||
fn duration_ms(duration: Duration) -> u64 {
|
||||
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
fn record_trace_error(trace: &TraceContext, error: &ProviderError) {
|
||||
trace.record("attempt.failed", json!({
|
||||
"kind": error.kind().as_str(), "sendState": format!("{:?}", error.send_state()),
|
||||
"diagnostic": error.diagnostic().map(|diagnostic| json!({
|
||||
"stage": diagnostic.stage(), "code": diagnostic.code(), "message": diagnostic.as_str(),
|
||||
})),
|
||||
"upstreamStatus": error.upstream_status(),
|
||||
"upstreamCode": error.upstream_code().map(|code| code.as_str()),
|
||||
"rawError": error.raw_upstream_error().map(|raw| {
|
||||
let value = serde_json::from_str(raw.as_str()).unwrap_or_else(|_| json!(raw.as_str()));
|
||||
crate::diagnostics::diagnostic_json(&value)
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use uuid::Uuid;
|
||||
use crate::engine::admission::{
|
||||
ClientAdmissionDecision, ClientAdmissionPort, ClientAdmissionRejection, ClientAdmissionRequest,
|
||||
};
|
||||
use crate::engine::budget::{ClientBudgetAdmission, ClientBudgetCharge, ClientBudgetPort};
|
||||
use crate::engine::continuation::{
|
||||
ContinuationBinding, NativeContinuationPin, NativeContinuationPort,
|
||||
NativeContinuationStoreErrorKind, PreviousResponseId,
|
||||
@@ -31,12 +32,12 @@ use crate::engine::{
|
||||
};
|
||||
use crate::error::{GatewayError, GatewayErrorKind, ProviderErrorKind, StoreError};
|
||||
use crate::event::{GatewayEvent, ProviderEvent, ProviderResponseHeader};
|
||||
use crate::identity::ProviderKind;
|
||||
use crate::lifecycle::CancellationToken;
|
||||
use crate::operation::{Operation, ProviderSessionState};
|
||||
use crate::policy::{ClientApiKeyId, ClientPolicy};
|
||||
use crate::routing::{
|
||||
ProviderKind, PublicModelId, PublicModelProfile, RoutingContext, RuntimeSnapshot,
|
||||
UpstreamModelId,
|
||||
PublicModelId, PublicModelProfile, RoutingContext, RuntimeSnapshot, UpstreamModelId,
|
||||
};
|
||||
use crate::runtime::RuntimeSnapshotHandle;
|
||||
|
||||
@@ -155,6 +156,9 @@ pub struct StartedExecution {
|
||||
}
|
||||
|
||||
pub trait ExecutionSession: Send {
|
||||
fn trace(&self) -> crate::diagnostics::TraceContext {
|
||||
crate::diagnostics::TraceContext::default()
|
||||
}
|
||||
fn next_event(&mut self) -> BoxFuture<'_, Result<Option<CoordinatedEvent>, EngineError>>;
|
||||
fn collect_uncommitted(&mut self) -> BoxFuture<'_, Result<Vec<ProviderEvent>, EngineError>>;
|
||||
fn response_headers(&self) -> &[ProviderResponseHeader];
|
||||
@@ -253,6 +257,7 @@ pub struct DefaultExecutionService {
|
||||
circuits: Arc<dyn ProviderCircuitPort>,
|
||||
continuation: Arc<dyn NativeContinuationPort>,
|
||||
client_api_key_usage: Arc<dyn ClientApiKeyUsageSink>,
|
||||
budget: Option<Arc<dyn ClientBudgetPort>>,
|
||||
}
|
||||
|
||||
impl DefaultExecutionService {
|
||||
@@ -280,9 +285,16 @@ impl DefaultExecutionService {
|
||||
circuits,
|
||||
continuation,
|
||||
client_api_key_usage,
|
||||
budget: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_budget(mut self, budget: Arc<dyn ClientBudgetPort>) -> Self {
|
||||
self.budget = Some(budget);
|
||||
self
|
||||
}
|
||||
|
||||
async fn start_inner(&self, request: StartExecution) -> Result<StartedExecution, GatewayError> {
|
||||
let StartExecution {
|
||||
client,
|
||||
@@ -322,9 +334,18 @@ impl DefaultExecutionService {
|
||||
&self,
|
||||
mut request: PendingStartExecution,
|
||||
) -> Result<StartedExecution, GatewayError> {
|
||||
request.client.policy.authorize().map_err(|_| {
|
||||
GatewayError::new(GatewayErrorKind::PolicyDenied, "client API key is disabled")
|
||||
})?;
|
||||
// 长连接每次执行都重新鉴权并冻结当前策略,确保限额和授权变更对新请求生效。
|
||||
request.client = self
|
||||
.authenticate(request.client.policy.plaintext_key().expose_for_auth())
|
||||
.map_err(|error| match error {
|
||||
ClientAuthenticationError::InvalidKey => {
|
||||
GatewayError::new(GatewayErrorKind::Unauthorized, "client API key is invalid")
|
||||
}
|
||||
ClientAuthenticationError::SnapshotUnavailable => GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"runtime snapshot is unavailable",
|
||||
),
|
||||
})?;
|
||||
let started_at = SystemTime::now();
|
||||
let deadline_at = started_at
|
||||
.checked_add(MODEL_REQUEST_DEADLINE)
|
||||
@@ -501,6 +522,18 @@ impl DefaultExecutionService {
|
||||
client_api_key_id: client.policy.key_id().clone(),
|
||||
model_request_id: request_id.clone(),
|
||||
};
|
||||
if let Some(budget) = &self.budget
|
||||
&& let Err(error) = budget
|
||||
.admit(ClientBudgetAdmission {
|
||||
key_id: client.policy.key_id().clone(),
|
||||
request_id: request_id.clone(),
|
||||
deadline_at,
|
||||
})
|
||||
.await
|
||||
{
|
||||
admission.release().await;
|
||||
return Err(error);
|
||||
}
|
||||
let observation = plan
|
||||
.candidates()
|
||||
.first()
|
||||
@@ -521,7 +554,7 @@ impl DefaultExecutionService {
|
||||
operation: operation.kind(),
|
||||
endpoint: metadata.endpoint,
|
||||
client_transport: metadata.transport.as_str().to_owned(),
|
||||
requested_model: target.into_public_model(),
|
||||
requested_model: target.into_public_model().or(observation.requested_model),
|
||||
client_ip: metadata.client_ip,
|
||||
user_agent: metadata.user_agent,
|
||||
reasoning_effort: observation.reasoning_effort,
|
||||
@@ -549,6 +582,17 @@ impl DefaultExecutionService {
|
||||
{
|
||||
Ok(core) => core,
|
||||
Err(error) => {
|
||||
if let Some(budget) = &self.budget {
|
||||
settle_budget(
|
||||
budget.as_ref(),
|
||||
ClientBudgetCharge {
|
||||
request_id: request_id.clone(),
|
||||
amount_usd: Some(crate::metering::Decimal::ZERO),
|
||||
completed_at: SystemTime::now(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
admission.release().await;
|
||||
return Err(gateway_error_from_engine(&error));
|
||||
}
|
||||
@@ -562,6 +606,7 @@ impl DefaultExecutionService {
|
||||
admission,
|
||||
Arc::clone(&self.circuits),
|
||||
Arc::clone(&self.continuation),
|
||||
self.budget.clone(),
|
||||
)),
|
||||
})
|
||||
}
|
||||
@@ -919,6 +964,12 @@ struct AdmissionLease {
|
||||
model_request_id: ModelRequestId,
|
||||
}
|
||||
|
||||
async fn settle_budget(port: &dyn ClientBudgetPort, charge: ClientBudgetCharge) {
|
||||
if let Err(error) = port.settle(charge).await {
|
||||
tracing::error!(%error, "Client budget settlement pending; durable admission requires reconciliation");
|
||||
}
|
||||
}
|
||||
|
||||
impl AdmissionLease {
|
||||
async fn release(self) {
|
||||
if let Err(error) = self
|
||||
@@ -938,6 +989,7 @@ struct DefaultExecutionSession {
|
||||
continuation: Arc<dyn NativeContinuationPort>,
|
||||
observed_provider_outcomes: usize,
|
||||
continuation_recorded: bool,
|
||||
budget: Option<Arc<dyn ClientBudgetPort>>,
|
||||
}
|
||||
|
||||
impl DefaultExecutionSession {
|
||||
@@ -946,6 +998,7 @@ impl DefaultExecutionSession {
|
||||
admission: AdmissionLease,
|
||||
circuits: Arc<dyn ProviderCircuitPort>,
|
||||
continuation: Arc<dyn NativeContinuationPort>,
|
||||
budget: Option<Arc<dyn ClientBudgetPort>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
core: Some(core),
|
||||
@@ -954,6 +1007,7 @@ impl DefaultExecutionSession {
|
||||
continuation,
|
||||
observed_provider_outcomes: 0,
|
||||
continuation_recorded: false,
|
||||
budget,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -970,6 +1024,11 @@ impl DefaultExecutionSession {
|
||||
.is_some_and(ResponseExecutionSession::is_finalized)
|
||||
&& let Some(admission) = self.admission.take()
|
||||
{
|
||||
if let Some(budget) = self.budget.take()
|
||||
&& let Some(core) = &self.core
|
||||
{
|
||||
settle_budget(budget.as_ref(), core.budget_charge()).await;
|
||||
}
|
||||
admission.release().await;
|
||||
}
|
||||
}
|
||||
@@ -1020,6 +1079,9 @@ impl DefaultExecutionSession {
|
||||
.get(self.observed_provider_outcomes..)
|
||||
.unwrap_or_default();
|
||||
publish_provider_attempt_outcomes(self.circuits.as_ref(), pending).await;
|
||||
if let Some(budget) = self.budget.take() {
|
||||
settle_budget(budget.as_ref(), core.budget_charge()).await;
|
||||
}
|
||||
if let Some(admission) = self.admission.take() {
|
||||
admission.release().await;
|
||||
}
|
||||
@@ -1035,6 +1097,12 @@ impl Drop for DefaultExecutionSession {
|
||||
}
|
||||
|
||||
impl ExecutionSession for DefaultExecutionSession {
|
||||
fn trace(&self) -> crate::diagnostics::TraceContext {
|
||||
self.core
|
||||
.as_ref()
|
||||
.map(ResponseExecutionSession::trace)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn next_event(&mut self) -> BoxFuture<'_, Result<Option<CoordinatedEvent>, EngineError>> {
|
||||
Box::pin(async move {
|
||||
let result = self.core_mut()?.next_event().await;
|
||||
@@ -1202,11 +1270,22 @@ fn new_request_id() -> Result<ModelRequestId, GatewayError> {
|
||||
.map_err(|_| GatewayError::new(GatewayErrorKind::Internal, "failed to allocate request ID"))
|
||||
}
|
||||
|
||||
fn map_routing_error(error: crate::error::RoutingError) -> GatewayError {
|
||||
fn map_routing_error(error: crate::validation::RoutingError) -> GatewayError {
|
||||
match error {
|
||||
crate::error::RoutingError::NoCapableProvider { .. }
|
||||
| crate::error::RoutingError::NoCapableProviderEndpoint { .. }
|
||||
| crate::error::RoutingError::EmptyAccountScope => GatewayError::new(
|
||||
crate::validation::RoutingError::ModelNotFound {
|
||||
model,
|
||||
mapped_model,
|
||||
} => GatewayError::new(
|
||||
GatewayErrorKind::ModelNotFound,
|
||||
if model == mapped_model {
|
||||
"the requested model was not found in the provider catalogs available to this API key; check the model name"
|
||||
} else {
|
||||
"the requested model maps to an upstream model that was not found in the provider catalogs available to this API key; check the configured model mapping"
|
||||
},
|
||||
),
|
||||
crate::validation::RoutingError::NoCapableProvider { .. }
|
||||
| crate::validation::RoutingError::NoCapableProviderEndpoint { .. }
|
||||
| crate::validation::RoutingError::EmptyAccountScope => GatewayError::new(
|
||||
GatewayErrorKind::NoAvailableProvider,
|
||||
"no provider can execute this request",
|
||||
),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! 模型请求生命周期、单行持久化 port 与 commit/send/cancellation 边界。
|
||||
|
||||
pub mod admission;
|
||||
pub mod budget;
|
||||
pub mod continuation;
|
||||
pub mod coordinator;
|
||||
pub mod execution;
|
||||
mod observation;
|
||||
pub mod probe;
|
||||
pub mod provider;
|
||||
|
||||
@@ -22,17 +24,18 @@ use thiserror::Error;
|
||||
use crate::account::{AccountSelectionPolicy, ProviderAccountId};
|
||||
use crate::engine::continuation::{ContinuationBinding, NativeContinuationPin};
|
||||
use crate::error::{
|
||||
GatewayError, IdentifierError, ProviderConnectionObservation, ProviderError, ProviderErrorKind,
|
||||
StoreError, validate_text,
|
||||
GatewayError, ProviderConnectionObservation, ProviderError, ProviderErrorKind, StoreError,
|
||||
};
|
||||
use crate::event::ProviderEvent;
|
||||
use crate::identity::ProviderKind;
|
||||
use crate::lifecycle::CancellationToken;
|
||||
use crate::metering::{CostEstimate, Usage};
|
||||
use crate::operation::OperationKind;
|
||||
use crate::operation::ProviderSessionState;
|
||||
use crate::policy::ClientApiKeyId;
|
||||
use crate::routing::{ConfigRevision, ProviderKind, PublicModelId, UpstreamModelId};
|
||||
use crate::routing::{ConfigRevision, PublicModelId, UpstreamModelId};
|
||||
use crate::upstream::UpstreamSendState;
|
||||
use crate::validation::{IdentifierError, validate_text};
|
||||
|
||||
/// `model_requests.id`。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
@@ -153,7 +156,7 @@ pub struct AccountAttemptContext {
|
||||
state_owner: Option<ProviderAccountStateOwner>,
|
||||
credential_recovery_attempted: bool,
|
||||
diagnostic_required_account: bool,
|
||||
account_scope: Option<Arc<crate::routing::FrozenAccountScope>>,
|
||||
account_scope: Option<Arc<crate::account::scope::FrozenAccountScope>>,
|
||||
}
|
||||
|
||||
impl AccountAttemptContext {
|
||||
@@ -194,7 +197,10 @@ impl AccountAttemptContext {
|
||||
|
||||
/// 附着普通请求认证时冻结的账号范围。
|
||||
#[must_use]
|
||||
pub fn with_account_scope(mut self, scope: Arc<crate::routing::FrozenAccountScope>) -> Self {
|
||||
pub fn with_account_scope(
|
||||
mut self,
|
||||
scope: Arc<crate::account::scope::FrozenAccountScope>,
|
||||
) -> Self {
|
||||
self.account_scope = Some(scope);
|
||||
self
|
||||
}
|
||||
@@ -233,7 +239,7 @@ impl AccountAttemptContext {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn account_scope(&self) -> Option<&Arc<crate::routing::FrozenAccountScope>> {
|
||||
pub const fn account_scope(&self) -> Option<&Arc<crate::account::scope::FrozenAccountScope>> {
|
||||
self.account_scope.as_ref()
|
||||
}
|
||||
}
|
||||
@@ -311,6 +317,7 @@ pub struct RequestAttemptContext {
|
||||
request_id: ModelRequestId,
|
||||
client_api_key_ref: ClientApiKeyId,
|
||||
timing_started_at: Instant,
|
||||
trace: crate::diagnostics::TraceContext,
|
||||
}
|
||||
|
||||
impl RequestAttemptContext {
|
||||
@@ -320,9 +327,16 @@ impl RequestAttemptContext {
|
||||
request_id,
|
||||
client_api_key_ref,
|
||||
timing_started_at: Instant::now(),
|
||||
trace: crate::diagnostics::TraceContext::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_trace(mut self, trace: crate::diagnostics::TraceContext) -> Self {
|
||||
self.trace = trace;
|
||||
self
|
||||
}
|
||||
|
||||
/// 覆盖本次请求的单调计时原点。
|
||||
#[must_use]
|
||||
pub fn with_timing_started_at(mut self, timing_started_at: Instant) -> Self {
|
||||
@@ -362,6 +376,12 @@ pub struct AttemptContext {
|
||||
}
|
||||
|
||||
impl AttemptContext {
|
||||
/// 当前 attempt 的诊断关联;克隆后可传给后台 transport 任务。
|
||||
#[must_use]
|
||||
pub fn trace(&self) -> crate::diagnostics::TraceContext {
|
||||
self.request.trace.attempt(self.attempt_index.get())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
request: RequestAttemptContext,
|
||||
@@ -469,7 +489,7 @@ impl AttemptContext {
|
||||
|
||||
/// 普通请求认证时冻结的账号范围;管理端诊断为 `None`。
|
||||
#[must_use]
|
||||
pub const fn account_scope(&self) -> Option<&Arc<crate::routing::FrozenAccountScope>> {
|
||||
pub const fn account_scope(&self) -> Option<&Arc<crate::account::scope::FrozenAccountScope>> {
|
||||
self.account.account_scope()
|
||||
}
|
||||
|
||||
@@ -607,6 +627,8 @@ pub struct ModelRequestFinalization {
|
||||
pub service_tier: Option<String>,
|
||||
/// Provider 已筛选的专有观测 JSON;Core 不解释字段。
|
||||
pub provider_metadata_json: Option<String>,
|
||||
/// 请求全程的有界诊断快照,跨 Provider 与重试保留。
|
||||
pub diagnostic_trace_json: Option<String>,
|
||||
pub error: Option<GatewayError>,
|
||||
pub provider_error_code: Option<String>,
|
||||
/// Provider 返回的原始错误正文或 WebSocket close/error frame。
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! 单次响应的计量、时间与响应身份事实;重试丢弃时统一清理。
|
||||
|
||||
use super::ModelRequestTimings;
|
||||
use crate::event::GatewayEvent;
|
||||
use crate::event::ProviderResponseObservation;
|
||||
use crate::metering::{CostEstimate, CostSource, Usage};
|
||||
use std::time::Instant;
|
||||
|
||||
pub(super) struct ResponseObservation {
|
||||
pub(super) timing_started_at: Instant,
|
||||
pub(super) usage: Usage,
|
||||
pub(super) cost: CostEstimate,
|
||||
pub(super) timings: ModelRequestTimings,
|
||||
pub(super) client_response_id: Option<String>,
|
||||
pub(super) upstream_response_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ResponseObservation {
|
||||
pub(super) fn new(timing_started_at: Instant) -> Self {
|
||||
Self {
|
||||
timing_started_at,
|
||||
usage: Usage::new(),
|
||||
cost: CostEstimate::unavailable(),
|
||||
timings: ModelRequestTimings::default(),
|
||||
client_response_id: None,
|
||||
upstream_response_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_ms(&self) -> u64 {
|
||||
u64::try_from(self.timing_started_at.elapsed().as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
pub(super) fn finish(&mut self) {
|
||||
self.timings.latency_ms = Some(self.elapsed_ms());
|
||||
}
|
||||
|
||||
pub(super) fn observe_event(&mut self, event: &GatewayEvent) {
|
||||
let elapsed = self.elapsed_ms();
|
||||
observe_event_timing(&mut self.timings, event, elapsed);
|
||||
if let GatewayEvent::Usage(observed) = event {
|
||||
self.usage.merge(observed);
|
||||
}
|
||||
if let GatewayEvent::CalculatedCost(observed) = event
|
||||
&& self.cost.source() != CostSource::ProviderReported
|
||||
{
|
||||
self.cost = observed.into_estimate();
|
||||
}
|
||||
if let GatewayEvent::ProviderCost(observed) = event {
|
||||
self.cost = observed.into_estimate();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn observe_identity(&mut self, event: &GatewayEvent) {
|
||||
let metadata = match event {
|
||||
GatewayEvent::Started(metadata) | GatewayEvent::Completed(metadata) => metadata,
|
||||
_ => return,
|
||||
};
|
||||
let response_id = metadata.response_id().to_owned();
|
||||
self.client_response_id = Some(response_id.clone());
|
||||
self.upstream_response_id = Some(response_id);
|
||||
}
|
||||
|
||||
pub(super) fn reset_for_attempt(&mut self) {
|
||||
self.usage = Usage::new();
|
||||
self.cost = CostEstimate::unavailable();
|
||||
self.client_response_id = None;
|
||||
self.upstream_response_id = None;
|
||||
self.timings.transport_decision_wait_ms = None;
|
||||
self.timings.connect_ms = None;
|
||||
self.timings.headers_ms = None;
|
||||
self.timings.first_event_ms = None;
|
||||
self.timings.first_reasoning_ms = None;
|
||||
self.timings.first_text_ms = None;
|
||||
self.timings.first_token_ms = None;
|
||||
self.timings.provider_processing_ms = None;
|
||||
}
|
||||
|
||||
pub(super) fn observe_response(&mut self, observation: &ProviderResponseObservation) {
|
||||
let observed = observation.timings();
|
||||
if let Some(value) = observed.transport_decision_wait_ms {
|
||||
self.timings.transport_decision_wait_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.connect_ms {
|
||||
self.timings.connect_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.headers_ms {
|
||||
self.timings.headers_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.first_event_ms {
|
||||
self.timings.first_event_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.first_reasoning_ms {
|
||||
self.timings.first_reasoning_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.first_text_ms {
|
||||
self.timings.first_text_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.first_token_ms {
|
||||
self.timings.first_token_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.provider_processing_ms {
|
||||
self.timings.provider_processing_ms = Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_event_timing(timings: &mut ModelRequestTimings, event: &GatewayEvent, elapsed_ms: u64) {
|
||||
timings.first_event_ms.get_or_insert(elapsed_ms);
|
||||
match event {
|
||||
GatewayEvent::ReasoningDelta(_) => {
|
||||
timings.first_reasoning_ms.get_or_insert(elapsed_ms);
|
||||
timings.first_token_ms.get_or_insert(elapsed_ms);
|
||||
}
|
||||
GatewayEvent::TextDelta(_) => {
|
||||
timings.first_text_ms.get_or_insert(elapsed_ms);
|
||||
timings.first_token_ms.get_or_insert(elapsed_ms);
|
||||
}
|
||||
// `response.output_item.added` 会先投影一个空参数的 tool delta;它只是结构帧,
|
||||
// 不能抢在真实工具参数之前成为首个可消费 token。
|
||||
GatewayEvent::ToolCallDelta(delta) if !delta.arguments_delta.is_empty() => {
|
||||
timings.first_token_ms.get_or_insert(elapsed_ms);
|
||||
}
|
||||
GatewayEvent::CalculatedCost(_) | GatewayEvent::ProviderCost(_) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ use bytes::Bytes;
|
||||
use futures::future::BoxFuture;
|
||||
|
||||
use crate::error::{ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind};
|
||||
use crate::routing::{ProviderKind, UpstreamModelId};
|
||||
use crate::identity::ProviderKind;
|
||||
use crate::routing::UpstreamModelId;
|
||||
use crate::{account::ProviderAccountId, operation::Operation, upstream::UpstreamSendState};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
|
||||
@@ -15,13 +15,15 @@ use crate::account::{
|
||||
AccountAttemptFeedback, AccountCapacitySnapshot, AccountFeedbackStats, ProviderAccountId,
|
||||
};
|
||||
use crate::engine::AttemptContext;
|
||||
use crate::error::{OpaqueUpstreamValue, PreDeliveryRetry, ProviderError, ProviderErrorKind};
|
||||
use crate::error::{PreDeliveryRetry, ProviderError, ProviderErrorKind};
|
||||
use crate::event::{EventSequenceValidator, ProviderEvent};
|
||||
use crate::identity::ProviderKind;
|
||||
use crate::operation::Operation;
|
||||
use crate::policy::ClientApiKeyId;
|
||||
use crate::routing::{
|
||||
ModelCapabilities, ModelPresentation, ProviderCandidate, ProviderKind, UpstreamModelId,
|
||||
ModelCapabilities, ModelPresentation, ProviderCandidate, PublicModelId, UpstreamModelId,
|
||||
};
|
||||
use crate::upstream::OpaqueUpstreamValue;
|
||||
use crate::upstream::{UpstreamSendState, UpstreamTransport};
|
||||
|
||||
/// Box 只出现在 Provider Registry 的统一 event envelope 边界。
|
||||
@@ -416,6 +418,8 @@ pub struct ProviderRequest {
|
||||
/// Provider 对公共观测表可解释的请求语义;未知字段保持空值。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ProviderRequestObservation {
|
||||
/// 独立 Provider 端点声明的请求模型,仅用于观测,不参与文本模型目录路由。
|
||||
pub requested_model: Option<PublicModelId>,
|
||||
/// 客户端原始请求中的推理强度。
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub reasoning_preset: Option<String>,
|
||||
|
||||
@@ -8,126 +8,11 @@ use bytes::Bytes;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::event::{ProviderEvent, ProviderResponseHeader};
|
||||
pub use crate::upstream::OpaqueUpstreamValue;
|
||||
use crate::upstream::UpstreamSendState;
|
||||
|
||||
/// 应用层标识不满足约束。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum IdentifierError {
|
||||
/// 标识为空。
|
||||
#[error("identifier must not be empty")]
|
||||
Empty,
|
||||
/// 标识超过核心允许的字节数。
|
||||
#[error("identifier exceeds {max_bytes} bytes")]
|
||||
TooLong {
|
||||
/// 最大字节数。
|
||||
max_bytes: usize,
|
||||
},
|
||||
/// 标识使用了保留的系统前缀。
|
||||
#[error("identifier uses the reserved system prefix")]
|
||||
ReservedPrefix,
|
||||
/// 标识包含控制字符。
|
||||
#[error("identifier contains control characters")]
|
||||
ControlCharacter,
|
||||
/// 标识缺少规定的语义前缀。
|
||||
#[error("identifier must start with `{expected}`")]
|
||||
MissingPrefix {
|
||||
/// 规定前缀。
|
||||
expected: &'static str,
|
||||
},
|
||||
/// 标识不满足该领域值对象的固定格式。
|
||||
#[error("identifier has an invalid format")]
|
||||
InvalidFormat,
|
||||
}
|
||||
|
||||
/// Operation 构造或校验失败。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum OperationError {
|
||||
/// 必填文本为空。
|
||||
#[error("`{field}` must not be empty")]
|
||||
EmptyField {
|
||||
/// 字段名。
|
||||
field: &'static str,
|
||||
},
|
||||
/// 数量字段为零。
|
||||
#[error("`{field}` must be greater than zero")]
|
||||
ZeroValue {
|
||||
/// 字段名。
|
||||
field: &'static str,
|
||||
},
|
||||
/// JSON 字段必须是 object。
|
||||
#[error("`{field}` must be a JSON object")]
|
||||
JsonObjectRequired {
|
||||
/// 字段名。
|
||||
field: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
/// 路由快照或 Route Plan 不满足不变量。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum RoutingError {
|
||||
/// 动态 Provider/model 标识无法构造。
|
||||
#[error("routing identifier is invalid")]
|
||||
InvalidIdentifier,
|
||||
/// 配置 revision 必须为正数。
|
||||
#[error("config revision must be greater than zero")]
|
||||
InvalidRevision,
|
||||
/// Restricted scope 必须保留至少一个持久化分组 binding。
|
||||
#[error("account routing scope is invalid")]
|
||||
InvalidAccountScope,
|
||||
/// Key 的账号范围中当前没有任何可参与路由的账号。
|
||||
#[error("account routing scope is empty")]
|
||||
EmptyAccountScope,
|
||||
/// 快照中存在重复实体。
|
||||
#[error("duplicate {entity} `{id}`")]
|
||||
DuplicateEntity {
|
||||
/// 实体类型。
|
||||
entity: &'static str,
|
||||
/// 实体 ID。
|
||||
id: String,
|
||||
},
|
||||
/// 实体引用不存在。
|
||||
#[error("{entity} `{id}` was not found")]
|
||||
NotFound {
|
||||
/// 实体类型。
|
||||
entity: &'static str,
|
||||
/// 实体 ID。
|
||||
id: String,
|
||||
},
|
||||
/// 固定平台内没有可执行本次请求的 Provider。
|
||||
#[error("no provider can execute model `{model}`")]
|
||||
NoCapableProvider {
|
||||
/// 客户端提交的模型名称。
|
||||
model: String,
|
||||
},
|
||||
/// 固定 Provider 的原生端点当前不可执行。
|
||||
#[error("provider endpoint `{provider}` is unavailable")]
|
||||
NoCapableProviderEndpoint {
|
||||
/// adapter 已绑定的 Provider。
|
||||
provider: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 调用方策略不满足约束或拒绝请求。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum PolicyError {
|
||||
/// 请求超过调用方策略。
|
||||
#[error("request was denied by caller policy: {reason}")]
|
||||
Denied {
|
||||
/// 稳定拒绝原因。
|
||||
reason: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
/// 用量或价格估算不满足事实约束。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum MeteringError {
|
||||
/// 十进制定点值格式无效或超过 `numeric(20, 10)`。
|
||||
#[error("decimal value must fit unsigned numeric(20, 10)")]
|
||||
InvalidDecimal,
|
||||
/// 货币代码无效。
|
||||
#[error("currency must be a three-letter uppercase ASCII code")]
|
||||
InvalidCurrency,
|
||||
}
|
||||
pub use crate::validation::{
|
||||
IdentifierError, MeteringError, OperationError, PolicyError, RoutingError,
|
||||
};
|
||||
|
||||
/// 跨 Provider 稳定的上游失败分类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -231,33 +116,6 @@ pub enum PreDeliveryRetry {
|
||||
SameAccountTransportFallback,
|
||||
}
|
||||
|
||||
/// Adapter 已明确分类为非 bearer 的不透明上游值。
|
||||
///
|
||||
/// Core 不解释或校验内容,只通过自定义 [`Debug`] 避免它意外进入日志。协议 adapter
|
||||
/// 可在原客户端响应中读取原值。
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct OpaqueUpstreamValue(String);
|
||||
|
||||
impl OpaqueUpstreamValue {
|
||||
/// 原样保存上游值。
|
||||
#[must_use]
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
/// 返回已经由 adapter 安全分类的原值。
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for OpaqueUpstreamValue {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("OpaqueUpstreamValue(<redacted-from-Debug>)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 已由 Provider 从结构化上游错误中提取、仅供原客户端协议展示的错误详情。
|
||||
///
|
||||
/// 它不是诊断日志或持久化错误信息:[`Debug`] 会隐藏全部值,调用方应只在
|
||||
@@ -314,17 +172,72 @@ impl fmt::Debug for ClientVisibleUpstreamError {
|
||||
/// Adapter 只能放入不含凭据、cookie、原始请求正文和 opaque response ID 的有界摘要;
|
||||
/// 原始上游错误返回由 [`RawUpstreamError`] 单独承载。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ProviderDiagnostic(String);
|
||||
pub struct ProviderDiagnostic {
|
||||
message: String,
|
||||
stage: Option<&'static str>,
|
||||
code: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl ProviderDiagnostic {
|
||||
#[must_use]
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self(message.into())
|
||||
Self {
|
||||
message: message.into(),
|
||||
stage: None,
|
||||
code: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 附着本地定义的阶段与原因码;独立于发送状态和重试策略。
|
||||
#[must_use]
|
||||
pub const fn with_classification(mut self, stage: &'static str, code: &'static str) -> Self {
|
||||
self.stage = Some(stage);
|
||||
self.code = Some(code);
|
||||
self
|
||||
}
|
||||
|
||||
/// 从错误链补充可证明的 I/O 原因;只保留本地原因码,不格式化原始错误。
|
||||
#[must_use]
|
||||
pub fn with_io_cause(mut self, error: &(dyn std::error::Error + 'static)) -> Self {
|
||||
let mut source = Some(error);
|
||||
while let Some(error) = source {
|
||||
if let Some(error) = error.downcast_ref::<std::io::Error>() {
|
||||
let code = match error.kind() {
|
||||
std::io::ErrorKind::ConnectionRefused => Some("connection_refused"),
|
||||
std::io::ErrorKind::ConnectionReset => Some("tcp_reset"),
|
||||
std::io::ErrorKind::ConnectionAborted => Some("connection_aborted"),
|
||||
std::io::ErrorKind::BrokenPipe => Some("broken_pipe"),
|
||||
std::io::ErrorKind::UnexpectedEof => Some("unexpected_eof"),
|
||||
std::io::ErrorKind::TimedOut => Some("io_timeout"),
|
||||
std::io::ErrorKind::NetworkUnreachable => Some("network_unreachable"),
|
||||
std::io::ErrorKind::HostUnreachable => Some("host_unreachable"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(code) = code {
|
||||
self.message.push_str("; I/O cause: ");
|
||||
self.message.push_str(code);
|
||||
self.code = Some(code);
|
||||
break;
|
||||
}
|
||||
}
|
||||
source = error.source();
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn stage(&self) -> Option<&'static str> {
|
||||
self.stage
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> Option<&'static str> {
|
||||
self.code
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
&self.message
|
||||
}
|
||||
}
|
||||
|
||||
@@ -623,13 +536,21 @@ impl ProviderError {
|
||||
.get_or_insert_with(|| Box::new(ProviderErrorFailureObservation::default()))
|
||||
}
|
||||
|
||||
/// 标记 Provider 已证明本次拒绝没有执行生成,可在下游提交前重放。
|
||||
/// 标记 Provider 已证明本次失败可在下游提交前安全重放。
|
||||
///
|
||||
/// 证明可以来自明确的“未执行”拒绝,也可以来自同 attempt 已交付、可恢复
|
||||
/// 同一业务请求的状态检查点;后者仍须由 Core 的同账号恢复门禁单独校验。
|
||||
#[must_use]
|
||||
pub const fn with_replay_safe(mut self) -> Self {
|
||||
self.replay_safe = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// 原地标记 Provider 已证明本次失败可从已交付的状态检查点安全恢复。
|
||||
pub const fn set_replay_safe(&mut self) {
|
||||
self.replay_safe = true;
|
||||
}
|
||||
|
||||
/// 允许 Core 仅在客户端尚未收到任何事件时执行一次受预算约束的换号恢复。
|
||||
///
|
||||
/// 该标记不证明上游未执行,也不等价于 [`Self::with_replay_safe`];它只表达
|
||||
@@ -951,7 +872,10 @@ impl fmt::Debug for ProviderError {
|
||||
"sensitive_context",
|
||||
&self.sensitive_context_redacted.then_some("<redacted>"),
|
||||
)
|
||||
.field("diagnostic", &self.diagnostic.as_ref().map(|_| "<present>"))
|
||||
.field(
|
||||
"diagnostic",
|
||||
&self.diagnostic.as_deref().map(|_| "<present>"),
|
||||
)
|
||||
.field(
|
||||
"raw_upstream_error",
|
||||
&self.raw_upstream_error.as_ref().map(|_| "<present>"),
|
||||
@@ -1049,8 +973,15 @@ impl GatewayErrorKind {
|
||||
pub struct GatewayError {
|
||||
kind: GatewayErrorKind,
|
||||
message: &'static str,
|
||||
diagnostic: Option<ProviderDiagnostic>,
|
||||
diagnostic: Option<Box<ProviderDiagnostic>>,
|
||||
client_visible_upstream_error: Option<ClientVisibleUpstreamError>,
|
||||
client_details: Option<Box<GatewayClientErrorDetails>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
struct GatewayClientErrorDetails {
|
||||
code: Option<&'static str>,
|
||||
retry_after: Option<Duration>,
|
||||
}
|
||||
|
||||
impl GatewayError {
|
||||
@@ -1062,9 +993,33 @@ impl GatewayError {
|
||||
message,
|
||||
diagnostic: None,
|
||||
client_visible_upstream_error: None,
|
||||
client_details: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_client_code(mut self, code: &'static str) -> Self {
|
||||
self.client_details
|
||||
.get_or_insert_with(Default::default)
|
||||
.code = Some(code);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_retry_after(mut self, delay: Duration) -> Self {
|
||||
self.client_details
|
||||
.get_or_insert_with(Default::default)
|
||||
.retry_after = Some(delay);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn retry_after(&self) -> Option<Duration> {
|
||||
self.client_details
|
||||
.as_ref()
|
||||
.and_then(|details| details.retry_after)
|
||||
}
|
||||
|
||||
/// 将 Provider 错误归一为客户端无关错误。
|
||||
#[must_use]
|
||||
pub fn from_provider(error: &ProviderError) -> Self {
|
||||
@@ -1127,7 +1082,7 @@ impl GatewayError {
|
||||
/// 附加只供运维观测使用的脱敏 Provider 摘要。
|
||||
#[must_use]
|
||||
pub fn with_diagnostic(mut self, diagnostic: ProviderDiagnostic) -> Self {
|
||||
self.diagnostic = Some(diagnostic);
|
||||
self.diagnostic = Some(Box::new(diagnostic));
|
||||
self
|
||||
}
|
||||
|
||||
@@ -1153,7 +1108,7 @@ impl GatewayError {
|
||||
/// 返回可安全持久化和展示的 Provider 诊断摘要。
|
||||
#[must_use]
|
||||
pub fn diagnostic(&self) -> Option<&ProviderDiagnostic> {
|
||||
self.diagnostic.as_ref()
|
||||
self.diagnostic.as_deref()
|
||||
}
|
||||
|
||||
/// 返回优先用于原客户端协议响应的 message;持久化和日志必须继续使用
|
||||
@@ -1179,6 +1134,11 @@ impl GatewayError {
|
||||
self.client_visible_upstream_error
|
||||
.as_ref()
|
||||
.and_then(ClientVisibleUpstreamError::code)
|
||||
.or_else(|| {
|
||||
self.client_details
|
||||
.as_ref()
|
||||
.and_then(|details| details.code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1188,7 +1148,10 @@ impl fmt::Debug for GatewayError {
|
||||
.debug_struct("GatewayError")
|
||||
.field("kind", &self.kind)
|
||||
.field("message", &self.message)
|
||||
.field("diagnostic", &self.diagnostic.as_ref().map(|_| "<present>"))
|
||||
.field(
|
||||
"diagnostic",
|
||||
&self.diagnostic.as_deref().map(|_| "<present>"),
|
||||
)
|
||||
.field(
|
||||
"client_visible_upstream_error",
|
||||
&self
|
||||
@@ -1234,29 +1197,3 @@ impl StoreError {
|
||||
self.kind
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_text(
|
||||
value: &str,
|
||||
max_bytes: usize,
|
||||
reject_reserved_prefix: bool,
|
||||
required_prefix: Option<&'static str>,
|
||||
) -> Result<(), IdentifierError> {
|
||||
if value.is_empty() {
|
||||
return Err(IdentifierError::Empty);
|
||||
}
|
||||
if value.len() > max_bytes {
|
||||
return Err(IdentifierError::TooLong { max_bytes });
|
||||
}
|
||||
if value.chars().any(char::is_control) {
|
||||
return Err(IdentifierError::ControlCharacter);
|
||||
}
|
||||
if reject_reserved_prefix && value.starts_with("__") {
|
||||
return Err(IdentifierError::ReservedPrefix);
|
||||
}
|
||||
if let Some(prefix) = required_prefix
|
||||
&& !value.starts_with(prefix)
|
||||
{
|
||||
return Err(IdentifierError::MissingPrefix { expected: prefix });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@ use bytes::Bytes;
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::error::{IdentifierError, OpaqueUpstreamValue, validate_text};
|
||||
use crate::metering::{CalculatedCost, ProviderReportedCost, Usage};
|
||||
use crate::operation::ProviderSessionState;
|
||||
use crate::upstream::OpaqueUpstreamValue;
|
||||
use crate::upstream::UpstreamTransport;
|
||||
use crate::validation::{IdentifierError, validate_text};
|
||||
|
||||
/// 一次响应的稳定元数据。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -763,7 +764,8 @@ impl ProviderEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/// 把本事件标记为 Provider 连接内状态的提交边界。
|
||||
/// 附加 Provider 私有状态检查点;Core 可将其用于同请求恢复,协议连接可在
|
||||
/// terminal 事件上持有它供下一轮使用。
|
||||
pub fn attach_session_update(&mut self, state: ProviderSessionState) {
|
||||
self.session_update = Some(Box::new(state));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//! 编译期 Provider 身份;账号与路由共同使用,不依赖路由计划。
|
||||
|
||||
use crate::validation::{IdentifierError, validate_text};
|
||||
use std::fmt;
|
||||
|
||||
/// 编译进二进制的 Provider adapter slug。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ProviderKind(String);
|
||||
|
||||
impl ProviderKind {
|
||||
/// 校验 Provider slug。
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, IdentifierError> {
|
||||
let value = value.into();
|
||||
validate_text(&value, 64, true, None)?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ProviderKind {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@
|
||||
//! 具体客户端协议和具体 Provider 都通过外层 adapter 接入。
|
||||
|
||||
pub mod account;
|
||||
pub mod diagnostics;
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod health;
|
||||
pub mod identity;
|
||||
pub mod lifecycle;
|
||||
pub mod metering;
|
||||
pub mod operation;
|
||||
@@ -17,6 +19,7 @@ pub mod routing;
|
||||
pub mod runtime;
|
||||
pub mod task;
|
||||
pub mod upstream;
|
||||
pub mod validation;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
@@ -49,6 +52,7 @@ pub struct CoreStorePorts {
|
||||
snapshots: Arc<dyn SnapshotStorePort>,
|
||||
snapshot_subscriptions: Arc<dyn SnapshotSubscriptionPort>,
|
||||
client_api_key_usage: Arc<dyn ClientApiKeyUsageSink>,
|
||||
budget: Option<Arc<dyn engine::budget::ClientBudgetPort>>,
|
||||
}
|
||||
|
||||
impl CoreStorePorts {
|
||||
@@ -76,8 +80,15 @@ impl CoreStorePorts {
|
||||
snapshots,
|
||||
snapshot_subscriptions,
|
||||
client_api_key_usage,
|
||||
budget: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_budget(mut self, budget: Arc<dyn engine::budget::ClientBudgetPort>) -> Self {
|
||||
self.budget = Some(budget);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CoreBundle {
|
||||
@@ -142,7 +153,7 @@ pub async fn initialize(
|
||||
Arc::clone(&ports.snapshot_subscriptions),
|
||||
));
|
||||
let worker_contributions = publisher.worker_contributions()?;
|
||||
let service = Arc::new(DefaultExecutionService::new(
|
||||
let mut service = DefaultExecutionService::new(
|
||||
snapshots.clone(),
|
||||
ports.execution,
|
||||
providers,
|
||||
@@ -150,7 +161,11 @@ pub async fn initialize(
|
||||
ports.circuits,
|
||||
ports.continuation,
|
||||
ports.client_api_key_usage,
|
||||
));
|
||||
);
|
||||
if let Some(budget) = ports.budget {
|
||||
service = service.with_budget(budget);
|
||||
}
|
||||
let service = Arc::new(service);
|
||||
let execution: Arc<dyn ExecutionService> = service.clone();
|
||||
let account_probe: Arc<dyn AccountProbe> = service;
|
||||
let snapshot_control: Arc<dyn SnapshotControl> = publisher;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::error::MeteringError;
|
||||
use crate::validation::MeteringError;
|
||||
|
||||
const DECIMAL_SCALE: u128 = 10_000_000_000;
|
||||
const MAX_SCALED_DECIMAL: u128 = 99_999_999_999_999_999_999;
|
||||
|
||||
@@ -12,7 +12,7 @@ use bytes::Bytes;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::error::{OperationError, validate_text};
|
||||
use crate::validation::{OperationError, validate_text};
|
||||
|
||||
/// 网关支持的稳定 operation 分类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
|
||||
@@ -11,8 +11,8 @@ pub use client_version::{
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::error::{IdentifierError, PolicyError, validate_text};
|
||||
use crate::routing::FrozenAccountScope;
|
||||
use crate::account::scope::FrozenAccountScope;
|
||||
use crate::validation::{IdentifierError, PolicyError, validate_text};
|
||||
|
||||
/// `client_api_keys.id` 的核心值对象。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
|
||||
@@ -12,9 +12,10 @@ use crate::account::{
|
||||
AccountFeedbackStats, AccountRuntimeSignals, CredentialRevision, CredentialState,
|
||||
OpaqueProviderData, ProviderAccountId, ProviderAccountStore,
|
||||
};
|
||||
use crate::error::{IdentifierError, validate_text};
|
||||
use crate::identity::ProviderKind;
|
||||
use crate::policy::ClientApiKeyId;
|
||||
use crate::routing::{ProviderKind, UpstreamModelId};
|
||||
use crate::routing::UpstreamModelId;
|
||||
use crate::validation::{IdentifierError, validate_text};
|
||||
|
||||
const MAX_PENDING_FLOW_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user