diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index bfb02f892..1e4ccbabb 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -528,6 +528,70 @@ addition to the `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` / metadata (a known catalog model keeps its catalog limit unless you set an *exact* env override for it). +### Exact-model pricing overrides (`settings.json`) + +Use `modelPricing` when a gateway's real price differs from OpenClaude's +built-in price or unknown-model estimate. Keys match the exact, case-sensitive +model identifier sent to the API. They are not prefixes, aliases, globs, or +route/profile-qualified keys. + +The four token fields are USD per 1,000,000 tokens. `webSearchRequests` is USD +per request; it defaults to `$0.01` when omitted. All four token fields are +required so an omitted cache rate never falls through to an unrelated built-in +price. Explicit zero is supported, including fully free gateways. The GLM +entry below is an illustrative paid estimate; replace it with your actual +gateway rates: + +```json +{ + "modelPricing": { + "nvidia/llama-3.1-nemotron-70b-instruct": { + "inputTokens": 0, + "outputTokens": 0, + "promptCacheReadTokens": 0, + "promptCacheWriteTokens": 0, + "webSearchRequests": 0 + }, + "z-ai/glm-5.2": { + "inputTokens": 0.6, + "outputTokens": 2.2, + "promptCacheReadTokens": 0.06, + "promptCacheWriteTokens": 0.75, + "webSearchRequests": 0.01 + } + } +} +``` + +Set this in user settings (`~/.openclaude/settings.json`), local gitignored +settings (`.openclaude/settings.local.json`), a `--settings`/SDK settings +source, or managed settings. Shared project settings +(`.openclaude/settings.json`) are deliberately ignored for `modelPricing`, so +repository content cannot silently change personal USD accounting. Under the +current architecture, one key applies to that exact model id across every +route and profile; route-specific prices are not represented yet. + +Pricing precedence is: + +1. exact trusted `modelPricing` entry; +2. built-in known-model pricing, including fast-mode pricing; +3. the existing unknown-model estimate and warning. + +For ordinary provider routes, find the exact pre-canonicalization identifier by +running with `OPENCLAUDE_LOG_TOKEN_USAGE=verbose` and copying the JSON log +line's `model` field. Use that value verbatim. Bedrock application inference +profiles are the exception: cost calculation uses the profile's resolved +backing model id. For example, an override for `claude-opus-4-8` does not match +a resolved id such as `claude-opus-4-8-20260815`; dated, backing, or +provider-suffixed ids need their own exact keys. + +Rates must be finite and nonnegative. Token rates are capped at `$100,000` per +million tokens, web-search rates at `$1,000` per request, model ids at 512 +characters, and the map at 256 entries. These deliberately generous bounds +reject accidental absurd values. An invalid pricing map is ignored without +discarding unrelated settings from the same file. `/config` does not currently +edit record-valued settings, so edit the JSON file directly. + ## Safety strictness OpenClaude runs several "safety" checks: a model-level refusal directive, bash diff --git a/src/QueryEngine.customPricingBudget.test.ts b/src/QueryEngine.customPricingBudget.test.ts new file mode 100644 index 000000000..b5f21d393 --- /dev/null +++ b/src/QueryEngine.customPricingBudget.test.ts @@ -0,0 +1,37 @@ +import { test } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const FIXTURE_TIMEOUT_MS = 60_000 + +test( + 'SDK result and maxBudgetUsd use positive and zero custom prices', + () => { + const fixture = resolve( + repoRoot, + 'src/test/fixtures/queryEngineCustomPricingBudget.fixture.ts', + ) + const result = spawnSync(process.execPath, [fixture], { + cwd: repoRoot, + encoding: 'utf8', + timeout: FIXTURE_TIMEOUT_MS, + env: { ...process.env, FORCE_COLOR: '0' }, + }) + + if (result.error) throw result.error + if (result.status !== 0) { + throw new Error( + [ + `Fixture exited with status ${result.status ?? 'unknown'}.`, + result.stdout.trim(), + result.stderr.trim(), + ] + .filter(Boolean) + .join('\n\n'), + ) + } + }, + { timeout: FIXTURE_TIMEOUT_MS + 5_000 }, +) diff --git a/src/commands/fast/fast.customPricing.test.ts b/src/commands/fast/fast.customPricing.test.ts new file mode 100644 index 000000000..4ca3f14d1 --- /dev/null +++ b/src/commands/fast/fast.customPricing.test.ts @@ -0,0 +1,36 @@ +import { test } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..') +const FIXTURE_TIMEOUT_MS = 60_000 + +test( + '/model and /fast price strings use the exact custom price', + () => { + const fixture = resolve( + repoRoot, + 'src/test/fixtures/customPricingDisplay.fixture.tsx', + ) + const result = spawnSync(process.execPath, [fixture], { + cwd: repoRoot, + encoding: 'utf8', + timeout: FIXTURE_TIMEOUT_MS, + env: { ...process.env, FORCE_COLOR: '0' }, + }) + if (result.error) throw result.error + if (result.status !== 0) { + throw new Error( + [ + `Fixture exited with status ${result.status ?? 'unknown'}.`, + result.stdout.trim(), + result.stderr.trim(), + ] + .filter(Boolean) + .join('\n\n'), + ) + } + }, + { timeout: FIXTURE_TIMEOUT_MS + 5_000 }, +) diff --git a/src/commands/fast/fast.tsx b/src/commands/fast/fast.tsx index af460c56a..2060d8f30 100644 --- a/src/commands/fast/fast.tsx +++ b/src/commands/fast/fast.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js'; import { Dialog } from '../../components/design-system/Dialog.js'; import { FastIcon, getFastIconString } from '../../components/FastIcon.js'; +import { useSettings } from '../../hooks/useSettings.js'; import { Box, Link, Text } from '../../ink.js'; import { useKeybindings } from '../../keybindings/useKeybinding.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; @@ -11,7 +12,8 @@ import { type AppState, useAppState, useSetAppState } from '../../state/AppState import type { LocalJSXCommandOnDone } from '../../types/command.js'; import { clearFastModeCooldown, FAST_MODE_MODEL_DISPLAY, getFastModeModel, getFastModeRuntimeState, getFastModeUnavailableReason, isFastModeEnabled, isFastModeSupportedByModel, prefetchFastModeStatus } from '../../utils/fastMode.js'; import { formatDuration } from '../../utils/format.js'; -import { formatModelPricing, getOpus46CostTier } from '../../utils/modelCost.js'; +import { getDefaultOpusModel } from '../../utils/model/model.js'; +import { getModelPricingString } from '../../utils/modelCost.js'; import { updateSettingsForSource } from '../../utils/settings/settings.js'; function applyFastMode(enable: boolean, setAppState: (f: (prev: AppState) => AppState) => void): void { clearFastModeCooldown(); @@ -47,6 +49,7 @@ export function FastModePicker(t0) { const model = useAppState(_temp); const initialFastMode = useAppState(_temp2); const setAppState = useSetAppState(); + useSettings(); const [enableFastMode, setEnableFastMode] = useState(initialFastMode ?? false); let t1; if ($[0] === Symbol.for("react.memo_cache_sentinel")) { @@ -58,14 +61,11 @@ export function FastModePicker(t0) { const runtimeState = t1; const isCooldown = runtimeState.status === "cooldown"; const isUnavailable = unavailableReason !== null; - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = formatModelPricing(getOpus46CostTier(true)); - $[1] = t2; - } else { - t2 = $[1]; - } - const pricing = t2; + // Settings are reloadable while the process is running, so do not pin this + // external-store-derived price in the compiler memo cache. + const pricing = getModelPricingString(getDefaultOpusModel(), { + speed: 'fast' + }) ?? ''; let t3; if ($[2] !== enableFastMode || $[3] !== isUnavailable || $[4] !== model || $[5] !== onDone || $[6] !== setAppState) { t3 = function handleConfirm() { @@ -80,7 +80,10 @@ export function FastModePicker(t0) { if (enableFastMode) { const fastIcon = getFastIconString(enableFastMode); const modelUpdated = !isFastModeSupportedByModel(model) ? ` · model set to ${FAST_MODE_MODEL_DISPLAY}` : ""; - onDone(`${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`); + const confirmationPricing = getModelPricingString(getDefaultOpusModel(), { + speed: 'fast' + }) ?? ''; + onDone(`${fastIcon} Fast mode ON${modelUpdated} · ${confirmationPricing}`); } else { setAppState(_temp3); onDone("Fast mode OFF"); @@ -178,17 +181,11 @@ export function FastModePicker(t0) { } else { t9 = $[21]; } - let t10; - if ($[22] !== enableFastMode || $[23] !== unavailableReason) { - t10 = unavailableReason ? {unavailableReason} : <>Fast mode{enableFastMode ? "ON " : "OFF"}{pricing}{isCooldown && runtimeState.status === "cooldown" && {runtimeState.reason === "overloaded" ? "Fast mode overloaded and is temporarily unavailable" : "You've hit your fast limit"}{" \xB7 resets in "}{formatDuration(runtimeState.resetAt - Date.now(), { - hideTrailingZeros: true - })}}; - $[22] = enableFastMode; - $[23] = unavailableReason; - $[24] = t10; - } else { - t10 = $[24]; - } + // Pricing comes from the reloadable settings cache, so keep the rendered + // value out of the compiler memo cache as well as the confirmation callback. + const t10 = unavailableReason ? {unavailableReason} : <>Fast mode{enableFastMode ? "ON " : "OFF"}{pricing}{isCooldown && runtimeState.status === "cooldown" && {runtimeState.reason === "overloaded" ? "Fast mode overloaded and is temporarily unavailable" : "You've hit your fast limit"}{" \xB7 resets in "}{formatDuration(runtimeState.resetAt - Date.now(), { + hideTrailingZeros: true + })}}; let t11; if ($[25] === Symbol.for("react.memo_cache_sentinel")) { t11 = Learn more:{" "}https://code.claude.com/docs/en/fast-mode; @@ -223,7 +220,7 @@ function _temp2(s_0) { function _temp(s) { return s.mainLoopModel; } -async function handleFastModeShortcut(enable: boolean, getAppState: () => AppState, setAppState: (f: (prev: AppState) => AppState) => void): Promise { +export async function handleFastModeShortcut(enable: boolean, getAppState: () => AppState, setAppState: (f: (prev: AppState) => AppState) => void): Promise { const unavailableReason = getFastModeUnavailableReason(); if (unavailableReason) { return `Fast mode unavailable: ${unavailableReason}`; @@ -239,7 +236,9 @@ async function handleFastModeShortcut(enable: boolean, getAppState: () => AppSta if (enable) { const fastIcon = getFastIconString(true); const modelUpdated = !isFastModeSupportedByModel(mainLoopModel) ? ` · model set to ${FAST_MODE_MODEL_DISPLAY}` : ''; - const pricing = formatModelPricing(getOpus46CostTier(true)); + const pricing = getModelPricingString(getDefaultOpusModel(), { + speed: 'fast' + }) ?? ''; return `${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`; } else { return `Fast mode OFF`; diff --git a/src/cost-tracker.customPricing.test.ts b/src/cost-tracker.customPricing.test.ts new file mode 100644 index 000000000..971b61d26 --- /dev/null +++ b/src/cost-tracker.customPricing.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { + getAllowedSettingSources, + getFlagSettingsInline, + getFlagSettingsPath, + setAllowedSettingSources, + setFlagSettingsInline, + setFlagSettingsPath, +} from './bootstrap/state.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from './test/sharedMutationLock.js' +import { + addToTotalSessionCost, + formatTotalCost, + getModelUsage, + getTotalCost, + hasUnknownModelCost, + resetCostState, +} from './cost-tracker.js' +import { resetSettingsCache } from './utils/settings/settingsCache.js' +import { + calculateCostFromTokens, + calculateUSDCost, +} from './utils/modelCost.js' + +let tempDir: string +let originalSources: ReturnType +let originalFlagPath: string | undefined +let originalFlagInline: Record | null + +beforeEach(async () => { + await acquireSharedMutationLock('cost-tracker.customPricing.test.ts') + tempDir = mkdtempSync(join(tmpdir(), 'openclaude-cost-pricing-')) + originalSources = [...getAllowedSettingSources()] + originalFlagPath = getFlagSettingsPath() + originalFlagInline = getFlagSettingsInline() + setAllowedSettingSources(['flagSettings']) + setFlagSettingsInline(null) + resetSettingsCache() + resetCostState() +}) + +afterEach(() => { + try { + resetCostState() + setAllowedSettingSources(originalSources) + setFlagSettingsPath(originalFlagPath) + setFlagSettingsInline(originalFlagInline) + resetSettingsCache() + rmSync(tempDir, { recursive: true, force: true }) + } finally { + releaseSharedMutationLock() + } +}) + +test('raw helper, per-model display, and session total share one custom price', () => { + const model = 'provider/model:v1?profile=paid' + const settingsPath = join(tempDir, 'settings.json') + writeFileSync( + settingsPath, + `${JSON.stringify({ + modelPricing: { + [model]: { + inputTokens: 1, + outputTokens: 2, + promptCacheReadTokens: 3, + promptCacheWriteTokens: 4, + webSearchRequests: 5, + }, + }, + })}\n`, + 'utf8', + ) + setFlagSettingsPath(settingsPath) + resetSettingsCache() + + const usage = { + input_tokens: 1_000_000, + output_tokens: 2_000_000, + cache_read_input_tokens: 3_000_000, + cache_creation_input_tokens: 4_000_000, + server_tool_use: { web_search_requests: 2 }, + } as Parameters[1] + const cost = calculateUSDCost(model, usage) + + expect(cost).toBe(40) + expect( + calculateCostFromTokens(model, { + inputTokens: 1_000_000, + outputTokens: 2_000_000, + cacheReadInputTokens: 3_000_000, + cacheCreationInputTokens: 4_000_000, + }), + ).toBe(30) + expect(addToTotalSessionCost(cost, usage, model)).toBe(40) + expect(getTotalCost()).toBe(40) + expect(getModelUsage()[model]).toMatchObject({ + inputTokens: 1_000_000, + outputTokens: 2_000_000, + cacheReadInputTokens: 3_000_000, + cacheCreationInputTokens: 4_000_000, + webSearchRequests: 2, + costUSD: 40, + }) + const display = formatTotalCost() + expect(display).toContain('Total cost: $40.00') + expect(display).toContain('provider/model:v1?profile=paid:') + expect(display).toContain('($40.00)') + expect(hasUnknownModelCost()).toBe(false) +}) + +test('omitted web-search price uses the documented $0.01 request default', () => { + const model = 'provider/model-with-default-web-price' + const settingsPath = join(tempDir, 'settings.json') + writeFileSync( + settingsPath, + `${JSON.stringify({ + modelPricing: { + [model]: { + inputTokens: 0, + outputTokens: 0, + promptCacheReadTokens: 0, + promptCacheWriteTokens: 0, + }, + }, + })}\n`, + 'utf8', + ) + setFlagSettingsPath(settingsPath) + resetSettingsCache() + + const usage = { + input_tokens: 0, + output_tokens: 0, + server_tool_use: { web_search_requests: 3 }, + } as Parameters[1] + expect(calculateUSDCost(model, usage)).toBeCloseTo(0.03) +}) diff --git a/src/test/fixtures/customPricingDisplay.fixture.tsx b/src/test/fixtures/customPricingDisplay.fixture.tsx new file mode 100644 index 000000000..f4d0e8bfe --- /dev/null +++ b/src/test/fixtures/customPricingDisplay.fixture.tsx @@ -0,0 +1,229 @@ +import { mock } from 'bun:test' +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import { stripVTControlCharacters as stripAnsi } from 'node:util' +import React from 'react' +import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js' +import { + getAllowedSettingSources, + getFlagSettingsInline, + getFlagSettingsPath, + resetModelStringsForTestingOnly, + setAllowedSettingSources, + setFlagSettingsInline, + setFlagSettingsPath, +} from '../../bootstrap/state.js' +import { resetSettingsCache } from '../../utils/settings/settingsCache.js' + +const originalOpenClaudeConfigDir = process.env.OPENCLAUDE_CONFIG_DIR +const fixtureDir = mkdtempSync(join(tmpdir(), 'openclaude-pricing-display-')) +const userConfigDir = join(fixtureDir, 'user-config') +const pricingSettingsPath = join(fixtureDir, 'pricing-settings.json') +process.env.OPENCLAUDE_CONFIG_DIR = userConfigDir + +mock.module('../../utils/model/providers.js', () => ({ + getAPIProvider: () => 'firstParty', + getAPIProviderForStatsig: () => 'firstParty', + isFirstPartyAnthropicBaseUrl: () => true, + isFirstPartyAnthropicProvider: () => true, + isCustomAnthropicProvider: () => false, + isGithubNativeAnthropicMode: () => false, + usesAnthropicAccountFlow: () => true, +})) +mock.module('../../utils/auth.js', () => ({ + getSubscriptionType: () => null, + isClaudeAISubscriber: () => false, + isMaxSubscriber: () => false, + isProSubscriber: () => false, + isTeamPremiumSubscriber: () => false, +})) +mock.module('../../utils/fastMode.js', () => ({ + clearFastModeCooldown: () => {}, + FAST_MODE_MODEL_DISPLAY: 'Opus 4.8', + getFastModeModel: () => 'opus', + getFastModeRuntimeState: () => ({ status: 'active' }), + getFastModeUnavailableReason: () => null, + isFastModeEnabled: () => true, + isFastModeSupportedByModel: () => true, + prefetchFastModeStatus: async () => {}, +})) + +const originalSources = [...getAllowedSettingSources()] +const originalFlagPath = getFlagSettingsPath() +const originalFlagInline = getFlagSettingsInline() + +function writePricing(opusInput: number, opusOutput: number): void { + writeFileSync( + pricingSettingsPath, + `${JSON.stringify({ + modelPricing: { + 'claude-sonnet-4-6': { + inputTokens: 9, + outputTokens: 10, + promptCacheReadTokens: 0, + promptCacheWriteTokens: 0, + webSearchRequests: 0, + }, + 'claude-opus-4-8': { + inputTokens: opusInput, + outputTokens: opusOutput, + promptCacheReadTokens: 0, + promptCacheWriteTokens: 0, + webSearchRequests: 0, + }, + }, + })}\n`, + 'utf8', + ) +} + +async function waitFor( + check: () => boolean, + description: string, + timeoutMs = 5_000, +): Promise { + const deadline = Date.now() + timeoutMs + while (!check()) { + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for ${description}`) + } + await Bun.sleep(5) + } +} + +let cleanupRender: (() => void) | undefined + +try { + writePricing(7, 8) + setAllowedSettingSources(['flagSettings']) + setFlagSettingsPath(pricingSettingsPath) + setFlagSettingsInline(null) + resetSettingsCache() + resetModelStringsForTestingOnly() + + const { + getDefaultOptionForUser, + getMaxOpus46_1MOption, + getMaxSonnet46_1MOption, + getOpus46_1MOption, + getSonnet46_1MOption, + } = await import('../../utils/model/modelOptions.js') + + assert.match(getDefaultOptionForUser().description, /\$9\/\$10 per Mtok/) + assert.match(getSonnet46_1MOption().description, /\$9\/\$10 per Mtok/) + assert.match(getMaxSonnet46_1MOption().description, /\$9\/\$10 per Mtok/) + assert.match(getOpus46_1MOption(true).description, /\$7\/\$8 per Mtok/) + assert.match(getMaxOpus46_1MOption(true).description, /\$7\/\$8 per Mtok/) + + const { FastModePicker, handleFastModeShortcut } = await import( + '../../commands/fast/fast.js' + ) + const { AppStateProvider, getDefaultAppState, useAppStateStore } = + await import('../../state/AppState.js') + const { createRoot } = await import('../../ink.js') + let appState = getDefaultAppState() + const shortcut = await handleFastModeShortcut( + true, + () => appState, + update => { + appState = update(appState) + }, + ) + assert.match(shortcut, /\$7\/\$8 per Mtok/) + const isolatedUserSettings = JSON.parse( + readFileSync(join(userConfigDir, 'settings.json'), 'utf8'), + ) as Record + assert.equal(isolatedUserSettings.fastMode, true) + + let output = '' + const stdout = new PassThrough() + const stdin = new PassThrough() as PassThrough & { + isTTY: boolean + ref: () => PassThrough + setRawMode: (mode: boolean) => void + unref: () => PassThrough + } + stdin.isTTY = true + stdin.ref = () => stdin + stdin.setRawMode = () => {} + stdin.unref = () => stdin + ;(stdout as unknown as { columns: number }).columns = 120 + stdout.on('data', chunk => { + output += chunk.toString() + }) + const completions: string[] = [] + const onPickerDone = (message: string) => completions.push(message) + let triggerSettingsReload: (() => void) | undefined + const PickerWithSettingsReload = () => { + const store = useAppStateStore() + triggerSettingsReload = () => { + store.setState(previous => ({ + ...previous, + settings: { ...previous.settings }, + })) + } + return + } + const picker = () => ( + + + + + + ) + const instance = await createRoot({ + stdin: stdin as unknown as NodeJS.ReadStream, + stdout: stdout as unknown as NodeJS.WriteStream, + exitOnCtrlC: false, + patchConsole: false, + }) + cleanupRender = () => { + instance.unmount() + stdin.end() + stdout.end() + } + instance.render(picker()) + await waitFor( + () => /\$7\/\$8 per Mtok/.test(stripAnsi(output)), + 'the initial fast-mode pricing render', + ) + assert.match(stripAnsi(output), /\$7\/\$8 per Mtok/) + + writePricing(17, 18) + resetSettingsCache() + output = '' + assert.ok(triggerSettingsReload) + triggerSettingsReload() + await waitFor( + () => /\$17\/\$18 per Mtok/.test(stripAnsi(output)), + 'the reloaded fast-mode pricing render', + ) + assert.match(stripAnsi(output), /\$17\/\$18 per Mtok/) + + stdin.write('\r') + await waitFor( + () => /\$17\/\$18 per Mtok/.test(completions.at(-1) ?? ''), + 'the fast-mode confirmation', + ) + assert.match(completions.at(-1) ?? '', /\$17\/\$18 per Mtok/) + + cleanupRender() + cleanupRender = undefined +} finally { + cleanupRender?.() + mock.restore() + resetModelStringsForTestingOnly() + setAllowedSettingSources(originalSources) + setFlagSettingsPath(originalFlagPath) + setFlagSettingsInline(originalFlagInline) + resetSettingsCache() + if (originalOpenClaudeConfigDir === undefined) { + delete process.env.OPENCLAUDE_CONFIG_DIR + } else { + process.env.OPENCLAUDE_CONFIG_DIR = originalOpenClaudeConfigDir + } + rmSync(fixtureDir, { recursive: true, force: true }) +} diff --git a/src/test/fixtures/queryEngineCustomPricingBudget.fixture.ts b/src/test/fixtures/queryEngineCustomPricingBudget.fixture.ts new file mode 100644 index 000000000..142cfd8f5 --- /dev/null +++ b/src/test/fixtures/queryEngineCustomPricingBudget.fixture.ts @@ -0,0 +1,234 @@ +import { mock } from 'bun:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + getAllowedSettingSources, + getFlagSettingsInline, + getFlagSettingsPath, + isSessionPersistenceDisabled, + setAllowedSettingSources, + setFlagSettingsInline, + setFlagSettingsPath, + setSessionPersistenceDisabled, +} from '../../bootstrap/state.js' +import type { SDKMessage } from '../../entrypoints/agentSdkTypes.js' +import type { AppState } from '../../state/AppState.js' +import type { Message } from '../../types/message.js' +import { + addToTotalSessionCost, + formatTotalCost, + resetCostState, +} from '../../cost-tracker.js' +import { + calculateCostFromTokens, + calculateUSDCost, +} from '../../utils/modelCost.js' +import { resetSettingsCache } from '../../utils/settings/settingsCache.js' + +const paidModel = 'provider/paid-model' +const freeModel = 'nvidia/free-model' +const usage = { + input_tokens: 10_000, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, +} + +let requestedModel = paidModel + +mock.module('../../utils/processUserInput/processUserInput.js', () => ({ + processUserInput: mock(async () => ({ + messages: [ + { + type: 'user', + message: { role: 'user', content: 'run' }, + isMeta: false, + uuid: `user-${Math.random()}`, + timestamp: new Date().toISOString(), + } as Message, + ], + shouldQuery: true, + allowedTools: [], + model: requestedModel, + resultText: undefined, + })), +})) +mock.module('../../utils/queryContext.js', () => ({ + fetchSystemPromptParts: mock(async () => ({ + defaultSystemPrompt: [], + userContext: {}, + systemContext: {}, + })), +})) +mock.module('../../utils/messages/systemInit.js', () => ({ + buildSystemInitMessage: mock((input: { model: string }) => ({ + type: 'system', + subtype: 'init', + session_id: 'test-session', + tools: [], + mcp_servers: [], + model: input.model, + permissionMode: 'default', + apiKeySource: 'none', + cwd: process.cwd(), + })), + sdkCompatToolName: (name: string) => name, +})) +mock.module('../../commands.js', () => ({ + REMOTE_SAFE_COMMANDS: new Set(), + builtInCommandNames: new Set(), + clearCommandsCache: () => {}, + findCommand: () => undefined, + getCommand: () => undefined, + getCommandName: (command: { name?: string }) => command.name ?? '', + getCommands: () => [], + getMcpSkillCommands: () => [], + getSkillToolCommands: () => [], + getSlashCommandToolSkills: mock(async () => []), + hasCommand: () => false, + isCommandEnabled: () => true, +})) +mock.module('src/entrypoints/agentSdkTypes.js', () => ({ HOOK_EVENTS: [] })) + +async function* pricedQuery({ + toolUseContext, +}: { + toolUseContext: { options: { mainLoopModel: string } } +}): AsyncGenerator { + const model = toolUseContext.options.mainLoopModel + const cost = calculateUSDCost(model, usage as never) + addToTotalSessionCost(cost, usage as never, model) + yield { + type: 'assistant', + message: { + id: `msg-${Math.random()}`, + type: 'message', + role: 'assistant', + model, + content: [{ type: 'text', text: 'done' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage, + }, + uuid: `assistant-${Math.random()}`, + timestamp: new Date().toISOString(), + } as Message +} + +async function runCase( + QueryEngine: typeof import('../../QueryEngine.js').QueryEngine, + model: string, + maxBudgetUsd: number, +): Promise> { + requestedModel = model + let appState = { + fastMode: false, + toolPermissionContext: { + mode: 'default', + additionalWorkingDirectories: new Map(), + alwaysAllowRules: {}, + }, + fileHistory: {}, + attribution: {}, + } as unknown as AppState + const engine = new QueryEngine({ + cwd: fixtureDir, + tools: [], + commands: [], + mcpClients: [], + agents: [], + canUseTool: async () => ({ behavior: 'allow' }), + getAppState: () => appState, + setAppState: update => { + appState = update(appState) + }, + readFileCache: {} as never, + userSpecifiedModel: model, + thinkingConfig: { type: 'disabled' }, + maxBudgetUsd, + query: pricedQuery as never, + }) + const events: SDKMessage[] = [] + for await (const event of engine.submitMessage('run')) events.push(event) + const result = events.findLast( + (event): event is Extract => + event.type === 'result', + ) + assert.ok(result) + return result +} + +const originalPersistence = isSessionPersistenceDisabled() +const originalSources = [...getAllowedSettingSources()] +const originalFlagPath = getFlagSettingsPath() +const originalFlagInline = getFlagSettingsInline() +const fixtureDir = mkdtempSync(join(tmpdir(), 'openclaude-query-budget-')) +const settingsPath = join(fixtureDir, 'settings.json') + +try { + writeFileSync( + settingsPath, + `${JSON.stringify({ + modelPricing: { + [paidModel]: { + inputTokens: 100, + outputTokens: 0, + promptCacheReadTokens: 0, + promptCacheWriteTokens: 0, + webSearchRequests: 0, + }, + [freeModel]: { + inputTokens: 0, + outputTokens: 0, + promptCacheReadTokens: 0, + promptCacheWriteTokens: 0, + webSearchRequests: 0, + }, + }, + })}\n`, + 'utf8', + ) + setSessionPersistenceDisabled(true) + setAllowedSettingSources(['flagSettings']) + setFlagSettingsPath(settingsPath) + setFlagSettingsInline(null) + resetSettingsCache() + + const { QueryEngine } = await import('../../QueryEngine.js') + + resetCostState() + const paid = await runCase(QueryEngine, paidModel, 0.5) + assert.equal(paid.subtype, 'error_max_budget_usd') + assert.equal(paid.total_cost_usd, 1) + assert.equal(paid.modelUsage[paidModel]?.costUSD, 1) + assert.equal( + calculateCostFromTokens(paidModel, { + inputTokens: 10_000, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + }), + 1, + ) + assert.match(formatTotalCost(), /Total cost:\s+\$1\.00/) + assert.match(formatTotalCost(), /provider\/paid-model:.*\(\$1\.00\)/s) + assert.match(JSON.stringify(paid), /"total_cost_usd":1/) + + resetCostState() + const free = await runCase(QueryEngine, freeModel, 0.01) + assert.equal(free.subtype, 'success') + assert.equal(free.total_cost_usd, 0) + assert.equal(free.modelUsage[freeModel]?.costUSD, 0) + assert.equal(free.modelUsage[freeModel]?.inputTokens, 10_000) +} finally { + mock.restore() + resetCostState() + setSessionPersistenceDisabled(originalPersistence) + setAllowedSettingSources(originalSources) + setFlagSettingsPath(originalFlagPath) + setFlagSettingsInline(originalFlagInline) + resetSettingsCache() + rmSync(fixtureDir, { recursive: true, force: true }) +} diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index c1123d133..71ea3c4e5 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -20,7 +20,7 @@ import { } from '../context.js' import { isEnvTruthy } from '../envUtils.js' import { getModelStrings, resolveOverriddenModel } from './modelStrings.js' -import { formatModelPricing, getOpus46CostTier } from '../modelCost.js' +import { getModelPricingString } from '../modelCost.js' import { getSettings_DEPRECATED } from '../settings/settings.js' import type { PermissionMode } from '../permissions/PermissionMode.js' import { @@ -562,9 +562,15 @@ export function renderDefaultModelSetting( return renderModelName(parseUserSpecifiedModel(setting)) } -export function getOpus46PricingSuffix(fastMode: boolean): string { +export function getOpus46PricingSuffix( + fastMode: boolean, + model: string = getModelStrings().opus48, +): string { if (!isFirstPartyAnthropicProvider()) return '' - const pricing = formatModelPricing(getOpus46CostTier(fastMode)) + const pricing = getModelPricingString(model, { + speed: fastMode ? 'fast' : 'standard', + }) + if (!pricing) return '' const fastModeIndicator = fastMode ? ` (${LIGHTNING_BOLT})` : '' return ` ·${fastModeIndicator} ${pricing}` } diff --git a/src/utils/model/modelOptions.gateways.test.ts b/src/utils/model/modelOptions.gateways.test.ts index 37c3f50ea..e99714b33 100644 --- a/src/utils/model/modelOptions.gateways.test.ts +++ b/src/utils/model/modelOptions.gateways.test.ts @@ -143,6 +143,19 @@ test('custom Anthropic endpoints use the third-party default description', async expect(defaultOption?.description).not.toContain('$') }) +test('custom Anthropic endpoints omit first-party pricing from every model option', async () => { + process.env.ANTHROPIC_BASE_URL = 'https://proxy.example/v1' + process.env.ANTHROPIC_API_KEY = 'proxy-key' + + const { getModelOptions } = await importFreshModelOptionsModule('firstParty') + const options = getModelOptions() + + expect(options.length).toBeGreaterThan(1) + for (const option of options) { + expect(option.description).not.toContain('$') + } +}) + test('OpenRouter active profile cache merges with the static route catalog', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' diff --git a/src/utils/model/modelOptions.ts b/src/utils/model/modelOptions.ts index e5efc0d0a..259f544a4 100644 --- a/src/utils/model/modelOptions.ts +++ b/src/utils/model/modelOptions.ts @@ -16,12 +16,7 @@ import { isTeamPremiumSubscriber, } from '../auth.js' import { getModelStrings } from './modelStrings.js' -import { - COST_TIER_3_15, - COST_HAIKU_35, - COST_HAIKU_45, - formatModelPricing, -} from '../modelCost.js' +import { getModelPricingString } from '../modelCost.js' import { getSettings_DEPRECATED } from '../settings/settings.js' import { checkOpus1mAccess, checkSonnet1mAccess } from './check1mAccess.js' import { @@ -187,10 +182,16 @@ export function getDefaultOptionForUser(fastMode = false): ModelOption { return { value: null, label: 'Default (recommended)', - description: `Use the default model (currently ${renderDefaultModelSetting(currentDefaultModel)})${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`, + description: `Use the default model (currently ${renderDefaultModelSetting(currentDefaultModel)})${getPricingSuffix(getDefaultSonnetModel())}`, } } +function getPricingSuffix(model: string): string { + if (!isFirstPartyAnthropicProvider()) return '' + const pricing = getModelPricingString(model) + return pricing ? ` · ${pricing}` : '' +} + function getCustomSonnetOption(): ModelOption | undefined { const is3P = getAPIProvider() !== 'firstParty' const customSonnetModel = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL @@ -216,7 +217,7 @@ function getSonnet46Option(): ModelOption { return { value: is3P ? getModelStrings().sonnet46 : 'sonnet', label: 'Sonnet', - description: `Sonnet 4.6 · Best for everyday tasks${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`, + description: `Sonnet 4.6 · Best for everyday tasks${getPricingSuffix(getModelStrings().sonnet46)}`, descriptionForModel: 'Sonnet 4.6 - best for everyday tasks. Generally recommended for most coding tasks', } @@ -253,7 +254,7 @@ function getOpus48Option(fastMode = false): ModelOption { return { value: is3P ? getModelStrings().opus48 : 'opus', label: 'Opus', - description: `Opus 4.8 · Most capable for complex work${getOpus46PricingSuffix(fastMode)}`, + description: `Opus 4.8 · Most capable for complex work${getOpus46PricingSuffix(fastMode, getModelStrings().opus48)}`, descriptionForModel: 'Opus 4.8 - most capable for complex work', } } @@ -263,7 +264,7 @@ function getOpus47Option(fastMode = false): ModelOption { return { value: is3P ? getModelStrings().opus47 : 'opus', label: 'Opus', - description: `Opus 4.7 · Most capable for complex work${getOpus46PricingSuffix(fastMode)}`, + description: `Opus 4.7 · Most capable for complex work${getOpus46PricingSuffix(fastMode, getModelStrings().opus47)}`, descriptionForModel: 'Opus 4.7 - most capable for complex work', } } @@ -273,7 +274,7 @@ function getOpus46Option(fastMode = false): ModelOption { return { value: is3P ? getModelStrings().opus46 : 'opus', label: 'Opus', - description: `Opus 4.6 · Most capable for complex work${getOpus46PricingSuffix(fastMode)}`, + description: `Opus 4.6 · Most capable for complex work${getOpus46PricingSuffix(fastMode, getModelStrings().opus46)}`, descriptionForModel: 'Opus 4.6 - most capable for complex work', } } @@ -283,7 +284,7 @@ export function getSonnet46_1MOption(): ModelOption { return { value: is3P ? getModelStrings().sonnet46 + '[1m]' : 'sonnet[1m]', label: 'Sonnet (1M context)', - description: `Sonnet 4.6 for long sessions${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`, + description: `Sonnet 4.6 for long sessions${getPricingSuffix(getModelStrings().sonnet46)}`, descriptionForModel: 'Sonnet 4.6 with 1M context window - for long sessions with large codebases', } @@ -297,7 +298,7 @@ export function getOpus46_1MOption(fastMode = false): ModelOption { return { value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]', label: 'Opus (1M context)', - description: `${opusName} for long sessions${getOpus46PricingSuffix(fastMode)}`, + description: `${opusName} for long sessions${getOpus46PricingSuffix(fastMode, is3P ? getModelStrings().opus46 : getModelStrings().opus48)}`, descriptionForModel: `${opusName} with 1M context window - for long sessions with large codebases`, } } @@ -319,22 +320,20 @@ function getCustomHaikuOption(): ModelOption | undefined { } function getHaiku45Option(): ModelOption { - const is3P = getAPIProvider() !== 'firstParty' return { value: 'haiku', label: 'Haiku', - description: `Haiku 4.5 · Fastest for quick answers${is3P ? '' : ` · ${formatModelPricing(COST_HAIKU_45)}`}`, + description: `Haiku 4.5 · Fastest for quick answers${getPricingSuffix(getModelStrings().haiku45)}`, descriptionForModel: 'Haiku 4.5 - fastest for quick answers. Lower cost but less capable than Sonnet 4.6.', } } function getHaiku35Option(): ModelOption { - const is3P = getAPIProvider() !== 'firstParty' return { value: 'haiku', label: 'Haiku', - description: `Haiku 3.5 for simple tasks${is3P ? '' : ` · ${formatModelPricing(COST_HAIKU_35)}`}`, + description: `Haiku 3.5 for simple tasks${getPricingSuffix(getModelStrings().haiku35)}`, descriptionForModel: 'Haiku 3.5 - faster and lower cost, but less capable than Sonnet. Use for simple tasks.', } @@ -352,17 +351,16 @@ function getMaxOpusOption(fastMode = false): ModelOption { return { value: 'opus', label: 'Opus', - description: `Opus 4.8 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`, + description: `Opus 4.8 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true, getModelStrings().opus48) : ''}`, } } export function getMaxSonnet46_1MOption(): ModelOption { - const is3P = getAPIProvider() !== 'firstParty' const billingInfo = isClaudeAISubscriber() ? ' · Billed as extra usage' : '' return { value: 'sonnet[1m]', label: 'Sonnet (1M context)', - description: `Sonnet 4.6 with 1M context${billingInfo}${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`, + description: `Sonnet 4.6 with 1M context${billingInfo}${getPricingSuffix(getModelStrings().sonnet46)}`, } } @@ -371,7 +369,7 @@ export function getMaxOpus46_1MOption(fastMode = false): ModelOption { return { value: 'opus[1m]', label: 'Opus (1M context)', - description: `Opus 4.8 with 1M context${billingInfo}${getOpus46PricingSuffix(fastMode)}`, + description: `Opus 4.8 with 1M context${billingInfo}${getOpus46PricingSuffix(fastMode, getModelStrings().opus48)}`, } } @@ -380,7 +378,7 @@ function getMergedOpus1MOption(fastMode = false): ModelOption { return { value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]', label: 'Opus (1M context)', - description: `${is3P ? 'Opus 4.6' : 'Opus 4.8'} with 1M context · Most capable for complex work${!is3P && fastMode ? getOpus46PricingSuffix(fastMode) : ''}`, + description: `${is3P ? 'Opus 4.6' : 'Opus 4.8'} with 1M context · Most capable for complex work${!is3P && fastMode ? getOpus46PricingSuffix(fastMode, getModelStrings().opus48) : ''}`, descriptionForModel: `${is3P ? 'Opus 4.6' : 'Opus 4.8'} with 1M context - most capable for complex work`, } diff --git a/src/utils/modelCost.modelGate.test.ts b/src/utils/modelCost.modelGate.test.ts index e0aa0491c..659c09888 100644 --- a/src/utils/modelCost.modelGate.test.ts +++ b/src/utils/modelCost.modelGate.test.ts @@ -1,10 +1,36 @@ import { afterEach, beforeEach, expect, mock, test } from 'bun:test' -import { hasUnknownModelCost, resetCostState } from '../bootstrap/state.js' +import { + getAllowedSettingSources, + getFlagSettingsInline, + getFlagSettingsPath, + hasUnknownModelCost, + resetCostState, + setAllowedSettingSources, + setFlagSettingsInline, + setFlagSettingsPath, +} from '../bootstrap/state.js' import { acquireSharedMutationLock, releaseSharedMutationLock, } from '../test/sharedMutationLock.js' import * as realFastMode from './fastMode.js' +import * as realModel from './model/model.js' +import { resetSettingsCache } from './settings/settingsCache.js' + +const realModelSnapshot = { ...realModel } + +type PricingOverride = { + inputTokens: number + outputTokens: number + promptCacheReadTokens: number + promptCacheWriteTokens: number + webSearchRequests: number +} + +let pricingByModel: Record +let originalSources: ReturnType +let originalFlagPath: string | undefined +let originalFlagInline: Record | null async function importFreshModelCost() { return import(`./modelCost.js?ts=${Date.now()}-${Math.random()}`) @@ -12,12 +38,25 @@ async function importFreshModelCost() { beforeEach(async () => { await acquireSharedMutationLock('utils/modelCost.modelGate.test.ts') + originalSources = [...getAllowedSettingSources()] + originalFlagPath = getFlagSettingsPath() + originalFlagInline = getFlagSettingsInline() + pricingByModel = Object.create(null) as Record + setAllowedSettingSources(['flagSettings']) + setFlagSettingsPath(undefined) + setFlagSettingsInline({ modelPricing: pricingByModel }) + resetSettingsCache() }) afterEach(() => { try { mock.restore() mock.module('./fastMode.js', () => realFastMode) + mock.module('./model/model.js', () => realModelSnapshot) + setAllowedSettingSources(originalSources) + setFlagSettingsPath(originalFlagPath) + setFlagSettingsInline(originalFlagInline) + resetSettingsCache() } finally { releaseSharedMutationLock() } @@ -111,3 +150,127 @@ test('proto-member model ids fall through the unknown-model path, not NaN', asyn resetCostState() } }) + +test('exact unknown-model override prices every usage field and suppresses the unknown warning', async () => { + const model = 'provider/model:v1?profile=paid' + pricingByModel[model] = { + inputTokens: 1, + outputTokens: 2, + promptCacheReadTokens: 3, + promptCacheWriteTokens: 4, + webSearchRequests: 5, + } + const { + calculateUSDCost, + calculateCostFromTokens, + getModelCosts, + getModelPricingString, + } = await importFreshModelCost() + const usage = { + input_tokens: 1_000_000, + output_tokens: 2_000_000, + cache_read_input_tokens: 3_000_000, + cache_creation_input_tokens: 4_000_000, + server_tool_use: { web_search_requests: 2 }, + } as Parameters[1] + + expect(getModelCosts(model, usage)).toEqual(pricingByModel[model]) + expect(calculateUSDCost(model, usage)).toBe(40) + expect( + calculateCostFromTokens(model, { + inputTokens: 1_000_000, + outputTokens: 2_000_000, + cacheReadInputTokens: 3_000_000, + cacheCreationInputTokens: 4_000_000, + }), + ).toBe(30) + expect(getModelPricingString(model)).toBe('$1/$2 per Mtok') + expect(hasUnknownModelCost()).toBe(false) +}) + +test('all-zero exact override is authoritative for unknown and fast-mode known models', async () => { + const zero = { + inputTokens: 0, + outputTokens: 0, + promptCacheReadTokens: 0, + promptCacheWriteTokens: 0, + webSearchRequests: 0, + } + pricingByModel['nvidia/free-model'] = zero + pricingByModel['claude-opus-4-8'] = zero + mock.module('./fastMode.js', () => ({ + ...realFastMode, + isFastModeEnabled: () => true, + })) + const { calculateUSDCost, getModelCosts, getModelPricingString } = + await importFreshModelCost() + const usage = { + input_tokens: 1_000_000, + output_tokens: 1_000_000, + cache_read_input_tokens: 1_000_000, + cache_creation_input_tokens: 1_000_000, + server_tool_use: { web_search_requests: 1 }, + speed: 'fast', + } as Parameters[1] + + for (const model of ['nvidia/free-model', 'claude-opus-4-8']) { + expect(getModelCosts(model, usage)).toEqual(zero) + expect(calculateUSDCost(model, usage)).toBe(0) + expect(getModelPricingString(model)).toBe('$0/$0 per Mtok') + } + expect(hasUnknownModelCost()).toBe(false) +}) + +test('custom pricing matches exact resolved ids only, including unusual own keys', async () => { + mock.restore() + mock.module('./fastMode.js', () => realFastMode) + mock.module('./model/model.js', () => realModelSnapshot) + const configured = { + inputTokens: 7, + outputTokens: 11, + promptCacheReadTokens: 1, + promptCacheWriteTokens: 2, + webSearchRequests: 0.5, + } + const exact = 'Provider/model:v1?route=alpha' + pricingByModel[exact] = configured + for (const model of ['constructor', 'toString', '__proto__']) { + Object.defineProperty(pricingByModel, model, { + configurable: true, + enumerable: true, + value: configured, + writable: true, + }) + } + const { + calculateUSDCost, + getModelCosts, + COST_TIER_5_25, + DEFAULT_UNKNOWN_MODEL_COST, + } = await importFreshModelCost() + const usage = { + input_tokens: 1_000_000, + output_tokens: 0, + } as Parameters[1] + + for (const model of [exact, 'constructor', 'toString', '__proto__']) { + expect(calculateUSDCost(model, usage)).toBe(7) + } + resetCostState() + for (const nearMatch of [ + exact.toLowerCase(), + exact.slice(0, -1), + `${exact}/child`, + ]) { + expect(getModelCosts(nearMatch, usage)).toEqual( + DEFAULT_UNKNOWN_MODEL_COST, + ) + } + expect(hasUnknownModelCost()).toBe(true) + + resetCostState() + expect(getModelCosts('claude-opus-4-8-20260815', usage)).toEqual( + COST_TIER_5_25, + ) + expect(hasUnknownModelCost()).toBe(false) +}) diff --git a/src/utils/modelCost.ts b/src/utils/modelCost.ts index e894fe4ec..30548f8a5 100644 --- a/src/utils/modelCost.ts +++ b/src/utils/modelCost.ts @@ -3,6 +3,7 @@ import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from import { logEvent } from 'src/services/analytics/index.js' import { setHasUnknownModelCost } from '../bootstrap/state.js' import { isFastModeEnabled } from './fastMode.js' +import { getModelPricingOverride } from './settings/modelPricing.js' import { CLAUDE_3_5_HAIKU_CONFIG, CLAUDE_3_5_V2_SONNET_CONFIG, @@ -87,7 +88,7 @@ export const COST_HAIKU_45 = { webSearchRequests: 0.01, } as const satisfies ModelCosts -const DEFAULT_UNKNOWN_MODEL_COST = COST_TIER_5_25 +export const DEFAULT_UNKNOWN_MODEL_COST = COST_TIER_5_25 /** * Get the cost tier for Opus 4.6 based on fast mode. @@ -146,7 +147,18 @@ function tokensToUSDCost(modelCosts: ModelCosts, usage: Usage): number { ) } -export function getModelCosts(model: string, usage: Usage): ModelCosts { +function getKnownModelCosts( + model: string, + usage: { speed?: Usage['speed'] }, +): ModelCosts | undefined { + // Custom prices match the exact resolved model id before any canonical + // first-party fallback. This intentionally does not normalize case, aliases, + // prefixes, route names, or provider-specific identifiers. + const override = getModelPricingOverride(model) + if (override) { + return override + } + const shortName = getCanonicalName(model) // Check if this is a fast-mode-capable Opus model (4.8/4.7/4.6) with fast mode @@ -168,11 +180,19 @@ export function getModelCosts(model: string, usage: Usage): ModelCosts { // getCanonicalName passes them through unchanged) would return a truthy // prototype value, skip the unknown-model path, and yield NaN costs downstream. // Match on own properties only. - if (!Object.hasOwn(MODEL_COSTS, shortName)) { - trackUnknownModelCost(model, shortName) - return DEFAULT_UNKNOWN_MODEL_COST + return Object.hasOwn(MODEL_COSTS, shortName) + ? MODEL_COSTS[shortName] + : undefined +} + +export function getModelCosts(model: string, usage: Usage): ModelCosts { + const costs = getKnownModelCosts(model, usage) + if (costs) { + return costs } - return MODEL_COSTS[shortName] + + trackUnknownModelCost(model, getCanonicalName(model)) + return DEFAULT_UNKNOWN_MODEL_COST } function trackUnknownModelCost(model: string, shortName: ModelShortName): void { @@ -236,10 +256,10 @@ export function formatModelPricing(costs: ModelCosts): string { * Accepts either a short name or full model name * Returns undefined if model is not found */ -export function getModelPricingString(model: string): string | undefined { - const shortName = getCanonicalName(model) - // Own-property guard: a proto-member id (`constructor`, `__proto__`) would - // otherwise return an inherited value and render "$NaN/$NaN per Mtok". - if (!Object.hasOwn(MODEL_COSTS, shortName)) return undefined - return formatModelPricing(MODEL_COSTS[shortName]) +export function getModelPricingString( + model: string, + usage: { speed?: Usage['speed'] } = {}, +): string | undefined { + const costs = getKnownModelCosts(model, usage) + return costs ? formatModelPricing(costs) : undefined } diff --git a/src/utils/settings/modelPricing.test.ts b/src/utils/settings/modelPricing.test.ts new file mode 100644 index 000000000..9451d269a --- /dev/null +++ b/src/utils/settings/modelPricing.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { + getAllowedSettingSources, + getFlagSettingsInline, + getFlagSettingsPath, + getOriginalCwd, + setAllowedSettingSources, + setFlagSettingsInline, + setFlagSettingsPath, + setOriginalCwd, +} from '../../bootstrap/state.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' +import { resetSettingsCache } from './settingsCache.js' +import type { SettingsJson } from './types.js' + +// Capture the actual module once so source-order tests can replace just the +// per-source reader without leaking a fake settings implementation. +// @ts-expect-error -- query suffix intentionally bypasses Bun's module cache. +import * as realSettings from './settings.js?modelPricingRealSettings' + +const paid = (inputTokens: number) => ({ + inputTokens, + outputTokens: 2, + promptCacheReadTokens: 0.1, + promptCacheWriteTokens: 2.5, +}) + +let tempDir: string +let originalCwd: string +let originalSources: ReturnType +let originalFlagPath: string | undefined +let originalFlagInline: Record | null +let settingsOverrideActive = false +let settingsBySource: Partial> = {} + +beforeEach(async () => { + await acquireSharedMutationLock('utils/settings/modelPricing.test.ts') + mock.restore() + tempDir = mkdtempSync(join(tmpdir(), 'openclaude-model-pricing-')) + originalCwd = getOriginalCwd() + originalSources = [...getAllowedSettingSources()] + originalFlagPath = getFlagSettingsPath() + originalFlagInline = getFlagSettingsInline() + setOriginalCwd(tempDir) + setFlagSettingsPath(undefined) + setFlagSettingsInline(null) + resetSettingsCache() + settingsOverrideActive = false + settingsBySource = {} + mock.module('./settings.js', () => ({ + ...realSettings, + getSettingsForSource: (source: Parameters[0]) => + settingsOverrideActive + ? settingsBySource[source] ?? null + : realSettings.getSettingsForSource(source), + })) +}) + +afterEach(() => { + try { + mock.restore() + setOriginalCwd(originalCwd) + setAllowedSettingSources(originalSources) + setFlagSettingsPath(originalFlagPath) + setFlagSettingsInline(originalFlagInline) + settingsOverrideActive = false + settingsBySource = {} + resetSettingsCache() + rmSync(tempDir, { recursive: true, force: true }) + } finally { + releaseSharedMutationLock() + } +}) + +async function importFreshModelPricing() { + return import(`./modelPricing.js?ts=${Date.now()}-${Math.random()}`) +} + +function writeJson(path: string, data: unknown): void { + writeFileSync(path, `${JSON.stringify(data)}\n`, 'utf8') +} + +test('shared project settings cannot influence personal pricing', async () => { + const settingsDir = join(tempDir, '.openclaude') + mkdirSync(settingsDir, { recursive: true }) + writeJson(join(settingsDir, 'settings.json'), { + modelPricing: { 'repo-controlled-model': paid(999) }, + }) + setAllowedSettingSources(['projectSettings']) + resetSettingsCache() + + const { getModelPricingOverride } = await importFreshModelPricing() + expect(getModelPricingOverride('repo-controlled-model')).toBeUndefined() +}) + +test('canonical source order keeps managed pricing above CLI pricing', async () => { + settingsBySource = { + userSettings: { modelPricing: { model: paid(1) } }, + flagSettings: { modelPricing: { model: paid(2) } }, + policySettings: { modelPricing: { model: paid(3) } }, + } + settingsOverrideActive = true + // policySettings and flagSettings remain enabled by policy even when the + // user restricts ordinary setting sources. + setAllowedSettingSources(['userSettings']) + + const { getModelPricingOverride } = await importFreshModelPricing() + expect(getModelPricingOverride('model')?.inputTokens).toBe(3) +}) + +test('settings cache reset reloads exact pricing without a bespoke watcher', async () => { + const settingsPath = join(tempDir, 'flag-settings.json') + setFlagSettingsPath(settingsPath) + setAllowedSettingSources(['flagSettings']) + writeJson(settingsPath, { + modelPricing: { 'provider/model:v1?route=paid': paid(4) }, + }) + resetSettingsCache() + + const { getModelPricingOverride } = await importFreshModelPricing() + const first = getModelPricingOverride('provider/model:v1?route=paid') + const firstClone = getModelPricingOverride('provider/model:v1?route=paid') + expect(first?.inputTokens).toBe(4) + expect(first?.webSearchRequests).toBe(0.01) + expect(Object.isFrozen(first)).toBe(true) + expect(firstClone).not.toBe(first) + expect(firstClone).toEqual(first) + + writeJson(settingsPath, { + modelPricing: { 'provider/model:v1?route=paid': paid(8) }, + }) + expect(getModelPricingOverride('provider/model:v1?route=paid')?.inputTokens).toBe(4) + + resetSettingsCache() + expect(getModelPricingOverride('provider/model:v1?route=paid')?.inputTokens).toBe(8) +}) + +test('prototype-like ids survive JSON parsing and require exact own-key lookup', async () => { + const settingsPath = join(tempDir, 'flag-settings.json') + const modelPricing = Object.create(null) as Record + for (const model of ['constructor', 'toString', '__proto__']) { + modelPricing[model] = paid(6) + } + setFlagSettingsPath(settingsPath) + setAllowedSettingSources(['flagSettings']) + writeJson(settingsPath, { modelPricing }) + resetSettingsCache() + + const { getModelPricingOverride } = await importFreshModelPricing() + for (const model of ['constructor', 'toString', '__proto__']) { + expect(getModelPricingOverride(model)?.inputTokens).toBe(6) + } + expect(getModelPricingOverride('tostring')).toBeUndefined() +}) + +test('SDK flag settings replace complete entries without inheriting lower rates', async () => { + const settingsPath = join(tempDir, 'flag-settings.json') + const inlinePricing = Object.create(null) as Record + inlinePricing.model = paid(8) + inlinePricing.__proto__ = paid(9) + setFlagSettingsPath(settingsPath) + setFlagSettingsInline({ modelPricing: inlinePricing }) + setAllowedSettingSources(['flagSettings']) + writeJson(settingsPath, { + modelPricing: { + model: { ...paid(4), webSearchRequests: 7 }, + 'file-only-model': paid(5), + }, + }) + resetSettingsCache() + + const { getModelPricingOverride } = await importFreshModelPricing() + expect(getModelPricingOverride('model')).toMatchObject({ + inputTokens: 8, + webSearchRequests: 0.01, + }) + expect(getModelPricingOverride('file-only-model')?.inputTokens).toBe(5) + expect(getModelPricingOverride('__proto__')?.inputTokens).toBe(9) +}) diff --git a/src/utils/settings/modelPricing.ts b/src/utils/settings/modelPricing.ts new file mode 100644 index 000000000..a0228cca5 --- /dev/null +++ b/src/utils/settings/modelPricing.ts @@ -0,0 +1,64 @@ +import { + isSettingSourceEnabled, + SETTING_SOURCES, + type SettingSource, +} from './constants.js' +import { getSettingsForSource } from './settings.js' + +export const DEFAULT_MODEL_WEB_SEARCH_RATE_USD_PER_REQUEST = 0.01 + +export type ResolvedModelPricing = Readonly<{ + inputTokens: number + outputTokens: number + promptCacheReadTokens: number + promptCacheWriteTokens: number + webSearchRequests: number +}> + +const TRUSTED_MODEL_PRICING_SOURCES = new Set([ + 'userSettings', + 'localSettings', + 'flagSettings', + 'policySettings', +]) + +/** + * Resolve a price for the exact model id used by the API request. + * + * Source precedence follows SETTING_SOURCES (later wins), but deliberately + * excludes shared project settings so repository content cannot change a + * user's personal USD accounting. The settings loader owns caching and reload + * invalidation; this function keeps no additional state. + */ +export function getModelPricingOverride( + model: string, +): ResolvedModelPricing | undefined { + let resolved: ResolvedModelPricing | undefined + + for (const source of SETTING_SOURCES) { + if ( + !TRUSTED_MODEL_PRICING_SOURCES.has(source) || + !isSettingSourceEnabled(source) + ) { + continue + } + + const pricing = getSettingsForSource(source)?.modelPricing + if (!pricing || !Object.hasOwn(pricing, model)) { + continue + } + + const entry = pricing[model] + resolved = Object.freeze({ + inputTokens: entry.inputTokens, + outputTokens: entry.outputTokens, + promptCacheReadTokens: entry.promptCacheReadTokens, + promptCacheWriteTokens: entry.promptCacheWriteTokens, + webSearchRequests: + entry.webSearchRequests ?? + DEFAULT_MODEL_WEB_SEARCH_RATE_USD_PER_REQUEST, + }) + } + + return resolved +} diff --git a/src/utils/settings/modelPricingSchema.test.ts b/src/utils/settings/modelPricingSchema.test.ts new file mode 100644 index 000000000..6718da1e9 --- /dev/null +++ b/src/utils/settings/modelPricingSchema.test.ts @@ -0,0 +1,140 @@ +import { expect, test } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { generateSettingsJSONSchema } from './schemaOutput.js' +import { parseSettingsFile } from './settings.js' +import { SettingsSchema } from './types.js' + +const completePrice = { + inputTokens: 1, + outputTokens: 2, + promptCacheReadTokens: 0.1, + promptCacheWriteTokens: 2.5, +} + +test('modelPricing accepts explicit zero and defaults web-search pricing later', () => { + const result = SettingsSchema().safeParse({ + modelPricing: { + 'nvidia/free-model': { + inputTokens: 0, + outputTokens: 0, + promptCacheReadTokens: 0, + promptCacheWriteTokens: 0, + webSearchRequests: 0, + }, + 'provider/paid:model?variant=1': completePrice, + }, + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.modelPricing).toEqual({ + 'nvidia/free-model': { + inputTokens: 0, + outputTokens: 0, + promptCacheReadTokens: 0, + promptCacheWriteTokens: 0, + webSearchRequests: 0, + }, + 'provider/paid:model?variant=1': completePrice, + }) + } +}) + +test('invalid modelPricing is dropped without invalidating unrelated settings', () => { + const tooManyEntries = Object.fromEntries( + Array.from({ length: 257 }, (_, i) => [`model-${i}`, completePrice]), + ) + const invalidValues: unknown[] = [ + 'not-an-object', + { model: 'not-an-entry-object' }, + { model: { ...completePrice, unknownField: 1 } }, + { model: { ...completePrice, inputTokens: -1 } }, + { model: { ...completePrice, outputTokens: Number.POSITIVE_INFINITY } }, + { model: { ...completePrice, outputTokens: Number.NaN } }, + { model: { ...completePrice, inputTokens: 100_001 } }, + { model: { ...completePrice, webSearchRequests: 1_001 } }, + { model: { inputTokens: 1, outputTokens: 2 } }, + { '': completePrice }, + { ['x'.repeat(513)]: completePrice }, + tooManyEntries, + ] + + for (const modelPricing of invalidValues) { + const result = SettingsSchema().safeParse({ + model: 'sonnet', + modelPricing, + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.model).toBe('sonnet') + expect(result.data.modelPricing).toBeUndefined() + } + } +}) + +test('parseSettingsFile reports the raw modelPricing value that it drops', () => { + const fixtureDir = mkdtempSync(join(tmpdir(), 'model-pricing-diagnostic-')) + const settingsPath = join(fixtureDir, 'settings.json') + const modelPricing = { + model: { ...completePrice, inputTokens: -1 }, + } + writeFileSync( + settingsPath, + JSON.stringify({ model: 'sonnet', modelPricing }), + 'utf8', + ) + + try { + const result = parseSettingsFile(settingsPath) + + expect(result.settings?.model).toBe('sonnet') + expect(result.settings?.modelPricing).toBeUndefined() + expect(result.errors).toEqual([ + { + file: settingsPath, + path: 'modelPricing', + message: 'Invalid modelPricing value was ignored', + expected: undefined, + invalidValue: modelPricing, + suggestion: undefined, + docLink: undefined, + }, + ]) + } finally { + rmSync(fixtureDir, { recursive: true, force: true }) + } +}) + +test('generated settings schema preserves modelPricing constraints', () => { + const schema = JSON.parse(generateSettingsJSONSchema()) as { + properties: { + modelPricing: { + additionalProperties: { + properties: Record + required: string[] + } + maxProperties: number + propertyNames: { type: string; maxLength: number; minLength: number } + } + } + } + const pricing = schema.properties.modelPricing + + expect(pricing.maxProperties).toBe(256) + expect(pricing.propertyNames).toEqual({ + type: 'string', + minLength: 1, + maxLength: 512, + }) + expect(pricing.additionalProperties.required).toEqual([ + 'inputTokens', + 'outputTokens', + 'promptCacheReadTokens', + 'promptCacheWriteTokens', + ]) + expect(pricing.additionalProperties.properties).toHaveProperty( + 'webSearchRequests', + ) +}) diff --git a/src/utils/settings/settings.ts b/src/utils/settings/settings.ts index 636fe4b1e..7d5dc8b68 100644 --- a/src/utils/settings/settings.ts +++ b/src/utils/settings/settings.ts @@ -46,6 +46,7 @@ import { } from './settingsCache.js' import { type SettingsJson, SettingsSchema } from './types.js' import { + filterInvalidModelPricing, filterInvalidPermissionRules, formatZodError, type SettingsWithErrors, @@ -215,15 +216,22 @@ function parseSettingsFileUncached(path: string): { // Filter invalid permission rules before schema validation so one bad // rule doesn't cause the entire settings file to be rejected. const ruleWarnings = filterInvalidPermissionRules(data, path) + const modelPricingWarnings = filterInvalidModelPricing(data, path) const result = SettingsSchema().safeParse(data) if (!result.success) { const errors = formatZodError(result.error, path) - return { settings: null, errors: [...ruleWarnings, ...errors] } + return { + settings: null, + errors: [...ruleWarnings, ...modelPricingWarnings, ...errors], + } } - return { settings: result.data, errors: ruleWarnings } + return { + settings: result.data, + errors: [...ruleWarnings, ...modelPricingWarnings], + } } catch (error) { handleFileSystemError(error, path) return { settings: null, errors: [] } @@ -524,7 +532,7 @@ export function updateSettingsForSource( } /** - * Custom merge function for arrays - concatenate and deduplicate + * Custom merge function for structured settings values. */ function mergeArrays(targetArray: T[], sourceArray: T[]): T[] { return uniq([...targetArray, ...sourceArray]) @@ -532,16 +540,45 @@ function mergeArrays(targetArray: T[], sourceArray: T[]): T[] { /** * Custom merge function for lodash mergeWith when merging settings. - * Arrays are concatenated and deduplicated; other values use default lodash merge behavior. + * Arrays are concatenated and deduplicated. Model-pricing maps preserve + * arbitrary exact keys and replace complete entries. Other values use default + * lodash merge behavior. * Exported for testing. */ export function settingsMergeCustomizer( objValue: unknown, srcValue: unknown, + key?: PropertyKey, ): unknown { if (Array.isArray(objValue) && Array.isArray(srcValue)) { return mergeArrays(objValue, srcValue) } + if ( + key === 'modelPricing' && + typeof srcValue === 'object' && + srcValue !== null && + !Array.isArray(srcValue) + ) { + // Model ids are arbitrary strings, including Object.prototype names. + // Copy into a null-prototype map so mergeWith cannot interpret them as + // structure. Replace each complete higher-priority entry atomically: a + // missing optional webSearchRequests field must use its documented + // default, not inherit a lower-priority source's value. + const merged = Object.create(null) as Record + if ( + typeof objValue === 'object' && + objValue !== null && + !Array.isArray(objValue) + ) { + for (const [model, entry] of Object.entries(objValue)) { + merged[model] = entry + } + } + for (const [model, entry] of Object.entries(srcValue)) { + merged[model] = entry + } + return merged + } // Return undefined to let lodash handle default merge behavior return undefined } diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index 4b7f3e7a9..121a17c3c 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -1,5 +1,5 @@ import { feature } from 'bun:bundle' -import { z } from 'zod/v4' +import { toJSONSchema, z } from 'zod/v4' import { SandboxSettingsSchema } from '../../entrypoints/sandboxTypes.js' import { isEnvTruthy } from '../envUtils.js' import { lazySchema } from '../lazySchema.js' @@ -30,6 +30,103 @@ import { type HookCommand, HooksSchema } from '../../schemas/hooks.js' import { AutoFixConfigSchema } from '../../services/autoFix/autoFixConfig.js' import { count } from '../array.js' +export const MAX_MODEL_PRICING_ENTRIES = 256 +export const MAX_MODEL_PRICING_ID_LENGTH = 512 +export const MAX_MODEL_TOKEN_RATE_USD_PER_MILLION = 100_000 +export const MAX_MODEL_WEB_SEARCH_RATE_USD_PER_REQUEST = 1_000 + +const ModelTokenRateSchema = z + .number() + .finite() + .nonnegative() + .max(MAX_MODEL_TOKEN_RATE_USD_PER_MILLION) + +export const ModelPricingEntrySchema = z + .object({ + inputTokens: ModelTokenRateSchema.describe( + 'Input-token price in USD per 1,000,000 tokens.', + ), + outputTokens: ModelTokenRateSchema.describe( + 'Output-token price in USD per 1,000,000 tokens.', + ), + promptCacheReadTokens: ModelTokenRateSchema.describe( + 'Prompt-cache read price in USD per 1,000,000 tokens.', + ), + promptCacheWriteTokens: ModelTokenRateSchema.describe( + 'Prompt-cache write price in USD per 1,000,000 tokens.', + ), + webSearchRequests: z + .number() + .finite() + .nonnegative() + .max(MAX_MODEL_WEB_SEARCH_RATE_USD_PER_REQUEST) + .optional() + .describe( + 'Web-search price in USD per request. Defaults to $0.01 when omitted.', + ), + }) + .strict() + +const MODEL_PRICING_KEY_PREFIX = '\0' + +const ModelPricingJSONSchema = z.record( + z.string().min(1).max(MAX_MODEL_PRICING_ID_LENGTH), + ModelPricingEntrySchema, +) + +const ModelPricingSchema = z.preprocess( + value => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return value + } + // Zod's record parser treats Object.prototype names as structural record + // members. Prefix every raw own key before validation so arbitrary exact + // model ids such as `constructor`, `toString`, and `__proto__` stay data. + const entries = Object.entries(value) + if ( + entries.length > MAX_MODEL_PRICING_ENTRIES || + entries.some( + ([model]) => + model.length < 1 || model.length > MAX_MODEL_PRICING_ID_LENGTH, + ) + ) { + return null + } + const encoded: Record = Object.create(null) as Record< + string, + unknown + > + for (const [model, entry] of entries) { + encoded[MODEL_PRICING_KEY_PREFIX + model] = entry + } + return encoded + }, + z + .record(z.string(), ModelPricingEntrySchema) + .transform(value => { + const decoded = Object.create(null) as Record< + string, + z.infer + > + for (const [encodedModel, entry] of Object.entries(value)) { + decoded[encodedModel.slice(MODEL_PRICING_KEY_PREFIX.length)] = entry + } + return decoded + }), +) + +export const ModelPricingDiagnosticSchema = z.unknown().superRefine( + (value, context) => { + if (!ModelPricingSchema.safeParse(value).success) { + context.addIssue({ + code: 'custom', + message: 'Invalid modelPricing value was ignored', + params: { received: value }, + }) + } + }, +) + /** * Schema for environment variables */ @@ -115,6 +212,19 @@ export const ExtraKnownMarketplaceSchema = lazySchema(() => }), ) +// The prefix transform above is deliberately invisible to settings authors, +// so expose the equivalent public input contract to JSON Schema consumers. +// Zod documents this hook for schemas whose runtime representation needs a +// custom JSON Schema (the preprocess itself is otherwise unrepresentable). +ModelPricingSchema._zod.toJSONSchema = () => { + const schema = toJSONSchema(ModelPricingJSONSchema, { + unrepresentable: 'any', + }) as Record + delete schema.$schema + schema.maxProperties = MAX_MODEL_PRICING_ENTRIES + return schema +} + /** * Schema for allowed MCP server entry in enterprise allowlist. * Supports matching by serverName, serverCommand, or serverUrl (mutually exclusive). @@ -867,6 +977,17 @@ export const SettingsSchema = lazySchema(() => 'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS / CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS env vars take precedence. ' + 'Example: { "qwen3.6-plus": { "contextWindow": 1048576, "maxOutputTokens": 32768 } }', ), + modelPricing: ModelPricingSchema.optional() + // One malformed pricing map must not invalidate unrelated settings in + // the same source. Drop the whole field so callers never combine a + // partially-trusted price with built-in cache rates. + .catch(undefined) + .describe( + 'Exact-model pricing overrides. Token fields are USD per 1,000,000 tokens; ' + + 'webSearchRequests is USD per request and defaults to $0.01 when omitted. ' + + 'All four token fields are required. Keys are exact, case-sensitive resolved model ids. ' + + 'Shared project settings are ignored for personal USD accounting.', + ), fastMode: z .boolean() .optional() @@ -1306,6 +1427,7 @@ export type DeniedMcpServerEntry = z.infer< ReturnType > export type SettingsJson = z.infer> +export type ModelPricingEntry = z.infer /** * Type guard for MCP server entry with serverName diff --git a/src/utils/settings/validation.ts b/src/utils/settings/validation.ts index fc4744c14..757ac0a5b 100644 --- a/src/utils/settings/validation.ts +++ b/src/utils/settings/validation.ts @@ -1,11 +1,14 @@ import type { ConfigScope } from 'src/services/mcp/types.js' -import type { ZodError, ZodIssue } from 'zod/v4' +import { type ZodError, type ZodIssue, z } from 'zod/v4' import { jsonParse } from '../slowOperations.js' import { plural } from '../stringUtils.js' import { validatePermissionRule } from './permissionValidation.js' import { generateSettingsJSONSchema } from './schemaOutput.js' import type { SettingsJson } from './types.js' -import { SettingsSchema } from './types.js' +import { + ModelPricingDiagnosticSchema, + SettingsSchema, +} from './types.js' import { getValidationTip } from './validationTips.js' /** @@ -263,3 +266,23 @@ export function filterInvalidPermissionRules( } return warnings } + +export function filterInvalidModelPricing( + data: unknown, + filePath: string, +): ValidationError[] { + if ( + !data || + typeof data !== 'object' || + !Object.hasOwn(data, 'modelPricing') + ) { + return [] + } + + const result = z + .object({ modelPricing: ModelPricingDiagnosticSchema }) + .safeParse({ + modelPricing: (data as Record).modelPricing, + }) + return result.success ? [] : formatZodError(result.error, filePath) +} diff --git a/web/src/data/configuration.ts b/web/src/data/configuration.ts index bbe6a9d10..59c059e8f 100644 --- a/web/src/data/configuration.ts +++ b/web/src/data/configuration.ts @@ -52,6 +52,7 @@ export const settingOptions: SettingOption[] = [ { key: 'subscriptionType', description: "Override the active subscription type from user settings only. Allowed values: free, pro, max, team, enterprise. To prevent spoofing, this override ignores project, repository, local, flag, and policy settings. Setting this to 'free' is authoritative and takes precedence over OAuth or fallback authentication." }, { key: 'smartRouting', description: 'Opt-in smart auto-routing: { enabled, simpleModel, strongModel } route simple turns to the configured simple model. Configure with /smartroute.' }, { key: 'modelLimits', description: 'Per-model overrides for context window and max output tokens, for OpenAI-compatible models missing from the built-in catalog. Example: { "qwen3.6-plus": { "contextWindow": 1048576, "maxOutputTokens": 32768 } }' }, + { key: 'modelPricing', description: 'Exact, case-sensitive resolved-model USD pricing from user, local, --settings/SDK, or managed settings; shared project settings are ignored. Each entry requires inputTokens, outputTokens, promptCacheReadTokens, and promptCacheWriteTokens in USD per 1M tokens; webSearchRequests is USD per request, defaults to $0.01, and explicit zero is valid. Exact entries override built-in/fast pricing, then the existing unknown-model estimate applies. Limits: 256 entries, 512-character ids, $100,000/Mtok, and $1,000/web request. One exact key applies globally across routes/profiles. Example: { "modelPricing": { "nvidia/model": { "inputTokens": 0, "outputTokens": 0, "promptCacheReadTokens": 0, "promptCacheWriteTokens": 0, "webSearchRequests": 0 } } }.' }, { key: 'providerFallbackChain', description: 'Ordered list of provider profile ids. On a rate-limit or quota error, OpenClaude advances to the next profile and retries the turn.' }, { key: 'agentModels', description: 'Map of route key to provider connection info (base_url, api_key, model) for cross-provider agent routing.' }, { key: 'agentRouting', description: 'Map of agent identifier to a model name from agentModels; use the "default" key as fallback.' },