mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-07 18:27:47 +08:00
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
This commit is contained in:
@@ -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 */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="api-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.apiKey}
|
||||
{provider === "ollama" &&
|
||||
` ${dict.modelConfig.optional}`}
|
||||
</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="api-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.apiKey}
|
||||
{provider === "ollama" &&
|
||||
` ${dict.modelConfig.optional}`}
|
||||
</Label>
|
||||
{info.apiKeyUrl && (
|
||||
<a
|
||||
href={info.apiKeyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{dict.modelConfig.getApiKey}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{renderSecret({ field: "apiKey", id: "api-key" })}
|
||||
</div>
|
||||
|
||||
@@ -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 && (
|
||||
<p className="text-xs text-muted-foreground font-mono break-all">
|
||||
{formatMessage(dict.modelConfig.requestUrl, {
|
||||
url: requestUrl,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{provider === "minimax" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.minimaxBaseUrlHint}
|
||||
|
||||
+13
-2
@@ -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(
|
||||
* <NAME>_API_KEY / <NAME>_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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -323,6 +323,8 @@
|
||||
"enterSecretKey": "シークレットアクセスキーを入力",
|
||||
"baseUrl": "ベース URL",
|
||||
"optional": "(オプション)",
|
||||
"getApiKey": "API キーを取得",
|
||||
"requestUrl": "リクエスト先: {url}",
|
||||
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
|
||||
"customEndpoint": "カスタムエンドポイント URL",
|
||||
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -122,22 +122,25 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
|
||||
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`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user