From 458d8050cc89bcac923f4988de2d1c299f6c5e19 Mon Sep 17 00:00:00 2001 From: Dayuan Jiang <34411969+DayuanJiang@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:31:03 +0900 Subject: [PATCH] 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. --- README.md | 4 +- app/[lang]/admin/models-section.tsx | 23 +- app/api/chat/route.ts | 34 +- app/api/provider-models/route.ts | 12 +- components/model-config-dialog.tsx | 117 +- components/provider-credentials-fields.tsx | 55 +- components/provider-logo.tsx | 22 +- components/settings/provider-picker.tsx | 18 +- components/settings/providers-list.tsx | 17 +- docs/cn/README_CN.md | 2 +- docs/cn/ai-providers.md | 40 +- docs/en/ai-providers.md | 40 +- docs/ja/README_JA.md | 2 +- docs/ja/ai-providers.md | 40 +- docs/plans/2026-10-provider-refactor.md | 757 +++ env.example | 6 +- hooks/use-model-config.ts | 4 +- lib/admin/providers.ts | 33 +- lib/ai-providers.ts | 924 +-- lib/i18n/dictionaries/en.json | 11 +- lib/i18n/dictionaries/ja.json | 11 +- lib/i18n/dictionaries/zh-Hant.json | 11 +- lib/i18n/dictionaries/zh.json | 11 +- lib/provider-models.ts | 30 +- lib/provider-setup.ts | 108 +- lib/providers.ts | 558 ++ lib/server-model-config.ts | 11 +- lib/ssrf-protection.ts | 7 +- lib/test-model-connection.ts | 95 +- lib/types/model-config.ts | 491 +- tests/e2e/settings-providers.spec.ts | 78 +- .../unit/__snapshots__/provider-matrix.jsonl | 5843 +++++++++++++++++ tests/unit/admin-providers.test.ts | 55 + tests/unit/chat-route-edgeone.test.ts | 25 + tests/unit/provider-matrix.test.ts | 571 ++ tests/unit/provider-setup.test.ts | 52 +- tests/unit/providers.test.ts | 218 + tests/unit/server-credentials.test.ts | 170 + tests/unit/ssrf-protection.test.ts | 8 +- tests/unit/validate-model-route.test.ts | 25 + 40 files changed, 9272 insertions(+), 1267 deletions(-) create mode 100644 docs/plans/2026-10-provider-refactor.md create mode 100644 lib/providers.ts create mode 100644 tests/unit/__snapshots__/provider-matrix.jsonl create mode 100644 tests/unit/provider-matrix.test.ts create mode 100644 tests/unit/providers.test.ts create mode 100644 tests/unit/server-credentials.test.ts diff --git a/README.md b/README.md index efa465fe..b01e657e 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Available as a web app, a desktop app for Windows, macOS and Linux, and an MCP s **Use** -- 24 model providers; enter your own API key in the browser and it stays on your machine +- 25 model providers, including an OpenAI-compatible preset for any other URL; enter your own API key in the browser and it stays on your machine - Models that reason show their thinking - Dark mode; the interface is available in English, Simplified Chinese, Traditional Chinese and Japanese @@ -151,7 +151,7 @@ Open [http://localhost:6002](http://localhost:6002). ### Models and providers -24 providers are supported: AWS Bedrock (default), OpenAI, Anthropic, Google AI, Google Vertex AI, Azure OpenAI, Ollama, OpenRouter, AIHubMix, DeepSeek, SiliconFlow, SGLang, Vercel AI Gateway, Tencent EdgeOne, ByteDance Doubao, ModelScope, Zhipu GLM, Qwen, Qiniu, Kimi, MiniMax, Novita, Xiaomi MiMo and Atlas Cloud. The environment variables and notes for each are in the [provider configuration guide](./docs/en/ai-providers.md). +25 providers are supported: AWS Bedrock (default), OpenAI, Anthropic, Google AI, Google Vertex AI, Azure OpenAI, Ollama, OpenRouter, AIHubMix, DeepSeek, SiliconFlow, SGLang, Vercel AI Gateway, Tencent EdgeOne, ByteDance Doubao, ModelScope, Zhipu GLM, Qwen, Qiniu, Kimi, MiniMax, Novita, Xiaomi MiMo, Atlas Cloud, and an OpenAI-compatible preset for any other URL (vLLM, LM Studio, a gateway). The environment variables and notes for each are in the [provider configuration guide](./docs/en/ai-providers.md). **Which model**: the task is to produce long text in a strict format (draw.io XML), so pick a capable model; small models tend to produce broken diagrams. diff --git a/app/[lang]/admin/models-section.tsx b/app/[lang]/admin/models-section.tsx index 242d3ab8..07f436b0 100644 --- a/app/[lang]/admin/models-section.tsx +++ b/app/[lang]/admin/models-section.tsx @@ -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 >({}) - 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({ > - {p.name || PROVIDER_INFO[p.provider].label} + {p.name || PROVIDERS[p.provider].label} {p.isDefault && ( - {(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({ >
- {PROVIDER_INFO[p].label} + {PROVIDERS[p].label} {envBlocked && ( { diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index e63e6f63..b2277cb9 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -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 { // 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 { 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 { 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 diff --git a/app/api/provider-models/route.ts b/app/api/provider-models/route.ts index 6b362e73..678ab107 100644 --- a/app/api/provider-models/route.ts +++ b/app/api/provider-models/route.ts @@ -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([ - "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 }, diff --git a/components/model-config-dialog.tsx b/components/model-config-dialog.tsx index 98012c02..702e2273 100644 --- a/components/model-config-dialog.tsx +++ b/components/model-config-dialog.tsx @@ -55,13 +55,13 @@ import { getApiEndpoint } from "@/lib/base-path" import { formatMessage } from "@/lib/i18n/utils" import type { ListedModel } from "@/lib/provider-models" import { hasCredentials } from "@/lib/provider-setup" +import { PROVIDERS, publicDefaultUrl } from "@/lib/providers" import { STORAGE_KEYS } from "@/lib/storage" import type { ModelConfig, ProviderConfig, ProviderName, } from "@/lib/types/model-config" -import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config" import { cn } from "@/lib/utils" import { useUiStore } from "@/stores/ui-store" @@ -300,19 +300,36 @@ export function ModelConfigDialog({ const errorHints = (p: ProviderConfig): Record => ({ ...(dict.errors.llm as Record), invalid_api_key: - p.provider === "bedrock" && !p.apiKey + PROVIDERS[p.provider].credential === "aws" && !p.apiKey ? t.awsKeysRejected : t.keyRejected, model_not_found: t.modelNotFound, }) - const handleFetchModels = async () => { + // Requests in flight, by provider id: a second request for the same + // provider (the button clicked while the automatic fetch runs, or the + // reverse) is not sent + const inFlight = useRef(new Set()) + // The credentials each provider's automatic fetch ran with, or was + // overtaken by a click with: one automatic fetch per set of credentials + const autoFetched = useRef>({}) + + /** + * Fetch the shown provider's model list. A click opens the picker with + * the result and shows what went wrong; the automatic fetch after + * credentials were entered (auto) only fills the list, so it never + * takes the focus or flashes an error while the user is still typing. + */ + const handleFetchModels = async (auto = false) => { if (!selectedProvider) return const providerId = selectedProvider.id + if (inFlight.current.has(providerId)) return const askedWith = credentialsOf(providerId) + autoFetched.current[providerId] = askedWith const hints = errorHints(selectedProvider) + inFlight.current.add(providerId) setFetchingFor((current) => [...current, providerId]) - setFetchModelsError("") + if (!auto) setFetchModelsError("") try { const response = await fetch( getApiEndpoint("/api/provider-models"), @@ -339,11 +356,11 @@ export function ModelConfigDialog({ ...current, [providerId]: { askedWith, models: data.models }, })) - if (!stillShown) return + if (!stillShown || auto) return if (data.models.length > 0) setModelPickerOpen(true) else setFetchModelsError(t.noModelsReturned) } else { - if (!stillShown) return + if (!stillShown || auto) return setFetchModelsError( [hints[data.code], data.error].filter(Boolean).join(" ") || formatMessage(t.requestFailed, { @@ -353,12 +370,14 @@ export function ModelConfigDialog({ } } catch { if ( + !auto && selectedProviderIdRef.current === providerId && credentialsOf(providerId) === askedWith ) { setFetchModelsError(dict.errors.networkError) } } finally { + inFlight.current.delete(providerId) setFetchingFor((current) => current.filter((id) => id !== providerId), ) @@ -366,16 +385,18 @@ export function ModelConfigDialog({ } // The provider's own list, if fetched with the credentials it has now, - // else the suggested models + // else the record's short list (only providers that cannot list models + // have one) const fetched = selectedProvider ? fetchedModels[selectedProvider.id] : undefined + const recordModels = selectedProvider + ? (PROVIDERS[selectedProvider.provider].models ?? []) + : [] const suggestedModels: ListedModel[] = selectedProvider ? fetched && fetched.askedWith === credentialsOf(selectedProvider.id) ? fetched.models - : (SUGGESTED_MODELS[selectedProvider.provider] || []).map((id) => ({ - id, - })) + : recordModels.map((id) => ({ id })) : [] // The picker closes when there is nothing left to pick from, so it does // not open by itself once there is again @@ -398,20 +419,60 @@ export function ModelConfigDialog({ const availableSuggestions = suggestedModels.filter( (model) => !existingModelIds.includes(model.id), ) - // A few suggested models to add with one click - const quickSuggestions = selectedProvider - ? (SUGGESTED_MODELS[selectedProvider.provider] || []) - .filter((modelId) => !existingModelIds.includes(modelId)) - .slice(0, 4) - : [] + // A few of the record's models to add with one click + const quickSuggestions = recordModels + .filter((modelId) => !existingModelIds.includes(modelId)) + .slice(0, 4) + + // A provider that can list its models gets its list fetched by itself, + // once, for credentials entered on its page while it has no model yet, + // so "Browse models" offers the provider's own list instead of a + // hand-written one. Credentials a provider already had when its page + // opened fetch nothing (the button does), nor does a provider with + // models, nor a local example URL still in the field (SGLang's). + const shownWith = useRef<{ id: string; credentials: string } | null>(null) + useEffect(() => { + if (!open || !selectedProvider) { + // The baseline belongs to one visit of one provider's page + shownWith.current = null + return + } + const id = selectedProvider.id + const now = credentialsOf(id) + if (shownWith.current?.id !== id) { + shownWith.current = { id, credentials: now } + } + const def = PROVIDERS[selectedProvider.provider] + const localExample = + !!selectedProvider.baseUrl && + selectedProvider.baseUrl === def.defaultBaseUrl && + !publicDefaultUrl(selectedProvider.provider) + if ( + !def.modelList || + selectedProvider.models.length > 0 || + !hasCredentials(selectedProvider) || + localExample || + now === shownWith.current.credentials || + autoFetched.current[id] === now + ) { + return + } + // Once the typing or pasting pauses + const timer = setTimeout(() => { + if (autoFetched.current[id] === now) return + void handleFetchModels(true) + }, 600) + return () => clearTimeout(timer) + }, [open, selectedProvider]) const openProvider = (providerId: string) => setModelsPage({ providerId }) // Handle adding a new provider const handleAddProvider = (providerType: ProviderName) => { const newProvider = addProvider(providerType) - // With the first suggested model picked, a key is all a test needs - const firstModel = SUGGESTED_MODELS[providerType]?.[0] + // A provider that cannot list its models starts with the first of + // the record's; the others fetch their list once the key is in + const firstModel = PROVIDERS[providerType].models?.[0] if (firstModel) addModel(newProvider.id, firstModel) openProvider(newProvider.id) } @@ -530,9 +591,9 @@ export function ModelConfigDialog({ const run = (latestTestRun.get(selectedProviderId) ?? 0) + 1 latestTestRun.set(selectedProviderId, run) - // For EdgeOne, construct baseUrl from current origin + // The site's own function (EdgeOne): its URL is this origin's const baseUrl = - selectedProvider.provider === "edgeone" + PROVIDERS[selectedProvider.provider].credential === "none" ? `${window.location.origin}/api/edgeai` : selectedProvider.baseUrl const hints = errorHints(selectedProvider) @@ -741,7 +802,7 @@ export function ModelConfigDialog({ } const providerName = (p: ProviderConfig) => - p.name || PROVIDER_INFO[p.provider].label + p.name || PROVIDERS[p.provider].label const renderModelRow = (model: ModelConfig) => { if (!selectedProvider) return null @@ -922,11 +983,13 @@ export function ModelConfigDialog({ : undefined const canTest = credentialsDone && models.length > 0 const testing = validationStatus === "validating" + const def = PROVIDERS[provider.provider] + // A key-only provider without a default URL (Azure) needs its URL too const testBlocker = !credentialsDone - ? provider.provider === "bedrock" + ? def.credential === "aws" ? t.needAws - : provider.provider === "azure" - ? t.needAzure + : def.credential === "apiKey" && !def.defaultBaseUrl + ? t.needUrl : t.needKey : models.length === 0 ? t.needModel @@ -984,7 +1047,7 @@ export function ModelConfigDialog({ done={credentialsDone && !keyRejected} title={t.stepConnect} > - {provider.provider === "edgeone" ? ( + {def.credential === "none" ? (

{t.edgeoneNoKey}

@@ -1027,10 +1090,10 @@ export function ModelConfigDialog({ done={models.length > 0} title={t.stepModels} action={ - PROVIDER_INFO[provider.provider].modelList && ( + def.modelList && (
) - // EdgeOne needs no credentials - if (provider === "edgeone") return null + // A provider without credentials (EdgeOne) has no fields + if (info.credential === "none") return null if (settingsLayout) { return (
- {provider === "bedrock" ? ( + {info.credential === "aws" ? ( <> {/* Buttons, not radios: arrow keys would empty secrets without a deliberate press */} @@ -393,7 +390,7 @@ export function ProviderCredentialsFields({ )} {regionField} - ) : provider === "vertexai" ? ( + ) : info.credential === "vertexKey" ? (