mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
0527002cca
* 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>
248 lines
6.0 KiB
TypeScript
248 lines
6.0 KiB
TypeScript
// 使用统计相关类型定义
|
||
|
||
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 0–1 */
|
||
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;
|
||
}
|