Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
64e543f
fix(config): read client API keys from CPA v8 layout
camy-x Sep 28, 2026
998c9c7
Merge pull request #887 from camy-x/fix/v8-api-key-config-layout
seakee Sep 29, 2026
3428c19
fix(usage): verify retained pricing rebuild source
seakee Sep 30, 2026
c86a0de
fix(model-price): gate structure changes on retained coverage
seakee Sep 30, 2026
74c7efa
fix(usage): rebuild pricing from retained events
seakee Sep 30, 2026
39f680d
test(model-price): allow retained structural changes
seakee Sep 30, 2026
a0f112a
test(usage): cover retained pricing rebuild
seakee Sep 30, 2026
5bd89d4
test(monitoring): expect retained pricing rebuild
seakee Sep 30, 2026
38acf24
test(monitoring): model valid retained rebuild resume
seakee Sep 30, 2026
597318c
fix(usage): classify retained coverage errors
seakee Sep 30, 2026
b762f1b
fix(model-price): preserve verifier system errors
seakee Sep 30, 2026
20e5ba4
test(model-price): preserve verifier system errors
seakee Sep 30, 2026
5828e5a
Merge pull request #893 from seakee/fix/890-retained-pricing-rebuild
seakee Sep 30, 2026
f22cf88
fix(usage): degrade account history when pricing is unrecoverable
seakee Sep 30, 2026
5a44237
fix(web): handle unavailable account history cost
seakee Sep 30, 2026
921bcbb
fix(usage): isolate account pricing coverage failures
seakee Sep 30, 2026
11eb207
perf(usage): scope retained pricing recovery by account
seakee Sep 30, 2026
57616ad
Merge pull request #894 from seakee/fix/891-account-history-pricing-d…
seakee Sep 30, 2026
66151fc
chore(release): prepare v1.14.2
seakee Sep 30, 2026
d461d9c
Merge pull request #895 from seakee/release/v1.14.2
seakee Sep 30, 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
20 changes: 9 additions & 11 deletions apps/manager-server/internal/repository/modelprice/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
"time"

"github.com/seakee/cpa-manager-plus/apps/manager-server/internal/model"
sqliterepo "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/sqlite"
"github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/usagepricing"
)

var ErrStructureChangeAfterRawDeletion = errors.New("model price structure cannot change after archived raw usage has been deleted")
Expand Down Expand Up @@ -251,13 +251,12 @@ func (r *repository) ReplaceAll(ctx context.Context, prices map[string]model.Mod
beforeRevision := model.ModelPriceStructureRevision(beforePrices)
afterRevision := model.ModelPriceStructureRevision(normalizedPrices)
if beforeRevision != afterRevision {
hasDeletedRaw, err := sqliterepo.HistoricalRawDeletionExists(tx)
if err != nil {
if err := usagepricing.VerifyRetainedPricingRebuildSourceTx(ctx, tx); err != nil {
if errors.Is(err, usagepricing.ErrRetainedPricingHistoryIncomplete) {
return fmt.Errorf("%w: %v", ErrStructureChangeAfterRawDeletion, err)
}
return err
}
if hasDeletedRaw {
return ErrStructureChangeAfterRawDeletion
}
}

if _, err := tx.ExecContext(ctx, `delete from model_price_service_tiers`); err != nil {
Expand Down Expand Up @@ -463,13 +462,12 @@ func (r *repository) UpsertSynced(ctx context.Context, prices map[string]model.M
beforeRevision := model.ModelPriceStructureRevision(beforePrices)
afterRevision := model.ModelPriceStructureRevision(afterPrices)
if beforeRevision != afterRevision {
hasDeletedRaw, err := sqliterepo.HistoricalRawDeletionExists(tx)
if err != nil {
if err := usagepricing.VerifyRetainedPricingRebuildSourceTx(ctx, tx); err != nil {
if errors.Is(err, usagepricing.ErrRetainedPricingHistoryIncomplete) {
return model.ModelPriceSyncResult{}, fmt.Errorf("%w: %v", ErrStructureChangeAfterRawDeletion, err)
}
return model.ModelPriceSyncResult{}, err
}
if hasDeletedRaw {
return model.ModelPriceSyncResult{}, ErrStructureChangeAfterRawDeletion
}
}
sort.Strings(result.Preserved)
if err := tx.Commit(); err != nil {
Expand Down
162 changes: 162 additions & 0 deletions apps/manager-server/internal/repository/modelprice/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/seakee/cpa-manager-plus/apps/manager-server/internal/model"
"github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/modelprice"
sqliterepo "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/sqlite"
"github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/usagemonitoring"
)

func openTestDB(t *testing.T) (*sql.DB, modelprice.Repository) {
Expand Down Expand Up @@ -43,6 +44,36 @@ func markRawDeleted(t *testing.T, db *sql.DB) {
insertArchiveRef(t, db, 2000)
}

func markRawDeletedWithRetainedProjection(t *testing.T, db *sql.DB) {
t.Helper()
ctx := context.Background()
const eventHash = "hash-retained"
if _, err := db.Exec(`insert into usage_events(
id, event_hash, timestamp_ms, timestamp, model, created_at_ms
) values(1, ?, 1000, '1970-01-01T00:00:01Z', 'model-a', 1000)`, eventHash); err != nil {
t.Fatalf("insert retained raw event: %v", err)
}
projection := usagemonitoring.New(db)
result, err := projection.CatchUpProjection(ctx, 10, 1500)
if err != nil {
t.Fatalf("build retained projection: %v", err)
}
if result.CoverageEventID != 1 || result.Pending {
t.Fatalf("unexpected retained projection result: %#v", result)
}
if _, err := db.Exec(`pragma foreign_keys = off`); err != nil {
t.Fatalf("disable foreign keys: %v", err)
}
if _, err := db.Exec(`insert into usage_archive_event_refs(
event_hash, run_id, segment_sequence, raw_event_id, timestamp_ms, archived_at_ms, raw_deleted_at_ms
) values(?, 'run-retained', 1, 1, 1000, 1600, 1700)`, eventHash); err != nil {
t.Fatalf("insert retained archive ref: %v", err)
}
if _, err := db.Exec(`delete from usage_events where id = 1`); err != nil {
t.Fatalf("delete retained raw event: %v", err)
}
}

// Test P1-1:没有 raw deletion,structure change 允许
func TestModelPriceStructureChangeAllowedWithoutRawDeletion(t *testing.T) {
ctx := context.Background()
Expand Down Expand Up @@ -514,3 +545,134 @@ func TestModelPriceUpsertSyncedManualPriceAndNewModelRejectedWithRawDeletion(t *
t.Fatalf("model-b should not exist after rollback")
}
}


func TestModelPriceStructureChangeAllowedAfterRawDeletionWithRetainedProjection(t *testing.T) {
ctx := context.Background()
db, repo := openTestDB(t)

if err := repo.ReplaceAll(ctx, map[string]model.ModelPrice{
"model-a": {Prompt: 1.0, Completion: 2.0},
}); err != nil {
t.Fatalf("initial ReplaceAll: %v", err)
}
markRawDeletedWithRetainedProjection(t, db)

if err := repo.ReplaceAll(ctx, map[string]model.ModelPrice{
"model-a": {Prompt: 1.0, Completion: 2.0},
"model-b": {Prompt: 3.0, Completion: 4.0},
}); err != nil {
t.Fatalf("structure change with retained projection: %v", err)
}

persisted, err := repo.LoadAll(ctx)
if err != nil {
t.Fatalf("LoadAll: %v", err)
}
if _, ok := persisted["model-b"]; !ok {
t.Fatalf("expected model-b to be persisted")
}
}

func TestModelPriceUpsertSyncedStructureChangeAllowedAfterRawDeletionWithRetainedProjection(t *testing.T) {
ctx := context.Background()
db, repo := openTestDB(t)

if err := repo.ReplaceAll(ctx, map[string]model.ModelPrice{
"model-a": {Prompt: 1.0, Completion: 2.0},
}); err != nil {
t.Fatalf("initial ReplaceAll: %v", err)
}
markRawDeletedWithRetainedProjection(t, db)

result, err := repo.UpsertSynced(ctx, map[string]model.ModelPrice{
"model-b": {Prompt: 3.0, Completion: 4.0, Source: "sync"},
})
if err != nil {
t.Fatalf("UpsertSynced with retained projection: %v", err)
}
if result.Imported != 1 {
t.Fatalf("unexpected sync result: %+v", result)
}
persisted, err := repo.LoadAll(ctx)
if err != nil {
t.Fatalf("LoadAll: %v", err)
}
if _, ok := persisted["model-b"]; !ok {
t.Fatalf("expected model-b to be persisted")
}
}


func TestModelPriceStructureChangePreservesRetainedVerifierSystemError(t *testing.T) {
ctx := context.Background()
db, repo := openTestDB(t)

if err := repo.ReplaceAll(ctx, map[string]model.ModelPrice{
"model-a": {Prompt: 1.0, Completion: 2.0},
}); err != nil {
t.Fatalf("initial ReplaceAll: %v", err)
}
markRawDeleted(t, db)
if _, err := db.Exec(`drop table usage_monitoring_rollup_state`); err != nil {
t.Fatalf("drop monitoring rollup state: %v", err)
}

err := repo.ReplaceAll(ctx, map[string]model.ModelPrice{
"model-a": {Prompt: 1.0, Completion: 2.0},
"model-b": {Prompt: 3.0, Completion: 4.0},
})
if err == nil {
t.Fatal("expected retained verifier system error")
}
if errors.Is(err, modelprice.ErrStructureChangeAfterRawDeletion) {
t.Fatalf("system error was misclassified as structure conflict: %v", err)
}

persisted, loadErr := repo.LoadAll(ctx)
if loadErr != nil {
t.Fatalf("LoadAll: %v", loadErr)
}
if len(persisted) != 1 {
t.Fatalf("expected mutation rollback, got %d models", len(persisted))
}
if _, ok := persisted["model-b"]; ok {
t.Fatal("model-b should not exist after verifier system error")
}
}

func TestModelPriceUpsertSyncedPreservesRetainedVerifierSystemError(t *testing.T) {
ctx := context.Background()
db, repo := openTestDB(t)

if err := repo.ReplaceAll(ctx, map[string]model.ModelPrice{
"model-a": {Prompt: 1.0, Completion: 2.0},
}); err != nil {
t.Fatalf("initial ReplaceAll: %v", err)
}
markRawDeleted(t, db)
if _, err := db.Exec(`drop table usage_monitoring_rollup_state`); err != nil {
t.Fatalf("drop monitoring rollup state: %v", err)
}

result, err := repo.UpsertSynced(ctx, map[string]model.ModelPrice{
"model-b": {Prompt: 3.0, Completion: 4.0, Source: "sync"},
})
if err == nil {
t.Fatalf("expected retained verifier system error, got result=%+v", result)
}
if errors.Is(err, modelprice.ErrStructureChangeAfterRawDeletion) {
t.Fatalf("system error was misclassified as structure conflict: %v", err)
}

persisted, loadErr := repo.LoadAll(ctx)
if loadErr != nil {
t.Fatalf("LoadAll: %v", loadErr)
}
if len(persisted) != 1 {
t.Fatalf("expected sync rollback, got %d models", len(persisted))
}
if _, ok := persisted["model-b"]; ok {
t.Fatal("model-b should not exist after verifier system error")
}
}
Loading
Loading