From 8c40d1bc2795d720806fb1f36689b8d430b74c0a Mon Sep 17 00:00:00 2001 From: Ramapitecus Date: Sat, 12 Sep 2026 18:21:22 +0500 Subject: [PATCH 01/10] fix: make usage valuation replay-safe and provider-aware --- docs/usage-accounting.md | 49 +++ src-tauri/resources/model_prices.json | 30 +- src-tauri/src/agents/backups/tests.rs | 6 + src-tauri/src/provider_health.rs | 277 ++++++++----- src-tauri/src/usage.rs | 542 +++++++++++++++---------- src-tauri/src/usage/accounting.rs | 461 +++++++++++++++++++++ src-tauri/src/usage/integrity_tests.rs | 405 ++++++++++++++++++ src-tauri/src/usage/multimodal.rs | 111 +++++ src/i18n/ja.ts | 13 + src/i18n/locales/en.ts | 21 +- src/i18n/locales/zh-CN.ts | 13 + src/pages/UsageRecordsPage.tsx | 80 +++- src/services/providerHealthCheck.ts | 9 +- tests/providerHealthCheck.test.ts | 6 +- 14 files changed, 1690 insertions(+), 333 deletions(-) create mode 100644 docs/usage-accounting.md create mode 100644 src-tauri/src/usage/accounting.rs create mode 100644 src-tauri/src/usage/integrity_tests.rs create mode 100644 src-tauri/src/usage/multimodal.rs diff --git a/docs/usage-accounting.md b/docs/usage-accounting.md new file mode 100644 index 00000000..89e94b65 --- /dev/null +++ b/docs/usage-accounting.md @@ -0,0 +1,49 @@ +# Usage and cost accounting + +The dashboard reports usage events and the known USD amount associated with them. It is not a provider invoice or an HTTP access log. Retries, tools, prewarms, health probes, and unmeasured operations are separate event kinds. Endpoint/transport filters and distinct generation IDs make these differences visible; a WebSocket frame is not a request. + +## Measurement and valuation + +The collector preserves normalized token breakdown v2, quality, raw provider usage, cache lifetimes, event/attempt/generation IDs, transport, upstream URL, and reported cost. V2 is authoritative. Legacy Gemini thinking is included in billable output; OpenAI cached/reasoning tokens are subsets. Invalid or incomplete breakdowns are never repaired by inventing tokens. A missing usage object differs from an observed zero. + +Each newly ingested event receives a valuation snapshot when its amount is known. The snapshot records the rates and rule version used, so editing prices does not rewrite known historical amounts. Legacy records without a snapshot can still be estimated using available prices, and an unknown event can acquire a later manual tariff. This does not recreate historical rates or lost upstream usage. + +- `reported`: provider-supplied USD amount (currently xAI ticks, with the original decimal retained). +- `estimated`: a supported token/modality tariff with its source and captured rates. +- `unknown`: no sufficient usage, inconsistent/partial usage, missing price, or unsupported billing dimensions. Cost is null, not free. The coverage ratio counts only known amounts. + +Amounts are evaluated per event before summation, preserving model-specific context thresholds and additivity. Request tier is overridden by an explicit response tier. Unsupported tier/context combinations remain unknown. Claude cache creation distinguishes 5-minute and 1-hour rates; an old aggregate write count cannot reveal its lifetime. Gemini thinking and audio input, OpenAI image/text/audio/cache dimensions, and DeepSeek's documented UTC peak schedule have separate rules. Unknown cache modality splits are not guessed. + +xAI video polling retains every event but charges only the increase over a previously recorded cumulative operation cost. General token estimates do not replace a provider's actual cost. Built-in API prices are estimates for OAuth/subscription usage too; they do not establish the subscription's cash charge, taxes, negotiated discounts, reseller margins, or external tool bills. Manual rates can be scoped to provider and upstream base URL. + +Model matching uses exact IDs, canonical provider prefixes, and known reasoning suffixes. An unknown media variant cannot inherit its parent text model's price. Manual alias mappings remain explicit user choices. Price sync preserves the last good cache on failure and rejects catalogs older than the bundled rule baseline. + +## Sources and coverage + +| Source | What is accounted for | Deliberate limit | +| --- | --- | --- | +| Chat/Responses/Claude/Gemini/Interactions executors | Reported tokens, cache, thinking, tiers and available billing dimensions | Aborted streams without final usage cannot establish complete spend | +| Codex Responses HTTP and WebSocket image tools | Separate parent and image-tool events, modality-specific rates | Unpublished/missing modality data remains unknown | +| Live/realtime WebSocket | Upstream terminal response usage, including text/audio/cache | Opaque WebRTC media without usage is an unmeasured operation | +| xAI image/video | Exact reported ticks, cumulative video operation costs | No invented per-image or duration-based charge when the provider omits billing | +| Alpha Search and management model probes | Available core-reported usage | Missing usage or independently priced search tools may remain unknown | +| Desktop health probes | Complete stream consumption, bounded output request, final usage for all four protocols, partial failure records | Network/size/timeout termination can leave only partial measurements | +| Token-count/control calls | Separate unmeasured operations | Predicted tokens are not charged as consumed tokens | +| Hosted tools | Claude search and OpenAI file-search request fees when counts are present | Ambiguous OpenAI search variants, grounding, containers and unsupported tools are not silently priced as token-only work | +| Plugins/custom gateways | Published SDK usage and explicit manual tariffs | Arbitrary external traffic and unpublished plugin charges cannot be inferred | + +Billing rules were checked against official sources on 2026-09-12: [OpenAI](https://developers.openai.com/api/docs/pricing), [Anthropic](https://platform.claude.com/docs/en/about-claude/pricing), [Gemini](https://ai.google.dev/gemini-api/docs/pricing), [xAI pricing](https://docs.x.ai/developers/pricing), [xAI cost tracking](https://docs.x.ai/developers/cost-tracking), and [DeepSeek](https://api-docs.deepseek.com/quick_start/pricing/). The catalog is not a complete historical price database. DeepSeek's new schedule is not applied retrospectively before this rule baseline. + +## Collection and deployment + +With the companion CLIProxyAPI update, the collector reads `/v0/management/usage-journal`, commits a local SQLite inbox before acknowledging event IDs, and deduplicates replay using a unique nonempty `event_id`. A repeated `request_id` remains valid across retries/tools. The UI uses the SQLite row ID. Credentials are fingerprinted before inbox persistence, including old queue messages and direct probes. + +Only a 404 enables legacy queue fallback; a journal storage/network/ACK error remains visible and is retried. A lost ACK cannot double-count a committed event. The core journal is a single-consumer local spool whose unacknowledged files do not expire. Operators must monitor storage if the collector is stopped. Disk failure, upstream omission, and events lost before upgrading cannot be fixed by deduplication. + +**Release dependency:** `core-version.txt` stays at 7.2.158 until an upstream core release containing the companion fix exists. Before shipping the complete feature, publish that core release and update the pin using the project's normal release workflow. The desktop changes remain backward compatible, but legacy cores do not provide the new durability and source coverage. + +## Regression validation + +Native tests cover the original arithmetic reproductions, context boundaries, tier modifiers, Claude TTL, Gemini thinking/audio, image/realtime modalities, reported media cost, repeated polling, replay IDs, endpoint/transport filters, provider-specific manual rates, price snapshots, unknown usage/price dimensions, and credential redaction. Health parser tests cover final stream usage. New regressions were first observed failing, then fixed. + +The complete Rust test suite, Bun test suite and TypeScript/Vite build pass locally. On macOS, use a real directory for `TMPDIR`: existing backup tests intentionally reject symlink paths such as `/var`. Their symlink cleanup now uses the correct Unix operation while retaining Windows junction handling. diff --git a/src-tauri/resources/model_prices.json b/src-tauri/resources/model_prices.json index 096dc5b0..bed0338e 100644 --- a/src-tauri/resources/model_prices.json +++ b/src-tauri/resources/model_prices.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-09-07", + "updatedAt": "2026-09-12", "models": { "gpt-6-astra": { "inputPer1M": 10.0, "outputPer1M": 50.0, "cacheReadPer1M": 1.0, "cacheCreationPer1M": 12.5 }, "gpt-5.6": { "inputPer1M": 4.0, "outputPer1M": 20.0, "cacheReadPer1M": 0.4, "cacheCreationPer1M": 5.0 }, @@ -8,9 +8,9 @@ "gpt-5.6-terra": { "inputPer1M": 2.0, "outputPer1M": 12.0, "cacheReadPer1M": 0.2, "cacheCreationPer1M": 2.5 }, "gpt-5.6-luna": { "inputPer1M": 0.2, "outputPer1M": 1.2, "cacheReadPer1M": 0.02, "cacheCreationPer1M": 0.25 }, "gpt-5.5": { "inputPer1M": 5.0, "outputPer1M": 30.0, "cacheReadPer1M": 0.5 }, - "gpt-5.5-pro": { "inputPer1M": 30.0, "outputPer1M": 180.0, "cacheReadPer1M": 3.0 }, + "gpt-5.5-pro": { "inputPer1M": 30.0, "outputPer1M": 180.0 }, "gpt-5.4": { "inputPer1M": 2.5, "outputPer1M": 15.0, "cacheReadPer1M": 0.25 }, - "gpt-5.4-pro": { "inputPer1M": 30.0, "outputPer1M": 180.0, "cacheReadPer1M": 3.0 }, + "gpt-5.4-pro": { "inputPer1M": 30.0, "outputPer1M": 180.0 }, "gpt-5.4-mini": { "inputPer1M": 0.75, "outputPer1M": 4.5, "cacheReadPer1M": 0.075 }, "gpt-5.4-nano": { "inputPer1M": 0.2, "outputPer1M": 1.25, "cacheReadPer1M": 0.02 }, "gpt-5.3-chat-latest": { "inputPer1M": 1.75, "outputPer1M": 14.0, "cacheReadPer1M": 0.175 }, @@ -65,8 +65,8 @@ "gemini-2.5-flash": { "inputPer1M": 0.3, "outputPer1M": 2.5, "cacheReadPer1M": 0.03 }, "gemini-2.5-flash-lite": { "inputPer1M": 0.1, "outputPer1M": 0.4, "cacheReadPer1M": 0.01 }, "deepseek-v4-pro": { "inputPer1M": 1.32, "outputPer1M": 3.96, "cacheReadPer1M": 0.044, "cacheCreationPer1M": 0.0 }, - "deepseek-v4-flash": { "inputPer1M": 0.44, "outputPer1M": 1.32, "cacheReadPer1M": 0.014, "cacheCreationPer1M": 0.0 }, - "deepseek-v4-flash-vision-exp": { "inputPer1M": 0.44, "outputPer1M": 1.32, "cacheReadPer1M": 0.014, "cacheCreationPer1M": 0.0 }, + "deepseek-v4-flash": { "inputPer1M": 0.3, "outputPer1M": 1.2, "cacheReadPer1M": 0.006 }, + "deepseek-v4-flash-vision-exp": { "inputPer1M": 0.3, "outputPer1M": 1.2, "cacheReadPer1M": 0.006 }, "deepseek-chat": { "inputPer1M": 0.28, "outputPer1M": 0.42, "cacheReadPer1M": 0.028 }, "deepseek-reasoner": { "inputPer1M": 0.28, "outputPer1M": 0.42, "cacheReadPer1M": 0.028 }, "grok-4.6": { "inputPer1M": 2.0, "outputPer1M": 6.0, "cacheReadPer1M": 0.5 }, @@ -78,6 +78,24 @@ "grok-4": { "inputPer1M": 1.25, "outputPer1M": 2.5, "cacheReadPer1M": 0.2 }, "grok-4-1-fast": { "inputPer1M": 1.25, "outputPer1M": 2.5, "cacheReadPer1M": 0.2 }, "grok-3": { "inputPer1M": 1.25, "outputPer1M": 2.5, "cacheReadPer1M": 0.2 }, - "grok-3-mini": { "inputPer1M": 1.25, "outputPer1M": 2.5, "cacheReadPer1M": 0.2 } + "grok-3-mini": { "inputPer1M": 1.25, "outputPer1M": 2.5, "cacheReadPer1M": 0.2 }, + "gpt-4o-2024-05-13": { "inputPer1M": 5, "outputPer1M": 15 }, + "deepseek-flash": { "inputPer1M": 0.3, "outputPer1M": 1.2, "cacheReadPer1M": 0.006 }, + "gpt-image-2": { "inputPer1M": 5, "outputPer1M": 0, "cacheReadPer1M": 1.25 }, + "gpt-image-2.5-sunburst": { "inputPer1M": 5, "outputPer1M": 0, "cacheReadPer1M": 1.25 }, + "gpt-image-2.5-flare": { "inputPer1M": 5, "outputPer1M": 0, "cacheReadPer1M": 1.25 }, + "gpt-image-1.5": { "inputPer1M": 5, "outputPer1M": 10, "cacheReadPer1M": 1.25 }, + "gpt-image-1": { "inputPer1M": 5, "outputPer1M": 0, "cacheReadPer1M": 1.25 }, + "gpt-image-1-mini": { "inputPer1M": 2, "outputPer1M": 0, "cacheReadPer1M": 0.2 }, + "chatgpt-image-latest": { "inputPer1M": 5, "outputPer1M": 10, "cacheReadPer1M": 1.25 }, + "gpt-realtime-2.1": { "inputPer1M": 4, "outputPer1M": 24, "cacheReadPer1M": 0.4 }, + "gpt-realtime-2": { "inputPer1M": 4, "outputPer1M": 24, "cacheReadPer1M": 0.4 }, + "gpt-realtime-1.5": { "inputPer1M": 4, "outputPer1M": 16, "cacheReadPer1M": 0.4 }, + "gpt-realtime": { "inputPer1M": 4, "outputPer1M": 16, "cacheReadPer1M": 0.4 }, + "gpt-realtime-2.1-mini": { "inputPer1M": 0.6, "outputPer1M": 2.4, "cacheReadPer1M": 0.06 }, + "gpt-realtime-mini": { "inputPer1M": 0.6, "outputPer1M": 2.4, "cacheReadPer1M": 0.06 }, + "gpt-audio": { "inputPer1M": 2.5, "outputPer1M": 10 }, + "gpt-audio-1.5": { "inputPer1M": 2.5, "outputPer1M": 10 }, + "gpt-audio-mini": { "inputPer1M": 0.6, "outputPer1M": 2.4 } } } diff --git a/src-tauri/src/agents/backups/tests.rs b/src-tauri/src/agents/backups/tests.rs index a87684bc..6b6a48ed 100644 --- a/src-tauri/src/agents/backups/tests.rs +++ b/src-tauri/src/agents/backups/tests.rs @@ -680,6 +680,9 @@ fn linked_configuration_and_backup_directories_are_rejected() { fs::read_to_string(outside.0.join("config.toml")).unwrap(), "custom='outside'" ); + #[cfg(unix)] + fs::remove_file(link).unwrap(); + #[cfg(windows)] fs::remove_dir(link).unwrap(); let data = agent_data_directory(&paths).unwrap(); fs::create_dir_all(&data).unwrap(); @@ -687,6 +690,9 @@ fn linked_configuration_and_backup_directories_are_rejected() { assert!(create_backup("codex", &home.0).is_err()); assert!(list_backups("codex", &home.0).is_err()); assert!(delete_backup("codex", &home.0, "1").is_err()); + #[cfg(unix)] + fs::remove_file(data.join("backups")).unwrap(); + #[cfg(windows)] fs::remove_dir(data.join("backups")).unwrap(); } diff --git a/src-tauri/src/provider_health.rs b/src-tauri/src/provider_health.rs index d7666e14..3e23d938 100644 --- a/src-tauri/src/provider_health.rs +++ b/src-tauri/src/provider_health.rs @@ -15,6 +15,10 @@ static PROVIDER_HEALTH_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::c #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ProviderHealthProbeRequest { + #[serde(default)] + provider: String, + #[serde(default)] + base_url: String, url: String, header: HashMap, data: String, @@ -38,6 +42,11 @@ pub(crate) struct ProviderHealthProbeResponse { #[derive(Default)] struct ProviderHealthUsageTokens { + observed: bool, + raw_usage: serde_json::Value, + cache_creation_tokens: u64, + cache_creation_5m_tokens: u64, + cache_creation_1h_tokens: u64, input_tokens: u64, output_tokens: u64, reasoning_tokens: u64, @@ -185,50 +194,78 @@ pub(crate) fn provider_health_stream_has_terminal_success(protocol: &str, bytes: fn provider_health_usage_tokens(protocol: &str, bytes: &[u8]) -> ProviderHealthUsageTokens { let mut tokens = ProviderHealthUsageTokens::default(); - if protocol != "gemini" { - return tokens; - } let text = String::from_utf8_lossy(bytes); - for line in text.lines() { - let line = line.trim(); - let data = line.strip_prefix("data:").map(str::trim).unwrap_or(line); - let Some(usage) = serde_json::from_str::(data) - .ok() - .and_then(|value| value.get("usageMetadata").cloned()) - else { - continue; + let values = serde_json::from_slice::(bytes) + .ok() + .into_iter() + .chain(text.lines().filter_map(|line| { + let data = line.trim().strip_prefix("data:")?.trim(); + serde_json::from_str::(data).ok() + })); + let mut merged = serde_json::Map::new(); + for value in values { + let node = match protocol { + "gemini" => value.get("usageMetadata"), + "openai-responses" => value + .pointer("/response/usage") + .or_else(|| value.get("usage")), + "claude" => value + .pointer("/message/usage") + .or_else(|| value.get("usage")), + _ => value.get("usage"), }; - tokens.input_tokens = tokens.input_tokens.max( - usage - .get("promptTokenCount") - .and_then(serde_json::Value::as_u64) - .unwrap_or_default(), - ); - tokens.output_tokens = tokens.output_tokens.max( - usage - .get("candidatesTokenCount") - .and_then(serde_json::Value::as_u64) - .unwrap_or_default(), - ); - tokens.reasoning_tokens = tokens.reasoning_tokens.max( - usage - .get("thoughtsTokenCount") - .and_then(serde_json::Value::as_u64) - .unwrap_or_default(), - ); - tokens.cache_read_tokens = tokens.cache_read_tokens.max( - usage - .get("cachedContentTokenCount") - .and_then(serde_json::Value::as_u64) - .unwrap_or_default(), - ); - tokens.total_tokens = tokens.total_tokens.max( - usage - .get("totalTokenCount") - .and_then(serde_json::Value::as_u64) - .unwrap_or_default(), - ); + if let Some(node) = node.and_then(serde_json::Value::as_object) { + tokens.observed = true; + for (key, value) in node { + merged.insert(key.clone(), value.clone()); + } + } } + let raw = serde_json::Value::Object(merged); + let number = |paths: &[&str]| { + paths + .iter() + .find_map(|p| raw.pointer(p).and_then(serde_json::Value::as_u64)) + .unwrap_or(0) + }; + tokens.input_tokens = number(&["/prompt_tokens", "/input_tokens", "/promptTokenCount"]); + tokens.output_tokens = number(&[ + "/completion_tokens", + "/output_tokens", + "/candidatesTokenCount", + ]); + tokens.reasoning_tokens = number(&[ + "/completion_tokens_details/reasoning_tokens", + "/output_tokens_details/reasoning_tokens", + "/output_tokens_details/thinking_tokens", + "/thoughtsTokenCount", + ]); + tokens.cache_read_tokens = number(&[ + "/prompt_tokens_details/cached_tokens", + "/input_tokens_details/cached_tokens", + "/cache_read_input_tokens", + "/cachedContentTokenCount", + ]); + tokens.cache_creation_tokens = number(&[ + "/cache_creation_input_tokens", + "/input_tokens_details/cache_creation_tokens", + ]); + tokens.cache_creation_5m_tokens = number(&["/cache_creation/ephemeral_5m_input_tokens"]); + tokens.cache_creation_1h_tokens = number(&["/cache_creation/ephemeral_1h_input_tokens"]); + tokens.total_tokens = number(&["/total_tokens", "/totalTokenCount"]); + if tokens.total_tokens == 0 && tokens.observed { + tokens.total_tokens = tokens.input_tokens.saturating_add(tokens.output_tokens); + if protocol == "gemini" { + tokens.total_tokens = tokens.total_tokens.saturating_add(tokens.reasoning_tokens); + } + if protocol == "claude" { + tokens.total_tokens = tokens + .total_tokens + .saturating_add(tokens.cache_read_tokens) + .saturating_add(tokens.cache_creation_tokens); + } + } + tokens.raw_usage = raw; tokens } @@ -242,23 +279,48 @@ fn provider_health_usage_provider(protocol: &str) -> &str { } } -fn persist_provider_health_success( +fn persist_provider_health_outcome( app: &tauri::AppHandle, request: &ProviderHealthProbeRequest, endpoint: &str, latency_ms: u64, ttft_ms: Option, received: &[u8], + failure: Option<&str>, + status: u16, ) { let tokens = provider_health_usage_tokens(&request.protocol, received); + let ticks = tokens.raw_usage["cost_in_usd_ticks"].as_u64(); + let cost_usd = ticks.map(|n| format!("{}.{:010}", n / 10_000_000_000, n % 10_000_000_000)); + let provider = if reqwest::Url::parse(&request.url) + .ok() + .is_some_and(|u| u.host_str() == Some("api.x.ai")) + { + "xai" + } else if request.provider.is_empty() { + provider_health_usage_provider(&request.protocol) + } else { + &request.provider + }; let event = serde_json::json!({ "timestamp": Local::now().to_rfc3339(), "latency_ms": latency_ms, "ttft_ms": ttft_ms, - "source": request.source.as_str(), + "source": if request.auth_index.is_empty() { &request.base_url } else { &request.auth_index }, + "api_key": request.source.as_str(), "auth_index": request.auth_index.as_str(), - "failed": false, - "provider": provider_health_usage_provider(&request.protocol), + "failed": failure.is_some(), + "fail": {"status_code":status, "body":failure.unwrap_or("")}, + "usage_observed": tokens.observed, + "usage_complete": failure.is_none(), + "raw_usage": tokens.raw_usage, + "kind": "health_check", + "stream": true, + "base_url": request.base_url.as_str(), + "cache_creation_5m_tokens": tokens.cache_creation_5m_tokens, + "cache_creation_1h_tokens": tokens.cache_creation_1h_tokens, + "provider": provider, + "cost_usd": cost_usd, "model": request.model.as_str(), "executor_type": "DesktopProviderHealthCheck", "endpoint": endpoint, @@ -268,6 +330,7 @@ fn persist_provider_health_success( "output_tokens": tokens.output_tokens, "reasoning_tokens": tokens.reasoning_tokens, "cache_read_tokens": tokens.cache_read_tokens, + "cache_creation_tokens": tokens.cache_creation_tokens, "total_tokens": tokens.total_tokens, }, }); @@ -339,63 +402,79 @@ pub(crate) async fn provider_health_probe( } let started_at = Instant::now(); - let response = client - .post(url) - .headers(headers) - .body(request.data.clone()) - .send() - .await - .map_err(|error| format!("健康检测请求失败: {error}"))?; - let status = response.status(); - if !status.is_success() { - let detail = response.text().await.unwrap_or_default(); - let detail = detail.trim(); - return Err(if detail.is_empty() { - format!("上游返回 HTTP {}", status.as_u16()) - } else { - format!("上游返回 HTTP {}: {}", status.as_u16(), detail) - }); - } - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("") - .to_ascii_lowercase(); - if !provider_health_content_type_is_streaming(&content_type) { - return Err("上游未返回流式响应,无法测量首字延迟".to_string()); - } - - let mut stream = response.bytes_stream(); let mut received = Vec::new(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|error| format!("读取健康检测流失败: {error}"))?; - if received.len().saturating_add(chunk.len()) > MAX_PROVIDER_HEALTH_STREAM_BYTES { - return Err("健康检测在限制范围内未收到模型首字".to_string()); + let mut first_token = None; + let mut status_code = 0; + let result = async { + let response = client + .post(url) + .headers(headers) + .body(request.data.clone()) + .send() + .await + .map_err(|error| format!("Health request failed: {error}"))?; + status_code = response.status().as_u16(); + if !response.status().is_success() { + return Err(format!( + "Upstream HTTP {}: {}", + status_code, + response.text().await.unwrap_or_default() + )); } - received.extend_from_slice(&chunk); - let elapsed_ms = started_at.elapsed().as_millis().max(1) as u64; - if provider_health_stream_has_text(&request.protocol, &received) { - persist_provider_health_success( - &app, - &request, - &endpoint, - elapsed_ms, - Some(elapsed_ms), - &received, - ); - return Ok(ProviderHealthProbeResponse { - first_token_latency_ms: Some(elapsed_ms), - response_latency_ms: elapsed_ms, - }); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| format!("Health stream failed: {error}"))?; + if received.len().saturating_add(chunk.len()) > MAX_PROVIDER_HEALTH_STREAM_BYTES { + return Err( + "Health response exceeded capture limit; usage is incomplete".to_string(), + ); + } + received.extend_from_slice(&chunk); + if first_token.is_none() + && provider_health_stream_has_text(&request.protocol, &received) + { + first_token = Some(started_at.elapsed().as_millis().max(1) as u64); + } } - if provider_health_stream_has_terminal_success(&request.protocol, &received) { - persist_provider_health_success(&app, &request, &endpoint, elapsed_ms, None, &received); - return Ok(ProviderHealthProbeResponse { - first_token_latency_ms: None, - response_latency_ms: elapsed_ms, - }); + if first_token.is_none() + && !provider_health_stream_has_terminal_success(&request.protocol, &received) + { + return Err("Health response contained no model output".to_string()); } + Ok(ProviderHealthProbeResponse { + first_token_latency_ms: first_token, + response_latency_ms: started_at.elapsed().as_millis().max(1) as u64, + }) + } + .await; + persist_provider_health_outcome( + &app, + &request, + &endpoint, + started_at.elapsed().as_millis().max(1) as u64, + first_token, + &received, + result.as_ref().err().map(String::as_str), + status_code, + ); + result +} + +#[cfg(test)] +mod accounting_tests { + use super::*; + #[test] + fn health_chat_usage_is_not_lost() { + let tokens = provider_health_usage_tokens( + "openai-chat", + br#"data: {"usage":{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110}}"#, + ); + assert_eq!(tokens.total_tokens, 110); + } + #[test] + fn health_claude_merges_start_and_delta_usage() { + let tokens = provider_health_usage_tokens("claude", b"data: {\"message\":{\"usage\":{\"input_tokens\":100,\"output_tokens\":1}}}\n\ndata: {\"usage\":{\"output_tokens\":10}}\n\n"); + assert_eq!(tokens.input_tokens, 100); + assert_eq!(tokens.output_tokens, 10); } - Err("健康检测未收到模型首字".to_string()) } diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 05cd3345..416c1049 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -1,3 +1,5 @@ +mod accounting; +mod multimodal; mod resp; #[cfg(target_os = "macos")] @@ -40,7 +42,7 @@ const USAGE_FAILURE_MIGRATION_KEY: &str = "failure_details_v4"; const USAGE_EVENT_KEY_MIGRATION_KEY: &str = "event_key_v5"; const USAGE_UPDATED_EVENT: &str = "usage-records-updated"; const USAGE_SCHEMA_VERSION: u8 = 1; -const USAGE_DATABASE_SCHEMA_VERSION: i64 = 5; +const USAGE_DATABASE_SCHEMA_VERSION: i64 = 6; const MAX_USAGE_FAILURE_BODY_CHARS: usize = 2_000; const USAGE_QUEUE_BATCH_SIZE: usize = 500; const USAGE_INBOX_PROCESS_LIMIT: usize = 500; @@ -48,7 +50,6 @@ const USAGE_INBOX_MAX_ATTEMPTS: i64 = 5; const USAGE_SUBSCRIBE_RETRY_SECONDS: u64 = 30; const SQLITE_BUSY_TIMEOUT_SECONDS: u64 = 5; const TOKENS_PER_PRICE_UNIT: f64 = 1_000_000.0; -const LONG_CONTEXT_INPUT_TOKEN_THRESHOLD: u64 = 272_000; const BUNDLED_MODEL_PRICE_CATALOG: &str = include_str!("../resources/model_prices.json"); const MODEL_PRICE_SYNC_URL: &str = "https://raw.githubusercontent.com/router-for-me/EasyCLIProxyAPI/main/src-tauri/resources/model_prices.json"; @@ -160,6 +161,8 @@ struct UsageTokenStats { #[derive(Clone, Serialize, Deserialize)] pub(crate) struct UsageRecord { + #[serde(default)] + accounting: Value, id: String, timestamp: String, #[serde(default)] @@ -214,7 +217,7 @@ pub(crate) struct UsageRecord { api_key_remark: String, #[serde(default)] request_id: String, - #[serde(default = "default_usage_generate", skip_serializing)] + #[serde(default = "default_usage_generate")] generate: bool, #[serde(default, skip_serializing)] cached_tokens: u64, @@ -240,6 +243,12 @@ struct LegacyUsageInboxFile { #[derive(Clone, Default, Deserialize)] pub(crate) struct UsageQuery { + #[serde(default)] + endpoint: Option, + #[serde(default)] + transport: Option, + #[serde(default)] + kind: Option, #[serde(default)] start: Option, #[serde(default)] @@ -265,6 +274,7 @@ pub(crate) struct UsageQuery { #[derive(Default, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct UsageOverview { + event_counts: Value, total_requests: u64, success_count: u64, failure_count: u64, @@ -299,6 +309,10 @@ pub(crate) struct UsageRepairResult { #[derive(Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ModelPrice { + #[serde(default)] + provider: String, + #[serde(default)] + base_url: String, model: String, prompt: f64, completion: f64, @@ -340,10 +354,6 @@ struct CostTokens { output: u64, cache_read: u64, cache_creation: u64, - long_input: u64, - long_output: u64, - long_cache_read: u64, - long_cache_creation: u64, } #[derive(Default, Serialize)] @@ -380,6 +390,8 @@ pub(crate) struct ModelPriceSyncResult { } struct UsageCostGroup { + accounting: Value, + timestamp: String, model: String, alias: String, service_tier: String, @@ -1166,6 +1178,10 @@ fn initialize_usage_schema(connection: &Connection) -> Result<(), String> { updated_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS scoped_model_prices ( + provider TEXT NOT NULL, base_url TEXT NOT NULL, model TEXT NOT NULL, price_json TEXT NOT NULL, + PRIMARY KEY(provider, base_url, model) + ); CREATE TABLE IF NOT EXISTS model_prices ( model TEXT PRIMARY KEY NOT NULL, prompt_per_1m REAL NOT NULL DEFAULT 0, @@ -1185,6 +1201,13 @@ fn initialize_usage_schema(connection: &Connection) -> Result<(), String> { ) .map_err(|error| format!("初始化 SQLite 使用记录结构失败: {error}"))?; ensure_usage_failure_columns(connection)?; + let columns = usage_table_columns(connection, "usage_events")?; + if !columns.contains("accounting_json") { + connection.execute_batch("ALTER TABLE usage_events ADD COLUMN accounting_json TEXT NOT NULL DEFAULT '{}'; ALTER TABLE usage_events ADD COLUMN event_id TEXT NOT NULL DEFAULT ''; UPDATE usage_events SET output_tokens = output_tokens + reasoning_tokens WHERE (lower(provider) IN ('gemini','antigravity','vertex') OR lower(executor_type) LIKE '%gemini%' OR lower(executor_type) LIKE '%antigravity%') AND reasoning_tokens > 0 AND total_tokens = input_tokens + output_tokens + reasoning_tokens;") + .map_err(|error| format!("Migrate usage accounting: {error}"))?; + } + connection.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_event_id ON usage_events(event_id) WHERE event_id != ''", []) + .map_err(|error| format!("Index usage identity: {error}"))?; connection .execute( "CREATE INDEX IF NOT EXISTS idx_usage_events_canceled_timestamp ON usage_events(canceled, timestamp_ms DESC)", @@ -1417,6 +1440,7 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) { }; let mut retry_seconds = 1_u64; + let mut next_journal_check = tokio::time::Instant::now(); let mut subscription: Option = None; let mut subscribe_retry_at = tokio::time::Instant::now(); let mut next_inbox_cleanup_at = tokio::time::Instant::now() + Duration::from_secs(60 * 60); @@ -1488,6 +1512,29 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) { continue; } + if tokio::time::Instant::now() >= next_journal_check { + match collect_usage_journal(&root, &config).await { + Ok(Some(saved)) => { + subscription = None; + publish_collected_records( + &app, + saved, + "Durable usage journal: committed and acknowledged", + ); + wait_or_cancel(&token, 1).await; + continue; + } + Ok(None) => { + next_journal_check = tokio::time::Instant::now() + Duration::from_secs(30); + } + Err(error) => { + set_collector_error(&app, error); + wait_or_cancel(&token, 1).await; + continue; + } + } + } + if subscription.is_none() && tokio::time::Instant::now() >= subscribe_retry_at { match UsageSubscription::connect(config.port, &config.management_secret_key).await { Ok(next_subscription) => { @@ -1607,6 +1654,51 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) { } } +async fn collect_usage_journal( + root: &Path, + config: &GuiConfigFile, +) -> Result, String> { + let client = management_http_client()?; + let response = client + .get(management_endpoint(config, "usage-journal")?) + .header("Authorization", management_authorization(config)?) + .query(&[("count", USAGE_QUEUE_BATCH_SIZE)]) + .send() + .await + .map_err(|e| e.to_string())?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + let response = response + .error_for_status() + .map_err(|e| format!("Durable usage journal unavailable: {e}"))?; + let items: Vec = response.json().await.map_err(|e| e.to_string())?; + let ids = items + .iter() + .map(|v| { + v["event_id"] + .as_str() + .map(str::to_string) + .filter(|id| !id.is_empty()) + .ok_or_else(|| "Journal event is missing event_id".to_string()) + }) + .collect::, _>>()?; + // A replay must be harmless even if the ACK response is lost. + let saved = persist_queue_items_from_source(root, "durable_journal", items, config)?; + if !ids.is_empty() { + client + .post(management_endpoint(config, "usage-journal/ack")?) + .header("Authorization", management_authorization(config)?) + .json(&serde_json::json!({"event_ids":ids})) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| format!("Usage committed locally; ACK will retry: {e}"))?; + } + Ok(Some(saved)) +} + async fn backfill_usage_queue(root: &Path, config: &GuiConfigFile) -> Result { let mut saved_total = 0_usize; loop { @@ -1763,6 +1855,7 @@ fn enqueue_usage_raw_messages( let messages = messages .into_iter() .filter(|message| !is_ignorable_usage_message(message)) + .map(accounting::redact_credentials) .collect::>(); if messages.is_empty() { return Ok(0); @@ -2053,17 +2146,18 @@ fn insert_usage_records_in_transaction( generate, cached_tokens, collector_source, input_tokens, output_tokens, reasoning_tokens, cache_read_tokens, cache_creation_tokens, total_tokens, canceled, failure_status, - failure_body, created_at + failure_body, created_at, accounting_json, event_id ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, - ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39, ?40 - ) + ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39, ?40, ?41, ?42 + ) ON CONFLICT(event_id) WHERE event_id != '' DO NOTHING "#, ) .map_err(|error| format!("准备 SQLite 使用记录写入失败: {error}"))?; let created_at = Local::now().to_rfc3339(); + let prices = load_model_prices(transaction)?; let mut inserted = 0_usize; for record in records { let api_group_key = if !record.api_group_key.trim().is_empty() { @@ -2082,19 +2176,27 @@ fn insert_usage_records_in_transaction( .cache_read_tokens .saturating_add(record.tokens.cache_creation_tokens); let input_before_invariant = record.tokens.input_tokens; - let input_tokens = if cache_components > input_before_invariant { - input_before_invariant.saturating_add(cache_components) - } else { - input_before_invariant - }; - let total_tokens = if record.tokens.total_tokens == 0 - || record.tokens.total_tokens - == input_before_invariant.saturating_add(record.tokens.output_tokens) - { - input_tokens.saturating_add(record.tokens.output_tokens) - } else { - record.tokens.total_tokens - }; + let input_tokens = input_before_invariant; + let total_tokens = record.tokens.total_tokens; + let mut accounting = record.accounting.clone(); + if !accounting.is_object() { + accounting = serde_json::json!({}); + } + // Freeze known estimates; unpriced legacy records can still acquire a tariff. + let mut valuation = accounting::snapshot(record, &prices); + if record.provider.eq_ignore_ascii_case("xai") && accounting["cost_scope"] == "operation" { + if let (Some(id), Some(cost)) = ( + accounting["billing_id"].as_str(), + valuation["cost"].as_f64(), + ) { + let previous: f64 = transaction.query_row("SELECT COALESCE(MAX(CAST(json_extract(accounting_json,'$.cost_usd') AS REAL)),0) FROM usage_events WHERE provider=?1 AND json_extract(accounting_json,'$.billing_id')=?2", params![record.provider,id],|row|row.get(0)).map_err(|e|e.to_string())?; + valuation["cost"] = serde_json::json!((cost - previous).max(0.0)); + valuation["cumulative_cost"] = serde_json::json!(cost); + } + } + if valuation["cost"].is_number() { + accounting["valuation"] = valuation; + } let cached_tokens = record.cached_tokens.max(cache_components); let collector_source = if record.collector_source.trim().is_empty() { "legacy_json" @@ -2148,6 +2250,8 @@ fn insert_usage_records_in_transaction( i64::from(record.failure_status), record.failure_body, created_at, + serde_json::to_string(&accounting).unwrap_or_else(|_| "{}".to_string()), + accounting["event_id"].as_str().unwrap_or(""), ]) .map_err(|error| format!("写入 SQLite 使用记录失败: {error}"))?, ); @@ -2165,11 +2269,11 @@ fn normalize_usage_record(value: Value, config: &GuiConfigFile) -> Result Result input_before_invariant { - tokens.input_tokens = input_before_invariant.saturating_add(cache_components); - if tokens.total_tokens == 0 - || tokens.total_tokens == input_before_invariant.saturating_add(tokens.output_tokens) - { - tokens.total_tokens = tokens.input_tokens.saturating_add(tokens.output_tokens); - } - } if tokens.total_tokens == 0 || (claude_excludes_cache && tokens.total_tokens == raw_total_without_cache) { tokens.total_tokens = tokens.input_tokens.saturating_add(tokens.output_tokens); } + let accounting = accounting::normalize_accounting(&value, &mut tokens); let id = request_id.clone(); let endpoint = string_field(object, "endpoint").unwrap_or_default(); let failed = object @@ -2261,6 +2357,7 @@ fn normalize_usage_record(value: Value, config: &GuiConfigFile) -> Result Result UsageSqlFilter { params.push(SqlValue::Integer(end)); } add_text_filter(&mut clauses, &mut params, "model", query.model.as_deref()); + add_text_filter( + &mut clauses, + &mut params, + "endpoint", + query.endpoint.as_deref(), + ); + add_text_filter( + &mut clauses, + &mut params, + "json_extract(accounting_json, '$.kind')", + query.kind.as_deref(), + ); + if let Some(transport) = query.transport.as_deref() { + match transport { + "websocket" => clauses.push("(json_extract(accounting_json, '$.transport') = 'websocket' OR lower(executor_type) LIKE '%websocket%')".to_string()), + "sse" => clauses.push("(json_extract(accounting_json, '$.transport') = 'sse' OR (json_extract(accounting_json, '$.transport') IS NULL AND json_extract(accounting_json, '$.stream') = 1 AND lower(executor_type) NOT LIKE '%websocket%'))".to_string()), + "http" => clauses.push("(json_extract(accounting_json, '$.transport') = 'http' OR (json_extract(accounting_json, '$.transport') IS NULL AND json_extract(accounting_json, '$.stream') = 0 AND lower(executor_type) NOT LIKE '%websocket%'))".to_string()), + _ => {}, + } + } add_text_filter( &mut clauses, &mut params, @@ -2603,13 +2721,9 @@ fn load_usage_cost_groups( COALESCE(SUM(output_tokens), 0), COALESCE(SUM(cache_read_tokens), 0), COALESCE(SUM(cache_creation_tokens), 0), - COALESCE(SUM(CASE WHEN input_tokens > {LONG_CONTEXT_INPUT_TOKEN_THRESHOLD} THEN input_tokens ELSE 0 END), 0), - COALESCE(SUM(CASE WHEN input_tokens > {LONG_CONTEXT_INPUT_TOKEN_THRESHOLD} THEN output_tokens ELSE 0 END), 0), - COALESCE(SUM(CASE WHEN input_tokens > {LONG_CONTEXT_INPUT_TOKEN_THRESHOLD} THEN cache_read_tokens ELSE 0 END), 0), - COALESCE(SUM(CASE WHEN input_tokens > {LONG_CONTEXT_INPUT_TOKEN_THRESHOLD} THEN cache_creation_tokens ELSE 0 END), 0), - COALESCE(SUM(total_tokens), 0) + COALESCE(SUM(total_tokens), 0), accounting_json, timestamp FROM usage_events{} - GROUP BY model, alias, service_tier, response_service_tier, executor_type, provider, auth_type + GROUP BY id "#, filter.clause ); @@ -2619,6 +2733,8 @@ fn load_usage_cost_groups( let groups = statement .query_map(params_from_iter(filter.params.iter()), |row| { Ok(UsageCostGroup { + accounting: serde_json::from_str(&row.get::<_, String>(13)?).unwrap_or_default(), + timestamp: row.get(14)?, model: row.get(0)?, alias: row.get(1)?, service_tier: row.get(2)?, @@ -2632,12 +2748,8 @@ fn load_usage_cost_groups( output: from_sql_i64(row.get(9)?), cache_read: from_sql_i64(row.get(10)?), cache_creation: from_sql_i64(row.get(11)?), - long_input: from_sql_i64(row.get(12)?), - long_output: from_sql_i64(row.get(13)?), - long_cache_read: from_sql_i64(row.get(14)?), - long_cache_creation: from_sql_i64(row.get(15)?), }, - total_tokens: from_sql_i64(row.get(16)?), + total_tokens: from_sql_i64(row.get(12)?), }) }) .map_err(|error| format!("查询使用成本失败: {error}"))? @@ -2650,77 +2762,25 @@ fn sum_usage_cost(groups: &[UsageCostGroup], prices: &HashMap f64 { - let price = enriched_model_price(model, price); - let short_cost = cost_for_token_segment( - tokens.input.saturating_sub(tokens.long_input), - tokens.output.saturating_sub(tokens.long_output), - tokens.cache_read.saturating_sub(tokens.long_cache_read), - tokens - .cache_creation - .saturating_sub(tokens.long_cache_creation), - &price, - 1.0, - 1.0, - ); - let long_cost = cost_for_token_segment( - tokens.long_input, - tokens.long_output, - tokens.long_cache_read, - tokens.long_cache_creation, - &price, - 2.0, - 1.5, - ); - let tier = service_tier.trim().to_ascii_lowercase(); - let multiplier = if tokens.long_input > 0 && matches!(tier.as_str(), "priority" | "fast") { - 1.0 - } else { - match tier.as_str() { - "flex" | "batch" => 0.5, - "priority" | "fast" => service_tier_multiplier(model), - _ => 1.0, - } - }; - (short_cost + long_cost) * multiplier -} - -fn cost_for_token_segment( - input: u64, - output: u64, - cache_read: u64, - cache_creation: u64, - price: &ModelPrice, - input_multiplier: f64, - output_multiplier: f64, -) -> f64 { - let prompt = input.saturating_sub(cache_read.saturating_add(cache_creation)); - ((prompt as f64 * price.prompt - + cache_read as f64 * price.cache_read - + cache_creation as f64 * price.cache_creation) - * input_multiplier - + output as f64 * price.completion * output_multiplier) - / TOKENS_PER_PRICE_UNIT + accounting::tariff_cost( + &normalized_model_tail(model), + service_tier, + tokens, + price, + &serde_json::json!({}), + "2026-09-12T12:00:00Z", + ) + .unwrap_or(0.0) } fn official_model_price(model: &str) -> Option { @@ -2753,12 +2813,14 @@ fn parse_model_price_catalog( if catalog.models.is_empty() { return Err("模型价格文件不包含任何模型".to_string()); } - let _catalog_updated_at = catalog.updated_at; + let catalog_updated_at = catalog.updated_at; let mut prices = HashMap::with_capacity(catalog.models.len()); for (model, entry) in catalog.models { let cache_read = entry.cache_read_per_1_m.unwrap_or(0.0); let cache_creation = entry.cache_creation_per_1_m.unwrap_or(0.0); let price = ModelPrice { + provider: String::new(), + base_url: String::new(), model: model.trim().to_string(), prompt: entry.input_per_1_m, completion: entry.output_per_1_m, @@ -2770,7 +2832,7 @@ fn parse_model_price_catalog( cache_read_configured: entry.cache_read_per_1_m.is_some(), cache_creation_configured: entry.cache_creation_per_1_m.is_some(), source: source.to_string(), - source_model_id: String::new(), + source_model_id: catalog_updated_at.clone(), updated_at_ms, }; validate_model_price(&price)?; @@ -2788,7 +2850,11 @@ fn find_model_price<'a>( } let case_insensitive = prices .iter() - .filter(|(key, _)| key.eq_ignore_ascii_case(model)) + .filter(|(key, price)| { + price.provider.is_empty() + && price.base_url.is_empty() + && key.eq_ignore_ascii_case(model) + }) .collect::>(); if case_insensitive.len() == 1 { return Some(case_insensitive[0].1); @@ -2796,22 +2862,28 @@ fn find_model_price<'a>( let tail = canonical_model_tail(model); let exact_tail = prices .iter() - .filter(|(key, _)| canonical_model_tail(key) == tail) + .filter(|(key, price)| { + price.provider.is_empty() + && price.base_url.is_empty() + && canonical_model_tail(key) == tail + }) .collect::>(); if exact_tail.len() == 1 { return Some(exact_tail[0].1); } - let normalized_tail = normalized_model_tail(model); - prices - .iter() - .filter_map(|(key, price)| { - let key_tail = normalized_model_tail(key); - normalized_tail - .starts_with(&format!("{key_tail}-")) - .then_some((key_tail.len(), price)) - }) - .max_by_key(|(length, _)| *length) - .map(|(_, price)| price) + // Only known reasoning suffixes may inherit a model tariff. Snapshots and + // media variants need an explicit entry; arbitrary prefix matching is unsafe. + let normalized = normalized_model_tail(model); + for suffix in [ + "-none", "-minimal", "-low", "-medium", "-high", "-xhigh", "-max", + ] { + if let Some(base) = normalized.strip_suffix(suffix) { + if let Some(price) = prices.get(base) { + return Some(price); + } + } + } + None } fn resolve_model_price<'a>( @@ -2824,7 +2896,11 @@ fn resolve_model_price<'a>( continue; } if let Some(price) = find_model_price(prices, candidate) { - return Some((model, price.clone())); + // An unknown upstream model must not inherit a different model's + // official tariff through its requested alias (notably image tools). + if candidate == model || price.source == "manual" { + return Some((candidate, price.clone())); + } } } None @@ -2840,49 +2916,13 @@ fn enriched_model_price(model: &str, price: &ModelPrice) -> ModelPrice { price.completion = official.completion; } } - if !price.cache_read_configured && price.cache_read <= 0.0 { - price.cache_read = if price.cache > 0.0 { - price.cache - } else { - price.prompt * 0.1 - }; - } - if !price.cache_creation_configured && price.cache_creation <= 0.0 { - price.cache_creation = price.prompt - * if is_model_family(model, "gpt-5.6") { - 1.25 - } else { - 1.0 - }; + if !price.cache_read_configured && price.cache > 0.0 { + price.cache_read = price.cache; + price.cache_read_configured = true; } price } -fn is_model_family(model: &str, family: &str) -> bool { - let normalized = model - .trim() - .to_ascii_lowercase() - .rsplit('/') - .next() - .unwrap_or_default() - .to_string(); - normalized == family || normalized.starts_with(&format!("{family}-")) -} - -fn service_tier_multiplier(model: &str) -> f64 { - if is_model_family(model, "gpt-5.5") { - 2.5 - } else if is_model_family(model, "gpt-5.6") - || is_model_family(model, "gpt-5.4") - || is_model_family(model, "gpt-5.4-mini") - || is_model_family(model, "gpt-5.3-codex") - { - 2.0 - } else { - 1.0 - } -} - fn load_model_prices(connection: &Connection) -> Result, String> { let mut merged = bundled_model_prices()?; let mut statement = connection @@ -2900,6 +2940,8 @@ fn load_model_prices(connection: &Connection) -> Result Result, _>>() .map_err(|error| format!("读取模型价格失败: {error}"))?; for price in prices { + if price.source == "github" && price.source_model_id.as_str() < "2026-09-12" { + continue; + } if price.source != "litellm" { if let Some(existing) = merged .keys() @@ -2930,9 +2975,59 @@ fn load_model_prices(connection: &Connection) -> Result(0)) + .map_err(|e| e.to_string())? + { + let price: ModelPrice = + serde_json::from_str(&item.map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; + merged.insert(model_price_key(&price), price); + } Ok(merged) } +fn model_price_key(price: &ModelPrice) -> String { + if price.provider.is_empty() && price.base_url.is_empty() { + price.model.clone() + } else { + format!("{} | {} | {}", price.provider, price.base_url, price.model) + } +} + +fn price_for_group( + group: &UsageCostGroup, + prices: &HashMap, +) -> Option { + let base_url = group.accounting["base_url"] + .as_str() + .unwrap_or("") + .trim_end_matches('/'); + for candidate in [&group.model, &group.alias] { + if let Some(price) = prices + .values() + .filter(|p| { + p.source == "manual" + && (!p.provider.is_empty() || !p.base_url.is_empty()) + && p.model.eq_ignore_ascii_case(candidate) + && (p.provider.is_empty() || p.provider.eq_ignore_ascii_case(&group.provider)) + && (p.base_url.is_empty() || p.base_url.trim_end_matches('/') == base_url) + }) + .max_by_key(|p| { + ( + u8::from(!p.base_url.is_empty()), + u8::from(!p.provider.is_empty()), + ) + }) + { + return Some(price.clone()); + } + } + resolve_model_price(&group.model, &group.alias, prices).map(|(_, p)| p) +} + fn validate_model_price(price: &ModelPrice) -> Result<(), String> { if price.model.trim().is_empty() { return Err("模型名称不能为空".to_string()); @@ -2953,6 +3048,11 @@ fn validate_model_price(price: &ModelPrice) -> Result<(), String> { fn upsert_model_price(connection: &Connection, price: &ModelPrice) -> Result<(), String> { validate_model_price(price)?; + if !price.provider.is_empty() || !price.base_url.is_empty() { + connection.execute("INSERT INTO scoped_model_prices(provider,base_url,model,price_json) VALUES(?1,?2,?3,?4) ON CONFLICT(provider,base_url,model) DO UPDATE SET price_json=excluded.price_json", + params![price.provider,price.base_url,price.model,serde_json::to_string(price).map_err(|e|e.to_string())?]).map_err(|e|e.to_string())?; + return Ok(()); + } connection .execute( r#" @@ -3012,12 +3112,15 @@ fn load_usage_pricing(connection: &Connection, query: &UsageQuery) -> Result Result Result Result<(), String> { price.model = price.model.trim().to_string(); + price.provider = price.provider.trim().to_ascii_lowercase(); + price.base_url = price.base_url.trim().trim_end_matches('/').to_string(); price.source = "manual".to_string(); price.source_model_id.clear(); price.updated_at_ms = Local::now().timestamp_millis(); @@ -3083,9 +3179,18 @@ pub(crate) async fn save_usage_model_price(mut price: ModelPrice) -> Result<(), } #[tauri::command] -pub(crate) async fn delete_usage_model_price(model: String) -> Result<(), String> { +pub(crate) async fn delete_usage_model_price( + model: String, + provider: Option, + base_url: Option, +) -> Result<(), String> { run_usage_task(move || { let connection = open_usage_database()?; + let provider = provider.unwrap_or_default(); let base_url = base_url.unwrap_or_default(); + if !provider.is_empty() || !base_url.is_empty() { + connection.execute("DELETE FROM scoped_model_prices WHERE provider=?1 AND base_url=?2 AND model=?3",params![provider,base_url,model]).map_err(|e|e.to_string())?; + return Ok(()); + } connection .execute( "DELETE FROM model_prices WHERE model = ?1 COLLATE NOCASE", @@ -3122,10 +3227,13 @@ pub(crate) async fn sync_usage_model_prices( None => None, }; let now = Local::now().timestamp_millis(); - let (remote_prices, used_builtin) = match remote_content - .as_deref() - .and_then(|content| parse_model_price_catalog(content, "github", now).ok()) - { + let (remote_prices, used_builtin) = match remote_content.as_deref().and_then(|content| { + let catalog: ModelPriceCatalog = serde_json::from_str(content).ok()?; + if catalog.updated_at.as_str() < "2026-09-12" { + return None; + } + parse_model_price_catalog(content, "github", now).ok() + }) { Some(prices) => (prices, false), None => (bundled_model_prices()?, true), }; @@ -3160,10 +3268,6 @@ pub(crate) async fn sync_usage_model_prices( transaction .commit() .map_err(|error| format!("提交模型价格更新失败: {error}"))?; - } else { - connection - .execute("DELETE FROM model_prices WHERE source = 'github'", []) - .map_err(|error| format!("恢复软件内置模型价格失败: {error}"))?; } let filter = build_usage_filter(&query); @@ -3436,7 +3540,7 @@ fn load_usage_events( let sql = format!( r#" SELECT - event_key, timestamp, latency_ms, ttft_ms, source, auth_index, failed, + CAST(id AS TEXT), timestamp, latency_ms, ttft_ms, source, auth_index, failed, provider, model, alias, reasoning_effort, service_tier, response_service_tier, executor_type, endpoint, auth_type, api_key_hash, api_key_display, api_key_remark, request_id, @@ -3444,7 +3548,7 @@ fn load_usage_events( cached_tokens, collector_source, input_tokens, output_tokens, reasoning_tokens, cache_read_tokens, cache_creation_tokens, total_tokens, canceled, failure_status, - failure_body + failure_body, accounting_json FROM usage_events{} ORDER BY timestamp_ms DESC, id DESC LIMIT ? OFFSET ? @@ -3462,7 +3566,14 @@ fn load_usage_events( .map_err(|error| format!("查询 SQLite 使用事件失败: {error}"))? .collect::, _>>() .map_err(|error| format!("读取 SQLite 使用事件失败: {error}"))?; + let prices = load_model_prices(connection)?; for item in &mut items { + if !item.accounting.is_object() { + item.accounting = serde_json::json!({}); + } + if item.accounting.get("valuation").is_none() { + item.accounting["valuation"] = accounting::snapshot(item, &prices); + } item.source_display = usage_source_display(config, &item.provider, &item.source); } Ok(UsageEventPage { @@ -3476,6 +3587,7 @@ fn load_usage_events( fn usage_record_from_row(row: &Row<'_>) -> rusqlite::Result { Ok(UsageRecord { + accounting: serde_json::from_str(&row.get::<_, String>(36)?).unwrap_or_default(), id: row.get(0)?, timestamp: row.get(1)?, latency_ms: from_sql_i64(row.get(2)?), @@ -3659,6 +3771,16 @@ fn usage_source_display(config: &GuiConfigFile, provider: &str, source: &str) -> if source.is_empty() { return "未知来源".to_string(); } + if let Some(hash) = source.strip_prefix("sha256:") { + if let Some(entry) = config + .api_access_remarks + .iter() + .find(|entry| entry.api_key_hash == hash && !entry.remark.is_empty()) + { + return entry.remark.clone(); + } + return format!("sha256:{}", hash.chars().take(12).collect::()); + } if let Some(remark) = config.api_access_remark_for_source(provider, source) { return remark.to_string(); } @@ -3991,6 +4113,7 @@ mod tests { fn sample_record(id: &str, timestamp: &str, model: &str) -> UsageRecord { UsageRecord { + accounting: serde_json::json!({}), id: id.to_string(), timestamp: timestamp.to_string(), latency_ms: 100, @@ -4227,7 +4350,7 @@ mod tests { } #[test] - fn enforces_cache_input_invariant_for_unknown_producers() { + fn marks_inconsistent_cache_without_inventing_input_tokens() { let record = normalize_usage_record( serde_json::json!({ "provider": "custom", @@ -4242,9 +4365,9 @@ mod tests { &GuiConfigFile::default(), ) .unwrap(); - assert_eq!(record.tokens.input_tokens, 700); - assert_eq!(record.tokens.total_tokens, 720); - assert!(record.tokens.cache_read_tokens <= record.tokens.input_tokens); + assert_eq!(record.tokens.input_tokens, 100); + assert_eq!(record.tokens.total_tokens, 120); + assert_eq!(record.accounting["quality"], "inconsistent"); } #[test] @@ -5033,7 +5156,7 @@ mod tests { assert_eq!(overview.priced_requests, 1); assert_eq!(analysis.models[0].key, "gpt-5.6-terra"); assert_eq!(events.total, 1); - assert_eq!(events.items[0].id, "request-2"); + assert_eq!(events.items[0].request_id, "request-2"); drop(connection); fs::remove_dir_all(root).unwrap(); } @@ -5108,19 +5231,16 @@ mod tests { ..CostTokens::default() }; let standard = cost_for_price("openai/gpt-5.6-terra", "default", &standard_tokens, &terra); - assert!((standard - 13.69).abs() < 0.000001); + assert!((standard - 21.38).abs() < 0.000001); let long_tokens = CostTokens { input: 300_000, output: 200_000, cache_read: 100_000, - long_input: 300_000, - long_output: 200_000, - long_cache_read: 100_000, ..CostTokens::default() }; let long_priority = cost_for_price("gpt-5.6-terra", "priority", &long_tokens, &terra); - assert!((long_priority - 4.44).abs() < 0.000001); + assert!((long_priority - 8.88).abs() < 0.000001); assert!(official_model_price("unpriced-model").is_none()); } @@ -5195,3 +5315,7 @@ mod tests { fs::remove_dir_all(root).unwrap(); } } + +#[cfg(test)] +#[path = "usage/integrity_tests.rs"] +mod integrity_tests; diff --git a/src-tauri/src/usage/accounting.rs b/src-tauri/src/usage/accounting.rs new file mode 100644 index 00000000..193214d2 --- /dev/null +++ b/src-tauri/src/usage/accounting.rs @@ -0,0 +1,461 @@ +use super::*; +use chrono::{Datelike, Timelike}; +use serde_json::json; + +pub(super) fn normalize_accounting(value: &Value, tokens: &mut UsageTokenStats) -> Value { + let mut accounting = json!({"raw_tokens": value.get("tokens")}); + for key in [ + "transport", + "billing_id", + "cost_scope", + "event_id", + "attempt_id", + "generation_id", + "usage_complete", + "kind", + "base_url", + "session_id", + "parent_session_id", + "stream", + "usage_observed", + "raw_usage", + "cost_usd", + "cache_creation_5m_tokens", + "cache_creation_1h_tokens", + "accounting_version", + "token_breakdown", + ] { + if let Some(v) = value.get(key) { + accounting[key] = v.clone(); + } + } + let mut quality = "legacy"; + if let Some(b) = value.get("token_breakdown") { + if b["schema_version"].as_u64() == Some(2) { + tokens.input_tokens = b["input"]["total_tokens"].as_u64().unwrap_or(0); + tokens.output_tokens = b["output"]["total_tokens"].as_u64().unwrap_or(0); + tokens.reasoning_tokens = b["output"]["reasoning_tokens"].as_u64().unwrap_or(0); + tokens.cache_read_tokens = b["input"]["cache_read_tokens"].as_u64().unwrap_or(0); + tokens.cache_creation_tokens = b["input"]["cache_write_tokens"].as_u64().unwrap_or(0); + tokens.total_tokens = b["total_tokens"].as_u64().unwrap_or(0); + quality = if b["quality"] == "complete" + && b["unclassified_tokens"].as_u64() == Some(0) + && b["input"]["uncached_tokens"] + .as_u64() + .and_then(|n| n.checked_add(tokens.cache_read_tokens)) + .and_then(|n| n.checked_add(tokens.cache_creation_tokens)) + == Some(tokens.input_tokens) + && b["output"]["non_reasoning_tokens"] + .as_u64() + .and_then(|n| n.checked_add(tokens.reasoning_tokens)) + == Some(tokens.output_tokens) + && tokens.input_tokens.checked_add(tokens.output_tokens) + == Some(tokens.total_tokens) + { + "complete" + } else { + "inconsistent" + }; + } else { + quality = "unsupported"; + } + } else { + let identity = format!( + "{} {}", + value["provider"].as_str().unwrap_or(""), + value["executor_type"].as_str().unwrap_or("") + ) + .to_ascii_lowercase(); + if ["gemini", "antigravity", "vertex"] + .iter() + .any(|p| identity.contains(p)) + { + tokens.output_tokens = tokens.output_tokens.saturating_add(tokens.reasoning_tokens); + if value["tokens"]["total_tokens"].as_u64().unwrap_or(0) == 0 { + tokens.total_tokens = tokens.input_tokens.saturating_add(tokens.output_tokens); + } + } + if tokens.input_tokens.checked_add(tokens.output_tokens) != Some(tokens.total_tokens) + || tokens + .cache_read_tokens + .checked_add(tokens.cache_creation_tokens) + .is_none_or(|n| n > tokens.input_tokens) + || tokens.reasoning_tokens > tokens.output_tokens + { + quality = "inconsistent"; + } + } + if value["usage_observed"] == false && tokens.total_tokens == 0 { + quality = "unknown"; + } + if !value.get("usage_observed").is_some() && tokens.total_tokens == 0 { + quality = "unknown"; + } + if value["tokens"].as_object().is_some_and(|o| { + o.values().any(|v| { + v.as_i64().is_some_and(|n| n < 0) || v.as_u64().is_some_and(|n| n > i64::MAX as u64) + }) + }) { + quality = "inconsistent"; + } + if value["usage_complete"] == false { + quality = "partial"; + } + accounting["quality"] = json!(quality); + accounting +} + +pub(super) fn estimate(group: &UsageCostGroup, prices: &HashMap) -> Value { + if let Some(snapshot) = group.accounting.get("valuation") { + return snapshot.clone(); + } + let unknown = |reason: &str| json!({"status":"unknown", "reason":reason, "cost":null}); + // xAI reports cost in exact USD ticks. This takes precedence over estimates, + // including image/video charges that cannot be represented as text tokens. + if group.provider.eq_ignore_ascii_case("xai") { + if let Some(cost) = group.accounting["cost_usd"] + .as_str() + .and_then(|s| s.parse::().ok()) + .filter(|v| v.is_finite() && *v >= 0.0) + { + return json!({"status":"reported", "cost":cost, "currency":"USD", "source":"provider", "decimal":group.accounting["cost_usd"]}); + } + } + let quality = group.accounting["quality"].as_str().unwrap_or("legacy"); + if !matches!(quality, "complete" | "legacy") { + return unknown("usage_incomplete"); + } + if group.tokens.input.checked_add(group.tokens.output) != Some(group.total_tokens) + || group.total_tokens == 0 && quality != "complete" + { + return unknown("usage_missing"); + } + let Some(price) = price_for_group(group, prices) else { + return unknown("tariff_missing"); + }; + let model = normalized_model_tail(&price.model); + let tier = if group.response_service_tier.trim().is_empty() { + &group.service_tier + } else { + &group.response_service_tier + }; + let Some(cost) = tariff_cost( + &model, + tier, + &group.tokens, + &price, + &group.accounting, + &group.timestamp, + ) else { + return unknown("tariff_dimensions_missing"); + }; + json!({"status":"estimated", "cost":cost, "currency":"USD", "source":price.source, "model":price.model, "price":price, "rules_version":"2026-09-12", "tier":tier, "captured_at":Local::now().to_rfc3339()}) +} + +pub(super) fn tariff_cost( + model: &str, + tier: &str, + t: &CostTokens, + price: &ModelPrice, + a: &Value, + timestamp: &str, +) -> Option { + if price.source != "manual" && multimodal::supported(model) { + let raw = &a["raw_usage"]; + if raw["unpriced_server_tools"] == true + || raw.get("tool_usage").is_some() + || raw.get("server_tool_use").is_some() + || raw["web_search_calls"].as_u64().unwrap_or(0) > 0 + || raw["file_search_calls"].as_u64().unwrap_or(0) > 0 + { + return None; + } + return multimodal::cost(model, &tier.trim().to_ascii_lowercase(), t, &a["raw_usage"]); + } + let price = enriched_model_price(model, price); + if t.cache_read > 0 && !price.cache_read_configured && price.cache <= 0.0 + || t.cache_creation > 0 && !price.cache_creation_configured + { + return None; + } + let prompt = t + .input + .checked_sub(t.cache_read.checked_add(t.cache_creation)?)?; + let mut input = price.prompt; + let mut output = price.completion; + let mut read = price.cache_read; + let mut write = price.cache_creation; + let tier = tier.trim().to_ascii_lowercase(); + let manual = price.source == "manual"; + if !manual { + let long_openai = matches!( + model, + "gpt-6-astra" + | "gpt-5.6" + | "gpt-5.6-sol" + | "gpt-5.6-terra" + | "gpt-5.6-luna" + | "gpt-5.5" + | "gpt-5.5-pro" + | "gpt-5.4" + | "gpt-5.4-pro" + ) && t.input > 272_000; + let long_gemini = matches!( + model, + "gemini-2.5-pro" | "gemini-3-pro-preview" | "gemini-3.1-pro-preview" | "gemini-3.1-pro" + ) && t.input > 200_000; + let long_grok = matches!( + model, + "grok-4.6" + | "grok-4.5" + | "grok-4.3" + | "grok-4.20" + | "grok-4.20-multi-agent" + | "grok-build-0.1" + ) && t.input >= 200_000; + if long_openai || long_gemini || long_grok { + input *= 2.0; + read *= 2.0; + write *= 2.0; + output *= if long_grok { 2.0 } else { 1.5 }; + } + match tier.as_str() { + "" | "default" | "auto" | "standard" => {} + "priority" | "fast" => { + // Unpublished mode/context combinations must not inherit standard rates. + if long_openai + && matches!(model, "gpt-5.5" | "gpt-5.5-pro" | "gpt-5.4" | "gpt-5.4-pro") + { + return None; + } + let mult = match model { + "gpt-6-astra" | "gpt-5.6" | "gpt-5.6-sol" | "gpt-5.6-terra" + | "gpt-5.6-luna" | "gpt-5.4" | "gpt-5.4-mini" | "gpt-5.3-codex" | "gpt-5.2" + | "gpt-5.2-codex" | "gpt-5.1" | "gpt-5" | "gpt-4.1-nano" => 2.0, + "gpt-5.5" => 2.5, + "gpt-5-mini" => 1.8, + "gpt-4.1" | "gpt-4.1-mini" | "gpt-4o-2024-05-13" | "o3" => 1.75, + "gpt-4o" => 1.7, + "gpt-4o-mini" => 5.0 / 3.0, + "o4-mini" => 20.0 / 11.0, + "claude-opus-5" | "claude-opus-4-8" => 2.0, + _ => return None, + }; + input *= mult; + output *= mult; + read *= mult; + write *= mult; + } + "batch" | "flex" => { + if model.starts_with("gemini-") { + input *= 0.5; + output *= 0.5; /* cached input stays at standard rates */ + } else if model.starts_with("claude-") && tier == "batch" { + input *= 0.5; + output *= 0.5; + read *= 0.5; + write *= 0.5; + } else if matches!( + model, + "gpt-6-astra" + | "gpt-5.6" + | "gpt-5.6-sol" + | "gpt-5.6-terra" + | "gpt-5.6-luna" + | "gpt-5.5" + | "gpt-5.4" + | "gpt-5.4-mini" + | "gpt-5.4-nano" + | "gpt-5.2" + | "gpt-5.1" + | "gpt-5" + | "gpt-5-mini" + | "gpt-5-nano" + | "o3" + | "o4-mini" + ) { + input *= 0.5; + output *= 0.5; + read *= 0.5; + write *= 0.5; + } else { + return None; + } + } + _ => return None, + } + if model.starts_with("deepseek-") { + let dt = DateTime::parse_from_rfc3339(timestamp) + .ok()? + .with_timezone(&chrono::Utc); + // Current rates cannot be applied retrospectively to pre-audit events. + if dt.format("%Y-%m-%d").to_string().as_str() < "2026-09-12" { + return None; + } + let peak = dt.weekday().number_from_monday() <= 5 + && ((1..4).contains(&dt.hour()) || (6..10).contains(&dt.hour())); + if !peak { + input *= 0.5; + output *= 0.5; + read *= 0.5; + } + } + } + let mut cache_cost = t.cache_creation as f64 * write; + if model.starts_with("claude-") && t.cache_creation > 0 && !manual { + let hour = a["cache_creation_1h_tokens"].as_u64().unwrap_or(0); + let five = a["cache_creation_5m_tokens"].as_u64().unwrap_or(0); + if hour.checked_add(five)? != t.cache_creation { + return None; + } + cache_cost = five as f64 * write + hour as f64 * input * 2.0; + } + // Non-text modalities and server tools have independent prices. Preserve raw + // dimensions, but do not label a text-only estimate as complete coverage. + let raw = &a["raw_usage"]; + let mut tool_cost = 0.0; + if raw["unpriced_server_tools"] == true { + return None; + } + if let Some(tools) = raw["tool_usage"].as_object() { + if tools.keys().any(|key| key != "image_gen") { + return None; + } + } + if let Some(tools) = raw["server_tool_use"].as_object() { + for (key, value) in tools { + let count = value.as_u64()?; + match key.as_str() { + "web_search_requests" => tool_cost += count as f64 * 0.01, + "web_fetch_requests" => {} + _ if count > 0 => return None, + _ => {} + } + } + } + let web_calls = raw["web_search_calls"].as_u64().unwrap_or(0); + if web_calls > 0 { + return None; + } // The response does not distinguish standard and legacy preview billing. + tool_cost += + web_calls as f64 * 0.01 + raw["file_search_calls"].as_u64().unwrap_or(0) as f64 * 0.0025; + for path in [ + "/input_token_details/audio_tokens", + "/output_token_details/audio_tokens", + "/prompt_tokens_details/audio_tokens", + "/completion_tokens_details/audio_tokens", + ] { + if raw.pointer(path).and_then(Value::as_u64).unwrap_or(0) > 0 && !manual { + return None; + } + } + let audio_count = |key: &str| { + raw[key] + .as_array() + .map(|parts| { + parts + .iter() + .filter(|p| { + p["modality"] + .as_str() + .is_some_and(|m| m.eq_ignore_ascii_case("AUDIO")) + }) + .map(|p| p["tokenCount"].as_u64().unwrap_or(0)) + .sum::() + }) + .unwrap_or(0) + }; + let audio_input = audio_count("promptTokensDetails"); + let audio_output = + audio_count("candidatesTokensDetails").max(audio_count("responseTokensDetails")); + let mut audio_adjustment = 0.0; + if !manual && (audio_input > 0 || audio_output > 0) { + if model != "gemini-2.5-flash" || audio_output > 0 { + return None; + } + if t.cache_read > 0 && !raw["cacheTokensDetails"].is_array() { + return None; + } + let audio_cached = audio_count("cacheTokensDetails"); + let audio_uncached = audio_input.checked_sub(audio_cached)?; + if audio_uncached > prompt || audio_cached > t.cache_read { + return None; + } + audio_adjustment = audio_uncached as f64 * input * (1.0 / 0.3 - 1.0) + + audio_cached as f64 * read * (1.0 / 0.3 - 1.0); + } + let mut cost = (prompt as f64 * input + + t.output as f64 * output + + t.cache_read as f64 * read + + cache_cost + + audio_adjustment) + / TOKENS_PER_PRICE_UNIT; + if !manual + && model.starts_with("claude-") + && raw["inference_geo"] + .as_str() + .is_some_and(|geo| geo.eq_ignore_ascii_case("us")) + { + cost *= 1.1; + } + cost += tool_cost; + cost.is_finite().then_some(cost) +} + +pub(super) fn snapshot(record: &UsageRecord, prices: &HashMap) -> Value { + let group = UsageCostGroup { + model: record.model.clone(), + alias: record.alias.clone(), + provider: record.provider.clone(), + executor_type: record.executor_type.clone(), + auth_type: record.auth_type.clone(), + service_tier: record.service_tier.clone(), + response_service_tier: record.response_service_tier.clone(), + requests: 1, + total_tokens: record.tokens.total_tokens, + timestamp: record.timestamp.clone(), + accounting: record.accounting.clone(), + tokens: CostTokens { + input: record.tokens.input_tokens, + output: record.tokens.output_tokens, + cache_read: record.tokens.cache_read_tokens, + cache_creation: record.tokens.cache_creation_tokens, + ..CostTokens::default() + }, + }; + estimate(&group, prices) +} + +// Sanitize before the raw inbox write, including legacy and direct probe events. +pub(super) fn redact_credentials(message: String) -> String { + let Ok(mut value) = serde_json::from_str::(&message) else { + return message; + }; + let Some(fields) = value.as_object_mut() else { + return message; + }; + let key = fields + .remove("api_key") + .and_then(|v| v.as_str().map(str::to_string)) + .unwrap_or_default(); + if !key.is_empty() { + fields.insert("api_key_hash".into(), json!(hash_text(&key))); + fields.insert("api_key_display".into(), json!(mask_api_key(&key))); + } + let auth = fields + .get("auth_type") + .and_then(Value::as_str) + .unwrap_or(""); + let source = fields.get("source").and_then(Value::as_str).unwrap_or(""); + if !source.is_empty() + && !source.starts_with("sha256:") + && (source == key || matches!(auth, "api_key" | "apikey")) + { + fields.insert( + "source".into(), + json!(format!("sha256:{}", hash_text(source))), + ); + } + fields.remove("response_headers"); + serde_json::to_string(&value).unwrap_or(message) +} diff --git a/src-tauri/src/usage/integrity_tests.rs b/src-tauri/src/usage/integrity_tests.rs new file mode 100644 index 00000000..c5d8fd5f --- /dev/null +++ b/src-tauri/src/usage/integrity_tests.rs @@ -0,0 +1,405 @@ +use serde_json::json; + +use super::*; +fn record(model: &str, input: u64, output: u64) -> Value { + json!({"request_id":"r1","timestamp":"2026-09-12T12:00:00Z","model":model,"provider":"openai","tokens":{"input_tokens":input,"output_tokens":output,"total_tokens":input+output}}) +} +fn db(values: Vec) -> Connection { + let mut c = Connection::open_in_memory().unwrap(); + initialize_usage_schema(&c).unwrap(); + let records = values + .into_iter() + .map(|v| normalize_usage_record(v, &GuiConfigFile::default()).unwrap()) + .collect::>(); + insert_usage_records(&mut c, &records).unwrap(); + c +} +fn cost(values: Vec) -> f64 { + let c = db(values); + load_usage_pricing(&c, &UsageQuery::default()) + .unwrap() + .total_cost +} +fn check(actual: f64, expected: f64) { + println!("actual=${actual:.9} expected=${expected:.9}"); + assert!( + (actual - expected).abs() < 1e-10, + "actual={actual}, expected={expected}" + ); +} +#[test] +fn control_openai_cached_reasoning_subset() { + let mut v = record("gpt-5.4", 10000, 1000); + v["tokens"]["cache_read_tokens"] = json!(4000); + v["tokens"]["reasoning_tokens"] = json!(600); + check(cost(vec![v]), 0.031); +} +#[test] +fn control_claude_independent_cache() { + let mut v = record("claude-sonnet-4-6", 1000, 1000); + v["provider"] = json!("claude"); + v["tokens"]["cache_read_tokens"] = json!(4000); + v["tokens"]["cache_creation_tokens"] = json!(2000); + v["tokens"]["total_tokens"] = json!(8000); + v["cache_creation_5m_tokens"] = json!(2000); + check(cost(vec![v]), 0.0267); +} +#[test] +fn gemini_thinking_is_billable_output() { + let mut v = record("gemini-2.5-pro", 10000, 1000); + v["provider"] = json!("gemini"); + v["tokens"]["reasoning_tokens"] = json!(9000); + v["tokens"]["total_tokens"] = json!(20000); + check(cost(vec![v]), 0.1125); +} +#[test] +fn gpt41_has_no_long_context_surcharge() { + check(cost(vec![record("gpt-4.1", 300000, 1000)]), 0.608); +} +#[test] +fn claude46_has_no_long_context_surcharge() { + check(cost(vec![record("claude-sonnet-4-6", 300000, 1000)]), 0.915); +} +#[test] +fn gemini_pro_threshold_is_200k() { + check(cost(vec![record("gemini-2.5-pro", 250000, 1000)]), 0.64); +} +#[test] +fn astra_fast_has_premium() { + let mut v = record("gpt-6-astra", 10000, 1000); + v["service_tier"] = json!("fast"); + check(cost(vec![v]), 0.3); +} +#[test] +fn gpt52_fast_has_premium() { + let mut v = record("gpt-5.2", 10000, 1000); + v["service_tier"] = json!("fast"); + check(cost(vec![v]), 0.063); +} +#[test] +fn sol_long_fast_has_premium() { + let mut v = record("gpt-5.6-sol", 300000, 1000); + v["service_tier"] = json!("fast"); + check(cost(vec![v]), 4.86); +} +#[test] +fn grouping_must_preserve_additivity() { + let mut a = record("gpt-5.6-sol", 10000, 1000); + a["service_tier"] = json!("fast"); + let mut b = record("gpt-5.6-sol", 300000, 1000); + b["service_tier"] = json!("fast"); + b["request_id"] = json!("r2"); + check( + cost(vec![a.clone(), b.clone()]), + cost(vec![a]) + cost(vec![b]), + ); +} +#[test] +fn dated_model_must_not_inherit_different_snapshot_price() { + check(cost(vec![record("gpt-4o-2024-05-13", 10000, 1000)]), 0.065); +} +#[test] +fn deepseek_flash_current_peak_price() { + let mut v = record("deepseek-v4-flash", 10000, 1000); + v["timestamp"] = json!("2026-09-14T02:00:00Z"); + check(cost(vec![v]), 0.0042); +} +#[test] +fn unknown_model_does_not_mean_zero_priced() { + let c = db(vec![record("unlisted-model", 10000, 1000)]); + let p = load_usage_pricing(&c, &UsageQuery::default()).unwrap(); + assert_eq!(p.priced_requests, 0); + check(p.total_cost, 0.0); +} +#[test] +fn event_count_preserves_attempts_and_prewarm_under_one_request() { + let mut a = record("gpt-5.4", 0, 0); + a["generate"] = json!(false); + let mut b = record("gpt-5.4", 0, 0); + b["failed"] = json!(true); + let c = db(vec![a, b, record("gpt-5.4", 100, 10)]); + let p = load_usage_pricing(&c, &UsageQuery::default()).unwrap(); + assert_eq!(p.total_requests, 3); + assert_eq!( + c.query_row( + "SELECT COUNT(DISTINCT request_id) FROM usage_events", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 1 + ); +} +#[test] +fn v2_breakdown_overrides_legacy_fields() { + let mut v = record("gpt-5.4", 0, 0); + v["tokens"]["total_tokens"] = json!(10000); + v["accounting_version"] = json!(2); + v["token_breakdown"] = json!({"schema_version":2,"quality":"complete","total_tokens":10000,"input":{"total_tokens":9000,"uncached_tokens":9000,"cache_read_tokens":0,"cache_write_tokens":0},"output":{"total_tokens":1000,"non_reasoning_tokens":1000,"reasoning_tokens":0},"unclassified_tokens":0}); + check(cost(vec![v]), 0.0375); +} + +#[test] +fn grok_long_context_starts_at_200k() { + check(cost(vec![record("grok-4.6", 250000, 1000)]), 1.012); +} +#[test] +fn grok_long_context_output_multiplier_is_two() { + check(cost(vec![record("grok-4.6", 300000, 10000)]), 1.32); +} +#[test] +fn claude_hour_cache_write_rate() { + let mut v = record("claude-sonnet-4-6", 0, 0); + v["provider"] = json!("claude"); + v["tokens"]["cache_creation_tokens"] = json!(100000); + v["tokens"]["total_tokens"] = json!(100000); + v["cache_creation_1h_tokens"] = json!(100000); + check(cost(vec![v]), 0.6); +} +#[test] +fn usage_with_only_total_must_not_be_marked_fully_priced() { + let mut v = record("gpt-5.4", 0, 0); + v["tokens"]["total_tokens"] = json!(10000); + let c = db(vec![v]); + assert_eq!( + load_usage_pricing(&c, &UsageQuery::default()) + .unwrap() + .priced_requests, + 0 + ); +} +#[test] +fn replay_deduplicates_event_not_request() { + let mut a = record("gpt-5.4", 100, 10); + a["event_id"] = json!("event-a"); + let mut b = a.clone(); + b["event_id"] = json!("event-b"); + let c = db(vec![a.clone(), a, b]); + assert_eq!( + load_usage_pricing(&c, &UsageQuery::default()) + .unwrap() + .total_requests, + 2 + ); + let p = load_usage_events(&c, &UsageQuery::default(), &GuiConfigFile::default()).unwrap(); + assert_ne!(p.items[0].id, p.items[1].id); +} +#[test] +fn provider_reported_cost_without_text_tokens_is_preserved() { + let mut a = record("grok-imagine-video", 0, 0); + a["provider"] = json!("xai"); + a["cost_usd"] = json!("0.0123456789"); + check(cost(vec![a]), 0.0123456789); +} +#[test] +fn unknown_cache_ttl_is_not_fully_priced() { + let mut a = record("claude-sonnet-4-6", 0, 0); + a["provider"] = json!("claude"); + a["tokens"]["cache_creation_tokens"] = json!(1000); + let c = db(vec![a]); + assert_eq!( + load_usage_pricing(&c, &UsageQuery::default()) + .unwrap() + .priced_requests, + 0 + ); +} +#[test] +fn tariff_snapshot_survives_manual_price_change() { + let c = db(vec![record("gpt-5.4", 10000, 1000)]); + let before = load_usage_pricing(&c, &UsageQuery::default()) + .unwrap() + .total_cost; + let mut p = official_model_price("gpt-5.4").unwrap(); + p.source = "manual".into(); + p.prompt = 999.; + upsert_model_price(&c, &p).unwrap(); + check( + load_usage_pricing(&c, &UsageQuery::default()) + .unwrap() + .total_cost, + before, + ); +} +#[test] +fn media_variant_does_not_inherit_text_price() { + assert!(official_model_price("gemini-2.5-flash-image").is_none()); +} +#[test] +fn response_tier_overrides_requested_codex_tier() { + let mut a = record("gpt-5.4", 10000, 1000); + a["provider"] = json!("codex"); + a["service_tier"] = json!("priority"); + a["response_service_tier"] = json!("default"); + check(cost(vec![a]), 0.04); +} + +#[test] +fn provider_and_upstream_prices_are_independent() { + let mut c = Connection::open_in_memory().unwrap(); + initialize_usage_schema(&c).unwrap(); + let mut price = official_model_price("gpt-5.4").unwrap(); + price.source = "manual".into(); + price.provider = "gateway".into(); + price.base_url = "https://one.example/v1".into(); + price.prompt = 1.; + price.completion = 2.; + upsert_model_price(&c, &price).unwrap(); + let mut a = record("gpt-5.4", 10000, 1000); + a["provider"] = json!("gateway"); + a["base_url"] = json!("https://one.example/v1"); + let mut b = a.clone(); + b["base_url"] = json!("https://two.example/v1"); + let config = GuiConfigFile::default(); + insert_usage_records( + &mut c, + &[ + normalize_usage_record(a, &config).unwrap(), + normalize_usage_record(b, &config).unwrap(), + ], + ) + .unwrap(); + check( + load_usage_pricing(&c, &UsageQuery::default()) + .unwrap() + .total_cost, + 0.012 + 0.04, + ); +} +#[test] +fn repeated_video_polling_counts_only_incremental_charge() { + let events = ["0.10", "0.10", "0.12"] + .into_iter() + .map(|cost| { + let mut v = record("grok-video", 0, 0); + v["provider"] = json!("xai"); + v["cost_usd"] = json!(cost); + v["billing_id"] = json!("xai-video/job1"); + v["cost_scope"] = json!("operation"); + v + }) + .collect(); + let c = db(events); + let p = load_usage_pricing(&c, &UsageQuery::default()).unwrap(); + check(p.total_cost, 0.12); + assert_eq!(p.total_requests, 3); +} +#[test] +fn context_threshold_boundaries_are_model_specific() { + check(cost(vec![record("gemini-2.5-pro", 200000, 1000)]), 0.26); + check( + cost(vec![record("gemini-2.5-pro", 200001, 1000)]), + 0.5150025, + ); + check(cost(vec![record("grok-4.6", 199999, 1000)]), 0.405998); + check(cost(vec![record("grok-4.6", 200000, 1000)]), 0.812); +} +#[test] +fn endpoint_and_transport_filters_preserve_attempts() { + let mut a = record("gpt-5.4", 100, 10); + a["endpoint"] = json!("POST /v1/responses"); + a["stream"] = json!(true); + a["kind"] = json!("attempt"); + let mut b = a.clone(); + b["executor_type"] = json!("CodexWebsocketsExecutor"); + let c = db(vec![a, b]); + let query = UsageQuery { + endpoint: Some("POST /v1/responses".into()), + transport: Some("sse".into()), + ..UsageQuery::default() + }; + assert_eq!( + load_usage_events(&c, &query, &GuiConfigFile::default()) + .unwrap() + .total, + 1 + ); +} + +#[test] +fn image_tool_has_separate_text_and_image_rates() { + let mut v = record("gpt-image-2", 100, 1000); + v["raw_usage"] = json!({"input_tokens_details":{"text_tokens":20,"image_tokens":80},"input_tokens":100,"output_tokens":1000}); + check(cost(vec![v]), 0.03074); +} +#[test] +fn realtime_audio_text_and_cached_audio_are_separate() { + let mut v = record("gpt-realtime", 100, 1000); + v["tokens"]["cache_read_tokens"] = json!(30); + v["raw_usage"] = json!({"input_token_details":{"text_tokens":20,"audio_tokens":80,"cached_tokens":30,"cached_tokens_details":{"text_tokens":10,"audio_tokens":20}},"output_token_details":{"text_tokens":100,"audio_tokens":900}}); + check(cost(vec![v]), 0.061172); +} +#[test] +fn image_tool_never_inherits_parent_text_tariff() { + let mut v = record("future-image-model", 100, 1000); + v["alias"] = json!("gpt-5.4"); + v["kind"] = json!("tool"); + let c = db(vec![v]); + assert_eq!( + load_usage_pricing(&c, &UsageQuery::default()) + .unwrap() + .priced_requests, + 0 + ); +} + +#[test] +fn multimodal_with_unpriced_hosted_tools_is_not_fully_valued() { + let mut v = record("gpt-image-2", 100, 1000); + v["raw_usage"] = json!({"input_tokens_details":{"text_tokens":20,"image_tokens":80},"unpriced_server_tools":true}); + let c = db(vec![v]); + assert_eq!( + load_usage_pricing(&c, &UsageQuery::default()) + .unwrap() + .priced_requests, + 0 + ); +} +#[test] +fn hosted_file_search_and_claude_search_have_separate_fees() { + let mut v = record("gpt-5.4", 10000, 1000); + v["raw_usage"] = json!({"file_search_calls":2}); + check(cost(vec![v]), 0.045); + let mut v = record("claude-sonnet-4-6", 1000, 1000); + v["raw_usage"] = json!({"server_tool_use":{"web_search_requests":2}}); + check(cost(vec![v]), 0.038); +} +#[test] +fn gemini_flash_audio_input_uses_audio_price() { + let mut v = record("gemini-2.5-flash", 1000, 100); + v["raw_usage"] = json!({"promptTokensDetails":[{"modality":"TEXT","tokenCount":400},{"modality":"AUDIO","tokenCount":600}]}); + check(cost(vec![v]), 0.00097); +} + +#[test] +fn fingerprinted_source_remains_readable_without_exposing_a_key() { + assert_eq!( + usage_source_display( + &GuiConfigFile::default(), + "openai", + "sha256:123456789012abcdef" + ), + "sha256:123456789012" + ); +} + +#[test] +fn inbox_does_not_persist_legacy_or_probe_credentials() { + let mut c = Connection::open_in_memory().unwrap(); + initialize_usage_schema(&c).unwrap(); + let mut v = record("gpt-5.4", 100, 10); + v["api_key"] = json!("test-downstream-secret"); + v["source"] = json!("test-upstream-secret"); + v["auth_type"] = json!("apikey"); + enqueue_usage_queue_items(&mut c, "desktop_health_check", vec![v]).unwrap(); + let raw: String = c + .query_row("SELECT raw_message FROM usage_inbox", [], |row| row.get(0)) + .unwrap(); + assert!(!raw.contains("test-downstream-secret") && !raw.contains("test-upstream-secret")); + let value: Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(value["api_key_hash"], hash_text("test-downstream-secret")); + assert_eq!( + process_usage_inbox(&mut c, &GuiConfigFile::default()).unwrap(), + 1 + ); +} diff --git a/src-tauri/src/usage/multimodal.rs b/src-tauri/src/usage/multimodal.rs new file mode 100644 index 00000000..9f584d81 --- /dev/null +++ b/src-tauri/src/usage/multimodal.rs @@ -0,0 +1,111 @@ +use super::*; + +// USD per million tokens: input, cached input, output. A negative value means +// that the provider does not publish/support that modality for this model. +// Source: https://developers.openai.com/api/docs/pricing (2026-09-12). +fn rates(model: &str) -> Option<[[f64; 3]; 3]> { + let table = match model { + "gpt-image-2" | "gpt-image-2.5-sunburst" | "gpt-image-2.5-flare" => { + [[5., 1.25, -1.], [-1., -1., -1.], [8., 2., 30.]] + } + "gpt-image-1.5" | "chatgpt-image-latest" => { + [[5., 1.25, 10.], [-1., -1., -1.], [8., 2., 32.]] + } + "gpt-image-1-mini" => [[2., 0.2, -1.], [-1., -1., -1.], [2.5, 0.25, 8.]], + "gpt-image-1" => [[5., 1.25, -1.], [-1., -1., -1.], [10., 2.5, 40.]], + "gpt-realtime-2.1" | "gpt-realtime-2" => [[4., 0.4, 24.], [32., 0.4, 64.], [5., 0.5, -1.]], + "gpt-realtime" | "gpt-realtime-1.5" => [[4., 0.4, 16.], [32., 0.4, 64.], [5., 0.5, -1.]], + "gpt-realtime-mini" | "gpt-realtime-2.1-mini" => { + [[0.6, 0.06, 2.4], [10., 0.3, 20.], [0.8, 0.08, -1.]] + } + "gpt-audio" | "gpt-audio-1.5" => [[2.5, -1., 10.], [32., -1., 64.], [-1., -1., -1.]], + "gpt-audio-mini" => [[0.6, -1., 2.4], [10., -1., 20.], [-1., -1., -1.]], + _ => return None, + }; + Some(table) +} + +pub(super) fn supported(model: &str) -> bool { + rates(model).is_some() +} + +pub(super) fn cost(model: &str, tier: &str, t: &CostTokens, raw: &Value) -> Option { + let mut rates = rates(model)?; + match tier { + "" | "auto" | "default" | "standard" => {} + "batch" + if matches!( + model, + "gpt-image-2" + | "gpt-image-1.5" + | "gpt-image-1-mini" + | "gpt-image-1" + | "chatgpt-image-latest" + ) => + { + for modality in &mut rates { + for rate in modality { + if *rate >= 0. { + *rate *= 0.5; + } + } + } + // Published batch cached rates are rounded independently. + if matches!( + model, + "gpt-image-1.5" | "gpt-image-1" | "chatgpt-image-latest" + ) { + rates[0][1] = 0.63; + } + if model == "gpt-image-1-mini" { + rates[2][1] = 0.13; + } + } + _ => return None, + } + let input = raw + .get("input_token_details") + .or_else(|| raw.get("input_tokens_details")) + .or_else(|| raw.get("prompt_tokens_details")); + let output = raw + .get("output_token_details") + .or_else(|| raw.get("output_tokens_details")) + .or_else(|| raw.get("completion_tokens_details")); + let counts = |node: Option<&Value>| -> [u64; 3] { + ["text_tokens", "audio_tokens", "image_tokens"] + .map(|key| node.and_then(|v| v[key].as_u64()).unwrap_or(0)) + }; + let input_counts = counts(input); + let mut output_counts = counts(output); + let cached_counts = counts(input.and_then(|v| v.get("cached_tokens_details"))); + if output.is_none() && model.starts_with("gpt-image-") && rates[0][2] < 0. { + output_counts[2] = t.output; + } + // Some audio Chat Completions responses omit text counts, but do not infer + // them from a remainder: unsupported image/video dimensions could be hidden. + let sum = |values: [u64; 3]| values.into_iter().try_fold(0_u64, u64::checked_add); + if sum(input_counts) != Some(t.input) + || sum(output_counts) != Some(t.output) + || sum(cached_counts) != Some(t.cache_read) + || t.cache_creation != 0 + { + return None; + } + let mut cost = 0.; + for i in 0..3 { + let uncached = input_counts[i].checked_sub(cached_counts[i])?; + for (count, rate) in [ + (uncached, rates[i][0]), + (cached_counts[i], rates[i][1]), + (output_counts[i], rates[i][2]), + ] { + if count > 0 { + if rate < 0. { + return None; + }; + cost += count as f64 * rate; + } + } + } + cost.is_finite().then_some(cost / TOKENS_PER_PRICE_UNIT) +} diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 65823554..29191695 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -2,6 +2,19 @@ import type { MessageKey } from './locales/zh-CN'; import { easyModeJa } from './easyMode'; export const jaOverrides = { + 'usage.filter.endpointPlaceholder': 'POST /v1/responses', + 'usage.transport.http': 'HTTP', + 'usage.transport.sse': 'SSE', + 'usage.kind.prewarm': 'ウォームアップ', + 'usage.kind.legacy': '従来のイベント', + 'usage.pricing.providerScope': 'プロバイダー(任意)', + 'usage.pricing.upstreamScope': '上流ベース URL(任意)', + 'usage.column.endpoint': 'エンドポイント', + 'usage.column.transport': '転送方式', + 'usage.column.kind': 'イベント種別', + 'usage.column.accounting': '使用量の品質', + 'usage.column.cost': 'イベント費用 (USD)', + 'usage.accounting.note': '件数は使用量イベントです。再試行、ツール、プローブと制御操作を区別します。不明な費用は無料ではなく合計対象外です。保存済み見積りは当時の料金を保持します。', ...easyModeJa, 'apiAccess.provider.codex': 'Codex API', 'apiAccess.provider.deepseek': 'DeepSeek', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index cbe6e00f..e308f609 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -2,6 +2,19 @@ import type { MessageKey } from './zh-CN'; import { easyModeEn } from '../easyMode'; export const en: Record = { + 'usage.filter.endpointPlaceholder': 'POST /v1/responses', + 'usage.transport.http': 'HTTP', + 'usage.transport.sse': 'SSE', + 'usage.kind.prewarm': 'Prewarm', + 'usage.kind.legacy': 'Legacy event', + 'usage.pricing.providerScope': 'Provider (optional)', + 'usage.pricing.upstreamScope': 'Upstream base URL (optional)', + 'usage.column.endpoint': 'Endpoint', + 'usage.column.transport': 'Transport', + 'usage.column.kind': 'Event kind', + 'usage.column.accounting': 'Usage quality', + 'usage.column.cost': 'Event cost (USD)', + 'usage.accounting.note': 'Counts are usage events; retries, tools, probes and control operations are separate. Known generations use core IDs. Unknown costs are excluded from the total, not treated as free. Saved estimates retain their tariff; legacy estimates use available rates.', ...easyModeEn, 'apiAccess.provider.codex': 'Codex API', 'apiAccess.provider.deepseek': 'DeepSeek', @@ -388,7 +401,7 @@ export const en: Record = { 'usage.filter.endTime': 'End time', 'usage.filter.to': 'to', 'usage.loading': 'Loading local SQLite usage history', - 'usage.stat.requests': 'Total Requests', + 'usage.stat.requests': 'Usage Events', 'usage.stat.requestMeta': 'OK {success} · Fail {failed} · Canceled {canceled}', 'usage.stat.requestMetaTitle': '{total} requests: {success} succeeded, {failed} failed, {canceled} canceled', 'usage.stat.tokens': 'Total Tokens', @@ -405,9 +418,9 @@ export const en: Record = { 'usage.stat.cacheHitMeta': 'Cache read {hit} · Input {input}', 'usage.stat.cacheHitMetaTitle': 'Cache hit rate {rate}%, cache read {hit} tokens, input {input} tokens', 'usage.stat.estimatedCost': 'Estimated Cost', - 'usage.stat.costMeta': 'Price coverage: {priced} / {total} requests', - 'usage.stat.costMetaTitle': '{priced} of {total} requests estimated from model prices; {unpriced} unpriced', - 'usage.pricing.coverage': '{priced} / {total} requests priced · {saved} model prices available', + 'usage.stat.costMeta': 'Cost coverage: {priced} / {total} events', + 'usage.stat.costMetaTitle': '{priced} of {total} events have a reported cost or supported estimate; {unpriced} unknown', + 'usage.pricing.coverage': '{priced} / {total} events valued · {saved} model prices available', 'usage.pricing.search': 'Search model', 'usage.pricing.add': 'Add Manually', 'usage.pricing.sync': 'Sync Prices', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 778e0c4d..3edb18aa 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -1,6 +1,19 @@ import { easyModeZhCN } from '../easyMode'; export const zhCN = { + 'usage.filter.endpointPlaceholder': 'POST /v1/responses', + 'usage.transport.http': 'HTTP', + 'usage.transport.sse': 'SSE', + 'usage.kind.prewarm': '预热', + 'usage.kind.legacy': '历史事件', + 'usage.pricing.providerScope': '供应商(可选)', + 'usage.pricing.upstreamScope': '上游基础 URL(可选)', + 'usage.column.endpoint': '端点', + 'usage.column.transport': '传输方式', + 'usage.column.kind': '事件类型', + 'usage.column.accounting': '计量质量', + 'usage.column.cost': '事件成本 (USD)', + 'usage.accounting.note': '计数为用量事件,重试、工具、探测及控制操作分别记录。未知成本不计入总额,不代表免费。已保存估算保留当时费率;历史记录使用可用费率。', ...easyModeZhCN, 'apiAccess.provider.codex': 'Codex API', 'apiAccess.provider.deepseek': 'DeepSeek', diff --git a/src/pages/UsageRecordsPage.tsx b/src/pages/UsageRecordsPage.tsx index 536b71a6..5882c7ac 100644 --- a/src/pages/UsageRecordsPage.tsx +++ b/src/pages/UsageRecordsPage.tsx @@ -52,6 +52,7 @@ type TimelinePoint = { }; type UsageOverview = { + eventCounts?: Record; totalRequests: number; successCount: number; failureCount: number; @@ -90,6 +91,12 @@ type UsageAnalysis = { }; type UsageRecord = { + generate: boolean; + executor_type: string; + accounting?: { + quality?: string; kind?: string; transport?: string; stream?: boolean; session_id?: string; generation_id?: string; attempt_id?: string; + valuation?: { status: string; cost?: number | null; reason?: string; source?: string }; + }; id: string; timestamp: string; latency_ms: number; @@ -127,6 +134,7 @@ type UsageEventPage = { }; type ModelPrice = { + provider: string; baseUrl: string; model: string; prompt: number; completion: number; @@ -177,6 +185,7 @@ type ModelPriceSyncResult = { }; type UsageQuery = { + endpoint?: string; transport?: string; kind?: string; start?: string; end?: string; model?: string; @@ -280,6 +289,9 @@ export function UsageRecordsPage() { const [customEnd, setCustomEnd] = useState(''); const [model, setModel] = useState(''); const [provider, setProvider] = useState(''); + const [endpoint, setEndpoint] = useState(''); + const [transport, setTransport] = useState(''); + const [kind, setKind] = useState(''); const [source, setSource] = useState(''); const [apiKeyHash, setApiKeyHash] = useState(''); const [result, setResult] = useState('all'); @@ -321,13 +333,16 @@ export function UsageRecordsPage() { ...nextTimeQuery, model: model || undefined, provider: provider || undefined, + endpoint: endpoint || undefined, + transport: transport || undefined, + kind: kind || undefined, source: source || undefined, api_key_hash: apiKeyHash || undefined, failed: result === 'failed' ? true : result === 'success' ? false : undefined, canceled: result === 'canceled' ? true : result === 'failed' ? false : undefined, } satisfies UsageQuery, }; - }, [apiKeyHash, customEnd, customStart, model, provider, range, result, source]); + }, [apiKeyHash, customEnd, customStart, model, provider, endpoint, transport, kind, range, result, source]); const executeLoadData = useCallback( async (quiet = false) => { @@ -352,7 +367,7 @@ export function UsageRecordsPage() { statusRequest, optionsRequest, invoke('get_usage_overview', { query }), - model || provider || source || apiKeyHash || result !== 'all' + model || provider || endpoint || transport || kind || source || apiKeyHash || result !== 'all' ? invoke('get_usage_analysis', { query }) : optionsRequest, ]); @@ -396,7 +411,7 @@ export function UsageRecordsPage() { if (requestId === requestIdRef.current) setLoading(false); } }, - [activeTab, buildQueries, page, pageSize, model, provider, source, apiKeyHash, result] + [activeTab, buildQueries, page, pageSize, model, provider, endpoint, transport, kind, source, apiKeyHash, result] ); const loadData = useCallback( @@ -448,12 +463,15 @@ export function UsageRecordsPage() { }; const hasActiveFilters = Boolean( - model || provider || source || apiKeyHash || (result && result !== 'all') || range === 'custom' + model || provider || endpoint || transport || kind || source || apiKeyHash || (result && result !== 'all') || range === 'custom' ); const resetFilters = () => { setModel(''); setProvider(''); + setEndpoint(''); + setTransport(''); + setKind(''); setSource(''); setApiKeyHash(''); setResult('all'); @@ -608,6 +626,22 @@ export function UsageRecordsPage() { + + +