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({
})}
)}
+ setShowAdvanced(!showAdvanced)}
+ aria-expanded={showAdvanced}
+ aria-controls="add-api-key-advanced-settings"
+ >
+
+ ▶
+
+ {t("advancedSettings")}
+
+ {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}
)}
+ setShowAdvanced(!showAdvanced)}
+ aria-expanded={showAdvanced}
+ aria-controls="edit-connection-advanced-settings"
+ >
+
+ ▶
+
+ {t("advancedSettings")}
+
+ {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);
});