From 22303d5f71022ee7ff859a8c9c14179198244604 Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Fri, 7 Aug 2026 19:14:47 +0800 Subject: [PATCH] feat(web): protocol-path suffix on base URL + unified preset-vendor protocol hints (#237) Co-authored-by: Claude Fable 5 --- .../web/src/features/models/models-page.tsx | 65 +++++++++++++------ .../web/src/features/models/protocol-path.ts | 46 +++++++++++++ packages/web/src/lib/strings-en.ts | 12 ++-- packages/web/src/lib/strings.ts | 16 ++--- packages/web/test/protocol-path.test.ts | 61 +++++++++++++++++ 5 files changed, 166 insertions(+), 34 deletions(-) create mode 100644 packages/web/src/features/models/protocol-path.ts create mode 100644 packages/web/test/protocol-path.test.ts 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 && ( <>

- {vendorGroup ? S.models.addAutoRouteHint : S.models.addProtocolHint} + {vendorGroup && dialogProvider + ? S.models.vendorProtocolHint(dialogProvider.label) + : S.models.addProtocolHint}

{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"); + }); +});