Repository navigation
Request-sized holds, charge abandoned requests, guest output limit - #22
Merged
Merged
Conversation
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>
This was referenced Nov 4, 2026
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>
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>
This was referenced Nov 4, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_allowedmakes 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-nexusdocs/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.
max_tokensbounds it.PROVIDER_SILENCE_TIMEOUT_SECONDS(15 min).3. Guest chat requests always carry an output limit (was #21)
A guest is an ordinary account (
is_guestfrom Metering) with a small credit and no login. The only difference: a guest chat request always carries an output limit of at mostGUEST_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.max_completion_tokens. The provider adapter sendsmax_tokensto DeepSeek, which ignoresmax_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/autoin 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_allowedfrom Metering). Anything else gets403 guest_model_not_allowedbefore routing, PII masking or Metering run. Routers are never public models, so they are always refused.Tests
go test -race ./...andgo vet ./...pass.TestStreamingUsageRetention(real HTTP through the production OpenAI adapter) now proves a client that hangs up mid-answer is charged.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) andguest_models_test.go(unticked model, router, free model, other endpoint refused with no routing, reservation or provider call; fails with the check removed).finish_reason: length.Squash on merge: the branch history includes a default limit for every request that was later removed.
🤖 Generated with Claude Code