diff --git a/plugins/ms-ai-architect/scripts/kb-update/lib/cost-estimat.mjs b/plugins/ms-ai-architect/scripts/kb-update/lib/cost-estimat.mjs new file mode 100644 index 0000000..9f490fd --- /dev/null +++ b/plugins/ms-ai-architect/scripts/kb-update/lib/cost-estimat.mjs @@ -0,0 +1,36 @@ +// cost-estimat.mjs — Heuristic cost-estimate for KB-update runs. +// Pure function. Auth-mode-aware: api-key returns numeric USD, +// subscription modes return null USD + kvote_warn flag. +// Zero dependencies. + +const AVG_INPUT_TOKENS_PER_FILE = 3000; +const AVG_OUTPUT_TOKENS_PER_FILE = 1500; +const SONNET_INPUT_USD_PER_M = 3.0; +const SONNET_OUTPUT_USD_PER_M = 15.0; + +const SUBSCRIPTION_MODES = new Set(['long-oauth', 'subscription-browser-only']); + +/** + * Estimate cost (and quota-warn flag) for a run of N files at given priorities. + * Filters to critical + high only (medium/low excluded per brief). + * + * @param {object} priorities — { critical, high, medium, low } file counts + * @param {object} [opts] + * @param {string} [opts.authMode] — 'api-key' | 'long-oauth' | 'subscription-browser-only' | 'unauthenticated' + * @returns {{tokens_input: number, tokens_output: number, usd: number|null, kvote_warn: boolean}} + */ +export function estimateCost(priorities = {}, opts = {}) { + const authMode = opts.authMode ?? 'api-key'; + const fileCount = (priorities.critical ?? 0) + (priorities.high ?? 0); + const tokens_input = fileCount * AVG_INPUT_TOKENS_PER_FILE; + const tokens_output = fileCount * AVG_OUTPUT_TOKENS_PER_FILE; + + if (SUBSCRIPTION_MODES.has(authMode)) { + return { tokens_input, tokens_output, usd: null, kvote_warn: true }; + } + + const usd = + (tokens_input / 1_000_000) * SONNET_INPUT_USD_PER_M + + (tokens_output / 1_000_000) * SONNET_OUTPUT_USD_PER_M; + return { tokens_input, tokens_output, usd, kvote_warn: false }; +} diff --git a/plugins/ms-ai-architect/tests/kb-update/test-cost-estimat.test.mjs b/plugins/ms-ai-architect/tests/kb-update/test-cost-estimat.test.mjs new file mode 100644 index 0000000..497e6e6 --- /dev/null +++ b/plugins/ms-ai-architect/tests/kb-update/test-cost-estimat.test.mjs @@ -0,0 +1,82 @@ +// tests/kb-update/test-cost-estimat.test.mjs +// Unit tests for scripts/kb-update/lib/cost-estimat.mjs + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { estimateCost } from '../../scripts/kb-update/lib/cost-estimat.mjs'; + +test('estimateCost — api-key returns numeric usd, kvote_warn unset', () => { + const result = estimateCost({ critical: 3, high: 15 }, { authMode: 'api-key' }); + assert.equal(typeof result.usd, 'number'); + assert.equal(result.kvote_warn, false); + assert.ok(result.usd > 0); +}); + +test('estimateCost — api-key empty input returns 0 USD', () => { + const result = estimateCost({}, { authMode: 'api-key' }); + assert.equal(result.usd, 0); + assert.equal(result.kvote_warn, false); + assert.equal(result.tokens_input, 0); + assert.equal(result.tokens_output, 0); +}); + +test('estimateCost — api-key tokens are integers', () => { + const result = estimateCost({ critical: 3, high: 15 }, { authMode: 'api-key' }); + assert.equal(Number.isInteger(result.tokens_input), true); + assert.equal(Number.isInteger(result.tokens_output), true); +}); + +test('estimateCost — ignores medium and low (only critical+high counted)', () => { + const a = estimateCost({ critical: 1, high: 1 }, { authMode: 'api-key' }); + const b = estimateCost({ critical: 1, high: 1, medium: 100, low: 100 }, { authMode: 'api-key' }); + assert.equal(a.usd, b.usd); + assert.equal(a.tokens_input, b.tokens_input); +}); + +test('estimateCost — long-oauth returns null usd, kvote_warn flag set', () => { + const result = estimateCost({ critical: 3, high: 15 }, { authMode: 'long-oauth' }); + assert.strictEqual(result.usd, null); + assert.strictEqual(result.kvote_warn, true); +}); + +test('estimateCost — subscription-browser-only returns null usd, kvote_warn flag set', () => { + const result = estimateCost({ critical: 3, high: 15 }, { authMode: 'subscription-browser-only' }); + assert.strictEqual(result.usd, null); + assert.strictEqual(result.kvote_warn, true); +}); + +test('estimateCost — auth-mode does not affect token math', () => { + const apikey = estimateCost({ critical: 5, high: 10 }, { authMode: 'api-key' }); + const oauth = estimateCost({ critical: 5, high: 10 }, { authMode: 'long-oauth' }); + const sub = estimateCost({ critical: 5, high: 10 }, { authMode: 'subscription-browser-only' }); + assert.equal(apikey.tokens_input, oauth.tokens_input); + assert.equal(apikey.tokens_input, sub.tokens_input); + assert.equal(apikey.tokens_output, oauth.tokens_output); + assert.equal(apikey.tokens_output, sub.tokens_output); +}); + +test('estimateCost — unauthenticated treated as best-effort api-key', () => { + const result = estimateCost({ critical: 3, high: 15 }, { authMode: 'unauthenticated' }); + assert.equal(typeof result.usd, 'number'); + assert.equal(result.kvote_warn, false); +}); + +test('estimateCost — missing authMode opt treated as best-effort api-key', () => { + const result = estimateCost({ critical: 3, high: 15 }); + assert.equal(typeof result.usd, 'number'); + assert.equal(result.kvote_warn, false); +}); + +test('estimateCost — unknown priority keys are ignored', () => { + const result = estimateCost({ critical: 1, high: 1, weird: 999 }, { authMode: 'api-key' }); + // Should equal {critical:1, high:1} alone + const baseline = estimateCost({ critical: 1, high: 1 }, { authMode: 'api-key' }); + assert.equal(result.usd, baseline.usd); +}); + +test('estimateCost — fixture {critical: 3, high: 15} produces expected order of magnitude', () => { + // 18 files * (3000 in + 1500 out) tokens = 54k in, 27k out + // api-key cost: 54k * $3/M + 27k * $15/M = $0.162 + $0.405 = $0.567 + const result = estimateCost({ critical: 3, high: 15 }, { authMode: 'api-key' }); + assert.ok(result.usd > 0.4 && result.usd < 0.8, `expected ~$0.567, got $${result.usd}`); +});