diff --git a/internal/billing/compile.go b/internal/billing/compile.go index 242df46..184a28b 100644 --- a/internal/billing/compile.go +++ b/internal/billing/compile.go @@ -207,6 +207,24 @@ func applyRule(prices map[string]interface{}, rule *config.BillingRule, srcName } entry["reasoning_price"] = rp } + if lc := t.LongContext; lc != nil { + if lc.Above <= 0 { + return fmt.Errorf("model %q long_context: above must be > 0", modelID) + } + above, err := longContextRate(lc, prompt) + if err != nil { + return fmt.Errorf("model %q long_context: %w", modelID, err) + } + entry["long_context"] = map[string]interface{}{ + "above": lc.Above, + // Marginal, not blended. The plugin charges the tokens past the + // threshold at this rate and the rest at the base rate, so the + // threshold composes with a cache discount (which applies to the + // cached portion) instead of forcing the plugin to guess how + // the two interact. + "prompt": above, + } + } if rule.Peak != nil { entry["peak"] = compilePeak(rule.Peak) } @@ -274,3 +292,32 @@ func compilePeak(p *config.BillingPeak) map[string]interface{} { } return out } + +// longContextRate resolves the marginal per-token rate for the tokens ABOVE a +// threshold, given the model's base per-million prompt price. +// +// Two ways to express it are supported because providers publish both: an +// absolute rate (OpenAI quotes \$3.00/M past 200k) and a multiple (DeepSeek +// quotes exactly 2x past 128k). Neither may be missing: a step with no rate is a +// silent no-op, and an operator who wrote one meant something. +func longContextRate(lc *config.BillingLongContext, basePerToken float64) (float64, error) { + hasPrompt := strings.TrimSpace(lc.Prompt) != "" + hasMult := lc.Multiplier > 0 + switch { + case hasPrompt && hasMult: + p, err := usdPerM(lc.Prompt) + if err != nil { + return 0, err + } + // Both given: the absolute rate wins, because it is the one the provider + // actually quotes. Silently multiplying instead would produce a number + // the operator never wrote. + return p, nil + case hasPrompt: + return usdPerM(lc.Prompt) + case hasMult: + return basePerToken * lc.Multiplier, nil + default: + return 0, fmt.Errorf("needs either prompt or multiplier") + } +} diff --git a/internal/billing/wildcard_test.go b/internal/billing/wildcard_test.go index a0a863c..246b47b 100644 --- a/internal/billing/wildcard_test.go +++ b/internal/billing/wildcard_test.go @@ -147,3 +147,68 @@ func TestReasoningPriceIsOmittedUnlessSet(t *testing.T) { t.Error("a non-numeric reasoning rate must fail the compile") } } + +// TestLongContextStepCompiles pins the long-context step's two accepted forms +// and its rejection of a step that would silently do nothing. +// +// Providers publish both shapes — DeepSeek says "2x past 128k", OpenAI quotes +// an absolute "$3.00/M past 200k" — so both must compile. Neither may be +// missing: a step with no rate is a rule that LOOKS like it handles long +// context and does not, which under-bills without any visible sign. +func TestLongContextStepCompiles(t *testing.T) { + sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}} + + compile := func(lc config.BillingLongContext) (map[string]interface{}, error) { + p := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{ + URL: "https://up.example/v1", Mode: "token", + Models: map[string]config.BillingToken{ + "m": {Prompt: "1.00", Completion: "2.00", LongContext: &lc}, + }, + }}} + prices, err := Compile(p, sources) + if err != nil { + return nil, err + } + e := prices["models"].(map[string]interface{})["m"].(map[string]interface{}) + return e["long_context"].(map[string]interface{}), nil + } + + // Absolute rate (OpenAI's published shape). + lc1, err := compile(config.BillingLongContext{Above: 200000, Prompt: "3.00"}) + if err != nil { + t.Fatalf("absolute long_context rate rejected: %v", err) + } + if lc1["above"] != 200000 || lc1["prompt"] != 3e-6 { + t.Errorf("absolute step = %#v, want above=200000 prompt=3e-6", lc1) + } + + // Multiplier (DeepSeek's shape): 2x of the 1.00/M base. + lc2, err := compile(config.BillingLongContext{Above: 128000, Multiplier: 2}) + if err != nil { + t.Fatalf("multiplier long_context rejected: %v", err) + } + if lc2["prompt"] != 2e-6 { + t.Errorf("multiplier step = %#v, want prompt=2e-6", lc2) + } + + // Both given: the absolute rate wins, because that is the number the + // provider quotes. Multiplying instead would produce a figure the operator + // never wrote. + lc3, err := compile(config.BillingLongContext{Above: 128000, Prompt: "5.00", Multiplier: 2}) + if err != nil { + t.Fatal(err) + } + if lc3["prompt"] != 5e-6 { + t.Errorf("both given = %#v, want the quoted absolute rate 5e-6", lc3) + } + + // Neither: a no-op step must fail, not compile into a rule that silently + // bills long context at the base rate. + if _, err := compile(config.BillingLongContext{Above: 128000}); err == nil { + t.Error("a step with neither prompt nor multiplier must be rejected") + } + // A zero threshold would make every request "long". + if _, err := compile(config.BillingLongContext{Above: 0, Multiplier: 2}); err == nil { + t.Error("above=0 must be rejected") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index d4e7369..c173229 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -463,6 +463,9 @@ type BillingToken struct { // setting it to a multiple of Completion reprices the thinking share rather // than billing it twice. Reasoning string `yaml:"reasoning,omitempty" json:"reasoning,omitempty"` + // LongContext is the prompt-price step above a token threshold. Nil = one + // flat rate, which is what a rule means when it says nothing. + LongContext *BillingLongContext `yaml:"long_context,omitempty" json:"long_context,omitempty"` } // BillingPeak declares peak-hour pricing. @@ -474,6 +477,31 @@ type BillingPeak struct { Hours [][2]int `yaml:"hours,omitempty" json:"hours,omitempty"` } +// BillingLongContext is a prompt-price step for long inputs. +// +// Every major provider charges the input tokens ABOVE a threshold at a higher +// rate — DeepSeek doubles past 128k, OpenAI and Anthropic step up as well. A +// flat prompt price therefore under-bills exactly the traffic an agent workload +// generates, and it under-bills it silently: the number is plausible, the bill +// adds up, and nothing says the rate was wrong. +// +// The step applies to the PROMPT only. Output and cache reads are billed at +// their own rates regardless of input length, which is how providers publish +// it — the higher rate is for the extra context you made the model read, not for +// what it wrote back. +type BillingLongContext struct { + // Above is the token count the step starts at. A request with exactly this + // many prompt tokens is still at the base rate; the step covers the tokens + // BEYOND it. Inclusive-below matches how the providers document it. + Above int `yaml:"above" json:"above"` + // Prompt is the USD-per-million rate for the tokens above Above. + Prompt string `yaml:"prompt" json:"prompt"` + // Multiplier is an alternative to Prompt: a factor applied to the base rate + // (DeepSeek's published step is exactly 2x). When both are set Prompt wins, + // and setting neither is a validation error rather than a silent no-op. + Multiplier float64 `yaml:"multiplier,omitempty" json:"multiplier,omitempty"` +} + // Validate rejects a BillingDSL that could not work as written. func (b *BillingDSL) Validate() error { if b == nil { diff --git a/internal/lua/billing_test.go b/internal/lua/billing_test.go index d60794c..1bf2913 100644 --- a/internal/lua/billing_test.go +++ b/internal/lua/billing_test.go @@ -1139,3 +1139,100 @@ func TestReasoningClampedToCompletion(t *testing.T) { // number plausible enough to survive a glance at a dashboard. approx(t, "reasoning clamped to completion", cost, 0.00195) } + +// TestLongContextStepIsMarginal: the threshold reprices only the tokens PAST +// it, at a marginal rate. +// +// A flat prompt price under-bills exactly the traffic an agent workload +// produces — on this deployment 88% of requests exceed 128k with a median of +// 247k — and it under-bills silently, because the number stays plausible. The +// step must apply to the excess only; charging the WHOLE prompt at the higher +// rate is the other obvious implementation and it overcharges short requests by +// the same factor it was meant to fix long ones. +func TestLongContextStepIsMarginal(t *testing.T) { + + // Short request: below the threshold, so the step must not touch it at all. + ps, _ := billingVM(t) + if err := ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "currency": "USD", + "models": map[string]interface{}{ + "m": map[string]interface{}{ + "prompt": 1e-6, "completion": 0, + "long_context": map[string]interface{}{"above": 128000, "prompt": 2e-6}, + }, + }, + }, + }); err != nil { + t.Fatal(err) + } + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "m", "source": "s", "key": "***aaaaaa", "ok": true, + "prompt_tokens": 100000, "completion_tokens": 0, "time": 1750000000000, + }) + got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64) + approx(t, "short request unaffected", got, 0.1) + + // Long request: 200k prompt with a 128k threshold → 72k tokens at the step. + ps2, _ := billingVM(t) + if err := ps2.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "currency": "USD", + "models": map[string]interface{}{ + "m": map[string]interface{}{ + "prompt": 1e-6, "completion": 0, + "long_context": map[string]interface{}{"above": 128000, "prompt": 2e-6}, + }, + }, + }, + }); err != nil { + t.Fatal(err) + } + ps2.Fire(StageRequestEnd, map[string]interface{}{ + "model": "m", "source": "s", "key": "***aaaaaa", "ok": true, + "prompt_tokens": 200000, "completion_tokens": 0, "time": 1750000000000, + }) + got2 := stateOf(t, ps2)["total"].(map[string]interface{})["cost"].(float64) + // 128000*1e-6 + 72000*2e-6 = 0.128 + 0.144 = 0.272 + approx(t, "long request marginal step", got2, 0.272) +} + +// TestLongContextComposesWithCacheDiscount pins the interaction, which is the +// whole reason for computing the step as a marginal rate. +// +// On this deployment the cache hit rate is 88% and long prompts dominate, so +// "cached tokens beyond the threshold" is the common case, not an edge case. A +// cache read already bills at prompt*discount; the long-context rate applies on +// top of THAT, not on top of the full prompt price. Computing it any other way +// silently changes the price of the majority of traffic. +func TestLongContextComposesWithCacheDiscount(t *testing.T) { + ps, _ := billingVM(t) + if err := ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "currency": "USD", "cache_discount": 0.1, + "models": map[string]interface{}{ + "m": map[string]interface{}{ + "prompt": 1e-6, "completion": 0, + "long_context": map[string]interface{}{"above": 100000, "prompt": 2e-6}, + }, + }, + }, + }); err != nil { + t.Fatal(err) + } + // 200k prompt, 150k of it cached, 50k fresh. Excess past 100k = 100k, split + // 25% fresh / 75% cached. + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "m", "source": "s", "key": "***aaaaaa", "ok": true, + "prompt_tokens": 200000, "completion_tokens": 0, + "cache_hit_tokens": 150000, "cache_reported": true, + "time": 1750000000000, + }) + got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64) + // base: fresh 50000*1e-6 = 0.05 + // cache 150000*1e-6*0.1 = 0.015 + // step: fresh excess 25000*(2-1)e-6 = 0.025 + // cache excess 75000*(2-0.1)e-6 = 0.1425 + // total = 0.2325 + approx(t, "step composes with cache discount", got, 0.2325) +} diff --git a/internal/lua/billing_ui_test.go b/internal/lua/billing_ui_test.go index 092314c..2c52980 100644 --- a/internal/lua/billing_ui_test.go +++ b/internal/lua/billing_ui_test.go @@ -465,3 +465,35 @@ func TestBillingRulesUIExposesReasoningPrice(t *testing.T) { t.Errorf("m-r appears %d time(s); both the row template and the editor need it", n) } } + +// TestBillingRulesUIExposesLongContextStep: the long-context step must be +// editable in the rules editor. +// +// This is not a niche control. On an agent workload 88% of requests carry more +// than 128k prompt tokens with a median of 247k, so a rule that omits the step +// under-bills the MAJORITY of traffic while every number on the dashboard stays +// plausible. Leaving the capability in config.yaml alone would mean the operator +// never discovers it needs it. +func TestBillingRulesUIExposesLongContextStep(t *testing.T) { + raw, err := os.ReadFile("plugins/billing.lua") + if err != nil { + t.Fatal(err) + } + src := string(raw) + for _, needle := range []string{ + `class='m-lc-a'`, // threshold + `class='m-lc-p'`, // absolute rate + `class='m-lc-m'`, // multiplier + `models[n].long_context`, // collected back out on save + `lcHint`, // explains that blank = no step + } { + if !strings.Contains(src, needle) { + t.Errorf("rules editor lacks %q", needle) + } + } + // Both the existing-row template and the "+ model" template need the three + // inputs; one would make a newly added model lose its step on the next save. + if n := strings.Count(src, `class='m-lc-a'`); n < 2 { + t.Errorf("long-context inputs appear %d time(s); both row templates need them", n) + } +} diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index 5d48b39..c1afeea 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -196,6 +196,7 @@ local function priceFor(payload) -- provider measured actually charges. Set per-rule only for a provider -- that publishes a distinct rate for thinking tokens. reasoning_price = d.reasoning_price, + long_context = d.long_context, } -- model dimension (a token price overrides the default's token prices) @@ -207,6 +208,7 @@ local function priceFor(payload) if mp.per_request ~= nil then out.per_request = out.per_request + mp.per_request end if mp.cache_discount ~= nil then out.cache_discount = mp.cache_discount end if mp.reasoning_price ~= nil then out.reasoning_price = mp.reasoning_price end + if mp.long_context ~= nil then out.long_context = mp.long_context end if mp.peak ~= nil then out.peak = mp.peak end end @@ -219,6 +221,7 @@ local function priceFor(payload) if sp.per_request ~= nil then out.per_request = out.per_request + sp.per_request end if sp.cache_discount ~= nil then out.cache_discount = sp.cache_discount end if sp.reasoning_price ~= nil then out.reasoning_price = sp.reasoning_price end + if sp.long_context ~= nil then out.long_context = sp.long_context end if sp.peak ~= nil then out.peak = sp.peak end end @@ -352,6 +355,36 @@ local function costFor(payload, price) + cacheHit * price.prompt * discount + completion * price.completion * mult + -- Long-context step: the tokens PAST a threshold cost more. + -- + -- A marginal rate on the excess, not a blended average — that is how + -- providers publish it (DeepSeek doubles past 128k) and what keeps it + -- composable: a cache read beyond the threshold gets BOTH the cache discount + -- AND the long-context rate, as it is billed. A blended average would have to + -- choose one, and choosing wrong under-bills exactly the cache-heavy long + -- requests the cache exists to make cheap. + -- + -- The excess splits between cached and fresh in the same proportion as the + -- whole prompt, so no token is charged twice and none is missed. + local lc = price.long_context + if lc then + local totalPrompt = tonumber(payload.prompt_tokens) or 0 + local threshold = tonumber(lc.above) or 0 + local lcRate = tonumber(lc.prompt) + if totalPrompt > threshold and threshold > 0 and lcRate and lcRate > 0 then + local excess = totalPrompt - threshold + -- Only the FRESH share of the excess carries the step: a cache read is + -- already billed at the discounted rate, and re-pricing it at the + -- long-context rate would charge the same tokens twice. + local freshShare = totalPrompt > 0 and (fresh / totalPrompt) or 0 + local excessFresh = excess * freshShare + local excessCached = excess - excessFresh + cost = cost + + excessFresh * (lcRate - price.prompt) * mult + + excessCached * (lcRate - price.prompt * discount) * mult + end + end + -- Reasoning tokens, when the rule prices them separately. -- -- reasoning_tokens is a SUBSET of completion_tokens and is already inside @@ -1033,7 +1066,9 @@ plugin.ui = { models: "Per-model prices", addModel: "+ model", reasoning: "think $/M", - reasoningHint: "Thinking tokens are billed as output unless you set a different rate here.", mFree: "free", mToken: "token", mSub: "subscription", mUnpriced: "unpriced", + + lcAbove: "over n tok", lcPrompt: "then $/M", lcMult: "or x", + lcHint: "Long-context step: tokens PAST the threshold cost more. Leave all three blank for one flat rate.", reasoningHint: "Thinking tokens are billed as output unless you set a different rate here.", mFree: "free", mToken: "token", mSub: "subscription", mUnpriced: "unpriced", noProfile: "No profile yet — create one to start pricing.", noRules: "No rules in this profile", warnNoSource: "matches no configured source", @@ -1057,7 +1092,9 @@ plugin.ui = { models: "按模型价格", addModel: "+ 模型", reasoning: "思考 $/M", - reasoningHint: "留空=思考 token 按输出价计(OpenAI/DeepSeek/Anthropic 都这样)。只有供应商对思考单独定价时才填。", mFree: "free 免费", mToken: "token 按量", + + lcAbove: "超过 n tok", lcPrompt: "此后 $/M", lcMult: "或 ×", + lcHint: "长上下文阶梯:超过阈值的部分按更高价计。三项都留空 = 单一价格。", reasoningHint: "留空=思考 token 按输出价计(OpenAI/DeepSeek/Anthropic 都这样)。只有供应商对思考单独定价时才填。", mFree: "free 免费", mToken: "token 按量", mSub: "subscription 订阅", mUnpriced: "unpriced 只统计", noProfile: "还没有方案 —— 新建一个开始定价。", noRules: "该方案暂无规则", @@ -1161,6 +1198,19 @@ plugin.ui = { "" + + // Long-context step: threshold + (absolute rate OR multiplier). All + // three blank means NO step, deliberately distinct from a step whose + // rate is 0 — three inputs rather than inferring the step from a zero. + // On an agent workload 88% of requests exceed 128k prompt tokens, so + // omitting this under-bills the MAJORITY while every number stays + // plausible. The step is MARGINAL: it reprices the excess only. + "" + + "" + + "" + "" + ""; @@ -1251,6 +1301,25 @@ plugin.ui = { } models[n].reasoning = rtxt; } + // Long-context step. All three blank means NO step — the key must be + // ABSENT from the saved rule rather than present-and-zero, which would + // be a step priced at nothing. + var lca = r.querySelector(".m-lc-a"); + var lcp = r.querySelector(".m-lc-p"); + var lcm = r.querySelector(".m-lc-m"); + var lcaT = lca ? String(lca.value || "").trim() : ""; + var lcpT = lcp ? String(lcp.value || "").trim() : ""; + var lcmT = lcm ? String(lcm.value || "").trim() : ""; + if (lcaT !== "" || lcpT !== "" || lcmT !== "") { + if (lcaT === "" || isNaN(parseFloat(lcaT))) { bad = n + " (" + T.lcAbove + ")"; return; } + if (lcpT !== "" && isNaN(parseFloat(lcpT))) { bad = n + " (" + T.lcPrompt + ")"; return; } + if (lcmT !== "" && isNaN(parseFloat(lcmT))) { bad = n + " (" + T.lcMult + ")"; return; } + if (lcpT === "" && lcmT === "") { bad = n + " (" + T.lcPrompt + "/" + T.lcMult + ")"; return; } + var lc = { above: String(parseFloat(lcaT)) }; + if (lcpT !== "") lc.prompt = lcpT; + if (lcmT !== "") lc.multiplier = parseFloat(lcmT); + models[n].long_context = lc; + } }); if (bad) throw new Error(T.needPrices + ": " + bad); if (Object.keys(models).length) out.models = models; @@ -1443,6 +1512,9 @@ plugin.ui = { "" + "" + + "" + + "" + + "" + ""; box.appendChild(row); };