feat(cost): support exact custom model pricing (#2131)

* feat(cost): support exact custom model pricing

* fix(cost): address custom pricing review feedback
This commit is contained in:
Bogdan
2026-08-16 15:55:02 +08:00
committed by GitHub
parent c30578819e
commit 09eba26d30
19 changed files with 1582 additions and 68 deletions
@@ -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 },
)
@@ -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 },
)
+22 -23
View File
@@ -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 ? <Box marginLeft={2}><Text color="error">{unavailableReason}</Text></Box> : <><Box flexDirection="column" gap={0} marginLeft={2}><Box flexDirection="row" gap={2}><Text bold={true}>Fast mode</Text><Text color={enableFastMode ? "fastMode" : undefined} bold={enableFastMode}>{enableFastMode ? "ON " : "OFF"}</Text><Text dimColor={true}>{pricing}</Text></Box></Box>{isCooldown && runtimeState.status === "cooldown" && <Box marginLeft={2}><Text color="warning">{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
})}</Text></Box>}</>;
$[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 ? <Box marginLeft={2}><Text color="error">{unavailableReason}</Text></Box> : <><Box flexDirection="column" gap={0} marginLeft={2}><Box flexDirection="row" gap={2}><Text bold={true}>Fast mode</Text><Text color={enableFastMode ? "fastMode" : undefined} bold={enableFastMode}>{enableFastMode ? "ON " : "OFF"}</Text><Text dimColor={true}>{pricing}</Text></Box></Box>{isCooldown && runtimeState.status === "cooldown" && <Box marginLeft={2}><Text color="warning">{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
})}</Text></Box>}</>;
let t11;
if ($[25] === Symbol.for("react.memo_cache_sentinel")) {
t11 = <Text dimColor={true}>Learn more:{" "}<Link url="https://code.claude.com/docs/en/fast-mode">https://code.claude.com/docs/en/fast-mode</Link></Text>;
@@ -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<string> {
export async function handleFastModeShortcut(enable: boolean, getAppState: () => AppState, setAppState: (f: (prev: AppState) => AppState) => void): Promise<string> {
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`;
+143
View File
@@ -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<typeof getAllowedSettingSources>
let originalFlagPath: string | undefined
let originalFlagInline: Record<string, unknown> | 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<typeof calculateUSDCost>[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<typeof calculateUSDCost>[1]
expect(calculateUSDCost(model, usage)).toBeCloseTo(0.03)
})
+229
View File
@@ -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<void> {
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<string, unknown>
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 <FastModePicker onDone={onPickerDone} unavailableReason={null} />
}
const picker = () => (
<AppStateProvider initialState={appState}>
<KeybindingSetup>
<PickerWithSettingsReload />
</KeybindingSetup>
</AppStateProvider>
)
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 })
}
@@ -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<string>(),
builtInCommandNames: new Set<string>(),
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<Message> {
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<Extract<SDKMessage, { type: 'result' }>> {
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<SDKMessage, { type: 'result' }> =>
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 })
}
+9 -3
View File
@@ -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}`
}
@@ -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'
+20 -22
View File
@@ -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`,
}
+164 -1
View File
@@ -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<string, PricingOverride>
let originalSources: ReturnType<typeof getAllowedSettingSources>
let originalFlagPath: string | undefined
let originalFlagInline: Record<string, unknown> | 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<string, PricingOverride>
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<typeof calculateUSDCost>[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<typeof calculateUSDCost>[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<typeof calculateUSDCost>[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)
})
+32 -12
View File
@@ -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
}
+185
View File
@@ -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<typeof getAllowedSettingSources>
let originalFlagPath: string | undefined
let originalFlagInline: Record<string, unknown> | null
let settingsOverrideActive = false
let settingsBySource: Partial<Record<string, SettingsJson>> = {}
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<typeof realSettings.getSettingsForSource>[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<string, unknown>
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<string, unknown>
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)
})
+64
View File
@@ -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<SettingSource>([
'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
}
@@ -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<string, unknown>
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',
)
})
+41 -4
View File
@@ -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<T>(targetArray: T[], sourceArray: T[]): T[] {
return uniq([...targetArray, ...sourceArray])
@@ -532,16 +540,45 @@ function mergeArrays<T>(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<string, unknown>
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
}
+123 -1
View File
@@ -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<string, unknown> = 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<typeof ModelPricingEntrySchema>
>
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<string, unknown>
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<typeof DeniedMcpServerEntrySchema>
>
export type SettingsJson = z.infer<ReturnType<typeof SettingsSchema>>
export type ModelPricingEntry = z.infer<typeof ModelPricingEntrySchema>
/**
* Type guard for MCP server entry with serverName
+25 -2
View File
@@ -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<string, unknown>).modelPricing,
})
return result.success ? [] : formatZodError(result.error, filePath)
}