diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js index a537fbf0..386e0e27 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.js @@ -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; } } diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index c1793ee6..18f74d29 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -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); diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index 4ea2193a..5f18c2bb 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -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), diff --git a/open-sse/handlers/chatCore/requestDetail.js b/open-sse/handlers/chatCore/requestDetail.js index 0c767c75..7d195a2d 100644 --- a/open-sse/handlers/chatCore/requestDetail.js +++ b/open-sse/handlers/chatCore/requestDetail.js @@ -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(() => {}); } diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.js index 1e0edeba..246cacfb 100644 --- a/open-sse/handlers/chatCore/sseToJsonHandler.js +++ b/open-sse/handlers/chatCore/sseToJsonHandler.js @@ -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({ diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js index 4f753223..a4af80ab 100644 --- a/open-sse/handlers/chatCore/streamingHandler.js +++ b/open-sse/handlers/chatCore/streamingHandler.js @@ -22,7 +22,7 @@ const CODEX_SOURCE_TO_TARGET = { /** * Determine which SSE transform stream to use based on provider/format. */ -function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }) { +function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, endpoint, comboName }) { const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli"); // Responses-API providers (e.g. codex) emit Responses SSE → translate into client format const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES; @@ -30,23 +30,23 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, if (needsCodexTranslation) { const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI; - return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey); + return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, endpoint, comboName); } if (needsTranslation(targetFormat, sourceFormat)) { - return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey); + return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, endpoint, comboName); } - return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey); + return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey, endpoint, comboName); } /** * Handle streaming response — pipe provider SSE through transform stream to client. */ -export function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete }) { +export function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, comboName, fallbackHistory, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete }) { if (onRequestSuccess) onRequestSuccess(); - const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }); + const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, endpoint: clientRawRequest?.endpoint, comboName }); // Responses passthrough: synthesize response.failed + [DONE] if the stream aborts/stalls before a terminal event const isResponsesPassthrough = sourceFormat === FORMATS.OPENAI_RESPONSES && targetFormat === FORMATS.OPENAI_RESPONSES; @@ -56,7 +56,7 @@ export function handleStreamingResponse({ providerResponse, provider, model, sou const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; saveRequestDetail(buildRequestDetail({ - provider, model, connectionId, + provider, model, connectionId, apiKey, comboName, fallbackHistory, latency: { ttft: 0, total: Date.now() - requestStartTime }, tokens: { prompt_tokens: 0, completion_tokens: 0 }, request: extractRequestConfig(body, stream), @@ -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), diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js index 9216ab2f..8a48631a 100644 --- a/open-sse/services/combo.js +++ b/open-sse/services/combo.js @@ -226,7 +226,7 @@ export function getComboModelsFromData(modelStr, combosData) { * @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching * @returns {Promise} */ -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 }); } } diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index e783b73e..31ea9778 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -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 }); } diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index abfc215c..52ccf9ea 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -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 ( @@ -299,13 +306,70 @@ function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy {/* Actions */}
{/* Strategy selector — always visible */} -
- onSetStrategy({ fallbackStrategy: e.target.value })} + selectClassName="py-1.5 text-xs" + /> + {!isFusion && ( + + )} +
+ {!isFusion && showAdvanced && ( +
+
+ Retries per provider + { + 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" + /> +
+
+ Max providers +
+ {maxFallbackDepth == null ? ( + + ) : ( + { + 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" + /> + )} + +
+
+
+ )}
@@ -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 (
{/* Drag handle */} + {/* Add Model button */} + +
+ + {/* Actions */} +
+ + +
- {/* Actions */} -
- - + {/* Right: real-time preview */} +
+
+

Preview

+

Real-time provider & model mapping

+
+ + {(() => { + const activeModels = models.filter(m => getProviderStatus(m) === true); + if (activeModels.length === 0) { + return ( +
+ layers +

+ {models.length === 0 ? "Add models to see preview" : "No active providers"} +

+
+ ); + } + return ( +
+ {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 ( +
+ {/* Provider row */} +
+ + {rank + 1} + + + + {activeConn?.name || providerPart || "Unknown"} + +
+ {/* Model name */} +
+ + {modelPart || model} + +
+
+ ); + })} +
+ ); + })()}
diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 88f5e81f..a71a4d1b 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -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 }) { vpn_key API Keys - +
+ + +
@@ -1145,9 +1177,14 @@ export default function APIPageClient({ machineId }) {

No API keys yet

Create your first API key to get started

- +
+ + +
) : (
@@ -1415,6 +1452,48 @@ export default function APIPageClient({ machineId }) {
+ {/* Add Custom Key Modal */} + { + setShowCustomKeyModal(false); + setCustomKeyName(""); + setCustomKeyValue(""); + }} + > +
+ setCustomKeyName(e.target.value)} + placeholder="My External Key" + /> + setCustomKeyValue(e.target.value)} + placeholder="Paste your API key here" + /> +
+ + +
+
+
+ {/* Created Key Modal */} [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 ( -
+
+ {/* Checkbox */} + {onToggleSelect && ( + 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 */}
-
+
{/* Proxy button with inline dropdown */} {(proxyPools || []).length > 0 && (
@@ -254,6 +292,45 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst )} + {!isOAuthConnection && !isCookieConnection && ( + <> + + + + )} + + {proxyPools.length > 0 && ( + + )} + + +
+
+ )} {connectionsList} {!isCompatible && (
diff --git a/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js b/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js index e450dd94..84b2f0c0 100644 --- a/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js +++ b/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js @@ -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 (
-
-
- - -
- -
- - 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" - )} - /> +
+ {/* Row 1: Provider, Model, Status, Has Fallback */} +
+
+ + +
+ +
+ + 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")} + /> +
+ +
+ + +
+ +
+ + +
-
- - 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 */} +
+
+ + +
+ +
+ + +
+ +
+ + 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")} + /> +
+ +
+ + 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")} + /> +
+ +
+ + 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")} + /> +
- -
- -
@@ -238,22 +323,25 @@ export default function RequestDetailsTab() {
- +
- - - - - - - - + + + + + + + + + + + {loading ? ( - ) : details.length === 0 ? ( - ) : ( - details.map((detail, index) => ( - - - - - - - + {/* Status dot */} + + + + + + + + - - - )) + + + + + ); + }) )}
TimestampModelProviderInput TokensOutput TokensLatencyAction
TimestampModelProviderAccountAPI KeyIn / OutLatencyAttemptsAction
+
progress_activity Loading... @@ -262,50 +350,83 @@ export default function RequestDetailsTab() {
+ No request details found
- {new Date(detail.timestamp).toLocaleString()} - - {detail.model} - - - {getProviderName(detail.provider, providerNameCache)} - - - {getInputTokens(detail.tokens).toLocaleString()} - - {detail.tokens?.completion_tokens?.toLocaleString() || 0} - -
+ details.map((detail, index) => { + const isOk = !detail.status || detail.status === "ok" || detail.status === "success"; + return ( +
+ + + {new Date(detail.timestamp).toLocaleString()} + +
{detail.model}
+ {detail.comboName && ( +
via {detail.comboName}
+ )} +
+ {getProviderName(detail.provider, providerNameCache)} + + {detail.accountName || } + + {detail.keyName || (detail.apiKey ? detail.apiKey.slice(0, 8) + "..." : )} + + {getInputTokens(detail.tokens).toLocaleString()}↑ + {" "} + {(detail.tokens?.completion_tokens || 0).toLocaleString()}↓ +
TTFT: {detail.latency?.ttft || 0}ms
Total: {detail.latency?.total || 0}ms
- -
- -
+ {detail.fallbackHistory?.length > 0 ? ( + + ) : ( + + )} + + +
@@ -332,6 +453,22 @@ export default function RequestDetailsTab() { > {selectedDetail && (
+ {/* Status banner */} + {(() => { + const isOk = !selectedDetail.status || selectedDetail.status === "ok" || selectedDetail.status === "success"; + return ( +
+ + {isOk ? "Success" : `Failed — ${selectedDetail.status}`} +
+ ); + })()} +
ID:{" "} @@ -342,21 +479,29 @@ export default function RequestDetailsTab() { {new Date(selectedDetail.timestamp).toLocaleString()}
- Provider:{" "} - {getProviderName(selectedDetail.provider, providerNameCache)} -
+ Provider:{" "} + {getProviderName(selectedDetail.provider, providerNameCache)} +
+
+ Account:{" "} + {selectedDetail.accountName || } +
Model:{" "} {selectedDetail.model} + {selectedDetail.comboName && ( +
via combo: {selectedDetail.comboName}
+ )}
- Status:{" "} - - {selectedDetail.status} - + API Key:{" "} + {selectedDetail.keyName ? ( + {selectedDetail.keyName} + ) : selectedDetail.apiKey ? ( + {selectedDetail.apiKey.slice(0, 16)}... + ) : ( + + )}
Latency:{" "} @@ -366,28 +511,57 @@ export default function RequestDetailsTab() {
Input Tokens:{" "} - + {getInputTokens(selectedDetail.tokens).toLocaleString()}
Output Tokens:{" "} - + {selectedDetail.tokens?.completion_tokens?.toLocaleString() || 0}
+ {selectedDetail.fallbackHistory?.length > 0 && ( +
+
+ history + + Fallback History — {selectedDetail.fallbackHistory.length} failed attempt{selectedDetail.fallbackHistory.length > 1 ? 's' : ''} before success + +
+
+ {selectedDetail.fallbackHistory.map((attempt, idx) => ( +
+
+ + #{idx + 1} — {attempt.model || attempt.connectionName || attempt.provider || "unknown"} + + + HTTP {attempt.status} + +
+ {attempt.error && ( + {attempt.error} + )} + {attempt.timestamp ? new Date(attempt.timestamp).toLocaleString() : ''} +
+ ))} +
+
+ )} +
-
+                
                   {JSON.stringify(selectedDetail.request, null, 2)}
                 
{selectedDetail.providerRequest && ( -
+                  
                     {JSON.stringify(selectedDetail.providerRequest, null, 2)}
                   
@@ -395,7 +569,7 @@ export default function RequestDetailsTab() { {selectedDetail.providerResponse && ( -
+                  
                     {typeof selectedDetail.providerResponse === 'object'
                       ? JSON.stringify(selectedDetail.providerResponse, null, 2)
                       : selectedDetail.providerResponse
@@ -403,25 +577,10 @@ export default function RequestDetailsTab() {
                   
)} - + - {selectedDetail.response?.thinking && ( -
-

- psychology - Thinking Process -

-
-                      {selectedDetail.response.thinking}
-                    
-
- )} - -

- Content -

-
-                  {selectedDetail.response?.content || "[No content]"}
+                
+                  {JSON.stringify(selectedDetail.response, null, 2) || "[No response]"}
                 
diff --git a/src/app/(dashboard)/dashboard/usage/page.js b/src/app/(dashboard)/dashboard/usage/page.js index 075b211a..1197d3f3 100644 --- a/src/app/(dashboard)/dashboard/usage/page.js +++ b/src/app/(dashboard)/dashboard/usage/page.js @@ -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() { diff --git a/src/app/api/keys/route.js b/src/app/api/keys/route.js index ab0470ae..ded51373 100644 --- a/src/app/api/keys/route.js +++ b/src/app/api/keys/route.js @@ -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({ diff --git a/src/app/api/providers/[id]/apikey/route.js b/src/app/api/providers/[id]/apikey/route.js new file mode 100644 index 00000000..cb7c5abd --- /dev/null +++ b/src/app/api/providers/[id]/apikey/route.js @@ -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 }); + } +} diff --git a/src/app/api/usage/chart/route.js b/src/app/api/usage/chart/route.js index 063cedd6..b32c3e2e 100644 --- a/src/app/api/usage/chart/route.js +++ b/src/app/api/usage/chart/route.js @@ -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 }); diff --git a/src/app/api/usage/request-details/route.js b/src/app/api/usage/request-details/route.js index 73a3ceb2..92ff3dd6 100644 --- a/src/app/api/usage/request-details/route.js +++ b/src/app/api/usage/request-details/route.js @@ -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); diff --git a/src/app/api/usage/stats/route.js b/src/app/api/usage/stats/route.js index 27e51090..c40df49b 100644 --- a/src/app/api/usage/stats/route.js +++ b/src/app/api/usage/stats/route.js @@ -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 }); diff --git a/src/lib/db/index.js b/src/lib/db/index.js index 0d5dd652..e5e8ca1f 100644 --- a/src/lib/db/index.js +++ b/src/lib/db/index.js @@ -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 diff --git a/src/lib/db/repos/apiKeysRepo.js b/src/lib/db/repos/apiKeysRepo.js index ff09d926..1a0eb241 100644 --- a/src/lib/db/repos/apiKeysRepo.js +++ b/src/lib/db/repos/apiKeysRepo.js @@ -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]); diff --git a/src/lib/db/repos/requestDetailsRepo.js b/src/lib/db/repos/requestDetailsRepo.js index 813974ae..a85e7c23 100644 --- a/src/lib/db/repos/requestDetailsRepo.js +++ b/src/lib/db/repos/requestDetailsRepo.js @@ -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, diff --git a/src/lib/db/repos/usageRepo.js b/src/lib/db/repos/usageRepo.js index 8bd632ad..12ca26b3 100644 --- a/src/lib/db/repos/usageRepo.js +++ b/src/lib/db/repos/usageRepo.js @@ -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()) { diff --git a/src/lib/localDb.js b/src/lib/localDb.js index 71d086e6..421f435f 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -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, diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index 911dd607..2661da49 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -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); diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index 950a7af9..7e688faa 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -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 ( + +
+ Usage by Provider +
+
+ + + + + + + + + + + + + {rows.map(({ provider, requests, promptTokens, completionTokens, cost }) => ( + + + + + + + + + ))} + +
ProviderRequestsInput TokensOutput TokensTotal TokensCost
+ {provider || "—"} + {fmt(requests)}{fmt(promptTokens)}{fmt(completionTokens)}{fmt(promptTokens + completionTokens)}{fmtCost(cost)}
+
+
+ ); +} + 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 = [] }) {
No requests yet.
) : (
- +
+ + @@ -61,13 +119,23 @@ function RecentRequests({ requests = [] }) { {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 ( - + + +
ModelProviderAPI Key In / Out When
{r.model}{displayModel}{r.provider || "—"}{r.keyName || "—"} + {hasFallback && ( + f.model || f.connectionName || f.provider).join(" → ")}`}> + ↺{r.fallbackHistory.length} + + )} + {!ok && ERR} {fmt(r.promptTokens)}↑ {" "} {fmt(r.completionTokens)}↓ @@ -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 : ( -
+
)}
+ + {/* Usage by Provider */} + {loading ? spinner : }
); } diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index c5a07001..529cced7 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -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;