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:
dayuan.jiang
2026-10-04 13:37:39 +09:00
parent c24aae6de0
commit 99890e9e37
10 changed files with 212 additions and 15 deletions
+13 -2
View File
@@ -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.
+2
View File
@@ -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",
+2
View File
@@ -323,6 +323,8 @@
"enterSecretKey": "シークレットアクセスキーを入力",
"baseUrl": "ベース URL",
"optional": "(オプション)",
"getApiKey": "API キーを取得",
"requestUrl": "リクエスト先: {url}",
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
"customEndpoint": "カスタムエンドポイント URL",
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用",
+2
View File
@@ -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",
+2
View File
@@ -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",
+58 -2
View File
@@ -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`
}