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"
>
-
-
+
+
+
+
+
{
+ 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",