diff --git a/CHANGELOG.md b/CHANGELOG.md index 27d2ba7..4d20881 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `promptphp/intercept-budget-guard`: estimates the cost of a generation step before the + provider is called, and enforces per-request, per-session, and per-user-per-day spending + budgets. Supports `block`, `log`, and `truncate` actions, a built-in pricing table for common + providers with custom overrides, and configurable handling for unpriced models. See + [#12](https://github.com/promptphp/intercept/issues/12). + ## [1.0.0] - 2026-09-27 Supports `laravel/ai` v1.0. The SDK now runs agent middleware once per generation step, not once diff --git a/ROADMAP.md b/ROADMAP.md index 369d2ad..784e957 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -57,30 +57,24 @@ Current focus: - safe logging with value hashes and tool call provenance - optional global config through `config/intercept.php` -## Proposed package roadmap - -I have a few ideas in mind and I've tried to prioritize them based on value and ease of implementation. This file will receieve updates as they are implemented and more ideas are conceived. - ### `promptphp/intercept-budget-guard` -Status: proposed next package. - -Budget Guard will look to protect applications from expensive, oversized, or abusive prompts before they reach the AI provider. +Estimates the cost of a generation step before the AI provider is called, and enforces per-request, per-session, and per-user-per-day spending budgets. -Why this matters: +Current focus: -AI requests can become expensive quickly. Your app should have validations in place for interesting scenarios but there's a need for that final gate just before hitting the AI provider. Budget Guard will look to provide a simple way to stop expensive requests before they hit the provider. +- per-request budget, including the cost already accrued by earlier steps of the same run +- per-session and per-user-per-day budgets, tracked in the cache with a rolling window +- a built-in pricing table for common Anthropic, OpenAI, Google, xAI, DeepSeek, and Mistral models, with family matching for dated and gateway-prefixed model ids +- custom or overriding pricing +- `block`, `log`, and `truncate` actions; `truncate` degrades to `block` for a session or daily breach, since shrinking the prompt cannot undo spend already recorded +- configurable behaviour for an unpriced model: `allow` (unmetered) or `block` +- safe logging with dollar amounts, never the session or user key spend was tracked against +- optional global config through `config/intercept.php` -Planned features: +## Proposed package roadmap -- maximum prompt characters -- estimated token budget -- estimated cost budget -- per-agent limits -- per-model limits -- optional tenant or user budget keys -- `block`, `log`, and `truncate` actions -- configurable limits through `config/intercept.php` +I have a few ideas in mind and I've tried to prioritize them based on value and ease of implementation. This file will receieve updates as they are implemented and more ideas are conceived. ## Contributing diff --git a/composer.json b/composer.json index 662baaa..d2a325b 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "pii-redaction", "rate-limiting", "semantic-caching", + "cost-control", "promptphp" ], "authors": [ @@ -42,6 +43,7 @@ "symfony/var-dumper": "^7.0|^8.0.8" }, "replace": { + "promptphp/intercept-budget-guard": "self.version", "promptphp/intercept-injection-guard": "self.version", "promptphp/intercept-pii-redactor": "self.version", "promptphp/intercept-support": "self.version", @@ -50,6 +52,7 @@ "autoload": { "psr-4": { "PromptPHP\\Intercept\\": "src/", + "PromptPHP\\Intercept\\BudgetGuard\\": "src/BudgetGuard/src/", "PromptPHP\\Intercept\\InjectionGuard\\": "src/InjectionGuard/src/", "PromptPHP\\Intercept\\PIIRedactor\\": "src/PIIRedactor/src/", "PromptPHP\\Intercept\\Support\\": "src/Support/src/", @@ -58,6 +61,7 @@ }, "autoload-dev": { "psr-4": { + "PromptPHP\\Intercept\\BudgetGuard\\Tests\\": "src/BudgetGuard/tests/", "PromptPHP\\Intercept\\InjectionGuard\\Tests\\": "src/InjectionGuard/tests/", "PromptPHP\\Intercept\\PIIRedactor\\Tests\\": "src/PIIRedactor/tests/", "PromptPHP\\Intercept\\Support\\Tests\\": "src/Support/tests/", @@ -99,6 +103,7 @@ ], "test": "vendor/bin/pest", "test:architecture": "vendor/bin/pest tests/Architecture", + "test:budget-guard": "vendor/bin/pest src/BudgetGuard/tests", "test:injection-guard": "vendor/bin/pest src/InjectionGuard/tests", "test:pii-redactor": "vendor/bin/pest src/PIIRedactor/tests", "test:support": "vendor/bin/pest src/Support/tests", diff --git a/docs/configuration.mdx b/docs/configuration.mdx index a196a9a..4e48d70 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -158,6 +158,29 @@ Note that `block_entities` stops the run whatever `action` is set to, so `action This middleware acts on the response rather than the prompt, because the model proposes the tool calls it guards. It has no mutating action, since a proposed tool call belongs to the paused turn the provider recorded. +### Budget Guard options + +| Option | Type | Default | Description | +| ---------------------------- | -------- | ------- | ------------------------------------------------------------------------ | +| `action` | `string` | `block` | How to handle a request that would exceed its budget. | +| `max_cost_per_request` | `float` | `null` | Maximum cost of a single run, in US dollars. `null` disables it. | +| `max_cost_per_session` | `float` | `null` | Maximum cost attributable to a session, in US dollars. | +| `max_cost_per_user_per_day` | `float` | `null` | Maximum cost attributable to a user per day, in US dollars. | +| `session_window_hours` | `int` | `24` | Hours a session's spend is tracked for, from its first request. | +| `assumed_output_tokens` | `int` | `1000` | Output tokens assumed before the provider replies. | +| `model_pricing` | `array` | `[]` | Custom pricing, merged over the built-in table. | +| `unknown_model_action` | `string` | `allow` | What to do when no pricing is known for the step's model. | +| `cache_store` | `string` | `null` | The cache store session and daily spend is recorded in. | +| `log_preview` | `bool` | `false` | Whether logs may include a short prompt preview. | + +Supported actions: + +1. block +2. log +3. truncate + +Every budget is disabled until you set a limit. `truncate` only applies to the per-request budget; a session or daily breach degrades to `block`, since shrinking the current prompt cannot undo spend already recorded. `sessionKeyResolver`, `userKeyResolver`, and `tokenEstimator` are constructor-only options (closures cannot live in a config file). See the [Budget Guard guide](/middleware/budget-guard) for defaults and examples. + ## Tool approval resumes When an agent pauses for tool approval and you resume it with `Decisions`, the prompt text is empty. The new content is whatever a human supplied while resolving the pending tool calls: edited tool arguments and rejection results. diff --git a/docs/docs.json b/docs/docs.json index d10e485..9dad939 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -50,7 +50,8 @@ "pages": [ "middleware/injection-guard", "middleware/pii-redactor", - "middleware/tool-approval-guard" + "middleware/tool-approval-guard", + "middleware/budget-guard" ] }, { diff --git a/docs/middleware/budget-guard.mdx b/docs/middleware/budget-guard.mdx new file mode 100644 index 0000000..e21ebb5 --- /dev/null +++ b/docs/middleware/budget-guard.mdx @@ -0,0 +1,248 @@ +--- +title: "Budget Guard" +description: "Use BudgetGuard to estimate the cost of a Laravel AI agent step before the provider is called, and enforce per-request, per-session, and per-user-per-day spending budgets." +sidebarTitle: "Budget Guard" +--- + +`BudgetGuard` estimates the cost of a generation step before the provider is called, and enforces per-request, per-session, and per-user-per-day spending budgets. + +It can block, log, or truncate the prompt down to size, or fully delegate handling to a custom callback. + +## Why this middleware exists + +A runaway tool-calling loop, a model switched to something far pricier, or simple abuse can turn one agent into an open-ended bill. `BudgetGuard` is the gate that sits just before the provider call and stops a request before it adds to that bill, rather than reporting on the damage afterwards. + + + Every budget is disabled until you set a limit. Adding this middleware with no options does nothing. + + +## Installation + +```bash +composer require promptphp/intercept-budget-guard +``` + +The package depends only on the [Support](/configuration) package, for its shared config and step-inspection helpers. + +## Basic usage + +```php +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Contracts\HasMiddleware; +use PromptPHP\Intercept\BudgetGuard\BudgetGuard; + +class SupportAgent implements Agent, HasMiddleware +{ + public function instructions(): string + { + return 'You help customers with support tickets.'; + } + + public function middleware(): array + { + return [ + new BudgetGuard( + maxCostPerRequest: 0.50, + ), + ]; + } +} +``` + +## What it checks + +Three budgets, each independent and each optional. A step reports the first one it would exceed. + +### Per-request budget + +```php +new BudgetGuard(maxCostPerRequest: 0.50) +``` + +The whole run, including the cost already accrued by its earlier steps. The Laravel AI SDK runs agent middleware once per generation step, and a multi-step tool-calling loop keeps spending with every round trip, so this is checked again on every step of a run, not only the first. + +### Per-session budget + +```php +new BudgetGuard(maxCostPerSession: 10.00) +``` + +The spend recorded against a session key, within a rolling window (`sessionWindowHours`, default 24). A session is only tracked once a key can be resolved for the step. By default that is the agent's own conversation ID, for an agent that implements `RemembersConversations`: + +```php +use Laravel\Ai\Concerns\RemembersConversations; +use Laravel\Ai\Contracts\RemembersConversations as RemembersConversationsContract; + +class SupportAgent implements Agent, HasMiddleware, RemembersConversationsContract +{ + use RemembersConversations; + + // ... +} +``` + +An agent that does not remember conversations has no built-in notion of a session. Supply your own resolver to attribute spend to whatever you consider a session: + +```php +new BudgetGuard( + maxCostPerSession: 10.00, + sessionKeyResolver: fn (PendingStep $step): ?string => request()->session()->getId(), +) +``` + +A resolver that returns `null` means the step cannot be attributed to a session, so the budget is silently skipped for that step. + +### Per-user daily budget + +```php +new BudgetGuard(maxCostPerUserPerDay: 5.00) +``` + +The spend recorded against a user key, for a rolling 24 hours from their first request in the window. By default the user is the agent's conversation participant, falling back to the authenticated user (`Auth::id()`). Supply your own resolver for anything else: + +```php +new BudgetGuard( + maxCostPerUserPerDay: 5.00, + userKeyResolver: fn (PendingStep $step): ?string => $step->options?->agent?->conversationParticipant()?->id, +) +``` + +## How cost is estimated and tracked + +Before the provider is called, the middleware estimates the step's input tokens from the full message history it is about to send, including tool call arguments and tool results, divided by an average characters-per-token ratio. Output tokens are assumed (`assumedOutputTokens`, default 1000), since the real count is not known until the provider replies. + +```php +new BudgetGuard( + maxCostPerRequest: 0.50, + assumedOutputTokens: 500, +) +``` + + + Supply `tokenEstimator` for an exact count from the provider's own tokenizer, instead of the + built-in character count: + + ```php + new BudgetGuard( + tokenEstimator: fn (PendingStep $step): int => MyTokenizer::count($step), + ) + ``` + + +Once the provider replies, the step's actual usage prices the spend recorded against the session and daily budgets, so the running total stays accurate even though the pre-call check was only an estimate. + +### Pricing + +A built-in table prices common models from Anthropic, OpenAI, Google, xAI, DeepSeek, and Mistral, in US dollars per million tokens. A model id is matched exactly first, then by the longest pricing key it starts with or contains, so a dated or gateway-prefixed id such as `us.anthropic.claude-3-5-haiku-20241022-v1:0` or `bedrock/gpt-4o-mini-2024-07-18` still resolves to its family. + +Published pricing changes faster than this package releases. Override or extend it: + +```php +new BudgetGuard( + modelPricing: [ + 'gpt-4o' => ['input' => 2.50, 'output' => 10.00], + 'my-self-hosted-model' => ['input' => 0.10, 'output' => 0.20], + ], +) +``` + + + When no pricing is known for a step's model, `unknownModelAction` decides what happens: + `allow` (the default) logs the gap and lets the request through unmetered, while `block` + stops the run so an unpriced model can never spend against a budget unchecked. + + ```php + new BudgetGuard(unknownModelAction: 'block') + ``` + + +## Supported actions + +| Action | Behaviour | +| ---------- | ------------------------------------------------------------------------------ | +| `block` | Throws before the provider is called. | +| `log` | Logs the breach and lets the request through. | +| `truncate` | Shrinks the newest user message to fit, or falls back to `block` if it can't. | + +```php +new BudgetGuard(action: 'log') // observe before enforcing +``` + + + `truncate` only applies to the per-request budget. Shrinking the current prompt cannot undo + spend already recorded against a session or a user's day, so a session or daily breach + degrades to `block` regardless of the configured action. + + +## Blocked runs + +```text +Request would exceed its configured budget [request: $0.6300 of $0.5000]. +``` + +The exception names the breached budget and the dollar amounts, but never the session or user key spend was tracked against. + +```php +use PromptPHP\Intercept\BudgetGuard\Exceptions\BudgetGuardException; +use PromptPHP\Intercept\Support\Exceptions\InterceptException; + +try { + $response = $agent->prompt($message); +} catch (BudgetGuardException $e) { + // $e->retryAfter holds the seconds until a windowed (session or daily) budget resets, + // or null for the per-request budget, which has no window. + return back()->withErrors('That request could not be completed.'); +} catch (InterceptException $e) { + return back()->withErrors('That request could not be completed.'); +} +``` + +`BudgetGuardException` extends `InterceptException` and carries a `retryAfter` in seconds for a windowed budget. Turn that into a `Retry-After` header in your own exception handler if you want one. + +## Custom callback handling + +A callback receives the step, the breach, and the cost estimate, and fully replaces the configured action. The return value is ignored, so throw to stop the run: + +```php +use Laravel\Ai\PendingStep; +use PromptPHP\Intercept\BudgetGuard\ValueObjects\BudgetBreach; +use PromptPHP\Intercept\BudgetGuard\ValueObjects\CostEstimate; + +new BudgetGuard( + maxCostPerRequest: 0.50, + callback: function (PendingStep $step, BudgetBreach $breach, CostEstimate $estimate): void { + CostAlert::dispatch($breach->reference(), $breach->overspend()); + }, +) +``` + +## Configuration + +You can set all options globally in `config/intercept.php` under `budget_guard`, or per agent through the constructor. Constructor values always win. + +```php +'budget_guard' => [ + 'action' => 'block', + 'max_cost_per_request' => null, + 'max_cost_per_session' => null, + 'max_cost_per_user_per_day' => null, + 'session_window_hours' => 24, + 'assumed_output_tokens' => 1000, + 'model_pricing' => [], + 'unknown_model_action' => 'allow', + 'cache_store' => null, + 'log_preview' => false, +], +``` + +See the [configuration guide](/configuration) for the full option list. + +## Limitations + +The cost estimate before the call is a character-based approximation unless you supply `tokenEstimator`. The amount a provider actually bills can differ, including from how it rounds or bundles usage. + +Pricing changes faster than this package releases. Confirm the built-in rates against your provider's own pricing page and override the ones that matter to you. + +The session and daily budgets are tracked in the cache, not the database, and reset on their own window rather than at a fixed clock boundary. They require a key resolver that can identify the session or the user; without one, those budgets are silently skipped. + +When a run's model changes between steps, the per-request budget prices the earlier steps' accrued usage at the current step's rate, which is an approximation. diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d0336d3..1a7c52b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -6,6 +6,7 @@ tests + src/BudgetGuard/tests src/InjectionGuard/tests src/Support/tests src/PIIRedactor/tests @@ -15,6 +16,7 @@ + src/BudgetGuard/src src/InjectionGuard/src src/Support/src src/PIIRedactor/src diff --git a/split-overrides.json b/split-overrides.json index ceb4d8c..cf79283 100644 --- a/split-overrides.json +++ b/split-overrides.json @@ -1,5 +1,6 @@ { "Support": "intercept-support", + "BudgetGuard": "intercept-budget-guard", "InjectionGuard": "intercept-injection-guard", "PIIRedactor": "intercept-pii-redactor", "ToolApprovalGuard": "intercept-tool-approval-guard" diff --git a/src/BudgetGuard/README.md b/src/BudgetGuard/README.md new file mode 100644 index 0000000..6adefb3 --- /dev/null +++ b/src/BudgetGuard/README.md @@ -0,0 +1,98 @@ +## Introduction + +`BudgetGuard` is a Laravel AI SDK agent middleware that estimates the cost of a generation step before the provider is called, and enforces per-request, per-session, and per-user-per-day spending budgets. + +It can block, log, or truncate the prompt down to size, or fully delegate handling to a custom callback. + +> [!Important] +> This middleware is part of the [Intercept middleware collection](https://github.com/promptphp/intercept). It estimates cost from the step it is about to send and the usage the provider reports back. It does not meter the provider's own bill, which can differ slightly depending on how a provider rounds or bundles usage. + +## Why this matters + +A runaway tool-calling loop, a model switched to something far pricier, or simple abuse can turn one agent into an open-ended bill. `BudgetGuard` is the gate that sits just before the provider call and stops a request before it adds to that bill, rather than reporting on the damage afterwards. + +## Quick start + +### Installation + +```sh +composer require promptphp/intercept-budget-guard +``` + +You may publish the config or not, the middleware works out of the box, though every budget is disabled until you set a limit. + +```sh +php artisan vendor:publish --tag=intercept-config +``` + +### Usage + +Return the `BudgetGuard` middleware on an agent's middleware method. + +> [!Important] +> To add middleware to an agent, implement the `HasMiddleware` interface and define a middleware method that returns an array of middleware classes. + +```php +use Laravel\Ai\Contracts\HasMiddleware; +use PromptPHP\Intercept\BudgetGuard\BudgetGuard; + +class SupportAgent implements Agent, HasMiddleware +{ + public function middleware(): array + { + return [ + new BudgetGuard( + maxCostPerRequest: 0.50, + ), + ]; + } +} +``` + +### Setting more than one budget + +```php +new BudgetGuard( + maxCostPerRequest: 0.50, + maxCostPerSession: 10.00, + maxCostPerUserPerDay: 5.00, +) +``` + +Each budget is independent and optional. The first one a step would exceed is the one that is reported. + +### Observing before enforcing + +```php +new BudgetGuard( + maxCostPerRequest: 0.50, + action: 'log', +) +``` + +> For the complete guide, see the [full documentation](#documentation) below. + +## Documentation + +Full documentation can be found at [https://intercept.promptphp.com/](https://intercept.promptphp.com/) or the [docs](docs/) directory on GitHub. + +## Contributing + +Thank you for considering contributing to Intercept by PromptPHP. The contribution guide can be found in +[CONTRIBUTING.md](CONTRIBUTING.md). + +## Code of Conduct + +We follow the Laravel [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). We expect you to abide by these guidelines as well. + +## Security Vulnerabilities + +If you discover a security vulnerability within Intercept by PromptPHP, please email Victor Ukam at [victorjohnukam@gmail.com](victorjohnukam@gmail.com). All security vulnerabilities will be addressed promptly. + +## License + +Intercept by PromptPHP is open-sourced software licensed under the [MIT license](LICENSE). + +## Support + +This library is created by [Victor Ukam](https://victorukam.com) with contributions from the [Open Source Community](https://github.com/promptphp/Intercept/graphs/contributors). If you've found this package useful, please consider [sponsoring this project](https://github.com/sponsors/veeqtoh). It will go a long way to help with maintenance. diff --git a/src/BudgetGuard/composer.json b/src/BudgetGuard/composer.json new file mode 100644 index 0000000..4629c7b --- /dev/null +++ b/src/BudgetGuard/composer.json @@ -0,0 +1,33 @@ +{ + "name": "promptphp/intercept-budget-guard", + "description": "Budget guard middleware for Laravel AI agents.", + "keywords": ["laravel", "ai", "middleware", "cost", "budget", "rate-limiting", "security"], + "homepage": "https://intercept.promptphp.com", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Victor Ukam", + "email": "victorjohnukam@gmail.com", + "homepage": "https://github.com/veeqtoh", + "role": "Developer" + } + ], + "require": { + "php": "^8.3", + "laravel/ai": "^1.0", + "promptphp/intercept-support": "*" + }, + "autoload": { + "psr-4": { + "PromptPHP\\Intercept\\BudgetGuard\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "PromptPHP\\Intercept\\BudgetGuard\\Tests\\": "tests/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/src/BudgetGuard/src/BudgetGuard.php b/src/BudgetGuard/src/BudgetGuard.php new file mode 100644 index 0000000..3984ebf --- /dev/null +++ b/src/BudgetGuard/src/BudgetGuard.php @@ -0,0 +1,663 @@ +> + */ + protected array $modelPricing; + + /** + * What to do when no pricing is known for the model a step runs on. + */ + protected UnknownModelActions $unknownModelAction = UnknownModelActions::ALLOW; + + /** + * The cache store spend is recorded in. Null uses the default store. + */ + protected ?string $cacheStore; + + /** + * Whether to include a short prompt preview in logs. + */ + protected bool $logPreview = false; + + /** + * Custom callback for handling a budget breach. + */ + protected ?Closure $callback; + + /** + * Resolves the key a session's spend is tracked under. + * + * Receives the generation step and returns a stable identifier, or null when the step + * cannot be attributed to a session. Defaults to the agent's own conversation ID, for an + * agent that remembers its conversations; otherwise the session budget never matches and is + * silently skipped. + */ + protected Closure $sessionKeyResolver; + + /** + * Resolves the key a user's daily spend is tracked under. + * + * Receives the generation step and returns a stable identifier, or null when the step + * cannot be attributed to a user. Defaults to the agent's conversation participant, falling + * back to the authenticated user; otherwise the daily budget never matches and is silently + * skipped. + */ + protected Closure $userKeyResolver; + + /** + * Estimates the input tokens a step will send to the provider. + * + * Receives the generation step and returns the estimated input token count. Defaults to a + * character count over the step's full message history, divided by `CHARS_PER_TOKEN`. + * Supply your own for an exact count from the provider's own tokenizer. + */ + protected Closure $tokenEstimator; + + /** + * The spend store backing the session and daily budgets. + */ + protected SpendStore $spendStore; + + /** + * Create a new Budget Guard instance. + * + * @param string|null $action What to do: 'block', 'log', or 'truncate'. + * @param float|null $maxCostPerRequest Maximum cost of a single run, in US dollars. + * @param float|null $maxCostPerSession Maximum cost attributable to a session, in US dollars. + * @param float|null $maxCostPerUserPerDay Maximum cost attributable to a user per day, in US dollars. + * @param int|null $sessionWindowHours Hours a session's spend is tracked for. + * @param int|null $assumedOutputTokens Output tokens assumed before the provider replies. + * @param array>|null $modelPricing Custom or overriding pricing, merged over the built-in table. + * @param string|null $unknownModelAction What to do for an unpriced model: 'allow' or 'block'. + * @param string|null $cacheStore The cache store spend is recorded in. + * @param bool|null $logPreview Whether to include a short prompt preview in logs. + * @param Closure|null $callback Custom handler for a budget breach. + * @param Closure|null $sessionKeyResolver Resolves the session budget key from the step. + * @param Closure|null $userKeyResolver Resolves the daily budget key from the step. + * @param Closure|null $tokenEstimator Estimates the input tokens a step will send. + */ + public function __construct( + ?string $action = null, + ?float $maxCostPerRequest = null, + ?float $maxCostPerSession = null, + ?float $maxCostPerUserPerDay = null, + ?int $sessionWindowHours = null, + ?int $assumedOutputTokens = null, + ?array $modelPricing = null, + ?string $unknownModelAction = null, + ?string $cacheStore = null, + ?bool $logPreview = null, + ?Closure $callback = null, + ?Closure $sessionKeyResolver = null, + ?Closure $userKeyResolver = null, + ?Closure $tokenEstimator = null, + ) { + $config = InterceptConfig::middleware('budget_guard', BudgetGuardDefaults::values()); + + $action ??= $config['action']; + $maxCostPerRequest ??= $config['max_cost_per_request']; + $maxCostPerSession ??= $config['max_cost_per_session']; + $maxCostPerUserPerDay ??= $config['max_cost_per_user_per_day']; + $sessionWindowHours ??= $config['session_window_hours']; + $assumedOutputTokens ??= $config['assumed_output_tokens']; + $modelPricing ??= $config['model_pricing']; + $unknownModelAction ??= $config['unknown_model_action']; + $cacheStore ??= $config['cache_store']; + $logPreview ??= $config['log_preview']; + + $this->validateAction($action); + $this->validateUnknownModelAction($unknownModelAction); + $this->validatePositive('sessionWindowHours', $sessionWindowHours); + $this->validatePositive('assumedOutputTokens', $assumedOutputTokens); + + $this->action = ActionTypes::from($action); + $this->maxCostPerRequest = $maxCostPerRequest; + $this->maxCostPerSession = $maxCostPerSession; + $this->maxCostPerUserPerDay = $maxCostPerUserPerDay; + $this->sessionWindowHours = $sessionWindowHours; + $this->assumedOutputTokens = $assumedOutputTokens; + $this->modelPricing = $modelPricing; + $this->unknownModelAction = UnknownModelActions::from($unknownModelAction); + $this->cacheStore = $cacheStore; + $this->logPreview = $logPreview; + $this->callback = $callback; + + $this->sessionKeyResolver = $sessionKeyResolver ?? $this->defaultSessionKey(...); + $this->userKeyResolver = $userKeyResolver ?? $this->defaultUserKey(...); + $this->tokenEstimator = $tokenEstimator ?? $this->estimateInputTokens(...); + + $this->spendStore = new SpendStore($this->cacheStore); + } + + /** + * Handle a generation step and enforce the configured budgets before the provider is called. + * + * This runs on every step of a run, not only the first, because a multi-step tool-calling + * loop keeps spending with every round trip. Session and daily spend is recorded from the + * step's own actual usage once the provider replies, so the next step, and the next run, see + * an accurate running total. + * + * @param PendingStep $step The generation step. + * @param Closure $next The next middleware in the pipeline. + */ + public function handle(PendingStep $step, Closure $next): StepResult + { + $rate = ModelPricing::resolve($step->model, [...ModelPricing::rates(), ...$this->modelPricing]); + + if ($rate === null) { + return $this->handleUnknownModel($step, $next); + } + + $estimate = $this->estimate($step, $rate); + $breach = $this->firstBreach($step, $estimate); + + if ($breach === null) { + return $this->proceed($step, $next, $rate); + } + + $this->log($step, $estimate, $breach, blocked: $this->action !== ActionTypes::LOG); + + if ($this->callback !== null) { + ($this->callback)($step, $breach, $estimate); + + return $this->proceed($step, $next, $rate); + } + + if ($this->action === ActionTypes::TRUNCATE && $breach->type === BudgetTypes::REQUEST) { + $truncated = $this->truncate($step, $estimate, $rate); + + if ($truncated !== null) { + return $this->proceed($truncated, $next, $rate); + } + } + + if ($this->action !== ActionTypes::LOG) { + $this->block($breach); + } + + return $this->proceed($step, $next, $rate); + } + + /** + * Handle a step whose model carries no known pricing. + * + * @param PendingStep $step The generation step. + * @param Closure $next The next middleware in the pipeline. + */ + protected function handleUnknownModel(PendingStep $step, Closure $next): StepResult + { + Log::warning('Budget Guard has no pricing for the requested model.', [ + ...$this->stepLogContext($step), + 'unknown_model_action' => $this->unknownModelAction->value, + ]); + + if ($this->unknownModelAction === UnknownModelActions::BLOCK) { + throw new BudgetGuardException(sprintf( + 'Budget Guard has no pricing for model [%s] and unknown_model_action is "block".', + $step->model, + )); + } + + return $next($step); + } + + /** + * Run the next middleware and record the step's actual spend once it resolves. + * + * @param PendingStep $step The generation step. + * @param Closure $next The next middleware in the pipeline. + * @param ModelRate $rate The rate the step's model is priced at. + */ + protected function proceed(PendingStep $step, Closure $next, ModelRate $rate): StepResult + { + $result = $next($step); + + if ($result instanceof StepResponse) { + $result = new StepResult($result); + } + + return $result->then( + fn (StepResponse $response) => $this->recordSpend($step, $response, $rate), + ); + } + + /** + * Estimate the cost of running a step. + * + * @param PendingStep $step The generation step. + * @param ModelRate $rate The rate the step's model is priced at. + */ + protected function estimate(PendingStep $step, ModelRate $rate): CostEstimate + { + $inputTokens = max(0, ($this->tokenEstimator)($step)); + + return new CostEstimate( + inputTokens: $inputTokens, + outputTokens: $this->assumedOutputTokens, + cost: $rate->cost($inputTokens, $this->assumedOutputTokens), + accrued: $rate->costOfUsage($step->usage), + ); + } + + /** + * Find the first budget a step's estimate would exceed. + * + * Checked in order: the request's own ceiling, then the session, then the user's daily + * ceiling. Each is independent: a run can breach more than one, but only the first found is + * reported, since one failing action is enough to stop or log the step. + * + * @param PendingStep $step The generation step. + * @param CostEstimate $estimate The step's cost estimate. + */ + protected function firstBreach(PendingStep $step, CostEstimate $estimate): ?BudgetBreach + { + if ($this->maxCostPerRequest !== null && $estimate->projected() > $this->maxCostPerRequest) { + return new BudgetBreach( + type: BudgetTypes::REQUEST, + limit: $this->maxCostPerRequest, + projected: $estimate->projected(), + spent: $estimate->accrued, + ); + } + + $windowSeconds = $this->sessionWindowHours * 3600; + + if ( + $this->maxCostPerSession !== null + && ($key = $this->resolveKey($this->sessionKeyResolver, $step)) !== null + && ($breach = $this->windowedBreach(BudgetTypes::SESSION, "budget_guard:session:{$key}", $this->maxCostPerSession, $estimate->cost, $windowSeconds)) !== null + ) { + return $breach; + } + + if ( + $this->maxCostPerUserPerDay !== null + && ($key = $this->resolveKey($this->userKeyResolver, $step)) !== null + && ($breach = $this->windowedBreach(BudgetTypes::USER_DAILY, 'budget_guard:daily:'.now()->format('Y-m-d').":{$key}", $this->maxCostPerUserPerDay, $estimate->cost, 86400)) !== null + ) { + return $breach; + } + + return null; + } + + /** + * Check a windowed budget against the spend already recorded for its key. + * + * @param BudgetTypes $type The budget being checked. + * @param string $key The cache key the spend is recorded under. + * @param float $limit The configured limit, in US dollars. + * @param float $stepCost The estimated cost of the step about to run. + * @param int $windowSeconds The width of the budget's window, in seconds. + */ + protected function windowedBreach(BudgetTypes $type, string $key, float $limit, float $stepCost, int $windowSeconds): ?BudgetBreach + { + $spent = $this->spendStore->spent($key); + $projected = $spent + $stepCost; + + if ($projected <= $limit) { + return null; + } + + return new BudgetBreach( + type: $type, + limit: $limit, + projected: $projected, + spent: $spent, + retryAfter: $this->spendStore->availableIn($key), + ); + } + + /** + * Resolve a key from a resolver, guarding against a user-supplied callback that throws. + * + * @param Closure $resolver The resolver to call. + * @param PendingStep $step The generation step to resolve a key for. + */ + protected function resolveKey(Closure $resolver, PendingStep $step): ?string + { + $key = $resolver($step); + + return $key === null || $key === '' ? null : $key; + } + + /** + * Shrink the newest user message of a step until its estimated cost fits the request budget. + * + * Returns null when no prompt short enough would still fit, or when the budget leaves no + * room for the output alone, so the caller can fall back to blocking. + * + * @param PendingStep $step The generation step to truncate. + * @param CostEstimate $estimate The step's cost estimate. + * @param ModelRate $rate The rate the step's model is priced at. + */ + protected function truncate(PendingStep $step, CostEstimate $estimate, ModelRate $rate): ?PendingStep + { + if ($this->maxCostPerRequest === null) { + return null; + } + + $outputCost = $rate->cost(0, $this->assumedOutputTokens); + $remainingInput = $this->maxCostPerRequest - $estimate->accrued - $outputCost; + + if ($remainingInput <= 0 || $rate->input <= 0) { + return null; + } + + $allowedInputTokens = (int) floor($remainingInput * ModelRate::PER_TOKENS / $rate->input); + $allowedChars = $allowedInputTokens * self::CHARS_PER_TOKEN; + + $latest = $this->latestUserMessage($step); + + if ($latest === null) { + return null; + } + + $otherChars = $this->historyChars($step) - mb_strlen((string) $latest->content) - $this->toolChars($step); + $allowedForLatest = $allowedChars - $otherChars; + + if ($allowedForLatest < 20) { + return null; + } + + return $this->mapUserMessages( + $step, + fn (string $content): string => Str::limit($content, $allowedForLatest, preserveWords: true), + latestOnly: true, + ); + } + + /** + * Estimate the input tokens a step will send to the provider. + * + * Counts the characters of the full message history that is resent with every step, + * including tool call arguments and tool results, plus a flat allowance per declared tool, + * and converts the total using `CHARS_PER_TOKEN`. + * + * @param PendingStep $step The generation step. + */ + protected function estimateInputTokens(PendingStep $step): int + { + $chars = $this->historyChars($step) + $this->toolChars($step); + + return (int) ceil($chars / self::CHARS_PER_TOKEN); + } + + /** + * Count the characters of a step's instructions and message history. + * + * @param PendingStep $step The generation step. + */ + protected function historyChars(PendingStep $step): int + { + $chars = mb_strlen((string) $step->instructions); + + foreach ($step->messages as $message) { + $chars += $this->messageChars($message); + } + + return $chars; + } + + /** + * Count the characters of a single message, including any tool calls or results it carries. + * + * @param Message $message The message to measure. + */ + protected function messageChars(Message $message): int + { + $chars = mb_strlen((string) $message->content); + + if ($message instanceof AssistantMessage) { + foreach ($message->toolCalls as $call) { + $chars += mb_strlen($call->name) + mb_strlen((string) json_encode($call->arguments)); + } + } + + if ($message instanceof ToolResultMessage) { + foreach ($message->toolResults as $result) { + $chars += mb_strlen((string) json_encode($result->result)); + } + } + + return $chars; + } + + /** + * Estimate the characters spent describing a step's declared tools. + * + * @param PendingStep $step The generation step. + */ + protected function toolChars(PendingStep $step): int + { + return count($step->tools) * self::TOOL_OVERHEAD_CHARS; + } + + /** + * Resolve the default session key: the agent's own conversation ID. + * + * @param PendingStep $step The generation step. + */ + protected function defaultSessionKey(PendingStep $step): ?string + { + $agent = $this->agentFor($step); + + return $agent instanceof RemembersConversations ? $agent->currentConversation() : null; + } + + /** + * Resolve the default user key: the agent's conversation participant, or the authenticated user. + * + * @param PendingStep $step The generation step. + */ + protected function defaultUserKey(PendingStep $step): ?string + { + $agent = $this->agentFor($step); + + if ($agent instanceof RemembersConversations && ($participant = $agent->conversationParticipant()) !== null) { + return Conversation::participantType($participant).':'.Conversation::participantKey($participant); + } + + return Auth::hasUser() ? (string) Auth::id() : null; + } + + /** + * Record the actual spend of a resolved step against the windowed budgets. + * + * @param PendingStep $step The generation step. + * @param StepResponse $response The response of the step. + * @param ModelRate $rate The rate the step's model is priced at. + */ + protected function recordSpend(PendingStep $step, StepResponse $response, ModelRate $rate): void + { + $cost = $rate->costOfUsage($response->usage); + + if ($cost <= 0.0) { + return; + } + + if (($key = $this->resolveKey($this->sessionKeyResolver, $step)) !== null) { + $this->spendStore->recordSpend("budget_guard:session:{$key}", $cost, $this->sessionWindowHours * 3600); + } + + if (($key = $this->resolveKey($this->userKeyResolver, $step)) !== null) { + $this->spendStore->recordSpend('budget_guard:daily:'.now()->format('Y-m-d').":{$key}", $cost, 86400); + } + } + + /** + * Block the run before the provider is called. + * + * The message names the breached budget and the dollar amounts, but never the session or + * user key spend was tracked against, so it stays safe to log and to surface. + * + * @param BudgetBreach $breach The breach that caused the block. + * + * @throws BudgetGuardException + */ + protected function block(BudgetBreach $breach): never + { + throw new BudgetGuardException( + sprintf('Request would exceed its configured budget [%s].', $breach->reference()), + retryAfter: $breach->retryAfter, + ); + } + + /** + * Log a budget breach safely. + * + * @param PendingStep $step The generation step. + * @param CostEstimate $estimate The step's cost estimate. + * @param BudgetBreach $breach The breach found. + * @param bool $blocked Whether the run is being stopped. + */ + protected function log(PendingStep $step, CostEstimate $estimate, BudgetBreach $breach, bool $blocked): void + { + $context = [ + ...$this->stepLogContext($step), + 'estimate' => $estimate->toArray(), + 'breach' => $breach->toArray(), + 'action' => $this->action->value, + 'timestamp' => now()->toIso8601String(), + ]; + + if ($this->logPreview && ($latest = $this->latestUserMessage($step)) !== null) { + $context['preview'] = str($latest->content)->limit(300)->toString(); + } + + Log::warning( + $blocked ? 'Budget Guard blocked a request that would exceed its budget.' : 'Budget Guard logged a request that would exceed its budget.', + $context, + ); + } + + /** + * Validate the provided action. + * + * @param string $action The action to validate. + */ + protected function validateAction(string $action): void + { + if (! in_array($action, array_column(ActionTypes::cases(), 'value'), true)) { + throw new InvalidArgumentException( + sprintf( + 'Unsupported budget guard action: %s. Must be one of: %s.', + $action, + implode(', ', array_column(ActionTypes::cases(), 'value')), + ) + ); + } + } + + /** + * Validate the provided unknown model action. + * + * @param string $action The action to validate. + */ + protected function validateUnknownModelAction(string $action): void + { + if (! in_array($action, array_column(UnknownModelActions::cases(), 'value'), true)) { + throw new InvalidArgumentException( + sprintf( + 'Unsupported unknown model action: %s. Must be one of: %s.', + $action, + implode(', ', array_column(UnknownModelActions::cases(), 'value')), + ) + ); + } + } + + /** + * Validate that a numeric option is positive. + * + * @param string $name The option name, for the exception message. + * @param int $value The value to validate. + */ + protected function validatePositive(string $name, int $value): void + { + if ($value <= 0) { + throw new InvalidArgumentException("The budget guard [{$name}] option must be greater than zero."); + } + } +} diff --git a/src/BudgetGuard/src/Defaults/BudgetGuardDefaults.php b/src/BudgetGuard/src/Defaults/BudgetGuardDefaults.php new file mode 100644 index 0000000..645726d --- /dev/null +++ b/src/BudgetGuard/src/Defaults/BudgetGuardDefaults.php @@ -0,0 +1,29 @@ + + */ + public static function values(): array + { + return [ + 'action' => 'block', + 'max_cost_per_request' => null, + 'max_cost_per_session' => null, + 'max_cost_per_user_per_day' => null, + 'session_window_hours' => 24, + 'assumed_output_tokens' => 1000, + 'model_pricing' => [], + 'unknown_model_action' => 'allow', + 'cache_store' => null, + 'log_preview' => false, + ]; + } +} diff --git a/src/BudgetGuard/src/Enums/ActionTypes.php b/src/BudgetGuard/src/Enums/ActionTypes.php new file mode 100644 index 0000000..6560461 --- /dev/null +++ b/src/BudgetGuard/src/Enums/ActionTypes.php @@ -0,0 +1,21 @@ +> + */ + public static function rates(): array + { + return [ + // Anthropic. + 'claude-opus-4' => ['input' => 15.00, 'output' => 75.00, 'cache_read' => 1.50, 'cache_write' => 18.75], + 'claude-sonnet-4' => ['input' => 3.00, 'output' => 15.00, 'cache_read' => 0.30, 'cache_write' => 3.75], + 'claude-haiku-4' => ['input' => 1.00, 'output' => 5.00, 'cache_read' => 0.10, 'cache_write' => 1.25], + 'claude-3-opus' => ['input' => 15.00, 'output' => 75.00, 'cache_read' => 1.50, 'cache_write' => 18.75], + 'claude-3-5-sonnet' => ['input' => 3.00, 'output' => 15.00, 'cache_read' => 0.30, 'cache_write' => 3.75], + 'claude-3-7-sonnet' => ['input' => 3.00, 'output' => 15.00, 'cache_read' => 0.30, 'cache_write' => 3.75], + 'claude-3-5-haiku' => ['input' => 0.80, 'output' => 4.00, 'cache_read' => 0.08, 'cache_write' => 1.00], + 'claude-3-haiku' => ['input' => 0.25, 'output' => 1.25, 'cache_read' => 0.03, 'cache_write' => 0.30], + + // OpenAI. + 'gpt-5' => ['input' => 1.25, 'output' => 10.00, 'cache_read' => 0.125], + 'gpt-5-mini' => ['input' => 0.25, 'output' => 2.00, 'cache_read' => 0.025], + 'gpt-5-nano' => ['input' => 0.05, 'output' => 0.40, 'cache_read' => 0.005], + 'gpt-4.1' => ['input' => 2.00, 'output' => 8.00, 'cache_read' => 0.50], + 'gpt-4.1-mini' => ['input' => 0.40, 'output' => 1.60, 'cache_read' => 0.10], + 'gpt-4.1-nano' => ['input' => 0.10, 'output' => 0.40, 'cache_read' => 0.025], + 'gpt-4o' => ['input' => 2.50, 'output' => 10.00, 'cache_read' => 1.25], + 'gpt-4o-mini' => ['input' => 0.15, 'output' => 0.60, 'cache_read' => 0.075], + 'o3' => ['input' => 2.00, 'output' => 8.00, 'cache_read' => 0.50], + 'o3-mini' => ['input' => 1.10, 'output' => 4.40, 'cache_read' => 0.55], + 'o4-mini' => ['input' => 1.10, 'output' => 4.40, 'cache_read' => 0.275], + + // Google. + 'gemini-2.5-pro' => ['input' => 1.25, 'output' => 10.00, 'cache_read' => 0.31], + 'gemini-2.5-flash' => ['input' => 0.30, 'output' => 2.50, 'cache_read' => 0.075], + 'gemini-2.5-flash-lite' => ['input' => 0.10, 'output' => 0.40, 'cache_read' => 0.025], + 'gemini-2.0-flash' => ['input' => 0.10, 'output' => 0.40, 'cache_read' => 0.025], + + // xAI. + 'grok-4' => ['input' => 3.00, 'output' => 15.00, 'cache_read' => 0.75], + 'grok-3' => ['input' => 3.00, 'output' => 15.00, 'cache_read' => 0.75], + 'grok-3-mini' => ['input' => 0.30, 'output' => 0.50, 'cache_read' => 0.075], + + // DeepSeek. + 'deepseek-chat' => ['input' => 0.27, 'output' => 1.10, 'cache_read' => 0.07], + 'deepseek-reasoner' => ['input' => 0.55, 'output' => 2.19, 'cache_read' => 0.14], + + // Mistral. + 'mistral-large' => ['input' => 2.00, 'output' => 6.00], + 'mistral-small' => ['input' => 0.10, 'output' => 0.30], + ]; + } + + /** + * Resolve the rate for a model from a pricing table. + * + * @param string $model The model of the step. + * @param array> $rates The pricing table to read. + * + * @return ModelRate|null The rate, or null when the table prices no matching model. + */ + public static function resolve(string $model, array $rates): ?ModelRate + { + $model = self::normalise($model); + + if ($model === '') { + return null; + } + + $matched = null; + $length = 0; + + foreach ($rates as $key => $entry) { + $candidate = self::normalise((string) $key); + + if ($candidate === '') { + continue; + } + + if ($candidate === $model) { + return ModelRate::fromArray($entry); + } + + if (mb_strlen($candidate) <= $length) { + continue; + } + + if (str_starts_with($model, $candidate) || str_contains($model, $candidate)) { + $matched = $entry; + $length = mb_strlen($candidate); + } + } + + return $matched === null ? null : ModelRate::fromArray($matched); + } + + /** + * Normalise a model id or pricing key for matching. + * + * Deployment ids carry gateway prefixes and region prefixes that the published model name + * does not, such as `bedrock/us.anthropic.claude-3-5-haiku-20241022-v1:0`. Dropping those + * leaves the family name the pricing table is keyed by. + * + * @param string $model The model id to normalise. + */ + private static function normalise(string $model): string + { + $model = mb_strtolower(trim($model)); + + if (($slash = mb_strrpos($model, '/')) !== false) { + $model = mb_substr($model, $slash + 1); + } + + return str_replace('_', '-', $model); + } +} diff --git a/src/BudgetGuard/src/Support/SpendStore.php b/src/BudgetGuard/src/Support/SpendStore.php new file mode 100644 index 0000000..7abcfab --- /dev/null +++ b/src/BudgetGuard/src/Support/SpendStore.php @@ -0,0 +1,104 @@ +cache()->get($key, 0)) / self::MICRO_DOLLARS; + } + + /** + * Record spend against a key and get the new total, in US dollars. + * + * The window starts on the key's first write and lasts `$windowSeconds`, so a daily budget + * resets a day after the request that first spends against it rather than at midnight. + * A null window keeps the key forever. + * + * @param string $key The budget key to record against. + * @param float $amount The amount to add, in US dollars. + * @param int|null $windowSeconds The number of seconds the key lives for. + */ + public function recordSpend(string $key, float $amount, ?int $windowSeconds = null): float + { + $cache = $this->cache(); + $micros = (int) round($amount * self::MICRO_DOLLARS); + + if ($windowSeconds !== null) { + $cache->add("{$key}:expires_at", $this->availableAt($windowSeconds), $windowSeconds); + } + + $added = $cache->add($key, 0, $windowSeconds); + $total = (int) $cache->increment($key, $micros); + + // The key may have expired between the add above and the increment, in which case the + // increment recreated it without a TTL. Reinstating one here keeps the window honest. + if (! $added && $total === $micros && $windowSeconds !== null) { + $cache->put($key, $total, $windowSeconds); + } + + return $total / self::MICRO_DOLLARS; + } + + /** + * Get the seconds until a windowed key resets, or null when it carries no window. + * + * @param string $key The budget key to read. + */ + public function availableIn(string $key): ?int + { + $expiresAt = $this->cache()->get("{$key}:expires_at"); + + if ($expiresAt === null) { + return null; + } + + return max(0, (int) $expiresAt - $this->currentTime()); + } + + /** + * Resolve the cache repository. + */ + private function cache(): Repository + { + return Cache::store($this->store); + } +} diff --git a/src/BudgetGuard/src/ValueObjects/BudgetBreach.php b/src/BudgetGuard/src/ValueObjects/BudgetBreach.php new file mode 100644 index 0000000..e5e23a8 --- /dev/null +++ b/src/BudgetGuard/src/ValueObjects/BudgetBreach.php @@ -0,0 +1,65 @@ +projected - $this->limit; + } + + /** + * Get a safe reference to the breach. + * + * Names the budget and the amounts, but never the key the spend was recorded against, so it + * is safe to put in an exception message or a log line. + */ + public function reference(): string + { + return sprintf('%s: $%s of $%s', $this->type->value, number_format($this->projected, 4), number_format($this->limit, 4)); + } + + /** + * Describe the breach for logging. + * + * @return array + */ + public function toArray(): array + { + return [ + 'budget' => $this->type->value, + 'limit' => round($this->limit, 6), + 'spent' => round($this->spent, 6), + 'projected' => round($this->projected, 6), + 'overspend' => round($this->overspend(), 6), + 'retry_after' => $this->retryAfter, + ]; + } +} diff --git a/src/BudgetGuard/src/ValueObjects/CostEstimate.php b/src/BudgetGuard/src/ValueObjects/CostEstimate.php new file mode 100644 index 0000000..ee67f6d --- /dev/null +++ b/src/BudgetGuard/src/ValueObjects/CostEstimate.php @@ -0,0 +1,49 @@ +accrued + $this->cost; + } + + /** + * Describe the estimate for logging. + * + * @return array + */ + public function toArray(): array + { + return [ + 'input_tokens' => $this->inputTokens, + 'output_tokens' => $this->outputTokens, + 'step_cost' => round($this->cost, 6), + 'accrued_cost' => round($this->accrued, 6), + 'run_cost' => round($this->projected(), 6), + ]; + } +} diff --git a/src/BudgetGuard/src/ValueObjects/ModelRate.php b/src/BudgetGuard/src/ValueObjects/ModelRate.php new file mode 100644 index 0000000..8be60aa --- /dev/null +++ b/src/BudgetGuard/src/ValueObjects/ModelRate.php @@ -0,0 +1,95 @@ + $entry The entry to read. + */ + public static function fromArray(array $entry): self + { + foreach (['input', 'output'] as $key) { + if (! isset($entry[$key]) || ! is_numeric($entry[$key])) { + throw new InvalidArgumentException("Model pricing entries require a numeric [{$key}] rate."); + } + } + + foreach (['cache_read', 'cache_write'] as $key) { + if (isset($entry[$key]) && ! is_numeric($entry[$key])) { + throw new InvalidArgumentException("The model pricing [{$key}] rate must be numeric."); + } + } + + return new self( + input: (float) $entry['input'], + output: (float) $entry['output'], + cacheRead: isset($entry['cache_read']) ? (float) $entry['cache_read'] : null, + cacheWrite: isset($entry['cache_write']) ? (float) $entry['cache_write'] : null, + ); + } + + /** + * Get the cost in US dollars of the given token counts. + * + * @param int $inputTokens The number of input tokens. + * @param int $outputTokens The number of output tokens. + */ + public function cost(int $inputTokens, int $outputTokens): float + { + return ($inputTokens * $this->input + $outputTokens * $this->output) / self::PER_TOKENS; + } + + /** + * Get the cost in US dollars of the usage a provider reported. + * + * Cached input tokens are priced at their own rates when the provider reports them and the + * pricing table carries them, because a cache read is typically a fraction of the input rate + * and a cache write a premium over it. + * + * @param TextUsage $usage The usage to price. + */ + public function costOfUsage(TextUsage $usage): float + { + $cacheRead = $usage->cacheReadInputTokens ?? 0; + $cacheWrite = $usage->cacheWriteInputTokens ?? 0; + + $cost = $usage->uncachedInputTokens() * $this->input + + $cacheRead * ($this->cacheRead ?? $this->input) + + $cacheWrite * ($this->cacheWrite ?? $this->input) + + $usage->outputTokens * $this->output; + + return $cost / self::PER_TOKENS; + } +} diff --git a/src/BudgetGuard/tests/BudgetGuardTest.php b/src/BudgetGuard/tests/BudgetGuardTest.php new file mode 100644 index 0000000..fb38ff5 --- /dev/null +++ b/src/BudgetGuard/tests/BudgetGuardTest.php @@ -0,0 +1,277 @@ + $messages + * @param array $tools + */ +function makeBudgetStep( + array $messages = [], + ?object $agent = null, + ?TextUsage $usage = null, + string $model = 'gpt-4o', + array $tools = [], + int $number = 0, +): PendingStep { + return new PendingStep( + number: $number, + isFinalStep: false, + provider: 'test-provider', + model: $model, + instructions: 'You are a support agent.', + messages: $messages === [] ? [new UserMessage('Hello there, can you help me today?')] : $messages, + tools: $tools, + schema: null, + options: new TextGenerationOptions(agent: $agent ?? new BudgetGuardTestAgent), + usage: $usage ?? new TextUsage, + invocationId: 'inv_1', + ); +} + +/** + * Build a step response with the given usage. + */ +function respondBudget(?TextUsage $usage = null, string $text = 'All done.'): StepResponse +{ + return new StepResponse($text, [], FinishReason::Stop, $usage ?? new TextUsage(100, 50), new Meta('test-provider', 'gpt-4o')); +} + +/** + * Run the guard on a step and resolve the step response, capturing the step actually sent on. + */ +function guardBudgetStep(BudgetGuard $guard, PendingStep $step, StepResponse $response, ?PendingStep &$receivedStep = null): ?StepResponse +{ + return $guard->handle($step, function (PendingStep $sent) use (&$receivedStep, $response): StepResult { + $receivedStep = $sent; + + return new StepResult($response); + })->response(); +} + +it('lets a request within every configured budget through untouched', function (): void { + $guard = new BudgetGuard(maxCostPerRequest: 10.00); + + $step = makeBudgetStep(); + $response = respondBudget(); + + expect(guardBudgetStep($guard, $step, $response))->toBe($response); +}); + +it('blocks a request projected to exceed the per-request budget', function (): void { + Log::shouldReceive('warning')->once(); + + $guard = new BudgetGuard(maxCostPerRequest: 0.0001, assumedOutputTokens: 1000); + + $step = makeBudgetStep(); + $response = respondBudget(); + + expect(fn () => guardBudgetStep($guard, $step, $response)) + ->toThrow(BudgetGuardException::class); +}); + +it('includes the cost already accrued by earlier steps in the request projection', function (): void { + Log::shouldReceive('warning')->once(); + + // A cheap new step, but a run that already burned through most of the budget. + $guard = new BudgetGuard(maxCostPerRequest: 0.01, assumedOutputTokens: 10); + + $accrued = new TextUsage(inputTokens: 1_000_000, outputTokens: 1_000_000); + $step = makeBudgetStep(messages: [new UserMessage('ok')], usage: $accrued, number: 1); + $response = respondBudget(); + + expect(fn () => guardBudgetStep($guard, $step, $response)) + ->toThrow(BudgetGuardException::class); +}); + +it('logs instead of blocking when the action is log', function (): void { + Log::shouldReceive('warning')->once(); + + $guard = new BudgetGuard(action: 'log', maxCostPerRequest: 0.0001); + + $step = makeBudgetStep(); + $response = respondBudget(); + + expect(guardBudgetStep($guard, $step, $response))->toBe($response); +}); + +it('truncates the newest user message to fit the per-request budget', function (): void { + Log::shouldReceive('warning')->once(); + + $guard = new BudgetGuard(action: 'truncate', maxCostPerRequest: 0.001, assumedOutputTokens: 10); + + $longMessage = new UserMessage(str_repeat('This is a very long support ticket. ', 50)); + $step = makeBudgetStep(messages: [$longMessage]); + $response = respondBudget(); + + $receivedStep = null; + + guardBudgetStep($guard, $step, $response, $receivedStep); + + expect($receivedStep)->not->toBeNull() + ->and(mb_strlen((string) $receivedStep->messages[0]->content)) + ->toBeLessThan(mb_strlen((string) $longMessage->content)); +}); + +it('degrades to blocking when truncation leaves no room for a prompt', function (): void { + Log::shouldReceive('warning')->once(); + + // The accrued cost alone already exceeds the budget, so no amount of truncation helps. + $guard = new BudgetGuard(action: 'truncate', maxCostPerRequest: 0.0001, assumedOutputTokens: 1000); + + $accrued = new TextUsage(inputTokens: 1_000_000, outputTokens: 1_000_000); + $step = makeBudgetStep(usage: $accrued, number: 1); + $response = respondBudget(); + + expect(fn () => guardBudgetStep($guard, $step, $response)) + ->toThrow(BudgetGuardException::class); +}); + +it('lets an unpriced model through by default and logs the gap', function (): void { + Log::shouldReceive('warning')->once(); + + $guard = new BudgetGuard(maxCostPerRequest: 0.01); + + $step = makeBudgetStep(model: 'some-future-model-nobody-has-priced-yet'); + $response = respondBudget(); + + expect(guardBudgetStep($guard, $step, $response))->toBe($response); +}); + +it('blocks an unpriced model when unknown_model_action is block', function (): void { + Log::shouldReceive('warning')->once(); + + $guard = new BudgetGuard(maxCostPerRequest: 0.01, unknownModelAction: 'block'); + + $step = makeBudgetStep(model: 'some-future-model-nobody-has-priced-yet'); + $response = respondBudget(); + + expect(fn () => guardBudgetStep($guard, $step, $response)) + ->toThrow(BudgetGuardException::class); +}); + +it('delegates to a custom callback and lets the run continue', function (): void { + Log::shouldReceive('warning')->once(); + + $seen = null; + + $guard = new BudgetGuard( + maxCostPerRequest: 0.0001, + callback: function (PendingStep $step, BudgetBreach $breach, CostEstimate $estimate) use (&$seen): void { + $seen = $breach; + }, + ); + + $step = makeBudgetStep(); + $response = respondBudget(); + + expect(guardBudgetStep($guard, $step, $response))->toBe($response) + ->and($seen)->toBeInstanceOf(BudgetBreach::class) + ->and($seen->type)->toBe(BudgetTypes::REQUEST); +}); + +it('tracks session spend across runs for a conversational agent and blocks once the budget is spent', function (): void { + $agent = new BudgetGuardTestConversationalAgent; + $agent->continue('conversation-'.Str::random()); + + $guard = new BudgetGuard(maxCostPerSession: 0.001, assumedOutputTokens: 1); + + $usage = new TextUsage(inputTokens: 1000, outputTokens: 1000); + + // First run spends against the session... + $first = guardBudgetStep( + $guard, + makeBudgetStep(agent: $agent, usage: new TextUsage), + respondBudget($usage), + ); + + expect($first)->not->toBeNull(); + + Log::shouldReceive('warning')->once(); + + // The second run's own estimate, on top of what the first already spent, breaches the budget... + expect(fn () => guardBudgetStep( + $guard, + makeBudgetStep(agent: $agent, usage: new TextUsage), + respondBudget($usage), + ))->toThrow(BudgetGuardException::class); +}); + +it('tracks daily spend for the conversation participant and blocks once the budget is spent', function (): void { + $agent = new BudgetGuardTestConversationalAgent; + $agent->forUser((object) ['id' => 'user-'.Str::random()]); + + $guard = new BudgetGuard(maxCostPerUserPerDay: 0.001, assumedOutputTokens: 1); + + $usage = new TextUsage(inputTokens: 1000, outputTokens: 1000); + + $first = guardBudgetStep( + $guard, + makeBudgetStep(agent: $agent, usage: new TextUsage), + respondBudget($usage), + ); + + expect($first)->not->toBeNull(); + + Log::shouldReceive('warning')->once(); + + expect(fn () => guardBudgetStep( + $guard, + makeBudgetStep(agent: $agent, usage: new TextUsage), + respondBudget($usage), + ))->toThrow(BudgetGuardException::class); +}); + +it('skips the session budget silently when no session key can be resolved', function (): void { + // The default agent does not remember conversations, so there is no session to attribute spend to. + $guard = new BudgetGuard(maxCostPerSession: 0.00000001); + + $step = makeBudgetStep(); + $response = respondBudget(); + + expect(guardBudgetStep($guard, $step, $response))->toBe($response); +}); + +it('rejects an unsupported action', function (): void { + expect(fn () => new BudgetGuard(action: 'redact')) + ->toThrow(InvalidArgumentException::class); +}); + +it('rejects an unsupported unknown model action', function (): void { + expect(fn () => new BudgetGuard(unknownModelAction: 'warn')) + ->toThrow(InvalidArgumentException::class); +}); + +it('rejects a non positive session window', function (): void { + expect(fn () => new BudgetGuard(sessionWindowHours: 0)) + ->toThrow(InvalidArgumentException::class); +}); + +it('rejects a non positive assumed output token count', function (): void { + expect(fn () => new BudgetGuard(assumedOutputTokens: -1)) + ->toThrow(InvalidArgumentException::class); +}); diff --git a/src/BudgetGuard/tests/Fixtures/BudgetGuardTestAgent.php b/src/BudgetGuard/tests/Fixtures/BudgetGuardTestAgent.php new file mode 100644 index 0000000..04f1842 --- /dev/null +++ b/src/BudgetGuard/tests/Fixtures/BudgetGuardTestAgent.php @@ -0,0 +1,105 @@ +toBeInstanceOf(ModelRate::class) + ->and($rate->input)->toBe(0.15) + ->and($rate->output)->toBe(0.60); +}); + +it('matches a dated or versioned model id to its family by longest prefix', function (): void { + $rate = ModelPricing::resolve('claude-sonnet-4-5-20250929', ModelPricing::rates()); + + expect($rate)->toBeInstanceOf(ModelRate::class) + ->and($rate->input)->toBe(3.00) + ->and($rate->output)->toBe(15.00); +}); + +it('prefers the longer, more specific match over a shorter prefix', function (): void { + $rate = ModelPricing::resolve('gpt-4o-mini-2024-07-18', ModelPricing::rates()); + + expect($rate->input)->toBe(0.15) + ->and($rate->output)->toBe(0.60); +}); + +it('strips a gateway or region prefix before matching', function (): void { + $rate = ModelPricing::resolve('bedrock/us.anthropic.claude-3-5-haiku-20241022-v1:0', ModelPricing::rates()); + + expect($rate)->toBeInstanceOf(ModelRate::class) + ->and($rate->input)->toBe(0.80); +}); + +it('returns null for a model nothing prices', function (): void { + expect(ModelPricing::resolve('some-brand-new-model', ModelPricing::rates()))->toBeNull(); +}); + +it('lets a custom pricing table override a built-in rate', function (): void { + $rate = ModelPricing::resolve('gpt-4o', [ + ...ModelPricing::rates(), + 'gpt-4o' => ['input' => 1.00, 'output' => 2.00], + ]); + + expect($rate->input)->toBe(1.00) + ->and($rate->output)->toBe(2.00); +}); diff --git a/src/Support/config/intercept.php b/src/Support/config/intercept.php index be80766..92066b5 100644 --- a/src/Support/config/intercept.php +++ b/src/Support/config/intercept.php @@ -235,5 +235,98 @@ */ 'log_preview' => false, ], + + /* + |-------------------------------------------------------------------------- + | Budget Guard Middleware + |-------------------------------------------------------------------------- + | + | The Budget Guard middleware estimates the cost of a generation step before + | the provider is called, and enforces per-request, per-session, and + | per-user-per-day spending budgets. + | + */ + 'budget_guard' => [ + + /* + * The action to take when a request would exceed its budget. + * Supported values: 'block', 'log', 'truncate'. + * + * 'truncate' only applies to the per-request budget: shrinking the current prompt + * cannot undo spend already recorded against a session or a user's day, so a + * session or daily breach degrades to 'block'. + */ + 'action' => 'block', + + /** + * The maximum cost of a single run, in US dollars, including the cost already + * accrued by its earlier steps. Null disables this budget. + */ + 'max_cost_per_request' => null, + + /** + * The maximum cost attributable to a session, in US dollars, within the session + * window below. Null disables this budget. + * + * Sessions are only tracked when a key can be resolved for the step. By default + * that is the agent's own conversation ID, for an agent that remembers its + * conversations. Supply a custom `sessionKeyResolver` to the middleware for + * anything else. + */ + 'max_cost_per_session' => null, + + /** + * The maximum cost attributable to a user per calendar day, in US dollars. + * Null disables this budget. + * + * By default the user is the agent's conversation participant, falling back to the + * authenticated user. Supply a custom `userKeyResolver` to the middleware for + * anything else. + */ + 'max_cost_per_user_per_day' => null, + + /** + * How many hours a session's spend is tracked for, from the first request that + * spends against it. + */ + 'session_window_hours' => 24, + + /** + * The output tokens assumed for a step's response, since the real count is not + * known until the provider replies. Used only to estimate cost before the call. + */ + 'assumed_output_tokens' => 1000, + + /** + * Custom pricing, merged over the built-in pricing table and keyed the same way: + * a model family name mapped to 'input', 'output', and optional 'cache_read' and + * 'cache_write' rates, in US dollars per million tokens. Entries here override a + * built-in rate for the same key. + */ + 'model_pricing' => [ + // 'gpt-4o' => ['input' => 2.50, 'output' => 10.00], + ], + + /** + * What to do when no pricing is known for the model a step runs on. + * Supported values: 'allow', 'block'. + * + * Pricing changes faster than this package releases, so an unpriced model is + * expected rather than exceptional. 'allow' keeps a model upgrade from taking an + * application down; 'block' guarantees a budget is never bypassed by one. + */ + 'unknown_model_action' => 'allow', + + /** + * The cache store session and daily spend is recorded in. Null uses the default + * store configured for the application. + */ + 'cache_store' => null, + + /** + * Whether to include a short prompt preview in logs. + */ + 'log_preview' => false, + ], ], ];