diff --git a/packages/web/src/features/models/models-page.tsx b/packages/web/src/features/models/models-page.tsx
index eb6d53a..ad77bef 100644
--- a/packages/web/src/features/models/models-page.tsx
+++ b/packages/web/src/features/models/models-page.tsx
@@ -71,6 +71,7 @@ import {
} from "@prismshadow/penguin-core/model-catalog";
import type { ModelProviderInfo } from "@prismshadow/penguin-core/model-catalog";
import { groupModelRows, isFreeModel, sameModelRef, userProviderInfo } from "./model-grouping";
+import { protocolPathForModel } from "./protocol-path";
import {
isGroupExpanded,
loadExpandedProviders,
@@ -1167,6 +1168,11 @@ function ModelDialog({
form.provider === "custom" ||
providerInfo(form.provider) === undefined;
const baseUrlRequired = !preset && openAiLike;
+ // Protocol-path suffix shown inside the base URL field (every model, even while the
+ // field is empty): the path the client appends to the base URL, i.e. the endpoint
+ // shape a custom URL must serve. Recomputed from the live form so switching the
+ // group in add mode updates it.
+ const protocolPath = protocolPathForModel(form.provider, form.clientType);
const validated = (): RowState | null => {
const modelId = form.modelId.trim();
@@ -1457,14 +1463,18 @@ function ModelDialog({
)}
- {/* Adding a model: protocol note first (first-party provider group = auto-route
- by id; custom / self-defined group / gateway = fixed OpenAI protocol), then
- the identity fields ("get model id / API key" links next to the respective
- inputs; fill in the id to test connectivity — verify before saving). */}
+ {/* Adding a model: protocol note first (preset direct-vendor group = only the
+ vendor's official protocol, named via the group label — the in-field suffix
+ on the base URL below says which path; custom / self-defined group / gateway
+ = fixed OpenAI protocol), then the identity fields ("get model id / API key"
+ links next to the respective inputs; fill in the id to test connectivity —
+ verify before saving). */}
{isNew && (
<>
{identityFields}
>
@@ -1562,20 +1572,37 @@ function ModelDialog({
)}
{/* 2) base URL (required for custom / user-defined groups and explicit openai protocol — see
- baseUrlRequired). Official-protocol entries (everything except the OpenAI-protocol path)
- carry a caution: a custom endpoint must still speak the vendor's official protocol. */}
- set({ baseUrl: e.target.value })}
- className="font-mono"
- placeholder={preset ? S.models.baseUrlHint : "https://…"}
- {...(fieldErrors.baseUrl ? { error: fieldErrors.baseUrl } : {})}
- {...(!openAiLike ? { hint: S.models.baseUrlOfficialNote } : {})}
- />
+ baseUrlRequired). The grey in-field suffix shows the protocol path the client appends
+ to the base URL — the endpoint shape a custom URL must serve; it renders for every
+ model and stays while the field is empty (hints the shape before typing). Reuses the
+ unit-adornment idiom of the context window / max tokens fields below; the error text
+ sits outside the relative wrapper (see Input.invalid). */}
+
{/* 3) Context window + max output tokens side by side (one row): the "Token" unit
sits inside each box as a muted right suffix. Placeholders cannot scroll, so at
diff --git a/packages/web/src/features/models/protocol-path.ts b/packages/web/src/features/models/protocol-path.ts
new file mode 100644
index 0000000..1cb542b
--- /dev/null
+++ b/packages/web/src/features/models/protocol-path.ts
@@ -0,0 +1,46 @@
+/**
+ * Protocol-path suffix for the config dialog's base URL field: the path the AgentHub
+ * client appends to a custom base URL, shown inside the field so the user knows which
+ * endpoint shape the URL must serve. Verified against the vendored agenthub 0.4.1
+ * clients and the SDKs they construct:
+ * - Anthropic direct (claude-* clients, `@anthropic-ai/sdk`): POST {base}/v1/messages —
+ * the SDK's default base URL (https://api.anthropic.com) carries no /v1; the request
+ * path does, so a custom base URL gains the full /v1/messages.
+ * - OpenAI direct (gpt-* clients): the Responses API, POST {base}/responses (the SDK's
+ * default base URL https://api.openai.com/v1 already ends in /v1, and a custom base
+ * URL replaces it whole).
+ * - Google direct (gemini-* clients, `@google/genai`): {base}/v1beta/models/:… —
+ * the SDK joins base URL + API version (v1beta) + the models path.
+ * - Every OpenAI-compatible client — explicit `client_type: "openai"` (gateways,
+ * custom and user-defined groups) plus the DeepSeek / GLM / Kimi direct clients —
+ * POST {base}/chat/completions.
+ */
+
+/**
+ * The protocol path appended to the base URL for a model entry. An explicit client
+ * type wins over group membership (a `client_type: "openai"` entry inside a vendor
+ * group still goes through the generic OpenAI-compatible client); with no client type
+ * the entry is auto-routed within its vendor group, so the group implies the client
+ * family. Pure display logic: the empty-input hint must work before anything is typed,
+ * so it keys off (provider, clientType) only, never the current base URL value.
+ */
+export function protocolPathForModel(provider: string, clientType: string): string {
+ const t = clientType.trim().toLowerCase();
+ if (t.includes("openai")) return "/chat/completions";
+ // Legacy explicit client types (historical config): pin the family like auto-routing would.
+ if (t.includes("claude")) return "/v1/messages";
+ if (t.includes("gemini")) return "/v1beta/models";
+ if (t.includes("gpt")) return "/responses";
+ // Any other explicit client type (deepseek-v4 / glm-* / kimi-*): all speak chat completions.
+ if (t !== "") return "/chat/completions";
+ switch (provider) {
+ case "anthropic":
+ return "/v1/messages";
+ case "openai":
+ return "/responses";
+ case "google":
+ return "/v1beta/models";
+ default:
+ return "/chat/completions";
+ }
+}
diff --git a/packages/web/src/lib/strings-en.ts b/packages/web/src/lib/strings-en.ts
index 20b871b..91046e6 100644
--- a/packages/web/src/lib/strings-en.ts
+++ b/packages/web/src/lib/strings-en.ts
@@ -355,14 +355,11 @@ export const en: Strings = {
addTitle: "Add model (OpenAI protocol)",
addTitleVendor: "Add model",
addProtocolHint:
- "New models always use the OpenAI Chat Completions protocol (no auto-routing by model id); set the base URL to a compatible endpoint",
- addAutoRouteHint:
- "New models in this group are auto-routed by their upstream id to the vendor's official client: leave the base URL empty for the official endpoint, and an empty API key falls back to the resolved client's environment variable",
- /** Caution beside the base URL when the entry uses a vendor's official protocol (everything except the OpenAI-protocol path). */
- baseUrlOfficialNote:
- "Note: this model uses the vendor's official protocol — a custom base URL must serve an endpoint compatible with it; it never switches the model to the OpenAI protocol",
+ "New models use the OpenAI Chat Completions protocol; set the base URL to a compatible endpoint",
+ vendorProtocolHint: (vendor: string): string =>
+ `Only ${vendor}'s official API protocol is supported; use a custom model group for OpenAI-compatible endpoints.`,
autoRouteNone:
- "AgentHub cannot auto-route this id: double-check it, or add the model under Custom / a user-defined group with the OpenAI protocol",
+ "This id is not a recognized official model id: double-check it, or add the model under Custom / a user-defined group with an OpenAI-compatible endpoint",
addGroup: "Add group",
addGroupTitle: "Add group",
addGroupDesc:
@@ -431,6 +428,7 @@ export const en: Strings = {
clearApiKey: "Clear stored API key",
baseUrl: "Custom base URL",
baseUrlHint: "Leave empty to use the provider default",
+ baseUrlSuffixTitle: "The client appends the grey protocol path to the base URL",
baseUrlRequired: "A base URL is required",
contextWindowDefaultHint: (n: number): string => `Defaults to ${n} if empty`,
confirmDeleteTitle: "Delete model",
diff --git a/packages/web/src/lib/strings.ts b/packages/web/src/lib/strings.ts
index 3007bc0..fdf9449 100644
--- a/packages/web/src/lib/strings.ts
+++ b/packages/web/src/lib/strings.ts
@@ -331,15 +331,13 @@ export const zh = {
editTitle: "模型配置",
addTitle: "新增模型(OpenAI 协议)",
addTitleVendor: "新增模型",
- addProtocolHint:
- "新增模型固定走 OpenAI Chat Completions 兼容协议(不按模型 id 自动路由),base URL 填其兼容端点",
- addAutoRouteHint:
- "该分组的新模型按上游 id 由 AgentHub 自动路由到厂商官方客户端:base URL 留空即官方端点,API key 留空按解析出的客户端读取环境变量",
- /** Caution beside the base URL when the entry uses a vendor's official protocol (everything except the OpenAI-protocol path). */
- baseUrlOfficialNote:
- "注意:该模型走厂商官方协议——自定义 base URL 必须是兼容官方协议的端点,不会因此切换为 OpenAI 协议",
+ addProtocolHint: "新增模型走 OpenAI Chat Completions 兼容协议,base URL 填其兼容端点",
+ /** Add-dialog note for preset direct-vendor groups (fed the provider label): states whose protocol the group speaks — the in-field suffix on the base URL shows which path. */
+ vendorProtocolHint: (vendor: string): string =>
+ `仅支持 ${vendor} 官方接口协议,OpenAI 兼容接口请使用自定义模型分组`,
+ /** Non-blocking warning under the model id (preset direct-vendor groups, adding): the typed id is not a recognized official model id. */
autoRouteNone:
- "该 id 无法被 AgentHub 自动路由:请核对 id,或改在 Custom / 自建分组下以 OpenAI 协议接入",
+ "该 id 不是可识别的官方模型 id:请核对,或改在 Custom / 自建分组以 OpenAI 兼容接口接入",
addGroup: "新增分组",
addGroupTitle: "新增分组",
addGroupDesc:
@@ -411,6 +409,8 @@ export const zh = {
clearApiKey: "清除已存 API key",
baseUrl: "自定义 base URL",
baseUrlHint: "留空使用厂商默认地址",
+ /** Hover title for the base URL field: explains the grey in-field suffix (the protocol path the client appends to the base URL). */
+ baseUrlSuffixTitle: "客户端会在 base URL 后追加右侧灰色协议路径",
baseUrlRequired: "必须填写 base URL",
contextWindowDefaultHint: (n: number): string => `留空按 ${n} 计`,
confirmDeleteTitle: "删除模型",
diff --git a/packages/web/test/protocol-path.test.ts b/packages/web/test/protocol-path.test.ts
new file mode 100644
index 0000000..ca75918
--- /dev/null
+++ b/packages/web/test/protocol-path.test.ts
@@ -0,0 +1,61 @@
+/**
+ * Protocol-path suffix for the base URL field (pure mapping): which path the AgentHub
+ * client appends to a custom base URL, keyed off (provider, clientType). The expected
+ * paths mirror the vendored agenthub clients: Anthropic direct posts /v1/messages,
+ * OpenAI direct uses the Responses API (/responses), Google direct hits
+ * /v1beta/models/:…, and every OpenAI-compatible client posts /chat/completions.
+ */
+import { describe, expect, it } from "vitest";
+import { protocolPathForModel } from "../src/features/models/protocol-path";
+
+describe("protocolPathForModel", () => {
+ it("first-party vendor groups (auto-routed, no client type) map to their official protocol path", () => {
+ expect(protocolPathForModel("anthropic", "")).toBe("/v1/messages");
+ expect(protocolPathForModel("openai", "")).toBe("/responses");
+ expect(protocolPathForModel("google", "")).toBe("/v1beta/models");
+ });
+
+ it("the remaining direct vendors speak chat completions", () => {
+ expect(protocolPathForModel("deepseek", "")).toBe("/chat/completions");
+ expect(protocolPathForModel("zhipu", "")).toBe("/chat/completions");
+ expect(protocolPathForModel("moonshot", "")).toBe("/chat/completions");
+ });
+
+ it("gateway groups always carry client_type openai and get /chat/completions", () => {
+ for (const provider of [
+ "openrouter",
+ "fireworks",
+ "siliconflow",
+ "qwen-token-plan",
+ "qwen-pay-as-you-go",
+ ]) {
+ expect(protocolPathForModel(provider, "openai")).toBe("/chat/completions");
+ }
+ });
+
+ it("custom and user-defined groups get /chat/completions (with or without the explicit client type)", () => {
+ expect(protocolPathForModel("custom", "openai")).toBe("/chat/completions");
+ // Legacy TOML entries in a user-defined group may lack client_type; the group still means the OpenAI protocol.
+ expect(protocolPathForModel("myproxy", "")).toBe("/chat/completions");
+ });
+
+ it("an explicit openai client type wins over vendor-group membership", () => {
+ expect(protocolPathForModel("anthropic", "openai")).toBe("/chat/completions");
+ expect(protocolPathForModel("google", "openai")).toBe("/chat/completions");
+ });
+
+ it("legacy explicit client types pin the family like auto-routing would", () => {
+ expect(protocolPathForModel("myproxy", "claude-5")).toBe("/v1/messages");
+ expect(protocolPathForModel("myproxy", "claude-4-6")).toBe("/v1/messages");
+ expect(protocolPathForModel("myproxy", "gemini-3.6")).toBe("/v1beta/models");
+ expect(protocolPathForModel("myproxy", "gpt-5.5")).toBe("/responses");
+ expect(protocolPathForModel("myproxy", "deepseek-v4")).toBe("/chat/completions");
+ expect(protocolPathForModel("myproxy", "glm-5.2")).toBe("/chat/completions");
+ expect(protocolPathForModel("myproxy", "kimi-k3")).toBe("/chat/completions");
+ });
+
+ it("client type matching is trim- and case-insensitive", () => {
+ expect(protocolPathForModel("custom", " OpenAI ")).toBe("/chat/completions");
+ expect(protocolPathForModel("anthropic", " Claude-5 ")).toBe("/v1/messages");
+ });
+});