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 */}
-
-
- {dict.modelConfig.apiKey}
- {provider === "ollama" &&
- ` ${dict.modelConfig.optional}`}
-
+
+
+
+ {dict.modelConfig.apiKey}
+ {provider === "ollama" &&
+ ` ${dict.modelConfig.optional}`}
+
+ {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()
+ })
+})