mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-13 04:59:51 +08:00
refactor(providers): resolveEndpoint decides key and URL; getAIModel loses its second switch
Steps 4a and 4b of the provider refactor plan. resolveEndpoint(provider, overrides) answers, by the record's credential kind, whose key a call uses (keySource), which key, which URL, whether the URL was configured and whether the client-chosen URL needs the redirect guard. It never throws, fetches or logs. The five branches of getAIModel's second switch (bedrock, vertexai, ollama, edgeone, default) are gone: Bedrock's credential selection moved unchanged into bedrockModel(), called when the record's sdk is bedrock; every other provider goes through resolveEndpoint and createModel, whose switch now runs on the record's sdk field (Vertex and Ollama became cases). The three errors for a missing Vertex key or a missing base URL stay in getAIModel with their texts. The SSRF guard at the top reads the credential kind instead of provider names. resolveApiKey returns whose key it found; the array apiKeyEnv log moved to getAIModel so a later usesServerCredentials call draws no second log line. The behavior snapshot is unchanged, and resolveEndpoint's keySource agrees with the pre-refactor usesServerCredentials on all 30720 input classes.
This commit is contained in:
+346
-243
@@ -22,7 +22,13 @@ import {
|
||||
loadAdminProviders,
|
||||
} from "@/lib/admin/providers"
|
||||
import { getEnvFallback } from "@/lib/admin/settings"
|
||||
import { PROVIDER_NAMES, PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
import {
|
||||
PROVIDER_NAMES,
|
||||
PROVIDERS,
|
||||
type ProviderDef,
|
||||
type ProviderName,
|
||||
publicDefaultUrl,
|
||||
} from "@/lib/providers"
|
||||
import { isPrivateUrl, redirectGuardedFetch } from "@/lib/ssrf-protection"
|
||||
import { normalizeBaseUrl, ollamaApiUrl } from "@/lib/types/model-config"
|
||||
|
||||
@@ -132,6 +138,24 @@ export function resolveBaseURL(
|
||||
return userBaseUrl || serverBaseUrl || undefined
|
||||
}
|
||||
|
||||
/** The provider record of a name that may not be one */
|
||||
function providerDef(name: string): ProviderDef | undefined {
|
||||
return Object.hasOwn(PROVIDERS, name)
|
||||
? PROVIDERS[name as ProviderName]
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A key and whose it is: the client's own, or the server's. "server" also
|
||||
* when the variable turns out empty, which the credential check reports.
|
||||
*/
|
||||
interface ResolvedKey {
|
||||
value?: string
|
||||
source: "client" | "server"
|
||||
/** The apiKeyEnv entry drawn, when there were several */
|
||||
selectedKeyEnv?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve API key from custom env var name or default env var.
|
||||
* Supports multiple API keys per provider via ai-models.json apiKeyEnv config.
|
||||
@@ -145,9 +169,9 @@ export function resolveBaseURL(
|
||||
*/
|
||||
function resolveApiKey(
|
||||
overrides: ClientOverrides | undefined,
|
||||
defaultEnvVar: string,
|
||||
): string | undefined {
|
||||
if (overrides?.apiKey) return overrides.apiKey
|
||||
defaultEnvVar: string | undefined,
|
||||
): ResolvedKey {
|
||||
if (overrides?.apiKey) return { value: overrides.apiKey, source: "client" }
|
||||
|
||||
if (overrides?.apiKeyEnv) {
|
||||
// Handle array of env var names - randomly select one
|
||||
@@ -162,17 +186,21 @@ function resolveApiKey(
|
||||
validEnvVars[
|
||||
Math.floor(Math.random() * validEnvVars.length)
|
||||
]
|
||||
console.log(
|
||||
`[API Key Routing] Selected ${selectedEnvVar} from ${validEnvVars.length} available keys`,
|
||||
)
|
||||
return process.env[selectedEnvVar]
|
||||
return {
|
||||
value: process.env[selectedEnvVar],
|
||||
source: "server",
|
||||
selectedKeyEnv: selectedEnvVar,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return process.env[overrides.apiKeyEnv]
|
||||
return { value: process.env[overrides.apiKeyEnv], source: "server" }
|
||||
}
|
||||
}
|
||||
|
||||
return process.env[defaultEnvVar]
|
||||
return {
|
||||
value: defaultEnvVar ? process.env[defaultEnvVar] : undefined,
|
||||
source: "server",
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -578,25 +606,250 @@ function bedrockRuntimeUrl(region: string): string {
|
||||
return `https://bedrock-runtime.${region}.${suffix}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers whose SDK has the official endpoint built in. The others are
|
||||
* OpenAI-compatible APIs (or Anthropic) that are called at the record's
|
||||
* defaultBaseUrl unless a base URL is configured.
|
||||
*/
|
||||
const SDK_KNOWS_ENDPOINT = new Set<ProviderName>(
|
||||
PROVIDER_NAMES.filter((p) => PROVIDERS[p].sdkKnowsEndpoint),
|
||||
)
|
||||
export type KeySource = "client" | "server" | "none"
|
||||
|
||||
/** Where and how to call a provider, once credentials are resolved */
|
||||
interface Endpoint {
|
||||
export interface Endpoint {
|
||||
apiKey?: string
|
||||
/** Anthropic Bearer auth (ANTHROPIC_AUTH_TOKEN), server only */
|
||||
authToken?: string
|
||||
/** Azure, the server's resource only */
|
||||
resourceName?: string
|
||||
/** undefined: the SDK's built-in endpoint */
|
||||
baseURL?: string
|
||||
/** The URL came from the client or a server variable, not the record */
|
||||
configuredBaseURL: boolean
|
||||
/** A client-chosen, untrusted URL: wrap fetch in redirectGuardedFetch */
|
||||
guarded: boolean
|
||||
/** "server" is what usesServerCredentials answers true for */
|
||||
keySource: KeySource
|
||||
/** The apiKeyEnv entry drawn (array case); getAIModel logs it */
|
||||
selectedKeyEnv?: string
|
||||
headers?: Record<string, string>
|
||||
/** Set by getAIModel from guarded; resolveEndpoint never fills it */
|
||||
fetch?: typeof fetch
|
||||
authToken?: string // Anthropic Bearer auth
|
||||
resourceName?: string // Azure
|
||||
// baseURL comes from the settings or env, not the provider's default
|
||||
configuredBaseURL?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Whose key a call uses and where it goes, by the provider's credential
|
||||
* kind. Reads its arguments and process.env, draws one key with Math.random
|
||||
* when apiKeyEnv is an array, and never throws, fetches or logs: getAIModel
|
||||
* decides what a missing key or URL means, usesServerCredentials reads
|
||||
* keySource. overrides.baseUrl must already be normalized (normalizeBaseUrl
|
||||
* drops one pasted endpoint path per call, so it runs once, in the caller).
|
||||
* Bedrock gets only keySource: bedrockModel() builds its SDK parameters.
|
||||
*/
|
||||
export function resolveEndpoint(
|
||||
provider: ProviderName,
|
||||
overrides?: ClientOverrides,
|
||||
): Endpoint {
|
||||
const def = PROVIDERS[provider]
|
||||
const vars = envVarsOf(provider)
|
||||
const clientUrl = overrides?.baseUrl || undefined
|
||||
// Requests to a base URL the client chose must not follow redirects. A
|
||||
// trusted URL is the server's own (the admin Test of an entry without
|
||||
// one), not a user's
|
||||
const guarded = !!clientUrl && !overrides?.trustedBaseUrl
|
||||
|
||||
switch (def.credential) {
|
||||
case "aws": {
|
||||
// The client's own Bedrock API key or access key pair, else the
|
||||
// server's (the admin panel's ADMIN_AWS_* keys or an IAM role)
|
||||
const clientKeys = !!(
|
||||
overrides?.apiKey ||
|
||||
(overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey)
|
||||
)
|
||||
return {
|
||||
configuredBaseURL: false,
|
||||
guarded,
|
||||
keySource: clientKeys ? "client" : "server",
|
||||
}
|
||||
}
|
||||
case "vertexKey": {
|
||||
// SECURITY: a client base URL only ever gets the client's key, so
|
||||
// the server's key is never sent to a client-chosen host. A client
|
||||
// key only goes to the client's URL or the official one.
|
||||
const apiKey = clientUrl
|
||||
? overrides?.vertexApiKey || undefined
|
||||
: overrides?.vertexApiKey || process.env[vars.apiKey as string]
|
||||
const baseURL = resolveBaseURL(
|
||||
overrides?.vertexApiKey,
|
||||
clientUrl,
|
||||
process.env[vars.baseUrl as string],
|
||||
)
|
||||
return {
|
||||
apiKey,
|
||||
baseURL,
|
||||
configuredBaseURL: !!baseURL,
|
||||
guarded,
|
||||
keySource: overrides?.vertexApiKey ? "client" : "server",
|
||||
}
|
||||
}
|
||||
case "none":
|
||||
// The site's own function (EdgeOne), an OpenAI-compatible API
|
||||
// without a key. The route passes the site's absolute URL and the
|
||||
// cookies and access code that authenticate the call.
|
||||
return {
|
||||
apiKey: "edgeone",
|
||||
baseURL: clientUrl || "/api/edgeai",
|
||||
headers: overrides?.headers,
|
||||
configuredBaseURL: !!clientUrl,
|
||||
guarded,
|
||||
keySource: "none",
|
||||
}
|
||||
case "optionalKey": {
|
||||
// SECURITY: with a client base URL only the client's key is used,
|
||||
// never the server's (Ollama). A user's key never goes to the
|
||||
// server's URL: without a URL of their own it goes to the
|
||||
// provider's cloud. The server's key goes to the server's URL
|
||||
// variable (or a server model's own), else to the SDK's local
|
||||
// default.
|
||||
const key: ResolvedKey = clientUrl
|
||||
? { value: overrides?.apiKey || undefined, source: "client" }
|
||||
: resolveApiKey(overrides, vars.apiKey)
|
||||
const baseURL =
|
||||
clientUrl ||
|
||||
(overrides?.apiKey
|
||||
? def.defaultBaseUrl
|
||||
: resolveBaseUrlEnv(overrides, vars.baseUrl as string))
|
||||
// Only a server key costs money; a keyless local server or the
|
||||
// client's own server does not. Spelled out because resolveApiKey
|
||||
// says "server" for an empty variable too.
|
||||
const serverKey =
|
||||
overrides?.apiKeyEnv || process.env[vars.apiKey as string]
|
||||
const keySource: KeySource = overrides?.apiKey
|
||||
? "client"
|
||||
: !clientUrl && serverKey
|
||||
? "server"
|
||||
: "none"
|
||||
return {
|
||||
apiKey: key.value,
|
||||
baseURL,
|
||||
configuredBaseURL: !!baseURL,
|
||||
guarded,
|
||||
keySource,
|
||||
selectedKeyEnv: key.selectedKeyEnv,
|
||||
}
|
||||
}
|
||||
case "apiKey": {
|
||||
// An API key and a base URL from <PREFIX>_API_KEY /
|
||||
// <PREFIX>_BASE_URL, or a server model's apiKeyEnv / baseUrlEnv
|
||||
const key = resolveApiKey(overrides, vars.apiKey)
|
||||
const configuredBaseURL = resolveBaseURL(
|
||||
overrides?.apiKey,
|
||||
clientUrl,
|
||||
resolveBaseUrlEnv(overrides, vars.baseUrl as string),
|
||||
)
|
||||
// Without a configured URL an SDK that knows its endpoint gets
|
||||
// none, except OpenAI with a user's key: the SDK would read the
|
||||
// server's OPENAI_BASE_URL, so the official endpoint is named.
|
||||
// The others get the record's https default (a local default,
|
||||
// SGLang's 127.0.0.1, only fills the settings form).
|
||||
const baseURL =
|
||||
configuredBaseURL ||
|
||||
(def.sdkKnowsEndpoint &&
|
||||
!(def.sdk === "openai" && overrides?.apiKey)
|
||||
? undefined
|
||||
: publicDefaultUrl(provider))
|
||||
return {
|
||||
apiKey: key.value,
|
||||
baseURL,
|
||||
configuredBaseURL: !!configuredBaseURL,
|
||||
guarded,
|
||||
keySource: key.source,
|
||||
selectedKeyEnv: key.selectedKeyEnv,
|
||||
// Bearer auth when there is no API key (Anthropic)
|
||||
authToken:
|
||||
def.altKeyEnv && !key.value
|
||||
? process.env[def.altKeyEnv]
|
||||
: undefined,
|
||||
// Only the server's own resource; a client key needs its URL
|
||||
// (Azure)
|
||||
resourceName:
|
||||
def.resourceNameEnv && !overrides?.apiKey
|
||||
? process.env[def.resourceNameEnv]
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Bedrock model from the client's credentials if it sent any (a Bedrock
|
||||
* API key, or an access key pair), else the admin panel's ADMIN_AWS_* keys,
|
||||
* else the default AWS credential chain (an IAM role, the AWS_* variables).
|
||||
*/
|
||||
function bedrockModel(
|
||||
modelId: string,
|
||||
overrides: ClientOverrides | undefined,
|
||||
): LanguageModel {
|
||||
const clientApiKey = overrides?.apiKey
|
||||
const hasClientCredentials =
|
||||
overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey
|
||||
// Keys from the admin panel. The ADMIN_ names keep them out of the
|
||||
// default AWS credential chain, which other clients such as the
|
||||
// DynamoDB quota manager use with their own credentials.
|
||||
const adminAccessKeyId = process.env.ADMIN_AWS_ACCESS_KEY_ID
|
||||
const adminSecretAccessKey = process.env.ADMIN_AWS_SECRET_ACCESS_KEY
|
||||
// The region becomes part of the endpoint's host name, so a
|
||||
// request's region must be a region name, or it could send the
|
||||
// server's credentials to another host
|
||||
if (
|
||||
overrides?.awsRegion &&
|
||||
!/^[a-z]{2,4}(-[a-z]+)+-\d{1,2}$/.test(overrides.awsRegion)
|
||||
) {
|
||||
throw Object.assign(
|
||||
new Error(`Invalid AWS region "${overrides.awsRegion}"`),
|
||||
{ statusCode: 400 },
|
||||
)
|
||||
}
|
||||
const bedrockRegion =
|
||||
overrides?.awsRegion ||
|
||||
process.env.ADMIN_AWS_REGION ||
|
||||
process.env.AWS_REGION ||
|
||||
"us-west-2"
|
||||
|
||||
const bedrockProvider = clientApiKey
|
||||
? createAmazonBedrock({
|
||||
region: bedrockRegion,
|
||||
apiKey: clientApiKey,
|
||||
// The SDK falls back to signing with AWS keys when it
|
||||
// drops a key (one of spaces): never the server's
|
||||
credentialProvider: async () => {
|
||||
throw new Error("The Bedrock API key is empty")
|
||||
},
|
||||
// Without a baseURL it reads the server's
|
||||
// AWS_ENDPOINT_URL_BEDROCK_RUNTIME / AWS_ENDPOINT_URL
|
||||
baseURL: bedrockRuntimeUrl(bedrockRegion),
|
||||
})
|
||||
: hasClientCredentials
|
||||
? createAmazonBedrock({
|
||||
region: bedrockRegion,
|
||||
accessKeyId: overrides.awsAccessKeyId as string,
|
||||
secretAccessKey: overrides.awsSecretAccessKey as string,
|
||||
...(overrides?.awsSessionToken && {
|
||||
sessionToken: overrides.awsSessionToken,
|
||||
}),
|
||||
// Without an apiKey the SDK reads the server's
|
||||
// AWS_BEARER_TOKEN_BEDROCK, which wins over the keys
|
||||
apiKey: "",
|
||||
// Without a baseURL it reads the server's
|
||||
// AWS_ENDPOINT_URL_BEDROCK_RUNTIME / AWS_ENDPOINT_URL
|
||||
baseURL: bedrockRuntimeUrl(bedrockRegion),
|
||||
})
|
||||
: adminAccessKeyId && adminSecretAccessKey
|
||||
? createAmazonBedrock({
|
||||
region: bedrockRegion,
|
||||
accessKeyId: adminAccessKeyId,
|
||||
secretAccessKey: adminSecretAccessKey,
|
||||
// The keys the admin panel's Test button checked
|
||||
apiKey: "",
|
||||
})
|
||||
: createAmazonBedrock({
|
||||
region: bedrockRegion,
|
||||
credentialProvider: fromNodeProviderChain(),
|
||||
})
|
||||
return bedrockProvider(modelId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -623,7 +876,10 @@ function compatibleModel(
|
||||
})
|
||||
}
|
||||
|
||||
/** Create the model for a provider. Credentials are already resolved. */
|
||||
/**
|
||||
* Create the model for a provider with its endpoint resolved, by the SDK
|
||||
* the record names. Bedrock never comes here: getAIModel calls bedrockModel.
|
||||
*/
|
||||
function createModel(
|
||||
provider: ProviderName,
|
||||
modelId: string,
|
||||
@@ -634,7 +890,7 @@ function createModel(
|
||||
...(e.baseURL && { baseURL: e.baseURL }),
|
||||
...(e.fetch && { fetch: e.fetch }),
|
||||
}
|
||||
switch (provider) {
|
||||
switch (PROVIDERS[provider].sdk) {
|
||||
case "openai": {
|
||||
const openaiProvider = createOpenAI(opts)
|
||||
// A configured base URL is usually a proxy that only has Chat
|
||||
@@ -679,8 +935,6 @@ function createModel(
|
||||
// Without a key or URL the SDK uses Vercel's endpoint and OIDC
|
||||
return createGateway(opts)(modelId)
|
||||
case "deepseek":
|
||||
case "kimi":
|
||||
case "mimo":
|
||||
// Kimi and MiMo return reasoning_content like DeepSeek and need it
|
||||
// passed back in multi-turn tool calls (MiMo answers 400 otherwise)
|
||||
return createDeepSeek(opts)(modelId)
|
||||
@@ -710,10 +964,21 @@ function createModel(
|
||||
})(modelId)
|
||||
: compatibleModel(provider, modelId, { ...e, baseURL })
|
||||
}
|
||||
default:
|
||||
// siliconflow, sglang, modelscope, glm, qwen, qiniu, novita,
|
||||
// atlascloud, edgeone
|
||||
case "vertex":
|
||||
// Express Mode: an API key; the URL only when one is configured
|
||||
return createVertex(opts)(modelId)
|
||||
case "ollama":
|
||||
return createOllama({
|
||||
...(e.baseURL && { baseURL: ollamaApiUrl(e.baseURL) }),
|
||||
...(e.apiKey && {
|
||||
headers: { Authorization: `Bearer ${e.apiKey}` },
|
||||
}),
|
||||
...(e.fetch && { fetch: e.fetch }),
|
||||
})(modelId)
|
||||
case "openai-compatible":
|
||||
return compatibleModel(provider, modelId, e)
|
||||
case "bedrock":
|
||||
throw new Error("Bedrock models come from bedrockModel()")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,18 +999,27 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
|
||||
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
|
||||
// If a custom baseUrl is provided, an API key MUST also be provided.
|
||||
// This prevents attackers from redirecting server API keys to malicious endpoints.
|
||||
// Exception: EdgeOne doesn't require API keys.
|
||||
// Ollama is exempt only when no server OLLAMA_API_KEY is configured;
|
||||
// when it IS configured, the outer guard also enforces client apiKey for custom baseUrls.
|
||||
// Exceptions, by the provider's credential kind: a provider without keys
|
||||
// (EdgeOne); a Vertex key; an optional key (Ollama) only when the server
|
||||
// has no key of that provider, otherwise the client must send its own.
|
||||
// A trusted URL is the server's own (the admin Test of an entry without
|
||||
// one), not a user's
|
||||
// one), not a user's. An unknown provider name has no kind and is
|
||||
// refused here like any other, and named below.
|
||||
const requested = overrides?.provider
|
||||
? providerDef(overrides.provider)
|
||||
: undefined
|
||||
if (
|
||||
overrides?.baseUrl &&
|
||||
!overrides?.trustedBaseUrl &&
|
||||
!overrides?.apiKey &&
|
||||
!(overrides?.provider === "vertexai" && overrides?.vertexApiKey) &&
|
||||
overrides?.provider !== "edgeone" &&
|
||||
!(overrides?.provider === "ollama" && !process.env.OLLAMA_API_KEY)
|
||||
!(requested?.credential === "vertexKey" && overrides?.vertexApiKey) &&
|
||||
requested?.credential !== "none" &&
|
||||
!(
|
||||
requested?.credential === "optionalKey" &&
|
||||
!process.env[
|
||||
envVarsOf(overrides.provider as ProviderName).apiKey as string
|
||||
]
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`API key is required when using a custom base URL. ` +
|
||||
@@ -833,222 +1107,51 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
|
||||
|
||||
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)
|
||||
|
||||
// Requests to a base URL the client chose must not follow redirects
|
||||
const guardedFetch =
|
||||
overrides?.baseUrl && !overrides.trustedBaseUrl
|
||||
? redirectGuardedFetch()
|
||||
: undefined
|
||||
// Build provider-specific options from environment variables
|
||||
let providerOptions = buildProviderOptions(provider, modelId)
|
||||
let model: LanguageModel
|
||||
const def = PROVIDERS[provider]
|
||||
|
||||
switch (provider) {
|
||||
case "bedrock": {
|
||||
// Use client-provided credentials if available (a Bedrock API
|
||||
// key, or an access key pair), otherwise fall back to IAM/env vars
|
||||
const clientApiKey = overrides?.apiKey
|
||||
const hasClientCredentials =
|
||||
overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey
|
||||
// Keys from the admin panel. The ADMIN_ names keep them out of the
|
||||
// default AWS credential chain, which other clients such as the
|
||||
// DynamoDB quota manager use with their own credentials.
|
||||
const adminAccessKeyId = process.env.ADMIN_AWS_ACCESS_KEY_ID
|
||||
const adminSecretAccessKey = process.env.ADMIN_AWS_SECRET_ACCESS_KEY
|
||||
// The region becomes part of the endpoint's host name, so a
|
||||
// request's region must be a region name, or it could send the
|
||||
// server's credentials to another host
|
||||
if (
|
||||
overrides?.awsRegion &&
|
||||
!/^[a-z]{2,4}(-[a-z]+)+-\d{1,2}$/.test(overrides.awsRegion)
|
||||
) {
|
||||
throw Object.assign(
|
||||
new Error(`Invalid AWS region "${overrides.awsRegion}"`),
|
||||
{ statusCode: 400 },
|
||||
)
|
||||
if (def.sdk === "bedrock") {
|
||||
model = bedrockModel(modelId, overrides)
|
||||
// Add Anthropic beta options if using Claude models via Bedrock
|
||||
if (modelId.includes("anthropic.claude")) {
|
||||
// Deep merge to preserve both anthropicBeta and reasoningConfig
|
||||
providerOptions = {
|
||||
bedrock: {
|
||||
...BEDROCK_ANTHROPIC_BETA.bedrock,
|
||||
...(providerOptions?.bedrock || {}),
|
||||
},
|
||||
}
|
||||
const bedrockRegion =
|
||||
overrides?.awsRegion ||
|
||||
process.env.ADMIN_AWS_REGION ||
|
||||
process.env.AWS_REGION ||
|
||||
"us-west-2"
|
||||
|
||||
const bedrockProvider = clientApiKey
|
||||
? createAmazonBedrock({
|
||||
region: bedrockRegion,
|
||||
apiKey: clientApiKey,
|
||||
// The SDK falls back to signing with AWS keys when it
|
||||
// drops a key (one of spaces): never the server's
|
||||
credentialProvider: async () => {
|
||||
throw new Error("The Bedrock API key is empty")
|
||||
},
|
||||
// Without a baseURL it reads the server's
|
||||
// AWS_ENDPOINT_URL_BEDROCK_RUNTIME / AWS_ENDPOINT_URL
|
||||
baseURL: bedrockRuntimeUrl(bedrockRegion),
|
||||
})
|
||||
: hasClientCredentials
|
||||
? createAmazonBedrock({
|
||||
region: bedrockRegion,
|
||||
accessKeyId: overrides.awsAccessKeyId as string,
|
||||
secretAccessKey: overrides.awsSecretAccessKey as string,
|
||||
...(overrides?.awsSessionToken && {
|
||||
sessionToken: overrides.awsSessionToken,
|
||||
}),
|
||||
// Without an apiKey the SDK reads the server's
|
||||
// AWS_BEARER_TOKEN_BEDROCK, which wins over the keys
|
||||
apiKey: "",
|
||||
// Without a baseURL it reads the server's
|
||||
// AWS_ENDPOINT_URL_BEDROCK_RUNTIME / AWS_ENDPOINT_URL
|
||||
baseURL: bedrockRuntimeUrl(bedrockRegion),
|
||||
})
|
||||
: adminAccessKeyId && adminSecretAccessKey
|
||||
? createAmazonBedrock({
|
||||
region: bedrockRegion,
|
||||
accessKeyId: adminAccessKeyId,
|
||||
secretAccessKey: adminSecretAccessKey,
|
||||
// The keys the admin panel's Test button checked
|
||||
apiKey: "",
|
||||
})
|
||||
: createAmazonBedrock({
|
||||
region: bedrockRegion,
|
||||
credentialProvider: fromNodeProviderChain(),
|
||||
})
|
||||
model = bedrockProvider(modelId)
|
||||
// Add Anthropic beta options if using Claude models via Bedrock
|
||||
if (modelId.includes("anthropic.claude")) {
|
||||
// Deep merge to preserve both anthropicBeta and reasoningConfig
|
||||
providerOptions = {
|
||||
bedrock: {
|
||||
...BEDROCK_ANTHROPIC_BETA.bedrock,
|
||||
...(providerOptions?.bedrock || {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "vertexai": {
|
||||
// Express Mode: Use API key for authentication
|
||||
// SECURITY: a client base URL only ever gets the client's key, so the
|
||||
// server's GOOGLE_VERTEX_API_KEY is never sent to a client-chosen host
|
||||
const vertexApiKey = overrides?.baseUrl
|
||||
? overrides.vertexApiKey
|
||||
: overrides?.vertexApiKey || process.env.GOOGLE_VERTEX_API_KEY
|
||||
|
||||
if (!vertexApiKey) {
|
||||
throw new Error(
|
||||
"Vertex AI requires an API key for Express Mode. " +
|
||||
"Get one from Google Cloud Console or set GOOGLE_VERTEX_API_KEY environment variable.",
|
||||
)
|
||||
}
|
||||
|
||||
// Support custom base URL from env or client override.
|
||||
// A client key only goes to the client's URL or the official one.
|
||||
const baseURL = resolveBaseURL(
|
||||
overrides?.vertexApiKey,
|
||||
overrides?.baseUrl,
|
||||
process.env.GOOGLE_VERTEX_BASE_URL,
|
||||
} else {
|
||||
const e = resolveEndpoint(provider, overrides)
|
||||
if (e.selectedKeyEnv) {
|
||||
console.log(`[API Key Routing] Selected ${e.selectedKeyEnv}`)
|
||||
}
|
||||
if (def.credential === "vertexKey" && !e.apiKey) {
|
||||
throw new Error(
|
||||
"Vertex AI requires an API key for Express Mode. " +
|
||||
"Get one from Google Cloud Console or set GOOGLE_VERTEX_API_KEY environment variable.",
|
||||
)
|
||||
model = createVertex({
|
||||
apiKey: vertexApiKey,
|
||||
...(baseURL && { baseURL }),
|
||||
...(guardedFetch && { fetch: guardedFetch }),
|
||||
})(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "ollama": {
|
||||
// SECURITY: When client provides a custom base URL, only use
|
||||
// client-provided API key. Never fall back to server OLLAMA_API_KEY
|
||||
// to prevent leaking server credentials to user-controlled endpoints.
|
||||
const apiKey = overrides?.baseUrl
|
||||
? overrides?.apiKey || undefined
|
||||
: resolveApiKey(overrides, "OLLAMA_API_KEY")
|
||||
// Like other providers, a user's key never goes to the server's
|
||||
// base URL: without a URL of their own it goes to Ollama Cloud.
|
||||
// The server's key goes to OLLAMA_BASE_URL (or a server model's
|
||||
// own variable), else to the SDK's local default.
|
||||
const baseURL =
|
||||
overrides?.baseUrl ||
|
||||
(overrides?.apiKey
|
||||
? PROVIDERS.ollama.defaultBaseUrl
|
||||
: resolveBaseUrlEnv(overrides, "OLLAMA_BASE_URL"))
|
||||
model = createOllama({
|
||||
...(baseURL && { baseURL: ollamaApiUrl(baseURL) }),
|
||||
...(apiKey && {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
}),
|
||||
...(guardedFetch && { fetch: guardedFetch }),
|
||||
})(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "edgeone":
|
||||
// EdgeOne Pages Edge AI, an OpenAI-compatible API without a key.
|
||||
// The SDK appends /chat/completions to the base URL. Cookies
|
||||
// (eo_token, eo_time) and the access code authenticate the call.
|
||||
model = compatibleModel(provider, modelId, {
|
||||
apiKey: "edgeone",
|
||||
baseURL: overrides?.baseUrl || "/api/edgeai",
|
||||
headers: overrides?.headers,
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
break
|
||||
|
||||
default: {
|
||||
// Every other provider takes an API key and a base URL from
|
||||
// <NAME>_API_KEY / <NAME>_BASE_URL (or a server model's apiKeyEnv)
|
||||
const apiKey = resolveApiKey(
|
||||
overrides,
|
||||
PROVIDER_ENV_VARS[provider] as string,
|
||||
// A key-only provider needs a URL unless its SDK knows the endpoint;
|
||||
// with a user's key an SDK that builds the URL from the server's
|
||||
// resource name (Azure) would read AZURE_RESOURCE_NAME, so it needs one too
|
||||
if (
|
||||
def.credential === "apiKey" &&
|
||||
!e.baseURL &&
|
||||
(!def.sdkKnowsEndpoint ||
|
||||
(def.resourceNameEnv && overrides?.apiKey))
|
||||
) {
|
||||
throw new Error(
|
||||
`${def.label} needs a base URL. Add it in the model settings.`,
|
||||
)
|
||||
const baseUrlEnv = envVarsOf(provider).baseUrl as string
|
||||
// A local default (SGLang's 127.0.0.1) only fills the settings
|
||||
// form; the server must not call its own machine for it. With a
|
||||
// user's key the OpenAI SDK would read the server's
|
||||
// OPENAI_BASE_URL, so name the official endpoint.
|
||||
const defaultUrl = PROVIDERS[provider].defaultBaseUrl
|
||||
const publicDefault = defaultUrl?.startsWith("https://")
|
||||
? defaultUrl
|
||||
: undefined
|
||||
const configuredBaseURL = resolveBaseURL(
|
||||
overrides?.apiKey,
|
||||
overrides?.baseUrl,
|
||||
resolveBaseUrlEnv(overrides, baseUrlEnv),
|
||||
)
|
||||
const baseURL =
|
||||
configuredBaseURL ||
|
||||
(SDK_KNOWS_ENDPOINT.has(provider) &&
|
||||
!(provider === "openai" && overrides?.apiKey)
|
||||
? undefined
|
||||
: publicDefault)
|
||||
// With a user's Azure key the SDK would read the server's
|
||||
// AZURE_RESOURCE_NAME
|
||||
if (
|
||||
!baseURL &&
|
||||
(!SDK_KNOWS_ENDPOINT.has(provider) ||
|
||||
(provider === "azure" && overrides?.apiKey))
|
||||
) {
|
||||
throw new Error(
|
||||
`${PROVIDERS[provider].label} needs a base URL. Add it in the model settings.`,
|
||||
)
|
||||
}
|
||||
model = createModel(provider, modelId, {
|
||||
apiKey,
|
||||
baseURL,
|
||||
configuredBaseURL: !!configuredBaseURL,
|
||||
fetch: guardedFetch,
|
||||
// Bearer auth for Anthropic when there is no API key
|
||||
authToken:
|
||||
provider === "anthropic" && !apiKey
|
||||
? process.env.ANTHROPIC_AUTH_TOKEN
|
||||
: undefined,
|
||||
// Only the server's own resource; a client key needs its URL
|
||||
resourceName:
|
||||
provider === "azure" && !overrides?.apiKey
|
||||
? process.env.AZURE_RESOURCE_NAME
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
model = createModel(provider, modelId, {
|
||||
...e,
|
||||
fetch: e.guarded ? redirectGuardedFetch() : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return { model, providerOptions, modelId, provider }
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
|
||||
import {
|
||||
type ClientOverrides,
|
||||
resolveEndpoint,
|
||||
SINGLE_SYSTEM_PROVIDERS,
|
||||
usesServerCredentials,
|
||||
usesServerEndpoint,
|
||||
@@ -179,6 +180,22 @@ describe("usesServerCredentials against its pre-refactor body", () => {
|
||||
`${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++
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user