Files
next-ai-draw-io/app/api/validate-model/route.ts
T
dayuan.jiang d5f31cb253 fix(server): use the keys the user sent, and more review fixes
Found by the second PR review:
- With AWS_BEARER_TOKEN_BEDROCK set on the server, a request with the
  user's AWS keys ran on the server's token: the Bedrock SDK prefers it.
  Checked with Bedrock: invalid user keys used to get an answer.
- An OpenAI key with the official URL filled in (the settings form does
  that) went to the Responses API. Back to main's rule: a configured base
  URL uses Chat Completions.
- A user's Ollama key went to the server's OLLAMA_BASE_URL, for chat and
  for the model list. Like every other provider, it goes to the user's
  base URL or Ollama Cloud.
- The server's keyless Ollama and EdgeOne were not counted in the quota.
- AI_MODEL models on the server's keys ran on any provider with a server
  key, not only on AI_PROVIDER.
- A user's Azure key without a base URL used the server's resource name.
- The admin panel's Test button failed whenever access codes were set.
- DeepSeek's errors in the stream (plain text) were shown as they were,
  without a hint and also on the server's keys. Bedrock's throttling in
  the stream was not recognised as a rate limit.
- The EdgeOne function accepted text/plain; x=application/json, which
  other sites can send without a CORS preflight.
- Desktop app: a launch that found the old port taken for a moment (the
  previous version still quitting after an update) remembered the new
  port for good. The new port is kept only when Windows reserves the old
  one. A failed read of the presets file moved it aside as corrupt, and a
  save could then replace the presets. Switching presets on the same port
  now reloads the page. The dev launcher no longer misses a preset change
  made before or during a restart.
2026-10-05 10:52:37 +09:00

184 lines
6.3 KiB
TypeScript

import { streamText, tool } from "ai"
import { NextResponse } from "next/server"
import { z } from "zod"
import { checkAccessCode } from "@/lib/access-code"
import { checkAdminAuth } from "@/lib/admin/auth"
import { getAIModel, usesServerCredentials } from "@/lib/ai-providers"
import { classifyLLMError } from "@/lib/llm-errors"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
import type { ProviderName } from "@/lib/types/model-config"
export const runtime = "nodejs"
interface ValidateRequest {
provider: ProviderName
apiKey: string
baseUrl?: string
modelId: string
// AWS Bedrock specific
awsAccessKeyId?: string
awsSecretAccessKey?: string
awsRegion?: string
// Vertex AI specific
vertexApiKey?: string // Express Mode API key
}
const TEST_TIMEOUT_MS = 15_000
// Drawing works through tool calls, so the test asks for one
const PING_TOOL = tool({
description: "Report that the connection works.",
inputSchema: z.object({}),
})
const NO_TOOL_CALL_WARNING =
"Connected, but the model answered without calling a tool. It may not support tool calls, which drawing needs."
export async function POST(req: Request) {
// Lets the server send requests to arbitrary URLs, so require the access
// code, or the admin password (the admin panel's Test button)
const accessError = checkAccessCode(req)
if (accessError && checkAdminAuth(req)) return accessError
try {
const body: ValidateRequest = await req.json()
const {
provider,
apiKey,
baseUrl,
modelId,
awsAccessKeyId,
awsSecretAccessKey,
awsRegion,
// Note: Express Mode only needs vertexApiKey
vertexApiKey,
} = body
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 },
)
}
// The Test button checks the user's own provider. On the server's
// keys (Ollama Cloud without a key or URL) anyone could run any model.
if (
usesServerCredentials(provider, {
apiKey,
baseUrl,
awsAccessKeyId,
awsSecretAccessKey,
vertexApiKey,
})
) {
return NextResponse.json(
{ valid: false, error: "API key is required" },
{ status: 400 },
)
}
// The same model the chat would use. A client base URL makes it
// refuse redirects to internal hosts.
const { model } = getAIModel({
provider,
modelId,
apiKey,
baseUrl,
awsAccessKeyId,
awsSecretAccessKey,
awsRegion,
vertexApiKey,
// EdgeOne checks the Pages cookies and the access code
...(provider === "edgeone" && {
headers: {
cookie: req.headers.get("cookie") || "",
"x-access-code": req.headers.get("x-access-code") || "",
},
}),
})
// Streaming, like the chat (some models only stream). Stop at the
// first tool call; a reasoning model that runs out of tokens first
// proves the connection but not tool support.
const startTime = Date.now()
const result = streamText({
model,
prompt: "Call the ping tool.",
tools: { ping: PING_TOOL },
maxOutputTokens: 1024,
maxRetries: 0,
abortSignal: AbortSignal.timeout(TEST_TIMEOUT_MS),
})
let calledTool = false
let finishReason: string | undefined
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
// The timeout ends the stream with an abort part, not an error
if (part.type === "abort") {
const timeout = new Error(
`The model did not answer within ${TEST_TIMEOUT_MS / 1000} s.`,
)
timeout.name = "TimeoutError"
throw timeout
}
if (part.type === "tool-call") {
calledTool = true
break
}
if (part.type === "finish") finishReason = part.finishReason
}
const responseTime = Date.now() - startTime
return NextResponse.json({
valid: true,
responseTime,
...(!calledTool &&
finishReason !== "length" && { warning: NO_TOOL_CALL_WARNING }),
})
} catch (error) {
console.error("[validate-model] Error:", error)
const { code, message } = classifyLLMError(error)
return NextResponse.json(
{ valid: false, code, error: message },
{ status: 200 }, // Return 200 so client can read error message
)
}
}