{
{{ selectedAccount.name }}{{ selectedAccount.email ?? '-' }}
- {{ accountTypeLabel(selectedAccount.account_type) }}
+ {{ providerAwareAccountTypeLabel(selectedAccount) }}
{{ selectedAccount.disabled ? t('已禁用', 'Disabled') : t('启用中', 'Enabled') }}
@@ -2623,7 +2895,10 @@ onBeforeUnmount(() => {
{{ formatDateTime(selectedAccount.last_checked_at) }}
-
+
{{ subscriptionDetailText(selectedAccount) }}
@@ -3333,6 +3608,25 @@ onBeforeUnmount(() => {
padding-top: 2px;
}
+.card-quota-antigravity-group {
+ display: grid;
+ gap: 10px;
+ min-width: 0;
+ padding-top: 9px;
+ border-top: 1px solid var(--account-card-inner-border);
+}
+
+.card-quota-antigravity-group:first-child {
+ padding-top: 0;
+ border-top: 0;
+}
+
+.card-quota-antigravity-group .card-quota-bar,
+.card-quota-antigravity-group .card-quota-bar:first-child {
+ padding-top: 0;
+ border-top: 0;
+}
+
.card-quota-bar {
display: grid;
gap: 7px;
@@ -3655,6 +3949,22 @@ onBeforeUnmount(() => {
min-height: 38px;
}
+:global(.quota-antigravity-group) {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+}
+
+:global(.quota-antigravity-group-title) {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--cpa-text-muted);
+ font-size: 11px;
+ font-weight: 700;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
:global(.quota-window-head) {
display: flex;
align-items: center;
diff --git a/frontend/src/shared/i18n/messages.ts b/frontend/src/shared/i18n/messages.ts
index 1844990a..8e444344 100644
--- a/frontend/src/shared/i18n/messages.ts
+++ b/frontend/src/shared/i18n/messages.ts
@@ -42,6 +42,8 @@ const exactServerMessages: MessagePair[] = [
['账号身份已变化(account_id 不一致),请刷新账号列表后重试', 'The account identity has changed (account_id mismatch). Refresh the account list and try again.'],
['账号尚未确认身份(缺少 account_id),请先刷新账号列表后再重置', 'The account identity is not confirmed yet (no account_id). Refresh the account list before resetting.'],
['账号身份冲突:列表与详情的 account_id/auth_index 不一致,已保留原快照', 'Account identity conflict: the list and detail disagree on account_id/auth_index; the previous snapshot was preserved.'],
+ ['账号身份冲突:Antigravity 列表与详情的 name/type/auth_index/project_id/email 不一致,已保留原快照', 'Account identity conflict: the Antigravity list and detail disagree on name/type/auth_index/project_id/email; the previous snapshot was preserved.'],
+ ['Antigravity 配额读取失败', 'Failed to read Antigravity quota'],
['无法确认可用重置额度(快照未知),请刷新后重试', 'Cannot confirm available reset credits (snapshot unknown). Refresh and try again.'],
['核销主动重置额度失败:网络异常,未确认是否已核销', 'Failed to redeem the reset credit: network error; redemption is unconfirmed.'],
['核销主动重置额度失败:管理接口异常', 'Failed to redeem the reset credit: management API error.'],
@@ -63,14 +65,14 @@ const exactServerMessages: MessagePair[] = [
['当前密码不正确', 'Current password is incorrect'],
['请先创建第一个管理员账号', 'Create the first admin account first'],
['尚未运行', 'Not run yet'],
- ['正在运行多个 Codex Keeper 任务', 'Multiple Codex Keeper tasks are running'],
- ['正在刷新 Codex 账号', 'Refreshing Codex accounts'],
- ['正在按条件刷新 Codex 账号', 'Refreshing Codex accounts by condition'],
- ['正在巡检 Codex 账号', 'Inspecting Codex accounts'],
+ ['正在运行多个 Keeper 任务', 'Multiple Keeper tasks are running'],
+ ['正在刷新账号', 'Refreshing accounts'],
+ ['正在按条件刷新账号', 'Refreshing accounts by condition'],
+ ['正在巡检账号', 'Inspecting accounts'],
['巡检完成', 'Inspection complete'],
- ['缓存时间内没有需要自动刷新的 Codex auth file', 'No Codex auth files need automatic refresh inside the cache window'],
- ['未发现指定 Codex auth file', 'No matching Codex auth file was found'],
- ['未发现 Codex auth file', 'No Codex auth files were found'],
+ ['缓存时间内没有需要自动刷新的 auth file', 'No auth files need automatic refresh inside the cache window'],
+ ['未发现指定 auth file', 'No matching auth file was found'],
+ ['未发现 auth file', 'No auth files were found'],
['缺少 access token', 'Missing access token'],
['读取 auth file 详情失败', 'Failed to read auth file details'],
['管理密钥未设置,无法运行 Codex Keeper', 'Management key is not set, so Codex Keeper cannot run'],
@@ -225,6 +227,10 @@ const serverTermTranslations: MessagePair[] = [
]
const serverMessagePatterns: ServerMessagePattern[] = [
+ // Antigravity quota log lines must be matched BEFORE the generic `…失败` family below, or the
+ // name-prefixed failure line falls through to `(.+)失败` and renders half-translated.
+ [/^(.+?):Antigravity 配额刷新成功((\d+) 组)$/, ([, name, count]) => `${name}: Antigravity quota refreshed (${count} groups)`],
+ [/^(.+?):Antigravity 配额读取失败$/, ([, name]) => `${name}: Failed to read Antigravity quota`],
[/^操作失败$/, () => 'Operation failed'],
[/^加载(.+)失败$/, ([, subject]) => `Failed to load ${translateTerms(subject ?? '')}`],
[/^保存(.+)失败$/, ([, subject]) => `Failed to save ${translateTerms(subject ?? '')}`],
@@ -290,11 +296,11 @@ const serverMessagePatterns: ServerMessagePattern[] = [
[/^启用代理时必须填写代理地址$/, () => 'Proxy URL is required when proxy is enabled'],
[/^Cron 表达式无效,请使用 5 段格式:分 时 日 月 周$/, () => 'Invalid Cron expression. Use the 5-field format: minute hour day month weekday'],
[/^Cron 表达式无效,请使用 5 段格式$/, () => 'Invalid Cron expression. Use the 5-field format'],
- [/^开始按条件刷新 (\d+) 个 Codex 账号$/, ([, count]) => `Started conditional refresh for ${count} Codex accounts`],
- [/^开始刷新 (\d+) 个 Codex 账号$/, ([, count]) => `Started refreshing ${count} Codex accounts`],
- [/^开始 Codex 账号巡检$/, () => 'Started Codex account inspection'],
+ [/^开始按条件刷新 (\d+) 个账号$/, ([, count]) => `Started conditional refresh for ${count} accounts`],
+ [/^开始刷新 (\d+) 个账号$/, ([, count]) => `Started refreshing ${count} accounts`],
+ [/^开始账号巡检$/, () => 'Started account inspection'],
[/^下一轮计划:(.+)$/, ([, time]) => `Next scheduled run: ${time}`],
- [/^清理本地已不存在的 Codex 账号 (\d+) 个$/, ([, count]) => `Cleaned up ${count} local Codex accounts that no longer exist`],
+ [/^清理本地已不存在的账号 (\d+) 个$/, ([, count]) => `Cleaned up ${count} local accounts that no longer exist`],
[/^巡检完成:网络错误 (\d+)$/, ([, count]) => `Inspection complete: ${count} network errors`],
[/^条件刷新完成:健康 (\d+),坏凭证禁用 (\d+),恢复启用 (\d+),优先级降级 (\d+),优先级恢复 (\d+),网络错误 (\d+),缓存跳过 (\d+)$/, ([, healthy, disabled, restored, degraded, priorityRestored, networkErrors, skipped]) => `Conditional refresh complete: ${healthy} healthy, ${disabled} bad credentials disabled, ${restored} restored, ${degraded} priorities lowered, ${priorityRestored} priorities restored, ${networkErrors} network errors, ${skipped} skipped by cache`],
[/^账号刷新完成:健康 (\d+),凭证异常 (\d+),恢复启用 (\d+),优先级降级 (\d+),优先级恢复 (\d+),网络错误 (\d+)$/, ([, healthy, credentialErrors, restored, degraded, priorityRestored, networkErrors]) => `Account refresh complete: ${healthy} healthy, ${credentialErrors} credential errors, ${restored} restored, ${degraded} priorities lowered, ${priorityRestored} priorities restored, ${networkErrors} network errors`],
diff --git a/frontend/src/shared/types/api.ts b/frontend/src/shared/types/api.ts
index 06133f68..277acf05 100644
--- a/frontend/src/shared/types/api.ts
+++ b/frontend/src/shared/types/api.ts
@@ -254,6 +254,23 @@ export interface CodexKeeperAccount {
reset_credit_count: number | null
reset_credits: CodexKeeperResetCredit[] | null
subscription_active_until: string | null
+ provider: string | null
+ antigravity_quota: AntigravityQuotaGroup[] | null
+}
+
+export interface AntigravityQuotaBucket {
+ bucket_id: string
+ display_name: string
+ window: string
+ remaining_fraction: number
+ reset_at: string | null
+ description?: string
+}
+
+export interface AntigravityQuotaGroup {
+ display_name: string
+ description?: string
+ buckets: AntigravityQuotaBucket[]
}
export interface CodexKeeperResetCredit {
From d50047412fb5a91c510cfa799af46b3b0c4a7383 Mon Sep 17 00:00:00 2001
From: Jiacheng
Date: Thu, 17 Sep 2026 15:44:35 +0800
Subject: [PATCH 20/25] feat(settings): configurable prefix for generated API
keys (#14)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
API keys minted by CPA-Helper were hardcoded to `sk-`. Add an admin
setting `api_key_prefix` so a deployment can brand its keys (ours will use
`sk-cortex`, yielding `sk-cortex-...`). A generated key is
`-<52 random alphanumerics>`.
- Default stays `sk`, so untouched deployments keep generating `sk-...` keys
byte-for-byte as before; blank input resets to the default.
- Only NEW keys use the prefix. Existing keys are never rewritten and keep
working (tested: the legacy key stays listed with its original value).
- Validation (422): letters, digits, `-` and `_`; must start with a letter or
digit and must not end with `-` (the generator adds the joining dash); max
32 chars; whitespace trimmed; rejected values do not persist.
- Migration 202609160001 adds `app_settings.api_key_prefix` (Down drops it;
a rollback only loses the configured prefix). LatestVersion, the `migrate
down-to` CLI test head, the Up/Down/replay schema test and the rollback
runbook are updated.
- The CLIProxyAPI key-sync path passes keys as opaque strings and makes no
prefix assumption (verified).
- Frontend: new "API KEY 设置 / API Key Settings" section with a live preview
and rules; key masking is now prefix-aware — the random secret never
contains `-`, so the last `-` separates any multi-segment prefix from the
secret (`sk-cortex-` stays readable, >= 8 secret chars always masked).
Extracted to a pure maskApiKey helper with a smoke test; i18n for the two
validation messages plus smoke assertions.
Signed-off-by: feiniu
Co-authored-by: feiniu (Raft agent)
Co-authored-by: Claude Fable 5.1
---
backend/cmd/cpa-helper/main_test.go | 6 +-
backend/internal/app/api_key_prefix.go | 57 ++++++
backend/internal/app/api_key_prefix_test.go | 177 ++++++++++++++++++
backend/internal/app/app.go | 15 +-
backend/internal/app/auth_settings.go | 8 +
backend/internal/app/migrations_test.go | 7 +
backend/internal/app/users.go | 11 +-
...2609160001_app_settings_api_key_prefix.sql | 9 +
backend/migrations/migrations.go | 2 +-
docs/migrations-rollback.md | 7 +-
frontend/package.json | 3 +-
frontend/scripts/i18n-smoke.mjs | 8 +
frontend/scripts/mask-api-key-smoke.mjs | 46 +++++
.../features/api-keys/views/ApiKeysView.vue | 9 +-
.../features/settings/views/SettingsView.vue | 28 +++
frontend/src/shared/i18n/messages.ts | 2 +
frontend/src/shared/types/api.ts | 2 +
frontend/src/shared/utils/maskApiKey.ts | 21 +++
18 files changed, 395 insertions(+), 23 deletions(-)
create mode 100644 backend/internal/app/api_key_prefix.go
create mode 100644 backend/internal/app/api_key_prefix_test.go
create mode 100644 backend/migrations/202609160001_app_settings_api_key_prefix.sql
create mode 100644 frontend/scripts/mask-api-key-smoke.mjs
create mode 100644 frontend/src/shared/utils/maskApiKey.ts
diff --git a/backend/cmd/cpa-helper/main_test.go b/backend/cmd/cpa-helper/main_test.go
index 3723f258..7f0259b9 100644
--- a/backend/cmd/cpa-helper/main_test.go
+++ b/backend/cmd/cpa-helper/main_test.go
@@ -49,8 +49,8 @@ func TestMigrateDownToRollsBackToTarget(t *testing.T) {
if !strings.Contains(out, "current_version=202609040002") {
t.Fatalf("rollback did not reach 202609040002: %s", out)
}
- if !strings.Contains(out, "previous_version=202609060005") {
- t.Fatalf("rollback did not start from head 202609060005: %s", out)
+ if !strings.Contains(out, "previous_version=202609160001") {
+ t.Fatalf("rollback did not start from head 202609160001: %s", out)
}
// A non-allowlisted target is refused.
@@ -101,7 +101,7 @@ func TestMigrateDownToRefusesPendingRedeems(t *testing.T) {
if err := run(ctx, []string{"migrate", "down-to", "202609040002"}, &bytes.Buffer{}); err == nil {
t.Fatal("rollback should be refused while a pending redeem exists")
}
- if v := currentVersionForTest(t, dbPath); v != 202609060005 {
+ if v := currentVersionForTest(t, dbPath); v != 202609160001 {
t.Fatalf("refused rollback still changed version to %d", v)
}
diff --git a/backend/internal/app/api_key_prefix.go b/backend/internal/app/api_key_prefix.go
new file mode 100644
index 00000000..efeb31a3
--- /dev/null
+++ b/backend/internal/app/api_key_prefix.go
@@ -0,0 +1,57 @@
+package app
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// defaultAPIKeyPrefix is used for generated API keys when no prefix is configured. A generated
+// key is `-`, so the default yields `sk-...` — byte-for-byte the shape produced
+// before the prefix became configurable.
+const defaultAPIKeyPrefix = "sk"
+
+// maxAPIKeyPrefixLength bounds the configurable prefix so keys stay a sane length.
+const maxAPIKeyPrefixLength = 32
+
+// apiKeyPrefixPattern allows letters, digits, `-` and `_`; it must start with a letter or digit
+// and must not END with `-` (the generator appends the joining dash itself, so `sk-cortex` is the
+// canonical form and `sk-cortex-` would double the dash).
+var apiKeyPrefixPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9_-]*[A-Za-z0-9_])?$`)
+
+const (
+ apiKeyPrefixTooLongMessage = "api_key_prefix 超出最大长度 32"
+ apiKeyPrefixInvalidMessage = "api_key_prefix 只能包含字母、数字、- 和 _,且不能以 - 开头或结尾"
+)
+
+// normalizeAPIKeyPrefix trims the configured prefix and substitutes the default for an empty
+// value. It does NOT validate — callers that accept user input must call validateAPIKeyPrefix
+// first; stored values are already validated.
+func normalizeAPIKeyPrefix(value string) string {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ return defaultAPIKeyPrefix
+ }
+ return trimmed
+}
+
+// validateAPIKeyPrefix rejects a user-supplied prefix that is too long or malformed. An empty
+// (or blank) value is accepted and means "use the default".
+func validateAPIKeyPrefix(value string) error {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ return nil
+ }
+ if len(trimmed) > maxAPIKeyPrefixLength {
+ return validationError(apiKeyPrefixTooLongMessage)
+ }
+ if !apiKeyPrefixPattern.MatchString(trimmed) {
+ return validationError(apiKeyPrefixInvalidMessage)
+ }
+ return nil
+}
+
+// buildAPIKey joins a (normalized) prefix and the random secret with a single dash.
+func buildAPIKey(prefix, secret string) string {
+ return fmt.Sprintf("%s-%s", prefix, secret)
+}
diff --git a/backend/internal/app/api_key_prefix_test.go b/backend/internal/app/api_key_prefix_test.go
new file mode 100644
index 00000000..91641f57
--- /dev/null
+++ b/backend/internal/app/api_key_prefix_test.go
@@ -0,0 +1,177 @@
+package app_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+
+ backendApp "cpa-helper/backend/internal/app"
+)
+
+// generatedAPIKeySecretLength mirrors the backend's random-secret length; a generated key is
+// `-`, so its total length is len(prefix)+1+52.
+const generatedAPIKeySecretLength = 52
+
+// newAPIKeyPrefixTestApp boots the app with an admin session and a permissive fake CPA that
+// accepts key sync, so API keys can actually be created.
+func newAPIKeyPrefixTestApp(t *testing.T) (http.Handler, []*http.Cookie, func()) {
+ t.Helper()
+ t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir())
+ var mu sync.Mutex
+ remoteKeys := []string{}
+ cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v0/management/api-keys" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ mu.Lock()
+ defer mu.Unlock()
+ switch r.Method {
+ case http.MethodPatch:
+ var payload struct {
+ New string `json:"new"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&payload)
+ remoteKeys = append(remoteKeys, payload.New)
+ _ = json.NewEncoder(w).Encode(map[string]any{"api-keys": remoteKeys})
+ case http.MethodGet:
+ _ = json.NewEncoder(w).Encode(map[string]any{"api-keys": remoteKeys})
+ case http.MethodPut:
+ var keys []string
+ _ = json.NewDecoder(r.Body).Decode(&keys)
+ remoteKeys = keys
+ _ = json.NewEncoder(w).Encode(map[string]any{"api-keys": remoteKeys})
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+ }))
+ app, err := backendApp.New()
+ if err != nil {
+ cpa.Close()
+ t.Fatalf("New() failed: %v", err)
+ }
+ handler := app.Routes()
+ cookies := requestJSON(t, handler, http.MethodPost, "/api/auth/setup", map[string]any{
+ "username": "admin", "password": "test-password", "nickname": "Admin",
+ }, nil, nil)
+ requestJSON(t, handler, http.MethodPut, "/api/settings", map[string]any{
+ "cliaproxy_url": cpa.URL, "management_key": "test-management-key", "collector_enabled": false,
+ }, cookies, nil)
+ return handler, cookies, func() { app.Close(); cpa.Close() }
+}
+
+func createAPIKeyForTest(t *testing.T, handler http.Handler, cookies []*http.Cookie) apiKeyCreateResponse {
+ t.Helper()
+ created := apiKeyCreateResponse{}
+ requestJSON(t, handler, http.MethodPost, "/api/api-keys", map[string]any{"description": "prefix test"}, cookies, &created)
+ if created.APIKey == "" || created.APIKeyHash == "" {
+ t.Fatalf("create returned empty key/hash: %+v", created)
+ }
+ return created
+}
+
+func settingsPrefixForTest(t *testing.T, handler http.Handler, cookies []*http.Cookie) string {
+ t.Helper()
+ var settings struct {
+ APIKeyPrefix string `json:"api_key_prefix"`
+ }
+ requestJSON(t, handler, http.MethodGet, "/api/settings", nil, cookies, &settings)
+ return settings.APIKeyPrefix
+}
+
+func TestAPIKeyPrefixDefaultsToSkAndKeepsLegacyShape(t *testing.T) {
+ handler, cookies, cleanup := newAPIKeyPrefixTestApp(t)
+ defer cleanup()
+ if got := settingsPrefixForTest(t, handler, cookies); got != "sk" {
+ t.Fatalf("default api_key_prefix = %q, want sk", got)
+ }
+ created := createAPIKeyForTest(t, handler, cookies)
+ if !strings.HasPrefix(created.APIKey, "sk-") {
+ t.Fatalf("default key %q must start with sk-", created.APIKey)
+ }
+ if len(created.APIKey) != len("sk-")+generatedAPIKeySecretLength {
+ t.Fatalf("default key length = %d, want %d (legacy shape preserved)", len(created.APIKey), len("sk-")+generatedAPIKeySecretLength)
+ }
+}
+
+func TestAPIKeyPrefixConfiguredIsUsedForNewKeysOnly(t *testing.T) {
+ handler, cookies, cleanup := newAPIKeyPrefixTestApp(t)
+ defer cleanup()
+ legacy := createAPIKeyForTest(t, handler, cookies)
+
+ var saved struct {
+ APIKeyPrefix string `json:"api_key_prefix"`
+ }
+ requestJSON(t, handler, http.MethodPut, "/api/settings", map[string]any{"api_key_prefix": " sk-cortex "}, cookies, &saved)
+ if saved.APIKeyPrefix != "sk-cortex" {
+ t.Fatalf("saved api_key_prefix = %q, want sk-cortex (trimmed)", saved.APIKeyPrefix)
+ }
+ if got := settingsPrefixForTest(t, handler, cookies); got != "sk-cortex" {
+ t.Fatalf("api_key_prefix did not persist: %q", got)
+ }
+
+ created := createAPIKeyForTest(t, handler, cookies)
+ if !strings.HasPrefix(created.APIKey, "sk-cortex-") || strings.HasPrefix(created.APIKey, "sk-cortex--") {
+ t.Fatalf("new key %q must be sk-cortex- with exactly one joining dash", created.APIKey)
+ }
+ if len(created.APIKey) != len("sk-cortex-")+generatedAPIKeySecretLength {
+ t.Fatalf("new key length = %d, want %d", len(created.APIKey), len("sk-cortex-")+generatedAPIKeySecretLength)
+ }
+
+ // Existing keys are never rewritten: the legacy key is still listed with its original value.
+ var keys []struct {
+ APIKey string `json:"api_key"`
+ APIKeyHash string `json:"api_key_hash"`
+ }
+ requestJSON(t, handler, http.MethodGet, "/api/api-keys", nil, cookies, &keys)
+ foundLegacy, foundNew := false, false
+ for _, key := range keys {
+ if key.APIKeyHash == legacy.APIKeyHash && strings.HasPrefix(key.APIKey, "sk-") && !strings.HasPrefix(key.APIKey, "sk-cortex-") {
+ foundLegacy = true
+ }
+ if key.APIKeyHash == created.APIKeyHash && strings.HasPrefix(key.APIKey, "sk-cortex-") {
+ foundNew = true
+ }
+ }
+ if !foundLegacy || !foundNew {
+ t.Fatalf("expected both the untouched legacy sk- key and the new sk-cortex- key; got %+v", keys)
+ }
+
+ // Blank resets to the default.
+ requestJSON(t, handler, http.MethodPut, "/api/settings", map[string]any{"api_key_prefix": ""}, cookies, &saved)
+ if saved.APIKeyPrefix != "sk" {
+ t.Fatalf("blank api_key_prefix should reset to sk, got %q", saved.APIKeyPrefix)
+ }
+}
+
+func TestAPIKeyPrefixRejectsMalformedValues(t *testing.T) {
+ handler, cookies, cleanup := newAPIKeyPrefixTestApp(t)
+ defer cleanup()
+ for _, bad := range []string{
+ "-sk", // leading dash
+ "sk-", // trailing dash (the generator adds the joining dash)
+ "sk cortex", // whitespace
+ "sk/cortex", // slash
+ "sk.cortex", // dot
+ "sk:cortex", // non-ASCII
+ strings.Repeat("a", 33), // too long
+ "sk-" + strings.Repeat("b", 30), // too long (33)
+ } {
+ requestJSONExpectStatus(t, handler, http.MethodPut, "/api/settings", map[string]any{"api_key_prefix": bad}, cookies, http.StatusUnprocessableEntity)
+ }
+ // The rejections must not have changed the stored value.
+ if got := settingsPrefixForTest(t, handler, cookies); got != "sk" {
+ t.Fatalf("rejected values must not persist; api_key_prefix = %q", got)
+ }
+ // Valid edge cases: single char, underscore, digits, 32 chars.
+ for _, ok := range []string{"a", "team_42", "SK-Cortex_2", strings.Repeat("z", 32)} {
+ requestJSON(t, handler, http.MethodPut, "/api/settings", map[string]any{"api_key_prefix": ok}, cookies, nil)
+ if got := settingsPrefixForTest(t, handler, cookies); got != ok {
+ t.Fatalf("valid prefix %q not stored, got %q", ok, got)
+ }
+ }
+}
diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go
index effb2d57..86af5a03 100644
--- a/backend/internal/app/app.go
+++ b/backend/internal/app/app.go
@@ -587,6 +587,9 @@ type AppConfig struct {
SessionSecret string `json:"session_secret"`
ProductName string `json:"product_name"`
ProductLogo string `json:"product_logo"`
+ // APIKeyPrefix is the prefix for NEWLY generated API keys (`-`), without the
+ // joining dash. Empty means the default (`sk`). Existing keys are never rewritten.
+ APIKeyPrefix string `json:"api_key_prefix"`
}
func defaultConfig() (AppConfig, error) {
@@ -624,6 +627,7 @@ func defaultConfig() (AppConfig, error) {
},
ModelRequestURL: defaultCPAURL,
SessionSecret: secret,
+ APIKeyPrefix: defaultAPIKeyPrefix,
}, nil
}
@@ -642,15 +646,15 @@ func (a *App) loadConfig(ctx context.Context) (AppConfig, error) {
SELECT collector_enabled, cliaproxy_url, management_key, queue_name, batch_size,
poll_interval_seconds, retry_interval_seconds, codex_keeper_settings,
codex_keeper_priority_rules, litellm_proxy_enabled, litellm_proxy_url,
- model_request_url, session_secret, product_name, product_logo
+ model_request_url, session_secret, product_name, product_logo, api_key_prefix
FROM app_settings WHERE id = 1
`)
var collectorEnabled, litellmProxyEnabled bool
var cliaproxyURL, managementKey, queueName, keeperJSON, rulesJSON, litellmProxyURL, modelRequestURL, sessionSecret string
- var productName, productLogo string
+ var productName, productLogo, apiKeyPrefix string
var batchSize int
var pollInterval, retryInterval float64
- if err := row.Scan(&collectorEnabled, &cliaproxyURL, &managementKey, &queueName, &batchSize, &pollInterval, &retryInterval, &keeperJSON, &rulesJSON, &litellmProxyEnabled, &litellmProxyURL, &modelRequestURL, &sessionSecret, &productName, &productLogo); err != nil {
+ if err := row.Scan(&collectorEnabled, &cliaproxyURL, &managementKey, &queueName, &batchSize, &pollInterval, &retryInterval, &keeperJSON, &rulesJSON, &litellmProxyEnabled, &litellmProxyURL, &modelRequestURL, &sessionSecret, &productName, &productLogo, &apiKeyPrefix); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return AppConfig{}, fmt.Errorf("%w: app_settings id=1 is missing; run `cpa-helper migrate`", ErrAppSettingsMissing)
}
@@ -689,6 +693,7 @@ func (a *App) loadConfig(ctx context.Context) (AppConfig, error) {
cfg.ModelRequestURL = nonBlank(strings.TrimRight(strings.TrimSpace(modelRequestURL), "/"), cfg.Collector.CLIProxyURL)
cfg.ProductName = strings.TrimSpace(productName)
cfg.ProductLogo = strings.TrimSpace(productLogo)
+ cfg.APIKeyPrefix = normalizeAPIKeyPrefix(apiKeyPrefix)
return cfg, nil
}
@@ -744,9 +749,9 @@ func (a *App) saveConfig(ctx context.Context, cfg AppConfig) error {
codex_keeper_settings = ?, codex_keeper_priority_rules = ?,
litellm_proxy_enabled = ?, litellm_proxy_url = ?,
model_request_url = ?, session_secret = ?,
- product_name = ?, product_logo = ?, updated_at = ?
+ product_name = ?, product_logo = ?, api_key_prefix = ?, updated_at = ?
WHERE id = 1
- `, cfg.Collector.Enabled, strings.TrimRight(strings.TrimSpace(cfg.Collector.CLIProxyURL), "/"), strings.TrimSpace(cfg.Collector.ManagementKey), strings.TrimSpace(cfg.Collector.QueueName), cfg.Collector.BatchSize, cfg.Collector.PollIntervalSeconds, cfg.Collector.RetryIntervalSeconds, string(keeperBytes), string(rulesBytes), cfg.LiteLLMProxy.Enabled, strings.TrimSpace(cfg.LiteLLMProxy.ProxyURL), strings.TrimRight(strings.TrimSpace(cfg.ModelRequestURL), "/"), cfg.SessionSecret, cfg.ProductName, cfg.ProductLogo, dbTime(time.Now()))
+ `, cfg.Collector.Enabled, strings.TrimRight(strings.TrimSpace(cfg.Collector.CLIProxyURL), "/"), strings.TrimSpace(cfg.Collector.ManagementKey), strings.TrimSpace(cfg.Collector.QueueName), cfg.Collector.BatchSize, cfg.Collector.PollIntervalSeconds, cfg.Collector.RetryIntervalSeconds, string(keeperBytes), string(rulesBytes), cfg.LiteLLMProxy.Enabled, strings.TrimSpace(cfg.LiteLLMProxy.ProxyURL), strings.TrimRight(strings.TrimSpace(cfg.ModelRequestURL), "/"), cfg.SessionSecret, cfg.ProductName, cfg.ProductLogo, normalizeAPIKeyPrefix(cfg.APIKeyPrefix), dbTime(time.Now()))
return err
}
diff --git a/backend/internal/app/auth_settings.go b/backend/internal/app/auth_settings.go
index c8048391..e25ac1cd 100644
--- a/backend/internal/app/auth_settings.go
+++ b/backend/internal/app/auth_settings.go
@@ -267,6 +267,7 @@ type settingsUpdateRequest struct {
RetryIntervalSeconds *float64 `json:"retry_interval_seconds"`
ProductName *string `json:"product_name"`
ProductLogo *string `json:"product_logo"`
+ APIKeyPrefix *string `json:"api_key_prefix"`
}
type modelRequestTestPayload struct {
@@ -365,6 +366,12 @@ func (a *App) handleSettings(w http.ResponseWriter, r *http.Request) error {
}
cfg.ProductLogo = logo
}
+ if payload.APIKeyPrefix != nil {
+ if err := validateAPIKeyPrefix(*payload.APIKeyPrefix); err != nil {
+ return err
+ }
+ cfg.APIKeyPrefix = normalizeAPIKeyPrefix(*payload.APIKeyPrefix)
+ }
if err := a.saveConfig(r.Context(), cfg); err != nil {
return err
}
@@ -389,6 +396,7 @@ func settingsResponse(cfg AppConfig) map[string]any {
"retry_interval_seconds": collector.RetryIntervalSeconds,
"product_name": cfg.ProductName,
"product_logo": cfg.ProductLogo,
+ "api_key_prefix": normalizeAPIKeyPrefix(cfg.APIKeyPrefix),
}
}
diff --git a/backend/internal/app/migrations_test.go b/backend/internal/app/migrations_test.go
index 9f6f487e..bc88d18e 100644
--- a/backend/internal/app/migrations_test.go
+++ b/backend/internal/app/migrations_test.go
@@ -460,6 +460,9 @@ func TestRollbackToPreConsumeRestoresCompatSchema(t *testing.T) {
if testTableExists(t, db, "codex_keeper_quota_resets") {
t.Fatal("head should have dropped codex_keeper_quota_resets")
}
+ if !testColumnExists(t, db, "app_settings", "api_key_prefix") {
+ t.Fatal("head is missing app_settings.api_key_prefix (migration 202609160001)")
+ }
// Rollback: Down to the version the previous binary targets.
const preConsumeVersion int64 = 202609040002
@@ -491,6 +494,9 @@ func TestRollbackToPreConsumeRestoresCompatSchema(t *testing.T) {
t.Fatalf("rollback left codex_keeper_auth_states.%s behind", col)
}
}
+ if testColumnExists(t, db, "app_settings", "api_key_prefix") {
+ t.Fatal("rollback left app_settings.api_key_prefix behind")
+ }
// Replay: from the prod baseline (202609040002) migrate Up to head again — the whole
// release must be re-runnable after a rollback (040002 → head → 040002 → head).
@@ -508,6 +514,7 @@ func TestRollbackToPreConsumeRestoresCompatSchema(t *testing.T) {
!testColumnExists(t, db, "codex_keeper_auth_states", "provider") ||
!testColumnExists(t, db, "codex_keeper_auth_states", "antigravity_quota") ||
!testColumnExists(t, db, "codex_keeper_auth_states", "antigravity_identity_digest") ||
+ !testColumnExists(t, db, "app_settings", "api_key_prefix") ||
!testTableExists(t, db, "codex_keeper_reset_redeems") ||
testTableExists(t, db, "codex_keeper_quota_resets") {
t.Fatal("replay to head did not restore the full head schema")
diff --git a/backend/internal/app/users.go b/backend/internal/app/users.go
index 6291654b..b2a7793c 100644
--- a/backend/internal/app/users.go
+++ b/backend/internal/app/users.go
@@ -12,7 +12,6 @@ import (
"time"
)
-const generatedAPIKeyPrefix = "sk-"
const generatedAPIKeyLength = 52
const generatedAPIKeyAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
@@ -665,9 +664,15 @@ func (a *App) upsertUserAPIKey(ctx context.Context, userID int, apiKeyHash, apiK
}
func (a *App) generateUniqueAPIKey(ctx context.Context) (string, error) {
+ // The prefix is a per-deployment setting (e.g. `sk-cortex`); the default keeps the historical
+ // `sk-...` shape. Only keys minted from now on use it — existing keys are never rewritten.
+ cfg, err := a.loadConfig(ctx)
+ if err != nil {
+ return "", err
+ }
+ prefix := normalizeAPIKeyPrefix(cfg.APIKeyPrefix)
for i := 0; i < 10; i++ {
var builder strings.Builder
- builder.WriteString(generatedAPIKeyPrefix)
for j := 0; j < generatedAPIKeyLength; j++ {
index, err := rand.Int(rand.Reader, big.NewInt(int64(len(generatedAPIKeyAlphabet))))
if err != nil {
@@ -675,7 +680,7 @@ func (a *App) generateUniqueAPIKey(ctx context.Context) (string, error) {
}
builder.WriteByte(generatedAPIKeyAlphabet[index.Int64()])
}
- apiKey := builder.String()
+ apiKey := buildAPIKey(prefix, builder.String())
var count int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_api_keys WHERE api_key_hash = ?`, hashAPIKey(apiKey)).Scan(&count); err != nil {
return "", err
diff --git a/backend/migrations/202609160001_app_settings_api_key_prefix.sql b/backend/migrations/202609160001_app_settings_api_key_prefix.sql
new file mode 100644
index 00000000..4e118cc1
--- /dev/null
+++ b/backend/migrations/202609160001_app_settings_api_key_prefix.sql
@@ -0,0 +1,9 @@
+-- +goose Up
+-- API keys minted by CPA-Helper carry a configurable prefix (e.g. `sk-cortex-...`). The value
+-- stored here is the prefix WITHOUT the joining dash; an empty value means the default `sk`,
+-- so every existing deployment keeps generating `sk-...` keys exactly as before. Only NEW keys
+-- are affected — existing keys are never rewritten.
+ALTER TABLE app_settings ADD COLUMN api_key_prefix TEXT NOT NULL DEFAULT '';
+
+-- +goose Down
+ALTER TABLE app_settings DROP COLUMN api_key_prefix;
diff --git a/backend/migrations/migrations.go b/backend/migrations/migrations.go
index 4623888a..3a1cbf50 100644
--- a/backend/migrations/migrations.go
+++ b/backend/migrations/migrations.go
@@ -3,7 +3,7 @@ package migrations
import "embed"
// LatestVersion is the newest embedded migration version this binary expects.
-const LatestVersion int64 = 202609060005
+const LatestVersion int64 = 202609160001
// FS contains SQL migrations embedded into the application binary.
//
diff --git a/docs/migrations-rollback.md b/docs/migrations-rollback.md
index 9182804c..f54ccc55 100644
--- a/docs/migrations-rollback.md
+++ b/docs/migrations-rollback.md
@@ -6,7 +6,7 @@ version is newer than the binary** (goose reports
`database migration version is newer than this application`). A binary rollback
therefore always requires migrating the schema **down first**.
-## Rolling back the keeper releases (migrations 202609060001–202609060005)
+## Rolling back the keeper releases (migrations 202609060001–202609160001)
This release added, on top of `202609040002`:
@@ -15,6 +15,7 @@ This release added, on top of `202609040002`:
- `202609060003` — `codex_keeper_reset_redeems` (redeem ledger) table.
- `202609060004` — `codex_keeper_auth_states.account_id` column (subscription identity scope).
- `202609060005` — `codex_keeper_auth_states.provider` + `antigravity_quota` + `antigravity_identity_digest` columns (multi-provider inspection: Antigravity accounts; the identity column stores a one-way digest, never the raw project/email). Its Down drops all three columns; no data beyond the Antigravity quota snapshot / provider tag / identity digest is lost.
+- `202609160001` — `app_settings.api_key_prefix` column (configurable prefix for NEWLY generated API keys, e.g. `sk-cortex`; empty = default `sk`). Its Down drops the column; the only thing lost is the configured prefix — existing API keys are never rewritten and keep working.
The previous binary (`a996697`, target version `202609040002`) both refuses to start
against a newer version **and** still `SELECT`s `codex_keeper_quota_resets` in
@@ -44,8 +45,8 @@ against a newer version **and** still `SELECT`s `codex_keeper_quota_resets` in
cpa-helper migrate down-to 202609040002 --allow-pending
```
- This runs the Down migrations for `202609060005`, `202609060004`, `202609060003`,
- `202609060002`, and `202609060001`: it drops the `provider` + `antigravity_quota` + `antigravity_identity_digest` columns,
+ This runs the Down migrations for `202609160001`, `202609060005`, `202609060004`, `202609060003`,
+ `202609060002`, and `202609060001`: it drops the `api_key_prefix` column, drops the `provider` + `antigravity_quota` + `antigravity_identity_digest` columns,
drops the `account_id` column, drops `codex_keeper_reset_redeems`,
drops the `subscription_active_until` column, and **recreates an empty
`codex_keeper_quota_resets`** so the old binary's `/accounts` query works.
diff --git a/frontend/package.json b/frontend/package.json
index 811ae73f..f5931840 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -11,7 +11,8 @@
"test:antigravity-countdown": "node scripts/antigravity-countdown-smoke.mjs",
"test:antigravity-window": "node scripts/antigravity-window-smoke.mjs",
"test:keeper-quota-exhaustion": "node scripts/keeper-quota-exhaustion-smoke.mjs",
- "test:antigravity-quota-format": "node scripts/antigravity-quota-format-smoke.mjs"
+ "test:antigravity-quota-format": "node scripts/antigravity-quota-format-smoke.mjs",
+ "test:mask-api-key": "node scripts/mask-api-key-smoke.mjs"
},
"dependencies": {
"echarts": "^5.5.1",
diff --git a/frontend/scripts/i18n-smoke.mjs b/frontend/scripts/i18n-smoke.mjs
index 4de958f6..bd8c20c8 100644
--- a/frontend/scripts/i18n-smoke.mjs
+++ b/frontend/scripts/i18n-smoke.mjs
@@ -171,6 +171,14 @@ try {
localizedServerMessage('Antigravity 配额读取失败'),
'Failed to read Antigravity quota',
)
+ assert.equal(
+ localizedServerMessage('api_key_prefix 超出最大长度 32'),
+ 'api_key_prefix exceeds the maximum length of 32',
+ )
+ assert.equal(
+ localizedServerMessage('api_key_prefix 只能包含字母、数字、- 和 _,且不能以 - 开头或结尾'),
+ 'api_key_prefix may only contain letters, digits, - and _, and must not start or end with -',
+ )
assert.equal(
localizedServerMessage('antigravity@example.com.json:Antigravity 配额刷新成功(2 组)'),
'antigravity@example.com.json: Antigravity quota refreshed (2 groups)',
diff --git a/frontend/scripts/mask-api-key-smoke.mjs b/frontend/scripts/mask-api-key-smoke.mjs
new file mode 100644
index 00000000..c4532566
--- /dev/null
+++ b/frontend/scripts/mask-api-key-smoke.mjs
@@ -0,0 +1,46 @@
+import assert from 'node:assert/strict'
+import { fileURLToPath } from 'node:url'
+
+import { createServer } from 'vite'
+
+const root = fileURLToPath(new URL('..', import.meta.url))
+const server = await createServer({ root, logLevel: 'error', server: { middlewareMode: true } })
+
+try {
+ const { maskApiKey } = await server.ssrLoadModule('/src/shared/utils/maskApiKey.ts')
+ const secret = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' // 52 chars, like generated keys
+
+ // Default prefix: the whole prefix + dash stays visible, secret masked, last 4 kept.
+ const sk = maskApiKey(`sk-${secret}`)
+ assert.equal(sk.length, `sk-${secret}`.length)
+ assert.ok(sk.startsWith('sk-*'), sk)
+ assert.ok(sk.endsWith('WXYZ'), sk)
+ assert.ok(!sk.includes('abcd'), 'secret head must be masked')
+
+ // Custom multi-segment prefix (the reason this helper exists): `sk-cortex-` stays readable.
+ const cortex = maskApiKey(`sk-cortex-${secret}`)
+ assert.equal(cortex.length, `sk-cortex-${secret}`.length)
+ assert.ok(cortex.startsWith('sk-cortex-*'), cortex)
+ assert.ok(cortex.endsWith('WXYZ'), cortex)
+
+ // A prefix containing '_' and digits.
+ assert.ok(maskApiKey(`team_42-${secret}`).startsWith('team_42-*'))
+
+ // Foreign key with no dash: short fixed head, still at least 8 masked, same length.
+ const foreign = maskApiKey('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123')
+ assert.equal(foreign.length, 30)
+ assert.equal(foreign.slice(0, 6), 'ABCDEF')
+ assert.ok(/^\*{8,}/.test(foreign.slice(6, -4)))
+
+ // A dash placed too late must not expose the secret: prefix is capped so ≥ 8 chars are masked.
+ const lateDash = maskApiKey('abcdefghijklmnop-xyz1')
+ assert.equal(lateDash.length, 21)
+ assert.ok(lateDash.slice(-4) === 'xyz1' && (lateDash.match(/\*/g) || []).length >= 8, lateDash)
+
+ // Short keys keep the legacy 3-visible behaviour.
+ assert.equal(maskApiKey('sk-short1234'), 'sk-*********')
+
+ console.log('mask-api-key-smoke: OK')
+} finally {
+ await server.close()
+}
diff --git a/frontend/src/features/api-keys/views/ApiKeysView.vue b/frontend/src/features/api-keys/views/ApiKeysView.vue
index f579414f..192bb878 100644
--- a/frontend/src/features/api-keys/views/ApiKeysView.vue
+++ b/frontend/src/features/api-keys/views/ApiKeysView.vue
@@ -53,6 +53,7 @@ import type {
import { useI18n } from '@/shared/i18n'
import { copyToClipboard } from '@/shared/utils/clipboard'
import { formatCompact, formatDateTime, formatInteger, formatUsd } from '@/shared/utils/format'
+import { maskApiKey } from '@/shared/utils/maskApiKey'
const message = useMessage()
const dialog = useDialog()
@@ -364,13 +365,7 @@ function maskDisplayedApiKey(apiKey: string | null | undefined): string {
if (!apiKey) {
return t('未知', 'Unknown')
}
- if (apiKey.length <= 12) {
- return `${apiKey.slice(0, 3)}${'*'.repeat(Math.max(apiKey.length - 3, 0))}`
- }
- const visiblePrefix = apiKey.startsWith('sk-') ? 4 : 6
- const visibleSuffix = 4
- const maskedLength = Math.max(apiKey.length - visiblePrefix - visibleSuffix, 8)
- return `${apiKey.slice(0, visiblePrefix)}${'*'.repeat(maskedLength)}${apiKey.slice(-visibleSuffix)}`
+ return maskApiKey(apiKey)
}
function renderMaskedKeyTitle() {
diff --git a/frontend/src/features/settings/views/SettingsView.vue b/frontend/src/features/settings/views/SettingsView.vue
index e73a3af3..7d994b9c 100644
--- a/frontend/src/features/settings/views/SettingsView.vue
+++ b/frontend/src/features/settings/views/SettingsView.vue
@@ -42,8 +42,11 @@ const settingsForm = reactive({
retry_interval_seconds: 10,
product_name: '',
product_logo: '',
+ api_key_prefix: 'sk',
})
+const apiKeyPrefixPreview = computed(() => `${(settingsForm.api_key_prefix || 'sk').trim() || 'sk'}-xxxxxxxx…`)
+
const logoPreview = computed(() => settingsForm.product_logo || null)
function handleLogoFile(event: Event) {
@@ -108,6 +111,7 @@ async function refresh() {
settingsForm.retry_interval_seconds = settings.retry_interval_seconds
settingsForm.product_name = settings.product_name ?? ''
settingsForm.product_logo = settings.product_logo ?? ''
+ settingsForm.api_key_prefix = settings.api_key_prefix || 'sk'
collectorStatus.value = status
} catch (error) {
message.error(errorText(error, '加载设置失败', 'Failed to load settings'))
@@ -129,9 +133,11 @@ async function saveSettings() {
retry_interval_seconds: settingsForm.retry_interval_seconds,
product_name: settingsForm.product_name,
product_logo: settingsForm.product_logo,
+ api_key_prefix: settingsForm.api_key_prefix,
}
const saved = await updateSettings(payload)
settingsForm.management_key = saved.management_key
+ settingsForm.api_key_prefix = saved.api_key_prefix || 'sk'
setProductInfo(saved.product_name ?? '', saved.product_logo ?? '')
message.success(t('设置已保存', 'Settings saved'))
await refresh()
@@ -278,6 +284,28 @@ onMounted(refresh)
+
+
+
{{ t('API KEY 设置', 'API Key Settings') }}
+
+
+
+
{{ t('API KEY 前缀', 'API key prefix') }}
+
+
+ {{ t(`新生成的 API KEY 形如 ${apiKeyPrefixPreview}。只允许字母、数字、- 和 _,不能以 - 开头或结尾;只影响之后新建的 KEY,已有 KEY 不变。`, `New API keys look like ${apiKeyPrefixPreview}. Letters, digits, - and _ only; must not start or end with -. Only affects keys created from now on; existing keys are unchanged.`) }}
+
+
+
+
+
+
+
{{ t('产品信息', 'Product Branding') }}
diff --git a/frontend/src/shared/i18n/messages.ts b/frontend/src/shared/i18n/messages.ts
index 8e444344..242d1f46 100644
--- a/frontend/src/shared/i18n/messages.ts
+++ b/frontend/src/shared/i18n/messages.ts
@@ -44,6 +44,8 @@ const exactServerMessages: MessagePair[] = [
['账号身份冲突:列表与详情的 account_id/auth_index 不一致,已保留原快照', 'Account identity conflict: the list and detail disagree on account_id/auth_index; the previous snapshot was preserved.'],
['账号身份冲突:Antigravity 列表与详情的 name/type/auth_index/project_id/email 不一致,已保留原快照', 'Account identity conflict: the Antigravity list and detail disagree on name/type/auth_index/project_id/email; the previous snapshot was preserved.'],
['Antigravity 配额读取失败', 'Failed to read Antigravity quota'],
+ ['api_key_prefix 超出最大长度 32', 'api_key_prefix exceeds the maximum length of 32'],
+ ['api_key_prefix 只能包含字母、数字、- 和 _,且不能以 - 开头或结尾', 'api_key_prefix may only contain letters, digits, - and _, and must not start or end with -'],
['无法确认可用重置额度(快照未知),请刷新后重试', 'Cannot confirm available reset credits (snapshot unknown). Refresh and try again.'],
['核销主动重置额度失败:网络异常,未确认是否已核销', 'Failed to redeem the reset credit: network error; redemption is unconfirmed.'],
['核销主动重置额度失败:管理接口异常', 'Failed to redeem the reset credit: management API error.'],
diff --git a/frontend/src/shared/types/api.ts b/frontend/src/shared/types/api.ts
index 277acf05..5d0b3c3d 100644
--- a/frontend/src/shared/types/api.ts
+++ b/frontend/src/shared/types/api.ts
@@ -39,6 +39,7 @@ export interface SettingsResponse {
retry_interval_seconds: number
product_name: string
product_logo: string
+ api_key_prefix: string
}
export interface SettingsUpdatePayload {
@@ -52,6 +53,7 @@ export interface SettingsUpdatePayload {
retry_interval_seconds?: number
product_name?: string
product_logo?: string
+ api_key_prefix?: string
}
export interface ModelRequestGuide {
diff --git a/frontend/src/shared/utils/maskApiKey.ts b/frontend/src/shared/utils/maskApiKey.ts
new file mode 100644
index 00000000..86d674a2
--- /dev/null
+++ b/frontend/src/shared/utils/maskApiKey.ts
@@ -0,0 +1,21 @@
+// maskApiKey hides the secret part of an API key while keeping its prefix recognisable.
+//
+// Keys minted by CPA-Helper are `-` where the random alphabet never contains
+// `-`, so the LAST `-` reliably separates the (possibly multi-segment, configurable) prefix
+// from the secret — `sk-…`, `sk-cortex-…`, or any custom prefix all mask correctly without the
+// UI having to know the configured prefix. Keys with no `-` (foreign / observed keys) fall back
+// to a short fixed head. The output always has the same length as the input, and at least 8
+// characters are masked for any key longer than 12.
+export function maskApiKey(apiKey: string): string {
+ if (apiKey.length <= 12) {
+ return `${apiKey.slice(0, 3)}${'*'.repeat(Math.max(apiKey.length - 3, 0))}`
+ }
+ const visibleSuffix = 4
+ const minMasked = 8
+ const dash = apiKey.lastIndexOf('-')
+ const wantedPrefix = dash > 0 ? dash + 1 : 6
+ const maxPrefix = apiKey.length - visibleSuffix - minMasked
+ const visiblePrefix = Math.max(1, Math.min(wantedPrefix, maxPrefix))
+ const maskedLength = apiKey.length - visiblePrefix - visibleSuffix
+ return `${apiKey.slice(0, visiblePrefix)}${'*'.repeat(maskedLength)}${apiKey.slice(-visibleSuffix)}`
+}
From ad01478f912389f23ed92012221e4ac2220c519f Mon Sep 17 00:00:00 2001
From: Jiacheng
Date: Thu, 17 Sep 2026 15:57:09 +0800
Subject: [PATCH 21/25] ci(frontend): run lint and all smoke tests in CI and
the Docker build (#15)
The frontend smoke scripts (i18n, Antigravity countdown/window/quota-format,
quota-exhaustion, API-key masking) were only run by hand. Add an aggregate
`test:smoke` script and run `npm run lint` + `npm run test:smoke` before the
frontend build in both the release workflow and the Dockerfile frontend stage,
so a regression blocks the release. No runtime behaviour change.
Signed-off-by: feiniu
Co-authored-by: feiniu (Raft agent)
Co-authored-by: Claude Fable 5.1
---
.github/workflows/build-and-release.yml | 9 +++++++++
Dockerfile | 2 ++
frontend/package.json | 3 ++-
3 files changed, 13 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml
index cbda8345..33187f10 100644
--- a/.github/workflows/build-and-release.yml
+++ b/.github/workflows/build-and-release.yml
@@ -142,6 +142,15 @@ jobs:
working-directory: frontend
run: npm ci --prefer-offline
+ # Frontend gates: lint plus every smoke script (i18n, Antigravity countdown/window/
+ # quota-format, quota-exhaustion, API-key masking). These used to be run by hand only;
+ # a failure here now blocks the release build.
+ - name: Frontend gates (lint + smoke tests)
+ working-directory: frontend
+ run: |
+ npm run lint
+ npm run test:smoke
+
- name: Build frontend
working-directory: frontend
run: npm run build
diff --git a/Dockerfile b/Dockerfile
index 67dcebe1..e4ba49cb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -10,6 +10,8 @@ RUN --mount=type=cache,id=cpa-helper-npm,target=/root/.npm,sharing=locked \
COPY VERSION ../VERSION
COPY frontend/ ./
+# Frontend gates (lint + all smoke scripts) run before the build so a regression fails the image.
+RUN npm run lint && npm run test:smoke
RUN npm run build
diff --git a/frontend/package.json b/frontend/package.json
index f5931840..60a979d5 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,7 +12,8 @@
"test:antigravity-window": "node scripts/antigravity-window-smoke.mjs",
"test:keeper-quota-exhaustion": "node scripts/keeper-quota-exhaustion-smoke.mjs",
"test:antigravity-quota-format": "node scripts/antigravity-quota-format-smoke.mjs",
- "test:mask-api-key": "node scripts/mask-api-key-smoke.mjs"
+ "test:mask-api-key": "node scripts/mask-api-key-smoke.mjs",
+ "test:smoke": "npm run test:i18n && npm run test:antigravity-countdown && npm run test:antigravity-window && npm run test:keeper-quota-exhaustion && npm run test:antigravity-quota-format && npm run test:mask-api-key"
},
"dependencies": {
"echarts": "^5.5.1",
From 994254cdd3d60ee6be03d62ecf478a0afa3bf2f4 Mon Sep 17 00:00:00 2001
From: Jiacheng
Date: Thu, 17 Sep 2026 18:37:41 +0800
Subject: [PATCH 22/25] feat(pricing): associate reverse-proxied model variants
with canonical prices (#16)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Usage from reverse-proxied accounts is reported under the proxy's provider
name and often a reasoning-tier suffix (provider `antigravity`, model
`gemini-3.8-flash-high`), while the price list synced from LiteLLM only knows
the canonical vendor entry (provider `gemini`, model
`gemini/gemini-3.8-flash`). Price lookup was exact-match only, so these models
were never priced.
findMatchingPrice now falls back through ordered candidates, most specific
first; an exact (provider, model) price always wins, so a manual price can
override any association:
- model: the name itself, then progressively stripped variant suffixes
(-thinking, -minimal, -low, -medium, -high, -xhigh), which do not change the
per-token price;
- provider: itself, then its canonical vendor (codex→openai, claude→anthropic,
gemini-cli/aistudio→gemini, antigravity→gemini/anthropic/openai by model
family);
- each pair is also tried with LiteLLM's `/` key form.
No schema change. Cost is computed at query time, so existing history is priced
as soon as this ships. All callers go through findMatchingPrice (usage cost,
quota charging, model catalog), so they stay consistent.
Signed-off-by: feiniu
Co-authored-by: feiniu (Raft agent)
Co-authored-by: Claude Fable 5.1
---
backend/internal/app/pricing.go | 21 ++--
backend/internal/app/pricing_association.go | 75 ++++++++++++
.../app/pricing_association_internal_test.go | 108 ++++++++++++++++++
3 files changed, 194 insertions(+), 10 deletions(-)
create mode 100644 backend/internal/app/pricing_association.go
create mode 100644 backend/internal/app/pricing_association_internal_test.go
diff --git a/backend/internal/app/pricing.go b/backend/internal/app/pricing.go
index 979bfe9c..05a3ba87 100644
--- a/backend/internal/app/pricing.go
+++ b/backend/internal/app/pricing.go
@@ -769,16 +769,17 @@ func findMatchingPrice(prices map[[2]string]ModelPrice, provider, model *string)
if providerKey == "" || modelKey == "" {
return nil
}
- candidates := []string{providerKey}
- if providerKey == "codex" {
- candidates = append(candidates, "openai")
- }
- if providerKey == "claude" {
- candidates = append(candidates, "anthropic")
- }
- for _, candidate := range candidates {
- if price, ok := prices[[2]string{candidate, modelKey}]; ok {
- return &price
+ // An exact (provider, model) price always wins; the association fallbacks below only apply
+ // when nothing matches exactly, so a manually created exact price can always override them.
+ for _, modelCandidate := range priceModelCandidates(modelKey) {
+ for _, providerCandidate := range priceProviderCandidates(providerKey, modelCandidate) {
+ if price, ok := prices[[2]string{providerCandidate, modelCandidate}]; ok {
+ return &price
+ }
+ // LiteLLM keys many models as "/" (e.g. gemini/gemini-3.8-flash).
+ if price, ok := prices[[2]string{providerCandidate, providerCandidate + "/" + modelCandidate}]; ok {
+ return &price
+ }
}
}
return nil
diff --git a/backend/internal/app/pricing_association.go b/backend/internal/app/pricing_association.go
new file mode 100644
index 00000000..f0ee1b19
--- /dev/null
+++ b/backend/internal/app/pricing_association.go
@@ -0,0 +1,75 @@
+package app
+
+import "strings"
+
+// Price association: reverse-proxied models are reported under the proxy's own provider name and
+// often with a reasoning-tier suffix (provider "antigravity", model "gemini-3.8-flash-high"),
+// while the price list — typically synced from LiteLLM — knows the canonical vendor entry
+// (provider "gemini", model "gemini/gemini-3.8-flash"). These helpers produce the ordered lookup
+// candidates that associate the former with the latter. Order is most-specific first, and the
+// caller tries every provider candidate for one model candidate before relaxing the model name.
+
+// priceModelVariantSuffixes are request-variant suffixes that select a reasoning tier / mode but
+// do not change the per-token price, so a variant may fall back to its base model's price.
+var priceModelVariantSuffixes = []string{"-thinking", "-minimal", "-low", "-medium", "-high", "-xhigh"}
+
+// priceModelCandidates returns the model itself followed by progressively stripped base names
+// ("x-thinking-high" → "x-thinking" → "x"). Input must already be lower-cased and trimmed.
+func priceModelCandidates(model string) []string {
+ candidates := []string{model}
+ current := model
+ for i := 0; i < 3; i++ {
+ stripped := current
+ for _, suffix := range priceModelVariantSuffixes {
+ if strings.HasSuffix(current, suffix) && len(current) > len(suffix) {
+ stripped = strings.TrimSuffix(current, suffix)
+ break
+ }
+ }
+ if stripped == current {
+ break
+ }
+ candidates = append(candidates, stripped)
+ current = stripped
+ }
+ return candidates
+}
+
+// priceProviderCandidates returns the provider itself followed by the canonical vendors whose
+// price entries it may use. Multi-vendor reverse proxies (antigravity) are resolved by the model
+// family. Input must already be lower-cased and trimmed.
+func priceProviderCandidates(provider, model string) []string {
+ candidates := []string{provider}
+ add := func(values ...string) {
+ for _, value := range values {
+ duplicate := false
+ for _, existing := range candidates {
+ if existing == value {
+ duplicate = true
+ break
+ }
+ }
+ if !duplicate {
+ candidates = append(candidates, value)
+ }
+ }
+ }
+ switch provider {
+ case "codex":
+ add("openai")
+ case "claude":
+ add("anthropic")
+ case "gemini-cli", "aistudio":
+ add("gemini")
+ case "antigravity":
+ switch {
+ case strings.HasPrefix(model, "gemini"):
+ add("gemini")
+ case strings.HasPrefix(model, "claude"):
+ add("anthropic")
+ case strings.HasPrefix(model, "gpt"), strings.HasPrefix(model, "o1"), strings.HasPrefix(model, "o3"), strings.HasPrefix(model, "o4"):
+ add("openai")
+ }
+ }
+ return candidates
+}
diff --git a/backend/internal/app/pricing_association_internal_test.go b/backend/internal/app/pricing_association_internal_test.go
new file mode 100644
index 00000000..20aa33cb
--- /dev/null
+++ b/backend/internal/app/pricing_association_internal_test.go
@@ -0,0 +1,108 @@
+package app
+
+import (
+ "reflect"
+ "testing"
+)
+
+func priceTable(entries ...[3]any) map[[2]string]ModelPrice {
+ table := map[[2]string]ModelPrice{}
+ for _, entry := range entries {
+ provider, model, input := entry[0].(string), entry[1].(string), entry[2].(float64)
+ table[priceKey(provider, model)] = ModelPrice{Provider: provider, Model: model, InputUSDPerMillion: input}
+ }
+ return table
+}
+
+func matchInput(t *testing.T, prices map[[2]string]ModelPrice, provider, model string) (float64, bool) {
+ t.Helper()
+ price := findMatchingPrice(prices, &provider, &model)
+ if price == nil {
+ return 0, false
+ }
+ return price.InputUSDPerMillion, true
+}
+
+func TestFindMatchingPriceAssociatesReverseProxiedVariantWithLiteLLMEntry(t *testing.T) {
+ // The reported field case: antigravity + tiered model vs LiteLLM's gemini/ key.
+ prices := priceTable([3]any{"gemini", "gemini/gemini-3.8-flash", 0.3})
+ for _, model := range []string{"gemini-3.8-flash-high", "gemini-3.8-flash-low", "gemini-3.8-flash", "Gemini-3.8-Flash-HIGH "} {
+ if got, ok := matchInput(t, prices, "antigravity", model); !ok || got != 0.3 {
+ t.Fatalf("antigravity/%q → %v,%v; want 0.3", model, got, ok)
+ }
+ }
+ // Other gemini reverse-proxy providers associate the same way.
+ for _, provider := range []string{"gemini-cli", "aistudio", "gemini"} {
+ if got, ok := matchInput(t, prices, provider, "gemini-3.8-flash-high"); !ok || got != 0.3 {
+ t.Fatalf("%s → %v,%v; want 0.3", provider, got, ok)
+ }
+ }
+}
+
+func TestFindMatchingPriceExactAlwaysWinsOverAssociation(t *testing.T) {
+ prices := priceTable(
+ [3]any{"gemini", "gemini/gemini-3.8-flash", 0.3},
+ [3]any{"gemini", "gemini-3.8-flash-high", 0.9}, // exact tiered model on the alias provider
+ [3]any{"antigravity", "gemini-3.8-flash-high", 1.5}, // exact provider + model (manual override)
+ )
+ if got, _ := matchInput(t, prices, "antigravity", "gemini-3.8-flash-high"); got != 1.5 {
+ t.Fatalf("exact provider+model must win, got %v", got)
+ }
+ delete(prices, priceKey("antigravity", "gemini-3.8-flash-high"))
+ if got, _ := matchInput(t, prices, "antigravity", "gemini-3.8-flash-high"); got != 0.9 {
+ t.Fatalf("exact model on the associated provider must beat the stripped base model, got %v", got)
+ }
+}
+
+func TestFindMatchingPriceAntigravityResolvesVendorByModelFamily(t *testing.T) {
+ prices := priceTable(
+ [3]any{"anthropic", "claude-sonnet-4-5", 3.0},
+ [3]any{"openai", "gpt-5.2", 1.25},
+ [3]any{"gemini", "gemini/gemini-3.8-flash", 0.3},
+ )
+ if got, ok := matchInput(t, prices, "antigravity", "claude-sonnet-4-5-thinking"); !ok || got != 3.0 {
+ t.Fatalf("claude via antigravity → %v,%v", got, ok)
+ }
+ if got, ok := matchInput(t, prices, "antigravity", "gpt-5.2-high"); !ok || got != 1.25 {
+ t.Fatalf("gpt via antigravity → %v,%v", got, ok)
+ }
+ // A gemini model must never borrow another vendor's price, and unknown families stay unpriced.
+ if _, ok := matchInput(t, priceTable([3]any{"openai", "gemini-3.8-flash", 9.0}), "antigravity", "gemini-3.8-flash-high"); ok {
+ t.Fatal("gemini model must not match an openai price")
+ }
+ if _, ok := matchInput(t, prices, "antigravity", "mystery-model-high"); ok {
+ t.Fatal("unknown model family must stay unpriced")
+ }
+}
+
+func TestFindMatchingPriceKeepsExistingBehaviour(t *testing.T) {
+ prices := priceTable([3]any{"openai", "gpt-5.2", 1.25}, [3]any{"anthropic", "claude-sonnet-4-5", 3.0})
+ if got, ok := matchInput(t, prices, "codex", "gpt-5.2"); !ok || got != 1.25 {
+ t.Fatalf("codex→openai alias regressed: %v,%v", got, ok)
+ }
+ if got, ok := matchInput(t, prices, "claude", "claude-sonnet-4-5"); !ok || got != 3.0 {
+ t.Fatalf("claude→anthropic alias regressed: %v,%v", got, ok)
+ }
+ // No cross-provider leakage for providers without an association.
+ if _, ok := matchInput(t, prices, "kimi", "gpt-5.2"); ok {
+ t.Fatal("unrelated provider must not match")
+ }
+ // A suffix is only stripped when something remains, and non-variant names are untouched.
+ if _, ok := matchInput(t, priceTable([3]any{"openai", "", 1.0}), "openai", "-high"); ok {
+ t.Fatal("bare suffix must not match")
+ }
+ if nilPrice := findMatchingPrice(prices, nil, nil); nilPrice != nil {
+ t.Fatal("nil inputs must not match")
+ }
+}
+
+func TestPriceModelCandidatesOrder(t *testing.T) {
+ got := priceModelCandidates("claude-opus-4-5-thinking-high")
+ want := []string{"claude-opus-4-5-thinking-high", "claude-opus-4-5-thinking", "claude-opus-4-5"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("candidates = %v, want %v", got, want)
+ }
+ if got := priceModelCandidates("gpt-5.2"); !reflect.DeepEqual(got, []string{"gpt-5.2"}) {
+ t.Fatalf("plain model candidates = %v", got)
+ }
+}
From 533d3e462890dcc11e3364ee766708fe4245fe00 Mon Sep 17 00:00:00 2001
From: Jiacheng
Date: Sun, 20 Sep 2026 16:36:49 +0800
Subject: [PATCH 23/25] feat(cli): read-only usage-cost subcommand (cost
grouped by model/provider/endpoint/source-account) (#19)
* feat(cli): read-only usage-cost subcommand reporting cost grouped by model/provider/endpoint/source-account
Prices records with the production recordCost derivation via a narrow
export so the report cannot drift from what was recorded; unpriced
records are reported separately and never read as free. --db defaults
to the service database path, overridable for analysis of other copies.
Golden vectors pin the wired cost function against production.
* fix(cli): bind dbTime-layout string for usage-cost --since window
Review finding (PR #19): LoadRecords bound since.UTC() as time.Time; the
driver serialises it space-separated UTC while production writes timestamps
as 'T'-separated Asia/Shanghai text, and ' ' < 'T' made the lexicographic
>= admit rows up to ~16h older than --since. Export app.UsageDBTime so the
reader binds the same byte shape production writes, and make fixtures write
timestamps through the same helper so the test exercises the real byte
layout instead of the reader's own.
---------
Co-authored-by: feiniu
Co-authored-by: feiniu (Raft agent)
---
backend/cmd/cpa-helper/main.go | 8 +
backend/internal/app/usage_cost_export.go | 32 ++
backend/internal/usagecost/aggregate.go | 98 +++++
backend/internal/usagecost/aggregate_test.go | 106 ++++++
backend/internal/usagecost/golden_test.go | 136 +++++++
backend/internal/usagecost/options.go | 133 +++++++
backend/internal/usagecost/options_test.go | 103 ++++++
backend/internal/usagecost/render.go | 92 +++++
backend/internal/usagecost/render_test.go | 97 +++++
backend/internal/usagecost/run.go | 40 +++
backend/internal/usagecost/store.go | 129 +++++++
backend/internal/usagecost/store_test.go | 290 +++++++++++++++
.../usagecost/testdata/cost_golden.json | 338 ++++++++++++++++++
backend/internal/usagecost/types.go | 52 +++
backend/internal/usagecost/wiring.go | 33 ++
15 files changed, 1687 insertions(+)
create mode 100644 backend/internal/app/usage_cost_export.go
create mode 100644 backend/internal/usagecost/aggregate.go
create mode 100644 backend/internal/usagecost/aggregate_test.go
create mode 100644 backend/internal/usagecost/golden_test.go
create mode 100644 backend/internal/usagecost/options.go
create mode 100644 backend/internal/usagecost/options_test.go
create mode 100644 backend/internal/usagecost/render.go
create mode 100644 backend/internal/usagecost/render_test.go
create mode 100644 backend/internal/usagecost/run.go
create mode 100644 backend/internal/usagecost/store.go
create mode 100644 backend/internal/usagecost/store_test.go
create mode 100644 backend/internal/usagecost/testdata/cost_golden.json
create mode 100644 backend/internal/usagecost/types.go
create mode 100644 backend/internal/usagecost/wiring.go
diff --git a/backend/cmd/cpa-helper/main.go b/backend/cmd/cpa-helper/main.go
index cf2557e2..22a7e96a 100644
--- a/backend/cmd/cpa-helper/main.go
+++ b/backend/cmd/cpa-helper/main.go
@@ -12,6 +12,7 @@ import (
backendApp "cpa-helper/backend/internal/app"
"cpa-helper/backend/internal/httpserver"
+ "cpa-helper/backend/internal/usagecost"
)
func main() {
@@ -87,6 +88,10 @@ func run(ctx context.Context, args []string, stdout io.Writer) error {
}
fmt.Fprintf(stdout, "ready: db=%s current_version=%d target_version=%d\n", report.DBPath, report.CurrentVersion, report.TargetVersion)
return nil
+ case "usage-cost":
+ // Read-only cost report over the same database the service writes to;
+ // --db only overrides where it reads, never what it records.
+ return usagecost.Run(ctx, args[1:], stdout)
case "help", "-h", "--help":
printUsage(stdout)
return nil
@@ -150,5 +155,8 @@ func printUsage(w io.Writer) {
Roll the schema DOWN to an allowlisted version (destructive)
cpa-helper serve Start only after read-only startup checks pass
cpa-helper doctor Run read-only startup checks and exit
+ cpa-helper usage-cost [--db path] --group-by model|provider|endpoint|source-account
+ --since [--json]
+ Report usage cost over recorded usage (read-only)
`)
}
diff --git a/backend/internal/app/usage_cost_export.go b/backend/internal/app/usage_cost_export.go
new file mode 100644
index 00000000..445c3111
--- /dev/null
+++ b/backend/internal/app/usage_cost_export.go
@@ -0,0 +1,32 @@
+package app
+
+import "time"
+
+// UsageRecordCost exports recordCost so the `cpa-helper usage-cost` subcommand
+// (internal/usagecost) can price records with the SAME derivation production
+// uses -- the report must reproduce production's number, and a copied
+// implementation would drift silently. The seam is narrow on purpose: only
+// the record->cost question is exported, not the matching internals.
+func UsageRecordCost(record UsageRecord, prices map[[2]string]ModelPrice) (usd float64, unpriced bool) {
+ return recordCost(record, prices)
+}
+
+// UsageDBTime formats a timestamp the way production writes it to TEXT columns
+// (dbTime(): Asia/Shanghai offset, 'T' separator). Read-side comparisons against
+// those columns must bind this exact byte shape -- binding a time.Time lets the
+// driver serialise it differently (space separator, UTC) and a lexicographic
+// comparison then stops being a time comparison.
+func UsageDBTime(t time.Time) string {
+ return dbTime(t)
+}
+
+// UsageDBPath resolves the database path the same way the service does
+// (CPA_HELPER_DATA_DIR, else /data) so `usage-cost` defaults to the
+// database the service actually writes to.
+func UsageDBPath() (string, error) {
+ paths, err := resolveRuntimePaths()
+ if err != nil {
+ return "", err
+ }
+ return paths.DBPath, nil
+}
diff --git a/backend/internal/usagecost/aggregate.go b/backend/internal/usagecost/aggregate.go
new file mode 100644
index 00000000..256d1328
--- /dev/null
+++ b/backend/internal/usagecost/aggregate.go
@@ -0,0 +1,98 @@
+package usagecost
+
+import "sort"
+
+// Group is one row of the report.
+type Group struct {
+ Key string `json:"key"`
+ // Requests counts every record in the group, Failed the subset that failed.
+ // Both are reported because a group's cost is only interpretable next to how
+ // many calls produced it.
+ Requests int `json:"requests"`
+ Failed int `json:"failed"`
+ // TotalTokens is summed from the records, not recomputed from the parts.
+ TotalTokens int64 `json:"total_tokens"`
+ // CostUSD covers only the PRICED records in this group.
+ CostUSD float64 `json:"cost_usd"`
+ // UnpricedRequests counts records that consumed something billable but
+ // matched no price. They contribute 0 to CostUSD, so without this column a
+ // group with no prices configured is indistinguishable from a free one.
+ UnpricedRequests int `json:"unpriced_requests"`
+}
+
+// Report is the whole answer, including what was asked for.
+type Report struct {
+ GroupBy string `json:"group_by"`
+ SinceDays int `json:"since_days"`
+ Since string `json:"since"`
+ Groups []Group `json:"groups"`
+ // TotalCostUSD and TotalUnpriced are summed over groups so a reader never
+ // has to add the column up by hand and get a different answer.
+ TotalCostUSD float64 `json:"total_cost_usd"`
+ TotalUnpriced int `json:"total_unpriced_requests"`
+}
+
+// unattributed labels records whose grouping dimension is NULL or blank.
+// They are kept in the report rather than dropped: silently discarding them
+// would make the report's total disagree with the database's, and nobody would
+// see why.
+const unattributed = "(unattributed)"
+
+// Aggregate groups records and costs them. It returns ErrNoCostFunc when no
+// pricing implementation is available, rather than a report full of zeros.
+func Aggregate(records []Record, prices map[PriceKey]ModelPrice, by GroupBy, cost CostFunc) ([]Group, error) {
+ if cost == nil {
+ return nil, ErrNoCostFunc
+ }
+ byKey := map[string]*Group{}
+ for _, record := range records {
+ key := groupKey(record, by)
+ group, ok := byKey[key]
+ if !ok {
+ group = &Group{Key: key}
+ byKey[key] = group
+ }
+ group.Requests++
+ if record.Failed {
+ group.Failed++
+ }
+ group.TotalTokens += int64(record.TotalTokens)
+ usd, unpriced := cost(record, prices)
+ if unpriced {
+ group.UnpricedRequests++
+ continue
+ }
+ group.CostUSD += usd
+ }
+ groups := make([]Group, 0, len(byKey))
+ for _, group := range byKey {
+ groups = append(groups, *group)
+ }
+ // Most expensive first; ties broken by key so the output is stable and two
+ // runs over the same data can be diffed.
+ sort.Slice(groups, func(i, j int) bool {
+ if groups[i].CostUSD != groups[j].CostUSD {
+ return groups[i].CostUSD > groups[j].CostUSD
+ }
+ return groups[i].Key < groups[j].Key
+ })
+ return groups, nil
+}
+
+func groupKey(record Record, by GroupBy) string {
+ var value *string
+ switch by {
+ case GroupByModel:
+ value = record.Model
+ case GroupByProvider:
+ value = record.Provider
+ case GroupByEndpoint:
+ value = record.Endpoint
+ case GroupBySourceAccount:
+ value = record.SourceAccount
+ }
+ if value == nil || *value == "" {
+ return unattributed
+ }
+ return *value
+}
diff --git a/backend/internal/usagecost/aggregate_test.go b/backend/internal/usagecost/aggregate_test.go
new file mode 100644
index 00000000..59dfca19
--- /dev/null
+++ b/backend/internal/usagecost/aggregate_test.go
@@ -0,0 +1,106 @@
+package usagecost
+
+import (
+ "errors"
+ "testing"
+)
+
+func strptr(s string) *string { return &s }
+
+// fixedCost is a test-only pricing stub. It exists so the aggregation layer can
+// be tested WITHOUT deciding where the real derivation comes from -- and it is
+// deliberately trivial (1 USD per priced request) so any arithmetic asserted
+// below is the aggregator's, not the stub's.
+func fixedCost(record Record, prices map[PriceKey]ModelPrice) (float64, bool) {
+ if record.Model == nil {
+ return 0, record.TotalTokens > 0
+ }
+ if _, ok := prices[PriceKey{"p", *record.Model}]; !ok {
+ return 0, record.TotalTokens > 0
+ }
+ return 1, false
+}
+
+var testPrices = map[PriceKey]ModelPrice{{"p", "priced"}: {}}
+
+func TestAggregateWithoutACostFuncRefusesInsteadOfReportingZero(t *testing.T) {
+ // The whole point of the seam: "I cannot price this" must never leave the
+ // package looking like "this cost nothing".
+ _, err := Aggregate([]Record{{Model: strptr("priced"), TotalTokens: 10}}, testPrices, GroupByModel, nil)
+ if !errors.Is(err, ErrNoCostFunc) {
+ t.Fatalf("err = %v, want ErrNoCostFunc", err)
+ }
+}
+
+func TestAggregateKeepsUnpricedRequestsOutOfCostButVisible(t *testing.T) {
+ records := []Record{
+ {Model: strptr("priced"), TotalTokens: 10},
+ {Model: strptr("priced"), TotalTokens: 5, Failed: true},
+ {Model: strptr("nameless"), TotalTokens: 7},
+ }
+ groups, err := Aggregate(records, testPrices, GroupByModel, fixedCost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(groups) != 2 {
+ t.Fatalf("groups = %+v, want 2", groups)
+ }
+ priced, unpricedGroup := groups[0], groups[1]
+ if priced.Key != "priced" || priced.Requests != 2 || priced.Failed != 1 ||
+ priced.TotalTokens != 15 || priced.CostUSD != 2 || priced.UnpricedRequests != 0 {
+ t.Fatalf("priced = %+v", priced)
+ }
+ // The unpriced group must NOT be dropped and must NOT be costed: a group
+ // that matched no price looks exactly like a free one unless it is counted.
+ if unpricedGroup.Key != "nameless" || unpricedGroup.Requests != 1 ||
+ unpricedGroup.CostUSD != 0 || unpricedGroup.UnpricedRequests != 1 {
+ t.Fatalf("unpriced = %+v", unpricedGroup)
+ }
+}
+
+func TestAggregateKeepsRowsWhoseDimensionIsMissing(t *testing.T) {
+ // Dropping these would make the report's request count disagree with the
+ // database's, with nothing to point at.
+ blank := ""
+ records := []Record{
+ {Model: strptr("priced"), Endpoint: nil, TotalTokens: 1},
+ {Model: strptr("priced"), Endpoint: &blank, TotalTokens: 1},
+ {Model: strptr("priced"), Endpoint: strptr("/v1/chat"), TotalTokens: 1},
+ }
+ groups, err := Aggregate(records, testPrices, GroupByEndpoint, fixedCost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ total := 0
+ seen := map[string]int{}
+ for _, group := range groups {
+ total += group.Requests
+ seen[group.Key] = group.Requests
+ }
+ if total != len(records) {
+ t.Fatalf("requests = %d, want %d (groups %+v)", total, len(records), groups)
+ }
+ // NULL and "" are the same state for reporting -- both mean "we do not know
+ // which endpoint" -- so they must land in ONE bucket, not two look-alikes.
+ if seen[unattributed] != 2 || seen["/v1/chat"] != 1 {
+ t.Fatalf("buckets = %+v", seen)
+ }
+}
+
+func TestAggregateOrdersByCostThenKeySoRunsAreDiffable(t *testing.T) {
+ prices := map[PriceKey]ModelPrice{{"p", "a"}: {}, {"p", "b"}: {}, {"p", "c"}: {}}
+ records := []Record{
+ {Model: strptr("b"), TotalTokens: 1}, {Model: strptr("b"), TotalTokens: 1},
+ {Model: strptr("c"), TotalTokens: 1},
+ {Model: strptr("a"), TotalTokens: 1},
+ }
+ groups, err := Aggregate(records, prices, GroupByModel, fixedCost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := []string{groups[0].Key, groups[1].Key, groups[2].Key}
+ // b costs 2; a and c both cost 1 and must then sort by key, not by map order.
+ if got[0] != "b" || got[1] != "a" || got[2] != "c" {
+ t.Fatalf("order = %v, want [b a c]", got)
+ }
+}
diff --git a/backend/internal/usagecost/golden_test.go b/backend/internal/usagecost/golden_test.go
new file mode 100644
index 00000000..5dd0dc84
--- /dev/null
+++ b/backend/internal/usagecost/golden_test.go
@@ -0,0 +1,136 @@
+package usagecost
+
+import (
+ "encoding/json"
+ "math"
+ "os"
+ "testing"
+)
+
+// The golden vectors are the numbers CPA-Helper's own recordCost produced.
+// Whatever implementation this repo wires into costFunc must reproduce them
+// exactly -- that equivalence is the entire reason this tool is allowed to
+// report a cost at all.
+type goldenFile struct {
+ GeneratedFrom struct {
+ Repo string `json:"repo"`
+ Commit string `json:"commit"`
+ } `json:"generated_from"`
+ Cases []goldenCase `json:"cases"`
+}
+
+type goldenCase struct {
+ Name string `json:"name"`
+ Why string `json:"why"`
+ Prices []struct {
+ Provider string `json:"provider"`
+ Model string `json:"model"`
+ InputUSDPerMillion float64 `json:"input_usd_per_million"`
+ OutputUSDPerMillion float64 `json:"output_usd_per_million"`
+ CacheReadUSDPerMillion float64 `json:"cache_read_usd_per_million"`
+ CacheCreationUSDPerMillion float64 `json:"cache_creation_usd_per_million"`
+ RequestUSD *float64 `json:"request_usd"`
+ } `json:"prices"`
+ Record Record `json:"record"`
+ WantUSD float64 `json:"want_usd"`
+ WantUnpriced bool `json:"want_unpriced"`
+}
+
+func loadGolden(t *testing.T) goldenFile {
+ t.Helper()
+ raw, err := os.ReadFile("testdata/cost_golden.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var file goldenFile
+ if err := json.Unmarshal(raw, &file); err != nil {
+ t.Fatal(err)
+ }
+ if len(file.Cases) == 0 || file.GeneratedFrom.Commit == "" {
+ // A vector file that lost its provenance cannot be trusted as evidence:
+ // it would still pass, and nobody could tell which behaviour it pinned.
+ t.Fatalf("golden file must carry cases and the commit they came from: %+v", file.GeneratedFrom)
+ }
+ return file
+}
+
+// TestGoldenVectorsCoverTheBranchesThatMatter guards the corpus itself. A
+// shrinking vector file is the quiet way this tooth stops biting: the
+// equivalence test below keeps passing while covering less and less.
+func TestGoldenVectorsCoverTheBranchesThatMatter(t *testing.T) {
+ file := loadGolden(t)
+ required := []string{
+ "token/non-claude/cached-bounded",
+ "token/non-claude/cached-exceeds-input",
+ "token/claude/cache-creation",
+ "token/no-price/tokens-used",
+ "token/no-price/no-tokens",
+ "token/nil-provider-and-model",
+ "alias/antigravity-to-gemini-family",
+ "alias/litellm-slash-key",
+ "request/image-success",
+ "request/image-failed",
+ "request/image-no-request-price",
+ "token/rounding-to-8dp",
+ }
+ present := map[string]bool{}
+ for _, c := range file.Cases {
+ present[c.Name] = true
+ }
+ for _, name := range required {
+ if !present[name] {
+ t.Fatalf("golden vectors no longer cover %q", name)
+ }
+ }
+ // The two "looks like zero" cases must disagree on Unpriced, or the corpus
+ // cannot detect an implementation that collapses them.
+ var noPriceUsed, noPriceIdle *goldenCase
+ for i := range file.Cases {
+ switch file.Cases[i].Name {
+ case "token/no-price/tokens-used":
+ noPriceUsed = &file.Cases[i]
+ case "token/no-price/no-tokens":
+ noPriceIdle = &file.Cases[i]
+ }
+ }
+ if noPriceUsed.WantUSD != 0 || noPriceIdle.WantUSD != 0 {
+ t.Fatal("both no-price cases must cost 0 -- that is what makes them look alike")
+ }
+ if !noPriceUsed.WantUnpriced || noPriceIdle.WantUnpriced {
+ t.Fatalf("the no-price cases must differ on Unpriced (%v vs %v), else 'no price configured' "+
+ "and 'nothing was used' are indistinguishable",
+ noPriceUsed.WantUnpriced, noPriceIdle.WantUnpriced)
+ }
+}
+
+// TestWiredCostFuncMatchesCPAHelper is the drift detector. It is skipped while
+// no implementation is wired in -- and the skip is loud, because a silently
+// skipped equivalence test is exactly how two implementations drift apart
+// without anybody noticing.
+func TestWiredCostFuncMatchesCPAHelper(t *testing.T) {
+ file := loadGolden(t)
+ cost := WiredCostFunc()
+ if cost == nil {
+ t.Skipf("no pricing implementation wired in yet; %d golden vectors from %s@%s are waiting",
+ len(file.Cases), file.GeneratedFrom.Repo, file.GeneratedFrom.Commit[:12])
+ }
+ for _, c := range file.Cases {
+ prices := map[PriceKey]ModelPrice{}
+ for _, p := range c.Prices {
+ prices[PriceKey{p.Provider, p.Model}] = ModelPrice{
+ InputUSDPerMillion: p.InputUSDPerMillion,
+ OutputUSDPerMillion: p.OutputUSDPerMillion,
+ CacheReadUSDPerMillion: p.CacheReadUSDPerMillion,
+ CacheCreationUSDPerMillion: p.CacheCreationUSDPerMillion,
+ RequestUSD: p.RequestUSD,
+ }
+ }
+ usd, unpriced := cost(c.Record, prices)
+ // Exact, not approximate: CPA-Helper rounds to 8 decimal places, so the
+ // two implementations either agree to the cent-of-a-cent or they do not.
+ if math.Abs(usd-c.WantUSD) > 1e-12 || unpriced != c.WantUnpriced {
+ t.Fatalf("%s (%s): got (%.10f, %v), want (%.10f, %v)",
+ c.Name, c.Why, usd, unpriced, c.WantUSD, c.WantUnpriced)
+ }
+ }
+}
diff --git a/backend/internal/usagecost/options.go b/backend/internal/usagecost/options.go
new file mode 100644
index 00000000..e869a942
--- /dev/null
+++ b/backend/internal/usagecost/options.go
@@ -0,0 +1,133 @@
+package usagecost
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ backendApp "cpa-helper/backend/internal/app"
+)
+
+// GroupBy is the closed set of dimensions this tool aggregates over. It is
+// closed on purpose: an unrecognised value is rejected at parse time rather
+// than silently producing a report grouped by something else.
+type GroupBy string
+
+const (
+ GroupByModel GroupBy = "model"
+ GroupByProvider GroupBy = "provider"
+ GroupByEndpoint GroupBy = "endpoint"
+ // GroupBySourceAccount answers "which upstream account is carrying the
+ // traffic", which none of the other three can: a single model or provider
+ // is served by several underlying accounts.
+ GroupBySourceAccount GroupBy = "source-account"
+)
+
+var groupByValues = []GroupBy{GroupByModel, GroupByProvider, GroupByEndpoint, GroupBySourceAccount}
+
+// Options is the fully validated command line. Nothing downstream re-checks
+// these, so ParseArgs must leave no invalid state behind.
+type Options struct {
+ DBPath string
+ GroupBy GroupBy
+ // Since is the start of the reporting window, already resolved against the
+ // caller's clock. Storing the instant rather than the day count means the
+ // window cannot shift underneath a long-running report.
+ Since time.Time
+ // SinceDays is kept for the report header so the output can say what was
+ // asked for, not only what it resolved to.
+ SinceDays int
+ JSON bool
+}
+
+// ParseArgs validates the command line. `now` is injected so tests do not
+// depend on the wall clock.
+func ParseArgs(args []string, now time.Time) (Options, error) {
+ opts := Options{}
+ var (
+ groupByRaw string
+ sinceRaw string
+ )
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ name, inlineValue, hasInline := strings.Cut(arg, "=")
+ value := func() (string, error) {
+ if hasInline {
+ if inlineValue == "" {
+ return "", fmt.Errorf("%s needs a value", name)
+ }
+ return inlineValue, nil
+ }
+ if i+1 >= len(args) {
+ return "", fmt.Errorf("%s needs a value", name)
+ }
+ i++
+ return args[i], nil
+ }
+ var err error
+ switch name {
+ case "--db":
+ opts.DBPath, err = value()
+ case "--group-by":
+ groupByRaw, err = value()
+ case "--since":
+ sinceRaw, err = value()
+ case "--json":
+ if hasInline {
+ err = fmt.Errorf("--json takes no value")
+ }
+ opts.JSON = true
+ default:
+ err = fmt.Errorf("unknown flag %q", name)
+ }
+ if err != nil {
+ return Options{}, err
+ }
+ }
+
+ if opts.DBPath == "" {
+ // Same resolution the service uses (CPA_HELPER_DATA_DIR, else
+ // /data): omitting --db must report on the database the service
+ // writes to, not fail for want of a path the user would only guess at.
+ defaultPath, err := backendApp.UsageDBPath()
+ if err != nil {
+ return Options{}, fmt.Errorf("--db not given and the default could not be resolved: %w", err)
+ }
+ opts.DBPath = defaultPath
+ }
+ if groupByRaw == "" {
+ return Options{}, fmt.Errorf("--group-by is required (one of %s)", joinGroupBy())
+ }
+ matched := false
+ for _, candidate := range groupByValues {
+ if groupByRaw == string(candidate) {
+ opts.GroupBy, matched = candidate, true
+ break
+ }
+ }
+ if !matched {
+ return Options{}, fmt.Errorf("--group-by %q is not one of %s", groupByRaw, joinGroupBy())
+ }
+ if sinceRaw == "" {
+ return Options{}, fmt.Errorf("--since is required (a whole number of days)")
+ }
+ // Deliberately strict: "7d", "7.0" and " 7" are all rejected rather than
+ // guessed at, because a misread window silently changes every number in the
+ // report and nothing downstream can detect it.
+ days, err := strconv.Atoi(sinceRaw)
+ if err != nil || days <= 0 {
+ return Options{}, fmt.Errorf("--since %q must be a positive whole number of days", sinceRaw)
+ }
+ opts.SinceDays = days
+ opts.Since = now.Add(-time.Duration(days) * 24 * time.Hour)
+ return opts, nil
+}
+
+func joinGroupBy() string {
+ parts := make([]string, 0, len(groupByValues))
+ for _, value := range groupByValues {
+ parts = append(parts, string(value))
+ }
+ return strings.Join(parts, "|")
+}
diff --git a/backend/internal/usagecost/options_test.go b/backend/internal/usagecost/options_test.go
new file mode 100644
index 00000000..0a3ba6a6
--- /dev/null
+++ b/backend/internal/usagecost/options_test.go
@@ -0,0 +1,103 @@
+package usagecost
+
+import (
+ "testing"
+ "time"
+)
+
+var testNow = time.Date(2026, 9, 20, 4, 0, 0, 0, time.UTC)
+
+func TestParseArgsAcceptsSeparatedAndInlineValues(t *testing.T) {
+ // Both spellings must land on the same Options. The separated form consumes
+ // the NEXT argv entry, so this also pins that the parser's cursor really
+ // advances past a consumed value -- if it did not, "model" would be read a
+ // second time as a flag and the parse would fail.
+ for _, args := range [][]string{
+ {"--db", "/tmp/x.sqlite3", "--group-by", "model", "--since", "7"},
+ {"--db=/tmp/x.sqlite3", "--group-by=model", "--since=7"},
+ } {
+ opts, err := ParseArgs(args, testNow)
+ if err != nil {
+ t.Fatalf("ParseArgs(%v) = %v", args, err)
+ }
+ if opts.DBPath != "/tmp/x.sqlite3" || opts.GroupBy != GroupByModel || opts.SinceDays != 7 {
+ t.Fatalf("ParseArgs(%v) = %+v", args, opts)
+ }
+ // Assert the same arithmetic the parser does (a rolling 7x24h window),
+ // not a calendar-day equivalent that only coincides in UTC.
+ if want := testNow.Add(-7 * 24 * time.Hour); !opts.Since.Equal(want) {
+ t.Fatalf("Since = %s, want %s", opts.Since, want)
+ }
+ if opts.JSON {
+ t.Fatal("JSON must default to false")
+ }
+ }
+}
+
+func TestParseArgsAcceptsEveryDimensionInTheClosedSet(t *testing.T) {
+ // Every advertised dimension must actually parse. Adding a constant without
+ // adding it to groupByValues would leave a flag the help text offers and the
+ // parser rejects.
+ for _, want := range []GroupBy{GroupByModel, GroupByProvider, GroupByEndpoint, GroupBySourceAccount} {
+ opts, err := ParseArgs([]string{"--db", "x", "--group-by", string(want), "--since", "1"}, testNow)
+ if err != nil {
+ t.Fatalf("--group-by %s: %v", want, err)
+ }
+ if opts.GroupBy != want {
+ t.Fatalf("GroupBy = %s, want %s", opts.GroupBy, want)
+ }
+ }
+}
+
+func TestParseArgsJSONIsAFlagNotAValue(t *testing.T) {
+ opts, err := ParseArgs([]string{"--db", "x", "--group-by", "provider", "--since", "1", "--json"}, testNow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !opts.JSON || opts.GroupBy != GroupByProvider {
+ t.Fatalf("got %+v", opts)
+ }
+ // --json must not swallow a following argument, or "--json --since 1" would
+ // silently lose the window.
+ if _, err := ParseArgs([]string{"--db", "x", "--group-by", "endpoint", "--json", "--since", "3"}, testNow); err != nil {
+ t.Fatalf("--json before another flag: %v", err)
+ }
+}
+
+func TestParseArgsDefaultsDBToTheServicePath(t *testing.T) {
+ // Omitting --db must land on the same database the service writes to
+ // (CPA_HELPER_DATA_DIR / /data), not fail for want of a path.
+ t.Setenv("CPA_HELPER_DATA_DIR", "/tmp/feiniu-usagecost-data")
+ opts, err := ParseArgs([]string{"--group-by", "model", "--since", "7"}, testNow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := "/tmp/feiniu-usagecost-data/db/cpa_helper.sqlite3"
+ if opts.DBPath != want {
+ t.Fatalf("DBPath = %q, want the service default %q", opts.DBPath, want)
+ }
+}
+
+func TestParseArgsRejectsEveryInvalidShape(t *testing.T) {
+ // Each of these must FAIL. A report that silently groups by the wrong
+ // dimension or covers the wrong window looks exactly like a correct one.
+ cases := map[string][]string{
+ "missing group-by": {"--db", "x", "--since", "7"},
+ "missing since": {"--db", "x", "--group-by", "model"},
+ "unknown group-by": {"--db", "x", "--group-by", "user", "--since", "7"},
+ "group-by underscore": {"--db", "x", "--group-by", "source_account", "--since", "7"},
+ "since zero": {"--db", "x", "--group-by", "model", "--since", "0"},
+ "since negative": {"--db", "x", "--group-by", "model", "--since", "-3"},
+ "since with suffix": {"--db", "x", "--group-by", "model", "--since", "7d"},
+ "since fractional": {"--db", "x", "--group-by", "model", "--since", "7.0"},
+ "unknown flag": {"--db", "x", "--group-by", "model", "--since", "7", "--limit", "5"},
+ "dangling value": {"--db", "x", "--group-by", "model", "--since"},
+ "empty inline value": {"--db=", "--group-by", "model", "--since", "7"},
+ "json with value": {"--db", "x", "--group-by", "model", "--since", "7", "--json=true"},
+ }
+ for name, args := range cases {
+ if _, err := ParseArgs(args, testNow); err == nil {
+ t.Fatalf("%s: ParseArgs(%v) must fail", name, args)
+ }
+ }
+}
diff --git a/backend/internal/usagecost/render.go b/backend/internal/usagecost/render.go
new file mode 100644
index 00000000..ce497da4
--- /dev/null
+++ b/backend/internal/usagecost/render.go
@@ -0,0 +1,92 @@
+package usagecost
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "strings"
+ "time"
+)
+
+// BuildReport assembles the answer, including the totals, so every reader gets
+// the same total instead of adding the column up themselves.
+func BuildReport(opts Options, groups []Group) Report {
+ report := Report{
+ GroupBy: string(opts.GroupBy),
+ SinceDays: opts.SinceDays,
+ Since: opts.Since.UTC().Format(time.RFC3339),
+ Groups: groups,
+ }
+ for _, group := range groups {
+ report.TotalCostUSD += group.CostUSD
+ report.TotalUnpriced += group.UnpricedRequests
+ }
+ return report
+}
+
+// WriteJSON emits the machine-readable form. Groups is never nil so the field
+// marshals as [] rather than null: a consumer testing `Array.isArray` (or Go
+// ranging over a decoded nil) must not have to special-case an empty report.
+func WriteJSON(w io.Writer, report Report) error {
+ if report.Groups == nil {
+ report.Groups = []Group{}
+ }
+ encoder := json.NewEncoder(w)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(report)
+}
+
+// WriteText emits the human form.
+func WriteText(w io.Writer, report Report) error {
+ header := fmt.Sprintf("usage cost by %s, since %s (%d day(s))",
+ report.GroupBy, report.Since, report.SinceDays)
+ if _, err := fmt.Fprintln(w, header); err != nil {
+ return err
+ }
+ if len(report.Groups) == 0 {
+ // Said out loud, because an empty table and a table that failed to load
+ // look identical once the header scrolls away.
+ _, err := fmt.Fprintln(w, "no usage records in this window")
+ return err
+ }
+ rows := [][]string{{"KEY", "REQUESTS", "FAILED", "TOKENS", "COST_USD", "UNPRICED"}}
+ for _, group := range report.Groups {
+ rows = append(rows, []string{
+ group.Key,
+ fmt.Sprintf("%d", group.Requests),
+ fmt.Sprintf("%d", group.Failed),
+ fmt.Sprintf("%d", group.TotalTokens),
+ fmt.Sprintf("%.6f", group.CostUSD),
+ fmt.Sprintf("%d", group.UnpricedRequests),
+ })
+ }
+ widths := make([]int, len(rows[0]))
+ for _, row := range rows {
+ for i, cell := range row {
+ if len(cell) > widths[i] {
+ widths[i] = len(cell)
+ }
+ }
+ }
+ for _, row := range rows {
+ parts := make([]string, len(row))
+ for i, cell := range row {
+ parts[i] = fmt.Sprintf("%-*s", widths[i], cell)
+ }
+ if _, err := fmt.Fprintln(w, strings.TrimRight(strings.Join(parts, " "), " ")); err != nil {
+ return err
+ }
+ }
+ if _, err := fmt.Fprintf(w, "\ntotal %.6f USD\n", report.TotalCostUSD); err != nil {
+ return err
+ }
+ if report.TotalUnpriced > 0 {
+ // The qualifier travels in the same sentence as the total, not in a
+ // column the reader may have skipped: a total that silently excludes
+ // unpriced usage reads as the whole bill.
+ _, err := fmt.Fprintf(w,
+ "warning: %d request(s) matched no price and are NOT in that total\n", report.TotalUnpriced)
+ return err
+ }
+ return nil
+}
diff --git a/backend/internal/usagecost/render_test.go b/backend/internal/usagecost/render_test.go
new file mode 100644
index 00000000..5141bef3
--- /dev/null
+++ b/backend/internal/usagecost/render_test.go
@@ -0,0 +1,97 @@
+package usagecost
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+)
+
+func testOptions() Options {
+ return Options{
+ GroupBy: GroupByModel,
+ SinceDays: 7,
+ Since: time.Date(2026, 9, 13, 4, 0, 0, 0, time.UTC),
+ }
+}
+
+func TestBuildReportTotalsMatchTheRows(t *testing.T) {
+ groups := []Group{
+ {Key: "a", CostUSD: 1.25, UnpricedRequests: 2},
+ {Key: "b", CostUSD: 0.75, UnpricedRequests: 1},
+ }
+ report := BuildReport(testOptions(), groups)
+ if report.TotalCostUSD != 2 || report.TotalUnpriced != 3 {
+ t.Fatalf("totals = %v / %v", report.TotalCostUSD, report.TotalUnpriced)
+ }
+ if report.GroupBy != "model" || report.SinceDays != 7 ||
+ report.Since != "2026-09-13T04:00:00Z" {
+ t.Fatalf("header = %+v", report)
+ }
+}
+
+func TestWriteJSONEmitsAnEmptyArrayNotNull(t *testing.T) {
+ var buf bytes.Buffer
+ if err := WriteJSON(&buf, BuildReport(testOptions(), nil)); err != nil {
+ t.Fatal(err)
+ }
+ // Asserted on the serialized text: a nil slice also has length zero, but it
+ // marshals to null, and a consumer that ranges or calls Array.isArray on the
+ // decoded value then has to special-case an empty report.
+ if !strings.Contains(buf.String(), `"groups": []`) {
+ t.Fatalf("groups must serialize as []: %s", buf.String())
+ }
+ var round Report
+ if err := json.Unmarshal(buf.Bytes(), &round); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestWriteTextSaysWhenTheWindowIsEmpty(t *testing.T) {
+ var buf bytes.Buffer
+ if err := WriteText(&buf, BuildReport(testOptions(), nil)); err != nil {
+ t.Fatal(err)
+ }
+ // An empty table and a table that failed to load look identical once the
+ // header scrolls away, so the empty case says so in words.
+ if !strings.Contains(buf.String(), "no usage records") {
+ t.Fatalf("empty report must say so: %q", buf.String())
+ }
+}
+
+func TestWriteTextPutsTheUnpricedWarningNextToTheTotal(t *testing.T) {
+ var buf bytes.Buffer
+ report := BuildReport(testOptions(), []Group{
+ {Key: "gemini", Requests: 3, TotalTokens: 30, CostUSD: 1.5},
+ {Key: "mystery", Requests: 2, TotalTokens: 20, UnpricedRequests: 2},
+ })
+ if err := WriteText(&buf, report); err != nil {
+ t.Fatal(err)
+ }
+ out := buf.String()
+ if !strings.Contains(out, "total 1.500000 USD") {
+ t.Fatalf("missing total: %q", out)
+ }
+ // The qualifier must travel WITH the total. A total that silently excludes
+ // unpriced usage reads as the whole bill, and the column alone is easy to
+ // skip past.
+ warningAt := strings.Index(out, "matched no price")
+ totalAt := strings.Index(out, "total 1.500000 USD")
+ if warningAt < 0 || warningAt < totalAt {
+ t.Fatalf("warning must follow the total and mention the count: %q", out)
+ }
+ if !strings.Contains(out, "2 request(s)") {
+ t.Fatalf("warning must carry the count: %q", out)
+ }
+
+ // The opposite direction: a fully priced report must NOT carry the warning,
+ // or it degrades into noise everyone learns to ignore.
+ buf.Reset()
+ if err := WriteText(&buf, BuildReport(testOptions(), []Group{{Key: "gemini", CostUSD: 1}})); err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(buf.String(), "matched no price") {
+ t.Fatalf("no warning expected: %q", buf.String())
+ }
+}
diff --git a/backend/internal/usagecost/run.go b/backend/internal/usagecost/run.go
new file mode 100644
index 00000000..04af5e81
--- /dev/null
+++ b/backend/internal/usagecost/run.go
@@ -0,0 +1,40 @@
+package usagecost
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "time"
+)
+
+// Run executes `cpa-helper usage-cost `: load prices and records, cost
+// every record with the production derivation, and write the report to `out`.
+func Run(ctx context.Context, args []string, out io.Writer) error {
+ opts, err := ParseArgs(args, time.Now().UTC())
+ if err != nil {
+ return err
+ }
+ db, err := OpenReadOnly(ctx, opts.DBPath)
+ if err != nil {
+ return err
+ }
+ defer db.Close()
+
+ prices, err := LoadPrices(ctx, db)
+ if err != nil {
+ return fmt.Errorf("load prices: %w", err)
+ }
+ records, err := LoadRecords(ctx, db, opts.Since)
+ if err != nil {
+ return fmt.Errorf("load usage records: %w", err)
+ }
+ groups, err := Aggregate(records, prices, opts.GroupBy, WiredCostFunc())
+ if err != nil {
+ return err
+ }
+ report := BuildReport(opts, groups)
+ if opts.JSON {
+ return WriteJSON(out, report)
+ }
+ return WriteText(out, report)
+}
diff --git a/backend/internal/usagecost/store.go b/backend/internal/usagecost/store.go
new file mode 100644
index 00000000..8916c664
--- /dev/null
+++ b/backend/internal/usagecost/store.go
@@ -0,0 +1,129 @@
+package usagecost
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "time"
+
+ backendApp "cpa-helper/backend/internal/app"
+
+ _ "modernc.org/sqlite"
+)
+
+// OpenReadOnly opens the CPA-Helper database for reporting only.
+//
+// Two independent guards, because this points at production data: the DSN asks
+// SQLite for a read-only connection, and `query_only` is then asserted on the
+// connection that was actually handed back. The second is not redundant -- a
+// future change to the DSN (adding a parameter, switching helper) can quietly
+// drop `mode=ro`, and without the assertion the first write would succeed
+// instead of failing.
+func OpenReadOnly(ctx context.Context, path string) (*sql.DB, error) {
+ db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro&_pragma=query_only(1)", path))
+ if err != nil {
+ return nil, err
+ }
+ // One connection: a pool would need the pragma re-asserted per connection,
+ // and this tool has no concurrency to gain from more.
+ db.SetMaxOpenConns(1)
+ if err := db.PingContext(ctx); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("open %s read-only: %w", path, err)
+ }
+ var queryOnly int
+ if err := db.QueryRowContext(ctx, "PRAGMA query_only").Scan(&queryOnly); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("read query_only pragma: %w", err)
+ }
+ if queryOnly != 1 {
+ db.Close()
+ return nil, fmt.Errorf("refusing to continue: connection to %s is not query_only", path)
+ }
+ return db, nil
+}
+
+// LoadPrices reads the price table into the map shape the cost derivation
+// expects. Keys are lowercased and trimmed by SQLite so the lookup matches
+// CPA-Helper's, which does the same normalisation in Go.
+func LoadPrices(ctx context.Context, db *sql.DB) (map[PriceKey]ModelPrice, error) {
+ rows, err := db.QueryContext(ctx, `
+ SELECT lower(trim(provider)), lower(trim(model)),
+ input_usd_per_million, output_usd_per_million,
+ cache_read_usd_per_million, cache_creation_usd_per_million,
+ request_usd
+ FROM model_prices`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ prices := map[PriceKey]ModelPrice{}
+ for rows.Next() {
+ var (
+ provider, model string
+ price ModelPrice
+ requestUSD sql.NullFloat64
+ )
+ if err := rows.Scan(&provider, &model,
+ &price.InputUSDPerMillion, &price.OutputUSDPerMillion,
+ &price.CacheReadUSDPerMillion, &price.CacheCreationUSDPerMillion,
+ &requestUSD); err != nil {
+ return nil, err
+ }
+ if requestUSD.Valid {
+ value := requestUSD.Float64
+ price.RequestUSD = &value
+ }
+ prices[PriceKey{provider, model}] = price
+ }
+ return prices, rows.Err()
+}
+
+// LoadRecords reads every usage row at or after `since`. The bound is the
+// dbTime() byte shape production writes to the TEXT `timestamp` column -- a
+// time.Time bound would be serialised by the driver as "2006-01-02 15:04:05
+// +0000 UTC" (space separator), and ' ' < 'T' makes the lexicographic >= let
+// older same-date rows through. Binding the same layout production writes is
+// the only way the comparison means what it says.
+func LoadRecords(ctx context.Context, db *sql.DB, since time.Time) ([]Record, error) {
+ rows, err := db.QueryContext(ctx, `
+ SELECT provider, model, endpoint, source_account, failed,
+ input_tokens, output_tokens, cached_tokens,
+ cache_read_tokens, cache_creation_tokens, reasoning_tokens, total_tokens
+ FROM usage_records
+ WHERE timestamp >= ?
+ ORDER BY id`, backendApp.UsageDBTime(since))
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ records := []Record{}
+ for rows.Next() {
+ var (
+ record Record
+ provider, model, endpoint, sourceAccount sql.NullString
+ failed bool
+ )
+ if err := rows.Scan(&provider, &model, &endpoint, &sourceAccount, &failed,
+ &record.InputTokens, &record.OutputTokens, &record.CachedTokens,
+ &record.CacheReadTokens, &record.CacheCreationTokens,
+ &record.ReasoningTokens, &record.TotalTokens); err != nil {
+ return nil, err
+ }
+ record.Provider = nullableString(provider)
+ record.Model = nullableString(model)
+ record.Endpoint = nullableString(endpoint)
+ record.SourceAccount = nullableString(sourceAccount)
+ record.Failed = failed
+ records = append(records, record)
+ }
+ return records, rows.Err()
+}
+
+func nullableString(value sql.NullString) *string {
+ if !value.Valid {
+ return nil
+ }
+ text := value.String
+ return &text
+}
diff --git a/backend/internal/usagecost/store_test.go b/backend/internal/usagecost/store_test.go
new file mode 100644
index 00000000..a3c783dc
--- /dev/null
+++ b/backend/internal/usagecost/store_test.go
@@ -0,0 +1,290 @@
+package usagecost
+
+import (
+ "context"
+ "database/sql"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ backendApp "cpa-helper/backend/internal/app"
+)
+
+// newFixtureDB writes a database with the columns this tool reads, using the
+// same names and types as CPA-Helper's schema
+// (backend/migrations/202605160001_initial_schema.sql).
+func newFixtureDB(t *testing.T) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "cpa_helper.sqlite3")
+ db, err := sql.Open("sqlite", "file:"+path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ if _, err := db.Exec(`
+ CREATE TABLE usage_records (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ created_at DATETIME NOT NULL,
+ timestamp DATETIME NOT NULL,
+ provider VARCHAR(120), model VARCHAR(180), endpoint VARCHAR(240),
+ source_account VARCHAR(320),
+ failed BOOLEAN NOT NULL DEFAULT 0,
+ input_tokens INTEGER NOT NULL DEFAULT 0,
+ output_tokens INTEGER NOT NULL DEFAULT 0,
+ cached_tokens INTEGER NOT NULL DEFAULT 0,
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
+ cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
+ reasoning_tokens INTEGER NOT NULL DEFAULT 0,
+ total_tokens INTEGER NOT NULL DEFAULT 0,
+ dedupe_key VARCHAR(80) NOT NULL UNIQUE,
+ raw_json TEXT NOT NULL
+ );
+ CREATE TABLE model_prices (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ provider VARCHAR(120) NOT NULL, model VARCHAR(180) NOT NULL,
+ input_usd_per_million REAL NOT NULL DEFAULT 0,
+ output_usd_per_million REAL NOT NULL DEFAULT 0,
+ cache_read_usd_per_million REAL NOT NULL DEFAULT 0,
+ cache_creation_usd_per_million REAL NOT NULL DEFAULT 0,
+ request_usd REAL,
+ source VARCHAR(40) NOT NULL DEFAULT 'manual',
+ updated_at DATETIME NOT NULL
+ );`); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func TestOpenReadOnlyRefusesWrites(t *testing.T) {
+ path := newFixtureDB(t)
+ ctx := context.Background()
+ db, err := OpenReadOnly(ctx, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ // This points at production data in real use, so the guard is asserted, not
+ // assumed: a DSN change that drops mode=ro would otherwise go unnoticed
+ // until the first write succeeded.
+ _, err = db.ExecContext(ctx,
+ `INSERT INTO model_prices (provider, model, updated_at) VALUES ('p','m','2026-09-20')`)
+ if err == nil {
+ t.Fatal("a write succeeded on a read-only connection")
+ }
+}
+
+func TestLoadRecordsHonoursTheWindowAndNullDimensions(t *testing.T) {
+ path := newFixtureDB(t)
+ db, err := sql.Open("sqlite", "file:"+path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Date(2026, 9, 20, 4, 0, 0, 0, time.UTC)
+ insert := func(key string, ts time.Time, provider, model, endpoint any, total int) {
+ // Write timestamps via the SAME serialisation production uses. Binding
+ // time.Time here would reproduce the reader's own byte shape, not the
+ // database's -- which is exactly how the space-separator bound bug was
+ // invisible to this test.
+ dbTs := backendApp.UsageDBTime(ts)
+ if _, err := db.Exec(`INSERT INTO usage_records
+ (created_at, timestamp, provider, model, endpoint, source_account, failed, total_tokens, dedupe_key, raw_json)
+ VALUES (?,?,?,?,?,?,0,?,?,'{}')`, dbTs, dbTs, provider, model, endpoint, "acct-"+key, total, key); err != nil {
+ t.Fatal(err)
+ }
+ }
+ insert("inside", now.Add(-1*time.Hour), "antigravity", "gemini", "/v1/chat", 10)
+ insert("edge", now.Add(-48*time.Hour), "xai", "grok", "/v1/chat", 20)
+ insert("outside", now.Add(-72*time.Hour), "devin", "swe", "/v1/chat", 40)
+ insert("nulls", now.Add(-2*time.Hour), nil, nil, nil, 5)
+ db.Close()
+
+ ctx := context.Background()
+ ro, err := OpenReadOnly(ctx, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ro.Close()
+ records, err := LoadRecords(ctx, ro, now.Add(-48*time.Hour))
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The boundary row must be INCLUDED (>= since) and the older one excluded --
+ // an off-by-one here shifts every number in the report with nothing to show
+ // for it.
+ if len(records) != 3 {
+ t.Fatalf("records = %d, want 3: %+v", len(records), records)
+ }
+ var sawNulls bool
+ for _, record := range records {
+ if record.Provider == nil && record.Model == nil && record.Endpoint == nil {
+ sawNulls = true
+ }
+ }
+ if !sawNulls {
+ t.Fatal("a row with NULL provider/model/endpoint must survive the load, not be dropped")
+ }
+}
+
+// A record older than `since` in real time must be excluded even when its
+// stored dbTime() string starts with the same UTC calendar date as the bound.
+// This is the exact escape the space-separator bound allowed: the driver writes
+// `since` as "2026-09-19 20:00:00 +0000 UTC" while production writes the record
+// as "2026-09-20T13:00:00+08:00", and ' ' < 'T' keeps it in the window.
+func TestLoadRecordsExcludesPreSinceRowStoredInDbTimeShape(t *testing.T) {
+ path := newFixtureDB(t)
+ db, err := sql.Open("sqlite", "file:"+path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // 2026-09-20 20:00 +08:00 -- stored as "2026-09-20T20:00:00+08:00".
+ since := time.Date(2026, 9, 20, 20, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
+ // 7h earlier in real time, same UTC calendar date as the buggy UTC bound.
+ old := since.Add(-7 * time.Hour)
+ for key, ts := range map[string]time.Time{"old": old, "new": since.Add(1 * time.Hour)} {
+ dbTs := backendApp.UsageDBTime(ts)
+ if _, err := db.Exec(`INSERT INTO usage_records
+ (created_at, timestamp, provider, model, endpoint, failed, total_tokens, dedupe_key, raw_json)
+ VALUES (?,?,?,?,?,0,1,?,'{}')`, dbTs, dbTs, "p", "m", "e", key); err != nil {
+ t.Fatal(err)
+ }
+ }
+ db.Close()
+
+ ctx := context.Background()
+ ro, err := OpenReadOnly(ctx, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ro.Close()
+ records, err := LoadRecords(ctx, ro, since)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(records) != 1 {
+ t.Fatalf("records = %d, want 1 -- the pre-since row leaked through the bound", len(records))
+ }
+}
+
+func TestLoadPricesNormalisesKeysLikeCPAHelperDoes(t *testing.T) {
+ path := newFixtureDB(t)
+ db, err := sql.Open("sqlite", "file:"+path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`INSERT INTO model_prices
+ (provider, model, input_usd_per_million, request_usd, updated_at)
+ VALUES (' Antigravity ', ' Gemini-3.8-Flash ', 1.5, NULL, '2026-09-20'),
+ ('xai', 'grok-4.6', 0, 0.002, '2026-09-20')`); err != nil {
+ t.Fatal(err)
+ }
+ db.Close()
+
+ ctx := context.Background()
+ ro, err := OpenReadOnly(ctx, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ro.Close()
+ prices, err := LoadPrices(ctx, ro)
+ if err != nil {
+ t.Fatal(err)
+ }
+ price, ok := prices[PriceKey{"antigravity", "gemini-3.8-flash"}]
+ if !ok {
+ t.Fatalf("key was not lowercased/trimmed: %+v", prices)
+ }
+ if price.InputUSDPerMillion != 1.5 {
+ t.Fatalf("input price = %v", price.InputUSDPerMillion)
+ }
+ // NULL request_usd and 0 request_usd are different states: the first means
+ // "not configured", the second means "configured as free". Collapsing them
+ // changes which billing branch a record takes.
+ if price.RequestUSD != nil {
+ t.Fatalf("NULL request_usd must stay nil, got %v", *price.RequestUSD)
+ }
+ grok := prices[PriceKey{"xai", "grok-4.6"}]
+ if grok.RequestUSD == nil || *grok.RequestUSD != 0.002 {
+ t.Fatalf("request_usd = %v", grok.RequestUSD)
+ }
+}
+
+func TestReportRefusesWhenNoPricingIsWired(t *testing.T) {
+ // End to end through the real store: with no cost derivation available the
+ // tool must fail, not print a table of zeros that reads like a $0 bill.
+ path := newFixtureDB(t)
+ ctx := context.Background()
+ db, err := OpenReadOnly(ctx, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ prices, err := LoadPrices(ctx, db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ records, err := LoadRecords(ctx, db, time.Unix(0, 0))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Aggregate(records, prices, GroupByModel, nil); err == nil ||
+ !strings.Contains(err.Error(), "pricing implementation") {
+ t.Fatalf("err = %v, want the no-pricing refusal", err)
+ }
+}
+
+func TestLoadRecordsCarriesSourceAccountForAttribution(t *testing.T) {
+ // artin asked for load per UNDERLYING account, which none of
+ // model/provider/endpoint can answer: one model is served by several
+ // accounts. The column has to survive the load for the dimension to exist.
+ path := newFixtureDB(t)
+ db, err := sql.Open("sqlite", "file:"+path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Date(2026, 9, 20, 4, 0, 0, 0, time.UTC)
+ dbNow := backendApp.UsageDBTime(now)
+ if _, err := db.Exec(`INSERT INTO usage_records
+ (created_at, timestamp, provider, model, endpoint, source_account, failed, total_tokens, dedupe_key, raw_json)
+ VALUES (?,?,'antigravity','gemini','/v1/chat','acct-a',0,10,'a','{}'),
+ (?,?,'antigravity','gemini','/v1/chat','acct-b',0,20,'b','{}'),
+ (?,?,'antigravity','gemini','/v1/chat',NULL,0,30,'c','{}')`,
+ dbNow, dbNow, dbNow, dbNow, dbNow, dbNow); err != nil {
+ t.Fatal(err)
+ }
+ db.Close()
+
+ ctx := context.Background()
+ ro, err := OpenReadOnly(ctx, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ro.Close()
+ records, err := LoadRecords(ctx, ro, now.Add(-time.Hour))
+ if err != nil {
+ t.Fatal(err)
+ }
+ groups, err := Aggregate(records, map[PriceKey]ModelPrice{}, GroupBySourceAccount, fixedCost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ seen := map[string]int64{}
+ for _, group := range groups {
+ seen[group.Key] = group.TotalTokens
+ }
+ // Three rows that are identical on every other dimension must still split
+ // three ways here -- otherwise the new flag is decorative.
+ if seen["acct-a"] != 10 || seen["acct-b"] != 20 || seen[unattributed] != 30 {
+ t.Fatalf("source-account buckets = %+v", seen)
+ }
+ // And the same rows must collapse to ONE group on a dimension they share,
+ // which is what proves the split above came from source_account and not
+ // from the rows differing somewhere else.
+ byModel, err := Aggregate(records, map[PriceKey]ModelPrice{}, GroupByModel, fixedCost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(byModel) != 1 || byModel[0].Requests != 3 {
+ t.Fatalf("by model = %+v, want one group of 3", byModel)
+ }
+}
diff --git a/backend/internal/usagecost/testdata/cost_golden.json b/backend/internal/usagecost/testdata/cost_golden.json
new file mode 100644
index 00000000..a5054864
--- /dev/null
+++ b/backend/internal/usagecost/testdata/cost_golden.json
@@ -0,0 +1,338 @@
+{
+ "_README": [
+ "Golden cost vectors: the numbers CPA-Helper's own recordCost produced for these inputs.",
+ "Whatever pricing implementation this repo wires in MUST reproduce them exactly.",
+ "",
+ "BOUNDARY: these freeze CPA-Helper's behaviour AT THE COMMIT BELOW. They are not a",
+ "statement about what the cost SHOULD be -- if CPA-Helper deliberately changes its",
+ "pricing rules, this file is stale and must be regenerated, not worked around.",
+ "A stale vector file and a correct one look identical until someone checks the commit.",
+ "",
+ "Regenerate: add the generator test to CPA-Helper backend/internal/app (it must live",
+ "there because recordCost is unexported and under internal/), then:",
+ " GOLDEN_OUT= go test ./internal/app/ -run TestZZGenerateGoldenVectors -count=1"
+ ],
+ "generated_from": {
+ "repo": "CPA-Helper",
+ "commit": "994254cdd3d60ee6be03d62ecf478a0afa3bf2f4",
+ "function": "backend/internal/app/pricing.go recordCost"
+ },
+ "cases": [
+ {
+ "name": "token/non-claude/cached-bounded",
+ "why": "non-claude splits cached out of input_tokens",
+ "prices": [
+ {
+ "provider": "xai",
+ "model": "grok-4.6",
+ "input_usd_per_million": 3,
+ "output_usd_per_million": 15,
+ "cache_read_usd_per_million": 0.3,
+ "cache_creation_usd_per_million": 0,
+ "request_usd": null
+ }
+ ],
+ "record": {
+ "provider": "xai",
+ "model": "grok-4.6",
+ "failed": false,
+ "input_tokens": 1000,
+ "output_tokens": 500,
+ "cached_tokens": 400,
+ "cache_read_tokens": 0,
+ "cache_creation_tokens": 0,
+ "total_tokens": 1500
+ },
+ "want_usd": 0.00942,
+ "want_unpriced": false
+ },
+ {
+ "name": "token/non-claude/cached-exceeds-input",
+ "why": "cached is clamped to input_tokens, never negative input",
+ "prices": [
+ {
+ "provider": "xai",
+ "model": "grok-4.6",
+ "input_usd_per_million": 3,
+ "output_usd_per_million": 15,
+ "cache_read_usd_per_million": 0.3,
+ "cache_creation_usd_per_million": 0,
+ "request_usd": null
+ }
+ ],
+ "record": {
+ "provider": "xai",
+ "model": "grok-4.6",
+ "failed": false,
+ "input_tokens": 100,
+ "output_tokens": 10,
+ "cached_tokens": 5000,
+ "cache_read_tokens": 0,
+ "cache_creation_tokens": 0,
+ "total_tokens": 110
+ },
+ "want_usd": 0.00018,
+ "want_unpriced": false
+ },
+ {
+ "name": "token/claude/cache-creation",
+ "why": "claude path adds cache_creation and uses cache_read, not cached",
+ "prices": [
+ {
+ "provider": "anthropic",
+ "model": "claude-sonnet-5",
+ "input_usd_per_million": 3,
+ "output_usd_per_million": 15,
+ "cache_read_usd_per_million": 0.3,
+ "cache_creation_usd_per_million": 3.75,
+ "request_usd": null
+ }
+ ],
+ "record": {
+ "provider": "anthropic",
+ "model": "claude-sonnet-5",
+ "failed": false,
+ "input_tokens": 1000,
+ "output_tokens": 200,
+ "cached_tokens": 900,
+ "cache_read_tokens": 300,
+ "cache_creation_tokens": 50,
+ "total_tokens": 1200
+ },
+ "want_usd": 0.0062775,
+ "want_unpriced": false
+ },
+ {
+ "name": "token/no-price/tokens-used",
+ "why": "billable usage with no price is UNPRICED, not free",
+ "prices": null,
+ "record": {
+ "provider": "nobody",
+ "model": "nothing",
+ "failed": false,
+ "input_tokens": 10,
+ "output_tokens": 0,
+ "cached_tokens": 0,
+ "cache_read_tokens": 0,
+ "cache_creation_tokens": 0,
+ "total_tokens": 10
+ },
+ "want_usd": 0,
+ "want_unpriced": true
+ },
+ {
+ "name": "token/no-price/no-tokens",
+ "why": "zero usage with no price is genuinely zero, not unpriced",
+ "prices": null,
+ "record": {
+ "provider": "nobody",
+ "model": "nothing",
+ "failed": false,
+ "input_tokens": 0,
+ "output_tokens": 0,
+ "cached_tokens": 0,
+ "cache_read_tokens": 0,
+ "cache_creation_tokens": 0,
+ "total_tokens": 0
+ },
+ "want_usd": 0,
+ "want_unpriced": false
+ },
+ {
+ "name": "token/nil-provider-and-model",
+ "why": "nil identity can never match a price",
+ "prices": [
+ {
+ "provider": "xai",
+ "model": "grok-4.6",
+ "input_usd_per_million": 3,
+ "output_usd_per_million": 15,
+ "cache_read_usd_per_million": 0.3,
+ "cache_creation_usd_per_million": 0,
+ "request_usd": null
+ }
+ ],
+ "record": {
+ "provider": null,
+ "model": null,
+ "failed": false,
+ "input_tokens": 10,
+ "output_tokens": 0,
+ "cached_tokens": 0,
+ "cache_read_tokens": 0,
+ "cache_creation_tokens": 0,
+ "total_tokens": 10
+ },
+ "want_usd": 0,
+ "want_unpriced": true
+ },
+ {
+ "name": "alias/antigravity-to-gemini-family",
+ "why": "reverse proxy provider + variant suffix both resolve",
+ "prices": [
+ {
+ "provider": "gemini",
+ "model": "gemini-3.8-flash",
+ "input_usd_per_million": 0.3,
+ "output_usd_per_million": 2.5,
+ "cache_read_usd_per_million": 0.075,
+ "cache_creation_usd_per_million": 0,
+ "request_usd": null
+ }
+ ],
+ "record": {
+ "provider": "antigravity",
+ "model": "gemini-3.8-flash-high",
+ "failed": false,
+ "input_tokens": 2000,
+ "output_tokens": 1000,
+ "cached_tokens": 0,
+ "cache_read_tokens": 0,
+ "cache_creation_tokens": 0,
+ "total_tokens": 3000
+ },
+ "want_usd": 0.0031,
+ "want_unpriced": false
+ },
+ {
+ "name": "alias/litellm-slash-key",
+ "why": "LiteLLM keys models as /",
+ "prices": [
+ {
+ "provider": "gemini",
+ "model": "gemini/gemini-3.8-flash",
+ "input_usd_per_million": 0.4,
+ "output_usd_per_million": 2.6,
+ "cache_read_usd_per_million": 0,
+ "cache_creation_usd_per_million": 0,
+ "request_usd": null
+ }
+ ],
+ "record": {
+ "provider": "gemini",
+ "model": "gemini-3.8-flash",
+ "failed": false,
+ "input_tokens": 1000,
+ "output_tokens": 100,
+ "cached_tokens": 0,
+ "cache_read_tokens": 0,
+ "cache_creation_tokens": 0,
+ "total_tokens": 1100
+ },
+ "want_usd": 0.00066,
+ "want_unpriced": false
+ },
+ {
+ "name": "request/image-success",
+ "why": "image models bill per request, not per token",
+ "prices": [
+ {
+ "provider": "openai",
+ "model": "gpt-image-1",
+ "input_usd_per_million": 0,
+ "output_usd_per_million": 0,
+ "cache_read_usd_per_million": 0,
+ "cache_creation_usd_per_million": 0,
+ "request_usd": 0.011
+ }
+ ],
+ "record": {
+ "provider": "openai",
+ "model": "gpt-image-1",
+ "failed": false,
+ "input_tokens": 9999,
+ "output_tokens": 9999,
+ "cached_tokens": 0,
+ "cache_read_tokens": 0,
+ "cache_creation_tokens": 0,
+ "total_tokens": 19998
+ },
+ "want_usd": 0.011,
+ "want_unpriced": false
+ },
+ {
+ "name": "request/image-failed",
+ "why": "a failed per-request call costs nothing and is NOT unpriced",
+ "prices": [
+ {
+ "provider": "openai",
+ "model": "gpt-image-1",
+ "input_usd_per_million": 0,
+ "output_usd_per_million": 0,
+ "cache_read_usd_per_million": 0,
+ "cache_creation_usd_per_million": 0,
+ "request_usd": 0.011
+ }
+ ],
+ "record": {
+ "provider": "openai",
+ "model": "gpt-image-1",
+ "failed": true,
+ "input_tokens": 0,
+ "output_tokens": 0,
+ "cached_tokens": 0,
+ "cache_read_tokens": 0,
+ "cache_creation_tokens": 0,
+ "total_tokens": 0
+ },
+ "want_usd": 0,
+ "want_unpriced": false
+ },
+ {
+ "name": "request/image-no-request-price",
+ "why": "per-request model without request_usd is UNPRICED",
+ "prices": [
+ {
+ "provider": "openai",
+ "model": "gpt-image-2",
+ "input_usd_per_million": 5,
+ "output_usd_per_million": 0,
+ "cache_read_usd_per_million": 0,
+ "cache_creation_usd_per_million": 0,
+ "request_usd": null
+ }
+ ],
+ "record": {
+ "provider": "openai",
+ "model": "gpt-image-2",
+ "failed": false,
+ "input_tokens": 100,
+ "output_tokens": 0,
+ "cached_tokens": 0,
+ "cache_read_tokens": 0,
+ "cache_creation_tokens": 0,
+ "total_tokens": 100
+ },
+ "want_usd": 0,
+ "want_unpriced": true
+ },
+ {
+ "name": "token/rounding-to-8dp",
+ "why": "pins the rounding, which silently shifts every total",
+ "prices": [
+ {
+ "provider": "xai",
+ "model": "tiny",
+ "input_usd_per_million": 0.3333333333333333,
+ "output_usd_per_million": 0,
+ "cache_read_usd_per_million": 0,
+ "cache_creation_usd_per_million": 0,
+ "request_usd": null
+ }
+ ],
+ "record": {
+ "provider": "xai",
+ "model": "tiny",
+ "failed": false,
+ "input_tokens": 7,
+ "output_tokens": 0,
+ "cached_tokens": 0,
+ "cache_read_tokens": 0,
+ "cache_creation_tokens": 0,
+ "total_tokens": 7
+ },
+ "want_usd": 2.33e-06,
+ "want_unpriced": false
+ }
+ ]
+}
diff --git a/backend/internal/usagecost/types.go b/backend/internal/usagecost/types.go
new file mode 100644
index 00000000..271e00bb
--- /dev/null
+++ b/backend/internal/usagecost/types.go
@@ -0,0 +1,52 @@
+package usagecost
+
+import (
+ "errors"
+
+ backendApp "cpa-helper/backend/internal/app"
+)
+
+// ModelPrice is CPA-Helper's own price row type. The report does not get a
+// second definition: two shapes for the same priced columns is how a report
+// and the production billing branch drift apart.
+type ModelPrice = backendApp.ModelPrice
+
+// PriceKey is (provider, model), both lowercased and trimmed -- the same shape
+// CPA-Helper keys its price map by. It is an alias, not a new type: the price
+// map must be passable to app.UsageRecordCost without a rebuild, or the wiring
+// could silently hand it a different map than the one that was loaded.
+type PriceKey = [2]string
+
+// Record is one usage row, narrowed to the fields the report reads. It stays
+// narrow on purpose: the report's own contract (grouping dimensions plus the
+// token counts cost depends on) is visible at a glance instead of being
+// implied by a 30-field struct. The JSON tags are load-bearing: the golden
+// vectors are stored snake_case, and untagged fields would silently decode to
+// zero while still "passing" a compile.
+type Record struct {
+ Provider *string `json:"provider"`
+ Model *string `json:"model"`
+ Endpoint *string `json:"endpoint"`
+ // SourceAccount is the upstream account the request was served by. It is
+ // not part of cost, only of attribution.
+ SourceAccount *string `json:"source_account"`
+ Failed bool `json:"failed"`
+ InputTokens int `json:"input_tokens"`
+ OutputTokens int `json:"output_tokens"`
+ CachedTokens int `json:"cached_tokens"`
+ CacheReadTokens int `json:"cache_read_tokens"`
+ CacheCreationTokens int `json:"cache_creation_tokens"`
+ ReasoningTokens int `json:"reasoning_tokens"`
+ TotalTokens int `json:"total_tokens"`
+}
+
+// CostFunc computes one record's estimated cost. `unpriced` reports that the
+// record consumed something billable but no price matched -- that is NOT the
+// same as a cost of zero, and the report keeps the two apart so a missing price
+// can never be read as free usage.
+type CostFunc func(Record, map[PriceKey]ModelPrice) (usd float64, unpriced bool)
+
+// ErrNoCostFunc is returned instead of a number when no pricing implementation
+// is available. "I cannot price this" must never leave the package looking
+// like "this cost nothing".
+var ErrNoCostFunc = errors.New("no pricing implementation is wired in")
diff --git a/backend/internal/usagecost/wiring.go b/backend/internal/usagecost/wiring.go
new file mode 100644
index 00000000..054b8243
--- /dev/null
+++ b/backend/internal/usagecost/wiring.go
@@ -0,0 +1,33 @@
+package usagecost
+
+import (
+ backendApp "cpa-helper/backend/internal/app"
+)
+
+// WiredCostFunc returns CPA-Helper's own cost derivation adapted to the
+// report's narrow Record shape. Reusing app.UsageRecordCost (which wraps the
+// unexported recordCost) is the whole point of living in this module: the
+// report must produce the SAME number production recorded, and the only
+// implementation that cannot drift from recordCost is recordCost itself.
+func WiredCostFunc() CostFunc { return usageRecordCost }
+
+func usageRecordCost(record Record, prices map[PriceKey]ModelPrice) (float64, bool) {
+ return backendApp.UsageRecordCost(toUsageRecord(record), prices)
+}
+
+func toUsageRecord(record Record) backendApp.UsageRecord {
+ return backendApp.UsageRecord{
+ Provider: record.Provider,
+ Model: record.Model,
+ Endpoint: record.Endpoint,
+ SourceAccount: record.SourceAccount,
+ Failed: record.Failed,
+ InputTokens: record.InputTokens,
+ OutputTokens: record.OutputTokens,
+ CachedTokens: record.CachedTokens,
+ CacheReadTokens: record.CacheReadTokens,
+ CacheCreationTokens: record.CacheCreationTokens,
+ ReasoningTokens: record.ReasoningTokens,
+ TotalTokens: record.TotalTokens,
+ }
+}
From d6cf7b86801fb1612192efa02fc7bbf6a4513fa0 Mon Sep 17 00:00:00 2001
From: Jiacheng
Date: Sun, 20 Sep 2026 19:12:29 +0800
Subject: [PATCH 24/25] =?UTF-8?q?feat:=20account-runway=20=E5=8F=AA?=
=?UTF-8?q?=E8=AF=BB=E5=AD=90=E5=91=BD=E4=BB=A4=EF=BC=88=E9=85=8D=E9=A2=9D?=
=?UTF-8?q?=E7=BB=AD=E8=88=AA=E6=B5=8B=E7=AE=97+=E5=8A=A0=E5=8F=B7?=
=?UTF-8?q?=E5=BB=BA=E8=AE=AE=EF=BC=89=20(#20)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(account-runway): read-only per-account quota runway report
* fix(usagecost): add busy_timeout to read-only DSN for delete-journal DB
Same one-line class as accountrunway: production runs journal_mode=delete
with frequent writer traffic, and a bare read-only open can hit
SQLITE_BUSY(5) during a writer transaction. Wait up to 5s for the lock.
---------
Co-authored-by: feiniu (Raft agent)
---
backend/cmd/cpa-helper/main.go | 7 +
backend/cmd/cpa-helper/main_test.go | 2 +-
backend/internal/accountrunway/run.go | 164 ++++++
backend/internal/accountrunway/runway.go | 479 ++++++++++++++++++
backend/internal/accountrunway/runway_test.go | 313 ++++++++++++
backend/internal/accountrunway/store.go | 176 +++++++
backend/internal/accountrunway/store_test.go | 300 +++++++++++
backend/internal/accountrunway/table.go | 25 +
backend/internal/app/usage_cost_export.go | 4 +
backend/internal/usagecost/store.go | 2 +-
10 files changed, 1470 insertions(+), 2 deletions(-)
create mode 100644 backend/internal/accountrunway/run.go
create mode 100644 backend/internal/accountrunway/runway.go
create mode 100644 backend/internal/accountrunway/runway_test.go
create mode 100644 backend/internal/accountrunway/store.go
create mode 100644 backend/internal/accountrunway/store_test.go
create mode 100644 backend/internal/accountrunway/table.go
diff --git a/backend/cmd/cpa-helper/main.go b/backend/cmd/cpa-helper/main.go
index 22a7e96a..4f32cf84 100644
--- a/backend/cmd/cpa-helper/main.go
+++ b/backend/cmd/cpa-helper/main.go
@@ -10,6 +10,7 @@ import (
"strconv"
"strings"
+ "cpa-helper/backend/internal/accountrunway"
backendApp "cpa-helper/backend/internal/app"
"cpa-helper/backend/internal/httpserver"
"cpa-helper/backend/internal/usagecost"
@@ -92,6 +93,10 @@ func run(ctx context.Context, args []string, stdout io.Writer) error {
// Read-only cost report over the same database the service writes to;
// --db only overrides where it reads, never what it records.
return usagecost.Run(ctx, args[1:], stdout)
+ case "account-runway":
+ // Read-only runway report over the same database the service writes to;
+ // --db only overrides where it reads, never what it records.
+ return accountrunway.Run(ctx, args[1:], stdout)
case "help", "-h", "--help":
printUsage(stdout)
return nil
@@ -158,5 +163,7 @@ func printUsage(w io.Writer) {
cpa-helper usage-cost [--db path] --group-by model|provider|endpoint|source-account
--since [--json]
Report usage cost over recorded usage (read-only)
+ cpa-helper account-runway [--db path] [--since days] [--provider antigravity|codex|all] [--json]
+ Estimate per-account quota runway until next reset (read-only)
`)
}
diff --git a/backend/cmd/cpa-helper/main_test.go b/backend/cmd/cpa-helper/main_test.go
index 7f0259b9..d0a74ad4 100644
--- a/backend/cmd/cpa-helper/main_test.go
+++ b/backend/cmd/cpa-helper/main_test.go
@@ -15,7 +15,7 @@ func TestRunHelpListsOperationalSubcommands(t *testing.T) {
t.Fatalf("run help failed: %v", err)
}
text := output.String()
- for _, want := range []string{"migrate", "serve", "doctor"} {
+ for _, want := range []string{"migrate", "serve", "doctor", "account-runway", "usage-cost"} {
if !strings.Contains(text, want) {
t.Fatalf("help output missing %q: %s", want, text)
}
diff --git a/backend/internal/accountrunway/run.go b/backend/internal/accountrunway/run.go
new file mode 100644
index 00000000..8382663f
--- /dev/null
+++ b/backend/internal/accountrunway/run.go
@@ -0,0 +1,164 @@
+package accountrunway
+
+import (
+ "context"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "math"
+ "strconv"
+ "strings"
+ "time"
+
+ backendApp "cpa-helper/backend/internal/app"
+)
+
+// Options carries parsed account-runway flags.
+type Options struct {
+ DBPath string
+ Since int // days
+ Provider string
+ JSON bool
+}
+
+// ParseArgs parses the subcommand flags. `now` is injectable for tests.
+func ParseArgs(args []string, now time.Time) (Options, error) {
+ _ = now
+ opts := Options{Since: 1, Provider: "all"}
+ fs := flag.NewFlagSet("account-runway", flag.ContinueOnError)
+ fs.StringVar(&opts.DBPath, "db", opts.DBPath, "SQLite database path")
+ fs.IntVar(&opts.Since, "since", opts.Since, "burn-rate window in days")
+ fs.StringVar(&opts.Provider, "provider", opts.Provider, "antigravity|codex|all")
+ fs.BoolVar(&opts.JSON, "json", false, "emit JSON")
+ if err := fs.Parse(args); err != nil {
+ return Options{}, err
+ }
+ if opts.Since <= 0 {
+ return Options{}, fmt.Errorf("--since must be a positive number of days, got %d", opts.Since)
+ }
+ switch opts.Provider {
+ case "antigravity", "codex", "all":
+ default:
+ return Options{}, fmt.Errorf("--provider must be antigravity, codex or all, got %q", opts.Provider)
+ }
+ if opts.DBPath == "" {
+ // Same resolution the service uses (CPA_HELPER_DATA_DIR, else
+ // /data): omitting --db must report on the database the service
+ // writes to, not fail for want of a path the user would only guess at.
+ p, err := backendApp.UsageDBPath()
+ if err != nil {
+ return Options{}, fmt.Errorf("--db not given and the default could not be resolved: %w", err)
+ }
+ opts.DBPath = p
+ }
+ return opts, nil
+}
+
+// Run executes `cpa-helper account-runway `.
+func Run(ctx context.Context, args []string, out io.Writer) error {
+ return RunAt(ctx, args, out, time.Now().UTC())
+}
+
+// RunAt is Run with an injectable clock for tests.
+func RunAt(ctx context.Context, args []string, out io.Writer, now time.Time) error {
+ opts, err := ParseArgs(args, now)
+ if err != nil {
+ return err
+ }
+ db, err := OpenReadOnly(ctx, opts.DBPath)
+ if err != nil {
+ return err
+ }
+ defer db.Close()
+
+ accounts, err := LoadAccounts(ctx, db)
+ if err != nil {
+ return fmt.Errorf("load keeper accounts: %w", err)
+ }
+ rows, err := LoadUsageRows(ctx, db, now.Add(-time.Duration(opts.Since)*24*time.Hour))
+ if err != nil {
+ return fmt.Errorf("load usage rows: %w", err)
+ }
+ report := Compute(accounts, rows, opts.Provider, opts.Since, now)
+ if opts.JSON {
+ return WriteJSON(out, report)
+ }
+ return WriteText(out, report)
+}
+
+// WriteJSON emits the stable machine-readable report.
+func WriteJSON(out io.Writer, report Report) error {
+ encoder := json.NewEncoder(out)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(report)
+}
+
+// WriteText renders the report as a terminal table.
+func WriteText(out io.Writer, report Report) error {
+ w := newTableWriter(out)
+ w.row("ACCOUNT", "PROVIDER", "BUCKET", "REMAIN%", "RESET", "BURN/H", "RUNWAY_H", "COVERAGE", "STATUS")
+ for _, account := range report.Accounts {
+ name := account.Name
+ if account.Disabled {
+ name += " (DISABLED)"
+ }
+ for _, bucket := range account.Buckets {
+ w.row(
+ name,
+ account.Provider,
+ bucket.Name,
+ formatPercent(bucket.RemainingFraction),
+ formatReset(bucket.ResetAtText),
+ formatFloat(account.BurnPerHour, 0),
+ formatFloatPtr(bucket.RunwayHours, 1),
+ formatFloatPtr(bucket.Coverage, 2),
+ bucketStatusText(bucket),
+ )
+ }
+ }
+ w.flush()
+ if report.UnattributedRows > 0 {
+ fmt.Fprintf(out, "\nunattributed usage: %d rows / %d tokens (no unique account match)\n",
+ report.UnattributedRows, report.UnattributedTokens)
+ }
+ fmt.Fprintf(out, "\npool: %d enabled / %d disabled accounts; recommendation: %s",
+ report.Pool.EnabledAccounts, report.Pool.DisabledAccounts, report.Pool.Recommendation)
+ if report.Pool.AdditionalAccounts > 0 {
+ fmt.Fprintf(out, " (add ~%d account(s), gap %.0f tokens)", report.Pool.AdditionalAccounts, report.Pool.GapTokens)
+ }
+ fmt.Fprintln(out)
+ return nil
+}
+
+func formatPercent(fraction float64) string {
+ return strconv.FormatFloat(fraction*100, 'f', 0, 64) + "%"
+}
+
+func formatReset(text string) string {
+ if text == "" {
+ return "-"
+ }
+ return text
+}
+
+func formatFloat(value float64, precision int) string {
+ return strconv.FormatFloat(value, 'f', precision, 64)
+}
+
+func formatFloatPtr(value *float64, precision int) string {
+ if value == nil {
+ return "-"
+ }
+ if math.IsInf(*value, 1) {
+ return "inf"
+ }
+ return strconv.FormatFloat(*value, 'f', precision, 64)
+}
+
+func bucketStatusText(bucket BucketReport) string {
+ if len(bucket.Notes) == 0 {
+ return bucket.Status
+ }
+ return bucket.Status + " (" + strings.Join(bucket.Notes, ",") + ")"
+}
diff --git a/backend/internal/accountrunway/runway.go b/backend/internal/accountrunway/runway.go
new file mode 100644
index 00000000..810c86be
--- /dev/null
+++ b/backend/internal/accountrunway/runway.go
@@ -0,0 +1,479 @@
+package accountrunway
+
+import (
+ "database/sql"
+ "encoding/json"
+ "math"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+
+ backendApp "cpa-helper/backend/internal/app"
+)
+
+// emailPattern mirrors app.usageEmailPattern: production writes source_account
+// as the lowercased email extracted from the usage `source` field.
+var emailPattern = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`)
+
+const (
+ // Default window fallbacks when a codex account row carries no window
+ // seconds: the 5h primary bucket and the weekly secondary bucket.
+ codexPrimaryWindowFallbackSeconds = int64(18000)
+ codexSecondaryWindowFallbackSeconds = int64(604800)
+ secondsPerWeek = float64(604800)
+
+ StatusHealthy = "HEALTHY"
+ StatusCritical = "CRITICAL"
+ StatusUnknown = "UNKNOWN"
+)
+
+var statusRank = map[string]int{StatusCritical: 0, StatusUnknown: 1, StatusHealthy: 2}
+
+type antigravityGroup struct {
+ DisplayName string `json:"display_name"`
+ Description string `json:"description,omitempty"`
+ Buckets []antigravityBucket `json:"buckets"`
+}
+
+type antigravityBucket struct {
+ BucketID string `json:"bucket_id"`
+ DisplayName string `json:"display_name"`
+ Window string `json:"window"`
+ RemainingFraction float64 `json:"remaining_fraction"`
+ ResetAt *time.Time `json:"reset_at"`
+ Description string `json:"description,omitempty"`
+}
+
+// parseAntigravityQuota decodes the stored antigravity_quota JSON blob. NULL,
+// empty, or malformed yields nil (no quota snapshot).
+func parseAntigravityQuota(value sql.NullString) []antigravityGroup {
+ if !value.Valid || strings.TrimSpace(value.String) == "" {
+ return nil
+ }
+ var groups []antigravityGroup
+ if err := json.Unmarshal([]byte(value.String), &groups); err != nil {
+ return nil
+ }
+ return groups
+}
+
+// BucketReport is the computed runway state of one quota window of one account.
+type BucketReport struct {
+ Name string `json:"name"`
+ RemainingFraction float64 `json:"remaining_fraction"`
+ ResetAtText string `json:"reset_at,omitempty"`
+ WindowSeconds int64 `json:"window_seconds,omitempty"`
+ Cap *float64 `json:"cap_tokens,omitempty"`
+ RemainingTokens *float64 `json:"remaining_tokens,omitempty"`
+ RunwayHours *float64 `json:"runway_hours,omitempty"`
+ HoursUntilReset *float64 `json:"hours_until_reset,omitempty"`
+ Coverage *float64 `json:"coverage,omitempty"`
+ Status string `json:"status"`
+ Notes []string `json:"notes,omitempty"`
+
+ resetAt *time.Time
+}
+
+// AccountReport is one keeper account's runway summary.
+type AccountReport struct {
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Provider string `json:"provider"`
+ Disabled bool `json:"disabled"`
+ BurnTokens int64 `json:"burn_tokens"`
+ BurnPerHour float64 `json:"burn_per_hour"`
+ Status string `json:"status"`
+ Buckets []BucketReport `json:"buckets"`
+}
+
+// Report is the full account-runway result.
+type Report struct {
+ GeneratedAt string `json:"generated_at"`
+ SinceDays int `json:"since_days"`
+ Provider string `json:"provider"`
+ Accounts []AccountReport `json:"accounts"`
+ UnattributedRows int `json:"unattributed_rows"`
+ UnattributedTokens int64 `json:"unattributed_tokens"`
+ Pool PoolReport `json:"pool"`
+}
+
+type PoolReport struct {
+ EnabledAccounts int `json:"enabled_accounts"`
+ DisabledAccounts int `json:"disabled_accounts"`
+ CriticalAccounts []string `json:"critical_accounts"`
+ GapTokens float64 `json:"gap_tokens"`
+ AvgWeeklyQuotaTokens *float64 `json:"avg_weekly_quota_tokens,omitempty"`
+ AdditionalAccounts int `json:"additional_accounts"`
+ Recommendation string `json:"recommendation"`
+}
+
+// Compute builds the runway report from loaded rows.
+func Compute(accounts []Account, rows []UsageRow, providerFilter string, sinceDays int, now time.Time) Report {
+ burn := attributeBurn(accounts, rows)
+ report := Report{
+ GeneratedAt: backendApp.UsageDBTime(now),
+ SinceDays: sinceDays,
+ Provider: providerFilter,
+ }
+ windowHours := float64(sinceDays) * 24
+
+ for _, account := range accounts {
+ if providerFilter != "all" && account.Provider != providerFilter {
+ continue
+ }
+ ar := AccountReport{
+ Name: account.Name,
+ Email: account.Email,
+ Provider: account.Provider,
+ Disabled: account.Disabled,
+ BurnTokens: burn.tokens[account.Name],
+ BurnPerHour: float64(burn.tokens[account.Name]) / windowHours,
+ }
+ ar.Buckets = bucketsForAccount(account, ar.BurnTokens, float64(sinceDays)*3600, now)
+ ar.Status = worstStatus(ar.Buckets)
+ report.Accounts = append(report.Accounts, ar)
+ }
+ sortAccounts(report.Accounts)
+ report.UnattributedRows = burn.rows
+ report.UnattributedTokens = burn.unattributedTokens
+ report.Pool = poolRecommendation(report.Accounts)
+ return report
+}
+
+// poolRecommendation computes the quota gap (enabled accounts that run dry
+// before their next reset) and how many extra accounts -- sized at the average
+// enabled account's weekly-equivalent cap -- would cover it.
+func poolRecommendation(accounts []AccountReport) PoolReport {
+ pool := PoolReport{}
+ var gap, weeklyCapSum float64
+ var weeklyCapCount int
+ for _, ar := range accounts {
+ if ar.Disabled {
+ pool.DisabledAccounts++
+ continue
+ }
+ pool.EnabledAccounts++
+ if ar.Status == StatusCritical {
+ pool.CriticalAccounts = append(pool.CriticalAccounts, ar.Name)
+ }
+ var bestWeekly float64
+ for _, b := range ar.Buckets {
+ if b.Cap != nil && b.WindowSeconds > 0 {
+ if weekly := *b.Cap * (secondsPerWeek / float64(b.WindowSeconds)); weekly > bestWeekly {
+ bestWeekly = weekly
+ }
+ }
+ if b.Status != StatusCritical || b.HoursUntilReset == nil || b.RemainingTokens == nil {
+ continue
+ }
+ need := ar.BurnPerHour * *b.HoursUntilReset
+ if need > *b.RemainingTokens {
+ gap += need - *b.RemainingTokens
+ }
+ }
+ if bestWeekly > 0 {
+ weeklyCapSum += bestWeekly
+ weeklyCapCount++
+ }
+ }
+ pool.GapTokens = gap
+ if weeklyCapCount > 0 {
+ avg := weeklyCapSum / float64(weeklyCapCount)
+ pool.AvgWeeklyQuotaTokens = &avg
+ }
+ switch {
+ case gap <= 0:
+ pool.Recommendation = "pool covers all enabled accounts until next reset"
+ case pool.AvgWeeklyQuotaTokens == nil || *pool.AvgWeeklyQuotaTokens <= 0:
+ pool.Recommendation = "pool runs dry before next reset; additional accounts needed but average weekly quota is unknown"
+ default:
+ pool.AdditionalAccounts = int(math.Ceil(gap / *pool.AvgWeeklyQuotaTokens))
+ pool.Recommendation = "pool runs dry before next reset; add accounts to cover the quota gap"
+ }
+ return pool
+}
+
+type burnAttribution struct {
+ tokens map[string]int64
+ rows int
+ unattributedTokens int64
+}
+
+// attributeBurn attributes usage rows to accounts the way production does
+// (keeperAccountNameForUsageRecord): source_account (already the extracted
+// email) wins; otherwise the email is extracted from `source`, else auth_index.
+// A row whose key matches no account, or matches several, is unattributed.
+func attributeBurn(accounts []Account, rows []UsageRow) burnAttribution {
+ type aliasSet struct {
+ byKey map[string]string
+ ambiguous map[string]bool
+ add func(key, name string)
+ }
+ newSet := func() *aliasSet {
+ set := &aliasSet{byKey: map[string]string{}, ambiguous: map[string]bool{}}
+ set.add = func(key, name string) {
+ key = strings.ToLower(strings.TrimSpace(key))
+ if key == "" {
+ return
+ }
+ if existing, ok := set.byKey[key]; ok {
+ if existing != name {
+ set.ambiguous[key] = true
+ }
+ return
+ }
+ set.byKey[key] = name
+ }
+ return set
+ }
+ emails, indexes := newSet(), newSet()
+ for _, account := range accounts {
+ emails.add(account.Email, account.Name)
+ if match := emailPattern.FindString(account.Name); match != "" {
+ emails.add(match, account.Name)
+ }
+ indexes.add(account.Name, account.Name)
+ indexes.add(account.AuthIndex, account.Name)
+ }
+
+ result := burnAttribution{tokens: map[string]int64{}}
+ unattributed := func(row UsageRow) {
+ result.rows++
+ result.unattributedTokens += row.TotalTokens
+ }
+ for _, row := range rows {
+ email := strings.ToLower(strings.TrimSpace(row.SourceAccount))
+ if email == "" {
+ email = strings.ToLower(emailPattern.FindString(row.Source))
+ }
+ var name string
+ switch {
+ case email != "":
+ if emails.ambiguous[email] {
+ unattributed(row)
+ continue
+ }
+ n, ok := emails.byKey[email]
+ if !ok {
+ unattributed(row)
+ continue
+ }
+ name = n
+ case strings.TrimSpace(row.AuthIndex) != "":
+ index := strings.ToLower(strings.TrimSpace(row.AuthIndex))
+ if indexes.ambiguous[index] {
+ unattributed(row)
+ continue
+ }
+ n, ok := indexes.byKey[index]
+ if !ok {
+ unattributed(row)
+ continue
+ }
+ name = n
+ default:
+ unattributed(row)
+ continue
+ }
+ result.tokens[name] += row.TotalTokens
+ }
+ return result
+}
+
+func bucketsForAccount(account Account, burnTokens int64, burnWindowSeconds float64, now time.Time) []BucketReport {
+ if account.Provider == "antigravity" {
+ return antigravityBuckets(account, burnTokens, burnWindowSeconds, now)
+ }
+ return codexBuckets(account, burnTokens, burnWindowSeconds, now)
+}
+
+func codexBuckets(account Account, burnTokens int64, burnWindowSeconds float64, now time.Time) []BucketReport {
+ out := []BucketReport{}
+ if account.PrimaryUsedPercent != nil {
+ window := codexPrimaryWindowFallbackSeconds
+ if account.PrimaryWindowSeconds != nil && *account.PrimaryWindowSeconds > 0 {
+ window = *account.PrimaryWindowSeconds
+ }
+ out = append(out, finishBucket(BucketReport{
+ Name: "primary",
+ RemainingFraction: fractionFromPercent(*account.PrimaryUsedPercent),
+ WindowSeconds: window,
+ resetAt: account.PrimaryResetAt,
+ }, burnTokens, burnWindowSeconds, now))
+ }
+ if account.SecondaryUsedPercent != nil {
+ window := codexSecondaryWindowFallbackSeconds
+ if account.SecondaryWindowSeconds != nil && *account.SecondaryWindowSeconds > 0 {
+ window = *account.SecondaryWindowSeconds
+ }
+ out = append(out, finishBucket(BucketReport{
+ Name: "secondary",
+ RemainingFraction: fractionFromPercent(*account.SecondaryUsedPercent),
+ WindowSeconds: window,
+ resetAt: account.SecondaryResetAt,
+ }, burnTokens, burnWindowSeconds, now))
+ }
+ if len(out) == 0 {
+ out = append(out, BucketReport{Name: "quota", Status: StatusUnknown, Notes: []string{"no quota snapshot recorded"}})
+ }
+ return out
+}
+
+func fractionFromPercent(used int64) float64 {
+ fraction := 1 - float64(used)/100
+ return math.Max(0, math.Min(1, fraction))
+}
+
+func antigravityBuckets(account Account, burnTokens int64, burnWindowSeconds float64, now time.Time) []BucketReport {
+ out := []BucketReport{}
+ for _, group := range account.AntigravityGroups {
+ for _, bucket := range group.Buckets {
+ name := bucket.DisplayName
+ if name == "" {
+ name = bucket.BucketID
+ }
+ if name == "" {
+ name = "bucket"
+ }
+ if group.DisplayName != "" {
+ name = group.DisplayName + "/" + name
+ }
+ report := BucketReport{
+ Name: name,
+ RemainingFraction: bucket.RemainingFraction,
+ resetAt: bucket.ResetAt,
+ }
+ window, ok := parseAntigravityWindow(bucket.Window)
+ if !ok {
+ report.Status = StatusUnknown
+ report.Notes = append(report.Notes, "window_unknown")
+ if bucket.ResetAt != nil {
+ report.ResetAtText = backendApp.UsageDBTime(*bucket.ResetAt)
+ }
+ out = append(out, report)
+ continue
+ }
+ report.WindowSeconds = window
+ out = append(out, finishBucket(report, burnTokens, burnWindowSeconds, now))
+ }
+ }
+ if len(out) == 0 {
+ out = append(out, BucketReport{Name: "quota", Status: StatusUnknown, Notes: []string{"no quota snapshot recorded"}})
+ }
+ return out
+}
+
+// parseAntigravityWindow maps the bucket's `window` label to seconds. Known
+// labels: named windows ("weekly", "monthly", "daily", "5h") and Go durations.
+// Anything else is window_unknown -- excluded from runway math, still shown.
+func parseAntigravityWindow(window string) (int64, bool) {
+ text := strings.ToLower(strings.TrimSpace(window))
+ if text == "" {
+ return 0, false
+ }
+ switch {
+ case strings.Contains(text, "month"):
+ return 2592000, true
+ case strings.Contains(text, "week"):
+ return 604800, true
+ case strings.Contains(text, "day"):
+ return 86400, true
+ }
+ if d, err := time.ParseDuration(text); err == nil && d > 0 {
+ return int64(d.Seconds()), true
+ }
+ return 0, false
+}
+
+// finishBucket fills in derived fields: reset text, hours until reset, cap
+// estimate, runway hours, coverage, status.
+//
+// Cap estimation (方案 B): no absolute quota is stored, so the cap is inferred
+// as consumed_in_window / (1 - remaining_fraction). The window's consumption is
+// approximated from the observed burn: elapsed_in_window / burn_window of the
+// measured tokens. Consumption of zero (or fraction == 1) makes the cap
+// indeterminate -- marked cap_unknown rather than guessed.
+func finishBucket(b BucketReport, burnTokens int64, burnWindowSeconds float64, now time.Time) BucketReport {
+ var elapsed float64
+ if b.resetAt != nil {
+ b.ResetAtText = backendApp.UsageDBTime(*b.resetAt)
+ hours := b.resetAt.Sub(now).Hours()
+ b.HoursUntilReset = &hours
+ elapsed = float64(b.WindowSeconds) - b.resetAt.Sub(now).Seconds()
+ if elapsed < 0 {
+ // Reset lies further out than the window length: snapshot is
+ // inconsistent, treat the whole window as consumed-at-risk.
+ elapsed = float64(b.WindowSeconds)
+ }
+ if elapsed > float64(b.WindowSeconds) {
+ elapsed = float64(b.WindowSeconds)
+ }
+ } else {
+ b.Notes = append(b.Notes, "reset_unknown")
+ }
+
+ var consumed float64
+ if elapsed > 0 {
+ if elapsed <= burnWindowSeconds {
+ consumed = float64(burnTokens)
+ } else {
+ consumed = float64(burnTokens) * elapsed / burnWindowSeconds
+ // The cap estimate leans on scaling a short observation up to the
+ // elapsed window; flag it so readers weigh confidence accordingly.
+ b.Notes = append(b.Notes, "short_observation")
+ }
+ }
+ if consumed > 0 && b.RemainingFraction < 1 {
+ cap := consumed / (1 - b.RemainingFraction)
+ b.Cap = &cap
+ remaining := cap * b.RemainingFraction
+ b.RemainingTokens = &remaining
+ burnPerHour := float64(burnTokens) / (burnWindowSeconds / 3600)
+ runway := math.Inf(1)
+ if burnPerHour > 0 {
+ runway = remaining / burnPerHour
+ }
+ b.RunwayHours = &runway
+ if b.HoursUntilReset != nil && *b.HoursUntilReset > 0 {
+ coverage := runway / *b.HoursUntilReset
+ b.Coverage = &coverage
+ if coverage >= 1 {
+ b.Status = StatusHealthy
+ } else {
+ b.Status = StatusCritical
+ }
+ } else {
+ // Reset already due/passed: the window refreshes imminently, so the
+ // bucket is not what limits the account.
+ b.Status = StatusHealthy
+ }
+ } else {
+ b.Notes = append(b.Notes, "cap_unknown")
+ }
+ if b.Status == "" {
+ b.Status = StatusUnknown
+ }
+ return b
+}
+
+func worstStatus(buckets []BucketReport) string {
+ worst := StatusHealthy
+ for _, b := range buckets {
+ if statusRank[b.Status] < statusRank[worst] {
+ worst = b.Status
+ }
+ }
+ return worst
+}
+
+// sortAccounts orders reports by status severity then name for stable output.
+func sortAccounts(accounts []AccountReport) {
+ sort.SliceStable(accounts, func(i, j int) bool {
+ ri, rj := statusRank[accounts[i].Status], statusRank[accounts[j].Status]
+ if ri != rj {
+ return ri < rj
+ }
+ return accounts[i].Name < accounts[j].Name
+ })
+}
diff --git a/backend/internal/accountrunway/runway_test.go b/backend/internal/accountrunway/runway_test.go
new file mode 100644
index 00000000..65f2abd9
--- /dev/null
+++ b/backend/internal/accountrunway/runway_test.go
@@ -0,0 +1,313 @@
+package accountrunway
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "math"
+ "testing"
+ "time"
+
+ backendApp "cpa-helper/backend/internal/app"
+)
+
+var fixedNow = time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC)
+
+func codexAccount(name, email string, primaryUsed int64, reset time.Time, window int64) Account {
+ return Account{
+ Name: name,
+ Email: email,
+ Provider: "codex",
+ PrimaryUsedPercent: i64(primaryUsed),
+ PrimaryResetAt: &reset,
+ PrimaryWindowSeconds: i64(window),
+ }
+}
+
+// TestComputeBurnAttribution covers the production attribution order:
+// source_account email wins, then email extracted from source, then auth_index;
+// unmatched and ambiguous rows are counted as unattributed.
+func TestComputeBurnAttribution(t *testing.T) {
+ idx := codexAccount("idx-7", "b@x.com", 50, fixedNow.Add(2*time.Hour), 18000)
+ idx.AuthIndex = "hash-zzz"
+ accounts := []Account{
+ codexAccount("acct-a@x.com", "a@x.com", 50, fixedNow.Add(2*time.Hour), 18000),
+ idx,
+ }
+ rows := []UsageRow{
+ {SourceAccount: "a@x.com", TotalTokens: 100}, // by source_account
+ {Source: "cli user b@x.com tail", TotalTokens: 40}, // email from source
+ {AuthIndex: "hash-zzz", TotalTokens: 20}, // via stored auth_index alias
+ {SourceAccount: "ghost@x.com", TotalTokens: 999}, // no match
+ {TotalTokens: 5}, // nothing to match on
+ {SourceAccount: "A@X.COM", TotalTokens: 10, Failed: true}, // case + failed still counts
+ }
+ report := Compute(accounts, rows, "all", 1, fixedNow)
+
+ byName := map[string]AccountReport{}
+ for _, a := range report.Accounts {
+ byName[a.Name] = a
+ }
+ if got := byName["acct-a@x.com"].BurnTokens; got != 110 {
+ t.Fatalf("acct-a burn = %d, want 110 (100 + failed 10)", got)
+ }
+ if got := byName["idx-7"].BurnTokens; got != 60 {
+ t.Fatalf("idx-7 burn = %d, want 60 (40 via source email + 20 via stored auth_index alias)", got)
+ }
+ if report.UnattributedRows != 2 || report.UnattributedTokens != 1004 {
+ t.Fatalf("unattributed = %d rows / %d tokens, want 2 / 1004",
+ report.UnattributedRows, report.UnattributedTokens)
+ }
+}
+
+// TestComputeAmbiguousEmailUnattributed: two accounts sharing an email alias
+// must not silently absorb burn — rows keyed to the shared email are
+// unattributed.
+func TestComputeAmbiguousEmailUnattributed(t *testing.T) {
+ accounts := []Account{
+ codexAccount("one", "shared@x.com", 50, fixedNow.Add(time.Hour), 18000),
+ codexAccount("two", "shared@x.com", 50, fixedNow.Add(time.Hour), 18000),
+ }
+ rows := []UsageRow{{SourceAccount: "shared@x.com", TotalTokens: 77}}
+ report := Compute(accounts, rows, "all", 1, fixedNow)
+ if report.UnattributedRows != 1 || report.UnattributedTokens != 77 {
+ t.Fatalf("ambiguous email should be unattributed: %+v", report)
+ }
+}
+
+// TestComputeFractionOneIsCapUnknown guards the fraction==1 division: a bucket
+// at 0% used has an indeterminate cap — UNKNOWN with cap_unknown, no Inf/NaN.
+func TestComputeFractionOneIsCapUnknown(t *testing.T) {
+ accounts := []Account{
+ codexAccount("fresh@x.com", "fresh@x.com", 0, fixedNow.Add(3*time.Hour), 18000),
+ }
+ rows := []UsageRow{{SourceAccount: "fresh@x.com", TotalTokens: 500}}
+ report := Compute(accounts, rows, "all", 1, fixedNow)
+
+ b := report.Accounts[0].Buckets[0]
+ if b.Status != StatusUnknown {
+ t.Fatalf("status = %q, want UNKNOWN", b.Status)
+ }
+ if b.Cap != nil || b.RemainingTokens != nil || b.RunwayHours != nil {
+ t.Fatalf("fraction==1 must not produce cap/runway numbers: %+v", b)
+ }
+ found := false
+ for _, n := range b.Notes {
+ if n == "cap_unknown" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("expected cap_unknown note, got %v", b.Notes)
+ }
+ if math.IsInf(b.RemainingFraction, 0) || math.IsNaN(b.RemainingFraction) {
+ t.Fatal("fraction became Inf/NaN")
+ }
+ if report.Accounts[0].Status != StatusUnknown {
+ t.Fatalf("account status = %q, want UNKNOWN", report.Accounts[0].Status)
+ }
+}
+
+// TestComputeHealthyAndCritical: remaining tokens vs burn-until-reset decides
+// status. Account "tight" burns so fast its bucket empties before reset.
+func TestComputeHealthyAndCritical(t *testing.T) {
+ reset := fixedNow.Add(4 * time.Hour)
+ accounts := []Account{
+ codexAccount("rich@x.com", "rich@x.com", 10, reset, 18000), // 90% left
+ codexAccount("tight@x.com", "tight@x.com", 95, reset, 18000), // 5% left
+ }
+ // 1-day window, both accounts burning the same absolute rate; the tight
+ // account's remaining sliver is what turns CRITICAL.
+ rows := []UsageRow{
+ {SourceAccount: "rich@x.com", TotalTokens: 2400},
+ {SourceAccount: "tight@x.com", TotalTokens: 2400},
+ }
+ report := Compute(accounts, rows, "all", 1, fixedNow)
+ byName := map[string]AccountReport{}
+ for _, a := range report.Accounts {
+ byName[a.Name] = a
+ }
+ if byName["rich@x.com"].Status != StatusHealthy {
+ t.Fatalf("rich should be HEALTHY, got %q (%+v)", byName["rich@x.com"].Status, byName["rich@x.com"].Buckets[0])
+ }
+ if byName["tight@x.com"].Status != StatusCritical {
+ t.Fatalf("tight should be CRITICAL, got %q (%+v)", byName["tight@x.com"].Status, byName["tight@x.com"].Buckets[0])
+ }
+ // Sorted: critical first.
+ if report.Accounts[0].Name != "tight@x.com" {
+ t.Fatalf("critical account should sort first, got %q", report.Accounts[0].Name)
+ }
+ // Only the enabled critical account lands in pool.critical_accounts.
+ found := false
+ for _, n := range report.Pool.CriticalAccounts {
+ if n == "tight@x.com" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("pool critical_accounts missing tight: %v", report.Pool.CriticalAccounts)
+ }
+ if report.Pool.GapTokens <= 0 {
+ t.Fatalf("expected positive gap, got %v", report.Pool.GapTokens)
+ }
+}
+
+// TestComputeDisabledExcludedFromGap: disabled accounts are counted but never
+// contribute to the pool gap or additional-accounts recommendation.
+func TestComputeDisabledExcludedFromGap(t *testing.T) {
+ reset := fixedNow.Add(4 * time.Hour)
+ disabled := codexAccount("dead@x.com", "dead@x.com", 95, reset, 18000)
+ disabled.Disabled = true
+ accounts := []Account{
+ codexAccount("rich@x.com", "rich@x.com", 10, reset, 18000),
+ disabled,
+ }
+ rows := []UsageRow{
+ {SourceAccount: "rich@x.com", TotalTokens: 100},
+ {SourceAccount: "dead@x.com", TotalTokens: 99999}, // huge burn, must not widen gap
+ }
+ report := Compute(accounts, rows, "all", 1, fixedNow)
+
+ if report.Pool.DisabledAccounts != 1 || report.Pool.EnabledAccounts != 1 {
+ t.Fatalf("pool counts wrong: %+v", report.Pool)
+ }
+ for _, n := range report.Pool.CriticalAccounts {
+ if n == "dead@x.com" {
+ t.Fatal("disabled account listed as critical")
+ }
+ }
+ if report.Pool.AdditionalAccounts != 0 {
+ t.Fatalf("disabled account influenced additional_accounts: %+v", report.Pool)
+ }
+ if report.Pool.GapTokens != 0 {
+ t.Fatalf("disabled account contributed to gap: %v", report.Pool.GapTokens)
+ }
+}
+
+// TestComputeProviderFilter keeps only the requested provider's accounts.
+func TestComputeProviderFilter(t *testing.T) {
+ accounts := []Account{
+ codexAccount("c@x.com", "c@x.com", 50, fixedNow.Add(time.Hour), 18000),
+ {Name: "a@x.com", Email: "a@x.com", Provider: "antigravity"},
+ }
+ report := Compute(accounts, nil, "codex", 1, fixedNow)
+ if len(report.Accounts) != 1 || report.Accounts[0].Provider != "codex" {
+ t.Fatalf("provider filter leaked accounts: %+v", report.Accounts)
+ }
+}
+
+// TestComputeAntigravityBuckets: groups/buckets decode to named buckets;
+// an unparseable window label is shown but excluded from runway math.
+func TestComputeAntigravityBuckets(t *testing.T) {
+ reset := fixedNow.Add(24 * time.Hour)
+ account := Account{
+ Name: "anti@x.com",
+ Email: "anti@x.com",
+ Provider: "antigravity",
+ AntigravityGroups: []antigravityGroup{{
+ DisplayName: "Gemini",
+ Buckets: []antigravityBucket{
+ {BucketID: "w", DisplayName: "Weekly", Window: "weekly", RemainingFraction: 0.8, ResetAt: &reset},
+ {BucketID: "x", DisplayName: "Odd", Window: "fortnightly-ish", RemainingFraction: 0.5},
+ },
+ }},
+ }
+ report := Compute([]Account{account}, nil, "all", 1, fixedNow)
+ buckets := report.Accounts[0].Buckets
+ if len(buckets) != 2 {
+ t.Fatalf("expected 2 buckets, got %+v", buckets)
+ }
+ if buckets[0].Name != "Gemini/Weekly" || buckets[0].WindowSeconds != 604800 {
+ t.Fatalf("weekly bucket wrong: %+v", buckets[0])
+ }
+ if buckets[1].Status != StatusUnknown {
+ t.Fatalf("unparseable window should be UNKNOWN, got %+v", buckets[1])
+ }
+ hasWindowNote := false
+ for _, n := range buckets[1].Notes {
+ if n == "window_unknown" {
+ hasWindowNote = true
+ }
+ }
+ if !hasWindowNote {
+ t.Fatalf("expected window_unknown note, got %v", buckets[1].Notes)
+ }
+}
+
+// TestRunAtGoldenJSON drives the full subcommand against a fixture DB with a
+// fixed clock and asserts the decoded report fields.
+func TestRunAtGoldenJSON(t *testing.T) {
+ writable, path := newFixtureDB(t)
+ reset := fixedNow.Add(4 * time.Hour)
+ insertAccount(t, writable, accountFixture{
+ name: "solo@x.com",
+ email: "solo@x.com",
+ provider: "codex",
+ primaryUsed: i64(50),
+ primaryReset: &reset,
+ primaryWin: i64(18000),
+ })
+ insertUsage(t, writable, usageFixture{at: fixedNow.Add(-time.Hour), sourceAccount: "solo@x.com", tokens: 1000})
+ insertUsage(t, writable, usageFixture{at: fixedNow.Add(-30 * time.Hour), sourceAccount: "solo@x.com", tokens: 9999})
+ if err := writable.Close(); err != nil {
+ t.Fatalf("close writable: %v", err)
+ }
+
+ var out bytes.Buffer
+ err := RunAt(context.Background(), []string{"--db", path, "--since", "1", "--json"}, &out, fixedNow)
+ if err != nil {
+ t.Fatalf("RunAt: %v", err)
+ }
+ var report Report
+ if err := json.Unmarshal(out.Bytes(), &report); err != nil {
+ t.Fatalf("output is not valid JSON: %v\n%s", err, out.String())
+ }
+ if report.GeneratedAt != backendApp.UsageDBTime(fixedNow) {
+ t.Fatalf("generated_at = %q, want %q", report.GeneratedAt, backendApp.UsageDBTime(fixedNow))
+ }
+ if report.SinceDays != 1 || report.Provider != "all" {
+ t.Fatalf("report meta wrong: %+v", report)
+ }
+ if len(report.Accounts) != 1 {
+ t.Fatalf("expected 1 account, got %+v", report.Accounts)
+ }
+ acct := report.Accounts[0]
+ if acct.Name != "solo@x.com" || acct.BurnTokens != 1000 {
+ t.Fatalf("account wrong: %+v", acct)
+ }
+ if len(acct.Buckets) != 1 || acct.Buckets[0].Name != "primary" {
+ t.Fatalf("buckets wrong: %+v", acct.Buckets)
+ }
+ if acct.Buckets[0].Cap == nil {
+ t.Fatalf("expected a cap estimate: %+v", acct.Buckets[0])
+ }
+ if report.UnattributedRows != 0 {
+ t.Fatalf("unexpected unattributed rows: %d", report.UnattributedRows)
+ }
+}
+
+// TestRunAtTextOutput exercises the human-readable path end to end.
+func TestRunAtTextOutput(t *testing.T) {
+ writable, path := newFixtureDB(t)
+ reset := fixedNow.Add(4 * time.Hour)
+ insertAccount(t, writable, accountFixture{
+ name: "solo@x.com",
+ email: "solo@x.com",
+ primaryUsed: i64(50),
+ primaryReset: &reset,
+ })
+ insertUsage(t, writable, usageFixture{at: fixedNow.Add(-time.Hour), sourceAccount: "solo@x.com", tokens: 100})
+ if err := writable.Close(); err != nil {
+ t.Fatalf("close writable: %v", err)
+ }
+ var out bytes.Buffer
+ if err := RunAt(context.Background(), []string{"--db", path}, &out, fixedNow); err != nil {
+ t.Fatalf("RunAt: %v", err)
+ }
+ text := out.String()
+ for _, want := range []string{"ACCOUNT", "solo@x.com", "primary", "pool:"} {
+ if !bytes.Contains(out.Bytes(), []byte(want)) {
+ t.Fatalf("text output missing %q:\n%s", want, text)
+ }
+ }
+}
diff --git a/backend/internal/accountrunway/store.go b/backend/internal/accountrunway/store.go
new file mode 100644
index 00000000..f87949fd
--- /dev/null
+++ b/backend/internal/accountrunway/store.go
@@ -0,0 +1,176 @@
+package accountrunway
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "time"
+
+ backendApp "cpa-helper/backend/internal/app"
+
+ _ "modernc.org/sqlite"
+)
+
+// OpenReadOnly opens the CPA-Helper database for reporting only.
+//
+// Two independent guards, because this points at production data: the DSN asks
+// SQLite for a read-only connection, and `query_only` is then asserted on the
+// connection that was actually handed back. The second is not redundant -- a
+// future change to the DSN (adding a parameter, switching helper) can quietly
+// drop `mode=ro`, and without the assertion the first write would succeed
+// instead of failing.
+func OpenReadOnly(ctx context.Context, path string) (*sql.DB, error) {
+ db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro&_pragma=query_only(1)&_pragma=busy_timeout(5000)", path))
+ if err != nil {
+ return nil, err
+ }
+ // One connection: a pool would need the pragma re-asserted per connection,
+ // and this tool has no concurrency to gain from more.
+ db.SetMaxOpenConns(1)
+ if err := db.PingContext(ctx); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("open %s read-only: %w", path, err)
+ }
+ var queryOnly int
+ if err := db.QueryRowContext(ctx, "PRAGMA query_only").Scan(&queryOnly); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("read query_only pragma: %w", err)
+ }
+ if queryOnly != 1 {
+ db.Close()
+ return nil, fmt.Errorf("refusing to continue: connection to %s is not query_only", path)
+ }
+ return db, nil
+}
+
+// Account is one keeper account row (codex_keeper_auth_states) plus the
+// decoded antigravity quota snapshot when the account is an antigravity one.
+type Account struct {
+ Name string
+ Email string
+ AuthIndex string
+ Disabled bool
+ Provider string // "codex" (default) or "antigravity"
+ PrimaryUsedPercent *int64
+ SecondaryUsedPercent *int64
+ PrimaryResetAt *time.Time
+ SecondaryResetAt *time.Time
+ PrimaryWindowSeconds *int64
+ SecondaryWindowSeconds *int64
+ AntigravityGroups []antigravityGroup
+}
+
+// UsageRow is a usage_records row inside the burn-rate window, carrying only
+// what attribution and aggregation need.
+type UsageRow struct {
+ SourceAccount string
+ Source string
+ AuthIndex string
+ TotalTokens int64
+ Failed bool
+}
+
+// LoadAccounts reads every keeper account. Provider NULL/empty normalises to
+// "codex" -- rows predating multi-provider support are codex.
+func LoadAccounts(ctx context.Context, db *sql.DB) ([]Account, error) {
+ rows, err := db.QueryContext(ctx, `
+ SELECT auth_name, auth_index, email, disabled, provider,
+ primary_used_percent, secondary_used_percent,
+ primary_reset_at, secondary_reset_at,
+ primary_window_seconds, secondary_window_seconds,
+ antigravity_quota
+ FROM codex_keeper_auth_states
+ ORDER BY auth_name`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ accounts := []Account{}
+ for rows.Next() {
+ var (
+ account Account
+ authName, authIndex, email sql.NullString
+ disabled bool
+ provider, primaryReset, secondaryReset sql.NullString
+ primaryUsed, secondaryUsed sql.NullInt64
+ primaryWindow, secondaryWindow sql.NullInt64
+ antigravityQuota sql.NullString
+ )
+ if err := rows.Scan(&authName, &authIndex, &email, &disabled, &provider,
+ &primaryUsed, &secondaryUsed, &primaryReset, &secondaryReset,
+ &primaryWindow, &secondaryWindow, &antigravityQuota); err != nil {
+ return nil, err
+ }
+ account.Name = nullableString(authName)
+ account.Email = nullableString(email)
+ account.AuthIndex = nullableString(authIndex)
+ account.Disabled = disabled
+ account.Provider = "codex"
+ if p := nullableString(provider); p != "" {
+ account.Provider = p
+ }
+ account.PrimaryUsedPercent = nullableInt(primaryUsed)
+ account.SecondaryUsedPercent = nullableInt(secondaryUsed)
+ account.PrimaryWindowSeconds = nullableInt(primaryWindow)
+ account.SecondaryWindowSeconds = nullableInt(secondaryWindow)
+ if t, ok := backendApp.UsageParseDBTime(primaryReset.String); primaryReset.Valid && ok {
+ account.PrimaryResetAt = &t
+ }
+ if t, ok := backendApp.UsageParseDBTime(secondaryReset.String); secondaryReset.Valid && ok {
+ account.SecondaryResetAt = &t
+ }
+ account.AntigravityGroups = parseAntigravityQuota(antigravityQuota)
+ accounts = append(accounts, account)
+ }
+ return accounts, rows.Err()
+}
+
+// LoadUsageRows reads usage rows at or after `since`. The bound is the UsageDBTime()
+// byte shape production writes to the TEXT `timestamp` column -- a time.Time
+// bound would be serialised by the driver as "2006-01-02 15:04:05 +0000 UTC"
+// (space separator), and ' ' < 'T' makes the lexicographic >= let older
+// same-date rows through. Binding the same layout production writes is the
+// only way the comparison means what it says.
+func LoadUsageRows(ctx context.Context, db *sql.DB, since time.Time) ([]UsageRow, error) {
+ rows, err := db.QueryContext(ctx, `
+ SELECT source_account, source, auth_index, total_tokens, failed
+ FROM usage_records
+ WHERE timestamp >= ?
+ ORDER BY id`, backendApp.UsageDBTime(since))
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ result := []UsageRow{}
+ for rows.Next() {
+ var (
+ row UsageRow
+ sourceAccount, source, authIndex sql.NullString
+ )
+ if err := rows.Scan(&sourceAccount, &source, &authIndex, &row.TotalTokens, &row.Failed); err != nil {
+ return nil, err
+ }
+ row.SourceAccount = nullableString(sourceAccount)
+ row.Source = nullableString(source)
+ row.AuthIndex = nullableString(authIndex)
+ result = append(result, row)
+ }
+ return result, rows.Err()
+}
+
+func nullableString(v sql.NullString) string {
+ if !v.Valid {
+ return ""
+ }
+ return v.String
+}
+
+func nullableInt(v sql.NullInt64) *int64 {
+ if !v.Valid {
+ return nil
+ }
+ value := v.Int64
+ return &value
+}
diff --git a/backend/internal/accountrunway/store_test.go b/backend/internal/accountrunway/store_test.go
new file mode 100644
index 00000000..f8d1c8bb
--- /dev/null
+++ b/backend/internal/accountrunway/store_test.go
@@ -0,0 +1,300 @@
+package accountrunway
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "path/filepath"
+ "testing"
+ "time"
+
+ backendApp "cpa-helper/backend/internal/app"
+
+ _ "modernc.org/sqlite"
+)
+
+// newFixtureDB creates a writable database with the minimal schema the report
+// queries touch, then returns its path. Rows must be inserted through the
+// writable handle (via insertAccount / insertUsage) before OpenReadOnly reads.
+func newFixtureDB(t *testing.T) (*sql.DB, string) {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "cpa_helper.sqlite3")
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatalf("open writable db: %v", err)
+ }
+ _, err = db.Exec(`
+ CREATE TABLE codex_keeper_auth_states (
+ auth_name TEXT PRIMARY KEY,
+ auth_index TEXT,
+ email TEXT,
+ disabled BOOLEAN NOT NULL DEFAULT 0,
+ provider TEXT,
+ primary_used_percent INTEGER,
+ secondary_used_percent INTEGER,
+ primary_reset_at TEXT,
+ secondary_reset_at TEXT,
+ primary_window_seconds INTEGER,
+ secondary_window_seconds INTEGER,
+ antigravity_quota TEXT
+ );
+ CREATE TABLE usage_records (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timestamp DATETIME,
+ source_account TEXT,
+ source TEXT,
+ auth_index TEXT,
+ total_tokens INTEGER NOT NULL DEFAULT 0,
+ failed BOOLEAN NOT NULL DEFAULT 0
+ )`)
+ if err != nil {
+ db.Close()
+ t.Fatalf("create schema: %v", err)
+ }
+ return db, path
+}
+
+type accountFixture struct {
+ name string
+ authIndex string
+ email string
+ disabled bool
+ provider string
+ primaryUsed *int64
+ secondaryUsed *int64
+ primaryReset *time.Time
+ secondaryRst *time.Time
+ primaryWin *int64
+ secondaryWin *int64
+ antiQuota string
+}
+
+func insertAccount(t *testing.T, db *sql.DB, a accountFixture) {
+ t.Helper()
+ var primaryReset, secondaryReset interface{}
+ if a.primaryReset != nil {
+ primaryReset = backendApp.UsageDBTime(*a.primaryReset)
+ }
+ if a.secondaryRst != nil {
+ secondaryReset = backendApp.UsageDBTime(*a.secondaryRst)
+ }
+ var provider interface{}
+ if a.provider != "" {
+ provider = a.provider
+ }
+ var authIndex interface{}
+ if a.authIndex != "" {
+ authIndex = a.authIndex
+ }
+ var quota interface{}
+ if a.antiQuota != "" {
+ quota = a.antiQuota
+ }
+ _, err := db.Exec(`INSERT INTO codex_keeper_auth_states
+ (auth_name, auth_index, email, disabled, provider, primary_used_percent, secondary_used_percent,
+ primary_reset_at, secondary_reset_at, primary_window_seconds, secondary_window_seconds,
+ antigravity_quota)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
+ a.name, authIndex, a.email, a.disabled, provider, a.primaryUsed, a.secondaryUsed,
+ primaryReset, secondaryReset, a.primaryWin, a.secondaryWin, quota)
+ if err != nil {
+ t.Fatalf("insert account %s: %v", a.name, err)
+ }
+}
+
+type usageFixture struct {
+ at time.Time
+ sourceAccount string
+ source string
+ authIndex string
+ tokens int64
+ failed bool
+}
+
+func insertUsage(t *testing.T, db *sql.DB, u usageFixture) {
+ t.Helper()
+ var sourceAccount, source, authIndex interface{}
+ if u.sourceAccount != "" {
+ sourceAccount = u.sourceAccount
+ }
+ if u.source != "" {
+ source = u.source
+ }
+ if u.authIndex != "" {
+ authIndex = u.authIndex
+ }
+ _, err := db.Exec(`INSERT INTO usage_records
+ (timestamp, source_account, source, auth_index, total_tokens, failed)
+ VALUES (?,?,?,?,?,?)`,
+ backendApp.UsageDBTime(u.at), sourceAccount, source, authIndex, u.tokens, u.failed)
+ if err != nil {
+ t.Fatalf("insert usage: %v", err)
+ }
+}
+
+func i64(v int64) *int64 { return &v }
+
+func TestOpenReadOnlyEnforcesQueryOnly(t *testing.T) {
+ writable, path := newFixtureDB(t)
+ if err := writable.Close(); err != nil {
+ t.Fatalf("close writable: %v", err)
+ }
+
+ db, err := OpenReadOnly(context.Background(), path)
+ if err != nil {
+ t.Fatalf("OpenReadOnly: %v", err)
+ }
+ defer db.Close()
+
+ if _, err := db.Exec(`INSERT INTO codex_keeper_auth_states (auth_name) VALUES ('x')`); err == nil {
+ t.Fatal("write succeeded on read-only connection")
+ }
+}
+
+func TestOpenReadOnlyMissingDB(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "missing.sqlite3")
+ if _, err := OpenReadOnly(context.Background(), path); err == nil {
+ t.Fatal("OpenReadOnly accepted a missing database file")
+ }
+}
+
+// TestLoadUsageRowsSinceBoundIsDbTime is the mutation-teeth test for the
+// timestamp comparison. The trap row stores the production '+08:00' layout with
+// the same UTC calendar date as the bound but an instant two hours older: a
+// buggy time.Time bound serialises as 'YYYY-MM-DD HH:MM:SS +0000 UTC', and
+// ' ' < 'T' makes the lexicographic >= wrongly include that row. The fixed
+// UsageDBTime bind ('T' separator) excludes it.
+func TestLoadUsageRowsSinceBoundIsDbTime(t *testing.T) {
+ writable, path := newFixtureDB(t)
+ now := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC)
+ since := now.Add(-24 * time.Hour)
+ // Stored literal: bound instant minus 2h, formatted at +08:00 so its UTC
+ // calendar date matches the bound's -- the ' ' < 'T' same-date trap.
+ stored := since.Add(-2 * time.Hour).In(time.FixedZone("Asia/Shanghai", 8*60*60)).Format("2006-01-02T15:04:05-07:00")
+ if _, err := writable.Exec(`INSERT INTO usage_records (timestamp, source_account, total_tokens) VALUES ('` + stored + `','a@x.com',111)`); err != nil {
+ t.Fatalf("insert trap row: %v", err)
+ }
+ insertUsage(t, writable, usageFixture{at: now.Add(-12 * time.Hour), sourceAccount: "a@x.com", tokens: 222})
+ if err := writable.Close(); err != nil {
+ t.Fatalf("close writable: %v", err)
+ }
+
+ db, err := OpenReadOnly(context.Background(), path)
+ if err != nil {
+ t.Fatalf("OpenReadOnly: %v", err)
+ }
+ defer db.Close()
+
+ rows, err := LoadUsageRows(context.Background(), db, since)
+ if err != nil {
+ t.Fatalf("LoadUsageRows: %v", err)
+ }
+ if len(rows) != 1 || rows[0].TotalTokens != 222 {
+ t.Fatalf("expected only the in-window row (222 tokens), got %+v", rows)
+ }
+}
+
+func TestLoadAccountsNormalisesProviderAndParsesResets(t *testing.T) {
+ writable, path := newFixtureDB(t)
+ reset := time.Date(2026, 9, 21, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
+ insertAccount(t, writable, accountFixture{
+ name: "codex-a@x.com",
+ email: "a@x.com",
+ primaryUsed: i64(40),
+ primaryReset: &reset,
+ primaryWin: i64(18000),
+ })
+ insertAccount(t, writable, accountFixture{
+ name: "anti-b@x.com",
+ email: "b@x.com",
+ provider: "antigravity",
+ antiQuota: `[{"display_name":"Gemini","buckets":[{"bucket_id":"b1","display_name":"Pro","window":"weekly","remaining_fraction":0.5,"reset_at":"2026-09-22T00:00:00+08:00"}]}]`,
+ })
+ if err := writable.Close(); err != nil {
+ t.Fatalf("close writable: %v", err)
+ }
+
+ db, err := OpenReadOnly(context.Background(), path)
+ if err != nil {
+ t.Fatalf("OpenReadOnly: %v", err)
+ }
+ defer db.Close()
+
+ accounts, err := LoadAccounts(context.Background(), db)
+ if err != nil {
+ t.Fatalf("LoadAccounts: %v", err)
+ }
+ if len(accounts) != 2 {
+ t.Fatalf("expected 2 accounts, got %d", len(accounts))
+ }
+ var codex, anti Account
+ for _, a := range accounts {
+ switch a.Name {
+ case "codex-a@x.com":
+ codex = a
+ case "anti-b@x.com":
+ anti = a
+ }
+ }
+ if codex.Provider != "codex" {
+ t.Fatalf("NULL provider should normalise to codex, got %q", codex.Provider)
+ }
+ if codex.PrimaryResetAt == nil || !codex.PrimaryResetAt.Equal(reset) {
+ t.Fatalf("primary reset not parsed: %+v", codex.PrimaryResetAt)
+ }
+ if anti.Provider != "antigravity" || len(anti.AntigravityGroups) != 1 {
+ t.Fatalf("antigravity account not decoded: %+v", anti)
+ }
+ if got := anti.AntigravityGroups[0].Buckets[0].RemainingFraction; got != 0.5 {
+ t.Fatalf("antigravity fraction = %v, want 0.5", got)
+ }
+}
+
+func TestLoadAccountsMalformedQuotaYieldsNoGroups(t *testing.T) {
+ writable, path := newFixtureDB(t)
+ insertAccount(t, writable, accountFixture{
+ name: "anti-bad@x.com",
+ provider: "antigravity",
+ antiQuota: `{not json`,
+ })
+ if err := writable.Close(); err != nil {
+ t.Fatalf("close writable: %v", err)
+ }
+ db, err := OpenReadOnly(context.Background(), path)
+ if err != nil {
+ t.Fatalf("OpenReadOnly: %v", err)
+ }
+ defer db.Close()
+ accounts, err := LoadAccounts(context.Background(), db)
+ if err != nil {
+ t.Fatalf("LoadAccounts: %v", err)
+ }
+ if len(accounts) != 1 || len(accounts[0].AntigravityGroups) != 0 {
+ t.Fatalf("malformed quota should yield no groups: %+v", accounts)
+ }
+}
+
+func TestParseArgsValidation(t *testing.T) {
+ if _, err := ParseArgs([]string{"--since", "0"}, time.Now()); err == nil {
+ t.Fatal("--since 0 accepted")
+ }
+ if _, err := ParseArgs([]string{"--provider", "claude"}, time.Now()); err == nil {
+ t.Fatal("unknown provider accepted")
+ }
+ opts, err := ParseArgs([]string{"--since", "7", "--provider", "codex", "--json"}, time.Now())
+ if err != nil {
+ t.Fatalf("valid args rejected: %v", err)
+ }
+ if opts.Since != 7 || opts.Provider != "codex" || !opts.JSON {
+ t.Fatalf("parsed options wrong: %+v", opts)
+ }
+}
+
+// Sanity check that UsageDBTime emits the 'T'-separated layout the bound relies on.
+func TestDbTimeLayout(t *testing.T) {
+ ts := backendApp.UsageDBTime(time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC))
+ want := fmt.Sprintf("2026-09-20T%02d:00:00", 12+8) // UTC+8
+ if ts[:len(want)] != want {
+ t.Fatalf("UsageDBTime layout unexpected: %q", ts)
+ }
+}
diff --git a/backend/internal/accountrunway/table.go b/backend/internal/accountrunway/table.go
new file mode 100644
index 00000000..05c92766
--- /dev/null
+++ b/backend/internal/accountrunway/table.go
@@ -0,0 +1,25 @@
+package accountrunway
+
+import (
+ "fmt"
+ "io"
+ "strings"
+ "text/tabwriter"
+)
+
+// tableWriter is a thin text/tabwriter helper for aligned terminal output.
+type tableWriter struct {
+ w *tabwriter.Writer
+}
+
+func newTableWriter(out io.Writer) *tableWriter {
+ return &tableWriter{w: tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)}
+}
+
+func (t *tableWriter) row(cells ...string) {
+ fmt.Fprintln(t.w, strings.Join(cells, "\t"))
+}
+
+func (t *tableWriter) flush() {
+ _ = t.w.Flush()
+}
diff --git a/backend/internal/app/usage_cost_export.go b/backend/internal/app/usage_cost_export.go
index 445c3111..284836a6 100644
--- a/backend/internal/app/usage_cost_export.go
+++ b/backend/internal/app/usage_cost_export.go
@@ -30,3 +30,7 @@ func UsageDBPath() (string, error) {
}
return paths.DBPath, nil
}
+
+// UsageParseDBTime parses stored timestamps the same way production does
+// (parseDBTime); *_reset_at columns on disk use the same layouts.
+func UsageParseDBTime(value string) (time.Time, bool) { return parseDBTime(value) }
diff --git a/backend/internal/usagecost/store.go b/backend/internal/usagecost/store.go
index 8916c664..fab9d080 100644
--- a/backend/internal/usagecost/store.go
+++ b/backend/internal/usagecost/store.go
@@ -20,7 +20,7 @@ import (
// drop `mode=ro`, and without the assertion the first write would succeed
// instead of failing.
func OpenReadOnly(ctx context.Context, path string) (*sql.DB, error) {
- db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro&_pragma=query_only(1)", path))
+ db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro&_pragma=query_only(1)&_pragma=busy_timeout(5000)", path))
if err != nil {
return nil, err
}
From e0dfc0fc039688f4bd2af05b0f8e168758a4f794 Mon Sep 17 00:00:00 2001
From: "feiniu (Raft agent)"
Date: Sun, 27 Sep 2026 16:20:23 +0000
Subject: [PATCH 25/25] ci(backend): add minimal go build+test CI for PRs
---
.github/workflows/ci.yml | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
create mode 100644 .github/workflows/ci.yml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..fd842e8f
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,21 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+jobs:
+ build-test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v6
+ with:
+ go-version-file: backend/go.mod
+ - name: Build
+ run: go build ./...
+ working-directory: backend
+ - name: Test
+ run: go test ./...
+ working-directory: backend