update: sync local changes with latest features
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Button, Badge, Input, Modal, Select } from "@/shared/components";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
@@ -38,6 +38,9 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
||||
});
|
||||
const [cloudflareData, setCloudflareData] = useState({ accountId: "" });
|
||||
const [region, setRegion] = useState(defaultRegion);
|
||||
const [extraBodyParams, setExtraBodyParams] = useState("");
|
||||
const [extraHeaderParams, setExtraHeaderParams] = useState("");
|
||||
const [jsonError, setJsonError] = useState("");
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -45,25 +48,54 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
||||
const [bulkText, setBulkText] = useState("");
|
||||
const [bulkResult, setBulkResult] = useState(null); // { success, failed }
|
||||
|
||||
const handleJsonChange = useCallback((value, setter) => {
|
||||
setter(value);
|
||||
if (value.trim()) {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
setJsonError("");
|
||||
} catch {
|
||||
setJsonError("Invalid JSON");
|
||||
}
|
||||
} else {
|
||||
setJsonError("");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const parseJsonSafe = (str) => {
|
||||
if (!str.trim()) return undefined;
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const buildProviderSpecificData = () => {
|
||||
const data = {};
|
||||
if (isOllamaLocal && formData.ollamaHostUrl.trim()) {
|
||||
return { baseUrl: formData.ollamaHostUrl.trim() };
|
||||
data.baseUrl = formData.ollamaHostUrl.trim();
|
||||
}
|
||||
if (isAzure) {
|
||||
return {
|
||||
Object.assign(data, {
|
||||
azureEndpoint: azureData.azureEndpoint,
|
||||
apiVersion: azureData.apiVersion,
|
||||
deployment: azureData.deployment,
|
||||
organization: azureData.organization,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (isCloudflareAi) {
|
||||
return { accountId: cloudflareData.accountId };
|
||||
data.accountId = cloudflareData.accountId;
|
||||
}
|
||||
if (providerRegions && region) {
|
||||
return { region };
|
||||
data.region = region;
|
||||
}
|
||||
return undefined;
|
||||
// Extra params
|
||||
const parsedBody = parseJsonSafe(extraBodyParams);
|
||||
const parsedHeaders = parseJsonSafe(extraHeaderParams);
|
||||
if (parsedBody) data.bodyParams = parsedBody;
|
||||
if (parsedHeaders) data.headerParams = parsedHeaders;
|
||||
return Object.keys(data).length > 0 ? data : undefined;
|
||||
};
|
||||
|
||||
const handleValidate = async () => {
|
||||
@@ -295,6 +327,35 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Extra Request Parameters */}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-xs font-medium text-text-muted hover:text-primary transition-colors select-none list-none flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[14px] group-open:rotate-90 transition-transform">chevron_right</span>
|
||||
Extra Request Parameters
|
||||
</summary>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-text-muted block mb-1">Extra Body Params <span className="text-text-muted/60">(JSON object, merged into request body)</span></label>
|
||||
<textarea
|
||||
className="w-full rounded border border-accent/30 bg-sidebar p-2 text-xs font-mono resize-y min-h-[60px] focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder={'{\n "max_tokens": 1,\n "stream": true\n}'}
|
||||
value={extraBodyParams}
|
||||
onChange={(e) => handleJsonChange(e.target.value, setExtraBodyParams)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted block mb-1">Extra Header Params <span className="text-text-muted/60">(JSON object, merged into request headers)</span></label>
|
||||
<textarea
|
||||
className="w-full rounded border border-accent/30 bg-sidebar p-2 text-xs font-mono resize-y min-h-[60px] focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder={'{\n "X-Custom-Header": "value"\n}'}
|
||||
value={extraHeaderParams}
|
||||
onChange={(e) => handleJsonChange(e.target.value, setExtraHeaderParams)}
|
||||
/>
|
||||
</div>
|
||||
{jsonError && <p className="text-xs text-red-500">{jsonError}</p>}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{isAzure && (
|
||||
<div className="bg-sidebar/50 p-4 rounded-lg border border-accent/20">
|
||||
<h3 className="font-semibold mb-3 text-sm">Azure OpenAI Configuration</h3>
|
||||
|
||||
@@ -53,7 +53,11 @@ export default function ProviderDetailPage() {
|
||||
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
|
||||
const [providerStrategy, setProviderStrategy] = useState(null);
|
||||
const [providerStickyLimit, setProviderStickyLimit] = useState("");
|
||||
const [accountCooldown, setAccountCooldown] = useState("");
|
||||
const [thinkingMode, setThinkingMode] = useState("auto");
|
||||
const [extraBodyParamsStr, setExtraBodyParamsStr] = useState("");
|
||||
const [extraHeaderParamsStr, setExtraHeaderParamsStr] = useState("");
|
||||
const [extraParamsJsonError, setExtraParamsJsonError] = useState("");
|
||||
const [suggestedModels, setSuggestedModels] = useState([]);
|
||||
const [kiloFreeModels, setKiloFreeModels] = useState([]);
|
||||
const [disabledModelIds, setDisabledModelIds] = useState([]);
|
||||
@@ -253,9 +257,15 @@ export default function ProviderDetailPage() {
|
||||
const override = (settingsData.providerStrategies || {})[providerId] || {};
|
||||
setProviderStrategy(override.fallbackStrategy || null);
|
||||
setProviderStickyLimit(override.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "1");
|
||||
// Load global account cooldown setting
|
||||
setAccountCooldown(settingsData.accountCooldownSeconds != null ? String(settingsData.accountCooldownSeconds) : "0");
|
||||
// Load per-provider thinking config
|
||||
const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {};
|
||||
setThinkingMode(thinkingCfg.mode || "auto");
|
||||
// Load per-provider extra params
|
||||
const extraParams = (settingsData.providerExtraParams || {})[providerId] || {};
|
||||
setExtraBodyParamsStr(extraParams.bodyParams ? JSON.stringify(extraParams.bodyParams, null, 2) : "");
|
||||
setExtraHeaderParamsStr(extraParams.headerParams ? JSON.stringify(extraParams.headerParams, null, 2) : "");
|
||||
if (nodesRes.ok) {
|
||||
let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null;
|
||||
|
||||
@@ -342,6 +352,20 @@ export default function ProviderDetailPage() {
|
||||
saveProviderStrategy("round-robin", value);
|
||||
};
|
||||
|
||||
const saveAccountCooldown = async (value) => {
|
||||
const num = Math.max(0, parseInt(value, 10) || 0);
|
||||
setAccountCooldown(String(num));
|
||||
try {
|
||||
await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ accountCooldownSeconds: num }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error saving account cooldown:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveThinkingConfig = async (mode) => {
|
||||
try {
|
||||
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
|
||||
@@ -368,6 +392,48 @@ export default function ProviderDetailPage() {
|
||||
saveThinkingConfig(mode);
|
||||
};
|
||||
|
||||
const handleExtraParamsChange = (field, value) => {
|
||||
const setter = field === "bodyParams" ? setExtraBodyParamsStr : setExtraHeaderParamsStr;
|
||||
setter(value);
|
||||
if (value.trim()) {
|
||||
try { JSON.parse(value); setExtraParamsJsonError(""); }
|
||||
catch { setExtraParamsJsonError("Invalid JSON"); }
|
||||
} else {
|
||||
setExtraParamsJsonError("");
|
||||
}
|
||||
};
|
||||
|
||||
const saveExtraParams = async () => {
|
||||
let parsedBody, parsedHeaders;
|
||||
try { parsedBody = extraBodyParamsStr.trim() ? JSON.parse(extraBodyParamsStr) : undefined; }
|
||||
catch { setExtraParamsJsonError("Invalid JSON in Body Params"); return; }
|
||||
try { parsedHeaders = extraHeaderParamsStr.trim() ? JSON.parse(extraHeaderParamsStr) : undefined; }
|
||||
catch { setExtraParamsJsonError("Invalid JSON in Header Params"); return; }
|
||||
|
||||
try {
|
||||
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
|
||||
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
||||
const current = settingsData.providerExtraParams || {};
|
||||
const updated = { ...current };
|
||||
const entry = {};
|
||||
if (parsedBody) entry.bodyParams = parsedBody;
|
||||
if (parsedHeaders) entry.headerParams = parsedHeaders;
|
||||
if (Object.keys(entry).length > 0) {
|
||||
updated[providerId] = entry;
|
||||
} else {
|
||||
delete updated[providerId];
|
||||
}
|
||||
await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ providerExtraParams: updated }),
|
||||
});
|
||||
setExtraParamsJsonError("");
|
||||
} catch (error) {
|
||||
console.log("Error saving extra params:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnections();
|
||||
fetchAliases();
|
||||
@@ -1306,6 +1372,58 @@ export default function ProviderDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Account Cooldown — global setting, visible on every provider page */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-text-muted font-medium">Account Cooldown</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={accountCooldown}
|
||||
onChange={(e) => setAccountCooldown(e.target.value)}
|
||||
onBlur={(e) => saveAccountCooldown(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") saveAccountCooldown(e.target.value); }}
|
||||
placeholder="0"
|
||||
className="w-16 px-2 py-1 text-xs border border-border rounded-md bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
<span className="text-xs text-text-muted">sec</span>
|
||||
</div>
|
||||
<span className="text-xs text-text-muted/50">
|
||||
{accountCooldown === "0" || accountCooldown === "" ? "(exponential backoff)" : "(global, fixed)"}
|
||||
</span>
|
||||
</div>
|
||||
{/* Extra Request Parameters (provider-level) */}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-xs font-medium text-text-muted hover:text-primary transition-colors select-none list-none flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[14px] group-open:rotate-90 transition-transform">chevron_right</span>
|
||||
Extra Request Parameters
|
||||
<span className="text-text-muted/50 font-normal">(applies to all connections)</span>
|
||||
</summary>
|
||||
<div className="mt-2 flex flex-col gap-2 min-w-[280px]">
|
||||
<div>
|
||||
<label className="text-xs text-text-muted block mb-1">Extra Body Params <span className="text-text-muted/60">(JSON object, merged into request body)</span></label>
|
||||
<textarea
|
||||
className="w-full rounded border border-accent/30 bg-sidebar p-2 text-xs font-mono resize-y min-h-[60px] focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder={'{\n "max_tokens": 1,\n "stream": true\n}'}
|
||||
value={extraBodyParamsStr}
|
||||
onChange={(e) => handleExtraParamsChange("bodyParams", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted block mb-1">Extra Header Params <span className="text-text-muted/60">(JSON object, merged into request headers)</span></label>
|
||||
<textarea
|
||||
className="w-full rounded border border-accent/30 bg-sidebar p-2 text-xs font-mono resize-y min-h-[60px] focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder={'{\n "X-Custom-Header": "value"\n}'}
|
||||
value={extraHeaderParamsStr}
|
||||
onChange={(e) => handleExtraParamsChange("headerParams", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{extraParamsJsonError && <p className="text-xs text-red-500">{extraParamsJsonError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={saveExtraParams}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ const DEFAULT_SETTINGS = {
|
||||
rtkEnabled: true,
|
||||
cavemanEnabled: false,
|
||||
cavemanLevel: "full",
|
||||
providerExtraParams: {},
|
||||
accountCooldownSeconds: 600,
|
||||
};
|
||||
|
||||
async function readRaw() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Modal from "@/shared/components/Modal";
|
||||
import Input from "@/shared/components/Input";
|
||||
@@ -21,12 +21,37 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
|
||||
organization: "",
|
||||
});
|
||||
const [cloudflareData, setCloudflareData] = useState({ accountId: "" });
|
||||
const [extraBodyParams, setExtraBodyParams] = useState("");
|
||||
const [extraHeaderParams, setExtraHeaderParams] = useState("");
|
||||
const [jsonError, setJsonError] = useState("");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const setExtraBodyParamsSafe = (v) => setExtraBodyParams(v);
|
||||
const setExtraHeaderParamsSafe = (v) => setExtraHeaderParams(v);
|
||||
|
||||
const handleJsonChange = useCallback((value, setter) => {
|
||||
setter(value);
|
||||
if (value.trim()) {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
setJsonError("");
|
||||
} catch {
|
||||
setJsonError("Invalid JSON");
|
||||
}
|
||||
} else {
|
||||
setJsonError("");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const parseJsonSafe = (str) => {
|
||||
if (!str.trim()) return undefined;
|
||||
try { return JSON.parse(str); } catch { return undefined; }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (connection) {
|
||||
setFormData({
|
||||
@@ -46,8 +71,20 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
|
||||
if (connection.provider === "cloudflare-ai" && connection.providerSpecificData) {
|
||||
setCloudflareData({ accountId: connection.providerSpecificData.accountId || "" });
|
||||
}
|
||||
// Load extra params from existing connection
|
||||
if (connection.providerSpecificData?.bodyParams) {
|
||||
setExtraBodyParams(JSON.stringify(connection.providerSpecificData.bodyParams, null, 2));
|
||||
} else {
|
||||
setExtraBodyParams("");
|
||||
}
|
||||
if (connection.providerSpecificData?.headerParams) {
|
||||
setExtraHeaderParams(JSON.stringify(connection.providerSpecificData.headerParams, null, 2));
|
||||
} else {
|
||||
setExtraHeaderParams("");
|
||||
}
|
||||
setTestResult(null);
|
||||
setValidationResult(null);
|
||||
setJsonError("");
|
||||
}
|
||||
}, [connection]);
|
||||
|
||||
@@ -150,6 +187,17 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
|
||||
if (isCloudflareAi) {
|
||||
updates.providerSpecificData = { accountId: cloudflareData.accountId };
|
||||
}
|
||||
|
||||
// Merge extra params into providerSpecificData
|
||||
const parsedBody = parseJsonSafe(extraBodyParams);
|
||||
const parsedHeaders = parseJsonSafe(extraHeaderParams);
|
||||
if (parsedBody || parsedHeaders) {
|
||||
updates.providerSpecificData = updates.providerSpecificData || { ...(connection.providerSpecificData || {}) };
|
||||
if (parsedBody) updates.providerSpecificData.bodyParams = parsedBody;
|
||||
else delete updates.providerSpecificData.bodyParams;
|
||||
if (parsedHeaders) updates.providerSpecificData.headerParams = parsedHeaders;
|
||||
else delete updates.providerSpecificData.headerParams;
|
||||
}
|
||||
|
||||
await onSave(updates);
|
||||
} finally {
|
||||
@@ -256,6 +304,35 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra Request Parameters */}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-xs font-medium text-text-muted hover:text-primary transition-colors select-none list-none flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[14px] group-open:rotate-90 transition-transform">chevron_right</span>
|
||||
Extra Request Parameters
|
||||
</summary>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-text-muted block mb-1">Extra Body Params <span className="text-text-muted/60">(JSON object, merged into request body)</span></label>
|
||||
<textarea
|
||||
className="w-full rounded border border-accent/30 bg-sidebar p-2 text-xs font-mono resize-y min-h-[60px] focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder={'{\n "max_tokens": 1,\n "stream": true\n}'}
|
||||
value={extraBodyParams}
|
||||
onChange={(e) => handleJsonChange(e.target.value, setExtraBodyParamsSafe)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted block mb-1">Extra Header Params <span className="text-text-muted/60">(JSON object, merged into request headers)</span></label>
|
||||
<textarea
|
||||
className="w-full rounded border border-accent/30 bg-sidebar p-2 text-xs font-mono resize-y min-h-[60px] focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder={'{\n "X-Custom-Header": "value"\n}'}
|
||||
value={extraHeaderParams}
|
||||
onChange={(e) => handleJsonChange(e.target.value, setExtraHeaderParamsSafe)}
|
||||
/>
|
||||
</div>
|
||||
{jsonError && <p className="text-xs text-red-500">{jsonError}</p>}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSubmit} fullWidth disabled={saving}>{saving ? "Saving..." : "Save"}</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>Cancel</Button>
|
||||
|
||||
@@ -225,6 +225,12 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
||||
},
|
||||
onRequestSuccess: async () => {
|
||||
await clearAccountError(credentials.connectionId, credentials, model);
|
||||
},
|
||||
onMidStreamError: async (error) => {
|
||||
// Mid-stream error detected after HTTP 200 was already sent (e.g. Qoder quota exceeded)
|
||||
// Apply cooldown so subsequent requests skip this account
|
||||
log.warn("AUTH", `Mid-stream error on ${credentials.connectionName}: ${error.message}`);
|
||||
await markAccountUnavailable(credentials.connectionId, error.status, error.message, provider, model);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -158,6 +158,9 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
|
||||
|
||||
const resolvedProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
|
||||
|
||||
// Inject provider-level extra params from settings (applies to all connections)
|
||||
const providerExtraParams = (settings.providerExtraParams || {})[providerId] || {};
|
||||
|
||||
return {
|
||||
authType: connection.authType,
|
||||
apiKey: connection.apiKey,
|
||||
@@ -171,7 +174,8 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
|
||||
connectionName: connection.displayName || connection.name || connection.email || connection.id,
|
||||
copilotToken: connection.providerSpecificData?.copilotToken,
|
||||
providerSpecificData: {
|
||||
...(connection.providerSpecificData || {}),
|
||||
...providerExtraParams, // provider-level base (less specific)
|
||||
...(connection.providerSpecificData || {}), // connection-level overrides (more specific)
|
||||
connectionProxyEnabled: resolvedProxy.connectionProxyEnabled,
|
||||
connectionProxyUrl: resolvedProxy.connectionProxyUrl,
|
||||
connectionNoProxy: resolvedProxy.connectionNoProxy,
|
||||
@@ -213,7 +217,10 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
|
||||
cooldownMs = Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS);
|
||||
newBackoffLevel = 0;
|
||||
} else {
|
||||
({ shouldFallback, cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel));
|
||||
// Read user-configured fixed cooldown (global, applies to all providers)
|
||||
const settings = await getSettings();
|
||||
const fixedCooldownMs = (settings.accountCooldownSeconds || 0) * 1000;
|
||||
({ shouldFallback, cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel, fixedCooldownMs));
|
||||
}
|
||||
if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user