mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-13 04:59:51 +08:00
fix(providers): apply the review of PR #988
Five reviewers (Fable 5.1, Opus 5.5, Sonnet 5.5, Codex GPT-6 Astra, Codex GPT-6.1 Sol) found no behavior or security regression; this commit applies what they asked for. Settings dialog: the automatic model list fetch only fills the list, which waits behind 'Browse models'; it no longer opens the picker (the modal picker took the focus from whatever the user was typing) and shows no error of its own. One list request per provider at a time, so a click during the automatic fetch sends nothing twice; the baseline that detects freshly entered credentials is taken on every visit of a provider page; SGLang's prefilled local example URL triggers no fetch. Entries saved with Azure's old placeholder URL still count as having no URL. The base URL field of Azure and of the custom preset is labelled required (new dictionary key baseUrlRequired). Smaller code: the PROVIDER_INFO alias is gone (three files and the tests import PROVIDERS), as are three logo values no code read, two unused type re-exports, three unused exports and SINGLE_SYSTEM_PROVIDERS (the chat route reads the record). Renamed for clarity: REQUIRED_KEY_VARS, requiredKeyEnv, configuredUrl, LocalizedText. Seven stale comments rewritten; the custom preset's note shortened. Tests: the matrix gains six override shapes (a Vertex key alone, AWS keys with a session token, headers with a URL, a trusted URL with a key, a key with a server model's URL variable, '/' alone), 5843 rows; the new rows were regenerated on the pre-refactor code and match it. The matrix and the server credential tests clear every variable they read; the admin variable table covers every provider; a test that derived its expectation from the function under test is gone; the singleSystem, listNeedsNoKey, sdkKnowsEndpoint and auto-detection sets are spelled out. Docs: the READMEs say 25 providers and name the OpenAI-compatible preset; the AI_PROVIDER lists are complete; the Azure example URL has its /openai suffix; 'Adding a Provider' names the tests to update.
This commit is contained in:
+21
-27
@@ -41,11 +41,6 @@ interface ModelConfig {
|
||||
provider: ProviderName
|
||||
}
|
||||
|
||||
// Providers that only support a single system message
|
||||
export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>(
|
||||
PROVIDER_NAMES.filter((p) => PROVIDERS[p].singleSystem),
|
||||
)
|
||||
|
||||
/**
|
||||
* Normalize MiniMax base URL for AI SDK compatibility.
|
||||
* MiniMax supports Anthropic-compatible and OpenAI-compatible endpoints.
|
||||
@@ -203,10 +198,7 @@ function resolveApiKey(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve base URL from custom env var name or default env var.
|
||||
* Supports multiple base URLs per provider via ai-models.json baseUrlEnv config.
|
||||
*/
|
||||
/** The server's base URL: a server model's own variable (baseUrlEnv) or the default one */
|
||||
function resolveBaseUrlEnv(
|
||||
overrides: ClientOverrides | undefined,
|
||||
defaultEnvVar: string,
|
||||
@@ -484,10 +476,10 @@ export function envVarsOf(provider: ProviderName): {
|
||||
}
|
||||
}
|
||||
|
||||
// Map of provider to required environment variable. null where the server
|
||||
// needs no key of this kind: Bedrock (the AWS SDK uses an IAM role or the
|
||||
// The key variable the server must have for each provider; null where it
|
||||
// needs none of this kind: Bedrock (the AWS SDK uses an IAM role or the
|
||||
// AWS_* variables), local Ollama, EdgeOne (the site's own function)
|
||||
const PROVIDER_ENV_VARS = Object.fromEntries(
|
||||
const REQUIRED_KEY_VARS = Object.fromEntries(
|
||||
PROVIDER_NAMES.map((p) => {
|
||||
const { credential } = PROVIDERS[p]
|
||||
const required = credential === "apiKey" || credential === "vertexKey"
|
||||
@@ -504,7 +496,8 @@ const PROVIDER_ENV_VARS = Object.fromEntries(
|
||||
* either variable; a custom variable without a value fails, with no fall
|
||||
* back to the default one; a provider with a resourceNameEnv (Azure) then
|
||||
* needs its base URL variable, the server model's own URL variable or the
|
||||
* resource name. Providers with no required key variable pass.
|
||||
* resource name. Providers with no required key variable pass unless a
|
||||
* custom variable was given.
|
||||
*/
|
||||
function serverCredentialError(
|
||||
provider: ProviderName,
|
||||
@@ -523,20 +516,20 @@ function serverCredentialError(
|
||||
}
|
||||
|
||||
const { altKeyEnv, resourceNameEnv } = PROVIDERS[provider]
|
||||
const keyEnv = PROVIDER_ENV_VARS[provider]
|
||||
const requiredKeyEnv = REQUIRED_KEY_VARS[provider]
|
||||
if (altKeyEnv && !customApiKeyEnv) {
|
||||
const hasCredential = !!(
|
||||
(keyEnv && process.env[keyEnv]) ||
|
||||
(requiredKeyEnv && process.env[requiredKeyEnv]) ||
|
||||
process.env[altKeyEnv]
|
||||
)
|
||||
if (!hasCredential) {
|
||||
return (
|
||||
`Either ${keyEnv} or ${altKeyEnv} environment variable is required for ${provider} provider. ` +
|
||||
`Either ${requiredKeyEnv} or ${altKeyEnv} environment variable is required for ${provider} provider. ` +
|
||||
`Please set one in your .env.local file.`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const requiredVar = customApiKeyEnv || keyEnv
|
||||
const requiredVar = customApiKeyEnv || requiredKeyEnv
|
||||
if (requiredVar && !process.env[requiredVar]) {
|
||||
return (
|
||||
`${requiredVar} environment variable is required for ${provider} provider. ` +
|
||||
@@ -545,7 +538,8 @@ function serverCredentialError(
|
||||
}
|
||||
}
|
||||
|
||||
// The SDK builds the URL from the resource name when there is no URL
|
||||
// The SDK builds the URL from the resource name when there is no URL.
|
||||
// Only Azure has one, and the text stays as it was (tests freeze it)
|
||||
if (resourceNameEnv) {
|
||||
const baseUrlEnv = envVarsOf(provider).baseUrl as string
|
||||
const hasBaseUrl =
|
||||
@@ -569,7 +563,7 @@ function serverCredentialError(
|
||||
function detectProvider(): ProviderName | null {
|
||||
const configuredProviders = PROVIDER_NAMES.filter(
|
||||
(p) =>
|
||||
PROVIDER_ENV_VARS[p] !== null && serverCredentialError(p) === null,
|
||||
REQUIRED_KEY_VARS[p] !== null && serverCredentialError(p) === null,
|
||||
)
|
||||
return configuredProviders.length === 1 ? configuredProviders[0] : null
|
||||
}
|
||||
@@ -606,10 +600,10 @@ function bedrockRuntimeUrl(region: string): string {
|
||||
return `https://bedrock-runtime.${region}.${suffix}`
|
||||
}
|
||||
|
||||
export type KeySource = "client" | "server" | "none"
|
||||
type KeySource = "client" | "server" | "none"
|
||||
|
||||
/** Where and how to call a provider, once credentials are resolved */
|
||||
export interface Endpoint {
|
||||
interface Endpoint {
|
||||
apiKey?: string
|
||||
/** Anthropic Bearer auth (ANTHROPIC_AUTH_TOKEN), server only */
|
||||
authToken?: string
|
||||
@@ -639,7 +633,7 @@ export interface Endpoint {
|
||||
* drops one pasted endpoint path per call, so it runs once, in the caller).
|
||||
* Bedrock gets only keySource: bedrockModel() builds its SDK parameters.
|
||||
*/
|
||||
export function resolveEndpoint(
|
||||
function resolveEndpoint(
|
||||
provider: ProviderName,
|
||||
overrides?: ClientOverrides,
|
||||
): Endpoint {
|
||||
@@ -735,7 +729,7 @@ export function resolveEndpoint(
|
||||
// An API key and a base URL from <PREFIX>_API_KEY /
|
||||
// <PREFIX>_BASE_URL, or a server model's apiKeyEnv / baseUrlEnv
|
||||
const key = resolveApiKey(overrides, vars.apiKey)
|
||||
const configuredBaseURL = resolveBaseURL(
|
||||
const configuredUrl = resolveBaseURL(
|
||||
overrides?.apiKey,
|
||||
clientUrl,
|
||||
resolveBaseUrlEnv(overrides, vars.baseUrl as string),
|
||||
@@ -746,7 +740,7 @@ export function resolveEndpoint(
|
||||
// The others get the record's https default (a local default,
|
||||
// SGLang's 127.0.0.1, only fills the settings form).
|
||||
const baseURL =
|
||||
configuredBaseURL ||
|
||||
configuredUrl ||
|
||||
(def.sdkKnowsEndpoint &&
|
||||
!(def.sdk === "openai" && overrides?.apiKey)
|
||||
? undefined
|
||||
@@ -754,7 +748,7 @@ export function resolveEndpoint(
|
||||
return {
|
||||
apiKey: key.value,
|
||||
baseURL,
|
||||
configuredBaseURL: !!configuredBaseURL,
|
||||
configuredBaseURL: !!configuredUrl,
|
||||
guarded,
|
||||
keySource: key.source,
|
||||
selectedKeyEnv: key.selectedKeyEnv,
|
||||
@@ -1069,12 +1063,12 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
|
||||
console.log(`[AI Provider] Auto-detected provider: ${provider}`)
|
||||
} else {
|
||||
// List configured providers for better error message
|
||||
const configured = Object.entries(PROVIDER_ENV_VARS)
|
||||
const configured = Object.entries(REQUIRED_KEY_VARS)
|
||||
.filter(([, envVar]) => envVar && process.env[envVar as string])
|
||||
.map(([p]) => p)
|
||||
|
||||
if (configured.length === 0) {
|
||||
const keys = Object.entries(PROVIDER_ENV_VARS)
|
||||
const keys = Object.entries(REQUIRED_KEY_VARS)
|
||||
.filter(([, envVar]) => envVar)
|
||||
.map(([p, envVar]) => `- ${envVar} for ${p}`)
|
||||
throw new Error(
|
||||
|
||||
@@ -298,6 +298,7 @@
|
||||
"mayNotDraw": "models.dev lists no tool call support for this model, so it may not be able to draw.",
|
||||
"requestUrl": "Requests go to {url}",
|
||||
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
|
||||
"baseUrlRequired": "Base URL (required)",
|
||||
"customEndpoint": "Custom endpoint URL",
|
||||
"models": "Models",
|
||||
"customModelId": "Custom model ID...",
|
||||
|
||||
@@ -253,6 +253,7 @@
|
||||
"mayNotDraw": "models.dev によると、このモデルはツール呼び出しに対応していないため、作図できない可能性があります。",
|
||||
"requestUrl": "リクエスト先: {url}",
|
||||
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
|
||||
"baseUrlRequired": "ベース URL(必須)",
|
||||
"customEndpoint": "カスタムエンドポイント URL",
|
||||
"models": "モデル",
|
||||
"customModelId": "カスタムモデル ID...",
|
||||
|
||||
@@ -298,6 +298,7 @@
|
||||
"mayNotDraw": "models.dev 顯示這個模型不支援工具呼叫,可能無法繪圖。",
|
||||
"requestUrl": "請求將傳送至 {url}",
|
||||
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
|
||||
"baseUrlRequired": "基礎 URL(必填)",
|
||||
"customEndpoint": "自訂端點 URL",
|
||||
"models": "模型",
|
||||
"customModelId": "自訂模型 ID...",
|
||||
|
||||
@@ -298,6 +298,7 @@
|
||||
"mayNotDraw": "models.dev 显示这个模型不支持工具调用,可能无法画图。",
|
||||
"requestUrl": "请求将发往 {url}",
|
||||
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
|
||||
"baseUrlRequired": "基础 URL(必填)",
|
||||
"customEndpoint": "自定义端点 URL",
|
||||
"models": "模型",
|
||||
"customModelId": "自定义模型 ID...",
|
||||
|
||||
+11
-6
@@ -8,8 +8,6 @@ import {
|
||||
} from "@/lib/providers"
|
||||
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
export type { ProviderGroupId }
|
||||
|
||||
/** The provider picker's groups, each provider in exactly one, in record order */
|
||||
export const PROVIDER_GROUPS: {
|
||||
id: ProviderGroupId
|
||||
@@ -44,6 +42,8 @@ export function filterProviderGroups(query: string) {
|
||||
})).filter((group) => group.providers.length > 0)
|
||||
}
|
||||
|
||||
const LEGACY_AZURE_PLACEHOLDER = "https://your-resource.openai.azure.com/openai"
|
||||
|
||||
/** The credentials a test or a chat request needs are filled in */
|
||||
export function hasCredentials(p: ProviderConfig): boolean {
|
||||
const def = PROVIDERS[p.provider]
|
||||
@@ -59,10 +59,15 @@ export function hasCredentials(p: ProviderConfig): boolean {
|
||||
case "none":
|
||||
case "optionalKey":
|
||||
return true
|
||||
case "apiKey":
|
||||
// Without a default URL in the record (Azure) the user's own is
|
||||
// part of the credentials
|
||||
return !!p.apiKey && (!!def.defaultBaseUrl || !!p.baseUrl)
|
||||
case "apiKey": {
|
||||
// Without a default URL in the record (Azure, custom) the user's
|
||||
// own is part of the credentials. Entries saved while Azure still
|
||||
// had a placeholder default hold that placeholder; it names no
|
||||
// real resource
|
||||
const baseUrl =
|
||||
p.baseUrl === LEGACY_AZURE_PLACEHOLDER ? "" : p.baseUrl
|
||||
return !!p.apiKey && (!!def.defaultBaseUrl || !!baseUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-14
@@ -46,7 +46,7 @@ export type ModelListStyle =
|
||||
export type ProviderGroup = "vendors" | "aggregators" | "cloud" | "selfHosted"
|
||||
|
||||
/** One sentence in every UI language */
|
||||
export type Copy = Record<Locale, string>
|
||||
export type LocalizedText = Record<Locale, string>
|
||||
|
||||
export interface ProviderDef {
|
||||
label: string
|
||||
@@ -70,8 +70,9 @@ export interface ProviderDef {
|
||||
resourceNameEnv?: string
|
||||
/**
|
||||
* The official endpoint, also the form's placeholder. A non-https value
|
||||
* (SGLang's 127.0.0.1) only fills the form: the server never calls it.
|
||||
* Missing for a credential "apiKey" provider: the user must enter one
|
||||
* (SGLang's 127.0.0.1) only fills the form; the server never falls back
|
||||
* to it. A credential "apiKey" provider without one (Azure, custom)
|
||||
* needs the user's URL
|
||||
*/
|
||||
defaultBaseUrl?: string
|
||||
/** The SDK has the official endpoint built in: pass no baseURL unless one is configured */
|
||||
@@ -92,12 +93,12 @@ export interface ProviderDef {
|
||||
fixedCredentials?: true
|
||||
/** models.dev logo name when it differs from the key */
|
||||
logo?: string
|
||||
/** A Lucide icon for providers without a logo on models.dev */
|
||||
/** A Lucide icon shown instead of a models.dev logo */
|
||||
icon?: "cloud" | "server" | "sparkles"
|
||||
/** The note shown under the name in the picker (Bedrock: "AWS credentials") */
|
||||
note?: Copy
|
||||
note?: LocalizedText
|
||||
/** A hint under the base URL field (MiniMax: which path selects which protocol) */
|
||||
baseUrlHint?: Copy
|
||||
baseUrlHint?: LocalizedText
|
||||
/** The folded options start open: the base URL usually needs changing */
|
||||
optionsOpen?: true
|
||||
}
|
||||
@@ -171,7 +172,6 @@ const defs = {
|
||||
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
apiKeyUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey",
|
||||
logo: "bytedance",
|
||||
icon: "sparkles",
|
||||
// Volcengine Ark ids use the dash form
|
||||
models: [
|
||||
@@ -356,7 +356,6 @@ const defs = {
|
||||
envPrefix: null,
|
||||
fixedCredentials: true,
|
||||
ownRequestPath: true,
|
||||
logo: "amazon-bedrock",
|
||||
icon: "cloud",
|
||||
note: {
|
||||
en: "AWS credentials",
|
||||
@@ -503,10 +502,10 @@ const defs = {
|
||||
credential: "apiKey",
|
||||
group: "selfHosted",
|
||||
envPrefix: "SGLANG",
|
||||
// A local example: the server never calls it, see publicDefaultUrl
|
||||
// A local example that only fills the form; the server never falls
|
||||
// back to it (publicDefaultUrl)
|
||||
defaultBaseUrl: "http://127.0.0.1:8000/v1",
|
||||
modelList: "openai",
|
||||
logo: "openai",
|
||||
icon: "server",
|
||||
optionsOpen: true,
|
||||
note: {
|
||||
@@ -531,10 +530,10 @@ const defs = {
|
||||
// Open-source chat templates reject several system messages
|
||||
singleSystem: true,
|
||||
note: {
|
||||
en: "Any OpenAI-compatible URL and key",
|
||||
zh: "任何 OpenAI 兼容地址和 key",
|
||||
ja: "OpenAI 互換の URL とキー",
|
||||
"zh-Hant": "任何 OpenAI 相容位址和 key",
|
||||
en: "Your URL and key",
|
||||
zh: "填地址和 Key",
|
||||
ja: "URL とキー",
|
||||
"zh-Hant": "填位址和 Key",
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, ProviderDef>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { z } from "zod"
|
||||
import type { ProviderName } from "@/lib/types/model-config"
|
||||
import { PROVIDER_INFO } from "@/lib/types/model-config"
|
||||
import { PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
|
||||
export const ProviderNameSchema: z.ZodType<ProviderName> = z
|
||||
.string()
|
||||
.refine((val): val is ProviderName => Object.hasOwn(PROVIDER_INFO, val), {
|
||||
.refine((val): val is ProviderName => Object.hasOwn(PROVIDERS, val), {
|
||||
message: "Invalid provider name",
|
||||
})
|
||||
|
||||
@@ -81,7 +80,7 @@ function configFromCommaSeparatedAiModel(): ServerModelsConfig | null {
|
||||
)
|
||||
return null
|
||||
}
|
||||
if (!Object.hasOwn(PROVIDER_INFO, aiProvider)) {
|
||||
if (!Object.hasOwn(PROVIDERS, aiProvider)) {
|
||||
console.warn(
|
||||
`[server-model-config] AI_PROVIDER="${aiProvider}" is not a known provider; skipping multi-model fallback.`,
|
||||
)
|
||||
@@ -102,7 +101,7 @@ function configFromCommaSeparatedAiModel(): ServerModelsConfig | null {
|
||||
return {
|
||||
providers: [
|
||||
{
|
||||
name: PROVIDER_INFO[providerName]?.label || providerName,
|
||||
name: PROVIDERS[providerName]?.label || providerName,
|
||||
provider: providerName,
|
||||
models,
|
||||
default: true,
|
||||
@@ -195,7 +194,7 @@ export async function loadFlattenedServerModels(): Promise<
|
||||
|
||||
for (const p of cfg.providers) {
|
||||
const providerLabel =
|
||||
p.name || PROVIDER_INFO[p.provider]?.label || p.provider
|
||||
p.name || PROVIDERS[p.provider]?.label || p.provider
|
||||
|
||||
// Use slugified name for unique ID (supports multiple API keys per provider)
|
||||
const nameSlug = slugify(p.name)
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
import { PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
|
||||
// The provider record, under the name the rest of the app imports it by
|
||||
export {
|
||||
type ModelListStyle,
|
||||
PROVIDERS as PROVIDER_INFO,
|
||||
type ProviderName,
|
||||
} from "@/lib/providers"
|
||||
export type { ProviderName } from "@/lib/providers"
|
||||
|
||||
// Individual model configuration
|
||||
export interface ModelConfig {
|
||||
|
||||
Reference in New Issue
Block a user