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:
dayuan.jiang
2026-10-12 21:17:51 +09:00
parent 65625ba212
commit 75ffc32a49
29 changed files with 1880 additions and 161 deletions
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -14,6 +14,7 @@ import {
validateAdminProviders,
} from "@/lib/admin/providers"
import { _resetForTests, saveSettings } from "@/lib/admin/settings"
import { PROVIDER_NAMES } from "@/lib/providers"
import { loadRawServerModelsConfig } from "@/lib/server-model-config"
let tmpDir: string
@@ -501,7 +502,9 @@ describe("credEnvNames", () => {
edgeone: null,
ollama: null,
sglang: "SGLANG",
custom: "CUSTOM",
}
expect(Object.keys(expected).sort()).toEqual([...PROVIDER_NAMES].sort())
for (const [p, prefix] of Object.entries(expected)) {
const first = credEnvNames(p as StoredAdminProvider["provider"], 0)
const third = credEnvNames(p as StoredAdminProvider["provider"], 2)
@@ -521,6 +524,5 @@ describe("credEnvNames", () => {
: {},
])
}
expect(Object.keys(expected)).toHaveLength(24)
})
})
+47 -15
View File
@@ -3,8 +3,9 @@
* of client override and every server environment it records what
* getAIModel hands to the SDK factory (or the error it throws) together with
* the answers of usesServerCredentials, usesServerEndpoint, globalBaseUrl
* and chatRequestUrl. Each refactor step must leave the snapshot file
* unchanged; a diff names the provider and the scenario whose behavior moved.
* and chatRequestUrl. A change to the provider layer must leave the
* existing rows unchanged (a new provider only adds rows); a diff names the
* provider and the scenario whose behavior moved.
*
* Every SDK factory is mocked to return a plain description of its options.
* redirectGuardedFetch returns a sentinel, so the snapshot shows whether
@@ -22,12 +23,8 @@ import {
usesServerCredentials,
usesServerEndpoint,
} from "@/lib/ai-providers"
import {
chatRequestUrl,
normalizeBaseUrl,
PROVIDER_INFO,
type ProviderName,
} from "@/lib/types/model-config"
import { PROVIDERS, type ProviderName } from "@/lib/providers"
import { chatRequestUrl, normalizeBaseUrl } from "@/lib/types/model-config"
const hoisted = vi.hoisted(() => {
/** An SDK factory whose models describe the factory call that made them */
@@ -130,8 +127,9 @@ vi.mock("ollama-ai-provider-v2", () => ({
}))
/**
* The providers in the order of today's ProviderName union. Written out so
* the snapshot's row order does not follow the order of the provider record.
* The providers in the order they had before the record existed, then the
* ones added since. Written out so the snapshot's row order does not follow
* the order of the provider record.
*/
const MATRIX_PROVIDERS: ProviderName[] = [
"openai",
@@ -161,7 +159,7 @@ const MATRIX_PROVIDERS: ProviderName[] = [
"custom",
]
/** The model id each provider runs in the matrix (the first suggested one) */
/** The model id each provider runs in the matrix (the first of its former suggested list, or test-model) */
const MATRIX_MODEL_IDS: Record<ProviderName, string> = {
openai: "gpt-6.1-sol",
anthropic: "claude-opus-5-5",
@@ -269,7 +267,7 @@ const ENVIRONMENTS: Record<string, (p: ProviderName) => Env> = {
const CLIENT_URL = "https://u.example/v1"
const CLIENT_KEY = "client-key"
/** Sixteen shapes of client override; some need variables of their own */
/** Twenty-two shapes of client override; some need variables of their own */
const SHAPES: Record<
string,
{ overrides: Omit<ClientOverrides, "provider" | "modelId">; env?: Env }
@@ -324,6 +322,40 @@ const SHAPES: Record<
keyAndPrivateUrl: {
overrides: { apiKey: CLIENT_KEY, baseUrl: "http://10.0.0.5:8000/v1" },
},
// The Vertex key alone goes to the official endpoint
vertexKeyOnly: { overrides: { vertexApiKey: "client-vertex-key" } },
// Temporary AWS credentials carry a session token
awsPairWithSession: {
overrides: {
awsAccessKeyId: "client-id",
awsSecretAccessKey: "client-secret",
awsSessionToken: "client-session",
awsRegion: "ap-northeast-1",
},
},
// The site's cookies and access code, as the routes pass them for EdgeOne
headersAndUrl: {
overrides: {
baseUrl: CLIENT_URL,
headers: { cookie: "eo_token=t", "x-access-code": "code" },
},
},
// The admin panel's Test of an entry with a key and the server's own URL
trustedUrlAndKey: {
overrides: {
apiKey: CLIENT_KEY,
baseUrl: CLIENT_URL,
trustedBaseUrl: true,
},
},
// A user's key with a server model's own URL variable: the key must not
// go to that URL
keyAndUrlEnv: {
overrides: { apiKey: CLIENT_KEY, baseUrlEnv: "CUSTOM_URL" },
env: { CUSTOM_URL: "https://custom-env.example/v1" },
},
// "/" alone cleans up to no URL at all
slashOnly: { overrides: { baseUrl: "/" } },
}
/** The shapes that carry a client URL, for the MiniMax /anthropic probe */
@@ -421,7 +453,7 @@ async function matrix(
// Variables the layer reads, cleared before the matrix runs so the
// machine's own environment cannot reach the snapshot
const AMBIENT =
/_API_KEY|_BASE_URL|_RESOURCE_NAME|_AUTH_TOKEN|_REASONING_|_THINKING_|^AWS_|^ADMIN_|^AI_|^GOOGLE_TOP_|^OLLAMA_|^NEXT_AI_DRAWIO_DESKTOP$|^ALLOW_PRIVATE_URLS$|^DYNAMODB_QUOTA_TABLE$|^VALIDATION_MODEL$/
/_API_KEY|_BASE_URL|_RESOURCE_NAME|_AUTH_TOKEN|_REASONING_|_THINKING_|^AWS_|^ADMIN_|^AI_|^GOOGLE_TOP_|^OLLAMA_|^NEXT_AI_DRAWIO_DESKTOP$|^ALLOW_PRIVATE_URLS$|^DYNAMODB_QUOTA_TABLE$|^VALIDATION_MODEL$|^CUSTOM_KEY|^CUSTOM_URL$|^KEY_[ABC]$/
const saved: Record<string, string | undefined> = {}
beforeAll(() => {
@@ -446,12 +478,12 @@ afterAll(() => {
describe("provider behavior snapshot", () => {
it("covers every provider", () => {
expect([...MATRIX_PROVIDERS].sort()).toEqual(
Object.keys(PROVIDER_INFO).sort(),
Object.keys(PROVIDERS).sort(),
)
})
it("matches the recorded behavior", async () => {
// The matrix: 25 providers, 16 shapes, 9 environments
// The matrix: 25 providers, 22 shapes, 9 environments
for (const provider of MATRIX_PROVIDERS) {
await matrix(provider, MATRIX_MODEL_IDS[provider])
}
+23 -21
View File
@@ -6,8 +6,8 @@ import {
PROVIDER_GROUPS,
providerStatus,
} from "@/lib/provider-setup"
import { PROVIDER_NAMES, PROVIDERS } from "@/lib/providers"
import { PROVIDER_INFO, type ProviderConfig } from "@/lib/types/model-config"
import { PROVIDERS } from "@/lib/providers"
import type { ProviderConfig } from "@/lib/types/model-config"
import { useUiStore } from "@/stores/ui-store"
const provider = (p: Partial<ProviderConfig>): ProviderConfig => ({
@@ -21,12 +21,12 @@ const provider = (p: Partial<ProviderConfig>): ProviderConfig => ({
describe("provider groups", () => {
it("hold every provider exactly once", () => {
const grouped = PROVIDER_GROUPS.flatMap((g) => g.providers)
expect([...grouped].sort()).toEqual(Object.keys(PROVIDER_INFO).sort())
expect([...grouped].sort()).toEqual(Object.keys(PROVIDERS).sort())
expect(new Set(grouped).size).toBe(grouped.length)
})
it("offer known providers as popular ones", () => {
for (const p of POPULAR_PROVIDERS) expect(PROVIDER_INFO[p]).toBeTruthy()
for (const p of POPULAR_PROVIDERS) expect(PROVIDERS[p]).toBeTruthy()
})
it("are searched by label or name, ignoring case and spaces", () => {
@@ -60,6 +60,25 @@ describe("hasCredentials", () => {
},
true,
],
// The placeholder Azure once wrote into new entries names no resource
[
{
provider: "azure",
apiKey: "k",
baseUrl: "https://your-resource.openai.azure.com/openai",
},
false,
],
// The OpenAI-compatible preset has no default URL either
[{ provider: "custom", apiKey: "k" }, false],
[
{
provider: "custom",
apiKey: "k",
baseUrl: "http://127.0.0.1:1234/v1",
},
true,
],
// SGLang's local default fills the form, so a key is enough here
[{ provider: "sglang", apiKey: "k" }, true],
[{ provider: "bedrock", apiKey: "k" }, false],
@@ -89,23 +108,6 @@ describe("hasCredentials", () => {
expect([p, hasCredentials(provider(p))]).toEqual([p, expected])
}
})
it("asks every provider for a key, an AWS pair, a Vertex key or nothing", () => {
// With only a key filled in, by the record's credential kind
for (const p of PROVIDER_NAMES) {
const { credential, defaultBaseUrl } = PROVIDERS[p]
const expected =
credential === "none" || credential === "optionalKey"
? true
: credential === "apiKey"
? !!defaultBaseUrl
: false
expect([
p,
hasCredentials(provider({ provider: p, apiKey: "k" })),
]).toEqual([p, expected])
}
})
})
describe("providerStatus", () => {
+67 -8
View File
@@ -12,7 +12,6 @@ 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"
import { PROVIDER_INFO } from "@/lib/types/model-config"
vi.mock("@/lib/admin/settings", () => ({
loadSettings: () => ({}),
@@ -22,18 +21,44 @@ vi.mock("@/lib/admin/settings", () => ({
const sorted = (list: Iterable<string>) => [...list].sort()
describe("the provider record", () => {
it("is what the rest of the app imports as PROVIDER_INFO", () => {
expect(PROVIDER_INFO).toBe(PROVIDERS)
expect(PROVIDER_NAMES).toHaveLength(25)
})
// 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("has 22 providers taking part in server key auto-detection", () => {
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(detected).toHaveLength(22)
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(
@@ -41,6 +66,40 @@ describe("the provider record", () => {
)
})
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]) {
@@ -59,7 +118,7 @@ describe("the provider record", () => {
expect(publicDefaultUrl("bedrock")).toBeUndefined()
})
it("gives the no-key and fixed-credential providers no key variable to detect", () => {
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])
+12 -10
View File
@@ -6,7 +6,8 @@
* answer and every error text below.
*/
import { beforeEach, describe, expect, it, vi } from "vitest"
import { getAIModel, getServerProvider } from "@/lib/ai-providers"
import { envVarsOf, getAIModel, getServerProvider } from "@/lib/ai-providers"
import { PROVIDER_NAMES, PROVIDERS } from "@/lib/providers"
vi.mock("@/lib/admin/settings", () => ({
loadSettings: () => ({}),
@@ -22,21 +23,22 @@ vi.mock("@ai-sdk/azure", () => ({ createAzure: factory }))
vi.mock("@ai-sdk/google-vertex", () => ({ createVertex: factory }))
vi.mock("ollama-ai-provider-v2", () => ({ createOllama: factory }))
// Every provider's key and URL variables, the alternative and resource
// variables, and this file's own: the machine's environment must not reach
// the checks under test
const VARS = [
"AI_PROVIDER",
"AI_MODEL",
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"AZURE_API_KEY",
"AZURE_BASE_URL",
"AZURE_RESOURCE_NAME",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"GOOGLE_VERTEX_API_KEY",
"OLLAMA_API_KEY",
"MY_KEY",
"MY_KEY_2",
"MY_URL",
...PROVIDER_NAMES.flatMap((p) => {
const { apiKey, baseUrl } = envVarsOf(p)
const { altKeyEnv, resourceNameEnv } = PROVIDERS[p]
return [apiKey, baseUrl, altKeyEnv, resourceNameEnv].filter(
(name): name is string => !!name,
)
}),
]
beforeEach(() => {