Docs

AISIX 1.2.0

This release rebuilds gateway compatibility on a fixed support floor.

Release date: September 8, 2026

This release rebuilds gateway compatibility on a fixed support floor. The control plane now refuses a configuration that a registered gateway could not load, instead of writing it and warning afterward. The gateway no longer pays whole-configuration costs on every watch event, which is what let a bulk edit starve its own request path. Operators can now choose the label set of each metric. The raw OpenAI cache-write count is carried end to end, from the gateway's own response through the usage logs and their export.

Behavior Changes

  • A token window now refuses the request that arrives with its budget exactly consumed. tpm and tpd admitted one more request while the committed token count sat exactly on the cap, and refused only once it was strictly over. Both backends, in-memory and shared Redis, now compare against the cap the same way. Swapping one for the other does not change the observable limit. This applies to every token window, policy-derived and key-level alike. A window explicitly configured to 0 now refuses every request rather than admitting one. That matches how a 0 request window, such as rpm, has always behaved. Request counters are pre-paid and were already exact, so they are unchanged.

  • A save whose configuration a registered gateway could not load is now refused, not warned about. The control plane validates every projected document against the read contract of every gateway release registered in the target environment. It answers 422 DP_INCOMPATIBLE when one of those releases would reject the document. Previously the write went through and the response carried a row_rejected warning. By that point the resource had already stopped serving on part of the fleet. The error.message field is a complete sentence, naming the gateway release, the affected gateways and the offending part of the document. The error.dp_compat object carries the same facts as fields. The row_rejected warning code is gone. A new below_floor warning reports gateways older than the oldest release this control plane supports, which are not checked at all rather than guessed about.

    A gateway keeps its registration for five minutes after it stops reporting. So shortly after a fleet upgrade, a refusal can still name a version the fleet has already left behind. That is the fail-closed direction and is deliberate. The refusal carries last_heartbeat_age_seconds, and retrying once the stale registration is reaped resolves it.

  • The control plane refuses to start against a database last run by a release older than 0.12.0. The API service records its own version during boot migration. It stops if the recorded version is below the supported floor, naming the recorded version and the way out. The offline installer's run.sh refuses the same upgrade before starting any container. Upgrade through a supported release first, or set AISIX_ALLOW_UNSUPPORTED_UPGRADE=1 after taking a backup. Both honor that variable, and the chart accepts it through api.extraEnvVars. A fresh install is not refused, and neither is a database last run by a build that predates this ledger.

  • An empty ignore_statuses list is now honored instead of the default. A model saved with an explicit empty list was accepted. It was then silently stored, read back and projected as [408, 429]. An operator who asked the background health probe to treat 408 and 429 as real failures kept a gateway that ignored them. Nothing on any surface said the input had been overridden. Empty now round-trips as empty on the stored value, the GET read-back and the document the gateway reads. Omitting the field is unchanged and still means [408, 429]. A model saved before this release keeps the substituted default until it is next saved.

  • The Anthropic billing-attribution line is dropped from the system prompt for non-Anthropic upstreams. Anthropic-native clients prepend an x-anthropic-billing-header line to the system prompt. One of its segments varies per request in some deployments. Because the line sits at the very front of the prompt, every other provider saw a different prefix on every turn and cached none of it. On POST /v1/messages and POST /v1/messages/count_tokens, the line is now removed before the upstream request is built, whenever the resolved target is not the first-party anthropic catalog provider. That covers the attribution block in an array-form system, and the first line of a string-form one. The rest of the prompt survives byte for byte, cache_control markers included, and messages is never touched. The gate is the catalog vendor id. A first-party key pointed at a regional or proxied Anthropic endpoint keeps the line. An OpenAI-chat bridge, a third-party Anthropic-compatible passthrough and the Bedrock, Vertex and Azure platform adapters all drop it. Nothing has to be reconfigured, and callers that do not send the line are unaffected.

New Features

  • Per-metric label selection. A new observability.metrics.labels block states the complete label list for each metric family. The environment variable AISIX_OBSERVABILITY__METRICS__LABELS carries the same thing as a JSON map. A metric family you leave alone keeps its existing defaults, so an upgraded gateway emits exactly what it emitted before. Selection is applied before observations accumulate. Removing a counter or histogram label therefore aggregates its observations rather than dropping them, and gauges keep their required identity labels. An unknown metric name, an unsupported label variable or a duplicate label fails startup, and a change takes effect on restart. Time-to-first-token and request latency can now carry provider-credential and caller attribution across native and bridged request paths. Both are available to select, and neither is on by default.

  • The Dashboard's gateway installation page offers the label selection. Operators can add supported labels, remove default ones and restore the defaults. The Docker, Compose, Helm and systemd snippets carry the complete selection as the startup environment variable. This is a gateway process setting, so it adds no resource field and changes no projection.

  • The raw OpenAI cache-write count is carried end to end. The cache_write_tokens value now survives from the upstream through the gateway's own response, at usage.prompt_tokens_details.cache_write_tokens. It also survives into the usage event, the Admin API usage-log list and export, and the Dashboard's log details. It is carried on native OpenAI, the protocol bridges, legacy non-streaming Completions and recognized passthrough usage. An explicit zero stays distinguishable from an absent value, and an omitted value remains absent. It is a raw count, kept separate from the additive Anthropic cache_creation_tokens, and it changes no token total and no billing. Existing usage rows stay null.

Improvements

  • A bulk configuration edit no longer stalls the gateway's own request path. Every accepted configuration write used to pay costs proportional to the whole configuration rather than to the write. That meant a deep copy of the entire snapshot. It also meant a re-parse and canonical re-serialization of every stored document, to recompute both hashes. Finally it bumped the single global version, which was the invalidation key of every derived cache. The next request on each worker thread then rebuilt the whole guardrail index synchronously, building one runtime and one HTTP client per enabled attachment, each reloading the CA store. The rebuild restarted without bound if the snapshot moved meanwhile. On that worker, /livez was queued behind the whole thing.

    Derived caches now key on the tables they read rather than on the global version. One guardrail instance is built per configured guardrail and shared across its attachments, and it is reused across rebuilds unless that row itself changed. The index build is single-flighted and the retry loop is gone. The snapshot clone is structural, digests are computed once per entry version, and queued configuration events are applied as one copy-on-write cycle. The aggregating /mcp endpoint caches each server's tool set per row, instead of re-walking every registered OpenAPI document per call.

    No configuration, wire shape or reported value changes. Both config_hash and source_hash stay byte-identical, the guardrails in force for a request are unchanged, and the rejected-row reporting is unchanged. One new info log line, guardrail index rebuilt, reports the row counts and how many instances were built versus reused.

  • Three metric families are now actually emitted. The aisix_redis_failures_total, aisix_otlp_fanout_failures_total and aisix_otlp_fanout_drops_total families had emit methods and no callers. Querying any of the three returned an empty result, whether the subsystem was healthy or failing constantly. Every failure they describe is fail-open by design, so these series are the only place the degradation shows. Redis failures are now counted for the rate-limit store, the response cache and the semantic cache. They are labeled by subsystem-qualified operation, such as ratelimit_acquire, cache_get and semantic_lookup, so an operator pointing all three at one Redis can tell which one is degraded. The OTLP fan-out counts one failure per failed export attempt, retries included. It counts dropped records by reason: queue_full, worker_stopped, retries_exhausted and permanent_error. A failed batch counts as many records as it carried.

  • A Dashboard page whose data fails to load now says so, instead of loading forever. A null list meant both "still loading" and "the load failed". A failed fetch therefore left an error card above a spinner that never resolved, and in a few places stated nothing at all. Every list page now settles: teams and team detail, budgets, A2A agents, admin tokens, MCP servers, provider keys, pricing, audit, notification channels and deliveries, and the three usage lists. The per-environment pages settle too: models, API keys, rate limits, cache policies, claim mappings, OIDC providers, passthrough routes, observability, logs and MCP policy. The organization settings cards and the team MCP entitlement card settle the same way. The settings and notifications pages now show the standard banner when the organization list itself fails, rather than rendering nothing. The flag is also raised again on a refresh, so a retry after a failure does not show the failure text for its whole in-flight window.

Fixes

  • An unreachable Redis no longer hangs every request that touches it. With ratelimit.backend set to redis, or with a cache policy on the Redis backend, a Redis that failed at runtime left such requests waiting for minutes instead of degrading. The aisix_redis_failures_total counter recorded the failure, but no request outcome followed. Every Redis round-trip and connection attempt is now bounded by a new timeout_secs setting on each redis: block, which defaults to 5 seconds. The existing fail-open path takes over inside that budget: rate limiting falls back to the per-process counters, and caches miss. A failure then short-circuits that subsystem for 30 seconds, so only about one probe request per 30 seconds pays the full budget during an outage. The breaker is shared per subsystem: the cache's exact and vector connections count as one, and the rate limiter is another. Once Redis recovers, the gateway notices within up to 30 seconds, and rate limiting counts per replica and caches miss until it does. A request whose upstream leg runs longer than 30 seconds can still pay a second budget on its cache write. Shared counting then resumes on its own, with no restart and no configuration change.

  • A write that the control plane could not commit no longer answers 2xx. The transaction was committed after the response had already gone out. A caller that read the status line and disconnected cancelled the context. The commit then failed and the insert was rolled back, while that caller kept a 201 for a row that no longer existed. A mutating request's response is now held until the commit succeeds. A 2xx the transaction did not keep is discarded and answered 500 with the standard error envelope, with the row rolled back as before. Only 2xx is withheld, so a 409, a 400 or a 499 is delivered exactly as before. A caller that stays connected sees no difference.

  • A streaming frame spread over several data: lines kept the upstream provider's model id. The event-stream format lets one frame spell its payload over several data: lines joined with a newline. The model-id rewrite read only the first line. Such a frame did not parse as JSON, and was forwarded untouched. The caller was answered with the provider's own model id instead of the alias they addressed. This affected the /v1/messages message_start frame and the /v1/responses snapshot frames. The rewrite now reads the whole joined payload. It writes each rewritten line back into the line it came from, so the framing survives byte for byte.

  • A failing token mint is no longer retried forever by the Dashboard. A failed mint round left no trace. With the auth plane unreachable, every open tab therefore started a fresh three-attempt round every 30 seconds, silently, driven by the budget banner's poll. A failed round is now remembered for a doubling backoff, starting at 30 seconds and capped at five minutes. Requests inside that window fail fast off the remembered error. The backoff resets on the next successful mint and on sign-out. A refresh that fails while the previous token is still valid keeps using it, as before.

  • Audit records for routing models no longer carry null field values. A routing model that left hash_on and fallback_on_statuses unset recorded them as JSON null in the audit event, a value the API neither accepts nor returns. The keys are now omitted when unset, matching the gateway-facing document. Existing rows are not rewritten.

API Changes

The structural comparison reports no operations added or removed, no request shape changed, and one new schema, DataPlaneIncompatibleError. The breaking check reports no errors. Its 36 warnings share one kind: a response enum value added.

EndpointChange
The 32 projecting write operations. POST and PATCH on models, API keys (including /rotate), guardrails and guardrail attachments. POST and PATCH on rate limits, cache policies (including /purge), claim mappings and passthrough routes. POST and PATCH on observability exporters, OIDC providers, MCP servers (including /approve) and MCP server submissions. POST and PATCH on provider keys and A2A agents. Plus PATCH /environments/{env_id}, PUT /environments/{env_id}/mcp_policy and PUT /model_pricing.Add a 422 response, DataPlaneIncompatibleError. It is returned when a registered gateway in the target environment could not load the configuration being saved. error.code is DP_INCOMPATIBLE, and error.message is a complete sentence safe to show an operator. error.dp_compat carries kind, dp_version, path, reason, affected_dp_count, affected_dp_nodes, affected_dp_versions and last_heartbeat_age_seconds. A caller must handle 422 on every write that reaches a gateway. The remedy is to upgrade the named gateways or remove the setting.
The 27 create and update operations above that return warnings. Plus GET on a single model, API key, guardrail, rate limit or cache policy. Plus GET on a single claim mapping, passthrough route, MCP server, provider key or A2A agent.The warnings[].code enum removes row_rejected and adds below_floor. The row_rejected code no longer occurs, because that outcome is the new 422. The below_floor code reports gateways older than the oldest release this control plane supports. Its field is empty, because it is about the gateway rather than one field. Automation that branches on row_rejected must be updated.
  • GET /environments/{env_id}/usage_events
  • GET /environments/{env_id}/usage_events/export
Usage-event representations add the optional cache_write_tokens. It is absent on rows recorded before this release, and on upstreams that report no cache write. An explicit 0 is distinguishable from absent. It is a raw count and is not additive to prompt tokens.

Three changes carry meaning the structural comparison cannot see.

  • CompatibilityWarning.min_dp_version keeps its type and its unreleased sentinel, but its meaning moved. It is now the oldest supported gateway version that reads the field, and for below_floor it is the oldest gateway version the control plane supports. It used to be the oldest gateway version that fully executes the field, derived from a hand-maintained table. The companion affected_dp_count moved likewise, from "registered gateways running a version older than min_dp_version" to "registered gateways this warning applies to". Same shape, different number in some fleets.
  • background_model_check.ignore_statuses accepted [] before this release and stored [408, 429]. It now stores, returns and projects the empty list. There is no shape change, but the value a caller reads back after sending [] is different. The field's description now states what an empty array means, and how that differs from omitting background_model_check on create versus on PATCH.
  • Every mutating operation under tenant scoping now answers 500 where it previously answered a 2xx whose row had been rolled back. This is reachable only when the caller disconnects mid-write, in which case it receives nothing at all. There is no shape change, and no change for a caller that stays connected.

Upgrade Notes

These notes apply when you upgrade from any release earlier than 1.2.0, including an upgrade that passes over it on the way to a later release.

  • Upgrade through 0.12.0 or later before upgrading to this release. The control plane API service and the offline installer both refuse a database last run by an older release. Setting AISIX_ALLOW_UNSUPPORTED_UPGRADE=1 overrides the refusal after you take a backup, and the chart accepts it through api.extraEnvVars. This control plane supports gateways 0.12.0 and newer.
  • Update automation that branches on the row_rejected warning code. That outcome is now a 422 DP_INCOMPATIBLE, and every projecting write can return it. The code below_floor is new in the same enum, and reports gateways older than the oldest supported release.
  • Re-save any model that was saved before this release with an explicit empty background_model_check.ignore_statuses. Such a model was stored with the substituted [408, 429], and it keeps that value until it is next saved. Saving it again stores the literal empty list.