0) { return ['usdPerEur' => $own, 'asOf' => (string)($settings['pricing']['usdPerEurAsOf'] ?? ''), 'source' => 'settings']; } $fx = raijuAiPricingCatalog()['fx']; return ['usdPerEur' => (float)$fx['usdPerEur'], 'asOf' => (string)$fx['asOf'], 'source' => (string)$fx['source']]; } function raijuAiRoundCents(float $cents): float { return round($cents, RAIJU_AI_COST_DECIMALS); } /** Add costs without floating point drift. */ function raijuAiAddCents(float|int $a, float|int $b): float { return raijuAiRoundCents((float)$a + (float)$b); } /** * The price of a model, or null when none is known. * * @return array{source:string,currency:string,input:float,output:float,cacheRead:?float,cacheWrite:?float,cacheWrite1h:?float,tiers:array,migrated:bool,label:string,url:string,updatedAt:string,model:string,provider:string}|null */ function raijuAiPriceFor(array $settings, string $model): ?array { $model = trim($model); if ($model === '') return null; $provider = raijuAiProviderKey($settings); $override = $settings['pricing']['models'][raijuAiPriceKey($settings, $model)] ?? null; if (is_array($override) && isset($override['input'], $override['output'])) { return [ 'source' => 'override', 'currency' => ($override['currency'] ?? 'USD') === 'EUR' ? 'EUR' : 'USD', 'input' => (float)$override['input'], 'output' => (float)$override['output'], 'cacheRead' => isset($override['cacheRead']) ? (float)$override['cacheRead'] : null, 'cacheWrite' => isset($override['cacheWrite']) ? (float)$override['cacheWrite'] : null, 'cacheWrite1h' => isset($override['cacheWrite1h']) ? (float)$override['cacheWrite1h'] : null, 'tiers' => [], 'migrated' => !empty($override['migrated']), 'label' => $model, 'url' => '', 'updatedAt' => '', 'model' => $model, 'provider' => $provider ]; } $host = raijuAiBaseUrlHost((string)($settings['baseUrl'] ?? '')); foreach (raijuAiPricingCatalog()['models'] as $entry) { if ($entry['provider'] === $provider && strtolower($entry['host']) === $host && strtolower($entry['model']) === strtolower($model)) { return [ 'source' => 'catalog', 'currency' => raijuAiPricingCatalog()['currency'], 'input' => (float)$entry['input'], 'output' => (float)$entry['output'], 'cacheRead' => isset($entry['cacheRead']) ? (float)$entry['cacheRead'] : null, 'cacheWrite' => isset($entry['cacheWrite']) ? (float)$entry['cacheWrite'] : null, 'cacheWrite1h' => isset($entry['cacheWrite1h']) ? (float)$entry['cacheWrite1h'] : null, 'tiers' => (array)($entry['tiers'] ?? []), 'migrated' => false, 'label' => (string)($entry['label'] ?? $model), 'url' => (string)($entry['source'] ?? ''), 'updatedAt' => (string)($entry['updatedAt'] ?? ''), 'model' => $model, 'provider' => $provider ]; } } return null; } /** Input tokens that count for the context tiers: everything in the prompt, cache reads and writes included. */ function raijuAiPromptTokens(array $usage): int { return (int)($usage['input'] ?? 0) + (int)($usage['cacheRead'] ?? 0) + (int)($usage['cacheWrite'] ?? 0) + (int)($usage['cacheWrite1h'] ?? 0); } /** The rates for a request of this size (a tier over a threshold applies to the whole request). */ function raijuAiTierRates(array $price, int $promptTokens): array { $rates = [ 'input' => $price['input'], 'output' => $price['output'], 'cacheRead' => $price['cacheRead'] ?? $price['input'], 'cacheWrite' => $price['cacheWrite'] ?? $price['input'], 'cacheWrite1h' => $price['cacheWrite1h'] ?? ($price['cacheWrite'] ?? $price['input']) ]; $applied = null; foreach ($price['tiers'] as $tier) { if ($promptTokens > (int)$tier['aboveInputTokens'] && (!$applied || $tier['aboveInputTokens'] > $applied['aboveInputTokens'])) $applied = $tier; } if ($applied) { foreach (['input', 'output', 'cacheRead', 'cacheWrite', 'cacheWrite1h'] as $key) { if (isset($applied[$key])) $rates[$key] = (float)$applied[$key]; } foreach (['input' => 'inputMultiplier', 'output' => 'outputMultiplier'] as $key => $multiplier) { if (isset($applied[$multiplier])) $rates[$key] *= (float)$applied[$multiplier]; } if (isset($applied['cacheMultiplier'])) { foreach (['cacheRead', 'cacheWrite', 'cacheWrite1h'] as $key) $rates[$key] *= (float)$applied['cacheMultiplier']; } } $rates['tier'] = $applied ? (int)$applied['aboveInputTokens'] : 0; return $rates; } /** * Cost in cents of one request. * * $usage: input (normal input tokens, without cache reads and writes), cacheRead, cacheWrite, cacheWrite1h and output. * Output already contains the reasoning tokens, so they are not added again. There is no multiplier for the effort level. */ function raijuAiComputeCents(array $settings, array $price, array $usage): float { $rates = raijuAiTierRates($price, raijuAiPromptTokens($usage)); $native = ((int)($usage['input'] ?? 0) * $rates['input'] + (int)($usage['cacheRead'] ?? 0) * $rates['cacheRead'] + (int)($usage['cacheWrite'] ?? 0) * $rates['cacheWrite'] + (int)($usage['cacheWrite1h'] ?? 0) * $rates['cacheWrite1h'] + (int)($usage['output'] ?? 0) * $rates['output']) / 1000000; $eur = $price['currency'] === 'EUR' ? $native : $native / raijuAiFxRate($settings)['usdPerEur']; return raijuAiRoundCents($eur * 100); } /** What a cost was based on; stored with every ledger entry so later price changes do not rewrite history. */ function raijuAiPriceBasis(array $settings, ?array $price, array $usage): array { if ($price === null) return ['priced' => false]; $rates = raijuAiTierRates($price, raijuAiPromptTokens($usage)); $basis = [ 'priced' => true, 'source' => $price['source'], 'currency' => $price['currency'], 'provider' => $price['provider'], 'model' => $price['model'], 'input' => $rates['input'], 'output' => $rates['output'], 'cacheRead' => $rates['cacheRead'], 'cacheWrite' => $rates['cacheWrite'], 'tierAbove' => $rates['tier'] ]; if ($price['migrated']) $basis['migrated'] = true; if ($price['currency'] === 'USD') { $fx = raijuAiFxRate($settings); $basis['usdPerEur'] = $fx['usdPerEur']; $basis['fxAsOf'] = $fx['asOf']; } return $basis; } /** * Cost of a finished request. * * @return array{cents:float,priced:bool,basis:array} */ function raijuAiCostForUsage(array $settings, string $model, array $usage): array { $price = raijuAiPriceFor($settings, $model); if ($price === null) return ['cents' => 0.0, 'priced' => false, 'basis' => ['priced' => false, 'provider' => raijuAiProviderKey($settings), 'model' => $model]]; return ['cents' => raijuAiComputeCents($settings, $price, $usage), 'priced' => true, 'basis' => raijuAiPriceBasis($settings, $price, $usage)]; } /** * Worst case for the reservation before a request: the estimated input and the full output limit (plus the * thinking headroom), at the rates of the tier the input falls into. * * @return array{cents:float,priced:bool,basis:array} */ function raijuAiReservationCost(array $settings, string $model, int $inputTokens, int $maxOutput): array { return raijuAiCostForUsage($settings, $model, ['input' => $inputTokens, 'output' => $maxOutput]); } /** * Read the token counts of any provider response into normalized usage. * OpenAI: cached tokens are part of prompt_tokens / input_tokens, reasoning tokens part of the output. * Anthropic: input_tokens excludes cache reads and writes, which have their own fields. * * @return array{input:int,cacheRead:int,cacheWrite:int,cacheWrite1h:int,output:int,reasoning:int,reported:bool}|null null when the response reports no usage */ function raijuAiNormalizeUsage(array $usage): ?array { if (!$usage) return null; $anthropic = array_key_exists('cache_read_input_tokens', $usage) || array_key_exists('cache_creation_input_tokens', $usage) || array_key_exists('cache_creation', $usage); if ($anthropic) { $write1h = (int)($usage['cache_creation']['ephemeral_1h_input_tokens'] ?? 0); $writeAll = (int)($usage['cache_creation_input_tokens'] ?? 0); return ['input' => (int)($usage['input_tokens'] ?? 0), 'cacheRead' => (int)($usage['cache_read_input_tokens'] ?? 0), 'cacheWrite' => max(0, $writeAll - $write1h), 'cacheWrite1h' => min($write1h, $writeAll), 'output' => (int)($usage['output_tokens'] ?? 0), 'reasoning' => 0, 'reported' => true]; } $prompt = $usage['prompt_tokens'] ?? $usage['input_tokens'] ?? null; $completion = $usage['completion_tokens'] ?? $usage['output_tokens'] ?? null; if ($prompt === null && $completion === null) return null; $cached = (int)($usage['prompt_tokens_details']['cached_tokens'] ?? $usage['input_tokens_details']['cached_tokens'] ?? 0); $cached = max(0, min($cached, (int)$prompt)); return ['input' => (int)$prompt - $cached, 'cacheRead' => $cached, 'cacheWrite' => 0, 'cacheWrite1h' => 0, 'output' => (int)$completion, 'reasoning' => (int)($usage['completion_tokens_details']['reasoning_tokens'] ?? $usage['output_tokens_details']['reasoning_tokens'] ?? 0), 'reported' => true]; } /** Total tokens of a normalized usage as stored in the usage buckets (input side includes cache). */ function raijuAiUsageInputTotal(array $usage): int { return raijuAiPromptTokens($usage); } /** Overrides migrated from the former global price pair (cents per 1M tokens, EUR) for the models in use. */ function raijuAiMigrateLegacyPricing(array $stored, string $providerKey, array $models): array { $pricing = (array)($stored['pricing'] ?? []); if (isset($pricing['models']) || !isset($pricing['inputCentsPerMillion'])) return (array)($pricing['models'] ?? []); $overrides = []; foreach (array_unique(array_filter(array_map(static fn($m) => strtolower(trim((string)$m)), $models))) as $model) { $overrides[$providerKey . '|' . $model] = [ 'input' => (float)$pricing['inputCentsPerMillion'] / 100, 'output' => (float)($pricing['outputCentsPerMillion'] ?? 0) / 100, 'currency' => 'EUR', 'migrated' => true ]; } return $overrides; } /** Validated overrides from the settings form. Existing ones not mentioned stay; a null entry removes one. */ function raijuAiCleanPriceOverrides($input, array $existing): array { $result = $existing; foreach ((is_array($input) ? $input : []) as $key => $entry) { $key = strtolower((string)$key); if (!preg_match('/^(openai-compatible|openrouter|anthropic|kie)\|[a-z0-9._:\/-]{1,120}$/', $key)) continue; if ($entry === null || !is_array($entry)) { unset($result[$key]); continue; } if (!isset($entry['input']) || !isset($entry['output']) || $entry['input'] === '' || $entry['output'] === '') { unset($result[$key]); continue; } $clean = ['input' => round(max(0.0, min(100000.0, (float)$entry['input'])), 6), 'output' => round(max(0.0, min(100000.0, (float)$entry['output'])), 6), 'currency' => ($entry['currency'] ?? 'USD') === 'EUR' ? 'EUR' : 'USD']; foreach (['cacheRead', 'cacheWrite', 'cacheWrite1h'] as $field) { if (isset($entry[$field]) && $entry[$field] !== '') $clean[$field] = round(max(0.0, min(100000.0, (float)$entry[$field])), 6); } $before = $existing[$key] ?? null; if (!empty($before['migrated']) && $before['input'] == $clean['input'] && $before['output'] == $clean['output'] && $before['currency'] === $clean['currency']) $clean['migrated'] = true; $result[$key] = $clean; } return $result; } /** What the settings page shows about prices: catalog entries, the rate with its date and the unit. */ function raijuAiPricingForUi(array $settings): array { $catalog = raijuAiPricingCatalog(); $models = []; foreach ($catalog['models'] as $entry) { $models[] = array_intersect_key($entry, array_flip(['provider', 'host', 'model', 'label', 'input', 'output', 'cacheRead', 'cacheWrite', 'tiers', 'source', 'updatedAt'])); } return ['currency' => $catalog['currency'], 'updatedAt' => $catalog['updatedAt'], 'fx' => raijuAiFxRate($settings), 'catalogFx' => $catalog['fx'], 'models' => $models]; }