From 99890e9e377c25e5f99a1b7b4de135ecf2ec65da Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 4 Oct 2026 13:37:29 +0900 Subject: [PATCH] feat(settings): link to each provider's key page and clean up base URLs - A "Get API key" link next to the API Key field for the 19 providers that have a key page (from env.example and the providers' docs). 17 answered 200 to curl; OpenAI's is behind a Cloudflare challenge and DeepSeek's behind a regional block, both checked in Chrome - Base URLs drop spaces, trailing slashes and a pasted endpoint path (/chat/completions, /completions, /messages, /responses), which the SDK would otherwise append a second time and get a 404. getAIModel does this for the chat and the Test button; the field does it on blur and shows the URL requests go to --- components/provider-credentials-fields.tsx | 56 ++++++++++++++++---- lib/ai-providers.ts | 15 +++++- lib/i18n/dictionaries/en.json | 2 + lib/i18n/dictionaries/ja.json | 2 + lib/i18n/dictionaries/zh-Hant.json | 2 + lib/i18n/dictionaries/zh.json | 2 + lib/types/model-config.ts | 60 +++++++++++++++++++++- tests/e2e/model-test.spec.ts | 31 +++++++++++ tests/unit/ai-providers.test.ts | 15 ++++++ tests/unit/base-url.test.ts | 42 +++++++++++++++ 10 files changed, 212 insertions(+), 15 deletions(-) create mode 100644 tests/unit/base-url.test.ts diff --git a/components/provider-credentials-fields.tsx b/components/provider-credentials-fields.tsx index 8a9bf0d1..ea3e0e60 100644 --- a/components/provider-credentials-fields.tsx +++ b/components/provider-credentials-fields.tsx @@ -13,7 +13,12 @@ import { } from "@/components/ui/select" import { useDictionary } from "@/hooks/use-dictionary" import { formatMessage } from "@/lib/i18n/utils" -import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config" +import { + chatRequestUrl, + normalizeBaseUrl, + PROVIDER_INFO, + type ProviderName, +} from "@/lib/types/model-config" // Logical secret field. The caller owns the actual input — plaintext for the // user dialog, write-only masked for the admin panel — supplied via @@ -77,6 +82,7 @@ export function ProviderCredentialsFields({ const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, { example: info.defaultBaseUrl || "https://api.example.com/v1", }) + const requestUrl = baseUrl ? chatRequestUrl(provider, baseUrl) : null // EdgeOne needs no credentials — the caller supplies just a test button if (provider === "edgeone") { @@ -208,17 +214,29 @@ export function ProviderCredentialsFields({ ) : ( <> - {/* API Key */} + {/* API Key, with a link to where the provider issues keys */}
- +
+ + {info.apiKeyUrl && ( + + {dict.modelConfig.getApiKey} + + )} +
{renderSecret({ field: "apiKey", id: "api-key" })}
@@ -238,12 +256,28 @@ export function ProviderCredentialsFields({ onChange={(e) => onChange("baseUrl", e.target.value) } + // Drop a pasted endpoint path such as /chat/completions + onBlur={(e) => { + const normalized = normalizeBaseUrl( + e.target.value, + ) + if (normalized !== e.target.value) { + onChange("baseUrl", normalized) + } + }} placeholder={ info.defaultBaseUrl || dict.modelConfig.customEndpoint } className="h-9 rounded-xl font-mono text-xs" /> + {requestUrl && ( +

+ {formatMessage(dict.modelConfig.requestUrl, { + url: requestUrl, + })} +

+ )} {provider === "minimax" && (

{dict.modelConfig.minimaxBaseUrlHint} diff --git a/lib/ai-providers.ts b/lib/ai-providers.ts index b74bdc44..1187f145 100644 --- a/lib/ai-providers.ts +++ b/lib/ai-providers.ts @@ -22,7 +22,11 @@ import { loadAdminProviders, } from "@/lib/admin/providers" import { redirectGuardedFetch } from "@/lib/ssrf-protection" -import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config" +import { + normalizeBaseUrl, + PROVIDER_INFO, + type ProviderName, +} from "@/lib/types/model-config" export type { ProviderName } @@ -766,7 +770,14 @@ function createModel( * _API_KEY / _BASE_URL, see env.example). The settings test * button uses the same function, so a passing test means the chat works. */ -export function getAIModel(overrides?: ClientOverrides): ModelConfig { +export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig { + // Drop an endpoint path pasted along with the client's base URL + const overrides = clientOverrides?.baseUrl + ? { + ...clientOverrides, + baseUrl: normalizeBaseUrl(clientOverrides.baseUrl), + } + : clientOverrides // SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm) // If a custom baseUrl is provided, an API key MUST also be provided. // This prevents attackers from redirecting server API keys to malicious endpoints. diff --git a/lib/i18n/dictionaries/en.json b/lib/i18n/dictionaries/en.json index ace8cda0..1e4f0e7f 100644 --- a/lib/i18n/dictionaries/en.json +++ b/lib/i18n/dictionaries/en.json @@ -369,6 +369,8 @@ "enterSecretKey": "Enter your secret access key", "baseUrl": "Base URL", "optional": "(optional)", + "getApiKey": "Get API key", + "requestUrl": "Requests go to {url}", "baseUrlWithExample": "Base URL (optional, e.g. {example})", "customEndpoint": "Custom endpoint URL", "minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API", diff --git a/lib/i18n/dictionaries/ja.json b/lib/i18n/dictionaries/ja.json index 214892cf..6fc0ba52 100644 --- a/lib/i18n/dictionaries/ja.json +++ b/lib/i18n/dictionaries/ja.json @@ -323,6 +323,8 @@ "enterSecretKey": "シークレットアクセスキーを入力", "baseUrl": "ベース URL", "optional": "(オプション)", + "getApiKey": "API キーを取得", + "requestUrl": "リクエスト先: {url}", "baseUrlWithExample": "ベース URL(オプション、例: {example})", "customEndpoint": "カスタムエンドポイント URL", "minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用", diff --git a/lib/i18n/dictionaries/zh-Hant.json b/lib/i18n/dictionaries/zh-Hant.json index e4e12602..9412ee04 100644 --- a/lib/i18n/dictionaries/zh-Hant.json +++ b/lib/i18n/dictionaries/zh-Hant.json @@ -369,6 +369,8 @@ "enterSecretKey": "輸入您的 Secret Key", "baseUrl": "基礎 URL", "optional": "(可選)", + "getApiKey": "取得 API Key", + "requestUrl": "請求將傳送至 {url}", "baseUrlWithExample": "基礎 URL(可選,例如 {example})", "customEndpoint": "自訂端點 URL", "minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API", diff --git a/lib/i18n/dictionaries/zh.json b/lib/i18n/dictionaries/zh.json index 56d322f2..abdf9f64 100644 --- a/lib/i18n/dictionaries/zh.json +++ b/lib/i18n/dictionaries/zh.json @@ -369,6 +369,8 @@ "enterSecretKey": "输入您的 Secret Key", "baseUrl": "基础 URL", "optional": "(可选)", + "getApiKey": "获取 API Key", + "requestUrl": "请求将发往 {url}", "baseUrlWithExample": "基础 URL(可选,例如 {example})", "customEndpoint": "自定义端点 URL", "minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API", diff --git a/lib/types/model-config.ts b/lib/types/model-config.ts index 3c2e44e0..4586b83a 100644 --- a/lib/types/model-config.ts +++ b/lib/types/model-config.ts @@ -122,22 +122,25 @@ export const PROVIDER_LOGO_MAP: Record = { atlascloud: "openai", } -// Provider metadata +// Provider metadata. apiKeyUrl is the page where users create a key. export const PROVIDER_INFO: Record< ProviderName, - { label: string; defaultBaseUrl?: string } + { label: string; defaultBaseUrl?: string; apiKeyUrl?: string } > = { openai: { label: "OpenAI", defaultBaseUrl: "https://api.openai.com/v1", + apiKeyUrl: "https://platform.openai.com/api-keys", }, anthropic: { label: "Anthropic", defaultBaseUrl: "https://api.anthropic.com/v1", + apiKeyUrl: "https://platform.claude.com/settings/keys", }, google: { label: "Google", defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta", + apiKeyUrl: "https://aistudio.google.com/apikey", }, vertexai: { label: "Google Vertex AI" }, azure: { @@ -148,22 +151,27 @@ export const PROVIDER_INFO: Record< ollama: { label: "Ollama", defaultBaseUrl: "https://ollama.com/api", + apiKeyUrl: "https://ollama.com/settings/keys", }, openrouter: { label: "OpenRouter", defaultBaseUrl: "https://openrouter.ai/api/v1", + apiKeyUrl: "https://openrouter.ai/keys", }, aihubmix: { label: "AIHubMix", defaultBaseUrl: "https://aihubmix.com/v1", + apiKeyUrl: "https://aihubmix.com/token", }, deepseek: { label: "DeepSeek", defaultBaseUrl: "https://api.deepseek.com/v1", + apiKeyUrl: "https://platform.deepseek.com/api_keys", }, siliconflow: { label: "SiliconFlow", defaultBaseUrl: "https://api.siliconflow.cn/v1", + apiKeyUrl: "https://cloud.siliconflow.cn/account/ak", }, sglang: { label: "SGLang", @@ -172,47 +180,60 @@ export const PROVIDER_INFO: Record< gateway: { label: "AI Gateway", defaultBaseUrl: "https://ai-gateway.vercel.sh/v1/ai", + apiKeyUrl: "https://vercel.com/ai-gateway", }, edgeone: { label: "EdgeOne Pages" }, doubao: { label: "Doubao (ByteDance)", defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3", + apiKeyUrl: + "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey", }, modelscope: { label: "ModelScope", defaultBaseUrl: "https://api-inference.modelscope.cn/v1", + apiKeyUrl: "https://modelscope.cn/my/myaccesstoken", }, glm: { label: "GLM (Zhipu)", defaultBaseUrl: "https://open.bigmodel.cn/api/paas/v4", + apiKeyUrl: "https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys", }, qwen: { label: "Qwen (Alibaba)", defaultBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + apiKeyUrl: "https://bailian.console.aliyun.com/?tab=model#/api-key", }, qiniu: { label: "Qiniu", defaultBaseUrl: "https://api.qnaigc.com/v1", + apiKeyUrl: "https://www.qiniu.com/ai/models", }, kimi: { label: "Kimi (Moonshot)", defaultBaseUrl: "https://api.moonshot.cn/v1", + apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", }, minimax: { label: "MiniMax", defaultBaseUrl: "https://api.minimaxi.com/anthropic", + apiKeyUrl: + "https://platform.minimaxi.com/user-center/basic-information/interface-key", }, novita: { label: "Novita AI", defaultBaseUrl: "https://api.novita.ai/openai", + apiKeyUrl: "https://novita.ai/dashboard/key", }, mimo: { label: "MiMo (Xiaomi)", defaultBaseUrl: "https://api.xiaomimimo.com/v1", + apiKeyUrl: "https://platform.xiaomimimo.com/#/console/api-keys", }, atlascloud: { label: "Atlas Cloud", defaultBaseUrl: "https://api.atlascloud.ai/v1", + apiKeyUrl: "https://www.atlascloud.ai/console/api-keys", }, } @@ -531,3 +552,38 @@ export function findModelById( ): FlattenedModel | undefined { return flattenModels(config).find((m) => m.id === modelId) } + +/** + * A base URL the way the SDKs expect it: no spaces, no trailing slash, and + * no endpoint path users often paste along (".../v1/chat/completions"), + * which the SDK would append a second time. + */ +export function normalizeBaseUrl(url: string): string { + return url + .trim() + .replace(/\/+$/, "") + .replace(/\/(?:chat\/completions|completions|messages|responses)$/, "") +} + +/** Where a chat request goes for a base URL, or null when the SDK decides */ +export function chatRequestUrl( + provider: ProviderName, + baseUrl: string, +): string | null { + const url = normalizeBaseUrl(baseUrl) + if (!url) return null + if (provider === "anthropic") return `${url}/messages` + // These SDKs build their own paths (or, for MiniMax, pick the protocol + // from the URL) + const ownPaths: ProviderName[] = [ + "google", + "vertexai", + "azure", + "bedrock", + "ollama", + "gateway", + "minimax", + "edgeone", + ] + return ownPaths.includes(provider) ? null : `${url}/chat/completions` +} diff --git a/tests/e2e/model-test.spec.ts b/tests/e2e/model-test.spec.ts index cf3101a8..2a226846 100644 --- a/tests/e2e/model-test.spec.ts +++ b/tests/e2e/model-test.spec.ts @@ -65,3 +65,34 @@ test("the Test button checks all models at once and shows each result", async ({ "model-ok", ]) }) + +test("the key link and the base URL cleanup", async ({ page }) => { + await page.addInitScript((config) => { + localStorage.setItem( + "next-ai-draw-io-model-configs", + JSON.stringify(config), + ) + }, CONFIG) + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + await page.locator("button:has(svg.lucide-bot)").first().click() + await page.getByText("Configure Models...").click() + const dialog = page.locator('[role="dialog"]') + await dialog.getByText("GLM (Zhipu)").first().click() + + await expect( + dialog.getByRole("link", { name: "Get API key" }), + ).toHaveAttribute( + "href", + "https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys", + ) + const baseUrl = dialog.locator("#base-url") + await baseUrl.fill("https://proxy.example.com/v4/chat/completions/") + await baseUrl.blur() + await expect(baseUrl).toHaveValue("https://proxy.example.com/v4") + await expect( + dialog.getByText( + "Requests go to https://proxy.example.com/v4/chat/completions", + ), + ).toBeVisible() +}) diff --git a/tests/unit/ai-providers.test.ts b/tests/unit/ai-providers.test.ts index 78fa29b7..769b57d9 100644 --- a/tests/unit/ai-providers.test.ts +++ b/tests/unit/ai-providers.test.ts @@ -332,6 +332,21 @@ describe("Atlas Cloud provider", () => { includeUsage: true, }) }) + + it("drops an endpoint path pasted along with the base URL", () => { + getAIModel({ + provider: "atlascloud", + apiKey: "client-atlas-key", + baseUrl: "https://proxy.example.com/v1/chat/completions/", + modelId: "deepseek-ai/deepseek-v4-pro", + }) + + expect(createCompatibleMock).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "https://proxy.example.com/v1", + }), + ) + }) }) describe("Kimi provider uses createDeepSeek for reasoning_content support", () => { diff --git a/tests/unit/base-url.test.ts b/tests/unit/base-url.test.ts new file mode 100644 index 00000000..67742fe8 --- /dev/null +++ b/tests/unit/base-url.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest" +import { chatRequestUrl, normalizeBaseUrl } from "@/lib/types/model-config" + +describe("normalizeBaseUrl", () => { + it("drops spaces, trailing slashes and a pasted endpoint path", () => { + expect(normalizeBaseUrl(" https://api.x.com/v1/ ")).toBe( + "https://api.x.com/v1", + ) + expect(normalizeBaseUrl("https://api.x.com/v1/chat/completions")).toBe( + "https://api.x.com/v1", + ) + expect( + normalizeBaseUrl("https://api.x.com/anthropic/v1/messages/"), + ).toBe("https://api.x.com/anthropic/v1") + }) + + it("keeps provider paths such as /api/paas/v4 and /api/v3", () => { + expect(normalizeBaseUrl("https://open.bigmodel.cn/api/paas/v4")).toBe( + "https://open.bigmodel.cn/api/paas/v4", + ) + expect( + normalizeBaseUrl("https://ark.cn-beijing.volces.com/api/v3"), + ).toBe("https://ark.cn-beijing.volces.com/api/v3") + }) +}) + +describe("chatRequestUrl", () => { + it("shows the endpoint the SDK will call", () => { + expect(chatRequestUrl("glm", "https://api.x.com/v1/")).toBe( + "https://api.x.com/v1/chat/completions", + ) + expect( + chatRequestUrl("anthropic", "https://proxy.example.com/v1"), + ).toBe("https://proxy.example.com/v1/messages") + }) + + it("stays out of the way for SDKs that build their own paths", () => { + expect(chatRequestUrl("google", "https://x.example.com")).toBeNull() + expect(chatRequestUrl("minimax", "https://x.example.com")).toBeNull() + expect(chatRequestUrl("glm", " ")).toBeNull() + }) +})