feat(web): protocol-path suffix on base URL + unified preset-vendor protocol hints (#237)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-07 19:14:47 +08:00
committed by GitHub
parent 864261eade
commit 22303d5f71
5 changed files with 166 additions and 34 deletions
@@ -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({
</div>
)}
{/* 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 && (
<>
<p className="text-xs text-gray-500 dark:text-gray-400">
{vendorGroup ? S.models.addAutoRouteHint : S.models.addProtocolHint}
{vendorGroup && dialogProvider
? S.models.vendorProtocolHint(dialogProvider.label)
: S.models.addProtocolHint}
</p>
{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. */}
<Input
size="sm"
label={S.models.baseUrl}
required={baseUrlRequired}
value={form.baseUrl}
disabled={!canEdit}
onChange={(e) => 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). */}
<label className="block">
<FieldLabel required={baseUrlRequired}>{S.models.baseUrl}</FieldLabel>
<span className="relative block">
<Input
size="sm"
required={baseUrlRequired}
value={form.baseUrl}
disabled={!canEdit}
invalid={Boolean(fieldErrors.baseUrl)}
onChange={(e) => set({ baseUrl: e.target.value })}
className="font-mono"
// Reserve room so the typed URL never slides under the suffix. Input and
// suffix share the same monospace size, so the suffix width is exactly its
// character count in ch (plus the right offset and a small gap).
style={{ paddingRight: `calc(${protocolPath.length}ch + 1.25rem)` }}
// The suffix itself is hover-transparent (pointer-events-none), so the
// explanation rides on the input's title.
title={S.models.baseUrlSuffixTitle}
placeholder={preset ? S.models.baseUrlHint : "https://…"}
/>
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center font-mono text-xs text-gray-400">
{protocolPath}
</span>
</span>
{fieldErrors.baseUrl && <FieldError>{fieldErrors.baseUrl}</FieldError>}
</label>
{/* 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
@@ -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/<id>:… —
* 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";
}
}
+5 -7
View File
@@ -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",
+8 -8
View File
@@ -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: "删除模型",
+61
View File
@@ -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/<id>:…, 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");
});
});