diff --git a/core-version.txt b/core-version.txt index da9f4314..643916c0 100644 --- a/core-version.txt +++ b/core-version.txt @@ -1 +1 @@ -7.2.158 +7.3.1 diff --git a/docs/usage-accounting.md b/docs/usage-accounting.md new file mode 100644 index 00000000..cf14575f --- /dev/null +++ b/docs/usage-accounting.md @@ -0,0 +1,57 @@ +# 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 records use the upstream Keeper normalizer exactly once: recognized executor contracts take precedence, provider identity is a fallback only for OAuth, and unknown producers retain their reported parent fields. Gemini thinking is included in billable output where that contract is known; 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` follows upstream `dev` at 7.3.1. Upstream already supports token breakdown schema v2; this release does not contain the proposed journal/ACK endpoints, delivery identities, and extended billing dimensions from the companion PR. Before shipping the complete feature, publish a core release containing those changes 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. + +## Independent review corrections + +Schema migration uses an atomic savepoint and repairs partially added accounting columns. The local inbox uses SQLite `synchronous=FULL`: a durable ACK must follow a flushed WAL commit, not merely a NORMAL-mode commit that could be lost on power failure. Overview counters are populated by event kind; logical generation counts deduplicate attempts and exclude prewarms/tools/probes. + +Direct health probes pass provider/base URL through IPC, preserve actual response tiers, and require a terminal response instead of treating the first text delta as proof of completeness. Error bodies are captured through the same bounded reader; aborted streams are partial. DeepSeek cache-hit and Gemini tool-input counters retain their provider semantics. + +Claude's actual `usage.speed` controls Fast pricing, including standard-speed fallback. Anthropic Priority commitments have no inferred Fast tariff. Eligible OpenAI US/EU processing endpoints receive the documented regional uplift; storage-only regions do not. Unsupported regional combinations remain unknown, and manual tariffs do not receive an extra automatic uplift. Sources: [Claude Fast mode](https://platform.claude.com/docs/en/build-with-claude/fast-mode), [OpenAI data residency support](https://developers.openai.com/api/docs/guides/your-data#api-endpoint-tool-and-model-support), and the pricing sources above. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 215de07a..40b344a0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -593,7 +593,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "cpa-gui" -version = "0.2.88" +version = "0.2.91" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c38b9a78..0e69f107 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "cpa-gui" # Single source of truth for the desktop app version. -version = "0.2.88" +version = "0.2.91" description = "CPA desktop management shell" authors = ["lzt404"] edition = "2021" 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..22720b4a 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, @@ -130,17 +139,9 @@ fn provider_health_json_has_text(protocol: &str, value: &serde_json::Value) -> b } pub(crate) fn provider_health_stream_has_text(protocol: &str, bytes: &[u8]) -> bool { - let text = String::from_utf8_lossy(bytes); - text.lines().any(|line| { - let line = line.trim(); - let data = line.strip_prefix("data:").map(str::trim).unwrap_or(line); - if data.is_empty() || data == "[DONE]" { - return false; - } - serde_json::from_str::(data) - .ok() - .is_some_and(|value| provider_health_json_has_text(protocol, &value)) - }) + provider_health_values(bytes) + .iter() + .any(|value| provider_health_json_has_text(protocol, value)) } fn provider_health_json_has_terminal_success(protocol: &str, value: &serde_json::Value) -> bool { @@ -170,65 +171,92 @@ fn provider_health_json_has_terminal_success(protocol: &str, value: &serde_json: } pub(crate) fn provider_health_stream_has_terminal_success(protocol: &str, bytes: &[u8]) -> bool { - let text = String::from_utf8_lossy(bytes); - text.lines().any(|line| { - let line = line.trim(); - let data = line.strip_prefix("data:").map(str::trim).unwrap_or(line); - if data.is_empty() || data == "[DONE]" { - return false; - } - serde_json::from_str::(data) - .ok() - .is_some_and(|value| provider_health_json_has_terminal_success(protocol, &value)) - }) + provider_health_values(bytes) + .iter() + .any(|value| provider_health_json_has_terminal_success(protocol, value)) } 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 = provider_health_values(bytes); + 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()); + } + } + if let Some(tier) = value + .pointer("/response/service_tier") + .or_else(|| value.get("service_tier")) + .and_then(serde_json::Value::as_str) + { + merged.insert("service_tier".into(), serde_json::json!(tier)); + } + } + 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"]); + if protocol == "gemini" { + tokens.input_tokens = tokens + .input_tokens + .saturating_add(number(&["/toolUsePromptTokenCount"])); } + 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", + "/input_token_details/cached_tokens", + "/prompt_cache_hit_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 +270,49 @@ 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(), + "response_service_tier": tokens.raw_usage.get("service_tier"), + "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 +322,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, }, }); @@ -276,6 +331,85 @@ fn persist_provider_health_success( } } +fn provider_health_values(bytes: &[u8]) -> Vec { + if let Ok(value) = serde_json::from_slice(bytes) { + return vec![value]; + } + String::from_utf8_lossy(bytes) + .lines() + .filter_map(|line| { + let line = line.trim().trim_start_matches('\u{1e}').trim(); + let data = line.strip_prefix("data:").unwrap_or(line).trim(); + serde_json::from_str(data).ok() + }) + .collect() +} + +fn provider_health_completion_result( + protocol: &str, + received: &[u8], + first_token: Option, +) -> Result<(), String> { + let values = provider_health_values(received); + if values.iter().any(|value| { + value.get("error").is_some_and(|v| !v.is_null()) + || matches!( + value["type"].as_str(), + Some("error" | "response.failed" | "response.incomplete" | "response.cancelled") + ) + || matches!( + value["status"] + .as_str() + .or(value["response"]["status"].as_str()), + Some("failed" | "incomplete" | "cancelled") + ) + }) { + return Err("Health response ended with an upstream error; usage is incomplete".into()); + } + let whole_json = !String::from_utf8_lossy(received) + .lines() + .any(|line| line.trim_start().starts_with("data:")); + let terminal = match protocol { + "openai-chat" => { + String::from_utf8_lossy(received).lines().any(|line| { + line.trim() + .strip_prefix("data:") + .is_some_and(|v| v.trim() == "[DONE]") + }) || whole_json + && values.iter().any(|v| { + v["choices"].as_array().is_some_and(|choices| { + !choices.is_empty() + && choices + .iter() + .all(|c| c["finish_reason"].as_str().is_some()) + }) + }) + } + "openai-responses" => values + .iter() + .any(|v| v["type"] == "response.completed" || whole_json && v["status"] == "completed"), + "claude" => values.iter().any(|v| { + v["type"] == "message_stop" || whole_json && v["stop_reason"].as_str().is_some() + }), + "gemini" => values.iter().any(|v| { + v["candidates"].as_array().is_some_and(|cs| { + !cs.is_empty() + && cs + .iter() + .all(|c| matches!(c["finishReason"].as_str(), Some("STOP" | "MAX_TOKENS"))) + }) + }), + _ => false, + }; + if !terminal { + return Err("Health stream ended before terminal response; usage is incomplete".into()); + } + if first_token.is_none() && !provider_health_stream_has_terminal_success(protocol, received) { + return Err("Health response contained no model output".into()); + } + Ok(()) +} + pub(crate) fn provider_health_content_type_is_streaming(content_type: &str) -> bool { let content_type = content_type.to_ascii_lowercase(); content_type.contains("text/event-stream") @@ -339,63 +473,143 @@ 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(); + 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); + } } - 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, - }); + if !(200..300).contains(&status_code) { + return Err(format!( + "Upstream HTTP {}: {}", + status_code, + String::from_utf8_lossy(&received) + )); } - 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, - }); + provider_health_completion_result(&request.protocol, &received, first_token)?; + 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); + } +} + +#[cfg(test)] +mod review_regressions { + use super::*; + #[test] + fn health_eof_after_text_is_incomplete() { + assert!(provider_health_completion_result( + "claude", + b"data: {\"delta\":{\"text\":\"hello\"}}\n\n", + Some(1) + ) + .is_err()); + } + #[test] + fn health_stream_error_cannot_be_hidden_by_text() { + let body = b"data: {\"delta\":{\"text\":\"hello\"}}\n\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\"}}\n\n"; + assert!(provider_health_completion_result("claude", body, Some(1)).is_err()); + } + #[test] + fn health_parses_provider_cache_and_tool_input_fields() { + let deepseek = provider_health_usage_tokens("openai-chat", br#"{"usage":{"prompt_tokens":1000,"prompt_cache_hit_tokens":900,"completion_tokens":10,"total_tokens":1010}}"#); + assert_eq!(deepseek.cache_read_tokens, 900); + let gemini = provider_health_usage_tokens("gemini", br#"{"usageMetadata":{"promptTokenCount":100,"toolUsePromptTokenCount":50,"candidatesTokenCount":20,"totalTokenCount":170}}"#); + assert_eq!(gemini.input_tokens, 150); + } +} + +#[cfg(test)] +mod terminal_controls { + use super::*; + #[test] + fn final_success_is_recognized_for_all_probe_protocols() { + for (protocol, payload) in [ + ("openai-chat", "data: [DONE]\n\n"), + ("openai-responses", "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n"), + ("claude", "data: {\"type\":\"message_stop\"}\n\n"), + ("gemini", "data: {\"candidates\":[{\"finishReason\":\"STOP\"}]}\n\n"), + ] { + assert!(provider_health_completion_result(protocol, payload.as_bytes(), Some(1)).is_ok(), "{protocol}"); } } - Err("健康检测未收到模型首字".to_string()) + #[test] + fn late_error_overrides_a_terminal_success() { + let bytes = + b"data: {\"type\":\"response.completed\"}\n\ndata: {\"type\":\"response.failed\"}\n\n"; + assert!(provider_health_completion_result("openai-responses", bytes, Some(1)).is_err()); + } +} + +#[cfg(test)] +mod final_review_regressions { + use super::*; + #[test] + fn ndjson_retains_text_usage_and_terminal_state() { + let body = b"{\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n{\"choices\":[{\"finish_reason\":\"stop\"}]}\n{\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":2,\"total_tokens\":102}}\n"; + assert!(provider_health_stream_has_text("openai-chat", body)); + assert_eq!( + provider_health_usage_tokens("openai-chat", body).total_tokens, + 102 + ); + assert!(provider_health_completion_result("openai-chat", body, Some(1)).is_ok()); + } + #[test] + fn direct_probe_preserves_actual_response_service_tier() { + let tokens=provider_health_usage_tokens("openai-responses",br#"data: {"type":"response.completed","response":{"service_tier":"fast","usage":{"input_tokens":10,"output_tokens":2,"total_tokens":12}}}"#); + assert_eq!(tokens.raw_usage["service_tier"], "fast"); + } } diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 05cd3345..9ea62a17 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -1,4 +1,7 @@ +mod accounting; +mod multimodal; mod resp; +mod token; #[cfg(target_os = "macos")] use super::executable_dir; @@ -27,7 +30,12 @@ use std::{ use tauri::{Emitter, Manager}; use tokio_util::sync::CancellationToken; -use self::resp::UsageSubscription; +use self::{ + resp::{pull_usage_queue, UsageSubscription}, + token::{ + normalize as normalize_keeper_tokens, ClampedTokenFields, TokenValues as KeeperTokenValues, + }, +}; const USAGE_DIR_NAME: &str = "usage-records"; const USAGE_DATABASE_FILE: &str = "usage.db"; @@ -40,15 +48,17 @@ 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_QUEUE_BATCH_SIZE: usize = 10_000; const USAGE_INBOX_PROCESS_LIMIT: usize = 500; const USAGE_INBOX_MAX_ATTEMPTS: i64 = 5; const USAGE_SUBSCRIBE_RETRY_SECONDS: u64 = 30; +const USAGE_QUEUE_KEY: &str = "usage"; +const LEGACY_USAGE_QUEUE_KEY: &str = "queue"; +const HTTP_USAGE_QUEUE_SOURCE: &str = "http_pull:usage_queue"; 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 +170,8 @@ struct UsageTokenStats { #[derive(Clone, Serialize, Deserialize)] pub(crate) struct UsageRecord { + #[serde(default)] + accounting: Value, id: String, timestamp: String, #[serde(default)] @@ -214,7 +226,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 +252,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 +283,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 +318,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 +363,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 +399,8 @@ pub(crate) struct ModelPriceSyncResult { } struct UsageCostGroup { + accounting: Value, + timestamp: String, model: String, alias: String, service_tier: String, @@ -1166,6 +1187,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 +1210,7 @@ fn initialize_usage_schema(connection: &Connection) -> Result<(), String> { ) .map_err(|error| format!("初始化 SQLite 使用记录结构失败: {error}"))?; ensure_usage_failure_columns(connection)?; + migrate_usage_accounting_columns(connection)?; connection .execute( "CREATE INDEX IF NOT EXISTS idx_usage_events_canceled_timestamp ON usage_events(canceled, timestamp_ms DESC)", @@ -1196,6 +1222,51 @@ fn initialize_usage_schema(connection: &Connection) -> Result<(), String> { .map_err(|error| format!("更新 SQLite 使用记录版本失败: {error}")) } +fn migrate_usage_accounting_columns(connection: &Connection) -> Result<(), String> { + // A savepoint also works when an older migration already owns a transaction. + connection + .execute_batch("SAVEPOINT usage_accounting_columns") + .map_err(|error| format!("Begin accounting migration: {error}"))?; + let result = (|| -> rusqlite::Result<()> { + let mut statement = connection.prepare("PRAGMA table_info(usage_events)")?; + let columns = statement + .query_map([], |row| row.get::<_, String>(1))? + .collect::>>()?; + drop(statement); + for (name, definition) in [ + ("accounting_json", "TEXT NOT NULL DEFAULT '{}'"), + ("event_id", "TEXT NOT NULL DEFAULT ''"), + ] { + if !columns.iter().any(|column| column == name) { + connection.execute_batch(&format!( + "ALTER TABLE usage_events ADD COLUMN {name} {definition}" + ))?; + } + } + // This equality makes repairing an interrupted older migration idempotent. + connection.execute_batch("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; + CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_event_id ON usage_events(event_id) WHERE event_id != '';")?; + Ok(()) + })(); + match result { + Ok(()) => connection + .execute_batch("RELEASE usage_accounting_columns") + .map_err(|error| format!("Commit accounting migration: {error}")), + Err(error) => { + connection + .execute_batch( + "ROLLBACK TO usage_accounting_columns; RELEASE usage_accounting_columns", + ) + .map_err(|rollback| { + format!("Accounting migration failed: {error}; rollback failed: {rollback}") + })?; + Err(format!("Migrate usage accounting: {error}")) + } + } +} + fn ensure_usage_failure_columns(connection: &Connection) -> Result<(), String> { let mut columns = usage_table_columns(connection, "usage_events")?; for (column, definition) in [ @@ -1305,7 +1376,7 @@ fn open_usage_database_at(root: &Path) -> Result { .pragma_update(None, "foreign_keys", "ON") .map_err(|error| format!("启用 SQLite foreign keys 失败: {error}"))?; connection - .pragma_update(None, "synchronous", "NORMAL") + .pragma_update(None, "synchronous", "FULL") .map_err(|error| format!("设置 SQLite synchronous 模式失败: {error}"))?; Ok(connection) } @@ -1390,6 +1461,65 @@ fn validate_legacy_schema(version: u8, path: &Path) -> Result<(), String> { } } +#[derive(Default)] +struct RedisUsageQueueSource { + selected_key: Option<&'static str>, +} + +struct RawUsageQueueBatch { + source: String, + messages: Vec, +} + +impl RedisUsageQueueSource { + async fn pull(&mut self, config: &GuiConfigFile) -> Result { + if let Some(queue_key) = self.selected_key { + return Self::pull_key(config, queue_key).await; + } + + match Self::pull_key(config, USAGE_QUEUE_KEY).await { + Ok(batch) => { + self.selected_key = Some(USAGE_QUEUE_KEY); + Ok(batch) + } + Err(usage_error) if redis_pull_can_try_legacy_queue(&usage_error) => { + match Self::pull_key(config, LEGACY_USAGE_QUEUE_KEY).await { + Ok(batch) => { + self.selected_key = Some(LEGACY_USAGE_QUEUE_KEY); + Ok(batch) + } + Err(legacy_error) => Err(format!( + "Redis usage 队列拉取失败(usage: {usage_error}; queue: {legacy_error})" + )), + } + } + Err(error) => Err(error), + } + } + + async fn pull_key( + config: &GuiConfigFile, + queue_key: &'static str, + ) -> Result { + let messages = pull_usage_queue( + config.port, + &config.management_secret_key, + queue_key, + USAGE_QUEUE_BATCH_SIZE, + ) + .await?; + Ok(RawUsageQueueBatch { + source: format!("redis_pull:{queue_key}"), + messages, + }) + } +} + +fn redis_pull_can_try_legacy_queue(error: &str) -> bool { + let error = error.to_ascii_lowercase(); + error.contains("unsupported channel") || error.contains("unsupported queue") +} + pub(crate) fn start_usage_collector(app: tauri::AppHandle) { let state = app.state::(); if let Ok(total_records) = total_usage_records() { @@ -1417,7 +1547,10 @@ 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 subscription_config: Option<(u16, String)> = None; + let mut redis_queue = RedisUsageQueueSource::default(); let mut subscribe_retry_at = tokio::time::Instant::now(); let mut next_inbox_cleanup_at = tokio::time::Instant::now() + Duration::from_secs(60 * 60); let mut next_inbox_recovery_at = tokio::time::Instant::now(); @@ -1436,6 +1569,14 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) { continue; } }; + if subscription_config.as_ref().is_some_and(|(port, secret)| { + *port != config.port || secret != &config.management_secret_key + }) { + subscription = None; + subscription_config = None; + redis_queue = RedisUsageQueueSource::default(); + subscribe_retry_at = tokio::time::Instant::now(); + } if tokio::time::Instant::now() >= next_inbox_cleanup_at { if let Err(error) = open_usage_database_at(&root) .and_then(|connection| cleanup_usage_inbox(&connection, Local::now())) @@ -1482,18 +1623,45 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) { } if !core_running { subscription = None; + subscription_config = None; + redis_queue = RedisUsageQueueSource::default(); + subscribe_retry_at = tokio::time::Instant::now(); set_collector_status(&app, "waiting-core", "等待内核启动", None); retry_seconds = 1; wait_or_cancel(&token, 1).await; 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) => { subscription = Some(next_subscription); + subscription_config = Some((config.port, config.management_secret_key.clone())); set_collector_status(&app, "collecting", "已连接 CPA usage 实时订阅", None); - match backfill_usage_queue(&root, &config).await { + match backfill_usage_queue(&root, &config, &mut redis_queue).await { Ok(saved) => { publish_collected_records( &app, @@ -1565,6 +1733,7 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) { } Ok(Err(error)) => { subscription = None; + subscription_config = None; subscribe_retry_at = tokio::time::Instant::now() + Duration::from_secs(USAGE_SUBSCRIBE_RETRY_SECONDS); set_collector_status( @@ -1577,27 +1746,43 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) { } } - match fetch_usage_queue(&config).await { - Ok(items) if items.is_empty() => { - set_collector_status(&app, "collecting", "使用记录采集中", None); + match pull_usage_queue_with_fallback(&mut redis_queue, &config).await { + Ok(batch) if batch.messages.is_empty() => { + set_collector_status( + &app, + "collecting", + &format!( + "使用记录采集中({})", + collector_source_label(&batch.source) + ), + None, + ); retry_seconds = 1; wait_or_cancel(&token, 1).await; } - Ok(items) => match persist_queue_items(&root, items, &config) { - Ok(saved) => { - publish_collected_records( - &app, - saved, - &format!("HTTP 兼容模式已保存 {saved} 条新记录"), - ); - retry_seconds = 1; - } - Err(error) => { - set_collector_error(&app, error); - wait_or_cancel(&token, retry_seconds).await; - retry_seconds = (retry_seconds * 2).min(10); + Ok(batch) => { + let source_label = collector_source_label(&batch.source); + match persist_raw_usage_messages_from_source( + &root, + &batch.source, + batch.messages, + &config, + ) { + Ok(saved) => { + publish_collected_records( + &app, + saved, + &format!("{source_label}已保存 {saved} 条新记录"), + ); + retry_seconds = 1; + } + Err(error) => { + set_collector_error(&app, error); + wait_or_cancel(&token, retry_seconds).await; + retry_seconds = (retry_seconds * 2).min(10); + } } - }, + } Err(error) => { set_collector_error(&app, error); wait_or_cancel(&token, retry_seconds).await; @@ -1607,18 +1792,100 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) { } } -async fn backfill_usage_queue(root: &Path, config: &GuiConfigFile) -> Result { +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, + redis_queue: &mut RedisUsageQueueSource, +) -> Result { let mut saved_total = 0_usize; loop { - let items = fetch_usage_queue(config).await?; - let fetched = items.len(); + match redis_queue.pull(config).await { + Ok(batch) => { + let fetched = batch.messages.len(); + if fetched == 0 { + return Ok(saved_total); + } + saved_total = saved_total.saturating_add(persist_raw_usage_messages_from_source( + root, + &batch.source, + batch.messages, + config, + )?); + if fetched < USAGE_QUEUE_BATCH_SIZE { + return Ok(saved_total); + } + } + Err(redis_error) => { + return backfill_http_usage_queue(root, config, saved_total) + .await + .map_err(|http_error| { + format!("补录队列失败(Redis: {redis_error}; HTTP: {http_error})") + }); + } + } + } +} + +async fn backfill_http_usage_queue( + root: &Path, + config: &GuiConfigFile, + mut saved_total: usize, +) -> Result { + loop { + let messages = fetch_usage_queue_raw(config).await?; + let fetched = messages.len(); if fetched == 0 { return Ok(saved_total); } - saved_total = saved_total.saturating_add(persist_queue_items_from_source( + saved_total = saved_total.saturating_add(persist_raw_usage_messages_from_source( root, - "http_backfill", - items, + HTTP_USAGE_QUEUE_SOURCE, + messages, config, )?); if fetched < USAGE_QUEUE_BATCH_SIZE { @@ -1696,6 +1963,43 @@ async fn fetch_usage_queue(config: &GuiConfigFile) -> Result, String> .map_err(|error| format!("解析 CPA 使用记录失败: {error}")) } +async fn fetch_usage_queue_raw(config: &GuiConfigFile) -> Result, String> { + fetch_usage_queue(config) + .await? + .into_iter() + .map(|item| { + serde_json::to_string(&item) + .map_err(|error| format!("序列化 CPA 使用记录失败: {error}")) + }) + .collect() +} + +async fn pull_usage_queue_with_fallback( + redis_queue: &mut RedisUsageQueueSource, + config: &GuiConfigFile, +) -> Result { + match redis_queue.pull(config).await { + Ok(batch) => Ok(batch), + Err(redis_error) => fetch_usage_queue_raw(config) + .await + .map(|messages| RawUsageQueueBatch { + source: HTTP_USAGE_QUEUE_SOURCE.to_string(), + messages, + }) + .map_err(|http_error| { + format!("读取 CPA 使用记录失败(Redis: {redis_error}; HTTP: {http_error})") + }), + } +} + +fn collector_source_label(source: &str) -> &'static str { + if source.starts_with("redis_pull:") { + "Redis 队列" + } else { + "HTTP 兼容模式" + } +} + fn set_collector_error(app: &tauri::AppHandle, error: String) { set_collector_status(app, "error", &error, None); } @@ -1726,7 +2030,7 @@ fn persist_queue_items( items: Vec, config: &GuiConfigFile, ) -> Result { - persist_queue_items_from_source(root, "http_pull", items, config) + persist_queue_items_from_source(root, HTTP_USAGE_QUEUE_SOURCE, items, config) } fn persist_queue_items_from_source( @@ -1763,6 +2067,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); @@ -1834,9 +2139,18 @@ fn persist_raw_usage_message_from_source( source: &str, raw_message: String, config: &GuiConfigFile, +) -> Result { + persist_raw_usage_messages_from_source(root, source, vec![raw_message], config) +} + +fn persist_raw_usage_messages_from_source( + root: &Path, + source: &str, + raw_messages: Vec, + config: &GuiConfigFile, ) -> Result { let mut connection = open_usage_database_at(root)?; - enqueue_usage_raw_messages(&mut connection, source, vec![raw_message])?; + enqueue_usage_raw_messages(&mut connection, source, raw_messages)?; process_usage_inbox(&mut connection, config) } @@ -2053,17 +2367,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() { @@ -2077,25 +2392,25 @@ fn insert_usage_records_in_transaction( } else { "unknown" }; - let cache_components = record - .tokens - .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 cached_tokens = record.cached_tokens.max(cache_components); + 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 collector_source = if record.collector_source.trim().is_empty() { "legacy_json" } else { @@ -2136,18 +2451,20 @@ fn insert_usage_records_in_transaction( record.x_forwarded_for, record.user_agent, record.generate, - to_sql_i64(cached_tokens), + to_sql_i64(record.cached_tokens), collector_source, - to_sql_i64(input_tokens), + to_sql_i64(record.tokens.input_tokens), to_sql_i64(record.tokens.output_tokens), to_sql_i64(record.tokens.reasoning_tokens), to_sql_i64(record.tokens.cache_read_tokens), to_sql_i64(record.tokens.cache_creation_tokens), - to_sql_i64(total_tokens), + to_sql_i64(record.tokens.total_tokens), record.canceled, 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,15 +2482,16 @@ fn normalize_usage_record(value: Value, config: &GuiConfigFile) -> Result Result 0 - && (raw_input_tokens < cache_components || tokens.total_tokens == raw_total_with_cache); - if claude_excludes_cache { - tokens.input_tokens = raw_input_tokens - .saturating_add(tokens.cache_read_tokens) - .saturating_add(tokens.cache_creation_tokens); - } - let input_before_invariant = tokens.input_tokens; - if cache_components > 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 +2567,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, @@ -2439,9 +2763,7 @@ fn load_usage_overview( AND failed = 0 AND canceled = 0 AND output_tokens > 0 - AND ttft_ms IS NOT NULL - AND ttft_ms > 0 - AND latency_ms > ttft_ms + AND latency_ms > 0 THEN output_tokens ELSE 0 END) * 1000.0 @@ -2450,10 +2772,8 @@ fn load_usage_overview( AND failed = 0 AND canceled = 0 AND output_tokens > 0 - AND ttft_ms IS NOT NULL - AND ttft_ms > 0 - AND latency_ms > ttft_ms - THEN latency_ms - ttft_ms + AND latency_ms > 0 + THEN latency_ms ELSE 0 END), 0), 0.0 @@ -2463,9 +2783,7 @@ fn load_usage_overview( AND failed = 0 AND canceled = 0 AND output_tokens > 0 - AND ttft_ms IS NOT NULL - AND ttft_ms > 0 - AND latency_ms > ttft_ms + AND latency_ms > 0 THEN 1 ELSE 0 END), 0), @@ -2536,7 +2854,26 @@ fn load_usage_overview( .collect::, _>>() .map_err(|error| format!("读取 SQLite 使用趋势失败: {error}"))?; + let mut event_counts = serde_json::Map::new(); + let mut counts_statement = connection.prepare(&format!( + "SELECT COALESCE(NULLIF(json_extract(accounting_json,'$.kind'),''),'legacy'), COUNT(*) FROM usage_events{} GROUP BY 1", filter.clause + )).map_err(|error| format!("Prepare usage kind counts: {error}"))?; + let counts = counts_statement + .query_map(params_from_iter(filter.params.iter()), |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + }) + .map_err(|error| format!("Count usage kinds: {error}"))?; + for count in counts { + let (kind, count) = count.map_err(|error| error.to_string())?; + event_counts.insert(kind, serde_json::json!(count)); + } + let generations: i64 = connection.query_row(&format!( + "SELECT COUNT(DISTINCT CASE WHEN generate != 0 AND COALESCE(json_extract(accounting_json,'$.kind'),'attempt')='attempt' THEN NULLIF(json_extract(accounting_json,'$.generation_id'),'') END) FROM usage_events{}", filter.clause + ), params_from_iter(filter.params.iter()), |row| row.get(0)).map_err(|error| format!("Count logical generations: {error}"))?; + event_counts.insert("logical_generations".into(), serde_json::json!(generations)); + let mut overview = UsageOverview { + event_counts: Value::Object(event_counts), total_requests: from_sql_i64(summary.0), success_count: from_sql_i64(summary.1), failure_count: from_sql_i64(summary.2), @@ -2603,13 +2940,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 +2952,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 +2967,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 +2981,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 +3032,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 +3051,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 +3069,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 +3081,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 +3115,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 +3135,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 +3159,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 +3194,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 +3267,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 +3331,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 +3398,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 +3446,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 +3487,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 +3759,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 +3767,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 +3785,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 +3806,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)?), @@ -3617,6 +3948,17 @@ fn token_u64(object: Option<&serde_json::Map>, key: &str) -> u64 object.map(|object| u64_field(object, key)).unwrap_or(0) } +fn token_was_negative(object: Option<&serde_json::Map>, key: &str) -> bool { + object + .and_then(|object| object.get(key)) + .and_then(|value| { + value + .as_i64() + .or_else(|| value.as_str().and_then(|text| text.parse::().ok())) + }) + .is_some_and(|value| value < 0) +} + fn hash_text(value: &str) -> String { if value.is_empty() { return String::new(); @@ -3659,6 +4001,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(); } @@ -3680,6 +4032,76 @@ fn usage_source_display(config: &GuiConfigFile, provider: &str, source: &str) -> mod tests { use super::*; + #[tokio::test] + async fn redis_usage_queue_falls_back_to_legacy_key_and_reuses_selection() { + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + }; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let legacy_message = r#"{"request_id":"legacy"}"#; + let expectations = vec![ + ( + USAGE_QUEUE_KEY.to_string(), + "-ERR unsupported channel 'usage'\r\n".to_string(), + ), + (LEGACY_USAGE_QUEUE_KEY.to_string(), "*0\r\n".to_string()), + ( + LEGACY_USAGE_QUEUE_KEY.to_string(), + format!("*1\r\n${}\r\n{legacy_message}\r\n", legacy_message.len()), + ), + ]; + let server = tokio::spawn(async move { + for (queue_key, response) in expectations { + let (mut stream, _) = listener.accept().await.unwrap(); + let expected_auth = b"*2\r\n$4\r\nAUTH\r\n$6\r\nsecret\r\n"; + let mut auth = vec![0_u8; expected_auth.len()]; + stream.read_exact(&mut auth).await.unwrap(); + assert_eq!(auth, expected_auth); + stream.write_all(b"+OK\r\n").await.unwrap(); + + let batch_size = USAGE_QUEUE_BATCH_SIZE.to_string(); + let expected_pop = format!( + "*3\r\n$4\r\nLPOP\r\n${}\r\n{queue_key}\r\n${}\r\n{batch_size}\r\n", + queue_key.len(), + batch_size.len() + ); + let mut pop = vec![0_u8; expected_pop.len()]; + stream.read_exact(&mut pop).await.unwrap(); + assert_eq!(pop, expected_pop.as_bytes()); + stream.write_all(response.as_bytes()).await.unwrap(); + } + }); + + let mut config = GuiConfigFile::default(); + config.port = port; + config.management_secret_key = "secret".to_string(); + let mut source = RedisUsageQueueSource::default(); + + let first = source.pull(&config).await.unwrap(); + assert_eq!(first.source, "redis_pull:queue"); + assert!(first.messages.is_empty()); + + let second = source.pull(&config).await.unwrap(); + assert_eq!(second.source, "redis_pull:queue"); + assert_eq!(second.messages, vec![legacy_message]); + server.await.unwrap(); + } + + #[test] + fn redis_usage_queue_only_falls_back_for_unsupported_key_errors() { + assert!(redis_pull_can_try_legacy_queue( + "ERR unsupported channel 'usage'" + )); + assert!(redis_pull_can_try_legacy_queue( + "ERR unsupported queue 'usage'" + )); + assert!(!redis_pull_can_try_legacy_queue("CPA usage 订阅认证失败")); + assert!(!redis_pull_can_try_legacy_queue("connection refused")); + } + #[tokio::test] async fn background_usage_jobs_limit_database_concurrency() { use std::sync::{ @@ -3991,6 +4413,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, @@ -4206,10 +4629,11 @@ mod tests { } #[test] - fn keeps_already_inclusive_claude_input_unchanged() { + fn legacy_claude_identity_uses_keeper_missing_cache_contract() { let record = normalize_usage_record( serde_json::json!({ "provider": "anthropic", + "auth_type": "oauth", "request_id": "claude-inclusive", "tokens": { "input_tokens": 720, @@ -4222,12 +4646,246 @@ mod tests { &GuiConfigFile::default(), ) .unwrap(); - assert_eq!(record.tokens.input_tokens, 720); - assert_eq!(record.tokens.total_tokens, 740); + assert_eq!(record.tokens.input_tokens, 1_340); + assert_eq!(record.tokens.total_tokens, 1_360); + } + + #[test] + fn unresolved_executors_only_use_provider_for_oauth() { + for executor in ["", "unknown", "FutureExecutor"] { + for auth_type in ["", "apikey", "api_key", "unknown", " OAUTH "] { + let record = normalize_usage_record( + serde_json::json!({ + "request_id": "unresolved-executor", + "executor_type": executor, + "provider": "anthropic", + "auth_type": auth_type, + "auth_index": "missing-identity", + "tokens": { + "input_tokens": 100, + "output_tokens": 20, + "cache_read_tokens": 30, + "total_tokens": 120 + } + }), + &GuiConfigFile::default(), + ) + .unwrap(); + let expected_input = if auth_type.trim().eq_ignore_ascii_case("oauth") { + 130 + } else { + 100 + }; + assert_eq!( + record.tokens.input_tokens, expected_input, + "{executor}/{auth_type}" + ); + assert_eq!( + record.tokens.total_tokens, + expected_input + 20, + "{executor}/{auth_type}" + ); + } + } + } + + #[test] + fn codex_cached_only_records_preserve_total_with_missing_or_zero_read() { + for explicit_read in [false, true] { + let mut tokens = serde_json::json!({ + "input_tokens": 0, + "output_tokens": 0, + "reasoning_tokens": 0, + "cached_tokens": 30, + "cache_creation_tokens": 0, + "total_tokens": 30 + }); + if explicit_read { + tokens["cache_read_tokens"] = serde_json::json!(0); + } + let record = normalize_usage_record( + serde_json::json!({ + "request_id": "codex-cached-only", + "executor_type": "CodexExecutor", + "tokens": tokens + }), + &GuiConfigFile::default(), + ) + .unwrap(); + assert_eq!( + record.tokens.total_tokens, 30, + "explicit_read={explicit_read}" + ); + assert_eq!(record.tokens.input_tokens, 0); + assert_eq!(record.tokens.output_tokens, 0); + assert_eq!( + record.tokens.cache_read_tokens, + if explicit_read { 0 } else { 30 } + ); + } + } + + #[test] + fn clamped_contract_fields_do_not_rewrite_nonzero_total() { + for executor in ["ClaudeExecutor", "GeminiExecutor", "CodexExecutor"] { + for field in [ + "input_tokens", + "output_tokens", + "reasoning_tokens", + "cache_read_tokens", + "cache_creation_tokens", + ] { + let mut tokens = serde_json::json!({ + "input_tokens": 10, + "output_tokens": 5, + "total_tokens": 99 + }); + tokens[field] = serde_json::json!(-1); + let record = normalize_usage_record( + serde_json::json!({ + "request_id": "clamped-contract", + "executor_type": executor, + "tokens": tokens + }), + &GuiConfigFile::default(), + ) + .unwrap(); + assert_eq!(record.tokens.total_tokens, 99, "{executor}/{field}"); + } + } + } + + #[test] + fn zero_total_reconciliation_respects_keeper_clamped_field_dependencies() { + for executor in [ + "ClaudeExecutor", + "GeminiExecutor", + "CodexExecutor", + "OpenAICompatExecutor", + "KimiExecutor", + "unknown", + ] { + for field in [ + "input_tokens", + "output_tokens", + "total_tokens", + "reasoning_tokens", + "cache_read_tokens", + "cache_creation_tokens", + "cached_tokens", + ] { + let mut tokens = serde_json::json!({ + "input_tokens": 10, + "output_tokens": 5, + "total_tokens": 0 + }); + tokens[field] = serde_json::json!(-1); + let record = normalize_usage_record( + serde_json::json!({ + "request_id": "clamped-zero-fallback", + "executor_type": executor, + "tokens": tokens + }), + &GuiConfigFile::default(), + ) + .unwrap(); + let expected = if matches!(field, "input_tokens" | "output_tokens" | "total_tokens") + || (executor == "ClaudeExecutor" && field == "reasoning_tokens") + { + 0 + } else { + 15 + }; + assert_eq!(record.tokens.total_tokens, expected, "{executor}/{field}"); + } + } + } + + #[test] + fn clamped_fields_cannot_trigger_dependent_token_folds() { + let cases = [ + ( + "ClaudeExecutor", + "anthropic", + serde_json::json!({ + "input_tokens": 10, "output_tokens": 5, "cache_read_tokens": -1, + "cache_creation_tokens": 30, "total_tokens": 99 + }), + 10, + 5, + ), + ( + "GeminiExecutor", + "gemini", + serde_json::json!({ + "input_tokens": 10, "output_tokens": -1, "reasoning_tokens": 5, + "total_tokens": 99 + }), + 10, + 0, + ), + ( + "", + "gemini", + serde_json::json!({ + "input_tokens": -1, "output_tokens": 5, "reasoning_tokens": 10, + "total_tokens": 15 + }), + 0, + 5, + ), + ( + "OpenAICompatExecutor", + "openai", + serde_json::json!({ + "input_tokens": -1, "output_tokens": 5, "reasoning_tokens": 10, + "total_tokens": 15 + }), + 0, + 5, + ), + ]; + for (executor, provider, tokens, input, output) in cases { + let record = normalize_usage_record( + serde_json::json!({ + "request_id": "clamped-fold", + "executor_type": executor, + "provider": provider, + "auth_type": "oauth", + "tokens": tokens + }), + &GuiConfigFile::default(), + ) + .unwrap(); + assert_eq!(record.tokens.input_tokens, input, "{executor}/{provider}"); + assert_eq!(record.tokens.output_tokens, output, "{executor}/{provider}"); + } + } + + #[test] + fn clamped_cache_read_is_not_backfilled_from_cached_alias() { + for executor in ["CodexExecutor", "KimiExecutor", "unknown"] { + let record = normalize_usage_record( + serde_json::json!({ + "request_id": "clamped-cache-alias", + "executor_type": executor, + "tokens": { + "input_tokens": 100, + "output_tokens": 20, + "cache_read_tokens": -1, + "cached_tokens": 30, + "total_tokens": 120 + } + }), + &GuiConfigFile::default(), + ) + .unwrap(); + assert_eq!(record.tokens.cache_read_tokens, 0, "{executor}"); + } } #[test] - fn enforces_cache_input_invariant_for_unknown_producers() { + fn unknown_producers_preserve_nonzero_parent_token_fields() { let record = normalize_usage_record( serde_json::json!({ "provider": "custom", @@ -4242,9 +4900,10 @@ 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"); + assert_eq!(record.tokens.cache_read_tokens, 600); } #[test] @@ -5033,13 +5692,13 @@ 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(); } #[test] - fn overview_tps_uses_weighted_generation_time_and_ignores_invalid_records() { + fn overview_tps_uses_weighted_total_latency_without_requiring_ttft() { let root = test_root("tps-overview"); let mut connection = open_test_database(&root); @@ -5075,6 +5734,13 @@ mod tests { non_generation.generate = false; non_generation.tokens.output_tokens = 100; + let mut zero_latency = sample_record("tps-8", "2026-07-17T20:37:00+08:00", "gpt-a"); + zero_latency.latency_ms = 0; + zero_latency.tokens.output_tokens = 100; + + let mut no_output = sample_record("tps-9", "2026-07-17T20:38:00+08:00", "gpt-a"); + no_output.tokens.output_tokens = 0; + insert_usage_records( &mut connection, &[ @@ -5085,14 +5751,16 @@ mod tests { failed, canceled, non_generation, + zero_latency, + no_output, ], ) .unwrap(); let overview = load_usage_overview(&connection, &UsageQuery::default()).unwrap(); - assert!((overview.tps - (100.0 * 1_000.0 / 1_800.0)).abs() < f64::EPSILON); - assert_eq!(overview.tps_sample_count, 2); + assert!((overview.tps - (300.0 * 1_000.0 / 4_000.0)).abs() < f64::EPSILON); + assert_eq!(overview.tps_sample_count, 4); drop(connection); fs::remove_dir_all(root).unwrap(); } @@ -5108,19 +5776,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 +5860,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..31552200 --- /dev/null +++ b/src-tauri/src/usage/accounting.rs @@ -0,0 +1,532 @@ +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 { + // Keeper normalization already applies the legacy executor contract. + // Reapplying provider-based folds here would count Gemini reasoning twice. + 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 mut tier: &str = if group.response_service_tier.trim().is_empty() { + &group.service_tier + } else { + &group.response_service_tier + }; + if model.starts_with("claude-") && price.source != "manual" { + let raw = &group.accounting["raw_usage"]; + // Anthropic Priority is a negotiated commitment, not OpenAI Fast mode. + if tier.eq_ignore_ascii_case("priority") || raw["service_tier"] == "priority" { + return unknown("tariff_dimensions_missing"); + } + if let Some(speed) = raw["speed"].as_str() { + if !matches!(speed, "fast" | "standard") || speed == "fast" && tier == "batch" { + return unknown("tariff_dimensions_missing"); + } + if tier != "batch" { + tier = speed; + } + } + } + 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 { + let regional = if price.source == "manual" { + 1.0 + } else { + regional_multiplier(model, tier, a)? + }; + 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"]) + .map(|cost| cost * regional); + } + 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 *= regional; + cost += tool_cost; + cost.is_finite().then_some(cost) +} + +// Only processing regions incur this premium; storage-only regions do not. +// Sources: OpenAI pricing and Your data model/endpoint support (2026-09-12). +fn regional_multiplier(model: &str, tier: &str, a: &Value) -> Option { + let host = reqwest::Url::parse(a["base_url"].as_str().unwrap_or("")) + .ok() + .and_then(|url| url.host_str().map(str::to_ascii_lowercase)) + .unwrap_or_default(); + if !matches!( + host.as_str(), + "us.api.openai.com" | "eu.api.openai.com" | "ae.api.openai.com" + ) { + return Some(1.0); + } + if host == "eu.api.openai.com" && model == "gpt-6-astra" && matches!(tier, "fast" | "priority") + { + return None; + } + let recent = 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" + | "gpt-5.4-mini" + | "gpt-5.4-nano" + ); + if recent { + if host == "ae.api.openai.com" + && !matches!(model, "gpt-5.6-luna" | "gpt-5.5" | "gpt-5.5-pro") + { + return None; + } + return Some(1.1); + } + if matches!( + model, + "gpt-5.3-codex" + | "gpt-5.2" + | "gpt-5.2-codex" + | "gpt-5.2-pro" + | "gpt-5.1" + | "gpt-5" + | "gpt-5-mini" + | "gpt-5-nano" + | "gpt-4.1" + | "gpt-4.1-mini" + | "gpt-4.1-nano" + | "gpt-4o" + | "gpt-4o-mini" + | "o3" + | "o4-mini" + ) { + return Some(1.0); + } + None +} + +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..771c9b2d --- /dev/null +++ b/src-tauri/src/usage/integrity_tests.rs @@ -0,0 +1,553 @@ +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["executor_type"] = json!("ClaudeExecutor"); + 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["executor_type"] = json!("GeminiExecutor"); + 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["executor_type"] = json!("ClaudeExecutor"); + 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 + ); +} + +#[test] +fn accounting_migration_recovers_a_partially_added_schema() { + let c = db(vec![record("gemini-2.5-pro", 100, 20)]); + c.execute_batch("DROP INDEX idx_usage_event_id; ALTER TABLE usage_events DROP COLUMN event_id; UPDATE usage_events SET provider='gemini', output_tokens=10, reasoning_tokens=10;").unwrap(); + initialize_usage_schema(&c).unwrap(); + initialize_usage_schema(&c).unwrap(); + assert!(usage_table_columns(&c, "usage_events") + .unwrap() + .contains("event_id")); + assert_eq!( + c.query_row("SELECT output_tokens FROM usage_events", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 20 + ); +} + +#[test] +fn accounting_migration_rolls_back_columns_when_data_update_fails() { + let c = db(vec![record("gemini-2.5-pro", 100, 20)]); + c.execute_batch("DROP INDEX idx_usage_event_id; ALTER TABLE usage_events DROP COLUMN event_id; ALTER TABLE usage_events DROP COLUMN accounting_json; UPDATE usage_events SET provider='gemini', output_tokens=10, reasoning_tokens=10; CREATE TRIGGER fail_accounting_update BEFORE UPDATE OF output_tokens ON usage_events BEGIN SELECT RAISE(ABORT, 'injected migration failure'); END;").unwrap(); + assert!(initialize_usage_schema(&c).is_err()); + let columns = usage_table_columns(&c, "usage_events").unwrap(); + assert!(!columns.contains("accounting_json") && !columns.contains("event_id")); + c.execute_batch("DROP TRIGGER fail_accounting_update;") + .unwrap(); + initialize_usage_schema(&c).unwrap(); +} + +#[test] +fn claude_actual_speed_controls_the_tariff() { + let mut v = record("claude-opus-5", 1000, 1000); + v["raw_usage"] = json!({"speed":"fast"}); + check(cost(vec![v]), 0.06); + let mut v = record("claude-opus-4-6", 1000, 1000); + v["service_tier"] = json!("fast"); + v["raw_usage"] = json!({"speed":"standard"}); + check(cost(vec![v]), 0.03); +} +#[test] +fn claude_priority_commitment_does_not_use_fast_prices() { + let mut v = record("claude-opus-5", 1000, 1000); + v["response_service_tier"] = json!("priority"); + let c = db(vec![v]); + assert_eq!( + load_usage_pricing(&c, &UsageQuery::default()) + .unwrap() + .priced_requests, + 0 + ); +} +#[test] +fn regional_processing_uplift_excludes_storage_only_regions_and_manual_rates() { + let mut v = record("gpt-5.6-sol", 1000, 1000); + v["base_url"] = json!("https://eu.api.openai.com/v1"); + check(cost(vec![v.clone()]), 0.0264); + v["base_url"] = json!("https://jp.api.openai.com/v1"); + check(cost(vec![v]), 0.024); +} + +#[test] +fn durable_inbox_flushes_wal_before_acknowledgement() { + let root = std::env::temp_dir().join(format!("usage-ack-durability-{}", unique_file_stamp())); + let c = open_usage_database_at(&root).unwrap(); + let sync: i64 = c.query_row("PRAGMA synchronous", [], |r| r.get(0)).unwrap(); + drop(c); + std::fs::remove_dir_all(root).unwrap(); + assert_eq!(sync, 2, "ACK requires synchronous FULL, not NORMAL"); +} + +#[test] +fn overview_distinguishes_events_from_billable_generation_groups() { + let mut attempt = record("gpt-5.4", 100, 10); + attempt["kind"] = json!("attempt"); + attempt["generation_id"] = json!("generation-1"); + let retry = attempt.clone(); + let mut tool = attempt.clone(); + tool["kind"] = json!("tool"); + let mut warm = attempt.clone(); + warm["kind"] = json!("prewarm"); + warm["generate"] = json!(false); + warm["generation_id"] = json!("local-prewarm"); + let mut health = attempt.clone(); + health["kind"] = json!("health_check"); + health["generate"] = json!(false); + let c = db(vec![attempt, retry, tool, warm, health]); + let overview = load_usage_overview(&c, &UsageQuery::default()).unwrap(); + assert_eq!(overview.total_requests, 5); + assert_eq!(overview.event_counts["attempt"], 2); + assert_eq!(overview.event_counts["tool"], 1); + assert_eq!(overview.event_counts["prewarm"], 1); + assert_eq!(overview.event_counts["logical_generations"], 1); +} + +#[test] +fn legacy_gemini_usage_is_folded_once_before_pricing() { + let mut value = record("gemini-2.5-pro", 10_000, 1_000); + value["provider"] = json!("gemini"); + value["executor_type"] = json!("GeminiExecutor"); + value["tokens"]["reasoning_tokens"] = json!(9_000); + value["tokens"]["total_tokens"] = json!(20_000); + let normalized = normalize_usage_record(value.clone(), &GuiConfigFile::default()).unwrap(); + assert_eq!(normalized.tokens.output_tokens, 10_000); + assert_eq!(normalized.tokens.total_tokens, 20_000); + assert_eq!(normalized.accounting["quality"], "legacy"); + check(cost(vec![value]), 0.1125); +} + +#[test] +fn provider_name_alone_does_not_rewrite_legacy_token_contract() { + for provider in ["gemini", "custom-gemini-proxy", "anthropic"] { + let mut value = record("custom-model", 100, 20); + value["provider"] = json!(provider); + value["tokens"]["reasoning_tokens"] = json!(5); + value["tokens"]["cache_read_tokens"] = json!(30); + let normalized = normalize_usage_record(value, &GuiConfigFile::default()).unwrap(); + assert_eq!(normalized.tokens.input_tokens, 100, "{provider}"); + assert_eq!(normalized.tokens.output_tokens, 20, "{provider}"); + assert_eq!(normalized.tokens.total_tokens, 120, "{provider}"); + assert_eq!(normalized.accounting["quality"], "legacy", "{provider}"); + } +} + +#[test] +fn canonical_breakdown_is_authoritative_after_keeper_normalization() { + for (provider, executor) in [("gemini", "GeminiExecutor"), ("claude", "ClaudeExecutor")] { + let mut value = record("custom-model", 100, 20); + value["provider"] = json!(provider); + value["executor_type"] = json!(executor); + value["tokens"]["reasoning_tokens"] = json!(5); + value["tokens"]["cache_read_tokens"] = json!(30); + value["token_breakdown"] = json!({ + "schema_version": 2, "quality": "complete", "total_tokens": 120, + "input": {"total_tokens": 100, "uncached_tokens": 70, "cache_read_tokens": 30, "cache_write_tokens": 0}, + "output": {"total_tokens": 20, "non_reasoning_tokens": 15, "reasoning_tokens": 5}, + "unclassified_tokens": 0 + }); + let normalized = normalize_usage_record(value, &GuiConfigFile::default()).unwrap(); + assert_eq!(normalized.tokens.input_tokens, 100, "{executor}"); + assert_eq!(normalized.tokens.output_tokens, 20, "{executor}"); + assert_eq!(normalized.tokens.total_tokens, 120, "{executor}"); + assert_eq!(normalized.accounting["quality"], "complete", "{executor}"); + } +} 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-tauri/src/usage/resp.rs b/src-tauri/src/usage/resp.rs index 83b39bce..7a4edbb8 100644 --- a/src-tauri/src/usage/resp.rs +++ b/src-tauri/src/usage/resp.rs @@ -9,9 +9,37 @@ use tokio::{ const USAGE_CHANNEL: &str = "usage"; const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const IO_TIMEOUT: Duration = Duration::from_secs(10); -const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; -const MAX_ARRAY_LENGTH: usize = 10_000; -const MAX_NESTING_DEPTH: usize = 8; +const MAX_BULK_BYTES: usize = 4 * 1024 * 1024; +const MAX_RESP_LINE_BYTES: usize = 4096; +const MAX_NESTING_DEPTH: usize = 4; +const SUBSCRIPTION_MAX_FRAME_BYTES: usize = MAX_BULK_BYTES + MAX_RESP_LINE_BYTES; +const SUBSCRIPTION_MAX_ARRAY_LENGTH: usize = 16; +const SUBSCRIPTION_MAX_TOTAL_BULK_BYTES: usize = MAX_BULK_BYTES; +const QUEUE_MAX_ARRAY_LENGTH: usize = 10_000; +const QUEUE_MAX_TOTAL_BULK_BYTES: usize = 16 * 1024 * 1024; + +#[derive(Clone, Copy)] +struct RespLimits { + max_frame_bytes: usize, + max_array_length: usize, + max_total_bulk_bytes: usize, +} + +const SUBSCRIPTION_LIMITS: RespLimits = RespLimits { + max_frame_bytes: SUBSCRIPTION_MAX_FRAME_BYTES, + max_array_length: SUBSCRIPTION_MAX_ARRAY_LENGTH, + max_total_bulk_bytes: SUBSCRIPTION_MAX_TOTAL_BULK_BYTES, +}; + +fn queue_limits(max_array_length: usize) -> RespLimits { + let max_array_length = max_array_length.min(QUEUE_MAX_ARRAY_LENGTH); + RespLimits { + max_frame_bytes: QUEUE_MAX_TOTAL_BULK_BYTES + .saturating_add((max_array_length + 1).saturating_mul(MAX_RESP_LINE_BYTES + 3)), + max_array_length, + max_total_bulk_bytes: QUEUE_MAX_TOTAL_BULK_BYTES, + } +} pub(super) struct UsageSubscription { stream: TcpStream, @@ -20,6 +48,18 @@ pub(super) struct UsageSubscription { impl UsageSubscription { pub(super) async fn connect(port: u16, management_key: &str) -> Result { + let mut subscription = Self::connect_authenticated(port, management_key).await?; + subscription + .send_command(&["SUBSCRIBE", USAGE_CHANNEL]) + .await?; + let acknowledgement = subscription.read_frame().await?; + if !is_subscription_ack(&acknowledgement) { + return Err("CPA 未确认 usage 订阅".to_string()); + } + Ok(subscription) + } + + async fn connect_authenticated(port: u16, management_key: &str) -> Result { let address = format!("127.0.0.1:{port}"); let stream = timeout(CONNECT_TIMEOUT, TcpStream::connect(&address)) .await @@ -35,13 +75,6 @@ impl UsageSubscription { RespValue::Error(error) => return Err(format!("CPA usage 订阅认证失败: {error}")), value => return Err(format!("CPA usage 订阅认证响应无效: {}", value.kind())), } - subscription - .send_command(&["SUBSCRIBE", USAGE_CHANNEL]) - .await?; - let acknowledgement = subscription.read_frame().await?; - if !is_subscription_ack(&acknowledgement) { - return Err("CPA 未确认 usage 订阅".to_string()); - } Ok(subscription) } @@ -71,16 +104,30 @@ impl UsageSubscription { } async fn read_frame(&mut self) -> Result { + self.read_frame_with_limits(SUBSCRIPTION_LIMITS).await + } + + async fn read_frame_with_limits(&mut self, limits: RespLimits) -> Result { loop { - match parse_resp_frame(&self.read_buffer, 0, 0)? { + let mut remaining_bulk_bytes = limits.max_total_bulk_bytes; + match parse_resp_frame_with_limits( + &self.read_buffer, + 0, + 0, + limits, + &mut remaining_bulk_bytes, + )? { ParseResult::Complete(value, consumed) => { + if consumed > limits.max_frame_bytes { + return Err("CPA usage RESP 响应超过大小限制".to_string()); + } self.read_buffer.drain(..consumed); return Ok(value); } ParseResult::Incomplete => {} } - if self.read_buffer.len() >= MAX_FRAME_BYTES { - return Err("CPA usage 订阅响应超过大小限制".to_string()); + if self.read_buffer.len() >= limits.max_frame_bytes { + return Err("CPA usage RESP 响应超过大小限制".to_string()); } let mut chunk = [0_u8; 8192]; let read = timeout(IO_TIMEOUT, self.stream.read(&mut chunk)) @@ -95,6 +142,43 @@ impl UsageSubscription { } } +pub(super) async fn pull_usage_queue( + port: u16, + management_key: &str, + queue_key: &str, + count: usize, +) -> Result, String> { + if count == 0 || count > QUEUE_MAX_ARRAY_LENGTH { + return Err(format!( + "CPA usage 队列批量大小必须在 1..={QUEUE_MAX_ARRAY_LENGTH} 之间" + )); + } + let mut connection = UsageSubscription::connect_authenticated(port, management_key).await?; + let count_argument = count.to_string(); + connection + .send_command(&["LPOP", queue_key, count_argument.as_str()]) + .await?; + match connection + .read_frame_with_limits(queue_limits(count)) + .await? + { + RespValue::Array(Some(values)) => values + .into_iter() + .map(|value| { + value + .text() + .ok_or_else(|| "CPA usage 队列包含非文本消息".to_string()) + }) + .collect(), + RespValue::Bulk(Some(value)) => String::from_utf8(value) + .map(|value| vec![value]) + .map_err(|_| "CPA usage 队列消息不是 UTF-8".to_string()), + RespValue::Array(None) | RespValue::Bulk(None) => Ok(Vec::new()), + RespValue::Error(error) => Err(format!("CPA usage 队列 LPOP 失败: {error}")), + value => Err(format!("CPA usage 队列 LPOP 响应无效: {}", value.kind())), + } +} + #[derive(Debug)] enum RespValue { Simple(String), @@ -131,31 +215,48 @@ enum ParseResult { } fn parse_resp_frame(input: &[u8], offset: usize, depth: usize) -> Result { + let mut remaining_bulk_bytes = SUBSCRIPTION_LIMITS.max_total_bulk_bytes; + parse_resp_frame_with_limits( + input, + offset, + depth, + SUBSCRIPTION_LIMITS, + &mut remaining_bulk_bytes, + ) +} + +fn parse_resp_frame_with_limits( + input: &[u8], + offset: usize, + depth: usize, + limits: RespLimits, + remaining_bulk_bytes: &mut usize, +) -> Result { if depth > MAX_NESTING_DEPTH { - return Err("CPA usage 订阅响应嵌套过深".to_string()); + return Err("CPA usage RESP 响应嵌套过深".to_string()); } let Some(prefix) = input.get(offset).copied() else { return Ok(ParseResult::Incomplete); }; match prefix { b'+' | b'-' | b':' => { - let Some((line, next)) = resp_line(input, offset + 1) else { + let Some((line, next)) = resp_line(input, offset + 1)? else { return Ok(ParseResult::Incomplete); }; let text = String::from_utf8(line.to_vec()) - .map_err(|_| "CPA usage 订阅文本响应不是 UTF-8".to_string())?; + .map_err(|_| "CPA usage RESP 文本响应不是 UTF-8".to_string())?; let value = match prefix { b'+' => RespValue::Simple(text), b'-' => RespValue::Error(text), _ => RespValue::Integer( text.parse::() - .map_err(|_| "CPA usage 订阅整数响应无效".to_string())?, + .map_err(|_| "CPA usage RESP 整数响应无效".to_string())?, ), }; Ok(ParseResult::Complete(value, next - offset)) } b'$' => { - let Some((line, data_start)) = resp_line(input, offset + 1) else { + let Some((line, data_start)) = resp_line(input, offset + 1)? else { return Ok(ParseResult::Incomplete); }; let length = parse_resp_length(line)?; @@ -166,9 +267,12 @@ fn parse_resp_frame(input: &[u8], offset: usize, depth: usize) -> Result MAX_FRAME_BYTES { - return Err("CPA usage 订阅字符串超过大小限制".to_string()); + usize::try_from(length).map_err(|_| "CPA usage RESP 字符串长度无效".to_string())?; + if length > MAX_BULK_BYTES { + return Err("CPA usage RESP 字符串超过大小限制".to_string()); + } + if length > *remaining_bulk_bytes { + return Err("CPA usage RESP 响应超过总字符串大小限制".to_string()); } let data_end = data_start.saturating_add(length); let frame_end = data_end.saturating_add(2); @@ -176,15 +280,16 @@ fn parse_resp_frame(input: &[u8], offset: usize, depth: usize) -> Result { - let Some((line, mut next)) = resp_line(input, offset + 1) else { + let Some((line, mut next)) = resp_line(input, offset + 1)? else { return Ok(ParseResult::Incomplete); }; let length = parse_resp_length(line)?; @@ -192,13 +297,19 @@ fn parse_resp_frame(input: &[u8], offset: usize, depth: usize) -> Result MAX_ARRAY_LENGTH { - return Err("CPA usage 订阅数组超过大小限制".to_string()); + usize::try_from(length).map_err(|_| "CPA usage RESP 数组长度无效".to_string())?; + if length > limits.max_array_length { + return Err("CPA usage RESP 数组超过大小限制".to_string()); } let mut values = Vec::with_capacity(length); for _ in 0..length { - match parse_resp_frame(input, next, depth + 1)? { + match parse_resp_frame_with_limits( + input, + next, + depth + 1, + limits, + remaining_bulk_bytes, + )? { ParseResult::Complete(value, consumed) => { values.push(value); next = next.saturating_add(consumed); @@ -211,24 +322,32 @@ fn parse_resp_frame(input: &[u8], offset: usize, depth: usize) -> Result Err("CPA usage 订阅响应类型无效".to_string()), + _ => Err("CPA usage RESP 响应类型无效".to_string()), } } -fn resp_line(input: &[u8], start: usize) -> Option<(&[u8], usize)> { - let relative_end = input - .get(start..)? - .windows(2) - .position(|pair| pair == b"\r\n")?; +fn resp_line(input: &[u8], start: usize) -> Result, String> { + let Some(remaining) = input.get(start..) else { + return Ok(None); + }; + let Some(relative_end) = remaining.windows(2).position(|pair| pair == b"\r\n") else { + if remaining.len() > MAX_RESP_LINE_BYTES + 1 { + return Err("CPA usage RESP 行超过大小限制".to_string()); + } + return Ok(None); + }; + if relative_end > MAX_RESP_LINE_BYTES { + return Err("CPA usage RESP 行超过大小限制".to_string()); + } let end = start + relative_end; - Some((&input[start..end], end + 2)) + Ok(Some((&input[start..end], end + 2))) } fn parse_resp_length(value: &[u8]) -> Result { std::str::from_utf8(value) - .map_err(|_| "CPA usage 订阅长度不是 UTF-8".to_string())? + .map_err(|_| "CPA usage RESP 长度不是 UTF-8".to_string())? .parse::() - .map_err(|_| "CPA usage 订阅长度无效".to_string()) + .map_err(|_| "CPA usage RESP 长度无效".to_string()) } fn is_subscription_ack(value: &RespValue) -> bool { @@ -277,7 +396,7 @@ mod tests { #[test] fn rejects_oversized_arrays() { - let error = match parse_resp_frame(b"*10001\r\n", 0, 0) { + let error = match parse_resp_frame(b"*17\r\n", 0, 0) { Err(error) => error, _ => panic!("expected oversized RESP array to fail"), }; @@ -317,4 +436,41 @@ mod tests { ); server.await.unwrap(); } + + #[tokio::test] + async fn authenticates_and_pulls_usage_queue_batch() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let expected_auth = b"*2\r\n$4\r\nAUTH\r\n$6\r\nsecret\r\n"; + let mut auth = vec![0_u8; expected_auth.len()]; + stream.read_exact(&mut auth).await.unwrap(); + assert_eq!(auth, expected_auth); + stream.write_all(b"+OK\r\n").await.unwrap(); + + let expected_pop = b"*3\r\n$4\r\nLPOP\r\n$5\r\nusage\r\n$2\r\n17\r\n"; + let mut pop = vec![0_u8; expected_pop.len()]; + stream.read_exact(&mut pop).await.unwrap(); + assert_eq!(pop, expected_pop); + let mut response = "*17\r\n".to_string(); + for request_id in 0..17 { + let message = format!("{{\"request_id\":{request_id}}}"); + response.push_str(&format!("${}\r\n{message}\r\n", message.len())); + } + stream.write_all(response.as_bytes()).await.unwrap(); + }); + + let messages = pull_usage_queue(port, "secret", "usage", 17).await.unwrap(); + assert_eq!(messages.len(), 17); + assert_eq!( + messages.first().map(String::as_str), + Some("{\"request_id\":0}") + ); + assert_eq!( + messages.last().map(String::as_str), + Some("{\"request_id\":16}") + ); + server.await.unwrap(); + } } diff --git a/src-tauri/src/usage/token.rs b/src-tauri/src/usage/token.rs new file mode 100644 index 00000000..c5e44502 --- /dev/null +++ b/src-tauri/src/usage/token.rs @@ -0,0 +1,398 @@ +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct TokenValues { + pub(super) input: u64, + pub(super) output: u64, + pub(super) reasoning: u64, + pub(super) cached: u64, + pub(super) cache_read: u64, + pub(super) cache_read_present: bool, + pub(super) cache_creation: u64, + pub(super) total: u64, + pub(super) clamped: ClampedTokenFields, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct ClampedTokenFields { + pub(super) input: bool, + pub(super) output: bool, + pub(super) reasoning: bool, + pub(super) cached: bool, + pub(super) cache_read: bool, + pub(super) cache_creation: bool, + pub(super) total: bool, +} + +impl ClampedTokenFields { + fn blocks_parent_contract(self) -> bool { + self.input || self.output || self.reasoning || self.cache_read || self.cache_creation + } + + fn blocks_zero_total(self) -> bool { + self.input || self.output || self.total + } + + fn blocks_reasoning_evidence(self) -> bool { + self.blocks_zero_total() || self.reasoning + } + + fn any(self) -> bool { + self.blocks_parent_contract() || self.cached || self.total + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TokenHandler { + Claude, + Gemini, + ResponsesInclusive, + Strict, + OpenAiCompatibility, +} + +pub(super) fn normalize( + executor_type: &str, + provider: &str, + auth_type: &str, + mut tokens: TokenValues, +) -> TokenValues { + let (handler, parser_contract) = resolve_handler(executor_type, provider, auth_type); + let raw_tokens = tokens; + if handler != TokenHandler::Claude + && !tokens.clamped.cached + && !tokens.clamped.cache_read + && tokens.cache_read == 0 + && tokens.cached > 0 + && (!tokens.cache_read_present || !parser_contract) + { + tokens.cache_read = tokens.cached; + } + + match handler { + TokenHandler::Claude => normalize_claude(tokens, parser_contract), + TokenHandler::Gemini => normalize_gemini(tokens, parser_contract), + TokenHandler::ResponsesInclusive => { + normalize_responses(tokens, parser_contract, executor_type, raw_tokens) + } + TokenHandler::OpenAiCompatibility => normalize_openai_compatibility(tokens), + TokenHandler::Strict => reconcile_zero_total(tokens), + } +} + +fn resolve_handler(executor_type: &str, provider: &str, auth_type: &str) -> (TokenHandler, bool) { + let executor = executor_type.trim().to_ascii_lowercase(); + let handler = match executor.as_str() { + "claudeexecutor" => Some(TokenHandler::Claude), + "geminiexecutor" + | "geminivertexexecutor" + | "geminicliexecutor" + | "aistudioexecutor" + | "antigravityexecutor" => Some(TokenHandler::Gemini), + "codexexecutor" + | "codexwebsocketsexecutor" + | "codexautoexecutor" + | "xaiexecutor" + | "xaiwebsocketsexecutor" + | "xaiautoexecutor" => Some(TokenHandler::ResponsesInclusive), + "kimiexecutor" => Some(TokenHandler::Strict), + "openaicompatexecutor" => Some(TokenHandler::OpenAiCompatibility), + _ => None, + }; + if let Some(handler) = handler { + return (handler, true); + } + + if !auth_type.trim().eq_ignore_ascii_case("oauth") { + return (TokenHandler::Strict, false); + } + let identity = provider.trim().to_ascii_lowercase(); + let handler = match identity.as_str() { + "claude" | "anthropic" => TokenHandler::Claude, + "gemini" + | "vertex" + | "gemini-cli" + | "gemini-cli-code-assist" + | "gemini-interactions" + | "aistudio" + | "ai-studio" + | "antigravity" => TokenHandler::Gemini, + "codex" | "xai" => TokenHandler::ResponsesInclusive, + "kimi" | "moonshot" => TokenHandler::Strict, + "openai" | "openai-compatible" | "openai_compatibility" | "openai-compatibility" => { + TokenHandler::OpenAiCompatibility + } + value if value.starts_with("openai-compatible-") => TokenHandler::OpenAiCompatibility, + _ => TokenHandler::Strict, + }; + (handler, false) +} + +fn normalize_claude(mut tokens: TokenValues, parser_contract: bool) -> TokenValues { + tokens.cached = tokens.cache_read; + if tokens.clamped.input || tokens.clamped.cache_read || tokens.clamped.cache_creation { + return reconcile_zero_total(tokens); + } + let raw_input = tokens.input; + let raw_total = tokens.total; + let Some(canonical_input) = + checked_sum(&[tokens.input, tokens.cache_read, tokens.cache_creation]) + else { + return tokens; + }; + tokens.input = canonical_input; + + let cache_total = checked_sum(&[tokens.cache_read, tokens.cache_creation]); + let raw_expected = checked_sum(&[raw_input, tokens.output]); + let canonical_expected = checked_sum(&[tokens.input, tokens.output]); + let legacy_missing_cache = !tokens.clamped.blocks_zero_total() + && cache_total.is_some_and(|value| value > 0) + && raw_expected == Some(raw_total) + && canonical_expected.is_some_and(|value| value != raw_total); + if parser_contract || legacy_missing_cache { + reconcile_canonical_total(tokens) + } else { + reconcile_zero_total(tokens) + } +} + +fn normalize_gemini(mut tokens: TokenValues, parser_contract: bool) -> TokenValues { + let should_fold = if tokens.clamped.output || tokens.clamped.reasoning { + false + } else if parser_contract { + tokens.reasoning > 0 + } else if tokens.clamped.blocks_reasoning_evidence() || tokens.reasoning == 0 { + false + } else if tokens.total == 0 { + true + } else if checked_sum(&[tokens.input, tokens.output]) == Some(tokens.total) { + false + } else { + checked_sum(&[tokens.input, tokens.output, tokens.reasoning]) == Some(tokens.total) + }; + if should_fold { + let Some(output) = checked_sum(&[tokens.output, tokens.reasoning]) else { + return tokens; + }; + tokens.output = output; + } + if parser_contract && !tokens.clamped.blocks_parent_contract() { + reconcile_canonical_total(tokens) + } else { + reconcile_zero_total(tokens) + } +} + +fn normalize_responses( + tokens: TokenValues, + parser_contract: bool, + executor_type: &str, + raw_tokens: TokenValues, +) -> TokenValues { + let legacy_codex_cached_only = parser_contract + && executor_type.trim().eq_ignore_ascii_case("CodexExecutor") + && !raw_tokens.clamped.any() + && raw_tokens.input == 0 + && raw_tokens.output == 0 + && raw_tokens.reasoning == 0 + && raw_tokens.cached > 0 + && raw_tokens.cache_read == 0 + && raw_tokens.cache_creation == 0 + && raw_tokens.total == raw_tokens.cached; + if legacy_codex_cached_only { + return tokens; + } + if parser_contract && !tokens.clamped.blocks_parent_contract() { + reconcile_canonical_total(tokens) + } else { + reconcile_zero_total(tokens) + } +} + +fn normalize_openai_compatibility(mut tokens: TokenValues) -> TokenValues { + if !tokens.clamped.blocks_reasoning_evidence() + && tokens.reasoning > 0 + && tokens.total > 0 + && checked_sum(&[tokens.input, tokens.output]) != Some(tokens.total) + && checked_sum(&[tokens.input, tokens.output, tokens.reasoning]) == Some(tokens.total) + { + if let Some(output) = checked_sum(&[tokens.output, tokens.reasoning]) { + tokens.output = output; + } + } + reconcile_zero_total(tokens) +} + +fn reconcile_canonical_total(mut tokens: TokenValues) -> TokenValues { + if tokens.clamped.blocks_parent_contract() || tokens.clamped.total { + return tokens; + } + let Some(cache_total) = checked_sum(&[tokens.cache_read, tokens.cache_creation]) else { + return reconcile_zero_total(tokens); + }; + if tokens.input < cache_total || tokens.output < tokens.reasoning { + return reconcile_zero_total(tokens); + } + if let Some(total) = checked_sum(&[tokens.input, tokens.output]) { + tokens.total = total; + } + tokens +} + +fn reconcile_zero_total(mut tokens: TokenValues) -> TokenValues { + if tokens.total != 0 || tokens.clamped.blocks_zero_total() { + return tokens; + } + if let Some(total) = checked_sum(&[tokens.input, tokens.output]) { + if total > 0 { + tokens.total = total; + return tokens; + } + } + if tokens.cache_read > 0 { + tokens.total = tokens.cache_read; + } + tokens +} + +fn checked_sum(values: &[u64]) -> Option { + values + .iter() + .try_fold(0_u64, |total, value| total.checked_add(*value)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn claude_executor_folds_cache_into_input_and_reconciles_total() { + let tokens = normalize( + "ClaudeExecutor", + "anthropic", + "oauth", + TokenValues { + input: 100, + output: 20, + cached: 10, + cache_read: 10, + cache_read_present: true, + cache_creation: 5, + total: 120, + ..TokenValues::default() + }, + ); + assert_eq!(tokens.input, 115); + assert_eq!(tokens.cached, 10); + assert_eq!(tokens.total, 135); + } + + #[test] + fn gemini_executor_folds_reasoning_into_output() { + let tokens = normalize( + "GeminiExecutor", + "gemini", + "oauth", + TokenValues { + input: 11, + output: 7, + reasoning: 3, + total: 21, + ..TokenValues::default() + }, + ); + assert_eq!(tokens.output, 10); + assert_eq!(tokens.total, 21); + } + + #[test] + fn responses_executor_preserves_inclusive_output() { + let tokens = normalize( + "CodexExecutor", + "codex", + "oauth", + TokenValues { + input: 100, + output: 20, + reasoning: 5, + cache_read: 30, + cache_read_present: true, + total: 120, + ..TokenValues::default() + }, + ); + assert_eq!(tokens.output, 20); + assert_eq!(tokens.total, 120); + } + + #[test] + fn openai_compatibility_only_folds_proven_separated_reasoning() { + let tokens = normalize( + "OpenAICompatExecutor", + "openai", + "apikey", + TokenValues { + input: 1_000, + output: 20, + reasoning: 50, + total: 1_070, + ..TokenValues::default() + }, + ); + assert_eq!(tokens.output, 70); + assert_eq!(tokens.total, 1_070); + } + + #[test] + fn explicit_zero_cache_read_is_not_backfilled_from_legacy_cached() { + let tokens = normalize( + "CodexExecutor", + "codex", + "oauth", + TokenValues { + input: 100, + output: 20, + cached: 30, + cache_read_present: true, + total: 120, + ..TokenValues::default() + }, + ); + assert_eq!(tokens.cache_read, 0); + } + + #[test] + fn identity_hint_keeps_keeper_legacy_cache_read_fallback() { + let tokens = normalize( + "", + "codex", + "oauth", + TokenValues { + input: 100, + output: 20, + cached: 30, + cache_read_present: true, + total: 120, + ..TokenValues::default() + }, + ); + assert_eq!(tokens.cache_read, 30); + } + + #[test] + fn unknown_identity_does_not_use_fuzzy_provider_matching() { + let tokens = normalize( + "", + "custom-anthropic-proxy", + "oauth", + TokenValues { + input: 100, + output: 20, + cache_read: 30, + cache_read_present: true, + total: 120, + ..TokenValues::default() + }, + ); + assert_eq!(tokens.input, 100); + assert_eq!(tokens.total, 120); + } +} diff --git a/src/components/ModelSelectionPanel.tsx b/src/components/ModelSelectionPanel.tsx new file mode 100644 index 00000000..90a4c160 --- /dev/null +++ b/src/components/ModelSelectionPanel.tsx @@ -0,0 +1,77 @@ +import { useId, useMemo, useRef, useState } from 'react'; +import { ArrowRight, Search, X } from 'lucide-react'; +import { useI18n } from '../i18n'; +import type { ModelOption } from '../services/modelService'; + +type ModelSelectionPanelProps = { + models: ModelOption[]; + selected: boolean; + loading: boolean; + onMove: (models: ModelOption[], selected: boolean) => void; +}; + +export function ModelSelectionPanel({ models, selected, loading, onMove }: ModelSelectionPanelProps) { + const { t } = useI18n(); + const titleId = useId(); + const searchRef = useRef(null); + const [search, setSearch] = useState(''); + const query = search.trim().toLowerCase(); + const visibleModels = useMemo(() => models.filter((model) => + `${model.name} ${model.alias ?? ''}`.toLowerCase().includes(query), + ), [models, query]); + const searchLabel = t(selected ? 'apiAccess.modelDialog.searchSelected' : 'apiAccess.modelDialog.searchUnselected'); + + return ( +
+
+

{t(selected ? 'apiAccess.modelDialog.selected' : 'apiAccess.modelDialog.unselected')}

+ {query ? `${visibleModels.length} / ${models.length}` : models.length} + +
+
+
+
+ {visibleModels.length ? visibleModels.map((model) => ( + + )) : ( +
+ {t(query ? 'apiAccess.modelDialog.noMatch' : loading ? 'apiAccess.modelDialog.fetching' : selected ? 'apiAccess.modelDialog.emptySelected' : 'apiAccess.modelDialog.emptyUnselected')} +
+ )} +
+
+ ); +} diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 65823554..204b6cad 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', @@ -26,6 +39,7 @@ export const jaOverrides = { 'easyMode.api.fetchingModels': 'モデル一覧を取得中…', 'easyMode.api.modelListTitle': 'モデルを選択', 'easyMode.api.modelListHint': '接続するモデルを選択してください', + 'easyMode.api.modelListStale': '接続情報が変更されました。保存する前にモデル一覧を更新してください', 'easyMode.api.credentialsRequired': '先に API の URL と API キーを入力してください', 'easyMode.api.modelRequired': 'モデル一覧を取得し、モデルを選択してください', 'easyMode.api.testSuccess': '接続に成功しました。{count} 件のモデルが見つかりました', @@ -413,7 +427,7 @@ export const jaOverrides = { 'usage.stat.averageLatency': '平均レイテンシ', 'usage.stat.tps': 'TPS', 'usage.stat.performanceMeta': '有効サンプル {samples} 件 · RPM {rpm}', - 'usage.stat.performanceMetaTitle': 'TPS {tps}、有効サンプル {samples} 件、RPM {rpm}、平均レイテンシ {latency} ms', + 'usage.stat.performanceMetaTitle': 'TPS {tps}(総レイテンシで加重)、有効サンプル {samples} 件、RPM {rpm}、平均レイテンシ {latency} ms', 'usage.stat.cacheHitRate': 'キャッシュヒット率', 'usage.stat.cacheHitMeta': 'キャッシュ読取 {hit} · 入力 {input}', 'usage.stat.cacheHitMetaTitle': 'キャッシュヒット率 {rate}%、読取 {hit} Token、入力 {input} Token', @@ -497,7 +511,7 @@ export const jaOverrides = { 'usage.column.cache': 'キャッシュ', 'usage.column.cacheRate': 'キャッシュ率', 'usage.column.total': '合計', - 'usage.column.speed': '生成速度', + 'usage.column.speed': '出力速度', 'usage.key.noRemark': 'メモなし', 'usage.previous': '前へ', 'usage.next': '次へ', @@ -1126,6 +1140,7 @@ export const jaOverrides = { 'apiAccess.error.requiredKey': 'API キーを入力してください', 'apiAccess.error.baseRequired': '{provider} 接続には Base URL が必要です', 'apiAccess.error.noModels': '公開可能なモデルが見つかりません。Base URL と API キーを確認してください', + 'apiAccess.error.fetchModelsBeforeSave': 'モデル一覧を正常に取得し、少なくとも 1 件選択してください', 'apiAccess.error.stale': '接続設定が別の操作で変更されました。更新してから再試行してください', 'apiAccess.error.duplicate': '同じ接続設定がすでに存在します', 'apiAccess.error.remarkInvalid': 'メモは 80 文字以内で、改行や制御文字を含めないでください', @@ -1186,6 +1201,9 @@ export const jaOverrides = { 'apiAccess.error.noAvailableModels': '利用可能なモデルが見つかりません', 'apiAccess.error.baseBeforeModels': 'モデルを取得する前に Base URL を入力してください', 'apiAccess.models.selected': '{count} 件のモデルを選択済み', + 'apiAccess.models.selectionRequired': 'モデルが選択されていません', + 'apiAccess.models.selectionRequiredHint': 'モデル一覧を取得し、少なくとも 1 件選択してください', + 'apiAccess.models.staleHint': '接続情報が変更されました。モデル一覧を再取得してください', 'apiAccess.models.restricted': 'モデル制限を設定済み', 'apiAccess.models.autoAll': '保存時にすべてのモデルを既定で公開', 'apiAccess.models.upstreamDefault': '上流の既定モデルを使用', @@ -1200,10 +1218,6 @@ export const jaOverrides = { 'apiAccess.remarkPlaceholder': '例:本番環境、チームのメインアカウント', 'apiAccess.baseRequiredPlaceholder': '必須、例:https://api.example.com', 'apiAccess.baseOptionalPlaceholder': '任意。空欄の場合は既定のアドレスを使用', - 'apiAccess.preset.title': 'OpenAI 互換プリセット', - 'apiAccess.preset.description': 'DeepSeek 公式アドレスを入力済みです。保存時にすべてのモデルを自動検出して公開します', - 'apiAccess.thinking.builtIn': '組み込み推論レベル', - 'apiAccess.thinking.builtInDescription': '現在公開中のすべてのモデルに自動適用します', 'apiAccess.thinking.title': '推論レベル', 'apiAccess.thinking.description': '上流の対応状況に合わせて追加してください', 'apiAccess.thinking.placeholder': '例:low、medium、またはカスタムレベル', @@ -1239,6 +1253,20 @@ export const jaOverrides = { 'apiAccess.modelDialog.summary': '{found} 件検出 · {selected} 件選択済み', 'apiAccess.modelDialog.deselectAll': 'すべて選択解除', 'apiAccess.modelDialog.selectVisible': '表示中をすべて選択', + 'apiAccess.modelDialog.unselected': '未選択のモデル', + 'apiAccess.modelDialog.selected': '選択済みのモデル', + 'apiAccess.modelDialog.searchUnselected': '未選択のモデルを検索', + 'apiAccess.modelDialog.searchSelected': '選択済みのモデルを検索', + 'apiAccess.modelDialog.clearSearch': '検索をクリア', + 'apiAccess.modelDialog.addAll': 'すべて追加', + 'apiAccess.modelDialog.removeAll': 'すべて解除', + 'apiAccess.modelDialog.addResults': '検索結果を追加', + 'apiAccess.modelDialog.removeResults': '検索結果を解除', + 'apiAccess.modelDialog.addModel': '{name} を追加', + 'apiAccess.modelDialog.removeModel': '{name} を解除', + 'apiAccess.modelDialog.emptySelected': 'まだモデルが選択されていません', + 'apiAccess.modelDialog.emptyUnselected': '未選択のモデルはありません', + 'apiAccess.modelDialog.chooseOne': 'モデルを1つ以上選択してください', 'common.clear': '消去', 'apiAccess.modelDialog.fetching': 'モデルを取得しています', 'apiAccess.modelDialog.fetchFailed': 'モデルの取得に失敗しました', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index cbe6e00f..6abddf19 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', @@ -244,6 +257,7 @@ export const en: Record = { 'easyMode.api.fetchingModels': 'Fetching model list...', 'easyMode.api.modelListTitle': 'Select models', 'easyMode.api.modelListHint': 'Select the models to connect', + 'easyMode.api.modelListStale': 'Connection details changed. Refresh the model list before saving.', 'easyMode.api.credentialsRequired': 'Enter an API URL and API key first', 'easyMode.api.modelRequired': 'Fetch and select a model', 'easyMode.api.testSuccess': 'Connection successful! Found {count} models', @@ -388,7 +402,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', @@ -400,14 +414,14 @@ export const en: Record = { 'usage.stat.averageLatency': 'Average Latency', 'usage.stat.tps': 'TPS', 'usage.stat.performanceMeta': '{samples} samples · RPM {rpm}', - 'usage.stat.performanceMetaTitle': 'TPS {tps}, {samples} valid samples, RPM {rpm}, average latency {latency} ms', + 'usage.stat.performanceMetaTitle': 'TPS {tps} (weighted by total latency), {samples} valid samples, RPM {rpm}, average latency {latency} ms', 'usage.stat.cacheHitRate': 'Cache Hit Rate', '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', @@ -485,7 +499,7 @@ export const en: Record = { 'usage.column.cache': 'Cache', 'usage.column.cacheRate': 'Cache Rate', 'usage.column.total': 'Total', - 'usage.column.speed': 'Generation Speed', + 'usage.column.speed': 'Output Speed', 'usage.key.noRemark': 'No remark', 'usage.previous': 'Previous', 'usage.next': 'Next', @@ -1126,6 +1140,7 @@ export const en: Record = { 'apiAccess.error.requiredKey': 'API key is required', 'apiAccess.error.baseRequired': 'A Base URL is required for {provider}', 'apiAccess.error.noModels': 'No models were discovered. Check the Base URL and API key.', + 'apiAccess.error.fetchModelsBeforeSave': 'Fetch the model list successfully and select at least one model', 'apiAccess.error.stale': 'The connection configuration changed elsewhere. Refresh and try again.', 'apiAccess.error.duplicate': 'An identical connection already exists', 'apiAccess.error.remarkInvalid': 'The remark cannot exceed 80 characters or contain line breaks or control characters', @@ -1186,6 +1201,9 @@ export const en: Record = { 'apiAccess.error.noAvailableModels': 'No available models found', 'apiAccess.error.baseBeforeModels': 'Enter a Base URL before fetching models', 'apiAccess.models.selected': '{count} models selected', + 'apiAccess.models.selectionRequired': 'No models selected', + 'apiAccess.models.selectionRequiredHint': 'Fetch the model list and select at least one model', + 'apiAccess.models.staleHint': 'Connection details changed. Fetch the model list again.', 'apiAccess.models.restricted': 'Model restrictions configured', 'apiAccess.models.autoAll': 'All models will be opened by default when saved', 'apiAccess.models.upstreamDefault': 'Using upstream default models', @@ -1200,10 +1218,6 @@ export const en: Record = { 'apiAccess.remarkPlaceholder': 'For example, Production or Team primary account', 'apiAccess.baseRequiredPlaceholder': 'Required, for example https://api.example.com', 'apiAccess.baseOptionalPlaceholder': 'Optional; leave blank to use the default address', - 'apiAccess.preset.title': 'OpenAI-Compatible Preset', - 'apiAccess.preset.description': 'The official DeepSeek address is prefilled; all discovered models are opened when saved', - 'apiAccess.thinking.builtIn': 'Built-in Reasoning Levels', - 'apiAccess.thinking.builtInDescription': 'Applied automatically to all currently open models', 'apiAccess.thinking.title': 'Reasoning Levels', 'apiAccess.thinking.description': 'Add levels supported by the upstream provider', 'apiAccess.thinking.placeholder': 'For example: low, medium, or a custom level', @@ -1239,6 +1253,20 @@ export const en: Record = { 'apiAccess.modelDialog.summary': '{found} found · {selected} selected', 'apiAccess.modelDialog.deselectAll': 'Deselect All', 'apiAccess.modelDialog.selectVisible': 'Select Visible', + 'apiAccess.modelDialog.unselected': 'Unselected Models', + 'apiAccess.modelDialog.selected': 'Selected Models', + 'apiAccess.modelDialog.searchUnselected': 'Search unselected models', + 'apiAccess.modelDialog.searchSelected': 'Search selected models', + 'apiAccess.modelDialog.clearSearch': 'Clear search', + 'apiAccess.modelDialog.addAll': 'Add All', + 'apiAccess.modelDialog.removeAll': 'Remove All', + 'apiAccess.modelDialog.addResults': 'Add Results', + 'apiAccess.modelDialog.removeResults': 'Remove Results', + 'apiAccess.modelDialog.addModel': 'Add {name}', + 'apiAccess.modelDialog.removeModel': 'Remove {name}', + 'apiAccess.modelDialog.emptySelected': 'No models selected yet', + 'apiAccess.modelDialog.emptyUnselected': 'No unselected models', + 'apiAccess.modelDialog.chooseOne': 'Select at least one model', 'common.clear': 'Clear', 'apiAccess.modelDialog.fetching': 'Fetching models', 'apiAccess.modelDialog.fetchFailed': 'Failed to fetch models', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 778e0c4d..c82e775e 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', @@ -243,6 +256,7 @@ export const zhCN = { 'easyMode.api.fetchingModels': '正在获取模型列表...', 'easyMode.api.modelListTitle': '选择模型', 'easyMode.api.modelListHint': '勾选需要接入的模型', + 'easyMode.api.modelListStale': '连接信息已变化,请刷新模型列表后再保存', 'easyMode.api.credentialsRequired': '请先填写 API URL 和 API 密钥', 'easyMode.api.modelRequired': '请先获取模型列表并选择模型', 'easyMode.api.testSuccess': '连接成功!发现 {count} 个模型', @@ -399,7 +413,7 @@ export const zhCN = { 'usage.stat.averageLatency': '平均延迟', 'usage.stat.tps': 'TPS', 'usage.stat.performanceMeta': '{samples} 条有效样本 · RPM {rpm}', - 'usage.stat.performanceMetaTitle': 'TPS {tps},{samples} 条有效样本,RPM {rpm},平均延迟 {latency} ms', + 'usage.stat.performanceMetaTitle': 'TPS {tps}(按总延迟加权),{samples} 条有效样本,RPM {rpm},平均延迟 {latency} ms', 'usage.stat.cacheHitRate': '缓存命中率', 'usage.stat.cacheHitMeta': '缓存读取 {hit} · 输入 {input}', 'usage.stat.cacheHitMetaTitle': '缓存命中率 {rate}%,缓存读取 {hit} Token,输入 {input} Token', @@ -484,7 +498,7 @@ export const zhCN = { 'usage.column.cache': '缓存', 'usage.column.cacheRate': '缓存率', 'usage.column.total': '总计', - 'usage.column.speed': '生成速度', + 'usage.column.speed': '输出速度', 'usage.key.noRemark': '未备注', 'usage.previous': '上一页', 'usage.next': '下一页', @@ -1125,6 +1139,7 @@ export const zhCN = { 'apiAccess.error.requiredKey': 'API 密钥不能为空', 'apiAccess.error.baseRequired': '{provider} 接入必须填写 Base URL', 'apiAccess.error.noModels': '没有发现可放行的模型,请确认 Base URL 和 API 密钥', + 'apiAccess.error.fetchModelsBeforeSave': '请先成功获取模型列表,并至少选择一个模型', 'apiAccess.error.stale': '接入配置已被其他操作修改,请刷新后重试', 'apiAccess.error.duplicate': '相同的接入配置已经存在', 'apiAccess.error.remarkInvalid': '备注不能超过 80 个字符,且不能包含换行或控制字符', @@ -1185,6 +1200,9 @@ export const zhCN = { 'apiAccess.error.noAvailableModels': '未发现可用模型', 'apiAccess.error.baseBeforeModels': '请先填写 Base URL,再获取模型', 'apiAccess.models.selected': '已选择 {count} 个模型', + 'apiAccess.models.selectionRequired': '尚未选择模型', + 'apiAccess.models.selectionRequiredHint': '请先获取模型列表,并选择至少一个模型', + 'apiAccess.models.staleHint': '连接信息已变化,请重新获取模型列表', 'apiAccess.models.restricted': '已配置模型限制', 'apiAccess.models.autoAll': '保存时默认开放全部模型', 'apiAccess.models.upstreamDefault': '使用上游默认模型', @@ -1199,10 +1217,6 @@ export const zhCN = { 'apiAccess.remarkPlaceholder': '例如:生产环境、团队主账号', 'apiAccess.baseRequiredPlaceholder': '必填,例如 https://api.example.com', 'apiAccess.baseOptionalPlaceholder': '可选,留空使用默认地址', - 'apiAccess.preset.title': 'OpenAI 兼容预设', - 'apiAccess.preset.description': '已预填 DeepSeek 官方地址,保存时自动发现并开放全部模型', - 'apiAccess.thinking.builtIn': '内置思考等级', - 'apiAccess.thinking.builtInDescription': '自动应用到当前开放的全部模型', 'apiAccess.thinking.title': '思考等级', 'apiAccess.thinking.description': '按上游支持情况自行添加', 'apiAccess.thinking.placeholder': '例如 low、medium 或自定义等级', @@ -1238,6 +1252,20 @@ export const zhCN = { 'apiAccess.modelDialog.summary': '找到 {found} 个 · 已选择 {selected} 个', 'apiAccess.modelDialog.deselectAll': '取消全选', 'apiAccess.modelDialog.selectVisible': '全选当前', + 'apiAccess.modelDialog.unselected': '未选模型', + 'apiAccess.modelDialog.selected': '已选模型', + 'apiAccess.modelDialog.searchUnselected': '搜索未选模型', + 'apiAccess.modelDialog.searchSelected': '搜索已选模型', + 'apiAccess.modelDialog.clearSearch': '清除搜索', + 'apiAccess.modelDialog.addAll': '全部添加', + 'apiAccess.modelDialog.removeAll': '全部移除', + 'apiAccess.modelDialog.addResults': '添加搜索结果', + 'apiAccess.modelDialog.removeResults': '移除搜索结果', + 'apiAccess.modelDialog.addModel': '添加 {name}', + 'apiAccess.modelDialog.removeModel': '移除 {name}', + 'apiAccess.modelDialog.emptySelected': '暂无已选模型', + 'apiAccess.modelDialog.emptyUnselected': '暂无未选模型', + 'apiAccess.modelDialog.chooseOne': '请至少选择一个模型', 'common.clear': '清空', 'apiAccess.modelDialog.fetching': '正在获取模型', 'apiAccess.modelDialog.fetchFailed': '获取模型失败', diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index 69a09bcf..373893f9 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -1,4 +1,6 @@ +import { createPortal } from 'react-dom'; import { useConfirmation } from '../components/ConfirmationDialog'; +import { ModelSelectionPanel } from '../components/ModelSelectionPanel'; import { type CSSProperties, FormEvent, @@ -27,7 +29,6 @@ import { } from '@dnd-kit/sortable'; import { CSS as DndCss } from '@dnd-kit/utilities'; import { - Check, Edit3, Filter, GripVertical, @@ -53,9 +54,12 @@ import { responseList, } from '../services/managementApi'; import { + DEEPSEEK_BASE_URL, fetchModels, + mergeModelOptions, modelsFromRecord, normalizeBaseUrl, + reconcileModelSelection, type ModelOption, type ModelProvider, } from '../services/modelService'; @@ -80,9 +84,8 @@ export type ProviderSection = export type ProviderCategory = ProviderSection | 'deepseek'; -export const DEEPSEEK_BASE_URL = 'https://api.deepseek.com'; export const OPENAI_THINKING_LEVELS = ['low', 'medium', 'high', 'xhigh'] as const; -export const DEEPSEEK_THINKING_LEVELS = ['low', 'high', 'max'] as const; +export { DEEPSEEK_BASE_URL }; type ProviderDefinition = { id: ProviderCategory; @@ -224,11 +227,11 @@ const providerDefinitions: ProviderDefinition[] = [ }, { id: 'deepseek', - section: 'openai-compatibility', - responseKey: 'openai-compatibility', + section: 'codex-api-key', + responseKey: 'codex-api-key', labelKey: 'apiAccess.provider.deepseek', icon: deepseekIcon, - openAi: true, + openAi: false, }, { id: 'claude-api-key', section: 'claude-api-key', responseKey: 'claude-api-key', labelKey: 'apiAccess.provider.claude', icon: claudeIcon, openAi: false }, { id: 'gemini-api-key', section: 'gemini-api-key', responseKey: 'gemini-api-key', labelKey: 'apiAccess.provider.gemini', icon: geminiIcon, openAi: false }, @@ -260,9 +263,17 @@ const isDeepSeekRecord = (record: Record) => { export const providerCategoryMatchesRecord = ( category: ProviderCategory, record: Record, + section: ProviderSection = definitionFor(category).section, ) => { - if (category === 'deepseek') return isDeepSeekRecord(record); - if (category === 'openai-compatibility') return !isDeepSeekRecord(record); + if (category === 'deepseek') { + return section === 'codex-api-key' && isDeepSeekRecord(record); + } + if (category === 'codex-api-key') { + return section === 'codex-api-key' && !isDeepSeekRecord(record); + } + if (category === 'openai-compatibility') { + return section === 'openai-compatibility'; + } return true; }; @@ -323,9 +334,13 @@ const providerHealthIdentity = (row: ProviderRow) => [ row.models.map((model) => model.name).join('\u0000'), ].join('\u0001'); -const providerModelType = (section: ProviderSection): ModelProvider => { +const providerModelType = ( + section: ProviderSection, + record?: Record, +): ModelProvider => { if (section === 'gemini-api-key') return 'gemini'; if (section === 'claude-api-key') return 'claude'; + if (section === 'codex-api-key' && record && isDeepSeekRecord(record)) return 'deepseek'; if (section === 'codex-api-key') return 'codex'; return 'openai'; }; @@ -430,9 +445,6 @@ export const modelSelectionForDiscovery = ( ); }; -export const allModelSelectionForDiscovery = (models: ModelOption[]) => - new Set(models.map((model) => model.name.trim().toLowerCase()).filter(Boolean)); - export const parseProviderApiKeys = (value: string) => value .split(/\r?\n/) .map((item) => item.trim()) @@ -472,9 +484,9 @@ const thinkingLevelsFromModels = (models: ModelOption[]): string[] => { const draftFromRow = (row: ProviderRow): ProviderDraft => { const definition = definitionFor(row.section); - const isDeepSeek = row.section === 'openai-compatibility' && isDeepSeekRecord(row.record); + const isDeepSeek = row.section === 'codex-api-key' && isDeepSeekRecord(row.record); return { - name: row.name, + name: isDeepSeek ? 'DeepSeek' : row.name, apiKey: definition.openAi ? row.apiKeys.join('\n') : row.apiKey, remark: row.remark || (definition.openAi && !isDeepSeek ? row.name : ''), baseUrl: row.baseUrl, @@ -539,7 +551,6 @@ export const createProviderDraft = (category: ProviderCategory): ProviderDraft = name: 'DeepSeek', remark: '', baseUrl: DEEPSEEK_BASE_URL, - thinkingLevels: [...DEEPSEEK_THINKING_LEVELS], }; }; @@ -557,9 +568,7 @@ export const applyProviderPreset = ( draft: ProviderDraft, ): ProviderDraft => { if (!definitionFor(category).openAi || draft.thinkingLevels === undefined) return draft; - const levels = category === 'deepseek' - ? [...DEEPSEEK_THINKING_LEVELS] - : draft.thinkingLevels; + const levels = draft.thinkingLevels; return { ...draft, models: draft.models.map((model) => { @@ -695,6 +704,9 @@ export const buildProviderRecord = ( 'api-key': draft.apiKey.trim(), models, }; + if (section === 'codex-api-key' && draft.name.trim().toLowerCase() === 'deepseek') { + next.name = 'DeepSeek'; + } if (draft.baseUrl.trim()) next['base-url'] = draft.baseUrl.trim(); else delete next['base-url']; if (priority !== null && Number.isFinite(priority)) next.priority = priority; @@ -805,7 +817,6 @@ export function ApiAccessPage() { const [error, setError] = useState(''); const feedback = useAppNotice(); const { showNotice: setNotice } = feedback; - const [feedbackRow, setFeedbackRow] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); const [editingRow, setEditingRow] = useState(null); const [dialogDraft, setDialogDraft] = useState(emptyProviderDraft); @@ -892,9 +903,12 @@ export function ApiAccessPage() { .map((record, index) => rowFromRecord(activeSection, record, index)) .map((row) => ({ ...row, + name: activeCategory === 'deepseek' + ? t('apiAccess.provider.deepseek') + : row.name, remark: apiAccessRemarks[providerRemarkIdentity(row.section, row.apiKeys)] ?? '', })) - .filter((row) => providerCategoryMatchesRecord(activeCategory, row.record)) + .filter((row) => providerCategoryMatchesRecord(activeCategory, row.record, activeSection)) .filter((row) => { const query = filter.trim().toLowerCase(); if (!query) return true; @@ -903,12 +917,11 @@ export function ApiAccessPage() { .toLowerCase() .includes(query); }), - [activeCategory, activeSection, apiAccessRemarks, filter, records], + [activeCategory, activeSection, apiAccessRemarks, filter, records, t], ); const openCreate = () => { feedback.clearNotice(); - setFeedbackRow(null); setError(''); setEditingRow(null); setDialogDraft(createProviderDraft(activeCategory)); @@ -917,18 +930,17 @@ export function ApiAccessPage() { const openEdit = (row: ProviderRow) => { feedback.clearNotice(); - setFeedbackRow(null); setError(''); setEditingRow(row); const draft = draftFromRow(row); - setDialogDraft(activeCategory === 'deepseek' - ? { ...draft, thinkingLevels: [...DEEPSEEK_THINKING_LEVELS] } - : draft); + setDialogDraft(draft); setDialogOpen(true); }; - const saveProvider = async (nextDraft: ProviderDraft): Promise => { - setFeedbackRow(null); + const saveProvider = async ( + nextDraft: ProviderDraft, + modelDiscoveryReady: boolean, + ): Promise => { const definition = activeDefinition; const preparedDraft = applyProviderRemarkIdentity( activeCategory, @@ -956,6 +968,16 @@ export function ApiAccessPage() { : t('apiAccess.error.requiredKey'), }; } + if ( + activeCategory === 'deepseek' + && (!modelDiscoveryReady || preparedDraftForSave.models.length === 0) + ) { + return { + saved: false, + target: 'models', + error: t('apiAccess.error.fetchModelsBeforeSave'), + }; + } if (Array.from(preparedDraft.remark.trim()).length > 80 || /[\u0000-\u001f\u007f]/.test(preparedDraft.remark)) { return { saved: false, target: 'form', error: t('apiAccess.error.remarkInvalid') }; } @@ -972,7 +994,11 @@ export function ApiAccessPage() { setError(''); try { let draftToSave = { ...preparedDraftForSave, baseUrl }; - if (definition.openAi && draftToSave.models.length === 0) { + if ( + definition.openAi + && activeCategory !== 'deepseek' + && draftToSave.models.length === 0 + ) { let fetchedModels: ModelOption[]; try { fetchedModels = await fetchModels( @@ -1057,7 +1083,6 @@ export function ApiAccessPage() { const deleteRow = async (row: ProviderRow) => { if (!await askConfirmation({ title: t('common.delete'), message: t('apiAccess.deleteConfirm', { remark: row.remark || row.name }), confirmText: t('common.delete'), variant: 'danger' })) return; - setFeedbackRow(providerDragId(row)); feedback.clearNotice(); setBusy(true); setError(''); @@ -1077,7 +1102,6 @@ export function ApiAccessPage() { remark: '', }, }); - setFeedbackRow(null); setNotice({ key: 'apiAccess.notice.deleted' }); await loadProviders(); } catch (requestError) { @@ -1088,7 +1112,6 @@ export function ApiAccessPage() { }; const toggleProvider = async (row: ProviderRow) => { - setFeedbackRow(providerDragId(row)); setBusy(true); setError(''); setNotice(''); @@ -1131,7 +1154,6 @@ export function ApiAccessPage() { const reorderProviders = async (source: ProviderRow, target: ProviderRow) => { if (source.section !== target.section || source.index === target.index) return; - setFeedbackRow(providerDragId(source)); setBusy(true); setError(''); setNotice(''); @@ -1173,7 +1195,7 @@ export function ApiAccessPage() { const countForDefinition = (definition: ProviderDefinition) => records[definition.section].filter((record) => - providerCategoryMatchesRecord(definition.id, record) + providerCategoryMatchesRecord(definition.id, record, definition.section) ).length; return ( @@ -1197,7 +1219,6 @@ export function ApiAccessPage() { {error ?
{error}
: null} -
- {feedbackRow === null ? : null} {loading ? (
{t('apiAccess.loading')}
) : rows.length === 0 ? ( @@ -1273,7 +1292,6 @@ export function ApiAccessPage() { {row.baseUrl || t('apiAccess.defaultUrl')} {row.models.length > 0 ? {t('apiAccess.models.summary', { count: row.models.length })} : null} - {feedbackRow === providerDragId(row) ? : null} {row.priority === null ? null : (
@@ -1335,6 +1353,17 @@ export function ApiAccessPage() { onClose={() => setHealthDialogRow(null)} /> ) : null} + {typeof document === 'undefined' + ? null + : createPortal( + , + document.body, + )} ); } @@ -1367,7 +1396,7 @@ function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) { }, []); const healthOptions = useMemo(() => ({ - provider: providerModelType(row.section), + provider: providerModelType(row.section, row.record), baseUrl: row.baseUrl, apiKeys: row.apiKeys, authIndex: row.authIndex, @@ -1563,10 +1592,10 @@ type ApiProviderDialogProps = { initialDraft: ProviderDraft; busy: boolean; onClose: () => void; - onSave: (draft: ProviderDraft) => Promise; + onSave: (draft: ProviderDraft, modelDiscoveryReady: boolean) => Promise; }; -function ApiProviderDialog({ +export function ApiProviderDialog({ activeCategory, editingRow, initialDraft, @@ -1583,37 +1612,73 @@ function ApiProviderDialog({ const [formError, setFormError] = useState(''); const [discoveredModels, setDiscoveredModels] = useState([]); const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false); - const [modelSearch, setModelSearch] = useState(''); + const [modelDiscoveryReady, setModelDiscoveryReady] = useState( + () => activeCategory !== 'deepseek' + || Boolean(editingRow && initialDraft.models.some((model) => model.name.trim())), + ); const [thinkingLevelInput, setThinkingLevelInput] = useState(''); const [selectedModelNames, setSelectedModelNames] = useState>( - () => new Set(initialDraft.models.map((model) => model.name.toLowerCase())), + () => new Set(mergeModelOptions(initialDraft.models).map((model) => model.name.toLowerCase())), ); const modelCardRef = useRef(null); + const modelDialogRef = useRef(null); + const discoverySelectionInitializedRef = useRef(false); + const discoveryRequestRef = useRef(0); - const modelOptions = useMemo(() => { - const options = new Map(); - [...discoveredModels, ...draft.models].forEach((model) => { - const name = model.name.trim(); - if (name) options.set(name.toLowerCase(), { ...model, name }); - }); - return Array.from(options.values()); - }, [discoveredModels, draft.models]); + const closeModelDiscovery = useCallback(() => { + discoveryRequestRef.current += 1; + setModelLoading(false); + setModelDiscoveryOpen(false); + }, []); + + useEffect(() => () => { discoveryRequestRef.current += 1; }, []); + + useEffect(() => { + if (!modelDiscoveryOpen) return; + const previousFocus = document.activeElement; + const dialog = modelDialogRef.current; + dialog?.querySelector('input')?.focus(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + closeModelDiscovery(); + } else if (event.key === 'Tab') { + const controls = Array.from(dialog?.querySelectorAll('button:not(:disabled), input:not(:disabled)') ?? []); + const first = controls[0]; + const last = controls[controls.length - 1]; + if (event.shiftKey && (document.activeElement === first || !dialog?.contains(document.activeElement))) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && (document.activeElement === last || !dialog?.contains(document.activeElement))) { + event.preventDefault(); + first?.focus(); + } + } + }; + document.addEventListener('keydown', onKeyDown, true); + return () => { + document.removeEventListener('keydown', onKeyDown, true); + if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus(); + }; + }, [modelDiscoveryOpen, closeModelDiscovery]); + + const modelOptions = useMemo( + () => mergeModelOptions(discoveredModels, draft.models), + [discoveredModels, draft.models], + ); const configuredModels = useMemo( () => draft.models.filter((model) => model.name.trim()), [draft.models], ); - const visibleModelOptions = useMemo(() => { - const query = modelSearch.trim().toLowerCase(); - if (!query) return modelOptions; - return modelOptions.filter((model) => - `${model.name} ${model.alias ?? ''}`.toLowerCase().includes(query), - ); - }, [modelOptions, modelSearch]); - - const allVisibleModelsSelected = visibleModelOptions.length > 0 - && visibleModelOptions.every((model) => selectedModelNames.has(model.name.toLowerCase())); + const selectedModels = useMemo(() => modelOptions.filter((model) => + selectedModelNames.has(model.name.toLowerCase()), + ), [modelOptions, selectedModelNames]); + const unselectedModels = useMemo(() => modelOptions.filter((model) => + !selectedModelNames.has(model.name.toLowerCase()), + ), [modelOptions, selectedModelNames]); const updateTextField = ( field: 'apiKey' | 'remark' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText', @@ -1621,7 +1686,10 @@ function ApiProviderDialog({ ) => { setFormError(''); if (field === 'apiKey' || field === 'baseUrl' || field === 'headersText') { + discoveryRequestRef.current += 1; + setModelLoading(false); setModelError(''); + if (activeCategory === 'deepseek') setModelDiscoveryReady(false); } setDraft((current) => ({ ...current, [field]: value })); }; @@ -1685,16 +1753,19 @@ function ApiProviderDialog({ setModelError(t('apiAccess.error.enterBaseUrl')); return; } + const requestId = ++discoveryRequestRef.current; setModelLoading(true); setModelError(''); try { - const provider: ModelProvider = definition.section === 'gemini-api-key' - ? 'gemini' - : definition.section === 'claude-api-key' - ? 'claude' - : definition.section === 'codex-api-key' - ? 'codex' - : 'openai'; + const provider: ModelProvider = activeCategory === 'deepseek' + ? 'deepseek' + : definition.section === 'gemini-api-key' + ? 'gemini' + : definition.section === 'claude-api-key' + ? 'claude' + : definition.section === 'codex-api-key' + ? 'codex' + : 'openai'; const modelApiKey = draft.apiKey.split(/\r?\n/).map((value) => value.trim()).find(Boolean) ?? ''; const fetchedModels = await fetchModels( provider, @@ -1703,21 +1774,24 @@ function ApiProviderDialog({ editingRow?.authIndex, parseProviderHeaders(draft.headersText ?? ''), ); + if (requestId !== discoveryRequestRef.current) return; const models = applyProviderPreset( activeCategory, { ...draft, models: fetchedModels }, ).models; + const initialized = discoverySelectionInitializedRef.current; setDiscoveredModels(models); - setSelectedModelNames(new Set( - [...models, ...draft.models] - .map((model) => model.name.trim().toLowerCase()) - .filter(Boolean), - )); + setSelectedModelNames((current) => + initialized + ? reconcileModelSelection(models, draft.models, current, 'refresh') + : modelSelectionForDiscovery(activeSection, draft.models, models, draft.excludedModelsText ?? '')); + discoverySelectionInitializedRef.current = true; + if (activeCategory === 'deepseek') setModelDiscoveryReady(true); if (!models.length) setModelError(t('apiAccess.error.noAvailableModels')); } catch (requestError) { - setModelError(requestErrorMessage(requestError)); + if (requestId === discoveryRequestRef.current) setModelError(requestErrorMessage(requestError)); } finally { - setModelLoading(false); + if (requestId === discoveryRequestRef.current) setModelLoading(false); } }; @@ -1728,41 +1802,30 @@ function ApiProviderDialog({ setModelError(t('apiAccess.error.baseBeforeModels')); return; } - setModelSearch(''); - setSelectedModelNames(new Set(draft.models.map((model) => model.name.toLowerCase()))); + discoverySelectionInitializedRef.current = false; + setSelectedModelNames(modelSelectionForDiscovery(activeSection, draft.models, discoveredModels, draft.excludedModelsText ?? '')); setModelDiscoveryOpen(true); void discoverModels(); }; - const toggleModelSelection = (model: ModelOption) => { - const key = model.name.toLowerCase(); + const moveModels = (models: ModelOption[], selected: boolean) => { + discoverySelectionInitializedRef.current = true; setSelectedModelNames((current) => { const next = new Set(current); - if (next.has(key)) next.delete(key); - else next.add(key); - return next; - }); - }; - - const toggleAllVisibleModels = () => { - setSelectedModelNames((current) => { - const next = new Set(current); - visibleModelOptions.forEach((model) => { + models.forEach((model) => { const key = model.name.toLowerCase(); - if (allVisibleModelsSelected) next.delete(key); - else next.add(key); + if (selected) next.add(key); + else next.delete(key); }); return next; }); }; const applyModelSelection = () => { - const models = modelOptions.filter((model) => - selectedModelNames.has(model.name.toLowerCase()), - ); + if (modelLoading || selectedModels.length === 0) return; setDraft((current) => ({ ...current, - models, + models: selectedModels, excludedModelsText: activeSection === 'openai-compatibility' ? current.excludedModelsText : exclusionsForModelSelection( @@ -1771,13 +1834,13 @@ function ApiProviderDialog({ selectedModelNames, ), })); - setModelDiscoveryOpen(false); + closeModelDiscovery(); }; const submit = async (event: FormEvent) => { event.preventDefault(); setFormError(''); - const result = await onSave(draft); + const result = await onSave(draft, modelDiscoveryReady); if (result.saved) { onClose(); return; @@ -1794,15 +1857,22 @@ function ApiProviderDialog({ const hasModelExclusions = activeSection !== 'openai-compatibility' && Boolean(draft.excludedModelsText?.trim()); + const deepSeekModelsStale = activeCategory === 'deepseek' && !modelDiscoveryReady; const modelSummaryTitle = configuredModels.length > 0 ? t('apiAccess.models.selected', { count: configuredModels.length }) + : activeCategory === 'deepseek' + ? t('apiAccess.models.selectionRequired') : hasModelExclusions ? t('apiAccess.models.restricted') : activeSection === 'openai-compatibility' ? t('apiAccess.models.autoAll') : t('apiAccess.models.upstreamDefault'); const modelSummaryDetail = configuredModels.length > 0 - ? configuredModels.slice(0, 3).map((model) => model.name).join('、') + ? deepSeekModelsStale + ? t('apiAccess.models.staleHint') + : configuredModels.slice(0, 3).map((model) => model.name).join('、') + : activeCategory === 'deepseek' + ? t('apiAccess.models.selectionRequiredHint') : hasModelExclusions ? t('apiAccess.models.hiddenHint') : activeSection === 'openai-compatibility' @@ -1834,26 +1904,7 @@ function ApiProviderDialog({ /> - {activeCategory === 'deepseek' ? ( -
- -
- {t('apiAccess.preset.title')} - {t('apiAccess.preset.description')} -
-
- ) : null} - {activeCategory === 'deepseek' ? ( -
-
- {t('apiAccess.thinking.builtIn')} - {t('apiAccess.thinking.builtInDescription')} -
-
- {DEEPSEEK_THINKING_LEVELS.map((level) => {level})} -
-
- ) : activeCategory === 'openai-compatibility' ? ( + {activeCategory === 'openai-compatibility' ? (
{t('apiAccess.thinking.title')} @@ -2003,63 +2054,36 @@ function ApiProviderDialog({
{modelDiscoveryOpen ? ( -
event.currentTarget === event.target && setModelDiscoveryOpen(false)}> -
+
event.currentTarget === event.target && closeModelDiscovery()}> +

{t('apiAccess.modelDialog.title')}

{t(definition.labelKey)}
- +
-
-
diff --git a/src/pages/EasyModePage.tsx b/src/pages/EasyModePage.tsx index 96f65c90..17dd271e 100644 --- a/src/pages/EasyModePage.tsx +++ b/src/pages/EasyModePage.tsx @@ -17,6 +17,7 @@ import { LoaderCircle, Monitor, Moon, + RefreshCw, Sun, X, } from "lucide-react"; @@ -30,8 +31,11 @@ import { responseList, } from "../services/managementApi"; import { + DEEPSEEK_BASE_URL, fetchModels, + mergeModelOptions, normalizeBaseUrl, + reconcileModelSelection, type ModelOption, type ModelProvider, } from "../services/modelService"; @@ -84,7 +88,7 @@ const apiSectionOptions: ApiSectionOption[] = [ { id: "claude", managementSection: "claude-api-key", nameKey: "easyMode.api.platformName.claude", provider: "claude", defaultBaseUrl: "", icon: claudeIcon }, { id: "codex", managementSection: "codex-api-key", nameKey: "easyMode.api.platformName.codex", provider: "codex", defaultBaseUrl: "", icon: codexIcon }, { id: "gemini", managementSection: "gemini-api-key", nameKey: "easyMode.api.platformName.gemini", provider: "gemini", defaultBaseUrl: "", icon: geminiIcon }, - { id: "deepseek", managementSection: "openai-compatibility", nameKey: "easyMode.api.platformName.deepseek", provider: "openai", defaultBaseUrl: "https://api.deepseek.com", icon: deepseekIcon }, + { id: "deepseek", managementSection: "codex-api-key", nameKey: "easyMode.api.platformName.deepseek", provider: "deepseek", defaultBaseUrl: DEEPSEEK_BASE_URL, icon: deepseekIcon }, ]; const isDeepSeekRecord = (record: Record) => { @@ -135,6 +139,7 @@ export function EasyModePage({ const [apiTesting, setApiTesting] = useState(false); const [apiTestedModels, setApiTestedModels] = useState([]); const [apiSelectedModels, setApiSelectedModels] = useState([]); + const [apiModelsReady, setApiModelsReady] = useState(false); const [apiErrorMessage, setApiTestError] = useState(""); const apiTestError = typeof apiErrorMessage === "string" ? apiErrorMessage @@ -190,7 +195,7 @@ export function EasyModePage({ const sourceList = recordsBySection[section.managementSection]; const list = section.id === "deepseek" ? sourceList.filter(isDeepSeekRecord) - : section.id === "openai-compatibility" + : section.id === "codex" ? sourceList.filter((record) => !isDeepSeekRecord(record)) : sourceList; counts[section.id] = list.length; @@ -321,6 +326,7 @@ export function EasyModePage({ if (opt) setApiBaseUrl(opt.defaultBaseUrl); setApiTestedModels([]); setApiSelectedModels([]); + setApiModelsReady(false); setApiTestError(""); clearApiNotice(); setGuideApiSaved(false); @@ -338,10 +344,7 @@ export function EasyModePage({ } setApiTesting(true); setApiTestError(""); - setApiTestedModels([]); - setApiSelectedModels([]); setGuideApiSaved(false); - setGuideApiModelsFetched(false); const opt = apiSectionOptions.find((o) => o.id === selectedApiSection); const providerType = opt ? opt.provider : "openai"; @@ -356,8 +359,18 @@ export function EasyModePage({ 10000, ); if (models.length > 0) { - setApiTestedModels(models); - setApiSelectedModels(models); + const mergedModels = mergeModelOptions(models); + const selectedNames = reconcileModelSelection( + mergedModels, + [], + apiSelectedModels.map((model) => model.name), + apiModelsReady ? "refresh" : "initial", + ); + setApiTestedModels(mergedModels); + setApiSelectedModels( + mergedModels.filter((model) => selectedNames.has(model.name.trim().toLowerCase())), + ); + setApiModelsReady(true); setGuideApiModelsFetched(true); } else { setApiTestError({ key: "easyMode.api.noModelsFound" }); @@ -390,6 +403,10 @@ export function EasyModePage({ setApiTestError({ key: "easyMode.api.apiKeyRequired" }); return; } + if (selectedApiSection === "deepseek" && !apiModelsReady) { + setApiTestError({ key: "easyMode.api.fetchListFirst" }); + return; + } if (apiSelectedModels.length === 0) { setApiTestError({ key: "easyMode.api.modelRequired" }); return; @@ -408,7 +425,8 @@ export function EasyModePage({ const managementSection = selectedOption?.managementSection ?? "openai-compatibility"; const configPayload = await managementApi.get("/config"); const list = responseList(configPayload, managementSection); - const models = apiSelectedModels.map((model) => ({ name: model.name.trim() })); + const selectedModels = apiSelectedModels.map((model) => ({ name: model.name.trim() })); + const models = selectedModels; const newEntry = managementSection === "openai-compatibility" ? { name: apiRemark.trim() || `${selectedApiSection} (${list.length + 1})`, @@ -419,6 +437,7 @@ export function EasyModePage({ models, } : { + ...(selectedApiSection === "deepseek" ? { name: "DeepSeek" } : {}), "api-key": apiKey.trim(), "base-url": normalizeBaseUrl(apiBaseUrl.trim()), models, @@ -923,7 +942,12 @@ export function EasyModePage({ type="text" className="text-input" value={apiBaseUrl} - onChange={(e) => { setApiBaseUrl(e.target.value); setGuideApiSaved(false); }} + onChange={(e) => { + setApiBaseUrl(e.target.value); + setApiModelsReady(false); + setGuideApiModelsFetched(false); + setGuideApiSaved(false); + }} placeholder="https://..." />
@@ -933,7 +957,12 @@ export function EasyModePage({ type="password" className="text-input" value={apiKey} - onChange={(e) => { setApiKey(e.target.value); setGuideApiSaved(false); }} + onChange={(e) => { + setApiKey(e.target.value); + setApiModelsReady(false); + setGuideApiModelsFetched(false); + setGuideApiSaved(false); + }} placeholder="sk-..." />
@@ -963,11 +992,24 @@ export function EasyModePage({ <>
{t("easyMode.api.modelListTitle")} +
- {t("easyMode.api.modelListHint")} + + {selectedApiSection === "deepseek" && !apiModelsReady + ? t("easyMode.api.modelListStale") + : t("easyMode.api.modelListHint")} +
{apiTestedModels.map((model) => { @@ -983,6 +1025,7 @@ export function EasyModePage({ handleToggleApiModel(model)} /> {model.name} @@ -996,7 +1039,13 @@ export function EasyModePage({ diff --git a/src/services/modelService.ts b/src/services/modelService.ts index d040c28c..7b9d7104 100644 --- a/src/services/modelService.ts +++ b/src/services/modelService.ts @@ -11,10 +11,49 @@ export type ModelOption = { inputModalities?: Array<'text' | 'image'>; thinking?: Record; }; -export type ModelProvider = 'gemini' | 'codex' | 'claude' | 'openai'; +export type ModelProvider = 'gemini' | 'codex' | 'deepseek' | 'claude' | 'openai'; + +export type ModelSelectionMode = 'initial' | 'refresh'; const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com'; const DEFAULT_CLAUDE_BASE_URL = 'https://api.anthropic.com'; +export const DEEPSEEK_BASE_URL = 'https://api.deepseek.com'; + +const modelKey = (name: string) => name.trim().toLowerCase(); + +export function mergeModelOptions(...groups: ModelOption[][]): ModelOption[] { + const merged = new Map(); + groups.flat().forEach((model) => { + const name = model.name.trim(); + if (!name) return; + merged.set(modelKey(name), { ...model, name }); + }); + return Array.from(merged.values()); +} + +export function reconcileModelSelection( + discoveredModels: ModelOption[], + configuredModels: ModelOption[], + selectedModelNames: Iterable, + mode: ModelSelectionMode, +): Set { + const availableNames = new Set( + mergeModelOptions(discoveredModels, configuredModels).map((model) => modelKey(model.name)), + ); + const configuredNames = new Set( + configuredModels.map((model) => modelKey(model.name)).filter(Boolean), + ); + const previousSelection = new Set( + Array.from(selectedModelNames, modelKey).filter(Boolean), + ); + const requestedSelection = mode === 'refresh' + ? previousSelection + : configuredNames.size > 0 + ? configuredNames + : new Set(discoveredModels.map((model) => modelKey(model.name)).filter(Boolean)); + + return new Set(Array.from(requestedSelection).filter((name) => availableNames.has(name))); +} export function normalizeBaseUrl(value: string): string { let raw = value.trim(); @@ -47,6 +86,8 @@ export const modelEndpointCandidates = (provider: ModelProvider, baseUrl: string ? DEFAULT_GEMINI_BASE_URL : provider === 'claude' ? DEFAULT_CLAUDE_BASE_URL + : provider === 'deepseek' + ? DEEPSEEK_BASE_URL : ''); const normalized = normalizeBaseUrl(resolvedBaseUrl); if (!normalized) return []; @@ -57,6 +98,7 @@ export const modelEndpointCandidates = (provider: ModelProvider, baseUrl: string const withoutVersion = base.replace(/\/(?:v1beta|v1)$/i, ''); if (provider === 'gemini') return [`${withoutVersion}/v1beta/models`]; if (provider === 'claude') return [`${withoutVersion}/v1/models`]; + if (provider === 'deepseek') return [`${base}/models`]; return [/\/v1$/i.test(base) ? `${base}/models` : `${base}/v1/models`]; }; diff --git a/src/services/providerHealthCheck.ts b/src/services/providerHealthCheck.ts index 1d48d07a..f4145fa1 100644 --- a/src/services/providerHealthCheck.ts +++ b/src/services/providerHealthCheck.ts @@ -5,6 +5,8 @@ export const PROVIDER_HEALTH_TIMEOUT_MS = 15_000; export const PROVIDER_HEALTH_CONCURRENCY = 4; export type ProviderHealthProbe = { + provider: ModelProvider; + baseUrl: string; url: string; header: Record; data: string; @@ -40,6 +42,7 @@ export type ProviderHealthCheckOptions = { const defaultBaseUrl = (provider: ModelProvider) => { if (provider === 'claude') return 'https://api.anthropic.com'; if (provider === 'gemini') return 'https://generativelanguage.googleapis.com'; + if (provider === 'deepseek') return 'https://api.deepseek.com'; return ''; }; @@ -109,6 +112,8 @@ export function buildProviderHealthProbe( ? model.trim().replace(/^models\//i, '') : model.trim(); const metadata = { + provider, + baseUrl: baseUrl.trim() || defaultBaseUrl(provider), model: normalizedModel, source: key, authIndex: authIndex.trim(), @@ -120,7 +125,7 @@ export function buildProviderHealthProbe( else if (authIndex) setHeaderIfMissing(headers, 'x-goog-api-key', '$TOKEN$'); return { ...metadata, - url: `${root}/v1beta/models/${encodeURIComponent(normalizedModel)}:generateContent?alt=sse`, + url: `${root}/v1beta/models/${encodeURIComponent(normalizedModel)}:streamGenerateContent?alt=sse`, header: headers, protocol: 'gemini', data: JSON.stringify({ @@ -153,15 +158,18 @@ export function buildProviderHealthProbe( if (key) setHeaderIfMissing(headers, 'Authorization', `Bearer ${key}`); else if (authIndex) setHeaderIfMissing(headers, 'Authorization', 'Bearer $TOKEN$'); - if (provider === 'codex') { + if (provider === 'codex' || provider === 'deepseek') { return { ...metadata, - url: `${root}/v1/responses`, + url: provider === 'deepseek' + ? `${normalizeBaseUrl(baseUrl.trim() || defaultBaseUrl(provider))}/responses` + : `${root}/v1/responses`, header: headers, protocol: 'openai-responses', data: JSON.stringify({ model: normalizedModel, input: 'hi', + max_output_tokens: 16, stream: true, }), }; @@ -175,7 +183,9 @@ export function buildProviderHealthProbe( data: JSON.stringify({ model: normalizedModel, messages: [{ role: 'user', content: 'hi' }], + max_completion_tokens: 16, stream: true, + stream_options: { include_usage: true }, }), }; } @@ -217,6 +227,8 @@ export async function checkProviderHealthProbe( responseLatencyMs: number; }>('provider_health_probe', { request: { + provider: probe.provider, + baseUrl: probe.baseUrl, protocol: probe.protocol, timeoutMs, data: probe.data, diff --git a/src/services/usageMetrics.ts b/src/services/usageMetrics.ts index 15568347..aa7af606 100644 --- a/src/services/usageMetrics.ts +++ b/src/services/usageMetrics.ts @@ -1,7 +1,6 @@ type GenerationSpeedInput = { outputTokens: number; latencyMs: number; - ttftMs: number | null; }; type CacheReadRateInput = { @@ -12,21 +11,17 @@ type CacheReadRateInput = { export const calculateGenerationSpeed = ({ outputTokens, latencyMs, - ttftMs, }: GenerationSpeedInput): number | null => { if ( !Number.isFinite(outputTokens) || !Number.isFinite(latencyMs) || - ttftMs === null || - !Number.isFinite(ttftMs) || outputTokens <= 0 || - ttftMs <= 0 || - latencyMs <= ttftMs + latencyMs <= 0 ) { return null; } - const speed = outputTokens / ((latencyMs - ttftMs) / 1_000); + const speed = outputTokens / (latencyMs / 1_000); return Number.isFinite(speed) && speed > 0 ? speed : null; }; diff --git a/src/styles.css b/src/styles.css index c71a66d2..e1ab1d86 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3643,7 +3643,9 @@ textarea:focus-visible { .model-discovery-toolbar > div { display: flex; + flex-wrap: wrap; gap: 7px; + justify-content: flex-end; } .model-discovery-content { @@ -3769,6 +3771,197 @@ textarea:focus-visible { min-width: 112px; } +.model-discovery-dialog.model-transfer-dialog { + display: flex; + flex-direction: column; + width: min(960px, 100%); + height: min(640px, calc(100dvh - 48px)); + min-height: 0; +} + +.model-transfer-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--theme-7a746c); + font-size: var(--font-size-label); +} + +.model-transfer-summary > button { + width: auto; + flex: 0 0 auto; +} + +.model-transfer-panels { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + flex: 1; + min-height: 0; +} + +.model-transfer-panel { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; + border: 1px solid var(--theme-e2ddd3); + border-radius: 8px; + background: var(--theme-ffffff); +} + +.model-transfer-panel-heading { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 10px; +} + +.model-transfer-panel-heading h3 { + margin: 0; + color: var(--theme-2d2a26); + font-size: var(--font-size-label); +} + +.model-transfer-panel-heading button { + width: auto; + flex: 0 0 auto; + margin-left: auto; +} + +.model-transfer-count { + border-radius: 5px; + padding: 2px 6px; + background: var(--theme-f4f2ec); + color: var(--theme-6d6760); + font-size: var(--font-size-meta); + font-variant-numeric: tabular-nums; +} + +.model-transfer-search { + display: flex; + align-items: center; + gap: 8px; + margin: 0 10px 10px; + padding: 0 8px; + border: 1px solid var(--theme-d5d2cb); + border-radius: 6px; + color: var(--theme-8a847c); +} + +.model-transfer-search:focus-within { + outline: 2px solid var(--theme-9ca9df); + outline-offset: 1px; +} + +.model-transfer-search input { + width: 100%; + min-width: 0; + height: 34px; + padding: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--theme-2d2a26); +} + +.model-transfer-list { + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + border-top: 1px solid var(--theme-eeece6); +} + +.model-transfer-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 44px; + padding: 9px 12px; + border: 0; + border-bottom: 1px solid var(--theme-eeece6); + background: transparent; + color: var(--theme-6d6760); + text-align: left; + cursor: pointer; +} + +.model-transfer-row:hover:not(:disabled) { + background: var(--theme-f4f2ec); +} + +.model-transfer-row:focus-visible { + outline: 2px solid var(--theme-9ca9df); + outline-offset: -2px; +} + +.model-transfer-row > span { + display: grid; + flex: 1; + min-width: 0; + gap: 2px; +} + +.model-transfer-row strong, +.model-transfer-row small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.model-transfer-row strong { + color: var(--theme-2d2a26); + font-size: var(--font-size-label); +} + +.model-transfer-row small { + color: var(--theme-8a847c); + font-size: var(--font-size-meta); +} + +.model-transfer-empty { + display: grid; + place-content: center; + height: 100%; + padding: 16px; + color: var(--theme-7a746c); + font-size: var(--font-size-label); + text-align: center; +} + +.model-transfer-actions { + flex-wrap: wrap; +} + +.model-transfer-actions > span { + margin-right: auto; + color: var(--theme-7a746c); + font-size: var(--font-size-label); +} + +@media (max-width: 600px) { + .model-discovery-dialog.model-transfer-dialog { + height: calc(100dvh - 24px); + padding: 12px; + } + + .model-transfer-panels { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(0, 1fr)); + gap: 8px; + } + + .model-transfer-actions > span { + width: 100%; + } +} + .model-discovery-dialog.auth-model-dialog { grid-template-rows: auto 38px auto minmax(0, 1fr) auto auto; height: min(720px, calc(100vh - 32px)); @@ -4284,6 +4477,16 @@ textarea:focus-visible { color: var(--theme-2f6b3f); } +.action-feedback.inline-notice.api-access-page-notice { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 80; + width: min(360px, calc(100vw - 32px)); + margin: 0; + box-shadow: 0 14px 34px rgba(45, 42, 38, 0.18); +} + .real-provider-workbench { grid-template-columns: 240px minmax(0, 1fr); } @@ -4547,40 +4750,6 @@ textarea:focus-visible { min-height: 42px; } -.provider-preset-summary { - display: grid; - grid-template-columns: 34px minmax(0, 1fr); - align-items: center; - gap: 10px; - min-height: 52px; - padding: 9px 11px; - border: 1px solid var(--theme-dce2fb); - border-radius: 7px; - background: var(--theme-f5f7ff); -} - -.provider-preset-summary .provider-logo { - width: 30px; - height: 30px; -} - -.provider-preset-summary > div { - display: grid; - min-width: 0; - gap: 3px; -} - -.provider-preset-summary strong { - color: var(--theme-344ca1); - font-size: var(--font-size-label); -} - -.provider-preset-summary span { - color: var(--theme-66709a); - font-size: var(--font-size-meta); - overflow-wrap: anywhere; -} - .thinking-level-config { display: grid; gap: 9px; diff --git a/tests/fixtures/model-selection.html b/tests/fixtures/model-selection.html new file mode 100644 index 00000000..8e2a1dad --- /dev/null +++ b/tests/fixtures/model-selection.html @@ -0,0 +1,5 @@ + + + Model selection fixture +
+ diff --git a/tests/fixtures/model-selection.tsx b/tests/fixtures/model-selection.tsx new file mode 100644 index 00000000..8a77dec7 --- /dev/null +++ b/tests/fixtures/model-selection.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { mockIPC } from '@tauri-apps/api/mocks'; +import { I18nProvider } from '../../src/i18n'; +import { ApiProviderDialog, type ProviderDraft } from '../../src/pages/ApiAccessPage'; +import '../../src/styles.css'; + +const params = new URLSearchParams(location.search); +localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') ?? 'en'); +document.documentElement.dataset.theme = params.get('theme') ?? 'light'; +const fixture = window as typeof window & { + fixtureCatalog: { name: string }[]; + fixtureFailFetch: boolean; + fixtureHoldNext: boolean; + fixturePending: (() => void)[]; + fixtureSaved: ProviderDraft | null; +}; +fixture.fixtureCatalog = [ + ...Array.from({ length: 240 }, (_, i) => ({ name: `gpt-${String(i + 1).padStart(3, '0')}` })), + ...Array.from({ length: 120 }, (_, i) => ({ name: `claude-${String(i + 1).padStart(3, '0')}` })), + ...Array.from({ length: 40 }, (_, i) => ({ name: `deepseek-${String(i + 1).padStart(3, '0')}` })), + { name: 'GPT-001' }, +]; +fixture.fixtureFailFetch = false; +fixture.fixtureHoldNext = false; +fixture.fixturePending = []; +fixture.fixtureSaved = null; +mockIPC(async (cmd, rawArgs) => { + if (cmd === 'set_app_locale') return null; + const args = rawArgs as { request?: { path?: string } }; + if (cmd === 'management_request' && args.request?.path === '/api-call') { + const response = fixture.fixtureFailFetch + ? { status_code: 503, body: 'Fixture discovery failed' } + : { status_code: 200, body: { data: fixture.fixtureCatalog.slice() } }; + if (fixture.fixtureHoldNext) { + fixture.fixtureHoldNext = false; + await new Promise((resolve) => fixture.fixturePending.push(resolve)); + } + return response; + } + throw new Error(`Unhandled fixture command: ${cmd}`); +}); + +const scenario = params.get('scenario') ?? 'saved'; +const initialDraft: ProviderDraft = { + name: '', apiKey: 'fixture-key', remark: '', baseUrl: 'https://models.example.test', priority: '', + models: scenario === 'saved' ? [{ name: ' gpt-001 ', alias: 'Focus' }, { name: 'custom-local', alias: 'Local alias' }] : [], + excludedModelsText: scenario === 'excluded' ? 'gpt-*' : '', +}; +createRoot(document.getElementById('root')!).render( + + {}} + onSave={async (draft) => { fixture.fixtureSaved = draft; return { saved: true }; }} + /> + , +); diff --git a/tests/model-selection-ui.cjs b/tests/model-selection-ui.cjs new file mode 100644 index 00000000..9cc10b23 --- /dev/null +++ b/tests/model-selection-ui.cjs @@ -0,0 +1,141 @@ +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const assert = require('node:assert/strict'); +const { mkdirSync } = require('node:fs'); + +(async () => { + const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] }); + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 820 } }); + page.setDefaultTimeout(10000); + page.setDefaultNavigationTimeout(30000); + await page.route('**/*', route => route.request().url().startsWith('http://127.0.0.1:1423/') ? route.continue() : route.abort()); + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + const dialog = page.getByRole('dialog', { name: 'Select Models' }); + const left = dialog.getByRole('region', { name: 'Unselected Models', exact: true }); + const right = dialog.getByRole('region', { name: 'Selected Models', exact: true }); + const rows = panel => panel.locator('.model-transfer-row'); + const search = panel => panel.getByRole('textbox'); + const ready = () => page.waitForFunction(() => { + const refresh = document.querySelector('.model-transfer-summary button'); + return refresh && !refresh.disabled; + }); + const counts = async (unselected, selected) => { + assert.equal(Number(await left.locator('.model-transfer-count').innerText()), unselected); + assert.equal(Number(await right.locator('.model-transfer-count').innerText()), selected); + }; + const open = async (scenario) => { + await page.goto(`http://127.0.0.1:1423/tests/fixtures/model-selection.html?scenario=${scenario}`, { waitUntil: 'domcontentloaded' }); + await page.getByRole('button', { name: 'Fetch Models', exact: true }).click(); + await ready(); + }; + + await open('saved'); + await counts(399, 2); + await search(right).fill('FOCUS'); + assert.equal(await rows(right).count(), 1, 'Search matches saved aliases, ignoring case'); + await right.getByRole('button', { name: 'Remove Results', exact: true }).click(); + assert.equal(await rows(right).count(), 0); + await right.getByRole('button', { name: 'Clear search', exact: true }).click(); + await counts(400, 1); + await search(left).fill(' GPT-00 '); + assert.equal(await rows(left).count(), 9); + await left.getByRole('button', { name: 'Add Results', exact: true }).click(); + assert.equal(await rows(left).count(), 0); + assert.equal(await rows(right).count(), 10, 'A filter on one side does not filter the other side'); + await search(right).fill('gpt-00'); + await right.getByRole('button', { name: 'Remove Results', exact: true }).click(); + await right.getByRole('button', { name: 'Clear search', exact: true }).click(); + await left.getByRole('button', { name: 'Clear search', exact: true }).click(); + await counts(400, 1); + + await right.getByRole('button', { name: 'Remove All', exact: true }).click(); + await counts(401, 0); + assert.equal(await dialog.getByRole('button', { name: 'Apply Selection (0)', exact: true }).isDisabled(), true); + await left.getByRole('button', { name: 'Add gpt-001', exact: true }).focus(); + await page.keyboard.press('Enter'); + assert.equal(await left.getByRole('button', { name: 'Add gpt-002', exact: true }).evaluate(el => el === document.activeElement), true); + await counts(400, 1); + await page.evaluate(() => { window.fixtureCatalog.push({ name: 'new-on-refresh' }); }); + await dialog.getByRole('button', { name: 'Refresh', exact: true }).click(); + await ready(); + await counts(401, 1); + assert.equal(await left.getByRole('button', { name: 'Add new-on-refresh', exact: true }).count(), 1); + + await search(left).fill('no-such-model'); + await page.evaluate(() => { window.fixtureFailFetch = true; }); + await dialog.getByRole('button', { name: 'Refresh', exact: true }).click(); + await ready(); + assert.match(await dialog.getByRole('alert').innerText(), /Fixture discovery failed/); + assert.equal(await rows(right).count(), 1, 'Failed refresh keeps selected models'); + await left.getByRole('button', { name: 'Clear search', exact: true }).click(); + await counts(401, 1); + await page.evaluate(() => { window.fixtureFailFetch = false; }); + await dialog.getByRole('button', { name: 'Apply Selection (1)', exact: true }).click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + const saved = await page.evaluate(() => window.fixtureSaved); + assert.deepEqual(saved.models, [{ name: 'gpt-001', alias: 'Focus' }]); + assert.ok(saved.excludedModelsText.includes('gpt-002')); + + await page.getByRole('button', { name: 'Fetch Models', exact: true }).click(); + await ready(); + await right.getByRole('button', { name: 'Remove All', exact: true }).click(); + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + await page.getByRole('button', { name: 'Fetch Models', exact: true }).click(); + await ready(); + assert.equal(await rows(right).count(), 1, 'Cancelling discards pending selection edits'); + await dialog.getByRole('button', { name: 'Apply Selection (1)', exact: true }).focus(); + await page.keyboard.press('Tab'); + assert.equal(await dialog.getByRole('button', { name: 'Close', exact: true }).evaluate(el => el === document.activeElement), true); + await page.keyboard.press('Escape'); + assert.equal(await dialog.count(), 0); + assert.equal(await page.getByRole('button', { name: 'Fetch Models', exact: true }).evaluate(el => el === document.activeElement), true); + + await page.evaluate(() => { window.fixtureHoldNext = true; }); + await page.getByRole('button', { name: 'Fetch Models', exact: true }).click(); + await page.waitForFunction(() => window.fixturePending.length === 1); + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + await page.getByRole('textbox', { name: 'Base URL', exact: true }).fill('https://new.example.test'); + await page.evaluate(() => { window.fixtureCatalog = [{ name: 'fresh-model' }]; }); + await page.getByRole('button', { name: 'Fetch Models', exact: true }).click(); + await ready(); + await counts(1, 1); + await page.evaluate(async () => { + window.fixturePending.shift()(); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }); + await counts(1, 1); + assert.equal(await left.getByRole('button', { name: 'Add fresh-model', exact: true }).count(), 1, 'Cancelled response cannot replace the current list'); + + await open('excluded'); + await counts(240, 160); + await open('new'); + await counts(0, 400); + await right.getByRole('button', { name: 'Remove All', exact: true }).click(); + await dialog.getByRole('button', { name: 'Refresh', exact: true }).click(); + await ready(); + await counts(400, 0); + await left.getByRole('button', { name: 'Add All', exact: true }).click(); + await counts(0, 400); + assert.deepEqual(errors, []); + + mkdirSync('misc', { recursive: true }); + for (const [locale, theme, width, height] of [['zh-CN', 'light', 1280, 820], ['en', 'dark', 1280, 820], ['ja', 'light', 390, 740]]) { + await page.setViewportSize({ width, height }); + await page.goto(`http://127.0.0.1:1423/tests/fixtures/model-selection.html?locale=${locale}&theme=${theme}`); + await page.locator('.model-config-heading button').click(); + await ready(); + const popup = page.locator('.model-transfer-dialog'); + const rect = await popup.boundingBox(); + assert.ok(rect.x >= 0 && rect.y >= 0 && rect.x + rect.width <= width && rect.y + rect.height <= height); + assert.equal(await popup.evaluate(el => el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight), false, 'Dialog must not overflow'); + for (const panel of await popup.locator('.model-transfer-panel').all()) { + assert.equal(await panel.evaluate(el => el.scrollWidth > el.clientWidth), false, 'Panel must not overflow horizontally'); + } + await page.screenshot({ path: `misc/model-selection-${locale}-${theme}.png` }); + } + console.log('PASS: 400 models, independent searches, filtered transfers, empty selection recovery, keyboard focus, refresh errors, selection persistence, cancel, stale responses, exclusions, save, and responsive themes'); + } finally { + await browser.close(); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/modelService.test.ts b/tests/modelService.test.ts new file mode 100644 index 00000000..0e70ffcc --- /dev/null +++ b/tests/modelService.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'bun:test'; +import { + mergeModelOptions, + reconcileModelSelection, +} from '../src/services/modelService'; + +describe('model discovery selection', () => { + const discovered = [ + { name: 'deepseek-chat' }, + { name: 'deepseek-reasoner' }, + { name: 'deepseek-new-model' }, + ]; + + it('selects every discovered model on the first fetch for a new connection', () => { + expect(Array.from(reconcileModelSelection(discovered, [], [], 'initial'))).toEqual([ + 'deepseek-chat', + 'deepseek-reasoner', + 'deepseek-new-model', + ]); + }); + + it('keeps saved selections and leaves newly discovered models unchecked', () => { + expect(Array.from(reconcileModelSelection( + discovered, + [{ name: 'deepseek-chat' }], + ['deepseek-chat'], + 'initial', + ))).toEqual(['deepseek-chat']); + }); + + it('preserves refresh selections, drops unavailable discoveries, and keeps configured models', () => { + expect(Array.from(reconcileModelSelection( + [{ name: 'deepseek-reasoner' }, { name: 'deepseek-new-model' }], + [{ name: 'custom-model' }], + ['DEEPSEEK-REASONER', 'removed-model', 'custom-model'], + 'refresh', + ))).toEqual(['deepseek-reasoner', 'custom-model']); + }); + + it('deduplicates model names case-insensitively and keeps configured metadata', () => { + expect(mergeModelOptions( + [{ name: 'deepseek-chat' }], + [{ name: ' DEEPSEEK-CHAT ', alias: 'Chat' }], + )).toEqual([{ name: 'DEEPSEEK-CHAT', alias: 'Chat' }]); + }); +}); diff --git a/tests/operationFeedback.test.ts b/tests/operationFeedback.test.ts index 75358474..bd695029 100644 --- a/tests/operationFeedback.test.ts +++ b/tests/operationFeedback.test.ts @@ -37,6 +37,21 @@ describe('控件自身反馈', () => { expect(nonEmptyCalls[0].arguments[1].getText()).toBe("'error'"); }); + it('API 接入提示固定在右下角,不插入页面布局', () => { + const source = readFileSync(new URL('../src/pages/ApiAccessPage.tsx', import.meta.url), 'utf8'); + expect(source).toContain("import { createPortal } from 'react-dom';"); + expect(source).toContain('className="api-access-page-notice"'); + expect(source).toContain('createPortal('); + expect(source).toContain('document.body'); + expect(source).not.toContain('feedbackRow'); + expect(source.indexOf('createPortal(')).toBeGreaterThan(source.indexOf('provider-workbench real-provider-workbench')); + expect(source.slice(source.indexOf('provider-workbench real-provider-workbench'), source.lastIndexOf('createPortal('))).not.toContain(' { expect(actionCalls('AuthFileManagementPage.tsx', 'toggleStatus', 'showNotice').calls).toHaveLength(0); }); diff --git a/tests/providerConfig.test.ts b/tests/providerConfig.test.ts index 3e265522..abec0e21 100644 --- a/tests/providerConfig.test.ts +++ b/tests/providerConfig.test.ts @@ -1,12 +1,10 @@ import { describe, expect, it } from 'bun:test'; import { - allModelSelectionForDiscovery, applyProviderRemarkIdentity, applyProviderPreset, buildProviderRecord, createProviderDraft, DEEPSEEK_BASE_URL, - DEEPSEEK_THINKING_LEVELS, exclusionsForModelSelection, modelSelectionForDiscovery, parseProviderHeaders, @@ -51,8 +49,7 @@ describe('API 接入配置合并', () => { ]); }); - it('DeepSeek 新增预设默认发现全部模型并应用内置思考等级', () => { - expect(DEEPSEEK_THINKING_LEVELS).toEqual(['low', 'high', 'max']); + it('DeepSeek 使用 Codex API 记录且不写入内置思考等级', () => { const draft = createProviderDraft('deepseek'); const discovered = [ { name: 'deepseek-chat' }, @@ -65,7 +62,7 @@ describe('API 接入配置合并', () => { models: discovered, }); const identified = applyProviderRemarkIdentity('deepseek', prepared); - const result = buildProviderRecord('openai-compatibility', identified); + const result = buildProviderRecord('codex-api-key', identified); expect(draft.name).toBe('DeepSeek'); expect(draft.remark).toBe(''); @@ -75,19 +72,16 @@ describe('API 接入配置合并', () => { expect(result).toMatchObject({ name: 'DeepSeek', 'base-url': 'https://api.deepseek.com', - 'api-key-entries': [{ 'api-key': 'deepseek-key' }], + 'api-key': 'deepseek-key', models: [ { name: 'deepseek-chat', - thinking: { levels: [...DEEPSEEK_THINKING_LEVELS] }, }, { name: 'deepseek-reasoner', - thinking: { levels: [...DEEPSEEK_THINKING_LEVELS] }, }, { name: 'deepseek-new-model', - thinking: { levels: [...DEEPSEEK_THINKING_LEVELS] }, }, ], }); @@ -104,14 +98,20 @@ describe('API 接入配置合并', () => { expect(draft.name).toBe('生产环境'); }); - it('DeepSeek 接入单独归类,不在 OpenAI 兼容列表重复显示', () => { - const record = { + it('DeepSeek 只从 Codex API 分类识别,旧 OpenAI 兼容记录保持原样', () => { + const codexRecord = { + name: 'custom-deepseek', + 'base-url': 'https://api.deepseek.com/v1', + }; + const legacyRecord = { name: 'custom-deepseek', 'base-url': 'https://api.deepseek.com/v1', }; - expect(providerCategoryMatchesRecord('deepseek', record)).toBe(true); - expect(providerCategoryMatchesRecord('openai-compatibility', record)).toBe(false); + expect(providerCategoryMatchesRecord('deepseek', codexRecord, 'codex-api-key')).toBe(true); + expect(providerCategoryMatchesRecord('codex-api-key', codexRecord, 'codex-api-key')).toBe(false); + expect(providerCategoryMatchesRecord('deepseek', legacyRecord, 'openai-compatibility')).toBe(false); + expect(providerCategoryMatchesRecord('openai-compatibility', legacyRecord, 'openai-compatibility')).toBe(true); }); it('OpenAI 兼容接入把选定思考等级写入全部开放模型', () => { @@ -387,18 +387,4 @@ describe('API 接入配置合并', () => { expect(Array.from(selected)).toEqual(['model-a', 'model-b']); }); - - it('模型选择窗口每次打开都以接口返回的全部模型作为默认选择', () => { - const selected = allModelSelectionForDiscovery([ - { name: 'deepseek-chat' }, - { name: 'deepseek-reasoner' }, - { name: 'deepseek-new-model' }, - ]); - - expect(Array.from(selected)).toEqual([ - 'deepseek-chat', - 'deepseek-reasoner', - 'deepseek-new-model', - ]); - }); }); diff --git a/tests/providerHealthCheck.test.ts b/tests/providerHealthCheck.test.ts index a05b9e30..c66b029a 100644 --- a/tests/providerHealthCheck.test.ts +++ b/tests/providerHealthCheck.test.ts @@ -1,6 +1,8 @@ +import { clearMocks, mockIPC } from '@tauri-apps/api/mocks'; import { describe, expect, it } from 'bun:test'; import { buildProviderHealthProbe, + checkProviderHealthProbe, mergeProviderHealthModels, primaryProviderHealthCredential, runProviderModelHealthChecks, @@ -92,9 +94,12 @@ describe('API 接入健康检测', () => { expect(JSON.parse(probe.data)).toEqual({ model: 'gpt-test', messages: [{ role: 'user', content: 'hi' }], + max_completion_tokens: 16, stream: true, + stream_options: { include_usage: true }, }); expect(probe.protocol).toBe('openai-chat'); + expect(probe.baseUrl).toBe('https://openrouter.example/api/v1'); }); it('为非 v1 的 OpenAI 兼容版本前缀保留原始版本号', () => { @@ -173,11 +178,33 @@ describe('API 接入健康检测', () => { expect(JSON.parse(probe.data)).toEqual({ model: 'gpt-5-codex', input: 'hi', + max_output_tokens: 16, stream: true, }); expect(probe.protocol).toBe('openai-responses'); }); + it('为 DeepSeek 使用 Codex Responses 协议并保留自定义路径前缀', () => { + const probe = buildProviderHealthProbe( + 'deepseek', + 'https://api.deepseek.com', + 'deepseek-chat', + 'deepseek-key', + ); + + expect(probe.url).toBe('https://api.deepseek.com/responses'); + expect(probe.protocol).toBe('openai-responses'); + expect(JSON.parse(probe.data)).toEqual({ + model: 'deepseek-chat', + input: 'hi', + stream: true, + max_output_tokens: 16, + }); + expect(modelEndpointCandidates('deepseek', 'https://api.deepseek.com')).toEqual([ + 'https://api.deepseek.com/models', + ]); + }); + it('为 Gemini 使用默认地址并移除 models/ 前缀', () => { const probe = buildProviderHealthProbe( 'gemini', @@ -187,7 +214,7 @@ describe('API 接入健康检测', () => { ); expect(probe.url).toBe( - 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?alt=sse', + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse', ); expect(probe.header['x-goog-api-key']).toBe('gemini-key'); expect(probe.protocol).toBe('gemini'); @@ -216,3 +243,20 @@ describe('API 接入健康检测', () => { }); }); + + it('passes provider and upstream tariff scope through the actual IPC call', async () => { + const original = Object.getOwnPropertyDescriptor(globalThis, 'window'); + Object.defineProperty(globalThis, 'window', {value:{}, configurable:true}); + let captured: any; + try { + mockIPC((command, args) => { expect(command).toBe('provider_health_probe'); captured=args; return {responseLatencyMs:10}; }); + const result = await checkProviderHealthProbe('openai', 'https://custom.example/v1', 'gpt-5.4', 'test-only-key'); + expect(result.success).toBe(true); + expect(captured.request.provider).toBe('openai'); + expect(captured.request.baseUrl).toBe('https://custom.example/v1'); + } finally { + clearMocks(); + if (original) Object.defineProperty(globalThis, 'window', original); + else Reflect.deleteProperty(globalThis, 'window'); + } + }); diff --git a/tests/usageMetrics.test.ts b/tests/usageMetrics.test.ts index 1645103d..c2001a6f 100644 --- a/tests/usageMetrics.test.ts +++ b/tests/usageMetrics.test.ts @@ -7,18 +7,19 @@ import { } from '../src/services/usageMetrics'; describe('generation speed', () => { - test('uses only the generation interval after the first token', () => { - const input = { outputTokens: 344, latencyMs: 10_600, ttftMs: 2_770 }; - expect(calculateGenerationSpeed(input)).toBeCloseTo(43.9336, 4); - expect(formatGenerationSpeed(input)).toBe('43.9 t/s'); + test('uses the total request latency without requiring TTFT', () => { + const input = { outputTokens: 344, latencyMs: 10_600 }; + expect(calculateGenerationSpeed(input)).toBeCloseTo(32.4528, 4); + expect(formatGenerationSpeed(input)).toBe('32.5 t/s'); }); test.each([ - { outputTokens: 344, latencyMs: 10_600, ttftMs: 0 }, - { outputTokens: 344, latencyMs: 10_600, ttftMs: null }, - { outputTokens: 344, latencyMs: 2_770, ttftMs: 2_770 }, - { outputTokens: 344, latencyMs: 2_000, ttftMs: 2_770 }, - { outputTokens: 0, latencyMs: 10_600, ttftMs: 2_770 }, + { outputTokens: 344, latencyMs: 0 }, + { outputTokens: 344, latencyMs: -1 }, + { outputTokens: 0, latencyMs: 10_600 }, + { outputTokens: -1, latencyMs: 10_600 }, + { outputTokens: Number.NaN, latencyMs: 10_600 }, + { outputTokens: 344, latencyMs: Number.POSITIVE_INFINITY }, ])('returns an em dash when generation speed cannot be calculated', (input) => { expect(calculateGenerationSpeed(input)).toBeNull(); expect(formatGenerationSpeed(input)).toBe('—');