Skip to content

Record requests refused before reaching any upstream - #366

Merged
fylorn merged 1 commit into
mainfrom
fix/log-model-refusals
Oct 22, 2026
Merged

fylorn merged 1 commit into
mainfrom
fix/log-model-refusals

Conversation

@fylorn

@fylorn fylorn commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

Refs ThinkWatchProject/ThinkWatch-Lite#356

Problem

Once the gateway key is recognised, a request can still be refused before routing finishes: a mistyped model, a model no upstream offers, a model outside the key's allow list, a request no rule matches, or a gateway with no upstreams configured. These returned early with ? before open(), so there was no RequestStarted, no RequestRouted, no row in the request store and nothing in /in-flight. They were invisible in the traffic view and impossible to diagnose.

Change

These cases now go through Routed::Refused, the same path a rule denial already uses:

  • RequestStarted with an empty upstream; model is the name the client sent
  • RequestRouted with an empty attempt chain
  • RequestFailed with the original error code and arguments (gw.model.no_upstream, gw.model.not_allowed, gw.model.alias_unserved, gw.model.pinned_*, the *_rewritten variants, gw.route.failed, gw.config.no_upstreams)

When the model admission fails, the row is attributed to the rule that decided the route. When no rule decided (no match, evaluation error, no upstreams), the deciding rule is empty; route stats count such rows toward the route only and do not report a rule with an empty name.

The response sent to the client is unchanged (same status code and error body). Requests rejected before the key is identified (missing or invalid key, source address not allowed, non-POST, oversized body) are still not recorded.

WebSocket

  • Responses connections: every turn that is not sent now records a row (model outside the key's allow, alias the connected upstream cannot serve, no rule matched, rule evaluation error), not only rule denials. NotSent carries which rule the row belongs to (Decider).
  • Realtime upgrades refused for their model, and upgrades whose routing fails, record a row like a rule-denied upgrade.

No schema change and no control API version change.

Tests

  • tw-gateway/tests/routing_facts.rs: model outside the key's allow, no rule matched, no upstreams configured; each asserts the start / empty-route / failure events and the unchanged client response.
  • tw-gateway/tests/models.rs: a model no upstream serves.
  • tw-gateway/tests/alias_ws.rs: per-turn alias and key-scope refusals and Realtime upgrade refusals now record a row (the old "no row" assertion is replaced).
  • tw-control/tests/route_stats.rs: refused requests land in /history with the client's model name and error code, and route stats count them without an empty rule.
  • tw-store unit test: an empty deciding rule counts toward the route only.

🤖 Generated with Claude Code

A request whose key was recognised but that was refused before routing
finished left no trace: no RequestStarted, no RequestRouted, nothing in
the request store or in-flight. A mistyped model, a model no upstream
offers, a model outside the key's allow list, a request no rule matches,
or a gateway with no upstreams configured could not be seen in the
traffic view.

These now take the same path as a rule denial (Routed::Refused): the
request starts with an empty upstream, is routed with an empty attempt
chain, and fails with the original error code and arguments. The model
on the row is the one the client sent. When no rule decided the route
(no match, evaluation error, no upstreams) the deciding rule is empty.

The response the client receives is unchanged (same status and body).
Requests rejected before the key is identified (missing or invalid key,
source address not allowed, non-POST, oversized body) are still not
recorded.

WebSocket:
- Each Responses turn that is not sent (model outside the key's allow
  list, alias the connected upstream cannot serve, no rule matched,
  rule evaluation error) records a row, like a rule-denied turn.
- A Realtime upgrade refused for its model, and an upgrade whose routing
  fails, record a row.

Route stats do not count an empty rule name as a rule; such rows count
toward the route only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fylorn
fylorn merged commit 70690bf into main Oct 22, 2026
6 checks passed
@fylorn
fylorn deleted the fix/log-model-refusals branch October 22, 2026 12:57
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 22, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant