fix(server): use the keys the user sent, and more review fixes

Found by the second PR review:
- With AWS_BEARER_TOKEN_BEDROCK set on the server, a request with the
  user's AWS keys ran on the server's token: the Bedrock SDK prefers it.
  Checked with Bedrock: invalid user keys used to get an answer.
- An OpenAI key with the official URL filled in (the settings form does
  that) went to the Responses API. Back to main's rule: a configured base
  URL uses Chat Completions.
- A user's Ollama key went to the server's OLLAMA_BASE_URL, for chat and
  for the model list. Like every other provider, it goes to the user's
  base URL or Ollama Cloud.
- The server's keyless Ollama and EdgeOne were not counted in the quota.
- AI_MODEL models on the server's keys ran on any provider with a server
  key, not only on AI_PROVIDER.
- A user's Azure key without a base URL used the server's resource name.
- The admin panel's Test button failed whenever access codes were set.
- DeepSeek's errors in the stream (plain text) were shown as they were,
  without a hint and also on the server's keys. Bedrock's throttling in
  the stream was not recognised as a rate limit.
- The EdgeOne function accepted text/plain; x=application/json, which
  other sites can send without a CORS preflight.
- Desktop app: a launch that found the old port taken for a moment (the
  previous version still quitting after an update) remembered the new
  port for good. The new port is kept only when Windows reserves the old
  one. A failed read of the presets file moved it aside as corrupt, and a
  save could then replace the presets. Switching presets on the same port
  now reloads the page. The dev launcher no longer misses a preset change
  made before or during a restart.
This commit is contained in:
dayuan.jiang
2026-10-05 10:52:37 +09:00
parent 080f44716f
commit d5f31cb253
19 changed files with 499 additions and 91 deletions
+36 -12
View File
@@ -639,6 +639,8 @@ interface Endpoint {
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
}
/**
@@ -679,11 +681,10 @@ function createModel(
switch (provider) {
case "openai": {
const openaiProvider = createOpenAI(opts)
// A custom base URL is usually a proxy that only has Chat
// Completions; the official endpoint uses the Responses API,
// which returns reasoning for the o-series and gpt-5 or later
return e.baseURL &&
e.baseURL !== PROVIDER_INFO.openai.defaultBaseUrl
// A configured base URL is usually a proxy that only has Chat
// Completions; without one the Responses API is used, which
// returns reasoning for the o-series and gpt-5 or later
return e.configuredBaseURL
? openaiProvider.chat(modelId)
: openaiProvider(modelId)
}
@@ -899,6 +900,9 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
...(overrides?.awsSessionToken && {
sessionToken: overrides.awsSessionToken,
}),
// Without an apiKey the SDK reads the server's
// AWS_BEARER_TOKEN_BEDROCK, which wins over the keys
apiKey: "",
})
: adminAccessKeyId && adminSecretAccessKey
? createAmazonBedrock({
@@ -955,7 +959,13 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
}
case "ollama": {
const baseURL = overrides?.baseUrl || process.env.OLLAMA_BASE_URL
// Like other providers, a user's key never goes to the server's
// base URL; without a base URL it is an Ollama Cloud key
const baseURL =
overrides?.baseUrl ||
(overrides?.apiKey
? PROVIDER_INFO.ollama.defaultBaseUrl
: process.env.OLLAMA_BASE_URL)
// 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.
@@ -1003,16 +1013,24 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
const publicDefault = defaultUrl?.startsWith("https://")
? defaultUrl
: undefined
const baseURL = resolveBaseURL(
const configuredBaseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
resolveBaseUrlEnv(overrides, baseUrlEnv),
SDK_KNOWS_ENDPOINT.has(provider) &&
!(provider === "openai" && overrides?.apiKey)
? undefined
: publicDefault,
)
if (!baseURL && !SDK_KNOWS_ENDPOINT.has(provider)) {
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(
`${PROVIDER_INFO[provider].label} needs a base URL. Add it in the model settings.`,
)
@@ -1020,6 +1038,7 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
model = createModel(provider, modelId, {
apiKey,
baseURL,
configuredBaseURL: !!configuredBaseURL,
fetch: guardedFetch,
// Bearer auth for Anthropic when there is no API key
authToken:
@@ -1038,6 +1057,11 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
return { model, providerOptions, modelId, provider }
}
/** The provider of the server's own config: AI_PROVIDER, or the one with a key */
export function getServerProvider(): ProviderName | null {
return (process.env.AI_PROVIDER as ProviderName) || detectProvider()
}
/**
* 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
+12 -3
View File
@@ -80,7 +80,8 @@ const GENERAL_TEXTS: Array<[RegExp, LLMErrorCode]> = [
/invalid[_ ]api[_ ]key|incorrect api key|unauthorized/i,
"invalid_api_key",
],
[/rate limit|too many requests/i, "rate_limited"],
// "too many tokens": Bedrock's throttling
[/rate limit|too many requests|too many tokens/i, "rate_limited"],
[
/Cannot connect to API|ECONNREFUSED|ENOTFOUND|ECONNRESET|ETIMEDOUT|fetch failed/i,
"cannot_connect",
@@ -115,8 +116,16 @@ function problemDetail(body: string): string | undefined {
* it can name the server's account, role or internal hosts.
*/
export function streamErrorText(error: unknown, hideDetails = false): string {
// The SDK passes an invalid tool call's error as a plain string
if (typeof error === "string") return error
// The SDK passes an invalid tool call's error as a plain string. Other
// strings come from providers (DeepSeek's SDK sends stream errors so).
if (
typeof error === "string" &&
/^(Invalid input for tool|Model tried to call unavailable tool)/.test(
error,
)
) {
return error
}
if (isToolCallError(error)) return (error as Error).message
const classified = classifyLLMError(error)
if (hideDetails) {
+6 -5
View File
@@ -77,11 +77,12 @@ async function getJson(
/**
* Where to list from without the user's base URL: where chat goes then. For
* Ollama 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 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.
*/
function listFallbackUrl(provider: ProviderName): string {
if (provider === "ollama") {
function listFallbackUrl(provider: ProviderName, apiKey?: string): string {
if (provider === "ollama" && !apiKey) {
return process.env.OLLAMA_BASE_URL || "http://127.0.0.1:11434/api"
}
const url = PROVIDER_INFO[provider].defaultBaseUrl
@@ -98,7 +99,7 @@ export async function listProviderModels(
{ apiKey, baseUrl }: { apiKey?: string; baseUrl?: string },
fetchFn: typeof fetch = fetch,
): Promise<ListedModel[]> {
const base = normalizeBaseUrl(baseUrl || listFallbackUrl(provider))
const base = normalizeBaseUrl(baseUrl || listFallbackUrl(provider, apiKey))
const bearer: Record<string, string> = apiKey
? { Authorization: `Bearer ${apiKey}` }
: {}