diff --git a/internal/billing/compile.go b/internal/billing/compile.go
index 474e846..242df46 100644
--- a/internal/billing/compile.go
+++ b/internal/billing/compile.go
@@ -195,6 +195,18 @@ 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 rule.Peak != nil {
entry["peak"] = compilePeak(rule.Peak)
}
diff --git a/internal/billing/wildcard_test.go b/internal/billing/wildcard_test.go
index ac25fb3..a0a863c 100644
--- a/internal/billing/wildcard_test.go
+++ b/internal/billing/wildcard_test.go
@@ -86,3 +86,64 @@ 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")
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 2b1f721..d4e7369 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -453,6 +453,16 @@ 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"`
}
// BillingPeak declares peak-hour pricing.
diff --git a/internal/lua/billing_ui_test.go b/internal/lua/billing_ui_test.go
index 9a81140..092314c 100644
--- a/internal/lua/billing_ui_test.go
+++ b/internal/lua/billing_ui_test.go
@@ -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)
+ }
+}
diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua
index 40da2f2..5d48b39 100644
--- a/internal/lua/plugins/billing.lua
+++ b/internal/lua/plugins/billing.lua
@@ -1031,7 +1031,9 @@ plugin.ui = {
monthly: "Monthly fee", peakMult: "Peak multiplier",
peakHours: "Peak hours (UTC)", peakDays: "Peak days (UTC 1=Mon)",
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.",
noRules: "No rules in this profile",
warnNoSource: "matches no configured source",
@@ -1053,7 +1055,9 @@ plugin.ui = {
monthly: "月费", peakMult: "峰段倍数",
peakHours: "峰段小时 (UTC)", peakDays: "峰段星期 (UTC 1=周一)",
models: "按模型价格", addModel: "+ 模型",
- mFree: "free 免费", mToken: "token 按量",
+
+ reasoning: "思考 $/M",
+ reasoningHint: "留空=思考 token 按输出价计(OpenAI/DeepSeek/Anthropic 都这样)。只有供应商对思考单独定价时才填。", mFree: "free 免费", mToken: "token 按量",
mSub: "subscription 订阅", mUnpriced: "unpriced 只统计",
noProfile: "还没有方案 —— 新建一个开始定价。",
noRules: "该方案暂无规则",
@@ -1151,6 +1155,12 @@ plugin.ui = {
"" +
"" +
+ // 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.
+ "" +
"" +
"";
@@ -1227,6 +1237,20 @@ 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;
+ }
});
if (bad) throw new Error(T.needPrices + ": " + bad);
if (Object.keys(models).length) out.models = models;
@@ -1417,6 +1441,8 @@ plugin.ui = {
"" +
"" +
"" +
+ "" +
"";
box.appendChild(row);
};