Commit Graph

263 Commits

  • fix(proxy): inject only ANTHROPIC_API_KEY for managed-account Claude takeover
    - Provider: add uses_managed_account_auth / is_github_copilot helpers
      to identify managed-account providers (GitHub Copilot / Codex OAuth)
    - ProxyService: choose auth policy by provider type when taking over
      Claude Live config. Managed accounts drop token env keys and write
      only the ANTHROPIC_API_KEY placeholder; other providers keep the
      existing ANTHROPIC_AUTH_TOKEN fallback behavior
    - Forwarder: add outbound guard that refuses to send the PROXY_MANAGED
      placeholder upstream to *.githubcopilot.com and chatgpt.com
      /backend-api/codex
    - Add unit tests covering detection, injection, and the outbound guard
  • fix(session): 修复session log模式下子Agent token统计遗漏 (#2821)
    * fix(session): 修复session log模式下子Agent token统计遗漏
    
    collect_jsonl_files() 只扫描了两层目录,遗漏了子Agent的JSONL日志文件,
    导致子Agent的独立token使用数据完全未统计到session费用中。
    (仅影响session log模式,proxy代理模式不受影响)
    
    * refactor(session): optimize collect_jsonl_files logic
    
    - Replace two independent if statements with if-else for mutually exclusive conditions
    - Remove unnecessary clone() when pushing file paths
    - Add clarifying comments for main session vs subagent files
    - Apply cargo fmt for consistent formatting
    
    Performance improvement: Eliminates redundant clone() operations when
    processing .jsonl files, as a path cannot be both a file and a directory.
    
    ---------
    
    Co-authored-by: Jason <farion1231@gmail.com>
  • fix(failover): patch P1-P3 reliability gaps surfaced by team review
    - Forwarder buffers non-streaming bodies and primes streaming first
      chunk before signaling success, so body timeouts and SSE first-chunk
      failures route through the circuit breaker instead of being recorded
      as success on response-header arrival
    - Atomic enable-failover: switch to P1 before persisting the flag, and
      roll back auto-added queue entries when the switch is rejected
      (e.g. official providers)
    - Hot-reload circuit breaker config on per-app proxy config change
      instead of waiting for a proxy restart
    - FailoverToggle / FailoverQueueManager / AutoFailoverConfigPanel
      require proxy takeover for the active app; the backend command also
      rejects enabling when takeover is off
    - ProviderHealthBadge consumes the backend is_healthy flag instead of
      hardcoding the 5-failure threshold
    
    Cleanup:
    - impl From<&AppProxyConfig> for CircuitBreakerConfig and use it from
      the command layer
    - Collapse three identical TabsContent blocks into a single map
  • feat(codex-oauth): fetch model list from ChatGPT backend on demand
    - Add `get_codex_oauth_models` Tauri command reusing the managed OAuth
      access token to hit `chatgpt.com/backend-api/codex/models`; HTTP and
      multi-shape JSON parsing live in `services::codex_oauth_models` so the
      command stays thin.
    - Unify the Claude form's "fetch models" button across normal / Copilot /
      Codex OAuth presets, drop the auto-load effect for Copilot in favor of
      explicit clicks, and guard against stale responses with a requestId ref.
    - Add Vitest coverage for both Copilot and Codex OAuth paths asserting no
      request on mount and the correct account id on click; add Rust unit
      tests for the four model-list payload shapes.
  • fix(proxy): expose real provider model names in Claude Code menu under takeover
    When proxy takeover is active, write per-role *_MODEL aliases for routing
    and *_MODEL_NAME with the upstream provider's real model name so the
    Claude Code model menu reflects the active provider instead of stale
    display names from a previous switch. Preserves the [1M] capability marker
    for Sonnet/Opus, and strips it from implicit display names.
  • fix(usage): pricing routing, SSE lifecycle, and validation hardening
    * model pricing routing: extend prefix-match families (gpt-/o1-o5/
      gemini-/deepseek-/qwen-/glm-/kimi-/minimax-) with per-family dash
      thresholds so short base IDs like gpt-5 no longer mis-match
      gpt-5-mini; strip ISO and 8-digit date suffixes via UTF-8-safe
      byte matching so claude-haiku-4-5-20251001 falls back to
      claude-haiku-4-5 pricing
    * SSE collector: SseUsageFinishGuard (RAII) guarantees finish() on
      early return or panic; AtomicBool fast path lets push() skip the
      Mutex once first-event time is recorded
    * validation: shared validate_cost_multiplier / validate_pricing_source
      helpers across DAO and service layers; PRICING_SOURCE_RESPONSE /
      PRICING_SOURCE_REQUEST constants replace string literals; price
      fields in update_model_pricing now reject empty / non-decimal /
      negative input before INSERT
    * backfill: add backfill_missing_usage_costs_for_model so a single
      price edit only scans matching rows instead of the full log table;
      startup backfill remains full-scan
    * session_usage{,_codex,_gemini}: share find_model_pricing helper from
      usage_stats; metadata_modified_nanos centralizes mtime precision
    * frontend: NON_NEGATIVE_DECIMAL_REGEX + isNonNegativeDecimalString
      replace three copies of the same multiplier regex; isUnpricedUsage
      surfaces zero-cost rows that have usage tokens (cached per row to
      avoid double evaluation); invalidate usageKeys.all on pricing mutate
      so backfilled rows refresh
  • fix(proxy): patch P0-P3 routing/lifecycle issues across forwarder paths
    * stream_check: thread Result from get_auth_headers via map_err so
      the workspace builds again
    * forwarder: scope rectifier / budget-rectifier flags per-provider so
      failover can still apply rectification on the next attempt
    * forwarder: categorize before record_result; route NonRetryable and
      ClientAbort through release_permit_neutral so client-side failures
      don't pollute circuit breaker or DB health
    * handler_context: parse Gemini model from uri.path() and strip both
      ?query and :action verb defensively in extract_gemini_model_from_path
    * forwarder + response_processor + handlers: introduce
      ActiveConnectionGuard (RAII) so active_connections decrement covers
      the full streaming body lifetime, not just response headers
    * claude_desktop_config: use sort_by_key to clear the clippy gate
  • fix(proxy): tighten takeover detection and use fallback restore on disable
    Two related drift bugs in the takeover state machine:
    
    1. The "already taken over?" guard used has_backup OR live_taken_over, so
       either condition alone would short-circuit. After a user or anomalous
       flow restores Live manually the backup row still made set_takeover
       return success, leaving the UI claiming takeover while requests bypass
       the local proxy. Tighten to AND so the rebuild branch repairs the two
       "split brain" states (backup-only and placeholder-only).
    
    2. Disabling takeover called the bare restore_live_config_for_app, which
       silently Ok()s when the backup is missing. If the backup was lost while
       Live still held proxy placeholders (PROXY_MANAGED token / local proxy
       URL), the client config was left broken with no error surfaced. Route
       the disable path through the already-existing
       restore_live_config_for_app_with_fallback (backup → SSOT → cleanup).
       The line 354 takeover-failure rollback intentionally keeps the bare
       variant since that path must preserve the backup for retry.
  • chore: drop trailing blank line in sql_helpers tests
    Rustfmt cleanup, no behavioral change.
  • fix(usage): correct cache cost semantics and silence pricing warn storm
    - Split CostCalculator into per-app cache semantics: Anthropic's
      input_tokens is already fresh input, while Codex/Gemini include
      cached tokens in their prompt count. The old shared formula
      double-subtracted cache_read for Claude, under-billing input cost.
    - Backfill now reads cost_multiplier from the per-log snapshot column
      instead of re-querying providers.meta, so historical rows are no
      longer rewritten with the current multiplier.
    - Move the "pricing not found" warn out of find_model_pricing_row;
      emit it only when a brand new log is written, and skip placeholder
      models (unknown / empty / null / none) entirely.
    - Broaden model id normalization: strip namespace prefixes
      (anthropic./openai./global./bedrock.), bedrock-style -vN suffixes,
      reasoning effort suffixes (-low/-medium/-high/-xhigh/-minimal),
      Claude Desktop's claude-<non-anthropic> wrapper, dot-to-dash for
      Claude, and try a LIKE prefix match for Claude short route ids
      (e.g. claude-haiku-4-5 -> claude-haiku-4-5-20251001).
    - Fall back to request_model when the stored model is missing, so
      early Codex session rows with model=unknown can still be priced.
  • feat(usage): filter-driven Hero with cache-normalized totals
    - Normalize OpenAI/Gemini input_tokens semantics in SQL via the new
      fresh_input_sql helper (cache_read subtracted at query time, no data
      migration). Recovers correct cache hit rates for Codex/Gemini.
    - Add get_usage_summary_by_app endpoint for per-app split (single
      UNION ALL + GROUP BY, avoids N+1).
    - Replace UsageSummaryCards + AppBreakdownRail with a single
      filter-driven UsageHero card; clicking a filter button now truly
      changes the displayed numbers and the title accent color.
    - Tighten KNOWN_APP_TYPES to the 3 app_types whose token data is
      reliably collected (claude/codex/gemini); hide claude-desktop,
      hermes, opencode, openclaw filter buttons and i18n keys.
    - Flag cache_creation as N/A for OpenAI-style protocols (Codex,
      Gemini); show a "partial" tooltip when the All view mixes both
      protocol families.
  • refactor(claude-desktop): trim duplication in proxy and switch flows
    - services/proxy.rs: collapse 10 repeated `OpenCode | OpenClaw | Hermes |
      ClaudeDesktop` match arms into `_` fallthroughs.
    - claude_desktop_config.rs: extract a `with_rollback` closure shared by
      apply_provider_to_paths and restore_official_at_paths.
    - useProviderActions.ts: replace the triple-nested ternary picking the
      switch-success toast message with a flat let/if/else block.
    
    Net -36 lines. No behavior change; cargo test and pnpm typecheck pass.
  • feat(claude-desktop): add 3P provider switching with proxy gateway
    Adds a new ClaudeDesktop AppType that writes Claude Desktop's third-party
    inference profile under configLibrary/, sharing _meta.json with other
    launchers (Ollama-compatible) so cc-switch can coexist with them.
    
    Two switch modes:
    - direct: provider already exposes claude-* / anthropic/claude-* model
      ids on Anthropic Messages, Claude Desktop connects to it directly.
    - proxy: cc-switch's local proxy acts as the inference gateway,
      presenting only claude-* route names to Claude Desktop and mapping
      them to real upstream models. Required after Anthropic restricted
      Claude Desktop to claude-family ids.
    
    Backend:
    - New module claude_desktop_config with snapshot/rollback, official seed
      bypass, /claude-desktop/v1/{models,messages} routes, and a single
      source of truth for default proxy routes.
    - Gateway token persisted in SQLite, validated on every proxied request.
    - get_claude_desktop_status surfaces drift signals (stale models,
      missing routes, proxy stopped, base URL mismatch, missing token).
    
    Frontend:
    - Slim ClaudeDesktopProviderForm independent from ProviderForm,
      controlled by a top-level appId guard.
    - ProviderList banner consumes the status query (5s polling) and
      renders actionable diagnostics.
    - ClaudeDesktopRouteToggle in the header to start/stop the local
      gateway without touching takeover state.
    - Three-locale i18n synchronised.
  • Fix Codex startup live import duplication (#2590)
    * Fix Codex startup live import duplication
    
    * Fix: Prevent duplicate Codex default provider on restart & add startup import tests
  • fix(proxy): derive Claude auth strategy from ANTHROPIC env var name
    Anthropic SDK assigns distinct semantics to the two env vars:
    
    - ANTHROPIC_API_KEY    -> x-api-key
    - ANTHROPIC_AUTH_TOKEN -> Authorization: Bearer
    
    The Claude adapter previously collapsed both into AuthStrategy::Anthropic
    and then emitted Authorization: Bearer regardless, breaking strict
    Anthropic-protocol endpoints (Anthropic official, Cloudflare AI Gateway,
    OpenCode Go, DashScope) and silently overriding the user's intended auth
    scheme.
    
    - claude::extract_auth: infer strategy from env var name
      (ANTHROPIC_AUTH_TOKEN -> ClaudeAuth, ANTHROPIC_API_KEY -> Anthropic),
      matching the precedence already used by extract_key.
    - claude::get_auth_headers: split the Anthropic arm so it emits
      x-api-key, while ClaudeAuth and Bearer continue to use Bearer.
    - stream_check: reuse ClaudeAdapter::get_auth_headers as the single
      source of truth, replacing the prior "always Bearer + maybe x-api-key"
      double injection that produced auth conflicts and false-negative
      health checks.
    - Cover each strategy -> header mapping and env-var precedence with
      new unit tests in claude.rs.
    
    Refs #2368, #2380
  • chore(usage): drop Hermes Agent tracking integration
    Hermes aggregates all in-process API calls into a single sessions row
    with the `model` field locked to the initial model, so the usage
    dashboard cannot cleanly surface per-call billing context. Two rounds
    of UI workarounds (raw mapping, then `<model> @ <host>` display) did
    not resolve the user-facing confusion, so the whole tracking
    integration is dropped for now.
    
    Removes session_usage_hermes service (and its 17 tests), sync wiring
    in commands/usage.rs and lib.rs, _hermes_session/hermes_session
    entries in usage_stats SQL (provider_name_coalesce CASE and
    effective_usage_log_filter IN clause), frontend Tab/banner/dropdown/
    icon entries, and four i18n keys per locale.
    
    Hermes app integration outside usage tracking (proxy routing,
    session manager, config) is preserved. Pre-existing hermes rows in
    proxy_request_logs are left as orphans — filtered out by the
    updated SQL and never surfaced in the UI.
  • feat(providers): add Baidu Qianfan Coding Plan for Claude Code (#2322)
    * feat(providers): add baidu qianfan coding plan presets
    
    * refactor(providers): align qianfan presets with existing format
    
    * chore(providers): narrow qianfan coding plan scope
  • fix(coding-plan): correct zhipu weekly tier name by reset time (#2420)
    Zhipu's `data.limits[]` returns 1 entry for legacy plans (subscribed
    before 2026-02-12) and 2 entries for current plans. Previously every
    TOKENS_LIMIT entry was hardcoded as `five_hour`, so the weekly bucket
    was rendered with the 5-hour i18n label.
    
    Sort TOKENS_LIMIT entries by nextResetTime ascending and assign
    `five_hour` to index 0, `weekly_limit` to index 1. Legacy plans
    naturally degrade to a single five_hour tier.
    
    Also harden the parser: case-insensitive type match (defends against
    upstream casing changes), reuse TIER_FIVE_HOUR/TIER_WEEKLY_LIMIT
    constants, and add 8 unit tests covering both plan shapes plus
    defensive edge cases.
  • Fix log message for session usage codex (#2473)
    * Fix log message for session usage codex
    
    * Fix comments in session_usage_codex.rs
  • 修复 Codex 切换供应商后历史记录变化 (#2349)
    * Keep Codex history stable across provider switches
    
    * Restore template Codex provider id when backfilling live config
    
    Backfill writes the current Codex live config back to the previous
    provider's stored template after a switch. Because the live file now
    carries a normalized stable model_provider id, the previous provider's
    template would lose its own provider-specific id (and any matching
    [profiles.*] references) on every subsequent switch.
    
    Reverse the normalization at backfill time by rewriting model_provider,
    the active model_providers section, and matching profile references back
    to the template's original id.
    
    ---------
    
    Co-authored-by: Jason <farion1231@gmail.com>
  • feat(usage): add Hermes Agent tracking + fix zero-cost bug + perf
    Hermes:
    - Parse ~/.hermes/state.db sessions (incl. profiles/*/state.db) into
      proxy_request_logs with data_source='hermes_session', WAL-aware
      incremental sync, Hermes-reported cost preferred over model_pricing
      fallback
    
    Zero-cost bug (dashboard showed \$0 totals):
    - GPT-5.5 family default pricing (~83% of affected rows used GPT-5.5)
    - find_model_pricing_row: ASCII-lowercase normalization so
      "OpenAI/GPT-5.5@HIGH" matches seeded "gpt-5.5"
    - Startup cost backfill in async task: scan rows where total_cost <= 0
      but tokens > 0, recompute via model_pricing in a single transaction
    
    Performance:
    - Add (app_type, created_at DESC) covering index for dashboard range
      queries
    - Add expression index on COALESCE(data_source, 'proxy') so dedup EXISTS
      subqueries use index lookup instead of full scan; drop superseded
      idx_request_logs_dedup_lookup
    
    Refactor:
    - row_to_request_log_detail helper (3-way de-dup; fixes cost_multiplier
      \"1\" vs \"1.0\" drift between callers)
    - Promote get_sync_state/update_sync_state to shared session_usage
      module (4 copies -> 1)
    - run_step helper in lib.rs replaces 9 if-let-Err blocks
    - maybe_backfill_log_costs returns bool to skip duplicate total_cost
      parsing in caller
  • fix(usage): prevent double-counting between proxy and session-log sources
    Proxy writes and session-log sync wrote to proxy_request_logs with
    mismatched request_ids: only Claude on a native Anthropic backend used the
    shared `session:{message_id}` key. Codex/Gemini and Claude-through-OpenAI
    providers always produced distinct ids, so primary-key dedup never fired
    and every real request was recorded twice.
    
    Adds a 7-dim fingerprint dedup (app_type, 4 token counts, 2xx status,
    model with case-insensitive match, ±10min window) wired into three layers:
    
    - Write path: should_skip_session_insert() blocks duplicate session rows
      before INSERT, unifying the previously-divergent Claude/Codex/Gemini
      paths through a single DedupKey-based helper.
    - Read path: effective_usage_log_filter() excludes already-covered session
      rows from every aggregation query.
    - Rollup path: same filter applied so usage_daily_rollups never absorbs
      duplicates.
    
    Also adds a covering index (idx_request_logs_dedup_lookup) so the EXISTS
    subquery stays index-only, and a transform.rs regression test that pins
    openai_to_anthropic id preservation - the missing piece that lets
    Claude+OpenAI-compatible providers reuse the session: id scheme.
  • fix(balance): show USD on SiliconFlow international site (was CNY)
    The query_siliconflow function received an is_cn flag that only switched
    the request domain (.cn vs .com) but the response builder hardcoded
    unit="CNY" for both sites. International users at api.siliconflow.com
    saw their USD balance labelled as CNY. Now unit and plan_name follow
    is_cn, so the EN site shows USD and "SiliconFlow (EN)".
  • feat(provider-form): soften validation with "save anyway" prompt (#2307)
    * feat(provider-form): soften business-rule validation with "save anyway" prompt
    
    Refactor handleSubmit so empty-field / missing-item validations (provider
    name, endpoint, API key, opencode model, template variables, provider key
    required) no longer hard-reject with toast.error. Instead they are collected
    into an issues list and presented via a ConfirmDialog; the user can cancel
    or choose "Save anyway" to proceed.
    
    Integrity constraints stay as hard rejections:
    - providerKey regex / duplicate (would corrupt other providers)
    - Copilot / Codex OAuth not authenticated (no token, cannot establish)
    - omo Other Fields JSON not an object / parse failure
    
    This aligns the frontend with the backend's existing "relaxed save / strict
    switch" split (see gemini_config.rs: validate_gemini_settings vs
    validate_gemini_settings_strict) and unblocks legitimate configs such as
    AWS Bedrock, Vertex AI, and custom Gemini base URLs that the UI previously
    refused to save.
    
    Refs: #2196, #1204
    
    * fix(provider-form): address review feedback on soft-validation
    
    P1: move empty providerKey back to hard rejection for OpenCode / OpenClaw /
    Hermes. Since providerKey is the primary identity for these apps and the
    mutations layer throws "Provider key is required" when absent, letting users
    click "save anyway" would surface a generic error toast instead of a
    precise, actionable one. Treat empty providerKey as an integrity constraint
    alongside regex / duplicate checks.
    
    P2: give the soft-confirm submit path its own submitting state. The
    confirm-dialog path bypassed react-hook-form's isSubmitting lifecycle, so
    slow or failing saves left the outer submit button responsive and could
    spawn unhandled rejections. Now the confirm handler awaits performSubmit
    inside try/catch/finally, uses an isConfirmSubmitting flag to gate both
    confirm and cancel clicks, and folds the flag into the outer disabled
    state and onSubmittingChange callback.
    
    Refs: #2307 review comments
    
    * chore(clippy): use push for single char '…' in truncate_body
    
    Clippy 1.95 added single_char_add_str which flagged the push_str("…")
    in truncate_body. Rebased onto latest upstream/main and applied the
    suggested fix so the Backend Checks clippy job passes.
    
    Unrelated to this PR's core changes; bundled in so the PR is mergeable
    without waiting for a separate upstream fix.
    
    ---------
    
    Co-authored-by: Allen <allen@AllenMacBook-M4-Pro.local>
  • fix(model-fetch): support /models for Anthropic-compat subpath providers
    Providers like DeepSeek, Kimi, Zhipu GLM and MiniMax expose the
    Anthropic-compatible API on a subpath (e.g. /anthropic) while the
    OpenAI-style /models endpoint lives at the API root. The previous
    heuristic blindly appended /v1/models to the Base URL, so every such
    provider returned 404 and the UI mislabeled it as "provider does not
    support fetching models".
    
    Backend now generates a candidate list and tries them in order:
    preset override -> baseURL /v1/models -> stripped-subpath /v1/models ->
    stripped-subpath /models. Non-404/405 responses (auth, network) stop
    immediately so we never retry against hostile status codes. Known
    compat suffixes are kept in a length-descending constant so the
    longest match wins; response bodies are truncated to 512 chars to
    avoid HTML 404 pages bloating the error string.
    
    Preset type gains an optional modelsUrl (DeepSeek points at
    https://api.deepseek.com/models). Frontend threads the override
    through fetchModelsForConfig when the current Base URL still matches
    the preset default. A new fetchModelsEndpointNotFound i18n key
    replaces the misleading "not supported" toast for exhausted-candidate
    and 404/405 cases (zh/en/ja).
  • feat(tray): show coding-plan usage for Kimi / Zhipu / MiniMax
    dc04165f surfaced tray usage badges for Claude/Codex/Gemini official
    OAuth only. Chinese coding-plan providers already expose 5h + weekly
    windows through coding_plan::get_coding_plan_quota, but two gaps kept
    the tray from rendering them.
    
    - format_script_summary read only data.first(), truncating the tier-
      flattened UsageResult to a single window. Detect plan_name matching
      TIER_FIVE_HOUR / TIER_WEEKLY_LIMIT and emit the "🟢 h12% w80%" layout
      used by format_subscription_summary; worst utilization drives the
      emoji. Copilot / balance / custom scripts keep the legacy single-
      bucket output via fallback.
    
    - usage_script previously required manual activation through
      UsageScriptModal. Auto-inject meta.usage_script on Claude provider
      creation when ANTHROPIC_BASE_URL matches a known coding plan, so the
      tray lights up without the user opening the modal. Does not overwrite
      existing usage_script on update.
    
    Extract the URL route table out of UsageScriptModal into a shared
    codingPlanProviders module so the modal, the creation hook, and the
    Rust coding_plan::detect_provider mirror all agree on one list.
    Add TIER_WEEKLY_LIMIT alongside TIER_FIVE_HOUR and a createUsageScript()
    factory to collapse the duplicated default fields across four call
    sites and drop the remaining stringly-typed tier names.
  • feat(tray): show cached provider usage in the system tray menu (#2184)
    * feat: add Rust-side write-through usage cache
    
    Introduce an in-memory UsageCache on AppState that the existing usage
    query commands populate on success. The cache is read-only to the rest
    of the app today; the next commit consumes it from the tray menu.
    
    - New services::usage_cache module with split maps: subscription keyed
      by AppType, script keyed by (AppType, provider_id).
    - AppType gains Eq + Hash so it can be used as a HashMap key.
    - commands::subscription::get_subscription_quota now takes State<AppState>
      and writes through on success (signature change is invisible to the
      frontend — Tauri injects State automatically).
    - commands::provider::queryProviderUsage body extracted into an inner
      async fn; the public command wraps it with write-through, covering
      Copilot, coding-plan, balance, and generic script paths uniformly.
    
    Cache is in-memory only; auto-query interval and the upcoming tray
    refresh action rebuild it after restarts.
    
    * feat(tray): surface cached usage in the system tray menu
    
    Read UsageCache populated by the previous commit and render it in three
    places, scoped to whatever TRAY_SECTIONS covers (Claude/Codex/Gemini):
    
    1. Inline suffix on each provider submenu item
       "AnyProvider  · 🟢 5h 18% / 7d 23%"
    2. Disabled summary row per visible app under "Show Main"
       "Claude · Anthropic Official · 🟢 5h 18% / 7d 23%"
    3. "Refresh all usage" menu item that triggers get_subscription_quota +
       queryProviderUsage for every applicable provider, then rebuilds the
       tray menu via the existing refresh_tray_menu path.
    
    Color encoding uses emoji (🟢 <70% / 🟠 70-89% / 🔴 ≥90%) since Tauri 2
    tray labels are plain text. Missing cache entry leaves the label
    unchanged — tray never issues network requests when opened. Three new
    i18n-ready strings live in TrayTexts (en/zh/ja), following the existing
    pattern for tray text.
    
    Closes #2178.
    
    * feat(usage): bridge tray UsageCache writes to frontend React Query
    
    Why: tray hover triggers backend-only refresh that wrote to UsageCache but
    never notified the frontend, leaving main UI stale while tray showed fresh
    numbers. Emit a payload-carrying event after each cache write so React Query
    can setQueryData directly, keeping both views in sync without duplicate fetches.
    
    * fix(tray): skip hidden apps on hover refresh and drop stale disabled-script cache
    
    Address P2 findings from automated review on #2184:
    
    1. refresh_all_usage_in_tray now filters TRAY_SECTIONS by settings.visible_apps
       before scheduling subscription/script queries, matching create_tray_menu and
       preventing wasted external API calls (and rate-limit/auth-error log noise)
       for apps the user has hidden.
    
    2. format_usage_suffix only trusts the script cache when provider.meta.usage_script
       is still enabled; when a script is disabled/removed the cached suffix is now
       invalidated so the tray label no longer shows stale data indefinitely.
    
    * refactor: consolidate codex provider helpers and fix test semantics
    
    - Add Provider::is_codex_oauth() and Provider::codex_fast_mode_enabled()
      to eliminate duplicated meta extraction in claude.rs and stream_check.rs
    - Fix non-codex-oauth tests to pass codex_fast_mode=false (was true, harmless
      but semantically misleading)
    - Remove redundant is_dir() guard after resolve_skill_source_dir already
      guarantees the returned path is a directory
    
    * style: apply cargo fmt
    
    * fix(tray): reflect failed refreshes in cache and support Gemini flash-lite
    
    Follow-up to the tray usage-display feature addressing review feedback:
    
    - Write snapshots for both Ok(success:false) and Err paths in
      queryProviderUsage / get_subscription_quota so stale success data
      no longer persists across failed refreshes; the original Err is
      still returned to the frontend onError handler.
    - Include gemini_flash_lite tier in the tray summary with label "l".
      Matches the frontend SubscriptionQuotaFooter and keeps the worst
      emoji correct when lite is the highest utilization.
    - Add TIER_GEMINI_PRO / _FLASH / _FLASH_LITE constants in
      services/subscription.rs and reuse them in classify_gemini_model
      and sort_order.
    - Extract Provider::has_usage_script_enabled() to remove the
      duplicated meta.usage_script chain at two call sites.
    - Use db.get_provider_by_id in refresh_all_usage_in_tray instead of
      materialising the full provider map, and parallelise subscription
      and script futures via futures::future::join.
    - Narrow refresh_all_usage_in_tray to each section's effective
      current provider (script if enabled, else subscription when the
      provider is official). Hover refreshes now issue at most
      TRAY_SECTIONS.len() outbound requests.
    - Add 10 unit tests in tray::tests covering Claude/Codex h/w dispatch,
      Gemini p/f/l dispatch (including lite-only and lite-worst cases),
      and success/failure guards.
    
    ---------
    
    Co-authored-by: Jason <farion1231@gmail.com>
  • Fix/一键配置失效 (#2249)
    * style(FailoverQueueManager): 显示供应商备注信息
    
    * style(FailoverQueueItem): 添加供应商备注字段以支持备注信息显示
    
    * style(FailoverQueueManager): 显示供应商备注信息
    
    * style(FailoverQueueItem): 添加供应商备注字段以支持备注信息显示
    
    * style(FailoverQueueManager): 更新供应商备注信息的显示样式
    
    * style(FailoverQueueItem): 添加条件序列化以优化供应商备注字段
    
    * fix: 优化模型状态管理,确保配置更新时正确引用最新设置
    
    * fix(skill): improve error handling for skill source directory resolution
    
    Co-authored-by: Copilot <copilot@github.com>
    
    * fix(gemini): simplify project directory retrieval in scan_sessions function
    
    * fix(useModelState): optimize latestConfigRef assignment in useModelState hook
    
    * fix(useModelState): remove unnecessary blank line in useModelState hook
    
    ---------
    
    Co-authored-by: Copilot <copilot@github.com>
  • feat: Add Codex OAuth FAST mode toggle (#2210)
    * Add Codex OAuth FAST mode toggle
    
    * fix(codex-oauth): default FAST mode to off to avoid surprise quota burn
    
    service_tier="priority" consumes ChatGPT subscription quota at a higher
    rate. Users must now opt in explicitly rather than inherit FAST mode
    silently when this feature ships.
    
    ---------
    
    Co-authored-by: Jason <farion1231@gmail.com>
  • fix: use TOML parser instead of regex for Codex model extraction (#2222) (#2227)
    * fix(codex): use TOML parser instead of regex for model extraction
    
    Regex only matched model=... on first line, TOML parser handles
    multiline TOML correctly.
    
    Fixes #2222
    
    * fix(stream_check): drop unused regex::Regex import
    
    The previous commit replaced the only Regex usage in stream_check.rs
    with toml::Table parsing, leaving `use regex::Regex;` orphaned.
    Without this removal, `cargo clippy -- -D warnings` (run in CI)
    fails with `unused import: regex::Regex`.
    
    ---------
    
    Co-authored-by: Jason <farion1231@gmail.com>
  • fix(hermes): stop health check from borrowing OpenClaw schema
    Hermes providers were routed through check_additive_app_stream, the
    OpenClaw dispatcher, which reads camelCase fields (baseUrl/apiKey/api)
    and emits "OpenClaw is missing ..." errors. Hermes stores snake_case
    fields (base_url/api_key/api_mode) with different protocol tags, so
    users saw "OpenClaw provider is missing baseUrl" even after filling in
    every Hermes field correctly.
    
    Introduce check_hermes_stream with Hermes-specific extractors. Route
    api_mode (chat_completions / anthropic_messages / codex_responses) to
    the matching check_claude_stream api_format, and return bedrock_converse
    as unsupported. Resolve api_mode before extracting URL/API key so users
    who picked bedrock_converse see the real cause first rather than a
    misleading "missing base_url" message.
  • feat(hermes): bind per-provider models to top-level model: on switch
    Hermes custom_providers entries now carry an ordered models array
    (id / context_length / max_tokens) plus suggestedDefaults. The backend
    serializes the array to the YAML dict shape Hermes expects on write and
    inverts it on read, preserving insertion order via the preserve_order
    feature on serde_json.
    
    When a user switches providers, switch_normal calls apply_switch_defaults
    so the top-level model.default / model.provider follow the selected
    provider's first model. Previously switching a Hermes provider only
    shuffled custom_providers[] and left Hermes pointing at whatever
    model.provider was set before.
    
    Seven existing Hermes presets now ship with a curated models list so
    switching lands on a working default without a detour through the
    Model panel.
  • fix: address Hermes review findings (5 medium issues)
    - Add missing Hermes MCP import on first launch (lib.rs)
    - Add Hermes branch in ProviderForm defaultValues fallback
    - Include Hermes in session manager subtitle (zh/en/ja)
    - Rename check_openclaw_stream to check_additive_app_stream
    - Cache parsed HERMES_DEFAULT_CONFIG to avoid repeated JSON.parse
  • feat: add Hermes UI components, presets, and config panels (Phase 8)
    - Add 7 provider presets (OpenRouter, Anthropic, OpenAI, Google, DeepSeek, Together, Nous)
    - Create HermesFormFields + useHermesFormState for provider form integration
    - Create Model/Agent/Env config panels with save/load functionality
    - Create HermesHealthBanner for config warnings
    - Add hermes icon (violet winged H) to icon system
    - Integrate into App.tsx: 3 new view types (hermesModel/hermesAgent/hermesEnv),
      sidebar buttons (Brain/Bot/KeyRound), health banner, session support
    - Integrate into ProviderForm: presets, form state, key validation, rendering
    - Integrate into AddProviderDialog: universal tab exclusion, providerKey, base_url extraction
    - Add i18n keys for all Hermes UI (zh/en/ja)
  • feat: implement Hermes MCP sync module (Phase 4)
    Add mcp/hermes.rs with bidirectional MCP format conversion:
    - convert_to_hermes_format: strip type field, infer from command/url
    - convert_from_hermes_format: infer type, strip Hermes-specific fields
    - Merge-on-write: existing Hermes fields (tools, sampling, timeout,
      roots, enabled) preserved when user has customized them
    - update_mcp_servers_yaml: closure-based read-modify-write under write
      lock to prevent TOCTOU races in concurrent sync operations
    - 9 unit tests for format conversion and merge logic
    
    Wire up all MCP service dispatch:
    - Replace Hermes TODO stubs with real sync/remove calls
    - Remove Hermes from sync_all_enabled skip list
    - Enable deep link hermes MCP flag (apps.hermes = true)
    - Add Hermes import to import_mcp_from_apps command
  • feat: implement Hermes config module and commands (Phase 3)
    Add hermes_config.rs (~1190 lines) with YAML section-level replacement
    that preserves comments and formatting in unmanaged sections:
    - Type definitions: HermesModelConfig, HermesAgentConfig, HermesEnvConfig
    - YAML section finder (find_yaml_section_range) with column-0 key detection
    - Provider CRUD on custom_providers array (indexed by name field)
    - Model/Agent config get/set via yaml<->json conversion
    - .env dotenv read/write preserving comments and line ordering
    - Health check, backup with rotation, write lock (OnceLock<Mutex>)
    - MCP section access stubs for Phase 4
    - 19 unit tests
    
    Add commands/hermes.rs with 10 Tauri commands registered in lib.rs.
    Replace all Hermes TODO stubs in services/provider/live.rs with real
    implementations (import, remove, write-to-live, read-live-settings).
  • feat: add Hermes Agent as 6th supported app type (Phase 1)
    Register AppType::Hermes across the entire Rust backend:
    - Add Hermes variant to AppType enum with additive mode and MCP support
    - Add hermes field to McpApps, SkillApps, CommonConfigSnippets, and all
      per-app structs (McpRoot, PromptRoot, VisibleApps, AppSettings)
    - Create minimal hermes_config.rs with get_hermes_dir() respecting
      settings override, matching the pattern of other app config modules
    - Update all match arms in commands, services, deeplink, proxy, mcp,
      session_manager, and test files
    - Extract shared build_additive_app_settings() to eliminate duplication
      between OpenClaw and Hermes deep link handling
    - Combine identical OpenClaw/Hermes proxy match arms into unified arms
  • fix: remove ANTHROPIC_REASONING_MODEL to decouple thinking from model selection (#2081)
    ANTHROPIC_REASONING_MODEL was a non-official env var that forced all
    requests with thinking params to use a single "reasoning model",
    overriding the user's /model selection. Since new Claude Code versions
    send adaptive thinking by default, this caused /model to silently fail.
    
    - Remove reasoning_model field and has_thinking_enabled() from model_mapper
    - Simplify map_model() to pure type-based matching (haiku/sonnet/opus)
    - Remove reasoning model UI field from provider form
    - Retain ANTHROPIC_REASONING_MODEL in ENV_EXCLUDES and override-key
      cleanup lists so legacy configs don't leak into common config
  • fix(skills): sync imported skills to app directories after import (#2101)
    `import_from_apps()` saves skills to the database but does not create
    symlinks/copies in the target app directories (e.g. `~/.claude/skills/`).
    This causes skills to appear as "installed" in the UI while the actual
    files are missing from the app directories.
    
    Add `sync_to_app_dir()` calls after `db.save_skill()` in the import
    loop, matching the pattern used by `install()` and `toggle_app()`.
  • feat(proxy): Gemini Native API proxy integration (#1918)
    * refactor(proxy): extract take_sse_block helper with CRLF delimiter support
    
    Replace inline `buffer.find("\n\n")` SSE splitting logic across streaming,
    streaming_responses, response_handler, and response_processor with a shared
    `take_sse_block` function that handles both `\n\n` and `\r\n\r\n` delimiters.
    
    * feat(proxy): add Gemini Native URL builder and full-URL resolver
    
    Introduce gemini_url module that normalizes legacy Gemini/OpenAI-compatible
    base URLs into canonical models/*:generateContent endpoints. Supports both
    structured Gemini URLs (auto-normalized) and opaque relay URLs (pass-through
    with query params only).
    
    * feat(proxy): add Gemini Native schema, shadow store, transform, and streaming
    
    - gemini_schema: Gemini generateContent request/response type definitions
    - gemini_shadow: session-scoped shadow store for thinking signature and
      tool-call state replay across streaming chunks
    - transform_gemini: bidirectional Anthropic Messages ↔ Gemini Native
      request/response conversion with thinking block and tool-use support
    - streaming_gemini: Gemini SSE → Anthropic SSE streaming adapter with
      incremental thinking/text/tool_use delta emission
    
    * feat(proxy): wire Gemini Native format into proxy core and Claude adapter
    
    Integrate gemini_native api_format throughout the proxy pipeline:
    - ClaudeAdapter: detect Gemini provider type, Google/GoogleOAuth auth
      strategies, and suppress Anthropic-specific headers for Gemini targets
    - Forwarder: Gemini URL resolution, shadow store threading, endpoint
      rewriting to models/*:generateContent with stream/non-stream variants
    - Handlers: route Gemini streaming through streaming_gemini adapter and
      non-streaming through transform_gemini converter
    - Server/State: add GeminiShadowStore to shared ProxyState
    - StreamCheck: support gemini_native health check with proper auth headers
    
    * feat(ui): add Gemini Native provider preset and api format option
    
    - Add gemini_native to ClaudeApiFormat type and ProviderMeta.apiFormat
    - Add "Gemini Native" provider preset with default Google AI endpoints
    - Show Gemini-specific endpoint hints and full-URL mode guidance
    - Add gemini_native option to API format selector in ClaudeFormFields
    - Add i18n strings for zh/en/ja
    
    * feat(proxy): add Gemini Native tool argument rectification
    
    * feat(proxy): update Gemini streaming and transformation logic
    
    * fix(proxy): align shadow turns to tail on client history truncation
    
    * fix: revert unrelated cache_key change in claude proxy transform
    
    Restore .unwrap_or(&provider.id) fallback for cache_key to match main
    branch behavior. Only gemini_native related changes should be in this branch.
    
    * Prevent Gemini review regressions in streaming and tool rectification
    
    PR #1918 review feedback exposed two correctness issues in the Gemini Native adapter path. Gemini SSE buffering was still using lossy UTF-8 decoding, which could corrupt split multibyte payloads and drop streamed output. Tool arg rectification also removed top-level parameters eagerly, which broke tools that legitimately define a parameters field.
    
    This change moves Gemini SSE buffering onto the existing append_utf8_safe path and makes parameters flattening conditional on the schema actually expecting nested extraction. The old Skill rectification path stays intact, and new regression tests cover both the preserved parameters case and UTF-8-split JSON payloads.
    
    Constraint: Existing PR #1918 review feedback must be fixed without staging unrelated local docs and artifact files
    Rejected: Keep String::from_utf8_lossy in Gemini SSE buffering | corrupts split multibyte payloads and can drop JSON chunks
    Rejected: Always preserve the parameters wrapper | regresses the existing nested-parameters rectification path for Skill-style tools
    Confidence: high
    Scope-risk: narrow
    Reversibility: clean
    Directive: Keep Gemini SSE buffering on the UTF-8-safe accumulator path and only unwrap parameters when the target schema does not declare it as a legitimate field
    Tested: cargo fmt --manifest-path src-tauri/Cargo.toml --all; cargo test --manifest-path src-tauri/Cargo.toml preserves_utf8_boundaries_when_json_payload_spans_chunks; cargo test --manifest-path src-tauri/Cargo.toml gemini_to_anthropic_rectifies_tool_args_from_schema_hints; cargo test --manifest-path src-tauri/Cargo.toml rectifies_streamed_skill_args_from_nested_parameters; cargo test --manifest-path src-tauri/Cargo.toml gemini_to_anthropic_preserves_legitimate_parameters_arg
    Not-tested: Full src-tauri test suite; live end-to-end Gemini relay traffic against upstream services
    
    * Keep Gemini tool replay stable across Claude request boundaries
    
    Claude Code follow-up requests were still falling back to locally reconstructed functionCall parts, which dropped Gemini thought signatures and triggered INVALID_ARGUMENT errors from the official Gemini API. The replay path needed to survive real Claude request boundaries, not just idealized in-process test flows.
    
    This change makes Claude requests reuse X-Claude-Code-Session-Id as the shadow session key, records streamed Gemini tool turns before tool_use events are fully drained, and matches assistant tool_use turns to shadow state by tool_use id and normalized tool name before positional fallback. Together these fixes keep thoughtSignature-bearing Gemini tool calls available for the next request in the loop.
    
    Constraint: Claude Code sends a stable X-Claude-Code-Session-Id header while metadata.session_id may be absent on follow-up requests
    Rejected: Rely on metadata-only Claude session extraction | generated fresh session ids and broke cross-request shadow replay
    Rejected: Record Gemini shadow only after streaming completes | loses the race when the client sends the next request immediately after tool_use
    Confidence: high
    Scope-risk: narrow
    Reversibility: clean
    Directive: Preserve Gemini shadow continuity across requests by keying Claude sessions from the header first and persisting tool-call shadow before yielding tool_use events downstream
    Tested: cargo fmt --manifest-path src-tauri/Cargo.toml --all; cargo test --manifest-path src-tauri/Cargo.toml test_extract_session_from_claude_header; cargo test --manifest-path src-tauri/Cargo.toml test_extract_session_from_claude_header_precedes_metadata; cargo test --manifest-path src-tauri/Cargo.toml stores_tool_shadow_before_tool_use_events_are_fully_drained; cargo test --manifest-path src-tauri/Cargo.toml shadow_replay_matches_tool_use_turn_by_id_when_position_drifts; cargo test --manifest-path src-tauri/Cargo.toml shadow_replay_aligns_to_latest_turns_after_client_truncation
    Not-tested: Full src-tauri test suite without test filters; live end-to-end Gemini relay after this exact commit hash
    
    * style: apply cargo fmt to pass Backend Checks CI
    
    Wrap prompt_cache_key chained call across lines per rustfmt default
    formatting. Pure formatting change, no behavior difference.
    
    * fix(proxy/gemini): synthesize unique ids for no-id tool calls + enforce object params schema
    
    P1 — Parallel tool calls without Gemini-assigned ids no longer collapse.
    Gemini 2.x native parallel `functionCall` entries may omit the `id` field.
    The previous `merge_tool_call_snapshots` fell back to matching by `name`,
    which silently merged two parallel calls to the same function into one
    entry — dropping the first call's args. The non-streaming path and shadow
    store further bottlenecked on empty-string ids: multiple `tool_use` blocks
    shared the same id, and `tool_name_by_id.get("")` could only return one
    mapping, causing later `tool_result` round-trips to fail with
    `Unable to resolve Gemini functionResponse.name` or bind to the wrong tool.
    
    Fix: introduce `synthesize_tool_call_id()` producing `gemini_synth_<uuid>`.
    Both streaming and non-streaming response paths now guarantee every
    Anthropic-visible tool_use carries a unique id. `merge_tool_call_snapshots`
    matches by id first, falling back to the `parts` array position (for the
    cumulative-streaming case) while preserving the synthesized id across
    chunks. `convert_message_content_to_parts` detects the synthetic prefix
    and strips the id from outbound `functionCall`/`functionResponse` so the
    internal identifier never leaks upstream. `shadow_parts` performs the
    same strip when replaying a recorded assistant turn.
    
    P2 — Vertex AI rejects empty `parameters` schemas. When an Anthropic tool
    arrives with missing or empty `input_schema`, the proxy used to emit
    `"parameters": {}` (no `type`), which fails Vertex AI validation with
    `functionDeclaration parameters schema should be of type OBJECT`.
    Contrary to the automated-review suggestion, the fix is not to omit
    `parameters` (that too is rejected) but to normalize to the canonical
    empty-object form `{type: "object", properties: {}}`.
    Refs: google-gemini/generative-ai-python#423, BerriAI/litellm#5055.
    
    Fix: new `ensure_object_schema` helper in `gemini_schema` promotes
    missing `type` to `"object"` and adds empty `properties` when absent,
    while leaving atomic (non-object) schemas untouched.
    
    Tests: seven new regressions covering parallel no-id calls, cumulative
    chunk id reuse, synthetic-id round-trip both directions, shadow replay
    id stripping, and the three Vertex-AI schema shapes.
    
    The two existing wrapper functions (`gemini_to_anthropic` and
    `gemini_to_anthropic_with_shadow`) gain `#[allow(dead_code)]` to clear
    a pre-existing clippy -D warnings failure — they are part of the public
    transform API surface and intentionally kept for future callers.
    
    Addresses Codex review P1/P2 on #1918.
    
    * fix(proxy/gemini): narrow URL normalization + guard empty OAuth access_token
    
    P2a — Preserve opaque relay URLs that contain `/v1/models/` prefixes.
    
    `should_normalize_gemini_full_url` previously flagged any full URL whose
    path merely contained `/v1beta/models/` or `/v1/models/` as a structured
    Gemini endpoint, forcing rewrite to `.../v1beta/models/{model}:method`.
    This silently dropped legitimate relay route segments (e.g.
    `https://relay.example/v1/models/invoke` → `.../v1beta/models/...:generateContent`,
    losing `/invoke`) and sent traffic to the wrong upstream path.
    
    Replace the bare `contains(...)` checks with
    `matches_structured_gemini_models_path`, which requires the
    `/models/` segment to be followed by a canonical Gemini method call
    (`*:generateContent` or `*:streamGenerateContent`). The
    `matches_bare_gemini_models_path` helper is generalized (and renamed) to
    handle both `/v1beta/models/` and `/v1/models/` alongside the original
    bare `/models/` shape.
    
    P2b — Reject empty Gemini OAuth access_tokens before they reach the
    bearer header.
    
    `GeminiAdapter::parse_oauth_credentials` accepts refresh-token-only JSON
    (and surfaces `{"access_token": "", ...}` for expired credentials) with
    `access_token` defaulting to `""`. The Claude adapter's GeminiCli branch
    then called `AuthInfo::with_access_token(key, creds.access_token)`
    unconditionally, so the bearer-header builder at
    `AuthStrategy::GoogleOAuth` resolved to `Authorization: Bearer ` — a
    deterministic 401 from upstream.
    
    CC Switch does not currently exchange the refresh_token for a fresh
    access_token (`OAuthCredentials::needs_refresh` / `can_refresh` are
    annotated `#[allow(dead_code)]`). Until that exists, only attach
    `access_token` when it is non-empty; fall back to plain GoogleOAuth
    strategy with the raw key and log a warn pointing users at
    `~/.gemini/oauth_creds.json` so the failure mode is observable.
    
    Tests:
    - gemini_url.rs: three new regressions — opaque `/v1/models/invoke`,
      opaque `/v1beta/models/route`, and the positive counter-case where a
      structured `/v1/models/...:generateContent` path still normalizes.
    - claude.rs: three new `test_extract_auth_gemini_cli_*` tests covering
      refresh-only JSON, empty-string access_token JSON, and the valid-JSON
      pass-through.
    
    All 839 lib tests pass; cargo fmt + clippy -D warnings clean.
    
    Addresses Codex review P2 findings on #1918.
    
    * fix(proxy/gemini): treat empty-string functionCall id as missing in streaming path
    
    Follow-up to the earlier P1 fix: some Gemini relays serialize an absent
    functionCall id as `"id": ""` instead of omitting the field. The
    non-streaming `extract_tool_call_meta` already filters these via
    `.filter(|s| !s.is_empty())`, but the streaming counterpart
    `extract_tool_calls` passed the empty string straight through
    `function_call.get("id").and_then(|v| v.as_str())` into
    `GeminiToolCallMeta::new`, producing a `Some("")` id.
    
    Downstream, `merge_tool_call_snapshots` would then match two parallel
    no-id calls against each other on their shared empty-string id,
    collapsing them into a single snapshot (silent data loss for the first
    call) and emitting an Anthropic `tool_use.id: ""` that breaks tool_result
    correlation on the Claude Code client.
    
    Fix:
    - `extract_tool_calls`: apply the same `filter(|s| !s.is_empty())` guard
      used in the non-streaming path so empty strings become `None` before
      reaching the shadow meta.
    - `merge_tool_call_snapshots`: defensively collapse any incoming
      `Some("")` to `None` up front — keeps the "missing vs present" invariant
      local to the merge step for future callers that might build
      `GeminiToolCallMeta` by hand.
    
    Tests (2 new, both in streaming_gemini):
    - `parallel_empty_string_id_calls_are_treated_as_missing_and_preserved`
      covers two parallel calls with explicit `"id": ""` — asserts both
      surface, no empty tool_use id leaks, and each gets a unique
      `gemini_synth_` id.
    - `single_empty_string_id_tool_call_gets_synthesized_id` covers the
      non-parallel degraded-relay case.
    
    All 841 lib tests pass; cargo fmt + clippy -D warnings clean.
    
    Addresses Codex follow-up P1 on #1918.
    
    * fix(proxy/gemini): gate generic REST path suffixes behind Google host whitelist
    
    `should_normalize_gemini_full_url` previously treated any full URL whose
    path ends with `/v1`, `/v1/models`, `/models`, `/v1/openai`, or `/openai`
    as a structured Gemini endpoint and rewrote it to
    `/v1beta/models/{model}:generateContent`. These are ubiquitous REST
    conventions — opaque relays such as `https://relay.example/custom/v1`
    legitimately use them for fixed endpoints — so the rewrite silently
    routed traffic to the wrong upstream path.
    
    Split the predicate into two layers:
    
    - **Unconditional**: `matches_structured_gemini_models_path` (i.e. a
      `/models/...:generateContent` method call anywhere in the path), the
      Google-specific `/v1beta*` family, and the deep OpenAI-compat paths
      (`/v1beta/openai/chat/completions`, `/openai/chat/completions`, and
      their `responses` siblings). These remain host-agnostic because the
      path grammar itself is Gemini-specific.
    - **Google-host gated**: `/v1`, `/v1/models`, `/models`, `/v1/openai`,
      `/openai`. Only normalized when the host is one of
      `generativelanguage.googleapis.com`, `aiplatform.googleapis.com`, or a
      real `*-aiplatform.googleapis.com` Vertex regional endpoint. The match
      is exact/suffix (not `contains`), so lookalike hosts like
      `aiplatform.example.com` are correctly treated as opaque relays.
    
    Tests (8 new in `gemini_url::tests`):
    - Four opaque-relay cases: `/custom/v1`, `/custom/models`,
      `/custom/v1/models`, `/custom/openai` — all preserved as-is.
    - Three Google-host counter-cases: `/v1`, `/models`, and
      `us-central1-aiplatform.googleapis.com/v1` still normalize.
    - One lookalike safety case: `aiplatform.example.com/v1` is NOT
      treated as Google.
    
    All 849 lib tests pass; cargo fmt + clippy -D warnings clean.
    
    Addresses Codex review P2 on #1918.
    
    * fix(proxy/gemini): align shadow id with client-visible id in non-streaming path
    
    When Gemini returns a `functionCall` without an id (common in 2.x
    parallel calls), `gemini_to_anthropic_with_shadow_and_hints` previously
    generated TWO independent synthesized UUIDs:
    
      1. Line 186-197 — synthesized id `A` used for the Anthropic-visible
         `content[tool_use].id` returned to the client.
      2. Line 850-881 — `extract_tool_call_meta` independently synthesized
         id `B ≠ A`, which populated `shadow_turn.tool_calls[i].id`.
    
    `shadow_content` (line 225-228, cloned from `rectified_parts`) retained
    the original missing/empty id. Result: the client sees id `A`, the
    shadow store holds id `B`.
    
    On the next turn, `convert_messages_to_contents` builds
    `tool_name_by_id` from `build_tool_name_map_from_shadow_turns`, which
    uses `tool_calls[i].id` — so the map contains `B → name` but not
    `A → name`. When the client sends back `tool_result(tool_use_id=A)`,
    resolution fails with:
    
      Unable to resolve Gemini functionResponse.name for tool_use_id `A`
    
    This affects both truncated histories (client sends only the
    tool_result) and full histories (shadow-replay branch at line 342-354
    skips `convert_message_content_to_parts`, so the assistant tool_use
    block never registers id `A` itself).
    
    Fix: make `rectified_parts` the single source of truth. After
    `rectify_tool_call_parts`, run a pre-pass that writes
    `synthesize_tool_call_id()` back into any `functionCall` that lacks a
    non-empty id. All three readers — the content builder (186-197), the
    shadow_content clone (225-228), and `extract_tool_call_meta` — then
    observe the same id. `shadow_parts()` already strips synthesized ids on
    replay (line 616-628), so the internal identifier never leaks to
    Gemini upstream.
    
    This mirrors the streaming path, which already has single-source-of-
    truth semantics via `tool_call_snapshots` in `streaming_gemini.rs` —
    no change needed there.
    
    Tests (5 new in `transform_gemini::tests`):
    - `non_stream_shadow_id_matches_client_visible_id`: asserts
      `response.content[0].id == shadow.tool_calls[0].id ==
      shadow.assistant_content.parts[0].functionCall.id`.
    - `non_stream_missing_id_scenario_a_truncated_history_resolves`: turn 2
      sends only `[tool_result(id=A)]`; resolution must succeed.
    - `non_stream_missing_id_scenario_b_full_history_replay_resolves`: turn 2
      sends `[assistant(tool_use=A), tool_result(A)]`; shadow-replay branch
      strips the synth id from outgoing `functionCall` while still
      resolving the subsequent `tool_result`.
    - `non_stream_preserves_original_gemini_id_when_present`: regression —
      genuine Gemini ids flow through unchanged.
    - `non_stream_synthesized_id_not_leaked_to_gemini_via_shadow_replay`:
      defensive — shadow-replay path must strip synth ids from both
      `functionCall.id` and `functionResponse.id`.
    
    All 854 lib tests pass; cargo fmt + clippy -D warnings clean.
    
    Addresses Codex follow-up P1 on #1918.
    
    * refactor(proxy/gemini): share build_anthropic_usage between stream and non-stream paths
    
    `streaming_gemini::anthropic_usage_from_gemini` and
    `transform_gemini::build_anthropic_usage` were byte-for-byte identical
    (32 lines each) — both converting Gemini `usageMetadata` into the
    Anthropic `usage` shape including `cache_read_input_tokens` mapping.
    
    Promote the non-streaming version to `pub(crate)` and reuse it from the
    streaming SSE converter. Removes ~30 lines of duplication and guarantees
    the two paths cannot drift apart.
    
    No behavioral change; all 854 lib tests pass; cargo fmt + clippy -D
    warnings clean.
    
    * fix(proxy/gemini): gate /v1beta behind Google host + normalize models/ model id prefix
    
    Two related P2 corrections to the Gemini Native URL surface, both
    folding into the existing Google-host-whitelist architecture.
    
    ## P2a — `/v1beta` suffix should not unconditionally trigger rewrite
    
    `should_normalize_gemini_full_url` placed `/v1beta` and `/v1beta/models`
    in the unconditional layer on the reasoning that `/v1beta` is
    Google-specific. In practice an opaque relay fronting a non-Gemini
    service at `https://relay.example/custom/v1beta` would still be
    silently rewritten to `/v1beta/models/{model}:generateContent`,
    breaking the deployment.
    
    Move `/v1beta`, `/v1beta/models`, and `/v1beta/openai` into the
    Google-host gated layer alongside `/v1`, `/models`, and friends. The
    unconditional layer now only accepts paths whose grammar is
    intrinsically Gemini — `/models/...:generateContent` method calls and
    the deep OpenAI-compat endpoints like `/openai/chat/completions` and
    `/openai/responses`. Pasted AI-Studio URLs such as
    `https://generativelanguage.googleapis.com/v1beta` still normalize
    because the host matches the whitelist.
    
    ## P2b — `model: "models/gemini-2.5-pro"` produced doubled path prefix
    
    Gemini SDKs (and the official `list_models` response) commonly surface
    model ids in resource-name form `models/gemini-2.5-pro`. Raw
    interpolation into `format!("/v1beta/models/{model}:...")` produced
    `/v1beta/models/models/gemini-2.5-pro:streamGenerateContent` which
    upstream rejects — yielding false-negative health checks for otherwise
    valid provider configs.
    
    Introduce `normalize_gemini_model_id(&str) -> &str` in `gemini_url`
    as the single source of truth: strips an optional leading `/` then an
    optional `models/` prefix, leaving bare ids untouched. Apply in the
    three call sites that build a Gemini method URL:
    - `services/stream_check.rs::resolve_claude_stream_url` (unified path)
    - `services/stream_check.rs::check_gemini_stream` (Gemini-only path)
    - `proxy/forwarder.rs::rewrite_claude_transform_endpoint` (production)
    
    Tests (9 new):
    - `gemini_url`: 3 regressions for opaque vs Google-host `/v1beta*`
      handling + 5 unit tests pinning `normalize_gemini_model_id` behavior
      (strip prefix, leave bare id, preserve nested slashes past the one
      stripped prefix, tolerate leading slash, pass through empty input).
    - `stream_check`: one end-to-end regression confirming
      `models/gemini-2.5-pro` collapses to the expected single-prefix URL.
    - `forwarder`: one end-to-end regression on the production rewrite
      path.
    
    All 864 lib tests pass; cargo fmt + clippy -D warnings clean.
    
    Addresses Codex P2 feedback on #1918.
    
    * fix(proxy/gemini): trim API key before provider-type detection and OAuth parsing
    
    Leading whitespace on a copied oauth_creds.json (e.g. trailing newline
    when the user copies the file content as-is) would slip past the
    `starts_with("ya29.") || starts_with('{')` prefix check in
    `ClaudeAdapter::provider_type`, causing the provider to be misclassified
    as raw-API-key Gemini and fall back to `x-goog-api-key` with the raw
    JSON as the key — which upstream rejects with 401.
    
    The frontend's `handleApiKeyChange` already trims on keystrokes but
    deep-link imports, the JSON editor, and live-config backfill all bypass
    that path. Trim at every backend extraction point so the coverage is
    uniform:
    
    - `ClaudeAdapter::extract_key` (5 env / fallback branches) gets
      `.map(str::trim)` before `.filter(|s| !s.is_empty())` so that
      whitespace-only values are also treated as missing.
    - `GeminiAdapter::extract_key_raw` gets the same chain (including
      the `.filter` it was missing before).
    - `GeminiAdapter::parse_oauth_credentials` gets a defensive
      `let key = key.trim();` at the entry as a belt-and-suspenders guard.
    
    Adds two regression tests covering JSON and bare `ya29.` keys with
    leading newline/space.
    
    * fix(proxy/gemini): gate generic REST suffix stripping behind Google host in non-full-URL mode
    
    `build_gemini_native_url` unconditionally stripped `/v1`, `/v1beta`,
    `/models`, and `/openai` suffixes from the base path regardless of
    host. This worked for Google's own endpoints but silently rewrote
    third-party relay URLs like `https://relay.example/custom/v1` to
    `.../custom/v1beta/models/...`, breaking any relay that mounts its
    Gemini-compatible namespace under a versioned prefix.
    
    The result was also asymmetric with the previously-fixed full-URL
    branch: toggling the "full URL" switch changed the outbound URL for
    the same base_url, which is exactly the kind of invisible behavior
    that makes debugging proxy deployments painful.
    
    Align `normalize_gemini_base_path` with
    `should_normalize_gemini_full_url`'s layered model:
    
    - Unconditional: `/models/...:method` structured paths and deep
      OpenAI-compat endpoints (`/openai/chat/completions`,
      `/openai/responses` and their versioned variants) — these are
      unambiguous Gemini-specific grammar on any host.
    - Google-host gated: generic `/v1`, `/v1beta`, `/models`, `/openai`
      suffixes only get stripped on `generativelanguage.googleapis.com`,
      `aiplatform.googleapis.com`, or `*-aiplatform.googleapis.com`.
      Other hosts preserve the prefix verbatim so relays keep their
      intended routing.
    
    Adds seven regression tests for the non-full-URL flow: opaque relay
    preservation (v1 / v1beta / models / openai suffix variants), Google
    host normalization (counter-case), and boundary cases (structured
    method path and deep OpenAI-compat endpoint stripped regardless of
    host).
    
    Test count: 864 -> 873.
    
    * Revert "fix(proxy/gemini): gate generic REST suffix stripping behind Google host in non-full-URL mode"
    
    This reverts commit d19ff09cb7.
    
    * test(proxy/gemini): pin non-full-URL versioned relay base stripping
    
    Adds two regression tests that lock in the intentional asymmetry
    between full-URL and non-full-URL modes:
    
    - Full-URL mode: opaque base path (e.g. `https://relay.example/custom/v1beta`)
      is preserved verbatim. Already covered by
      `preserves_opaque_full_url_with_bare_v1beta_suffix`.
    - Non-full-URL mode: base path MUST strip `/v1`, `/v1beta`, etc. so the
      standard `/v1beta/models/{model}:method` endpoint can be appended
      without producing a doubled `/v1beta/v1beta/models/...` path.
    
    The non-full-URL contract is "base URL + cc-switch appends the
    canonical Gemini endpoint". A user who needs a relay's custom
    namespace (e.g. `/v1/models/...`) must use full-URL mode and paste
    the complete method path. This commit adds regression coverage so a
    future attempt to mirror full-URL's host-whitelist gating into
    `normalize_gemini_base_path` will fail the test suite immediately.
    
    * chore(lint): address clippy 1.95 findings in existing modules
    
    CI upgraded to Rust 1.95 and flagged ten pre-existing warnings that
    older toolchains did not enforce. None relate to the Gemini proxy
    integration PR itself but they block CI on the feature branch, so
    clean them up here as a separate commit for easy review:
    
    collapsible_match:
    - proxy/providers/gemini_schema.rs: `"items" if value.is_object()`
      match guard instead of nested if.
    - proxy/providers/transform_responses.rs: fold
      `map_responses_stop_reason`'s `"completed"` / `"incomplete"` arms
      into match guards, relying on the existing `_ => "end_turn"` fall-
      through for non-matching guard conditions (semantics preserved).
    - services/session_usage_codex.rs: fold
      `"session_meta" if state.session_id.is_none()` guard, relying on
      the existing `_ => {}` fall-through.
    
    unnecessary_sort_by:
    - services/provider/endpoints.rs: `sort_by_key(|ep| Reverse(ep.added_at))`.
    - services/skill.rs (backup list): same Reverse idiom on `created_at`.
    - services/skill.rs (skill listings x2): `sort_by_key(|s| s.name.to_lowercase())`.
    
    useless_conversion:
    - services/skill.rs: drop the explicit `.into_iter()` on `zip`'s argument.
    
    while_let_loop:
    - services/webdav_auto_sync.rs: `while let Some(wait_for) = ...`
      instead of `loop { let Some(...) = ... else { break }; ... }`.
    
    All changes are mechanical and preserve behavior. `cargo test --lib`
    remains green (868 passed).
    
    * fix(proxy/gemini): reconcile synthesized tool-call ids with later real ids + preserve thoughtSignature
    
    Three related findings on `streaming_gemini.rs` for Gemini's cumulative
    `streamGenerateContent` stream, all centered on `merge_tool_call_snapshots`:
    
    1. (P1) Match upgraded tool-call IDs by position.
       When Gemini delivers a `functionCall` without an id on chunk 1
       (cc-switch synthesizes `gemini_synth_*`) and then upgrades it to a
       real id on chunk 2, the `Some(incoming_id)` branch only matched by
       id and missed the existing synthesized snapshot. A second entry
       would be pushed, yielding duplicate `tool_use` content blocks at
       stream end — one with the synthesized id, one with the real id —
       which could trigger duplicate tool execution and break tool_result
       correlation. Add a positional fallback: when no id match exists but
       the same-position slot holds a synthesized id, merge into it.
       `or(preserved_id)` already lets the real id win the merge.
    
    2. (P2) Preserve prior thoughtSignature when merging snapshots.
       `tool_call_snapshots[index] = tool_call` overwrote the slot
       entirely, dropping any `thoughtSignature` captured on an earlier
       chunk if the current cumulative snapshot omitted it. Since
       `build_shadow_assistant_parts` writes `thoughtSignature` into the
       shadow turn from `tool_call.thought_signature`, a dropped signature
       would cause later replay requests to Gemini to be rejected with
       invalid-signature errors. Preserve the existing signature when the
       incoming chunk does not carry one.
    
    3. (P2) Document the part-order streaming trade-off.
       All `tool_use` content blocks are emitted after the final text
       `content_block_stop`, so interleaved [text, functionCall, text,
       functionCall] parts arrive at the Anthropic client as [text(concat),
       tool_use, tool_use] — different from the non-streaming transformer,
       which preserves part order. This is intentional given the cumulative
       snapshot model and the consumers we target (claude-code-like clients
       don't depend on strict interleaving for tool execution correctness).
       Add a block comment at the flush site describing the trade-off and
       what a strict-order fix would entail, so this isn't rediscovered as
       a bug later.
    
    Regression tests:
    - upgraded_real_id_merges_into_existing_synthesized_snapshot
    - thought_signature_preserved_when_later_chunk_omits_it
    
    Test count: 868 -> 870. clippy 1.95 clean. fmt clean.
    
    * fix(proxy/gemini): prefer exact tool-call id over normalized-name fallback
    
    The shadow-turn matcher used a three-branch `||` chain (id / full name /
    normalized name). When two tools share a suffix (e.g. `server_a:search`
    and `server_b:search`), the normalized-name clause could short-circuit
    on an earlier turn whose id is actually wrong for the incoming tool_use,
    mis-routing replay state (functionCall id / thoughtSignature) for later
    tool_result resolution.
    
    Split matching into two layers: when the incoming message carries any
    tool_use ids, run id-based lookup first and return on the earliest hit.
    Only fall back to full-name / normalized-name matching when the incoming
    ids are absent or none of them resolve.
    
    Add two regressions:
    
    - shadow_replay_prefers_exact_id_match_over_normalized_name_collision
      Two shadow turns with colliding normalized names and two assistant
      messages whose ids cross the positional order; asserts each message
      replays the id-correct shadow turn (including thoughtSignature).
    
    - shadow_replay_falls_back_to_name_when_ids_absent
      Shadow turn with no id and incoming tool_use with an empty id;
      asserts the name fallback still populates the replayed part.
    
    ---------
    
    Co-authored-by: Jason <farion1231@gmail.com>
  • feat(usage): refine usage dashboard UI and date range picker (#2002)
    * feat(usage): enhance usage stats backend and query hooks
    
    * feat(usage): redesign calendar date range picker with auto-switch and simplified layout
    
    * refactor(usage): streamline dashboard layout and stats components
    
    * refactor(usage): compact request log table with merged cache/multiplier columns and centered layout
    
    * feat(i18n): add cache short labels and usage stats translations for zh/en/ja
    
    * Align usage dashboard stats with range boundaries
    
    The usage dashboard mixed second-precision detail rows with day-level rollups, which caused custom half-day ranges to overcount historical rollup data and left the request log paginator on stale pages after top-level filter changes.
    
    This change limits rollups to fully covered local days, aligns multi-day trend buckets with natural local days, and resets request log pagination when the dashboard range or app filter changes.
    
    Constraint: usage_daily_rollups stores only daily aggregates after pruning old detail rows
    Rejected: Include partial boundary rollups proportionally | historical intra-day detail is unavailable after pruning
    Rejected: Force RequestLogTable remount on range change | would discard local draft filters unnecessarily
    Confidence: high
    Scope-risk: moderate
    Reversibility: clean
    Directive: Keep summary, trends, provider stats, and model stats on the same rollup-boundary rules
    Tested: cargo test --manifest-path src-tauri/Cargo.toml usage_stats
    Tested: pnpm exec vitest run tests/components/RequestLogTable.test.tsx
    Tested: pnpm typecheck
    Not-tested: Manual UI validation in the Tauri app
    
    * Preserve full-day usage filters at minute precision
    
    The latest review surfaced two interaction bugs in the usage dashboard: rollup-backed stats undercounted end days selected via the minute-precision picker, and immediate select changes accidentally applied unsubmitted text drafts from the request log filters.
    
    This change treats 23:59 as a fully selected local end day for rollup inclusion and narrows select-side state syncing so app/status updates do not commit provider/model drafts.
    
    Constraint: The custom range picker emits minute-precision timestamps, while rollups are stored at day granularity
    Rejected: Require exact 23:59:59 end timestamps | unreachable from the current picker UI
    Rejected: Rebuild applied filters from the full draft state on select changes | silently commits unsaved text input
    Confidence: high
    Scope-risk: narrow
    Reversibility: clean
    Directive: Keep request-log text fields on explicit apply semantics even when select filters remain immediate
    Tested: cargo test --manifest-path src-tauri/Cargo.toml usage_stats
    Tested: pnpm exec vitest run tests/components/RequestLogTable.test.tsx
    Tested: pnpm typecheck
    Not-tested: Manual Tauri dashboard interaction
    
    * refactor(usage): move range presets into date picker, single-row layout
    
    - UsageDateRangePicker: add preset shortcuts (今天/1d/7d/14d/30d) inside
      popover top; clicking a preset applies immediately and closes popover
    - UsageDashboard: collapse to single row (app filters + refresh + picker);
      remove standalone preset buttons and summary stats bar
    - RequestLogTable: replace static Calendar badge with interactive
      UsageDateRangePicker via onRangeChange prop; single filter row
    
    * Keep usage pagination regression coverage aligned with the rendered UI
    
    The new regression test was asserting a non-existent pagination label and page summary text, so it failed before it could verify the real page-reset behavior. This commit switches the assertions to the numbered pagination buttons that the component actually renders and validates the reset through the query hook arguments.
    
    Constraint: RequestLogTable exposes numbered pagination buttons, not a "Next page" label or "2 / 6" summary text
    Rejected: Add synthetic pagination labels solely for the test | would couple production markup to a test-only assumption
    Confidence: high
    Scope-risk: narrow
    Reversibility: clean
    Directive: Prefer pagination assertions that follow the rendered controls or hook inputs instead of invented summary text
    Tested: pnpm vitest run tests/components/RequestLogTable.test.tsx; pnpm typecheck; pnpm test:unit
    
    * refactor(usage): clean up dead code and polish date range picker
    
    - Remove unused exports MAX_CUSTOM_USAGE_RANGE_SECONDS,
      timestampToLocalDatetime, and localDatetimeToTimestamp from
      usageRange.ts (replaced by the calendar picker)
    - Deduplicate getPresetLabel from UsageDashboard and
      UsageDateRangePicker into shared getUsageRangePresetLabel helper
    - Add aria-label, aria-current and aria-pressed to calendar day
      buttons so screen readers can disambiguate same-numbered days
      across adjacent months
    - Drop unused cacheReadShort and cacheWriteShort i18n keys (zh/en/ja);
      the request log table renders R/W prefixes inline
    - Align customRangeHint copy with the removed 30-day limit by
      dropping "up to 30 days" wording (zh/en/ja)
    
    * fix(usage): align rollup cutoff to local midnight to keep days complete
    
    `rollup_and_prune` previously used `Utc::now() - retain_days * 86400`
    as the cutoff. Because rollups are bucketed by *local* date and detail
    rows below the cutoff are pruned, an unaligned cutoff left the youngest
    rolled-up day half-rolled-up and half-pruned. Combined with the new
    `compute_rollup_date_bounds` boundary trimming (which excludes any
    rollup day not fully covered by the requested range), custom range
    queries that touch that day silently under-count summary, trend,
    provider, and model stats.
    
    Fix the invariant at the source: snap the cutoff to the next local
    midnight after `(now - retain_days)`. Every rollup row now reflects a
    complete local day, so the boundary trimmer's all-or-nothing assumption
    holds.
    
    Includes unit tests for the cutoff math (typical case + already-on-
    midnight case). DST gap is handled defensively by bumping forward by
    an hour.
    
    Addresses Codex P2 review finding on PR #2002.
    
    ---------
    
    Co-authored-by: Jason <farion1231@gmail.com>
  • feat(stream-check): refresh default models and detect model-not-found errors (#2099)
    * chore(stream-check): update default health check models to latest
    
    Replaces deprecated gpt-5.1-codex@low with gpt-5.4@low and switches
    the Gemini default from gemini-3-pro-preview to gemini-3-flash-preview
    to pick the lightest variant of the latest series for fast, low-cost
    health checks.
    
    https://claude.ai/code/session_01NGWLchcTP76rJHjiP5Ehte
    
    * feat(stream-check): detect model-not-found errors with dedicated toast
    
    Health check previously classified failures purely by HTTP status code,
    which meant deprecated/invalid models showed up as a generic "Not found
    (404)" error pointing users to check the Base URL — misleading when the
    URL is fine and only the test model is wrong (e.g. gpt-5.1-codex after
    it was retired).
    
    Backend: add detect_error_category() that inspects 4xx response bodies
    for model-not-found indicators (model_not_found, does not exist,
    invalid model, not_found_error, etc.) and returns a "modelNotFound"
    category. Thread the resolved test model through build_stream_check_result
    so the failed result carries it in model_used. Add StreamCheckResult
    .error_category field (serde-skipped when None).
    
    Frontend: useStreamCheck branches on errorCategory === "modelNotFound"
    before the HTTP-status fallback and renders a toast.error with the model
    name and a description pointing to Model Test Config. Add i18n keys
    (modelNotFound / modelNotFoundHint) for zh/en/ja.
    
    Tests: unit-test detect_error_category against real OpenAI/Anthropic
    error shapes, 5xx false-positive avoidance, and plain 401 auth errors.
    
    https://claude.ai/code/session_01NGWLchcTP76rJHjiP5Ehte
    
    * fix(stream-check): add missing error_category field in fallback
    
    The error_category field was added to StreamCheckResult in this branch
    but the fallback constructor in stream_check_all_providers was not
    updated, which broke cargo build.
    
    ---------
    
    Co-authored-by: Claude <noreply@anthropic.com>
  • fix: preserve env vars when saving Google Official Gemini provider (#2087)
    write_gemini_live() unconditionally cleared env_map for GoogleOfficial
    auth type, discarding user-configured env vars (e.g. GEMINI_MODEL).
    Remove the env_map.clear() call so the user's settings_config.env is
    written as-is, and merge identical Packycode/Generic match arms.
  • feat: classify stream check errors with color-coded toasts
    Distinguish between "provider rejects probe" (yellow warning) and
    "genuinely broken" (red error) in health check results.
    
    Backend: add AppError::HttpStatus variant to carry structured HTTP
    status codes, populate http_status on error results, classify codes
    into short labels (e.g. "Auth rejected (401)"), and truncate overly
    long response bodies.
    
    Frontend: route 401/403/400/429/5xx to toast.warning with localized
    hints explaining the error may not indicate actual unusability; route
    404/402/connection errors to toast.error. Add i18n keys for all three
    locales (zh/en/ja).
    
    Also deduplicate check_once by reusing build_stream_check_result.