From 231ca8a935d08ecef9f258928eebc1bc6e1fc83c Mon Sep 17 00:00:00 2001 From: "R.D." Date: Sat, 4 Apr 2026 01:09:51 -0400 Subject: [PATCH 1/3] support custom user agent for third-party providers --- CHANGELOG.md | 4 + open-sse/executors/base.ts | 34 +++- open-sse/handlers/chatCore.ts | 38 ++-- .../dashboard/providers/[id]/page.tsx | 102 +++++++++-- src/app/api/providers/validate/route.ts | 5 +- src/lib/providers/validation.ts | 168 ++++++++++++------ src/shared/validation/schemas.ts | 31 +++- tests/unit/plan3-p0.test.mjs | 34 ++++ .../provider-validation-branches.test.mjs | 32 +++- 9 files changed, 356 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eed5dc9080..f4161cd2cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### ✨ New Features + +- **API Provider Advanced Settings:** Added per-connection custom `User-Agent` overrides for API-key provider connections. The override is stored in `providerSpecificData.customUserAgent` and now applies to validation probes and upstream execution requests. + --- ## [3.5.0] — 2026-04-03 diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index fd261cec74..f3cb636079 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -70,11 +70,40 @@ export function mergeUpstreamExtraHeaders( if (!extra) return; for (const [k, v] of Object.entries(extra)) { if (typeof k === "string" && k.length > 0 && typeof v === "string") { + if (k.toLowerCase() === "user-agent") { + setUserAgentHeader(headers, v); + continue; + } headers[k] = v; } } } +export function getCustomUserAgent(providerSpecificData?: JsonRecord | null): string | null { + const customUserAgent = + typeof providerSpecificData?.customUserAgent === "string" + ? providerSpecificData.customUserAgent.trim() + : ""; + return customUserAgent || null; +} + +export function setUserAgentHeader(headers: Record, userAgent: string): void { + headers["User-Agent"] = userAgent; + if ("user-agent" in headers) { + headers["user-agent"] = userAgent; + } +} + +export function applyConfiguredUserAgent( + headers: Record, + providerSpecificData?: JsonRecord | null +): void { + const customUserAgent = getCustomUserAgent(providerSpecificData); + if (customUserAgent) { + setUserAgentHeader(headers, customUserAgent); + } +} + export function mergeAbortSignals(primary: AbortSignal, secondary: AbortSignal): AbortSignal { const controller = new AbortController(); @@ -156,9 +185,7 @@ export class BaseExecutor { const envKey = `${providerId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_USER_AGENT`; const envUA = process.env[envKey]?.trim(); if (envUA) { - // Override both common casing variants - headers["User-Agent"] = envUA; - if (headers["user-agent"]) headers["user-agent"] = envUA; + setUserAgentHeader(headers, envUA); } } @@ -238,6 +265,7 @@ export class BaseExecutor { for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const url = this.buildUrl(model, stream, urlIndex, credentials); const headers = this.buildHeaders(credentials, stream); + applyConfiguredUserAgent(headers, credentials?.providerSpecificData); // Append 1M context beta header when [1m] suffix was used // Only supported for specific Claude models per Anthropic docs diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index cfb903bcc6..61faa0ae43 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -632,18 +632,36 @@ export async function handleChatCore({ // Primary path: merge client model id + alias target so config on either key applies; resolved // id wins on same header name. T5 family fallback uses only (nextModel, resolveModelAlias(next)) // so A-model headers are not sent to B — see buildUpstreamHeadersForExecute. + const connectionCustomUserAgent = + credentials?.providerSpecificData && + typeof credentials.providerSpecificData === "object" && + typeof credentials.providerSpecificData.customUserAgent === "string" + ? credentials.providerSpecificData.customUserAgent.trim() + : ""; + const buildUpstreamHeadersForExecute = (modelToCall: string): Record => { - if (modelToCall === effectiveModel) { - return { - ...getModelUpstreamExtraHeaders(provider || "", model || "", sourceFormat), - ...getModelUpstreamExtraHeaders(provider || "", resolvedModel || "", sourceFormat), - }; + const upstreamHeaders = + modelToCall === effectiveModel + ? { + ...getModelUpstreamExtraHeaders(provider || "", model || "", sourceFormat), + ...getModelUpstreamExtraHeaders(provider || "", resolvedModel || "", sourceFormat), + } + : (() => { + const r = resolveModelAlias(modelToCall); + return { + ...getModelUpstreamExtraHeaders(provider || "", modelToCall || "", sourceFormat), + ...getModelUpstreamExtraHeaders(provider || "", r || "", sourceFormat), + }; + })(); + + if (connectionCustomUserAgent) { + upstreamHeaders["User-Agent"] = connectionCustomUserAgent; + if ("user-agent" in upstreamHeaders) { + upstreamHeaders["user-agent"] = connectionCustomUserAgent; + } } - const r = resolveModelAlias(modelToCall); - return { - ...getModelUpstreamExtraHeaders(provider || "", modelToCall || "", sourceFormat), - ...getModelUpstreamExtraHeaders(provider || "", r || "", sourceFormat), - }; + + return upstreamHeaders; }; // Default to false unless client explicitly sets stream: true (OpenAI spec compliant) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index c0d6f5d6e5..3d2e20fb22 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -4523,11 +4523,13 @@ function AddApiKeyModal({ region: isVertex ? defaultRegion : "", apiRegion: "international", validationModelId: "", + customUserAgent: "", }); const [validating, setValidating] = useState(false); const [validationResult, setValidationResult] = useState(null); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); + const [showAdvanced, setShowAdvanced] = useState(false); const handleValidate = async () => { setValidating(true); @@ -4540,6 +4542,7 @@ function AddApiKeyModal({ provider, apiKey: formData.apiKey, validationModelId: formData.validationModelId || undefined, + customUserAgent: formData.customUserAgent.trim() || undefined, }), }); const data = await res.json(); @@ -4578,6 +4581,7 @@ function AddApiKeyModal({ provider, apiKey: formData.apiKey, validationModelId: formData.validationModelId || undefined, + customUserAgent: formData.customUserAgent.trim() || undefined, }), }); const data = await res.json(); @@ -4594,29 +4598,27 @@ function AddApiKeyModal({ return; } + const providerSpecificData: Record = {}; + if (formData.customUserAgent.trim()) { + providerSpecificData.customUserAgent = formData.customUserAgent.trim(); + } + if (isBailian) { + providerSpecificData.baseUrl = validatedBailianBaseUrl; + } else if (isVertex) { + providerSpecificData.region = formData.region; + } else if (isGlm) { + providerSpecificData.apiRegion = formData.apiRegion; + } + const payload = { name: formData.name, apiKey: formData.apiKey, priority: formData.priority, testStatus: "active", - providerSpecificData: undefined, + providerSpecificData: + Object.keys(providerSpecificData).length > 0 ? providerSpecificData : undefined, }; - // Include baseUrl in providerSpecificData for bailian-coding-plan - if (isBailian) { - payload.providerSpecificData = { - baseUrl: validatedBailianBaseUrl, - }; - } else if (isVertex) { - payload.providerSpecificData = { - region: formData.region, - }; - } else if (isGlm) { - payload.providerSpecificData = { - apiRegion: formData.apiRegion, - }; - } - const error = await onSave(payload); if (error) { setSaveError(typeof error === "string" ? error : t("failedSaveConnection")); @@ -4694,6 +4696,35 @@ function AddApiKeyModal({ })}

)} + + {showAdvanced && ( +
+ setFormData({ ...formData, customUserAgent: e.target.value })} + placeholder="my-app/1.0" + hint="Optional override sent upstream as the User-Agent header for this connection" + /> +
+ )} (null); const [extraApiKeys, setExtraApiKeys] = useState([]); const [newExtraKey, setNewExtraKey] = useState(""); + const [showAdvanced, setShowAdvanced] = useState(false); const isBailian = connection?.provider === "bailian-coding-plan"; const defaultBailianUrl = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1"; @@ -4818,6 +4851,9 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec const existingBaseUrl = typeof rawBaseUrl === "string" ? rawBaseUrl : ""; const rawRegion = connection.providerSpecificData?.region; const existingRegion = typeof rawRegion === "string" ? rawRegion : ""; + const rawCustomUserAgent = connection.providerSpecificData?.customUserAgent; + const existingCustomUserAgent = + typeof rawCustomUserAgent === "string" ? rawCustomUserAgent : ""; setFormData({ name: connection.name || "", priority: connection.priority || 1, @@ -4828,11 +4864,13 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec apiRegion: (connection.providerSpecificData?.apiRegion as string) || "international", validationModelId: (connection.providerSpecificData?.validationModelId as string) || "", tag: (connection.providerSpecificData?.tag as string) || "", + customUserAgent: existingCustomUserAgent, }); // Load existing extra keys from providerSpecificData const existing = connection.providerSpecificData?.extraApiKeys; setExtraApiKeys(Array.isArray(existing) ? existing : []); setNewExtraKey(""); + setShowAdvanced(!!existingCustomUserAgent); setTestResult(null); setValidationResult(null); setSaveError(null); @@ -4880,6 +4918,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec provider: connection.provider, apiKey: formData.apiKey, validationModelId: formData.validationModelId || undefined, + customUserAgent: formData.customUserAgent.trim() || undefined, }), }); const data = await res.json(); @@ -4925,6 +4964,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec provider: connection.provider, apiKey: formData.apiKey, validationModelId: formData.validationModelId || undefined, + customUserAgent: formData.customUserAgent.trim() || undefined, }), }); const data = await res.json(); @@ -4952,6 +4992,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec ...(connection.providerSpecificData || {}), extraApiKeys: extraApiKeys.filter((k) => k.trim().length > 0), tag: formData.tag.trim() || undefined, + customUserAgent: formData.customUserAgent.trim(), }; if (formData.validationModelId) { updates.providerSpecificData.validationModelId = formData.validationModelId; @@ -5067,6 +5108,35 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec {saveError} )} + + {showAdvanced && ( +
+ setFormData({ ...formData, customUserAgent: e.target.value })} + placeholder="my-app/1.0" + hint="Optional override sent upstream as the User-Agent header for this connection" + /> +
+ )} , providerSpecificData: any = {}) { + const customUserAgent = getCustomUserAgent(providerSpecificData); + if (!customUserAgent) return headers; + headers["User-Agent"] = customUserAgent; + if ("user-agent" in headers) { + headers["user-agent"] = customUserAgent; + } + return headers; +} + +function withCustomUserAgent(init: RequestInit, providerSpecificData: any = {}) { return { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, + ...init, + headers: applyCustomUserAgent( + { ...((init.headers as Record | undefined) || {}) }, + providerSpecificData + ), }; } +function buildBearerHeaders(apiKey: string, providerSpecificData: any = {}) { + return applyCustomUserAgent( + { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + providerSpecificData + ); +} + async function validateOpenAILikeProvider({ provider, apiKey, @@ -106,7 +135,7 @@ async function validateOpenAILikeProvider({ const modelsRes = await fetch(modelsUrl, { method: "GET", - headers: buildBearerHeaders(apiKey), + headers: buildBearerHeaders(apiKey, providerSpecificData), }); if (modelsRes.ok) { @@ -132,7 +161,7 @@ async function validateOpenAILikeProvider({ const chatRes = await fetch(chatUrl, { method: "POST", - headers: buildBearerHeaders(apiKey), + headers: buildBearerHeaders(apiKey, providerSpecificData), body: JSON.stringify(testBody), }); @@ -167,10 +196,13 @@ async function validateAnthropicLikeProvider({ return { valid: false, error: "Missing base URL" }; } - const requestHeaders = { - "Content-Type": "application/json", - ...headers, - }; + const requestHeaders = applyCustomUserAgent( + { + "Content-Type": "application/json", + ...headers, + }, + providerSpecificData + ); if (!requestHeaders["x-api-key"] && !requestHeaders["X-API-Key"]) { requestHeaders["x-api-key"] = apiKey; @@ -200,7 +232,7 @@ async function validateAnthropicLikeProvider({ return { valid: true, error: null }; } -async function validateGeminiLikeProvider({ apiKey, baseUrl }: any) { +async function validateGeminiLikeProvider({ apiKey, baseUrl, providerSpecificData = {} }: any) { if (!baseUrl) { return { valid: false, error: "Missing base URL" }; } @@ -208,7 +240,7 @@ async function validateGeminiLikeProvider({ apiKey, baseUrl }: any) { const separator = baseUrl.includes("?") ? "&" : "?"; const response = await fetch(`${baseUrl}${separator}key=${encodeURIComponent(apiKey)}`, { method: "GET", - headers: { "Content-Type": "application/json" }, + headers: applyCustomUserAgent({ "Content-Type": "application/json" }, providerSpecificData), }); if (response.ok) { @@ -224,11 +256,11 @@ async function validateGeminiLikeProvider({ apiKey, baseUrl }: any) { // ── Specialty providers (non-standard APIs) ── -async function validateDeepgramProvider({ apiKey }: any) { +async function validateDeepgramProvider({ apiKey, providerSpecificData = {} }: any) { try { const response = await fetch("https://api.deepgram.com/v1/auth/token", { method: "GET", - headers: { Authorization: `Token ${apiKey}` }, + headers: applyCustomUserAgent({ Authorization: `Token ${apiKey}` }, providerSpecificData), }); if (response.ok) return { valid: true, error: null }; if (response.status === 401 || response.status === 403) { @@ -240,14 +272,17 @@ async function validateDeepgramProvider({ apiKey }: any) { } } -async function validateAssemblyAIProvider({ apiKey }: any) { +async function validateAssemblyAIProvider({ apiKey, providerSpecificData = {} }: any) { try { const response = await fetch("https://api.assemblyai.com/v2/transcript?limit=1", { method: "GET", - headers: { - Authorization: apiKey, - "Content-Type": "application/json", - }, + headers: applyCustomUserAgent( + { + Authorization: apiKey, + "Content-Type": "application/json", + }, + providerSpecificData + ), }); if (response.ok) return { valid: true, error: null }; if (response.status === 401 || response.status === 403) { @@ -259,16 +294,19 @@ async function validateAssemblyAIProvider({ apiKey }: any) { } } -async function validateNanoBananaProvider({ apiKey }: any) { +async function validateNanoBananaProvider({ apiKey, providerSpecificData = {} }: any) { try { // NanoBanana doesn't expose a lightweight validation endpoint, // so we send a minimal generate request that will succeed or fail on auth. const response = await fetch("https://api.nanobananaapi.ai/api/v1/nanobanana/generate", { method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, + headers: applyCustomUserAgent( + { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + providerSpecificData + ), body: JSON.stringify({ prompt: "test", model: "nanobanana-flash", @@ -284,15 +322,18 @@ async function validateNanoBananaProvider({ apiKey }: any) { } } -async function validateElevenLabsProvider({ apiKey }: any) { +async function validateElevenLabsProvider({ apiKey, providerSpecificData = {} }: any) { try { // Lightweight auth check endpoint const response = await fetch("https://api.elevenlabs.io/v1/voices", { method: "GET", - headers: { - "xi-api-key": apiKey, - "Content-Type": "application/json", - }, + headers: applyCustomUserAgent( + { + "xi-api-key": apiKey, + "Content-Type": "application/json", + }, + providerSpecificData + ), }); if (response.ok) return { valid: true, error: null }; @@ -306,16 +347,19 @@ async function validateElevenLabsProvider({ apiKey }: any) { } } -async function validateInworldProvider({ apiKey }: any) { +async function validateInworldProvider({ apiKey, providerSpecificData = {} }: any) { try { // Inworld TTS lacks a simple key-introspection endpoint. // Send a minimal synth request and treat non-auth 4xx as auth-pass. const response = await fetch("https://api.inworld.ai/tts/v1/voice", { method: "POST", - headers: { - Authorization: `Basic ${apiKey}`, - "Content-Type": "application/json", - }, + headers: applyCustomUserAgent( + { + Authorization: `Basic ${apiKey}`, + "Content-Type": "application/json", + }, + providerSpecificData + ), body: JSON.stringify({ text: "test", modelId: "inworld-tts-1.5-mini", @@ -348,11 +392,14 @@ async function validateBailianCodingPlanProvider({ apiKey, providerSpecificData const response = await fetch(messagesUrl, { method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": apiKey, - "anthropic-version": "2023-06-01", - }, + headers: applyCustomUserAgent( + { + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + providerSpecificData + ), body: JSON.stringify({ model: "qwen3-coder-plus", max_tokens: 1, @@ -396,7 +443,7 @@ async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData = try { const modelsRes = await fetch(`${baseUrl}/models`, { method: "GET", - headers: buildBearerHeaders(apiKey), + headers: buildBearerHeaders(apiKey, providerSpecificData), }); modelsReachable = true; @@ -441,7 +488,7 @@ async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData = try { const chatRes = await fetch(chatUrl, { method: "POST", - headers: buildBearerHeaders(apiKey), + headers: buildBearerHeaders(apiKey, providerSpecificData), body: JSON.stringify({ model: testModelId, messages: [{ role: "user", content: "test" }], @@ -503,7 +550,7 @@ async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData = try { const pingRes = await fetch(baseUrl, { method: "GET", - headers: buildBearerHeaders(apiKey), + headers: buildBearerHeaders(apiKey, providerSpecificData), signal: AbortSignal.timeout(5000), }); @@ -524,12 +571,15 @@ async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificDat return { valid: false, error: "No base URL configured for Anthropic compatible provider" }; } - const headers = { - "Content-Type": "application/json", - "x-api-key": apiKey, - "anthropic-version": "2023-06-01", - Authorization: `Bearer ${apiKey}`, - }; + const headers = applyCustomUserAgent( + { + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + Authorization: `Bearer ${apiKey}`, + }, + providerSpecificData + ); // Step 1: Try GET /models try { @@ -590,7 +640,10 @@ export async function validateClaudeCodeCompatibleProvider({ const modelsPath = providerSpecificData?.modelsPath || CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH; const chatPath = providerSpecificData?.chatPath || CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH; - const defaultHeaders = buildClaudeCodeCompatibleHeaders(apiKey, false); + const defaultHeaders = applyCustomUserAgent( + buildClaudeCodeCompatibleHeaders(apiKey, false), + providerSpecificData + ); try { const modelsRes = await fetch(joinClaudeCodeCompatibleUrl(baseUrl, modelsPath), { @@ -617,7 +670,10 @@ export async function validateClaudeCodeCompatibleProvider({ try { const messagesRes = await fetch(joinClaudeCodeCompatibleUrl(baseUrl, chatPath), { method: "POST", - headers: buildClaudeCodeCompatibleHeaders(apiKey, true, sessionId), + headers: applyCustomUserAgent( + buildClaudeCodeCompatibleHeaders(apiKey, true, sessionId), + providerSpecificData + ), body: JSON.stringify(payload), }); @@ -657,10 +713,11 @@ export async function validateClaudeCodeCompatibleProvider({ async function validateSearchProvider( url: string, - init: RequestInit + init: RequestInit, + providerSpecificData: any = {} ): Promise<{ valid: boolean; error: string | null; unsupported: false }> { try { - const response = await fetch(url, init); + const response = await fetch(url, withCustomUserAgent(init, providerSpecificData)); if (response.ok) return { valid: true, error: null, unsupported: false }; if (response.status === 401 || response.status === 403) { return { valid: false, error: "Invalid API key", unsupported: false }; @@ -757,11 +814,11 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi inworld: validateInworldProvider, "bailian-coding-plan": validateBailianCodingPlanProvider, // LongCat AI — does not expose /v1/models; validate via chat completions directly (#592) - longcat: async ({ apiKey }: any) => { + longcat: async ({ apiKey, providerSpecificData }: any) => { try { const res = await fetch("https://api.longcat.chat/openai/v1/chat/completions", { method: "POST", - headers: buildBearerHeaders(apiKey), + headers: buildBearerHeaders(apiKey, providerSpecificData), body: JSON.stringify({ model: "longcat", messages: [{ role: "user", content: "test" }], @@ -781,9 +838,9 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi ...Object.fromEntries( Object.entries(SEARCH_VALIDATOR_CONFIGS).map(([id, configFn]) => [ id, - ({ apiKey }: any) => { + ({ apiKey, providerSpecificData }: any) => { const { url, init } = configFn(apiKey); - return validateSearchProvider(url, init); + return validateSearchProvider(url, init, providerSpecificData); }, ]) ), @@ -847,6 +904,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi return await validateGeminiLikeProvider({ apiKey, baseUrl, + providerSpecificData, }); } diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 396ff4e8fc..68452a75b5 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -32,14 +32,25 @@ export const createProviderSchema = z.object({ .superRefine((data, ctx) => { if (!data) return; const baseUrl = data.baseUrl; - if (baseUrl === undefined) return; - if (typeof baseUrl !== "string" || !isHttpUrl(baseUrl)) { + if (baseUrl !== undefined && (typeof baseUrl !== "string" || !isHttpUrl(baseUrl))) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "providerSpecificData.baseUrl must be a valid http(s) URL", path: ["baseUrl"], }); } + const customUserAgent = data.customUserAgent; + if ( + customUserAgent !== undefined && + customUserAgent !== null && + (typeof customUserAgent !== "string" || customUserAgent.length > 500) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "providerSpecificData.customUserAgent must be a string up to 500 chars", + path: ["customUserAgent"], + }); + } }), }); @@ -1033,14 +1044,25 @@ export const updateProviderConnectionSchema = z .superRefine((data, ctx) => { if (!data) return; const baseUrl = data.baseUrl; - if (baseUrl === undefined) return; - if (typeof baseUrl !== "string" || !isHttpUrl(baseUrl)) { + if (baseUrl !== undefined && (typeof baseUrl !== "string" || !isHttpUrl(baseUrl))) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "providerSpecificData.baseUrl must be a valid http(s) URL", path: ["baseUrl"], }); } + const customUserAgent = data.customUserAgent; + if ( + customUserAgent !== undefined && + customUserAgent !== null && + (typeof customUserAgent !== "string" || customUserAgent.length > 500) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "providerSpecificData.customUserAgent must be a string up to 500 chars", + path: ["customUserAgent"], + }); + } }), }) .superRefine((value, ctx) => { @@ -1075,6 +1097,7 @@ export const validateProviderApiKeySchema = z.object({ provider: z.string().trim().min(1, "Provider and API key required"), apiKey: z.string().trim().min(1, "Provider and API key required"), validationModelId: z.string().trim().optional(), + customUserAgent: z.string().trim().max(500).optional(), }); const geminiPartSchema = z diff --git a/tests/unit/plan3-p0.test.mjs b/tests/unit/plan3-p0.test.mjs index bd2d8259db..b4b6a0347a 100644 --- a/tests/unit/plan3-p0.test.mjs +++ b/tests/unit/plan3-p0.test.mjs @@ -69,6 +69,40 @@ test("DefaultExecutor uses x-api-key for kimi-coding-apikey", () => { assert.equal(headers.Authorization, undefined); }); +test("DefaultExecutor execute honors connection-level custom User-Agent", async () => { + const executor = new DefaultExecutor("openai"); + const originalFetch = globalThis.fetch; + let capturedHeaders = null; + + globalThis.fetch = async (_url, init = {}) => { + capturedHeaders = init.headers || null; + return new Response(JSON.stringify({ id: "chatcmpl-test" }), { status: 200 }); + }; + + try { + await executor.execute({ + model: "gpt-4o", + body: { + model: "gpt-4o", + messages: [{ role: "user", content: "hello" }], + }, + stream: false, + credentials: { + apiKey: "sk-openai-test", + providerSpecificData: { + customUserAgent: "OmniRouteCustomUA/2.0", + }, + }, + }); + } finally { + globalThis.fetch = originalFetch; + } + + assert.ok(capturedHeaders); + assert.equal(capturedHeaders.Authorization, "Bearer sk-openai-test"); + assert.equal(capturedHeaders["User-Agent"], "OmniRouteCustomUA/2.0"); +}); + test("CodexExecutor forces stream=true for upstream compatibility", () => { const executor = new CodexExecutor(); const transformed = executor.transformRequest( diff --git a/tests/unit/provider-validation-branches.test.mjs b/tests/unit/provider-validation-branches.test.mjs index ad45301e91..0ce4bf09af 100644 --- a/tests/unit/provider-validation-branches.test.mjs +++ b/tests/unit/provider-validation-branches.test.mjs @@ -41,8 +41,8 @@ test("openai-compatible validation reports missing base URL", async () => { test("openai-compatible validation accepts rate-limited /models responses", async () => { const calls = []; - globalThis.fetch = async (url) => { - calls.push(String(url)); + globalThis.fetch = async (url, init = {}) => { + calls.push({ url: String(url), headers: init.headers || {} }); return new Response(JSON.stringify({ error: "rate limited" }), { status: 429 }); }; @@ -55,7 +55,33 @@ test("openai-compatible validation accepts rate-limited /models responses", asyn assert.equal(result.valid, true); assert.equal(result.method, "models_endpoint"); assert.match(result.warning, /Rate limited/i); - assert.deepEqual(calls, ["https://api.example.com/v1/models"]); + assert.deepEqual( + calls.map((call) => call.url), + ["https://api.example.com/v1/models"] + ); + assert.equal(calls[0].headers.Authorization, "Bearer sk-test"); +}); + +test("openai-compatible validation forwards custom User-Agent", async () => { + const calls = []; + globalThis.fetch = async (url, init = {}) => { + calls.push({ url: String(url), headers: init.headers || {} }); + return new Response(JSON.stringify({ error: "rate limited" }), { status: 429 }); + }; + + const result = await validateProviderApiKey({ + provider: "openai-compatible-custom-ua", + apiKey: "sk-test", + providerSpecificData: { + baseUrl: "https://api.example.com/v1", + customUserAgent: "MyRouteTester/1.0", + }, + }); + + assert.equal(result.valid, true); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://api.example.com/v1/models"); + assert.equal(calls[0].headers["User-Agent"], "MyRouteTester/1.0"); }); test("openai-compatible validation treats chat 400 as authenticated fallback", async () => { From 606824d2821161a18ff87e4c00afebf16e784c56 Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Sat, 4 Apr 2026 06:32:31 -0400 Subject: [PATCH 2/3] fix: providers filter persistence and settings i18n (#970) --- .../(dashboard)/dashboard/providers/page.tsx | 13 ++++++ .../providers/providerPageStorage.ts | 42 +++++++++++++++++++ src/i18n/messages/en.json | 8 ++++ src/i18n/messages/zh-CN.json | 14 ++++++- tests/unit/providers-page-utils.test.mjs | 34 +++++++++++++++ tests/unit/settings-i18n-keys.test.mjs | 29 +++++++++++++ 6 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 src/app/(dashboard)/dashboard/providers/providerPageStorage.ts create mode 100644 tests/unit/settings-i18n-keys.test.mjs diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index fcebe0f76b..cad056e06e 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -31,6 +31,7 @@ import { buildProviderEntries, filterConfiguredProviderEntries, } from "./providerPageUtils"; +import { readConfiguredOnlyPreference, writeConfiguredOnlyPreference } from "./providerPageStorage"; const CC_COMPATIBLE_LABEL = "CC Compatible"; const ADD_CC_COMPATIBLE_LABEL = "Add CC Compatible"; @@ -115,10 +116,16 @@ export default function ProvidersPage() { const [testResults, setTestResults] = useState(null); const [importingZed, setImportingZed] = useState(false); const [showConfiguredOnly, setShowConfiguredOnly] = useState(false); + const [configuredOnlyPreferenceReady, setConfiguredOnlyPreferenceReady] = useState(false); const notify = useNotificationStore(); const t = useTranslations("providers"); const tc = useTranslations("common"); + useEffect(() => { + setShowConfiguredOnly(readConfiguredOnlyPreference()); + setConfiguredOnlyPreferenceReady(true); + }, []); + useEffect(() => { const fetchData = async () => { try { @@ -145,6 +152,12 @@ export default function ProvidersPage() { fetchData(); }, []); + useEffect(() => { + if (!configuredOnlyPreferenceReady) return; + + writeConfiguredOnlyPreference(showConfiguredOnly); + }, [configuredOnlyPreferenceReady, showConfiguredOnly]); + const handleZedImport = async () => { setImportingZed(true); try { diff --git a/src/app/(dashboard)/dashboard/providers/providerPageStorage.ts b/src/app/(dashboard)/dashboard/providers/providerPageStorage.ts new file mode 100644 index 0000000000..ff7df5b817 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/providerPageStorage.ts @@ -0,0 +1,42 @@ +export const SHOW_CONFIGURED_ONLY_STORAGE_KEY = "omniroute-providers-show-configured-only"; + +interface StorageReader { + getItem(key: string): string | null; +} + +interface StorageWriter extends StorageReader { + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +export function parseConfiguredOnlyPreference(value: string | null | undefined): boolean { + return value === "true"; +} + +function getBrowserStorage(): StorageWriter | null { + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } +} + +export function readConfiguredOnlyPreference(storage: StorageReader | null = getBrowserStorage()) { + if (!storage) return false; + + return parseConfiguredOnlyPreference(storage.getItem(SHOW_CONFIGURED_ONLY_STORAGE_KEY)); +} + +export function writeConfiguredOnlyPreference( + enabled: boolean, + storage: StorageWriter | null = getBrowserStorage() +) { + if (!storage) return; + + if (enabled) { + storage.setItem(SHOW_CONFIGURED_ONLY_STORAGE_KEY, "true"); + return; + } + + storage.removeItem(SHOW_CONFIGURED_ONLY_STORAGE_KEY); +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 0fb43524eb..7a554b35bc 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1834,6 +1834,8 @@ "cacheTTL": "Cache TTL", "maxCacheSize": "Max Cache Size", "clearCache": "Clear Cache", + "cacheCleared": "Cache cleared successfully", + "clearCacheFailed": "Failed to clear cache", "cacheHits": "Cache Hits", "cacheMisses": "Cache Misses", "hitRate": "Hit Rate", @@ -2248,9 +2250,15 @@ "themeCoral": "Coral", "adaptiveVolumeRouting": "Adaptive Volume Routing", "adaptiveVolumeRoutingDesc": "Scale connections dynamically based on payload volume and throughput pressure.", + "lkgpToggleTitle": "Last Known Good Provider (LKGP)", + "lkgpToggleDesc": "When enabled, the router remembers which provider last served a successful response and tries it first on subsequent requests.", + "clearLkgpCache": "Clear LKGP Cache", + "lkgpCacheCleared": "LKGP cache cleared successfully", + "lkgpCacheClearFailed": "Failed to clear LKGP cache", "days": "Days", "lkgp": "LKGP Mode", "lkgpDesc": "Last Known Good Provider (Predictable resilience)", + "maintenance": "Maintenance", "purgeExpiredLogs": "Purge Expired Logs", "purgeLogsFailed": "Failed to purge logs" }, diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 4db9f3c441..0c10142fd8 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1753,6 +1753,8 @@ "cacheTTL": "缓存生存时间", "maxCacheSize": "最大缓存大小", "clearCache": "清除缓存", + "cacheCleared": "缓存已成功清除", + "clearCacheFailed": "清除缓存失败", "cacheHits": "缓存命中", "cacheMisses": "缓存未命中", "hitRate": "命中率", @@ -2164,7 +2166,17 @@ "customPricingNote": "你可以覆盖特定模型的默认定价。自定义覆盖会优先于自动检测到的定价。", "editPricing": "编辑定价", "viewFullDetails": "查看完整详情", - "themeCoral": "珊瑚色" + "themeCoral": "珊瑚色", + "adaptiveVolumeRouting": "自适应流量路由", + "adaptiveVolumeRoutingDesc": "根据实时负载量和吞吐压力,动态调整各提供商连接承载的流量。", + "lkgpToggleTitle": "最后已知良好提供商(LKGP)", + "lkgpToggleDesc": "启用后,路由器会记住上一次成功返回响应的提供商,并在后续请求中优先尝试它。", + "clearLkgpCache": "清除 LKGP 缓存", + "lkgpCacheCleared": "LKGP 缓存已成功清除", + "lkgpCacheClearFailed": "清除 LKGP 缓存失败", + "maintenance": "维护", + "purgeExpiredLogs": "清理过期日志", + "purgeLogsFailed": "清理日志失败" }, "translator": { "title": "翻译者", diff --git a/tests/unit/providers-page-utils.test.mjs b/tests/unit/providers-page-utils.test.mjs index 1347201e8d..b1b89bd1d5 100644 --- a/tests/unit/providers-page-utils.test.mjs +++ b/tests/unit/providers-page-utils.test.mjs @@ -3,6 +3,8 @@ import assert from "node:assert/strict"; const providerPageUtils = await import("../../src/app/(dashboard)/dashboard/providers/providerPageUtils.ts"); +const providerPageStorage = + await import("../../src/app/(dashboard)/dashboard/providers/providerPageStorage.ts"); const providers = await import("../../src/shared/constants/providers.ts"); test("merged OAuth providers keep free-tier providers in the OAuth section", () => { @@ -72,3 +74,35 @@ test("configured-only filter keeps only providers with saved connections", () => ); assert.equal(providerPageUtils.filterConfiguredProviderEntries(entries, false).length, 3); }); + +test("configured-only preference parser only enables explicit true values", () => { + assert.equal(providerPageStorage.parseConfiguredOnlyPreference("true"), true); + assert.equal(providerPageStorage.parseConfiguredOnlyPreference("false"), false); + assert.equal(providerPageStorage.parseConfiguredOnlyPreference(null), false); + assert.equal(providerPageStorage.parseConfiguredOnlyPreference(undefined), false); +}); + +test("configured-only preference storage round-trips correctly", () => { + const storage = new Map(); + const mockStorage = { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + storage.set(key, value); + }, + removeItem(key) { + storage.delete(key); + }, + }; + + assert.equal(providerPageStorage.readConfiguredOnlyPreference(mockStorage), false); + + providerPageStorage.writeConfiguredOnlyPreference(true, mockStorage); + assert.equal(storage.get(providerPageStorage.SHOW_CONFIGURED_ONLY_STORAGE_KEY), "true"); + assert.equal(providerPageStorage.readConfiguredOnlyPreference(mockStorage), true); + + providerPageStorage.writeConfiguredOnlyPreference(false, mockStorage); + assert.equal(storage.has(providerPageStorage.SHOW_CONFIGURED_ONLY_STORAGE_KEY), false); + assert.equal(providerPageStorage.readConfiguredOnlyPreference(mockStorage), false); +}); diff --git a/tests/unit/settings-i18n-keys.test.mjs b/tests/unit/settings-i18n-keys.test.mjs new file mode 100644 index 0000000000..56efc1098e --- /dev/null +++ b/tests/unit/settings-i18n-keys.test.mjs @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const en = require("../../src/i18n/messages/en.json"); +const zhCn = require("../../src/i18n/messages/zh-CN.json"); + +const requiredSettingsKeys = [ + "adaptiveVolumeRouting", + "adaptiveVolumeRoutingDesc", + "lkgpToggleTitle", + "lkgpToggleDesc", + "clearLkgpCache", + "lkgpCacheCleared", + "lkgpCacheClearFailed", + "maintenance", + "cacheCleared", + "clearCacheFailed", + "purgeExpiredLogs", + "purgeLogsFailed", +]; + +test("settings translations include LKGP and maintenance keys in English and Simplified Chinese", () => { + for (const key of requiredSettingsKeys) { + assert.equal(typeof en.settings?.[key], "string", `en.settings.${key} should exist`); + assert.equal(typeof zhCn.settings?.[key], "string", `zh-CN.settings.${key} should exist`); + } +}); From 9148dc9e034d512ee63567249ae46a62dd63b590 Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:32:51 +0700 Subject: [PATCH 3/3] fix(providers): resolve Gemini validation 4xx errors with header-based auth (#977) Switch validateGeminiLikeProvider from query-param auth (?key=) to x-goog-api-key header auth, matching the actual request pipeline. Parse Google error response bodies to distinguish auth failures (API_KEY_INVALID, API_KEY_EXPIRED, PERMISSION_DENIED) from other 400 errors. Google returns 400 (not 401/403) for invalid keys. Add 5 new test cases covering 400/401 rejection paths and success. Fixes #976 Co-authored-by: oyi77 --- src/lib/providers/validation.ts | 64 +++++- .../provider-validation-branches.test.mjs | 188 +++++++++++++++++- 2 files changed, 239 insertions(+), 13 deletions(-) diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index e4d2acdb77..cd5fe17168 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -200,23 +200,70 @@ async function validateAnthropicLikeProvider({ return { valid: true, error: null }; } -async function validateGeminiLikeProvider({ apiKey, baseUrl }: any) { +async function validateGeminiLikeProvider({ apiKey, baseUrl, authType }: any) { if (!baseUrl) { return { valid: false, error: "Missing base URL" }; } - const separator = baseUrl.includes("?") ? "&" : "?"; - const response = await fetch(`${baseUrl}${separator}key=${encodeURIComponent(apiKey)}`, { - method: "GET", - headers: { "Content-Type": "application/json" }, - }); + // Use the correct auth header based on provider config: + // - gemini (API key): x-goog-api-key + // - gemini-cli (OAuth): Bearer token + const headers: Record = { "Content-Type": "application/json" }; + if (authType === "oauth") { + headers["Authorization"] = `Bearer ${apiKey}`; + } else { + headers["x-goog-api-key"] = apiKey; + } + + const response = await fetch(baseUrl, { method: "GET", headers }); if (response.ok) { return { valid: true, error: null }; } - if (response.status === 401 || response.status === 403) { - return { valid: false, error: "Invalid API key" }; + // 429 = rate limited, but auth is valid + if (response.status === 429) { + return { valid: true, error: null }; + } + + // Google returns 400 (not 401/403) for invalid API keys on the models endpoint. + // Parse the response body to detect auth failures. + if (response.status === 400 || response.status === 401 || response.status === 403) { + const isAuthError = (body: any) => { + const message = (body?.error?.message || "").toLowerCase(); + const reason = body?.error?.details?.[0]?.reason || ""; + const status = body?.error?.status || ""; + const authPatterns = [ + "api key not valid", + "api key expired", + "api key invalid", + "API_KEY_INVALID", + "API_KEY_EXPIRED", + "PERMISSION_DENIED", + "UNAUTHENTICATED", + ]; + return authPatterns.some( + (p) => message.includes(p.toLowerCase()) || reason === p || status === p + ); + }; + + try { + const body = await response.json(); + if (isAuthError(body)) { + return { valid: false, error: "Invalid API key" }; + } + // 401/403 are always auth failures even without matching patterns + if (response.status === 401 || response.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + } catch { + // Unparseable body — 401/403 are always auth failures + if (response.status === 401 || response.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + // 400 without parseable body — likely auth issue for Gemini + return { valid: false, error: "Invalid API key" }; + } } return { valid: false, error: `Validation failed: ${response.status}` }; @@ -847,6 +894,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi return await validateGeminiLikeProvider({ apiKey, baseUrl, + authType: entry.authType, }); } diff --git a/tests/unit/provider-validation-branches.test.mjs b/tests/unit/provider-validation-branches.test.mjs index ad45301e91..f1b5b445e9 100644 --- a/tests/unit/provider-validation-branches.test.mjs +++ b/tests/unit/provider-validation-branches.test.mjs @@ -182,10 +182,10 @@ test("registry openai-like providers report unsupported validation endpoints on ]); }); -test("gemini validation rejects invalid API keys", async () => { +test("gemini validation rejects invalid API keys (401)", async () => { const calls = []; - globalThis.fetch = async (url) => { - calls.push(String(url)); + globalThis.fetch = async (url, init) => { + calls.push({ url: String(url), headers: init?.headers }); return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 }); }; @@ -197,6 +197,184 @@ test("gemini validation rejects invalid API keys", async () => { assert.equal(result.valid, false); assert.equal(result.error, "Invalid API key"); assert.equal(calls.length, 1); - assert.match(calls[0], /generativelanguage\.googleapis\.com/); - assert.match(calls[0], /key=bad-key/); + assert.match(calls[0].url, /generativelanguage\.googleapis\.com/); + assert.equal(calls[0].headers["x-goog-api-key"], "bad-key"); +}); + +test("gemini validation rejects invalid API keys (400 with API_KEY_INVALID)", async () => { + globalThis.fetch = async () => { + return new Response( + JSON.stringify({ + error: { + code: 400, + message: "API key not valid. Please pass a valid API key.", + status: "INVALID_ARGUMENT", + details: [{ reason: "API_KEY_INVALID" }], + }, + }), + { status: 400 } + ); + }; + + const result = await validateProviderApiKey({ + provider: "gemini", + apiKey: "bad-key", + }); + + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid API key"); +}); + +test("gemini validation rejects expired API keys (400 with API_KEY_EXPIRED)", async () => { + globalThis.fetch = async () => { + return new Response( + JSON.stringify({ + error: { + code: 400, + message: "API key expired.", + status: "INVALID_ARGUMENT", + details: [{ reason: "API_KEY_EXPIRED" }], + }, + }), + { status: 400 } + ); + }; + + const result = await validateProviderApiKey({ + provider: "gemini", + apiKey: "expired-key", + }); + + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid API key"); +}); + +test("gemini validation rejects invalid keys via PERMISSION_DENIED status", async () => { + globalThis.fetch = async () => { + return new Response( + JSON.stringify({ + error: { + code: 400, + message: "Request had insufficient authentication scopes.", + status: "PERMISSION_DENIED", + details: [], + }, + }), + { status: 400 } + ); + }; + + const result = await validateProviderApiKey({ + provider: "gemini", + apiKey: "bad-key", + }); + + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid API key"); +}); + +test("gemini validation accepts valid API key (200)", async () => { + globalThis.fetch = async () => { + return new Response( + JSON.stringify({ + models: [ + { name: "models/gemini-2.5-flash", supportedGenerationMethods: ["generateContent"] }, + ], + }), + { status: 200 } + ); + }; + + const result = await validateProviderApiKey({ + provider: "gemini", + apiKey: "valid-key", + }); + + assert.equal(result.valid, true); + assert.equal(result.error, null); +}); + +test("gemini validation treats 400 with unknown body as invalid key", async () => { + globalThis.fetch = async () => { + return new Response("not json", { status: 400 }); + }; + + const result = await validateProviderApiKey({ + provider: "gemini", + apiKey: "bad-key", + }); + + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid API key"); +}); + +test("gemini validation treats 429 rate limit as valid key", async () => { + globalThis.fetch = async () => { + return new Response( + JSON.stringify({ + error: { + code: 429, + message: "You exceeded your current quota.", + status: "RESOURCE_EXHAUSTED", + }, + }), + { status: 429 } + ); + }; + + const result = await validateProviderApiKey({ + provider: "gemini", + apiKey: "valid-but-rate-limited-key", + }); + + assert.equal(result.valid, true); + assert.equal(result.error, null); +}); + +test("gemini validation rejects invalid keys via UNAUTHENTICATED status", async () => { + globalThis.fetch = async () => { + return new Response( + JSON.stringify({ + error: { + code: 401, + message: "Request is missing valid authentication credentials.", + status: "UNAUTHENTICATED", + details: [], + }, + }), + { status: 401 } + ); + }; + + const result = await validateProviderApiKey({ + provider: "gemini", + apiKey: "bad-key", + }); + + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid API key"); +}); + +test("gemini-cli validation uses Bearer auth (OAuth)", async () => { + const calls = []; + globalThis.fetch = async (url, init) => { + calls.push({ url: String(url), headers: init?.headers }); + return new Response( + JSON.stringify({ + models: [{ name: "models/gemini-2.5-flash" }], + }), + { status: 200 } + ); + }; + + const result = await validateProviderApiKey({ + provider: "gemini-cli", + apiKey: "oauth-access-token", + }); + + assert.equal(result.valid, true); + assert.equal(result.error, null); + assert.equal(calls.length, 1); + assert.equal(calls[0].headers["Authorization"], "Bearer oauth-access-token"); + assert.equal(calls[0].headers["x-goog-api-key"], undefined); });