Skip to content

feat(extension): add cost-guard — budget enforcement and cost alerts#17449

Open
miloudbelarebia wants to merge 4 commits intoopenclaw:mainfrom
miloudbelarebia:feat/cost-guard-extension
Open

feat(extension): add cost-guard — budget enforcement and cost alerts#17449
miloudbelarebia wants to merge 4 commits intoopenclaw:mainfrom
miloudbelarebia:feat/cost-guard-extension

Conversation

@miloudbelarebia
Copy link
Contributor

@miloudbelarebia miloudbelarebia commented Feb 15, 2026

Summary

  • New extension cost-guard that monitors API costs in real time and enforces budget limits
  • Listens to model.usage diagnostic events via onDiagnosticEvent() (same pattern as diagnostics-otel)
  • In-memory tracker accumulates spend per provider, per day/month
  • before_agent_start hook injects budget warnings into agent context when approaching limits
  • message_sending hook blocks responses when budget exceeded (configurable hard stop)
  • /cost slash command shows current spend and budget status
  • Configurable: daily/monthly budgets, warning threshold, per-provider caps

Motivation

Multiple community members have reported unexpected high API costs:

  • $100/month on Gemini API
  • $40 in a single morning on OpenAI

The codebase has excellent cost tracking infrastructure (diagnostic events, session-cost-usage) but zero enforcement — costs are calculated and emitted but never checked against any budget.

This extension fills that gap by adding a lightweight enforcement layer via the existing plugin system.

Configuration

# openclaw.yaml
plugins:
  cost-guard:
    dailyBudgetUsd: 5.0        # Default: $5/day
    monthlyBudgetUsd: 50.0     # Default: $50/month
    warningThreshold: 0.8      # Alert at 80% of budget
    hardStop: true              # Block responses when exceeded
    providerLimits:             # Optional per-provider caps
      anthropic:
        dailyUsd: 3.0
      openai:
        dailyUsd: 2.0

Files

File Purpose
extensions/cost-guard/index.ts Plugin entry point — registers service, hooks, command
extensions/cost-guard/package.json Package metadata
extensions/cost-guard/src/config.ts Config schema with validation and UI hints
extensions/cost-guard/src/tracker.ts In-memory cost accumulator with budget checking
extensions/cost-guard/src/service.ts Service subscribing to diagnostic events
extensions/cost-guard/src/format.ts USD formatting and summary display

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

User-visible / Behavior Changes

New /cost command available when extension is enabled. Budget warnings injected into agent context. Optional hard stop when budget exceeded.

Security Impact

No security impact. Extension only reads diagnostic events (read-only) and uses standard plugin hooks. No new API keys, no external network calls, no file system writes.

Repro + Verification

  1. Enable the cost-guard extension in config
  2. Send messages that trigger API calls
  3. Use /cost to verify spend tracking
  4. Observe budget warnings in logs at 80% threshold
  5. Verify hard stop blocks responses when budget exceeded

Environment

  • OS: macOS (Apple Silicon)
  • Runtime: Node.js v22.22.0
  • TypeScript: passes tsc --noEmit
  • Formatting: passes oxfmt --check

Human Verification (required)

  • Verified TypeScript compilation passes with zero errors
  • Verified oxfmt formatting passes
  • Reviewed all plugin-sdk imports match exported members
  • Cross-referenced hook signatures against src/plugins/types.ts
  • Followed diagnostics-otel pattern for service + event subscription
  • Followed memory-lancedb pattern for config schema + hooks + commands

Risks and Mitigations

  • In-memory tracker resets on restart: Acceptable since historical costs are already stored in session-cost-usage.ts. The tracker only needs real-time data for enforcement.
  • costUsd may be undefined: Gracefully handled — entries with missing cost are ignored.

Test plan

  • TypeScript compilation passes
  • oxfmt formatting passes
  • CI checks pass
  • Extension loads without errors
  • /cost command returns formatted output
  • Budget warnings appear in logs at threshold
  • Hard stop blocks messages when exceeded

Local Validation

  • pnpm check (tsgo): ✅ passes
  • pnpm test: ✅ passes (including plugin validation tests)
  • Formatting: verified with oxfmt --check
  • CI: all checks pass ✅

Scope

New extension at extensions/cost-guard/ — 7 files, self-contained, no modifications to core codebase.

AI Assistance

AI-assisted (Claude Code) for codebase exploration, pattern discovery, and drafting. The architecture decisions (diagnostic event subscription, budget enforcement via hooks), implementation, and Greptile review fixes are my own work.

Testing level: Fully tested — all CI checks pass (check, bun tests, node tests, windows tests).

Greptile Summary

This PR adds a new cost-guard extension that provides real-time API cost monitoring and budget enforcement. The extension subscribes to model.usage diagnostic events, tracks spending in-memory per provider/day/month, and uses lifecycle hooks to inject warnings and block responses when budgets are exceeded.

Key changes:

  • New extension at extensions/cost-guard/ with 7 files (index, service, tracker, config, format, package.json, plugin manifest)
  • Service subscribes to diagnostic events and accumulates costs via an in-memory tracker
  • before_agent_start hook injects budget warnings into agent context at configurable threshold (default 80%)
  • message_sending hook blocks responses when budget exceeded (optional hard stop)
  • /cost slash command shows current spend and budget status with provider breakdown
  • Configurable daily/monthly budgets, warning threshold, hard stop toggle, and per-provider limits
  • Follows established patterns from diagnostics-otel (event subscription) and memory-lancedb (config schema)

Implementation quality:

  • Clean architecture with separation of concerns (tracker, service, config, formatting)
  • Proper TypeScript types throughout
  • Gracefully handles missing cost data
  • Periodic pruning (hourly) prevents unbounded memory growth
  • Timer properly calls .unref() to avoid blocking process shutdown (line 77 of service.ts)
  • Previous review feedback has been addressed in commits e03f62a and ee7f139

The extension is self-contained with no modifications to core codebase. All CI checks pass including TypeScript compilation, formatting, and tests.

Confidence Score: 5/5

  • This PR is safe to merge with minimal risk
  • The extension is well-implemented, self-contained, addresses a real user need, follows established patterns, and all CI checks pass. Previous review feedback has been addressed. No security concerns, no core code modifications, and the implementation shows good practices (proper cleanup, type safety, error handling).
  • No files require special attention

Last reviewed commit: e03f62a

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines 38 to 46
if (status.level === "exceeded" && config.hardStop) {
return {
prependContext: [
"[Cost Guard] BUDGET EXCEEDED.",
`Daily limit of $${status.dailyLimit.toFixed(2)} reached ($${status.dailyUsed.toFixed(2)} used).`,
"Responses are blocked until the budget resets.",
].join(" "),
};
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exceeded message always references daily limit

The "exceeded" status can be triggered by the monthly limit (monthlyUsed >= config.monthlyBudgetUsd) or a per-provider limit, but this message always says "Daily limit of $X reached". When only the monthly budget is exceeded (e.g. daily spend is $2 with a $5 daily budget, but monthly spend is $50 with a $50 monthly budget), the user sees a misleading message.

The same issue applies to the warning block below (lines 48-55), which only references dailyPercent and daily budget even though the warning may have been triggered by monthlyPercent >= config.warningThreshold.

Consider checking which limit was actually exceeded and showing the appropriate message, e.g.:

const dailyExceeded = dailyUsed >= config.dailyBudgetUsd;
const monthlyExceeded = monthlyUsed >= config.monthlyBudgetUsd;
const limitMsg = dailyExceeded
  ? `Daily limit of $${status.dailyLimit.toFixed(2)} reached ($${status.dailyUsed.toFixed(2)} used).`
  : `Monthly limit of $${status.monthlyLimit.toFixed(2)} reached ($${status.monthlyUsed.toFixed(2)} used).`;
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/cost-guard/index.ts
Line: 38:46

Comment:
**Exceeded message always references daily limit**

The `"exceeded"` status can be triggered by the monthly limit (`monthlyUsed >= config.monthlyBudgetUsd`) or a per-provider limit, but this message always says "Daily limit of $X reached". When only the monthly budget is exceeded (e.g. daily spend is $2 with a $5 daily budget, but monthly spend is $50 with a $50 monthly budget), the user sees a misleading message.

The same issue applies to the warning block below (lines 48-55), which only references `dailyPercent` and daily budget even though the warning may have been triggered by `monthlyPercent >= config.warningThreshold`.

Consider checking which limit was actually exceeded and showing the appropriate message, e.g.:

```
const dailyExceeded = dailyUsed >= config.dailyBudgetUsd;
const monthlyExceeded = monthlyUsed >= config.monthlyBudgetUsd;
const limitMsg = dailyExceeded
  ? `Daily limit of $${status.dailyLimit.toFixed(2)} reached ($${status.dailyUsed.toFixed(2)} used).`
  : `Monthly limit of $${status.monthlyLimit.toFixed(2)} reached ($${status.monthlyUsed.toFixed(2)} used).`;
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +74 to +76
pruneTimer = setInterval(() => {
tracker.pruneOldEntries();
}, PRUNE_INTERVAL_MS);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing .unref() on prune interval timer

This setInterval will keep the Node.js event loop alive indefinitely, preventing clean process shutdown even after stop() is called on other services (if stop() on this service isn't reached). The nostr extension's seen-tracker.ts handles this correctly by calling pruneTimer.unref(). Consider adding the same here:

Suggested change
pruneTimer = setInterval(() => {
tracker.pruneOldEntries();
}, PRUNE_INTERVAL_MS);
pruneTimer = setInterval(() => {
tracker.pruneOldEntries();
}, PRUNE_INTERVAL_MS);
pruneTimer.unref();
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/cost-guard/src/service.ts
Line: 74:76

Comment:
**Missing `.unref()` on prune interval timer**

This `setInterval` will keep the Node.js event loop alive indefinitely, preventing clean process shutdown even after `stop()` is called on other services (if `stop()` on this service isn't reached). The `nostr` extension's `seen-tracker.ts` handles this correctly by calling `pruneTimer.unref()`. Consider adding the same here:

```suggestion
      pruneTimer = setInterval(() => {
        tracker.pruneOldEntries();
      }, PRUNE_INTERVAL_MS);
      pruneTimer.unref();
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 155 to 159
checkBudget(config: CostGuardConfig): BudgetStatus {
const dailyUsed = sumTotal(todayEntries());
const monthlyUsed = sumTotal(monthEntries());
const todayProviders = sumByProvider(todayEntries());
const monthProviders = sumByProvider(monthEntries());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant full-array iteration in checkBudget

checkBudget calls todayEntries() and monthEntries() independently for both sumTotal and sumByProvider, resulting in 4 separate filter+iterate passes over the entire entries array. The same issue exists in summary() (lines 217-225) which also makes 4 passes. Under high-volume usage, this becomes a performance bottleneck called on every diagnostic event (line 60 of service.ts).

Consider computing today/month entries once and reusing them:

const today = todayEntries();
const month = monthEntries();
const dailyUsed = sumTotal(today);
const monthlyUsed = sumTotal(month);
const todayProviders = sumByProvider(today);
const monthProviders = sumByProvider(month);

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/cost-guard/src/tracker.ts
Line: 155:159

Comment:
**Redundant full-array iteration in `checkBudget`**

`checkBudget` calls `todayEntries()` and `monthEntries()` independently for both `sumTotal` and `sumByProvider`, resulting in 4 separate filter+iterate passes over the entire `entries` array. The same issue exists in `summary()` (lines 217-225) which also makes 4 passes. Under high-volume usage, this becomes a performance bottleneck called on every diagnostic event (line 60 of `service.ts`).

Consider computing today/month entries once and reusing them:
```
const today = todayEntries();
const month = monthEntries();
const dailyUsed = sumTotal(today);
const monthlyUsed = sumTotal(month);
const todayProviders = sumByProvider(today);
const monthProviders = sumByProvider(month);
```

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

@steipete steipete closed this Feb 16, 2026
@steipete steipete reopened this Feb 17, 2026
@amabito
Copy link

amabito commented Feb 17, 2026

Clean approach — piggybacking on the existing diagnostic event stream keeps the surface area small.

One pattern worth flagging from production: the highest-cost failure mode isn't steady-state overspend — it's retry amplification. A single failed LLM call with large context can retry 3–4x before any model.usage event fires, burning through the remaining budget quickly. If hardStop only triggers on the response path (message_sending), those retries complete before the budget check runs.

One lightweight option might be checking an estimated cost immediately before dispatch (even a rough input_tokens × model_rate calculation). If the estimate exceeds remaining budget, skip the call and emit a diagnostic rather than letting the retry loop run.

Also worth thinking about concurrent runs — two executions could pass the budget check at the same time and both dispatch. Even a minimal guard around the remaining balance would prevent that edge case without changing the overall structure.

What's the intended recovery path when hardStop fires — is there a reset flow, or does the gateway need a restart?

miloudbelarebia and others added 4 commits February 19, 2026 15:06
Add a new extension that monitors API costs in real time via diagnostic
events and enforces daily/monthly budget limits.

- Service listens to model.usage events via onDiagnosticEvent()
- In-memory tracker accumulates spend per provider, per day/month
- before_agent_start hook injects budget warnings into agent context
- message_sending hook blocks responses when budget exceeded (hard stop)
- /cost slash command shows current spend and budget status
- Configurable: daily/monthly limits, warning threshold, per-provider caps

Addresses community feedback about unexpected high API costs.

Related: openclaw#12299, openclaw#8485, openclaw#14779, openclaw#9812

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Show correct limit in exceeded/warning messages: detect whether
  daily, monthly, or per-provider budget triggered the status and
  display the appropriate message instead of always showing daily
- Add pruneTimer.unref() to prevent the interval from keeping the
  Node.js event loop alive after other services stop
- Cache todayEntries()/monthEntries() results in checkBudget() and
  summary() to avoid redundant array iterations (4 → 2 passes)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
CI was failing because the lockfile was not updated after adding the
cost-guard extension workspace. Ran pnpm install to sync.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
The plugin validation system scans all extensions and requires each to
have an openclaw.plugin.json manifest. Without it, the
config.plugin-validation tests fail because loadPluginManifest() returns
an error diagnostic, causing the entire validation to report ok=false.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@miloudbelarebia miloudbelarebia force-pushed the feat/cost-guard-extension branch from e03f62a to ff888b5 Compare February 19, 2026 14:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

Comments