Skip to content

feat(proxy, admin): runtime model status, cooldown, background health check, /admin/v1/models/status - #268

Merged
moonming merged 4 commits into
mainfrom
feat/runtime-model-status-and-health
May 14, 2026
Merged

feat(proxy, admin): runtime model status, cooldown, background health check, /admin/v1/models/status#268
moonming merged 4 commits into
mainfrom
feat/runtime-model-status-and-health

Conversation

@moonming

@moonming moonming commented May 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds runtime model status tracking, request-path cooldown, background health probe (background_model_check), and a new read-only endpoint GET /admin/v1/models/status.

Runtime status tracker (ModelRuntimeStatusTracker)

  • Per-direct-model runtime state keyed by resolved direct-model id
  • Three states: healthy, cooldown (request-path retryable failure), unhealthy (background check failure)
  • Stale awareness: unhealthy entries auto-revert to healthy after stale_after_seconds

Request-path cooldown

  • Retryable bridge errors set a 30s cooldown on the failed target model
  • Routing filter skips targets in cooldown/unhealthy during target selection
  • If filtering would empty all candidates, cooldown/unhealthy is ignored (falls back to original order)

Background model check

  • New background_model_check config block on direct models only (rejected on routing models)
  • Config: enabled, interval_seconds, timeout_seconds, prompt, max_tokens, ignore_statuses, stale_after_seconds
  • Background checker sends a lightweight chat completion probe per configured model
  • ignore_statuses (default [408, 429]) prevents transient failures from flipping unhealthy
  • Non-ignored failures -> unhealthy; success -> clears unhealthy; ignored -> recorded as healthy with observability

/admin/v1/models/status

  • Returns runtime status for every model in the snapshot
  • Direct models: live runtime state with stale awareness
  • Routing models: always not_applicable
  • Includes last_checked_at, last_check_status, status_reason when available

Implementation notes

  • Follows LiteLLM-style design (not full upstream health-check subsystem)
  • No new external concepts (no DeploymentKey, target, deployment)
  • Runtime status TTLs use SystemTime (not tokio::time::Instant) for cross-thread snapshot consistency
  • Background checker runs as a periodic tokio task in aisix-server/src/main.rs

Test coverage

  • Rust unit tests for HealthTracker, ModelRuntimeStatusTracker, background check logic, routing filtering
  • Rust tests for admin GET /admin/v1/models/status response shape
  • Rust tests for background_model_check schema validation (direct accepted, routing rejected)
  • E2E tests: runtime-status-e2e.test.ts, background-health-e2e.test.ts, runtime-mixed-filtering-e2e.test.ts, retry-on-429-vs-background-ignore-e2e.test.ts

Summary by CodeRabbit

  • New Features

    • Admin endpoint to list per-model runtime status (kind, status, last-check, reason).
    • Periodic background health checks for direct models with per-model config (interval, timeout, stale window, ignored statuses).
    • Per-model runtime health tracking, cooldowns, and routing policy to control behavior when all candidates are filtered.
    • Upstream Retry-After honored to influence cooldowns and 503 retry hints.
  • Tests

    • Expanded unit and end-to-end tests for background checks, cooldowns, routing, and retry-after handling.

Review Change Stack

…alth check, and /admin/v1/models/status

- Add ModelRuntimeStatusTracker with cooldown/unhealthy/healthy states
- Set cooldown on request-path retryable failures (30s TTL)
- Add background_model_check config (direct-model only) for periodic health probes
- Mark unhealthy on background check failure, ignore configured transient statuses (408/429)
- Filter routing targets by runtime status (skip cooldown/unhealthy unless all would be excluded)
- Add GET /admin/v1/models/status returning per-model runtime status with stale awareness
- Add BackgroundModelCheck schema (direct-model only, routing models rejected)
- Wire background checker in aisix-server with configurable interval
Copilot AI review requested due to automatic review settings May 13, 2026 08:23
@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: ae97f120-cb10-4bee-9788-60f8e6e4b6e4

📥 Commits

Reviewing files that changed from the base of the PR and between 6b109ed and 7c4dfb2.

📒 Files selected for processing (11)
  • crates/aisix-core/src/models/routing.rs
  • crates/aisix-proxy/src/audio.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/cooldown.rs
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/messages.rs
  • crates/aisix-proxy/src/rerank.rs
  • crates/aisix-proxy/src/responses.rs
  • tests/e2e/src/cases/cooldown-contract-e2e.test.ts
  • tests/e2e/src/cases/routing-strategies-e2e.test.ts
  • tests/e2e/src/harness/admin.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • tests/e2e/src/harness/admin.ts
  • crates/aisix-proxy/src/responses.rs
  • crates/aisix-core/src/models/routing.rs
  • crates/aisix-proxy/src/audio.rs
  • tests/e2e/src/cases/cooldown-contract-e2e.test.ts

📝 Walkthrough

Walkthrough

This PR adds per-direct-model background health checks, a ModelRuntimeStatusTracker with cooldown and stale semantics, Retry-After propagation, health-aware routing/filtering that prefers healthy targets, a GET /admin/v1/models/status admin endpoint (with OpenAPI), server background task wiring, and extensive unit and end-to-end test coverage.

Changes

Background Model Health Checking and Runtime Status

Layer / File(s) Summary
Core model configuration & OpenAPI
crates/aisix-core/src/models/model.rs, crates/aisix-core/src/models/mod.rs, crates/aisix-core/src/models/schema.rs, crates/aisix-admin/src/openapi.rs
Adds BackgroundModelCheck and cooldown fields to Model, enforces direct-model-only constraints in JSON Schema, updates OpenAPI components, and re-exports related types/constants.
Runtime status tracker
crates/aisix-proxy/src/health.rs, crates/aisix-proxy/src/state.rs, crates/aisix-proxy/src/lib.rs
Introduces RuntimeStatus, RuntimeStatusSnapshot, and ModelRuntimeStatusTracker with APIs for mark_cooldown/mark_healthy/mark_unhealthy/record_ignored_check and stale-aware queries; integrates tracker into proxy state.
Background health checks
crates/aisix-proxy/src/background.rs
Implements run_background_model_check_once, per-model check execution via registered bridges with concurrency gating, maps outcomes to tracker updates (success, ignored transient, failure), and includes wiremock tests.
Retry-After parsing & BridgeError plumbing
crates/aisix-gateway/src/bridge.rs, crates/aisix-gateway/src/lib.rs, provider bridges
Adds parse_retry_after, extends BridgeError::UpstreamStatus with retry_after, provides constructors, and updates provider/gateway codepaths/tests to propagate retry-after hints.
Cooldown decision & central note_failure
crates/aisix-proxy/src/cooldown.rs
Adds decide_cooldown, note_failure, and reason_for_status to determine cooldown TTLs (honoring Retry-After/clamping/disable semantics) and to record cooldowns via the runtime tracker; includes unit tests.
Health-aware routing & dispatch integration
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/messages.rs, crates/aisix-proxy/src/rerank.rs, crates/aisix-proxy/src/responses.rs, crates/aisix-proxy/src/audio.rs, crates/aisix-proxy/src/error.rs
Introduces AttemptModel, filter_attempt_models using runtime-status snapshots and OnAllFilteredPolicy, applies attempt-id-based mark_healthy/mark_cooldown, integrates cooldown decisions across dispatch paths, and adds ProxyError::AllCandidatesUnavailable for fail-fast cases; updates many dispatch paths to call note_failure and mark healthy on success.
Admin API and OpenAPI
crates/aisix-admin/src/models_status_handler.rs, crates/aisix-admin/src/lib.rs, crates/aisix-admin/src/state.rs, crates/aisix-admin/src/openapi.rs, tests/e2e/src/harness/admin.ts
Adds ModelKind and ModelStatusView; implements get_models_status that returns flattened runtime snapshots (routing → NotApplicable); wires route and admin state builder for optional tracker; updates OpenAPI and e2e harness to list model statuses.
Server integration & lifecycle
crates/aisix-server/src/main.rs
Spawns periodic background-check task (interval from model configs, 1s floor), passes runtime tracker to admin state when applicable, and awaits task during shutdown.
E2E tests & harness
tests/e2e/src/cases/*, tests/e2e/src/harness/upstream-openai.ts
Adds/updates multiple Vitest E2E suites for background health, cooldown contracts, retry-on-429 interactions, routing strategies, mixed filtering, runtime-status assertions; extends mock upstream harness to set response headers and admin client to list model statuses.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

…st on all-filtered

Addresses HIGH/MEDIUM findings from issue #264 audit on PR #268.

HIGH:
- H1: cooldown trigger is now a configurable status list
  (default [401, 408, 429, 500, 502, 503, 504]) independent of
  is_retryable. 401 / 404 / 408 cool down even though they are
  non-retryable: the same target will keep failing on subsequent
  requests, so the routing filter should skip it.
- H2: upstream Retry-After header (seconds form) is parsed by the
  OpenAI / Anthropic bridges and threaded through
  BridgeError::UpstreamStatus.retry_after. The cooldown layer
  honors it (clamped by max_seconds, default 600s).
- H3: filter_attempt_models returns an AllUnhealthy outcome when
  every candidate is excluded; the dispatch loop converts it into a
  new ProxyError::AllCandidatesUnavailable that maps to 503 +
  Retry-After. Routing models can opt into the legacy "send to
  known-bad" behavior via `on_all_filtered: original_order`.

MEDIUM:
- M1: 429 always cools down regardless of retry_on_429. Retry (same
  request) and cooldown (next request) are independent layers.
- M3: background checks run under a 4-permit semaphore so 100
  direct models cannot fan out 100 concurrent provider probes;
  schema enforces interval_seconds >= 5.

New per-direct-model `cooldown` config block (all fields optional):
  enabled / default_seconds / max_seconds / honor_retry_after /
  trigger_statuses / trigger_on_timeout / trigger_on_transport.

New per-routing-model `on_all_filtered` policy: `fail` (default)
or `original_order`.

E2E:
- 4 baseline files (runtime-status, background-health,
  runtime-mixed-filtering, retry-on-429-vs-background-ignore)
  that were claimed in the original PR description but never
  committed. Now committed and updated for the new contract
  (status_reason names, interval_seconds=5 floor).
- 1 new file `cooldown-contract-e2e.test.ts` with 5 contract
  tests: H1 401 cooldown, M1 429-cools-down-without-retry, H2
  Retry-After-drives-TTL, H3 fail-fast 503, H3 original_order
  escape hatch.
- 2 pre-existing tests (fallback-e2e, routing-strategies-e2e)
  fixed to avoid warming cooldown in their readiness probes —
  use bare waitConfigPropagation() so per-target hit counts
  measure the test request, not the probe.

Rust unit coverage: 22 new chat.rs tests for decide_cooldown +
filter_attempt_models (covers default policy, override,
Retry-After clamping, all-filtered branching, on_all_filtered
policy enforcement).

cargo test --workspace: 686 pass / 0 fail
cargo clippy --workspace --all-targets -- -D warnings: clean
cargo fmt --all -- --check: clean
e2e: 92 pass / 0 fail across 47 files
@moonming

Copy link
Copy Markdown
Collaborator Author

Update: addressed audit findings from issue #264

Force-pushed 6b109ed with the contract fixes the issue review flagged. Summary:

HIGH

  • H1 cooldown trigger decoupled from is_retryable: new cooldown.trigger_statuses config (default [401, 408, 429, 500, 502, 503, 504]). 401 / 408 now cool down even though they are non-retryable — same target would keep failing on the next request, so routing filter should skip it.
  • H2 Retry-After honored: OpenAI and Anthropic bridges parse the seconds-form header into BridgeError::UpstreamStatus.retry_after. Cooldown layer uses it as TTL, clamped by cooldown.max_seconds (default 600s).
  • H3 fail-fast on all-filtered: when every candidate is background-unhealthy, filter_attempt_models returns AllUnhealthy → dispatch returns 503 + Retry-After (new ProxyError::AllCandidatesUnavailable). Legacy behavior opt-in via per-routing on_all_filtered: original_order.

MEDIUM

  • M1 429 cools down regardless of retry_on_429 — retry (same request) and cooldown (next request) are independent layers.
  • M3 background-check safety: 4-permit semaphore caps concurrent probes; schema interval_seconds floor raised to 5.

New config surfaces

# Direct model (all optional)
cooldown:
  enabled: true                    # default
  default_seconds: 30              # default
  max_seconds: 600                 # default
  honor_retry_after: true          # default
  trigger_statuses: [401, 408, 429, 500, 502, 503, 504]  # default
  trigger_on_timeout: true         # default
  trigger_on_transport: true       # default

# Routing model (optional)
on_all_filtered: fail              # fail | original_order; default fail

Test coverage delta

E2E (real backend + mock upstreams, no stubbed bridges):

  • 4 baseline files that were claimed in the original PR description but never committed are now committed and updated for the new contract
  • 1 new file cooldown-contract-e2e.test.ts (5 tests) — H1 401, M1 429 without retry_on_429, H2 Retry-After, H3 fail, H3 escape hatch
  • 2 pre-existing tests (fallback-e2e, routing-strategies-e2e) fixed to avoid warming cooldown in their readiness probes

Rust unit coverage: 22 new chat.rs tests (decide_cooldown + filter_attempt_models).

Checks

  • cargo test --workspace: 686 pass / 0 fail
  • cargo clippy --workspace --all-targets -- -D warnings: clean
  • cargo fmt --all -- --check: clean
  • e2e: 92 pass / 0 fail across 47 files (run against real backend with etcd + mock upstreams)

Independent audit on commit 6b109ed flagged these blockers:

H-1: cooldown was wired only into chat.rs. Anthropic /v1/messages,
OpenAI /v1/responses, audio/speech, audio/transcriptions, and /v1/rerank
all silently dropped the cooldown signal. A 401 from Anthropic via
/v1/messages would never take that target out of rotation.
- Extract decide_cooldown + reason_for_status into crate::cooldown
- Call from messages.rs (both passthrough + cross_provider_dispatch
  paths), responses.rs, audio.rs (speech + transcribe), rerank.rs

H-2: routing-strategies-e2e readiness was weakened to bare
waitConfigPropagation(), losing the verification that the virtual
record had reached the DP. Restored by gating on admin.listModels()
finding the virtual entry — proves propagation without sending
dispatcher traffic that would warm cooldown.

M-1: H2 e2e relied on undocumented serde-default SystemTime shape.
Added wire-shape assertion so a future serialization change fails
with a clear message instead of NaN.

M-2: cooldown.default_seconds: 0 was schema-accepted but at runtime
silently re-derived as max_seconds (default 600s) — the opposite of
the operator's likely intent. Now treated as "disable cooldown on
this model" with new unit test pinning the contract.

M-5: OnAllFilteredPolicy::Fail rustdoc claimed Retry-After is
"derived from the next cooldown expiry"; impl returns a fixed 30s
fallback. Doc corrected to match the actual constant-fallback
behavior with rationale.

Drive-by: tests/e2e/src/harness/admin.ts listModels was decoding
the response as `{items: [...]}` but the admin endpoint returns a
bare array of ResourceEntry. The helper had no callers before now,
so the bug was latent.

cargo test --workspace: 689 pass / 0 fail (+3 for the new
cooldown.rs unit tests).
cargo clippy --workspace --all-targets -- -D warnings: clean.
cargo fmt --all -- --check: clean.
e2e: 92 pass / 0 fail across 47 files against real backend.

Audit-flagged LOW items deferred: HTTP-date Retry-After parsing and
CooldownConfig::default() docs.

M-3 (BridgeError field addition) and M-4 (interval_seconds floor
1→5) are doc-only — call them out as breaking-on-config-reload in
the PR body for upgrade notes.
Copilot AI review requested due to automatic review settings May 14, 2026 00:27
@moonming

Copy link
Copy Markdown
Collaborator Author

Audit response — addressed findings from independent review

Force-pushed 2ea8a17 with the fixes for the independent audit that ran against the prior commit.

HIGH — both addressed

Finding Fix
H-1 cooldown was chat-only; /v1/messages, /v1/responses, audio, /v1/rerank silently dropped the signal Extracted decide_cooldown to crate::cooldown and wired it into all five dispatch sites (messages passthrough + cross-provider, responses, audio/speech, audio/transcribe, rerank)
H-2 routing-strategies-e2e readiness was weakened to bare waitConfigPropagation() Restored verification by gating on admin.listModels() finding the virtual record — proves propagation without dispatching traffic that would warm cooldown

MEDIUM — all addressed

Finding Fix
M-1 H2 e2e relied on undocumented serde-default SystemTime shape Added wire-shape assertion before parsing — future serialization change fails with a clear message instead of NaN
M-2 cooldown.default_seconds: 0 silently re-derived as 600s (opposite of operator intent) Now treated as "disable cooldown on this model"; new unit test pins the contract
M-5 OnAllFilteredPolicy::Fail rustdoc claimed Retry-After is "derived from cooldown expiry"; impl returns fixed 30s Doc corrected to match implementation with rationale

Breaking-change call-outs (audit M-3 / M-4) — doc-only

For upgrade docs:

  • BridgeError::UpstreamStatus gained retry_after: Option<Duration> — workspace-internal; all in-tree construction sites use BridgeError::upstream_status(...) or upstream_status_with_retry_after(...). No on-the-wire effect (the type isn't serialized into etcd or HTTP responses).
  • background_model_check.interval_seconds schema minimum 1 → 5 — existing etcd payloads with interval_seconds: 1 (the previous floor) will be rejected on DP snapshot reload. Operators that ran with 1 should bump to >=5 before upgrading; the schema enforcement guards against burning provider quota on a tight loop.

Drive-by fix

tests/e2e/src/harness/admin.ts::listModels was decoding the response as {items: [...]} but the admin endpoint returns a bare ResourceEntry array. The helper had no callers before this PR so the bug was latent — surfaced when H-2 fix started using it.

Audit-flagged LOW deferred

  • HTTP-date form of Retry-After not parsed (Anthropic rarely uses this) — tracked implicitly by the negative test, can be a follow-up issue if observed in production.
  • CooldownConfig::default() returns all None but enabled_or_default() returns true — slightly counterintuitive when reading the struct cold. Doc improvement, not behavior.

Checks

  • cargo test --workspace: 689 pass / 0 fail (+3 for the new cooldown.rs unit tests)
  • cargo clippy --workspace --all-targets -- -D warnings: clean
  • cargo fmt --all -- --check: clean
  • e2e (real backend + mock upstreams): 92 pass / 0 fail across 47 files

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

…hrough mark_healthy

Round-2 independent audit on commit 2ea8a17 caught that the cooldown
wiring on the five non-chat dispatchers was incomplete: only the
`!status.is_success()` branch ran `decide_cooldown`. The `?`-escape
paths on `.send().await` (transport / TCP reset / DNS) and on
`.json()` / `.bytes()` decode skipped cooldown entirely. So a hard
upstream outage on /v1/messages — exactly the H-1 failure mode the
prior commit claimed to fix — would still leave the target healthy
in routing.

Round-2 HIGH:
- Add `crate::cooldown::note_failure(tracker, model_id, cfg, err)`
  that runs the cooldown decision and marks the tracker, returning
  the BridgeError unchanged so call sites can keep using `?`.
- Wire it into every `.map_err(|e| BridgeError::Transport(...))` and
  `.map_err(|e| BridgeError::UpstreamDecode(...))` on
  messages.rs (2 sites), responses.rs (2 sites),
  rerank.rs (2 sites), audio.rs (4 sites across multipart + speech).

Round-2 MEDIUM:
- messages.rs Anthropic-passthrough success path now also calls
  `state.runtime_status.mark_healthy(&model_entry.id)` so a target
  that recovers via /v1/messages exits `cooldown` cleanly on
  /admin/v1/models/status. Cross-provider sibling paths already did
  this; passthrough was the outlier.

3 new unit tests pin the contract:
- `note_failure_marks_cooldown_for_transport_errors`
- `note_failure_marks_cooldown_for_decode_errors`
- `note_failure_no_op_when_cooldown_disabled`

cargo test --workspace: 692 pass / 0 fail.
cargo clippy --workspace --all-targets -- -D warnings: clean.
cargo fmt --all -- --check: clean.
e2e (real backend + mock upstreams): 92 pass / 0 fail across 47 files.

Audit LOW (admin-snapshot readiness race window) deferred: practical
gap is sub-100ms with 50ms poll cadence, no observed flakes.
@moonming

Copy link
Copy Markdown
Collaborator Author

Audit round-2 response

Round-2 independent audit on commit 2ea8a17 caught a real gap: the H-1 fix wired cooldown into the !status.is_success() branch but NOT into the ?-escape paths on .send().await (transport) and .json() / .bytes() (decode). A hard upstream outage on /v1/messages would still leave the target healthy in routing — the exact failure mode H-1 was supposed to fix.

Force-pushed 7c4dfb2 with:

HIGH — Transport/Decode error cooldown

  • Added crate::cooldown::note_failure(tracker, model_id, cfg, err) helper that runs the cooldown decision, marks the tracker, and returns the BridgeError unchanged so call sites can keep using ?
  • Wired it into every .map_err(BridgeError::Transport) and .map_err(BridgeError::UpstreamDecode) across:
    • messages.rs (send + decode — 2 sites)
    • responses.rs (send + decode — 2 sites)
    • rerank.rs (send + decode — 2 sites)
    • audio.rs (multipart send/decode + speech send/decode — 4 sites)

MEDIUM — Anthropic passthrough mark_healthy

  • messages.rs Anthropic-passthrough success path now calls state.runtime_status.mark_healthy(&model_entry.id) so a recovered target exits cooldown cleanly on /admin/v1/models/status. The cross-provider sibling paths already did this; passthrough was the outlier.

Audit LOW deferred

  • Admin-snapshot readiness race window — practical gap is sub-100ms with 50ms waitConfigPropagation poll cadence; no observed flakes. If we start seeing them, gate on a DP-side health surface instead.

New unit coverage

  • note_failure_marks_cooldown_for_transport_errors
  • note_failure_marks_cooldown_for_decode_errors
  • note_failure_no_op_when_cooldown_disabled

Checks

  • cargo test --workspace: 692 pass / 0 fail
  • cargo clippy --workspace --all-targets -- -D warnings: clean
  • cargo fmt --all -- --check: clean
  • e2e: 92 pass / 0 fail across 47 files (real backend + mock upstreams)

@moonming
moonming merged commit 88ecc10 into main May 14, 2026
7 checks passed
@jarvis9443
jarvis9443 deleted the feat/runtime-model-status-and-health branch June 25, 2026 06:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants