Skip to content
Open
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
65 changes: 65 additions & 0 deletions lib/pricing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// ── LLM Pricing (via @pydantic/genai-prices) ──
const { calcPrice } = require('@pydantic/genai-prices');

// Free models (no cost regardless of pricing data)
const FREE_MODELS = new Set([
'xiaomi/mimo-v2-pro',
'mimo-v2-pro',
]);

// Hermes billing_provider → genai-prices providerId mapping
const PROVIDER_MAP = {
openrouter: 'openrouter',
'openai-codex': 'openai',
'opencode-go': 'openrouter', // minimax available via OpenRouter pricing
};

// Custom pricing for models not in genai-prices (per million tokens).
// MiniMax-M3 uses the standard <=512k baseline; the published long-context
// threshold has no exact integer expansion, so the fallback avoids guessing it.
const CUSTOM_PRICING = {
'minimax-m2': { input_mtok: 0.30, output_mtok: 1.20, cache_read_mtok: 0.03 },
'minimax-m2.7': { input_mtok: 0.30, output_mtok: 1.20, cache_read_mtok: 0.06 },
'minimax-m3.1-flash': { input_mtok: 0.15, output_mtok: 0.60, cache_read_mtok: 0.015 },
'minimax-m3': { input_mtok: 0.30, output_mtok: 1.20, cache_read_mtok: 0.06 },
};

// Calculate cost in USD from token counts
function calculateCost(model, inputTokens, outputTokens, cacheReadTokens = 0, billingProvider) {
if (!model || FREE_MODELS.has(model)) return 0;

// genai-prices expects input_tokens = total input (including cache)
// Our DB stores input and cache separately, so combine them
const usage = {
input_tokens: inputTokens + cacheReadTokens,
output_tokens: outputTokens,
cache_read_tokens: cacheReadTokens,
};

// 1. Try with provider hint
const providerId = PROVIDER_MAP[billingProvider];
if (providerId) {
try {
const r = calcPrice(usage, model, { providerId });
if (r) return r.total_price;
} catch (_) { /* fallback to next */ }
}

// 2. Try without provider (match across all providers)
try {
const r = calcPrice(usage, model);
if (r) return r.total_price;
} catch (_) { /* fallback to custom pricing */ }

// 3. Custom pricing fallback
const custom = CUSTOM_PRICING[String(model).toLowerCase()];
if (custom) {
return (inputTokens / 1e6) * custom.input_mtok
+ (outputTokens / 1e6) * custom.output_mtok
+ (cacheReadTokens / 1e6) * (custom.cache_read_mtok || custom.input_mtok * 0.1);
}

return 0;
}

module.exports = { calculateCost };
60 changes: 1 addition & 59 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,65 +21,7 @@ const {
// ── TUI Gateway Bridge ──
const { getBridge, killAllBridges } = require('./lib/tui-gateway-bridge');

// ── LLM Pricing (via @pydantic/genai-prices) ──
const { calcPrice } = require('@pydantic/genai-prices');

// Free models (no cost regardless of pricing data)
const FREE_MODELS = new Set([
'xiaomi/mimo-v2-pro',
'mimo-v2-pro',
]);

// Hermes billing_provider → genai-prices providerId mapping
const PROVIDER_MAP = {
'openrouter': 'openrouter',
'openai-codex': 'openai',
'opencode-go': 'openrouter', // minimax available via OpenRouter pricing
};

// Custom pricing for models not in genai-prices (per million tokens)
const CUSTOM_PRICING = {
'minimax-m2': { input_mtok: 0.30, output_mtok: 1.20, cache_read_mtok: 0.03 },
'minimax-m2.7': { input_mtok: 0.30, output_mtok: 1.20, cache_read_mtok: 0.03 },
};

// Calculate cost in USD from token counts
function calculateCost(model, inputTokens, outputTokens, cacheReadTokens = 0, billingProvider) {
if (!model || FREE_MODELS.has(model)) return 0;

// genai-prices expects input_tokens = total input (including cache)
// Our DB stores input and cache separately, so combine them
const usage = {
input_tokens: inputTokens + cacheReadTokens,
output_tokens: outputTokens,
cache_read_tokens: cacheReadTokens,
};

// 1. Try with provider hint
const providerId = PROVIDER_MAP[billingProvider];
if (providerId) {
try {
const r = calcPrice(usage, model, { providerId });
if (r) return r.total_price;
} catch (_) { /* fallback to next */ }
}

// 2. Try without provider (match across all providers)
try {
const r = calcPrice(usage, model);
if (r) return r.total_price;
} catch (_) { /* fallback to custom pricing */ }

// 3. Custom pricing fallback
const custom = CUSTOM_PRICING[model];
if (custom) {
return (inputTokens / 1e6) * custom.input_mtok
+ (outputTokens / 1e6) * custom.output_mtok
+ (cacheReadTokens / 1e6) * (custom.cache_read_mtok || custom.input_mtok * 0.1);
}

return 0;
}
const { calculateCost } = require('./lib/pricing');

// Async shell execution utility (non-blocking)
function parseShellTimeout(value) {
Expand Down
58 changes: 58 additions & 0 deletions test/pricing.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Module = require('node:module');

function loadPricing(calcPrice) {
const originalLoad = Module._load;
Module._load = function load(request, parent, isMain) {
if (request === '@pydantic/genai-prices') return { calcPrice };
return originalLoad.call(this, request, parent, isMain);
};

try {
delete require.cache[require.resolve('../lib/pricing')];
return require('../lib/pricing');
} finally {
Module._load = originalLoad;
}
}

test('MiniMax M3.1 Flash fallback uses the documented direct rates and provider hint', () => {
const calls = [];
const { calculateCost } = loadPricing((usage, model, options) => {
calls.push({ usage, model, options });
return null;
});

assert.equal(
calculateCost('MiniMax-M3.1-Flash', 1_000_000, 1_000_000, 1_000_000, 'opencode-go'),
0.765
);
assert.deepEqual(calls, [
{
usage: { input_tokens: 2_000_000, output_tokens: 1_000_000, cache_read_tokens: 1_000_000 },
model: 'MiniMax-M3.1-Flash',
options: { providerId: 'openrouter' },
},
{
usage: { input_tokens: 2_000_000, output_tokens: 1_000_000, cache_read_tokens: 1_000_000 },
model: 'MiniMax-M3.1-Flash',
options: undefined,
},
]);
});

test('MiniMax M3 fallback stays on the documented standard baseline', () => {
const { calculateCost } = loadPricing(() => null);

assert.equal(calculateCost('minimax-m3', 1_000_000, 1_000_000, 1_000_000), 1.56);
});

test('SDK pricing remains authoritative and legacy/free/unknown behavior is preserved', () => {
const { calculateCost: calculateWithSdk } = loadPricing(() => ({ total_price: 7.25 }));
assert.equal(calculateWithSdk('minimax-m3.1-flash', 1_000_000, 1_000_000), 7.25);

const { calculateCost: calculateWithFallback } = loadPricing(() => null);
assert.equal(calculateWithFallback('mimo-v2-pro', 1_000_000, 1_000_000), 0);
assert.equal(calculateWithFallback('custom/saved-model', 1_000_000, 1_000_000), 0);
});