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’stimeout_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_openorfail_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.
/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 itsContent-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_errorwith the request id.
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 theX-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):
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 aDPoP 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. Atools/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 ishttps://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.
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:
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.
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, inmessages, 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-streamheader alone put a request on the streaming path. Withstream: falsethe 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. Sendstream: trueto 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 with400 streaming_redaction_unsupported; the same call withoutstream: trueis served and redacted. - Gemini streaming (
:streamGenerateContent) was not recognised as streaming at all, and the?alt=sseit needs was not forwarded. It is now streamed through the Gemini redactor, withalt=ssesupplied by the gateway.
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_failedand adetector_unavailablerow in the PII events ledger.
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
systemas 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
messagesfield 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.
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: OpenAItool_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, functionname) 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: errorframe (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 theholdback 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)
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_modedecides whether a prompt is tokenized on the way out;pii_response_modedecides 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 totokenize. - 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 is400 pii_unscannable_body, and a request whose policy needs an analyzer that is down is503 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
blockfirewall policy is refused (400 canary_leak) rather than served with awarnverdict recorded. And a model your pricebook cannot price is refused under ablockbudget (403 budget_unpriced_model) rather than metering $0 and slipping the cap. - One error envelope on both data planes,
{error, error_description, code}witherrorandcodealways equal, andRetry-Afteron the 429s. The/v1management API and the/v1/mcpJSON-RPC error object are unchanged.
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 incache_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_modeandpii_response_modeboth default to on, and either one keeps approximate matching off, so an Agent created with the defaults and switched tocache_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
missrows are corrected to “no cache outcome” by migration20261118. They were all embedding rows. Counts for windows spanning 2026-09-08 therefore go down, and up in accuracy.
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_codemints once, and a second poll with it is refused as a replay. - Polling faster than the returned
intervalis answeredslow_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.
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}/grantslists connections and revokes them (one, or all), in all five SDKs; no response carries a token in any form. - Register
https://oauth.knoxcall.com/callbackas the redirect URI — the one KnoxCall publishes, the same for every tenant. - An empty
scopeslist is refused: most providers read it as “give this app your default grant”. - A literal
client_secretis refused (422). Passclient_secret_refas{{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.
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
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_claimon the workload binding to the claim that names the user (for exampleemail). 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.
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:
{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:
reasonis gone from authentication and audience-guard refusals. Its string is nowerror_description, the field the pipeline refusals already used.erroron an authentication refusal is the code (expired,ip_cidr_mismatch, …) rather than the wordUnauthorizedorForbidden.X-Auth-Failureis unchanged and still carries the same value.- The
/v1management envelope ({error: {type, message, request_id}}) and the/v1/mcpJSON-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: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 plusrequest_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 withcost_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, ortag:<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/mainis true for every repository on GitHub.
A token parked by impossible-travel detection now says so
Fixed. When a Gateway’s impossible-travel setting isblock 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 ablockbudget does not bind on that model.
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
semanticwhose 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.
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 accepteddpop_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:
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 NAMEnames an environment variable; there is no--secret-value. An argv value lands in shell history,psoutput 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-agentrefuses 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’sAIProviderId
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 acreateAgent 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 apresidio_*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.requestmode is offered the tool ARGUMENTS after redaction and before they leave;responsemode is offered the tool RESULT before it is returned; ablockverdict refuses the call. Unlike/v1/aithere is no streaming carve-out and noguardrail_streaming_unsupported: the MCP transport materialises every JSON-RPC message, so both directions are always inspectable. Ablockarrives as a JSON-RPC error on a 200, not as an HTTP 4xx — the MCP plane speaks JSON-RPC.
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-serverscall is now confined to the calling request’s own space. A production key onapi.knoxcall.comdoes not see a Test server, and a test key onsandbox.knoxcall.comdoes not see a Live one — a 404, not a 403, because the row is not yours to know about. sandboxappears 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
slugmay now exist in both spaces, so your MCP client’sconnect_urldoes 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 creatinggithubno longer collides with a Livegithubit 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.
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/aianswers429 budget_exceededwith the message prefixedGateway budget:so you can tell it apart from the agent’s own cap./v1/mcprefusestools/callwith 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.
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 withbudget_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 toblock 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_idsentry you do not own is a400 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 emptyrecognizer_idsmeans every enabled recognizer — so quietly removing the last id would widen the policy, not shrink it.
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 withpresidio_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 anai_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. Aguardrail_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 sandboxingContent-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.
Upstream Set-Cookie is never relayed
⚠ 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_:
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, asecret_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 explicitexpires_in_seconds, up to the 90-day cap.
Archiving an agent or gateway disables its orphaned routes
⚠ Behaviour change. Archiving left the underlyingroutes 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 usingcache_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 403tenant_unavailable on /v1/ai and /v1/mcp,
and /v1/oauth/token answers invalid_grant.
Cookie is never forwarded upstream
⚠ 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 503agent_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 intobudget_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 setsX-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 toauth.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’scontent 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: truewas effectively an opt-out from redaction for this content. - OpenAI
delta.refusal— when a model declines, its explanation streams inrefusal, notcontent, and a refusal routinely quotes back what it is refusing.
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-suppliedmcp-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 inspectedchoices[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. Tenantupstream_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 wasx-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’sproviders, 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 omittingstream: 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.