Skip to main content

AI Gateway Changelog

Newest first. Entries marked ⚠ Behaviour change could alter what a working integration sees; everything else is additive.
This changelog was backfilled on 2026-08-25 from the commit history, and again on 2026-09-07 for the 2026-08-26 → 2026-09-03 window, so entries are reconstructed rather than written at the time. Dates are the date the change landed on the integration branch, not the date it reached production. A change under src/ai-gateway now has to touch this file or say in its commit that it does not need to — see the docs-truth guard in tests/coverage/.

2026-10-08 — ⚠ Behaviour change: the PII token-map view asks for a fresh second factor and is recorded

The admin API’s debug read of a conversation’s PII token map, GET /admin/ai-gateway-pii/token-map/{conversationId} (no dashboard page calls it), shows each original value’s first three characters and its length. That still narrows a card number, an SSN or an email a long way, so it is now a reveal, like listing a vault’s tokens: every request asks for a fresh second factor (an authenticator code, a passkey or a security key), writes a plaintext.reveal entry to the audit log naming the conversation before anything is decrypted, and counts against the per-person reveal budget. Without a fresh verification the request is refused with the same step-up refusal every dashboard reveal answers (the dashboard’s own codes, listed in the API changelog entry for 2026-10-06); verify through the step-up endpoints and repeat it. If the audit entry cannot be written, or the budget cannot be counted, it is refused rather than served. Each view that is served also writes an ai_gateway.pii_token_map_viewed entry naming the conversation and how many tokens it held (never an original value), and the excessive-reveals security alert counts it together with secret reveals, vault detokenizations and the other dashboard reveals. Who can use it does not change: an Owner or an Admin, as before. A conversation the gateway cannot address (a name with no ?principal=) is still a 400, answered before anything is asked for or recorded. Not affected: the data plane. Tokenizing a prompt and restoring values in the response for the caller who sent them, and the playground’s redaction view of the prompt you just typed, work exactly as before. See PII redaction.

2026-10-05 — ⚠ Behaviour change: responses through a static-egress route no longer carry X-Egress-* headers

When an agent’s route sends its calls through a static-egress server (a fixed outbound IP), the egress server adds four headers of its own to the provider’s response: X-Egress-Request-Time, X-Egress-Upstream-Time, X-Egress-Connect-Time and X-Egress-Server-IP. They describe KnoxCall’s egress infrastructure, not your provider, and the last one is the egress server’s own address. Until now they were passed back to you on every /v1/ai/{slug}/… response that went through such a route. They are no longer returned. Any response header in the X-Egress- namespace is now withheld, including one your provider sends itself, on every plane that returns a provider’s response: the AI Gateway, the route proxy and /v1/proxy. The provider’s own status, body and every other header are unchanged. What to do. If your integration read these headers, it will no longer find them. The AI Gateway does not expose these egress timings anywhere else today. Calls on routes that do not use a static-egress server never had them and are unaffected.

2026-10-03 — The gateway’s own side calls are bounded as a whole, not only when they go quiet

Four calls the gateway makes on your behalf, beside the provider call itself, already had a timeout: your guardrail webhook, your Presidio analyzer, the embeddings lookup behind the semantic cache, and OIDC discovery / JWKS for workload identity. That timeout measured silence. An endpoint that kept sending a byte at a time never went silent, so it was never cut. Each of those calls now also ends when its timeout has passed in total, whatever the endpoint is doing. The numbers are the existing timeouts: the guardrail webhook’s own setting on the agent (100 ms to 10 s, default 2 s), the Presidio analyzer’s timeout_ms (default 1.5 s), 4 s for the embeddings lookup and 10 s for discovery and JWKS. The outcome is the one a timeout already had:
  • guardrail webhook: your configured failure action applies (fail_open or fail_closed), with the reason “did not answer within N ms”;
  • Presidio: the scan is skipped for that request, counted by the circuit breaker, and the built-in detectors still run;
  • embeddings: a semantic-cache miss; your completion is sent to the provider as usual;
  • discovery / JWKS: the token exchange fails as it does for an unreachable issuer.
What to do. Nothing. If your guardrail webhook or your Presidio analyzer routinely takes longer in total than the timeout you set for it, raise that timeout: it is now the budget for the whole call. The provider call itself (/v1/ai/{slug}/…) is unchanged by this entry.

2026-10-03 — ⚠ Behaviour change: a provider that hangs up part-way through a response fails the call instead of leaving it open

When a provider sent its response headers and then closed the connection before the body was complete — a body shorter than its Content-Length, or a chunked body with no final chunk — a non-streaming /v1/ai/{slug}/… call was never answered. The gateway kept waiting for a body that could no longer arrive, so the caller saw nothing until its own timeout. That call now fails as soon as the connection closes, and is handled like any other failed upstream attempt:
  • if the agent has a fallback route, or its retry policy treats a transport error as retryable, the gateway moves on to the next attempt;
  • otherwise the caller gets 502 upstream_error with the request id.
Nothing changes for a provider that answers in full, including one whose body is ended by the connection closing (no Content-Length, not chunked) — that is a complete response and is returned as before. Streaming calls already reported a dropped connection and are unchanged. What to do. Nothing, unless you were relying on your own client timeout to detect this: you will now get a 502 (or a successful fallback) sooner. Treat it as retryable.

2026-10-02 — ⚠ Behaviour change: headers a proxy in front of KnoxCall adds are no longer forwarded to your provider

A request to /v1/ai/... reaches KnoxCall through an edge that adds headers describing the connection to us. Some of those were being passed on to your model provider, along with two of KnoxCall’s own. They are now withheld, each matched by its exact name: X-KnoxCall-Env, X-KnoxCall-Origin, X-Real-IP, True-Client-IP, CF-Worker, CF-EW-Via, X-Client-Cert-Verify, X-Client-Cert-Thumbprint, X-Client-Cert-Subject, Cf-Access-Jwt-Assertion, and the other request headers Cloudflare adds. The API changelog names every one, and Request headers KnoxCall does not forward is the complete list. What to check. If your client sends one of these names itself and your provider or an endpoint of your own reads it, it no longer arrives, and nothing in the response says so. To send one, add it to the injected headers of the route behind the agent: an injected header is sent with the value the route configures. A header that only resembles one of these is unaffected. cf-aig-authorization and the other headers you send to a Cloudflare AI Gateway upstream are still forwarded.

2026-09-30 — Latency figures are measured on a clock that cannot go backwards

KnoxCall’s own latency, the upstream timing and time-to-first-byte — in the X-Knox-* response block, Server-Timing, and the request log — were computed by subtracting two wall-clock readings. When the host’s clock was corrected mid-request (an NTP step), a figure could come out wrong, including negative, and the upstream time could exceed the total it is part of. They are now measured on a monotonic clock, so a clock correction cannot bend them and the upstream time is never larger than the total. The one visible difference on a normal request: figures are rounded up to the whole millisecond rather than truncated, so a value may read 1 ms higher than before. The timestamps on the request timeline are unchanged and remain wall-clock.

2026-09-29 — ⚠ Behaviour change: a KnoxCall dependency outage answers 503 dependency_unavailable with Retry-After, not a 500 or 502

When KnoxCall cannot reach one of its own dependencies in time — its control-plane database (a connection pool that could not hand out a connection, a dropped connection, a server that refused the statement) or its rate-limit store — the request is now refused with a typed, retryable answer instead of a generic server error. Affected endpoints and what changes. The response. The AI plane’s usual envelope, plus a Retry-After header — whole seconds, 5 today (one pool checkout interval):
The description names the coarse class — the control plane or the rate-limit store — and never the component, the reason or a driver code. What could break you. If your integration treated a 500/502 from the gateway as “KnoxCall is broken, do not retry”, the same outage now arrives as a 503 that says do retry, after Retry-After. Nothing reached your provider, so resending is safe and costs no provider tokens. It is not a credential problem — do not re-mint a phantom token because of it — and it is not your budget or rate limit. The official SDKs honour Retry-After on a 503 as they do on a 429 from their next release. Every other gateway 503 (firewall_policy_unavailable, pii_policy_unavailable, …) is unchanged. The same 503 dependency_unavailable is answered on the route data plane, on /v1/proxy, and on the /v1 control plane — see Response metadata and Errors.

2026-09-21 — ⚠ Behaviour change: every AI-gateway response carries a labelled X-Knox-* block, and upstreams can no longer forge one

Three things change on /v1/ai/{slug}, on all three response paths — cache hit, streamed and buffered. New response headers. A KnoxCall-authored block, separate from whatever your upstream said: a request id, which side answered, which plane served the call, KnoxCall’s own latency and what that number measured, and — when a real upstream answered — that upstream’s status and its timing. The exact header names, their values and how to branch on them are in Response metadata, which is the one place the full block is specified so this page cannot drift from it. A Server-Timing entry is appended (never overwritten — it is a shared list and your provider’s own timings stay). Why it exists. Three situations were indistinguishable from the response alone: your upstream answered 429, KnoxCall answered 429, or KnoxCall never reached your upstream at all. A wrapped provider SDK gets this actively wrong — stripe-node reads a KnoxCall 429 as a Stripe rate limit and retries into the limit that is refusing it. The load-bearing signal is X-Knox-Upstream-Status: present if and only if a real upstream response was received. X-Knox-Origin is the same fact spelled for a human reading curl -i, and is derived rather than passed, so a disagreeing pair is unrepresentable. What could break you. If you read the x-knox- response namespace today, you were reading values an upstream could set. Before this change NEVER_RETURNED_UPSTREAM_HEADERS was hop-by-hop plus cookie-setting only, so the whole namespace relayed verbatim — a measured example returned attacker-chosen X-Knox-Origin, X-Knox-Error, X-Knox-Upstream-Status and X-Knox-Request-Id to the caller unchanged. On /v1/proxy the upstream’s values also overwrote KnoxCall’s, including the request id you quote to support. The namespace is now refused at the relay chokepoint by prefix, so a field added to the block tomorrow is not spoofable during the window before someone remembers to list it. If your integration depended on an upstream being able to set x-knox-*, it will stop working — deliberately. Nothing else in the response body or status changes. Latency-Phase is labelled, not guessed. complete on a buffered response, first-byte on a streamed one, because headers must flush before a streamed body and the number cannot mean the same thing in both. The upstream/overhead split is omitted rather than estimated where it cannot be measured honestly.

2026-09-20 — DPoP responses now carry a server nonce (RFC 9449 §8)

Every gateway response to a request that presents a DPoP header now carries a DPoP-Nonce header, and a proof that chooses to include a nonce claim must include one this platform issued in the last two minutes. Nothing you send today changes. A proof with no nonce claim — which is every proof any current SDK or tool produces — is accepted exactly as before. The nonce is advertised so that clients can start echoing it; the SDKs learn to do that in a later release. What it is for. A DPoP proof is signed entirely from values the client picks, so proofs can be generated in advance and used from anywhere inside the iat window. The nonce is the one value the server picks: echoing it proves the holder of the key was talking to us in the last couple of minutes, not at some earlier point on some other machine. If you do send a nonce and it is stale, the request is refused with HTTP 401, WWW-Authenticate: DPoP error="use_dpop_nonce", and the refusal itself carries a fresh DPoP-Nonce — re-sign once with that value (use a new jti) and retry. The stale-nonce refusal deliberately does not spend your proof’s jti, so the retry is not refused a second time for a different reason. Applies to /v1/ai/* and /mcp (the phantom-token authenticators). The gateway’s own federated mint at /v1/oauth/token still issues bearer tokens and reads no DPoP header — that is unchanged and tracked separately.

2026-09-16 — ⚠ Behaviour change: the card-number refusal now covers MCP tool calls and every SDK request

The 2026-09-14 entry below closed the raw-card gap on the route-executor dispatch and said plainly that two paths on this plane were still open. Both are now behind the same refusal. MCP tool calls. A tools/call whose arguments carry a payment card number in the clear is refused before the upstream is dialled, unless the MCP server’s host is on the workspace’s raw-card egress allowlist. Arguments are scanned at any depth, including object keys. The caller sees a JSON-RPC error with code -32000 — the code this gateway already uses for a governance refusal — carrying the same message the HTTP surfaces return, naming the destination and the two remedies and containing no digit of the card. It is not -32603: this is our refusal, not your MCP server failing, and an alert on upstream failures must not count it. Every other request an SDK makes. The pinned fetch the gateway hands to an SDK now runs the same scan on each request body, per redirect hop, under the surface name ai_gateway_sdk_fetch. That covers the MCP transport’s own traffic and anything else built on the wrapper. What is unchanged. Detection is the same — 13–19 digits (spaces and hyphens tolerated) that pass the Luhn checksum and carry a recognised issuer prefix — so an ordinary 16-digit reference number is unaffected, and a pan-vault token is excluded by lookup rather than by shape. The allowlist, the audit entry (egress.raw_pan_refused) and the month-to-date counter are the same ones the 2026-09-14 change introduced. A refused tool call writes exactly one audit entry and one counter tick. The remedy is the same. Take the number out of the tool arguments, or have an owner or admin add the destination with PUT /admin/pan-egress/allowed-hosts.

2026-09-16 — A private Kubernetes cluster can now federate: three JWKS sources

Until now KnoxCall could only verify an issuer whose signing keys it could fetch over public HTTPS via OIDC discovery. That excluded the most common self-managed setup: a Kubernetes cluster whose issuer is https://kubernetes.default.svc and is reachable only from inside the cluster. An issuer’s keys can now come from one of three sources, chosen per issuer: discovery remains the default and nothing about an existing issuer changes. This is additive: if you have never configured a JWKS source, you have one, and it behaves exactly as before. Set it in the Dashboard under Settings → Workload Identity, or with POST /admin/federation/issuers/jwks-source. Changing it requires a recent step-up verification made with a second factor for the oauth.client action (an authenticator-app code, a passkey or a security key) — pasting keys is telling KnoxCall which signer to believe for your tenant, so it carries the same bar as creating a binding. Switching an issuer back to discovery is gated too. Corrected 2026-10-04: this entry first said an emailed code would do. This gate has never accepted one. Security keys can be used from 2026-10-04. The document is parsed and rejected when you save it if it cannot produce a usable key, rather than letting you discover the problem on your first exchange.
Inline keys do not refresh. When your cluster rotates its service-account signing keys, exchanges stop verifying until you paste the new document. KnoxCall names that cause specifically rather than reporting a generic signature failure, and the binding’s status in the Dashboard shows it — but nothing rotates them for you. If your cluster rotates on a schedule, put this on the same schedule.
Keys are held per tenant, and an assertion is only ever verified against the keys of the tenant whose binding it matched. https://kubernetes.default.svc is the default issuer string for every cluster, so a different customer registering the same issuer string cannot mint into your tenant, and you cannot mint into theirs. Full guide: Kubernetes workload identity.

2026-09-16 — ⚠ Behaviour change: token-exchange refusals no longer say WHY

POST /v1/oauth/token and POST /oauth/token now give one answer to every refusal that depends on your workspace’s configuration:
Previously they distinguished “no binding is registered for this issuer” from “a binding exists but your claims did not match” from “the signature did not verify”. Those are different facts about a workspace, and the exchanges answer unauthenticated callers — so anyone who could POST could learn whether some KnoxCall customer federates from a given private issuer. Harmless for a shared issuer like GitHub’s; not ours to publish for a company’s own Okta or Entra tenant. Refusals about YOUR OWN TOKEN are unchanged and still specific — a malformed JWS, an unsupported alg, a missing or expired exp, the wrong subject_token_type. Those say nothing about any workspace, and blurring them would have removed the diagnostic for the commonest failure while closing nothing. Where the reason went. Two places, both of which authenticate you and scope to your own bindings:
  • Settings → Workload Identity → the binding’s status, and the new GET /admin/federation/auth-history, which lists your refused exchanges with the real reason.
  • The connect-workload wizard’s diagnose step, which runs a real token through the exchange’s own verifier and tells you exactly which check failed.
This ordering was deliberate: the history shipped first, because making the error opaque before it existed would have left tenants with no diagnostic at all.

2026-09-16 — ⚠ Behaviour change: an assertion with no kid is now refused

POST /v1/oauth/token and its management twin POST /oauth/token require the subject token’s JWS header to carry a kid naming the issuer’s signing key. What changed. Previously a kid-less assertion was accepted when the issuer published exactly one key, and refused only when it published more than one. So the rule’s strength depended on the issuer’s current key count — which the issuer changes without telling anyone. An IdP that normally publishes two keys (making kid effectively mandatory) publishes one for the length of a rotation, and in that window a kid-less token became acceptable. It is now refused unconditionally. Who this can break: an issuer that omits kid and publishes a single key. Every mainstream IdP sets it by default; if yours does not, it is usually a signing configuration option rather than a limitation. The refusal is invalid_grant with subject_token has no kid; KnoxCall requires the JWS header to name the signing key, and the binding’s diagnose view explains it in full. More signing algorithms are accepted — additive, nothing previously working stops working. The set is now the full asymmetric range: RS256/384/512, PS256/384/512, ES256/384/512 and EdDSA, where before it was RS256, ES256 and EdDSA only. Okta, Entra and some SPIRE profiles sign with algorithms we used to refuse. HMAC (HS*) and none remain refused and always will be — the only key material we hold for your issuer is its public JWKS, so accepting an HMAC would mean verifying a token signed with the key we ourselves published. Attribute conditions may now end in * to match by prefix — additive — and a prefix too broad to identify a workload (repo:*, or a bare *) is refused both when you create the binding and when a token is exchanged against it. A trailing wildcard only: no infix, no leading.

2026-09-16 — ⚠ Behaviour change: token-exchange refusal wording changed

POST /v1/oauth/token and its management twin POST /oauth/token now run one shared verification pipeline instead of two near-identical copies. Nothing about which requests are accepted or refused changed, and the RFC 6749 error codes are identical — invalid_grant, invalid_target, invalid_request, unsupported_grant_type. What did change is the human-readable error_description on the gateway exchange, which now matches the management one word for word: If your CI matches on error_description text, update it — or better, switch to the error code, which is stable and is what RFC 6749 intends you to branch on. We are flagging a wording change because a string match is a real integration even though it is a fragile one. Why do it at all: the two exchanges had drifted into separate implementations of the same checks, which is how a control ends up correct on one path and simply absent on the other. They are now one pipeline with two mint adapters, and the differences that remain — the audience each mints for, the clock-skew tolerances — are explicit, documented fields rather than an accident of two files.

2026-09-14 — ⚠ Behaviour change: a clear card number in a prompt is refused

A request whose body carries a payment card number in the clear — in the prompt, in messages, in tool arguments, or in any other field — is no longer forwarded to the model provider, unless the provider’s host is on the workspace’s raw-card egress allowlist. The allowlist is empty for every workspace by default, so this refuses on first contact rather than after a configuration change. What the caller sees. 403 with code raw_pan_egress_refused. The message names the destination and the two remedies and contains no digit of the card. The request is refused before any bytes reach the provider; a failover candidate is not tried, because sending the same card to a different provider is not a recovery. Each refusal writes an egress.raw_pan_refused audit entry carrying the destination host and a masked sample (last four only), and increments a month-to-date counter readable at GET /admin/pan-egress/allowed-hosts. The remedy. Either take the number out of the prompt, or — if a provider genuinely must receive it — have an owner or admin add that host with PUT /admin/pan-egress/allowed-hosts. Adding a host requires a fresh second factor; removing one does not. PII tokenization is unaffected. The surrogate that pii_request_mode mints for a card is format-preserving, so it is Luhn-valid and looks exactly like a card; it is exempted by construction (a value present after tokenization and absent before it is, by definition, something the tokenizer minted). An agent with pii_request_mode: 'tokenize' therefore keeps working unchanged. What is newly refused is a card the tokenizer does not reach: a non-prompt field, or an agent with pii_request_mode: 'off' — which is the default on every system-role agent. What this does NOT cover yet. Two egress paths on this plane are not behind the refusal: MCP tool calls (tools/call arguments to a tenant-chosen MCP upstream) and the SDK-level fetch wrapper every gateway SDK is handed. The wrapper is the real gateway chokepoint and moving the control down to it is a follow-up lane; until then, a card number sent through either path still reaches the upstream. Detection is 13–19 digits (spaces and hyphens tolerated) that pass the Luhn checksum and carry a recognised issuer prefix, so an ordinary 16-digit reference number is not affected.
Closed on 2026-09-16 — see the entry above. This paragraph is kept as written because the gap was real for two days, and a changelog that quietly edits away what it admitted is worth less than one that says when it was fixed.

2026-09-13 — Workload-federation refusals now appear in your denial events

POST /v1/oauth/token (and its management twin POST /oauth/token) refused on their own and wrote nothing to the audit chain. A run of expired, replayed or wrongly-scoped workload tokens against your gateway produced no record: nothing in an audit export, nothing for an alert to group on. The token planes stopped being silent like that in AIGW-121; the exchanges were the surface that fix did not reach. What changes. A refused exchange now writes the same ai_gateway.request_denied entry every other refusal on this platform writes, carrying plane: "federation" and the exact outcome in detail.code. Reasons reuse the existing grouped set where they mean the same thing — a replayed single-use assertion is auth_oneshot_replay, a scope the binding does not allow is auth_scope_denied — plus two new ones: federation_binding_unusable (the binding matched but its own configuration cannot mint — a revoked backing key, or a subject_claim that resolved to nobody) and federation_resource_denied (an RFC 8707 resource the binding cannot be confined to). Only refusals that reached one of YOUR bindings are recorded, and that is deliberate rather than incidental. Before a binding matches, the request has proven nothing about which tenant it belongs to — and issuers like GitHub’s are shared by every repository in the world — so attributing those attempts to a tenant would publish one customer’s workload traffic in another’s audit trail. A bad signature, an unknown issuer or a rule that matched nothing therefore still writes no row. Successful exchanges are not denials and write none; a KnoxCall-side error is recorded as our failure, not as a refusal of yours. Nothing to change on your side — existing bindings and exchanges behave exactly as before. If you export audit events or alert on ai_gateway.request_denied, expect federation refusals to start appearing in that stream.

2026-09-12 — Fixed: the Presidio sidecar image could not be built

Self-hosted only. docker/presidio-sidecar/Dockerfile copied a recognizers/ directory that was never in the repo, so the build command in its own header failed at that line — for anyone who tried it, since PR-12. The copy was never needed: custom recognizers are pushed to the running sidecar over HTTP (POST /reload-custom-recognizers) and held in memory, so the image does not carry them. The image is now built and vulnerability-scanned in CI alongside the agent image, so it cannot quietly stop building again.

2026-09-12 — ⚠ Behaviour change: only a request that asks the provider to stream is streamed — and only where it can be redacted

Three ways a response could reach you through the streaming path without being read by the mid-stream redactor, each now closed:
  • An Accept: text/event-stream header alone put a request on the streaming path. With stream: false the provider answered plain JSON, and that JSON went to you with no PII redaction, no detokenization and no canary scan. The header no longer counts: such a request is served on the buffered path and fully redacted. Send stream: true to stream.
  • A streamed call to the OpenAI Responses API or legacy completions (/v1/responses, /v1/completions) streamed an event family the redactor does not read, so it passed through unredacted. It is now refused with 400 streaming_redaction_unsupported; the same call without stream: true is served and redacted.
  • Gemini streaming (:streamGenerateContent) was not recognised as streaming at all, and the ?alt=sse it needs was not forwarded. It is now streamed through the Gemini redactor, with alt=sse supplied by the gateway.
Two refusals are new. An agent with streaming_enabled: false now refuses a request that asks to stream (400 streaming_disabled) instead of receiving the provider’s stream on the buffered path, where a value split across two frames could not be matched. And a streamed request the provider answers with something other than an event stream is refused (502 upstream_stream_shape_unexpected) rather than forwarded unread. See Streaming.

2026-09-12 — ⚠ Behaviour change: a Presidio call that fails on one request is no longer treated as a clean scan

An analyzer that resolved fine and then failed on the call — a timeout, a 5xx — returned “no matches”, which is what “found nothing” looks like too. The request was scanned by the in-process tiers alone and recorded as a clean scan. Those tiers do not detect names, street addresses or medical record numbers, which is most of what a policy with an analyzer attached is for. A failed call is now reported, and gets the same two postures a resolve-time failure has had since 2026-09-07:
  • With a PII policy attached — 503 pii_policy_degraded, and the prompt is not forwarded. This is retryable: it says this call lost NER coverage, not that the analyzer is down.
  • With no policy attached — served, with X-Knox-AI-Pii-Degraded: presidio_call_failed and a detector_unavailable row in the PII events ledger.
What you should do. If you attach a policy that uses Presidio, make sure the sidecar is sized for your traffic and retry a 503 pii_policy_degraded: before this change those requests succeeded with part of your policy silently not applied. See PII redaction.

2026-09-12 — ⚠ Behaviour change: an address that keeps failing authentication is refused at the door

/v1/ai and /v1/mcp now refuse an address that has failed authentication 60 times in 5 minutes, with 429 rate_limit_exceeded and a Retry-After, before its token is checked. Only refused tokens count — missing, unknown, expired, revoked or out of scope — so successful traffic from a shared address is never affected. The two planes share one allowance per address. Until now nothing stopped an unauthenticated caller from making either plane look up a token and write a denial record on every request, and /v1/mcp also parsed up to 4 MB of request body before checking the token at all. It now checks the token first. A request with both a bad token and a malformed body therefore gets 401, where it used to get 400. What you should do. Nothing, unless a service of yours is retrying with a revoked or expired token: after 60 such failures from one address, that address is refused on both planes until the window drains. Fix the credential and wait out the Retry-After. See Limits.

2026-09-12 — ⚠ Behaviour change: the system-prompt canary no longer replaces a block-array system prompt, or adds a field other APIs reject

On an agent whose firewall policy has canary tokens enabled — the default — the gateway adds a canary to the system prompt of each request. It only knew two request shapes, and every other shape was handled as one of them:
  • Anthropic system as a block array — the form prompt caching uses — was replaced by the canary. The request succeeded, and the model never saw the system prompt you sent.
  • Gemini, OpenAI Responses, Bedrock Converse, Cohere v1, embeddings and legacy completions requests had a messages field added that those APIs do not define. Gemini and the OpenAI Responses API reject a request carrying an unknown field, so those requests failed at the provider.
  • An agent on an Anthropic upstream whose model name the gateway did not recognise was sent a role: "system" message, which the Messages API refuses.
What changes. The canary now goes where each API keeps its system prompt, alongside yours — see Where the canary is placed. Requests that have no system prompt (legacy completions, embeddings, batch envelopes) are forwarded without a canary rather than with an invented field. What you should do. If an agent appeared to ignore its system prompt, or Gemini or Responses calls through the gateway failed with an unknown-field error, this was the cause, and nothing on your side needs to change.

2026-09-11 — ⚠ Behaviour change: streamed tool-call arguments are now scanned for PII, and arrive when the call finishes

A tool call’s arguments are model output, and they routinely carry values the model lifted from the prompt. On a streamed response they were not scanned: OpenAI tool_calls[].function.arguments (and the older function_call), Anthropic input_json_delta and Gemini functionCall.args went to your client exactly as the model wrote them — while the same response’s text was redacted, and the same arguments were scanned on a non-streamed response. What changes. Arguments are now scanned on the streaming path too — every key and value, including numbers — and under pii_response_mode: "detokenize" any KC_* placeholder in them is restored, so the tool you run receives your original values instead of a placeholder. Because arguments stream as JSON fragments that cannot be rewritten one at a time, the gateway holds each call’s arguments until the call finishes and releases them as one fragment:
  • The call’s first chunk (id, function name) still arrives when the call starts; the arguments arrive all at once when the call finishes. A UI that renders arguments character by character will see them appear in one step.
  • Arguments with nothing detected are byte-for-byte unchanged. When something is redacted, the document is re-serialised with compact whitespace.
  • A system-prompt canary in the arguments is caught before any of them leave.
  • A single call over 4,194,304 characters ends the stream with an event: error frame (tool_arguments_too_large) instead of being released unscanned.
monitor streaming mode is unchanged: arguments pass through and are scanned for the record. See Streamed tool calls.

2026-09-11 — Fixed: streamed answers were cut short in the official OpenAI and Anthropic SDKs

On an agent with response-side PII redaction (the default) streaming in the holdback mode (the default), the last characters of each streamed answer — up to the hold-back window, 96 by default — were sent after the provider’s own end-of-stream marker: after data: [DONE] on OpenAI-shaped providers, and after message_stop with no event: line on Anthropic. The official SDKs stop reading at those markers, so they never delivered those characters. The answer came back shorter than the model wrote it, with no error on either side. A client reading raw SSE to the end of the connection received the whole text. What changes. The held-back characters are now released before the provider’s end marker for the block they belong to — before content_block_stop, before the finish_reason chunk, inside Gemini’s finishReason chunk — and every frame the gateway writes on an Anthropic stream carries its event: name. Nothing to do on your side; if streamed answers through the gateway ended mid-sentence, this was the cause.

2026-09-11 — ⚠ Behaviour change: a streamed response that leaks your system-prompt canary is now stopped under a block policy

Until now a block firewall policy refused a canary leak only on a buffered response (400 canary_leak). On a streamed response the leak was recorded and the canary itself was spliced out, but the stream carried on to the end — so the rest of an answer written by a model that had just revealed your system prompt still reached the caller. What changes. Under a block policy a streamed response now stops at the frame that carries the leak and ends with an event: error frame whose code is canary_leak. It cannot be a 400: the 200 and the first frames have already gone out. Nothing after the leak is delivered, and anything sent before it contained no canary. The error frame reports a matched_count, never the token. Who notices. Only agents that stream and have a block firewall policy attached. A client reading the stream sees an event: error frame and an early end instead of a complete answer — handle it the way you handle any mid-stream provider error. Under warn, nothing changes. One exception. In the monitor streaming mode the response passes through unredacted and is inspected only once it has finished, so a leaked canary reaches the caller before any refusal can fire. Do not rely on a block policy for an agent in monitor mode. See Firewall and Errors.

2026-09-11 — A system-prompt canary carried through an MCP tool is now caught

The canary KnoxCall places in each agent’s system prompt was only watched for on /v1/ai. An agent that extracted it and then moved it through an MCP tool — in the call’s arguments, out to an upstream, or back in a tool result — passed through /v1/mcp unnoticed, because that plane scanned tool traffic for everything except our own marker. What changes. /v1/mcp now scans tool-call arguments and tool results for your tenant’s canaries. A match is replaced with [REDACTED:CANARY] before the arguments leave for the upstream and before the result reaches the caller, and raises the same critical ai_gateway.canary_leak audit entry as the chat plane — recorded against the MCP server rather than an agent, one per call, carrying a count of matches and never the token. The call itself is still served: this plane redacts and alerts rather than refusing. Who notices. Nobody in normal operation — a canary only appears in tool traffic if a system prompt was extracted. Scanning runs wherever the server’s PII inspection is on, which is the default. If the canaries cannot be loaded, the call is served without canary scanning and the failure is logged. See MCP gateway.

2026-09-10 — ⚠ Behaviour change: the prompt-inspecting controls were reading an empty body, and now read the real one

If you rely on any AI Gateway control that inspects the prompt, read this one. Between 2026-08-26 and 2026-09-10, the /v1/ai data plane forwarded an empty request body to the provider’s controls. The request itself reached the model normally, so nothing looked broken — but every control that makes its decision by looking at the prompt was handed two bytes ("") instead of your content. What was affected, for that window:
  • the model allowlist / denylist
  • the tool allowlist
  • the prompt-injection firewall
  • the guardrail webhook
  • the PII tokenizer (request side)
Each of them evaluated an empty prompt, so each of them passed everything. A policy that should have denied a request allowed it, and a prompt that should have been tokenized was not. POST /v1/mcp was affected differently and more visibly: it answered -32600 to every well-formed call. What changes for you now that it is fixed. The controls now see your actual prompt, which is what they were always configured to do — so a request that was allowed during that window may now be denied, and PII that passed through untokenized will now be tokenized. Nothing about your configuration changed; the controls simply begin enforcing what they already said. If you tuned a policy by observing its behaviour during that window, re-check it: you were tuning against a control that was not reading anything. What you should do. If you depend on the injection firewall, the guardrail webhook or request-side PII tokenization for a compliance claim, treat 2026-08-26 → 2026-09-10 as a window in which those controls were not enforcing, and review what passed through in that period. The denial audit trail for the window records decisions made against the empty body, not against your prompts. This is announced rather than buried because it is the shape this changelog exists for: nothing errored, nothing looked wrong, and the controls were open.

2026-09-07 → 2026-09-08 — the round, in one place

A single round of work landed everything between this entry and the 2026-09-03 entries, across eleven parallel workstreams. The individual entries below say exactly what each change does; this one exists so you can see the shape of the round without reading all of them, and so you can tell at a glance whether it touches you. If you have an integration today, these are the four that can change what you see. Each is written up in full below.
  • PII is now two explicit settings, not one. pii_request_mode decides whether a prompt is tokenized on the way out; pii_response_mode decides whether the reply is restored on the way back. They used to be one flag, and it was the response-side one — so an agent with a PII policy attached and detokenization off sent the prompt upstream untouched. Existing agents were migrated to the behaviour they had, except that request-side tokenization no longer depends on the response-side setting; a tenant agent with a policy attached backfills to tokenize.
  • Three requests that used to be served are now refused, because serving them meant serving unredacted text: a streaming request on a provider with no mid-stream rewriter is 400 streaming_redaction_unsupported, a body the gateway cannot parse is 400 pii_unscannable_body, and a request whose policy needs an analyzer that is down is 503 pii_policy_degraded. With no policy attached, the third degrades to the built-in detectors and says so in a response header instead.
  • A confirmed canary echo under a block firewall policy is refused (400 canary_leak) rather than served with a warn verdict recorded. And a model your pricebook cannot price is refused under a block budget (403 budget_unpriced_model) rather than metering $0 and slipping the cap.
  • One error envelope on both data planes, {error, error_description, code} with error and code always equal, and Retry-After on the 429s. The /v1 management API and the /v1/mcp JSON-RPC error object are unchanged.
The MCP plane caught up with the model plane. Tool calls are now preflighted against the gateway’s USD caps, offered to the same external guardrail hook (on the arguments before dispatch and the result before return), and scanned with your own PII recognizers when you attach a policy to the server. MCP servers gained a Test/Live partition — a kp_test_ token reaches only Test servers, and a mode it cannot prove is refused — so the same slug can now exist in both spaces. A parity guard whose input is the model plane’s own list of refusals keeps the two planes from drifting again. Everything else is additive: the agent detail page edits 34 of the 36 settings the API accepts instead of 6, on one dirty-gated Save; new Connect, Requests, Cache, Federation and Usage surfaces; PII policies and recognizers on /v1 with the full SDK, Postman, OpenAPI and CLI fan-out; a real knoxcall ai command group in all five SDK CLIs; every /v1/ai-gateway operation now rendered from the spec rather than hand-copied; and prices that tell you whether they are a vendor list price or our estimate.

2026-09-08

The cache hit rate on the Agent’s cache panel was not a hit rate

If you enabled response caching and read a hit rate off the Agent’s cache panel, that number was wrong, and for an Agent in cache_mode: "exact" it was always 100%. It is right now, and the panel gained the counters that make it right. cache_outcome on the usage ledger declared four values — hit_exact, hit_semantic, miss, bypass — and the gateway only ever wrote the two hits. The one thing writing miss was the embedding call the semantic cache makes on the way to answering a request, which is not a request and which fires twice per missed one. So the panel divided hits by hits (plus, for a semantic Agent, some embedding calls), and an Agent that never embeds reported a perfect cache from its first hit onward. What you get instead:
  • A real hit rate. Every request an Agent with caching on now records what the cache did: served it, was consulted and had nothing, or was never eligible. Ineligible requests — a streamed response, a response-direction guardrail hook, a customer-managed encryption key, a caller the gateway cannot attribute to a token — are counted as bypassed, beside the rate and never inside it. Requests on an Agent with caching off record nothing and stay out of the denominator entirely.
  • A semantic hit rate with its own denominator, plus a count of the requests that asked for approximate matching and did not get it, with the reason. The commonest reason is worth knowing before you turn the feature on: pii_request_mode and pii_response_mode both default to on, and either one keeps approximate matching off, so an Agent created with the defaults and switched to cache_mode: "semantic" silently gets the exact cache and nothing else. The panel used to guess at this from “every hit was an exact one”, which says nothing when there are no hits; it now counts it.
  • Historical miss rows are corrected to “no cache outcome” by migration 20261118. They were all embedding rows. Counts for windows spanning 2026-09-08 therefore go down, and up in accuracy.
The cache itself is unchanged — same keys, same scoping, same behaviour. Only what is recorded about it changed. If you want a number before you trust one, the measurement, its fixture and its caveats are written up in docs/internal/ai-gateway-semantic-cache-measurement-2026-09.md.

A human can finally get an MCP token — device flow + connect packs

POST /v1/oauth/device_code, and the RFC 8628 device_code grant on POST /v1/oauth/token. Until now the only way to obtain a resource-bound tool token was the OIDC workload-federation exchange: present an id_token from a bound CI issuer. That is the right door for a build job and no door at all for a person on Claude Code, Claude Desktop or Cursor — which is why there were no connect instructions to give them. The flow: ask for a code, approve it in the console at /ai-gateway/device, poll for the token. It mints through the same exchange as the federation path, so a device token is indistinguishable from a federated one at the data plane — audience-bound to one MCP server, bound to one person, and confined to the mode you approved it in.
  • Codes last 15 minutes and are single-use; a device_code mints once, and a second poll with it is refused as a replay.
  • Polling faster than the returned interval is answered slow_down, not with a token.
  • The approval screen shows the name the client gave itself and says plainly that it is not verified — approve only a code you generated yourself.
  • Everything the token can do is fixed by the approver, from their session: the workspace, the Live/Test mode, the MCP server and the person it names. The device supplies a server slug and a name for itself and nothing else it says is trusted.
New page: Connect Claude Code, Claude Desktop and Cursor — the exact config block each client expects, and the flow you run. You copy the block into place yourself; there is no installer and no extension.

Delegated OAuth for MCP upstreams

A third upstream-auth mode, auth.mode: "delegated". The person consents once through the upstream’s own OAuth; KnoxCall envelope-encrypts their refresh token under your tenant key and mints a short-lived access token per call. The upstream applies its own authorization to the real person, and nothing in the MCP client’s configuration holds a secret. The other two modes are unchanged. secret dials with one shared credential; passthrough forwards each caller’s own and stores nothing.
  • Connect from the MCP server’s page in the console. There is no API to create a connection, deliberately: consent has to be given by the person whose credential it is. /v1/ai-gateway/mcp-servers/{id}/grants lists connections and revokes them (one, or all), in all five SDKs; no response carries a token in any form.
  • Register https://oauth.knoxcall.com/callback as the redirect URI — the one KnoxCall publishes, the same for every tenant.
  • An empty scopes list is refused: most providers read it as “give this app your default grant”.
  • A literal client_secret is refused (422). Pass client_secret_ref as {{secret_id:<uuid>}}.
  • Changing a server’s auth, or archiving it, revokes every connection on it. Revoking destroys the stored tokens rather than flagging them; it does not revoke KnoxCall’s authorization at the provider.
  • Only the person who started a connection can finish it: the callback requires a KnoxCall session and refuses, audibly, if it is not the same user.
Requires a token that names a person — see the identity entry below.

MCP passthrough identity is bound to the token, not claimed by a header ⚠ Behaviour change

X-KC-User is no longer an identity on /v1/mcp. On a mode: "passthrough" MCP server, the person a call is attributed to now comes only from a subject bound into the tool token’s capability scope, fixed at mint and covered by the token’s capability signature. It used to come from the X-KC-User request header, checked only for the existence of a matching row in your directory. So
was accepted, and Bob’s action against Jira was recorded against Alice — in the one artifact an incident reads. The control reduced to “name any provisioned employee”. What changes for you. A passthrough call made with a token that names nobody is refused with 401 passthrough_identity_unbound (it used to be accepted as long as a header named someone). Nothing changes on secret-mode MCP servers, on /v1/ai, or for per-user cost attribution on the chat plane, where X-KC-User is still what it always was: a hint that decorates a usage row. How to get a token that names a person. Two paths, both of which prove the person before minting:
  • OIDC exchange — set subject_claim on the workload binding to the claim that names the user (for example email). The value is read out of an id_token your IdP signed, resolved against your directory, and bound into the minted token. A claim naming someone you have not provisioned is refused rather than minted unbound. Leave it null for ordinary CI: a build job is not a person.
  • Device-code grant — for a human on Claude Code, Claude Desktop or Cursor.
If you send X-KC-User anyway it is kept on the call record as a claim, clearly separate from the bound subject, and a disagreement is logged. It never decides anything. Two related corrections landed with it. A passthrough refusal is now audited as what it is — mcp_identity_unbound / mcp_passthrough_refused — instead of mcp_upstream_refused with upstream_unreachable: true, which wrote a false statement about your upstream every time one of our own access controls fired. And mcp_subject is a reserved capability-scope key: it cannot be hand-written into a binding’s scope, because who a token speaks for is proven per exchange, never configured.

One error envelope on the data plane ⚠ Behaviour change

Every refusal /v1/ai/* can return now arrives in one shape, and it is documented — endpoint included — in the OpenAPI spec for the first time:
Three shapes used to answer this one URL and they disagreed about which field was the machine-readable one. The request pipeline sent {error: "<code>", error_description}. The authentication layer, one middleware earlier on the same request, sent {error: "Unauthorized", code, reason} — the inversion. The MCP audience guard sent a third reading in which error was a code that was not the code. Nothing in the response said which layer had refused, so no client could read error without shape-sniffing, and this page’s own guidance was to read the X-Auth-Failure header instead of the body. error and code now always carry the same identifier, so a client that reads either keeps working. What changes:
  • reason is gone from authentication and audience-guard refusals. Its string is now error_description, the field the pipeline refusals already used.
  • error on an authentication refusal is the code (expired, ip_cidr_mismatch, …) rather than the word Unauthorized or Forbidden. X-Auth-Failure is unchanged and still carries the same value.
  • The /v1 management envelope ({error: {type, message, request_id}}) and the /v1/mcp JSON-RPC error object are deliberately untouched — different planes, different protocols. The mapping is on the errors page.

A 429 now says when to come back

New. budget_exceeded and token_scope_limit_exceeded carry Retry-After, and the number is exact rather than a heuristic: spend counters are keyed by UTC day and UTC month, so the header counts to that cap’s actual rollover. Both data planes emit it — /v1/ai and the JSON-RPC /v1/mcp — because it rides on the shared refusal rather than on either plane’s response code. Where the server cannot know the answer it sends no header rather than a guess. So a 429 without Retry-After means “back off on your own schedule”, and the header is never 0 and never fractional.

A stream that dies mid-flight says why

New. A streamed call commits its status line with the first frame, so until now a provider reset, an early close or a detector fault reached you as a stream that simply stopped — the same clean EOF as a model that finished, with no code and nothing to retry on. An SSE response that fails now ends with a terminal frame carrying the same object as any other refusal:
A stream that ends with neither this frame nor the provider’s own terminal event is a transport truncation — retry it.

The buffered 502 stopped echoing internal errors ⚠ Behaviour change

Fixed. A failure in the buffered response path answered with the thrown error’s own message. That path wraps the response detector stack, detokenization, usage accounting and a database INSERT, so a Postgres failure put connection strings, role names and column layout in a response reachable by anyone holding a phantom token — the exact leak the pipeline’s own error boundary refuses by name forty lines below it. It now answers the generic sentence plus request_id; the detail goes to the operator’s logs. Quote the request id.

Usage & Cost tells the truth about what it cannot price

Fixed — read this if you have been reading the total. The Usage tab used to add up its numbers in your browser. A model that is not in KnoxCall’s pricebook records a cost of $0 with cost_source: unpriced, so that sum reported a confidently wrong bill: the total was an undercount and nothing said so. The page now warns when any request in the period could not be priced, names the models, and flags the rows that carry them. It also says the part that costs money: an unpriced model is the same reason a per-Agent budget set to block does not stop spend on it.

Group usage by agent, provider or a cost-centre tag — and export it

New. The breakdown is grouped server-side, on every dimension the FinOps exporter has had all along: person, team, agent, model, provider, or tag:<key> from your Agents’ cost-attribution tags. CSV export downloads exactly the cut on screen, from the same aggregator — so the file and the table cannot disagree. Two more things that were being computed and thrown away: people and teams are now shown by name rather than as a raw uuid, and a cost-per-day chart sits above the breakdown.

Federate CI with OIDC, from the product

New. Gateway → Federation creates and lists OIDC workload bindings, so a CI job exchanges its own id_token for a short-lived KnoxCall capability token and you store no long-lived secret in your CI provider. Presets for GitHub Actions, GitLab CI, CircleCI and HCP Terraform fill in the issuer and the claim that issuer is identified by, and there is a copyable GitHub Actions snippet. The API has accepted these since PR-A5; there was no way to create or even see one without calling it by hand. Two refusals the form makes before the server does, because both are easier to fix while you are still looking at the field:
  • A scope that narrows nothing is refused. An absent list is read as no restriction, so a binding with an empty scope would mint a token reaching every provider credential you have configured, on any model, with any tool, with no cost cap. Name at least one allowlist or one cap.
  • Conditions must pin the claim that identifies your workload. On GitHub’s issuer, ref: refs/heads/main is true for every repository on GitHub.
Bindings are tenant-wide — the same list appears under every Gateway, and deleting one deletes it everywhere. The page says so.

A token parked by impossible-travel detection now says so

Fixed. When a Gateway’s impossible-travel setting is block and a capability token is used from somewhere it could not have travelled to, KnoxCall parks it: its next call is refused until the holder re-authenticates. The token page did not show that. It looked healthy while every call it made came back 401 step_up_required, and the endpoint that clears the flag had existed all along. The page now shows a banner naming what tripped it and when, the recent security events behind it, and a Clear step-up flag button for a confirmed false positive — a shared machine key used from two regions, say. Clearing is audited against your account, and the confirmation says so: if the flag was a true positive, that record is who let the token back in. block never revokes, and the setting now says that too. A revoked token would turn a false-positive geolocation into an outage; a parked one costs its holder a re-authentication — which is exactly what someone holding only the stolen bearer value cannot do.

Impossible-travel detection is reachable in the UI

Fixed. geo_velocity_action is on Gateway → Settings (off / warn / block). The column and its validation both shipped earlier and nothing in the admin UI ever sent it, so every Gateway sat on the off default and the control could not be turned on without calling the API by hand.

A Requests tab, and the protection counters on Usage

New. Gateway → Requests is a per-request activity feed: when, which agent and model, the status, the latency, the tokens, the cost — and, for the first time on any screen, what the gateway’s own controls did to that request. A blocked prompt, a request whose PII was tokenized, a response served from cache: each is visible as such, filterable, and linkable (the filter is in the URL). Gateway → Usage grows a Protection row above the breakdown: PII spans redacted, prompts blocked, cache hits and the estimated saving. These numbers were being recorded on every request and shown nowhere. Two details that are easy to get wrong and are not:
  • Blocked prompts are counted from firewall events, not from metered requests. A blocked request never reaches cost accounting, so it has no usage row at all — counting there would report the one number that matters as zero.
  • An unpriced model reads as unpriced, never as $0.0000. A model KnoxCall cannot price is a gap in the pricebook, not a free request — and the same gap is why a block budget does not bind on that model.
Aggregate and redacted, deliberately. No prompt or response body reaches this view, and none is returned by the API behind it. These rows describe traffic that has already been through PII tokenization; a body here would be the one place in the product where the values the gateway exists to withhold come back. What a detector found is shown as its entity type (EMAIL_ADDRESS), never as the value. Paging is keyset on the request ledger, so pages never overlap or skip while new requests are still arriving.

See what the response cache is actually doing, and clear it

New. Agent → Budgets & cache now shows the cache’s own numbers over the last 30 days: hit rate, exact hits, near hits, misses, bypassed requests, how many entries are stored and how large they are — plus a Purge cache button for the moment you change a system prompt or a model and a replayed answer would be stale. Three things it is careful about:
  • A hit rate of nothing is not a hit rate of zero. With no eligible traffic the panel says so, rather than printing 0% and sending you to debug a cache that has had no chance to work.
  • The saving is an estimate, and says so. A request served from cache never reached a provider, so what it would have cost cannot be known. The figure is this Agent’s mean priced request cost over the same window times the number of hits, and it ignores models KnoxCall cannot price.
  • A silent degrade is named. An Agent set to semantic whose every hit was an exact one is told: semantic matching additionally needs an embedding model, a conversation id on the request, and reversible tokenization off in both directions, and without them it quietly falls back to exact matching. That was previously invisible.
The counters come from the request ledger, not from the entry table — an entry row only knows it was stored, while the ledger records what happened to each request.

Set up Cursor, Claude Code, Cline or Continue from the Agent page

New. The Agent detail page has a Connect tab. Pick the tool you use, and KnoxCall mints a capability key scoped to that Agent and renders the exact files, environment variables and commands to apply — including the verification command, so you can prove the first call went through the gateway before you change anything else. Ten setup packs ship today: Cursor, Claude Code, Cline, Continue, the Anthropic and OpenAI SDKs for Node and Python, and two generic Anthropic- and OpenAI-compatible profiles. Which ones are marked recommended comes from the Agent’s own provider, not from the name of its model. This is also the first supported way to obtain a DPoP-bound setup. The mint APIs have accepted dpop_required since they were written, and nothing generated the key pair a client needs in order to send a proof; the render endpoint generates one and writes it into the tool’s own config. KnoxCall keeps the public half and the thumbprint — never the private key. Binding is opt-in and separate, because a token bound to a key pair the client cannot use is a token that 401s on every call: tools with no DPoP install path do not offer it, and the server refuses it for them. The rendered files carry the key in plaintext, so the request is a POST (never a GET, which would leave a live credential in browser history and in every proxy access log on the way), it is refused to platform operators who are not members of your tenant, and each render is audited. The key is shown once.

Connection snippets are composed by the server

Fixed. The env-var, curl and SDK snippets on the Agent page were built in your browser from a second copy of KnoxCall’s provider table. The copy was held byte-identical to the real one by a CI guard, so it never drifted — but two copies of one table is one copy too many, and the browser had no business knowing the shape of a Bedrock invoke path. GET /admin/ai-gateway/{gateway}/agents/{agent} now returns snippets, built by the same module the quickstart uses, and the page renders them. No behaviour changes for you: the same bytes, from one place. The token in those snippets is a placeholder — the GET mints nothing. It is replaced with the real value only in the moment you issue a key, and only on that screen.

2026-09-07

knoxcall ai is a working golden path, in every SDK

New. The ai command group grew from one sub-command to six, and the new five are in all five SDK CLIs:
Two commands from an empty tenant to a capability token and an agent_url you can curl. create-agent escrows the key, picks or creates the gateway, and prints the command that follows; gateways, agents and usage fill in the rest. Everything takes flags, never positionals, so the surface is identical in all five languages. Three things it deliberately will not do:
  • Take a provider key as a flag. --secret-from-env NAME names an environment variable; there is no --secret-value. An argv value lands in shell history, ps output and the CI log line that echoes the command.
  • Create an agent with no upstream. The API accepts that and stores an agent whose first call fails; create-agent refuses instead, and says why.
  • Guess which gateway you meant. With several and no --gateway, it lists them and stops.

knoxcall ai import ships in an artifact you can install

Fixed. The LiteLLM importer lived in a cli/ package that was never published, never in CI and never tested beyond one suite — so Migrating from LiteLLM documented a command that shipped nowhere. Install it and follow the page and you got invalid choice: 'import'. It now lives in the Node SDK CLI (npm i -g @knoxcall/knoxcall), and the flag is --file:
import is the one ai sub-command that is Node-only, on purpose — it carries a hand-rolled YAML parser because the CLI ships zero runtime dependencies, and four more hand-ports of that parser would be four more ways for the migration plan to disagree with itself about your config. The page says so.

agent_url is on every agent response

New. agent_url — the data-plane base URL, https://{tenant}.knoxcall.com/v1/ai/{slug} — used to come back only from create and from fetching a single agent. It is now on the list rows and on PATCH as well, and every SDK declares it on its agent type. PATCH is the one that mattered: renaming an agent’s slug moves that URL, and the response that made the change was the one response that did not tell you the new one.

SDKs no longer enumerate the provider catalog

⚠ Behaviour change, TypeScript and Go only. The Node SDK’s AIProviderId was a six-value union while the server catalog held fourteen, so provider: "groq" was a type error for a value the API happily accepts. It is now string, with the current catalog in its doc comment; Go’s AIProviderIDs is documented as a convenience list rather than a validation set, and both list all fourteen. Pass the string and read the server’s 400, which names the valid set — the catalog grows server-side and an SDK-side enum can only ever be behind it. If you were pattern-matching exhaustively on AIProviderId, that no longer type- checks; narrow to your own union at your call site.

The SDK READMEs no longer teach an agent that 502s

Docs fix, and the one worth reading. All five SDK READMEs showed a createAgent example with no provider/upstream_secret_id and no primary_route_id. That call succeeds — and produces an agent with no upstream and no credential template, so the first /v1/ai request to it fails. The copy-paste starting point ended in a 502 on the one call the product exists to make. Every README now escrows a Secret first and passes provider + upstream_secret_id, and a CI guard reads all five so the snippet cannot regress.

Your PII recognizers and your scanner now reach /v1/mcp

⚠ Behaviour change. Two controls that governed /v1/ai and quietly did not govern /v1/mcp:
  • Your own PII recognizers. The MCP plane built one detector stack at boot and used it for every tenant, so it held the shipped built-ins only. The custom entity shapes you defined — or that a compliance pack installed — were redacted out of a chat completion and passed through verbatim in a tool result. An MCP server now takes a pii_redact_policy_id, exactly like an agent, and loads your recognizers per request through the same loader and the same short-TTL cache. With a policy attached the plane fails closed: if the policy does not resolve for your tenant, if a stored pattern will not compile, if a presidio_* recognizer cannot run in-process, or if your set was truncated at the 250 limit, the tool call is refused rather than served under a partially-applied policy. With no policy attached, a load failure degrades to the built-ins and logs — because you asked us to enforce nothing in particular.
  • Your guardrail webhook. An MCP server now takes the same five guardrail_webhook_* fields an agent does. request mode is offered the tool ARGUMENTS after redaction and before they leave; response mode is offered the tool RESULT before it is returned; a block verdict refuses the call. Unlike /v1/ai there is no streaming carve-out and no guardrail_streaming_unsupported: the MCP transport materialises every JSON-RPC message, so both directions are always inspectable. A block arrives as a JSON-RPC error on a 200, not as an HTTP 4xx — the MCP plane speaks JSON-RPC.
Deleting a PII policy an MCP server still references is now a 409 policy_in_use, alongside the existing agent rule. The FK is ON DELETE SET NULL, so without it the delete would have silently detached every bound server and turned their redaction off. What we did NOT add, and why. The MCP plane does not get the pii_request_mode / pii_response_mode split the chat plane gained on 2026-09-07. Those exist to fix a specific defect — request-side tokenization gated on a response-side flag — and that defect provably does not exist here: tool arguments and tool results are inspected by two independent reads of pii_inspection, neither of which consults the other. And tokenize / detokenize have no meaning on this plane at all: redaction is irreversible, there is no conversation and no token map, so a mode named tokenize would name a behaviour that cannot exist. pii_inspection stays one boolean because it is answering one question.

MCP servers are Live or Test, like everything else

⚠ Behaviour change. ai_gateway_mcp_servers was the last tenant-owned resource on either AI-gateway data plane with no Live/Test split. On /v1/ai the mode rides the agent’s route and is pinned twice; an MCP server has no route, so GET/PATCH/DELETE and the data-plane lookup filtered on no mode at all — and a kp_test_ capability token reached the Live MCP server, at which point KnoxCall decrypted the Live upstream credential and sent it on. What changes for you:
  • Every /v1/ai-gateway/…/mcp-servers call is now confined to the calling request’s own space. A production key on api.knoxcall.com does not see a Test server, and a test key on sandbox.knoxcall.com does not see a Live one — a 404, not a 403, because the row is not yours to know about.
  • sandbox appears on every MCP server response. It is read-only: it comes from the mode of the request that created the server. Sending it in a create or patch body is ignored.
  • The same slug may now exist in both spaces, so your MCP client’s connect_url does not change between Live and Test — which capability token you present decides which server answers. The uniqueness keys moved from (tenant, name) / (tenant, slug) to (tenant, sandbox, name) / (tenant, sandbox, slug), so a test key creating github no longer collides with a Live github it cannot even see.
  • A kp_test_ capability token against a Live MCP server is a 404. A request that reaches the MCP plane with no proven env at all is a 401 — “unknown” never resolves to Live.
Every existing MCP server is Live. They were created before the partition existed and their auth references resolve Live secrets; nothing was silently moved into Test. If you want a Test twin, create it with a test API key on the sandbox host — the same slug is fine.

A gateway budget is now a control, and it reaches /v1/mcp

⚠ Behaviour change. budget_daily_usd and budget_monthly_usd on a gateway were writable from the console and the API since the gateway existed and were read by nothing. A gateway capped at $50/day enforced no cap at all. They are enforced now, on both data planes:
  • /v1/ai answers 429 budget_exceeded with the message prefixed Gateway budget: so you can tell it apart from the agent’s own cap.
  • /v1/mcp refuses tools/call with the same reason. Discovery methods (initialize, ping, tools/list) are deliberately still served: a client refused at the handshake reports “could not connect” and you never see the cap that caused it.
The new field is budget_overage_action on a gateway: block (refuse) or warn (serve and emit X-Knox-AI-Budget-Pct / X-Knox-AI-Budget-Warning). There is no fallback — that action swaps an agent’s route and model, which a gateway does not have. Existing gateways that already carried a budget were set to warn, not block. Those numbers were typed in while the column enforced nothing, and turning them into a hard refusal during a deploy would be an outage we chose on your behalf. New gateways default to block. One PATCH turns an existing one on. The MCP plane also gained the H-32 refusal it was missing: a capability token carrying max_cost_per_day_usd or max_tokens_per_day is now refused at /v1/mcp rather than served. A tool call costs nothing and consumes no model tokens, so that counter would never move — serving it would grant unlimited use of a credential whose own signed grant says it is capped.

A hard spend cap refuses a model nothing can price

⚠ Behaviour change. An agent with budget_overage_action: "block" and a dollar cap is now refused with 403 budget_unpriced_model when its model has no price in KnoxCall’s pricebook. The cap could not bind on such a model: the call is metered at $0, the counter never moves, and the dashboard shows the budget comfortably unspent while spend continues. That is permanent for openai-compatible, azure-openai and ollama, whose endpoints are yours and therefore structurally unpriceable. warn and fallback agents are unaffected — they never promised a hard stop. If you need block on an unpriceable provider, either add the model to the pricebook or switch the action to warn and alert on cost_source = 'unpriced' in your usage export.

A leaked system-prompt canary is blocked, not just noted

⚠ Behaviour change. When a firewall policy is set to block and the provider’s response echoes the system-prompt canary — proof that the prompt in that request extracted your system prompt — the response is now refused with 400 canary_leak instead of being served with a 200. The marker itself never reached you either way; that part was already correct. What was wrong was the verdict: ai_gateway_usage.firewall_outcome recorded warn for a confirmed extraction, so a firewall report said the control held. Both the buffered and the streaming path now record the policy’s real action. On a streamed response the bytes have already left when the leak is detected, so there the change is the record and the alert, not a refusal. Policies set to warn are unchanged.

PII policies and recognizers are on the API

New. /v1/ai-gateway/pii-policies and /v1/ai-gateway/pii-recognizers now carry full CRUD, plus a side-effect-free pattern tester at POST /v1/ai-gateway/pii-recognizers/test. They are in all five SDKs, the Postman collection and the OpenAPI spec. Why it matters: pii_redact_policy_id has always been accepted on POST …/agents, but the only way to obtain a policy id was to click through the console. A HIPAA-shaped setup — define your own entity shapes, bundle them, attach the bundle to an agent — is now scriptable end to end from an API key. There is a worked example in PII redaction.

Three PII changes that used to succeed quietly are now refused

⚠ Behaviour change. All three apply on the console as well as the API, because both surfaces call the same code.
  • A recognizer_ids entry you do not own is a 400 recognizer_not_found. It used to be stored. It was never a cross-tenant read — the data plane resolves it to nothing — which is exactly the problem: the policy listed three recognizers and ran zero, and nothing said so until the PII reached the provider.
  • Deleting a policy an agent still uses is a 409 policy_in_use. It used to succeed and silently detach every bound agent, turning redaction off for each of them. Detach the policy from the agents first.
  • Deleting a recognizer a policy still lists is a 409 recognizer_in_use. Remove it from each policy first. We refuse rather than dropping the id for you, because an empty recognizer_ids means every enabled recognizer — so quietly removing the last id would widen the policy, not shrink it.
If you script against these, the failures are visible ones: a refusal you can see beats a policy that looks armed and is not.

The Presidio analyzer URL goes through the SSRF chokepoint

⚠ Behaviour change, security. If you point the gateway at your own Microsoft Presidio sidecar (Settings → Integrations → AI Gateway PII analyzer), that URL is now validated the way every other tenant-supplied destination on this platform is: the hostname is resolved, every returned address is checked, and the request is sent to the address we checked rather than to whatever the name resolves to a moment later. Why it matters: the analyzer receives the prompt text being inspected. Before this, the URL was dialled with an unvalidated HTTP client, so a value pointing at a private or link-local address — http://169.254.169.254/, http://127.0.0.1, or a hostname that resolves to one — made the gateway send prompt text there. What you may notice:
  • On the cloud service, an analyzer URL must be a publicly-routable http(s) host. Saving a private-network URL now fails with presidio_url_blocked, and an existing row with such a URL stops being used: the detector stack falls back to its built-in regex/checksum tiers (degraded, not broken) and logs the refusal.
  • On a self-hosted deployment, a sidecar on your own LAN is still supported. Tick “Analyzer is on this deployment’s private network” on the same integration. It is off by default, it is ignored on the cloud service, and it never permits link-local addresses (169.254.0.0/16, fe80::/10) — the cloud metadata range is not a network anyone runs a sidecar on.
  • Redirects and non-http(s) schemes on the analyzer URL are refused.

Failed AI and MCP authentications are audited

Additive. Every refusal from the gateway’s authentication layer now writes an ai_gateway.request_denied row to your audit log, on both /v1/ai and /v1/mcp. Previously only refusals from inside the request pipeline (firewall, budget, model policy) were audited, and everything the auth layer rejected — expired tokens, revoked tokens, a replayed one-shot token, a token used from outside its ip_cidrs or its valid_window, a DPoP failure, an MCP token presented to the wrong resource — produced a status code and no record at all. A credential-stuffing run against your gateway was invisible in the audit log. Filter on details.reason to see them: auth_token_rejected, auth_scope_denied, auth_tenant_unavailable, auth_agent_unavailable, auth_oneshot_replay, mcp_audience_denied. details.code carries the exact failure and details.plane is ai or mcp. Two deliberate exclusions: a request presenting no token, or a token that matches no record, writes nothing — there is no workspace to attribute it to, and an unauthenticated request must not be able to write to your audit log. A paused agent or gateway writes nothing either, because that is configuration state and it would fire on every request for as long as the pause lasts.

PII redaction is two settings now, and three parallel paths that leaked are closed

⚠ Behaviour change, security. The core promise — a detected entity does not reach the provider — held on the default path and failed on three others. All three are closed, and the single boolean that caused the first one is now two explicit settings. pii_request_mode and pii_response_mode replace pii_detokenize_response. One flag was answering two questions, and the one it named was the wrong one: request-side tokenization was gated on pii_detokenize_response, a response toggle. An agent with a PII policy attached and pii_detokenize_response: false loaded its recognizers, validated them, refused on a degraded policy — and then sent the prompt to the provider verbatim. Existing agents were migrated to today’s response behaviour (true → detokenize, false → redact) and to pii_request_mode: "tokenize" including the agents that had detokenize off — those are exactly the ones that were leaking. If one of your agents deliberately forwarded prompts untouched, set pii_request_mode: "off" on it explicitly. pii_detokenize_response is still accepted on create and update, and is now a read-only mirror of pii_response_mode == "detokenize" on reads; sending both with contradictory values is a 400. Streaming on a shape with no mid-stream redactor is refused. KnoxCall ships a hold-back rewriter for the Anthropic, OpenAI and Gemini SSE shapes. Cohere’s native SSE shape has none, and Bedrock streams length-prefixed application/vnd.amazon.eventstream binary frames rather than SSE at all. The handler used to stream those through untouched — no response-side redaction, no detokenization, no canary scan, no refusal. A streamed request on a cohere or bedrock agent is now 400 streaming_redaction_unsupported. Send it without stream: true (buffered responses are fully redacted), or set streaming_enabled: false on the agent. A body the tokenizer cannot read is refused. tokenizeRequestBody returned the RAW body with a 200 for anything that would not JSON.parse. On an agent with pii_request_mode: "tokenize" that is now 400 pii_unscannable_body. “We found no personal data” and “we could not look” are different answers. More of the prompt is scanned. The shared prompt-shape enumeration the tokenizer and the firewall both read now covers Cohere message, preamble and chat_history[].message (on Cohere, message is the prompt — those agents were sending every turn upstream in the clear), the OpenAI Responses instructions field, RAG grounding documents[], and tool descriptions on all four declaration shapes. Tool schemas are still not rewritten, deliberately. A configured Presidio analyzer that cannot run is announced. getPresidioDetector answered null to two different questions — “no analyzer configured” and “an analyzer IS configured and this deployment will not use it” (URL refused by the egress check, or circuit breaker open after five consecutive failures). The second degraded to the Aho-Corasick + regex tiers, served 200, and recorded nothing anywhere. Now: with a PII policy attached, 503 pii_policy_degraded; with no policy, served with X-Knox-AI-Pii-Degraded: presidio_<reason> and a detector_unavailable row in the PII events ledger. Having no analyzer configured is still silent, because nothing was asked for. Semantic caching follows the new columns. “Reversible tokenization is active” is now two columns, and the one that actually mints tokens is pii_request_mode. Either half keeps semantic matching off (exact caching is unaffected). Because the migration set pii_request_mode: "tokenize" on every tenant-owned agent, an agent that had pii_detokenize_response: false and cache_mode: "semantic" drops to exact caching. That is the safe direction and the reason the guard exists: those bodies are full of conversation-scoped KC_* tokens. Set pii_request_mode: "off" to get semantic matching back. See PII redaction.

2026-09-03

Credentials in a URL are refused

⚠ Behaviour change. A guardrail_webhook_url on an agent, or an upstream_url on an MCP server, may no longer carry userinfo — https://user:pass@host/… is rejected at create and at update. A credential written into a URL is stored in plaintext in a column nothing encrypts, and it leaks into every log line, audit row and error message that echoes the URL. Any userinfo already stored is stripped from API read responses. Move the credential into a Secret and reference it from the auth headers instead.

2026-09-02

A route requiring a signature is refused, not silently unsigned

⚠ Behaviour change. /v1/ai cannot compute a rolling URL signature, so an agent pointed at a route with require_signature used to reach the upstream without one. It is now refused with 503 route_signature_unsupported, and a failover candidate with the same setting is skipped rather than tried, with the skip written to the audit row. Same shape as route_field_actions_unsupported (2026-08-27): the plane refuses what it cannot honour rather than serving a weaker version of it.

Responses carry KnoxCall’s own document-isolation headers

⚠ Behaviour change. A response served on a KnoxCall host now carries a sandboxing Content-Security-Policy, X-Frame-Options: DENY and X-Content-Type-Options: nosniff written by the gateway — and the upstream’s values for those headers are dropped rather than relayed. The body is tenant-controlled bytes on our origin; the upstream does not get to set the policy the browser honours there. If you were reading an upstream’s CSP through the gateway, you will now see KnoxCall’s instead. ⚠ Behaviour change. Set-Cookie and Set-Cookie2 from the upstream are dropped on every /v1/ai path — cache hit, buffered and streamed alike — and the full RFC 7230 hop-by-hop set is stripped rather than just transfer-encoding. An upstream cannot set a cookie on a KnoxCall origin. Multi-valued response headers now relay as a list instead of being collapsed.

2026-09-01

Phantom tokens move to their own kp_ prefix

⚠⚠ Breaking change, no compatibility window. Deployed to production 2026-09-02. Phantom tokens — the AI data-plane credential — were kc_-prefixed, which is the OAuth 2.1 access token family. They are now kp_:
A kc_-prefixed phantom token no longer parses. There is no dual-parse window. Re-mint every AI data-plane token. A token issued before this change fails at the edge; the fix is a new mint, and nothing else recovers it. Why it could not stay: src/client-api/auth.ts classifies a bearer credential by kc_, so a phantom token presented to any /v1 client-API route was parsed as an OAuth token — which requires four underscore-separated segments where this format has five — and refused as 401 invalid_token: Malformed access token. The credential was valid; it had reached the wrong plane, and the error said nothing of the sort. Two credential families sharing a prefix is a type-confusion hazard whether or not a particular caller trips it today. kp_ is now disjoint from kc_ (OAuth access), kr_ (OAuth refresh), tk_ (tenant API key) and AKE (enterprise access key), and it has been added to the never-forward list that stops a KnoxCall credential being relayed to a third-party MCP server — see MCP gateway.
This landed while the AI gateway had no live tenant traffic, which is why no customer integration is known to have broken. That was luck, not process. This changelog entry, and the docs-truth guard that now requires one for any change under src/ai-gateway, exist so the next prefix change is announced before it ships.

2026-08-31

Linked-secret domain violations are audited on both tenants

When a shared secret’s domain lock refuses an upstream, a secret_share.domain_violation audit row is now written for both the tenant that used the secret and the tenant that shared it — from /v1/ai and, newly, from the MCP plane. The lender could previously not see that their lock had fired.

Test-mode PII detections stop firing live compliance alerts

PII detection events now record which data space they came from. A detection in Test no longer raises the Live HIPAA / SOC 2 compliance alert or consumes its cooldown, so a sandbox experiment cannot mask a real Live detection. Expect fewer, truer alerts.

Federated mints are audited

Every token minted through /v1/oauth/token now writes an ai_gateway.token_mint audit row tagged via: oidc_federation. This file previously wrote no audit row of any kind, so a burst of federated data-plane credentials was invisible to the privileged-mint velocity monitor.

2026-08-30

Minted tokens expire by default

⚠ Behaviour change. A mint that named no expiry produced a token that never expired. Every mint path now shares one implementation: an omitted expiry means 30 days, and any requested TTL is clamped to between 60 seconds and 90 days. The default is deliberately not the ceiling — long enough that a CI key is not a weekly chore, short enough that a key leaked from a repo scraped last quarter is already dead. If you rely on a never-expiring AI token, you do not have one any more. Re-mint with an explicit expires_in_seconds, up to the 90-day cap.

Archiving an agent or gateway disables its orphaned routes

⚠ Behaviour change. Archiving left the underlying routes rows enabled, so the upstream stayed reachable through a gateway object you had archived. Archive now disables the routes it orphans — scoped to the data space you archived in, so archiving a sandbox agent does not disable the Live route (that scoping was itself a same-week fix; between 2026-08-30 and 2026-08-31 it was not scoped).

CSV exports are quoted and formula-neutralized

⚠ Behaviour change. Usage-export and compliance-bundle CSVs now quote every cell and prefix any cell beginning =, +, - or @ with a single quote, so a model name or tag cannot execute as a spreadsheet formula when the file is opened. A strict downstream parser that assumed unquoted cells needs updating.

A response containing an injected credential is never cached

If a response body contains a resolved injected credential, it is not written to the response cache. Those calls will never register a cache hit.

Guardrail webhooks receive a scrubbed body

⚠ Behaviour change. The guardrail webhook payload now decompresses the upstream response before scanning it, removes any resolved secret value found in it, and sends an empty body when the content encoding cannot be decoded — a body that cannot be inspected is not forwarded to your endpoint. A webhook that parsed the raw compressed bytes needs updating; see Guardrail webhook.

2026-08-28

Semantic-cache embedding calls are metered

⚠ Behaviour change, cost. The semantic cache’s embedding calls were real provider spend and were recorded nowhere. They now produce their own usage rows, count against the agent’s budget, and count against a token’s signed daily cost and token caps. Expect your usage row count and your metered spend to rise on agents using cache_mode: "semantic" — the money was always being spent; only the accounting changed. A budget can now trip on cache traffic. Under a signed max_cost_per_day_usd, an embedding on a model that is not in the pricebook is not purchased at all — the semantic cache silently degrades to exact matching rather than making a spend the cap cannot count.

A suspended workspace is refused on every plane

⚠ Behaviour change. A phantom token belonging to a workspace that is not servable is refused with 403 tenant_unavailable on /v1/ai and /v1/mcp, and /v1/oauth/token answers invalid_grant. ⚠ Behaviour change. A Cookie header on a request to the data plane is dropped rather than relayed to the provider, and a KnoxCall Authorization, DPoP or x-api-key credential is consumed by the gateway rather than passed through. A browser-originated call that relied on its cookies reaching the upstream will see them gone.

2026-08-27

A paused agent or gateway refuses, on both planes

⚠ Behaviour change. A paused or archived agent — or one whose gateway is paused or archived — is refused with 503 agent_not_active / gateway_not_active on /v1/ai, and, newly, on /v1/mcp as JSON-RPC -32603. The MCP plane previously served traffic for a paused gateway.

A route with Relay field actions is refused

⚠ Behaviour change. /v1/ai cannot apply a route’s Relay field actions, so an agent pointed at such a route is refused with 503 route_field_actions_unsupported rather than served with those actions silently skipped. A failover candidate with them is skipped and audited.

Batch spend counts against your budget

⚠ Behaviour change. Batch spend was metered but not charged: it appeared in usage and never moved a budget. It now counts against the agent’s budget and the token’s signed daily caps, so a batch can push you into budget_exceeded or token_scope_limit_exceeded.

2026-08-26

An unenforceable budget refuses rather than serving unmetered

⚠ Behaviour change. When the cross-worker spend counter is unusable, the gateway can no longer know what has been spent. It now sets X-Knox-AI-Budget-Unmetered: 1 on the response, and on an agent whose budget_overage_action is block it refuses with 503 budget_unenforceable instead of serving a request it cannot count. warn and fallback agents are still served, unmetered, with X-Knox-AI-Budget-Pct suppressed — a percentage from a frozen counter reads 0.0%, which is the lie in header form. A counter holding a non-numeric value now raises instead of reading as zero.

DPoP htu is derived from the canonical URL

⚠ Behaviour change. The htu claim in a DPoP proof is now checked against the canonical public request URL rather than the raw X-Forwarded-* / Host headers. Sign your proof against the URL you actually call. A client behind a proxy that rewrites those headers may need to change what it signs.

2026-08-25

Batch spend is metered, once, at the batch rate

Provider batch APIs bill at a discount, and batch requests were being metered at the interactive rate — or, on some shapes, not at all. Batch usage is now metered exactly once, at the batch rate. (Charging it against the budget came two days later; see 2026-08-27.)

Per-user MCP credential passthrough

An MCP server can be set to auth.mode: "passthrough", where each caller supplies their own upstream credential per request and KnoxCall stores nothing. X-KC-User is required and is resolved against your directory; a KnoxCall credential in the header is refused; connections are never shared between users. See MCP gateway.

A residency denial names the destination, not the pin

A data-residency refusal recorded the agent’s configured region rather than the region the request would have reached, which is the one an auditor needs.

Extended-thinking and refusal text are redacted

⚠ Behaviour change, security. The streaming redactor scanned a model’s content and nothing else. Two other fields carry model-visible text and were going out unscanned:
  • Anthropic thinking_delta — with extended thinking enabled, the model’s reasoning streams in its own delta type, and reasoning quotes the prompt constantly. An SSN in a thinking block reached the client verbatim and produced no detection event, so it appeared in no evidence row and fired no canary alert. Because the buffered path scans the whole response body, stream: true was effectively an opt-out from redaction for this content.
  • OpenAI delta.refusal — when a model declines, its explanation streams in refusal, not content, and a refusal routinely quotes back what it is refusing.
Both are now redacted, each in its own hold-back window. See what in a stream is scanned. If you replay Anthropic thinking blocks verbatim on a later turn, note that redacting their text invalidates the block’s signature — re-prompt instead. Streamed tool-call arguments are still not scanned; that is stated explicitly on the streaming page rather than left to be discovered.

MCP sessions are scoped to the presenting token

⚠ Behaviour change. The upstream MCP session pool was keyed on the client-supplied mcp-session-id header. Two different phantom tokens sending the same value — including the common case of sending none at all — shared one upstream connection. The pool key now leads with the verified token, so each token gets its own upstream MCP session. What you may notice: slightly more upstream connections, and one extra initialization handshake the first time each token calls. If your upstream MCP server keeps per-session state, that state is now per-token rather than shared. A single token holds at most 4 concurrent sessions per server per environment; opening a fifth closes the least recently used.

server_type: 'collection' MCP servers are refused at creation

Creating an MCP server backed by a route collection was accepted by the API and then failed on every call, because the feature was never built. It is now refused at create with a clear message. An existing collection row answers 501 (it previously produced a generic 502 that pointed you at an upstream nothing had dialled).

resources/* and prompts/* on the MCP plane are documented as refused

No code change — the gateway governs tools, and has always answered method-not-found (-32601) for resource and prompt methods. It was not written down anywhere. See MCP gateway.

2026-08-24

Every content block in a streamed response is redacted

⚠ Behaviour change, security. The OpenAI streaming redactor inspected choices[0] only. A request with n > 1 streamed its second and later completions unredacted — the control was running and looking at the wrong element. Gemini’s redactor had the same defect one field over (candidates[0].parts[0]), and additionally missed a candidate’s second text part. All completions are now extracted and redacted, each with its own hold-back window. If you use n > 1 with PII redaction on, expect redaction to appear in completions where it previously did not.

X-KC-Team is honoured

⚠ Behaviour change. Sending X-KC-Team previously cleared the team on the usage row — the opposite of what the header is for — so calls that named a cost centre were the ones attributed to nobody. The header is now resolved against your directory and lands on attributed_team_id. See Budgets & FinOps.

Rate limits on all three data planes

/v1/ai, /v1/mcp and /v1/oauth/token register ahead of the proxy’s catch-all and so had no request-rate ceiling of any kind. All three now enforce cross-worker sliding windows — see Limits. The ceilings are generous abuse floors; a normal integration will not meet one.

Provider credentials are actually injected

⚠ Behaviour change, and the big one. The data plane did not perform route secret injection at all: every /v1/ai upstream call went out with no provider credential, so it 401’d against any real provider. The route’s inject_headers_json / inject_body_json are now rendered through the same secret-resolution chokepoint the main proxy uses, on the primary route, every failover route, the output-schema retry, and the semantic-cache embedder. If you had worked around this by putting a real provider key in your own client, stop — the gateway now injects one too and the upstream will see whichever it resolves.

Live/Test confinement is enforced

⚠ Behaviour change. A phantom token’s environment segment (…_live_ / …_test_) was parsed and discarded, so a Test token drove Live routes. The segment is now part of the route lookup on the primary and every failover route, and a token whose environment cannot be proven is refused. A Test token that had been reaching a Live route will now get a 404 for that agent’s route. (Phantom tokens carried a kc_ prefix at the time; they moved to kp_ on 2026-09-01 — see below.)

Firewall policies can be created, and block is reachable

No product surface could create a row in the firewall-policy table, so with no policy the evaluator capped every outcome at warn — block was unreachable for every tenant while the UI presented it as configurable. Policies are now creatable from /v1, the admin API and the gateway-creation seeder, and a block policy blocks. See Firewall.

Tenant PII recognizers are read by the data plane

Custom recognizers and compliance-pack recognizers were written and never read. They now compile into the detector stack on the outbound, streaming and buffered paths. An attached policy that cannot be resolved, or that holds a pattern that will not compile, now refuses the request rather than forwarding unredacted.

The MCP control plane exists

/v1/ai-gateway/.../mcp-servers CRUD, tool management, an admin twin and an MCP tab. Before this, creating an MCP server required a raw database insert.

MCP upstream egress and credentials go through the chokepoints

Security. Tenant upstream_url values are now resolved-and-pinned like every other tenant-supplied destination, and upstream auth must be a {{secret_id:…}} reference — a cleartext credential in the auth column is refused rather than forwarded.

Streaming hold-back leaked at the boundary

⚠ Behaviour change, security. The hold-back FSM deferred a match ending inside its tail but emitted everything before that same index, so an entity straddling the boundary had its head emitted in the clear. On a typical one-word-per-frame stream that was nearly every entity. Separately, patterns that do not match until complete (JWTs, provider API keys) had their heads emitted at the default window. Both are fixed; the minimum hold-back window is now 96 characters, raised from whatever a tenant had configured below it.

Error boundary and fail-closed policy loads

A database error inside the pipeline used to reject an async middleware promise that Express 4 ignores — leaving the request open indefinitely. Every path now answers. A firewall or PII policy that cannot be loaded refuses the request (503) instead of proceeding with the control off.

Guardrail quality is measured

A labelled corpus and a recall ratchet now run on every build, so a weakened detector fails CI. Published figures live in PII redaction.

2026-08-12

Usage rollup is scheduled

ai_gateway_usage_daily had no writer registered anywhere, so it was empty — and everything reading it (the spend-spike check, console usage) silently saw nothing. It now runs hourly on a single scheduler process.

2026-08-08

H-31 — the response cache is keyed on the caller, not just the conversation

⚠ Behaviour change, security. The cache’s isolation boundary was x-kc-conversation-id, a plain client header with no binding to the caller’s token. Two users in one tenant sending the same body with no conversation header both scoped to the empty string and could serve each other’s responses. Stored response headers were replayed wholesale, Set-Cookie included. The cache key now folds in the phantom token id — the only caller identity the gateway verifies — and the key prefix moved v1 → v2 so nothing written under the weaker assumption can ever be matched again. A request the gateway cannot attribute to a token does not read from or write to the cache at all. What you may notice: every cache entry written before 2026-08-08 was abandoned, so hit rates started from zero that day. Two clients that were (incorrectly) sharing a cache entry no longer do.

H-32 — capability-token scope is enforced

⚠ Behaviour change, security. A token’s providers, models, tools, egress_routes, max_cost_per_day_usd and max_tokens_per_day were HMAC-signed and never checked — only valid_window and ip_cidrs were. The admin UI rendered the unenforced fields as though they were restrictions. They are now enforced, after model policy resolves. Three fail-open cases inside the fix were corrected in the same change: a malformed scope denies rather than being ignored; a present-but-empty list is malformed rather than “unrestricted”; and a cost cap refuses a model with no price rather than metering it as free. If you minted tokens with scope fields set, expect them to start taking effect — this is the change most likely to turn a working call into a 403. Check X-Auth-Failure and the error code on any new refusal before assuming an outage.

H-33 — buffered responses get response-side redaction

⚠ Behaviour change, security. Non-streaming responses bypassed all response-side PII redaction and detokenization, so a client could opt out of redaction simply by omitting stream: true. Both branches now run the same stack.

2026-07-09

Semantic caching, conversation-scoped

Semantic (embedding-similarity) cache matching was re-enabled with a hard conversation scope, so an approximate match can only occur within one conversation. See Response caching.

Impossible-travel step-up

geo_velocity_action = 'block' flags a token for step-up on an anomalous location change rather than revoking it: the token’s next use is refused with step_up_required until it is re-authenticated or the flag is cleared. See Tokens & DPoP.

Earlier

The gateway was built as roughly thirty sequential changes through May–July 2026: the phantom-token format and DPoP binding, the PII detector stack and hold-back streaming redactor, the prompt firewall and canary tokens, the pricebook, budgets and FinOps exports, OIDC workload federation at /v1/oauth/token, the response cache, and the MCP gateway. Those predate this changelog; the pages in this section describe what they do today rather than the order they arrived in.