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/.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 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/backend/cmd/cpa-helper/main.go b/backend/cmd/cpa-helper/main.go index e507f32f..4f32cf84 100644 --- a/backend/cmd/cpa-helper/main.go +++ b/backend/cmd/cpa-helper/main.go @@ -10,8 +10,10 @@ import ( "strconv" "strings" + "cpa-helper/backend/internal/accountrunway" backendApp "cpa-helper/backend/internal/app" "cpa-helper/backend/internal/httpserver" + "cpa-helper/backend/internal/usagecost" ) func main() { @@ -39,6 +41,41 @@ func run(ctx context.Context, args []string, stdout io.Writer) error { case "serve": return serve(ctx) case "migrate": + // `migrate down-to ` rolls the schema DOWN to an allowlisted rollback + // target (destructive; see docs/migrations-rollback.md). Bare `migrate` runs Up. + // A destructive subcommand rejects unknown subcommands/flags rather than silently + // falling back to Up or ignoring a typo. + if len(args) >= 2 { + sub := strings.TrimSpace(args[1]) + if sub != "down-to" { + printUsage(stdout) + return fmt.Errorf("unknown migrate subcommand %q", sub) + } + if len(args) < 3 { + printUsage(stdout) + return fmt.Errorf("migrate down-to requires a target version") + } + target, err := strconv.ParseInt(strings.TrimSpace(args[2]), 10, 64) + if err != nil { + return fmt.Errorf("invalid target version %q: %w", args[2], err) + } + allowPending := false + for _, extra := range args[3:] { + switch strings.TrimSpace(extra) { + case "--allow-pending": + allowPending = true + default: + printUsage(stdout) + return fmt.Errorf("unknown flag %q for migrate down-to", extra) + } + } + report, err := backendApp.MigrateDownTo(ctx, target, allowPending) + if err != nil { + return err + } + fmt.Fprintf(stdout, "rollback completed: db=%s previous_version=%d current_version=%d target_version=%d\n", report.DBPath, report.PreviousVersion, report.CurrentVersion, report.TargetVersion) + return nil + } report, err := backendApp.Migrate(ctx) if err != nil { return err @@ -52,6 +89,14 @@ 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 "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 @@ -110,8 +155,15 @@ func printUsage(w io.Writer) { fmt.Fprint(w, `Usage: cpa-helper Run migrations, then start the service cpa-helper start Run migrations, then start the service - cpa-helper migrate Run database migrations and exit + cpa-helper migrate Run database migrations (Up) and exit + cpa-helper migrate down-to + 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) + 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 f12084a3..d0a74ad4 100644 --- a/backend/cmd/cpa-helper/main_test.go +++ b/backend/cmd/cpa-helper/main_test.go @@ -3,6 +3,8 @@ package main import ( "bytes" "context" + "database/sql" + "path/filepath" "strings" "testing" ) @@ -13,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) } @@ -26,3 +28,103 @@ func TestBackendAddrRejectsBarePort(t *testing.T) { t.Fatal("backendAddr accepted a bare port") } } + +// TestMigrateDownToRollsBackToTarget proves the real `migrate down-to ` +// subcommand actually downgrades the schema to an allowlisted target (unlike a bare +// `migrate`, which only runs Up), and refuses unlisted targets / a missing version. +func TestMigrateDownToRollsBackToTarget(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + ctx := context.Background() + + var up bytes.Buffer + if err := run(ctx, []string{"migrate"}, &up); err != nil { + t.Fatalf("migrate up: %v", err) + } + + var down bytes.Buffer + if err := run(ctx, []string{"migrate", "down-to", "202609040002"}, &down); err != nil { + t.Fatalf("migrate down-to: %v", err) + } + out := down.String() + if !strings.Contains(out, "current_version=202609040002") { + t.Fatalf("rollback did not reach 202609040002: %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. + if err := run(ctx, []string{"migrate", "down-to", "202609040001"}, &bytes.Buffer{}); err == nil { + t.Fatal("down-to accepted a non-allowlisted target") + } + // A missing version errors rather than silently no-op'ing. + if err := run(ctx, []string{"migrate", "down-to"}, &bytes.Buffer{}); err == nil { + t.Fatal("down-to accepted a missing version") + } + // A destructive subcommand rejects unknown flags/subcommands instead of silently + // ignoring a typo or falling back to Up. + if err := run(ctx, []string{"migrate", "down-to", "202609040002", "--typo"}, &bytes.Buffer{}); err == nil { + t.Fatal("down-to accepted an unknown flag") + } + if err := run(ctx, []string{"migrate", "nonsense"}, &bytes.Buffer{}); err == nil { + t.Fatal("migrate accepted an unknown subcommand (must not fall back to Up)") + } +} + +// TestMigrateDownToRefusesPendingRedeems proves a rollback is refused by default when the +// redeem ledger holds a pending (unresolved) redeem — dropping it would lose the unique +// idempotency key — and proceeds only with the explicit --allow-pending override. +func TestMigrateDownToRefusesPendingRedeems(t *testing.T) { + dataDir := t.TempDir() + t.Setenv("CPA_HELPER_DATA_DIR", dataDir) + ctx := context.Background() + + if err := run(ctx, []string{"migrate"}, &bytes.Buffer{}); err != nil { + t.Fatalf("migrate up: %v", err) + } + + // Inject a pending redeem directly into the ledger. + dbPath := filepath.Join(dataDir, "db", "cpa_helper.sqlite3") + db, err := sql.Open("sqlite", dbPath+"?_pragma=busy_timeout(5000)") + if err != nil { + t.Fatalf("open db: %v", err) + } + if _, err := db.Exec(`INSERT INTO codex_keeper_reset_redeems (account_id, redeem_request_id, status, updated_at) VALUES ('acct-p','rid','pending','2026-01-01 00:00:00')`); err != nil { + _ = db.Close() + t.Fatalf("insert pending: %v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("close db: %v", err) + } + + // Default rollback is refused while a pending redeem exists. + 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 != 202609160001 { + t.Fatalf("refused rollback still changed version to %d", v) + } + + // The explicit override proceeds. + var out bytes.Buffer + if err := run(ctx, []string{"migrate", "down-to", "202609040002", "--allow-pending"}, &out); err != nil { + t.Fatalf("override rollback: %v", err) + } + if !strings.Contains(out.String(), "current_version=202609040002") { + t.Fatalf("override rollback did not reach target: %s", out.String()) + } +} + +func currentVersionForTest(t *testing.T, dbPath string) int64 { + t.Helper() + db, err := sql.Open("sqlite", dbPath+"?mode=ro&_pragma=busy_timeout(5000)") + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + var v int64 + if err := db.QueryRow(`SELECT MAX(version_id) FROM goose_db_version`).Scan(&v); err != nil { + t.Fatalf("query version: %v", err) + } + return v +} diff --git a/backend/go.mod b/backend/go.mod index de2f84ab..ff39497e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,6 +3,7 @@ module cpa-helper/backend go 1.25.7 require ( + github.com/google/uuid v1.6.0 github.com/pressly/goose/v3 v3.27.1 github.com/robfig/cron/v3 v3.0.1 golang.org/x/crypto v0.50.0 @@ -11,7 +12,6 @@ require ( require ( github.com/dustin/go-humanize v1.0.1 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.21 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect 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/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 78fa2268..86af5a03 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -401,8 +401,14 @@ func (a *App) serveExternalSPA(w http.ResponseWriter, r *http.Request) (bool, er } indexPath := filepath.Join(a.frontendDist, "index.html") if _, err := os.Stat(indexPath); err == nil { - http.ServeFile(w, r, indexPath) - return true, nil + data, err := os.ReadFile(indexPath) + if err != nil { + return false, err + } + data = a.injectBranding(r.Context(), data) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, err = w.Write(data) + return true, err } return false, nil } @@ -415,11 +421,95 @@ func (a *App) serveEmbeddedSPA(w http.ResponseWriter, r *http.Request) (bool, er } } if _, err := fs.Stat(a.frontendFS, "index.html"); err == nil { - return true, serveFSFile(w, r, a.frontendFS, "index.html") + data, err := fs.ReadFile(a.frontendFS, "index.html") + if err != nil { + return false, err + } + data = a.injectBranding(r.Context(), data) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, err = w.Write(data) + return true, err } return false, nil } +// injectBranding replaces the tag in the given HTML with the product +// name from the app configuration, and injects a <script> block that exposes +// window.__PRODUCT_INFO__ so the frontend can bootstrap without an extra HTTP +// round-trip. The favicon is applied by the frontend from __PRODUCT_INFO__, +// because the vite build already substitutes the __CPA_HELPER_LOGO_URL__ +// placeholder in index.html at build time. +// On any error it returns the original HTML unchanged. +func (a *App) injectBranding(ctx context.Context, html []byte) []byte { + if a.db == nil { + return html + } + cfg, err := a.loadConfig(ctx) + if err != nil { + return html + } + + productName := cfg.ProductName + if productName == "" { + productName = "CPA-Helper" + } + logoURL := cfg.ProductLogo + if logoURL == "" { + logoURL = "/logo.png" + } + + html = replaceTitleTag(html, productName) + html = injectProductInfoScript(html, productName, logoURL) + return html +} + +// injectProductInfoScript injects a <script> block before </head> that sets +// window.__PRODUCT_INFO__ = {product_name: "...", product_logo: "..."}. +// Values are embedded as JSON so they are properly escaped. +func injectProductInfoScript(html []byte, productName, productLogo string) []byte { + type productInfo struct { + ProductName string `json:"product_name"` + ProductLogo string `json:"product_logo"` + } + jsonBytes, err := json.Marshal(productInfo{ProductName: productName, ProductLogo: productLogo}) + if err != nil { + return html + } + script := []byte("<script>window.__PRODUCT_INFO__=" + string(jsonBytes) + "</script>") + const headClose = "</head>" + idx := bytes.Index(html, []byte(headClose)) + if idx == -1 { + return html + } + var buf bytes.Buffer + buf.Write(html[:idx]) + buf.Write(script) + buf.Write(html[idx:]) + return buf.Bytes() +} + +// replaceTitleTag replaces the content of the first <title>... in html. +func replaceTitleTag(html []byte, title string) []byte { + const openTag = "" + const closeTag = "" + start := bytes.Index(html, []byte(openTag)) + if start == -1 { + return html + } + end := bytes.Index(html[start:], []byte(closeTag)) + if end == -1 { + return html + } + end += start + var buf bytes.Buffer + buf.Write(html[:start]) + buf.WriteString(openTag) + buf.WriteString(title) + buf.WriteString(closeTag) + buf.Write(html[end+len(closeTag):]) + return buf.Bytes() +} + func cleanSPAPath(requestPath string) string { cleaned := slashpath.Clean("/" + strings.TrimPrefix(requestPath, "/")) if cleaned == "/" { @@ -495,6 +585,11 @@ type AppConfig struct { LiteLLMProxy LiteLLMProxyConfig `json:"litellm_proxy"` ModelRequestURL string `json:"model_request_url"` 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) { @@ -532,6 +627,7 @@ func defaultConfig() (AppConfig, error) { }, ModelRequestURL: defaultCPAURL, SessionSecret: secret, + APIKeyPrefix: defaultAPIKeyPrefix, }, nil } @@ -550,14 +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 + 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, 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); 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) } @@ -594,6 +691,9 @@ func (a *App) loadConfig(ctx context.Context) (AppConfig, error) { ProxyURL: strings.TrimSpace(litellmProxyURL), } 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 } @@ -648,9 +748,10 @@ func (a *App) saveConfig(ctx context.Context, cfg AppConfig) error { 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 = ?, updated_at = ? + model_request_url = ?, session_secret = ?, + 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, 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 a83f7e1c..e25ac1cd 100644 --- a/backend/internal/app/auth_settings.go +++ b/backend/internal/app/auth_settings.go @@ -265,6 +265,9 @@ type settingsUpdateRequest struct { BatchSize *int `json:"batch_size"` PollIntervalSeconds *float64 `json:"poll_interval_seconds"` 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 { @@ -349,6 +352,26 @@ func (a *App) handleSettings(w http.ResponseWriter, r *http.Request) error { } cfg.Collector.RetryIntervalSeconds = *payload.RetryIntervalSeconds } + if payload.ProductName != nil { + name := strings.TrimSpace(*payload.ProductName) + if len([]rune(name)) > 64 { + return validationError("product_name 超出最大长度 64") + } + cfg.ProductName = name + } + if payload.ProductLogo != nil { + logo := strings.TrimSpace(*payload.ProductLogo) + if logo != "" && !isAllowedLogoDataURL(logo) { + return validationError("product_logo 必须是 PNG、JPEG、WebP 或 GIF 的 base64 data URL") + } + 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 } @@ -371,9 +394,19 @@ func settingsResponse(cfg AppConfig) map[string]any { "batch_size": collector.BatchSize, "poll_interval_seconds": collector.PollIntervalSeconds, "retry_interval_seconds": collector.RetryIntervalSeconds, + "product_name": cfg.ProductName, + "product_logo": cfg.ProductLogo, + "api_key_prefix": normalizeAPIKeyPrefix(cfg.APIKeyPrefix), } } +func isAllowedLogoDataURL(s string) bool { + return strings.HasPrefix(s, "data:image/png;base64,") || + strings.HasPrefix(s, "data:image/jpeg;base64,") || + strings.HasPrefix(s, "data:image/webp;base64,") || + strings.HasPrefix(s, "data:image/gif;base64,") +} + func (a *App) handleCurrentModelRequestGuide(w http.ResponseWriter, r *http.Request) error { if err := requireMethod(r, http.MethodGet); err != nil { return err diff --git a/backend/internal/app/codex_keeper.go b/backend/internal/app/codex_keeper.go index f39ae78a..00111eff 100644 --- a/backend/internal/app/codex_keeper.go +++ b/backend/internal/app/codex_keeper.go @@ -10,6 +10,7 @@ import ( "fmt" "log" "log/slog" + "math" "net/http" "net/url" "os" @@ -19,10 +20,28 @@ import ( "strings" "sync" "time" + + "github.com/google/uuid" ) const ( - keeperUsageURL = "https://chatgpt.com/backend-api/wham/usage" + keeperUsageURL = "https://chatgpt.com/backend-api/wham/usage" + keeperResetCreditsURL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits" + keeperResetCreditsConsumeURL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume" + keeperResetCreditType = "codex_rate_limits" + keeperResetCreditStatus = "available" + // Inner response codes returned by wham/rate-limit-reset-credits/consume + // (OpenAI Codex backend-client rate_limit_resets). A 2xx inner status only + // means the request was processed; the code decides whether a credit was + // actually redeemed this call. + keeperResetCreditCodeReset = "reset" // a real reset happened now + keeperResetCreditCodeAlreadyRedeemed = "already_redeemed" // same redeem_request_id replayed (idempotent) — still our redemption + keeperResetCreditCodeNoCredit = "no_credit" // no credit available (snapshot was stale-high) — NOT consumed + keeperResetCreditCodeNothingToReset = "nothing_to_reset" // nothing to reset — NOT consumed + // keeperRedeemStatusPending marks a redeem ledger row whose outcome is unknown + // (in-flight or a prior attempt that failed transport/protocol). Its + // redeem_request_id must be reused on the next attempt for idempotency. + keeperRedeemStatusPending = "pending" keeperLogFilePrefix = "codex-keeper-" keeperLogComponent = "codex_keeper" keeperLogRetainedFiles = 3 @@ -34,20 +53,21 @@ const ( ) type KeeperRunner struct { - app *App - mu sync.Mutex - daemonStop chan struct{} - daemonDone chan struct{} - running bool - runningModes map[string]struct{} - inFlightAuths map[string]string - state string - detail string - mode *string - lastStartedAt *time.Time - lastFinishedAt *time.Time - stats keeperStats - logs []string + app *App + mu sync.Mutex + daemonStop chan struct{} + daemonDone chan struct{} + running bool + runningModes map[string]struct{} + inFlightAuths map[string]string + inFlightAccounts map[string]bool + state string + detail string + mode *string + lastStartedAt *time.Time + lastFinishedAt *time.Time + stats keeperStats + logs []string } type keeperStats struct { @@ -59,6 +79,18 @@ type keeperStats struct { PriorityRestored int `json:"priority_restored"` Skipped int `json:"skipped"` NetworkError int `json:"network_error"` + // IdentityError counts accounts whose list/detail identity (account_id or auth_index) + // conflicted this run, so the snapshot was preserved and NOT refreshed from an + // ambiguous mixed detail — a post-reset refresh must be audited as error, not ok. + IdentityError int `json:"identity_error"` + // ResetCreditsUnavailable counts otherwise-healthy accounts whose reset-credit + // fetch failed this run (snapshot preserved, health unchanged). Not persisted to + // the runs table; used to audit a post-reset refresh as partial. + ResetCreditsUnavailable int `json:"reset_credits_unavailable"` + // StateWriteError counts accounts whose state write-back to the DB failed this + // run. The inspection may have looked healthy, but nothing was persisted — so a + // post-reset refresh must be audited as error, not ok. + StateWriteError int `json:"state_write_error"` } type keeperStatusResponse struct { @@ -110,47 +142,93 @@ type keeperPriorityUpdateRequest struct { Priority int `json:"priority"` } +type keeperQuotaResetRequest struct { + AuthName string `json:"auth_name"` +} + type keeperAccount struct { - Name string `json:"name"` - Email *string `json:"email"` - AuthIndex *string `json:"auth_index"` - AccountType *string `json:"account_type"` - Disabled bool `json:"disabled"` - Priority *int `json:"priority"` - PrimaryUsedPercent *int `json:"primary_used_percent"` - SecondaryUsedPercent *int `json:"secondary_used_percent"` - PrimaryResetAt *time.Time `json:"primary_reset_at"` - SecondaryResetAt *time.Time `json:"secondary_reset_at"` - PrimaryWindowSeconds *int `json:"primary_window_seconds"` - SecondaryWindowSeconds *int `json:"secondary_window_seconds"` - QuotaThreshold *int `json:"quota_threshold"` - LastStatusCode *int `json:"last_status_code"` - LastError *string `json:"last_error"` - LatestAction *string `json:"latest_action"` - LastCheckedAt *time.Time `json:"last_checked_at"` - LastHealthyAt *time.Time `json:"last_healthy_at"` + Name string `json:"name"` + Email *string `json:"email"` + AuthIndex *string `json:"auth_index"` + AccountType *string `json:"account_type"` + // Provider is the upstream this account belongs to: "codex" or "antigravity". Nil/absent is + // treated as codex (rows predating multi-provider support). The frontend uses it to choose the + // Codex vs Antigravity quota UI and to hide Codex-only actions (reset/subscription). + Provider *string `json:"provider"` + // AntigravityQuota is the Antigravity quota summary (groups -> buckets) for antigravity + // accounts; nil for codex accounts. + AntigravityQuota []keeperAntigravityGroup `json:"antigravity_quota"` + // AntigravityIdentityDigest is the Google project the quota belongs to (the resource identity used + // to detect a project swap). Internal — not exposed in the API. + AntigravityIdentityDigest *string `json:"-"` + Disabled bool `json:"disabled"` + Priority *int `json:"priority"` + PrimaryUsedPercent *int `json:"primary_used_percent"` + SecondaryUsedPercent *int `json:"secondary_used_percent"` + PrimaryResetAt *time.Time `json:"primary_reset_at"` + SecondaryResetAt *time.Time `json:"secondary_reset_at"` + PrimaryWindowSeconds *int `json:"primary_window_seconds"` + SecondaryWindowSeconds *int `json:"secondary_window_seconds"` + QuotaThreshold *int `json:"quota_threshold"` + LastStatusCode *int `json:"last_status_code"` + ResetCreditCount *int `json:"reset_credit_count"` + ResetCredits []keeperResetCredit `json:"reset_credits"` + SubscriptionActiveUntil *time.Time `json:"subscription_active_until"` + LastError *string `json:"last_error"` + LatestAction *string `json:"latest_action"` + LastCheckedAt *time.Time `json:"last_checked_at"` + LastHealthyAt *time.Time `json:"last_healthy_at"` +} + +// keeperResetCredit is the safe projection of one entry from +// wham/rate-limit-reset-credits: only identity + status + timestamps are kept, +// never the profile URL / description. ExpiresAt is nil for a never-expiring +// credit (upstream expires_at: null), which must still be shown, not dropped. +type keeperResetCredit struct { + ID string `json:"id"` + ResetType string `json:"reset_type"` + Status string `json:"status"` + GrantedAt *time.Time `json:"granted_at"` + ExpiresAt *time.Time `json:"expires_at"` + Title string `json:"title,omitempty"` +} + +// keeperResetCreditResponse is the wire shape of one reset credit. Timestamps are +// formatted for the product timezone; ExpiresAt nil means "never expires". +type keeperResetCreditResponse struct { + ID string `json:"id"` + ResetType string `json:"reset_type"` + Status string `json:"status"` + GrantedAt *string `json:"granted_at"` + ExpiresAt *string `json:"expires_at"` + Title string `json:"title,omitempty"` } type keeperAccountResponse struct { - Name string `json:"name"` - Email *string `json:"email"` - AccountType *string `json:"account_type"` - Disabled bool `json:"disabled"` - Priority *int `json:"priority"` - PrimaryUsedPercent *int `json:"primary_used_percent"` - SecondaryUsedPercent *int `json:"secondary_used_percent"` - PrimaryResetAt *string `json:"primary_reset_at"` - SecondaryResetAt *string `json:"secondary_reset_at"` - PrimaryWindowSeconds *int `json:"primary_window_seconds"` - SecondaryWindowSeconds *int `json:"secondary_window_seconds"` - PrimaryWindowUsage *keeperQuotaWindowUsageResponse `json:"primary_window_usage"` - SecondaryWindowUsage *keeperQuotaWindowUsageResponse `json:"secondary_window_usage"` - QuotaThreshold *int `json:"quota_threshold"` - LastStatusCode *int `json:"last_status_code"` - LastError *string `json:"last_error"` - LatestAction *string `json:"latest_action"` - LastCheckedAt *string `json:"last_checked_at"` - LastHealthyAt *string `json:"last_healthy_at"` + Name string `json:"name"` + Email *string `json:"email"` + AccountType *string `json:"account_type"` + Provider *string `json:"provider"` + AntigravityQuota []keeperAntigravityGroupResponse `json:"antigravity_quota"` + Disabled bool `json:"disabled"` + Priority *int `json:"priority"` + PrimaryUsedPercent *int `json:"primary_used_percent"` + SecondaryUsedPercent *int `json:"secondary_used_percent"` + PrimaryResetAt *string `json:"primary_reset_at"` + SecondaryResetAt *string `json:"secondary_reset_at"` + PrimaryWindowSeconds *int `json:"primary_window_seconds"` + SecondaryWindowSeconds *int `json:"secondary_window_seconds"` + PrimaryWindowUsage *keeperQuotaWindowUsageResponse `json:"primary_window_usage"` + SecondaryWindowUsage *keeperQuotaWindowUsageResponse `json:"secondary_window_usage"` + QuotaThreshold *int `json:"quota_threshold"` + LastStatusCode *int `json:"last_status_code"` + LastError *string `json:"last_error"` + LatestAction *string `json:"latest_action"` + LastCheckedAt *string `json:"last_checked_at"` + LastHealthyAt *string `json:"last_healthy_at"` + ResetCreditCount *int `json:"reset_credit_count"` + ResetCredits []keeperResetCreditResponse `json:"reset_credits"` + SubscriptionActiveUntil *string `json:"subscription_active_until"` } type keeperQuotaWindowUsageResponse struct { @@ -206,8 +284,12 @@ type keeperWindowUsageCache struct { type keeperAuthState struct { keeperAccount RestorePriority *int - CreatedAt time.Time - UpdatedAt time.Time + // AccountID is the stored ChatGPT account identity (nil until an inspection observed + // it). resetKeeperQuota compares it with the fresh identity so a same-filename account + // swap (auth_index unchanged) never resets the wrong account. + AccountID *string + CreatedAt time.Time + UpdatedAt time.Time } type keeperUsageInfo struct { @@ -248,6 +330,40 @@ type keeperAccountResult struct { LastError *string LatestAction *string CheckedAt time.Time + // ResetCreditCount / ResetCredits are set only when a reset-credit fetch + // succeeds. Left nil (the failure/skip case) they preserve the previous + // snapshot in upsertKeeperState via COALESCE, so a failed fetch never wipes + // good data. A successful empty result carries a non-nil count of 0. + ResetCreditCount *int + ResetCredits *string + // SubscriptionActiveUntil is the ChatGPT subscription renewal time parsed from + // the account's id_token claims. It is tri-state together with + // SubscriptionKnown: when SubscriptionKnown is true the value is authoritative + // (a nil pointer means "confirmed no subscription" and clears the stored + // value); when SubscriptionKnown is false the claim was unreadable/malformed + // and upsertKeeperState preserves the previous snapshot. + SubscriptionActiveUntil *time.Time + SubscriptionKnown bool + // AccountID is the ChatGPT account identity this inspection observed (nil when the + // detail/claims were unreadable). upsertKeeperState uses it to scope the subscription + // preserve-on-unknown to the ACCOUNT, so an auth_name swapped to a different account + // under the same auth_index never inherits the previous account's renewal date. + AccountID *string + // ResetCreditsUnavailable is set when the account is healthy but its reset-credit + // fetch failed, so the snapshot was not refreshed this inspection. + ResetCreditsUnavailable bool + // StateWriteFailed is set when persisting this result to the DB failed, so the + // inspection did not actually update the stored state. + StateWriteFailed bool + // Provider is the upstream this inspection processed ("codex" or "antigravity"). + Provider *string + // AntigravityQuota is the parsed Antigravity quota summary serialized to JSON; nil (a + // failed/skipped fetch) preserves the previous snapshot in upsertKeeperState via COALESCE. + AntigravityQuota *string + // AntigravityIdentityDigest is the resolved Google project id (resource identity). It is set even + // when the quota fetch fails, so upsertKeeperState can detect a project SWAP and clear the + // stale quota rather than preserving it. + AntigravityIdentityDigest *string } func NewKeeperRunner(app *App) *KeeperRunner { @@ -312,6 +428,26 @@ func (r *KeeperRunner) StartAccounts(authNames []string) error { return nil } +// InspectAccountsLocked inspects the given accounts synchronously using ONLY the +// per-auth locks — deliberately not the global "accounts" mode. The per-auth lock +// is the correctness-critical guard (it never double-inspects an account another +// run already holds, and prevents a stale concurrent write from clobbering a fresh +// snapshot). Skipping the global mutex means a targeted reset refresh of account B +// is not blocked by an unrelated run inspecting account A — that unrelated run +// would never refresh B, so blocking would leave B's post-reset snapshot stale. +// It blocks until done so the caller (reset-quota) can guarantee the write first. +func (r *KeeperRunner) InspectAccountsLocked(authNames []string) (keeperStats, error) { + names, err := normalizeKeeperAuthNames(authNames) + if err != nil { + return keeperStats{}, err + } + options := keeperRunOptionsForMode("accounts", names) + options.TryLockAuthName = r.tryLockAuthName + options.UnlockAuthName = r.unlockAuthName + stats, _, err := r.app.executeKeeperRunWithOptions(context.Background(), options, r.log) + return stats, err +} + func (r *KeeperRunner) StartDaemon() error { cfg, err := r.app.loadConfig(context.Background()) if err != nil { @@ -571,6 +707,39 @@ func (r *KeeperRunner) unlockAuthName(name string) { delete(r.inFlightAuths, name) } +// tryLockAccountID / unlockAccountID guard the RESOURCE (an OpenAI account_id), separately +// from the auth_name (file) lock. Two different filenames/routes for the SAME account +// (rename overlap, duplicate import) take different auth_name locks but the SAME account_id +// lock, so a reset that redeems a credit for an account excludes any other concurrent reset +// of the same account — without this a second route could consume a second credit even in a +// single process. Non-blocking: a contended reset returns a conflict. +func (r *KeeperRunner) tryLockAccountID(accountID string) bool { + accountID = strings.TrimSpace(accountID) + if accountID == "" { + return true + } + r.mu.Lock() + defer r.mu.Unlock() + if r.inFlightAccounts == nil { + r.inFlightAccounts = map[string]bool{} + } + if r.inFlightAccounts[accountID] { + return false + } + r.inFlightAccounts[accountID] = true + return true +} + +func (r *KeeperRunner) unlockAccountID(accountID string) { + accountID = strings.TrimSpace(accountID) + if accountID == "" { + return + } + r.mu.Lock() + defer r.mu.Unlock() + delete(r.inFlightAccounts, accountID) +} + func keeperModeOrder(mode string) int { switch mode { case "daemon": @@ -604,18 +773,18 @@ func keeperStatusModePtr(modes []string) *string { func keeperRunningDetail(modes []string) string { if len(modes) > 1 { - return "正在运行多个 Codex Keeper 任务" + return "正在运行多个 Keeper 任务" } if len(modes) == 0 { return "尚未运行" } switch modes[0] { case "accounts": - return "正在刷新 Codex 账号" + return "正在刷新账号" case "conditional": - return "正在按条件刷新 Codex 账号" + return "正在按条件刷新账号" default: - return "正在巡检 Codex 账号" + return "正在巡检账号" } } @@ -645,7 +814,7 @@ func (r *KeeperRunner) run(mode string) { r.runAccounts(mode, nil) } -func (r *KeeperRunner) runAccounts(mode string, authNames []string) { +func (r *KeeperRunner) runAccounts(mode string, authNames []string) (keeperStats, error) { options := keeperRunOptionsForMode(mode, authNames) options.TryLockAuthName = r.tryLockAuthName options.UnlockAuthName = r.unlockAuthName @@ -679,6 +848,7 @@ func (r *KeeperRunner) runAccounts(mode string, authNames []string) { if strings.TrimSpace(logMessage) != "" { r.log(logMessage) } + return stats, err } func (r *KeeperRunner) log(message string) { @@ -716,6 +886,92 @@ func formatKeeperLogLine(timestamp time.Time, message string) string { return strings.TrimSuffix(output.String(), "\n") } +// auditKeeperOp records a successful single-account operation (reset-quota, enable, +// disable, delete, …) to both the Keeper run log (UI + rotating log file) and the +// process log (journalctl). CPA-Helper previously logged only on error/panic, so +// HTTP-200 mutations left no per-account audit trail; this closes that gap for +// troubleshooting and reconciliation. kv are alternating key/value pairs. +func (a *App) auditKeeperOp(op, authName string, kv ...any) { + var b strings.Builder + fmt.Fprintf(&b, "[%s] %s", op, authName) + for i := 0; i+1 < len(kv); i += 2 { + fmt.Fprintf(&b, " %v=%v", kv[i], kv[i+1]) + } + if a.keeper != nil { + a.keeper.log(b.String()) + } + args := append([]any{"op", op, "account", authName}, kv...) + slog.Info("codex_keeper op", args...) +} + +// keeperSafeReason maps an error to a stable, non-sensitive reason code for audit +// logs. Known AppErrors expose their machine code (e.g. not_found, conflict, +// validation_error); anything else collapses to "internal_error" so a raw error +// message (which could carry a URL, token, or upstream detail) never reaches the log. +func keeperSafeReason(err error) string { + if err == nil { + return "ok" + } + var appErr *AppError + if errors.As(err, &appErr) && appErr.Code != "" { + return appErr.Code + } + return "internal_error" +} + +// keeperRefreshAuditOutcome classifies the result of the post-reset single-account +// re-inspection for the audit log, so the trail never misreports a refresh: +// - a mode conflict (a background run holds "accounts") is skipped, not an error — +// that run will refresh the account; +// - any other run error (auth-file list transport/parse, normalize) is a real error; +// - a network error during the inspect is an error; +// - a per-auth lock skip is skipped/account_busy; +// - Total==0 means the account was absent from the remote list, so nothing was +// inspected — skipped/not_inspected, NOT ok; +// - otherwise the account was inspected and written: ok. +func keeperRefreshAuditOutcome(stats keeperStats, err error) (result string, reason string) { + switch { + case err != nil: + reason = keeperSafeReason(err) + if reason == "conflict" { + return "skipped", "conflict" + } + return "error", reason + case stats.StateWriteError > 0: + // The inspection may have looked healthy, but the state write-back to the DB + // failed — nothing was persisted, so this is never a successful refresh. + return "error", "state_write_error" + case stats.NetworkError > 0: + return "error", "network_error" + case stats.IdentityError > 0: + // The account's list/detail identity conflicted, so its snapshot was preserved + // (not refreshed from an ambiguous mixed detail) — never a successful refresh. + return "error", "identity_error" + case stats.StatusDisabled > 0: + // The account was disabled during the inspect (invalid/expired credentials) + // before the usage + reset-credit fetch could refresh the snapshot. + return "error", "status_disabled" + case stats.Skipped > 0: + return "skipped", "account_busy" + case stats.Total == 0: + return "skipped", "not_inspected" + } + // Only these post-fetch outcomes mean a snapshot was actually refreshed. Require + // every inspected account to have completed (Total == okCount) so a partially + // completed batch is never reported ok; for the single-account reset refresh + // this is simply "the one account completed". + okCount := stats.Healthy + stats.StatusEnabled + stats.PriorityDegraded + stats.PriorityRestored + if okCount > 0 && stats.Total == okCount { + // The account(s) inspected healthily, but if the reset-credit fetch itself + // failed the snapshot — the whole point of this refresh — was not updated. + if stats.ResetCreditsUnavailable > 0 { + return "partial", "reset_credits_unavailable" + } + return "ok", "" + } + return "skipped", "not_inspected" +} + type keeperLogFile struct { path string date time.Time @@ -891,6 +1147,45 @@ func (a *App) handleCodexKeeper(w http.ResponseWriter, r *http.Request) error { } writeJSON(w, http.StatusOK, a.keeper.Status()) return nil + case len(parts) == 1 && parts[0] == "reset-quota": + if err := requireMethod(r, http.MethodPost); err != nil { + return err + } + var payload keeperQuotaResetRequest + if err := decodeJSON(r, &payload); err != nil { + return err + } + name := strings.TrimSpace(payload.AuthName) + if name == "" { + return validationError("auth_name 不能为空") + } + result, err := a.resetKeeperQuota(r.Context(), name) + if err != nil { + // An irreversible partial (credit consumed, cooldown-clear failed) is already + // audited as result=partial inside resetKeeperQuota; do NOT overwrite it with a + // generic result=error line that would mask the partial in the log tail. + if keeperSafeReason(err) != keeperResetPartialCode { + a.auditKeeperOp("reset-quota", name, "result", "error", "reason", keeperSafeReason(err)) + } + return err + } + // Immediately re-inspect just this account so its post-reset usage / window / + // reset-credit state is refreshed in the DB (a reset consumes a credit and + // clears the cooldown). The frontend reloads accounts right after a successful + // reset, so it then shows the fresh state instead of the stale pre-reset + // snapshot. InspectAccountsLocked holds the per-auth lock (never concurrent + // with a background inspection of the same account) but not the global + // "accounts" mode, so an unrelated run inspecting a different account does not + // block this one. Best-effort: the reset already succeeded, so the refresh + // outcome is audited (skipped/error/ok) but never fails the response. + stats, ierr := a.keeper.InspectAccountsLocked([]string{name}) + if result, reason := keeperRefreshAuditOutcome(stats, ierr); reason != "" { + a.auditKeeperOp("reset-quota-refresh", name, "result", result, "reason", reason) + } else { + a.auditKeeperOp("reset-quota-refresh", name, "result", result) + } + writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "account": result}) + return nil case len(parts) == 1 && parts[0] == "accounts": if err := requireMethod(r, http.MethodGet); err != nil { return err @@ -965,6 +1260,11 @@ func (a *App) handleCodexKeeper(w http.ResponseWriter, r *http.Request) error { } disabled := parts[2] == "disable" if err := a.setKeeperAccountDisabled(r.Context(), authName, disabled); err != nil { + op := "enable" + if disabled { + op = "disable" + } + a.auditKeeperOp(op, authName, "result", "error", "reason", keeperSafeReason(err)) return err } if disabled { @@ -979,6 +1279,7 @@ func (a *App) handleCodexKeeper(w http.ResponseWriter, r *http.Request) error { return validationError("账号名称无效") } if err := a.deleteKeeperAccount(r.Context(), authName); err != nil { + a.auditKeeperOp("delete", authName, "result", "error", "reason", keeperSafeReason(err)) return err } writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) @@ -996,6 +1297,7 @@ func (a *App) handleCodexKeeper(w http.ResponseWriter, r *http.Request) error { return err } if err := a.updateKeeperAccountPriority(r.Context(), authName, payload.Priority); err != nil { + a.auditKeeperOp("priority", authName, "result", "error", "reason", keeperSafeReason(err)) return err } writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) @@ -1035,30 +1337,56 @@ func keeperAccountResponses(accounts []keeperAccount, windowUsages map[string]ke for _, account := range accounts { usage := windowUsages[account.Name] responses = append(responses, keeperAccountResponse{ - Name: account.Name, - Email: account.Email, - AccountType: account.AccountType, - Disabled: account.Disabled, - Priority: keeperDisplayPriority(account.Priority), - PrimaryUsedPercent: account.PrimaryUsedPercent, - SecondaryUsedPercent: account.SecondaryUsedPercent, - PrimaryResetAt: apiDateTimePtr(account.PrimaryResetAt), - SecondaryResetAt: apiDateTimePtr(account.SecondaryResetAt), - PrimaryWindowSeconds: account.PrimaryWindowSeconds, - SecondaryWindowSeconds: account.SecondaryWindowSeconds, - PrimaryWindowUsage: keeperQuotaWindowUsageResponseFrom(usage.Primary), - SecondaryWindowUsage: keeperQuotaWindowUsageResponseFrom(usage.Secondary), - QuotaThreshold: account.QuotaThreshold, - LastStatusCode: account.LastStatusCode, - LastError: account.LastError, - LatestAction: account.LatestAction, - LastCheckedAt: apiDateTimePtr(account.LastCheckedAt), - LastHealthyAt: apiDateTimePtr(account.LastHealthyAt), + Name: account.Name, + Email: account.Email, + AccountType: account.AccountType, + Provider: account.Provider, + AntigravityQuota: keeperAntigravityQuotaResponses(account.AntigravityQuota), + Disabled: account.Disabled, + Priority: keeperDisplayPriority(account.Priority), + PrimaryUsedPercent: account.PrimaryUsedPercent, + SecondaryUsedPercent: account.SecondaryUsedPercent, + PrimaryResetAt: apiDateTimePtr(account.PrimaryResetAt), + SecondaryResetAt: apiDateTimePtr(account.SecondaryResetAt), + PrimaryWindowSeconds: account.PrimaryWindowSeconds, + SecondaryWindowSeconds: account.SecondaryWindowSeconds, + PrimaryWindowUsage: keeperQuotaWindowUsageResponseFrom(usage.Primary), + SecondaryWindowUsage: keeperQuotaWindowUsageResponseFrom(usage.Secondary), + QuotaThreshold: account.QuotaThreshold, + LastStatusCode: account.LastStatusCode, + LastError: account.LastError, + LatestAction: account.LatestAction, + LastCheckedAt: apiDateTimePtr(account.LastCheckedAt), + LastHealthyAt: apiDateTimePtr(account.LastHealthyAt), + ResetCreditCount: account.ResetCreditCount, + ResetCredits: keeperResetCreditResponses(account.ResetCredits), + SubscriptionActiveUntil: apiDateTimePtr(account.SubscriptionActiveUntil), }) } return responses } +// keeperResetCreditResponses formats persisted reset credits for the API, keeping +// the stored expires_at-ascending order and emitting a nil ExpiresAt (never +// expires) unchanged so the frontend can render "永不过期". +func keeperResetCreditResponses(credits []keeperResetCredit) []keeperResetCreditResponse { + if len(credits) == 0 { + return nil + } + out := make([]keeperResetCreditResponse, 0, len(credits)) + for _, credit := range credits { + out = append(out, keeperResetCreditResponse{ + ID: credit.ID, + ResetType: credit.ResetType, + Status: credit.Status, + GrantedAt: apiDateTimePtr(credit.GrantedAt), + ExpiresAt: apiDateTimePtr(credit.ExpiresAt), + Title: credit.Title, + }) + } + return out +} + func keeperQuotaWindowUsageResponseFrom(usage *keeperQuotaWindowUsage) *keeperQuotaWindowUsageResponse { if usage == nil { return nil @@ -1568,11 +1896,11 @@ func (a *App) executeKeeperRunWithOptions(ctx context.Context, options keeperRun targetSet[name] = true } if options.Mode == "conditional" { - logFn(fmt.Sprintf("开始按条件刷新 %d 个 Codex 账号", len(targetSet))) + logFn(fmt.Sprintf("开始按条件刷新 %d 个账号", len(targetSet))) } else if len(targetSet) > 0 { - logFn(fmt.Sprintf("开始刷新 %d 个 Codex 账号", len(targetSet))) + logFn(fmt.Sprintf("开始刷新 %d 个账号", len(targetSet))) } else { - logFn("开始 Codex 账号巡检") + logFn("开始账号巡检") } stats := keeperStats{} detail := "巡检完成" @@ -1586,7 +1914,7 @@ func (a *App) executeKeeperRunWithOptions(ctx context.Context, options keeperRun filtered := make([]map[string]any, 0, len(authFiles)) remoteCodexNames := map[string]bool{} for _, item := range authFiles { - if keeperString(item["type"]) != "codex" { + if !keeperIsInspectableProvider(keeperString(item["type"])) { continue } name := keeperString(item["name"]) @@ -1606,13 +1934,24 @@ func (a *App) executeKeeperRunWithOptions(ctx context.Context, options keeperRun return stats, "", err } if pruned > 0 { - logFn(fmt.Sprintf("清理本地已不存在的 Codex 账号 %d 个", pruned)) + logFn(fmt.Sprintf("清理本地已不存在的账号 %d 个", pruned)) } } stats.Total = len(filtered) if cfg.CodexKeeper.EnableCredentialWebsockets && !cfg.CodexKeeper.DryRun { + // The credential-websocket transport is Codex-specific; keep non-Codex (Antigravity) + // items out of it and recombine afterward so they are still inspected. + var codexItems, otherItems []map[string]any + for _, item := range filtered { + if keeperString(item["type"]) == keeperProviderAntigravity { + otherItems = append(otherItems, item) + } else { + codexItems = append(codexItems, item) + } + } var websocketFailures []keeperAccountResult - filtered, websocketFailures = a.ensureKeeperAuthWebsockets(ctx, cfg, options.Mode, filtered, logFn, options.TryLockAuthName, options.UnlockAuthName) + codexItems, websocketFailures = a.ensureKeeperAuthWebsockets(ctx, cfg, options.Mode, codexItems, logFn, options.TryLockAuthName, options.UnlockAuthName) + filtered = append(codexItems, otherItems...) for _, result := range websocketFailures { a.mergeKeeperStats(&stats, result) if runID > 0 { @@ -1643,11 +1982,11 @@ func (a *App) executeKeeperRunWithOptions(ctx context.Context, options keeperRun if stats.NetworkError > 0 { detail = fmt.Sprintf("巡检完成:网络错误 %d", stats.NetworkError) } else if stats.Total > 0 && options.UseRefreshCache { - detail = "缓存时间内没有需要自动刷新的 Codex auth file" + detail = "缓存时间内没有需要自动刷新的 auth file" } else if len(targetSet) > 0 { - detail = "未发现指定 Codex auth file" + detail = "未发现指定 auth file" } else { - detail = "未发现 Codex auth file" + detail = "未发现 auth file" } if runID > 0 { _ = a.finishKeeperRun(ctx, runID, "completed", detail, stats) @@ -1882,10 +2221,14 @@ func (a *App) reconcileKeeperConditionalRemoteAuthStates(ctx context.Context, cf if err != nil { return err } + // remoteNames is the EXISTENCE full-set used for prune protection — it must include every + // inspectable provider (codex + antigravity), otherwise a conditional tick would prune a + // still-present Antigravity row that a full inspection just wrote. refreshableRemoteNames is + // the set of enabled accounts eligible for a conditional refresh. remoteNames := map[string]bool{} refreshableRemoteNames := map[string]bool{} for _, item := range authFiles { - if keeperString(item["type"]) != "codex" { + if !keeperIsInspectableProvider(keeperString(item["type"])) { continue } name := keeperString(item["name"]) @@ -2299,8 +2642,13 @@ func (a *App) ensureKeeperAuthWebsockets( if err := a.setKeeperRemoteWebsockets(ctx, cfg, name); err != nil { message := "启用 WebSocket 传输失败:" + err.Error() disabled := keeperBool(item["disabled"]) + // This path only runs for Codex items (antigravity is split out before the websocket + // step), so tag the result Codex — otherwise upsertKeeperState's COALESCE would keep a + // stale provider/antigravity_quota from a row that was previously an Antigravity file. + codex := keeperProviderCodex result := keeperAccountResult{ Name: name, + Provider: &codex, AuthIndex: keeperRemoteAuthIndex(item), AccountType: accountTypeFromKeeperDetail(item, nil), Disabled: &disabled, @@ -2325,19 +2673,43 @@ func (a *App) ensureKeeperAuthWebsockets( } func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map[string]any, logFn func(string), manualRefresh bool) keeperAccountResult { + // Dispatch by provider: an Antigravity account takes a separate quota path with none of the + // ChatGPT-specific usage/reset/subscription logic below. + if keeperString(authInfo["type"]) == keeperProviderAntigravity { + return a.processKeeperAntigravityAuth(ctx, cfg, authInfo, logFn, manualRefresh) + } now := time.Now().In(appTimeLocation) name := keeperString(authInfo["name"]) if name == "" { name = "unknown" } - result := keeperAccountResult{Name: name, Result: "skipped", CheckedAt: now} + codexProvider := keeperProviderCodex + result := keeperAccountResult{Name: name, Result: "skipped", CheckedAt: now, Provider: &codexProvider} + // Subscription renewal time comes from the auth-file list entry's parsed + // id_token claims (available regardless of the usage-fetch outcome), so set it + // up front to persist on every path. + result.SubscriptionActiveUntil, result.SubscriptionKnown = keeperSubscriptionActiveUntil(authInfo) + // persistState writes the result to the DB and, if that fails, records the + // failure on the result (and logs it) rather than swallowing the error — so a + // state that never reached the DB is not later reported as a healthy refresh. + persistState := func(r keeperAccountResult) keeperAccountResult { + if err := a.upsertKeeperState(ctx, r); err != nil { + // Keep the raw DB error out of the user-visible Keeper log (and the + // audit) — surface only a stable marker there; the raw detail goes to the + // server process log for diagnostics. + logFn(r.Name + ":状态写回失败(state_write_error)") + log.Printf("codex keeper state write-back failed for %s: %v", r.Name, err) + r.StateWriteFailed = true + } + return r + } detail, err := a.getKeeperRemoteAuthFile(ctx, cfg, name) if err != nil { message := "读取 auth file 详情失败:" + err.Error() result.Result = "network_error" result.LastError = &message result.LatestAction = &message - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) logFn(name + ": " + message) return result } @@ -2346,12 +2718,78 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map result.Result = "network_error" result.LastError = &message result.LatestAction = &message - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) return result } merged := mergeKeeperObjects(authInfo, detail) + // Reconcile the account identity across BOTH sources FIRST — the list entry's explicit + // account_id (its id_token.chatgpt_account_id, the same source as the subscription + // claim) and auth_index, and the download detail's account_id and auth_index. If either + // conflicts (or a single source is self-contradictory), the merged detail mixes two + // accounts, so bail out with an identity_error and preserve the prior snapshot rather + // than fetching usage / writing account data from an ambiguous mix. This is audited as + // an error (never a successful refresh) and never binds a wrong account_id. + identity, consistent := keeperReconcileInspectionIdentity(authInfo, detail) + if !consistent { + message := "账号身份冲突:列表与详情的 account_id/auth_index 不一致,已保留原快照" + result.Result = "identity_error" + result.LastError = &message + result.LatestAction = &message + result.SubscriptionKnown = false + // The merged detail mixes two accounts, so every business column (email, + // auth_index, account_id, account_type, disabled, priority, usage, quota, + // reset credits, subscription) on `result` is empty/ambiguous here. Persisting + // it through the normal upsert (which is `= excluded.*` for those columns) would + // clobber the prior good snapshot to NULL/false — which also breaks a later reset + // that needs the stored auth_index. Preserve ALL existing state and only stamp the + // error/latest_action and the check time. If no row exists yet (first-ever + // inspection hits a conflict) there is nothing trustworthy to insert, so this + // touches zero rows rather than manufacturing an incomplete identity row. + if err := a.markKeeperIdentityError(ctx, name, &message, result.CheckedAt); err != nil { + logFn(name + ":状态写回失败(state_write_error)") + log.Printf("codex keeper identity-error write-back failed for %s: %v", name, err) + result.StateWriteFailed = true + } + logFn(name + ":" + message) + return result + } result.Email = keeperStringPtr(merged["email"], merged["account_email"], merged["user_email"]) + // Normalize the merged object with the per-source-VALIDATED identity so all account-scoped + // work below consumes only the reconciled {auth_index, account_id}, never re-derived from + // the raw right-biased merge. Routing (checkKeeperUsage / fetchKeeperResetCredits call + // keeperAuthIndex, which otherwise falls back to the auth NAME) uses the reconciled + // auth_index; the Chatgpt-Account-Id header (read from the top-level account_id) uses the + // reconciled account_id. Each is injected only when non-empty. Safe because the identity is + // reconciled (non-conflicting) for this merged detail. + if identity.authIndex != "" { + merged["auth_index"] = identity.authIndex + } result.AuthIndex = keeperRemoteAuthIndex(merged) + // accountIDKnown gates the account-scoped work below. keeperReconcileInspectionIdentity + // yields an empty account_id when NEITHER source carries one (e.g. a legacy auth_index-only + // credential): the identity is consistent (nothing to conflict) but UNKNOWN. Without a stable + // account_id we cannot attribute reset credits to a resource (the api-call would omit the + // Chatgpt-Account-Id header and OpenAI would resolve the account from $TOKEN$ alone) nor detect + // a same-index account swap in the snapshot CASE. So we still run usage/priority (transient + // current state, always overwritten), but skip the reset-credit fetch and the subscription + // write and preserve the prior account-scoped snapshot rather than overwrite it blind. + acct := identity.accountID + accountIDKnown := acct != "" + if accountIDKnown { + result.AccountID = &acct + // Attribute the account-scoped api-calls (Chatgpt-Account-Id, read from the top-level + // account_id) to the confirmed account even when the id came only from the list's + // id_token claim and the download detail carried no top-level account_id — otherwise a + // known account_id could send an account-less request yet write an account-scoped + // snapshot, an inconsistent attribution. + merged["account_id"] = acct + } else { + // Clear the up-front subscription claim so upsertKeeperState's CASE falls through to + // COALESCE(NULL, existing) = preserve, instead of binding a renewal date to an + // account we cannot identify. + result.SubscriptionActiveUntil = nil + result.SubscriptionKnown = false + } result.Priority = keeperIntPtr(merged["priority"]) disabled := keeperBool(merged["disabled"]) result.Disabled = &disabled @@ -2366,7 +2804,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map if disabled && !manualRefresh && !recoverableUnauthorizedDisabled { result.Result = "disabled" a.preserveKeeperBadCredentialDiagnosis(ctx, &result) - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) return result } if keeperString(merged["access_token"]) == "" { @@ -2378,7 +2816,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map message = "禁用坏凭证失败:" + err.Error() result.LastError = &message result.Result = "network_error" - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) return result } } @@ -2393,7 +2831,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map result.Result = "status_disabled" result.LastError = &message result.LatestAction = &action - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) logFn(name + ": " + action) return result } @@ -2404,7 +2842,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map result.Result = "network_error" result.LastError = &message result.LatestAction = &message - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) logFn(name + ": " + message) return result } @@ -2421,7 +2859,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map message = "禁用坏凭证失败:" + err.Error() result.Result = "network_error" result.LastError = &message - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) return result } } @@ -2436,7 +2874,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map result.Result = "status_disabled" result.LastError = &message result.LatestAction = &action - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) logFn(name + ": " + action) return result } @@ -2448,7 +2886,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map result.Result = "network_error" result.LastError = &message result.LatestAction = &message - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) return result } usage := parseKeeperUsageInfo(usageResult.JSONData) @@ -2460,6 +2898,30 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map result.PrimaryWindowSeconds = usage.PrimaryWindowSeconds result.SecondaryWindowSeconds = usage.SecondaryWindowSeconds result.QuotaThreshold = &cfg.CodexKeeper.QuotaThreshold + // Best-effort reset-credit snapshot. A failed/malformed fetch leaves both fields nil, + // so upsertKeeperState preserves the previous snapshot instead of wiping it; a + // successful empty result carries count 0 and an empty list. (An identity conflict has + // already bailed out above, so this fetch only runs for a reconciled identity.) + if !accountIDKnown { + // No stable account_id → we cannot safely attribute a reset-credit snapshot to a + // resource, so we do NOT fetch it (leaving both fields nil preserves the prior + // account-scoped snapshot via the upsert's COALESCE). Flag it unavailable so a + // post-reset refresh is audited partial, never a falsely-healthy ok. + result.ResetCreditsUnavailable = true + } else if count, credits, ok := a.fetchKeeperResetCredits(ctx, cfg, merged); ok { + result.ResetCreditCount = &count + if encoded, err := json.Marshal(credits); err == nil { + payload := string(encoded) + result.ResetCredits = &payload + } + } else { + // The usage check succeeded (account is healthy), but the reset-credit fetch + // itself failed (inner 401/500, malformed, transport). The snapshot is + // preserved (best-effort) and account health is unchanged, but the reset + // credits were NOT refreshed — flag it so a post-reset refresh is audited as + // partial rather than falsely reported ok. + result.ResetCreditsUnavailable = true + } result.Result = "healthy" if recoverableUnauthorizedDisabled { @@ -2470,7 +2932,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map result.Result = "network_error" result.LastError = &message result.LatestAction = &message - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) logFn(name + ": " + message) return result } @@ -2483,7 +2945,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map result.LatestAction = &action result.ClearRestorePriority = true result.LastError = nil - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) logFn(name + ": " + action) return result } @@ -2519,7 +2981,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map result.ClearRestorePriority = true } result.LastError = nil - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) return result } @@ -2632,9 +3094,17 @@ func (a *App) mergeKeeperStats(stats *keeperStats, result keeperAccountResult) { stats.PriorityRestored++ case "network_error": stats.NetworkError++ + case "identity_error": + stats.IdentityError++ default: stats.Skipped++ } + if result.ResetCreditsUnavailable { + stats.ResetCreditsUnavailable++ + } + if result.StateWriteFailed { + stats.StateWriteError++ + } } func (stats *keeperStats) add(delta keeperStats) { @@ -2646,6 +3116,9 @@ func (stats *keeperStats) add(delta keeperStats) { stats.PriorityRestored += delta.PriorityRestored stats.Skipped += delta.Skipped stats.NetworkError += delta.NetworkError + stats.IdentityError += delta.IdentityError + stats.ResetCreditsUnavailable += delta.ResetCreditsUnavailable + stats.StateWriteError += delta.StateWriteError } func (stats *keeperStats) mergeCachedState(state keeperAuthState) { @@ -2660,7 +3133,12 @@ func (stats *keeperStats) mergeCachedState(state keeperAuthState) { stats.NetworkError++ return } - if state.Priority != nil && *state.Priority == -1 { + // The quota-usage → priority=-1 "degraded" semantic is Codex-only; Antigravity has no such + // mechanism (priority is managed differently and -1 is not a quota-exhaustion marker). So an + // Antigravity cached row at priority -1 must NOT be counted as degraded — it falls through to + // the healthy/timestamp branch like any other provider. Mirrors the frontend's + // isQuotaExhaustedAccount() !isAntigravity guard. + if *keeperProviderOrCodex(state.Provider) != keeperProviderAntigravity && state.Priority != nil && *state.Priority == -1 { stats.PriorityDegraded++ return } @@ -2819,12 +3297,287 @@ func (a *App) checkKeeperUsage(ctx context.Context, cfg AppConfig, detail map[st } } +// fetchKeeperResetCredits pulls the account's reset-credit list from +// wham/rate-limit-reset-credits through the same per-auth api-call egress used by +// checkKeeperUsage. It is best-effort: any transport error, non-2xx inner status, +// or malformed/incomplete body returns ok=false so the caller preserves the +// previous snapshot rather than overwriting it. On success it returns the +// authoritative available_count (never credits length, which upstream may +// truncate) and the safe-projected, expires_at-ascending credit list. +func (a *App) fetchKeeperResetCredits(ctx context.Context, cfg AppConfig, detail map[string]any) (int, []keeperResetCredit, bool) { + authIndex := keeperAuthIndex(detail) + header := map[string]string{ + "Authorization": "Bearer $TOKEN$", + "Content-Type": "application/json", + "User-Agent": "codex_cli_rs/0.76.0", + } + if accountID := keeperString(detail["account_id"]); accountID != "" { + header["Chatgpt-Account-Id"] = accountID + } + body := map[string]any{ + "auth_index": authIndex, + "method": "GET", + "url": keeperResetCreditsURL, + "header": header, + "data": "", + } + response, payload, err := a.keeperRequest(ctx, cfg, http.MethodPost, "/v0/management/api-call", nil, body, time.Duration(cfg.CodexKeeper.UsageTimeoutSeconds)*time.Second) + if err != nil { + return 0, nil, false + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return 0, nil, false + } + var raw map[string]any + if err := json.Unmarshal(payload, &raw); err != nil { + return 0, nil, false + } + // The inner status must be a strict JSON integer in the 2xx range. Reusing the + // lenient keeperIntPtr would let a deceptive status_code of 200.5 (truncated) or + // "200" (string) pass as success and overwrite the snapshot, so parse it strictly. + if !keeperInnerStatusOK(raw) { + return 0, nil, false + } + return parseKeeperResetCredits(keeperBodyJSON(raw["body"])) +} + +// keeperInnerStatusOK reports whether the api-call wrapper's inner status is a +// strict non-negative JSON integer in [200,300). It prefers status_code and only +// falls back to statusCode when status_code is absent; a present-but-malformed +// status_code (fractional, string, negative, out-of-range) fails closed. +func keeperInnerStatusOK(raw map[string]any) bool { + value, present := raw["status_code"] + if !present { + value = raw["statusCode"] + } + status, ok := keeperStrictNonNegInt(value) + return ok && status >= 200 && status < 300 +} + +// parseKeeperResetCredits validates and projects a rate-limit-reset-credits body. +// The top-level available_count must be a JSON integer >= 0; a fractional, +// negative, missing, or non-numeric count fails the whole snapshot (ok=false) so +// the previous snapshot is preserved rather than trusting a malformed response. +// A credits array is likewise required. Entries are kept only when +// reset_type == codex_rate_limits, status == available, is_supported_by_plan is +// not explicitly false, AND they pass strict per-entry validation: a non-empty id, +// a present+parseable granted_at, and an expires_at that is either null/absent +// (never-expiring, kept and sorted last) or a valid RFC3339 timestamp. A malformed +// entry is explicitly dropped — never kept with silently nil'd fields — while the +// authoritative available_count is unaffected. Only id/reset_type/status/ +// granted_at/expires_at/title are projected; profile URL and description dropped. +func parseKeeperResetCredits(body map[string]any) (int, []keeperResetCredit, bool) { + if body == nil { + return 0, nil, false + } + count, ok := keeperStrictNonNegInt(body["available_count"]) + if !ok { + return 0, nil, false + } + rawCredits, ok := body["credits"].([]any) + if !ok { + return 0, nil, false + } + credits := make([]keeperResetCredit, 0, len(rawCredits)) + for _, item := range rawCredits { + entry, ok := item.(map[string]any) + if !ok { + continue + } + if keeperString(entry["reset_type"]) != keeperResetCreditType { + continue + } + if keeperString(entry["status"]) != keeperResetCreditStatus { + continue + } + if v, present := entry["is_supported_by_plan"]; present && !keeperBool(v) { + continue + } + id := keeperString(entry["id"]) + if id == "" { + continue // schema requires an id; drop rather than fabricate identity + } + granted, ok := keeperParseRequiredTime(entry["granted_at"]) + if !ok { + continue // granted_at must be present and parseable + } + expires, ok := keeperParseOptionalTime(entry["expires_at"]) + if !ok { + continue // a non-null expires_at that will not parse is malformed + } + credits = append(credits, keeperResetCredit{ + ID: id, + ResetType: keeperString(entry["reset_type"]), + Status: keeperString(entry["status"]), + GrantedAt: granted, + ExpiresAt: expires, + Title: keeperString(entry["title"]), + }) + } + sort.SliceStable(credits, func(i, j int) bool { + a, b := credits[i].ExpiresAt, credits[j].ExpiresAt + if a == nil && b == nil { + return false + } + if a == nil { // never-expiring sorts last + return false + } + if b == nil { + return true + } + return a.Before(*b) + }) + return count, credits, true +} + +// parseStoredKeeperResetCredits decodes the reset_credits JSON snapshot column +// back into projected credits, returning nil for NULL/empty/invalid JSON. +func parseStoredKeeperResetCredits(value sql.NullString) []keeperResetCredit { + if !value.Valid || strings.TrimSpace(value.String) == "" { + return nil + } + var credits []keeperResetCredit + if err := json.Unmarshal([]byte(value.String), &credits); err != nil { + return nil + } + return credits +} + +// keeperMaxSafeCount is the largest count we accept: 2^53-1, the JSON +// safe-integer limit. A float64 represents integers exactly only up to this +// bound, so a larger value cannot be trusted as an exact count (and int(f) could +// overflow), and available_count is realistically tiny anyway. +const keeperMaxSafeCount = 1<<53 - 1 + +// keeperStrictNonNegInt accepts only a JSON integer value (float64 with no +// fractional part) in [0, 2^53-1]. It rejects fractional numbers (e.g. 2.5), +// negatives, out-of-range/NaN/Inf, strings, and non-numeric values so a malformed +// available_count fails closed instead of being silently truncated by keeperIntPtr. +func keeperStrictNonNegInt(value any) (int, bool) { + f, ok := value.(float64) + if !ok { + return 0, false + } + if math.IsNaN(f) || math.IsInf(f, 0) { + return 0, false + } + if f < 0 || f > keeperMaxSafeCount || f != math.Trunc(f) { + return 0, false + } + return int(f), true +} + +// keeperParseRequiredTime requires a present, non-empty, RFC3339-parseable value: +// (t,true) on success, (nil,false) otherwise. +func keeperParseRequiredTime(value any) (*time.Time, bool) { + s := keeperString(value) + if s == "" { + return nil, false + } + parsed, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + return nil, false + } + return &parsed, true +} + +// keeperParseOptionalTime enforces the strict expires_at contract: only JSON null +// or an absent field (both surface as a nil value) mean "never expires" (nil,true). +// Any other present value must be a non-empty RFC3339 string; an empty string, a +// non-string type (e.g. a number), or an unparseable string is malformed +// (nil,false) so the entry is dropped rather than silently treated as never-expiring. +func keeperParseOptionalTime(value any) (*time.Time, bool) { + if value == nil { + return nil, true // JSON null or absent field → never expires + } + s, ok := value.(string) + if !ok || s == "" { + return nil, false // present but not a non-empty string → malformed + } + parsed, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + return nil, false + } + return &parsed, true +} + +// keeperSubscriptionActiveUntil extracts the ChatGPT subscription renewal time +// from an auth-file list entry's parsed id_token claims (CPA's ListAuthFiles +// surfaces `id_token.chatgpt_subscription_active_until`). It is tri-state so a +// missing/malformed claim never corrupts a good stored snapshot: +// - (t, true) — claim present and parsed (Unix seconds or RFC3339 / date). +// - (nil, true) — id_token readable but the claim is confirmed absent/null: +// the account has no active subscription, so the old value may be cleared. +// - (nil, false)— UNKNOWN: id_token unreadable, or the claim is present but +// malformed (wrong type, empty, non-positive, unparseable time). The caller +// must preserve the previous snapshot rather than clearing it. +func keeperSubscriptionActiveUntil(authInfo map[string]any) (*time.Time, bool) { + idToken, ok := authInfo["id_token"].(map[string]any) + if !ok { + return nil, false + } + value, present := idToken["chatgpt_subscription_active_until"] + if !present || value == nil { + return nil, true + } + switch v := value.(type) { + case float64: + // JSON numbers arrive as float64. Require a finite, whole value: reject NaN/Inf + // and a fractional value (a truncated/garbage number). The magnitude is checked + // as float64 BEFORE the int64 conversion so an overflowing value (e.g. + // math.MaxInt64) can never wrap into a spurious in-range integer. + if math.IsNaN(v) || math.IsInf(v, 0) || v != math.Trunc(v) { + return nil, false + } + if v < keeperSubscriptionMinUnix || v > keeperSubscriptionMaxUnix { + return nil, false + } + return keeperValidateSubscriptionTime(time.Unix(int64(v), 0)) + case string: + s := strings.TrimSpace(v) + if s == "" { + return nil, false + } + for _, layout := range []string{time.RFC3339Nano, "2006-01-02T15:04:05Z07:00", "2006-01-02"} { + if t, err := time.Parse(layout, s); err == nil { + return keeperValidateSubscriptionTime(t) + } + } + if secs, err := strconv.ParseInt(s, 10, 64); err == nil { + return keeperValidateSubscriptionTime(time.Unix(secs, 0)) + } + } + return nil, false +} + +// keeperSubscription{Min,Max}Unix bound a plausible subscription renewal time +// (2000-01-01 .. 2100-01-01 UTC) so a corrupt/extreme epoch never becomes a stored +// timestamp. +const ( + keeperSubscriptionMinUnix = 946684800 // 2000-01-01T00:00:00Z + keeperSubscriptionMaxUnix = 4102444800 // 2100-01-01T00:00:00Z +) + +// keeperValidateSubscriptionTime is the single range gate every parsed subscription +// time (numeric Unix, RFC3339, or date string) must pass: it returns the UTC time only +// when it falls inside the plausible [2000, 2100] window, otherwise (nil, false). This +// keeps an extreme string date (e.g. 0001-01-01 / 9999-...) from being stored as known. +func keeperValidateSubscriptionTime(t time.Time) (*time.Time, bool) { + if secs := t.Unix(); secs < keeperSubscriptionMinUnix || secs > keeperSubscriptionMaxUnix { + return nil, false + } + utc := t.UTC() + return &utc, true +} + func (a *App) listKeeperAccounts(ctx context.Context) ([]keeperAccount, error) { rows, err := a.db.QueryContext(ctx, ` SELECT auth_name, email, auth_index, account_type, disabled, priority, primary_used_percent, secondary_used_percent, CAST(primary_reset_at AS TEXT), CAST(secondary_reset_at AS TEXT), quota_threshold, last_status_code, last_error, latest_action, CAST(last_checked_at AS TEXT), CAST(last_healthy_at AS TEXT), - primary_window_seconds, secondary_window_seconds, restore_priority, CAST(created_at AS TEXT), CAST(updated_at AS TEXT) + primary_window_seconds, secondary_window_seconds, restore_priority, CAST(created_at AS TEXT), CAST(updated_at AS TEXT), + reset_credit_count, CAST(reset_credits AS TEXT), CAST(subscription_active_until AS TEXT), CAST(account_id AS TEXT), + provider, CAST(antigravity_quota AS TEXT), antigravity_identity_digest FROM codex_keeper_auth_states ORDER BY COALESCE(email, ''), auth_name `) @@ -2840,7 +3593,645 @@ func (a *App) listKeeperAccounts(ctx context.Context) ([]keeperAccount, error) { } accounts = append(accounts, state.keeperAccount) } - return accounts, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + return accounts, nil +} + +// keeperResetOutcomeCooldownOnly is the outcome when no consume ran (no credit was +// available and no pending redeem existed) — only the local cooldown was cleared. The +// other outcomes are exactly the OpenAI terminal codes, so the caller can message each +// distinctly (already_redeemed ≠ reset; nothing_to_reset ≠ no_credit). +const keeperResetOutcomeCooldownOnly = "cooldown_only" + +// keeperResetPartialCode is the stable error code for an irreversible partial reset (a +// credit was consumed but the local cooldown clear then failed). The handler recognizes +// it and does NOT re-audit a generic result=error over the already-recorded partial. +const keeperResetPartialCode = "reset_partial" + +func keeperResetPartialError() *AppError { + return appError(keeperResetPartialCode, http.StatusConflict, "已消耗 1 次主动重置额度,但清理本地冷却失败;请稍后重试(系统会复用同一凭据幂等重试,不会重复消耗)") +} + +type keeperQuotaResetResult struct { + Name string `json:"name"` + // Outcome is one of: reset, already_redeemed, no_credit, nothing_to_reset, + // cooldown_only. It preserves the real business result without leaking internals. + Outcome string `json:"outcome"` +} + +// resetKeeperQuota performs a real reset for one auth. When the account has an +// available reset credit (reset_credit_count > 0, the authoritative +// available_count — never the possibly-truncated credits list length), it first +// redeems one credit against OpenAI via wham/rate-limit-reset-credits/consume +// (fail-closed: any transport error / non-2xx / non-ok inner status aborts the +// whole operation and never reports a redemption). It then always clears the +// local CLIProxyAPI 429 cooldown via /reset-quota. When no credit is available +// it skips the consume and only clears the cooldown. +func (a *App) resetKeeperQuota(ctx context.Context, authName string) (keeperQuotaResetResult, error) { + cfg, err := a.loadConfig(ctx) + if err != nil { + return keeperQuotaResetResult{}, err + } + // Dry-run means "no real side effects". A manual reset really redeems a paid credit + // and clears the cooldown, so it is fail-closed under dry-run — an admin testing the + // keeper must not silently burn a credit. Turn dry-run off to perform a real reset. + if cfg.CodexKeeper.DryRun { + a.auditKeeperOp("reset-quota", authName, "result", "error", "reason", "dry_run") + return keeperQuotaResetResult{}, validationError("当前为 dry-run 模式,已阻止真实核销/重置;请关闭 dry-run 后重试") + } + + // A missing runner is fail-closed rather than "proceed unlocked" — never let a serve/test + // variant bypass the concurrency guards below. + if a.keeper == nil { + return keeperQuotaResetResult{}, validationError("Keeper 未初始化,无法安全重置") + } + // Acquire the per-auth_name lock FIRST, then read state under it, so the decision never uses + // a pre-lock snapshot that a concurrent delete/rebuild could have invalidated. This lock is a + // per-FILE/state guard: it serializes operations on THIS auth_name (reset/inspect/delete/…) + // so its state row and remote credential are not mutated concurrently. It is NOT by itself the + // cross-file monetary guard — two DIFFERENT files that resolve to the same OpenAI account hold + // DIFFERENT auth_name locks; the paid-credit mutual exclusion across those routes is the + // stored-account_id fence acquired below. Non-blocking: a contended request returns a + // conflict. Released on return, BEFORE the handler's chained InspectAccountsLocked (which + // re-acquires it itself). + if !a.keeper.tryLockAuthName("reset", authName) { + return keeperQuotaResetResult{}, conflictError("账号正在巡检或重置中,请稍后重试") + } + defer a.keeper.unlockAuthName(authName) + + state, err := a.getKeeperState(ctx, authName) + if err != nil { + return keeperQuotaResetResult{}, err + } + if state == nil { + return keeperQuotaResetResult{}, notFoundError("账号不存在") + } + if state.AuthIndex == nil || strings.TrimSpace(*state.AuthIndex) == "" { + return keeperQuotaResetResult{}, validationError("该账号缺少 auth_index,请先刷新账号列表") + } + // The DB auth_index is only a page snapshot for the initial existence check; it must + // NOT route any remote call (see below). + dbAuthIndex := strings.TrimSpace(*state.AuthIndex) + + // The account fence MUST be acquired before any fresh list/download resolution, so two + // different files that resolve to the SAME account cannot interleave in a pre-fence window + // (file A blocked mid-download while file B fully resolves, consumes, finishes and releases, + // then A resumes, sees a terminal ledger and mints a fresh redeem id → a SECOND consume). + // Fencing before the remote resolve requires a stable key up front, so the fence keys on the + // STORED account_id. That means the row must already carry a confirmed account_id: a legacy + // pre-account_id (NULL) row must be inspected once first (which persists its account_id) + // before it can be reset — fail closed otherwise rather than fencing on an unknown identity. + if state.AccountID == nil || strings.TrimSpace(*state.AccountID) == "" { + a.auditKeeperOp("reset-quota", authName, "result", "error", "reason", "account_id_unknown", "auth_index", dbAuthIndex) + return keeperQuotaResetResult{}, validationError("账号尚未确认身份(缺少 account_id),请先刷新账号列表后再重置") + } + storedAccountID := strings.TrimSpace(*state.AccountID) + + // Hold the ACCOUNT-level fence (in addition to the per-auth_name lock) across the WHOLE + // operation — resolve → pending-lookup → fetch → claim → consume → cooldown → finish. The + // paid, limited credit is the ACCOUNT's, so two different filenames/routes for the SAME + // account (rename overlap / duplicate import) must be mutually exclusive even though they + // hold different auth_name locks — otherwise the second route could redeem a second credit. + // Keying on the stored account_id and taking it BEFORE the resolve closes the pre-fence + // window. Non-blocking: a contended reset of the same account returns a conflict. + if !a.keeper.tryLockAccountID(storedAccountID) { + a.auditKeeperOp("reset-quota", authName, "result", "error", "reason", "account_busy", "auth_index", dbAuthIndex) + return keeperQuotaResetResult{}, conflictError("同一 OpenAI 账号的另一路由正在重置,请稍后重试") + } + defer a.keeper.unlockAccountID(storedAccountID) + + // Resolve the account identity (auth_index + account_id) FRESH from CPA and bind + // every downstream call to it. The list entry and download detail are validated + // separately: a conflicting explicit auth_index/account_id between them fails + // closed (never silently merged into a cross-row mix), there is no auth-name + // fallback for the auth_index on the reset path, and a missing account_id fails + // closed (the consume header needs it). + identity, ierr := a.resolveKeeperResetIdentity(ctx, cfg, authName) + if ierr != nil { + a.auditKeeperOp("reset-quota", authName, "result", "error", "reason", keeperSafeReason(ierr), "auth_index", dbAuthIndex) + return keeperQuotaResetResult{}, ierr + } + // From here ALL remote routing (consume, /reset-quota cooldown clear) and audits use the + // FRESH auth_index — never the stale DB value — so a reindexed account is consumed AND + // cooled down on the SAME (current) index. Routing the cooldown clear with the old index + // would clear the wrong account or leave an irreversible partial. + authIndex := identity.authIndex + + // account_id is the RESOURCE identity; auth_index is only a routing selector (a CPA hash of + // the file path) and legitimately changes on a file rename/move/reorder, so it is NOT + // compared to the DB row — the fresh auth_index is used as-is for routing. What MUST match is + // the account: the FRESH account_id has to equal the STORED account_id we fenced on, else a + // stale page is acting on a swapped-out account (or the file was rebound to another account) + // and would consume the WRONG account's credit. Fail closed and ask for a refresh. + if identity.accountID != storedAccountID { + a.auditKeeperOp("reset-quota", authName, "result", "error", "reason", "account_id_mismatch", "auth_index", authIndex) + return keeperQuotaResetResult{}, validationError("账号身份已变化(account_id 不一致),请刷新账号列表后重试") + } + + // A normalized detail carrying ONLY the validated identity, so the fetch/consume + // never re-derive an index from a name fallback or a conflicting field. + identDetail := map[string]any{"auth_index": identity.authIndex, "account_id": identity.accountID} + + consumed := false + // Set when a real credit was redeemed: the ledger stays pending through the + // cooldown step and is finalized only after the whole operation succeeds. + var pendingRedeemID, redeemCode string + // availableCount is only meaningful (>=0) on the new-operation path; a pending + // replay does not depend on it. -1 marks "not fetched" for the audit trail. + availableCount := -1 + + // Resolve an identity-matched pending redeem FIRST. It must be replayable with its + // original key even when the count endpoint is temporarily unavailable — a lost + // first response may have consumed the last credit, and only replaying the same key + // recovers the true terminal state (already_redeemed). The fresh count gate applies + // ONLY to a brand-new operation. + redeemID, hasPending, perr := a.lookupPendingKeeperRedeem(ctx, identity.accountID) + if perr != nil { + return keeperQuotaResetResult{}, perr + } + doConsume := hasPending + if !hasPending { + // A NEW operation requires a known authoritative available_count: a NULL/stale + // stored count must not be read as a known 0, and an unknown fresh count blocks + // the operation instead of degrading to a cooldown-only clear. + count, _, ok := a.fetchKeeperResetCredits(ctx, cfg, identDetail) + if !ok { + a.auditKeeperOp("reset-quota", authName, "result", "error", "reason", "credit_count_unknown", "auth_index", authIndex) + return keeperQuotaResetResult{}, validationError("无法确认可用重置额度(快照未知),请刷新后重试") + } + availableCount = count + if count > 0 { + newID, cerr := a.createKeeperRedeem(ctx, identity.accountID) + if cerr != nil { + return keeperQuotaResetResult{}, cerr + } + redeemID = newID + doConsume = true + } + } + if doConsume { + code, cerr := a.consumeKeeperResetCredit(ctx, cfg, authName, identity.authIndex, identDetail, redeemID) + if cerr != nil { + // Unknown outcome: keep the ledger pending (same redeemID) so the next + // attempt reuses it. Fail closed; never report a redemption. + a.auditKeeperOp("reset-quota", authName, "result", "error", "reason", "consume_failed", "auth_index", authIndex) + return keeperQuotaResetResult{}, cerr + } + switch code { + case keeperResetCreditCodeReset, keeperResetCreditCodeAlreadyRedeemed: + // A credit was consumed. Do NOT finalize the ledger yet — the operation is + // not done until the local cooldown is cleared. Keeping it pending lets a + // retry after a cooldown failure reuse the same id (already_redeemed) and + // never burn a second credit. + consumed = true + pendingRedeemID = redeemID + redeemCode = code + case keeperResetCreditCodeNoCredit, keeperResetCreditCodeNothingToReset: + // Nothing was redeemed (the fresh count was stale-high) — no credit is at + // risk, so finalize the ledger now. + redeemCode = code + if ferr := a.finishKeeperRedeem(ctx, identity.accountID, redeemID, code); ferr != nil { + a.auditKeeperOp("reset-redeem-ledger", authName, "result", "error", "reason", keeperSafeReason(ferr)) + } + } + } + + // Always clear the local CLIProxyAPI 429 cooldown for this auth. + timeout := time.Duration(cfg.CodexKeeper.CPATimeoutSeconds) * time.Second + _, payload, err := a.keeperRequest(ctx, cfg, http.MethodPost, "/v0/management/reset-quota", nil, + map[string]any{"auth_index": authIndex}, timeout) + if err != nil { + if consumed { + // Irreversible partial: the credit was consumed but the cooldown clear + // failed. The ledger stays pending so a retry reuses the same id. Audit the + // partial and return a partial-coded error so the handler does NOT overwrite + // this line with a generic result=error. + a.auditKeeperOp("reset-quota", authName, "result", "partial", "reason", "cooldown_failed_after_consume", "code", redeemCode, "auth_index", authIndex) + return keeperQuotaResetResult{}, keeperResetPartialError() + } + return keeperQuotaResetResult{}, err + } + // A 2xx alone is not proof of a reset: require the CLIProxyAPI response to confirm + // status=ok for the exact auth_index we asked about, otherwise fail closed. + var cpaResult struct { + Status string `json:"status"` + AuthIndex string `json:"auth_index"` + } + if err := json.Unmarshal(payload, &cpaResult); err != nil || + cpaResult.Status != "ok" || cpaResult.AuthIndex != authIndex { + if consumed { + a.auditKeeperOp("reset-quota", authName, "result", "partial", "reason", "cooldown_failed_after_consume", "code", redeemCode, "auth_index", authIndex) + return keeperQuotaResetResult{}, keeperResetPartialError() + } + return keeperQuotaResetResult{}, validationError("CLIProxyAPI 未确认重置成功(响应缺少 status=ok 或 auth_index 不匹配)") + } + // The whole operation succeeded — now finalize the redeem ledger. A ledger-write + // failure here is non-fatal: a later reset reuses the same id and OpenAI returns + // already_redeemed (idempotent). + if pendingRedeemID != "" { + if ferr := a.finishKeeperRedeem(ctx, identity.accountID, pendingRedeemID, redeemCode); ferr != nil { + a.auditKeeperOp("reset-redeem-ledger", authName, "result", "error", "reason", keeperSafeReason(ferr)) + } + } + // The outcome is the exact terminal code when a consume ran, else cooldown-only. + outcome := keeperResetOutcomeCooldownOnly + if redeemCode != "" { + outcome = redeemCode + } + a.auditKeeperOp("reset-quota", authName, "result", "ok", "outcome", outcome, "consumed", consumed, "available", availableCount, "auth_index", authIndex) + return keeperQuotaResetResult{Name: authName, Outcome: outcome}, nil +} + +// keeperResetIdentity is the validated account identity a reset acts on. +type keeperResetIdentity struct { + authIndex string + accountID string +} + +// resolveKeeperResetIdentity reads the account's list entry and download detail and +// resolves the (auth_index, account_id) to act on, validating each source's EXPLICIT +// identity separately. It never falls back to the auth name for an auth_index, fails +// closed when the two sources carry conflicting explicit identities, and fails closed +// when no explicit auth_index or no account_id can be determined. +func (a *App) resolveKeeperResetIdentity(ctx context.Context, cfg AppConfig, authName string) (keeperResetIdentity, error) { + items, err := a.listKeeperRemoteAuthFiles(ctx, cfg) + if err != nil { + return keeperResetIdentity{}, err + } + var authInfo map[string]any + matches := 0 + for _, item := range items { + if keeperString(item["name"]) == authName { + authInfo = item + matches++ + } + } + if authInfo == nil { + return keeperResetIdentity{}, validationError("账号在远端列表中不存在,请刷新后重试") + } + // A duplicate name in the remote list (a malformed/corrupt response) makes the + // "same-row binding" ambiguous — pick nothing, fail closed rather than an arbitrary row. + if matches > 1 { + return keeperResetIdentity{}, validationError("远端存在多个同名账号条目,无法安全重置,请核对后重试") + } + // listKeeperRemoteAuthFiles returns every provider. The reset consume targets + // OpenAI's Codex endpoint, so require this entry to be a Codex account — an + // auth_name reused by / drifted to another provider must never send a non-Codex + // token/index to the consume. + if keeperString(authInfo["type"]) != "codex" { + return keeperResetIdentity{}, validationError("账号不是 Codex 类型,无法主动重置") + } + detail, err := a.getKeeperRemoteAuthFile(ctx, cfg, authName) + if err != nil { + return keeperResetIdentity{}, err + } + if detail == nil { + return keeperResetIdentity{}, validationError("读取账号详情失败,未执行主动重置") + } + // If the download detail carries an explicit name, it MUST still be the account we + // asked for — a proxy misroute / mismatched response must not bind another + // credential's detail to this target. + if dn := strings.TrimSpace(keeperString(detail["name"])); dn != "" && dn != authName { + return keeperResetIdentity{}, validationError("账号详情名称不匹配,请刷新后重试") + } + // A download detail that carries an explicit, non-Codex type is a provider drift / + // list-vs-detail conflict — fail closed rather than act on it. + if dt := keeperString(detail["type"]); dt != "" && dt != "codex" { + return keeperResetIdentity{}, validationError("账号类型冲突(详情非 Codex),请刷新后重试") + } + // Require a Codex access_token in the auth JSON: the consume relies on CLIProxyAPI's + // $TOKEN$ substitution, which otherwise falls back to api_key / id_token / cookie and + // could send the wrong credential to wham. Reset is irreversible, so a missing token + // fails closed rather than depending on an upstream 401. + if strings.TrimSpace(keeperString(detail["access_token"])) == "" { + return keeperResetIdentity{}, validationError("账号缺少 access_token,无法安全核销,请刷新后重试") + } + listAuthIndex, err := keeperExplicitAuthIndex(authInfo) + if err != nil { + return keeperResetIdentity{}, validationError("列表条目 auth_index 字段自相矛盾,请刷新后重试") + } + detailAuthIndex, err := keeperExplicitAuthIndex(detail) + if err != nil { + return keeperResetIdentity{}, validationError("详情 auth_index 字段自相矛盾,请刷新后重试") + } + authIndex, err := keeperReconcileIdentityField(listAuthIndex, detailAuthIndex) + if err != nil { + return keeperResetIdentity{}, validationError("账号 auth_index 身份冲突(列表与详情不一致),请刷新后重试") + } + if authIndex == "" { + return keeperResetIdentity{}, validationError("无法确定账号 auth_index,请刷新账号列表后重试") + } + // The consume header's account_id comes from the download's account_id (top-level + // and/or id_token claim, which must agree) and is REQUIRED. The list side's account_id + // is an optional second exact cross-check when present — its absence must not block a + // legitimate older credential. + accountID, err := keeperExplicitAccountID(detail) + if err != nil { + return keeperResetIdentity{}, validationError("详情 account_id 字段自相矛盾,请刷新后重试") + } + if accountID == "" { + return keeperResetIdentity{}, validationError("账号缺少 account_id,无法安全核销,请刷新后重试") + } + listAccountID, err := keeperExplicitAccountID(authInfo) + if err != nil { + return keeperResetIdentity{}, validationError("列表条目 account_id 字段自相矛盾,请刷新后重试") + } + if listAccountID != "" && listAccountID != accountID { + return keeperResetIdentity{}, validationError("账号 account_id 身份冲突(列表与详情不一致),请刷新后重试") + } + return keeperResetIdentity{authIndex: authIndex, accountID: accountID}, nil +} + +var errKeeperIdentityConflict = errors.New("keeper identity conflict") + +// keeperReconcileIdentityField returns the agreed value of an identity field from two +// sources: when both are present they must be equal (else a conflict error); otherwise +// the present one, or "" when neither is present. +func keeperReconcileIdentityField(left, right string) (string, error) { + left = strings.TrimSpace(left) + right = strings.TrimSpace(right) + switch { + case left != "" && right != "" && left != right: + return "", errKeeperIdentityConflict + case left != "": + return left, nil + default: + return right, nil + } +} + +// keeperConsistentValue returns the single agreed non-empty value among candidates, or +// an error when two present candidates disagree. Reset is irreversible, so an object +// whose own alias fields conflict (a deceptive/corrupt entry) fails closed rather than +// silently taking a precedence winner. +func keeperConsistentValue(candidates ...string) (string, error) { + agreed := "" + for _, c := range candidates { + c = strings.TrimSpace(c) + if c == "" { + continue + } + if agreed == "" { + agreed = c + } else if agreed != c { + return "", errKeeperIdentityConflict + } + } + return agreed, nil +} + +// keeperInspectionIdentity is the per-source-validated identity for an inspection: the +// reconciled account_id and auth_index. Callers route (auth_index) and header (account_id) +// account-scoped requests using ONLY these normalized values, never re-deriving them from the +// raw right-biased merge (which can drop a list-only value or fall back to the auth name). +type keeperInspectionIdentity struct { + accountID string + authIndex string +} + +// keeperReconcileInspectionIdentity reconciles the account identity across BOTH sources +// during inspection — the list entry's explicit account_id (its id_token.chatgpt_account_id, +// the same source as the subscription claim) and auth_index, and the download detail's +// account_id and auth_index. It returns (identity, true) when each field agrees (or only one +// source has it, or neither), and ({}, false) when ANY field conflicts across sources or a +// single source is self-contradictory. A false result means the merged detail mixes two +// accounts, so the caller must not fetch/write account data from it. +func keeperReconcileInspectionIdentity(authInfo, detail map[string]any) (keeperInspectionIdentity, bool) { + listAcct, lerr := keeperExplicitAccountID(authInfo) + detailAcct, derr := keeperExplicitAccountID(detail) + listIdx, lierr := keeperExplicitAuthIndex(authInfo) + detailIdx, dierr := keeperExplicitAuthIndex(detail) + if lerr != nil || derr != nil || lierr != nil || dierr != nil { + return keeperInspectionIdentity{}, false + } + acct, rerr := keeperReconcileIdentityField(listAcct, detailAcct) + if rerr != nil { + return keeperInspectionIdentity{}, false + } + idx, ierr := keeperReconcileIdentityField(listIdx, detailIdx) + if ierr != nil { + return keeperInspectionIdentity{}, false + } + return keeperInspectionIdentity{accountID: acct, authIndex: idx}, true +} + +// keeperExplicitStringField reads an identity field that, WHEN PRESENT, must be a string. +// It distinguishes absent (key missing or null → "", nil) from present-but-invalid (present +// with a non-string type, e.g. a JSON number → errKeeperIdentityConflict). Identity fields +// gate an irreversible consume, so a present-but-wrong-type alias must fail closed rather than +// be silently ignored (keeperString maps it to "") in favor of a differently-typed sibling. +func keeperExplicitStringField(o map[string]any, key string) (string, error) { + value, present := o[key] + if !present || value == nil { + return "", nil + } + text, ok := value.(string) + if !ok { + return "", errKeeperIdentityConflict + } + return strings.TrimSpace(text), nil +} + +// keeperExplicitAuthIndex returns an object's explicit auth_index — NEVER the auth name — +// validating that its alias fields (auth_index/authIndex/index) all agree. Any alias that is +// present but not a string is illegal and fails closed (not silently skipped). +func keeperExplicitAuthIndex(o map[string]any) (string, error) { + ai, err := keeperExplicitStringField(o, "auth_index") + if err != nil { + return "", err + } + aiCamel, err := keeperExplicitStringField(o, "authIndex") + if err != nil { + return "", err + } + idx, err := keeperExplicitStringField(o, "index") + if err != nil { + return "", err + } + return keeperConsistentValue(ai, aiCamel, idx) +} + +// keeperExplicitAccountID returns an object's account_id, from the top-level field AND the +// id_token's chatgpt_account_id claim, validating they all agree. The id_token may be a map +// OR — as in CLIProxyAPI's real download auth JSON — a raw JWT string; both forms are decoded +// (keeperIDTokenClaims), so a deceptive entry with top-level account_id A but a JWT claim B is +// caught as a conflict instead of silently trusting A and mixing A's metadata with B's token. +// An id_token that is present but cannot be parsed leaves the identity INDETERMINATE, so it +// fails closed (returns a conflict) rather than trusting the un-cross-checked top-level value. +func keeperExplicitAccountID(o map[string]any) (string, error) { + top, err := keeperExplicitStringField(o, "account_id") + if err != nil { + return "", err + } + candidates := []string{top} + if raw, present := o["id_token"]; present && raw != nil { + claims := keeperIDTokenClaims(raw) + if claims == nil { + return "", errKeeperIdentityConflict + } + ids, err := keeperClaimsAccountIDs(claims) + if err != nil { + return "", err + } + candidates = append(candidates, ids...) + } + return keeperConsistentValue(candidates...) +} + +// consumeKeeperResetCredit redeems one reset credit for the given auth through +// the per-auth api-call egress (CLIProxyAPI injects the account's $TOKEN$ by +// auth_index). redeemID is the caller-owned idempotency key (from the redeem +// ledger); it is passed through so keeperRequest's internal retries AND a later +// reuse of a pending id both carry the same key, and OpenAI dedups — a lost +// response can never double-consume. +// +// It returns (terminalCode, err). A 2xx inner status alone is NOT proof of a +// redemption: the inner body's `code` decides, and only a recognized terminal +// code is returned. `reset` / `already_redeemed` mean a credit was redeemed; +// `no_credit` / `nothing_to_reset` mean nothing was redeemed (the caller still +// clears the cooldown). Any transport error, non-2xx outer status, unparseable +// body, non-ok inner status, or unrecognized code is an UNKNOWN outcome → returns +// ("", err), fails closed, and (per the ledger) keeps the redeem pending for an +// idempotent retry. No external body — not even truncated — is ever logged; the +// audit trail carries only a stable reason plus whitelisted inner status_code / +// recognized code. +func (a *App) consumeKeeperResetCredit(ctx context.Context, cfg AppConfig, authName, authIndex string, detail map[string]any, redeemID string) (string, error) { + header := map[string]string{ + "Authorization": "Bearer $TOKEN$", + "Content-Type": "application/json", + "User-Agent": "codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal", + } + if accountID := keeperString(detail["account_id"]); accountID != "" { + header["Chatgpt-Account-Id"] = accountID + } + body := map[string]any{ + "auth_index": authIndex, + "method": "POST", + "url": keeperResetCreditsConsumeURL, + "header": header, + "data": fmt.Sprintf(`{"redeem_request_id":%q}`, redeemID), + } + response, payload, err := a.keeperRequest(ctx, cfg, http.MethodPost, "/v0/management/api-call", nil, body, time.Duration(cfg.CodexKeeper.UsageTimeoutSeconds)*time.Second) + if err != nil { + a.auditKeeperOp("reset-consume", authName, "result", "error", "reason", "transport_error") + return "", validationError("核销主动重置额度失败:网络异常,未确认是否已核销") + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + a.auditKeeperOp("reset-consume", authName, "result", "error", "reason", "management_status", "http_status", response.StatusCode) + return "", validationError("核销主动重置额度失败:管理接口异常") + } + var raw map[string]any + if err := json.Unmarshal(payload, &raw); err != nil { + a.auditKeeperOp("reset-consume", authName, "result", "error", "reason", "invalid_response") + return "", validationError("核销主动重置额度失败:响应无效") + } + innerStatus := keeperInnerStatusCode(raw) + if !keeperInnerStatusOK(raw) { + a.auditKeeperOp("reset-consume", authName, "result", "error", "reason", "inner_status", "status_code", innerStatus) + return "", validationError("核销主动重置额度失败:OpenAI 拒绝核销") + } + switch code := keeperString(keeperBodyJSON(raw["body"])["code"]); code { + case keeperResetCreditCodeReset, keeperResetCreditCodeAlreadyRedeemed, keeperResetCreditCodeNoCredit, keeperResetCreditCodeNothingToReset: + a.auditKeeperOp("reset-consume", authName, "result", "ok", "code", code, "status_code", innerStatus) + return code, nil + default: + // Do NOT echo the untrusted code value; log only the stable classification. + a.auditKeeperOp("reset-consume", authName, "result", "error", "reason", "unknown_code", "status_code", innerStatus) + return "", validationError("核销主动重置额度失败:响应状态未知") + } +} + +// keeperInnerStatusCode returns the api-call wrapper's inner status as an int, or +// -1 when absent/malformed. It is used only for whitelisted audit fields. +func keeperInnerStatusCode(raw map[string]any) int { + value, present := raw["status_code"] + if !present { + value = raw["statusCode"] + } + if status, ok := keeperStrictNonNegInt(value); ok { + return status + } + return -1 +} + +// lookupPendingKeeperRedeem returns the pending redeem_request_id for the OpenAI account +// accountID, if any. The ledger is keyed by the STABLE account_id (the resource identity), +// NOT by auth_name/auth_index (routing selectors that change on file rename/move/reorder), +// so a lost-response pending is still found after the account is re-routed. It is a pure +// read: it does NOT depend on the fresh credit count, so a pending can be replayed even +// when the count endpoint is temporarily unavailable. The money-critical caller (reset) MUST +// hold the per-account_id fence — the mutex that serializes same-account consume across +// different files/routes — around the lookup→consume→finish window (reset also holds the +// per-auth_name lock, but that is per-file and does not by itself serialize two files of the +// same account). +func (a *App) lookupPendingKeeperRedeem(ctx context.Context, accountID string) (redeemID string, ok bool, err error) { + var id, status string + qerr := a.db.QueryRowContext(ctx, `SELECT redeem_request_id, status FROM codex_keeper_reset_redeems WHERE account_id = ?`, accountID).Scan(&id, &status) + if qerr != nil && !errors.Is(qerr, sql.ErrNoRows) { + return "", false, qerr + } + if qerr == nil && status == keeperRedeemStatusPending && strings.TrimSpace(id) != "" { + return id, true, nil + } + return "", false, nil +} + +// createKeeperRedeem atomically claims a fresh pending redeem_request_id for the OpenAI +// account accountID and returns the WINNING row's id. Keyed by account_id, the same +// account converges on ONE key however it is routed (auth_name/auth_index may change). The +// upsert overwrites only this account's own non-pending (terminal) row; a concurrent +// pending row for the same account is left untouched, so two claimers (e.g. blue/green +// instances sharing the DB) converge on ONE request_id. SQLite serializes the writes, so +// the second upsert observes the first's committed row; within one instance the money-critical +// caller's per-account_id fence serializes same-account callers across different files/routes +// (the per-auth_name lock is per-file and does not, on its own, serialize two files that +// resolve to the same account). +// +// This HARDENS the concurrent-overlap window but is NOT a full cross-process operation +// lease: if one instance finalizes a redeem (terminal row) and a second, delayed request +// then claims, it legitimately starts a NEW operation. CPA-Helper's contract is a SINGLE +// ACTIVE INSTANCE on single-writer SQLite (no overlapping ingress during a blue/green +// swap); true cross-process single-operation idempotency would need a client-supplied +// idempotency key spanning the HTTP request, out of scope for this single-admin backend. +func (a *App) createKeeperRedeem(ctx context.Context, accountID string) (string, error) { + newID := uuid.NewString() + now := dbTime(time.Now()) + if _, err := a.db.ExecContext(ctx, ` + INSERT INTO codex_keeper_reset_redeems (account_id, redeem_request_id, status, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(account_id) DO UPDATE SET + redeem_request_id = excluded.redeem_request_id, + status = excluded.status, + updated_at = excluded.updated_at + WHERE codex_keeper_reset_redeems.status != ? + `, accountID, newID, keeperRedeemStatusPending, now, keeperRedeemStatusPending); err != nil { + return "", err + } + // Read this account's winning row. It must now be pending — either ours (INSERT / + // terminal-overwrite won) or a concurrent claimer's (preserved by the guard). Anything + // else is a lost race we must not consume against. + var id, status string + if err := a.db.QueryRowContext(ctx, `SELECT redeem_request_id, status FROM codex_keeper_reset_redeems WHERE account_id = ?`, accountID).Scan(&id, &status); err != nil { + return "", err + } + if status != keeperRedeemStatusPending || strings.TrimSpace(id) == "" { + return "", validationError("重置额度核销状态异常,请稍后重试") + } + return id, nil +} + +// finishKeeperRedeem records the terminal code for a resolved redeem (by account_id) so the +// next reset mints a fresh redeem_request_id. It only updates the row when the stored +// request_id still matches, so a concurrent fresh redeem is never clobbered. +func (a *App) finishKeeperRedeem(ctx context.Context, accountID, redeemID, code string) error { + now := dbTime(time.Now()) + _, err := a.db.ExecContext(ctx, ` + UPDATE codex_keeper_reset_redeems SET status = ?, updated_at = ? + WHERE account_id = ? AND redeem_request_id = ? + `, code, now, accountID, redeemID) + return err } func (a *App) pruneKeeperMissingAuthStates(ctx context.Context, remoteNames map[string]bool) (int, error) { @@ -2868,29 +4259,42 @@ func (a *App) pruneKeeperMissingAuthStates(ctx context.Context, remoteNames map[ if len(stale) == 0 { return 0, nil } - tx, err := a.db.BeginTx(ctx, nil) - if err != nil { - return 0, err - } - defer tx.Rollback() - stmt, err := tx.PrepareContext(ctx, `DELETE FROM codex_keeper_auth_states WHERE auth_name = ?`) - if err != nil { - return 0, err + // Prune deletes only the state row for accounts absent remotely; it never touches the + // account_id-keyed redeem ledger, so a stale/transient-empty remote list can no longer + // drop an in-flight idempotency key. It still takes the per-auth lock (skip a stale + // name a reset currently holds) so the state row is not removed mid-reset, and skips + // the whole pass when there is no runner. + if a.keeper == nil { + return 0, nil } - defer stmt.Close() pruned := 0 for _, name := range stale { - result, err := stmt.ExecContext(ctx, name) - if err != nil { - return 0, err + if !a.keeper.tryLockAuthName("prune", name) { + continue + } + affected, derr := a.deleteKeeperStateRow(ctx, name) + a.keeper.unlockAuthName(name) + if derr != nil { + return pruned, derr } - affected, _ := result.RowsAffected() - pruned += int(affected) + pruned += affected } - if err := tx.Commit(); err != nil { + return pruned, nil +} + +// deleteKeeperStateRow removes an account's state row, returning the number of rows +// deleted. It deliberately does NOT touch the redeem ledger: that ledger is keyed by the +// stable account_id, not by the auth_name/file, so deleting or pruning a file must not +// drop the account's in-flight idempotency key — if the account is re-imported (any +// filename) the pending redeem is still replayed. Callers needing the per-auth fence hold +// it around this call. +func (a *App) deleteKeeperStateRow(ctx context.Context, authName string) (int, error) { + res, err := a.db.ExecContext(ctx, `DELETE FROM codex_keeper_auth_states WHERE auth_name = ?`, authName) + if err != nil { return 0, err } - return pruned, nil + affected, _ := res.RowsAffected() + return int(affected), nil } func (a *App) getKeeperState(ctx context.Context, name string) (*keeperAuthState, error) { @@ -2898,7 +4302,9 @@ func (a *App) getKeeperState(ctx context.Context, name string) (*keeperAuthState SELECT auth_name, email, auth_index, account_type, disabled, priority, primary_used_percent, secondary_used_percent, CAST(primary_reset_at AS TEXT), CAST(secondary_reset_at AS TEXT), quota_threshold, last_status_code, last_error, latest_action, CAST(last_checked_at AS TEXT), CAST(last_healthy_at AS TEXT), - primary_window_seconds, secondary_window_seconds, restore_priority, CAST(created_at AS TEXT), CAST(updated_at AS TEXT) + primary_window_seconds, secondary_window_seconds, restore_priority, CAST(created_at AS TEXT), CAST(updated_at AS TEXT), + reset_credit_count, CAST(reset_credits AS TEXT), CAST(subscription_active_until AS TEXT), CAST(account_id AS TEXT), + provider, CAST(antigravity_quota AS TEXT), antigravity_identity_digest FROM codex_keeper_auth_states WHERE auth_name = ? `, name) if err != nil { @@ -2917,17 +4323,20 @@ func (a *App) getKeeperState(ctx context.Context, name string) (*keeperAuthState func scanKeeperState(scanner interface{ Scan(dest ...any) error }) (keeperAuthState, error) { var state keeperAuthState - var email, authIndex, accountType, primaryReset, secondaryReset, lastError, latestAction, lastChecked, lastHealthy, createdAt, updatedAt sql.NullString - var priority, primaryUsed, secondaryUsed, quotaThreshold, lastStatus, primaryWindowSeconds, secondaryWindowSeconds, restorePriority sql.NullInt64 + var email, authIndex, accountType, primaryReset, secondaryReset, lastError, latestAction, lastChecked, lastHealthy, createdAt, updatedAt, resetCredits, subscriptionActiveUntil, accountID, provider, antigravityQuota, antigravityProjectDigest sql.NullString + var priority, primaryUsed, secondaryUsed, quotaThreshold, lastStatus, primaryWindowSeconds, secondaryWindowSeconds, restorePriority, resetCreditCount sql.NullInt64 err := scanner.Scan( &state.Name, &email, &authIndex, &accountType, &state.Disabled, &priority, &primaryUsed, &secondaryUsed, &primaryReset, &secondaryReset, "aThreshold, &lastStatus, &lastError, &latestAction, &lastChecked, &lastHealthy, &primaryWindowSeconds, &secondaryWindowSeconds, &restorePriority, - &createdAt, &updatedAt, + &createdAt, &updatedAt, &resetCreditCount, &resetCredits, &subscriptionActiveUntil, &accountID, &provider, &antigravityQuota, &antigravityProjectDigest, ) if err != nil { return keeperAuthState{}, err } + state.Provider = keeperProviderOrCodex(nullableString(provider)) + state.AntigravityQuota = parseStoredAntigravityQuota(antigravityQuota) + state.AntigravityIdentityDigest = nullableString(antigravityProjectDigest) state.Email = nullableString(email) state.AuthIndex = nullableString(authIndex) state.AccountType = keeperAccountTypeOrUnknown(nullableString(accountType)) @@ -2945,6 +4354,10 @@ func scanKeeperState(scanner interface{ Scan(dest ...any) error }) (keeperAuthSt state.LastCheckedAt = timePtr(lastChecked) state.LastHealthyAt = timePtr(lastHealthy) state.RestorePriority = nullableInt(restorePriority) + state.ResetCreditCount = nullableInt(resetCreditCount) + state.ResetCredits = parseStoredKeeperResetCredits(resetCredits) + state.SubscriptionActiveUntil = timePtr(subscriptionActiveUntil) + state.AccountID = nullableString(accountID) if parsed, ok := parseDBTime(createdAt.String); ok { state.CreatedAt = parsed } @@ -2966,15 +4379,24 @@ func (a *App) upsertKeeperState(ctx context.Context, result keeperAccountResult) auth_name, email, auth_index, account_type, disabled, priority, restore_priority, latest_action, last_error, last_status_code, primary_used_percent, secondary_used_percent, quota_threshold, primary_reset_at, secondary_reset_at, primary_window_seconds, secondary_window_seconds, + reset_credit_count, reset_credits, subscription_active_until, account_id, provider, antigravity_quota, antigravity_identity_digest, last_checked_at, last_healthy_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(auth_name) DO UPDATE SET + -- Track the current account identity, keeping the old value only on a + -- fetch where no account_id was observed. An antigravity inspection has no ChatGPT + -- account_id, so a provider switch to antigravity clears the stale Codex identity. + account_id = CASE + WHEN excluded.provider = 'antigravity' THEN NULL + ELSE COALESCE(excluded.account_id, codex_keeper_auth_states.account_id) + END, email = excluded.email, auth_index = excluded.auth_index, account_type = excluded.account_type, disabled = excluded.disabled, priority = excluded.priority, restore_priority = CASE + WHEN excluded.provider = 'antigravity' THEN NULL WHEN ? THEN NULL WHEN excluded.restore_priority IS NOT NULL THEN excluded.restore_priority ELSE codex_keeper_auth_states.restore_priority @@ -2989,10 +4411,100 @@ func (a *App) upsertKeeperState(ctx context.Context, result keeperAccountResult) secondary_reset_at = excluded.secondary_reset_at, primary_window_seconds = excluded.primary_window_seconds, secondary_window_seconds = excluded.secondary_window_seconds, + -- Preserve-on-failed-fetch, scoped to ACCOUNT identity (account_id) — CPA's file + -- auth_index is a hash of provider+path and stays the same when a filename is + -- swapped to a different account, so auth_index alone can't detect a swap: + -- 1. identity unconfirmed (auth_index NULL, detail read failed) → preserve. + -- 2. confirmed account SWAP (both account_ids known and DIFFERENT) → write the + -- incoming value, clearing the old account's stale credits (a failed fetch + -- writes NULL rather than inheriting the previous account's count/schedule). + -- 3. same/undeterminable account → COALESCE: write a fresh fetch, else preserve. + reset_credit_count = CASE + WHEN excluded.provider = 'antigravity' THEN NULL + WHEN excluded.auth_index IS NULL THEN codex_keeper_auth_states.reset_credit_count + WHEN codex_keeper_auth_states.account_id IS NOT NULL AND excluded.account_id IS NOT NULL + AND codex_keeper_auth_states.account_id <> excluded.account_id + THEN excluded.reset_credit_count + ELSE COALESCE(excluded.reset_credit_count, codex_keeper_auth_states.reset_credit_count) + END, + reset_credits = CASE + WHEN excluded.provider = 'antigravity' THEN NULL + WHEN excluded.auth_index IS NULL THEN codex_keeper_auth_states.reset_credits + WHEN codex_keeper_auth_states.account_id IS NOT NULL AND excluded.account_id IS NOT NULL + AND codex_keeper_auth_states.account_id <> excluded.account_id + THEN excluded.reset_credits + ELSE COALESCE(excluded.reset_credits, codex_keeper_auth_states.reset_credits) + END, + -- Tri-state, scoped to ACCOUNT identity (account_id), because CPA's file + -- auth_index is a hash of provider+path and stays the same when a filename is + -- swapped to a different OpenAI account: + -- 1. known claim + confirmed identity (auth_index present) → write authoritatively. + -- 2. identity unconfirmed this inspection (auth_index NULL, detail read failed) + -- → preserve the previous snapshot (transient failure, not a change). + -- 3. confirmed account SWAP (both account_ids known and DIFFERENT) → write the + -- incoming value so the new account never inherits the old renewal date. + -- 4. otherwise (same/undeterminable account) → preserve on an unknown claim. + subscription_active_until = CASE + WHEN excluded.provider = 'antigravity' THEN NULL + WHEN ? AND excluded.auth_index IS NOT NULL THEN excluded.subscription_active_until + WHEN excluded.auth_index IS NULL THEN codex_keeper_auth_states.subscription_active_until + WHEN codex_keeper_auth_states.account_id IS NOT NULL AND excluded.account_id IS NOT NULL + AND codex_keeper_auth_states.account_id <> excluded.account_id + THEN excluded.subscription_active_until + ELSE COALESCE(excluded.subscription_active_until, codex_keeper_auth_states.subscription_active_until) + END, + -- provider: keep the stored value when this inspection did not set it (a Codex path + -- leaves it nil); antigravity_quota: preserve on a failed/skipped fetch (nil) like + -- reset_credits, otherwise write the fresh snapshot. + provider = COALESCE(excluded.provider, codex_keeper_auth_states.provider), + -- The antigravity identity digest is the resource identity. A codex inspection clears it + -- (not a codex column). An antigravity inspection with a RESOLVED identity writes the + -- fresh digest; when the identity is UNKNOWN this inspection (excluded digest NULL, e.g. + -- the detail read failed) it must PRESERVE the stored digest — wiping it would erase the + -- swap-detection anchor and let the next inspection's COALESCE keep a different account's + -- quota. So COALESCE(fresh, stored) rather than force-writing the (possibly NULL) fresh. + antigravity_identity_digest = CASE + WHEN excluded.provider = 'codex' THEN NULL + ELSE COALESCE(excluded.antigravity_identity_digest, codex_keeper_auth_states.antigravity_identity_digest) + END, + antigravity_quota = CASE + WHEN excluded.provider = 'codex' THEN NULL + -- Incoming identity is KNOWN and the stored quota is NOT proven to belong to it — + -- either the stored digest is NULL (legacy/unbound snapshot: no identity binding, so + -- it cannot be shown to be this account's) OR the stored digest DIFFERS (confirmed + -- swap). Write the incoming value, which is NULL on a failed fetch, so an unprovable + -- or stale quota is CLEARED rather than inherited by the current identity. + WHEN excluded.antigravity_identity_digest IS NOT NULL + AND (codex_keeper_auth_states.antigravity_identity_digest IS NULL + OR codex_keeper_auth_states.antigravity_identity_digest <> excluded.antigravity_identity_digest) + THEN excluded.antigravity_quota + -- Same proven identity, or incoming identity UNKNOWN (excluded digest NULL): preserve + -- the stored quota across a transient fetch failure (COALESCE keeps stored when the + -- fresh value is NULL). Preserve-on-unknown pairs with the digest COALESCE above. + ELSE COALESCE(excluded.antigravity_quota, codex_keeper_auth_states.antigravity_quota) + END, last_checked_at = excluded.last_checked_at, last_healthy_at = COALESCE(excluded.last_healthy_at, codex_keeper_auth_states.last_healthy_at), updated_at = excluded.updated_at - `, result.Name, result.Email, result.AuthIndex, result.AccountType, boolValue(result.Disabled), result.Priority, result.RestorePriority, result.LatestAction, result.LastError, result.LastStatusCode, result.PrimaryUsedPercent, result.SecondaryUsedPercent, result.QuotaThreshold, dbTimePtr(result.PrimaryResetAt), dbTimePtr(result.SecondaryResetAt), result.PrimaryWindowSeconds, result.SecondaryWindowSeconds, checkedAt, lastHealthy, now, now, result.ClearRestorePriority) + `, result.Name, result.Email, result.AuthIndex, result.AccountType, boolValue(result.Disabled), result.Priority, result.RestorePriority, result.LatestAction, result.LastError, result.LastStatusCode, result.PrimaryUsedPercent, result.SecondaryUsedPercent, result.QuotaThreshold, dbTimePtr(result.PrimaryResetAt), dbTimePtr(result.SecondaryResetAt), result.PrimaryWindowSeconds, result.SecondaryWindowSeconds, result.ResetCreditCount, result.ResetCredits, dbTimePtr(result.SubscriptionActiveUntil), result.AccountID, result.Provider, result.AntigravityQuota, result.AntigravityIdentityDigest, checkedAt, lastHealthy, now, now, result.ClearRestorePriority, boolValue(&result.SubscriptionKnown)) + return err +} + +// markKeeperIdentityError records an identity-conflict inspection outcome WITHOUT touching +// any business column. A list/detail identity conflict means the merged detail mixes two +// accounts, so the incoming email/auth_index/account_id/account_type/disabled/priority/usage/ +// quota/reset-credit/subscription values are untrustworthy; the prior snapshot is authoritative +// and must survive intact (a cleared auth_index would also block a later reset). This only +// stamps last_error/latest_action and the check time. It deliberately does NOT set +// last_healthy_at (an identity conflict is never a healthy refresh) and does NOT INSERT: if no +// row exists yet it touches zero rows rather than persisting a partial/ambiguous identity. +func (a *App) markKeeperIdentityError(ctx context.Context, authName string, message *string, checkedAt time.Time) error { + now := dbTime(time.Now()) + _, err := a.db.ExecContext(ctx, ` + UPDATE codex_keeper_auth_states + SET last_error = ?, latest_action = ?, last_checked_at = ?, updated_at = ? + WHERE auth_name = ? + `, message, message, dbTime(checkedAt), now, authName) return err } @@ -3001,6 +4513,18 @@ func (a *App) setKeeperAccountDisabled(ctx context.Context, authName string, dis if err != nil { return err } + // Share the per-auth fence: an enable/disable must not change the remote credential + // or DB identity while a reset holds the lock mid-consume (it would drift the row the + // consume/cooldown is bound to). This is handler-only; processKeeperAuth uses the + // lock-free setKeeperRemoteDisabled helper, so there is no self-conflict. + if a.keeper == nil { + return validationError("Keeper 未初始化,无法安全操作") + } + if !a.keeper.tryLockAuthName("toggle", authName) { + return conflictError("账号正在巡检或重置中,请稍后重试") + } + defer a.keeper.unlockAuthName(authName) + state, err := a.getKeeperState(ctx, authName) if err != nil { return err @@ -3025,7 +4549,15 @@ func (a *App) setKeeperAccountDisabled(ctx context.Context, authName string, dis last_checked_at = ?, last_healthy_at = COALESCE(?, last_healthy_at), updated_at = ? WHERE auth_name = ? `, disabled, disabled, disabled, disabled, disabled, disabled, checkedAt, lastHealthy, now, state.Name) - return err + if err != nil { + return err + } + op := "enable" + if disabled { + op = "disable" + } + a.auditKeeperOp(op, authName, "result", "ok") + return nil } func (a *App) deleteKeeperAccount(ctx context.Context, authName string) error { @@ -3033,6 +4565,19 @@ func (a *App) deleteKeeperAccount(ctx context.Context, authName string) error { if err != nil { return err } + // Delete shares the per-auth fence with reset/inspection so it never removes the state + // row while a reset holds the lock mid-consume. It does NOT touch the redeem ledger: + // that ledger is keyed by the stable account_id, so deleting the file cannot drop the + // account's in-flight key (a re-import under any filename still replays it). Fail closed + // if the runner is missing. + if a.keeper == nil { + return validationError("Keeper 未初始化,无法安全删除") + } + if !a.keeper.tryLockAuthName("delete", authName) { + return conflictError("账号正在巡检或重置中,请稍后重试") + } + defer a.keeper.unlockAuthName(authName) + state, err := a.getKeeperState(ctx, authName) if err != nil { return err @@ -3043,8 +4588,11 @@ func (a *App) deleteKeeperAccount(ctx context.Context, authName string) error { if err := a.deleteKeeperRemoteAuthFile(ctx, cfg, authName); err != nil { return err } - _, err = a.db.ExecContext(ctx, `DELETE FROM codex_keeper_auth_states WHERE auth_name = ?`, authName) - return err + if _, err := a.deleteKeeperStateRow(ctx, authName); err != nil { + return err + } + a.auditKeeperOp("delete", authName, "result", "ok") + return nil } func (a *App) bulkDeleteKeeperAccounts(w http.ResponseWriter, r *http.Request) error { @@ -3060,6 +4608,7 @@ func (a *App) bulkDeleteKeeperAccounts(w http.ResponseWriter, r *http.Request) e failures := []map[string]string{} for _, name := range names { if err := a.deleteKeeperAccount(r.Context(), name); err != nil { + a.auditKeeperOp("delete", name, "result", "error", "reason", keeperSafeReason(err)) failures = append(failures, map[string]string{"name": name, "message": err.Error()}) continue } @@ -3074,6 +4623,16 @@ func (a *App) updateKeeperAccountPriority(ctx context.Context, authName string, if err != nil { return err } + // Share the per-auth fence (handler-only; processKeeperAuth uses the lock-free + // setKeeperRemotePriority helper, so no self-conflict). + if a.keeper == nil { + return validationError("Keeper 未初始化,无法安全操作") + } + if !a.keeper.tryLockAuthName("priority", authName) { + return conflictError("账号正在巡检或重置中,请稍后重试") + } + defer a.keeper.unlockAuthName(authName) + state, err := a.getKeeperState(ctx, authName) if err != nil { return err @@ -3084,12 +4643,15 @@ func (a *App) updateKeeperAccountPriority(ctx context.Context, authName string, if err := a.setKeeperRemotePriority(ctx, cfg, authName, &priority); err != nil { return err } - _, err = a.db.ExecContext(ctx, ` + if _, err = a.db.ExecContext(ctx, ` UPDATE codex_keeper_auth_states SET priority = ?, restore_priority = NULL, latest_action = NULL, last_error = NULL, updated_at = ? WHERE auth_name = ? - `, priority, dbTime(time.Now()), authName) - return err + `, priority, dbTime(time.Now()), authName); err != nil { + return err + } + a.auditKeeperOp("priority", authName, "result", "ok", "priority", priority) + return nil } func (a *App) createKeeperRun(ctx context.Context, mode string) (int, error) { @@ -3361,6 +4923,32 @@ func keeperIDTokenPlanValues(value any) []string { return values } +// keeperClaimsAccountIDs pulls chatgpt_account_id from id_token claims, from BOTH the top +// level and the OpenAI auth namespace claim (https://api.openai.com/auth) where OpenAI's real +// id_token nests it. Both are returned so keeperConsistentValue validates they agree with each +// other and with the object's top-level account_id — any disagreement is an identity conflict. +// A present-but-wrong-type claim (a non-string account id, or a namespace that is present but +// not an object) is illegal and fails closed rather than being silently ignored. +func keeperClaimsAccountIDs(claims map[string]any) ([]string, error) { + top, err := keeperExplicitStringField(claims, "chatgpt_account_id") + if err != nil { + return nil, err + } + ids := []string{top} + if ns, present := claims["https://api.openai.com/auth"]; present && ns != nil { + auth, ok := ns.(map[string]any) + if !ok { + return nil, errKeeperIdentityConflict + } + nested, err := keeperExplicitStringField(auth, "chatgpt_account_id") + if err != nil { + return nil, err + } + ids = append(ids, nested) + } + return ids, nil +} + func keeperIDTokenClaims(value any) map[string]any { switch typed := value.(type) { case nil: diff --git a/backend/internal/app/codex_keeper_antigravity.go b/backend/internal/app/codex_keeper_antigravity.go new file mode 100644 index 00000000..733277e0 --- /dev/null +++ b/backend/internal/app/codex_keeper_antigravity.go @@ -0,0 +1,529 @@ +package app + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "math" + "net/http" + "strings" + "time" +) + +// Provider identifiers for a keeper account. The keeper started as a Codex-only feature; it now +// dispatches per provider so non-Codex accounts (Antigravity) are inspected with their own quota +// source instead of being silently skipped. +const ( + keeperProviderCodex = "codex" + keeperProviderAntigravity = "antigravity" +) + +// Antigravity (Google Cloud Code) quota summary is fetched through the SAME per-auth +// /v0/management/api-call egress the Codex checks use — CPA injects the account's $TOKEN$ and +// auto-routes via the credential's proxy_url (WARP), so no explicit proxy is needed. The +// endpoint moves between daily/sandbox/stable hosts, so the candidates are tried in order and +// the first successful, parseable response wins. UA mirrors the Antigravity CLI. +const antigravityQuotaUserAgent = "antigravity/cli/1.0.13 (aidev_client; os_type=darwin; arch=arm64)" + +var antigravityQuotaURLs = []string{ + "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary", + "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:retrieveUserQuotaSummary", + "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary", +} + +// keeperAntigravityBucket is one quota window inside a group (e.g. a "weekly" or "5h" window): +// the fraction of the limit still remaining (0..1) and when it fully refreshes. +type keeperAntigravityBucket 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"` +} + +// keeperAntigravityGroup is a set of models that share quota windows (e.g. "Gemini Models", +// "Claude and GPT models"), each carrying its own buckets. +type keeperAntigravityGroup struct { + DisplayName string `json:"display_name"` + Description string `json:"description,omitempty"` + Buckets []keeperAntigravityBucket `json:"buckets"` +} + +// keeperIsInspectableProvider reports whether an auth-file `type` is one the keeper inspects. +// Codex has always been inspected; Antigravity is now included so its accounts appear and get +// their quota refreshed. Other providers are still skipped. +func keeperIsInspectableProvider(authType string) bool { + switch authType { + case keeperProviderCodex, keeperProviderAntigravity: + return true + default: + return false + } +} + +// keeperAntigravityBucketResponse / keeperAntigravityGroupResponse are the API projection of the +// stored quota, with reset times formatted the same way as every other keeper API timestamp. +type keeperAntigravityBucketResponse struct { + BucketID string `json:"bucket_id"` + DisplayName string `json:"display_name"` + Window string `json:"window"` + RemainingFraction float64 `json:"remaining_fraction"` + ResetAt *string `json:"reset_at"` + Description string `json:"description,omitempty"` +} + +type keeperAntigravityGroupResponse struct { + DisplayName string `json:"display_name"` + Description string `json:"description,omitempty"` + Buckets []keeperAntigravityBucketResponse `json:"buckets"` +} + +// keeperAntigravityQuotaResponses projects stored quota groups for the /accounts API response. +func keeperAntigravityQuotaResponses(groups []keeperAntigravityGroup) []keeperAntigravityGroupResponse { + if len(groups) == 0 { + return nil + } + out := make([]keeperAntigravityGroupResponse, 0, len(groups)) + for _, g := range groups { + buckets := make([]keeperAntigravityBucketResponse, 0, len(g.Buckets)) + for _, b := range g.Buckets { + buckets = append(buckets, keeperAntigravityBucketResponse{ + BucketID: b.BucketID, + DisplayName: b.DisplayName, + Window: b.Window, + RemainingFraction: b.RemainingFraction, + ResetAt: apiDateTimePtr(b.ResetAt), + Description: b.Description, + }) + } + out = append(out, keeperAntigravityGroupResponse{DisplayName: g.DisplayName, Description: g.Description, Buckets: buckets}) + } + return out +} + +// keeperProviderOrCodex normalizes a stored provider value to a non-nil pointer, defaulting a +// NULL/empty value to "codex" (rows written before multi-provider support). +func keeperProviderOrCodex(value *string) *string { + if value == nil || strings.TrimSpace(*value) == "" { + codex := keeperProviderCodex + return &codex + } + normalized := strings.ToLower(strings.TrimSpace(*value)) + return &normalized +} + +// parseStoredAntigravityQuota decodes the JSON antigravity_quota blob back into groups; a NULL, +// empty, or malformed blob yields nil (no quota) rather than an error. +func parseStoredAntigravityQuota(value sql.NullString) []keeperAntigravityGroup { + if !value.Valid || strings.TrimSpace(value.String) == "" { + return nil + } + var groups []keeperAntigravityGroup + if err := json.Unmarshal([]byte(value.String), &groups); err != nil { + return nil + } + return groups +} + +// keeperStringFirst returns the first non-empty string among the candidates (used to accept +// both snake_case and camelCase aliases from the remote JSON). +func keeperStringFirst(values ...any) string { + for _, v := range values { + if s := keeperString(v); s != "" { + return s + } + } + return "" +} + +// keeperParseFraction returns the first candidate that is a finite JSON number in [0,1]. A +// present-but-out-of-range/non-finite value fails (false) so a garbage fraction never renders as +// a real remaining amount. +func keeperParseFraction(values ...any) (float64, bool) { + for _, v := range values { + if v == nil { + continue + } + f, ok := v.(float64) + if !ok { + return 0, false + } + if math.IsNaN(f) || math.IsInf(f, 0) || f < 0 || f > 1 { + return 0, false + } + return f, true + } + return 0, false +} + +// parseAntigravityQuotaGroups projects a retrieveUserQuotaSummary body into groups → buckets. +// A group is kept only when it has at least one valid bucket (a valid remaining fraction; a +// present-but-unparseable reset time drops just that bucket). ok=false means the body had no +// usable groups, so the caller preserves the previous snapshot rather than trusting an empty/ +// malformed response. +func parseAntigravityQuotaGroups(body map[string]any) ([]keeperAntigravityGroup, bool) { + if body == nil { + return nil, false + } + rawGroups, ok := body["groups"].([]any) + if !ok { + return nil, false + } + groups := make([]keeperAntigravityGroup, 0, len(rawGroups)) + for _, g := range rawGroups { + gm, ok := g.(map[string]any) + if !ok { + continue + } + rawBuckets, ok := gm["buckets"].([]any) + if !ok { + continue + } + buckets := make([]keeperAntigravityBucket, 0, len(rawBuckets)) + for _, b := range rawBuckets { + bm, ok := b.(map[string]any) + if !ok { + continue + } + fraction, ok := keeperParseFraction(bm["remainingFraction"], bm["remaining_fraction"]) + if !ok { + continue + } + var resetRaw any = bm["resetTime"] + if resetRaw == nil { + resetRaw = bm["reset_time"] + } + resetAt, ok := keeperParseOptionalTime(resetRaw) + if !ok { + continue + } + buckets = append(buckets, keeperAntigravityBucket{ + BucketID: keeperStringFirst(bm["bucketId"], bm["bucket_id"]), + DisplayName: keeperStringFirst(bm["displayName"], bm["display_name"]), + Window: keeperString(bm["window"]), + RemainingFraction: fraction, + ResetAt: resetAt, + Description: keeperString(bm["description"]), + }) + } + if len(buckets) == 0 { + continue + } + groups = append(groups, keeperAntigravityGroup{ + DisplayName: keeperStringFirst(gm["displayName"], gm["display_name"]), + Description: keeperString(gm["description"]), + Buckets: buckets, + }) + } + if len(groups) == 0 { + return nil, false + } + return groups, true +} + +// antigravityIdentity is the reconciled routing identity for an Antigravity inspection. +type antigravityIdentity struct { + authIndex string + projectID string + email string +} + +// keeperReconcileAntigravityIdentity validates that the list entry and download detail describe +// the SAME Antigravity account before any quota call: the list type is antigravity, the detail +// (when it states them) agrees on type and name, the auth_index is explicit on each source (never +// the auth NAME), reconciles across them, and is non-empty, and a project_id is resolvable from +// the detail. ok=false on any conflict/missing field so the caller fails closed. +func keeperReconcileAntigravityIdentity(authInfo, detail map[string]any, name string) (antigravityIdentity, bool) { + // Every identity field distinguishes absent/null from present-but-wrong-type: a present field + // that is not the expected type is illegal (a malformed/deceptive detail) and fails closed, + // rather than being treated as "missing" and silently backfilled from the list. + listType, lterr := keeperExplicitStringField(authInfo, "type") + if lterr != nil || listType != keeperProviderAntigravity { + return antigravityIdentity{}, false + } + detailType, dterr := keeperExplicitStringField(detail, "type") + if dterr != nil { + return antigravityIdentity{}, false + } + if detailType != "" && detailType != keeperProviderAntigravity { + return antigravityIdentity{}, false + } + // CPA emits `provider` as an authoritative alias of `type` (buildAuthFileEntry sets both from + // the same auth.Provider), so a present provider MUST agree with the antigravity type on each + // source. A present-but-conflicting provider (e.g. provider=codex on a type=antigravity entry) + // is a corrupt/deceptive entry — fail closed rather than dispatch a quota call on `type` alone. + listProvider, lpverr := keeperExplicitStringField(authInfo, "provider") + if lpverr != nil || (listProvider != "" && listProvider != keeperProviderAntigravity) { + return antigravityIdentity{}, false + } + detailProvider, dpverr := keeperExplicitStringField(detail, "provider") + if dpverr != nil || (detailProvider != "" && detailProvider != keeperProviderAntigravity) { + return antigravityIdentity{}, false + } + detailName, dnerr := keeperExplicitStringField(detail, "name") + if dnerr != nil { + return antigravityIdentity{}, false + } + if detailName != "" && detailName != name { + return antigravityIdentity{}, false + } + listIdx, lierr := keeperExplicitAuthIndex(authInfo) + detailIdx, dierr := keeperExplicitAuthIndex(detail) + if lierr != nil || dierr != nil { + return antigravityIdentity{}, false + } + idx, ierr := keeperReconcileIdentityField(listIdx, detailIdx) + if ierr != nil || strings.TrimSpace(idx) == "" { + return antigravityIdentity{}, false + } + // The CPA list entry also exposes project_id; when BOTH sources carry one they must agree + // (a same-name file swap / memory-vs-disk drift would otherwise let the detail's project run a + // quota call for a different account). A non-empty project must be resolvable. + listProject, lperr := keeperExplicitAntigravityProjectID(authInfo) + detailProject, dperr := keeperExplicitAntigravityProjectID(detail) + if lperr != nil || dperr != nil { + return antigravityIdentity{}, false + } + project, perr := keeperReconcileIdentityField(listProject, detailProject) + if perr != nil || strings.TrimSpace(project) == "" { + return antigravityIdentity{}, false + } + // A Google project can be shared across accounts, so the project alone does not identify the + // account. Reconcile the account email across list+detail (present must be a string; both + // present must agree) and fold it into the stored identity digest, so a credential swap to a + // different email under the same project is detected as an identity change. + listEmail, leerr := keeperExplicitAntigravityEmail(authInfo) + detailEmail, deerr := keeperExplicitAntigravityEmail(detail) + if leerr != nil || deerr != nil { + return antigravityIdentity{}, false + } + email, eerr := keeperReconcileIdentityField(listEmail, detailEmail) + if eerr != nil { + return antigravityIdentity{}, false + } + // The resource key is provider+project+email, and a Google project can be shared across + // accounts, so an empty email would make two distinct credentials under the same project + // indistinguishable (one could inherit the other's quota). CPA's Antigravity login requires a + // non-empty userinfo email, so a missing email means the identity is unproven: fail closed + // (identity_error / no quota call) rather than degrade to a project-only digest. + if strings.TrimSpace(email) == "" { + return antigravityIdentity{}, false + } + return antigravityIdentity{authIndex: idx, projectID: project, email: email}, true +} + +// keeperAntigravityIdentityDigest returns a stable, versioned one-way digest of the Antigravity +// account identity (provider + project id + normalized email). Only this digest is persisted — +// never the raw project id or email — so the DB can detect an identity swap (project OR email +// changed) and clear the stale quota without storing or exposing the identifiers. A change to any +// confirmed component yields a different digest. +func keeperAntigravityIdentityDigest(projectID, email string) string { + normEmail := strings.ToLower(strings.TrimSpace(email)) + sum := sha256.Sum256([]byte(keeperProviderAntigravity + "\x00" + projectID + "\x00" + normEmail)) + return "v1:" + hex.EncodeToString(sum[:]) +} + +// keeperExplicitAntigravityEmail resolves the account email across the same aliases the result +// layer reads (email / account_email / user_email), normalized to lowercase, with present-invalid +// detection: each present alias must be a string and they must all agree (case-insensitively). A +// present-but-wrong-type value or two disagreeing aliases fail closed, so the identity digest can't +// be bound to an arbitrary alias while a conflicting one is ignored. +func keeperExplicitAntigravityEmail(o map[string]any) (string, error) { + candidates := []string{} + for _, k := range []string{"email", "account_email", "user_email"} { + v, err := keeperExplicitStringField(o, k) + if err != nil { + return "", err + } + if v != "" { + candidates = append(candidates, strings.ToLower(v)) + } + } + return keeperConsistentValue(candidates...) +} + +// keeperExplicitAntigravityProjectID resolves the project id across every alias location (top-level +// project_id/projectId and the nested metadata/attributes/installed/web blocks, plus attributes' +// gemini_virtual_project) with present-invalid detection: each present alias must be a string and +// they must all agree. A present-but-wrong-type value or two disagreeing aliases fail closed. +func keeperExplicitAntigravityProjectID(o map[string]any) (string, error) { + candidates := []string{} + collect := func(m map[string]any, keys ...string) error { + for _, k := range keys { + v, err := keeperExplicitStringField(m, k) + if err != nil { + return err + } + if v != "" { + candidates = append(candidates, v) + } + } + return nil + } + if err := collect(o, "project_id", "projectId"); err != nil { + return "", err + } + for _, nestedKey := range []string{"metadata", "attributes", "installed", "web"} { + raw, present := o[nestedKey] + if !present || raw == nil { + continue + } + // A present container of the wrong type (e.g. a JSON number) is NOT absent — treating it as + // absent would let a malformed detail slip past the present-invalid contract. Fail closed. + nested, ok := raw.(map[string]any) + if !ok { + return "", errKeeperIdentityConflict + } + keys := []string{"project_id", "projectId"} + if nestedKey == "attributes" { + keys = append(keys, "gemini_virtual_project") + } + if err := collect(nested, keys...); err != nil { + return "", err + } + } + // Every present alias (top-level + nested) must be a string and they must all agree; a + // present-but-wrong-type value or two disagreeing aliases fail closed rather than silently + // binding the identity digest to an arbitrary first value. + return keeperConsistentValue(candidates...) +} + +// processKeeperAntigravityAuth inspects one Antigravity account: it reads the download detail, +// records the identity/health fields, and (unless disabled) fetches the quota summary. It is a +// SEPARATE path from the Codex inspection — no ChatGPT usage/reset-credit/subscription/priority +// logic applies. A failed quota fetch preserves the previous snapshot (AntigravityQuota nil → +// COALESCE) and records a network error rather than wiping the account. +func (a *App) processKeeperAntigravityAuth(ctx context.Context, cfg AppConfig, authInfo map[string]any, logFn func(string), manualRefresh bool) keeperAccountResult { + now := time.Now().In(appTimeLocation) + name := keeperString(authInfo["name"]) + if name == "" { + name = "unknown" + } + provider := keeperProviderAntigravity + result := keeperAccountResult{Name: name, Result: "skipped", CheckedAt: now, Provider: &provider} + persist := func(r keeperAccountResult) keeperAccountResult { + if err := a.upsertKeeperState(ctx, r); err != nil { + logFn(r.Name + ":状态写回失败(state_write_error)") + log.Printf("codex keeper antigravity state write-back failed for %s: %v", r.Name, err) + r.StateWriteFailed = true + } + return r + } + detail, err := a.getKeeperRemoteAuthFile(ctx, cfg, name) + if err != nil || detail == nil { + message := "读取 auth file 详情失败" + if err != nil { + message += ":" + err.Error() + } + result.Result = "network_error" + result.LastError = &message + result.LatestAction = &message + result = persist(result) + logFn(name + ": " + message) + return result + } + // Bind identity FIRST (mirror the Codex rigor): the download must be for THIS account name, + // still be an Antigravity account, and carry an explicit auth_index consistent with the list + // entry (no filename fallback). Any mismatch mixes accounts, so fail closed as identity_error, + // preserve the prior snapshot, and make NO quota call. + identity, ok := keeperReconcileAntigravityIdentity(authInfo, detail, name) + if !ok { + message := "账号身份冲突:Antigravity 列表与详情的 name/type/auth_index/project_id/email 不一致,已保留原快照" + result.Result = "identity_error" + result.LastError = &message + result.LatestAction = &message + if err := a.markKeeperIdentityError(ctx, name, &message, result.CheckedAt); err != nil { + logFn(name + ":状态写回失败(state_write_error)") + log.Printf("codex keeper antigravity identity-error write-back failed for %s: %v", name, err) + result.StateWriteFailed = true + } + logFn(name + ":" + message) + return result + } + merged := mergeKeeperObjects(authInfo, detail) + result.Email = keeperStringPtr(merged["email"], merged["account_email"], merged["user_email"]) + idx := identity.authIndex + result.AuthIndex = &idx + // Bind a one-way DIGEST of the resolved account identity (provider + project + email; never the + // raw values) regardless of the fetch outcome, so a later inspection can detect an identity swap + // (project OR email changed) and clear the stale quota even when the fresh quota fetch fails — + // without persisting the project or email. + digest := keeperAntigravityIdentityDigest(identity.projectID, identity.email) + result.AntigravityIdentityDigest = &digest + result.Priority = keeperIntPtr(merged["priority"]) + disabled := keeperBool(merged["disabled"]) + result.Disabled = &disabled + if disabled && !manualRefresh { + result.Result = "disabled" + result = persist(result) + return result + } + if groups, ok := a.fetchAntigravityQuota(ctx, cfg, identity.authIndex, identity.projectID); ok { + if encoded, err := json.Marshal(groups); err == nil { + payload := string(encoded) + result.AntigravityQuota = &payload + } + result.Result = "healthy" + logFn(fmt.Sprintf("%s:Antigravity 配额刷新成功(%d 组)", name, len(groups))) + } else { + message := "Antigravity 配额读取失败" + result.Result = "network_error" + result.LastError = &message + result.LatestAction = &message + logFn(name + ":" + message) + } + result = persist(result) + return result +} + +// fetchAntigravityQuota pulls the account's Antigravity quota summary through the per-auth +// api-call egress (same $TOKEN$/proxy plumbing as the Codex checks). It requires a resolvable +// project id; a transport error, non-2xx outer/inner status, or unparseable body on every +// candidate host leaves both return values empty (ok=false) so the caller preserves the prior +// snapshot instead of wiping it. +func (a *App) fetchAntigravityQuota(ctx context.Context, cfg AppConfig, authIndex, projectID string) ([]keeperAntigravityGroup, bool) { + if strings.TrimSpace(authIndex) == "" || strings.TrimSpace(projectID) == "" { + return nil, false + } + dataBytes, err := json.Marshal(map[string]string{"project": projectID}) + if err != nil { + return nil, false + } + header := map[string]string{ + "Authorization": "Bearer $TOKEN$", + "Content-Type": "application/json", + "User-Agent": antigravityQuotaUserAgent, + } + for _, quotaURL := range antigravityQuotaURLs { + body := map[string]any{ + "auth_index": authIndex, + "method": "POST", + "url": quotaURL, + "header": header, + "data": string(dataBytes), + } + response, payload, err := a.keeperRequest(ctx, cfg, http.MethodPost, "/v0/management/api-call", nil, body, time.Duration(cfg.CodexKeeper.UsageTimeoutSeconds)*time.Second) + if err != nil { + continue + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + continue + } + var raw map[string]any + if err := json.Unmarshal(payload, &raw); err != nil { + continue + } + if !keeperInnerStatusOK(raw) { + continue + } + if groups, ok := parseAntigravityQuotaGroups(keeperBodyJSON(raw["body"])); ok { + return groups, true + } + } + return nil, false +} diff --git a/backend/internal/app/codex_keeper_antigravity_internal_test.go b/backend/internal/app/codex_keeper_antigravity_internal_test.go new file mode 100644 index 00000000..06b81798 --- /dev/null +++ b/backend/internal/app/codex_keeper_antigravity_internal_test.go @@ -0,0 +1,997 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +// antigravityGoldenBody is the real retrieveUserQuotaSummary response captured from a production +// Antigravity account (values from @Friday's read-back), used as the golden parse fixture. +const antigravityGoldenBody = `{ + "groups": [ + { + "buckets": [ + {"bucketId":"gemini-weekly","displayName":"Weekly Limit Remaining","window":"weekly","resetTime":"2026-09-13T14:43:19Z","description":"You have used some of your weekly limit, it will fully refresh in 4 days, 6 hours.","remainingFraction":0.8308332}, + {"bucketId":"gemini-5h","displayName":"Five Hour Limit Remaining","window":"5h","resetTime":"2026-09-09T13:34:49Z","description":"You have used some of your 5-hour limit, it will fully refresh in 4 hours, 56 minutes.","remainingFraction":0.9927133} + ], + "displayName":"Gemini Models", + "description":"Models within this group: Gemini Flash, Gemini Pro" + }, + { + "buckets": [ + {"bucketId":"3p-weekly","displayName":"Weekly Limit Remaining","window":"weekly","resetTime":"2026-09-14T06:16:39Z","remainingFraction":1.0}, + {"bucketId":"3p-5h","displayName":"Five Hour Limit Remaining","window":"5h","resetTime":"2026-09-09T13:34:56Z","remainingFraction":1.0} + ], + "displayName":"Claude and GPT models", + "description":"Models within this group: Claude Opus, Claude Sonnet, GPT-OSS" + } + ], + "description":"Within each group, models share a weekly limit and a 5-hour limit." +}` + +func antigravityGoldenMap(t *testing.T) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal([]byte(antigravityGoldenBody), &m); err != nil { + t.Fatalf("unmarshal golden body: %v", err) + } + return m +} + +func TestParseAntigravityQuotaGroupsGolden(t *testing.T) { + groups, ok := parseAntigravityQuotaGroups(antigravityGoldenMap(t)) + if !ok { + t.Fatal("golden body must parse") + } + if len(groups) != 2 { + t.Fatalf("groups = %d, want 2", len(groups)) + } + g0 := groups[0] + if g0.DisplayName != "Gemini Models" || len(g0.Buckets) != 2 { + t.Fatalf("group0 = %+v, want Gemini Models with 2 buckets", g0) + } + weekly := g0.Buckets[0] + if weekly.BucketID != "gemini-weekly" || weekly.Window != "weekly" || weekly.RemainingFraction != 0.8308332 { + t.Fatalf("gemini weekly bucket = %+v", weekly) + } + if weekly.ResetAt == nil || weekly.ResetAt.UTC().Format("2006-01-02T15:04:05Z") != "2026-09-13T14:43:19Z" { + t.Fatalf("gemini weekly resetAt = %v, want 2026-09-13T14:43:19Z", weekly.ResetAt) + } + if g0.Buckets[1].Window != "5h" || g0.Buckets[1].RemainingFraction != 0.9927133 { + t.Fatalf("gemini 5h bucket = %+v", g0.Buckets[1]) + } + // Second group: buckets without a description still parse; fraction 1.0 kept. + if groups[1].DisplayName != "Claude and GPT models" || len(groups[1].Buckets) != 2 || groups[1].Buckets[0].RemainingFraction != 1.0 { + t.Fatalf("group1 = %+v", groups[1]) + } +} + +func TestParseAntigravityQuotaGroupsEdgeCases(t *testing.T) { + // No groups → not ok. + if _, ok := parseAntigravityQuotaGroups(map[string]any{}); ok { + t.Fatal("missing groups must be ok=false") + } + if _, ok := parseAntigravityQuotaGroups(map[string]any{"groups": []any{}}); ok { + t.Fatal("empty groups must be ok=false") + } + // A group whose only bucket has an out-of-range fraction is dropped → no usable groups. + bad := map[string]any{"groups": []any{map[string]any{ + "displayName": "G", "buckets": []any{map[string]any{"window": "5h", "remainingFraction": 1.5}}, + }}} + if _, ok := parseAntigravityQuotaGroups(bad); ok { + t.Fatal("out-of-range fraction must drop the bucket and the empty group") + } + // A present-but-unparseable resetTime drops just that bucket. + badReset := map[string]any{"groups": []any{map[string]any{ + "displayName": "G", "buckets": []any{ + map[string]any{"window": "5h", "remainingFraction": 0.5, "resetTime": "not-a-time"}, + map[string]any{"window": "weekly", "remainingFraction": 0.9}, + }, + }}} + groups, ok := parseAntigravityQuotaGroups(badReset) + if !ok || len(groups) != 1 || len(groups[0].Buckets) != 1 || groups[0].Buckets[0].Window != "weekly" { + t.Fatalf("bad-reset case = (%v, %+v), want the weekly bucket only", ok, groups) + } + // snake_case aliases are accepted. + snake := map[string]any{"groups": []any{map[string]any{ + "display_name": "G", "buckets": []any{map[string]any{"bucket_id": "b", "display_name": "B", "window": "5h", "remaining_fraction": 0.25, "reset_time": "2026-01-01T00:00:00Z"}}, + }}} + sg, ok := parseAntigravityQuotaGroups(snake) + if !ok || sg[0].Buckets[0].BucketID != "b" || sg[0].Buckets[0].RemainingFraction != 0.25 { + t.Fatalf("snake_case aliases not accepted: %v %+v", ok, sg) + } +} + +func TestKeeperExplicitAntigravityProjectID(t *testing.T) { + cases := []struct { + name string + detail map[string]any + want string + wantErr bool + }{ + {"top-level", map[string]any{"project_id": "aicode-consumers"}, "aicode-consumers", false}, + {"camel", map[string]any{"projectId": "p2"}, "p2", false}, + {"metadata", map[string]any{"metadata": map[string]any{"project_id": "p3"}}, "p3", false}, + {"attributes-virtual", map[string]any{"attributes": map[string]any{"gemini_virtual_project": "p4"}}, "p4", false}, + {"installed", map[string]any{"installed": map[string]any{"project_id": "p5"}}, "p5", false}, + {"web", map[string]any{"web": map[string]any{"project_id": "p6"}}, "p6", false}, + {"none", map[string]any{"email": "x@y.com"}, "", false}, + // All present aliases must agree and be strings. + {"top-level-agree", map[string]any{"project_id": "p", "projectId": "p"}, "p", false}, + {"top-level-conflict", map[string]any{"project_id": "p", "projectId": "other"}, "", true}, + {"top-level-wrong-type", map[string]any{"project_id": float64(1)}, "", true}, + {"nested-wrong-type", map[string]any{"metadata": map[string]any{"project_id": float64(2)}}, "", true}, + {"nested-vs-top-conflict", map[string]any{"projectId": "other", "metadata": map[string]any{"project_id": "p"}}, "", true}, + // A nested container that is PRESENT but not an object is present-invalid, not absent — + // even when the top-level project id is valid, a wrong-type container must fail closed + // (the whole detail is untrustworthy) rather than being silently skipped. + {"metadata-non-object", map[string]any{"project_id": "p", "metadata": float64(7)}, "", true}, + {"attributes-non-object", map[string]any{"project_id": "p", "attributes": float64(7)}, "", true}, + {"installed-non-object", map[string]any{"project_id": "p", "installed": float64(7)}, "", true}, + {"web-non-object", map[string]any{"project_id": "p", "web": float64(7)}, "", true}, + // A null (or absent) container is genuinely absent and is skipped, not an error. + {"nested-null-skipped", map[string]any{"project_id": "p", "metadata": nil}, "p", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := keeperExplicitAntigravityProjectID(tc.detail) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error (conflict/wrong-type), got %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("projectID = %q, want %q", got, tc.want) + } + }) + } +} + +// TestFetchAntigravityQuota exercises the api-call egress: it requires a project id, tries the +// candidate hosts in order (first non-2xx is skipped), and parses the inner body. It also +// asserts the outgoing api-call carries the antigravity UA + {"project":...} data. +func TestFetchAntigravityQuota(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + var mu sync.Mutex + var seenUA, seenData string + callCount := 0 + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPost || r.URL.Path != "/v0/management/api-call" { + http.NotFound(w, r) + return + } + var p struct { + URL string `json:"url"` + Header map[string]string `json:"header"` + Data string `json:"data"` + } + _ = json.NewDecoder(r.Body).Decode(&p) + mu.Lock() + callCount++ + n := callCount + seenUA = p.Header["User-Agent"] + seenData = p.Data + mu.Unlock() + // First candidate host fails (404) to exercise the fallback; second succeeds. + if n == 1 { + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 404, "body": map[string]any{"error": "not found"}}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": antigravityGoldenMap(t)}) + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + cfg, err := app.loadConfig(ctx) + if err != nil { + t.Fatalf("loadConfig: %v", err) + } + groups, ok := app.fetchAntigravityQuota(ctx, cfg, "idx-ag", "aicode-consumers") + if !ok || len(groups) != 2 { + t.Fatalf("fetch = (%v, %d groups), want ok with 2 groups", ok, len(groups)) + } + mu.Lock() + defer mu.Unlock() + if callCount != 2 { + t.Fatalf("call count = %d, want 2 (first host 404 → fallback)", callCount) + } + if seenUA != antigravityQuotaUserAgent { + t.Fatalf("outgoing UA = %q, want antigravity UA", seenUA) + } + if seenData != `{"project":"aicode-consumers"}` { + t.Fatalf("outgoing data = %q, want the project body", seenData) + } +} + +// TestKeeperInspectAntigravityAccount is the end-to-end path: an antigravity account is now +// INCLUDED in the inspection (not skipped), routed to the antigravity provider path, and stored +// with provider=antigravity + its quota groups — visible via listKeeperAccounts. +func TestKeeperInspectAntigravityAccount(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "antigravity-1.json" + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + // Real CPA emits both `type` and `provider` (same auth.Provider); include the matching + // provider so this happy path also proves a present-and-agreeing provider is accepted. + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "antigravity", "provider": "antigravity", "auth_index": "idx-ag"}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "antigravity", "provider": "antigravity", "auth_index": "idx-ag", + "project_id": "aicode-consumers", "email": "eyo@example.com", "disabled": false, + "priority": 1, "access_token": "tok", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": antigravityGoldenMap(t)}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + stats, err := app.keeper.InspectAccountsLocked([]string{authName}) + if err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + if stats.Healthy != 1 { + t.Fatalf("stats = %+v, want Healthy=1 (antigravity inspected, not skipped)", stats) + } + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get state: %v", err) + } + if st.Provider == nil || *st.Provider != "antigravity" { + t.Fatalf("stored provider = %v, want antigravity", st.Provider) + } + if len(st.AntigravityQuota) != 2 || st.AntigravityQuota[0].DisplayName != "Gemini Models" { + t.Fatalf("stored antigravity quota not persisted: %+v", st.AntigravityQuota) + } + // Codex-only fields stay empty for an antigravity account. + if st.PrimaryUsedPercent != nil || st.ResetCreditCount != nil || st.SubscriptionActiveUntil != nil { + t.Fatalf("codex-only fields must be nil for antigravity: %+v", st) + } + // It appears in the account list the frontend consumes. + accounts, err := app.listKeeperAccounts(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + var found *keeperAccount + for i := range accounts { + if accounts[i].Name == authName { + found = &accounts[i] + } + } + if found == nil || found.Provider == nil || *found.Provider != "antigravity" || len(found.AntigravityQuota) != 2 { + t.Fatalf("antigravity account not visible with quota in list: %+v", found) + } + // The API projection (the exact path the /accounts handler serializes) must carry both + // fields — otherwise the frontend never sees the provider or quota. + raw, err := json.Marshal(keeperAccountResponses(accounts, nil)) + if err != nil { + t.Fatalf("marshal responses: %v", err) + } + js := string(raw) + if !strings.Contains(js, `"provider":"antigravity"`) { + t.Fatalf("API response drops provider: %s", js) + } + if !strings.Contains(js, `"antigravity_quota":[`) || !strings.Contains(js, `"remaining_fraction"`) || !strings.Contains(js, `"reset_at"`) { + t.Fatalf("API response drops antigravity_quota buckets: %s", js) + } +} + +// TestKeeperInspectAntigravityIdentityConflictFailsClosed reproduces the reverse probes: when the +// download detail disagrees with the list entry on name/index, has drifted to type=codex, or when +// neither source carries an explicit auth_index, the inspection must fail closed — NO quota +// api-call, identity_error, and the prior quota snapshot preserved. +func TestKeeperInspectAntigravityIdentityConflictFailsClosed(t *testing.T) { + const authName = "antigravity-x.json" + cases := []struct { + name string + listEntry map[string]any + download map[string]any + }{ + {"name-only-mismatch", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": "other.json", "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "access_token": "t"}}, + {"index-only-mismatch", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-other", "project_id": "p", "access_token": "t"}}, + {"type-drift-to-codex", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag"}, + map[string]any{"name": authName, "type": "codex", "auth_index": "idx-ag", "project_id": "p", "access_token": "t"}}, + {"no-explicit-auth-index", + map[string]any{"name": authName, "type": "antigravity"}, + map[string]any{"name": authName, "type": "antigravity", "project_id": "p", "access_token": "t"}}, + {"project-id-conflict", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "project-A"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "project-B", "access_token": "t"}}, + // present-but-wrong-type detail fields must fail closed (not be treated as absent + backfilled). + {"detail-name-wrong-type", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": float64(123), "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "access_token": "t"}}, + {"detail-type-wrong-type", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": authName, "type": float64(1), "auth_index": "idx-ag", "project_id": "p", "access_token": "t"}}, + {"detail-project-wrong-type", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": float64(7), "access_token": "t"}}, + // project-id aliases (top-level + nested) must all agree and be strings. + {"detail-project-alias-conflict", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "projectId": "other", "access_token": "t"}}, + {"detail-nested-project-wrong-type", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "metadata": map[string]any{"project_id": float64(5)}, "access_token": "t"}}, + {"detail-nested-vs-top-conflict", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "projectId": "other", "metadata": map[string]any{"project_id": "p"}, "access_token": "t"}}, + // email aliases (email/account_email/user_email) must agree and be strings, across sources too. + {"email-alias-cross-conflict", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "email": "a@x.com"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "account_email": "b@x.com", "access_token": "t"}}, + {"detail-email-alias-self-conflict", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "email": "a@x.com", "account_email": "b@x.com", "access_token": "t"}}, + {"detail-email-wrong-type", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "email": float64(9), "access_token": "t"}}, + // CPA emits `provider` as an authoritative alias of `type` (both from auth.Provider). A + // present provider that disagrees with the antigravity type — on either source — is a + // corrupt/deceptive entry and must fail closed. Everything else here is consistent, so the + // provider conflict is the SOLE reason these fail (without the check they'd make a quota call). + {"list-provider-conflict", + map[string]any{"name": authName, "type": "antigravity", "provider": "codex", "auth_index": "idx-ag", "project_id": "p", "email": "a@x.com"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "email": "a@x.com", "access_token": "t"}}, + {"detail-provider-conflict", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "email": "a@x.com"}, + map[string]any{"name": authName, "type": "antigravity", "provider": "codex", "auth_index": "idx-ag", "project_id": "p", "email": "a@x.com", "access_token": "t"}}, + {"detail-provider-wrong-type", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "email": "a@x.com"}, + map[string]any{"name": authName, "type": "antigravity", "provider": float64(3), "auth_index": "idx-ag", "project_id": "p", "email": "a@x.com", "access_token": "t"}}, + // Missing email on BOTH sides: the resource key is provider+project+email and a project can be + // shared, so an unbound (email-less) identity is unprovable and must fail closed — not degrade + // to a project-only digest that a second email-less credential could inherit. Everything else + // (name/type/index/project) is consistent, so the ONLY reason this fails is the missing email. + {"missing-email-both-sides", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "access_token": "t"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + var mu sync.Mutex + quotaCalls := 0 + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{tc.listEntry}}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + _ = json.NewEncoder(w).Encode(tc.download) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + var p struct { + URL string `json:"url"` + } + _ = json.NewDecoder(r.Body).Decode(&p) + if strings.Contains(p.URL, "retrieveUserQuotaSummary") { + mu.Lock() + quotaCalls++ + mu.Unlock() + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": antigravityGoldenMap(t)}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + // Seed a prior good antigravity snapshot to prove it is preserved on the conflict. + seed, _ := parseAntigravityQuotaGroups(antigravityGoldenMap(t)) + encoded, _ := json.Marshal(seed) + blob := string(encoded) + ag, idx := keeperProviderAntigravity, "idx-ag" + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: time.Now(), Provider: &ag, AuthIndex: &idx, AntigravityQuota: &blob, + }); err != nil { + t.Fatalf("seed: %v", err) + } + + stats, err := app.keeper.InspectAccountsLocked([]string{authName}) + if err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + if stats.IdentityError != 1 || stats.Healthy != 0 { + t.Fatalf("stats = %+v, want IdentityError=1, Healthy=0", stats) + } + mu.Lock() + calls := quotaCalls + mu.Unlock() + if calls != 0 { + t.Fatalf("identity conflict made %d quota api-calls; must be 0", calls) + } + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get: %v", err) + } + if len(st.AntigravityQuota) != 2 { + t.Fatalf("prior snapshot not preserved on conflict: %+v", st.AntigravityQuota) + } + if st.LastError == nil { + t.Fatal("identity conflict did not record an error") + } + }) + } +} + +// TestKeeperUpsertProviderSwitchClearsStaleFields proves the upsert enforces the provider +// invariant: switching a row codex→antigravity clears the stale Codex-only columns (account_id, +// reset credits, subscription, usage), and switching back antigravity→codex clears the stale +// antigravity_quota. So a filename reused for a different provider never shows mixed data. +func TestKeeperUpsertProviderSwitchClearsStaleFields(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + const authName = "switcher.json" + + // Seed a fully-populated Codex row. + codex := keeperProviderCodex + idx, used, count, acct, restore := "idx-1", 40, 3, "acct-A", 9 + sub := timeMust(t, "2026-10-01T00:00:00Z") + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: timeMust(t, "2026-09-09T00:00:00Z"), Provider: &codex, + AuthIndex: &idx, PrimaryUsedPercent: &used, ResetCreditCount: &count, ResetCredits: stringPtr(resetCreditSnapshotJSON), + SubscriptionActiveUntil: &sub, SubscriptionKnown: true, AccountID: &acct, RestorePriority: &restore, + }); err != nil { + t.Fatalf("seed codex: %v", err) + } + if seeded, _ := app.getKeeperState(ctx, authName); seeded.RestorePriority == nil || *seeded.RestorePriority != 9 { + t.Fatalf("seed restore_priority not stored: %v", seeded.RestorePriority) + } + + // Same filename now inspected as antigravity. + antigravity := keeperProviderAntigravity + quota, _ := parseAntigravityQuotaGroups(antigravityGoldenMap(t)) + encoded, _ := json.Marshal(quota) + blob := string(encoded) + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: timeMust(t, "2026-09-09T01:00:00Z"), Provider: &antigravity, + AuthIndex: &idx, AntigravityQuota: &blob, + }); err != nil { + t.Fatalf("upsert antigravity: %v", err) + } + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get: %v", err) + } + if st.Provider == nil || *st.Provider != "antigravity" || len(st.AntigravityQuota) != 2 { + t.Fatalf("switch to antigravity: provider/quota = %v/%d", st.Provider, len(st.AntigravityQuota)) + } + if st.PrimaryUsedPercent != nil || st.ResetCreditCount != nil || st.ResetCredits != nil || st.SubscriptionActiveUntil != nil || st.AccountID != nil { + t.Fatalf("codex-only fields not cleared on provider switch: %+v", st.keeperAccount) + } + if st.RestorePriority != nil { + t.Fatalf("codex restore_priority not cleared on provider switch: %v", st.RestorePriority) + } + + // Switch back to codex → the antigravity quota must be cleared. + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: timeMust(t, "2026-09-09T02:00:00Z"), Provider: &codex, + AuthIndex: &idx, PrimaryUsedPercent: &used, + }); err != nil { + t.Fatalf("upsert codex again: %v", err) + } + st, err = app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get2: %v", err) + } + if st.Provider == nil || *st.Provider != "codex" || len(st.AntigravityQuota) != 0 { + t.Fatalf("switch back to codex: provider=%v antigravity_quota len=%d (want cleared)", st.Provider, len(st.AntigravityQuota)) + } +} + +// TestKeeperConditionalReconcilePreservesAntigravity proves the conditional reconcile's prune +// existence-set includes Antigravity: a stored Antigravity row that the remote list still +// returns must NOT be pruned (the bug deleted it because the set was codex-only). +func TestKeeperConditionalReconcilePreservesAntigravity(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "antigravity-keep.json" + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files" { + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "antigravity", "auth_index": "idx-ag"}, + }}) + return + } + http.NotFound(w, r) + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + ag, idx := keeperProviderAntigravity, "idx-ag" + if err := app.upsertKeeperState(ctx, keeperAccountResult{Name: authName, Result: "healthy", CheckedAt: time.Now(), Provider: &ag, AuthIndex: &idx}); err != nil { + t.Fatalf("seed: %v", err) + } + cfg, err := app.loadConfig(ctx) + if err != nil { + t.Fatalf("loadConfig: %v", err) + } + if err := app.reconcileKeeperConditionalRemoteAuthStates(ctx, cfg, func(string) {}); err != nil { + t.Fatalf("reconcile: %v", err) + } + if _, err := app.getKeeperState(ctx, authName); err != nil { + t.Fatalf("conditional reconcile pruned a still-present antigravity row: %v", err) + } +} + +// TestKeeperWebsocketFailureClearsStaleAntigravityProvider reproduces the provider-switch probe on +// the credential-websocket failure path: a row stored as Antigravity whose remote file is now Codex +// and whose websocket-enable PATCH fails must still be re-tagged provider=codex (clearing the stale +// antigravity_quota), not left showing as Antigravity. +func TestKeeperWebsocketFailureClearsStaleAntigravityProvider(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "switched.json" + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + // The file is now a Codex account (websockets not yet enabled). + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "codex", "auth_index": "idx-c", "websockets": false}, + }}) + case r.Method == http.MethodPatch && r.URL.Path == "/v0/management/auth-files/fields": + http.Error(w, "boom", http.StatusBadGateway) // enabling websockets fails + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, func(cfg *AppConfig) { + cfg.CodexKeeper.EnableCredentialWebsockets = true + cfg.CodexKeeper.DryRun = false + }) + ctx := context.Background() + + // Seed a prior Antigravity row with quota (the file used to be antigravity). + seed, _ := parseAntigravityQuotaGroups(antigravityGoldenMap(t)) + encoded, _ := json.Marshal(seed) + blob := string(encoded) + ag, idx := keeperProviderAntigravity, "idx-c" + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: time.Now(), Provider: &ag, AuthIndex: &idx, AntigravityQuota: &blob, + }); err != nil { + t.Fatalf("seed: %v", err) + } + + stats, err := app.keeper.InspectAccountsLocked([]string{authName}) + if err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + if stats.NetworkError != 1 { + t.Fatalf("stats = %+v, want NetworkError=1 (websocket enable failed)", stats) + } + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get: %v", err) + } + if st.Provider == nil || *st.Provider != "codex" { + t.Fatalf("stale provider not switched to codex on websocket-failure path: %v", st.Provider) + } + if len(st.AntigravityQuota) != 0 { + t.Fatalf("stale antigravity_quota not cleared on provider switch: %+v", st.AntigravityQuota) + } +} + +// TestKeeperInspectAntigravityProjectSwapClearsStaleQuotaOnFailure reproduces the cross-inspection +// project-swap probe: a row stored for project-A whose list+detail now consistently say project-B +// (name/index unchanged) and whose project-B quota fetch FAILS must clear A's quota (a confirmed +// project swap), not keep showing A's quota against B. +func TestKeeperInspectAntigravityProjectSwapClearsStaleQuotaOnFailure(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "proj-swap.json" + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "project-B", "email": "sw@example.com"}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "project-B", "email": "sw@example.com", "access_token": "t", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + http.Error(w, "quota boom", http.StatusBadGateway) // project-B quota fetch fails + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + // Seed project-A with its quota bound to project-A. + seed, _ := parseAntigravityQuotaGroups(antigravityGoldenMap(t)) + encoded, _ := json.Marshal(seed) + blob := string(encoded) + ag, idx, projectADigest := keeperProviderAntigravity, "idx-ag", keeperAntigravityIdentityDigest("project-A", "sw@example.com") + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: time.Now(), Provider: &ag, AuthIndex: &idx, + AntigravityQuota: &blob, AntigravityIdentityDigest: &projectADigest, + }); err != nil { + t.Fatalf("seed: %v", err) + } + + if _, err := app.keeper.InspectAccountsLocked([]string{authName}); err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get: %v", err) + } + if st.AntigravityIdentityDigest == nil || *st.AntigravityIdentityDigest != keeperAntigravityIdentityDigest("project-B", "sw@example.com") { + t.Fatalf("project digest not rebound to project-B: %v", st.AntigravityIdentityDigest) + } + // The stored digest must NOT be the raw project id (privacy contract). + if st.AntigravityIdentityDigest != nil && *st.AntigravityIdentityDigest == "project-B" { + t.Fatalf("raw project id leaked into storage: %v", st.AntigravityIdentityDigest) + } + if len(st.AntigravityQuota) != 0 { + t.Fatalf("stale project-A quota not cleared on project swap + failed fetch: %+v", st.AntigravityQuota) + } +} + +// TestKeeperInspectAntigravityEmailSwapClearsStaleQuotaOnFailure proves the identity binding also +// covers the account email: a shared Google project reused by a different email (name/index/project +// unchanged) whose new fetch fails must clear the old account's quota, not label it under the new +// email. +func TestKeeperInspectAntigravityEmailSwapClearsStaleQuotaOnFailure(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "email-swap.json" + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "shared-proj", "email": "b@example.com"}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "shared-proj", "email": "b@example.com", "access_token": "t", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + http.Error(w, "quota boom", http.StatusBadGateway) // new account's fetch fails + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + // Seed the SAME project but a DIFFERENT email (a@example.com) with its quota. + seed, _ := parseAntigravityQuotaGroups(antigravityGoldenMap(t)) + encoded, _ := json.Marshal(seed) + blob := string(encoded) + ag, idx, digestA := keeperProviderAntigravity, "idx-ag", keeperAntigravityIdentityDigest("shared-proj", "a@example.com") + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: time.Now(), Provider: &ag, AuthIndex: &idx, + AntigravityQuota: &blob, AntigravityIdentityDigest: &digestA, + }); err != nil { + t.Fatalf("seed: %v", err) + } + + if _, err := app.keeper.InspectAccountsLocked([]string{authName}); err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get: %v", err) + } + if st.AntigravityIdentityDigest == nil || *st.AntigravityIdentityDigest != keeperAntigravityIdentityDigest("shared-proj", "b@example.com") { + t.Fatalf("identity digest not rebound to the new email: %v", st.AntigravityIdentityDigest) + } + if len(st.AntigravityQuota) != 0 { + t.Fatalf("stale quota kept under a different email (shared project): %+v", st.AntigravityQuota) + } +} + +// TestKeeperInspectAntigravityDetailFailurePreservesIdentityDigest reproduces the 3-phase probe: a +// transient detail-read failure (identity UNKNOWN) must NOT wipe the stored identity digest, or the +// next inspection loses its swap anchor and keeps the old account's quota. Phase 1 stores project-A +// + quota; phase 2 fails the download (list still A) — digest+quota preserved; phase 3 resolves +// project-B but the quota fetch fails — the A digest is still present so the swap is detected and +// A's quota is cleared (not shown under B). +func TestKeeperInspectAntigravityDetailFailurePreservesIdentityDigest(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "digest-preserve.json" + const email = "a@x.com" + var mu sync.Mutex + project := "project-A" + downloadFail := false + quotaFail := false + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + mu.Lock() + curProject, dFail, qFail := project, downloadFail, quotaFail + mu.Unlock() + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": curProject, "email": email}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + if dFail { + http.Error(w, "download boom", http.StatusBadGateway) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": curProject, "email": email, "access_token": "t", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + if qFail { + http.Error(w, "quota boom", http.StatusBadGateway) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": antigravityGoldenMap(t)}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + inspect := func() { + if _, err := app.keeper.InspectAccountsLocked([]string{authName}); err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + } + + // Phase 1: project-A resolves + quota fetched. + inspect() + st, _ := app.getKeeperState(ctx, authName) + if st.AntigravityIdentityDigest == nil || *st.AntigravityIdentityDigest != keeperAntigravityIdentityDigest("project-A", email) || len(st.AntigravityQuota) != 2 { + t.Fatalf("phase1: digest/quota not stored: %v %d", st.AntigravityIdentityDigest, len(st.AntigravityQuota)) + } + + // Phase 2: download fails (identity unknown) — digest AND quota must be preserved. + mu.Lock() + downloadFail = true + mu.Unlock() + inspect() + st, _ = app.getKeeperState(ctx, authName) + if st.AntigravityIdentityDigest == nil || *st.AntigravityIdentityDigest != keeperAntigravityIdentityDigest("project-A", email) { + t.Fatalf("phase2: identity digest was wiped on a transient detail failure: %v", st.AntigravityIdentityDigest) + } + if len(st.AntigravityQuota) != 2 { + t.Fatalf("phase2: quota not preserved on transient failure: %d", len(st.AntigravityQuota)) + } + + // Phase 3: detail recovers as project-B but the quota fetch fails — swap detected, A quota cleared. + mu.Lock() + downloadFail = false + project = "project-B" + quotaFail = true + mu.Unlock() + inspect() + st, _ = app.getKeeperState(ctx, authName) + if st.AntigravityIdentityDigest == nil || *st.AntigravityIdentityDigest != keeperAntigravityIdentityDigest("project-B", email) { + t.Fatalf("phase3: digest not rebound to project-B: %v", st.AntigravityIdentityDigest) + } + if len(st.AntigravityQuota) != 0 { + t.Fatalf("phase3: stale project-A quota shown under project-B: %+v", st.AntigravityQuota) + } +} + +// TestKeeperUpsertLegacyUnboundQuotaClearedWhenIdentityKnown proves the digest-CASE fail-closed +// branch for a legacy/unbound snapshot: a stored antigravity_quota whose identity digest is NULL +// (a pre-digest row, or a snapshot never bound to an identity) has no provable owner. When a new +// inspection resolves a KNOWN identity but its quota fetch FAILS (incoming quota NULL), the unbound +// quota must be CLEARED rather than COALESCE-preserved and inherited by the now-known identity. The +// same-identity transient failure (preserve-on-unknown) is covered by the 3-phase test above. +func TestKeeperUpsertLegacyUnboundQuotaClearedWhenIdentityKnown(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + const authName = "legacy-unbound.json" + + // Seed a legacy row: quota present, identity digest NULL (never bound to an identity). + seed, _ := parseAntigravityQuotaGroups(antigravityGoldenMap(t)) + encoded, _ := json.Marshal(seed) + blob := string(encoded) + ag, idx := keeperProviderAntigravity, "idx-ag" + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: time.Now(), Provider: &ag, AuthIndex: &idx, + AntigravityQuota: &blob, // AntigravityIdentityDigest left nil (unbound) + }); err != nil { + t.Fatalf("seed: %v", err) + } + if seeded, _ := app.getKeeperState(ctx, authName); seeded.AntigravityIdentityDigest != nil || len(seeded.AntigravityQuota) != 2 { + t.Fatalf("seed precondition: digest=%v quota=%d (want NULL digest, 2 groups)", seeded.AntigravityIdentityDigest, len(seeded.AntigravityQuota)) + } + + // New inspection: identity now KNOWN, but the quota fetch failed (incoming quota NULL). + digest := keeperAntigravityIdentityDigest("proj", "e@x.com") + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "network_error", CheckedAt: time.Now(), Provider: &ag, AuthIndex: &idx, + AntigravityIdentityDigest: &digest, // AntigravityQuota nil (fetch failed) + }); err != nil { + t.Fatalf("upsert: %v", err) + } + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get: %v", err) + } + if st.AntigravityIdentityDigest == nil || *st.AntigravityIdentityDigest != digest { + t.Fatalf("identity digest not bound: %v", st.AntigravityIdentityDigest) + } + if len(st.AntigravityQuota) != 0 { + t.Fatalf("unbound legacy quota not cleared once identity known + fetch failed: %+v", st.AntigravityQuota) + } +} + +// TestKeeperCachedAntigravityPriorityNotDegraded proves the cache-audit stat entry +// (keeperCachedAuthStats → mergeCachedState, used by conditional-refresh / cache-skip accounting) +// does NOT count an Antigravity account at priority -1 as PriorityDegraded: the Codex +// quota-usage→priority=-1 "degraded" semantic does not apply to Antigravity, so it falls through to +// healthy. A Codex control row at priority -1 IS still counted as degraded, proving the provider +// guard is load-bearing rather than blanket-suppressing the branch. +func TestKeeperCachedAntigravityPriorityNotDegraded(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + + minusOne := -1 + ag, codex := keeperProviderAntigravity, keeperProviderCodex + agIdx, cIdx := "idx-ag", "idx-c" + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "ag.json", Result: "healthy", CheckedAt: time.Now(), Provider: &ag, AuthIndex: &agIdx, Priority: &minusOne, + }); err != nil { + t.Fatalf("seed antigravity: %v", err) + } + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "codex.json", Result: "healthy", CheckedAt: time.Now(), Provider: &codex, AuthIndex: &cIdx, Priority: &minusOne, + }); err != nil { + t.Fatalf("seed codex: %v", err) + } + + // Antigravity at priority -1: falls through to healthy, NOT degraded. + agStats, err := app.keeperCachedAuthStats(ctx, []string{"ag.json"}) + if err != nil { + t.Fatalf("ag stats: %v", err) + } + if agStats.PriorityDegraded != 0 || agStats.Healthy != 1 { + t.Fatalf("antigravity priority=-1 miscounted: %+v (want PriorityDegraded=0, Healthy=1)", agStats) + } + + // Codex control at priority -1: still counted as degraded (the guard is provider-specific). + cStats, err := app.keeperCachedAuthStats(ctx, []string{"codex.json"}) + if err != nil { + t.Fatalf("codex stats: %v", err) + } + if cStats.PriorityDegraded != 1 || cStats.Healthy != 0 { + t.Fatalf("codex priority=-1 should be degraded: %+v (want PriorityDegraded=1, Healthy=0)", cStats) + } +} + +func timeMust(t *testing.T, s string) time.Time { + t.Helper() + parsed, err := time.Parse(time.RFC3339, s) + if err != nil { + t.Fatalf("parse time %q: %v", s, err) + } + return parsed +} + +// TestFetchAntigravityQuotaNoProject proves a missing project id fails closed with no remote call. +func TestFetchAntigravityQuotaNoProject(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + var mu sync.Mutex + calls := 0 + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + calls++ + mu.Unlock() + http.NotFound(w, r) + })) + defer cpa.Close() + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + cfg, err := app.loadConfig(context.Background()) + if err != nil { + t.Fatalf("loadConfig: %v", err) + } + if _, ok := app.fetchAntigravityQuota(context.Background(), cfg, "idx", ""); ok { + t.Fatal("missing project_id must fail closed") + } + mu.Lock() + defer mu.Unlock() + if calls != 0 { + t.Fatalf("no project_id made %d remote calls; want 0", calls) + } +} diff --git a/backend/internal/app/codex_keeper_internal_test.go b/backend/internal/app/codex_keeper_internal_test.go index 660ad8d7..c32267df 100644 --- a/backend/internal/app/codex_keeper_internal_test.go +++ b/backend/internal/app/codex_keeper_internal_test.go @@ -1,9 +1,12 @@ package app import ( + "bytes" "context" "encoding/base64" "encoding/json" + "errors" + "io" "math" "net/http" "net/http/httptest" @@ -14,6 +17,31 @@ import ( "time" ) +// keeperTestIsResetCreditsCall reports whether an api-call proxies the +// rate-limit-reset-credits endpoint. It peeks the body and restores it so the +// handler can still decode the request afterward. +func keeperTestIsResetCreditsCall(r *http.Request) bool { + if r.Body == nil { + return false + } + raw, err := io.ReadAll(r.Body) + if err != nil { + return false + } + r.Body = io.NopCloser(bytes.NewReader(raw)) + var payload struct { + URL string `json:"url"` + } + _ = json.Unmarshal(raw, &payload) + return strings.Contains(payload.URL, "rate-limit-reset-credits") +} + +// keeperTestEmptyResetCreditsPayload is a valid, empty reset-credits api-call +// response (available_count 0, no credits). +func keeperTestEmptyResetCreditsPayload() map[string]any { + return map[string]any{"status_code": 200, "body": map[string]any{"available_count": 0, "credits": []any{}}} +} + func TestKeeperUsageTimeoutDefaultIsThirtyButExistingValueIsPreserved(t *testing.T) { cfg, err := defaultConfig() if err != nil { @@ -833,6 +861,10 @@ func TestAutomaticKeeperRunsRespectCacheButManualRefreshBypasses(t *testing.T) { "access_token": "test-token", }) case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + if keeperTestIsResetCreditsCall(r) { + _ = json.NewEncoder(w).Encode(keeperTestEmptyResetCreditsPayload()) + return + } usageCalls++ _ = json.NewEncoder(w).Encode(map[string]any{ "status_code": 200, @@ -1339,6 +1371,10 @@ func TestKeeperAuthDetailRequestFailureCountsAsNetworkError(t *testing.T) { case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": http.Error(w, "temporary management failure", http.StatusBadGateway) case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + if keeperTestIsResetCreditsCall(r) { + _ = json.NewEncoder(w).Encode(keeperTestEmptyResetCreditsPayload()) + return + } usageCalls++ _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{}}) default: @@ -1402,6 +1438,10 @@ func TestKeeperRunSkipsInFlightAuthBeforeProcessing(t *testing.T) { "access_token": "test-token", }) case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + if keeperTestIsResetCreditsCall(r) { + _ = json.NewEncoder(w).Encode(keeperTestEmptyResetCreditsPayload()) + return + } usageCalls++ _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{}}) default: @@ -2004,11 +2044,16 @@ func newKeeperRecoveryTestCPA(t *testing.T, authDetails map[string]map[string]an case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": var payload struct { AuthIndex string `json:"auth_index"` + URL string `json:"url"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } + if strings.Contains(payload.URL, "rate-limit-reset-credits") { + _ = json.NewEncoder(w).Encode(keeperTestEmptyResetCreditsPayload()) + return + } cpa.mu.Lock() cpa.usageCalls[payload.AuthIndex]++ status := cpa.usageStatuses[payload.AuthIndex] @@ -2214,3 +2259,1447 @@ func keeperWebsocketUsageSuccessPayload(usedPercent int) map[string]any { }, } } + +// resetCreditSnapshotJSON is a single valid projected reset credit for identity tests. +const resetCreditSnapshotJSON = `[{"id":"c1","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":"2026-09-21T00:08:46.146320Z"}]` + +func healthyResetResult(name, authIndex, accountID string, count *int, credits *string) keeperAccountResult { + r := keeperAccountResult{ + Name: name, + Result: "healthy", + AuthIndex: stringPtr(authIndex), + CheckedAt: time.Now().In(appTimeLocation), + ResetCreditCount: count, + ResetCredits: credits, + } + if accountID != "" { + r.AccountID = stringPtr(accountID) + } + return r +} + +// TestUpsertKeeperStateClearsResetCreditsOnIdentityChange pins the identity +// boundary: when an auth_name is rebound to a different ACCOUNT (a new account_id) +// and the new account's reset-credit fetch fails (nil count/credits), the previous +// account's snapshot must NOT be preserved by COALESCE — it must be cleared so the +// wrong account's schedule never surfaces on the new identity's row. +func TestUpsertKeeperStateClearsResetCreditsOnIdentityChange(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + ctx := context.Background() + + // idx-1 inspects healthy with a populated reset-credit snapshot. + two := 2 + if err := app.upsertKeeperState(ctx, healthyResetResult("reused.json", "idx-1", "acct-1", &two, stringPtr(resetCreditSnapshotJSON))); err != nil { + t.Fatalf("upsert idx-1: %v", err) + } + state, err := app.getKeeperState(ctx, "reused.json") + if err != nil { + t.Fatalf("get after idx-1: %v", err) + } + if state.ResetCreditCount == nil || *state.ResetCreditCount != 2 || len(state.ResetCredits) != 1 { + t.Fatalf("idx-1 snapshot not stored: count=%v credits=%d", state.ResetCreditCount, len(state.ResetCredits)) + } + + // Same auth_name reassigned to idx-2; the new identity's fetch failed (nil). + if err := app.upsertKeeperState(ctx, healthyResetResult("reused.json", "idx-2", "acct-2", nil, nil)); err != nil { + t.Fatalf("upsert idx-2: %v", err) + } + state, err = app.getKeeperState(ctx, "reused.json") + if err != nil { + t.Fatalf("get after idx-2: %v", err) + } + if state.AuthIndex == nil || *state.AuthIndex != "idx-2" { + t.Fatalf("auth_index = %v, want idx-2", state.AuthIndex) + } + if state.ResetCreditCount != nil { + t.Fatalf("reset_credit_count = %d, want nil (stale snapshot must be cleared on identity change)", *state.ResetCreditCount) + } + if len(state.ResetCredits) != 0 { + t.Fatalf("reset_credits = %+v, want empty (must not show old account's schedule)", state.ResetCredits) + } +} + +// TestUpsertKeeperStatePreservesResetCreditsOnSameIdentity is the companion: a +// failed fetch on the SAME auth_index keeps the last good snapshot (the intended +// preserve-on-failure semantics), so the boundary fix does not over-clear. +func TestUpsertKeeperStatePreservesResetCreditsOnSameIdentity(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + ctx := context.Background() + + two := 2 + if err := app.upsertKeeperState(ctx, healthyResetResult("stable.json", "idx-1", "acct-1", &two, stringPtr(resetCreditSnapshotJSON))); err != nil { + t.Fatalf("upsert first: %v", err) + } + // Same identity, failed fetch (nil count/credits). + if err := app.upsertKeeperState(ctx, healthyResetResult("stable.json", "idx-1", "acct-1", nil, nil)); err != nil { + t.Fatalf("upsert second: %v", err) + } + state, err := app.getKeeperState(ctx, "stable.json") + if err != nil { + t.Fatalf("get: %v", err) + } + if state.ResetCreditCount == nil || *state.ResetCreditCount != 2 || len(state.ResetCredits) != 1 { + t.Fatalf("same-identity failed fetch must preserve snapshot: count=%v credits=%d", state.ResetCreditCount, len(state.ResetCredits)) + } +} + +// TestUpsertKeeperStatePreservesResetCreditsOnUnknownIdentity covers the +// transient-failure path: when getKeeperRemoteAuthFile fails (network_error / +// 404) the result carries a nil AuthIndex. That unknown identity must NOT clear +// the previous snapshot — a momentary auth-file read failure on the same account +// should preserve the schedule, not drop it. Only a KNOWN, different auth_index +// (a real reassignment) clears it. +func TestUpsertKeeperStatePreservesResetCreditsOnUnknownIdentity(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + ctx := context.Background() + + two := 2 + if err := app.upsertKeeperState(ctx, healthyResetResult("same.json", "idx-1", "acct-1", &two, stringPtr(resetCreditSnapshotJSON))); err != nil { + t.Fatalf("upsert idx-1: %v", err) + } + // A transport/404 failure on the auth-file read: nil AuthIndex, network_error. + failed := keeperAccountResult{ + Name: "same.json", + Result: "network_error", + AuthIndex: nil, + CheckedAt: time.Now().In(appTimeLocation), + } + if err := app.upsertKeeperState(ctx, failed); err != nil { + t.Fatalf("upsert network_error: %v", err) + } + state, err := app.getKeeperState(ctx, "same.json") + if err != nil { + t.Fatalf("get: %v", err) + } + if state.ResetCreditCount == nil || *state.ResetCreditCount != 2 || len(state.ResetCredits) != 1 { + t.Fatalf("unknown identity (nil auth_index) must preserve snapshot, not clear: count=%v credits=%d", state.ResetCreditCount, len(state.ResetCredits)) + } +} + +// TestKeeperResetInspectHonorsPerAuthLock proves the fix for the concurrency +// blocker: the reset-triggered inspection goes through InspectAccountsLocked, which +// wires the runner's per-auth lock. When a background run already holds the lock +// for the account, the sync inspect must SKIP it (no concurrent usage request), +// instead of the old direct executeKeeperRunForAccounts call that bypassed the +// lock and issued a second in-flight usage request for the same account. +func TestKeeperResetInspectHonorsPerAuthLock(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + + const authName = "locked.json" + var mu sync.Mutex + usageCalls := 0 + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{{"name": authName, "type": "codex"}}}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-lock", + "email": "lock@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + mu.Lock() + usageCalls++ + mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{ + "rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}, + }}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + + // A background run holds the per-auth lock for this account. + if !app.keeper.tryLockAuthName("daemon", authName) { + t.Fatal("could not acquire per-auth lock for the simulated background run") + } + + // The reset-triggered sync inspect must skip the locked account — the per-auth + // lock is the guard that prevents a concurrent inspection of the same account. + if _, err := app.keeper.InspectAccountsLocked([]string{authName}); err != nil { + t.Fatalf("InspectAccountsLocked returned %v; expected it to run and skip the locked account", err) + } + mu.Lock() + got := usageCalls + mu.Unlock() + if got != 0 { + t.Fatalf("usage calls = %d, want 0 — the sync inspect bypassed the per-auth lock and inspected a locked account", got) + } + + // Once the background run releases the lock, a fresh sync inspect proceeds. + app.keeper.unlockAuthName(authName) + if _, err := app.keeper.InspectAccountsLocked([]string{authName}); err != nil { + t.Fatalf("InspectAccountsLocked after unlock: %v", err) + } + mu.Lock() + got = usageCalls + mu.Unlock() + if got == 0 { + t.Fatal("usage calls still 0 after unlock — the sync inspect never ran even when the lock was free") + } +} + +// TestKeeperResetInspectNotBlockedByUnrelatedRun proves a targeted reset refresh +// is NOT blocked by an unrelated run occupying the global "accounts" mode: the +// account is still inspected (per-auth lock only, no global mode gate). Under the +// old RunAccountsSync (markRunning "accounts") this returned conflict and left the +// target's post-reset snapshot stale — this test would then show 0 usage calls. +func TestKeeperResetInspectNotBlockedByUnrelatedRun(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + + const target = "target.json" + var mu sync.Mutex + usageCalls := 0 + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{{"name": target, "type": "codex"}}}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": target, "type": "codex", "auth_index": "idx-target", + "email": "t@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + mu.Lock() + usageCalls++ + mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{ + "rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}, + }}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + + // An unrelated manual refresh already occupies the global "accounts" mode. + if !app.keeper.markRunning("accounts") { + t.Fatal("could not mark accounts running") + } + // The targeted reset refresh of a DIFFERENT account must still run. + if _, err := app.keeper.InspectAccountsLocked([]string{target}); err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + mu.Lock() + got := usageCalls + mu.Unlock() + if got == 0 { + t.Fatal("usage calls = 0 — the reset refresh was blocked by an unrelated accounts run") + } +} + +// TestKeeperSafeReason proves audit reasons are stable machine codes and never +// leak a raw error message (which could carry a URL/token/upstream detail). +func TestKeeperSafeReason(t *testing.T) { + if got := keeperSafeReason(nil); got != "ok" { + t.Fatalf("nil -> %q, want ok", got) + } + if got := keeperSafeReason(validationError("auth_index missing")); got != "validation_error" { + t.Fatalf("validationError -> %q, want validation_error", got) + } + if got := keeperSafeReason(notFoundError("gone")); got != "not_found" { + t.Fatalf("notFoundError -> %q, want not_found", got) + } + if got := keeperSafeReason(conflictError("busy")); got != "conflict" { + t.Fatalf("conflictError -> %q, want conflict", got) + } + raw := errors.New("dial https://cpa.internal:8317 failed: token=sk-secret") + got := keeperSafeReason(raw) + if got != "internal_error" { + t.Fatalf("opaque error -> %q, want internal_error", got) + } + if strings.Contains(got, "token") || strings.Contains(got, "cpa.internal") || strings.Contains(got, "sk-secret") { + t.Fatalf("safe reason leaked raw error content: %q", got) + } +} + +// TestKeeperRefreshAuditOutcome pins the post-reset refresh audit classification so +// the reconciliation log never misreports: a mode conflict is skipped (not error), +// a real run error is error, a vanished target (Total==0) is skipped/not_inspected +// (not ok), and only a genuine inspection is ok. +func TestKeeperRefreshAuditOutcome(t *testing.T) { + cases := []struct { + name string + stats keeperStats + err error + wantResult string + wantReason string + }{ + {"conflict is skipped", keeperStats{}, conflictError("busy"), "skipped", "conflict"}, + {"validation is error", keeperStats{}, validationError("bad"), "error", "validation_error"}, + {"opaque run error", keeperStats{}, errors.New("dial cpa.internal token=sk"), "error", "internal_error"}, + {"network error", keeperStats{Total: 1, NetworkError: 1}, nil, "error", "network_error"}, + {"status disabled (bad creds)", keeperStats{Total: 1, StatusDisabled: 1}, nil, "error", "status_disabled"}, + {"healthy but state write failed", keeperStats{Total: 1, Healthy: 1, StateWriteError: 1}, nil, "error", "state_write_error"}, + {"per-auth lock skip", keeperStats{Total: 1, Skipped: 1}, nil, "skipped", "account_busy"}, + {"vanished target not inspected", keeperStats{Total: 0}, nil, "skipped", "not_inspected"}, + {"inspected ok", keeperStats{Total: 1, Healthy: 1}, nil, "ok", ""}, + {"healthy but reset-credits unavailable is partial", keeperStats{Total: 1, Healthy: 1, ResetCreditsUnavailable: 1}, nil, "partial", "reset_credits_unavailable"}, + {"recovered enabled ok", keeperStats{Total: 1, StatusEnabled: 1}, nil, "ok", ""}, + {"priority degraded ok", keeperStats{Total: 1, PriorityDegraded: 1}, nil, "ok", ""}, + {"priority restored ok", keeperStats{Total: 1, PriorityRestored: 1}, nil, "ok", ""}, + {"total>0 no outcome not ok", keeperStats{Total: 1}, nil, "skipped", "not_inspected"}, + {"partial batch not ok", keeperStats{Total: 2, Healthy: 1}, nil, "skipped", "not_inspected"}, + {"disabled prioritized over skipped", keeperStats{Total: 2, StatusDisabled: 1, Skipped: 1}, nil, "error", "status_disabled"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result, reason := keeperRefreshAuditOutcome(tc.stats, tc.err) + if result != tc.wantResult || reason != tc.wantReason { + t.Fatalf("got (%q,%q), want (%q,%q)", result, reason, tc.wantResult, tc.wantReason) + } + }) + } +} + +// TestKeeperResetCreditsFetchFailureFlagged proves that when usage succeeds but the +// reset-credit fetch fails (inner 401/malformed/transport), the account stays +// healthy but the run reports ResetCreditsUnavailable — so a post-reset refresh is +// audited partial (reset_credits_unavailable), not a false ok. +func TestKeeperResetCreditsFetchFailureFlagged(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + + const authName = "creds-fail.json" + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{{"name": authName, "type": "codex"}}}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-cf", + "email": "cf@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + var payload struct { + URL string `json:"url"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + if strings.Contains(payload.URL, "rate-limit-reset-credits") { + // Reset-credit fetch fails at the inner layer. + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 401, "body": map[string]any{"detail": "unauth"}}) + return + } + // Usage check succeeds -> account healthy. + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{ + "rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}, + }}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + + stats, err := app.keeper.InspectAccountsLocked([]string{authName}) + if err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + // Usage succeeded, so the account reaches an ok-class outcome (healthy or a + // priority action), never disabled/network — the point is the account is fine. + okCount := stats.Healthy + stats.StatusEnabled + stats.PriorityDegraded + stats.PriorityRestored + if okCount != 1 || stats.NetworkError != 0 || stats.StatusDisabled != 0 { + t.Fatalf("expected one healthy-class outcome, stats=%+v", stats) + } + if stats.ResetCreditsUnavailable != 1 { + t.Fatalf("ResetCreditsUnavailable = %d, want 1 (reset-credit fetch failed)", stats.ResetCreditsUnavailable) + } + if result, reason := keeperRefreshAuditOutcome(stats, nil); result != "partial" || reason != "reset_credits_unavailable" { + t.Fatalf("audit outcome = (%q,%q), want (partial, reset_credits_unavailable)", result, reason) + } +} + +// TestKeeperStatsAddSumsEveryField guards keeperStats.add so a newly added counter +// (e.g. ResetCreditsUnavailable) is not silently dropped by the generalized +// aggregation. Every field is given a distinct value and must sum. +func TestKeeperStatsAddSumsEveryField(t *testing.T) { + base := keeperStats{Total: 1, Healthy: 2, StatusDisabled: 3, StatusEnabled: 4, PriorityDegraded: 5, PriorityRestored: 6, Skipped: 7, NetworkError: 8, IdentityError: 12, ResetCreditsUnavailable: 9, StateWriteError: 11} + delta := keeperStats{Total: 10, Healthy: 20, StatusDisabled: 30, StatusEnabled: 40, PriorityDegraded: 50, PriorityRestored: 60, Skipped: 70, NetworkError: 80, IdentityError: 120, ResetCreditsUnavailable: 90, StateWriteError: 110} + base.add(delta) + want := keeperStats{Total: 11, Healthy: 22, StatusDisabled: 33, StatusEnabled: 44, PriorityDegraded: 55, PriorityRestored: 66, Skipped: 77, NetworkError: 88, IdentityError: 132, ResetCreditsUnavailable: 99, StateWriteError: 121} + if base != want { + t.Fatalf("add sum = %+v, want %+v", base, want) + } +} + +// TestKeeperResetInspectStateWriteFailure proves a DB write-back failure is not +// swallowed: with a BEFORE UPDATE trigger aborting the upsert, the account still +// inspects healthy, but the run reports StateWriteError, the stored snapshot is +// unchanged, and the audit outcome is error/state_write_error (never ok). +func TestKeeperResetInspectStateWriteFailure(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + + const authName = "writefail.json" + credit := map[string]any{"id": "c1", "reset_type": "codex_rate_limits", "status": "available", "granted_at": "2026-08-22T00:08:46.146320Z", "expires_at": "2026-09-21T00:08:46.146320Z"} + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{{"name": authName, "type": "codex"}}}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-wf", + "email": "wf@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + var payload struct { + URL string `json:"url"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + if strings.Contains(payload.URL, "rate-limit-reset-credits") { + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"available_count": 1, "credits": []map[string]any{credit}}}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{ + "rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}, + }}) + case r.Method == http.MethodPatch && r.URL.Path == "/v0/management/auth-files/fields": + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + + // First inspection creates the row (INSERT, before the trigger exists). + if _, err := app.keeper.InspectAccountsLocked([]string{authName}); err != nil { + t.Fatalf("first inspect: %v", err) + } + before, err := app.getKeeperState(context.Background(), authName) + if err != nil { + t.Fatalf("get before: %v", err) + } + + // Make every subsequent UPDATE fail, as the review probe did. + if _, err := app.db.ExecContext(context.Background(), + `CREATE TRIGGER block_keeper_update BEFORE UPDATE ON codex_keeper_auth_states BEGIN SELECT RAISE(ABORT, 'blocked'); END;`); err != nil { + t.Fatalf("create trigger: %v", err) + } + + // Second inspection: usage/reset-credits succeed, but the upsert (now an UPDATE) + // aborts. The failure must surface, not be swallowed. + stats, err := app.keeper.InspectAccountsLocked([]string{authName}) + if err != nil { + t.Fatalf("second inspect: %v", err) + } + okCount := stats.Healthy + stats.StatusEnabled + stats.PriorityDegraded + stats.PriorityRestored + if okCount < 1 { + t.Fatalf("expected a healthy-class outcome, stats=%+v", stats) + } + if stats.StateWriteError != 1 { + t.Fatalf("StateWriteError = %d, want 1 (write-back failure must be recorded)", stats.StateWriteError) + } + if result, reason := keeperRefreshAuditOutcome(stats, nil); result != "error" || reason != "state_write_error" { + t.Fatalf("audit outcome = (%q,%q), want (error, state_write_error)", result, reason) + } + + // The stored snapshot must be unchanged (the aborted UPDATE wrote nothing). + after, err := app.getKeeperState(context.Background(), authName) + if err != nil { + t.Fatalf("get after: %v", err) + } + if before.LastCheckedAt == nil || after.LastCheckedAt == nil || !before.LastCheckedAt.Equal(*after.LastCheckedAt) { + t.Fatalf("last_checked_at changed despite a failed write: before=%v after=%v", before.LastCheckedAt, after.LastCheckedAt) + } + + // The raw DB error (the trigger's RAISE text) must NOT reach the Keeper UI log; + // only the stable marker is user-visible. + lines, err := app.loadKeeperLogLines(500) + if err != nil { + t.Fatalf("load keeper log lines: %v", err) + } + sawMarker := false + for _, line := range lines { + if strings.Contains(line, "blocked") { + t.Fatalf("raw DB error leaked into Keeper log: %q", line) + } + if strings.Contains(line, "state_write_error") { + sawMarker = true + } + } + if !sawMarker { + t.Fatal("expected a stable state_write_error marker in the Keeper log") + } +} + +// TestKeeperSubscriptionActiveUntil pins the tri-state contract: a parsed value +// is known, a confirmed-absent claim is known-and-nil (clears the stored value), +// and an unreadable/malformed claim is unknown (preserves the stored value). +func TestKeeperSubscriptionActiveUntil(t *testing.T) { + idToken := func(m map[string]any) map[string]any { + return map[string]any{"id_token": m} + } + want := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + + cases := []struct { + name string + authInfo map[string]any + wantTime *time.Time + wantKnown bool + }{ + {"unix-seconds", idToken(map[string]any{"chatgpt_subscription_active_until": float64(want.Unix())}), &want, true}, + {"rfc3339", idToken(map[string]any{"chatgpt_subscription_active_until": "2026-10-01T00:00:00Z"}), &want, true}, + {"date-only", idToken(map[string]any{"chatgpt_subscription_active_until": "2026-10-01"}), &want, true}, + {"unix-string", idToken(map[string]any{"chatgpt_subscription_active_until": "1790812800"}), &want, true}, + // Confirmed absent: id_token readable but no claim -> known, nil (clear). + {"claim-absent", idToken(map[string]any{"plan_type": "pro"}), nil, true}, + {"claim-null", idToken(map[string]any{"chatgpt_subscription_active_until": nil}), nil, true}, + // Unknown: unreadable id_token or malformed claim -> preserve. + {"no-id-token", map[string]any{"name": "x"}, nil, false}, + {"id-token-not-map", map[string]any{"id_token": "raw.jwt.string"}, nil, false}, + {"empty-string", idToken(map[string]any{"chatgpt_subscription_active_until": ""}), nil, false}, + {"non-positive", idToken(map[string]any{"chatgpt_subscription_active_until": float64(0)}), nil, false}, + {"garbage-string", idToken(map[string]any{"chatgpt_subscription_active_until": "not-a-time"}), nil, false}, + {"wrong-type", idToken(map[string]any{"chatgpt_subscription_active_until": true}), nil, false}, + // Strict numeric: fractional, non-finite, and out-of-range epochs are rejected. + {"fractional", idToken(map[string]any{"chatgpt_subscription_active_until": float64(want.Unix()) + 0.5}), nil, false}, + {"nan", idToken(map[string]any{"chatgpt_subscription_active_until": math.NaN()}), nil, false}, + {"positive-inf", idToken(map[string]any{"chatgpt_subscription_active_until": math.Inf(1)}), nil, false}, + {"negative-inf", idToken(map[string]any{"chatgpt_subscription_active_until": math.Inf(-1)}), nil, false}, + {"below-range", idToken(map[string]any{"chatgpt_subscription_active_until": float64(100)}), nil, false}, // ~1970 + {"above-range", idToken(map[string]any{"chatgpt_subscription_active_until": float64(5000000000)}), nil, false}, // ~2128 + {"max-int64", idToken(map[string]any{"chatgpt_subscription_active_until": float64(math.MaxInt64)}), nil, false}, + {"negative", idToken(map[string]any{"chatgpt_subscription_active_until": float64(-1)}), nil, false}, + {"fractional-explicit", idToken(map[string]any{"chatgpt_subscription_active_until": float64(1700000000.5)}), nil, false}, + {"unix-string-out-of-range", idToken(map[string]any{"chatgpt_subscription_active_until": "100"}), nil, false}, + // String date forms are range-gated too, not just the numeric path. + {"rfc3339-year-0001", idToken(map[string]any{"chatgpt_subscription_active_until": "0001-01-01T00:00:00Z"}), nil, false}, + {"rfc3339-year-9999", idToken(map[string]any{"chatgpt_subscription_active_until": "9999-12-31T23:59:59Z"}), nil, false}, + {"date-year-1000", idToken(map[string]any{"chatgpt_subscription_active_until": "1000-01-01"}), nil, false}, + {"date-year-2500", idToken(map[string]any{"chatgpt_subscription_active_until": "2500-01-01"}), nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, known := keeperSubscriptionActiveUntil(tc.authInfo) + if known != tc.wantKnown { + t.Fatalf("known = %v, want %v", known, tc.wantKnown) + } + switch { + case tc.wantTime == nil && got != nil: + t.Fatalf("time = %v, want nil", got) + case tc.wantTime != nil && (got == nil || !got.Equal(*tc.wantTime)): + t.Fatalf("time = %v, want %v", got, tc.wantTime) + } + }) + } +} + +// TestUpsertSubscriptionPreservedWhenIdentityUnconfirmed proves the subscription write is +// gated on a confirmed identity: a later inspection whose detail read failed (AuthIndex +// nil) must NOT overwrite/clear the stored renewal time even though SubscriptionKnown was +// set early from the list claim. +func TestUpsertSubscriptionPreservedWhenIdentityUnconfirmed(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + idx := "idx-sub" + known := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + + // A confirmed inspection stores a known subscription renewal time. + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "sub.json", Result: "healthy", CheckedAt: time.Now(), + AuthIndex: &idx, SubscriptionActiveUntil: &known, SubscriptionKnown: true, + }); err != nil { + t.Fatalf("first upsert: %v", err) + } + + // A later inspection whose DETAIL read failed: identity is unconfirmed (AuthIndex + // nil) but SubscriptionKnown is still true. The stored value must be preserved. + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "sub.json", Result: "error", CheckedAt: time.Now(), + AuthIndex: nil, SubscriptionActiveUntil: nil, SubscriptionKnown: true, + }); err != nil { + t.Fatalf("second upsert: %v", err) + } + + st, err := app.getKeeperState(ctx, "sub.json") + if err != nil { + t.Fatalf("get: %v", err) + } + if st.SubscriptionActiveUntil == nil || !st.SubscriptionActiveUntil.Equal(known) { + t.Fatalf("subscription not preserved on unconfirmed identity: got %v, want %v", st.SubscriptionActiveUntil, known) + } +} + +// TestCreateKeeperRedeemConvergesAcrossApps proves the DB-atomic claim is truly +// cross-process, not just in-process: two App instances with independent DB handles on +// the SAME SQLite file concurrently claim a fresh redeem for the same identity and both +// converge on ONE winner request_id (so OpenAI dedups a single logical key). +func TestCreateKeeperRedeemConvergesAcrossApps(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app1, err := New() + if err != nil { + t.Fatalf("New() app1: %v", err) + } + defer app1.Close() + // Same data dir → same SQLite file, but a distinct *sql.DB handle (a second process). + app2, err := New() + if err != nil { + t.Fatalf("New() app2: %v", err) + } + defer app2.Close() + + ctx := context.Background() + var wg sync.WaitGroup + ids := make([]string, 2) + errs := make([]error, 2) + apps := []*App{app1, app2} + wg.Add(2) + for i := range apps { + go func(i int) { + defer wg.Done() + ids[i], errs[i] = apps[i].createKeeperRedeem(ctx, "acct-shared") + }(i) + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Fatalf("app%d claim: %v", i+1, err) + } + } + if ids[0] == "" || ids[0] != ids[1] { + t.Fatalf("cross-process claims did not converge on one request_id: %v", ids) + } +} + +// keeperInsertPendingRedeem seeds a pending redeem ledger row (keyed by account_id). +func keeperInsertPendingRedeem(t *testing.T, app *App, accountID, id string) { + t.Helper() + if _, err := app.db.ExecContext(context.Background(), + `INSERT INTO codex_keeper_reset_redeems (account_id, redeem_request_id, status, updated_at) VALUES (?, ?, 'pending', '2026-01-01 00:00:00')`, + accountID, id); err != nil { + t.Fatalf("seed pending redeem: %v", err) + } +} + +func keeperInsertStateRow(t *testing.T, app *App, authName string) { + t.Helper() + if err := app.upsertKeeperState(context.Background(), keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: time.Now(), + }); err != nil { + t.Fatalf("seed state row: %v", err) + } +} + +// TestPruneKeepsAccountLedger proves prune deletes an absent account's STATE row but never +// touches the account_id-keyed redeem ledger, so a pending idempotency key survives a +// transient/empty remote list and the account can replay it after re-import. +func TestPruneKeepsAccountLedger(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + + keeperInsertStateRow(t, app, "gone.json") + keeperInsertPendingRedeem(t, app, "acct-gone", "rid-gone") + + // A transient/empty remote list marks the account stale. + pruned, err := app.pruneKeeperMissingAuthStates(ctx, map[string]bool{}) + if err != nil { + t.Fatalf("prune: %v", err) + } + if pruned != 1 { + t.Fatalf("pruned = %d, want 1", pruned) + } + if st, _ := app.getKeeperState(ctx, "gone.json"); st != nil { + t.Fatal("stale account state should have been pruned") + } + // The account_id-keyed ledger row must survive the prune. + if id, ok, _ := app.lookupPendingKeeperRedeem(ctx, "acct-gone"); !ok || id != "rid-gone" { + t.Fatalf("prune dropped the account's ledger key: got (%q,%v), want (rid-gone,true)", id, ok) + } +} + +// TestPruneFailsClosedWithoutRunner proves prune deletes nothing when there is no runner +// (no per-auth fence), rather than proceeding unlocked. +func TestPruneFailsClosedWithoutRunner(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + keeperInsertStateRow(t, app, "orphan.json") + app.keeper = nil // simulate a maintenance/test variant without a runner + + pruned, err := app.pruneKeeperMissingAuthStates(ctx, map[string]bool{}) + if err != nil { + t.Fatalf("prune: %v", err) + } + if pruned != 0 { + t.Fatalf("prune without a runner deleted %d rows; must fail closed", pruned) + } + if st, _ := app.getKeeperState(ctx, "orphan.json"); st == nil { + t.Fatal("prune without a fence deleted state; must skip") + } +} + +// TestDeleteStateRowKeepsLedger proves deleting a file's state row never drops the +// account_id-keyed redeem ledger, so a re-import (any filename) still replays the pending +// key rather than minting a new one. +func TestDeleteStateRowKeepsLedger(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + + keeperInsertStateRow(t, app, "del.json") + keeperInsertPendingRedeem(t, app, "acct-del", "rid-del") + + if _, err := app.deleteKeeperStateRow(ctx, "del.json"); err != nil { + t.Fatalf("delete state row: %v", err) + } + if st, _ := app.getKeeperState(ctx, "del.json"); st != nil { + t.Fatal("state row was not deleted") + } + if id, ok, _ := app.lookupPendingKeeperRedeem(ctx, "acct-del"); !ok || id != "rid-del" { + t.Fatalf("delete dropped the account's ledger key: got (%q,%v), want (rid-del,true)", id, ok) + } +} + +// TestUpsertSubscriptionClearedOnAccountSwapSameIndex proves the subscription snapshot is +// scoped to the ACCOUNT, not just auth_index: when the same auth_name+auth_index is swapped +// to a different account_id and the new claim is unknown, the previous account's renewal +// date is NOT inherited (it is cleared); the same-account unknown case still preserves. +func TestUpsertSubscriptionClearedOnAccountSwapSameIndex(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + idx := "idx-fixed" + acctA := "acct-A" + acctB := "acct-B" + known := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + + // Account A stores a known renewal date. + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "swap.json", Result: "healthy", CheckedAt: time.Now(), + AuthIndex: &idx, AccountID: &acctA, SubscriptionActiveUntil: &known, SubscriptionKnown: true, + }); err != nil { + t.Fatalf("seed A: %v", err) + } + + // Same auth_index, same account, unknown claim → preserve. + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "swap.json", Result: "error", CheckedAt: time.Now(), + AuthIndex: &idx, AccountID: &acctA, SubscriptionActiveUntil: nil, SubscriptionKnown: false, + }); err != nil { + t.Fatalf("same-account unknown: %v", err) + } + if st, _ := app.getKeeperState(ctx, "swap.json"); st.SubscriptionActiveUntil == nil || !st.SubscriptionActiveUntil.Equal(known) { + t.Fatalf("same-account unknown must preserve; got %v", st.SubscriptionActiveUntil) + } + + // Same auth_index but the file now backs a DIFFERENT account, unknown claim → clear + // (must not inherit A's renewal date). + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "swap.json", Result: "error", CheckedAt: time.Now(), + AuthIndex: &idx, AccountID: &acctB, SubscriptionActiveUntil: nil, SubscriptionKnown: false, + }); err != nil { + t.Fatalf("swap unknown: %v", err) + } + if st, _ := app.getKeeperState(ctx, "swap.json"); st.SubscriptionActiveUntil != nil { + t.Fatalf("account swap must clear the inherited renewal date; got %v", st.SubscriptionActiveUntil) + } +} + +// TestUpsertResetCreditClearedOnAccountSwapSameIndex proves the reset-credit snapshot is +// scoped to the ACCOUNT (account_id), not just auth_index: when the same auth_name+index is +// swapped to a different account and the new fetch failed, the old account's count/credits +// are cleared (not inherited); the same-account failed fetch still preserves. +func TestUpsertResetCreditClearedOnAccountSwapSameIndex(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + idx := "idx-rc" + acctA := "acct-A" + acctB := "acct-B" + count := 2 + credits := `[{"id":"c1","reset_type":"codex_rate_limits","status":"available"}]` + + // Account A stores a reset-credit snapshot. + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "rc.json", Result: "healthy", CheckedAt: time.Now(), + AuthIndex: &idx, AccountID: &acctA, ResetCreditCount: &count, ResetCredits: &credits, + }); err != nil { + t.Fatalf("seed A: %v", err) + } + + // Same account, fetch failed (nil snapshot) → preserve. + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "rc.json", Result: "healthy", CheckedAt: time.Now(), + AuthIndex: &idx, AccountID: &acctA, ResetCreditCount: nil, ResetCredits: nil, + }); err != nil { + t.Fatalf("same-account failed fetch: %v", err) + } + if st, _ := app.getKeeperState(ctx, "rc.json"); st.ResetCreditCount == nil || *st.ResetCreditCount != 2 { + t.Fatalf("same-account failed fetch must preserve count; got %v", st.ResetCreditCount) + } + + // Same auth_index, DIFFERENT account, fetch failed → clear (do not inherit). + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "rc.json", Result: "healthy", CheckedAt: time.Now(), + AuthIndex: &idx, AccountID: &acctB, ResetCreditCount: nil, ResetCredits: nil, + }); err != nil { + t.Fatalf("swap failed fetch: %v", err) + } + st, _ := app.getKeeperState(ctx, "rc.json") + if st.ResetCreditCount != nil { + t.Fatalf("account swap must clear the inherited reset-credit count; got %v", *st.ResetCreditCount) + } + if len(st.ResetCredits) != 0 { + t.Fatalf("account swap must clear the inherited reset-credit list; got %v", st.ResetCredits) + } +} + +// TestKeeperReconcileInspectionAccountID pins the cross-source identity reconciliation: +// the list id_token.chatgpt_account_id and the download account_id must AGREE (or only one +// present) to be trusted; a conflict — or a single source that is self-contradictory — +// yields (·, false) so the caller treats the identity as unknown. +func TestKeeperReconcileInspectionIdentity(t *testing.T) { + idTokenAcct := func(id string) map[string]any { + return map[string]any{"id_token": map[string]any{"chatgpt_account_id": id}} + } + cases := []struct { + name string + authInfo map[string]any + detail map[string]any + wantID string + wantOK bool + }{ + {"agree", idTokenAcct("acct-A"), map[string]any{"account_id": "acct-A"}, "acct-A", true}, + {"list-only", idTokenAcct("acct-A"), map[string]any{}, "acct-A", true}, + {"detail-only", map[string]any{}, map[string]any{"account_id": "acct-B"}, "acct-B", true}, + {"neither", map[string]any{}, map[string]any{}, "", true}, + {"account-conflict", idTokenAcct("acct-A"), map[string]any{"account_id": "acct-B"}, "", false}, + // A single source self-contradicting (top-level vs id_token claim) is also untrusted. + {"detail-self-conflict", map[string]any{}, map[string]any{"account_id": "acct-B", "id_token": map[string]any{"chatgpt_account_id": "acct-C"}}, "", false}, + // auth_index conflict alone (accounts agree) is also untrusted. + {"authindex-conflict", map[string]any{"auth_index": "idx-A", "id_token": map[string]any{"chatgpt_account_id": "acct-A"}}, map[string]any{"auth_index": "idx-B", "account_id": "acct-A"}, "", false}, + // account AND auth_index agree. + {"both-agree", map[string]any{"auth_index": "idx-A", "id_token": map[string]any{"chatgpt_account_id": "acct-A"}}, map[string]any{"auth_index": "idx-A", "account_id": "acct-A"}, "acct-A", true}, + // A raw JWT id_token string (CLIProxyAPI's real download form) is decoded and its + // chatgpt_account_id claim cross-checked against the top-level account_id. + {"rawjwt-agree", map[string]any{"account_id": "acct-A"}, map[string]any{"account_id": "acct-A", "id_token": keeperTestJWT(t, map[string]any{"chatgpt_account_id": "acct-A"})}, "acct-A", true}, + // The deceptive case: top-level account_id A but a raw JWT claim B → conflict, untrusted. + {"rawjwt-conflict", map[string]any{"account_id": "acct-A"}, map[string]any{"account_id": "acct-A", "id_token": keeperTestJWT(t, map[string]any{"chatgpt_account_id": "acct-B"})}, "", false}, + // The claim nested under the OpenAI auth namespace is also cross-checked. + {"rawjwt-namespace-conflict", map[string]any{"account_id": "acct-A"}, map[string]any{"account_id": "acct-A", "id_token": keeperTestJWT(t, map[string]any{"https://api.openai.com/auth": map[string]any{"chatgpt_account_id": "acct-B"}})}, "", false}, + // An id_token that is present but unparseable leaves the identity indeterminate → fail closed. + {"idtoken-unparseable", map[string]any{}, map[string]any{"account_id": "acct-A", "id_token": "not-a-jwt"}, "", false}, + // A present-but-wrong-type identity alias must fail closed, not be silently ignored in + // favor of a differently-typed sibling. + {"authindex-wrong-type", map[string]any{"id_token": map[string]any{"chatgpt_account_id": "acct-A"}}, map[string]any{"account_id": "acct-A", "auth_index": float64(123), "authIndex": "idx-A"}, "", false}, + {"account-id-wrong-type", map[string]any{}, map[string]any{"account_id": float64(123), "id_token": map[string]any{"chatgpt_account_id": "acct-A"}}, "", false}, + // The JWT auth namespace present but not an object is illegal. + {"jwt-namespace-not-object", map[string]any{"account_id": "acct-A"}, map[string]any{"account_id": "acct-A", "id_token": keeperTestJWT(t, map[string]any{"https://api.openai.com/auth": "not-object"})}, "", false}, + // The nested claim present but not a string is illegal. + {"jwt-nested-claim-wrong-type", map[string]any{"account_id": "acct-A"}, map[string]any{"account_id": "acct-A", "id_token": keeperTestJWT(t, map[string]any{"https://api.openai.com/auth": map[string]any{"chatgpt_account_id": float64(7)}})}, "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := keeperReconcileInspectionIdentity(tc.authInfo, tc.detail) + if ok != tc.wantOK || got.accountID != tc.wantID { + t.Fatalf("reconcile = (%q,%v), want (%q,%v)", got.accountID, ok, tc.wantID, tc.wantOK) + } + }) + } +} + +// keeperTestJWT builds a raw JWT string (header.payload.sig, base64url) carrying the given +// claims — the shape CLIProxyAPI's real download auth JSON uses for id_token, so tests can +// exercise the raw-JWT identity cross-check. The signature is a placeholder (identity parsing +// reads the payload, it does not verify the signature). +func keeperTestJWT(t *testing.T, claims map[string]any) string { + t.Helper() + enc := func(v any) string { + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal jwt part: %v", err) + } + return base64.RawURLEncoding.EncodeToString(b) + } + return enc(map[string]any{"alg": "none", "typ": "JWT"}) + "." + enc(claims) + ".sig" +} + +// TestKeeperLedgerPerAccountAndRouteAgnostic proves the redeem ledger keys on the stable +// account_id: distinct accounts keep separate rows, the SAME account converges on one key +// regardless of how it is routed (a claim reusing the same account_id returns the existing +// pending id), and a resolved (terminal) row lets the next claim mint a fresh id. +func TestKeeperLedgerPerAccountAndRouteAgnostic(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + + // Two distinct accounts get distinct pending rows. + idA, err := app.createKeeperRedeem(ctx, "acct-A") + if err != nil { + t.Fatalf("claim A: %v", err) + } + idB, err := app.createKeeperRedeem(ctx, "acct-B") + if err != nil { + t.Fatalf("claim B: %v", err) + } + if idA == idB { + t.Fatalf("distinct accounts share a request_id %q; each must get its own", idA) + } + + // The SAME account, however it is now routed, converges on its existing pending id + // (route-agnostic) rather than minting a new one. + idAAgain, err := app.createKeeperRedeem(ctx, "acct-A") + if err != nil { + t.Fatalf("re-claim A: %v", err) + } + if idAAgain != idA { + t.Fatalf("re-claim of account A minted a new id %q (want existing %q)", idAAgain, idA) + } + if got, ok, _ := app.lookupPendingKeeperRedeem(ctx, "acct-A"); !ok || got != idA { + t.Fatalf("account A pending lookup = (%q,%v), want (%q,true)", got, ok, idA) + } + + // After A resolves (terminal), the next claim mints a fresh id. + if err := app.finishKeeperRedeem(ctx, "acct-A", idA, keeperResetCreditCodeReset); err != nil { + t.Fatalf("finish A: %v", err) + } + idAFresh, err := app.createKeeperRedeem(ctx, "acct-A") + if err != nil { + t.Fatalf("fresh claim A: %v", err) + } + if idAFresh == idA { + t.Fatalf("claim after a resolved redeem reused the terminal id %q; must mint fresh", idA) + } +} + +// TestKeeperInspectIdentityConflictPreservesSnapshot proves an inspection whose list and +// download identities conflict (list account_id A, detail account_id B) bails out with an +// identity_error: it does NOT fetch the reset credits, preserves the previous snapshot, and +// the refresh audit reports error (never a healthy/ok refresh). +func TestKeeperInspectIdentityConflictPreservesSnapshot(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "conflict.json" + var mu sync.Mutex + creditFetches := 0 + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + // List identity: account A. + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "codex", "id_token": map[string]any{"chatgpt_account_id": "acct-LIST-A"}}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + // Download identity: account B (conflicts with the list). + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-1", "account_id": "acct-DETAIL-B", + "email": "c@example.com", "account_type": "pro", "disabled": false, "priority": 1, "access_token": "test-token", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + var p struct { + URL string `json:"url"` + } + _ = json.NewDecoder(r.Body).Decode(&p) + if strings.Contains(p.URL, "rate-limit-reset-credits") { + mu.Lock() + creditFetches++ + mu.Unlock() + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}}}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + // Seed a FULL good prior snapshot for account B: every business column set, so we can + // prove the identity-conflict write preserves all of them, not just reset credits. + idx, acctB, count := "idx-1", "acct-DETAIL-B", 5 + email, acctType := "b@example.com", "pro" + prio, prim, sec, qt := 1, 40, 20, 80 + dis := false + healthyAt := time.Now().Add(-time.Hour).Truncate(time.Second) + sub := time.Now().Add(720 * time.Hour).Truncate(time.Second) + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: healthyAt, Email: &email, AuthIndex: &idx, + AccountID: &acctB, AccountType: &acctType, Disabled: &dis, Priority: &prio, + PrimaryUsedPercent: &prim, SecondaryUsedPercent: &sec, QuotaThreshold: &qt, + SubscriptionActiveUntil: &sub, ResetCreditCount: &count, ResetCredits: stringPtr(resetCreditSnapshotJSON), + }); err != nil { + t.Fatalf("seed snapshot: %v", err) + } + before, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("read seeded state: %v", err) + } + + stats, err := app.keeper.InspectAccountsLocked([]string{authName}) + if err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + if stats.IdentityError != 1 || stats.Healthy != 0 { + t.Fatalf("stats = %+v, want IdentityError=1, Healthy=0", stats) + } + if result, reason := keeperRefreshAuditOutcome(stats, nil); result != "error" || reason != "identity_error" { + t.Fatalf("audit outcome = (%q,%q), want (error, identity_error)", result, reason) + } + mu.Lock() + fetches := creditFetches + mu.Unlock() + if fetches != 0 { + t.Fatalf("reset-credit fetched %d times on identity conflict; must not fetch from a mixed detail", fetches) + } + // EVERY business column of the prior snapshot must survive intact — an identity + // conflict must not clobber email/auth_index/account_id/account_type/disabled/priority/ + // usage/quota/reset-credit/subscription to NULL (a cleared auth_index would also block a + // later reset). Only last_error/latest_action/last_checked_at may change. + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get state: %v", err) + } + if st.Email == nil || *st.Email != "b@example.com" || st.AuthIndex == nil || *st.AuthIndex != "idx-1" || + st.AccountID == nil || *st.AccountID != "acct-DETAIL-B" || st.AccountType == nil || *st.AccountType != "pro" || + st.Disabled != false || st.Priority == nil || *st.Priority != 1 { + t.Fatalf("identity fields not preserved on conflict: %+v", st) + } + if st.PrimaryUsedPercent == nil || *st.PrimaryUsedPercent != 40 || + st.SecondaryUsedPercent == nil || *st.SecondaryUsedPercent != 20 || + st.QuotaThreshold == nil || *st.QuotaThreshold != 80 { + t.Fatalf("usage/quota not preserved on conflict: %+v", st) + } + if st.ResetCreditCount == nil || *st.ResetCreditCount != 5 || len(st.ResetCredits) != 1 { + t.Fatalf("reset credits not preserved on conflict: count=%v credits=%d", st.ResetCreditCount, len(st.ResetCredits)) + } + if st.SubscriptionActiveUntil == nil || !st.SubscriptionActiveUntil.Equal(*before.SubscriptionActiveUntil) { + t.Fatalf("subscription not preserved on conflict: got=%v want=%v", st.SubscriptionActiveUntil, before.SubscriptionActiveUntil) + } + // last_healthy_at must NOT advance (a conflict is not a healthy refresh). + if st.LastHealthyAt == nil || before.LastHealthyAt == nil || !st.LastHealthyAt.Equal(*before.LastHealthyAt) { + t.Fatalf("last_healthy_at changed on conflict: got=%v want=%v", st.LastHealthyAt, before.LastHealthyAt) + } + // The error/latest_action IS updated, and the check time advances. + if st.LastError == nil { + t.Fatal("identity conflict did not record an error on the account") + } + if st.LastCheckedAt == nil || !st.LastCheckedAt.After(healthyAt) { + t.Fatalf("last_checked_at not advanced on conflict: got=%v seed=%v", st.LastCheckedAt, healthyAt) + } +} + +// TestKeeperInspectNeitherAccountIDSkipsAccountScopedWrites proves that when neither the list +// nor the download detail carries an account_id (a legacy auth_index-only credential), the +// inspection does NOT fetch a reset-credit snapshot (it cannot attribute it to a resource) and +// does NOT bind the list's subscription claim; it preserves the prior account-scoped snapshot +// (reset credits, subscription, the previously-known account_id) and reports the refresh as +// partial/reset_credits_unavailable — never a falsely-healthy ok that overwrote account state. +func TestKeeperInspectNeitherAccountIDSkipsAccountScopedWrites(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "legacy.json" + // The list carries a PARSEABLE renewal claim that DIFFERS from the seeded snapshot but + // still no account_id — so this pins the subscription guard: with an unknown account_id + // the list's renewal must NOT be bound; the old snapshot value must be preserved. + listSub := time.Now().Add(1000 * time.Hour).UTC().Truncate(time.Second) + var mu sync.Mutex + creditFetches := 0 + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + // List entry: auth_index + a subscription claim, but NO account_id (the id_token + // has chatgpt_subscription_active_until yet no chatgpt_account_id). + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "codex", "auth_index": "idx-1", + "id_token": map[string]any{"chatgpt_subscription_active_until": listSub.Format(time.RFC3339)}}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + // Download detail: auth_index only, NO account_id. + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-1", + "email": "legacy@example.com", "account_type": "pro", "disabled": false, "priority": 1, "access_token": "test-token", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + var p struct { + URL string `json:"url"` + } + _ = json.NewDecoder(r.Body).Decode(&p) + if strings.Contains(p.URL, "rate-limit-reset-credits") { + mu.Lock() + creditFetches++ + mu.Unlock() + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}}}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + // Seed a prior account-scoped snapshot bound to a KNOWN account_id, with reset credits + // and a subscription renewal date — none of which this inspection may overwrite. + idx, priorAcct, count := "idx-1", "acct-KNOWN-X", 5 + sub := time.Now().Add(720 * time.Hour).Truncate(time.Second) + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: time.Now(), AuthIndex: &idx, AccountID: &priorAcct, + ResetCreditCount: &count, ResetCredits: stringPtr(resetCreditSnapshotJSON), + SubscriptionActiveUntil: &sub, SubscriptionKnown: true, + }); err != nil { + t.Fatalf("seed snapshot: %v", err) + } + + stats, err := app.keeper.InspectAccountsLocked([]string{authName}) + if err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + // Usage ran (an ok-family outcome) but the reset-credit snapshot could not be refreshed, + // and this is never an identity/network error. + okFamily := stats.Healthy + stats.StatusEnabled + stats.PriorityDegraded + stats.PriorityRestored + if okFamily != 1 || stats.ResetCreditsUnavailable != 1 || stats.IdentityError != 0 || stats.NetworkError != 0 { + t.Fatalf("stats = %+v, want ok-family=1, ResetCreditsUnavailable=1, IdentityError=0, NetworkError=0", stats) + } + if result, reason := keeperRefreshAuditOutcome(stats, nil); result != "partial" || reason != "reset_credits_unavailable" { + t.Fatalf("audit outcome = (%q,%q), want (partial, reset_credits_unavailable)", result, reason) + } + mu.Lock() + fetches := creditFetches + mu.Unlock() + if fetches != 0 { + t.Fatalf("reset-credit fetched %d times with no account_id; must not attribute a snapshot to an unknown resource", fetches) + } + // The prior account-scoped snapshot must survive: reset credits, subscription, AND the + // previously-known account_id (COALESCE preserves it when this inspection had none). + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get state: %v", err) + } + if st.ResetCreditCount == nil || *st.ResetCreditCount != 5 || len(st.ResetCredits) != 1 { + t.Fatalf("reset credits not preserved with unknown account_id: count=%v credits=%d", st.ResetCreditCount, len(st.ResetCredits)) + } + if st.SubscriptionActiveUntil == nil || !st.SubscriptionActiveUntil.Equal(sub) { + t.Fatalf("subscription not preserved with unknown account_id: got=%v want=%v", st.SubscriptionActiveUntil, sub) + } + if st.SubscriptionActiveUntil.Equal(listSub) { + t.Fatalf("list renewal claim was bound despite unknown account_id: got=%v (list=%v)", st.SubscriptionActiveUntil, listSub) + } + if st.AccountID == nil || *st.AccountID != "acct-KNOWN-X" { + t.Fatalf("prior account_id not preserved with unknown inspection identity: %v", st.AccountID) + } +} + +// TestKeeperInspectRawJWTAccountConflictPreservesSnapshot proves the inspection path also +// catches a deceptive raw-JWT identity: the download detail has top-level account_id=A but a +// raw JWT id_token whose account claim (nested under the OpenAI auth namespace — the real +// on-wire location) is B. The detail is self-contradictory, so the inspection bails as +// identity_error: NO reset-credit fetch, NO usage/credit snapshot write, the prior snapshot +// preserved, and the refresh audited as an error. +func TestKeeperInspectRawJWTAccountConflictPreservesSnapshot(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "jwt-conflict.json" + var mu sync.Mutex + creditFetches := 0 + deceptiveJWT := keeperTestJWT(t, map[string]any{"https://api.openai.com/auth": map[string]any{"chatgpt_account_id": "acct-B"}}) + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "codex", "auth_index": "idx-1", "id_token": map[string]any{"chatgpt_account_id": "acct-A"}}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + // Top-level account_id=A, but the raw JWT's own claim is B → self-contradictory. + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-1", "account_id": "acct-A", + "id_token": deceptiveJWT, "email": "j@example.com", "account_type": "pro", + "disabled": false, "priority": 1, "access_token": "test-token", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + var p struct { + URL string `json:"url"` + } + _ = json.NewDecoder(r.Body).Decode(&p) + if strings.Contains(p.URL, "rate-limit-reset-credits") { + mu.Lock() + creditFetches++ + mu.Unlock() + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 99, "reset_after_seconds": 3600}}}}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + // Seed a prior snapshot with a KNOWN usage percent + reset credits to prove they survive. + idx, acct, count, used := "idx-1", "acct-A", 5, 42 + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: time.Now(), AuthIndex: &idx, AccountID: &acct, + PrimaryUsedPercent: &used, ResetCreditCount: &count, ResetCredits: stringPtr(resetCreditSnapshotJSON), + }); err != nil { + t.Fatalf("seed snapshot: %v", err) + } + + stats, err := app.keeper.InspectAccountsLocked([]string{authName}) + if err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + if stats.IdentityError != 1 || stats.Healthy != 0 { + t.Fatalf("stats = %+v, want IdentityError=1, Healthy=0", stats) + } + if result, reason := keeperRefreshAuditOutcome(stats, nil); result != "error" || reason != "identity_error" { + t.Fatalf("audit outcome = (%q,%q), want (error, identity_error)", result, reason) + } + mu.Lock() + fetches := creditFetches + mu.Unlock() + if fetches != 0 { + t.Fatalf("reset-credit fetched %d times on a raw-JWT identity conflict; must not fetch", fetches) + } + // The usage snapshot must NOT be overwritten by the deceptive inspection's fresh 99%. + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get state: %v", err) + } + if st.PrimaryUsedPercent == nil || *st.PrimaryUsedPercent != 42 { + t.Fatalf("usage snapshot overwritten on raw-JWT conflict: got=%v want=42", st.PrimaryUsedPercent) + } + if st.ResetCreditCount == nil || *st.ResetCreditCount != 5 { + t.Fatalf("reset credits not preserved on raw-JWT conflict: %v", st.ResetCreditCount) + } + if st.LastError == nil { + t.Fatal("raw-JWT identity conflict did not record an error") + } +} + +// TestKeeperInspectListOnlyAccountIDSetsHeader proves that when the account_id is known only +// from the LIST entry's id_token claim (the download detail is a legacy raw file with no +// top-level account_id), the inspection still sends the Chatgpt-Account-Id header on BOTH the +// usage and reset-credit api-call egress (attributing the request to the confirmed account), +// AND writes the reset-credit snapshot — i.e. a known account_id never sends an account-less +// request while writing an account-scoped snapshot. +func TestKeeperInspectListOnlyAccountIDSetsHeader(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "list-only.json" + var mu sync.Mutex + usageHeader, creditHeader, creditRouteIndex := "", "", "" + creditFetches := 0 + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + // account_id AND auth_index known ONLY from the list entry. + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "codex", "auth_index": "idx-1", "id_token": map[string]any{"chatgpt_account_id": "acct-A"}}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + // Legacy raw detail: token only, NO top-level account_id, NO id_token, and an + // EXPLICIT-null auth_index (the dangerous case a right-biased merge would let + // overwrite the list's idx-1, then keeperAuthIndex would fall back to the name). + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "codex", "auth_index": nil, + "email": "a@example.com", "account_type": "pro", "disabled": false, "priority": 1, "access_token": "test-token", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + var p struct { + URL string `json:"url"` + AuthIndex string `json:"auth_index"` + Header map[string]string `json:"header"` + } + _ = json.NewDecoder(r.Body).Decode(&p) + switch { + case strings.Contains(p.URL, "rate-limit-reset-credits"): + mu.Lock() + creditFetches++ + creditHeader = p.Header["Chatgpt-Account-Id"] + creditRouteIndex = p.AuthIndex + mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"available_count": 1, "credits": []map[string]any{ + {"id": "RateLimitResetCredit_A", "reset_type": "codex_rate_limits", "status": "available", "granted_at": "2026-08-22T00:08:46.146320Z", "expires_at": "2026-09-21T00:08:46.146320Z"}, + }}}) + default: + mu.Lock() + usageHeader = p.Header["Chatgpt-Account-Id"] + mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}}}) + } + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + stats, err := app.keeper.InspectAccountsLocked([]string{authName}) + if err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + okFamily := stats.Healthy + stats.StatusEnabled + stats.PriorityDegraded + stats.PriorityRestored + if okFamily != 1 || stats.ResetCreditsUnavailable != 0 || stats.IdentityError != 0 { + t.Fatalf("stats = %+v, want ok-family=1, ResetCreditsUnavailable=0, IdentityError=0", stats) + } + mu.Lock() + uh, ch, cri, fetches := usageHeader, creditHeader, creditRouteIndex, creditFetches + mu.Unlock() + if uh != "acct-A" { + t.Fatalf("usage Chatgpt-Account-Id = %q, want acct-A (known from list claim)", uh) + } + if fetches != 1 || ch != "acct-A" { + t.Fatalf("reset-credit fetch=%d header=%q, want 1 fetch with Chatgpt-Account-Id=acct-A", fetches, ch) + } + // Routing uses the reconciled list auth_index, not the auth NAME (the detail's explicit-null + // auth_index must not win the merge and force a name fallback). + if cri != "idx-1" { + t.Fatalf("reset-credit routed auth_index = %q, want idx-1 (reconciled from the list, not the name)", cri) + } + // The snapshot is written and account-scoped to the confirmed account_id. + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get state: %v", err) + } + if st.AccountID == nil || *st.AccountID != "acct-A" { + t.Fatalf("stored account_id = %v, want acct-A", st.AccountID) + } + if st.ResetCreditCount == nil || *st.ResetCreditCount != 1 { + t.Fatalf("reset-credit snapshot not written: %v", st.ResetCreditCount) + } +} + +// TestKeeperResetNullAccountRefusedUntilInspected proves a legacy pre-account_id (NULL) state +// row cannot be reset: the account fence keys on the stored account_id and is taken before any +// remote resolve, so an unconfirmed identity is refused (fail closed) rather than fenced on an +// unknown key. No consume / cooldown is attempted. +func TestKeeperResetNullAccountRefusedUntilInspected(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "legacy-null.json" + var mu sync.Mutex + remoteCalls := 0 + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + remoteCalls++ + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + http.NotFound(w, r) + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, func(cfg *AppConfig) { cfg.CodexKeeper.DryRun = false }) + ctx := context.Background() + + // Seed a legacy NULL-account row (auth_index set, account_id NULL). + idx := "idx-1" + if err := app.upsertKeeperState(ctx, keeperAccountResult{Name: authName, Result: "healthy", CheckedAt: time.Now(), AuthIndex: &idx}); err != nil { + t.Fatalf("seed: %v", err) + } + if _, rerr := app.resetKeeperQuota(ctx, authName); rerr == nil { + t.Fatal("reset on a NULL-account row must fail closed (require inspection first)") + } + // It must fail before ANY remote resolve/consume. + mu.Lock() + defer mu.Unlock() + if remoteCalls != 0 { + t.Fatalf("reset on an unconfirmed identity made %d remote calls; must refuse before resolving", remoteCalls) + } +} diff --git a/backend/internal/app/codex_keeper_reset_credits_test.go b/backend/internal/app/codex_keeper_reset_credits_test.go new file mode 100644 index 00000000..ccaa0168 --- /dev/null +++ b/backend/internal/app/codex_keeper_reset_credits_test.go @@ -0,0 +1,371 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// realResetCreditsBody is Friday's authoritative rate-limit-reset-credits response: +// two available codex_rate_limits credits expiring 2026-09-21 and 2026-10-04. +const realResetCreditsBody = `{ + "credits": [ + {"id":"RateLimitResetCredit_A","reset_type":"codex_rate_limits","is_supported_by_plan":true,"status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":"2026-09-21T00:08:46.146320Z","redeem_started_at":null,"redeemed_at":null,"profile_image_url":"https://example.com/a.png","profile_user_id":"user_a","title":"Full reset","description":"secret description a"}, + {"id":"RateLimitResetCredit_B","reset_type":"codex_rate_limits","is_supported_by_plan":true,"status":"available","granted_at":"2026-09-04T02:24:33.736521Z","expires_at":"2026-10-04T02:24:33.736521Z","redeem_started_at":null,"redeemed_at":null,"profile_image_url":"https://example.com/b.png","profile_user_id":"user_b","title":"Full reset","description":"secret description b"} + ], + "available_count": 2, + "total_earned_count": 2, + "immediate_reset_purchase_eligible": false, + "history_enabled": false +}` + +func mustBody(t *testing.T, raw string) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal([]byte(raw), &m); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + return m +} + +func TestParseKeeperResetCreditsRealFixture(t *testing.T) { + count, credits, ok := parseKeeperResetCredits(mustBody(t, realResetCreditsBody)) + if !ok { + t.Fatal("expected ok for real fixture") + } + if count != 2 { + t.Fatalf("count=%d, want 2", count) + } + if len(credits) != 2 { + t.Fatalf("credits=%d, want 2", len(credits)) + } + // Ascending by expires_at: A (09/21) before B (10/04). + if credits[0].ID != "RateLimitResetCredit_A" || credits[1].ID != "RateLimitResetCredit_B" { + t.Fatalf("order = %s,%s want A,B", credits[0].ID, credits[1].ID) + } + want, _ := time.Parse(time.RFC3339Nano, "2026-09-21T00:08:46.146320Z") + if credits[0].ExpiresAt == nil || !credits[0].ExpiresAt.Equal(want) { + t.Fatalf("expires_at microsecond parse mismatch: %v want %v", credits[0].ExpiresAt, want) + } + if credits[0].GrantedAt == nil { + t.Fatal("granted_at should parse") + } + if credits[0].Title != "Full reset" { + t.Fatalf("title=%q want Full reset", credits[0].Title) + } + // Safe projection: the struct has no field for profile URL / description, so + // re-marshalling must not carry them. + encoded, _ := json.Marshal(credits[0]) + for _, leaked := range []string{"profile_image_url", "description", "secret description"} { + if strings.Contains(string(encoded), leaked) { + t.Fatalf("projected credit leaked %q: %s", leaked, encoded) + } + } +} + +func TestParseKeeperResetCreditsFilters(t *testing.T) { + body := mustBody(t, `{ + "available_count": 1, + "credits": [ + {"id":"keep","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z"}, + {"id":"wrong-type","reset_type":"gpt4_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z"}, + {"id":"redeemed","reset_type":"codex_rate_limits","status":"redeemed","granted_at":"2026-08-22T00:08:46.146320Z"}, + {"id":"unsupported","reset_type":"codex_rate_limits","status":"available","is_supported_by_plan":false,"granted_at":"2026-08-22T00:08:46.146320Z"} + ] + }`) + count, credits, ok := parseKeeperResetCredits(body) + if !ok { + t.Fatal("expected ok") + } + if count != 1 { + t.Fatalf("count=%d want 1 (authoritative available_count)", count) + } + if len(credits) != 1 || credits[0].ID != "keep" { + t.Fatalf("filtered credits = %+v, want only 'keep'", credits) + } +} + +func TestParseKeeperResetCreditsNullExpirySortedLast(t *testing.T) { + body := mustBody(t, `{ + "available_count": 3, + "credits": [ + {"id":"never","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":null}, + {"id":"later","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":"2026-10-04T02:24:33.736521Z"}, + {"id":"sooner","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":"2026-09-21T00:08:46.146320Z"} + ] + }`) + _, credits, ok := parseKeeperResetCredits(body) + if !ok { + t.Fatal("expected ok") + } + if len(credits) != 3 { + t.Fatalf("credits=%d want 3 (null-expiry entry must be kept)", len(credits)) + } + if credits[0].ID != "sooner" || credits[1].ID != "later" || credits[2].ID != "never" { + t.Fatalf("order = %s,%s,%s want sooner,later,never", credits[0].ID, credits[1].ID, credits[2].ID) + } + if credits[2].ExpiresAt != nil { + t.Fatal("never-expiring credit must keep nil ExpiresAt") + } +} + +func TestParseKeeperResetCreditsTruncatedDetail(t *testing.T) { + // Upstream may truncate the detail list while available_count stays authoritative. + body := mustBody(t, `{ + "available_count": 5, + "credits": [ + {"id":"a","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":"2026-09-21T00:08:46.146320Z"}, + {"id":"b","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":"2026-10-04T02:24:33.736521Z"} + ] + }`) + count, credits, ok := parseKeeperResetCredits(body) + if !ok { + t.Fatal("expected ok") + } + if count != 5 { + t.Fatalf("count=%d want 5 (must not be overwritten by len)", count) + } + if len(credits) != 2 { + t.Fatalf("credits=%d want 2", len(credits)) + } +} + +func TestParseKeeperResetCreditsEmptyArrayClearsToZero(t *testing.T) { + count, credits, ok := parseKeeperResetCredits(mustBody(t, `{"available_count":0,"credits":[]}`)) + if !ok { + t.Fatal("expected ok for a successful empty result") + } + if count != 0 { + t.Fatalf("count=%d want 0", count) + } + if len(credits) != 0 { + t.Fatalf("credits=%d want 0", len(credits)) + } +} + +func TestParseKeeperResetCreditsMalformed(t *testing.T) { + cases := map[string]string{ + "missing available_count": `{"credits":[]}`, + "non-int available_count": `{"available_count":"lots","credits":[]}`, + "fractional available_count": `{"available_count":2.5,"credits":[]}`, + "negative available_count": `{"available_count":-1,"credits":[]}`, + "oversized available_count": `{"available_count":1e19,"credits":[]}`, + "credits not array": `{"available_count":1,"credits":{}}`, + "missing credits": `{"available_count":1}`, + } + for name, raw := range cases { + t.Run(name, func(t *testing.T) { + if _, _, ok := parseKeeperResetCredits(mustBody(t, raw)); ok { + t.Fatalf("expected ok=false for %s", name) + } + }) + } + if _, _, ok := parseKeeperResetCredits(nil); ok { + t.Fatal("expected ok=false for nil body") + } +} + +// TestParseKeeperResetCreditsDropsMalformedEntries pins the strict per-entry +// validation: an available/codex entry is dropped (not kept with silently nil'd +// fields) when it has an empty id, a missing/unparseable granted_at, or a non-null +// but unparseable expires_at. The authoritative available_count is unaffected, and +// a fully valid entry (including a null "never expires") still survives. +func TestParseKeeperResetCreditsDropsMalformedEntries(t *testing.T) { + body := mustBody(t, `{ + "available_count": 5, + "credits": [ + {"id":"","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":"2026-09-21T00:08:46.146320Z"}, + {"id":"no-granted","reset_type":"codex_rate_limits","status":"available","expires_at":"2026-09-21T00:08:46.146320Z"}, + {"id":"bad-expiry","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":"not-a-date"}, + {"id":"good","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":null} + ] + }`) + count, credits, ok := parseKeeperResetCredits(body) + if !ok { + t.Fatal("expected ok (malformed entries are dropped, not fail-closed)") + } + if count != 5 { + t.Fatalf("count=%d want 5 (authoritative, unaffected by dropped entries)", count) + } + if len(credits) != 1 || credits[0].ID != "good" { + t.Fatalf("kept credits = %+v, want only the valid 'good' entry", credits) + } + if credits[0].GrantedAt == nil { + t.Fatal("kept entry must carry a parsed granted_at, never nil") + } + if credits[0].ExpiresAt != nil { + t.Fatal("the valid null-expiry entry must keep nil ExpiresAt (never expires)") + } +} + +// TestParseKeeperResetCreditsExpiryPresentVsNull pins the strict present-vs-null +// distinction for expires_at: only JSON null or an absent field mean "never +// expires" and are kept; a present empty string or a non-string value is malformed +// and the entry is dropped (not silently treated as never-expiring). +func TestParseKeeperResetCreditsExpiryPresentVsNull(t *testing.T) { + const g = `"granted_at":"2026-08-22T00:08:46.146320Z"` + body := mustBody(t, `{ + "available_count": 6, + "credits": [ + {"id":"null-expiry","reset_type":"codex_rate_limits","status":"available",`+g+`,"expires_at":null}, + {"id":"absent-expiry","reset_type":"codex_rate_limits","status":"available",`+g+`}, + {"id":"empty-string","reset_type":"codex_rate_limits","status":"available",`+g+`,"expires_at":""}, + {"id":"numeric","reset_type":"codex_rate_limits","status":"available",`+g+`,"expires_at":123}, + {"id":"valid","reset_type":"codex_rate_limits","status":"available",`+g+`,"expires_at":"2026-09-21T00:08:46.146320Z"} + ] + }`) + _, credits, ok := parseKeeperResetCredits(body) + if !ok { + t.Fatal("expected ok") + } + kept := map[string]*keeperResetCredit{} + for i := range credits { + kept[credits[i].ID] = &credits[i] + } + if len(credits) != 3 { + t.Fatalf("kept %d credits, want 3 (null-expiry, absent-expiry, valid); got %v", len(credits), keysOf(kept)) + } + if c, present := kept["null-expiry"]; !present || c.ExpiresAt != nil { + t.Fatal("null expires_at must be kept as never-expiring") + } + if c, present := kept["absent-expiry"]; !present || c.ExpiresAt != nil { + t.Fatal("absent expires_at must be kept as never-expiring") + } + if _, present := kept["empty-string"]; present { + t.Fatal(`expires_at:"" must be dropped, not treated as never-expiring`) + } + if _, present := kept["numeric"]; present { + t.Fatal("expires_at:123 (non-string) must be dropped, not treated as never-expiring") + } + if c, present := kept["valid"]; !present || c.ExpiresAt == nil { + t.Fatal("valid RFC3339 expires_at must be kept with a parsed time") + } +} + +func keysOf(m map[string]*keeperResetCredit) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +// fetchResetCreditsCPA builds a fake CLIProxyAPI whose api-call proxy returns the +// given inner status code and body (an object, or a raw string for malformed cases). +func fetchResetCreditsCPA(t *testing.T, innerStatus int, innerBody any, outerStatus int) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/v0/management/api-call" { + http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound) + return + } + if outerStatus != 0 && outerStatus != http.StatusOK { + http.Error(w, "outer failure", outerStatus) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "status_code": innerStatus, + "body": innerBody, + }) + })) +} + +func fetchResetCreditsCfg(url string) AppConfig { + return AppConfig{ + Collector: CollectorConfig{CLIProxyURL: url, ManagementKey: "test-key"}, + CodexKeeper: KeeperConfig{UsageTimeoutSeconds: 5}, + } +} + +func TestFetchKeeperResetCreditsSuccess(t *testing.T) { + var body map[string]any + _ = json.Unmarshal([]byte(realResetCreditsBody), &body) + cpa := fetchResetCreditsCPA(t, http.StatusOK, body, http.StatusOK) + defer cpa.Close() + + count, credits, ok := (&App{}).fetchKeeperResetCredits(context.Background(), fetchResetCreditsCfg(cpa.URL), map[string]any{"auth_index": "idx-1"}) + if !ok { + t.Fatal("expected ok") + } + if count != 2 || len(credits) != 2 { + t.Fatalf("count=%d credits=%d want 2,2", count, len(credits)) + } +} + +// TestFetchKeeperResetCreditsRejectsDeceptiveInnerStatus pins strict inner-status +// parsing: an outer 2xx api-call whose inner status_code is a fractional number, +// a numeric string, or negative must NOT be accepted as success (which would +// overwrite the snapshot). Only a strict integer in [200,300) succeeds. +func TestFetchKeeperResetCreditsRejectsDeceptiveInnerStatus(t *testing.T) { + var body map[string]any + _ = json.Unmarshal([]byte(realResetCreditsBody), &body) + + rawStatusCPA := func(statusValue any) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Encode status_code as the raw JSON provided so a non-integer survives. + payload := `{"status_code":` + statusValue.(string) + `,"body":` + mustMarshal(body) + `}` + _, _ = w.Write([]byte(payload)) + })) + } + reject := []struct { + name string + statusRaw string + }{ + {"fractional 200.5", "200.5"}, + {"string \"200\"", `"200"`}, + {"negative -1", "-1"}, + {"null", "null"}, + } + for _, tc := range reject { + t.Run(tc.name, func(t *testing.T) { + cpa := rawStatusCPA(tc.statusRaw) + defer cpa.Close() + if _, _, ok := (&App{}).fetchKeeperResetCredits(context.Background(), fetchResetCreditsCfg(cpa.URL), map[string]any{"auth_index": "idx-1"}); ok { + t.Fatalf("%s: expected ok=false (deceptive inner status must not be accepted)", tc.name) + } + }) + } + // Sanity: a strict integer 200 still succeeds. + cpa := rawStatusCPA("200") + defer cpa.Close() + if _, _, ok := (&App{}).fetchKeeperResetCredits(context.Background(), fetchResetCreditsCfg(cpa.URL), map[string]any{"auth_index": "idx-1"}); !ok { + t.Fatal("strict integer status_code 200 should succeed") + } +} + +func mustMarshal(v any) string { + b, _ := json.Marshal(v) + return string(b) +} + +func TestFetchKeeperResetCreditsInnerErrorsPreserve(t *testing.T) { + var body map[string]any + _ = json.Unmarshal([]byte(realResetCreditsBody), &body) + cases := []struct { + name string + innerStatus int + innerBody any + outerStatus int + }{ + {"inner 401", http.StatusUnauthorized, map[string]any{"detail": "unauth"}, http.StatusOK}, + {"inner 500", http.StatusInternalServerError, map[string]any{"detail": "boom"}, http.StatusOK}, + {"outer 502", http.StatusOK, body, http.StatusBadGateway}, + {"malformed inner body", http.StatusOK, "not-json-object", http.StatusOK}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cpa := fetchResetCreditsCPA(t, tc.innerStatus, tc.innerBody, tc.outerStatus) + defer cpa.Close() + _, _, ok := (&App{}).fetchKeeperResetCredits(context.Background(), fetchResetCreditsCfg(cpa.URL), map[string]any{"auth_index": "idx-1"}) + if ok { + t.Fatalf("%s: expected ok=false so the previous snapshot is preserved", tc.name) + } + }) + } +} diff --git a/backend/internal/app/codex_keeper_reset_test.go b/backend/internal/app/codex_keeper_reset_test.go new file mode 100644 index 00000000..decaf5bb --- /dev/null +++ b/backend/internal/app/codex_keeper_reset_test.go @@ -0,0 +1,1404 @@ +package app_test + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + backendApp "cpa-helper/backend/internal/app" +) + +// rawJWTWithClaims builds a raw JWT string (header.payload.sig, base64url) carrying the given +// claims — the shape CLIProxyAPI's real download auth JSON uses for id_token. The signature is +// a placeholder; identity parsing reads the payload and does not verify the signature. +func rawJWTWithClaims(t *testing.T, claims map[string]any) string { + t.Helper() + enc := func(v any) string { + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal jwt part: %v", err) + } + return base64.RawURLEncoding.EncodeToString(b) + } + return enc(map[string]any{"alg": "none", "typ": "JWT"}) + "." + enc(claims) + ".sig" +} + +// keeperResetResponse is the minimal wire shape the reset route returns: only the +// account name plus the operation outcome (reset|already_redeemed|no_credit| +// nothing_to_reset|cooldown_only) for this operation. +type keeperResetResponse struct { + Status string `json:"status"` + Account struct { + Name string `json:"name"` + Outcome string `json:"outcome"` + } `json:"account"` +} + +// keeperResetControl is the shared, mutex-guarded mock state that lets each +// sub-case steer the fake CLIProxyAPI: the authoritative available credit count, +// how the consume endpoint replies, and how /reset-quota replies. It also records +// call counts so a test can assert fail-closed ordering (e.g. a failed consume +// must never reach /reset-quota). +// keeperConsumeSensitiveSentinel is a fake sensitive token embedded in the mock's +// inner consume error body; the redaction canary asserts it NEVER reaches the log. +const keeperConsumeSensitiveSentinel = "SENSITIVE-sk-consume-secret-zzz" + +type keeperResetControl struct { + mu sync.Mutex + availableCount int // authoritative available_count returned by the fresh fetch + fetchMode string // ok | fail (fresh reset-credit GET) + consumeMode string // ok | http-fail | unknown-code | no-credit | lost + consumeSuccessCode string // terminal code returned on a successful consume (default "reset") + resetMode string // ok | http-fail | empty-body | wrong-index | bad-status | padded-index + consumeCalls int + consumeRequestIDs []string // redeem_request_id seen on each consume call, in order + resetCreditFetch int + resetQuotaCalls []string + // Concurrency gate: when armed, the first fresh reset-credit fetch to run under + // the per-auth lock signals gateReached (once) then blocks on gateRelease, so a + // test can deterministically hold the winner in-lock while contending requests hit + // tryLock and 409. + gateReached chan struct{} + gateRelease chan struct{} + gateOnce *sync.Once + // gateAtDownload moves the concurrency gate from the reset-credit fetch to the auth-files + // download stage, so a test can hold a reset inside identity resolution (before the fetch) + // and assert the account fence — taken before the resolve — already excludes a second route. + gateAtDownload bool + // authIndexOverride, when non-empty, replaces the downloaded detail's auth_index + // so a test can simulate a reassignment (fresh auth_index != stored DB row). + authIndexOverride string + // accountIDOverride, when non-empty, replaces the downloaded detail's account_id + // so a test can simulate an account_id change on the same auth_index. + accountIDOverride string + // omitAccountID drops account_id from the downloaded detail entirely. + omitAccountID bool + // listAccountIDClaim, when non-empty, adds id_token.chatgpt_account_id to the + // list entry so a test can exercise the list-vs-download account_id cross-check. + listAccountIDClaim string + // listTypeOverride, when non-empty, replaces the list entry's "type" (default + // "codex") so a test can simulate a non-Codex / drifted provider. + listTypeOverride string + // omitAccessToken drops access_token from the downloaded detail. + omitAccessToken bool + // duplicateListName emits a second list entry with the same name (different + // auth_index) to simulate a malformed/corrupt remote list. + duplicateListName bool + // detailAliasConflict adds a conflicting authIndex alias to the download detail + // (auth_index != authIndex) to simulate a deceptive intra-object identity. + detailAliasConflict bool + // detailNameOverride, when non-empty, replaces the download detail's "name" to + // simulate a proxy misroute binding another credential's detail to this target. + detailNameOverride string + // detailIDTokenOverride, when non-empty, sets the download detail's id_token to this + // raw value (a JWT string as CLIProxyAPI really returns), so a test can inject a token + // whose chatgpt_account_id claim conflicts with the top-level account_id. + detailIDTokenOverride string +} + +func newKeeperResetCPA(t *testing.T, authName string, authDetail map[string]any, ctrl *keeperResetControl) *httptest.Server { + t.Helper() + credit := func(id, expires string) map[string]any { + return map[string]any{ + "id": id, "reset_type": "codex_rate_limits", "status": "available", + "granted_at": "2026-08-22T00:08:46.146320Z", "expires_at": expires, + } + } + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + entry := map[string]any{"name": authName, "type": "codex"} + ctrl.mu.Lock() + claim := ctrl.listAccountIDClaim + typeOvr := ctrl.listTypeOverride + dup := ctrl.duplicateListName + ctrl.mu.Unlock() + if claim != "" { + entry["id_token"] = map[string]any{"chatgpt_account_id": claim} + } + if typeOvr != "" { + entry["type"] = typeOvr + } + files := []map[string]any{entry} + if dup { + files = append(files, map[string]any{"name": authName, "type": "codex", "auth_index": "idx-duplicate"}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"files": files}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + detail := map[string]any{} + for k, v := range authDetail { + detail[k] = v + } + ctrl.mu.Lock() + ovr := ctrl.authIndexOverride + acctOvr := ctrl.accountIDOverride + omitAcct := ctrl.omitAccountID + omitToken := ctrl.omitAccessToken + aliasConflict := ctrl.detailAliasConflict + nameOvr := ctrl.detailNameOverride + idTokenOvr := ctrl.detailIDTokenOverride + ctrl.mu.Unlock() + if nameOvr != "" { + detail["name"] = nameOvr + } + if idTokenOvr != "" { + detail["id_token"] = idTokenOvr + } + if ovr != "" { + detail["auth_index"] = ovr + } + if acctOvr != "" { + detail["account_id"] = acctOvr + } + if omitAcct { + delete(detail, "account_id") + } + if omitToken { + delete(detail, "access_token") + } + if aliasConflict { + detail["authIndex"] = "idx-conflict" + } + _ = json.NewEncoder(w).Encode(detail) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + var p struct { + URL string `json:"url"` + Data string `json:"data"` + } + _ = json.NewDecoder(r.Body).Decode(&p) + switch { + // The consume URL also contains "rate-limit-reset-credits", so match the + // more specific "/consume" suffix first. + case strings.Contains(p.URL, "rate-limit-reset-credits/consume"): + var d struct { + RedeemRequestID string `json:"redeem_request_id"` + } + _ = json.Unmarshal([]byte(p.Data), &d) + ctrl.mu.Lock() + ctrl.consumeCalls++ + ctrl.consumeRequestIDs = append(ctrl.consumeRequestIDs, d.RedeemRequestID) + mode := ctrl.consumeMode + successCode := ctrl.consumeSuccessCode + // A real reset consumes one credit. + if mode == "ok" && (successCode == "" || successCode == "reset") && ctrl.availableCount > 0 { + ctrl.availableCount-- + } + ctrl.mu.Unlock() + if mode == "lost" { + // Every attempt of this operation loses its response (outer 5xx): + // even keeperRequest's idempotent retries fail, so the whole operation + // is unknown and the ledger stays pending for the next operation. + http.Error(w, "gateway", http.StatusBadGateway) + return + } + switch mode { + case "http-fail": + // Inner non-2xx carrying a sensitive sentinel: the canary asserts it + // never reaches the log. + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 403, "body": map[string]any{"error": map[string]any{"message": keeperConsumeSensitiveSentinel}}}) + case "unknown-code": + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"code": "surprise", "detail": keeperConsumeSensitiveSentinel}}) + case "no-credit": + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"code": "no_credit"}}) + default: + code := successCode + if code == "" { + code = "reset" + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"code": code, "windows_reset": []any{}}}) + } + case strings.Contains(p.URL, "rate-limit-reset-credits"): + ctrl.mu.Lock() + ctrl.resetCreditFetch++ + n := ctrl.availableCount + mode := ctrl.fetchMode + gReached, gRelease, gOnce := ctrl.gateReached, ctrl.gateRelease, ctrl.gateOnce + ctrl.mu.Unlock() + if gRelease != nil { + gOnce.Do(func() { close(gReached) }) + <-gRelease + } + if mode == "fail" { + http.Error(w, "boom", http.StatusInternalServerError) + return + } + credits := []map[string]any{} + if n >= 1 { + credits = append(credits, credit("RateLimitResetCredit_A", "2026-09-21T00:08:46.146320Z")) + } + if n >= 2 { + credits = append(credits, credit("RateLimitResetCredit_B", "2026-10-04T02:24:33.736521Z")) + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"available_count": n, "credits": credits}}) + default: + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{ + "rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}, + }}) + } + case r.Method == http.MethodPatch && r.URL.Path == "/v0/management/auth-files/fields": + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/reset-quota": + var payload struct { + AuthIndex string `json:"auth_index"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil || payload.AuthIndex == "" { + http.Error(w, "auth_index is required", http.StatusBadRequest) + return + } + ctrl.mu.Lock() + mode := ctrl.resetMode + ctrl.resetQuotaCalls = append(ctrl.resetQuotaCalls, payload.AuthIndex) + ctrl.mu.Unlock() + switch mode { + case "http-fail": + http.Error(w, "boom", http.StatusBadRequest) + case "empty-body": + _, _ = w.Write([]byte(`{}`)) + case "wrong-index": + _ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "auth_index": "someone-else", "models": []string{}}) + case "padded-index": + _ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "auth_index": " " + payload.AuthIndex + " ", "models": []string{}}) + case "bad-status": + _ = json.NewEncoder(w).Encode(map[string]any{"status": "error", "auth_index": payload.AuthIndex}) + default: + _ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "auth_index": payload.AuthIndex, "models": []string{}}) + } + default: + http.NotFound(w, r) + } + })) +} + +func setupKeeperResetApp(t *testing.T, cpaURL string) (http.Handler, []*http.Cookie, func()) { + t.Helper() + app, err := backendApp.New() + if err != nil { + 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": cpaURL, "management_key": "test-management-key", "collector_enabled": false, + }, cookies, nil) + requestJSON(t, handler, http.MethodPut, "/api/codex-keeper/settings", map[string]any{ + "schedule_cron": "0 0 29 2 *", "dry_run": false, "quota_threshold": 100, + "worker_threads": 1, "cpa_timeout_seconds": 1, + }, cookies, nil) + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/run-once", nil, cookies, nil) + waitForKeeperAccounts(t, handler, cookies, 1) + return handler, cookies, func() { app.Close() } +} + +// TestKeeperReset drives the real reset route through its new contract: when a +// credit is available it redeems one (consume) and reports outcome=reset; when +// none is available it clears only the local cooldown (outcome=cooldown_only); a +// failed consume fails closed WITHOUT clearing the cooldown; an unconfirmed +// CLIProxyAPI reset surfaces an error; and the response wire shape stays minimal. +func TestKeeperReset(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + + authName := "reset-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-7", + "email": "reset@example.com", "account_type": "plus", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-123", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // Happy path: a credit is available, so one is redeemed (outcome=reset) and the + // cooldown is cleared exactly once. + reset := keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" || reset.Account.Name != authName || reset.Account.Outcome != "reset" { + t.Fatalf("reset response = %+v, want outcome=reset for %s", reset, authName) + } + ctrl.mu.Lock() + if ctrl.consumeCalls != 1 { + ctrl.mu.Unlock() + t.Fatalf("consume calls = %d, want exactly 1", ctrl.consumeCalls) + } + if len(ctrl.resetQuotaCalls) != 1 || ctrl.resetQuotaCalls[0] != "idx-7" { + ctrl.mu.Unlock() + t.Fatalf("reset-quota calls = %v, want one for idx-7", ctrl.resetQuotaCalls) + } + ctrl.mu.Unlock() + + // Wire minimalism: the reset response must not leak internal account fields. + raw := map[string]json.RawMessage{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &raw) + var accountFields map[string]any + if err := json.Unmarshal(raw["account"], &accountFields); err != nil { + t.Fatalf("decode reset account payload: %v", err) + } + for key := range accountFields { + switch key { + case "name", "outcome": + default: + t.Fatalf("reset response leaks internal field %q (payload %v)", key, accountFields) + } + } + + // No-credit path: the authoritative count is 0, so consume is skipped entirely + // and only the local cooldown is cleared (outcome=cooldown_only). + ctrl.mu.Lock() + ctrl.availableCount = 0 + ctrl.consumeCalls = 0 + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + reset = keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" || reset.Account.Outcome != "cooldown_only" { + t.Fatalf("no-credit reset = %+v, want ok with outcome=cooldown_only", reset) + } + ctrl.mu.Lock() + if ctrl.consumeCalls != 0 { + ctrl.mu.Unlock() + t.Fatalf("no-credit path made %d consume calls, want 0", ctrl.consumeCalls) + } + if len(ctrl.resetQuotaCalls) != 1 { + ctrl.mu.Unlock() + t.Fatalf("no-credit path reset-quota calls = %v, want exactly one", ctrl.resetQuotaCalls) + } + ctrl.mu.Unlock() + + // Unknown available count (fresh fetch failed) blocks rather than degrading to a + // cooldown-only clear. This runs BEFORE any consume-failure leaves a replayable + // pending redeem (which would legitimately bypass the fresh-count gate). + ctrl.mu.Lock() + ctrl.availableCount = 2 + ctrl.fetchMode = "fail" + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + if len(ctrl.resetQuotaCalls) != 0 { + ctrl.mu.Unlock() + t.Fatalf("unknown-count path reached /reset-quota; it must block") + } + ctrl.fetchMode = "ok" + ctrl.mu.Unlock() + + // Fail-closed: when a credit is available but the consume fails (inner non-2xx + // or an unrecognized code), the whole operation errors and NEVER reaches + // /reset-quota — the cooldown is not cleared on a half-done redemption. This leaves + // a pending redeem (unknown outcome), which is resolved right after. + for _, mode := range []string{"http-fail", "unknown-code"} { + ctrl.mu.Lock() + ctrl.availableCount = 2 + ctrl.consumeMode = mode + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + calls := ctrl.resetQuotaCalls + ctrl.mu.Unlock() + if len(calls) != 0 { + t.Fatalf("consume mode %s reached /reset-quota (%v); it must fail closed first", mode, calls) + } + } + // Resolve the pending left by the fail-closed loop with a clean successful reset so + // the following assertions start without a replayable pending. + ctrl.mu.Lock() + ctrl.consumeMode = "ok" + ctrl.availableCount = 2 + ctrl.mu.Unlock() + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &keeperResetResponse{}) + + // A credit is available, so consume succeeds; any CLIProxyAPI outcome short of a + // confirmed cooldown clear is then an irreversible PARTIAL (409), not a plain error. + for _, mode := range []string{"http-fail", "empty-body", "wrong-index", "bad-status", "padded-index"} { + ctrl.mu.Lock() + ctrl.availableCount = 2 + ctrl.resetMode = mode + ctrl.mu.Unlock() + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusConflict) + } + ctrl.mu.Lock() + ctrl.resetMode = "ok" + ctrl.mu.Unlock() + + // Bad requests are rejected before any CLIProxyAPI reset call. + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": ""}, cookies, http.StatusUnprocessableEntity) + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": "nope.json"}, cookies, http.StatusNotFound) +} + +// keeperResetInspectAccountsResponse reads the reset_credit count from the +// accounts endpoint so a test can assert the post-reset inspection refreshed it. +type keeperResetInspectAccountsResponse struct { + Items []struct { + Name string `json:"name"` + ResetCreditCount *int `json:"reset_credit_count"` + } `json:"items"` +} + +// TestKeeperResetQuotaTriggersInspection proves a successful reset synchronously +// re-inspects just that account: after redeeming one of two credits the DB +// snapshot is refreshed from a live fetch (2 -> 1), so the frontend's follow-up +// accounts reload shows the post-reset state rather than the stale pre-reset one. +func TestKeeperResetQuotaTriggersInspection(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + + const authName = "inspect-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-9", + "email": "inspect@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-9", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // After the initial inspection the snapshot shows 2 available credits. + before := keeperResetInspectAccountsResponse{} + requestJSON(t, handler, http.MethodGet, "/api/codex-keeper/accounts", nil, cookies, &before) + if len(before.Items) != 1 || before.Items[0].ResetCreditCount == nil || *before.Items[0].ResetCreditCount != 2 { + t.Fatalf("pre-reset reset_credit_count = %+v, want 2", before.Items) + } + ctrl.mu.Lock() + fetchesBeforeReset := ctrl.resetCreditFetch + ctrl.mu.Unlock() + + // Reset succeeds; it redeems one credit and then re-inspects this account. + reset := keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" || reset.Account.Outcome != "reset" { + t.Fatalf("reset = %+v, want outcome=reset", reset) + } + + ctrl.mu.Lock() + fetchesAfterReset := ctrl.resetCreditFetch + ctrl.mu.Unlock() + if fetchesAfterReset <= fetchesBeforeReset { + t.Fatalf("reset-credit fetches did not increase after reset (%d -> %d): no post-reset inspection ran", fetchesBeforeReset, fetchesAfterReset) + } + + // The accounts readback now reflects the post-reset live fetch (1 credit left). + after := keeperResetInspectAccountsResponse{} + requestJSON(t, handler, http.MethodGet, "/api/codex-keeper/accounts", nil, cookies, &after) + if len(after.Items) != 1 || after.Items[0].ResetCreditCount == nil || *after.Items[0].ResetCreditCount != 1 { + t.Fatalf("post-reset reset_credit_count = %+v, want 1 (refreshed by the chained inspection)", after.Items) + } +} + +// postKeeperReset drives the reset route and returns only the HTTP status, so it +// is safe to call from goroutines (no t.Fatal off the test goroutine). +func postKeeperReset(handler http.Handler, cookies []*http.Cookie, authName string) int { + req := httptest.NewRequest(http.MethodPost, "/api/codex-keeper/reset-quota", strings.NewReader(`{"auth_name":"`+authName+`"}`)) + req.Header.Set("Content-Type", "application/json") + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec.Code +} + +// TestKeeperResetConcurrentSingleConsume proves the per-auth lock makes concurrent +// resets of the SAME account redeem at most one credit: while one request holds the +// lock (blocked in its fresh reset-credit fetch), every contending request returns +// 409 without reaching consume. +func TestKeeperResetConcurrentSingleConsume(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "race-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-race", + "email": "race@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-race", + } + ctrl := &keeperResetControl{availableCount: 3, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // Arm the gate only AFTER setup, so the run-once inspection's fetch is not held. + ctrl.mu.Lock() + ctrl.gateReached = make(chan struct{}) + ctrl.gateRelease = make(chan struct{}) + ctrl.gateOnce = &sync.Once{} + ctrl.mu.Unlock() + + // Winner: acquires the per-auth lock and blocks inside its fresh fetch. + winnerCh := make(chan int, 1) + go func() { winnerCh <- postKeeperReset(handler, cookies, authName) }() + <-ctrl.gateReached // the winner now holds the per-auth lock + + // Contenders: fire concurrently while the winner holds the lock; all must 409. + const contenders = 4 + var wg sync.WaitGroup + statuses := make([]int, contenders) + for i := 0; i < contenders; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + statuses[idx] = postKeeperReset(handler, cookies, authName) + }(i) + } + wg.Wait() + for i, s := range statuses { + if s != http.StatusConflict { + t.Fatalf("contender %d status = %d, want 409 (per-auth lock held)", i, s) + } + } + + close(ctrl.gateRelease) // let the winner finish + if s := <-winnerCh; s != http.StatusOK { + t.Fatalf("winner status = %d, want 200", s) + } + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 1 { + t.Fatalf("consume calls under concurrency = %d, want exactly 1", ctrl.consumeCalls) + } +} + +// TestKeeperResetLostResponseReusesRedeemID proves the persistent redeem ledger +// closes the cross-operation double-consume window: when a consume's response is +// lost (outer 5xx = unknown), the redeem stays pending and the NEXT reset reuses the +// SAME redeem_request_id (OpenAI then returns already_redeemed idempotently) instead +// of minting a new key that could burn a second credit. +func TestKeeperResetLostResponseReusesRedeemID(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "lost-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-lost", + "email": "lost@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-lost", + } + ctrl := &keeperResetControl{ + availableCount: 2, fetchMode: "ok", consumeMode: "lost", resetMode: "ok", + } + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // First attempt: every consume attempt loses its response (unknown) → fail closed, + // ledger left pending. + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + + // The account recovers; the next attempt must reuse the pending redeem_request_id + // so OpenAI resolves it idempotently as already_redeemed instead of burning a + // second credit. + ctrl.mu.Lock() + ctrl.consumeMode = "ok" + ctrl.consumeSuccessCode = "already_redeemed" + ctrl.mu.Unlock() + + reset := keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" || reset.Account.Outcome != "already_redeemed" { + t.Fatalf("retry reset = %+v, want outcome=already_redeemed", reset) + } + + ctrl.mu.Lock() + ids := append([]string{}, ctrl.consumeRequestIDs...) + ctrl.mu.Unlock() + if len(ids) < 2 { + t.Fatalf("consume calls = %d, want >= 2 (lost + retry)", len(ids)) + } + // Every consume attempt (the lost operation's retries AND the recovery operation) + // must carry the exact same redeem_request_id. + for i, id := range ids { + if id == "" || id != ids[0] { + t.Fatalf("redeem_request_id not reused (call %d = %q, first = %q); all=%v", i, id, ids[0], ids) + } + } +} + +// TestKeeperResetConsumeLogRedaction is the canary: a failed consume whose inner +// body carries a sensitive sentinel must never write that raw body to the Keeper +// log; only a stable classification (reset-consume … inner_status) is recorded. +func TestKeeperResetConsumeLogRedaction(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "redact-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-redact", + "email": "redact@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-redact", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "http-fail", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + + var status struct { + Logs []string `json:"logs"` + } + requestJSON(t, handler, http.MethodGet, "/api/codex-keeper/status", nil, cookies, &status) + joined := strings.Join(status.Logs, "\n") + if strings.Contains(joined, keeperConsumeSensitiveSentinel) { + t.Fatalf("sensitive inner body leaked into Keeper log") + } + if !strings.Contains(joined, "reset-consume") || !strings.Contains(joined, "inner_status") { + t.Fatalf("expected stable reset-consume/inner_status classification in log; logs=%v", status.Logs) + } +} + +// TestKeeperResetAuthIndexChangeProceeds proves auth_index is treated as a ROUTING +// selector, not the resource identity: when only the auth_index changes (file +// rename/move/reorder) but the account_id is unchanged, the reset proceeds normally using +// the fresh auth_index for routing — it does NOT fail closed. (Only an account_id change +// fails closed; see TestKeeperResetAccountIDMismatchFailsClosed.) +func TestKeeperResetAuthIndexChangeProceeds(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "moved-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-orig", + "email": "moved@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-moved", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // The account was inspected with idx-orig; the file is now routed via a different + // auth_index, but it is the SAME account (account_id unchanged). + ctrl.mu.Lock() + ctrl.authIndexOverride = "idx-different" + ctrl.consumeCalls = 0 + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + + reset := keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" || reset.Account.Outcome != "reset" { + t.Fatalf("auth_index-only change reset = %+v, want ok/reset (index is routing, not identity)", reset) + } + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 1 { + t.Fatalf("consume calls = %d, want 1 (index change should proceed)", ctrl.consumeCalls) + } + // The cooldown clear MUST route with the FRESH index (idx-different), not the stale DB + // value — otherwise a reindex clears the wrong account / leaves a partial. + if len(ctrl.resetQuotaCalls) != 1 || ctrl.resetQuotaCalls[0] != "idx-different" { + t.Fatalf("/reset-quota routed with %v, want [idx-different] (fresh index)", ctrl.resetQuotaCalls) + } +} + +// TestKeeperResetMissingAccountID proves the reset fails closed when no account_id can +// be resolved (the consume header requires it) — no consume, no cooldown clear. +func TestKeeperResetMissingAccountID(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "noacct-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-noacct", + "email": "noacct@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-noacct", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // Now the fresh detail drops account_id (the list entry never had one). + ctrl.mu.Lock() + ctrl.omitAccountID = true + ctrl.consumeCalls = 0 + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 0 || len(ctrl.resetQuotaCalls) != 0 { + t.Fatalf("missing account_id must fail closed (consume=%d, reset-quota=%v)", ctrl.consumeCalls, ctrl.resetQuotaCalls) + } +} + +// TestKeeperResetAccountIDMismatchFailsClosed proves a stale page cannot reset a +// swapped-out account: when the fresh account_id differs from the DB row's stored +// account_id (the file was rebound to a different account under the same auth_index), the +// reset fails closed (account_id mismatch) and never consumes or clears the cooldown. +func TestKeeperResetAccountIDMismatchFailsClosed(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "switch-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-switch", + "email": "switch@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-A", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // The run-once inspection stored account_id=acct-A. The file is now rebound to a + // different account (same auth_index), but the DB row (and the user's page) still + // shows acct-A. + ctrl.mu.Lock() + ctrl.accountIDOverride = "acct-B" + ctrl.consumeCalls = 0 + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 0 || len(ctrl.resetQuotaCalls) != 0 { + t.Fatalf("account_id mismatch must fail closed (consume=%d, reset-quota=%v)", ctrl.consumeCalls, ctrl.resetQuotaCalls) + } +} + +// TestKeeperResetRawJWTAccountIDConflictFailsClosed proves the deceptive raw-JWT case: the +// download detail has top-level account_id=A but an id_token raw JWT whose account claim is B, +// carried in the REAL on-wire location — nested under the OpenAI auth namespace +// (https://api.openai.com/auth.chatgpt_account_id) rather than a flattened top-level claim (the +// list endpoint flattens; the raw download JWT nests). The identity resolver decodes the JWT, +// sees the top-level A conflict with the token's own B, and fails closed — NO consume, NO +// /reset-quota — instead of acting on A's header/route with B's token. +func TestKeeperResetRawJWTAccountIDConflictFailsClosed(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "deceptive-jwt.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-jwt", + "email": "jwt@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-A", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // After the clean seed inspection, the download starts returning a raw JWT id_token whose + // claim (acct-B) contradicts the still-top-level account_id (acct-A) — a deceptive entry. + ctrl.mu.Lock() + ctrl.detailIDTokenOverride = rawJWTWithClaims(t, map[string]any{"https://api.openai.com/auth": map[string]any{"chatgpt_account_id": "acct-B"}}) + ctrl.consumeCalls = 0 + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 0 || len(ctrl.resetQuotaCalls) != 0 { + t.Fatalf("raw-JWT account_id conflict must fail closed (consume=%d, reset-quota=%v)", ctrl.consumeCalls, ctrl.resetQuotaCalls) + } +} + +// TestKeeperResetPendingReplayedWhenCountZero pins the control-flow rule that an +// identity-matched pending redeem is replayed with its original key even when the +// fresh available_count is 0: a lost first response that actually consumed the LAST +// credit is recovered as outcome=already_redeemed, never short-circuited into +// a cooldown-only clear. +func TestKeeperResetPendingReplayedWhenCountZero(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "zero-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-zero", + "email": "zero@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-zero", + } + ctrl := &keeperResetControl{availableCount: 1, fetchMode: "ok", consumeMode: "lost", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // First reset: the only credit's consume response is lost → pending, fail closed. + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + lostIDs := append([]string{}, ctrl.consumeRequestIDs...) + consumesBefore := ctrl.consumeCalls + // The lost attempt actually consumed the last credit, so the fresh count is now 0 + // and a replay resolves it idempotently. + ctrl.availableCount = 0 + ctrl.consumeMode = "ok" + ctrl.consumeSuccessCode = "already_redeemed" + ctrl.mu.Unlock() + + // Second reset: count is 0, but the pending redeem MUST still be replayed. + reset := keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" || reset.Account.Outcome != "already_redeemed" { + t.Fatalf("count-zero replay = %+v, want outcome=already_redeemed (not short-circuited to cooldown-only)", reset) + } + + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls <= consumesBefore { + t.Fatalf("pending redeem was NOT replayed at count=0 (consume calls %d -> %d)", consumesBefore, ctrl.consumeCalls) + } + if len(lostIDs) == 0 || ctrl.consumeRequestIDs[len(ctrl.consumeRequestIDs)-1] != lostIDs[0] { + t.Fatalf("replay used a different redeem_request_id than the pending one: pending=%v all=%v", lostIDs, ctrl.consumeRequestIDs) + } +} + +// TestKeeperResetAccountIDCrossCheck proves the list id_token.chatgpt_account_id is an +// OPTIONAL second cross-check: a matching claim passes, a conflicting claim fails +// closed, and (via the other reset tests where the list omits it) an absent claim never +// blocks a legitimate credential whose download account_id is present. +func TestKeeperResetAccountIDCrossCheck(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "xcheck-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-xcheck", + "email": "xcheck@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-xcheck", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // Matching list claim: reset proceeds and consumes. + ctrl.mu.Lock() + ctrl.listAccountIDClaim = "acct-xcheck" + ctrl.mu.Unlock() + reset := keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" || reset.Account.Outcome != "reset" { + t.Fatalf("matching list account_id claim reset = %+v, want outcome=reset", reset) + } + + // Conflicting list claim: fail closed, no consume, no cooldown clear. + ctrl.mu.Lock() + ctrl.listAccountIDClaim = "acct-DIFFERENT" + ctrl.consumeCalls = 0 + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 0 || len(ctrl.resetQuotaCalls) != 0 { + t.Fatalf("account_id conflict must fail closed (consume=%d, reset-quota=%v)", ctrl.consumeCalls, ctrl.resetQuotaCalls) + } +} + +// TestKeeperResetPendingReplayedWhenFetchUnavailable proves an identity-matched pending +// redeem is replayed with its original key even when the fresh reset-credit GET is +// temporarily unavailable (ok=false): the count gate applies only to a NEW operation, so +// a lost-response pending is not stranded while the count endpoint is down. +func TestKeeperResetPendingReplayedWhenFetchUnavailable(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "fetchdown-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-fetchdown", + "email": "fetchdown@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-fetchdown", + } + ctrl := &keeperResetControl{availableCount: 1, fetchMode: "ok", consumeMode: "lost", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // First reset: the consume response is lost → pending, fail closed. + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + lostIDs := append([]string{}, ctrl.consumeRequestIDs...) + // The count endpoint is now temporarily unavailable, but the account recovers. + ctrl.fetchMode = "fail" + ctrl.consumeMode = "ok" + ctrl.consumeSuccessCode = "already_redeemed" + ctrl.mu.Unlock() + + // Second reset: fresh GET would fail, but the pending redeem MUST still be replayed. + reset := keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" || reset.Account.Outcome != "already_redeemed" { + t.Fatalf("pending replay with fetch down = %+v, want outcome=already_redeemed", reset) + } + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if len(lostIDs) == 0 || ctrl.consumeRequestIDs[len(ctrl.consumeRequestIDs)-1] != lostIDs[0] { + t.Fatalf("replay used a different redeem_request_id: pending=%v all=%v", lostIDs, ctrl.consumeRequestIDs) + } +} + +// TestKeeperResetNonCodexFailsClosed proves the reset resolver only acts on a Codex +// list entry: if the auth_name's remote type has drifted to another provider, the reset +// fails closed and never sends a non-Codex token/index to the OpenAI consume. +func TestKeeperResetNonCodexFailsClosed(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "drift-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-drift", + "email": "drift@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-drift", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // The account was inspected as Codex; now its remote list type has drifted. + ctrl.mu.Lock() + ctrl.listTypeOverride = "gemini" + ctrl.consumeCalls = 0 + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 0 || len(ctrl.resetQuotaCalls) != 0 { + t.Fatalf("non-Codex reset must fail closed (consume=%d, reset-quota=%v)", ctrl.consumeCalls, ctrl.resetQuotaCalls) + } +} + +// TestKeeperResetOutcomeCodes proves the reset DTO returns a distinct outcome per real +// business result — reset, already_redeemed, no_credit, nothing_to_reset are NOT +// collapsed into one another, and cooldown_only is distinct from no_credit. +func TestKeeperResetOutcomeCodes(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "outcome-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-outcome", + "email": "outcome@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-outcome", + } + ctrl := &keeperResetControl{availableCount: 5, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + cases := []struct { + name string + consumeMode string + successCode string + available int + want string + }{ + {"reset", "ok", "reset", 5, "reset"}, + {"already_redeemed", "ok", "already_redeemed", 5, "already_redeemed"}, + {"no_credit", "no-credit", "", 5, "no_credit"}, + {"nothing_to_reset", "ok", "nothing_to_reset", 5, "nothing_to_reset"}, + {"cooldown_only", "ok", "reset", 0, "cooldown_only"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctrl.mu.Lock() + ctrl.availableCount = tc.available + ctrl.consumeMode = tc.consumeMode + ctrl.consumeSuccessCode = tc.successCode + ctrl.mu.Unlock() + reset := keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" || reset.Account.Outcome != tc.want { + t.Fatalf("outcome = %+v, want %q", reset, tc.want) + } + }) + } +} + +// TestKeeperResetDryRunFailsClosed proves a manual reset is blocked under dry-run so an +// admin testing the keeper never silently burns a paid credit. +func TestKeeperResetDryRunFailsClosed(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "dryrun-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-dryrun", + "email": "dryrun@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-dryrun", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // Turn dry-run on. + requestJSON(t, handler, http.MethodPut, "/api/codex-keeper/settings", map[string]any{ + "schedule_cron": "0 0 29 2 *", "dry_run": true, "quota_threshold": 100, + "worker_threads": 1, "cpa_timeout_seconds": 1, + }, cookies, nil) + ctrl.mu.Lock() + ctrl.consumeCalls = 0 + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 0 || len(ctrl.resetQuotaCalls) != 0 { + t.Fatalf("dry-run reset must fail closed (consume=%d, reset-quota=%v)", ctrl.consumeCalls, ctrl.resetQuotaCalls) + } +} + +// TestKeeperResetResolverGuardsFailClosed proves the identity resolver fails closed on an +// ambiguous/deceptive remote response: a duplicate name, a self-conflicting auth_index +// alias, or a missing access_token must never reach the consume. +func TestKeeperResetResolverGuardsFailClosed(t *testing.T) { + cases := []struct { + name string + apply func(*keeperResetControl) + }{ + {"duplicate-name", func(c *keeperResetControl) { c.duplicateListName = true }}, + {"alias-conflict", func(c *keeperResetControl) { c.detailAliasConflict = true }}, + {"missing-access-token", func(c *keeperResetControl) { c.omitAccessToken = true }}, + {"detail-name-mismatch", func(c *keeperResetControl) { c.detailNameOverride = "other-account.json" }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "guard-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-guard", + "email": "guard@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-guard", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + ctrl.mu.Lock() + tc.apply(ctrl) + ctrl.consumeCalls = 0 + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 0 || len(ctrl.resetQuotaCalls) != 0 { + t.Fatalf("%s must fail closed (consume=%d, reset-quota=%v)", tc.name, ctrl.consumeCalls, ctrl.resetQuotaCalls) + } + }) + } +} + +// TestKeeperResetPartialAuditOnCooldownFailure proves that when a credit was consumed but +// the local cooldown clear then failed, the audit records an irreversible PARTIAL (not a +// plain error), so the money-affecting half-completion is not masked. +func TestKeeperResetPartialAuditOnCooldownFailure(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "partial-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-partial", + "email": "partial@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-partial", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "http-fail"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // Consume succeeds (reset) but the cooldown clear fails → 409 partial to the caller. + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusConflict) + + var status struct { + Logs []string `json:"logs"` + } + requestJSON(t, handler, http.MethodGet, "/api/codex-keeper/status", nil, cookies, &status) + joined := strings.Join(status.Logs, "\n") + if !strings.Contains(joined, "cooldown_failed_after_consume") || !strings.Contains(joined, "result=partial") { + t.Fatalf("cooldown-after-consume must audit an irreversible partial; logs=%v", status.Logs) + } + // The handler must NOT overwrite the partial with a generic result=error line. + if strings.Contains(joined, "reset-quota") && strings.Contains(joined, "result=error reason=validation_error") { + t.Fatalf("partial was masked by a generic result=error audit; logs=%v", status.Logs) + } +} + +func deleteKeeperAccountReq(handler http.Handler, cookies []*http.Cookie, authName string) int { + req := httptest.NewRequest(http.MethodDelete, "/api/codex-keeper/accounts/"+authName, nil) + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec.Code +} + +// TestKeeperDeleteConflictsWithInFlightReset proves delete shares the per-auth fence with +// reset: while a reset holds the lock mid-consume, a concurrent delete of the same account +// is refused (409) so it can never drop the in-flight redeem ledger key; after the reset +// finishes the lock is free again. +func TestKeeperDeleteConflictsWithInFlightReset(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "fence-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-fence", + "email": "fence@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-fence", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // Arm the gate so a reset holds the per-auth lock while blocked in its fresh fetch. + ctrl.mu.Lock() + ctrl.gateReached = make(chan struct{}) + ctrl.gateRelease = make(chan struct{}) + ctrl.gateOnce = &sync.Once{} + ctrl.mu.Unlock() + + winnerCh := make(chan int, 1) + go func() { winnerCh <- postKeeperReset(handler, cookies, authName) }() + <-ctrl.gateReached // the reset now holds the per-auth lock + + if s := deleteKeeperAccountReq(handler, cookies, authName); s != http.StatusConflict { + t.Fatalf("delete during in-flight reset = %d, want 409 (per-auth fence)", s) + } + + close(ctrl.gateRelease) + if s := <-winnerCh; s != http.StatusOK { + t.Fatalf("reset winner = %d, want 200", s) + } +} + +// TestKeeperResetReplaysPendingAcrossIndexChange proves the redeem ledger is keyed by the +// stable account_id, not the routing auth_index: after a lost consume leaves a pending +// redeem, a later reset whose auth_index has CHANGED (file rename/move/reorder) but whose +// account_id is unchanged still finds and replays the SAME redeem_request_id (recovered as +// already_redeemed) rather than minting a new key that could double-consume. +func TestKeeperResetReplaysPendingAcrossIndexChange(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "reindex-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-1", + "email": "reindex@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-stable", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "lost", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // First reset at auth_index idx-1: consume lost → pending (keyed by acct-stable). + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + lostID := ctrl.consumeRequestIDs[len(ctrl.consumeRequestIDs)-1] + // The file is renamed/reordered → CPA gives it a NEW auth_index, but it is the SAME + // OpenAI account. The account recovers so the replay resolves. + ctrl.authIndexOverride = "idx-2" + ctrl.consumeMode = "ok" + ctrl.consumeSuccessCode = "already_redeemed" + ctrl.mu.Unlock() + + reset := keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" || reset.Account.Outcome != "already_redeemed" { + t.Fatalf("post-reindex reset = %+v, want already_redeemed (pending replayed across index change)", reset) + } + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + replayID := ctrl.consumeRequestIDs[len(ctrl.consumeRequestIDs)-1] + if lostID == "" || replayID != lostID { + t.Fatalf("index change lost the pending key: first %q, replay %q (should reuse the same key)", lostID, replayID) + } +} + +// newTwoFileSameAccountCPA serves two auth files (fileA idx-A, fileB idx-B) that BOTH map to +// the same OpenAI account_id, so a test can exercise the account-level fence: the fresh +// reset-credit fetch is gated (fileA blocks in it while holding the account lock). +func newTwoFileSameAccountCPA(t *testing.T, fileA, fileB, accountID string, ctrl *keeperResetControl) *httptest.Server { + t.Helper() + detailFor := func(name, idx string) map[string]any { + return map[string]any{ + "name": name, "type": "codex", "auth_index": idx, "account_id": accountID, + "account_type": "pro", "disabled": false, "priority": 1, "access_token": "test-token", + } + } + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": fileA, "type": "codex"}, {"name": fileB, "type": "codex"}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + ctrl.mu.Lock() + atDownload := ctrl.gateAtDownload + gReached, gRelease, gOnce := ctrl.gateReached, ctrl.gateRelease, ctrl.gateOnce + ctrl.mu.Unlock() + if atDownload && gRelease != nil { + gOnce.Do(func() { close(gReached) }) + <-gRelease + } + name := r.URL.Query().Get("name") + idx := "idx-A" + if name == fileB { + idx = "idx-B" + } + _ = json.NewEncoder(w).Encode(detailFor(name, idx)) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + var p struct { + URL string `json:"url"` + } + _ = json.NewDecoder(r.Body).Decode(&p) + switch { + case strings.Contains(p.URL, "rate-limit-reset-credits/consume"): + ctrl.mu.Lock() + ctrl.consumeCalls++ + ctrl.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"code": "reset", "windows_reset": []any{}}}) + case strings.Contains(p.URL, "rate-limit-reset-credits"): + ctrl.mu.Lock() + atDownload := ctrl.gateAtDownload + gReached, gRelease, gOnce := ctrl.gateReached, ctrl.gateRelease, ctrl.gateOnce + ctrl.mu.Unlock() + if !atDownload && gRelease != nil { + gOnce.Do(func() { close(gReached) }) + <-gRelease + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"available_count": 2, "credits": []map[string]any{{"id": "c1", "reset_type": "codex_rate_limits", "status": "available", "granted_at": "2026-08-22T00:08:46Z", "expires_at": "2026-09-21T00:08:46Z"}}}}) + default: + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}}}) + } + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/reset-quota": + var payload struct { + AuthIndex string `json:"auth_index"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + ctrl.mu.Lock() + ctrl.resetQuotaCalls = append(ctrl.resetQuotaCalls, payload.AuthIndex) + ctrl.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "auth_index": payload.AuthIndex, "models": []string{}}) + case r.Method == http.MethodPatch && r.URL.Path == "/v0/management/auth-files/fields": + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + default: + http.NotFound(w, r) + } + })) +} + +// TestKeeperResetAccountFenceAcrossTwoFiles proves the account-level fence: two different +// files (auth_names) that map to the SAME OpenAI account cannot both consume concurrently. +// While fileA holds the account fence (blocked in its fresh fetch), a reset of fileB — a +// different auth_name, same account — is refused (409), so at most one credit is consumed. +func TestKeeperResetAccountFenceAcrossTwoFiles(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + ctrl := &keeperResetControl{} + cpa := newTwoFileSameAccountCPA(t, "a.json", "b.json", "acct-shared", ctrl) + defer cpa.Close() + + app, err := backendApp.New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + 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) + requestJSON(t, handler, http.MethodPut, "/api/codex-keeper/settings", map[string]any{ + "schedule_cron": "0 0 29 2 *", "dry_run": false, "quota_threshold": 100, + "worker_threads": 1, "cpa_timeout_seconds": 1, + }, cookies, nil) + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/run-once", nil, cookies, nil) + waitForKeeperAccounts(t, handler, cookies, 2) + + // Both files were inspected (state.AccountID = acct-shared for each). Arm the gate so the + // first reset blocks inside its fresh fetch while holding the account fence. + ctrl.mu.Lock() + ctrl.gateReached = make(chan struct{}) + ctrl.gateRelease = make(chan struct{}) + ctrl.gateOnce = &sync.Once{} + ctrl.mu.Unlock() + + winnerCh := make(chan int, 1) + go func() { winnerCh <- postKeeperReset(handler, cookies, "a.json") }() + <-ctrl.gateReached // fileA now holds the account fence, blocked in fetch + + // fileB (different auth_name, SAME account) must be refused while fileA holds the fence. + if s := postKeeperReset(handler, cookies, "b.json"); s != http.StatusConflict { + t.Fatalf("second file for the same account = %d, want 409 (account fence)", s) + } + + close(ctrl.gateRelease) + if s := <-winnerCh; s != http.StatusOK { + t.Fatalf("fileA reset = %d, want 200", s) + } + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 1 { + t.Fatalf("consume calls across two files of one account = %d, want exactly 1", ctrl.consumeCalls) + } +} + +// TestKeeperResetAccountFenceBeforeResolve is the deterministic PRE-fence-window right-cause: +// the account fence keys on the stored account_id and is taken BEFORE the fresh list/download +// resolve. fileA is held inside its auth-files DOWNLOAD (identity resolution, before any fetch) +// while already holding the fence; a reset of fileB — a different auth_name, same account — is +// refused (409) even though fileA has not reached its consume yet. Exactly one credit is +// consumed. Before the fix (fence taken after resolve), fileB could resolve and consume in this +// window, yielding a second consume. +func TestKeeperResetAccountFenceBeforeResolve(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + ctrl := &keeperResetControl{} + cpa := newTwoFileSameAccountCPA(t, "a.json", "b.json", "acct-shared", ctrl) + defer cpa.Close() + + app, err := backendApp.New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + 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) + requestJSON(t, handler, http.MethodPut, "/api/codex-keeper/settings", map[string]any{ + "schedule_cron": "0 0 29 2 *", "dry_run": false, "quota_threshold": 100, + "worker_threads": 1, "cpa_timeout_seconds": 1, + }, cookies, nil) + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/run-once", nil, cookies, nil) + waitForKeeperAccounts(t, handler, cookies, 2) + + // Gate at the DOWNLOAD stage so fileA is held inside identity resolution — before its fetch + // — while already holding the account fence. + ctrl.mu.Lock() + ctrl.gateAtDownload = true + ctrl.gateReached = make(chan struct{}) + ctrl.gateRelease = make(chan struct{}) + ctrl.gateOnce = &sync.Once{} + ctrl.mu.Unlock() + + winnerCh := make(chan int, 1) + go func() { winnerCh <- postKeeperReset(handler, cookies, "a.json") }() + <-ctrl.gateReached // fileA holds the account fence, blocked in its download (pre-fetch) + + if s := postKeeperReset(handler, cookies, "b.json"); s != http.StatusConflict { + t.Fatalf("second file for the same account, blocked in pre-resolve = %d, want 409 (fence before resolve)", s) + } + + close(ctrl.gateRelease) + if s := <-winnerCh; s != http.StatusOK { + t.Fatalf("fileA reset = %d, want 200", s) + } + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 1 { + t.Fatalf("consume calls with a pre-resolve fence = %d, want exactly 1", ctrl.consumeCalls) + } +} diff --git a/backend/internal/app/codex_keeper_test.go b/backend/internal/app/codex_keeper_test.go index cf5562d3..fd888ccd 100644 --- a/backend/internal/app/codex_keeper_test.go +++ b/backend/internal/app/codex_keeper_test.go @@ -375,8 +375,13 @@ func TestKeeperRunMaintainsSystemPriorityRules(t *testing.T) { case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": var payload struct { AuthIndex string `json:"auth_index"` + URL string `json:"url"` } _ = json.NewDecoder(r.Body).Decode(&payload) + if strings.Contains(payload.URL, "rate-limit-reset-credits") { + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"available_count": 0, "credits": []any{}}}) + return + } usedPercent := usagePercents[payload.AuthIndex] if usedPercent == 0 { usedPercent = 10 @@ -526,8 +531,13 @@ func TestKeeperRefreshAccountsOnlyProcessesSelectedAuths(t *testing.T) { case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": var payload struct { AuthIndex string `json:"auth_index"` + URL string `json:"url"` } _ = json.NewDecoder(r.Body).Decode(&payload) + if strings.Contains(payload.URL, "rate-limit-reset-credits") { + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"available_count": 0, "credits": []any{}}}) + return + } mu.Lock() usageCalls[payload.AuthIndex]++ mu.Unlock() @@ -640,8 +650,13 @@ func TestKeeperRefreshAccountsDisablesBadCredentialAndAppliesPriorityPolicy(t *t case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": var payload struct { AuthIndex string `json:"auth_index"` + URL string `json:"url"` } _ = json.NewDecoder(r.Body).Decode(&payload) + if strings.Contains(payload.URL, "rate-limit-reset-credits") { + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"available_count": 0, "credits": []any{}}}) + return + } statusCode := 200 usedPercent := 100 if payload.AuthIndex == "bad-token.json" { diff --git a/backend/internal/app/frontend_test.go b/backend/internal/app/frontend_test.go index 1daffdfc..321d5097 100644 --- a/backend/internal/app/frontend_test.go +++ b/backend/internal/app/frontend_test.go @@ -1,6 +1,7 @@ package app import ( + "context" "net/http/httptest" "os" "path/filepath" @@ -96,3 +97,98 @@ func TestHandleSPAFrontendDistOverrideUsesExternalFiles(t *testing.T) { t.Fatalf("body = %q", body) } } + +func TestReplaceTitleTag(t *testing.T) { + tests := []struct { + name string + input string + title string + want string + }{ + { + name: "replaces existing title", + input: `CPA-Helper`, + title: "My Product", + want: `My Product`, + }, + { + name: "no title tag returns unchanged", + input: ``, + title: "My Product", + want: ``, + }, + { + name: "no closing tag returns unchanged", + input: `CPA-Helper</head></html>`, + title: "My Product", + want: `<html><head><title>CPA-Helper</head></html>`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := string(replaceTitleTag([]byte(tc.input), tc.title)) + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestInjectBrandingNilDB(t *testing.T) { + // When a.db is nil (no database), injectBranding returns the HTML unchanged. + app := &App{} + input := `<html><head><title>CPA-Helper` + got := string(app.injectBranding(context.Background(), []byte(input))) + if got != input { + t.Fatalf("expected unchanged HTML when db is nil, got %q", got) + } +} + +func TestInjectProductInfoScript(t *testing.T) { + tests := []struct { + name string + input string + productName string + productLogo string + wantContain string + }{ + { + name: "injects script before ", + input: `CPA-Helper`, + productName: "My Product", + productLogo: "/logo.png", + wantContain: ``, + }, + { + name: "no tag returns unchanged", + input: ``, + productName: "My Product", + productLogo: "/logo.png", + wantContain: ``, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := string(injectProductInfoScript([]byte(tc.input), tc.productName, tc.productLogo)) + if !strings.Contains(got, tc.wantContain) { + t.Fatalf("got %q, want it to contain %q", got, tc.wantContain) + } + }) + } +} + +func TestHandleSPAInjectsTitle(t *testing.T) { + app := &App{frontendFS: fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte(`CPA-Helper`)}, + }} + + req := httptest.NewRequest("GET", "http://example.com/", nil) + recorder := httptest.NewRecorder() + if err := app.handleSPA(recorder, req); err != nil { + t.Fatal(err) + } + body := recorder.Body.String() + if !strings.Contains(body, "CPA-Helper") { + t.Fatalf("body = %q", body) + } +} diff --git a/backend/internal/app/migrations_test.go b/backend/internal/app/migrations_test.go index b9435b57..bc88d18e 100644 --- a/backend/internal/app/migrations_test.go +++ b/backend/internal/app/migrations_test.go @@ -419,3 +419,104 @@ func testColumnExists(t *testing.T, db *sql.DB, table, column string) bool { func ensureTestDir(path string) error { return os.MkdirAll(path, 0o755) } + +// TestRollbackToPreConsumeRestoresCompatSchema pins the PR #12 rollback contract: +// the previous binary (a996697, target 202609040002) refuses to start against a +// newer goose version AND its /accounts SELECTs codex_keeper_quota_resets, so a +// rollback MUST run the Down migrations first. This proves Down from the PR #12 head +// back to 202609040002 restores the (empty) compat table the old binary needs and +// removes the PR #12 objects. Down restores schema only, never historical counts. +func TestRollbackToPreConsumeRestoresCompatSchema(t *testing.T) { + ctx := context.Background() + db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "rollback.sqlite3")) + if err != nil { + t.Fatal(err) + } + db.SetMaxOpenConns(1) + defer db.Close() + + goose.SetBaseFS(backendMigrations.FS) + if err := goose.SetDialect("sqlite3"); err != nil { + t.Fatal(err) + } + if err := goose.UpToContext(ctx, db, ".", backendMigrations.LatestVersion); err != nil { + t.Fatalf("migrate up to head: %v", err) + } + // PR #12 head schema. + if !testColumnExists(t, db, "codex_keeper_auth_states", "subscription_active_until") { + t.Fatal("head is missing subscription_active_until") + } + if !testColumnExists(t, db, "codex_keeper_auth_states", "account_id") { + t.Fatal("head is missing codex_keeper_auth_states.account_id") + } + for _, col := range []string{"provider", "antigravity_quota", "antigravity_identity_digest"} { + if !testColumnExists(t, db, "codex_keeper_auth_states", col) { + t.Fatalf("head is missing codex_keeper_auth_states.%s (migration 202609060005)", col) + } + } + if !testTableExists(t, db, "codex_keeper_reset_redeems") { + t.Fatal("head is missing codex_keeper_reset_redeems") + } + 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 + if err := goose.DownToContext(ctx, db, ".", preConsumeVersion); err != nil { + t.Fatalf("migrate down to %d: %v", preConsumeVersion, err) + } + var version int64 + if err := db.QueryRow(`SELECT MAX(version_id) FROM goose_db_version`).Scan(&version); err != nil { + t.Fatalf("query goose version: %v", err) + } + if version != preConsumeVersion { + t.Fatalf("post-rollback goose version = %d, want %d", version, preConsumeVersion) + } + // The old binary needs the compat table back (empty), and the PR #12 objects gone. + if !testTableExists(t, db, "codex_keeper_quota_resets") { + t.Fatal("rollback did not restore the codex_keeper_quota_resets compat table") + } + if testTableExists(t, db, "codex_keeper_reset_redeems") { + t.Fatal("rollback left codex_keeper_reset_redeems behind") + } + if testColumnExists(t, db, "codex_keeper_auth_states", "subscription_active_until") { + t.Fatal("rollback left subscription_active_until behind") + } + if testColumnExists(t, db, "codex_keeper_auth_states", "account_id") { + t.Fatal("rollback left codex_keeper_auth_states.account_id behind") + } + for _, col := range []string{"provider", "antigravity_quota", "antigravity_identity_digest"} { + if testColumnExists(t, db, "codex_keeper_auth_states", col) { + 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). + if err := goose.UpToContext(ctx, db, ".", backendMigrations.LatestVersion); err != nil { + t.Fatalf("replay up to head after rollback: %v", err) + } + if v := func() int64 { + var v int64 + _ = db.QueryRow(`SELECT MAX(version_id) FROM goose_db_version`).Scan(&v) + return v + }(); v != backendMigrations.LatestVersion { + t.Fatalf("post-replay version = %d, want head %d", v, backendMigrations.LatestVersion) + } + if !testColumnExists(t, db, "codex_keeper_auth_states", "account_id") || + !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/pricing.go b/backend/internal/app/pricing.go index 2cd23778..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 @@ -878,6 +879,16 @@ func usageAggregateInputTokens(record UsageRecord) int { return inputTokens } +// usageCacheHitTokens returns the tokens served from provider prompt cache. +// Claude-style records report cache reads in cache_read_tokens (kept outside +// input_tokens); everything else reports cached_tokens as a subset of input. +func usageCacheHitTokens(record UsageRecord) int { + if isClaudeProvider(record.Provider) { + return nonNegativeTokens(record.CacheReadTokens) + } + return nonNegativeTokens(record.CachedTokens) +} + func usageAggregateTotalTokens(record UsageRecord) int { if isClaudeProvider(record.Provider) { return usageAggregateInputTokens(record) + nonNegativeTokens(record.OutputTokens) + nonNegativeTokens(record.ReasoningTokens) 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) + } +} diff --git a/backend/internal/app/startup.go b/backend/internal/app/startup.go index ff4721d9..239d768c 100644 --- a/backend/internal/app/startup.go +++ b/backend/internal/app/startup.go @@ -7,9 +7,12 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" backendMigrations "cpa-helper/backend/migrations" + + "github.com/pressly/goose/v3" ) var ( @@ -79,6 +82,110 @@ func Migrate(ctx context.Context) (MigrationReport, error) { }, nil } +// allowedRollbackTargets whitelists the goose versions `migrate down-to` may roll back +// to. A downgrade is destructive — its Down migrations drop the columns/tables (and the +// data in them) added since the target — so only explicitly reviewed rollback targets +// are permitted, never an arbitrary version. +var allowedRollbackTargets = map[int64]bool{ + 202609040002: true, // pre-reset-credit-consume baseline (see docs/migrations-rollback.md) +} + +func sortedRollbackTargets() []int64 { + targets := make([]int64, 0, len(allowedRollbackTargets)) + for v := range allowedRollbackTargets { + targets = append(targets, v) + } + sort.Slice(targets, func(i, j int) bool { return targets[i] < targets[j] }) + return targets +} + +// pendingRedeemCount returns how many in-flight (pending) rows the redeem ledger holds, +// or 0 when the table does not exist yet (a partially-migrated DB below 202609060003). +func pendingRedeemCount(ctx context.Context, db *sql.DB) (int, error) { + var name string + err := db.QueryRowContext(ctx, `SELECT name FROM sqlite_master WHERE type='table' AND name='codex_keeper_reset_redeems'`).Scan(&name) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + if err != nil { + return 0, err + } + var n int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM codex_keeper_reset_redeems WHERE status = ?`, keeperRedeemStatusPending).Scan(&n); err != nil { + return 0, err + } + return n, nil +} + +// MigrateDownTo rolls the schema DOWN to target, which MUST be an allowlisted rollback +// target. It refuses to act when target is not whitelisted or is not strictly below the +// current version. DESTRUCTIVE: the Down migrations drop everything added since target +// (for the reset-credit-consume release that includes subscription_active_until and its +// data). By default it also REFUSES when the redeem ledger holds any pending (unresolved, +// unknown-outcome) redeem, because dropping the ledger loses that redeem's unique +// idempotency key — a later re-upgrade + reset would mint a NEW key and could double +// consume. Pass allowPending=true only after reconciling those redeems and backing up. +// +// This is an OFFLINE operation: stop the CPA-Helper service first. The pending-redeem +// check is a pre-flight guard, NOT atomic protection — a still-running service could +// create a pending redeem between the check and the drop. Quiescence is the operator's +// responsibility per docs/migrations-rollback.md. +func MigrateDownTo(ctx context.Context, target int64, allowPending bool) (MigrationReport, error) { + if ctx == nil { + ctx = context.Background() + } + if !allowedRollbackTargets[target] { + return MigrationReport{}, fmt.Errorf("refusing to roll back to unlisted version %d; allowed targets: %v", target, sortedRollbackTargets()) + } + paths, err := resolveRuntimePaths() + if err != nil { + return MigrationReport{}, err + } + db, err := openRuntimeDB(paths, false) + if err != nil { + return MigrationReport{}, err + } + defer db.Close() + + before, err := currentMigrationVersion(ctx, db) + if err != nil { + return MigrationReport{DBPath: paths.DBPath, TargetVersion: target}, err + } + if before <= target { + return MigrationReport{DBPath: paths.DBPath, PreviousVersion: before, CurrentVersion: before, TargetVersion: target}, + fmt.Errorf("current version %d is not newer than target %d; nothing to roll back", before, target) + } + // Refuse to roll back a DB that is newer than this binary knows about: its embedded + // Down migrations would not cover the extra versions, so the rollback would be + // incomplete/unsafe. Use a binary that embeds those migrations instead. + if before > backendMigrations.LatestVersion { + return MigrationReport{DBPath: paths.DBPath, PreviousVersion: before, TargetVersion: target}, + fmt.Errorf("database version %d is newer than this binary (%d); roll back with a binary that embeds those migrations", before, backendMigrations.LatestVersion) + } + if !allowPending { + pending, perr := pendingRedeemCount(ctx, db) + if perr != nil { + return MigrationReport{DBPath: paths.DBPath, PreviousVersion: before, TargetVersion: target}, perr + } + if pending > 0 { + return MigrationReport{DBPath: paths.DBPath, PreviousVersion: before, TargetVersion: target}, + fmt.Errorf("refusing to roll back: %d pending redeem(s) in codex_keeper_reset_redeems whose idempotency keys would be lost; reconcile them, back up, then re-run with --allow-pending", pending) + } + } + goose.SetBaseFS(backendMigrations.FS) + if err := goose.SetDialect("sqlite3"); err != nil { + return MigrationReport{DBPath: paths.DBPath, PreviousVersion: before, TargetVersion: target}, err + } + if err := goose.DownToContext(ctx, db, ".", target); err != nil { + return MigrationReport{DBPath: paths.DBPath, PreviousVersion: before, TargetVersion: target}, err + } + after, err := currentMigrationVersion(ctx, db) + if err != nil { + return MigrationReport{DBPath: paths.DBPath, PreviousVersion: before, TargetVersion: target}, err + } + return MigrationReport{DBPath: paths.DBPath, PreviousVersion: before, CurrentVersion: after, TargetVersion: target}, nil +} + func CheckStartup(ctx context.Context) (StartupCheck, error) { if ctx == nil { ctx = context.Background() diff --git a/backend/internal/app/usage.go b/backend/internal/app/usage.go index 920acb1e..a641d9fc 100644 --- a/backend/internal/app/usage.go +++ b/backend/internal/app/usage.go @@ -856,6 +856,7 @@ func listItemFromRecord(record UsageRecord, users map[string]userInfo, prices ma func usageSummaryFromRecords(filters UsageFilters, records []UsageRecord, prices map[[2]string]ModelPrice) map[string]any { failed := 0 input, output, cached, reasoning, total := 0, 0, 0, 0, 0 + cacheHit := 0 estimated := 0.0 unpriced := 0 ttftTotal := 0.0 @@ -867,6 +868,7 @@ func usageSummaryFromRecords(filters UsageFilters, records []UsageRecord, prices input += usageAggregateInputTokens(record) output += record.OutputTokens cached += record.CachedTokens + cacheHit += usageCacheHitTokens(record) reasoning += record.ReasoningTokens total += usageAggregateTotalTokens(record) amount, isUnpriced := recordCost(record, prices) @@ -893,6 +895,7 @@ func usageSummaryFromRecords(filters UsageFilters, records []UsageRecord, prices "input_tokens": input, "output_tokens": output, "cached_tokens": cached, + "cache_hit_tokens": cacheHit, "reasoning_tokens": reasoning, "total_tokens": total, "estimated_cost_usd": estimated, diff --git a/backend/internal/app/usage_cost_export.go b/backend/internal/app/usage_cost_export.go new file mode 100644 index 00000000..284836a6 --- /dev/null +++ b/backend/internal/app/usage_cost_export.go @@ -0,0 +1,36 @@ +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 +} + +// 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/app/usage_internal_test.go b/backend/internal/app/usage_internal_test.go index 108b7d3a..98c7a4ef 100644 --- a/backend/internal/app/usage_internal_test.go +++ b/backend/internal/app/usage_internal_test.go @@ -122,3 +122,28 @@ func TestSaveUsageMessageIgnoresZeroTTFT(t *testing.T) { t.Fatalf("stored ttft_ms = %v, want NULL", ttftMS.Float64) } } + +func TestUsageSummaryCacheHitTokensAcrossProviders(t *testing.T) { + claude := "claude" + codex := "codex" + records := []UsageRecord{ + // Claude-style: cache reads live outside input_tokens. + {Provider: &claude, InputTokens: 100, OutputTokens: 10, CacheReadTokens: 400, CacheCreationTokens: 50}, + // Codex/OpenAI-style: cached_tokens is a subset of input_tokens. + {Provider: &codex, InputTokens: 1000, OutputTokens: 20, CachedTokens: 700, CacheReadTokens: 700, TotalTokens: 1020}, + // No cache usage at all. + {Provider: &codex, InputTokens: 50, OutputTokens: 5, TotalTokens: 55}, + } + summary := usageSummaryFromRecords(UsageFilters{}, records, nil) + if got := summary["cache_hit_tokens"]; got != 1100 { + t.Fatalf("cache_hit_tokens = %v, want 1100 (claude 400 + codex 700)", got) + } + // claude input aggregates to 100+400+50=550; codex rows contribute 1000+50. + if got := summary["input_tokens"]; got != 1600 { + t.Fatalf("input_tokens = %v, want 1600", got) + } + // cached_tokens keeps its legacy meaning (codex-style only). + if got := summary["cached_tokens"]; got != 700 { + t.Fatalf("cached_tokens = %v, want 700", got) + } +} 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/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..fab9d080 --- /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)&_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 +} + +// 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, + } +} diff --git a/backend/migrations/202608060001_product_branding.sql b/backend/migrations/202608060001_product_branding.sql new file mode 100644 index 00000000..02b88783 --- /dev/null +++ b/backend/migrations/202608060001_product_branding.sql @@ -0,0 +1,7 @@ +-- +goose Up +ALTER TABLE app_settings ADD COLUMN product_name VARCHAR(64) NOT NULL DEFAULT ''; +ALTER TABLE app_settings ADD COLUMN product_logo TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE app_settings DROP COLUMN product_logo; +ALTER TABLE app_settings DROP COLUMN product_name; diff --git a/backend/migrations/202609040001_keeper_quota_resets.sql b/backend/migrations/202609040001_keeper_quota_resets.sql new file mode 100644 index 00000000..232e2703 --- /dev/null +++ b/backend/migrations/202609040001_keeper_quota_resets.sql @@ -0,0 +1,9 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS codex_keeper_quota_resets ( + auth_name VARCHAR(500) PRIMARY KEY, + reset_count INTEGER NOT NULL DEFAULT 0, + last_reset_at TIMESTAMP NULL +); + +-- +goose Down +DROP TABLE IF EXISTS codex_keeper_quota_resets; diff --git a/backend/migrations/202609040002_keeper_reset_credits.sql b/backend/migrations/202609040002_keeper_reset_credits.sql new file mode 100644 index 00000000..bb3a2f6d --- /dev/null +++ b/backend/migrations/202609040002_keeper_reset_credits.sql @@ -0,0 +1,7 @@ +-- +goose Up +ALTER TABLE codex_keeper_auth_states ADD COLUMN reset_credit_count INTEGER; +ALTER TABLE codex_keeper_auth_states ADD COLUMN reset_credits TEXT; + +-- +goose Down +ALTER TABLE codex_keeper_auth_states DROP COLUMN reset_credits; +ALTER TABLE codex_keeper_auth_states DROP COLUMN reset_credit_count; diff --git a/backend/migrations/202609060001_keeper_subscription_active_until.sql b/backend/migrations/202609060001_keeper_subscription_active_until.sql new file mode 100644 index 00000000..e41ddf47 --- /dev/null +++ b/backend/migrations/202609060001_keeper_subscription_active_until.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE codex_keeper_auth_states ADD COLUMN subscription_active_until TIMESTAMP NULL; + +-- +goose Down +ALTER TABLE codex_keeper_auth_states DROP COLUMN subscription_active_until; diff --git a/backend/migrations/202609060002_drop_keeper_quota_resets.sql b/backend/migrations/202609060002_drop_keeper_quota_resets.sql new file mode 100644 index 00000000..4e92b2be --- /dev/null +++ b/backend/migrations/202609060002_drop_keeper_quota_resets.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- The manual quota-reset counter carried no business value (it counted local +-- cooldown clears, not real credit redemptions). Drop it now that resetKeeperQuota +-- redeems a real OpenAI reset credit instead of incrementing this table. +DROP TABLE IF EXISTS codex_keeper_quota_resets; + +-- +goose Down +-- Recreate the empty table schema so a rollback restores the shape (the historical +-- counts are intentionally not restored — they were worthless cooldown-clear tallies). +CREATE TABLE IF NOT EXISTS codex_keeper_quota_resets ( + auth_name VARCHAR(500) PRIMARY KEY, + reset_count INTEGER NOT NULL DEFAULT 0, + last_reset_at TIMESTAMP NULL +); diff --git a/backend/migrations/202609060003_keeper_reset_redeems.sql b/backend/migrations/202609060003_keeper_reset_redeems.sql new file mode 100644 index 00000000..c5292e61 --- /dev/null +++ b/backend/migrations/202609060003_keeper_reset_redeems.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- Persistent redeem ledger for real reset-credit consumption, keyed by the STABLE OpenAI +-- account_id (the resource identity). auth_name (a filename) and auth_index (a CPA hash of +-- the file path) are only routing selectors — both can change for the same account (file +-- rename / move / reorder) — so the idempotency key must NOT depend on them. A pending row +-- is an in-flight/unknown-outcome redeem whose redeem_request_id must be reused +-- idempotently on the next attempt for the SAME account (however it is now routed), so a +-- lost response can never burn a second credit. +CREATE TABLE IF NOT EXISTS codex_keeper_reset_redeems ( + account_id TEXT PRIMARY KEY, + redeem_request_id TEXT NOT NULL, + status TEXT NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +-- +goose Down +DROP TABLE IF EXISTS codex_keeper_reset_redeems; diff --git a/backend/migrations/202609060004_keeper_account_id.sql b/backend/migrations/202609060004_keeper_account_id.sql new file mode 100644 index 00000000..2d09fa2d --- /dev/null +++ b/backend/migrations/202609060004_keeper_account_id.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- Store the account_id (ChatGPT account identity) alongside each keeper auth state so the +-- subscription renewal snapshot can be scoped to the ACCOUNT, not just the auth_index. +-- CPA's EnsureIndex() hashes file auth by provider + file path, so swapping the same +-- filename to a different OpenAI account keeps auth_index unchanged; without account_id a +-- preserve-on-unknown subscription write would inherit the previous account's renewal date. +ALTER TABLE codex_keeper_auth_states ADD COLUMN account_id TEXT NULL; + +-- +goose Down +ALTER TABLE codex_keeper_auth_states DROP COLUMN account_id; diff --git a/backend/migrations/202609060005_keeper_provider_antigravity.sql b/backend/migrations/202609060005_keeper_provider_antigravity.sql new file mode 100644 index 00000000..71845562 --- /dev/null +++ b/backend/migrations/202609060005_keeper_provider_antigravity.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- The Codex-Keeper now inspects more than Codex accounts. `provider` records which upstream a +-- row belongs to (codex | antigravity); existing rows predate this and are treated as codex when +-- NULL. `antigravity_quota` stores the parsed Antigravity quota summary (groups -> buckets) as a +-- JSON blob, mirroring how reset_credits is stored; it stays NULL for codex rows. +ALTER TABLE codex_keeper_auth_states ADD COLUMN provider TEXT; +ALTER TABLE codex_keeper_auth_states ADD COLUMN antigravity_quota TEXT; +-- antigravity_identity_digest is a stable, versioned one-way digest of the Antigravity account +-- identity (provider + Google project + normalized email). Only the digest is stored — never the +-- raw project id or email — so a later inspection can detect an identity SWAP (digest changed) and +-- clear the stale quota, without persisting the project or email. +ALTER TABLE codex_keeper_auth_states ADD COLUMN antigravity_identity_digest TEXT; + +-- +goose Down +ALTER TABLE codex_keeper_auth_states DROP COLUMN antigravity_identity_digest; +ALTER TABLE codex_keeper_auth_states DROP COLUMN antigravity_quota; +ALTER TABLE codex_keeper_auth_states DROP COLUMN provider; 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 17562db2..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 = 202607290001 +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 new file mode 100644 index 00000000..f54ccc55 --- /dev/null +++ b/docs/migrations-rollback.md @@ -0,0 +1,81 @@ +# Migration rollback runbook + +The database schema version is tracked by goose in `goose_db_version`. On startup the +app runs migrations up to `migrations.LatestVersion` and **refuses to start when the DB +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–202609160001) + +This release added, on top of `202609040002`: + +- `202609060001` — `codex_keeper_auth_states.subscription_active_until` column. +- `202609060002` — **DROP** of the obsolete `codex_keeper_quota_resets` table. +- `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 +`/accounts`. So you cannot just swap the binary back. + +**Rollback procedure (do this in order):** + +0. **Stop the CPA-Helper service and back up the SQLite database.** The downgrade is an + **offline** operation: `migrate down-to` must run against a quiescent database. Its + pending-redeem pre-flight check (step 1) is a safety net, **not** atomic protection — + a still-running service could create a pending redeem between the check and the drop. + Ensure no CPA-Helper process is running before proceeding. + +1. Migrate the schema down to the previous binary's target version using the real + `migrate down-to` subcommand (run it from the **current**, this-release binary): + + ``` + cpa-helper migrate down-to 202609040002 + ``` + + **Pending redeems:** the command **refuses** to run when the redeem ledger holds any + `status='pending'` row, because dropping the ledger loses that redeem's unique + idempotency key (a later re-upgrade + reset could then double-consume). Reconcile + those redeems first; only after that (and a backup) re-run with `--allow-pending`: + + ``` + cpa-helper migrate down-to 202609040002 --allow-pending + ``` + + 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. + `202609040002` is the only allowlisted rollback target; the command refuses any other + version and refuses to run when the DB is not newer than the target. + + > Do NOT expect a bare `cpa-helper migrate` to downgrade — it only runs migrations + > **Up**. The dedicated `migrate down-to` subcommand is required. + +2. Deploy the previous binary (`a996697`). + +**Data semantics — what the rollback loses:** + +- `codex_keeper_quota_resets` is restored as an **empty** table. The historical + manual-reset counts are not recovered (they carried no business value and were + deliberately discarded). +- `subscription_active_until` (the column **and every collected subscription-renewal + timestamp**) is dropped and **not** recovered. If you need it, restore from the backup + taken in step 0; otherwise accept the loss. +- `account_id` (the column **and every stored account identity**) is dropped and **not** + recovered. It is re-derived on the next inspection after re-upgrading, so the loss is + transient; restore from the step-0 backup only if you need it before re-upgrading. +- `codex_keeper_reset_redeems` (the in-flight redeem ledger) is dropped. Any unresolved + pending redeem is lost; after rollback the old binary cannot consume credits at all, so + this is acceptable. +- Other data — auth states, credit snapshots, usage records — is untouched by these Down + migrations. + +This contract is covered by `TestRollbackToPreConsumeRestoresCompatSchema` (Down restores +the compat table and removes the new objects) and by +`TestMigrateDownToRollsBackToTarget` (the `migrate down-to` CLI really moves the version +from the head to `202609040002` and rejects unlisted targets). diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ddf1e353..87fe4b9d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1103,7 +1103,6 @@ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1153,7 +1152,6 @@ "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", @@ -1615,7 +1613,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1785,7 +1782,6 @@ "resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz", "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", "license": "MIT", - "peer": true, "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" @@ -1821,7 +1817,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -1948,7 +1943,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2685,7 +2679,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2961,7 +2954,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3036,7 +3028,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -3115,7 +3106,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz", "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.33", "@vue/compiler-sfc": "3.5.33", diff --git a/frontend/package.json b/frontend/package.json index 90c7ab46..60a979d5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,13 @@ "dev": "vite --host 0.0.0.0 --port 5173", "build": "vue-tsc -b && vite build", "lint": "eslint . --max-warnings=0", - "test:i18n": "node scripts/i18n-smoke.mjs" + "test:i18n": "node scripts/i18n-smoke.mjs", + "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: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", diff --git a/frontend/scripts/antigravity-countdown-smoke.mjs b/frontend/scripts/antigravity-countdown-smoke.mjs new file mode 100644 index 00000000..c719328c --- /dev/null +++ b/frontend/scripts/antigravity-countdown-smoke.mjs @@ -0,0 +1,73 @@ +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 { formatAntigravityResetCountdown } = await server.ssrLoadModule( + '/src/features/codex-keeper/antigravityCountdown.ts', + ) + + const now = Date.UTC(2026, 0, 1, 0, 0, 0) + const MINUTE = 60_000 + const HOUR = 3_600_000 + const DAY = 86_400_000 + + const at = (delta) => new Date(now + delta).toISOString() + const zh = (delta) => formatAntigravityResetCountdown(at(delta), now, 'zh') + const en = (delta) => formatAntigravityResetCountdown(at(delta), now, 'en') + + // 4 days 6 hours (exact) → day + hour bucket + assert.equal(zh(4 * DAY + 6 * HOUR), '4 天 6 小时后刷新') + assert.equal(en(4 * DAY + 6 * HOUR), 'Refresh in 4d 6h') + + // 4 hours 56 min (exact) → hour + minute bucket + assert.equal(zh(4 * HOUR + 56 * MINUTE), '4 小时 56 分钟后刷新') + assert.equal(en(4 * HOUR + 56 * MINUTE), 'Refresh in 4h 56m') + + // 59 min → minute bucket + assert.equal(zh(59 * MINUTE), '59 分钟后刷新') + assert.equal(en(59 * MINUTE), 'Refresh in 59m') + + // --- non-integer-minute boundaries: CEIL must NOT under-report --- + + // 4h 56m 30s → ceils up into 4h57m (never floors down to 4h56m) + assert.equal(zh(4 * HOUR + 56 * MINUTE + 30_000), '4 小时 57 分钟后刷新') + assert.equal(en(4 * HOUR + 56 * MINUTE + 30_000), 'Refresh in 4h 57m') + + // 4d 6h 30s → the extra 30s ceils into minutes but stays within the same + // hour bucket (day+hour display drops the minutes); still not under-reported. + assert.equal(zh(4 * DAY + 6 * HOUR + 30_000), '4 天 6 小时后刷新') + assert.equal(en(4 * DAY + 6 * HOUR + 30_000), 'Refresh in 4d 6h') + + // exactly 60000 ms (1 min) → minute bucket + assert.equal(zh(60_000), '1 分钟后刷新') + assert.equal(en(60_000), 'Refresh in 1m') + + // 30 sec → ceils up to 1 minute (no under-report below true remaining) + assert.equal(zh(30_000), '1 分钟后刷新') + assert.equal(en(30_000), 'Refresh in 1m') + + // -5 min → expired / refreshable now + assert.equal(zh(-5 * MINUTE), '可刷新') + assert.equal(en(-5 * MINUTE), 'Refresh available') + + // null resetAtIso → empty string (caller renders a dash) + assert.equal(formatAntigravityResetCountdown(null, now, 'zh'), '') + assert.equal(formatAntigravityResetCountdown(null, now, 'en'), '') + + // unparseable string → empty string as well + assert.equal(formatAntigravityResetCountdown('not-a-date', now, 'zh'), '') + + console.log('antigravity-countdown-smoke: OK') +} finally { + await server.close() +} diff --git a/frontend/scripts/antigravity-quota-format-smoke.mjs b/frontend/scripts/antigravity-quota-format-smoke.mjs new file mode 100644 index 00000000..238c6f30 --- /dev/null +++ b/frontend/scripts/antigravity-quota-format-smoke.mjs @@ -0,0 +1,39 @@ +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 { antigravityResetWithCountdown, joinAntigravityBuckets, antigravityGroupLine } = + await server.ssrLoadModule('/src/features/codex-keeper/antigravityQuotaFormat.ts') + + // Reset + countdown: Chinese uses full-width parens with no leading space; English uses ASCII + // parens with a leading space. This is the exact spot that rendered `…(Refresh in …)` in English. + assert.equal(antigravityResetWithCountdown('2026-09-13 14:43', '4 天 6 小时后刷新', 'zh'), '2026-09-13 14:43(4 天 6 小时后刷新)') + assert.equal(antigravityResetWithCountdown('2026-09-13 14:43', 'Refresh in 4d 6h', 'en'), '2026-09-13 14:43 (Refresh in 4d 6h)') + // Empty countdown returns the reset time unchanged (no dangling parens) in both locales. + assert.equal(antigravityResetWithCountdown('2026-09-13 14:43', '', 'zh'), '2026-09-13 14:43') + assert.equal(antigravityResetWithCountdown('2026-09-13 14:43', '', 'en'), '2026-09-13 14:43') + + // Bucket separator: full-width comma (zh) vs ", " (en). + assert.equal(joinAntigravityBuckets(['a', 'b', 'c'], 'zh'), 'a,b,c') + assert.equal(joinAntigravityBuckets(['a', 'b', 'c'], 'en'), 'a, b, c') + + // Group line: label + full-width colon (zh) vs ": " (en). No Chinese punctuation may leak into en. + assert.equal(antigravityGroupLine('Gemini 模型', 'a,b', 'zh'), 'Gemini 模型:a,b') + const en = antigravityGroupLine('Gemini Models', 'Weekly 83% remaining, refreshes X (Refresh in 4d 6h)', 'en') + assert.equal(en, 'Gemini Models: Weekly 83% remaining, refreshes X (Refresh in 4d 6h)') + assert.ok(!/[:,()]/.test(en), 'English group line must contain no full-width punctuation') + + console.log('antigravity-quota-format-smoke: OK') +} finally { + await server.close() +} diff --git a/frontend/scripts/antigravity-window-smoke.mjs b/frontend/scripts/antigravity-window-smoke.mjs new file mode 100644 index 00000000..7e819e48 --- /dev/null +++ b/frontend/scripts/antigravity-window-smoke.mjs @@ -0,0 +1,47 @@ +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 { normalizeAntigravityWindow } = await server.ssrLoadModule( + '/src/features/codex-keeper/antigravityWindow.ts', + ) + + // 5-hour window: every spelling/alias collapses to the same key so the UI localizes it. + for (const alias of ['5h', '5H', '5 hour', '5-hour', '5_hours', ' 5H ']) { + assert.equal(normalizeAntigravityWindow(alias), '5h', `5h alias: ${alias}`) + } + + // Weekly. + for (const alias of ['weekly', 'Weekly', 'week', 'WEEK']) { + assert.equal(normalizeAntigravityWindow(alias), 'weekly', `weekly alias: ${alias}`) + } + + // Daily — a window the reference implementation covers but the old label() dropped to English. + for (const alias of ['daily', 'Daily', 'day', ' DAY ']) { + assert.equal(normalizeAntigravityWindow(alias), 'daily', `daily alias: ${alias}`) + } + + // Monthly. + for (const alias of ['monthly', 'Monthly', 'month', 'MONTH']) { + assert.equal(normalizeAntigravityWindow(alias), 'monthly', `monthly alias: ${alias}`) + } + + // Genuinely unknown windows / empty / null return null so the caller keeps display_name. + for (const unknown of ['yearly', 'hourly', '', ' ', null, undefined]) { + assert.equal(normalizeAntigravityWindow(unknown), null, `unknown window: ${unknown}`) + } + + console.log('antigravity-window-smoke: OK') +} finally { + await server.close() +} diff --git a/frontend/scripts/i18n-smoke.mjs b/frontend/scripts/i18n-smoke.mjs index 31f7cb07..bd8c20c8 100644 --- a/frontend/scripts/i18n-smoke.mjs +++ b/frontend/scripts/i18n-smoke.mjs @@ -135,6 +135,96 @@ try { localizedServerMessage('请求体不是有效 JSON'), 'Request body is not valid JSON', ) + assert.equal( + localizedServerMessage('CLIProxyAPI 未确认重置成功(响应缺少 status=ok 或 auth_index 不匹配)'), + 'CLIProxyAPI did not confirm the reset (response missing status=ok or auth_index mismatch)', + ) + assert.equal( + localizedServerMessage('该账号缺少 auth_index,请先刷新账号列表'), + 'This account has no auth_index yet; refresh the account list first', + ) + // Reset-quota (real credit consume) errors must localize to specific recovery + // guidance, not degrade to the generic validation message. + assert.equal( + localizedServerMessage('账号正在巡检或重置中,请稍后重试'), + 'The account is being inspected or reset. Try again shortly.', + ) + assert.equal( + localizedServerMessage('账号 auth_index 已变化,请刷新账号列表后重试'), + 'The account auth_index has changed. Refresh the account list and try again.', + ) + assert.equal( + localizedServerMessage('同一 OpenAI 账号的另一路由正在重置,请稍后重试'), + 'Another route for the same OpenAI account is being reset. Try again shortly.', + ) + assert.equal( + localizedServerMessage('账号身份冲突:列表与详情的 account_id/auth_index 不一致,已保留原快照'), + 'Account identity conflict: the list and detail disagree on account_id/auth_index; the previous snapshot was preserved.', + ) + assert.equal( + localizedServerMessage('账号身份冲突: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 quota inspection log/last_error strings must localize (English account page + // must not show Chinese): a bare failure exact, plus name-prefixed success/failure log lines. + assert.equal( + 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)', + ) + assert.equal( + localizedServerMessage('antigravity@example.com.json:Antigravity 配额读取失败'), + 'antigravity@example.com.json: Failed to read Antigravity quota', + ) + assert.equal( + localizedServerMessage('账号 account_id 身份冲突(列表与详情不一致),请刷新后重试'), + 'Account account_id identity conflict (list and detail disagree). Refresh and try again.', + ) + assert.equal( + localizedServerMessage('账号不是 Codex 类型,无法主动重置'), + 'This account is not a Codex account and cannot be reset.', + ) + assert.equal( + localizedServerMessage('当前为 dry-run 模式,已阻止真实核销/重置;请关闭 dry-run 后重试'), + 'Dry-run mode blocked the real redemption/reset. Turn dry-run off and try again.', + ) + assert.equal( + localizedServerMessage('重置额度核销状态异常,请稍后重试'), + 'The reset-credit redemption state is inconsistent. Try again shortly.', + ) + assert.equal( + localizedServerMessage('账号缺少 account_id,无法安全核销,请刷新后重试'), + 'The account has no account_id; a safe redemption is not possible. Refresh and try again.', + ) + assert.equal( + localizedServerMessage('账号尚未确认身份(缺少 account_id),请先刷新账号列表后再重置'), + 'The account identity is not confirmed yet (no account_id). Refresh the account list before resetting.', + ) + assert.equal( + localizedServerMessage('无法确认可用重置额度(快照未知),请刷新后重试'), + 'Cannot confirm available reset credits (snapshot unknown). Refresh and try again.', + ) + assert.equal( + localizedServerMessage('核销主动重置额度失败:网络异常,未确认是否已核销'), + 'Failed to redeem the reset credit: network error; redemption is unconfirmed.', + ) + assert.equal( + localizedServerMessage('核销主动重置额度失败:OpenAI 拒绝核销'), + 'Failed to redeem the reset credit: OpenAI rejected the redemption.', + ) + assert.equal(localizedServerMessage('auth_name 不能为空'), 'auth_name must not be empty') + assert.equal(localizedServerMessage('账号不存在'), 'Account not found') assert.equal(localizedKeeperStatusDetail(null), 'Not running') const { apiClient } = await server.ssrLoadModule(`/src/shared/api/apiClient.ts?case=${moduleCase++}`) diff --git a/frontend/scripts/keeper-quota-exhaustion-smoke.mjs b/frontend/scripts/keeper-quota-exhaustion-smoke.mjs new file mode 100644 index 00000000..c4932381 --- /dev/null +++ b/frontend/scripts/keeper-quota-exhaustion-smoke.mjs @@ -0,0 +1,50 @@ +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 { isAntigravityAccount, isQuotaExhaustedAccount } = await server.ssrLoadModule( + '/src/features/codex-keeper/keeperQuotaExhaustion.ts', + ) + + // Codex account at priority -1 (not disabled) IS quota-exhausted. + assert.equal( + isQuotaExhaustedAccount({ provider: 'codex', disabled: false, priority: -1 }), + true, + 'codex priority -1 should be exhausted', + ) + + // Antigravity account at priority -1 must NOT be counted as quota-exhausted — Antigravity does + // not run the Codex quota->priority policy. This is the provider-isolation guard both the status + // page and the inspection-settings page share. + assert.equal( + isQuotaExhaustedAccount({ provider: 'antigravity', disabled: false, priority: -1 }), + false, + 'antigravity priority -1 must not be exhausted', + ) + + // Disabled codex at -1 is reported by its disabled state, not exhaustion. + assert.equal(isQuotaExhaustedAccount({ provider: 'codex', disabled: true, priority: -1 }), false) + + // Non -1 priority is never exhausted (including the ?? 0 default when priority is missing). + assert.equal(isQuotaExhaustedAccount({ provider: 'codex', disabled: false, priority: 5 }), false) + assert.equal(isQuotaExhaustedAccount({ provider: 'codex', disabled: false }), false) + + // Provider discriminator. + assert.equal(isAntigravityAccount({ provider: 'antigravity' }), true) + assert.equal(isAntigravityAccount({ provider: 'codex' }), false) + assert.equal(isAntigravityAccount({}), false) + + console.log('keeper-quota-exhaustion-smoke: OK') +} finally { + await server.close() +} 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/app/layout/AppShell.vue b/frontend/src/app/layout/AppShell.vue index 91b22374..9de237c7 100644 --- a/frontend/src/app/layout/AppShell.vue +++ b/frontend/src/app/layout/AppShell.vue @@ -42,7 +42,7 @@ import { getMe, isAuthUser, logout } from '@/features/auth/api/authApi' import { useCurrentUser } from '@/features/auth/state/currentUser' import { useThemePreference } from '@/shared/composables/useThemePreference' import { useI18n } from '@/shared/i18n' -import { logoUrl } from '@/shared/utils/assets' +import { useProductInfo } from '@/shared/state/productInfo' const route = useRoute() const router = useRouter() @@ -57,6 +57,7 @@ const { currentUser, setCurrentUser } = useCurrentUser() const hasLoadedUser = ref(currentUser.value !== null) const { isDark, preference, setThemePreference, toggleTheme } = useThemePreference() const { language, t, toggleLanguage } = useI18n() +const { productName, productLogo } = useProductInfo() let navigationFeedbackTimer: number | undefined let routeTransitionReleaseTimer: number | undefined @@ -309,10 +310,10 @@ const logoutAriaLabel = computed(() => t('退出登录', 'Sign out')) >
- +
- CPA-Helper + {{ productName }} {{ accountText }} · {{ roleText }}
@@ -378,11 +379,11 @@ const logoutAriaLabel = computed(() => t('退出登录', 'Sign out')) -
- +
+
- CPA-Helper + {{ productName }} {{ appVersion }}
{{ accountText }} · {{ roleText }} @@ -430,7 +431,7 @@ const logoutAriaLabel = computed(() => t('退出登录', 'Sign out')) - +
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/auth/views/ChangeCredentialsView.vue b/frontend/src/features/auth/views/ChangeCredentialsView.vue index 78bcbff7..0446b92a 100644 --- a/frontend/src/features/auth/views/ChangeCredentialsView.vue +++ b/frontend/src/features/auth/views/ChangeCredentialsView.vue @@ -6,11 +6,12 @@ import { NAlert, NButton, NCard, NForm, NFormItem, NInput, useMessage } from 'na import { changeCredentials, getMe } from '@/features/auth/api/authApi' import { setCurrentUser } from '@/features/auth/state/currentUser' import { useI18n } from '@/shared/i18n' -import { logoUrl } from '@/shared/utils/assets' +import { useProductInfo } from '@/shared/state/productInfo' const router = useRouter() const message = useMessage() const { errorText, t } = useI18n() +const { productLogo } = useProductInfo() const isLoading = ref(false) const errorMessage = ref(null) const form = reactive({ @@ -58,7 +59,7 @@ async function handleSubmit() {
- +
diff --git a/frontend/src/features/auth/views/LoginView.vue b/frontend/src/features/auth/views/LoginView.vue index 784cf1b4..c6efcc7c 100644 --- a/frontend/src/features/auth/views/LoginView.vue +++ b/frontend/src/features/auth/views/LoginView.vue @@ -6,12 +6,13 @@ import { NAlert, NButton, NCard, NForm, NFormItem, NInput, useMessage } from 'na import { getSetupState, login, setupFirstAdmin } from '@/features/auth/api/authApi' import { setCurrentUser } from '@/features/auth/state/currentUser' import { useI18n } from '@/shared/i18n' -import { logoUrl } from '@/shared/utils/assets' +import { useProductInfo } from '@/shared/state/productInfo' const route = useRoute() const router = useRouter() const message = useMessage() const { errorText, t } = useI18n() +const { productName, productLogo } = useProductInfo() const isLoading = ref(false) const isSetupLoading = ref(true) const setupRequired = ref(false) @@ -21,7 +22,7 @@ const form = reactive({ password: '', nickname: '', }) -const headingTitle = computed(() => (setupRequired.value ? t('创建首个管理员账号', 'Create first admin account') : 'CPA-Helper')) +const headingTitle = computed(() => (setupRequired.value ? t('创建首个管理员账号', 'Create first admin account') : productName.value)) const headingSubtitle = computed(() => setupRequired.value ? t('首次使用前需要先录入管理员账号', 'Create an admin account before first use') : t('本地 AI 用量管理控制台', 'Local AI usage management console'), ) @@ -83,7 +84,7 @@ async function handleSubmit() {
- +
diff --git a/frontend/src/features/codex-keeper/antigravityCountdown.ts b/frontend/src/features/codex-keeper/antigravityCountdown.ts new file mode 100644 index 00000000..69b103c9 --- /dev/null +++ b/frontend/src/features/codex-keeper/antigravityCountdown.ts @@ -0,0 +1,48 @@ +// formatAntigravityResetCountdown renders a FINE-GRAINED reset countdown for +// Antigravity quota buckets, mirroring upstream CPAMC's duration buckets: +// day+hour / hour+minute / minute. This deliberately differs from Codex's coarse +// formatQuotaResetCountdown ("1小时后" for 59m, etc.). Pure and dependency-free so +// it can be unit-tested in isolation. +// +// Uses CEIL-to-minute (CPAMC's algorithm) so we NEVER under-report the remaining +// time: e.g. 4h56m30s rounds up to 4h57m rather than flooring to 4h56m. +// +// - resetAtIso null/undefined/unparseable → '' (caller renders a dash). +// - deltaMs <= 0 → zh '可刷新' / en 'Refresh available'. +// - days >= 1 → zh '${d} 天 ${h} 小时后刷新' / en 'Refresh in ${d}d ${h}h'. +// - hours >= 1 → zh '${h} 小时 ${m} 分钟后刷新' / en 'Refresh in ${h}h ${m}m'. +// - otherwise → zh '${m} 分钟后刷新' / en 'Refresh in ${m}m'. +// (minutes is >= 1 here because Math.ceil of any positive delta is >= 1) +export function formatAntigravityResetCountdown( + resetAtIso: string | null | undefined, + nowMs: number, + lang: 'zh' | 'en', +): string { + if (!resetAtIso) { + return '' + } + const resetMs = new Date(resetAtIso).getTime() + if (Number.isNaN(resetMs)) { + return '' + } + const deltaMs = resetMs - nowMs + if (deltaMs <= 0) { + return lang === 'zh' ? '可刷新' : 'Refresh available' + } + const totalMinutes = Math.ceil(deltaMs / 60000) + const days = Math.floor(totalMinutes / 1440) + const remMin = totalMinutes % 1440 + const hours = Math.floor(remMin / 60) + const minutes = remMin % 60 + if (days >= 1) { + return lang === 'zh' + ? `${days} 天 ${hours} 小时后刷新` + : `Refresh in ${days}d ${hours}h` + } + if (hours >= 1) { + return lang === 'zh' + ? `${hours} 小时 ${minutes} 分钟后刷新` + : `Refresh in ${hours}h ${minutes}m` + } + return lang === 'zh' ? `${minutes} 分钟后刷新` : `Refresh in ${minutes}m` +} diff --git a/frontend/src/features/codex-keeper/antigravityQuotaFormat.ts b/frontend/src/features/codex-keeper/antigravityQuotaFormat.ts new file mode 100644 index 00000000..16455939 --- /dev/null +++ b/frontend/src/features/codex-keeper/antigravityQuotaFormat.ts @@ -0,0 +1,25 @@ +// Locale-correct punctuation for the Antigravity quota summary. The status table cell and the +// account drawer both stitch together bucket lines, so the separators/parentheses must follow the +// active UI language — Chinese uses full-width `:,()`, English uses ASCII `: , ()`. Hardcoding +// full-width punctuation made the English account page render e.g. `Gemini Models:Weekly …(Refresh +// in …)`. These helpers are pure so they can be unit-smoke-tested for both locales. +export type QuotaLang = 'zh' | 'en' + +// antigravityResetWithCountdown appends the countdown to the absolute reset time in locale-correct +// parentheses. An empty countdown returns the reset time unchanged. +export function antigravityResetWithCountdown(resetTime: string, countdown: string, lang: QuotaLang): string { + if (!countdown) { + return resetTime + } + return lang === 'zh' ? `${resetTime}(${countdown})` : `${resetTime} (${countdown})` +} + +// joinAntigravityBuckets joins bucket summaries within a group. +export function joinAntigravityBuckets(parts: string[], lang: QuotaLang): string { + return parts.join(lang === 'zh' ? ',' : ', ') +} + +// antigravityGroupLine prefixes a group's label to its joined buckets. +export function antigravityGroupLine(label: string, buckets: string, lang: QuotaLang): string { + return lang === 'zh' ? `${label}:${buckets}` : `${label}: ${buckets}` +} diff --git a/frontend/src/features/codex-keeper/antigravityWindow.ts b/frontend/src/features/codex-keeper/antigravityWindow.ts new file mode 100644 index 00000000..fecf229f --- /dev/null +++ b/frontend/src/features/codex-keeper/antigravityWindow.ts @@ -0,0 +1,24 @@ +// normalizeAntigravityWindow collapses the many aliases the Antigravity quota schema uses for the +// same reset window into a stable key, so the UI can localize it instead of leaking the raw English +// bucket display_name. CPAMC's reference implementation treats e.g. "5h"/"5 hour"/"5-hour" as one +// window and also surfaces "daily"/"monthly"; the schema may return any of these spellings. We +// lower-case and strip spaces, hyphens, and underscores before matching. A genuinely unknown window +// returns null so the caller can fall back to the bucket's own display_name. +export type AntigravityWindowKey = 'weekly' | '5h' | 'daily' | 'monthly' + +export function normalizeAntigravityWindow(window: string | null | undefined): AntigravityWindowKey | null { + const normalized = (window ?? '').toLowerCase().replace(/[\s_-]+/g, '') + if (normalized === '5h' || normalized === '5hour' || normalized === '5hours') { + return '5h' + } + if (normalized === 'weekly' || normalized === 'week') { + return 'weekly' + } + if (normalized === 'daily' || normalized === 'day') { + return 'daily' + } + if (normalized === 'monthly' || normalized === 'month') { + return 'monthly' + } + return null +} diff --git a/frontend/src/features/codex-keeper/api/codexKeeperApi.ts b/frontend/src/features/codex-keeper/api/codexKeeperApi.ts index ffd9b334..c6e69564 100644 --- a/frontend/src/features/codex-keeper/api/codexKeeperApi.ts +++ b/frontend/src/features/codex-keeper/api/codexKeeperApi.ts @@ -76,6 +76,31 @@ export function refreshCodexKeeperAccounts(payload: CodexKeeperRefreshPayload): return apiClient.post('/codex-keeper/accounts/refresh', payload) } +// CodexKeeperResetOutcome is the real business result of a reset. reset = a credit was +// redeemed now; already_redeemed = an in-flight redeem resolved idempotently; no_credit +// / nothing_to_reset = OpenAI reported nothing was redeemed (distinct reasons); +// cooldown_only = no credit available, only the local 429 cooldown was cleared. +export type CodexKeeperResetOutcome = + | 'reset' + | 'already_redeemed' + | 'no_credit' + | 'nothing_to_reset' + | 'cooldown_only' + +export interface CodexKeeperResetResult { + status: string + account: { + name: string + outcome: CodexKeeperResetOutcome + } +} + +export function resetCodexKeeperQuota(authName: string): Promise { + return apiClient.post('/codex-keeper/reset-quota', { + auth_name: authName, + }) +} + export function updateCodexKeeperPriority(authName: string, priority: number): Promise { return apiClient.patch(`/codex-keeper/accounts/${encodeURIComponent(authName)}/priority`, { priority, diff --git a/frontend/src/features/codex-keeper/keeperQuotaExhaustion.ts b/frontend/src/features/codex-keeper/keeperQuotaExhaustion.ts new file mode 100644 index 00000000..d056eab9 --- /dev/null +++ b/frontend/src/features/codex-keeper/keeperQuotaExhaustion.ts @@ -0,0 +1,18 @@ +import type { CodexKeeperAccount } from '@/shared/types/api' + +// isAntigravityAccount identifies an Antigravity-provider Keeper account. The Keeper now inspects +// both Codex and Antigravity accounts, so provider is the discriminator, not account type. +export function isAntigravityAccount(account: Pick): boolean { + return account.provider === 'antigravity' +} + +// isQuotaExhaustedAccount reports whether an account is in the Codex "quota exhausted" state. +// priority === -1 is the Codex quota-usage→priority policy's exhaustion marker; Antigravity does +// NOT run that policy, so a -1 on an Antigravity account must never be read as quota exhausted. +// Shared by every Keeper view (status + inspection settings) so the provider guard can't drift +// between them. +export function isQuotaExhaustedAccount( + account: Pick, +): boolean { + return !account.disabled && !isAntigravityAccount(account) && (account.priority ?? 0) === -1 +} diff --git a/frontend/src/features/codex-keeper/views/CodexKeeperInspectionView.vue b/frontend/src/features/codex-keeper/views/CodexKeeperInspectionView.vue index 85420e98..4d3cf5f5 100644 --- a/frontend/src/features/codex-keeper/views/CodexKeeperInspectionView.vue +++ b/frontend/src/features/codex-keeper/views/CodexKeeperInspectionView.vue @@ -37,6 +37,7 @@ import { stopCodexKeeper, updateCodexKeeperSettings, } from '@/features/codex-keeper/api/codexKeeperApi' +import { isQuotaExhaustedAccount } from '@/features/codex-keeper/keeperQuotaExhaustion' import type { CodexKeeperAccount, CodexKeeperPriorityRule, @@ -181,9 +182,6 @@ function applySettings(nextSettings: Awaited ({ ...rule })) } -function isQuotaExhaustedAccount(account: CodexKeeperAccount): boolean { - return !account.disabled && (account.priority ?? 0) === -1 -} async function loadAll() { isLoading.value = true @@ -511,7 +509,7 @@ onBeforeUnmount(() => {
-

{{ t('查看 Codex auth file 的健康、额度和优先级维护结果', 'View Codex auth file health, quota, and priority maintenance results') }}

+

{{ t('查看 Keeper 账号的健康、额度和优先级维护结果', 'View Keeper account health, quota, and priority maintenance results') }}

@@ -2248,7 +2665,7 @@ onBeforeUnmount(() => {