Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
104 lines
4.7 KiB
JavaScript
104 lines
4.7 KiB
JavaScript
/**
|
||
* 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(); }
|