godcrm/backend/services/agent-loop/pricing.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

104 lines
4.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* agent-loop/pricing.js — ADR-165 WP-2b derived-cost pricing.
*
* WP-2a proved there is NO uniform `cost_usd` in the agent loop: native
* Anthropic/OpenAI branches emit only tokens, CLI branches drop their native
* cost. So cost is DERIVED here from token usage × per-model price — the one
* signal already universal at the two native emission points.
*
* Prices come from the AI Models table (`input_price`/`output_price`, USD per
* 1e6 tokens, synced from OpenRouter by ModelSyncService), keyed on the
* `model_id` string sent to the provider (= `resolved.model`).
*
* HARD INVARIANT (owner scope-lock): an unknown/unpriced model yields cost 0,
* so `cost_limit_usd` never trips for it — NO cap, never a wrong or zero-priced
* cap. `normalizePrice` and `deriveCostUsd` are pure (DB-free) so the invariant
* is unit-testable without a database.
*/
import { dbGet, isPostgres } from '../../database/connection.js';
import { apiLogger } from '../../utils/logger.js';
const _PER_MILLION = 1_000_000;
// ── In-memory price cache (SecretsVault form, ADR-0040): 60s TTL per model_id.
// Keeps the loop off the DB — resolution is already once-per-run, this also
// dedupes across concurrent/repeated runs of the same model. Value is the
// normalized price object OR null (an unpriced model is a valid, cached answer).
// ponytail (WP-2c): pg_notify cluster-wide eviction is deferred — a cost cap
// tolerates ≤60s price staleness, and instant eviction would need a new DB
// trigger on the AI Models table. Revisit before flipping cost enforce true.
const _PRICE_CACHE_TTL_MS = 60_000;
const _priceCache = new Map();
/**
* Coerce a raw (input_price, output_price) pair into a usable price object.
* Returns null unless BOTH are finite, non-negative numbers — a half-priced
* model would undercount cost and leak the cap, so it counts as "no price".
*/
export function normalizePrice(inputPrice, outputPrice) {
// Guard null/undefined BEFORE Number(): Number(null) === 0 would silently turn
// a missing (JSON-null) price into a $0 component — undercounting cost and
// leaking the cap. A missing half is "no price", never "free".
if (inputPrice == null || outputPrice == null) return null;
const i = Number(inputPrice);
const o = Number(outputPrice);
if (!Number.isFinite(i) || !Number.isFinite(o) || i < 0 || o < 0) return null;
return { inputPrice: i, outputPrice: o };
}
/**
* Derive USD cost for one step from token usage × per-model price.
* `price` is the object from normalizePrice/getModelPriceUsd, or null.
* Returns 0 when `price` is null (unknown model → no cost → no cap).
*/
export function deriveCostUsd(usage, price) {
if (!price) return 0;
const prompt = Number(usage?.prompt_tokens) || 0;
const completion = Number(usage?.completion_tokens) || 0;
if (prompt < 0 || completion < 0) return 0;
return (prompt * price.inputPrice + completion * price.outputPrice) / _PER_MILLION;
}
/**
* Look up per-model prices (USD per 1e6 tokens) from the AI Models table,
* keyed on the `model_id` string. Returns { inputPrice, outputPrice } or null
* when the model is unknown/unpriced (→ no cap, per invariant). Never throws —
* a pricing lookup must not break the run; failure fails open to no-cap.
*/
export async function getModelPriceUsd(modelId) {
if (!modelId || typeof modelId !== 'string') return null;
const cached = _priceCache.get(modelId);
if (cached && Date.now() < cached.expiresAt) return cached.value;
try {
const row = await dbGet(
isPostgres()
? `SELECT tr.data FROM table_rows tr
JOIN universal_tables ut ON tr.table_id = ut.id
WHERE ut.name LIKE '%Models%' AND tr.data->>'model_id' = $1
LIMIT 1`
: `SELECT tr.data FROM table_rows tr
JOIN universal_tables ut ON tr.table_id = ut.id
WHERE ut.name LIKE '%Models%' AND json_extract(tr.data, '$.model_id') = ?
LIMIT 1`,
[modelId]
);
let value = null;
if (row) {
let data = {};
try { data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {}); }
catch { data = {}; }
value = normalizePrice(data.input_price, data.output_price);
}
// Cache the resolved answer (a null = "unpriced model" is a valid cache hit).
// Transient DB errors below are NOT cached — they retry on the next run.
_priceCache.set(modelId, { value, expiresAt: Date.now() + _PRICE_CACHE_TTL_MS });
return value;
} catch (err) {
apiLogger.error({ err: err.message, modelId }, 'ADR-165 getModelPriceUsd failed — failing open to no-cap');
return null;
}
}
/** Test-only: clear the price cache so each test starts clean. @internal */
export function __resetPriceCacheForTests() { _priceCache.clear(); }