Files
next-ai-draw-io/tests/unit/providers.test.ts
T
dayuan.jiang 75ffc32a49 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.
2026-10-12 21:17:51 +09:00

219 lines
7.3 KiB
TypeScript

/**
* The rules the provider record (lib/providers.ts) must keep, and the parts
* of the UI that follow its order. The record replaced thirteen tables keyed
* by provider name; while they were being replaced, a fixture of their old
* values checked the derived ones, and the behavior snapshot
* (provider-matrix.test.ts) still holds every scenario's answer.
*/
import { readFileSync } from "node:fs"
import path from "node:path"
import { describe, expect, it, vi } from "vitest"
import { envVarsOf } from "@/lib/ai-providers"
import { i18n } from "@/lib/i18n/config"
import { POPULAR_PROVIDERS, PROVIDER_GROUPS } from "@/lib/provider-setup"
import { PROVIDER_NAMES, PROVIDERS, publicDefaultUrl } from "@/lib/providers"
vi.mock("@/lib/admin/settings", () => ({
loadSettings: () => ({}),
getEnvFallback: (key: string) => process.env[key] ?? null,
}))
const sorted = (list: Iterable<string>) => [...list].sort()
describe("the provider record", () => {
// The fields that change what the server does are spelled out here, so
// a slip in one record fails a test instead of changing a request
const withField = (
field: "singleSystem" | "listNeedsNoKey" | "sdkKnowsEndpoint",
) => sorted(PROVIDER_NAMES.filter((p) => PROVIDERS[p][field]))
it("lets the server auto-detect exactly the providers that need a key", () => {
// 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(sorted(detected)).toEqual(
sorted([
"openai",
"anthropic",
"google",
"deepseek",
"qwen",
"doubao",
"glm",
"kimi",
"minimax",
"mimo",
"openrouter",
"aihubmix",
"siliconflow",
"modelscope",
"novita",
"atlascloud",
"qiniu",
"gateway",
"vertexai",
"azure",
"sglang",
"custom",
]),
)
// Ollama has a key variable too, an optional one
const withKeyVar = PROVIDER_NAMES.filter((p) => !!envVarsOf(p).apiKey)
expect(sorted(detected)).toEqual(
sorted(withKeyVar.filter((p) => p !== "ollama")),
)
})
it("merges system messages for the providers that reject several", () => {
expect(withField("singleSystem")).toEqual(
sorted([
"minimax",
"glm",
"qwen",
"kimi",
"qiniu",
"novita",
"mimo",
"custom",
]),
)
})
it("lists models without a key for the providers with a public or local list", () => {
expect(withField("listNeedsNoKey")).toEqual(
sorted(["ollama", "openrouter", "aihubmix"]),
)
})
it("leaves the base URL to the SDKs that know their endpoint", () => {
expect(withField("sdkKnowsEndpoint")).toEqual(
sorted([
"openai",
"google",
"azure",
"openrouter",
"gateway",
"deepseek",
]),
)
})
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 providers no env prefix and limits fixed credentials to non-key kinds", () => {
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"])
})
it("keeps a short model list exactly for 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?.length ?? 0]).not.toEqual([p, 0])
}
}
})
it("names every server variable in env.example", () => {
// env.example is written by hand; a new provider adds its two lines
const example = readFileSync(
path.join(process.cwd(), "env.example"),
"utf8",
)
for (const p of PROVIDER_NAMES) {
const { apiKey, baseUrl } = envVarsOf(p)
for (const name of [apiKey, baseUrl]) {
if (!name) continue
expect([p, name, example.includes(name)]).toEqual([
p,
name,
true,
])
}
}
})
})
describe("the picker's order", () => {
it("groups every provider once, in record order", () => {
expect(PROVIDER_GROUPS).toEqual([
{
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", "custom"] },
])
})
it("offers the same popular providers", () => {
expect(POPULAR_PROVIDERS).toEqual([
"openai",
"anthropic",
"google",
"deepseek",
"openrouter",
"qwen",
])
})
})