update: sync local changes with latest features

This commit is contained in:
2026-06-22 11:26:25 +07:00
parent 1cf55126f5
commit 98412aa0bb
16 changed files with 491 additions and 33 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -36,6 +36,8 @@ const DEFAULT_SETTINGS = {
rtkEnabled: true,
cavemanEnabled: false,
cavemanLevel: "full",
providerExtraParams: {},
accountCooldownSeconds: 600,
};
async function readRaw() {

View File

@@ -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>

View File

@@ -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);
}
});

View File

@@ -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 };