fix(count_tokens): emit the usage event a refusal needs to be findable #2244
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| # Manual escape hatch: GitHub intermittently fails to materialize | |
| # pull_request-triggered runs for a branch (observed on #936 — the | |
| # synchronize/opened events produced no check suite while every other | |
| # branch's did). Dispatching on the PR ref attaches the same check | |
| # runs to the head commit. | |
| workflow_dispatch: | |
| env: | |
| CARGO_TERM_COLOR: always | |
| RUST_BACKTRACE: 1 | |
| jobs: | |
| lint: | |
| name: lint (fmt + clippy) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: rustfmt, clippy | |
| - uses: ./.github/actions/setup-protoc | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: cargo fmt --check | |
| run: cargo fmt --all -- --check | |
| - name: cargo clippy | |
| run: cargo clippy --workspace --all-targets -- -D warnings | |
| schema-drift: | |
| name: schema drift (resources) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-protoc | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: regenerate schemas | |
| run: cargo run -p aisix-core --bin dump-schema | |
| - name: assert no drift | |
| run: | | |
| if ! git diff --exit-code schemas/; then | |
| echo "::error::Resource JSON Schemas in 'schemas/resources/' drift from the Rust types in 'crates/aisix-core/src/models/'." | |
| echo "::error::Fix: run 'cargo run -p aisix-core --bin dump-schema' locally and commit the diff." | |
| exit 1 | |
| fi | |
| openapi-generate: | |
| name: generate admin openapi | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-protoc | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: generate Admin API OpenAPI | |
| run: | | |
| cargo run -p aisix-admin --bin dump-openapi > /tmp/admin-api.openapi.json | |
| - name: validate generated OpenAPI | |
| run: | | |
| python3 - <<'PY' | |
| import json | |
| with open("/tmp/admin-api.openapi.json") as f: | |
| spec = json.load(f) | |
| assert spec["openapi"].startswith("3.") | |
| assert spec["info"]["title"] == "AISIX Admin API" | |
| assert spec["info"]["version"] == "dev" | |
| assert "/admin/v1/models" in spec["paths"] | |
| assert "Model" in spec["components"]["schemas"] | |
| PY | |
| rust-unit: | |
| name: rust unit + coverage | |
| runs-on: ubuntu-latest | |
| services: | |
| redis: | |
| # redis:8 ships the vector-search query engine the semantic | |
| # cache needs; a superset of redis:7 for the exact-cache tests. | |
| image: redis:8-alpine | |
| ports: ["6379:6379"] | |
| # Real etcd for crates/aisix-admin/tests/etcd_integration.rs — | |
| # exercises the full Admin handler → EtcdConfigStore → | |
| # aisix-etcd::loader round-trip so regressions in subkey | |
| # constants or KV serde shape get caught here, not at runtime. | |
| # | |
| # Use the upstream Quay-hosted image (Bitnami retired the | |
| # `bitnami/etcd:3.5` floating tag from Docker Hub in 2025; the | |
| # GHA runner's anonymous docker pull fails with "manifest | |
| # unknown" against that registry now). The upstream image | |
| # honours `ETCD_*` env vars directly so we don't need a | |
| # command override. | |
| etcd: | |
| image: quay.io/coreos/etcd:v3.5.18 | |
| ports: ["2379:2379"] | |
| env: | |
| ETCD_LISTEN_CLIENT_URLS: http://0.0.0.0:2379 | |
| ETCD_ADVERTISE_CLIENT_URLS: http://0.0.0.0:2379 | |
| env: | |
| # Picked up by crates/aisix-cache/tests/redis_integration.rs. | |
| # The tests no-op when this is unset (local dev), so absence is safe. | |
| # | |
| # MUST NOT start with the `AISIX_` prefix — Config::load_from_path | |
| # merges every AISIX_* env var into the root Config (via | |
| # config-rs Environment::with_prefix("AISIX")), and Config has | |
| # `#[serde(deny_unknown_fields)]`. An AISIX_REDIS_URL leaks into | |
| # every Config::load_from_path test as `redis_url` and breaks | |
| # the entire `aisix-core::config::tests` module. | |
| CACHE_TEST_REDIS_URL: redis://127.0.0.1:6379 | |
| # Picked up by crates/aisix-cache/tests/semantic_redis_integration.rs | |
| # — needs the vector-capable redis:8 service above. The PLAIN url | |
| # points at a cluster node (redis:7, no vector search) to pin the | |
| # capability probe's failure path. Same skip-if-unset / | |
| # no-AISIX_-prefix rules. | |
| CACHE_TEST_REDIS_VECTOR_URL: redis://127.0.0.1:6379 | |
| CACHE_TEST_REDIS_PLAIN_URL: redis://127.0.0.1:7000 | |
| # Picked up by crates/aisix-ratelimit/tests/redis_integration.rs | |
| # (shared cluster-level counters, #798). Same skip-if-unset / | |
| # no-AISIX_-prefix rules as CACHE_TEST_REDIS_URL above. | |
| RATELIMIT_TEST_REDIS_URL: redis://127.0.0.1:6379 | |
| # Redis Cluster + Sentinel topologies started by the "Start Redis | |
| # Cluster + Sentinel" step below — exercise the `mode: cluster` / | |
| # `mode: sentinel` connection paths in both cache + ratelimit | |
| # integration tests. Same skip-if-unset / no-AISIX_-prefix rules. | |
| RATELIMIT_TEST_REDIS_CLUSTER_NODES: redis://127.0.0.1:7000,redis://127.0.0.1:7001,redis://127.0.0.1:7002 | |
| RATELIMIT_TEST_REDIS_SENTINELS: redis://127.0.0.1:26379 | |
| RATELIMIT_TEST_REDIS_MASTER: mymaster | |
| CACHE_TEST_REDIS_CLUSTER_NODES: redis://127.0.0.1:7000,redis://127.0.0.1:7001,redis://127.0.0.1:7002 | |
| CACHE_TEST_REDIS_SENTINELS: redis://127.0.0.1:26379 | |
| CACHE_TEST_REDIS_MASTER: mymaster | |
| # Picked up by crates/aisix-redis/tests/sentinel_failover.rs — the | |
| # master+replica+sentinel(26380) topology the setup step brings up, | |
| # so the failover/reconnect test forces a real promotion in CI. | |
| REDIS_FAILOVER_SENTINELS: redis://127.0.0.1:26380 | |
| REDIS_FAILOVER_MASTER: myfo | |
| # Picked up by crates/aisix-admin/tests/etcd_integration.rs. | |
| # Same skip-if-unset pattern as the Redis case above. | |
| ADMIN_TEST_ETCD_URL: http://127.0.0.1:2379 | |
| # Picked up by crates/aisix-etcd/tests/watch_integration.rs. | |
| ETCD_TEST_URL: http://127.0.0.1:2379 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| # crates/aisix-core/tests/compat_debt.rs resolves "has a release | |
| # shipped since this compat marker's anchor?" from `git tag`. The | |
| # default shallow checkout carries no tags, and that gate FAILS | |
| # rather than skips when it finds none — do not drop this. | |
| fetch-tags: true | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: llvm-tools-preview | |
| - uses: ./.github/actions/setup-protoc | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: install cargo-llvm-cov | |
| uses: taiki-e/install-action@cargo-llvm-cov | |
| - name: Start Redis Cluster + Sentinel | |
| # The `redis` service above covers single-node. Here we bring up a | |
| # 3-node cluster (7000-7002) and a sentinel (26379) monitoring the | |
| # 6379 service as `mymaster`, so the cache + ratelimit integration | |
| # tests exercise the cluster/sentinel connection modes. Host | |
| # networking + announce-ip keep the advertised addresses reachable | |
| # from the test process. | |
| run: | | |
| for p in 7000 7001 7002; do | |
| docker run -d --name rediscl-$p --network host redis:7-alpine \ | |
| redis-server --port $p --cluster-enabled yes --cluster-config-file nodes.conf \ | |
| --cluster-node-timeout 5000 --cluster-announce-ip 127.0.0.1 --appendonly no | |
| done | |
| sleep 2 | |
| docker exec rediscl-7000 redis-cli --cluster create \ | |
| 127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 --cluster-replicas 0 --cluster-yes | |
| for i in $(seq 1 30); do | |
| state=$(docker exec rediscl-7000 redis-cli -p 7000 cluster info | tr -d '\r' | grep -oP 'cluster_state:\K\w+') | |
| [ "$state" = "ok" ] && break | |
| sleep 1 | |
| done | |
| # Fail fast if the cluster never formed, instead of letting the | |
| # integration tests surface it later as confusing connection errors. | |
| [ "$state" = "ok" ] || { echo "::error::Redis cluster did not become ready"; exit 1; } | |
| printf 'port 26379\nsentinel monitor mymaster 127.0.0.1 6379 1\nsentinel down-after-milliseconds mymaster 5000\nsentinel failover-timeout mymaster 10000\n' > /tmp/sentinel.conf | |
| docker run -d --name redis-sentinel --network host -v /tmp/sentinel.conf:/data/sentinel.conf \ | |
| redis:7-alpine redis-sentinel /data/sentinel.conf | |
| sleep 2 | |
| # get-master-addr-by-name prints host and port on separate lines; | |
| # join them so the IP+port match isn't defeated by grep's per-line | |
| # matching. | |
| docker exec redis-sentinel redis-cli -p 26379 sentinel get-master-addr-by-name mymaster | tr -d '\r' | tr '\n' ' ' | grep -qE '127\.0\.0\.1 +6379' \ | |
| || { echo "::error::Sentinel 26379 did not resolve mymaster"; exit 1; } | |
| # Failover topology: master (6381) + replica (6382) + sentinel | |
| # (26380) monitoring `myfo`, so the failover/reconnect test can | |
| # force a real promotion. The sentinel conf dir is mounted (not a | |
| # single file) so sentinel can rewrite it on failover. | |
| docker run -d --name redis-fo-master --network host redis:7-alpine redis-server --port 6381 | |
| docker run -d --name redis-fo-replica --network host redis:7-alpine \ | |
| redis-server --port 6382 --replicaof 127.0.0.1 6381 --replica-announce-ip 127.0.0.1 | |
| sleep 2 | |
| mkdir -p /tmp/sentinel-fo | |
| printf 'port 26380\nsentinel monitor myfo 127.0.0.1 6381 1\nsentinel down-after-milliseconds myfo 5000\nsentinel failover-timeout myfo 10000\nsentinel parallel-syncs myfo 1\n' > /tmp/sentinel-fo/sentinel.conf | |
| docker run -d --name redis-fo-sentinel --network host -v /tmp/sentinel-fo:/data \ | |
| redis:7-alpine redis-sentinel /data/sentinel.conf | |
| sleep 2 | |
| docker exec redis-fo-sentinel redis-cli -p 26380 sentinel get-master-addr-by-name myfo | tr -d '\r' | tr '\n' ' ' | grep -qE '127\.0\.0\.1 +6381' \ | |
| || { echo "::error::Failover sentinel 26380 did not resolve myfo to the master"; exit 1; } | |
| - name: unit tests with coverage | |
| run: cargo llvm-cov --workspace --all-features --lcov --output-path lcov-unit.info | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: coverage-unit | |
| path: lcov-unit.info | |
| retention-days: 7 | |
| build-bin: | |
| name: build aisix (instrumented) | |
| runs-on: ubuntu-latest | |
| env: | |
| RUSTFLAGS: "-C instrument-coverage" | |
| LLVM_PROFILE_FILE: "coverage/aisix-%p-%m.profraw" | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: llvm-tools-preview | |
| - uses: ./.github/actions/setup-protoc | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: build | |
| run: cargo build -p aisix-server --bin aisix | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: aisix-bin | |
| path: target/debug/aisix | |
| # 72 MB binary × many runs will refill the quota quickly; | |
| # keep only as long as the downstream e2e job needs it. | |
| retention-days: 1 | |
| e2e: | |
| name: e2e (vitest, ${{ matrix.serving }}) + coverage | |
| needs: [build-bin] | |
| runs-on: ubuntu-latest | |
| strategy: | |
| # The two serving modes are separate products of the same binary: | |
| # thread-per-core answers a request on the thread that accepted it | |
| # and dispatches on that thread's own upstream pool, work-stealing | |
| # shares one runtime and one pool. A failure in one says nothing | |
| # about the other, so neither leg cancels the other. | |
| fail-fast: false | |
| matrix: | |
| include: | |
| # The Linux default, and so the one production runs. | |
| - serving: thread-per-core | |
| thread_per_core: "true" | |
| # The documented fallback. Kept green so it stays a real | |
| # option when a deployment needs it. | |
| - serving: work-stealing | |
| thread_per_core: "false" | |
| services: | |
| etcd: | |
| image: quay.io/coreos/etcd:v3.5.15 | |
| env: | |
| ETCD_LISTEN_CLIENT_URLS: "http://0.0.0.0:2379" | |
| ETCD_ADVERTISE_CLIENT_URLS: "http://0.0.0.0:2379" | |
| ports: ["2379:2379"] | |
| redis: | |
| # redis:8 = vector-capable, so the semantic-cache redis e2e | |
| # exercises the real path against AISIX_E2E_REDIS. | |
| image: redis:8-alpine | |
| ports: ["6379:6379"] | |
| redis-plain: | |
| # A vector-LESS redis so the semantic-cache degradation suite | |
| # (exact-only on plain redis) actually runs in CI instead of | |
| # skipping forever now that the main service is redis:8. | |
| image: redis:7-alpine | |
| ports: ["6377:6379"] | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/download-artifact@v8 | |
| with: | |
| name: aisix-bin | |
| path: target/debug | |
| - run: chmod +x target/debug/aisix | |
| - uses: actions/setup-node@v6 | |
| with: | |
| # pnpm 11 requires Node 22.13 or newer — it imports `node:sqlite` | |
| # for its store index. The job only passes on Node 20 because it | |
| # happens not to reach that code path; any pnpm command that | |
| # touches the store (a cache step, `pnpm store path`) fails with | |
| # ERR_UNKNOWN_BUILTIN_MODULE. GitHub is also deprecating Node 20 | |
| # on its runners. | |
| node-version: 22 | |
| - uses: pnpm/action-setup@v6 | |
| with: | |
| # Matches the pnpm developers run locally. The harness's | |
| # `tests/e2e/pnpm-workspace.yaml` uses the `allowBuilds` key this | |
| # line unlocks — pnpm 9 rejects that file outright, so the two | |
| # must move together. | |
| version: 11 | |
| - name: check harness exists | |
| id: harness | |
| run: | | |
| if [ -f tests/e2e/package.json ]; then | |
| echo "present=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "present=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::tests/e2e harness not present yet — skipping" | |
| fi | |
| - name: e2e install | |
| if: steps.harness.outputs.present == 'true' | |
| working-directory: tests/e2e | |
| run: pnpm install --no-frozen-lockfile | |
| - name: run e2e | |
| if: steps.harness.outputs.present == 'true' | |
| working-directory: tests/e2e | |
| # Read by the harness, which writes it into every spawned | |
| # gateway's `proxy.thread_per_core`. The binary's own default is | |
| # platform-derived, so without this both legs would run the same | |
| # mode and the second would prove nothing. | |
| env: | |
| E2E_THREAD_PER_CORE: ${{ matrix.thread_per_core }} | |
| # Vector-less redis for the semantic-cache degradation suite; | |
| # the main 6379 service is redis:8 (vector-capable). | |
| AISIX_E2E_REDIS_PLAIN: redis://127.0.0.1:6377 | |
| run: pnpm test | |
| - name: tag coverage by leg | |
| # The gate downloads every leg with `merge-multiple: true`, which | |
| # flattens same-named files to one — the legs must upload distinct | |
| # filenames or only one leg's coverage survives the merge. | |
| if: always() | |
| run: | | |
| if [ -f tests/e2e/coverage/lcov.info ]; then | |
| mv tests/e2e/coverage/lcov.info "tests/e2e/coverage/lcov-${{ matrix.serving }}.info" | |
| fi | |
| - uses: actions/upload-artifact@v7 | |
| if: always() | |
| with: | |
| # Per leg: two uploads under one name is a hard conflict the | |
| # moment the suite starts emitting coverage. | |
| name: coverage-e2e-${{ matrix.serving }} | |
| path: tests/e2e/coverage/lcov-${{ matrix.serving }}.info | |
| if-no-files-found: ignore | |
| retention-days: 7 | |
| e2e-gate: | |
| # `main` requires the status check named literally `e2e (vitest) + | |
| # coverage`. The matrix above publishes one check per leg instead, so | |
| # that exact context never arrives and the PR sits forever at | |
| # "Expected — Waiting for status to be reported" with every leg green. | |
| # This job republishes the required name over the matrix's aggregate | |
| # result, which keeps the branch-protection setting untouched — the | |
| # other open PRs still report the old name and would all break the | |
| # moment the required contexts were renamed to the two leg names. | |
| name: e2e (vitest) + coverage | |
| needs: [e2e, mcp-conformance] | |
| # A skipped required check counts as satisfied, so this must still run | |
| # when the matrix fails — otherwise the gate would vanish exactly when | |
| # it matters. | |
| if: always() | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: assert every serving mode passed | |
| run: | | |
| if [ "${{ needs.e2e.result }}" != "success" ]; then | |
| echo "::error::e2e matrix result: ${{ needs.e2e.result }}" | |
| exit 1 | |
| fi | |
| - name: assert MCP conformance passed | |
| # Folded into this required aggregate so the official-suite gate is | |
| # merge-blocking without renaming the branch-protection contexts. | |
| run: | | |
| if [ "${{ needs.mcp-conformance.result }}" != "success" ]; then | |
| echo "::error::mcp conformance result: ${{ needs.mcp-conformance.result }}" | |
| exit 1 | |
| fi | |
| mcp-conformance: | |
| # The OFFICIAL MCP conformance suite against the shipped /mcp chain | |
| # (scoped gateway + bridge + in-process upstream). Scenario list and | |
| # deliberate exclusions live in scripts/mcp-conformance.sh; the suite | |
| # and its transitive graph are pinned by tools/mcp-conformance/ | |
| # package-lock.json; needs Node >= 22 (fs.globSync). Merge-blocking | |
| # through the e2e-gate aggregate below. | |
| name: mcp conformance (official suite) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| # This job executes third-party code (npm ci + cargo build scripts): | |
| # keep the token read-only and out of the checkout's git config. | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-protoc | |
| - uses: Swatinem/rust-cache@v2 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: 22 | |
| - name: build the conformance target | |
| run: cargo build -p aisix-mcp --example conformance_server | |
| - name: run the applicable server scenarios | |
| run: bash scripts/mcp-conformance.sh | |
| coverage-gate: | |
| name: coverage >= 90% | |
| needs: [rust-unit, e2e] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/download-artifact@v8 | |
| with: { name: coverage-unit, path: cov/unit } | |
| - uses: actions/download-artifact@v8 | |
| # The e2e job invokes `pnpm test` which is `vitest run` with no | |
| # `--coverage` flag, so `tests/e2e/coverage/lcov.info` is never | |
| # written and no `coverage-e2e` artifact is produced. The upload | |
| # step at ~L182 has `if-no-files-found: ignore` so the absence | |
| # is silent. Until the e2e job actually emits coverage (tracked | |
| # follow-up), this download must remain soft — otherwise the | |
| # coverage-gate hard-fails every run on `Artifact not found`. | |
| continue-on-error: true | |
| with: { pattern: coverage-e2e-*, path: cov/e2e, merge-multiple: true } | |
| - name: merge + threshold | |
| run: | | |
| npm i -g lcov-result-merger @lcov-viewer/cli | |
| merged=cov/combined.info | |
| files=(cov/unit/*.info) | |
| [ -d cov/e2e ] && files+=(cov/e2e/*.info) | |
| lcov-result-merger "${files[@]}" "$merged" || cp "${files[0]}" "$merged" | |
| pct=$(awk -F: '/^LF:/ {tot+=$2} /^LH:/ {hit+=$2} END {if (tot>0) printf "%.2f", hit/tot*100; else print 0}' "$merged") | |
| echo "Combined line coverage: ${pct}%" | |
| threshold=${COVERAGE_THRESHOLD:-90} | |
| awk -v p="$pct" -v t="$threshold" 'BEGIN { exit (p+0 < t+0) }' || { | |
| echo "::error::Coverage ${pct}% below threshold ${threshold}%" | |
| exit 1 | |
| } | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: coverage-combined | |
| path: cov/combined.info | |
| retention-days: 7 |