feat(hermes): align provider schema with Hermes Agent 0.10.0

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
This commit is contained in:
Jason
2026-04-20 22:13:54 +08:00
parent 111ddf8d73
commit 0be75668cc
9 changed files with 375 additions and 89 deletions
@@ -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<BaseUrlErrorCode, string> = {
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 (
<Collapsible open={open} onOpenChange={onOpenChange}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{open ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
{t(labelKey)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-2">
{children}
</CollapsibleContent>
</Collapsible>
);
}
export function HermesFormFields({
@@ -75,6 +153,8 @@ export function HermesFormFields({
onApiModeChange,
models,
onModelsChange,
rateLimitDelay,
onRateLimitDelayChange,
}: HermesFormFieldsProps) {
const { t } = useTranslation();
const [expandedModels, setExpandedModels] = useState<Record<number, boolean>>(
@@ -82,6 +162,24 @@ export function HermesFormFields({
);
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
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
}
/>
<p className="text-xs text-muted-foreground">
{t("hermes.form.baseUrlHint", {
defaultValue: "供应商的 API 端点地址。",
})}
</p>
{showBaseUrlError ? (
<p className="text-xs text-destructive">{baseUrlErrorMessage}</p>
) : (
<p className="text-xs text-muted-foreground">
{t("hermes.form.baseUrlHint", {
defaultValue: "供应商的 API 端点地址。",
})}
</p>
)}
</div>
<ApiKeySection
@@ -377,74 +486,31 @@ export function HermesFormFields({
</Button>
</div>
<Collapsible
<AdvancedSection
open={expandedModels[index] ?? false}
onOpenChange={() => toggleModelAdvanced(index)}
labelKey="hermes.form.advancedOptions"
>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{expandedModels[index] ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
{t("hermes.form.advancedOptions", {
defaultValue: "高级选项",
<div className="space-y-1">
<label className="text-xs text-muted-foreground">
{t("hermes.form.contextLength", {
defaultValue: "上下文长度",
})}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-2">
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("hermes.form.contextLength", {
defaultValue: "上下文长度",
})}
</label>
<Input
type="number"
value={model.context_length ?? ""}
onChange={(e) =>
handleModelChange(
index,
"context_length",
e.target.value
? parseInt(e.target.value)
: undefined,
)
}
placeholder="200000"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("hermes.form.maxTokens", {
defaultValue: "最大输出 Tokens",
})}
</label>
<Input
type="number"
value={model.max_tokens ?? ""}
onChange={(e) =>
handleModelChange(
index,
"max_tokens",
e.target.value
? parseInt(e.target.value)
: undefined,
)
}
placeholder="32000"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</label>
<Input
type="number"
value={model.context_length ?? ""}
onChange={(e) =>
handleModelChange(
index,
"context_length",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="200000"
/>
</div>
</AdvancedSection>
</div>
))}
</div>
@@ -457,6 +523,44 @@ export function HermesFormFields({
})}
</p>
</div>
<AdvancedSection
open={providerAdvancedOpen}
onOpenChange={setProviderAdvancedOpen}
labelKey="hermes.form.providerAdvanced"
>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">
{t("hermes.form.rateLimitDelay", {
defaultValue: "请求间隔(秒)",
})}
</label>
<Input
type="number"
step="0.1"
min="0"
value={rateLimitDelay ?? ""}
onChange={(e) => {
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"
/>
<p className="text-xs text-muted-foreground">
{t("hermes.form.rateLimitDelayHint", {
defaultValue:
"连续请求间的最小间隔秒数(可选)。留空表示无限制。",
})}
</p>
</div>
</AdvancedSection>
</>
);
}
@@ -1902,6 +1902,10 @@ export function ProviderForm({
onApiModeChange={hermesForm.handleHermesApiModeChange}
models={hermesForm.hermesModels}
onModelsChange={hermesForm.handleHermesModelsChange}
rateLimitDelay={hermesForm.hermesRateLimitDelay}
onRateLimitDelayChange={
hermesForm.handleHermesRateLimitDelayChange
}
/>
)}
@@ -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<HermesProviderSettingsConfig>) => void;
}
@@ -63,6 +65,12 @@ function parseHermesField<T>(
}
}
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<HermesModel[]>(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<string, unknown>) => 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<HermesProviderSettingsConfig>) => {
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,
};
}
+15 -11
View File
@@ -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,
},
],
},
+8 -2
View File
@@ -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",
+8 -2
View File
@@ -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 を開く",
+8 -2
View File
@@ -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",