feat: pre-request token validation, mid-stream error handling, and usage stats improvements

- Qoder: handle mid-stream errors by returning proper error Response instead of embedding in stream
- Qoder: add refreshCredentials() to validate token via quota endpoint before requests
- chatCore: validate and refresh provider tokens before sending chat requests
- chatCore: bail early with 401 on unrecoverable token refresh errors
- Usage stats: track apiKey, comboName, fallbackHistory in request details
- Dashboard: improve Combos, Endpoint, Provider, Usage, and RequestDetails pages
- API keys route: upsert logic with provider_type support
- DB repos: usageRepo query improvements, requestDetailsRepo pagination, apiKeysRepo updates
This commit is contained in:
2026-06-29 10:08:07 +07:00
parent 1fe8115dad
commit 2305e26e25
27 changed files with 1433 additions and 459 deletions

View File

@@ -368,16 +368,10 @@ async function wrapQoderSSE(response, model, midStreamError = {}) {
const inner = typeof envelope.body === "string" ? envelope.body : "";
if (statusVal !== 200) {
const parsed = parseQoderStreamError(statusVal, inner);
// Store error in shared state so onStreamComplete can trigger cooldown
// 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 };
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\n${parsed.message}` }, finish_reason: "stop" }],
});
controller.enqueue(encoder.encode(`data: ${errChunk}\n\n`));
// 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;
@@ -555,19 +549,82 @@ export class QoderExecutor extends BaseExecutor {
}
const midStreamError = {};
const wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`, 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;
}
}

View File

@@ -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, onMidStreamError, 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);
@@ -241,7 +271,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
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),
@@ -249,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);
@@ -287,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),
@@ -295,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}`);
@@ -302,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, midStreamError, onMidStreamError };
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);

View File

@@ -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, midStreamError, onMidStreamError }) {
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;
@@ -189,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)
@@ -234,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),

View File

@@ -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,13 +75,13 @@ 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;
@@ -112,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(() => {});
}

View File

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

View File

@@ -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),
@@ -79,7 +79,7 @@ export function handleStreamingResponse({ providerResponse, provider, model, sou
* @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, midStreamError, onMidStreamError }) {
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) => {
@@ -100,7 +100,7 @@ export function buildOnStreamComplete({ provider, model, connectionId, apiKey, r
}
saveRequestDetail(buildRequestDetail({
provider, model, connectionId,
provider, model, connectionId, apiKey, comboName, fallbackHistory,
latency,
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
request: extractRequestConfig(body, stream),

View File

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

View File

@@ -49,7 +49,9 @@ export function createSSEStream(options = {}) {
connectionId = null,
body = null,
onStreamComplete = null,
apiKey = null
apiKey = null,
endpoint = null,
comboName = null
} = options;
let buffer = "";
@@ -335,11 +337,11 @@ export function createSSEStream(options = {}) {
}
if (hasValidUsage(usage)) {
saveUsageStats({ provider, model, tokens: usage, 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
@@ -418,7 +420,7 @@ export function createSSEStream(options = {}) {
}
if (hasValidUsage(state?.usage)) {
saveUsageStats({ provider: state.provider || targetFormat, model, tokens: state.usage, 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(() => { });
}
@@ -436,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,
@@ -448,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,
@@ -461,6 +465,8 @@ export function createPassthroughStreamWithLogger(provider = null, reqLogger = n
connectionId,
body,
onStreamComplete,
apiKey
apiKey,
endpoint,
comboName
});
}

View File

@@ -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 &amp; 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>

View File

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

View File

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

View File

@@ -832,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]);
@@ -886,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);
};
@@ -896,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,
}));
@@ -908,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">
@@ -949,6 +1007,8 @@ export default function ProviderDetailPage() {
}}
onDelete={() => handleDelete(conn.id)}
oneByOneStatus={oneByOneResults[conn.id] || null}
isSelected={isSelected(conn.id)}
onToggleSelect={toggleSelectConnection}
/>
</div>
</div>
@@ -962,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">
@@ -1565,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">

View File

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

View File

@@ -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() {

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()) {

View File

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

View File

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

View File

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

View File

@@ -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,
@@ -287,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;