mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-07 10:17:47 +08:00
The unauthenticated /api/validate-model endpoint should not let callers raise the token budget; the env var / admin setting alone fixes #883. Also import the shared constants in the settings registry instead of duplicating them, and move the setting to the Features group alongside the other validation settings.
462 lines
16 KiB
TypeScript
462 lines
16 KiB
TypeScript
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
|
|
import { createAnthropic } from "@ai-sdk/anthropic"
|
|
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
|
import { createGateway } from "@ai-sdk/gateway"
|
|
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
|
import { createVertex } from "@ai-sdk/google-vertex"
|
|
import { createOpenAI } from "@ai-sdk/openai"
|
|
import { createAihubmix } from "@aihubmix/ai-sdk-provider"
|
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
|
import { generateText } from "ai"
|
|
import { NextResponse } from "next/server"
|
|
import { createOllama } from "ollama-ai-provider-v2"
|
|
import {
|
|
AIHUBMIX_APP_CODE,
|
|
isAihubmixStandardBaseURL,
|
|
normalizeMiniMaxBaseURL,
|
|
} from "@/lib/ai-providers"
|
|
import { resolveModelValidationMaxTokens } from "@/lib/model-validation"
|
|
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
|
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
|
|
|
export const runtime = "nodejs"
|
|
|
|
interface ValidateRequest {
|
|
provider: string
|
|
apiKey: string
|
|
baseUrl?: string
|
|
modelId: string
|
|
// AWS Bedrock specific
|
|
awsAccessKeyId?: string
|
|
awsSecretAccessKey?: string
|
|
awsRegion?: string
|
|
// Vertex AI specific
|
|
vertexApiKey?: string // Express Mode API key
|
|
}
|
|
|
|
export async function POST(req: Request) {
|
|
try {
|
|
const body: ValidateRequest = await req.json()
|
|
const {
|
|
provider,
|
|
apiKey,
|
|
baseUrl,
|
|
modelId,
|
|
awsAccessKeyId,
|
|
awsSecretAccessKey,
|
|
awsRegion,
|
|
// Note: Express Mode only needs vertexApiKey
|
|
vertexApiKey,
|
|
} = body
|
|
const validationMaxTokens = resolveModelValidationMaxTokens(
|
|
process.env.MODEL_VALIDATION_MAX_TOKENS,
|
|
)
|
|
|
|
if (!provider || !modelId) {
|
|
return NextResponse.json(
|
|
{ valid: false, error: "Provider and model ID are required" },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
// SECURITY: Block SSRF attacks via custom baseUrl
|
|
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
|
return NextResponse.json(
|
|
{ valid: false, error: "Invalid base URL" },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
// Validate credentials based on provider
|
|
if (provider === "bedrock") {
|
|
if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) {
|
|
return NextResponse.json(
|
|
{
|
|
valid: false,
|
|
error: "AWS credentials (Access Key ID, Secret Access Key, Region) are required",
|
|
},
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
} else if (provider === "vertexai") {
|
|
if (!vertexApiKey) {
|
|
return NextResponse.json(
|
|
{
|
|
valid: false,
|
|
error: "Vertex AI API key is required for Express Mode",
|
|
},
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
|
|
return NextResponse.json(
|
|
{ valid: false, error: "API key is required" },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
let model: any
|
|
|
|
switch (provider) {
|
|
case "openai": {
|
|
const openai = createOpenAI({
|
|
apiKey,
|
|
...(baseUrl && { baseURL: baseUrl }),
|
|
})
|
|
model = openai.chat(modelId)
|
|
break
|
|
}
|
|
|
|
case "anthropic": {
|
|
const anthropic = createAnthropic({
|
|
apiKey,
|
|
baseURL: baseUrl || "https://api.anthropic.com/v1",
|
|
})
|
|
model = anthropic(modelId)
|
|
break
|
|
}
|
|
|
|
case "google": {
|
|
const google = createGoogleGenerativeAI({
|
|
apiKey,
|
|
...(baseUrl && { baseURL: baseUrl }),
|
|
})
|
|
model = google(modelId)
|
|
break
|
|
}
|
|
|
|
case "vertexai": {
|
|
const vertex = createVertex({
|
|
apiKey: vertexApiKey,
|
|
...(baseUrl && { baseURL: baseUrl }),
|
|
})
|
|
model = vertex(modelId)
|
|
break
|
|
}
|
|
|
|
case "azure": {
|
|
const azure = createOpenAI({
|
|
apiKey,
|
|
baseURL: baseUrl,
|
|
})
|
|
model = azure.chat(modelId)
|
|
break
|
|
}
|
|
|
|
case "bedrock": {
|
|
const bedrock = createAmazonBedrock({
|
|
accessKeyId: awsAccessKeyId,
|
|
secretAccessKey: awsSecretAccessKey,
|
|
region: awsRegion,
|
|
})
|
|
model = bedrock(modelId)
|
|
break
|
|
}
|
|
|
|
case "openrouter": {
|
|
const openrouter = createOpenRouter({
|
|
apiKey,
|
|
...(baseUrl && { baseURL: baseUrl }),
|
|
})
|
|
model = openrouter(modelId)
|
|
break
|
|
}
|
|
|
|
case "aihubmix": {
|
|
const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl
|
|
|
|
if (
|
|
isAihubmixStandardBaseURL(baseUrl) ||
|
|
baseUrl === defaultBaseURL
|
|
) {
|
|
const aihubmix = createAihubmix({
|
|
apiKey,
|
|
appCode: AIHUBMIX_APP_CODE,
|
|
})
|
|
model = aihubmix(modelId)
|
|
} else {
|
|
const aihubmixCompatible = createOpenAI({
|
|
apiKey,
|
|
baseURL: baseUrl,
|
|
})
|
|
model = aihubmixCompatible.chat(modelId)
|
|
}
|
|
break
|
|
}
|
|
|
|
case "deepseek": {
|
|
if (baseUrl || apiKey) {
|
|
const ds = createDeepSeek({
|
|
apiKey,
|
|
...(baseUrl && { baseURL: baseUrl }),
|
|
})
|
|
model = ds(modelId)
|
|
} else {
|
|
model = deepseek(modelId)
|
|
}
|
|
break
|
|
}
|
|
|
|
case "siliconflow": {
|
|
const sf = createOpenAI({
|
|
apiKey,
|
|
baseURL: baseUrl || "https://api.siliconflow.cn/v1",
|
|
})
|
|
model = sf.chat(modelId)
|
|
break
|
|
}
|
|
|
|
case "ollama": {
|
|
// SECURITY: Mirror ai-providers.ts guard — only use server
|
|
// OLLAMA_API_KEY when the URL is also from server config.
|
|
const ollamaApiKey = baseUrl
|
|
? apiKey || undefined
|
|
: apiKey || process.env.OLLAMA_API_KEY || undefined
|
|
const ollamaProvider = createOllama({
|
|
baseURL:
|
|
baseUrl ||
|
|
process.env.OLLAMA_BASE_URL ||
|
|
"https://ollama.com/api",
|
|
...(ollamaApiKey && {
|
|
headers: { Authorization: `Bearer ${ollamaApiKey}` },
|
|
}),
|
|
})
|
|
model = ollamaProvider(modelId)
|
|
break
|
|
}
|
|
|
|
case "gateway": {
|
|
const gw = createGateway({
|
|
apiKey,
|
|
...(baseUrl && { baseURL: baseUrl }),
|
|
})
|
|
model = gw(modelId)
|
|
break
|
|
}
|
|
|
|
case "edgeone": {
|
|
// EdgeOne uses OpenAI-compatible API via Edge Functions
|
|
// Need to pass cookies for EdgeOne Pages authentication
|
|
const cookieHeader = req.headers.get("cookie") || ""
|
|
const edgeone = createOpenAI({
|
|
apiKey: "edgeone", // EdgeOne doesn't require API key
|
|
baseURL: baseUrl || "/api/edgeai",
|
|
headers: {
|
|
cookie: cookieHeader,
|
|
},
|
|
})
|
|
model = edgeone.chat(modelId)
|
|
break
|
|
}
|
|
|
|
case "sglang": {
|
|
// SGLang is OpenAI-compatible
|
|
const sglang = createOpenAI({
|
|
apiKey: apiKey || "not-needed",
|
|
baseURL: baseUrl || "http://127.0.0.1:8000/v1",
|
|
})
|
|
model = sglang.chat(modelId)
|
|
break
|
|
}
|
|
|
|
case "doubao": {
|
|
// ByteDance Doubao: use DeepSeek for DeepSeek/Kimi models, OpenAI for others
|
|
const doubaoBaseUrl =
|
|
baseUrl || "https://ark.cn-beijing.volces.com/api/v3"
|
|
const lowerModelId = modelId.toLowerCase()
|
|
if (
|
|
lowerModelId.includes("deepseek") ||
|
|
lowerModelId.includes("kimi")
|
|
) {
|
|
const doubao = createDeepSeek({
|
|
apiKey,
|
|
baseURL: doubaoBaseUrl,
|
|
})
|
|
model = doubao(modelId)
|
|
} else {
|
|
const doubao = createOpenAI({
|
|
apiKey,
|
|
baseURL: doubaoBaseUrl,
|
|
})
|
|
model = doubao.chat(modelId)
|
|
}
|
|
break
|
|
}
|
|
|
|
case "modelscope": {
|
|
const baseURL =
|
|
baseUrl || "https://api-inference.modelscope.cn/v1"
|
|
const startTime = Date.now()
|
|
|
|
try {
|
|
// Initiate a streaming request (required for QwQ-32B and certain Qwen3 models)
|
|
const response = await fetch(
|
|
`${baseURL}/chat/completions`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: modelId,
|
|
messages: [
|
|
{ role: "user", content: "Say 'OK'" },
|
|
],
|
|
max_tokens: validationMaxTokens,
|
|
stream: true,
|
|
enable_thinking: false,
|
|
}),
|
|
},
|
|
)
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text()
|
|
throw new Error(
|
|
`ModelScope API error (${response.status}): ${errorText}`,
|
|
)
|
|
}
|
|
|
|
const contentType =
|
|
response.headers.get("content-type") || ""
|
|
const isValidStreamingResponse =
|
|
response.status === 200 &&
|
|
(contentType.includes("text/event-stream") ||
|
|
contentType.includes("application/json"))
|
|
|
|
if (!isValidStreamingResponse) {
|
|
throw new Error(
|
|
`Unexpected response format: ${contentType}`,
|
|
)
|
|
}
|
|
|
|
const responseTime = Date.now() - startTime
|
|
|
|
if (response.body) {
|
|
response.body.cancel().catch(() => {
|
|
/* Ignore cancellation errors */
|
|
})
|
|
}
|
|
|
|
return NextResponse.json({
|
|
valid: true,
|
|
responseTime,
|
|
note: "ModelScope model validated (using streaming API)",
|
|
})
|
|
} catch (error) {
|
|
console.error(
|
|
"[validate-model] ModelScope validation failed:",
|
|
error,
|
|
)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
case "minimax": {
|
|
const rawUrl =
|
|
baseUrl ||
|
|
PROVIDER_INFO.minimax?.defaultBaseUrl ||
|
|
"https://api.minimaxi.com/anthropic"
|
|
const { baseURL: minimaxBaseUrl, isAnthropicCompatible } =
|
|
normalizeMiniMaxBaseURL(rawUrl)
|
|
|
|
if (isAnthropicCompatible) {
|
|
const minimax = createAnthropic({
|
|
apiKey,
|
|
baseURL: minimaxBaseUrl,
|
|
})
|
|
model = minimax.chat(modelId)
|
|
} else {
|
|
const minimax = createOpenAI({
|
|
apiKey,
|
|
baseURL: minimaxBaseUrl,
|
|
})
|
|
model = minimax.chat(modelId)
|
|
}
|
|
break
|
|
}
|
|
|
|
// GLM, Qwen, Kimi, Qiniu, Novita - OpenAI compatible
|
|
case "glm":
|
|
case "qwen":
|
|
case "kimi":
|
|
case "qiniu":
|
|
case "novita": {
|
|
const baseURL =
|
|
baseUrl ||
|
|
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
|
|
""
|
|
|
|
if (!baseURL) {
|
|
return NextResponse.json(
|
|
{
|
|
valid: false,
|
|
error: `No base URL configured for provider: ${provider}`,
|
|
},
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
const openai = createOpenAI({
|
|
apiKey,
|
|
baseURL,
|
|
})
|
|
model = openai.chat(modelId)
|
|
break
|
|
}
|
|
|
|
default:
|
|
return NextResponse.json(
|
|
{ valid: false, error: `Unknown provider: ${provider}` },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
// Make a minimal test request
|
|
const startTime = Date.now()
|
|
await generateText({
|
|
model,
|
|
prompt: "Say 'OK'",
|
|
maxOutputTokens: validationMaxTokens,
|
|
})
|
|
const responseTime = Date.now() - startTime
|
|
|
|
return NextResponse.json({
|
|
valid: true,
|
|
responseTime,
|
|
})
|
|
} catch (error) {
|
|
console.error("[validate-model] Error:", error)
|
|
|
|
let errorMessage = "Validation failed"
|
|
if (error instanceof Error) {
|
|
// Extract meaningful error message
|
|
if (
|
|
error.message.includes("401") ||
|
|
error.message.includes("Unauthorized")
|
|
) {
|
|
errorMessage = "Invalid API key"
|
|
} else if (
|
|
error.message.includes("404") ||
|
|
error.message.includes("not found")
|
|
) {
|
|
errorMessage = "Model not found"
|
|
} else if (
|
|
error.message.includes("429") ||
|
|
error.message.includes("rate limit")
|
|
) {
|
|
errorMessage = "Rate limited - try again later"
|
|
} else if (error.message.includes("ECONNREFUSED")) {
|
|
errorMessage = "Cannot connect to server"
|
|
} else {
|
|
errorMessage = error.message.slice(0, 100)
|
|
}
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{ valid: false, error: errorMessage },
|
|
{ status: 200 }, // Return 200 so client can read error message
|
|
)
|
|
}
|
|
}
|