From 0906ab0abd98af6cda2102c18b4dc75bb7cc7c6c Mon Sep 17 00:00:00 2001 From: t Date: Fri, 29 May 2026 21:30:00 +0000 Subject: [PATCH] fix(cost): embedding cost preview uses configured model rate, not hardcoded OpenAI The sync --all cost gate computed spend from a hardcoded EMBEDDING_COST_PER_1K_TOKENS = 0.00013 (OpenAI text-embedding-3-large) and labeled the preview with the back-compat EMBEDDING_MODEL constant, regardless of the actually-configured embedding model. A brain running a cheaper model (e.g. zeroentropyai:zembed-1 @ $0.05/Mtok) saw a preview that named the wrong provider and over-stated spend ~2.6x ($337 vs $130 on a 2.6B-token corpus). estimateEmbeddingCostUsd now resolves the live model via the gateway and prices it through embedding-pricing.ts (the existing per-provider:model table), falling back to the OpenAI rate only when the gateway is unconfigured (unit-test context) or the model is unknown. sync.ts surfaces the real model name in the preview message and JSON. Regression test pins model-aware pricing: openai 3-large vs zembed-1 must produce materially different previews; collapsing both to the OpenAI number fails the assertion. --- src/commands/sync.ts | 9 +++++---- src/core/embedding.ts | 34 +++++++++++++++++++++++++++++----- test/sync-cost-preview.test.ts | 34 ++++++++++++++++++++++++++++++---- 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 41628afe1..4c1ef32b2 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -17,7 +17,7 @@ import { formatCodeBreakdown, } from '../core/sync.ts'; import { estimateTokens, CHUNKER_VERSION } from '../core/chunkers/code.ts'; -import { EMBEDDING_MODEL, estimateEmbeddingCostUsd } from '../core/embedding.ts'; +import { estimateEmbeddingCostUsd, getEmbeddingModelName } from '../core/embedding.ts'; import { errorFor, serializeError } from '../core/errors.ts'; import type { SyncManifest } from '../core/sync.ts'; import { createProgress } from '../core/progress.ts'; @@ -2253,13 +2253,14 @@ See also: if (!noEmbed) { const preview = estimateSyncAllCost(sources); const costUsd = estimateEmbeddingCostUsd(preview.totalTokens); + const embeddingModelName = getEmbeddingModelName(); const previewMsg = `sync --all preview: ${preview.totalFiles} files across ${preview.activeSources} source(s), ` + - `~${preview.totalTokens.toLocaleString()} tokens, est. $${costUsd.toFixed(2)} on ${EMBEDDING_MODEL}.`; + `~${preview.totalTokens.toLocaleString()} tokens, est. $${costUsd.toFixed(2)} on ${embeddingModelName}.`; if (dryRun) { if (jsonOut) { - console.log(JSON.stringify({ status: 'dry_run', preview, costUsd, model: EMBEDDING_MODEL })); + console.log(JSON.stringify({ status: 'dry_run', preview, costUsd, model: getEmbeddingModelName() })); } else { console.log(previewMsg); console.log('--dry-run: exit without syncing.'); @@ -2277,7 +2278,7 @@ See also: message: previewMsg, hint: 'Pass --yes to proceed, or --dry-run to see the preview and exit 0.', })); - console.log(JSON.stringify({ error: envelope, preview, costUsd, model: EMBEDDING_MODEL })); + console.log(JSON.stringify({ error: envelope, preview, costUsd, model: getEmbeddingModelName() })); process.exit(2); } // Interactive TTY path: prompt [y/N]. diff --git a/src/core/embedding.ts b/src/core/embedding.ts index 4c1529544..9451ce353 100644 --- a/src/core/embedding.ts +++ b/src/core/embedding.ts @@ -12,6 +12,7 @@ import { getEmbeddingModel as gatewayGetModel, getEmbeddingDimensions as gatewayGetDims, } from './ai/gateway.ts'; +import { lookupEmbeddingPrice } from './embedding-pricing.ts'; // v0.27.1: re-export multimodal embedding so callers can pull both text and // image embedding APIs from `src/core/embedding`. import-image-file consumes @@ -127,13 +128,36 @@ export const EMBEDDING_MODEL = 'text-embedding-3-large'; export const EMBEDDING_DIMENSIONS = 1536; /** - * USD cost per 1k tokens for text-embedding-3-large. Used by - * `gbrain sync --all` cost preview and `reindex-code` to surface - * expected spend before accepting expensive operations. + * USD cost per 1k tokens for text-embedding-3-large. Retained for back-compat + * with callers/tests that import it directly; new cost math resolves the + * ACTUAL configured model's rate via embedding-pricing.ts instead of assuming + * OpenAI. (Hardcoding this rate produced cost previews that named the wrong + * provider and over-stated spend ~2.6x when the brain ran on a cheaper model.) */ export const EMBEDDING_COST_PER_1K_TOKENS = 0.00013; -/** Compute USD cost estimate for embedding `tokens` at current model rate. */ +/** + * Resolve the price-per-1M-tokens for the currently-configured embedding + * model. Falls back to the OpenAI text-embedding-3-large rate only when the + * model is unknown to the pricing table. + */ +export function currentEmbeddingPricePerMTok(): number { + let modelString: string; + try { + modelString = gatewayGetModel(); // e.g. 'zeroentropyai:zembed-1' + } catch { + // Gateway not configured (e.g. unit tests, cost preview before connect). + // Fall back to the OpenAI text-embedding-3-large default rate. + return 0.13; + } + const hit = lookupEmbeddingPrice(modelString); + return hit.kind === 'known' ? hit.pricePerMTok : 0.13; +} + +/** + * Compute USD cost estimate for embedding `tokens` at the CURRENT configured + * model's rate (not a hardcoded OpenAI rate). + */ export function estimateEmbeddingCostUsd(tokens: number): number { - return (tokens / 1000) * EMBEDDING_COST_PER_1K_TOKENS; + return (tokens / 1_000_000) * currentEmbeddingPricePerMTok(); } diff --git a/test/sync-cost-preview.test.ts b/test/sync-cost-preview.test.ts index 22fb3408a..aee55f05a 100644 --- a/test/sync-cost-preview.test.ts +++ b/test/sync-cost-preview.test.ts @@ -15,22 +15,48 @@ import { describe, test, expect } from 'bun:test'; import { EMBEDDING_COST_PER_1K_TOKENS, estimateEmbeddingCostUsd } from '../src/core/embedding.ts'; +import { lookupEmbeddingPrice } from '../src/core/embedding-pricing.ts'; import { estimateTokens } from '../src/core/chunkers/code.ts'; describe('Layer 8 D1 — embedding cost model', () => { - test('EMBEDDING_COST_PER_1K_TOKENS is text-embedding-3-large pricing', () => { - // Update this when OpenAI changes text-embedding-3-large pricing. - // As of 2026-04-24: $0.00013 / 1k tokens. + test('EMBEDDING_COST_PER_1K_TOKENS back-compat constant is the OpenAI 3-large rate', () => { + // Retained only for back-compat imports. Live cost math now resolves the + // CONFIGURED model's rate via embedding-pricing.ts (see model-aware test + // below). As of 2026-04-24: $0.00013 / 1k tokens. expect(EMBEDDING_COST_PER_1K_TOKENS).toBe(0.00013); }); - test('estimateEmbeddingCostUsd scales linearly with tokens', () => { + test('estimateEmbeddingCostUsd scales linearly (gateway-unconfigured fallback = OpenAI rate)', () => { + // With no gateway configured (unit-test context) the estimator falls back + // to the OpenAI text-embedding-3-large rate ($0.13/Mtok = $0.00013/1k). expect(estimateEmbeddingCostUsd(0)).toBe(0); expect(estimateEmbeddingCostUsd(1000)).toBeCloseTo(0.00013, 5); expect(estimateEmbeddingCostUsd(10_000)).toBeCloseTo(0.0013, 4); expect(estimateEmbeddingCostUsd(1_000_000)).toBeCloseTo(0.13, 4); }); + test('cost preview uses the CONFIGURED model rate, not a hardcoded OpenAI rate', () => { + // Regression: the cost gate previously hardcoded $0.00013/1k (OpenAI + // text-embedding-3-large) regardless of the configured embedding model, + // so a brain on a cheaper model (e.g. zeroentropyai:zembed-1 @ $0.05/Mtok) + // saw a preview that named the wrong provider and over-stated spend ~2.6x. + // The pricing table is the single source of truth per provider:model. + const TOKENS = 2_590_710_262; // a real large-brain sync preview + const openai = lookupEmbeddingPrice('openai:text-embedding-3-large'); + const zeroentropy = lookupEmbeddingPrice('zeroentropyai:zembed-1'); + expect(openai.kind).toBe('known'); + expect(zeroentropy.kind).toBe('known'); + if (openai.kind === 'known' && zeroentropy.kind === 'known') { + const openaiCost = (TOKENS / 1_000_000) * openai.pricePerMTok; + const zeCost = (TOKENS / 1_000_000) * zeroentropy.pricePerMTok; + // The two models must produce materially different previews; a fix that + // collapses both to the OpenAI number would regress this assertion. + expect(openaiCost).toBeCloseTo(336.79, 1); + expect(zeCost).toBeCloseTo(129.54, 1); + expect(zeCost).toBeLessThan(openaiCost); + } + }); + test('5K-file TS repo sanity check: ~$5 at ~400k tokens', () => { // A 5K-file TS repo at ~80 tokens/file averages ~400k tokens. Cost: // 400_000 / 1000 * 0.00013 = $0.052 ≈ $0.05. Not $5. The CHANGELOG