feat(hermes): warn when a provider key is a Hermes built-in provider id

Hermes routes model.provider to its built-in provider whenever the name is a
canonical built-in id (runtime_provider_custom._shadowed_by_builtin), so a card
keyed "deepseek" or "openrouter" is ignored: Hermes asks for the built-in's own
API key env var and never uses the card's endpoint or key. #8538 only fixed the
two MiniMax presets by prefilling cc-switch-minimax.

The provider key field now shows a warning for any of the 52 canonical ids
(generated from hermes-agent rc.3-v0.22.0) and suggests cc-switch-<key>. A
locked key gets a variant that says to re-add the provider under another key.
Aliases such as kimi still reach the custom provider and are not flagged.
Saving is not blocked.
This commit is contained in:
Jason committed 2026-10-20 21:59:20 +08:00
1 parent 73adfaf01d
commit f460410f5d
9 files changed
+247 -10

No files matched your search

@@ -57,6 +57,7 @@ import {
hermesProviderPresets,
type HermesProviderPreset,
} from "@/config/hermesProviderPresets";
import { isHermesBuiltinProviderId } from "@/config/hermesBuiltinProviderIds";
import { OpenCodeFormFields } from "./OpenCodeFormFields";
import { OpenClawFormFields } from "./OpenClawFormFields";
import { HermesFormFields } from "./HermesFormFields";
@@ -2544,11 +2545,25 @@ function ProviderFormFull({
{t("hermes.form.providerKeyInvalid")}
</p>
)}
{!(
additiveExistingProviderKeys.includes(
hermesForm.hermesProviderKey,
) && !isProviderKeyLocked
) &&
{isHermesBuiltinProviderId(hermesForm.hermesProviderKey) && (
<p
className="text-xs text-warning-text"
data-testid="hermes-key-builtin-warning"
>
{t(
isProviderKeyLocked
? "hermes.form.providerKeyBuiltinLocked"
: "hermes.form.providerKeyBuiltin",
{ key: hermesForm.hermesProviderKey.trim() },
)}
</p>
)}
{!isHermesBuiltinProviderId(hermesForm.hermesProviderKey) &&
!(
additiveExistingProviderKeys.includes(
hermesForm.hermesProviderKey,
) && !isProviderKeyLocked
) &&
(hermesForm.hermesProviderKey.trim() === "" ||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
hermesForm.hermesProviderKey,
+78
View File
@@ -0,0 +1,78 @@
/**
* Hermes built-in provider ids that shadow a same-named custom provider.
*
* Hermes resolves `model.provider` to its built-in provider whenever the name
* IS a canonical built-in id (`hermes_cli/runtime_provider_custom.py`,
* `_shadowed_by_builtin`: `auth.resolve_provider(name) == name`). A CC Switch
* card whose provider key is one of these ids is then ignored: Hermes asks for
* the built-in's own credential (e.g. `MINIMAX_API_KEY`) and never uses the
* card's base URL or API key. Names that are merely aliases (`kimi` →
* `kimi-coding`, `minimax_cn` → `minimax-cn`) and explicit `custom:<name>`
* keys still reach the custom provider.
*
* Generated by running `_shadowed_by_builtin` over every `PROVIDER_REGISTRY`
* key and alias (plugin providers included) on hermes-agent rc.3-v0.22.0
* (commit d97007c32d6, 2026-10-13). Hermes up to v2026.5.16 also shadowed
* aliases; those versions are not covered here.
*/
export const HERMES_BUILTIN_PROVIDER_IDS: ReadonlySet<string> = new Set([
"actual",
"ai-gateway",
"alibaba",
"alibaba-cn",
"alibaba-coding-plan",
"alibaba-coding-plan-cn",
"alibaba-token-plan",
"alibaba-token-plan-cn",
"anthropic",
"arcee",
"azure-foundry",
"bedrock",
"commandcode",
"commandcode-anthropic",
"copilot",
"copilot-acp",
"deepinfra",
"deepseek",
"fireworks",
"gemini",
"gmi",
"huggingface",
"kilocode",
"kimi-coding",
"kimi-coding-cn",
"lmstudio",
"meta-ai",
"minimax",
"minimax-cn",
"minimax-oauth",
"nebius-token-factory",
"nous",
"novita",
"nvidia",
"ollama-cloud",
"openai-api",
"openai-codex",
"opencode-go",
"opencode-zen",
"openrouter",
"qwen-oauth",
"router",
"solstice",
"stepfun",
"tencent-tokenhub",
"tencent-tokenplan",
"upstage",
"vertex",
"xai",
"xai-oauth",
"xiaomi",
"zai",
]);
/** Whether Hermes would route a provider key to its built-in provider. */
export function isHermesBuiltinProviderId(providerKey: string): boolean {
return HERMES_BUILTIN_PROVIDER_IDS.has(
providerKey.trim().toLowerCase().replace(/ /g, "-"),
);
}
+2
View File
@@ -3135,6 +3135,8 @@
"providerKeyRequired": "Provider key is required",
"providerKeyInvalid": "Provider key can only contain lowercase letters, numbers, and hyphens",
"providerKeyDuplicate": "This provider key already exists",
"providerKeyBuiltin": "\"{{key}}\" is the ID of a Hermes built-in provider. Hermes will use the built-in provider (which needs its own API key environment variable) and ignore this card's endpoint and key. Use a different key, e.g. cc-switch-{{key}}.",
"providerKeyBuiltinLocked": "\"{{key}}\" is the ID of a Hermes built-in provider. Hermes will use the built-in provider and ignore this card's endpoint and key. The key is locked: add this provider again under another key (e.g. cc-switch-{{key}}), then delete this card.",
"apiMode": "API Mode",
"apiModeHint": "Provider API protocol. Choose the format that matches your endpoint.",
"apiModeChatCompletions": "OpenAI Chat Completions",
+2
View File
@@ -3135,6 +3135,8 @@
"providerKeyRequired": "プロバイダーキーは必須です",
"providerKeyInvalid": "プロバイダーキーは小文字、数字、ハイフンのみ使用できます",
"providerKeyDuplicate": "このプロバイダーキーは既に存在します",
"providerKeyBuiltin": "「{{key}}」は Hermes 組み込みプロバイダーの ID です。Hermes は組み込みプロバイダー(専用の環境変数の API キーが必要)を使い、このカードのエンドポイントとキーは使われません。cc-switch-{{key}} など別のキーにしてください。",
"providerKeyBuiltinLocked": "「{{key}}」は Hermes 組み込みプロバイダーの ID です。Hermes は組み込みプロバイダーを使い、このカードのエンドポイントとキーは使われません。キーはロックされているため、別のキー(例: cc-switch-{{key}})でこのプロバイダーを追加し直してから、このカードを削除してください。",
"apiMode": "API モード",
"apiModeHint": "プロバイダーの API プロトコル。エンドポイントに合わせて正しい形式を選択してください。",
"apiModeChatCompletions": "OpenAI Chat Completions",
+2
View File
@@ -3136,6 +3136,8 @@
"providerKeyRequired": "供應商標識不能為空",
"providerKeyInvalid": "供應商標識只能包含小寫字母、數字和連字號",
"providerKeyDuplicate": "該供應商標識已存在",
"providerKeyBuiltin": "「{{key}}」是 Hermes 內建供應商的標識。Hermes 會改用內建供應商(需要它自己的環境變數 Key),這張卡的位址和 Key 不會生效。請換一個標識,例如 cc-switch-{{key}}。",
"providerKeyBuiltinLocked": "「{{key}}」是 Hermes 內建供應商的標識。Hermes 會改用內建供應商,這張卡的位址和 Key 不會生效。標識已鎖定無法修改,請用其他標識(例如 cc-switch-{{key}})重新新增這個供應商,再刪除這張卡。",
"apiMode": "API 模式",
"apiModeHint": "供應商 API 協定。請根據端點選擇正確的協定。",
"apiModeChatCompletions": "OpenAI Chat Completions",
+2
View File
@@ -3135,6 +3135,8 @@
"providerKeyRequired": "供应商标识不能为空",
"providerKeyInvalid": "供应商标识只能包含小写字母、数字和连字符",
"providerKeyDuplicate": "该供应商标识已存在",
"providerKeyBuiltin": "「{{key}}」是 Hermes 内置供应商的标识。Hermes 会改用内置供应商(要它自己的环境变量 Key),这张卡的地址和 Key 不会生效。请换一个标识,例如 cc-switch-{{key}}。",
"providerKeyBuiltinLocked": "「{{key}}」是 Hermes 内置供应商的标识。Hermes 会改用内置供应商,这张卡的地址和 Key 不会生效。标识已锁定无法修改,请用其他标识(例如 cc-switch-{{key}})重新添加这个供应商,再删除这张卡。",
"apiMode": "API 模式",
"apiModeHint": "供应商 API 协议。请根据端点选择正确的协议。",
"apiModeChatCompletions": "OpenAI Chat Completions",
@@ -0,0 +1,95 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ProviderForm } from "@/components/providers/forms/ProviderForm";
import { server } from "../msw/server";
import { createTestQueryClient } from "../utils/testQueryClient";
const toastError = vi.hoisted(() => vi.fn());
vi.mock("sonner", () => ({ toast: { error: toastError, success: vi.fn() } }));
vi.mock("@/components/JsonEditor", () => ({
default: ({ value }: { value: string }) => (
<textarea readOnly value={value} />
),
}));
const renderHermesForm = (liveProviderIds: string[], providerId?: string) => {
server.use(
http.post("http://tauri.local/get_hermes_live_provider_ids", () =>
HttpResponse.json(liveProviderIds),
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<ProviderForm
appId="hermes"
providerId={providerId}
initialData={
providerId
? {
name: "MiniMax",
settingsConfig: {
name: providerId,
base_url: "https://api.minimaxi.com/v1",
api_key: "test-key",
},
}
: undefined
}
submitLabel="save-provider"
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>
</QueryClientProvider>,
);
};
describe("Hermes provider key shadowed by a built-in provider", () => {
beforeEach(() => {
toastError.mockClear();
});
it("warns while the typed key is a Hermes built-in id", async () => {
// A saved card not yet in config.yaml keeps its key editable.
renderHermesForm([], "my-card");
const input = await screen.findByDisplayValue("my-card");
await waitFor(() => expect(input).toBeEnabled());
fireEvent.change(input, { target: { value: "deepseek" } });
expect(
await screen.findByTestId("hermes-key-builtin-warning"),
).toHaveTextContent("hermes.form.providerKeyBuiltin");
expect(
screen.queryByText(/^Lowercase letters, numbers, and hyphens only/),
).not.toBeInTheDocument();
fireEvent.change(input, { target: { value: "cc-switch-deepseek" } });
expect(
screen.queryByTestId("hermes-key-builtin-warning"),
).not.toBeInTheDocument();
expect(
screen.getByText(/^Lowercase letters, numbers, and hyphens only/),
).toBeInTheDocument();
});
it("does not warn for an alias that still reaches the custom provider", async () => {
// A saved card not yet in config.yaml keeps its key editable.
renderHermesForm([], "my-card");
const input = await screen.findByDisplayValue("my-card");
await waitFor(() => expect(input).toBeEnabled());
fireEvent.change(input, { target: { value: "kimi" } });
expect(
screen.queryByTestId("hermes-key-builtin-warning"),
).not.toBeInTheDocument();
});
it("tells the owner of a locked built-in key to re-add the provider", async () => {
renderHermesForm(["minimax"], "minimax");
expect(
await screen.findByText("hermes.form.providerKeyBuiltinLocked"),
).toBeInTheDocument();
expect(screen.getByDisplayValue("minimax")).toBeDisabled();
});
});
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
HERMES_BUILTIN_PROVIDER_IDS,
isHermesBuiltinProviderId,
} from "@/config/hermesBuiltinProviderIds";
import { hermesProviderPresets } from "@/config/hermesProviderPresets";
describe("Hermes built-in provider ids", () => {
it.each(["minimax", "minimax-cn", "minimax-oauth", "deepseek", "openrouter"])(
"flags canonical built-in id %s",
(key) => {
expect(isHermesBuiltinProviderId(key)).toBe(true);
},
);
it("normalizes like Hermes before matching", () => {
expect(isHermesBuiltinProviderId(" DeepSeek ")).toBe(true);
});
// Aliases resolve to a different canonical id, so Hermes still picks the
// same-named custom provider.
it.each(["kimi", "glm", "zhipu", "moonshot", "cc-switch-minimax", "custom"])(
"does not flag %s",
(key) => {
expect(isHermesBuiltinProviderId(key)).toBe(false);
},
);
it("only lists ids a provider key can hold", () => {
for (const id of HERMES_BUILTIN_PROVIDER_IDS) {
expect(id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
}
});
it("no preset prefills a provider key that Hermes shadows", () => {
for (const preset of hermesProviderPresets) {
if (!preset.providerKey) continue;
expect({
preset: preset.name,
shadowed: isHermesBuiltinProviderId(preset.providerKey),
}).toEqual({ preset: preset.name, shadowed: false });
}
});
});
@@ -1,10 +1,7 @@
import { describe, expect, it } from "vitest";
import { isHermesBuiltinProviderId } from "@/config/hermesBuiltinProviderIds";
import { hermesProviderPresets } from "@/config/hermesProviderPresets";
// Hermes routes `model.provider` to a built-in provider when the name is a
// canonical built-in id, ignoring a same-named custom_provider.
const HERMES_BUILTIN_MINIMAX_IDS = ["minimax", "minimax-cn", "minimax-oauth"];
describe("Hermes MiniMax presets", () => {
it.each(["MiniMax", "MiniMax en"])(
"%s prefills a provider key that no Hermes built-in shadows",
@@ -14,7 +11,7 @@ describe("Hermes MiniMax presets", () => {
const key = preset!.providerKey;
expect(key).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
expect(HERMES_BUILTIN_MINIMAX_IDS).not.toContain(key);
expect(isHermesBuiltinProviderId(key!)).toBe(false);
expect(preset!.settingsConfig.name).toBe(key);
expect(preset!.suggestedDefaults?.model.provider).toBe(key);
},