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

@@ -0,0 +1,4 @@
# Taste (Continuously Learned by [CommandCode][cmd])
[cmd]: https://commandcode.ai/

23
docker-compose.yml Normal file
View File

@@ -0,0 +1,23 @@
services:
9router:
build:
context: .
image: 9router:local
container_name: 9router
restart: unless-stopped
env_file:
- .env
environment:
NODE_ENV: production
PORT: 20128
HOSTNAME: 0.0.0.0
DATA_DIR: /app/data
BASE_URL: http://localhost:20128
NEXT_PUBLIC_BASE_URL: http://localhost:20128
ports:
- "20128:20128"
volumes:
- 9router-data:/app/data
volumes:
9router-data:

View File

@@ -116,8 +116,20 @@ export class BaseExecutor {
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const url = this.buildUrl(model, stream, urlIndex, credentials);
const transformedBody = this.transformRequest(model, body, stream, credentials);
const headers = this.buildHeaders(credentials, stream);
// Merge extra body params from provider-specific data (request body takes priority)
let mergedBody = this.transformRequest(model, body, stream, credentials);
const extraBodyParams = credentials?.providerSpecificData?.bodyParams;
if (extraBodyParams && typeof extraBodyParams === "object" && !Array.isArray(extraBodyParams)) {
mergedBody = { ...extraBodyParams, ...mergedBody };
}
// Merge extra header params from provider-specific data (request headers take priority)
let headers = this.buildHeaders(credentials, stream);
const extraHeaderParams = credentials?.providerSpecificData?.headerParams;
if (extraHeaderParams && typeof extraHeaderParams === "object" && !Array.isArray(extraHeaderParams)) {
headers = { ...extraHeaderParams, ...headers };
}
if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0;
@@ -128,7 +140,7 @@ export class BaseExecutor {
const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal;
try {
const bodyStr = JSON.stringify(transformedBody);
const bodyStr = JSON.stringify(mergedBody);
const fetchT0 = Date.now();
dbg("FETCH", `${this.provider.toUpperCase()}${url} | body=${bodyStr.length}B | connectTimeout=${timeoutMs}ms`);
const response = await proxyAwareFetch(url, {
@@ -150,7 +162,7 @@ export class BaseExecutor {
continue;
}
return { response, url, headers, transformedBody };
return { response, url, headers, transformedBody: mergedBody };
} catch (error) {
clearTimeout(connectTimer);
lastError = error;

View File

@@ -121,6 +121,68 @@ function truncate(s, n) {
return s && s.length > n ? `${s.slice(0, n)}...` : s || "";
}
/**
* Parse Qoder-specific mid-stream error codes into user-friendly messages.
* Qoder embeds errors inside SSE envelope body as JSON: {"code":"112","message":"{...}"}
*
* Known codes:
* 112 → quota/billing exceeded (message contains pricingUrl)
* 113 → model not available for current plan
*
* @param {number} statusVal - Upstream status code from envelope
* @param {string} bodyStr - Raw body string from envelope
* @returns {{ statusCode: number, message: string, errorCode: string|null }}
*/
function parseQoderStreamError(statusVal, bodyStr) {
let code = null;
let innerMessage = "";
try {
const inner = JSON.parse(bodyStr);
code = String(inner.code || "");
innerMessage = inner.message || bodyStr;
// Code 112: quota exceeded — inner message is JSON with pricingUrl
if (code === "112") {
let pricingUrl = "";
try {
const msgObj = JSON.parse(innerMessage);
pricingUrl = msgObj.pricingUrl || "";
} catch { /* innerMessage is not JSON */ }
return {
statusCode: statusVal,
message: pricingUrl
? `Qoder quota exceeded. Upgrade your plan at: ${pricingUrl}`
: "Qoder quota exceeded. Please check your plan limits.",
errorCode: code,
};
}
// Code 113: model not available
if (code === "113") {
return {
statusCode: statusVal,
message: `Qoder model not available for your current plan. ${innerMessage}`,
errorCode: code,
};
}
} catch {
// bodyStr is not valid JSON — fall through to generic message
}
// Generic fallback
const statusLabel = statusVal >= 500 ? "server error"
: statusVal === 429 ? "rate limit exceeded"
: statusVal === 403 ? "permission error"
: statusVal === 401 ? "authentication error"
: "request error";
return {
statusCode: statusVal,
message: `Qoder ${statusLabel}: ${truncate(innerMessage || bodyStr, 200)}`,
errorCode: code,
};
}
/**
* Map the OpenAI-style request body into the exact shape Qoder expects.
*/
@@ -222,7 +284,7 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
* and re-emit as `data: <inner>\n\n`. Errors become `data: [DONE]\n\n` plus
* a synthetic OpenAI error chunk.
*/
async function wrapQoderSSE(response, model) {
async function wrapQoderSSE(response, model, midStreamError = {}) {
if (!response.ok || !response.body) return response;
const decoder = new TextDecoder();
@@ -304,13 +366,15 @@ async function wrapQoderSSE(response, model) {
const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200;
const inner = typeof envelope.body === "string" ? envelope.body : "";
if (statusVal !== 200) {
const msg = inner || `upstream status ${statusVal}`;
const parsed = parseQoderStreamError(statusVal, inner);
// Store error in shared state so onStreamComplete can trigger cooldown
midStreamError.error = { status: parsed.statusCode, message: parsed.message, errorCode: parsed.errorCode };
const errChunk = JSON.stringify({
id: `qoder-error-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, delta: { content: `\n[qoder error ${statusVal}: ${truncate(msg, 200)}]` }, finish_reason: "stop" }],
choices: [{ index: 0, delta: { content: `\n\n${parsed.message}` }, finish_reason: "stop" }],
});
controller.enqueue(encoder.encode(`data: ${errChunk}\n\n`));
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
@@ -489,8 +553,9 @@ export class QoderExecutor extends BaseExecutor {
return { response, url, headers, transformedBody: payload };
}
const wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`);
return { response: wrapped, url, headers, transformedBody: payload };
const midStreamError = {};
const wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`, midStreamError);
return { response: wrapped, url, headers, transformedBody: payload, midStreamError };
}
// Qoder device tokens don't refresh through OAuth — the upstream returns
@@ -511,6 +576,7 @@ export default QoderExecutor;
// should import QoderExecutor and use its public methods.
export const __test__ = {
normalizeMessages,
parseQoderStreamError,
wrapQoderSSE,
buildQoderRequestBody,
};

View File

@@ -28,7 +28,7 @@ import { compressMessages, formatRtkLog } from "../rtk/index.js";
* @param {object} options.credentials - Provider credentials
* @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses")
*/
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, cavemanEnabled, cavemanLevel, sourceFormatOverride, providerThinking }) {
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, onMidStreamError, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, cavemanEnabled, cavemanLevel, sourceFormatOverride, providerThinking }) {
const { provider, model } = modelInfo;
const requestStartTime = Date.now();
@@ -185,13 +185,14 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
}
// Execute request
let providerResponse, providerUrl, providerHeaders, finalBody;
let providerResponse, providerUrl, providerHeaders, finalBody, midStreamError;
try {
const result = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions });
providerResponse = result.response;
providerUrl = result.url;
providerHeaders = result.headers;
finalBody = result.transformedBody;
midStreamError = result.midStreamError;
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
} catch (error) {
trackPendingRequest(model, provider, connectionId, false, true);
@@ -258,7 +259,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
return createErrorResult(statusCode, errMsg, resetsAtMs);
}
const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess };
const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, midStreamError, onMidStreamError };
const appendLog = (extra) => appendRequestLog({ model, provider, connectionId, ...extra }).catch(() => { });
const trackDone = () => trackPendingRequest(model, provider, connectionId, false);
@@ -275,8 +276,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
return result;
}
// Streaming response
const { onStreamComplete } = buildOnStreamComplete({ ...sharedCtx });
// Streaming response (midStreamError + onMidStreamError already in sharedCtx)
const { onStreamComplete } = buildOnStreamComplete(sharedCtx);
return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete });
}

View File

@@ -132,7 +132,7 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source
/**
* Handle non-streaming response from provider.
*/
export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, trackDone, appendLog }) {
export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, trackDone, appendLog, midStreamError, onMidStreamError }) {
trackDone();
const contentType = providerResponse.headers.get("content-type") || "";
let responseBody;
@@ -144,6 +144,21 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
appendLog({ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}` });
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request");
}
// Check for mid-stream errors detected during SSE parsing (e.g. Qoder quota exceeded).
// The upstream returned HTTP 200 but the SSE envelope contained a non-200 statusCodeValue.
// Without this check, the error message would be silently served as normal content with HTTP 200.
if (midStreamError?.error) {
const err = midStreamError.error;
if (typeof onMidStreamError === "function") {
onMidStreamError(err).catch(e => {
console.error("[MidStreamError] Failed to apply cooldown:", e.message);
});
}
appendLog({ status: `FAILED ${err.status || 429}` });
return createErrorResult(err.status || 429, err.message || "Upstream error detected in SSE stream");
}
responseBody = parsed;
} else {
try {

View File

@@ -75,8 +75,10 @@ export function handleStreamingResponse({ providerResponse, provider, model, sou
/**
* Build onStreamComplete callback for streaming usage tracking.
* @param {object} options.midStreamError - Shared state object from executor (filled during streaming)
* @param {function} options.onMidStreamError - Callback to invoke when mid-stream error is detected
*/
export function buildOnStreamComplete({ provider, model, connectionId, apiKey, requestStartTime, body, stream, finalBody, translatedBody, clientRawRequest }) {
export function buildOnStreamComplete({ provider, model, connectionId, apiKey, requestStartTime, body, stream, finalBody, translatedBody, clientRawRequest, midStreamError, onMidStreamError }) {
const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const onStreamComplete = (contentObj, usage, ttftAt) => {
@@ -87,6 +89,15 @@ export function buildOnStreamComplete({ provider, model, connectionId, apiKey, r
const safeContent = contentObj?.content || "[Empty streaming response]";
const safeThinking = contentObj?.thinking || null;
// Check if a mid-stream error was detected during streaming (e.g. Qoder quota exceeded)
// The stream already returned HTTP 200, so the pre-stream error path in chatCore didn't fire.
// Apply cooldown here so the account gets locked for subsequent requests.
if (midStreamError?.error && typeof onMidStreamError === "function") {
onMidStreamError(midStreamError.error).catch(err => {
console.error("[MidStreamError] Failed to apply cooldown:", err.message);
});
}
saveRequestDetail(buildRequestDetail({
provider, model, connectionId,
latency,
@@ -95,7 +106,7 @@ export function buildOnStreamComplete({ provider, model, connectionId, apiKey, r
providerRequest: finalBody || translatedBody || null,
providerResponse: safeContent,
response: { content: safeContent, thinking: safeThinking, type: "streaming" },
status: "success"
status: midStreamError?.error ? "error" : "success"
}, { id: streamDetailId })).catch(err => {
console.error("[RequestDetail] Failed to update streaming content:", err.message);
});

View File

@@ -18,9 +18,10 @@ export function getQuotaCooldown(backoffLevel = 0) {
* @param {number} status - HTTP status code
* @param {string} errorText - Error message text
* @param {number} backoffLevel - Current backoff level for exponential backoff
* @param {number} [fixedCooldownMs=0] - When >0, override all cooldowns with this fixed value (ms)
* @returns {{ shouldFallback: boolean, cooldownMs: number, newBackoffLevel?: number }}
*/
export function checkFallbackError(status, errorText, backoffLevel = 0) {
export function checkFallbackError(status, errorText, backoffLevel = 0, fixedCooldownMs = 0) {
const lowerError = errorText
? (typeof errorText === "string" ? errorText : JSON.stringify(errorText)).toLowerCase()
: "";
@@ -28,6 +29,9 @@ export function checkFallbackError(status, errorText, backoffLevel = 0) {
for (const rule of ERROR_RULES) {
// Text-based rule: match substring in error message
if (rule.text && lowerError && lowerError.includes(rule.text)) {
if (fixedCooldownMs > 0) {
return { shouldFallback: true, cooldownMs: fixedCooldownMs, newBackoffLevel: 0 };
}
if (rule.backoff) {
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
return { shouldFallback: true, cooldownMs: getQuotaCooldown(newLevel), newBackoffLevel: newLevel };
@@ -37,6 +41,9 @@ export function checkFallbackError(status, errorText, backoffLevel = 0) {
// Status-based rule: match HTTP status code
if (rule.status && rule.status === status) {
if (fixedCooldownMs > 0) {
return { shouldFallback: true, cooldownMs: fixedCooldownMs, newBackoffLevel: 0 };
}
if (rule.backoff) {
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
return { shouldFallback: true, cooldownMs: getQuotaCooldown(newLevel), newBackoffLevel: newLevel };
@@ -46,7 +53,8 @@ export function checkFallbackError(status, errorText, backoffLevel = 0) {
}
// Default: transient cooldown for any unmatched error
return { shouldFallback: true, cooldownMs: TRANSIENT_COOLDOWN_MS };
const defaultCooldown = fixedCooldownMs > 0 ? fixedCooldownMs : TRANSIENT_COOLDOWN_MS;
return { shouldFallback: true, cooldownMs: defaultCooldown, newBackoffLevel: 0 };
}
/**

View File

@@ -19,6 +19,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.7.0",
"@tailwindcss/postcss": "^4.3.1",
"@xyflow/react": "^12.10.1",
"bcryptjs": "^3.0.3",
"confbox": "^0.2.4",
@@ -34,6 +35,8 @@
"node-machine-id": "^1.1.12",
"open": "^11.0.0",
"ora": "^9.1.0",
"postcss": "^8.5.6",
"prop-types": "^15.8.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-is": "^16.13.1",
@@ -41,6 +44,7 @@
"selfsigned": "^5.5.0",
"socks-proxy-agent": "^8.0.5",
"sql.js": "^1.14.1",
"tailwindcss": "^4",
"undici": "^7.19.2",
"uuid": "^13.0.0",
"zustand": "^5.0.10"
@@ -50,10 +54,7 @@
},
"comment_better_sqlite3": "kept in optionalDependencies so npm install doesn't fail on systems without build tools — sql.js is used as fallback at runtime",
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"postcss": "^8.5.6",
"tailwindcss": "^4"
"eslint-config-next": "16.1.6"
}
}

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

View File

@@ -466,4 +466,50 @@ describe("wrapQoderSSE", () => {
const wrapped = wrapQoderSSE(r, "qoder/auto");
expect(wrapped).toBe(r);
});
// Regression: Qoder quota-exceeded returns HTTP 200 with a non-200
// statusCodeValue inside the SSE envelope. The first-chunk peek must
// detect this and return an error Response (not HTTP 200).
it("first-chunk quota error (429 envelope) returns error Response with proper status", async () => {
const errorBody = JSON.stringify({
code: "112",
message: JSON.stringify({ pricingUrl: "https://qoder.com/pricing" }),
});
const env = JSON.stringify({ statusCodeValue: 429, body: errorBody });
const wrapped = await wrapQoderSSE(
makeResponse([`data: ${env}\n\n`], { status: 200 }),
"qoder/qmodel_latest",
);
// Must NOT be 200 — the error should be surfaced as a proper HTTP error
expect(wrapped.status).toBe(429);
expect(wrapped.ok).toBe(false);
const json = await wrapped.json();
expect(json.error.message).toContain("quota exceeded");
});
// Regression: when a mid-stream error arrives after some successful chunks,
// the shared midStreamError object must be populated so that
// handleNonStreamingResponse and buildOnStreamComplete can trigger cooldown.
it("mid-stream error populates the shared midStreamError object", async () => {
const goodInner = JSON.stringify({ choices: [{ delta: { content: "hi" } }] });
const goodEnv = JSON.stringify({ statusCodeValue: 200, body: goodInner });
const errorBody = JSON.stringify({
code: "112",
message: JSON.stringify({ pricingUrl: "https://qoder.com/pricing" }),
});
const errorEnv = JSON.stringify({ statusCodeValue: 429, body: errorBody });
const midStreamError = {};
const wrapped = await wrapQoderSSE(
makeResponse([`data: ${goodEnv}\n\ndata: ${errorEnv}\n\n`], { status: 200 }),
"qoder/qmodel_latest",
midStreamError,
);
await drain(wrapped);
expect(midStreamError.error).toBeDefined();
expect(midStreamError.error.status).toBe(429);
expect(midStreamError.error.message).toContain("quota exceeded");
expect(midStreamError.error.errorCode).toBe("112");
});
});