mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-09 18:57:21 +00:00
Compare commits
17 Commits
v1.9.4
...
archive/pr
| Author | SHA1 | Date | |
|---|---|---|---|
| b75b64a39a | |||
| 120ba9e957 | |||
| c17a851ec1 | |||
| a6c6e6e5c7 | |||
| 098a4a5eb4 | |||
| 6bc3ff8bbd | |||
| b5f6cac0ef | |||
| 111dc6d53f | |||
| 0fbb8e5e88 | |||
| 34fcc0b40f | |||
| 7b201cbea3 | |||
| d71d235a8f | |||
| 59802d7697 | |||
| ac58f9485b | |||
| 0938a45ced | |||
| 57abc25bb9 | |||
| 95ac9db67a |
32
ARCHIVE.md
Normal file
32
ARCHIVE.md
Normal file
@ -0,0 +1,32 @@
|
||||
# archive/pre-v1.9-quota-refactor
|
||||
|
||||
**这是一个归档分支,不要从它继续开发。**
|
||||
|
||||
它冻结的是 v1.9 之前的一段插件重构历史,保存于 2026-10-05。
|
||||
|
||||
## 为什么保留
|
||||
|
||||
分支上有 16 个提交没有以等价形式进入主线。逐个 patch-id 核对后,其中 **3 个
|
||||
是远端完全没有的**:
|
||||
|
||||
| commit | 内容 | 为什么没进主线 |
|
||||
|---|---|---|
|
||||
| `a6c6e6e` | 可见性、state 分区、注册式 HTTP 端点 | 后继版本 `35b9422` 是它的 rebase 等价版,但这个 hash 上还挂着另一些只存在于此分支的改动 |
|
||||
| `6bc3ff8` | ccquota 显示每月额度 | ccquota 后来被通用 `quota` 插件取代,这段额度 UI 没进主线 |
|
||||
| `b5f6cac` | ccquota 页面首次打开是空的 | 同上,是 ccquota 生命周期内的修复 |
|
||||
|
||||
其余 13 个已在主线以等价 patch 存在(`35b9422` / `cab3361` / `ba10f03` 等),
|
||||
它们保留在这里是为了让这段历史可追溯,不是为了再次使用。
|
||||
|
||||
## 现在的功能落点
|
||||
|
||||
| 当时在这里 | 现在在哪 |
|
||||
|---|---|
|
||||
| ccquota 多账户额度插件 | `internal/lua/plugins/quota.lua`(通用套餐余量插件) |
|
||||
| 可见性 / state 分区 / 按密钥授权 | `internal/lua/plugin_access.go`(`AccessRule`) |
|
||||
| 注册式 HTTP 端点 | `internal/lua/plugin_http_handler.go` + `AccessRule` |
|
||||
|
||||
## 当前发布线
|
||||
|
||||
- `main` — 下一个未发布版本
|
||||
- `release/v1.9.x` — v1.9.x 发布线(最新 tag `v1.9.5`)
|
||||
4
cmd/gui/package-lock.json
generated
4
cmd/gui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.0",
|
||||
"license": "Proprietary",
|
||||
"devDependencies": {
|
||||
"electron": "^33.0.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.0",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
|
||||
@ -195,36 +195,6 @@ func applyRule(prices map[string]interface{}, rule *config.BillingRule, srcName
|
||||
if t.CacheDiscount != nil {
|
||||
entry["cache_discount"] = *t.CacheDiscount
|
||||
}
|
||||
// reasoning is optional and stays ABSENT (not nil, not 0) when the
|
||||
// rule does not set it, which is what makes the billing plugin skip
|
||||
// the repricing branch entirely. Writing an explicit 0 here would
|
||||
// mean "thinking is free" and silently under-bill every request
|
||||
// from a provider that bills it like ordinary output.
|
||||
if strings.TrimSpace(t.Reasoning) != "" {
|
||||
rp, err := usdPerM(t.Reasoning)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model %q reasoning: %w", modelID, err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@ -292,32 +262,3 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -86,129 +86,3 @@ func TestSpecificURLTokenRuleStillMarksItsSource(t *testing.T) {
|
||||
t.Error("an unrelated source was marked priced by a specific-url rule")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReasoningPriceIsOmittedUnlessSet pins the absence convention for the
|
||||
// thinking-token rate.
|
||||
//
|
||||
// A rule that says nothing about reasoning must produce a price entry with NO
|
||||
// reasoning_price key, so the billing plugin skips the repricing branch and
|
||||
// bills thinking at the ordinary output rate — which is what every provider
|
||||
// actually charges. Writing an explicit 0 instead would read as "thinking is
|
||||
// free" and silently under-bill every request from that model.
|
||||
func TestReasoningPriceIsOmittedUnlessSet(t *testing.T) {
|
||||
sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}}
|
||||
|
||||
// Case 1: no reasoning declared → the key must not appear.
|
||||
p1 := &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"},
|
||||
},
|
||||
}}}
|
||||
prices, err := Compile(p1, sources)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
|
||||
if _, present := entry["reasoning_price"]; present {
|
||||
t.Errorf("reasoning_price present (%v) without a rule; the plugin would treat 0 as free",
|
||||
entry["reasoning_price"])
|
||||
}
|
||||
|
||||
// Case 2: declared → present, and converted to per-token like every other rate.
|
||||
p2 := &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", Reasoning: "6.00"},
|
||||
},
|
||||
}}}
|
||||
prices2, err := Compile(p2, sources)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e2 := prices2["models"].(map[string]interface{})["m"].(map[string]interface{})
|
||||
rp, ok := e2["reasoning_price"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("reasoning_price missing or wrong type: %#v", e2["reasoning_price"])
|
||||
}
|
||||
if rp != 6e-6 {
|
||||
t.Errorf("reasoning_price = %v, want 6e-6 (USD per TOKEN)", rp)
|
||||
}
|
||||
// A malformed rate must be rejected, not silently ignored: an operator who
|
||||
// typed "6 USD" expects either that value or an error, never a rule that
|
||||
// looks applied and bills nothing.
|
||||
p3 := &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", Reasoning: "six dollars"},
|
||||
},
|
||||
}}}
|
||||
if _, err := Compile(p3, sources); err == nil {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -453,19 +453,6 @@ type BillingToken struct {
|
||||
Prompt string `yaml:"prompt" json:"prompt"` // USD per million tokens, e.g. "0.15"
|
||||
Completion string `yaml:"completion" json:"completion"` // USD per million tokens
|
||||
CacheDiscount *float64 `yaml:"cache_discount,omitempty" json:"cache_discount,omitempty"`
|
||||
// Reasoning is the USD-per-million rate for thinking tokens, for the rare
|
||||
// provider that charges them differently from ordinary output. Omit it and
|
||||
// reasoning is billed at Completion — which is what OpenAI, DeepSeek and
|
||||
// Anthropic all do, and what every source on this deployment reports.
|
||||
//
|
||||
// It is a rate for a SUBSET of completion tokens, never an extra charge: the
|
||||
// billing plugin applies only the difference between this and Completion, so
|
||||
// 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.
|
||||
@ -477,31 +464,6 @@ 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 {
|
||||
|
||||
@ -789,18 +789,6 @@ func recordChatUsage(rec *Req, req *types.ChatRequest, resp *types.UnifiedRespon
|
||||
// Cache accounting: whichever source format the adapter normalized into
|
||||
// (prompt_tokens_details.cached_tokens or legacy prompt_cache_hit_tokens),
|
||||
// read it back so the request record carries the hit/miss split.
|
||||
// reasoning_tokens is a SUBSET of completion_tokens and is billed at the
|
||||
// completion rate by every provider measured, so it must NOT be added to
|
||||
// Compl. Recording it separately is what lets a price rule split thinking
|
||||
// from output for the providers that do price them differently, and what
|
||||
// keeps the billing plugin from guessing.
|
||||
//
|
||||
// Leaving it unrecorded is worse than useless: the adapters already parse it
|
||||
// and forward it (opencodego/gemini/anthropic all do), so the number exists
|
||||
// in the response and is thrown away at the one place that could keep it.
|
||||
if d := resp.TokenUsage.CompletionTokensDetails; d != nil && d.ReasoningTokens > 0 {
|
||||
rec.Reasoning = int64(d.ReasoningTokens)
|
||||
}
|
||||
if d := resp.TokenUsage.PromptTokensDetails; d != nil && d.CachedTokens > 0 {
|
||||
rec.CacheHit = int64(d.CachedTokens)
|
||||
}
|
||||
@ -1056,9 +1044,6 @@ func (g *Gateway) fireEnd(rec *Req) {
|
||||
// carried this (rec.CacheReported); the plugin payload did not, so any
|
||||
// plugin-level cache accounting had to guess.
|
||||
"cache_reported": rec.CacheReported,
|
||||
// A SUBSET of completion_tokens, not an extra. Billing plugins that
|
||||
// add it to completion would double-charge every thinking token.
|
||||
"reasoning_tokens": rec.Reasoning,
|
||||
"image_count": rec.ImageCount,
|
||||
"error": rec.Err,
|
||||
"time": rec.Time,
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/types"
|
||||
)
|
||||
|
||||
// TestRecordChatUsageKeepsReasoningTokens pins the reasoning-token plumbing.
|
||||
//
|
||||
// The adapters already parse completion_tokens_details.reasoning_tokens and
|
||||
// forward it (opencodego, gemini, anthropic all do), and types.go has carried
|
||||
// the field since it was defined — but nothing ever READ it. The number arrived
|
||||
// in the response and was discarded at the one place that could have kept it,
|
||||
// so a pricing rule had no way to split thinking from output.
|
||||
//
|
||||
// The property that matters most: reasoning is a SUBSET of completion. Every
|
||||
// provider measured bills it at the completion rate, so a billing plugin that
|
||||
// added it to completion_tokens would double-charge every thinking token. The
|
||||
// test asserts completion is left alone.
|
||||
func TestRecordChatUsageKeepsReasoningTokens(t *testing.T) {
|
||||
rec := &Req{}
|
||||
resp := &types.UnifiedResponse{
|
||||
TokenUsage: types.TokenUsage{
|
||||
Prompt: 100,
|
||||
Completion: 500,
|
||||
CompletionTokensDetails: &types.CompletionTokensDetails{
|
||||
ReasoningTokens: 320,
|
||||
},
|
||||
},
|
||||
}
|
||||
recordChatUsage(rec, &types.ChatRequest{}, resp)
|
||||
|
||||
if rec.Reasoning != 320 {
|
||||
t.Errorf("Reasoning = %d, want 320", rec.Reasoning)
|
||||
}
|
||||
// The subset must not be folded into completion — it is already counted there.
|
||||
if rec.Compl != 500 {
|
||||
t.Errorf("Compl = %d, want 500 (reasoning is a subset, never additive)", rec.Compl)
|
||||
}
|
||||
if rec.Prompt != 100 {
|
||||
t.Errorf("Prompt = %d, want 100", rec.Prompt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordChatUsageReasoningAbsentStaysZero: "not reported" and "reported as
|
||||
// zero" must both leave the field at 0, and neither may invent a number. A
|
||||
// plugin reading Reasoning cannot tell them apart, which is why the field is
|
||||
// documented as "0 means not reported" rather than "0 means no thinking".
|
||||
func TestRecordChatUsageReasoningAbsentStaysZero(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
u types.TokenUsage
|
||||
}{
|
||||
{"no details object", types.TokenUsage{Prompt: 10, Completion: 20}},
|
||||
{"details without reasoning", types.TokenUsage{
|
||||
Prompt: 10,
|
||||
Completion: 20,
|
||||
CompletionTokensDetails: &types.CompletionTokensDetails{},
|
||||
}},
|
||||
} {
|
||||
rec := &Req{}
|
||||
recordChatUsage(rec, &types.ChatRequest{}, &types.UnifiedResponse{TokenUsage: tc.u})
|
||||
if rec.Reasoning != 0 {
|
||||
t.Errorf("%s: Reasoning = %d, want 0", tc.name, rec.Reasoning)
|
||||
}
|
||||
if rec.Compl != 20 {
|
||||
t.Errorf("%s: Compl = %d, want 20", tc.name, rec.Compl)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -43,16 +43,6 @@ type Req struct {
|
||||
// accounting at all (even when the hit count is 0). The WebUI shows
|
||||
// "0%" instead of "—" for such rows.
|
||||
CacheReported bool `json:"cache_reported,omitempty"`
|
||||
// Reasoning is completion_tokens_details.reasoning_tokens.
|
||||
//
|
||||
// It is a SUBSET of Compl, not an addition: reasoning tokens are billed at
|
||||
// the completion rate and already counted there. This field exists so a
|
||||
// pricing rule can split them when a provider prices thinking tokens
|
||||
// differently (most do NOT — OpenAI and DeepSeek bill them as ordinary
|
||||
// output), and so a reader can see what fraction of a bill is thinking.
|
||||
// 0 means "not reported", which is NOT the same as "no reasoning happened" —
|
||||
// three of the sources measured return no such field at all.
|
||||
Reasoning int64 `json:"reasoning_tokens,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
// Status http status code
|
||||
Status int `json:"status"`
|
||||
|
||||
@ -994,245 +994,3 @@ func TestBillingCacheHitClampedInStats(t *testing.T) {
|
||||
tot["cache_fresh_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnpricedIsTrackedPerDay pins the day-keyed unpriced tally.
|
||||
//
|
||||
// The lifetime counters cannot answer the question an operator actually has —
|
||||
// "is it STILL happening?" — because they keep the debt from before a rule was
|
||||
// added. After pricing a mispriced source the lifetime number never moves, so a
|
||||
// dashboard reading "unpriced: 6376" is indistinguishable from a system that is
|
||||
// still billing nothing. Day buckets are what make a pricing fix observable: the
|
||||
// offending day keeps its count and the next day starts at zero for it.
|
||||
func TestUnpricedIsTrackedPerDay(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
// Nothing is priced in the default config, so every request is unpriced.
|
||||
// Two requests on two different UTC days.
|
||||
for _, ms := range []int64{1790000000000, 1790100000000} {
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "mystery-model", "source": "nowhere", "key": "***aaaaaa",
|
||||
"ok": true, "prompt_tokens": 10, "completion_tokens": 5,
|
||||
"time": ms,
|
||||
})
|
||||
}
|
||||
st := stateOf(t, ps)
|
||||
byDay, ok := st["unpriced_by_day"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("state has no unpriced_by_day map: %T", st["unpriced_by_day"])
|
||||
}
|
||||
if len(byDay) != 2 {
|
||||
t.Fatalf("expected 2 day buckets, got %d: %v", len(byDay), byDay)
|
||||
}
|
||||
var sum float64
|
||||
for day, bucket := range byDay {
|
||||
b := bucket.(map[string]interface{})
|
||||
sum += b["reqs"].(float64)
|
||||
models := b["models"].(map[string]interface{})
|
||||
if models["mystery-model"].(float64) != 1 {
|
||||
t.Errorf("%s: model tally = %v, want 1", day, models["mystery-model"])
|
||||
}
|
||||
}
|
||||
if sum != 2 {
|
||||
t.Errorf("day buckets sum to %v, want 2 (the lifetime counter must not be double-counted)", sum)
|
||||
}
|
||||
// The lifetime counters still exist and still agree — this is additive, not
|
||||
// a replacement.
|
||||
if st["unpriced_reqs"].(float64) != 2 {
|
||||
t.Errorf("unpriced_reqs = %v, want 2", st["unpriced_reqs"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestReasoningBilledAtCompletionRateByDefault is the property that keeps the
|
||||
// standard formula matching a real invoice.
|
||||
//
|
||||
// reasoning_tokens is a subset of completion_tokens and every provider measured
|
||||
// (OpenAI, DeepSeek, Anthropic) charges it at the ordinary output rate. A rule
|
||||
// with no reasoning_price must therefore produce EXACTLY the number the
|
||||
// pre-reasoning formula produced — the field exists for a provider that prices
|
||||
// thinking differently, not as a new charge.
|
||||
func TestReasoningBilledAtCompletionRateByDefault(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
if err := ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"currency": "USD",
|
||||
"models": map[string]interface{}{
|
||||
// USD PER TOKEN (compile.go divides the per-million figure by 1e6).
|
||||
// prompt 0.15/M, completion 0.60/M. No reasoning_price anywhere.
|
||||
"m": map[string]interface{}{"prompt": 0.15e-6, "completion": 0.60e-6},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 1000,
|
||||
"reasoning_tokens": 800, // all of it thinking; still billed as output
|
||||
"time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
cost := st["total"].(map[string]interface{})["cost"].(float64)
|
||||
approx(t, "reasoning at completion rate", cost, 0.00075)
|
||||
}
|
||||
|
||||
// TestReasoningPriceOverridesOnlyWhenSet: with a rule that DOES price thinking
|
||||
// differently, only the difference is applied. Charging the reasoning portion a
|
||||
// second time in full is the obvious wrong implementation and it doubles the
|
||||
// completion line.
|
||||
func TestReasoningPriceOverridesOnlyWhenSet(t *testing.T) {
|
||||
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": 0.15e-6, "completion": 0.60e-6,
|
||||
// Thinking costs 3x ordinary output: 1.80/M.
|
||||
"reasoning_price": 1.80e-6,
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 1000, "reasoning_tokens": 800,
|
||||
"time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
cost := st["total"].(map[string]interface{})["cost"].(float64)
|
||||
// base = 1000*0.15e-6 + 1000*0.60e-6 = 0.00075
|
||||
// + 800 * (1.80-0.60)e-6 = 0.00096 → 0.00171
|
||||
approx(t, "reasoning repriced to 1.80/M", cost, 0.00171)
|
||||
}
|
||||
|
||||
// TestReasoningClampedToCompletion: a provider claiming MORE reasoning than
|
||||
// completion is malformed. Without a clamp the visible-output count goes
|
||||
// negative and the delta exceeds the whole request, so the bill silently
|
||||
// explodes on bad data instead of degrading.
|
||||
func TestReasoningClampedToCompletion(t *testing.T) {
|
||||
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": 0.15e-6, "completion": 0.60e-6, "reasoning_price": 1.80e-6,
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 1000, "reasoning_tokens": 99999,
|
||||
"time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
cost := st["total"].(map[string]interface{})["cost"].(float64)
|
||||
// reasoning is clamped from 99999 down to completion (1000):
|
||||
// base = 1000*0.15e-6 + 1000*0.60e-6 = 0.00075
|
||||
// delta = 1000 * (1.80-0.60)e-6 = 0.00120
|
||||
// total = 0.00195
|
||||
// Without the clamp this would be 0.18 — 92x the whole request, and a
|
||||
// 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)
|
||||
}
|
||||
|
||||
@ -414,86 +414,3 @@ func TestRuleTableLocksColumnWidthsInAColgroup(t *testing.T) {
|
||||
"control and must never be squeezed by the flexible inputs")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingUIShowsRecentUnpriced: the page must be able to distinguish a
|
||||
// settled pricing debt from a live leak. The lifetime unpriced counter answers
|
||||
// neither — it keeps every request from before a rule was added, so it reads the
|
||||
// same number in both situations. The day buckets exist to make the difference
|
||||
// visible, and a bucket nothing renders is a bucket nobody benefits from.
|
||||
func TestBillingUIShowsRecentUnpriced(t *testing.T) {
|
||||
raw, err := os.ReadFile("plugins/billing.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(raw)
|
||||
for _, needle := range []string{
|
||||
"unpriced_by_day", // the tally is written
|
||||
"recentUnpriced", // it is reduced to a window
|
||||
"unpriced_recent", // it reaches the view
|
||||
"unpriced7d", // the card labels it
|
||||
"unpricedClean", // and says so when the window is clear
|
||||
} {
|
||||
if !strings.Contains(src, needle) {
|
||||
t.Errorf("billing UI lacks %q", needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingRulesUIExposesReasoningPrice: the thinking-token rate must be
|
||||
// editable in the rules editor, not only settable by hand-editing config.yaml.
|
||||
// A pricing capability that lives only in the config file is one the operator
|
||||
// will assume does not exist.
|
||||
func TestBillingRulesUIExposesReasoningPrice(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-r'`, // the input, in both the existing rows and the "+ model" row
|
||||
`models[n].reasoning`, // collected back out of the DOM on save
|
||||
`reasoningHint`, // and it says what leaving it blank means
|
||||
} {
|
||||
if !strings.Contains(src, needle) {
|
||||
t.Errorf("rules editor lacks %q", needle)
|
||||
}
|
||||
}
|
||||
// Two occurrences: an existing model's row and the template for a new one.
|
||||
// One would mean the "+ model" button produces a row that silently drops
|
||||
// the rate on the next save.
|
||||
if n := strings.Count(src, `class='m-r'`); n < 2 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -192,11 +192,6 @@ local function priceFor(payload)
|
||||
out = {
|
||||
prompt = d.prompt or 0, completion = d.completion or 0,
|
||||
per_request = 0, cache_discount = d.cache_discount, peak = d.peak,
|
||||
-- nil = bill reasoning at the completion rate, which is what every
|
||||
-- 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,8 +202,6 @@ local function priceFor(payload)
|
||||
if mp.completion ~= nil then out.completion = mp.completion end
|
||||
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
|
||||
|
||||
@ -220,8 +213,6 @@ local function priceFor(payload)
|
||||
if sp.completion ~= nil then out.completion = sp.completion end
|
||||
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
|
||||
|
||||
@ -355,60 +346,6 @@ 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
|
||||
-- the term above. This block only matters when a provider charges thinking
|
||||
-- at a DIFFERENT rate — and every provider measured does not: OpenAI,
|
||||
-- DeepSeek and Anthropic all bill reasoning at the ordinary output rate. So
|
||||
-- reasoning_price is nil by default and this is skipped entirely, which is
|
||||
-- what makes the standard formula match the invoice.
|
||||
--
|
||||
-- When it IS set, only the DIFFERENCE is applied: the reasoning portion is
|
||||
-- re-priced from the normal rate to the reasoning rate. Charging it a
|
||||
-- second time in full would double-bill every thinking token.
|
||||
local reasoningPrice = tonumber(price.reasoning_price)
|
||||
if reasoningPrice and reasoningPrice ~= price.completion then
|
||||
local reasoning = tonumber(payload.reasoning_tokens) or 0
|
||||
if reasoning > 0 then
|
||||
-- Defensive clamp: a provider reporting more reasoning than completion
|
||||
-- would otherwise produce a negative visible-output count and a cost
|
||||
-- delta larger than the whole request.
|
||||
if reasoning > completion then reasoning = completion end
|
||||
cost = cost + reasoning * (reasoningPrice - price.completion) * mult
|
||||
end
|
||||
end
|
||||
|
||||
local flat = price.per_request
|
||||
if not payload.ok and not plugin.count_failures then
|
||||
flat = 0
|
||||
@ -505,21 +442,6 @@ function plugin.on_request_end(payload)
|
||||
s.unpriced_reqs = s.unpriced_reqs + 1
|
||||
local m = payload.model or "?"
|
||||
s.unpriced_models[m] = (s.unpriced_models[m] or 0) + 1
|
||||
|
||||
-- Day-keyed unpriced tally. The lifetime counters above cannot answer the
|
||||
-- question an operator actually has — "is it STILL happening?" — because they
|
||||
-- keep the debt from before a pricing rule was added. After pricing a source
|
||||
-- that was mispriced, the lifetime number never moves, so "unpriced: 6376" on
|
||||
-- a dashboard is indistinguishable from a system that is still billing
|
||||
-- nothing. Counting by day makes the fix observable: the offending day keeps
|
||||
-- its number, new days start at zero.
|
||||
if s.unpriced_by_day == nil then s.unpriced_by_day = {} end
|
||||
-- dayKey wants epoch SECONDS; payload.time is milliseconds.
|
||||
local uday = dayKey(math.floor((tonumber(payload.time) or (os.time() * 1000)) / 1000))
|
||||
local ubucket = s.unpriced_by_day[uday]
|
||||
if ubucket == nil then ubucket = { reqs = 0, models = {} }; s.unpriced_by_day[uday] = ubucket end
|
||||
ubucket.reqs = ubucket.reqs + 1
|
||||
ubucket.models[m] = (ubucket.models[m] or 0) + 1
|
||||
end
|
||||
if s.degraded_reqs == nil then s.degraded_reqs = 0 end
|
||||
-- Degradation is counted here rather than in the chain_step hook because
|
||||
@ -595,7 +517,7 @@ plugin.ui = {
|
||||
global = {
|
||||
"total", "by_source", "by_model", "by_day", "by_day_src",
|
||||
"by_day_model", "by_tier_served", "skip_reasons", "degraded_reqs",
|
||||
"unpriced_models", "unpriced_reqs", "unpriced_by_day", "started",
|
||||
"unpriced_models", "unpriced_reqs", "started",
|
||||
},
|
||||
depth = { by_key = 1, by_day_key = 2 },
|
||||
},
|
||||
@ -671,7 +593,6 @@ plugin.ui = {
|
||||
en: {
|
||||
total: "Total", requests: "Requests", degraded: "Degraded",
|
||||
unpriced: "Unpriced", prompt: "Prompt tokens",
|
||||
unpriced7d: "Unpriced (7d)", unpricedClean: "none in last 7 days",
|
||||
completion: "Completion tokens", failures: "Failures",
|
||||
cacheRate: "Cache hit rate", cacheTokens: "Cache read tokens",
|
||||
perSource: "Per source", perModel: "Per model",
|
||||
@ -693,7 +614,6 @@ plugin.ui = {
|
||||
zh: {
|
||||
total: "总开销", requests: "请求数", degraded: "降级",
|
||||
unpriced: "未定价", prompt: "输入 tokens",
|
||||
unpriced7d: "未定价(近 7 天)", unpricedClean: "近 7 天无",
|
||||
completion: "输出 tokens", failures: "失败",
|
||||
cacheRate: "缓存命中率", cacheTokens: "缓存读取 tokens",
|
||||
perSource: "按源", perModel: "按模型",
|
||||
@ -814,35 +734,13 @@ plugin.ui = {
|
||||
});
|
||||
return out;
|
||||
}
|
||||
// recentUnpriced counts unpriced requests over the last n days. When the
|
||||
// result is zero THAT is the useful signal: every pricing rule added since
|
||||
// then is holding. Null when there is no day data at all (a state file
|
||||
// written before the buckets existed) so the UI can say "unknown" rather than
|
||||
// claim a clean record it cannot support.
|
||||
function recentUnpriced(st, n) {
|
||||
var byDay = st && st.unpriced_by_day;
|
||||
if (!byDay) return null;
|
||||
var cutoff = Date.now() - n * 86400000;
|
||||
var sum = 0, seen = false;
|
||||
Object.keys(byDay).forEach(function (d) {
|
||||
var t = Date.parse(d + "T00:00:00Z");
|
||||
if (isNaN(t)) return;
|
||||
seen = true;
|
||||
if (t >= cutoff) sum += (byDay[d].reqs || 0);
|
||||
});
|
||||
return seen ? sum : null;
|
||||
}
|
||||
|
||||
// rescale returns the state as the selected period presents it. Degraded and
|
||||
// unpriced counters are lifetime-only (there is no day-keyed tally for them)
|
||||
// so they are returned as null and the UI hides them instead of printing a
|
||||
// lifetime number beside a per-window cost — the exact mismatch the period
|
||||
// view exists to remove.
|
||||
function rescale(st) {
|
||||
if (PERIOD === "all" || !st.by_day) {
|
||||
if (st) st.unpriced_recent = recentUnpriced(st, 7);
|
||||
return st;
|
||||
}
|
||||
if (PERIOD === "all" || !st.by_day) return st;
|
||||
var days = daysInWindow(PERIOD, utcToday());
|
||||
var total = zeroBucket();
|
||||
var byDay = {};
|
||||
@ -860,10 +758,6 @@ plugin.ui = {
|
||||
by_day: byDay,
|
||||
degraded_reqs: null,
|
||||
unpriced_reqs: null,
|
||||
// The lifetime unpriced total is null here for the same reason, but the
|
||||
// day buckets are not — they are what lets the card report "0 in the last
|
||||
// 7 days" instead of leaving a settled debt indistinguishable from a leak.
|
||||
unpriced_recent: recentUnpriced(st, 7),
|
||||
};
|
||||
return out;
|
||||
}
|
||||
@ -944,15 +838,6 @@ plugin.ui = {
|
||||
// print a lifetime number next to a per-window cost.
|
||||
view.degraded_reqs != null ? [T.degraded, fmtInt(view.degraded_reqs)] : null,
|
||||
view.unpriced_reqs != null ? [T.unpriced, fmtInt(view.unpriced_reqs)] : null,
|
||||
// "Unpriced (7d)" answers what the lifetime number cannot: is it STILL
|
||||
// happening? A settled debt and a live leak print the same lifetime total.
|
||||
view.unpriced_recent != null
|
||||
? [
|
||||
T.unpriced7d,
|
||||
fmtInt(view.unpriced_recent),
|
||||
view.unpriced_recent === 0 ? T.unpricedClean : null,
|
||||
]
|
||||
: null,
|
||||
[T.prompt, fmtInt(t.prompt_tokens || 0)],
|
||||
[T.completion, fmtInt(t.completion_tokens || 0)],
|
||||
[T.failures, t.failures || 0],
|
||||
@ -1064,11 +949,7 @@ plugin.ui = {
|
||||
monthly: "Monthly fee", peakMult: "Peak multiplier",
|
||||
peakHours: "Peak hours (UTC)", peakDays: "Peak days (UTC 1=Mon)",
|
||||
models: "Per-model prices", addModel: "+ model",
|
||||
|
||||
reasoning: "think $/M",
|
||||
|
||||
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",
|
||||
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",
|
||||
@ -1090,11 +971,7 @@ plugin.ui = {
|
||||
monthly: "月费", peakMult: "峰段倍数",
|
||||
peakHours: "峰段小时 (UTC)", peakDays: "峰段星期 (UTC 1=周一)",
|
||||
models: "按模型价格", addModel: "+ 模型",
|
||||
|
||||
reasoning: "思考 $/M",
|
||||
|
||||
lcAbove: "超过 n tok", lcPrompt: "此后 $/M", lcMult: "或 ×",
|
||||
lcHint: "长上下文阶梯:超过阈值的部分按更高价计。三项都留空 = 单一价格。", reasoningHint: "留空=思考 token 按输出价计(OpenAI/DeepSeek/Anthropic 都这样)。只有供应商对思考单独定价时才填。", mFree: "free 免费", mToken: "token 按量",
|
||||
mFree: "free 免费", mToken: "token 按量",
|
||||
mSub: "subscription 订阅", mUnpriced: "unpriced 只统计",
|
||||
noProfile: "还没有方案 —— 新建一个开始定价。",
|
||||
noRules: "该方案暂无规则",
|
||||
@ -1192,25 +1069,6 @@ plugin.ui = {
|
||||
"<input class='m-name' value='" + esc(m) + "' style='flex:1 1 90px;min-width:0'>" +
|
||||
"<input class='m-p' value='" + esc(t.prompt == null ? "" : t.prompt) +
|
||||
"' placeholder='" + esc(T.prompt) + "' style='flex:0 1 62px;min-width:0'>" +
|
||||
// Left BLANK by default on purpose: empty means "bill thinking at the
|
||||
// completion rate", which is what OpenAI, DeepSeek and Anthropic do.
|
||||
// A 0 here would mean "thinking is free" and would under-bill quietly.
|
||||
"<input class='m-r' value='" + esc(t.reasoning == null ? "" : t.reasoning) +
|
||||
"' placeholder='" + esc(T.reasoning) + "' title='" + esc(T.reasoningHint) +
|
||||
"' style='flex:0 1 62px;min-width:0'>" +
|
||||
// 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.
|
||||
"<input class='m-lc-a' value='" + esc(t.long_context == null ? "" : t.long_context.above) +
|
||||
"' placeholder='" + esc(T.lcAbove) + "' title='" + esc(T.lcHint) +
|
||||
"' style='flex:0 1 52px;min-width:0'>" +
|
||||
"<input class='m-lc-p' value='" + esc(t.long_context == null ? "" : (t.long_context.prompt || "")) +
|
||||
"' placeholder='" + esc(T.lcPrompt) + "' style='flex:0 1 52px;min-width:0'>" +
|
||||
"<input class='m-lc-m' value='" + esc(t.long_context == null ? "" : (t.long_context.multiplier || "")) +
|
||||
"' placeholder='" + esc(T.lcMult) + "' style='flex:0 1 46px;min-width:0'>" +
|
||||
"<input class='m-c' value='" + esc(t.completion == null ? "" : t.completion) +
|
||||
"' placeholder='" + esc(T.completion) + "' style='flex:0 1 62px;min-width:0'>" +
|
||||
"<button class='ghost small m-del' title='" + esc(T.delRule) + "' style='flex:0 0 auto'>×</button></div>";
|
||||
@ -1287,39 +1145,6 @@ plugin.ui = {
|
||||
return;
|
||||
}
|
||||
models[n] = { prompt: pv, completion: cv };
|
||||
|
||||
// Optional thinking-token rate, read from the same row. Blank means
|
||||
// "bill reasoning at the completion rate" — what OpenAI, DeepSeek and
|
||||
// Anthropic all do. A 0 would mean "thinking is free", so an empty
|
||||
// field must stay ABSENT from the saved rule rather than becoming 0.
|
||||
var rel = r.querySelector(".m-r");
|
||||
var rtxt = rel ? String(rel.value || "").trim() : "";
|
||||
if (rtxt !== "") {
|
||||
if (isNaN(parseFloat(rtxt))) {
|
||||
bad = n + " (" + T.reasoning + ")";
|
||||
return;
|
||||
}
|
||||
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;
|
||||
@ -1510,11 +1335,6 @@ plugin.ui = {
|
||||
"<input class='m-name' placeholder='model' style='flex:1;min-width:80px'>" +
|
||||
"<input class='m-p' placeholder='" + esc(T.prompt) + "' style='width:96px'>" +
|
||||
"<input class='m-c' placeholder='" + esc(T.completion) + "' style='width:96px'>" +
|
||||
"<input class='m-r' placeholder='" + esc(T.reasoning) + "' title='" + esc(T.reasoningHint) +
|
||||
"' style='width:96px'>" +
|
||||
"<input class='m-lc-a' placeholder='" + esc(T.lcAbove) + "' title='" + esc(T.lcHint) + "' style='width:78px'>" +
|
||||
"<input class='m-lc-p' placeholder='" + esc(T.lcPrompt) + "' style='width:78px'>" +
|
||||
"<input class='m-lc-m' placeholder='" + esc(T.lcMult) + "' style='width:70px'>" +
|
||||
"<button class='ghost small m-del'>×</button>";
|
||||
box.appendChild(row);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user