Skip to content

Request-sized holds, charge abandoned requests, guest output limit - #22

Merged
Marketen merged 8 commits into
mainfrom
feat/guest-chat-gateway
Nov 5, 2026
Merged

Marketen merged 8 commits into
mainfrom
feat/guest-chat-gateway

Conversation

@Marketen

@Marketen Marketen commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

All gateway changes for guest chat in one PR. Replaces #19, #20 and #21. Companion PRs: dappnode/dappnode-nexus#156 (Metering holds and guest chat) and dappnode/infrastructure#309 (settings).

Deploy order: dappnode-nexus#156 (Metering) first. Metering ignores unknown request fields, and a Metering without the estimate holds the context window, as today. But a Metering without guest_allowed makes every model read as closed to guests (part 4), which only matters once guest chat is on.

1. Send an upper-bound prompt estimate with each credit hold (was #19)

Metering held every request's prompt as the model's full context window, which rejected small balances even for short chats. The gateway now sends an upper bound of the real prompt, computed on the body after PII masking:

estimate = body bytes + 1,024 + Σ over image parts (16,384 − part bytes)

Tokenizers never produce more tokens than UTF-8 bytes. No estimate is sent for audio, video, files, unknown part types or /v1/systemone; Metering then holds the full context window. Full rules: dappnode-nexus docs/METERING_HOLDS.md.

2. Charge requests whose client hangs up before the answer ends (was #20)

A request is charged when the provider reports usage, at the end of the answer. Before, a client that hung up early made the gateway cut the provider off. No usage arrived and the hold was released unpaid, while the provider still billed us. Anyone could get answers for free by disconnecting just before the end.

Providers have no API to look up a cancelled request's cost. So once credit is reserved, the provider call no longer follows the client's connection: the answer runs to the end and its reported usage is charged, for every account.

  • Pressing Stop no longer lowers the bill; max_tokens bounds it.
  • A stalled provider still ends through the existing PROVIDER_SILENCE_TIMEOUT_SECONDS (15 min).
  • No fallback answer is generated for a client that has hung up.

3. Guest chat requests always carry an output limit (was #21)

A guest is an ordinary account (is_guest from Metering) with a small credit and no login. The only difference: a guest chat request always carries an output limit of at most GUEST_MAX_OUTPUT_TOKENS (default 8,192), written into the request so the provider enforces it. Without one, Metering would hold the model's maximum output (46 cents on Flash), far more than a guest's 3-cent credit.

  • 8,192 covers almost every answer: production DeepSeek V4.1 Flash answers averaged about 1,100 output tokens over a week, and the longest was about 15,000.
  • At 1,024, a hard question spent the whole limit reasoning and returned an empty answer (checked against the real DeepSeek API).
  • When the guest set no limit, the gateway writes max_completion_tokens. The provider adapter sends max_tokens to DeepSeek, which ignores max_completion_tokens. Checked against the real APIs (2026-11-04): Novita, Phala, Doubleword, MiniMax, Dappnode and Tinfoil enforce both fields.

Other accounts' requests go upstream as sent. Without max_tokens, Metering holds the model's maximum output (dappnode-nexus#156), which the provider can't exceed.

4. Guest keys call only models open to guests

Routing runs before credit is checked, and the Jev router (nexus/auto in prod) calls TypeSafe's paid API, so an exhausted guest key could keep making us pay TypeSafe. A free model costs a guest nothing against its credit, so a guest could use prod's free Qwen without limit. Guest keys now reach only chat completions on models an operator ticked Guests can use this model in the admin UI (guest_allowed from Metering). Anything else gets 403 guest_model_not_allowed before routing, PII masking or Metering run. Routers are never public models, so they are always refused.

Tests

  • go test -race ./... and go vet ./... pass.
  • TestStreamingUsageRetention (real HTTP through the production OpenAI adapter) now proves a client that hangs up mid-answer is charged.
  • New tests: input_estimate_test.go, proxy_hangup_test.go (each fails with its part of the fix removed), output_limit_test.go (guest limit written and held; signed-in requests untouched) and guest_models_test.go (unticked model, router, free model, other endpoint refused with no routing, reservation or provider call; fails with the check removed).
  • End to end with real DeepSeek: with the guest limit set to 16, a guest asking for 300 lines got 16 tokens and finish_reason: length.
  • Local end-to-end run against real DeepSeek with dappnode-nexus#156: results in a comment there.

Squash on merge: the branch history includes a default limit for every request that was later removed.

🤖 Generated with Claude Code

Marketen and others added 3 commits November 4, 2026 16:07
Metering used to hold the model's whole context window for every prompt.
The gateway now sends an upper bound of the prompt's tokens: the bytes of
the body sent upstream, plus 1,024 for what providers add, with each image
counted as 16,384 tokens instead of its bytes. Tokenizers never produce more
tokens than UTF-8 bytes, so the bound holds for text. Requests with audio,
video, files or unknown content parts, and SystemOne requests, send no
estimate, and Metering holds the full context window as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A request is charged when the provider reports its usage, which it does at
the end of the answer. When the client hung up earlier, the gateway cut the
provider off, no usage arrived, and Metering released the hold without a
charge, while the provider still billed us for what it had generated. Anyone
could get answers for free by disconnecting just before the end.

Once credit is reserved, the provider call no longer follows the client's
connection: the answer runs to the end, and its reported usage is charged,
for every account. Pressing Stop therefore no longer lowers the bill; the
request's max_tokens bounds it, and the provider client's existing idle
timeout still ends a stalled stream. A client that hung up gets no fallback
answer. The old 5-second post-finish drain is no longer needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Guest chat accounts (is_guest, from Metering) are landing-page visitors with
a small credit and no login. Their requests are ordinary requests with one
difference: a chat request always carries an output limit of at most
GUEST_MAX_OUTPUT_TOKENS (default 8,192), written into the request so the
provider enforces it. Metering holds credit for that limit, so a guest's
answer can never cost more than its hold; without a limit the request would
go upstream unbounded and could leave a debt nobody pays.

8,192 covers almost every answer, reasoning included: over a week of
production traffic on DeepSeek V4.1 Flash, answers averaged about 1,100
output tokens and the longest was about 15,000. A 1,024 limit left hard
questions with an empty answer, all of it spent on reasoning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Marketen and others added 2 commits November 4, 2026 16:33
Metering holds 32,768 output tokens for a request without max_tokens, but
the request went upstream unbounded and a longer answer was charged as debt.
An account with a small credit could run many such requests in parallel on
a model with a large maximum output and leave a debt many times its credit;
a subscriber in debt keeps using the plan.

The gateway now writes an output limit into every chat request: the
client's own when smaller, otherwise 32,768, the same value Metering holds.
The provider enforces it, so an answer can no longer outrun its hold. Guest
requests use the same rule with GUEST_MAX_OUTPUT_TOKENS (8,192). Requests
without max_tokens whose answers ran past 32,768 tokens now end with
finish_reason "length".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the client sets no output limit, the gateway now writes OpenAI's
current field, max_completion_tokens; newer OpenAI models reject max_tokens.
The provider adapter still sends max_tokens to DeepSeek, which ignores
max_completion_tokens. Checked against the real APIs on 2026-11-04: Novita,
Phala, Doubleword, MiniMax, Dappnode and Tinfoil enforce both fields;
DeepSeek enforces only max_tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Marketen Marketen changed the title Request-sized holds, charge abandoned requests, guest output limit Request-sized holds, charge abandoned requests, output limit on every request Nov 4, 2026
Requests from other accounts go upstream as sent again: without max_tokens,
Metering holds the model's maximum output (dappnode-nexus#155), which the
provider can't exceed, so no default limit is needed. Guest requests keep
GUEST_MAX_OUTPUT_TOKENS (8,192), since a model's maximum would not fit a
guest's credit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Marketen Marketen changed the title Request-sized holds, charge abandoned requests, output limit on every request Request-sized holds, charge abandoned requests, guest output limit Nov 4, 2026
Marketen and others added 2 commits November 4, 2026 17:20
CodeQL flagged len(text)+1 in the Presidio rune table and
len(Metadata)+len(fields)/2 in WithMeta as possible allocation-size
overflows once request data reaches them through new code paths. Both are
only capacity hints, so they now use a plain length and let append and the
map grow. Behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A guest key could reach a router or a free model. Routing runs before credit
is checked, and the Jev router calls TypeSafe's paid API, so an exhausted
guest key could keep spending; a free model costs a guest nothing against its
credit. Guest keys now reach only chat completions on models an operator
ticked "Guests can use this model" (guest_allowed from Metering). Anything
else gets 403 guest_model_not_allowed before routing, PII masking or Metering
run. Routers are never public models, so they are always refused.

Needs the Metering release that sends guest_allowed: with an older one every
model reads as closed, which only matters once guest chat is on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Marketen
Marketen merged commit d1e8c3c into main Nov 5, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant