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
+64
View File
@@ -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
@@ -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)
}
+1
View File
@@ -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.' },