Compare commits
6 Commits
master
...
feature/sy
| Author | SHA1 | Date | |
|---|---|---|---|
| 2305e26e25 | |||
| 1fe8115dad | |||
| 98412aa0bb | |||
| 1cf55126f5 | |||
| ba4ee30122 | |||
| bef54d5f12 |
4
.commandcode/taste/taste.md
Normal file
4
.commandcode/taste/taste.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Taste (Continuously Learned by [CommandCode][cmd])
|
||||
|
||||
[cmd]: https://commandcode.ai/
|
||||
|
||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal 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:
|
||||
@@ -125,8 +125,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;
|
||||
|
||||
@@ -137,7 +149,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, {
|
||||
@@ -159,7 +171,7 @@ export class BaseExecutor {
|
||||
continue;
|
||||
}
|
||||
|
||||
return { response, url, headers, transformedBody };
|
||||
return { response, url, headers, transformedBody: mergedBody };
|
||||
} catch (error) {
|
||||
clearTimeout(connectTimer);
|
||||
lastError = error;
|
||||
|
||||
@@ -122,6 +122,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.
|
||||
*/
|
||||
@@ -223,22 +285,75 @@ 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.
|
||||
*/
|
||||
function wrapQoderSSE(response, model) {
|
||||
async function wrapQoderSSE(response, model, midStreamError = {}) {
|
||||
if (!response.ok || !response.body) return response;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Peek at first chunk to detect errors early
|
||||
const reader = response.body.getReader();
|
||||
const firstRead = await reader.read();
|
||||
|
||||
if (firstRead.done) {
|
||||
// Empty stream
|
||||
return new Response("data: [DONE]\n\n", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Parse first line to check for error
|
||||
const firstText = decoder.decode(firstRead.value, { stream: true });
|
||||
const nlIndex = firstText.indexOf("\n");
|
||||
const firstLine = nlIndex !== -1 ? firstText.slice(0, nlIndex) : firstText;
|
||||
const trimmed = firstLine.replace(/\r$/, "").trim();
|
||||
|
||||
if (trimmed.startsWith("data:")) {
|
||||
const data = trimmed.slice(5).trimStart();
|
||||
if (data !== "[DONE]") {
|
||||
try {
|
||||
const envelope = JSON.parse(data);
|
||||
const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200;
|
||||
|
||||
if (statusVal !== 200) {
|
||||
// Error detected - return error Response to trigger failover
|
||||
const msg = envelope.body || `upstream status ${statusVal}`;
|
||||
const errorResponse = new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: `qoder error ${statusVal}: ${truncate(msg, 500)}`,
|
||||
type: "upstream_error",
|
||||
code: String(statusVal)
|
||||
}
|
||||
}),
|
||||
{
|
||||
status: statusVal >= 400 && statusVal < 600 ? statusVal : 502,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}
|
||||
);
|
||||
reader.cancel();
|
||||
return errorResponse;
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, continue as normal stream
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No error detected - proceed with normal TransformStream
|
||||
let buffer = "";
|
||||
let doneEmitted = false;
|
||||
|
||||
// Process one already-extracted SSE line (no trailing newline). Returns
|
||||
// false when the line indicated end-of-stream so the caller can stop
|
||||
// forwarding any remaining chunks after [DONE].
|
||||
const processLine = (line, controller) => {
|
||||
const trimmed = line.replace(/\r$/, "").trim();
|
||||
if (!trimmed) return;
|
||||
if (!trimmed.startsWith("data:")) return;
|
||||
if (doneEmitted) return; // never forward chunks past stream end
|
||||
if (doneEmitted) return;
|
||||
|
||||
const data = trimmed.slice(5).trimStart();
|
||||
if (data === "[DONE]") {
|
||||
@@ -252,15 +367,11 @@ 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 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" }],
|
||||
});
|
||||
controller.enqueue(encoder.encode(`data: ${errChunk}\n\n`));
|
||||
const parsed = parseQoderStreamError(statusVal, inner);
|
||||
// Store error in shared state so the caller can return a proper error Response
|
||||
midStreamError.error = { status: parsed.statusCode, message: parsed.message, errorCode: parsed.errorCode };
|
||||
// End the stream with [DONE]; the caller (QoderExecutor.execute) will detect
|
||||
// midStreamError and return a non-2xx Response so the API client gets a real error.
|
||||
controller.enqueue(encoder.encode(SSE_DONE));
|
||||
doneEmitted = true;
|
||||
return;
|
||||
@@ -271,10 +382,6 @@ function wrapQoderSSE(response, model) {
|
||||
doneEmitted = true;
|
||||
return;
|
||||
}
|
||||
// Inner is an OpenAI-shaped chunk. Strip any embedded newlines so the
|
||||
// SSE frame stays a single event (a literal "\n" inside `inner` would
|
||||
// otherwise split the frame across multiple data: lines and downstream
|
||||
// parsers would reassemble them as separate events).
|
||||
const sanitized = inner.replace(/\r?\n/g, "");
|
||||
controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`));
|
||||
};
|
||||
@@ -290,13 +397,7 @@ function wrapQoderSSE(response, model) {
|
||||
}
|
||||
},
|
||||
flush(controller) {
|
||||
// Finalize the decoder so any pending multi-byte sequence is
|
||||
// released into `buffer` instead of being silently dropped.
|
||||
buffer += decoder.decode();
|
||||
// Drain any trailing line that arrived without a terminating newline
|
||||
// (e.g. upstream closed the socket immediately after the last write,
|
||||
// or a CDN stripped the final CRLF). Without this, the chunk that
|
||||
// carries finish_reason is silently lost.
|
||||
if (buffer.length > 0) {
|
||||
processLine(buffer, controller);
|
||||
buffer = "";
|
||||
@@ -308,9 +409,25 @@ function wrapQoderSSE(response, model) {
|
||||
},
|
||||
});
|
||||
|
||||
const transformed = response.body.pipeThrough(transform);
|
||||
// Build a Response with passable headers; the streaming handler reads
|
||||
// `.body` as a ReadableStream regardless of Content-Type.
|
||||
// Create a ReadableStream that emits the first chunk + remaining chunks
|
||||
const combinedStream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(firstRead.value);
|
||||
},
|
||||
async pull(controller) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
controller.close();
|
||||
} else {
|
||||
controller.enqueue(value);
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
reader.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
const transformed = combinedStream.pipeThrough(transform);
|
||||
return new Response(transformed, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
@@ -431,19 +548,83 @@ export class QoderExecutor extends BaseExecutor {
|
||||
return { response, url, headers, transformedBody: payload };
|
||||
}
|
||||
|
||||
const wrapped = wrapQoderSSE(response, `qoder/${qoderKey}`);
|
||||
return { response: wrapped, url, headers, transformedBody: payload };
|
||||
const midStreamError = {};
|
||||
let wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`, midStreamError);
|
||||
|
||||
// If a mid-stream error was detected, return a proper error Response instead of
|
||||
// a successful one with an error message inside.
|
||||
if (midStreamError?.error) {
|
||||
const err = midStreamError.error;
|
||||
log?.error?.("QODER", `Upstream error ${err.status}: ${err.message}`);
|
||||
wrapped = new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: err.message,
|
||||
type: "upstream_error",
|
||||
code: String(err.status),
|
||||
}
|
||||
}),
|
||||
{
|
||||
status: err.status >= 400 && err.status < 600 ? err.status : 502,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return { response: wrapped, url, headers, transformedBody: payload, midStreamError };
|
||||
}
|
||||
|
||||
// Qoder device tokens don't refresh through OAuth — the upstream returns
|
||||
// 403 for our flow. Surfacing failure via 401-on-chat is enough; the
|
||||
// dashboard tells users to re-login when their token expires (~30 days).
|
||||
async refreshCredentials() {
|
||||
return null;
|
||||
// Validate Qoder token by calling the quota endpoint. If it returns 403,
|
||||
// return a structured unrecoverable error so chatCore bails before sending
|
||||
// the actual chat request (instead of forwarding a "quota exceeded" success).
|
||||
async refreshCredentials(credentials, log) {
|
||||
// Qoder's quota endpoint validates the access token. If it returns 403,
|
||||
// the token is invalid and the user needs to reconnect.
|
||||
const oauth = PROVIDERS.qoder?.oauth;
|
||||
const url = oauth?.quotaUsageUrl || "https://openapi.qoder.sh/api/v2/quota/usage";
|
||||
const authToken = credentials?.accessToken;
|
||||
if (!authToken) return null;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (res.status === 403) {
|
||||
const errText = await res.text().catch(() => "");
|
||||
log?.error?.("TOKEN_REFRESH", `Qoder token invalid (403): ${errText}`);
|
||||
return {
|
||||
error: "unrecoverable_refresh_error",
|
||||
code: "invalid_token",
|
||||
message: "qoder token invalid; reconnect the account",
|
||||
};
|
||||
}
|
||||
|
||||
// Token is valid — stamp lastRefreshAt so needsRefresh stays quiet
|
||||
const psd = { ...credentials.providerSpecificData, lastRefreshAt: new Date().toISOString() };
|
||||
return {
|
||||
accessToken: authToken,
|
||||
expiresIn: 86400,
|
||||
providerSpecificData: psd,
|
||||
lastRefreshAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
log?.warn?.("TOKEN_REFRESH", `Qoder refresh check failed: ${err.message}`);
|
||||
return null; // Network blip — let the request proceed, failover handles 403
|
||||
}
|
||||
}
|
||||
|
||||
needsRefresh() {
|
||||
return false;
|
||||
// 24h cooldown before re-checking token validity
|
||||
needsRefresh(credentials) {
|
||||
if (!credentials?.accessToken) return false;
|
||||
const psd = credentials?.providerSpecificData || {};
|
||||
if (!psd.lastRefreshAt) return true;
|
||||
const elapsed = Date.now() - new Date(psd.lastRefreshAt).getTime();
|
||||
return elapsed > 24 * 60 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,6 +634,7 @@ export default QoderExecutor;
|
||||
// should import QoderExecutor and use its public methods.
|
||||
export const __test__ = {
|
||||
normalizeMessages,
|
||||
parseQoderStreamError,
|
||||
wrapQoderSSE,
|
||||
buildQoderRequestBody,
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||
import { handleBypassRequest } from "../utils/bypassHandler.js";
|
||||
import { trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
||||
import { getExecutor } from "../executors/index.js";
|
||||
import { buildRequestDetail, extractRequestConfig } from "./chatCore/requestDetail.js";
|
||||
import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./chatCore/requestDetail.js";
|
||||
import { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js";
|
||||
import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js";
|
||||
import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js";
|
||||
@@ -34,7 +34,7 @@ import { prefetchRemoteImages } from "../translator/concerns/prefetch.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, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking }) {
|
||||
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, onMidStreamError, clientRawRequest, connectionId, userAgent, apiKey, comboName, fallbackHistory, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking }) {
|
||||
const { provider, model } = modelInfo;
|
||||
const requestStartTime = Date.now();
|
||||
|
||||
@@ -185,6 +185,36 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
const msgCount = translatedBody.messages?.length || translatedBody.input?.length || translatedBody.contents?.length || translatedBody.request?.contents?.length || 0;
|
||||
log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`);
|
||||
|
||||
// Validate credentials before sending request - try refresh if needed
|
||||
if (!executor.noAuth && credentials?.accessToken) {
|
||||
const needsRefresh = executor.needsRefresh?.(credentials) ?? shouldRefreshCredentials(provider, credentials);
|
||||
if (needsRefresh) {
|
||||
try {
|
||||
const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log);
|
||||
if (newCredentials?.accessToken || newCredentials?.copilotToken) {
|
||||
log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed before request`);
|
||||
Object.assign(credentials, newCredentials);
|
||||
if (onCredentialsRefreshed) {
|
||||
try { await onCredentialsRefreshed(newCredentials); } catch (e) { log?.warn?.("TOKEN", `onCredentialsRefreshed failed: ${e.message}`); }
|
||||
}
|
||||
} else if (newCredentials?.error === "unrecoverable_refresh_error") {
|
||||
const msg = newCredentials.message || `${provider} token invalid; reconnect the account`;
|
||||
log?.warn?.("TOKEN", `${provider.toUpperCase()} | unrecoverable refresh error: ${msg}`);
|
||||
trackPendingRequest(model, provider, connectionId, false, true);
|
||||
return createErrorResult(HTTP_STATUS.UNAUTHORIZED, msg);
|
||||
} else {
|
||||
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed before request`);
|
||||
trackPendingRequest(model, provider, connectionId, false, true);
|
||||
return createErrorResult(HTTP_STATUS.UNAUTHORIZED, `${provider} token refresh failed. Please reconnect the account.`);
|
||||
}
|
||||
} catch (e) {
|
||||
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh threw before request: ${e.message}`);
|
||||
trackPendingRequest(model, provider, connectionId, false, true);
|
||||
return createErrorResult(HTTP_STATUS.UNAUTHORIZED, `${provider} token refresh failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const streamController = createStreamController({
|
||||
onDisconnect: (reason) => {
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
@@ -228,19 +258,20 @@ 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);
|
||||
appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => { });
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
provider, model, connectionId,
|
||||
provider, model, connectionId, apiKey, comboName, fallbackHistory,
|
||||
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
@@ -248,6 +279,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
response: { error: error.message || String(error), status: error.name === "AbortError" ? 499 : 502, thinking: null },
|
||||
status: "error"
|
||||
})).catch(() => { });
|
||||
saveUsageStats({ provider, model, tokens: { prompt_tokens: 0, completion_tokens: 0 }, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, comboName, fallbackHistory, status: "error", label: "ERROR" });
|
||||
|
||||
if (error.name === "AbortError") {
|
||||
streamController.handleError(error);
|
||||
@@ -286,7 +318,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
const { statusCode, message, resetsAtMs } = await parseUpstreamError(providerResponse, executor);
|
||||
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { });
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
provider, model, connectionId,
|
||||
provider, model, connectionId, apiKey, comboName, fallbackHistory,
|
||||
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
@@ -294,6 +326,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
response: { error: message, status: statusCode, thinking: null },
|
||||
status: "error"
|
||||
})).catch(() => { });
|
||||
saveUsageStats({ provider, model, tokens: { prompt_tokens: 0, completion_tokens: 0 }, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, comboName, fallbackHistory, status: "error", label: "ERROR" });
|
||||
|
||||
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
|
||||
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
|
||||
@@ -301,7 +334,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, comboName, fallbackHistory, clientRawRequest, onRequestSuccess, midStreamError, onMidStreamError };
|
||||
const appendLog = (extra) => appendRequestLog({ model, provider, connectionId, ...extra }).catch(() => { });
|
||||
const trackDone = () => trackPendingRequest(model, provider, connectionId, false);
|
||||
|
||||
@@ -318,8 +351,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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,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, comboName, fallbackHistory, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, trackDone, appendLog, midStreamError, onMidStreamError }) {
|
||||
trackDone();
|
||||
const contentType = providerResponse.headers.get("content-type") || "";
|
||||
let responseBody;
|
||||
@@ -155,6 +155,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 {
|
||||
@@ -174,7 +189,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
|
||||
|
||||
const usage = extractUsageFromResponse(responseBody);
|
||||
appendLog({ tokens: usage, status: "200 OK" });
|
||||
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });
|
||||
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, comboName });
|
||||
|
||||
const translatedResponse = needsTranslation(targetFormat, sourceFormat)
|
||||
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
|
||||
@@ -219,7 +234,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
|
||||
|
||||
const totalLatency = Date.now() - requestStartTime;
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
provider, model, connectionId,
|
||||
provider, model, connectionId, apiKey, comboName, fallbackHistory,
|
||||
latency: { ttft: totalLatency, total: totalLatency },
|
||||
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
|
||||
@@ -60,6 +60,9 @@ export function buildRequestDetail(base, overrides = {}) {
|
||||
provider: base.provider || "unknown",
|
||||
model: base.model || "unknown",
|
||||
connectionId: base.connectionId || undefined,
|
||||
apiKey: base.apiKey || undefined,
|
||||
comboName: base.comboName || null,
|
||||
fallbackHistory: base.fallbackHistory || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
latency: base.latency || { ttft: 0, total: 0 },
|
||||
tokens: base.tokens || { prompt_tokens: 0, completion_tokens: 0 },
|
||||
@@ -72,22 +75,37 @@ export function buildRequestDetail(base, overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, endpoint, label = "USAGE" }) {
|
||||
export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, endpoint, comboName, fallbackHistory, status = "ok", label = "USAGE" }) {
|
||||
if (!tokens || typeof tokens !== "object") return;
|
||||
|
||||
const inTokens = tokens.input_tokens ?? tokens.prompt_tokens ?? 0;
|
||||
const outTokens = tokens.output_tokens ?? tokens.completion_tokens ?? 0;
|
||||
|
||||
if (inTokens === 0 && outTokens === 0) return;
|
||||
if (inTokens === 0 && outTokens === 0 && status !== "error") return;
|
||||
|
||||
// Extract cache/reasoning tokens (unified from different formats)
|
||||
const cacheRead = tokens.cache_read_input_tokens || tokens.cached_tokens || tokens.prompt_tokens_details?.cached_tokens || 0;
|
||||
const cacheCreation = tokens.cache_creation_input_tokens || 0;
|
||||
const reasoning = tokens.reasoning_tokens || 0;
|
||||
|
||||
const time = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
const accountSuffix = connectionId ? ` | account=${connectionId.slice(0, 8)}...` : "";
|
||||
console.log(`${COLORS.green}[${time}] 📊 [${label}] ${provider.toUpperCase()} | in=${inTokens} | out=${outTokens}${accountSuffix}${COLORS.reset}`);
|
||||
|
||||
let msg = `${COLORS.green}[${time}] 📊 [${label}] ${provider?.toUpperCase() || "UNKNOWN"} | in=${inTokens} | out=${outTokens}${accountSuffix}`;
|
||||
if (tokens.estimated) msg += ` ${COLORS.yellow}(estimated)${COLORS.reset}`;
|
||||
if (cacheRead) msg += ` | cache_read=${cacheRead}`;
|
||||
if (cacheCreation) msg += ` | cache_create=${cacheCreation}`;
|
||||
if (reasoning) msg += ` | reasoning=${reasoning}`;
|
||||
msg += `${COLORS.reset}`;
|
||||
console.log(msg);
|
||||
|
||||
// Normalize to OpenAI token shape for storage
|
||||
// Normalize to OpenAI token shape for storage (include all token types)
|
||||
const normalized = {
|
||||
prompt_tokens: tokens.prompt_tokens ?? tokens.input_tokens ?? 0,
|
||||
completion_tokens: tokens.completion_tokens ?? tokens.output_tokens ?? 0
|
||||
completion_tokens: tokens.completion_tokens ?? tokens.output_tokens ?? 0,
|
||||
cache_read_input_tokens: cacheRead,
|
||||
cache_creation_input_tokens: cacheCreation,
|
||||
reasoning_tokens: reasoning,
|
||||
};
|
||||
|
||||
saveRequestUsage({
|
||||
@@ -97,6 +115,9 @@ export function saveUsageStats({ provider, model, tokens, connectionId, apiKey,
|
||||
timestamp: new Date().toISOString(),
|
||||
connectionId: connectionId || undefined,
|
||||
apiKey: apiKey || undefined,
|
||||
endpoint: endpoint || null
|
||||
endpoint: endpoint || null,
|
||||
comboName: comboName || undefined,
|
||||
fallbackHistory: fallbackHistory || undefined,
|
||||
status: status || "ok",
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
|
||||
* Handle case: provider forced streaming but client wants JSON.
|
||||
* Supports both Codex/Responses API SSE and standard Chat Completions SSE.
|
||||
*/
|
||||
export async function handleForcedSSEToJson({ providerResponse, sourceFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, trackDone, appendLog }) {
|
||||
export async function handleForcedSSEToJson({ providerResponse, sourceFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, comboName, fallbackHistory, clientRawRequest, onRequestSuccess, trackDone, appendLog }) {
|
||||
const contentType = providerResponse.headers.get("content-type") || "";
|
||||
const isSSE = contentType.includes("text/event-stream") || (contentType === "" && isResponsesProvider(provider));
|
||||
if (!isSSE) return null; // not handled here
|
||||
@@ -110,7 +110,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr
|
||||
trackDone();
|
||||
|
||||
const ctx = {
|
||||
provider, model, connectionId,
|
||||
provider, model, connectionId, apiKey, comboName, fallbackHistory,
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: finalBody || translatedBody || null
|
||||
};
|
||||
@@ -124,7 +124,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr
|
||||
|
||||
const usage = jsonResponse.usage || {};
|
||||
appendLog({ tokens: usage, status: "200 OK" });
|
||||
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });
|
||||
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, comboName });
|
||||
|
||||
const { msgItem, textContent } = pickAssistantMessageForChatCompletion(jsonResponse.output);
|
||||
const totalLatency = Date.now() - requestStartTime;
|
||||
@@ -200,7 +200,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr
|
||||
|
||||
const usage = parsed.usage || {};
|
||||
appendLog({ tokens: usage, status: "200 OK" });
|
||||
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });
|
||||
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, comboName });
|
||||
|
||||
const totalLatency = Date.now() - requestStartTime;
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { pipeWithDisconnect } from "../../utils/streamHandler.js";
|
||||
import { PROVIDERS } from "../../config/providers.js";
|
||||
import { STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js";
|
||||
import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.js";
|
||||
import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js";
|
||||
import { buildRequestDetail, extractRequestConfig } from "./requestDetail.js";
|
||||
import { saveRequestDetail } from "@/lib/usageDb.js";
|
||||
import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js";
|
||||
|
||||
@@ -22,7 +22,7 @@ const CODEX_SOURCE_TO_TARGET = {
|
||||
/**
|
||||
* Determine which SSE transform stream to use based on provider/format.
|
||||
*/
|
||||
function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }) {
|
||||
function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, endpoint, comboName }) {
|
||||
const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli");
|
||||
// Responses-API providers (e.g. codex) emit Responses SSE → translate into client format
|
||||
const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES;
|
||||
@@ -30,23 +30,23 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent,
|
||||
|
||||
if (needsCodexTranslation) {
|
||||
const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI;
|
||||
return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
|
||||
return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, endpoint, comboName);
|
||||
}
|
||||
|
||||
if (needsTranslation(targetFormat, sourceFormat)) {
|
||||
return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
|
||||
return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, endpoint, comboName);
|
||||
}
|
||||
|
||||
return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey);
|
||||
return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey, endpoint, comboName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle streaming response — pipe provider SSE through transform stream to client.
|
||||
*/
|
||||
export function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete }) {
|
||||
export function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, comboName, fallbackHistory, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete }) {
|
||||
if (onRequestSuccess) onRequestSuccess();
|
||||
|
||||
const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey });
|
||||
const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, endpoint: clientRawRequest?.endpoint, comboName });
|
||||
|
||||
// Responses passthrough: synthesize response.failed + [DONE] if the stream aborts/stalls before a terminal event
|
||||
const isResponsesPassthrough = sourceFormat === FORMATS.OPENAI_RESPONSES && targetFormat === FORMATS.OPENAI_RESPONSES;
|
||||
@@ -56,7 +56,7 @@ export function handleStreamingResponse({ providerResponse, provider, model, sou
|
||||
|
||||
const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
provider, model, connectionId,
|
||||
provider, model, connectionId, apiKey, comboName, fallbackHistory,
|
||||
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
@@ -76,8 +76,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, comboName, fallbackHistory, requestStartTime, body, stream, finalBody, translatedBody, clientRawRequest, midStreamError, onMidStreamError }) {
|
||||
const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
|
||||
const onStreamComplete = (contentObj, usage, ttftAt) => {
|
||||
@@ -88,20 +90,27 @@ 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,
|
||||
provider, model, connectionId, apiKey, comboName, fallbackHistory,
|
||||
latency,
|
||||
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
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);
|
||||
});
|
||||
|
||||
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, label: "STREAM USAGE" });
|
||||
};
|
||||
|
||||
return { onStreamComplete, streamDetailId };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -226,7 +226,7 @@ export function getComboModelsFromData(modelStr, combosData) {
|
||||
* @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1, autoSwitch = true }) {
|
||||
export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1, autoSwitch = true, retryCount = 1, maxFallbackDepth = null }) {
|
||||
// Apply rotation strategy if enabled
|
||||
let rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit);
|
||||
|
||||
@@ -241,71 +241,102 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co
|
||||
rotatedModels = reordered;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const effectiveRetryCount = (Number.isInteger(retryCount) && retryCount >= 1) ? retryCount : 1;
|
||||
const effectiveDepth = (Number.isInteger(maxFallbackDepth) && maxFallbackDepth >= 1) ? maxFallbackDepth : null;
|
||||
const effectiveModels = effectiveDepth != null ? rotatedModels.slice(0, effectiveDepth) : rotatedModels;
|
||||
|
||||
let lastError = null;
|
||||
let earliestRetryAfter = null;
|
||||
let lastStatus = null;
|
||||
const comboFallbackHistory = [];
|
||||
|
||||
for (let i = 0; i < rotatedModels.length; i++) {
|
||||
const modelStr = rotatedModels[i];
|
||||
log.info("COMBO", `Trying model ${i + 1}/${rotatedModels.length}: ${modelStr}`);
|
||||
for (let i = 0; i < effectiveModels.length; i++) {
|
||||
const modelStr = effectiveModels[i];
|
||||
log.info("COMBO", `Trying model ${i + 1}/${effectiveModels.length}: ${modelStr}`);
|
||||
|
||||
try {
|
||||
const result = await handleSingleModel(body, modelStr);
|
||||
|
||||
// Success (2xx) - return response
|
||||
if (result.ok) {
|
||||
log.info("COMBO", `Model ${modelStr} succeeded`);
|
||||
return result;
|
||||
for (let attempt = 0; attempt < effectiveRetryCount; attempt++) {
|
||||
if (attempt > 0) {
|
||||
log.info("COMBO", `Retrying model ${modelStr} (attempt ${attempt + 1}/${effectiveRetryCount})`);
|
||||
}
|
||||
|
||||
// Extract error info from response
|
||||
let errorText = result.statusText || "";
|
||||
let retryAfter = null;
|
||||
try {
|
||||
const errorBody = await result.clone().json();
|
||||
errorText = errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText;
|
||||
retryAfter = errorBody?.retryAfter || null;
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
const result = await handleSingleModel(body, modelStr, comboFallbackHistory.length ? [...comboFallbackHistory] : null);
|
||||
|
||||
// Success (2xx) - return response
|
||||
if (result.ok) {
|
||||
log.info("COMBO", `Model ${modelStr} succeeded${attempt > 0 ? ` on retry ${attempt + 1}` : ""}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Extract error info from response
|
||||
let errorText = result.statusText || "";
|
||||
let retryAfter = null;
|
||||
try {
|
||||
const errorBody = await result.clone().json();
|
||||
errorText = errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText;
|
||||
retryAfter = errorBody?.retryAfter || null;
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
|
||||
// Track earliest retryAfter across all combo models
|
||||
if (retryAfter && (!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter))) {
|
||||
earliestRetryAfter = retryAfter;
|
||||
}
|
||||
|
||||
// Normalize error text to string (Worker-safe)
|
||||
if (typeof errorText !== "string") {
|
||||
try { errorText = JSON.stringify(errorText); } catch { errorText = String(errorText); }
|
||||
}
|
||||
|
||||
// Check if should fallback to next model
|
||||
const { shouldFallback, cooldownMs } = checkFallbackError(result.status, errorText);
|
||||
|
||||
if (!shouldFallback) {
|
||||
// Hard failure — no retry, no fallback
|
||||
log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status });
|
||||
return result;
|
||||
}
|
||||
|
||||
// For transient errors (503/502/504), wait for cooldown before retrying/falling through
|
||||
if (cooldownMs && cooldownMs > 0 && cooldownMs <= 5000 &&
|
||||
(result.status === 503 || result.status === 502 || result.status === 504)) {
|
||||
log.info("COMBO", `Model ${modelStr} transient ${result.status}, waiting ${cooldownMs}ms`);
|
||||
await new Promise(r => setTimeout(r, cooldownMs));
|
||||
}
|
||||
|
||||
lastError = errorText || String(result.status);
|
||||
if (!lastStatus) lastStatus = result.status;
|
||||
|
||||
if (attempt < effectiveRetryCount - 1) continue;
|
||||
|
||||
// Exhausted retries for this provider — push history and move to next
|
||||
comboFallbackHistory.push({
|
||||
model: modelStr,
|
||||
status: result.status,
|
||||
error: lastError,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
log.warn("COMBO", `Model ${modelStr} exhausted ${effectiveRetryCount} attempt(s), trying next`, { status: result.status });
|
||||
} catch (error) {
|
||||
// Catch unexpected exceptions to ensure fallback continues
|
||||
lastError = error.message || String(error);
|
||||
if (!lastStatus) lastStatus = 500;
|
||||
|
||||
if (attempt < effectiveRetryCount - 1) {
|
||||
log.warn("COMBO", `Model ${modelStr} threw (attempt ${attempt + 1}/${effectiveRetryCount}), retrying`, { error: lastError });
|
||||
continue;
|
||||
}
|
||||
|
||||
comboFallbackHistory.push({
|
||||
model: modelStr,
|
||||
status: 500,
|
||||
error: lastError,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
log.warn("COMBO", `Model ${modelStr} threw error, trying next`, { error: lastError });
|
||||
}
|
||||
|
||||
// Track earliest retryAfter across all combo models
|
||||
if (retryAfter && (!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter))) {
|
||||
earliestRetryAfter = retryAfter;
|
||||
}
|
||||
|
||||
// Normalize error text to string (Worker-safe)
|
||||
if (typeof errorText !== "string") {
|
||||
try { errorText = JSON.stringify(errorText); } catch { errorText = String(errorText); }
|
||||
}
|
||||
|
||||
// Check if should fallback to next model
|
||||
const { shouldFallback, cooldownMs } = checkFallbackError(result.status, errorText);
|
||||
|
||||
if (!shouldFallback) {
|
||||
log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status });
|
||||
return result;
|
||||
}
|
||||
|
||||
// For transient errors (503/502/504), wait for cooldown before falling through
|
||||
// so a briefly-overloaded provider gets a chance to recover rather than being
|
||||
// skipped immediately (fixes: combo falls through on transient 503)
|
||||
if (cooldownMs && cooldownMs > 0 && cooldownMs <= 5000 &&
|
||||
(result.status === 503 || result.status === 502 || result.status === 504)) {
|
||||
log.info("COMBO", `Model ${modelStr} transient ${result.status}, waiting ${cooldownMs}ms before next`);
|
||||
await new Promise(r => setTimeout(r, cooldownMs));
|
||||
}
|
||||
|
||||
// Fallback to next model
|
||||
lastError = errorText || String(result.status);
|
||||
if (!lastStatus) lastStatus = result.status;
|
||||
log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status });
|
||||
} catch (error) {
|
||||
// Catch unexpected exceptions to ensure fallback continues
|
||||
lastError = error.message || String(error);
|
||||
if (!lastStatus) lastStatus = 500;
|
||||
log.warn("COMBO", `Model ${modelStr} threw error, trying next`, { error: lastError });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { translateResponse, initState } from "../translator/index.js";
|
||||
import { FORMATS } from "../translator/formats.js";
|
||||
import { trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js";
|
||||
import { extractUsage, hasValidUsage, estimateUsage, logUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js";
|
||||
import { extractUsage, hasValidUsage, estimateUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js";
|
||||
import { saveUsageStats } from "../handlers/chatCore/requestDetail.js";
|
||||
import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.js";
|
||||
import { getOpenAIResponsesEventName, isOpenAIResponsesTerminalEvent, formatIncompleteOpenAIResponsesStreamFailure } from "./responsesStreamHelpers.js";
|
||||
import { dbg, isDebugEnabled } from "./debugLog.js";
|
||||
@@ -48,7 +49,9 @@ export function createSSEStream(options = {}) {
|
||||
connectionId = null,
|
||||
body = null,
|
||||
onStreamComplete = null,
|
||||
apiKey = null
|
||||
apiKey = null,
|
||||
endpoint = null,
|
||||
comboName = null
|
||||
} = options;
|
||||
|
||||
let buffer = "";
|
||||
@@ -334,11 +337,11 @@ export function createSSEStream(options = {}) {
|
||||
}
|
||||
|
||||
if (hasValidUsage(usage)) {
|
||||
logUsage(provider, usage, model, connectionId, apiKey);
|
||||
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint, comboName });
|
||||
} else {
|
||||
appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { });
|
||||
}
|
||||
|
||||
|
||||
// IMPORTANT: In passthrough mode we still must terminate the SSE stream.
|
||||
// Some clients (e.g. OpenClaw) expect the OpenAI-style sentinel:
|
||||
// data: [DONE]\n\n
|
||||
@@ -417,7 +420,7 @@ export function createSSEStream(options = {}) {
|
||||
}
|
||||
|
||||
if (hasValidUsage(state?.usage)) {
|
||||
logUsage(state.provider || targetFormat, state.usage, model, connectionId, apiKey);
|
||||
saveUsageStats({ provider: state.provider || targetFormat, model, tokens: state.usage, connectionId, apiKey, endpoint, comboName });
|
||||
} else {
|
||||
appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { });
|
||||
}
|
||||
@@ -435,7 +438,7 @@ export function createSSEStream(options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null) {
|
||||
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, endpoint = null, comboName = null) {
|
||||
return createSSEStream({
|
||||
mode: STREAM_MODE.TRANSLATE,
|
||||
targetFormat,
|
||||
@@ -447,11 +450,13 @@ export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, p
|
||||
connectionId,
|
||||
body,
|
||||
onStreamComplete,
|
||||
apiKey
|
||||
apiKey,
|
||||
endpoint,
|
||||
comboName
|
||||
});
|
||||
}
|
||||
|
||||
export function createPassthroughStreamWithLogger(provider = null, reqLogger = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null) {
|
||||
export function createPassthroughStreamWithLogger(provider = null, reqLogger = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, endpoint = null, comboName = null) {
|
||||
return createSSEStream({
|
||||
mode: STREAM_MODE.PASSTHROUGH,
|
||||
provider,
|
||||
@@ -460,6 +465,8 @@ export function createPassthroughStreamWithLogger(provider = null, reqLogger = n
|
||||
connectionId,
|
||||
body,
|
||||
onStreamComplete,
|
||||
apiKey
|
||||
apiKey,
|
||||
endpoint,
|
||||
comboName
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* Token Usage Tracking - Extract, normalize, estimate and log token usage
|
||||
*/
|
||||
|
||||
import { saveRequestUsage, appendRequestLog } from "@/lib/usageDb.js";
|
||||
import { FORMATS } from "../translator/formats.js";
|
||||
|
||||
// ANSI color codes
|
||||
@@ -299,49 +298,3 @@ export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI
|
||||
targetFormat
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log usage with cache info (green color)
|
||||
*/
|
||||
export function logUsage(provider, usage, model = null, connectionId = null, apiKey = null) {
|
||||
if (!usage || typeof usage !== "object") return;
|
||||
|
||||
const p = provider?.toUpperCase() || "UNKNOWN";
|
||||
|
||||
// Support both formats:
|
||||
// - OpenAI: prompt_tokens, completion_tokens
|
||||
// - Claude: input_tokens, output_tokens
|
||||
const inTokens = usage?.prompt_tokens || usage?.input_tokens || 0;
|
||||
const outTokens = usage?.completion_tokens || usage?.output_tokens || 0;
|
||||
const accountPrefix = connectionId ? connectionId.slice(0, 8) + "..." : "unknown";
|
||||
|
||||
let msg = `[${getTimeString()}] 📊 ${COLORS.green}[USAGE] ${p} | in=${inTokens} | out=${outTokens} | account=${accountPrefix}${COLORS.reset}`;
|
||||
|
||||
// Add estimated flag if present
|
||||
if (usage.estimated) {
|
||||
msg += ` ${COLORS.yellow}(estimated)${COLORS.reset}`;
|
||||
}
|
||||
|
||||
// Add cache info if present (unified from different formats)
|
||||
const cacheRead = usage.cache_read_input_tokens || usage.cached_tokens || usage.prompt_tokens_details?.cached_tokens;
|
||||
if (cacheRead) msg += ` | cache_read=${cacheRead}`;
|
||||
|
||||
const cacheCreation = usage.cache_creation_input_tokens;
|
||||
if (cacheCreation) msg += ` | cache_create=${cacheCreation}`;
|
||||
|
||||
const reasoning = usage.reasoning_tokens;
|
||||
if (reasoning) msg += ` | reasoning=${reasoning}`;
|
||||
|
||||
console.log(msg);
|
||||
|
||||
// Save to usage DB
|
||||
const tokens = {
|
||||
prompt_tokens: inTokens,
|
||||
completion_tokens: outTokens,
|
||||
cache_read_input_tokens: cacheRead || 0,
|
||||
cache_creation_input_tokens: cacheCreation || 0,
|
||||
reasoning_tokens: reasoning || 0
|
||||
};
|
||||
saveRequestUsage({ model, provider, connectionId, tokens, apiKey: apiKey || undefined }).catch(() => { });
|
||||
appendRequestLog({ model, provider, connectionId, tokens, status: "200 OK" }).catch(() => { });
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@next/third-parties": "^16.2.9",
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"confbox": "^0.2.4",
|
||||
@@ -35,6 +36,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",
|
||||
@@ -42,6 +45,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"
|
||||
@@ -51,10 +55,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,8 +121,12 @@ export default function CombosPage() {
|
||||
try {
|
||||
const updated = { ...comboStrategies };
|
||||
const next = { ...(updated[comboName] || {}), ...patch };
|
||||
// Prune to keep settings clean: default fallback with no extras = no entry.
|
||||
if (!next.fallbackStrategy || next.fallbackStrategy === "fallback") {
|
||||
// Prune to keep settings clean: only store entry when non-default values exist.
|
||||
const isDefault =
|
||||
(!next.fallbackStrategy || next.fallbackStrategy === "fallback") &&
|
||||
(!next.retryCount || next.retryCount === 1) &&
|
||||
(next.maxFallbackDepth == null);
|
||||
if (isDefault) {
|
||||
delete updated[comboName];
|
||||
} else {
|
||||
updated[comboName] = next;
|
||||
@@ -242,9 +246,12 @@ const STRATEGY_OPTIONS = [
|
||||
|
||||
function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy }) {
|
||||
const [showJudgeSelect, setShowJudgeSelect] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const current = strategy.fallbackStrategy || "fallback";
|
||||
const judge = strategy.judgeModel || "";
|
||||
const isFusion = current === "fusion";
|
||||
const retryCount = strategy.retryCount ?? 1;
|
||||
const maxFallbackDepth = strategy.maxFallbackDepth ?? null;
|
||||
|
||||
return (
|
||||
<Card padding="sm" className="group">
|
||||
@@ -299,13 +306,70 @@ function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy
|
||||
{/* Actions */}
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center sm:gap-3 sm:shrink-0">
|
||||
{/* Strategy selector — always visible */}
|
||||
<div className="w-full sm:w-[200px]">
|
||||
<Select
|
||||
options={STRATEGY_OPTIONS}
|
||||
value={current}
|
||||
onChange={(e) => onSetStrategy({ fallbackStrategy: e.target.value })}
|
||||
selectClassName="py-1.5 text-xs"
|
||||
/>
|
||||
<div className="flex w-full flex-col gap-1.5 sm:w-[200px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<Select
|
||||
options={STRATEGY_OPTIONS}
|
||||
value={current}
|
||||
onChange={(e) => onSetStrategy({ fallbackStrategy: e.target.value })}
|
||||
selectClassName="py-1.5 text-xs"
|
||||
/>
|
||||
{!isFusion && (
|
||||
<button
|
||||
onClick={() => setShowAdvanced(v => !v)}
|
||||
className={`shrink-0 rounded p-1 transition-colors ${showAdvanced ? "text-primary bg-primary/10" : "text-text-muted hover:text-primary hover:bg-primary/10"}`}
|
||||
title="Retry & depth settings"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">tune</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isFusion && showAdvanced && (
|
||||
<div className="flex flex-col gap-1.5 rounded border border-border bg-black/3 px-2 py-2 dark:bg-white/3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-text-muted whitespace-nowrap">Retries per provider</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={retryCount}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!isNaN(v) && v >= 1) onSetStrategy({ retryCount: v });
|
||||
}}
|
||||
className="w-14 rounded border border-border bg-surface px-1.5 py-0.5 text-xs text-center font-mono focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-text-muted whitespace-nowrap">Max providers</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{maxFallbackDepth == null ? (
|
||||
<span className="text-[11px] text-text-muted font-mono">∞</span>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={maxFallbackDepth}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!isNaN(v) && v >= 1) onSetStrategy({ maxFallbackDepth: v });
|
||||
}}
|
||||
className="w-14 rounded border border-border bg-surface px-1.5 py-0.5 text-xs text-center font-mono focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
)}
|
||||
<label className="flex items-center gap-1 cursor-pointer" title="Unlimited providers">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={maxFallbackDepth == null}
|
||||
onChange={(e) => onSetStrategy({ maxFallbackDepth: e.target.checked ? null : combo.models.length || 3 })}
|
||||
className="w-3 h-3 accent-primary"
|
||||
/>
|
||||
<span className="text-[10px] text-text-muted">All</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-1 sm:flex">
|
||||
@@ -353,7 +417,7 @@ function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy
|
||||
);
|
||||
}
|
||||
|
||||
function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMoveDown, onRemove }) {
|
||||
function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMoveDown, onRemove, providerStatus }) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useSortable({ id });
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -375,11 +439,22 @@ function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMove
|
||||
if (e.key === "Escape") { setDraft(model); setEditing(false); }
|
||||
};
|
||||
|
||||
const dotColor = providerStatus === true
|
||||
? "bg-green-500"
|
||||
: providerStatus === false
|
||||
? "bg-red-400"
|
||||
: "bg-gray-400/60";
|
||||
const dotTitle = providerStatus === true
|
||||
? "Provider active"
|
||||
: providerStatus === false
|
||||
? "Provider inactive — will be skipped"
|
||||
: "Provider status unknown";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`group flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 bg-black/[0.02] hover:bg-black/[0.04] dark:bg-white/[0.02] dark:hover:bg-white/[0.04] transition-colors ${isDragging ? "shadow-md ring-1 ring-primary/30" : ""}`}
|
||||
className={`group flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 bg-black/[0.02] hover:bg-black/[0.04] dark:bg-white/[0.02] dark:hover:bg-white/[0.04] transition-colors ${isDragging ? "shadow-md ring-1 ring-primary/30" : ""} ${providerStatus === false ? "opacity-50" : ""}`}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<button
|
||||
@@ -399,6 +474,12 @@ function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMove
|
||||
{/* Index badge */}
|
||||
<span className="text-[10px] font-medium text-text-muted w-3 text-center shrink-0">{index + 1}</span>
|
||||
|
||||
{/* Provider status dot */}
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full shrink-0 ${dotColor}`}
|
||||
title={dotTitle}
|
||||
/>
|
||||
|
||||
{/* Inline editable model value */}
|
||||
{editing ? (
|
||||
<input
|
||||
@@ -451,6 +532,27 @@ function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMove
|
||||
);
|
||||
}
|
||||
|
||||
// Extract provider prefix from "provider/model" string
|
||||
function getModelProvider(model) {
|
||||
const slash = typeof model === "string" ? model.indexOf("/") : -1;
|
||||
return slash > 0 ? model.slice(0, slash) : null;
|
||||
}
|
||||
|
||||
// Build provider → active status map from connections array
|
||||
function buildProviderActiveMap(connections) {
|
||||
const map = {};
|
||||
for (const conn of connections || []) {
|
||||
const active = conn.isActive === true || conn.isActive === 1;
|
||||
// If any connection for this provider is active, mark it active
|
||||
if (active) {
|
||||
map[conn.provider] = true;
|
||||
} else if (!(conn.provider in map)) {
|
||||
map[conn.provider] = false;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindFilter = null }) {
|
||||
// Initialize state with combo values - key prop on parent handles reset on remount
|
||||
const [name, setName] = useState(combo?.name || "");
|
||||
@@ -460,6 +562,14 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF
|
||||
const [nameError, setNameError] = useState("");
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
|
||||
const providerActiveMap = buildProviderActiveMap(activeProviders);
|
||||
const getProviderStatus = (model) => {
|
||||
const provider = getModelProvider(model);
|
||||
if (!provider) return null;
|
||||
if (provider in providerActiveMap) return providerActiveMap[provider];
|
||||
return null;
|
||||
};
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
@@ -557,81 +667,139 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEdit ? "Edit Combo" : "Create Combo"}
|
||||
size="full"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<Input
|
||||
label="Combo Name"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
placeholder="my-combo"
|
||||
error={nameError}
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted mt-0.5">
|
||||
Only letters, numbers, -, _ and . allowed
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-h-0 gap-5">
|
||||
{/* Left: form */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<Input
|
||||
label="Combo Name"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
placeholder="my-combo"
|
||||
error={nameError}
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted mt-0.5">
|
||||
Only letters, numbers, -, _ and . allowed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Models */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1.5 block">Models</label>
|
||||
{/* Models */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1.5 block">Models</label>
|
||||
|
||||
{models.length === 0 ? (
|
||||
<div className="text-center py-4 border border-dashed border-black/10 dark:border-white/10 rounded-lg bg-black/[0.01] dark:bg-white/[0.01]">
|
||||
<span className="material-symbols-outlined text-text-muted text-xl mb-1">layers</span>
|
||||
<p className="text-xs text-text-muted">No models added yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd} modifiers={[restrictToVerticalAxis, restrictToParentElement]}>
|
||||
<SortableContext items={modelItems.map((m) => m.uid)} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex max-h-[55vh] min-w-0 flex-col gap-1 overflow-y-auto sm:max-h-[350px]">
|
||||
{modelItems.map(({ uid, model }, index) => (
|
||||
<ModelItem
|
||||
key={uid}
|
||||
id={uid}
|
||||
index={index}
|
||||
model={model}
|
||||
isFirst={index === 0}
|
||||
isLast={index === modelItems.length - 1}
|
||||
onEdit={(newVal) => {
|
||||
const updated = [...models];
|
||||
updated[index] = newVal;
|
||||
setModels(updated);
|
||||
}}
|
||||
onMoveUp={() => handleMoveUp(index)}
|
||||
onMoveDown={() => handleMoveDown(index)}
|
||||
onRemove={() => handleRemoveModel(index)}
|
||||
/>
|
||||
))}
|
||||
{models.length === 0 ? (
|
||||
<div className="text-center py-4 border border-dashed border-black/10 dark:border-white/10 rounded-lg bg-black/[0.01] dark:bg-white/[0.01]">
|
||||
<span className="material-symbols-outlined text-text-muted text-xl mb-1">layers</span>
|
||||
<p className="text-xs text-text-muted">No models added yet</p>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd} modifiers={[restrictToVerticalAxis, restrictToParentElement]}>
|
||||
<SortableContext items={modelItems.map((m) => m.uid)} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex max-h-[55vh] min-w-0 flex-col gap-1 overflow-y-auto sm:max-h-[320px]">
|
||||
{modelItems.map(({ uid, model }, index) => (
|
||||
<ModelItem
|
||||
key={uid}
|
||||
id={uid}
|
||||
index={index}
|
||||
model={model}
|
||||
isFirst={index === 0}
|
||||
isLast={index === modelItems.length - 1}
|
||||
providerStatus={getProviderStatus(model)}
|
||||
onEdit={(newVal) => {
|
||||
const updated = [...models];
|
||||
updated[index] = newVal;
|
||||
setModels(updated);
|
||||
}}
|
||||
onMoveUp={() => handleMoveUp(index)}
|
||||
onMoveDown={() => handleMoveDown(index)}
|
||||
onRemove={() => handleRemoveModel(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* Add Model button */}
|
||||
<button
|
||||
onClick={() => setShowModelSelect(true)}
|
||||
className="w-full mt-2 py-2 border border-dashed border-black/10 dark:border-white/10 rounded-lg text-xs text-primary font-medium hover:text-primary hover:border-primary/50 transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">add</span>
|
||||
Add Model
|
||||
</button>
|
||||
{/* Add Model button */}
|
||||
<button
|
||||
onClick={() => setShowModelSelect(true)}
|
||||
className="w-full mt-2 py-2 border border-dashed border-black/10 dark:border-white/10 rounded-lg text-xs text-primary font-medium hover:text-primary hover:border-primary/50 transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">add</span>
|
||||
Add Model
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 pt-1 sm:flex-row">
|
||||
<Button onClick={onClose} variant="ghost" fullWidth size="sm">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
fullWidth
|
||||
size="sm"
|
||||
disabled={!name.trim() || !!nameError || saving}
|
||||
>
|
||||
{saving ? "Saving..." : isEdit ? "Save" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 pt-1 sm:flex-row">
|
||||
<Button onClick={onClose} variant="ghost" fullWidth size="sm">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
fullWidth
|
||||
size="sm"
|
||||
disabled={!name.trim() || !!nameError || saving}
|
||||
>
|
||||
{saving ? "Saving..." : isEdit ? "Save" : "Create"}
|
||||
</Button>
|
||||
{/* Right: real-time preview */}
|
||||
<div className="hidden sm:flex w-56 shrink-0 flex-col gap-2 border-l border-black/8 dark:border-white/8 pl-5">
|
||||
<div className="mb-0.5">
|
||||
<p className="text-xs font-semibold text-text-main">Preview</p>
|
||||
<p className="text-[10px] text-text-muted mt-0.5">Real-time provider & model mapping</p>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const activeModels = models.filter(m => getProviderStatus(m) === true);
|
||||
if (activeModels.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center py-8 text-center">
|
||||
<span className="material-symbols-outlined text-[28px] text-text-muted/30 mb-2">layers</span>
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{models.length === 0 ? "Add models to see preview" : "No active providers"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 overflow-y-auto max-h-[360px]">
|
||||
{activeModels.map((model, rank) => {
|
||||
const providerPart = getModelProvider(model);
|
||||
const modelPart = providerPart ? model.slice(providerPart.length + 1) : model;
|
||||
const activeConn = (activeProviders || []).find(
|
||||
p => p.provider === providerPart && (p.isActive === true || p.isActive === 1)
|
||||
);
|
||||
return (
|
||||
<div key={model + rank} className="rounded-lg border border-black/5 dark:border-white/5 bg-black/[0.02] dark:bg-white/[0.02] p-2">
|
||||
{/* Provider row */}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="text-[9px] font-mono text-text-muted/60 w-3 text-right shrink-0">
|
||||
{rank + 1}
|
||||
</span>
|
||||
<span className="w-1.5 h-1.5 rounded-full shrink-0 bg-green-500" />
|
||||
<span className="text-[10px] font-medium truncate text-text-muted">
|
||||
{activeConn?.name || providerPart || "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
{/* Model name */}
|
||||
<div className="pl-[18px]">
|
||||
<span className="block truncate font-mono text-[11px] text-text-main">
|
||||
{modelPart || model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -26,6 +26,9 @@ export default function APIPageClient({ machineId }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false);
|
||||
const [customKeyName, setCustomKeyName] = useState("");
|
||||
const [customKeyValue, setCustomKeyValue] = useState("");
|
||||
const [createdKey, setCreatedKey] = useState(null);
|
||||
const [confirmState, setConfirmState] = useState(null);
|
||||
|
||||
@@ -775,6 +778,30 @@ export default function APIPageClient({ machineId }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCustomKey = async () => {
|
||||
if (!customKeyName.trim() || !customKeyValue.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: customKeyName, key: customKeyValue }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
await fetchData();
|
||||
setCustomKeyName("");
|
||||
setCustomKeyValue("");
|
||||
setShowCustomKeyModal(false);
|
||||
} else {
|
||||
alert(data.error || "Failed to add custom key");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error adding custom key:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteKey = async (id) => {
|
||||
setConfirmState({
|
||||
title: "Delete API Key",
|
||||
@@ -1114,9 +1141,14 @@ export default function APIPageClient({ machineId }) {
|
||||
<span className="material-symbols-outlined text-primary">vpn_key</span>
|
||||
API Keys
|
||||
</h2>
|
||||
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
<Button icon="vpn_key" variant="secondary" onClick={() => setShowCustomKeyModal(true)}>
|
||||
Add Custom Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pb-4 mb-4 border-b border-border">
|
||||
@@ -1145,9 +1177,14 @@ export default function APIPageClient({ machineId }) {
|
||||
</div>
|
||||
<p className="text-text-main font-medium mb-1">No API keys yet</p>
|
||||
<p className="text-sm text-text-muted mb-4">Create your first API key to get started</p>
|
||||
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
<Button icon="vpn_key" variant="secondary" onClick={() => setShowCustomKeyModal(true)}>
|
||||
Add Custom Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
@@ -1415,6 +1452,48 @@ export default function APIPageClient({ machineId }) {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Add Custom Key Modal */}
|
||||
<Modal
|
||||
isOpen={showCustomKeyModal}
|
||||
title="Add Custom API Key"
|
||||
onClose={() => {
|
||||
setShowCustomKeyModal(false);
|
||||
setCustomKeyName("");
|
||||
setCustomKeyValue("");
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Key Name"
|
||||
value={customKeyName}
|
||||
onChange={(e) => setCustomKeyName(e.target.value)}
|
||||
placeholder="My External Key"
|
||||
/>
|
||||
<Input
|
||||
label="API Key"
|
||||
value={customKeyValue}
|
||||
onChange={(e) => setCustomKeyValue(e.target.value)}
|
||||
placeholder="Paste your API key here"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreateCustomKey} fullWidth disabled={!customKeyName.trim() || !customKeyValue.trim()}>
|
||||
Add Key
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowCustomKeyModal(false);
|
||||
setCustomKeyName("");
|
||||
setCustomKeyValue("");
|
||||
}}
|
||||
variant="ghost"
|
||||
fullWidth
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Created Key Modal */}
|
||||
<Modal
|
||||
isOpen={!!createdKey}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,11 +5,15 @@ import { getStatusVariant as getConnectionStatusVariant } from "@/shared/utils/c
|
||||
import PropTypes from "prop-types";
|
||||
import { Badge, Toggle, Tooltip } from "@/shared/components";
|
||||
import CooldownTimer from "./CooldownTimer";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null, autoPing = null }) {
|
||||
export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null, autoPing = null, isSelected = false, onToggleSelect = null }) {
|
||||
const [showProxyDropdown, setShowProxyDropdown] = useState(false);
|
||||
const [updatingProxy, setUpdatingProxy] = useState(false);
|
||||
const proxyDropdownRef = useRef(null);
|
||||
const [revealedKey, setRevealedKey] = useState(null);
|
||||
const [loadingKey, setLoadingKey] = useState(false);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
const proxyPoolMap = new Map((proxyPools || []).map((pool) => [pool.id, pool]));
|
||||
const boundProxyPoolId = connection.providerSpecificData?.proxyPoolId || null;
|
||||
@@ -56,6 +60,23 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [showProxyDropdown]);
|
||||
|
||||
const handleRevealKey = async () => {
|
||||
if (revealedKey) {
|
||||
setRevealedKey(null);
|
||||
return;
|
||||
}
|
||||
setLoadingKey(true);
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connection.id}/apikey`);
|
||||
const data = await res.json();
|
||||
if (res.ok) setRevealedKey(data.apiKey);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectProxy = async (poolId) => {
|
||||
setUpdatingProxy(true);
|
||||
try {
|
||||
@@ -133,8 +154,18 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`group flex min-w-0 flex-col gap-3 rounded-lg p-2 transition-colors hover:bg-black/[0.02] dark:hover:bg-white/[0.02] sm:flex-row sm:items-center sm:justify-between ${connection.isActive === false ? "opacity-60" : ""}`}>
|
||||
<div className={`group flex min-w-0 flex-col gap-3 rounded-lg p-2 transition-colors hover:bg-black/[0.02] dark:hover:bg-white/[0.02] sm:flex-row sm:items-center sm:justify-between ${connection.isActive === false ? "opacity-60" : ""} ${isSelected ? "bg-primary/5 dark:bg-primary/10" : ""}`}>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2 sm:items-center sm:gap-3">
|
||||
{/* Checkbox */}
|
||||
{onToggleSelect && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleSelect(connection.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="shrink-0 h-4 w-4 rounded border-border accent-primary cursor-pointer mt-1 sm:mt-0"
|
||||
/>
|
||||
)}
|
||||
{/* Priority arrows */}
|
||||
<div className="flex shrink-0 flex-col">
|
||||
<button
|
||||
@@ -205,10 +236,17 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{revealedKey && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<code className="max-w-full break-all rounded bg-black/5 px-1.5 py-0.5 font-mono text-[10px] text-text-muted dark:bg-white/5 sm:max-w-[420px]">
|
||||
{revealedKey}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-end">
|
||||
<div className="grid flex-1 grid-cols-3 gap-1 sm:flex sm:flex-none">
|
||||
<div className="grid flex-1 grid-cols-4 gap-1 sm:flex sm:flex-none">
|
||||
{/* Proxy button with inline dropdown */}
|
||||
{(proxyPools || []).length > 0 && (
|
||||
<div className="relative" ref={proxyDropdownRef}>
|
||||
@@ -254,6 +292,45 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isOAuthConnection && !isCookieConnection && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleRevealKey}
|
||||
disabled={loadingKey}
|
||||
className={`flex flex-col items-center rounded px-2 py-1 transition-colors hover:bg-black/5 dark:hover:bg-white/5 ${revealedKey ? "text-primary" : "text-text-muted hover:text-primary"}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
{loadingKey ? "progress_activity" : revealedKey ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
<span className="text-[10px] leading-tight">Show</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (revealedKey) {
|
||||
copy(revealedKey);
|
||||
return;
|
||||
}
|
||||
setLoadingKey(true);
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connection.id}/apikey`);
|
||||
const data = await res.json();
|
||||
if (res.ok) copy(data.apiKey);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingKey(false);
|
||||
}
|
||||
}}
|
||||
disabled={loadingKey}
|
||||
className={`flex flex-col items-center rounded px-2 py-1 transition-colors hover:bg-black/5 dark:hover:bg-white/5 ${copied ? "text-green-500" : "text-text-muted hover:text-primary"}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
{copied ? "check" : "content_copy"}
|
||||
</span>
|
||||
<span className="text-[10px] leading-tight">{copied ? "Copied" : "Copy"}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={onEdit} className="flex flex-col items-center rounded px-2 py-1 text-text-muted hover:bg-black/5 hover:text-primary dark:hover:bg-white/5">
|
||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||
<span className="text-[10px] leading-tight">Edit</span>
|
||||
@@ -311,4 +388,6 @@ ConnectionRow.propTypes = {
|
||||
on: PropTypes.bool,
|
||||
onToggle: PropTypes.func,
|
||||
}),
|
||||
isSelected: PropTypes.bool,
|
||||
onToggleSelect: PropTypes.func,
|
||||
};
|
||||
|
||||
@@ -57,7 +57,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 [autoPing, setAutoPing] = useState({ enabled: false, connections: {} });
|
||||
const [suggestedModels, setSuggestedModels] = useState([]);
|
||||
const [kiloFreeModels, setKiloFreeModels] = useState([]);
|
||||
@@ -270,9 +274,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) : "");
|
||||
const apCfg = settingsData.claudeAutoPing || {};
|
||||
setAutoPing({ enabled: apCfg.enabled === true, connections: apCfg.connections || {} });
|
||||
if (nodesRes.ok) {
|
||||
@@ -361,6 +371,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" });
|
||||
@@ -387,6 +411,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);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAutoPing = async (next) => {
|
||||
setAutoPing(next);
|
||||
try {
|
||||
@@ -404,6 +470,7 @@ export default function ProviderDetailPage() {
|
||||
saveAutoPing({ ...autoPing, connections: { ...autoPing.connections, [connectionId]: on } });
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnections();
|
||||
fetchAliases();
|
||||
@@ -765,6 +832,48 @@ export default function ProviderDetailPage() {
|
||||
setBulkProxyPoolId("__none__");
|
||||
};
|
||||
|
||||
const handleBulkActivate = async () => {
|
||||
const ids = [...selectedConnectionIds];
|
||||
await Promise.all(ids.map(id =>
|
||||
fetch(`/api/providers/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isActive: true }),
|
||||
})
|
||||
));
|
||||
setConnections(prev => prev.map(c => ids.includes(c.id) ? { ...c, isActive: true } : c));
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const handleBulkDeactivate = async () => {
|
||||
const ids = [...selectedConnectionIds];
|
||||
await Promise.all(ids.map(id =>
|
||||
fetch(`/api/providers/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isActive: false }),
|
||||
})
|
||||
));
|
||||
setConnections(prev => prev.map(c => ids.includes(c.id) ? { ...c, isActive: false } : c));
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
const ids = [...selectedConnectionIds];
|
||||
setConfirmState({
|
||||
title: "Delete Connections",
|
||||
message: `Delete ${ids.length} selected connection${ids.length === 1 ? "" : "s"}?`,
|
||||
onConfirm: async () => {
|
||||
setConfirmState(null);
|
||||
await Promise.all(ids.map(id =>
|
||||
fetch(`/api/providers/${id}`, { method: "DELETE" })
|
||||
));
|
||||
setConnections(prev => prev.filter(c => !ids.includes(c.id)));
|
||||
clearSelection();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedConnectionIds((prev) => prev.filter((id) => connections.some((conn) => conn.id === id)));
|
||||
}, [connections]);
|
||||
@@ -819,7 +928,8 @@ export default function ProviderDetailPage() {
|
||||
};
|
||||
|
||||
const handleApplySinglePool = (proxyPoolId) => {
|
||||
const targets = connections.map((c) => ({ connectionId: c.id, proxyPoolId }));
|
||||
const scope = selectedConnectionIds.length > 0 ? selectedConnections : connections;
|
||||
const targets = scope.map((c) => ({ connectionId: c.id, proxyPoolId }));
|
||||
return applyProxyAssignments(targets);
|
||||
};
|
||||
|
||||
@@ -829,7 +939,8 @@ export default function ProviderDetailPage() {
|
||||
alert("No active proxy pools available.");
|
||||
return;
|
||||
}
|
||||
const targets = connections.map((c, i) => ({
|
||||
const scope = selectedConnectionIds.length > 0 ? selectedConnections : connections;
|
||||
const targets = scope.map((c, i) => ({
|
||||
connectionId: c.id,
|
||||
proxyPoolId: activePools[i % activePools.length].id,
|
||||
}));
|
||||
@@ -841,6 +952,20 @@ export default function ProviderDetailPage() {
|
||||
|
||||
const connectionsList = (
|
||||
<div className="flex min-w-0 flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
||||
{/* Select all row */}
|
||||
{connections.length > 1 && (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={toggleSelectAllConnections}
|
||||
className="h-4 w-4 rounded border-border accent-primary cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-text-muted">
|
||||
{allSelected ? "Deselect all" : `Select all (${connections.length})`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{connections
|
||||
.map((conn, index) => (
|
||||
<div key={conn.id} className="flex min-w-0 items-stretch">
|
||||
@@ -882,6 +1007,8 @@ export default function ProviderDetailPage() {
|
||||
}}
|
||||
onDelete={() => handleDelete(conn.id)}
|
||||
oneByOneStatus={oneByOneResults[conn.id] || null}
|
||||
isSelected={isSelected(conn.id)}
|
||||
onToggleSelect={toggleSelectConnection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -895,7 +1022,7 @@ export default function ProviderDetailPage() {
|
||||
<Modal
|
||||
isOpen={showBulkProxyModal}
|
||||
onClose={closeBulkProxyModal}
|
||||
title={`Apply Proxy (${connections.length} connections)`}
|
||||
title={`Apply Proxy (${selectedConnectionIds.length > 0 ? selectedConnectionIds.length : connections.length} connections)`}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col">
|
||||
@@ -1377,6 +1504,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>
|
||||
|
||||
@@ -1446,6 +1625,55 @@ export default function ProviderDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Bulk action bar */}
|
||||
{selectedConnectionIds.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2">
|
||||
<span className="text-sm font-medium text-primary">{selectedConnectionIds.length} selected</span>
|
||||
<div className="flex flex-wrap items-center gap-1.5 ml-auto">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="check_circle"
|
||||
onClick={handleBulkActivate}
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="cancel"
|
||||
onClick={handleBulkDeactivate}
|
||||
>
|
||||
Deactivate
|
||||
</Button>
|
||||
{proxyPools.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="lan"
|
||||
onClick={openBulkProxyModal}
|
||||
>
|
||||
Proxy
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="delete"
|
||||
onClick={handleBulkDelete}
|
||||
className="text-red-500 hover:bg-red-500/10"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="text-xs text-text-muted hover:text-primary transition-colors px-1"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{connectionsList}
|
||||
{!isCompatible && (
|
||||
<div className="mt-4 grid grid-cols-1 gap-2 sm:flex">
|
||||
|
||||
@@ -100,20 +100,32 @@ export default function RequestDetailsTab() {
|
||||
const [selectedDetail, setSelectedDetail] = useState(null);
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [providers, setProviders] = useState([]);
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
const [apiKeys, setApiKeys] = useState([]);
|
||||
const [providerNameCache, setProviderNameCache] = useState(null);
|
||||
const [filters, setFilters] = useState({
|
||||
provider: "",
|
||||
model: "",
|
||||
status: "",
|
||||
connectionId: "",
|
||||
apiKey: "",
|
||||
comboName: "",
|
||||
hasFallback: "",
|
||||
startDate: "",
|
||||
endDate: ""
|
||||
});
|
||||
|
||||
const fetchProviders = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/usage/providers");
|
||||
const data = await res.json();
|
||||
setProviders(data.providers || []);
|
||||
|
||||
const cache = await fetchProviderNames();
|
||||
const [provRes, connRes, keysRes, cache] = await Promise.all([
|
||||
fetch("/api/usage/providers").then(r => r.json()),
|
||||
fetch("/api/providers").then(r => r.json()),
|
||||
fetch("/api/keys").then(r => r.json()),
|
||||
fetchProviderNames(),
|
||||
]);
|
||||
setProviders(provRes.providers || []);
|
||||
setAccounts((connRes.connections || []).filter(c => c.isActive !== false).map(c => ({ id: c.id, name: c.name || c.email || c.id })));
|
||||
setApiKeys(keysRes.keys || []);
|
||||
setProviderNameCache(cache.providerNameCache);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch providers:", error);
|
||||
@@ -128,6 +140,12 @@ export default function RequestDetailsTab() {
|
||||
pageSize: pagination.pageSize.toString()
|
||||
});
|
||||
if (filters.provider) params.append("provider", filters.provider);
|
||||
if (filters.model) params.append("model", filters.model);
|
||||
if (filters.status) params.append("status", filters.status);
|
||||
if (filters.connectionId) params.append("connectionId", filters.connectionId);
|
||||
if (filters.apiKey) params.append("apiKey", filters.apiKey);
|
||||
if (filters.comboName) params.append("comboName", filters.comboName);
|
||||
if (filters.hasFallback) params.append("hasFallback", filters.hasFallback);
|
||||
if (filters.startDate) params.append("startDate", filters.startDate);
|
||||
if (filters.endDate) params.append("endDate", filters.endDate);
|
||||
|
||||
@@ -165,71 +183,138 @@ export default function RequestDetailsTab() {
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setFilters({ provider: "", startDate: "", endDate: "" });
|
||||
setFilters({ provider: "", model: "", status: "", connectionId: "", apiKey: "", comboName: "", hasFallback: "", startDate: "", endDate: "" });
|
||||
};
|
||||
|
||||
const hasActiveFilters = Object.values(filters).some(v => v !== "");
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<Card padding="md">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<label htmlFor="provider-filter" className="text-sm font-medium text-text-main">Provider</label>
|
||||
<select
|
||||
id="provider-filter"
|
||||
value={filters.provider}
|
||||
onChange={(e) => setFilters({ ...filters, provider: e.target.value })}
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
|
||||
"text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20",
|
||||
"w-full min-w-0 cursor-pointer"
|
||||
)}
|
||||
style={{ colorScheme: 'auto' }}
|
||||
>
|
||||
<option value="">All Providers</option>
|
||||
{providers.map((provider) => (
|
||||
<option key={provider.id} value={provider.id}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<label htmlFor="start-date-filter" className="text-sm font-medium text-text-main">Start Date</label>
|
||||
<input
|
||||
id="start-date-filter"
|
||||
type="datetime-local"
|
||||
value={filters.startDate}
|
||||
onChange={(e) => setFilters({ ...filters, startDate: e.target.value })}
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
|
||||
"w-full min-w-0 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Row 1: Provider, Model, Status, Has Fallback */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-text-muted">Provider</label>
|
||||
<select
|
||||
value={filters.provider}
|
||||
onChange={(e) => setFilters(f => ({ ...f, provider: e.target.value }))}
|
||||
className={cn("h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20 w-full cursor-pointer")}
|
||||
style={{ colorScheme: 'auto' }}
|
||||
>
|
||||
<option value="">All Providers</option>
|
||||
{providers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-text-muted">Model</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. claude-3-5-sonnet"
|
||||
value={filters.model}
|
||||
onChange={(e) => setFilters(f => ({ ...f, model: e.target.value }))}
|
||||
className={cn("h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface w-full text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/20")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-text-muted">Status</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters(f => ({ ...f, status: e.target.value }))}
|
||||
className={cn("h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20 w-full cursor-pointer")}
|
||||
style={{ colorScheme: 'auto' }}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="ok">OK</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-text-muted">Has Fallback</label>
|
||||
<select
|
||||
value={filters.hasFallback}
|
||||
onChange={(e) => setFilters(f => ({ ...f, hasFallback: e.target.value }))}
|
||||
className={cn("h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20 w-full cursor-pointer")}
|
||||
style={{ colorScheme: 'auto' }}
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="true">With fallback</option>
|
||||
<option value="false">No fallback</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<label htmlFor="end-date-filter" className="text-sm font-medium text-text-main">End Date</label>
|
||||
<input
|
||||
id="end-date-filter"
|
||||
type="datetime-local"
|
||||
value={filters.endDate}
|
||||
onChange={(e) => setFilters({ ...filters, endDate: e.target.value })}
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
|
||||
"w-full min-w-0 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
)}
|
||||
/>
|
||||
{/* Row 2: Account, API Key, Combo Name, Date range, Clear */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-text-muted">Account</label>
|
||||
<select
|
||||
value={filters.connectionId}
|
||||
onChange={(e) => setFilters(f => ({ ...f, connectionId: e.target.value }))}
|
||||
className={cn("h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20 w-full cursor-pointer")}
|
||||
style={{ colorScheme: 'auto' }}
|
||||
>
|
||||
<option value="">All Accounts</option>
|
||||
{accounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-text-muted">API Key</label>
|
||||
<select
|
||||
value={filters.apiKey}
|
||||
onChange={(e) => setFilters(f => ({ ...f, apiKey: e.target.value }))}
|
||||
className={cn("h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20 w-full cursor-pointer")}
|
||||
style={{ colorScheme: 'auto' }}
|
||||
>
|
||||
<option value="">All API Keys</option>
|
||||
{apiKeys.map(k => <option key={k.id} value={k.key}>{k.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-text-muted">Combo</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Combo name"
|
||||
value={filters.comboName}
|
||||
onChange={(e) => setFilters(f => ({ ...f, comboName: e.target.value }))}
|
||||
className={cn("h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface w-full text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/20")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-text-muted">From</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={filters.startDate}
|
||||
onChange={(e) => setFilters(f => ({ ...f, startDate: e.target.value }))}
|
||||
className={cn("h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface w-full text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-text-muted">To</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={filters.endDate}
|
||||
onChange={(e) => setFilters(f => ({ ...f, endDate: e.target.value }))}
|
||||
className={cn("h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface w-full text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:col-span-2 lg:col-span-1">
|
||||
<span className="hidden text-sm font-medium text-text-main opacity-0 lg:block" aria-hidden="true">Clear</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClearFilters}
|
||||
disabled={!filters.provider && !filters.startDate && !filters.endDate}
|
||||
className="w-full"
|
||||
>
|
||||
|
||||
{/* Clear button + active filter count */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-text-muted">
|
||||
{hasActiveFilters
|
||||
? `${Object.values(filters).filter(v => v !== "").length} filter${Object.values(filters).filter(v => v !== "").length > 1 ? "s" : ""} active`
|
||||
: "No filters"}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={handleClearFilters} disabled={!hasActiveFilters}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
@@ -238,22 +323,25 @@ export default function RequestDetailsTab() {
|
||||
|
||||
<Card padding="none">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[880px]">
|
||||
<table className="w-full min-w-[1100px]">
|
||||
<thead>
|
||||
<tr className="border-b border-black/5 dark:border-white/5">
|
||||
<th className="text-left p-4 text-sm font-semibold text-text-main">Timestamp</th>
|
||||
<th className="text-left p-4 text-sm font-semibold text-text-main">Model</th>
|
||||
<th className="text-left p-4 text-sm font-semibold text-text-main">Provider</th>
|
||||
<th className="text-right p-4 text-sm font-semibold text-text-main">Input Tokens</th>
|
||||
<th className="text-right p-4 text-sm font-semibold text-text-main">Output Tokens</th>
|
||||
<th className="text-left p-4 text-sm font-semibold text-text-main">Latency</th>
|
||||
<th className="text-center p-4 text-sm font-semibold text-text-main">Action</th>
|
||||
<tr className="border-b border-black/5 dark:border-white/5 bg-bg-subtle">
|
||||
<th className="text-left p-4 text-xs font-semibold uppercase tracking-wide text-text-muted w-6"></th>
|
||||
<th className="text-left p-4 text-xs font-semibold uppercase tracking-wide text-text-muted">Timestamp</th>
|
||||
<th className="text-left p-4 text-xs font-semibold uppercase tracking-wide text-text-muted">Model</th>
|
||||
<th className="text-left p-4 text-xs font-semibold uppercase tracking-wide text-text-muted">Provider</th>
|
||||
<th className="text-left p-4 text-xs font-semibold uppercase tracking-wide text-text-muted">Account</th>
|
||||
<th className="text-left p-4 text-xs font-semibold uppercase tracking-wide text-text-muted">API Key</th>
|
||||
<th className="text-right p-4 text-xs font-semibold uppercase tracking-wide text-text-muted">In / Out</th>
|
||||
<th className="text-left p-4 text-xs font-semibold uppercase tracking-wide text-text-muted">Latency</th>
|
||||
<th className="text-left p-4 text-xs font-semibold uppercase tracking-wide text-text-muted">Attempts</th>
|
||||
<th className="text-center p-4 text-xs font-semibold uppercase tracking-wide text-text-muted">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="p-8 text-center text-text-muted">
|
||||
<td colSpan="10" className="p-8 text-center text-text-muted">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="material-symbols-outlined animate-spin text-[20px]">progress_activity</span>
|
||||
Loading...
|
||||
@@ -262,50 +350,83 @@ export default function RequestDetailsTab() {
|
||||
</tr>
|
||||
) : details.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="p-8 text-center text-text-muted">
|
||||
<td colSpan="10" className="p-8 text-center text-text-muted">
|
||||
No request details found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
details.map((detail, index) => (
|
||||
<tr
|
||||
key={`${detail.id}-${index}`}
|
||||
className="border-b border-black/5 dark:border-white/5 last:border-b-0 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<td className="whitespace-nowrap p-4 text-sm text-text-main">
|
||||
{new Date(detail.timestamp).toLocaleString()}
|
||||
</td>
|
||||
<td className="max-w-[260px] truncate p-4 font-mono text-sm text-text-main">
|
||||
{detail.model}
|
||||
</td>
|
||||
<td className="max-w-[180px] truncate p-4 text-sm text-text-main">
|
||||
<span className="font-medium">
|
||||
{getProviderName(detail.provider, providerNameCache)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-sm text-text-main text-right font-mono">
|
||||
{getInputTokens(detail.tokens).toLocaleString()}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-text-main text-right font-mono">
|
||||
{detail.tokens?.completion_tokens?.toLocaleString() || 0}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-text-muted">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
details.map((detail, index) => {
|
||||
const isOk = !detail.status || detail.status === "ok" || detail.status === "success";
|
||||
return (
|
||||
<tr
|
||||
key={`${detail.id}-${index}`}
|
||||
className={cn(
|
||||
"border-b border-black/5 dark:border-white/5 last:border-b-0 transition-colors",
|
||||
isOk
|
||||
? "hover:bg-black/[0.02] dark:hover:bg-white/[0.02]"
|
||||
: "bg-red-50/60 dark:bg-red-950/20 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
)}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<td className="pl-4 pr-0 py-3">
|
||||
<span className={cn(
|
||||
"block w-2 h-2 rounded-full",
|
||||
isOk ? "bg-green-500" : "bg-red-500"
|
||||
)} title={isOk ? "Success" : `Error: ${detail.status}`} />
|
||||
</td>
|
||||
<td className="whitespace-nowrap p-4 text-sm text-text-main">
|
||||
{new Date(detail.timestamp).toLocaleString()}
|
||||
</td>
|
||||
<td className="max-w-[220px] p-4 text-sm">
|
||||
<div className={cn("font-mono truncate", isOk ? "text-text-main" : "text-red-700 dark:text-red-300")} title={detail.model}>{detail.model}</div>
|
||||
{detail.comboName && (
|
||||
<div className="text-xs text-text-muted truncate" title={`via ${detail.comboName}`}>via {detail.comboName}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="max-w-[140px] truncate p-4 text-sm text-text-main">
|
||||
<span className="font-medium">{getProviderName(detail.provider, providerNameCache)}</span>
|
||||
</td>
|
||||
<td className="max-w-[140px] p-4 text-sm text-text-muted truncate" title={detail.accountName || "—"}>
|
||||
{detail.accountName || <span className="opacity-40">—</span>}
|
||||
</td>
|
||||
<td className="max-w-[120px] truncate p-4 text-sm text-text-muted" title={detail.keyName || detail.apiKey || ""}>
|
||||
{detail.keyName || (detail.apiKey ? detail.apiKey.slice(0, 8) + "..." : <span className="opacity-40">—</span>)}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-right whitespace-nowrap font-mono">
|
||||
<span className="text-primary">{getInputTokens(detail.tokens).toLocaleString()}↑</span>
|
||||
{" "}
|
||||
<span className="text-success">{(detail.tokens?.completion_tokens || 0).toLocaleString()}↓</span>
|
||||
</td>
|
||||
<td className="p-4 text-xs text-text-muted whitespace-nowrap">
|
||||
<div>TTFT: <span className="font-mono">{detail.latency?.ttft || 0}ms</span></div>
|
||||
<div>Total: <span className="font-mono">{detail.latency?.total || 0}ms</span></div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleViewDetail(detail)}
|
||||
>
|
||||
Detail
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
</td>
|
||||
<td className="p-4 text-sm">
|
||||
{detail.fallbackHistory?.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleViewDetail(detail)}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors"
|
||||
title={detail.fallbackHistory.map(f => `${f.model || f.connectionName || f.provider} (${f.status})`).join(' → ')}
|
||||
>
|
||||
↺ {detail.fallbackHistory.length} fallback{detail.fallbackHistory.length > 1 ? 's' : ''}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-text-muted text-xs opacity-40">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleViewDetail(detail)}
|
||||
>
|
||||
Detail
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -332,6 +453,22 @@ export default function RequestDetailsTab() {
|
||||
>
|
||||
{selectedDetail && (
|
||||
<div className="space-y-6">
|
||||
{/* Status banner */}
|
||||
{(() => {
|
||||
const isOk = !selectedDetail.status || selectedDetail.status === "ok" || selectedDetail.status === "success";
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium",
|
||||
isOk
|
||||
? "bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-300 border border-green-200 dark:border-green-800"
|
||||
: "bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
|
||||
)}>
|
||||
<span className={cn("block w-2 h-2 rounded-full flex-shrink-0", isOk ? "bg-green-500" : "bg-red-500")} />
|
||||
{isOk ? "Success" : `Failed — ${selectedDetail.status}`}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="grid min-w-0 grid-cols-1 gap-4 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="text-text-muted">ID:</span>{" "}
|
||||
@@ -342,21 +479,29 @@ export default function RequestDetailsTab() {
|
||||
<span className="text-text-main">{new Date(selectedDetail.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Provider:</span>{" "}
|
||||
<span className="text-text-main font-medium">{getProviderName(selectedDetail.provider, providerNameCache)}</span>
|
||||
</div>
|
||||
<span className="text-text-muted">Provider:</span>{" "}
|
||||
<span className="text-text-main font-medium">{getProviderName(selectedDetail.provider, providerNameCache)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Account:</span>{" "}
|
||||
<span className="text-text-main">{selectedDetail.accountName || <span className="text-text-muted">—</span>}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Model:</span>{" "}
|
||||
<span className="text-text-main font-mono">{selectedDetail.model}</span>
|
||||
{selectedDetail.comboName && (
|
||||
<div className="mt-0.5 text-xs text-text-muted">via combo: <span className="font-mono">{selectedDetail.comboName}</span></div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Status:</span>{" "}
|
||||
<span className={cn(
|
||||
"font-medium",
|
||||
selectedDetail.status === "success" ? "text-green-600" : "text-red-600"
|
||||
)}>
|
||||
{selectedDetail.status}
|
||||
</span>
|
||||
<span className="text-text-muted">API Key:</span>{" "}
|
||||
{selectedDetail.keyName ? (
|
||||
<span className="text-text-main">{selectedDetail.keyName}</span>
|
||||
) : selectedDetail.apiKey ? (
|
||||
<span className="text-text-main font-mono">{selectedDetail.apiKey.slice(0, 16)}...</span>
|
||||
) : (
|
||||
<span className="text-text-muted">—</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Latency:</span>{" "}
|
||||
@@ -366,28 +511,57 @@ export default function RequestDetailsTab() {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Input Tokens:</span>{" "}
|
||||
<span className="text-text-main font-mono">
|
||||
<span className="text-primary font-mono">
|
||||
{getInputTokens(selectedDetail.tokens).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Output Tokens:</span>{" "}
|
||||
<span className="text-text-main font-mono">
|
||||
<span className="text-success font-mono">
|
||||
{selectedDetail.tokens?.completion_tokens?.toLocaleString() || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedDetail.fallbackHistory?.length > 0 && (
|
||||
<div className="rounded-lg border border-amber-200 dark:border-amber-800 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-amber-50 dark:bg-amber-950/30 border-b border-amber-200 dark:border-amber-800">
|
||||
<span className="material-symbols-outlined text-[18px] text-amber-600 dark:text-amber-400">history</span>
|
||||
<span className="font-semibold text-sm text-amber-900 dark:text-amber-200">
|
||||
Fallback History — {selectedDetail.fallbackHistory.length} failed attempt{selectedDetail.fallbackHistory.length > 1 ? 's' : ''} before success
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-amber-100 dark:divide-amber-900/50">
|
||||
{selectedDetail.fallbackHistory.map((attempt, idx) => (
|
||||
<div key={idx} className="px-4 py-3 text-sm flex flex-col gap-1 bg-amber-50/50 dark:bg-amber-950/20">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="font-medium text-text-main font-mono text-xs">
|
||||
#{idx + 1} — {attempt.model || attempt.connectionName || attempt.provider || "unknown"}
|
||||
</span>
|
||||
<span className="font-mono text-xs rounded px-1.5 py-0.5 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
HTTP {attempt.status}
|
||||
</span>
|
||||
</div>
|
||||
{attempt.error && (
|
||||
<span className="text-text-muted text-xs truncate">{attempt.error}</span>
|
||||
)}
|
||||
<span className="text-text-muted text-xs">{attempt.timestamp ? new Date(attempt.timestamp).toLocaleString() : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="1. Client Request (Input)" defaultOpen={true} icon="input">
|
||||
<pre className="max-h-[300px] max-w-full overflow-auto rounded-lg border border-black/5 bg-black/5 p-3 font-mono text-xs text-text-main dark:border-white/5 dark:bg-white/5 sm:p-4">
|
||||
<pre className="max-h-[400px] max-w-full overflow-auto rounded-lg border border-black/5 bg-black/5 p-3 font-mono text-xs text-text-main dark:border-white/5 dark:bg-white/5 sm:p-4 whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(selectedDetail.request, null, 2)}
|
||||
</pre>
|
||||
</CollapsibleSection>
|
||||
|
||||
{selectedDetail.providerRequest && (
|
||||
<CollapsibleSection title="2. Provider Request (Translated)" icon="translate">
|
||||
<pre className="max-h-[300px] max-w-full overflow-auto rounded-lg border border-black/5 bg-black/5 p-3 font-mono text-xs text-text-main dark:border-white/5 dark:bg-white/5 sm:p-4">
|
||||
<pre className="max-h-[400px] max-w-full overflow-auto rounded-lg border border-black/5 bg-black/5 p-3 font-mono text-xs text-text-main dark:border-white/5 dark:bg-white/5 sm:p-4 whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(selectedDetail.providerRequest, null, 2)}
|
||||
</pre>
|
||||
</CollapsibleSection>
|
||||
@@ -395,7 +569,7 @@ export default function RequestDetailsTab() {
|
||||
|
||||
{selectedDetail.providerResponse && (
|
||||
<CollapsibleSection title="3. Provider Response (Raw)" icon="data_object">
|
||||
<pre className="max-h-[300px] max-w-full overflow-auto rounded-lg border border-black/5 bg-black/5 p-3 font-mono text-xs text-text-main dark:border-white/5 dark:bg-white/5 sm:p-4">
|
||||
<pre className="max-h-[400px] max-w-full overflow-auto rounded-lg border border-black/5 bg-black/5 p-3 font-mono text-xs text-text-main dark:border-white/5 dark:bg-white/5 sm:p-4 whitespace-pre-wrap break-all">
|
||||
{typeof selectedDetail.providerResponse === 'object'
|
||||
? JSON.stringify(selectedDetail.providerResponse, null, 2)
|
||||
: selectedDetail.providerResponse
|
||||
@@ -403,25 +577,10 @@ export default function RequestDetailsTab() {
|
||||
</pre>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
|
||||
<CollapsibleSection title="4. Client Response (Final)" defaultOpen={true} icon="output">
|
||||
{selectedDetail.response?.thinking && (
|
||||
<div className="mb-4">
|
||||
<h4 className="font-semibold text-text-main mb-2 flex items-center gap-2 text-xs uppercase tracking-wide opacity-70">
|
||||
<span className="material-symbols-outlined text-[16px]">psychology</span>
|
||||
Thinking Process
|
||||
</h4>
|
||||
<pre className="max-h-[200px] max-w-full overflow-auto rounded-lg border border-amber-200 bg-amber-50 p-3 font-mono text-xs text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100 sm:p-4">
|
||||
{selectedDetail.response.thinking}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h4 className="font-semibold text-text-main mb-2 text-xs uppercase tracking-wide opacity-70">
|
||||
Content
|
||||
</h4>
|
||||
<pre className="max-h-[300px] max-w-full overflow-auto rounded-lg border border-black/5 bg-black/5 p-3 font-mono text-xs text-text-main dark:border-white/5 dark:bg-white/5 sm:p-4">
|
||||
{selectedDetail.response?.content || "[No content]"}
|
||||
<pre className="max-h-[400px] max-w-full overflow-auto rounded-lg border border-black/5 bg-black/5 p-3 font-mono text-xs text-text-main dark:border-white/5 dark:bg-white/5 sm:p-4 whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(selectedDetail.response, null, 2) || "[No response]"}
|
||||
</pre>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
@@ -7,10 +7,9 @@ import RequestDetailsTab from "./components/RequestDetailsTab";
|
||||
|
||||
const PERIODS = [
|
||||
{ value: "today", label: "Today" },
|
||||
{ value: "24h", label: "24h" },
|
||||
{ value: "7d", label: "7D" },
|
||||
{ value: "30d", label: "30D" },
|
||||
{ value: "60d", label: "60D" },
|
||||
{ value: "week", label: "This Week" },
|
||||
{ value: "month", label: "This Month" },
|
||||
{ value: "all", label: "All" },
|
||||
];
|
||||
|
||||
export default function UsagePage() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiKeys, createApiKey } from "@/lib/localDb";
|
||||
import { getApiKeys, createApiKey, createCustomApiKey } from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -19,7 +19,7 @@ export async function GET() {
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name } = body;
|
||||
const { name, key } = body;
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
||||
@@ -27,6 +27,22 @@ export async function POST(request) {
|
||||
|
||||
// Always get machineId from server
|
||||
const machineId = await getConsistentMachineId();
|
||||
|
||||
// If a custom key is provided, store it directly (no format validation)
|
||||
if (key) {
|
||||
if (!key.trim()) {
|
||||
return NextResponse.json({ error: "Key cannot be empty" }, { status: 400 });
|
||||
}
|
||||
const apiKey = await createCustomApiKey(name, key.trim(), machineId);
|
||||
return NextResponse.json({
|
||||
key: apiKey.key,
|
||||
name: apiKey.name,
|
||||
id: apiKey.id,
|
||||
machineId: apiKey.machineId,
|
||||
}, { status: 201 });
|
||||
}
|
||||
|
||||
// Otherwise auto-generate a key
|
||||
const apiKey = await createApiKey(name, machineId);
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
27
src/app/api/providers/[id]/apikey/route.js
Normal file
27
src/app/api/providers/[id]/apikey/route.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById } from "@/models";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/providers/[id]/apikey - Reveal API key for a connection
|
||||
export async function GET(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const connection = await getProviderConnectionById(id);
|
||||
|
||||
if (!connection) {
|
||||
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const apiKey = connection.apiKey || null;
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "No API key for this connection" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ apiKey });
|
||||
} catch (error) {
|
||||
console.log("Error fetching API key:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch API key" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getChartData } from "@/lib/usageDb";
|
||||
|
||||
const VALID_PERIODS = new Set(["today", "24h", "7d", "30d", "60d"]);
|
||||
const VALID_PERIODS = new Set(["today", "week", "month", "all"]);
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const period = searchParams.get("period") || "7d";
|
||||
const period = searchParams.get("period") || "today";
|
||||
|
||||
if (!VALID_PERIODS.has(period)) {
|
||||
return NextResponse.json({ error: "Invalid period" }, { status: 400 });
|
||||
|
||||
@@ -17,32 +17,36 @@ export async function GET(request) {
|
||||
const status = searchParams.get("status");
|
||||
const startDate = searchParams.get("startDate");
|
||||
const endDate = searchParams.get("endDate");
|
||||
|
||||
const comboName = searchParams.get("comboName");
|
||||
const hasFallbackParam = searchParams.get("hasFallback");
|
||||
const apiKey = searchParams.get("apiKey");
|
||||
|
||||
if (page < 1) {
|
||||
return NextResponse.json(
|
||||
{ error: "Page must be >= 1" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (pageSize < 1 || pageSize > 100) {
|
||||
return NextResponse.json(
|
||||
{ error: "PageSize must be between 1 and 100" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const filter = {
|
||||
page,
|
||||
pageSize
|
||||
};
|
||||
|
||||
|
||||
const filter = { page, pageSize };
|
||||
|
||||
if (provider) filter.provider = provider;
|
||||
if (model) filter.model = model;
|
||||
if (connectionId) filter.connectionId = connectionId;
|
||||
if (status) filter.status = status;
|
||||
if (startDate) filter.startDate = startDate;
|
||||
if (endDate) filter.endDate = endDate;
|
||||
if (comboName) filter.comboName = comboName;
|
||||
if (hasFallbackParam === "true") filter.hasFallback = true;
|
||||
if (hasFallbackParam === "false") filter.hasFallback = false;
|
||||
if (apiKey) filter.apiKey = apiKey;
|
||||
|
||||
const result = await getRequestDetails(filter);
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getUsageStats } from "@/lib/usageDb";
|
||||
|
||||
const VALID_PERIODS = new Set(["today", "24h", "7d", "30d", "60d", "all"]);
|
||||
const VALID_PERIODS = new Set(["today", "week", "month", "all"]);
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const period = searchParams.get("period") || "7d";
|
||||
const period = searchParams.get("period") || "today";
|
||||
|
||||
if (!VALID_PERIODS.has(period)) {
|
||||
return NextResponse.json({ error: "Invalid period" }, { status: 400 });
|
||||
|
||||
@@ -29,7 +29,7 @@ export {
|
||||
|
||||
// API keys
|
||||
export {
|
||||
getApiKeys, getApiKeyById, createApiKey, updateApiKey, deleteApiKey, validateApiKey,
|
||||
getApiKeys, getApiKeyById, createApiKey, createCustomApiKey, updateApiKey, deleteApiKey, validateApiKey,
|
||||
} from "./repos/apiKeysRepo.js";
|
||||
|
||||
// Combos
|
||||
|
||||
@@ -67,6 +67,25 @@ export async function deleteApiKey(id) {
|
||||
return (res?.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function createCustomApiKey(name, key, machineId) {
|
||||
if (!machineId) throw new Error("machineId is required");
|
||||
if (!key || typeof key !== "string" || !key.trim()) throw new Error("key is required");
|
||||
const db = await getAdapter();
|
||||
const apiKey = {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
key: key.trim(),
|
||||
machineId,
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
db.run(
|
||||
`INSERT INTO apiKeys(id, key, name, machineId, isActive, createdAt) VALUES(?, ?, ?, ?, ?, ?)`,
|
||||
[apiKey.id, apiKey.key, apiKey.name, apiKey.machineId, 1, apiKey.createdAt]
|
||||
);
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
export async function validateApiKey(key) {
|
||||
const db = await getAdapter();
|
||||
const row = db.get(`SELECT isActive FROM apiKeys WHERE key = ?`, [key]);
|
||||
|
||||
@@ -90,6 +90,9 @@ async function flushToDatabase() {
|
||||
provider: item.provider || null,
|
||||
model: item.model || null,
|
||||
connectionId: item.connectionId || null,
|
||||
apiKey: item.apiKey || null,
|
||||
comboName: item.comboName || null,
|
||||
fallbackHistory: item.fallbackHistory || null,
|
||||
timestamp: item.timestamp,
|
||||
status: item.status || null,
|
||||
latency: item.latency || {},
|
||||
@@ -147,11 +150,15 @@ export async function getRequestDetails(filter = {}) {
|
||||
const params = [];
|
||||
|
||||
if (filter.provider) { conds.push("provider = ?"); params.push(filter.provider); }
|
||||
if (filter.model) { conds.push("model = ?"); params.push(filter.model); }
|
||||
if (filter.model) { conds.push("model LIKE ?"); params.push(`%${filter.model}%`); }
|
||||
if (filter.connectionId) { conds.push("connectionId = ?"); params.push(filter.connectionId); }
|
||||
if (filter.status) { conds.push("status = ?"); params.push(filter.status); }
|
||||
if (filter.startDate) { conds.push("timestamp >= ?"); params.push(new Date(filter.startDate).toISOString()); }
|
||||
if (filter.endDate) { conds.push("timestamp <= ?"); params.push(new Date(filter.endDate).toISOString()); }
|
||||
if (filter.comboName) { conds.push("json_extract(data, '$.comboName') LIKE ?"); params.push(`%${filter.comboName}%`); }
|
||||
if (filter.hasFallback === true) { conds.push("json_extract(data, '$.fallbackHistory') IS NOT NULL"); }
|
||||
if (filter.hasFallback === false) { conds.push("json_extract(data, '$.fallbackHistory') IS NULL"); }
|
||||
if (filter.apiKey) { conds.push("apiKey = ?"); params.push(filter.apiKey); }
|
||||
|
||||
const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
|
||||
const cntRow = db.get(`SELECT COUNT(*) as c FROM requestDetails ${where}`, params);
|
||||
@@ -166,7 +173,33 @@ export async function getRequestDetails(filter = {}) {
|
||||
`SELECT data FROM requestDetails ${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?`,
|
||||
[...params, pageSize, offset]
|
||||
);
|
||||
const details = rows.map((r) => parseJson(r.data, {}));
|
||||
|
||||
// Build apiKey → name map for display
|
||||
let apiKeyMap = {};
|
||||
try {
|
||||
const { getApiKeys } = await import("./apiKeysRepo.js");
|
||||
const allKeys = await getApiKeys();
|
||||
for (const k of allKeys) apiKeyMap[k.key] = k.name;
|
||||
} catch {}
|
||||
|
||||
// Build connectionId → account name map
|
||||
let connMap = {};
|
||||
try {
|
||||
const { getProviderConnections } = await import("./connectionsRepo.js");
|
||||
const allConns = await getProviderConnections();
|
||||
for (const c of allConns) connMap[c.id] = c.name || c.email || c.id;
|
||||
} catch {}
|
||||
|
||||
const details = rows.map((r) => {
|
||||
const d = parseJson(r.data, {});
|
||||
if (d.apiKey) {
|
||||
d.keyName = apiKeyMap[d.apiKey] || null;
|
||||
}
|
||||
if (d.connectionId) {
|
||||
d.accountName = connMap[d.connectionId] || null;
|
||||
}
|
||||
return d;
|
||||
});
|
||||
|
||||
return {
|
||||
details,
|
||||
|
||||
@@ -42,6 +42,8 @@ const DEFAULT_SETTINGS = {
|
||||
cavemanLevel: "full",
|
||||
ponytailEnabled: false,
|
||||
ponytailLevel: "full",
|
||||
providerExtraParams: {},
|
||||
accountCooldownSeconds: 600,
|
||||
};
|
||||
|
||||
async function readRaw() {
|
||||
|
||||
@@ -6,7 +6,23 @@ import { getMeta, setMeta } from "../helpers/metaStore.js";
|
||||
const PENDING_TIMEOUT_MS = 60 * 1000;
|
||||
const RING_CAP = 50;
|
||||
const CONN_CACHE_TTL_MS = 30 * 1000;
|
||||
const PERIOD_MS = { "24h": 86400000, "7d": 604800000, "30d": 2592000000, "60d": 5184000000 };
|
||||
function dateToKey(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// Returns the start dateKey for a given period (null = no cutoff = "all")
|
||||
function getPeriodStartDateKey(period) {
|
||||
const today = new Date();
|
||||
if (period === "week") {
|
||||
const dow = (today.getDay() + 6) % 7; // Mon=0, Sun=6
|
||||
const start = new Date(today.getFullYear(), today.getMonth(), today.getDate() - dow);
|
||||
return dateToKey(start);
|
||||
}
|
||||
if (period === "month") {
|
||||
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-01`;
|
||||
}
|
||||
return null; // "all" — no cutoff
|
||||
}
|
||||
|
||||
// In-memory state shared across Next.js modules
|
||||
if (!global._pendingRequests) global._pendingRequests = { byModel: {}, byAccount: {} };
|
||||
@@ -101,12 +117,17 @@ async function ensureRingInitialized() {
|
||||
recentRing.initialized = true;
|
||||
try {
|
||||
const db = await getAdapter();
|
||||
const rows = db.all(`SELECT timestamp, provider, model, connectionId, apiKey, endpoint, cost, status, tokens FROM usageHistory ORDER BY id DESC LIMIT ?`, [RING_CAP]);
|
||||
recentRing.items = rows.reverse().map((r) => ({
|
||||
timestamp: r.timestamp, provider: r.provider, model: r.model, connectionId: r.connectionId,
|
||||
apiKey: r.apiKey, endpoint: r.endpoint, cost: r.cost, status: r.status,
|
||||
tokens: parseJson(r.tokens, {}),
|
||||
}));
|
||||
const rows = db.all(`SELECT timestamp, provider, model, connectionId, apiKey, endpoint, cost, status, tokens, meta FROM usageHistory ORDER BY id DESC LIMIT ?`, [RING_CAP]);
|
||||
recentRing.items = rows.reverse().map((r) => {
|
||||
const meta = parseJson(r.meta, {});
|
||||
return {
|
||||
timestamp: r.timestamp, provider: r.provider, model: r.model, connectionId: r.connectionId,
|
||||
apiKey: r.apiKey, endpoint: r.endpoint, cost: r.cost, status: r.status,
|
||||
tokens: parseJson(r.tokens, {}),
|
||||
comboName: meta.comboName || null,
|
||||
fallbackHistory: meta.fallbackHistory || null,
|
||||
};
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -214,6 +235,14 @@ export async function getActiveRequests() {
|
||||
}
|
||||
|
||||
await ensureRingInitialized();
|
||||
|
||||
let apiKeyMap = {};
|
||||
try {
|
||||
const { getApiKeys } = await import("./apiKeysRepo.js");
|
||||
const allApiKeys = await getApiKeys();
|
||||
for (const k of allApiKeys) apiKeyMap[k.key] = { name: k.name };
|
||||
} catch {}
|
||||
|
||||
const seen = new Set();
|
||||
const recentRequests = [...recentRing.items]
|
||||
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
||||
@@ -221,15 +250,18 @@ export async function getActiveRequests() {
|
||||
const t = e.tokens || {};
|
||||
return {
|
||||
timestamp: e.timestamp, model: e.model, provider: e.provider || "",
|
||||
comboName: e.comboName || null,
|
||||
fallbackHistory: e.fallbackHistory || null,
|
||||
promptTokens: t.prompt_tokens || t.input_tokens || 0,
|
||||
completionTokens: t.completion_tokens || t.output_tokens || 0,
|
||||
status: e.status || "ok",
|
||||
keyName: e.apiKey ? (apiKeyMap[e.apiKey]?.name || e.apiKey.slice(0, 8) + "...") : null,
|
||||
};
|
||||
})
|
||||
.filter((e) => {
|
||||
if (e.promptTokens === 0 && e.completionTokens === 0) return false;
|
||||
if (e.status !== "error" && e.promptTokens === 0 && e.completionTokens === 0) return false;
|
||||
const minute = e.timestamp ? e.timestamp.slice(0, 16) : "";
|
||||
const key = `${e.model}|${e.provider}|${e.promptTokens}|${e.completionTokens}|${minute}`;
|
||||
const key = `${e.model}|${e.provider}|${e.status}|${e.promptTokens}|${e.completionTokens}|${minute}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
@@ -260,7 +292,10 @@ export async function saveRequestUsage(entry) {
|
||||
entry.timestamp, entry.provider || null, entry.model || null,
|
||||
entry.connectionId || null, entry.apiKey || null, entry.endpoint || null,
|
||||
promptTokens, completionTokens, entry.cost || 0, entry.status || "ok",
|
||||
stringifyJson(tokens), stringifyJson({}),
|
||||
stringifyJson(tokens), stringifyJson({
|
||||
...(entry.comboName ? { comboName: entry.comboName } : {}),
|
||||
...(entry.fallbackHistory?.length ? { fallbackHistory: entry.fallbackHistory } : {}),
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -306,14 +341,9 @@ export async function getUsageHistory(filter = {}) {
|
||||
}));
|
||||
}
|
||||
|
||||
function loadDaysInRange(adapter, maxDays) {
|
||||
if (maxDays == null) {
|
||||
return adapter.all(`SELECT dateKey, data FROM usageDaily`);
|
||||
}
|
||||
const today = new Date();
|
||||
const cutoff = new Date(today.getFullYear(), today.getMonth(), today.getDate() - maxDays + 1);
|
||||
const cutoffKey = `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, "0")}-${String(cutoff.getDate()).padStart(2, "0")}`;
|
||||
return adapter.all(`SELECT dateKey, data FROM usageDaily WHERE dateKey >= ?`, [cutoffKey]);
|
||||
function loadDailyFrom(adapter, fromDateKey) {
|
||||
if (!fromDateKey) return adapter.all(`SELECT dateKey, data FROM usageDaily ORDER BY dateKey`);
|
||||
return adapter.all(`SELECT dateKey, data FROM usageDaily WHERE dateKey >= ? ORDER BY dateKey`, [fromDateKey]);
|
||||
}
|
||||
|
||||
export async function getUsageStats(period = "all") {
|
||||
@@ -342,22 +372,26 @@ export async function getUsageStats(period = "all") {
|
||||
for (const k of allApiKeys) apiKeyMap[k.key] = { name: k.name, id: k.id, createdAt: k.createdAt };
|
||||
|
||||
// recentRequests from live history (last 100 entries enough for 20 deduped)
|
||||
const recentRows = db.all(`SELECT timestamp, provider, model, tokens, status FROM usageHistory ORDER BY id DESC LIMIT 100`);
|
||||
const recentRows = db.all(`SELECT timestamp, provider, model, apiKey, tokens, status, meta FROM usageHistory ORDER BY id DESC LIMIT 100`);
|
||||
const seen = new Set();
|
||||
const recentRequests = recentRows
|
||||
.map((r) => {
|
||||
const t = parseJson(r.tokens, {}) || {};
|
||||
const meta = parseJson(r.meta, {});
|
||||
return {
|
||||
timestamp: r.timestamp, model: r.model, provider: r.provider || "",
|
||||
comboName: meta.comboName || null,
|
||||
fallbackHistory: meta.fallbackHistory || null,
|
||||
promptTokens: t.prompt_tokens || t.input_tokens || 0,
|
||||
completionTokens: t.completion_tokens || t.output_tokens || 0,
|
||||
status: r.status || "ok",
|
||||
keyName: r.apiKey ? (apiKeyMap[r.apiKey]?.name || r.apiKey.slice(0, 8) + "...") : null,
|
||||
};
|
||||
})
|
||||
.filter((e) => {
|
||||
if (e.promptTokens === 0 && e.completionTokens === 0) return false;
|
||||
if (e.status !== "error" && e.promptTokens === 0 && e.completionTokens === 0) return false;
|
||||
const minute = e.timestamp ? e.timestamp.slice(0, 16) : "";
|
||||
const key = `${e.model}|${e.provider}|${e.promptTokens}|${e.completionTokens}|${minute}`;
|
||||
const key = `${e.model}|${e.provider}|${e.status}|${e.promptTokens}|${e.completionTokens}|${minute}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
@@ -415,12 +449,11 @@ export async function getUsageStats(period = "all") {
|
||||
}
|
||||
}
|
||||
|
||||
const useDailySummary = period !== "24h" && period !== "today";
|
||||
const useDailySummary = period !== "today";
|
||||
|
||||
if (useDailySummary) {
|
||||
const periodDays = { "7d": 7, "30d": 30, "60d": 60 };
|
||||
const maxDays = periodDays[period] || null;
|
||||
const dayRows = loadDaysInRange(db, maxDays);
|
||||
const fromDateKey = getPeriodStartDateKey(period);
|
||||
const dayRows = loadDailyFrom(db, fromDateKey);
|
||||
|
||||
for (const dr of dayRows) {
|
||||
const dateKey = dr.dateKey;
|
||||
@@ -503,7 +536,7 @@ export async function getUsageStats(period = "all") {
|
||||
}
|
||||
|
||||
// Overlay precise lastUsed timestamps from history
|
||||
const overlayCutoff = maxDays ? Date.now() - maxDays * 86400000 : 0;
|
||||
const overlayCutoff = fromDateKey ? new Date(fromDateKey).getTime() : 0;
|
||||
const histRows = db.all(
|
||||
`SELECT timestamp, provider, model, connectionId, apiKey, endpoint FROM usageHistory WHERE timestamp >= ?`,
|
||||
[new Date(overlayCutoff).toISOString()]
|
||||
@@ -529,15 +562,10 @@ export async function getUsageStats(period = "all") {
|
||||
if (stats.byEndpoint[endpointKey] && new Date(ts) > new Date(stats.byEndpoint[endpointKey].lastUsed)) stats.byEndpoint[endpointKey].lastUsed = ts;
|
||||
}
|
||||
} else {
|
||||
// 24h / today: live history
|
||||
let cutoff;
|
||||
if (period === "today") {
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
cutoff = startOfDay.toISOString();
|
||||
} else {
|
||||
cutoff = new Date(Date.now() - PERIOD_MS["24h"]).toISOString();
|
||||
}
|
||||
// today: live history from midnight
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const cutoff = startOfDay.toISOString();
|
||||
const filtered = db.all(
|
||||
`SELECT timestamp, provider, model, connectionId, apiKey, endpoint, promptTokens, completionTokens, cost, tokens FROM usageHistory WHERE timestamp >= ?`,
|
||||
[cutoff]
|
||||
@@ -617,27 +645,23 @@ export async function getUsageStats(period = "all") {
|
||||
return stats;
|
||||
}
|
||||
|
||||
export async function getChartData(period = "7d") {
|
||||
export async function getChartData(period = "week") {
|
||||
const db = await getAdapter();
|
||||
const now = Date.now();
|
||||
|
||||
if (period === "today") {
|
||||
const bucketCount = 24;
|
||||
const bucketMs = 3600000;
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const startTime = startOfDay.getTime();
|
||||
const endTime = startTime + bucketCount * bucketMs;
|
||||
const bucketCount = 24;
|
||||
const bucketMs = 3600000;
|
||||
const labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
const buckets = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cost: 0 }));
|
||||
|
||||
const rows = db.all(
|
||||
`SELECT timestamp, promptTokens, completionTokens, cost FROM usageHistory WHERE timestamp >= ?`,
|
||||
[new Date(startTime).toISOString()]
|
||||
[startOfDay.toISOString()]
|
||||
);
|
||||
for (const r of rows) {
|
||||
const t = new Date(r.timestamp).getTime();
|
||||
if (t < startTime || t >= endTime) continue;
|
||||
const idx = Math.floor((t - startTime) / bucketMs);
|
||||
if (idx >= 0 && idx < bucketCount) {
|
||||
buckets[idx].tokens += (r.promptTokens || 0) + (r.completionTokens || 0);
|
||||
@@ -647,47 +671,61 @@ export async function getChartData(period = "7d") {
|
||||
return buckets;
|
||||
}
|
||||
|
||||
if (period === "24h") {
|
||||
const bucketCount = 24;
|
||||
const bucketMs = 3600000;
|
||||
const labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
const startTime = now - bucketCount * bucketMs;
|
||||
const buckets = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cost: 0 }));
|
||||
|
||||
const rows = db.all(
|
||||
`SELECT timestamp, promptTokens, completionTokens, cost FROM usageHistory WHERE timestamp >= ?`,
|
||||
[new Date(startTime).toISOString()]
|
||||
);
|
||||
for (const r of rows) {
|
||||
const t = new Date(r.timestamp).getTime();
|
||||
if (t < startTime || t > now) continue;
|
||||
const idx = Math.min(Math.floor((t - startTime) / bucketMs), bucketCount - 1);
|
||||
buckets[idx].tokens += (r.promptTokens || 0) + (r.completionTokens || 0);
|
||||
buckets[idx].cost += r.cost || 0;
|
||||
}
|
||||
return buckets;
|
||||
if (period === "week") {
|
||||
const today = new Date();
|
||||
const dow = (today.getDay() + 6) % 7; // Mon=0, Sun=6
|
||||
const startOfWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() - dow);
|
||||
const fromDateKey = dateToKey(startOfWeek);
|
||||
const dayRows = loadDailyFrom(db, fromDateKey);
|
||||
const dayMap = {};
|
||||
for (const r of dayRows) dayMap[r.dateKey] = parseJson(r.data, {});
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date(startOfWeek);
|
||||
d.setDate(startOfWeek.getDate() + i);
|
||||
const dk = dateToKey(d);
|
||||
const day = dayMap[dk];
|
||||
return {
|
||||
label: d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }),
|
||||
tokens: day ? (day.promptTokens || 0) + (day.completionTokens || 0) : 0,
|
||||
cost: day ? (day.cost || 0) : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const bucketCount = period === "7d" ? 7 : period === "30d" ? 30 : 60;
|
||||
const today = new Date();
|
||||
const labelFn = (d) => d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
if (period === "month") {
|
||||
const today = new Date();
|
||||
const daysInMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate();
|
||||
const fromDateKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-01`;
|
||||
const dayRows = loadDailyFrom(db, fromDateKey);
|
||||
const dayMap = {};
|
||||
for (const r of dayRows) dayMap[r.dateKey] = parseJson(r.data, {});
|
||||
return Array.from({ length: daysInMonth }, (_, i) => {
|
||||
const d = new Date(today.getFullYear(), today.getMonth(), i + 1);
|
||||
const dk = dateToKey(d);
|
||||
const day = dayMap[dk];
|
||||
return {
|
||||
label: d.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
|
||||
tokens: day ? (day.promptTokens || 0) + (day.completionTokens || 0) : 0,
|
||||
cost: day ? (day.cost || 0) : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Build map of dateKey → day data
|
||||
const dayRows = loadDaysInRange(db, bucketCount);
|
||||
const dayMap = {};
|
||||
for (const r of dayRows) dayMap[r.dateKey] = parseJson(r.data, {});
|
||||
|
||||
return Array.from({ length: bucketCount }, (_, i) => {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - (bucketCount - 1 - i));
|
||||
const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
const dayData = dayMap[dateKey];
|
||||
return {
|
||||
label: labelFn(d),
|
||||
tokens: dayData ? (dayData.promptTokens || 0) + (dayData.completionTokens || 0) : 0,
|
||||
cost: dayData ? (dayData.cost || 0) : 0,
|
||||
};
|
||||
});
|
||||
// "all": group by month
|
||||
const allRows = loadDailyFrom(db, null);
|
||||
const monthMap = {};
|
||||
for (const r of allRows) {
|
||||
const mk = r.dateKey.slice(0, 7); // YYYY-MM
|
||||
const day = parseJson(r.data, {});
|
||||
if (!monthMap[mk]) monthMap[mk] = { tokens: 0, cost: 0 };
|
||||
monthMap[mk].tokens += (day.promptTokens || 0) + (day.completionTokens || 0);
|
||||
monthMap[mk].cost += day.cost || 0;
|
||||
}
|
||||
return Object.keys(monthMap).sort().map((mk) => ({
|
||||
label: new Date(mk + "-15").toLocaleDateString("en-US", { year: "numeric", month: "short" }),
|
||||
tokens: monthMap[mk].tokens,
|
||||
cost: monthMap[mk].cost,
|
||||
}));
|
||||
}
|
||||
|
||||
function formatLogDate(date = new Date()) {
|
||||
|
||||
@@ -10,7 +10,7 @@ export {
|
||||
createProviderNode, updateProviderNode, deleteProviderNode,
|
||||
getProxyPools, getProxyPoolById,
|
||||
createProxyPool, updateProxyPool, deleteProxyPool,
|
||||
getApiKeys, getApiKeyById, createApiKey, updateApiKey, deleteApiKey, validateApiKey,
|
||||
getApiKeys, getApiKeyById, createApiKey, createCustomApiKey, updateApiKey, deleteApiKey, validateApiKey,
|
||||
getCombos, getComboById, getComboByName,
|
||||
createCombo, updateCombo, deleteCombo,
|
||||
getModelAliases, setModelAlias, deleteModelAlias,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -249,9 +249,23 @@ export default function ModelSelectModal({
|
||||
value: `${nodePrefix}/${fullModel.replace(`${providerId}/`, "")}`,
|
||||
}));
|
||||
|
||||
// Always show compatible providers that are connected, even with no aliases.
|
||||
// When no aliases exist, show a placeholder so users know it's available.
|
||||
const modelsToShow = nodeModels.length > 0 ? nodeModels : [{
|
||||
// Models added via "Add Model" button are stored in customModels with providerAlias === providerId.
|
||||
const customProviderModels = customModels
|
||||
.filter((m) => m.providerAlias === providerId && (!getModelKind(m) || getModelKind(m) === "llm"))
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name || m.id,
|
||||
value: `${nodePrefix}/${m.id}`,
|
||||
isCustom: true,
|
||||
}));
|
||||
|
||||
// Merge alias-based and custom-registered models, deduplicated by value.
|
||||
const seenValues = new Set(nodeModels.map((m) => m.value));
|
||||
const mergedModels = [...nodeModels, ...customProviderModels.filter((m) => !seenValues.has(m.value))];
|
||||
|
||||
// Always show compatible providers that are connected, even with no models.
|
||||
// When none exist, show a placeholder so users know it's available.
|
||||
const modelsToShow = mergedModels.length > 0 ? mergedModels : [{
|
||||
id: `__placeholder__${providerId}`,
|
||||
name: `${nodePrefix}/model-id`,
|
||||
value: `${nodePrefix}/model-id`,
|
||||
@@ -264,7 +278,7 @@ export default function ModelSelectModal({
|
||||
color: providerInfo.color,
|
||||
models: modelsToShow,
|
||||
isCustom: true,
|
||||
hasModels: nodeModels.length > 0,
|
||||
hasModels: mergedModels.length > 0,
|
||||
};
|
||||
} else {
|
||||
const hardcodedModels = getModelsByProviderId(providerId);
|
||||
|
||||
@@ -17,6 +17,62 @@ import UsageTable, { fmt, fmtTime } from "@/app/(dashboard)/dashboard/usage/comp
|
||||
import ProviderTopology from "@/app/(dashboard)/dashboard/usage/components/ProviderTopology";
|
||||
import UsageChart from "@/app/(dashboard)/dashboard/usage/components/UsageChart";
|
||||
|
||||
function fmtCost(v) {
|
||||
if (!v) return "$0.00";
|
||||
if (v < 0.01) return `$${v.toFixed(4)}`;
|
||||
return `$${v.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function UsageByProvider({ byProvider = {} }) {
|
||||
const rows = Object.entries(byProvider)
|
||||
.map(([provider, d]) => ({
|
||||
provider,
|
||||
requests: d.requests || 0,
|
||||
promptTokens: d.promptTokens || 0,
|
||||
completionTokens: d.completionTokens || 0,
|
||||
cost: d.cost || 0,
|
||||
}))
|
||||
.sort((a, b) => b.requests - a.requests);
|
||||
|
||||
if (!rows.length) return null;
|
||||
|
||||
return (
|
||||
<Card padding="none" className="overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wide">Usage by Provider</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-subtle">
|
||||
<th className="px-4 py-2 text-left text-xs font-semibold text-text-muted">Provider</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-semibold text-text-muted">Requests</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-semibold text-text-muted">Input Tokens</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-semibold text-text-muted">Output Tokens</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-semibold text-text-muted">Total Tokens</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-semibold text-text-muted">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{rows.map(({ provider, requests, promptTokens, completionTokens, cost }) => (
|
||||
<tr key={provider} className="hover:bg-bg-subtle transition-colors">
|
||||
<td className="px-4 py-2.5">
|
||||
<Badge variant="neutral" size="sm">{provider || "—"}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-text-main">{fmt(requests)}</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-primary">{fmt(promptTokens)}</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-success">{fmt(completionTokens)}</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-text-muted">{fmt(promptTokens + completionTokens)}</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-text-main">{fmtCost(cost)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function timeAgo(timestamp) {
|
||||
const diff = Math.floor((Date.now() - new Date(timestamp)) / 1000);
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
@@ -49,11 +105,13 @@ function RecentRequests({ requests = [] }) {
|
||||
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">No requests yet.</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<table className="w-full min-w-[300px] border-collapse text-xs">
|
||||
<table className="w-full min-w-[400px] border-collapse text-xs">
|
||||
<thead className="sticky top-0 bg-bg z-10">
|
||||
<tr className="border-b border-border">
|
||||
<th className="py-1.5 text-left font-semibold text-text-muted w-2"></th>
|
||||
<th className="py-1.5 text-left font-semibold text-text-muted">Model</th>
|
||||
<th className="py-1.5 text-left font-semibold text-text-muted">Provider</th>
|
||||
<th className="py-1.5 text-left font-semibold text-text-muted">API Key</th>
|
||||
<th className="py-1.5 text-right font-semibold text-text-muted whitespace-nowrap">In / Out</th>
|
||||
<th className="py-1.5 text-right font-semibold text-text-muted">When</th>
|
||||
</tr>
|
||||
@@ -61,13 +119,23 @@ function RecentRequests({ requests = [] }) {
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{requests.map((r, i) => {
|
||||
const ok = !r.status || r.status === "ok" || r.status === "success";
|
||||
const hasFallback = r.fallbackHistory?.length > 0;
|
||||
const displayModel = r.comboName ? `${r.comboName} (${r.model})` : r.model;
|
||||
return (
|
||||
<tr key={i} className="hover:bg-bg-subtle transition-colors">
|
||||
<td className="py-1.5">
|
||||
<span className={`block w-1.5 h-1.5 rounded-full ${ok ? "bg-success" : "bg-error"}`} />
|
||||
</td>
|
||||
<td className="py-1.5 font-mono truncate max-w-[120px]" title={r.model}>{r.model}</td>
|
||||
<td className="py-1.5 font-mono truncate max-w-[200px]" title={displayModel}>{displayModel}</td>
|
||||
<td className="py-1.5 text-text-muted truncate max-w-[80px]" title={r.provider || "—"}>{r.provider || "—"}</td>
|
||||
<td className="py-1.5 text-text-muted truncate max-w-[80px]" title={r.keyName || "—"}>{r.keyName || "—"}</td>
|
||||
<td className="py-1.5 text-right whitespace-nowrap">
|
||||
{hasFallback && (
|
||||
<span className="mr-1 rounded px-1 py-0.5 text-[10px] font-semibold bg-warning/15 text-warning" title={`Retried ${r.fallbackHistory.length}x: ${r.fallbackHistory.map(f => f.model || f.connectionName || f.provider).join(" → ")}`}>
|
||||
↺{r.fallbackHistory.length}
|
||||
</span>
|
||||
)}
|
||||
{!ok && <span className="mr-1 text-error text-[10px] font-semibold">ERR</span>}
|
||||
<span className="text-primary">{fmt(r.promptTokens)}↑</span>
|
||||
{" "}
|
||||
<span className="text-success">{fmt(r.completionTokens)}↓</span>
|
||||
@@ -183,10 +251,9 @@ const TABLE_OPTIONS = [
|
||||
|
||||
const PERIODS = [
|
||||
{ value: "today", label: "Today" },
|
||||
{ value: "24h", label: "24h" },
|
||||
{ value: "7d", label: "7D" },
|
||||
{ value: "30d", label: "30D" },
|
||||
{ value: "60d", label: "60D" },
|
||||
{ value: "week", label: "This Week" },
|
||||
{ value: "month", label: "This Month" },
|
||||
{ value: "all", label: "All" },
|
||||
];
|
||||
|
||||
export default function UsageStats({ period: periodProp, setPeriod: setPeriodProp, hidePeriodSelector = false } = {}) {
|
||||
@@ -458,7 +525,7 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
|
||||
|
||||
{/* Provider topology + Recent Requests */}
|
||||
{loading ? spinner : (
|
||||
<div className="grid min-w-0 grid-cols-1 items-stretch gap-2 lg:grid-cols-[minmax(0,2fr)_minmax(280px,1fr)]">
|
||||
<div className="grid min-w-0 grid-cols-1 items-stretch gap-2 lg:grid-cols-2">
|
||||
<ProviderTopology
|
||||
providers={providers}
|
||||
activeRequests={stats.activeRequests || []}
|
||||
@@ -517,6 +584,9 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Usage by Provider */}
|
||||
{loading ? spinner : <UsageByProvider byProvider={stats?.byProvider || {}} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { cacheClaudeHeaders } from "open-sse/utils/claudeHeaderCache.js";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { getModelInfo, getComboModels } from "../services/model.js";
|
||||
import { handleChatCore } from "open-sse/handlers/chatCore.js";
|
||||
import { saveUsageStats } from "open-sse/handlers/chatCore/requestDetail.js";
|
||||
import { DEFAULT_HEADROOM_URL } from "@/lib/headroom/detect";
|
||||
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
||||
import { handleComboChat, handleFusionChat } from "open-sse/services/combo.js";
|
||||
@@ -119,15 +120,19 @@ export async function handleChat(request, clientRawRequest = null) {
|
||||
}
|
||||
|
||||
const comboStickyLimit = settings.comboStickyRoundRobinLimit;
|
||||
const comboRetryCount = comboStrategies[modelStr]?.retryCount ?? 1;
|
||||
const comboMaxFallbackDepth = comboStrategies[modelStr]?.maxFallbackDepth ?? null;
|
||||
log.info("CHAT", `Combo "${modelStr}" with ${comboModels.length} models (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
|
||||
return handleComboChat({
|
||||
body,
|
||||
models: comboModels,
|
||||
handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey),
|
||||
handleSingleModel: (b, m, comboFallbackHistory) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey, modelStr, comboFallbackHistory),
|
||||
log,
|
||||
comboName: modelStr,
|
||||
comboStrategy,
|
||||
comboStickyLimit
|
||||
comboStickyLimit,
|
||||
retryCount: comboRetryCount,
|
||||
maxFallbackDepth: comboMaxFallbackDepth,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,7 +143,7 @@ export async function handleChat(request, clientRawRequest = null) {
|
||||
/**
|
||||
* Handle single model chat request
|
||||
*/
|
||||
async function handleSingleModelChat(body, modelStr, clientRawRequest = null, request = null, apiKey = null) {
|
||||
async function handleSingleModelChat(body, modelStr, clientRawRequest = null, request = null, apiKey = null, comboName = null, comboFallbackHistory = null) {
|
||||
const modelInfo = await getModelInfo(modelStr);
|
||||
|
||||
// If provider is null, this might be a combo name - check and handle
|
||||
@@ -172,15 +177,19 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
||||
}
|
||||
|
||||
const comboStickyLimit = chatSettings.comboStickyRoundRobinLimit;
|
||||
const comboRetryCount = comboStrategies[modelStr]?.retryCount ?? 1;
|
||||
const comboMaxFallbackDepth = comboStrategies[modelStr]?.maxFallbackDepth ?? null;
|
||||
log.info("CHAT", `Combo "${modelStr}" with ${comboModels.length} models (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
|
||||
return handleComboChat({
|
||||
body,
|
||||
models: comboModels,
|
||||
handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey),
|
||||
handleSingleModel: (b, m, cfh) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey, modelStr, cfh),
|
||||
log,
|
||||
comboName: modelStr,
|
||||
comboStrategy,
|
||||
comboStickyLimit
|
||||
comboStickyLimit,
|
||||
retryCount: comboRetryCount,
|
||||
maxFallbackDepth: comboMaxFallbackDepth,
|
||||
});
|
||||
}
|
||||
log.warn("CHAT", "Invalid model format", { model: modelStr });
|
||||
@@ -203,6 +212,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
||||
const excludeConnectionIds = new Set();
|
||||
let lastError = null;
|
||||
let lastStatus = null;
|
||||
const fallbackHistory = [];
|
||||
|
||||
while (true) {
|
||||
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model);
|
||||
@@ -213,13 +223,18 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
||||
const errorMsg = lastError || credentials.lastError || "Unavailable";
|
||||
const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
|
||||
log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
|
||||
const mergedHistory = [...(comboFallbackHistory || []), ...fallbackHistory];
|
||||
saveUsageStats({ provider, model, tokens: { prompt_tokens: 0, completion_tokens: 0 }, apiKey, endpoint: null, comboName, fallbackHistory: mergedHistory.length ? mergedHistory : null, status: "error", label: "NO-CREDS" });
|
||||
return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
|
||||
}
|
||||
if (excludeConnectionIds.size === 0) {
|
||||
log.warn("AUTH", `No active credentials for provider: ${provider}`);
|
||||
saveUsageStats({ provider, model, tokens: { prompt_tokens: 0, completion_tokens: 0 }, apiKey, endpoint: null, comboName, fallbackHistory: (comboFallbackHistory || []).length ? comboFallbackHistory : null, status: "error", label: "NO-CREDS" });
|
||||
return errorResponse(HTTP_STATUS.NOT_FOUND, `No active credentials for provider: ${provider}`);
|
||||
}
|
||||
log.warn("CHAT", "No more accounts available", { provider });
|
||||
const mergedHistory = [...(comboFallbackHistory || []), ...fallbackHistory];
|
||||
saveUsageStats({ provider, model, tokens: { prompt_tokens: 0, completion_tokens: 0 }, apiKey, endpoint: null, comboName, fallbackHistory: mergedHistory.length ? mergedHistory : null, status: "error", label: "NO-CREDS" });
|
||||
return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable");
|
||||
}
|
||||
|
||||
@@ -250,6 +265,11 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
||||
connectionId: credentials.connectionId,
|
||||
userAgent,
|
||||
apiKey,
|
||||
comboName,
|
||||
fallbackHistory: (() => {
|
||||
const merged = [...(comboFallbackHistory || []), ...fallbackHistory];
|
||||
return merged.length ? merged : null;
|
||||
})(),
|
||||
ccFilterNaming: !!chatSettings.ccFilterNaming,
|
||||
rtkEnabled: !!chatSettings.rtkEnabled,
|
||||
headroomEnabled: !!chatSettings.headroomEnabled,
|
||||
@@ -271,6 +291,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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -281,6 +307,14 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
||||
|
||||
if (shouldFallback) {
|
||||
log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
|
||||
fallbackHistory.push({
|
||||
connectionName: credentials.connectionName,
|
||||
provider,
|
||||
model,
|
||||
status: result.status,
|
||||
error: result.error,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
excludeConnectionIds.add(credentials.connectionId);
|
||||
lastError = result.error;
|
||||
lastStatus = result.status;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user