From 0be75668ccc2da2dbac1815fb906d755589c3f44 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 20 Apr 2026 22:13:54 +0800 Subject: [PATCH] feat(hermes): align provider schema with Hermes Agent 0.10.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes 0.10.0 tightened custom_providers validation (commit 2cdae233): invalid base_urls are rejected, unknown fields produce warnings, and new fields (rate_limit_delay, bedrock_converse, key_env) landed. - Add bedrock_converse to the api_mode selector (and i18n labels) - Expose rate_limit_delay in a provider-level advanced panel - Validate base_url client-side (URL shape, template-token friendly) - Drop per-model max_tokens — not in _VALID_CUSTOM_PROVIDER_FIELDS - Round-trip test asserts set_provider preserves rate_limit_delay / key_env / any unknown forward-compat field --- src-tauri/src/lib.rs | 2 +- src-tauri/tests/hermes_roundtrip.rs | 124 +++++++++ .../providers/forms/HermesFormFields.tsx | 246 +++++++++++++----- .../providers/forms/ProviderForm.tsx | 4 + .../forms/hooks/useHermesFormState.ts | 32 +++ src/config/hermesProviderPresets.ts | 26 +- src/i18n/locales/en.json | 10 +- src/i18n/locales/ja.json | 10 +- src/i18n/locales/zh.json | 10 +- 9 files changed, 375 insertions(+), 89 deletions(-) create mode 100644 src-tauri/tests/hermes_roundtrip.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b14afee60..ebaa2f8c1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,7 +11,7 @@ mod deeplink; mod error; mod gemini_config; mod gemini_mcp; -mod hermes_config; +pub mod hermes_config; mod init_status; mod lightweight; #[cfg(target_os = "linux")] diff --git a/src-tauri/tests/hermes_roundtrip.rs b/src-tauri/tests/hermes_roundtrip.rs new file mode 100644 index 000000000..9d4fe1d05 --- /dev/null +++ b/src-tauri/tests/hermes_roundtrip.rs @@ -0,0 +1,124 @@ +mod support; + +use cc_switch_lib::{hermes_config, update_settings, AppSettings}; + +/// 读取并回写 Hermes provider 时,Hermes v12+ 新增或未来才会出现的字段 +/// (例如 `rate_limit_delay`、`key_env`)必须透传,不能因为 UI 不感知就静默丢弃。 +/// 否则用户在 Hermes Web UI 配置的高级字段会在 CC Switch 编辑后消失。 +fn with_temp_hermes_dir(f: F) { + let guard = support::test_mutex().lock().expect("test mutex poisoned"); + let home = support::ensure_test_home(); + support::reset_test_fs(); + + let hermes_dir = home.join(".hermes-roundtrip"); + let _ = std::fs::remove_dir_all(&hermes_dir); + std::fs::create_dir_all(&hermes_dir).expect("create temp hermes dir"); + + update_settings(AppSettings { + hermes_config_dir: Some(hermes_dir.to_string_lossy().into_owned()), + ..AppSettings::default() + }) + .expect("set hermes_config_dir override"); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&hermes_dir))); + + // Always restore settings and drop fixture dir, even on test failure. + let _ = update_settings(AppSettings::default()); + let _ = std::fs::remove_dir_all(&hermes_dir); + drop(guard); + + if let Err(err) = result { + std::panic::resume_unwind(err); + } +} + +#[test] +fn set_provider_preserves_unknown_and_future_fields() { + with_temp_hermes_dir(|dir| { + let yaml = r#"custom_providers: + - name: myhost + base_url: https://api.example.com/v1 + api_key: sk-old + api_mode: chat_completions + rate_limit_delay: 0.5 + key_env: MY_API_KEY + foo_bar: keep-me-around + models: + gpt-4: + context_length: 8192 +"#; + let config_path = dir.join("config.yaml"); + std::fs::write(&config_path, yaml).expect("seed config.yaml"); + + // Simulate the UI sending back only the fields it knows about. + let patch = serde_json::json!({ + "name": "myhost", + "base_url": "https://api.example.com/v1", + "api_key": "sk-new", + "api_mode": "chat_completions", + "models": [ + { "id": "gpt-4", "context_length": 8192 } + ] + }); + + hermes_config::set_provider("myhost", patch).expect("set_provider"); + + let written = std::fs::read_to_string(&config_path).expect("read written config"); + + assert!( + written.contains("rate_limit_delay"), + "rate_limit_delay stripped:\n{written}" + ); + assert!( + written.contains("key_env"), + "key_env key stripped:\n{written}" + ); + assert!( + written.contains("MY_API_KEY"), + "key_env value stripped:\n{written}" + ); + assert!( + written.contains("foo_bar"), + "unknown forward-compat field stripped:\n{written}" + ); + assert!( + written.contains("sk-new"), + "api_key was not updated to sk-new:\n{written}" + ); + assert!( + !written.contains("sk-old"), + "old api_key still present:\n{written}" + ); + }); +} + +#[test] +fn get_providers_surfaces_rate_limit_delay_and_key_env() { + with_temp_hermes_dir(|dir| { + let yaml = r#"custom_providers: + - name: myhost + base_url: https://api.example.com/v1 + api_key: sk-xxx + api_mode: chat_completions + rate_limit_delay: 2.5 + key_env: FOO_KEY + models: + m1: {} +"#; + std::fs::write(dir.join("config.yaml"), yaml).expect("seed config.yaml"); + + let providers = hermes_config::get_providers().expect("get_providers"); + let entry = providers.get("myhost").expect("myhost missing"); + + assert_eq!( + entry.get("rate_limit_delay").and_then(|v| v.as_f64()), + Some(2.5), + "rate_limit_delay not surfaced to DAO payload" + ); + assert_eq!( + entry.get("key_env").and_then(|v| v.as_str()), + Some("FOO_KEY"), + "key_env not surfaced to DAO payload" + ); + }); +} diff --git a/src/components/providers/forms/HermesFormFields.tsx b/src/components/providers/forms/HermesFormFields.tsx index 598a2e0d7..a33178f67 100644 --- a/src/components/providers/forms/HermesFormFields.tsx +++ b/src/components/providers/forms/HermesFormFields.tsx @@ -1,5 +1,12 @@ import { useTranslation } from "react-i18next"; -import { useState, useRef, useCallback, useMemo } from "react"; +import { + useState, + useRef, + useCallback, + useMemo, + useEffect, + type ReactNode, +} from "react"; import { FormLabel } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -59,6 +66,77 @@ interface HermesFormFieldsProps { onApiModeChange: (mode: HermesApiMode) => void; models: HermesModel[]; onModelsChange: (models: HermesModel[]) => void; + rateLimitDelay: number | undefined; + onRateLimitDelayChange: (delay: number | undefined) => void; +} + +type BaseUrlErrorCode = "empty" | "invalid" | "scheme"; + +const BASE_URL_ERROR_I18N_KEY: Record = { + empty: "hermes.form.baseUrlRequired", + scheme: "hermes.form.baseUrlScheme", + invalid: "hermes.form.baseUrlInvalid", +}; + +const TEMPLATE_TOKEN_RE = /\$\{[^}]+\}/g; + +/** + * Hermes 0.10.0+ rejects `base_url` entries that don't parse as proper URLs + * (commit 2cdae233). Validate client-side so the error surfaces before the + * request ever reaches Hermes' startup. + */ +function validateBaseUrl(raw: string): BaseUrlErrorCode | null { + const trimmed = raw.trim(); + if (!trimmed) return "empty"; + // Presets like KAT-Coder embed `${VAR}` tokens — swap them before URL parse. + const candidate = trimmed.replace(TEMPLATE_TOKEN_RE, "placeholder"); + let u: URL; + try { + u = new URL(candidate); + } catch { + return "invalid"; + } + if (!u.protocol.startsWith("http")) return "scheme"; + if (!u.hostname) return "invalid"; + return null; +} + +interface AdvancedSectionProps { + open: boolean; + onOpenChange: (next: boolean) => void; + labelKey: string; + children: ReactNode; +} + +function AdvancedSection({ + open, + onOpenChange, + labelKey, + children, +}: AdvancedSectionProps) { + const { t } = useTranslation(); + return ( + + + + + + {children} + + + ); } export function HermesFormFields({ @@ -75,6 +153,8 @@ export function HermesFormFields({ onApiModeChange, models, onModelsChange, + rateLimitDelay, + onRateLimitDelayChange, }: HermesFormFieldsProps) { const { t } = useTranslation(); const [expandedModels, setExpandedModels] = useState>( @@ -82,6 +162,24 @@ export function HermesFormFields({ ); const [fetchedModels, setFetchedModels] = useState([]); const [isFetchingModels, setIsFetchingModels] = useState(false); + const [baseUrlTouched, setBaseUrlTouched] = useState(false); + const [providerAdvancedOpen, setProviderAdvancedOpen] = useState( + rateLimitDelay !== undefined, + ); + + // Auto-expand when a preset switch brings in a value so the user sees it; + // don't force-collapse on clear, to avoid yanking the panel shut mid-edit. + useEffect(() => { + if (rateLimitDelay !== undefined) { + setProviderAdvancedOpen(true); + } + }, [rateLimitDelay]); + + const baseUrlErrorCode = useMemo(() => validateBaseUrl(baseUrl), [baseUrl]); + const showBaseUrlError = baseUrlTouched && baseUrlErrorCode !== null; + const baseUrlErrorMessage = baseUrlErrorCode + ? t(BASE_URL_ERROR_I18N_KEY[baseUrlErrorCode]) + : ""; // Stable list keys: a manual ref rather than UUID-in-state so adding/removing // rows doesn't re-mount unrelated inputs (would drop focus mid-typing). @@ -120,7 +218,7 @@ export function HermesFormFields({ modelKeysRef.current.push(crypto.randomUUID()); onModelsChange([ ...models, - { id: "", name: "", context_length: undefined, max_tokens: undefined }, + { id: "", name: "", context_length: undefined }, ]); }; @@ -209,13 +307,24 @@ export function HermesFormFields({ id="hermes-baseurl" value={baseUrl} onChange={(e) => onBaseUrlChange(e.target.value)} + onBlur={() => setBaseUrlTouched(true)} placeholder="https://api.example.com/v1" + aria-invalid={showBaseUrlError} + className={ + showBaseUrlError + ? "border-destructive focus-visible:ring-destructive" + : undefined + } /> -

- {t("hermes.form.baseUrlHint", { - defaultValue: "供应商的 API 端点地址。", - })} -

+ {showBaseUrlError ? ( +

{baseUrlErrorMessage}

+ ) : ( +

+ {t("hermes.form.baseUrlHint", { + defaultValue: "供应商的 API 端点地址。", + })} +

+ )} - toggleModelAdvanced(index)} + labelKey="hermes.form.advancedOptions" > - - - - -
-
- - - handleModelChange( - index, - "context_length", - e.target.value - ? parseInt(e.target.value) - : undefined, - ) - } - placeholder="200000" - /> -
-
- - - handleModelChange( - index, - "max_tokens", - e.target.value - ? parseInt(e.target.value) - : undefined, - ) - } - placeholder="32000" - /> -
-
-
-
+ + + handleModelChange( + index, + "context_length", + e.target.value ? parseInt(e.target.value) : undefined, + ) + } + placeholder="200000" + /> + + ))} @@ -457,6 +523,44 @@ export function HermesFormFields({ })}

+ + +
+ + { + const v = e.target.value; + if (v === "") { + onRateLimitDelayChange(undefined); + return; + } + const n = parseFloat(v); + onRateLimitDelayChange( + Number.isFinite(n) && n >= 0 ? n : undefined, + ); + }} + placeholder="0.5" + /> +

+ {t("hermes.form.rateLimitDelayHint", { + defaultValue: + "连续请求间的最小间隔秒数(可选)。留空表示无限制。", + })} +

+
+
); } diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 398f76ae2..2cb445c82 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -1902,6 +1902,10 @@ export function ProviderForm({ onApiModeChange={hermesForm.handleHermesApiModeChange} models={hermesForm.hermesModels} onModelsChange={hermesForm.handleHermesModelsChange} + rateLimitDelay={hermesForm.hermesRateLimitDelay} + onRateLimitDelayChange={ + hermesForm.handleHermesRateLimitDelayChange + } /> )} diff --git a/src/components/providers/forms/hooks/useHermesFormState.ts b/src/components/providers/forms/hooks/useHermesFormState.ts index 44c5450eb..f306efa93 100644 --- a/src/components/providers/forms/hooks/useHermesFormState.ts +++ b/src/components/providers/forms/hooks/useHermesFormState.ts @@ -37,11 +37,13 @@ export interface HermesFormState { hermesApiKey: string; hermesApiMode: HermesApiMode; hermesModels: HermesModel[]; + hermesRateLimitDelay: number | undefined; existingHermesKeys: string[]; handleHermesBaseUrlChange: (baseUrl: string) => void; handleHermesApiKeyChange: (apiKey: string) => void; handleHermesApiModeChange: (mode: HermesApiMode) => void; handleHermesModelsChange: (models: HermesModel[]) => void; + handleHermesRateLimitDelayChange: (delay: number | undefined) => void; resetHermesState: (config?: Partial) => void; } @@ -63,6 +65,12 @@ function parseHermesField( } } +function parseRateLimitDelay(raw: unknown): number | undefined { + return typeof raw === "number" && Number.isFinite(raw) && raw >= 0 + ? raw + : undefined; +} + export function useHermesFormState({ initialData, appId, @@ -108,6 +116,13 @@ export function useHermesFormState({ return parseHermesField(initialData, "models", []); }); + const [hermesRateLimitDelay, setHermesRateLimitDelay] = useState< + number | undefined + >(() => { + if (appId !== "hermes") return undefined; + return parseRateLimitDelay(initialData?.settingsConfig?.rate_limit_delay); + }); + const updateHermesConfig = useCallback( (updater: (config: Record) => void) => { try { @@ -165,6 +180,20 @@ export function useHermesFormState({ [updateHermesConfig], ); + const handleHermesRateLimitDelayChange = useCallback( + (delay: number | undefined) => { + setHermesRateLimitDelay(delay); + updateHermesConfig((config) => { + if (delay === undefined) { + delete config.rate_limit_delay; + } else { + config.rate_limit_delay = delay; + } + }); + }, + [updateHermesConfig], + ); + const resetHermesState = useCallback( (config?: Partial) => { setHermesProviderKey(""); @@ -172,6 +201,7 @@ export function useHermesFormState({ setHermesApiKey(config?.api_key || ""); setHermesApiMode(config?.api_mode ?? HERMES_DEFAULT_API_MODE); setHermesModels(config?.models ?? []); + setHermesRateLimitDelay(parseRateLimitDelay(config?.rate_limit_delay)); }, [], ); @@ -183,11 +213,13 @@ export function useHermesFormState({ hermesApiKey, hermesApiMode, hermesModels, + hermesRateLimitDelay, existingHermesKeys, handleHermesBaseUrlChange, handleHermesApiKeyChange, handleHermesApiModeChange, handleHermesModelsChange, + handleHermesRateLimitDelayChange, resetHermesState, }; } diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts index de5dbabbd..2b061a40d 100644 --- a/src/config/hermesProviderPresets.ts +++ b/src/config/hermesProviderPresets.ts @@ -37,8 +37,12 @@ export function isHermesReadOnlyProvider(settingsConfig: unknown): boolean { * models: * anthropic/claude-opus-4-7: * context_length: 200000 - * max_tokens: 32000 * ``` + * + * Hermes' `_VALID_CUSTOM_PROVIDER_FIELDS` (hermes_cli/config.py) does not include + * `max_tokens` at the per-model level — writing it produces an "unknown field" + * warning on Hermes startup. Max tokens is a per-request parameter, not a + * provider-level config. */ export interface HermesModel { /** Model ID — becomes the YAML key and the value written to top-level model.default. */ @@ -47,16 +51,14 @@ export interface HermesModel { name?: string; /** Override the auto-detected context window. */ context_length?: number; - /** Response-length cap. */ - max_tokens?: number; } /** * Top-level `model:` defaults suggested by a preset. * * Written to the YAML `model:` section when the user switches to this provider. - * Per-model `context_length` / `max_tokens` live on the individual `HermesModel` - * entries and flow through `custom_providers[].models`, not this object. + * Per-model `context_length` lives on the individual `HermesModel` entries and + * flows through `custom_providers[].models`, not this object. */ export interface HermesSuggestedDefaults { model: { @@ -71,7 +73,8 @@ export interface HermesSuggestedDefaults { export type HermesApiMode = | "chat_completions" | "anthropic_messages" - | "codex_responses"; + | "codex_responses" + | "bedrock_converse"; /** Default mode used when a provider has no stored value yet. */ export const HERMES_DEFAULT_API_MODE: HermesApiMode = "chat_completions"; @@ -87,6 +90,10 @@ export const hermesApiModes: Array<{ labelKey: "hermes.form.apiModeAnthropicMessages", }, { value: "codex_responses", labelKey: "hermes.form.apiModeCodexResponses" }, + { + value: "bedrock_converse", + labelKey: "hermes.form.apiModeBedrockConverse", + }, ]; export interface HermesProviderPreset { @@ -115,6 +122,8 @@ export interface HermesProviderSettingsConfig { api_mode?: HermesApiMode; /** UI-side ordered list; serialized to YAML as a dict keyed by id. */ models?: HermesModel[]; + /** Delay in seconds between consecutive requests to this provider. */ + rate_limit_delay?: number; [key: string]: unknown; } @@ -154,19 +163,16 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ id: "anthropic/claude-opus-4-7", name: "Claude Opus 4.7", context_length: 1000000, - max_tokens: 32000, }, { id: "anthropic/claude-sonnet-4-6", name: "Claude Sonnet 4.6", context_length: 1000000, - max_tokens: 32000, }, { id: "anthropic/claude-haiku-4-5", name: "Claude Haiku 4.5", context_length: 200000, - max_tokens: 32000, }, { id: "openai/gpt-5.4", @@ -202,13 +208,11 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ id: "deepseek-chat", name: "DeepSeek V3.2", context_length: 128000, - max_tokens: 8000, }, { id: "deepseek-reasoner", name: "DeepSeek R1", context_length: 128000, - max_tokens: 64000, }, ], }, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 144b7eb16..614b01f98 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1655,6 +1655,10 @@ "apiModeChatCompletions": "OpenAI Chat Completions", "apiModeAnthropicMessages": "Anthropic Messages", "apiModeCodexResponses": "OpenAI Responses", + "apiModeBedrockConverse": "AWS Bedrock Converse", + "baseUrlRequired": "API endpoint is required", + "baseUrlScheme": "Use an http:// or https:// address", + "baseUrlInvalid": "API endpoint is not a valid URL", "models": "Models", "addModel": "Add model", "noModels": "No models configured. Switching to this provider won't change the default model.", @@ -1663,11 +1667,13 @@ "modelName": "Display name", "modelNamePlaceholder": "Claude Opus 4.7", "contextLength": "Context length", - "maxTokens": "Max output tokens", "advancedOptions": "Advanced options", "modelsHint": "On switch, the first model is written to top-level model.default.", "primaryModel": "Default", - "fallbackModel": "Alternate" + "fallbackModel": "Alternate", + "providerAdvanced": "Provider advanced options", + "rateLimitDelay": "Rate limit delay (seconds)", + "rateLimitDelayHint": "Minimum delay in seconds between consecutive requests (optional). Leave empty for no limit." }, "webui": { "open": "Open Hermes Web UI", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3dc46b8b6..dda71b043 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1655,6 +1655,10 @@ "apiModeChatCompletions": "OpenAI Chat Completions", "apiModeAnthropicMessages": "Anthropic Messages", "apiModeCodexResponses": "OpenAI Responses", + "apiModeBedrockConverse": "AWS Bedrock Converse", + "baseUrlRequired": "API エンドポイントは必須です", + "baseUrlScheme": "http:// または https:// で始まるアドレスを指定してください", + "baseUrlInvalid": "API エンドポイントは有効な URL ではありません", "models": "モデル一覧", "addModel": "モデルを追加", "noModels": "モデル未設定。このプロバイダーに切り替えてもデフォルトモデルは更新されません。", @@ -1663,11 +1667,13 @@ "modelName": "表示名", "modelNamePlaceholder": "Claude Opus 4.7", "contextLength": "コンテキスト長", - "maxTokens": "最大出力トークン", "advancedOptions": "詳細オプション", "modelsHint": "切り替え時、最初のモデルが model.default に書き込まれます。", "primaryModel": "デフォルト", - "fallbackModel": "予備" + "fallbackModel": "予備", + "providerAdvanced": "プロバイダー詳細オプション", + "rateLimitDelay": "リクエスト間隔(秒)", + "rateLimitDelayHint": "連続したリクエスト間の最小待機秒数(任意)。空欄の場合は制限なし。" }, "webui": { "open": "Hermes Web UI を開く", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 0df36ad9e..f4170930d 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1655,6 +1655,10 @@ "apiModeChatCompletions": "OpenAI Chat Completions", "apiModeAnthropicMessages": "Anthropic Messages", "apiModeCodexResponses": "OpenAI Responses", + "apiModeBedrockConverse": "AWS Bedrock Converse", + "baseUrlRequired": "API 端点不能为空", + "baseUrlScheme": "请使用 http:// 或 https:// 开头的地址", + "baseUrlInvalid": "API 端点不是有效的 URL", "models": "模型列表", "addModel": "添加模型", "noModels": "暂无模型配置。切换到此供应商时将不会更新默认模型。", @@ -1663,11 +1667,13 @@ "modelName": "显示名称", "modelNamePlaceholder": "Claude Opus 4.7", "contextLength": "上下文长度", - "maxTokens": "最大输出 Tokens", "advancedOptions": "高级选项", "modelsHint": "切换到此供应商时,第一个模型会写入顶层 model.default。", "primaryModel": "默认模型", - "fallbackModel": "备选模型" + "fallbackModel": "备选模型", + "providerAdvanced": "供应商高级选项", + "rateLimitDelay": "请求间隔(秒)", + "rateLimitDelayHint": "连续请求间的最小间隔秒数(可选)。留空表示无限制。" }, "webui": { "open": "打开 Hermes Web UI",