Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
cc7c68e
feat: add product name and logo branding configuration
Copilot Aug 6, 2026
08cf6e8
fix: address review comments on branding feature
Copilot Aug 6, 2026
8d6bc6c
fix: sync document.title with product name branding
bytemain Aug 10, 2026
90de775
fix: inject product branding into index.html server-side
Copilot Aug 19, 2026
441bf6a
fix: embed product info in HTML to eliminate /api/product-info request
Copilot Aug 19, 2026
67ac061
refactor: simplify productInfo.ts branding/title sync
Copilot Aug 20, 2026
d6932d5
refactor: drop dead /api/product-info endpoint and placeholder replace
bytemain Aug 21, 2026
89273f8
feat: projected per-account quota-window cost (本窗口预计消费)
Aug 21, 2026
11284f3
Revert "feat: projected per-account quota-window cost (本窗口预计消费)"
Aug 22, 2026
b419e61
fix(codex-keeper): label quota windows by actual window seconds (#3)
bytemain Aug 24, 2026
42ec869
fix(usage): show success rates with two decimals
bytemain Aug 25, 2026
f38f6c3
feat(usage): 缓存命中率 metric card on usage dashboards
Aug 26, 2026
45fabcd
Merge pull request #6 from bytemain/feat/cache-hit-rate-30
bytemain Aug 26, 2026
c6e4a46
feat(account-status): per-account quota reset with reset counter (#7)
bytemain Sep 4, 2026
edd0d29
fix(account-status): fail-closed reset verification, row fence, minim…
bytemain Sep 4, 2026
16bdb62
feat(account-status): replace reset-count column with quota reset-win…
bytemain Sep 4, 2026
88cd197
fix(account-status): reset-credit timeline from rate-limit-reset-cred…
bytemain Sep 4, 2026
a996697
feat(account-status): re-inspect on reset-quota + audit logging (#11)
bytemain Sep 5, 2026
da4484f
feat(account-status): true reset credits and subscription renewal (#12)
bytemain Sep 7, 2026
7884d37
feat(codex-keeper): support Antigravity quota inspection (#13)
bytemain Sep 9, 2026
d500474
feat(settings): configurable prefix for generated API keys (#14)
bytemain Sep 17, 2026
ad01478
ci(frontend): run lint and all smoke tests in CI and the Docker build…
bytemain Sep 17, 2026
994254c
feat(pricing): associate reverse-proxied model variants with canonica…
bytemain Sep 17, 2026
533d3e4
feat(cli): read-only usage-cost subcommand (cost grouped by model/pro…
bytemain Sep 20, 2026
d6cf7b8
feat: account-runway 只读子命令(配额续航测算+加号建议) (#20)
bytemain Sep 20, 2026
e0dfc0f
ci(backend): add minimal go build+test CI for PRs
Sep 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/build-and-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
54 changes: 53 additions & 1 deletion backend/cmd/cpa-helper/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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 <version>` 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
Expand All @@ -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
Expand Down Expand Up @@ -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 <version>
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 <days> [--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)
`)
}
104 changes: 103 additions & 1 deletion backend/cmd/cpa-helper/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package main
import (
"bytes"
"context"
"database/sql"
"path/filepath"
"strings"
"testing"
)
Expand All @@ -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)
}
Expand All @@ -26,3 +28,103 @@ func TestBackendAddrRejectsBarePort(t *testing.T) {
t.Fatal("backendAddr accepted a bare port")
}
}

// TestMigrateDownToRollsBackToTarget proves the real `migrate down-to <version>`
// 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
}
2 changes: 1 addition & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading