Skip to content

fix(count_tokens): emit the usage event a refusal needs to be findable #2244

fix(count_tokens): emit the usage event a refusal needs to be findable

fix(count_tokens): emit the usage event a refusal needs to be findable #2244

Workflow file for this run

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