mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 20:49:53 +08:00
refactor(providers): one record per provider, one place for the key-and-URL decision (#988)
Every fact about a provider lives in one record in lib/providers.ts (SDK kind, credential kind, env var prefix, default URL, picker group, four-language notes, and a short model list for the six providers that cannot list models). The thirteen tables keyed by provider name are gone. resolveEndpoint decides whose key a call uses and where it goes; usesServerCredentials reads its answer instead of mirroring getAIModel's branches; serverCredentialError holds the server credential check once. The UI, the connection test and the routes read the record's credential and sdk fields instead of comparing provider names (44 such checks before, 6 after, in the admin panel's fixed-variable branches). Intended behavior changes: providers that can list their models have no hand-written list; the settings dialog fetches the live list in the background once credentials are entered for a provider without models and offers it behind "Browse models". Azure has no placeholder default URL (old entries that hold it still count as unset); the base URL field of Azure and of the new "custom" OpenAI-compatible preset (CUSTOM_API_KEY, CUSTOM_BASE_URL) is labelled required. The connection test answers 400 for an unknown provider name; prototype names are refused everywhere. Provider lists in error messages and the admin "add provider" dropdown follow the picker's order. Also fixed, from before the refactor: the redirect guard drops x-access-code on cross-origin redirects, and edgeOneEndpoint accepts the Origin header only when it matches Host or X-Forwarded-Host. Verification: a behavior snapshot of getAIModel's SDK factory calls and the quota functions over 25 providers, 22 client override shapes and 9 server environments (5843 rows), recorded on the old code and unchanged apart from the intended changes; 752 unit tests, 65 provider e2e tests, tsc, biome, next build. Reviewed by five models from one brief; their findings and the resulting fixes are recorded in docs/plans/2026-10-provider-refactor.md.
This commit is contained in:
@@ -33,14 +33,9 @@ import {
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import {
|
||||
FIXED_CRED_PROVIDERS,
|
||||
generateId,
|
||||
PROVIDER_INFO,
|
||||
type ProviderName,
|
||||
SUGGESTED_MODELS,
|
||||
} from "@/lib/types/model-config"
|
||||
import { generateId } from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
type AdminProvider,
|
||||
@@ -72,8 +67,10 @@ function ProviderDetail({
|
||||
Record<string, { ok: boolean; message: string }>
|
||||
>({})
|
||||
|
||||
const info = PROVIDER_INFO[provider.provider]
|
||||
const suggestions = (SUGGESTED_MODELS[provider.provider] || []).filter(
|
||||
const info = PROVIDERS[provider.provider]
|
||||
// Only providers that cannot list their models have a list here; for
|
||||
// the others the admin types the model id
|
||||
const suggestions = (info.models ?? []).filter(
|
||||
(m) => !provider.models.includes(m),
|
||||
)
|
||||
|
||||
@@ -500,7 +497,7 @@ export function ModelsSection({
|
||||
>
|
||||
<ProviderLogo provider={p.provider} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{p.name || PROVIDER_INFO[p.provider].label}
|
||||
{p.name || PROVIDERS[p.provider].label}
|
||||
</span>
|
||||
{p.isDefault && (
|
||||
<Star
|
||||
@@ -526,13 +523,13 @@ export function ModelsSection({
|
||||
{dict.modelConfig.addProvider}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{(Object.keys(PROVIDER_INFO) as ProviderName[]).map(
|
||||
{(Object.keys(PROVIDERS) as ProviderName[]).map(
|
||||
(p) => {
|
||||
// Global-credential providers already in
|
||||
// the env config can't be added here —
|
||||
// panel credentials would override theirs
|
||||
const envBlocked =
|
||||
FIXED_CRED_PROVIDERS.includes(p) &&
|
||||
!!PROVIDERS[p].fixedCredentials &&
|
||||
envProviders.some(
|
||||
(e) => e.provider === p,
|
||||
)
|
||||
@@ -544,7 +541,7 @@ export function ModelsSection({
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderLogo provider={p} />
|
||||
{PROVIDER_INFO[p].label}
|
||||
{PROVIDERS[p].label}
|
||||
{envBlocked && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{
|
||||
|
||||
+19
-15
@@ -16,7 +16,6 @@ import {
|
||||
edgeOneEndpoint,
|
||||
getAIModel,
|
||||
getServerProvider,
|
||||
SINGLE_SYSTEM_PROVIDERS,
|
||||
supportsPromptCaching,
|
||||
usesServerCredentials,
|
||||
usesServerEndpoint,
|
||||
@@ -47,6 +46,7 @@ import {
|
||||
resolveMaxOutputTokens,
|
||||
withOutputTokenLimitFallback,
|
||||
} from "@/lib/output-token-limit"
|
||||
import { PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
import {
|
||||
type FlattenedServerModel,
|
||||
findServerModelById,
|
||||
@@ -173,14 +173,17 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
// A server model's provider comes from its config: for one set up in
|
||||
// the admin panel the header holds the provider name's slug. Without
|
||||
// either, the server's own AI_PROVIDER.
|
||||
const isEdgeOne =
|
||||
(serverModelConfig.provider || provider || getServerProvider()) ===
|
||||
"edgeone"
|
||||
|
||||
// EdgeOne is this deployment's own function, whatever URL the request
|
||||
// names: another host would get the user's EdgeOne cookies, and the
|
||||
// quota counts it. Absolute, as the SDK needs.
|
||||
if (isEdgeOne) baseUrl = edgeOneEndpoint(req)
|
||||
const requestedProvider =
|
||||
serverModelConfig.provider || provider || getServerProvider()
|
||||
// A provider without credentials (EdgeOne) is this deployment's own
|
||||
// function, whatever URL the request names: another host would get the
|
||||
// user's EdgeOne cookies, and the quota counts it. Absolute, as the SDK
|
||||
// needs.
|
||||
const onSiteFunction =
|
||||
!!requestedProvider &&
|
||||
Object.hasOwn(PROVIDERS, requestedProvider) &&
|
||||
PROVIDERS[requestedProvider as ProviderName].credential === "none"
|
||||
if (onSiteFunction) baseUrl = edgeOneEndpoint(req)
|
||||
|
||||
// Same rule as validate-model: with ALLOW_PRIVATE_URLS=false a request may
|
||||
// not point the server at a private or internal address
|
||||
@@ -195,12 +198,13 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
const cookieHeader = req.headers.get("cookie")
|
||||
|
||||
const clientOverrides = {
|
||||
// Server model provider takes precedence over client header; EdgeOne
|
||||
// named only in AI_PROVIDER is named here, for its own base URL
|
||||
// Server model provider takes precedence over client header; the
|
||||
// site's function named only in AI_PROVIDER is named here, for its
|
||||
// own base URL
|
||||
provider:
|
||||
serverModelConfig.provider ||
|
||||
provider ||
|
||||
(isEdgeOne ? "edgeone" : null),
|
||||
(onSiteFunction ? requestedProvider : null),
|
||||
baseUrl,
|
||||
apiKey: req.headers.get("x-ai-api-key"),
|
||||
// A server model runs the model it was configured with, whatever the header says
|
||||
@@ -216,7 +220,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
vertexApiKey: req.headers.get("x-vertex-api-key"),
|
||||
// Pass cookies for EdgeOne Pages authentication, and the access code,
|
||||
// which the EdgeOne function checks too
|
||||
...(isEdgeOne && {
|
||||
...(onSiteFunction && {
|
||||
headers: {
|
||||
...(cookieHeader && { cookie: cookieHeader }),
|
||||
"x-access-code": req.headers.get("x-access-code") || "",
|
||||
@@ -420,7 +424,7 @@ ${userInputText}
|
||||
// Also merge for OpenAI-compatible providers with custom base URLs (e.g. vLLM, LMStudio)
|
||||
// because open-source model chat templates (Qwen, Llama, etc.) typically reject multiple system messages
|
||||
const isCustomOpenAIEndpoint =
|
||||
resolvedProvider === "openai" &&
|
||||
PROVIDERS[resolvedProvider].sdk === "openai" &&
|
||||
!!(
|
||||
baseUrl ||
|
||||
process.env.OPENAI_BASE_URL ||
|
||||
@@ -428,7 +432,7 @@ ${userInputText}
|
||||
process.env[serverModelConfig.baseUrlEnv])
|
||||
)
|
||||
const isSingleSystemProvider =
|
||||
SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider) || isCustomOpenAIEndpoint
|
||||
!!PROVIDERS[resolvedProvider].singleSystem || isCustomOpenAIEndpoint
|
||||
|
||||
const selectionContext = formatSelectionContext(body.selectedCells)
|
||||
// The model reads the diagram in the compact notation it writes
|
||||
|
||||
@@ -6,23 +6,16 @@ import {
|
||||
listProviderModels,
|
||||
ModelListError,
|
||||
} from "@/lib/provider-models"
|
||||
import { PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
import {
|
||||
allowPrivateUrls,
|
||||
isPrivateUrl,
|
||||
RedirectRefusedError,
|
||||
redirectGuardedFetch,
|
||||
} from "@/lib/ssrf-protection"
|
||||
import type { ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
// Public lists need no key
|
||||
const NO_KEY_NEEDED = new Set<ProviderName>([
|
||||
"ollama",
|
||||
"openrouter",
|
||||
"aihubmix",
|
||||
])
|
||||
|
||||
/**
|
||||
* The models a provider offers, for the "Fetch models" button in model
|
||||
* settings. Answers { models: null } for providers that cannot list them,
|
||||
@@ -47,7 +40,8 @@ export async function POST(req: Request) {
|
||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||
return NextResponse.json({ error: "Invalid base URL" }, { status: 400 })
|
||||
}
|
||||
if (!apiKey && !NO_KEY_NEEDED.has(provider)) {
|
||||
// Public and local lists need no key
|
||||
if (!apiKey && !PROVIDERS[provider].listNeedsNoKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "API key is required" },
|
||||
{ status: 400 },
|
||||
|
||||
Reference in New Issue
Block a user