Files
CC-Switch/src/types/usage.ts
T
@nothingness0db nothingness0db 0527002cca feat(usage): add OpenCode session usage sync (#3215)
* feat(usage): add OpenCode session usage sync

Add OpenCode as a fourth app type in the usage statistics system.
Reads per-message token data from opencode's local SQLite database
(~/.local/share/opencode/opencode.db) and imports into proxy_request_logs.

- New session_usage_opencode.rs module following Codex/Gemini pattern
- Parses assistant message.data JSON for tokens, cost, model
- Adds "opencode" to AppType union and filter tabs
- Updates dedup filters to include opencode_session data_source
- Adds i18n keys for all 4 locales

* fix(usage): add opencode to UsageHero title themes

* fix: respect XDG_DATA_HOME and platform defaults for OpenCode DB path

- Support OPENCODE_DB env var override (absolute and relative paths)
- Use ~/Library/Application Support/opencode/ on macOS
- Use XDG_DATA_HOME/opencode/ when set
- Fall back to ~/.local/share/opencode/ on Linux
- Rename misleading test to test_parse_message_data_ignores_role

* fix(usage): use ~/.local/share/opencode on all platforms

OpenCode relies on xdg-basedir, which ignores macOS/Windows conventions,
so its DB always lives at ~/.local/share/opencode. The previous macOS
default pointed at ~/Library/Application Support/opencode, which does not
exist, making the sync a silent no-op for macOS users without
XDG_DATA_HOME set.

* fix(usage): include opencode -wal mtime in freshness check

OpenCode runs its SQLite DB in WAL mode; new commits land in the -wal
file and the main DB file's mtime only advances on checkpoint. Keying
the freshness gate solely on opencode.db could skip newly written
sessions until a checkpoint occurred. Take the max of the db and -wal
mtimes instead.

* style(usage): apply cargo fmt to opencode session sync

Fixes Backend Checks cargo fmt --check failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(usage): ignore empty OPENCODE_DB and XDG_DATA_HOME env vars

An empty OPENCODE_DB collapsed the path to the data dir (dropping the opencode.db filename); an empty XDG_DATA_HOME produced a relative "opencode" path. Treat empty strings as unset, per the XDG spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(usage): harden OpenCode session sync error handling and labeling

- Map '_opencode_session' provider_id to 'OpenCode (Session)' display name
- Return accurate inserted flag from insert_opencode_message (was always true)
- Do not advance file/session sync_state when a session errors, so failed
  inserts are retried next run instead of being permanently skipped
- Surface per-message insert failures into the sync result errors
- Add opencode_session data-source icon and i18n labels (zh/zh-TW/en/ja)
- Add provider-stats labeling test for opencode session rows

* fix(usage): only import finalized OpenCode messages

An in-progress assistant message holds partial tokens; OpenCode updates the same message_id with final values later. Since request_id is fixed and the insert uses INSERT OR IGNORE, a partial row could never be corrected. Skip messages without time.completed so each turn is imported once with final usage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(usage): keep OpenCode incomplete sessions retryable

---------

Co-authored-by: Eira Hazel <kip3vx9ma@mozmail.com>
Co-authored-by: Eria hazel <git config --global user.email your@email.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-06-04 22:26:24 +08:00

248 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 使用统计相关类型定义
export interface TokenUsage {
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
}
export interface RequestLog {
requestId: string;
providerId: string;
providerName?: string;
appType: string;
model: string;
requestModel?: string;
costMultiplier: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
inputCostUsd: string;
outputCostUsd: string;
cacheReadCostUsd: string;
cacheCreationCostUsd: string;
totalCostUsd: string;
isStreaming: boolean;
latencyMs: number;
firstTokenMs?: number;
durationMs?: number;
statusCode: number;
errorMessage?: string;
createdAt: number;
dataSource?: string;
}
export interface SessionSyncResult {
imported: number;
skipped: number;
filesScanned: number;
errors: string[];
}
export interface DataSourceSummary {
dataSource: string;
requestCount: number;
totalCostUsd: string;
}
export interface PaginatedLogs {
data: RequestLog[];
total: number;
page: number;
pageSize: number;
}
export interface ModelPricing {
modelId: string;
displayName: string;
inputCostPerMillion: string;
outputCostPerMillion: string;
cacheReadCostPerMillion: string;
cacheCreationCostPerMillion: string;
}
export interface UsageSummary {
totalRequests: number;
totalCost: string;
totalInputTokens: number;
totalOutputTokens: number;
totalCacheCreationTokens: number;
totalCacheReadTokens: number;
successRate: number;
/** input + output + cache_creation + cache_read, all cache-normalized */
realTotalTokens: number;
/** cache_read / (input + cache_creation + cache_read), range 01 */
cacheHitRate: number;
}
export interface UsageSummaryByApp {
appType: string;
summary: UsageSummary;
}
export interface DailyStats {
date: string;
requestCount: number;
totalCost: string;
totalTokens: number;
totalInputTokens: number;
totalOutputTokens: number;
totalCacheCreationTokens: number;
totalCacheReadTokens: number;
}
export interface ProviderStats {
providerId: string;
providerName: string;
requestCount: number;
totalTokens: number;
totalCost: string;
successRate: number;
avgLatencyMs: number;
}
export interface ModelStats {
model: string;
requestCount: number;
totalTokens: number;
totalCost: string;
avgCostPerRequest: string;
}
export interface LogFilters {
appType?: string;
providerName?: string;
model?: string;
statusCode?: number;
startDate?: number;
endDate?: number;
}
export interface ProviderLimitStatus {
providerId: string;
dailyUsage: string;
dailyLimit?: string;
dailyExceeded: boolean;
monthlyUsage: string;
monthlyLimit?: string;
monthlyExceeded: boolean;
}
export type UsageRangePreset = "today" | "1d" | "7d" | "14d" | "30d" | "custom";
export interface UsageRangeSelection {
preset: UsageRangePreset;
customStartDate?: number;
customEndDate?: number;
}
/**
* App types whose token usage is reliably collected by the proxy.
*
* The proxy has handlers for `claude-desktop` too, but in practice those
* requests overwhelmingly fail (500/503) and contribute near-zero tokens, so
* it is hidden from the Dashboard. `opencode` / `openclaw` / `hermes` have
* no proxy handler at all — they appear only as managed apps elsewhere.
*/
export type AppType = "claude" | "codex" | "gemini" | "opencode";
export type AppTypeFilter = "all" | AppType;
export const KNOWN_APP_TYPES: ReadonlyArray<AppType> = [
"claude",
"codex",
"gemini",
"opencode",
];
/**
* App types whose proxy uses an OpenAI-style protocol. Two consequences:
*
* 1. `inputTokens` already includes the cached portion (must subtract
* `cacheReadTokens` to get fresh-input semantics — see
* [getFreshInputTokens]).
* 2. The protocol does not report cache _creation_ separately, only cache
* _reads_. So `cacheCreationTokens` is always 0 for these app types and
* the UI should label it as N/A rather than 0.
*
* Mirror of the Rust `CACHE_INCLUSIVE_APP_TYPES` whitelist.
*/
export const CACHE_INCLUSIVE_APP_TYPES: ReadonlySet<string> = new Set([
"codex",
"gemini",
]);
/** Subset of request-log fields needed to derive cache-normalized input. */
export interface CacheNormalizableLog {
appType: string;
inputTokens: number;
cacheReadTokens: number;
}
/**
* For a single request log, return the input token count with cache reads
* removed. Anthropic-style providers already report `inputTokens` without
* cache, so they pass through unchanged.
*/
export function getFreshInputTokens(log: CacheNormalizableLog): number {
if (
CACHE_INCLUSIVE_APP_TYPES.has(log.appType) &&
log.inputTokens >= log.cacheReadTokens
) {
return log.inputTokens - log.cacheReadTokens;
}
return log.inputTokens;
}
export const NON_NEGATIVE_DECIMAL_REGEX = /^\d+(?:\.\d+)?$/;
export function isNonNegativeDecimalString(value: string): boolean {
const trimmed = value.trim();
if (!NON_NEGATIVE_DECIMAL_REGEX.test(trimmed)) return false;
return Number.isFinite(Number(trimmed));
}
type UsageCostLog = Pick<
RequestLog,
| "inputTokens"
| "outputTokens"
| "cacheReadTokens"
| "cacheCreationTokens"
| "totalCostUsd"
| "statusCode"
> &
Partial<Pick<RequestLog, "costMultiplier">>;
export function hasUsageTokens(log: UsageCostLog): boolean {
return (
log.inputTokens > 0 ||
log.outputTokens > 0 ||
log.cacheReadTokens > 0 ||
log.cacheCreationTokens > 0
);
}
export function isUnpricedUsage(log: UsageCostLog): boolean {
const totalCost = Number.parseFloat(log.totalCostUsd);
const multiplier =
log.costMultiplier == null
? undefined
: Number.parseFloat(log.costMultiplier);
return (
log.statusCode >= 200 &&
log.statusCode < 300 &&
hasUsageTokens(log) &&
Number.isFinite(totalCost) &&
(!Number.isFinite(multiplier) || multiplier !== 0) &&
totalCost === 0
);
}
export interface StatsFilters {
timeRange: UsageRangePreset;
providerId?: string;
appType?: string;
}