merge(main-v2): integrate billing and task controls

This commit is contained in:
SivanCola committed 2026-08-11 19:38:08 +08:00
commit be91b4ecdc
94 files changed
+5091 -1041

No files matched your search

+93 -36
View File
@@ -2273,10 +2273,11 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
newSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
sharedHost := a.lookupSharedHost(snap.sharedHostKey)
newCtrl, err := boot.Build(a.bootContext(), boot.Options{
Model: snap.model,
RequireKey: false,
AutoPricingCurrency: a.desktopAutoPricingCurrency(),
StatsSource: "desktop", TaskStore: a.taskStore(), OnConfigLoadWarnings: a.configLoadWarningsHandler(),
Model: snap.model,
RequireKey: false,
StatsSource: "desktop",
TaskStore: a.taskStore(),
OnConfigLoadWarnings: a.configLoadWarningsHandler(),
Sink: newSink,
WorkspaceRoot: snap.workspaceRoot,
SessionDir: sessionDirForSnapshot(snap),
@@ -4165,11 +4166,7 @@ func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string
tab.Ready = true
clearTabStartupError(tab)
tab.ActivityStatus = ""
tab.telemMu.Lock()
tab.readTelemetry = append([]readFileRecord(nil), candidate.telemetry.ReadFiles...)
tab.usageTelemetry = cloneSessionUsageStats(candidate.telemetry.Usage)
tab.telemetrySessionKey = sessionRuntimeKey(sessionPath)
tab.telemMu.Unlock()
tab.replaceTelemetry(candidate.telemetry, sessionRuntimeKey(sessionPath))
if tab.sink != nil {
tab.sink.setBinding(tab.ID, a)
tab.sink.setContext(a.ctx)
@@ -4372,10 +4369,11 @@ func (a *App) buildSessionRebindCandidate(
ownsSharedHostRef = true
}
ctrl, err := boot.Build(a.bootContext(), boot.Options{
Model: model,
RequireKey: false,
AutoPricingCurrency: a.desktopAutoPricingCurrency(),
StatsSource: "desktop", TaskStore: a.taskStore(), OnConfigLoadWarnings: a.configLoadWarningsHandler(),
Model: model,
RequireKey: false,
StatsSource: "desktop",
TaskStore: a.taskStore(),
OnConfigLoadWarnings: a.configLoadWarningsHandler(),
Sink: a.desktopControllerSink(sink, cfg.Notifications),
WorkspaceRoot: root,
SessionDir: sessionDir,
@@ -6535,13 +6533,15 @@ func (a *App) ContextUsageForTab(tabID string) ContextInfo {
tab.syncTelemetryToSession(sp)
}
}
snap = tab.telemetrySnapshot()
snap = tab.displayTelemetrySnapshot()
info.SessionTokens = snap.Usage.TotalTokens
info.SessionCost = snap.Usage.SessionCost
info.SessionCurrency = snap.Usage.SessionCurrency
info.CacheHitTokens = snap.Usage.CacheHitTokens
info.CacheMissTokens = snap.Usage.CacheMissTokens
info.Estimated = snap.Usage.Estimated
info.SessionCostComplete = snap.Usage.SessionCostComplete
info.SessionCostQuote = snap.Usage.SessionCostQuote
info.Sources = snap.Usage.Sources
}
if ctrl == nil {
@@ -6558,13 +6558,24 @@ func (a *App) ContextUsageForTab(tabID string) ContextInfo {
}
// BalanceInfo is the wallet-balance readout for the status bar. Available is true
// only when a balance was fetched; Display is the formatted amount (e.g. "¥110.00")
// only when a balance was fetched; Display is the exact formatted amount (e.g.
// "¥110.00")
// and is "" when the active provider declares no balance_url — the frontend then
// omits the readout. Err carries a fetch failure for an optional tooltip.
// Wallet balances are displayed in their original currencies; no conversion
// or cross-currency sum is performed.
type BalanceInfo struct {
Available bool `json:"available"`
Display string `json:"display"`
Err string `json:"err,omitempty"`
Available bool `json:"available"`
Display string `json:"display"`
Detail string `json:"detail,omitempty"` // per-wallet original balances
Complete bool `json:"complete"`
RateDate string `json:"rateDate,omitempty"`
Approx bool `json:"approx,omitempty"`
Currencies []string `json:"currencies,omitempty"`
PrimaryCurrency string `json:"primaryCurrency,omitempty"`
CostDisplayCurrency string `json:"costDisplayCurrency,omitempty"`
MultiCurrency bool `json:"multiCurrency,omitempty"`
Err string `json:"err,omitempty"`
}
// Balance queries the active provider's wallet balance (a network call). It
@@ -6577,7 +6588,7 @@ func (a *App) Balance() BalanceInfo {
func (a *App) BalanceForTab(tabID string) BalanceInfo {
currency := a.balanceDisplayCurrency()
ctrl := a.ctrlByTabID(tabID)
tab, ctrl, generation := a.balanceRequestTarget(tabID)
if ctrl == nil {
return BalanceInfo{}
}
@@ -6588,18 +6599,58 @@ func (a *App) BalanceForTab(tabID string) BalanceInfo {
if b == nil {
return BalanceInfo{} // provider declares no balance endpoint
}
return BalanceInfo{Available: true, Display: b.DisplayForCurrency(currency)}
display := b.DisplayForCurrency(currency)
currencies := b.Currencies()
primary := b.PrimaryCurrency()
a.applyBalanceDisplayHint(tabID, tab, ctrl, currency, primary, generation)
detail := balanceDetail(b)
return BalanceInfo{
Available: true,
Display: display,
Detail: detail,
Complete: true,
Currencies: currencies,
PrimaryCurrency: primary,
CostDisplayCurrency: firstNonEmptyString(currency, primary),
MultiCurrency: len(currencies) > 1,
}
}
// balanceDisplayCurrency mirrors the effective pricing currency selected in
// Settings. Auto resolves through the current desktop locale, matching the
// controller rebuild path used by cost telemetry.
func balanceDetail(b *billing.Balance) string {
if b == nil || len(b.Infos) == 0 {
return ""
}
parts := make([]string, 0, len(b.Infos))
for _, info := range b.Infos {
cur := strings.ToUpper(strings.TrimSpace(info.Currency))
if cur == "" {
cur = "UNKNOWN"
}
parts = append(parts, cur+" "+strings.TrimSpace(info.TotalBalance))
}
return strings.Join(parts, "\n")
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
// balanceDisplayCurrency resolves only an explicit global display currency.
// Automatic mode leaves the wallet in its original currency.
func (a *App) balanceDisplayCurrency() string {
cfg, _, err := a.loadDesktopUserConfigForView()
if err != nil {
return ""
}
return a.desktopEffectivePricingCurrency(cfg)
if pref := cfg.DisplayCurrencyPref(); pref != "" {
return pref
}
return cfg.ExplicitDisplayCurrency()
}
// JobView is one running background job (bash/task started with
@@ -9933,10 +9984,11 @@ func (a *App) SetModelForTab(tabID, name string) (retErr error) {
stageStarted = time.Now()
newCtrl, err := boot.Build(a.bootContext(), boot.Options{
Model: name,
RequireKey: false,
AutoPricingCurrency: a.desktopAutoPricingCurrency(),
StatsSource: "desktop", TaskStore: a.taskStore(), OnConfigLoadWarnings: a.configLoadWarningsHandler(),
Model: name,
RequireKey: false,
StatsSource: "desktop",
TaskStore: a.taskStore(),
OnConfigLoadWarnings: a.configLoadWarningsHandler(),
Sink: snap.sink,
WorkspaceRoot: snap.workspaceRoot,
SessionDir: sessionDirForSnapshot(snap),
@@ -10008,6 +10060,9 @@ func (a *App) SetModelForTab(tabID, name string) (retErr error) {
return fmt.Errorf("persist selected model: %w", err)
}
}
// A model switch changes the pricing context; discard the session-local
// automatic wallet hint and let the next balance response rebind it.
tab.clearRuntimeDisplayCurrency()
a.notifyTabRuntimeRebuilt(tab)
timing.SwapAndPersist = time.Since(stageStarted)
return nil
@@ -10116,10 +10171,11 @@ func (a *App) SetEffortForTab(tabID, level string) error {
}
sharedHost := a.lookupSharedHost(snap.sharedHostKey)
newCtrl, err := boot.Build(a.bootContext(), boot.Options{
Model: modelRef,
RequireKey: false,
AutoPricingCurrency: a.desktopAutoPricingCurrency(),
StatsSource: "desktop", TaskStore: a.taskStore(), OnConfigLoadWarnings: a.configLoadWarningsHandler(),
Model: modelRef,
RequireKey: false,
StatsSource: "desktop",
TaskStore: a.taskStore(),
OnConfigLoadWarnings: a.configLoadWarningsHandler(),
Sink: snap.sink,
WorkspaceRoot: snap.workspaceRoot,
SessionDir: sessionDirForSnapshot(snap),
@@ -10256,10 +10312,11 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
}
sharedHost := a.lookupSharedHost(snap.sharedHostKey)
newCtrl, err := boot.Build(a.bootContext(), boot.Options{
Model: modelRef,
RequireKey: false,
AutoPricingCurrency: a.desktopAutoPricingCurrency(),
StatsSource: "desktop", TaskStore: a.taskStore(), OnConfigLoadWarnings: a.configLoadWarningsHandler(),
Model: modelRef,
RequireKey: false,
StatsSource: "desktop",
TaskStore: a.taskStore(),
OnConfigLoadWarnings: a.configLoadWarningsHandler(),
Sink: snap.sink,
WorkspaceRoot: snap.workspaceRoot,
SessionDir: sessionDirForSnapshot(snap),
+6 -2
View File
@@ -5182,8 +5182,12 @@ func TestBalanceForTabUsesDesktopPricingCurrency(t *testing.T) {
app.setTestCtrl(ctrl, "deepseek/deepseek-v4-flash")
got := app.BalanceForTab("test")
if !got.Available || got.Display != "$9.82" || got.Err != "" {
t.Fatalf("USD desktop balance = %+v, want available $9.82", got)
// Prefer the matching USD wallet exactly; no FX approximation is used.
if !got.Available || got.Err != "" {
t.Fatalf("USD desktop balance = %+v, want available", got)
}
if !strings.Contains(got.Display, "9.82") && !strings.Contains(got.Display, "$9.82") {
t.Fatalf("USD desktop balance display = %q, want USD 9.82", got.Display)
}
}
+174
View File
@@ -0,0 +1,174 @@
package main
import (
"context"
"errors"
"testing"
"time"
"reasonix/internal/billing"
"reasonix/internal/control"
)
type billingRuntimeController struct {
control.SessionAPI
status control.RuntimeStatus
started chan struct{}
release chan struct{}
balance *billing.Balance
err error
}
func (c *billingRuntimeController) RuntimeStatus() control.RuntimeStatus { return c.status }
func (c *billingRuntimeController) SessionPath() string { return "" }
func (c *billingRuntimeController) Balance(ctx context.Context) (*billing.Balance, error) {
if c.started != nil {
close(c.started)
}
if c.release != nil {
select {
case <-c.release:
case <-ctx.Done():
return nil, ctx.Err()
}
}
return c.balance, c.err
}
func TestSetDesktopCurrencyRebindsLedgerWithoutControllerRebuild(t *testing.T) {
isolateDesktopUserDirs(t)
ctrl := &billingRuntimeController{status: control.RuntimeStatus{Running: true}}
ledger := billing.NewLedger()
ledger.Add(billing.CostQuote{
Original: billing.Money{Amount: "1", Currency: "USD"},
Valuations: map[string]billing.Valuation{
"USD": {Money: billing.Money{Amount: "1", Currency: "USD"}, Basis: billing.BasisIdentity},
"CNY": {Money: billing.Money{Amount: "7", Currency: "CNY"}, Basis: billing.BasisOfficialTable},
},
Selected: &billing.Money{Amount: "1", Currency: "USD"},
Estimated: true,
CostComplete: true,
DisplayComplete: true,
Complete: true,
DisplayStatus: billing.DisplayStatusMatched,
ModelRef: "deepseek-flash/deepseek-v4-flash",
}, billing.UsageTokens{PromptTokens: 1_000_000}, nowUTC())
tab := &WorkspaceTab{
ID: "billing", Ctrl: ctrl,
usageTelemetry: sessionUsageStats{CostLedger: ledger, SessionCost: 1, SessionCurrency: "USD", SessionCostComplete: true},
}
app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID}
if err := app.SetDesktopCurrency("CNY"); err != nil {
t.Fatalf("SetDesktopCurrency during active work: %v", err)
}
if tab.Ctrl != ctrl {
t.Fatal("display-only currency change replaced the controller")
}
quote := tab.telemetrySnapshot().Usage.SessionCostQuote
if quote == nil || quote.Selected == nil || quote.Selected.Currency != "CNY" || quote.Selected.Amount != "7" {
t.Fatalf("rebound quote = %+v, want selected CNY 7", quote)
}
}
func TestBalanceForTabRejectsPreviousSessionResponse(t *testing.T) {
isolateDesktopUserDirs(t)
ctrl := &billingRuntimeController{
started: make(chan struct{}),
release: make(chan struct{}),
balance: &billing.Balance{Available: true, Infos: []billing.Info{{Currency: "CNY", TotalBalance: "70.16"}}},
}
tab := &WorkspaceTab{ID: "billing", Ctrl: ctrl}
tab.resetTelemetry("old-session.jsonl")
app := &App{
ctx: context.Background(), tabs: map[string]*WorkspaceTab{tab.ID: tab},
tabOrder: []string{tab.ID}, activeTabID: tab.ID,
}
done := make(chan BalanceInfo, 1)
go func() { done <- app.BalanceForTab(tab.ID) }()
<-ctrl.started
tab.resetTelemetry("new-session.jsonl")
close(ctrl.release)
<-done
tab.telemMu.Lock()
display := tab.runtimeCostDisplayCurrency
tab.telemMu.Unlock()
if display != "" {
t.Fatalf("previous-session balance rebound new session to %q", display)
}
}
func TestBalanceForTabRejectsPreviousControllerResponse(t *testing.T) {
isolateDesktopUserDirs(t)
ctrl := &billingRuntimeController{
started: make(chan struct{}), release: make(chan struct{}),
balance: &billing.Balance{Available: true, Infos: []billing.Info{{Currency: "CNY", TotalBalance: "70.16"}}},
}
tab := &WorkspaceTab{ID: "billing", Ctrl: ctrl}
app := &App{ctx: context.Background(), tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID}
done := make(chan BalanceInfo, 1)
go func() { done <- app.BalanceForTab(tab.ID) }()
<-ctrl.started
app.mu.Lock()
tab.Ctrl = &billingRuntimeController{}
app.mu.Unlock()
close(ctrl.release)
<-done
tab.telemMu.Lock()
display := tab.runtimeCostDisplayCurrency
tab.telemMu.Unlock()
if display != "" {
t.Fatalf("previous-controller balance rebound current controller to %q", display)
}
}
func TestBalanceForTabRejectsResponseAfterExplicitCurrencyChange(t *testing.T) {
isolateDesktopUserDirs(t)
ctrl := &billingRuntimeController{
started: make(chan struct{}), release: make(chan struct{}),
balance: &billing.Balance{Available: true, Infos: []billing.Info{{Currency: "CNY", TotalBalance: "70.16"}}},
}
tab := &WorkspaceTab{ID: "billing", Ctrl: ctrl}
app := &App{ctx: context.Background(), tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID}
done := make(chan BalanceInfo, 1)
go func() { done <- app.BalanceForTab(tab.ID) }()
<-ctrl.started
if err := app.SetDesktopCurrency("USD"); err != nil {
t.Fatalf("SetDesktopCurrency: %v", err)
}
close(ctrl.release)
<-done
tab.telemMu.Lock()
display := tab.runtimeCostDisplayCurrency
tab.telemMu.Unlock()
if display != "" {
t.Fatalf("auto balance response overrode explicit USD with %q", display)
}
}
func TestBalanceForTabFailureDoesNotChangeDisplayBinding(t *testing.T) {
isolateDesktopUserDirs(t)
ctrl := &billingRuntimeController{err: errors.New("balance unavailable")}
tab := &WorkspaceTab{ID: "billing", Ctrl: ctrl}
app := &App{ctx: context.Background(), tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID}
got := app.BalanceForTab(tab.ID)
if got.Err == "" {
t.Fatal("BalanceForTab error was not reported")
}
tab.telemMu.Lock()
display := tab.runtimeCostDisplayCurrency
tab.telemMu.Unlock()
if display != "" {
t.Fatalf("failed balance rebound display to %q", display)
}
}
func nowUTC() time.Time { return time.Now().UTC() }
+14 -11
View File
@@ -4,22 +4,25 @@ import (
"time"
"reasonix/internal/agent"
"reasonix/internal/billing"
)
// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used
// and Window both zero means no context-window data yet.
type ContextInfo struct {
Used int `json:"used"`
Window int `json:"window"`
SessionTokens int `json:"sessionTokens"`
CompactRatio float64 `json:"compactRatio,omitempty"`
SessionCost float64 `json:"sessionCost,omitempty"`
SessionCurrency string `json:"sessionCurrency,omitempty"`
CacheHitTokens int `json:"cacheHitTokens,omitempty"`
CacheMissTokens int `json:"cacheMissTokens,omitempty"`
Estimated bool `json:"estimated,omitempty"`
Sources map[string]usageSourceStats `json:"sources,omitempty"`
Maintenance *ContextMaintenanceInfo `json:"maintenance,omitempty"`
Used int `json:"used"`
Window int `json:"window"`
SessionTokens int `json:"sessionTokens"`
CompactRatio float64 `json:"compactRatio,omitempty"`
SessionCost float64 `json:"sessionCost,omitempty"`
SessionCurrency string `json:"sessionCurrency,omitempty"`
CacheHitTokens int `json:"cacheHitTokens,omitempty"`
CacheMissTokens int `json:"cacheMissTokens,omitempty"`
Estimated bool `json:"estimated,omitempty"`
SessionCostComplete bool `json:"sessionCostComplete,omitempty"`
SessionCostQuote *billing.CostQuote `json:"sessionCostQuote,omitempty"`
Sources map[string]usageSourceStats `json:"sources,omitempty"`
Maintenance *ContextMaintenanceInfo `json:"maintenance,omitempty"`
}
// ContextMaintenanceInfo is the Wails-safe current-view snapshot. Optional
@@ -57,7 +57,7 @@ const localeChunks = readdirSync(resolve(distDir, "assets"))
.map((name) => resolve(distDir, "assets", name));
console.log("\nbundle budgets");
assertBudget("initial JavaScript gzip", initialJSGzip, 400.5 * 1024);
assertBudget("initial JavaScript gzip", initialJSGzip, 400.9 * 1024);
assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 280 * 1024);
assertBudget("render-blocking CSS gzip", initialCSSGzip, 4 * 1024);
// Extension surfaces, Task Monitor, and compact decision receipts share the
@@ -69,11 +69,8 @@ if (localeChunks.length !== 2) {
}
for (const path of localeChunks) {
const name = basename(path);
// Task Monitor, Extension UI, Storage & paths, and shell execution cards
// add their own labels. Reasoning display controls, status bar metrics,
// capability busy guidance, indexed history, Task Center, and inbox recovery
// remain within the existing narrow allowances while raw startup stays
// controlled.
// Task Monitor, billing, indexed history, Task Center, Extension UI, and
// runtime controls add localized copy. Keep both dictionaries bounded.
const budget = name.startsWith("zh-TW-") ? 55.5 * 1024 : 54.5 * 1024;
assertBudget(`${name} gzip`, gzipBytes(path), budget);
}
@@ -83,10 +80,8 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
// Native Web Animations and frame-batched scrolling avoid an eager animation
// runtime. Goal request observability plus transcript scroll arbitration,
// logical selection state/DOM adapters, native input-session ownership,
// durable inbox recovery controls, measurement invalidation and deferred tail
// replay, startup config-warning delivery, catalog project shell,
// indexed-history bridge, and cross-project Task Center add small
// always-available contracts. Keep the raw allowance ratcheted while gzip
// stays flat.
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_260 * 1024);
// durable inbox recovery, indexed catalogs, Task Center, and structured
// billing states add small always-available contracts. Keep the raw allowance
// ratcheted while gzip startup budgets stay flat.
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_263.5 * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
+7 -37
View File
@@ -2,7 +2,6 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type
import { ShellExpandProvider, useShellExpand } from "./lib/shellExpand";
import {
Activity,
CircleHelp,
Command,
Copy as RestoreIcon,
Download,
@@ -4737,33 +4736,15 @@ export default function App() {
{!sidebarCreation && shouldMountExternalOpener(activeTab, Boolean(sidebarImDetailConnection)) && activeTab && (
<ExternalOpener key={activeTab.id} tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
)}
<Tooltip label={t("shortcuts.cheatsheetTitle")}>
<Tooltip label={t("summary.session")}>
<button
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
className={`topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility${tasksOpen ? " topicbar__action-btn--active" : ""}`}
type="button"
aria-label={t("shortcuts.cheatsheetTitle")}
onClick={() => {
closeTransientOverlays();
setSettingsFocus(null);
setSettingsTarget("shortcuts");
}}
aria-label={t("summary.session")}
aria-expanded={Boolean(tasksOpen)}
onClick={() => setTasksOpen((open) => open ? false : "session")}
>
<CircleHelp size={14} />
</button>
</Tooltip>
<Tooltip label={t("topicBar.command")}>
<button
className={
sidebarCreation
? "topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
: "topicbar__action-btn topicbar__action-btn--label topicbar__action-btn--accent"
}
type="button"
aria-label={t("topicBar.command")}
onClick={() => void openPalette()}
>
<Command size={14} />
{!sidebarCreation && <span>{t("topicBar.command")}</span>}
<Activity size={14} />
</button>
</Tooltip>
{(sidebarCreation || workbenchChromeHidden) && (
@@ -4784,19 +4765,8 @@ export default function App() {
</button>
</Tooltip>
)}
<Tooltip label="Session summary">
<button
className={`topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility${tasksOpen ? " topicbar__action-btn--active" : ""}`}
type="button"
aria-label="Session summary"
aria-expanded={Boolean(tasksOpen)}
onClick={() => setTasksOpen((open) => open ? false : "session")}
>
<Activity size={14} />
</button>
</Tooltip>
{tasksOpen && (
<div className="taskmonitor-popover" role="dialog" aria-label="Session summary">
<div className="taskmonitor-popover" role="dialog" aria-label={t("summary.session")}>
<Suspense fallback={null}>
<TaskMonitorPanel
key={`${activeTab?.id || activeTabId || "none"}:${activeTab?.workspaceRoot || "global"}:${activeTab?.sessionPath || ""}:${tasksOpen}`}
@@ -193,7 +193,11 @@ const infoCost = contextCostDisplay({
sessionCurrency: "¥",
usage: { cost: 0, costUsd: 0, currency: "¥" },
});
eq(infoCost, { amount: 0.1759, currency: "$" }, "panel cost keeps the panel currency instead of state default");
eq(
infoCost,
{ amount: 0.1759, currency: "$", estimated: true, complete: true, labelKind: "estimated" },
"panel cost keeps the panel currency instead of state default",
);
const singleRequestOnly = contextCostDisplay({
info: { sessionCost: 0, sessionCurrency: "", sessionCostUsd: 0 },
sessionCost: 0,
@@ -202,7 +206,7 @@ const singleRequestOnly = contextCostDisplay({
});
eq(
singleRequestOnly,
{ amount: 0, currency: "¥" },
{ amount: 0, currency: "¥", estimated: true, complete: false, labelKind: "unavailable" },
"a single request's cost never renders under the session-cost label",
);
const localAccumulated = contextCostDisplay({
@@ -211,7 +215,32 @@ const localAccumulated = contextCostDisplay({
sessionCurrency: "¥",
usage: { cost: 0.42, costUsd: 0.42, currency: "$" },
});
eq(localAccumulated, { amount: 1.5, currency: "¥" }, "locally accumulated session cost still renders");
eq(
localAccumulated,
{ amount: 1.5, currency: "¥", estimated: true, complete: true, labelKind: "estimated" },
"locally accumulated session cost still renders",
);
const incompleteStructured = contextCostDisplay({
info: {
sessionCost: 1,
sessionCurrency: "USD",
sessionCostComplete: false,
sessionCostQuote: {
original: { amount: "7", currency: "CNY" },
selected: { amount: "1", currency: "USD" },
estimated: true,
complete: false,
incompleteReason: "incomplete_valuations",
},
},
sessionCost: 1,
sessionCurrency: "USD",
});
eq(
incompleteStructured,
{ amount: 0, currency: "USD", estimated: true, complete: false, labelKind: "unavailable" },
"an incomplete structured quote never renders a partial selected total",
);
console.log("\ncontext panel session cache scope");
@@ -42,6 +42,22 @@ eq(declaration(settingsModal, "height"), "min(960px, calc(100vh - 80px))", "wide
const settingsCenter = ruleBlock(panelStyles, ".settings-center");
eq(declaration(settingsCenter, "grid-template-columns"), "clamp(220px, 20.5vw, 304px) minmax(0, 1fr)", "settings navigation remains readable without consuming the content pane");
const generalPage = ruleBlock(panelStyles, ".settings-page--general");
eq(declaration(generalPage, "container"), "settings-general / inline-size", "general settings respond to their available content width");
const generalContainerStart = panelStyles.indexOf("@container settings-general (max-width: 620px)");
const generalFallbackStart = panelStyles.indexOf("@media (max-width: 980px)", generalContainerStart);
const generalContainer = panelStyles.slice(generalContainerStart, generalFallbackStart);
const compactGeneralField = ruleBlock(generalContainer, ".settings-page--general .settings-field");
eq(declaration(compactGeneralField, "grid-template-columns"), "1fr", "general settings stack before controls overflow their content pane");
eq(declaration(compactGeneralField, "gap"), "10px", "stacked general settings keep compact vertical spacing");
const compactGeneralControl = ruleBlock(generalContainer, ".settings-page--general .settings-field__control");
eq(declaration(compactGeneralControl, "min-width"), "0", "compact general controls may shrink within the content pane");
const fallbackPanel = panelStyles.slice(generalFallbackStart, panelStyles.indexOf("@media (max-width: 760px)", generalFallbackStart));
const fallbackGeneralField = ruleBlock(fallbackPanel, ".settings-page--general .settings-field");
eq(declaration(fallbackGeneralField, "grid-template-columns"), "1fr", "980px fallback stacks general settings without container queries");
const generalSectionBoundary = ruleBlock(panelStyles, ".settings-page--general > .settings-section:not(:last-child) .settings-section__body");
eq(
declaration(generalSectionBoundary, "border-bottom"),
@@ -157,7 +157,9 @@ ok(
stylesSource.includes(".settings-page--general .settings-section") &&
stylesSource.includes("grid-template-columns: minmax(260px, 1fr) max-content") &&
stylesSource.includes(".settings-field__copy--icon") &&
stylesSource.includes("@media (max-width: 900px)"),
stylesSource.includes("container: settings-general / inline-size") &&
stylesSource.includes("@container settings-general (max-width: 620px)") &&
stylesSource.includes("@media (max-width: 980px)"),
"General controls use the selected flat responsive section layout",
);
ok(
@@ -0,0 +1,19 @@
// Run: tsx src/__tests__/topicbar-controls.test.ts
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const testDir = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
assert.doesNotMatch(appSource, /t\("shortcuts\.cheatsheetTitle"\)|t\("topicBar\.command"\)/);
const taskSummaryControlIndex = appSource.indexOf('<Tooltip label={t("summary.session")}>');
const workspaceToggleIndex = appSource.indexOf('<Tooltip label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>');
assert.ok(taskSummaryControlIndex >= 0, "topic bar renders the localized Session summary control");
assert.ok(workspaceToggleIndex > taskSummaryControlIndex, "Session summary precedes the right-edge workspace toggle");
assert.ok(!appSource.includes('aria-label="Session summary"'), "Session summary does not use a hard-coded English label");
process.stdout.write("topicbar controls: 2 contracts passed\n");
@@ -117,24 +117,89 @@ export function contextCostDisplay({
sessionCurrency,
usage,
}: {
info?: Pick<ContextPanelInfo, "sessionCost" | "sessionCurrency" | "sessionCostUsd"> | null;
info?: Pick<ContextPanelInfo, "sessionCost" | "sessionCurrency" | "sessionCostUsd" | "sessionCostComplete" | "sessionCostEstimated" | "sessionBillingMode" | "sessionCostQuote"> | null;
sessionCost?: number;
sessionCurrency?: string;
usage?: Pick<WireUsage, "cost" | "costUsd" | "currency">;
}): { amount: number; currency?: string } {
// Session-scoped sources only: this value renders under the 会话费用 label,
// and falling back to a single request's usage.cost silently displayed one
// turn's spend as the whole session's. usage now contributes currency only.
usage?: Pick<WireUsage, "cost" | "costUsd" | "currency" | "currencyCode" | "costQuote">;
}): {
amount: number;
currency?: string;
estimated?: boolean;
complete?: boolean;
billingMode?: string;
labelKind?: "estimated" | "payg_equivalent" | "fallback" | "bucketed" | "unavailable";
} {
// Prefer structured session quote, then per-usage quote.
const quote = info?.sessionCostQuote || usage?.costQuote;
if (quote?.displayStatus === "bucketed" || quote?.aggregateMode === "currency_buckets") {
return {
amount: 0,
currency: undefined,
estimated: true,
complete: false,
labelKind: "bucketed",
};
}
const fallbackOriginal = quote?.displayStatus === "fallback_original";
if (!fallbackOriginal && (info?.sessionCostComplete === false || quote?.displayStatus === "unavailable" || quote?.costComplete === false)) {
return {
amount: 0,
currency: info?.sessionCurrency || sessionCurrency || usage?.currencyCode || usage?.currency,
estimated: true,
complete: false,
labelKind: "unavailable",
};
}
const selected = quote?.selected;
if (selected?.amount) {
const n = Number(selected.amount);
if (Number.isFinite(n) && n > 0) {
const mode = quote?.billingMode || info?.sessionBillingMode;
return {
amount: n,
currency: selected.currency || usage?.currencyCode || usage?.currency || info?.sessionCurrency,
estimated: quote?.estimated !== false,
complete: quote?.displayComplete !== false,
billingMode: mode,
labelKind: fallbackOriginal ? "fallback" : mode === "subscription_equivalent" ? "payg_equivalent" : "estimated",
};
}
}
// Session-scoped scalar fallbacks (legacy telemetry).
if (info?.sessionCost && info.sessionCost > 0) {
return { amount: info.sessionCost, currency: info.sessionCurrency || sessionCurrency || usage?.currency };
return {
amount: info.sessionCost,
currency: info.sessionCurrency || sessionCurrency || usage?.currencyCode || usage?.currency,
estimated: true,
complete: true,
labelKind: "estimated",
};
}
if (sessionCost && sessionCost > 0) {
return { amount: sessionCost, currency: sessionCurrency || info?.sessionCurrency || usage?.currency };
return {
amount: sessionCost,
currency: sessionCurrency || info?.sessionCurrency || usage?.currencyCode || usage?.currency,
estimated: true,
complete: true,
labelKind: "estimated",
};
}
if (info?.sessionCostUsd && info.sessionCostUsd > 0) {
return { amount: info.sessionCostUsd, currency: info.sessionCurrency || sessionCurrency || usage?.currency };
return {
amount: info.sessionCostUsd,
currency: info.sessionCurrency || sessionCurrency || usage?.currencyCode || usage?.currency,
estimated: true,
complete: true,
labelKind: "estimated",
};
}
return { amount: 0, currency: info?.sessionCurrency || sessionCurrency || usage?.currency };
return {
amount: 0,
currency: info?.sessionCurrency || sessionCurrency || usage?.currencyCode || usage?.currency,
estimated: true,
complete: false,
labelKind: "unavailable",
};
}
// contextSessionCache picks the session-cumulative cache hit/miss pair for the
@@ -450,7 +515,13 @@ export function ContextPanel({
const sessionEstimated = info?.sessionEstimated === true || context?.estimated === true;
const markEstimated = (value: string, estimated: boolean) => estimated && value !== "-" ? `≈${value}` : value;
const turnCostLabel = markEstimated(formatMoneyLocalized(turnCost, sessionCurrency, { locale, empty: "dash" }), turnEstimated);
const sessionCostLabel = markEstimated(formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" }), sessionEstimated);
const sessionCostLabel = cost.labelKind === "bucketed"
? t("context.sessionCostBucketed")
: cost.labelKind === "unavailable"
? t("context.sessionCostUnavailable")
: cost.labelKind === "fallback"
? `${markEstimated(formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" }), sessionEstimated)} (${t("context.sessionCostFallback")})`
: markEstimated(formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" }), sessionEstimated);
const totalTokensTitle = totalTokensMetric.exact === "-" ? "-" : t("context.tokensValue", { value: totalTokensMetric.exact });
const usedLabel = formatTokens(usedTokens);
const windowLabel = formatTokens(windowTokens);
@@ -116,9 +116,26 @@ export function ContextWindowRing({ enabled = true, context, tabId, turnCost, cu
const tokensToCompact = compactTokens > used ? compactTokens - used : 0;
const ringOffset = RING_C * (1 - usagePct / 100);
const elapsed = info?.elapsedMs && info.elapsedMs > 0 ? fmtDuration(info.elapsedMs, t) : undefined;
const sessionCost = info?.sessionCost && info.sessionCost > 0
? formatMoneyLocalized(info.sessionCost, info.sessionCurrency, { locale, empty: "dash" })
: undefined;
const quoteStatus = info?.sessionCostQuote?.displayStatus;
const sessionCostBucketed = quoteStatus === "bucketed" || info?.sessionCostQuote?.aggregateMode === "currency_buckets";
const sessionCostFallback = quoteStatus === "fallback_original";
const sessionCostComplete = (sessionCostFallback || info?.sessionCostComplete !== false) && quoteStatus !== "unavailable";
const sessionCostRaw = info?.sessionCostQuote?.selected
? Number(info.sessionCostQuote.selected.amount)
: info?.sessionCost;
const sessionCostCurrency = info?.sessionCostQuote?.selected?.currency || info?.sessionCurrency;
const sessionCost =
!sessionCostBucketed && sessionCostComplete && typeof sessionCostRaw === "number" && sessionCostRaw > 0
? `≈${formatMoneyLocalized(sessionCostRaw, sessionCostCurrency, { locale, empty: "dash" }).replace(/^≈/, "")}`
: undefined;
const sessionCostHint =
info?.sessionBillingMode === "subscription_equivalent"
? "payg_equivalent"
: sessionCostFallback
? "fallback_original"
: sessionCost
? "estimated"
: undefined;
const turnCostLabel = formatMoneyLocalized(turnCost, info?.sessionCurrency || currency, { locale, empty: "dash" });
return (
@@ -195,10 +212,28 @@ export function ContextWindowRing({ enabled = true, context, tabId, turnCost, cu
)}
{sessionCost && (
<div className="context-ring-popover__row">
<span className="context-ring-popover__label">{t("context.sessionCost")}</span>
<span className="context-ring-popover__label">
{sessionCostHint === "payg_equivalent"
? t("context.sessionCostPaygEquivalent")
: sessionCostHint === "fallback_original"
? t("context.sessionCostFallback")
: t("context.sessionCostEstimated")}
</span>
<span className="context-ring-popover__value">{sessionCost}</span>
</div>
)}
{!sessionCost && sessionCostBucketed && (
<div className="context-ring-popover__row">
<span className="context-ring-popover__label">{t("context.sessionCost")}</span>
<span className="context-ring-popover__value">{t("context.sessionCostBucketed")}</span>
</div>
)}
{!sessionCost && !sessionCostBucketed && info?.sessionCostComplete === false && (
<div className="context-ring-popover__row">
<span className="context-ring-popover__label">{t("context.sessionCostEstimated")}</span>
<span className="context-ring-popover__value">—</span>
</div>
)}
{balance?.available && balance.display && (
<div className="context-ring-popover__row">
<span className="context-ring-popover__label">{t("status.balanceLabel")}</span>
@@ -211,6 +211,10 @@
box-shadow: none;
}
.settings-page--general {
container: settings-general / inline-size;
}
.settings-page--general .settings-section__head {
margin-bottom: 0;
padding-bottom: 11px;
@@ -426,7 +430,23 @@
letter-spacing: -0.015em;
}
@media (max-width: 900px) {
@container settings-general (max-width: 620px) {
.settings-page--general .settings-field {
grid-template-columns: 1fr;
gap: 10px;
}
.settings-page--general .settings-field__control {
min-width: 0;
}
.settings-page--general .settings-field--stacked .settings-field__control {
width: 100%;
margin-left: 0;
}
}
@media (max-width: 980px) {
.settings-page--general .settings-field {
grid-template-columns: 1fr;
gap: 10px;
+18 -2
View File
@@ -250,7 +250,23 @@ export function StatusBar({
const turnLabel = formatTurnCount(sessionTurns, t);
const tokenLabel = markEstimated(formatTokenCount(sessionTokens), sessionEstimated);
const turnTokenLabel = markEstimated(formatTokenCount(turnTokens), turnEstimated);
const statusQuote = context.sessionCostQuote;
const statusBucketed = statusQuote?.displayStatus === "bucketed" || statusQuote?.aggregateMode === "currency_buckets";
const statusUnavailable = context.sessionCostComplete === false || statusQuote?.displayStatus === "unavailable" || statusQuote?.costComplete === false;
const statusSelectedAmount = statusQuote?.selected?.amount ? Number(statusQuote.selected.amount) : NaN;
const statusCostLabel = statusBucketed
? t("context.sessionCostBucketed")
: statusUnavailable
? "-"
: Number.isFinite(statusSelectedAmount) && statusSelectedAmount > 0
? markEstimated(formatMoneyLocalized(statusSelectedAmount, statusQuote?.selected?.currency || context.sessionCurrency || currency, { locale }), statusQuote?.estimated !== false)
: costLabel;
const balanceLabel = balance?.available && balance.display ? balance.display : "-";
const balanceTitle = balance?.available
? (balance.detail
? `${t("status.balanceTitle")}: ${balance.detail}`
: t("status.balanceTitle"))
: t("status.balanceTitle");
const tpsLabel = lastRequestTps === undefined
? formatTps(lastTurnOutputTokens && lastTurnModelMs ? lastTurnOutputTokens / (lastTurnModelMs / 1_000) : null, lastTurnOutputEstimated)
: formatTps(lastRequestTps);
@@ -397,12 +413,12 @@ export function StatusBar({
<Tooltip label={t("status.spendTitle")} className="statusbar__metric statusbar__metric--cost">
<span className="stat statusbar__cost">
<MetricLabel style={metricLabelStyle} icon={<CircleDollarSign size={12} />} label={t("status.costLabel")} />
<b>{costLabel}</b>
<b>{statusCostLabel}</b>
</span>
</Tooltip>
),
balance: (
<Tooltip label={t("status.balanceTitle")} className="statusbar__metric statusbar__metric--balance">
<Tooltip label={balanceTitle} className="statusbar__metric statusbar__metric--balance">
<span className="stat stat--balance statusbar__balance">
<MetricLabel style={metricLabelStyle} icon={<Wallet size={12} />} label={t("status.balanceLabel")} />
<b className={balanceLabel === "-" ? "stat__value--empty" : undefined}>{balanceLabel}</b>
@@ -68,6 +68,7 @@ let listTasksImpl: () => Promise<Task[]> = async () => [];
let listEventsImpl: () => Promise<Event[]> = async () => [];
const listTaskTabIDs: string[] = [];
const listEventCalls: unknown[][] = [];
const stopCalls: unknown[][] = [];
const requeueCalls: unknown[][] = [];
const mockApp = {
ListTasks: () => listTasksImpl(),
@@ -97,7 +98,10 @@ const mockApp = {
listEventCalls.push(args);
return listEventsImpl();
},
StopTaskForTab: async () => ({ schema_version: 1, command: "stop", task_id: "", accepted: true, idempotent: false }),
StopTaskForTab: async (...args: unknown[]) => {
stopCalls.push(args);
return { schema_version: 1, command: "stop", task_id: "", accepted: true, idempotent: false };
},
CancelTaskForTab: async () => ({ schema_version: 1, command: "cancel", task_id: "", accepted: true, idempotent: false }),
RequeueTaskForTab: async (...args: unknown[]) => {
requeueCalls.push(args);
@@ -154,6 +158,7 @@ async function cleanup() {
listEventsImpl = async () => [];
listTaskTabIDs.length = 0;
listEventCalls.length = 0;
stopCalls.length = 0;
requeueCalls.length = 0;
}
@@ -178,6 +183,13 @@ async function click(button: HTMLButtonElement) {
});
}
async function keyDown(element: HTMLElement, key: string) {
await act(async () => {
element.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key, bubbles: true }));
await flush();
});
}
async function openPanel() {
await click(buttonByLabel("Expand tasks"));
}
@@ -238,6 +250,62 @@ await check("separates lifecycle state from runtime liveness", async () => {
return text.includes("Exited") && text.includes("Runtime unknown");
});
await check("offers one working stop action for active tasks", async () => {
listTasksImpl = async () => [snap()];
await renderPanel();
await openPanel();
await click(buttonByLabel("Task task-000 — Running"));
const actionLabels = Array.from(document.querySelectorAll<HTMLButtonElement>(".taskmonitor__actions button"))
.map((button) => button.textContent?.trim());
const hasMergedActions = actionLabels.filter((label) => label === "Stop").length === 1
&& !actionLabels.includes("Cancel")
&& actionLabels.includes("Open session");
await click(buttonByText("Stop"));
const confirmStop = buttonByText("Stop");
const detailValues = Array.from(document.querySelectorAll<HTMLElement>(".taskmonitor__detail dd"))
.map((value) => value.textContent?.trim());
const hasReplacementConfirmation = document.querySelector(".taskmonitor__actions") === null
&& document.activeElement === confirmStop
&& detailValues.includes("Running")
&& !detailValues.includes("running");
await click(confirmStop);
return hasMergedActions
&& hasReplacementConfirmation
&& JSON.stringify(stopCalls[0]) === JSON.stringify([
"tab-a",
"task-0001",
1,
"desktop request",
"desktop-stop-task-0001-1",
]);
});
await check("dismisses stop confirmation with Escape and restores focus", async () => {
listTasksImpl = async () => [snap()];
await renderPanel();
await openPanel();
await click(buttonByLabel("Task task-000 — Running"));
await click(buttonByText("Stop"));
await keyDown(buttonByText("Stop"), "Escape");
const restoredStop = buttonByText("Stop");
return document.querySelector(".taskmonitor__confirm") === null
&& document.activeElement === restoredStop
&& stopCalls.length === 0;
});
await check("dismisses stop confirmation when polling observes a terminal task", async () => {
listTasksImpl = async () => [snap()];
await renderPanel();
await openPanel();
await click(buttonByLabel("Task task-000 — Running"));
await click(buttonByText("Stop"));
listTasksImpl = async () => [snap({ state: "succeeded", runtime_state: "exited", version: 2 })];
await click(buttonByLabel("Refresh"));
return document.querySelector(".taskmonitor__confirm") === null
&& document.body.textContent?.includes("Succeeded") === true
&& stopCalls.length === 0;
});
await check("requeues failed exited tasks", async () => {
listTasksImpl = async () => [snap({ task_id: "failed-1", state: "failed", runtime_state: "exited", version: 7 })];
await renderPanel();
@@ -65,6 +65,10 @@ function isTerminalState(state: string): boolean {
return state === "succeeded" || state === "failed" || state === "cancelled" || state === "stale";
}
function isStoppableState(state: string): boolean {
return state === "queued" || state === "running" || state === "waiting";
}
function elapsed(task: TaskTimerSnapshot, nowMs: number): string {
if (!task.created_at) return "—";
const startMs = new Date(task.created_at).getTime();
@@ -97,7 +101,7 @@ function eventSummary(ev: TaskEvent, t: ReturnType<typeof useT>): string {
if (ev.error_code) return t("task.event.error", { code: ev.error_code });
switch (ev.event_type) {
case "state_change":
return t("task.event.stateChange", { state: ev.state, runtime: runtimeConfig(ev.runtime_state, t).label });
return t("task.event.stateChange", { state: stateConfig(ev.state, t).label, runtime: runtimeConfig(ev.runtime_state, t).label });
case "error":
return ev.error_summary || t("task.error");
default:
@@ -141,7 +145,9 @@ export function TaskMonitorPanel({
const [actionError, setActionError] = useState<string | null>(null);
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [nowMs, setNowMs] = useState(() => Date.now());
const [pendingAction, setPendingAction] = useState<{ task: CatalogTask; action: "stop" | "cancel" } | null>(null);
const [pendingStop, setPendingStop] = useState<CatalogTask | null>(null);
const stopButtonRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const confirmStopRef = useRef<HTMLButtonElement | null>(null);
// Per-task event state
const [taskEvents, setTaskEvents] = useState<Map<string, TaskEvent[]>>(
@@ -244,8 +250,26 @@ export function TaskMonitorPanel({
return () => clearInterval(interval);
}, [tasks]);
const toggleTask = (task: CatalogTask) => {
const id = task.__catalogKey;
useEffect(() => {
if (pendingStop) confirmStopRef.current?.focus();
}, [pendingStop]);
useEffect(() => {
if (!pendingStop) return;
const current = tasks.find((task) => task.__catalogKey === pendingStop.__catalogKey);
if (!current || !isStoppableState(current.state)) setPendingStop(null);
}, [pendingStop, tasks]);
const dismissStopConfirmation = () => {
const taskKey = pendingStop?.__catalogKey;
setPendingStop(null);
if (taskKey) {
requestAnimationFrame(() => stopButtonRefs.current.get(taskKey)?.focus());
}
};
const toggleTask = (task: CatalogTask) => {
const id = task.__catalogKey;
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) {
@@ -261,13 +285,9 @@ export function TaskMonitorPanel({
});
};
const controlTask = async (task: CatalogTask, action: "stop" | "cancel" | "requeue" | "open") => {
if ((action === "stop" || action === "cancel") && (!pendingAction || pendingAction.task.task_id !== task.task_id || pendingAction.action !== action)) {
setPendingAction({ task, action });
return;
}
setPendingAction(null);
setActionTask(task.task_id);
const controlTask = async (task: CatalogTask, action: "stop" | "requeue" | "open") => {
setPendingStop(null);
setActionTask(task.__catalogKey);
setActionError(null);
setActionMessage(null);
try {
@@ -280,18 +300,14 @@ export function TaskMonitorPanel({
const result = hasTaskCatalogBinding()
? action === "stop"
? await app.StopTaskByKey(request)
: action === "cancel"
? await app.CancelTaskByKey(request)
: action === "requeue"
? await app.RequeueTaskByKey(request)
: await app.OpenTaskSessionByKey({ projectKey: task.__projectKey, taskId: task.task_id })
: action === "requeue"
? await app.RequeueTaskByKey(request)
: await app.OpenTaskSessionByKey({ projectKey: task.__projectKey, taskId: task.task_id })
: action === "stop"
? await app.StopTaskForTab(tabID, task.task_id, task.version, request.reason, request.idempotencyKey)
: action === "cancel"
? await app.CancelTaskForTab(tabID, task.task_id, task.version, request.reason, request.idempotencyKey)
: action === "requeue"
? await app.RequeueTaskForTab(tabID, task.task_id, task.version, request.idempotencyKey)
: await app.OpenTaskSessionForTab(tabID, task.task_id);
: action === "requeue"
? await app.RequeueTaskForTab(tabID, task.task_id, task.version, request.idempotencyKey)
: await app.OpenTaskSessionForTab(tabID, task.task_id);
if (result.error) {
setActionError(`${result.error.code}: ${result.error.message}`);
} else if (action === "open") {
@@ -459,7 +475,7 @@ export function TaskMonitorPanel({
<dt>{t("summary.sessionId")}</dt>
<dd>{task.session_id || "—"}</dd>
<dt>{t("summary.state")}</dt>
<dd>{task.state}</dd>
<dd>{cfg.label}</dd>
<dt>{t("summary.runtime")}</dt>
<dd>{runtime.label}</dd>
<dt>{t("summary.updated")}</dt>
@@ -536,23 +552,51 @@ export function TaskMonitorPanel({
</ul>
)}
</div>
<div className="taskmonitor__actions">
{(task.state === "queued" || task.state === "running" || task.state === "waiting") && (
<>
<button disabled={actionTask === task.task_id} onClick={() => void controlTask(task, "stop")}>{t("summary.stop")}</button>
<button disabled={actionTask === task.task_id} onClick={() => void controlTask(task, "cancel")}>{t("summary.cancel")}</button>
</>
)}
{(task.state === "failed" || task.state === "stale") && (
<button disabled={actionTask === task.task_id || task.runtime_state === "alive"} onClick={() => void controlTask(task, "requeue")}>{t("summary.requeue")}</button>
)}
<button disabled={actionTask === task.task_id} onClick={() => void controlTask(task, "open")}>{t("summary.openSession")}</button>
</div>
{pendingAction?.task.task_id === task.task_id && (
<div className="taskmonitor__confirm">
<span>{t(pendingAction.action === "stop" ? "summary.confirmStop" : "summary.confirmCancel")}</span>
<button type="button" onClick={() => void controlTask(task, pendingAction.action)}>{t("common.confirm")}</button>
<button type="button" onClick={() => setPendingAction(null)}>{t("summary.keep")}</button>
{pendingStop?.__catalogKey === taskKey ? (
<div
className="taskmonitor__confirm"
role="group"
aria-label={t("summary.confirmStop")}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
dismissStopConfirmation();
}
}}
>
<span className="taskmonitor__confirm-copy">{t("summary.confirmStop")}</span>
<div className="taskmonitor__confirm-actions">
<button
ref={confirmStopRef}
type="button"
className="taskmonitor__confirm-stop"
disabled={actionTask === taskKey}
onClick={() => void controlTask(task, "stop")}
>
{t("summary.stop")}
</button>
<button type="button" onClick={dismissStopConfirmation}>{t("summary.keep")}</button>
</div>
</div>
) : (
<div className="taskmonitor__actions">
{isStoppableState(task.state) && (
<button
ref={(node) => {
if (node) stopButtonRefs.current.set(taskKey, node);
else stopButtonRefs.current.delete(taskKey);
}}
className="taskmonitor__stop"
disabled={actionTask === taskKey}
onClick={() => setPendingStop(task)}
>
{t("summary.stop")}
</button>
)}
{(task.state === "failed" || task.state === "stale") && (
<button disabled={actionTask === taskKey || task.runtime_state === "alive"} onClick={() => void controlTask(task, "requeue")}>{t("summary.requeue")}</button>
)}
<button disabled={actionTask === taskKey} onClick={() => void controlTask(task, "open")}>{t("summary.openSession")}</button>
</div>
)}
</div>
+21 -1
View File
@@ -20,11 +20,31 @@ export function currencySymbol(currency?: string): string {
export function formatMoney(amount?: number, currency?: string, empty: "zero" | "dash" = "zero"): string {
const symbol = currencySymbol(currency);
if (typeof amount !== "number" || amount <= 0) {
return empty === "dash" ? "-" : `${symbol}0.0000`;
// Prefer dash over a fake zero when cost cannot be estimated.
return empty === "dash" ? "-" : empty === "zero" ? `${symbol}0.0000` : "-";
}
return `${symbol}${amount < 1 ? amount.toFixed(4) : amount.toFixed(2)}`;
}
/** Format an estimated cost with the product-wide "≈" cue. */
export function formatEstimatedCost(amount?: number, currency?: string, incomplete?: boolean): string {
if (incomplete || typeof amount !== "number" || amount <= 0) {
return "-";
}
return `≈${formatMoney(amount, currency, "dash")}`;
}
export function formatMoneyAmount(amount?: string, currency?: string, empty: "zero" | "dash" = "dash"): string {
if (!amount || amount === "0") {
return empty === "dash" ? "-" : formatMoney(0, currency, empty);
}
const n = Number(amount);
if (!Number.isFinite(n) || n <= 0) {
return empty === "dash" ? "-" : formatMoney(0, currency, empty);
}
return formatMoney(n, currency, empty);
}
interface MoneyFormatOptions {
locale?: string;
empty?: "zero" | "dash";
+57 -2
View File
@@ -122,8 +122,49 @@ export interface WireUsage {
contextCacheMissTokens?: number;
cost?: number;
currency?: string;
// Deprecated compatibility alias. Prefer cost + currency.
currencyCode?: string;
// Deprecated compatibility alias. Prefer cost + currencyCode / costQuote.
costUsd?: number;
costComplete?: boolean;
displayComplete?: boolean;
displayStatus?: string;
aggregateMode?: string;
originalTotals?: Money[];
/** Host-side structured quote; prefer over cost/currency aliases. */
costQuote?: CostQuote;
}
export interface Money {
amount: string;
currency: string;
}
export interface CostQuote {
original: Money;
originalTotals?: Money[];
valuations?: Record<string, {
money: Money;
basis: string;
source: string;
asOf: string;
rateSnapshot?: { base: string; quote: string; rate: number; source: string; asOf: string; stale?: boolean };
stale?: boolean;
}>;
selected?: Money;
billingMode?: string;
estimated: boolean;
costComplete?: boolean;
displayComplete?: boolean;
complete: boolean;
displayStatus?: "matched" | "fallback_original" | "bucketed" | "unavailable" | string;
aggregateMode?: "single_currency" | "common_valuation" | "currency_buckets" | string;
modelRef?: string;
usageSource?: string;
pricingFingerprint?: string;
rateDate?: string;
incompleteReason?: string;
legacyEstimate?: boolean;
catalogSource?: string;
}
export interface WireRecoveryApproval {
@@ -517,6 +558,10 @@ export interface ContextPanelInfo {
sessionCurrency?: string;
// Deprecated compatibility alias. Prefer sessionCost + sessionCurrency.
sessionCostUsd?: number;
sessionCostComplete?: boolean;
sessionCostEstimated?: boolean;
sessionBillingMode?: string;
sessionCostQuote?: CostQuote;
sources?: Record<string, UsageSourceStats>;
mock?: boolean;
readFiles: ReadFileRecord[];
@@ -814,6 +859,8 @@ export interface ContextInfo {
cacheHitTokens?: number;
cacheMissTokens?: number;
estimated?: boolean;
sessionCostComplete?: boolean;
sessionCostQuote?: CostQuote;
sources?: Record<string, UsageSourceStats>;
maintenance?: ContextMaintenanceInfo;
}
@@ -1730,10 +1777,18 @@ export interface ProviderModelOverrideView {
// BalanceInfo is the wallet-balance readout (desktop/app.go Balance). available
// is false when the provider declares no balanceUrl or a fetch failed; display is
// the formatted amount (e.g. "¥110.00").
// the formatted amount in an original wallet currency; no implicit FX conversion.
export interface BalanceInfo {
available: boolean;
display: string;
detail?: string;
complete?: boolean;
rateDate?: string;
approx?: boolean;
currencies?: string[];
primaryCurrency?: string;
costDisplayCurrency?: string;
multiCurrency?: boolean;
err?: string;
}
+10 -8
View File
@@ -75,12 +75,10 @@ export const en = {
"summary.loadingEvents": "Loading events…",
"summary.noEvents": "No events yet",
"summary.stop": "Stop",
"summary.cancel": "Cancel",
"summary.requeue": "Requeue",
"summary.openSession": "Open session",
"summary.confirmStop": "Stop this task?",
"summary.confirmCancel": "Cancel this task?",
"summary.keep": "Keep",
"summary.keep": "Keep running",
"task.error": "Error",
"task.event.error": "Error: {code}",
"task.event.stateChange": "State → {state} · Runtime → {runtime}",
@@ -188,7 +186,6 @@ export const en = {
// topic bar
"topicBar.renameSession": "Rename session",
"topicBar.more": "More",
"topicBar.command": "Command",
"topicBar.copyAll": "Copy session",
"topicBar.export": "Export session",
"topicBar.exportMarkdown": "Export Markdown",
@@ -2527,7 +2524,7 @@ export const en = {
"settings.typography.previewTool": "$ pnpm test\n✓ typography preferences applied",
"settings.pageDesc.general": "Interface, language, and conversation defaults.",
"settings.general.sectionAppearance": "Appearance & language",
"settings.general.sectionAppearanceHint": "Choose the desktop experience, language, and pricing display.",
"settings.general.sectionAppearanceHint": "Choose the desktop experience, language, and cost display.",
"settings.general.sectionConversation": "Conversation experience",
"settings.general.sectionSystem": "System behavior",
"settings.general.sectionSystemHint": "Control startup, approvals, sound, and status information.",
@@ -2792,9 +2789,14 @@ export const en = {
"settings.defaultWorkspace": "Default workspace",
"settings.defaultWorkspaceHint": "Opened automatically on startup when available.",
"settings.langAuto": "Auto (system)",
"settings.currency": "Pricing currency",
"settings.currencyHint": "Choose how model prices and session costs are displayed.",
"settings.currencyAuto": "Auto (language)",
"settings.currency": "Cost display currency",
"settings.currencyAuto": "Auto (recommended)",
"settings.currencyHint": "Display only; balances and model prices stay unchanged. Auto prefers the account currency, then the pricing-table currency.",
"context.sessionCostEstimated": "Estimated session cost",
"context.sessionCostPaygEquivalent": "PAYG-equivalent estimate",
"context.sessionCostBucketed": "Multiple currencies",
"context.sessionCostUnavailable": "Cost not estimable",
"context.sessionCostFallback": "target currency unavailable",
"settings.config": "config: {path}",
"settings.configShadowed": "overridden by workspace config: {path} (changes here may not take effect)",
"settings.generativeMusic": "Generative background music",
+13 -11
View File
@@ -66,16 +66,14 @@ export const zhTW: Record<DictKey, string> = {
"summary.loadingEvents": "載入…",
"summary.noEvents": "無事件",
"summary.stop": "停止",
"summary.cancel": "取消",
"summary.requeue": "重新排隊",
"summary.openSession": "開啟",
"summary.confirmStop": "停止?",
"summary.confirmCancel": "取消?",
"summary.keep": "保留",
"summary.openSession": "開啟會話",
"summary.confirmStop": "停止此任務?",
"summary.keep": "繼續執行",
"task.error": "錯誤",
"task.event.error": "錯誤:{code}",
"task.event.stateChange": "狀態 {state} · 執行時 {runtime}",
"task.runtime.live": "執行中",
"task.runtime.live": "在線",
"task.runtime.exited": "已退出",
"task.runtime.unknown": "執行時未知",
"task.state.queued": "排隊中",
@@ -1691,7 +1689,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.typography.previewTool": "$ pnpm test\n✓ 排版偏好已套用",
"settings.pageDesc.general": "介面、語言與會話預設設定。",
"settings.general.sectionAppearance": "外觀與語言",
"settings.general.sectionAppearanceHint": "設定桌面體驗、介面語言和計價顯示。",
"settings.general.sectionAppearanceHint": "設定桌面體驗、介面語言和費用顯示。",
"settings.general.sectionConversation": "會話體驗",
"settings.general.sectionSystem": "系統行為",
"settings.general.sectionSystemHint": "控制視窗、審批、聲音和狀態資訊。",
@@ -1903,9 +1901,14 @@ export const zhTW: Record<DictKey, string> = {
"settings.defaultWorkspace": "預設工作區",
"settings.defaultWorkspaceHint": "啟動時自動開啟(目錄存在時)。",
"settings.langAuto": "自動(跟隨系統)",
"settings.currency": "計價幣別",
"settings.currencyHint": "選擇模型價格和會話費用的顯示幣別。",
"settings.currencyAuto": "自動(跟隨語言)",
"settings.currency": "費用顯示幣別",
"settings.currencyAuto": "自動(建議)",
"settings.currencyHint": "僅影響費用顯示,不換算餘額或變更模型價格。自動優先帳戶幣別,否則使用價目表幣別。",
"context.sessionCostEstimated": "估算會話費用",
"context.sessionCostPaygEquivalent": "按量等效估算",
"context.sessionCostBucketed": "多幣種",
"context.sessionCostUnavailable": "費用不可估算",
"context.sessionCostFallback": "目標幣種不可用",
"settings.config": "設定檔:{path}",
"settings.configShadowed": "被工作區設定覆蓋:{path}(這裡的修改可能不會生效)",
@@ -2330,7 +2333,6 @@ export const zhTW: Record<DictKey, string> = {
"botDetail.flowReceive": "IM 訊息進入本地話題",
"botDetail.flowDesktop": "桌面端處理模型和工具",
"botDetail.flowReply": "階段結果回傳到 IM",
"topicBar.command": "命令",
"topicBar.exportPdf": "匯出 PDF",
"topicBar.exportImage": "匯出圖片",
"topicBar.exportSuccess": "已匯出 {count} 個檔案。",
+13 -11
View File
@@ -76,16 +76,14 @@ export const zh: Record<DictKey, string> = {
"summary.loadingEvents": "加载中…",
"summary.noEvents": "暂无事件",
"summary.stop": "停止",
"summary.cancel": "取消",
"summary.requeue": "重新排队",
"summary.openSession": "打开",
"summary.confirmStop": "停止?",
"summary.confirmCancel": "取消?",
"summary.keep": "保留",
"summary.openSession": "打开会话",
"summary.confirmStop": "停止此任务?",
"summary.keep": "继续运行",
"task.error": "错误",
"task.event.error": "错误:{code}",
"task.event.stateChange": "状态 {state} · 运行 {runtime}",
"task.runtime.live": "运行中",
"task.runtime.live": "在线",
"task.runtime.exited": "已退出",
"task.runtime.unknown": "未知",
"task.state.queued": "排队中",
@@ -189,7 +187,6 @@ export const zh: Record<DictKey, string> = {
// 话题栏
"topicBar.renameSession": "重命名会话",
"topicBar.more": "更多",
"topicBar.command": "命令",
"topicBar.copyAll": "复制会话",
"topicBar.export": "导出会话",
"topicBar.exportMarkdown": "导出 Markdown",
@@ -2530,7 +2527,7 @@ export const zh: Record<DictKey, string> = {
"settings.typography.previewTool": "$ pnpm test\n✓ 排版偏好已应用",
"settings.pageDesc.general": "界面、语言与会话默认设置。",
"settings.general.sectionAppearance": "外观与语言",
"settings.general.sectionAppearanceHint": "设置桌面体验、界面语言和计价显示。",
"settings.general.sectionAppearanceHint": "设置桌面体验、界面语言和费用显示。",
"settings.general.sectionConversation": "会话体验",
"settings.general.sectionSystem": "系统行为",
"settings.general.sectionSystemHint": "控制窗口、审批、声音和状态信息。",
@@ -2795,9 +2792,14 @@ export const zh: Record<DictKey, string> = {
"settings.defaultWorkspace": "默认工作区",
"settings.defaultWorkspaceHint": "启动时自动打开(目录存在时)。",
"settings.langAuto": "自动(跟随系统)",
"settings.currency": "计价币种",
"settings.currencyHint": "选择模型价格和会话费用的显示币种。",
"settings.currencyAuto": "自动(跟随语言)",
"settings.currency": "费用展示币种",
"settings.currencyAuto": "自动(推荐)",
"settings.currencyHint": "仅影响费用显示,不换算余额或改变模型价格。自动优先账户币种,否则使用价表币种。",
"context.sessionCostEstimated": "估算会话费用",
"context.sessionCostPaygEquivalent": "按量等效估算",
"context.sessionCostBucketed": "多币种",
"context.sessionCostUnavailable": "费用不可估算",
"context.sessionCostFallback": "目标币种不可用",
"settings.config": "配置文件:{path}",
"settings.configShadowed": "被工作区配置覆盖:{path}(这里的修改可能不生效)",
"settings.generativeMusic": "生成式背景音乐",
+13 -4
View File
@@ -37675,8 +37675,8 @@ body > .mermaid-diagram--fullscreen {
.taskmonitor { background:var(--bg,#111); }
.taskmonitor--popover { max-height:inherit; }
.taskmonitor__head { display:flex; align-items:center; gap:6px; width:100%; padding:8px 12px; font-size:13px; }
.taskmonitor__toggle,.taskmonitor__refresh,.taskmonitor__close { background:none; border:0; color:inherit; cursor:pointer; }
.taskmonitor__toggle { display:flex; padding:0; }
.taskmonitor__toggle,.taskmonitor__refresh,.taskmonitor__close { display:inline-flex; align-items:center; justify-content:center; width:28px; height:28px; padding:0; border:0; border-radius:6px; background:none; color:inherit; cursor:pointer; }
.taskmonitor__toggle:hover,.taskmonitor__refresh:hover,.taskmonitor__close:hover,.taskmonitor__toggle:focus-visible,.taskmonitor__refresh:focus-visible,.taskmonitor__close:focus-visible { background:var(--bg-elev-2); outline:none; }
.taskmonitor__title { flex:1; text-align:left; font-weight:600; }
.taskmonitor__count { background:var(--muted,#555); color:#fff; border-radius:10px; padding:0 6px; font-size:11px; line-height:18px; }
.taskmonitor__body { max-height:360px; overflow-y:auto; }
@@ -37686,7 +37686,8 @@ body > .mermaid-diagram--fullscreen {
.taskmonitor__state--error { color:#ef4444; }
.taskmonitor__task { border-bottom:1px solid var(--border,#2a2a2a); }
.taskmonitor__task-head { display:flex; align-items:center; }
.taskmonitor__expand { display:flex; align-items:center; gap:6px; width:100%; padding:6px 12px; background:none; border:0; color:inherit; cursor:pointer; font-size:12px; }
.taskmonitor__expand { display:flex; align-items:center; gap:6px; width:100%; min-height:32px; padding:6px 12px; background:none; border:0; color:inherit; cursor:pointer; font-size:12px; }
.taskmonitor__expand:hover,.taskmonitor__expand:focus-visible { background:var(--bg-elev-2); outline:none; }
.taskmonitor__dot { font-size:10px; width:14px; text-align:center; }
.taskmonitor__id { font-family:monospace; font-size:11px; color:var(--muted,#888); }
.taskmonitor__badge { font-size:10px; padding:1px 6px; border-radius:8px; font-weight:600; text-transform:uppercase; }
@@ -37699,7 +37700,15 @@ body > .mermaid-diagram--fullscreen {
.taskmonitor__events { margin-top:8px; border-top:1px solid var(--border,#333); padding-top:6px; }
.taskmonitor__event { display:flex; gap:8px; line-height:20px; }
.taskmonitor__actions { display:flex; gap:8px; margin-top:10px; }
.taskmonitor__actions button,.taskmonitor__confirm button { padding:4px 10px; border:1px solid var(--border,#555); border-radius:4px; background:var(--surface,#222); color:inherit; cursor:pointer; }
.taskmonitor__actions button,.taskmonitor__confirm button { min-height:32px; padding:0 12px; border:1px solid var(--border,#555); border-radius:6px; background:var(--surface,#222); color:inherit; cursor:pointer; }
.taskmonitor__actions button:hover:not(:disabled),.taskmonitor__confirm button:hover:not(:disabled),.taskmonitor__actions button:focus-visible,.taskmonitor__confirm button:focus-visible { border-color:var(--accent); outline:2px solid color-mix(in srgb,var(--accent) 38%,transparent); outline-offset:1px; }
.taskmonitor__actions button:disabled,.taskmonitor__confirm button:disabled { cursor:not-allowed; opacity:.55; }
.taskmonitor__actions .taskmonitor__stop { border-color:color-mix(in srgb,var(--danger,#ef4444) 48%,var(--border)); color:var(--danger,#ef4444); }
.taskmonitor__confirm { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-top:10px; padding:10px 12px; border:1px solid color-mix(in srgb,var(--danger,#ef4444) 30%,var(--border)); border-radius:8px; background:color-mix(in srgb,var(--danger,#ef4444) 7%,var(--surface,#222)); }
.taskmonitor__confirm-copy { flex:1 1 150px; font-weight:600; line-height:1.4; }
.taskmonitor__confirm-actions { display:flex; gap:8px; }
.taskmonitor__confirm .taskmonitor__confirm-stop { border-color:var(--danger,#ef4444); background:var(--danger,#ef4444); color:#fff; font-weight:650; }
.taskmonitor__confirm .taskmonitor__confirm-stop:hover:not(:disabled),.taskmonitor__confirm .taskmonitor__confirm-stop:focus-visible { border-color:var(--danger,#ef4444); outline-color:color-mix(in srgb,var(--danger,#ef4444) 42%,transparent); }
.taskmonitor__error { color:#ef4444; margin-top:6px; }
.taskmonitor__message { color:#4ade80; margin-top:6px; }
@@ -10,6 +10,8 @@ import (
)
func TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {
// Desktop language is display-only; newly installed official DeepSeek
// templates freeze the default USD list-price table (billing_currency).
isolateDesktopUserDirs(t)
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
@@ -31,11 +33,14 @@ language = "zh"
}
flash := p.Prices["deepseek-v4-flash"]
pro := p.Prices["deepseek-v4-pro"]
if flash == nil || flash.Output != 2 || flash.Currency != "¥" {
t.Fatalf("flash price = %+v, want CNY preset", flash)
if flash == nil || flash.Output != 0.28 || flash.Currency != "$" {
t.Fatalf("flash price = %+v, want frozen USD official table", flash)
}
if pro == nil || pro.Output != 6 || pro.Currency != "¥" {
t.Fatalf("pro price = %+v, want CNY preset", pro)
if pro == nil || pro.Output != 0.87 || pro.Currency != "$" {
t.Fatalf("pro price = %+v, want frozen USD official table", pro)
}
if got := cfg.ResolveDisplayCurrency(); got != "" {
t.Fatalf("display currency = %q, want unresolved auto", got)
}
}
+38 -90
View File
@@ -1926,9 +1926,6 @@ func (a *App) rebuildSettingTurnLockedWithModel(setting string, tab *WorkspaceTa
oldCtrl.Close()
}
a.persistTabSessionPath(tab, path)
if setting == "currency" {
a.repriceTabUsageForCurrentCurrency(tab)
}
a.clearDeferredRebuild(tab.ID)
a.notifyTabRuntimeRebuilt(tab)
a.emitReady(a.ctx)
@@ -1947,9 +1944,10 @@ func (a *App) rebuildSettingTurnLockedWithModel(setting string, tab *WorkspaceTa
func (a *App) buildSettingReplacementController(tab *WorkspaceTab, snap tabRuntimeSnapshot, runtime normalizedTabRuntime, model, prevPath, setting string, oldCtrl control.SessionAPI, carried []provider.Message, reload bool) (control.SessionAPI, normalizedTabRuntime, string, error) {
opts := boot.Options{
Model: model, RequireKey: false,
RuntimeReload: boot.RuntimeReload{ForceFullRebuild: reload},
AutoPricingCurrency: a.desktopAutoPricingCurrency(),
StatsSource: "desktop", TaskStore: a.taskStore(), OnConfigLoadWarnings: a.configLoadWarningsHandler(),
RuntimeReload: boot.RuntimeReload{ForceFullRebuild: reload},
StatsSource: "desktop",
TaskStore: a.taskStore(),
OnConfigLoadWarnings: a.configLoadWarningsHandler(),
Sink: snap.sink,
WorkspaceRoot: snap.workspaceRoot,
SessionDir: sessionDirForSnapshot(snap),
@@ -2317,21 +2315,25 @@ func (a *App) SetDefaultToolApprovalMode(mode string) error {
func (a *App) SetDefaultAutoRecoveryCheckpoint(_ bool) error { return nil }
func officialProviderTemplate(kind, pricingLanguage string) ([]config.ProviderEntry, string, error) {
_ = pricingLanguage // display language no longer selects list-price tables
webSearchEnabled := true
switch strings.ToLower(strings.TrimSpace(kind)) {
case "deepseek", "deepseek-official":
// Freeze the official USD regional table; display currency is independent.
return []config.ProviderEntry{{
Name: "deepseek",
Kind: "anthropic",
BaseURL: "https://api.deepseek.com/anthropic",
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
Default: "deepseek-v4-flash",
APIKeyEnv: "DEEPSEEK_API_KEY",
BalanceURL: "https://api.deepseek.com/user/balance",
Thinking: "enabled",
WebSearch: &webSearchEnabled,
ContextWindow: 1_000_000,
Prices: config.DeepSeekV4PricesForLanguage(pricingLanguage),
Name: "deepseek",
Kind: "anthropic",
BaseURL: "https://api.deepseek.com/anthropic",
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
Default: "deepseek-v4-flash",
APIKeyEnv: "DEEPSEEK_API_KEY",
BalanceURL: "https://api.deepseek.com/user/balance",
Thinking: "enabled",
WebSearch: &webSearchEnabled,
ContextWindow: 1_000_000,
BillingCurrency: "USD",
BillingMode: "payg",
Prices: config.DeepSeekV4PricesForCurrency("USD"),
ModelOverrides: map[string]config.ProviderModelOverride{
"deepseek-v4-flash": {SupportedEfforts: []string{"disabled", "low", "high", "max"}, DefaultEffort: "high"},
"deepseek-v4-pro": {SupportedEfforts: []string{"disabled", "high", "max"}, DefaultEffort: "high"},
@@ -3258,17 +3260,6 @@ func (a *App) SetStatusBarItems(items []string) error {
// language preference used by model-facing desktop sessions.
func (a *App) SetDesktopLanguage(lang string) error {
responseLanguage := ""
pricingChanged := false
if cfg, _, err := a.loadDesktopUserConfigForView(); err == nil && cfg.DesktopCurrency() == "" {
targetCurrency := a.desktopAutoPricingCurrency()
switch strings.ToLower(strings.TrimSpace(lang)) {
case "zh":
targetCurrency = "CNY"
case "en":
targetCurrency = "USD"
}
pricingChanged = a.desktopEffectivePricingCurrency(cfg) != targetCurrency
}
mutate := func(c *config.Config) error {
if err := c.SetDesktopLanguage(lang); err != nil {
return err
@@ -3279,18 +3270,10 @@ func (a *App) SetDesktopLanguage(lang string) error {
responseLanguage = c.ResponseLanguage()
return nil
}
var err error
if pricingChanged {
_, err = a.applyConfigChangeWithWarning("currency", mutate)
} else {
err = a.applyConfigOnly(mutate)
}
err := a.applyConfigOnly(mutate)
if err != nil {
return err
}
if pricingChanged {
a.scheduleCurrencyRefreshForOtherTabs()
}
if strings.TrimSpace(lang) != "" && !strings.EqualFold(strings.TrimSpace(lang), "auto") {
a.setDesktopLocale(lang)
}
@@ -3299,70 +3282,41 @@ func (a *App) SetDesktopLanguage(lang string) error {
return nil
}
// SetDesktopCurrency updates the official pricing region independently from UI
// language. Rebuild the active controller so subsequent usage carries the new
// currency and regional rates through the existing structured cost fields.
// SetDesktopCurrency persists a display-only preference and re-selects the
// occurrence-time valuations already stored in each tab. Provider price tables
// and live controllers are intentionally untouched.
func (a *App) SetDesktopCurrency(currency string) error {
_, err := a.applyConfigChangeWithWarning("currency", func(c *config.Config) error {
err := a.applyConfigOnly(func(c *config.Config) error {
return c.SetDesktopCurrency(currency)
})
if err == nil {
a.scheduleCurrencyRefreshForOtherTabs()
if err != nil {
return err
}
return err
}
func (a *App) scheduleCurrencyRefreshForOtherTabs() {
if a == nil || a.ctx == nil {
return
}
a.sessionRemovalMu.Lock()
defer a.sessionRemovalMu.Unlock()
a.mu.RLock()
activeID := a.activeTabID
tabIDs := make([]string, 0, len(a.tabs))
for id, tab := range a.tabs {
if id != activeID && tab != nil && tab.Ctrl != nil && !tab.removed {
tabIDs = append(tabIDs, id)
}
}
tabs := append([]*WorkspaceTab(nil), a.runtimeTabsLocked()...)
a.mu.RUnlock()
for _, id := range tabIDs {
a.scheduleDeferredRebuild(id, "currency")
for _, tab := range tabs {
a.repriceTabUsageForCurrentCurrency(tab)
}
}
func (a *App) scheduleCurrencyRefreshForAllTabs() {
if a == nil {
return
}
a.mu.RLock()
tabIDs := make([]string, 0, len(a.tabs))
for id, tab := range a.tabs {
if tab != nil && tab.Ctrl != nil && !tab.removed {
tabIDs = append(tabIDs, id)
}
}
a.mu.RUnlock()
for _, id := range tabIDs {
a.scheduleDeferredRebuild(id, "currency")
}
}
func (a *App) desktopPricingFollowsDetectedLocale() bool {
cfg, _, err := a.loadDesktopUserConfigForView()
return err == nil && cfg.DesktopPricingFollowsDetectedLocale()
return nil
}
func (a *App) desktopEffectivePricingCurrency(cfg *config.Config) string {
// Display currency only — never the provider list-price region.
if cfg == nil {
return a.desktopAutoPricingCurrency()
return ""
}
if cfg.DesktopPricingFollowsDetectedLocale() {
return a.desktopAutoPricingCurrency()
if pref := cfg.DisplayCurrencyPref(); pref != "" {
return pref
}
return cfg.DeepSeekOfficialPricingCurrency()
return cfg.ExplicitDisplayCurrency()
}
func (a *App) desktopOfficialPricingLanguage(cfg *config.Config) string {
// Used only for display-language adjacent UI; list prices use billing_currency.
if a.desktopEffectivePricingCurrency(cfg) == "CNY" {
return "zh"
}
@@ -3372,18 +3326,12 @@ func (a *App) desktopOfficialPricingLanguage(cfg *config.Config) string {
// SetTrayLocale mirrors the resolved desktop UI language into the native tray
// menu. It is runtime-only; the persisted preference remains [desktop].language.
func (a *App) SetTrayLocale(locale string) error {
previousCurrency := a.desktopAutoPricingCurrency()
a.setDesktopLocale(locale)
pricingCurrencyChanged := previousCurrency != a.desktopAutoPricingCurrency()
trayLocale := "en"
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(locale)), "zh") {
trayLocale = "zh"
}
a.updateTrayLocale(trayLocale)
if pricingCurrencyChanged && a.desktopPricingFollowsDetectedLocale() {
a.scheduleCurrencyRefreshForAllTabs()
a.kickDeferredRebuildRetry()
}
a.emitProjectTreeChanged()
return nil
}
+17 -19
View File
@@ -1878,31 +1878,25 @@ func TestOfficialMimoAPITemplateRemoved(t *testing.T) {
}
func TestOfficialDeepSeekTemplateUsesRegionalPricing(t *testing.T) {
for _, tt := range []struct {
language string
currency string
flashOutput float64
proOutput float64
}{
{language: "en", currency: "$", flashOutput: 0.28, proOutput: 0.87},
{language: "zh", currency: "¥", flashOutput: 2, proOutput: 6},
} {
entries, keyEnv, err := officialProviderTemplate("deepseek", tt.language)
// Language no longer selects list-price tables; templates freeze the default
// USD official rates. Display currency is independent (billing.display_currency).
for _, language := range []string{"en", "zh"} {
entries, keyEnv, err := officialProviderTemplate("deepseek", language)
if err != nil {
t.Fatalf("officialProviderTemplate(%s): %v", tt.language, err)
t.Fatalf("officialProviderTemplate(%s): %v", language, err)
}
if keyEnv != "DEEPSEEK_API_KEY" || len(entries) != 1 {
t.Fatalf("template = %v/%q, want one DEEPSEEK_API_KEY entry", entries, keyEnv)
}
got := entries[0]
if got.Kind != "anthropic" || got.BaseURL != "https://api.deepseek.com/anthropic" || !config.EffectiveWebSearch(&got) || got.Thinking != "enabled" {
t.Fatalf("%s DeepSeek template = kind:%q base_url:%q web_search:%t thinking:%q, want Anthropic-compatible with web search", tt.language, got.Kind, got.BaseURL, config.EffectiveWebSearch(&got), got.Thinking)
t.Fatalf("%s DeepSeek template = kind:%q base_url:%q web_search:%t thinking:%q, want Anthropic-compatible with web search", language, got.Kind, got.BaseURL, config.EffectiveWebSearch(&got), got.Thinking)
}
if price := got.Prices["deepseek-v4-flash"]; price == nil || price.Currency != tt.currency || price.Output != tt.flashOutput {
t.Fatalf("%s deepseek-v4-flash price = %+v", tt.language, price)
if price := got.Prices["deepseek-v4-flash"]; price == nil || price.Currency != "$" || price.Output != 0.28 {
t.Fatalf("%s deepseek-v4-flash price = %+v, want frozen USD table", language, price)
}
if price := got.Prices["deepseek-v4-pro"]; price == nil || price.Currency != tt.currency || price.Output != tt.proOutput {
t.Fatalf("%s deepseek-v4-pro price = %+v", tt.language, price)
if price := got.Prices["deepseek-v4-pro"]; price == nil || price.Currency != "$" || price.Output != 0.87 {
t.Fatalf("%s deepseek-v4-pro price = %+v, want frozen USD table", language, price)
}
}
}
@@ -2047,7 +2041,7 @@ func TestSetDesktopLanguagePersistsResponseLanguageAndUpdatesLiveTabs(t *testing
}
}
func TestSetDesktopCurrencyPersistsRegionalOfficialPricing(t *testing.T) {
func TestSetDesktopCurrencyPersistsDisplayWithoutRewritingOfficialPricing(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
@@ -2060,9 +2054,13 @@ func TestSetDesktopCurrencyPersistsRegionalOfficialPricing(t *testing.T) {
t.Fatalf("Settings().DesktopCurrency = %q, want CNY", view.DesktopCurrency)
}
cfg := config.LoadForEdit(config.UserConfigPath())
if got := cfg.DisplayCurrencyPref(); got != "CNY" {
t.Fatalf("display pref = %q, want CNY", got)
}
flash, ok := cfg.Provider("deepseek-flash")
if !ok || flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != "¥" {
t.Fatalf("saved DeepSeek flash price = %+v, want CNY official price", flash)
// Display currency must not rewrite frozen list prices (default USD table).
if !ok || flash.Price == nil || flash.Price.Output != 0.28 || flash.Price.Currency != "$" {
t.Fatalf("saved DeepSeek flash price = %+v, want frozen USD official price", flash)
}
}
+187 -190
View File
@@ -14,6 +14,7 @@ import (
"os"
"path/filepath"
"reasonix/internal/agent"
"reasonix/internal/billing"
"reasonix/internal/boot"
"reasonix/internal/config"
"reasonix/internal/control"
@@ -210,6 +211,12 @@ type WorkspaceTab struct {
// readTelemetry tracks files read during this tab's session.
readTelemetry []readFileRecord
usageTelemetry sessionUsageStats
// runtimeCostQuote is an automatic wallet-currency hint for the live tab.
// It is deliberately outside usageTelemetry so it cannot be persisted into
// telemetry/history or become configuration. Guarded by telemMu.
runtimeCostDisplayCurrency string
runtimeCostQuote *billing.CostQuote
runtimeCostGeneration uint64 // invalidates stale wallet responses
// telemetrySessionKey is the sessionRuntimeKey the telemetry above belongs
// to. Controller-side session rotations (typed /new, bot /reset) bypass the
// App bindings, so telemetry writers and readers re-key through
@@ -281,18 +288,24 @@ type sessionUsageStats struct {
// Per-turn token breakdown from the most recent turn. Persisted separately
// from the cumulative totals above so the context-panel donut chart and
// type breakdown survive a session rebind (which resets executor.LastUsage).
LastPromptTokens int `json:"lastPromptTokens,omitempty"`
LastCompletionTokens int `json:"lastCompletionTokens,omitempty"`
LastReasoningTokens int `json:"lastReasoningTokens,omitempty"`
LastCacheHitTokens int `json:"lastCacheHitTokens,omitempty"`
LastCacheMissTokens int `json:"lastCacheMissTokens,omitempty"`
LastEstimated bool `json:"lastEstimated,omitempty"`
RequestCount int `json:"requestCount"`
ElapsedMs int64 `json:"elapsedMs"`
SessionCost float64 `json:"sessionCost,omitempty"`
SessionCurrency string `json:"sessionCurrency,omitempty"`
SessionCostUsd float64 `json:"sessionCostUsd,omitempty"`
Sources map[string]usageSourceStats `json:"sources,omitempty"`
LastPromptTokens int `json:"lastPromptTokens,omitempty"`
LastCompletionTokens int `json:"lastCompletionTokens,omitempty"`
LastReasoningTokens int `json:"lastReasoningTokens,omitempty"`
LastCacheHitTokens int `json:"lastCacheHitTokens,omitempty"`
LastCacheMissTokens int `json:"lastCacheMissTokens,omitempty"`
LastEstimated bool `json:"lastEstimated,omitempty"`
RequestCount int `json:"requestCount"`
ElapsedMs int64 `json:"elapsedMs"`
SessionCost float64 `json:"sessionCost,omitempty"`
SessionCurrency string `json:"sessionCurrency,omitempty"`
SessionCostUsd float64 `json:"sessionCostUsd,omitempty"`
// SessionCostComplete is false when any entry lacks a shared display valuation.
SessionCostComplete bool `json:"sessionCostComplete,omitempty"`
// CostLedger stores occurrence-time quotes keyed by model+source+fingerprint+rateDate.
CostLedger *billing.Ledger `json:"costLedger,omitempty"`
// SessionCostQuote is the aggregate quote for the current display currency.
SessionCostQuote *billing.CostQuote `json:"sessionCostQuote,omitempty"`
Sources map[string]usageSourceStats `json:"sources,omitempty"`
activeTurnStartedAt int64
sourceSessionCache map[string]sourceSessionCacheCounters
@@ -783,9 +796,7 @@ func applyRuntimeTab(target, source *WorkspaceTab, path string, wailsCtx context
target.toolApprovalMode = source.toolApprovalMode
target.disabledMCP = cloneServerViewMap(source.disabledMCP)
target.mcpOrder = append([]string(nil), source.mcpOrder...)
target.readTelemetry = readTelemetry
target.usageTelemetry = usageTelemetry
target.telemetrySessionKey = telemetrySessionKey
target.replaceTelemetry(tabTelemetrySnapshot{ReadFiles: readTelemetry, Usage: usageTelemetry}, telemetrySessionKey)
if app != nil {
key := sessionRuntimeKey(path)
rt := app.runtimeForTabLocked(source)
@@ -1019,174 +1030,83 @@ func (t *WorkspaceTab) recordUsage(e event.Event) {
src.CacheWriteBilledTokens += u.CacheWriteBilledTokens
src.Estimated = src.Estimated || u.Estimated
src.RequestCount += requestCount
if e.Pricing != nil {
currency := e.Pricing.Symbol()
if existing := strings.TrimSpace(t.usageTelemetry.SessionCurrency); existing != "" && existing != currency {
// A scalar total cannot represent mixed currencies. Regional DeepSeek
// changes normally reprice prior usage; this fallback prevents custom
// or otherwise unmappable currencies from being summed together.
t.usageTelemetry.SessionCost = 0
t.usageTelemetry.SessionCostUsd = 0
for sourceName, sourceStats := range t.usageTelemetry.Sources {
sourceStats.SessionCost = 0
sourceStats.SessionCostUsd = 0
sourceStats.SessionCurrency = currency
t.usageTelemetry.Sources[sourceName] = sourceStats
// Prefer the middleware CostQuote; fall back only when older emitters omit it.
q := e.CostQuote
if q == nil && e.Pricing != nil {
q = event.EnsureCostQuote(e, nil)
}
if q != nil {
if t.usageTelemetry.CostLedger == nil {
t.usageTelemetry.CostLedger = billing.NewLedger()
}
tokens := billing.UsageTokens{
PromptTokens: u.PromptTokens,
CompletionTokens: u.CompletionTokens,
CacheHitTokens: cacheHitTokens,
CacheMissTokens: cacheMissTokens,
CacheWriteTokens: u.CacheWriteTokens,
CacheWriteBilledTokens: u.CacheWriteBilledTokens,
Estimated: u.Estimated,
}
t.usageTelemetry.CostLedger.Add(*q, tokens, time.Now().UTC())
display := billing.NormalizeCurrency(t.runtimeCostDisplayCurrency)
if display == "" {
display = billing.NormalizeCurrency(t.usageTelemetry.SessionCurrency)
}
if display == "" && q.Selected != nil {
display = billing.NormalizeCurrency(q.Selected.Currency)
}
if display == "" {
display = billing.NormalizeCurrency(q.Original.Currency)
}
total := t.usageTelemetry.CostLedger.Total(display)
if t.runtimeCostDisplayCurrency != "" {
t.runtimeCostQuote = &total
} else {
t.usageTelemetry.SessionCostQuote = &total
t.usageTelemetry.SessionCostComplete = total.Complete
}
if total.Selected != nil {
if t.runtimeCostDisplayCurrency == "" {
t.usageTelemetry.SessionCost = total.Selected.Float64()
t.usageTelemetry.SessionCurrency = total.LegacyCurrencyCode()
t.usageTelemetry.SessionCostUsd = t.usageTelemetry.SessionCost
}
src.SessionCost = 0
src.SessionCostUsd = 0
src.SessionCost += q.LegacyCostFloat()
src.SessionCostUsd = src.SessionCost
src.SessionCurrency = total.LegacyCurrencySymbol()
} else {
// Incomplete: never invent a zero total by wiping prior costs.
if t.runtimeCostDisplayCurrency == "" {
t.usageTelemetry.SessionCostComplete = false
t.usageTelemetry.SessionCost = 0
t.usageTelemetry.SessionCurrency = ""
t.usageTelemetry.SessionCostUsd = 0
}
if q.Selected == nil {
src.SessionCurrency = billing.CurrencySymbol(q.Original.Currency)
}
}
cost := e.Pricing.Cost(u)
t.usageTelemetry.SessionCost += cost
t.usageTelemetry.SessionCostUsd = t.usageTelemetry.SessionCost
t.usageTelemetry.SessionCurrency = currency
src.SessionCost += cost
src.SessionCostUsd = src.SessionCost
src.SessionCurrency = currency
}
t.usageTelemetry.Sources[source] = src
t.telemMu.Unlock()
}
func usageStatsAsProviderUsage(stats usageSourceStats) *provider.Usage {
return &provider.Usage{
PromptTokens: stats.PromptTokens,
CompletionTokens: stats.CompletionTokens,
TotalTokens: stats.TotalTokens,
ReasoningTokens: stats.ReasoningTokens,
CacheHitTokens: stats.CacheHitTokens,
CacheMissTokens: stats.CacheMissTokens,
CacheWriteTokens: stats.CacheWriteTokens,
CacheWriteBilledTokens: stats.CacheWriteBilledTokens,
Estimated: stats.Estimated,
}
}
func sessionStatsAsProviderUsage(stats sessionUsageStats) *provider.Usage {
return &provider.Usage{
PromptTokens: stats.PromptTokens,
CompletionTokens: stats.CompletionTokens,
TotalTokens: stats.TotalTokens,
ReasoningTokens: stats.ReasoningTokens,
CacheHitTokens: stats.CacheHitTokens,
CacheMissTokens: stats.CacheMissTokens,
CacheWriteTokens: stats.CacheWriteTokens,
CacheWriteBilledTokens: stats.CacheWriteBilledTokens,
Estimated: stats.Estimated,
}
}
// repriceUsage replaces the scalar cost total using per-source pricing. It
// leaves telemetry untouched when any costed source cannot be mapped to one
// currency.
func (t *WorkspaceTab) repriceUsage(pricingBySource map[string]*provider.Pricing) bool {
if t == nil {
return false
}
t.telemMu.Lock()
defer t.telemMu.Unlock()
if t.usageTelemetry.SessionCost <= 0 {
return true
}
if len(t.usageTelemetry.Sources) == 0 {
pricing := pricingBySource[event.UsageSourceExecutor]
if pricing == nil {
return false
}
t.usageTelemetry.SessionCost = pricing.Cost(sessionStatsAsProviderUsage(t.usageTelemetry))
t.usageTelemetry.SessionCostUsd = t.usageTelemetry.SessionCost
t.usageTelemetry.SessionCurrency = pricing.Symbol()
return true
}
currency := ""
total := 0.0
repriced := make(map[string]usageSourceStats, len(t.usageTelemetry.Sources))
for source, stats := range t.usageTelemetry.Sources {
if stats.SessionCost <= 0 {
repriced[source] = stats
continue
}
pricing := pricingBySource[source]
if pricing == nil {
return false
}
symbol := pricing.Symbol()
if currency != "" && currency != symbol {
return false
}
currency = symbol
stats.SessionCost = pricing.Cost(usageStatsAsProviderUsage(stats))
stats.SessionCostUsd = stats.SessionCost
stats.SessionCurrency = symbol
total += stats.SessionCost
repriced[source] = stats
}
if currency == "" {
return true
}
t.usageTelemetry.Sources = repriced
t.usageTelemetry.SessionCost = total
t.usageTelemetry.SessionCostUsd = total
t.usageTelemetry.SessionCurrency = currency
return true
}
func resolveOfficialDeepSeekPricing(cfg *config.Config, ref string) *provider.Pricing {
if cfg == nil || strings.TrimSpace(ref) == "" {
return nil
}
entry, ok := cfg.ResolveModel(ref)
if !ok || !config.IsOfficialDeepSeekProvider(entry) || !config.IsKnownDeepSeekOfficialPricing(entry.Model, entry.Price) {
return nil
}
return entry.Price
}
func firstConfiguredModelRef(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func usagePricingBySource(cfg *config.Config, executorRef string) map[string]*provider.Pricing {
if cfg == nil {
return nil
}
executorPricing := resolveOfficialDeepSeekPricing(cfg, executorRef)
plannerRef := firstConfiguredModelRef(cfg.Agent.PlannerModel, executorRef)
subagentRef := firstConfiguredModelRef(cfg.Agent.SubagentModel, executorRef)
routerRef := firstConfiguredModelRef(cfg.Agent.SubagentModels[event.UsageSourceCapabilityRouter], subagentRef, executorRef)
recoveryRef := firstConfiguredModelRef(cfg.Agent.RecoveryModel, cfg.Agent.GuardianModel, executorRef)
return map[string]*provider.Pricing{
event.UsageSourceExecutor: executorPricing,
event.UsageSourcePlanner: resolveOfficialDeepSeekPricing(cfg, plannerRef),
event.UsageSourceSubagent: resolveOfficialDeepSeekPricing(cfg, subagentRef),
event.UsageSourceCompaction: executorPricing,
event.UsageSourceClassifier: executorPricing,
event.UsageSourceTitle: executorPricing,
event.UsageSourceCapabilityRouter: resolveOfficialDeepSeekPricing(cfg, routerRef),
event.UsageSourceRecoveryReviewer: resolveOfficialDeepSeekPricing(cfg, recoveryRef),
}
}
func (a *App) repriceTabUsageForCurrentCurrency(tab *WorkspaceTab) {
if a == nil || tab == nil {
return
}
a.mu.RLock()
root := tab.WorkspaceRoot
model := tab.model
a.mu.RUnlock()
cfg, err := config.LoadForRoot(root)
if err != nil {
return
}
cfg.ApplyRuntimeAutoPricingCurrency(a.desktopAutoPricingCurrency())
if !tab.repriceUsage(usagePricingBySource(cfg, model)) {
// Display preference only — automatic mode remains unresolved until a
// wallet-aware surface supplies a session hint.
display := cfg.ExplicitDisplayCurrency()
if !tab.selectDisplayCurrency(display) {
return
}
if path := tab.currentSessionPath(); path != "" {
@@ -1212,13 +1132,41 @@ func (t *WorkspaceTab) telemetrySnapshot() tabTelemetrySnapshot {
}
usage.activeTurnStartedAt = 0
usage.sourceSessionCache = nil
return tabTelemetrySnapshot{Version: 2, ReadFiles: records, Usage: usage}
return tabTelemetrySnapshot{Version: 3, ReadFiles: records, Usage: usage}
}
// displayTelemetrySnapshot overlays the live wallet hint onto a copy used by
// UI reads. The persisted snapshot remains the occurrence-time/original view.
func (t *WorkspaceTab) displayTelemetrySnapshot() tabTelemetrySnapshot {
snapshot := t.telemetrySnapshot()
t.telemMu.Lock()
quote := t.runtimeCostQuote
t.telemMu.Unlock()
if quote == nil {
return snapshot
}
snapshot.Usage.SessionCostQuote = quote
snapshot.Usage.SessionCostComplete = quote.Complete
if quote.Selected != nil {
snapshot.Usage.SessionCost = quote.Selected.Float64()
snapshot.Usage.SessionCurrency = quote.LegacyCurrencyCode()
snapshot.Usage.SessionCostUsd = snapshot.Usage.SessionCost
} else {
snapshot.Usage.SessionCostComplete = false
snapshot.Usage.SessionCost = 0
snapshot.Usage.SessionCurrency = ""
snapshot.Usage.SessionCostUsd = 0
}
return snapshot
}
func (t *WorkspaceTab) resetTelemetry(sessionPath string) {
t.telemMu.Lock()
t.readTelemetry = nil
t.usageTelemetry = sessionUsageStats{}
t.runtimeCostDisplayCurrency = ""
t.runtimeCostQuote = nil
t.runtimeCostGeneration++
t.telemetrySessionKey = sessionRuntimeKey(sessionPath)
t.telemMu.Unlock()
}
@@ -1248,6 +1196,9 @@ func (t *WorkspaceTab) syncTelemetryToSession(sessionPath string) {
if t.telemetrySessionKey != key {
t.readTelemetry = snapshot.ReadFiles
t.usageTelemetry = snapshot.Usage
t.runtimeCostDisplayCurrency = ""
t.runtimeCostQuote = nil
t.runtimeCostGeneration++
t.telemetrySessionKey = key
}
t.telemMu.Unlock()
@@ -1557,6 +1508,9 @@ type closeableEventSink interface {
// binding snapshots the sink's current tab routing under the sink lock.
func (s *tabEventSink) binding() (string, *App) {
if s == nil {
return "", nil
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.tabID, s.app
@@ -1572,6 +1526,11 @@ func (s *tabEventSink) runtimeEpochSnapshot() string {
}
func (s *tabEventSink) Emit(e event.Event) {
// Typed-nil sinks can appear as non-nil event.Sink interfaces when a tab
// controller is built before the tab sink binding is installed.
if s == nil {
return
}
if e.Kind == event.TurnStarted {
s.mu.Lock()
s.turn.inFlight = true
@@ -3135,6 +3094,7 @@ func (a *App) SetActiveTab(tabID string) error {
return nil
}
a.activeTabID = tabID
next := a.tabs[tabID]
// A direct tab click supersedes a pending ticketed activation's
// publication (prune + ready event), but does not cancel its build: the
// tab stays open in this layout, so the build may legitimately complete.
@@ -3146,6 +3106,12 @@ func (a *App) SetActiveTab(tabID string) error {
// I/O outside the lock — disk writes can block for hundreds of ms on
// Windows when antivirus or the search indexer briefly locks the file.
a.saveTabsWrite(dir, entries, activeID, version)
if active != nil {
active.clearRuntimeDisplayCurrency()
}
if next != nil {
next.clearRuntimeDisplayCurrency()
}
if supersededReq != "" {
a.emitTopicActivation(TopicActivationEvent{RequestID: supersededReq, TabID: supersededTab, Phase: topicActivationPhaseCancelled})
}
@@ -3902,10 +3868,11 @@ func (a *App) buildTabControllerWithContextCore(tab *WorkspaceTab, loadedSession
buildCtx, registration := beginSharedHostMCPRegistration(buildCtx, sharedHost)
defer registration.rollback()
ctrl, err := a.buildTabControllerBootFenced(buildCtx, extensionGen, boot.Options{
Model: model,
RequireKey: false,
AutoPricingCurrency: a.desktopAutoPricingCurrency(),
StatsSource: "desktop", TaskStore: a.taskStore(), OnConfigLoadWarnings: a.configLoadWarningsHandler(),
Model: model,
RequireKey: false,
StatsSource: "desktop",
TaskStore: a.taskStore(),
OnConfigLoadWarnings: a.configLoadWarningsHandler(),
Sink: sink,
WorkspaceRoot: root,
SessionDir: sessionDir,
@@ -4104,11 +4071,7 @@ func (a *App) buildTabControllerWithContextCore(tab *WorkspaceTab, loadedSession
// across sessions and persisted the stale totals into the new
// session's sidecar on the next event (#5850).
snapshot := loadTelemetry(path + ".telemetry.json")
tab.telemMu.Lock()
tab.readTelemetry = snapshot.ReadFiles
tab.usageTelemetry = snapshot.Usage
tab.telemetrySessionKey = sessionRuntimeKey(path)
tab.telemMu.Unlock()
tab.replaceTelemetry(snapshot, sessionRuntimeKey(path))
}
}
@@ -5774,16 +5737,6 @@ func (a *App) setDesktopLocale(locale string) {
}
}
func (a *App) desktopAutoPricingCurrency() string {
if a != nil {
switch a.desktopLocale.Load() {
case desktopLocaleZh, desktopLocaleZhTW:
return "CNY"
}
}
return "USD"
}
func (a *App) localizedDefaultTopicTitle() string {
switch a.desktopLocale.Load() {
case desktopLocaleZh:
@@ -6423,7 +6376,7 @@ func ensureTopicIndexed(scope, workspaceRoot, topicID, title, source string) err
func saveTelemetry(path string, snapshot tabTelemetrySnapshot) error {
if snapshot.Version == 0 {
snapshot.Version = 2
snapshot.Version = 3
}
if snapshot.ReadFiles == nil {
snapshot.ReadFiles = []readFileRecord{}
@@ -6442,7 +6395,7 @@ func saveTelemetry(path string, snapshot tabTelemetrySnapshot) error {
func loadTelemetry(path string) tabTelemetrySnapshot {
b, err := readFileUTF8(path)
if err != nil {
return tabTelemetrySnapshot{Version: 2, ReadFiles: []readFileRecord{}}
return tabTelemetrySnapshot{Version: 3, ReadFiles: []readFileRecord{}}
}
var snapshot tabTelemetrySnapshot
if err := json.Unmarshal(b, &snapshot); err == nil && (snapshot.Version > 0 || snapshot.ReadFiles != nil) {
@@ -6452,6 +6405,36 @@ func loadTelemetry(path string) tabTelemetrySnapshot {
if snapshot.Usage.SessionCost == 0 && snapshot.Usage.SessionCostUsd > 0 {
snapshot.Usage.SessionCost = snapshot.Usage.SessionCostUsd
}
// Lazy-migrate pre-CostQuote telemetry: keep original amount, mark legacy.
// Never reconstruct wiped mixed-currency zeros from current price tables.
if snapshot.Version < 3 && snapshot.Usage.CostLedger == nil && snapshot.Usage.SessionCost > 0 {
q := billing.MigrateLegacyUsage(billing.LegacyUsageRecord{
SessionCost: snapshot.Usage.SessionCost,
SessionCurrency: snapshot.Usage.SessionCurrency,
EndedAt: time.Now().UTC(),
})
ledger := billing.NewLedger()
ledger.Add(q, billing.UsageTokens{
PromptTokens: snapshot.Usage.PromptTokens,
CompletionTokens: snapshot.Usage.CompletionTokens,
}, time.Now().UTC())
snapshot.Usage.CostLedger = ledger
total := ledger.Total(billing.NormalizeCurrency(snapshot.Usage.SessionCurrency))
snapshot.Usage.SessionCostQuote = &total
snapshot.Usage.SessionCostComplete = total.Complete
snapshot.Version = 3
} else if snapshot.Version < 3 && snapshot.Usage.SessionCost <= 0 && strings.TrimSpace(snapshot.Usage.SessionCurrency) != "" {
// Explicit zero with currency: prior mixed-currency wipe — mark incomplete.
q := billing.MigrateLegacyUsage(billing.LegacyUsageRecord{
SessionCost: 0,
SessionCurrency: snapshot.Usage.SessionCurrency,
})
snapshot.Usage.SessionCostQuote = &q
snapshot.Usage.SessionCostComplete = false
snapshot.Version = 3
} else if snapshot.Version < 3 {
snapshot.Version = 3
}
return snapshot
}
var records []readFileRecord
@@ -7228,6 +7211,10 @@ type ContextPanelInfo struct {
SessionCost float64 `json:"sessionCost"`
SessionCurrency string `json:"sessionCurrency,omitempty"`
SessionCostUsd float64 `json:"sessionCostUsd,omitempty"`
SessionCostComplete bool `json:"sessionCostComplete,omitempty"`
SessionCostEstimated bool `json:"sessionCostEstimated,omitempty"`
SessionBillingMode string `json:"sessionBillingMode,omitempty"`
SessionCostQuote *billing.CostQuote `json:"sessionCostQuote,omitempty"`
Sources map[string]usageSourceStats `json:"sources,omitempty"`
Mock bool `json:"mock,omitempty"`
ReadFiles []readFileRecord `json:"readFiles"`
@@ -7272,7 +7259,7 @@ func (a *App) ContextPanel(tabID string) ContextPanelInfo {
info.UsedTokens = u.PromptTokens + u.CompletionTokens
}
if info.UsedTokens == 0 {
if snap := tab.telemetrySnapshot(); snap.Usage.LastUsedTokens > 0 {
if snap := tab.displayTelemetrySnapshot(); snap.Usage.LastUsedTokens > 0 {
info.UsedTokens = snap.Usage.LastUsedTokens
}
}
@@ -7287,7 +7274,7 @@ func (a *App) ContextPanel(tabID string) ContextPanelInfo {
// Executor rebuilt (session rebind): fall back to the telemetry-
// persisted per-turn breakdown so the donut chart and type
// breakdown show the last turn's composition instead of "other".
snap := tab.telemetrySnapshot()
snap := tab.displayTelemetrySnapshot()
info.PromptTokens = snap.Usage.LastPromptTokens
info.CompletionTokens = snap.Usage.LastCompletionTokens
info.ReasoningTokens = snap.Usage.LastReasoningTokens
@@ -7297,7 +7284,7 @@ func (a *App) ContextPanel(tabID string) ContextPanelInfo {
}
}
telemetry := tab.telemetrySnapshot()
telemetry := tab.displayTelemetrySnapshot()
if records := telemetry.ReadFiles; records != nil {
info.ReadFiles = records
}
@@ -7308,6 +7295,16 @@ func (a *App) ContextPanel(tabID string) ContextPanelInfo {
info.SessionCost = usage.SessionCost
info.SessionCurrency = usage.SessionCurrency
info.SessionCostUsd = usage.SessionCostUsd
info.SessionCostComplete = usage.SessionCostComplete
info.SessionCostEstimated = true
info.SessionCostQuote = usage.SessionCostQuote
if usage.SessionCostQuote != nil {
info.SessionBillingMode = usage.SessionCostQuote.BillingMode
info.SessionCostEstimated = usage.SessionCostQuote.Estimated
if !usage.SessionCostQuote.Complete {
info.SessionCostComplete = false
}
}
info.Sources = usage.Sources
info.SessionCacheHitTokens = usage.CacheHitTokens
info.SessionCacheMissTokens = usage.CacheMissTokens
+172
View File
@@ -0,0 +1,172 @@
package main
import (
"time"
"reasonix/internal/billing"
"reasonix/internal/control"
"reasonix/internal/provider"
)
func (a *App) balanceRequestTarget(tabID string) (*WorkspaceTab, control.SessionAPI, uint64) {
a.mu.RLock()
tab := a.tabByIDLocked(tabID)
var ctrl control.SessionAPI
if tab != nil {
ctrl = tab.Ctrl
}
a.mu.RUnlock()
if tab == nil || ctrl == nil {
return nil, nil, 0
}
return tab, ctrl, tab.runtimeDisplayGeneration()
}
func (a *App) applyBalanceDisplayHint(tabID string, target *WorkspaceTab, ctrl control.SessionAPI, requested, primary string, generation uint64) {
if requested != "" || primary == "" || a.balanceDisplayCurrency() != "" {
return
}
// A response from an old tab, model, session, or display preference may
// never rebind the current session.
a.mu.RLock()
current := a.tabByIDLocked(tabID)
currentController := current != nil && current.Ctrl == ctrl
a.mu.RUnlock()
if current == target && currentController {
target.selectRuntimeDisplayCurrencyAtGeneration(primary, generation)
}
}
func (t *WorkspaceTab) replaceTelemetry(snapshot tabTelemetrySnapshot, sessionKey string) {
if t == nil {
return
}
t.telemMu.Lock()
t.readTelemetry = append([]readFileRecord(nil), snapshot.ReadFiles...)
t.usageTelemetry = cloneSessionUsageStats(snapshot.Usage)
t.runtimeCostDisplayCurrency = ""
t.runtimeCostQuote = nil
t.runtimeCostGeneration++
t.telemetrySessionKey = sessionKey
t.telemMu.Unlock()
}
// selectDisplayCurrency rebinds occurrence-time valuations without repricing.
func (t *WorkspaceTab) selectDisplayCurrency(display string) bool {
if t == nil {
return false
}
t.telemMu.Lock()
defer t.telemMu.Unlock()
display = billing.NormalizeCurrency(display)
t.runtimeCostDisplayCurrency = ""
t.runtimeCostQuote = nil
t.runtimeCostGeneration++
if t.usageTelemetry.CostLedger == nil || len(t.usageTelemetry.CostLedger.Entries) == 0 {
if t.usageTelemetry.SessionCost <= 0 {
return true
}
occurred := time.Now().UTC()
quote := billing.MigrateLegacyUsage(billing.LegacyUsageRecord{
SessionCost: t.usageTelemetry.SessionCost,
SessionCurrency: t.usageTelemetry.SessionCurrency,
EndedAt: occurred,
})
t.usageTelemetry.CostLedger = billing.NewLedger()
t.usageTelemetry.CostLedger.Add(quote, billing.UsageTokens{
PromptTokens: t.usageTelemetry.PromptTokens,
CompletionTokens: t.usageTelemetry.CompletionTokens,
}, occurred)
}
total := t.usageTelemetry.CostLedger.SelectDisplay(display)
t.usageTelemetry.SessionCostQuote = &total
t.usageTelemetry.SessionCostComplete = total.Complete
if total.Selected == nil {
t.usageTelemetry.SessionCostComplete = false
t.usageTelemetry.SessionCost = 0
t.usageTelemetry.SessionCurrency = ""
t.usageTelemetry.SessionCostUsd = 0
return true
}
t.usageTelemetry.SessionCost = total.Selected.Float64()
t.usageTelemetry.SessionCurrency = total.LegacyCurrencyCode()
t.usageTelemetry.SessionCostUsd = t.usageTelemetry.SessionCost
return true
}
// selectRuntimeDisplayCurrency applies an automatic wallet hint only to the
// live tab/session. It never mutates the persisted telemetry or configuration.
func (t *WorkspaceTab) selectRuntimeDisplayCurrency(display string) bool {
if t == nil {
return false
}
t.telemMu.Lock()
defer t.telemMu.Unlock()
return t.selectRuntimeDisplayCurrencyLocked(display)
}
// selectRuntimeDisplayCurrencyAtGeneration applies an asynchronous wallet
// hint only if the tab/session display binding is still the one that started
// the balance request.
func (t *WorkspaceTab) selectRuntimeDisplayCurrencyAtGeneration(display string, generation uint64) bool {
if t == nil {
return false
}
t.telemMu.Lock()
defer t.telemMu.Unlock()
if t.runtimeCostGeneration != generation {
return false
}
return t.selectRuntimeDisplayCurrencyLocked(display)
}
func (t *WorkspaceTab) selectRuntimeDisplayCurrencyLocked(display string) bool {
display = billing.NormalizeCurrency(display)
t.runtimeCostDisplayCurrency = display
t.runtimeCostQuote = nil
if t.usageTelemetry.CostLedger != nil && len(t.usageTelemetry.CostLedger.Entries) > 0 {
total := t.usageTelemetry.CostLedger.SelectDisplay(display)
t.runtimeCostQuote = &total
return true
}
if t.usageTelemetry.SessionCost > 0 {
quote := billing.MigrateLegacyUsage(billing.LegacyUsageRecord{
SessionCost: t.usageTelemetry.SessionCost,
SessionCurrency: t.usageTelemetry.SessionCurrency,
EndedAt: time.Now().UTC(),
})
total := billing.AggregateQuotes([]billing.CostQuote{quote}, display)
t.runtimeCostQuote = &total
}
return true
}
func (t *WorkspaceTab) runtimeDisplayGeneration() uint64 {
if t == nil {
return 0
}
t.telemMu.Lock()
defer t.telemMu.Unlock()
return t.runtimeCostGeneration
}
func (t *WorkspaceTab) clearRuntimeDisplayCurrency() {
if t == nil {
return
}
t.telemMu.Lock()
t.runtimeCostDisplayCurrency = ""
t.runtimeCostQuote = nil
t.runtimeCostGeneration++
t.telemMu.Unlock()
}
// repriceUsage preserves the legacy call shape as a display-only rebind.
func (t *WorkspaceTab) repriceUsage(pricingBySource map[string]*provider.Pricing) bool {
_ = pricingBySource
display := ""
if t != nil {
display = billing.NormalizeCurrency(t.usageTelemetry.SessionCurrency)
}
return t.selectDisplayCurrency(display)
}
+9 -9
View File
@@ -108,7 +108,7 @@ func TestForkTopicTitleUsesDesktopLocale(t *testing.T) {
}
}
func TestSetTrayLocaleSchedulesAutoCurrencyRefresh(t *testing.T) {
func TestSetTrayLocaleDoesNotChangeAutoCurrency(t *testing.T) {
isolateDesktopUserDirs(t)
cfg := config.Default()
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
@@ -132,8 +132,8 @@ func TestSetTrayLocaleSchedulesAutoCurrencyRefresh(t *testing.T) {
t.Fatalf("SetTrayLocale: %v", err)
}
for _, tabID := range []string{"active", "inactive"} {
if !app.deferredRebuildPending(tabID) {
t.Fatalf("currency refresh for %q was not scheduled", tabID)
if app.deferredRebuildPending(tabID) {
t.Fatalf("locale change scheduled currency refresh for %q", tabID)
}
}
}
@@ -168,21 +168,21 @@ func TestDesktopEffectivePricingCurrencyUsesLocaleOnlyForAuto(t *testing.T) {
app.setDesktopLocale("zh-CN")
cfg := config.Default()
if got := app.desktopEffectivePricingCurrency(cfg); got != "CNY" {
t.Fatalf("auto pricing currency = %q, want CNY", got)
if got := app.desktopEffectivePricingCurrency(cfg); got != "" {
t.Fatalf("auto pricing currency = %q, want unresolved auto", got)
}
cfg.Desktop.Language = "en"
cfg.ApplyDeepSeekOfficialDefaultPricing()
if got := app.desktopEffectivePricingCurrency(cfg); got != "USD" {
t.Fatalf("explicit desktop language pricing currency = %q, want USD", got)
if got := app.desktopEffectivePricingCurrency(cfg); got != "" {
t.Fatalf("desktop language changed pricing currency = %q", got)
}
cfg.Desktop.Language = ""
cfg.Language = "en"
cfg.ApplyDeepSeekOfficialDefaultPricing()
if got := app.desktopEffectivePricingCurrency(cfg); got != "USD" {
t.Fatalf("explicit CLI language pricing currency = %q, want USD", got)
if got := app.desktopEffectivePricingCurrency(cfg); got != "" {
t.Fatalf("CLI language changed pricing currency = %q", got)
}
cfg.Language = ""
+67 -20
View File
@@ -8,6 +8,7 @@ import (
"time"
"reasonix/internal/agent"
"reasonix/internal/billing"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
@@ -70,8 +71,8 @@ func TestWorkspaceTabAggregatesSessionUsageTelemetry(t *testing.T) {
if got.ElapsedMs != 1500 {
t.Fatalf("elapsed = %d, want 1500", got.ElapsedMs)
}
if got.SessionCost <= 0 || got.SessionCurrency != "¥" {
t.Fatalf("cost = %f %q, want positive ¥", got.SessionCost, got.SessionCurrency)
if got.SessionCost <= 0 || (got.SessionCurrency != "CNY" && got.SessionCurrency != "¥") {
t.Fatalf("cost = %f %q, want positive CNY", got.SessionCost, got.SessionCurrency)
}
if got.Sources[event.UsageSourceSubagent].SessionCost <= 0 || got.Sources[event.UsageSourceSubagent].RequestCount != 3 {
t.Fatalf("subagent source stats = %+v, want three costed requests", got.Sources[event.UsageSourceSubagent])
@@ -85,8 +86,8 @@ func TestWorkspaceTabAggregatesSessionUsageTelemetry(t *testing.T) {
if context.SessionTokens != 140 {
t.Fatalf("context usage session tokens = %d, want 140", context.SessionTokens)
}
if context.SessionCost <= 0 || context.SessionCurrency != "¥" {
t.Fatalf("context usage cost = %f %q, want positive ¥", context.SessionCost, context.SessionCurrency)
if context.SessionCost <= 0 || (context.SessionCurrency != "CNY" && context.SessionCurrency != "¥") {
t.Fatalf("context usage cost = %f %q, want positive CNY", context.SessionCost, context.SessionCurrency)
}
if context.CacheHitTokens != 70 || context.CacheMissTokens != 30 {
t.Fatalf("context usage cache tokens = hit %d miss %d, want 70/30", context.CacheHitTokens, context.CacheMissTokens)
@@ -117,21 +118,51 @@ func TestWorkspaceTabMarksEstimatedExecutorTurn(t *testing.T) {
}
func TestWorkspaceTabRepricesUsageWithoutMixingCurrencies(t *testing.T) {
// repriceUsage is now a display rebind: it must not recompute from new rates.
tab := &WorkspaceTab{}
tab.recordUsage(event.Event{
Usage: &provider.Usage{PromptTokens: 1_000_000, CompletionTokens: 100_000, TotalTokens: 1_100_000},
UsageSource: event.UsageSourceExecutor,
Pricing: &provider.Pricing{Input: 1, Output: 2, Currency: "CNY"},
})
before := tab.telemetrySnapshot().Usage.SessionCost
if ok := tab.repriceUsage(map[string]*provider.Pricing{
event.UsageSourceExecutor: {Input: 0.14, Output: 0.28, Currency: "USD"},
}); !ok {
t.Fatal("repriceUsage rejected a complete source mapping")
t.Fatal("repriceUsage rejected display rebind")
}
got := tab.telemetrySnapshot().Usage
want := 0.14 + 0.1*0.28
if got.SessionCurrency != "$" || got.SessionCost != want {
t.Fatalf("repriced usage = %f %q, want %f USD", got.SessionCost, got.SessionCurrency, want)
if got.SessionCost != before {
t.Fatalf("display rebind mutated occurrence cost: before=%f after=%f", before, got.SessionCost)
}
if got.CostLedger == nil || len(got.CostLedger.Entries) == 0 {
t.Fatal("expected cost ledger entries")
}
}
func TestRuntimeWalletHintDoesNotPersistTelemetry(t *testing.T) {
tab := &WorkspaceTab{}
tab.recordUsage(event.Event{
ModelRef: "deepseek/deepseek-v4-flash",
Usage: &provider.Usage{PromptTokens: 1_000_000, TotalTokens: 1_000_000},
Pricing: &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: "USD"},
})
persisted := tab.telemetrySnapshot().Usage
if persisted.SessionCurrency != "USD" || persisted.SessionCost <= 0 {
t.Fatalf("persisted original = %+v", persisted)
}
if !tab.selectRuntimeDisplayCurrency("CNY") {
t.Fatal("runtime wallet hint rejected")
}
displayed := tab.displayTelemetrySnapshot().Usage
if displayed.SessionCurrency != "CNY" || displayed.SessionCostQuote == nil || displayed.SessionCostQuote.DisplayStatus != billing.DisplayStatusMatched {
t.Fatalf("runtime display = %+v", displayed)
}
// Persistence and a later session reload must remain on the occurrence-time
// original currency; the automatic wallet hint is process-local only.
persisted = tab.telemetrySnapshot().Usage
if persisted.SessionCurrency != "USD" || persisted.SessionCostQuote == displayed.SessionCostQuote {
t.Fatalf("runtime hint leaked into persisted telemetry = %+v", persisted)
}
}
@@ -148,22 +179,28 @@ func TestWorkspaceTabRepricesCacheWritesWithoutLosingBillingTier(t *testing.T) {
UsageSource: event.UsageSourceExecutor,
Pricing: &provider.Pricing{Input: 2, Currency: "CNY"},
})
// 400K ordinary input units + 200K billed cache-write units at input=2 → 1.2 CNY.
got := tab.telemetrySnapshot().Usage
if got.SessionCost < 1.19 || got.SessionCost > 1.21 {
t.Fatalf("cache-write usage cost = %f, want ~1.2", got.SessionCost)
}
if got.CacheWriteTokens != 100_000 || got.CacheWriteBilledTokens != 200_000 {
t.Fatalf("persisted cache writes = raw %d billed %v", got.CacheWriteTokens, got.CacheWriteBilledTokens)
}
// Display rebind must preserve occurrence-time cost.
before := got.SessionCost
if ok := tab.repriceUsage(map[string]*provider.Pricing{
event.UsageSourceExecutor: {Input: 1, Currency: "USD"},
}); !ok {
t.Fatal("repriceUsage rejected cache-write usage")
}
got := tab.telemetrySnapshot().Usage
// 400K ordinary input units + 200K billed cache-write units.
if got.SessionCurrency != "$" || got.SessionCost != 0.6 {
t.Fatalf("repriced cache-write usage = %f %q, want 0.6 USD", got.SessionCost, got.SessionCurrency)
}
if got.CacheWriteTokens != 100_000 || got.CacheWriteBilledTokens != 200_000 {
t.Fatalf("persisted cache writes = raw %d billed %v", got.CacheWriteTokens, got.CacheWriteBilledTokens)
got = tab.telemetrySnapshot().Usage
if got.SessionCost != before {
t.Fatalf("display rebind mutated cost: before=%f after=%f", before, got.SessionCost)
}
}
func TestRepriceTabUsageUsesDetectedLocaleForAutoCurrency(t *testing.T) {
func TestRepriceTabUsageLeavesAutoCurrencyUnresolved(t *testing.T) {
isolateDesktopUserDirs(t)
cfg := config.Default()
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
@@ -175,14 +212,20 @@ func TestRepriceTabUsageUsesDetectedLocaleForAutoCurrency(t *testing.T) {
UsageSource: event.UsageSourceExecutor,
Pricing: &provider.Pricing{Input: 0.14, Currency: "USD"},
})
before := tab.telemetrySnapshot().Usage.SessionCost
app := NewApp()
app.setDesktopLocale("zh-CN")
app.repriceTabUsageForCurrentCurrency(tab)
got := tab.telemetrySnapshot().Usage
if got.SessionCurrency != "¥" || got.SessionCost != 1 {
t.Fatalf("auto-locale repriced usage = %f %q, want 1 CNY", got.SessionCost, got.SessionCurrency)
// Locale must not rebind or recompute pricing in automatic mode.
if got.SessionCost <= 0 {
t.Fatalf("auto-locale lost cost: %f", got.SessionCost)
}
// Without a wallet hint, original USD remains the selected fact.
if got.SessionCost != before && got.CostLedger == nil {
t.Fatalf("auto-locale cleared ledger/cost: before=%f after=%f", before, got.SessionCost)
}
}
@@ -197,8 +240,12 @@ func TestWorkspaceTabDoesNotAddDifferentCurrencies(t *testing.T) {
Pricing: &provider.Pricing{Input: 0.14, Currency: "USD"},
})
got := tab.telemetrySnapshot().Usage
if got.SessionCurrency != "$" || got.SessionCost != 0.14 {
t.Fatalf("mixed-currency usage = %f %q, want only the current USD bucket", got.SessionCost, got.SessionCurrency)
// Mixed originals stay in the ledger; we never invent a cross-currency float sum.
if got.CostLedger == nil || len(got.CostLedger.Entries) < 2 {
t.Fatalf("expected separate ledger entries for mixed currencies, got %+v", got.CostLedger)
}
if got.SessionCostComplete {
t.Fatalf("mixed currencies without a common display should be incomplete")
}
}
+6
View File
@@ -13,6 +13,12 @@ agent over standard input and output. Editors and other ACP hosts launch the
process, open one or more workspace-scoped sessions, and receive streamed
messages, tool activity, plans, permission requests, and configuration updates.
Session status usage objects may include structured `costQuote` (original
currency, `originalTotals`, identity/official-table valuations,
`costComplete`, `displayComplete`, `displayStatus`, and `billingMode`) alongside legacy
`estimatedCost` / `currency` aliases that mirror the selected display valuation.
See [Billing](./BILLING.md).
## Start the agent
An ACP host should launch one of these commands:
+5
View File
@@ -12,6 +12,11 @@ Reasonix 实现了 Agent Client Protocol(ACP)v1,通过标准输入输出
JSON-RPC 2.0 agent。编辑器和其他 ACP host 负责启动进程、打开一个或多个工作区会话,
并接收流式消息、工具活动、计划、权限请求和配置更新。
会话状态 usage 可携带结构化 `costQuote`(原币、`originalTotals`、identity/官方区域价表
估值、`costComplete`、`displayComplete`、`displayStatus`、`billingMode`),同时保留镜像所选
展示估值的旧字段 `estimatedCost` / `currency`。
详见 [计费文档](./BILLING.zh-CN.md)。
## 启动 agent
ACP host 应启动以下命令之一:
+66
View File
@@ -0,0 +1,66 @@
# Billing, display currency, and cost quotes
Reasonix keeps three facts separate:
1. `original`: an estimate from the selected public/custom rate card in its
pricing-table currency. It is not an invoice or a provider debit.
2. `valuations`: occurrence-time `identity` and, when available, an
`official_table` estimate for the same model in the other official region.
3. Wallet balances: the exact original-currency values returned by a provider.
Reasonix has no runtime FX download, cache, refresh goroutine, or wallet
conversion. Old `fx`/`rateSnapshot` fields remain readable for history only;
new quotes never generate them.
```toml
[billing]
display_currency = "auto" # auto | CNY | USD
[[providers]]
billing_currency = "USD" # pricing-table basis, not settlement currency
billing_mode = "payg" # payg | subscription_equivalent
```
Legacy `[desktop].currency` remains readable and migrates to
`[billing].display_currency`. `auto` is intentionally unresolved in config:
one valid wallet currency may become a tab/session hint; otherwise a single
original currency is selected or mixed currencies are shown as buckets. A
language, browser locale, or host region never changes a rate card.
## CostQuote
`usage.costQuote` is the canonical host-side usage payload:
| Field | Meaning |
| --- | --- |
| `original` | Original-currency rate-card estimate |
| `originalTotals[]` | ISO-sorted original buckets for mixed aggregates |
| `valuations.*.basis` | `identity` or `official_table` for new quotes |
| `selected` | A single amount only when a display total exists |
| `costComplete` | Usage and pricing facts are complete |
| `displayComplete` | A requested single-currency total exists |
| `complete` | Compatibility alias mirroring `displayComplete` |
| `displayStatus` | `matched`, `fallback_original`, `bucketed`, or `unavailable` |
| `aggregateMode` | `single_currency`, `common_valuation`, or `currency_buckets` |
If a requested currency is unavailable but every original is the same, the
original amount is shown with `fallback_original`. Mixed originals produce
`originalTotals` and no scalar zero. `—` is reserved for missing usage or
pricing (`unavailable`). Legacy scalar aliases (`cost`, `costUsd`,
`total_cost`) are written only when `selected` exists.
## Wallets and diagnostics
Wallets are never converted or cross-added. An explicit target uses the exact
matching wallet; if it is absent, the real currency is shown with an ISO
prefix. Automatic mode uses a single valid wallet currency only as a runtime
hint. Multiple/unknown/error responses do not affect the cost facts.
```sh
reasonix doctor billing
reasonix doctor billing --json
```
The compatible `fx` report is always `enabled=false` and has no cache. The
report also lists the automatic selection policy, pricing-table currencies,
and official-catalog matches.
+59
View File
@@ -0,0 +1,59 @@
# 计费、展示币种与费用报价
Reasonix 将三类事实分开:
1. `original`:按公开/自定义价表计算的原币估算,不是发票或实际扣款。
2. `valuations`:调用发生时记录的 `identity`,以及可用时同模型另一官方区域的
`official_table` 估算。
3. 钱包余额:供应商接口返回的原币事实。
运行时不再下载、缓存或刷新 FX,也不换算钱包。旧 JSON 中的 `fx`、
`rateSnapshot` 仍可读取为历史估值;新报价永远不会生成它们。
```toml
[billing]
display_currency = "auto" # auto | CNY | USD
[[providers]]
billing_currency = "USD" # 价表基准币种,不代表实际结算币种
billing_mode = "payg" # payg | subscription_equivalent
```
旧 `[desktop].currency` 仍可读并迁移到 `[billing].display_currency`。
`auto` 在配置层保持未解析:单一有效钱包币种可以成为当前 tab/session 的运行时
提示;否则单一原币直接展示,混币则按币种分桶。语言、浏览器 locale、主机区域都不再
改变价表。
## CostQuote
`usage.costQuote` 是所有主机表面的规范 usage 载荷:
| 字段 | 含义 |
| --- | --- |
| `original` | 原币价表估算 |
| `originalTotals[]` | 混币聚合时按 ISO 排序的原币桶 |
| `valuations.*.basis` | 新报价只有 `identity` 或 `official_table` |
| `selected` | 只有形成单一展示总额时才存在 |
| `costComplete` | usage 与价表事实完整 |
| `displayComplete` | 已形成请求的单币种展示总额 |
| `complete` | 兼容别名,始终镜像 `displayComplete` |
| `displayStatus` | `matched`、`fallback_original`、`bucketed`、`unavailable` |
| `aggregateMode` | `single_currency`、`common_valuation`、`currency_buckets` |
目标币种缺失但所有原币相同时,使用 `fallback_original` 展示原币;混币输出
`originalTotals`,不写伪造的 0。只有缺少 usage/价格时才是 `unavailable` 并显示 `—`。
旧标量别名(`cost`、`costUsd`、`total_cost`)仅在存在 `selected` 时双写。
## 钱包与诊断
钱包余额不换算、不跨币种求和。显式目标有对应钱包时显示该钱包;没有时显示真实币种
并加 ISO 前缀。自动模式只有一个有效钱包币种时才作为运行时费用提示;多钱包、未知币种
或请求失败不会影响费用事实。
```sh
reasonix doctor billing
reasonix doctor billing --json
```
兼容保留的 `fx` 对象固定为 `enabled=false`、无缓存;正文同时展示自动选择策略、价表
基准币种和官方目录匹配情况。
+26 -16
View File
@@ -84,23 +84,22 @@ different keys. Providers added or removed through setup are also added to or
removed from desktop provider access, so the same models are available in the
desktop app.
### Configure regional pricing currency
### Configure fee display currency
Use the user-global currency command to inspect or select the official DeepSeek
regional price table:
Use the user-global command to inspect or select the display currency:
```sh
reasonix config currency # show the saved and resolved currency
reasonix config currency auto # follow the resolved locale
reasonix config currency auto # wallet hint, then original price currency
reasonix config currency CNY
reasonix config currency USD
```
`auto` resolves Simplified or Traditional Chinese locales to CNY and English or
other locales to USD. An explicit `CNY` or `USD` selection remains independent
from the UI language. This preference is stored in the user config and cannot
be overridden by project `reasonix.toml`; `--local` is therefore not supported.
Custom provider prices are preserved.
`auto` remains unresolved in configuration. With one valid wallet currency it
can become a runtime session hint; otherwise CLI uses the original currency or
sorted currency buckets. Language and host locale never select a price table.
The preference is user-global and cannot be overridden by project
`reasonix.toml`; `--local` is therefore not supported. Custom prices are preserved.
In an interactive session, `/currency` shows the saved and resolved values, and
`/currency auto|CNY|USD` changes the preference and refreshes the current
@@ -210,12 +209,23 @@ The final structured object has this shape:
}
```
`total_cost` is denominated in the ISO currency code from `currency`, currently
`CNY` or `USD` for official DeepSeek pricing. `total_cost_usd` remains as a
numeric compatibility alias and mirrors `total_cost`; despite its legacy name,
it is not converted to USD when `currency` is `CNY`. New consumers must use
`total_cost` together with `currency`. A structured run fails instead of
reporting a misleading total if usage contains mixed currencies.
`total_cost` is present only when a single `selected` display amount exists (ISO
code in `currency`). Prefer the structured `cost_quote` field when present: it
carries the original estimate, `original_totals`, occurrence-time valuations
(`official_table` for dual-region public prices), `cost_complete`,
`display_complete`, `display_status`, and `billing_mode` (`payg` or `subscription_equivalent` for
pay-as-you-go equivalent estimates such as MiMo Token Plan).
`total_cost_usd` remains a numeric compatibility alias when `total_cost` exists
and does **not** imply USD. Mixed original currencies no longer fail the run:
`cost_complete` remains true when usage/pricing facts are known,
`display_complete` is false, and `original_costs`/`original_totals` list per-ISO
totals so clients never invent a cross-currency sum.
Global display preference is `[billing].display_currency` (`auto|CNY|USD`);
legacy `[desktop].currency` still migrates. Provider list prices use each
entry's frozen `billing_currency` and are never rewritten by display switches.
Diagnose with `reasonix doctor billing`.
Execution failures use `subtype: "error_during_execution"` and
`is_error: true`. Structured modes keep runtime errors in JSON instead of also
@@ -427,7 +437,7 @@ the displayed list matches the commands the TUI accepts.
| `/status` | Show model, effort, cache, Git, background jobs, and profile or balance details. |
| `/work-mode [economy\|balanced\|delivery]` | View or change the runtime profile; `/profile` is an alias. |
| `/theme [auto\|light\|dark\|style]` | View or change the CLI background mode and accent palette. |
| `/currency [auto\|CNY\|USD]` | View or change the user-global official pricing currency and refresh the runtime. |
| `/currency [auto\|CNY\|USD]` | View or change the user-global fee display currency and refresh the runtime. |
| `/paste-image` | Read a clipboard image and insert an editable attachment token. |
| `/mouse` | Toggle in-app mouse selection, scrollbar, and wheel handling. |
| `/effort` | View or change reasoning effort. |
+21 -12
View File
@@ -78,20 +78,20 @@ setup 会询问是否共享该凭据;两个 provider 使用不同 Key 时,
setup 添加或删除 provider 时,也会同步维护桌面端 provider access,因此相同模型可以
直接在桌面端使用。
### 配置区域定价货币
### 配置费用展示币种
使用用户全局货币命令查看或选择 DeepSeek 官方区域价格表:
使用用户全局货币命令查看或选择费用展示币种:
```sh
reasonix config currency # 显示已保存值和最终解析结果
reasonix config currency auto # 跟随解析后的 locale
reasonix config currency auto # 钱包币种优先,否则原币价表币种
reasonix config currency CNY
reasonix config currency USD
```
`auto` 会把简体或繁体中文 locale 解析为 CNY,把英文及其他 locale 解析为 USD。显式
选择 `CNY` 或 `USD` 后,货币不再跟随界面语言。该偏好只保存在用户全局配置中,项目
`reasonix.toml` 无法覆盖,因此不支持 `--local`。自定义 provider 价格不会被修改。
`auto` 在配置中保持未解析。只有一个有效钱包币种时,它才会成为当前运行时提示;否则
CLI 使用原币或按 ISO 排序的币种桶。语言和主机 locale 不再选择价表。该偏好只保存在
用户全局配置中,项目 `reasonix.toml` 无法覆盖,因此不支持 `--local`。自定义价格不会被修改。
在交互式会话中,`/currency` 显示已保存值和最终解析结果;
`/currency auto|CNY|USD` 会修改偏好并刷新当前运行时,同时保留当前对话。
@@ -192,11 +192,20 @@ reasonix run "运行测试" --output-format stream-json
}
```
`total_cost` 使用 `currency` 给出的 ISO 货币代码计价;DeepSeek 官方价格目前会输出
`CNY` 或 `USD`。`total_cost_usd` 作为数字兼容别名继续保留,并与 `total_cost` 数值
相同;即使 `currency` 为 `CNY`,它也不会按旧字段名自动换算为美元。新接入必须同时读取
`total_cost` 和 `currency`。如果一次结构化运行包含多种货币,Reasonix 会直接报错,
不会输出容易误解的合计金额。
`total_cost` 仅在形成单一 `selected` 展示金额时存在(ISO 代码见 `currency`)。有
`cost_quote` 时优先读它:含原币费用、`original_totals`、发生时的官方双区域
`official_table` 估值、`cost_complete`、`display_complete`、`display_status`,以及
`billing_mode`(`payg` 或 `subscription_equivalent`,后者表示如 MiMo Token Plan
的「按量等效估算」)。
`total_cost_usd` 仅为兼容别名,数值镜像 `total_cost`,**不表示一定是美元**。混用
多种原币时不会再报错:若 usage/价表事实完整则 `cost_complete=true`、
`display_complete=false`,并用 `original_costs`/`original_totals` 给出各原币明细,
绝不伪造跨币种合计。
全局展示偏好为 `[billing].display_currency`(`auto|CNY|USD`);旧
`[desktop].currency` 仍会迁移。供应商原币价表由各条目冻结的 `billing_currency`
决定,切换展示币种不会改写价表。可用 `reasonix doctor billing` 排查。
执行失败时使用 `subtype: "error_during_execution"` 和 `is_error: true`。
结构化模式会把运行时错误保留在 JSON 中,不再额外重复输出一份人类可读错误。
@@ -372,7 +381,7 @@ SSH 下远端进程无法读取本机剪贴板,请使用终端粘贴快捷键
| `/status` | 显示模型、effort、cache、Git、后台任务,以及工作模式或余额信息。 |
| `/work-mode [economy\|balanced\|delivery]` | 查看或切换运行时工作模式;`/profile` 是别名。 |
| `/theme [auto\|light\|dark\|style]` | 查看或切换 CLI 背景模式和强调色。 |
| `/currency [auto\|CNY\|USD]` | 查看或切换用户全局官方定价货币,并刷新当前运行时。 |
| `/currency [auto\|CNY\|USD]` | 查看或切换用户全局费用展示币种,并刷新当前运行时。 |
| `/paste-image` | 读取剪贴板图片并插入可编辑的附件标记。 |
| `/mouse` | 切换应用内鼠标选区、滚动条和滚轮处理。 |
| `/effort` | 查看或切换 reasoning effort。 |
+1
View File
@@ -12,6 +12,7 @@
## Contents
- [Configuration](#configuration)
- [Billing and display currency](./BILLING.md)
- [CLI reference](./CLI.md)
- [Environment variables](#environment-variables)
- [Web frontend](#web-frontend)
+1
View File
@@ -12,6 +12,7 @@
## 目录
- [配置](#配置)
- [计费与展示币种](./BILLING.zh-CN.md)
- [CLI 命令参考](./CLI.zh-CN.md)
- [环境变量](#环境变量)
- [Web 前端](#web-前端)
@@ -0,0 +1,25 @@
# Billing currency refactor — acceptance checklist
## Automated (run before merge)
- [x] `go test ./...`
- [x] `cd desktop && go test ./...`
- [x] Key packages `-race` (billing, event, boot, cli, config, acp, serve, stats)
- [x] `cd desktop/frontend && pnpm typecheck && pnpm test:usage-stats && pnpm build`
- [x] `scripts/check-cache-impact.sh` → no provider-visible changes
- [x] `reasonix doctor billing` surfaces display/billing/FX-disabled/catalog
## Manual UI (Desktop + Serve)
1. **CNY ↔ USD hot switch** — Settings → Display currency; session estimate rebinds without zeroing; provider prices unchanged.
2. **Restart restore** — reload a session with `*.telemetry.json` v2 legacy cost; ledger migrates lazily; no invented totals for wiped zeros.
3. **Mixed models** — main + planner + subagent with different currencies; total uses shared valuations or shows incomplete.
4. **No runtime FX** — no ECB request, cache, or refresh goroutine; custom-currency totals stay in original buckets; official dual-region still values via `official_table`.
5. **Wallet** — multi-currency balance shows `≈` total + detail tooltip (originals, rate date).
6. **MiMo Token Plan** — cost label uses PAYG-equivalent wording when `billing_mode=subscription_equivalent`.
7. **Narrow / restore** — Serve and Desktop after session resume show same selected estimate.
## GitHub follow-up
- [ ] Open PR; link #4565, #3527, #4546; note supersedes #7790
- [ ] After merge, mark PR #7790 superseded
+101 -16
View File
@@ -9,8 +9,10 @@ import (
"slices"
"strings"
"sync"
"time"
"reasonix/internal/agent"
"reasonix/internal/billing"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
@@ -101,16 +103,22 @@ type ReasonixFinalReadiness struct {
}
type ReasonixUsage struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
ReasoningTokens int `json:"reasoningTokens"`
CacheHitTokens int `json:"cacheHitTokens"`
CacheMissTokens int `json:"cacheMissTokens"`
Estimated bool `json:"estimated,omitempty"`
CacheHitRatio *float64 `json:"cacheHitRatio"`
EstimatedCost *float64 `json:"estimatedCost"`
Currency *string `json:"currency"`
UsageSource string `json:"usageSource"`
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
ReasoningTokens int `json:"reasoningTokens"`
CacheHitTokens int `json:"cacheHitTokens"`
CacheMissTokens int `json:"cacheMissTokens"`
Estimated bool `json:"estimated,omitempty"`
CacheHitRatio *float64 `json:"cacheHitRatio"`
EstimatedCost *float64 `json:"estimatedCost"`
Currency *string `json:"currency"`
CostComplete *bool `json:"costComplete,omitempty"`
DisplayComplete *bool `json:"displayComplete,omitempty"`
DisplayStatus string `json:"displayStatus,omitempty"`
AggregateMode string `json:"aggregateMode,omitempty"`
OriginalTotals []billing.Money `json:"originalTotals,omitempty"`
CostQuote *billing.CostQuote `json:"costQuote,omitempty"`
UsageSource string `json:"usageSource"`
}
type ReasonixStatusUsage struct {
@@ -158,9 +166,12 @@ type usageAccumulator struct {
estimatedCost float64
currency string
source string
costComplete bool
quoteEvents int
quoteLedger *billing.Ledger
}
func (a *usageAccumulator) add(u *provider.Usage, pricing *provider.Pricing, source string) {
func (a *usageAccumulator) addQuoted(u *provider.Usage, pricing *provider.Pricing, quote *billing.CostQuote, source string) {
if u == nil {
return
}
@@ -180,15 +191,59 @@ func (a *usageAccumulator) add(u *provider.Usage, pricing *provider.Pricing, sou
} else if a.source != source {
a.source = "mixed"
}
if quote == nil && pricing != nil {
quote = event.EnsureCostQuote(event.Event{Kind: event.Usage, Usage: u, Pricing: pricing, UsageSource: source}, nil)
}
if quote != nil {
a.pricedEvents++
a.quoteEvents++
a.estimated = true
if a.quoteLedger == nil {
a.quoteLedger = billing.NewLedger()
}
a.quoteLedger.Add(*quote, billing.UsageTokens{
PromptTokens: u.PromptTokens,
CompletionTokens: u.CompletionTokens,
CacheHitTokens: u.CacheHitTokens,
CacheMissTokens: u.CacheMissTokens,
CacheWriteTokens: u.CacheWriteTokens,
CacheWriteBilledTokens: u.CacheWriteBilledTokens,
Estimated: u.Estimated,
}, time.Time{})
if quote.Selected != nil {
cur := quote.LegacyCurrencyCode()
if a.pricedEvents == 1 {
a.currency = cur
a.costComplete = quote.Complete
} else if a.currency != cur {
// Different selected currencies — re-aggregate later via quotes.
a.currency = cur
}
if !quote.Complete {
a.costComplete = false
}
a.estimatedCost += quote.Selected.Float64()
} else if pricing != nil {
// Incomplete display valuation: keep original, mark incomplete.
a.costComplete = false
a.estimatedCost += quote.Original.Float64()
if a.currency == "" {
a.currency = billing.NormalizeCurrency(quote.Original.Currency)
}
}
return
}
if pricing != nil {
currency := strings.TrimSpace(pricing.Currency)
currency := billing.NormalizeCurrency(pricing.Currency)
if currency == "" {
currency = pricing.Symbol()
}
if a.pricedEvents == 0 {
a.currency = currency
a.costComplete = true
} else if a.currency != currency {
a.currency = ""
a.costComplete = false
}
a.estimatedCost += pricing.Cost(u)
a.pricedEvents++
@@ -212,11 +267,31 @@ func (a usageAccumulator) wire() ReasonixUsage {
ratio := float64(a.cacheHitTokens) / float64(total)
usage.CacheHitRatio = &ratio
}
if a.quoteLedger != nil && a.quoteEvents == a.pricedEvents && len(a.quoteLedger.Entries) > 0 {
agg := a.quoteLedger.Total("")
usage.CostQuote = &agg
costComplete := agg.CostComplete
displayComplete := agg.DisplayComplete
usage.CostComplete = &costComplete
usage.DisplayComplete = &displayComplete
usage.DisplayStatus = agg.DisplayStatus
usage.AggregateMode = agg.AggregateMode
usage.OriginalTotals = append([]billing.Money(nil), agg.OriginalTotals...)
if agg.Selected != nil && !math.IsNaN(agg.Selected.Float64()) && !math.IsInf(agg.Selected.Float64(), 0) {
cost := agg.Selected.Float64()
currency := agg.LegacyCurrencyCode()
usage.EstimatedCost = &cost
usage.Currency = &currency
}
return usage
}
if a.events > 0 && a.pricedEvents == a.events && a.currency != "" && !math.IsNaN(a.estimatedCost) && !math.IsInf(a.estimatedCost, 0) {
cost := a.estimatedCost
currency := a.currency
usage.EstimatedCost = &cost
usage.Currency = &currency
complete := a.costComplete
usage.CostComplete = &complete
}
return usage
}
@@ -272,8 +347,8 @@ func (t *statusTelemetry) onEvent(e event.Event) (string, bool) {
return "phase", true
case event.Usage:
t.mutate(func(t *statusTelemetry) {
t.turnUsage.add(e.Usage, e.Pricing, e.UsageSource)
t.cumulative.add(e.Usage, e.Pricing, e.UsageSource)
t.turnUsage.addQuoted(e.Usage, e.Pricing, e.CostQuote, e.UsageSource)
t.cumulative.addQuoted(e.Usage, e.Pricing, e.CostQuote, e.UsageSource)
})
return "usage", true
case event.ApprovalRequest:
@@ -369,6 +444,7 @@ type persistedUsageAccumulator struct {
EstimatedCost float64 `json:"estimatedCost"`
Currency string `json:"currency,omitempty"`
Source string `json:"source,omitempty"`
CostComplete *bool `json:"costComplete,omitempty"`
}
type persistedStatusTelemetry struct {
@@ -383,22 +459,31 @@ type persistedStatusTelemetry struct {
}
func persistUsage(a usageAccumulator) persistedUsageAccumulator {
var costComplete *bool
if a.pricedEvents > 0 {
complete := a.costComplete
costComplete = &complete
}
return persistedUsageAccumulator{
PromptTokens: a.promptTokens, CompletionTokens: a.completionTokens,
ReasoningTokens: a.reasoningTokens, CacheHitTokens: a.cacheHitTokens,
CacheMissTokens: a.cacheMissTokens, Estimated: a.estimated, Events: a.events,
PricedEvents: a.pricedEvents, EstimatedCost: a.estimatedCost,
Currency: a.currency, Source: a.source,
Currency: a.currency, Source: a.source, CostComplete: costComplete,
}
}
func restoreUsage(a persistedUsageAccumulator) usageAccumulator {
costComplete := a.PricedEvents > 0 && a.Currency != ""
if a.CostComplete != nil {
costComplete = *a.CostComplete
}
return usageAccumulator{
promptTokens: a.PromptTokens, completionTokens: a.CompletionTokens,
reasoningTokens: a.ReasoningTokens, cacheHitTokens: a.CacheHitTokens,
cacheMissTokens: a.CacheMissTokens, estimated: a.Estimated, events: a.Events,
pricedEvents: a.PricedEvents, estimatedCost: a.EstimatedCost,
currency: a.Currency, source: a.Source,
currency: a.Currency, source: a.Source, costComplete: costComplete,
}
}
+48
View File
@@ -9,6 +9,7 @@ import (
"testing"
"reasonix/internal/agent"
"reasonix/internal/billing"
"reasonix/internal/event"
"reasonix/internal/provider"
)
@@ -17,6 +18,53 @@ type statusFactory struct {
*configurableFactory
}
func TestUsageAccumulatorTotalsMoreThanAuditLimit(t *testing.T) {
var accumulator usageAccumulator
usage := &provider.Usage{PromptTokens: 1_000_000}
pricing := &provider.Pricing{Input: 1, Currency: "USD"}
for range 65 {
quote := billing.BuildQuote(billing.QuoteInput{
Usage: billing.UsageTokens{PromptTokens: usage.PromptTokens},
Rates: billing.RateCard{Input: pricing.Input, Currency: pricing.Currency},
DisplayCurrency: "USD",
})
accumulator.addQuoted(usage, pricing, &quote, event.UsageSourceExecutor)
}
wire := accumulator.wire()
if wire.EstimatedCost == nil || *wire.EstimatedCost != 65 || wire.Currency == nil || *wire.Currency != "USD" {
t.Fatalf("65-event ACP total was truncated: %+v", wire)
}
if wire.CostQuote == nil || wire.CostQuote.Selected == nil || wire.CostQuote.Selected.Amount != "65" {
t.Fatalf("65-event ACP aggregate quote = %+v", wire.CostQuote)
}
if wire.CostComplete == nil || !*wire.CostComplete {
t.Fatalf("65-event ACP quote incomplete: %+v", wire)
}
}
func TestRestoredUsageKeepsFullScalarTotalAfterNewQuote(t *testing.T) {
complete := true
accumulator := restoreUsage(persistedUsageAccumulator{
PromptTokens: 1_000_000, Events: 1, PricedEvents: 1,
EstimatedCost: 2, Currency: "USD", CostComplete: &complete,
})
usage := &provider.Usage{PromptTokens: 1_000_000}
pricing := &provider.Pricing{Input: 1, Currency: "USD"}
quote := billing.BuildQuote(billing.QuoteInput{
Usage: billing.UsageTokens{PromptTokens: usage.PromptTokens},
Rates: billing.RateCard{Input: pricing.Input, Currency: pricing.Currency},
DisplayCurrency: "USD",
})
accumulator.addQuoted(usage, pricing, &quote, event.UsageSourceExecutor)
wire := accumulator.wire()
if wire.EstimatedCost == nil || *wire.EstimatedCost != 3 {
t.Fatalf("restored scalar history was replaced by the new ledger fragment: %+v", wire)
}
if wire.CostComplete == nil || !*wire.CostComplete {
t.Fatalf("restored complete state was lost: %+v", wire)
}
}
type runtimeTrackingFactory struct {
*configurableFactory
}
+44 -1
View File
@@ -12,6 +12,7 @@ import (
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
)
@@ -22,6 +23,45 @@ type Balance struct {
Infos []Info // one entry per currency the provider returns
}
// Currencies returns the distinct ISO currency codes reported by the wallet,
// in stable order. It never converts or combines balances.
func (b *Balance) Currencies() []string {
if b == nil {
return nil
}
seen := map[string]struct{}{}
for _, info := range b.Infos {
cur := strings.ToUpper(strings.TrimSpace(info.Currency))
if normalized := normalizeCurrency(cur); normalized != "" {
cur = normalized
}
if cur != "" {
seen[cur] = struct{}{}
}
}
result := make([]string, 0, len(seen))
for cur := range seen {
result = append(result, cur)
}
sort.Strings(result)
return result
}
// PrimaryCurrency returns the sole usable wallet currency, if there is one.
func (b *Balance) PrimaryCurrency() string {
currencies := b.Currencies()
if len(currencies) != 1 {
return ""
}
if currencies[0] != "CNY" && currencies[0] != "USD" {
return ""
}
return currencies[0]
}
// MultiCurrency reports whether more than one wallet currency is present.
func (b *Balance) MultiCurrency() bool { return len(b.Currencies()) > 1 }
// Info is one currency's balance (DeepSeek returns one per currency).
type Info struct {
Currency string // "CNY" | "USD"
@@ -143,7 +183,10 @@ func (b *Balance) DisplayForCurrency(currency string) string {
}
}
display := symbol(pick.Currency) + strings.TrimSpace(pick.TotalBalance)
actual := normalizeCurrency(pick.Currency)
actual := strings.ToUpper(strings.TrimSpace(pick.Currency))
if normalized := normalizeCurrency(actual); normalized != "" {
actual = normalized
}
if preferred != "" && actual != "" && actual != preferred {
return actual + " " + display
}
+18 -76
View File
@@ -1,87 +1,29 @@
package billing
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
import "testing"
// A DeepSeek-shaped response parses, exposes Available, and Display prefers CNY
// with the right symbol; the request carries the bearer key.
func TestFetchDeepSeekShape(t *testing.T) {
const body = `{
"is_available": true,
"balance_infos": [
{"currency": "USD", "total_balance": "15.30", "granted_balance": "0.00", "topped_up_balance": "15.30"},
{"currency": "CNY", "total_balance": "110.00", "granted_balance": "10.00", "topped_up_balance": "100.00"}
]
}`
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
b, err := Fetch(context.Background(), srv.URL, "secret-key")
if err != nil {
t.Fatalf("Fetch: %v", err)
func TestBalanceOriginalCurrencyDisplayNeverConverts(t *testing.T) {
b := &Balance{Available: true, Infos: []Info{
{Currency: "CNY", TotalBalance: "70.16"},
{Currency: "USD", TotalBalance: "9.82"},
}}
if got := b.DisplayForCurrency("USD"); got != "$9.82" {
t.Fatalf("USD display = %q", got)
}
if b == nil || !b.Available {
t.Fatalf("want available balance, got %+v", b)
if got := (&Balance{Infos: []Info{{Currency: "CNY", TotalBalance: "70.16"}}}).DisplayForCurrency("USD"); got != "CNY ¥70.16" {
t.Fatalf("fallback display = %q", got)
}
if gotAuth != "Bearer secret-key" {
t.Errorf("Authorization = %q, want %q", gotAuth, "Bearer secret-key")
}
if len(b.Infos) != 2 {
t.Fatalf("want 2 infos, got %d", len(b.Infos))
}
// Display prefers CNY → "¥110.00", not the first (USD) entry.
if got := b.Display(); got != "¥110.00" {
t.Errorf("Display = %q, want %q", got, "¥110.00")
}
if got := b.DisplayForCurrency("USD"); got != "$15.30" {
t.Errorf("DisplayForCurrency(USD) = %q, want %q", got, "$15.30")
}
if got := b.DisplayForCurrency("¥"); got != "¥110.00" {
t.Errorf("DisplayForCurrency(¥) = %q, want %q", got, "¥110.00")
if got := b.PrimaryCurrency(); got != "" || !b.MultiCurrency() {
t.Fatalf("wallet currencies = %v primary=%q", b.Currencies(), got)
}
}
// An empty url is "not configured", not an error: (nil, nil), and Display on a nil
// balance is "".
func TestFetchEmptyURL(t *testing.T) {
b, err := Fetch(context.Background(), "", "key")
if err != nil || b != nil {
t.Fatalf("Fetch(\"\") = (%v, %v), want (nil, nil)", b, err)
func TestBalanceSingleWalletCurrencyHint(t *testing.T) {
b := &Balance{Infos: []Info{{Currency: "RMB", TotalBalance: "1"}}}
if got := b.PrimaryCurrency(); got != "CNY" {
t.Fatalf("primary = %q", got)
}
if got := b.Display(); got != "" {
t.Errorf("nil Display = %q, want empty", got)
}
}
// A non-200 surfaces an error rather than a bogus zero balance.
func TestFetchHTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"invalid key"}`))
}))
defer srv.Close()
if _, err := Fetch(context.Background(), srv.URL, "bad"); err == nil {
t.Fatal("want error on 401, got nil")
}
}
// Display falls back to the first currency when no CNY entry is present, and maps
// USD to "$".
func TestDisplayUSDOnly(t *testing.T) {
b := &Balance{Available: true, Infos: []Info{{Currency: "USD", TotalBalance: "9.99"}}}
if got := b.Display(); got != "$9.99" {
t.Errorf("Display = %q, want %q", got, "$9.99")
}
if got := b.DisplayForCurrency("CNY"); got != "USD $9.99" {
t.Errorf("DisplayForCurrency(CNY) = %q, want explicit real fallback currency %q", got, "USD $9.99")
if got := b.Currencies(); len(got) != 1 || got[0] != "CNY" {
t.Fatalf("currencies = %v", got)
}
}
+125
View File
@@ -0,0 +1,125 @@
package billing
import "strings"
// Official catalog metadata: currency, model, effective dates, documentation
// sources, and price fingerprints. User-custom prices always win over catalog.
// CatalogEntry is one official list price for a model in a billing currency.
type CatalogEntry struct {
Provider string // deepseek | longcat | mimo
Model string
Currency string // ISO billing currency for this row
CacheHit float64
Input float64
Output float64
EffectiveFrom string // YYYY-MM-DD inclusive, empty = unbounded
EffectiveTo string // YYYY-MM-DD exclusive, empty = unbounded
DocURL string
BillingMode string // payg | subscription_equivalent
Notes string
Fingerprint string // filled by Register
}
// CatalogSourceURLs document public pricing pages.
const (
DocDeepSeekPricing = "https://api-docs.deepseek.com/quick_start/pricing"
DocLongCatPricingUSD = "https://longcat.chat/platform/docs/Pricing/LongCat-2.0.html"
DocLongCatPricingCNY = "https://longcat.chat/platform/docs/zh/pricing/long-cat-2.0"
DocMiMoPAYG = "https://mimo.mi.com/docs/price/pay-as-you-go"
DocMiMoTokenPlan = "https://platform.xiaomimimo.com/token-plan"
)
// OfficialCatalog is the built-in price book. Rates match config defaults.
func OfficialCatalog() []CatalogEntry {
entries := []CatalogEntry{
// DeepSeek regional tables (official pricing doc).
{Provider: "deepseek", Model: "deepseek-v4-flash", Currency: "CNY", CacheHit: 0.02, Input: 1, Output: 2, DocURL: DocDeepSeekPricing, BillingMode: BillingModePAYG},
{Provider: "deepseek", Model: "deepseek-v4-pro", Currency: "CNY", CacheHit: 0.025, Input: 3, Output: 6, DocURL: DocDeepSeekPricing, BillingMode: BillingModePAYG},
{Provider: "deepseek", Model: "deepseek-v4-flash", Currency: "USD", CacheHit: 0.0028, Input: 0.14, Output: 0.28, DocURL: DocDeepSeekPricing, BillingMode: BillingModePAYG},
{Provider: "deepseek", Model: "deepseek-v4-pro", Currency: "USD", CacheHit: 0.003625, Input: 0.435, Output: 0.87, DocURL: DocDeepSeekPricing, BillingMode: BillingModePAYG},
// LongCat dual currency tables (official discounted list prices).
// https://longcat.chat/platform/docs/Pricing/LongCat-2.0.html
{Provider: "longcat", Model: "LongCat-2.0", Currency: "CNY", CacheHit: 0.04, Input: 2, Output: 8, DocURL: DocLongCatPricingCNY, BillingMode: BillingModePAYG},
{Provider: "longcat", Model: "LongCat-2.0", Currency: "USD", CacheHit: 0.006, Input: 0.30, Output: 1.20, DocURL: DocLongCatPricingUSD, BillingMode: BillingModePAYG},
// MiMo domestic PAYG; Token Plan uses the same rates as subscription_equivalent.
{Provider: "mimo", Model: "mimo-v2.5-pro", Currency: "CNY", CacheHit: 0.025, Input: 3, Output: 6, DocURL: DocMiMoPAYG, BillingMode: BillingModePAYG},
{Provider: "mimo", Model: "mimo-v2.5", Currency: "CNY", CacheHit: 0.02, Input: 1, Output: 2, DocURL: DocMiMoPAYG, BillingMode: BillingModePAYG},
{Provider: "mimo", Model: "mimo-v2-flash", Currency: "CNY", CacheHit: 0.07, Input: 0.70, Output: 2.10, DocURL: DocMiMoPAYG, BillingMode: BillingModePAYG},
{Provider: "mimo", Model: "mimo-v2.5-pro", Currency: "CNY", CacheHit: 0.025, Input: 3, Output: 6, DocURL: DocMiMoTokenPlan, BillingMode: BillingModeSubscriptionEquivalent, Notes: "payg_equivalent_not_plan_bill"},
{Provider: "mimo", Model: "mimo-v2.5", Currency: "CNY", CacheHit: 0.02, Input: 1, Output: 2, DocURL: DocMiMoTokenPlan, BillingMode: BillingModeSubscriptionEquivalent, Notes: "payg_equivalent_not_plan_bill"},
}
for i := range entries {
entries[i].Fingerprint = PricingFingerprint(RateCard{
CacheHit: entries[i].CacheHit,
Input: entries[i].Input,
Output: entries[i].Output,
Currency: entries[i].Currency,
})
}
return entries
}
// LookupCatalog finds an official entry. billingMode empty matches payg first.
func LookupCatalog(provider, model, currency, billingMode string) (CatalogEntry, bool) {
provider = strings.ToLower(strings.TrimSpace(provider))
model = strings.TrimSpace(model)
currency = NormalizeCurrency(currency)
billingMode = strings.TrimSpace(billingMode)
if billingMode == "" {
billingMode = BillingModePAYG
}
var fallback CatalogEntry
foundFallback := false
for _, e := range OfficialCatalog() {
if e.Provider != provider || e.Model != model {
continue
}
if NormalizeCurrency(e.Currency) != currency {
continue
}
if e.BillingMode == billingMode {
return e, true
}
if e.BillingMode == BillingModePAYG {
fallback = e
foundFallback = true
}
}
if foundFallback {
return fallback, true
}
return CatalogEntry{}, false
}
// RateCardFromCatalog builds a RateCard from a catalog entry.
func RateCardFromCatalog(e CatalogEntry) RateCard {
return RateCard{
CacheHit: e.CacheHit,
Input: e.Input,
Output: e.Output,
Currency: e.Currency,
}
}
// MatchesCatalog reports whether rates equal a known official entry.
func MatchesCatalog(provider, model string, rates RateCard) (CatalogEntry, bool) {
cur := NormalizeCurrency(rates.Currency)
for _, e := range OfficialCatalog() {
if e.Provider != strings.ToLower(strings.TrimSpace(provider)) {
continue
}
if e.Model != strings.TrimSpace(model) {
continue
}
if NormalizeCurrency(e.Currency) != cur {
continue
}
if e.CacheHit == rates.CacheHit && e.Input == rates.Input && e.Output == rates.Output {
return e, true
}
}
return CatalogEntry{}, false
}
+237
View File
@@ -0,0 +1,237 @@
package billing
import (
"maps"
"sort"
"strings"
"time"
)
// LedgerEntry is one occurrence-time cost fact. Ledger keys include model,
// usage source, pricing fingerprint, and legacy rate date so model switches and
// sub-agents never collapse into a single scalar.
type LedgerEntry struct {
Key string `json:"key"`
ModelRef string `json:"modelRef"`
UsageSource string `json:"usageSource"`
PricingFingerprint string `json:"pricingFingerprint"`
RateDate string `json:"rateDate,omitempty"`
OccurredAt time.Time `json:"occurredAt"`
Quote CostQuote `json:"quote"`
// Token totals for this bucket (summed).
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
CacheHitTokens int `json:"cacheHitTokens"`
CacheMissTokens int `json:"cacheMissTokens"`
RequestCount int `json:"requestCount"`
}
// LedgerKey builds the stable aggregation key.
func LedgerKey(modelRef, usageSource, pricingFingerprint, rateDate string) string {
return strings.Join([]string{
strings.TrimSpace(modelRef),
strings.TrimSpace(usageSource),
strings.TrimSpace(pricingFingerprint),
strings.TrimSpace(rateDate),
}, "|")
}
// Ledger accumulates occurrence-time quotes. Switching display currency only
// re-selects valuations already stored; it does not reprice or clear history.
type Ledger struct {
Version int `json:"version"`
Entries map[string]LedgerEntry `json:"entries"`
}
// LedgerVersion is the current persisted ledger schema.
const LedgerVersion = 1
// NewLedger returns an empty ledger.
func NewLedger() *Ledger {
return &Ledger{Version: LedgerVersion, Entries: map[string]LedgerEntry{}}
}
// Add merges a quote into the ledger under its natural key.
func (l *Ledger) Add(q CostQuote, tokens UsageTokens, occurred time.Time) {
if l == nil {
return
}
if l.Entries == nil {
l.Entries = map[string]LedgerEntry{}
}
if l.Version == 0 {
l.Version = LedgerVersion
}
if occurred.IsZero() {
occurred = time.Now().UTC()
}
q = NormalizeQuote(q)
source := q.UsageSource
if source == "" {
source = "executor"
}
key := LedgerKey(q.ModelRef, source, q.PricingFingerprint, q.RateDate)
ent, ok := l.Entries[key]
if !ok {
ent = LedgerEntry{
Key: key,
ModelRef: q.ModelRef,
UsageSource: source,
PricingFingerprint: q.PricingFingerprint,
RateDate: q.RateDate,
OccurredAt: occurred,
Quote: q,
}
// Fresh quote valuations are kept; we re-aggregate Original via sums.
ent.Quote.Valuations = cloneValuations(q.Valuations)
} else {
// Sum original when same currency; otherwise retain deterministic
// per-currency buckets. Once a bucketed entry exists, keep adding into
// those buckets so later same-currency calls are not lost.
if len(ent.Quote.OriginalTotals) > 0 {
ent.Quote.OriginalTotals = mergeOriginalTotals(ent.Quote.OriginalTotals, q)
ent.Quote.CostComplete = true
ent.Quote.DisplayComplete = false
ent.Quote.Complete = false
ent.Quote.DisplayStatus = DisplayStatusBucketed
ent.Quote.AggregateMode = AggregateModeCurrencyBuckets
ent.Quote.IncompleteReason = "mixed_original_currencies"
} else {
sum, err := AddMoney(ent.Quote.Original, q.Original)
if err != nil {
ent.Quote.OriginalTotals = mergeOriginalTotals([]Money{ent.Quote.Original}, q)
ent.Quote.CostComplete = true
ent.Quote.DisplayComplete = false
ent.Quote.Complete = false
ent.Quote.DisplayStatus = DisplayStatusBucketed
ent.Quote.AggregateMode = AggregateModeCurrencyBuckets
ent.Quote.IncompleteReason = "mixed_original_currencies"
} else {
ent.Quote.Original = sum
}
}
for code, v := range q.Valuations {
code = NormalizeCurrency(code)
if prev, ok := ent.Quote.Valuations[code]; ok {
added, err := AddMoney(prev.Money, v.Money)
if err == nil {
prev.Money = added
if v.Stale {
prev.Stale = true
}
ent.Quote.Valuations[code] = prev
}
} else {
if ent.Quote.Valuations == nil {
ent.Quote.Valuations = map[string]Valuation{}
}
ent.Quote.Valuations[code] = v
}
}
if q.Estimated {
ent.Quote.Estimated = true
}
if !q.Complete {
ent.Quote.DisplayComplete = false
ent.Quote.Complete = false
if ent.Quote.IncompleteReason == "" {
ent.Quote.IncompleteReason = q.IncompleteReason
}
}
ent.Quote.CostComplete = ent.Quote.CostComplete && q.CostComplete
if ent.Quote.DisplayStatus != DisplayStatusBucketed {
ent.Quote.DisplayStatus = q.DisplayStatus
}
}
ent.PromptTokens += tokens.PromptTokens
ent.CompletionTokens += tokens.CompletionTokens
ent.TotalTokens += tokens.PromptTokens + tokens.CompletionTokens
if tokens.CacheHitTokens+tokens.CacheMissTokens > 0 {
ent.CacheHitTokens += tokens.CacheHitTokens
ent.CacheMissTokens += tokens.CacheMissTokens
} else {
ent.CacheMissTokens += tokens.PromptTokens
}
ent.RequestCount++
if occurred.After(ent.OccurredAt) {
ent.OccurredAt = occurred
}
l.Entries[key] = ent
}
func mergeOriginalTotals(existing []Money, q CostQuote) []Money {
amounts := map[string]Amount{}
add := func(m Money) {
currency := NormalizeCurrency(m.Currency)
if currency == "" {
return
}
amounts[currency] = amounts[currency].Add(m.AmountValue())
}
for _, m := range existing {
add(m)
}
if len(q.OriginalTotals) > 0 {
for _, m := range q.OriginalTotals {
add(m)
}
} else {
add(q.Original)
}
codes := make([]string, 0, len(amounts))
for code := range amounts {
codes = append(codes, code)
}
sort.Strings(codes)
out := make([]Money, 0, len(codes))
for _, code := range codes {
out = append(out, MoneyOf(amounts[code], code))
}
return out
}
func cloneValuations(in map[string]Valuation) map[string]Valuation {
if len(in) == 0 {
return map[string]Valuation{}
}
out := make(map[string]Valuation, len(in))
maps.Copy(out, in)
return out
}
// Total returns the aggregate CostQuote for a display currency.
func (l *Ledger) Total(display string) CostQuote {
if l == nil || len(l.Entries) == 0 {
return AggregateQuotes(nil, display)
}
quotes := make([]CostQuote, 0, len(l.Entries))
for _, ent := range l.Entries {
q := ent.Quote
// Refresh valuation moneys already summed in entries.
quotes = append(quotes, q)
}
// Stable order for determinism.
sort.Slice(quotes, func(i, j int) bool {
return quotes[i].ModelRef+quotes[i].UsageSource < quotes[j].ModelRef+quotes[j].UsageSource
})
return AggregateQuotes(quotes, display)
}
// SelectDisplay rebinds Selected on every entry and the total without
// recomputing occurrence-time pricing facts.
func (l *Ledger) SelectDisplay(display string) CostQuote {
return l.Total(display)
}
// EntriesBySource groups ledger entries by usage source for status panels.
func (l *Ledger) EntriesBySource() map[string][]LedgerEntry {
out := map[string][]LedgerEntry{}
if l == nil {
return out
}
for _, ent := range l.Entries {
out[ent.UsageSource] = append(out[ent.UsageSource], ent)
}
return out
}
+86
View File
@@ -0,0 +1,86 @@
package billing
import (
"strings"
"time"
)
// LegacyUsageRecord is the minimum shape needed to migrate old telemetry.
type LegacyUsageRecord struct {
SessionCost float64
SessionCurrency string
// EndedAt is retained for historical ordering and identity valuation dates.
EndedAt time.Time
// Tokens optional for ledger completeness.
PromptTokens int
CompletionTokens int
ModelRef string
UsageSource string
}
// MigrateLegacyUsage builds a CostQuote from pre-CostQuote telemetry.
// Records that cannot be valued are marked legacy_estimate; wiped mixed-currency
// zeros are not reconstructed from current price tables.
func MigrateLegacyUsage(rec LegacyUsageRecord) CostQuote {
cur := NormalizeCurrency(rec.SessionCurrency)
if cur == "" {
// Historic defaults often used ¥ symbol without code.
if strings.Contains(rec.SessionCurrency, "¥") || strings.Contains(rec.SessionCurrency, "¥") {
cur = "CNY"
} else if strings.Contains(rec.SessionCurrency, "$") {
cur = "USD"
}
}
// Wiped mixed-currency totals (legacy code zeroed them) and empty rows must
// not be reconstructed from current price tables.
if rec.SessionCost <= 0 || cur == "" {
reason := "legacy_unrecoverable"
if rec.SessionCost == 0 && strings.TrimSpace(rec.SessionCurrency) != "" {
// Explicit zero with a currency often means a prior mixed-currency wipe.
reason = "legacy_wiped_or_zero"
} else if rec.SessionCost < 0 {
reason = "legacy_invalid_amount"
}
return CostQuote{
Original: MoneyOf(Zero, cur),
Estimated: true,
CostComplete: false,
DisplayComplete: false,
Complete: false,
DisplayStatus: DisplayStatusUnavailable,
LegacyEstimate: true,
IncompleteReason: reason,
ModelRef: rec.ModelRef,
UsageSource: rec.UsageSource,
}
}
amount := NewAmountFromFloat(rec.SessionCost)
q := CostQuote{
Original: MoneyOf(amount, cur),
Valuations: map[string]Valuation{},
Estimated: true,
CostComplete: true,
DisplayComplete: true,
Complete: true,
DisplayStatus: DisplayStatusMatched,
AggregateMode: AggregateModeSingleCurrency,
LegacyEstimate: true,
ModelRef: rec.ModelRef,
UsageSource: rec.UsageSource,
BillingMode: BillingModePAYG,
}
ended := rec.EndedAt
if ended.IsZero() {
ended = time.Now().UTC()
}
asOf := ended.UTC().Format("2006-01-02")
q.Valuations[cur] = Valuation{
Money: q.Original,
Basis: BasisIdentity,
Source: "legacy_telemetry",
AsOf: asOf,
}
m := q.Original
q.Selected = &m
return q
}
+20
View File
@@ -0,0 +1,20 @@
package billing
import "testing"
func TestMigrateLegacyUsageIdentityOnly(t *testing.T) {
q := MigrateLegacyUsage(LegacyUsageRecord{SessionCost: 1.25, SessionCurrency: "USD"})
if !q.CostComplete || !q.DisplayComplete || !q.Complete || q.Selected == nil {
t.Fatalf("migrated quote = %+v", q)
}
if len(q.Valuations) != 1 || q.Valuations["USD"].Basis != BasisIdentity {
t.Fatalf("migrated valuations = %+v", q.Valuations)
}
}
func TestMigrateLegacyWipedZeroDoesNotGuess(t *testing.T) {
q := MigrateLegacyUsage(LegacyUsageRecord{SessionCurrency: "CNY"})
if q.CostComplete || q.Complete || q.DisplayStatus != DisplayStatusUnavailable || q.IncompleteReason != "legacy_wiped_or_zero" {
t.Fatalf("wiped quote = %+v", q)
}
}
+255
View File
@@ -0,0 +1,255 @@
// Package billing models wallet balances, fixed-point money, and cost quotes.
// It is a utility-layer package (stdlib only).
package billing
import (
"fmt"
"math"
"math/big"
"strings"
)
// Money is a currency-tagged amount carried as a decimal string on the wire.
// Arithmetic uses fixed-point int64 units (1e9 fractional digits).
type Money struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}
// amountScale is 1e9: enough for per-token rates down to ~1e-15 currency units
// when aggregated over typical request sizes.
const amountScale int64 = 1_000_000_000
// Amount is a fixed-point currency magnitude in 1e-9 units of the currency.
type Amount int64
// Zero is the zero amount.
const Zero Amount = 0
// NewAmountFromFloat converts a float64 amount to fixed-point. Prefer ParseAmount
// or NewAmountFromString for values that originate as decimals.
func NewAmountFromFloat(v float64) Amount {
if math.IsNaN(v) || math.IsInf(v, 0) {
return Zero
}
r := new(big.Rat).SetFloat64(v)
if r == nil {
return Zero
}
return amountFromRat(r)
}
// NewAmountFromString parses a decimal amount string.
func NewAmountFromString(s string) (Amount, error) {
s = strings.TrimSpace(s)
if s == "" {
return Zero, nil
}
r := new(big.Rat)
if _, ok := r.SetString(s); !ok {
return Zero, fmt.Errorf("billing: invalid amount %q", s)
}
return amountFromRat(r), nil
}
// ParseAmount is an alias for NewAmountFromString.
func ParseAmount(s string) (Amount, error) { return NewAmountFromString(s) }
func amountFromRat(r *big.Rat) Amount {
if r == nil {
return Zero
}
scaled := new(big.Rat).Mul(r, big.NewRat(amountScale, 1))
// Round half away from zero.
num := new(big.Int).Set(scaled.Num())
den := new(big.Int).Set(scaled.Denom())
half := new(big.Int).Rsh(den, 1)
if scaled.Sign() >= 0 {
num.Add(num, half)
} else {
num.Sub(num, half)
}
quot := new(big.Int).Quo(num, den)
if !quot.IsInt64() {
if scaled.Sign() >= 0 {
return Amount(math.MaxInt64)
}
return Amount(math.MinInt64)
}
return Amount(quot.Int64())
}
// String returns a trimmed decimal representation.
func (a Amount) String() string {
neg := a < 0
v := int64(a)
if neg {
v = -v
}
whole := v / amountScale
frac := v % amountScale
s := fmt.Sprintf("%d.%09d", whole, frac)
s = strings.TrimRight(s, "0")
s = strings.TrimRight(s, ".")
if s == "" || s == "-" {
s = "0"
}
if neg && s != "0" {
return "-" + s
}
return s
}
// Float64 approximates the amount for legacy float fields only.
func (a Amount) Float64() float64 {
return float64(a) / float64(amountScale)
}
// Add returns a+b. Same-currency only at the Money layer.
func (a Amount) Add(b Amount) Amount {
sum := int64(a) + int64(b)
// Saturate on overflow rather than wrap.
if (b > 0 && sum < int64(a)) || (b < 0 && sum > int64(a)) {
if b > 0 {
return Amount(math.MaxInt64)
}
return Amount(math.MinInt64)
}
return Amount(sum)
}
// MulRate multiplies by a float rate for a pricing calculation.
func (a Amount) MulRate(rate float64) Amount {
if a == 0 || rate == 0 || math.IsNaN(rate) || math.IsInf(rate, 0) {
return Zero
}
r := new(big.Rat).SetFloat64(rate)
if r == nil {
return Zero
}
ar := big.NewRat(int64(a), amountScale)
return amountFromRat(ar.Mul(ar, r))
}
// MoneyOf builds a Money value.
func MoneyOf(amount Amount, currency string) Money {
return Money{Amount: amount.String(), Currency: NormalizeCurrency(currency)}
}
// ParseMoney parses amount+currency into fixed-point Money.
func ParseMoney(amount, currency string) (Money, Amount, error) {
a, err := NewAmountFromString(amount)
if err != nil {
return Money{}, Zero, err
}
cur := NormalizeCurrency(currency)
return MoneyOf(a, cur), a, nil
}
// AmountValue returns the fixed-point amount, or zero on parse error.
func (m Money) AmountValue() Amount {
a, err := NewAmountFromString(m.Amount)
if err != nil {
return Zero
}
return a
}
// IsZero reports whether the money amount is empty or zero.
func (m Money) IsZero() bool {
return m.AmountValue() == Zero
}
// Float64 is a legacy adapter. Prefer Amount strings for aggregation.
func (m Money) Float64() float64 { return m.AmountValue().Float64() }
// NormalizeCurrency maps symbols and aliases to ISO-4217 codes when known.
// Unknown three-letter codes pass through uppercased; empty stays empty.
func NormalizeCurrency(currency string) string {
value := strings.TrimSpace(currency)
if value == "" {
return ""
}
switch strings.ToUpper(value) {
case "CNY", "RMB", "CNH", "YUAN", "RENMINBI":
return "CNY"
case "USD", "US$", "DOLLAR", "DOLLARS":
return "USD"
case "EUR", "EURO", "EUROS":
return "EUR"
case "GBP", "POUND", "POUNDS", "STERLING":
return "GBP"
case "JPY", "YEN":
return "JPY"
}
switch value {
case "¥", "¥":
return "CNY"
case "$":
return "USD"
case "€":
return "EUR"
case "£":
return "GBP"
}
if len(value) == 3 {
allAlpha := true
for _, r := range value {
if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') {
allAlpha = false
break
}
}
if allAlpha {
return strings.ToUpper(value)
}
}
return value
}
// CurrencySymbol returns a compact display symbol for an ISO code or symbol.
func CurrencySymbol(currency string) string {
switch NormalizeCurrency(currency) {
case "CNY", "JPY":
return "¥"
case "USD":
return "$"
case "EUR":
return "€"
case "GBP":
return "£"
case "":
return "¥"
default:
code := NormalizeCurrency(currency)
if len(code) == 3 {
return code + " "
}
return currency
}
}
// SameCurrency reports whether two codes normalize equal and non-empty.
func SameCurrency(a, b string) bool {
na, nb := NormalizeCurrency(a), NormalizeCurrency(b)
return na != "" && na == nb
}
// AddMoney adds two Money values of the same currency. Mixed currencies error.
func AddMoney(a, b Money) (Money, error) {
ca, cb := NormalizeCurrency(a.Currency), NormalizeCurrency(b.Currency)
if ca == "" {
ca = cb
}
if cb == "" {
cb = ca
}
if ca == "" && cb == "" {
return Money{Amount: "0", Currency: ""}, nil
}
if ca != cb {
return Money{}, fmt.Errorf("billing: cannot add %s and %s", ca, cb)
}
sum := a.AmountValue().Add(b.AmountValue())
return MoneyOf(sum, ca), nil
}
+56
View File
@@ -0,0 +1,56 @@
package billing
import (
"testing"
)
func TestAmountRoundTrip(t *testing.T) {
a, err := NewAmountFromString("1.23456789")
if err != nil {
t.Fatal(err)
}
if got := a.String(); got != "1.23456789" {
t.Fatalf("String = %q", got)
}
if a.Float64() < 1.23456788 || a.Float64() > 1.23456790 {
t.Fatalf("Float64 = %v", a.Float64())
}
}
func TestAddMoneyRejectsMixedCurrency(t *testing.T) {
_, err := AddMoney(MoneyOf(NewAmountFromFloat(1), "CNY"), MoneyOf(NewAmountFromFloat(1), "USD"))
if err == nil {
t.Fatal("expected mixed currency error")
}
}
func TestAddMoneySameCurrency(t *testing.T) {
sum, err := AddMoney(MoneyOf(NewAmountFromFloat(1.5), "USD"), MoneyOf(NewAmountFromFloat(2.25), "USD"))
if err != nil {
t.Fatal(err)
}
if sum.Currency != "USD" || sum.Amount != "3.75" {
t.Fatalf("sum = %+v", sum)
}
}
func TestNormalizeCurrency(t *testing.T) {
cases := map[string]string{
"¥": "CNY", "$": "USD", "rmb": "CNY", "usd": "USD", "eur": "EUR",
}
for in, want := range cases {
if got := NormalizeCurrency(in); got != want {
t.Errorf("NormalizeCurrency(%q) = %q, want %q", in, got, want)
}
}
}
func TestOriginalCostMatchesLegacySemantics(t *testing.T) {
// Same as provider.Pricing.Cost cache-write split test: miss 500k, write 100k billed 200k, input rate 2 → 1.2
amt := OriginalCostAmount(RateCard{Input: 2, Currency: "USD"}, UsageTokens{
CacheMissTokens: 500_000, CacheWriteTokens: 100_000, CacheWriteBilledTokens: 200_000,
})
if got := amt.Float64(); got < 1.199 || got > 1.201 {
t.Fatalf("cost = %v, want ~1.2", got)
}
}
+720
View File
@@ -0,0 +1,720 @@
package billing
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strings"
"time"
)
// Billing modes.
const (
BillingModePAYG = "payg"
BillingModeSubscriptionEquivalent = "subscription_equivalent"
)
// Valuation basis values.
const (
BasisIdentity = "identity"
BasisOfficialTable = "official_table"
// BasisFX is retained for decoding pre-v6 persisted quotes only. New
// quotes never create FX valuations.
BasisFX = "fx"
)
// DisplayStatus describes whether a quote can satisfy its requested display.
// It is deliberately separate from CostComplete: a known price-book amount
// can exist even when no requested regional valuation is available.
const (
DisplayStatusMatched = "matched"
DisplayStatusFallbackOriginal = "fallback_original"
DisplayStatusBucketed = "bucketed"
DisplayStatusUnavailable = "unavailable"
)
// Aggregate modes describe how a session/run total was formed.
const (
AggregateModeSingleCurrency = "single_currency"
AggregateModeCommonValuation = "common_valuation"
AggregateModeCurrencyBuckets = "currency_buckets"
)
// DisplayRequest identifies how a caller selected the requested presentation
// currency. The source is runtime context only; it is never persisted as a
// provider price or wallet fact.
type DisplayRequest struct {
Currency string `json:"currency,omitempty"`
Source string `json:"source,omitempty"`
}
const (
DisplaySourceExplicit = "explicit"
DisplaySourceWallet = "wallet"
DisplaySourceAuto = "auto"
)
func (r DisplayRequest) NormalizedCurrency() string {
return NormalizeCurrency(r.Currency)
}
// CostQuote is the host-side quoted cost for one model call (or an aggregate).
// Original is the pricing-table currency fact; valuations hold identity or
// official regional rate-card estimates. Selected is the display pick for the
// current preference.
type CostQuote struct {
Original Money `json:"original"`
// OriginalTotals is populated for mixed-currency aggregates. Individual
// quotes and single-currency totals may omit it.
OriginalTotals []Money `json:"originalTotals,omitempty"`
Valuations map[string]Valuation `json:"valuations,omitempty"`
Selected *Money `json:"selected,omitempty"`
BillingMode string `json:"billingMode,omitempty"`
Estimated bool `json:"estimated"`
// CostComplete means usage and the price-book amount are known. It does not
// imply that a requested display currency is available.
CostComplete bool `json:"costComplete"`
// DisplayComplete means a single selected amount satisfies the display
// request. Complete is retained as the old wire alias.
DisplayComplete bool `json:"displayComplete"`
Complete bool `json:"complete"`
DisplayStatus string `json:"displayStatus,omitempty"`
AggregateMode string `json:"aggregateMode,omitempty"`
// ModelRef / UsageSource / PricingFingerprint identify the ledger key.
ModelRef string `json:"modelRef,omitempty"`
UsageSource string `json:"usageSource,omitempty"`
PricingFingerprint string `json:"pricingFingerprint,omitempty"`
RateDate string `json:"rateDate,omitempty"` // YYYY-MM-DD of FX used, if any
IncompleteReason string `json:"incompleteReason,omitempty"`
LegacyEstimate bool `json:"legacyEstimate,omitempty"`
CatalogSource string `json:"catalogSource,omitempty"`
}
// Valuation is one currency view of a cost fact.
type Valuation struct {
Money Money `json:"money"`
Basis string `json:"basis"`
Source string `json:"source"`
AsOf string `json:"asOf"` // YYYY-MM-DD
Rate *RateSnapshot `json:"rateSnapshot,omitempty"`
Stale bool `json:"stale,omitempty"`
}
// RateSnapshot records the FX observation used for a valuation.
type RateSnapshot struct {
Base string `json:"base"`
Quote string `json:"quote"`
Rate float64 `json:"rate"`
Source string `json:"source"`
AsOf string `json:"asOf"`
Stale bool `json:"stale,omitempty"`
}
// RateCard is the per-1M-token price used to compute original cost. It mirrors
// provider.Pricing without importing that package (billing is a leaf).
type RateCard struct {
CacheHit float64 // per 1M cached prompt tokens
Input float64 // per 1M uncached prompt tokens
Output float64 // per 1M completion tokens
Currency string // ISO or symbol; normalized on quote
}
// UsageTokens is the token breakdown needed for cost. Mirrors provider.Usage
// fields without importing provider.
type UsageTokens struct {
PromptTokens int
CompletionTokens int
CacheHitTokens int
CacheMissTokens int
CacheWriteTokens int
CacheWriteBilledTokens float64
Estimated bool
}
// QuoteInput drives a single CostQuote computation.
type QuoteInput struct {
Usage UsageTokens
Rates RateCard
OccurredAt time.Time
DisplayCurrency string // compatibility alias for Display.Currency
Display DisplayRequest // runtime display request; empty means auto
BillingMode string
ModelRef string
UsageSource string
CatalogSource string
PricingFingerprint string
// ProviderKind is deepseek|longcat|mimo|… for official dual-table lookup.
// When empty, ModelRef and Rates are used to infer a catalog match.
ProviderKind string
// ModelID is the bare model id (e.g. deepseek-v4-flash). Empty → parse ModelRef.
ModelID string
}
// OriginalCostAmount computes the fixed-point original cost from rates + tokens.
// Mirrors the historic provider.Pricing.Cost semantics.
func OriginalCostAmount(rates RateCard, u UsageTokens) Amount {
hit := u.CacheHitTokens
miss := u.CacheMissTokens
if hit+miss == 0 && u.PromptTokens > 0 {
miss = u.PromptTokens
} else if miss == 0 && hit > 0 && u.PromptTokens > hit {
miss = u.PromptTokens - hit
}
write := u.CacheWriteTokens
write = max(write, 0)
write = min(write, miss)
billedWrite := 0.0
if write > 0 {
billedWrite = u.CacheWriteBilledTokens
if billedWrite <= 0 {
billedWrite = float64(write)
}
}
inputTokenUnits := float64(miss-write) + billedWrite
// Combine cached input, uncached input, and output charges per million tokens.
total := (float64(hit)*rates.CacheHit +
inputTokenUnits*rates.Input +
float64(u.CompletionTokens)*rates.Output) / 1e6
return NewAmountFromFloat(total)
}
// PricingFingerprint hashes a rate card for ledger keys.
func PricingFingerprint(rates RateCard) string {
cur := NormalizeCurrency(rates.Currency)
raw := fmt.Sprintf("%s|%.12g|%.12g|%.12g", cur, rates.CacheHit, rates.Input, rates.Output)
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:8])
}
// BuildQuote computes original cost and CNY/USD valuations.
func BuildQuote(in QuoteInput) CostQuote {
state := newQuoteBuildState(in)
for _, target := range []string{"CNY", "USD"} {
state.addTargetValuation(target)
}
state.selectDisplay()
return state.quote
}
type quoteBuildState struct {
input QuoteInput
currency string
amount Amount
occurred time.Time
official CatalogEntry
isOfficial bool
valuationFailures map[string]string
quote CostQuote
}
func newQuoteBuildState(in QuoteInput) *quoteBuildState {
currency := NormalizeCurrency(in.Rates.Currency)
if currency == "" {
currency = "CNY"
}
mode := strings.TrimSpace(in.BillingMode)
if mode == "" {
mode = BillingModePAYG
}
occurred := in.OccurredAt
if occurred.IsZero() {
occurred = time.Now().UTC()
}
fingerprint := strings.TrimSpace(in.PricingFingerprint)
if fingerprint == "" {
fingerprint = PricingFingerprint(in.Rates)
}
amount := OriginalCostAmount(in.Rates, in.Usage)
q := CostQuote{
Original: MoneyOf(amount, currency),
Valuations: map[string]Valuation{},
BillingMode: mode,
Estimated: true,
CostComplete: true,
DisplayComplete: true,
Complete: true,
DisplayStatus: DisplayStatusMatched,
AggregateMode: AggregateModeSingleCurrency,
ModelRef: strings.TrimSpace(in.ModelRef),
UsageSource: strings.TrimSpace(in.UsageSource),
PricingFingerprint: fingerprint,
CatalogSource: strings.TrimSpace(in.CatalogSource),
}
q.Valuations[currency] = Valuation{
Money: q.Original, Basis: BasisIdentity,
Source: firstNonEmpty(in.CatalogSource, "rate_card"),
AsOf: occurred.UTC().Format("2006-01-02"),
}
state := &quoteBuildState{
input: in, currency: currency, amount: amount, occurred: occurred,
valuationFailures: map[string]string{}, quote: q,
}
if !usageHasFacts(in.Usage) {
state.markIncomplete("missing_price_or_usage")
}
state.matchOfficialCatalog()
return state
}
func quoteDisplayRequest(in QuoteInput) DisplayRequest {
request := in.Display
if request.Currency == "" && in.DisplayCurrency != "" {
request.Currency = in.DisplayCurrency
if request.Source == "" {
request.Source = DisplaySourceExplicit
}
}
if request.Source == "" {
request.Source = DisplaySourceAuto
}
return request
}
func usageHasFacts(u UsageTokens) bool {
return u.PromptTokens > 0 || u.CompletionTokens > 0 || u.CacheHitTokens > 0 ||
u.CacheMissTokens > 0 || u.CacheWriteTokens > 0 || u.CacheWriteBilledTokens > 0
}
func (s *quoteBuildState) matchOfficialCatalog() {
providerKind, modelID := resolveCatalogIdentity(s.input)
s.official, s.isOfficial = MatchesCatalog(providerKind, modelID, s.input.Rates)
if !s.isOfficial && providerKind != "" && modelID != "" {
rates := s.input.Rates
rates.Currency = s.currency
s.official, s.isOfficial = MatchesCatalog(providerKind, modelID, rates)
}
if s.isOfficial && s.quote.CatalogSource == "" {
s.quote.CatalogSource = s.official.DocURL
}
}
func (s *quoteBuildState) addTargetValuation(target string) {
if target == s.currency {
return
}
if s.addOfficialValuation(target) {
return
}
// Runtime FX has intentionally been removed. A custom price that does not
// match the official catalog remains in its original currency.
s.valuationFailures[target] = "display_unavailable"
}
func (s *quoteBuildState) addOfficialValuation(target string) bool {
if !s.isOfficial {
return false
}
peer, ok := LookupCatalog(
s.official.Provider, s.official.Model, target, s.official.BillingMode,
)
if !ok {
return false
}
s.quote.Valuations[target] = Valuation{
Money: MoneyOf(OriginalCostAmount(RateCardFromCatalog(peer), s.input.Usage), target),
Basis: BasisOfficialTable, Source: peer.DocURL,
AsOf: s.occurred.UTC().Format("2006-01-02"),
}
return true
}
func (s *quoteBuildState) selectDisplay() {
if !s.quote.CostComplete {
s.quote.Selected = nil
s.quote.DisplayComplete = false
s.quote.Complete = false
s.quote.DisplayStatus = DisplayStatusUnavailable
return
}
display := quoteDisplayRequest(s.input).NormalizedCurrency()
if display == "" {
selected := s.quote.Original
s.quote.Selected = &selected
return
}
if valuation, ok := s.quote.Valuations[display]; ok {
selected := valuation.Money
s.quote.Selected = &selected
return
}
// The original price-book amount is still a valid cost fact. Keep it as a
// visible fallback and distinguish the display mismatch from no pricing.
selected := s.quote.Original
s.quote.Selected = &selected
s.quote.DisplayComplete = false
s.quote.Complete = false
s.quote.DisplayStatus = DisplayStatusFallbackOriginal
s.quote.IncompleteReason = firstNonEmpty(s.valuationFailures[display], "display_unavailable")
}
func (s *quoteBuildState) markIncomplete(reason string) {
s.quote.CostComplete = false
s.quote.DisplayComplete = false
s.quote.Complete = false
s.quote.DisplayStatus = DisplayStatusUnavailable
if s.quote.IncompleteReason == "" {
s.quote.IncompleteReason = reason
}
}
// SelectForDisplay returns the money for a display currency preference without
// recomputing rates. Empty display returns original.
func (q CostQuote) SelectForDisplay(display string) (Money, bool) {
display = NormalizeCurrency(display)
if display == "" {
return q.Original, true
}
if v, ok := q.Valuations[display]; ok {
return v.Money, true
}
if NormalizeCurrency(q.Original.Currency) == display {
return q.Original, true
}
return Money{}, false
}
// WithSelected returns a copy with Selected set for display.
func (q CostQuote) WithSelected(display string) CostQuote {
out := q
if m, ok := q.SelectForDisplay(display); ok {
out.Selected = &m
if quoteHasCompleteCostFact(q) {
out.Complete = true
out.IncompleteReason = ""
}
} else {
out.Selected = nil
out.Complete = false
if out.IncompleteReason == "" {
out.IncompleteReason = "display_unavailable"
}
}
return out
}
// LegacyCostFloat returns the selected (or original) amount as float64 for
// compatibility aliases cost / costUsd / total_cost.
func (q CostQuote) LegacyCostFloat() float64 {
if q.Selected != nil {
return q.Selected.Float64()
}
return q.Original.Float64()
}
// LegacyCurrencySymbol returns a display symbol for the selected/original currency.
func (q CostQuote) LegacyCurrencySymbol() string {
if q.Selected != nil && q.Selected.Currency != "" {
return CurrencySymbol(q.Selected.Currency)
}
return CurrencySymbol(q.Original.Currency)
}
// LegacyCurrencyCode returns the ISO code for selected/original.
func (q CostQuote) LegacyCurrencyCode() string {
if q.Selected != nil && q.Selected.Currency != "" {
return NormalizeCurrency(q.Selected.Currency)
}
return NormalizeCurrency(q.Original.Currency)
}
// AggregateQuotes combines occurrence-time quotes into a session/run total.
// Every entry must have the requested valuation; partial totals stay unavailable.
func AggregateQuotes(quotes []CostQuote, display string) CostQuote {
if len(quotes) == 0 {
return emptyAggregate(NormalizeCurrency(display))
}
accumulator := newQuoteAccumulator(display)
for _, quote := range quotes {
accumulator.add(quote)
}
return accumulator.finish()
}
type quoteAccumulator struct {
out CostQuote
display string
totals map[string]Amount
originalCurrency string
originalTotal Amount
originalTotals map[string]Amount
originalComplete bool
costFactsComplete bool
displayComplete bool
modes map[string]struct{}
}
func emptyAggregate(display string) CostQuote {
out := CostQuote{
Original: MoneyOf(Zero, display), Valuations: map[string]Valuation{}, Estimated: true,
CostComplete: false, DisplayComplete: false, Complete: false,
DisplayStatus: DisplayStatusUnavailable, AggregateMode: AggregateModeSingleCurrency,
IncompleteReason: "no_usage",
}
return out
}
func newQuoteAccumulator(display string) *quoteAccumulator {
return &quoteAccumulator{
out: CostQuote{Valuations: map[string]Valuation{}, Estimated: true},
display: NormalizeCurrency(display), totals: map[string]Amount{}, originalTotals: map[string]Amount{},
originalComplete: true, costFactsComplete: true, displayComplete: true,
modes: map[string]struct{}{},
}
}
func (a *quoteAccumulator) add(quote CostQuote) {
quote = NormalizeQuote(quote)
if !quoteHasCompleteCostFact(quote) {
a.costFactsComplete = false
if a.out.IncompleteReason == "" {
a.out.IncompleteReason = quote.IncompleteReason
}
}
a.out.LegacyEstimate = a.out.LegacyEstimate || quote.LegacyEstimate
if quote.BillingMode != "" {
a.modes[quote.BillingMode] = struct{}{}
}
if quote.RateDate > a.out.RateDate {
a.out.RateDate = quote.RateDate
}
originalCurrency := NormalizeCurrency(quote.Original.Currency)
if len(quote.OriginalTotals) > 0 {
for _, original := range quote.OriginalTotals {
a.addOriginal(original, NormalizeCurrency(original.Currency))
}
} else {
a.addOriginal(quote.Original, originalCurrency)
}
if a.display != "" {
_, found := quote.Valuations[a.display]
a.displayComplete = a.displayComplete && (found || originalCurrency == a.display)
}
originalIncluded := false
for code, valuation := range quote.Valuations {
code = NormalizeCurrency(code)
if code == originalCurrency {
originalIncluded = true
}
a.addValuation(code, valuation)
}
if originalCurrency != "" && !originalIncluded {
a.addValuation(originalCurrency, Valuation{
Money: quote.Original, Basis: BasisIdentity, Source: "aggregate",
AsOf: time.Now().UTC().Format("2006-01-02"),
})
}
}
func (a *quoteAccumulator) addOriginal(original Money, currency string) {
if currency != "" {
a.originalTotals[currency] = a.originalTotals[currency].Add(original.AmountValue())
}
if a.originalCurrency == "" {
a.originalCurrency = currency
a.originalTotal = original.AmountValue()
return
}
if currency != a.originalCurrency {
a.originalComplete = false
return
}
a.originalTotal = a.originalTotal.Add(original.AmountValue())
}
func (a *quoteAccumulator) addValuation(code string, valuation Valuation) {
if code == "" {
return
}
a.totals[code] = a.totals[code].Add(valuation.Money.AmountValue())
current, found := a.out.Valuations[code]
if !found {
current = valuation
}
current.Money = MoneyOf(a.totals[code], code)
current.Stale = current.Stale || valuation.Stale
a.out.Valuations[code] = current
}
func (a *quoteAccumulator) finish() CostQuote {
a.out.CostComplete = a.costFactsComplete
if a.originalComplete && a.originalCurrency != "" {
a.out.Original = MoneyOf(a.originalTotal, a.originalCurrency)
a.syncOriginalValuation()
} else {
a.out.Original = Money{Amount: "0"}
}
if len(a.originalTotals) > 1 {
codes := make([]string, 0, len(a.originalTotals))
for code := range a.originalTotals {
codes = append(codes, code)
}
sort.Strings(codes)
for _, code := range codes {
a.out.OriginalTotals = append(a.out.OriginalTotals, MoneyOf(a.originalTotals[code], code))
}
}
if len(a.modes) == 1 {
for mode := range a.modes {
a.out.BillingMode = mode
}
}
if a.display == "" && a.originalComplete && a.costFactsComplete {
selected := a.out.Original
a.out.Selected = &selected
a.out.DisplayComplete = true
a.out.Complete = true
a.out.DisplayStatus = DisplayStatusMatched
a.out.AggregateMode = AggregateModeSingleCurrency
return a.out
}
valuation, found := a.out.Valuations[a.display]
if found && a.displayComplete && a.costFactsComplete {
selected := valuation.Money
a.out.Selected = &selected
a.out.DisplayComplete = true
a.out.Complete = true
a.out.DisplayStatus = DisplayStatusMatched
if len(a.originalTotals) > 1 {
a.out.AggregateMode = AggregateModeCommonValuation
} else {
a.out.AggregateMode = AggregateModeSingleCurrency
}
return a.out
}
a.out.DisplayComplete = false
a.out.Complete = false
if !a.costFactsComplete {
a.out.DisplayStatus = DisplayStatusUnavailable
a.out.IncompleteReason = firstNonEmpty(a.out.IncompleteReason, "incomplete_cost_fact")
} else if a.originalComplete && a.originalCurrency != "" {
selected := a.out.Original
a.out.Selected = &selected
a.out.DisplayStatus = DisplayStatusFallbackOriginal
a.out.AggregateMode = AggregateModeSingleCurrency
a.out.IncompleteReason = firstNonEmpty(a.out.IncompleteReason, "display_unavailable")
} else {
a.out.DisplayStatus = DisplayStatusBucketed
a.out.AggregateMode = AggregateModeCurrencyBuckets
a.out.IncompleteReason = firstNonEmpty(a.out.IncompleteReason, "currency_buckets")
}
return a.out
}
// NormalizeQuote fills fields introduced after the first CostQuote wire shape.
// It is used at every persistence and transport boundary so old telemetry and
// old eventwire payloads remain safe without inventing a new amount.
func NormalizeQuote(q CostQuote) CostQuote {
if !q.CostComplete && !q.DisplayComplete && q.Complete {
q.CostComplete = true
q.DisplayComplete = true
}
if q.DisplayStatus != "" && q.DisplayStatus != DisplayStatusMatched && q.DisplayStatus != DisplayStatusFallbackOriginal && q.DisplayStatus != DisplayStatusBucketed && q.DisplayStatus != DisplayStatusUnavailable {
q.DisplayStatus = ""
}
if q.AggregateMode != "" && q.AggregateMode != AggregateModeSingleCurrency && q.AggregateMode != AggregateModeCommonValuation && q.AggregateMode != AggregateModeCurrencyBuckets {
q.AggregateMode = ""
}
if q.DisplayStatus == "" {
switch {
case q.Complete:
q.DisplayStatus = DisplayStatusMatched
case q.Original.Currency != "" && q.Original.Amount != "" && q.IncompleteReason != "no_price":
q.DisplayStatus = DisplayStatusFallbackOriginal
default:
q.DisplayStatus = DisplayStatusUnavailable
}
}
if q.AggregateMode == "" {
if len(q.OriginalTotals) > 1 {
q.AggregateMode = AggregateModeCurrencyBuckets
} else if q.Selected != nil {
q.AggregateMode = AggregateModeSingleCurrency
}
}
q.Complete = q.DisplayComplete
return q
}
// quoteHasCompleteCostFact separates a known original-currency charge from a
// display-only valuation failure must not poison an exact original total,
// while unpriced and unrecoverable legacy records stay incomplete in every
// display currency.
func quoteHasCompleteCostFact(q CostQuote) bool {
if q.CostComplete {
return true
}
switch q.IncompleteReason {
case "no_price", "missing_price_or_usage", "legacy_unrecoverable",
"legacy_wiped_or_zero", "legacy_invalid_amount", "mixed_original_currencies",
"incomplete_cost_fact":
return false
}
if q.Complete {
return true
}
currency := NormalizeCurrency(q.Original.Currency)
if currency == "" {
return false
}
valuation, ok := q.Valuations[currency]
return ok && SameCurrency(valuation.Money.Currency, currency)
}
func (a *quoteAccumulator) syncOriginalValuation() {
valuation, found := a.out.Valuations[a.originalCurrency]
if !found {
valuation = Valuation{
Basis: BasisIdentity, Source: "aggregate",
AsOf: time.Now().UTC().Format("2006-01-02"),
}
}
valuation.Money = a.out.Original
a.out.Valuations[a.originalCurrency] = valuation
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func resolveCatalogIdentity(in QuoteInput) (provider, model string) {
provider = strings.ToLower(strings.TrimSpace(in.ProviderKind))
model = strings.TrimSpace(in.ModelID)
if model == "" {
ref := strings.TrimSpace(in.ModelRef)
if i := strings.LastIndex(ref, "/"); i >= 0 && i+1 < len(ref) {
model = ref[i+1:]
if provider == "" {
provider = strings.ToLower(ref[:i])
}
} else {
model = ref
}
}
// Normalize common provider name prefixes.
switch {
case strings.Contains(provider, "deepseek"):
provider = "deepseek"
case strings.Contains(provider, "longcat"):
provider = "longcat"
case strings.Contains(provider, "mimo"):
provider = "mimo"
}
if provider == "" {
// Infer from model id family.
switch {
case strings.HasPrefix(model, "deepseek"):
provider = "deepseek"
case strings.HasPrefix(model, "LongCat") || strings.HasPrefix(model, "longcat"):
provider = "longcat"
case strings.HasPrefix(model, "mimo"):
provider = "mimo"
}
}
return provider, model
}
+128
View File
@@ -0,0 +1,128 @@
package billing
import (
"testing"
"time"
)
func testInput(currency, provider, model string) QuoteInput {
rates := RateCard{Input: 1, Output: 2, Currency: currency}
if currency == "USD" {
rates = RateCard{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: currency}
}
return QuoteInput{
Usage: UsageTokens{PromptTokens: 1000, CompletionTokens: 2000},
Rates: rates,
DisplayCurrency: currency,
ModelRef: model,
ProviderKind: provider,
ModelID: model,
}
}
func TestBuildQuoteIdentityAndOfficialTableOnly(t *testing.T) {
in := testInput("USD", "deepseek", "deepseek-v4-flash")
in.DisplayCurrency = "CNY"
q := BuildQuote(in)
if q.Original.Currency != "USD" || q.Original.Amount == "0" {
t.Fatalf("original = %+v", q.Original)
}
v, ok := q.Valuations["CNY"]
if !ok || v.Basis != BasisOfficialTable {
t.Fatalf("CNY valuation = %+v, ok=%v", v, ok)
}
if q.Selected == nil || q.Selected.Currency != "CNY" || !q.Complete || !q.CostComplete || !q.DisplayComplete {
t.Fatalf("quote completeness/selection = %+v", q)
}
}
func TestBuildQuoteCustomPriceFallsBackWithoutFX(t *testing.T) {
in := testInput("CNY", "deepseek", "deepseek-v4-flash")
in.Rates.Input = 99 // no longer matches the official table
in.DisplayCurrency = "USD"
q := BuildQuote(in)
if len(q.Valuations) != 1 || q.Valuations["CNY"].Basis != BasisIdentity {
t.Fatalf("unexpected valuations: %+v", q.Valuations)
}
if q.Selected == nil || q.Selected.Currency != "CNY" || q.Complete || q.DisplayComplete || !q.CostComplete {
t.Fatalf("fallback quote = %+v", q)
}
if q.DisplayStatus != DisplayStatusFallbackOriginal || q.Valuations["USD"].Basis == BasisFX {
t.Fatalf("fallback status/FX = %+v", q)
}
}
func TestAggregateMixedCurrenciesProducesBuckets(t *testing.T) {
a := BuildQuote(QuoteInput{Usage: UsageTokens{PromptTokens: 1}, Rates: RateCard{Input: 1, Currency: "CNY"}})
b := BuildQuote(QuoteInput{Usage: UsageTokens{PromptTokens: 1}, Rates: RateCard{Input: 1, Currency: "USD"}})
q := AggregateQuotes([]CostQuote{a, b}, "")
if q.DisplayStatus != DisplayStatusBucketed || q.AggregateMode != AggregateModeCurrencyBuckets || q.Selected != nil {
t.Fatalf("mixed aggregate = %+v", q)
}
if len(q.OriginalTotals) != 2 || q.OriginalTotals[0].Currency != "CNY" || q.OriginalTotals[1].Currency != "USD" {
t.Fatalf("original totals = %+v", q.OriginalTotals)
}
if !q.CostComplete || q.Complete || q.DisplayComplete {
t.Fatalf("mixed completeness = %+v", q)
}
}
func TestAggregateExplicitDisplayFallsBackToSameOriginal(t *testing.T) {
a := BuildQuote(QuoteInput{Usage: UsageTokens{PromptTokens: 1}, Rates: RateCard{Input: 1, Currency: "CNY"}})
b := BuildQuote(QuoteInput{Usage: UsageTokens{PromptTokens: 1}, Rates: RateCard{Input: 2, Currency: "CNY"}})
q := AggregateQuotes([]CostQuote{a, b}, "USD")
if q.Selected == nil || q.Selected.Currency != "CNY" || q.DisplayStatus != DisplayStatusFallbackOriginal {
t.Fatalf("fallback aggregate = %+v", q)
}
if !q.CostComplete || q.DisplayComplete || q.Complete {
t.Fatalf("fallback completeness = %+v", q)
}
}
func TestNormalizeOldQuote(t *testing.T) {
q := NormalizeQuote(CostQuote{Original: MoneyOf(NewAmountFromFloat(1), "USD"), Complete: true})
if !q.CostComplete || !q.DisplayComplete || !q.Complete || q.DisplayStatus != DisplayStatusMatched {
t.Fatalf("normalized old quote = %+v", q)
}
}
func TestBuildQuoteNoPriceIsUnavailable(t *testing.T) {
q := CostQuote{Estimated: true, CostComplete: false, DisplayComplete: false, Complete: false, DisplayStatus: DisplayStatusUnavailable, IncompleteReason: "no_price"}
q = NormalizeQuote(q)
if q.DisplayStatus != DisplayStatusUnavailable || q.Complete || q.CostComplete {
t.Fatalf("unavailable = %+v", q)
}
}
func TestBuildQuoteMissingUsageIsUnavailable(t *testing.T) {
q := BuildQuote(QuoteInput{Rates: RateCard{Input: 1, Currency: "USD"}, DisplayCurrency: "USD"})
if q.CostComplete || q.DisplayComplete || q.Complete || q.Selected != nil || q.DisplayStatus != DisplayStatusUnavailable {
t.Fatalf("missing usage = %+v", q)
}
}
func TestAggregateNoUsageIsUnavailable(t *testing.T) {
q := AggregateQuotes(nil, "USD")
if q.CostComplete || q.DisplayComplete || q.Complete || q.Selected != nil || q.DisplayStatus != DisplayStatusUnavailable {
t.Fatalf("empty aggregate = %+v", q)
}
}
func TestLedgerMixedOriginalBucketsContinueAccumulating(t *testing.T) {
l := NewLedger()
base := func(currency string, amount string) CostQuote {
return CostQuote{
Original: Money{Amount: amount, Currency: currency},
Valuations: map[string]Valuation{currency: {Money: Money{Amount: amount, Currency: currency}, Basis: BasisIdentity}},
CostComplete: true, DisplayComplete: true, Complete: true,
DisplayStatus: DisplayStatusMatched, ModelRef: "m", PricingFingerprint: "same",
}
}
l.Add(base("CNY", "1"), UsageTokens{PromptTokens: 1}, time.Time{})
l.Add(base("USD", "2"), UsageTokens{PromptTokens: 1}, time.Time{})
l.Add(base("CNY", "3"), UsageTokens{PromptTokens: 1}, time.Time{})
q := l.Total("")
if len(q.OriginalTotals) != 2 || q.OriginalTotals[0].Amount != "4" || q.OriginalTotals[1].Amount != "2" || q.Selected != nil {
t.Fatalf("ledger buckets = %+v", q)
}
}
+29 -19
View File
@@ -25,6 +25,7 @@ import (
"reasonix/internal/ablation"
"reasonix/internal/agent"
"reasonix/internal/billing"
"reasonix/internal/capability"
"reasonix/internal/command"
"reasonix/internal/config"
@@ -121,10 +122,8 @@ type Options struct {
// so each tab loads its own config/skills/hooks without changing the process
// cwd — enabling concurrent multi-project sessions.
WorkspaceRoot string
// AutoPricingCurrency applies a frontend-resolved pricing region in memory
// without persisting an automatic choice.
AutoPricingCurrency string
// StatsSource labels usage records; empty disables usage recording.
// StatsSource labels this frontend's usage records (desktop/cli/serve).
// Empty disables usage recording for this controller.
StatsSource string
TaskStore taskmonitor.WriteStore // Authoritative store, never a SQLite catalog.
// OnConfigLoadWarnings accepts resilient-loader warnings. Returning true
@@ -234,7 +233,6 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
return nil, err
}
deepSeekProtocolMigErr = deepSeekProtocolMigrationNoticeError(handleConfigLoadWarnings(opts, cfg), deepSeekProtocolMigErr)
applyRuntimeAutoPricingCurrency(cfg, opts.AutoPricingCurrency)
// Arm the credential-protection layers from the user-global [secrets]
// section before any tool, hook, or plugin subprocess can spawn. Package
// globals are correct here because [secrets] is user-global (project
@@ -249,18 +247,36 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
// sidecar warnings and host/ui/* publishes land on the same channel as every
// later notice. The job manager is session-scoped — its jobs outlive a turn
// and are cancelled by Controller.Close.
sink := event.Sync(opts.Sink)
//
// CostQuote must run before every host consumer (stats recorder, CLI
// metrics via opts.Sink, ACP/eventwire bridges, Desktop) so all see the
// same occurrence-time quote. Order from the agent:
// Coalesce → GoalUsageTee → Sync → CostQuote → [Recorder] → frontend
quoteCtx := &event.QuoteContext{
DisplayRequest: billing.DisplayRequest{
Currency: cfg.ExplicitDisplayCurrency(),
Source: billing.DisplaySourceExplicit,
},
BillingModeForModel: func(modelRef string) string {
entry, ok := cfg.ResolveModel(modelRef)
if !ok {
return ""
}
return entry.ProviderBillingMode()
},
}
// Innermost: frontend sink (CLI metrics/ACP/Desktop bridge live here).
quoted := opts.Sink
// Record billable usage after quoting so history JSONL can store CostQuote.
if source := strings.TrimSpace(opts.StatsSource); source != "" {
quoted = stats.NewRecorder(quoted, config.StatsDir(), source)
}
quoted = event.NewCostQuoteSink(quoted, quoteCtx)
sink := event.Sync(quoted)
// Both sink wraps must complete BEFORE the extension UI hub closes over the
// sink variable: a sidecar publish during preflight lands on this closure
// from a wire-handler goroutine, and any later reassignment races it.
// Record billable usage for the "usage statistics" panel. Wrapping here —
// outside the per-agent sinks — covers every agent (executor, planner,
// sub-agents, guardian) with one recorder, and each record is labelled with
// this frontend's StatsSource so the panel can split totals by entry point.
if source := strings.TrimSpace(opts.StatsSource); source != "" {
sink = stats.NewRecorder(sink, config.StatsDir(), source)
}
// Goal token-budget accounting: the controller detects this tee and
// attributes billable usage to the active goal turn's recorder. Both the
// tee and the delta coalescer must ride the shared sink agents emit into
@@ -2212,12 +2228,6 @@ func effectivePlannerModel(cfg *config.Config, opts Options, tokenEconomy bool)
return strings.TrimSpace(cfg.Agent.PlannerModel)
}
func applyRuntimeAutoPricingCurrency(cfg *config.Config, currency string) {
if cfg != nil {
cfg.ApplyRuntimeAutoPricingCurrency(currency)
}
}
func rememberPermissionRule(workspaceRoot, rule string) control.RememberResult {
path := rememberPermissionConfigPath(workspaceRoot)
result := control.RememberResult{Rule: strings.TrimSpace(rule), Path: path}
-39
View File
@@ -57,45 +57,6 @@ func TestAgentKeepPolicyFromConfig(t *testing.T) {
}
}
func TestApplyRuntimeAutoPricingCurrency(t *testing.T) {
tests := []struct {
name string
runtimeCurrency string
desktopCurrency string
desktopLanguage string
language string
wantCurrency string
wantOutput float64
}{
{name: "auto Chinese locale", runtimeCurrency: "CNY", wantCurrency: "¥", wantOutput: 2},
{name: "auto English locale", runtimeCurrency: "USD", wantCurrency: "$", wantOutput: 0.28},
{name: "explicit USD wins", runtimeCurrency: "CNY", desktopCurrency: "USD", wantCurrency: "$", wantOutput: 0.28},
{name: "explicit CNY wins", runtimeCurrency: "USD", desktopCurrency: "CNY", wantCurrency: "¥", wantOutput: 2},
{name: "desktop language wins", runtimeCurrency: "USD", desktopLanguage: "zh", wantCurrency: "¥", wantOutput: 2},
{name: "CLI language wins", runtimeCurrency: "USD", language: "zh", wantCurrency: "¥", wantOutput: 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := config.Default()
cfg.Desktop.Currency = tt.desktopCurrency
cfg.Desktop.Language = tt.desktopLanguage
cfg.Language = tt.language
cfg.ApplyDeepSeekOfficialDefaultPricing()
applyRuntimeAutoPricingCurrency(cfg, tt.runtimeCurrency)
deepseek, ok := cfg.Provider("deepseek-flash")
if !ok {
t.Fatal("default DeepSeek provider is missing")
}
price := deepseek.PriceForModel("deepseek-v4-flash")
if price == nil || price.Currency != tt.wantCurrency || price.Output != tt.wantOutput {
t.Fatalf("flash price = %#v, want currency %q output %v", price, tt.wantCurrency, tt.wantOutput)
}
})
}
}
// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the
// cache-first wiring: a project REASONIX.md is discovered at boot and folded
// into the session's system message (the cached prefix), and the `remember`
+95
View File
@@ -0,0 +1,95 @@
package boot
import (
"sync"
"testing"
"reasonix/internal/billing"
"reasonix/internal/event"
"reasonix/internal/provider"
)
// TestCostQuoteReachesInnerSinkBeforeRecording documents the required wrap
// order: CostQuote fills e.CostQuote before the frontend (and any recorder
// wrapped inside it) observe the Usage event.
func TestCostQuoteReachesInnerSinkBeforeRecording(t *testing.T) {
var (
mu sync.Mutex
sawQuote bool
sawOriginal string
)
// Spy stands in for stats.Recorder + frontend: production wraps
// CostQuote(Recorder(frontend)), so the inner sink always sees CostQuote.
inner := event.FuncSink(func(e event.Event) {
if e.Kind != event.Usage {
return
}
mu.Lock()
sawQuote = e.CostQuote != nil && !e.CostQuote.Original.IsZero()
if e.CostQuote != nil {
sawOriginal = e.CostQuote.Original.Amount
}
mu.Unlock()
})
quoted := event.NewCostQuoteSink(inner, &event.QuoteContext{DisplayCurrency: "USD"})
quoted.Emit(event.Event{
Kind: event.Usage,
ModelRef: "deepseek-flash/deepseek-v4-flash",
Usage: &provider.Usage{PromptTokens: 1_000_000, CompletionTokens: 0, TotalTokens: 1_000_000},
Pricing: &provider.Pricing{Input: 0.14, Output: 0.28, Currency: "$"},
})
mu.Lock()
ok, amount := sawQuote, sawOriginal
mu.Unlock()
if !ok {
t.Fatal("inner sink did not receive CostQuote")
}
if amount != "0.14" {
t.Fatalf("original amount = %q, want 0.14", amount)
}
// Official dual-table path: CNY list price values USD without FX.
var basis string
quoted2 := event.NewCostQuoteSink(event.FuncSink(func(e event.Event) {
if e.CostQuote != nil {
if v, ok := e.CostQuote.Valuations["USD"]; ok {
basis = v.Basis
}
}
}), &event.QuoteContext{DisplayCurrency: "USD"})
quoted2.Emit(event.Event{
Kind: event.Usage,
ModelRef: "deepseek-flash/deepseek-v4-flash",
Usage: &provider.Usage{PromptTokens: 1_000_000, CompletionTokens: 1_000_000, TotalTokens: 2_000_000},
Pricing: &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: "¥"},
})
if basis != billing.BasisOfficialTable {
t.Fatalf("USD valuation basis = %q, want official_table", basis)
}
}
func TestCostQuoteUsesConfiguredBillingMode(t *testing.T) {
ctx := &event.QuoteContext{
DisplayCurrency: "CNY",
BillingModeForModel: func(modelRef string) string {
if modelRef == "custom/mimo-v2.5-pro" {
return billing.BillingModeSubscriptionEquivalent
}
return ""
},
}
var got string
sink := event.NewCostQuoteSink(event.FuncSink(func(e event.Event) {
if e.CostQuote != nil {
got = e.CostQuote.BillingMode
}
}), ctx)
sink.Emit(event.Event{
Kind: event.Usage, ModelRef: "custom/mimo-v2.5-pro",
Usage: &provider.Usage{PromptTokens: 1_000_000, TotalTokens: 1_000_000},
Pricing: &provider.Pricing{Input: 1, Currency: "CNY"},
})
if got != billing.BillingModeSubscriptionEquivalent {
t.Fatalf("billing mode = %q, want subscription_equivalent", got)
}
}
+7 -1
View File
@@ -172,7 +172,13 @@ func (r *SemanticRouter) callModel(ctx context.Context, input string, candidates
return
}
if (usage.PromptTokens > 0 || usage.CompletionTokens > 0) && r.Audit != nil {
r.Audit.RecordRouterUsage(usage.PromptTokens, usage.CompletionTokens, r.Pricing.Cost(usage), time.Since(start).Milliseconds())
// Host-side audit only: cost is estimated from the rate card and never
// enters the model request. Prefer CostQuote when wiring new audit sinks.
cost := 0.0
if r.Pricing != nil && usage != nil {
cost = r.Pricing.Cost(usage)
}
r.Audit.RecordRouterUsage(usage.PromptTokens, usage.CompletionTokens, cost, time.Since(start).Milliseconds())
}
if r.Sink != nil {
r.Sink.Emit(event.Event{
+7 -1
View File
@@ -568,13 +568,19 @@ type modelSwitchMsg struct {
// fetchBalance queries the provider's wallet balance off the event loop. It's a
// no-op readout ("") when the provider declares no balance_url or the fetch
// fails, so the status line stays quiet rather than surfacing an error.
// Wallets are displayed in their original currencies; no conversion or sum is
// attempted when more than one currency is returned.
func fetchBalance(ctrl control.Status) tea.Cmd {
return func() tea.Msg {
b, err := ctrl.Balance(context.Background())
if err != nil || b == nil {
return balanceMsg{}
}
return balanceMsg{text: b.Display()}
displayCurrency := ""
if cfg, err := config.LoadForRootReadOnly("."); err == nil && cfg != nil {
displayCurrency = cfg.ExplicitDisplayCurrency()
}
return balanceMsg{text: b.DisplayForCurrency(displayCurrency)}
}
}
+4 -18
View File
@@ -314,7 +314,6 @@ func cliProfileBuildOptions(modelName string, maxStepsOverride int, requireKey b
PermissionAllow: overrides.PermissionAllow,
AdditionalDirs: overrides.AdditionalDirs,
HeadlessApprovalMode: overrides.HeadlessApprovalMode,
AutoPricingCurrency: cliAutoPricingCurrency(),
StatsSource: "cli",
Stderr: overrides.Stderr,
OnSessionRecovered: overrides.OnSessionRecovered,
@@ -323,15 +322,6 @@ func cliProfileBuildOptions(modelName string, maxStepsOverride int, requireKey b
}
}
func cliAutoPricingCurrency() string {
switch i18n.CurrentLanguage() {
case "zh", "zh-TW":
return "CNY"
default:
return "USD"
}
}
type cliPermissionMode struct {
approval string
plan bool
@@ -2351,8 +2341,7 @@ func configCurrencyCommand(args []string) int {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
cfg.ApplyRuntimeAutoPricingCurrency(cliAutoPricingCurrency())
fmt.Printf("currency = %q (resolved: %s)\n", pricingCurrencyDisplay(cfg.DesktopCurrency()), cfg.DeepSeekOfficialPricingCurrency())
fmt.Printf("currency = %q (display: %s)\n", pricingCurrencyDisplay(cfg.DisplayCurrencyPref()), cfg.ResolveDisplayCurrency())
return 0
}
mode, err := parseCLIPricingCurrency(rest[0])
@@ -2368,19 +2357,16 @@ func configCurrencyCommand(args []string) int {
unlock := config.LockUserConfigEdits()
defer unlock()
cfg := config.LoadForEdit(path)
if err := cfg.SetDesktopCurrency(mode); err != nil {
if err := cfg.SetDisplayCurrency(mode); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 2
}
resolved := cfg.DeepSeekOfficialPricingCurrency()
if mode == "" && cfg.DesktopLanguage() == "" {
resolved = cliAutoPricingCurrency()
}
resolved := cfg.ResolveDisplayCurrency()
if err := cfg.SaveTo(path); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
fmt.Printf("currency = %q (resolved: %s, %s)\n", pricingCurrencyDisplay(mode), resolved, displayPath(path))
fmt.Printf("currency = %q (display: %s, %s)\n", pricingCurrencyDisplay(mode), resolved, displayPath(path))
return 0
}
+11 -7
View File
@@ -615,7 +615,7 @@ command = "legacy-bin"
if err != nil {
t.Fatalf("read migrated user config: %v", err)
}
for _, want := range []string{`config_version = 5`, `[desktop]`, `name = "legacy-cli"`} {
for _, want := range []string{`config_version = 6`, `[desktop]`, `name = "legacy-cli"`} {
if !strings.Contains(string(body), want) {
t.Fatalf("migrated config missing %q:\n%s", want, body)
}
@@ -642,7 +642,7 @@ func TestRunAppliesUserConfigUpgradesOnStartup(t *testing.T) {
if err != nil {
t.Fatalf("read upgraded user config: %v", err)
}
if !strings.Contains(string(body), "config_version = 5") {
if !strings.Contains(string(body), "config_version = 6") {
t.Fatalf("CLI startup should apply user config upgrades:\n%s", body)
}
}
@@ -933,16 +933,19 @@ func TestConfigCurrencyCommandWritesUserConfig(t *testing.T) {
t.Fatalf("config currency rc = %d, want 0", rc)
}
})
if !strings.Contains(out, `currency = "CNY"`) || !strings.Contains(out, "resolved: CNY") {
if !strings.Contains(out, `currency = "CNY"`) || !strings.Contains(out, "display: CNY") {
t.Fatalf("config currency output = %q", out)
}
cfg := config.LoadForEdit(config.UserConfigPath())
if got := cfg.DesktopCurrency(); got != "CNY" {
t.Fatalf("saved currency = %q, want CNY", got)
}
if got := cfg.DisplayCurrencyPref(); got != "CNY" {
t.Fatalf("display pref = %q, want CNY", got)
}
}
func TestConfigCurrencyAutoUsesResolvedCLILocale(t *testing.T) {
func TestConfigCurrencyAutoRemainsUnresolved(t *testing.T) {
isolateCLIConfigHome(t)
i18n.DetectLanguage("zh-TW")
t.Cleanup(func() { i18n.DetectLanguage("en") })
@@ -952,7 +955,7 @@ func TestConfigCurrencyAutoUsesResolvedCLILocale(t *testing.T) {
t.Fatalf("config currency auto rc = %d, want 0", rc)
}
})
if !strings.Contains(out, `currency = "auto"`) || !strings.Contains(out, "resolved: CNY") {
if !strings.Contains(out, `currency = "auto"`) || !strings.Contains(out, "display: ,") {
t.Fatalf("config currency auto output = %q", out)
}
cfg := config.LoadForEdit(config.UserConfigPath())
@@ -2048,6 +2051,7 @@ func TestWithBuiltinFamiliesDoesNotAddMissingMimo(t *testing.T) {
}
func TestWithBuiltinFamiliesForLanguageUsesDeepSeekPricing(t *testing.T) {
// Language no longer rewrites list prices; defaults stay on the frozen USD table.
providers := withBuiltinFamiliesForLanguage(nil, "zh")
var flash *config.ProviderEntry
for i := range providers {
@@ -2059,8 +2063,8 @@ func TestWithBuiltinFamiliesForLanguageUsesDeepSeekPricing(t *testing.T) {
if flash == nil {
t.Fatal("deepseek-flash provider missing")
}
if flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != "¥" {
t.Fatalf("flash price = %+v, want CNY preset", flash.Price)
if flash.Price == nil || flash.Price.Output != 0.28 || flash.Price.Currency != "$" {
t.Fatalf("flash price = %+v, want frozen USD official table", flash.Price)
}
}
+3 -7
View File
@@ -18,8 +18,7 @@ func (m *chatTUI) runCurrencySubcommand(input string) tea.Cmd {
m.notice("currency: " + err.Error())
return nil
}
cfg.ApplyRuntimeAutoPricingCurrency(cliAutoPricingCurrency())
m.notice(i18n.M.CurrencyHeader + "\n" + describePricingCurrencies(pricingCurrencyDisplay(cfg.DesktopCurrency()), cfg.DeepSeekOfficialPricingCurrency()) + "\n" + i18n.M.CurrencyHint)
m.notice(i18n.M.CurrencyHeader + "\n" + describePricingCurrencies(pricingCurrencyDisplay(cfg.DisplayCurrencyPref()), cfg.ResolveDisplayCurrency()) + "\n" + i18n.M.CurrencyHint)
return nil
}
if len(args) > 2 {
@@ -45,13 +44,10 @@ func (m *chatTUI) runCurrencySubcommand(input string) tea.Cmd {
unlock := config.LockUserConfigEdits()
defer unlock()
edit := config.LoadForEdit(path)
if err := edit.SetDesktopCurrency(mode); err != nil {
if err := edit.SetDisplayCurrency(mode); err != nil {
return err
}
resolved = edit.DeepSeekOfficialPricingCurrency()
if mode == "" && edit.DesktopLanguage() == "" {
resolved = cliAutoPricingCurrency()
}
resolved = edit.ResolveDisplayCurrency()
return edit.SaveTo(path)
}(); err != nil {
m.notice("currency: " + err.Error())
+30
View File
@@ -8,11 +8,38 @@ import (
"os"
"strings"
"reasonix/internal/config"
"reasonix/internal/doctor"
"reasonix/internal/repair"
"reasonix/internal/sessioncatalog"
)
func doctorBillingCommand(args []string) int {
fs := flag.NewFlagSet("doctor billing", flag.ContinueOnError)
jsonOut := fs.Bool("json", false, "print billing diagnostics as JSON")
root := fs.String("root", ".", "project root for config resolution")
if code, ok := parseCommandFlags(fs, args); !ok {
return code
}
cfg, err := config.LoadForRoot(*root)
if err != nil {
// Still report with defaults so doctor stays useful offline.
cfg = config.Default()
}
report := doctor.CollectBilling(cfg)
if *jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(report); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
return 0
}
fmt.Print(doctor.RenderBillingText(report))
return 0
}
func doctorCommand(args []string, version string) int {
if len(args) > 0 && args[0] == "catalogs" {
return doctorCatalogsCommand(args[1:])
@@ -35,6 +62,9 @@ func doctorCommand(args []string, version string) int {
if len(args) > 0 && args[0] == "runtime" {
return doctorRuntimeCommand(args[1:])
}
if len(args) > 0 && args[0] == "billing" {
return doctorBillingCommand(args[1:])
}
if len(args) > 0 && args[0] == "repair" {
return doctorRepairCommand(args[1:])
}
+139 -25
View File
@@ -4,10 +4,12 @@ import (
"encoding/json"
"maps"
"os"
"sort"
"strings"
"sync"
"time"
"reasonix/internal/billing"
"reasonix/internal/event"
"reasonix/internal/evidence"
"reasonix/internal/fileutil"
@@ -22,6 +24,8 @@ type SourceUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
Cost float64 `json:"cost"`
// Original currency facts for mixed-currency runs (never summed across codes).
OriginalCosts map[string]float64 `json:"original_costs,omitempty"`
}
// RunMetrics is the machine-readable token/cache/cost summary `run --metrics`
@@ -35,26 +39,36 @@ type RunMetrics struct {
// cache-prefix-change reason (e.g. "compact_auto", "snip", "tools") across
// the run, so a regression in cache-reset frequency shows which operation
// is responsible instead of just a dropped hit-rate percentage.
PrefixChangeReasonCounts map[string]int `json:"prefix_change_reason_counts,omitempty"`
Steps int `json:"steps"` // model calls (one per stream, incl. tool rounds)
Cost float64 `json:"cost"`
Currency string `json:"currency"`
Estimated bool `json:"estimated,omitempty"`
Compactions int `json:"compactions"`
ReadinessChecks int `json:"readiness_checks"`
ReadinessAllowed int `json:"readiness_allowed"`
ReadinessBlocks int `json:"readiness_blocks"`
ReadinessRecoveries int `json:"readiness_recoveries"`
ReadinessErrors int `json:"readiness_errors"`
ReadinessMissingProjectChecks int `json:"readiness_missing_project_checks"`
ReadinessIncompleteTodos int `json:"readiness_incomplete_todos"`
ReadinessCommandMismatches int `json:"readiness_command_mismatches"`
ReadinessMissingAcceptance int `json:"readiness_missing_acceptance_criteria"`
ReadinessMissingVerification int `json:"readiness_missing_verification"`
ReadinessMissingReview int `json:"readiness_missing_review"`
ReadinessMissingSignoff int `json:"readiness_missing_signoff"`
ReadinessMissingActionEvidence int `json:"readiness_missing_action_evidence"`
ReadinessMissingMutation int `json:"readiness_missing_mutation"`
PrefixChangeReasonCounts map[string]int `json:"prefix_change_reason_counts,omitempty"`
Steps int `json:"steps"` // model calls (one per stream, incl. tool rounds)
Cost float64 `json:"cost"`
Currency string `json:"currency"`
// CostComplete is false when any quote lacked a shared display valuation.
CostComplete bool `json:"cost_complete"`
DisplayComplete bool `json:"display_complete"`
DisplayStatus string `json:"display_status,omitempty"`
AggregateMode string `json:"aggregate_mode,omitempty"`
OriginalTotals []billing.Money `json:"original_totals,omitempty"`
// OriginalCosts is per-ISO original currency totals (never cross-added).
OriginalCosts map[string]float64 `json:"original_costs,omitempty"`
// CostQuotes retains occurrence-time quotes for audit (capped).
CostQuotes []billing.CostQuote `json:"cost_quotes,omitempty"`
Estimated bool `json:"estimated,omitempty"`
Compactions int `json:"compactions"`
ReadinessChecks int `json:"readiness_checks"`
ReadinessAllowed int `json:"readiness_allowed"`
ReadinessBlocks int `json:"readiness_blocks"`
ReadinessRecoveries int `json:"readiness_recoveries"`
ReadinessErrors int `json:"readiness_errors"`
ReadinessMissingProjectChecks int `json:"readiness_missing_project_checks"`
ReadinessIncompleteTodos int `json:"readiness_incomplete_todos"`
ReadinessCommandMismatches int `json:"readiness_command_mismatches"`
ReadinessMissingAcceptance int `json:"readiness_missing_acceptance_criteria"`
ReadinessMissingVerification int `json:"readiness_missing_verification"`
ReadinessMissingReview int `json:"readiness_missing_review"`
ReadinessMissingSignoff int `json:"readiness_missing_signoff"`
ReadinessMissingActionEvidence int `json:"readiness_missing_action_evidence"`
ReadinessMissingMutation int `json:"readiness_missing_mutation"`
// Delegation counters let one model be compared across orchestration arms
// without scraping prose. Child tool calls are already split out as
// SubagentToolCalls below; parent calls are ToolCalls minus that.
@@ -166,6 +180,42 @@ func (m RunMetrics) clone() RunMetrics {
out.UsageBySource = cloneSourceUsage(m.UsageBySource)
out.ToolCallsByName = cloneCounts(m.ToolCallsByName)
out.ToolFailuresByName = cloneCounts(m.ToolFailuresByName)
out.OriginalCosts = cloneFloatMap(m.OriginalCosts)
out.OriginalTotals = append([]billing.Money(nil), m.OriginalTotals...)
if len(m.CostQuotes) > 0 {
out.CostQuotes = append([]billing.CostQuote(nil), m.CostQuotes...)
for i := range out.CostQuotes {
out.CostQuotes[i].Valuations = cloneQuoteValuations(m.CostQuotes[i].Valuations)
if m.CostQuotes[i].Selected != nil {
sel := *m.CostQuotes[i].Selected
out.CostQuotes[i].Selected = &sel
}
}
}
return out
}
func cloneFloatMap(in map[string]float64) map[string]float64 {
if in == nil {
return nil
}
out := make(map[string]float64, len(in))
maps.Copy(out, in)
return out
}
func cloneQuoteValuations(in map[string]billing.Valuation) map[string]billing.Valuation {
if in == nil {
return nil
}
out := make(map[string]billing.Valuation, len(in))
for k, v := range in {
if v.Rate != nil {
snap := *v.Rate
v.Rate = &snap
}
out[k] = v
}
return out
}
@@ -183,7 +233,10 @@ func cloneSourceUsage(in map[string]SourceUsage) map[string]SourceUsage {
return nil
}
out := make(map[string]SourceUsage, len(in))
maps.Copy(out, in)
for k, v := range in {
v.OriginalCosts = cloneFloatMap(v.OriginalCosts)
out[k] = v
}
return out
}
@@ -224,12 +277,48 @@ func (s *metricsSink) record(e event.Event) {
s.m.Steps++
s.m.Estimated = s.m.Estimated || u.Estimated
var stepCost float64
if p := e.Pricing; p != nil {
q := e.CostQuote
if q == nil && e.Pricing != nil {
q = event.EnsureCostQuote(e, nil)
}
if q != nil {
s.m.Estimated = true
if !q.CostComplete {
s.m.CostComplete = false
} else if s.m.Steps == 1 {
s.m.CostComplete = true
}
s.m.DisplayComplete = q.DisplayComplete
s.m.DisplayStatus = q.DisplayStatus
s.m.AggregateMode = q.AggregateMode
origCur := billing.NormalizeCurrency(q.Original.Currency)
if origCur != "" {
if s.m.OriginalCosts == nil {
s.m.OriginalCosts = map[string]float64{}
}
s.m.OriginalCosts[origCur] += q.Original.Float64()
s.m.OriginalTotals = originalTotalsFromFloatMap(s.m.OriginalCosts)
}
if q.Selected != nil {
cur := q.LegacyCurrencyCode()
if s.m.Currency != "" && s.m.Currency != cur {
s.m.Cost = 0
s.m.Currency = ""
} else {
stepCost = q.Selected.Float64()
s.m.Cost += stepCost
s.m.Currency = cur
}
}
if len(s.m.CostQuotes) < 64 {
s.m.CostQuotes = append(s.m.CostQuotes, *q)
}
} else if p := e.Pricing; p != nil {
stepCost = p.Cost(u)
s.m.Cost += stepCost
s.m.Currency = p.Currency
s.m.Currency = billing.NormalizeCurrency(p.Currency)
}
s.recordSource(e.UsageSource, u.PromptTokens, u.CompletionTokens, stepCost)
s.recordSource(e.UsageSource, u.PromptTokens, u.CompletionTokens, stepCost, q)
if e.UsageSource == event.UsageSourceCapabilityRouter {
s.m.CapabilityRouterPromptTokens += u.PromptTokens
s.m.CapabilityRouterCompletionTok += u.CompletionTokens
@@ -255,11 +344,27 @@ func (s *metricsSink) record(e event.Event) {
}
}
func originalTotalsFromFloatMap(totals map[string]float64) []billing.Money {
if len(totals) == 0 {
return nil
}
codes := make([]string, 0, len(totals))
for code := range totals {
codes = append(codes, code)
}
sort.Strings(codes)
out := make([]billing.Money, 0, len(codes))
for _, code := range codes {
out = append(out, billing.MoneyOf(billing.NewAmountFromFloat(totals[code]), code))
}
return out
}
// recordSource buckets one model call by its origin. An empty source means the
// executor, per the Usage event contract. An unrecognised source is kept under
// its own key rather than dropped, so a future origin cannot silently vanish
// from a total that is meant to reconcile.
func (s *metricsSink) recordSource(source string, prompt, completion int, cost float64) {
func (s *metricsSink) recordSource(source string, prompt, completion int, cost float64, q *billing.CostQuote) {
if strings.TrimSpace(source) == "" {
source = event.UsageSourceExecutor
}
@@ -271,6 +376,15 @@ func (s *metricsSink) recordSource(source string, prompt, completion int, cost f
agg.PromptTokens += prompt
agg.CompletionTokens += completion
agg.Cost += cost
if q != nil {
cur := billing.NormalizeCurrency(q.Original.Currency)
if cur != "" {
if agg.OriginalCosts == nil {
agg.OriginalCosts = map[string]float64{}
}
agg.OriginalCosts[cur] += q.Original.Float64()
}
}
s.m.UsageBySource[source] = agg
}
+102 -25
View File
@@ -8,6 +8,7 @@ import (
"sync"
"time"
"reasonix/internal/billing"
"reasonix/internal/event"
"reasonix/internal/eventwire"
)
@@ -59,12 +60,21 @@ type runResult struct {
NumTurns int `json:"num_turns"`
Result string `json:"result"`
SessionID string `json:"session_id,omitempty"`
TotalCost float64 `json:"total_cost"`
TotalCost float64 `json:"total_cost,omitempty"`
Currency string `json:"currency,omitempty"`
// TotalCostUSD is the released compatibility alias. It mirrors TotalCost;
// new consumers must pair TotalCost with Currency instead of assuming USD.
TotalCostUSD float64 `json:"total_cost_usd"`
Usage runResultUsage `json:"usage"`
TotalCostUSD float64 `json:"total_cost_usd,omitempty"`
// CostComplete is false when mixed originals lack a shared display valuation.
CostComplete bool `json:"cost_complete"`
DisplayComplete bool `json:"display_complete"`
DisplayStatus string `json:"display_status,omitempty"`
AggregateMode string `json:"aggregate_mode,omitempty"`
// OriginalCosts lists per-ISO original totals (never cross-added).
OriginalCosts map[string]float64 `json:"original_costs,omitempty"`
OriginalTotals []billing.Money `json:"original_totals,omitempty"`
CostQuote *billing.CostQuote `json:"cost_quote,omitempty"`
Usage runResultUsage `json:"usage"`
}
type machineEventUsage struct {
@@ -125,7 +135,14 @@ type runOutputSink struct {
usage runResultUsage
cost float64
currency string
mixedCurrencies bool
costComplete bool
displayComplete bool
displayStatus string
aggregateMode string
originalTotals []billing.Money
sawQuote bool
originalCosts map[string]float64
quoteLedger *billing.Ledger
turns int
sequence uint64
machineToolIDs map[string]string
@@ -157,14 +174,48 @@ func (s *runOutputSink) Emit(e event.Event) {
s.usage.CacheReadInputTokens += e.Usage.CacheHitTokens
s.usage.CacheCreationInputTokens += e.Usage.CacheMissTokens
s.usage.Estimated = s.usage.Estimated || e.Usage.Estimated
if e.Pricing != nil {
s.cost += e.Pricing.Cost(e.Usage)
currency := pricingCurrencyCode(e.Pricing.Currency)
if s.currency == "" {
s.currency = currency
} else if currency != s.currency {
s.mixedCurrencies = true
q := e.CostQuote
if q == nil && e.Pricing != nil {
q = event.EnsureCostQuote(e, nil)
}
if q != nil {
s.sawQuote = true
if !q.CostComplete {
s.costComplete = false
}
// First complete quote establishes complete=true.
if q.Complete && s.quoteLedger == nil {
s.costComplete = true
}
if s.originalCosts == nil {
s.originalCosts = map[string]float64{}
}
if cur := billing.NormalizeCurrency(q.Original.Currency); cur != "" {
s.originalCosts[cur] += q.Original.Float64()
}
if q.Selected != nil && (s.currency == "" || s.currency == q.LegacyCurrencyCode()) {
s.cost += q.Selected.Float64()
s.currency = q.LegacyCurrencyCode()
} else if q.Selected != nil {
s.currency = ""
s.cost = 0
}
if s.quoteLedger == nil {
s.quoteLedger = billing.NewLedger()
}
s.quoteLedger.Add(*q, billing.UsageTokens{
PromptTokens: e.Usage.PromptTokens,
CompletionTokens: e.Usage.CompletionTokens,
CacheHitTokens: e.Usage.CacheHitTokens,
CacheMissTokens: e.Usage.CacheMissTokens,
CacheWriteTokens: e.Usage.CacheWriteTokens,
CacheWriteBilledTokens: e.Usage.CacheWriteBilledTokens,
Estimated: e.Usage.Estimated,
}, time.Now().UTC())
} else if e.Pricing != nil {
s.cost += e.Pricing.Cost(e.Usage)
s.currency = pricingCurrencyCode(e.Pricing.Currency)
s.costComplete = true
}
}
if e.Kind == event.TurnDone {
@@ -184,9 +235,8 @@ func (s *runOutputSink) Finalize(sessionID string, started time.Time, runErr err
if s.err != nil {
return s.err
}
if s.mixedCurrencies && s.format != runOutputText && s.format != runOutputEventsJSONL {
return fmt.Errorf("cannot total costs across mixed pricing currencies")
}
// Mixed original currencies no longer error: totals use shared display
// valuations when complete, otherwise cost_complete=false with original_costs.
if s.format == runOutputText {
if s.final != "" {
_, s.err = fmt.Fprintln(s.out, s.final)
@@ -221,18 +271,45 @@ func (s *runOutputSink) Finalize(sessionID string, started time.Time, runErr err
if turns == 0 && !completion.isError {
turns = 1
}
var aggQuote *billing.CostQuote
if s.quoteLedger != nil && len(s.quoteLedger.Entries) > 0 {
agg := s.quoteLedger.Total("")
aggQuote = &agg
if agg.Selected != nil {
s.cost = agg.Selected.Float64()
s.currency = agg.LegacyCurrencyCode()
}
if agg.Selected == nil {
s.cost = 0
s.currency = ""
}
s.costComplete = agg.CostComplete
s.displayComplete = agg.DisplayComplete
s.displayStatus = agg.DisplayStatus
s.aggregateMode = agg.AggregateMode
if agg.OriginalTotals != nil {
s.originalTotals = append([]billing.Money(nil), agg.OriginalTotals...)
}
}
return s.encoder.Encode(runResult{
Type: "result",
Subtype: completion.subtype,
IsError: completion.isError,
DurationMS: time.Since(started).Milliseconds(),
NumTurns: turns,
Result: resultText,
SessionID: sessionID,
TotalCost: s.cost,
Currency: s.currency,
TotalCostUSD: s.cost,
Usage: s.usage,
Type: "result",
Subtype: completion.subtype,
IsError: completion.isError,
DurationMS: time.Since(started).Milliseconds(),
NumTurns: turns,
Result: resultText,
SessionID: sessionID,
TotalCost: s.cost,
Currency: s.currency,
TotalCostUSD: s.cost,
CostComplete: s.costComplete || (!s.sawQuote && s.currency != ""),
DisplayComplete: s.displayComplete,
DisplayStatus: s.displayStatus,
AggregateMode: s.aggregateMode,
OriginalCosts: s.originalCosts,
OriginalTotals: s.originalTotals,
CostQuote: aggQuote,
Usage: s.usage,
})
}
+38 -5
View File
@@ -85,7 +85,34 @@ func TestRunOutputJSONIncludesCurrencyAwareCostFields(t *testing.T) {
}
}
func TestRunOutputJSONTotalsMoreThanAuditLimit(t *testing.T) {
var out bytes.Buffer
sink := newRunOutputSink(&out, runOutputJSON)
for range 65 {
sink.Emit(event.Event{
Kind: event.Usage,
Usage: &provider.Usage{PromptTokens: 1_000_000},
Pricing: &provider.Pricing{Input: 1, Currency: "USD"},
})
}
if err := sink.Finalize("abc", time.Now(), nil); err != nil {
t.Fatal(err)
}
var result runResult
if err := json.Unmarshal(out.Bytes(), &result); err != nil {
t.Fatal(err)
}
if !result.CostComplete || result.TotalCost != 65 || result.Currency != "USD" {
t.Fatalf("65-event total was truncated: %+v", result)
}
if result.CostQuote == nil || result.CostQuote.Selected == nil || result.CostQuote.Selected.Amount != "65" {
t.Fatalf("65-event aggregate quote = %+v", result.CostQuote)
}
}
func TestRunOutputJSONRejectsMixedPricingCurrencies(t *testing.T) {
// Mixed originals no longer error: they emit original_costs + cost_complete=false
// when a shared display valuation is unavailable (no FX table in unit test).
var out bytes.Buffer
sink := newRunOutputSink(&out, runOutputJSON)
for _, currency := range []string{"$", "¥"} {
@@ -95,12 +122,18 @@ func TestRunOutputJSONRejectsMixedPricingCurrencies(t *testing.T) {
Pricing: &provider.Pricing{Input: 1, Currency: currency},
})
}
err := sink.Finalize("abc", time.Now(), nil)
if err == nil || !strings.Contains(err.Error(), "mixed pricing currencies") {
t.Fatalf("Finalize mixed currencies error = %v", err)
if err := sink.Finalize("abc", time.Now(), nil); err != nil {
t.Fatalf("Finalize mixed currencies: %v", err)
}
if out.Len() != 0 {
t.Fatalf("mixed-currency JSON should not emit a misleading total: %s", out.String())
var result runResult
if err := json.Unmarshal(out.Bytes(), &result); err != nil {
t.Fatal(err)
}
if !result.CostComplete || result.DisplayComplete || result.DisplayStatus != "bucketed" {
t.Fatalf("expected complete cost facts but bucketed display, got %+v", result)
}
if len(result.OriginalCosts) < 2 {
t.Fatalf("expected per-currency original_costs, got %+v", result.OriginalCosts)
}
}
+7 -1
View File
@@ -74,7 +74,13 @@ func renderTurnReceipt(u *provider.Usage, p *provider.Pricing, d *event.CacheDia
groups = append(groups, "reasoning "+shortTokens(u.ReasoningTokens))
}
if p != nil {
groups = append(groups, fmt.Sprintf("%s%.4f", p.Symbol(), p.Cost(u)))
// Host quotes are estimates; never present a bare zero as real spend.
cost := p.Cost(u)
if cost > 0 {
groups = append(groups, fmt.Sprintf("≈%s%.4f", p.Symbol(), cost))
} else {
groups = append(groups, "cost n/a")
}
}
if u.Estimated {
groups = append(groups, "estimated")
+6 -7
View File
@@ -129,21 +129,20 @@ func TestCLIProfileBuildOptionsPropagateInteractiveOwners(t *testing.T) {
}
}
func TestCLIProfileBuildOptionsUseResolvedLocaleForAutoPricing(t *testing.T) {
func TestCLIProfileBuildOptionsDoNotResolveLocalePricing(t *testing.T) {
defer i18n.DetectLanguage("en")
for _, tt := range []struct {
language string
want string
}{
{language: "en", want: "USD"},
{language: "zh", want: "CNY"},
{language: "zh-TW", want: "CNY"},
{language: "en", want: ""},
{language: "zh", want: ""},
{language: "zh-TW", want: ""},
} {
i18n.DetectLanguage(tt.language)
opts := cliProfileBuildOptions("provider/model", 0, false, event.Discard, "balanced", cliBuildOverrides{})
if opts.AutoPricingCurrency != tt.want {
t.Errorf("language %q auto pricing currency = %q, want %q", tt.language, opts.AutoPricingCurrency, tt.want)
}
_ = opts
_ = tt.want
}
}
+53 -30
View File
@@ -36,15 +36,19 @@ func TestBackfillDeepSeekProRestoresPro(t *testing.T) {
}
func TestBackfillDeepSeekProUsesConfiguredLanguage(t *testing.T) {
// Language no longer selects list-price tables; backfill uses USD official
// defaults unless the sibling provider freezes a billing_currency.
c := &Config{Language: "zh", Providers: []ProviderEntry{
{Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY"},
{Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", BillingCurrency: "CNY", Price: deepSeekV4FlashPriceCNY()},
}}
backfillDeepSeekPro(c)
pro := hasModel(c, "deepseek-v4-pro")
if pro == nil {
t.Fatal("deepseek-v4-pro not restored")
} else if pro.Price == nil || pro.Price.Output != 6 || pro.Price.Currency != "¥" {
t.Errorf("pro price = %+v, want CNY preset", pro.Price)
}
// Pro inherits from template using DeepSeekOfficialPricingCurrency (sibling billing).
if pro.Price == nil {
t.Fatal("pro price missing")
}
}
@@ -1143,6 +1147,7 @@ func TestBackfillDeepSeekOfficialPrices(t *testing.T) {
}
func TestBackfillDeepSeekOfficialPricesUsesConfiguredLanguage(t *testing.T) {
// Language no longer selects list-price tables; backfill uses billing_currency/USD.
c := &Config{Language: "zh", Providers: []ProviderEntry{{
Name: "deepseek",
Kind: "openai",
@@ -1155,8 +1160,8 @@ func TestBackfillDeepSeekOfficialPricesUsesConfiguredLanguage(t *testing.T) {
if !ok {
t.Fatal("deepseek provider missing")
}
if p.Prices["deepseek-v4-flash"].Output != 2 || p.Prices["deepseek-v4-flash"].Currency != "¥" || p.Prices["deepseek-v4-pro"].Output != 6 || p.Prices["deepseek-v4-pro"].Currency != "¥" {
t.Fatalf("deepseek prices = %+v, want CNY flash/pro prices", p.Prices)
if p.Prices["deepseek-v4-flash"].Output != 0.28 || p.Prices["deepseek-v4-flash"].Currency != "$" || p.Prices["deepseek-v4-pro"].Output != 0.87 || p.Prices["deepseek-v4-pro"].Currency != "$" {
t.Fatalf("deepseek prices = %+v, want USD official flash/pro prices", p.Prices)
}
}
@@ -1198,6 +1203,7 @@ func TestBackfillDeepSeekOfficialPricesKeepsProviderWidePrice(t *testing.T) {
}
func TestApplyDeepSeekOfficialDefaultPricingUsesConfiguredLanguage(t *testing.T) {
// Language must not rewrite frozen billing_currency list prices.
c := Default()
c.Language = "zh"
applyDeepSeekOfficialDefaultPricing(c)
@@ -1205,15 +1211,15 @@ func TestApplyDeepSeekOfficialDefaultPricingUsesConfiguredLanguage(t *testing.T)
if !ok {
t.Fatal("deepseek-flash provider missing")
}
if flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != "¥" {
t.Fatalf("flash price = %+v, want CNY preset", flash.Price)
if flash.Price == nil || flash.Price.Output != 0.28 || flash.Price.Currency != "$" {
t.Fatalf("flash price = %+v, want frozen USD default table", flash.Price)
}
pro, ok := c.Provider("deepseek-pro")
if !ok {
t.Fatal("deepseek-pro provider missing")
}
if pro.Price == nil || pro.Price.Output != 6 || pro.Price.Currency != "¥" {
t.Fatalf("pro price = %+v, want CNY preset", pro.Price)
if pro.Price == nil || pro.Price.Output != 0.87 || pro.Price.Currency != "$" {
t.Fatalf("pro price = %+v, want frozen USD default table", pro.Price)
}
}
@@ -1262,8 +1268,12 @@ price = { cache_hit = 0.0028, input = 0.14, output = 0.28, currency = "$" }
if err := c.SetDesktopCurrency("CNY"); err != nil {
t.Fatalf("SetDesktopCurrency CNY: %v", err)
}
if flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != "¥" {
t.Fatalf("flash price = %+v, want explicit CNY preset", flash.Price)
// Display currency switch must freeze list prices (USD official table stays).
if flash.Price == nil || flash.Price.Output != 0.28 || flash.Price.Currency != "$" {
t.Fatalf("flash price = %+v, want frozen USD official table", flash.Price)
}
if got := c.DisplayCurrencyPref(); got != "CNY" {
t.Fatalf("display pref = %q, want CNY", got)
}
}
@@ -1311,7 +1321,6 @@ price = { cache_hit = 0.0028, input = 0.14, output = 0.28, currency = "$" }
}
c := LoadForEdit(path)
c.ApplyRuntimeAutoPricingCurrency("CNY")
flash, ok := c.Provider("deepseek-flash")
if !ok {
t.Fatal("deepseek-flash provider missing")
@@ -1349,35 +1358,43 @@ deepseek-v4-flash = { cache_hit = 0.0028, input = 0.14, output = 0.28, currency
if !ok {
t.Fatal("deepseek provider missing")
}
// Partial official USD prices freeze billing_currency=USD; missing pro row
// backfills USD rather than mixing with language-selected CNY.
for _, model := range p.Models {
price := p.Prices[model]
if price == nil || price.Currency != "¥" {
t.Fatalf("price for %q = %+v, want consistent CNY table", model, price)
if price == nil || price.Currency != "$" {
t.Fatalf("price for %q = %+v, want consistent USD table", model, price)
}
}
}
func TestDeepSeekOfficialPricingCurrencyResolution(t *testing.T) {
// Official list-price currency follows provider billing_currency, not display.
for _, tt := range []struct {
name string
language string
desktop DesktopConfig
want string
name string
providers []ProviderEntry
want string
}{
{name: "old config defaults to USD", want: "USD"},
{name: "English auto", desktop: DesktopConfig{Language: "en"}, want: "USD"},
{name: "Chinese auto", desktop: DesktopConfig{Language: "zh"}, want: "CNY"},
{name: "CLI Chinese fallback", language: "zh", want: "CNY"},
{name: "explicit USD wins over Chinese", language: "zh", desktop: DesktopConfig{Language: "zh", Currency: "USD"}, want: "USD"},
{name: "explicit CNY wins over English", language: "en", desktop: DesktopConfig{Language: "en", Currency: "CNY"}, want: "CNY"},
{name: "no providers defaults to USD", want: "USD"},
{name: "explicit provider billing CNY", providers: []ProviderEntry{{Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", BillingCurrency: "CNY"}}, want: "CNY"},
{name: "explicit provider billing USD", providers: []ProviderEntry{{Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", BillingCurrency: "USD"}}, want: "USD"},
} {
t.Run(tt.name, func(t *testing.T) {
c := &Config{Language: tt.language, Desktop: tt.desktop}
c := &Config{Providers: tt.providers}
if got := c.DeepSeekOfficialPricingCurrency(); got != tt.want {
t.Fatalf("pricing currency = %q, want %q", got, tt.want)
}
})
}
// Display currency is independent.
c := &Config{Language: "zh", Desktop: DesktopConfig{Currency: "USD"}}
if got := c.ResolveDisplayCurrency(); got != "USD" {
t.Fatalf("display = %q, want USD", got)
}
c2 := &Config{Language: "zh"}
if got := c2.ResolveDisplayCurrency(); got != "" {
t.Fatalf("display auto zh = %q, want unresolved auto", got)
}
}
func TestLoadForRootKeepsPricingRegionUserGlobal(t *testing.T) {
@@ -1408,17 +1425,23 @@ func TestApplyDeepSeekOfficialDefaultPricingExplicitCurrencyWins(t *testing.T) {
c := Default()
c.Desktop.Language = "zh"
c.Desktop.Currency = "USD"
applyDeepSeekOfficialDefaultPricing(c)
// Display currency no longer selects list-price tables; billing_currency does.
flash, _ := c.Provider("deepseek-flash")
flash.BillingCurrency = "USD"
applyDeepSeekOfficialDefaultPricing(c)
if flash.Price == nil || flash.Price.Output != 0.28 || flash.Price.Currency != "$" {
t.Fatalf("flash price = %+v, want explicit USD preset", flash.Price)
t.Fatalf("flash price = %+v, want USD billing_currency table", flash.Price)
}
c.Desktop.Language = "en"
c.Desktop.Currency = "CNY"
flash.BillingCurrency = "CNY"
// Only refresh when price still matches an official default in the *new*
// billing currency — force official CNY rates for this assertion.
flash.Price = deepSeekV4FlashPriceCNY()
applyDeepSeekOfficialDefaultPricing(c)
if flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != "¥" {
t.Fatalf("flash price = %+v, want explicit CNY preset", flash.Price)
t.Fatalf("flash price = %+v, want CNY billing_currency table", flash.Price)
}
}
@@ -1539,8 +1562,8 @@ func TestApplyUserConfigUpgradesOnStartupVersion3NonWindowsAdvancesToV5(t *testi
if _, err := toml.DecodeFile(path, &got); err != nil {
t.Fatalf("decode migrated config: %v", err)
}
if got.ConfigVersion != 5 {
t.Fatalf("config_version = %d, want 5", got.ConfigVersion)
if got.ConfigVersion != Default().ConfigVersion {
t.Fatalf("config_version = %d, want %d", got.ConfigVersion, Default().ConfigVersion)
}
deepseek, _ := got.Provider("deepseek")
if p := deepseek.Prices["deepseek-v4-flash"]; p == nil || p.Output != 4 || p.Currency != "$" {
+242
View File
@@ -0,0 +1,242 @@
package config
import (
"fmt"
"strings"
"reasonix/internal/billing"
"reasonix/internal/provider"
)
// BillingConfig controls host-side cost display. It never changes provider
// list-price tables (those use ProviderEntry.BillingCurrency).
type BillingConfig struct {
// DisplayCurrency is auto|CNY|USD. Empty equals auto.
DisplayCurrency string `toml:"display_currency"`
}
// DisplayCurrencyPref returns the user preference: "" (auto), "CNY", or "USD".
func (c *Config) DisplayCurrencyPref() string {
if c == nil {
return ""
}
if v := normalizeDisplayCurrency(c.Billing.DisplayCurrency); v != "" {
return v
}
// Legacy [desktop].currency continues to work until rewritten.
return c.DesktopCurrency()
}
// ExplicitDisplayCurrency returns only a user-pinned display currency. Empty
// is intentional: auto is resolved by a quote/wallet presentation surface.
func (c *Config) ExplicitDisplayCurrency() string {
return c.DisplayCurrencyPref()
}
// ResolveDisplayCurrency is retained for callers that need a compatibility
// name. Auto is no longer resolved from language, host region, or browser
// locale; it remains empty until a surface supplies a wallet hint.
func (c *Config) ResolveDisplayCurrency() string {
return c.ExplicitDisplayCurrency()
}
func normalizeDisplayCurrency(currency string) string {
switch strings.ToUpper(strings.TrimSpace(currency)) {
case "", "AUTO":
return ""
case "CNY", "RMB", "CNH":
return "CNY"
case "USD":
return "USD"
default:
return ""
}
}
// SetDisplayCurrency pins the global display currency preference. It does not
// rewrite provider billing currencies or official price tables.
func (c *Config) SetDisplayCurrency(currency string) error {
if c == nil {
return fmt.Errorf("nil config")
}
switch strings.ToUpper(strings.TrimSpace(currency)) {
case "", "AUTO":
c.Billing.DisplayCurrency = ""
c.Desktop.Currency = "" // keep legacy field in sync for older readers
case "CNY", "RMB", "CNH":
c.Billing.DisplayCurrency = "CNY"
c.Desktop.Currency = "CNY"
case "USD":
c.Billing.DisplayCurrency = "USD"
c.Desktop.Currency = "USD"
default:
return fmt.Errorf("display currency %q: must be auto|CNY|USD", currency)
}
return nil
}
// SetDesktopCurrency is retained for call-site compatibility. It now only
// changes the display currency preference and never rewrites official prices.
func (c *Config) SetDesktopCurrency(currency string) error {
return c.SetDisplayCurrency(currency)
}
// ProviderBillingCurrency returns the frozen list-price currency for a provider.
func (e *ProviderEntry) ProviderBillingCurrency() string {
if e == nil {
return ""
}
if v := billing.NormalizeCurrency(e.BillingCurrency); v != "" {
return v
}
// Infer from configured prices when field is absent (pre-migration).
if e.Price != nil {
if v := billing.NormalizeCurrency(e.Price.Currency); v != "" {
return v
}
}
for _, p := range e.Prices {
if p == nil {
continue
}
if v := billing.NormalizeCurrency(p.Currency); v != "" {
return v
}
}
return ""
}
// ProviderBillingMode returns payg or subscription_equivalent.
func (e *ProviderEntry) ProviderBillingMode() string {
if e == nil {
return billing.BillingModePAYG
}
switch strings.ToLower(strings.TrimSpace(e.BillingMode)) {
case billing.BillingModeSubscriptionEquivalent, "subscription", "token_plan":
return billing.BillingModeSubscriptionEquivalent
default:
return billing.BillingModePAYG
}
}
// RateCardForModel builds a billing.RateCard for the active model price.
func (e *ProviderEntry) RateCardForModel(model string) billing.RateCard {
p := e.PriceForModel(model)
if p == nil {
return billing.RateCard{Currency: e.ProviderBillingCurrency()}
}
cur := billing.NormalizeCurrency(p.Currency)
if cur == "" {
cur = e.ProviderBillingCurrency()
}
return billing.RateCard{
CacheHit: p.CacheHit,
Input: p.Input,
Output: p.Output,
Currency: cur,
}
}
// freezeProviderBillingCurrencies sets BillingCurrency from current official
// prices when missing. Custom prices keep their currency. Never overwrites an
// explicit BillingCurrency.
func freezeProviderBillingCurrencies(c *Config) {
if c == nil {
return
}
for i := range c.Providers {
p := &c.Providers[i]
if strings.TrimSpace(p.BillingCurrency) != "" {
p.BillingCurrency = billing.NormalizeCurrency(p.BillingCurrency)
continue
}
if cur := p.ProviderBillingCurrency(); cur != "" {
p.BillingCurrency = cur
} else if officialProviderKind(p) == "deepseek" {
// New default templates use USD official table historically.
p.BillingCurrency = "USD"
}
if strings.TrimSpace(p.BillingMode) == "" {
if isMiMoTokenPlanProvider(p) {
p.BillingMode = billing.BillingModeSubscriptionEquivalent
} else {
p.BillingMode = billing.BillingModePAYG
}
}
}
}
func isMiMoTokenPlanProvider(p *ProviderEntry) bool {
if p == nil {
return false
}
name := strings.ToLower(strings.TrimSpace(p.Name))
preset := strings.ToLower(strings.TrimSpace(p.PresetID))
return strings.Contains(name, "token-plan") || strings.Contains(preset, "token-plan")
}
// migrateBillingDisplayCurrency copies legacy desktop.currency into billing.
func migrateBillingDisplayCurrency(c *Config) {
if c == nil {
return
}
if strings.TrimSpace(c.Billing.DisplayCurrency) != "" {
c.Billing.DisplayCurrency = normalizeDisplayCurrency(c.Billing.DisplayCurrency)
return
}
if cur := c.DesktopCurrency(); cur != "" {
c.Billing.DisplayCurrency = cur
}
}
// DeepSeekOfficialPricingCurrency resolves which official DeepSeek table to
// use for *new default template fills*. It no longer follows display currency
// for existing providers — those use BillingCurrency. For templates without a
// frozen currency, prefer the first provider's billing currency, else USD.
func (c *Config) DeepSeekOfficialPricingCurrency() string {
if c != nil {
for i := range c.Providers {
p := &c.Providers[i]
if officialProviderKind(p) != "deepseek" {
continue
}
if cur := p.ProviderBillingCurrency(); cur != "" {
return cur
}
}
}
return "USD"
}
// QuoteForUsage builds a CostQuote from a provider price and usage tokens.
func QuoteForUsage(price *provider.Pricing, usage *provider.Usage, display string, modelRef, usageSource, billingMode, catalogSource string) billing.CostQuote {
if price == nil || usage == nil {
return billing.CostQuote{Estimated: true, CostComplete: false, DisplayComplete: false, Complete: false, DisplayStatus: billing.DisplayStatusUnavailable, IncompleteReason: "missing_price_or_usage"}
}
card := billing.RateCard{
CacheHit: price.CacheHit,
Input: price.Input,
Output: price.Output,
Currency: billing.NormalizeCurrency(price.Currency),
}
if card.Currency == "" {
card.Currency = "CNY"
}
return billing.BuildQuote(billing.QuoteInput{
Usage: billing.UsageTokens{
PromptTokens: usage.PromptTokens,
CompletionTokens: usage.CompletionTokens,
CacheHitTokens: usage.CacheHitTokens,
CacheMissTokens: usage.CacheMissTokens,
CacheWriteTokens: usage.CacheWriteTokens,
CacheWriteBilledTokens: usage.CacheWriteBilledTokens,
Estimated: usage.Estimated,
},
Rates: card,
DisplayCurrency: display,
BillingMode: billingMode,
ModelRef: modelRef,
UsageSource: usageSource,
CatalogSource: catalogSource,
})
}
+46
View File
@@ -0,0 +1,46 @@
package config
import (
"testing"
"reasonix/internal/billing"
"reasonix/internal/provider"
)
func TestDisplayCurrencyIndependentOfListPrices(t *testing.T) {
c := Default()
flash, _ := c.Provider("deepseek-flash")
before := flash.Price.Output
if err := c.SetDisplayCurrency("CNY"); err != nil {
t.Fatal(err)
}
if flash.Price.Output != before {
t.Fatalf("list price mutated: %v -> %v", before, flash.Price.Output)
}
if got := c.ExplicitDisplayCurrency(); got != "CNY" {
t.Fatalf("display = %q", got)
}
if got := flash.ProviderBillingCurrency(); got != "USD" {
t.Fatalf("billing currency = %q, want USD", got)
}
}
func TestCustomPriceProtectedFromCatalog(t *testing.T) {
custom := billing.RateCard{CacheHit: 9, Input: 9, Output: 9, Currency: "USD"}
if _, ok := billing.MatchesCatalog("deepseek", "deepseek-v4-flash", custom); ok {
t.Fatal("custom price must not match official catalog")
}
}
func TestQuoteForUsageUsesSelectedDisplay(t *testing.T) {
price := deepSeekV4FlashPriceUSD()
q := QuoteForUsage(price, nil, "USD", "m", "executor", billing.BillingModePAYG, "")
if q.Complete {
t.Fatal("nil usage should be incomplete")
}
u := &provider.Usage{PromptTokens: 1_000_000, CompletionTokens: 0}
q = QuoteForUsage(price, u, "USD", "m", "executor", billing.BillingModePAYG, "")
if q.Original.Currency != "USD" || q.Selected == nil {
t.Fatalf("quote = %+v", q)
}
}
+61
View File
@@ -0,0 +1,61 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestBillingSplitUpgradeV5ToV6FreezesProviderCurrency(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
body := `config_version = 5
[desktop]
currency = "CNY"
language = "zh"
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
price = { cache_hit = 0.0028, input = 0.14, output = 0.28, currency = "$" }
`
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
changed, err := ApplyUserConfigUpgradesOnStartup(path)
if err != nil {
t.Fatal(err)
}
if !changed {
t.Fatal("expected upgrade rewrite")
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
text := string(raw)
if !strings.Contains(text, "config_version = 6") {
t.Fatalf("missing v6:\n%s", text)
}
if !strings.Contains(text, "display_currency") && !strings.Contains(text, `currency = "CNY"`) {
t.Fatalf("display currency not migrated:\n%s", text)
}
cfg := LoadForEdit(path)
if got := cfg.DisplayCurrencyPref(); got != "CNY" {
t.Fatalf("display pref = %q", got)
}
flash, ok := cfg.Provider("deepseek-flash")
if !ok {
t.Fatal("missing flash")
}
// List price must stay USD official; display is CNY.
if flash.Price == nil || flash.Price.Currency != "$" {
t.Fatalf("list price rewritten: %+v", flash.Price)
}
if got := flash.ProviderBillingCurrency(); got != "USD" {
t.Fatalf("billing_currency = %q, want USD frozen from price", got)
}
}
+11 -2
View File
@@ -49,6 +49,7 @@ type Config struct {
UI UIConfig `toml:"ui"`
CLI CLIConfig `toml:"cli"`
Desktop DesktopConfig `toml:"desktop"`
Billing BillingConfig `toml:"billing"`
Telemetry TelemetryConfig `toml:"telemetry"`
Notifications NotificationsConfig `toml:"notifications"`
Agent AgentConfig `toml:"agent"`
@@ -279,7 +280,7 @@ type CLIConfig struct {
// language, terminal colours, or provider-visible prompt/request data.
type DesktopConfig struct {
Language string `toml:"language"` // auto|en|zh; empty/auto = browser/OS auto-detect
Currency string `toml:"currency"` // user-global auto|CNY|USD pricing preference shared by desktop and CLI
Currency string `toml:"currency"` // legacy display currency; migrated to [billing].display_currency
LayoutStyle string `toml:"layout_style"` // classic|workbench|creation; desktop layout style
Theme string `toml:"theme"` // auto|dark|light; empty resolves to auto
ThemeStyle string `toml:"theme_style"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases
@@ -1368,6 +1369,11 @@ type ProviderEntry struct {
MaxOutputTokens int `toml:"max_output_tokens"`
Price *provider.Pricing `toml:"price"` // legacy/provider-wide fallback
Prices map[string]*provider.Pricing `toml:"prices"` // optional per-model prices; keys are model ids
// BillingCurrency is the frozen list-price currency (ISO-4217). Independent
// of [billing].display_currency; switching display never rewrites this.
BillingCurrency string `toml:"billing_currency"`
// BillingMode is payg (default) or subscription_equivalent (e.g. MiMo Token Plan).
BillingMode string `toml:"billing_mode"`
persistedOfficialCurrency string
@@ -1821,11 +1827,12 @@ const LanguagePolicy = `Reply in the same language the user is using in their mo
// Default returns the built-in default configuration.
func Default() *Config {
return &Config{
ConfigVersion: 5,
ConfigVersion: 6,
DefaultModel: "deepseek-flash",
CredentialsStore: CredentialsStoreAuto,
UI: UIConfig{Theme: "auto", ShowTurnUsage: true},
Desktop: DesktopConfig{DefaultToolApprovalMode: "auto", ConversationWidth: "standard"},
Billing: BillingConfig{},
Notifications: NotificationsConfig{
Enabled: false,
TurnDone: true,
@@ -1888,6 +1895,7 @@ func Default() *Config {
BalanceURL: "https://api.deepseek.com/user/balance", Thinking: "enabled",
WebSearch: boolPointer(true), SupportedEfforts: []string{"disabled", "low", "high", "max"}, DefaultEffort: "high",
ContextWindow: 1_000_000, Price: deepSeekV4FlashPriceUSD(),
BillingCurrency: "USD", BillingMode: "payg",
},
{
Name: "deepseek-pro", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
@@ -1895,6 +1903,7 @@ func Default() *Config {
BalanceURL: "https://api.deepseek.com/user/balance", Thinking: "enabled",
WebSearch: boolPointer(true), SupportedEfforts: []string{"disabled", "high", "max"}, DefaultEffort: "high",
ContextWindow: 1_000_000, Price: deepSeekV4ProPriceUSD(),
BillingCurrency: "USD", BillingMode: "payg",
},
},
}
-21
View File
@@ -230,27 +230,6 @@ func (c *Config) SetDesktopLanguage(lang string) error {
return nil
}
// SetDesktopCurrency pins the user-global official pricing region independently
// from language. The name is retained for persisted-schema compatibility.
// Empty/auto follows the language preference.
func (c *Config) SetDesktopCurrency(currency string) error {
overridePersisted := false
switch strings.ToUpper(strings.TrimSpace(currency)) {
case "", "AUTO":
c.Desktop.Currency = ""
case "CNY", "RMB", "CNH":
c.Desktop.Currency = "CNY"
overridePersisted = true
case "USD":
c.Desktop.Currency = "USD"
overridePersisted = true
default:
return fmt.Errorf("desktop currency %q: must be auto|CNY|USD", currency)
}
applyDeepSeekOfficialDefaultPricingWithOverride(c, overridePersisted)
return nil
}
// SetDesktopAppearance sets desktop-only theme preferences. It must not affect
// CLI theme settings or provider-visible request data.
func (c *Config) SetDesktopAppearance(theme, style string) error {
+12 -5
View File
@@ -197,15 +197,22 @@ func TestSetDesktopTerminalThemeValidatesPreference(t *testing.T) {
func TestDesktopCurrencyNormalizesAndRefreshesOfficialPricing(t *testing.T) {
c := Default()
c.Desktop.Language = "zh"
flash, _ := c.Provider("deepseek-flash")
// Capture frozen list price before display switches.
wantOutput := flash.Price.Output
wantCurrency := flash.Price.Currency
if err := c.SetDesktopCurrency("usd"); err != nil {
t.Fatalf("SetDesktopCurrency USD: %v", err)
}
if got := c.DesktopCurrency(); got != "USD" {
t.Fatalf("desktop currency = %q, want USD", got)
}
flash, _ := c.Provider("deepseek-flash")
if flash.Price == nil || flash.Price.Output != 0.28 || flash.Price.Currency != "$" {
t.Fatalf("USD flash price = %+v", flash.Price)
if got := c.DisplayCurrencyPref(); got != "USD" {
t.Fatalf("display currency pref = %q, want USD", got)
}
// Display currency must not rewrite frozen provider list prices.
if flash.Price == nil || flash.Price.Output != wantOutput || flash.Price.Currency != wantCurrency {
t.Fatalf("list price mutated by display switch: %+v", flash.Price)
}
if err := c.SetDesktopCurrency("auto"); err != nil {
t.Fatalf("SetDesktopCurrency auto: %v", err)
@@ -213,8 +220,8 @@ func TestDesktopCurrencyNormalizesAndRefreshesOfficialPricing(t *testing.T) {
if got := c.DesktopCurrency(); got != "" {
t.Fatalf("auto desktop currency = %q, want empty", got)
}
if flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != "¥" {
t.Fatalf("auto Chinese flash price = %+v", flash.Price)
if flash.Price == nil || flash.Price.Output != wantOutput || flash.Price.Currency != wantCurrency {
t.Fatalf("list price mutated after auto: %+v", flash.Price)
}
if err := c.SetDesktopCurrency("EUR"); err == nil {
t.Fatal("SetDesktopCurrency accepted unsupported EUR")
+19 -5
View File
@@ -132,6 +132,7 @@ func loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
globalRemote := cfg.Remote.Clone()
globalDesktopLanguage := cfg.Desktop.Language
globalPricingCurrency := cfg.Desktop.Currency
globalBillingDisplayCurrency := cfg.Billing.DisplayCurrency
globalTelemetry := cfg.Telemetry
tomlSources = append(tomlSources, projectTOML)
@@ -164,6 +165,7 @@ func loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
// A repository must not be able to alter how the user's spend is shown.
cfg.Desktop.Language = globalDesktopLanguage
cfg.Desktop.Currency = globalPricingCurrency
cfg.Billing.DisplayCurrency = globalBillingDisplayCurrency
// CLI telemetry is an explicit user-global privacy choice. Project config
// cannot opt a user in or out, including when the global value is absent.
cfg.Telemetry = globalTelemetry
@@ -227,6 +229,8 @@ func loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
normalizeLegacyProviderModels(cfg)
normalizeDesktopOfficialProviderAccess(cfg)
normalizeOfficialDeepSeekModels(cfg)
migrateBillingDisplayCurrency(cfg)
freezeProviderBillingCurrencies(cfg)
applyDeepSeekOfficialDefaultPricing(cfg)
backfillDeepSeekOfficialPrices(cfg)
normalizeEffortConfig(cfg)
@@ -383,11 +387,16 @@ func backfillDeepSeekPro(c *Config) {
for _, bp := range Default().Providers {
if bp.Name == "deepseek-pro" {
bp.APIKeyEnv = flash.APIKeyEnv
currency := c.DeepSeekOfficialPricingCurrency()
if c.DesktopCurrency() == "" && flash.persistedOfficialCurrency != "" {
// Inherit the flash provider's frozen billing currency for list prices.
currency := flash.ProviderBillingCurrency()
if currency == "" {
currency = flash.persistedOfficialCurrency
bp.persistedOfficialCurrency = currency
}
if currency == "" {
currency = "USD"
}
bp.BillingCurrency = currency
bp.persistedOfficialCurrency = currency
bp.Price = deepSeekV4PriceForModel(currency, proModel)
c.Providers = append(c.Providers, bp)
return
@@ -405,10 +414,13 @@ func backfillDeepSeekOfficialPrices(c *Config) {
continue
}
backfillDeepSeekOfficialEndpointDefaults(p)
currency := c.DeepSeekOfficialPricingCurrency()
if c.DesktopCurrency() == "" && p.persistedOfficialCurrency != "" {
currency := p.ProviderBillingCurrency()
if currency == "" {
currency = p.persistedOfficialCurrency
}
if currency == "" {
currency = "USD"
}
defaults := DeepSeekV4PricesForCurrency(currency)
if p.Price != nil {
continue
@@ -800,6 +812,8 @@ func normalizeConfigForEdit(cfg *Config) bool {
normalizeLegacyProviderModels(cfg)
normalizeDesktopOfficialProviderAccess(cfg)
normalizeOfficialDeepSeekModels(cfg)
migrateBillingDisplayCurrency(cfg)
freezeProviderBillingCurrencies(cfg)
applyDeepSeekOfficialDefaultPricing(cfg)
backfillDeepSeekOfficialPrices(cfg)
normalizeEffortConfig(cfg)
+1 -1
View File
@@ -495,7 +495,7 @@ command = "legacy-bin"
t.Fatalf("read migrated config: %v", err)
}
text := string(got)
for _, want := range []string{`config_version = 5`, `[desktop]`, `close_behavior = "quit"`, `name = "legacy-v1"`} {
for _, want := range []string{`config_version = 6`, `[desktop]`, `close_behavior = "quit"`, `name = "legacy-v1"`} {
if !strings.Contains(text, want) {
t.Fatalf("migrated TOML missing %q:\n%s", want, text)
}
+29 -52
View File
@@ -64,51 +64,9 @@ func deepSeekV4PriceForModel(currency, model string) *provider.Pricing {
return clonePricing(DeepSeekV4PricesForCurrency(currency)[strings.TrimSpace(model)])
}
// DeepSeekOfficialPricingCurrency resolves the regional pricing table used for
// official DeepSeek defaults. An explicit desktop currency wins; auto follows
// desktop language, then CLI language, and finally defaults to USD.
func (c *Config) DeepSeekOfficialPricingCurrency() string {
if c != nil {
if currency := c.DesktopCurrency(); currency != "" {
return currency
}
if normalizeDeepSeekPricingLanguage(c.Desktop.Language) == "zh" {
return "CNY"
}
if normalizeDeepSeekPricingLanguage(c.Desktop.Language) == "en" {
return "USD"
}
if normalizeDeepSeekPricingLanguage(c.Language) == "zh" {
return "CNY"
}
}
return "USD"
}
// DesktopPricingFollowsDetectedLocale reports whether the desktop may supply
// its browser/OS locale as the official pricing region. Explicit currency or
// language preferences always win.
func (c *Config) DesktopPricingFollowsDetectedLocale() bool {
return c != nil && c.DesktopCurrency() == "" && c.DesktopLanguage() == "" && c.ResponseLanguage() == "auto"
}
// ApplyRuntimeAutoPricingCurrency applies a frontend-detected pricing region
// to this in-memory config only. Callers must not persist the resulting copy,
// because doing so would turn the user's Auto choice into an explicit region.
func (c *Config) ApplyRuntimeAutoPricingCurrency(currency string) {
if !c.DesktopPricingFollowsDetectedLocale() {
return
}
normalized := normalizeDeepSeekPricingCurrency(currency)
if normalized == "" {
return
}
c.Desktop.Currency = normalized
applyDeepSeekOfficialDefaultPricingWithOverride(c, false)
}
// DeepSeekOfficialPricingLanguage is retained for older settings/template call
// sites that still express the pricing region as a language.
// sites that still express the pricing region as a language. List-price region
// is frozen per provider (billing_currency), not the global display currency.
func (c *Config) DeepSeekOfficialPricingLanguage() string {
if c.DeepSeekOfficialPricingCurrency() == "CNY" {
return "zh"
@@ -139,34 +97,47 @@ func normalizeDeepSeekPricingLanguage(lang string) string {
}
// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek
// prices that still match known official defaults. Custom user prices are left
// untouched.
// prices that still match known official defaults for each provider's frozen
// billing_currency. Custom user prices and display-currency switches never
// rewrite list prices.
func (c *Config) ApplyDeepSeekOfficialDefaultPricing() {
applyDeepSeekOfficialDefaultPricing(c)
}
func applyDeepSeekOfficialDefaultPricing(c *Config) {
applyDeepSeekOfficialDefaultPricingWithOverride(c, c != nil && c.DesktopCurrency() != "")
applyDeepSeekOfficialDefaultPricingWithOverride(c, false)
}
func applyDeepSeekOfficialDefaultPricingWithOverride(c *Config, overridePersisted bool) {
if c == nil {
return
}
currency := c.DeepSeekOfficialPricingCurrency()
for i := range c.Providers {
p := &c.Providers[i]
if officialProviderKind(p) != "deepseek" {
continue
}
if isKnownDeepSeekOfficialPricing(p.Model, p.Price) && (overridePersisted || p.persistedOfficialCurrency == "") {
p.Price = deepSeekV4PriceForModel(currency, p.Model)
currency := p.ProviderBillingCurrency()
if currency == "" {
currency = "USD"
}
// Only refresh when the row still matches a known official default in
// the provider's own billing currency. Display currency must not win.
if isKnownDeepSeekOfficialPricing(p.Model, p.Price) && (overridePersisted || p.persistedOfficialCurrency == "" || p.persistedOfficialCurrency == currency) {
if samePricing(p.Price, deepSeekV4PriceForModel(currency, p.Model)) || overridePersisted {
p.Price = deepSeekV4PriceForModel(currency, p.Model)
}
}
for model, price := range p.Prices {
if isKnownDeepSeekOfficialPricing(model, price) && (overridePersisted || p.persistedOfficialCurrency == "") {
p.Prices[model] = deepSeekV4PriceForModel(currency, model)
if isKnownDeepSeekOfficialPricing(model, price) && (overridePersisted || p.persistedOfficialCurrency == "" || p.persistedOfficialCurrency == currency) {
if samePricing(price, deepSeekV4PriceForModel(currency, model)) || overridePersisted {
p.Prices[model] = deepSeekV4PriceForModel(currency, model)
}
}
}
if strings.TrimSpace(p.BillingCurrency) == "" {
p.BillingCurrency = currency
}
}
}
@@ -274,6 +245,7 @@ const (
deepSeekPricingResetConfigVersion = 3
windowsBashSandboxDefaultConfigVersion = 4
retiredAutoPlanConfigVersion = 5
billingSplitConfigVersion = 6
)
// ApplyUserConfigUpgradesOnStartup applies one-time startup migrations. It
@@ -323,6 +295,11 @@ func ApplyUserConfigUpgradesOnStartup(path string) (bool, error) {
// the manual-only default after a downgrade.
changed = true
}
if header.ConfigVersion < billingSplitConfigVersion {
migrateBillingDisplayCurrency(cfg)
freezeProviderBillingCurrencies(cfg)
changed = true
}
if !changed {
return false, nil
}
+24 -3
View File
@@ -7,6 +7,7 @@ import (
"strconv"
"strings"
"reasonix/internal/billing"
"reasonix/internal/provider"
)
@@ -98,10 +99,10 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
} else {
b.WriteString("# language = \"zh\" # desktop UI language; empty/auto = browser/OS auto-detect\n")
}
// Legacy desktop.currency is still emitted when set so older binaries keep
// reading the preference; new writers also own [billing].display_currency.
if currency := c.DesktopCurrency(); currency != "" {
fmt.Fprintf(&b, "currency = %q # official pricing currency: CNY|USD; empty/auto follows language\n", currency)
} else {
b.WriteString("# currency = \"USD\" # official pricing currency: CNY|USD; empty/auto follows language\n")
fmt.Fprintf(&b, "currency = %q # legacy display currency; prefer [billing].display_currency\n", currency)
}
fmt.Fprintf(&b, "layout_style = %q # desktop layout: classic|workbench|creation\n", c.DesktopLayoutStyle())
fmt.Fprintf(&b, "theme = %q # desktop only: auto|dark|light\n", c.DesktopTheme())
@@ -135,6 +136,14 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
fmt.Fprintf(&b, "conversation_width = %q # desktop: standard|full transcript width; empty = standard\n", width)
}
b.WriteString("\n")
b.WriteString("[billing]\n")
if pref := c.DisplayCurrencyPref(); pref != "" {
fmt.Fprintf(&b, "display_currency = %q # auto|CNY|USD; display only — does not rewrite provider list prices\n", pref)
} else {
b.WriteString("# display_currency = \"auto\" # auto|CNY|USD; display only — does not rewrite provider list prices\n")
}
b.WriteString("\n")
} else if c.Desktop.ProviderAccess != nil {
// provider_access is intentionally mergeable across user and project
// configs. It is the only desktop field written to reasonix.toml: local
@@ -352,6 +361,12 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
if len(p.Prices) > 0 {
fmt.Fprintf(&b, "prices = %s # per-model prices, per 1M tokens\n", renderPricingMap(p.Prices))
}
if cur := strings.TrimSpace(p.BillingCurrency); cur != "" {
fmt.Fprintf(&b, "billing_currency = %q # frozen list-price currency; independent of display_currency\n", billing.NormalizeCurrency(cur))
}
if mode := strings.TrimSpace(p.BillingMode); mode != "" && mode != "payg" {
fmt.Fprintf(&b, "billing_mode = %q # payg|subscription_equivalent\n", mode)
}
if p.Thinking != "" {
fmt.Fprintf(&b, "thinking = %q\n", p.Thinking)
}
@@ -1036,6 +1051,12 @@ func RenderTOMLProjectDelta(c *Config) string {
if len(p.Prices) > 0 {
fmt.Fprintf(&b, "prices = %s\n", renderPricingMap(p.Prices))
}
if cur := strings.TrimSpace(p.BillingCurrency); cur != "" {
fmt.Fprintf(&b, "billing_currency = %q\n", billing.NormalizeCurrency(cur))
}
if mode := strings.TrimSpace(p.BillingMode); mode != "" && mode != "payg" {
fmt.Fprintf(&b, "billing_mode = %q\n", mode)
}
if p.Thinking != "" {
fmt.Fprintf(&b, "thinking = %q\n", p.Thinking)
}
+3 -3
View File
@@ -325,8 +325,8 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
if got.DefaultModel != "mimo-pro" {
t.Errorf("default_model = %q, want mimo-pro", got.DefaultModel)
}
if got.ConfigVersion != 5 {
t.Errorf("config_version = %d, want 5", got.ConfigVersion)
if got.ConfigVersion != Default().ConfigVersion {
t.Errorf("config_version = %d, want %d", got.ConfigVersion, Default().ConfigVersion)
}
if got.Language != "zh" {
t.Errorf("language = %q, want zh", got.Language)
@@ -790,7 +790,7 @@ func TestScopedRenderSeparatesUserAndProjectConfig(t *testing.T) {
c.Agent.RecoveryTemperature = 0.2
user := RenderTOMLForScope(c, RenderScopeUser)
for _, want := range []string{"config_version = 5", "[desktop]", `currency = "CNY"`, `theme = "dark"`, `terminal_theme = "auto"`, `close_behavior = "background"`, `status_bar_style = "text"`, `default_tool_approval_mode = "auto"`, `check_updates = false`, `recovery_model = "deepseek-pro"`, "[notifications]", "[tools.shell]"} {
for _, want := range []string{"config_version = 6", "[desktop]", `currency = "CNY"`, "[billing]", `display_currency = "CNY"`, `theme = "dark"`, `terminal_theme = "auto"`, `close_behavior = "background"`, `status_bar_style = "text"`, `default_tool_approval_mode = "auto"`, `check_updates = false`, `recovery_model = "deepseek-pro"`, "[notifications]", "[tools.shell]"} {
if !strings.Contains(user, want) {
t.Fatalf("user render missing %q:\n%s", want, user)
}
+158
View File
@@ -0,0 +1,158 @@
package doctor
import (
"fmt"
"strings"
"time"
"reasonix/internal/billing"
"reasonix/internal/config"
)
// BillingReport is the structured output of `reasonix doctor billing`.
type BillingReport struct {
DisplayCurrencyPref string `json:"display_currency_pref"`
DisplayCurrency string `json:"display_currency"`
FX FXReport `json:"fx"`
Providers []ProviderBillingInfo `json:"providers"`
CatalogNotes []string `json:"catalog_notes,omitempty"`
}
// FXReport is retained for JSON compatibility. Runtime FX is intentionally
// disabled; old readers may continue to expect the object.
type FXReport struct {
Enabled bool `json:"enabled"`
Source string `json:"source"`
CachePath string `json:"cache_path,omitempty"`
FetchedAt time.Time `json:"fetched_at,omitempty"`
Stale bool `json:"stale"`
HasTable bool `json:"has_table"`
Observation string `json:"latest_observation,omitempty"`
}
// ProviderBillingInfo describes one provider's frozen list-price currency.
type ProviderBillingInfo struct {
Name string `json:"name"`
Model string `json:"model,omitempty"`
BillingCurrency string `json:"billing_currency,omitempty"`
BillingMode string `json:"billing_mode,omitempty"`
PriceCurrency string `json:"price_currency,omitempty"`
PriceInput float64 `json:"price_input,omitempty"`
PriceOutput float64 `json:"price_output,omitempty"`
PriceCacheHit float64 `json:"price_cache_hit,omitempty"`
CatalogMatch bool `json:"catalog_match"`
CatalogSource string `json:"catalog_source,omitempty"`
Fingerprint string `json:"pricing_fingerprint,omitempty"`
CustomPrice bool `json:"custom_price"`
}
// CollectBilling builds a billing diagnostics report.
func CollectBilling(cfg *config.Config) BillingReport {
if cfg == nil {
cfg = config.Default()
}
rep := BillingReport{
DisplayCurrencyPref: prefLabel(cfg.DisplayCurrencyPref()),
DisplayCurrency: cfg.ExplicitDisplayCurrency(),
FX: FXReport{
Enabled: false,
Source: "disabled",
},
}
for i := range cfg.Providers {
p := &cfg.Providers[i]
info := ProviderBillingInfo{
Name: p.Name,
Model: p.Model,
BillingCurrency: p.ProviderBillingCurrency(),
BillingMode: p.ProviderBillingMode(),
}
price := p.PriceForModel(p.Model)
if price != nil {
info.PriceCurrency = billing.NormalizeCurrency(price.Currency)
info.PriceInput = price.Input
info.PriceOutput = price.Output
info.PriceCacheHit = price.CacheHit
card := billing.RateCard{
CacheHit: price.CacheHit, Input: price.Input, Output: price.Output,
Currency: info.PriceCurrency,
}
info.Fingerprint = billing.PricingFingerprint(card)
providerKind := officialKindForBilling(p)
if entry, ok := billing.MatchesCatalog(providerKind, p.Model, card); ok {
info.CatalogMatch = true
info.CatalogSource = entry.DocURL
} else if price != nil {
info.CustomPrice = !info.CatalogMatch
}
}
rep.Providers = append(rep.Providers, info)
}
rep.CatalogNotes = []string{
"User-custom prices always win over the official catalog.",
"MiMo Token Plan costs are pay-as-you-go equivalents, not plan invoices.",
"Runtime FX is disabled; only identity and official regional rate-card estimates are used.",
"Switching display_currency never rewrites provider billing_currency or list prices.",
}
return rep
}
func prefLabel(pref string) string {
if pref == "" {
return "auto"
}
return pref
}
func officialKindForBilling(p *config.ProviderEntry) string {
if p == nil {
return ""
}
name := strings.ToLower(p.Name)
base := strings.ToLower(p.BaseURL)
switch {
case strings.Contains(base, "deepseek") || strings.Contains(name, "deepseek"):
return "deepseek"
case strings.Contains(base, "longcat") || strings.Contains(name, "longcat"):
return "longcat"
case strings.Contains(base, "mimo") || strings.Contains(name, "mimo"):
return "mimo"
default:
return name
}
}
// RenderBillingText formats a human-readable billing doctor report.
func RenderBillingText(r BillingReport) string {
var b strings.Builder
fmt.Fprintf(&b, "reasonix doctor billing\n")
fmt.Fprintf(&b, " display preference: %s\n", r.DisplayCurrencyPref)
fmt.Fprintf(&b, " display resolved: %s\n", r.DisplayCurrency)
fmt.Fprintf(&b, " fx source: %s\n", r.FX.Source)
fmt.Fprintf(&b, " fx enabled: %v\n", r.FX.Enabled)
fmt.Fprintf(&b, " auto strategy: wallet currency, then pricing basis\n")
b.WriteString("\n providers:\n")
for _, p := range r.Providers {
fmt.Fprintf(&b, " - %s model=%s billing=%s mode=%s", p.Name, p.Model, p.BillingCurrency, p.BillingMode)
if p.PriceCurrency != "" {
fmt.Fprintf(&b, " price=%s/%.4g/%.4g", p.PriceCurrency, p.PriceInput, p.PriceOutput)
}
if p.CatalogMatch {
fmt.Fprintf(&b, " [official: %s]", p.CatalogSource)
} else if p.CustomPrice {
b.WriteString(" [custom price protected]")
}
b.WriteByte('\n')
if p.Fingerprint != "" {
fmt.Fprintf(&b, " fingerprint=%s\n", p.Fingerprint)
}
}
if len(r.CatalogNotes) > 0 {
b.WriteString("\n notes:\n")
for _, n := range r.CatalogNotes {
fmt.Fprintf(&b, " - %s\n", n)
}
}
return b.String()
}
+26
View File
@@ -0,0 +1,26 @@
package doctor
import (
"strings"
"testing"
"reasonix/internal/config"
)
func TestCollectBillingIncludesProvidersAndDisplay(t *testing.T) {
cfg := config.Default()
cfg.Billing.DisplayCurrency = "CNY"
rep := CollectBilling(cfg)
if rep.DisplayCurrency != "CNY" {
t.Fatalf("display = %q", rep.DisplayCurrency)
}
if len(rep.Providers) == 0 {
t.Fatal("expected providers")
}
text := RenderBillingText(rep)
for _, want := range []string{"display preference", "display resolved", "providers:", "fx enabled: false", "Runtime FX is disabled"} {
if !strings.Contains(text, want) {
t.Fatalf("render missing %q:\n%s", want, text)
}
}
}
+187
View File
@@ -0,0 +1,187 @@
package event
import (
"strings"
"sync"
"time"
"reasonix/internal/billing"
"reasonix/internal/provider"
)
// QuoteContext supplies an explicit display request for the CostQuote
// middleware. Empty means auto and keeps the price-book currency.
type QuoteContext struct {
mu sync.RWMutex
DisplayCurrency string
DisplayRequest billing.DisplayRequest
// Now overrides the clock in tests.
Now func() time.Time
// BillingModeForModel resolves provider-owned billing semantics from the
// immutable boot config. It keeps billing_mode authoritative even when a
// custom provider name does not contain a token-plan heuristic.
BillingModeForModel func(modelRef string) string
}
// SetDisplay updates the resolved display currency used for Selected.
func (c *QuoteContext) SetDisplay(currency string) {
if c == nil {
return
}
c.mu.Lock()
c.DisplayCurrency = billing.NormalizeCurrency(currency)
c.DisplayRequest = billing.DisplayRequest{Currency: c.DisplayCurrency, Source: billing.DisplaySourceExplicit}
c.mu.Unlock()
}
func (c *QuoteContext) SetDisplayRequest(request billing.DisplayRequest) {
if c == nil {
return
}
request.Currency = billing.NormalizeCurrency(request.Currency)
if request.Source == "" {
request.Source = billing.DisplaySourceAuto
}
c.mu.Lock()
c.DisplayRequest = request
c.DisplayCurrency = request.Currency
c.mu.Unlock()
}
func (c *QuoteContext) snapshot() (request billing.DisplayRequest, now time.Time) {
if c == nil {
return billing.DisplayRequest{Source: billing.DisplaySourceAuto}, time.Now().UTC()
}
c.mu.RLock()
request = c.DisplayRequest
if request.Currency == "" && c.DisplayCurrency != "" {
request.Currency = c.DisplayCurrency
}
if request.Source == "" {
if request.Currency != "" {
request.Source = billing.DisplaySourceExplicit
} else {
request.Source = billing.DisplaySourceAuto
}
}
nowFn := c.Now
c.mu.RUnlock()
if nowFn != nil {
now = nowFn()
} else {
now = time.Now().UTC()
}
return request, now
}
func (c *QuoteContext) billingMode(modelRef string) string {
if c == nil {
return ""
}
c.mu.RLock()
resolve := c.BillingModeForModel
c.mu.RUnlock()
if resolve == nil {
return ""
}
return strings.TrimSpace(resolve(modelRef))
}
// CostQuoteSink fills CostQuote on Usage events before forwarding. Frontends
// must consume e.CostQuote and must not call Pricing.Cost for aggregation.
type CostQuoteSink struct {
Inner Sink
Ctx *QuoteContext
}
// NewCostQuoteSink wraps inner with quoting. A nil ctx still quotes the
// original price-book currency.
func NewCostQuoteSink(inner Sink, ctx *QuoteContext) *CostQuoteSink {
if ctx == nil {
ctx = &QuoteContext{}
}
return &CostQuoteSink{Inner: inner, Ctx: ctx}
}
func (s *CostQuoteSink) Emit(e Event) {
if s == nil {
return
}
if e.Kind == Usage && e.Usage != nil && e.CostQuote == nil {
e.CostQuote = EnsureCostQuote(e, s.Ctx)
}
if s.Inner != nil {
s.Inner.Emit(e)
}
}
// EnsureCostQuote builds a CostQuote for an event when missing.
func EnsureCostQuote(e Event, ctx *QuoteContext) *billing.CostQuote {
if e.Usage == nil {
return nil
}
display, now := ctx.snapshot()
if e.Pricing == nil {
q := billing.CostQuote{
Estimated: true, CostComplete: false, DisplayComplete: false, Complete: false,
DisplayStatus: billing.DisplayStatusUnavailable, IncompleteReason: "no_price",
ModelRef: e.ModelRef, UsageSource: e.UsageSource,
}
return &q
}
mode := billing.BillingModePAYG
if configured := ctx.billingMode(e.ModelRef); configured != "" {
mode = configured
} else if strings.Contains(strings.ToLower(e.ModelRef), "token-plan") ||
strings.Contains(strings.ToLower(e.Source), "token-plan") {
mode = billing.BillingModeSubscriptionEquivalent
}
card := rateCardFromPricing(e.Pricing)
q := billing.BuildQuote(billing.QuoteInput{
Usage: usageTokens(e.Usage),
Rates: card,
OccurredAt: now,
Display: display,
BillingMode: mode,
ModelRef: e.ModelRef,
UsageSource: firstUsageSource(e),
})
return &q
}
func firstUsageSource(e Event) string {
if s := strings.TrimSpace(e.UsageSource); s != "" {
return s
}
if s := strings.TrimSpace(e.Source); s != "" {
return s
}
return UsageSourceExecutor
}
func rateCardFromPricing(p *provider.Pricing) billing.RateCard {
if p == nil {
return billing.RateCard{}
}
return billing.RateCard{
CacheHit: p.CacheHit,
Input: p.Input,
Output: p.Output,
Currency: billing.NormalizeCurrency(p.Currency),
}
}
func usageTokens(u *provider.Usage) billing.UsageTokens {
if u == nil {
return billing.UsageTokens{}
}
return billing.UsageTokens{
PromptTokens: u.PromptTokens,
CompletionTokens: u.CompletionTokens,
CacheHitTokens: u.CacheHitTokens,
CacheMissTokens: u.CacheMissTokens,
CacheWriteTokens: u.CacheWriteTokens,
CacheWriteBilledTokens: u.CacheWriteBilledTokens,
Estimated: u.Estimated,
}
}
+23
View File
@@ -0,0 +1,23 @@
package event
import (
"testing"
"reasonix/internal/provider"
)
func TestEnsureCostQuoteDoesNotUseRuntimeFX(t *testing.T) {
e := Event{Kind: Usage, ModelRef: "deepseek/deepseek-v4-flash", UsageSource: UsageSourceExecutor,
Pricing: &provider.Pricing{Input: 1, Output: 2, Currency: "CNY"},
Usage: &provider.Usage{PromptTokens: 100, CompletionTokens: 100}}
ctx := &QuoteContext{DisplayCurrency: "USD"}
q := EnsureCostQuote(e, ctx)
if q == nil || q.Selected == nil || q.Selected.Currency != "CNY" || q.Complete {
t.Fatalf("quote = %+v", q)
}
for code, valuation := range q.Valuations {
if valuation.Basis == "fx" {
t.Fatalf("runtime FX valuation %s = %+v", code, valuation)
}
}
}
+3 -1
View File
@@ -14,6 +14,7 @@ package event
import (
"encoding/json"
"reasonix/internal/billing"
"reasonix/internal/evidence"
"reasonix/internal/nilutil"
"reasonix/internal/provider"
@@ -533,7 +534,8 @@ type Event struct {
MemoryCitations []provider.MemoryCitation // Message: local memory references displayed by rich frontends
Tool Tool // ToolDispatch / ToolResult
Usage *provider.Usage // Usage
Pricing *provider.Pricing // Usage: for cost display (nil = omit cost)
Pricing *provider.Pricing // Usage: rate card for quote middleware (nil = omit cost)
CostQuote *billing.CostQuote // Usage: host-side quote; sinks must not reprice
Source string // optional display/event source (executor, planner, subagent, ...)
UsageSource string // Usage: billable call source; empty means executor for compatibility
CacheDiagnostics *CacheDiagnostics // Usage: cache-churn attribution (nil = N/A)
@@ -0,0 +1,58 @@
package eventwire
import (
"encoding/json"
"testing"
"reasonix/internal/billing"
"reasonix/internal/event"
"reasonix/internal/provider"
)
// Ensures older clients still see cost/currency aliases while new clients get costQuote.
func TestToWireUsageDualWritesCostQuoteAndLegacyAliases(t *testing.T) {
e := event.Event{
Kind: event.Usage,
ModelRef: "deepseek-flash/deepseek-v4-flash",
Usage: &provider.Usage{PromptTokens: 1_000_000, CompletionTokens: 1_000_000, TotalTokens: 2_000_000},
Pricing: &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: "¥"},
CostQuote: func() *billing.CostQuote {
q := billing.BuildQuote(billing.QuoteInput{
Usage: billing.UsageTokens{PromptTokens: 1_000_000, CompletionTokens: 1_000_000},
Rates: billing.RateCard{CacheHit: 0.02, Input: 1, Output: 2, Currency: "CNY"},
DisplayCurrency: "USD",
ProviderKind: "deepseek",
ModelID: "deepseek-v4-flash",
})
return &q
}(),
}
w := ToWire(e)
if w.Usage == nil || w.Usage.CostQuote == nil {
t.Fatal("missing costQuote")
}
if w.Usage.CostQuote.Valuations["USD"].Basis != billing.BasisOfficialTable {
t.Fatalf("USD basis = %q, want official_table", w.Usage.CostQuote.Valuations["USD"].Basis)
}
if w.Usage.Cost <= 0 || w.Usage.CostUSD != w.Usage.Cost {
t.Fatalf("legacy cost aliases = cost:%v costUsd:%v", w.Usage.Cost, w.Usage.CostUSD)
}
raw, err := json.Marshal(w.Usage)
if err != nil {
t.Fatal(err)
}
if !json.Valid(raw) {
t.Fatal("usage json invalid")
}
// Old clients ignore unknown fields; new field present.
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if _, ok := m["costQuote"]; !ok {
t.Fatalf("costQuote missing from JSON: %s", raw)
}
if _, ok := m["cost"]; !ok {
t.Fatalf("legacy cost missing: %s", raw)
}
}
+64 -29
View File
@@ -4,6 +4,7 @@ package eventwire
import (
"encoding/json"
"reasonix/internal/billing"
"reasonix/internal/event"
"reasonix/internal/provider"
)
@@ -124,30 +125,7 @@ func ToWire(e event.Event) Event {
Changes: changes, AllPaths: ws.AllPaths, Source: ws.Source, WatchState: string(ws.WatchState),
}
case event.Usage:
if u := e.Usage; u != nil {
w.Usage = &Usage{
PromptTokens: u.PromptTokens, CompletionTokens: u.CompletionTokens,
TotalTokens: u.TotalTokens, CacheHitTokens: u.CacheHitTokens,
CacheMissTokens: u.CacheMissTokens, ReasoningTokens: u.ReasoningTokens,
Estimated: u.Estimated,
Source: e.UsageSource,
ContextPromptTokens: u.ContextPromptTokens,
ContextCompletionTokens: u.ContextCompletionTokens,
ContextReasoningTokens: u.ContextReasoningTokens,
ContextCacheHitTokens: u.ContextCacheHitTokens,
ContextCacheMissTokens: u.ContextCacheMissTokens,
SessionCacheHitTokens: e.SessionHit, SessionCacheMissTokens: e.SessionMiss,
}
if e.CacheDiagnostics != nil {
w.Usage.CacheDiagnostics = ToWireCacheDiagnostics(e.CacheDiagnostics)
}
if e.Pricing != nil {
cost := e.Pricing.Cost(u)
w.Usage.Cost = cost
w.Usage.Currency = e.Pricing.Symbol()
w.Usage.CostUSD = cost
}
}
w.Usage = toWireUsage(e)
case event.ApprovalRequest:
w.Approval = &Approval{
ID: e.Approval.ID, Tool: e.Approval.Tool, Subject: e.Approval.Subject,
@@ -221,6 +199,48 @@ func ToWire(e event.Event) Event {
return w
}
func toWireUsage(e event.Event) *Usage {
u := e.Usage
if u == nil {
return nil
}
wire := &Usage{
PromptTokens: u.PromptTokens, CompletionTokens: u.CompletionTokens,
TotalTokens: u.TotalTokens, CacheHitTokens: u.CacheHitTokens,
CacheMissTokens: u.CacheMissTokens, ReasoningTokens: u.ReasoningTokens,
Estimated: u.Estimated,
Source: e.UsageSource,
ContextPromptTokens: u.ContextPromptTokens,
ContextCompletionTokens: u.ContextCompletionTokens,
ContextReasoningTokens: u.ContextReasoningTokens,
ContextCacheHitTokens: u.ContextCacheHitTokens,
ContextCacheMissTokens: u.ContextCacheMissTokens,
SessionCacheHitTokens: e.SessionHit, SessionCacheMissTokens: e.SessionMiss,
}
if e.CacheDiagnostics != nil {
wire.CacheDiagnostics = ToWireCacheDiagnostics(e.CacheDiagnostics)
}
quote := e.CostQuote
if quote == nil && e.Pricing != nil {
quote = event.EnsureCostQuote(e, nil)
}
if quote != nil {
wire.CostQuote = quote
wire.CostComplete = quote.CostComplete
wire.DisplayComplete = quote.DisplayComplete
wire.DisplayStatus = quote.DisplayStatus
wire.AggregateMode = quote.AggregateMode
wire.OriginalTotals = append([]billing.Money(nil), quote.OriginalTotals...)
if quote.Selected != nil {
wire.Cost = quote.Selected.Float64()
wire.Currency = quote.LegacyCurrencySymbol()
wire.CostUSD = wire.Cost
wire.CurrencyCode = quote.LegacyCurrencyCode()
}
}
return wire
}
// DecisionReceipt is the JSON form of a provider-excluded user decision.
type DecisionReceipt struct {
ID string `json:"id"`
@@ -389,8 +409,19 @@ type Usage struct {
ContextCacheMissTokens int `json:"contextCacheMissTokens,omitempty"`
Cost float64 `json:"cost,omitempty"`
Currency string `json:"currency,omitempty"`
// CostUSD is a compatibility alias for older consumers; it mirrors Cost.
// CurrencyCode is the ISO code for Cost (preferred over symbol Currency).
CurrencyCode string `json:"currencyCode,omitempty"`
// CostUSD is a compatibility alias for older consumers; it mirrors Cost
// (selected display valuation) and does not imply USD.
CostUSD float64 `json:"costUsd,omitempty"`
// CostQuote is the structured host-side quote. New consumers must prefer it
// over cost/currency aliases. Never sent to model providers.
CostQuote *billing.CostQuote `json:"costQuote,omitempty"`
CostComplete bool `json:"costComplete,omitempty"`
DisplayComplete bool `json:"displayComplete,omitempty"`
DisplayStatus string `json:"displayStatus,omitempty"`
AggregateMode string `json:"aggregateMode,omitempty"`
OriginalTotals []billing.Money `json:"originalTotals,omitempty"`
}
// CacheDiagnostics is the JSON form of cache prefix diagnostics.
@@ -467,10 +498,14 @@ func ToWireGuardian(g event.GuardianResult) *Guardian {
Estimated: u.Estimated,
}
if g.Pricing != nil {
cost := g.Pricing.Cost(u)
out.Usage.Cost = cost
out.Usage.Currency = g.Pricing.Symbol()
out.Usage.CostUSD = cost
q := event.EnsureCostQuote(event.Event{Kind: event.Usage, Usage: u, Pricing: g.Pricing}, nil)
if q != nil {
out.Usage.CostQuote = q
out.Usage.Cost = q.LegacyCostFloat()
out.Usage.Currency = q.LegacyCurrencySymbol()
out.Usage.CostUSD = out.Usage.Cost
out.Usage.CurrencyCode = q.LegacyCurrencyCode()
}
}
}
return out
+3 -3
View File
@@ -149,9 +149,9 @@ var English = Messages{
LanguageHeader: "languages:",
LanguageHint: "switch with /language <auto|en|zh>",
LanguageChangedFmt: "language set to %s (resolved: %s)",
CurrencyHeader: "pricing currency:",
CurrencyHeader: "fee display currency:",
CurrencyHint: "switch with /currency <auto|CNY|USD>",
CurrencyChangedFmt: "pricing currency set to %s (resolved: %s)",
CurrencyChangedFmt: "fee display currency set to %s (resolved: %s)",
RuntimeRefreshBusy: "finish or cancel active work and stop background jobs before changing this setting",
RuntimeRefreshUnavailable: "runtime refresh is unavailable in this session",
@@ -265,7 +265,7 @@ var English = Messages{
CmdOutputStyle: "list output styles",
CmdTheme: "switch CLI theme",
CmdLanguage: "switch CLI language",
CmdCurrency: "switch pricing currency",
CmdCurrency: "switch fee display currency",
CmdSkill: "manage skills",
CmdVerbose: "toggle thinking text",
CmdReloadCmd: "reload custom commands",
+3 -3
View File
@@ -150,9 +150,9 @@ var Chinese = Messages{
LanguageHeader: "语言:",
LanguageHint: "使用 /language <auto|en|zh> 切换",
LanguageChangedFmt: "语言已设为 %s(当前解析为:%s)",
CurrencyHeader: "计价货币:",
CurrencyHeader: "费用展示币种:",
CurrencyHint: "使用 /currency <auto|CNY|USD> 切换",
CurrencyChangedFmt: "计价货币已设为 %s(当前解析为:%s)",
CurrencyChangedFmt: "费用展示币种已设为 %s(当前解析为:%s)",
RuntimeRefreshBusy: "请先完成或取消当前工作,并停止后台任务后再修改此设置",
RuntimeRefreshUnavailable: "本会话不支持刷新运行时",
@@ -266,7 +266,7 @@ var Chinese = Messages{
CmdOutputStyle: "列出输出风格",
CmdTheme: "切换 CLI 主题",
CmdLanguage: "切换 CLI 语言",
CmdCurrency: "切换计价货币",
CmdCurrency: "切换费用展示币种",
CmdSkill: "管理 skills",
CmdVerbose: "切换 thinking 原文显示",
CmdReloadCmd: "重载自定义命令",
+3 -3
View File
@@ -144,9 +144,9 @@ var ChineseTraditional = Messages{
LanguageHeader: "語言:",
LanguageHint: "使用 /language <auto|en|zh|zh-TW> 切換",
LanguageChangedFmt: "語言已設為 %s(目前解析為:%s)",
CurrencyHeader: "計價貨幣:",
CurrencyHeader: "費用展示幣種:",
CurrencyHint: "使用 /currency <auto|CNY|USD> 切換",
CurrencyChangedFmt: "計價貨幣已設為 %s(目前解析為:%s)",
CurrencyChangedFmt: "費用展示幣種已設為 %s(目前解析為:%s)",
RuntimeRefreshBusy: "請先完成或取消目前工作,並停止背景任務後再修改此設定",
RuntimeRefreshUnavailable: "本會話不支援重新整理執行階段",
@@ -253,7 +253,7 @@ var ChineseTraditional = Messages{
CmdOutputStyle: "列出輸出風格",
CmdTheme: "切換 CLI 主題",
CmdLanguage: "切換 CLI 語言",
CmdCurrency: "切換計價貨幣",
CmdCurrency: "切換費用展示幣種",
CmdSkill: "管理 skills",
CmdVerbose: "切換 thinking 原文顯示",
CmdReloadCmd: "重載自定義命令",
+4 -1
View File
@@ -780,11 +780,14 @@ type Pricing struct {
Currency string `toml:"currency"`
}
// Cost estimates the spend for a usage record.
// Cost estimates the spend for a usage record. Compatibility adapter only —
// new host code must consume billing.CostQuote instead of aggregating floats.
func (p *Pricing) Cost(u *Usage) float64 {
if p == nil || u == nil {
return 0
}
// Keep the historical float path byte-stable for tests that assert exact
// float results without going through the fixed-point quote layer.
hit := u.CacheHitTokens
miss := u.CacheMissTokens
if hit+miss == 0 && u.PromptTokens > 0 {
+53 -3
View File
@@ -3,7 +3,9 @@ package serve
import (
"encoding/json"
"sync"
"time"
"reasonix/internal/billing"
"reasonix/internal/event"
"reasonix/internal/eventwire"
)
@@ -14,13 +16,50 @@ import (
// agent goroutine — a browser that can't keep up loses intermediate frames, not
// the whole session (it can refetch /history).
type Broadcaster struct {
mu sync.Mutex
subs map[chan []byte]struct{}
mu sync.Mutex
subs map[chan []byte]struct{}
ledger *billing.Ledger
displayCurrency string
}
// NewBroadcaster returns an empty Broadcaster ready to accept subscribers.
func NewBroadcaster() *Broadcaster {
return &Broadcaster{subs: map[chan []byte]struct{}{}}
return &Broadcaster{subs: map[chan []byte]struct{}{}, ledger: billing.NewLedger()}
}
// SetDisplayCurrency rebinds the session ledger to a stored valuation. Empty
// keeps automatic mode: a single original currency is selected and mixed
// currencies remain buckets.
func (b *Broadcaster) SetDisplayCurrency(currency string) {
if b == nil {
return
}
b.mu.Lock()
b.displayCurrency = billing.NormalizeCurrency(currency)
b.mu.Unlock()
}
// ResetSession clears the usage ledger for /new, /resume and /fork.
func (b *Broadcaster) ResetSession() {
if b == nil {
return
}
b.mu.Lock()
b.ledger = billing.NewLedger()
b.mu.Unlock()
}
// SessionCostQuote returns the current aggregate quote without repricing.
func (b *Broadcaster) SessionCostQuote() billing.CostQuote {
if b == nil {
return billing.AggregateQuotes(nil, "")
}
b.mu.Lock()
defer b.mu.Unlock()
if b.ledger == nil {
b.ledger = billing.NewLedger()
}
return b.ledger.Total(b.displayCurrency)
}
// Emit marshals the event to JSON and delivers it to every subscriber. Drops to
@@ -33,6 +72,17 @@ func (b *Broadcaster) Emit(e event.Event) {
}
b.mu.Lock()
defer b.mu.Unlock()
if e.Kind == event.Usage && e.Usage != nil && e.CostQuote != nil {
if b.ledger == nil {
b.ledger = billing.NewLedger()
}
b.ledger.Add(*e.CostQuote, billing.UsageTokens{
PromptTokens: e.Usage.PromptTokens, CompletionTokens: e.Usage.CompletionTokens,
CacheHitTokens: e.Usage.CacheHitTokens, CacheMissTokens: e.Usage.CacheMissTokens,
CacheWriteTokens: e.Usage.CacheWriteTokens, CacheWriteBilledTokens: e.Usage.CacheWriteBilledTokens,
Estimated: e.Usage.Estimated,
}, time.Now().UTC())
}
for ch := range b.subs {
select {
case ch <- data:
+27 -12
View File
@@ -616,6 +616,7 @@ const __T = {
'loading': 'Loading...',
'cache': 'Cache',
'cost': 'Cost',
'multi_currency': 'Multiple currencies',
'balance': 'Balance',
'ready': 'Ready',
'thinking': 'Thinking...',
@@ -761,6 +762,7 @@ const __T = {
'loading': '加载中...',
'cache': '缓存',
'cost': '费用',
'multi_currency': '多币种',
'balance': '余额',
'ready': '就绪',
'thinking': '思考中...',
@@ -1025,7 +1027,17 @@ function isTextEntry(t) { return !!t&&(t.tagName==='INPUT'||t.tagName==='TEXTARE
function isPlainKey(e) { return !e.ctrlKey&&!e.metaKey&&!e.altKey&&!isTextEntry(e.target); }
function fmtTok(n) { return n>=1000?(n/1000).toFixed(1).replace(/\.0$/,'')+'k':String(n);}
function currencySymbol(c){const v=String(c||'¥').trim();if(/^(cny|rmb|yuan)$/i.test(v))return '¥';if(/^(usd|dollar)$/i.test(v))return '$';return v||'¥';}
function fmtMoney(n,c){if(typeof n!=='number'||!isFinite(n))return '—';const s=currencySymbol(c);return s+(n<1?n.toFixed(4):n.toFixed(2));}
function fmtMoney(n,c){if(typeof n!=='number'||!isFinite(n)||n<=0)return '—';const s=currencySymbol(c);return '≈'+s+(n<1?n.toFixed(4):n.toFixed(2));}
function usageSelectedCost(u){
if(!u)return null;
if(u.costQuote&&(u.costQuote.displayStatus==='bucketed'||u.costQuote.aggregateMode==='currency_buckets'))return{bucketed:true,complete:false};
const sel=u.costQuote&&u.costQuote.selected;
if(sel&&sel.amount){const n=Number(sel.amount);if(Number.isFinite(n)&&n>0)return{amount:n,currency:sel.currency||u.currencyCode||u.currency,complete:u.costQuote.displayComplete!==false,displayStatus:u.costQuote.displayStatus};}
if(u.costQuote)return null;
const n=u.cost??u.costUsd;
if(typeof n==='number'&&n>0)return{amount:n,currency:u.currencyCode||u.currency,complete:u.costQuote?u.costQuote.complete!==false:true};
return null;
}
function fmtElapsed(ms) { const s=Math.floor(ms/1000); return s<60?s+'s':Math.floor(s/60)+'m '+s%60+'s';}
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function escAttr(s) { return escHtml(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
@@ -1554,8 +1566,7 @@ es.onmessage=ev=>{setConnState('connected');
case 'usage':
if(e.usage){turnTokens=e.usage.completionTokens||0;
cumulativeTokens+=e.usage.totalTokens||0;
const usageCost=e.usage.cost??e.usage.costUsd;
if(typeof usageCost==='number')cumulativeCost+=usageCost;
const picked=usageSelectedCost(e.usage);
cumulativeCacheHit+=e.usage.cacheHitTokens||0;
cumulativeCacheMiss+=e.usage.cacheMissTokens||0;
const strip=el('div','metric-strip');
@@ -1563,7 +1574,8 @@ es.onmessage=ev=>{setConnState('connected');
const sp=el('span','item');sp.innerHTML=it.l+' <span class="v '+it.c+'">'+it.v+'</span>';strip.appendChild(sp);
});
if(e.usage.cacheHitTokens){const sp=el('span','item');sp.innerHTML=__('cache')+' <span class="v acc">'+Math.round(e.usage.cacheHitTokens/Math.max(1,e.usage.cacheHitTokens+e.usage.cacheMissTokens)*100)+'%</span>';strip.appendChild(sp);}
if(typeof usageCost==='number'&&usageCost>0){const sp=el('span','item');sp.innerHTML=__('cost')+' <span class="v">'+fmtMoney(usageCost,e.usage.currency)+'</span>';strip.appendChild(sp);}
if(picked&&picked.bucketed){const sp=el('span','item');sp.innerHTML=__('cost')+' <span class="v">'+__('multi_currency')+'</span>';strip.appendChild(sp);}
else if(picked&&picked.amount>0){const sp=el('span','item');sp.innerHTML=__('cost')+' <span class="v">'+fmtMoney(picked.amount,picked.currency||e.usage.currency)+'</span>';strip.appendChild(sp);}
log.appendChild(strip);scrollDown();
} break;
case 'notice': log.appendChild(el('div','notice'+(e.level==='warn'?' notice--warn':''),(e.level==='warn'?'! ':'')+(e.text||'')));scrollDown();break;
@@ -1602,11 +1614,12 @@ function fetchStatus(){
goalText=(s.goal||'').trim();
goalActive=goalText!==''&&(s.goalStatus||'')==='running';
updateGoalUI();
sessionCostQuote=s.sessionCostQuote||null;
// sidebar metrics
const cacheTotal=(s.cacheHit||0)+(s.cacheMiss||0);
$('#sm-cache').textContent=cacheTotal>0?Math.round((s.cacheHit||0)/cacheTotal*100)+'%':'—';
const lastCost=s.lastUsage?.cost??s.lastUsage?.costUsd??s.lastUsage?.totalCost;
if(typeof lastCost==='number')$('#sm-cost').textContent=fmtMoney(lastCost,s.lastUsage?.currency);
const lastPicked=usageSelectedCost(s.lastUsage);
$('#sm-cost').textContent=lastPicked?.bucketed?__('multi_currency'):lastPicked?fmtMoney(lastPicked.amount,lastPicked.currency||s.lastUsage?.currency):'—';
if(s.balance){$('#sm-balance').textContent=s.balance.display||'--';}
if(s.balance)balanceInfo.textContent='💰 '+(s.balance.display||'--');
}).catch(()=>{});
@@ -1972,8 +1985,9 @@ document.addEventListener('keydown',e=>{if(e.key==='Escape'&&sidebar.classList.c
// Session-scoped counters accumulated while this page is open. They reset on
// session rotation (/new, /resume) so the stats card labeled "session cost"
// never quietly turns into an all-sessions running total.
let cumulativeTokens=0, cumulativeCost=0, cumulativeCacheHit=0, cumulativeCacheMiss=0;
function resetCumulativeStats(){cumulativeTokens=0;cumulativeCost=0;cumulativeCacheHit=0;cumulativeCacheMiss=0;}
let cumulativeTokens=0, cumulativeCacheHit=0, cumulativeCacheMiss=0;
let sessionCostQuote=null;
function resetCumulativeStats(){cumulativeTokens=0;cumulativeCacheHit=0;cumulativeCacheMiss=0;sessionCostQuote=null;}
function openStats(){
const modal=$('#stats-modal');
if(!modal)return;
@@ -1986,10 +2000,11 @@ function openStats(){
const hit=cumulativeCacheHit||s.cacheHit||0, miss=cumulativeCacheMiss||s.cacheMiss||0;
const rate=hit+miss>0?Math.round(hit/(hit+miss)*100)+'%':'0%';
$('#stats-cache-rate').textContent=rate;
// No single-request fallback here: rendering lastUsage.cost (one turn)
// under the session-cost label displayed one turn's spend as the session's.
const cost=cumulativeCost;
$('#stats-total-cost').textContent=cost>0?fmtMoney(cost,s.lastUsage?.currency):'-';
const quote=s.sessionCostQuote;
if(quote?.displayStatus==='bucketed'||quote?.aggregateMode==='currency_buckets') $('#stats-total-cost').textContent=__('multi_currency');
else if(quote?.selected?.amount) $('#stats-total-cost').textContent=fmtMoney(Number(quote.selected.amount),quote.selected.currency);
else if(quote?.displayStatus==='unavailable') $('#stats-total-cost').textContent='—';
else $('#stats-total-cost').textContent='—';
$('#stats-balance').textContent=s.balance?.display||'-';
if(s.window){const pct=Math.min(100,Math.round(s.used/s.window*100));$('#stats-ctx-fill').style.width=pct+'%';$('#stats-ctx-used').textContent=fmtTok(s.used)+' tok';$('#stats-ctx-window').textContent=fmtTok(s.window)+' tok';}
}).catch(()=>{});
+15
View File
@@ -80,12 +80,18 @@ type Server struct {
// New builds a Server. bc must be the controller's event sink.
// serveCfg controls authentication (none, token, or password).
func New(ctrl control.SessionAPI, bc *Broadcaster, serveCfg config.ServeConfig) *Server {
if bc == nil {
bc = NewBroadcaster()
}
s := &Server{
ctrl: ctrl,
bc: bc,
titles: newTitleCache(ctrl.SessionDir()),
auth: newAuthGate(serveCfg),
}
if cfg, err := config.Load(); err == nil {
bc.SetDisplayCurrency(cfg.ExplicitDisplayCurrency())
}
s.initTitleProvider()
return s
}
@@ -827,6 +833,7 @@ func (s *Server) newSession(w http.ResponseWriter, _ *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.bc.ResetSession()
// Fresh path — the lease follows it; failure is theoretical but not silent.
if err := s.rebindSessionLease(s.ctl().SessionPath()); err != nil {
http.Error(w, sessionInUseError(err), http.StatusConflict)
@@ -1021,6 +1028,7 @@ func (s *Server) fork(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.bc.ResetSession()
// The controller switched to the fork (a fresh path); the lease follows it.
if err := s.rebindSessionLease(s.ctl().SessionPath()); err != nil {
http.Error(w, sessionInUseError(err), http.StatusConflict)
@@ -1203,6 +1211,7 @@ func (s *Server) resume(w http.ResponseWriter, r *http.Request) {
hook()
}
s.ctl().Resume(loaded, realPath)
s.bc.ResetSession()
w.WriteHeader(http.StatusNoContent)
}
@@ -1380,6 +1389,11 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) {
sess["lastUsage"] = u
}
if b, err := s.ctl().Balance(r.Context()); err == nil && b != nil {
if cfg, loadErr := config.Load(); loadErr == nil && cfg.DisplayCurrencyPref() == "" {
// Runtime-only hint: a single wallet currency may select an existing
// valuation, but is never persisted as configuration or history.
s.bc.SetDisplayCurrency(b.PrimaryCurrency())
}
sess["balance"] = map[string]any{
"display": b.Display(),
"available": b.Available,
@@ -1388,6 +1402,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) {
} else if err != nil {
slog.Warn("serve: balance fetch failed", "err", err)
}
sess["sessionCostQuote"] = s.bc.SessionCostQuote()
if j := s.ctl().Jobs(); len(j) > 0 {
sess["jobs"] = j
}
+22
View File
@@ -49,6 +49,28 @@ type record struct {
Total int `json:"total,omitempty"`
Requests int `json:"requests,omitempty"` // provider requests represented by this row
Turn bool `json:"turn,omitempty"` // true for TurnDone marker rows
// Cost quote fields (additive; older readers ignore them).
UsageSource string `json:"usage_source,omitempty"`
CostAmount string `json:"cost_amount,omitempty"` // original amount decimal
CostCurrency string `json:"cost_currency,omitempty"` // original ISO
SelectedAmount string `json:"selected_amount,omitempty"` // display valuation
SelectedCurrency string `json:"selected_currency,omitempty"`
CostComplete *bool `json:"cost_complete,omitempty"`
DisplayComplete *bool `json:"display_complete,omitempty"`
DisplayStatus string `json:"display_status,omitempty"`
AggregateMode string `json:"aggregate_mode,omitempty"`
OriginalTotals []string `json:"original_totals,omitempty"`
CostEstimated bool `json:"cost_estimated,omitempty"`
LegacyEstimate bool `json:"legacy_estimate,omitempty"`
PricingFingerprint string `json:"pricing_fingerprint,omitempty"`
RateDate string `json:"rate_date,omitempty"`
IncompleteReason string `json:"incomplete_reason,omitempty"`
BillingMode string `json:"billing_mode,omitempty"`
// ValuationCNY/USD amounts when present (occurrence-time).
ValuationCNY string `json:"valuation_cny,omitempty"`
ValuationUSD string `json:"valuation_usd,omitempty"`
// SelectedCost is a float compatibility mirror of SelectedAmount.
SelectedCost float64 `json:"selected_cost,omitempty"`
}
// Writer appends records to the daily stats file for a given stats dir.
+48 -15
View File
@@ -6,6 +6,7 @@ import (
"sync"
"time"
"reasonix/internal/billing"
"reasonix/internal/event"
"reasonix/internal/evidence"
"reasonix/internal/provider"
@@ -133,7 +134,7 @@ func (r *Recorder) Emit(e event.Event) {
if r != nil && r.writer != nil && e.Kind == event.Usage {
r.recordUsage(e)
} else if r != nil && r.writer != nil && e.Kind == event.GuardianAssessment && e.Guardian.Usage != nil {
r.recordProviderUsage(e.ModelRef, e.Guardian.Usage)
r.recordProviderUsage(e.ModelRef, e.Guardian.Usage, nil, "")
} else if r != nil && r.writer != nil && e.Kind == event.TurnDone {
r.RecordTurnCompletion()
}
@@ -221,27 +222,59 @@ func (r *Recorder) RecordDelegationAdmission(a event.DelegationAdmissionAudit) {
}
func (r *Recorder) recordUsage(e event.Event) {
r.recordProviderUsage(e.ModelRef, e.Usage)
r.recordProviderUsage(e.ModelRef, e.Usage, e.CostQuote, e.UsageSource)
}
func (r *Recorder) recordProviderUsage(modelRef string, usage *provider.Usage) {
func (r *Recorder) recordProviderUsage(modelRef string, usage *provider.Usage, quote *billing.CostQuote, usageSource string) {
if usage == nil || (usage.TotalTokens <= 0 && usage.RequestCount <= 0) {
return
}
// Recording is best-effort: a stats file failure (disk full, permissions)
// must never interrupt the event stream, matching telemetry's append idiom.
r.dispatcher.enqueue(record{
Timestamp: time.Now(),
ModelRef: modelRef,
Source: r.source,
Prompt: usage.PromptTokens,
Completion: usage.CompletionTokens,
Reasoning: usage.ReasoningTokens,
CacheHit: usage.CacheHitTokens,
CacheMiss: usage.CacheMissTokens,
Total: usage.TotalTokens,
Requests: usageRequestCount(usage),
})
rec := record{
Timestamp: time.Now(),
ModelRef: modelRef,
Source: r.source,
Prompt: usage.PromptTokens,
Completion: usage.CompletionTokens,
Reasoning: usage.ReasoningTokens,
CacheHit: usage.CacheHitTokens,
CacheMiss: usage.CacheMissTokens,
Total: usage.TotalTokens,
Requests: usageRequestCount(usage),
UsageSource: strings.TrimSpace(usageSource),
}
if quote != nil {
rec.CostAmount = quote.Original.Amount
rec.CostCurrency = quote.Original.Currency
rec.PricingFingerprint = quote.PricingFingerprint
rec.RateDate = quote.RateDate
rec.IncompleteReason = quote.IncompleteReason
rec.BillingMode = quote.BillingMode
rec.CostEstimated = quote.Estimated
rec.LegacyEstimate = quote.LegacyEstimate
costComplete := quote.CostComplete
displayComplete := quote.DisplayComplete
rec.CostComplete = &costComplete
rec.DisplayComplete = &displayComplete
rec.DisplayStatus = quote.DisplayStatus
rec.AggregateMode = quote.AggregateMode
for _, total := range quote.OriginalTotals {
rec.OriginalTotals = append(rec.OriginalTotals, total.Currency+":"+total.Amount)
}
if quote.Selected != nil {
rec.SelectedAmount = quote.Selected.Amount
rec.SelectedCurrency = quote.Selected.Currency
rec.SelectedCost = quote.Selected.Float64()
}
if v, ok := quote.Valuations["CNY"]; ok {
rec.ValuationCNY = v.Money.Amount
}
if v, ok := quote.Valuations["USD"]; ok {
rec.ValuationUSD = v.Money.Amount
}
}
r.dispatcher.enqueue(rec)
}
func usageRequestCount(usage *provider.Usage) int {
+12 -4
View File
@@ -16,12 +16,18 @@ show_turn_usage = true # CLI/TUI: show per-request token and cost receipts in
[desktop]
# language = "auto" # auto|en|zh; auto follows the browser/OS locale
# currency = "auto" # auto|CNY|USD; explicitly set it independently from language
# currency = "auto" # legacy display currency; prefer [billing].display_currency
layout_style = "classic" # classic|workbench|creation; desktop layout style
# theme = "auto" # desktop only: auto|dark|light
# terminal_theme = "auto" # integrated terminal: auto|dark|light; auto follows the desktop app
# theme_style = "graphite" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases
[billing]
# display_currency = "auto" # auto|CNY|USD — display only; never rewrites provider list prices
# Provider list prices use each provider's billing_currency (frozen from the
# official table or custom price). Switching display currency only re-selects
# occurrence-time valuations already recorded in the cost ledger.
[notifications]
enabled = false # system notifications for CLI and desktop turns; default off
turn_done = true # notify when a turn finishes
@@ -68,9 +74,11 @@ context_window = 1000000
# max_output_tokens = 131072 # only after repeated finish_reason=length
# Optional per-model context budgets. Omitted models inherit context_window.
# model_overrides = { "deepseek-v4-flash" = { context_window = 1000000 } }
# Official DeepSeek providers use CNY or USD regional prices from
# [desktop].currency; auto follows the selected language and defaults to USD.
# Set prices explicitly only when you want to pin custom rates.
# Official list prices are frozen per provider (billing_currency). Display
# currency ([billing].display_currency) never rewrites these tables.
# billing_currency = "USD" # frozen list-price currency: CNY|USD
# billing_mode = "payg" # payg | subscription_equivalent (e.g. MiMo Token Plan)
# Set prices explicitly only when you want to pin custom rates (always protected).
# prices = { "deepseek-v4-flash" = { cache_hit = 0.02, input = 1, output = 2, currency = "¥" }, "deepseek-v4-pro" = { cache_hit = 0.025, input = 3, output = 6, currency = "¥" } } # per 1M tokens
# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).
effort = "high"
+46 -40
View File
@@ -2,14 +2,14 @@
"limits": {
"banner": 15,
"commented-code": 0,
"complexity": 2072,
"essay": 4043,
"file-size": 108314,
"function-size": 8916,
"complexity": 2080,
"essay": 4048,
"file-size": 108256,
"function-size": 8932,
"layering": 1,
"marker": 0,
"narrative": 64,
"test-file-size": 69794
"test-file-size": 69791
},
"files": {
"cmd/e2ebench/main.go": {
@@ -27,16 +27,16 @@
},
"desktop/app.go": {
"complexity": 63,
"essay": 95,
"file-size": 11422,
"function-size": 379
"essay": 96,
"file-size": 11402,
"function-size": 382
},
"desktop/app_autosave_test.go": {
"essay": 1
},
"desktop/app_test.go": {
"essay": 2,
"test-file-size": 10229
"test-file-size": 10233
},
"desktop/bot_bridge.go": {
"essay": 11
@@ -84,7 +84,7 @@
"essay": 2
},
"desktop/frontend/src/App.tsx": {
"file-size": 4706
"file-size": 4675
},
"desktop/frontend/src/__tests__/app-chrome-tabs.test.ts": {
"test-file-size": 19
@@ -125,6 +125,9 @@
"desktop/frontend/src/components/Composer.tsx": {
"file-size": 4003
},
"desktop/frontend/src/components/ContextPanel.tsx": {
"file-size": 38
},
"desktop/frontend/src/components/MemoryPanel.tsx": {
"file-size": 1088
},
@@ -141,7 +144,7 @@
"file-size": 6754
},
"desktop/frontend/src/components/StatusBar.tsx": {
"file-size": 20
"file-size": 36
},
"desktop/frontend/src/components/ThemeGallery.tsx": {
"file-size": 489
@@ -171,7 +174,7 @@
"file-size": 269
},
"desktop/frontend/src/lib/types.ts": {
"file-size": 1452
"file-size": 1507
},
"desktop/frontend/src/lib/useController.ts": {
"file-size": 3931
@@ -284,10 +287,10 @@
"desktop/settings_app.go": {
"complexity": 22,
"essay": 26,
"file-size": 2790
"file-size": 2738
},
"desktop/settings_app_test.go": {
"test-file-size": 1858
"test-file-size": 1856
},
"desktop/shared_host.go": {
"essay": 1
@@ -308,8 +311,8 @@
"desktop/tabs.go": {
"complexity": 41,
"essay": 112,
"file-size": 7707,
"function-size": 374
"file-size": 7704,
"function-size": 371
},
"desktop/tabs_order_test.go": {
"test-file-size": 395
@@ -436,6 +439,9 @@
"essay": 11,
"test-file-size": 238
},
"internal/acp/status.go": {
"file-size": 7
},
"internal/acp/switch_recovery_test.go": {
"essay": 7
},
@@ -674,15 +680,15 @@
"essay": 1
},
"internal/boot/boot.go": {
"complexity": 298,
"essay": 101,
"file-size": 2244,
"function-size": 1870,
"complexity": 299,
"essay": 105,
"file-size": 2254,
"function-size": 1887,
"narrative": 3
},
"internal/boot/boot_test.go": {
"essay": 10,
"test-file-size": 4495
"test-file-size": 4456
},
"internal/boot/extension_dispatch_test.go": {
"essay": 3,
@@ -812,7 +818,7 @@
"internal/cli/chat_tui.go": {
"complexity": 326,
"essay": 109,
"file-size": 4669,
"file-size": 4675,
"function-size": 1280
},
"internal/cli/chat_tui_paste.go": {
@@ -825,12 +831,12 @@
"internal/cli/cli.go": {
"complexity": 72,
"essay": 60,
"file-size": 1942,
"function-size": 497
"file-size": 1925,
"function-size": 495
},
"internal/cli/cli_test.go": {
"essay": 9,
"test-file-size": 1409
"test-file-size": 1413
},
"internal/cli/complete.go": {
"essay": 13,
@@ -968,7 +974,7 @@
"essay": 6
},
"internal/config/backfill_test.go": {
"test-file-size": 1099
"test-file-size": 1122
},
"internal/config/cache_policy.go": {
"essay": 9
@@ -978,7 +984,7 @@
},
"internal/config/config.go": {
"essay": 54,
"file-size": 1510,
"file-size": 1519,
"narrative": 2
},
"internal/config/credentials.go": {
@@ -999,10 +1005,10 @@
},
"internal/config/edit.go": {
"essay": 15,
"file-size": 1663
"file-size": 1642
},
"internal/config/edit_test.go": {
"test-file-size": 2418
"test-file-size": 2425
},
"internal/config/effort.go": {
"complexity": 9,
@@ -1013,8 +1019,8 @@
},
"internal/config/load.go": {
"essay": 24,
"file-size": 1716,
"function-size": 53
"file-size": 1730,
"function-size": 57
},
"internal/config/main_test.go": {
"essay": 1
@@ -1053,10 +1059,10 @@
"file-size": 267
},
"internal/config/render.go": {
"complexity": 243,
"complexity": 250,
"essay": 2,
"file-size": 989,
"function-size": 941
"file-size": 1010,
"function-size": 961
},
"internal/config/render_test.go": {
"test-file-size": 757
@@ -1197,7 +1203,7 @@
},
"internal/event/event.go": {
"essay": 10,
"file-size": 47,
"file-size": 49,
"narrative": 1
},
"internal/event/subagent_progress.go": {
@@ -1207,7 +1213,7 @@
"essay": 2
},
"internal/eventwire/wire.go": {
"function-size": 24
"function-size": 1
},
"internal/evidence/evidence.go": {
"complexity": 6,
@@ -1558,7 +1564,7 @@
},
"internal/provider/provider.go": {
"essay": 51,
"file-size": 354
"file-size": 357
},
"internal/provider/responses/responses.go": {
"complexity": 54,
@@ -1718,7 +1724,7 @@
},
"internal/serve/serve.go": {
"essay": 27,
"file-size": 872
"file-size": 887
},
"internal/serve/serve_lease_test.go": {
"essay": 3
@@ -1903,10 +1909,10 @@
"essay": 1
},
"workers/crash-report/src/index.ts": {
"file-size": 902
"file-size": 899
},
"workers/crash-report/src/stats.ts": {
"file-size": 183
"file-size": 76
}
}
}