Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions desktop/renderer/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,17 @@ interface OpenClawAPI {
};
usage: {
getStats(): Promise<any>;
getDetailedStats(params: {
startDate?: string;
endDate?: string;
filter?: string;
}): Promise<any>;
getExchangeRate(): Promise<{
rate: number;
currency: string;
isFallback: boolean;
fetchedAt: number;
}>;
};
}

Expand Down
1 change: 1 addition & 0 deletions desktop/renderer/src/i18n/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ export default {
"settings.usageLoading": "Loading usage data…",
"settings.retry": "Retry",
"settings.totalSpend": "Total Spend",
"settings.currencySymbol": "¥",
"settings.budget": "Budget",
"settings.tokenUsage30d": "Token Usage (Last 30 Days)",
"settings.sessionCount": "Sessions",
Expand Down
1 change: 1 addition & 0 deletions desktop/renderer/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ export default {
"settings.usageLoading": "正在加载用量数据…",
"settings.retry": "重试",
"settings.totalSpend": "总花费",
"settings.currencySymbol": "¥",
"settings.budget": "预算",
"settings.tokenUsage30d": "Token 用量(近 30 天)",
"settings.sessionCount": "会话数",
Expand Down
26 changes: 21 additions & 5 deletions desktop/renderer/src/views/SettingsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,16 @@
<div class="card-group">
<div class="card-row" :class="{ 'no-border': !usageData.maxBudget }">
<span class="row-label">{{ t("settings.totalSpend") }}</span>
<span class="row-value usage-spend">${{ usageData.totalSpend.toFixed(4) }}</span>
<span class="row-value usage-spend"
>{{ t("settings.currencySymbol") }}{{ toCny(usageData.totalSpend).toFixed(4) }}</span
>
</div>
<div v-if="usageData.maxBudget" class="card-row no-border">
<span class="row-label">{{ t("settings.budget") }}</span>
<div class="budget-bar-wrapper">
<span class="row-value"
>${{ usageData.totalSpend.toFixed(2) }} / ${{
usageData.maxBudget.toFixed(2)
}}</span
>{{ t("settings.currencySymbol") }}{{ toCny(usageData.totalSpend).toFixed(2) }} /
{{ t("settings.currencySymbol") }}{{ toCny(usageData.maxBudget).toFixed(2) }}</span
>
<div class="budget-bar">
<div
Expand Down Expand Up @@ -163,7 +164,9 @@
{{ m.completionTokens.toLocaleString() }} {{ t("settings.outputSuffix") }}</span
>
</div>
<span class="row-value usage-spend">${{ m.spend.toFixed(4) }}</span>
<span class="row-value usage-spend"
>{{ t("settings.currencySymbol") }}{{ toCny(m.spend).toFixed(4) }}</span
>
</div>
</div>
</template>
Expand Down Expand Up @@ -1482,11 +1485,24 @@ interface UsageStats {
cacheWriteTokens?: number;
sessionCount?: number;
toolCalls?: number;
/** CNY per 1 USD, provided by the main process to convert relayed USD spend. */
exchangeRate?: number;
/** Display currency code for spend values (e.g. "CNY"). */
currency?: string;
}
const usageData = ref<UsageStats | null>(null);
const usageLoading = ref(false);
const usageError = ref("");

// The gateway relays spend in USD; convert to CNY for display using the live rate
// from the main process (defaults to the fallback rate until stats load).
// Fallback mirrors USD_TO_CNY_FALLBACK_RATE in the main process (approx. 2026 rate).
const USD_TO_CNY_FALLBACK_RATE = 7.2;
function toCny(usd: number | null | undefined): number {
const rate = usageData.value?.exchangeRate ?? USD_TO_CNY_FALLBACK_RATE;
return (usd ?? 0) * rate;
}

const usageModelList = computed(() => {
if (!usageData.value) return [];
// Use detailed breakdown if available, otherwise fall back to modelSpend
Expand Down
16 changes: 16 additions & 0 deletions desktop/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@ export const WEIXIN_LOGIN_TIMEOUT_MS = 180_000;
/** Number of days of usage data to query from the gateway. */
export const USAGE_QUERY_DAYS = 30;

/**
* Free, no-API-key FX endpoint (ECB data via Frankfurter) used to convert the
* USD spend figures relayed from the OpenClaw gateway into CNY for display.
*/
export const EXCHANGE_RATE_API_URL = "https://api.frankfurter.app/latest?from=USD&to=CNY";

/** How long a fetched USD→CNY rate stays cached in memory before we refetch. */
export const EXCHANGE_RATE_CACHE_TTL_MS = 12 * 60 * 60 * 1_000;

/**
* Fallback USD→CNY rate used when the FX endpoint is unreachable (offline / API
* down) so the usage UI never breaks. Approximate mid-market rate as of 2026 —
* this is only a safety net; the live rate is preferred whenever available.
*/
export const USD_TO_CNY_FALLBACK_RATE = 7.2;

/** Public manifest used by the P0 manual update checker. */
export const UPDATE_MANIFEST_URL = "https://microclaw.microsoftol.com/releases/latest.json";

Expand Down
78 changes: 78 additions & 0 deletions desktop/src/exchange-rate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getUsdToCnyRate, resetExchangeRateCache } from "./exchange-rate";
import { USD_TO_CNY_FALLBACK_RATE } from "./constants";

afterEach(() => {
resetExchangeRateCache();
});

describe("getUsdToCnyRate", () => {
it("returns the live CNY rate from a valid response", async () => {
const result = await getUsdToCnyRate(async () => ({
amount: 1,
base: "USD",
date: "2026-07-29",
rates: { CNY: 7.15 },
}));

expect(result.rate).toBe(7.15);
expect(result.currency).toBe("CNY");
expect(result.isFallback).toBe(false);
});

it("caches the rate within the TTL and does not refetch", async () => {
const fetchJson = vi.fn(async () => ({ rates: { CNY: 7.11 } }));

const first = await getUsdToCnyRate(fetchJson, 1_000);
const second = await getUsdToCnyRate(fetchJson, 2_000);

expect(fetchJson).toHaveBeenCalledTimes(1);
expect(first.rate).toBe(7.11);
expect(second.rate).toBe(7.11);
expect(second.fetchedAt).toBe(1_000);
});

it("refetches once the cache TTL has elapsed", async () => {
const fetchJson = vi
.fn()
.mockResolvedValueOnce({ rates: { CNY: 7.0 } })
.mockResolvedValueOnce({ rates: { CNY: 7.5 } });

const first = await getUsdToCnyRate(fetchJson, 0);
// 12h + 1ms later — past the TTL.
const second = await getUsdToCnyRate(fetchJson, 12 * 60 * 60 * 1_000 + 1);

expect(fetchJson).toHaveBeenCalledTimes(2);
expect(first.rate).toBe(7.0);
expect(second.rate).toBe(7.5);
});

it("falls back to the hardcoded rate when the fetch fails", async () => {
const result = await getUsdToCnyRate(async () => {
throw new Error("offline");
});

expect(result.rate).toBe(USD_TO_CNY_FALLBACK_RATE);
expect(result.isFallback).toBe(true);
});

it("falls back when the response is missing a valid CNY rate", async () => {
const result = await getUsdToCnyRate(async () => ({ rates: {} }));

expect(result.rate).toBe(USD_TO_CNY_FALLBACK_RATE);
expect(result.isFallback).toBe(true);
});

it("reuses the last good rate when a later fetch fails", async () => {
const fetchJson = vi
.fn()
.mockResolvedValueOnce({ rates: { CNY: 7.2 } })
.mockRejectedValueOnce(new Error("offline"));

await getUsdToCnyRate(fetchJson, 0);
const stale = await getUsdToCnyRate(fetchJson, 12 * 60 * 60 * 1_000 + 1);

expect(stale.rate).toBe(7.2);
expect(stale.isFallback).toBe(false);
});
});
105 changes: 105 additions & 0 deletions desktop/src/exchange-rate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {
EXCHANGE_RATE_API_URL,
EXCHANGE_RATE_CACHE_TTL_MS,
USD_TO_CNY_FALLBACK_RATE,
} from "./constants";

/**
* USD→CNY conversion used to display the spend figures relayed from the OpenClaw
* gateway. The gateway reports cost in USD (OpenClaw's `estimateUsageCost()` is
* priced from a USD model catalog); MicroClaw converts to CNY purely for display.
*/
export interface ExchangeRate {
/** Amount of CNY per 1 USD. */
rate: number;
/** Target currency code — always "CNY" here. */
currency: "CNY";
/** True when the hardcoded fallback rate was used instead of a live fetch. */
isFallback: boolean;
/** Epoch millis when this rate was obtained. */
fetchedAt: number;
}

/** Injectable JSON fetcher so tests can run without real network access. */
export type FetchJson = (url: string) => Promise<unknown>;

interface CacheEntry {
rate: number;
fetchedAt: number;
}

let cache: CacheEntry | null = null;

async function fetchRateJson(url: string): Promise<unknown> {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Exchange rate request failed with HTTP ${response.status}`);
}
return response.json();
}

/**
* Parses the Frankfurter response shape `{ rates: { CNY: number } }` and returns
* the CNY rate, or throws if the payload is missing/invalid.
*/
function parseCnyRate(value: unknown): number {
if (!value || typeof value !== "object") {
throw new Error("Exchange rate response must be a JSON object");
}
const rates = (value as Record<string, unknown>).rates;
if (!rates || typeof rates !== "object") {
throw new Error("Exchange rate response is missing rates");
}
const cny = (rates as Record<string, unknown>).CNY;
if (typeof cny !== "number" || !Number.isFinite(cny) || cny <= 0) {
throw new Error("Exchange rate response is missing a valid CNY rate");
}
return cny;
}

/**
* Returns the current USD→CNY rate, caching it in memory for
* {@link EXCHANGE_RATE_CACHE_TTL_MS}. Falls back to {@link USD_TO_CNY_FALLBACK_RATE}
* when the endpoint is unreachable so the UI never breaks.
*/
export async function getUsdToCnyRate(
fetchJson: FetchJson = fetchRateJson,
now: number = Date.now(),
): Promise<ExchangeRate> {
if (cache && now - cache.fetchedAt < EXCHANGE_RATE_CACHE_TTL_MS) {
return {
rate: cache.rate,
currency: "CNY",
isFallback: false,
fetchedAt: cache.fetchedAt,
};
}

try {
const rate = parseCnyRate(await fetchJson(EXCHANGE_RATE_API_URL));
cache = { rate, fetchedAt: now };
return { rate, currency: "CNY", isFallback: false, fetchedAt: now };
} catch {
// Network/API failure: use the last good cached rate if we have one,
// otherwise the hardcoded fallback so the usage UI keeps working offline.
if (cache) {
return {
rate: cache.rate,
currency: "CNY",
isFallback: false,
fetchedAt: cache.fetchedAt,
};
}
return {
rate: USD_TO_CNY_FALLBACK_RATE,
currency: "CNY",
isFallback: true,
fetchedAt: now,
};
}
}

/** Clears the in-memory rate cache. Intended for tests. */
export function resetExchangeRateCache(): void {
cache = null;
}
14 changes: 13 additions & 1 deletion desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { shieldIfNeeded, unshieldIfNeeded } from "./sensitive-shield";
import { StudioBackendManager } from "./studio-backend-manager";
import { resolveSupportedLocale, t as mainT } from "./i18n";
import { checkForUpdates } from "./update-checker";
import { getUsdToCnyRate } from "./exchange-rate";
import {
recoverInterruptedOpenClawUpgrade,
UpgradeInProgressError,
Expand Down Expand Up @@ -3434,7 +3435,6 @@ function registerIpcHandlers(): void {
// --- Usage (via gateway WebSocket sessions.usage) ---
ipcMain.handle("usage:get-stats", async () => {
if (!gwClient?.connected) throw new Error("Gateway 未连接");

const endDate = new Date().toISOString().split("T")[0];
const startDate = new Date(Date.now() - USAGE_QUERY_DAYS * 86_400_000)
.toISOString()
Expand Down Expand Up @@ -3483,6 +3483,10 @@ function registerIpcHandlers(): void {
if (d.date) dailySpend[d.date] = d.cost || 0;
}

// The gateway reports cost in USD; fetch the live USD→CNY rate so the
// renderer can convert + relabel spend to ¥ (falls back gracefully offline).
const fx = await getUsdToCnyRate();

return {
totalSpend: totals.totalCost || 0,
maxBudget: null,
Expand All @@ -3502,9 +3506,17 @@ function registerIpcHandlers(): void {
cacheWriteTokens: totals.cacheWrite || 0,
sessionCount: result?.sessions?.length || 0,
toolCalls: aggregates.tools?.totalCalls || 0,
// USD→CNY conversion metadata (spend values above remain raw USD)
exchangeRate: fx.rate,
currency: fx.currency,
};
});

// Live USD→CNY exchange rate used to render spend figures in CNY.
ipcMain.handle("usage:get-exchange-rate", async () => {
return getUsdToCnyRate();
});

// Detailed usage stats for the full Usage dashboard page
ipcMain.handle(
"usage:get-detailed-stats",
Expand Down
1 change: 1 addition & 0 deletions desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ contextBridge.exposeInMainWorld("openclaw", {
getStats: () => ipcRenderer.invoke("usage:get-stats"),
getDetailedStats: (params: { startDate?: string; endDate?: string; filter?: string }) =>
ipcRenderer.invoke("usage:get-detailed-stats", params),
getExchangeRate: () => ipcRenderer.invoke("usage:get-exchange-rate"),
},

// --- Studio Backend ---
Expand Down
Loading