mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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:
@@ -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
@@ -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`;
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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`,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.' },
|
||||
|
||||
Reference in New Issue
Block a user