mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
merge(main-v2): integrate billing and task controls
This commit is contained in:
commit
be91b4ecdc
94 files changed
+5091
-1041
No files matched your search
+93
-36
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} 個檔案。",
|
||||
|
||||
@@ -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": "生成式背景音乐",
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 应启动以下命令之一:
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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。 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 = ¤cy
|
||||
}
|
||||
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 = ¤cy
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "e, 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, "e, 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 := "eBuildState{
|
||||
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 "eAccumulator{
|
||||
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
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 != "$" {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "重载自定义命令",
|
||||
|
||||
@@ -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: "重載自定義命令",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||
function escAttr(s) { return escHtml(s).replace(/"/g,'"').replace(/'/g,''');}
|
||||
@@ -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(()=>{});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user