From f57edfd69770144cde0fb5647f1a779099156353 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 20 Apr 2026 10:43:23 +0800 Subject: [PATCH] refactor(hermes): share provider-source marker constants and write guard After /simplify review of the P1-3 second wave, two small cleanups: - Lift the `_cc_source` / `providers_dict` magic strings out of ProviderCard into a shared helper (`isHermesReadOnlyProvider`) and named constants in hermesProviderPresets.ts. Front-end and back-end now document the same marker contract in two mirrored places instead of drifting strings. - Replace the duplicate `is_dict_only_provider` + `format!` branches at the top of `set_provider` / `remove_provider` with a single `ensure_provider_writable(config, name, verb)` guard. Future error copy tweaks only have to happen once. No behaviour change; all 52 hermes_config tests stay green. --- src-tauri/src/hermes_config.rs | 29 +++++++++++++++-------- src/components/providers/ProviderCard.tsx | 5 ++-- src/config/hermesProviderPresets.ts | 23 ++++++++++++++++++ 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index a851167d5..9565c1988 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -635,6 +635,23 @@ pub fn get_providers() -> Result, App Ok(map) } +/// Reject writes that would target a dict-only overlay entry. +/// +/// `verb` is inlined into the user-facing error so both "edit" and "remove" +/// callers can share one implementation. +fn ensure_provider_writable( + config: &serde_yaml::Value, + name: &str, + verb: &str, +) -> Result<(), AppError> { + if is_dict_only_provider(config, name) { + return Err(AppError::Config(format!( + "Provider '{name}' is managed by Hermes' 'providers:' dict — {verb} via Hermes Web UI" + ))); + } + Ok(()) +} + /// True when `name` appears in `providers:` dict but not in `custom_providers:` /// list — i.e. it is a read-only overlay CC Switch must not touch. fn is_dict_only_provider(config: &serde_yaml::Value, name: &str) -> bool { @@ -691,11 +708,7 @@ pub fn set_provider( let _guard = hermes_write_lock().lock()?; let config = read_hermes_config()?; - if is_dict_only_provider(&config, name) { - return Err(AppError::Config(format!( - "Provider '{name}' is managed by Hermes' 'providers:' dict — edit via Hermes Web UI" - ))); - } + ensure_provider_writable(&config, name, "edit")?; let mut providers: Vec = config .get("custom_providers") .and_then(|v| v.as_sequence()) @@ -768,11 +781,7 @@ pub fn remove_provider(name: &str) -> Result { let _guard = hermes_write_lock().lock()?; let config = read_hermes_config()?; - if is_dict_only_provider(&config, name) { - return Err(AppError::Config(format!( - "Provider '{name}' is managed by Hermes' 'providers:' dict — remove via Hermes Web UI" - ))); - } + ensure_provider_writable(&config, name, "remove")?; let mut providers: Vec = config .get("custom_providers") diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index a54d3da15..049672715 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -15,6 +15,7 @@ import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter"; import CopilotQuotaFooter from "@/components/CopilotQuotaFooter"; import CodexOauthQuotaFooter from "@/components/CodexOauthQuotaFooter"; import { PROVIDER_TYPES } from "@/config/constants"; +import { isHermesReadOnlyProvider } from "@/config/hermesProviderPresets"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge"; import { extractCodexBaseUrl } from "@/utils/providerConfigUtils"; @@ -183,9 +184,7 @@ export function ProviderCard({ // Hermes v12+ overlay entries live under the `providers:` dict and are // read-only here — writes have to go through Hermes Web UI. const isHermesReadOnly = - appId === "hermes" && - (provider.settingsConfig as Record)?._cc_source === - "providers_dict"; + appId === "hermes" && isHermesReadOnlyProvider(provider.settingsConfig); const isCodexOauth = provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH; diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts index 59d048d9d..bcc41e523 100644 --- a/src/config/hermesProviderPresets.ts +++ b/src/config/hermesProviderPresets.ts @@ -5,6 +5,29 @@ import type { ProviderCategory } from "../types"; import type { PresetTheme, TemplateValueConfig } from "./claudeProviderPresets"; +/** + * Marker field and source values that `hermes_config.rs::get_providers` + * injects onto each settings payload. Kept in sync with the Rust constants + * `PROVIDER_SOURCE_FIELD` / `PROVIDER_SOURCE_CUSTOM_LIST` / `PROVIDER_SOURCE_DICT`. + */ +export const HERMES_PROVIDER_SOURCE_FIELD = "_cc_source"; +export const HERMES_PROVIDER_SOURCE_CUSTOM_LIST = "custom_providers"; +export const HERMES_PROVIDER_SOURCE_DICT = "providers_dict"; + +/** + * True when the provider was sourced from Hermes' v12+ `providers:` dict — + * CC Switch renders those read-only and routes edits to Hermes Web UI. + */ +export function isHermesReadOnlyProvider(settingsConfig: unknown): boolean { + if (!settingsConfig || typeof settingsConfig !== "object") { + return false; + } + const marker = (settingsConfig as Record)[ + HERMES_PROVIDER_SOURCE_FIELD + ]; + return marker === HERMES_PROVIDER_SOURCE_DICT; +} + /** * A model entry under a Hermes custom_provider. *