mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-13 04:59:51 +08:00
Step 2 of the provider refactor plan. lib/providers.ts holds one record per provider (SDK kind, credential kind, env prefix, default URL, picker group, notes and base URL hints in four languages, and the short model list of the six providers that cannot list models). ProviderName is now the record's key set and lib/types/model-config.ts re-exports the record as PROVIDER_INFO, so the twelve importers are unchanged. Derived from the record, under their old names and in their old files: FIXED_CRED_PROVIDERS, PROVIDER_LOGO_MAP and chatRequestUrl's own-path list (model-config.ts), SINGLE_SYSTEM_PROVIDERS, PROVIDER_ENV_VARS and SDK_KNOWS_ENDPOINT (ai-providers.ts), PROVIDER_GROUPS and OPTIONS_OPEN (provider-setup.ts), NO_KEY_NEEDED (provider-models route). tests/unit/providers.test.ts freezes the old tables' values and checks the derived ones against them. The record is written in the picker's order, so the provider lists in two 'No AI provider configured' messages change order; those two snapshot rows are the only snapshot change.
511 lines
16 KiB
TypeScript
511 lines
16 KiB
TypeScript
/**
|
|
* The provider record (lib/providers.ts) replaces thirteen tables keyed by
|
|
* provider name. This file freezes those tables' values as they were written
|
|
* before the refactor and checks that the record derives the same values.
|
|
* The fixture goes away once the derived tables have no readers left; the
|
|
* last two tests stay.
|
|
*/
|
|
import { describe, expect, it, vi } from "vitest"
|
|
import { SINGLE_SYSTEM_PROVIDERS } from "@/lib/ai-providers"
|
|
import { i18n } from "@/lib/i18n/config"
|
|
import enDict from "@/lib/i18n/dictionaries/en.json"
|
|
import jaDict from "@/lib/i18n/dictionaries/ja.json"
|
|
import zhDict from "@/lib/i18n/dictionaries/zh.json"
|
|
import zhHantDict from "@/lib/i18n/dictionaries/zh-Hant.json"
|
|
import {
|
|
OPTIONS_OPEN,
|
|
POPULAR_PROVIDERS,
|
|
PROVIDER_GROUPS,
|
|
} from "@/lib/provider-setup"
|
|
import { PROVIDER_NAMES, PROVIDERS, publicDefaultUrl } from "@/lib/providers"
|
|
import {
|
|
chatRequestUrl,
|
|
FIXED_CRED_PROVIDERS,
|
|
PROVIDER_INFO,
|
|
PROVIDER_LOGO_MAP,
|
|
SUGGESTED_MODELS,
|
|
} from "@/lib/types/model-config"
|
|
|
|
vi.mock("@/lib/admin/settings", () => ({
|
|
loadSettings: () => ({}),
|
|
getEnvFallback: (key: string) => process.env[key] ?? null,
|
|
}))
|
|
|
|
// The tables as they were before lib/providers.ts existed
|
|
const fixture = {
|
|
providerNames: [
|
|
"openai",
|
|
"anthropic",
|
|
"google",
|
|
"vertexai",
|
|
"azure",
|
|
"bedrock",
|
|
"ollama",
|
|
"openrouter",
|
|
"aihubmix",
|
|
"deepseek",
|
|
"siliconflow",
|
|
"sglang",
|
|
"gateway",
|
|
"edgeone",
|
|
"doubao",
|
|
"modelscope",
|
|
"glm",
|
|
"qwen",
|
|
"qiniu",
|
|
"kimi",
|
|
"minimax",
|
|
"novita",
|
|
"mimo",
|
|
"atlascloud",
|
|
],
|
|
providerInfo: {
|
|
openai: {
|
|
label: "OpenAI",
|
|
defaultBaseUrl: "https://api.openai.com/v1",
|
|
apiKeyUrl: "https://platform.openai.com/api-keys",
|
|
modelList: "openai",
|
|
},
|
|
anthropic: {
|
|
label: "Anthropic",
|
|
defaultBaseUrl: "https://api.anthropic.com/v1",
|
|
apiKeyUrl: "https://platform.claude.com/settings/keys",
|
|
modelList: "anthropic",
|
|
},
|
|
google: {
|
|
label: "Google",
|
|
defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
|
apiKeyUrl: "https://aistudio.google.com/apikey",
|
|
modelList: "google",
|
|
},
|
|
vertexai: { label: "Google Vertex AI" },
|
|
azure: {
|
|
label: "Azure OpenAI",
|
|
defaultBaseUrl: "https://your-resource.openai.azure.com/openai",
|
|
},
|
|
bedrock: { label: "Amazon Bedrock" },
|
|
ollama: {
|
|
label: "Ollama",
|
|
defaultBaseUrl: "https://ollama.com/api",
|
|
apiKeyUrl: "https://ollama.com/settings/keys",
|
|
modelList: "ollama",
|
|
},
|
|
openrouter: {
|
|
label: "OpenRouter",
|
|
defaultBaseUrl: "https://openrouter.ai/api/v1",
|
|
apiKeyUrl: "https://openrouter.ai/keys",
|
|
modelList: "openrouter",
|
|
},
|
|
aihubmix: {
|
|
label: "AIHubMix",
|
|
defaultBaseUrl: "https://aihubmix.com/v1",
|
|
apiKeyUrl: "https://aihubmix.com/token",
|
|
modelList: "aihubmix",
|
|
},
|
|
deepseek: {
|
|
label: "DeepSeek",
|
|
defaultBaseUrl: "https://api.deepseek.com/v1",
|
|
apiKeyUrl: "https://platform.deepseek.com/api_keys",
|
|
modelList: "openai",
|
|
},
|
|
siliconflow: {
|
|
label: "SiliconFlow",
|
|
defaultBaseUrl: "https://api.siliconflow.cn/v1",
|
|
apiKeyUrl: "https://cloud.siliconflow.cn/account/ak",
|
|
modelList: "openai",
|
|
},
|
|
sglang: {
|
|
label: "SGLang",
|
|
defaultBaseUrl: "http://127.0.0.1:8000/v1",
|
|
modelList: "openai",
|
|
},
|
|
gateway: {
|
|
label: "AI Gateway",
|
|
defaultBaseUrl: "https://ai-gateway.vercel.sh/v1/ai",
|
|
apiKeyUrl: "https://vercel.com/ai-gateway",
|
|
modelList: "gateway",
|
|
},
|
|
edgeone: { label: "EdgeOne Pages" },
|
|
doubao: {
|
|
label: "Doubao (ByteDance)",
|
|
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
|
apiKeyUrl:
|
|
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey",
|
|
},
|
|
modelscope: {
|
|
label: "ModelScope",
|
|
defaultBaseUrl: "https://api-inference.modelscope.cn/v1",
|
|
apiKeyUrl: "https://modelscope.cn/my/myaccesstoken",
|
|
modelList: "openai",
|
|
},
|
|
glm: {
|
|
label: "GLM (Zhipu)",
|
|
defaultBaseUrl: "https://open.bigmodel.cn/api/paas/v4",
|
|
apiKeyUrl: "https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys",
|
|
modelList: "openai",
|
|
},
|
|
qwen: {
|
|
label: "Qwen (Alibaba)",
|
|
defaultBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
apiKeyUrl: "https://bailian.console.aliyun.com/?tab=model#/api-key",
|
|
modelList: "openai",
|
|
},
|
|
qiniu: {
|
|
label: "Qiniu",
|
|
defaultBaseUrl: "https://api.qnaigc.com/v1",
|
|
apiKeyUrl: "https://www.qiniu.com/ai/models",
|
|
modelList: "openai",
|
|
},
|
|
kimi: {
|
|
label: "Kimi (Moonshot)",
|
|
defaultBaseUrl: "https://api.moonshot.cn/v1",
|
|
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
|
|
modelList: "openai",
|
|
},
|
|
minimax: {
|
|
label: "MiniMax",
|
|
defaultBaseUrl: "https://api.minimaxi.com/anthropic",
|
|
apiKeyUrl:
|
|
"https://platform.minimaxi.com/user-center/basic-information/interface-key",
|
|
},
|
|
novita: {
|
|
label: "Novita AI",
|
|
defaultBaseUrl: "https://api.novita.ai/openai",
|
|
apiKeyUrl: "https://novita.ai/dashboard/key",
|
|
modelList: "openai",
|
|
},
|
|
mimo: {
|
|
label: "MiMo (Xiaomi)",
|
|
defaultBaseUrl: "https://api.xiaomimimo.com/v1",
|
|
apiKeyUrl: "https://platform.xiaomimimo.com/#/console/api-keys",
|
|
modelList: "openai",
|
|
},
|
|
atlascloud: {
|
|
label: "Atlas Cloud",
|
|
defaultBaseUrl: "https://api.atlascloud.ai/v1",
|
|
apiKeyUrl: "https://www.atlascloud.ai/console/api-keys",
|
|
modelList: "openai",
|
|
},
|
|
} as Record<
|
|
string,
|
|
{
|
|
label: string
|
|
defaultBaseUrl?: string
|
|
apiKeyUrl?: string
|
|
modelList?: string
|
|
}
|
|
>,
|
|
fixedCredProviders: ["bedrock", "vertexai", "ollama"],
|
|
providerLogoMap: {
|
|
openai: "openai",
|
|
anthropic: "anthropic",
|
|
google: "google",
|
|
azure: "azure",
|
|
bedrock: "amazon-bedrock",
|
|
openrouter: "openrouter",
|
|
aihubmix: "aihubmix",
|
|
deepseek: "deepseek",
|
|
siliconflow: "siliconflow",
|
|
sglang: "openai",
|
|
gateway: "vercel",
|
|
edgeone: "tencent-cloud",
|
|
vertexai: "google",
|
|
doubao: "bytedance",
|
|
modelscope: "modelscope",
|
|
minimax: "minimax",
|
|
novita: "novita",
|
|
mimo: "xiaomi",
|
|
atlascloud: "openai",
|
|
} as Record<string, string>,
|
|
ownPaths: [
|
|
"google",
|
|
"vertexai",
|
|
"azure",
|
|
"bedrock",
|
|
"gateway",
|
|
"minimax",
|
|
"edgeone",
|
|
],
|
|
singleSystemProviders: [
|
|
"minimax",
|
|
"glm",
|
|
"qwen",
|
|
"kimi",
|
|
"qiniu",
|
|
"novita",
|
|
"mimo",
|
|
],
|
|
providerEnvVars: {
|
|
bedrock: null,
|
|
openai: "OPENAI_API_KEY",
|
|
anthropic: "ANTHROPIC_API_KEY",
|
|
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
|
vertexai: "GOOGLE_VERTEX_API_KEY",
|
|
azure: "AZURE_API_KEY",
|
|
ollama: null,
|
|
openrouter: "OPENROUTER_API_KEY",
|
|
aihubmix: "AIHUBMIX_API_KEY",
|
|
deepseek: "DEEPSEEK_API_KEY",
|
|
siliconflow: "SILICONFLOW_API_KEY",
|
|
sglang: "SGLANG_API_KEY",
|
|
gateway: "AI_GATEWAY_API_KEY",
|
|
edgeone: null,
|
|
doubao: "DOUBAO_API_KEY",
|
|
modelscope: "MODELSCOPE_API_KEY",
|
|
glm: "GLM_API_KEY",
|
|
qwen: "QWEN_API_KEY",
|
|
qiniu: "QINIU_API_KEY",
|
|
kimi: "KIMI_API_KEY",
|
|
minimax: "MINIMAX_API_KEY",
|
|
novita: "NOVITA_API_KEY",
|
|
mimo: "MIMO_API_KEY",
|
|
atlascloud: "ATLASCLOUD_API_KEY",
|
|
} as Record<string, string | null>,
|
|
sdkKnowsEndpoint: [
|
|
"openai",
|
|
"google",
|
|
"azure",
|
|
"openrouter",
|
|
"gateway",
|
|
"deepseek",
|
|
],
|
|
providerGroups: [
|
|
{
|
|
id: "vendors",
|
|
providers: [
|
|
"openai",
|
|
"anthropic",
|
|
"google",
|
|
"deepseek",
|
|
"qwen",
|
|
"doubao",
|
|
"glm",
|
|
"kimi",
|
|
"minimax",
|
|
"mimo",
|
|
],
|
|
},
|
|
{
|
|
id: "aggregators",
|
|
providers: [
|
|
"openrouter",
|
|
"aihubmix",
|
|
"siliconflow",
|
|
"modelscope",
|
|
"novita",
|
|
"atlascloud",
|
|
"qiniu",
|
|
"gateway",
|
|
],
|
|
},
|
|
{ id: "cloud", providers: ["bedrock", "vertexai", "azure", "edgeone"] },
|
|
{ id: "selfHosted", providers: ["ollama", "sglang"] },
|
|
],
|
|
popularProviders: [
|
|
"openai",
|
|
"anthropic",
|
|
"google",
|
|
"deepseek",
|
|
"openrouter",
|
|
"qwen",
|
|
],
|
|
providerNoteKeys: {
|
|
bedrock: "noteBedrock",
|
|
vertexai: "noteVertex",
|
|
azure: "noteAzure",
|
|
edgeone: "noteEdgeone",
|
|
ollama: "noteOllama",
|
|
sglang: "noteSglang",
|
|
} as Record<string, string>,
|
|
baseUrlHintKeys: {
|
|
minimax: "minimaxBaseUrlHint",
|
|
mimo: "mimoBaseUrlHint",
|
|
} as Record<string, string>,
|
|
optionsOpen: ["azure", "ollama", "sglang", "minimax", "mimo"],
|
|
noKeyNeeded: ["ollama", "openrouter", "aihubmix"],
|
|
}
|
|
|
|
const sorted = (list: Iterable<string>) => [...list].sort()
|
|
|
|
describe("the provider record derives the old tables", () => {
|
|
it("names the same providers", () => {
|
|
expect(sorted(PROVIDER_NAMES)).toEqual(sorted(fixture.providerNames))
|
|
expect(PROVIDER_INFO).toBe(PROVIDERS)
|
|
})
|
|
|
|
it("PROVIDER_INFO: label, defaultBaseUrl, apiKeyUrl and modelList", () => {
|
|
for (const p of PROVIDER_NAMES) {
|
|
const { label, defaultBaseUrl, apiKeyUrl, modelList } = PROVIDERS[p]
|
|
expect([
|
|
p,
|
|
{ label, defaultBaseUrl, apiKeyUrl, modelList },
|
|
]).toEqual([p, { ...fixture.providerInfo[p] }])
|
|
}
|
|
})
|
|
|
|
it("FIXED_CRED_PROVIDERS", () => {
|
|
expect(FIXED_CRED_PROVIDERS).toEqual(fixture.fixedCredProviders)
|
|
})
|
|
|
|
it("PROVIDER_LOGO_MAP, as its reader resolves it", () => {
|
|
for (const p of PROVIDER_NAMES) {
|
|
expect([p, PROVIDER_LOGO_MAP[p] || p]).toEqual([
|
|
p,
|
|
fixture.providerLogoMap[p] || p,
|
|
])
|
|
expect([p, PROVIDERS[p].logo ?? p]).toEqual([
|
|
p,
|
|
fixture.providerLogoMap[p] || p,
|
|
])
|
|
}
|
|
})
|
|
|
|
it("chatRequestUrl's ownPaths", () => {
|
|
const own = PROVIDER_NAMES.filter(
|
|
(p) => chatRequestUrl(p, "https://x.example/v1") === null,
|
|
)
|
|
expect(sorted(own)).toEqual(sorted(fixture.ownPaths))
|
|
})
|
|
|
|
it("SINGLE_SYSTEM_PROVIDERS", () => {
|
|
expect(sorted(SINGLE_SYSTEM_PROVIDERS)).toEqual(
|
|
sorted(fixture.singleSystemProviders),
|
|
)
|
|
})
|
|
|
|
it("PROVIDER_ENV_VARS: the key variable each provider must have", () => {
|
|
for (const p of PROVIDER_NAMES) {
|
|
const { credential, envPrefix, keyEnv } = PROVIDERS[p]
|
|
const required =
|
|
credential === "apiKey" || credential === "vertexKey"
|
|
const derived = required ? (keyEnv ?? `${envPrefix}_API_KEY`) : null
|
|
expect([p, derived]).toEqual([p, fixture.providerEnvVars[p]])
|
|
}
|
|
})
|
|
|
|
it("SDK_KNOWS_ENDPOINT", () => {
|
|
const derived = PROVIDER_NAMES.filter(
|
|
(p) => PROVIDERS[p].sdkKnowsEndpoint,
|
|
)
|
|
expect(sorted(derived)).toEqual(sorted(fixture.sdkKnowsEndpoint))
|
|
})
|
|
|
|
it("PROVIDER_GROUPS, in the picker's order", () => {
|
|
expect(PROVIDER_GROUPS).toEqual(fixture.providerGroups)
|
|
})
|
|
|
|
it("POPULAR_PROVIDERS", () => {
|
|
expect(POPULAR_PROVIDERS).toEqual(fixture.popularProviders)
|
|
})
|
|
|
|
it("OPTIONS_OPEN", () => {
|
|
expect(sorted(OPTIONS_OPEN)).toEqual(sorted(fixture.optionsOpen))
|
|
})
|
|
|
|
it("NO_KEY_NEEDED", () => {
|
|
const derived = PROVIDER_NAMES.filter(
|
|
(p) => PROVIDERS[p].listNeedsNoKey,
|
|
)
|
|
expect(sorted(derived)).toEqual(sorted(fixture.noKeyNeeded))
|
|
})
|
|
|
|
it("the picker notes and base URL hints, word for word in every language", () => {
|
|
const dicts: Record<string, Record<string, string>> = {
|
|
en: enDict.modelConfig,
|
|
zh: zhDict.modelConfig,
|
|
ja: jaDict.modelConfig,
|
|
"zh-Hant": zhHantDict.modelConfig,
|
|
}
|
|
const noted = PROVIDER_NAMES.filter((p) => PROVIDERS[p].note)
|
|
expect(sorted(noted)).toEqual(
|
|
sorted(Object.keys(fixture.providerNoteKeys)),
|
|
)
|
|
for (const p of noted) {
|
|
for (const [lang, dict] of Object.entries(dicts)) {
|
|
expect([p, lang, PROVIDERS[p].note?.[lang as never]]).toEqual([
|
|
p,
|
|
lang,
|
|
dict[fixture.providerNoteKeys[p]],
|
|
])
|
|
}
|
|
}
|
|
const hinted = PROVIDER_NAMES.filter((p) => PROVIDERS[p].baseUrlHint)
|
|
expect(sorted(hinted)).toEqual(
|
|
sorted(Object.keys(fixture.baseUrlHintKeys)),
|
|
)
|
|
for (const p of hinted) {
|
|
for (const [lang, dict] of Object.entries(dicts)) {
|
|
expect([
|
|
p,
|
|
lang,
|
|
PROVIDERS[p].baseUrlHint?.[lang as never],
|
|
]).toEqual([p, lang, dict[fixture.baseUrlHintKeys[p]]])
|
|
}
|
|
}
|
|
})
|
|
|
|
it("the model lists of the providers that cannot list models", () => {
|
|
for (const p of PROVIDER_NAMES) {
|
|
const { modelList, models } = PROVIDERS[p]
|
|
if (modelList) {
|
|
expect([p, models]).toEqual([p, undefined])
|
|
} else {
|
|
expect([p, models]).toEqual([p, SUGGESTED_MODELS[p]])
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
describe("the provider record's own rules", () => {
|
|
it("has 21 providers taking part in server key auto-detection", () => {
|
|
// credential apiKey or vertexKey: the providers whose <P>_API_KEY the
|
|
// server must have; aws, optionalKey and none need no key
|
|
const detected = PROVIDER_NAMES.filter((p) =>
|
|
["apiKey", "vertexKey"].includes(PROVIDERS[p].credential),
|
|
)
|
|
expect(detected).toHaveLength(21)
|
|
expect(sorted(detected)).toEqual(
|
|
sorted(
|
|
Object.entries(fixture.providerEnvVars)
|
|
.filter(([, v]) => v)
|
|
.map(([p]) => p),
|
|
),
|
|
)
|
|
})
|
|
|
|
it("writes every note and hint in every UI language", () => {
|
|
for (const p of PROVIDER_NAMES) {
|
|
for (const copy of [PROVIDERS[p].note, PROVIDERS[p].baseUrlHint]) {
|
|
if (!copy) continue
|
|
expect(sorted(Object.keys(copy))).toEqual(sorted(i18n.locales))
|
|
for (const text of Object.values(copy)) {
|
|
expect(text.trim()).not.toBe("")
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
it("only calls https defaults on its own", () => {
|
|
expect(publicDefaultUrl("sglang")).toBeUndefined()
|
|
expect(publicDefaultUrl("openai")).toBe("https://api.openai.com/v1")
|
|
expect(publicDefaultUrl("bedrock")).toBeUndefined()
|
|
})
|
|
|
|
it("gives the no-key and fixed-credential providers no key variable to detect", () => {
|
|
for (const p of PROVIDER_NAMES) {
|
|
const { credential, envPrefix, fixedCredentials } = PROVIDERS[p]
|
|
if (credential === "none") expect([p, envPrefix]).toEqual([p, null])
|
|
if (credential === "aws") expect([p, envPrefix]).toEqual([p, null])
|
|
if (fixedCredentials) {
|
|
expect(["aws", "vertexKey", "optionalKey"]).toContain(
|
|
credential,
|
|
)
|
|
}
|
|
}
|
|
const fixed = PROVIDER_NAMES.filter(
|
|
(p) => PROVIDERS[p].fixedCredentials,
|
|
)
|
|
expect(fixed).toEqual(["bedrock", "vertexai", "ollama"])
|
|
})
|
|
})
|