mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-13 04:59:51 +08:00
refactor(providers): one record per provider, one place for the key-and-URL decision (#988)
Every fact about a provider lives in one record in lib/providers.ts (SDK kind, credential kind, env var prefix, default URL, picker group, four-language notes, and a short model list for the six providers that cannot list models). The thirteen tables keyed by provider name are gone. resolveEndpoint decides whose key a call uses and where it goes; usesServerCredentials reads its answer instead of mirroring getAIModel's branches; serverCredentialError holds the server credential check once. The UI, the connection test and the routes read the record's credential and sdk fields instead of comparing provider names (44 such checks before, 6 after, in the admin panel's fixed-variable branches). Intended behavior changes: providers that can list their models have no hand-written list; the settings dialog fetches the live list in the background once credentials are entered for a provider without models and offers it behind "Browse models". Azure has no placeholder default URL (old entries that hold it still count as unset); the base URL field of Azure and of the new "custom" OpenAI-compatible preset (CUSTOM_API_KEY, CUSTOM_BASE_URL) is labelled required. The connection test answers 400 for an unknown provider name; prototype names are refused everywhere. Provider lists in error messages and the admin "add provider" dropdown follow the picker's order. Also fixed, from before the refactor: the redirect guard drops x-access-code on cross-origin redirects, and edgeOneEndpoint accepts the Origin header only when it matches Host or X-Forwarded-Host. Verification: a behavior snapshot of getAIModel's SDK factory calls and the quota functions over 25 providers, 22 client override shapes and 9 server environments (5843 rows), recorded on the old code and unchanged apart from the intended changes; 752 unit tests, 65 provider e2e tests, tsc, biome, next build. Reviewed by five models from one brief; their findings and the resulting fixes are recorded in docs/plans/2026-10-provider-refactor.md.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import {
|
||||
ADMIN_PROVIDERS_KEY,
|
||||
adminProvidersToConfig,
|
||||
credEnvNames,
|
||||
deriveEnvUpdates,
|
||||
loadAdminProviders,
|
||||
maskAdminProviders,
|
||||
@@ -13,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
|
||||
@@ -471,3 +473,56 @@ describe("loadAdminProviders", () => {
|
||||
expect(() => maskAdminProviders(loaded)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("credEnvNames", () => {
|
||||
it("names the panel's variables for every provider", () => {
|
||||
// bedrock, vertexai and ollama write fixed variables; EdgeOne has none
|
||||
const expected: Record<string, string | null> = {
|
||||
openai: "OPENAI",
|
||||
anthropic: "ANTHROPIC",
|
||||
google: "GOOGLE",
|
||||
deepseek: "DEEPSEEK",
|
||||
qwen: "QWEN",
|
||||
doubao: "DOUBAO",
|
||||
glm: "GLM",
|
||||
kimi: "KIMI",
|
||||
minimax: "MINIMAX",
|
||||
mimo: "MIMO",
|
||||
openrouter: "OPENROUTER",
|
||||
aihubmix: "AIHUBMIX",
|
||||
siliconflow: "SILICONFLOW",
|
||||
modelscope: "MODELSCOPE",
|
||||
novita: "NOVITA",
|
||||
atlascloud: "ATLASCLOUD",
|
||||
qiniu: "QINIU",
|
||||
gateway: "AI_GATEWAY",
|
||||
bedrock: null,
|
||||
vertexai: null,
|
||||
azure: "AZURE",
|
||||
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)
|
||||
expect([p, first, third]).toEqual([
|
||||
p,
|
||||
prefix
|
||||
? {
|
||||
key: `ADMIN_${prefix}_API_KEY`,
|
||||
url: `ADMIN_${prefix}_BASE_URL`,
|
||||
}
|
||||
: {},
|
||||
prefix
|
||||
? {
|
||||
key: `ADMIN_${prefix}_API_KEY_3`,
|
||||
url: `ADMIN_${prefix}_BASE_URL_3`,
|
||||
}
|
||||
: {},
|
||||
])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,3 +126,28 @@ describe("EdgeOne endpoints", () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the EdgeOne endpoint's origin", () => {
|
||||
it("takes the browser's Origin for the host the request was addressed to", async () => {
|
||||
vi.stubEnv("AI_PROVIDER", "edgeone")
|
||||
vi.stubEnv("AI_MODEL", "@tx/deepseek-ai/deepseek-v3-0324")
|
||||
// Behind a proxy the request URL is internal; Origin names the site
|
||||
await send({ origin: "https://draw.example", host: "draw.example" })
|
||||
expect(calls[0]?.url).toBe(
|
||||
"https://draw.example/api/edgeai/chat/completions",
|
||||
)
|
||||
})
|
||||
|
||||
it("ignores another site's Origin, so the cookies stay on this site", async () => {
|
||||
vi.stubEnv("AI_PROVIDER", "edgeone")
|
||||
vi.stubEnv("AI_MODEL", "@tx/deepseek-ai/deepseek-v3-0324")
|
||||
await send({
|
||||
origin: "https://attacker.example",
|
||||
host: "localhost",
|
||||
cookie: "eo_token=t",
|
||||
})
|
||||
expect(calls[0]?.url).toBe(
|
||||
"http://localhost/api/edgeai/chat/completions",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* A behavior snapshot of the provider layer. For every provider, every shape
|
||||
* 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. 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
|
||||
* getAIModel asked for the redirect guard (the real function returns
|
||||
* undefined unless ALLOW_PRIVATE_URLS=false or DYNAMODB_QUOTA_TABLE is set).
|
||||
* isPrivateUrl answers by host name. Math.random is 0, so an array apiKeyEnv
|
||||
* picks its first variable with a value.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
|
||||
import {
|
||||
type ClientOverrides,
|
||||
getAIModel,
|
||||
getServerProvider,
|
||||
globalBaseUrl,
|
||||
usesServerCredentials,
|
||||
usesServerEndpoint,
|
||||
} from "@/lib/ai-providers"
|
||||
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 */
|
||||
function factory(name: string, methods: string[] = []) {
|
||||
return (options: unknown) => {
|
||||
const provider = (modelId: string) => ({
|
||||
factory: name,
|
||||
method: "default",
|
||||
modelId,
|
||||
options,
|
||||
})
|
||||
for (const method of methods) {
|
||||
;(provider as any)[method] = (modelId: string) => ({
|
||||
factory: name,
|
||||
method,
|
||||
modelId,
|
||||
options,
|
||||
})
|
||||
}
|
||||
return provider
|
||||
}
|
||||
}
|
||||
return { factory }
|
||||
})
|
||||
|
||||
vi.mock("@/lib/admin/settings", () => ({
|
||||
loadSettings: () => ({}),
|
||||
getEnvFallback: (key: string) => process.env[key] ?? null,
|
||||
}))
|
||||
|
||||
vi.mock("@/lib/ssrf-protection", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/ssrf-protection")>()
|
||||
const guard = Object.assign(async () => new Response(), {
|
||||
sentinel: "guarded",
|
||||
})
|
||||
const privateHosts = new Set(["127.0.0.1", "localhost", "10.0.0.5"])
|
||||
return {
|
||||
...actual,
|
||||
redirectGuardedFetch: () => guard,
|
||||
isPrivateUrl: async (url: string) =>
|
||||
privateHosts.has(new URL(url).hostname),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
...actual,
|
||||
createGateway: hoisted.factory("gateway"),
|
||||
wrapLanguageModel: ({ model, middleware }: any) => ({
|
||||
wrapped: model,
|
||||
middleware,
|
||||
}),
|
||||
extractReasoningMiddleware: (options: any) => ({
|
||||
middleware: "extractReasoning",
|
||||
...options,
|
||||
}),
|
||||
defaultSettingsMiddleware: (options: any) => ({
|
||||
middleware: "defaultSettings",
|
||||
...options,
|
||||
}),
|
||||
}
|
||||
})
|
||||
vi.mock("@ai-sdk/openai", () => ({
|
||||
createOpenAI: hoisted.factory("openai", ["chat", "responses"]),
|
||||
}))
|
||||
vi.mock("@ai-sdk/anthropic", () => ({
|
||||
createAnthropic: hoisted.factory("anthropic"),
|
||||
}))
|
||||
vi.mock("@ai-sdk/google", () => ({
|
||||
createGoogleGenerativeAI: hoisted.factory("google"),
|
||||
}))
|
||||
vi.mock("@ai-sdk/google-vertex", () => ({
|
||||
createVertex: hoisted.factory("vertex"),
|
||||
}))
|
||||
vi.mock("@ai-sdk/azure", () => ({
|
||||
createAzure: hoisted.factory("azure", ["chat", "responses"]),
|
||||
}))
|
||||
vi.mock("@ai-sdk/amazon-bedrock", () => ({
|
||||
createAmazonBedrock: hoisted.factory("bedrock"),
|
||||
}))
|
||||
vi.mock("@aws-sdk/credential-providers", () => ({
|
||||
fromNodeProviderChain: () => "node-chain",
|
||||
}))
|
||||
vi.mock("@ai-sdk/deepseek", () => ({
|
||||
createDeepSeek: hoisted.factory("deepseek"),
|
||||
}))
|
||||
vi.mock("@ai-sdk/openai-compatible", () => ({
|
||||
createOpenAICompatible: hoisted.factory("openai-compatible"),
|
||||
}))
|
||||
vi.mock("@aihubmix/ai-sdk-provider", () => ({
|
||||
createAihubmix: hoisted.factory("aihubmix"),
|
||||
}))
|
||||
vi.mock("@openrouter/ai-sdk-provider", () => ({
|
||||
createOpenRouter: hoisted.factory("openrouter"),
|
||||
}))
|
||||
vi.mock("ollama-ai-provider-v2", () => ({
|
||||
createOllama: hoisted.factory("ollama"),
|
||||
}))
|
||||
|
||||
/**
|
||||
* 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",
|
||||
"anthropic",
|
||||
"google",
|
||||
"vertexai",
|
||||
"azure",
|
||||
"bedrock",
|
||||
"ollama",
|
||||
"openrouter",
|
||||
"aihubmix",
|
||||
"deepseek",
|
||||
"siliconflow",
|
||||
"sglang",
|
||||
"gateway",
|
||||
"edgeone",
|
||||
"doubao",
|
||||
"modelscope",
|
||||
"glm",
|
||||
"qwen",
|
||||
"qiniu",
|
||||
"kimi",
|
||||
"minimax",
|
||||
"novita",
|
||||
"mimo",
|
||||
"atlascloud",
|
||||
"custom",
|
||||
]
|
||||
|
||||
/** 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",
|
||||
google: "gemini-3.1-pro",
|
||||
vertexai: "gemini-3.1-pro-preview",
|
||||
azure: "gpt-5.5",
|
||||
bedrock: "global.anthropic.claude-opus-5-5",
|
||||
ollama: "test-model",
|
||||
openrouter: "anthropic/claude-opus-5.5",
|
||||
aihubmix: "claude-fable-5",
|
||||
deepseek: "deepseek-v4-pro",
|
||||
siliconflow: "deepseek-ai/DeepSeek-V4-Pro",
|
||||
sglang: "default",
|
||||
gateway: "anthropic/claude-opus-5.5",
|
||||
edgeone: "@tx/deepseek-ai/deepseek-v32",
|
||||
doubao: "doubao-seed-2-0-pro-260215",
|
||||
modelscope: "deepseek-ai/DeepSeek-V4-Pro",
|
||||
glm: "test-model",
|
||||
qwen: "test-model",
|
||||
qiniu: "test-model",
|
||||
kimi: "test-model",
|
||||
minimax: "MiniMax-M3",
|
||||
novita: "minimax/minimax-m3",
|
||||
mimo: "mimo-v2.5-pro",
|
||||
atlascloud: "qwen/qwen3.5-flash",
|
||||
custom: "test-model",
|
||||
}
|
||||
|
||||
/** Model ids whose providerOptions the snapshot records for openai and google */
|
||||
const REASONING_PROBE_IDS: Partial<Record<ProviderName, string[]>> = {
|
||||
openai: [
|
||||
// The catalog says these reason, the name pattern does not
|
||||
"gpt-daybreak-blue-latest",
|
||||
"gpt-daybreak-red-latest",
|
||||
"gpt-realtime-2.1",
|
||||
// Matched by the pattern; unknown to the catalog; an alias by case
|
||||
"gpt-5",
|
||||
"gpt-unknown",
|
||||
"GPT-Realtime-2.1",
|
||||
],
|
||||
google: [
|
||||
// The catalog says these reason, the name pattern does not
|
||||
"deep-research-max-preview-04-2026",
|
||||
"deep-research-preview-04-2026",
|
||||
"gemini-flash-latest",
|
||||
"gemini-flash-lite-latest",
|
||||
"gemma-4-26b-a4b-it",
|
||||
"gemma-4-31b-it",
|
||||
// Matched by the pattern; unknown to the catalog; an alias by suffix
|
||||
"gemini-2.5-flash",
|
||||
"gemini-unknown",
|
||||
"gemini-flash-latest-001",
|
||||
],
|
||||
}
|
||||
|
||||
/** The server's key variable for a provider, as the code reads it today */
|
||||
function keyVar(p: ProviderName): string {
|
||||
if (p === "google") return "GOOGLE_GENERATIVE_AI_API_KEY"
|
||||
if (p === "vertexai") return "GOOGLE_VERTEX_API_KEY"
|
||||
if (p === "gateway") return "AI_GATEWAY_API_KEY"
|
||||
return `${p.toUpperCase()}_API_KEY`
|
||||
}
|
||||
|
||||
/** The server's base URL variable for a provider */
|
||||
function urlVar(p: ProviderName): string {
|
||||
if (p === "vertexai") return "GOOGLE_VERTEX_BASE_URL"
|
||||
if (p === "gateway") return "AI_GATEWAY_BASE_URL"
|
||||
return `${p.toUpperCase()}_BASE_URL`
|
||||
}
|
||||
|
||||
type Env = Record<string, string>
|
||||
|
||||
/** Nine server environments, each a function of the provider under test */
|
||||
const ENVIRONMENTS: Record<string, (p: ProviderName) => Env> = {
|
||||
empty: () => ({}),
|
||||
serverKey: (p) => ({ [keyVar(p)]: "server-key" }),
|
||||
serverKeyAndUrl: (p) => ({
|
||||
[keyVar(p)]: "server-key",
|
||||
[urlVar(p)]: "https://server.example/v1",
|
||||
}),
|
||||
ollamaKey: () => ({ OLLAMA_API_KEY: "server-ollama-key" }),
|
||||
azureResource: () => ({ AZURE_RESOURCE_NAME: "server-resource" }),
|
||||
anthropicToken: () => ({ ANTHROPIC_AUTH_TOKEN: "server-token" }),
|
||||
adminAws: () => ({
|
||||
ADMIN_AWS_ACCESS_KEY_ID: "admin-id",
|
||||
ADMIN_AWS_SECRET_ACCESS_KEY: "admin-secret",
|
||||
ADMIN_AWS_REGION: "eu-west-1",
|
||||
}),
|
||||
desktop: (p) => ({
|
||||
NEXT_AI_DRAWIO_DESKTOP: "1",
|
||||
[keyVar(p)]: "server-key",
|
||||
}),
|
||||
everything: (p) => ({
|
||||
[keyVar(p)]: "server-key",
|
||||
[urlVar(p)]: "https://server.example/v1",
|
||||
OLLAMA_API_KEY: "server-ollama-key",
|
||||
AZURE_RESOURCE_NAME: "server-resource",
|
||||
ANTHROPIC_AUTH_TOKEN: "server-token",
|
||||
ADMIN_AWS_ACCESS_KEY_ID: "admin-id",
|
||||
ADMIN_AWS_SECRET_ACCESS_KEY: "admin-secret",
|
||||
AWS_REGION: "us-east-1",
|
||||
}),
|
||||
}
|
||||
|
||||
const CLIENT_URL = "https://u.example/v1"
|
||||
const CLIENT_KEY = "client-key"
|
||||
|
||||
/** Twenty-two shapes of client override; some need variables of their own */
|
||||
const SHAPES: Record<
|
||||
string,
|
||||
{ overrides: Omit<ClientOverrides, "provider" | "modelId">; env?: Env }
|
||||
> = {
|
||||
none: { overrides: {} },
|
||||
key: { overrides: { apiKey: CLIENT_KEY } },
|
||||
url: { overrides: { baseUrl: CLIENT_URL } },
|
||||
keyAndUrl: { overrides: { apiKey: CLIENT_KEY, baseUrl: CLIENT_URL } },
|
||||
keyAndSlash: { overrides: { apiKey: CLIENT_KEY, baseUrl: "/" } },
|
||||
// normalizeBaseUrl drops one endpoint path per call: this URL shows
|
||||
// whether it ran once or twice
|
||||
keyAndPastedPath: {
|
||||
overrides: {
|
||||
apiKey: CLIENT_KEY,
|
||||
baseUrl: "https://u.example/v1/chat/completions/messages",
|
||||
},
|
||||
},
|
||||
keyEnv: {
|
||||
overrides: { apiKeyEnv: "CUSTOM_KEY" },
|
||||
env: { CUSTOM_KEY: "custom-env-key" },
|
||||
},
|
||||
keyEnvEmpty: { overrides: { apiKeyEnv: "CUSTOM_KEY_EMPTY" } },
|
||||
keyEnvArrayPartial: {
|
||||
overrides: { apiKeyEnv: ["CUSTOM_KEY_EMPTY", "CUSTOM_KEY"] },
|
||||
env: { CUSTOM_KEY: "custom-env-key" },
|
||||
},
|
||||
keyEnvArrayEmpty: {
|
||||
overrides: { apiKeyEnv: ["CUSTOM_KEY_EMPTY", "CUSTOM_KEY_EMPTY_2"] },
|
||||
},
|
||||
keyEnvAndUrlEnv: {
|
||||
overrides: { apiKeyEnv: "CUSTOM_KEY", baseUrlEnv: "CUSTOM_URL" },
|
||||
env: {
|
||||
CUSTOM_KEY: "custom-env-key",
|
||||
CUSTOM_URL: "https://custom-env.example/v1",
|
||||
},
|
||||
},
|
||||
awsPair: {
|
||||
overrides: {
|
||||
awsAccessKeyId: "client-id",
|
||||
awsSecretAccessKey: "client-secret",
|
||||
awsRegion: "ap-northeast-1",
|
||||
},
|
||||
},
|
||||
awsIdOnly: {
|
||||
overrides: { awsAccessKeyId: "client-id", awsRegion: "ap-northeast-1" },
|
||||
},
|
||||
vertexKeyAndUrl: {
|
||||
overrides: { vertexApiKey: "client-vertex-key", baseUrl: CLIENT_URL },
|
||||
},
|
||||
trustedUrl: { overrides: { baseUrl: CLIENT_URL, trustedBaseUrl: true } },
|
||||
// A private address: usesServerEndpoint counts it as the server's network
|
||||
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 */
|
||||
const URL_SHAPES = ["url", "keyAndUrl", "keyAndPastedPath", "trustedUrl"]
|
||||
|
||||
// Only the variables set by a row stay set; everything else is cleared
|
||||
const touched = new Set<string>()
|
||||
function setEnv(env: Env) {
|
||||
for (const name of touched) delete process.env[name]
|
||||
touched.clear()
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
process.env[name] = value
|
||||
touched.add(name)
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain data for the snapshot: functions become labels, undefined is dropped */
|
||||
async function plain(value: unknown): Promise<unknown> {
|
||||
if (typeof value === "function") {
|
||||
if ((value as any).sentinel === "guarded") return "guarded"
|
||||
// Bedrock's credentialProvider for a client API key must refuse
|
||||
try {
|
||||
await (value as () => Promise<unknown>)()
|
||||
return "function"
|
||||
} catch (error) {
|
||||
return { rejects: (error as Error).message }
|
||||
}
|
||||
}
|
||||
if (Array.isArray(value)) return Promise.all(value.map(plain))
|
||||
if (value && typeof value === "object") {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [key, inner] of Object.entries(value)) {
|
||||
if (inner !== undefined) out[key] = await plain(inner)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** One getAIModel call and the four companion answers */
|
||||
async function record(overrides: ClientOverrides) {
|
||||
let model: unknown
|
||||
try {
|
||||
const result = getAIModel(overrides)
|
||||
model = {
|
||||
provider: result.provider,
|
||||
modelId: result.modelId,
|
||||
model: await plain(result.model),
|
||||
...(result.providerOptions && {
|
||||
providerOptions: result.providerOptions,
|
||||
}),
|
||||
}
|
||||
} catch (error) {
|
||||
model = { error: (error as Error).message }
|
||||
}
|
||||
const provider = overrides.provider as ProviderName | undefined
|
||||
if (!provider) return model
|
||||
const baseUrl = overrides.baseUrl ?? ""
|
||||
return {
|
||||
getAIModel: model,
|
||||
usesServerCredentials: usesServerCredentials(provider, overrides),
|
||||
usesServerEndpoint: await usesServerEndpoint(
|
||||
provider,
|
||||
normalizeBaseUrl(baseUrl),
|
||||
overrides.apiKey,
|
||||
),
|
||||
globalBaseUrl: globalBaseUrl(provider),
|
||||
chatRequestUrl: chatRequestUrl(provider, baseUrl),
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
function line(label: Record<string, unknown>, result: unknown) {
|
||||
lines.push(JSON.stringify({ ...label, ...(result as object) }))
|
||||
}
|
||||
|
||||
/** Every shape in every environment for one provider and model id */
|
||||
async function matrix(
|
||||
provider: ProviderName,
|
||||
modelId: string,
|
||||
shapes: string[] = Object.keys(SHAPES),
|
||||
urlOverride?: string,
|
||||
) {
|
||||
for (const shape of shapes) {
|
||||
const { overrides, env } = SHAPES[shape]
|
||||
for (const [envName, environment] of Object.entries(ENVIRONMENTS)) {
|
||||
setEnv({ ...environment(provider), ...env })
|
||||
const row: ClientOverrides = { ...overrides, provider, modelId }
|
||||
if (urlOverride && row.baseUrl) row.baseUrl = urlOverride
|
||||
line({ provider, modelId, shape, env: envName }, await record(row))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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$|^CUSTOM_KEY|^CUSTOM_URL$|^KEY_[ABC]$/
|
||||
const saved: Record<string, string | undefined> = {}
|
||||
|
||||
beforeAll(() => {
|
||||
for (const name of Object.keys(process.env)) {
|
||||
if (AMBIENT.test(name)) {
|
||||
saved[name] = process.env[name]
|
||||
delete process.env[name]
|
||||
}
|
||||
}
|
||||
vi.spyOn(Math, "random").mockReturnValue(0)
|
||||
vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
setEnv({})
|
||||
for (const [name, value] of Object.entries(saved)) {
|
||||
if (value !== undefined) process.env[name] = value
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("provider behavior snapshot", () => {
|
||||
it("covers every provider", () => {
|
||||
expect([...MATRIX_PROVIDERS].sort()).toEqual(
|
||||
Object.keys(PROVIDERS).sort(),
|
||||
)
|
||||
})
|
||||
|
||||
it("matches the recorded behavior", async () => {
|
||||
// The matrix: 25 providers, 22 shapes, 9 environments
|
||||
for (const provider of MATRIX_PROVIDERS) {
|
||||
await matrix(provider, MATRIX_MODEL_IDS[provider])
|
||||
}
|
||||
|
||||
// Without a provider: auto-detection and the server's AI_PROVIDER
|
||||
const detectionEnvs: Record<string, Env> = {}
|
||||
for (const p of MATRIX_PROVIDERS) {
|
||||
if (["bedrock", "ollama", "edgeone"].includes(p)) continue
|
||||
detectionEnvs[`only ${keyVar(p)}`] = { [keyVar(p)]: "server-key" }
|
||||
}
|
||||
detectionEnvs["vertex and openai"] = {
|
||||
GOOGLE_VERTEX_API_KEY: "server-key",
|
||||
OPENAI_API_KEY: "server-key",
|
||||
}
|
||||
detectionEnvs["only OLLAMA_API_KEY"] = {
|
||||
OLLAMA_API_KEY: "server-ollama-key",
|
||||
}
|
||||
detectionEnvs["no key at all"] = {}
|
||||
for (const [envName, env] of Object.entries(detectionEnvs)) {
|
||||
setEnv(env)
|
||||
line(
|
||||
{ noProvider: true, env: envName },
|
||||
{
|
||||
serverProvider: getServerProvider(),
|
||||
getAIModel: await record({ modelId: "test-model" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
for (const p of MATRIX_PROVIDERS) {
|
||||
setEnv({ AI_PROVIDER: p })
|
||||
line(
|
||||
{ noProvider: true, env: `AI_PROVIDER=${p}` },
|
||||
{ getAIModel: await record({ modelId: "test-model" }) },
|
||||
)
|
||||
}
|
||||
|
||||
// Model id probes: OpenAI's chat or responses choice, Doubao's
|
||||
// DeepSeek detection, MiniMax's protocol from the URL path
|
||||
for (const modelId of ["gpt-5", "gpt-4o"]) {
|
||||
await matrix("openai", modelId)
|
||||
}
|
||||
for (const modelId of ["deepseek-v3", "doubao-seed"]) {
|
||||
await matrix("doubao", modelId)
|
||||
}
|
||||
await matrix(
|
||||
"minimax",
|
||||
MATRIX_MODEL_IDS.minimax,
|
||||
URL_SHAPES,
|
||||
"https://u.example/anthropic",
|
||||
)
|
||||
|
||||
// providerOptions probes: which model ids get reasoning options
|
||||
for (const provider of ["openai", "google"] as const) {
|
||||
setEnv(ENVIRONMENTS.serverKey(provider))
|
||||
for (const modelId of REASONING_PROBE_IDS[provider] ?? []) {
|
||||
const { providerOptions } = getAIModel({ provider, modelId })
|
||||
line(
|
||||
{ reasoningProbe: true, provider, modelId },
|
||||
{ providerOptions: providerOptions ?? null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await expect(lines.join("\n") + "\n").toMatchFileSnapshot(
|
||||
"./__snapshots__/provider-matrix.jsonl",
|
||||
)
|
||||
})
|
||||
|
||||
it("draws an array apiKeyEnv at random among the variables with a value", () => {
|
||||
setEnv({ KEY_A: "a", KEY_B: "b", KEY_C: "c" })
|
||||
const overrides = {
|
||||
provider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
apiKeyEnv: ["KEY_A", "KEY_B", "KEY_C"],
|
||||
}
|
||||
vi.mocked(Math.random).mockReturnValue(0)
|
||||
expect((getAIModel(overrides).model as any).options.apiKey).toBe("a")
|
||||
vi.mocked(Math.random).mockReturnValue(0.999)
|
||||
expect((getAIModel(overrides).model as any).options.apiKey).toBe("c")
|
||||
vi.mocked(Math.random).mockReturnValue(0)
|
||||
// None set: the server credential check refuses before any fallback
|
||||
setEnv({ OPENAI_API_KEY: "default-key" })
|
||||
expect(() => getAIModel(overrides)).toThrow(/At least one of/)
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,13 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
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 {
|
||||
filterProviderGroups,
|
||||
hasCredentials,
|
||||
POPULAR_PROVIDERS,
|
||||
PROVIDER_GROUPS,
|
||||
PROVIDER_NOTE_KEYS,
|
||||
providerStatus,
|
||||
} from "@/lib/provider-setup"
|
||||
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 => ({
|
||||
@@ -25,21 +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()
|
||||
})
|
||||
|
||||
it("have their notes in every language", () => {
|
||||
for (const dict of [enDict, zhDict, jaDict, zhHantDict]) {
|
||||
const t = dict.modelConfig as Record<string, string>
|
||||
for (const key of Object.values(PROVIDER_NOTE_KEYS)) {
|
||||
expect(t[key as string]).toBeTruthy()
|
||||
}
|
||||
}
|
||||
for (const p of POPULAR_PROVIDERS) expect(PROVIDERS[p]).toBeTruthy()
|
||||
})
|
||||
|
||||
it("are searched by label or name, ignoring case and spaces", () => {
|
||||
@@ -63,6 +50,37 @@ describe("hasCredentials", () => {
|
||||
[{ provider: "edgeone" }, true],
|
||||
[{ provider: "vertexai", apiKey: "k" }, false],
|
||||
[{ provider: "vertexai", vertexApiKey: "k" }, true],
|
||||
// Azure has no default URL: the user's own is part of signing in
|
||||
[{ provider: "azure", apiKey: "k" }, false],
|
||||
[
|
||||
{
|
||||
provider: "azure",
|
||||
apiKey: "k",
|
||||
baseUrl: "https://contoso.openai.azure.com/openai",
|
||||
},
|
||||
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],
|
||||
[
|
||||
{ provider: "bedrock", apiKey: "k", awsRegion: "us-east-1" },
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* 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",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* The server credential check getAIModel runs when the request brings no
|
||||
* key of its own, and the auto-detection of the server's provider. Written
|
||||
* against the pre-refactor validateProviderCredentials and detectProvider;
|
||||
* the refactor merges both into serverCredentialError, which must keep every
|
||||
* answer and every error text below.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { envVarsOf, getAIModel, getServerProvider } from "@/lib/ai-providers"
|
||||
import { PROVIDER_NAMES, PROVIDERS } from "@/lib/providers"
|
||||
|
||||
vi.mock("@/lib/admin/settings", () => ({
|
||||
loadSettings: () => ({}),
|
||||
getEnvFallback: (key: string) => process.env[key] ?? null,
|
||||
}))
|
||||
|
||||
const { factory } = vi.hoisted(() => ({
|
||||
factory: () => (modelId: string) => ({ modelId }),
|
||||
}))
|
||||
vi.mock("@ai-sdk/openai", () => ({ createOpenAI: factory }))
|
||||
vi.mock("@ai-sdk/anthropic", () => ({ createAnthropic: factory }))
|
||||
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",
|
||||
"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(() => {
|
||||
for (const name of VARS) vi.stubEnv(name, undefined)
|
||||
vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
const run = (overrides: Parameters<typeof getAIModel>[0]) => () =>
|
||||
getAIModel({ modelId: "m", ...overrides })
|
||||
|
||||
describe("server credentials", () => {
|
||||
it("accepts ANTHROPIC_AUTH_TOKEN alone for Anthropic", () => {
|
||||
vi.stubEnv("ANTHROPIC_AUTH_TOKEN", "token")
|
||||
expect(run({ provider: "anthropic" })).not.toThrow()
|
||||
vi.stubEnv("ANTHROPIC_AUTH_TOKEN", undefined)
|
||||
expect(run({ provider: "anthropic" })).toThrow(
|
||||
"Either ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN environment variable is required for anthropic provider. Please set one in your .env.local file.",
|
||||
)
|
||||
})
|
||||
|
||||
it("holds a server model to its own variable, even for Anthropic", () => {
|
||||
vi.stubEnv("ANTHROPIC_API_KEY", "k")
|
||||
vi.stubEnv("ANTHROPIC_AUTH_TOKEN", "token")
|
||||
expect(run({ provider: "anthropic", apiKeyEnv: "MY_KEY" })).toThrow(
|
||||
"MY_KEY environment variable is required for anthropic provider. Please set it in your .env.local file.",
|
||||
)
|
||||
})
|
||||
|
||||
it("does not fall back to the default variable from an empty custom one", () => {
|
||||
vi.stubEnv("OPENAI_API_KEY", "k")
|
||||
expect(run({ provider: "openai", apiKeyEnv: "MY_KEY" })).toThrow(
|
||||
"MY_KEY environment variable is required for openai provider. Please set it in your .env.local file.",
|
||||
)
|
||||
vi.stubEnv("MY_KEY", "custom")
|
||||
expect(run({ provider: "openai", apiKeyEnv: "MY_KEY" })).not.toThrow()
|
||||
})
|
||||
|
||||
it("needs a URL or a resource name for Azure after the key", () => {
|
||||
expect(run({ provider: "azure" })).toThrow(
|
||||
"AZURE_API_KEY environment variable is required for azure provider. Please set it in your .env.local file.",
|
||||
)
|
||||
vi.stubEnv("AZURE_API_KEY", "k")
|
||||
expect(run({ provider: "azure" })).toThrow(
|
||||
"Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME to be set. Please set one in your .env.local file.",
|
||||
)
|
||||
vi.stubEnv("AZURE_RESOURCE_NAME", "res")
|
||||
expect(run({ provider: "azure" })).not.toThrow()
|
||||
})
|
||||
|
||||
it("lets a server model's own URL variable stand in for Azure's", () => {
|
||||
vi.stubEnv("MY_KEY", "k")
|
||||
vi.stubEnv("MY_URL", "https://res.openai.azure.com/openai")
|
||||
expect(
|
||||
run({
|
||||
provider: "azure",
|
||||
apiKeyEnv: "MY_KEY",
|
||||
baseUrlEnv: "MY_URL",
|
||||
}),
|
||||
).not.toThrow()
|
||||
vi.stubEnv("MY_URL", undefined)
|
||||
expect(
|
||||
run({
|
||||
provider: "azure",
|
||||
apiKeyEnv: "MY_KEY",
|
||||
baseUrlEnv: "MY_URL",
|
||||
}),
|
||||
).toThrow(/AZURE_BASE_URL or AZURE_RESOURCE_NAME/)
|
||||
})
|
||||
|
||||
it("passes an array apiKeyEnv on any value and checks nothing else", () => {
|
||||
vi.stubEnv("MY_KEY_2", "k")
|
||||
// No Azure URL anywhere, and the key check still passes
|
||||
expect(
|
||||
run({ provider: "azure", apiKeyEnv: ["MY_KEY", "MY_KEY_2"] }),
|
||||
).not.toThrow()
|
||||
vi.stubEnv("MY_KEY_2", undefined)
|
||||
expect(
|
||||
run({ provider: "azure", apiKeyEnv: ["MY_KEY", "MY_KEY_2"] }),
|
||||
).toThrow(
|
||||
"At least one of [MY_KEY, MY_KEY_2] environment variables is required for azure provider. Please set at least one in your .env.local file.",
|
||||
)
|
||||
})
|
||||
|
||||
it("asks nothing of providers without a required key variable", () => {
|
||||
expect(run({ provider: "ollama" })).not.toThrow()
|
||||
// A custom variable is still checked
|
||||
expect(run({ provider: "ollama", apiKeyEnv: "MY_KEY" })).toThrow(
|
||||
"MY_KEY environment variable is required for ollama provider. Please set it in your .env.local file.",
|
||||
)
|
||||
})
|
||||
|
||||
it("names Vertex AI's key variable", () => {
|
||||
expect(run({ provider: "vertexai" })).toThrow(
|
||||
"GOOGLE_VERTEX_API_KEY environment variable is required for vertexai provider. Please set it in your .env.local file.",
|
||||
)
|
||||
vi.stubEnv("GOOGLE_VERTEX_API_KEY", "k")
|
||||
expect(run({ provider: "vertexai" })).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("server provider auto-detection", () => {
|
||||
it("finds the one provider with a key", () => {
|
||||
vi.stubEnv("GOOGLE_VERTEX_API_KEY", "k")
|
||||
expect(getServerProvider()).toBe("vertexai")
|
||||
expect(run({})).not.toThrow()
|
||||
})
|
||||
|
||||
it("needs AI_PROVIDER when two have keys", () => {
|
||||
vi.stubEnv("GOOGLE_VERTEX_API_KEY", "k")
|
||||
vi.stubEnv("OPENAI_API_KEY", "k")
|
||||
expect(getServerProvider()).toBeNull()
|
||||
expect(run({})).toThrow(
|
||||
"Multiple AI providers configured (openai, vertexai). Please set AI_PROVIDER to specify which one to use.",
|
||||
)
|
||||
})
|
||||
|
||||
it("accepts AI_PROVIDER=ollama with no key at all", () => {
|
||||
vi.stubEnv("AI_PROVIDER", "ollama")
|
||||
expect(getServerProvider()).toBe("ollama")
|
||||
expect(run({})).not.toThrow()
|
||||
})
|
||||
|
||||
it("only counts Azure with a URL or a resource name", () => {
|
||||
vi.stubEnv("AZURE_API_KEY", "k")
|
||||
expect(getServerProvider()).toBeNull()
|
||||
vi.stubEnv("AZURE_RESOURCE_NAME", "res")
|
||||
expect(getServerProvider()).toBe("azure")
|
||||
})
|
||||
})
|
||||
@@ -179,6 +179,7 @@ describe("redirectGuardedFetch with the quota on", () => {
|
||||
"x-api-key": "anthropic-key",
|
||||
"x-goog-api-key": "google-key",
|
||||
"api-key": "azure-key",
|
||||
"x-access-code": "site-code",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
@@ -194,7 +195,12 @@ describe("redirectGuardedFetch with the quota on", () => {
|
||||
expect(sent(0).get("authorization")).toBe("Bearer user-key")
|
||||
expect(sent(1).get("authorization")).toBeNull()
|
||||
expect(sent(1).get("cookie")).toBeNull()
|
||||
for (const name of ["x-api-key", "x-goog-api-key", "api-key"]) {
|
||||
for (const name of [
|
||||
"x-api-key",
|
||||
"x-goog-api-key",
|
||||
"api-key",
|
||||
"x-access-code",
|
||||
]) {
|
||||
expect(sent(1).get(name)).toBeNull()
|
||||
}
|
||||
expect(sent(1).get("content-type")).toBe("application/json")
|
||||
|
||||
@@ -382,3 +382,28 @@ describe("the Test on the deployment's own endpoints", () => {
|
||||
expect(quota.checks).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("POST /api/validate-model with an unknown provider", () => {
|
||||
const send = (body: object) =>
|
||||
validateModel(
|
||||
new Request("http://localhost/api/validate-model", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ modelId: "m", ...body }),
|
||||
}),
|
||||
)
|
||||
|
||||
it("refuses a name that is not a provider, with or without a key", async () => {
|
||||
for (const body of [
|
||||
{ provider: "nope", apiKey: "k" },
|
||||
{ provider: "nope" },
|
||||
// A prototype property is not a provider either
|
||||
{ provider: "toString", apiKey: "k" },
|
||||
]) {
|
||||
const res = await send(body)
|
||||
expect(res.status).toBe(400)
|
||||
const { error } = await res.json()
|
||||
expect(error).toMatch(/^Invalid provider: /)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user