take the completion ceiling from the model catalog, not a constant
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s

The reservation landed with a 32768 constant standing in for "the most a
completion can be". That number is wrong per model by construction: it caps a
1M-context model at whatever was typed, and it is one release out of date the
moment a model ships. This estate has already paid for that shape twice —
ai/model's name-matching table gave deepseek-v4-pro 16384 and 402'd every long
prompt, and glm-5.2 dead-ended /compact on a stale 16K fallback. Both were
fixed by moving the number into models.yaml. This does the same for billing.

  - completionCeiling(model) resolves through SetCompletionCeiling, installed
    in apps/ai from ModelConfig.MaxOutput (ai v1.832.18), falling back to the
    model's context window — still a true bound, since prompt + completion can
    never exceed it. The constant survives only as a FLOOR for a model the
    catalog does not declare, and is documented as never a per-model answer.
  - the seam exists because hanzoai/ai/controllers imports hanzoai/cloud, so
    the catalog is a CYCLE from cloud's root, not merely weight. apps/ai links
    both, which is where every other cross-module hook is installed.
  - atMost no longer writes the ceiling onto the request. What we reserve is a
    billing fact; req.MaxTokens is the CALLER's, and forwarding a limit they
    never asked for silently truncates their answer. The reservation is sound
    regardless — a model cannot exceed its own max output — so the bound holds
    whether or not we restate it on the wire. Only a caller-set MaxTokens now
    reaches the provider.

TestCeilingComesFromTheModelNotAConstant pins all four: a 1M model reserves
>=1M, an undeclared model takes the floor, a caller's MaxTokens wins, and
atMost never mutates it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
Hanzo Dev
2026-08-03 19:20:50 -07:00
parent 85128ed712
commit 637e151a9c
5 changed files with 116 additions and 26 deletions
+23
View File
@@ -21,6 +21,7 @@ import (
"fmt"
aimod "github.com/hanzoai/ai"
aictl "github.com/hanzoai/ai/controllers"
aiobject "github.com/hanzoai/ai/object"
airouters "github.com/hanzoai/ai/routers"
"github.com/hanzoai/cloud"
@@ -110,6 +111,28 @@ func Mount(app *zip.App, deps cloud.Deps) error {
if cloud.TracerProviderInstalled() {
aiobject.AdoptHostTracerProvider()
}
// THE PREPAID GATE'S COMPLETION CEILING, PER MODEL, FROM THE CATALOG.
//
// cloud's meter must bound a completion BEFORE it runs, and that bound is a
// property of the model — 1M-context models exist, and any constant caps them
// at whatever number was typed. It cannot read models.yaml itself:
// hanzoai/ai/controllers imports hanzoai/cloud, so the catalog is a CYCLE from
// cloud's root, not merely weight. This package already links both, which is
// why the seam is installed here beside the other cross-module hooks.
//
// max_output_tokens is the answer when the catalog declares one; otherwise the
// model's context window is still a true architectural bound (prompt +
// completion can never exceed it). 0 from both leaves cloud on its own floor.
cloud.SetCompletionCeiling(func(model string) int {
mc := aictl.GetModelConfig()
if mc == nil {
return 0
}
if n := mc.MaxOutput(model); n > 0 {
return n
}
return mc.ContextWindow(model)
})
// INSTALL ONLY WHAT THIS PROCESS ACTUALLY HAS. `ai` runs as its OWN process
// (ps in a prod pod: /cloud, /kms, /tasks, /ai, …), and these hooks are
// package-level vars — so a reader wireFinance sets in the CLOUD process is
+1 -1
View File
@@ -684,7 +684,7 @@ require (
github.com/hanzo-ds/go v1.0.1
github.com/hanzo-ds/native v0.72.0 // indirect
github.com/hanzoai/agent v0.1.3
github.com/hanzoai/ai v1.832.17
github.com/hanzoai/ai v1.832.18
github.com/hanzoai/authz v1.10.29
github.com/hanzoai/base v1.5.11
github.com/hanzoai/licensing v0.1.5
+2
View File
@@ -1003,6 +1003,8 @@ github.com/hanzoai/ai v1.832.16 h1:cgQJUY+Gc+bjEr+Qv1q7Y4HLMgVsXgi7cTdld6qSgmM=
github.com/hanzoai/ai v1.832.16/go.mod h1:Dr2pwcJxy+/4fitSPdVpJla56SjeWpPv9ofJY+ryQJU=
github.com/hanzoai/ai v1.832.17 h1:XZYGsHRCWLn7H+4UGfaITEjjFktcVqAf87JKCuCbtvk=
github.com/hanzoai/ai v1.832.17/go.mod h1:Dr2pwcJxy+/4fitSPdVpJla56SjeWpPv9ofJY+ryQJU=
github.com/hanzoai/ai v1.832.18 h1:5+Av9BFxezLB0hZf+bWFT+X4BGpDvvKEywpyf1vfFNM=
github.com/hanzoai/ai v1.832.18/go.mod h1:Dr2pwcJxy+/4fitSPdVpJla56SjeWpPv9ofJY+ryQJU=
github.com/hanzoai/authz v1.10.29 h1:b4vWtI9g4Mvay1zizW7cwly/hDk06r+oAdA/y+317Do=
github.com/hanzoai/authz v1.10.29/go.mod h1:xkzFdiIFx4UQMlU0NkmSRz0dgRQYqSXpKgVjn8ijn3E=
github.com/hanzoai/base v1.5.11 h1:AZxGFPQN7sus/f5phZHoeC5pz7iNLDmwf1NoE/1hSvk=
+51 -25
View File
@@ -237,37 +237,63 @@ func (m *meteredAI) gate(ctx context.Context, org, project string, cents int64)
return m.meter.Gate(ctx, org, project, false, AIMeterProvider, cents)
}
// atMost resolves the completion ceiling ONTO the request and returns what the
// chat could cost at most: the prompt, which is known, plus that ceiling.
// atMost is what a chat could cost at most: the prompt, which is known before
// the call, plus the most completion the MODEL can produce.
//
// Writing the ceiling back is what makes the reservation SOUND rather than
// merely optimistic. The transport sends req.MaxTokens upstream, so the provider
// is bound by the very number the gate priced — one value, resolved once, and
// the meter and the wire cannot disagree about what was paid for. Reserving a
// ceiling nobody enforces would leave the completion just as unfunded as pricing
// the prompt alone, only less visibly.
// The ceiling is per-MODEL and comes from the catalog, never from a constant. A
// constant is wrong by construction — it caps a 1M-context model at whatever
// number was typed, and it is one release out of date the moment a model ships.
// This estate has paid for that twice already: ai/model's deleted name-matching
// table gave deepseek-v4-pro 16384 and 402'd every long prompt, and glm-5.2
// dead-ended /compact on a stale 16K fallback. Both were fixed the same way —
// declare it in models.yaml, resolve it here.
//
// It does NOT write the ceiling onto the request. What we reserve is a billing
// fact; req.MaxTokens is the CALLER's, and forwarding a limit they never asked
// for silently truncates their answer. The reservation is sound regardless: a
// model cannot exceed its own max output, so the bound holds whether or not we
// restate it on the wire.
func atMost(req *types.ChatRequest) int {
if req.MaxTokens <= 0 {
req.MaxTokens = maxCompletionTokens()
out := req.MaxTokens
if out <= 0 {
out = completionCeiling(req.Model)
}
return EstTokens(req.Prompt) + req.MaxTokens
return EstTokens(req.Prompt) + out
}
// defaultMaxCompletionTokens is the completion ceiling assumed for a caller that
// states no MaxTokens. It is what the gate RESERVES, never what it charges —
// settlement always debits the exact usage and the rest of the reservation is
// released — so the only thing this number trades off is which failure a caller
// meets at the edge of its balance:
// ceilingOf resolves a model's max completion length. Installed at wire-up from
// the model catalog (see SetCompletionCeiling); nil until then.
var ceilingOf func(model string) int
// SetCompletionCeiling installs the per-model completion-ceiling lookup the
// prepaid gate reserves against. Called once at startup by the package that
// links the model catalog (apps/ai), so package cloud states WHAT it needs
// without importing where the answer lives — the same seam shape the AI module
// uses for SetContextWindowResolver.
func SetCompletionCeiling(f func(model string) int) { ceilingOf = f }
// completionCeiling answers the most tokens a completion of `model` can be: the
// catalog's number when it declares one, else the floor.
func completionCeiling(model string) int {
if ceilingOf != nil {
if n := ceilingOf(model); n > 0 {
return n
}
}
return maxCompletionTokens()
}
// defaultMaxCompletionTokens is the FLOOR used when the catalog declares nothing
// for a model — a deployment with no models.yaml, or a model reaching the
// gateway before its entry lands. It is not a per-model answer and must never be
// used as one; MaxOutput in the catalog is the answer.
//
// too LOW -> the ceiling is also sent upstream, so a legitimate long answer is
// TRUNCATED. Silent, visible only in the output, and a product bug.
// too HIGH -> a nearly-empty wallet is refused a call it could almost afford.
// Loud, correct, and exactly what "prepay fully" means.
//
// So it is set generously (32k — a common modern output cap, above what real
// completions reach) and the second failure is the one we choose. At the default
// price that reserves ~7c, which no funded account notices. Ops tunes it per
// deployment with CLOUD_AI_MAX_COMPLETION_TOKENS.
// A floor, not a guess, in the same sense as ai/model.DefaultContextLength: the
// value that is safe across the lineup. It is only ever RESERVED, never charged
// (settlement debits the exact usage and releases the rest) and never sent
// upstream, so it cannot truncate an answer. Its only effect is which nearly
// empty wallets are refused early — so it is set generously. Ops overrides it
// per deployment with CLOUD_AI_MAX_COMPLETION_TOKENS.
const defaultMaxCompletionTokens = 32768
// maxCompletionTokens resolves the assumed completion ceiling. A
+39
View File
@@ -256,3 +256,42 @@ func TestConcurrentCallsCannotEachSpendTheWholeBalance(t *testing.T) {
}
func usd(micros int64) string { return fmt.Sprintf("$%.4f", float64(micros)/1e6) }
// A 1M-CONTEXT MODEL IS NOT CAPPED BY A CONSTANT.
//
// The ceiling a chat is reserved against is a property of the MODEL, resolved
// from the catalog. This is the regression that keeps recurring in this estate:
// ai/model's name-matching table gave deepseek-v4-pro 16384 and 402'd every long
// prompt, and glm-5.2 dead-ended /compact on a stale 16K fallback. A constant
// here would reintroduce it on the billing path — reserving 32k for a model that
// can emit 1M, and under-reserving by ~30x.
func TestCeilingComesFromTheModelNotAConstant(t *testing.T) {
t.Cleanup(func() { SetCompletionCeiling(nil) })
SetCompletionCeiling(func(model string) int {
if model == "glm-5.2" {
return 1_000_000
}
return 0 // undeclared → the floor
})
big := atMost(&types.ChatRequest{Model: "glm-5.2", Prompt: "hi"})
if big < 1_000_000 {
t.Errorf("glm-5.2 reserved %d tokens, want >= 1M — a 1M model was capped by a constant", big)
}
small := atMost(&types.ChatRequest{Model: "unknown", Prompt: "hi"})
if small >= 1_000_000 {
t.Errorf("an undeclared model reserved %d tokens; it must take the floor, not another model's ceiling", small)
}
if small != EstTokens("hi")+defaultMaxCompletionTokens {
t.Errorf("undeclared model reserved %d, want prompt+floor(%d)", small, defaultMaxCompletionTokens)
}
// The caller's own MaxTokens always wins over the catalog: it is their stated
// intent, and it is the only value ever sent upstream.
req := &types.ChatRequest{Model: "glm-5.2", Prompt: "hi", MaxTokens: 500}
if got := atMost(req); got != EstTokens("hi")+500 {
t.Errorf("caller MaxTokens=500 reserved %d, want prompt+500", got)
}
if req.MaxTokens != 500 {
t.Errorf("atMost mutated the caller's MaxTokens to %d — a ceiling we synthesize must never reach the wire", req.MaxTokens)
}
}