mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(billing): 长上下文阶梯定价(边际费率,与缓存折扣/峰段可叠加)
## 影响面实测
审计里 1000 条请求:**883 条(88.3%)prompt 超过 128k**,中位数 246849,
p90 298179,最大 354251。这不是小众场景,是 agent 负载的主流形态。
按「模型 × 源」拆开:其中真正按 token 计价的只有 alittokenplan 的 10 次
(deepseek-v4.1-flash,约 350k prompt),其余走 $70 套餐(commandcode)或
免费源(localzen),记 0 是正确的,对阶梯无影响。
那 10 次当前少算 54.9%($0.3148 → $0.4877)。绝对值不大,但比例严重,
而且这个偏差只会随上下文变长而变大。
## 边际而非混合平均
阶梯只对**超过阈值的部分**重新计价,不是把整段按高价算。理由是可叠加性:
缓存读取同时享受缓存折扣**和**长上下文阶梯价,这与供应商的计费方式一致。
混合平均必须二选一,选错就正好低估「缓存命中的长请求」——而这恰是缓存
存在的意义所在(本部署缓存命中率 88%)。
超出部分按缓存/新鲜的比例拆分,不重不漏。
## DSL 两种写法
供应商两种发布方式都支持:绝对费率(OpenAI 报 \$3.00/M past 200k)和
倍数(DeepSeek 报 2x past 128k)。两者都缺必须报错 —— 一个没有费率的阶梯
会编译成一条「看起来处理了长上下文、实际没处理」的规则,静默少算且无迹可寻。
两者都给时以绝对费率为准,因为那是供应商真正报出的数字。
## 判据
Lua 侧两条(边际性、与缓存折扣叠加)+ Go 侧一条(两种写法与拒绝规则)。
变异自证三次:整段按高价 / 缓存部分不叠加折扣 / 缓存部分不计阶梯,
分别命中 0.40 vs 0.272、0.165 vs 0.2325、0.09 vs 0.2325。
规则编辑器加了三个输入(阈值 + 绝对费率 + 倍数),三项都留空 = 无阶梯 ——
与「阶梯费率为 0」刻意区分,所以是三个独立输入而不是从 0 推断是否存在阶梯。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit 314f4da8f2)
This commit is contained in:
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
"<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>";
|
||||
@ -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 = {
|
||||
"<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