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.' },