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:
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "删除模型",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user