feat: add configurable currency symbol for LiteLLM provider

- Add litellmCurrencySymbol field to provider settings schema
- Create getCurrencySymbol utility function for retrieving the appropriate symbol
- Add UI configuration in LiteLLM settings panel
- Update cost display components to use configurable currency symbol:
  - TaskItemFooter
  - ChatRow
  - BrowserSessionRow
  - TaskHeader
  - CondensationResultRow
  - MaxCostInput
  - MaxLimitInputs
  - AutoApproveSettings
- Add i18n translations for the new setting
- Add comprehensive tests for getCurrencySymbol and MaxCostInput

Closes #10370
This commit is contained in:
Roo Code 2025-12-30 04:13:50 +00:00
parent 19b7dac719
commit b84ac82986
14 changed files with 197 additions and 10 deletions

View file

@ -373,6 +373,7 @@ const litellmSchema = baseProviderSettingsSchema.extend({
litellmApiKey: z.string().optional(),
litellmModelId: z.string().optional(),
litellmUsePromptCache: z.boolean().optional(),
litellmCurrencySymbol: z.string().optional(),
})
const cerebrasSchema = apiModelIdProviderModelSchema.extend({

View file

@ -8,6 +8,7 @@ import { BrowserAction, BrowserActionResult, ClineSayBrowserAction } from "@roo/
import { vscode } from "@src/utils/vscode"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { getCurrencySymbol } from "@src/utils/getCurrencySymbol"
import CodeBlock from "../common/CodeBlock"
import { ProgressIndicator } from "./ProgressIndicator"
@ -161,15 +162,19 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
// Try to use ExtensionStateContext if available, otherwise use props
let browserViewportSize = props.browserViewportSizeProp || "900x600"
let isBrowserSessionActive = props.isBrowserSessionActiveProp || false
let apiConfiguration: import("@roo-code/types").ProviderSettings | undefined
try {
const extensionState = useExtensionState()
browserViewportSize = extensionState.browserViewportSize || "900x600"
isBrowserSessionActive = extensionState.isBrowserSessionActive || false
apiConfiguration = extensionState.apiConfiguration
} catch (_e) {
// Not in ExtensionStateContext, use props
}
const currencySymbol = getCurrencySymbol(apiConfiguration)
const [viewportWidth, viewportHeight] = browserViewportSize.split("x").map(Number)
const defaultMousePosition = `${Math.round(viewportWidth / 2)},${Math.round(viewportHeight / 2)}`
@ -603,7 +608,8 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
display: "flex",
alignItems: "center",
}}>
${totalApiCost.toFixed(4)}
{currencySymbol}
{totalApiCost.toFixed(4)}
</div>
)}

View file

@ -15,6 +15,7 @@ import { useExtensionState } from "@src/context/ExtensionStateContext"
import { findMatchingResourceOrTemplate } from "@src/utils/mcp"
import { vscode } from "@src/utils/vscode"
import { formatPathTooltip } from "@src/utils/formatPathTooltip"
import { getCurrencySymbol } from "@src/utils/getCurrencySymbol"
import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock"
import UpdateTodoListToolBlock from "./UpdateTodoListToolBlock"
@ -1068,7 +1069,8 @@ export const ChatRowContent = ({
<div
className="text-xs text-vscode-dropdown-foreground border-vscode-dropdown-border/50 border px-1.5 py-0.5 rounded-lg"
style={{ opacity: cost !== null && cost !== undefined && cost > 0 ? 1 : 0 }}>
${Number(cost || 0)?.toFixed(4)}
{getCurrencySymbol(apiConfiguration)}
{Number(cost || 0)?.toFixed(4)}
</div>
</div>
{(((cost === null || cost === undefined) && apiRequestFailedMessage) ||

View file

@ -26,6 +26,7 @@ import { StandardTooltip, Button } from "@src/components/ui"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel"
import { vscode } from "@src/utils/vscode"
import { getCurrencySymbol } from "@src/utils/getCurrencySymbol"
import Thumbnails from "../common/Thumbnails"
@ -63,6 +64,7 @@ const TaskHeader = ({
const { t } = useTranslation()
const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive } = useExtensionState()
const { id: modelId, info: model } = useSelectedModel(apiConfiguration)
const currencySymbol = getCurrencySymbol(apiConfiguration)
const [isTaskExpanded, setIsTaskExpanded] = useState(false)
const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false)
const { isOpen, openUpsell, closeUpsell, handleConnect } = useCloudUpsell({
@ -248,7 +250,12 @@ const TaskHeader = ({
{formatLargeNumber(contextTokens || 0)} / {formatLargeNumber(contextWindow)}
</span>
</StandardTooltip>
{!!totalCost && <span>${totalCost.toFixed(2)}</span>}
{!!totalCost && (
<span>
{currencySymbol}
{totalCost.toFixed(2)}
</span>
)}
</div>
{showBrowserGlobe && (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
@ -386,7 +393,10 @@ const TaskHeader = ({
{t("chat:task.apiCost")}
</th>
<td className="font-light align-top">
<span>${totalCost?.toFixed(2)}</span>
<span>
{currencySymbol}
{totalCost?.toFixed(2)}
</span>
</td>
</tr>
)}

View file

@ -5,6 +5,8 @@ import { FoldVertical } from "lucide-react"
import type { ContextCondense } from "@roo-code/types"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { getCurrencySymbol } from "@src/utils/getCurrencySymbol"
import { Markdown } from "../Markdown"
interface CondensationResultRowProps {
@ -17,6 +19,8 @@ interface CondensationResultRowProps {
*/
export function CondensationResultRow({ data }: CondensationResultRowProps) {
const { t } = useTranslation()
const { apiConfiguration } = useExtensionState()
const currencySymbol = getCurrencySymbol(apiConfiguration)
const [isExpanded, setIsExpanded] = useState(false)
const { cost, prevContextTokens, newContextTokens, summary } = data
@ -41,7 +45,8 @@ export function CondensationResultRow({ data }: CondensationResultRowProps) {
{t("chat:contextManagement.tokens")}
</span>
<VSCodeBadge className={displayCost > 0 ? "opacity-100" : "opacity-0"}>
${displayCost.toFixed(2)}
{currencySymbol}
{displayCost.toFixed(2)}
</VSCodeBadge>
</div>
<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>

View file

@ -1,6 +1,8 @@
import React from "react"
import type { HistoryItem } from "@roo-code/types"
import { formatTimeAgo } from "@/utils/format"
import { getCurrencySymbol } from "@/utils/getCurrencySymbol"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { CopyButton } from "./CopyButton"
import { ExportButton } from "./ExportButton"
import { DeleteButton } from "./DeleteButton"
@ -14,6 +16,9 @@ export interface TaskItemFooterProps {
}
const TaskItemFooter: React.FC<TaskItemFooterProps> = ({ item, variant, isSelectionMode = false, onDelete }) => {
const { apiConfiguration } = useExtensionState()
const currencySymbol = getCurrencySymbol(apiConfiguration)
return (
<div className="text-xs text-vscode-descriptionForeground flex justify-between items-center">
<div className="flex gap-1 items-center text-vscode-descriptionForeground/60">
@ -25,7 +30,7 @@ const TaskItemFooter: React.FC<TaskItemFooterProps> = ({ item, variant, isSelect
{/* Cost */}
{!!item.totalCost && (
<span className="flex items-center" data-testid="cost-footer-compact">
{"$" + item.totalCost.toFixed(2)}
{currencySymbol + item.totalCost.toFixed(2)}
</span>
)}
</div>

View file

@ -77,7 +77,7 @@ export const AutoApproveSettings = ({
const { t } = useAppTranslation()
const [commandInput, setCommandInput] = useState("")
const [deniedCommandInput, setDeniedCommandInput] = useState("")
const { autoApprovalEnabled, setAutoApprovalEnabled } = useExtensionState()
const { autoApprovalEnabled, setAutoApprovalEnabled, apiConfiguration } = useExtensionState()
const toggles = useAutoApprovalToggles()
@ -168,6 +168,7 @@ export const AutoApproveSettings = ({
allowedMaxCost={allowedMaxCost}
onMaxRequestsChange={(value) => setCachedStateField("allowedMaxRequests", value)}
onMaxCostChange={(value) => setCachedStateField("allowedMaxCost", value)}
apiConfiguration={apiConfiguration}
/>
</div>

View file

@ -1,14 +1,19 @@
import { useTranslation } from "react-i18next"
import type { ProviderSettings } from "@roo-code/types"
import { getCurrencySymbol } from "@src/utils/getCurrencySymbol"
import { FormattedTextField, unlimitedDecimalFormatter } from "../common/FormattedTextField"
interface MaxCostInputProps {
allowedMaxCost?: number
onValueChange: (value: number | undefined) => void
apiConfiguration?: ProviderSettings
}
export function MaxCostInput({ allowedMaxCost, onValueChange }: MaxCostInputProps) {
export function MaxCostInput({ allowedMaxCost, onValueChange, apiConfiguration }: MaxCostInputProps) {
const { t } = useTranslation()
const currencySymbol = getCurrencySymbol(apiConfiguration)
return (
<>
@ -23,7 +28,7 @@ export function MaxCostInput({ allowedMaxCost, onValueChange }: MaxCostInputProp
placeholder={t("settings:autoApprove.apiCostLimit.unlimited")}
style={{ maxWidth: "200px" }}
data-testid="max-cost-input"
leftNodes={[<span key="dollar">$</span>]}
leftNodes={[<span key="dollar">{currencySymbol}</span>]}
/>
</>
)

View file

@ -1,5 +1,8 @@
import React from "react"
import { useTranslation } from "react-i18next"
import type { ProviderSettings } from "@roo-code/types"
import { MaxRequestsInput } from "./MaxRequestsInput"
import { MaxCostInput } from "./MaxCostInput"
@ -8,6 +11,7 @@ export interface MaxLimitInputsProps {
allowedMaxCost?: number
onMaxRequestsChange: (value: number | undefined) => void
onMaxCostChange: (value: number | undefined) => void
apiConfiguration?: ProviderSettings
}
export const MaxLimitInputs: React.FC<MaxLimitInputsProps> = ({
@ -15,6 +19,7 @@ export const MaxLimitInputs: React.FC<MaxLimitInputsProps> = ({
allowedMaxCost,
onMaxRequestsChange,
onMaxCostChange,
apiConfiguration,
}) => {
const { t } = useTranslation()
@ -22,7 +27,11 @@ export const MaxLimitInputs: React.FC<MaxLimitInputsProps> = ({
<div className="space-y-2">
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-2 items-center">
<MaxRequestsInput allowedMaxRequests={allowedMaxRequests} onValueChange={onMaxRequestsChange} />
<MaxCostInput allowedMaxCost={allowedMaxCost} onValueChange={onMaxCostChange} />
<MaxCostInput
allowedMaxCost={allowedMaxCost}
onValueChange={onMaxCostChange}
apiConfiguration={apiConfiguration}
/>
</div>
<div className="text-xs text-vscode-descriptionForeground">
{t("settings:autoApprove.maxLimits.description")}

View file

@ -1,5 +1,7 @@
import { render, screen, fireEvent } from "@testing-library/react"
import type { ProviderSettings } from "@roo-code/types"
import { MaxCostInput } from "../MaxCostInput"
vi.mock("@/utils/vscode", () => ({
@ -81,4 +83,39 @@ describe("MaxCostInput", () => {
expect(mockOnValueChange).toHaveBeenCalledWith(0.15)
})
it("shows default $ currency symbol when no apiConfiguration provided", () => {
render(<MaxCostInput allowedMaxCost={10} onValueChange={mockOnValueChange} />)
expect(screen.getByText("$")).toBeInTheDocument()
})
it("shows custom currency symbol for LiteLLM provider", () => {
const litellmConfig: ProviderSettings = {
apiProvider: "litellm",
litellmCurrencySymbol: "€",
}
render(<MaxCostInput allowedMaxCost={10} onValueChange={mockOnValueChange} apiConfiguration={litellmConfig} />)
expect(screen.getByText("€")).toBeInTheDocument()
})
it("shows default $ when LiteLLM has empty currency symbol", () => {
const litellmConfig: ProviderSettings = {
apiProvider: "litellm",
litellmCurrencySymbol: "",
}
render(<MaxCostInput allowedMaxCost={10} onValueChange={mockOnValueChange} apiConfiguration={litellmConfig} />)
expect(screen.getByText("$")).toBeInTheDocument()
})
it("shows default $ for non-LiteLLM providers", () => {
const openaiConfig: ProviderSettings = {
apiProvider: "openai",
}
render(<MaxCostInput allowedMaxCost={10} onValueChange={mockOnValueChange} apiConfiguration={openaiConfig} />)
expect(screen.getByText("$")).toBeInTheDocument()
})
})

View file

@ -178,6 +178,21 @@ export const LiteLLM = ({
}
return null
})()}
{/* Currency symbol configuration */}
<div className="mt-4">
<VSCodeTextField
value={apiConfiguration?.litellmCurrencySymbol || ""}
onInput={handleInputChange("litellmCurrencySymbol")}
placeholder={t("settings:providers.litellmCurrencySymbolPlaceholder")}
className="w-full"
style={{ maxWidth: "200px" }}>
<label className="block font-medium mb-1">{t("settings:providers.litellmCurrencySymbol")}</label>
</VSCodeTextField>
<div className="text-sm text-vscode-descriptionForeground mt-1">
{t("settings:providers.litellmCurrencySymbolDescription")}
</div>
</div>
</>
)
}

View file

@ -370,6 +370,9 @@
"getXaiApiKey": "Get xAI API Key",
"litellmApiKey": "LiteLLM API Key",
"litellmBaseUrl": "LiteLLM Base URL",
"litellmCurrencySymbol": "Currency Symbol",
"litellmCurrencySymbolDescription": "Set a custom currency symbol for cost display (e.g., €, £, ¥). Useful when LiteLLM costs are configured in a currency other than USD.",
"litellmCurrencySymbolPlaceholder": "Default: $",
"awsCredentials": "AWS Credentials",
"awsProfile": "AWS Profile",
"awsApiKey": "Amazon Bedrock API Key",

View file

@ -0,0 +1,63 @@
import type { ProviderSettings } from "@roo-code/types"
import { getCurrencySymbol, DEFAULT_CURRENCY_SYMBOL } from "../getCurrencySymbol"
describe("getCurrencySymbol", () => {
it("returns default currency symbol when apiConfiguration is undefined", () => {
expect(getCurrencySymbol(undefined)).toBe(DEFAULT_CURRENCY_SYMBOL)
})
it("returns default currency symbol when apiConfiguration is empty", () => {
expect(getCurrencySymbol({})).toBe(DEFAULT_CURRENCY_SYMBOL)
})
it("returns default currency symbol when provider is not litellm", () => {
const config: ProviderSettings = {
apiProvider: "anthropic",
}
expect(getCurrencySymbol(config)).toBe(DEFAULT_CURRENCY_SYMBOL)
})
it("returns default currency symbol when provider is litellm but no custom symbol is set", () => {
const config: ProviderSettings = {
apiProvider: "litellm",
}
expect(getCurrencySymbol(config)).toBe(DEFAULT_CURRENCY_SYMBOL)
})
it("returns default currency symbol when provider is litellm and symbol is empty string", () => {
const config: ProviderSettings = {
apiProvider: "litellm",
litellmCurrencySymbol: "",
}
expect(getCurrencySymbol(config)).toBe(DEFAULT_CURRENCY_SYMBOL)
})
it("returns custom currency symbol when provider is litellm and custom symbol is set", () => {
const config: ProviderSettings = {
apiProvider: "litellm",
litellmCurrencySymbol: "€",
}
expect(getCurrencySymbol(config)).toBe("€")
})
it("returns custom currency symbol for various currency symbols", () => {
const symbols = ["£", "¥", "₹", "₽", "CHF", "R$"]
symbols.forEach((symbol) => {
const config: ProviderSettings = {
apiProvider: "litellm",
litellmCurrencySymbol: symbol,
}
expect(getCurrencySymbol(config)).toBe(symbol)
})
})
it("returns default currency symbol when litellm is provider but other settings exist without currency", () => {
const config: ProviderSettings = {
apiProvider: "litellm",
litellmBaseUrl: "http://localhost:8000",
litellmApiKey: "test-key",
litellmModelId: "test-model",
}
expect(getCurrencySymbol(config)).toBe(DEFAULT_CURRENCY_SYMBOL)
})
})

View file

@ -0,0 +1,25 @@
import type { ProviderSettings } from "@roo-code/types"
/**
* Default currency symbol used across the application.
*/
export const DEFAULT_CURRENCY_SYMBOL = "$"
/**
* Gets the appropriate currency symbol based on the provider settings.
* When LiteLLM is the active provider and a custom currency symbol is configured,
* returns that custom symbol. Otherwise, returns the default currency symbol ($).
*
* @param apiConfiguration - The current provider settings
* @returns The currency symbol to use for cost display
*/
export function getCurrencySymbol(apiConfiguration?: ProviderSettings): string {
if (
apiConfiguration?.apiProvider === "litellm" &&
apiConfiguration.litellmCurrencySymbol !== undefined &&
apiConfiguration.litellmCurrencySymbol !== ""
) {
return apiConfiguration.litellmCurrencySymbol
}
return DEFAULT_CURRENCY_SYMBOL
}