mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat: allow custom User-Agent per provider connection (#975)
This commit is contained in:
@@ -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
|
||||
|
||||
23
README.md
23
README.md
@@ -11,9 +11,28 @@ _Your universal API proxy — one endpoint, 60+ providers, zero downtime. Now wi
|
||||
<div align="center">
|
||||
|
||||
[](https://www.npmjs.com/package/omniroute)
|
||||
[](https://www.npmjs.com/package/omniroute)
|
||||
[](https://hub.docker.com/r/diegosouzapw/omniroute)
|
||||
[](https://hub.docker.com/r/diegosouzapw/omniroute)
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
[](https://github.com/diegosouzapw/OmniRoute/stargazers)
|
||||
[](https://github.com/diegosouzapw/OmniRoute/issues)
|
||||
[](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
|
||||
[](https://github.com/diegosouzapw/OmniRoute/commits/main)
|
||||
[](https://github.com/diegosouzapw)
|
||||
[](https://github.com/diegosouzapw/OmniRoute)
|
||||
[](https://github.com/diegosouzapw/OmniRoute/pulls?q=is%3Apr+is%3Aclosed)
|
||||
[](https://github.com/diegosouzapw/OmniRoute/tags)
|
||||
[](https://github.com/diegosouzapw)
|
||||
[](https://github.com/diegosouzapw?tab=followers)
|
||||
[](https://github.com/diegosouzapw/OmniRoute/network/members)
|
||||
[](https://github.com/diegosouzapw/OmniRoute/watchers)
|
||||
|
||||
[](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
|
||||
[](https://omniroute.online)
|
||||
[](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
|
||||
|
||||
@@ -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<string, string>, userAgent: string): void {
|
||||
headers["User-Agent"] = userAgent;
|
||||
if ("user-agent" in headers) {
|
||||
headers["user-agent"] = userAgent;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyConfiguredUserAgent(
|
||||
headers: Record<string, string>,
|
||||
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
|
||||
|
||||
@@ -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<string, string> => {
|
||||
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)
|
||||
|
||||
@@ -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<string | null>(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<string, unknown> = {};
|
||||
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({
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
aria-expanded={showAdvanced}
|
||||
aria-controls="add-api-key-advanced-settings"
|
||||
>
|
||||
<span
|
||||
className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
{t("advancedSettings")}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div
|
||||
id="add-api-key-advanced-settings"
|
||||
className="flex flex-col gap-3 pl-2 border-l-2 border-border"
|
||||
>
|
||||
<Input
|
||||
label="Custom User-Agent"
|
||||
value={formData.customUserAgent}
|
||||
onChange={(e) => setFormData({ ...formData, customUserAgent: e.target.value })}
|
||||
placeholder="my-app/1.0"
|
||||
hint="Optional override sent upstream as the User-Agent header for this connection"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label="Model ID (opcional)"
|
||||
placeholder="ex: grok-3 ou meta-llama/Llama-3.1-8B-Instruct"
|
||||
@@ -4796,6 +4827,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
apiRegion: "international",
|
||||
validationModelId: "",
|
||||
tag: "",
|
||||
customUserAgent: "",
|
||||
});
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
@@ -4805,6 +4837,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [extraApiKeys, setExtraApiKeys] = useState<string[]>([]);
|
||||
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}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
aria-expanded={showAdvanced}
|
||||
aria-controls="edit-connection-advanced-settings"
|
||||
>
|
||||
<span
|
||||
className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
{t("advancedSettings")}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div
|
||||
id="edit-connection-advanced-settings"
|
||||
className="flex flex-col gap-3 pl-2 border-l-2 border-border"
|
||||
>
|
||||
<Input
|
||||
label="Custom User-Agent"
|
||||
value={formData.customUserAgent}
|
||||
onChange={(e) => setFormData({ ...formData, customUserAgent: e.target.value })}
|
||||
placeholder="my-app/1.0"
|
||||
hint="Optional override sent upstream as the User-Agent header for this connection"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label="Model ID (opcional)"
|
||||
placeholder="ex: grok-3 ou meta-llama/Llama-3.1-8B-Instruct"
|
||||
|
||||
@@ -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<any>(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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -31,9 +31,12 @@ export async function POST(request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { provider, apiKey, validationModelId } = validation.data;
|
||||
const { provider, apiKey, validationModelId, customUserAgent } = validation.data;
|
||||
|
||||
let providerSpecificData: any = { validationModelId };
|
||||
if (customUserAgent) {
|
||||
providerSpecificData.customUserAgent = customUserAgent;
|
||||
}
|
||||
|
||||
if (isOpenAICompatibleProvider(provider) || isAnthropicCompatibleProvider(provider)) {
|
||||
const node: any = await getProviderNodeById(provider);
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "翻译者",
|
||||
|
||||
@@ -80,13 +80,42 @@ function resolveChatUrl(provider: string, baseUrl: string, providerSpecificData:
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildBearerHeaders(apiKey: string) {
|
||||
function getCustomUserAgent(providerSpecificData: any = {}) {
|
||||
if (typeof providerSpecificData?.customUserAgent !== "string") return null;
|
||||
const customUserAgent = providerSpecificData.customUserAgent.trim();
|
||||
return customUserAgent || null;
|
||||
}
|
||||
|
||||
function applyCustomUserAgent(headers: Record<string, string>, 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<string, string> | 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,23 +232,76 @@ async function validateAnthropicLikeProvider({
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
|
||||
async function validateGeminiLikeProvider({ apiKey, baseUrl }: any) {
|
||||
async function validateGeminiLikeProvider({
|
||||
apiKey,
|
||||
baseUrl,
|
||||
authType,
|
||||
providerSpecificData = {},
|
||||
}: 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<string, string> = { "Content-Type": "application/json" };
|
||||
if (authType === "oauth") {
|
||||
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
} else {
|
||||
headers["x-goog-api-key"] = apiKey;
|
||||
}
|
||||
applyCustomUserAgent(headers, providerSpecificData);
|
||||
|
||||
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}` };
|
||||
@@ -224,11 +309,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 +325,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 +347,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 +375,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 +400,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 +445,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 +496,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 +541,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 +603,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 +624,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 +693,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 +723,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 +766,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 +867,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 +891,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 +957,8 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
return await validateGeminiLikeProvider({
|
||||
apiKey,
|
||||
baseUrl,
|
||||
providerSpecificData,
|
||||
authType: entry.authType,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -182,10 +208,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 +223,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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
29
tests/unit/settings-i18n-keys.test.mjs
Normal file
29
tests/unit/settings-i18n-keys.test.mjs
Normal file
@@ -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`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user