Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions app/Domain/Budget/Actions/EnforceMaxCreditsPerCallAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

namespace App\Domain\Budget\Actions;

use App\Domain\Budget\Exceptions\InsufficientBudgetException;
use App\Domain\Budget\Services\CostCalculator;
use App\Domain\Shared\Models\Team;

/**
* Pre-call enforcement of the per-team max-platform-credits-per-call cap.
*
* Throws InsufficientBudgetException BEFORE the LLM call so a runaway Opus call
* cannot single-handedly drain the customer's monthly credit pool.
*
* Skipped when team has no cap (cap = null).
*/
class EnforceMaxCreditsPerCallAction
{
public function __construct(
private readonly CostCalculator $costCalculator,
) {}

public function execute(
string $teamId,
string $provider,
string $model,
int $maxOutputTokens,
int $estimatedInputTokens = 500,
?string $cacheStrategy = null,
): void {
if ($teamId === '') {
return;
}

$team = Team::withoutGlobalScopes()->find($teamId);
if (! $team) {
return;
}

$cap = $team->effectiveMaxCreditsPerCall();
if ($cap === null) {
return;
}

$marginOverride = $team->effectiveMarginMultiplier();

$estimate = $this->costCalculator->estimatePlatformCredits(
provider: $provider,
model: $model,
estimatedInputTokens: $estimatedInputTokens,
maxOutputTokens: $maxOutputTokens,
cacheStrategy: $cacheStrategy,
marginOverride: $marginOverride,
);

if ($estimate > $cap) {
throw new InsufficientBudgetException(
"Estimated {$estimate} platform_credits exceeds team cap of {$cap}. ".
'Increase max_credits_per_call in team settings or use a cheaper model.'
);
}
}
}
212 changes: 193 additions & 19 deletions app/Domain/Budget/Services/CostCalculator.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,154 @@
use App\Infrastructure\AI\Enums\BudgetPressureLevel;
use App\Models\GlobalSetting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

class CostCalculator
{
public function calculateCost(string $provider, string $model, int $inputTokens, int $outputTokens): int
{
$pricing = $this->getPricing($provider, $model);
public const CACHE_STRATEGY_NONE = 'none';

public const CACHE_STRATEGY_5M = 'ephemeral_5m';

public const CACHE_STRATEGY_1H = 'ephemeral_1h';

if (! $pricing) {
/**
* Back-compat: existing callers (BudgetEnforcement, PrismAiGateway, SkillCostCalculator)
* read cost_credits where 1 credit = $0.001 USD.
*/
public function calculateCost(
string $provider,
string $model,
int $inputTokens,
int $outputTokens,
int $cachedInputTokens = 0,
?string $cacheStrategy = null,
): int {
$rawCostUsd = $this->rawCostUsd($provider, $model, $inputTokens, $outputTokens, $cachedInputTokens, $cacheStrategy);

if ($rawCostUsd <= 0.0) {
return 0;
}

$inputCost = (int) ceil(($inputTokens / 1000) * $pricing['input']);
$outputCost = (int) ceil(($outputTokens / 1000) * $pricing['output']);
$creditValueUsd = (float) config('llm_pricing.credit_value_usd', 0.001);

return $inputCost + $outputCost;
return (int) ceil($rawCostUsd / $creditValueUsd);
}

/**
* Back-compat: existing reservation flow uses cost_credits.
*/
public function estimateCost(string $provider, string $model, int $maxTokens): int
{
$pricing = $this->getPricing($provider, $model);

if (! $pricing) {
if ($pricing === null) {
return 0;
}

// Estimate: assume ~500 input tokens + full maxTokens output
$estimatedInputTokens = 500;
$inputCost = (int) ceil(($estimatedInputTokens / 1000) * $pricing['input']);
$outputCost = (int) ceil(($maxTokens / 1000) * $pricing['output']);
$multiplier = $this->reservationMultiplierFor($pricing);

$rawCostUsd = $this->rawCostUsd($provider, $model, $estimatedInputTokens, $maxTokens, 0, null);

if ($rawCostUsd <= 0.0) {
return 0;
}

$multiplier = config('llm_pricing.reservation_multiplier', 1.5);
$creditValueUsd = (float) config('llm_pricing.credit_value_usd', 0.001);

return (int) ceil(($inputCost + $outputCost) * $multiplier);
return (int) ceil(($rawCostUsd / $creditValueUsd) * $multiplier);
}

/**
* NEW — primary entry point for platform_credits deduction.
*
* @return array{
* platform_credits:int,
* raw_cost_usd:float,
* billable_cost_usd:float,
* margin_applied:float,
* model_pricing:array<string,mixed>|null
* }
*/
public function calculatePlatformCredits(
string $provider,
string $model,
int $inputTokens,
int $outputTokens,
int $cachedInputTokens = 0,
?string $cacheStrategy = null,
?float $marginOverride = null,
?int $maxCapOverride = null,
): array {
$pricing = $this->getPricing($provider, $model);
$rawCostUsd = $this->rawCostUsd($provider, $model, $inputTokens, $outputTokens, $cachedInputTokens, $cacheStrategy);

$margin = $marginOverride ?? (float) config('llm_pricing.margin_multiplier', 1.30);
$billableCostUsd = $rawCostUsd * $margin;

$usdPerCredit = (float) config('llm_pricing.usd_per_credit', 0.01);
$minCredits = (int) config('llm_pricing.min_credits_per_call', 1);
$configMax = config('llm_pricing.max_credits_per_call');
$maxCap = $maxCapOverride ?? ($configMax !== null ? (int) $configMax : null);

if ($rawCostUsd <= 0.0) {
return [
'platform_credits' => 0,
'raw_cost_usd' => 0.0,
'billable_cost_usd' => 0.0,
'margin_applied' => $margin,
'model_pricing' => $pricing,
];
}

$rawCredits = (int) ceil($billableCostUsd / $usdPerCredit);
$platformCredits = max($minCredits, $rawCredits);
if ($maxCap !== null && $maxCap > 0) {
$platformCredits = min($platformCredits, $maxCap);
$platformCredits = max($minCredits, $platformCredits);
}

return [
'platform_credits' => $platformCredits,
'raw_cost_usd' => $rawCostUsd,
'billable_cost_usd' => $billableCostUsd,
'margin_applied' => $margin,
'model_pricing' => $pricing,
];
}

/**
* Pre-call platform-credit estimate for max-cap enforcement and reservation.
* Uses tier-specific reservation multiplier.
*/
public function estimatePlatformCredits(
string $provider,
string $model,
int $estimatedInputTokens,
int $maxOutputTokens,
?string $cacheStrategy = null,
?float $marginOverride = null,
): int {
$pricing = $this->getPricing($provider, $model);

if ($pricing === null) {
return (int) config('llm_pricing.min_credits_per_call', 1);
}

$multiplier = $this->reservationMultiplierFor($pricing);

$result = $this->calculatePlatformCredits(
provider: $provider,
model: $model,
inputTokens: $estimatedInputTokens,
outputTokens: $maxOutputTokens,
cachedInputTokens: 0,
cacheStrategy: $cacheStrategy,
marginOverride: $marginOverride,
maxCapOverride: null,
);

return (int) ceil($result['platform_credits'] * $multiplier);
}

public function getBudgetPressureLevel(string $teamId): BudgetPressureLevel
Expand All @@ -58,25 +173,21 @@ public function getBudgetPressureLevel(string $teamId): BudgetPressureLevel

private function calculateBudgetPressure(string $teamId): BudgetPressureLevel
{
// Get team's latest balance from the most recent ledger entry
$latestEntry = CreditLedger::withoutGlobalScopes()
->where('team_id', $teamId)
->orderByDesc('created_at')
->orderByDesc('id')
->first(['balance_after']);

// No ledger entries at all — team has never had credits, no pressure
if (! $latestEntry) {
return BudgetPressureLevel::None;
}

// Get total purchased/refunded credits (the team's ceiling)
$totalBudget = (int) CreditLedger::withoutGlobalScopes()
->where('team_id', $teamId)
->whereIn('type', [LedgerType::Purchase->value, LedgerType::Refund->value])
->sum('amount');

// No purchased credits — self-hosted/community install, no pressure
if ($totalBudget <= 0) {
return BudgetPressureLevel::None;
}
Expand Down Expand Up @@ -105,11 +216,74 @@ private function calculateBudgetPressure(string $teamId): BudgetPressureLevel
return BudgetPressureLevel::None;
}

private function rawCostUsd(
string $provider,
string $model,
int $inputTokens,
int $outputTokens,
int $cachedInputTokens,
?string $cacheStrategy,
): float {
$pricing = $this->getPricing($provider, $model);

if ($pricing === null) {
Log::debug('cost_calculator.unknown_model', ['provider' => $provider, 'model' => $model]);

return 0.0;
}

$inputRate = (float) ($pricing['input_usd_per_mtok'] ?? 0);
$outputRate = (float) ($pricing['output_usd_per_mtok'] ?? 0);
$cacheReadRate = (float) ($pricing['cache_read_usd_per_mtok'] ?? $inputRate);

$cachedInputTokens = max(0, min($cachedInputTokens, $inputTokens));
$uncachedInput = $inputTokens - $cachedInputTokens;

$cost = ($uncachedInput / 1_000_000.0) * $inputRate
+ ($cachedInputTokens / 1_000_000.0) * $cacheReadRate
+ ($outputTokens / 1_000_000.0) * $outputRate;

if ($cacheStrategy === self::CACHE_STRATEGY_5M
&& isset($pricing['cache_write_5m_usd_per_mtok'])) {
$cost += ($inputTokens / 1_000_000.0) * (float) $pricing['cache_write_5m_usd_per_mtok'];
} elseif ($cacheStrategy === self::CACHE_STRATEGY_1H
&& isset($pricing['cache_write_1h_usd_per_mtok'])) {
$cost += ($inputTokens / 1_000_000.0) * (float) $pricing['cache_write_1h_usd_per_mtok'];
}

return $cost;
}

/**
* @return array{input: int, output: int}|null
* @param array<string,mixed> $pricing
*/
private function reservationMultiplierFor(array $pricing): float
{
$tier = (string) ($pricing['tier'] ?? 'default');
$tiered = config("llm_pricing.reservation_multipliers.{$tier}");

if ($tiered !== null) {
return (float) $tiered;
}

return (float) config('llm_pricing.reservation_multiplier', 1.5);
}

/**
* @return array<string,mixed>|null
*/
private function getPricing(string $provider, string $model): ?array
{
return config("llm_pricing.providers.{$provider}.{$model}");
$direct = config("llm_pricing.providers.{$provider}.{$model}");
if ($direct !== null) {
return $direct;
}

$wildcard = config("llm_pricing.providers.{$provider}.*");
if ($wildcard !== null) {
return $wildcard;
}

return null;
}
}
21 changes: 21 additions & 0 deletions app/Domain/Shared/Models/Team.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
* @property array<string, mixed>|null $settings
* @property string|null $plan
* @property array<string, mixed>|null $custom_limits
* @property string|null $sub_program_slug
* @property float|null $credit_margin_multiplier
* @property int|null $max_credits_per_call
*/
class Team extends Model
{
Expand Down Expand Up @@ -131,6 +134,24 @@ public function hasFeature(string $feature): bool
return true;
}

/**
* Community edition has no per-team override — falls through to config.
*/
public function effectiveMarginMultiplier(): float
{
return (float) config('llm_pricing.margin_multiplier', 1.30);
}

/**
* Community edition has no per-team cap — falls through to config.
*/
public function effectiveMaxCreditsPerCall(): ?int
{
$configMax = config('llm_pricing.max_credits_per_call');

return $configMax !== null ? (int) $configMax : null;
}

/**
* Look up the owner_id for a team without instantiating the model and
* without relying on TeamScope. Used as a userId fallback in actions
Expand Down
Loading
Loading