Files
next-ai-draw-io/tests/unit/ai-providers-credentials.test.ts
T
dayuan.jiang 0855b35ff2 fix(server): count quota by the key actually used, and more review fixes
Found by the PR review, each with a test that failed first:
- Quota: any key header skipped it, even one the provider never reads
  (x-aws-access-key-id with OpenAI), so a request ran on the server's
  key without being counted. The check now runs after the model is
  resolved and uses usesServerCredentials. On main already.
- usesServerCredentials read the raw base URL; "/" cleans up to none, so
  an Ollama request ran on the server's key past the server-model check.
- SGLang's default 127.0.0.1:8000 only fills the settings form. Chat and
  the model list used it as a real address, so the server called its own
  machine even with private URLs blocked. Now a base URL is required.
- With a user's OpenAI key and no base URL, the SDK read the server's
  OPENAI_BASE_URL. The official endpoint is now passed. On main already.
- The Test button refused nothing on the server's keys (Ollama Cloud),
  and a 15 s timeout reported "connected, no tool call".
- The model list for Ollama without a base URL came from ollama.com while
  chat went to the server's Ollama.
- Bedrock's "Too many tokens, please wait" counted as context too long.
- On the server's keys the provider's error text stays in the server log;
  it can name the server's AWS account, role or internal hosts.
- Desktop app: the preset keys are the user's own (NEXT_AI_DRAWIO_DESKTOP),
  so Max Output Tokens can be raised and keyless models in settings work
  again. A launch that found the remembered port taken no longer replaces
  it, which hid the user's chats and settings for good.
2026-10-04 23:04:21 +09:00

321 lines
11 KiB
TypeScript

import { createOpenAI } from "@ai-sdk/openai"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import {
getAIModel,
getValidationModel,
usesServerCredentials,
} from "@/lib/ai-providers"
const settings = vi.hoisted(() => ({ values: {} as Record<string, string> }))
vi.mock("@/lib/admin/settings", () => ({
loadSettings: () => settings.values,
}))
vi.mock("@ai-sdk/google-vertex", () => {
const mockProviderFn = vi.fn(() => ({ modelId: "test-model" }))
return { createVertex: vi.fn(() => mockProviderFn) }
})
vi.mock("@ai-sdk/openai", () => {
const mockModel = { modelId: "test-model" }
const mockProviderFn = vi.fn(() => mockModel) as any
mockProviderFn.chat = vi.fn(() => mockModel)
return {
createOpenAI: vi.fn(() => mockProviderFn),
openai: vi.fn(() => mockModel),
}
})
vi.mock("@ai-sdk/amazon-bedrock", () => {
const mockProviderFn = vi.fn(() => ({ modelId: "test-model" }))
return { createAmazonBedrock: vi.fn(() => mockProviderFn) }
})
vi.mock("@aws-sdk/credential-providers", () => ({
fromNodeProviderChain: vi.fn(() => "node-chain"),
}))
vi.mock("@openrouter/ai-sdk-provider", () => {
const mockProviderFn = vi.fn(() => ({ modelId: "test-model" }))
return { createOpenRouter: vi.fn(() => mockProviderFn) }
})
const ENV_KEYS = [
"GOOGLE_VERTEX_API_KEY",
"GOOGLE_VERTEX_BASE_URL",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"OPENROUTER_API_KEY",
"ADMIN_OPENAI_API_KEY",
"ADMIN_OPENROUTER_API_KEY",
"OLLAMA_API_KEY",
"ADMIN_AWS_ACCESS_KEY_ID",
"ADMIN_AWS_SECRET_ACCESS_KEY",
"ADMIN_AWS_REGION",
"AWS_REGION",
"AI_PROVIDER",
"AI_MODEL",
"VALIDATION_MODEL",
"NEXT_AI_DRAWIO_DESKTOP",
"SGLANG_API_KEY",
"SGLANG_BASE_URL",
]
const savedEnv: Record<string, string | undefined> = {}
beforeEach(() => {
for (const key of ENV_KEYS) {
savedEnv[key] = process.env[key]
delete process.env[key]
}
settings.values = {}
vi.clearAllMocks()
})
afterEach(() => {
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) delete process.env[key]
else process.env[key] = savedEnv[key]
}
})
describe("Vertex AI key security", () => {
it("never sends the server key to a client base URL", () => {
process.env.GOOGLE_VERTEX_API_KEY = "server-vertex-key"
// Any x-ai-api-key passes the outer guard; the branch must still refuse
expect(() =>
getAIModel({
provider: "vertexai",
apiKey: "x",
baseUrl: "https://attacker.example",
modelId: "gemini-2.5-flash",
}),
).toThrow("Vertex AI requires an API key")
})
it("sends the client key to the client base URL", async () => {
process.env.GOOGLE_VERTEX_API_KEY = "server-vertex-key"
const { createVertex } = await import("@ai-sdk/google-vertex")
getAIModel({
provider: "vertexai",
vertexApiKey: "client-key",
baseUrl: "https://my-proxy.example",
modelId: "gemini-2.5-flash",
})
expect(createVertex).toHaveBeenCalledWith({
apiKey: "client-key",
baseURL: "https://my-proxy.example",
})
})
it("does not send the client key to the server's base URL", async () => {
process.env.GOOGLE_VERTEX_BASE_URL = "https://server-proxy.internal"
const { createVertex } = await import("@ai-sdk/google-vertex")
getAIModel({
provider: "vertexai",
vertexApiKey: "client-key",
modelId: "gemini-2.5-flash",
})
expect(createVertex).toHaveBeenCalledWith({ apiKey: "client-key" })
})
it("still uses the server key and base URL without client overrides", async () => {
process.env.GOOGLE_VERTEX_API_KEY = "server-vertex-key"
process.env.GOOGLE_VERTEX_BASE_URL = "https://server-proxy.internal"
const { createVertex } = await import("@ai-sdk/google-vertex")
getAIModel({ provider: "vertexai", modelId: "gemini-2.5-flash" })
expect(createVertex).toHaveBeenCalledWith({
apiKey: "server-vertex-key",
baseURL: "https://server-proxy.internal",
})
})
})
describe("Bedrock admin panel credentials", () => {
it("uses the ADMIN_AWS_* keys when the client sends none", async () => {
process.env.ADMIN_AWS_ACCESS_KEY_ID = "panel-id"
process.env.ADMIN_AWS_SECRET_ACCESS_KEY = "panel-secret"
process.env.ADMIN_AWS_REGION = "eu-west-1"
process.env.AWS_REGION = "us-east-1"
const { createAmazonBedrock } = await import("@ai-sdk/amazon-bedrock")
getAIModel({ provider: "bedrock", modelId: "amazon.nova-lite-v1:0" })
expect(createAmazonBedrock).toHaveBeenCalledWith({
region: "eu-west-1",
accessKeyId: "panel-id",
secretAccessKey: "panel-secret",
})
})
it("prefers the client's keys and region", async () => {
process.env.ADMIN_AWS_ACCESS_KEY_ID = "panel-id"
process.env.ADMIN_AWS_SECRET_ACCESS_KEY = "panel-secret"
process.env.ADMIN_AWS_REGION = "eu-west-1"
const { createAmazonBedrock } = await import("@ai-sdk/amazon-bedrock")
getAIModel({
provider: "bedrock",
modelId: "amazon.nova-lite-v1:0",
awsAccessKeyId: "client-id",
awsSecretAccessKey: "client-secret",
awsRegion: "ap-northeast-1",
})
expect(createAmazonBedrock).toHaveBeenCalledWith({
region: "ap-northeast-1",
accessKeyId: "client-id",
secretAccessKey: "client-secret",
})
})
it("falls back to the default AWS credential chain", async () => {
process.env.AWS_REGION = "us-east-1"
const { createAmazonBedrock } = await import("@ai-sdk/amazon-bedrock")
getAIModel({ provider: "bedrock", modelId: "amazon.nova-lite-v1:0" })
expect(createAmazonBedrock).toHaveBeenCalledWith({
region: "us-east-1",
credentialProvider: "node-chain",
})
})
})
describe("usesServerCredentials", () => {
it("is true when no key comes with the request", () => {
expect(usesServerCredentials("openai", {})).toBe(true)
expect(usesServerCredentials("openai", { apiKey: "k" })).toBe(false)
})
it("looks at the credential each provider actually uses", () => {
// A stray x-ai-api-key does not replace the IAM role or Vertex key
expect(usesServerCredentials("bedrock", { apiKey: "x" })).toBe(true)
expect(
usesServerCredentials("bedrock", {
awsAccessKeyId: "id",
awsSecretAccessKey: "secret",
}),
).toBe(false)
expect(usesServerCredentials("vertexai", { apiKey: "x" })).toBe(true)
expect(usesServerCredentials("vertexai", { vertexApiKey: "k" })).toBe(
false,
)
})
it("treats keyless EdgeOne and local Ollama as free", () => {
expect(usesServerCredentials("edgeone", {})).toBe(false)
expect(usesServerCredentials("ollama", {})).toBe(false)
expect(
usesServerCredentials("ollama", {
baseUrl: "http://localhost:11434",
}),
).toBe(false)
process.env.OLLAMA_API_KEY = "server-ollama-key"
expect(usesServerCredentials("ollama", {})).toBe(true)
})
})
describe("server model apiKeyEnv", () => {
it("uses the custom env var on the official OpenAI endpoint", async () => {
process.env.ADMIN_OPENAI_API_KEY = "panel-key"
const { createOpenAI, openai } = await import("@ai-sdk/openai")
getAIModel({
provider: "openai",
modelId: "gpt-4o",
apiKeyEnv: "ADMIN_OPENAI_API_KEY",
})
// The default instance would read OPENAI_API_KEY instead
expect(openai).not.toHaveBeenCalled()
expect(createOpenAI).toHaveBeenCalledWith({ apiKey: "panel-key" })
})
})
describe("getValidationModel", () => {
it("uses the admin panel default's ADMIN_ key", async () => {
settings.values = {
ADMIN_PROVIDERS: JSON.stringify([
{
id: "p1",
provider: "openrouter",
name: "My OpenRouter",
apiKey: "panel-key",
models: ["openai/gpt-4o"],
isDefault: true,
},
]),
}
// What deriveEnvUpdates writes for that panel config
process.env.AI_PROVIDER = "openrouter"
process.env.AI_MODEL = "openai/gpt-4o"
process.env.ADMIN_OPENROUTER_API_KEY = "panel-key"
const { createOpenRouter } = await import("@openrouter/ai-sdk-provider")
expect(() => getValidationModel()).not.toThrow()
expect(createOpenRouter).toHaveBeenCalledWith({ apiKey: "panel-key" })
})
it("uses the standard env vars without a panel default", async () => {
process.env.AI_PROVIDER = "openrouter"
process.env.AI_MODEL = "openai/gpt-4o"
process.env.OPENROUTER_API_KEY = "env-key"
const { createOpenRouter } = await import("@openrouter/ai-sdk-provider")
getValidationModel()
expect(createOpenRouter).toHaveBeenCalledWith({ apiKey: "env-key" })
})
})
describe("whose keys a request uses", () => {
it("cleans the base URL like the request does", () => {
// "/" and a pasted path clean up to no base URL: the server's Ollama
process.env.OLLAMA_API_KEY = "server-ollama-key"
expect(usesServerCredentials("ollama", { baseUrl: "/" })).toBe(true)
expect(
usesServerCredentials("ollama", { baseUrl: "/chat/completions" }),
).toBe(true)
})
it("counts the desktop app's keys as the user's own", () => {
// Electron passes the user's preset keys as server env vars
process.env.NEXT_AI_DRAWIO_DESKTOP = "1"
expect(usesServerCredentials("openai", {})).toBe(false)
})
it("sends a user's OpenAI key to the official endpoint", () => {
// The SDK would otherwise read the server's OPENAI_BASE_URL
process.env.OPENAI_BASE_URL = "https://operator-proxy.example.com/v1"
getAIModel({
provider: "openai",
apiKey: "user-key",
modelId: "gpt-5.5",
})
expect(createOpenAI).toHaveBeenLastCalledWith(
expect.objectContaining({
apiKey: "user-key",
baseURL: "https://api.openai.com/v1",
}),
)
// Still the Responses API, like without a base URL
const provider = vi.mocked(createOpenAI).mock.results.at(-1)?.value
expect(provider.chat).not.toHaveBeenCalled()
})
it("needs a base URL for SGLang instead of using 127.0.0.1", () => {
expect(() =>
getAIModel({ provider: "sglang", apiKey: "k", modelId: "m" }),
).toThrow(/base URL/)
})
})