mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 20:27:23 +00:00
feat(billing): reasoning 价接入 DSL 与规则编辑器
上一提交把链路打通后,reasoning_price 只能靠手改 prices 生效 —— 而 prices 是
编译产物,不是人能编辑的东西。这条补上让它真正可用:
- BillingToken 加 reasoning 字段(USD/百万,与 prompt/completion 同单位)
- compile 只在规则显式声明时写 reasoning_price,缺省时字段不出现
- 规则编辑器的模型行加「思考 $/M」输入框,含新增行的模板
缺省时「不出现」是刻意的:写 0 会被 billing 插件读成「思考免费」,
每个来自该模型的请求都会静默少算。判据专门钉住这一点,变异成「缺省写 0」
即变红。
输入框 placeholder 写明「留空=按输出价计(OpenAI/DeepSeek/Anthropic 都这样)」,
因为一个没有解释的空输入框会被当成「必填但我不知道填什么」。
判据还要求 m-r 出现两次 —— 只改已有行的模板而不改「+ 模型」新建的行,
会让新加的模型在下一次保存时静默丢掉这个价。
生产验证:PUT 一个 reasoning: "1.80" 的规则,编译产物里出现
reasoning_price: 1.8000000000000001e-06(USD/token)。测完已把生产配置恢复为
6 条规则(备份 config.yaml.bak-billing-20261004-122151 为基准重建,
20 个源数量核对一致)。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit b93799a919)
This commit is contained in:
@ -195,6 +195,18 @@ func applyRule(prices map[string]interface{}, rule *config.BillingRule, srcName
|
|||||||
if t.CacheDiscount != nil {
|
if t.CacheDiscount != nil {
|
||||||
entry["cache_discount"] = *t.CacheDiscount
|
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 rule.Peak != nil {
|
if rule.Peak != nil {
|
||||||
entry["peak"] = compilePeak(rule.Peak)
|
entry["peak"] = compilePeak(rule.Peak)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,3 +86,64 @@ func TestSpecificURLTokenRuleStillMarksItsSource(t *testing.T) {
|
|||||||
t.Error("an unrelated source was marked priced by a specific-url rule")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -453,6 +453,16 @@ type BillingToken struct {
|
|||||||
Prompt string `yaml:"prompt" json:"prompt"` // USD per million tokens, e.g. "0.15"
|
Prompt string `yaml:"prompt" json:"prompt"` // USD per million tokens, e.g. "0.15"
|
||||||
Completion string `yaml:"completion" json:"completion"` // USD per million tokens
|
Completion string `yaml:"completion" json:"completion"` // USD per million tokens
|
||||||
CacheDiscount *float64 `yaml:"cache_discount,omitempty" json:"cache_discount,omitempty"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BillingPeak declares peak-hour pricing.
|
// BillingPeak declares peak-hour pricing.
|
||||||
|
|||||||
@ -438,3 +438,30 @@ func TestBillingUIShowsRecentUnpriced(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1031,7 +1031,9 @@ plugin.ui = {
|
|||||||
monthly: "Monthly fee", peakMult: "Peak multiplier",
|
monthly: "Monthly fee", peakMult: "Peak multiplier",
|
||||||
peakHours: "Peak hours (UTC)", peakDays: "Peak days (UTC 1=Mon)",
|
peakHours: "Peak hours (UTC)", peakDays: "Peak days (UTC 1=Mon)",
|
||||||
models: "Per-model prices", addModel: "+ model",
|
models: "Per-model prices", addModel: "+ model",
|
||||||
mFree: "free", mToken: "token", mSub: "subscription", mUnpriced: "unpriced",
|
|
||||||
|
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",
|
||||||
noProfile: "No profile yet — create one to start pricing.",
|
noProfile: "No profile yet — create one to start pricing.",
|
||||||
noRules: "No rules in this profile",
|
noRules: "No rules in this profile",
|
||||||
warnNoSource: "matches no configured source",
|
warnNoSource: "matches no configured source",
|
||||||
@ -1053,7 +1055,9 @@ plugin.ui = {
|
|||||||
monthly: "月费", peakMult: "峰段倍数",
|
monthly: "月费", peakMult: "峰段倍数",
|
||||||
peakHours: "峰段小时 (UTC)", peakDays: "峰段星期 (UTC 1=周一)",
|
peakHours: "峰段小时 (UTC)", peakDays: "峰段星期 (UTC 1=周一)",
|
||||||
models: "按模型价格", addModel: "+ 模型",
|
models: "按模型价格", addModel: "+ 模型",
|
||||||
mFree: "free 免费", mToken: "token 按量",
|
|
||||||
|
reasoning: "思考 $/M",
|
||||||
|
reasoningHint: "留空=思考 token 按输出价计(OpenAI/DeepSeek/Anthropic 都这样)。只有供应商对思考单独定价时才填。", mFree: "free 免费", mToken: "token 按量",
|
||||||
mSub: "subscription 订阅", mUnpriced: "unpriced 只统计",
|
mSub: "subscription 订阅", mUnpriced: "unpriced 只统计",
|
||||||
noProfile: "还没有方案 —— 新建一个开始定价。",
|
noProfile: "还没有方案 —— 新建一个开始定价。",
|
||||||
noRules: "该方案暂无规则",
|
noRules: "该方案暂无规则",
|
||||||
@ -1151,6 +1155,12 @@ plugin.ui = {
|
|||||||
"<input class='m-name' value='" + esc(m) + "' style='flex:1 1 90px;min-width:0'>" +
|
"<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) +
|
"<input class='m-p' value='" + esc(t.prompt == null ? "" : t.prompt) +
|
||||||
"' placeholder='" + esc(T.prompt) + "' style='flex:0 1 62px;min-width:0'>" +
|
"' 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'>" +
|
||||||
"<input class='m-c' value='" + esc(t.completion == null ? "" : t.completion) +
|
"<input class='m-c' value='" + esc(t.completion == null ? "" : t.completion) +
|
||||||
"' placeholder='" + esc(T.completion) + "' style='flex:0 1 62px;min-width:0'>" +
|
"' 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>";
|
"<button class='ghost small m-del' title='" + esc(T.delRule) + "' style='flex:0 0 auto'>×</button></div>";
|
||||||
@ -1227,6 +1237,20 @@ plugin.ui = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
models[n] = { prompt: pv, completion: cv };
|
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;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (bad) throw new Error(T.needPrices + ": " + bad);
|
if (bad) throw new Error(T.needPrices + ": " + bad);
|
||||||
if (Object.keys(models).length) out.models = models;
|
if (Object.keys(models).length) out.models = models;
|
||||||
@ -1417,6 +1441,8 @@ plugin.ui = {
|
|||||||
"<input class='m-name' placeholder='model' style='flex:1;min-width:80px'>" +
|
"<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-p' placeholder='" + esc(T.prompt) + "' style='width:96px'>" +
|
||||||
"<input class='m-c' placeholder='" + esc(T.completion) + "' 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'>" +
|
||||||
"<button class='ghost small m-del'>×</button>";
|
"<button class='ghost small m-del'>×</button>";
|
||||||
box.appendChild(row);
|
box.appendChild(row);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user