mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
19b7dac719
commit
b84ac82986
14 changed files with 197 additions and 10 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) ||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>]}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
63
webview-ui/src/utils/__tests__/getCurrencySymbol.spec.ts
Normal file
63
webview-ui/src/utils/__tests__/getCurrencySymbol.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
25
webview-ui/src/utils/getCurrencySymbol.ts
Normal file
25
webview-ui/src/utils/getCurrencySymbol.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue