diff --git a/.kiro/specs/billing-post-usage-correctness-hardening/spec.json b/.kiro/specs/billing-post-usage-correctness-hardening/spec.json index 239def0d..ab51c414 100644 --- a/.kiro/specs/billing-post-usage-correctness-hardening/spec.json +++ b/.kiro/specs/billing-post-usage-correctness-hardening/spec.json @@ -1,23 +1,23 @@ { "feature_name": "billing-post-usage-correctness-hardening", "created_at": "2026-08-16T15:42:00+02:00", - "updated_at": "2026-08-16T15:42:00+02:00", + "updated_at": "2026-08-16T21:08:51+02:00", "language": "en", "phase": "tasks-generated", "approvals": { "requirements": { "generated": true, - "approved": false + "approved": true }, "design": { "generated": true, - "approved": false + "approved": true }, "tasks": { "generated": true, - "approved": false + "approved": true } }, - "ready_for_implementation": false, + "ready_for_implementation": true, "project_description": "Correct and harden the post-usage billing architecture merged in PR #340 without reopening the broader simplification refactor: preserve actual B-leg ordering, settle against the correct backend/model customer rate card, make customer settlement independent from provider-cost catalog readiness, and bound all runtime billing-call state to the lifetime of one BillingCallID." } diff --git a/.kiro/specs/billing-post-usage-correctness-hardening/tasks.md b/.kiro/specs/billing-post-usage-correctness-hardening/tasks.md index 029f5621..93db6533 100644 --- a/.kiro/specs/billing-post-usage-correctness-hardening/tasks.md +++ b/.kiro/specs/billing-post-usage-correctness-hardening/tasks.md @@ -10,28 +10,28 @@ ## Phase 0 — Characterize the Blocking Defects -- [ ] 0.1 Add RED regression for reversed lexical B-leg IDs versus real attempt sequence. +- [x] 0.1 Add RED regression for reversed lexical B-leg IDs versus real attempt sequence. - Cover failed/canceled/no-surfaced selection and prove current positional reconstruction selects the wrong leg. - _Boundary: core billing + B2BUA characterization tests_ - _Depends: none_ - _Validation: go test ./internal/core/billing ./internal/core/b2bua_ - _Requirements: 2.1-2.8, 8.1, 8.4_ -- [ ] 0.2 Add RED mixed-model customer-pricing regressions. +- [x] 0.2 Add RED mixed-model customer-pricing regressions. - Prove admission sees route-specific prices while current settlement loses them. - _Boundary: billingcompose + billing rating tests_ - _Depends: none_ - _Validation: go test ./internal/core/billing ./internal/infra/billingcompose ./internal/infra/billingadmission_ - _Requirements: 4.1-4.8, 8.1, 8.4_ -- [ ] 0.3 Add RED customer/operator snapshot independence regression. +- [x] 0.3 Add RED customer/operator snapshot independence regression. - Missing operator rate must currently reproduce the customer-settlement blockage. - _Boundary: billingcompose / post-usage worker tests_ - _Depends: none_ - _Validation: go test ./internal/infra/billingcompose ./internal/core/billing_ - _Requirements: 5.1-5.7, 8.2_ -- [ ] 0.4 Add retained-state characterization and architecture baseline. +- [x] 0.4 Add retained-state characterization and architecture baseline. - Exercise many BillingCallIDs through one executor and record current collector growth plus relevant production symbols. - _Boundary: runtime tests / architecture baseline_ - _Depends: none_ @@ -40,28 +40,28 @@ ## Phase 1 — Preserve B-Leg Sequence Durably -- [ ] 1.1 Add sequence-aware `CallLegUsageRecord` contract and v1/v2 replay tests. +- [x] 1.1 Add sequence-aware `CallLegUsageRecord` contract and v1/v2 replay tests. - New writes require positive `AttemptSeq`; existing v1 rows remain verifiable. - _Boundary: core billing contracts_ - _Depends: 0.1_ - _Validation: go test ./internal/core/billing -run 'CallLeg|Sequence|Replay'_ - _Requirements: 2.1-2.8, 3.2-3.6_ -- [ ] 1.2 Add Bun sequence migration and cross-dialect store support. +- [x] 1.2 Add Bun sequence migration and cross-dialect store support. - Nullable legacy column, unique known sequence per call, no guessed backfill. - _Boundary: billingstore migrations / persistence_ - _Depends: 1.1_ - _Validation: go test ./internal/infra/billingstore -run 'Migration|CallLeg|Sequence'; LIP_REQUIRE_POSTGRES=1 go test -tags=integration ./internal/infra/billingstore -run 'Postgres.*Sequence|PostgresBillingStoreContract'_ - _Requirements: 2.3-2.5, 3.1-3.7, 8.5_ -- [ ] 1.3 Thread exact B2BUA sequence through all terminal leg producers. +- [x] 1.3 Thread exact B2BUA sequence through all terminal leg producers. - Cover opened, never-started, failed-open, swallowed, parallel loser/winner and cancellation paths. - _Boundary: runtime B2BUA -> billing record seam_ - _Depends: 1.1_ - _Validation: go test ./internal/core/runtime -run 'Billing.*Leg|Parallel|Abort|Failover'_ - _Requirements: 2.1-2.8, 7.1-7.2_ -- [ ] 1.4 Make customer leg selection consume only authoritative sequence. +- [x] 1.4 Make customer leg selection consume only authoritative sequence. - Remove positional reconstruction; legacy unknown sequence fails closed only when order is required. - _Boundary: core billing rating_ - _Depends: 1.1, 1.3_ @@ -70,28 +70,28 @@ ## Phase 2 — Correct Customer Pricing and Resolver Independence -- [ ] 2.1 Split customer snapshot resolution from operator-rate resolution. +- [x] 2.1 Split customer snapshot resolution from operator-rate resolution. - Customer path resolves only customer pricing/policy/model cards. - _Boundary: billingcompose_ - _Depends: 0.3_ - _Validation: go test ./internal/infra/billingcompose_ - _Requirements: 5.1-5.7_ -- [ ] 2.2 Carry model-specific pricing through `CallRatingInput`. +- [x] 2.2 Carry model-specific pricing through `CallRatingInput`. - Remove unused operator-rate collection from customer rating input. - _Boundary: core billing contracts_ - _Depends: 0.2, 2.1_ - _Validation: go test ./internal/core/billing ./internal/infra/billingcompose_ - _Requirements: 4.1-4.7, 5.2, 5.6_ -- [ ] 2.3 Rate selected B-legs with their effective backend/model customer cards. +- [x] 2.3 Rate selected B-legs with their effective backend/model customer cards. - Default only when no applicable override exists; missing required card fails explicitly. - _Boundary: core billing rating_ - _Depends: 2.2_ - _Validation: go test ./internal/core/billing -run 'RateCall|Model|Pricing|Failover'_ - _Requirements: 4.1-4.8_ -- [ ] 2.4 Prove provider-rate failure cannot hold customer exposure open. +- [x] 2.4 Prove provider-rate failure cannot hold customer exposure open. - Customer operation settles/closes while provider-cost work stays pending/unreconciled. - _Boundary: post-usage integration / billingstore_ - _Depends: 2.1, 2.3_ @@ -100,28 +100,28 @@ ## Phase 3 — Replace Executor-Lifetime Billing State -- [ ] 3.1 Introduce private request/BillingCallID-scoped state with RED ownership tests. +- [x] 3.1 Introduce private request/BillingCallID-scoped state with RED ownership tests. - Allocate once per prepared invocation and define shared retry/parallel/interleaved ownership. - _Boundary: runtime private lifecycle_ - _Depends: 0.4_ - _Validation: go test ./internal/core/runtime -run 'BillingCallState|BillingCallID|Interleaved|Parallel'_ - _Requirements: 6.1-6.6_ -- [ ] 3.2 Move allocated-leg set and terminal timing bounds into call-scoped state. +- [x] 3.2 Move allocated-leg set and terminal timing bounds into call-scoped state. - Closure reads set/timing only; no financial evidence aggregation. - _Boundary: runtime terminal billing seam_ - _Depends: 1.3, 3.1_ - _Validation: go test ./internal/core/runtime -run 'Billing.*Closure|Billing.*Leg|Abort'_ - _Requirements: 6.1-6.7, 7.1-7.2_ -- [ ] 3.3 Move `FinalizeBilling` single-flight into call-scoped state. +- [x] 3.3 Move `FinalizeBilling` single-flight into call-scoped state. - External backend finalization occurs outside call-state lock; racing terminal paths share result. - _Boundary: runtime/backend billing-finalization seam_ - _Depends: 3.1_ - _Validation: go test ./internal/core/runtime -run 'FinalizeBilling|Parallel|Close|Terminal'_ - _Requirements: 6.2-6.6, 6.9, 7.4-7.5_ -- [ ] 3.4 Delete executor-global lifetime-growing billing-call maps. +- [x] 3.4 Delete executor-global lifetime-growing billing-call maps. - Remove obsolete collector eviction helpers and prove no completed call remains reachable from `Executor`. - _Boundary: runtime simplification_ - _Depends: 3.2, 3.3_ @@ -130,28 +130,28 @@ ## Phase 4 — Brownfield and B2BUA Integration Hardening -- [ ] 4.1 Add legacy sequence-unknown processing tests. +- [x] 4.1 Add legacy sequence-unknown processing tests. - Completed surfaced and charge-all sequence-independent cases may settle; sequence-dependent ambiguous cases reconcile. - _Boundary: billing processor/store_ - _Depends: 1.2, 1.4_ - _Validation: go test ./internal/core/billing ./internal/infra/billingstore -run 'Legacy|Sequence|CompleteCall|Reconcile'_ - _Requirements: 3.1-3.7, 8.5_ -- [ ] 4.2 Add full failover/parallel/mixed-model B2BUA billing matrix. +- [x] 4.2 Add full failover/parallel/mixed-model B2BUA billing matrix. - Include opaque reverse-order IDs, different rates, rejected/never-started legs, surfaced winner and cancellations. - _Boundary: runtime + billing integration_ - _Depends: 1.4, 2.3, 3.4_ - _Validation: go test ./internal/core/runtime ./internal/core/billing ./internal/infra/billingstore_ - _Requirements: 2.8, 4.7-4.8, 7.1-7.5, 8.4-8.5_ -- [ ] 4.3 Prove post-output persistence failures still cannot cause provider retry. +- [x] 4.3 Prove post-output persistence failures still cannot cause provider retry. - Preserve current durable append/outbox semantics while using call-scoped state. - _Boundary: runtime terminal failure tests_ - _Depends: 3.4_ - _Validation: go test ./internal/core/runtime ./internal/infra/runtimebundle -run 'Billing|Append|Retry|Output'_ - _Requirements: 1.4, 7.3, 7.6_ -- [ ] 4.4 Run targeted race and retained-memory tests. +- [x] 4.4 Run targeted race and retained-memory tests. - Repeated calls on one executor, Recv/Close races, parallel terminalization, finalization single-flight. - _Boundary: concurrency / runtime lifecycle_ - _Depends: 3.4, 4.2_ @@ -160,28 +160,28 @@ ## Phase 5 — Ratchets and Final Certification -- [ ] 5.1 Add architecture guards for sequence, pricing, resolver and state ownership. +- [x] 5.1 Add architecture guards for sequence, pricing, resolver and state ownership. - Forbid positional/lexical financial order, customer->operator-rate coupling, and executor-global billing call registries. - _Boundary: architecture tests_ - _Depends: 1.4, 2.4, 3.4_ - _Validation: go test ./internal/archtest_ - _Requirements: 8.6-8.7_ -- [ ] 5.2 Re-run billingstore SQLite/PostgreSQL contract and replay tests. +- [x] 5.2 Re-run billingstore SQLite/PostgreSQL contract and replay tests. - Include migration from pre-sequence schema plus new writes. - _Boundary: persistence certification_ - _Depends: 4.1_ - _Validation: go test ./internal/infra/billingstore; LIP_REQUIRE_POSTGRES=1 go test -tags=integration ./internal/infra/billingstore -run 'Postgres.*Billing|Postgres.*Sequence'_ - _Requirements: 3.1-3.7, 8.5, 8.8_ -- [ ] 5.3 Update billing architecture/host docs for corrected semantics. +- [x] 5.3 Update billing architecture/host docs for corrected semantics. - Document attempt sequence as authoritative fact and customer/provider snapshot independence; do not document successor deletions as completed. - _Boundary: docs / steering_ - _Depends: 5.1_ - _Validation: make docs-check; go test ./internal/archtest_ - _Requirements: 1.1-1.6, 2.1-2.8, 4.1-5.7, 6.1-6.9_ -- [ ] 5.4 Perform final spec conformance review and establish successor baseline. +- [x] 5.4 Perform final spec conformance review and establish successor baseline. - Verify every criterion, no hold regression, no wrong-price/sequence behavior, bounded runtime state, and record exact main SHA/production shape for the convergence spec. - _Boundary: final certification_ - _Depends: 5.1, 5.2, 5.3_ diff --git a/.kiro/specs/billing-post-usage-correctness-hardening/validation.md b/.kiro/specs/billing-post-usage-correctness-hardening/validation.md new file mode 100644 index 00000000..9e9e3a2d --- /dev/null +++ b/.kiro/specs/billing-post-usage-correctness-hardening/validation.md @@ -0,0 +1,44 @@ +# Validation and Successor Baseline + +Concise Phase 5 certification evidence for `billing-post-usage-correctness-hardening`. +`spec.json` intentionally remains in `tasks-generated` with approvals and `ready_for_implementation: true`; it is not archived because the successor spec owns TUR/LUR cleanup. + +## Baseline for `billing-architecture-final-convergence` + +- Main branch commit: `bdf6e5037c75a2586015cbf1ecac5207dadc3afe` (parent of this branch). +- Working branch: `feat/billing-post-usage-correctness-hardening` (certification tree at commit `cdbb5d3f`). +- Production shape the successor must preserve: + - one `BillingCallID` per invocation; cheap credit screen -> route/quote -> atomic operational exposure admission -> billing-blind execution -> terminal leg/call records -> post-usage customer settlement (independent of provider-cost readiness). + - positive persisted `attempt_seq` (v2 fingerprint) is the only legal customer-leg ordering source; `ExpectedBLegIDs` is a completeness set. + - customer rating consumes only `CustomerRatingSnapshots` (pricing, policy, per-model cards); `OperatorRate` is provider-cost-only (`ProviderCostJoinResolver.ResolveProviderCost`). + - runtime billing bookkeeping lives in private `billingCallState` on `preparedRequest` / `retryRecvStream`; `Executor` holds no billing-call registry. + - legacy TUR/LUR rating bridge remains a temporary adapter and is a successor deletion target; nothing here deletes it. + +## Requirement conformance summary (all green) + +| Requirement | Outcome | Evidence | +| :--- | :--- | :--- | +| 1 Monetary authority preserved | PASS | hold-deletion + no-stream-money ratchets active; `go test ./internal/archtest` | +| 2 Actual B-leg attempt order | PASS | sequence contract/rating tests; sequence ratchets; `TestPostgresCallLegSequencePersistence` | +| 3 Pre-fix durable rows safe | PASS | legacy NULL/v1 replay SQLite+Postgres; pre-sequence schema migration test | +| 4 Correct backend/model customer pricing | PASS | model-card resolution through `CallRatingInput`; rating tests | +| 5 Customer/provider independence | PASS | resolver split; operator-freedom ratchets; `provider_cost_independence_test.go` | +| 6 Bounded runtime billing state | PASS | call-scoped state + stress/race tests; state-ownership ratchets | +| 7 Terminal usage/failure semantics | PASS | closure/finalization/parallel/post-output tests | +| 8 Correction proven before cleanup | PASS | Phase 0 RED tests, matrix, ratchets, this review | + +## Verification commands run + +- `go test ./internal/archtest/...` — pass (incl. new Phase 5.1 ratchets, per-file 500-line limit, docs contract). +- `go test ./internal/core/billing ./internal/core/runtime ./internal/infra/billingcompose ./internal/infra/billingadmission ./internal/infra/runtimebundle ./internal/infra/billingstore` — pass. +- `LIP_REQUIRE_POSTGRES=1 go test -tags=integration ./internal/infra/billingstore` — pass (3 Postgres tests actually ran; skip cleanly when DSN/requirement absent). +- `go test -race ./internal/core/runtime -run 'Billing|Parallel|Close|CallState|Interleaved'` and `go test -race ./internal/infra/billingstore -run 'Concurrent|Sequence|Legacy'` — pass. +- `go vet` affected packages — pass. +- `make quality-checks` — pass. `make test-unit` — pass. +- `make test-race` on Windows is a documented skip (toolchain policy); targeted `-race` runs above provide evidence. + +## Residual risks + +- Sequences for legacy pre-fix rows remain unknown by design; order-dependent policies on such calls fail closed into reconcile-required (expected brownfield behavior). +- Postgres parity runs relied on a locally configured DSN; CI must keep `LIP_REQUIRE_POSTGRES=1` for the same coverage. +- Full `make test-race` only runs in Linux CI; Windows evidence is from targeted `-race` runs. diff --git a/.kiro/steering/structure.md b/.kiro/steering/structure.md index 6aa5ab84..75db5e9c 100644 --- a/.kiro/steering/structure.md +++ b/.kiro/steering/structure.md @@ -52,7 +52,7 @@ Core owns orchestration and policy. Core imports `pkg/lipapi` and `pkg/lipsdk`; - **Streaming**: `stream/` (canonical stream, event pumps), `streamrecovery/` - **Hooks & Extensions**: `hooks/` (stage evaluation), `extensions/` (stage-four extension platform) - **Core State & Accounting**: `auxreq/`, `state/`, `traffic/`, `workspace/`, `modelcatalog/`, `modelregistry/`, `accounting/`, `billing/`, `tokenaccounting/` - - `billing/` owns BillingCallID, quote/exposure policy, immutable per-call/per-leg usage contracts, post-usage rating, journal settlement, and billing reports. Runtime performs cheap credit screening and atomic operational exposure admission, then appends terminal usage; it must not enrich prices or write the legacy token ledger. + - `billing/` owns BillingCallID, quote/exposure policy, immutable per-call/per-leg usage contracts (including authoritative persisted `AttemptSeq`), post-usage rating, journal settlement, and billing reports. Runtime performs cheap credit screening and atomic operational exposure admission, then appends terminal usage; it must not enrich prices or write the legacy token ledger. Customer rating resolves customer pricing and model cards only, independent of provider/operator-rate readiness; runtime billing bookkeeping is `BillingCallID`-scoped (no executor-global call registry). - `tokenaccounting/` remains a protocol/quota usage projection and admin counting surface only; it is not a financial balance or journal input. - Durable money persistence is `internal/infra/billingstore` (Bun). Host injection is `internal/infra/billingcompose` (snapshot catalog + identity) plus `runtimebundle.ComposeBilling`. Admission adapter is `internal/infra/billingadmission`. Public `pkg/lipruntime.Options` stays non-money. diff --git a/.kiro/steering/tech.md b/.kiro/steering/tech.md index 3c9c1918..4e072c1b 100644 --- a/.kiro/steering/tech.md +++ b/.kiro/steering/tech.md @@ -46,6 +46,9 @@ - **Injection only**: Hosts open the durable store themselves and call `runtimebundle.ComposeBilling`, then pass `ProductionOptions` into `BuildHost`. YAML `accounting.billing.authoritative: true` is a fail-closed gate, not a DSN factory. Stock `lipstd` does not call `ComposeBilling`. Public `pkg/lipruntime.Options` stays non-money. - **Catalog vs journal**: Snapshot **bodies** live in the process-local catalog (ID+Version). Exposure/call/usage records store immutable refs only. A missing referenced version fails closed at rating. - **Billing separation**: Operational exposure is not settled money. Admission does not post a journal or mutate balance; customer settlement closes exposure after terminal usage, while provider COGS is an independent per-B-leg operation. +- **Sequence vs order**: the persisted positive `attempt_seq` (v2 fingerprint) is the authoritative B2BUA attempt order; `ExpectedBLegIDs` is a completeness set whose ordering has no financial meaning. Legacy rows keep `attempt_seq NULL` under the v1 contract and fail closed (`ErrBillingAttemptSequenceUnknown`) whenever order-based selection needs a sequence. +- **Snapshot independence**: customer rating resolves only customer pricing/policy/model cards; operator-rate lookup belongs solely to provider-cost resolution, so missing provider-cost data never blocks customer settlement or exposure close. +- **Call-scoped state**: runtime billing bookkeeping lives in one private `billingCallState` per `BillingCallID`/prepared request; the executor owns no lifetime-growing billing-call registry. - **Leftover YAML**: `accounting.ledger.*` may parse but must not open. Production `accounting.authority` rejects monetary `budget` / `spend_cap` / `money_nano`. --- diff --git a/docs/billing-host-composition.md b/docs/billing-host-composition.md index e9c07b4d..bfd2e044 100644 --- a/docs/billing-host-composition.md +++ b/docs/billing-host-composition.md @@ -58,6 +58,28 @@ Execution never renews, decrements, or closes exposure. Terminal usage persisten Historical authorization-hold rows are migration/reconciliation evidence only. New authoritative composition does not require authorization, hold lookup, hold release, expiry, or authorization-book capabilities. Open-hold inventory blocks ready accounts until reconciled; after open inventory is empty, `authorization_holds` is dropped and is not part of the normal call path. +## B-leg attempt sequence is authoritative + +Every new terminal B-leg record persists the exact positive B2BUA attempt sequence (`b2bua.BLegRecord.Seq`) in `CallLegUsageRecord.AttemptSeq` and the durable `usage_leg_records.attempt_seq` column. The runtime copies the value verbatim from the authoritative allocation point; it is never reconstructed from B-leg ids, array position, timestamps, provider order, or completion order. Within one `BillingCallID` two persisted legs cannot claim the same positive sequence unless they are the idempotent byte-identical same leg; the `(call_id, attempt_seq)` unique index and the sequence-aware replay fingerprint enforce that. + +Post-usage customer leg selection consults only the persisted sequence. `ExpectedBLegIDs` is a canonical completeness set: it may be sorted for completeness checks, but its ordering has no financial meaning. When an interrupted call has multiple accepted legs and no surfaced winner, customer rating chooses the latest accepted leg by `AttemptSeq` — never by lexical B-leg-id order, storage order, or timestamps. + +## Customer and provider economics resolve independently + +Customer rating resolves customer pricing, charge policy, and per-backend/model customer pricing cards only. Provider-cost resolution is a separate path that reads operator rates. Customer settlement never looks up, validates, or requires operator rates: missing, invalid, stale, or unreconciled provider-cost data cannot block an otherwise rateable customer call from settling or from closing operational exposure. Provider COGS remains an independent per-B-leg operation that may retry or stay unreconciled without changing the customer posting. + +## Billing bookkeeping is request-scoped + +Runtime billing bookkeeping is owned by one private call-scoped state object allocated together with the `BillingCallID` in the prepared request and shared by that request's retries, failover alternatives, parallel arms, and hidden interleaved continuations. Retained state is bounded to the active call: allocated B-leg identities and sequences, terminal timing bounds, and per-B-leg finalization single-flight entries all become unreachable when the call leaves scope. The `Executor` holds no lifetime-growing billing-call registry or map; a later invocation on the same A-leg/session receives a distinct state object and distinct `BillingCallID`. + +## Legacy rows without a sequence + +`attempt_seq` is nullable. Rows persisted before this correction keep `attempt_seq NULL` and remain readable under the original v1 fingerprint contract; the upgrade never rewrites opaque legacy B-leg ids into guessed sequence values. New runtime appends always require a known positive sequence. A legacy row with unknown sequence may still be rated automatically when the applicable customer policy is provably sequence-independent for that call, such as a completed call with an unambiguous surfaced leg or a charge-all policy. When sequence is required to choose the customer-billable leg and the legacy row lacks it, the post-usage processor fails closed into `ErrBillingAttemptSequenceUnknown` and the call is retried and ultimately marked reconcile-required — it never sorts ids or timestamps to guess execution order. + +## Successor boundary + +The legacy `TurnUsageRecord`/`LegUsageRecord` rating bridge remains in place as a temporary implementation adapter and is deliberately not described as deleted here. Its removal, the retirement of the old TUR/LUR tables and `reserved_nano` residue, and the remaining economic-architecture simplifications belong to the separate `billing-architecture-final-convergence` effort and must preserve every behavior documented in this page. + ## Reports and trusted provisioning `ReportingStore` separates settled customer spend, open operational exposure, and independent provider cost. Use `QueryOpenExposures` and `CallExplanation` (admin `/exposures` and `/call`) for call-path diagnostics. Retired TUR-processing and authorization-hold report endpoints are removed; `reserved_nano` remains a legacy always-zero account column rejected on ready accounts. Session/A-leg reports may aggregate multiple BillingCallIDs but never use A-leg or session identity as a financial idempotency key. diff --git a/internal/archtest/billing_correctness_ratchet.go b/internal/archtest/billing_correctness_ratchet.go new file mode 100644 index 00000000..9392df7c --- /dev/null +++ b/internal/archtest/billing_correctness_ratchet.go @@ -0,0 +1,280 @@ +package archtest + +import ( + "fmt" + "go/ast" + "os" + "path/filepath" + "strings" +) + +// Phase 5.1 correctness ratchets for the corrected post-usage billing baseline. +// +// Four semantic guards protect the converged architecture against regressions: +// 1. attempt sequence authority — customer leg selection may never derive +// B2BUA attempt order from slice position, opaque B-leg ids, timestamps, or +// provider order; the rating adapter must copy the persisted AttemptSeq. +// 2. customer/operator snapshot independence — customer rating must never +// resolve, carry, or depend on operator-rate data. +// 3. request-scoped billing state — executor-global lifetime-growing +// billing-call registries/maps are forbidden; bookkeeping lives on +// request/BillingCallID-scoped objects. +// 4. monetary hold and stream-money protection — the existing hold-deletion +// and no-stream-money ratchets must remain active and green. +// +// Guards target symbols and semantics (AST), not formatting. + +const ( + BillingCorrectnessRuleSequencePositional = "billing_attempt_sequence_positional" + BillingCorrectnessRuleSequenceTimestamp = "billing_attempt_sequence_timestamp" + BillingCorrectnessRuleSequenceLexical = "billing_attempt_sequence_lexical" + BillingCorrectnessRuleSequenceAdapterAuthoritative = "billing_attempt_sequence_authoritative_adapter" + BillingCorrectnessRuleCustomerOperatorCoupling = "billing_customer_operator_coupling" + BillingCorrectnessRuleCustomerInputCarriesOperatorRates = "billing_customer_input_carries_operator_rates" + BillingCorrectnessRuleExecutorGlobalBillingRegistry = "billing_executor_global_registry" + BillingCorrectnessRuleExecutorMapField = "billing_executor_map_field" + BillingCorrectnessRuleCallScopedStateOwnerMissing = "billing_call_scoped_owner_missing" + BillingCorrectnessRuleHoldAndStreamMoneyLock = "billing_hold_stream_money_lock" +) + +// billingCorrectnessOperatorRateIdents are the customer/operator coupling +// symbols that must never appear inside customer rating resolution, customer +// rating inputs, or the customer post-usage worker. +var billingCorrectnessOperatorRateIdents = []string{ + "OperatorRate", + "OperatorRates", + "OperatorRateSet", + "OperatorRateRef", + "operatorRates", +} + +// billingCorrectnessLifetimeRegistryIdents are the executor-global billing-call +// registries the corrected baseline removed. Their reappearance anywhere in +// runtime production would reintroduce lifetime-growing bookkeeping. +var billingCorrectnessLifetimeRegistryIdents = []string{ + "billingTurnCollector", + "allocatedByCall", + "frozenByCall", + "legTimesByCall", + "finalizeByKey", +} + +func billingCorrectnessRuleFinding(rule, path, detail string) RuleFinding { + return RuleFinding{Rule: rule, Path: path, Detail: detail} +} + +// readProductionSource reads a repo-relative production file. Missing files +// return ("", nil) so partial trees (unit-test fixtures) skip cleanly. +func readProductionSource(root, rel string) (string, error) { + src, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel))) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + return string(src), nil +} + +// parseProductionFile parses a repo-relative production file into an AST. +func parseProductionFile(root, rel string) (*ast.File, error) { + abs := filepath.Join(root, filepath.FromSlash(rel)) + src, err := os.ReadFile(abs) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + _, f, err := ParseGoSource(abs, src) + if err != nil { + return nil, fmt.Errorf("%s: %w", rel, err) + } + return f, nil +} + +// findTypeSpec returns the TypeSpec with the given name, or nil. +func findTypeSpec(f *ast.File, name string) *ast.TypeSpec { + for _, decl := range f.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok.String() != "type" { + continue + } + for _, spec := range gd.Specs { + ts, ok := spec.(*ast.TypeSpec) + if ok && ts.Name != nil && ts.Name.Name == name { + return ts + } + } + } + return nil +} + +// findFuncDecl returns the first function (with any receiver) named funcName. +func findFuncDecl(f *ast.File, funcName string) *ast.FuncDecl { + for _, decl := range f.Decls { + fd, ok := decl.(*ast.FuncDecl) + if ok && fd.Name != nil && fd.Name.Name == funcName { + return fd + } + } + return nil +} + +// collectIdentNames returns every identifier name referenced inside node. +func collectIdentNames(node ast.Node) map[string]struct{} { + names := make(map[string]struct{}) + if node == nil { + return names + } + ast.Inspect(node, func(n ast.Node) bool { + if id, ok := n.(*ast.Ident); ok { + names[id.Name] = struct{}{} + } + return true + }) + return names +} + +func compositeLiteralTypeName(t ast.Expr) string { + switch v := t.(type) { + case *ast.Ident: + return v.Name + case *ast.SelectorExpr: + return v.Sel.Name + default: + return "" + } +} + +// scanFileForbiddenIdents rejects forbidden identifiers anywhere in a file. +func scanFileForbiddenIdents(root, rel string, forbidden []string, rule, detail string) []RuleFinding { + f, err := parseProductionFile(root, rel) + if err != nil { + return []RuleFinding{billingCorrectnessRuleFinding(rule, rel, "required production target failed to parse: "+err.Error())} + } + if f == nil { + return []RuleFinding{billingCorrectnessRuleFinding(rule, rel, "required production target is missing")} + } + names := collectIdentNames(f) + return forbiddenIdentFindings(rel, names, forbidden, rule, detail) +} + +// scanFuncBodyForbiddenIdents rejects forbidden identifiers inside one named +// function body. +func scanFuncBodyForbiddenIdents(root, rel, funcName string, forbidden []string, rule, detail string) []RuleFinding { + f, err := parseProductionFile(root, rel) + if err != nil { + return []RuleFinding{billingCorrectnessRuleFinding(rule, rel, "required production target failed to parse: "+err.Error())} + } + if f == nil { + return []RuleFinding{billingCorrectnessRuleFinding(rule, rel, "required production target is missing")} + } + fd := findFuncDecl(f, funcName) + if fd == nil { + return []RuleFinding{billingCorrectnessRuleFinding( + rule, rel, "expected function "+funcName+" is missing")} + } + names := collectIdentNames(fd.Body) + return forbiddenIdentFindings(rel, names, forbidden, rule, detail) +} + +func forbiddenIdentFindings(rel string, names map[string]struct{}, forbidden []string, rule, detail string) []RuleFinding { + var out []RuleFinding + for _, name := range forbidden { + if _, ok := names[name]; ok { + out = append(out, billingCorrectnessRuleFinding(rule, rel, detail+": "+name)) + } + } + return out +} + +// scanStructFieldNamesForbidden rejects struct fields whose names contain a +// forbidden substring. +func scanStructFieldNamesForbidden(root, rel, typeName, forbidden string, rule, detail string) []RuleFinding { + f, err := parseProductionFile(root, rel) + if err != nil { + return []RuleFinding{billingCorrectnessRuleFinding(rule, rel, "required production target failed to parse: "+err.Error())} + } + if f == nil { + return []RuleFinding{billingCorrectnessRuleFinding(rule, rel, "required production target is missing")} + } + ts := findTypeSpec(f, typeName) + if ts == nil { + return []RuleFinding{billingCorrectnessRuleFinding(rule, rel, "expected type "+typeName+" is missing")} + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + return []RuleFinding{billingCorrectnessRuleFinding(rule, rel, "expected type "+typeName+" is not a struct")} + } + var out []RuleFinding + for _, field := range st.Fields.List { + for _, name := range field.Names { + if strings.Contains(name.Name, forbidden) { + out = append(out, billingCorrectnessRuleFinding( + rule, rel, detail+": "+typeName+"."+name.Name)) + } + } + } + return out +} + +func containsString(slice []string, want string) bool { + for _, s := range slice { + if s == want { + return true + } + } + return false +} + +func isBillingCallIDKeyExpr(expr ast.Expr, aliases map[string]struct{}) bool { + switch value := expr.(type) { + case *ast.SelectorExpr: + return value.Sel != nil && value.Sel.Name == "BillingCallID" + case *ast.Ident: + _, ok := aliases[value.Name] + return ok + default: + return false + } +} + +func billingCallIDAliases(files []*ast.File) map[string]struct{} { + aliases := map[string]struct{}{"BillingCallID": {}} + changed := true + for changed { + changed = false + for _, file := range files { + for _, decl := range file.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok.String() != "type" { + continue + } + for _, spec := range gd.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok || ts.Name == nil || !isBillingCallIDTypeExpr(ts.Type, aliases) { + continue + } + if _, exists := aliases[ts.Name.Name]; !exists { + aliases[ts.Name.Name] = struct{}{} + changed = true + } + } + } + } + } + return aliases +} + +func isBillingCallIDTypeExpr(expr ast.Expr, aliases map[string]struct{}) bool { + switch value := expr.(type) { + case *ast.SelectorExpr: + return value.Sel != nil && value.Sel.Name == "BillingCallID" + case *ast.Ident: + _, ok := aliases[value.Name] + return ok + default: + return false + } +} diff --git a/internal/archtest/billing_correctness_ratchet_test.go b/internal/archtest/billing_correctness_ratchet_test.go new file mode 100644 index 00000000..00cb0d7c --- /dev/null +++ b/internal/archtest/billing_correctness_ratchet_test.go @@ -0,0 +1,343 @@ +package archtest + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Phase 5.1 architecture ratchets for the corrected post-usage billing +// baseline. Each guard is written against the *semantics* of the corrected +// architecture (authoritative attempt sequence, customer/provider snapshot +// independence, request-scoped billing state, no monetary holds / no stream +// money), not formatting quirks, so a regression to positional/lexical +// ordering, operator-rate coupling in the customer path, or executor-global +// lifetime billing registries fails closed. + +func TestPhase51AttemptSequenceAuthorityRatchets(t *testing.T) { + t.Parallel() + root := repoRoot(t) + got, err := EvaluateBillingAttemptSequenceAuthority(root) + if err != nil { + t.Fatalf("evaluate sequence authority: %v", err) + } + if len(got) > 0 { + t.Fatalf("attempt-sequence ratchets must pass against corrected code:\n%s", formatRatchetFindings(got)) + } +} + +func TestPhase51CustomerOperatorIndependenceRatchets(t *testing.T) { + t.Parallel() + root := repoRoot(t) + got, err := EvaluateBillingCustomerOperatorIndependence(root) + if err != nil { + t.Fatalf("evaluate customer/operator independence: %v", err) + } + if len(got) > 0 { + t.Fatalf("customer rating must never resolve or carry operator rates:\n%s", formatRatchetFindings(got)) + } +} + +func TestPhase51CallScopedStateOwnershipRatchets(t *testing.T) { + t.Parallel() + root := repoRoot(t) + got, err := EvaluateBillingCallScopedStateOwnership(root) + if err != nil { + t.Fatalf("evaluate call-scoped state ownership: %v", err) + } + if len(got) > 0 { + t.Fatalf("runtime billing bookkeeping must be call-scoped, not executor-global:\n%s", formatRatchetFindings(got)) + } +} + +func TestPhase51HoldDeletionAndNoStreamMoneyRatchetsStayActive(t *testing.T) { + t.Parallel() + root := repoRoot(t) + got, err := EvaluateBillingHoldAndStreamMoneyLock(root) + if err != nil { + t.Fatalf("evaluate hold/stream-money lock: %v", err) + } + if len(got) > 0 { + t.Fatalf("hold-deletion and no-stream-money ratchets must remain active and green:\n%s", formatRatchetFindings(got)) + } +} + +func TestEvaluateBillingAttemptSequenceAuthorityDetectsPositionalReconstruction(t *testing.T) { + t.Parallel() + rel := "internal/core/billing/call_rating.go" + root := t.TempDir() + abs := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + // Positional reconstruction: the rating adapter rebuilds sequence from the + // slice index instead of the persisted B2BUA attempt sequence. + body := `package billing + +func RateCall(in CallRatingInput) (CallRatingResult, error) { + legs := make([]LegUsageRecord, 0, len(in.Legs)) + for i, source := range in.Legs { + legs = append(legs, LegUsageRecord{Seq: i + 1, BLegID: source.BLegID}) + } + return CallRatingResult{}, nil +} +` + if err := os.WriteFile(abs, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + got, err := EvaluateBillingAttemptSequenceAuthority(root) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + if len(got) == 0 { + t.Fatal("positional sequence reconstruction must be detected") + } + found := false + for _, finding := range got { + if finding.Rule == BillingCorrectnessRuleSequencePositional { + found = true + } + } + if !found { + t.Fatalf("want positional-sequence finding, got:\n%s", formatRatchetFindings(got)) + } +} + +func TestEvaluateBillingAttemptSequenceAuthorityAcceptsAuthoritativeAdapter(t *testing.T) { + t.Parallel() + rel := "internal/core/billing/call_rating.go" + root := t.TempDir() + abs := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + body := `package billing + +func RateCall(in CallRatingInput) (CallRatingResult, error) { + legs := make([]LegUsageRecord, 0, len(in.Legs)) + for _, source := range in.Legs { + leg, err := source.Seal() + if err != nil { + return CallRatingResult{}, err + } + legs = append(legs, LegUsageRecord{Seq: leg.AttemptSeq, BLegID: leg.BLegID}) + } + return CallRatingResult{}, nil +} +` + if err := os.WriteFile(abs, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + ratingAbs := filepath.Join(root, filepath.FromSlash("internal/core/billing/rating.go")) + if err := os.WriteFile(ratingAbs, []byte("package billing\n\nfunc latest(legs []LegUsageRecord) bool { return legs[0].Seq > legs[1].Seq }\n"), 0o600); err != nil { + t.Fatal(err) + } + got, err := EvaluateBillingAttemptSequenceAuthority(root) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + if len(got) != 0 { + t.Fatalf("authoritative adapter must pass:\n%s", formatRatchetFindings(got)) + } + + badBody := strings.Replace(body, "Seq: leg.AttemptSeq", "Seq: 1", 1) + if err := os.WriteFile(abs, []byte(badBody), 0o600); err != nil { + t.Fatal(err) + } + bad, err := EvaluateBillingAttemptSequenceAuthority(root) + if err != nil { + t.Fatalf("evaluate violating adapter: %v", err) + } + if len(bad) == 0 { + t.Fatal("violating sequence adapter must be detected") + } +} + +func TestEvaluateBillingCustomerOperatorIndependenceDetectsCoupling(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rel string + body string + wantEmpty bool + }{ + { + name: "customer snapshots resolve operator rate", + rel: "internal/infra/billingcompose/catalog.go", + body: `package billingcompose + +func (c *SnapshotCatalog) CustomerRatingSnapshots(call billing.CallUsageRecord, legs []billing.CallLegUsageRecord) (CustomerRatingSnapshots, error) { + rate, err := c.OperatorRate(legs[0].OperatorRateRef) + if err != nil { + return CustomerRatingSnapshots{}, err + } + _ = rate + return CustomerRatingSnapshots{}, nil +} +`, + wantEmpty: false, + }, + { + name: "customer resolver decoupled", + rel: "internal/infra/billingcompose/catalog.go", + body: `package billingcompose + +func (c *SnapshotCatalog) CustomerRatingSnapshots(call billing.CallUsageRecord, legs []billing.CallLegUsageRecord) (CustomerRatingSnapshots, error) { + pricing, err := c.pricingSnapshot(call.CustomerPricingRef) + if err != nil { + return CustomerRatingSnapshots{}, err + } + cards, err := c.modelPricingForLegs(legs, pricing) + if err != nil { + return CustomerRatingSnapshots{}, err + } + return CustomerRatingSnapshots{DefaultPricing: pricing, ModelPricing: cards}, nil +} +`, + wantEmpty: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + abs := filepath.Join(root, filepath.FromSlash(tt.rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(tt.body), 0o600); err != nil { + t.Fatal(err) + } + writeCustomerOperatorSupportFixtures(t, root) + got, err := EvaluateBillingCustomerOperatorIndependence(root) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + if tt.wantEmpty && len(got) != 0 { + t.Fatalf("want no findings, got:\n%s", formatRatchetFindings(got)) + } + if !tt.wantEmpty && len(got) == 0 { + t.Fatal("want findings, got none") + } + }) + } +} + +func writeCustomerOperatorSupportFixtures(t *testing.T, root string) { + t.Helper() + fixtures := map[string]string{ + "internal/infra/billingcompose/resolver.go": `package billingcompose + +func (c *SnapshotCatalog) ResolveCallRating() {} +func (c *SnapshotCatalog) ResolveProviderCost() { var rate OperatorRate; _ = rate } +`, + "internal/core/billing/call_post_usage_worker.go": `package billing +`, + "internal/core/billing/call_rating.go": `package billing + +type CallRatingInput struct{} +`, + "internal/core/billing/rating.go": `package billing + +type RatingInput struct{} +`, + } + for rel, body := range fixtures { + abs := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } +} + +func TestEvaluateBillingCallScopedStateOwnershipDetectsExecutorGlobalMap(t *testing.T) { + t.Parallel() + rel := "internal/core/runtime/executor.go" + root := t.TempDir() + abs := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + body := `package runtime + +import "github.com/matdev83/go-llm-interactive-proxy/internal/core/billing" + +type Executor struct { + allocatedByCall map[string]int + billingByCall map[billing.BillingCallID]*billingCallState + finalizeByKey map[string]*finalizeCacheEntry +} + +func (e *Executor) Execute() {} +` + if err := os.WriteFile(abs, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + got, err := EvaluateBillingCallScopedStateOwnership(root) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + if len(got) == 0 { + t.Fatal("executor-global lifetime billing registries must be detected") + } +} + +func TestEvaluateBillingCallScopedStateOwnershipAcceptsCallScopedState(t *testing.T) { + t.Parallel() + rel := "internal/core/runtime/executor.go" + root := t.TempDir() + abs := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + body := `package runtime + +import "github.com/matdev83/go-llm-interactive-proxy/internal/core/billing" + +type Executor struct { + Backends map[string]execbackend.Backend +} + +func (e *Executor) Execute() {} +` + if err := os.WriteFile(abs, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + got, err := EvaluateBillingCallScopedStateOwnership(root) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + if len(got) != 0 { + t.Fatalf("legitimate Backends map must not be flagged:\n%s", formatRatchetFindings(got)) + } +} + +func TestBillingCorrectnessRatchetRuleNamesReferenced(t *testing.T) { + t.Parallel() + seen := make(map[string]struct{}) + for _, name := range []string{ + BillingCorrectnessRuleSequencePositional, + BillingCorrectnessRuleSequenceTimestamp, + BillingCorrectnessRuleSequenceLexical, + BillingCorrectnessRuleSequenceAdapterAuthoritative, + BillingCorrectnessRuleCustomerOperatorCoupling, + BillingCorrectnessRuleCustomerInputCarriesOperatorRates, + BillingCorrectnessRuleExecutorGlobalBillingRegistry, + BillingCorrectnessRuleExecutorMapField, + BillingCorrectnessRuleCallScopedStateOwnerMissing, + BillingCorrectnessRuleHoldAndStreamMoneyLock, + } { + if strings.TrimSpace(name) == "" { + t.Fatal("ratchet rule name must not be empty") + } + if _, exists := seen[name]; exists { + t.Fatalf("duplicate ratchet rule name %q", name) + } + seen[name] = struct{}{} + } +} diff --git a/internal/archtest/billing_hold_lock_ratchet.go b/internal/archtest/billing_hold_lock_ratchet.go new file mode 100644 index 00000000..516d1d28 --- /dev/null +++ b/internal/archtest/billing_hold_lock_ratchet.go @@ -0,0 +1,72 @@ +package archtest + +// EvaluateBillingHoldAndStreamMoneyLock verifies the committed hold-deletion +// and no-stream-money ratchets remain active and green (requirement 8.7). It +// asserts the committed baseline flags are active, the deleted stream-time +// monetary declarations are still forbidden, the hold-lifecycle target +// inventory is intact, and the committed deletion/identity ratchets pass +// against the current tree. +func EvaluateBillingHoldAndStreamMoneyLock(root string) ([]RuleFinding, error) { + var out []RuleFinding + doc, err := LoadBillingExposureBaseline(root) + if err != nil { + return nil, err + } + if !doc.ForbidHoldSymbols { + out = append(out, billingCorrectnessRuleFinding( + BillingCorrectnessRuleHoldAndStreamMoneyLock, BillingExposureBaselineRelPath, + "forbid_hold_symbols must stay active (7.1 hold deletion lock)")) + } + if !doc.RequireNetLOCReduction { + out = append(out, billingCorrectnessRuleFinding( + BillingCorrectnessRuleHoldAndStreamMoneyLock, BillingExposureBaselineRelPath, + "require_net_loc_reduction must stay active (7.4 convergence lock)")) + } + + inventory := make(map[string]struct{}) + for _, decl := range ForbiddenDeclarations { + key := decl.Package + ":" + string(decl.Kind) + ":" + decl.Name + inventory[key] = struct{}{} + } + for _, required := range []string{ + "internal/core/runtime:method:enrichUsageCost", + "internal/core/runtime:method:recordTokenAccountingLedger", + "internal/core/runtime:method:recordPartialTokenAccountingLedger", + "internal/core/runtime:method:recordCancellationBillingMarker", + "internal/core/runtime:method:rateMonetaryExposure", + "internal/core/runtime:func:rateMonetaryExposure", + "pkg/lipsdk/economics:type:RatingRequest", + "pkg/lipsdk/economics:type:RatingResult", + "pkg/lipsdk/economics:type:Rater", + } { + if _, ok := inventory[required]; !ok { + out = append(out, billingCorrectnessRuleFinding( + BillingCorrectnessRuleHoldAndStreamMoneyLock, "internal/archtest/symbol_rules.go", + "stream-time monetary declaration must stay forbidden: "+required)) + } + } + for _, required := range []string{ + "Authorization", "AuthorizationStore", "authorization_holds", "reserved_nano", + "hold_expiry", "hold_remainder", "hold_release", "JournalBookLegacyAuthorization", + } { + if !containsString(billingExposureHoldLifecycleTargetIDs, required) { + out = append(out, billingCorrectnessRuleFinding( + BillingCorrectnessRuleHoldAndStreamMoneyLock, "internal/archtest/billing_exposure_ratchet.go", + "hold-lifecycle target must stay inventoried: "+required)) + } + } + + // The committed deletion/identity ratchets must stay green against the + // current tree (Requirement 8.7: ratchets remain active and green). + del, err := EvaluateBillingExposureDeletionRatchet(root, doc) + if err != nil { + return nil, err + } + out = append(out, del...) + ident, err := EvaluateBillingExposureIdentityRatchet(root, doc) + if err != nil { + return nil, err + } + out = append(out, ident...) + return out, nil +} diff --git a/internal/archtest/billing_independence_ratchet.go b/internal/archtest/billing_independence_ratchet.go new file mode 100644 index 00000000..ac489a2d --- /dev/null +++ b/internal/archtest/billing_independence_ratchet.go @@ -0,0 +1,65 @@ +package archtest + +// EvaluateBillingCustomerOperatorIndependence rejects any customer-rating path +// that resolves or carries operator rates. Customer snapshot resolution, the +// customer join resolver, the customer rating inputs, and the customer +// post-usage worker must stay free of provider-cost data; only the provider +// join resolver may read operator rates. +func EvaluateBillingCustomerOperatorIndependence(root string) ([]RuleFinding, error) { + var out []RuleFinding + out = append(out, scanFuncBodyForbiddenIdents( + root, "internal/infra/billingcompose/catalog.go", "CustomerRatingSnapshots", + billingCorrectnessOperatorRateIdents, + BillingCorrectnessRuleCustomerOperatorCoupling, + "customer snapshot resolution must never look up or carry operator rates")...) + out = append(out, scanFuncBodyForbiddenIdents( + root, "internal/infra/billingcompose/resolver.go", "ResolveCallRating", + billingCorrectnessOperatorRateIdents, + BillingCorrectnessRuleCustomerOperatorCoupling, + "customer join resolver must never resolve operator rates")...) + out = append(out, scanFileForbiddenIdents( + root, "internal/core/billing/call_post_usage_worker.go", + billingCorrectnessOperatorRateIdents, + BillingCorrectnessRuleCustomerOperatorCoupling, + "customer post-usage worker must never depend on provider-cost resolution")...) + out = append(out, scanStructFieldNamesForbidden( + root, "internal/core/billing/call_rating.go", "CallRatingInput", + "Operator", BillingCorrectnessRuleCustomerInputCarriesOperatorRates, + "customer rating input must not carry operator-rate collections")...) + out = append(out, scanStructFieldNamesForbidden( + root, "internal/core/billing/rating.go", "RatingInput", + "Operator", BillingCorrectnessRuleCustomerInputCarriesOperatorRates, + "customer rating input must not carry operator-rate collections")...) + out = append(out, requireProviderPathResolvesOperatorRate(root)...) + return out, nil +} + +// requireProviderPathResolvesOperatorRate locks the provider join resolver as +// the sole customer-independent consumer of operator rates. +func requireProviderPathResolvesOperatorRate(root string) []RuleFinding { + rel := "internal/infra/billingcompose/resolver.go" + f, err := parseProductionFile(root, rel) + if err != nil { + return []RuleFinding{billingCorrectnessRuleFinding( + BillingCorrectnessRuleCustomerOperatorCoupling, rel, + "provider cost resolver target failed to parse: "+err.Error())} + } + if f == nil { + return []RuleFinding{billingCorrectnessRuleFinding( + BillingCorrectnessRuleCustomerOperatorCoupling, rel, + "provider cost resolver target is missing")} + } + fd := findFuncDecl(f, "ResolveProviderCost") + if fd == nil { + return []RuleFinding{billingCorrectnessRuleFinding( + BillingCorrectnessRuleCustomerOperatorCoupling, rel, + "provider cost resolver ResolveProviderCost is missing")} + } + names := collectIdentNames(fd.Body) + if _, ok := names["OperatorRate"]; !ok { + return []RuleFinding{billingCorrectnessRuleFinding( + BillingCorrectnessRuleCustomerOperatorCoupling, rel, + "provider cost resolution (the provider-only path) must read catalog.OperatorRate")} + } + return nil +} diff --git a/internal/archtest/billing_sequence_ratchet.go b/internal/archtest/billing_sequence_ratchet.go new file mode 100644 index 00000000..c8b86122 --- /dev/null +++ b/internal/archtest/billing_sequence_ratchet.go @@ -0,0 +1,179 @@ +package archtest + +import ( + "go/ast" + "go/token" + "strings" +) + +// EvaluateBillingAttemptSequenceAuthority rejects positional, lexical, and +// timestamp sources for customer leg ordering and requires the rating adapter +// to copy the persisted B2BUA attempt sequence verbatim (the only authoritative +// order source). +func EvaluateBillingAttemptSequenceAuthority(root string) ([]RuleFinding, error) { + var out []RuleFinding + readable := false + for _, rel := range []string{ + "internal/core/billing/call_rating.go", + "internal/core/billing/rating.go", + } { + src, err := readProductionSource(root, rel) + if err != nil { + return nil, err + } + if src == "" { + continue + } + readable = true + f, err := parseProductionFile(root, rel) + if err != nil { + return nil, err + } + out = append(out, scanSeqPositionalReconstruction(rel, src, f)...) + out = append(out, scanSeqTimestampOrdering(rel, src)...) + out = append(out, scanSeqLexicalOrdering(rel, src)...) + } + if !readable { + out = append(out, billingCorrectnessRuleFinding( + BillingCorrectnessRuleSequenceAdapterAuthoritative, + "internal/core/billing", + "attempt-sequence authority targets are missing")) + return out, nil + } + out = append(out, scanLatestAcceptedUsesPersistedSequence(root)...) + return out, nil +} + +// scanSeqPositionalReconstruction rejects rebuilding the attempt sequence from +// slice position in the customer rating adapter and any direct Seq assignment +// from an index/position expression. +func scanSeqPositionalReconstruction(rel, src string, f *ast.File) []RuleFinding { + var out []RuleFinding + ast.Inspect(f, func(n ast.Node) bool { + cl, ok := n.(*ast.CompositeLit) + if !ok { + return true + } + if compositeLiteralTypeName(cl.Type) != "LegUsageRecord" { + return true + } + for _, el := range cl.Elts { + kv, ok := el.(*ast.KeyValueExpr) + if !ok { + continue + } + key, ok := kv.Key.(*ast.Ident) + if !ok || key.Name != "Seq" { + continue + } + sel, ok := kv.Value.(*ast.SelectorExpr) + if !ok || sel.Sel == nil || (sel.Sel.Name != "AttemptSeq" && sel.Sel.Name != "Seq") { + out = append(out, billingCorrectnessRuleFinding( + BillingCorrectnessRuleSequencePositional, rel, + "LegUsageRecord.Seq must copy the persisted attempt sequence; positional reconstruction is forbidden")) + return true + } + if _, ok := sel.X.(*ast.Ident); !ok { + out = append(out, billingCorrectnessRuleFinding( + BillingCorrectnessRuleSequencePositional, rel, + "LegUsageRecord.Seq must derive from the call-leg record, not an inline expression")) + } + } + return true + }) + // Surgical markers catch direct assignments such as leg.Seq = i + 1. + for _, marker := range []string{ + ".Seq = i", "Seq: i +", "Seq: i+", "Seq: i+1", + "Seq: index +", "Seq: index+", "Seq: idx +", "Seq: idx+", + "Seq: position", "Seq: pos", ".Seq = index", ".Seq = idx", + } { + if strings.Contains(src, marker) { + out = append(out, billingCorrectnessRuleFinding( + BillingCorrectnessRuleSequencePositional, rel, + "forbidden positional sequence reconstruction marker "+marker)) + } + } + return out +} + +// scanSeqTimestampOrdering rejects timestamps as a customer leg-ordering source. +func scanSeqTimestampOrdering(rel, src string) []RuleFinding { + var out []RuleFinding + for _, marker := range []string{ + "StartedAt.After(", "StartedAt.Before(", + "FinishedAt.After(", "FinishedAt.Before(", + } { + if strings.Contains(src, marker) { + out = append(out, billingCorrectnessRuleFinding( + BillingCorrectnessRuleSequenceTimestamp, rel, + "customer leg selection must not derive order from timestamps ("+marker+")")) + } + } + return out +} + +// scanSeqLexicalOrdering rejects sorting/comparing B-leg identities as a +// customer leg-ordering source. The canonical ExpectedBLegIDs set sort in +// call_usage.go is completeness-only and deliberately outside these files. +func scanSeqLexicalOrdering(rel, src string) []RuleFinding { + var out []RuleFinding + for _, marker := range []string{ + "sort.Slice(", "slices.SortFunc(", "slices.Sort(", "slices.SortStableFunc(", + "strings.Compare(", + } { + if strings.Contains(src, marker) { + out = append(out, billingCorrectnessRuleFinding( + BillingCorrectnessRuleSequenceLexical, rel, + "customer leg selection must not derive order from sorting or comparing B-leg identities ("+marker+")")) + } + } + return out +} + +// scanLatestAcceptedUsesPersistedSequence requires the interrupted-call +// latest-accepted rule to compare persisted sequence selectors. +func scanLatestAcceptedUsesPersistedSequence(root string) []RuleFinding { + rel := "internal/core/billing/rating.go" + f, err := parseProductionFile(root, rel) + if err != nil { + return []RuleFinding{billingCorrectnessRuleFinding( + BillingCorrectnessRuleSequenceAdapterAuthoritative, rel, + "latest-accepted sequence target failed to parse: "+err.Error())} + } + if f == nil { + return []RuleFinding{billingCorrectnessRuleFinding( + BillingCorrectnessRuleSequenceAdapterAuthoritative, rel, + "latest-accepted sequence target is missing")} + } + found := false + ast.Inspect(f, func(n ast.Node) bool { + binary, ok := n.(*ast.BinaryExpr) + if !ok || !isSequenceComparison(binary.Op) { + return true + } + if isSeqSelector(binary.X) && isSeqSelector(binary.Y) { + found = true + } + return true + }) + if !found { + return []RuleFinding{billingCorrectnessRuleFinding( + BillingCorrectnessRuleSequenceAdapterAuthoritative, rel, + "latest-accepted selection must compare persisted sequence selectors (not IDs/timestamps/position)")} + } + return nil +} + +func isSeqSelector(expr ast.Expr) bool { + sel, ok := expr.(*ast.SelectorExpr) + return ok && sel.Sel != nil && sel.Sel.Name == "Seq" +} + +func isSequenceComparison(op token.Token) bool { + switch op { + case token.EQL, token.NEQ, token.LSS, token.LEQ, token.GTR, token.GEQ: + return true + default: + return false + } +} diff --git a/internal/archtest/billing_state_ownership_ratchet.go b/internal/archtest/billing_state_ownership_ratchet.go new file mode 100644 index 00000000..6cd5e75a --- /dev/null +++ b/internal/archtest/billing_state_ownership_ratchet.go @@ -0,0 +1,220 @@ +package archtest + +import ( + "go/ast" + "os" + "path/filepath" + "strings" +) + +// EvaluateBillingCallScopedStateOwnership rejects executor-global +// lifetime-growing billing-call registries and requires call-scoped state to +// live on request/stream objects, not on the executor. Ordinary provider maps +// such as a string-keyed Backends catalog and request-scoped bookkeeping (for +// example a per-stream recorded-leg set) are not flagged. +func EvaluateBillingCallScopedStateOwnership(root string) ([]RuleFinding, error) { + var out []RuleFinding + out = append(out, scanRuntimeLifetimeRegistryIdents(root)...) + out = append(out, scanExecutorStructBillingFields(root)...) + out = append(out, scanBillingCallIDKeyedMaps(root)...) + out = append(out, scanCallScopedStateOwners(root)...) + return out, nil +} + +// scanRuntimeLifetimeRegistryIdents forbids the removed executor-global +// collector names anywhere in runtime production. +func scanRuntimeLifetimeRegistryIdents(root string) []RuleFinding { + rel := "internal/core/runtime" + dir := filepath.Join(root, filepath.FromSlash(rel)) + entries, err := os.ReadDir(dir) + if err != nil { + return []RuleFinding{billingCorrectnessRuleFinding( + BillingCorrectnessRuleExecutorGlobalBillingRegistry, rel, + "runtime production directory is unreadable: "+err.Error())} + } + var out []RuleFinding + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + fileRel := rel + "/" + entry.Name() + f, err := parseProductionFile(root, fileRel) + if err != nil || f == nil { + out = append(out, billingCorrectnessRuleFinding( + BillingCorrectnessRuleExecutorGlobalBillingRegistry, fileRel, + "runtime production target failed to parse")) + continue + } + out = append(out, forbiddenIdentFindings( + fileRel, collectIdentNames(f), billingCorrectnessLifetimeRegistryIdents, + BillingCorrectnessRuleExecutorGlobalBillingRegistry, + "executor-global lifetime billing registry must not return")...) + } + return out +} + +// scanExecutorStructBillingFields forbids billing-call registries declared +// directly on the Executor struct: lifetime-registry names, BillingCallID-keyed +// maps, and call-scoped state fields. Legitimate maps such as a string-keyed +// Backends catalog are allowed. +func scanExecutorStructBillingFields(root string) []RuleFinding { + rel := "internal/core/runtime/executor.go" + f, err := parseProductionFile(root, rel) + if err != nil || f == nil { + return []RuleFinding{billingCorrectnessRuleFinding( + BillingCorrectnessRuleExecutorGlobalBillingRegistry, rel, + "executor production target is missing or unparsable")} + } + aliases := runtimeBillingCallIDAliases(root) + ts := findTypeSpec(f, "Executor") + if ts == nil { + return nil + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + return nil + } + var out []RuleFinding + for _, field := range st.Fields.List { + if len(field.Names) == 0 { + continue // embedded type + } + for _, name := range field.Names { + if name.Name == "billingCallState" || name.Name == "billingTurnCollector" { + out = append(out, billingCorrectnessRuleFinding( + BillingCorrectnessRuleExecutorGlobalBillingRegistry, rel, + "call-scoped billing state must not live on the executor ("+name.Name+")")) + } + if isBillingRegistryFieldName(name.Name) { + out = append(out, billingCorrectnessRuleFinding( + BillingCorrectnessRuleExecutorGlobalBillingRegistry, rel, + "executor-global billing registry field "+name.Name+" is forbidden")) + } + } + if isBillingCallIDKeyedMap(field, aliases) { + out = append(out, billingCorrectnessRuleFinding( + BillingCorrectnessRuleExecutorMapField, rel, + "executor may not hold a BillingCallID-keyed map (lifetime-growing call registry)")) + } + } + return out +} + +func isBillingRegistryFieldName(name string) bool { + for _, suffix := range []string{"ByCall", "ByBillingCall", "Collector"} { + if strings.HasSuffix(name, suffix) { + return true + } + } + return false +} + +func isBillingCallIDKeyedMap(field *ast.Field, aliases map[string]struct{}) bool { + mt, ok := field.Type.(*ast.MapType) + if !ok { + return false + } + return isBillingCallIDKeyExpr(mt.Key, aliases) +} + +// scanBillingCallIDKeyedMaps rejects any production BillingCallID-keyed map +// registry under runtime (executor-global or otherwise). +func scanBillingCallIDKeyedMaps(root string) []RuleFinding { + rel := "internal/core/runtime" + dir := filepath.Join(root, filepath.FromSlash(rel)) + entries, err := os.ReadDir(dir) + if err != nil { + return []RuleFinding{billingCorrectnessRuleFinding( + BillingCorrectnessRuleExecutorMapField, rel, + "runtime production directory is unreadable: "+err.Error())} + } + aliases := runtimeBillingCallIDAliases(root) + var out []RuleFinding + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + f, err := parseProductionFile(root, rel+"/"+entry.Name()) + if err != nil || f == nil { + continue + } + ast.Inspect(f, func(n ast.Node) bool { + mt, ok := n.(*ast.MapType) + if !ok { + return true + } + if isBillingCallIDKeyExpr(mt.Key, aliases) { + out = append(out, billingCorrectnessRuleFinding( + BillingCorrectnessRuleExecutorMapField, rel+"/"+entry.Name(), + "runtime must not hold a BillingCallID-keyed map registry (request-scoped state replaces it)")) + } + return true + }) + } + return out +} + +func runtimeBillingCallIDAliases(root string) map[string]struct{} { + rel := "internal/core/runtime" + dir := filepath.Join(root, filepath.FromSlash(rel)) + entries, err := os.ReadDir(dir) + if err != nil { + return billingCallIDAliases(nil) + } + files := make([]*ast.File, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + f, err := parseProductionFile(root, rel+"/"+entry.Name()) + if err == nil && f != nil { + files = append(files, f) + } + } + return billingCallIDAliases(files) +} + +// scanCallScopedStateOwners requires the request-scoped owner structs to carry +// the private billingCallState object. +func scanCallScopedStateOwners(root string) []RuleFinding { + var out []RuleFinding + for _, require := range []struct { + rel string + typeName string + fieldName string + }{ + {rel: "internal/core/runtime/executor_prepare_request.go", typeName: "preparedRequest", fieldName: "billingCallID"}, + {rel: "internal/core/runtime/executor_prepare_request.go", typeName: "preparedRequest", fieldName: "billingCallState"}, + {rel: "internal/core/runtime/executor_retry_stream.go", typeName: "retryRecvStream", fieldName: "billingCallState"}, + } { + f, err := parseProductionFile(root, require.rel) + if err != nil || f == nil { + continue + } + ts := findTypeSpec(f, require.typeName) + if ts == nil { + out = append(out, billingCorrectnessRuleFinding( + BillingCorrectnessRuleCallScopedStateOwnerMissing, require.rel, + "request-scoped owner type "+require.typeName+" is missing")) + continue + } + st, ok := ts.Type.(*ast.StructType) + if !ok || !structHasField(st, require.fieldName) { + out = append(out, billingCorrectnessRuleFinding( + BillingCorrectnessRuleCallScopedStateOwnerMissing, require.rel, + require.typeName+" must own the private "+require.fieldName+" field")) + } + } + return out +} + +func structHasField(st *ast.StructType, name string) bool { + for _, field := range st.Fields.List { + for _, n := range field.Names { + if n.Name == name { + return true + } + } + } + return false +} diff --git a/internal/core/billing/call_post_usage_worker.go b/internal/core/billing/call_post_usage_worker.go index c7573fd0..eaba3ad6 100644 --- a/internal/core/billing/call_post_usage_worker.go +++ b/internal/core/billing/call_post_usage_worker.go @@ -102,7 +102,11 @@ func (w *CallPostUsageWorker) ProcessOnce(ctx context.Context) error { } result, err := w.resolver.ResolveCallRating(ctx, complete, exposure) if err != nil { - allErr = errors.Join(allErr, w.retryCall(ctx, complete.Closure.CallID, "rating_input", err)) + code := "rating_input" + if errors.Is(err, ErrBillingAttemptSequenceUnknown) { + code = "settlement_reconcile_required" + } + allErr = errors.Join(allErr, w.retryCall(ctx, complete.Closure.CallID, code, err)) continue } if _, err := w.settlement.ApplyCallBillingResult(ctx, ApplyCallBillingInput{Call: complete.Closure, Exposure: exposure, Result: result}); err != nil { diff --git a/internal/core/billing/call_rating.go b/internal/core/billing/call_rating.go index cdad332e..fba6ae75 100644 --- a/internal/core/billing/call_rating.go +++ b/internal/core/billing/call_rating.go @@ -12,7 +12,17 @@ type CallRatingInput struct { MaxCustomerCharge Money CustomerPricing PricingSnapshot CustomerPolicy ChargePolicy - OperatorRates OperatorRateSet + // ModelPricing carries the effective per backend/model customer pricing + // cards resolved for the call legs. An empty set means no route/model + // override exists and the configured default pricing applies to every + // selected leg. When overrides exist, each selected leg must resolve its + // own card; a missing applicable card fails rating explicitly rather than + // silently substituting an unrelated model or the default price. + // + // Operator-rate data is deliberately absent from this customer type: it + // belongs to provider COGS processing only, so provider-cost readiness can + // never couple into customer settlement. + ModelPricing []ModelCustomerPricing } type CallRatingResult struct { CallID BillingCallID @@ -51,7 +61,7 @@ func RateCall(in CallRatingInput) (CallRatingResult, error) { turnKey := call.Key legs := make([]LegUsageRecord, 0, len(in.Legs)) legFingerprints := make([]string, 0, len(in.Legs)) - for seq, source := range in.Legs { + for _, source := range in.Legs { leg, sealErr := source.Seal() if sealErr != nil { return CallRatingResult{}, sealErr @@ -60,7 +70,11 @@ func RateCall(in CallRatingInput) (CallRatingResult, error) { return CallRatingResult{}, fmt.Errorf("%w: leg %q is not expected for call", ErrRatingSnapshotMismatch, leg.BLegID) } legs = append(legs, LegUsageRecord{ - ALegID: leg.ALegID, BLegID: leg.BLegID, Seq: seq + 1, + // AttemptSeq is the exact persisted b2bua.BLegRecord.Seq. Never + // reconstruct sequence from slice position or opaque IDs: zero + // means legacy-unknown and the rating policy fails closed when it + // needs order. + ALegID: leg.ALegID, BLegID: leg.BLegID, Seq: leg.AttemptSeq, BackendID: leg.BackendID, ProviderID: leg.ProviderID, ModelID: leg.ModelID, StartedAt: leg.StartedAt, FinishedAt: leg.FinishedAt, Outcome: LegOutcome(leg.Outcome), Surfaced: leg.Surfaced, Evidence: leg.Evidence, OperatorRateRef: leg.OperatorRateRef, @@ -76,7 +90,7 @@ func RateCall(in CallRatingInput) (CallRatingResult, error) { if err := in.CustomerPricing.Validate(in.MaxCustomerCharge.Currency); err != nil { return CallRatingResult{}, err } - ratingInput := RatingInput{Record: turn, CustomerPricing: in.CustomerPricing, CustomerPolicy: in.CustomerPolicy, OperatorRates: in.OperatorRates} + ratingInput := RatingInput{Record: turn, CustomerPricing: in.CustomerPricing, CustomerPolicy: in.CustomerPolicy, ModelPricing: in.ModelPricing} customer, err := calculateCustomerCharge(turn, ratingInput) if err != nil { return CallRatingResult{}, err diff --git a/internal/core/billing/call_usage.go b/internal/core/billing/call_usage.go index 84eb6dec..1e811582 100644 --- a/internal/core/billing/call_usage.go +++ b/internal/core/billing/call_usage.go @@ -28,6 +28,7 @@ type CallLegUsageRecord struct { CallID BillingCallID ALegID string BLegID string + AttemptSeq int BackendID string ProviderID string ModelID string @@ -131,6 +132,19 @@ func (l CallLegUsageRecord) Seal() (CallLegUsageRecord, error) { return out, nil } +// SemanticFingerprint computes the immutable evidence hash for a call-leg +// record. +// +// Fingerprint versions: +// - v1 (legacy): AttemptSeq == 0. The sequence is unknown (pre-fix durable +// rows have attempt_seq NULL). The byte stream is byte-for-byte identical +// to the pre-sequence contract so historical fingerprints stay valid. +// - v2 (sequence-aware): AttemptSeq > 0. The exact b2bua.BLegRecord.Seq is a +// financial fact and participates in replay identity, so a same-key replay +// with a different sequence fingerprints differently and conflicts. +// +// A zero AttemptSeq never represents a known sequence; the runtime append seam +// requires a positive sequence for every new record. func (l CallLegUsageRecord) SemanticFingerprint() (string, error) { if err := l.validate(); err != nil { return "", err @@ -160,6 +174,9 @@ func (l CallLegUsageRecord) SemanticFingerprint() (string, error) { c.string(string(l.Evidence.Authority)) c.string(l.Evidence.DedupeKey) writeVersionRef(&c, l.OperatorRateRef) + if l.AttemptSeq > 0 { + c.u64(uint64(l.AttemptSeq)) + } return digest(c.bytes()), nil } @@ -286,5 +303,10 @@ func (l CallLegUsageRecord) validate() error { if !validLegOutcome(l.Outcome) || !validSurfacedState(l.Surfaced) { return fmt.Errorf("%w: invalid call-leg outcome/surfaced state", ErrInvalidRecord) } + // AttemptSeq == 0 means the sequence is absent (legacy v1 row); a positive + // value is the exact B2BUA attempt sequence. Negative values are nonsense. + if l.AttemptSeq < 0 { + return fmt.Errorf("%w: call-leg attempt sequence cannot be negative", ErrInvalidRecord) + } return validateEvidence(l.Evidence) } diff --git a/internal/core/billing/model_pricing_test.go b/internal/core/billing/model_pricing_test.go new file mode 100644 index 00000000..85d0ec37 --- /dev/null +++ b/internal/core/billing/model_pricing_test.go @@ -0,0 +1,178 @@ +package billing + +import ( + "errors" + "testing" + "time" +) + +// Phase 2.2/2.3 — model-specific customer pricing flows through the internal +// call-rating input and every selected customer-billable B-leg is rated with +// its effective backend/model card: +// +// - no model cards -> configured default pricing applies; +// - model cards present -> each selected leg uses its own card before +// summing, never an unrelated model or the default as a silent substitute; +// - mixed-model failover -> the surfaced (or charge-all) winner pays its +// own effective card; +// - missing applicable card -> rating fails explicitly (ErrRatingEvidenceMissing) +// when any override context exists. +// +// The exact admitted-maximum contract (actual returned unclamped, settlement +// reconciles mismatches) is exercised by the existing RateCall tests. + +func TestRateCallMixedModelChargeAllUsesEachLegsOwnCard(t *testing.T) { + t.Parallel() + callID := mustBillingCallID(t) + policy := ratingPolicy(ChargeAllPotentialLegs) + pricing := ratingPricing() + + // model-a keeps the default card (input 100, fixed 3). + // model-b is overridden in the catalog to input 1000/output 2000 and carries + // no fixed charges. Each leg must be rated with its own card before summing. + modelCard := PricingSnapshot{ + Ref: pricing.Ref, Currency: "USD", + InputPerMillionNano: 1000, OutputPerMillionNano: 2000, + InputRatePresent: true, OutputRatePresent: true, + } + + legA := testLeg("b-a", SurfacedYes, 1_000_000, 0, MoneyEvidence{}, true) + legA.ModelID = "model-a" + legA.ALegID = "a-1" + legA.CallID = callID + legB := testLeg("b-b", SurfacedYes, 1_000_000, 0, MoneyEvidence{}, true) + legB.ModelID = "model-b" + legB.ALegID = "a-1" + legB.CallID = callID + + call := CallUsageRecord{ + SchemaVersion: CurrentRecordSchemaVersion, CallID: callID, AccountID: "acct-1", + ALegID: "a-1", ExpectedBLegIDs: []string{"b-a", "b-b"}, + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(101, 0).UTC(), + Outcome: TurnOutcomeCompleted, CustomerPricingRef: pricing.Ref, ChargePolicyRef: policy.Ref, + } + + result, err := RateCall(CallRatingInput{ + Call: call, + Legs: []CallLegUsageRecord{legA, legB}, + MaxCustomerCharge: Money{Nano: 10000, Currency: "USD"}, + CustomerPricing: pricing, + CustomerPolicy: policy, + ModelPricing: []ModelCustomerPricing{ + {BackendID: "backend", ModelID: "model-a", Pricing: pricing}, + {BackendID: "backend", ModelID: "model-b", Pricing: modelCard}, + }, + }) + if err != nil { + t.Fatal(err) + } + // model-a: 1,000,000*100/1e6 + 3 fixed = 103; model-b: 1,000,000*1000/1e6 = 1000. + if got, want := result.CustomerCharge.Nano, int64(1103); got != want { + t.Fatalf("mixed-model customer = %d, want %d (each leg must use its own card before summing)", got, want) + } +} + +func TestRateCallMixedModelFailoverSettlesExpensiveWinner(t *testing.T) { + t.Parallel() + callID := mustBillingCallID(t) + policy := ratingPolicy(ChargeSurfacedTurn) + pricing := ratingPricing() + + // Cheap attempt 1 (model-a) fails and is not surfaced. The surfaced winner + // is the expensive failover model-b; settlement must use model-b's card. + cheap := testLeg("b_z7x9p", SurfacedNo, 1_000_000, 0, MoneyEvidence{}, true) + cheap.ModelID = "model-a" + cheap.AttemptSeq = 1 + cheap.ALegID = "a-1" + cheap.CallID = callID + + expensive := testLeg("b_a1b2c", SurfacedYes, 1_000_000, 0, MoneyEvidence{}, true) + expensive.ModelID = "model-b" + expensive.AttemptSeq = 2 + expensive.ALegID = "a-1" + expensive.CallID = callID + + modelCard := PricingSnapshot{ + Ref: pricing.Ref, Currency: "USD", + InputPerMillionNano: 1000, OutputPerMillionNano: 2000, + InputRatePresent: true, OutputRatePresent: true, + } + + call := CallUsageRecord{ + SchemaVersion: CurrentRecordSchemaVersion, CallID: callID, AccountID: "acct-1", + ALegID: "a-1", ExpectedBLegIDs: []string{"b_a1b2c", "b_z7x9p"}, + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(101, 0).UTC(), + Outcome: TurnOutcomeCompleted, CustomerPricingRef: pricing.Ref, ChargePolicyRef: policy.Ref, + } + + result, err := RateCall(CallRatingInput{ + Call: call, + Legs: []CallLegUsageRecord{cheap, expensive}, + MaxCustomerCharge: Money{Nano: 10000, Currency: "USD"}, + CustomerPricing: pricing, + CustomerPolicy: policy, + ModelPricing: []ModelCustomerPricing{ + {BackendID: "backend", ModelID: "model-a", Pricing: pricing}, + {BackendID: "backend", ModelID: "model-b", Pricing: modelCard}, + }, + }) + if err != nil { + t.Fatal(err) + } + // Only the surfaced model-b leg is billed at model-b's effective card. + if got, want := result.CustomerCharge.Nano, int64(1000); got != want { + t.Fatalf("failover customer = %d, want %d (settle expensive winner with its own card)", got, want) + } +} + +func TestRateCallMissingApplicableModelCardFailsExplicitly(t *testing.T) { + t.Parallel() + callID := mustBillingCallID(t) + policy := ratingPolicy(ChargeAllPotentialLegs) + pricing := ratingPricing() + + legA := testLeg("b-a", SurfacedYes, 1_000_000, 0, MoneyEvidence{}, true) + legA.ModelID = "model-a" + legA.ALegID = "a-1" + legA.CallID = callID + legB := testLeg("b-b", SurfacedYes, 1_000_000, 0, MoneyEvidence{}, true) + legB.ModelID = "model-b" + legB.ALegID = "a-1" + legB.CallID = callID + + call := CallUsageRecord{ + SchemaVersion: CurrentRecordSchemaVersion, CallID: callID, AccountID: "acct-1", + ALegID: "a-1", ExpectedBLegIDs: []string{"b-a", "b-b"}, + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(101, 0).UTC(), + Outcome: TurnOutcomeCompleted, CustomerPricingRef: pricing.Ref, ChargePolicyRef: policy.Ref, + } + + // A route/model override context exists (model-a card supplied) but the + // selected model-b leg has no applicable card. Rating must fail explicitly; + // it must not silently fall back to the default or an unrelated model price. + result, err := RateCall(CallRatingInput{ + Call: call, + Legs: []CallLegUsageRecord{legA, legB}, + MaxCustomerCharge: Money{Nano: 10000, Currency: "USD"}, + CustomerPricing: pricing, + CustomerPolicy: policy, + ModelPricing: []ModelCustomerPricing{ + {BackendID: "backend", ModelID: "model-a", Pricing: pricing}, + }, + }) + if !errors.Is(err, ErrRatingEvidenceMissing) { + t.Fatalf("missing applicable model card = err %v (result %+v), want ErrRatingEvidenceMissing", err, result) + } +} + +func TestRateCallDefaultPricingWhenNoModelCards(t *testing.T) { + t.Parallel() + policy := ratingPolicy(ChargeSurfacedTurn) + leg := testLeg("b-1", SurfacedYes, 1_000_000, 1_000_000, MoneyEvidence{}, true) + leg.AttemptSeq = 1 + // No ModelPricing at all: default pricing applies (input 100 + output 200 + fixed 3). + result := rateCallFromLegs(t, TurnOutcomeCompleted, []CallLegUsageRecord{leg}, policy, 1000) + if got, want := result.CustomerCharge.Nano, int64(303); got != want { + t.Fatalf("default-pricing customer = %d, want %d", got, want) + } +} diff --git a/internal/core/billing/phase0_red_test.go b/internal/core/billing/phase0_red_test.go new file mode 100644 index 00000000..8a109358 --- /dev/null +++ b/internal/core/billing/phase0_red_test.go @@ -0,0 +1,82 @@ +package billing + +import ( + "testing" + "time" +) + +func TestRateCall_ReversedLexicalBLegIDSequence(t *testing.T) { + // Task 0.1: Add RED regression for reversed lexical B-leg IDs versus real attempt sequence. + // Cover failed/canceled/no-surfaced selection and prove current positional reconstruction + // selects the wrong leg. IDs must be opaque and lexical order reversed relative to execution. + t.Parallel() + + callID := mustBillingCallID(t) + policy := ratingPolicy(ChargeSurfacedTurn) + pricing := ratingPricing() + + // Leg A: execution attempt 1, but lexically later ("b_z7x9p"). Has 1,000,000 input tokens. + legA := testCallLegUsageRecord(callID, "b_z7x9p") + legA.AttemptSeq = 1 + legA.Surfaced = SurfacedNo + legA.Outcome = LegOutcomeFailed + legA.Evidence = FinalBillingEvidence{ + InputTokens: Quantity{Value: 1_000_000, Present: true}, + Source: EvidenceSourceProviderReported, + Authority: EvidenceAuthorityAuthoritative, + } + + // Leg B: execution attempt 2, but lexically earlier ("b_a1b2c"). Has 2,000,000 input tokens. + legB := testCallLegUsageRecord(callID, "b_a1b2c") + legB.AttemptSeq = 2 + legB.Surfaced = SurfacedNo + legB.Outcome = LegOutcomeFailed + legB.Evidence = FinalBillingEvidence{ + InputTokens: Quantity{Value: 2_000_000, Present: true}, + Source: EvidenceSourceProviderReported, + Authority: EvidenceAuthorityAuthoritative, + } + + // Note: We pass them to RateCall in lexical/sorted order of their BLegID. + // ExpectedBLegIDs in CallUsageRecord is also sorted. + call := CallUsageRecord{ + SchemaVersion: CurrentRecordSchemaVersion, + CallID: callID, + AccountID: "acct-1", + ALegID: "a-shared", + SessionID: "sess-shared", + StartedAt: time.Unix(100, 0).UTC(), + FinishedAt: time.Unix(101, 0).UTC(), + Outcome: TurnOutcomeFailed, // canceled/failed call + CustomerPricingRef: pricing.Ref, + ChargePolicyRef: policy.Ref, + ExpectedBLegIDs: []string{"b_a1b2c", "b_z7x9p"}, + } + + // Current positional reconstruction uses index in the Legs slice to assign Seq. + // If Legs is passed sorted by BLegID: [legB ("b_a1b2c"), legA ("b_z7x9p")], + // legB gets Seq = 1 and legA gets Seq = 2. + // Thus, it will select legA (Seq = 2) and rate it (charging for 100 nano). + // Desired behavior: Select the latest attempt (AttemptSeq = 2), which is legB ("b_a1b2c"), charging 200 nano. + legs := []CallLegUsageRecord{legB, legA} + + result, err := RateCall(CallRatingInput{ + Call: call, + Legs: legs, + MaxCustomerCharge: Money{Nano: 1000, Currency: "USD"}, + CustomerPricing: pricing, + CustomerPolicy: policy, + }) + if err != nil { + t.Fatalf("RateCall: %v", err) + } + + // Calculate expected charge based on legB (2,000,000 tokens * 100 per million nano = 200 nano, + // plus 3 nano fixed charge = 203 nano). + // Under current positional reconstruction, it selects legA (1,000,000 tokens * 100 per million nano = 100 nano + // plus 3 nano fixed charge = 103 nano). + // We assert the desired behavior (203 nano). + if got, want := result.CustomerCharge.Nano, int64(203); got != want { + t.Errorf("CustomerCharge = %d, want %d (RED regression: current positional reconstruction selects legA instead of legB)", got, want) + } +} diff --git a/internal/core/billing/rating.go b/internal/core/billing/rating.go index 080b295d..18604981 100644 --- a/internal/core/billing/rating.go +++ b/internal/core/billing/rating.go @@ -12,6 +12,11 @@ var ( ErrRatingCurrencyMismatch = errors.New("billing: rating currency mismatch") ErrRatingEvidenceMissing = errors.New("billing: required rating evidence is missing") ErrUnreconciledCost = errors.New("billing: provider cost is unreconciled") + // ErrBillingAttemptSequenceUnknown fails closed when customer leg + // selection requires the persisted B2BUA attempt sequence but a legacy + // pre-fix leg row carries none. The call must be retried/reconciled rather + // than guessing order from IDs or timestamps. + ErrBillingAttemptSequenceUnknown = errors.New("billing: customer leg selection requires unknown attempt sequence") ) type OperatorRateSnapshot struct { @@ -60,7 +65,6 @@ type RatingInput struct { CustomerPricing PricingSnapshot ModelPricing []ModelCustomerPricing CustomerPolicy ChargePolicy - OperatorRates OperatorRateSet } type ModelCustomerPricing struct { BackendID string @@ -77,7 +81,10 @@ type OperatorCostResult struct { } func calculateCustomerCharge(record TurnUsageRecord, in RatingInput) (Money, error) { - selected := selectCustomerLegs(record.Legs, in.CustomerPolicy.Scope, record.Outcome) + selected, err := selectCustomerLegs(record.Legs, in.CustomerPolicy.Scope, record.Outcome) + if err != nil { + return Money{}, err + } var total int64 for _, leg := range selected { pricing, err := in.customerPricingForLeg(leg) @@ -109,10 +116,10 @@ func (in RatingInput) customerPricingForLeg(leg LegUsageRecord) (PricingSnapshot return PricingSnapshot{}, fmt.Errorf("%w: customer pricing for %s/%s", ErrRatingEvidenceMissing, leg.BackendID, leg.ModelID) } -func selectCustomerLegs(legs []LegUsageRecord, scope ChargePolicyScope, outcome TurnOutcome) []LegUsageRecord { +func selectCustomerLegs(legs []LegUsageRecord, scope ChargePolicyScope, outcome TurnOutcome) ([]LegUsageRecord, error) { accepted := acceptedCustomerLegs(legs) if scope == ChargeAllPotentialLegs { - return accepted + return accepted, nil } if outcome != TurnOutcomeCompleted { return oneLogicalAcceptedTurn(accepted) @@ -123,7 +130,7 @@ func selectCustomerLegs(legs []LegUsageRecord, scope ChargePolicyScope, outcome selected = append(selected, leg) } } - return selected + return selected, nil } func acceptedCustomerLegs(legs []LegUsageRecord) []LegUsageRecord { @@ -136,9 +143,14 @@ func acceptedCustomerLegs(legs []LegUsageRecord) []LegUsageRecord { return accepted } -func oneLogicalAcceptedTurn(accepted []LegUsageRecord) []LegUsageRecord { +// oneLogicalAcceptedTurn selects a single billable accepted leg for an +// interrupted (failed/canceled) call. A surfaced leg is unambiguous and needs +// no order. Without a surfaced leg the latest accepted attempt is chosen using +// the persisted B2BUA sequence; when the sequence is unknown for more than one +// accepted leg the selection is indeterminate and fails closed. +func oneLogicalAcceptedTurn(accepted []LegUsageRecord) ([]LegUsageRecord, error) { if len(accepted) == 0 { - return accepted + return accepted, nil } surfaced := make([]LegUsageRecord, 0, 1) for _, leg := range accepted { @@ -147,7 +159,15 @@ func oneLogicalAcceptedTurn(accepted []LegUsageRecord) []LegUsageRecord { } } if len(surfaced) > 0 { - return surfaced + return surfaced, nil + } + if len(accepted) == 1 { + return accepted, nil + } + for _, leg := range accepted { + if leg.Seq <= 0 { + return nil, fmt.Errorf("%w: interrupted call has %d accepted legs and requires the latest accepted attempt", ErrBillingAttemptSequenceUnknown, len(accepted)) + } } best := accepted[0] for _, leg := range accepted[1:] { @@ -155,7 +175,7 @@ func oneLogicalAcceptedTurn(accepted []LegUsageRecord) []LegUsageRecord { best = leg } } - return []LegUsageRecord{best} + return []LegUsageRecord{best}, nil } func providerAcceptedEvidence(e FinalBillingEvidence) bool { diff --git a/internal/core/billing/rating_test.go b/internal/core/billing/rating_test.go index 2424538d..590ff308 100644 --- a/internal/core/billing/rating_test.go +++ b/internal/core/billing/rating_test.go @@ -47,7 +47,7 @@ func rateCallFromLegs(t *testing.T, outcome TurnOutcome, legs []CallLegUsageReco } result, err := RateCall(CallRatingInput{ Call: call, Legs: legs, MaxCustomerCharge: Money{Nano: maxNano, Currency: "USD"}, - CustomerPricing: ratingPricing(), CustomerPolicy: policy, OperatorRates: OperatorRateSet{operatorRate()}, + CustomerPricing: ratingPricing(), CustomerPolicy: policy, }) if err != nil { t.Fatalf("RateCall: %v", err) @@ -116,10 +116,14 @@ func TestRateCallCancelWithUnsurfacedOutputStillBills(t *testing.T) { func TestRateCallSurfacedTurnInterruptBillsOneLogicalAcceptedLeg(t *testing.T) { t.Parallel() policy := ratingPolicy(ChargeSurfacedTurn) - result := rateCallFromLegs(t, TurnOutcomeCanceled, []CallLegUsageRecord{ + legs := []CallLegUsageRecord{ testLeg("b-1", SurfacedNo, 1_000_000, 1_000_000, MoneyEvidence{}, true), testLeg("b-2", SurfacedNo, 1_000_000, 1_000_000, MoneyEvidence{}, true), - }, policy, 1000) + } + // Real B2BUA attempt sequence: b-1 is attempt 1, b-2 is attempt 2. + legs[0].AttemptSeq = 1 + legs[1].AttemptSeq = 2 + result := rateCallFromLegs(t, TurnOutcomeCanceled, legs, policy, 1000) // latest Seq wins for interrupt without surfaced legs: one leg + fixed = 303 if result.CustomerCharge.Nano != 303 { t.Fatalf("customer = %d, want 303", result.CustomerCharge.Nano) @@ -236,27 +240,21 @@ func TestRateCallUsesPerModelCustomerCards(t *testing.T) { } legA := testLeg("b-a", SurfacedYes, 1_000_000, 0, MoneyEvidence{}, true) legA.ModelID = "model-a" + legA.ALegID = "a-1" legA.CallID = callID legB := testLeg("b-b", SurfacedYes, 1_000_000, 0, MoneyEvidence{}, true) legB.ModelID = "model-b" + legB.ALegID = "a-1" legB.CallID = callID call := CallUsageRecord{ SchemaVersion: CurrentRecordSchemaVersion, CallID: callID, AccountID: "acct-1", ALegID: "a-1", ExpectedBLegIDs: []string{"b-a", "b-b"}, StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(101, 0).UTC(), Outcome: TurnOutcomeCompleted, CustomerPricingRef: pricing.Ref, ChargePolicyRef: policy.Ref, } - // RateCall currently does not accept ModelPricing on CallRatingInput. - // Exercise the shared helper via temporary RatingInput used by RateCall path: - turnLegs := []LegUsageRecord{ - {ALegID: "a-1", BLegID: "b-a", Seq: 1, BackendID: "backend", ModelID: "model-a", Surfaced: SurfacedYes, Evidence: legA.Evidence, Outcome: LegOutcomeWinner}, - {ALegID: "a-1", BLegID: "b-b", Seq: 2, BackendID: "backend", ModelID: "model-b", Surfaced: SurfacedYes, Evidence: legB.Evidence, Outcome: LegOutcomeWinner}, - } - turn := TurnUsageRecord{ - SchemaVersion: CurrentRecordSchemaVersion, Key: "tmp", AccountID: "acct-1", TurnID: callID.String(), - ALegID: "a-1", Outcome: TurnOutcomeCompleted, CustomerPricingRef: pricing.Ref, ChargePolicyRef: policy.Ref, Legs: turnLegs, - } - got, err := calculateCustomerCharge(turn, RatingInput{ - Record: turn, CustomerPricing: pricing, CustomerPolicy: policy, + result, err := RateCall(CallRatingInput{ + Call: call, Legs: []CallLegUsageRecord{legA, legB}, + MaxCustomerCharge: Money{Nano: 10000, Currency: "USD"}, + CustomerPricing: pricing, CustomerPolicy: policy, ModelPricing: []ModelCustomerPricing{ {BackendID: "backend", ModelID: "model-a", Pricing: pricing}, {BackendID: "backend", ModelID: "model-b", Pricing: modelCard}, @@ -265,9 +263,8 @@ func TestRateCallUsesPerModelCustomerCards(t *testing.T) { if err != nil { t.Fatal(err) } - // model-a uses catalog fixed charge (100+3); model-b card has no fixed charges (1000). - if got.Nano != 1103 { - t.Fatalf("customer = %d, want 1103", got.Nano) + // model-a uses default card (100 input + 3 fixed); model-b card has no fixed charges (1000 input). + if got, want := result.CustomerCharge.Nano, int64(1103); got != want { + t.Fatalf("customer = %d, want %d", got, want) } - _ = call } diff --git a/internal/core/billing/sequence_contract_test.go b/internal/core/billing/sequence_contract_test.go new file mode 100644 index 00000000..31ef0fbd --- /dev/null +++ b/internal/core/billing/sequence_contract_test.go @@ -0,0 +1,194 @@ +package billing + +import ( + "encoding/json" + "errors" + "testing" +) + +// Phase 1.1: sequence-aware CallLegUsageRecord contract. +// +// New records carry the authoritative positive b2bua.BLegRecord.Seq in +// AttemptSeq and participate in a v2 semantic fingerprint. Legacy v1 rows have +// no sequence (AttemptSeq == 0, durable attempt_seq NULL) and must keep the +// historical v1 fingerprint so brownfield replay never appears corrupt. + +func TestCallLegUsageReplayConflictsWhenAttemptSequenceChanges(t *testing.T) { + t.Parallel() + callID := mustBillingCallID(t) + + first, err := testCallLegUsageRecord(callID, "b-1").Seal() + if err != nil { + t.Fatal(err) + } + first.AttemptSeq = 1 + first, err = first.Seal() + if err != nil { + t.Fatal(err) + } + + replayed, err := testCallLegUsageRecord(callID, "b-1").Seal() + if err != nil { + t.Fatal(err) + } + replayed.AttemptSeq = 2 + replayed, err = replayed.Seal() + if err != nil { + t.Fatal(err) + } + + if err := CheckCallLegUsageReplay(first, replayed); !errors.Is(err, ErrReplayConflict) { + t.Fatalf("same-key replay with changed AttemptSeq = %v, want ErrReplayConflict", err) + } + + identical, err := testCallLegUsageRecord(callID, "b-1").Seal() + if err != nil { + t.Fatal(err) + } + identical.AttemptSeq = 1 + identical, err = identical.Seal() + if err != nil { + t.Fatal(err) + } + if err := CheckCallLegUsageReplay(first, identical); err != nil { + t.Fatalf("identical v2 replay must be a no-op: %v", err) + } +} + +func TestCallLegUsageAttemptSequenceParticipatesInFingerprint(t *testing.T) { + t.Parallel() + callID := mustBillingCallID(t) + + seq1 := testCallLegUsageRecord(callID, "b-1") + seq1.AttemptSeq = 1 + seq2 := testCallLegUsageRecord(callID, "b-1") + seq2.AttemptSeq = 2 + + a, err := seq1.Seal() + if err != nil { + t.Fatal(err) + } + b, err := seq2.Seal() + if err != nil { + t.Fatal(err) + } + if a.Fingerprint == b.Fingerprint { + t.Fatal("v2 fingerprint must include AttemptSeq; seq 1 and seq 2 produced the same fingerprint") + } + if a.Key != b.Key { + t.Fatalf("key = %q vs %q, want same (BillingCallID + BLegID)", a.Key, b.Key) + } +} + +func TestCallLegUsageLegacyMissingSequenceKeepsV1Fingerprint(t *testing.T) { + t.Parallel() + callID := BillingCallID("bc_00000000000000000000000000000000") + + legacy := testCallLegUsageRecord(callID, "b-1") // AttemptSeq zero = pre-fix/legacy row + sealed, err := legacy.Seal() + if err != nil { + t.Fatalf("legacy v1 row must still seal: %v", err) + } + if err := CheckCallLegUsageReplay(sealed, sealed); err != nil { + t.Fatalf("legacy v1 row must validate against its own fingerprint: %v", err) + } + if got, want := sealed.Fingerprint, "56877481b2f322e821e15935ca117e43e4a5ef2004a611407d62d84f01a9aa8e"; got != want { + t.Fatalf("legacy v1 fingerprint = %s, want %s", got, want) + } + + // A legacy row replayed with a known sequence is a different (v2) record. + known, err := testCallLegUsageRecord(callID, "b-1").Seal() + if err != nil { + t.Fatal(err) + } + known.AttemptSeq = 1 + known, err = known.Seal() + if err != nil { + t.Fatal(err) + } + if err := CheckCallLegUsageReplay(sealed, known); !errors.Is(err, ErrReplayConflict) { + t.Fatalf("legacy v1 vs v2 same-key replay = %v, want ErrReplayConflict", err) + } +} + +func TestCallLegUsageRejectsNegativeAttemptSequence(t *testing.T) { + t.Parallel() + callID := mustBillingCallID(t) + src := testCallLegUsageRecord(callID, "b-1") + src.AttemptSeq = -1 + if _, err := src.Seal(); !errors.Is(err, ErrInvalidRecord) { + t.Fatalf("negative AttemptSeq seal = %v, want ErrInvalidRecord", err) + } +} + +func TestCallLegUsageSequencePresenceRoundTripsThroughJSON(t *testing.T) { + t.Parallel() + callID := mustBillingCallID(t) + + // Legacy payload: JSON historically did not carry any sequence member. + legacy := testCallLegUsageRecord(callID, "b-1") + legacyJSON, err := json.Marshal(stripField(legacy, "AttemptSeq")) + if err != nil { + t.Fatal(err) + } + var decodedLegacy CallLegUsageRecord + if err := json.Unmarshal(legacyJSON, &decodedLegacy); err != nil { + t.Fatal(err) + } + if decodedLegacy.AttemptSeq != 0 { + t.Fatalf("legacy payload decoded AttemptSeq = %d, want 0 (unknown)", decodedLegacy.AttemptSeq) + } + sealedLegacy, err := decodedLegacy.Seal() + if err != nil { + t.Fatal(err) + } + if err := CheckCallLegUsageReplay(sealedLegacy, sealedLegacy); err != nil { + t.Fatalf("legacy JSON round-trip must remain v1-readable: %v", err) + } + + // New v2 payload carries and restores the sequence explicitly. + newRecord := testCallLegUsageRecord(callID, "b-2") + newRecord.AttemptSeq = 3 + sealedNew, err := newRecord.Seal() + if err != nil { + t.Fatal(err) + } + newJSON, err := json.Marshal(sealedNew) + if err != nil { + t.Fatal(err) + } + var decodedNew CallLegUsageRecord + if err := json.Unmarshal(newJSON, &decodedNew); err != nil { + t.Fatal(err) + } + if decodedNew.AttemptSeq != 3 { + t.Fatalf("new payload decoded AttemptSeq = %d, want 3", decodedNew.AttemptSeq) + } + if err := CheckCallLegUsageReplay(sealedNew, decodedNew); err != nil { + t.Fatalf("v2 JSON round-trip replay: %v", err) + } +} + +// stripField marshals v as JSON without the named field, simulating a +// pre-fix payload that predates sequence persistence. +func stripField(v any, field string) any { + return structWithoutField{v: v, field: field} +} + +type structWithoutField struct { + v any + field string +} + +func (s structWithoutField) MarshalJSON() ([]byte, error) { + raw, err := json.Marshal(s.v) + if err != nil { + return nil, err + } + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + return nil, err + } + delete(m, s.field) + return json.Marshal(m) +} diff --git a/internal/core/billing/sequence_rating_test.go b/internal/core/billing/sequence_rating_test.go new file mode 100644 index 00000000..87664f27 --- /dev/null +++ b/internal/core/billing/sequence_rating_test.go @@ -0,0 +1,122 @@ +package billing + +import ( + "errors" + "testing" +) + +// Phase 1.4: customer leg selection consumes only persisted authoritative +// sequence. Legacy unknown sequence (AttemptSeq == 0) may be auto-rated only +// for provably sequence-independent cases; otherwise the call fails closed. + +func TestRateCallFailsClosedWhenSequenceUnknownAndOrderRequired(t *testing.T) { + t.Parallel() + policy := ratingPolicy(ChargeSurfacedTurn) + legs := []CallLegUsageRecord{ + testLeg("b-1", SurfacedNo, 1_000_000, 1_000_000, MoneyEvidence{}, true), + testLeg("b-2", SurfacedNo, 1_000_000, 1_000_000, MoneyEvidence{}, true), + } + // Neither leg carries a known attempt sequence (legacy rows). + legs[0].AttemptSeq = 0 + legs[1].AttemptSeq = 0 + result, err := rateCallFromLegsReturningError(t, TurnOutcomeCanceled, legs, policy, 1000) + if !errors.Is(err, ErrBillingAttemptSequenceUnknown) { + t.Fatalf("canceled interrupt with unknown sequence = err %v (result %+v), want ErrBillingAttemptSequenceUnknown", err, result) + } +} + +func TestRateCallSequenceIndependenceAllowsLegacyUnknownLegs(t *testing.T) { + t.Parallel() + policy := ratingPolicy(ChargeSurfacedTurn) + + // Completed call with an unambiguous surfaced winner: sequence is + // irrelevant, so legacy unknown-sequence legs remain auto-rateable. + completed := []CallLegUsageRecord{ + testLeg("b-1", SurfacedNo, 1_000_000, 1_000_000, MoneyEvidence{}, true), + testLeg("b-2", SurfacedYes, 1_000_000, 1_000_000, MoneyEvidence{}, true), + } + completed[0].AttemptSeq = 0 + completed[1].AttemptSeq = 0 + result := rateCallFromLegs(t, TurnOutcomeCompleted, completed, policy, 1000) + if result.CustomerCharge.Nano != 303 { + t.Fatalf("completed surfaced winner customer = %d, want 303 (sequence-independent)", result.CustomerCharge.Nano) + } + + // Failed/canceled call with a single accepted leg needs no order either. + single := []CallLegUsageRecord{ + testLeg("b-1", SurfacedNo, 1_000_000, 1_000_000, MoneyEvidence{}, true), + } + single[0].AttemptSeq = 0 + result = rateCallFromLegs(t, TurnOutcomeCanceled, single, policy, 1000) + if result.CustomerCharge.Nano != 303 { + t.Fatalf("canceled single-leg customer = %d, want 303", result.CustomerCharge.Nano) + } + + // Charge-all policy never needs order to choose a billable leg. + chargeAll := ratingPolicy(ChargeAllPotentialLegs) + all := []CallLegUsageRecord{ + testLeg("b-1", SurfacedNo, 1_000_000, 1_000_000, MoneyEvidence{}, true), + testLeg("b-2", SurfacedNo, 1_000_000, 1_000_000, MoneyEvidence{}, true), + } + all[0].AttemptSeq = 0 + all[1].AttemptSeq = 0 + result = rateCallFromLegs(t, TurnOutcomeCanceled, all, chargeAll, 1000) + if result.CustomerCharge.Nano != 606 { + t.Fatalf("charge-all unknown-sequence customer = %d, want 606", result.CustomerCharge.Nano) + } +} + +func TestRateCallUsesPersistedAttemptSequenceForInterruptedSelection(t *testing.T) { + t.Parallel() + policy := ratingPolicy(ChargeSurfacedTurn) + + // Canceled call, no surfaced leg: latest known attempt (AttemptSeq 2) + // wins regardless of BLegID lexical order. IDs are opaque; lexical order + // ("b_a" < "b_z") is the reverse of execution order. Attempts carry + // distinct token dimensions so selection is observable. + legA := testLeg("b_z7x9p", SurfacedNo, 1_000_000, -1, MoneyEvidence{}, true) // attempt 1: input only -> 103 + legA.AttemptSeq = 1 + legB := testLeg("b_a1b2c", SurfacedNo, -1, 1_000_000, MoneyEvidence{}, true) // attempt 2: output only -> 203 + legB.AttemptSeq = 2 + result := rateCallFromLegs(t, TurnOutcomeCanceled, []CallLegUsageRecord{legA, legB}, policy, 1000) + if result.CustomerCharge.Nano != 203 { + t.Fatalf("interrupted selection customer = %d, want 203 from latest attempt (lexical order must be ignored)", result.CustomerCharge.Nano) + } +} + +func rateCallFromLegsReturningError(t *testing.T, outcome TurnOutcome, legs []CallLegUsageRecord, policy ChargePolicy, maxNano int64) (CallRatingResult, error) { + t.Helper() + legs = append([]CallLegUsageRecord(nil), legs...) + call, err := buildRateCall(callIDOfLegs(t), outcome, legs, policy) + if err != nil { + t.Fatal(err) + } + return RateCall(CallRatingInput{ + Call: call, Legs: legs, MaxCustomerCharge: Money{Nano: maxNano, Currency: "USD"}, + CustomerPricing: ratingPricing(), CustomerPolicy: policy, + }) +} + +func callIDOfLegs(t *testing.T) BillingCallID { + t.Helper() + callID, err := NewBillingCallID() + if err != nil { + t.Fatal(err) + } + return callID +} + +func buildRateCall(callID BillingCallID, outcome TurnOutcome, legs []CallLegUsageRecord, policy ChargePolicy) (CallUsageRecord, error) { + ids := make([]string, 0, len(legs)) + for i := range legs { + legs[i].CallID = callID + legs[i].ALegID = "a-1" + ids = append(ids, legs[i].BLegID) + } + return CallUsageRecord{ + SchemaVersion: CurrentRecordSchemaVersion, CallID: callID, AccountID: "acct-1", + ALegID: "a-1", ExpectedBLegIDs: ids, StartedAt: testCallUsageRecord(callID).StartedAt, + FinishedAt: testCallUsageRecord(callID).FinishedAt, + Outcome: outcome, CustomerPricingRef: ratingPricing().Ref, ChargePolicyRef: policy.Ref, + }, nil +} diff --git a/internal/core/runtime/billing_abort_leg_completeness_test.go b/internal/core/runtime/billing_abort_leg_completeness_test.go index d7820b43..be17dc84 100644 --- a/internal/core/runtime/billing_abort_leg_completeness_test.go +++ b/internal/core/runtime/billing_abort_leg_completeness_test.go @@ -207,6 +207,7 @@ func TestParallelRegisterBLegFailureAppendsTerminalLegForJoin(t *testing.T) { p := authorityOpenParams(t, aLegID, &attemptBudget{max: 10}) p.aScope = aScope p.billingCallID = callID + p.billingCallState = newBillingCallState(callID) raceErr := runRaceInGoroutine(t, 5*time.Second, func() error { _, err := ex.tryOpenParallelGroup(context.Background(), p, []routing.AttemptCandidate{authorityCandidate()}, nil, "", false) @@ -227,7 +228,7 @@ func TestParallelRegisterBLegFailureAppendsTerminalLegForJoin(t *testing.T) { t.Fatalf("leg CallID = %s, want %s", legs[0].CallID, callID) } - frozen := ex.billingTurns().freezeAllocatedBLegs(callID) + frozen := p.billingCallState.freezeAllocatedBLegs() if len(frozen) == 0 { t.Fatal("expected allocated B-leg to remain frozen after RegisterBLeg failure") } diff --git a/internal/core/runtime/billing_admission.go b/internal/core/runtime/billing_admission.go index 25dba631..9ad8453b 100644 --- a/internal/core/runtime/billing_admission.go +++ b/internal/core/runtime/billing_admission.go @@ -167,7 +167,7 @@ func (e *Executor) appendExposureAbortClosure(ctx context.Context, prep *prepare Outcome: billing.TurnOutcomeFailed, CustomerPricingRef: prep.billingCustomerPricing, ChargePolicyRef: prep.billingChargePolicy, - ExpectedBLegIDs: e.billingTurns().freezeAllocatedBLegs(prep.billingCallID), + ExpectedBLegIDs: prep.billingCallState.freezeAllocatedBLegs(), } sealed, err := record.Seal() if err != nil { diff --git a/internal/core/runtime/billing_append_diagnostics_test.go b/internal/core/runtime/billing_append_diagnostics_test.go index 772048a1..9197cdbe 100644 --- a/internal/core/runtime/billing_append_diagnostics_test.go +++ b/internal/core/runtime/billing_append_diagnostics_test.go @@ -63,7 +63,7 @@ func TestAppendIndependentCallLegLogsCriticalOnDualFailure(t *testing.T) { t.Fatal(err) } ex.appendIndependentCallLeg(context.Background(), callID, billing.LegUsageRecord{ - ALegID: "a-1", BLegID: "b-1", BackendID: "be", ProviderID: "be", ModelID: "m", + ALegID: "a-1", BLegID: "b-1", Seq: 1, BackendID: "be", ProviderID: "be", ModelID: "m", StartedAt: time.Unix(1, 0).UTC(), FinishedAt: time.Unix(2, 0).UTC(), Outcome: billing.LegOutcomeFailed, Surfaced: billing.SurfacedNo, Evidence: billing.FinalBillingEvidence{Source: billing.EvidenceSourceUnavailable, Authority: billing.EvidenceAuthorityUnavailable}, @@ -91,7 +91,7 @@ func TestAppendIndependentCallLegWarnsWhenOutboxArmed(t *testing.T) { t.Fatal(err) } ex.appendIndependentCallLeg(context.Background(), callID, billing.LegUsageRecord{ - ALegID: "a-1", BLegID: "b-1", BackendID: "be", ProviderID: "be", ModelID: "m", + ALegID: "a-1", BLegID: "b-1", Seq: 1, BackendID: "be", ProviderID: "be", ModelID: "m", StartedAt: time.Unix(1, 0).UTC(), FinishedAt: time.Unix(2, 0).UTC(), Outcome: billing.LegOutcomeFailed, Surfaced: billing.SurfacedNo, Evidence: billing.FinalBillingEvidence{Source: billing.EvidenceSourceUnavailable, Authority: billing.EvidenceAuthorityUnavailable}, diff --git a/internal/core/runtime/billing_call_closure.go b/internal/core/runtime/billing_call_closure.go index a79cac90..bf5f65f8 100644 --- a/internal/core/runtime/billing_call_closure.go +++ b/internal/core/runtime/billing_call_closure.go @@ -14,6 +14,7 @@ func (s *retryRecvStream) appendCallClosureLocked(ctx context.Context, command s if s.executor.CallUsageAppender == nil || s.billingCallClosureSuccess { return } + s.ensureBillingCallState() if err := s.billingCallID.Validate(); err != nil { return } @@ -24,9 +25,9 @@ func (s *retryRecvStream) appendCallClosureLocked(ctx context.Context, command s if accountID == "" { return } - ids := s.executor.billingTurns().freezeAllocatedBLegs(s.billingCallID) + ids := s.billingCallState.freezeAllocatedBLegs() now := s.now() - started, finished := callClosureTimes(s.executor.billingTurns().closureLegTimes(s.billingCallID), now) + started, finished := s.billingCallState.timingBounds(now) record := billing.CallUsageRecord{ SchemaVersion: billing.CurrentRecordSchemaVersion, CallID: s.billingCallID, diff --git a/internal/core/runtime/billing_call_closure_test.go b/internal/core/runtime/billing_call_closure_test.go index e054989d..0f508d03 100644 --- a/internal/core/runtime/billing_call_closure_test.go +++ b/internal/core/runtime/billing_call_closure_test.go @@ -56,9 +56,10 @@ func TestCallUsageAppenderFreezesAllocatedBLegsAtRequestTerminal(t *testing.T) { cand: routing.AttemptCandidate{Primary: routing.Primary{Backend: "backend", Model: "model"}}, } stampStreamIdentity(stream) - coll := executor.billingTurns() - coll.noteAllocatedBLeg(callID, "b-2") - coll.noteAllocatedBLeg(callID, "b-1") + state := newBillingCallState(callID) + stream.billingCallState = state + state.noteAllocatedBLeg("b-2", 2) + state.noteAllocatedBLeg("b-1", 1) stream.handoffBillingTurn(context.Background(), sdkterminal.CommandNormalFinish) if len(got) != 1 { t.Fatalf("call-closure appends = %d, want 1", len(got)) @@ -76,9 +77,9 @@ func TestCallUsageAppenderFreezesAllocatedBLegsAtRequestTerminal(t *testing.T) { t.Fatalf("frozen expected B-legs = %#v", got[0].ExpectedBLegIDs) } - coll.noteAllocatedBLeg(callID, "b-3") + state.noteAllocatedBLeg("b-3", 3) stream.handoffBillingTurn(context.Background(), sdkterminal.CommandNormalFinish) - frozen := coll.freezeAllocatedBLegs(callID) + frozen := state.freezeAllocatedBLegs() if len(frozen) != 2 || frozen[0] != "b-1" || frozen[1] != "b-2" { t.Fatalf("allocated set grew after terminal freeze: %#v", frozen) } @@ -387,11 +388,7 @@ func TestCallUsageAppenderSwallowedAttemptDoesNotFreezeUntilRequestTerminal(t *t if len(got) != 0 { t.Fatalf("swallowed attempt-terminal must not append call-closure, got %d", len(got)) } - coll := executor.billingTurns() - coll.mu.Lock() - _, frozen := coll.frozenByCall[callID.String()] - coll.mu.Unlock() - if frozen { + if stream.billingCallState != nil && stream.billingCallState.hasFrozen { t.Fatal("swallowed attempt-terminal must not freeze allocated B-legs") } diff --git a/internal/core/runtime/billing_call_id.go b/internal/core/runtime/billing_call_id.go index e9dac044..ac70c062 100644 --- a/internal/core/runtime/billing_call_id.go +++ b/internal/core/runtime/billing_call_id.go @@ -18,5 +18,6 @@ func stampBillingCallID(prep *preparedRequest) error { return err } prep.billingCallID = id + prep.billingCallState = newBillingCallState(id) return nil } diff --git a/internal/core/runtime/billing_call_state_red_test.go b/internal/core/runtime/billing_call_state_red_test.go new file mode 100644 index 00000000..b698f1a1 --- /dev/null +++ b/internal/core/runtime/billing_call_state_red_test.go @@ -0,0 +1,188 @@ +package runtime + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/matdev83/go-llm-interactive-proxy/internal/core/b2bua" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/billing" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/execbackend" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/hooks" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipapi" +) + +func TestBillingCallState_OwnershipAndBillingCallID(t *testing.T) { + // Task 3.1: Verify request/BillingCallID-scoped state is allocated once per prepared invocation. + // Two distinct prepared requests must have distinct state objects and distinct call IDs. + t.Parallel() + + st, err := b2bua.NewMemoryStore(b2bua.MemoryStoreOptions{}) + if err != nil { + t.Fatal(err) + } + + executor := TestExecutor() + executor.Store = st + executor.Bus = hooks.New(hooks.Config{}) + executor.CallUsageAppender = billing.CallUsageAppenderFunc(func(ctx context.Context, record billing.CallUsageRecord) error { + return nil + }) + + call1 := &lipapi.Call{ + ID: "call-1", + Session: lipapi.SessionRef{AuthoritativeSessionID: "session-1"}, + Messages: []lipapi.Message{{ + Role: lipapi.RoleUser, + Parts: []lipapi.Part{lipapi.TextPart("hi")}, + }}, + } + prep1, _, cleanup1, err := executor.prepareRequest(context.Background(), call1) + if err != nil { + t.Fatal(err) + } + defer cleanup1() + + if prep1.billingCallState == nil { + t.Fatal("billingCallState should be allocated during prepareRequest") + } + + call2 := &lipapi.Call{ + ID: "call-2", + Session: lipapi.SessionRef{AuthoritativeSessionID: "session-1"}, + Messages: []lipapi.Message{{ + Role: lipapi.RoleUser, + Parts: []lipapi.Part{lipapi.TextPart("hi")}, + }}, + } + prep2, _, cleanup2, err := executor.prepareRequest(context.Background(), call2) + if err != nil { + t.Fatal(err) + } + defer cleanup2() + + if prep2.billingCallState == nil { + t.Fatal("billingCallState should be allocated for prep2") + } + + if prep1.billingCallState == prep2.billingCallState { + t.Error("distinct invocations on the same session/A-leg must receive distinct state pointers") + } + if prep1.billingCallID == prep2.billingCallID { + t.Errorf("distinct invocations reused BillingCallID %q", prep1.billingCallID) + } + + if prep1.billingCallState.callID != prep1.billingCallID { + t.Errorf("state callID %q does not match prep billingCallID %q", prep1.billingCallState.callID, prep1.billingCallID) + } +} + +func TestBillingCallState_ParallelAndInterleavedSharing(t *testing.T) { + // Verify retry/parallel/interleaved paths for one invocation share the state pointer. + t.Parallel() + + state := &billingCallState{ + callID: "test-call-id", + } + + // 1. Parallel leg allocation sharing + var wg sync.WaitGroup + const numParallel = 5 + for i := 0; i < numParallel; i++ { + wg.Add(1) + go func(seq int) { + defer wg.Done() + state.noteAllocatedBLeg(billingSyntheticBLegID(seq), seq) + }(i + 1) + } + wg.Wait() + + expectedSet := state.freezeAllocatedBLegs() + if len(expectedSet) != numParallel { + t.Errorf("expected %d allocated legs, got %d", numParallel, len(expectedSet)) + } +} + +func TestBillingCallState_FinalizationSingleFlight(t *testing.T) { + // Verify racing finalizations use single-flight behavior and share results. + t.Parallel() + + state := &billingCallState{ + callID: "test-call-id", + } + + var callCount int64 + finalizeFn := func(ctx context.Context, in execbackend.BillingFinalizationInput) (lipapi.Event, error) { + time.Sleep(10 * time.Millisecond) + return lipapi.Event{ + Kind: lipapi.EventUsageDelta, + InputTokens: 42, + }, nil + } + + var wg sync.WaitGroup + const numRacers = 10 + results := make([]lipapi.Event, numRacers) + oks := make([]bool, numRacers) + + for i := 0; i < numRacers; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + ev, ok := state.finalizeOnce(context.Background(), execbackend.BillingFinalizationInput{ + BLegID: "b-leg-1", + Backend: "backend-1", + Model: "model-1", + }, func(ctx context.Context, in execbackend.BillingFinalizationInput) (lipapi.Event, error) { + callCount++ + return finalizeFn(ctx, in) + }) + results[idx] = ev + oks[idx] = ok + }(i) + } + wg.Wait() + + if callCount != 1 { + t.Errorf("expected backend FinalizeBilling to be called exactly once, got %d", callCount) + } + + for i := 0; i < numRacers; i++ { + if !oks[i] { + t.Errorf("racer %d failed", i) + } + if results[i].InputTokens != 42 { + t.Errorf("racer %d got unexpected tokens: %d", i, results[i].InputTokens) + } + } +} + +func TestBillingCallState_TimingBoundsClosure(t *testing.T) { + // Verify timing bounds and closure expected set logic. + t.Parallel() + + state := &billingCallState{ + callID: "test-call-id", + } + + now := time.Now() + state.noteLegTimes(now.Add(-10*time.Second), now.Add(-5*time.Second)) + state.noteLegTimes(now.Add(-8*time.Second), now.Add(-2*time.Second)) + + state.noteAllocatedBLeg("b-leg-1", 1) + state.noteAllocatedBLeg("b-leg-2", 2) + + expectedIds := state.freezeAllocatedBLegs() + if len(expectedIds) != 2 || expectedIds[0] != "b-leg-1" || expectedIds[1] != "b-leg-2" { + t.Errorf("unexpected expectedIds: %v", expectedIds) + } + + started, finished := state.timingBounds(now) + if !started.Equal(now.Add(-10 * time.Second)) { + t.Errorf("expected started time to be min start, got %v", started) + } + if !finished.Equal(now.Add(-2 * time.Second)) { + t.Errorf("expected finished time to be max finish, got %v", finished) + } +} diff --git a/internal/core/runtime/billing_collector.go b/internal/core/runtime/billing_collector.go index 1680b786..1d0de142 100644 --- a/internal/core/runtime/billing_collector.go +++ b/internal/core/runtime/billing_collector.go @@ -2,6 +2,7 @@ package runtime import ( "context" + "fmt" "slices" "strings" "sync" @@ -13,123 +14,144 @@ import ( "github.com/matdev83/go-llm-interactive-proxy/pkg/lipapi" ) -type billingTurnCollector struct { - exec *Executor - mu sync.Mutex - allocatedByCall map[string]map[string]struct{} - frozenByCall map[string][]string - legTimesByCall map[string][]billing.LegUsageRecord - finalizeMu sync.Mutex - finalizeByKey map[string]*finalizeCacheEntry +func (e *Executor) billingEnabled() bool { + return e != nil && (e.BillingLegObserver != nil || e.CallLegUsageAppender != nil || e.CallUsageAppender != nil) } -func (e *Executor) billingTurns() *billingTurnCollector { - if e == nil { +func (e *Executor) observeBillingLeg(ctx context.Context, record billing.LegUsageRecord) { + if e == nil || e.BillingLegObserver == nil { + return + } + _ = safety.Call(safety.BoundaryStream, "billing_leg_observer", func() error { + e.BillingLegObserver.ObserveBillingLeg(ctx, record) return nil + }) +} + +func (e *Executor) callFinalizeBilling(ctx context.Context, in execbackend.BillingFinalizationInput) (lipapi.Event, error) { + if e == nil || e.Backends == nil { + return lipapi.Event{}, fmt.Errorf("executor finalizer: no backends") + } + backendID := strings.TrimSpace(in.Backend) + be, ok := e.Backends[backendID] + if !ok || be.FinalizeBilling == nil { + return lipapi.Event{}, fmt.Errorf("executor finalizer: backend %q does not support FinalizeBilling", backendID) } - e.billingOnce.Do(func() { - e.billingColl = &billingTurnCollector{exec: e} + persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), billingFinalizeTimeout) + defer cancel() + in.Backend = backendID + ev, err := safety.CallValue(safety.BoundaryBackend, "backend_finalize_billing", func() (lipapi.Event, error) { + return be.FinalizeBilling(persistCtx, in) }) - return e.billingColl + if err != nil { + if e.Log != nil { + e.Log.DebugContext(persistCtx, "billing FinalizeBilling", "error", err) + } + return lipapi.Event{}, err + } + if ev.Kind != lipapi.EventUsageDelta { + return lipapi.Event{}, fmt.Errorf("executor finalizer: invalid event kind %q", ev.Kind) + } + return ev, nil } -func (c *billingTurnCollector) enabled() bool { - return c != nil && c.exec != nil && (c.exec.BillingLegObserver != nil || c.exec.CallLegUsageAppender != nil || c.exec.CallUsageAppender != nil) +type billingCallState struct { + callID billing.BillingCallID + + mu sync.Mutex + + allocated map[string]int // BLegID -> actual AttemptSeq + frozen []string + hasFrozen bool + legTimes []billing.LegUsageRecord + + finalizeMu sync.Mutex + finalize map[string]*finalizeCacheEntry } -func (c *billingTurnCollector) observe(ctx context.Context, record billing.LegUsageRecord) { - if c == nil || c.exec == nil || c.exec.BillingLegObserver == nil { - return +func newBillingCallState(callID billing.BillingCallID) *billingCallState { + return &billingCallState{ + callID: callID, + allocated: make(map[string]int), + finalize: make(map[string]*finalizeCacheEntry), } - _ = safety.Call(safety.BoundaryStream, "billing_leg_observer", func() error { - c.exec.BillingLegObserver.ObserveBillingLeg(ctx, record) - return nil - }) } -func (c *billingTurnCollector) noteAllocatedBLeg(callID billing.BillingCallID, bLegID string) { - if c == nil { - return - } - if err := callID.Validate(); err != nil { +func (s *billingCallState) noteAllocatedBLeg(bLegID string, seq int) { + if s == nil { return } bLegID = strings.TrimSpace(bLegID) if bLegID == "" { return } - key := callID.String() - c.mu.Lock() - defer c.mu.Unlock() - if _, frozen := c.frozenByCall[key]; frozen { + s.mu.Lock() + defer s.mu.Unlock() + if s.hasFrozen { return } - if c.allocatedByCall == nil { - c.allocatedByCall = make(map[string]map[string]struct{}) - } - set := c.allocatedByCall[key] - if set == nil { - set = make(map[string]struct{}) - c.allocatedByCall[key] = set + if s.allocated == nil { + s.allocated = make(map[string]int) } - set[bLegID] = struct{}{} + s.allocated[bLegID] = seq } -func (c *billingTurnCollector) noteLegTimes(callID billing.BillingCallID, started, finished time.Time) { - if c == nil { - return - } - if err := callID.Validate(); err != nil || started.IsZero() || finished.IsZero() { - return +func (s *billingCallState) freezeAllocatedBLegs() []string { + if s == nil { + return nil } - key := callID.String() - c.mu.Lock() - defer c.mu.Unlock() - if _, frozen := c.frozenByCall[key]; frozen { - return + s.mu.Lock() + defer s.mu.Unlock() + if s.hasFrozen { + return append([]string(nil), s.frozen...) } - if c.legTimesByCall == nil { - c.legTimesByCall = make(map[string][]billing.LegUsageRecord) + ids := make([]string, 0, len(s.allocated)) + for id := range s.allocated { + ids = append(ids, id) } - c.legTimesByCall[key] = append(c.legTimesByCall[key], billing.LegUsageRecord{StartedAt: started, FinishedAt: finished}) + slices.Sort(ids) + s.frozen = append([]string(nil), ids...) + s.hasFrozen = true + return ids } -func (c *billingTurnCollector) closureLegTimes(callID billing.BillingCallID) []billing.LegUsageRecord { - if c == nil { - return nil +func (s *billingCallState) noteLegTimes(started, finished time.Time) { + if s == nil || started.IsZero() || finished.IsZero() { + return } - if err := callID.Validate(); err != nil { - return nil + s.mu.Lock() + defer s.mu.Unlock() + if s.hasFrozen { + return } - c.mu.Lock() - defer c.mu.Unlock() - return append([]billing.LegUsageRecord(nil), c.legTimesByCall[callID.String()]...) + s.legTimes = append(s.legTimes, billing.LegUsageRecord{StartedAt: started, FinishedAt: finished}) } -func (c *billingTurnCollector) freezeAllocatedBLegs(callID billing.BillingCallID) []string { - if c == nil { - return nil +func (s *billingCallState) timingBounds(now time.Time) (time.Time, time.Time) { + if s == nil { + return now, now } - if err := callID.Validate(); err != nil { - return nil + s.mu.Lock() + defer s.mu.Unlock() + var started, finished time.Time + for _, leg := range s.legTimes { + if !leg.StartedAt.IsZero() && (started.IsZero() || leg.StartedAt.Before(started)) { + started = leg.StartedAt + } + if !leg.FinishedAt.IsZero() && (finished.IsZero() || leg.FinishedAt.After(finished)) { + finished = leg.FinishedAt + } } - key := callID.String() - c.mu.Lock() - defer c.mu.Unlock() - if frozen, ok := c.frozenByCall[key]; ok { - return append([]string(nil), frozen...) + if started.IsZero() { + started = now } - set := c.allocatedByCall[key] - ids := make([]string, 0, len(set)) - for id := range set { - ids = append(ids, id) + if finished.IsZero() { + finished = now } - slices.Sort(ids) - if c.frozenByCall == nil { - c.frozenByCall = make(map[string][]string) + if finished.Before(started) { + finished = started } - c.frozenByCall[key] = append([]string(nil), ids...) - return ids + return started, finished } type finalizeCacheEntry struct { @@ -145,66 +167,60 @@ func finalizeCacheKey(in execbackend.BillingFinalizationInput) string { return strings.TrimSpace(in.ALegID) + "|" + strings.TrimSpace(in.Backend) + "|" + strings.TrimSpace(in.Model) } -func (c *billingTurnCollector) finalizeOnce(ctx context.Context, in execbackend.BillingFinalizationInput) (lipapi.Event, bool) { - if c == nil || c.exec == nil { +func (s *billingCallState) finalizeOnce(ctx context.Context, in execbackend.BillingFinalizationInput, finalizeFn func(context.Context, execbackend.BillingFinalizationInput) (lipapi.Event, error)) (lipapi.Event, bool) { + if s == nil { return lipapi.Event{}, false } key := finalizeCacheKey(in) if key == "" { - return c.callFinalizeBilling(ctx, in) - } - c.finalizeMu.Lock() - if c.finalizeByKey == nil { - c.finalizeByKey = make(map[string]*finalizeCacheEntry) - } - if entry, ok := c.finalizeByKey[key]; ok { - c.finalizeMu.Unlock() - <-entry.done - return entry.ev, entry.ok + ev, err := finalizeFn(ctx, in) + return ev, err == nil && ev.Kind == lipapi.EventUsageDelta } - entry := &finalizeCacheEntry{done: make(chan struct{})} - c.finalizeByKey[key] = entry - c.finalizeMu.Unlock() - entry.ev, entry.ok = c.callFinalizeBilling(ctx, in) - close(entry.done) - return entry.ev, entry.ok -} -func (c *billingTurnCollector) callFinalizeBilling(ctx context.Context, in execbackend.BillingFinalizationInput) (lipapi.Event, bool) { - if c == nil || c.exec == nil || c.exec.Backends == nil { - return lipapi.Event{}, false + s.finalizeMu.Lock() + if s.finalize == nil { + s.finalize = make(map[string]*finalizeCacheEntry) } - backendID := strings.TrimSpace(in.Backend) - be, ok := c.exec.Backends[backendID] - if !ok || be.FinalizeBilling == nil { - return lipapi.Event{}, false - } - persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), billingFinalizeTimeout) - defer cancel() - in.Backend = backendID - ev, err := be.FinalizeBilling(persistCtx, in) - if err != nil { - if c.exec.Log != nil { - c.exec.Log.DebugContext(persistCtx, "billing FinalizeBilling", "error", err) + entry, ok := s.finalize[key] + if ok { + s.finalizeMu.Unlock() + select { + case <-entry.done: + case <-ctx.Done(): + return lipapi.Event{}, false } - return lipapi.Event{}, false - } - if ev.Kind != lipapi.EventUsageDelta { - return lipapi.Event{}, false + return entry.ev, entry.ok } - return ev, true + + entry = &finalizeCacheEntry{done: make(chan struct{})} + s.finalize[key] = entry + s.finalizeMu.Unlock() + + defer close(entry.done) + ev, err := finalizeFn(ctx, in) + entry.ev = ev + entry.ok = err == nil && ev.Kind == lipapi.EventUsageDelta + + return entry.ev, entry.ok } -func (c *billingTurnCollector) evictFinalizeCache(aLegID string, legs []billing.LegUsageRecord) { - if c == nil { +func (s *retryRecvStream) ensureBillingCallState() { + if s == nil { return } - c.finalizeMu.Lock() - defer c.finalizeMu.Unlock() - for _, leg := range legs { - delete(c.finalizeByKey, finalizeCacheKey(execbackend.BillingFinalizationInput{ - ALegID: aLegID, BLegID: leg.BLegID, Backend: leg.BackendID, Model: leg.ModelID, - })) + if s.billingCallState == nil { + id := s.billingCallID + if id == "" { + newID, err := billing.NewBillingCallID() + if err != nil { + // Preserve an empty ID so persistence fails closed, not under a placeholder key. + s.billingCallState = newBillingCallState("") + return + } + id = newID + s.billingCallID = id + } + s.billingCallState = newBillingCallState(id) } } diff --git a/internal/core/runtime/billing_exposure_runtime_state_test.go b/internal/core/runtime/billing_exposure_runtime_state_test.go index 46e59e0b..1233c9e4 100644 --- a/internal/core/runtime/billing_exposure_runtime_state_test.go +++ b/internal/core/runtime/billing_exposure_runtime_state_test.go @@ -14,21 +14,13 @@ func (exposureRuntimeStateAdmission) Admit(context.Context, BillingExposureAdmis } func TestExposureGenerationUsesOnlyTerminalCallState(t *testing.T) { - executor := &Executor{BillingRuntime: BillingRuntime{ - BillingExposureAdmission: exposureRuntimeStateAdmission{}, - CallUsageAppender: billing.CallUsageAppenderFunc(func(context.Context, billing.CallUsageRecord) error { return nil }), - }} - - collector := executor.billingTurns() - if collector == nil { - t.Fatal("billing call state is required") - } callID, err := billing.NewBillingCallID() if err != nil { t.Fatal(err) } - collector.noteAllocatedBLeg(callID, "b-1") - got := collector.freezeAllocatedBLegs(callID) + state := newBillingCallState(callID) + state.noteAllocatedBLeg("b-1", 1) + got := state.freezeAllocatedBLegs() if len(got) != 1 || got[0] != "b-1" { t.Fatalf("frozen terminal leg set = %v", got) } diff --git a/internal/core/runtime/billing_leg.go b/internal/core/runtime/billing_leg.go index 5283b6d7..e33a111c 100644 --- a/internal/core/runtime/billing_leg.go +++ b/internal/core/runtime/billing_leg.go @@ -79,9 +79,10 @@ func (e *Executor) operatorRateRef(ctx context.Context, primary routing.Primary) } func (s *retryRecvStream) recordBillingLeg(ctx context.Context, command sdkterminal.Command) { - if s == nil || s.executor == nil || !s.executor.billingTurns().enabled() { + if s == nil || s.executor == nil || !s.executor.billingEnabled() { return } + s.ensureBillingCallState() blegID := strings.TrimSpace(s.bleg.BLegID) if blegID == "" { blegID = billingSyntheticBLegID(s.bleg.Seq) @@ -96,13 +97,15 @@ func (s *retryRecvStream) recordBillingLeg(ctx context.Context, command sdktermi } s.billingLegRecorded[blegID] = struct{}{} s.billingLegMu.Unlock() - s.executor.billingTurns().noteAllocatedBLeg(s.billingCallID, blegID) + if s.bleg.Seq > 0 { + s.billingCallState.noteAllocatedBLeg(blegID, s.bleg.Seq) + } now := s.now() started := s.accounting.requestStartedAt if started.IsZero() { started = now } - s.executor.billingTurns().noteLegTimes(s.billingCallID, started, now) + s.billingCallState.noteLegTimes(started, now) surfaced := billing.SurfacedNo if command == sdkterminal.CommandNormalFinish || s.isCommitted() { surfaced = billing.SurfacedYes @@ -121,7 +124,7 @@ func (s *retryRecvStream) recordBillingLeg(ctx context.Context, command sdktermi stream: streamEv, operatorRateRef: s.executor.operatorRateRef(ctx, s.cand.Primary), }) - s.executor.billingTurns().observe(ctx, legRecord) + s.executor.observeBillingLeg(ctx, legRecord) s.executor.appendIndependentCallLeg(ctx, s.billingCallID, legRecord) } @@ -130,13 +133,16 @@ func (s *retryRecvStream) finalizeBillingEvidence(ctx context.Context, reason st if s == nil || s.executor == nil { return fallback } - ev, ok := s.executor.billingTurns().finalizeOnce(ctx, execbackend.BillingFinalizationInput{ + s.ensureBillingCallState() + ev, ok := s.billingCallState.finalizeOnce(ctx, execbackend.BillingFinalizationInput{ TraceID: strings.TrimSpace(s.traceID), ALegID: strings.TrimSpace(s.aLegID), BLegID: strings.TrimSpace(s.bleg.BLegID), Backend: strings.TrimSpace(s.cand.Primary.Backend), Model: strings.TrimSpace(s.cand.Primary.Model), Reason: strings.TrimSpace(reason), + }, func(cctx context.Context, in execbackend.BillingFinalizationInput) (lipapi.Event, error) { + return s.executor.callFinalizeBilling(cctx, in) }) if !ok { return fallback @@ -261,7 +267,16 @@ func (e *Executor) appendIndependentCallLeg(ctx context.Context, callID billing. if e == nil || e.CallLegUsageAppender == nil { return } - independent := billing.CallLegUsageRecord{CallID: callID, ALegID: leg.ALegID, BLegID: leg.BLegID, BackendID: leg.BackendID, ProviderID: leg.ProviderID, ModelID: leg.ModelID, StartedAt: leg.StartedAt, FinishedAt: leg.FinishedAt, Outcome: leg.Outcome, Surfaced: leg.Surfaced, Evidence: leg.Evidence, OperatorRateRef: leg.OperatorRateRef} + // AttemptSeq is the authoritative B2BUA financial fact; reject unknown + // sequences rather than deriving order. Legacy NULL rows remain readable, + // but order-dependent rating fails closed. + if leg.Seq <= 0 { + if e.Log != nil { + e.Log.ErrorContext(ctx, "billing call-leg append rejected: attempt sequence missing", "error", fmt.Errorf("%w: attempt sequence for B-leg %q", billing.ErrInvalidRecord, leg.BLegID), "b_leg_id", leg.BLegID) + } + return + } + independent := billing.CallLegUsageRecord{CallID: callID, ALegID: leg.ALegID, BLegID: leg.BLegID, AttemptSeq: leg.Seq, BackendID: leg.BackendID, ProviderID: leg.ProviderID, ModelID: leg.ModelID, StartedAt: leg.StartedAt, FinishedAt: leg.FinishedAt, Outcome: leg.Outcome, Surfaced: leg.Surfaced, Evidence: leg.Evidence, OperatorRateRef: leg.OperatorRateRef} persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), billingHandoffTimeout) defer cancel() if err := e.CallLegUsageAppender.AppendCallLegUsage(persistCtx, independent); err != nil { @@ -284,7 +299,7 @@ func (e *Executor) logBillingUsageAppendFailure(ctx context.Context, criticalMsg e.Log.WarnContext(ctx, warnMsg, "error", err) } -func (e *Executor) appendIndependentTerminalLeg(ctx context.Context, callID billing.BillingCallID, aLegID string, bleg b2bua.BLegRecord, primary routing.Primary, started, finished time.Time, outcome billing.LegOutcome) { +func (e *Executor) appendIndependentTerminalLeg(ctx context.Context, state *billingCallState, aLegID string, bleg b2bua.BLegRecord, primary routing.Primary, started, finished time.Time, outcome billing.LegOutcome) { if e == nil || e.CallLegUsageAppender == nil { return } @@ -309,20 +324,21 @@ func (e *Executor) appendIndependentTerminalLeg(ctx context.Context, callID bill Evidence: billing.FinalBillingEvidence{Source: billing.EvidenceSourceUnavailable, Authority: billing.EvidenceAuthorityUnavailable}, OperatorRateRef: e.operatorRateRef(ctx, primary), } - if err := callID.Validate(); err == nil { - e.billingTurns().noteLegTimes(callID, started, finished) + var callID billing.BillingCallID + if state != nil { + callID = state.callID + state.noteLegTimes(started, finished) + } + e.observeBillingLeg(ctx, leg) + if callID != "" { + e.appendIndependentCallLeg(ctx, callID, leg) } - e.billingTurns().observe(ctx, leg) - e.appendIndependentCallLeg(ctx, callID, leg) } -func (e *Executor) appendPostOpenTerminalLeg(ctx context.Context, callID billing.BillingCallID, aLegID string, bleg b2bua.BLegRecord, primary routing.Primary, started, finished time.Time) { +func (e *Executor) appendPostOpenTerminalLeg(ctx context.Context, state *billingCallState, aLegID string, bleg b2bua.BLegRecord, primary routing.Primary, started, finished time.Time) { if e == nil || strings.TrimSpace(bleg.BLegID) == "" { return } - if err := callID.Validate(); err != nil { - return - } if started.IsZero() { started = e.now() } @@ -333,7 +349,7 @@ func (e *Executor) appendPostOpenTerminalLeg(ctx context.Context, callID billing if ctx.Err() != nil { outcome = billing.LegOutcomeCanceled } - e.appendIndependentTerminalLeg(ctx, callID, aLegID, bleg, primary, started, finished, outcome) + e.appendIndependentTerminalLeg(ctx, state, aLegID, bleg, primary, started, finished, outcome) } func (e *Executor) recordParallelBillingLeg(ctx context.Context, leg *parallelLeg, usage lipapi.Event, command sdkterminal.Command, committed bool) { @@ -341,7 +357,7 @@ func (e *Executor) recordParallelBillingLeg(ctx context.Context, leg *parallelLe return } if leg.startedAt.IsZero() { - e.appendIndependentTerminalLeg(ctx, leg.callID, leg.bleg.ALegID, leg.bleg, leg.cand.Primary, e.now(), e.now(), billing.LegOutcomeNeverStarted) + e.appendIndependentTerminalLeg(ctx, leg.billingCallState, leg.bleg.ALegID, leg.bleg, leg.cand.Primary, e.now(), e.now(), billing.LegOutcomeNeverStarted) return } surfaced := billing.SurfacedNo @@ -349,18 +365,20 @@ func (e *Executor) recordParallelBillingLeg(ctx context.Context, leg *parallelLe surfaced = billing.SurfacedYes } fallback := lastUsageDeltaOrShell([]lipapi.Event{usage}) - finalizeEv, ok := e.billingTurns().finalizeOnce(ctx, execbackend.BillingFinalizationInput{ + finalizeEv, ok := leg.billingCallState.finalizeOnce(ctx, execbackend.BillingFinalizationInput{ ALegID: strings.TrimSpace(leg.bleg.ALegID), BLegID: strings.TrimSpace(leg.bleg.BLegID), Backend: strings.TrimSpace(leg.cand.Primary.Backend), Model: strings.TrimSpace(leg.cand.Primary.Model), Reason: "parallel_loser", + }, func(cctx context.Context, in execbackend.BillingFinalizationInput) (lipapi.Event, error) { + return e.callFinalizeBilling(cctx, in) }) if !ok { finalizeEv = fallback } - if err := leg.callID.Validate(); err == nil { - e.billingTurns().noteLegTimes(leg.callID, leg.startedAt, e.now()) + if leg.billingCallState != nil { + leg.billingCallState.noteLegTimes(leg.startedAt, e.now()) } legRecord := billingLegRecord(billingLegDraft{ aLegID: leg.bleg.ALegID, @@ -375,8 +393,8 @@ func (e *Executor) recordParallelBillingLeg(ctx context.Context, leg *parallelLe stream: fallback, operatorRateRef: e.operatorRateRef(ctx, leg.cand.Primary), }) - e.billingTurns().observe(ctx, legRecord) - if err := leg.callID.Validate(); err == nil { - e.appendIndependentCallLeg(ctx, leg.callID, legRecord) + e.observeBillingLeg(ctx, legRecord) + if leg.billingCallState != nil { + e.appendIndependentCallLeg(ctx, leg.billingCallState.callID, legRecord) } } diff --git a/internal/core/runtime/billing_matrix_test.go b/internal/core/runtime/billing_matrix_test.go new file mode 100644 index 00000000..6d5f7354 --- /dev/null +++ b/internal/core/runtime/billing_matrix_test.go @@ -0,0 +1,261 @@ +package runtime + +import ( + "math/rand" + "reflect" + "testing" + "time" + + "github.com/matdev83/go-llm-interactive-proxy/internal/core/billing" +) + +func TestB2BUABillingMatrix(t *testing.T) { + t.Parallel() + + // 1. Setup pricing/policy snapshots + pricingRef := billing.VersionRef{ID: "prices", Version: "v1"} + policyRef := billing.VersionRef{ID: "policy", Version: "v1"} + + defaultPricing := billing.PricingSnapshot{ + Ref: pricingRef, + Currency: "USD", + InputPerMillionNano: 1000, + OutputPerMillionNano: 2000, + InputRatePresent: true, + OutputRatePresent: true, + } + + policySurfaced := billing.ChargePolicy{ + Ref: policyRef, + PricingRef: pricingRef, + Scope: billing.ChargeSurfacedTurn, + IncludeInputTokens: true, + IncludeOutputTokens: true, + } + + modelPricing := []billing.ModelCustomerPricing{ + { + BackendID: "back-expensive", + ModelID: "model-expensive", + Pricing: billing.PricingSnapshot{ + Ref: pricingRef, + Currency: "USD", + InputPerMillionNano: 5000, + OutputPerMillionNano: 10000, + InputRatePresent: true, + OutputRatePresent: true, + }, + }, + { + BackendID: "back-cheap", + ModelID: "model-cheap", + Pricing: billing.PricingSnapshot{ + Ref: pricingRef, + Currency: "USD", + InputPerMillionNano: 10, + OutputPerMillionNano: 20, + InputRatePresent: true, + OutputRatePresent: true, + }, + }, + { + BackendID: "back-zero", + ModelID: "model-zero", + Pricing: billing.PricingSnapshot{ + Ref: pricingRef, + Currency: "USD", + InputPerMillionNano: 0, + OutputPerMillionNano: 0, + InputRatePresent: true, + OutputRatePresent: true, + }, + }, + } + + callID, err := billing.NewBillingCallID() + if err != nil { + t.Fatal(err) + } + + // 2. Define our legs matrix + // We use opaque B-leg IDs whose lexical order is opposite the actual AttemptSeq. + // This ensures that sorting or selection logic must use AttemptSeq rather than lexical order. + legs := []billing.CallLegUsageRecord{ + { + CallID: callID, ALegID: "a-leg", BLegID: "b_f9b2d8e4", AttemptSeq: 1, + BackendID: "back-expensive", ProviderID: "prov", ModelID: "model-expensive", + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(100, 500000000).UTC(), + Outcome: billing.LegOutcomeNeverStarted, Surfaced: billing.SurfacedNo, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 1_000_000, Present: true}, OutputTokens: billing.Quantity{Value: 0, Present: true}, + }, + }, + { + CallID: callID, ALegID: "a-leg", BLegID: "b_d8c4a1b0", AttemptSeq: 2, + BackendID: "back-expensive", ProviderID: "prov", ModelID: "model-expensive", + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(100, 500000000).UTC(), + Outcome: billing.LegOutcomeFailed, Surfaced: billing.SurfacedNo, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 1_000_000, Present: true}, OutputTokens: billing.Quantity{Value: 0, Present: true}, + }, + }, + { + CallID: callID, ALegID: "a-leg", BLegID: "b_b3e5f2c1", AttemptSeq: 3, + BackendID: "back-expensive", ProviderID: "prov", ModelID: "model-expensive", + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(100, 500000000).UTC(), + Outcome: billing.LegOutcomeSwallowed, Surfaced: billing.SurfacedNo, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 1_000_000, Present: true}, OutputTokens: billing.Quantity{Value: 0, Present: true}, + }, + }, + { + CallID: callID, ALegID: "a-leg", BLegID: "b_9a2f1e8d", AttemptSeq: 4, + BackendID: "back-cheap", ProviderID: "prov", ModelID: "model-cheap", + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(100, 500000000).UTC(), + Outcome: billing.LegOutcomeLoser, Surfaced: billing.SurfacedNo, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 1_000_000, Present: true}, OutputTokens: billing.Quantity{Value: 0, Present: true}, + }, + }, + { + CallID: callID, ALegID: "a-leg", BLegID: "b_7d6c5b4a", AttemptSeq: 5, + BackendID: "back-cheap", ProviderID: "prov", ModelID: "model-cheap", + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(100, 500000000).UTC(), + Outcome: billing.LegOutcomeWinner, Surfaced: billing.SurfacedNo, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 1_000_000, Present: true}, OutputTokens: billing.Quantity{Value: 0, Present: true}, + }, + }, + { + CallID: callID, ALegID: "a-leg", BLegID: "b_5c4b3a2e", AttemptSeq: 6, + BackendID: "back-cheap", ProviderID: "prov", ModelID: "model-cheap", + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(100, 500000000).UTC(), + Outcome: billing.LegOutcomeWinner, Surfaced: billing.SurfacedYes, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 1_000_000, Present: true}, OutputTokens: billing.Quantity{Value: 0, Present: true}, + }, + }, + { + CallID: callID, ALegID: "a-leg", BLegID: "b_2a1f0e9d", AttemptSeq: 7, + BackendID: "back-zero", ProviderID: "prov", ModelID: "model-zero", + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(100, 500000000).UTC(), + Outcome: billing.LegOutcomeWinner, Surfaced: billing.SurfacedNo, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 1_000_000, Present: true}, OutputTokens: billing.Quantity{Value: 0, Present: true}, + }, + }, + } + + expectedIDs := make([]string, 0, len(legs)) + for _, l := range legs { + expectedIDs = append(expectedIDs, l.BLegID) + } + + closure := billing.CallUsageRecord{ + SchemaVersion: billing.CurrentRecordSchemaVersion, + CallID: callID, + AccountID: "acct", + ALegID: "a-leg", + SessionID: "sess", + StartedAt: time.Unix(100, 0).UTC(), + FinishedAt: time.Unix(101, 0).UTC(), + Outcome: billing.TurnOutcomeCompleted, + CustomerPricingRef: pricingRef, + ChargePolicyRef: policyRef, + ExpectedBLegIDs: expectedIDs, + } + + sealedClosure, err := closure.Seal() + if err != nil { + t.Fatal(err) + } + + sealedLegs := make([]billing.CallLegUsageRecord, 0, len(legs)) + for _, l := range legs { + sl, err := l.Seal() + if err != nil { + t.Fatal(err) + } + sealedLegs = append(sealedLegs, sl) + } + + // 3. Test JoinCompleteCall is independent of append/join order + complete1, err := billing.JoinCompleteCall(sealedClosure, sealedLegs) + if err != nil { + t.Fatalf("JoinCompleteCall basic: %v", err) + } + + // Shuffle sealedLegs + r := rand.New(rand.NewSource(42)) + shuffledLegs := append([]billing.CallLegUsageRecord(nil), sealedLegs...) + r.Shuffle(len(shuffledLegs), func(i, j int) { + shuffledLegs[i], shuffledLegs[j] = shuffledLegs[j], shuffledLegs[i] + }) + + complete2, err := billing.JoinCompleteCall(sealedClosure, shuffledLegs) + if err != nil { + t.Fatalf("JoinCompleteCall shuffled: %v", err) + } + + // Proving append/join order is irrelevant + if !reflect.DeepEqual(complete1.Legs, complete2.Legs) { + t.Fatalf("JoinCompleteCall results differ on legs order:\n1: %+v\n2: %+v", complete1.Legs, complete2.Legs) + } + + // 4. Rate complete call using ChargeSurfacedTurn policy: + // Only surfaced winner legs (Seq 6, "b_5c4b3a2e") should be charged. + // Model price for back-cheap/model-cheap is 10/million. + // 1,000,000 input tokens * 10/million = 10 nano units. + ratingInput := billing.CallRatingInput{ + Call: sealedClosure, + Legs: sealedLegs, + MaxCustomerCharge: billing.Money{Nano: 100000, Currency: "USD"}, + CustomerPricing: defaultPricing, + CustomerPolicy: policySurfaced, + ModelPricing: modelPricing, + } + result, err := billing.RateCall(ratingInput) + if err != nil { + t.Fatalf("RateCall surfaced policy failed: %v", err) + } + + if result.CustomerCharge.Nano != 10 { + t.Fatalf("expected CustomerCharge to be 10, got %d", result.CustomerCharge.Nano) + } + + // 5. Rate using ChargeAllPotentialLegs policy: + // All potential billable legs should be sum-charged. + // Billable legs are all accepted legs (winner/loser/failed/canceled/never-started with evidence, etc.): + // Let's see: + // - Seq 1: LegOutcomeNeverStarted (cost 0, but input tokens 1,000,000 present, backend back-expensive -> 5000) + // - Seq 2: LegOutcomeFailed (input tokens 1,000,000 present, backend back-expensive -> 5000) + // - Seq 3: LegOutcomeSwallowed (input tokens 1,000,000 present, backend back-expensive -> 5000) + // - Seq 4: LegOutcomeLoser (input tokens 1,000,000 present, backend back-cheap -> 10) + // - Seq 5: LegOutcomeWinner (input tokens 1,000,000 present, backend back-cheap -> 10) + // - Seq 6: LegOutcomeWinner (input tokens 1,000,000 present, backend back-cheap -> 10) + // - Seq 7: LegOutcomeWinner (input tokens 1,000,000 present, backend back-zero -> 0) + // Total expected charge = 5000 + 5000 + 5000 + 10 + 10 + 10 + 0 = 15030 nano units. + policyChargeAll := billing.ChargePolicy{ + Ref: policyRef, + PricingRef: pricingRef, + Scope: billing.ChargeAllPotentialLegs, + IncludeInputTokens: true, + IncludeOutputTokens: true, + } + ratingInputChargeAll := billing.CallRatingInput{ + Call: sealedClosure, + Legs: sealedLegs, + MaxCustomerCharge: billing.Money{Nano: 100000, Currency: "USD"}, + CustomerPricing: defaultPricing, + CustomerPolicy: policyChargeAll, + ModelPricing: modelPricing, + } + resultChargeAll, err := billing.RateCall(ratingInputChargeAll) + if err != nil { + t.Fatalf("RateCall charge-all policy failed: %v", err) + } + + if resultChargeAll.CustomerCharge.Nano != 15030 { + t.Fatalf("expected CustomerCharge for charge-all to be 15030, got %d", resultChargeAll.CustomerCharge.Nano) + } +} diff --git a/internal/core/runtime/billing_post_output_failure_test.go b/internal/core/runtime/billing_post_output_failure_test.go new file mode 100644 index 00000000..0ca68690 --- /dev/null +++ b/internal/core/runtime/billing_post_output_failure_test.go @@ -0,0 +1,122 @@ +package runtime + +import ( + "context" + "errors" + "sync/atomic" + "testing" + + "github.com/matdev83/go-llm-interactive-proxy/internal/core/b2bua" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/billing" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/execbackend" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/hooks" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/routing" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipapi" +) + +type mockFailingAppender struct { + callAppends atomic.Int32 + legAppends atomic.Int32 +} + +func (m *mockFailingAppender) AppendCallUsage(ctx context.Context, record billing.CallUsageRecord) error { + m.callAppends.Add(1) + return errors.New("failing call usage append") +} + +func (m *mockFailingAppender) AppendCallLegUsage(ctx context.Context, record billing.CallLegUsageRecord) error { + m.legAppends.Add(1) + return errors.New("failing leg usage append") +} + +func TestBillingAppendRetryOutputPersistenceFailureAfterSuccess(t *testing.T) { + t.Parallel() + st, err := b2bua.NewMemoryStore(b2bua.MemoryStoreOptions{}) + if err != nil { + t.Fatal(err) + } + + var opens int32 + ex := TestExecutor() + ex.Store = st + ex.Bus = hooks.New(hooks.Config{}) + ex.Rand = routing.NewSeededRng(3) + + // Set up the failing appenders + appender := &mockFailingAppender{} + ex.CallUsageAppender = appender + ex.CallLegUsageAppender = appender + + // Setup Billing Identity + ex.BillingIdentity = BillingIdentity{ + AccountID: func(context.Context, lipapi.Call) string { return "acct-test" }, + CustomerPricingRef: func(context.Context, lipapi.Call) billing.VersionRef { + return billing.VersionRef{ID: "pricing:test", Version: "1"} + }, + ChargePolicyRef: func(context.Context, lipapi.Call) billing.VersionRef { + return billing.VersionRef{ID: "policy:test", Version: "1"} + }, + OperatorRateRef: func(context.Context, string, string) billing.VersionRef { + return billing.VersionRef{ID: "operator:test", Version: "1"} + }, + } + ex.BillingAuthoritative = true + ex.BillingCreditGate = creditGateFunc(func(context.Context, string) error { return nil }) + ex.BillingExposureAdmission = exposureAdmissionFunc(func(_ context.Context, in BillingExposureAdmissionInput) (billing.CallExposure, error) { + return billing.CallExposure{ + AccountID: "acct-test", + CallID: in.CallID, + PricingRef: billing.VersionRef{ID: "pricing:test", Version: "1"}, + ChargePolicyRef: billing.VersionRef{ID: "policy:test", Version: "1"}, + Status: billing.ExposureOpen, + }, nil + }) + + ex.Backends = map[string]execbackend.Backend{ + "openai": { + Caps: lipapi.NewBackendCaps(lipapi.CapabilityStreaming), + Open: func(ctx context.Context, call lipapi.Call, cand routing.AttemptCandidate) (lipapi.ManagedEventStream, error) { + atomic.AddInt32(&opens, 1) + return lipapi.NewFixedEventStream([]lipapi.Event{ + {Kind: lipapi.EventResponseStarted}, + {Kind: lipapi.EventMessageStarted}, + {Kind: lipapi.EventTextDelta, Delta: "ok"}, + {Kind: lipapi.EventResponseFinished}, + }), nil + }, + }, + } + + call := &lipapi.Call{ + Route: lipapi.RouteIntent{Selector: "openai:gpt-4"}, + Messages: []lipapi.Message{{ + Role: lipapi.RoleUser, + Parts: []lipapi.Part{lipapi.TextPart("hi")}, + }}, + } + + stream, err := ex.Execute(context.Background(), call) + if err != nil { + t.Fatal(err) + } + + col, err := lipapi.Collect(context.Background(), stream) + // Output/client-visible stream must succeed despite database append failures + if err != nil { + t.Fatalf("Collect failed: %v", err) + } + if col.Text.String() != "ok" { + t.Fatalf("text: %q, want 'ok'", col.Text.String()) + } + + // Verify that only 1 backend open happened (no retry/failover triggered by database failure) + if got := atomic.LoadInt32(&opens); got != 1 { + t.Fatalf("backend opens = %d, want 1 (no retry/failover occurred)", got) + } + if got := appender.callAppends.Load(); got == 0 { + t.Fatal("AppendCallUsage was not attempted") + } + if got := appender.legAppends.Load(); got == 0 { + t.Fatal("AppendCallLegUsage was not attempted") + } +} diff --git a/internal/core/runtime/billing_sequence_test.go b/internal/core/runtime/billing_sequence_test.go new file mode 100644 index 00000000..26a10b44 --- /dev/null +++ b/internal/core/runtime/billing_sequence_test.go @@ -0,0 +1,159 @@ +package runtime + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/matdev83/go-llm-interactive-proxy/internal/core/b2bua" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/billing" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/routing" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipapi" + sdkterminal "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/terminal" +) + +// TestAppendIndependentCallLegRejectsMissingAttemptSequence proves the +// independent call-leg seam refuses to persist a leg whose B2BUA sequence was +// lost (Seq <= 0). No new record may be created with an unknown sequence under +// the v2 contract; legacy NULL-sequence rows remain store-side v1 reads. +func TestAppendIndependentCallLegRejectsMissingAttemptSequence(t *testing.T) { + var mu sync.Mutex + appended := 0 + executor := &Executor{BillingRuntime: BillingRuntime{ + CallLegUsageAppender: billing.CallLegUsageAppenderFunc(func(_ context.Context, record billing.CallLegUsageRecord) error { + mu.Lock() + appended++ + mu.Unlock() + return nil + }), + }} + callID, err := billing.NewBillingCallID() + if err != nil { + t.Fatal(err) + } + leg := billing.LegUsageRecord{ + ALegID: "a-1", BLegID: "b-no-seq", Seq: 0, + BackendID: "backend", ProviderID: "provider", ModelID: "model", + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(101, 0).UTC(), + Outcome: billing.LegOutcomeFailed, Surfaced: billing.SurfacedNo, + } + executor.appendIndependentCallLeg(context.Background(), callID, leg) + mu.Lock() + got := appended + mu.Unlock() + if got != 0 { + t.Fatalf("appends with missing attempt sequence = %d, want 0 (fail closed)", got) + } + + leg.Seq = 7 + executor.appendIndependentCallLeg(context.Background(), callID, leg) + mu.Lock() + got = appended + mu.Unlock() + if got != 1 { + t.Fatalf("appends with known sequence = %d, want 1", got) + } +} + +// TestExecutorBillingLegProducersCarryExactB2BUASequence drives every terminal +// leg producer seam and proves the exact b2bua.BLegRecord.Seq reaches the +// durable CallLegUsageRecord.AttemptSeq untouched: opened winner, never-started, +// failed-open/canceled, parallel loser, and swallowed producers all funnel +// through the independent append seam. +func TestExecutorBillingLegProducersCarryExactB2BUASequence(t *testing.T) { + var mu sync.Mutex + seqByBLeg := map[string]int{} + executor := &Executor{BillingRuntime: BillingRuntime{ + CallLegUsageAppender: billing.CallLegUsageAppenderFunc(func(_ context.Context, record billing.CallLegUsageRecord) error { + sealed, err := record.Seal() + if err != nil { + return err + } + if sealed.AttemptSeq <= 0 { + t.Errorf("producer %q appended non-positive AttemptSeq %d", sealed.BLegID, sealed.AttemptSeq) + } + mu.Lock() + seqByBLeg[sealed.BLegID] = sealed.AttemptSeq + mu.Unlock() + return nil + }), + }} + now := time.Unix(200, 0).UTC() + primary := func(backend, model string) routing.Primary { + return routing.Primary{Backend: backend, Model: model} + } + callID, err := billing.NewBillingCallID() + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + state := newBillingCallState(callID) + + // 1. Never-started producer (open never began; terminal record still uses + // the allocated sequence). + executor.appendIndependentTerminalLeg(ctx, state, "a-1", + b2bua.BLegRecord{BLegID: "b_b3e5f2c1", ALegID: "a-1", Seq: 3}, + primary("backend", "model"), now, now, billing.LegOutcomeNeverStarted) + + // 2. Open failure / canceled producer. + executor.appendPostOpenTerminalLeg(ctx, state, "a-1", + b2bua.BLegRecord{BLegID: "b_9a2f1e8d", ALegID: "a-1", Seq: 4}, + primary("backend", "model"), now, now) + + // 3. Opened winner producer (recordBillingLeg path). + stream := &retryRecvStream{ + executor: executor, + aLegID: "a-1", + billingCallID: callID, + billingCallState: state, + bleg: b2bua.BLegRecord{BLegID: "b_7d6c5b4a", ALegID: "a-1", Seq: 5}, + cand: routing.AttemptCandidate{Primary: primary("backend", "model")}, + } + stream.recordBillingLeg(ctx, sdkterminal.CommandNormalFinish) + + // 4. Parallel loser producer (also covers the parallel winner path: the + // same reporting seam runs for both with distinct allocated sequences). + parallel := ¶llelLeg{ + billingCallState: state, + bleg: b2bua.BLegRecord{BLegID: "b_5c4b3a2e", ALegID: "a-1", Seq: 6}, + cand: routing.AttemptCandidate{Primary: primary("backend", "model")}, + startedAt: now, + } + executor.recordParallelBillingLeg(ctx, parallel, lipapi.Event{}, sdkterminal.CommandParallelLoser, false) + + // 5. Swallowed producer (recordBillingLeg with the swallowed command uses + // the attempt's own sequence). + swallowed := &retryRecvStream{ + executor: executor, + aLegID: "a-1", + billingCallID: callID, + billingCallState: state, + bleg: b2bua.BLegRecord{BLegID: "b_2a1f0e9d", ALegID: "a-1", Seq: 7}, + cand: routing.AttemptCandidate{Primary: primary("backend", "model")}, + } + swallowed.recordBillingLeg(ctx, sdkterminal.CommandSwallowedAttempt) + + mu.Lock() + got := map[string]int{} + for k, v := range seqByBLeg { + got[k] = v + } + mu.Unlock() + + want := map[string]int{ + "b_b3e5f2c1": 3, + "b_9a2f1e8d": 4, + "b_7d6c5b4a": 5, + "b_5c4b3a2e": 6, + "b_2a1f0e9d": 7, + } + for bLegID, wantSeq := range want { + if got[bLegID] != wantSeq { + t.Errorf("B-leg %q AttemptSeq = %d, want exact b2bua sequence %d (all=%v)", bLegID, got[bLegID], wantSeq, got) + } + } + if len(got) != len(want) { + t.Fatalf("producer legs = %v, want %v", got, want) + } +} diff --git a/internal/core/runtime/executor.go b/internal/core/runtime/executor.go index c47a432a..429a9df3 100644 --- a/internal/core/runtime/executor.go +++ b/internal/core/runtime/executor.go @@ -45,8 +45,6 @@ type Executor struct { rngOnce sync.Once lockedRand routing.Rng secureSessionMu sync.Mutex - billingOnce sync.Once - billingColl *billingTurnCollector // quarantinePersistenceFault is intentional process-wide fail-closed state after a // secret-guard quarantine write (or SessionID invariant) failure. While latched, // AssertActive-before-open denies further backend dispatch on this executor until @@ -145,7 +143,7 @@ func (e *Executor) Execute(ctx context.Context, call *lipapi.Call) (_ lipapi.Eve // stream to a terminal recorder. Emit the Failed leg before freezing // ExpectedBLegIDs on the abort closure so the post-usage join can complete. if out.opened && strings.TrimSpace(out.bleg.BLegID) != "" { - e.appendPostOpenTerminalLeg(prepCtx, prep.billingCallID, prep.aLeg.ALegID, out.bleg, out.cand.Primary, time.Time{}, time.Time{}) + e.appendPostOpenTerminalLeg(prepCtx, prep.billingCallState, prep.aLeg.ALegID, out.bleg, out.cand.Primary, time.Time{}, time.Time{}) } e.appendExposureAbortAfterAdmission(prepCtx, prep, plan) return nil, err diff --git a/internal/core/runtime/executor_assemble_stream.go b/internal/core/runtime/executor_assemble_stream.go index 8eca5a8f..c6e641ba 100644 --- a/internal/core/runtime/executor_assemble_stream.go +++ b/internal/core/runtime/executor_assemble_stream.go @@ -47,6 +47,7 @@ func (a streamAssembler) assemble(ctx context.Context, prep *preparedRequest, pl billingChargePolicy: prep.billingChargePolicy, billingIdentityStamped: prep.billingIdentityStamped, billingCallID: prep.billingCallID, + billingCallState: prep.billingCallState, customer: newCustomerEvidenceAccumulator(), accounting: newAttemptAccountingTracker(e.now()), recoverPolicy: streamrecovery.NewPolicy(e.StreamRecovery, e.now()), diff --git a/internal/core/runtime/executor_open_attempt.go b/internal/core/runtime/executor_open_attempt.go index 663cc3d5..88043136 100644 --- a/internal/core/runtime/executor_open_attempt.go +++ b/internal/core/runtime/executor_open_attempt.go @@ -81,7 +81,8 @@ type attemptOpenParams struct { deferMemoInjectionCommit bool // billingCallID is the incoming invocation identity. NextBLeg notes allocated // B-leg IDs against it so call-closure expected sets freeze at request terminal. - billingCallID billing.BillingCallID + billingCallID billing.BillingCallID + billingCallState *billingCallState } type attemptOpenResult struct { @@ -513,11 +514,11 @@ func (e *Executor) openPlannedCandidate( if ctx.Err() != nil { outcome = billing.LegOutcomeCanceled } - e.appendIndependentTerminalLeg(ctx, p.billingCallID, p.aLegID, bleg, c.Primary, started, finished, outcome) + e.appendIndependentTerminalLeg(ctx, p.billingCallState, p.aLegID, bleg, c.Primary, started, finished, outcome) } }() - if e.CallUsageAppender != nil { - e.billingTurns().noteAllocatedBLeg(p.billingCallID, bleg.BLegID) + if e.CallUsageAppender != nil && bleg.Seq > 0 { + p.billingCallState.noteAllocatedBLeg(bleg.BLegID, bleg.Seq) } if err := e.enforcePostAdmitClamps(ctx, &openCall, authorizedFreeze, previewedClamps, previewRan, authState, c, int64(admitDecision.Count.InputTokens)); err != nil { releaseKind = authorityapp.ReleaseKindAdmissionFailure diff --git a/internal/core/runtime/executor_open_loop.go b/internal/core/runtime/executor_open_loop.go index 41a71f25..e1c32518 100644 --- a/internal/core/runtime/executor_open_loop.go +++ b/internal/core/runtime/executor_open_loop.go @@ -50,6 +50,7 @@ func (o attemptOpenOwner) openInitial(ctx context.Context, prep *preparedRequest transformExcludes: &plan.transformExcludes, interleaved: plan.interleaved, billingCallID: prep.billingCallID, + billingCallState: prep.billingCallState, }) if err != nil { return attemptOpenResult{}, fmt.Errorf("executor: plan or open attempt: %w", err) @@ -74,7 +75,7 @@ func (o attemptOpenOwner) openInitial(ctx context.Context, prep *preparedRequest l.finalizeIncurredOrRelease(ctx, authorityapp.ReleaseKindSwallowed, emptyOperatorUsageShell()) // Open succeeded and NextBLeg was noted, but no terminal recorder owns // the stream. Emit Failed usage before Execute's abort closure freezes. - e.appendPostOpenTerminalLeg(ctx, prep.billingCallID, prep.aLeg.ALegID, out.bleg, out.cand.Primary, time.Time{}, time.Time{}) + e.appendPostOpenTerminalLeg(ctx, prep.billingCallState, prep.aLeg.ALegID, out.bleg, out.cand.Primary, time.Time{}, time.Time{}) return attemptOpenResult{}, err } } diff --git a/internal/core/runtime/executor_prepare_request.go b/internal/core/runtime/executor_prepare_request.go index 471095fd..2fac1297 100644 --- a/internal/core/runtime/executor_prepare_request.go +++ b/internal/core/runtime/executor_prepare_request.go @@ -43,9 +43,11 @@ type preparedRequest struct { // billingCallID is allocated once per incoming invocation and shared by // that request's retries, failover alternatives, and parallel B-legs. billingCallID billing.BillingCallID - execSpan trace.Span - metering *checkpoint.RequestHolder - routeAuth routeAuthoritySnapshot + // billingCallState is the private request/BillingCallID-scoped state object. + billingCallState *billingCallState + execSpan trace.Span + metering *checkpoint.RequestHolder + routeAuth routeAuthoritySnapshot } // prepareRequest executes phases 1-9 of the former inline [Executor.Execute]: diff --git a/internal/core/runtime/executor_recv_loop.go b/internal/core/runtime/executor_recv_loop.go index 42b7a9e1..3fd9b205 100644 --- a/internal/core/runtime/executor_recv_loop.go +++ b/internal/core/runtime/executor_recv_loop.go @@ -321,6 +321,7 @@ func (s *retryRecvStream) tryReplacementIteration(ctx context.Context) (opened b suppressVisibleMemo: s.suppressVisibleMemo, lastParallelFailure: &s.lastParallelFailure, billingCallID: s.billingCallID, + billingCallState: s.billingCallState, }) if err != nil { return false, err @@ -347,7 +348,7 @@ func (s *retryRecvStream) tryReplacementIteration(ctx context.Context) (opened b l.finalizeIncurredOrRelease(cctx, authorityapp.ReleaseKindSwallowed, emptyOperatorUsageShell()) return nil }) - s.executor.appendPostOpenTerminalLeg(ctx, s.billingCallID, s.aLegID, out.bleg, out.cand.Primary, time.Time{}, time.Time{}) + s.executor.appendPostOpenTerminalLeg(ctx, s.billingCallState, s.aLegID, out.bleg, out.cand.Primary, time.Time{}, time.Time{}) return false, err } } diff --git a/internal/core/runtime/executor_retry_stream.go b/internal/core/runtime/executor_retry_stream.go index 6d7c1de3..d5013372 100644 --- a/internal/core/runtime/executor_retry_stream.go +++ b/internal/core/runtime/executor_retry_stream.go @@ -191,6 +191,7 @@ type retryRecvStream struct { billingChargePolicy billing.VersionRef billingIdentityStamped bool billingCallID billing.BillingCallID + billingCallState *billingCallState isInterleavedThinker bool finalStreamObs *extensions.FinalStreamObservationSession diff --git a/internal/core/runtime/executor_settlement.go b/internal/core/runtime/executor_settlement.go index 3c696a6d..9ab04a78 100644 --- a/internal/core/runtime/executor_settlement.go +++ b/internal/core/runtime/executor_settlement.go @@ -88,13 +88,16 @@ func (s *retryRecvStream) finalizeBillingAfterCancel(ctx context.Context, reason if s == nil || s.executor == nil { return false } - ev, ok := s.executor.billingTurns().finalizeOnce(ctx, execbackend.BillingFinalizationInput{ + s.ensureBillingCallState() + ev, ok := s.billingCallState.finalizeOnce(ctx, execbackend.BillingFinalizationInput{ TraceID: strings.TrimSpace(s.traceID), ALegID: strings.TrimSpace(s.aLegID), BLegID: strings.TrimSpace(s.bleg.BLegID), Backend: strings.TrimSpace(s.cand.Primary.Backend), Model: strings.TrimSpace(s.cand.Primary.Model), Reason: strings.TrimSpace(reason), + }, func(cctx context.Context, in execbackend.BillingFinalizationInput) (lipapi.Event, error) { + return s.executor.callFinalizeBilling(cctx, in) }) if !ok { return false diff --git a/internal/core/runtime/interleaved_open.go b/internal/core/runtime/interleaved_open.go index 7743d397..98645325 100644 --- a/internal/core/runtime/interleaved_open.go +++ b/internal/core/runtime/interleaved_open.go @@ -223,6 +223,7 @@ func (e *Executor) openInterleavedExecutorContinuation(ctx context.Context, from suppressThinker: true, suppressVisibleMemo: true, billingCallID: from.billingCallID, + billingCallState: from.billingCallState, }) if err != nil { return nil, fmt.Errorf("executor: interleaved continuation plan/open: %w", err) @@ -245,7 +246,7 @@ func (e *Executor) openInterleavedExecutorContinuation(ctx context.Context, from if out.stream != nil && !errors.Is(err, leglifecycle.ErrALegCanceled) { _ = out.stream.Close() } - e.appendPostOpenTerminalLeg(ctx, from.billingCallID, from.aLegID, out.bleg, out.cand.Primary, time.Time{}, time.Time{}) + e.appendPostOpenTerminalLeg(ctx, from.billingCallState, from.aLegID, out.bleg, out.cand.Primary, time.Time{}, time.Time{}) return nil, err } } @@ -280,6 +281,7 @@ func (e *Executor) openInterleavedExecutorContinuation(ctx context.Context, from bleg: out.bleg, cand: out.cand, billingCallID: from.billingCallID, + billingCallState: from.billingCallState, billingAccountID: from.billingAccountID, billingCustomerPricing: from.billingCustomerPricing, billingChargePolicy: from.billingChargePolicy, diff --git a/internal/core/runtime/interleaved_stream.go b/internal/core/runtime/interleaved_stream.go index 04529788..0d3b7363 100644 --- a/internal/core/runtime/interleaved_stream.go +++ b/internal/core/runtime/interleaved_stream.go @@ -446,7 +446,7 @@ func (s *interleavedContinuationStream) abortExecutorHandoff(ctx context.Context if started.IsZero() { started = exec.now() } - exec.executor.appendIndependentTerminalLeg(cleanupCtx, exec.billingCallID, exec.aLegID, exec.bleg, exec.cand.Primary, started, exec.now(), billing.LegOutcomeCanceled) + exec.executor.appendIndependentTerminalLeg(cleanupCtx, exec.billingCallState, exec.aLegID, exec.bleg, exec.cand.Primary, started, exec.now(), billing.LegOutcomeCanceled) exec.markFinished() } diff --git a/internal/core/runtime/parallel_race.go b/internal/core/runtime/parallel_race.go index 1a2b9ecc..9f026b51 100644 --- a/internal/core/runtime/parallel_race.go +++ b/internal/core/runtime/parallel_race.go @@ -14,7 +14,6 @@ import ( "time" "github.com/matdev83/go-llm-interactive-proxy/internal/core/b2bua" - "github.com/matdev83/go-llm-interactive-proxy/internal/core/billing" "github.com/matdev83/go-llm-interactive-proxy/internal/core/diag" "github.com/matdev83/go-llm-interactive-proxy/internal/core/interleavedstate" "github.com/matdev83/go-llm-interactive-proxy/internal/core/interleavedthinking" @@ -39,12 +38,12 @@ func (e *Executor) logParallelRacePanic(ctx context.Context, pe *safety.PanicErr } type parallelLeg struct { - callID billing.BillingCallID - cand routing.AttemptCandidate - bleg b2bua.BLegRecord - stream lipapi.ManagedEventStream - authority authorityLifecycle - delay time.Duration + billingCallState *billingCallState + cand routing.AttemptCandidate + bleg b2bua.BLegRecord + stream lipapi.ManagedEventStream + authority authorityLifecycle + delay time.Duration // startedAt is when the leg successfully opened a backend stream. Zero means // the open time is unknown (do not fabricate identical start/finish). startedAt time.Time @@ -174,7 +173,7 @@ func (e *Executor) tryOpenParallelGroup( } for i, entry := range entries { - legs[i] = parallelLeg{callID: p.billingCallID, cand: entry.cand, delay: entry.startDelay} + legs[i] = parallelLeg{billingCallState: p.billingCallState, cand: entry.cand, delay: entry.startDelay} } for idx, entry := range entries { @@ -281,7 +280,7 @@ func (e *Executor) tryOpenParallelGroup( // RegisterBLeg failed after Open, so this B-leg never enters legs[idx] // and releaseLosers will not record it. Emit Failed terminal usage now // so a later call-closure freeze remains joinable. - e.appendPostOpenTerminalLeg(ctx, p.billingCallID, p.aLegID, out.bleg, entry.cand.Primary, time.Time{}, time.Time{}) + e.appendPostOpenTerminalLeg(ctx, p.billingCallState, p.aLegID, out.bleg, entry.cand.Primary, time.Time{}, time.Time{}) return } } diff --git a/internal/core/runtime/phase0_red_test.go b/internal/core/runtime/phase0_red_test.go new file mode 100644 index 00000000..47c33a4a --- /dev/null +++ b/internal/core/runtime/phase0_red_test.go @@ -0,0 +1,30 @@ +package runtime + +import ( + "reflect" + "testing" +) + +func TestExecutor_BillingCollectorStateGrowth(t *testing.T) { + // Task 3.4: Adapt the Phase 0 retention test to assert executor does not retain + // completed-call state without relying on deleted implementation names. + // We verify that the Executor struct itself does not retain any maps or registries + // that could grow with the number of calls, as state is now request-scoped. + t.Parallel() + + executor := &Executor{} + val := reflect.ValueOf(executor).Elem() + typ := val.Type() + + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + // Check for any map fields directly on Executor + if field.Type.Kind() == reflect.Map { + t.Errorf("Forbidden map field found on Executor: %s", field.Name) + } + // Assert that billingColl or billingOnce no longer exist + if field.Name == "billingColl" || field.Name == "billingOnce" { + t.Errorf("Obsolete billing field found on Executor: %s", field.Name) + } + } +} diff --git a/internal/infra/billingcompose/catalog.go b/internal/infra/billingcompose/catalog.go index 6cf526df..18567932 100644 --- a/internal/infra/billingcompose/catalog.go +++ b/internal/infra/billingcompose/catalog.go @@ -194,41 +194,48 @@ func (c *SnapshotCatalog) SetOperatorRateBinding(backend, model string, ref bill return nil } -func (c *SnapshotCatalog) SnapshotsFor(record billing.TurnUsageRecord) (pricing billing.PricingSnapshot, policy billing.ChargePolicy, rates []billing.OperatorRateSnapshot, modelPricing []billing.ModelCustomerPricing, err error) { +// CustomerRatingSnapshots is the complete immutable input set customer rating +// resolves for one call. It deliberately carries no OperatorRateSnapshot +// values: customer settlement must never depend on provider-cost readiness. +type CustomerRatingSnapshots struct { + DefaultPricing billing.PricingSnapshot + Policy billing.ChargePolicy + ModelPricing []billing.ModelCustomerPricing +} + +// CustomerRatingSnapshots resolves customer pricing/policy/model cards only. +// It never looks up, validates, or loads operator-rate snapshots, so missing, +// invalid, stale, or unreconciled provider-cost data cannot block customer +// settlement or leave operational exposure open (requirements 5.1-5.6). +// +// The model cards mirror what admission quotes: a route/model override binds a +// versioned immutable pricing body; a route without an override keeps the +// configured default card. When any override exists for the call and an +// override body is missing this fails closed rather than substituting an +// unrelated model price. +func (c *SnapshotCatalog) CustomerRatingSnapshots(call billing.CallUsageRecord, legs []billing.CallLegUsageRecord) (CustomerRatingSnapshots, error) { if c == nil { - return billing.PricingSnapshot{}, billing.ChargePolicy{}, nil, nil, errNilSnapshotCatalog + return CustomerRatingSnapshots{}, errNilSnapshotCatalog } c.mu.RLock() defer c.mu.RUnlock() - pricing, ok := c.pricing[keyOf(record.CustomerPricingRef)] + pricing, ok := c.pricing[keyOf(call.CustomerPricingRef)] if !ok { - return billing.PricingSnapshot{}, billing.ChargePolicy{}, nil, nil, lookupMiss("customer pricing") + return CustomerRatingSnapshots{}, lookupMiss("customer pricing") } - policy, ok = c.policies[keyOf(record.ChargePolicyRef)] + policy, ok := c.policies[keyOf(call.ChargePolicyRef)] if !ok { - return billing.PricingSnapshot{}, billing.ChargePolicy{}, nil, nil, lookupMiss("charge policy") + return CustomerRatingSnapshots{}, lookupMiss("charge policy") } - seenRates := make(map[versionKey]struct{}) - for _, leg := range record.Legs { - if emptyRef(leg.OperatorRateRef) { - continue - } - rk := keyOf(leg.OperatorRateRef) - if _, seen := seenRates[rk]; seen { - continue - } - rate, found := c.operatorRates[rk] - if !found { - return billing.PricingSnapshot{}, billing.ChargePolicy{}, nil, nil, lookupMiss("operator rate") - } - seenRates[rk] = struct{}{} - rates = append(rates, rate) - } - modelPricing, err = c.modelPricingForRecord(record, pricing) + modelPricing, err := c.modelPricingForLegs(legs, pricing) if err != nil { - return billing.PricingSnapshot{}, billing.ChargePolicy{}, nil, nil, err + return CustomerRatingSnapshots{}, err } - return clonePricing(pricing), policy, rates, modelPricing, nil + return CustomerRatingSnapshots{ + DefaultPricing: clonePricing(pricing), + Policy: policy, + ModelPricing: modelPricing, + }, nil } func (c *SnapshotCatalog) RoutePricing(ctx context.Context, backend, model string) (billing.PricingSnapshot, error) { @@ -329,9 +336,15 @@ func (c *SnapshotCatalog) OperatorRateRef(_ context.Context, backend, model stri return billing.VersionRef{} } -func (c *SnapshotCatalog) modelPricingForRecord(record billing.TurnUsageRecord, customer billing.PricingSnapshot) ([]billing.ModelCustomerPricing, error) { +// modelPricingForLegs builds the effective per backend/model customer pricing +// cards for the legs of one call. Cards are emitted only when at least one +// route/model override exists for the call; a route without an override keeps +// the customer default pricing (requirement 4.4). An override binding whose +// immutable body is missing fails closed (requirement 4.5) instead of rating +// with an unrelated model price. +func (c *SnapshotCatalog) modelPricingForLegs(legs []billing.CallLegUsageRecord, customer billing.PricingSnapshot) ([]billing.ModelCustomerPricing, error) { anyOverride := false - for _, leg := range record.Legs { + for _, leg := range legs { if _, found := c.routePricing[routeOf(leg.BackendID, leg.ModelID)]; found { anyOverride = true break @@ -342,7 +355,7 @@ func (c *SnapshotCatalog) modelPricingForRecord(record billing.TurnUsageRecord, } var cards []billing.ModelCustomerPricing seenRoutes := make(map[routeKey]struct{}) - for _, leg := range record.Legs { + for _, leg := range legs { rk := routeOf(leg.BackendID, leg.ModelID) if _, seen := seenRoutes[rk]; seen { continue @@ -401,10 +414,6 @@ func parseRoute(backend, model string) (routeKey, error) { return rk, nil } -func emptyRef(ref billing.VersionRef) bool { - return strings.TrimSpace(ref.ID) == "" && strings.TrimSpace(ref.Version) == "" -} - func clonePricing(p billing.PricingSnapshot) billing.PricingSnapshot { out := p if p.FixedCharges != nil { diff --git a/internal/infra/billingcompose/catalog_test.go b/internal/infra/billingcompose/catalog_test.go index e199aa0e..f49069b6 100644 --- a/internal/infra/billingcompose/catalog_test.go +++ b/internal/infra/billingcompose/catalog_test.go @@ -88,14 +88,18 @@ func TestSnapshotCatalog_PutRejectsMutationAllowsReplay(t *testing.T) { if err := c.SetDefaults(pricing.Ref, policy.Ref); err != nil { t.Fatalf("SetDefaults: %v", err) } - gotPricing, gotPolicy, gotRates, _, err := c.SnapshotsFor(catalogRecord(pricing.Ref, policy.Ref, catalogLeg("backend", "model", rates.Ref))) + got, err := c.CustomerRatingSnapshots(catalogCustomerCall(pricing.Ref, policy.Ref, catalogCustomerLeg("backend", "model"))) if err != nil { - t.Fatalf("SnapshotsFor after rejected mutation: %v", err) + t.Fatalf("CustomerRatingSnapshots after rejected mutation: %v", err) } - assertPricingEqual(t, gotPricing, pricing) - assertPolicyEqual(t, gotPolicy, policy) - if len(gotRates) != 1 || !operatorRateEqual(gotRates[0], rates) { - t.Fatalf("operator rates = %+v, want original %+v", gotRates, rates) + assertPricingEqual(t, got.DefaultPricing, pricing) + assertPolicyEqual(t, got.Policy, policy) + gotRate, err := c.OperatorRate(rates.Ref) + if err != nil { + t.Fatalf("OperatorRate: %v", err) + } + if !operatorRateEqual(gotRate, rates) { + t.Fatalf("operator rate = %+v, want original %+v", gotRate, rates) } }) } @@ -117,21 +121,20 @@ func TestSnapshotCatalog_PutSameIdentityDifferentFetchedAtIsReplay(t *testing.T) t.Fatalf("timestamp-only replay: %v", err) } policy := catalogPolicy() - rates := catalogOperatorRate() if err := c.PutPolicy(policy); err != nil { t.Fatal(err) } - if err := c.PutOperatorRate(rates); err != nil { + if err := c.PutOperatorRate(catalogOperatorRate()); err != nil { t.Fatal(err) } if err := c.SetDefaults(pricing.Ref, policy.Ref); err != nil { t.Fatal(err) } - got, _, _, _, err := c.SnapshotsFor(catalogRecord(billing.VersionRef{ID: pricing.Ref.ID, Version: pricing.Ref.Version}, policy.Ref, catalogLeg("backend", "model", rates.Ref))) + got, err := c.CustomerRatingSnapshots(catalogCustomerCall(billing.VersionRef{ID: pricing.Ref.ID, Version: pricing.Ref.Version}, policy.Ref, catalogCustomerLeg("backend", "model"))) if err != nil { t.Fatal(err) } - assertPricingEqual(t, got, pricing) + assertPricingEqual(t, got.DefaultPricing, pricing) } func TestSnapshotCatalog_PutRejectsInvalidSnapshots(t *testing.T) { @@ -168,26 +171,26 @@ func TestSnapshotCatalog_PutRejectsInvalidSnapshots(t *testing.T) { func TestSnapshotCatalog_ReturnedBodiesAreCopies(t *testing.T) { t.Parallel() - c, pricing, policy, rates := seedCatalog(t) + c, pricing, policy, _ := seedCatalog(t) pricing.FixedCharges[0].Amount.Nano = 999 if err := c.PutPricing(catalogPricing()); err != nil { t.Fatalf("caller mutation after Put must not change catalog: %v", err) } - got, _, _, _, err := c.SnapshotsFor(catalogRecord(pricing.Ref, policy.Ref, catalogLeg("backend", "model", rates.Ref))) + got, err := c.CustomerRatingSnapshots(catalogCustomerCall(pricing.Ref, policy.Ref, catalogCustomerLeg("backend", "model"))) if err != nil { - t.Fatal(err) + t.Fatalf("CustomerRatingSnapshots after rejected mutation: %v", err) } - if got.FixedCharges[0].Amount.Nano != 3 { - t.Fatalf("catalog body mutated via caller slice = %d, want 3", got.FixedCharges[0].Amount.Nano) + if got.DefaultPricing.FixedCharges[0].Amount.Nano != 3 { + t.Fatalf("catalog body mutated via caller slice = %d, want 3", got.DefaultPricing.FixedCharges[0].Amount.Nano) } - got.InputRatePresent = false - got.FixedCharges[0].Amount.Nano = 1 - again, _, _, _, err := c.SnapshotsFor(catalogRecord(pricing.Ref, policy.Ref, catalogLeg("backend", "model", rates.Ref))) + got.DefaultPricing.InputRatePresent = false + got.DefaultPricing.FixedCharges[0].Amount.Nano = 1 + again, err := c.CustomerRatingSnapshots(catalogCustomerCall(pricing.Ref, policy.Ref, catalogCustomerLeg("backend", "model"))) if err != nil { - t.Fatal(err) + t.Fatalf("CustomerRatingSnapshots after returned mutation: %v", err) } - if !again.InputRatePresent || again.FixedCharges[0].Amount.Nano != 3 { - t.Fatalf("catalog body mutated via returned copy: %+v", again) + if !again.DefaultPricing.InputRatePresent || again.DefaultPricing.FixedCharges[0].Amount.Nano != 3 { + t.Fatalf("catalog body mutated via returned copy: %+v", again.DefaultPricing) } } @@ -365,7 +368,7 @@ func TestSnapshotCatalog_AdmissionSnapshotRefs(t *testing.T) { } } -func TestSnapshotCatalog_SnapshotsForReturnsExactBodies(t *testing.T) { +func TestSnapshotCatalog_CustomerRatingSnapshotsReturnsExactBodies(t *testing.T) { t.Parallel() c, pricing, policy, rates := seedCatalog(t) override := catalogPricing() @@ -387,45 +390,36 @@ func TestSnapshotCatalog_SnapshotsForReturnsExactBodies(t *testing.T) { t.Fatal(err) } - unboundRecord := catalogRecord(pricing.Ref, policy.Ref, catalogLeg("backend", "model", rates.Ref)) - _, _, _, unboundCards, err := c.SnapshotsFor(unboundRecord) + unbound, err := c.CustomerRatingSnapshots(catalogCustomerCall(pricing.Ref, policy.Ref, catalogCustomerLeg("backend", "model"))) if err != nil { t.Fatal(err) } - if len(unboundCards) != 0 { - t.Fatalf("model pricing = %+v, want empty when no TUR leg matches a route override", unboundCards) + if len(unbound.ModelPricing) != 0 { + t.Fatalf("model pricing = %+v, want empty when no call leg matches a route override", unbound.ModelPricing) } - record := catalogRecord(pricing.Ref, policy.Ref, - catalogLeg("backend", "model", rates.Ref), - func() billing.LegUsageRecord { - leg := catalogLeg("backend", "special", secondRate.Ref) - leg.BLegID = "b-2" - leg.Seq = 2 - return leg - }(), - ) - gotPricing, gotPolicy, gotRates, modelPricing, err := c.SnapshotsFor(record) + special := catalogCustomerLeg("backend", "special") + special.BLegID = "b-2" + special.AttemptSeq = 2 + got, err := c.CustomerRatingSnapshots(catalogCustomerCall(pricing.Ref, policy.Ref, catalogCustomerLeg("backend", "model"), special)) if err != nil { t.Fatal(err) } - assertPricingEqual(t, gotPricing, pricing) - assertPolicyEqual(t, gotPolicy, policy) - if len(gotRates) != 2 { - t.Fatalf("operator rates len = %d, want 2", len(gotRates)) + assertPricingEqual(t, got.DefaultPricing, pricing) + assertPolicyEqual(t, got.Policy, policy) + if gotRate, err := c.OperatorRate(rates.Ref); err != nil || !operatorRateEqual(gotRate, rates) { + t.Fatalf("operator rate (default ref) = %+v err=%v", gotRate, err) } - if !operatorRateEqual(gotRates[0], rates) || !operatorRateEqual(gotRates[1], secondRate) { - t.Fatalf("operator rates = %+v", gotRates) + if gotRate, err := c.OperatorRate(secondRate.Ref); err != nil || !operatorRateEqual(gotRate, secondRate) { + t.Fatalf("operator rate (v2 ref) = %+v err=%v", gotRate, err) } + modelPricing := got.ModelPricing if len(modelPricing) != 2 { t.Fatalf("model pricing len = %d, want 2 unique billed backend/model cards, got %+v", len(modelPricing), modelPricing) } for i, card := range modelPricing { - if card.Pricing.Ref != gotPricing.Ref { - t.Fatalf("modelPricing[%d] Ref = %+v, want shared CustomerPricing Ref %+v", i, card.Pricing.Ref, gotPricing.Ref) - } - if !versionIdentityEqual(card.Pricing.Ref, record.CustomerPricingRef) { - t.Fatalf("modelPricing[%d] Ref = %+v, want TUR CustomerPricingRef %+v", i, card.Pricing.Ref, record.CustomerPricingRef) + if card.Pricing.Ref != got.DefaultPricing.Ref { + t.Fatalf("modelPricing[%d] Ref = %+v, want shared CustomerPricing Ref %+v", i, card.Pricing.Ref, got.DefaultPricing.Ref) } if versionIdentityEqual(card.Pricing.Ref, override.Ref) { t.Fatalf("modelPricing[%d] emitted override document identity %+v", i, card.Pricing.Ref) @@ -435,68 +429,89 @@ func TestSnapshotCatalog_SnapshotsForReturnsExactBodies(t *testing.T) { assertPricingEqual(t, defaultCard.Pricing, pricing) overrideCard := findModelPricing(t, modelPricing, "backend", "special") wantOverride := override - wantOverride.Ref = record.CustomerPricingRef + wantOverride.Ref = got.DefaultPricing.Ref assertPricingEqual(t, overrideCard.Pricing, wantOverride) if !overrideCard.Pricing.InputRatePresent || overrideCard.Pricing.OutputRatePresent { t.Fatalf("override Present bits not preserved: %+v", overrideCard.Pricing) } - gotOverride, _, _, _, err := c.SnapshotsFor(catalogRecord(override.Ref, policy.Ref, catalogLeg("backend", "model", rates.Ref))) + gotOverride, err := c.CustomerRatingSnapshots(catalogCustomerCall(override.Ref, policy.Ref, catalogCustomerLeg("backend", "model"))) if err != nil { t.Fatal(err) } - assertPricingEqual(t, gotOverride, override) + assertPricingEqual(t, gotOverride.DefaultPricing, override) } -func TestSnapshotCatalog_SnapshotsForFailsClosedWithoutSubstitute(t *testing.T) { +func TestSnapshotCatalog_CustomerRatingSnapshotsFailsClosedWithoutSubstitute(t *testing.T) { t.Parallel() tests := []struct { - name string - record func(pricing billing.PricingSnapshot, policy billing.ChargePolicy, rates billing.OperatorRateSnapshot) billing.TurnUsageRecord + name string + call func(pricing billing.PricingSnapshot, policy billing.ChargePolicy) (billing.CallUsageRecord, []billing.CallLegUsageRecord) }{ { name: "missing customer pricing version", - record: func(pricing billing.PricingSnapshot, policy billing.ChargePolicy, rates billing.OperatorRateSnapshot) billing.TurnUsageRecord { - return catalogRecord(billing.VersionRef{ID: pricing.Ref.ID, Version: "missing"}, policy.Ref, catalogLeg("backend", "model", rates.Ref)) + call: func(pricing billing.PricingSnapshot, policy billing.ChargePolicy) (billing.CallUsageRecord, []billing.CallLegUsageRecord) { + return catalogCustomerCall(billing.VersionRef{ID: pricing.Ref.ID, Version: "missing"}, policy.Ref, catalogCustomerLeg("backend", "model")) }, }, { name: "missing charge policy version", - record: func(pricing billing.PricingSnapshot, policy billing.ChargePolicy, rates billing.OperatorRateSnapshot) billing.TurnUsageRecord { - return catalogRecord(pricing.Ref, billing.VersionRef{ID: policy.Ref.ID, Version: "missing"}, catalogLeg("backend", "model", rates.Ref)) - }, - }, - { - name: "missing operator rate version", - record: func(pricing billing.PricingSnapshot, policy billing.ChargePolicy, rates billing.OperatorRateSnapshot) billing.TurnUsageRecord { - return catalogRecord(pricing.Ref, policy.Ref, catalogLeg("backend", "model", billing.VersionRef{ID: rates.Ref.ID, Version: "missing"})) + call: func(pricing billing.PricingSnapshot, policy billing.ChargePolicy) (billing.CallUsageRecord, []billing.CallLegUsageRecord) { + return catalogCustomerCall(pricing.Ref, billing.VersionRef{ID: policy.Ref.ID, Version: "missing"}, catalogCustomerLeg("backend", "model")) }, }, { name: "missing pricing id does not use default", - record: func(_ billing.PricingSnapshot, policy billing.ChargePolicy, rates billing.OperatorRateSnapshot) billing.TurnUsageRecord { - return catalogRecord(billing.VersionRef{ID: "other-prices", Version: "v7"}, policy.Ref, catalogLeg("backend", "model", rates.Ref)) + call: func(_ billing.PricingSnapshot, policy billing.ChargePolicy) (billing.CallUsageRecord, []billing.CallLegUsageRecord) { + return catalogCustomerCall(billing.VersionRef{ID: "other-prices", Version: "v7"}, policy.Ref, catalogCustomerLeg("backend", "model")) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - c, pricing, policy, rates := seedCatalog(t) - gotPricing, gotPolicy, gotRates, modelPricing, err := c.SnapshotsFor(tt.record(pricing, policy, rates)) + c, pricing, policy, _ := seedCatalog(t) + got, err := c.CustomerRatingSnapshots(tt.call(pricing, policy)) if err == nil { t.Fatal("expected fail-closed error") } if !errors.Is(err, billing.ErrRatingSnapshotMismatch) && !errors.Is(err, billingcompose.ErrSnapshotNotFound) { t.Fatalf("error = %v, want snapshot mismatch or not found", err) } - if !pricingEqual(gotPricing, billing.PricingSnapshot{}) || gotPolicy != (billing.ChargePolicy{}) || gotRates != nil || modelPricing != nil { - t.Fatalf("partial substitute returned: pricing=%+v policy=%+v rates=%+v model=%+v", gotPricing, gotPolicy, gotRates, modelPricing) + if !pricingEqual(got.DefaultPricing, billing.PricingSnapshot{}) || got.Policy != (billing.ChargePolicy{}) || got.ModelPricing != nil { + t.Fatalf("partial substitute returned: %+v", got) } }) } } +func TestSnapshotCatalog_RoutePricingBindingRequiresPublishedBody(t *testing.T) { + t.Parallel() + c, pricing, policy, _ := seedCatalog(t) + unpublished := catalogPricing() + unpublished.Ref = billing.VersionRef{ID: "pricing-unpublished", Version: "v1"} + unpublished.InputPerMillionNano = 777 + // The binding API cannot reference a pricing body that is not published, so + // an override binding always resolves to an immutable published card; + // resolution can never silently substitute another model's price. + if err := c.SetRoutePricing("backend", "special", unpublished.Ref); !errors.Is(err, billingcompose.ErrSnapshotNotFound) { + t.Fatalf("SetRoutePricing(unpublished) = %v, want ErrSnapshotNotFound", err) + } + if err := c.PutPricing(unpublished); err != nil { + t.Fatal(err) + } + if err := c.SetRoutePricing("backend", "special", unpublished.Ref); err != nil { + t.Fatal(err) + } + got, err := c.CustomerRatingSnapshots(catalogCustomerCall(pricing.Ref, policy.Ref, catalogCustomerLeg("backend", "special"))) + if err != nil { + t.Fatal(err) + } + if len(got.ModelPricing) != 1 || got.ModelPricing[0].Pricing.InputPerMillionNano != unpublished.InputPerMillionNano { + t.Fatalf("model pricing = %+v, want override body", got.ModelPricing) + } +} + func TestSnapshotCatalog_RoutePricingFailsClosedWithoutDefault(t *testing.T) { t.Parallel() c := billingcompose.NewSnapshotCatalog() @@ -509,7 +524,7 @@ func TestSnapshotCatalog_RoutePricingFailsClosedWithoutDefault(t *testing.T) { } func TestSnapshotCatalog_ConcurrentReadsDuringPublish(t *testing.T) { - c, pricing, policy, rates := seedCatalog(t) + c, pricing, policy, _ := seedCatalog(t) const ( readers = 8 @@ -517,7 +532,7 @@ func TestSnapshotCatalog_ConcurrentReadsDuringPublish(t *testing.T) { iterations = 200 ) - record := catalogRecord(pricing.Ref, policy.Ref, catalogLeg("backend", "model", rates.Ref)) + record, recordLegs := catalogCustomerCall(pricing.Ref, policy.Ref, catalogCustomerLeg("backend", "model")) var wg sync.WaitGroup errs := make(chan error, readers+writers) @@ -533,7 +548,7 @@ func TestSnapshotCatalog_ConcurrentReadsDuringPublish(t *testing.T) { errs <- errors.New("defaults lost during concurrent read") return } - if _, _, _, _, err := c.SnapshotsFor(record); err != nil { + if _, err := c.CustomerRatingSnapshots(record, recordLegs); err != nil { errs <- err return } @@ -665,9 +680,13 @@ func catalogOperatorRate() billing.OperatorRateSnapshot { } } -func catalogLeg(backend, model string, rate billing.VersionRef) billing.LegUsageRecord { - return billing.LegUsageRecord{ - ALegID: "a-1", BLegID: "b-1", Seq: 1, +// catalogCustomerLeg builds a call-leg usage record carrying backend/model +// identity only: the facts customer snapshot resolution consumes. Operator +// rate refs are intentionally absent so these helpers cannot accidentally +// couple customer resolution to provider rates. +func catalogCustomerLeg(backend, model string) billing.CallLegUsageRecord { + return billing.CallLegUsageRecord{ + CallID: catalogCustomerCallID, ALegID: "a-1", BLegID: "b-1", AttemptSeq: 1, BackendID: backend, ProviderID: "provider", ModelID: model, StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(101, 0).UTC(), Outcome: billing.LegOutcomeWinner, Surfaced: billing.SurfacedYes, @@ -675,24 +694,28 @@ func catalogLeg(backend, model string, rate billing.VersionRef) billing.LegUsage InputTokens: billing.Quantity{Value: 1, Present: true}, OutputTokens: billing.Quantity{Value: 1, Present: true}, }, - OperatorRateRef: rate, } } -func catalogRecord(pricing, policy billing.VersionRef, legs ...billing.LegUsageRecord) billing.TurnUsageRecord { - return billing.TurnUsageRecord{ - SchemaVersion: billing.CurrentRecordSchemaVersion, - AccountID: "acct-1", - TurnID: "turn-1", - ALegID: "a-1", - LegacyAuthorizationID: "auth-1", - StartedAt: time.Unix(100, 0).UTC(), - FinishedAt: time.Unix(101, 0).UTC(), - Outcome: billing.TurnOutcomeCompleted, - CustomerPricingRef: pricing, - ChargePolicyRef: policy, - Legs: legs, - } +var catalogCustomerCallID = billing.BillingCallID("bc_00000000000000000000000000000000") + +func catalogCustomerCall(pricing, policy billing.VersionRef, legs ...billing.CallLegUsageRecord) (billing.CallUsageRecord, []billing.CallLegUsageRecord) { + ids := make([]string, 0, len(legs)) + for _, leg := range legs { + ids = append(ids, leg.BLegID) + } + return billing.CallUsageRecord{ + SchemaVersion: billing.CurrentRecordSchemaVersion, + CallID: catalogCustomerCallID, + AccountID: "acct-1", + ALegID: "a-1", + StartedAt: time.Unix(100, 0).UTC(), + FinishedAt: time.Unix(101, 0).UTC(), + Outcome: billing.TurnOutcomeCompleted, + CustomerPricingRef: pricing, + ChargePolicyRef: policy, + ExpectedBLegIDs: ids, + }, legs } func assertPricingEqual(t *testing.T, got, want billing.PricingSnapshot) { diff --git a/internal/infra/billingcompose/phase0_red_test.go b/internal/infra/billingcompose/phase0_red_test.go new file mode 100644 index 00000000..a4d91fc7 --- /dev/null +++ b/internal/infra/billingcompose/phase0_red_test.go @@ -0,0 +1,259 @@ +package billingcompose_test + +import ( + "context" + "testing" + "time" + + "github.com/matdev83/go-llm-interactive-proxy/internal/core/billing" + "github.com/matdev83/go-llm-interactive-proxy/internal/infra/billingcompose" +) + +func TestResolveCallRating_MixedModelPricingKeepsOverrides(t *testing.T) { + // Cover two customer-billable model legs with distinct effective cards so + // settlement remains aligned with route-specific admission pricing. + t.Parallel() + + c, pricing, policy, rates := seedCatalog(t) + + // Create a policy that charges all legs, but excludes fixed charges for simpler math. + policyAll := policy + policyAll.Ref = billing.VersionRef{ID: "policy", Version: "v10"} + policyAll.Scope = billing.ChargeAllPotentialLegs + policyAll.IncludeFixedCharges = false + if err := c.PutPolicy(policyAll); err != nil { + t.Fatal(err) + } + + // Create an override pricing snapshot for "backend-special-1/model-special-1" + // with input pricing ($500/million vs default $100/million). + override1 := pricing + override1.Ref = billing.VersionRef{ID: "pricing-override-1", Version: "v1"} + override1.InputPerMillionNano = 500 + override1.OutputPerMillionNano = 1000 + override1.InputRatePresent = true + override1.OutputRatePresent = true + override1.FixedCharges = nil + if err := c.PutPricing(override1); err != nil { + t.Fatal(err) + } + if err := c.SetRoutePricing("backend-special-1", "model-special-1", override1.Ref); err != nil { + t.Fatal(err) + } + + // Create an override pricing snapshot for "backend-special-2/model-special-2" + // with input pricing ($300/million vs default $100/million). + override2 := pricing + override2.Ref = billing.VersionRef{ID: "pricing-override-2", Version: "v1"} + override2.InputPerMillionNano = 300 + override2.OutputPerMillionNano = 600 + override2.InputRatePresent = true + override2.OutputRatePresent = true + override2.FixedCharges = nil + if err := c.PutPricing(override2); err != nil { + t.Fatal(err) + } + if err := c.SetRoutePricing("backend-special-2", "model-special-2", override2.Ref); err != nil { + t.Fatal(err) + } + + callID, err := billing.NewBillingCallID() + if err != nil { + t.Fatal(err) + } + + // 1. Admission: Prove that the admission Quote sees the route-specific override prices. + admPricing1, err := c.RoutePricing(context.Background(), "backend-special-1", "model-special-1") + if err != nil { + t.Fatal(err) + } + if admPricing1.InputPerMillionNano != 500 { + t.Fatalf("admission pricing 1 = %d, want 500", admPricing1.InputPerMillionNano) + } + + admPricing2, err := c.RoutePricing(context.Background(), "backend-special-2", "model-special-2") + if err != nil { + t.Fatal(err) + } + if admPricing2.InputPerMillionNano != 300 { + t.Fatalf("admission pricing 2 = %d, want 300", admPricing2.InputPerMillionNano) + } + + // 2. Settlement: Build a complete call using both models. + // Leg 1: backend-special-1/model-special-1, 1,000,000 input tokens. + leg1 := billing.CallLegUsageRecord{ + CallID: callID, + ALegID: "a-1", + BLegID: "b-1", + BackendID: "backend-special-1", + ProviderID: "provider", + ModelID: "model-special-1", + StartedAt: time.Unix(100, 0).UTC(), + FinishedAt: time.Unix(101, 0).UTC(), + Outcome: billing.LegOutcomeWinner, + Surfaced: billing.SurfacedYes, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 1_000_000, Present: true}, + OutputTokens: billing.Quantity{Value: 0, Present: true}, + Source: billing.EvidenceSourceProviderReported, + Authority: billing.EvidenceAuthorityAuthoritative, + }, + OperatorRateRef: rates.Ref, + } + sealedLeg1, err := leg1.Seal() + if err != nil { + t.Fatal(err) + } + + // Leg 2: backend-special-2/model-special-2, 2,000,000 input tokens. + leg2 := billing.CallLegUsageRecord{ + CallID: callID, + ALegID: "a-1", + BLegID: "b-2", + BackendID: "backend-special-2", + ProviderID: "provider", + ModelID: "model-special-2", + StartedAt: time.Unix(100, 0).UTC(), + FinishedAt: time.Unix(101, 0).UTC(), + Outcome: billing.LegOutcomeWinner, + Surfaced: billing.SurfacedYes, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 2_000_000, Present: true}, + OutputTokens: billing.Quantity{Value: 0, Present: true}, + Source: billing.EvidenceSourceProviderReported, + Authority: billing.EvidenceAuthorityAuthoritative, + }, + OperatorRateRef: rates.Ref, + } + sealedLeg2, err := leg2.Seal() + if err != nil { + t.Fatal(err) + } + + closure := billing.CallUsageRecord{ + SchemaVersion: billing.CurrentRecordSchemaVersion, + CallID: callID, + AccountID: "acct-1", + ALegID: "a-1", + StartedAt: time.Unix(100, 0).UTC(), + FinishedAt: time.Unix(101, 0).UTC(), + Outcome: billing.TurnOutcomeCompleted, + CustomerPricingRef: pricing.Ref, + ChargePolicyRef: policyAll.Ref, + ExpectedBLegIDs: []string{"b-1", "b-2"}, + } + sealedClosure, err := closure.Seal() + if err != nil { + t.Fatal(err) + } + + resolver, err := billingcompose.NewCallRatingResolver(c) + if err != nil { + t.Fatal(err) + } + + complete := billing.CompleteCall{ + Closure: sealedClosure, + Legs: []billing.CallLegUsageRecord{sealedLeg1, sealedLeg2}, + } + + exposure := billing.CallExposure{ + CallID: callID.String(), + Max: billing.Money{Nano: 10000, Currency: "USD"}, + } + + result, err := resolver.ResolveCallRating(context.Background(), complete, exposure) + if err != nil { + t.Fatal(err) + } + + // Leg 1 uses override1: 1,000,000 * 500 / 1,000,000 = 500 nano. + // Leg 2 uses override2: 2,000,000 * 300 / 1,000,000 = 600 nano. + if got, want := result.CustomerCharge.Nano, int64(1100); got != want { + t.Errorf("CustomerCharge = %d, want %d", got, want) + } +} + +func TestResolveCallRating_MissingOperatorRateDoesNotBlockCustomerRating(t *testing.T) { + t.Parallel() + + c, pricing, policy, _ := seedCatalog(t) + + callID, err := billing.NewBillingCallID() + if err != nil { + t.Fatal(err) + } + + // Leg references an operator rate version "missing" which does NOT exist in the catalog + missingRateRef := billing.VersionRef{ID: "operator-rates", Version: "missing"} + + leg := billing.CallLegUsageRecord{ + CallID: callID, + ALegID: "a-1", + BLegID: "b-1", + BackendID: "backend", + ProviderID: "provider", + ModelID: "model", + StartedAt: time.Unix(100, 0).UTC(), + FinishedAt: time.Unix(101, 0).UTC(), + Outcome: billing.LegOutcomeWinner, + Surfaced: billing.SurfacedYes, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 1_000_000, Present: true}, + OutputTokens: billing.Quantity{Value: 0, Present: true}, + Source: billing.EvidenceSourceProviderReported, + Authority: billing.EvidenceAuthorityAuthoritative, + }, + OperatorRateRef: missingRateRef, + } + sealedLeg, err := leg.Seal() + if err != nil { + t.Fatal(err) + } + + closure := billing.CallUsageRecord{ + SchemaVersion: billing.CurrentRecordSchemaVersion, + CallID: callID, + AccountID: "acct-1", + ALegID: "a-1", + StartedAt: time.Unix(100, 0).UTC(), + FinishedAt: time.Unix(101, 0).UTC(), + Outcome: billing.TurnOutcomeCompleted, + CustomerPricingRef: pricing.Ref, + ChargePolicyRef: policy.Ref, + ExpectedBLegIDs: []string{"b-1"}, + } + sealedClosure, err := closure.Seal() + if err != nil { + t.Fatal(err) + } + + resolver, err := billingcompose.NewCallRatingResolver(c) + if err != nil { + t.Fatal(err) + } + + complete := billing.CompleteCall{ + Closure: sealedClosure, + Legs: []billing.CallLegUsageRecord{sealedLeg}, + } + + exposure := billing.CallExposure{ + CallID: callID.String(), + Max: billing.Money{Nano: 10000, Currency: "USD"}, + } + + // Customer rating resolves only customer pricing/policy/model cards and is + // independent of provider-cost readiness: the missing operator-rate ref must + // not block settlement (Phase 2 split). + result, err := resolver.ResolveCallRating(context.Background(), complete, exposure) + if err != nil { + t.Errorf("ResolveCallRating failed: %v", err) + return + } + + // The customer charge should be computed successfully (1,000,000 * 100 + 3 = 103 nano). + if got, want := result.CustomerCharge.Nano, int64(103); got != want { + t.Errorf("CustomerCharge = %d, want %d", got, want) + } +} diff --git a/internal/infra/billingcompose/resolver.go b/internal/infra/billingcompose/resolver.go index 6f7dd766..652447e6 100644 --- a/internal/infra/billingcompose/resolver.go +++ b/internal/infra/billingcompose/resolver.go @@ -27,16 +27,21 @@ func NewCallRatingResolver(catalog *SnapshotCatalog) (billing.CallRatingResolver func (r *JoinRatingResolver) ResolveCallRating(_ context.Context, complete billing.CompleteCall, exposure billing.CallExposure) (billing.CallRatingResult, error) { call := complete.Closure - legs := make([]billing.LegUsageRecord, 0, len(complete.Legs)) - for i, leg := range complete.Legs { - legs = append(legs, billing.LegUsageRecord{ALegID: leg.ALegID, BLegID: leg.BLegID, Seq: i + 1, BackendID: leg.BackendID, ProviderID: leg.ProviderID, ModelID: leg.ModelID, StartedAt: leg.StartedAt, FinishedAt: leg.FinishedAt, Outcome: billing.LegOutcome(leg.Outcome), Surfaced: leg.Surfaced, Evidence: leg.Evidence, OperatorRateRef: leg.OperatorRateRef}) - } - catalogRecord := billing.TurnUsageRecord{AccountID: call.AccountID, TurnID: call.CallID.String(), ALegID: call.ALegID, CustomerPricingRef: call.CustomerPricingRef, ChargePolicyRef: call.ChargePolicyRef, Legs: legs} - pricing, policy, rates, _, err := r.catalog.SnapshotsFor(catalogRecord) + // Customer rating resolves customer pricing/policy/model cards only. The + // combined provider-compose method is gone: no operator-rate lookup happens + // here, so missing provider-cost data can never block customer settlement. + snapshots, err := r.catalog.CustomerRatingSnapshots(call, complete.Legs) if err != nil { - return billing.CallRatingResult{}, fmt.Errorf("billingcompose: call snapshot catalog: %w", err) + return billing.CallRatingResult{}, fmt.Errorf("billingcompose: customer rating snapshots: %w", err) } - return billing.RateCall(billing.CallRatingInput{Call: call, Legs: complete.Legs, MaxCustomerCharge: exposure.Max, CustomerPricing: pricing, CustomerPolicy: policy, OperatorRates: rates}) + return billing.RateCall(billing.CallRatingInput{ + Call: call, + Legs: complete.Legs, + MaxCustomerCharge: exposure.Max, + CustomerPricing: snapshots.DefaultPricing, + CustomerPolicy: snapshots.Policy, + ModelPricing: snapshots.ModelPricing, + }) } type ProviderCostJoinResolver struct { diff --git a/internal/infra/billingcompose/snapshot_independence_test.go b/internal/infra/billingcompose/snapshot_independence_test.go new file mode 100644 index 00000000..6cb9ed68 --- /dev/null +++ b/internal/infra/billingcompose/snapshot_independence_test.go @@ -0,0 +1,174 @@ +package billingcompose_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/matdev83/go-llm-interactive-proxy/internal/core/billing" + "github.com/matdev83/go-llm-interactive-proxy/internal/infra/billingcompose" +) + +// Phase 2.1/2.3/2.4 — customer snapshot resolution is split from +// provider/operator-rate resolution, and every selected customer-billable +// B-leg is rated with its effective backend/model card. + +func TestCustomerRatingSnapshotsIgnoresOperatorRates(t *testing.T) { + t.Parallel() + c, pricing, policy, _ := seedCatalog(t) + callID, err := billing.NewBillingCallID() + if err != nil { + t.Fatal(err) + } + missingRateRef := billing.VersionRef{ID: "operator-rates", Version: "missing"} + leg := billing.CallLegUsageRecord{ + CallID: callID, ALegID: "a-1", BLegID: "b-1", + BackendID: "backend", ProviderID: "provider", ModelID: "model", + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(101, 0).UTC(), + Outcome: billing.LegOutcomeWinner, Surfaced: billing.SurfacedYes, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 1_000_000, Present: true}, + OutputTokens: billing.Quantity{Value: 0, Present: true}, + Source: billing.EvidenceSourceProviderReported, + Authority: billing.EvidenceAuthorityAuthoritative, + }, + OperatorRateRef: missingRateRef, + } + sealedLeg, err := leg.Seal() + if err != nil { + t.Fatal(err) + } + closure := billing.CallUsageRecord{ + SchemaVersion: billing.CurrentRecordSchemaVersion, + CallID: callID, + AccountID: "acct-1", + ALegID: "a-1", + StartedAt: time.Unix(100, 0).UTC(), + FinishedAt: time.Unix(101, 0).UTC(), + Outcome: billing.TurnOutcomeCompleted, + CustomerPricingRef: pricing.Ref, + ChargePolicyRef: policy.Ref, + ExpectedBLegIDs: []string{"b-1"}, + } + sealedClosure, err := closure.Seal() + if err != nil { + t.Fatal(err) + } + + // The customer path must succeed even though the persisted OperatorRateRef + // has no published rate: customer rating needs pricing/policy/model cards + // only and must never eagerly load operator rates. + got, err := c.CustomerRatingSnapshots(sealedClosure, []billing.CallLegUsageRecord{sealedLeg}) + if err != nil { + t.Fatalf("CustomerRatingSnapshots failed on missing operator rate: %v (customer rating must be independent of provider-cost readiness)", err) + } + assertPricingEqual(t, got.DefaultPricing, pricing) + assertPolicyEqual(t, got.Policy, policy) + if len(got.ModelPricing) != 0 { + t.Fatalf("unexpected model cards for unoverridden route: %+v", got.ModelPricing) + } + + // The provider path is independently per-leg and fails closed when its own + // operator rate is missing: it must not preclude or alter the customer path. + providerResolver, err := billingcompose.NewProviderCostResolver(c, "USD") + if err != nil { + t.Fatal(err) + } + if _, err := providerResolver.ResolveProviderCost(context.Background(), sealedLeg); !errors.Is(err, billing.ErrUnreconciledCost) { + t.Fatalf("provider cost = err %v, want ErrUnreconciledCost (missing operator rate)", err) + } +} + +func TestResolveCallRatingFailoverSettlesSurfacedModelCard(t *testing.T) { + t.Parallel() + c, pricing, policy, _ := seedCatalog(t) + + // A route/model pricing override exists for the failover model; admission + // quotes with it and settlement must use the same effective card. + override := pricing + override.Ref = billing.VersionRef{ID: "pricing-failover", Version: "v1"} + override.InputPerMillionNano = 1000 + override.OutputPerMillionNano = 2000 + override.InputRatePresent = true + override.OutputRatePresent = true + override.FixedCharges = nil + if err := c.PutPricing(override); err != nil { + t.Fatal(err) + } + if err := c.SetRoutePricing("backend-special", "model-special", override.Ref); err != nil { + t.Fatal(err) + } + + callID, err := billing.NewBillingCallID() + if err != nil { + t.Fatal(err) + } + // Cheap attempt 1 (no override route) fails and is not surfaced. + cheap := billing.CallLegUsageRecord{ + CallID: callID, ALegID: "a-1", BLegID: "b_z7x9p", AttemptSeq: 1, + BackendID: "backend", ProviderID: "provider", ModelID: "model", + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(101, 0).UTC(), + Outcome: billing.LegOutcomeFailed, Surfaced: billing.SurfacedNo, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 1_000_000, Present: true}, + OutputTokens: billing.Quantity{Value: 0, Present: true}, + Source: billing.EvidenceSourceProviderReported, + Authority: billing.EvidenceAuthorityAuthoritative, + }, + } + sealedCheap, err := cheap.Seal() + if err != nil { + t.Fatal(err) + } + // Expensive failover attempt 2 is the surfaced winner and must be billed + // with its override card (input 1000), never the default (input 100). + winner := billing.CallLegUsageRecord{ + CallID: callID, ALegID: "a-1", BLegID: "b_a1b2c", AttemptSeq: 2, + BackendID: "backend-special", ProviderID: "provider", ModelID: "model-special", + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(101, 0).UTC(), + Outcome: billing.LegOutcomeWinner, Surfaced: billing.SurfacedYes, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 1_000_000, Present: true}, + OutputTokens: billing.Quantity{Value: 0, Present: true}, + Source: billing.EvidenceSourceProviderReported, + Authority: billing.EvidenceAuthorityAuthoritative, + }, + } + sealedWinner, err := winner.Seal() + if err != nil { + t.Fatal(err) + } + + closure := billing.CallUsageRecord{ + SchemaVersion: billing.CurrentRecordSchemaVersion, + CallID: callID, + AccountID: "acct-1", + ALegID: "a-1", + StartedAt: time.Unix(100, 0).UTC(), + FinishedAt: time.Unix(101, 0).UTC(), + Outcome: billing.TurnOutcomeCompleted, + CustomerPricingRef: pricing.Ref, + ChargePolicyRef: policy.Ref, + ExpectedBLegIDs: []string{"b_a1b2c", "b_z7x9p"}, + } + sealedClosure, err := closure.Seal() + if err != nil { + t.Fatal(err) + } + + resolver, err := billingcompose.NewCallRatingResolver(c) + if err != nil { + t.Fatal(err) + } + complete := billing.CompleteCall{Closure: sealedClosure, Legs: []billing.CallLegUsageRecord{sealedCheap, sealedWinner}} + exposure := billing.CallExposure{CallID: callID.String(), Max: billing.Money{Nano: 10000, Currency: "USD"}} + result, err := resolver.ResolveCallRating(context.Background(), complete, exposure) + if err != nil { + t.Fatal(err) + } + // Surfaced winner: 1,000,000 input at override 1000/million = 1000. + if got, want := result.CustomerCharge.Nano, int64(1000); got != want { + t.Errorf("failover settlement = %d, want %d (settle surfaced winner with its own model card)", got, want) + } +} diff --git a/internal/infra/billingstore/20260812000000_billing_baseline.go b/internal/infra/billingstore/20260812000000_billing_baseline.go index 25854cf4..0ff76b51 100644 --- a/internal/infra/billingstore/20260812000000_billing_baseline.go +++ b/internal/infra/billingstore/20260812000000_billing_baseline.go @@ -35,6 +35,7 @@ func registerMigrations() { registerAuthorizationHoldsDropMigration() registerReservedNanoZeroMigration() registerCompleteCallClaimLeaseMigration() + registerUsageLegSequenceMigration() }) } diff --git a/internal/infra/billingstore/20260828000000_billing_usage_leg_sequence.go b/internal/infra/billingstore/20260828000000_billing_usage_leg_sequence.go new file mode 100644 index 00000000..20b5cade --- /dev/null +++ b/internal/infra/billingstore/20260828000000_billing_usage_leg_sequence.go @@ -0,0 +1,53 @@ +package billingstore + +import ( + "context" + "fmt" + + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect" +) + +const UsageLegSequenceMigrationName = "20260828000000" + +const usageLegCallAttemptSeqIndex = "idx_usage_leg_records_call_attempt_seq" + +// registerUsageLegSequenceMigration adds nullable attempt_seq; legacy rows remain unknown and corrected positive sequences are unique per call. +func registerUsageLegSequenceMigration() { + migrations.MustRegister(usageLegSequenceSchemaUp, func(context.Context, *bun.DB) error { return nil }) +} + +func usageLegSequenceSchemaUp(ctx context.Context, db *bun.DB) error { + if db == nil { + return fmt.Errorf("billing usage-leg sequence schema: nil database") + } + switch db.Dialect().Name() { + case dialect.SQLite: + var columnCount int + if err := db.NewRaw(`SELECT COUNT(1) FROM pragma_table_info('usage_leg_records') WHERE name = ?`, "attempt_seq").Scan(ctx, &columnCount); err != nil { + return fmt.Errorf("billing usage-leg sequence SQLite column probe: %w", err) + } + if columnCount == 0 { + if _, err := db.ExecContext(ctx, `ALTER TABLE usage_leg_records ADD COLUMN attempt_seq INTEGER NULL`); err != nil { + return fmt.Errorf("billing usage-leg sequence SQLite add column: %w", err) + } + } + if _, err := db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS `+usageLegCallAttemptSeqIndex+` ON usage_leg_records(call_id, attempt_seq)`); err != nil { + return fmt.Errorf("billing usage-leg sequence SQLite index: %w", err) + } + return nil + case dialect.PG: + statements := []string{ + `ALTER TABLE usage_leg_records ADD COLUMN IF NOT EXISTS attempt_seq BIGINT NULL`, + `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ` + usageLegCallAttemptSeqIndex + ` ON usage_leg_records(call_id, attempt_seq)`, + } + for _, statement := range statements { + if _, err := db.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("billing usage-leg sequence PostgreSQL DDL: %w", err) + } + } + return nil + default: + return fmt.Errorf("billing usage-leg sequence schema: unsupported bun dialect %s", db.Dialect().Name().String()) + } +} diff --git a/internal/infra/billingstore/20260828000000_billing_usage_leg_sequence_test.go b/internal/infra/billingstore/20260828000000_billing_usage_leg_sequence_test.go new file mode 100644 index 00000000..80a65540 --- /dev/null +++ b/internal/infra/billingstore/20260828000000_billing_usage_leg_sequence_test.go @@ -0,0 +1,124 @@ +package billingstore + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/matdev83/go-llm-interactive-proxy/internal/core/billing" +) + +func TestUsageLegSequenceSchemaCreatesCallAttemptSeqUniqueIndex(t *testing.T) { + store := newSQLiteTestStore(t) + ctx := context.Background() + var name string + if err := store.db.NewRaw(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`, usageLegCallAttemptSeqIndex).Scan(ctx, &name); err != nil { + t.Fatalf("call/attempt_seq unique index lookup: %v", err) + } + if name != usageLegCallAttemptSeqIndex { + t.Fatalf("index = %q, want %q", name, usageLegCallAttemptSeqIndex) + } + if err := usageLegSequenceSchemaUp(ctx, store.db); err != nil { + t.Fatalf("usage-leg sequence schema idempotent: %v", err) + } + if err := VerifySchema(ctx, store.db); err != nil { + t.Fatalf("VerifySchema: %v", err) + } + var attemptSeqType string + if err := store.db.NewRaw(`SELECT type FROM pragma_table_info('usage_leg_records') WHERE name = 'attempt_seq'`).Scan(ctx, &attemptSeqType); err != nil { + t.Fatal(err) + } + if attemptSeqType != "INTEGER" { + t.Fatalf("attempt_seq column type = %q, want INTEGER (nullable)", attemptSeqType) + } +} + +// TestSQLiteMigrateFromPreSequenceSchemaPreservesLegacyRows proves the +// brownfield upgrade path: a deployment whose usage_leg_records predates the +// sequence migration keeps its v1 rows readable (attempt_seq NULL, no guessed +// values) after the migration adds the nullable column and the +// (call_id, attempt_seq) unique index on top. +func TestSQLiteMigrateFromPreSequenceSchemaPreservesLegacyRows(t *testing.T) { + store := newSQLiteTestStore(t) + ctx := context.Background() + + // Revert the sequence migration in place to reconstruct the pre-fix + // schema: index first, then the nullable column, then the migration marker. + if _, err := store.db.NewRaw(`DROP INDEX ` + usageLegCallAttemptSeqIndex).Exec(ctx); err != nil { + t.Fatalf("drop attempt_seq index: %v", err) + } + if _, err := store.db.NewRaw(`ALTER TABLE usage_leg_records DROP COLUMN attempt_seq`).Exec(ctx); err != nil { + t.Fatalf("drop attempt_seq column: %v", err) + } + if _, err := store.db.NewRaw(`DELETE FROM bun_billing_migrations WHERE name = ?`, UsageLegSequenceMigrationName).Exec(ctx); err != nil { + t.Fatalf("reset sequence migration marker: %v", err) + } + var legacyCol string + if err := store.db.NewRaw(`SELECT name FROM pragma_table_info('usage_leg_records') WHERE name = 'attempt_seq'`).Scan(ctx, &legacyCol); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("pre-fix schema must lack attempt_seq (err=%v)", err) + } + + // Pre-fix rows were sealed with the v1 contract (no sequence) and written + // without an attempt_seq column. + callID, err := billing.NewBillingCallID() + if err != nil { + t.Fatal(err) + } + for _, bLegID := range []string{"b-pre-fix-1", "b-pre-fix-2"} { + legacySrc := testIndependentCallLegFor(callID, bLegID) + legacySrc.AttemptSeq = 0 + legacy, sealErr := legacySrc.Seal() + if sealErr != nil { + t.Fatal(sealErr) + } + payload, marshalErr := json.Marshal(legacy) + if marshalErr != nil { + t.Fatal(marshalErr) + } + sealedAt := time.Now().UTC() + if _, err := store.db.NewRaw(`INSERT INTO usage_leg_records( usage_leg_key, fingerprint, call_id, a_leg_id, b_leg_id, backend_id, provider_id, model_id, started_at, finished_at, outcome, surfaced, payload_json, sealed_at ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, legacy.Key, legacy.Fingerprint, legacy.CallID.String(), legacy.ALegID, legacy.BLegID, legacy.BackendID, legacy.ProviderID, legacy.ModelID, legacy.StartedAt, legacy.FinishedAt, string(legacy.Outcome), string(legacy.Surfaced), string(payload), sealedAt).Exec(ctx); err != nil { + t.Fatalf("pre-fix insert: %v", err) + } + } + + // Upgrade applies only the sequence migration on top of the pre-fix schema. + if err := Migrate(ctx, store.db); err != nil { + t.Fatalf("upgrade migrate: %v", err) + } + if err := VerifySchema(ctx, store.db); err != nil { + t.Fatalf("VerifySchema after upgrade: %v", err) + } + + // Legacy rows remain readable with NULL attempt_seq (order unknown, never + // guessed) and the new positive-sequence contract coexists in the same call. + for _, bLegID := range []string{"b-pre-fix-1", "b-pre-fix-2"} { + key := mustCallLegKey(t, callID, bLegID) + var attemptSeq sql.NullString + if err := store.db.NewRaw(`SELECT attempt_seq FROM usage_leg_records WHERE usage_leg_key = ?`, key).Scan(ctx, &attemptSeq); err != nil { + t.Fatal(err) + } + if attemptSeq.Valid { + t.Fatalf("upgraded legacy attempt_seq = %q, want NULL", attemptSeq.String) + } + got, err := store.GetCallLegUsage(ctx, key) + if err != nil { + t.Fatalf("legacy row must remain readable after upgrade: %v", err) + } + if got.AttemptSeq != 0 { + t.Fatalf("legacy restored AttemptSeq = %d, want 0 (unknown)", got.AttemptSeq) + } + } + newer := testIndependentCallLegFor(callID, "b-new") + newer.AttemptSeq = 1 + if err := store.AppendCallLegUsage(ctx, newer); err != nil { + t.Fatalf("new positive-sequence leg after upgrade: %v", err) + } + dup := testIndependentCallLegFor(callID, "b-dup") + dup.AttemptSeq = 1 // same positive sequence in the same call -> conflict + if err := store.AppendCallLegUsage(ctx, dup); !errors.Is(err, ErrLegAttemptSequenceConflict) { + t.Fatalf("duplicate attempt_seq after upgrade = %v, want ErrLegAttemptSequenceConflict", err) + } +} diff --git a/internal/infra/billingstore/account_tx.go b/internal/infra/billingstore/account_tx.go index 5da2eabb..2a4fbfb3 100644 --- a/internal/infra/billingstore/account_tx.go +++ b/internal/infra/billingstore/account_tx.go @@ -36,10 +36,12 @@ func withAccountTx[T any](ctx context.Context, cfg accountTxRetry, fn func() (T, return out, nil } lastErr = err - if !isSQLiteBusy(err) && !isUniqueViolation(err) { - if cfg.Classify != nil { - return zero, cfg.Classify(err) + if cfg.Classify != nil { + if classified := cfg.Classify(err); classified != nil { + return zero, classified } + } + if !isSQLiteBusy(err) && !isUniqueViolation(err) { return zero, err } if attempt == attempts-1 { diff --git a/internal/infra/billingstore/account_tx_test.go b/internal/infra/billingstore/account_tx_test.go index 6d04f673..093b8279 100644 --- a/internal/infra/billingstore/account_tx_test.go +++ b/internal/infra/billingstore/account_tx_test.go @@ -3,6 +3,7 @@ package billingstore import ( "context" "errors" + "fmt" "testing" "time" ) @@ -45,6 +46,30 @@ func TestWithAccountTxClassifiesNonRetryable(t *testing.T) { } } +func TestWithAccountTxClassifierStopsRetryableConflict(t *testing.T) { + t.Parallel() + var calls int + _, err := withAccountTx(context.Background(), accountTxRetry{ + Attempts: 20, + Delay: time.Millisecond, + Classify: func(err error) error { + if errors.Is(err, ErrLegAttemptSequenceConflict) { + return err + } + return nil + }, + }, func() (struct{}, error) { + calls++ + return struct{}{}, fmt.Errorf("%w: wrapped driver conflict", ErrLegAttemptSequenceConflict) + }) + if !errors.Is(err, ErrLegAttemptSequenceConflict) { + t.Fatalf("err=%v, want ErrLegAttemptSequenceConflict", err) + } + if calls != 1 { + t.Fatalf("calls=%d, want 1", calls) + } +} + func TestWithAccountTxExhaustedReturnsConfiguredError(t *testing.T) { t.Parallel() exhausted := errors.New("budget exhausted") diff --git a/internal/infra/billingstore/call_leg_usage_store.go b/internal/infra/billingstore/call_leg_usage_store.go index 40e38ea1..f78afc09 100644 --- a/internal/infra/billingstore/call_leg_usage_store.go +++ b/internal/infra/billingstore/call_leg_usage_store.go @@ -12,8 +12,20 @@ import ( "github.com/matdev83/go-llm-interactive-proxy/internal/core/billing" ) +// ErrLegAttemptSequenceConflict identifies duplicate positive sequences within one BillingCallID; legacy NULL values remain outside it. +var ErrLegAttemptSequenceConflict = errors.New("billingstore: call-leg attempt sequence conflict") + func (s *DurableStore) AppendCallLegUsage(ctx context.Context, record billing.CallLegUsageRecord) error { - return withAccountTxErr(ctx, accountTxRetry{Attempts: 20, Delay: 5 * time.Millisecond}, func() error { + return withAccountTxErr(ctx, accountTxRetry{ + Attempts: 20, + Delay: 5 * time.Millisecond, + Classify: func(err error) error { + if errors.Is(err, ErrLegAttemptSequenceConflict) { + return err + } + return nil + }, + }, func() error { return s.appendCallLegUsageAttempt(ctx, record) }) } @@ -54,10 +66,19 @@ func (s *DurableStore) appendCallLegUsageAttempt(ctx context.Context, record bil return fmt.Errorf("billingstore: encode call-leg usage: %w", err) } sealedAt := time.Now().UTC() - _, err = tx.NewRaw(`INSERT INTO usage_leg_records( usage_leg_key, fingerprint, call_id, a_leg_id, b_leg_id, backend_id, provider_id, model_id, started_at, finished_at, outcome, surfaced, payload_json, sealed_at ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, sealed.Key, sealed.Fingerprint, sealed.CallID.String(), sealed.ALegID, sealed.BLegID, + // Pre-fix legacy rows have no sequence (attempt_seq NULL); corrected + // records persist the exact positive attempt sequence explicitly. + var attemptSeq any + if sealed.AttemptSeq > 0 { + attemptSeq = sealed.AttemptSeq + } + _, err = tx.NewRaw(`INSERT INTO usage_leg_records( usage_leg_key, fingerprint, call_id, a_leg_id, b_leg_id, attempt_seq, backend_id, provider_id, model_id, started_at, finished_at, outcome, surfaced, payload_json, sealed_at ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, sealed.Key, sealed.Fingerprint, sealed.CallID.String(), sealed.ALegID, sealed.BLegID, attemptSeq, sealed.BackendID, sealed.ProviderID, sealed.ModelID, sealed.StartedAt, sealed.FinishedAt, string(sealed.Outcome), string(sealed.Surfaced), string(payload), sealedAt).Exec(ctx) if err != nil { + if isLegAttemptSeqConflict(err) { + return fmt.Errorf("%w: %w", ErrLegAttemptSequenceConflict, err) + } return fmt.Errorf("billingstore: insert call-leg usage: %w", err) } if _, err := tx.NewRaw(`INSERT INTO provider_cost_work(usage_leg_key, call_id, status, attempt_count, next_attempt_at, last_error, updated_at) VALUES (?, ?, 'pending', 0, ?, '', ?) ON CONFLICT(usage_leg_key) DO NOTHING`, sealed.Key, sealed.CallID.String(), sealedAt, sealedAt).Exec(ctx); err != nil { diff --git a/internal/infra/billingstore/call_leg_usage_store_test.go b/internal/infra/billingstore/call_leg_usage_store_test.go index 741adc56..86d3b409 100644 --- a/internal/infra/billingstore/call_leg_usage_store_test.go +++ b/internal/infra/billingstore/call_leg_usage_store_test.go @@ -2,6 +2,7 @@ package billingstore import ( "context" + "database/sql" "encoding/json" "errors" "strings" @@ -26,6 +27,7 @@ func testIndependentCallLegFor(callID billing.BillingCallID, bLegID string) bill CallID: callID, ALegID: "a-shared", BLegID: bLegID, + AttemptSeq: 1, BackendID: "backend-a", ProviderID: "provider-a", ModelID: "model-a", @@ -238,9 +240,10 @@ func runAppendCallLegUsageRejectedNeverStartedEvidenceUnavailable(t *testing.T, {name: "never_started", bLegID: "b-never", outcome: billing.LegOutcomeNeverStarted, surfaced: billing.SurfacedNo}, {name: "evidence_unavailable", bLegID: "b-no-ev", outcome: billing.LegOutcomeFailed, surfaced: billing.SurfacedNo}, } - for _, tc := range cases { + for seqIndex, tc := range cases { t.Run(tc.name, func(t *testing.T) { src := testIndependentCallLegFor(callID, tc.bLegID) + src.AttemptSeq = seqIndex + 1 src.Outcome = tc.outcome src.Surfaced = tc.surfaced src.Evidence = billing.FinalBillingEvidence{ @@ -275,9 +278,11 @@ func runAppendCallLegUsagePreservesQuantityAndCostPresence(t *testing.T, store * t.Fatal(err) } absent := testIndependentCallLegFor(callID, "b-absent") + absent.AttemptSeq = 1 absent.Evidence.InputTokens = billing.Quantity{} absent.Evidence.Cost = billing.MoneyEvidence{} zero := testIndependentCallLegFor(callID, "b-zero") + zero.AttemptSeq = 2 zero.Evidence.InputTokens = billing.Quantity{Present: true} zero.Evidence.Cost = billing.MoneyEvidence{Currency: "USD", Present: true} if err := store.AppendCallLegUsage(ctx, absent); err != nil { @@ -392,3 +397,174 @@ func mustCallLegKey(t *testing.T, callID billing.BillingCallID, bLegID string) s } return key } + +func TestSQLiteAppendCallLegUsagePersistsAttemptSequence(t *testing.T) { + runAppendCallLegUsagePersistsAttemptSequence(t, newSQLiteTestStore(t)) +} + +func TestSQLiteAppendCallLegUsageRejectsDuplicateAttemptSequenceWithinCall(t *testing.T) { + runAppendCallLegUsageRejectsDuplicateAttemptSequenceWithinCall(t, newSQLiteTestStore(t)) +} + +func TestSQLiteAppendCallLegUsageReplayConflictOnChangedSequence(t *testing.T) { + runAppendCallLegUsageReplayConflictOnChangedSequence(t, newSQLiteTestStore(t)) +} + +func TestSQLiteLegacyNullAttemptSequenceRowsRemainReadable(t *testing.T) { + runLegacyNullAttemptSequenceRowsRemainReadable(t, newSQLiteTestStore(t)) +} + +func runAppendCallLegUsagePersistsAttemptSequence(t *testing.T, store *DurableStore) { + t.Helper() + ctx := context.Background() + callID, err := billing.NewBillingCallID() + if err != nil { + t.Fatal(err) + } + record := testIndependentCallLegFor(callID, "b-1") + record.AttemptSeq = 3 + if err := store.AppendCallLegUsage(ctx, record); err != nil { + t.Fatalf("AppendCallLegUsage: %v", err) + } + var attemptSeq sql.NullInt64 + key := mustCallLegKey(t, callID, "b-1") + if err := store.db.NewRaw(`SELECT attempt_seq FROM usage_leg_records WHERE usage_leg_key = ?`, key).Scan(ctx, &attemptSeq); err != nil { + t.Fatal(err) + } + if !attemptSeq.Valid || attemptSeq.Int64 != 3 { + t.Fatalf("persisted attempt_seq = %+v, want 3", attemptSeq) + } + got, err := store.GetCallLegUsage(ctx, key) + if err != nil { + t.Fatal(err) + } + if got.AttemptSeq != 3 { + t.Fatalf("restored AttemptSeq = %d, want 3 (no inference)", got.AttemptSeq) + } +} + +func runAppendCallLegUsageRejectsDuplicateAttemptSequenceWithinCall(t *testing.T, store *DurableStore) { + t.Helper() + ctx := context.Background() + callID, err := billing.NewBillingCallID() + if err != nil { + t.Fatal(err) + } + first := testIndependentCallLegFor(callID, "b-first") + first.AttemptSeq = 2 + if err := store.AppendCallLegUsage(ctx, first); err != nil { + t.Fatalf("first append: %v", err) + } + second := testIndependentCallLegFor(callID, "b-second") + second.AttemptSeq = 2 // same positive sequence in the same call -> conflict + if err := store.AppendCallLegUsage(ctx, second); !errors.Is(err, ErrLegAttemptSequenceConflict) { + t.Fatalf("duplicate attempt_seq append = %v, want ErrLegAttemptSequenceConflict", err) + } + var rows int + if err := store.db.NewRaw(`SELECT COUNT(1) FROM usage_leg_records WHERE call_id = ?`, callID.String()).Scan(ctx, &rows); err != nil { + t.Fatal(err) + } + if rows != 1 { + t.Fatalf("conflicting rows = %d, want 1", rows) + } + // A different positive sequence in the same call is fine. + third := testIndependentCallLegFor(callID, "b-third") + third.AttemptSeq = 3 + if err := store.AppendCallLegUsage(ctx, third); err != nil { + t.Fatalf("distinct sequence append: %v", err) + } +} + +func runAppendCallLegUsageReplayConflictOnChangedSequence(t *testing.T, store *DurableStore) { + t.Helper() + ctx := context.Background() + callID, err := billing.NewBillingCallID() + if err != nil { + t.Fatal(err) + } + record := testIndependentCallLegFor(callID, "b-1") + record.AttemptSeq = 1 + if err := store.AppendCallLegUsage(ctx, record); err != nil { + t.Fatalf("append: %v", err) + } + if err := store.AppendCallLegUsage(ctx, record); err != nil { + t.Fatalf("identical replay: %v", err) + } + changed := record + changed.AttemptSeq = 2 + if err := store.AppendCallLegUsage(ctx, changed); !errors.Is(err, billing.ErrReplayConflict) { + t.Fatalf("same-key changed-sequence replay = %v, want ErrReplayConflict", err) + } + got, err := store.GetCallLegUsage(ctx, mustCallLegKey(t, callID, "b-1")) + if err != nil { + t.Fatal(err) + } + if got.AttemptSeq != 1 { + t.Fatalf("AttemptSeq after conflict = %d, want original 1", got.AttemptSeq) + } +} + +func runLegacyNullAttemptSequenceRowsRemainReadable(t *testing.T, store *DurableStore) { + t.Helper() + ctx := context.Background() + callID, err := billing.NewBillingCallID() + if err != nil { + t.Fatal(err) + } + for _, bLegID := range []string{"b-legacy-1", "b-legacy-2"} { + // Simulate a pre-fix row: v1-sealed payload (no sequence) persisted + // with attempt_seq NULL. + legacySrc := testIndependentCallLegFor(callID, bLegID) + legacySrc.AttemptSeq = 0 + legacy, sealErr := legacySrc.Seal() + if sealErr != nil { + t.Fatal(sealErr) + } + if legacy.AttemptSeq != 0 { + t.Fatalf("legacy fixture AttemptSeq = %d, want 0", legacy.AttemptSeq) + } + payload, marshalErr := json.Marshal(legacy) + if marshalErr != nil { + t.Fatal(marshalErr) + } + sealedAt := time.Now().UTC() + if _, err := store.db.NewRaw(`INSERT INTO usage_leg_records( usage_leg_key, fingerprint, call_id, a_leg_id, b_leg_id, backend_id, provider_id, model_id, started_at, finished_at, outcome, surfaced, payload_json, sealed_at ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, legacy.Key, legacy.Fingerprint, legacy.CallID.String(), legacy.ALegID, legacy.BLegID, legacy.BackendID, legacy.ProviderID, legacy.ModelID, legacy.StartedAt, legacy.FinishedAt, string(legacy.Outcome), string(legacy.Surfaced), string(payload), sealedAt).Exec(ctx); err != nil { + t.Fatalf("legacy insert: %v", err) + } + // NULL sequences may coexist with no guessed values. + var attemptSeq sql.NullString + if err := store.db.NewRaw(`SELECT attempt_seq FROM usage_leg_records WHERE usage_leg_key = ?`, legacy.Key).Scan(ctx, &attemptSeq); err != nil { + t.Fatal(err) + } + if attemptSeq.Valid { + t.Fatalf("legacy attempt_seq = %q, want NULL", attemptSeq.String) + } + got, err := store.GetCallLegUsage(ctx, legacy.Key) + if err != nil { + t.Fatalf("legacy row must remain readable under the old contract: %v", err) + } + if got.AttemptSeq != 0 { + t.Fatalf("legacy AttemptSeq = %d, want 0 (unknown)", got.AttemptSeq) + } + } + legs, err := store.ListCallLegUsage(ctx, callID) + if err != nil { + t.Fatal(err) + } + if len(legs) != 2 { + t.Fatalf("legacy legs = %d, want 2", len(legs)) + } + // A new corrected leg (positive sequence) can join the same call. + newer := testIndependentCallLegFor(callID, "b-new") + newer.AttemptSeq = 1 + if err := store.AppendCallLegUsage(ctx, newer); err != nil { + t.Fatalf("new leg alongside legacy rows: %v", err) + } + got, err := store.GetCallLegUsage(ctx, mustCallLegKey(t, callID, "b-new")) + if err != nil { + t.Fatal(err) + } + if got.AttemptSeq != 1 { + t.Fatalf("new leg AttemptSeq = %d, want 1", got.AttemptSeq) + } +} diff --git a/internal/infra/billingstore/call_usage_store_test.go b/internal/infra/billingstore/call_usage_store_test.go index 4115fe48..f55add0c 100644 --- a/internal/infra/billingstore/call_usage_store_test.go +++ b/internal/infra/billingstore/call_usage_store_test.go @@ -309,6 +309,12 @@ func runClaimCompleteCallIndependentOfAppendOrder(t *testing.T, store *DurableSt } case strings.HasPrefix(step, "leg:"): leg := testIndependentCallLegFor(callID, strings.TrimPrefix(step, "leg:")) + // Real B2BUA allocation order: b-fail is attempt 1, b-win is attempt 2. + if strings.HasPrefix(step, "leg:b-fail") { + leg.AttemptSeq = 1 + } else { + leg.AttemptSeq = 2 + } if err := store.AppendCallLegUsage(ctx, leg); err != nil { t.Fatalf("append %s: %v", step, err) } diff --git a/internal/infra/billingstore/legacy_sequence_worker_test.go b/internal/infra/billingstore/legacy_sequence_worker_test.go new file mode 100644 index 00000000..d88bb399 --- /dev/null +++ b/internal/infra/billingstore/legacy_sequence_worker_test.go @@ -0,0 +1,246 @@ +package billingstore + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/matdev83/go-llm-interactive-proxy/internal/core/billing" + "github.com/matdev83/go-llm-interactive-proxy/internal/infra/billingcompose" +) + +func TestLegacySequenceWorkerAmbiguity(t *testing.T) { + t.Parallel() + store := newSQLiteTestStore(t) + ctx := context.Background() + + // 1. Create a prepaid account + accountID := "legacy-seq-acct" + account := billing.Account{ + ID: accountID, + Currency: "USD", + Mode: billing.AccountPrepaid, + BalanceNano: 1000000, + State: billing.AccountReady, + Version: 1, + } + if err := store.CreateAccount(ctx, account); err != nil { + t.Fatal(err) + } + + // 2. Setup snapshot catalog with defaults and charge-all policy pricing. + pricing := billing.PricingSnapshot{ + Ref: billing.VersionRef{ID: "prices", Version: "v1"}, + Currency: "USD", + InputPerMillionNano: 100, + OutputPerMillionNano: 200, + InputRatePresent: true, + OutputRatePresent: true, + FixedCharges: []billing.ChargeComponent{{Name: "request", Amount: billing.Money{Nano: 10, Currency: "USD"}}}, + } + policySurfaced := billing.ChargePolicy{ + Ref: billing.VersionRef{ID: "policy-surfaced", Version: "v1"}, + PricingRef: pricing.Ref, + Scope: billing.ChargeSurfacedTurn, + IncludeInputTokens: true, + IncludeOutputTokens: true, + IncludeFixedCharges: true, + } + policyChargeAll := billing.ChargePolicy{ + Ref: billing.VersionRef{ID: "policy-chargeall", Version: "v1"}, + PricingRef: pricing.Ref, + Scope: billing.ChargeAllPotentialLegs, + IncludeInputTokens: true, + IncludeOutputTokens: true, + IncludeFixedCharges: true, + } + + catalog := billingcompose.NewSnapshotCatalog() + if err := catalog.PutPricing(pricing); err != nil { + t.Fatal(err) + } + if err := catalog.PutPolicy(policySurfaced); err != nil { + t.Fatal(err) + } + if err := catalog.PutPolicy(policyChargeAll); err != nil { + t.Fatal(err) + } + + resolver, err := billingcompose.NewCallRatingResolver(catalog) + if err != nil { + t.Fatal(err) + } + + worker, err := billing.NewCallPostUsageWorker(store, store, resolver, 10) + if err != nil { + t.Fatal(err) + } + + // Helper to create a call with legacy null-sequence legs. + createLegacyCall := func(callID billing.BillingCallID, outcome billing.TurnOutcome, policyRef billing.VersionRef, legs []billing.CallLegUsageRecord) { + expectedIDs := make([]string, 0, len(legs)) + for _, l := range legs { + expectedIDs = append(expectedIDs, l.BLegID) + } + call := billing.CallUsageRecord{ + SchemaVersion: billing.CurrentRecordSchemaVersion, + CallID: callID, + AccountID: accountID, + ALegID: "a-leg", + SessionID: "sess", + StartedAt: time.Unix(100, 0).UTC(), + FinishedAt: time.Unix(101, 0).UTC(), + Outcome: outcome, + CustomerPricingRef: pricing.Ref, + ChargePolicyRef: policyRef, + ExpectedBLegIDs: expectedIDs, + } + if err := store.AppendCallUsage(ctx, call); err != nil { + t.Fatal(err) + } + for _, l := range legs { + if err := store.AppendCallLegUsage(ctx, l); err != nil { + t.Fatal(err) + } + } + _, err := store.AdmitExposure(ctx, billing.AdmitExposureInput{ + AccountID: accountID, + CallID: callID.String(), + Max: billing.Money{Nano: 100000, Currency: "USD"}, + PricingRef: pricing.Ref, + ChargePolicyRef: policyRef, + }) + if err != nil { + t.Fatal(err) + } + } + + // --- Case A: Completed surfaced sequence-independent case should settle --- + callIDA, err := billing.NewBillingCallID() + if err != nil { + t.Fatal(err) + } + legsA := []billing.CallLegUsageRecord{ + { + CallID: callIDA, ALegID: "a-leg", BLegID: "b-1", AttemptSeq: 0, + BackendID: "back", ProviderID: "prov", ModelID: "model", + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(100, 500000000).UTC(), + Outcome: billing.LegOutcomeWinner, Surfaced: billing.SurfacedNo, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 1000, Present: true}, OutputTokens: billing.Quantity{Value: 0, Present: true}, Source: billing.EvidenceSourceProviderReported, Authority: billing.EvidenceAuthorityAuthoritative, + }, + }, + { + CallID: callIDA, ALegID: "a-leg", BLegID: "b-2", AttemptSeq: 0, + BackendID: "back", ProviderID: "prov", ModelID: "model", + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(100, 500000000).UTC(), + Outcome: billing.LegOutcomeWinner, Surfaced: billing.SurfacedYes, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 2000, Present: true}, OutputTokens: billing.Quantity{Value: 0, Present: true}, Source: billing.EvidenceSourceProviderReported, Authority: billing.EvidenceAuthorityAuthoritative, + }, + }, + } + createLegacyCall(callIDA, billing.TurnOutcomeCompleted, policySurfaced.Ref, legsA) + + // --- Case B: Charge-all policy sequence-independent case should settle --- + callIDB, err := billing.NewBillingCallID() + if err != nil { + t.Fatal(err) + } + legsB := []billing.CallLegUsageRecord{ + { + CallID: callIDB, ALegID: "a-leg", BLegID: "b-1", AttemptSeq: 0, + BackendID: "back", ProviderID: "prov", ModelID: "model", + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(100, 500000000).UTC(), + Outcome: billing.LegOutcomeWinner, Surfaced: billing.SurfacedNo, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 1000, Present: true}, OutputTokens: billing.Quantity{Value: 0, Present: true}, Source: billing.EvidenceSourceProviderReported, Authority: billing.EvidenceAuthorityAuthoritative, + }, + }, + { + CallID: callIDB, ALegID: "a-leg", BLegID: "b-2", AttemptSeq: 0, + BackendID: "back", ProviderID: "prov", ModelID: "model", + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(100, 500000000).UTC(), + Outcome: billing.LegOutcomeWinner, Surfaced: billing.SurfacedNo, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 2000, Present: true}, OutputTokens: billing.Quantity{Value: 0, Present: true}, Source: billing.EvidenceSourceProviderReported, Authority: billing.EvidenceAuthorityAuthoritative, + }, + }, + } + createLegacyCall(callIDB, billing.TurnOutcomeCanceled, policyChargeAll.Ref, legsB) + + // --- Case C: Sequence-dependent ambiguous case should fail and reconcile --- + callIDC, err := billing.NewBillingCallID() + if err != nil { + t.Fatal(err) + } + legsC := []billing.CallLegUsageRecord{ + { + CallID: callIDC, ALegID: "a-leg", BLegID: "b-1", AttemptSeq: 0, + BackendID: "back", ProviderID: "prov", ModelID: "model", + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(100, 500000000).UTC(), + Outcome: billing.LegOutcomeWinner, Surfaced: billing.SurfacedNo, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 1000, Present: true}, OutputTokens: billing.Quantity{Value: 0, Present: true}, Source: billing.EvidenceSourceProviderReported, Authority: billing.EvidenceAuthorityAuthoritative, + }, + }, + { + CallID: callIDC, ALegID: "a-leg", BLegID: "b-2", AttemptSeq: 0, + BackendID: "back", ProviderID: "prov", ModelID: "model", + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(100, 500000000).UTC(), + Outcome: billing.LegOutcomeWinner, Surfaced: billing.SurfacedNo, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 2000, Present: true}, OutputTokens: billing.Quantity{Value: 0, Present: true}, Source: billing.EvidenceSourceProviderReported, Authority: billing.EvidenceAuthorityAuthoritative, + }, + }, + } + createLegacyCall(callIDC, billing.TurnOutcomeCanceled, policySurfaced.Ref, legsC) + + before, err := store.GetAccount(ctx, accountID) + if err != nil { + t.Fatal(err) + } + if before.BalanceNano != account.BalanceNano { + t.Fatalf("balance before worker = %d, want %d", before.BalanceNano, account.BalanceNano) + } + + err = worker.ProcessOnce(ctx) + if !errors.Is(err, billing.ErrBillingAttemptSequenceUnknown) { + t.Fatalf("ProcessOnce error = %v, want ErrBillingAttemptSequenceUnknown", err) + } + + // Verify Case A status + var statusA string + if err := store.db.NewRaw(`SELECT claim_status FROM usage_call_records WHERE call_id = ?`, callIDA.String()).Scan(ctx, &statusA); err != nil { + t.Fatal(err) + } + if statusA != "processed" { + t.Errorf("Case A (completed surfaced) claim_status = %q, want 'processed'", statusA) + } + + // Verify Case B status + var statusB string + if err := store.db.NewRaw(`SELECT claim_status FROM usage_call_records WHERE call_id = ?`, callIDB.String()).Scan(ctx, &statusB); err != nil { + t.Fatal(err) + } + if statusB != "processed" { + t.Errorf("Case B (charge all policy) claim_status = %q, want 'processed'", statusB) + } + after, err := store.GetAccount(ctx, accountID) + if err != nil { + t.Fatal(err) + } + if after.BalanceNano != account.BalanceNano-30 { + t.Fatalf("balance after settled Cases A and B = %d, want %d", after.BalanceNano, account.BalanceNano-30) + } + + // Verify Case C status + var statusC string + if err := store.db.NewRaw(`SELECT claim_status FROM usage_call_records WHERE call_id = ?`, callIDC.String()).Scan(ctx, &statusC); err != nil { + t.Fatal(err) + } + if statusC != "reconcile_required" { + t.Errorf("Case C (ambiguous) claim_status = %q, want 'reconcile_required'", statusC) + } +} diff --git a/internal/infra/billingstore/operator_rate_failure_independence_test.go b/internal/infra/billingstore/operator_rate_failure_independence_test.go new file mode 100644 index 00000000..0e62e7b6 --- /dev/null +++ b/internal/infra/billingstore/operator_rate_failure_independence_test.go @@ -0,0 +1,175 @@ +package billingstore + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/matdev83/go-llm-interactive-proxy/internal/core/billing" + "github.com/matdev83/go-llm-interactive-proxy/internal/infra/billingcompose" +) + +// Phase 2.4 — customer settlement and exposure close must succeed even when +// one or more operator-rate lookups fail. Provider-cost work then remains +// pending/unreconciled and never alters the customer posting. + +func TestSQLiteCustomerSettlementClosesWhileOperatorRateLookupFails(t *testing.T) { + t.Parallel() + store := newSQLiteTestStore(t) + ctx := context.Background() + + account := billing.Account{ID: "op-rate-fail-customer", Currency: "USD", Mode: billing.AccountPrepaid, BalanceNano: 1000, State: billing.AccountReady, Version: 1} + if err := store.CreateAccount(ctx, account); err != nil { + t.Fatal(err) + } + + // Catalog publishes customer pricing/policy defaults only. No operator rate + // is published for the legacy operator-rates refs, and no route/model + // pricing overrides exist. + pricing := billing.PricingSnapshot{ + Ref: billing.VersionRef{ID: "prices", Version: "v1"}, + Currency: "USD", + InputPerMillionNano: 100, + OutputPerMillionNano: 200, + InputRatePresent: true, + OutputRatePresent: true, + FixedCharges: []billing.ChargeComponent{{Name: "request", Amount: billing.Money{Nano: 3, Currency: "USD"}}}, + } + policy := billing.ChargePolicy{ + Ref: billing.VersionRef{ID: "policy", Version: "v2"}, + PricingRef: pricing.Ref, + Scope: billing.ChargeSurfacedTurn, + IncludeInputTokens: true, + IncludeOutputTokens: true, + IncludeFixedCharges: true, + } + c := billingcompose.NewSnapshotCatalog() + if err := c.PutPricing(pricing); err != nil { + t.Fatal(err) + } + if err := c.PutPolicy(policy); err != nil { + t.Fatal(err) + } + if err := c.SetDefaults(pricing.Ref, policy.Ref); err != nil { + t.Fatal(err) + } + + callID, err := billing.NewBillingCallID() + if err != nil { + t.Fatal(err) + } + call := billing.CallUsageRecord{ + SchemaVersion: billing.CurrentRecordSchemaVersion, CallID: callID, AccountID: account.ID, + ALegID: "a-shared", SessionID: "sess-1", StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(101, 0).UTC(), + Outcome: billing.TurnOutcomeCompleted, CustomerPricingRef: pricing.Ref, ChargePolicyRef: policy.Ref, + ExpectedBLegIDs: []string{"b-1"}, + } + if err := store.AppendCallUsage(ctx, call); err != nil { + t.Fatal(err) + } + + // The leg carries token evidence (so provider cost requires an operator + // rate) but references an operator-rate version that does not exist in the + // catalog. Customer rating must be wholly unaffected. + leg := billing.CallLegUsageRecord{ + CallID: callID, ALegID: "a-shared", BLegID: "b-1", AttemptSeq: 1, + BackendID: "backend-a", ProviderID: "provider-a", ModelID: "model-a", + StartedAt: time.Unix(100, 0).UTC(), FinishedAt: time.Unix(100, 500000000).UTC(), + Outcome: billing.LegOutcomeWinner, Surfaced: billing.SurfacedYes, + Evidence: billing.FinalBillingEvidence{ + InputTokens: billing.Quantity{Value: 1_000_000, Present: true}, + OutputTokens: billing.Quantity{Value: 0, Present: true}, + Source: billing.EvidenceSourceProviderReported, + Authority: billing.EvidenceAuthorityAuthoritative, + }, + OperatorRateRef: billing.VersionRef{ID: "operator-rates", Version: "missing"}, + } + if err := store.AppendCallLegUsage(ctx, leg); err != nil { + t.Fatal(err) + } + + exposure, err := store.AdmitExposure(ctx, billing.AdmitExposureInput{ + AccountID: account.ID, CallID: callID.String(), Max: billing.Money{Nano: 1000, Currency: "USD"}, + PricingRef: pricing.Ref, ChargePolicyRef: policy.Ref, + }) + if err != nil { + t.Fatal(err) + } + + sealedClosure, err := call.Seal() + if err != nil { + t.Fatal(err) + } + sealedLeg, err := leg.Seal() + if err != nil { + t.Fatal(err) + } + + // 1. Customer settlement through the real compose resolver: must succeed + // with default pricing (1,000,000 input * 100/million + 3 fixed = 103). + resolver, err := billingcompose.NewCallRatingResolver(c) + if err != nil { + t.Fatal(err) + } + result, err := resolver.ResolveCallRating(ctx, billing.CompleteCall{Closure: sealedClosure, Legs: []billing.CallLegUsageRecord{sealedLeg}}, exposure) + if err != nil { + t.Fatalf("customer rating failed while operator rate is missing: %v", err) + } + if got, want := result.CustomerCharge.Nano, int64(103); got != want { + t.Fatalf("customer charge = %d, want %d", got, want) + } + if _, err := store.ApplyCallBillingResult(ctx, billing.ApplyCallBillingInput{Call: sealedClosure, Exposure: exposure, Result: result}); err != nil { + t.Fatal(err) + } + + got, err := store.GetAccount(ctx, account.ID) + if err != nil { + t.Fatal(err) + } + if got.BalanceNano != 897 { + t.Fatalf("balance after customer settlement = %d, want 897", got.BalanceNano) + } + var status string + if err := store.db.NewRaw(`SELECT status FROM call_exposures WHERE call_id = ?`, callID.String()).Scan(ctx, &status); err != nil { + t.Fatal(err) + } + if status != "closed" { + t.Fatalf("exposure status = %q, want closed after customer settlement despite missing operator rate", status) + } + + // 2. Provider-cost resolution for the same leg fails closed independently. + providerResolver, err := billingcompose.NewProviderCostResolver(c, "USD") + if err != nil { + t.Fatal(err) + } + if _, err := providerResolver.ResolveProviderCost(ctx, sealedLeg); !errors.Is(err, billing.ErrUnreconciledCost) { + t.Fatalf("provider cost = err %v, want ErrUnreconciledCost", err) + } + + // 3. Provider-cost work stays pending and must not touch the settled + // customer account or reopen exposure. + pending, err := store.ListPendingProviderCostWork(ctx, 10) + if err != nil { + t.Fatal(err) + } + if len(pending) != 1 { + t.Fatalf("pending provider-cost work = %d, want 1 (unreconciled work remains queued)", len(pending)) + } + if pending[0].Leg.OperatorRateRef != leg.OperatorRateRef { + t.Fatalf("pending work leg = %+v, want original operator rate ref %+v", pending[0].Leg, leg.OperatorRateRef) + } + after, err := store.GetAccount(ctx, account.ID) + if err != nil { + t.Fatal(err) + } + if after.BalanceNano != 897 || after.Version != got.Version { + t.Fatalf("provider-cost failure altered customer posting: before=%+v after=%+v", got, after) + } + if err := store.db.NewRaw(`SELECT status FROM call_exposures WHERE call_id = ?`, callID.String()).Scan(ctx, &status); err != nil { + t.Fatal(err) + } + if status != "closed" { + t.Fatalf("exposure status = %q, want still closed after failed provider-cost lookup", status) + } +} diff --git a/internal/infra/billingstore/postgres_sequence_integration_test.go b/internal/infra/billingstore/postgres_sequence_integration_test.go new file mode 100644 index 00000000..395b5931 --- /dev/null +++ b/internal/infra/billingstore/postgres_sequence_integration_test.go @@ -0,0 +1,42 @@ +//go:build integration + +package billingstore + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/matdev83/go-llm-interactive-proxy/internal/infra/db" + "github.com/matdev83/go-llm-interactive-proxy/internal/testkit" +) + +// TestPostgresCallLegSequencePersistence proves SQLite/PostgreSQL parity for +// the nullable attempt_seq migration: explicit sequence persistence and +// restore, sequence-uniqueness conflicts within one call, replay conflicts on a +// changed sequence, and legacy NULL-sequence row readability under the old v1 +// fingerprint contract. Skips cleanly when the integration DSN is absent. +func TestPostgresCallLegSequencePersistence(t *testing.T) { + dsn := testkit.SkipUnlessPostgres(t) + ctx, cancel := context.WithTimeout(context.Background(), db.DefaultPostgresOpenMigrateTimeout) + defer cancel() + bunDB, err := db.OpenPostgresBun(ctx, dsn, db.PoolSettings{MaxOpenConns: 4, MaxIdleConns: 4}) + if err != nil { + t.Fatal(err) + } + store, err := NewDurableStore(ctx, bunDB, Config{StoreID: "sequence-postgres"}) + if err != nil { + _ = bunDB.Close() + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + if err := VerifySchema(ctx, store.db); err != nil { + t.Fatalf("VerifySchema: %v", err) + } + runAppendCallLegUsagePersistsAttemptSequence(t, store) + runAppendCallLegUsageRejectsDuplicateAttemptSequenceWithinCall(t, store) + runAppendCallLegUsageReplayConflictOnChangedSequence(t, store) + runLegacyNullAttemptSequenceRowsRemainReadable(t, store) + runBillingStoreContract(t, store, fmt.Sprintf("sequence-contract-%d", time.Now().UnixNano())) +} diff --git a/internal/infra/billingstore/provider_cost_independence_test.go b/internal/infra/billingstore/provider_cost_independence_test.go index bbc8e5dc..37a359aa 100644 --- a/internal/infra/billingstore/provider_cost_independence_test.go +++ b/internal/infra/billingstore/provider_cost_independence_test.go @@ -108,7 +108,9 @@ func TestSQLiteCustomerSettlementIndependentOfProviderCostOrdering(t *testing.T) t.Fatal(err) } leg1 := testIndependentCallLegFor(callID, "b-1") + leg1.AttemptSeq = 1 leg2 := testIndependentCallLegFor(callID, "b-2") + leg2.AttemptSeq = 2 if err := store.AppendCallLegUsage(ctx, leg1); err != nil { t.Fatal(err) } diff --git a/internal/infra/billingstore/store.go b/internal/infra/billingstore/store.go index 180645c5..68d8acbb 100644 --- a/internal/infra/billingstore/store.go +++ b/internal/infra/billingstore/store.go @@ -12,7 +12,7 @@ import ( "github.com/uptrace/bun/dialect" ) -var RequiredMigrationNames = []string{BaselineMigrationName, LegacyAuthorizationSchemaMigrationName, Phase4MigrationName, Phase6MigrationName, Phase7MigrationName, SessionIDMigrationName, UsageLegRecordsMigrationName, UsageCallRecordsMigrationName, ProviderCostWorkMigrationName, ProviderCostWorkRetryMigrationName, ExposureMigrationName, HoldRetirementMigrationName, UsageAppendOutboxMigrationName, AuthorizationHoldsDropMigrationName, ReservedNanoZeroMigrationName, CompleteCallClaimLeaseMigrationName} +var RequiredMigrationNames = []string{BaselineMigrationName, LegacyAuthorizationSchemaMigrationName, Phase4MigrationName, Phase6MigrationName, Phase7MigrationName, SessionIDMigrationName, UsageLegRecordsMigrationName, UsageCallRecordsMigrationName, ProviderCostWorkMigrationName, ProviderCostWorkRetryMigrationName, ExposureMigrationName, HoldRetirementMigrationName, UsageAppendOutboxMigrationName, AuthorizationHoldsDropMigrationName, ReservedNanoZeroMigrationName, CompleteCallClaimLeaseMigrationName, UsageLegSequenceMigrationName} type Config struct { StoreID string @@ -66,7 +66,7 @@ func VerifySchema(ctx context.Context, database *bun.DB) error { return fmt.Errorf("billingstore: migration %s is not recorded", migrationName) } } - for _, index := range []string{"idx_billing_processing_status", "idx_billing_journal_account_sequence", "idx_billing_journal_source", journalReversalUniqueIndex, sessionAccountIndex, usageLegCallBLegIndex, usageCallCallIDIndex, usageCallAccountSessionIndex, usageCallClaimStatusIndex, usageCallClaimPendingIndex, providerCostWorkStatusIndex, providerCostWorkPendingIndex, exposureAccountStatusIndex} { + for _, index := range []string{"idx_billing_processing_status", "idx_billing_journal_account_sequence", "idx_billing_journal_source", journalReversalUniqueIndex, sessionAccountIndex, usageLegCallBLegIndex, usageLegCallAttemptSeqIndex, usageCallCallIDIndex, usageCallAccountSessionIndex, usageCallClaimStatusIndex, usageCallClaimPendingIndex, providerCostWorkStatusIndex, providerCostWorkPendingIndex, exposureAccountStatusIndex} { var name string if err := database.NewRaw(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`, index).Scan(ctx, &name); err != nil || name != index { if err != nil { @@ -82,7 +82,7 @@ func VerifySchema(ctx context.Context, database *bun.DB) error { "billing_account_policy_events": {"FOREIGN KEY(account_id) REFERENCES billing_accounts", "UNIQUE(account_id, source_key)"}, "turn_usage_records": {"CHECK", "UNIQUE(account_id, turn_id)", "FOREIGN KEY(account_id) REFERENCES billing_accounts", "session_id"}, "leg_usage_records": {"CHECK", "UNIQUE(tur_key, b_leg_id)", "UNIQUE(tur_key, sequence)", "FOREIGN KEY(tur_key) REFERENCES turn_usage_records"}, - "usage_leg_records": {"usage_leg_key", "call_id", "b_leg_id", "payload_json", "fingerprint"}, + "usage_leg_records": {"usage_leg_key", "call_id", "b_leg_id", "attempt_seq", "payload_json", "fingerprint"}, "provider_cost_work": {"usage_leg_key", "call_id", "status", "attempt_count", "next_attempt_at", "last_error", "updated_at"}, "usage_call_records": {"usage_call_key", "call_id", "account_id", "a_leg_id", "session_id", "expected_b_leg_ids", "payload_json", "fingerprint", "claim_status", "claim_attempt_count", "next_claim_at", "last_claim_error"}, "call_exposures": {"exposure_key", "account_id", "call_id", "max_exposure_nano", "pricing_ref", "charge_policy_ref", "fingerprint", "status", "FOREIGN KEY(account_id) REFERENCES billing_accounts", "UNIQUE(account_id, call_id)"}, @@ -137,6 +137,9 @@ func VerifySchema(ctx context.Context, database *bun.DB) error { {"TUR session index", `SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND indexname = ? LIMIT 1`, []any{sessionAccountIndex}, []string{"account_id", "session_id", "tur_key"}}, {"usage leg table", `SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'usage_leg_records' LIMIT 1`, nil, []string{"usage_leg_records"}}, {"usage leg CallID/BLegID unique index", `SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND indexname = ? LIMIT 1`, []any{usageLegCallBLegIndex}, []string{"UNIQUE", "call_id", "b_leg_id"}}, + {"usage leg attempt seq column", `SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'usage_leg_records' AND column_name = 'attempt_seq' LIMIT 1`, nil, []string{"attempt_seq"}}, + {"usage leg CallID/attempt_seq unique index", `SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND indexname = ? LIMIT 1`, []any{usageLegCallAttemptSeqIndex}, []string{"UNIQUE", "call_id", "attempt_seq"}}, + {"usage leg sequence migration history", `SELECT name FROM bun_billing_migrations WHERE name = ? LIMIT 1`, []any{UsageLegSequenceMigrationName}, []string{UsageLegSequenceMigrationName}}, {"usage call table", `SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'usage_call_records' LIMIT 1`, nil, []string{"usage_call_records"}}, {"usage call CallID unique index", `SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND indexname = ? LIMIT 1`, []any{usageCallCallIDIndex}, []string{"UNIQUE", "call_id"}}, {"usage call account/session index", `SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND indexname = ? LIMIT 1`, []any{usageCallAccountSessionIndex}, []string{"account_id", "session_id", "call_id"}}, diff --git a/internal/infra/billingstore/unique.go b/internal/infra/billingstore/unique.go index 3cf98ac2..b54594b9 100644 --- a/internal/infra/billingstore/unique.go +++ b/internal/infra/billingstore/unique.go @@ -2,6 +2,7 @@ package billingstore import ( "errors" + "strings" "github.com/uptrace/bun/driver/pgdriver" "modernc.org/sqlite" @@ -25,3 +26,14 @@ func isUniqueViolation(err error) bool { } return false } + +// isLegAttemptSeqConflict reports a unique violation raised by the +// (call_id, attempt_seq) uniqueness contract rather than the key or +// (call_id, b_leg_id) replay identities. +func isLegAttemptSeqConflict(err error) bool { + if !isUniqueViolation(err) { + return false + } + lower := strings.ToLower(err.Error()) + return strings.Contains(lower, usageLegCallAttemptSeqIndex) || strings.Contains(lower, "attempt_seq") +} diff --git a/internal/infra/runtimebundle/billing_host_loop_test.go b/internal/infra/runtimebundle/billing_host_loop_test.go index f41ba96c..63176e47 100644 --- a/internal/infra/runtimebundle/billing_host_loop_test.go +++ b/internal/infra/runtimebundle/billing_host_loop_test.go @@ -282,7 +282,8 @@ func TestBillingHostLoop_MissingCatalogRefs(t *testing.T) { store := openBillingHostLoopStore(t) catalog, pricing, policy, _ := seedBillingHostLoopCatalog(t) // Do not bind the published operator-rate body. Stamp a VersionRef that was - // never Put so SnapshotsFor fails closed after admission still succeeds. + // never Put: the customer path must settle while provider-cost resolution + // stays unreconciled for the same leg. identity := billingcompose.PrincipalSessionIdentity(billingcompose.SnapshotRefFuncs{ CustomerPricingRef: catalog.CustomerPricingRef, @@ -350,26 +351,18 @@ func TestBillingHostLoop_MissingCatalogRefs(t *testing.T) { } assertNoStreamPrices(t, drainBillingHostLoopStream(t, ctx, stream)) - records := waitBillingHostLoopCallRecords(t, store, accountID) - if len(records) != 1 { - t.Fatalf("call records = %+v, want one incomplete call", records) - } - record := records[0] - assertVersionRefIdentity(t, "call CustomerPricingRef", record.CustomerPricingRef, pricing.Ref) - assertVersionRefIdentity(t, "call ChargePolicyRef", record.ChargePolicyRef, policy.Ref) - exposure, err := store.GetCallExposure(ctx, record.CallID) - if err != nil { - t.Fatalf("GetCallExposure: %v", err) - } - if !exposure.IsOpen() { - t.Fatalf("unrateable call must retain open exposure: %+v", exposure) - } - complete, err := store.ClaimCompleteCall(ctx, record.CallID) - if err != nil { - t.Fatalf("ClaimCompleteCall: %v", err) + // The customer path no longer resolves operator-rate snapshots: this call + // settles and closes exposure even though the persisted OperatorRateRef has + // no published rate. The same leg's provider-cost work stays + // pending/unreconciled and never posts COGS or alters the customer posting. + callRecord, exposure, complete := waitBillingHostLoopCall(t, store, accountID) + assertVersionRefIdentity(t, "call CustomerPricingRef", callRecord.CustomerPricingRef, pricing.Ref) + assertVersionRefIdentity(t, "call ChargePolicyRef", callRecord.ChargePolicyRef, policy.Ref) + if exposure.IsOpen() { + t.Fatalf("missing operator rate must not keep customer exposure open: %+v", exposure) } if len(complete.Legs) != 1 { - t.Fatalf("call legs = %+v, want 1", complete.Legs) + t.Fatalf("complete call legs = %+v, want 1", complete.Legs) } assertVersionRefIdentity(t, "leg OperatorRateRef", complete.Legs[0].OperatorRateRef, billingHostLoopMissingOperatorRef) @@ -377,20 +370,48 @@ func TestBillingHostLoop_MissingCatalogRefs(t *testing.T) { if err != nil { t.Fatalf("AccountReport: %v", err) } - if report.Account.BalanceNano != billingHostLoopOpeningNano { - t.Fatalf("account balance=%d, want opening %d (no invented customer charge)", - report.Account.BalanceNano, billingHostLoopOpeningNano) + wantBalance := billingHostLoopOpeningNano - billingHostLoopCustomerNano + if report.Account.BalanceNano != wantBalance { + t.Fatalf("settled balance = %d, want %d (customer settlement proceeds without operator rate)", report.Account.BalanceNano, wantBalance) } + var customerSettlements int for _, journal := range report.Transactions { - if journal.OperationKind == "customer_settlement" { - t.Fatalf("customer_settlement posted despite missing catalog refs: %+v", journal) + switch journal.OperationKind { + case "customer_call_settlement": + customerSettlements++ + if journal.Entries[0].Amount.Nano != billingHostLoopCustomerNano { + t.Fatalf("customer settlement entries = %+v, want %d", journal.Entries, billingHostLoopCustomerNano) + } + case "provider_call_cogs": + t.Fatalf("provider COGS posted despite missing operator rate: %+v", journal) } } - - for _, journal := range report.Transactions { - if journal.OperationKind == "customer_call_settlement" { - t.Fatalf("customer settlement posted despite missing catalog refs: %+v", journal) + if customerSettlements != 1 { + t.Fatalf("customer settlement transactions = %d, want exactly one", customerSettlements) + } + // Provider-cost work for the same leg remains queued for retry: it can + // never be processed (no published rate), so it keeps reappearing in the + // pending work list after each backoff while the customer posting above + // stays untouched. + var sawPending bool + deadline := time.Now().Add(6 * time.Second) + for time.Now().Before(deadline) { + pending, err := store.ListPendingProviderCostWork(ctx, 10) + if err != nil { + t.Fatal(err) + } + for _, item := range pending { + if item.Leg.OperatorRateRef == billingHostLoopMissingOperatorRef { + sawPending = true + } } + if sawPending { + break + } + time.Sleep(200 * time.Millisecond) + } + if !sawPending { + t.Fatalf("provider-cost work never reappeared as pending/unreconciled after missing operator rate") } }