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:
1 parent
864261eade
commit
22303d5f71
5 files changed
+166
-34
No files matched your search
@@ -71,6 +71,7 @@ import {
|
|||||||
} from "@prismshadow/penguin-core/model-catalog";
|
} from "@prismshadow/penguin-core/model-catalog";
|
||||||
import type { ModelProviderInfo } from "@prismshadow/penguin-core/model-catalog";
|
import type { ModelProviderInfo } from "@prismshadow/penguin-core/model-catalog";
|
||||||
import { groupModelRows, isFreeModel, sameModelRef, userProviderInfo } from "./model-grouping";
|
import { groupModelRows, isFreeModel, sameModelRef, userProviderInfo } from "./model-grouping";
|
||||||
|
import { protocolPathForModel } from "./protocol-path";
|
||||||
import {
|
import {
|
||||||
isGroupExpanded,
|
isGroupExpanded,
|
||||||
loadExpandedProviders,
|
loadExpandedProviders,
|
||||||
@@ -1167,6 +1168,11 @@ function ModelDialog({
|
|||||||
form.provider === "custom" ||
|
form.provider === "custom" ||
|
||||||
providerInfo(form.provider) === undefined;
|
providerInfo(form.provider) === undefined;
|
||||||
const baseUrlRequired = !preset && openAiLike;
|
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 validated = (): RowState | null => {
|
||||||
const modelId = form.modelId.trim();
|
const modelId = form.modelId.trim();
|
||||||
@@ -1457,14 +1463,18 @@ function ModelDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Adding a model: protocol note first (first-party provider group = auto-route
|
{/* Adding a model: protocol note first (preset direct-vendor group = only the
|
||||||
by id; custom / self-defined group / gateway = fixed OpenAI protocol), then
|
vendor's official protocol, named via the group label — the in-field suffix
|
||||||
the identity fields ("get model id / API key" links next to the respective
|
on the base URL below says which path; custom / self-defined group / gateway
|
||||||
inputs; fill in the id to test connectivity — verify before saving). */}
|
= 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 && (
|
{isNew && (
|
||||||
<>
|
<>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<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>
|
</p>
|
||||||
{identityFields}
|
{identityFields}
|
||||||
</>
|
</>
|
||||||
@@ -1562,20 +1572,37 @@ function ModelDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 2) base URL (required for custom / user-defined groups and explicit openai protocol — see
|
{/* 2) base URL (required for custom / user-defined groups and explicit openai protocol — see
|
||||||
baseUrlRequired). Official-protocol entries (everything except the OpenAI-protocol path)
|
baseUrlRequired). The grey in-field suffix shows the protocol path the client appends
|
||||||
carry a caution: a custom endpoint must still speak the vendor's official protocol. */}
|
to the base URL — the endpoint shape a custom URL must serve; it renders for every
|
||||||
<Input
|
model and stays while the field is empty (hints the shape before typing). Reuses the
|
||||||
size="sm"
|
unit-adornment idiom of the context window / max tokens fields below; the error text
|
||||||
label={S.models.baseUrl}
|
sits outside the relative wrapper (see Input.invalid). */}
|
||||||
required={baseUrlRequired}
|
<label className="block">
|
||||||
value={form.baseUrl}
|
<FieldLabel required={baseUrlRequired}>{S.models.baseUrl}</FieldLabel>
|
||||||
disabled={!canEdit}
|
<span className="relative block">
|
||||||
onChange={(e) => set({ baseUrl: e.target.value })}
|
<Input
|
||||||
className="font-mono"
|
size="sm"
|
||||||
placeholder={preset ? S.models.baseUrlHint : "https://…"}
|
required={baseUrlRequired}
|
||||||
{...(fieldErrors.baseUrl ? { error: fieldErrors.baseUrl } : {})}
|
value={form.baseUrl}
|
||||||
{...(!openAiLike ? { hint: S.models.baseUrlOfficialNote } : {})}
|
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
|
{/* 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
|
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)",
|
addTitle: "Add model (OpenAI protocol)",
|
||||||
addTitleVendor: "Add model",
|
addTitleVendor: "Add model",
|
||||||
addProtocolHint:
|
addProtocolHint:
|
||||||
"New models always use the OpenAI Chat Completions protocol (no auto-routing by model id); set the base URL to a compatible endpoint",
|
"New models use the OpenAI Chat Completions protocol; set the base URL to a compatible endpoint",
|
||||||
addAutoRouteHint:
|
vendorProtocolHint: (vendor: string): string =>
|
||||||
"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",
|
`Only ${vendor}'s official API protocol is supported; use a custom model group for OpenAI-compatible endpoints.`,
|
||||||
/** 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",
|
|
||||||
autoRouteNone:
|
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",
|
addGroup: "Add group",
|
||||||
addGroupTitle: "Add group",
|
addGroupTitle: "Add group",
|
||||||
addGroupDesc:
|
addGroupDesc:
|
||||||
@@ -431,6 +428,7 @@ export const en: Strings = {
|
|||||||
clearApiKey: "Clear stored API key",
|
clearApiKey: "Clear stored API key",
|
||||||
baseUrl: "Custom base URL",
|
baseUrl: "Custom base URL",
|
||||||
baseUrlHint: "Leave empty to use the provider default",
|
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",
|
baseUrlRequired: "A base URL is required",
|
||||||
contextWindowDefaultHint: (n: number): string => `Defaults to ${n} if empty`,
|
contextWindowDefaultHint: (n: number): string => `Defaults to ${n} if empty`,
|
||||||
confirmDeleteTitle: "Delete model",
|
confirmDeleteTitle: "Delete model",
|
||||||
|
|||||||
@@ -331,15 +331,13 @@ export const zh = {
|
|||||||
editTitle: "模型配置",
|
editTitle: "模型配置",
|
||||||
addTitle: "新增模型(OpenAI 协议)",
|
addTitle: "新增模型(OpenAI 协议)",
|
||||||
addTitleVendor: "新增模型",
|
addTitleVendor: "新增模型",
|
||||||
addProtocolHint:
|
addProtocolHint: "新增模型走 OpenAI Chat Completions 兼容协议,base URL 填其兼容端点",
|
||||||
"新增模型固定走 OpenAI Chat Completions 兼容协议(不按模型 id 自动路由),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. */
|
||||||
addAutoRouteHint:
|
vendorProtocolHint: (vendor: string): string =>
|
||||||
"该分组的新模型按上游 id 由 AgentHub 自动路由到厂商官方客户端:base URL 留空即官方端点,API key 留空按解析出的客户端读取环境变量",
|
`仅支持 ${vendor} 官方接口协议,OpenAI 兼容接口请使用自定义模型分组`,
|
||||||
/** Caution beside the base URL when the entry uses a vendor's official protocol (everything except the OpenAI-protocol path). */
|
/** Non-blocking warning under the model id (preset direct-vendor groups, adding): the typed id is not a recognized official model id. */
|
||||||
baseUrlOfficialNote:
|
|
||||||
"注意:该模型走厂商官方协议——自定义 base URL 必须是兼容官方协议的端点,不会因此切换为 OpenAI 协议",
|
|
||||||
autoRouteNone:
|
autoRouteNone:
|
||||||
"该 id 无法被 AgentHub 自动路由:请核对 id,或改在 Custom / 自建分组下以 OpenAI 协议接入",
|
"该 id 不是可识别的官方模型 id:请核对,或改在 Custom / 自建分组以 OpenAI 兼容接口接入",
|
||||||
addGroup: "新增分组",
|
addGroup: "新增分组",
|
||||||
addGroupTitle: "新增分组",
|
addGroupTitle: "新增分组",
|
||||||
addGroupDesc:
|
addGroupDesc:
|
||||||
@@ -411,6 +409,8 @@ export const zh = {
|
|||||||
clearApiKey: "清除已存 API key",
|
clearApiKey: "清除已存 API key",
|
||||||
baseUrl: "自定义 base URL",
|
baseUrl: "自定义 base URL",
|
||||||
baseUrlHint: "留空使用厂商默认地址",
|
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",
|
baseUrlRequired: "必须填写 base URL",
|
||||||
contextWindowDefaultHint: (n: number): string => `留空按 ${n} 计`,
|
contextWindowDefaultHint: (n: number): string => `留空按 ${n} 计`,
|
||||||
confirmDeleteTitle: "删除模型",
|
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