diff --git a/src/core/task/StreamingMetricsManager.ts b/src/core/task/StreamingMetricsManager.ts index abc62936..abf9e52c 100644 --- a/src/core/task/StreamingMetricsManager.ts +++ b/src/core/task/StreamingMetricsManager.ts @@ -84,8 +84,8 @@ export class StreamingMetricsManager { }) } - /** Compute the cost, using the provider-calculated value when available. */ - getTotalCost(): number { + /** Compute the cost, using the provider-calculated value when available. Returns undefined when pricing is unknown. */ + getTotalCost(): number | undefined { return ( this.metrics.totalCost ?? calculateCost({ diff --git a/src/core/task/__tests__/StreamingMetricsManager.test.ts b/src/core/task/__tests__/StreamingMetricsManager.test.ts index 4ca3c1e4..87bb6730 100644 --- a/src/core/task/__tests__/StreamingMetricsManager.test.ts +++ b/src/core/task/__tests__/StreamingMetricsManager.test.ts @@ -41,10 +41,18 @@ describe("StreamingMetricsManager", () => { expect(manager.getTotalCost()).to.equal(1.23) }) - it("computes cost via calculateCost when provider totalCost is absent", () => { + it("returns undefined when provider totalCost is absent and model has no pricing", () => { const manager = new StreamingMetricsManager({} as any, 0, stubApi(100_000) as any) manager.updateFromChunk({ inputTokens: 0, outputTokens: 0 }) - // zero tokens and no provider cost -> calculateCost yields 0 + // no provider cost + no model pricing -> unknown, not zero + expect(manager.getTotalCost()).to.be.undefined + }) + + it("returns 0 when provider totalCost is absent but model has explicit zero pricing", () => { + const freeApi = { getModel: () => ({ info: { contextWindow: 100_000, inputPrice: 0, outputPrice: 0 } }) } as any + const manager = new StreamingMetricsManager({} as any, 0, freeApi) + manager.updateFromChunk({ inputTokens: 10, outputTokens: 5 }) + // explicitly free model -> cost is genuinely 0 expect(manager.getTotalCost()).to.equal(0) }) diff --git a/src/core/task/index.ts b/src/core/task/index.ts index 3c8353bd..e62222fd 100644 --- a/src/core/task/index.ts +++ b/src/core/task/index.ts @@ -2185,7 +2185,8 @@ export class Task { this.taskState.totalReasoningTokens += metrics.reasoningTokens this.taskState.totalCacheWriteTokens += metrics.cacheWriteTokens this.taskState.totalCacheReadTokens += metrics.cacheReadTokens - this.taskState.totalCost += metricsManager.getTotalCost() + const cost = metricsManager.getTotalCost() + if (cost !== undefined) this.taskState.totalCost += cost const currentApiReqIndex = findLastIndex( this.messageStateHandler.getDiracMessages(), diff --git a/src/core/task/utils.ts b/src/core/task/utils.ts index d3bc2b69..c8ca6053 100644 --- a/src/core/task/utils.ts +++ b/src/core/task/utils.ts @@ -2,9 +2,7 @@ import { ApiHandler } from "@core/api" import { execSync } from "child_process" import { showSystemNotification } from "@/integrations/notifications" import { DiracApiReqCancelReason, DiracApiReqInfo, DiracMessageType } from "@/shared/ExtensionMessage" - -import { calculateApiCostAnthropic } from "@/utils/cost" -import { calculateApiCostOpenAI, calculateApiCostQwen } from "@/utils/cost" +import { calculateApiCostAnthropic, calculateApiCostOpenAI, calculateApiCostQwen } from "@/utils/cost" import { MessageStateHandler } from "./message-state" export const showNotificationForApproval = (message: string, notificationsEnabled: boolean) => { @@ -40,7 +38,7 @@ export const calculateCost = (params: { cacheReadTokens: number reasoningTokens: number api: ApiHandler -}): number => { +}): number | undefined => { const info = params.api.getModel().info const provider = params.api.constructor.name if (provider === "ZAiHandler" || provider === "OpenAiHandler" || provider === "DeepSeekHandler") { diff --git a/src/shared/api/models/index.ts b/src/shared/api/models/index.ts index 7632358f..9d435d8a 100644 --- a/src/shared/api/models/index.ts +++ b/src/shared/api/models/index.ts @@ -1,42 +1,56 @@ // Barrel re-export — all model registries, types, and capabilities -export { - type ModelCapabilities, - type ModelInfo, - type PriceTier, - type OpenAiCompatibleProfile, - type ModelProviderPreset, - type ModelProviderSelection, - type OpenAiCompatibleModelInfo, - type OcaModelInfo, - type LiteLLMModelInfo, - type BasetenModelInfo, -} from "./types" -export { createModelProviderSelection } from "./types" -export { MODEL_CAPABILITIES } from "./capabilities" -export { GPT_5_5_TIERS, GPT_5_4_TIERS, GPT_5_4_PRO_TIERS } from "./shared-tiers" - // Anthropic export { + ANTHROPIC_FAST_MODE_SUFFIX, + ANTHROPIC_MAX_THINKING_BUDGET, + ANTHROPIC_MIN_THINKING_BUDGET, type AnthropicModelId, anthropicDefaultModelId, anthropicModels, - ANTHROPIC_FAST_MODE_SUFFIX, - ANTHROPIC_MIN_THINKING_BUDGET, - ANTHROPIC_MAX_THINKING_BUDGET, isAnthropicAdaptiveThinkingSupported, } from "./anthropic" -export { type ClaudeCodeModelId, claudeCodeDefaultModelId, claudeCodeModels } from "./claude-code" - +// Baseten +export { type BasetenModelId, basetenDefaultModelId, basetenModels } from "./baseten" // AWS Bedrock export { type BedrockModelId, bedrockDefaultModelId, bedrockModels } from "./bedrock" - -// Google Vertex AI -export { type VertexModelId, vertexDefaultModelId, vertexModels, vertexGlobalModels } from "./vertex" - +export { MODEL_CAPABILITIES } from "./capabilities" +// Cerebras +export { type CerebrasModelId, cerebrasDefaultModelId, cerebrasModels } from "./cerebras" +export { type ClaudeCodeModelId, claudeCodeDefaultModelId, claudeCodeModels } from "./claude-code" +// DeepSeek +export { type DeepSeekModelId, deepSeekDefaultModelId, deepSeekModels } from "./deepseek" +// Doubao +export { type DoubaoModelId, doubaoDefaultModelId, doubaoModels } from "./doubao" +// Fireworks +export { type FireworksModelId, fireworksDefaultModelId, fireworksModels } from "./fireworks" // Google Gemini export { type GeminiModelId, geminiDefaultModelId, geminiModels } from "./gemini" - +// Groq +export { type GroqModelId, groqDefaultModelId, groqModels } from "./groq" +// Huawei Cloud MaaS +export { type HuaweiCloudMaasModelId, huaweiCloudMaasDefaultModelId, huaweiCloudMaasModels } from "./huawei-cloud-maas" +// HuggingFace +export { type HuggingFaceModelId, huggingFaceDefaultModelId, huggingFaceModels } from "./huggingface" +// LiteLLM +export { type LiteLLMModelId, liteLlmDefaultModelId, liteLlmModelInfoSaneDefaults } from "./litellm" +// Minimax +export { type MinimaxModelId, minimaxDefaultModelId, minimaxModels } from "./minimax" +// Mistral +export { type MistralModelId, mistralDefaultModelId, mistralModels } from "./mistral" +// Moonshot +export { type MoonshotModelId, moonshotDefaultModelId, moonshotModels } from "./moonshot" +// Nebius +export { type NebiusModelId, nebiusDefaultModelId, nebiusModels } from "./nebius" +// NousResearch +export { type NousResearchModelId, nousResearchDefaultModelId, nousResearchModels } from "./nousresearch" +export { + type OpenAiCodexModelId, + type OpenAiCodexModelInfo, + openAiCodexDefaultModelId, + openAiCodexModels, +} from "./openai-codex" +export { azureOpenAiDefaultApiVersion, openAiModelInfoSaneDefaults } from "./openai-defaults" // OpenAI Native export { type OpenAiNativeModelId, @@ -44,73 +58,36 @@ export { openAiNativeDefaultModelId, openAiNativeModels, } from "./openai-native" -export { - type OpenAiCodexModelId, - type OpenAiCodexModelInfo, - openAiCodexDefaultModelId, - openAiCodexModels, -} from "./openai-codex" -export { openAiModelInfoSaneDefaults, azureOpenAiDefaultApiVersion } from "./openai-defaults" - -// DeepSeek -export { type DeepSeekModelId, deepSeekDefaultModelId, deepSeekModels } from "./deepseek" - -// HuggingFace -export { type HuggingFaceModelId, huggingFaceDefaultModelId, huggingFaceModels } from "./huggingface" - +export { type QwenCodeModelId, qwenCodeDefaultModelId, qwenCodeModels } from "./qwen-code" // Qwen export { type InternationalQwenModelId, internationalQwenDefaultModelId, internationalQwenModels } from "./qwen-international" export { type MainlandQwenModelId, mainlandQwenDefaultModelId, mainlandQwenModels, QwenApiRegions } from "./qwen-mainland" -export { type QwenCodeModelId, qwenCodeDefaultModelId, qwenCodeModels } from "./qwen-code" - -// Doubao -export { type DoubaoModelId, doubaoDefaultModelId, doubaoModels } from "./doubao" - -// Mistral -export { type MistralModelId, mistralDefaultModelId, mistralModels } from "./mistral" - -// Nebius -export { type NebiusModelId, nebiusDefaultModelId, nebiusModels } from "./nebius" - +// Requesty +export { requestyDefaultModelId, requestyDefaultModelInfo } from "./requesty" +// Sambanova +export { type SambanovaModelId, sambanovaDefaultModelId, sambanovaModels } from "./sambanova" +export { GPT_5_4_PRO_TIERS, GPT_5_4_TIERS, GPT_5_5_TIERS } from "./shared-tiers" +export { + type BasetenModelInfo, + createModelProviderSelection, + hasPricing, + isFreeModel, + type LiteLLMModelInfo, + type ModelCapabilities, + type ModelInfo, + type ModelProviderPreset, + type ModelProviderSelection, + type OcaModelInfo, + type OpenAiCompatibleModelInfo, + type OpenAiCompatibleProfile, + type PriceTier, +} from "./types" +// Google Vertex AI +export { type VertexModelId, vertexDefaultModelId, vertexGlobalModels, vertexModels } from "./vertex" // Wandb export { type WandbModelId, wandbDefaultModelId, wandbModels } from "./wandb" - // XAI export { type XAIModelId, xaiDefaultModelId, xaiModels } from "./xai" - -// Sambanova -export { type SambanovaModelId, sambanovaDefaultModelId, sambanovaModels } from "./sambanova" - -// Cerebras -export { type CerebrasModelId, cerebrasDefaultModelId, cerebrasModels } from "./cerebras" - -// Groq -export { type GroqModelId, groqDefaultModelId, groqModels } from "./groq" - -// Moonshot -export { type MoonshotModelId, moonshotDefaultModelId, moonshotModels } from "./moonshot" - -// Huawei Cloud MaaS -export { type HuaweiCloudMaasModelId, huaweiCloudMaasDefaultModelId, huaweiCloudMaasModels } from "./huawei-cloud-maas" - -// Baseten -export { type BasetenModelId, basetenDefaultModelId, basetenModels } from "./baseten" - // ZAI -export { type internationalZAiModelId, internationalZAiDefaultModelId, internationalZAiModels } from "./zai-international" -export { type mainlandZAiModelId, mainlandZAiDefaultModelId, mainlandZAiModels } from "./zai-mainland" - -// Fireworks -export { type FireworksModelId, fireworksDefaultModelId, fireworksModels } from "./fireworks" - -// Minimax -export { type MinimaxModelId, minimaxDefaultModelId, minimaxModels } from "./minimax" - -// NousResearch -export { type NousResearchModelId, nousResearchDefaultModelId, nousResearchModels } from "./nousresearch" - -// LiteLLM -export { type LiteLLMModelId, liteLlmDefaultModelId, liteLlmModelInfoSaneDefaults } from "./litellm" - -// Requesty -export { requestyDefaultModelId, requestyDefaultModelInfo } from "./requesty" +export { internationalZAiDefaultModelId, type internationalZAiModelId, internationalZAiModels } from "./zai-international" +export { mainlandZAiDefaultModelId, type mainlandZAiModelId, mainlandZAiModels } from "./zai-mainland" diff --git a/src/shared/api/models/litellm.ts b/src/shared/api/models/litellm.ts index 6ffa0114..5ab6ef2e 100644 --- a/src/shared/api/models/litellm.ts +++ b/src/shared/api/models/litellm.ts @@ -13,10 +13,7 @@ export const liteLlmModelInfoSaneDefaults: LiteLLMModelInfo = { contextWindow: 128_000, supportsImages: true, supportsPromptCache: true, - inputPrice: 0, supportsTools: true, - outputPrice: 0, - cacheWritesPrice: 0, - cacheReadsPrice: 0, + // No inputPrice/outputPrice — unknown LiteLLM models have unknown pricing, not $0 temperature: 0, } diff --git a/src/shared/api/models/openai-defaults.ts b/src/shared/api/models/openai-defaults.ts index 029aa088..3619dc59 100644 --- a/src/shared/api/models/openai-defaults.ts +++ b/src/shared/api/models/openai-defaults.ts @@ -9,8 +9,7 @@ export const openAiModelInfoSaneDefaults: OpenAiCompatibleModelInfo = { supportsReasoning: true, supportsStrictTools: false, isR1FormatRequired: false, - inputPrice: 0, - outputPrice: 0, + // No inputPrice/outputPrice — unknown models have unknown pricing, not $0 temperature: 0, } diff --git a/src/shared/api/models/types.ts b/src/shared/api/models/types.ts index 18736a27..cd76c5c2 100644 --- a/src/shared/api/models/types.ts +++ b/src/shared/api/models/types.ts @@ -129,3 +129,13 @@ export interface LiteLLMModelInfo extends ModelInfo { export interface BasetenModelInfo extends ModelInfo { supportedFeatures?: string[] } + +// True when the model has any pricing data (even $0); false when pricing is unknown. +export function hasPricing(modelInfo: ModelInfo): boolean { + return modelInfo.inputPrice !== undefined || modelInfo.outputPrice !== undefined +} + +// True only for models with explicitly zero base prices (genuinely free). +export function isFreeModel(modelInfo: ModelInfo): boolean { + return modelInfo.inputPrice === 0 && modelInfo.outputPrice === 0 +} diff --git a/src/utils/cost.test.ts b/src/utils/cost.test.ts index 6e63d7df..bb40155f 100644 --- a/src/utils/cost.test.ts +++ b/src/utils/cost.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "mocha" import "should" -import { ModelInfo } from "@shared/api" +import { isFreeModel, type ModelInfo } from "@shared/api" import { calculateApiCostAnthropic, calculateApiCostOpenAI, calculateApiCostQwen } from "@utils/cost" describe("Cost Utilities", () => { @@ -16,17 +16,28 @@ describe("Cost Utilities", () => { // Input: (3.0 / 1_000_000) * 1000 = 0.003 // Output: (15.0 / 1_000_000) * 500 = 0.0075 // Total: 0.003 + 0.0075 = 0.0105 - cost.should.equal(0.0105) + cost!.should.equal(0.0105) }) - it("should handle missing prices", () => { + it("should return undefined when prices are missing (unknown pricing)", () => { const modelInfo: ModelInfo = { supportsPromptCache: true, // No prices specified } const cost = calculateApiCostAnthropic(modelInfo, 1000, 500) - cost.should.equal(0) + should.not.exist(cost) + }) + + it("should return 0 when prices are explicitly zero (genuinely free)", () => { + const modelInfo: ModelInfo = { + supportsPromptCache: true, + inputPrice: 0, + outputPrice: 0, + } + + const cost = calculateApiCostAnthropic(modelInfo, 1000, 500) + cost!.should.equal(0) }) it("should use real model configuration (Claude 3.5 Sonnet)", () => { @@ -47,7 +58,7 @@ describe("Cost Utilities", () => { // Input: (3.0 / 1_000_000) * 2000 = 0.006 // Output: (15.0 / 1_000_000) * 1000 = 0.015 // Total: 0.005625 + 0.00015 + 0.006 + 0.015 = 0.026775 - cost.should.equal(0.026775) + cost!.should.equal(0.026775) }) it("should handle zero token counts", () => { @@ -60,7 +71,7 @@ describe("Cost Utilities", () => { } const cost = calculateApiCostAnthropic(modelInfo, 0, 0, 0, 0) - cost.should.equal(0) + cost!.should.equal(0) }) }) @@ -76,17 +87,28 @@ describe("Cost Utilities", () => { // Input: (3.0 / 1_000_000) * 1000 = 0.003 // Output: (15.0 / 1_000_000) * 500 = 0.0075 // Total: 0.003 + 0.0075 = 0.0105 - cost.should.equal(0.0105) + cost!.should.equal(0.0105) }) - it("should handle missing prices", () => { + it("should return undefined when prices are missing (unknown pricing)", () => { const modelInfo: ModelInfo = { supportsPromptCache: true, // No prices specified } const cost = calculateApiCostOpenAI(modelInfo, 1000, 500) - cost.should.equal(0) + should.not.exist(cost) + }) + + it("should return 0 when prices are explicitly zero (genuinely free)", () => { + const modelInfo: ModelInfo = { + supportsPromptCache: true, + inputPrice: 0, + outputPrice: 0, + } + + const cost = calculateApiCostOpenAI(modelInfo, 1000, 500) + cost!.should.equal(0) }) it("should use real model configuration (Claude 3.5 Sonnet)", () => { @@ -107,7 +129,7 @@ describe("Cost Utilities", () => { // Input: (3.0 / 1_000_000) * (2100 - 1500 - 500) = 0.0003 // Output: (15.0 / 1_000_000) * 1000 = 0.015 // Total: 0.005625 + 0.00015 + 0.0003 + 0.015 = 0.021075 - cost.should.equal(0.021075) + cost!.should.equal(0.021075) }) it("should handle zero token counts", () => { @@ -120,7 +142,7 @@ describe("Cost Utilities", () => { } const cost = calculateApiCostOpenAI(modelInfo, 0, 0, 0, 0) - cost.should.equal(0) + cost!.should.equal(0) }) }) @@ -136,17 +158,28 @@ describe("Cost Utilities", () => { // Input: (0.15 / 1_000_000) * 1000 = 0.00015 // Output: (0.6 / 1_000_000) * 500 = 0.0003 // Total: 0.00015 + 0.0003 = 0.00045 - cost.should.equal(0.00045) + cost!.should.equal(0.00045) }) - it("should handle missing prices", () => { + it("should return undefined when prices are missing (unknown pricing)", () => { const modelInfo: ModelInfo = { supportsPromptCache: true, // No prices specified } const cost = calculateApiCostQwen(modelInfo, 1000, 500) - cost.should.equal(0) + should.not.exist(cost) + }) + + it("should return 0 when prices are explicitly zero (genuinely free)", () => { + const modelInfo: ModelInfo = { + supportsPromptCache: true, + inputPrice: 0, + outputPrice: 0, + } + + const cost = calculateApiCostQwen(modelInfo, 1000, 500) + cost!.should.equal(0) }) it("should use real Qwen model configuration (30B)", () => { @@ -163,7 +196,7 @@ describe("Cost Utilities", () => { // Input: (0.15 / 1_000_000) * 1000 = 0.00015 // Output: (0.6 / 1_000_000) * 500 = 0.0003 // Total: 0.00015 + 0.0003 = 0.00045 - cost.should.equal(0.00045) + cost!.should.equal(0.00045) }) it("should handle cache tokens correctly (Qwen-style)", () => { @@ -182,7 +215,7 @@ describe("Cost Utilities", () => { // Input: (0.15 / 1_000_000) * (2100 - 1500 - 500) = 0.000015 // Output: (0.6 / 1_000_000) * 1000 = 0.0006 // Total: 0.0003 + 0.000025 + 0.000015 + 0.0006 = 0.00094 - cost.should.equal(0.00094) + cost!.should.equal(0.00094) }) it("should handle zero token counts", () => { @@ -195,7 +228,33 @@ describe("Cost Utilities", () => { } const cost = calculateApiCostQwen(modelInfo, 0, 0, 0, 0) - cost.should.equal(0) + cost!.should.equal(0) + }) + }) + + describe("isFreeModel", () => { + it("returns true when input and output prices are zero", () => { + isFreeModel({ supportsPromptCache: true, inputPrice: 0, outputPrice: 0 }).should.be.true() + }) + + it("returns false for paid models", () => { + isFreeModel({ supportsPromptCache: true, inputPrice: 3.0, outputPrice: 15.0 }).should.be.false() + }) + + it("returns false for unknown-pricing models", () => { + isFreeModel({ supportsPromptCache: true }).should.be.false() + }) + + it("returns false when one base price is missing", () => { + isFreeModel({ supportsPromptCache: true, inputPrice: 0 }).should.be.false() + }) + + // Regression: paid model with totalCost === 0 must not be labeled FREE. + it("paid model with zero tokens is not labeled FREE", () => { + const paidModel: ModelInfo = { supportsPromptCache: true, inputPrice: 3.0, outputPrice: 15.0 } + const cost = calculateApiCostAnthropic(paidModel, 0, 0, 0, 0) + cost!.should.equal(0) + isFreeModel(paidModel).should.be.false() }) }) }) diff --git a/src/utils/cost.ts b/src/utils/cost.ts index fb690c92..8ee92f4f 100644 --- a/src/utils/cost.ts +++ b/src/utils/cost.ts @@ -1,4 +1,4 @@ -import { ModelInfo } from "@shared/api" +import { hasPricing, ModelInfo } from "@shared/api" function calculateApiCostInternal( modelInfo: ModelInfo, @@ -9,7 +9,10 @@ function calculateApiCostInternal( totalInputTokensForPricing?: number, // The *total* input tokens, used for tiered pricing lookup thinkingBudgetTokens?: number, // Add thinking budget info reasoningTokens?: number, -): number { +): number | undefined { + // No pricing data at all → cost is unknown, not zero + if (!hasPricing(modelInfo)) return undefined + const usedThinkingBudget = thinkingBudgetTokens && thinkingBudgetTokens > 0 // Default prices @@ -73,7 +76,7 @@ export function calculateApiCostAnthropic( cacheReadInputTokens?: number, thinkingBudgetTokens?: number, reasoningTokens?: number, -): number { +): number | undefined { const cacheCreationInputTokensNum = cacheCreationInputTokens || 0 const cacheReadInputTokensNum = cacheReadInputTokens || 0 // Anthropic style: inputTokens already represents the total, so pass it directly for tiered pricing lookup if needed @@ -100,7 +103,7 @@ export function calculateApiCostOpenAI( cacheReadInputTokens?: number, thinkingBudgetTokens?: number, // Pass thinking budget info reasoningTokens?: number, -): number { +): number | undefined { const cacheCreationInputTokensNum = cacheCreationInputTokens || 0 const cacheReadInputTokensNum = cacheReadInputTokens || 0 // Calculate non-cached tokens for the internal function's 'inputTokens' parameter @@ -127,7 +130,7 @@ export function calculateApiCostQwen( cacheReadInputTokens?: number, thinkingBudgetTokens?: number, reasoningTokens?: number, -): number { +): number | undefined { const cacheCreationInputTokensNum = cacheCreationInputTokens || 0 const cacheReadInputTokensNum = cacheReadInputTokens || 0 // Calculate non-cached tokens for the internal function's 'inputTokens' parameter diff --git a/webview-ui/src/features/history/components/HistoryViewItem.tsx b/webview-ui/src/features/history/components/HistoryViewItem.tsx index 27ce35e5..3b7b3254 100644 --- a/webview-ui/src/features/history/components/HistoryViewItem.tsx +++ b/webview-ui/src/features/history/components/HistoryViewItem.tsx @@ -122,7 +122,7 @@ const HistoryViewItem = ({ {formatDate(item.ts)} - ${item.totalCost?.toFixed(4) ?? "0.0000"} + {item.totalCost > 0 ? `$${item.totalCost.toFixed(4)}` : "—"} {expanded ? ( ) : ( diff --git a/webview-ui/src/features/modular-ui/chat/components/TaskHeader/TaskHeader.tsx b/webview-ui/src/features/modular-ui/chat/components/TaskHeader/TaskHeader.tsx index 437f6e53..0d1e21c3 100644 --- a/webview-ui/src/features/modular-ui/chat/components/TaskHeader/TaskHeader.tsx +++ b/webview-ui/src/features/modular-ui/chat/components/TaskHeader/TaskHeader.tsx @@ -3,7 +3,7 @@ import { ChevronDownIcon, ChevronRightIcon } from "lucide-react" import React, { useCallback, useMemo } from "react" import { useAppStore } from "@/app/store/appStore" import { useTaskStore } from "@/entities/task/store/taskStore" -import { getModeSpecificFields, normalizeApiConfiguration } from "@/features/settings/components/utils/providerUtils" +import { normalizeApiConfiguration } from "@/features/settings/components/utils/providerUtils" import { useSettingsStore } from "@/features/settings/store/settingsStore" import { cn } from "@/lib/utils" import { getEnvironmentColor } from "@/shared/lib/environmentColors" @@ -14,6 +14,7 @@ import DeleteTaskButton from "./buttons/DeleteTaskButton" import OpenDiskConversationHistoryButton from "./buttons/OpenDiskConversationHistoryButton" import { CheckpointError } from "./CheckpointError" import ContextWindow from "./ContextWindow" +import { getCostLabel } from "./getCostLabel" import { highlightText } from "./Highlights" const IS_DEV = process.env.IS_DEV === '"true"' @@ -52,7 +53,6 @@ const TaskHeader: React.FC = ({ task, totalCost, cacheHitRate, const currentTaskItem = useTaskStore((state) => state.currentTaskItem) const { selectedModelInfo } = normalizeApiConfiguration(apiConfiguration, mode as Mode) - const modeFields = getModeSpecificFields(apiConfiguration, mode as Mode) const taskText = task.content.type === DiracMessageType.MARKDOWN ? task.content.content : "" const highlightedText = useMemo(() => highlightText(taskText, false), [taskText]) @@ -74,14 +74,7 @@ const TaskHeader: React.FC = ({ task, totalCost, cacheHitRate, return (lastApiReqTotalTokens / contextWindow) * 100 }, [contextWindow, lastApiReqTotalTokens]) - const isCostAvailable = - (totalCost && - modeFields.apiProvider === "openai" && - modeFields.openAiModelInfo?.inputPrice && - modeFields.openAiModelInfo?.outputPrice) || - (modeFields.apiProvider !== "vscode-lm" && - modeFields.apiProvider !== "lmstudio" && - modeFields.apiProvider !== "openai-codex") + const costLabel = getCostLabel(totalCost, selectedModelInfo) const toggleTaskExpanded = useCallback(() => setIsTaskExpanded(!isTaskExpanded), [setIsTaskExpanded, isTaskExpanded]) @@ -135,11 +128,9 @@ const TaskHeader: React.FC = ({ task, totalCost, cacheHitRate, )} - {isCostAvailable && ( -
- ${totalCost?.toFixed(4)} -
- )} +
+ {costLabel} +
{cacheHitRate > 0 && (
{ + it("returns n/a when model info is missing", () => { + expect(getCostLabel(0)).toBe("n/a") + }) + + it("returns n/a when pricing is unknown", () => { + expect(getCostLabel(0, unknown)).toBe("n/a") + expect(getCostLabel(1.23, unknown)).toBe("n/a") + }) + + it("returns FREE for explicitly zero-priced models", () => { + expect(getCostLabel(0, free)).toBe("FREE") + expect(getCostLabel(0.5, free)).toBe("FREE") + }) + + // Regression: paid model with totalCost === 0 must show $0.0000, not FREE. + it("returns $0.0000 for paid models with no usage yet", () => { + expect(getCostLabel(0, paid)).toBe("$0.0000") + }) + + it("returns formatted cost for paid models with usage", () => { + expect(getCostLabel(0.0105, paid)).toBe("$0.0105") + }) +}) diff --git a/webview-ui/src/features/modular-ui/chat/components/TaskHeader/getCostLabel.ts b/webview-ui/src/features/modular-ui/chat/components/TaskHeader/getCostLabel.ts new file mode 100644 index 00000000..16413c05 --- /dev/null +++ b/webview-ui/src/features/modular-ui/chat/components/TaskHeader/getCostLabel.ts @@ -0,0 +1,8 @@ +import { hasPricing, isFreeModel, type ModelInfo } from "@shared/api" + +// n/a | FREE | $X.XXXX (paid with no usage yet is $0.0000) +export function getCostLabel(totalCost: number, modelInfo?: ModelInfo): string { + if (!modelInfo || !hasPricing(modelInfo)) return "n/a" + if (isFreeModel(modelInfo)) return "FREE" + return `$${totalCost.toFixed(4)}` +}