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:
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user