mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-25 17:22:27 +08:00
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:
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,
|
||||
|
||||
@@ -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, "-"),
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user