mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -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
|
metadata (a known catalog model keeps its catalog limit unless you set an
|
||||||
*exact* env override for it).
|
*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
|
## Safety strictness
|
||||||
|
|
||||||
OpenClaude runs several "safety" checks: a model-level refusal directive, bash
|
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 type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js';
|
||||||
import { Dialog } from '../../components/design-system/Dialog.js';
|
import { Dialog } from '../../components/design-system/Dialog.js';
|
||||||
import { FastIcon, getFastIconString } from '../../components/FastIcon.js';
|
import { FastIcon, getFastIconString } from '../../components/FastIcon.js';
|
||||||
|
import { useSettings } from '../../hooks/useSettings.js';
|
||||||
import { Box, Link, Text } from '../../ink.js';
|
import { Box, Link, Text } from '../../ink.js';
|
||||||
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||||
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.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 type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||||
import { clearFastModeCooldown, FAST_MODE_MODEL_DISPLAY, getFastModeModel, getFastModeRuntimeState, getFastModeUnavailableReason, isFastModeEnabled, isFastModeSupportedByModel, prefetchFastModeStatus } from '../../utils/fastMode.js';
|
import { clearFastModeCooldown, FAST_MODE_MODEL_DISPLAY, getFastModeModel, getFastModeRuntimeState, getFastModeUnavailableReason, isFastModeEnabled, isFastModeSupportedByModel, prefetchFastModeStatus } from '../../utils/fastMode.js';
|
||||||
import { formatDuration } from '../../utils/format.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';
|
import { updateSettingsForSource } from '../../utils/settings/settings.js';
|
||||||
function applyFastMode(enable: boolean, setAppState: (f: (prev: AppState) => AppState) => void): void {
|
function applyFastMode(enable: boolean, setAppState: (f: (prev: AppState) => AppState) => void): void {
|
||||||
clearFastModeCooldown();
|
clearFastModeCooldown();
|
||||||
@@ -47,6 +49,7 @@ export function FastModePicker(t0) {
|
|||||||
const model = useAppState(_temp);
|
const model = useAppState(_temp);
|
||||||
const initialFastMode = useAppState(_temp2);
|
const initialFastMode = useAppState(_temp2);
|
||||||
const setAppState = useSetAppState();
|
const setAppState = useSetAppState();
|
||||||
|
useSettings();
|
||||||
const [enableFastMode, setEnableFastMode] = useState(initialFastMode ?? false);
|
const [enableFastMode, setEnableFastMode] = useState(initialFastMode ?? false);
|
||||||
let t1;
|
let t1;
|
||||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||||
@@ -58,14 +61,11 @@ export function FastModePicker(t0) {
|
|||||||
const runtimeState = t1;
|
const runtimeState = t1;
|
||||||
const isCooldown = runtimeState.status === "cooldown";
|
const isCooldown = runtimeState.status === "cooldown";
|
||||||
const isUnavailable = unavailableReason !== null;
|
const isUnavailable = unavailableReason !== null;
|
||||||
let t2;
|
// Settings are reloadable while the process is running, so do not pin this
|
||||||
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
|
// external-store-derived price in the compiler memo cache.
|
||||||
t2 = formatModelPricing(getOpus46CostTier(true));
|
const pricing = getModelPricingString(getDefaultOpusModel(), {
|
||||||
$[1] = t2;
|
speed: 'fast'
|
||||||
} else {
|
}) ?? '';
|
||||||
t2 = $[1];
|
|
||||||
}
|
|
||||||
const pricing = t2;
|
|
||||||
let t3;
|
let t3;
|
||||||
if ($[2] !== enableFastMode || $[3] !== isUnavailable || $[4] !== model || $[5] !== onDone || $[6] !== setAppState) {
|
if ($[2] !== enableFastMode || $[3] !== isUnavailable || $[4] !== model || $[5] !== onDone || $[6] !== setAppState) {
|
||||||
t3 = function handleConfirm() {
|
t3 = function handleConfirm() {
|
||||||
@@ -80,7 +80,10 @@ export function FastModePicker(t0) {
|
|||||||
if (enableFastMode) {
|
if (enableFastMode) {
|
||||||
const fastIcon = getFastIconString(enableFastMode);
|
const fastIcon = getFastIconString(enableFastMode);
|
||||||
const modelUpdated = !isFastModeSupportedByModel(model) ? ` · model set to ${FAST_MODE_MODEL_DISPLAY}` : "";
|
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 {
|
} else {
|
||||||
setAppState(_temp3);
|
setAppState(_temp3);
|
||||||
onDone("Fast mode OFF");
|
onDone("Fast mode OFF");
|
||||||
@@ -178,17 +181,11 @@ export function FastModePicker(t0) {
|
|||||||
} else {
|
} else {
|
||||||
t9 = $[21];
|
t9 = $[21];
|
||||||
}
|
}
|
||||||
let t10;
|
// Pricing comes from the reloadable settings cache, so keep the rendered
|
||||||
if ($[22] !== enableFastMode || $[23] !== unavailableReason) {
|
// value out of the compiler memo cache as well as the confirmation callback.
|
||||||
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(), {
|
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
|
hideTrailingZeros: true
|
||||||
})}</Text></Box>}</>;
|
})}</Text></Box>}</>;
|
||||||
$[22] = enableFastMode;
|
|
||||||
$[23] = unavailableReason;
|
|
||||||
$[24] = t10;
|
|
||||||
} else {
|
|
||||||
t10 = $[24];
|
|
||||||
}
|
|
||||||
let t11;
|
let t11;
|
||||||
if ($[25] === Symbol.for("react.memo_cache_sentinel")) {
|
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>;
|
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) {
|
function _temp(s) {
|
||||||
return s.mainLoopModel;
|
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();
|
const unavailableReason = getFastModeUnavailableReason();
|
||||||
if (unavailableReason) {
|
if (unavailableReason) {
|
||||||
return `Fast mode unavailable: ${unavailableReason}`;
|
return `Fast mode unavailable: ${unavailableReason}`;
|
||||||
@@ -239,7 +236,9 @@ async function handleFastModeShortcut(enable: boolean, getAppState: () => AppSta
|
|||||||
if (enable) {
|
if (enable) {
|
||||||
const fastIcon = getFastIconString(true);
|
const fastIcon = getFastIconString(true);
|
||||||
const modelUpdated = !isFastModeSupportedByModel(mainLoopModel) ? ` · model set to ${FAST_MODE_MODEL_DISPLAY}` : '';
|
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}`;
|
return `${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`;
|
||||||
} else {
|
} else {
|
||||||
return `Fast mode OFF`;
|
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'
|
} from '../context.js'
|
||||||
import { isEnvTruthy } from '../envUtils.js'
|
import { isEnvTruthy } from '../envUtils.js'
|
||||||
import { getModelStrings, resolveOverriddenModel } from './modelStrings.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 { getSettings_DEPRECATED } from '../settings/settings.js'
|
||||||
import type { PermissionMode } from '../permissions/PermissionMode.js'
|
import type { PermissionMode } from '../permissions/PermissionMode.js'
|
||||||
import {
|
import {
|
||||||
@@ -562,9 +562,15 @@ export function renderDefaultModelSetting(
|
|||||||
return renderModelName(parseUserSpecifiedModel(setting))
|
return renderModelName(parseUserSpecifiedModel(setting))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOpus46PricingSuffix(fastMode: boolean): string {
|
export function getOpus46PricingSuffix(
|
||||||
|
fastMode: boolean,
|
||||||
|
model: string = getModelStrings().opus48,
|
||||||
|
): string {
|
||||||
if (!isFirstPartyAnthropicProvider()) return ''
|
if (!isFirstPartyAnthropicProvider()) return ''
|
||||||
const pricing = formatModelPricing(getOpus46CostTier(fastMode))
|
const pricing = getModelPricingString(model, {
|
||||||
|
speed: fastMode ? 'fast' : 'standard',
|
||||||
|
})
|
||||||
|
if (!pricing) return ''
|
||||||
const fastModeIndicator = fastMode ? ` (${LIGHTNING_BOLT})` : ''
|
const fastModeIndicator = fastMode ? ` (${LIGHTNING_BOLT})` : ''
|
||||||
return ` ·${fastModeIndicator} ${pricing}`
|
return ` ·${fastModeIndicator} ${pricing}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,19 @@ test('custom Anthropic endpoints use the third-party default description', async
|
|||||||
expect(defaultOption?.description).not.toContain('$')
|
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 () => {
|
test('OpenRouter active profile cache merges with the static route catalog', async () => {
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1'
|
process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1'
|
||||||
|
|||||||
@@ -16,12 +16,7 @@ import {
|
|||||||
isTeamPremiumSubscriber,
|
isTeamPremiumSubscriber,
|
||||||
} from '../auth.js'
|
} from '../auth.js'
|
||||||
import { getModelStrings } from './modelStrings.js'
|
import { getModelStrings } from './modelStrings.js'
|
||||||
import {
|
import { getModelPricingString } from '../modelCost.js'
|
||||||
COST_TIER_3_15,
|
|
||||||
COST_HAIKU_35,
|
|
||||||
COST_HAIKU_45,
|
|
||||||
formatModelPricing,
|
|
||||||
} from '../modelCost.js'
|
|
||||||
import { getSettings_DEPRECATED } from '../settings/settings.js'
|
import { getSettings_DEPRECATED } from '../settings/settings.js'
|
||||||
import { checkOpus1mAccess, checkSonnet1mAccess } from './check1mAccess.js'
|
import { checkOpus1mAccess, checkSonnet1mAccess } from './check1mAccess.js'
|
||||||
import {
|
import {
|
||||||
@@ -187,10 +182,16 @@ export function getDefaultOptionForUser(fastMode = false): ModelOption {
|
|||||||
return {
|
return {
|
||||||
value: null,
|
value: null,
|
||||||
label: 'Default (recommended)',
|
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 {
|
function getCustomSonnetOption(): ModelOption | undefined {
|
||||||
const is3P = getAPIProvider() !== 'firstParty'
|
const is3P = getAPIProvider() !== 'firstParty'
|
||||||
const customSonnetModel = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
const customSonnetModel = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||||
@@ -216,7 +217,7 @@ function getSonnet46Option(): ModelOption {
|
|||||||
return {
|
return {
|
||||||
value: is3P ? getModelStrings().sonnet46 : 'sonnet',
|
value: is3P ? getModelStrings().sonnet46 : 'sonnet',
|
||||||
label: '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:
|
descriptionForModel:
|
||||||
'Sonnet 4.6 - best for everyday tasks. Generally recommended for most coding tasks',
|
'Sonnet 4.6 - best for everyday tasks. Generally recommended for most coding tasks',
|
||||||
}
|
}
|
||||||
@@ -253,7 +254,7 @@ function getOpus48Option(fastMode = false): ModelOption {
|
|||||||
return {
|
return {
|
||||||
value: is3P ? getModelStrings().opus48 : 'opus',
|
value: is3P ? getModelStrings().opus48 : 'opus',
|
||||||
label: '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',
|
descriptionForModel: 'Opus 4.8 - most capable for complex work',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,7 +264,7 @@ function getOpus47Option(fastMode = false): ModelOption {
|
|||||||
return {
|
return {
|
||||||
value: is3P ? getModelStrings().opus47 : 'opus',
|
value: is3P ? getModelStrings().opus47 : 'opus',
|
||||||
label: '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',
|
descriptionForModel: 'Opus 4.7 - most capable for complex work',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,7 +274,7 @@ function getOpus46Option(fastMode = false): ModelOption {
|
|||||||
return {
|
return {
|
||||||
value: is3P ? getModelStrings().opus46 : 'opus',
|
value: is3P ? getModelStrings().opus46 : 'opus',
|
||||||
label: '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',
|
descriptionForModel: 'Opus 4.6 - most capable for complex work',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,7 +284,7 @@ export function getSonnet46_1MOption(): ModelOption {
|
|||||||
return {
|
return {
|
||||||
value: is3P ? getModelStrings().sonnet46 + '[1m]' : 'sonnet[1m]',
|
value: is3P ? getModelStrings().sonnet46 + '[1m]' : 'sonnet[1m]',
|
||||||
label: 'Sonnet (1M context)',
|
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:
|
descriptionForModel:
|
||||||
'Sonnet 4.6 with 1M context window - for long sessions with large codebases',
|
'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 {
|
return {
|
||||||
value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]',
|
value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]',
|
||||||
label: 'Opus (1M context)',
|
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`,
|
descriptionForModel: `${opusName} with 1M context window - for long sessions with large codebases`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,22 +320,20 @@ function getCustomHaikuOption(): ModelOption | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getHaiku45Option(): ModelOption {
|
function getHaiku45Option(): ModelOption {
|
||||||
const is3P = getAPIProvider() !== 'firstParty'
|
|
||||||
return {
|
return {
|
||||||
value: 'haiku',
|
value: 'haiku',
|
||||||
label: '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:
|
descriptionForModel:
|
||||||
'Haiku 4.5 - fastest for quick answers. Lower cost but less capable than Sonnet 4.6.',
|
'Haiku 4.5 - fastest for quick answers. Lower cost but less capable than Sonnet 4.6.',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHaiku35Option(): ModelOption {
|
function getHaiku35Option(): ModelOption {
|
||||||
const is3P = getAPIProvider() !== 'firstParty'
|
|
||||||
return {
|
return {
|
||||||
value: 'haiku',
|
value: 'haiku',
|
||||||
label: '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:
|
descriptionForModel:
|
||||||
'Haiku 3.5 - faster and lower cost, but less capable than Sonnet. Use for simple tasks.',
|
'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 {
|
return {
|
||||||
value: 'opus',
|
value: 'opus',
|
||||||
label: '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 {
|
export function getMaxSonnet46_1MOption(): ModelOption {
|
||||||
const is3P = getAPIProvider() !== 'firstParty'
|
|
||||||
const billingInfo = isClaudeAISubscriber() ? ' · Billed as extra usage' : ''
|
const billingInfo = isClaudeAISubscriber() ? ' · Billed as extra usage' : ''
|
||||||
return {
|
return {
|
||||||
value: 'sonnet[1m]',
|
value: 'sonnet[1m]',
|
||||||
label: 'Sonnet (1M context)',
|
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 {
|
return {
|
||||||
value: 'opus[1m]',
|
value: 'opus[1m]',
|
||||||
label: 'Opus (1M context)',
|
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 {
|
return {
|
||||||
value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]',
|
value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]',
|
||||||
label: 'Opus (1M context)',
|
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:
|
descriptionForModel:
|
||||||
`${is3P ? 'Opus 4.6' : 'Opus 4.8'} with 1M context - most capable for complex work`,
|
`${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 { 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 {
|
import {
|
||||||
acquireSharedMutationLock,
|
acquireSharedMutationLock,
|
||||||
releaseSharedMutationLock,
|
releaseSharedMutationLock,
|
||||||
} from '../test/sharedMutationLock.js'
|
} from '../test/sharedMutationLock.js'
|
||||||
import * as realFastMode from './fastMode.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() {
|
async function importFreshModelCost() {
|
||||||
return import(`./modelCost.js?ts=${Date.now()}-${Math.random()}`)
|
return import(`./modelCost.js?ts=${Date.now()}-${Math.random()}`)
|
||||||
@@ -12,12 +38,25 @@ async function importFreshModelCost() {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await acquireSharedMutationLock('utils/modelCost.modelGate.test.ts')
|
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(() => {
|
afterEach(() => {
|
||||||
try {
|
try {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
mock.module('./fastMode.js', () => realFastMode)
|
mock.module('./fastMode.js', () => realFastMode)
|
||||||
|
mock.module('./model/model.js', () => realModelSnapshot)
|
||||||
|
setAllowedSettingSources(originalSources)
|
||||||
|
setFlagSettingsPath(originalFlagPath)
|
||||||
|
setFlagSettingsInline(originalFlagInline)
|
||||||
|
resetSettingsCache()
|
||||||
} finally {
|
} finally {
|
||||||
releaseSharedMutationLock()
|
releaseSharedMutationLock()
|
||||||
}
|
}
|
||||||
@@ -111,3 +150,127 @@ test('proto-member model ids fall through the unknown-model path, not NaN', asyn
|
|||||||
resetCostState()
|
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 { logEvent } from 'src/services/analytics/index.js'
|
||||||
import { setHasUnknownModelCost } from '../bootstrap/state.js'
|
import { setHasUnknownModelCost } from '../bootstrap/state.js'
|
||||||
import { isFastModeEnabled } from './fastMode.js'
|
import { isFastModeEnabled } from './fastMode.js'
|
||||||
|
import { getModelPricingOverride } from './settings/modelPricing.js'
|
||||||
import {
|
import {
|
||||||
CLAUDE_3_5_HAIKU_CONFIG,
|
CLAUDE_3_5_HAIKU_CONFIG,
|
||||||
CLAUDE_3_5_V2_SONNET_CONFIG,
|
CLAUDE_3_5_V2_SONNET_CONFIG,
|
||||||
@@ -87,7 +88,7 @@ export const COST_HAIKU_45 = {
|
|||||||
webSearchRequests: 0.01,
|
webSearchRequests: 0.01,
|
||||||
} as const satisfies ModelCosts
|
} 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.
|
* 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)
|
const shortName = getCanonicalName(model)
|
||||||
|
|
||||||
// Check if this is a fast-mode-capable Opus model (4.8/4.7/4.6) with fast mode
|
// 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
|
// getCanonicalName passes them through unchanged) would return a truthy
|
||||||
// prototype value, skip the unknown-model path, and yield NaN costs downstream.
|
// prototype value, skip the unknown-model path, and yield NaN costs downstream.
|
||||||
// Match on own properties only.
|
// Match on own properties only.
|
||||||
if (!Object.hasOwn(MODEL_COSTS, shortName)) {
|
return Object.hasOwn(MODEL_COSTS, shortName)
|
||||||
trackUnknownModelCost(model, shortName)
|
? MODEL_COSTS[shortName]
|
||||||
return DEFAULT_UNKNOWN_MODEL_COST
|
: 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 {
|
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
|
* Accepts either a short name or full model name
|
||||||
* Returns undefined if model is not found
|
* Returns undefined if model is not found
|
||||||
*/
|
*/
|
||||||
export function getModelPricingString(model: string): string | undefined {
|
export function getModelPricingString(
|
||||||
const shortName = getCanonicalName(model)
|
model: string,
|
||||||
// Own-property guard: a proto-member id (`constructor`, `__proto__`) would
|
usage: { speed?: Usage['speed'] } = {},
|
||||||
// otherwise return an inherited value and render "$NaN/$NaN per Mtok".
|
): string | undefined {
|
||||||
if (!Object.hasOwn(MODEL_COSTS, shortName)) return undefined
|
const costs = getKnownModelCosts(model, usage)
|
||||||
return formatModelPricing(MODEL_COSTS[shortName])
|
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'
|
} from './settingsCache.js'
|
||||||
import { type SettingsJson, SettingsSchema } from './types.js'
|
import { type SettingsJson, SettingsSchema } from './types.js'
|
||||||
import {
|
import {
|
||||||
|
filterInvalidModelPricing,
|
||||||
filterInvalidPermissionRules,
|
filterInvalidPermissionRules,
|
||||||
formatZodError,
|
formatZodError,
|
||||||
type SettingsWithErrors,
|
type SettingsWithErrors,
|
||||||
@@ -215,15 +216,22 @@ function parseSettingsFileUncached(path: string): {
|
|||||||
// Filter invalid permission rules before schema validation so one bad
|
// Filter invalid permission rules before schema validation so one bad
|
||||||
// rule doesn't cause the entire settings file to be rejected.
|
// rule doesn't cause the entire settings file to be rejected.
|
||||||
const ruleWarnings = filterInvalidPermissionRules(data, path)
|
const ruleWarnings = filterInvalidPermissionRules(data, path)
|
||||||
|
const modelPricingWarnings = filterInvalidModelPricing(data, path)
|
||||||
|
|
||||||
const result = SettingsSchema().safeParse(data)
|
const result = SettingsSchema().safeParse(data)
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const errors = formatZodError(result.error, path)
|
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) {
|
} catch (error) {
|
||||||
handleFileSystemError(error, path)
|
handleFileSystemError(error, path)
|
||||||
return { settings: null, errors: [] }
|
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[] {
|
function mergeArrays<T>(targetArray: T[], sourceArray: T[]): T[] {
|
||||||
return uniq([...targetArray, ...sourceArray])
|
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.
|
* 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.
|
* Exported for testing.
|
||||||
*/
|
*/
|
||||||
export function settingsMergeCustomizer(
|
export function settingsMergeCustomizer(
|
||||||
objValue: unknown,
|
objValue: unknown,
|
||||||
srcValue: unknown,
|
srcValue: unknown,
|
||||||
|
key?: PropertyKey,
|
||||||
): unknown {
|
): unknown {
|
||||||
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
|
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
|
||||||
return mergeArrays(objValue, 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 to let lodash handle default merge behavior
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
+123
-1
@@ -1,5 +1,5 @@
|
|||||||
import { feature } from 'bun:bundle'
|
import { feature } from 'bun:bundle'
|
||||||
import { z } from 'zod/v4'
|
import { toJSONSchema, z } from 'zod/v4'
|
||||||
import { SandboxSettingsSchema } from '../../entrypoints/sandboxTypes.js'
|
import { SandboxSettingsSchema } from '../../entrypoints/sandboxTypes.js'
|
||||||
import { isEnvTruthy } from '../envUtils.js'
|
import { isEnvTruthy } from '../envUtils.js'
|
||||||
import { lazySchema } from '../lazySchema.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 { AutoFixConfigSchema } from '../../services/autoFix/autoFixConfig.js'
|
||||||
import { count } from '../array.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
|
* 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.
|
* Schema for allowed MCP server entry in enterprise allowlist.
|
||||||
* Supports matching by serverName, serverCommand, or serverUrl (mutually exclusive).
|
* 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. ' +
|
'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS / CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS env vars take precedence. ' +
|
||||||
'Example: { "qwen3.6-plus": { "contextWindow": 1048576, "maxOutputTokens": 32768 } }',
|
'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
|
fastMode: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -1306,6 +1427,7 @@ export type DeniedMcpServerEntry = z.infer<
|
|||||||
ReturnType<typeof DeniedMcpServerEntrySchema>
|
ReturnType<typeof DeniedMcpServerEntrySchema>
|
||||||
>
|
>
|
||||||
export type SettingsJson = z.infer<ReturnType<typeof SettingsSchema>>
|
export type SettingsJson = z.infer<ReturnType<typeof SettingsSchema>>
|
||||||
|
export type ModelPricingEntry = z.infer<typeof ModelPricingEntrySchema>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type guard for MCP server entry with serverName
|
* Type guard for MCP server entry with serverName
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import type { ConfigScope } from 'src/services/mcp/types.js'
|
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 { jsonParse } from '../slowOperations.js'
|
||||||
import { plural } from '../stringUtils.js'
|
import { plural } from '../stringUtils.js'
|
||||||
import { validatePermissionRule } from './permissionValidation.js'
|
import { validatePermissionRule } from './permissionValidation.js'
|
||||||
import { generateSettingsJSONSchema } from './schemaOutput.js'
|
import { generateSettingsJSONSchema } from './schemaOutput.js'
|
||||||
import type { SettingsJson } from './types.js'
|
import type { SettingsJson } from './types.js'
|
||||||
import { SettingsSchema } from './types.js'
|
import {
|
||||||
|
ModelPricingDiagnosticSchema,
|
||||||
|
SettingsSchema,
|
||||||
|
} from './types.js'
|
||||||
import { getValidationTip } from './validationTips.js'
|
import { getValidationTip } from './validationTips.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -263,3 +266,23 @@ export function filterInvalidPermissionRules(
|
|||||||
}
|
}
|
||||||
return warnings
|
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: '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: '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: '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: '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: '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.' },
|
{ 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