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
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user