Commit Graph

1695 Commits

  • refactor(proxy): extract handle_rectifier_retry_failure helper
    The signature (RECT-003) and budget (RECT-012) rectifier branches each
    carried ~50 lines of identical "provider error -> record + continue /
    client error -> release permit + return" handling. The only piece that
    varied between them was a log label ("整流" vs "budget 整流").
    
    Move the shared logic into RequestForwarder::handle_rectifier_retry_failure
    that returns Option<ForwardError> — None means "continue to the next
    provider", Some(err) means "terminal failure, return to the client".
    Each call site shrinks from ~50 lines to ~17, drops one level of
    indentation, and the two branches now provably cannot drift apart.
    
    forwarder.rs nets ~40 lines smaller.
  • refactor(proxy): share auth_header_value helper across provider adapters
    claude.rs and gemini.rs each defined an identical `hv` closure that wrapped
    `HeaderValue::from_str` into a ProxyError::AuthError result, and codex.rs
    spelled the same conversion out inline. /simplify reviewers flagged this
    as drift-prone copy-paste.
    
    Move the conversion into a single `pub fn auth_header_value` in
    providers/adapter.rs and have the three adapters import it locally. Same
    error wording everywhere, one place to update if HeaderValue semantics
    ever change.
  • fix(proxy-ui): accept IPv6 listen addresses in ProxyPanel validation
    The backend already understands `::` -> `::1` and wraps IPv6 literals
    in brackets (services/proxy.rs), but the panel's save-time validator
    only accepted localhost, 0.0.0.0, and IPv4 dotted-quads. Users who
    wanted to listen on an IPv6 loopback had to bypass the UI and edit
    config directly.
    
    Add an isValidIpv6 helper that requires at least one ':' and round-trips
    through `new URL('http://[<addr>]/')` so the platform's built-in IPv6
    parser does the heavy lifting (covers compressed `::`, full 8-group
    form, zone IDs). Update the invalidAddress copy in zh / en / ja so the
    error message reflects the new accepted set.
  • feat(proxy): forward client HTTP method instead of hard-coding POST
    The forwarder used to call client.post(&url) / http::Method::POST in
    both the reqwest and hyper paths, and the Gemini route table only
    registered POST /v1beta/*. As a result anything the Gemini SDK / CLI
    sent as GET (models list, models/<id> info) hit a 404 at the router
    and bypassed the local proxy's stats, rectifiers, and failover.
    
    Thread the request method end-to-end:
    
    - ProviderAdapter forwarder API now takes the http::Method by reference
      per attempt and dispatches client.request(method, &url) for reqwest
      and method.clone() for the hyper raw path.
    - All five callers in handlers.rs (handle_messages_for_app for Claude /
      Claude Desktop, handle_chat_completions, handle_responses,
      handle_responses_compact, handle_gemini) pull the method out of the
      incoming axum::extract::Request and pass it on.
    - handle_gemini tolerates an empty body (GET endpoints have none) and
      the forwarder skips serializing / sending a body for GET / HEAD —
      attaching JSON to a GET makes Gemini reject the request.
    - server.rs swaps the Gemini routes to any(handle_gemini) so the same
      handler handles GET / POST / PUT / DELETE, and adds /gemini/v1/*
      for the GA path version.
  • fix(proxy): move client-request counters out of per-attempt loop
    Three statistics-shape issues fixed together so the dashboard reflects
    client requests, not provider attempts:
    
    1. active_connections never moved off zero — the field had no caller in
       the entire crate. Wrap forward_with_retry into a thin entry point
       that saturating_add(1) on enter and saturating_sub(1) on exit; every
       inner return path is covered automatically.
    
    2. total_requests counted attempts, not requests. A single client call
       that failed over P1 -> P2 -> success was recorded as
       total=2 / success=1 -> 50% success rate. Move the increment and the
       last_request_at refresh into the wrapper so they fire once per
       client request regardless of how many providers were tried.
    
    3. current_provider / current_provider_id stay inside the inner loop
       because they are intentionally per-attempt ("what am I trying right
       now?") — moving them would break the live-failover indicator.
    
    Refactor: split forward_with_retry into a public wrapper + private
    forward_with_retry_inner. Every existing `return Err(...)` inside inner
    remains correct because the wrapper always runs the decrement on its
    return.
  • fix(proxy): wire AppProxyConfig.max_retries into request forwarder
    The UI has exposed "请求失败时的重试次数 (0-10, default 3)" since the
    auto-failover panel was added, but the value was silently dropped —
    RequestForwarder never received it and the per-provider loop walked the
    whole list regardless. From the user's perspective the setting was
    inert.
    
    Thread AppProxyConfig.max_retries through create_forwarder into
    RequestForwarder, derive max_attempts = max_retries + 1 (so max_retries=0
    matches the UI copy "0 retries" = single attempt), and break the loop
    once attempts hit the cap. The check is placed before the circuit
    breaker allow-permit so an over-cap iteration does not waste a HalfOpen
    probe slot.
    
    When auto-failover is disabled we also force max_retries to 0, mirroring
    how timeouts already bypass in that mode — "no failover" should mean
    "one provider, one try", not "limited retries against the same list".
  • fix(proxy): map Anthropic tool_choice to OpenAI Chat nested form
    The Chat-Completions transformer used to forward tool_choice verbatim,
    but the two APIs disagree on shape:
    
      Anthropic   "any" | {"type":"tool","name":"X"}
      OpenAI Chat "required" | {"type":"function","function":{"name":"X"}}
    
    Pass-through made the upstream return 400 for any tool-forcing client
    (Claude Code, Copilot, etc.). The Responses-API transformer already had
    the equivalent map_tool_choice_to_responses helper; this commit adds a
    sibling map_tool_choice_to_chat with the chat-specific *nested* function
    selector and five regression tests covering string / object × any /
    auto / none / tool.
    
    The two helpers are intentionally not merged: the difference between
    flat and nested function selectors is exactly what the original bug
    was, so keeping them as separate self-documenting functions reduces the
    chance of the same regression returning.
  • fix(proxy): refine failover decisions in forwarder
    Two related changes to make per-provider failover behave correctly.
    
    1. Bucket UpstreamError by status code in categorize_proxy_error.
    
       The old "every UpstreamError is Retryable" rule meant a malformed
       client request (400 / 422) would be replayed against every provider
       in the queue: errors amplified N-fold, the circuit breaker accrued
       unwarranted failure counts, and quota was burned. Now
       400 / 405 / 406 / 413 / 414 / 415 / 422 / 501 are NonRetryable since
       the request itself is wrong and no provider will accept it.
       401 / 403 / 404 / 408 / 409 / 429 / 451 and all 5xx remain Retryable
       because the next provider may carry a different key, quota, region,
       or model mapping.
    
    2. Make the rectifier-retry path participate in failover.
    
       Both the signature (RECT-003) and budget (RECT-012) rectifier branches
       used to "return Err(...)" after the retry failed, short-circuiting the
       per-provider loop. A provider-side failure (5xx / Timeout /
       ForwardFailed) now records the circuit breaker, accumulates into
       last_error / last_provider, and "continue"s to the next provider —
       matching the normal Retryable arm. Client-side failures still return
       immediately since a different provider cannot fix a malformed payload.
  • 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.
  • fix(proxy): extract Gemini request model from URI path correctly
    split('/') strips the slashes, so find(|s| s.starts_with("models/")) never
    matched any segment and request_model fell through to "unknown" for every
    Gemini call, poisoning usage records, per-request billing, and logs.
    
    Match the literal "models" segment and take the next one, stripping any
    :action suffix and query string. The extraction is now a pub(crate) free
    function so it can be unit-tested directly; seven regression tests cover
    action suffixes, dotted versions, the /gemini/ proxy prefix, query
    strings, the bare list endpoint, and missing-segment paths.
  • fix(proxy): return Result from get_auth_headers to avoid panic on bad credentials
    User-pasted API keys can contain control chars or CR/LF that make
    HeaderValue::from_str return Err; the previous unwrap inside every
    adapter turned such input into a process-wide panic instead of a request
    error. The trait now returns Result<_, ProxyError>; Claude/Codex/Gemini
    impls propagate ProxyError::AuthError so the client sees a 401 with the
    underlying parse error instead of a crash. Adds a regression test that
    pastes a CRLF-containing key and asserts AuthError.
  • 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(claude-code): role-based model mapping with display names and 1M flag
    - Replace the four flat env inputs with a Sonnet/Opus/Haiku role table.
      Each row exposes ANTHROPIC_DEFAULT_*_MODEL plus a new display name
      field ANTHROPIC_DEFAULT_*_MODEL_NAME, and Sonnet/Opus gain a
      "Declare 1M" checkbox that toggles the [1M] suffix.
    - Strip the [1M] context-capability marker before forwarding non-Copilot
      requests upstream. Copilot keeps its existing [1m]->-1m normalization.
    - Claude Desktop import now consumes ANTHROPIC_DEFAULT_*_MODEL_NAME as
      label_override, closing the Claude Code -> Claude Desktop displayName
      pipeline; add_route's merge logic is shared between hashmap branches.
    - Unify the [1M] marker as ONE_M_CONTEXT_MARKER across
      claude_desktop_config and proxy::model_mapper; rename the strip
      helper to strip_one_m_suffix_for_upstream.
    - Collapse useModelState's seven duplicated useState initializers and
      the useEffect parse block into a single parseModelsFromConfig call.
    - Add tests/hooks/useModelState.test.tsx and a Claude Desktop import
      test covering Kimi K2 -> label_override. i18n (en/ja/zh) updated.
  • refactor(claude-desktop): lock route IDs to sonnet/opus/haiku roles
    Adapt to Claude Desktop 1.6259.1+ fail-all validation which only
    accepts claude-(sonnet|opus|haiku)-* route IDs. Branded model names
    (DeepSeek, Kimi, GLM, etc.) now live in a new labelOverride field
    instead of being embedded in route IDs.
    
    - Backend auto-repairs legacy unsafe routes to the next free
      sonnet/opus/haiku slot instead of erroring
    - Frontend swaps the free-form route input for a role dropdown plus
      menu display name field
    - Add CLAUDE_DESKTOP_ROLE_ROUTE_IDS as the single source of truth
      for role-to-route mapping; presets and form both consume it
    - Drop the dead displayName alias on ClaudeDesktopModelRoute and the
      ineffective /v1/models display_name injection (UI ignores it)
    - Update i18n (en/ja/zh) and form focus test for the new fields
  • 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.
  • feat(claude-desktop): rework Claude Code import flow
    - Derive route keys from the upstream model name (pass-through style)
      instead of fixed Claude aliases, and translate the legacy [1M] suffix
      into the supports1m field at the import boundary. Three Claude aliases
      mapped to the same upstream now collapse to a single route (e.g.
      MiniMax-M2 across SONNET/OPUS/HAIKU env produces one
      claude-MiniMax-M2 -> MiniMax-M2 row), with [1M] OR-aggregated.
    - Add an import-time safety net that rebuilds claude-desktop-official
      when missing, so users who deleted it can recover via the normal
      import button without losing customizations on other providers.
    - Hide API key and endpoint URL inputs in the official provider edit
      form to mirror Claude Code's behavior and prevent user confusion.
    - Reword the empty-state import button label for clarity.
  • refactor(claude-desktop): replace [1M] suffix with supports1m field
    inferenceModels entries now emit {name, supports1m: true} objects when
    1M is enabled (plain strings otherwise), instead of appending a " [1M]"
    suffix to model IDs. Route IDs and upstream model IDs are stored
    verbatim; the suffix is rejected on input rather than silently stripped,
    and proxy request mapping now requires an exact route_id match.
  • fix(ui): center Monitor badge icon in app switcher
    The Monitor glyph's visual weight skews upward (screen rect dominates
    while the stand is two thin lines), making it appear off-center inside
    the 11px Claude Desktop badge. Add a per-badge offsetY config and
    apply translateY(0.5px) to compensate.
  • - 修复 Claude Desktop 模型输入框失焦
    - 为动态模型行添加稳定 rowId,避免编辑模型 ID 时重挂载
    
    - 增加模型映射和直连模型列表焦点保持回归测试
  • - 恢复 Claude Desktop 共享功能入口
    - 将 Claude Desktop 的 Prompts、Skills、Sessions 映射到 Claude Code 配置
    
    - 恢复 Claude Desktop 顶部功能按钮组
    
    - 继续复用统一 MCP 面板入口
  • - 支持 Claude Desktop 使用 Copilot/Codex OAuth 供应商
    - 放开本地路由托管 OAuth 供应商校验,允许动态 Token
    - 新增 Claude Desktop Copilot/Codex 预设与账号选择
    - 添加 OAuth proxy 回归测试
  • refactor(claude-desktop): drop displayName from model route schema
    Claude Desktop's new model menu reads model IDs directly and ignores the
    display_name field, so a separate displayName slot added UI noise without
    any product value. Collapse the routeId / model / displayName tuple down
    to routeId / model, and let the route ID carry the user-visible name
    through a non-editable claude- prefix rendered next to the input.
    
    Drop display_name from ClaudeDesktopModelRoute, ClaudeDesktopDefaultRoute,
    and ResolvedModelRoute on the Rust side plus the matching TS interfaces,
    stop emitting it in /v1/models responses, derive route IDs from upstream
    model IDs when picked via the model dropdown, and update zh/en/ja copy to
    describe the new two-field layout.
  • fix(ui): hide empty toolbar capsule when Claude Desktop is active
    Claude Desktop disables Skills, Prompts, Sessions, and MCP, which left
    the secondary toolbar capsule next to the app switcher completely empty
    but still rendered as a grey rounded pill. Wrap the capsule in an
    activeApp !== "claude-desktop" guard so it disappears entirely, and
    drop the two inner guards that this outer check makes redundant.
  • feat(ui): use "Claude Code" label in app visibility settings
    The app visibility section in Settings showed "Claude" for the first
    entry, identical to "Claude Desktop" at a glance. Add a dedicated
    i18n key apps.claudeCode and point the settings panel at it, while
    leaving apps.claude untouched so other panels (MCP, Skills, Usage,
    etc.) keep their shorter "Claude" label.
  • feat(ui): distinguish Claude Code vs Claude Desktop in app switcher
    The two Claude entries shared the same orange logo, making them hard to
    tell apart at a glance. Rename the first entry to "Claude Code" and
    overlay a Terminal badge on its logo; overlay a Monitor badge on the
    Claude Desktop logo. Changes are scoped to AppSwitcher only; other
    panels (MCP, Skills, Usage, etc.) continue to show "Claude".
  • chore(release): surface ccswitch.io in release notes template
    Each tagged release now leads with the canonical official website
    in three languages, ensuring every Release page (which is indexed
    independently by Google) becomes a dofollow backlink to ccswitch.io.
  • docs: add Hermes Agent to README subtitles (en/zh/ja)
    Aligns README subtitles with the GitHub repo description that
    now lists Hermes Agent as a managed application.
  • chore(brand): surface ccswitch.io as the sole official website
    Add an "Only Official Website" header to the three READMEs, an
    About panel button, and a tray menu entry — all pointing to
    ccswitch.io. Consolidates brand and SEO signals on the canonical
    domain across docs, GUI, and system tray.
  • perf(proxy): trim per-request hot-path work and db wait
    - Guard debug body serialization with `log::log_enabled!`; previously
      serialized the filtered body to a throwaway String on every forward,
      even with debug logging off.
    - Skip SSE parse + UTF-8 buffer loop when no usage collector and debug
      is off; the per-chunk `serde_json::from_str::<Value>` ran even in
      pure passthrough mode.
    - Add cheap per-app SSE event pre-filter (string `contains`) so usage
      collectors only parse events that could contain usage (e.g. Claude
      `message_start` / `message_delta`).
    - Skip non-streaming response body JSON parse when usage logging is
      disabled.
    - Move `ProviderRouter::record_result` off the success response path
      via `tokio::spawn` for non-HalfOpen state; that call internally does
      `get_proxy_config_for_app` + `update_provider_health`, two SQLite
      ops that previously blocked TTFB.
    
    Also: dedupe `usage_logging_enabled` (was duplicated in handlers.rs)
    and merge `SseUsageCollector::{new, new_filtered}` into a single
    constructor that takes `Option<StreamUsageEventFilter>`.
  • fix(proxy): improve cache hit rate for Codex/Responses requests
    prompt_cache_key was falling back to provider.id when the client did not
    supply a session, which collapsed every conversation onto a single key
    and defeated upstream prefix caching. Only emit the key when a real
    client-provided session/thread identity is available; otherwise let the
    upstream use its default matching behaviour.
    
    Additional fixes that affect cache stability:
    - Canonicalise (sort) JSON keys in outgoing request bodies and in
      tool_call arguments / tool_result content so semantically identical
      requests produce identical byte sequences for upstream prefix caches.
    - Exempt JSON Schema property maps (properties, patternProperties,
      definitions, \$defs) from the underscore-prefix filter so user-defined
      schema keys like _id and _meta survive.
    - Add a [CacheTrace] debug log with stable hashes for instructions,
      tools, input and include to help diagnose cache misses.
    - Thread session_id into the usage logger for request correlation.
  • fix(proxy): drop empty pages from Read tool input (#2472)
    * fix(proxy): drop empty pages from Read tool input
    
    * fix(proxy): preserve Read args across duplicate tool starts
  • - fix(ci): restore frontend formatting and Linux clippy
    - Format Claude Desktop provider presets with Prettier
    
    - Gate platform-specific Claude Desktop path helpers behind cfg
  • chore(icons): add ClaudeCN and RunAPI raster icons
    Resized to 512x512 to match the existing extracted-icons size convention
    (hermes.png / lemondata.png). Source uploads were oversized (7.3 MB
    8635x8635 for ClaudeCN; 800x800 for RunAPI) and would have bloated the
    bundle if imported as-is.
    
    Note: not yet wired into index.ts / metadata.ts; register there when
    they need to surface in the app UI.
  • docs: add RunAPI sponsor entry to README (en/zh/ja)
    Insert sponsor row in all three README locales linking to runapi.co.
    EN/JA copy adapted from the Chinese original. Banner center-cropped to
    the project standard 1920x798 aspect ratio (preserves the logo and
    slogan, trims the decorative top/bottom padding) and saved as JPEG to
    keep file size reasonable (1.3 MB PNG -> 206 KB JPEG).
  • docs: add ClaudeCN sponsor entry to README (en/zh/ja)
    Insert sponsor row in all three README locales linking to claudecn.top.
    EN/JA copy adapted from the Chinese original. Banner normalized to the
    project standard sponsor image spec (1920x798 RGB on white background)
    and recompressed; alt text unified to "ClaudeCN" across all three files.
  • docs: use Volcengine logo for Chinese README sponsor entry
    Replace byteplus.png with localized huoshan.png (Volcengine/火山引擎)
    in README_ZH.md so Chinese readers see the regional brand. EN/JA
    README continue to use the BytePlus logo and link to byteplus.com.
    
    The new logo is normalized to the project's standard sponsor image
    spec: 1920x798 RGB on a white background.
  • docs: add BytePlus sponsor entry to README (en/zh/ja)
    Insert sponsor row in all three README locales. EN/JA point to
    byteplus.com/modelark; ZH points to volcengine.com/agentplan
    (Volcengine being the China-region counterpart). Logo normalized
    to the project-standard 1920x798 RGB white background and
    recompressed (432K -> 84K).
  • feat(claude-desktop): add 44 provider presets translated from Claude Code
    - New src/config/claudeDesktopProviderPresets.ts with the Claude Code
      preset list re-shaped into Desktop's three-segment route format
      (routeId / upstreamModel / displayName); excludes OAuth providers,
      AWS Bedrock (no SigV4 support) and KAT-Coder (placeholder URL).
    - Non-Claude upstream presets show upstream model id as displayName
      (e.g. deepseek-v4-pro) so the Desktop model list reflects what is
      actually being requested. OpenRouter/TheRouter/PIPELLM keep
      Sonnet/Opus/Haiku since their upstream really is Anthropic Claude.
    - Wire ProviderPresetSelector into ClaudeDesktopProviderForm so
      selecting a preset back-fills baseUrl, mode, routes and apiFormat.
    - Drop the hard-coded ANTHROPIC_AUTH_TOKEN write in handleSubmit so
      ANTHROPIC_API_KEY presets (LemonData / AiHubMix / Gemini Native)
      save under the correct env key, and clear the opposite key on switch.
    - Hide the universal-providers tab for claude-desktop because its
      meta-driven routing has no analogue in the universal flat-env shape.
    - Add apps."claude-desktop" i18n key (zh/en/ja) so the dialog tab
      label resolves instead of showing the literal key.
  • chore(backend): satisfy cargo fmt and clippy --all-targets
    - Apply rustfmt diffs in claude_desktop_config.rs
    - Allow needless_return on current_platform_paths (cfg-mirrored arms)
    - Allow too_many_arguments on RequestForwarder::forward
    - Replace `let mut + reassign` with struct literals in tests
      (settings, backup, provider, response_processor)
    - Use Path::new instead of PathBuf::from to fix cmp_owned in misc tests
    - Replace 3.14 with 3.5 in config test to avoid approx_constant lint
  • docs: update sponsor logos and listings
    - Add ClaudeAPI as new sponsor (all 3 languages)
    - Remove ChefShop sponsor entry (all 3 languages)
    - Convert shengsuanyun logo from SVG to PNG
    - Standardize partner logos to 1920x798 canvas
    - Fix ClaudeAPI alt text typo and spacing
    - Sync sponsor order across zh/en/ja READMEs
  • refactor(claude-desktop): show badge only for providers requiring routing
    Direct-mode providers no longer display a badge since routing is
    optional for them. Proxy-mode providers now show "需要路由" / "Requires
    routing" to clarify that local routing must be active.
  • fix(claude-desktop): match proxy model route without [1M] suffix
    Claude Desktop strips the [1M] suffix from model IDs when sending
    requests, causing route lookup to fail with "model route is not
    configured". Fall back to base-name comparison when exact match misses.
  • refactor(claude-desktop): simplify model mapping UX
    - Remove "Import from Claude" button from main provider list (keep in empty state)
    - Remove "Desktop model" column from proxy mode mapping table; route names are now auto-generated
    - Rename "upstream model" label to "requested model" and equalize column widths
    - Rewrite model mapping hint and toggle description for end-user clarity
    - Update validation messages and remove dead i18n keys (routeModelLabel)
  • refactor(claude-desktop): align provider form UI with Claude Code
    - Rename field labels: "Gateway Base URL" → "API Endpoint", "Bearer Token" → "API Key"
    - Change layout from 2-column grid to vertical sections matching Claude Code
    - Reuse shared EndpointField component with format-aware amber hint box
    - Replace native <datalist> with vendor-grouped ModelDropdown (OpenCode pattern)
    - Move Fetch/Add buttons to section headers with compact sm styling
    - Extract ModelDropdown to shared module, deduplicate from OpenCodeFormFields
    - Extract renderActionButtons helper to eliminate proxy/direct button duplication
    - Remove dead i18n keys (gatewayBaseUrl, bearerToken) from all 3 locales
  • fix(claude-desktop): remove proxy-stopped status alert
    The route toggle in the top-right corner already communicates proxy
    state; the extra warning banner was redundant and inconsistent with
    other managed apps.
  • 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.