mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 20:49:53 +08:00
refactor(providers): the quota functions and the model list fallback read the record
Step 4c of the provider refactor plan. usesServerCredentials asks resolveEndpoint whose key the call uses, so the 'mirrors each branch of getAIModel' comment and its switch are gone. usesServerEndpoint reads the credential kind (none, optionalKey) and the sdk (bedrock) instead of three provider names. listFallbackUrl reads the credential kind and publicDefaultUrl. Before this commit the differential test agreed with the pre-refactor copies on all 30720 and 288 input classes; the copies are deleted now, and the behavior snapshot keeps both functions' answers for every recorded scenario.
This commit is contained in:
+16
-30
@@ -1203,10 +1203,11 @@ export function getServerProvider(): ProviderName | null {
|
||||
|
||||
/**
|
||||
* Whether a call made with the caller's own settings runs on an endpoint of
|
||||
* the deployment: its EdgeOne function, the server's keyless Ollama, or an
|
||||
* the deployment: the site's own function (EdgeOne, credential "none"), the
|
||||
* server's keyless local provider (Ollama, credential "optionalKey"), or an
|
||||
* address on the server's network (which ignores a dummy key header).
|
||||
* Bedrock and EdgeOne never use a client base URL. Never in the desktop
|
||||
* app, where every endpoint is the user's. clientBaseUrl: normalized.
|
||||
* Bedrock never uses a client base URL. Never in the desktop app, where
|
||||
* every endpoint is the user's. clientBaseUrl: normalized.
|
||||
*/
|
||||
export async function usesServerEndpoint(
|
||||
provider: ProviderName | null | undefined,
|
||||
@@ -1214,10 +1215,13 @@ export async function usesServerEndpoint(
|
||||
apiKey: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (process.env.NEXT_AI_DRAWIO_DESKTOP === "1") return false
|
||||
if (provider === "edgeone") return true
|
||||
if (provider === "ollama" && !clientBaseUrl && !apiKey) return true
|
||||
const def = provider ? providerDef(provider) : undefined
|
||||
if (def?.credential === "none") return true
|
||||
if (def?.credential === "optionalKey" && !clientBaseUrl && !apiKey) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
provider !== "bedrock" &&
|
||||
def?.sdk !== "bedrock" &&
|
||||
!!clientBaseUrl &&
|
||||
(await isPrivateUrl(clientBaseUrl))
|
||||
)
|
||||
@@ -1225,8 +1229,8 @@ export async function usesServerEndpoint(
|
||||
|
||||
/**
|
||||
* Whether the call is paid for by the server's own credentials (env keys or
|
||||
* IAM role) rather than credentials sent with the request. Mirrors which key
|
||||
* each branch of getAIModel ends up using.
|
||||
* IAM role) rather than credentials sent with the request: the key
|
||||
* resolveEndpoint picks for getAIModel is the server's.
|
||||
*/
|
||||
export function usesServerCredentials(
|
||||
provider: ProviderName,
|
||||
@@ -1236,28 +1240,10 @@ export function usesServerCredentials(
|
||||
if (process.env.NEXT_AI_DRAWIO_DESKTOP === "1") return false
|
||||
// Cleaned like getAIModel does: "/" means no base URL
|
||||
const baseUrl = normalizeBaseUrl(overrides?.baseUrl ?? "")
|
||||
switch (provider) {
|
||||
case "bedrock":
|
||||
return !(
|
||||
overrides?.apiKey ||
|
||||
(overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey)
|
||||
)
|
||||
case "vertexai":
|
||||
return !overrides?.vertexApiKey
|
||||
case "edgeone":
|
||||
// The platform's own endpoint, no key involved
|
||||
return false
|
||||
case "ollama":
|
||||
// Only a server key costs money; a keyless local server or the
|
||||
// client's own server does not
|
||||
return (
|
||||
!baseUrl &&
|
||||
!overrides?.apiKey &&
|
||||
!!(overrides?.apiKeyEnv || process.env.OLLAMA_API_KEY)
|
||||
)
|
||||
default:
|
||||
return !overrides?.apiKey
|
||||
}
|
||||
return (
|
||||
resolveEndpoint(provider, { ...overrides, baseUrl }).keySource ===
|
||||
"server"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+10
-18
@@ -1,12 +1,8 @@
|
||||
import { createGateway } from "ai"
|
||||
import { getModelInfo } from "@/lib/model-catalog"
|
||||
import { PROVIDERS, type ProviderName, publicDefaultUrl } from "@/lib/providers"
|
||||
import { readLimitedBody } from "@/lib/read-limited-body"
|
||||
import {
|
||||
normalizeBaseUrl,
|
||||
ollamaApiUrl,
|
||||
PROVIDER_INFO,
|
||||
type ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
import { normalizeBaseUrl, ollamaApiUrl } from "@/lib/types/model-config"
|
||||
|
||||
/** A model a provider offers. tools is false when it cannot call tools. */
|
||||
export interface ListedModel {
|
||||
@@ -17,10 +13,7 @@ export interface ListedModel {
|
||||
const AIHUBMIX_MODELS_ENDPOINT = "https://aihubmix.com/api/v1/models"
|
||||
|
||||
export function canListModels(provider: ProviderName): boolean {
|
||||
return (
|
||||
Object.hasOwn(PROVIDER_INFO, provider) &&
|
||||
!!PROVIDER_INFO[provider].modelList
|
||||
)
|
||||
return Object.hasOwn(PROVIDERS, provider) && !!PROVIDERS[provider].modelList
|
||||
}
|
||||
|
||||
// Models in OpenAI-style lists that are not for chat
|
||||
@@ -131,16 +124,15 @@ async function getJson(
|
||||
|
||||
/**
|
||||
* Where to list from without the user's base URL: where chat goes then. For
|
||||
* Ollama without a key that is the server's Ollama, else the SDK's local
|
||||
* default; a local default in PROVIDER_INFO (SGLang's) only fills the
|
||||
* settings form.
|
||||
* Ollama (an optional key) without a key that is the server's Ollama, else
|
||||
* the SDK's local default; a local default in the record (SGLang's) only
|
||||
* fills the settings form.
|
||||
*/
|
||||
function listFallbackUrl(provider: ProviderName, apiKey?: string): string {
|
||||
if (provider === "ollama" && !apiKey) {
|
||||
if (PROVIDERS[provider].credential === "optionalKey" && !apiKey) {
|
||||
return process.env.OLLAMA_BASE_URL || "http://127.0.0.1:11434/api"
|
||||
}
|
||||
const url = PROVIDER_INFO[provider].defaultBaseUrl
|
||||
return url?.startsWith("https://") ? url : ""
|
||||
return publicDefaultUrl(provider) ?? ""
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,7 +159,7 @@ export async function listProviderModels(
|
||||
baseUrl &&
|
||||
!/^https:\/\/aihubmix\.com(\/v1)?$/.test(base)
|
||||
? "openai"
|
||||
: PROVIDER_INFO[provider].modelList
|
||||
: PROVIDERS[provider].modelList
|
||||
|
||||
switch (style) {
|
||||
case "anthropic": {
|
||||
@@ -242,7 +234,7 @@ export async function listProviderModels(
|
||||
default: {
|
||||
if (!base) {
|
||||
throw new ModelListError(
|
||||
`${PROVIDER_INFO[provider].label} needs a base URL to list its models.`,
|
||||
`${PROVIDERS[provider].label} needs a base URL to list its models.`,
|
||||
)
|
||||
}
|
||||
const data = await getJson(`${base}/models`, bearer, fetchFn)
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
/**
|
||||
* Differential tests for the provider refactor. The two quota functions'
|
||||
* bodies as they were before the refactor are copied here; every equivalence
|
||||
* class of their inputs is enumerated and the live functions must agree.
|
||||
* These copies go away once the functions read the provider record (step 4c);
|
||||
* the snapshot in provider-matrix.test.ts keeps their answers after that.
|
||||
*
|
||||
* The last block checks that every provider-keyed table names only known
|
||||
* providers, so a typo in one table cannot hide behind a Partial type.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
|
||||
import {
|
||||
type ClientOverrides,
|
||||
resolveEndpoint,
|
||||
SINGLE_SYSTEM_PROVIDERS,
|
||||
usesServerCredentials,
|
||||
usesServerEndpoint,
|
||||
} from "@/lib/ai-providers"
|
||||
import {
|
||||
OPTIONS_OPEN,
|
||||
POPULAR_PROVIDERS,
|
||||
PROVIDER_GROUPS,
|
||||
PROVIDER_NOTE_KEYS,
|
||||
} from "@/lib/provider-setup"
|
||||
import {
|
||||
FIXED_CRED_PROVIDERS,
|
||||
normalizeBaseUrl,
|
||||
PROVIDER_INFO,
|
||||
PROVIDER_LOGO_MAP,
|
||||
type ProviderName,
|
||||
SUGGESTED_MODELS,
|
||||
} from "@/lib/types/model-config"
|
||||
|
||||
vi.mock("@/lib/admin/settings", () => ({
|
||||
loadSettings: () => ({}),
|
||||
getEnvFallback: (key: string) => process.env[key] ?? null,
|
||||
}))
|
||||
|
||||
const PRIVATE_HOSTS = new Set(["10.0.0.5", "localhost"])
|
||||
const isPrivate = (url: string) => PRIVATE_HOSTS.has(new URL(url).hostname)
|
||||
|
||||
vi.mock("@/lib/ssrf-protection", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/ssrf-protection")>()
|
||||
return {
|
||||
...actual,
|
||||
isPrivateUrl: async (url: string) =>
|
||||
new Set(["10.0.0.5", "localhost"]).has(new URL(url).hostname),
|
||||
}
|
||||
})
|
||||
|
||||
const PROVIDERS = Object.keys(PROVIDER_INFO) as ProviderName[]
|
||||
|
||||
// lib/ai-providers.ts usesServerCredentials, copied before the refactor
|
||||
function legacyUsesServerCredentials(
|
||||
provider: ProviderName,
|
||||
overrides?: ClientOverrides,
|
||||
): boolean {
|
||||
// The desktop app's local server only has the user's own keys (its .env)
|
||||
if (process.env.NEXT_AI_DRAWIO_DESKTOP === "1") return false
|
||||
// Cleaned like getAIModel does: "/" means no base URL
|
||||
const baseUrl = normalizeBaseUrl(overrides?.baseUrl ?? "")
|
||||
switch (provider) {
|
||||
case "bedrock":
|
||||
return !(
|
||||
overrides?.apiKey ||
|
||||
(overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey)
|
||||
)
|
||||
case "vertexai":
|
||||
return !overrides?.vertexApiKey
|
||||
case "edgeone":
|
||||
// The platform's own endpoint, no key involved
|
||||
return false
|
||||
case "ollama":
|
||||
// Only a server key costs money; a keyless local server or the
|
||||
// client's own server does not
|
||||
return (
|
||||
!baseUrl &&
|
||||
!overrides?.apiKey &&
|
||||
!!(overrides?.apiKeyEnv || process.env.OLLAMA_API_KEY)
|
||||
)
|
||||
default:
|
||||
return !overrides?.apiKey
|
||||
}
|
||||
}
|
||||
|
||||
// lib/ai-providers.ts usesServerEndpoint, copied before the refactor, with
|
||||
// isPrivateUrl replaced by a synchronous host name check
|
||||
function legacyUsesServerEndpoint(
|
||||
provider: ProviderName | null | undefined,
|
||||
clientBaseUrl: string,
|
||||
apiKey: string | null | undefined,
|
||||
): boolean {
|
||||
if (process.env.NEXT_AI_DRAWIO_DESKTOP === "1") return false
|
||||
if (provider === "edgeone") return true
|
||||
if (provider === "ollama" && !clientBaseUrl && !apiKey) return true
|
||||
return provider !== "bedrock" && !!clientBaseUrl && isPrivate(clientBaseUrl)
|
||||
}
|
||||
|
||||
const saved: Record<string, string | undefined> = {}
|
||||
const VARS = [
|
||||
"NEXT_AI_DRAWIO_DESKTOP",
|
||||
"OLLAMA_API_KEY",
|
||||
"SET_VAR",
|
||||
"EMPTY_VAR",
|
||||
]
|
||||
|
||||
beforeAll(() => {
|
||||
for (const name of VARS) {
|
||||
saved[name] = process.env[name]
|
||||
delete process.env[name]
|
||||
}
|
||||
process.env.SET_VAR = "a-value"
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
for (const name of VARS) {
|
||||
if (saved[name] === undefined) delete process.env[name]
|
||||
else process.env[name] = saved[name]
|
||||
}
|
||||
})
|
||||
|
||||
function setFlag(name: string, on: boolean) {
|
||||
if (on) process.env[name] = "1"
|
||||
else delete process.env[name]
|
||||
}
|
||||
|
||||
describe("usesServerCredentials against its pre-refactor body", () => {
|
||||
it("agrees on all 30720 input classes", () => {
|
||||
const apiKeys = [undefined, "k"]
|
||||
const baseUrls = [
|
||||
undefined,
|
||||
"/",
|
||||
"https://u.example",
|
||||
"https://u.example/v1/chat/completions/messages",
|
||||
]
|
||||
const awsIds = [undefined, "id"]
|
||||
const awsSecrets = [undefined, "s"]
|
||||
const vertexKeys = [undefined, "v"]
|
||||
const apiKeyEnvs: (string | string[] | undefined)[] = [
|
||||
undefined,
|
||||
"SET_VAR",
|
||||
"EMPTY_VAR",
|
||||
["EMPTY_VAR", "SET_VAR"],
|
||||
["EMPTY_VAR", "EMPTY_VAR_2"],
|
||||
]
|
||||
let count = 0
|
||||
for (const desktop of [false, true]) {
|
||||
setFlag("NEXT_AI_DRAWIO_DESKTOP", desktop)
|
||||
for (const ollamaKey of [false, true]) {
|
||||
if (ollamaKey) process.env.OLLAMA_API_KEY = "server-ollama-key"
|
||||
else delete process.env.OLLAMA_API_KEY
|
||||
for (const provider of PROVIDERS)
|
||||
for (const apiKey of apiKeys)
|
||||
for (const baseUrl of baseUrls)
|
||||
for (const awsAccessKeyId of awsIds)
|
||||
for (const awsSecretAccessKey of awsSecrets)
|
||||
for (const vertexApiKey of vertexKeys)
|
||||
for (const apiKeyEnv of apiKeyEnvs) {
|
||||
const overrides: ClientOverrides = {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
vertexApiKey,
|
||||
apiKeyEnv,
|
||||
}
|
||||
const expected =
|
||||
legacyUsesServerCredentials(
|
||||
provider,
|
||||
overrides,
|
||||
)
|
||||
const actual =
|
||||
usesServerCredentials(
|
||||
provider,
|
||||
overrides,
|
||||
)
|
||||
if (actual !== expected) {
|
||||
expect.fail(
|
||||
`${provider} ${JSON.stringify(overrides)} desktop=${desktop} ollamaKey=${ollamaKey}: ${actual} instead of ${expected}`,
|
||||
)
|
||||
}
|
||||
// resolveEndpoint's keySource is
|
||||
// what the function will read;
|
||||
// the desktop rule stays outside
|
||||
const fromEndpoint =
|
||||
!desktop &&
|
||||
resolveEndpoint(provider, {
|
||||
...overrides,
|
||||
baseUrl: normalizeBaseUrl(
|
||||
baseUrl ?? "",
|
||||
),
|
||||
}).keySource === "server"
|
||||
if (fromEndpoint !== expected) {
|
||||
expect.fail(
|
||||
`resolveEndpoint ${provider} ${JSON.stringify(overrides)} ollamaKey=${ollamaKey}: ${fromEndpoint} instead of ${expected}`,
|
||||
)
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(count).toBe(30720)
|
||||
})
|
||||
})
|
||||
|
||||
describe("usesServerEndpoint against its pre-refactor body", () => {
|
||||
it("agrees on all 288 input classes", async () => {
|
||||
let count = 0
|
||||
for (const desktop of [false, true]) {
|
||||
setFlag("NEXT_AI_DRAWIO_DESKTOP", desktop)
|
||||
for (const provider of PROVIDERS) {
|
||||
for (const clientBaseUrl of [
|
||||
"",
|
||||
"https://u.example",
|
||||
"http://10.0.0.5:8000",
|
||||
]) {
|
||||
for (const apiKey of [undefined, "k"]) {
|
||||
const expected = legacyUsesServerEndpoint(
|
||||
provider,
|
||||
clientBaseUrl,
|
||||
apiKey,
|
||||
)
|
||||
const actual = await usesServerEndpoint(
|
||||
provider,
|
||||
clientBaseUrl,
|
||||
apiKey,
|
||||
)
|
||||
if (actual !== expected) {
|
||||
expect.fail(
|
||||
`${provider} url=${clientBaseUrl} key=${apiKey} desktop=${desktop}: ${actual} instead of ${expected}`,
|
||||
)
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(count).toBe(288)
|
||||
})
|
||||
})
|
||||
|
||||
describe("provider-keyed tables", () => {
|
||||
const names = new Set<string>(PROVIDERS)
|
||||
const onlyKnown = (list: Iterable<string>, table: string) => {
|
||||
const items = [...list]
|
||||
for (const p of items) {
|
||||
if (!names.has(p)) expect.fail(`${table} names unknown "${p}"`)
|
||||
}
|
||||
expect(new Set(items).size, `${table} has duplicates`).toBe(
|
||||
items.length,
|
||||
)
|
||||
}
|
||||
|
||||
it("name only known providers, each once", () => {
|
||||
onlyKnown(SINGLE_SYSTEM_PROVIDERS, "SINGLE_SYSTEM_PROVIDERS")
|
||||
onlyKnown(FIXED_CRED_PROVIDERS, "FIXED_CRED_PROVIDERS")
|
||||
onlyKnown(Object.keys(PROVIDER_LOGO_MAP), "PROVIDER_LOGO_MAP")
|
||||
onlyKnown(Object.keys(SUGGESTED_MODELS), "SUGGESTED_MODELS")
|
||||
onlyKnown(POPULAR_PROVIDERS, "POPULAR_PROVIDERS")
|
||||
onlyKnown(Object.keys(PROVIDER_NOTE_KEYS), "PROVIDER_NOTE_KEYS")
|
||||
onlyKnown(OPTIONS_OPEN, "OPTIONS_OPEN")
|
||||
const grouped = PROVIDER_GROUPS.flatMap((g) => g.providers)
|
||||
onlyKnown(grouped, "PROVIDER_GROUPS")
|
||||
expect(grouped.length).toBe(PROVIDERS.length)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user