Compare commits
6 Commits
master
...
feature/sy
| Author | SHA1 | Date | |
|---|---|---|---|
| 2305e26e25 | |||
| 1fe8115dad | |||
| 98412aa0bb | |||
| 1cf55126f5 | |||
| ba4ee30122 | |||
| bef54d5f12 |
4
.commandcode/taste/taste.md
Normal file
4
.commandcode/taste/taste.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Taste (Continuously Learned by [CommandCode][cmd])
|
||||||
|
|
||||||
|
[cmd]: https://commandcode.ai/
|
||||||
|
|
||||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
9router:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
image: 9router:local
|
||||||
|
container_name: 9router
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 20128
|
||||||
|
HOSTNAME: 0.0.0.0
|
||||||
|
DATA_DIR: /app/data
|
||||||
|
BASE_URL: http://localhost:20128
|
||||||
|
NEXT_PUBLIC_BASE_URL: http://localhost:20128
|
||||||
|
ports:
|
||||||
|
- "20128:20128"
|
||||||
|
volumes:
|
||||||
|
- 9router-data:/app/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
9router-data:
|
||||||
@@ -125,8 +125,20 @@ export class BaseExecutor {
|
|||||||
|
|
||||||
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
||||||
const url = this.buildUrl(model, stream, urlIndex, credentials);
|
const url = this.buildUrl(model, stream, urlIndex, credentials);
|
||||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
|
||||||
const headers = this.buildHeaders(credentials, stream);
|
// Merge extra body params from provider-specific data (request body takes priority)
|
||||||
|
let mergedBody = this.transformRequest(model, body, stream, credentials);
|
||||||
|
const extraBodyParams = credentials?.providerSpecificData?.bodyParams;
|
||||||
|
if (extraBodyParams && typeof extraBodyParams === "object" && !Array.isArray(extraBodyParams)) {
|
||||||
|
mergedBody = { ...extraBodyParams, ...mergedBody };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge extra header params from provider-specific data (request headers take priority)
|
||||||
|
let headers = this.buildHeaders(credentials, stream);
|
||||||
|
const extraHeaderParams = credentials?.providerSpecificData?.headerParams;
|
||||||
|
if (extraHeaderParams && typeof extraHeaderParams === "object" && !Array.isArray(extraHeaderParams)) {
|
||||||
|
headers = { ...extraHeaderParams, ...headers };
|
||||||
|
}
|
||||||
|
|
||||||
if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0;
|
if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0;
|
||||||
|
|
||||||
@@ -137,7 +149,7 @@ export class BaseExecutor {
|
|||||||
const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal;
|
const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bodyStr = JSON.stringify(transformedBody);
|
const bodyStr = JSON.stringify(mergedBody);
|
||||||
const fetchT0 = Date.now();
|
const fetchT0 = Date.now();
|
||||||
dbg("FETCH", `${this.provider.toUpperCase()} → ${url} | body=${bodyStr.length}B | connectTimeout=${timeoutMs}ms`);
|
dbg("FETCH", `${this.provider.toUpperCase()} → ${url} | body=${bodyStr.length}B | connectTimeout=${timeoutMs}ms`);
|
||||||
const response = await proxyAwareFetch(url, {
|
const response = await proxyAwareFetch(url, {
|
||||||
@@ -159,7 +171,7 @@ export class BaseExecutor {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { response, url, headers, transformedBody };
|
return { response, url, headers, transformedBody: mergedBody };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearTimeout(connectTimer);
|
clearTimeout(connectTimer);
|
||||||
lastError = error;
|
lastError = error;
|
||||||
|
|||||||
@@ -122,6 +122,68 @@ function truncate(s, n) {
|
|||||||
return s && s.length > n ? `${s.slice(0, n)}...` : s || "";
|
return s && s.length > n ? `${s.slice(0, n)}...` : s || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Qoder-specific mid-stream error codes into user-friendly messages.
|
||||||
|
* Qoder embeds errors inside SSE envelope body as JSON: {"code":"112","message":"{...}"}
|
||||||
|
*
|
||||||
|
* Known codes:
|
||||||
|
* 112 → quota/billing exceeded (message contains pricingUrl)
|
||||||
|
* 113 → model not available for current plan
|
||||||
|
*
|
||||||
|
* @param {number} statusVal - Upstream status code from envelope
|
||||||
|
* @param {string} bodyStr - Raw body string from envelope
|
||||||
|
* @returns {{ statusCode: number, message: string, errorCode: string|null }}
|
||||||
|
*/
|
||||||
|
function parseQoderStreamError(statusVal, bodyStr) {
|
||||||
|
let code = null;
|
||||||
|
let innerMessage = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inner = JSON.parse(bodyStr);
|
||||||
|
code = String(inner.code || "");
|
||||||
|
innerMessage = inner.message || bodyStr;
|
||||||
|
|
||||||
|
// Code 112: quota exceeded — inner message is JSON with pricingUrl
|
||||||
|
if (code === "112") {
|
||||||
|
let pricingUrl = "";
|
||||||
|
try {
|
||||||
|
const msgObj = JSON.parse(innerMessage);
|
||||||
|
pricingUrl = msgObj.pricingUrl || "";
|
||||||
|
} catch { /* innerMessage is not JSON */ }
|
||||||
|
return {
|
||||||
|
statusCode: statusVal,
|
||||||
|
message: pricingUrl
|
||||||
|
? `Qoder quota exceeded. Upgrade your plan at: ${pricingUrl}`
|
||||||
|
: "Qoder quota exceeded. Please check your plan limits.",
|
||||||
|
errorCode: code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code 113: model not available
|
||||||
|
if (code === "113") {
|
||||||
|
return {
|
||||||
|
statusCode: statusVal,
|
||||||
|
message: `Qoder model not available for your current plan. ${innerMessage}`,
|
||||||
|
errorCode: code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// bodyStr is not valid JSON — fall through to generic message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic fallback
|
||||||
|
const statusLabel = statusVal >= 500 ? "server error"
|
||||||
|
: statusVal === 429 ? "rate limit exceeded"
|
||||||
|
: statusVal === 403 ? "permission error"
|
||||||
|
: statusVal === 401 ? "authentication error"
|
||||||
|
: "request error";
|
||||||
|
return {
|
||||||
|
statusCode: statusVal,
|
||||||
|
message: `Qoder ${statusLabel}: ${truncate(innerMessage || bodyStr, 200)}`,
|
||||||
|
errorCode: code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map the OpenAI-style request body into the exact shape Qoder expects.
|
* Map the OpenAI-style request body into the exact shape Qoder expects.
|
||||||
*/
|
*/
|
||||||
@@ -223,22 +285,75 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
|
|||||||
* and re-emit as `data: <inner>\n\n`. Errors become `data: [DONE]\n\n` plus
|
* and re-emit as `data: <inner>\n\n`. Errors become `data: [DONE]\n\n` plus
|
||||||
* a synthetic OpenAI error chunk.
|
* a synthetic OpenAI error chunk.
|
||||||
*/
|
*/
|
||||||
function wrapQoderSSE(response, model) {
|
async function wrapQoderSSE(response, model, midStreamError = {}) {
|
||||||
if (!response.ok || !response.body) return response;
|
if (!response.ok || !response.body) return response;
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
// Peek at first chunk to detect errors early
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const firstRead = await reader.read();
|
||||||
|
|
||||||
|
if (firstRead.done) {
|
||||||
|
// Empty stream
|
||||||
|
return new Response("data: [DONE]\n\n", {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse first line to check for error
|
||||||
|
const firstText = decoder.decode(firstRead.value, { stream: true });
|
||||||
|
const nlIndex = firstText.indexOf("\n");
|
||||||
|
const firstLine = nlIndex !== -1 ? firstText.slice(0, nlIndex) : firstText;
|
||||||
|
const trimmed = firstLine.replace(/\r$/, "").trim();
|
||||||
|
|
||||||
|
if (trimmed.startsWith("data:")) {
|
||||||
|
const data = trimmed.slice(5).trimStart();
|
||||||
|
if (data !== "[DONE]") {
|
||||||
|
try {
|
||||||
|
const envelope = JSON.parse(data);
|
||||||
|
const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200;
|
||||||
|
|
||||||
|
if (statusVal !== 200) {
|
||||||
|
// Error detected - return error Response to trigger failover
|
||||||
|
const msg = envelope.body || `upstream status ${statusVal}`;
|
||||||
|
const errorResponse = new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: `qoder error ${statusVal}: ${truncate(msg, 500)}`,
|
||||||
|
type: "upstream_error",
|
||||||
|
code: String(statusVal)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: statusVal >= 400 && statusVal < 600 ? statusVal : 502,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
reader.cancel();
|
||||||
|
return errorResponse;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Not JSON, continue as normal stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No error detected - proceed with normal TransformStream
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
let doneEmitted = false;
|
let doneEmitted = false;
|
||||||
|
|
||||||
// Process one already-extracted SSE line (no trailing newline). Returns
|
|
||||||
// false when the line indicated end-of-stream so the caller can stop
|
|
||||||
// forwarding any remaining chunks after [DONE].
|
|
||||||
const processLine = (line, controller) => {
|
const processLine = (line, controller) => {
|
||||||
const trimmed = line.replace(/\r$/, "").trim();
|
const trimmed = line.replace(/\r$/, "").trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
if (!trimmed.startsWith("data:")) return;
|
if (!trimmed.startsWith("data:")) return;
|
||||||
if (doneEmitted) return; // never forward chunks past stream end
|
if (doneEmitted) return;
|
||||||
|
|
||||||
const data = trimmed.slice(5).trimStart();
|
const data = trimmed.slice(5).trimStart();
|
||||||
if (data === "[DONE]") {
|
if (data === "[DONE]") {
|
||||||
@@ -252,15 +367,11 @@ function wrapQoderSSE(response, model) {
|
|||||||
const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200;
|
const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200;
|
||||||
const inner = typeof envelope.body === "string" ? envelope.body : "";
|
const inner = typeof envelope.body === "string" ? envelope.body : "";
|
||||||
if (statusVal !== 200) {
|
if (statusVal !== 200) {
|
||||||
const msg = inner || `upstream status ${statusVal}`;
|
const parsed = parseQoderStreamError(statusVal, inner);
|
||||||
const errChunk = JSON.stringify({
|
// Store error in shared state so the caller can return a proper error Response
|
||||||
id: `qoder-error-${Date.now()}`,
|
midStreamError.error = { status: parsed.statusCode, message: parsed.message, errorCode: parsed.errorCode };
|
||||||
object: "chat.completion.chunk",
|
// End the stream with [DONE]; the caller (QoderExecutor.execute) will detect
|
||||||
created: Math.floor(Date.now() / 1000),
|
// midStreamError and return a non-2xx Response so the API client gets a real error.
|
||||||
model,
|
|
||||||
choices: [{ index: 0, delta: { content: `\n[qoder error ${statusVal}: ${truncate(msg, 200)}]` }, finish_reason: "stop" }],
|
|
||||||
});
|
|
||||||
controller.enqueue(encoder.encode(`data: ${errChunk}\n\n`));
|
|
||||||
controller.enqueue(encoder.encode(SSE_DONE));
|
controller.enqueue(encoder.encode(SSE_DONE));
|
||||||
doneEmitted = true;
|
doneEmitted = true;
|
||||||
return;
|
return;
|
||||||
@@ -271,10 +382,6 @@ function wrapQoderSSE(response, model) {
|
|||||||
doneEmitted = true;
|
doneEmitted = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Inner is an OpenAI-shaped chunk. Strip any embedded newlines so the
|
|
||||||
// SSE frame stays a single event (a literal "\n" inside `inner` would
|
|
||||||
// otherwise split the frame across multiple data: lines and downstream
|
|
||||||
// parsers would reassemble them as separate events).
|
|
||||||
const sanitized = inner.replace(/\r?\n/g, "");
|
const sanitized = inner.replace(/\r?\n/g, "");
|
||||||
controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`));
|
controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`));
|
||||||
};
|
};
|
||||||
@@ -290,13 +397,7 @@ function wrapQoderSSE(response, model) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
flush(controller) {
|
flush(controller) {
|
||||||
// Finalize the decoder so any pending multi-byte sequence is
|
|
||||||
// released into `buffer` instead of being silently dropped.
|
|
||||||
buffer += decoder.decode();
|
buffer += decoder.decode();
|
||||||
// Drain any trailing line that arrived without a terminating newline
|
|
||||||
// (e.g. upstream closed the socket immediately after the last write,
|
|
||||||
// or a CDN stripped the final CRLF). Without this, the chunk that
|
|
||||||
// carries finish_reason is silently lost.
|
|
||||||
if (buffer.length > 0) {
|
if (buffer.length > 0) {
|
||||||
processLine(buffer, controller);
|
processLine(buffer, controller);
|
||||||
buffer = "";
|
buffer = "";
|
||||||
@@ -308,9 +409,25 @@ function wrapQoderSSE(response, model) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const transformed = response.body.pipeThrough(transform);
|
// Create a ReadableStream that emits the first chunk + remaining chunks
|
||||||
// Build a Response with passable headers; the streaming handler reads
|
const combinedStream = new ReadableStream({
|
||||||
// `.body` as a ReadableStream regardless of Content-Type.
|
start(controller) {
|
||||||
|
controller.enqueue(firstRead.value);
|
||||||
|
},
|
||||||
|
async pull(controller) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
controller.close();
|
||||||
|
} else {
|
||||||
|
controller.enqueue(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
reader.cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformed = combinedStream.pipeThrough(transform);
|
||||||
return new Response(transformed, {
|
return new Response(transformed, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
@@ -431,19 +548,83 @@ export class QoderExecutor extends BaseExecutor {
|
|||||||
return { response, url, headers, transformedBody: payload };
|
return { response, url, headers, transformedBody: payload };
|
||||||
}
|
}
|
||||||
|
|
||||||
const wrapped = wrapQoderSSE(response, `qoder/${qoderKey}`);
|
const midStreamError = {};
|
||||||
return { response: wrapped, url, headers, transformedBody: payload };
|
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
|
// Validate Qoder token by calling the quota endpoint. If it returns 403,
|
||||||
// 403 for our flow. Surfacing failure via 401-on-chat is enough; the
|
// return a structured unrecoverable error so chatCore bails before sending
|
||||||
// dashboard tells users to re-login when their token expires (~30 days).
|
// the actual chat request (instead of forwarding a "quota exceeded" success).
|
||||||
async refreshCredentials() {
|
async refreshCredentials(credentials, log) {
|
||||||
return null;
|
// 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() {
|
// 24h cooldown before re-checking token validity
|
||||||
return false;
|
needsRefresh(credentials) {
|
||||||
|
if (!credentials?.accessToken) return false;
|
||||||
|
const psd = credentials?.providerSpecificData || {};
|
||||||
|
if (!psd.lastRefreshAt) return true;
|
||||||
|
const elapsed = Date.now() - new Date(psd.lastRefreshAt).getTime();
|
||||||
|
return elapsed > 24 * 60 * 60 * 1000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,6 +634,7 @@ export default QoderExecutor;
|
|||||||
// should import QoderExecutor and use its public methods.
|
// should import QoderExecutor and use its public methods.
|
||||||
export const __test__ = {
|
export const __test__ = {
|
||||||
normalizeMessages,
|
normalizeMessages,
|
||||||
|
parseQoderStreamError,
|
||||||
wrapQoderSSE,
|
wrapQoderSSE,
|
||||||
buildQoderRequestBody,
|
buildQoderRequestBody,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
|||||||
import { handleBypassRequest } from "../utils/bypassHandler.js";
|
import { handleBypassRequest } from "../utils/bypassHandler.js";
|
||||||
import { trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
import { trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
||||||
import { getExecutor } from "../executors/index.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 { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js";
|
||||||
import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js";
|
import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js";
|
||||||
import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.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 {object} options.credentials - Provider credentials
|
||||||
* @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses")
|
* @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses")
|
||||||
*/
|
*/
|
||||||
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking }) {
|
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, onMidStreamError, clientRawRequest, connectionId, userAgent, apiKey, comboName, fallbackHistory, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking }) {
|
||||||
const { provider, model } = modelInfo;
|
const { provider, model } = modelInfo;
|
||||||
const requestStartTime = Date.now();
|
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;
|
const msgCount = translatedBody.messages?.length || translatedBody.input?.length || translatedBody.contents?.length || translatedBody.request?.contents?.length || 0;
|
||||||
log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`);
|
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({
|
const streamController = createStreamController({
|
||||||
onDisconnect: (reason) => {
|
onDisconnect: (reason) => {
|
||||||
trackPendingRequest(model, provider, connectionId, false);
|
trackPendingRequest(model, provider, connectionId, false);
|
||||||
@@ -228,19 +258,20 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute request
|
// Execute request
|
||||||
let providerResponse, providerUrl, providerHeaders, finalBody;
|
let providerResponse, providerUrl, providerHeaders, finalBody, midStreamError;
|
||||||
try {
|
try {
|
||||||
const result = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions });
|
const result = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions });
|
||||||
providerResponse = result.response;
|
providerResponse = result.response;
|
||||||
providerUrl = result.url;
|
providerUrl = result.url;
|
||||||
providerHeaders = result.headers;
|
providerHeaders = result.headers;
|
||||||
finalBody = result.transformedBody;
|
finalBody = result.transformedBody;
|
||||||
|
midStreamError = result.midStreamError;
|
||||||
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
|
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
trackPendingRequest(model, provider, connectionId, false, true);
|
trackPendingRequest(model, provider, connectionId, false, true);
|
||||||
appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => { });
|
appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => { });
|
||||||
saveRequestDetail(buildRequestDetail({
|
saveRequestDetail(buildRequestDetail({
|
||||||
provider, model, connectionId,
|
provider, model, connectionId, apiKey, comboName, fallbackHistory,
|
||||||
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
||||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||||
request: extractRequestConfig(body, stream),
|
request: extractRequestConfig(body, stream),
|
||||||
@@ -248,6 +279,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
response: { error: error.message || String(error), status: error.name === "AbortError" ? 499 : 502, thinking: null },
|
response: { error: error.message || String(error), status: error.name === "AbortError" ? 499 : 502, thinking: null },
|
||||||
status: "error"
|
status: "error"
|
||||||
})).catch(() => { });
|
})).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") {
|
if (error.name === "AbortError") {
|
||||||
streamController.handleError(error);
|
streamController.handleError(error);
|
||||||
@@ -286,7 +318,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
const { statusCode, message, resetsAtMs } = await parseUpstreamError(providerResponse, executor);
|
const { statusCode, message, resetsAtMs } = await parseUpstreamError(providerResponse, executor);
|
||||||
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { });
|
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { });
|
||||||
saveRequestDetail(buildRequestDetail({
|
saveRequestDetail(buildRequestDetail({
|
||||||
provider, model, connectionId,
|
provider, model, connectionId, apiKey, comboName, fallbackHistory,
|
||||||
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
||||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||||
request: extractRequestConfig(body, stream),
|
request: extractRequestConfig(body, stream),
|
||||||
@@ -294,6 +326,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
response: { error: message, status: statusCode, thinking: null },
|
response: { error: message, status: statusCode, thinking: null },
|
||||||
status: "error"
|
status: "error"
|
||||||
})).catch(() => { });
|
})).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);
|
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
|
||||||
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
|
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
|
||||||
@@ -301,7 +334,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
return createErrorResult(statusCode, errMsg, resetsAtMs);
|
return createErrorResult(statusCode, errMsg, resetsAtMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess };
|
const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, comboName, fallbackHistory, clientRawRequest, onRequestSuccess, midStreamError, onMidStreamError };
|
||||||
const appendLog = (extra) => appendRequestLog({ model, provider, connectionId, ...extra }).catch(() => { });
|
const appendLog = (extra) => appendRequestLog({ model, provider, connectionId, ...extra }).catch(() => { });
|
||||||
const trackDone = () => trackPendingRequest(model, provider, connectionId, false);
|
const trackDone = () => trackPendingRequest(model, provider, connectionId, false);
|
||||||
|
|
||||||
@@ -318,8 +351,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streaming response
|
// Streaming response (midStreamError + onMidStreamError already in sharedCtx)
|
||||||
const { onStreamComplete } = buildOnStreamComplete({ ...sharedCtx });
|
const { onStreamComplete } = buildOnStreamComplete(sharedCtx);
|
||||||
return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete });
|
return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source
|
|||||||
/**
|
/**
|
||||||
* Handle non-streaming response from provider.
|
* Handle non-streaming response from provider.
|
||||||
*/
|
*/
|
||||||
export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, trackDone, appendLog }) {
|
export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, comboName, fallbackHistory, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, trackDone, appendLog, midStreamError, onMidStreamError }) {
|
||||||
trackDone();
|
trackDone();
|
||||||
const contentType = providerResponse.headers.get("content-type") || "";
|
const contentType = providerResponse.headers.get("content-type") || "";
|
||||||
let responseBody;
|
let responseBody;
|
||||||
@@ -155,6 +155,21 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
|
|||||||
appendLog({ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}` });
|
appendLog({ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}` });
|
||||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request");
|
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for mid-stream errors detected during SSE parsing (e.g. Qoder quota exceeded).
|
||||||
|
// The upstream returned HTTP 200 but the SSE envelope contained a non-200 statusCodeValue.
|
||||||
|
// Without this check, the error message would be silently served as normal content with HTTP 200.
|
||||||
|
if (midStreamError?.error) {
|
||||||
|
const err = midStreamError.error;
|
||||||
|
if (typeof onMidStreamError === "function") {
|
||||||
|
onMidStreamError(err).catch(e => {
|
||||||
|
console.error("[MidStreamError] Failed to apply cooldown:", e.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
appendLog({ status: `FAILED ${err.status || 429}` });
|
||||||
|
return createErrorResult(err.status || 429, err.message || "Upstream error detected in SSE stream");
|
||||||
|
}
|
||||||
|
|
||||||
responseBody = parsed;
|
responseBody = parsed;
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
@@ -174,7 +189,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
|
|||||||
|
|
||||||
const usage = extractUsageFromResponse(responseBody);
|
const usage = extractUsageFromResponse(responseBody);
|
||||||
appendLog({ tokens: usage, status: "200 OK" });
|
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)
|
const translatedResponse = needsTranslation(targetFormat, sourceFormat)
|
||||||
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
|
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
|
||||||
@@ -219,7 +234,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
|
|||||||
|
|
||||||
const totalLatency = Date.now() - requestStartTime;
|
const totalLatency = Date.now() - requestStartTime;
|
||||||
saveRequestDetail(buildRequestDetail({
|
saveRequestDetail(buildRequestDetail({
|
||||||
provider, model, connectionId,
|
provider, model, connectionId, apiKey, comboName, fallbackHistory,
|
||||||
latency: { ttft: totalLatency, total: totalLatency },
|
latency: { ttft: totalLatency, total: totalLatency },
|
||||||
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
||||||
request: extractRequestConfig(body, stream),
|
request: extractRequestConfig(body, stream),
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ export function buildRequestDetail(base, overrides = {}) {
|
|||||||
provider: base.provider || "unknown",
|
provider: base.provider || "unknown",
|
||||||
model: base.model || "unknown",
|
model: base.model || "unknown",
|
||||||
connectionId: base.connectionId || undefined,
|
connectionId: base.connectionId || undefined,
|
||||||
|
apiKey: base.apiKey || undefined,
|
||||||
|
comboName: base.comboName || null,
|
||||||
|
fallbackHistory: base.fallbackHistory || null,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
latency: base.latency || { ttft: 0, total: 0 },
|
latency: base.latency || { ttft: 0, total: 0 },
|
||||||
tokens: base.tokens || { prompt_tokens: 0, completion_tokens: 0 },
|
tokens: base.tokens || { prompt_tokens: 0, completion_tokens: 0 },
|
||||||
@@ -72,22 +75,37 @@ export function buildRequestDetail(base, overrides = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, endpoint, label = "USAGE" }) {
|
export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, endpoint, comboName, fallbackHistory, status = "ok", label = "USAGE" }) {
|
||||||
if (!tokens || typeof tokens !== "object") return;
|
if (!tokens || typeof tokens !== "object") return;
|
||||||
|
|
||||||
const inTokens = tokens.input_tokens ?? tokens.prompt_tokens ?? 0;
|
const inTokens = tokens.input_tokens ?? tokens.prompt_tokens ?? 0;
|
||||||
const outTokens = tokens.output_tokens ?? tokens.completion_tokens ?? 0;
|
const outTokens = tokens.output_tokens ?? tokens.completion_tokens ?? 0;
|
||||||
|
|
||||||
if (inTokens === 0 && outTokens === 0) return;
|
if (inTokens === 0 && outTokens === 0 && status !== "error") return;
|
||||||
|
|
||||||
|
// Extract cache/reasoning tokens (unified from different formats)
|
||||||
|
const cacheRead = tokens.cache_read_input_tokens || tokens.cached_tokens || tokens.prompt_tokens_details?.cached_tokens || 0;
|
||||||
|
const cacheCreation = tokens.cache_creation_input_tokens || 0;
|
||||||
|
const reasoning = tokens.reasoning_tokens || 0;
|
||||||
|
|
||||||
const time = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
const time = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
const accountSuffix = connectionId ? ` | account=${connectionId.slice(0, 8)}...` : "";
|
const accountSuffix = connectionId ? ` | account=${connectionId.slice(0, 8)}...` : "";
|
||||||
console.log(`${COLORS.green}[${time}] 📊 [${label}] ${provider.toUpperCase()} | in=${inTokens} | out=${outTokens}${accountSuffix}${COLORS.reset}`);
|
|
||||||
|
|
||||||
// Normalize to OpenAI token shape for storage
|
let msg = `${COLORS.green}[${time}] 📊 [${label}] ${provider?.toUpperCase() || "UNKNOWN"} | in=${inTokens} | out=${outTokens}${accountSuffix}`;
|
||||||
|
if (tokens.estimated) msg += ` ${COLORS.yellow}(estimated)${COLORS.reset}`;
|
||||||
|
if (cacheRead) msg += ` | cache_read=${cacheRead}`;
|
||||||
|
if (cacheCreation) msg += ` | cache_create=${cacheCreation}`;
|
||||||
|
if (reasoning) msg += ` | reasoning=${reasoning}`;
|
||||||
|
msg += `${COLORS.reset}`;
|
||||||
|
console.log(msg);
|
||||||
|
|
||||||
|
// Normalize to OpenAI token shape for storage (include all token types)
|
||||||
const normalized = {
|
const normalized = {
|
||||||
prompt_tokens: tokens.prompt_tokens ?? tokens.input_tokens ?? 0,
|
prompt_tokens: tokens.prompt_tokens ?? tokens.input_tokens ?? 0,
|
||||||
completion_tokens: tokens.completion_tokens ?? tokens.output_tokens ?? 0
|
completion_tokens: tokens.completion_tokens ?? tokens.output_tokens ?? 0,
|
||||||
|
cache_read_input_tokens: cacheRead,
|
||||||
|
cache_creation_input_tokens: cacheCreation,
|
||||||
|
reasoning_tokens: reasoning,
|
||||||
};
|
};
|
||||||
|
|
||||||
saveRequestUsage({
|
saveRequestUsage({
|
||||||
@@ -97,6 +115,9 @@ export function saveUsageStats({ provider, model, tokens, connectionId, apiKey,
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
connectionId: connectionId || undefined,
|
connectionId: connectionId || undefined,
|
||||||
apiKey: apiKey || undefined,
|
apiKey: apiKey || undefined,
|
||||||
endpoint: endpoint || null
|
endpoint: endpoint || null,
|
||||||
|
comboName: comboName || undefined,
|
||||||
|
fallbackHistory: fallbackHistory || undefined,
|
||||||
|
status: status || "ok",
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
|
|||||||
* Handle case: provider forced streaming but client wants JSON.
|
* Handle case: provider forced streaming but client wants JSON.
|
||||||
* Supports both Codex/Responses API SSE and standard Chat Completions SSE.
|
* 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 contentType = providerResponse.headers.get("content-type") || "";
|
||||||
const isSSE = contentType.includes("text/event-stream") || (contentType === "" && isResponsesProvider(provider));
|
const isSSE = contentType.includes("text/event-stream") || (contentType === "" && isResponsesProvider(provider));
|
||||||
if (!isSSE) return null; // not handled here
|
if (!isSSE) return null; // not handled here
|
||||||
@@ -110,7 +110,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr
|
|||||||
trackDone();
|
trackDone();
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
provider, model, connectionId,
|
provider, model, connectionId, apiKey, comboName, fallbackHistory,
|
||||||
request: extractRequestConfig(body, stream),
|
request: extractRequestConfig(body, stream),
|
||||||
providerRequest: finalBody || translatedBody || null
|
providerRequest: finalBody || translatedBody || null
|
||||||
};
|
};
|
||||||
@@ -124,7 +124,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr
|
|||||||
|
|
||||||
const usage = jsonResponse.usage || {};
|
const usage = jsonResponse.usage || {};
|
||||||
appendLog({ tokens: usage, status: "200 OK" });
|
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 { msgItem, textContent } = pickAssistantMessageForChatCompletion(jsonResponse.output);
|
||||||
const totalLatency = Date.now() - requestStartTime;
|
const totalLatency = Date.now() - requestStartTime;
|
||||||
@@ -200,7 +200,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr
|
|||||||
|
|
||||||
const usage = parsed.usage || {};
|
const usage = parsed.usage || {};
|
||||||
appendLog({ tokens: usage, status: "200 OK" });
|
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;
|
const totalLatency = Date.now() - requestStartTime;
|
||||||
saveRequestDetail(buildRequestDetail({
|
saveRequestDetail(buildRequestDetail({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { pipeWithDisconnect } from "../../utils/streamHandler.js";
|
|||||||
import { PROVIDERS } from "../../config/providers.js";
|
import { PROVIDERS } from "../../config/providers.js";
|
||||||
import { STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js";
|
import { STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js";
|
||||||
import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.js";
|
import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.js";
|
||||||
import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js";
|
import { buildRequestDetail, extractRequestConfig } from "./requestDetail.js";
|
||||||
import { saveRequestDetail } from "@/lib/usageDb.js";
|
import { saveRequestDetail } from "@/lib/usageDb.js";
|
||||||
import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js";
|
import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js";
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ const CODEX_SOURCE_TO_TARGET = {
|
|||||||
/**
|
/**
|
||||||
* Determine which SSE transform stream to use based on provider/format.
|
* 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");
|
const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli");
|
||||||
// Responses-API providers (e.g. codex) emit Responses SSE → translate into client format
|
// Responses-API providers (e.g. codex) emit Responses SSE → translate into client format
|
||||||
const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES;
|
const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES;
|
||||||
@@ -30,23 +30,23 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent,
|
|||||||
|
|
||||||
if (needsCodexTranslation) {
|
if (needsCodexTranslation) {
|
||||||
const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI;
|
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)) {
|
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.
|
* 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();
|
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
|
// 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;
|
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)}`;
|
const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
saveRequestDetail(buildRequestDetail({
|
saveRequestDetail(buildRequestDetail({
|
||||||
provider, model, connectionId,
|
provider, model, connectionId, apiKey, comboName, fallbackHistory,
|
||||||
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
||||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||||
request: extractRequestConfig(body, stream),
|
request: extractRequestConfig(body, stream),
|
||||||
@@ -76,8 +76,10 @@ export function handleStreamingResponse({ providerResponse, provider, model, sou
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build onStreamComplete callback for streaming usage tracking.
|
* Build onStreamComplete callback for streaming usage tracking.
|
||||||
|
* @param {object} options.midStreamError - Shared state object from executor (filled during streaming)
|
||||||
|
* @param {function} options.onMidStreamError - Callback to invoke when mid-stream error is detected
|
||||||
*/
|
*/
|
||||||
export function buildOnStreamComplete({ provider, model, connectionId, apiKey, requestStartTime, body, stream, finalBody, translatedBody, clientRawRequest }) {
|
export function buildOnStreamComplete({ provider, model, connectionId, apiKey, comboName, fallbackHistory, requestStartTime, body, stream, finalBody, translatedBody, clientRawRequest, midStreamError, onMidStreamError }) {
|
||||||
const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
|
||||||
const onStreamComplete = (contentObj, usage, ttftAt) => {
|
const onStreamComplete = (contentObj, usage, ttftAt) => {
|
||||||
@@ -88,20 +90,27 @@ export function buildOnStreamComplete({ provider, model, connectionId, apiKey, r
|
|||||||
const safeContent = contentObj?.content || "[Empty streaming response]";
|
const safeContent = contentObj?.content || "[Empty streaming response]";
|
||||||
const safeThinking = contentObj?.thinking || null;
|
const safeThinking = contentObj?.thinking || null;
|
||||||
|
|
||||||
|
// Check if a mid-stream error was detected during streaming (e.g. Qoder quota exceeded)
|
||||||
|
// The stream already returned HTTP 200, so the pre-stream error path in chatCore didn't fire.
|
||||||
|
// Apply cooldown here so the account gets locked for subsequent requests.
|
||||||
|
if (midStreamError?.error && typeof onMidStreamError === "function") {
|
||||||
|
onMidStreamError(midStreamError.error).catch(err => {
|
||||||
|
console.error("[MidStreamError] Failed to apply cooldown:", err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
saveRequestDetail(buildRequestDetail({
|
saveRequestDetail(buildRequestDetail({
|
||||||
provider, model, connectionId,
|
provider, model, connectionId, apiKey, comboName, fallbackHistory,
|
||||||
latency,
|
latency,
|
||||||
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
||||||
request: extractRequestConfig(body, stream),
|
request: extractRequestConfig(body, stream),
|
||||||
providerRequest: finalBody || translatedBody || null,
|
providerRequest: finalBody || translatedBody || null,
|
||||||
providerResponse: safeContent,
|
providerResponse: safeContent,
|
||||||
response: { content: safeContent, thinking: safeThinking, type: "streaming" },
|
response: { content: safeContent, thinking: safeThinking, type: "streaming" },
|
||||||
status: "success"
|
status: midStreamError?.error ? "error" : "success"
|
||||||
}, { id: streamDetailId })).catch(err => {
|
}, { id: streamDetailId })).catch(err => {
|
||||||
console.error("[RequestDetail] Failed to update streaming content:", err.message);
|
console.error("[RequestDetail] Failed to update streaming content:", err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, label: "STREAM USAGE" });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { onStreamComplete, streamDetailId };
|
return { onStreamComplete, streamDetailId };
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ export function getQuotaCooldown(backoffLevel = 0) {
|
|||||||
* @param {number} status - HTTP status code
|
* @param {number} status - HTTP status code
|
||||||
* @param {string} errorText - Error message text
|
* @param {string} errorText - Error message text
|
||||||
* @param {number} backoffLevel - Current backoff level for exponential backoff
|
* @param {number} backoffLevel - Current backoff level for exponential backoff
|
||||||
|
* @param {number} [fixedCooldownMs=0] - When >0, override all cooldowns with this fixed value (ms)
|
||||||
* @returns {{ shouldFallback: boolean, cooldownMs: number, newBackoffLevel?: number }}
|
* @returns {{ shouldFallback: boolean, cooldownMs: number, newBackoffLevel?: number }}
|
||||||
*/
|
*/
|
||||||
export function checkFallbackError(status, errorText, backoffLevel = 0) {
|
export function checkFallbackError(status, errorText, backoffLevel = 0, fixedCooldownMs = 0) {
|
||||||
const lowerError = errorText
|
const lowerError = errorText
|
||||||
? (typeof errorText === "string" ? errorText : JSON.stringify(errorText)).toLowerCase()
|
? (typeof errorText === "string" ? errorText : JSON.stringify(errorText)).toLowerCase()
|
||||||
: "";
|
: "";
|
||||||
@@ -28,6 +29,9 @@ export function checkFallbackError(status, errorText, backoffLevel = 0) {
|
|||||||
for (const rule of ERROR_RULES) {
|
for (const rule of ERROR_RULES) {
|
||||||
// Text-based rule: match substring in error message
|
// Text-based rule: match substring in error message
|
||||||
if (rule.text && lowerError && lowerError.includes(rule.text)) {
|
if (rule.text && lowerError && lowerError.includes(rule.text)) {
|
||||||
|
if (fixedCooldownMs > 0) {
|
||||||
|
return { shouldFallback: true, cooldownMs: fixedCooldownMs, newBackoffLevel: 0 };
|
||||||
|
}
|
||||||
if (rule.backoff) {
|
if (rule.backoff) {
|
||||||
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
|
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
|
||||||
return { shouldFallback: true, cooldownMs: getQuotaCooldown(newLevel), newBackoffLevel: newLevel };
|
return { shouldFallback: true, cooldownMs: getQuotaCooldown(newLevel), newBackoffLevel: newLevel };
|
||||||
@@ -37,6 +41,9 @@ export function checkFallbackError(status, errorText, backoffLevel = 0) {
|
|||||||
|
|
||||||
// Status-based rule: match HTTP status code
|
// Status-based rule: match HTTP status code
|
||||||
if (rule.status && rule.status === status) {
|
if (rule.status && rule.status === status) {
|
||||||
|
if (fixedCooldownMs > 0) {
|
||||||
|
return { shouldFallback: true, cooldownMs: fixedCooldownMs, newBackoffLevel: 0 };
|
||||||
|
}
|
||||||
if (rule.backoff) {
|
if (rule.backoff) {
|
||||||
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
|
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
|
||||||
return { shouldFallback: true, cooldownMs: getQuotaCooldown(newLevel), newBackoffLevel: newLevel };
|
return { shouldFallback: true, cooldownMs: getQuotaCooldown(newLevel), newBackoffLevel: newLevel };
|
||||||
@@ -46,7 +53,8 @@ export function checkFallbackError(status, errorText, backoffLevel = 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default: transient cooldown for any unmatched error
|
// Default: transient cooldown for any unmatched error
|
||||||
return { shouldFallback: true, cooldownMs: TRANSIENT_COOLDOWN_MS };
|
const defaultCooldown = fixedCooldownMs > 0 ? fixedCooldownMs : TRANSIENT_COOLDOWN_MS;
|
||||||
|
return { shouldFallback: true, cooldownMs: defaultCooldown, newBackoffLevel: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ export function getComboModelsFromData(modelStr, combosData) {
|
|||||||
* @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching
|
* @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching
|
||||||
* @returns {Promise<Response>}
|
* @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
|
// Apply rotation strategy if enabled
|
||||||
let rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit);
|
let rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit);
|
||||||
|
|
||||||
@@ -242,70 +242,101 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 lastError = null;
|
||||||
let earliestRetryAfter = null;
|
let earliestRetryAfter = null;
|
||||||
let lastStatus = null;
|
let lastStatus = null;
|
||||||
|
const comboFallbackHistory = [];
|
||||||
|
|
||||||
for (let i = 0; i < rotatedModels.length; i++) {
|
for (let i = 0; i < effectiveModels.length; i++) {
|
||||||
const modelStr = rotatedModels[i];
|
const modelStr = effectiveModels[i];
|
||||||
log.info("COMBO", `Trying model ${i + 1}/${rotatedModels.length}: ${modelStr}`);
|
log.info("COMBO", `Trying model ${i + 1}/${effectiveModels.length}: ${modelStr}`);
|
||||||
|
|
||||||
try {
|
for (let attempt = 0; attempt < effectiveRetryCount; attempt++) {
|
||||||
const result = await handleSingleModel(body, modelStr);
|
if (attempt > 0) {
|
||||||
|
log.info("COMBO", `Retrying model ${modelStr} (attempt ${attempt + 1}/${effectiveRetryCount})`);
|
||||||
// Success (2xx) - return response
|
|
||||||
if (result.ok) {
|
|
||||||
log.info("COMBO", `Model ${modelStr} succeeded`);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract error info from response
|
|
||||||
let errorText = result.statusText || "";
|
|
||||||
let retryAfter = null;
|
|
||||||
try {
|
try {
|
||||||
const errorBody = await result.clone().json();
|
const result = await handleSingleModel(body, modelStr, comboFallbackHistory.length ? [...comboFallbackHistory] : null);
|
||||||
errorText = errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText;
|
|
||||||
retryAfter = errorBody?.retryAfter || null;
|
// Success (2xx) - return response
|
||||||
} catch {
|
if (result.ok) {
|
||||||
// Ignore JSON parse errors
|
log.info("COMBO", `Model ${modelStr} succeeded${attempt > 0 ? ` on retry ${attempt + 1}` : ""}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract error info from response
|
||||||
|
let errorText = result.statusText || "";
|
||||||
|
let retryAfter = null;
|
||||||
|
try {
|
||||||
|
const errorBody = await result.clone().json();
|
||||||
|
errorText = errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText;
|
||||||
|
retryAfter = errorBody?.retryAfter || null;
|
||||||
|
} catch {
|
||||||
|
// Ignore JSON parse errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track earliest retryAfter across all combo models
|
||||||
|
if (retryAfter && (!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter))) {
|
||||||
|
earliestRetryAfter = retryAfter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize error text to string (Worker-safe)
|
||||||
|
if (typeof errorText !== "string") {
|
||||||
|
try { errorText = JSON.stringify(errorText); } catch { errorText = String(errorText); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if should fallback to next model
|
||||||
|
const { shouldFallback, cooldownMs } = checkFallbackError(result.status, errorText);
|
||||||
|
|
||||||
|
if (!shouldFallback) {
|
||||||
|
// Hard failure — no retry, no fallback
|
||||||
|
log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For transient errors (503/502/504), wait for cooldown before retrying/falling through
|
||||||
|
if (cooldownMs && cooldownMs > 0 && cooldownMs <= 5000 &&
|
||||||
|
(result.status === 503 || result.status === 502 || result.status === 504)) {
|
||||||
|
log.info("COMBO", `Model ${modelStr} transient ${result.status}, waiting ${cooldownMs}ms`);
|
||||||
|
await new Promise(r => setTimeout(r, cooldownMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError = errorText || String(result.status);
|
||||||
|
if (!lastStatus) lastStatus = result.status;
|
||||||
|
|
||||||
|
if (attempt < effectiveRetryCount - 1) continue;
|
||||||
|
|
||||||
|
// Exhausted retries for this provider — push history and move to next
|
||||||
|
comboFallbackHistory.push({
|
||||||
|
model: modelStr,
|
||||||
|
status: result.status,
|
||||||
|
error: lastError,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
log.warn("COMBO", `Model ${modelStr} exhausted ${effectiveRetryCount} attempt(s), trying next`, { status: result.status });
|
||||||
|
} catch (error) {
|
||||||
|
// Catch unexpected exceptions to ensure fallback continues
|
||||||
|
lastError = error.message || String(error);
|
||||||
|
if (!lastStatus) lastStatus = 500;
|
||||||
|
|
||||||
|
if (attempt < effectiveRetryCount - 1) {
|
||||||
|
log.warn("COMBO", `Model ${modelStr} threw (attempt ${attempt + 1}/${effectiveRetryCount}), retrying`, { error: lastError });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
comboFallbackHistory.push({
|
||||||
|
model: modelStr,
|
||||||
|
status: 500,
|
||||||
|
error: lastError,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
log.warn("COMBO", `Model ${modelStr} threw error, trying next`, { error: lastError });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track earliest retryAfter across all combo models
|
|
||||||
if (retryAfter && (!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter))) {
|
|
||||||
earliestRetryAfter = retryAfter;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize error text to string (Worker-safe)
|
|
||||||
if (typeof errorText !== "string") {
|
|
||||||
try { errorText = JSON.stringify(errorText); } catch { errorText = String(errorText); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if should fallback to next model
|
|
||||||
const { shouldFallback, cooldownMs } = checkFallbackError(result.status, errorText);
|
|
||||||
|
|
||||||
if (!shouldFallback) {
|
|
||||||
log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status });
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For transient errors (503/502/504), wait for cooldown before falling through
|
|
||||||
// so a briefly-overloaded provider gets a chance to recover rather than being
|
|
||||||
// skipped immediately (fixes: combo falls through on transient 503)
|
|
||||||
if (cooldownMs && cooldownMs > 0 && cooldownMs <= 5000 &&
|
|
||||||
(result.status === 503 || result.status === 502 || result.status === 504)) {
|
|
||||||
log.info("COMBO", `Model ${modelStr} transient ${result.status}, waiting ${cooldownMs}ms before next`);
|
|
||||||
await new Promise(r => setTimeout(r, cooldownMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to next model
|
|
||||||
lastError = errorText || String(result.status);
|
|
||||||
if (!lastStatus) lastStatus = result.status;
|
|
||||||
log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status });
|
|
||||||
} catch (error) {
|
|
||||||
// Catch unexpected exceptions to ensure fallback continues
|
|
||||||
lastError = error.message || String(error);
|
|
||||||
if (!lastStatus) lastStatus = 500;
|
|
||||||
log.warn("COMBO", `Model ${modelStr} threw error, trying next`, { error: lastError });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { translateResponse, initState } from "../translator/index.js";
|
import { translateResponse, initState } from "../translator/index.js";
|
||||||
import { FORMATS } from "../translator/formats.js";
|
import { FORMATS } from "../translator/formats.js";
|
||||||
import { trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js";
|
import { trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js";
|
||||||
import { extractUsage, hasValidUsage, estimateUsage, logUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js";
|
import { extractUsage, hasValidUsage, estimateUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js";
|
||||||
|
import { saveUsageStats } from "../handlers/chatCore/requestDetail.js";
|
||||||
import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.js";
|
import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.js";
|
||||||
import { getOpenAIResponsesEventName, isOpenAIResponsesTerminalEvent, formatIncompleteOpenAIResponsesStreamFailure } from "./responsesStreamHelpers.js";
|
import { getOpenAIResponsesEventName, isOpenAIResponsesTerminalEvent, formatIncompleteOpenAIResponsesStreamFailure } from "./responsesStreamHelpers.js";
|
||||||
import { dbg, isDebugEnabled } from "./debugLog.js";
|
import { dbg, isDebugEnabled } from "./debugLog.js";
|
||||||
@@ -48,7 +49,9 @@ export function createSSEStream(options = {}) {
|
|||||||
connectionId = null,
|
connectionId = null,
|
||||||
body = null,
|
body = null,
|
||||||
onStreamComplete = null,
|
onStreamComplete = null,
|
||||||
apiKey = null
|
apiKey = null,
|
||||||
|
endpoint = null,
|
||||||
|
comboName = null
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
@@ -334,7 +337,7 @@ export function createSSEStream(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasValidUsage(usage)) {
|
if (hasValidUsage(usage)) {
|
||||||
logUsage(provider, usage, model, connectionId, apiKey);
|
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint, comboName });
|
||||||
} else {
|
} else {
|
||||||
appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { });
|
appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { });
|
||||||
}
|
}
|
||||||
@@ -417,7 +420,7 @@ export function createSSEStream(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasValidUsage(state?.usage)) {
|
if (hasValidUsage(state?.usage)) {
|
||||||
logUsage(state.provider || targetFormat, state.usage, model, connectionId, apiKey);
|
saveUsageStats({ provider: state.provider || targetFormat, model, tokens: state.usage, connectionId, apiKey, endpoint, comboName });
|
||||||
} else {
|
} else {
|
||||||
appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { });
|
appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { });
|
||||||
}
|
}
|
||||||
@@ -435,7 +438,7 @@ export function createSSEStream(options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null) {
|
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, endpoint = null, comboName = null) {
|
||||||
return createSSEStream({
|
return createSSEStream({
|
||||||
mode: STREAM_MODE.TRANSLATE,
|
mode: STREAM_MODE.TRANSLATE,
|
||||||
targetFormat,
|
targetFormat,
|
||||||
@@ -447,11 +450,13 @@ export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, p
|
|||||||
connectionId,
|
connectionId,
|
||||||
body,
|
body,
|
||||||
onStreamComplete,
|
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({
|
return createSSEStream({
|
||||||
mode: STREAM_MODE.PASSTHROUGH,
|
mode: STREAM_MODE.PASSTHROUGH,
|
||||||
provider,
|
provider,
|
||||||
@@ -460,6 +465,8 @@ export function createPassthroughStreamWithLogger(provider = null, reqLogger = n
|
|||||||
connectionId,
|
connectionId,
|
||||||
body,
|
body,
|
||||||
onStreamComplete,
|
onStreamComplete,
|
||||||
apiKey
|
apiKey,
|
||||||
|
endpoint,
|
||||||
|
comboName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
* Token Usage Tracking - Extract, normalize, estimate and log token usage
|
* Token Usage Tracking - Extract, normalize, estimate and log token usage
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { saveRequestUsage, appendRequestLog } from "@/lib/usageDb.js";
|
|
||||||
import { FORMATS } from "../translator/formats.js";
|
import { FORMATS } from "../translator/formats.js";
|
||||||
|
|
||||||
// ANSI color codes
|
// ANSI color codes
|
||||||
@@ -299,49 +298,3 @@ export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI
|
|||||||
targetFormat
|
targetFormat
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Log usage with cache info (green color)
|
|
||||||
*/
|
|
||||||
export function logUsage(provider, usage, model = null, connectionId = null, apiKey = null) {
|
|
||||||
if (!usage || typeof usage !== "object") return;
|
|
||||||
|
|
||||||
const p = provider?.toUpperCase() || "UNKNOWN";
|
|
||||||
|
|
||||||
// Support both formats:
|
|
||||||
// - OpenAI: prompt_tokens, completion_tokens
|
|
||||||
// - Claude: input_tokens, output_tokens
|
|
||||||
const inTokens = usage?.prompt_tokens || usage?.input_tokens || 0;
|
|
||||||
const outTokens = usage?.completion_tokens || usage?.output_tokens || 0;
|
|
||||||
const accountPrefix = connectionId ? connectionId.slice(0, 8) + "..." : "unknown";
|
|
||||||
|
|
||||||
let msg = `[${getTimeString()}] 📊 ${COLORS.green}[USAGE] ${p} | in=${inTokens} | out=${outTokens} | account=${accountPrefix}${COLORS.reset}`;
|
|
||||||
|
|
||||||
// Add estimated flag if present
|
|
||||||
if (usage.estimated) {
|
|
||||||
msg += ` ${COLORS.yellow}(estimated)${COLORS.reset}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add cache info if present (unified from different formats)
|
|
||||||
const cacheRead = usage.cache_read_input_tokens || usage.cached_tokens || usage.prompt_tokens_details?.cached_tokens;
|
|
||||||
if (cacheRead) msg += ` | cache_read=${cacheRead}`;
|
|
||||||
|
|
||||||
const cacheCreation = usage.cache_creation_input_tokens;
|
|
||||||
if (cacheCreation) msg += ` | cache_create=${cacheCreation}`;
|
|
||||||
|
|
||||||
const reasoning = usage.reasoning_tokens;
|
|
||||||
if (reasoning) msg += ` | reasoning=${reasoning}`;
|
|
||||||
|
|
||||||
console.log(msg);
|
|
||||||
|
|
||||||
// Save to usage DB
|
|
||||||
const tokens = {
|
|
||||||
prompt_tokens: inTokens,
|
|
||||||
completion_tokens: outTokens,
|
|
||||||
cache_read_input_tokens: cacheRead || 0,
|
|
||||||
cache_creation_input_tokens: cacheCreation || 0,
|
|
||||||
reasoning_tokens: reasoning || 0
|
|
||||||
};
|
|
||||||
saveRequestUsage({ model, provider, connectionId, tokens, apiKey: apiKey || undefined }).catch(() => { });
|
|
||||||
appendRequestLog({ model, provider, connectionId, tokens, status: "200 OK" }).catch(() => { });
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@next/third-parties": "^16.2.9",
|
"@next/third-parties": "^16.2.9",
|
||||||
|
"@tailwindcss/postcss": "^4.3.1",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"confbox": "^0.2.4",
|
"confbox": "^0.2.4",
|
||||||
@@ -35,6 +36,8 @@
|
|||||||
"node-machine-id": "^1.1.12",
|
"node-machine-id": "^1.1.12",
|
||||||
"open": "^11.0.0",
|
"open": "^11.0.0",
|
||||||
"ora": "^9.1.0",
|
"ora": "^9.1.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-is": "^16.13.1",
|
"react-is": "^16.13.1",
|
||||||
@@ -42,6 +45,7 @@
|
|||||||
"selfsigned": "^5.5.0",
|
"selfsigned": "^5.5.0",
|
||||||
"socks-proxy-agent": "^8.0.5",
|
"socks-proxy-agent": "^8.0.5",
|
||||||
"sql.js": "^1.14.1",
|
"sql.js": "^1.14.1",
|
||||||
|
"tailwindcss": "^4",
|
||||||
"undici": "^7.19.2",
|
"undici": "^7.19.2",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"zustand": "^5.0.10"
|
"zustand": "^5.0.10"
|
||||||
@@ -51,10 +55,7 @@
|
|||||||
},
|
},
|
||||||
"comment_better_sqlite3": "kept in optionalDependencies so npm install doesn't fail on systems without build tools — sql.js is used as fallback at runtime",
|
"comment_better_sqlite3": "kept in optionalDependencies so npm install doesn't fail on systems without build tools — sql.js is used as fallback at runtime",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6"
|
||||||
"postcss": "^8.5.6",
|
|
||||||
"tailwindcss": "^4"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,8 +121,12 @@ export default function CombosPage() {
|
|||||||
try {
|
try {
|
||||||
const updated = { ...comboStrategies };
|
const updated = { ...comboStrategies };
|
||||||
const next = { ...(updated[comboName] || {}), ...patch };
|
const next = { ...(updated[comboName] || {}), ...patch };
|
||||||
// Prune to keep settings clean: default fallback with no extras = no entry.
|
// Prune to keep settings clean: only store entry when non-default values exist.
|
||||||
if (!next.fallbackStrategy || next.fallbackStrategy === "fallback") {
|
const isDefault =
|
||||||
|
(!next.fallbackStrategy || next.fallbackStrategy === "fallback") &&
|
||||||
|
(!next.retryCount || next.retryCount === 1) &&
|
||||||
|
(next.maxFallbackDepth == null);
|
||||||
|
if (isDefault) {
|
||||||
delete updated[comboName];
|
delete updated[comboName];
|
||||||
} else {
|
} else {
|
||||||
updated[comboName] = next;
|
updated[comboName] = next;
|
||||||
@@ -242,9 +246,12 @@ const STRATEGY_OPTIONS = [
|
|||||||
|
|
||||||
function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy }) {
|
function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy }) {
|
||||||
const [showJudgeSelect, setShowJudgeSelect] = useState(false);
|
const [showJudgeSelect, setShowJudgeSelect] = useState(false);
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
const current = strategy.fallbackStrategy || "fallback";
|
const current = strategy.fallbackStrategy || "fallback";
|
||||||
const judge = strategy.judgeModel || "";
|
const judge = strategy.judgeModel || "";
|
||||||
const isFusion = current === "fusion";
|
const isFusion = current === "fusion";
|
||||||
|
const retryCount = strategy.retryCount ?? 1;
|
||||||
|
const maxFallbackDepth = strategy.maxFallbackDepth ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="sm" className="group">
|
<Card padding="sm" className="group">
|
||||||
@@ -299,13 +306,70 @@ function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy
|
|||||||
{/* Actions */}
|
{/* 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">
|
<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 */}
|
{/* Strategy selector — always visible */}
|
||||||
<div className="w-full sm:w-[200px]">
|
<div className="flex w-full flex-col gap-1.5 sm:w-[200px]">
|
||||||
<Select
|
<div className="flex items-center gap-1">
|
||||||
options={STRATEGY_OPTIONS}
|
<Select
|
||||||
value={current}
|
options={STRATEGY_OPTIONS}
|
||||||
onChange={(e) => onSetStrategy({ fallbackStrategy: e.target.value })}
|
value={current}
|
||||||
selectClassName="py-1.5 text-xs"
|
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>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-1 sm:flex">
|
<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 { attributes, listeners, setNodeRef, transform, isDragging } = useSortable({ id });
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
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); }
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
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 */}
|
{/* Drag handle */}
|
||||||
<button
|
<button
|
||||||
@@ -399,6 +474,12 @@ function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMove
|
|||||||
{/* Index badge */}
|
{/* Index badge */}
|
||||||
<span className="text-[10px] font-medium text-text-muted w-3 text-center shrink-0">{index + 1}</span>
|
<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 */}
|
{/* Inline editable model value */}
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<input
|
<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 }) {
|
function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindFilter = null }) {
|
||||||
// Initialize state with combo values - key prop on parent handles reset on remount
|
// Initialize state with combo values - key prop on parent handles reset on remount
|
||||||
const [name, setName] = useState(combo?.name || "");
|
const [name, setName] = useState(combo?.name || "");
|
||||||
@@ -460,6 +562,14 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF
|
|||||||
const [nameError, setNameError] = useState("");
|
const [nameError, setNameError] = useState("");
|
||||||
const [modelAliases, setModelAliases] = 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(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||||
@@ -557,81 +667,139 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF
|
|||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={isEdit ? "Edit Combo" : "Create Combo"}
|
title={isEdit ? "Edit Combo" : "Create Combo"}
|
||||||
|
size="full"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex min-h-0 gap-5">
|
||||||
{/* Name */}
|
{/* Left: form */}
|
||||||
<div>
|
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||||
<Input
|
{/* Name */}
|
||||||
label="Combo Name"
|
<div>
|
||||||
value={name}
|
<Input
|
||||||
onChange={handleNameChange}
|
label="Combo Name"
|
||||||
placeholder="my-combo"
|
value={name}
|
||||||
error={nameError}
|
onChange={handleNameChange}
|
||||||
/>
|
placeholder="my-combo"
|
||||||
<p className="text-[10px] text-text-muted mt-0.5">
|
error={nameError}
|
||||||
Only letters, numbers, -, _ and . allowed
|
/>
|
||||||
</p>
|
<p className="text-[10px] text-text-muted mt-0.5">
|
||||||
</div>
|
Only letters, numbers, -, _ and . allowed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Models */}
|
{/* Models */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1.5 block">Models</label>
|
<label className="text-sm font-medium mb-1.5 block">Models</label>
|
||||||
|
|
||||||
{models.length === 0 ? (
|
{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]">
|
<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>
|
<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>
|
<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)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</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 */}
|
{/* Add Model button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowModelSelect(true)}
|
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"
|
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>
|
<span className="material-symbols-outlined text-[16px]">add</span>
|
||||||
Add Model
|
Add Model
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Right: real-time preview */}
|
||||||
<div className="flex flex-col gap-2 pt-1 sm:flex-row">
|
<div className="hidden sm:flex w-56 shrink-0 flex-col gap-2 border-l border-black/8 dark:border-white/8 pl-5">
|
||||||
<Button onClick={onClose} variant="ghost" fullWidth size="sm">
|
<div className="mb-0.5">
|
||||||
Cancel
|
<p className="text-xs font-semibold text-text-main">Preview</p>
|
||||||
</Button>
|
<p className="text-[10px] text-text-muted mt-0.5">Real-time provider & model mapping</p>
|
||||||
<Button
|
</div>
|
||||||
onClick={handleSave}
|
|
||||||
fullWidth
|
{(() => {
|
||||||
size="sm"
|
const activeModels = models.filter(m => getProviderStatus(m) === true);
|
||||||
disabled={!name.trim() || !!nameError || saving}
|
if (activeModels.length === 0) {
|
||||||
>
|
return (
|
||||||
{saving ? "Saving..." : isEdit ? "Save" : "Create"}
|
<div className="flex flex-1 flex-col items-center justify-center py-8 text-center">
|
||||||
</Button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export default function APIPageClient({ machineId }) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
const [newKeyName, setNewKeyName] = useState("");
|
const [newKeyName, setNewKeyName] = useState("");
|
||||||
|
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false);
|
||||||
|
const [customKeyName, setCustomKeyName] = useState("");
|
||||||
|
const [customKeyValue, setCustomKeyValue] = useState("");
|
||||||
const [createdKey, setCreatedKey] = useState(null);
|
const [createdKey, setCreatedKey] = useState(null);
|
||||||
const [confirmState, setConfirmState] = 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) => {
|
const handleDeleteKey = async (id) => {
|
||||||
setConfirmState({
|
setConfirmState({
|
||||||
title: "Delete API Key",
|
title: "Delete API Key",
|
||||||
@@ -1114,9 +1141,14 @@ export default function APIPageClient({ machineId }) {
|
|||||||
<span className="material-symbols-outlined text-primary">vpn_key</span>
|
<span className="material-symbols-outlined text-primary">vpn_key</span>
|
||||||
API Keys
|
API Keys
|
||||||
</h2>
|
</h2>
|
||||||
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
<div className="flex items-center gap-2">
|
||||||
Create Key
|
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
||||||
</Button>
|
Create Key
|
||||||
|
</Button>
|
||||||
|
<Button icon="vpn_key" variant="secondary" onClick={() => setShowCustomKeyModal(true)}>
|
||||||
|
Add Custom Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pb-4 mb-4 border-b border-border">
|
<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>
|
</div>
|
||||||
<p className="text-text-main font-medium mb-1">No API keys yet</p>
|
<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>
|
<p className="text-sm text-text-muted mb-4">Create your first API key to get started</p>
|
||||||
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
<div className="flex items-center gap-2">
|
||||||
Create Key
|
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
||||||
</Button>
|
Create Key
|
||||||
|
</Button>
|
||||||
|
<Button icon="vpn_key" variant="secondary" onClick={() => setShowCustomKeyModal(true)}>
|
||||||
|
Add Custom Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -1415,6 +1452,48 @@ export default function APIPageClient({ machineId }) {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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 */}
|
{/* Created Key Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={!!createdKey}
|
isOpen={!!createdKey}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { Button, Badge, Input, Modal, Select } from "@/shared/components";
|
import { Button, Badge, Input, Modal, Select } from "@/shared/components";
|
||||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||||
@@ -38,6 +38,9 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
|||||||
});
|
});
|
||||||
const [cloudflareData, setCloudflareData] = useState({ accountId: "" });
|
const [cloudflareData, setCloudflareData] = useState({ accountId: "" });
|
||||||
const [region, setRegion] = useState(defaultRegion);
|
const [region, setRegion] = useState(defaultRegion);
|
||||||
|
const [extraBodyParams, setExtraBodyParams] = useState("");
|
||||||
|
const [extraHeaderParams, setExtraHeaderParams] = useState("");
|
||||||
|
const [jsonError, setJsonError] = useState("");
|
||||||
const [validating, setValidating] = useState(false);
|
const [validating, setValidating] = useState(false);
|
||||||
const [validationResult, setValidationResult] = useState(null);
|
const [validationResult, setValidationResult] = useState(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -45,25 +48,54 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
|||||||
const [bulkText, setBulkText] = useState("");
|
const [bulkText, setBulkText] = useState("");
|
||||||
const [bulkResult, setBulkResult] = useState(null); // { success, failed }
|
const [bulkResult, setBulkResult] = useState(null); // { success, failed }
|
||||||
|
|
||||||
|
const handleJsonChange = useCallback((value, setter) => {
|
||||||
|
setter(value);
|
||||||
|
if (value.trim()) {
|
||||||
|
try {
|
||||||
|
JSON.parse(value);
|
||||||
|
setJsonError("");
|
||||||
|
} catch {
|
||||||
|
setJsonError("Invalid JSON");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setJsonError("");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const parseJsonSafe = (str) => {
|
||||||
|
if (!str.trim()) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(str);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const buildProviderSpecificData = () => {
|
const buildProviderSpecificData = () => {
|
||||||
|
const data = {};
|
||||||
if (isOllamaLocal && formData.ollamaHostUrl.trim()) {
|
if (isOllamaLocal && formData.ollamaHostUrl.trim()) {
|
||||||
return { baseUrl: formData.ollamaHostUrl.trim() };
|
data.baseUrl = formData.ollamaHostUrl.trim();
|
||||||
}
|
}
|
||||||
if (isAzure) {
|
if (isAzure) {
|
||||||
return {
|
Object.assign(data, {
|
||||||
azureEndpoint: azureData.azureEndpoint,
|
azureEndpoint: azureData.azureEndpoint,
|
||||||
apiVersion: azureData.apiVersion,
|
apiVersion: azureData.apiVersion,
|
||||||
deployment: azureData.deployment,
|
deployment: azureData.deployment,
|
||||||
organization: azureData.organization,
|
organization: azureData.organization,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
if (isCloudflareAi) {
|
if (isCloudflareAi) {
|
||||||
return { accountId: cloudflareData.accountId };
|
data.accountId = cloudflareData.accountId;
|
||||||
}
|
}
|
||||||
if (providerRegions && region) {
|
if (providerRegions && region) {
|
||||||
return { region };
|
data.region = region;
|
||||||
}
|
}
|
||||||
return undefined;
|
// Extra params
|
||||||
|
const parsedBody = parseJsonSafe(extraBodyParams);
|
||||||
|
const parsedHeaders = parseJsonSafe(extraHeaderParams);
|
||||||
|
if (parsedBody) data.bodyParams = parsedBody;
|
||||||
|
if (parsedHeaders) data.headerParams = parsedHeaders;
|
||||||
|
return Object.keys(data).length > 0 ? data : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleValidate = async () => {
|
const handleValidate = async () => {
|
||||||
@@ -295,6 +327,35 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Extra Request Parameters */}
|
||||||
|
<details className="group">
|
||||||
|
<summary className="cursor-pointer text-xs font-medium text-text-muted hover:text-primary transition-colors select-none list-none flex items-center gap-1">
|
||||||
|
<span className="material-symbols-outlined text-[14px] group-open:rotate-90 transition-transform">chevron_right</span>
|
||||||
|
Extra Request Parameters
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-muted block mb-1">Extra Body Params <span className="text-text-muted/60">(JSON object, merged into request body)</span></label>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded border border-accent/30 bg-sidebar p-2 text-xs font-mono resize-y min-h-[60px] focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
placeholder={'{\n "max_tokens": 1,\n "stream": true\n}'}
|
||||||
|
value={extraBodyParams}
|
||||||
|
onChange={(e) => handleJsonChange(e.target.value, setExtraBodyParams)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-muted block mb-1">Extra Header Params <span className="text-text-muted/60">(JSON object, merged into request headers)</span></label>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded border border-accent/30 bg-sidebar p-2 text-xs font-mono resize-y min-h-[60px] focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
placeholder={'{\n "X-Custom-Header": "value"\n}'}
|
||||||
|
value={extraHeaderParams}
|
||||||
|
onChange={(e) => handleJsonChange(e.target.value, setExtraHeaderParams)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{jsonError && <p className="text-xs text-red-500">{jsonError}</p>}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
{isAzure && (
|
{isAzure && (
|
||||||
<div className="bg-sidebar/50 p-4 rounded-lg border border-accent/20">
|
<div className="bg-sidebar/50 p-4 rounded-lg border border-accent/20">
|
||||||
<h3 className="font-semibold mb-3 text-sm">Azure OpenAI Configuration</h3>
|
<h3 className="font-semibold mb-3 text-sm">Azure OpenAI Configuration</h3>
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import { getStatusVariant as getConnectionStatusVariant } from "@/shared/utils/c
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { Badge, Toggle, Tooltip } from "@/shared/components";
|
import { Badge, Toggle, Tooltip } from "@/shared/components";
|
||||||
import CooldownTimer from "./CooldownTimer";
|
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 [showProxyDropdown, setShowProxyDropdown] = useState(false);
|
||||||
const [updatingProxy, setUpdatingProxy] = useState(false);
|
const [updatingProxy, setUpdatingProxy] = useState(false);
|
||||||
const proxyDropdownRef = useRef(null);
|
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 proxyPoolMap = new Map((proxyPools || []).map((pool) => [pool.id, pool]));
|
||||||
const boundProxyPoolId = connection.providerSpecificData?.proxyPoolId || null;
|
const boundProxyPoolId = connection.providerSpecificData?.proxyPoolId || null;
|
||||||
@@ -56,6 +60,23 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
|||||||
return () => document.removeEventListener("mousedown", handler);
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
}, [showProxyDropdown]);
|
}, [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) => {
|
const handleSelectProxy = async (poolId) => {
|
||||||
setUpdatingProxy(true);
|
setUpdatingProxy(true);
|
||||||
try {
|
try {
|
||||||
@@ -133,8 +154,18 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<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 */}
|
{/* Priority arrows */}
|
||||||
<div className="flex shrink-0 flex-col">
|
<div className="flex shrink-0 flex-col">
|
||||||
<button
|
<button
|
||||||
@@ -205,10 +236,17 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div className="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-end">
|
<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 */}
|
{/* Proxy button with inline dropdown */}
|
||||||
{(proxyPools || []).length > 0 && (
|
{(proxyPools || []).length > 0 && (
|
||||||
<div className="relative" ref={proxyDropdownRef}>
|
<div className="relative" ref={proxyDropdownRef}>
|
||||||
@@ -254,6 +292,45 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
|||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</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">
|
<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="material-symbols-outlined text-[18px]">edit</span>
|
||||||
<span className="text-[10px] leading-tight">Edit</span>
|
<span className="text-[10px] leading-tight">Edit</span>
|
||||||
@@ -311,4 +388,6 @@ ConnectionRow.propTypes = {
|
|||||||
on: PropTypes.bool,
|
on: PropTypes.bool,
|
||||||
onToggle: PropTypes.func,
|
onToggle: PropTypes.func,
|
||||||
}),
|
}),
|
||||||
|
isSelected: PropTypes.bool,
|
||||||
|
onToggleSelect: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,7 +57,11 @@ export default function ProviderDetailPage() {
|
|||||||
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
|
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
|
||||||
const [providerStrategy, setProviderStrategy] = useState(null);
|
const [providerStrategy, setProviderStrategy] = useState(null);
|
||||||
const [providerStickyLimit, setProviderStickyLimit] = useState("");
|
const [providerStickyLimit, setProviderStickyLimit] = useState("");
|
||||||
|
const [accountCooldown, setAccountCooldown] = useState("");
|
||||||
const [thinkingMode, setThinkingMode] = useState("auto");
|
const [thinkingMode, setThinkingMode] = useState("auto");
|
||||||
|
const [extraBodyParamsStr, setExtraBodyParamsStr] = useState("");
|
||||||
|
const [extraHeaderParamsStr, setExtraHeaderParamsStr] = useState("");
|
||||||
|
const [extraParamsJsonError, setExtraParamsJsonError] = useState("");
|
||||||
const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} });
|
const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} });
|
||||||
const [suggestedModels, setSuggestedModels] = useState([]);
|
const [suggestedModels, setSuggestedModels] = useState([]);
|
||||||
const [kiloFreeModels, setKiloFreeModels] = useState([]);
|
const [kiloFreeModels, setKiloFreeModels] = useState([]);
|
||||||
@@ -270,9 +274,15 @@ export default function ProviderDetailPage() {
|
|||||||
const override = (settingsData.providerStrategies || {})[providerId] || {};
|
const override = (settingsData.providerStrategies || {})[providerId] || {};
|
||||||
setProviderStrategy(override.fallbackStrategy || null);
|
setProviderStrategy(override.fallbackStrategy || null);
|
||||||
setProviderStickyLimit(override.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "1");
|
setProviderStickyLimit(override.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "1");
|
||||||
|
// Load global account cooldown setting
|
||||||
|
setAccountCooldown(settingsData.accountCooldownSeconds != null ? String(settingsData.accountCooldownSeconds) : "0");
|
||||||
// Load per-provider thinking config
|
// Load per-provider thinking config
|
||||||
const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {};
|
const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {};
|
||||||
setThinkingMode(thinkingCfg.mode || "auto");
|
setThinkingMode(thinkingCfg.mode || "auto");
|
||||||
|
// Load per-provider extra params
|
||||||
|
const extraParams = (settingsData.providerExtraParams || {})[providerId] || {};
|
||||||
|
setExtraBodyParamsStr(extraParams.bodyParams ? JSON.stringify(extraParams.bodyParams, null, 2) : "");
|
||||||
|
setExtraHeaderParamsStr(extraParams.headerParams ? JSON.stringify(extraParams.headerParams, null, 2) : "");
|
||||||
const apCfg = settingsData.claudeAutoPing || {};
|
const apCfg = settingsData.claudeAutoPing || {};
|
||||||
setAutoPing({ enabled: apCfg.enabled === true, connections: apCfg.connections || {} });
|
setAutoPing({ enabled: apCfg.enabled === true, connections: apCfg.connections || {} });
|
||||||
if (nodesRes.ok) {
|
if (nodesRes.ok) {
|
||||||
@@ -361,6 +371,20 @@ export default function ProviderDetailPage() {
|
|||||||
saveProviderStrategy("round-robin", value);
|
saveProviderStrategy("round-robin", value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveAccountCooldown = async (value) => {
|
||||||
|
const num = Math.max(0, parseInt(value, 10) || 0);
|
||||||
|
setAccountCooldown(String(num));
|
||||||
|
try {
|
||||||
|
await fetch("/api/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ accountCooldownSeconds: num }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error saving account cooldown:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const saveThinkingConfig = async (mode) => {
|
const saveThinkingConfig = async (mode) => {
|
||||||
try {
|
try {
|
||||||
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
|
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
|
||||||
@@ -387,6 +411,48 @@ export default function ProviderDetailPage() {
|
|||||||
saveThinkingConfig(mode);
|
saveThinkingConfig(mode);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExtraParamsChange = (field, value) => {
|
||||||
|
const setter = field === "bodyParams" ? setExtraBodyParamsStr : setExtraHeaderParamsStr;
|
||||||
|
setter(value);
|
||||||
|
if (value.trim()) {
|
||||||
|
try { JSON.parse(value); setExtraParamsJsonError(""); }
|
||||||
|
catch { setExtraParamsJsonError("Invalid JSON"); }
|
||||||
|
} else {
|
||||||
|
setExtraParamsJsonError("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveExtraParams = async () => {
|
||||||
|
let parsedBody, parsedHeaders;
|
||||||
|
try { parsedBody = extraBodyParamsStr.trim() ? JSON.parse(extraBodyParamsStr) : undefined; }
|
||||||
|
catch { setExtraParamsJsonError("Invalid JSON in Body Params"); return; }
|
||||||
|
try { parsedHeaders = extraHeaderParamsStr.trim() ? JSON.parse(extraHeaderParamsStr) : undefined; }
|
||||||
|
catch { setExtraParamsJsonError("Invalid JSON in Header Params"); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
|
||||||
|
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
||||||
|
const current = settingsData.providerExtraParams || {};
|
||||||
|
const updated = { ...current };
|
||||||
|
const entry = {};
|
||||||
|
if (parsedBody) entry.bodyParams = parsedBody;
|
||||||
|
if (parsedHeaders) entry.headerParams = parsedHeaders;
|
||||||
|
if (Object.keys(entry).length > 0) {
|
||||||
|
updated[providerId] = entry;
|
||||||
|
} else {
|
||||||
|
delete updated[providerId];
|
||||||
|
}
|
||||||
|
await fetch("/api/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ providerExtraParams: updated }),
|
||||||
|
});
|
||||||
|
setExtraParamsJsonError("");
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error saving extra params:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const saveAutoPing = async (next) => {
|
const saveAutoPing = async (next) => {
|
||||||
setAutoPing(next);
|
setAutoPing(next);
|
||||||
try {
|
try {
|
||||||
@@ -404,6 +470,7 @@ export default function ProviderDetailPage() {
|
|||||||
saveAutoPing({ ...autoPing, connections: { ...autoPing.connections, [connectionId]: on } });
|
saveAutoPing({ ...autoPing, connections: { ...autoPing.connections, [connectionId]: on } });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConnections();
|
fetchConnections();
|
||||||
fetchAliases();
|
fetchAliases();
|
||||||
@@ -765,6 +832,48 @@ export default function ProviderDetailPage() {
|
|||||||
setBulkProxyPoolId("__none__");
|
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(() => {
|
useEffect(() => {
|
||||||
setSelectedConnectionIds((prev) => prev.filter((id) => connections.some((conn) => conn.id === id)));
|
setSelectedConnectionIds((prev) => prev.filter((id) => connections.some((conn) => conn.id === id)));
|
||||||
}, [connections]);
|
}, [connections]);
|
||||||
@@ -819,7 +928,8 @@ export default function ProviderDetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleApplySinglePool = (proxyPoolId) => {
|
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);
|
return applyProxyAssignments(targets);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -829,7 +939,8 @@ export default function ProviderDetailPage() {
|
|||||||
alert("No active proxy pools available.");
|
alert("No active proxy pools available.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const targets = connections.map((c, i) => ({
|
const scope = selectedConnectionIds.length > 0 ? selectedConnections : connections;
|
||||||
|
const targets = scope.map((c, i) => ({
|
||||||
connectionId: c.id,
|
connectionId: c.id,
|
||||||
proxyPoolId: activePools[i % activePools.length].id,
|
proxyPoolId: activePools[i % activePools.length].id,
|
||||||
}));
|
}));
|
||||||
@@ -841,6 +952,20 @@ export default function ProviderDetailPage() {
|
|||||||
|
|
||||||
const connectionsList = (
|
const connectionsList = (
|
||||||
<div className="flex min-w-0 flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
<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
|
{connections
|
||||||
.map((conn, index) => (
|
.map((conn, index) => (
|
||||||
<div key={conn.id} className="flex min-w-0 items-stretch">
|
<div key={conn.id} className="flex min-w-0 items-stretch">
|
||||||
@@ -882,6 +1007,8 @@ export default function ProviderDetailPage() {
|
|||||||
}}
|
}}
|
||||||
onDelete={() => handleDelete(conn.id)}
|
onDelete={() => handleDelete(conn.id)}
|
||||||
oneByOneStatus={oneByOneResults[conn.id] || null}
|
oneByOneStatus={oneByOneResults[conn.id] || null}
|
||||||
|
isSelected={isSelected(conn.id)}
|
||||||
|
onToggleSelect={toggleSelectConnection}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -895,7 +1022,7 @@ export default function ProviderDetailPage() {
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={showBulkProxyModal}
|
isOpen={showBulkProxyModal}
|
||||||
onClose={closeBulkProxyModal}
|
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 gap-3">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -1377,6 +1504,58 @@ export default function ProviderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Account Cooldown — global setting, visible on every provider page */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs text-text-muted font-medium">Account Cooldown</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={accountCooldown}
|
||||||
|
onChange={(e) => setAccountCooldown(e.target.value)}
|
||||||
|
onBlur={(e) => saveAccountCooldown(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") saveAccountCooldown(e.target.value); }}
|
||||||
|
placeholder="0"
|
||||||
|
className="w-16 px-2 py-1 text-xs border border-border rounded-md bg-background focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-text-muted">sec</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-text-muted/50">
|
||||||
|
{accountCooldown === "0" || accountCooldown === "" ? "(exponential backoff)" : "(global, fixed)"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Extra Request Parameters (provider-level) */}
|
||||||
|
<details className="group">
|
||||||
|
<summary className="cursor-pointer text-xs font-medium text-text-muted hover:text-primary transition-colors select-none list-none flex items-center gap-1">
|
||||||
|
<span className="material-symbols-outlined text-[14px] group-open:rotate-90 transition-transform">chevron_right</span>
|
||||||
|
Extra Request Parameters
|
||||||
|
<span className="text-text-muted/50 font-normal">(applies to all connections)</span>
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 flex flex-col gap-2 min-w-[280px]">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-muted block mb-1">Extra Body Params <span className="text-text-muted/60">(JSON object, merged into request body)</span></label>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded border border-accent/30 bg-sidebar p-2 text-xs font-mono resize-y min-h-[60px] focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
placeholder={'{\n "max_tokens": 1,\n "stream": true\n}'}
|
||||||
|
value={extraBodyParamsStr}
|
||||||
|
onChange={(e) => handleExtraParamsChange("bodyParams", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-muted block mb-1">Extra Header Params <span className="text-text-muted/60">(JSON object, merged into request headers)</span></label>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded border border-accent/30 bg-sidebar p-2 text-xs font-mono resize-y min-h-[60px] focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
placeholder={'{\n "X-Custom-Header": "value"\n}'}
|
||||||
|
value={extraHeaderParamsStr}
|
||||||
|
onChange={(e) => handleExtraParamsChange("headerParams", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{extraParamsJsonError && <p className="text-xs text-red-500">{extraParamsJsonError}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" onClick={saveExtraParams}>Save</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1446,6 +1625,55 @@ export default function ProviderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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}
|
{connectionsList}
|
||||||
{!isCompatible && (
|
{!isCompatible && (
|
||||||
<div className="mt-4 grid grid-cols-1 gap-2 sm:flex">
|
<div className="mt-4 grid grid-cols-1 gap-2 sm:flex">
|
||||||
|
|||||||
@@ -100,20 +100,32 @@ export default function RequestDetailsTab() {
|
|||||||
const [selectedDetail, setSelectedDetail] = useState(null);
|
const [selectedDetail, setSelectedDetail] = useState(null);
|
||||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
const [providers, setProviders] = useState([]);
|
const [providers, setProviders] = useState([]);
|
||||||
|
const [accounts, setAccounts] = useState([]);
|
||||||
|
const [apiKeys, setApiKeys] = useState([]);
|
||||||
const [providerNameCache, setProviderNameCache] = useState(null);
|
const [providerNameCache, setProviderNameCache] = useState(null);
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
provider: "",
|
provider: "",
|
||||||
|
model: "",
|
||||||
|
status: "",
|
||||||
|
connectionId: "",
|
||||||
|
apiKey: "",
|
||||||
|
comboName: "",
|
||||||
|
hasFallback: "",
|
||||||
startDate: "",
|
startDate: "",
|
||||||
endDate: ""
|
endDate: ""
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchProviders = useCallback(async () => {
|
const fetchProviders = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/usage/providers");
|
const [provRes, connRes, keysRes, cache] = await Promise.all([
|
||||||
const data = await res.json();
|
fetch("/api/usage/providers").then(r => r.json()),
|
||||||
setProviders(data.providers || []);
|
fetch("/api/providers").then(r => r.json()),
|
||||||
|
fetch("/api/keys").then(r => r.json()),
|
||||||
const cache = await fetchProviderNames();
|
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);
|
setProviderNameCache(cache.providerNameCache);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch providers:", error);
|
console.error("Failed to fetch providers:", error);
|
||||||
@@ -128,6 +140,12 @@ export default function RequestDetailsTab() {
|
|||||||
pageSize: pagination.pageSize.toString()
|
pageSize: pagination.pageSize.toString()
|
||||||
});
|
});
|
||||||
if (filters.provider) params.append("provider", filters.provider);
|
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.startDate) params.append("startDate", filters.startDate);
|
||||||
if (filters.endDate) params.append("endDate", filters.endDate);
|
if (filters.endDate) params.append("endDate", filters.endDate);
|
||||||
|
|
||||||
@@ -165,71 +183,138 @@ export default function RequestDetailsTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClearFilters = () => {
|
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 (
|
return (
|
||||||
<div className="flex min-w-0 flex-col gap-6">
|
<div className="flex min-w-0 flex-col gap-6">
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex min-w-0 flex-col gap-2">
|
{/* Row 1: Provider, Model, Status, Has Fallback */}
|
||||||
<label htmlFor="provider-filter" className="text-sm font-medium text-text-main">Provider</label>
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<select
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
id="provider-filter"
|
<label className="text-xs font-semibold uppercase tracking-wide text-text-muted">Provider</label>
|
||||||
value={filters.provider}
|
<select
|
||||||
onChange={(e) => setFilters({ ...filters, provider: e.target.value })}
|
value={filters.provider}
|
||||||
className={cn(
|
onChange={(e) => setFilters(f => ({ ...f, provider: e.target.value }))}
|
||||||
"h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
|
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")}
|
||||||
"text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20",
|
style={{ colorScheme: 'auto' }}
|
||||||
"w-full min-w-0 cursor-pointer"
|
>
|
||||||
)}
|
<option value="">All Providers</option>
|
||||||
style={{ colorScheme: 'auto' }}
|
{providers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
>
|
</select>
|
||||||
<option value="">All Providers</option>
|
</div>
|
||||||
{providers.map((provider) => (
|
|
||||||
<option key={provider.id} value={provider.id}>
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
{provider.name}
|
<label className="text-xs font-semibold uppercase tracking-wide text-text-muted">Model</label>
|
||||||
</option>
|
<input
|
||||||
))}
|
type="text"
|
||||||
</select>
|
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>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-col gap-2">
|
{/* Row 2: Account, API Key, Combo Name, Date range, Clear */}
|
||||||
<label htmlFor="start-date-filter" className="text-sm font-medium text-text-main">Start Date</label>
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
<input
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
id="start-date-filter"
|
<label className="text-xs font-semibold uppercase tracking-wide text-text-muted">Account</label>
|
||||||
type="datetime-local"
|
<select
|
||||||
value={filters.startDate}
|
value={filters.connectionId}
|
||||||
onChange={(e) => setFilters({ ...filters, startDate: e.target.value })}
|
onChange={(e) => setFilters(f => ({ ...f, connectionId: e.target.value }))}
|
||||||
className={cn(
|
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")}
|
||||||
"h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
|
style={{ colorScheme: 'auto' }}
|
||||||
"w-full min-w-0 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20"
|
>
|
||||||
)}
|
<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>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-col gap-2">
|
{/* Clear button + active filter count */}
|
||||||
<label htmlFor="end-date-filter" className="text-sm font-medium text-text-main">End Date</label>
|
<div className="flex items-center justify-between">
|
||||||
<input
|
<span className="text-xs text-text-muted">
|
||||||
id="end-date-filter"
|
{hasActiveFilters
|
||||||
type="datetime-local"
|
? `${Object.values(filters).filter(v => v !== "").length} filter${Object.values(filters).filter(v => v !== "").length > 1 ? "s" : ""} active`
|
||||||
value={filters.endDate}
|
: "No filters"}
|
||||||
onChange={(e) => setFilters({ ...filters, endDate: e.target.value })}
|
</span>
|
||||||
className={cn(
|
<Button variant="ghost" size="sm" onClick={handleClearFilters} disabled={!hasActiveFilters}>
|
||||||
"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>
|
|
||||||
|
|
||||||
<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 Filters
|
Clear Filters
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,22 +323,25 @@ export default function RequestDetailsTab() {
|
|||||||
|
|
||||||
<Card padding="none">
|
<Card padding="none">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[880px]">
|
<table className="w-full min-w-[1100px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-black/5 dark:border-white/5">
|
<tr className="border-b border-black/5 dark:border-white/5 bg-bg-subtle">
|
||||||
<th className="text-left p-4 text-sm font-semibold text-text-main">Timestamp</th>
|
<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-sm font-semibold text-text-main">Model</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-sm font-semibold text-text-main">Provider</th>
|
<th className="text-left p-4 text-xs font-semibold uppercase tracking-wide text-text-muted">Model</th>
|
||||||
<th className="text-right p-4 text-sm font-semibold text-text-main">Input Tokens</th>
|
<th className="text-left p-4 text-xs font-semibold uppercase tracking-wide text-text-muted">Provider</th>
|
||||||
<th className="text-right p-4 text-sm font-semibold text-text-main">Output Tokens</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-sm font-semibold text-text-main">Latency</th>
|
<th className="text-left p-4 text-xs font-semibold uppercase tracking-wide text-text-muted">API Key</th>
|
||||||
<th className="text-center p-4 text-sm font-semibold text-text-main">Action</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr>
|
<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">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<span className="material-symbols-outlined animate-spin text-[20px]">progress_activity</span>
|
<span className="material-symbols-outlined animate-spin text-[20px]">progress_activity</span>
|
||||||
Loading...
|
Loading...
|
||||||
@@ -262,50 +350,83 @@ export default function RequestDetailsTab() {
|
|||||||
</tr>
|
</tr>
|
||||||
) : details.length === 0 ? (
|
) : details.length === 0 ? (
|
||||||
<tr>
|
<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
|
No request details found
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
details.map((detail, index) => (
|
details.map((detail, index) => {
|
||||||
<tr
|
const isOk = !detail.status || detail.status === "ok" || detail.status === "success";
|
||||||
key={`${detail.id}-${index}`}
|
return (
|
||||||
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"
|
<tr
|
||||||
>
|
key={`${detail.id}-${index}`}
|
||||||
<td className="whitespace-nowrap p-4 text-sm text-text-main">
|
className={cn(
|
||||||
{new Date(detail.timestamp).toLocaleString()}
|
"border-b border-black/5 dark:border-white/5 last:border-b-0 transition-colors",
|
||||||
</td>
|
isOk
|
||||||
<td className="max-w-[260px] truncate p-4 font-mono text-sm text-text-main">
|
? "hover:bg-black/[0.02] dark:hover:bg-white/[0.02]"
|
||||||
{detail.model}
|
: "bg-red-50/60 dark:bg-red-950/20 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||||
</td>
|
)}
|
||||||
<td className="max-w-[180px] truncate p-4 text-sm text-text-main">
|
>
|
||||||
<span className="font-medium">
|
{/* Status dot */}
|
||||||
{getProviderName(detail.provider, providerNameCache)}
|
<td className="pl-4 pr-0 py-3">
|
||||||
</span>
|
<span className={cn(
|
||||||
</td>
|
"block w-2 h-2 rounded-full",
|
||||||
<td className="p-4 text-sm text-text-main text-right font-mono">
|
isOk ? "bg-green-500" : "bg-red-500"
|
||||||
{getInputTokens(detail.tokens).toLocaleString()}
|
)} title={isOk ? "Success" : `Error: ${detail.status}`} />
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 text-sm text-text-main text-right font-mono">
|
<td className="whitespace-nowrap p-4 text-sm text-text-main">
|
||||||
{detail.tokens?.completion_tokens?.toLocaleString() || 0}
|
{new Date(detail.timestamp).toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 text-sm text-text-muted">
|
<td className="max-w-[220px] p-4 text-sm">
|
||||||
<div className="flex flex-col gap-0.5">
|
<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>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>Total: <span className="font-mono">{detail.latency?.total || 0}ms</span></div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
<td className="p-4 text-sm">
|
||||||
<td className="p-4 text-center">
|
{detail.fallbackHistory?.length > 0 ? (
|
||||||
<Button
|
<button
|
||||||
variant="outline"
|
type="button"
|
||||||
size="sm"
|
onClick={() => handleViewDetail(detail)}
|
||||||
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
|
>
|
||||||
</Button>
|
↺ {detail.fallbackHistory.length} fallback{detail.fallbackHistory.length > 1 ? 's' : ''}
|
||||||
</td>
|
</button>
|
||||||
</tr>
|
) : (
|
||||||
))
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -332,6 +453,22 @@ export default function RequestDetailsTab() {
|
|||||||
>
|
>
|
||||||
{selectedDetail && (
|
{selectedDetail && (
|
||||||
<div className="space-y-6">
|
<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 className="grid min-w-0 grid-cols-1 gap-4 text-sm sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-text-muted">ID:</span>{" "}
|
<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>
|
<span className="text-text-main">{new Date(selectedDetail.timestamp).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-text-muted">Provider:</span>{" "}
|
<span className="text-text-muted">Provider:</span>{" "}
|
||||||
<span className="text-text-main font-medium">{getProviderName(selectedDetail.provider, providerNameCache)}</span>
|
<span className="text-text-main font-medium">{getProviderName(selectedDetail.provider, providerNameCache)}</span>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<span className="text-text-muted">Model:</span>{" "}
|
<span className="text-text-muted">Model:</span>{" "}
|
||||||
<span className="text-text-main font-mono">{selectedDetail.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>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-text-muted">Status:</span>{" "}
|
<span className="text-text-muted">API Key:</span>{" "}
|
||||||
<span className={cn(
|
{selectedDetail.keyName ? (
|
||||||
"font-medium",
|
<span className="text-text-main">{selectedDetail.keyName}</span>
|
||||||
selectedDetail.status === "success" ? "text-green-600" : "text-red-600"
|
) : selectedDetail.apiKey ? (
|
||||||
)}>
|
<span className="text-text-main font-mono">{selectedDetail.apiKey.slice(0, 16)}...</span>
|
||||||
{selectedDetail.status}
|
) : (
|
||||||
</span>
|
<span className="text-text-muted">—</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-text-muted">Latency:</span>{" "}
|
<span className="text-text-muted">Latency:</span>{" "}
|
||||||
@@ -366,28 +511,57 @@ export default function RequestDetailsTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-text-muted">Input Tokens:</span>{" "}
|
<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()}
|
{getInputTokens(selectedDetail.tokens).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-text-muted">Output Tokens:</span>{" "}
|
<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}
|
{selectedDetail.tokens?.completion_tokens?.toLocaleString() || 0}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="space-y-4">
|
||||||
<CollapsibleSection title="1. Client Request (Input)" defaultOpen={true} icon="input">
|
<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)}
|
{JSON.stringify(selectedDetail.request, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
{selectedDetail.providerRequest && (
|
{selectedDetail.providerRequest && (
|
||||||
<CollapsibleSection title="2. Provider Request (Translated)" icon="translate">
|
<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)}
|
{JSON.stringify(selectedDetail.providerRequest, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
@@ -395,7 +569,7 @@ export default function RequestDetailsTab() {
|
|||||||
|
|
||||||
{selectedDetail.providerResponse && (
|
{selectedDetail.providerResponse && (
|
||||||
<CollapsibleSection title="3. Provider Response (Raw)" icon="data_object">
|
<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'
|
{typeof selectedDetail.providerResponse === 'object'
|
||||||
? JSON.stringify(selectedDetail.providerResponse, null, 2)
|
? JSON.stringify(selectedDetail.providerResponse, null, 2)
|
||||||
: selectedDetail.providerResponse
|
: selectedDetail.providerResponse
|
||||||
@@ -405,23 +579,8 @@ export default function RequestDetailsTab() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<CollapsibleSection title="4. Client Response (Final)" defaultOpen={true} icon="output">
|
<CollapsibleSection title="4. Client Response (Final)" defaultOpen={true} icon="output">
|
||||||
{selectedDetail.response?.thinking && (
|
<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">
|
||||||
<div className="mb-4">
|
{JSON.stringify(selectedDetail.response, null, 2) || "[No response]"}
|
||||||
<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>
|
</pre>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ import RequestDetailsTab from "./components/RequestDetailsTab";
|
|||||||
|
|
||||||
const PERIODS = [
|
const PERIODS = [
|
||||||
{ value: "today", label: "Today" },
|
{ value: "today", label: "Today" },
|
||||||
{ value: "24h", label: "24h" },
|
{ value: "week", label: "This Week" },
|
||||||
{ value: "7d", label: "7D" },
|
{ value: "month", label: "This Month" },
|
||||||
{ value: "30d", label: "30D" },
|
{ value: "all", label: "All" },
|
||||||
{ value: "60d", label: "60D" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function UsagePage() {
|
export default function UsagePage() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getApiKeys, createApiKey } from "@/lib/localDb";
|
import { getApiKeys, createApiKey, createCustomApiKey } from "@/lib/localDb";
|
||||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -19,7 +19,7 @@ export async function GET() {
|
|||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name } = body;
|
const { name, key } = body;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
||||||
@@ -27,6 +27,22 @@ export async function POST(request) {
|
|||||||
|
|
||||||
// Always get machineId from server
|
// Always get machineId from server
|
||||||
const machineId = await getConsistentMachineId();
|
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);
|
const apiKey = await createApiKey(name, machineId);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
27
src/app/api/providers/[id]/apikey/route.js
Normal file
27
src/app/api/providers/[id]/apikey/route.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getProviderConnectionById } from "@/models";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// GET /api/providers/[id]/apikey - Reveal API key for a connection
|
||||||
|
export async function GET(request, { params }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const connection = await getProviderConnectionById(id);
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = connection.apiKey || null;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json({ error: "No API key for this connection" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ apiKey });
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error fetching API key:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch API key" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getChartData } from "@/lib/usageDb";
|
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) {
|
export async function GET(request) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const period = searchParams.get("period") || "7d";
|
const period = searchParams.get("period") || "today";
|
||||||
|
|
||||||
if (!VALID_PERIODS.has(period)) {
|
if (!VALID_PERIODS.has(period)) {
|
||||||
return NextResponse.json({ error: "Invalid period" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid period" }, { status: 400 });
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export async function GET(request) {
|
|||||||
const status = searchParams.get("status");
|
const status = searchParams.get("status");
|
||||||
const startDate = searchParams.get("startDate");
|
const startDate = searchParams.get("startDate");
|
||||||
const endDate = searchParams.get("endDate");
|
const endDate = searchParams.get("endDate");
|
||||||
|
const comboName = searchParams.get("comboName");
|
||||||
|
const hasFallbackParam = searchParams.get("hasFallback");
|
||||||
|
const apiKey = searchParams.get("apiKey");
|
||||||
|
|
||||||
if (page < 1) {
|
if (page < 1) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -32,10 +35,7 @@ export async function GET(request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filter = {
|
const filter = { page, pageSize };
|
||||||
page,
|
|
||||||
pageSize
|
|
||||||
};
|
|
||||||
|
|
||||||
if (provider) filter.provider = provider;
|
if (provider) filter.provider = provider;
|
||||||
if (model) filter.model = model;
|
if (model) filter.model = model;
|
||||||
@@ -43,6 +43,10 @@ export async function GET(request) {
|
|||||||
if (status) filter.status = status;
|
if (status) filter.status = status;
|
||||||
if (startDate) filter.startDate = startDate;
|
if (startDate) filter.startDate = startDate;
|
||||||
if (endDate) filter.endDate = endDate;
|
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);
|
const result = await getRequestDetails(filter);
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getUsageStats } from "@/lib/usageDb";
|
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 const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET(request) {
|
export async function GET(request) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const period = searchParams.get("period") || "7d";
|
const period = searchParams.get("period") || "today";
|
||||||
|
|
||||||
if (!VALID_PERIODS.has(period)) {
|
if (!VALID_PERIODS.has(period)) {
|
||||||
return NextResponse.json({ error: "Invalid period" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid period" }, { status: 400 });
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export {
|
|||||||
|
|
||||||
// API keys
|
// API keys
|
||||||
export {
|
export {
|
||||||
getApiKeys, getApiKeyById, createApiKey, updateApiKey, deleteApiKey, validateApiKey,
|
getApiKeys, getApiKeyById, createApiKey, createCustomApiKey, updateApiKey, deleteApiKey, validateApiKey,
|
||||||
} from "./repos/apiKeysRepo.js";
|
} from "./repos/apiKeysRepo.js";
|
||||||
|
|
||||||
// Combos
|
// Combos
|
||||||
|
|||||||
@@ -67,6 +67,25 @@ export async function deleteApiKey(id) {
|
|||||||
return (res?.changes ?? 0) > 0;
|
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) {
|
export async function validateApiKey(key) {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
const row = db.get(`SELECT isActive FROM apiKeys WHERE key = ?`, [key]);
|
const row = db.get(`SELECT isActive FROM apiKeys WHERE key = ?`, [key]);
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ async function flushToDatabase() {
|
|||||||
provider: item.provider || null,
|
provider: item.provider || null,
|
||||||
model: item.model || null,
|
model: item.model || null,
|
||||||
connectionId: item.connectionId || null,
|
connectionId: item.connectionId || null,
|
||||||
|
apiKey: item.apiKey || null,
|
||||||
|
comboName: item.comboName || null,
|
||||||
|
fallbackHistory: item.fallbackHistory || null,
|
||||||
timestamp: item.timestamp,
|
timestamp: item.timestamp,
|
||||||
status: item.status || null,
|
status: item.status || null,
|
||||||
latency: item.latency || {},
|
latency: item.latency || {},
|
||||||
@@ -147,11 +150,15 @@ export async function getRequestDetails(filter = {}) {
|
|||||||
const params = [];
|
const params = [];
|
||||||
|
|
||||||
if (filter.provider) { conds.push("provider = ?"); params.push(filter.provider); }
|
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.connectionId) { conds.push("connectionId = ?"); params.push(filter.connectionId); }
|
||||||
if (filter.status) { conds.push("status = ?"); params.push(filter.status); }
|
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.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.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 where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
|
||||||
const cntRow = db.get(`SELECT COUNT(*) as c FROM requestDetails ${where}`, params);
|
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 ?`,
|
`SELECT data FROM requestDetails ${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?`,
|
||||||
[...params, pageSize, 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 {
|
return {
|
||||||
details,
|
details,
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const DEFAULT_SETTINGS = {
|
|||||||
cavemanLevel: "full",
|
cavemanLevel: "full",
|
||||||
ponytailEnabled: false,
|
ponytailEnabled: false,
|
||||||
ponytailLevel: "full",
|
ponytailLevel: "full",
|
||||||
|
providerExtraParams: {},
|
||||||
|
accountCooldownSeconds: 600,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function readRaw() {
|
async function readRaw() {
|
||||||
|
|||||||
@@ -6,7 +6,23 @@ import { getMeta, setMeta } from "../helpers/metaStore.js";
|
|||||||
const PENDING_TIMEOUT_MS = 60 * 1000;
|
const PENDING_TIMEOUT_MS = 60 * 1000;
|
||||||
const RING_CAP = 50;
|
const RING_CAP = 50;
|
||||||
const CONN_CACHE_TTL_MS = 30 * 1000;
|
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
|
// In-memory state shared across Next.js modules
|
||||||
if (!global._pendingRequests) global._pendingRequests = { byModel: {}, byAccount: {} };
|
if (!global._pendingRequests) global._pendingRequests = { byModel: {}, byAccount: {} };
|
||||||
@@ -101,12 +117,17 @@ async function ensureRingInitialized() {
|
|||||||
recentRing.initialized = true;
|
recentRing.initialized = true;
|
||||||
try {
|
try {
|
||||||
const db = await getAdapter();
|
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]);
|
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) => ({
|
recentRing.items = rows.reverse().map((r) => {
|
||||||
timestamp: r.timestamp, provider: r.provider, model: r.model, connectionId: r.connectionId,
|
const meta = parseJson(r.meta, {});
|
||||||
apiKey: r.apiKey, endpoint: r.endpoint, cost: r.cost, status: r.status,
|
return {
|
||||||
tokens: parseJson(r.tokens, {}),
|
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 {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +235,14 @@ export async function getActiveRequests() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ensureRingInitialized();
|
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 seen = new Set();
|
||||||
const recentRequests = [...recentRing.items]
|
const recentRequests = [...recentRing.items]
|
||||||
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
||||||
@@ -221,15 +250,18 @@ export async function getActiveRequests() {
|
|||||||
const t = e.tokens || {};
|
const t = e.tokens || {};
|
||||||
return {
|
return {
|
||||||
timestamp: e.timestamp, model: e.model, provider: e.provider || "",
|
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,
|
promptTokens: t.prompt_tokens || t.input_tokens || 0,
|
||||||
completionTokens: t.completion_tokens || t.output_tokens || 0,
|
completionTokens: t.completion_tokens || t.output_tokens || 0,
|
||||||
status: e.status || "ok",
|
status: e.status || "ok",
|
||||||
|
keyName: e.apiKey ? (apiKeyMap[e.apiKey]?.name || e.apiKey.slice(0, 8) + "...") : null,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((e) => {
|
.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 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;
|
if (seen.has(key)) return false;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
return true;
|
return true;
|
||||||
@@ -260,7 +292,10 @@ export async function saveRequestUsage(entry) {
|
|||||||
entry.timestamp, entry.provider || null, entry.model || null,
|
entry.timestamp, entry.provider || null, entry.model || null,
|
||||||
entry.connectionId || null, entry.apiKey || null, entry.endpoint || null,
|
entry.connectionId || null, entry.apiKey || null, entry.endpoint || null,
|
||||||
promptTokens, completionTokens, entry.cost || 0, entry.status || "ok",
|
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) {
|
function loadDailyFrom(adapter, fromDateKey) {
|
||||||
if (maxDays == null) {
|
if (!fromDateKey) return adapter.all(`SELECT dateKey, data FROM usageDaily ORDER BY dateKey`);
|
||||||
return adapter.all(`SELECT dateKey, data FROM usageDaily`);
|
return adapter.all(`SELECT dateKey, data FROM usageDaily WHERE dateKey >= ? ORDER BY dateKey`, [fromDateKey]);
|
||||||
}
|
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUsageStats(period = "all") {
|
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 };
|
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)
|
// 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 seen = new Set();
|
||||||
const recentRequests = recentRows
|
const recentRequests = recentRows
|
||||||
.map((r) => {
|
.map((r) => {
|
||||||
const t = parseJson(r.tokens, {}) || {};
|
const t = parseJson(r.tokens, {}) || {};
|
||||||
|
const meta = parseJson(r.meta, {});
|
||||||
return {
|
return {
|
||||||
timestamp: r.timestamp, model: r.model, provider: r.provider || "",
|
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,
|
promptTokens: t.prompt_tokens || t.input_tokens || 0,
|
||||||
completionTokens: t.completion_tokens || t.output_tokens || 0,
|
completionTokens: t.completion_tokens || t.output_tokens || 0,
|
||||||
status: r.status || "ok",
|
status: r.status || "ok",
|
||||||
|
keyName: r.apiKey ? (apiKeyMap[r.apiKey]?.name || r.apiKey.slice(0, 8) + "...") : null,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((e) => {
|
.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 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;
|
if (seen.has(key)) return false;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
return true;
|
return true;
|
||||||
@@ -415,12 +449,11 @@ export async function getUsageStats(period = "all") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const useDailySummary = period !== "24h" && period !== "today";
|
const useDailySummary = period !== "today";
|
||||||
|
|
||||||
if (useDailySummary) {
|
if (useDailySummary) {
|
||||||
const periodDays = { "7d": 7, "30d": 30, "60d": 60 };
|
const fromDateKey = getPeriodStartDateKey(period);
|
||||||
const maxDays = periodDays[period] || null;
|
const dayRows = loadDailyFrom(db, fromDateKey);
|
||||||
const dayRows = loadDaysInRange(db, maxDays);
|
|
||||||
|
|
||||||
for (const dr of dayRows) {
|
for (const dr of dayRows) {
|
||||||
const dateKey = dr.dateKey;
|
const dateKey = dr.dateKey;
|
||||||
@@ -503,7 +536,7 @@ export async function getUsageStats(period = "all") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Overlay precise lastUsed timestamps from history
|
// 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(
|
const histRows = db.all(
|
||||||
`SELECT timestamp, provider, model, connectionId, apiKey, endpoint FROM usageHistory WHERE timestamp >= ?`,
|
`SELECT timestamp, provider, model, connectionId, apiKey, endpoint FROM usageHistory WHERE timestamp >= ?`,
|
||||||
[new Date(overlayCutoff).toISOString()]
|
[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;
|
if (stats.byEndpoint[endpointKey] && new Date(ts) > new Date(stats.byEndpoint[endpointKey].lastUsed)) stats.byEndpoint[endpointKey].lastUsed = ts;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 24h / today: live history
|
// today: live history from midnight
|
||||||
let cutoff;
|
const startOfDay = new Date();
|
||||||
if (period === "today") {
|
startOfDay.setHours(0, 0, 0, 0);
|
||||||
const startOfDay = new Date();
|
const cutoff = startOfDay.toISOString();
|
||||||
startOfDay.setHours(0, 0, 0, 0);
|
|
||||||
cutoff = startOfDay.toISOString();
|
|
||||||
} else {
|
|
||||||
cutoff = new Date(Date.now() - PERIOD_MS["24h"]).toISOString();
|
|
||||||
}
|
|
||||||
const filtered = db.all(
|
const filtered = db.all(
|
||||||
`SELECT timestamp, provider, model, connectionId, apiKey, endpoint, promptTokens, completionTokens, cost, tokens FROM usageHistory WHERE timestamp >= ?`,
|
`SELECT timestamp, provider, model, connectionId, apiKey, endpoint, promptTokens, completionTokens, cost, tokens FROM usageHistory WHERE timestamp >= ?`,
|
||||||
[cutoff]
|
[cutoff]
|
||||||
@@ -617,27 +645,23 @@ export async function getUsageStats(period = "all") {
|
|||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getChartData(period = "7d") {
|
export async function getChartData(period = "week") {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
if (period === "today") {
|
if (period === "today") {
|
||||||
const bucketCount = 24;
|
|
||||||
const bucketMs = 3600000;
|
|
||||||
const startOfDay = new Date();
|
const startOfDay = new Date();
|
||||||
startOfDay.setHours(0, 0, 0, 0);
|
startOfDay.setHours(0, 0, 0, 0);
|
||||||
const startTime = startOfDay.getTime();
|
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 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 buckets = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cost: 0 }));
|
||||||
|
|
||||||
const rows = db.all(
|
const rows = db.all(
|
||||||
`SELECT timestamp, promptTokens, completionTokens, cost FROM usageHistory WHERE timestamp >= ?`,
|
`SELECT timestamp, promptTokens, completionTokens, cost FROM usageHistory WHERE timestamp >= ?`,
|
||||||
[new Date(startTime).toISOString()]
|
[startOfDay.toISOString()]
|
||||||
);
|
);
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const t = new Date(r.timestamp).getTime();
|
const t = new Date(r.timestamp).getTime();
|
||||||
if (t < startTime || t >= endTime) continue;
|
|
||||||
const idx = Math.floor((t - startTime) / bucketMs);
|
const idx = Math.floor((t - startTime) / bucketMs);
|
||||||
if (idx >= 0 && idx < bucketCount) {
|
if (idx >= 0 && idx < bucketCount) {
|
||||||
buckets[idx].tokens += (r.promptTokens || 0) + (r.completionTokens || 0);
|
buckets[idx].tokens += (r.promptTokens || 0) + (r.completionTokens || 0);
|
||||||
@@ -647,47 +671,61 @@ export async function getChartData(period = "7d") {
|
|||||||
return buckets;
|
return buckets;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (period === "24h") {
|
if (period === "week") {
|
||||||
const bucketCount = 24;
|
const today = new Date();
|
||||||
const bucketMs = 3600000;
|
const dow = (today.getDay() + 6) % 7; // Mon=0, Sun=6
|
||||||
const labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
const startOfWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() - dow);
|
||||||
const startTime = now - bucketCount * bucketMs;
|
const fromDateKey = dateToKey(startOfWeek);
|
||||||
const buckets = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cost: 0 }));
|
const dayRows = loadDailyFrom(db, fromDateKey);
|
||||||
|
const dayMap = {};
|
||||||
const rows = db.all(
|
for (const r of dayRows) dayMap[r.dateKey] = parseJson(r.data, {});
|
||||||
`SELECT timestamp, promptTokens, completionTokens, cost FROM usageHistory WHERE timestamp >= ?`,
|
return Array.from({ length: 7 }, (_, i) => {
|
||||||
[new Date(startTime).toISOString()]
|
const d = new Date(startOfWeek);
|
||||||
);
|
d.setDate(startOfWeek.getDate() + i);
|
||||||
for (const r of rows) {
|
const dk = dateToKey(d);
|
||||||
const t = new Date(r.timestamp).getTime();
|
const day = dayMap[dk];
|
||||||
if (t < startTime || t > now) continue;
|
return {
|
||||||
const idx = Math.min(Math.floor((t - startTime) / bucketMs), bucketCount - 1);
|
label: d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }),
|
||||||
buckets[idx].tokens += (r.promptTokens || 0) + (r.completionTokens || 0);
|
tokens: day ? (day.promptTokens || 0) + (day.completionTokens || 0) : 0,
|
||||||
buckets[idx].cost += r.cost || 0;
|
cost: day ? (day.cost || 0) : 0,
|
||||||
}
|
};
|
||||||
return buckets;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const bucketCount = period === "7d" ? 7 : period === "30d" ? 30 : 60;
|
if (period === "month") {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const labelFn = (d) => d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
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
|
// "all": group by month
|
||||||
const dayRows = loadDaysInRange(db, bucketCount);
|
const allRows = loadDailyFrom(db, null);
|
||||||
const dayMap = {};
|
const monthMap = {};
|
||||||
for (const r of dayRows) dayMap[r.dateKey] = parseJson(r.data, {});
|
for (const r of allRows) {
|
||||||
|
const mk = r.dateKey.slice(0, 7); // YYYY-MM
|
||||||
return Array.from({ length: bucketCount }, (_, i) => {
|
const day = parseJson(r.data, {});
|
||||||
const d = new Date(today);
|
if (!monthMap[mk]) monthMap[mk] = { tokens: 0, cost: 0 };
|
||||||
d.setDate(d.getDate() - (bucketCount - 1 - i));
|
monthMap[mk].tokens += (day.promptTokens || 0) + (day.completionTokens || 0);
|
||||||
const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
monthMap[mk].cost += day.cost || 0;
|
||||||
const dayData = dayMap[dateKey];
|
}
|
||||||
return {
|
return Object.keys(monthMap).sort().map((mk) => ({
|
||||||
label: labelFn(d),
|
label: new Date(mk + "-15").toLocaleDateString("en-US", { year: "numeric", month: "short" }),
|
||||||
tokens: dayData ? (dayData.promptTokens || 0) + (dayData.completionTokens || 0) : 0,
|
tokens: monthMap[mk].tokens,
|
||||||
cost: dayData ? (dayData.cost || 0) : 0,
|
cost: monthMap[mk].cost,
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLogDate(date = new Date()) {
|
function formatLogDate(date = new Date()) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export {
|
|||||||
createProviderNode, updateProviderNode, deleteProviderNode,
|
createProviderNode, updateProviderNode, deleteProviderNode,
|
||||||
getProxyPools, getProxyPoolById,
|
getProxyPools, getProxyPoolById,
|
||||||
createProxyPool, updateProxyPool, deleteProxyPool,
|
createProxyPool, updateProxyPool, deleteProxyPool,
|
||||||
getApiKeys, getApiKeyById, createApiKey, updateApiKey, deleteApiKey, validateApiKey,
|
getApiKeys, getApiKeyById, createApiKey, createCustomApiKey, updateApiKey, deleteApiKey, validateApiKey,
|
||||||
getCombos, getComboById, getComboByName,
|
getCombos, getComboById, getComboByName,
|
||||||
createCombo, updateCombo, deleteCombo,
|
createCombo, updateCombo, deleteCombo,
|
||||||
getModelAliases, setModelAlias, deleteModelAlias,
|
getModelAliases, setModelAlias, deleteModelAlias,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import Modal from "@/shared/components/Modal";
|
import Modal from "@/shared/components/Modal";
|
||||||
import Input from "@/shared/components/Input";
|
import Input from "@/shared/components/Input";
|
||||||
@@ -21,12 +21,37 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
|
|||||||
organization: "",
|
organization: "",
|
||||||
});
|
});
|
||||||
const [cloudflareData, setCloudflareData] = useState({ accountId: "" });
|
const [cloudflareData, setCloudflareData] = useState({ accountId: "" });
|
||||||
|
const [extraBodyParams, setExtraBodyParams] = useState("");
|
||||||
|
const [extraHeaderParams, setExtraHeaderParams] = useState("");
|
||||||
|
const [jsonError, setJsonError] = useState("");
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState(null);
|
const [testResult, setTestResult] = useState(null);
|
||||||
const [validating, setValidating] = useState(false);
|
const [validating, setValidating] = useState(false);
|
||||||
const [validationResult, setValidationResult] = useState(null);
|
const [validationResult, setValidationResult] = useState(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const setExtraBodyParamsSafe = (v) => setExtraBodyParams(v);
|
||||||
|
const setExtraHeaderParamsSafe = (v) => setExtraHeaderParams(v);
|
||||||
|
|
||||||
|
const handleJsonChange = useCallback((value, setter) => {
|
||||||
|
setter(value);
|
||||||
|
if (value.trim()) {
|
||||||
|
try {
|
||||||
|
JSON.parse(value);
|
||||||
|
setJsonError("");
|
||||||
|
} catch {
|
||||||
|
setJsonError("Invalid JSON");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setJsonError("");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const parseJsonSafe = (str) => {
|
||||||
|
if (!str.trim()) return undefined;
|
||||||
|
try { return JSON.parse(str); } catch { return undefined; }
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (connection) {
|
if (connection) {
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -46,8 +71,20 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
|
|||||||
if (connection.provider === "cloudflare-ai" && connection.providerSpecificData) {
|
if (connection.provider === "cloudflare-ai" && connection.providerSpecificData) {
|
||||||
setCloudflareData({ accountId: connection.providerSpecificData.accountId || "" });
|
setCloudflareData({ accountId: connection.providerSpecificData.accountId || "" });
|
||||||
}
|
}
|
||||||
|
// Load extra params from existing connection
|
||||||
|
if (connection.providerSpecificData?.bodyParams) {
|
||||||
|
setExtraBodyParams(JSON.stringify(connection.providerSpecificData.bodyParams, null, 2));
|
||||||
|
} else {
|
||||||
|
setExtraBodyParams("");
|
||||||
|
}
|
||||||
|
if (connection.providerSpecificData?.headerParams) {
|
||||||
|
setExtraHeaderParams(JSON.stringify(connection.providerSpecificData.headerParams, null, 2));
|
||||||
|
} else {
|
||||||
|
setExtraHeaderParams("");
|
||||||
|
}
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
setValidationResult(null);
|
setValidationResult(null);
|
||||||
|
setJsonError("");
|
||||||
}
|
}
|
||||||
}, [connection]);
|
}, [connection]);
|
||||||
|
|
||||||
@@ -151,6 +188,17 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
|
|||||||
updates.providerSpecificData = { accountId: cloudflareData.accountId };
|
updates.providerSpecificData = { accountId: cloudflareData.accountId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge extra params into providerSpecificData
|
||||||
|
const parsedBody = parseJsonSafe(extraBodyParams);
|
||||||
|
const parsedHeaders = parseJsonSafe(extraHeaderParams);
|
||||||
|
if (parsedBody || parsedHeaders) {
|
||||||
|
updates.providerSpecificData = updates.providerSpecificData || { ...(connection.providerSpecificData || {}) };
|
||||||
|
if (parsedBody) updates.providerSpecificData.bodyParams = parsedBody;
|
||||||
|
else delete updates.providerSpecificData.bodyParams;
|
||||||
|
if (parsedHeaders) updates.providerSpecificData.headerParams = parsedHeaders;
|
||||||
|
else delete updates.providerSpecificData.headerParams;
|
||||||
|
}
|
||||||
|
|
||||||
await onSave(updates);
|
await onSave(updates);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -256,6 +304,35 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Extra Request Parameters */}
|
||||||
|
<details className="group">
|
||||||
|
<summary className="cursor-pointer text-xs font-medium text-text-muted hover:text-primary transition-colors select-none list-none flex items-center gap-1">
|
||||||
|
<span className="material-symbols-outlined text-[14px] group-open:rotate-90 transition-transform">chevron_right</span>
|
||||||
|
Extra Request Parameters
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-muted block mb-1">Extra Body Params <span className="text-text-muted/60">(JSON object, merged into request body)</span></label>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded border border-accent/30 bg-sidebar p-2 text-xs font-mono resize-y min-h-[60px] focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
placeholder={'{\n "max_tokens": 1,\n "stream": true\n}'}
|
||||||
|
value={extraBodyParams}
|
||||||
|
onChange={(e) => handleJsonChange(e.target.value, setExtraBodyParamsSafe)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-muted block mb-1">Extra Header Params <span className="text-text-muted/60">(JSON object, merged into request headers)</span></label>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded border border-accent/30 bg-sidebar p-2 text-xs font-mono resize-y min-h-[60px] focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
placeholder={'{\n "X-Custom-Header": "value"\n}'}
|
||||||
|
value={extraHeaderParams}
|
||||||
|
onChange={(e) => handleJsonChange(e.target.value, setExtraHeaderParamsSafe)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{jsonError && <p className="text-xs text-red-500">{jsonError}</p>}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={handleSubmit} fullWidth disabled={saving}>{saving ? "Saving..." : "Save"}</Button>
|
<Button onClick={handleSubmit} fullWidth disabled={saving}>{saving ? "Saving..." : "Save"}</Button>
|
||||||
<Button onClick={onClose} variant="ghost" fullWidth>Cancel</Button>
|
<Button onClick={onClose} variant="ghost" fullWidth>Cancel</Button>
|
||||||
|
|||||||
@@ -249,9 +249,23 @@ export default function ModelSelectModal({
|
|||||||
value: `${nodePrefix}/${fullModel.replace(`${providerId}/`, "")}`,
|
value: `${nodePrefix}/${fullModel.replace(`${providerId}/`, "")}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Always show compatible providers that are connected, even with no aliases.
|
// Models added via "Add Model" button are stored in customModels with providerAlias === providerId.
|
||||||
// When no aliases exist, show a placeholder so users know it's available.
|
const customProviderModels = customModels
|
||||||
const modelsToShow = nodeModels.length > 0 ? nodeModels : [{
|
.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}`,
|
id: `__placeholder__${providerId}`,
|
||||||
name: `${nodePrefix}/model-id`,
|
name: `${nodePrefix}/model-id`,
|
||||||
value: `${nodePrefix}/model-id`,
|
value: `${nodePrefix}/model-id`,
|
||||||
@@ -264,7 +278,7 @@ export default function ModelSelectModal({
|
|||||||
color: providerInfo.color,
|
color: providerInfo.color,
|
||||||
models: modelsToShow,
|
models: modelsToShow,
|
||||||
isCustom: true,
|
isCustom: true,
|
||||||
hasModels: nodeModels.length > 0,
|
hasModels: mergedModels.length > 0,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const hardcodedModels = getModelsByProviderId(providerId);
|
const hardcodedModels = getModelsByProviderId(providerId);
|
||||||
|
|||||||
@@ -17,6 +17,62 @@ import UsageTable, { fmt, fmtTime } from "@/app/(dashboard)/dashboard/usage/comp
|
|||||||
import ProviderTopology from "@/app/(dashboard)/dashboard/usage/components/ProviderTopology";
|
import ProviderTopology from "@/app/(dashboard)/dashboard/usage/components/ProviderTopology";
|
||||||
import UsageChart from "@/app/(dashboard)/dashboard/usage/components/UsageChart";
|
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) {
|
function timeAgo(timestamp) {
|
||||||
const diff = Math.floor((Date.now() - new Date(timestamp)) / 1000);
|
const diff = Math.floor((Date.now() - new Date(timestamp)) / 1000);
|
||||||
if (diff < 60) return `${diff}s ago`;
|
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 flex items-center justify-center text-text-muted text-sm">No requests yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-y-auto">
|
<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">
|
<thead className="sticky top-0 bg-bg z-10">
|
||||||
<tr className="border-b border-border">
|
<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 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">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 whitespace-nowrap">In / Out</th>
|
||||||
<th className="py-1.5 text-right font-semibold text-text-muted">When</th>
|
<th className="py-1.5 text-right font-semibold text-text-muted">When</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -61,13 +119,23 @@ function RecentRequests({ requests = [] }) {
|
|||||||
<tbody className="divide-y divide-border/50">
|
<tbody className="divide-y divide-border/50">
|
||||||
{requests.map((r, i) => {
|
{requests.map((r, i) => {
|
||||||
const ok = !r.status || r.status === "ok" || r.status === "success";
|
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 (
|
return (
|
||||||
<tr key={i} className="hover:bg-bg-subtle transition-colors">
|
<tr key={i} className="hover:bg-bg-subtle transition-colors">
|
||||||
<td className="py-1.5">
|
<td className="py-1.5">
|
||||||
<span className={`block w-1.5 h-1.5 rounded-full ${ok ? "bg-success" : "bg-error"}`} />
|
<span className={`block w-1.5 h-1.5 rounded-full ${ok ? "bg-success" : "bg-error"}`} />
|
||||||
</td>
|
</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">
|
<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-primary">{fmt(r.promptTokens)}↑</span>
|
||||||
{" "}
|
{" "}
|
||||||
<span className="text-success">{fmt(r.completionTokens)}↓</span>
|
<span className="text-success">{fmt(r.completionTokens)}↓</span>
|
||||||
@@ -183,10 +251,9 @@ const TABLE_OPTIONS = [
|
|||||||
|
|
||||||
const PERIODS = [
|
const PERIODS = [
|
||||||
{ value: "today", label: "Today" },
|
{ value: "today", label: "Today" },
|
||||||
{ value: "24h", label: "24h" },
|
{ value: "week", label: "This Week" },
|
||||||
{ value: "7d", label: "7D" },
|
{ value: "month", label: "This Month" },
|
||||||
{ value: "30d", label: "30D" },
|
{ value: "all", label: "All" },
|
||||||
{ value: "60d", label: "60D" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function UsageStats({ period: periodProp, setPeriod: setPeriodProp, hidePeriodSelector = false } = {}) {
|
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 */}
|
{/* Provider topology + Recent Requests */}
|
||||||
{loading ? spinner : (
|
{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
|
<ProviderTopology
|
||||||
providers={providers}
|
providers={providers}
|
||||||
activeRequests={stats.activeRequests || []}
|
activeRequests={stats.activeRequests || []}
|
||||||
@@ -517,6 +584,9 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Usage by Provider */}
|
||||||
|
{loading ? spinner : <UsageByProvider byProvider={stats?.byProvider || {}} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { cacheClaudeHeaders } from "open-sse/utils/claudeHeaderCache.js";
|
|||||||
import { getSettings } from "@/lib/localDb";
|
import { getSettings } from "@/lib/localDb";
|
||||||
import { getModelInfo, getComboModels } from "../services/model.js";
|
import { getModelInfo, getComboModels } from "../services/model.js";
|
||||||
import { handleChatCore } from "open-sse/handlers/chatCore.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 { DEFAULT_HEADROOM_URL } from "@/lib/headroom/detect";
|
||||||
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
||||||
import { handleComboChat, handleFusionChat } from "open-sse/services/combo.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 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})`);
|
log.info("CHAT", `Combo "${modelStr}" with ${comboModels.length} models (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
|
||||||
return handleComboChat({
|
return handleComboChat({
|
||||||
body,
|
body,
|
||||||
models: comboModels,
|
models: comboModels,
|
||||||
handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey),
|
handleSingleModel: (b, m, comboFallbackHistory) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey, modelStr, comboFallbackHistory),
|
||||||
log,
|
log,
|
||||||
comboName: modelStr,
|
comboName: modelStr,
|
||||||
comboStrategy,
|
comboStrategy,
|
||||||
comboStickyLimit
|
comboStickyLimit,
|
||||||
|
retryCount: comboRetryCount,
|
||||||
|
maxFallbackDepth: comboMaxFallbackDepth,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +143,7 @@ export async function handleChat(request, clientRawRequest = null) {
|
|||||||
/**
|
/**
|
||||||
* Handle single model chat request
|
* 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);
|
const modelInfo = await getModelInfo(modelStr);
|
||||||
|
|
||||||
// If provider is null, this might be a combo name - check and handle
|
// 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 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})`);
|
log.info("CHAT", `Combo "${modelStr}" with ${comboModels.length} models (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
|
||||||
return handleComboChat({
|
return handleComboChat({
|
||||||
body,
|
body,
|
||||||
models: comboModels,
|
models: comboModels,
|
||||||
handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey),
|
handleSingleModel: (b, m, cfh) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey, modelStr, cfh),
|
||||||
log,
|
log,
|
||||||
comboName: modelStr,
|
comboName: modelStr,
|
||||||
comboStrategy,
|
comboStrategy,
|
||||||
comboStickyLimit
|
comboStickyLimit,
|
||||||
|
retryCount: comboRetryCount,
|
||||||
|
maxFallbackDepth: comboMaxFallbackDepth,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
log.warn("CHAT", "Invalid model format", { model: modelStr });
|
log.warn("CHAT", "Invalid model format", { model: modelStr });
|
||||||
@@ -203,6 +212,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|||||||
const excludeConnectionIds = new Set();
|
const excludeConnectionIds = new Set();
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
let lastStatus = null;
|
let lastStatus = null;
|
||||||
|
const fallbackHistory = [];
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model);
|
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 errorMsg = lastError || credentials.lastError || "Unavailable";
|
||||||
const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
|
const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
|
||||||
log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
|
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);
|
return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
|
||||||
}
|
}
|
||||||
if (excludeConnectionIds.size === 0) {
|
if (excludeConnectionIds.size === 0) {
|
||||||
log.warn("AUTH", `No active credentials for provider: ${provider}`);
|
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}`);
|
return errorResponse(HTTP_STATUS.NOT_FOUND, `No active credentials for provider: ${provider}`);
|
||||||
}
|
}
|
||||||
log.warn("CHAT", "No more accounts available", { 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");
|
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,
|
connectionId: credentials.connectionId,
|
||||||
userAgent,
|
userAgent,
|
||||||
apiKey,
|
apiKey,
|
||||||
|
comboName,
|
||||||
|
fallbackHistory: (() => {
|
||||||
|
const merged = [...(comboFallbackHistory || []), ...fallbackHistory];
|
||||||
|
return merged.length ? merged : null;
|
||||||
|
})(),
|
||||||
ccFilterNaming: !!chatSettings.ccFilterNaming,
|
ccFilterNaming: !!chatSettings.ccFilterNaming,
|
||||||
rtkEnabled: !!chatSettings.rtkEnabled,
|
rtkEnabled: !!chatSettings.rtkEnabled,
|
||||||
headroomEnabled: !!chatSettings.headroomEnabled,
|
headroomEnabled: !!chatSettings.headroomEnabled,
|
||||||
@@ -271,6 +291,12 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|||||||
},
|
},
|
||||||
onRequestSuccess: async () => {
|
onRequestSuccess: async () => {
|
||||||
await clearAccountError(credentials.connectionId, credentials, model);
|
await clearAccountError(credentials.connectionId, credentials, model);
|
||||||
|
},
|
||||||
|
onMidStreamError: async (error) => {
|
||||||
|
// Mid-stream error detected after HTTP 200 was already sent (e.g. Qoder quota exceeded)
|
||||||
|
// Apply cooldown so subsequent requests skip this account
|
||||||
|
log.warn("AUTH", `Mid-stream error on ${credentials.connectionName}: ${error.message}`);
|
||||||
|
await markAccountUnavailable(credentials.connectionId, error.status, error.message, provider, model);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -281,6 +307,14 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|||||||
|
|
||||||
if (shouldFallback) {
|
if (shouldFallback) {
|
||||||
log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
|
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);
|
excludeConnectionIds.add(credentials.connectionId);
|
||||||
lastError = result.error;
|
lastError = result.error;
|
||||||
lastStatus = result.status;
|
lastStatus = result.status;
|
||||||
|
|||||||
@@ -158,6 +158,9 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
|
|||||||
|
|
||||||
const resolvedProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
|
const resolvedProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
|
||||||
|
|
||||||
|
// Inject provider-level extra params from settings (applies to all connections)
|
||||||
|
const providerExtraParams = (settings.providerExtraParams || {})[providerId] || {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authType: connection.authType,
|
authType: connection.authType,
|
||||||
apiKey: connection.apiKey,
|
apiKey: connection.apiKey,
|
||||||
@@ -171,7 +174,8 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
|
|||||||
connectionName: connection.displayName || connection.name || connection.email || connection.id,
|
connectionName: connection.displayName || connection.name || connection.email || connection.id,
|
||||||
copilotToken: connection.providerSpecificData?.copilotToken,
|
copilotToken: connection.providerSpecificData?.copilotToken,
|
||||||
providerSpecificData: {
|
providerSpecificData: {
|
||||||
...(connection.providerSpecificData || {}),
|
...providerExtraParams, // provider-level base (less specific)
|
||||||
|
...(connection.providerSpecificData || {}), // connection-level overrides (more specific)
|
||||||
connectionProxyEnabled: resolvedProxy.connectionProxyEnabled,
|
connectionProxyEnabled: resolvedProxy.connectionProxyEnabled,
|
||||||
connectionProxyUrl: resolvedProxy.connectionProxyUrl,
|
connectionProxyUrl: resolvedProxy.connectionProxyUrl,
|
||||||
connectionNoProxy: resolvedProxy.connectionNoProxy,
|
connectionNoProxy: resolvedProxy.connectionNoProxy,
|
||||||
@@ -213,7 +217,10 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
|
|||||||
cooldownMs = Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS);
|
cooldownMs = Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS);
|
||||||
newBackoffLevel = 0;
|
newBackoffLevel = 0;
|
||||||
} else {
|
} else {
|
||||||
({ shouldFallback, cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel));
|
// Read user-configured fixed cooldown (global, applies to all providers)
|
||||||
|
const settings = await getSettings();
|
||||||
|
const fixedCooldownMs = (settings.accountCooldownSeconds || 0) * 1000;
|
||||||
|
({ shouldFallback, cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel, fixedCooldownMs));
|
||||||
}
|
}
|
||||||
if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 };
|
if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 };
|
||||||
|
|
||||||
|
|||||||
@@ -466,4 +466,50 @@ describe("wrapQoderSSE", () => {
|
|||||||
const wrapped = wrapQoderSSE(r, "qoder/auto");
|
const wrapped = wrapQoderSSE(r, "qoder/auto");
|
||||||
expect(wrapped).toBe(r);
|
expect(wrapped).toBe(r);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression: Qoder quota-exceeded returns HTTP 200 with a non-200
|
||||||
|
// statusCodeValue inside the SSE envelope. The first-chunk peek must
|
||||||
|
// detect this and return an error Response (not HTTP 200).
|
||||||
|
it("first-chunk quota error (429 envelope) returns error Response with proper status", async () => {
|
||||||
|
const errorBody = JSON.stringify({
|
||||||
|
code: "112",
|
||||||
|
message: JSON.stringify({ pricingUrl: "https://qoder.com/pricing" }),
|
||||||
|
});
|
||||||
|
const env = JSON.stringify({ statusCodeValue: 429, body: errorBody });
|
||||||
|
const wrapped = await wrapQoderSSE(
|
||||||
|
makeResponse([`data: ${env}\n\n`], { status: 200 }),
|
||||||
|
"qoder/qmodel_latest",
|
||||||
|
);
|
||||||
|
// Must NOT be 200 — the error should be surfaced as a proper HTTP error
|
||||||
|
expect(wrapped.status).toBe(429);
|
||||||
|
expect(wrapped.ok).toBe(false);
|
||||||
|
const json = await wrapped.json();
|
||||||
|
expect(json.error.message).toContain("quota exceeded");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression: when a mid-stream error arrives after some successful chunks,
|
||||||
|
// the shared midStreamError object must be populated so that
|
||||||
|
// handleNonStreamingResponse and buildOnStreamComplete can trigger cooldown.
|
||||||
|
it("mid-stream error populates the shared midStreamError object", async () => {
|
||||||
|
const goodInner = JSON.stringify({ choices: [{ delta: { content: "hi" } }] });
|
||||||
|
const goodEnv = JSON.stringify({ statusCodeValue: 200, body: goodInner });
|
||||||
|
const errorBody = JSON.stringify({
|
||||||
|
code: "112",
|
||||||
|
message: JSON.stringify({ pricingUrl: "https://qoder.com/pricing" }),
|
||||||
|
});
|
||||||
|
const errorEnv = JSON.stringify({ statusCodeValue: 429, body: errorBody });
|
||||||
|
|
||||||
|
const midStreamError = {};
|
||||||
|
const wrapped = await wrapQoderSSE(
|
||||||
|
makeResponse([`data: ${goodEnv}\n\ndata: ${errorEnv}\n\n`], { status: 200 }),
|
||||||
|
"qoder/qmodel_latest",
|
||||||
|
midStreamError,
|
||||||
|
);
|
||||||
|
await drain(wrapped);
|
||||||
|
|
||||||
|
expect(midStreamError.error).toBeDefined();
|
||||||
|
expect(midStreamError.error.status).toBe(429);
|
||||||
|
expect(midStreamError.error.message).toContain("quota exceeded");
|
||||||
|
expect(midStreamError.error.errorCode).toBe("112");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user