Skip to content

[AIG-867] Cost/budget governance + spend attribution su OTel/multitenancy - #55

Merged
blackms merged 5 commits into
mainfrom
pm-agent/AIG-867-cost-governance
Jun 8, 2026
Merged

[AIG-867] Cost/budget governance + spend attribution su OTel/multitenancy#55
blackms merged 5 commits into
mainfrom
pm-agent/AIG-867-cost-governance

Conversation

@blackms

@blackms blackms commented Jun 7, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a cost-governance layer on top of the existing OpenTelemetry usage signal (src/observability/tracing.ts) and multi-tenancy (src/multitenancy/). It derives spend (tokens + estimated USD) from each LLM call, attributes it per tenant / workspace / project / agent-pattern, and enforces optional budget caps with a two-stage kill-switch (warn → block).

The OTel span aistack.llm.chat already exposes llm.usage.*; the spawner already calls resourceService.recordApiCall right after it — that is the single point where token usage is known, so spend is recorded there (avoiding double counting from review-loop / consensus spans).

Security default (opt-in, observe-only first)

  • config.governance.enabled defaults to false → the whole module is a no-op (mirrors guardrails AIG-868, audit AIG-635, tracing — all off by default).
  • config.governance.enforce.block defaults to false → even when enabled with a budget defined, an over-budget call is never blocked; it is only accounted and a cost.budget.warn / cost.budget.block audit event is emitted.
  • Hard blocking at 100% requires explicitly setting enforce.block: true. A mis-configured budget can never take agents offline unless an operator opts in.

Acceptance criteria

  1. Budget cap configurable per team/project/agent-patternconfig.governance.budgets[] with hierarchical scope { tenant, workspace, project, agentPattern } (glob on agent type); most-specific match wins; no budget = unlimited.
  2. Spend aggregation (tokens / estimated USD) from OTel spans, attributed per tenant/workspaceCostAggregator persists one append-only cost_ledger row per call on the shared SQLite store; tenant/workspace read from agent metadata (AIG-649); reports grouped by tenant/workspace/project/agent.
  3. Kill-switch warn → block with audit logBudgetEnforcer: warn at warnThresholdPercent (default 80%) → block at 100%; emits cost.budget.warn / cost.budget.block via the hash-chained audit log (idempotent per budget+window); throws CostBudgetExceededError before the LLM call only when enforce.block.
  4. Report/endpoint to consult spend per dimension — REST GET /api/v1/governance/{status,budgets,spend?dimension=tenant|workspace|project|agent&from&to} + aistack governance {status,budgets,report} CLI (JSON + table).
  5. Unit + integration teststests/unit/governance/{price-table,aggregator,enforcer}.test.ts and tests/integration/governance.test.ts (token aggregation, tenant attribution, warn→block, default-disabled no-op, fail-open pricing, block opt-in).

Design decisions (defaults documented in docs/GOVERNANCE.md)

  • Unit of cost = tokens (input/output separate); USD estimated via a configurable price-table per (provider, model) glob (USD per 1M tokens). Unknown model → USD 0 (fail-open) but tokens still aggregated + warn logged. Pricing is never a reason to block.
  • Scoping = tenant/workspace (from MultitenancyContext) + project + agent-pattern; resolution by specificity.
  • Kill-switch = warn 80% → block 100%, idempotent audit events per window.
  • Persistence = append-only cost_ledger on config.memory.path, inline idempotent schema (mirrors TenantService); window day|week|month|total (default month).
  • Interface = REST primary (existing web router + shared store) + minimal CLI for parity with audit/tenant.
  • Attribution site = in-code at the spawner (single llm.usage.* point). An optional GovernanceSpanProcessor is provided as a secondary path but not wired (no in-process SpanProcessor is cabled today); it must be mutually exclusive with the in-code path to avoid double counting.

Known limitations (see docs)

  • Soft cap, not an atomic quota — concurrent calls between check and record can slightly overrun.
  • Usage-bearing providers only (Anthropic/OpenAI/Ollama); CLI providers without chatResponse.usage aren't tracked.
  • cost_ledger is append-only; retention/rollup is a follow-up (indexed by (tenant, workspace, ts)).

Files (22)

New: src/governance/{index,types,price-table,aggregator,enforcer,service,span-adapter}.ts, src/web/routes/governance.ts, src/cli/commands/governance.ts, tests/unit/governance/{price-table,aggregator,enforcer}.test.ts, tests/integration/governance.test.ts, docs/GOVERNANCE.md.
Modified: src/agents/spawner.ts, src/types.ts, src/utils/config.ts, src/index.ts, src/web/routes/index.ts, src/web/server.ts, src/cli/commands/index.ts, src/cli/index.ts.

CI note

This branch does not touch .github/workflows/ci.yml (changed in parallel by another branch). The new tests run under the existing vitest unit + integration suites — no new CI step is required. Validation (typecheck / test / lint) runs in CI on this PR (the dev environment has no Node).

Manual smoke-test (relevant to AIG-866 sibling)

  1. Set governance.enabled: true in aistack.config.json with a tenant-scoped limitUsd budget and enforce.block: false.
  2. Run an agent that performs an LLM call (Anthropic/OpenAI). Verify aistack governance report --dimension tenant shows tokens + USD.
  3. Drive spend past the cap; verify cost.budget.warn then cost.budget.block in aistack audit export (with audit enabled), and that agents are not blocked.
  4. Set enforce.block: true; verify the next over-budget call throws CostBudgetExceededError before the LLM call.

Closes AIG-867

Cost-governance layer that derives spend (tokens + estimated USD) from the
existing aistack.llm.chat usage signal, attributes it per
tenant/workspace/project/agent-pattern, and enforces optional budget caps
with a warn -> block kill-switch.

- src/governance/: types, price-table (fail-open USD estimate), CostAggregator
  (append-only cost_ledger on shared SQLite), BudgetEnforcer (warn/block +
  idempotent audit events), GovernanceService facade, optional SpanProcessor.
- Spawner wiring: pre-call checkBudget (may block) + post-call recordSpend at
  the single llm.usage.* site (no double counting). No-op when disabled.
- REST /api/v1/governance/{status,budgets,spend} + aistack governance CLI.
- Config: governance schema/types; enabled=false and enforce.block=false by
  default (opt-in, observe-only first).
- Public API exports + docs/GOVERNANCE.md.
- Unit + integration tests (token aggregation, tenant attribution,
  warn->block, default-disabled no-op, fail-open pricing).

SECURITY DEFAULT: module is a no-op unless governance.enabled=true, and hard
blocking requires enforce.block=true (coherent with guardrails AIG-868).
@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@blackms, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 16 minutes and 48 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 58ef125f-c043-4fc2-9b87-c1f53e4b6b63

📥 Commits

Reviewing files that changed from the base of the PR and between de30879 and cded888.

📒 Files selected for processing (22)
  • docs/GOVERNANCE.md
  • src/agents/spawner.ts
  • src/cli/commands/governance.ts
  • src/cli/commands/index.ts
  • src/cli/index.ts
  • src/governance/aggregator.ts
  • src/governance/enforcer.ts
  • src/governance/index.ts
  • src/governance/price-table.ts
  • src/governance/service.ts
  • src/governance/span-adapter.ts
  • src/governance/types.ts
  • src/index.ts
  • src/types.ts
  • src/utils/config.ts
  • src/web/routes/governance.ts
  • src/web/routes/index.ts
  • src/web/server.ts
  • tests/integration/governance.test.ts
  • tests/unit/governance/aggregator.test.ts
  • tests/unit/governance/enforcer.test.ts
  • tests/unit/governance/price-table.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pm-agent/AIG-867-cost-governance

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

aistack PM agent added 4 commits June 8, 2026 00:09
- governance/index.ts: import RecordSpendInput as a top-level type instead of
  an inline import() type expression.
- enforcer.test.ts: capture audit() calls via vi.hoisted so the array is
  initialised before the hoisted vi.mock factory runs.
A re-export (export type { ... } from) does not bind the name within the
re-exporting module, so BudgetEvaluation.budget referencing CostBudget failed
to compile. Add a local 'import type { CostBudget }' alongside the re-export.
This was the single root cause of the Build / Type Check / docker-helm
integration failures.
@blackms
blackms marked this pull request as ready for review June 8, 2026 07:43
@blackms
blackms merged commit 790612d into main Jun 8, 2026
7 checks passed
@blackms
blackms deleted the pm-agent/AIG-867-cost-governance branch June 8, 2026 07:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant